aboutsummaryrefslogtreecommitdiffstats
path: root/bsfs/graph
diff options
context:
space:
mode:
Diffstat (limited to 'bsfs/graph')
-rw-r--r--bsfs/graph/__init__.py18
-rw-r--r--bsfs/graph/ac/__init__.py20
-rw-r--r--bsfs/graph/ac/base.py71
-rw-r--r--bsfs/graph/ac/null.py52
-rw-r--r--bsfs/graph/graph.py113
-rw-r--r--bsfs/graph/nodes.py217
-rw-r--r--bsfs/graph/schema.nt18
7 files changed, 509 insertions, 0 deletions
diff --git a/bsfs/graph/__init__.py b/bsfs/graph/__init__.py
new file mode 100644
index 0000000..82d2235
--- /dev/null
+++ b/bsfs/graph/__init__.py
@@ -0,0 +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 typing
+
+# inner-module imports
+from .graph import Graph
+
+# exports
+__all__: typing.Sequence[str] = (
+ 'Graph',
+ )
+
+## EOF ##
diff --git a/bsfs/graph/ac/__init__.py b/bsfs/graph/ac/__init__.py
new file mode 100644
index 0000000..420de01
--- /dev/null
+++ b/bsfs/graph/ac/__init__.py
@@ -0,0 +1,20 @@
+"""
+
+Part of the BlackStar filesystem (bsfs) module.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# imports
+import typing
+
+# inner-module imports
+from .base import AccessControlBase
+from .null import NullAC
+
+# exports
+__all__: typing.Sequence[str] = (
+ 'AccessControlBase',
+ 'NullAC',
+ )
+
+## EOF ##
diff --git a/bsfs/graph/ac/base.py b/bsfs/graph/ac/base.py
new file mode 100644
index 0000000..bc9aeb3
--- /dev/null
+++ b/bsfs/graph/ac/base.py
@@ -0,0 +1,71 @@
+"""
+
+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.triple_store import TripleStoreBase
+from bsfs.utils import URI
+
+# exports
+__all__: typing.Sequence[str] = (
+ 'AccessControlBase',
+ )
+
+
+## code ##
+
+class AccessControlBase(abc.ABC):
+ """Defines the interface for access control policies.
+
+ An access control policy governs which actions a user may take to query
+ or to manipulate a graph.
+
+ """
+
+ # The triple store backend.
+ _backend: TripleStoreBase
+
+ # The current user.
+ _user: URI
+
+ def __init__(
+ self,
+ backend: TripleStoreBase,
+ user: URI,
+ ):
+ self._backend = backend
+ self._user = URI(user)
+
+ @abc.abstractmethod
+ def is_protected_predicate(self, pred: schema.Predicate) -> bool:
+ """Return True if a predicate cannot be modified manually."""
+
+ @abc.abstractmethod
+ def create(self, node_type: schema.Node, guids: typing.Iterable[URI]):
+ """Perform post-creation operations on nodes, e.g. ownership information."""
+
+ @abc.abstractmethod
+ def link_from_node(self, node_type: schema.Node, guids: typing.Iterable[URI]) -> typing.Iterable[URI]:
+ """Return nodes for which outbound links can be written."""
+
+ @abc.abstractmethod
+ def link_to_node(self, node_type: schema.Node, guids: typing.Iterable[URI]) -> typing.Iterable[URI]:
+ """Return nodes for which inbound links can be written."""
+
+ @abc.abstractmethod
+ def write_literal(self, node_type: schema.Node, guids: typing.Iterable[URI]) -> typing.Iterable[URI]:
+ """Return nodes to which literals can be attached."""
+
+ @abc.abstractmethod
+ def createable(self, node_type: schema.Node, guids: typing.Iterable[URI]) -> typing.Iterable[URI]:
+ """Return nodes that are allowed to be created."""
+
+
+## EOF ##
diff --git a/bsfs/graph/ac/null.py b/bsfs/graph/ac/null.py
new file mode 100644
index 0000000..36838bd
--- /dev/null
+++ b/bsfs/graph/ac/null.py
@@ -0,0 +1,52 @@
+"""
+
+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.utils import URI
+
+# inner-module imports
+from . import base
+
+# exports
+__all__: typing.Sequence[str] = (
+ 'NullAC',
+ )
+
+
+## code ##
+
+class NullAC(base.AccessControlBase):
+ """The NULL access control implements a dummy policy that allows any action to any user."""
+
+ 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
+
+ def create(self, node_type: schema.Node, guids: typing.Iterable[URI]):
+ """Perform post-creation operations on nodes, e.g. ownership information."""
+
+ def link_from_node(self, node_type: schema.Node, guids: typing.Iterable[URI]) -> typing.Iterable[URI]:
+ """Return nodes for which outbound links can be written."""
+ return guids
+
+ def link_to_node(self, node_type: schema.Node, guids: typing.Iterable[URI]) -> typing.Iterable[URI]:
+ """Return nodes for which inbound links can be written."""
+ return guids
+
+ def write_literal(self, node_type: schema.Node, guids: typing.Iterable[URI]) -> typing.Iterable[URI]:
+ """Return nodes to which literals can be attached."""
+ return guids
+
+ def createable(self, node_type: schema.Node, guids: typing.Iterable[URI]) -> typing.Iterable[URI]:
+ """Return nodes that are allowed to be created."""
+ return guids
+
+## EOF ##
diff --git a/bsfs/graph/graph.py b/bsfs/graph/graph.py
new file mode 100644
index 0000000..b7b9f1c
--- /dev/null
+++ b/bsfs/graph/graph.py
@@ -0,0 +1,113 @@
+"""
+
+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.triple_store import TripleStoreBase
+from bsfs.utils import URI, typename
+
+# inner-module imports
+from . import nodes as _nodes
+
+# exports
+__all__: typing.Sequence[str] = (
+ 'Graph',
+ )
+
+
+## code ##
+
+class Graph():
+ """The Graph class is
+
+ 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*.
+
+ """
+
+ # link to the triple storage backend.
+ _backend: TripleStoreBase
+
+ # user uri.
+ _user: URI
+
+ def __init__(self, backend: TripleStoreBase, user: URI):
+ self._backend = backend
+ self._user = user
+ # ensure Graph schema requirements
+ self.migrate(self._backend.schema)
+
+ def __hash__(self) -> int:
+ return hash((type(self), self._backend, self._user))
+
+ def __eq__(self, other) -> bool:
+ return isinstance(other, type(self)) \
+ and self._backend == other._backend \
+ and self._user == other._user
+
+ def __repr__(self) -> str:
+ return f'{typename(self)}(backend={repr(self._backend)}, user={self._user})'
+
+ def __str__(self) -> str:
+ return f'{typename(self)}({str(self._backend)}, {self._user})'
+
+ @property
+ def schema(self) -> Schema:
+ """Return the store's local schema."""
+ return self._backend.schema
+
+ def migrate(self, schema: Schema, append: bool = True) -> 'Graph':
+ """Migrate the current schema to a new *schema*.
+
+ Appends to the current schema by default; control this via *append*.
+ The `Graph` may add additional classes to the schema that are required for its interals.
+
+ """
+ # check args
+ if not isinstance(schema, 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())
+ # migrate schema in backend
+ # FIXME: consult access controls!
+ self._backend.schema = schema
+ # return self
+ return self
+
+ def nodes(self, node_type: URI, guids: typing.Iterable[URI]) -> _nodes.Nodes:
+ """Return nodes *guids* of type *node_type* as a `bsfs.graph.Nodes` instance.
+
+ Note that the *guids* need not to exist (however, the *node_type* has
+ to be part of the schema). Inexistent guids will be created (using
+ *node_type*) once some data is assigned to them.
+
+ """
+ type_ = self.schema.node(node_type)
+ # NOTE: Nodes constructor materializes guids.
+ return _nodes.Nodes(self._backend, self._user, 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
+ to be part of the schema). An inexistent guid will be created (using
+ *node_type*) once some data is assigned to them.
+
+ """
+ type_ = self.schema.node(node_type)
+ return _nodes.Nodes(self._backend, self._user, type_, {guid})
+
+## EOF ##
diff --git a/bsfs/graph/nodes.py b/bsfs/graph/nodes.py
new file mode 100644
index 0000000..c417a0e
--- /dev/null
+++ b/bsfs/graph/nodes.py
@@ -0,0 +1,217 @@
+"""
+
+Part of the BlackStar filesystem (bsfs) module.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# imports
+import time
+import typing
+
+# bsfs imports
+from bsfs import schema as _schema
+from bsfs.namespace import ns
+from bsfs.triple_store import TripleStoreBase
+from bsfs.utils import errors, URI, typename
+
+# inner-module imports
+from . import ac
+
+# exports
+__all__: typing.Sequence[str] = (
+ 'Nodes',
+ )
+
+
+## code ##
+
+class Nodes():
+ """
+ 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
+
+ # node type.
+ _node_type: _schema.Node
+
+ # guids of nodes. Can be empty.
+ _guids: typing.Set[URI]
+
+ def __init__(
+ self,
+ backend: TripleStoreBase,
+ user: URI,
+ node_type: _schema.Node,
+ guids: typing.Iterable[URI],
+ ):
+ self._backend = backend
+ self._user = user
+ self._node_type = node_type
+ self._guids = set(guids)
+ self.__ac = ac.NullAC(self._backend, self._user)
+
+ def __eq__(self, other: typing.Any) -> bool:
+ return isinstance(other, Nodes) \
+ and self._backend == other._backend \
+ and self._user == other._user \
+ 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))))
+
+ def __repr__(self) -> str:
+ return f'{typename(self)}({self._backend}, {self._user}, {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:
+ """Return the node's type."""
+ return self._node_type
+
+ @property
+ def guids(self) -> typing.Iterator[URI]:
+ """Return all node guids."""
+ return iter(self._guids)
+
+ def set(
+ self,
+ pred: URI, # FIXME: URI or _schema.Predicate?
+ value: typing.Any,
+ ) -> 'Nodes':
+ """Set predicate *pred* to *value*."""
+ return self.set_from_iterable([(pred, value)])
+
+ def set_from_iterable(
+ self,
+ predicate_values: typing.Iterable[typing.Tuple[URI, typing.Any]], # FIXME: URI or _schema.Predicate?
+ ) -> 'Nodes':
+ """Set mutliple predicate-value pairs at once."""
+ # TODO: Could group predicate_values by predicate to gain some efficiency
+ # TODO: ignore errors on some predicates; For now this could leave residual
+ # data (e.g. some nodes were created, some not).
+ try:
+ # insert triples
+ for pred, value in predicate_values:
+ self.__set(pred, value)
+ # save changes
+ self._backend.commit()
+
+ except (
+ errors.PermissionDeniedError, # tried to set a protected predicate (ns.bsm.t_created)
+ 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
+ ValueError, # multiple values passed to unique predicate
+ ):
+ # revert changes
+ self._backend.rollback()
+ # notify the client
+ raise
+
+ # FIXME: How about other errors? Shouldn't I then rollback as well?!
+
+ return self
+
+ def __set(self, predicate: URI, value: typing.Any):
+ """
+ """
+ # get normalized predicate. Raises KeyError if *pred* not in the schema.
+ pred = self._backend.schema.predicate(predicate)
+
+ # node_type must be a subclass of the predicate's domain
+ node_type = self.node_type
+ if not node_type <= pred.domain:
+ raise errors.ConsistencyError(f'{node_type} must be a subclass of {pred.domain}')
+
+ # check reserved predicates (access controls, metadata, internal structures)
+ # 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):
+ raise errors.PermissionDeniedError(pred)
+
+ # set operation affects all nodes (if possible)
+ guids = set(self.guids)
+
+ # ensure subject node existence; create nodes if need be
+ guids = set(self._ensure_nodes(node_type, guids))
+
+ # check value
+ if isinstance(pred.range, _schema.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))
+
+ # insert literals
+ # TODO: Support passing iterators as values for non-unique predicates
+ self._backend.set(
+ node_type,
+ guids,
+ pred,
+ [value],
+ )
+
+ elif isinstance(pred.range, _schema.Node):
+ # check value type
+ if not isinstance(value, Nodes):
+ raise TypeError(value)
+ # value's node_type must be a subclass of the predicate's range
+ if not value.node_type <= pred.range:
+ raise errors.ConsistencyError(f'{value.node_type} must be a subclass of {pred.range}')
+
+ # check link permissions on source 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))
+
+ # 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))
+
+ # insert node links
+ self._backend.set(
+ node_type,
+ guids,
+ pred,
+ targets,
+ )
+
+ else:
+ raise errors.UnreachableError()
+
+ def _ensure_nodes(self, node_type: _schema.Node, guids: typing.Iterable[URI]):
+ """
+ """
+ # check node existence
+ guids = set(guids)
+ existing = set(self._backend.exists(node_type, guids))
+ # get nodes to be created
+ missing = guids - existing
+ # create nodes if need be
+ if len(missing) > 0:
+ # check which missing nodes can be created
+ 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()])
+ # add permission triples
+ self.__ac.create(node_type, missing)
+ # return available nodes
+ return existing | missing
+
+## EOF ##
diff --git a/bsfs/graph/schema.nt b/bsfs/graph/schema.nt
new file mode 100644
index 0000000..8612681
--- /dev/null
+++ b/bsfs/graph/schema.nt
@@ -0,0 +1,18 @@
+
+# generic prefixes
+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#>
+
+# literals
+xsd:integer rdfs:subClassOf bsfs:Literal .
+
+# predicates
+bsm:t_created rdfs:subClassOf bsfs:Predicate ;
+ rdfs:domain bsfs:Node ;
+ rdfs:range xsd:integer ;
+ bsfs:unique "true"^^xsd:boolean .
+