aboutsummaryrefslogtreecommitdiffstats
path: root/bsfs/graph
diff options
context:
space:
mode:
authorMatthias Baumgartner <dev@igsor.net>2023-03-05 19:25:29 +0100
committerMatthias Baumgartner <dev@igsor.net>2023-03-05 19:25:29 +0100
commit48b6081d0092e9c5a1b0ad79bdde2e51649bf61a (patch)
tree634198c34aae3c0306ce30ac7452abd7b53a14e8 /bsfs/graph
parent91437ba89d35bf482f3d9671bb99ef2fc69f5985 (diff)
parente4845c627e97a6d125bf33d9e7a4a8d373d7fc4a (diff)
downloadbsfs-0.23.03.tar.gz
bsfs-0.23.03.tar.bz2
bsfs-0.23.03.zip
Merge branch 'develop'v0.23.03
Diffstat (limited to 'bsfs/graph')
-rw-r--r--bsfs/graph/__init__.py5
-rw-r--r--bsfs/graph/ac/__init__.py5
-rw-r--r--bsfs/graph/ac/base.py33
-rw-r--r--bsfs/graph/ac/null.py20
-rw-r--r--bsfs/graph/graph.py99
-rw-r--r--bsfs/graph/nodes.py250
-rw-r--r--bsfs/graph/resolve.py174
-rw-r--r--bsfs/graph/result.py119
-rw-r--r--bsfs/graph/schema.nt12
-rw-r--r--bsfs/graph/walk.py115
10 files changed, 749 insertions, 83 deletions
diff --git a/bsfs/graph/__init__.py b/bsfs/graph/__init__.py
index 82d2235..8d38d23 100644
--- a/bsfs/graph/__init__.py
+++ b/bsfs/graph/__init__.py
@@ -1,9 +1,4 @@
-"""
-Part of the BlackStar filesystem (bsfs) module.
-A copy of the license is provided with the project.
-Author: Matthias Baumgartner, 2022
-"""
# imports
import typing
diff --git a/bsfs/graph/ac/__init__.py b/bsfs/graph/ac/__init__.py
index 420de01..11b45df 100644
--- a/bsfs/graph/ac/__init__.py
+++ b/bsfs/graph/ac/__init__.py
@@ -1,9 +1,4 @@
-"""
-Part of the BlackStar filesystem (bsfs) module.
-A copy of the license is provided with the project.
-Author: Matthias Baumgartner, 2022
-"""
# imports
import typing
diff --git a/bsfs/graph/ac/base.py b/bsfs/graph/ac/base.py
index bc9aeb3..e85c1dd 100644
--- a/bsfs/graph/ac/base.py
+++ b/bsfs/graph/ac/base.py
@@ -1,17 +1,13 @@
-"""
-Part of the BlackStar filesystem (bsfs) module.
-A copy of the license is provided with the project.
-Author: Matthias Baumgartner, 2022
-"""
# imports
import abc
import typing
# bsfs imports
from bsfs import schema
+from bsfs.query import ast
from bsfs.triple_store import TripleStoreBase
-from bsfs.utils import URI
+from bsfs.utils import URI, typename
# exports
__all__: typing.Sequence[str] = (
@@ -43,6 +39,20 @@ class AccessControlBase(abc.ABC):
self._backend = backend
self._user = URI(user)
+ def __str__(self) -> str:
+ return f'{typename(self)}({self._user})'
+
+ def __repr__(self) -> str:
+ return f'{typename(self)}({self._user})'
+
+ def __eq__(self, other: typing.Any) -> bool:
+ return isinstance(other, type(self)) \
+ and self._backend == other._backend \
+ and self._user == other._user
+
+ def __hash__(self) -> int:
+ return hash((type(self), self._backend, self._user))
+
@abc.abstractmethod
def is_protected_predicate(self, pred: schema.Predicate) -> bool:
"""Return True if a predicate cannot be modified manually."""
@@ -67,5 +77,16 @@ class AccessControlBase(abc.ABC):
def createable(self, node_type: schema.Node, guids: typing.Iterable[URI]) -> typing.Iterable[URI]:
"""Return nodes that are allowed to be created."""
+ @abc.abstractmethod
+ def filter_read(
+ self,
+ node_type: schema.Node,
+ query: typing.Optional[ast.filter.FilterExpression],
+ ) -> typing.Optional[ast.filter.FilterExpression]:
+ """Re-write a filter *query* to get (i.e., read) *node_type* nodes."""
+
+ @abc.abstractmethod
+ def fetch_read(self, node_type: schema.Node, query: ast.fetch.FetchExpression) -> ast.fetch.FetchExpression:
+ """Re-write a fetch *query* to get (i.e, read) values for *node_type* nodes."""
## EOF ##
diff --git a/bsfs/graph/ac/null.py b/bsfs/graph/ac/null.py
index 36838bd..c9ec7d0 100644
--- a/bsfs/graph/ac/null.py
+++ b/bsfs/graph/ac/null.py
@@ -1,15 +1,11 @@
-"""
-Part of the BlackStar filesystem (bsfs) module.
-A copy of the license is provided with the project.
-Author: Matthias Baumgartner, 2022
-"""
# imports
import typing
# bsfs imports
from bsfs import schema
from bsfs.namespace import ns
+from bsfs.query import ast
from bsfs.utils import URI
# inner-module imports
@@ -28,7 +24,7 @@ class NullAC(base.AccessControlBase):
def is_protected_predicate(self, pred: schema.Predicate) -> bool:
"""Return True if a predicate cannot be modified manually."""
- return pred.uri == ns.bsm.t_created
+ return pred.uri == ns.bsn.t_created
def create(self, node_type: schema.Node, guids: typing.Iterable[URI]):
"""Perform post-creation operations on nodes, e.g. ownership information."""
@@ -49,4 +45,16 @@ class NullAC(base.AccessControlBase):
"""Return nodes that are allowed to be created."""
return guids
+ def filter_read(
+ self,
+ node_type: schema.Node,
+ query: typing.Optional[ast.filter.FilterExpression]
+ ) -> typing.Optional[ast.filter.FilterExpression]:
+ """Re-write a filter *query* to get (i.e., read) *node_type* nodes."""
+ return query
+
+ def fetch_read(self, node_type: schema.Node, query: ast.fetch.FetchExpression) -> ast.fetch.FetchExpression:
+ """Re-write a fetch *query* to get (i.e, read) values for *node_type* nodes."""
+ return query
+
## EOF ##
diff --git a/bsfs/graph/graph.py b/bsfs/graph/graph.py
index b7b9f1c..1b4c212 100644
--- a/bsfs/graph/graph.py
+++ b/bsfs/graph/graph.py
@@ -1,20 +1,18 @@
-"""
-Part of the BlackStar filesystem (bsfs) module.
-A copy of the license is provided with the project.
-Author: Matthias Baumgartner, 2022
-"""
# imports
import os
import typing
# bsfs imports
-from bsfs.schema import Schema
+from bsfs.query import ast, validate
+from bsfs import schema as bsc
from bsfs.triple_store import TripleStoreBase
from bsfs.utils import URI, typename
# inner-module imports
+from . import ac
from . import nodes as _nodes
+from . import resolve
# exports
__all__: typing.Sequence[str] = (
@@ -25,9 +23,7 @@ __all__: typing.Sequence[str] = (
## code ##
class Graph():
- """The Graph class is
-
- The Graph class provides a convenient interface to query and access a graph.
+ """The Graph class provides a convenient interface to query and access a graph.
Since it logically builds on the concept of graphs it is easier to
navigate than raw triple stores. Naturally, it uses a triple store
as *backend*. It also controls actions via access permissions to a *user*.
@@ -37,35 +33,40 @@ class Graph():
# link to the triple storage backend.
_backend: TripleStoreBase
- # user uri.
- _user: URI
+ # access controls.
+ _ac: ac.AccessControlBase
- def __init__(self, backend: TripleStoreBase, user: URI):
+ def __init__(
+ self,
+ backend: TripleStoreBase,
+ access_control: ac.AccessControlBase,
+ ):
+ # store members
self._backend = backend
- self._user = user
+ self._ac = access_control
# ensure Graph schema requirements
self.migrate(self._backend.schema)
def __hash__(self) -> int:
- return hash((type(self), self._backend, self._user))
+ return hash((type(self), self._backend, self._ac))
def __eq__(self, other) -> bool:
return isinstance(other, type(self)) \
and self._backend == other._backend \
- and self._user == other._user
+ and self._ac == other._ac
def __repr__(self) -> str:
- return f'{typename(self)}(backend={repr(self._backend)}, user={self._user})'
+ return f'{typename(self)}({repr(self._backend)}, {self._ac})'
def __str__(self) -> str:
- return f'{typename(self)}({str(self._backend)}, {self._user})'
+ return f'{typename(self)}({str(self._backend)})'
@property
- def schema(self) -> Schema:
+ def schema(self) -> bsc.Schema:
"""Return the store's local schema."""
return self._backend.schema
- def migrate(self, schema: Schema, append: bool = True) -> 'Graph':
+ def migrate(self, schema: bsc.Schema, append: bool = True) -> 'Graph':
"""Migrate the current schema to a new *schema*.
Appends to the current schema by default; control this via *append*.
@@ -73,14 +74,14 @@ class Graph():
"""
# check args
- if not isinstance(schema, Schema):
+ if not isinstance(schema, bsc.Schema):
raise TypeError(schema)
# append to current schema
if append:
schema = schema + self._backend.schema
# add Graph schema requirements
with open(os.path.join(os.path.dirname(__file__), 'schema.nt'), mode='rt', encoding='UTF-8') as ifile:
- schema = schema + Schema.from_string(ifile.read())
+ schema = schema + bsc.from_string(ifile.read())
# migrate schema in backend
# FIXME: consult access controls!
self._backend.schema = schema
@@ -95,19 +96,69 @@ class Graph():
*node_type*) once some data is assigned to them.
"""
+ # get node type
type_ = self.schema.node(node_type)
# NOTE: Nodes constructor materializes guids.
- return _nodes.Nodes(self._backend, self._user, type_, guids)
+ return _nodes.Nodes(self._backend, self._ac, type_, guids)
def node(self, node_type: URI, guid: URI) -> _nodes.Nodes:
"""Return node *guid* of type *node_type* as a `bsfs.graph.Nodes` instance.
- Note that the *guids* need not to exist (however, the *node_type* has
+ Note that the *guid* need not to exist (however, the *node_type* has
to be part of the schema). An inexistent guid will be created (using
*node_type*) once some data is assigned to them.
"""
+ return self.nodes(node_type, {guid})
+
+ def empty(self, node_type: URI) -> _nodes.Nodes:
+ """Return a `Nodes` instance with type *node_type* but no nodes."""
+ return self.nodes(node_type, set())
+
+ def get(
+ self,
+ node_type: URI,
+ query: typing.Optional[ast.filter.FilterExpression],
+ ) -> _nodes.Nodes:
+ """Return a `Nodes` instance over all nodes of type *node_type* that match the *query*."""
+ # return Nodes instance
+ type_ = self.schema.node(node_type)
+ return _nodes.Nodes(self._backend, self._ac, type_, self.__get(node_type, query))
+
+ def sorted(
+ self,
+ node_type: URI,
+ query: typing.Optional[ast.filter.FilterExpression],
+ # FIXME: sort ast
+ ) -> typing.Iterator[_nodes.Nodes]:
+ """Return a iterator over `Nodes` instances over all nodes of type *node_type* that match the *query*."""
+ # FIXME: Order should be a parameter
+ # return iterator over Nodes instances
+ type_ = self.schema.node(node_type)
+ for guid in self.__get(node_type, query):
+ yield _nodes.Nodes(self._backend, self._ac, type_, {guid})
+
+ def all(self, node_type: URI) -> _nodes.Nodes:
+ """Return all instances of type *node_type*."""
+ type_ = self.schema.node(node_type)
+ return _nodes.Nodes(self._backend, self._ac, type_, self.__get(node_type, None))
+
+ def __get(
+ self,
+ node_type: URI,
+ query: typing.Optional[ast.filter.FilterExpression],
+ ) -> typing.Iterator[URI]:
+ """Build and execute a get query."""
+ # get node type
type_ = self.schema.node(node_type)
- return _nodes.Nodes(self._backend, self._user, type_, {guid})
+ # resolve Nodes instances
+ query = resolve.Filter(self._backend.schema).resolve(type_, query)
+ # add access controls to query
+ query = self._ac.filter_read(type_, query)
+ # validate query
+ if query is not None:
+ validate.Filter(self._backend.schema).validate(type_, query)
+ # query the backend and return the (non-materialized) result
+ return self._backend.get(type_, query)
## EOF ##
diff --git a/bsfs/graph/nodes.py b/bsfs/graph/nodes.py
index c417a0e..47b0217 100644
--- a/bsfs/graph/nodes.py
+++ b/bsfs/graph/nodes.py
@@ -1,21 +1,20 @@
-"""
-Part of the BlackStar filesystem (bsfs) module.
-A copy of the license is provided with the project.
-Author: Matthias Baumgartner, 2022
-"""
# imports
+from collections import abc
import time
import typing
# bsfs imports
-from bsfs import schema as _schema
+from bsfs import schema as bsc
from bsfs.namespace import ns
+from bsfs.query import ast, validate
from bsfs.triple_store import TripleStoreBase
from bsfs.utils import errors, URI, typename
# inner-module imports
from . import ac
+from . import result
+from . import walk
# exports
__all__: typing.Sequence[str] = (
@@ -26,18 +25,20 @@ __all__: typing.Sequence[str] = (
## code ##
class Nodes():
- """
+ """Container for graph nodes, provides operations on nodes.
+
+ NOTE: Should not be created directly but only via `bsfs.graph.Graph`.
NOTE: guids may or may not exist. This is not verified as nodes are created on demand.
"""
# triple store backend.
_backend: TripleStoreBase
- # user uri.
- _user: URI
+ # access controls.
+ _ac: ac.AccessControlBase
# node type.
- _node_type: _schema.Node
+ _node_type: bsc.Node
# guids of nodes. Can be empty.
_guids: typing.Set[URI]
@@ -45,34 +46,35 @@ class Nodes():
def __init__(
self,
backend: TripleStoreBase,
- user: URI,
- node_type: _schema.Node,
+ access_control: ac.AccessControlBase,
+ node_type: bsc.Node,
guids: typing.Iterable[URI],
):
+ # set main members
self._backend = backend
- self._user = user
+ self._ac = access_control
self._node_type = node_type
- self._guids = set(guids)
- self.__ac = ac.NullAC(self._backend, self._user)
+ # convert to URI since this is not guaranteed by Graph
+ self._guids = {URI(guid) for guid in guids}
def __eq__(self, other: typing.Any) -> bool:
return isinstance(other, Nodes) \
and self._backend == other._backend \
- and self._user == other._user \
+ and self._ac == other._ac \
and self._node_type == other._node_type \
and self._guids == other._guids
def __hash__(self) -> int:
- return hash((type(self), self._backend, self._user, self._node_type, tuple(sorted(self._guids))))
+ return hash((type(self), self._backend, self._ac, self._node_type, tuple(sorted(self._guids))))
def __repr__(self) -> str:
- return f'{typename(self)}({self._backend}, {self._user}, {self._node_type}, {self._guids})'
+ return f'{typename(self)}({self._backend}, {self._ac}, {self._node_type}, {self._guids})'
def __str__(self) -> str:
return f'{typename(self)}({self._node_type}, {self._guids})'
@property
- def node_type(self) -> _schema.Node:
+ def node_type(self) -> bsc.Node:
"""Return the node's type."""
return self._node_type
@@ -81,9 +83,72 @@ class Nodes():
"""Return all node guids."""
return iter(self._guids)
+ @property
+ def schema(self) -> bsc.Schema:
+ """Return the store's local schema."""
+ return self._backend.schema
+
+ def __add__(self, other: typing.Any) -> 'Nodes':
+ """Concatenate guids. Backend, AC, and node type must match."""
+ if not isinstance(other, type(self)):
+ return NotImplemented
+ if self._backend != other._backend:
+ raise ValueError(other)
+ if self._ac != other._ac:
+ raise ValueError(other)
+ if self.node_type != other.node_type:
+ raise ValueError(other)
+ return Nodes(self._backend, self._ac, self.node_type, self._guids | other._guids)
+
+ def __or__(self, other: typing.Any) -> 'Nodes':
+ """Concatenate guids. Backend, AC, and node type must match."""
+ return self.__add__(other)
+
+ def __sub__(self, other: typing.Any) -> 'Nodes':
+ """Subtract guids. Backend, AC, and node type must match."""
+ if not isinstance(other, type(self)):
+ return NotImplemented
+ if self._backend != other._backend:
+ raise ValueError(other)
+ if self._ac != other._ac:
+ raise ValueError(other)
+ if self.node_type != other.node_type:
+ raise ValueError(other)
+ return Nodes(self._backend, self._ac, self.node_type, self._guids - other._guids)
+
+ def __and__(self, other: typing.Any) -> 'Nodes':
+ """Intersect guids. Backend, AC, and node type must match."""
+ if not isinstance(other, type(self)):
+ return NotImplemented
+ if self._backend != other._backend:
+ raise ValueError(other)
+ if self._ac != other._ac:
+ raise ValueError(other)
+ if self.node_type != other.node_type:
+ raise ValueError(other)
+ return Nodes(self._backend, self._ac, self.node_type, self._guids & other._guids)
+
+ def __len__(self) -> int:
+ """Return the number of guids."""
+ return len(self._guids)
+
+ def __iter__(self) -> typing.Iterator['Nodes']:
+ """Iterate over individual guids. Returns `Nodes` instances."""
+ return iter(
+ Nodes(self._backend, self._ac, self.node_type, {guid})
+ for guid in self._guids
+ )
+
+ def __getattr__(self, name: str):
+ try:
+ return super().__getattr__(name) # type: ignore [misc] # parent has no getattr
+ except AttributeError:
+ pass
+ return walk.Walk(self, walk.Walk.step(self.schema, self.node_type, name))
+
def set(
self,
- pred: URI, # FIXME: URI or _schema.Predicate?
+ pred: URI, # FIXME: URI or bsc.Predicate?
value: typing.Any,
) -> 'Nodes':
"""Set predicate *pred* to *value*."""
@@ -91,7 +156,7 @@ class Nodes():
def set_from_iterable(
self,
- predicate_values: typing.Iterable[typing.Tuple[URI, typing.Any]], # FIXME: URI or _schema.Predicate?
+ predicate_values: typing.Iterable[typing.Tuple[URI, typing.Any]], # FIXME: URI or bsc.Predicate?
) -> 'Nodes':
"""Set mutliple predicate-value pairs at once."""
# TODO: Could group predicate_values by predicate to gain some efficiency
@@ -105,7 +170,7 @@ class Nodes():
self._backend.commit()
except (
- errors.PermissionDeniedError, # tried to set a protected predicate (ns.bsm.t_created)
+ errors.PermissionDeniedError, # tried to set a protected predicate
errors.ConsistencyError, # node types are not in the schema or don't match the predicate
errors.InstanceError, # guids/values don't have the correct type
TypeError, # value is supposed to be a Nodes instance
@@ -120,6 +185,126 @@ class Nodes():
return self
+ def get(
+ self,
+ *paths: typing.Union[URI, typing.Iterable[URI]],
+ view: typing.Union[typing.Type[list], typing.Type[dict]] = dict,
+ **view_kwargs,
+ ) -> typing.Any:
+ """Get values or nodes at *paths*.
+ Return an iterator (view=list) or a dict (view=dict) over the results.
+ """
+ # FIXME: user-provided Fetch query AST?
+ # check args
+ if len(paths) == 0:
+ raise AttributeError('expected at least one path, found none')
+ if view not in (dict, list):
+ raise ValueError(f'expected dict or list, found {view}')
+ # process paths: create fetch ast, build name mapping, and find unique paths
+ schema = self.schema
+ statements = set()
+ name2path = {}
+ unique_paths = set() # paths that result in a single (unique) value
+ normpath: typing.Tuple[URI, ...]
+ for idx, path in enumerate(paths):
+ # normalize path
+ if isinstance(path, str):
+ normpath = (URI(path), )
+ elif isinstance(path, abc.Iterable):
+ if not all(isinstance(step, str) for step in path):
+ raise TypeError(path)
+ normpath = tuple(URI(step) for step in path)
+ else:
+ raise TypeError(path)
+ # check path's schema consistency
+ if not all(schema.has_predicate(pred) for pred in normpath):
+ raise errors.ConsistencyError(f'path is not fully covered by the schema: {path}')
+ # check path's uniqueness
+ if all(schema.predicate(pred).unique for pred in normpath):
+ unique_paths.add(path)
+ # fetch tail predicate
+ tail = schema.predicate(normpath[-1])
+ # determine tail ast node type
+ factory = ast.fetch.Node if isinstance(tail.range, bsc.Node) else ast.fetch.Value
+ # assign name
+ name = f'fetch{idx}'
+ name2path[name] = (path, tail)
+ # create tail ast node
+ curr: ast.fetch.FetchExpression = factory(tail.uri, name)
+ # walk towards front
+ hop: URI
+ for hop in normpath[-2::-1]:
+ curr = ast.fetch.Fetch(hop, curr)
+ # add to fetch query
+ statements.add(curr)
+ # aggregate fetch statements
+ if len(statements) == 1:
+ fetch = next(iter(statements))
+ else:
+ fetch = ast.fetch.All(*statements)
+ # add access controls to fetch
+ fetch = self._ac.fetch_read(self.node_type, fetch)
+
+ if len(self._guids) == 0:
+ # shortcut: no need to query; no triples
+ # FIXME: if the Fetch query can given by the user, we might want to check its validity
+ def triple_iter():
+ return []
+ else:
+ # compose filter ast
+ filter = ast.filter.IsIn(self.guids) # pylint: disable=redefined-builtin
+ # add access controls to filter
+ filter = self._ac.filter_read(self.node_type, filter) # type: ignore [assignment]
+
+ # validate queries
+ validate.Filter(self._backend.schema).validate(self.node_type, filter)
+ validate.Fetch(self._backend.schema).validate(self.node_type, fetch)
+
+ # process results, convert if need be
+ def triple_iter():
+ # query the backend
+ triples = self._backend.fetch(self.node_type, filter, fetch)
+ # process triples
+ for root, name, raw in triples:
+ # get node
+ node = Nodes(self._backend, self._ac, self.node_type, {root})
+ # get path
+ path, tail = name2path[name]
+ # covert raw to value
+ if isinstance(tail.range, bsc.Node):
+ value = Nodes(self._backend, self._ac, tail.range, {raw})
+ else:
+ value = raw
+ # emit triple
+ yield node, path, value
+
+ # simplify by default
+ view_kwargs['node'] = view_kwargs.get('node', len(self._guids) != 1)
+ view_kwargs['path'] = view_kwargs.get('path', len(paths) != 1)
+ view_kwargs['value'] = view_kwargs.get('value', False)
+
+ # return results view
+ if view == list:
+ return result.to_list_view(
+ triple_iter(),
+ # aggregation args
+ **view_kwargs,
+ )
+
+ if view == dict:
+ return result.to_dict_view(
+ triple_iter(),
+ # context
+ len(self._guids) == 1,
+ len(paths) == 1,
+ unique_paths,
+ # aggregation args
+ **view_kwargs,
+ )
+
+ raise errors.UnreachableError() # view was already checked
+
+
def __set(self, predicate: URI, value: typing.Any):
"""
"""
@@ -135,7 +320,7 @@ class Nodes():
# FIXME: Needed? Could be integrated into other AC methods (by passing the predicate!)
# This could allow more fine-grained predicate control (e.g. based on ownership)
# rather than a global approach like this.
- if self.__ac.is_protected_predicate(pred):
+ if self._ac.is_protected_predicate(pred):
raise errors.PermissionDeniedError(pred)
# set operation affects all nodes (if possible)
@@ -145,11 +330,11 @@ class Nodes():
guids = set(self._ensure_nodes(node_type, guids))
# check value
- if isinstance(pred.range, _schema.Literal):
+ if isinstance(pred.range, bsc.Literal):
# check write permissions on existing nodes
# As long as the user has write permissions, we don't restrict
# the creation or modification of literal values.
- guids = set(self.__ac.write_literal(node_type, guids))
+ guids = set(self._ac.write_literal(node_type, guids))
# insert literals
# TODO: Support passing iterators as values for non-unique predicates
@@ -160,8 +345,9 @@ class Nodes():
[value],
)
- elif isinstance(pred.range, _schema.Node):
+ elif isinstance(pred.range, bsc.Node):
# check value type
+ # FIXME: value could be a set of Nodes
if not isinstance(value, Nodes):
raise TypeError(value)
# value's node_type must be a subclass of the predicate's range
@@ -172,14 +358,14 @@ class Nodes():
# Link permissions cover adding and removing links on the source node.
# Specifically, link permissions also allow to remove links to other
# nodes if needed (e.g. for unique predicates).
- guids = set(self.__ac.link_from_node(node_type, guids))
+ guids = set(self._ac.link_from_node(node_type, guids))
# get link targets
targets = set(value.guids)
# ensure existence of value nodes; create nodes if need be
targets = set(self._ensure_nodes(value.node_type, targets))
# check link permissions on target nodes
- targets = set(self.__ac.link_to_node(value.node_type, targets))
+ targets = set(self._ac.link_to_node(value.node_type, targets))
# insert node links
self._backend.set(
@@ -192,7 +378,7 @@ class Nodes():
else:
raise errors.UnreachableError()
- def _ensure_nodes(self, node_type: _schema.Node, guids: typing.Iterable[URI]):
+ def _ensure_nodes(self, node_type: bsc.Node, guids: typing.Iterable[URI]):
"""
"""
# check node existence
@@ -203,14 +389,14 @@ class Nodes():
# create nodes if need be
if len(missing) > 0:
# check which missing nodes can be created
- missing = set(self.__ac.createable(node_type, missing))
+ missing = set(self._ac.createable(node_type, missing))
# create nodes
self._backend.create(node_type, missing)
# add bookkeeping triples
self._backend.set(node_type, missing,
- self._backend.schema.predicate(ns.bsm.t_created), [time.time()])
+ self._backend.schema.predicate(ns.bsn.t_created), [time.time()])
# add permission triples
- self.__ac.create(node_type, missing)
+ self._ac.create(node_type, missing)
# return available nodes
return existing | missing
diff --git a/bsfs/graph/resolve.py b/bsfs/graph/resolve.py
new file mode 100644
index 0000000..a58eb67
--- /dev/null
+++ b/bsfs/graph/resolve.py
@@ -0,0 +1,174 @@
+
+# imports
+import typing
+
+# bsfs imports
+from bsfs import schema as bsc
+from bsfs.query import ast
+from bsfs.utils import errors
+
+# inner-module imports
+from . import nodes
+
+# exports
+__all__: typing.Sequence[str] = (
+ 'Filter',
+ )
+
+
+## code ##
+
+class Filter():
+ """Rewrites the query to replace `bsfs.graph.nodes.Nodes` instances with the respective URI.
+ Does only limited type checking and schema validation.
+ Use `bsfs.schema.validate.Filter` to do so.
+
+ Example:
+ input: Any(ns.bse.tag, Is(Nodes(...)))
+ output: Any(ns.bse.tag, Or(Is(...), Is(...), ...)))
+
+ >>> tags = graph.node(ns.bsn.Tag, 'http://example.com/me/tag#1234')
+ >>> graph.get(ns.bsn.Entity, ast.filter.Any(ns.bse.tag, ast.filter.Is(tags)))
+
+ """
+
+ def __init__(self, schema):
+ self.schema = schema
+
+ def __call__(
+ self,
+ root_type: bsc.Node,
+ node: typing.Optional[ast.filter.FilterExpression],
+ ):
+ """Alias for `Resolve.resolve`."""
+ return self.resolve(root_type, node)
+
+ def resolve(
+ self,
+ root_type: bsc.Node,
+ node: typing.Optional[ast.filter.FilterExpression],
+ ):
+ """Resolve Nodes instances of a *node* query starting at *root_type*."""
+ if node is None:
+ return None
+ return self._parse_filter_expression(root_type, node)
+
+ def _parse_filter_expression(
+ self,
+ type_: bsc.Vertex,
+ node: ast.filter.FilterExpression,
+ ) -> ast.filter.FilterExpression:
+ """Route *node* to the handler of the respective FilterExpression subclass."""
+ if isinstance(node, ast.filter.Is):
+ return self._is(type_, node)
+ if isinstance(node, ast.filter.Not):
+ return self._not(type_, node)
+ if isinstance(node, ast.filter.Has):
+ return self._has(type_, node)
+ if isinstance(node, ast.filter.Any):
+ return self._any(type_, node)
+ if isinstance(node, ast.filter.All):
+ return self._all(type_, node)
+ if isinstance(node, ast.filter.And):
+ return self._and(type_, node)
+ if isinstance(node, ast.filter.Or):
+ return self._or(type_, node)
+ if isinstance(node, ast.filter.Distance):
+ return self._distance(type_, node)
+ if isinstance(node, (ast.filter.Equals, ast.filter.Substring, \
+ ast.filter.StartsWith, ast.filter.EndsWith)):
+ return self._value(type_, node)
+ if isinstance(node, (ast.filter.LessThan, ast.filter.GreaterThan)):
+ return self._bounded(type_, node)
+ # invalid node
+ raise errors.BackendError(f'expected filter expression, found {node}')
+
+ def _parse_predicate_expression(self, node: ast.filter.PredicateExpression) -> bsc.Vertex:
+ """Route *node* to the handler of the respective PredicateExpression subclass."""
+ if isinstance(node, ast.filter.Predicate):
+ return self._predicate(node)
+ if isinstance(node, ast.filter.OneOf):
+ return self._one_of(node)
+ # invalid node
+ raise errors.BackendError(f'expected predicate expression, found {node}')
+
+ def _predicate(self, node: ast.filter.Predicate) -> bsc.Vertex:
+ if not self.schema.has_predicate(node.predicate):
+ raise errors.ConsistencyError(f'predicate {node.predicate} is not in the schema')
+ pred = self.schema.predicate(node.predicate)
+ dom, rng = pred.domain, pred.range
+ if node.reverse:
+ dom, rng = rng, dom
+ return rng
+
+ def _one_of(self, node: ast.filter.OneOf) -> bsc.Vertex:
+ # determine domain and range types
+ rng = None
+ for pred in node:
+ # parse child expression
+ subrng = self._parse_predicate_expression(pred)
+ # determine the next type
+ if rng is None or subrng > rng: # pick most generic range
+ rng = subrng
+ # check range consistency
+ if not subrng <= rng and not subrng >= rng:
+ raise errors.ConsistencyError(f'ranges {subrng} and {rng} are not related')
+ if not isinstance(rng, (bsc.Node, bsc.Literal)):
+ raise errors.BackendError(f'the range of node {node} is undefined')
+ return rng
+
+ def _any(self, type_: bsc.Vertex, node: ast.filter.Any) -> ast.filter.Any: # pylint: disable=unused-argument
+ next_type = self._parse_predicate_expression(node.predicate)
+ return ast.filter.Any(node.predicate, self._parse_filter_expression(next_type, node.expr))
+
+ def _all(self, type_: bsc.Vertex, node: ast.filter.All) -> ast.filter.All: # pylint: disable=unused-argument
+ next_type = self._parse_predicate_expression(node.predicate)
+ return ast.filter.All(node.predicate, self._parse_filter_expression(next_type, node.expr))
+
+ def _and(self, type_: bsc.Vertex, node: ast.filter.And) -> ast.filter.And:
+ return ast.filter.And({self._parse_filter_expression(type_, expr) for expr in node})
+
+ def _or(self, type_: bsc.Vertex, node: ast.filter.Or) -> ast.filter.Or:
+ return ast.filter.Or({self._parse_filter_expression(type_, expr) for expr in node})
+
+ def _not(self, type_: bsc.Vertex, node: ast.filter.Not) -> ast.filter.Not:
+ return ast.filter.Not(self._parse_filter_expression(type_, node.expr))
+
+ def _has(self, type_: bsc.Vertex, node: ast.filter.Has) -> ast.filter.Has: # pylint: disable=unused-argument
+ return node
+
+ def _distance(self, type_: bsc.Vertex, node: ast.filter.Distance): # pylint: disable=unused-argument
+ return node
+
+ def _value(self, type_: bsc.Vertex, node: ast.filter._Value) -> ast.filter._Value: # pylint: disable=unused-argument
+ return node
+
+ def _bounded(self, type_: bsc.Vertex, node: ast.filter._Bounded) -> ast.filter._Bounded: # pylint: disable=unused-argument
+ return node
+
+ def _is(self, type_: bsc.Vertex, node: ast.filter.Is) -> typing.Union[ast.filter.Or, ast.filter.Is]:
+ # check if action is needed
+ if not isinstance(node.value, nodes.Nodes):
+ return node
+ # check schema consistency
+ if node.value.node_type not in self.schema.nodes():
+ raise errors.ConsistencyError(f'node {node.value.node_type} is not in the schema')
+ # check type compatibility
+ if not isinstance(type_, bsc.Node):
+ raise errors.ConsistencyError(f'expected a node, found {type_}')
+ if not node.value.node_type <= type_:
+ raise errors.ConsistencyError(f'expected type {type_} or subtype thereof, found {node.value.node_type}')
+ # NOTE: We assume that the node type is checked when writing to the backend.
+ # Links to any of the guids can therefore only exist if the type matches.
+ # Hence, we don't add a type check/constrain here.
+ return ast.filter.Or(ast.filter.Is(guid) for guid in node.value.guids)
+ # optimized code, removing unnecessary ast.filter.Or
+ #guids = set(node.value.guids)
+ #if len(guids) == 0:
+ # raise errors.BackendError(f'')
+ #if len(guids) == 1:
+ # return ast.filter.Nodeid(next(iter(guids)))
+ #return ast.filter.Or(ast.filter.Is(guid) for guid in guids)
+
+
+## EOF ##
diff --git a/bsfs/graph/result.py b/bsfs/graph/result.py
new file mode 100644
index 0000000..0fcbb13
--- /dev/null
+++ b/bsfs/graph/result.py
@@ -0,0 +1,119 @@
+
+# imports
+from collections import defaultdict
+import typing
+
+# bsfs imports
+from bsfs.utils import URI
+
+# exports
+__all__: typing.Sequence[str] = (
+ 'to_list_view',
+ 'to_dict_view',
+ )
+
+
+## code ##
+
+# FIXME: node, path, value seem counter-intuitive:
+# node.get(..., node=True) removes the node part.
+# wouldn't it make more sense if node=True keeps the node part
+# and node=False drops it?
+
+def to_list_view(
+ triples,
+ # aggregators
+ node: bool,
+ path: bool,
+ value: bool, # pylint: disable=unused-argument
+ ):
+ """Return an iterator over results.
+
+ Dependent on the *node*, *path*, and *value* flags,
+ the respective component is omitted.
+
+ """
+ if not node and not path:
+ return iter(val for _, _, val in triples)
+ if not node:
+ return iter((pred, val) for _, pred, val in triples)
+ if not path:
+ return iter((subj, val) for subj, _, val in triples)
+ return iter((subj, pred, val) for subj, pred, val in triples)
+
+
+def to_dict_view(
+ triples,
+ # context
+ one_node: bool,
+ one_path: bool,
+ unique_paths: typing.Set[typing.Union[URI, typing.Iterable[URI]]],
+ # aggregators
+ node: bool,
+ path: bool,
+ value: bool,
+ default: typing.Optional[typing.Any] = None,
+ ) -> typing.Any:
+ """Return a dict of results.
+
+ Note that triples are materialized to create this view.
+
+ The returned structure depends on the *node*, *path*, and *value* flags.
+ If all flags are set to False, returns a dict(node -> dict(path -> set(values))).
+ Setting a flag to true omits or simplifies the respective component (if possible).
+
+ """
+ # NOTE: To create a dict, we need to materialize or make further assumptions
+ # (e.g., sorted in a specific order).
+
+ data: typing.Any # disable type checks on data since it's very flexibly typed.
+
+ # FIXME: type of data can be overwritten later on (if value)
+
+ if not node and not path:
+ data = set()
+ elif node ^ path:
+ data = defaultdict(set)
+ else:
+ data = defaultdict(lambda: defaultdict(set))
+
+ for subj, pred, val in triples:
+ unique = pred in unique_paths
+ if not node and not path:
+ if not value and unique and one_node and one_path:
+ return val
+ data.add(val)
+ elif not node:
+ # remove node from result, group by predicate
+ if not value and unique and one_node:
+ data[pred] = val
+ else:
+ data[pred].add(val)
+ elif not path:
+ # remove predicate from result, group by node
+ if not value and unique and one_path:
+ data[subj] = val
+ else:
+ data[subj].add(val)
+ else:
+ if not value and unique:
+ data[subj][pred] = val
+ else:
+ data[subj][pred].add(val)
+
+ # FIXME: Combine multiple Nodes instances into one?
+
+ # convert defaultdict to ordinary dict
+ # pylint: disable=too-many-boolean-expressions
+ if not node and not path and not value \
+ and len(unique_paths) > 0 and one_node and one_path \
+ and len(data) == 0:
+ return default
+ # pylint: enable=too-many-boolean-expressions
+ if not node and not path:
+ return data
+ if node ^ path:
+ return dict(data)
+ return {key: dict(val) for key, val in data.items()}
+
+## EOF ##
diff --git a/bsfs/graph/schema.nt b/bsfs/graph/schema.nt
index 8612681..37bba5e 100644
--- a/bsfs/graph/schema.nt
+++ b/bsfs/graph/schema.nt
@@ -4,15 +4,17 @@ prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>
prefix xsd: <http://www.w3.org/2001/XMLSchema#>
# bsfs prefixes
-prefix bsfs: <http://bsfs.ai/schema/>
-prefix bsm: <http://bsfs.ai/schema/Meta#>
+prefix bsfs: <https://schema.bsfs.io/core/>
+prefix bsl: <https://schema.bsfs.io/core/Literal/>
+prefix bsn: <https://schema.bsfs.io/core/Node#>
# literals
-xsd:integer rdfs:subClassOf bsfs:Literal .
+bsl:Number rdfs:subClassOf bsfs:Literal .
+xsd:float rdfs:subClassOf bsl:Number .
# predicates
-bsm:t_created rdfs:subClassOf bsfs:Predicate ;
+bsn:t_created rdfs:subClassOf bsfs:Predicate ;
rdfs:domain bsfs:Node ;
- rdfs:range xsd:integer ;
+ rdfs:range xsd:float ;
bsfs:unique "true"^^xsd:boolean .
diff --git a/bsfs/graph/walk.py b/bsfs/graph/walk.py
new file mode 100644
index 0000000..6415c9b
--- /dev/null
+++ b/bsfs/graph/walk.py
@@ -0,0 +1,115 @@
+
+# imports
+from collections import abc
+import typing
+
+# bsfs imports
+from bsfs import schema as bsc
+
+# inner-module imports
+# NOTE: circular import! OK as long as only used for type annotations.
+from . import nodes # pylint: disable=cyclic-import
+
+# exports
+__all__: typing.Sequence[str] = (
+ 'Walk',
+ )
+
+
+## code ##
+
+class Walk(abc.Hashable, abc.Callable): # type: ignore [misc] # invalid base class (Callable)
+ """Syntactic sugar for `Nodes` to build and act on predicate paths via members."""
+
+ # Link to Nodes instance.
+ _root: 'nodes.Nodes'
+
+ # Current predicate path.
+ _path: typing.Tuple[bsc.Predicate, ...]
+
+ def __init__(
+ self,
+ root: 'nodes.Nodes',
+ path: typing.Sequence[bsc.Predicate],
+ ):
+ self._root = root
+ self._path = tuple(path)
+
+ @property
+ def tail(self):
+ """Return the node type at the end of the path."""
+ return self._path[-1].range
+
+
+ ## comparison
+
+ def __hash__(self) -> int:
+ """Return an integer hash that identifies the instance."""
+ return hash((type(self), self._root, self._path))
+
+ def __eq__(self, other) -> bool:
+ """Compare against *other* backend."""
+ return isinstance(other, type(self)) \
+ and self._root == other._root \
+ and self._path == other._path
+
+
+ ## representation
+
+ def __repr__(self) -> str:
+ """Return a formal string representation."""
+ path = ', '.join(pred.uri for pred in self._path)
+ return f'Walk({self._root.node_type.uri}, ({path}))'
+
+ def __str__(self) -> str:
+ """Return an informal string representation."""
+ path = ', '.join(pred.uri for pred in self._path)
+ return f'Walk(@{self._root.node_type.uri}: {path})'
+
+
+ ## walk
+
+ @staticmethod
+ def step(
+ schema: bsc.Schema,
+ node: bsc.Node,
+ name: str,
+ ) -> typing.Tuple[bsc.Predicate]:
+ """Get an predicate at *node* whose fragment matches *name*."""
+ predicates = tuple(
+ pred
+ for pred
+ in schema.predicates_at(node)
+ if pred.uri.get('fragment', None) == name
+ )
+ if len(predicates) == 0: # no fragment found for name
+ raise ValueError(f'no available predicate matches {name}') # FIXME: Custom exception
+ if len(predicates) > 1: # ambiguous name
+ raise ValueError(f'{name} matches multiple predicates') # FIXME: Custom exception
+ # append predicate to walk
+ return predicates # type: ignore [return-value] # size is one
+
+ def __getattr__(self, name: str) -> 'Walk':
+ """Alias for `Walk.step(name)`."""
+ try:
+ return super().__getattr__(name)
+ except AttributeError:
+ pass
+ # get predicate
+ pred = self.step(self._root.schema, self.tail, name)
+ # append predicate to walk
+ return Walk(self._root, self._path + pred)
+
+
+ ## get paths ##
+
+ def get(self, **kwargs) -> typing.Any:
+ """Alias for `Nodes.get(..)`."""
+ return self._root.get(tuple(pred.uri for pred in self._path), **kwargs)
+
+ def __call__(self, **kwargs) -> typing.Any: # pylint: disable=arguments-differ
+ """Alias for `Walk.get(...)`."""
+ return self.get(**kwargs)
+
+
+## EOF ##