diff options
author | Matthias Baumgartner <dev@igsor.net> | 2023-03-05 19:25:29 +0100 |
---|---|---|
committer | Matthias Baumgartner <dev@igsor.net> | 2023-03-05 19:25:29 +0100 |
commit | 48b6081d0092e9c5a1b0ad79bdde2e51649bf61a (patch) | |
tree | 634198c34aae3c0306ce30ac7452abd7b53a14e8 | |
parent | 91437ba89d35bf482f3d9671bb99ef2fc69f5985 (diff) | |
parent | e4845c627e97a6d125bf33d9e7a4a8d373d7fc4a (diff) | |
download | bsfs-48b6081d0092e9c5a1b0ad79bdde2e51649bf61a.tar.gz bsfs-48b6081d0092e9c5a1b0ad79bdde2e51649bf61a.tar.bz2 bsfs-48b6081d0092e9c5a1b0ad79bdde2e51649bf61a.zip |
Merge branch 'develop'v0.23.03
97 files changed, 12089 insertions, 1235 deletions
@@ -22,6 +22,7 @@ build/ # doc builds doc/build/ +doc/source/api # doc extra files @@ -76,10 +76,10 @@ max-attributes=7 max-bool-expr=5 # Maximum number of branch for function / method body. -max-branches=15 +max-branches=20 # Maximum number of locals for function / method body. -max-locals=15 +max-locals=20 # Maximum number of parents for a class (see R0901). max-parents=7 @@ -88,10 +88,10 @@ max-parents=7 max-public-methods=20 # Maximum number of return / yield for function / method body. -max-returns=6 +max-returns=15 # Maximum number of statements in function / method body. -max-statements=50 +max-statements=100 # Minimum number of public methods for a class (see R0903). min-public-methods=1 @@ -144,6 +144,19 @@ allow-wildcard-with-all=no logging-format-style=old +[MESSAGES CONTROL] + +# disable similarities check +disable=raw-checker-failed, + bad-inline-option, + locally-disabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-symbolic-message-instead, + duplicate-code + [MISCELLANEOUS] @@ -164,7 +177,7 @@ score=yes [SIMILARITIES] # Minimum lines number of a similarity. -min-similarity-lines=4 +min-similarity-lines=5 [STRING] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fb66c1e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ + +# Changelog + +## 0.23.03 (Initial release) + +### Added + +- File graph protocol + - Graph access and navigation + - Syntactic sugar + - Fetch result shortcuts +- Filter and Fetch Queries + - Syntax trees + - Validation + - Matching +- Infrastructure to Open a storage +- Storage schema +- Backend + - Basic interface + - Sparql triple store: Manage triples via rdflib and sparql. +- Access controls + - Basic interface + - NullAC: A dummy access control mechanism. +- Essential utilities + - URI + - uuid + - namespaces + diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..a06c41c --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include bsfs/graph/schema.nt @@ -1,57 +0,0 @@ - -The Black Star File System -========================== - - -### Developer tools setup - -#### Test coverage (coverage) - -Resources: -* https://coverage.readthedocs.io/en/6.5.0/index.html -* https://nedbatchelder.com/blog/200710/flaws_in_coverage_measurement.html - -Commands: -$ pip install coverage -$ coverage run ; coverage html ; xdg-open .htmlcov/index.html - - - -#### Static code analysis (pylint) - -Resources: -* https://github.com/PyCQA/pylint -* https://pylint.org/ -* https://pylint.pycqa.org/en/latest/user_guide/messages/messages_overview.html#messages-overview - -Commands: -$ pip install pylint -$ pylint bsfs - - - -#### Type analysis (mypy) - -Resources: -* https://github.com/python/mypy -* https://mypy.readthedocs.io/en/stable/ - -Commands: -$ pip install mypy -$ mypy - - - -#### Documentation (sphinx) - -Resources: -* -* - -Commands: -$ pip install ... -$ - - - - diff --git a/README.md b/README.md new file mode 100644 index 0000000..796c198 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ + +# The Black Star File System + +The Black Star File System (BSFS) is a semantic file system, meaning that it organizes files +by association, and can record files, their metadata and content in a structured manner. + + +## Installation + +You can install BSFS via pip: + + $ pip install --extra-index-url https://pip.bsfs.io bsfs + + +## Development + +Set up a virtual environment: + + $ virtualenv env + $ source env/bin/activate + +Install bsfs as editable from the git repository: + + $ git clone https://git.bsfs.io/bsfs.git + $ cd bsfs + $ pip install -e . + +If you want to develop (*dev*), run the tests (*test*), edit the +documentation (*doc*), or build a distributable (*build*), +install bsfs with the respective extras: + + $ pip install -e .[dev,doc,build,test] + +Or, you can manually install the following packages besides BSFS: + + $ pip install coverage mypy pylint + $ pip install sphinx sphinx-copybutton furo + $ pip install build + +To ensure code style discipline, run the following commands: + + $ coverage run ; coverage html ; xdg-open .htmlcov/index.html + $ pylint bsfs + $ mypy + +To build the package, do: + + $ python -m build + +To run only the tests (without coverage), run the following command from the **test folder**: + + $ python -m unittest + +To build the documentation, run the following commands from the **doc folder**: + + $ sphinx-apidoc -f -o source/api ../bsfs/ --module-first -d 1 --separate + $ make html + $ xdg-open build/html/index.html + @@ -1,52 +1,6 @@ -"""BSFS tools. - -Part of the BlackStar filesystem (bsfs) module. -A copy of the license is provided with the project. -Author: Matthias Baumgartner, 2022 -""" -# imports -import argparse -import typing - -# module imports -import bsfs -import bsfs.apps - -# exports -__all__: typing.Sequence[str] = ( - 'main', - ) - -# config -apps = { - 'init' : bsfs.apps.init, - 'migrate' : bsfs.apps.migrate, - } - - -## code ## - -def main(argv): - """Black Star File System maintenance tools.""" - parser = argparse.ArgumentParser(description=main.__doc__, prog='bsfs') - # version - parser.add_argument('--version', action='version', - version='%(prog)s version {}.{}.{}'.format(*bsfs.version_info)) - # application selection - parser.add_argument('app', choices=apps.keys(), - help='Select the application to run.') - # dangling args - parser.add_argument('rest', nargs=argparse.REMAINDER) - # parse - args = parser.parse_args() - # run application - apps[args.app](args.rest) - - -## main ## - +#!/usr/bin/env python3 if __name__ == '__main__': + import bsfs.apps import sys - main(sys.argv[1:]) + bsfs.apps.main(sys.argv[1:]) -## EOF ## diff --git a/bsfs.toml b/bsfs.toml deleted file mode 100644 index 45bf1c9..0000000 --- a/bsfs.toml +++ /dev/null @@ -1,11 +0,0 @@ -[project] -name = "bsfs" -description = "A content aware graph file system." -version = "0.0.1" -license = {text = "BSD 3-Clause License"} -authors = [{name='Matthias Baumgartner', email="dev@igsor.net"}] -dependencies = [ - "rdflib", -] -requires-python = ">=3.7" - diff --git a/bsfs/__init__.py b/bsfs/__init__.py index 079ffaf..cf08d64 100644 --- a/bsfs/__init__.py +++ b/bsfs/__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 collections import typing diff --git a/bsfs/apps/__init__.py b/bsfs/apps/__init__.py index 7efaa87..62dc5b5 100644 --- a/bsfs/apps/__init__.py +++ b/bsfs/apps/__init__.py @@ -1,20 +1,53 @@ -""" +#!/usr/bin/env python3 -Part of the BlackStar filesystem (bsfs) module. -A copy of the license is provided with the project. -Author: Matthias Baumgartner, 2022 -""" # imports +import argparse import typing +# bsfs imports +import bsfs + # inner-module imports from .init import main as init from .migrate import main as migrate # exports __all__: typing.Sequence[str] = ( + 'main', 'init', 'migrate', ) +# config +apps = { + 'init' : init, + 'migrate' : migrate, + } + + +## code ## + +def main(argv=None): + """Black Star File System maintenance tools.""" + parser = argparse.ArgumentParser(description=main.__doc__, prog='bsfs') + # version + parser.add_argument('--version', action='version', + version='%(prog)s version {}.{}.{}'.format(*bsfs.version_info)) # pylint: disable=C0209 + # application selection + parser.add_argument('app', choices=apps.keys(), + help='Select the application to run.') + # dangling args + parser.add_argument('rest', nargs=argparse.REMAINDER) + # parse + args = parser.parse_args(argv) + # run application + apps[args.app](args.rest) + + +## main ## + +if __name__ == '__main__': + import sys + main(sys.argv[1:]) + ## EOF ## diff --git a/bsfs/apps/init.py b/bsfs/apps/init.py index 3e2ef37..9afbdd5 100644 --- a/bsfs/apps/init.py +++ b/bsfs/apps/init.py @@ -1,9 +1,5 @@ -""" +#!/usr/bin/env python3 -Part of the BlackStar filesystem (bsfs) module. -A copy of the license is provided with the project. -Author: Matthias Baumgartner, 2022 -""" # imports import argparse import json @@ -60,9 +56,10 @@ def main(argv): # print config if args.output is not None: with open(args.output, mode='wt', encoding='UTF-8') as ofile: - json.dump(config, ofile) + json.dump(config, ofile, indent=4) else: - json.dump(config, sys.stdout) + json.dump(config, sys.stdout, indent=4) + print('') ## main ## diff --git a/bsfs/apps/migrate.py b/bsfs/apps/migrate.py index 91c1661..34ea2e7 100644 --- a/bsfs/apps/migrate.py +++ b/bsfs/apps/migrate.py @@ -1,9 +1,5 @@ -""" +#!/usr/bin/env python3 -Part of the BlackStar filesystem (bsfs) module. -A copy of the license is provided with the project. -Author: Matthias Baumgartner, 2022 -""" # imports import argparse import json @@ -42,15 +38,15 @@ def main(argv): graph = bsfs.Open(config) # initialize schema - schema = bsfs.schema.Schema.Empty() + schema = bsfs.schema.Schema() if len(args.schema) == 0: # assemble schema from standard input - schema = schema + bsfs.schema.Schema.from_string(sys.stdin.read()) + schema = schema + bsfs.schema.from_string(sys.stdin.read()) else: # assemble schema from input files for pth in args.schema: with open(pth, mode='rt', encoding='UTF-8') as ifile: - schema = schema + bsfs.schema.Schema.from_string(ifile.read()) + schema = schema + bsfs.schema.from_string(ifile.read()) # migrate schema graph.migrate(schema, not args.remove) diff --git a/bsfs/front/__init__.py b/bsfs/front/__init__.py index 92886ab..cedcd7f 100644 --- a/bsfs/front/__init__.py +++ b/bsfs/front/__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/front/bsfs.py b/bsfs/front/bsfs.py index 968b3f5..f437212 100644 --- a/bsfs/front/bsfs.py +++ b/bsfs/front/bsfs.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/front/builder.py b/bsfs/front/builder.py index 73f1703..b1d488b 100644 --- a/bsfs/front/builder.py +++ b/bsfs/front/builder.py @@ -1,14 +1,9 @@ -""" -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.graph import Graph +from bsfs.graph import Graph, ac from bsfs.triple_store import TripleStoreBase, SparqlStore from bsfs.utils import URI, errors @@ -68,8 +63,10 @@ def build_graph(cfg: typing.Any) -> Graph: if 'backend' not in args: raise errors.ConfigError('required argument "backend" is not provided') backend = build_backend(args['backend']) + # build access controls + access_controls = ac.NullAC(backend, user) # build and return graph cls = _graph_classes[name] - return cls(backend, user) + return cls(backend, access_controls) ## EOF ## 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 ## diff --git a/bsfs/namespace/__init__.py b/bsfs/namespace/__init__.py index 98d472f..76f39a2 100644 --- a/bsfs/namespace/__init__.py +++ b/bsfs/namespace/__init__.py @@ -1,19 +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 typing # inner-module imports from . import predefined as ns -from .namespace import ClosedNamespace, Namespace +from .namespace import Namespace # exports __all__: typing.Sequence[str] = ( - 'ClosedNamespace', 'Namespace', 'ns', ) diff --git a/bsfs/namespace/namespace.py b/bsfs/namespace/namespace.py index f652dcd..b388f53 100644 --- a/bsfs/namespace/namespace.py +++ b/bsfs/namespace/namespace.py @@ -1,104 +1,54 @@ -""" -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.utils import URI, typename +from bsfs.utils import URI # exports __all__: typing.Sequence[str] = ( - 'ClosedNamespace', 'Namespace', + 'FinalNamespace', ) ## code ## -class Namespace(): - """A namespace consists of a common prefix that is used in a set of URIs. - Note that the prefix must include the separator between - path and fragment (typically a '#' or a '/'). - """ - - # namespace prefix. - prefix: URI - - # fragment separator. - fsep: str - - # path separator. - psep: str - - def __init__(self, prefix: URI, fsep: str = '#', psep: str = '/'): - # ensure prefix type - prefix = URI(prefix) - # truncate fragment separator - while prefix.endswith(fsep): - prefix = URI(prefix[:-1]) - # truncate path separator - while prefix.endswith(psep): - prefix = URI(prefix[:-1]) - # store members - self.prefix = prefix - self.fsep = fsep - self.psep = psep - - def __eq__(self, other: typing.Any) -> bool: - return isinstance(other, type(self)) \ - and self.prefix == other.prefix \ - and self.fsep == other.fsep \ - and self.psep == other.psep +class Namespace(URI): + """The Namespace allows you to incrementally append path segments to an URI. - def __hash__(self) -> int: - return hash((type(self), self.prefix, self.fsep, self.psep)) + Segments are separated by `Namespace.sep` ('/'). + The `__call__` method signals that the URI is complete until the query part. - def __str__(self) -> str: - return f'{typename(self)}({self.prefix})' - - def __repr__(self) -> str: - return f'{typename(self)}({self.prefix}, {self.fsep}, {self.psep})' - - def __getattr__(self, fragment: str) -> URI: - """Return prefix + fragment.""" - return URI(self.prefix + self.fsep + fragment) - - def __getitem__(self, fragment: str) -> URI: - """Alias for getattr(self, fragment).""" - return self.__getattr__(fragment) + """ - def __add__(self, value: typing.Any) -> 'Namespace': - """Concatenate another namespace to this one.""" - if not isinstance(value, str): - return NotImplemented - return Namespace(self.prefix + self.psep + value, self.fsep, self.psep) + # path separator + sep: str = '/' + def __getattr__(self, query: str) -> 'Namespace': + """Append the *query* to the current value and return as Namespace.""" + return Namespace(self + self.sep + query) -class ClosedNamespace(Namespace): - """Namespace that covers a restricted set of URIs.""" + def __call__(self, sep: str = '#') -> 'FinalNamespace': + """Finalize the namespace.""" + return FinalNamespace(self, sep) - # set of permissible fragments. - fragments: typing.Set[str] - def __init__(self, prefix: URI, *args: str, fsep: str = '#', psep: str = '/'): - super().__init__(prefix, fsep, psep) - self.fragments = set(args) +# FIXME: Integrate FinalNamespace into Namespace? Do we need to have both? +class FinalNamespace(URI): + """The FinalNamespace allows you to append a fragment to an URI.""" - def __eq__(self, other: typing.Any) -> bool: - return super().__eq__(other) and self.fragments == other.fragments + # fragment separator + sep: str - def __hash__(self) -> int: - return hash((type(self), self.prefix, tuple(sorted(self.fragments)))) + def __new__(cls, value: str, sep: str = '#'): + inst = URI.__new__(cls, value) + inst.sep = sep + return inst def __getattr__(self, fragment: str) -> URI: - """Return prefix + fragment or raise a KeyError if the fragment is not part of this namespace.""" - if fragment not in self.fragments: - raise KeyError(f'{fragment} is not a valid fragment of namespace {self.prefix}') - return super().__getattr__(fragment) + """Append the *fragment* to the current value and return as URI.""" + return URI(self + self.sep + fragment) ## EOF ## diff --git a/bsfs/namespace/predefined.py b/bsfs/namespace/predefined.py index cd48a46..8b60d39 100644 --- a/bsfs/namespace/predefined.py +++ b/bsfs/namespace/predefined.py @@ -1,35 +1,29 @@ -""" -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.utils import URI - # inner-module imports -from . import namespace +from .namespace import Namespace, FinalNamespace # essential bsfs namespaces -bsfs: namespace.Namespace = namespace.Namespace(URI('http://bsfs.ai/schema'), fsep='/') - +bsfs = Namespace('https://schema.bsfs.io/core') # additional bsfs namespaces -bse: namespace.Namespace = namespace.Namespace(URI('http://bsfs.ai/schema/Entity')) -bsm: namespace.Namespace = namespace.Namespace(URI('http://bsfs.ai/schema/Meta')) +bsd = bsfs.distance() +bsl = bsfs.Literal +bsn = bsfs.Node() # generic namespaces -rdf: namespace.Namespace = namespace.Namespace(URI('http://www.w3.org/1999/02/22-rdf-syntax-ns')) -rdfs: namespace.Namespace = namespace.Namespace(URI('http://www.w3.org/2000/01/rdf-schema')) -schema: namespace.Namespace = namespace.Namespace(URI('http://schema.org'), fsep='/') -xsd: namespace.Namespace = namespace.Namespace(URI('http://www.w3.org/2001/XMLSchema')) +rdf = FinalNamespace('http://www.w3.org/1999/02/22-rdf-syntax-ns') +rdfs = FinalNamespace('http://www.w3.org/2000/01/rdf-schema') +xsd = FinalNamespace('http://www.w3.org/2001/XMLSchema') +schema = FinalNamespace('http://schema.org', sep='/') +# exports __all__: typing.Sequence[str] = ( - 'bse', + 'bsd', 'bsfs', - 'bsm', + 'bsl', + 'bsn', 'rdf', 'rdfs', 'schema', diff --git a/bsfs/query/__init__.py b/bsfs/query/__init__.py new file mode 100644 index 0000000..58ff03a --- /dev/null +++ b/bsfs/query/__init__.py @@ -0,0 +1,15 @@ + +# imports +import typing + +# inner-module imports +from . import ast +from . import validator as validate + +# exports +__all__: typing.Sequence[str] = ( + 'ast', + 'validate', + ) + +## EOF ## diff --git a/bsfs/query/ast/__init__.py b/bsfs/query/ast/__init__.py new file mode 100644 index 0000000..bceaac0 --- /dev/null +++ b/bsfs/query/ast/__init__.py @@ -0,0 +1,23 @@ +"""Query AST components. + +The query AST consists of a Filter and a Fetch syntax trees. + +Classes beginning with an underscore (_) represent internal type hierarchies +and should not be used for parsing. Note that the AST structures do not +(and cannot) check semantic validity or consistency with a given schema. + +""" +# imports +import typing + +# inner-module imports +from . import fetch +from . import filter_ as filter # pylint: disable=redefined-builtin + +# exports +__all__: typing.Sequence[str] = ( + 'fetch', + 'filter', + ) + +## EOF ## diff --git a/bsfs/query/ast/fetch.py b/bsfs/query/ast/fetch.py new file mode 100644 index 0000000..66d94e1 --- /dev/null +++ b/bsfs/query/ast/fetch.py @@ -0,0 +1,169 @@ + +# imports +from collections import abc +import typing + +# bsfs imports +from bsfs.utils import URI, typename, normalize_args + +# exports +__all__ : typing.Sequence[str] = ( + 'All', + 'Fetch', + 'FetchExpression', + 'Node', + 'This', + 'Value', + ) + + +## code ## + +class FetchExpression(abc.Hashable): + """Generic Fetch expression.""" + + def __repr__(self) -> str: + """Return the expressions's string representation.""" + return f'{typename(self)}()' + + def __hash__(self) -> int: + """Return the expression's integer representation.""" + return hash(type(self)) + + def __eq__(self, other: typing.Any) -> bool: + """Return True if *self* and *other* are equivalent.""" + return isinstance(other, type(self)) + + +class All(FetchExpression): + """Fetch all child expressions.""" + + # child expressions. + expr: typing.Set[FetchExpression] + + def __init__(self, *expr): + # unpack child expressions + unfolded = set(normalize_args(*expr)) + # check child expressions + if len(unfolded) == 0: + raise AttributeError('expected at least one expression, found none') + if not all(isinstance(itm, FetchExpression) for itm in unfolded): + raise TypeError(expr) + # initialize + super().__init__() + # assign members + self.expr = unfolded + + def __iter__(self) -> typing.Iterator[FetchExpression]: + return iter(self.expr) + + def __len__(self) -> int: + return len(self.expr) + + def __repr__(self) -> str: + return f'{typename(self)}({self.expr})' + + def __hash__(self) -> int: + return hash((super().__hash__(), tuple(sorted(self.expr, key=repr)))) + + def __eq__(self, other: typing.Any) -> bool: + return super().__eq__(other) and self.expr == other.expr + + +class _Branch(FetchExpression): + """Branch along a predicate.""" + + # FIXME: Use a Predicate (like in ast.filter) so that we can also reverse them! + + # predicate to follow. + predicate: URI + + def __init__(self, predicate: URI): + if not isinstance(predicate, URI): + raise TypeError(predicate) + self.predicate = predicate + + def __repr__(self) -> str: + return f'{typename(self)}({self.predicate})' + + def __hash__(self) -> int: + return hash((super().__hash__(), self.predicate)) + + def __eq__(self, other: typing.Any) -> bool: + return super().__eq__(other) and self.predicate == other.predicate + + +class Fetch(_Branch): + """Follow a predicate before evaluating a child epxression.""" + + # child expression. + expr: FetchExpression + + def __init__(self, predicate: URI, expr: FetchExpression): + # check child expressions + if not isinstance(expr, FetchExpression): + raise TypeError(expr) + # initialize + super().__init__(predicate) + # assign members + self.expr = expr + + def __repr__(self) -> str: + return f'{typename(self)}({self.predicate}, {self.expr})' + + def __hash__(self) -> int: + return hash((super().__hash__(), self.expr)) + + def __eq__(self, other: typing.Any) -> bool: + return super().__eq__(other) and self.expr == other.expr + + +class _Named(_Branch): + """Fetch a (named) symbol at a predicate.""" + + # symbol name. + name: str + + def __init__(self, predicate: URI, name: str): + super().__init__(predicate) + self.name = str(name) + + def __repr__(self) -> str: + return f'{typename(self)}({self.predicate}, {self.name})' + + def __hash__(self) -> int: + return hash((super().__hash__(), self.name)) + + def __eq__(self, other: typing.Any) -> bool: + return super().__eq__(other) and self.name == other.name + + +class Node(_Named): # pylint: disable=too-few-public-methods + """Fetch a Node at a predicate.""" + # FIXME: Is this actually needed? + + +class Value(_Named): # pylint: disable=too-few-public-methods + """Fetch a Literal at a predicate.""" + + +class This(FetchExpression): + """Fetch the current Node.""" + + # symbol name. + name: str + + def __init__(self, name: str): + super().__init__() + self.name = str(name) + + def __repr__(self) -> str: + return f'{typename(self)}({self.name})' + + def __hash__(self) -> int: + return hash((super().__hash__(), self.name)) + + def __eq__(self, other: typing.Any) -> bool: + return super().__eq__(other) and self.name == other.name + +## EOF ## diff --git a/bsfs/query/ast/filter_.py b/bsfs/query/ast/filter_.py new file mode 100644 index 0000000..610fdb4 --- /dev/null +++ b/bsfs/query/ast/filter_.py @@ -0,0 +1,516 @@ +"""Filter AST. + +Note that it is easily possible to construct an AST that is inconsistent with +a given schema. Furthermore, it is possible to construct a semantically invalid +AST which that cannot be parsed correctly or includes contradicting statements. +The AST nodes do not (and cannot) check such issues. + +For example, consider the following AST: + +>>> Any(ns.bse.collection, +... And( +... Equals('hello'), +... Is('hello world'), +... Any(ns.bse.tag, Equals('world')), +... Any(ns.bst.label, Equals('world')), +... All(ns.bst.label, Not(Equals('world'))), +... ) +... ) + +This AST has multiple issues that are not verified upon its creation: +* A condition on a non-literal. +* A Filter on a literal. +* Conditions exclude each other +* The predicate along the branch have incompatible domains and ranges. + +""" +# imports +from collections import abc +import typing + +# bsfs imports +from bsfs.utils import URI, typename, normalize_args + +# exports +__all__ : typing.Sequence[str] = ( + # base classes + 'FilterExpression', + 'PredicateExpression', + # predicate expressions + 'OneOf', + 'Predicate', + # branching + 'All', + 'Any', + # aggregators + 'And', + 'Or', + # value matchers + 'Equals', + 'Substring', + 'EndsWith', + 'StartsWith', + # range matchers + 'GreaterThan', + 'LessThan', + # misc + 'Has', + 'Is', + 'Not', + ) + + +## code ## + +# pylint: disable=too-few-public-methods # Many expressions use mostly magic methods + +class _Expression(abc.Hashable): + def __repr__(self) -> str: + """Return the expressions's string representation.""" + return f'{typename(self)}()' + + def __hash__(self) -> int: + """Return the expression's integer representation.""" + return hash(type(self)) + + def __eq__(self, other: typing.Any) -> bool: + """Return True if *self* and *other* are equivalent.""" + return isinstance(other, type(self)) + + +class FilterExpression(_Expression): + """Generic Filter expression.""" + + +class PredicateExpression(_Expression): + """Generic Predicate expression.""" + + +class _Branch(FilterExpression): + """Branch the filter along a predicate.""" + + # predicate to follow. + predicate: PredicateExpression + + # child expression to evaluate. + expr: FilterExpression + + def __init__( + self, + predicate: typing.Union[PredicateExpression, URI], + expr: FilterExpression, + ): + # process predicate argument + if isinstance(predicate, URI): + predicate = Predicate(predicate) + elif not isinstance(predicate, PredicateExpression): + raise TypeError(predicate) + # process expression argument + if not isinstance(expr, FilterExpression): + raise TypeError(expr) + # assign members + self.predicate = predicate + self.expr = expr + + def __repr__(self) -> str: + return f'{typename(self)}({self.predicate}, {self.expr})' + + def __hash__(self) -> int: + return hash((super().__hash__(), self.predicate, self.expr)) + + def __eq__(self, other) -> bool: + return super().__eq__(other) \ + and self.predicate == other.predicate \ + and self.expr == other.expr + +class Any(_Branch): + """Any (and at least one) triple matches.""" + + +class All(_Branch): + """All (and at least one) triples match.""" + + +class _Agg(FilterExpression, abc.Collection): + """Combine multiple expressions.""" + + # child expressions + expr: typing.Set[FilterExpression] + + def __init__( + self, + *expr: typing.Union[FilterExpression, + typing.Iterable[FilterExpression], + typing.Iterator[FilterExpression]] + ): + # unfold arguments + unfolded = set(normalize_args(*expr)) + # check type + if not all(isinstance(e, FilterExpression) for e in unfolded): + raise TypeError(expr) + # FIXME: Require at least one child expression? + # assign member + self.expr = unfolded + + def __contains__(self, expr: typing.Any) -> bool: + """Return True if *expr* is among the child expressions.""" + return expr in self.expr + + def __iter__(self) -> typing.Iterator[FilterExpression]: + """Iterator over child expressions.""" + return iter(self.expr) + + def __len__(self) -> int: + """Number of child expressions.""" + return len(self.expr) + + def __repr__(self) -> str: + return f'{typename(self)}({self.expr})' + + def __hash__(self) -> int: + return hash((super().__hash__(), tuple(sorted(self.expr, key=repr)))) + + def __eq__(self, other) -> bool: + return super().__eq__(other) and self.expr == other.expr + + +class And(_Agg): + """All conditions match.""" + + +class Or(_Agg): + """At least one condition matches.""" + + +class Not(FilterExpression): + """Invert a statement.""" + + # child expression + expr: FilterExpression + + def __init__(self, expr: FilterExpression): + # check argument + if not isinstance(expr, FilterExpression): + raise TypeError(expr) + # assign member + self.expr = expr + + def __repr__(self) -> str: + return f'{typename(self)}({self.expr})' + + def __hash__(self) -> int: + return hash((super().__hash__(), self.expr)) + + def __eq__(self, other: typing.Any) -> bool: + return super().__eq__(other) and self.expr == other.expr + + +class Has(FilterExpression): + """Has predicate N times""" + + # predicate to follow. + predicate: PredicateExpression + + # target count + count: FilterExpression + + def __init__( + self, + predicate: typing.Union[PredicateExpression, URI], + count: typing.Optional[typing.Union[FilterExpression, int]] = None, + ): + # check predicate + if isinstance(predicate, URI): + predicate = Predicate(predicate) + elif not isinstance(predicate, PredicateExpression): + raise TypeError(predicate) + # check count + if count is None: + count = GreaterThan(1, strict=False) + elif isinstance(count, int): + count = Equals(count) + elif not isinstance(count, FilterExpression): + raise TypeError(count) + # assign members + self.predicate = predicate + self.count = count + + def __repr__(self) -> str: + return f'{typename(self)}({self.predicate}, {self.count})' + + def __hash__(self) -> int: + return hash((super().__hash__(), self.predicate, self.count)) + + def __eq__(self, other) -> bool: + return super().__eq__(other) \ + and self.predicate == other.predicate \ + and self.count == other.count + + +class _Value(FilterExpression): + """Matches some value.""" + + # target value. + value: typing.Any + + def __init__(self, value: typing.Any): + self.value = value + + def __repr__(self) -> str: + return f'{typename(self)}({self.value})' + + def __hash__(self) -> int: + return hash((super().__hash__(), self.value)) + + def __eq__(self, other) -> bool: + return super().__eq__(other) and self.value == other.value + + +class Is(_Value): + """Match the URI of a node.""" + + +class Equals(_Value): + """Value matches exactly. + NOTE: Value must correspond to literal type. + """ + + +class Substring(_Value): + """Value matches a substring + NOTE: value must be a string. + """ + + +class StartsWith(_Value): + """Value begins with a given string.""" + + +class EndsWith(_Value): + """Value ends with a given string.""" + + +class Distance(FilterExpression): + """Distance to a reference is (strictly) below a threshold. Assumes a Feature literal.""" + + # FIXME: + # (a) pass a node/predicate as anchor instead of a value. + # Then we don't need to materialize the reference. + # (b) pass a FilterExpression (_Bounded) instead of a threshold. + # Then, we could also query values greater than a threshold. + + # reference value. + reference: typing.Any + + # distance threshold. + threshold: float + + # closed (True) or open (False) bound. + strict: bool + + def __init__( + self, + reference: typing.Any, + threshold: float, + strict: bool = False, + ): + self.reference = reference + self.threshold = float(threshold) + self.strict = bool(strict) + + def __repr__(self) -> str: + return f'{typename(self)}({self.reference}, {self.threshold}, {self.strict})' + + def __hash__(self) -> int: + return hash((super().__hash__(), tuple(self.reference), self.threshold, self.strict)) + + def __eq__(self, other) -> bool: + return super().__eq__(other) \ + and self.reference == other.reference \ + and self.threshold == other.threshold \ + and self.strict == other.strict + + +class _Bounded(FilterExpression): + """Value is bounded by a threshold. Assumes a Number literal.""" + + # bound. + threshold: float + + # closed (True) or open (False) bound. + strict: bool + + def __init__( + self, + threshold: float, + strict: bool = True, + ): + self.threshold = float(threshold) + self.strict = bool(strict) + + def __repr__(self) -> str: + return f'{typename(self)}({self.threshold}, {self.strict})' + + def __hash__(self) -> int: + return hash((super().__hash__(), self.threshold, self.strict)) + + def __eq__(self, other) -> bool: + return super().__eq__(other) \ + and self.threshold == other.threshold \ + and self.strict == other.strict + + + +class LessThan(_Bounded): + """Value is (strictly) smaller than threshold. Assumes a Number literal.""" + + +class GreaterThan(_Bounded): + """Value is (strictly) larger than threshold. Assumes a Number literal.""" + + +class Predicate(PredicateExpression): + """A single predicate.""" + + # predicate URI + predicate: URI + + # reverse the predicate's direction + reverse: bool + + def __init__( + self, + predicate: URI, + reverse: typing.Optional[bool] = False, + ): + # check arguments + if not isinstance(predicate, URI): + raise TypeError(predicate) + # assign members + self.predicate = predicate + self.reverse = bool(reverse) + + def __repr__(self) -> str: + return f'{typename(self)}({self.predicate}, {self.reverse})' + + def __hash__(self) -> int: + return hash((super().__hash__(), self.predicate, self.reverse)) + + def __eq__(self, other) -> bool: + return super().__eq__(other) \ + and self.predicate == other.predicate \ + and self.reverse == other.reverse + + +class OneOf(PredicateExpression, abc.Collection): + """A set of predicate alternatives. + + The predicates' domains must be ascendants or descendants of each other. + The overall domain is the most specific one. + + The predicate's domains must be ascendants or descendants of each other. + The overall range is the most generic one. + """ + + # predicate alternatives + expr: typing.Set[PredicateExpression] + + def __init__(self, *expr: typing.Union[PredicateExpression, URI]): + # unfold arguments + unfolded = set(normalize_args(*expr)) # type: ignore [arg-type] # this is getting too complex... + # check arguments + if len(unfolded) == 0: + raise AttributeError('expected at least one expression, found none') + # ensure PredicateExpression + unfolded = {Predicate(e) if isinstance(e, URI) else e for e in unfolded} + # check type + if not all(isinstance(e, PredicateExpression) for e in unfolded): + raise TypeError(expr) + # assign member + self.expr = unfolded + + def __contains__(self, expr: typing.Any) -> bool: + """Return True if *expr* is among the child expressions.""" + return expr in self.expr + + def __iter__(self) -> typing.Iterator[PredicateExpression]: + """Iterator over child expressions.""" + return iter(self.expr) + + def __len__(self) -> int: + """Number of child expressions.""" + return len(self.expr) + + def __repr__(self) -> str: + return f'{typename(self)}({self.expr})' + + def __hash__(self) -> int: + return hash((super().__hash__(), tuple(sorted(self.expr, key=repr)))) + + def __eq__(self, other) -> bool: + return super().__eq__(other) and self.expr == other.expr + + +# Helpers +# invalid-name is disabled since they explicitly mimic an expression + +def IsIn(*values) -> FilterExpression: # pylint: disable=invalid-name + """Match any of the given URIs.""" + args = normalize_args(*values) + if len(args) == 0: + raise AttributeError('expected at least one value, found none') + if len(args) == 1: + return Is(args[0]) + return Or(Is(value) for value in args) + +def IsNotIn(*values) -> FilterExpression: # pylint: disable=invalid-name + """Match none of the given URIs.""" + return Not(IsIn(*values)) + + +def Between( # pylint: disable=invalid-name + lo: float = float('-inf'), + hi: float = float('inf'), + lo_strict: bool = True, + hi_strict: bool = True, + ) -> FilterExpression : + """Match numerical values between *lo* and *hi*. Include bounds if strict is False.""" + if abs(lo) == hi == float('inf'): + raise ValueError('range cannot be INF on both sides') + if lo > hi: + raise ValueError(f'lower bound ({lo}) cannot be less than upper bound ({hi})') + if lo == hi and not lo_strict and not hi_strict: + return Equals(lo) + if lo == hi: # either bound is strict + raise ValueError('bounds cannot be equal when either is strict') + if lo != float('-inf') and hi != float('inf'): + return And(GreaterThan(lo, lo_strict), LessThan(hi, hi_strict)) + if lo != float('-inf'): + return GreaterThan(lo, lo_strict) + # hi != float('inf'): + return LessThan(hi, hi_strict) + + +def Includes(*values, approx: bool = False) -> FilterExpression: # pylint: disable=invalid-name + """Match any of the given *values*. Uses `Substring` if *approx* is set.""" + args = normalize_args(*values) + cls = Substring if approx else Equals + if len(args) == 0: + raise AttributeError('expected at least one value, found none') + if len(args) == 1: + return cls(args[0]) + return Or(cls(v) for v in args) + + +def Excludes(*values, approx: bool = False) -> FilterExpression: # pylint: disable=invalid-name + """Match none of the given *values*. Uses `Substring` if *approx* is set.""" + args = normalize_args(*values) + cls = Substring if approx else Equals + if len(args) == 0: + raise AttributeError('expected at least one value, found none') + if len(args) == 1: + return Not(cls(args[0])) + return Not(Or(cls(v) for v in args)) + + +## EOF ## diff --git a/bsfs/query/matcher.py b/bsfs/query/matcher.py new file mode 100644 index 0000000..17c9c8e --- /dev/null +++ b/bsfs/query/matcher.py @@ -0,0 +1,361 @@ + +# imports +from collections import defaultdict +from itertools import product +from time import time +import random +import threading +import typing + +# external imports +from hopcroftkarp import HopcroftKarp + +# bsfs imports +from bsfs.utils import errors, typename + +# inner-module imports +from . import ast + +# exports +__all__ : typing.Sequence[str] = ( + 'Filter', + ) + + +## code ## + +class Any(ast.filter.FilterExpression, ast.filter.PredicateExpression): + """Match any ast class. + + Note that Any instances are unique, i.e. they do not compare, and + can hence be repeated in a set: + >>> Any() == Any() + False + >>> len({Any(), Any(), Any(), Any()}) + 4 + + """ + + # unique instance id + _uid: typing.Tuple[int, int, float, float] + + def __init__(self): + self._uid = ( + id(self), + id(threading.current_thread()), + time(), + random.random(), + ) + + def __eq__(self, other: typing.Any): + return super().__eq__(other) and self._uid == other._uid + + def __hash__(self): + return hash((super().__hash__(), self._uid)) + + +class Rest(ast.filter.FilterExpression, ast.filter.PredicateExpression): + """Match the leftovers in a set of items to be compared. + + Rest can be used in junction with aggregating expressions such as ast.filter.And, + ast.filter.Or, ast.filter.OneOf. It controls childs expressions that were not yet + consumed by other matching rules. Rest may match to only a specific expression. + The expresssion defaults to Any(). + + For example, the following to ast structures would match since Rest + allows an arbitrary repetition of ast.filter.Equals statements. + + >>> And(Equals('hello'), Equals('world'), Equals('foobar')) + >>> And(Equals('world'), Rest(Partial(Equals))) + + """ + + # child expression for the Rest. + expr: typing.Union[ast.filter.FilterExpression, ast.filter.PredicateExpression] + + def __init__( + self, + expr: typing.Optional[typing.Union[ast.filter.FilterExpression, ast.filter.PredicateExpression]] = None, + ): + if expr is None: + expr = Any() + self.expr = expr + + def __repr__(self) -> str: + return f'{typename(self)}({self.expr})' + + def __hash__(self) -> int: + return hash((super().__hash__(), self.expr)) + + def __eq__(self, other: typing.Any) -> bool: + return super().__eq__(other) and self.expr == other.expr + + +class Partial(ast.filter.FilterExpression, ast.filter.PredicateExpression): + """Match a partially defined ast expression. + + Literal values might be irrelevant or unknown when comparing two ast + structures. Partial allows to constrain the matcher to a certain + ast class, while leaving some of its members unspecified. + + Pass the class (not instance) and its members as keyword arguments + to Partial. Note that the arguments are not validated. + + For example, the following instance matches any ast.filter.Equals, + irrespective of its value: + + >>> Partial(ast.filter.Equals) + + Likewise, the following instance matches any ast.filter.LessThan + that has a strict bounds, but makes no claim about the threshold: + + >>> Partial(ast.filter.LessThan, strict=False) + + """ + + # target node type. + node: typing.Type + + # node construction args. + kwargs: typing.Dict[str, typing.Any] + + def __init__( + self, + node: typing.Type, + **kwargs, + ): + self.node = node + self.kwargs = kwargs + + def __repr__(self) -> str: + return f'{typename(self)}({self.node.__name__}, {self.kwargs})' + + def __hash__(self) -> int: + kwargs = tuple((key, self.kwargs[key]) for key in sorted(self.kwargs)) + return hash((super().__hash__(), self.node, kwargs)) + + def __eq__(self, other: typing.Any) -> bool: + return super().__eq__(other) \ + and self.node == other.node \ + and self.kwargs == other.kwargs + + def match( + self, + name: str, + value: typing.Any, + ) -> bool: + """Return True if *name* is unspecified or matches *value*.""" + return name not in self.kwargs or self.kwargs[name] == value + + +T_ITEM_TYPE = typing.TypeVar('T_ITEM_TYPE') # pylint: disable=invalid-name + +def _set_matcher( + query: typing.Collection[T_ITEM_TYPE], + reference: typing.Collection[T_ITEM_TYPE], + cmp: typing.Callable[[T_ITEM_TYPE, T_ITEM_TYPE], bool], + ) -> bool: + """Compare two sets of child expressions. + + This check has a best-case complexity of O(|N|**2) and worst-case + complexity of O(|N|**3), with N the number of child expressions. + """ + # get reference items + r_items = list(reference) + # deal with Rest + r_rest = {itm for itm in r_items if isinstance(itm, Rest)} + if len(r_rest) > 1: + raise errors.BackendError(f'there must be at most one Rest instance per set, found {len(r_rest)}') + if len(r_rest) == 1: + # replace Rest by filling the reference up with rest's expression + # NOTE: convert r_items to list so that items can be repeated + expr = next(iter(r_rest)).expr # type: ignore [attr-defined] + r_items = [itm for itm in r_items if not isinstance(itm, Rest)] + r_items += [expr for _ in range(len(query) - len(r_items))] # type: ignore [misc] + # sanity check: cannot match if the item sizes differ: + # either a reference item is unmatched (len(r_items) > len(query)) + # or a query item is unmatched (len(r_items) < len(query)) + if len(query) != len(r_items): + return False + + # To have a positive match between the query and the reference, + # each query expr has to match any reference expr. + # However, each reference expr can only be "consumed" once even + # if it matches multiple query exprs (e.g., the Any expression matches + # every query expr). + # This is a bipartide matching problem (Hall's marriage problem) + # and the Hopcroft-Karp-Karzanov algorithm finds a maximum + # matching. While there might be multiple maximum matchings, + # we only need to know whether (at least) one complete matching + # exists. The hopcroftkarp module provides this functionality. + # The HKK algorithm has worst-case complexity of O(|N|**2 * sqrt(|N|)) + # and we also need to compare expressions pairwise, hence O(|N|**2). + num_items = len(r_items) + graph = defaultdict(set) + # build the bipartide graph as {lhs: {rhs}, ...} + # lhs and rhs must be disjoint identifiers. + for (ridx, ref), (nidx, node) in product(enumerate(r_items), enumerate(query)): + # add edges for equal expressions + if cmp(node, ref): + graph[ridx].add(num_items + nidx) + + # maximum_matching returns the matches for all nodes in the graph + # ({ref_itm: node_itm}), hence a complete matching's size is + # the number of reference's child expressions. + return len(HopcroftKarp(graph).maximum_matching(keys_only=True)) == num_items + + +class Filter(): + """Compare a bsfs.query.ast.filter` query's structure to a reference ast. + + The reference ast may include `Rest`, `Partial`, or `Any` to account for irrelevant + or unknown ast pieces. + + This is only a structural comparison, not a semantic one. For example, the + two following queries are semantically identical, but structurally different, + and would therefore not match: + + >>> ast.filter.OneOf(ast.filter.Predicate(ns.bse.name)) + >>> ast.filter.Predicate(ns.bse.name) + + """ + + def __call__(self, query: ast.filter.FilterExpression, reference: ast.filter.FilterExpression) -> bool: + """Compare a *query* to a *reference* ast structure. + Return True if both are structurally equivalent. + """ + if not isinstance(query, ast.filter.FilterExpression): + raise errors.BackendError(f'expected filter expression, found {query}') + if not isinstance(reference, ast.filter.FilterExpression): + raise errors.BackendError(f'expected filter expression, found {reference}') + return self._parse_filter_expression(query, reference) + + def _parse_filter_expression( + self, + node: ast.filter.FilterExpression, + reference: ast.filter.FilterExpression, + ) -> bool: + """Route *node* to the handler of the respective FilterExpression subclass.""" + # generic checks: reference type must be Any or match node type + if isinstance(reference, Any): + return True + # node-specific checks + if isinstance(node, ast.filter.Not): + return self._not(node, reference) + if isinstance(node, ast.filter.Has): + return self._has(node, reference) + if isinstance(node, ast.filter.Distance): + return self._distance(node, reference) + if isinstance(node, (ast.filter.Any, ast.filter.All)): + return self._branch(node, reference) + if isinstance(node, (ast.filter.And, ast.filter.Or)): + return self._agg(node, reference) + if isinstance(node, (ast.filter.Is, ast.filter.Equals, ast.filter.Substring, + ast.filter.StartsWith, ast.filter.EndsWith)): + return self._value(node, reference) + if isinstance(node, (ast.filter.LessThan, ast.filter.GreaterThan)): + return self._bounded(node, reference) + # invalid node + raise errors.BackendError(f'expected filter expression, found {node}') + + def _parse_predicate_expression( + self, + node: ast.filter.PredicateExpression, + reference: ast.filter.PredicateExpression, + ) -> bool: + """Route *node* to the handler of the respective PredicateExpression subclass.""" + if isinstance(reference, Any): + return True + if isinstance(node, ast.filter.Predicate): + return self._predicate(node, reference) + if isinstance(node, ast.filter.OneOf): + return self._one_of(node, reference) + # invalid node + raise errors.BackendError(f'expected predicate expression, found {node}') + + def _one_of(self, node: ast.filter.OneOf, reference: ast.filter.PredicateExpression) -> bool: + if not isinstance(reference, type(node)): + return False + return _set_matcher(node, reference, self._parse_predicate_expression) + + def _predicate(self, node: ast.filter.Predicate, reference: ast.filter.PredicateExpression) -> bool: + if not isinstance(reference, (Partial, type(node))): + return False + # partial check + if isinstance(reference, Partial): + if not isinstance(node, reference.node): + return False + return reference.match('predicate', node.predicate) \ + and reference.match('reverse', node.reverse) + # full check + return node.predicate == reference.predicate \ + and node.reverse == reference.reverse + + def _branch(self, + node: typing.Union[ast.filter.Any, ast.filter.All], + reference: ast.filter.FilterExpression, + ) -> bool: + if not isinstance(reference, type(node)): + return False + if not self._parse_predicate_expression(node.predicate, reference.predicate): # type: ignore [attr-defined] + return False + if not self._parse_filter_expression(node.expr, reference.expr): # type: ignore [attr-defined] + return False + return True + + def _agg(self, node: typing.Union[ast.filter.And, ast.filter.Or], reference: ast.filter.FilterExpression) -> bool: + if not isinstance(reference, type(node)): + return False + return _set_matcher(node, reference, self._parse_filter_expression) # type: ignore [arg-type] + + def _not(self, node: ast.filter.Not, reference: ast.filter.FilterExpression) -> bool: + if not isinstance(reference, type(node)): + return False + return self._parse_filter_expression(node.expr, reference.expr) + + def _has(self, node: ast.filter.Has, reference: ast.filter.FilterExpression) -> bool: + if not isinstance(reference, type(node)): + return False + return self._parse_predicate_expression(node.predicate, reference.predicate) \ + and self._parse_filter_expression(node.count, reference.count) + + def _distance(self, node: ast.filter.Distance, reference: ast.filter.FilterExpression) -> bool: + if not isinstance(reference, (Partial, type(node))): + return False + # partial check + if isinstance(reference, Partial): + if not isinstance(node, reference.node): + return False + return reference.match('reference', node.reference) \ + and reference.match('threshold', node.threshold) \ + and reference.match('strict', node.strict) + # full check + return node.reference == reference.reference \ + and node.threshold == reference.threshold \ + and node.strict == reference.strict + + def _value(self, node: ast.filter._Value, reference: ast.filter.FilterExpression) -> bool: + if not isinstance(reference, (Partial, type(node))): + return False + # partial check + if isinstance(reference, Partial): + if not isinstance(node, reference.node): + return False + return reference.match('value', node.value) + # full ckeck + return node.value == reference.value + + def _bounded(self, node: ast.filter._Bounded, reference: ast.filter.FilterExpression) -> bool: + if not isinstance(reference, (Partial, type(node))): + return False + # partial check + if isinstance(reference, Partial): + if not isinstance(node, reference.node): + return False + return reference.match('threshold', node.threshold) \ + and reference.match('strict', node.strict) + # full check + return node.threshold == reference.threshold \ + and node.strict == reference.strict + +## EOF ## diff --git a/bsfs/query/validator.py b/bsfs/query/validator.py new file mode 100644 index 0000000..10ca492 --- /dev/null +++ b/bsfs/query/validator.py @@ -0,0 +1,351 @@ + +# imports +import typing + +# bsfs imports +from bsfs import schema as bsc +from bsfs.namespace import ns +from bsfs.utils import errors, typename + +# inner-module imports +from . import ast + +# exports +__all__ : typing.Sequence[str] = ( + 'Filter', + ) + +# FIXME: Split into a submodule and the two classes into their own respective files. + +## code ## + +class Filter(): + """Validate a `bsfs.query.ast.filter` query's structure and schema compliance. + + * Conditions (Bounded, Value) can only be applied on literals + * Branches, Id, and Has can only be applied on nodes + * Predicates' domain and range must match + * Predicate paths must follow the schema + * Referenced types are present in the schema + + """ + + # schema to validate against. + schema: bsc.Schema + + def __init__(self, schema: bsc.Schema): + self.schema = schema + + def __call__(self, root_type: bsc.Node, query: ast.filter.FilterExpression) -> bool: + """Alias for `Filter.validate`.""" + return self.validate(root_type, query) + + def validate(self, root_type: bsc.Node, query: ast.filter.FilterExpression) -> bool: + """Validate a filter *query*, assuming the subject having *root_type*. + + Raises a `bsfs.utils.errors.ConsistencyError` if the query violates the schema. + Raises a `bsfs.utils.errors.BackendError` if the query structure is invalid. + + """ + # root_type must be a schema.Node + if not isinstance(root_type, bsc.Node): + raise TypeError(f'expected a node, found {typename(root_type)}') + # root_type must exist in the schema + if root_type not in self.schema.nodes(): + raise errors.ConsistencyError(f'{root_type} is not defined in the schema') + # check root expression + self._parse_filter_expression(root_type, query) + # all tests passed + return True + + + ## routing methods + + def _parse_filter_expression(self, type_: bsc.Vertex, node: 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.Distance): + return self._distance(type_, node) + if isinstance(node, (ast.filter.Any, ast.filter.All)): + return self._branch(type_, node) + if isinstance(node, (ast.filter.And, ast.filter.Or)): + return self._agg(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) -> typing.Tuple[bsc.Vertex, 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}') + + + ## predicate expressions + + def _predicate(self, node: ast.filter.Predicate) -> typing.Tuple[bsc.Vertex, bsc.Vertex]: + # predicate exists in the schema + if not self.schema.has_predicate(node.predicate): + raise errors.ConsistencyError(f'predicate {node.predicate} is not in the schema') + # determine domain and range + pred = self.schema.predicate(node.predicate) + if not isinstance(pred.range, (bsc.Node, bsc.Literal)): + raise errors.BackendError(f'the range of predicate {pred} is undefined') + dom, rng = pred.domain, pred.range + if node.reverse: + dom, rng = rng, dom # type: ignore [assignment] # variable re-use confuses mypy + # return domain and range + return dom, rng + + def _one_of(self, node: ast.filter.OneOf) -> typing.Tuple[bsc.Vertex, bsc.Vertex]: + # determine domain and range types + # NOTE: select the most specific domain and the most generic range + dom, rng = None, None + for pred in node: + # parse child expression + subdom, subrng = self._parse_predicate_expression(pred) + # determine overall domain + if dom is None or subdom < dom: # pick most specific domain + dom = subdom + # domains must be related across all child expressions + if not subdom <= dom and not subdom >= dom: + raise errors.ConsistencyError(f'domains {subdom} and {dom} are not related') + # determine overall range + if rng is None or subrng > rng: # pick most generic range + rng = subrng + # ranges must be related across all child expressions + if not subrng <= rng and not subrng >= rng: + raise errors.ConsistencyError(f'ranges {subrng} and {rng} are not related') + # OneOf guarantees at least one expression, dom and rng are always bsc.Vertex. + # mypy does not realize this, hence we ignore the warning. + return dom, rng # type: ignore [return-value] + + + ## intermediates + + def _branch(self, type_: bsc.Vertex, node: ast.filter._Branch): + # type is a Node + if not isinstance(type_, bsc.Node): + raise errors.ConsistencyError(f'expected a Node, found {type_}') + # type exists in the schema + # FIXME: Isn't it actually guaranteed that the type (except the root type) is part of the schema? + # all types can be traced back to (a) root_type, (b) predicate, or (c) manually set (e.g. in _is). + # For (a), we do (and have to) perform a check. For (c), the code base should be consistent throughout + # the module, so this is an assumption that has to be ensured in schema.Schema. For (b), we know (and + # check) that the predicate is in the schema, hence all node/literals derived from it are also in the + # schema by construction of the schema.Schema class. So, why do we check this every time? + if type_ not in self.schema.nodes(): + raise errors.ConsistencyError(f'node {type_} is not in the schema') + # predicate is valid + dom, rng = self._parse_predicate_expression(node.predicate) + # type_ is a subtype of the predicate's domain + if not type_ <= dom: + raise errors.ConsistencyError(f'expected type {dom} or subtype thereof, found {type_}') + # child expression is valid + self._parse_filter_expression(rng, node.expr) + + def _agg(self, type_: bsc.Vertex, node: ast.filter._Agg): + for expr in node: + # child expression is valid + self._parse_filter_expression(type_, expr) + + def _not(self, type_: bsc.Vertex, node: ast.filter.Not): + # child expression is valid + self._parse_filter_expression(type_, node.expr) + + def _has(self, type_: bsc.Vertex, node: ast.filter.Has): + # type is a Node + if not isinstance(type_, bsc.Node): + raise errors.ConsistencyError(f'expected a Node, found {type_}') + # type exists in the schema + if type_ not in self.schema.nodes(): + raise errors.ConsistencyError(f'node {type_} is not in the schema') + # predicate is valid + dom, _= self._parse_predicate_expression(node.predicate) + # type_ is a subtype of the predicate's domain + if not type_ <= dom: + raise errors.ConsistencyError(f'expected type {dom}, found {type_}') + # node.count is a numerical expression + self._parse_filter_expression(self.schema.literal(ns.bsl.Number), node.count) + + def _distance(self, type_: bsc.Vertex, node: ast.filter.Distance): + # type is a Literal + if not isinstance(type_, bsc.Feature): + raise errors.ConsistencyError(f'expected a Feature, found {type_}') + # type exists in the schema + if type_ not in self.schema.literals(): + raise errors.ConsistencyError(f'literal {type_} is not in the schema') + # reference matches type_ + if len(node.reference) != type_.dimension: + raise errors.ConsistencyError(f'reference has dimension {len(node.reference)}, expected {type_.dimension}') + # FIXME: test dtype + + + ## conditions + + def _is(self, type_: bsc.Vertex, node: ast.filter.Is): # pylint: disable=unused-argument # (node) + if not isinstance(type_, bsc.Node): + raise errors.ConsistencyError(f'expected a Node, found {type_}') + if type_ not in self.schema.nodes(): + raise errors.ConsistencyError(f'node {type_} is not in the schema') + + def _value(self, type_: bsc.Vertex, node: ast.filter._Value): # pylint: disable=unused-argument # (node) + # type is a literal + if not isinstance(type_, bsc.Literal): + raise errors.ConsistencyError(f'expected a Literal, found {type_}') + # type exists in the schema + if type_ not in self.schema.literals(): + raise errors.ConsistencyError(f'literal {type_} is not in the schema') + # FIXME: Check if node.value corresponds to type_ + # FIXME: A specific literal might be requested (i.e., a numeric type when used in Has) + + def _bounded(self, type_: bsc.Vertex, node: ast.filter._Bounded): # pylint: disable=unused-argument # (node) + # type is a literal + if not isinstance(type_, bsc.Literal): + raise errors.ConsistencyError(f'expected a Literal, found {type_}') + # type exists in the schema + if type_ not in self.schema.literals(): + raise errors.ConsistencyError(f'literal {type_} is not in the schema') + # type must be a numerical + if not type_ <= self.schema.literal(ns.bsl.Number): + raise errors.ConsistencyError(f'expected a number type, found {type_}') + # FIXME: Check if node.value corresponds to type_ + + +class Fetch(): + """Validate a `bsfs.query.ast.fetch` query's structure and schema compliance. + + * Value can only be applied on literals + * Node can only be applied on nodes + * Names must be non-empty + * Branching nodes' predicates must match the type + * Symbols must be in the schema + * Predicates must follow the schema + + """ + + # schema to validate against. + schema: bsc.Schema + + def __init__(self, schema: bsc.Schema): + self.schema = schema + + def __call__(self, root_type: bsc.Node, query: ast.fetch.FetchExpression) -> bool: + """Alias for `Fetch.validate`.""" + return self.validate(root_type, query) + + def validate(self, root_type: bsc.Node, query: ast.fetch.FetchExpression) -> bool: + """Validate a fetch *query*, assuming the subject having *root_type*. + + Raises a `bsfs.utils.errors.ConsistencyError` if the query violates the schema. + Raises a `bsfs.utils.errors.BackendError` if the query structure is invalid. + + """ + # root_type must be a schema.Node + if not isinstance(root_type, bsc.Node): + raise TypeError(f'expected a node, found {typename(root_type)}') + # root_type must exist in the schema + if root_type not in self.schema.nodes(): + raise errors.ConsistencyError(f'{root_type} is not defined in the schema') + # query must be a FetchExpression + if not isinstance(query, ast.fetch.FetchExpression): + raise TypeError(f'expected a fetch expression, found {typename(query)}') + # check root expression + self._parse_fetch_expression(root_type, query) + # all tests passed + return True + + def _parse_fetch_expression(self, type_: bsc.Vertex, node: ast.fetch.FetchExpression): + """Route *node* to the handler of the respective FetchExpression subclass.""" + if isinstance(node, (ast.fetch.Fetch, ast.fetch.Value, ast.fetch.Node)): + # NOTE: don't return so that checks below are executed + self._branch(type_, node) + if isinstance(node, (ast.fetch.Value, ast.fetch.Node)): + # NOTE: don't return so that checks below are executed + self._named(type_, node) + if isinstance(node, ast.fetch.All): + return self._all(type_, node) + if isinstance(node, ast.fetch.Fetch): + return self._fetch(type_, node) + if isinstance(node, ast.fetch.Value): + return self._value(type_, node) + if isinstance(node, ast.fetch.Node): + return self._node(type_, node) + if isinstance(node, ast.fetch.This): + return self._this(type_, node) + # invalid node + raise errors.BackendError(f'expected fetch expression, found {node}') + + def _all(self, type_: bsc.Vertex, node: ast.fetch.All): + # check child expressions + for expr in node: + self._parse_fetch_expression(type_, expr) + + def _branch(self, type_: bsc.Vertex, node: ast.fetch._Branch): + # type is a node + if not isinstance(type_, bsc.Node): + raise errors.ConsistencyError(f'expected a Node, found {type_}') + # node exists in the schema + if type_ not in self.schema.nodes(): + raise errors.ConsistencyError(f'node {type_} is not in the schema') + # predicate exists in the schema + 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) + # type_ must be a subclass of domain + if not type_ <= pred.domain: + raise errors.ConsistencyError( + f'expected type {pred.domain} or subtype thereof, found {type_}') + + def _fetch(self, type_: bsc.Vertex, node: ast.fetch.Fetch): # pylint: disable=unused-argument # type_ was considered in _branch + # range must be a node + rng = self.schema.predicate(node.predicate).range + if not isinstance(rng, bsc.Node): + raise errors.ConsistencyError( + f'expected the predicate\'s range to be a Node, found {rng}') + # child expression must be valid + self._parse_fetch_expression(rng, node.expr) + + def _named(self, type_: bsc.Vertex, node: ast.fetch._Named): # pylint: disable=unused-argument # type_ was considered in _branch + # name must be set + if node.name.strip() == '': + raise errors.BackendError('node name cannot be empty') + # FIXME: check for double name use? + + def _node(self, type_: bsc.Vertex, node: ast.fetch.Node): # pylint: disable=unused-argument # type_ was considered in _branch + # range must be a node + rng = self.schema.predicate(node.predicate).range + if not isinstance(rng, bsc.Node): + raise errors.ConsistencyError( + f'expected the predicate\'s range to be a Node, found {rng}') + + def _value(self, type_: bsc.Vertex, node: ast.fetch.Value): # pylint: disable=unused-argument # type_ was considered in _branch + # range must be a literal + rng = self.schema.predicate(node.predicate).range + if not isinstance(rng, bsc.Literal): + raise errors.ConsistencyError( + f'expected the predicate\'s range to be a Literal, found {rng}') + + def _this(self, type_: bsc.Vertex, node: ast.fetch.This): + # type is a node + if not isinstance(type_, bsc.Node): + raise errors.ConsistencyError(f'expected a Node, found {type_}') + # node exists in the schema + if type_ not in self.schema.nodes(): + raise errors.ConsistencyError(f'node {type_} is not in the schema') + # name must be set + if node.name.strip() == '': + raise errors.BackendError('node name cannot be empty') + +## EOF ## diff --git a/bsfs/schema/__init__.py b/bsfs/schema/__init__.py index ad4d456..ca2e0cd 100644 --- a/bsfs/schema/__init__.py +++ b/bsfs/schema/__init__.py @@ -1,15 +1,15 @@ -""" -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 .schema import Schema -from .types import Literal, Node, Predicate +from .serialize import from_string, to_string +from .types import Literal, Node, Predicate, Vertex, Feature, \ + ROOT_VERTEX, ROOT_NODE, ROOT_LITERAL, \ + ROOT_NUMBER, ROOT_TIME, \ + ROOT_ARRAY, ROOT_FEATURE, \ + ROOT_PREDICATE # exports __all__: typing.Sequence[str] = ( @@ -17,6 +17,8 @@ __all__: typing.Sequence[str] = ( 'Node', 'Predicate', 'Schema', + 'from_string', + 'to_string', ) ## EOF ## diff --git a/bsfs/schema/schema.py b/bsfs/schema/schema.py index c5d4571..c104436 100644 --- a/bsfs/schema/schema.py +++ b/bsfs/schema/schema.py @@ -1,16 +1,9 @@ -""" -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, namedtuple import typing -import rdflib # bsfs imports -from bsfs.namespace import ns from bsfs.utils import errors, URI, typename # inner-module imports @@ -51,11 +44,13 @@ class Schema(): def __init__( self, - predicates: typing.Iterable[types.Predicate], + predicates: typing.Optional[typing.Iterable[types.Predicate]] = None, nodes: typing.Optional[typing.Iterable[types.Node]] = None, literals: typing.Optional[typing.Iterable[types.Literal]] = None, ): # materialize arguments + if predicates is None: + predicates = set() if nodes is None: nodes = set() if literals is None: @@ -63,24 +58,41 @@ class Schema(): nodes = set(nodes) literals = set(literals) predicates = set(predicates) + + # add root types to the schema + nodes.add(types.ROOT_NODE) + literals.add(types.ROOT_LITERAL) + predicates.add(types.ROOT_PREDICATE) + # add minimally necessary types to the schema + literals.add(types.ROOT_BLOB) + literals.add(types.ROOT_NUMBER) + literals.add(types.ROOT_TIME) + literals.add(types.ROOT_ARRAY) + literals.add(types.ROOT_FEATURE) + + # FIXME: ensure that types derive from the right root? + # include parents in predicates set # TODO: review type annotations and ignores for python >= 3.11 (parents is _Type but should be typing.Self) predicates |= {par for pred in predicates for par in pred.parents()} # type: ignore [misc] # include predicate domain in nodes set nodes |= {pred.domain for pred in predicates} # include predicate range in nodes and literals sets - prange = {pred.range for pred in predicates if pred.range is not None} + prange = {pred.range for pred in predicates} nodes |= {vert for vert in prange if isinstance(vert, types.Node)} literals |= {vert for vert in prange if isinstance(vert, types.Literal)} + # NOTE: ROOT_PREDICATE has a Vertex as range which is neither in nodes nor literals + # FIXME: with the ROOT_VERTEX missing, the schema is not complete anymore! + # include parents in nodes and literals sets - # NOTE: Must be done after predicate domain/range was handled - # so that their parents are included as well. + # NOTE: Must come after predicate domain/range was handled to have their parents as well. nodes |= {par for node in nodes for par in node.parents()} # type: ignore [misc] literals |= {par for lit in literals for par in lit.parents()} # type: ignore [misc] # assign members self._nodes = {node.uri: node for node in nodes} self._literals = {lit.uri: lit for lit in literals} self._predicates = {pred.uri: pred for pred in predicates} + # verify unique uris if len(nodes) != len(self._nodes): raise errors.ConsistencyError('inconsistent nodes') @@ -214,6 +226,7 @@ class Schema(): >>> Schema.Union([a, b, c]) """ + # FIXME: copy type annotations? if len(args) == 0: raise TypeError('Schema.Union requires at least one argument (Schema or Iterable)') if isinstance(args[0], cls): # args is sequence of Schema instances @@ -295,92 +308,8 @@ class Schema(): """Return the Literal matching the *uri*.""" return self._literals[uri] - - ## constructors ## - - - @classmethod - def Empty(cls) -> 'Schema': # pylint: disable=invalid-name # capitalized classmethod - """Return a minimal Schema.""" - node = types.Node(ns.bsfs.Node, None) - literal = types.Literal(ns.bsfs.Literal, None) - predicate = types.Predicate( - uri=ns.bsfs.Predicate, - parent=None, - domain=node, - range=None, - unique=False, - ) - return cls((predicate, ), (node, ), (literal, )) - - - @classmethod - def from_string(cls, schema: str) -> 'Schema': # pylint: disable=invalid-name # capitalized classmethod - """Load and return a Schema from a string.""" - # parse string into rdf graph - graph = rdflib.Graph() - graph.parse(data=schema, format='turtle') - - def _fetch_hierarchically(factory, curr): - # emit current node - yield curr - # walk through childs - for child in graph.subjects(rdflib.URIRef(ns.rdfs.subClassOf), rdflib.URIRef(curr.uri)): - # convert to URI - child = URI(child) - # check circular dependency - if child == curr.uri or child in {node.uri for node in curr.parents()}: - raise errors.ConsistencyError('circular dependency') - # recurse and emit (sub*)childs - yield from _fetch_hierarchically(factory, factory(child, curr)) - - # fetch nodes - nodes = set(_fetch_hierarchically(types.Node, types.Node(ns.bsfs.Node, None))) - nodes_lut = {node.uri: node for node in nodes} - if len(nodes_lut) != len(nodes): - raise errors.ConsistencyError('inconsistent nodes') - - # fetch literals - literals = set(_fetch_hierarchically(types.Literal, types.Literal(ns.bsfs.Literal, None))) - literals_lut = {lit.uri: lit for lit in literals} - if len(literals_lut) != len(literals): - raise errors.ConsistencyError('inconsistent literals') - - # fetch predicates - def build_predicate(uri, parent): - uri = rdflib.URIRef(uri) - # get domain - domains = set(graph.objects(uri, rdflib.RDFS.domain)) - if len(domains) != 1: - raise errors.ConsistencyError(f'inconsistent domain: {domains}') - dom = nodes_lut.get(next(iter(domains))) - if dom is None: - raise errors.ConsistencyError('missing domain') - # get range - ranges = set(graph.objects(uri, rdflib.RDFS.range)) - if len(ranges) != 1: - raise errors.ConsistencyError(f'inconsistent range: {ranges}') - rng = next(iter(ranges)) - rng = nodes_lut.get(rng, literals_lut.get(rng)) - if rng is None: - raise errors.ConsistencyError('missing range') - # get unique flag - uniques = set(graph.objects(uri, rdflib.URIRef(ns.bsfs.unique))) - if len(uniques) != 1: - raise errors.ConsistencyError(f'inconsistent unique flags: {uniques}') - unique = bool(next(iter(uniques))) - # build Predicate - return types.Predicate(URI(uri), parent, dom, rng, unique) - - root_predicate = types.Predicate( - uri=ns.bsfs.Predicate, - parent=None, - domain=nodes_lut[ns.bsfs.Node], - range=None, # FIXME: Unclear how to handle this! Can be either a Literal or a Node - unique=False, - ) - predicates = _fetch_hierarchically(build_predicate, root_predicate) - # return Schema - return cls(predicates, nodes, literals) + def predicates_at(self, node: types.Node) -> typing.Iterator[types.Predicate]: + """Return predicates that have domain *node* (or superclass thereof).""" + return iter(pred for pred in self._predicates.values() if node <= pred.domain) ## EOF ## diff --git a/bsfs/schema/serialize.py b/bsfs/schema/serialize.py new file mode 100644 index 0000000..ea8b2f4 --- /dev/null +++ b/bsfs/schema/serialize.py @@ -0,0 +1,255 @@ + +# standard imports +import itertools +import typing + +# external imports +import rdflib + +# bsfs imports +from bsfs.namespace import ns +from bsfs.utils import errors, URI, typename + +# inner-module imports +from . import types +from . import schema + +# exports +__all__: typing.Sequence[str] = ( + 'to_string', + 'from_string', + ) + + +## code ## + +def from_string(schema_str: str) -> schema.Schema: + """Load and return a Schema from a string.""" + # parse string into rdf graph + graph = rdflib.Graph() + graph.parse(data=schema_str, format='turtle') + + # helper functions + def _fetch_value( + subject: URI, + predicate: rdflib.URIRef, + value_factory: typing.Callable[[typing.Any], typing.Any], + ) -> typing.Optional[typing.Any]: + """Fetch the object of a given subject and predicate. + Raises a `errors.ConsistencyError` if multiple objects match. + """ + values = list(graph.objects(rdflib.URIRef(subject), predicate)) + if len(values) == 0: + return None + if len(values) == 1: + return value_factory(values[0]) + raise errors.ConsistencyError( + f'{subject} has multiple values for predicate {str(predicate)}, expected zero or one') + + def _convert(value): + """Convert the subject type from rdflib to a bsfs native type.""" + if isinstance(value, rdflib.Literal): + return value.value + if isinstance(value, rdflib.URIRef): + return URI(value) + # value is neither a node nor a literal, but e.g. a blank node + raise errors.BackendError(f'expected Literal or URIRef, found {typename(value)}') + + def _fetch_hierarchically(factory, curr): + """Walk through a rdfs:subClassOf hierarchy, creating symbols along the way.""" + # emit current node + yield curr + # walk through childs + for child in graph.subjects(rdflib.URIRef(ns.rdfs.subClassOf), rdflib.URIRef(curr.uri)): + # fetch annotations + annotations = { + URI(pred): _convert(value) + for pred, value # FIXME: preserve datatype of value?! + in graph.predicate_objects(child) + if URI(pred) != ns.rdfs.subClassOf + } + # convert child to URI + child = URI(child) + # check circular dependency + if child == curr.uri or child in {node.uri for node in curr.parents()}: + raise errors.ConsistencyError('circular dependency') + # recurse and emit (sub*)childs + yield from _fetch_hierarchically(factory, factory(child, curr, **annotations)) + + # fetch nodes + nodes = set(_fetch_hierarchically(types.Node, types.ROOT_NODE)) + nodes_lut = {node.uri: node for node in nodes} + if len(nodes_lut) != len(nodes): + raise errors.ConsistencyError('inconsistent nodes') + + # fetch literals + def _build_literal(uri, parent, **annotations): + """Literal factory.""" + # break out on root feature type + if uri == types.ROOT_FEATURE.uri: + return types.ROOT_FEATURE + # handle feature types + if isinstance(parent, types.Feature): + # clean annotations + annotations.pop(ns.bsfs.dimension, None) + annotations.pop(ns.bsfs.dtype, None) + annotations.pop(ns.bsfs.distance, None) + # get dimension + dimension = _fetch_value(uri, rdflib.URIRef(ns.bsfs.dimension), int) + # get dtype + dtype = _fetch_value(uri, rdflib.URIRef(ns.bsfs.dtype), URI) + # get distance + distance = _fetch_value(uri, rdflib.URIRef(ns.bsfs.distance), URI) + # return feature + return parent.child(URI(uri), dtype=dtype, dimension=dimension, distance=distance, **annotations) + # handle non-feature types + return parent.child(URI(uri), **annotations) + + literals = set(_fetch_hierarchically(_build_literal, types.ROOT_LITERAL)) + literals_lut = {lit.uri: lit for lit in literals} + if len(literals_lut) != len(literals): + raise errors.ConsistencyError('inconsistent literals') + + # fetch predicates + def _build_predicate(uri, parent, **annotations): + """Predicate factory.""" + # clean annotations + annotations.pop(ns.rdfs.domain, None) + annotations.pop(ns.rdfs.range, None) + annotations.pop(ns.bsfs.unique, None) + # get domain + dom = _fetch_value(uri, rdflib.RDFS.domain, URI) + if dom is not None and dom not in nodes_lut: + raise errors.ConsistencyError(f'predicate {uri} has undefined domain {dom}') + if dom is not None: + dom = nodes_lut[dom] + # get range + rng = _fetch_value(uri, rdflib.RDFS.range, URI) + if rng is not None and rng not in nodes_lut and rng not in literals_lut: + raise errors.ConsistencyError(f'predicate {uri} has undefined range {rng}') + if rng is not None: + rng = nodes_lut.get(rng, literals_lut.get(rng)) + # get unique + unique = _fetch_value(uri, rdflib.URIRef(ns.bsfs.unique), bool) + # build predicate + return parent.child(URI(uri), domain=dom, range=rng, unique=unique, **annotations) + + predicates = _fetch_hierarchically(_build_predicate, types.ROOT_PREDICATE) + + return schema.Schema(predicates, nodes, literals) + + + +def to_string(schema_inst: schema.Schema, fmt: str = 'turtle') -> str: + """Serialize a `bsfs.schema.Schema` to a string. + See `rdflib.Graph.serialize` for viable formats (default: turtle). + """ + + # type of emitted triples. + T_TRIPLE = typing.Iterator[typing.Tuple[rdflib.URIRef, rdflib.URIRef, rdflib.term.Identifier]] + + def _type(tpe: types._Type) -> T_TRIPLE : + """Emit _Type properties (parent, annotations).""" + # emit parent + if tpe.parent is not None: + yield ( + rdflib.URIRef(tpe.uri), + rdflib.URIRef(ns.rdfs.subClassOf), + rdflib.URIRef(tpe.parent.uri), + ) + # emit annotations + for prop, value in tpe.annotations.items(): + yield ( + rdflib.URIRef(tpe.uri), + rdflib.URIRef(prop), + rdflib.Literal(value), # FIXME: datatype?! + ) + + def _predicate(pred: types.Predicate) -> T_TRIPLE: + """Emit Predicate properties (domain, range, unique).""" + # no need to emit anything for the root predicate + if pred == types.ROOT_PREDICATE: + return + # emit domain + if pred.domain != getattr(pred.parent, 'domain', None): + yield ( + rdflib.URIRef(pred.uri), + rdflib.URIRef(ns.rdfs.domain), + rdflib.URIRef(pred.domain.uri), + ) + # emit range + if pred.range != getattr(pred.parent, 'range', None): + yield ( + rdflib.URIRef(pred.uri), + rdflib.URIRef(ns.rdfs.range), + rdflib.URIRef(pred.range.uri), + ) + # emit cardinality + if pred.unique != getattr(pred.parent, 'unique', None): + yield ( + rdflib.URIRef(pred.uri), + rdflib.URIRef(ns.bsfs.unique), + rdflib.Literal(pred.unique, datatype=rdflib.XSD.boolean), + ) + + def _feature(feat: types.Feature) -> T_TRIPLE: + """Emit Feature properties (dimension, dtype, distance).""" + # emit size + if feat.dimension != getattr(feat.parent, 'dimension', None): + yield ( + rdflib.URIRef(feat.uri), + rdflib.URIRef(ns.bsfs.dimension), + rdflib.Literal(feat.dimension, datatype=rdflib.XSD.integer), + ) + # emit dtype + if feat.dtype != getattr(feat.parent, 'dtype', None): + yield ( + rdflib.URIRef(feat.uri), + rdflib.URIRef(ns.bsfs.dtype), + rdflib.URIRef(feat.dtype), + ) + # emit distance + if feat.distance != getattr(feat.parent, 'distance', None): + yield ( + rdflib.URIRef(feat.uri), + rdflib.URIRef(ns.bsfs.distance), + rdflib.URIRef(feat.distance), + ) + + def _parse(node: types._Type) -> T_TRIPLE: + """Emit all properties of a type.""" + # check arg + if not isinstance(node, types._Type): # pylint: disable=protected-access + raise TypeError(node) + # emit _Type essentials + yield from _type(node) + # emit properties of derived types + if isinstance(node, types.Predicate): + yield from _predicate(node) + if isinstance(node, types.Feature): + yield from _feature(node) + + # create graph + graph = rdflib.Graph() + # add triples to graph + nodes = itertools.chain( + schema_inst.nodes(), + schema_inst.literals(), + schema_inst.predicates()) + for node in nodes: + for triple in _parse(node): + graph.add(triple) + # add known namespaces for readability + # FIXME: more generically? + graph.bind('bsfs', rdflib.URIRef(ns.bsfs + '/')) + graph.bind('bsl', rdflib.URIRef(ns.bsl + '/')) + graph.bind('bsn', rdflib.URIRef(ns.bsn + '#')) + graph.bind('bse', rdflib.URIRef(ns.bsfs.Entity() + '#')) + graph.bind('rdf', rdflib.URIRef(ns.rdf)) + graph.bind('rdfs', rdflib.URIRef(ns.rdfs)) + graph.bind('schema', rdflib.URIRef(ns.schema)) + graph.bind('xsd', rdflib.URIRef(ns.xsd)) + # serialize to turtle + return graph.serialize(format=fmt) + +## EOF ## diff --git a/bsfs/schema/types.py b/bsfs/schema/types.py index 54a7e99..5834df8 100644 --- a/bsfs/schema/types.py +++ b/bsfs/schema/types.py @@ -1,13 +1,9 @@ -""" -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.namespace import ns from bsfs.utils import errors, URI, typename # exports @@ -15,6 +11,7 @@ __all__: typing.Sequence[str] = ( 'Literal', 'Node', 'Predicate', + 'Feature', ) @@ -99,9 +96,11 @@ class _Type(): self, uri: URI, parent: typing.Optional['_Type'] = None, + **annotations: typing.Any, ): - self.uri = uri + self.uri = URI(uri) self.parent = parent + self.annotations = annotations def parents(self) -> typing.Generator['_Type', None, None]: """Generate a list of parent nodes.""" @@ -110,9 +109,17 @@ class _Type(): yield curr curr = curr.parent - def get_child(self, uri: URI, **kwargs): + def child( + self, + uri: URI, + **kwargs, + ): """Return a child of the current class.""" - return type(self)(uri, self, **kwargs) + return type(self)( + uri=uri, + parent=self, + **kwargs + ) def __str__(self) -> str: return f'{typename(self)}({self.uri})' @@ -138,8 +145,10 @@ class _Type(): def __lt__(self, other: typing.Any) -> bool: """Return True iff *self* is a true subclass of *other*.""" - if not type(self) is type(other): # type mismatch # pylint: disable=unidiomatic-typecheck + if not isinstance(other, _Type): return NotImplemented + if not isinstance(other, type(self)): # FIXME: necessary? + return False if self.uri == other.uri: # equivalence return False if self in other.parents(): # superclass @@ -151,8 +160,10 @@ class _Type(): def __le__(self, other: typing.Any) -> bool: """Return True iff *self* is equivalent or a subclass of *other*.""" - if not type(self) is type(other): # type mismatch # pylint: disable=unidiomatic-typecheck + if not isinstance(other, _Type): return NotImplemented + if not isinstance(other, type(self)): # FIXME: necessary? + return False if self.uri == other.uri: # equivalence return True if self in other.parents(): # superclass @@ -164,8 +175,10 @@ class _Type(): def __gt__(self, other: typing.Any) -> bool: """Return True iff *self* is a true superclass of *other*.""" - if not type(self) is type(other): # type mismatch # pylint: disable=unidiomatic-typecheck + if not isinstance(other, _Type): return NotImplemented + if not isinstance(other, type(self)): # FIXME: necessary? + return False if self.uri == other.uri: # equivalence return False if self in other.parents(): # superclass @@ -177,8 +190,10 @@ class _Type(): def __ge__(self, other: typing.Any) -> bool: """Return True iff *self* is eqiuvalent or a superclass of *other*.""" - if not type(self) is type(other): # type mismatch # pylint: disable=unidiomatic-typecheck + if not isinstance(other, _Type): return NotImplemented + if not isinstance(other, type(self)): # FIXME: necessary? + return False if self.uri == other.uri: # equivalence return True if self in other.parents(): # superclass @@ -189,32 +204,95 @@ class _Type(): return False -class _Vertex(_Type): +class Vertex(_Type): """Graph vertex types. Can be a Node or a Literal.""" - def __init__(self, uri: URI, parent: typing.Optional['_Vertex']): - super().__init__(uri, parent) + parent: typing.Optional['Vertex'] + def __init__(self, uri: URI, parent: typing.Optional['Vertex'], **kwargs): + super().__init__(uri, parent, **kwargs) -class Node(_Vertex): +class Node(Vertex): """Node type.""" - def __init__(self, uri: URI, parent: typing.Optional['Node']): - super().__init__(uri, parent) + parent: typing.Optional['Node'] + def __init__(self, uri: URI, parent: typing.Optional['Node'], **kwargs): + super().__init__(uri, parent, **kwargs) -class Literal(_Vertex): +class Literal(Vertex): """Literal type.""" - def __init__(self, uri: URI, parent: typing.Optional['Literal']): - super().__init__(uri, parent) + parent: typing.Optional['Literal'] + def __init__(self, uri: URI, parent: typing.Optional['Literal'], **kwargs): + super().__init__(uri, parent, **kwargs) + + +class Feature(Literal): + """Feature type.""" + + # Number of feature vector dimensions. + dimension: int + + # Feature vector datatype. + dtype: URI + + # Distance measure to compare feature vectors. + distance: URI + + def __init__( + self, + # Type members + uri: URI, + parent: typing.Optional[Literal], + # Feature members + dimension: int, + dtype: URI, + distance: URI, + **kwargs, + ): + super().__init__(uri, parent, **kwargs) + self.dimension = int(dimension) + self.dtype = URI(dtype) + self.distance = URI(distance) + + def __hash__(self) -> int: + return hash((super().__hash__(), self.dimension, self.dtype, self.distance)) + + def __eq__(self, other: typing.Any) -> bool: + return super().__eq__(other) \ + and self.dimension == other.dimension \ + and self.dtype == other.dtype \ + and self.distance == other.distance + def child( + self, + uri: URI, + dimension: typing.Optional[int] = None, + dtype: typing.Optional[URI] = None, + distance: typing.Optional[URI] = None, + **kwargs, + ): + """Return a child of the current class.""" + if dimension is None: + dimension = self.dimension + if dtype is None: + dtype = self.dtype + if distance is None: + distance = self.distance + return super().child( + uri=uri, + dimension=dimension, + dtype=dtype, + distance=distance, + **kwargs, + ) class Predicate(_Type): - """Predicate type.""" + """Predicate base type.""" # source type. domain: Node # destination type. - range: typing.Optional[typing.Union[Node, Literal]] + range: Vertex # maximum cardinality of type. unique: bool @@ -226,22 +304,23 @@ class Predicate(_Type): parent: typing.Optional['Predicate'], # Predicate members domain: Node, - range: typing.Optional[typing.Union[Node, Literal]], # pylint: disable=redefined-builtin + range: Vertex, # pylint: disable=redefined-builtin unique: bool, + **kwargs, ): # check arguments if not isinstance(domain, Node): raise TypeError(domain) - if range is not None and not isinstance(range, Node) and not isinstance(range, Literal): + if range != ROOT_VERTEX and not isinstance(range, (Node, Literal)): raise TypeError(range) # initialize - super().__init__(uri, parent) + super().__init__(uri, parent, **kwargs) self.domain = domain self.range = range - self.unique = unique + self.unique = bool(unique) def __hash__(self) -> int: - return hash((super().__hash__(), self.domain, self.range, self.unique)) + return hash((super().__hash__(), self.domain, self.unique, self.range)) def __eq__(self, other: typing.Any) -> bool: return super().__eq__(other) \ @@ -249,11 +328,11 @@ class Predicate(_Type): and self.range == other.range \ and self.unique == other.unique - def get_child( + def child( self, uri: URI, domain: typing.Optional[Node] = None, - range: typing.Optional[_Vertex] = None, # pylint: disable=redefined-builtin + range: typing.Optional[Vertex] = None, # pylint: disable=redefined-builtin unique: typing.Optional[bool] = None, **kwargs, ): @@ -264,13 +343,73 @@ class Predicate(_Type): raise errors.ConsistencyError(f'{domain} must be a subclass of {self.domain}') if range is None: range = self.range - if range is None: # inherited range from ns.bsfs.Predicate - raise ValueError('range must be defined by the parent or argument') - if self.range is not None and not range <= self.range: + # NOTE: The root predicate has a Vertex as range, which is neither a parent of the root + # Node nor Literal. Hence, that test is skipped since a child should be allowed to + # specialize from Vertex to anything. + if self.range != ROOT_VERTEX and not range <= self.range: raise errors.ConsistencyError(f'{range} must be a subclass of {self.range}') if unique is None: unique = self.unique - return super().get_child(uri, domain=domain, range=range, unique=unique, **kwargs) + return super().child( + uri=uri, + domain=domain, + range=range, + unique=unique, + **kwargs + ) + + +# essential vertices +ROOT_VERTEX = Vertex( + uri=ns.bsfs.Vertex, + parent=None, + ) + +ROOT_NODE = Node( + uri=ns.bsfs.Node, + parent=None, + ) + +ROOT_LITERAL = Literal( + uri=ns.bsfs.Literal, + parent=None, + ) + +ROOT_BLOB = Literal( + uri=ns.bsl.BinaryBlob, + parent=ROOT_LITERAL, + ) + +ROOT_NUMBER = Literal( + uri=ns.bsl.Number, + parent=ROOT_LITERAL, + ) + +ROOT_TIME = Literal( + uri=ns.bsl.Time, + parent=ROOT_LITERAL, + ) + +ROOT_ARRAY = Literal( + uri=ns.bsl.Array, + parent=ROOT_LITERAL, + ) +ROOT_FEATURE = Feature( + uri=ns.bsl.Array.Feature, + parent=ROOT_ARRAY, + dimension=1, + dtype=ns.bsfs.dtype().f16, + distance=ns.bsd.euclidean, + ) + +# essential predicates +ROOT_PREDICATE = Predicate( + uri=ns.bsfs.Predicate, + parent=None, + domain=ROOT_NODE, + range=ROOT_VERTEX, + unique=False, + ) ## EOF ## diff --git a/bsfs/triple_store/__init__.py b/bsfs/triple_store/__init__.py index fb5a8a9..79a2887 100644 --- a/bsfs/triple_store/__init__.py +++ b/bsfs/triple_store/__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/triple_store/base.py b/bsfs/triple_store/base.py index 6561262..58b5670 100644 --- a/bsfs/triple_store/base.py +++ b/bsfs/triple_store/base.py @@ -1,16 +1,12 @@ -""" -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 # inner-module imports +from bsfs.query import ast from bsfs.utils import URI, typename -import bsfs.schema as _schema +import bsfs.schema as bsc # exports __all__: typing.Sequence[str] = ( @@ -81,12 +77,12 @@ class TripleStoreBase(abc.ABC): @property @abc.abstractmethod - def schema(self) -> _schema.Schema: + def schema(self) -> bsc.Schema: """Return the store's local schema.""" @schema.setter @abc.abstractmethod - def schema(self, schema: _schema.Schema): + def schema(self, schema: bsc.Schema): """Migrate to new schema by adding or removing class definitions. Commits before and after the migration. @@ -109,9 +105,30 @@ class TripleStoreBase(abc.ABC): """ @abc.abstractmethod + def get( + self, + node_type: bsc.Node, + filter: typing.Optional[ast.filter.FilterExpression] = None, # pylint: disable=redefined-builtin + ) -> typing.Iterator[URI]: + """Return guids of nodes of type *node_type* that match the *filter*. + Return all guids of the respective type if *filter* is None. + """ + + @abc.abstractmethod + def fetch( + self, + node_type: bsc.Node, + filter: ast.filter.FilterExpression, # pylint: disable=redefined-builtin + fetch: ast.fetch.FetchExpression, + ) -> typing.Iterator[typing.Tuple[URI, str, typing.Any]]: + """Return (guid, name, value) triples where the guid is determined by the *filter* + query and the name matches the *fetch* query. + """ + + @abc.abstractmethod def exists( self, - node_type: _schema.Node, + node_type: bsc.Node, guids: typing.Iterable[URI], ) -> typing.Iterable[URI]: """Return those *guids* that exist and have type *node_type* or a subclass thereof.""" @@ -119,7 +136,7 @@ class TripleStoreBase(abc.ABC): @abc.abstractmethod def create( self, - node_type: _schema.Node, + node_type: bsc.Node, guids: typing.Iterable[URI], ): """Create *guid* nodes with type *subject*.""" @@ -127,9 +144,9 @@ class TripleStoreBase(abc.ABC): @abc.abstractmethod def set( self, - node_type: _schema.Node, # FIXME: is the node_type even needed? Couldn't I infer from the predicate? + node_type: bsc.Node, # FIXME: is the node_type even needed? Couldn't I infer from the predicate? guids: typing.Iterable[URI], - predicate: _schema.Predicate, + predicate: bsc.Predicate, values: typing.Iterable[typing.Any], ): """Add triples to the graph. diff --git a/bsfs/triple_store/sparql/__init__.py b/bsfs/triple_store/sparql/__init__.py new file mode 100644 index 0000000..cfa2732 --- /dev/null +++ b/bsfs/triple_store/sparql/__init__.py @@ -0,0 +1,13 @@ + +# imports +import typing + +# inner-module imports +from .sparql import SparqlStore + +# exports +__all__: typing.Sequence[str] = ( + 'SparqlStore', + ) + +## EOF ## diff --git a/bsfs/triple_store/sparql/distance.py b/bsfs/triple_store/sparql/distance.py new file mode 100644 index 0000000..2c2f355 --- /dev/null +++ b/bsfs/triple_store/sparql/distance.py @@ -0,0 +1,51 @@ + +# standard imports +import typing + +# external imports +import numpy as np + +# bsfs imports +from bsfs.namespace import ns + +# constants +EPS = 1e-9 + +# exports +__all__: typing.Sequence[str] = ( + 'DISTANCE_FU', + ) + + +## code ## + +def euclid(fst, snd) -> float: + """Euclidean distance (l2 norm).""" + fst = np.array(fst) + snd = np.array(snd) + return float(np.linalg.norm(fst - snd)) + +def cosine(fst, snd) -> float: + """Cosine distance.""" + fst = np.array(fst) + snd = np.array(snd) + if (fst == snd).all(): + return 0.0 + nrm0 = np.linalg.norm(fst) + nrm1 = np.linalg.norm(snd) + return float(1.0 - np.dot(fst, snd) / (nrm0 * nrm1 + EPS)) + +def manhatten(fst, snd) -> float: + """Manhatten (cityblock) distance (l1 norm).""" + fst = np.array(fst) + snd = np.array(snd) + return float(np.abs(fst - snd).sum()) + +# Known distance functions. +DISTANCE_FU = { + ns.bsd.euclidean: euclid, + ns.bsd.cosine: cosine, + ns.bsd.manhatten: manhatten, +} + +## EOF ## diff --git a/bsfs/triple_store/sparql/parse_fetch.py b/bsfs/triple_store/sparql/parse_fetch.py new file mode 100644 index 0000000..fab8173 --- /dev/null +++ b/bsfs/triple_store/sparql/parse_fetch.py @@ -0,0 +1,104 @@ + +# standard 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 .utils import GenHopName, Query + +# exports +__all__: typing.Sequence[str] = ( + 'Fetch', + ) + + +## code ## + +class Fetch(): + """Translate `bsfs.query.ast.fetch` structures into Sparql queries.""" + + def __init__(self, schema): + self.schema = schema + self.ngen = GenHopName(prefix='?fch') + + def __call__( + self, + root_type: bsc.Node, + root: ast.fetch.FetchExpression, + ) -> Query: + """ + """ + # check root_type + if not isinstance(root_type, bsc.Node): + raise errors.BackendError(f'expected Node, found {root_type}') + if root_type not in self.schema.nodes(): + raise errors.ConsistencyError(f'node {root_type} is not in the schema') + # parse root + terms, expr = self._parse_fetch_expression(root_type, root, '?ent') + # assemble query + return Query( + root_type=root_type.uri, + root_head='?ent', + select=terms, + where=expr, + ) + + def _parse_fetch_expression( + self, + node_type: bsc.Vertex, + node: ast.fetch.FetchExpression, + head: str, + ): + """Route *node* to the handler of the respective FetchExpression subclass.""" + if isinstance(node, ast.fetch.All): + return self._all(node_type, node, head) + if isinstance(node, ast.fetch.Fetch): + return self._fetch(node_type, node, head) + if isinstance(node, ast.fetch.Node): + return self._node(node_type, node, head) + if isinstance(node, ast.fetch.Value): + return self._value(node_type, node, head) + if isinstance(node, ast.fetch.This): + return self._this(node_type, node, head) + # invalid node + raise errors.BackendError(f'expected fetch expression, found {node}') + + def _all(self, node_type: bsc.Vertex, node: ast.fetch.All, head: str): + # child expressions + terms, exprs = zip(*[self._parse_fetch_expression(node_type, expr, head) for expr in node]) + terms = {term for sub in terms for term in sub} + exprs = ' .\n'.join({expr for expr in exprs if len(expr.strip()) > 0}) + return terms, exprs + + def _fetch(self, node_type: bsc.Vertex, node: ast.fetch.Fetch, head: str): # pylint: disable=unused-argument # (node_type) + # child expressions + rng = self.schema.predicate(node.predicate).range + nexthead = next(self.ngen) + terms, expr = self._parse_fetch_expression(rng, node.expr, nexthead) + return terms, f'OPTIONAL{{ {head} <{node.predicate}> {nexthead} .\n {expr} }}' + + def _node(self, node_type: bsc.Vertex, node: ast.fetch.Node, head: str): # pylint: disable=unused-argument # (node_type) + if f'?{node.name}'.startswith(self.ngen.prefix): + raise errors.BackendError(f'Node name must start with {self.ngen.prefix}') + # compose and return statement + term = next(self.ngen) + return {(term, node.name)}, f'OPTIONAL{{ {head} <{node.predicate}> {term} }}' + + def _value(self, node_type: bsc.Vertex, node: ast.fetch.Value, head: str): # pylint: disable=unused-argument # (node_type) + if f'?{node.name}'.startswith(self.ngen.prefix): + raise errors.BackendError(f'Value name must start with {self.ngen.prefix}') + # compose and return statement + term = next(self.ngen) + return {(term, node.name)}, f'OPTIONAL{{ {head} <{node.predicate}> {term} }}' + + def _this(self, node_type: bsc.Vertex, node: ast.fetch.This, head: str): # pylint: disable=unused-argument # (node_type) + if f'?{node.name}'.startswith(self.ngen.prefix): + raise errors.BackendError(f'This name must start with {self.ngen.prefix}') + # compose and return statement + return {(head, node.name)}, '' + +## EOF ## diff --git a/bsfs/triple_store/sparql/parse_filter.py b/bsfs/triple_store/sparql/parse_filter.py new file mode 100644 index 0000000..2f5a25b --- /dev/null +++ b/bsfs/triple_store/sparql/parse_filter.py @@ -0,0 +1,316 @@ + +# imports +import operator +import typing + +# external imports +import rdflib + +# bsfs imports +from bsfs import schema as bsc +from bsfs.namespace import ns +from bsfs.query import ast +from bsfs.utils import URI, errors + +# inner-module imports +from .distance import DISTANCE_FU +from .utils import GenHopName, Query + +# exports +__all__: typing.Sequence[str] = ( + 'Filter', + ) + + +## code ## + +class Filter(): + """Translate `bsfs.query.ast.filter` structures into Sparql queries.""" + + # Current schema to validate against. + schema: bsc.Schema + + # Generator that produces unique symbol names. + ngen: GenHopName + + def __init__(self, graph, schema): + self.graph = graph + self.schema = schema + self.ngen = GenHopName(prefix='?flt') + + def __call__( + self, + root_type: bsc.Node, + root: typing.Optional[ast.filter.FilterExpression] = None, + ) -> Query: + """ + """ + # check root_type + if not isinstance(root_type, bsc.Node): + raise errors.BackendError(f'expected Node, found {root_type}') + if root_type not in self.schema.nodes(): + raise errors.ConsistencyError(f'node {root_type} is not in the schema') + # parse root + if root is None: + cond = '' + else: + cond = self._parse_filter_expression(root_type, root, '?ent') + # assemble query + return Query( + root_type=root_type.uri, + root_head='?ent', + where=cond, + ) + + def _parse_filter_expression( + self, + type_: bsc.Vertex, + node: ast.filter.FilterExpression, + head: str, + ) -> str: + """Route *node* to the handler of the respective FilterExpression subclass.""" + if isinstance(node, ast.filter.Is): + return self._is(type_, node, head) + if isinstance(node, ast.filter.Not): + return self._not(type_, node, head) + if isinstance(node, ast.filter.Has): + return self._has(type_, node, head) + if isinstance(node, ast.filter.Distance): + return self._distance(type_, node, head) + if isinstance(node, ast.filter.Any): + return self._any(type_, node, head) + if isinstance(node, ast.filter.All): + return self._all(type_, node, head) + if isinstance(node, ast.filter.And): + return self._and(type_, node, head) + if isinstance(node, ast.filter.Or): + return self._or(type_, node, head) + if isinstance(node, ast.filter.Equals): + return self._equals(type_, node, head) + if isinstance(node, ast.filter.Substring): + return self._substring(type_, node, head) + if isinstance(node, ast.filter.StartsWith): + return self._starts_with(type_, node, head) + if isinstance(node, ast.filter.EndsWith): + return self._ends_with(type_, node, head) + if isinstance(node, ast.filter.LessThan): + return self._less_than(type_, node, head) + if isinstance(node, ast.filter.GreaterThan): + return self._greater_than(type_, node, head) + # invalid node + raise errors.BackendError(f'expected filter expression, found {node}') + + def _parse_predicate_expression( + self, + type_: bsc.Vertex, + node: ast.filter.PredicateExpression + ) -> typing.Tuple[str, bsc.Vertex]: + """Route *node* to the handler of the respective PredicateExpression subclass.""" + if isinstance(node, ast.filter.Predicate): + return self._predicate(type_, node) + if isinstance(node, ast.filter.OneOf): + return self._one_of(type_, node) + # invalid node + raise errors.BackendError(f'expected predicate expression, found {node}') + + def _one_of(self, node_type: bsc.Vertex, node: ast.filter.OneOf) -> typing.Tuple[str, bsc.Vertex]: + """ + """ + if not isinstance(node_type, bsc.Node): + raise errors.BackendError(f'expected Node, found {node_type}') + # walk through predicates + suburi, rng = set(), None + for pred in node: # OneOf guarantees at least one expression + puri, subrng = self._parse_predicate_expression(node_type, pred) + # track predicate uris + suburi.add(puri) + # check for more generic range + if rng is None or subrng > rng: + rng = subrng + # check range consistency + if not subrng <= rng and not subrng >= rng: + raise errors.ConsistencyError(f'ranges {subrng} and {rng} are not related') + # return joint predicate expression and next range + # OneOf guarantees at least one expression, rng is always a bsc.Vertex. + # mypy does not realize this, hence we ignore the warning. + return '|'.join(suburi), rng # type: ignore [return-value] + + def _predicate(self, node_type: bsc.Vertex, node: ast.filter.Predicate) -> typing.Tuple[str, bsc.Vertex]: + """ + """ + # check node_type + if not isinstance(node_type, bsc.Node): + raise errors.BackendError(f'expected Node, found {node_type}') + # fetch predicate and its uri + puri = node.predicate + # get and check predicate, domain, and range + if not self.schema.has_predicate(puri): + raise errors.ConsistencyError(f'predicate {puri} is not in the schema') + pred = self.schema.predicate(puri) + if not isinstance(pred.range, (bsc.Node, bsc.Literal)): + raise errors.BackendError(f'the range of predicate {pred} is undefined') + dom, rng = pred.domain, pred.range + # encapsulate predicate uri + uri_str = f'<{puri}>' + # apply reverse flag + if node.reverse: + uri_str = '^' + uri_str + dom, rng = rng, dom # type: ignore [assignment] # variable re-use confuses mypy + # check path consistency + if not node_type <= dom: + raise errors.ConsistencyError(f'expected type {dom} or subtype thereof, found {node_type}') + # return predicate URI and next node type + return uri_str, rng + + def _any(self, node_type: bsc.Vertex, node: ast.filter.Any, head: str) -> str: + """ + """ + if not isinstance(node_type, bsc.Node): + raise errors.BackendError(f'expected Node, found {node_type}') + # parse predicate + pred, next_type = self._parse_predicate_expression(node_type, node.predicate) + # parse expression + nexthead = next(self.ngen) + expr = self._parse_filter_expression(next_type, node.expr, nexthead) + # combine results + return f'{head} {pred} {nexthead} . {expr}' + + def _all(self, node_type: bsc.Vertex, node: ast.filter.All, head: str) -> str: + """ + """ + # NOTE: All(P, E) := Not(Any(P, Not(E))) and EXISTS(P, ?) + if not isinstance(node_type, bsc.Node): + raise errors.BackendError(f'expected Node, found {node_type}') + # parse rewritten ast + expr = self._parse_filter_expression(node_type, + ast.filter.Not( + ast.filter.Any(node.predicate, + ast.filter.Not(node.expr))), head) + # parse predicate for existence constraint + pred, _ = self._parse_predicate_expression(node_type, node.predicate) + temphead = next(self.ngen) + # return existence and rewritten expression + return f'FILTER EXISTS {{ {head} {pred} {temphead} }} . ' + expr + + def _and(self, node_type: bsc.Vertex, node: ast.filter.And, head: str) -> str: + """ + """ + sub = [self._parse_filter_expression(node_type, expr, head) for expr in node] + return ' . '.join(sub) + + def _or(self, node_type: bsc.Vertex, node: ast.filter.Or, head: str) -> str: + """ + """ + # potential special case optimization: + # * ast: Or(Equals('foo'), Equals('bar'), ...) + # * query: VALUES ?head { "value1"^^<...> "value2"^^<...> "value3"^<...> ... } + sub = [self._parse_filter_expression(node_type, expr, head) for expr in node] + sub = ['{' + expr + '}' for expr in sub] + return ' UNION '.join(sub) + + def _not(self, node_type: bsc.Vertex, node: ast.filter.Not, head: str) -> str: + """ + """ + expr = self._parse_filter_expression(node_type, node.expr, head) + if isinstance(node_type, bsc.Literal): + return f'MINUS {{ {expr} }}' + # NOTE: for bsc.Node types, we must include at least one expression in the body of MINUS, + # otherwise the connection between the context and body of MINUS is lost. + # The simplest (and non-interfering) choice is a type statement. + return f'MINUS {{ {head} <{ns.rdf.type}>/<{ns.rdfs.subClassOf}>* <{node_type.uri}> . {expr} }}' + + def _has(self, node_type: bsc.Vertex, node: ast.filter.Has, head: str) -> str: + """ + """ + if not isinstance(node_type, bsc.Node): + raise errors.BackendError(f'expected Node, found {node_type}') + # parse predicate + pred, _ = self._parse_predicate_expression(node_type, node.predicate) + # get new heads + inner = next(self.ngen) + outer = next(self.ngen) + # predicate count expression (fetch number of predicates at *head*) + num_preds = f'{{ SELECT (COUNT(distinct {inner}) as {outer}) WHERE {{ {head} {pred} {inner} }} }}' + # count expression + count_bounds = self._parse_filter_expression(self.schema.literal(ns.xsd.integer), node.count, outer) + # combine + return num_preds + ' . ' + count_bounds + + def _distance(self, node_type: bsc.Vertex, node: ast.filter.Distance, head: str) -> str: + """ + """ + if not isinstance(node_type, bsc.Feature): + raise errors.BackendError(f'expected Feature, found {node_type}') + if len(node.reference) != node_type.dimension: + raise errors.ConsistencyError( + f'reference has dimension {len(node.reference)}, expected {node_type.dimension}') + # get distance metric + dist = DISTANCE_FU[node_type.distance] + # get operator + cmp = operator.lt if node.strict else operator.le + # get candidate values + candidates = { + f'"{cand}"^^<{node_type.uri}>' + for cand + in self.graph.objects() + if isinstance(cand, rdflib.Literal) + and cand.datatype == rdflib.URIRef(node_type.uri) + and cmp(dist(cand.value, node.reference), node.threshold) + } + # combine candidate values + values = ' '.join(candidates) if len(candidates) else f'"impossible value"^^<{ns.xsd.string}>' + # return sparql fragment + return f'VALUES {head} {{ {values} }}' + + def _is(self, node_type: bsc.Vertex, node: ast.filter.Is, head: str) -> str: + """ + """ + if not isinstance(node_type, bsc.Node): + raise errors.BackendError(f'expected Node, found {node_type}') + return f'VALUES {head} {{ <{URI(node.value)}> }}' + + def _equals(self, node_type: bsc.Vertex, node: ast.filter.Equals, head: str) -> str: + """ + """ + if not isinstance(node_type, bsc.Literal): + raise errors.BackendError(f'expected Literal, found {node}') + return f'VALUES {head} {{ "{node.value}"^^<{node_type.uri}> }}' + + def _substring(self, node_type: bsc.Vertex, node: ast.filter.Substring, head: str) -> str: + """ + """ + if not isinstance(node_type, bsc.Literal): + raise errors.BackendError(f'expected Literal, found {node_type}') + return f'FILTER contains(str({head}), "{node.value}")' + + def _starts_with(self, node_type: bsc.Vertex, node: ast.filter.StartsWith, head: str) -> str: + """ + """ + if not isinstance(node_type, bsc.Literal): + raise errors.BackendError(f'expected Literal, found {node_type}') + return f'FILTER strstarts(str({head}), "{node.value}")' + + def _ends_with(self, node_type: bsc.Vertex, node: ast.filter.EndsWith, head: str) -> str: + """ + """ + if not isinstance(node_type, bsc.Literal): + raise errors.BackendError(f'expected Literal, found {node_type}') + return f'FILTER strends(str({head}), "{node.value}")' + + def _less_than(self, node_type: bsc.Vertex, node: ast.filter.LessThan, head: str) -> str: + """ + """ + if not isinstance(node_type, bsc.Literal): + raise errors.BackendError(f'expected Literal, found {node_type}') + equality = '=' if not node.strict else '' + return f'FILTER ({head} <{equality} {float(node.threshold)})' + + def _greater_than(self, node_type: bsc.Vertex, node: ast.filter.GreaterThan, head: str) -> str: + """ + """ + if not isinstance(node_type, bsc.Literal): + raise errors.BackendError(f'expected Literal, found {node_type}') + equality = '=' if not node.strict else '' + return f'FILTER ({head} >{equality} {float(node.threshold)})' + +## EOF ## diff --git a/bsfs/triple_store/sparql.py b/bsfs/triple_store/sparql/sparql.py index 7516dff..99e67d6 100644 --- a/bsfs/triple_store/sparql.py +++ b/bsfs/triple_store/sparql/sparql.py @@ -1,20 +1,23 @@ -""" -Part of the BlackStar filesystem (bsfs) module. -A copy of the license is provided with the project. -Author: Matthias Baumgartner, 2022 -""" # imports +import base64 import itertools import typing + +# external imports import rdflib # bsfs imports from bsfs import schema as bsc +from bsfs.namespace import ns +from bsfs.query import ast from bsfs.utils import errors, URI # inner-module imports -from . import base +from . import parse_fetch +from . import parse_filter +from .. import base +from .distance import DISTANCE_FU # exports @@ -25,6 +28,8 @@ __all__: typing.Sequence[str] = ( ## code ## +rdflib.term.bind(ns.bsl.BinaryBlob, bytes, constructor=base64.b64decode) + class _Transaction(): """Lightweight rdflib transactions for in-memory databases.""" @@ -85,11 +90,19 @@ class SparqlStore(base.TripleStoreBase): # The local schema. _schema: bsc.Schema + # Filter parser + _filter_parser: parse_filter.Filter + + # Fetch parser + _fetch_parser: parse_fetch.Fetch + def __init__(self): super().__init__(None) self._graph = rdflib.Graph() self._transaction = _Transaction(self._graph) - self._schema = bsc.Schema.Empty() + self._schema = bsc.Schema(literals={bsc.ROOT_NUMBER.child(ns.xsd.integer)}) + self._filter_parser = parse_filter.Filter(self._graph, self._schema) + self._fetch_parser = parse_fetch.Fetch(self._schema) # NOTE: mypy and pylint complain about the **kwargs not being listed (contrasting super) # However, not having it here is clearer since it's explicit that there are no arguments. @@ -115,6 +128,16 @@ class SparqlStore(base.TripleStoreBase): # check compatibility: No contradicting definitions if not self.schema.consistent_with(schema): raise errors.ConsistencyError(f'{schema} is inconsistent with {self.schema}') + # check distance functions of features + invalid = { + (cand.uri, cand.distance) + for cand + in schema.literals() + if isinstance(cand, bsc.Feature) and cand.distance not in DISTANCE_FU} + if len(invalid) > 0: + cand, dist = zip(*invalid) + raise errors.UnsupportedError( + f'unknown distance function {",".join(dist)} in feature {", ".join(cand)}') # commit the current transaction self.commit() @@ -126,10 +149,17 @@ class SparqlStore(base.TripleStoreBase): # get deleted classes sub = self.schema - schema - # remove predicate instances for pred in sub.predicates: + # remove predicate instances for src, trg in self._graph.subject_objects(rdflib.URIRef(pred.uri)): self._transaction.remove((src, rdflib.URIRef(pred.uri), trg)) + # remove predicate definition + if pred.parent is not None: # NOTE: there shouldn't be any predicate w/o parent + self._transaction.remove(( + rdflib.URIRef(pred.uri), + rdflib.RDFS.subClassOf, + rdflib.URIRef(pred.parent.uri), + )) # remove node instances for node in sub.nodes: @@ -143,15 +173,82 @@ class SparqlStore(base.TripleStoreBase): self._transaction.remove((inst, pred, trg)) # remove instance self._transaction.remove((inst, rdflib.RDF.type, rdflib.URIRef(node.uri))) - - # NOTE: Nothing to do for literals + # remove node definition + if node.parent is not None: # NOTE: there shouldn't be any node w/o parent + self._transaction.remove(( + rdflib.URIRef(node.uri), + rdflib.RDFS.subClassOf, + rdflib.URIRef(node.parent.uri), + )) + + for lit in sub.literals: + # remove literal definition + if lit.parent is not None: # NOTE: there shouldn't be any literal w/o parent + self._transaction.remove(( + rdflib.URIRef(lit.uri), + rdflib.RDFS.subClassOf, + rdflib.URIRef(lit.parent.uri), + )) + + # add predicate, node, and literal hierarchies to the graph + for itm in itertools.chain(schema.predicates(), schema.nodes(), schema.literals()): + if itm.parent is not None: + self._transaction.add((rdflib.URIRef(itm.uri), rdflib.RDFS.subClassOf, rdflib.URIRef(itm.parent.uri))) # commit instance changes self.commit() # migrate schema self._schema = schema + self._filter_parser.schema = schema + self._fetch_parser.schema = schema + def fetch( + self, + node_type: bsc.Node, + filter: ast.filter.FilterExpression, # pylint: disable=redefined-builtin + fetch: ast.fetch.FetchExpression, + ) -> typing.Iterator[typing.Tuple[URI, str, typing.Any]]: + if node_type not in self.schema.nodes(): + raise errors.ConsistencyError(f'{node_type} is not defined in the schema') + if not isinstance(filter, ast.filter.FilterExpression): + raise TypeError(filter) + if not isinstance(fetch, ast.fetch.FetchExpression): + raise TypeError(fetch) + # compose a query from fetch and filter ast + query = self._filter_parser(node_type, filter) + query += self._fetch_parser(node_type, fetch) + # run query + emitted = set() + for result in query(self._graph): + guid = URI(result[0]) + for name, raw in zip(query.names, result[1:]): + if raw is None: # undefined value + continue + if isinstance(raw, rdflib.Literal): + value = raw.value + else: + value = URI(raw) + # emit triple + triple = (guid, name, value) + if triple not in emitted: # FIXME: needs a better solution! + emitted.add(triple) + yield guid, name, value + + def get( + self, + node_type: bsc.Node, + filter: typing.Optional[ast.filter.FilterExpression] = None, # pylint: disable=redefined-builtin + ) -> typing.Iterator[URI]: + if node_type not in self.schema.nodes(): + raise errors.ConsistencyError(f'{node_type} is not defined in the schema') + if filter is not None and not isinstance(filter, ast.filter.FilterExpression): + raise TypeError(filter) + # compose query + query = self._filter_parser(node_type, filter) + # run query + for guid, in query(self._graph): + yield URI(guid) def _has_type(self, subject: URI, node_type: bsc.Node) -> bool: """Return True if *subject* is a node of class *node_type* or a subclass thereof.""" @@ -187,7 +284,7 @@ class SparqlStore(base.TripleStoreBase): raise errors.ConsistencyError(f'{node_type} is not defined in the schema') # check and create guids for guid in guids: - subject = rdflib.URIRef(guid) + subject = rdflib.URIRef(URI(guid)) # check node existence if (subject, rdflib.RDF.type, None) in self._graph: # FIXME: node exists and may have a different type! ignore? raise? report? @@ -226,7 +323,7 @@ class SparqlStore(base.TripleStoreBase): raise errors.InstanceError(inconsistent) # check guids # FIXME: Fail or skip inexistent nodes? - guids = set(guids) + guids = {URI(guid) for guid in guids} inconsistent = {guid for guid in guids if not self._has_type(guid, node_type)} if len(inconsistent) > 0: raise errors.InstanceError(inconsistent) @@ -237,7 +334,11 @@ class SparqlStore(base.TripleStoreBase): guid = rdflib.URIRef(guid) # convert value if isinstance(predicate.range, bsc.Literal): - value = rdflib.Literal(value, datatype=rdflib.URIRef(predicate.range.uri)) + dtype = rdflib.URIRef(predicate.range.uri) + if predicate.range <= self.schema.literal(ns.bsl.BinaryBlob): + dtype = rdflib.URIRef(ns.bsl.BinaryBlob) + value = base64.b64encode(value) + value = rdflib.Literal(value, datatype=dtype) elif isinstance(predicate.range, bsc.Node): value = rdflib.URIRef(value) else: diff --git a/bsfs/triple_store/sparql/utils.py b/bsfs/triple_store/sparql/utils.py new file mode 100644 index 0000000..38062c2 --- /dev/null +++ b/bsfs/triple_store/sparql/utils.py @@ -0,0 +1,137 @@ + +# standard imports +import typing + +# external imports +import rdflib + +# bsfs imports +from bsfs.namespace import ns +from bsfs.utils import typename + +# exports +__all__: typing.Sequence[str] = ( + 'GenHopName', + 'Query', + ) + + +## code ## + +class GenHopName(): + """Generator that produces a new unique symbol name with each iteration.""" + + # Symbol name prefix. + prefix: str + + # Current counter. + curr: int + + def __init__(self, prefix: str = '?hop', start: int = 0): + self.prefix = prefix + self.curr = start - 1 + + def __next__(self): + """Generate and return the next unique name.""" + self.curr += 1 + return self.prefix + str(self.curr) + + +class Query(): + """Hold, manage, and complete partial Sparql queries.""" + + # root node type URI. + root_type: str + + # root node variable name. + root_head: str + + # (head, name) tuples (w/o root) + select: typing.Tuple[typing.Tuple[str, str], ...] + + # where statements. + where: str + + def __init__( + self, + root_type: str, + root_head: str = '?ent', + select: typing.Optional[typing.Iterable[typing.Tuple[str, str]]] = None, + where: typing.Optional[str] = None, + ): + # check arguments + if select is None: + select = [] + if where is None: + where = '' + # set members + self.root_type = root_type + self.root_head = root_head + self.select = tuple(select) # tuple ensures presistent order + self.where = where.strip() + + def __str__(self) -> str: + return self.query + + def __repr__(self) -> str: + return f'{typename(self)}({self.root_type}, {self.root_head}, {self.select}, {self.where})' + + def __eq__(self, other: typing.Any) -> bool: + return isinstance(other, type(self)) \ + and self.root_type == other.root_type \ + and self.root_head == other.root_head \ + and self.select == other.select \ + and self.where == other.where + + def __hash__(self) -> int: + return hash((type(self), self.root_type, self.root_head, self.select, self.where)) + + def __add__(self, other: typing.Any) -> 'Query': + # check other's type + if not isinstance(other, type(self)): + return NotImplemented + # check query compatibility + if not self.root_type == other.root_type: + raise ValueError(other) + if not self.root_head == other.root_head: + raise ValueError(other) + # combine selections + select = self.select + other.select + # combine conditions + conds = [] + if self.where != '': + conds.append(self.where) + if other.where != '': + conds.append(other.where) + where = ' . '.join(conds) + # return new query + return Query( + root_type=self.root_type, + root_head=self.root_head, + select=select, + where=where, + ) + + @property + def names(self) -> typing.Tuple[str, ...]: + """Return a tuple of selected variable names, excluding the root.""" + return tuple(name for _, name in self.select) + + @property + def query(self) -> str: + """Return an executable sparql query.""" + select = ' '.join(f'({head} as ?{name})' for head, name in self.select) + return f''' + SELECT DISTINCT {self.root_head} {select} + WHERE {{ + {self.root_head} <{ns.rdf.type}>/<{ns.rdfs.subClassOf}>* <{self.root_type}> . + {self.where} + }} + ORDER BY str({self.root_head}) + ''' + + def __call__(self, graph: rdflib.Graph) -> rdflib.query.Result: + """Execute the query on a *graph* and return the query result.""" + return graph.query(self.query) + +## EOF ## diff --git a/bsfs/utils/__init__.py b/bsfs/utils/__init__.py index 94680ee..d497645 100644 --- a/bsfs/utils/__init__.py +++ b/bsfs/utils/__init__.py @@ -1,15 +1,10 @@ -""" -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 . import errors -from .commons import typename +from .commons import typename, normalize_args from .uri import URI from .uuid import UUID, UCID @@ -19,6 +14,7 @@ __all__ : typing.Sequence[str] = ( 'URI', 'UUID', 'errors', + 'normalize_args', 'typename', ) diff --git a/bsfs/utils/commons.py b/bsfs/utils/commons.py index bad2fe0..a7092ae 100644 --- a/bsfs/utils/commons.py +++ b/bsfs/utils/commons.py @@ -1,14 +1,11 @@ -""" -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 typing # exports __all__: typing.Sequence[str] = ( + 'normalize_args', 'typename', ) @@ -19,5 +16,37 @@ def typename(obj) -> str: """Return the type name of *obj*.""" return type(obj).__name__ +# argument type in `normalize_args`. +ArgType = typing.TypeVar('ArgType') # pylint: disable=invalid-name # type vars don't follow the usual convention + +def normalize_args( + *args: typing.Union[ArgType, typing.Iterable[ArgType], typing.Iterator[ArgType]] + ) -> typing.Tuple[ArgType, ...]: + """Arguments to a function can be passed as individual arguments, list-like + structures, or iterables. This function processes any of these styles and + returns a tuple of the respective items. Typically used within a function + provide a flexible interface but sill have parameters in a normalized form. + + Examples: + + >>> normalize_args(0,1,2) + (1,2,3) + >>> normalize_args([0,1,2]) + (1,2,3) + >>> normalize_args(range(3)) + (1,2,3) + + """ + if len(args) == 0: # foo() + return tuple() + if len(args) > 1: # foo(0, 1, 2) + return tuple(args) # type: ignore [arg-type] # we assume that argument styles (arg vs. iterable) are not mixed. + if isinstance(args[0], abc.Iterator): # foo(iter([0,1,2])) + return tuple(args[0]) + if isinstance(args[0], abc.Iterable) and not isinstance(args[0], str): # foo([0, 1, 2]) + return tuple(args[0]) + # foo(0) + return (args[0], ) # type: ignore [return-value] # if args[0] is a str, we assume that ArgType was str. + ## EOF ## diff --git a/bsfs/utils/errors.py b/bsfs/utils/errors.py index c5e8e16..b82e6e2 100644 --- a/bsfs/utils/errors.py +++ b/bsfs/utils/errors.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 @@ -38,4 +33,10 @@ class UnreachableError(ProgrammingError): class ConfigError(_BSFSError): """User config issue.""" +class BackendError(_BSFSError): + """Could not parse an AST structure.""" + +class UnsupportedError(_BSFSError): + """Some requested functionality is not supported by an implementation.""" + ## EOF ## diff --git a/bsfs/utils/uri.py b/bsfs/utils/uri.py index 84854a4..5755a6e 100644 --- a/bsfs/utils/uri.py +++ b/bsfs/utils/uri.py @@ -1,14 +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 re import typing # constants +RX_CHARS = re.compile(r'[<>" {}|\\^]') + RX_URI = re.compile(r''' ^ (?:(?P<scheme>[^:/?#]+):)? # scheme, ://-delimited @@ -82,6 +79,9 @@ class URI(str): no claim about the validity of an URI! """ + # check characters + if RX_CHARS.search(query) is not None: + return False # check uri parts = RX_URI.match(query) if parts is not None: @@ -232,9 +232,6 @@ class URI(str): # overload formatting methods - def format(self, *args, **kwargs) -> 'URI': - return URI(super().format(*args, **kwargs)) - def __mod__(self, *args) -> 'URI': return URI(super().__mod__(*args)) diff --git a/bsfs/utils/uuid.py b/bsfs/utils/uuid.py index 6366b18..ad7fc1c 100644 --- a/bsfs/utils/uuid.py +++ b/bsfs/utils/uuid.py @@ -1,12 +1,9 @@ -""" -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 hashlib +import io +import json import os import platform import random @@ -105,4 +102,21 @@ class UCID(): with open(path, 'rb') as ifile: return HASH(ifile.read()).hexdigest() + @staticmethod + def from_buffer(buffer: io.IOBase) -> str: + """Read the content from a buffer.""" + if isinstance(buffer, io.TextIOBase): + return HASH(buffer.read().encode('utf-8', errors='ignore')).hexdigest() + return HASH(buffer.read()).hexdigest() + + @staticmethod + def from_bytes(content: bytes) -> str: + """Get the content from as bytes.""" + return HASH(content).hexdigest() + + @staticmethod + def from_dict(content: dict) -> str: + """Get the content from a dict.""" + return HASH(json.dumps(content).encode('ascii', 'ignore')).hexdigest() + ## EOF ## diff --git a/doc/Makefile b/doc/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/doc/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/doc/make.bat b/doc/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/doc/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=source
+set BUILDDIR=build
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+ echo.
+ echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+ echo.installed, then set the SPHINXBUILD environment variable to point
+ echo.to the full path of the 'sphinx-build' executable. Alternatively you
+ echo.may add the Sphinx directory to PATH.
+ echo.
+ echo.If you don't have Sphinx installed, grab it from
+ echo.https://www.sphinx-doc.org/
+ exit /b 1
+)
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+
+:end
+popd
diff --git a/doc/source/_static/arch_dark.png b/doc/source/_static/arch_dark.png Binary files differnew file mode 100644 index 0000000..b5ea1b3 --- /dev/null +++ b/doc/source/_static/arch_dark.png diff --git a/doc/source/_static/arch_dark.svg b/doc/source/_static/arch_dark.svg new file mode 100644 index 0000000..22de237 --- /dev/null +++ b/doc/source/_static/arch_dark.svg @@ -0,0 +1,500 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + width="226.45284mm" + height="347.16772mm" + viewBox="0 0 226.45283 347.16772" + version="1.1" + id="svg8" + inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)" + sodipodi:docname="architecture.svg" + inkscape:export-filename="/home/matthias/projects/black_star/modules/bsfs/doc/source/_static/architecture.png" + inkscape:export-xdpi="36.581741" + inkscape:export-ydpi="36.581741" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:dc="http://purl.org/dc/elements/1.1/"> + <defs + id="defs2"> + <marker + style="overflow:visible" + id="marker5768" + refX="0.0" + refY="0.0" + orient="auto" + inkscape:stockid="Arrow2Mstart" + inkscape:isstock="true"> + <path + transform="scale(0.6) translate(0,0)" + d="M 8.7185878,4.0337352 L -2.2072895,0.016013256 L 8.7185884,-4.0017078 C 6.9730900,-1.6296469 6.9831476,1.6157441 8.7185878,4.0337352 z " + style="stroke:context-stroke;fill-rule:evenodd;fill:context-stroke;stroke-width:0.62500000;stroke-linejoin:round" + id="path5499" /> + </marker> + <marker + style="overflow:visible;" + id="Arrow2Mend" + refX="0.0" + refY="0.0" + orient="auto" + inkscape:stockid="Arrow2Mend" + inkscape:isstock="true"> + <path + transform="scale(0.6) rotate(180) translate(0,0)" + d="M 8.7185878,4.0337352 L -2.2072895,0.016013256 L 8.7185884,-4.0017078 C 6.9730900,-1.6296469 6.9831476,1.6157441 8.7185878,4.0337352 z " + style="stroke:context-stroke;fill-rule:evenodd;fill:context-stroke;stroke-width:0.62500000;stroke-linejoin:round;" + id="path5502" /> + </marker> + <marker + style="overflow:visible;" + id="Arrow1Send" + refX="0.0" + refY="0.0" + orient="auto" + inkscape:stockid="Arrow1Send" + inkscape:isstock="true"> + <path + transform="scale(0.2) rotate(180) translate(6,0)" + style="fill-rule:evenodd;fill:context-stroke;stroke:context-stroke;stroke-width:1.0pt;" + d="M 0.0,0.0 L 5.0,-5.0 L -12.5,0.0 L 5.0,5.0 L 0.0,0.0 z " + id="path5490" /> + </marker> + <marker + style="overflow:visible;" + id="Arrow1Mend" + refX="0.0" + refY="0.0" + orient="auto" + inkscape:stockid="Arrow1Mend" + inkscape:isstock="true"> + <path + transform="scale(0.4) rotate(180) translate(10,0)" + style="fill-rule:evenodd;fill:context-stroke;stroke:context-stroke;stroke-width:1.0pt;" + d="M 0.0,0.0 L 5.0,-5.0 L -12.5,0.0 L 5.0,5.0 L 0.0,0.0 z " + id="path5484" /> + </marker> + <marker + style="overflow:visible" + id="Arrow1Mstart" + refX="0.0" + refY="0.0" + orient="auto" + inkscape:stockid="Arrow1Mstart" + inkscape:isstock="true"> + <path + transform="scale(0.4) translate(10,0)" + style="fill-rule:evenodd;fill:context-stroke;stroke:context-stroke;stroke-width:1.0pt" + d="M 0.0,0.0 L 5.0,-5.0 L -12.5,0.0 L 5.0,5.0 L 0.0,0.0 z " + id="path5481" /> + </marker> + <marker + inkscape:stockid="Arrow2Mstart" + orient="auto" + refY="0" + refX="0" + id="Arrow2Mstart" + style="overflow:visible" + inkscape:isstock="true"> + <path + id="path1226" + style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.625;stroke-linejoin:round;stroke-opacity:1" + d="M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z" + transform="scale(0.6)" + inkscape:connector-curvature="0" /> + </marker> + <marker + inkscape:isstock="true" + style="overflow:visible" + id="marker2003" + refX="0" + refY="0" + orient="auto" + inkscape:stockid="Arrow2Mend"> + <path + transform="scale(-0.6)" + d="M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z" + style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.625;stroke-linejoin:round;stroke-opacity:1" + id="path2001" + inkscape:connector-curvature="0" /> + </marker> + <marker + inkscape:isstock="true" + style="overflow:visible" + id="marker1943" + refX="0" + refY="0" + orient="auto" + inkscape:stockid="Arrow1Mend"> + <path + transform="matrix(-0.4,0,0,-0.4,-4,0)" + style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.00000003pt;stroke-opacity:1" + d="M 0,0 5,-5 -12.5,0 5,5 Z" + id="path1941" + inkscape:connector-curvature="0" /> + </marker> + <marker + inkscape:isstock="true" + style="overflow:visible" + id="marker1883" + refX="0" + refY="0" + orient="auto" + inkscape:stockid="Arrow1Send"> + <path + transform="matrix(-0.2,0,0,-0.2,-1.2,0)" + style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.00000003pt;stroke-opacity:1" + d="M 0,0 5,-5 -12.5,0 5,5 Z" + id="path1881" + inkscape:connector-curvature="0" /> + </marker> + <inkscape:path-effect + effect="bspline" + id="path-effect1392" + is_visible="true" + weight="33.333333" + steps="2" + helper_size="0" + apply_no_weight="true" + apply_with_weight="true" + only_selected="false" /> + <clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath1104"> + <rect + ry="1.9402644e-06" + y="299.74707" + x="38.425957" + height="39.287846" + width="72.863937" + id="rect1106" + style="opacity:1;vector-effect:none;fill:none;fill-opacity:0.99141636;stroke:#ab0000;stroke-width:1.50988853;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" /> + </clipPath> + </defs> + <sodipodi:namedview + id="base" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageopacity="0.0" + inkscape:pageshadow="2" + inkscape:zoom="0.48410751" + inkscape:cx="549.46473" + inkscape:cy="378.01521" + inkscape:document-units="mm" + inkscape:current-layer="layer1" + showgrid="false" + inkscape:snap-bbox="true" + inkscape:bbox-paths="true" + inkscape:bbox-nodes="true" + inkscape:snap-bbox-edge-midpoints="true" + inkscape:snap-bbox-midpoints="true" + inkscape:object-paths="true" + inkscape:snap-intersection-paths="true" + inkscape:snap-smooth-nodes="true" + inkscape:snap-midpoints="true" + inkscape:snap-object-midpoints="true" + inkscape:snap-center="true" + inkscape:snap-text-baseline="true" + inkscape:snap-page="true" + showguides="true" + inkscape:guide-bbox="true" + inkscape:snap-global="false" + fit-margin-top="0" + fit-margin-left="0" + fit-margin-right="0" + fit-margin-bottom="0" + inkscape:window-width="1920" + inkscape:window-height="1147" + inkscape:window-x="0" + inkscape:window-y="29" + inkscape:window-maximized="1" + inkscape:pagecheckerboard="0" /> + <metadata + id="metadata5"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title /> + </cc:Work> + </rdf:RDF> + </metadata> + <g + inkscape:label="Layer 1" + inkscape:groupmode="layer" + id="layer1" + transform="translate(62.276606,-425.46216)"> + <text + xml:space="preserve" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#c5c9c7;fill-opacity:1;stroke:none;stroke-width:0.264583" + x="32.604725" + y="629.79327" + id="text858"><tspan + sodipodi:role="line" + id="tspan856" + x="32.604725" + y="629.79327" + style="stroke-width:0.264583;fill:#c5c9c7;fill-opacity:1">Graph</tspan></text> + <text + xml:space="preserve" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#c5c9c7;fill-opacity:1;stroke:none;stroke-width:0.264583" + x="31.855417" + y="653.12" + id="text862"><tspan + sodipodi:role="line" + id="tspan860" + x="31.855417" + y="653.12" + style="stroke-width:0.264583;fill:#c5c9c7;fill-opacity:1">Nodes</tspan></text> + <text + xml:space="preserve" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#c5c9c7;fill-opacity:1;stroke:none;stroke-width:0.264583" + x="41.7075" + y="675.25732" + id="text866"><tspan + sodipodi:role="line" + id="tspan864" + x="41.7075" + y="675.25732" + style="stroke-width:0.264583;fill:#c5c9c7;fill-opacity:1">AC</tspan></text> + <rect + style="vector-effect:none;fill:none;fill-opacity:1;stroke:#c5c9c7;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" + id="rect854-9-6" + width="58.738262" + height="66.901787" + x="19.325045" + y="615.82233" /> + <text + xml:space="preserve" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#c5c9c7;fill-opacity:1;stroke:none;stroke-width:0.264583" + x="26.010805" + y="742.52582" + id="text870"><tspan + sodipodi:role="line" + id="tspan868" + x="26.010805" + y="742.52582" + style="stroke-width:0.264583;fill:#c5c9c7;fill-opacity:1">Backend</tspan></text> + <rect + style="vector-effect:none;fill:none;fill-opacity:1;stroke:#c5c9c7;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" + id="rect854-9-7" + width="58.738262" + height="66.901787" + x="19.325045" + y="705.22809" /> + <text + xml:space="preserve" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#c5c9c7;fill-opacity:1;stroke:none;stroke-width:0.264583" + x="38.606937" + y="541.41333" + id="text874"><tspan + sodipodi:role="line" + id="tspan872" + x="38.606937" + y="541.41333" + style="stroke-width:0.264583;fill:#c5c9c7;fill-opacity:1">App</tspan></text> + <text + xml:space="preserve" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#c5c9c7;fill-opacity:1;stroke:none;stroke-width:0.264583" + x="40.692081" + y="563.71997" + id="text878"><tspan + sodipodi:role="line" + id="tspan876" + x="40.692081" + y="563.71997" + style="stroke-width:0.264583;fill:#c5c9c7;fill-opacity:1">Lib</tspan></text> + <rect + style="vector-effect:none;fill:none;fill-opacity:1;stroke:#c5c9c7;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" + id="rect854-9-9" + width="58.738262" + height="66.901787" + x="19.325045" + y="526.41656" /> + <text + xml:space="preserve" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#c5c9c7;fill-opacity:1;stroke:none;stroke-width:0.264583" + x="31.886417" + y="585.92139" + id="text882"><tspan + sodipodi:role="line" + id="tspan880" + x="31.886417" + y="585.92139" + style="stroke-width:0.264583;fill:#c5c9c7;fill-opacity:1">Config</tspan></text> + <rect + transform="rotate(-90)" + y="-34.720181" + x="-772.12939" + height="14.9375" + width="230.69272" + id="rect815" + style="vector-effect:none;fill:none;fill-opacity:1;stroke:#c5c9c7;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" /> + <text + transform="rotate(-90)" + id="text819" + y="-23.857788" + x="-677.57135" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#c5c9c7;fill-opacity:1;stroke:none;stroke-width:0.264583" + xml:space="preserve"><tspan + style="stroke-width:0.264583;fill:#c5c9c7;fill-opacity:1" + y="-23.857788" + x="-677.57135" + id="tspan817" + sodipodi:role="line">Query AST</tspan></text> + <rect + transform="rotate(-90)" + y="-61.77615" + x="-772.12897" + height="15.004211" + width="230.69226" + id="rect815-3" + style="vector-effect:none;fill:none;fill-opacity:1;stroke:#c5c9c7;stroke-width:1.00091;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" /> + <text + transform="rotate(-90)" + id="text819-6" + y="-50.361935" + x="-670.42908" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#c5c9c7;fill-opacity:1;stroke:none;stroke-width:0.264583" + xml:space="preserve"><tspan + style="stroke-width:0.264583;fill:#c5c9c7;fill-opacity:1" + y="-50.361935" + x="-670.42908" + id="tspan817-7" + sodipodi:role="line">Schema</tspan></text> + <rect + transform="rotate(-90)" + y="-7.7468448" + x="-772.1449" + height="15.036049" + width="230.70819" + id="rect815-3-7" + style="vector-effect:none;fill:none;fill-opacity:1;stroke:#c5c9c7;stroke-width:0.969072;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" /> + <text + transform="matrix(0,-0.96614119,1.0350454,0,0,0)" + id="text819-6-5" + y="3.5665975" + x="-691.72668" + style="font-style:normal;font-weight:normal;font-size:10.2467px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#c5c9c7;fill-opacity:1;stroke:none;stroke-width:0.256167" + xml:space="preserve"><tspan + style="stroke-width:0.256167;fill:#c5c9c7;fill-opacity:1" + y="3.5665975" + x="-691.72668" + id="tspan817-7-9" + sodipodi:role="line">Utils</tspan></text> + <g + id="g1066-2" + transform="translate(79.507472,180.86615)" /> + <path + style="fill:none;stroke:#c5c9c7;stroke-width:0.98072147;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + d="m 97.390174,771.63946 c 23.866256,0 0,-33.21047 23.866256,-33.21047 -23.866256,0 0,-33.2105 -23.866256,-33.2105" + id="path1406-3-2-6" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + <text + xml:space="preserve" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#c5c9c7;fill-opacity:1;stroke:none;stroke-width:0.264583" + x="128.08542" + y="563.90021" + id="text1481"><tspan + sodipodi:role="line" + id="tspan1479" + x="128.08542" + y="563.90021" + style="stroke-width:0.264583;fill:#c5c9c7;fill-opacity:1">Front</tspan></text> + <text + xml:space="preserve" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#c5c9c7;fill-opacity:1;stroke:none;stroke-width:0.264583" + x="128.52983" + y="653.37573" + id="text1481-1"><tspan + sodipodi:role="line" + id="tspan1479-5" + x="128.52983" + y="653.37573" + style="stroke-width:0.264583;fill:#c5c9c7;fill-opacity:1">Center</tspan></text> + <text + xml:space="preserve" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#c5c9c7;fill-opacity:1;stroke:none;stroke-width:0.264583" + x="128.08542" + y="742.37445" + id="text1481-5"><tspan + sodipodi:role="line" + id="tspan1479-4" + x="128.08542" + y="742.37445" + style="stroke-width:0.264583;fill:#c5c9c7;fill-opacity:1">Back</tspan></text> + <text + xml:space="preserve" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#c5c9c7;fill-opacity:1;stroke:none;stroke-width:0.264583" + x="-51.901089" + y="536.83838" + id="text1481-7"><tspan + sodipodi:role="line" + id="tspan1479-6" + x="-51.901089" + y="536.83838" + style="stroke-width:0.264583;fill:#c5c9c7;fill-opacity:1">Envelope</tspan></text> + <path + style="fill:none;stroke:#c5c9c7;stroke-width:0.98072147;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + d="m 97.390174,682.73371 c 23.866246,0 0,-33.21047 23.866246,-33.21047 -23.866246,0 0,-33.2105 -23.866246,-33.2105" + id="path1406-3-2-6-1" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + <path + style="fill:none;stroke:#c5c9c7;stroke-width:0.98072147;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + d="m 97.390174,593.32795 c 23.866246,0 0,-33.21047 23.866246,-33.21047 -23.866246,0 0,-33.2105 -23.866246,-33.2105" + id="path1406-3-2-6-9" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + <text + id="text1593" + y="491.35275" + x="-4.7864752" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#c5c9c7;fill-opacity:1;stroke:none;stroke-width:0.264583" + xml:space="preserve"><tspan + style="stroke-width:0.264583;fill:#c5c9c7;fill-opacity:1" + y="491.35275" + x="-4.7864752" + id="tspan1591" + sodipodi:role="line">Client</tspan></text> + <g + transform="translate(-64.429786,152.46769)" + style="stroke:#c5c9c7;stroke-width:1;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="g1110"> + <circle + style="opacity:1;vector-effect:none;fill:none;fill-opacity:0.991416;stroke:#c5c9c7;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" + id="path1089" + cx="75.138489" + cy="287.25885" + r="13.76438" /> + <ellipse + style="opacity:1;vector-effect:none;fill:none;fill-opacity:0.991416;stroke:#c5c9c7;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" + id="path1091" + cx="75.138489" + cy="352.85028" + rx="30.794813" + ry="49.827057" + clip-path="url(#clipPath1104)" /> + </g> + <path + style="mix-blend-mode:normal;fill:none;fill-opacity:0.101961;stroke:#c5c9c7;stroke-width:0.718;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-start:url(#marker5768);marker-end:url(#Arrow2Mend);paint-order:normal" + d="m -26.95943,522.5725 c 0,-0.24385 0.07083,-4.09079 0.07878,-4.32466 0.761115,-22.40679 37.602027,0.47268 37.602027,-22.88944 0,23.36145 36.838813,0.48396 37.601969,22.88752 0.008,0.23448 0.0397,4.10164 0.0397,4.34615" + id="path1406-3-2" + inkscape:connector-curvature="0" + sodipodi:nodetypes="cscsc" /> + <rect + style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#c5c9c7;stroke-width:0.96907479;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" + id="rect815-3-7-7" + width="69.081276" + height="15.036072" + x="-61.792068" + y="526.40063" /> + </g> +</svg> diff --git a/doc/source/_static/arch_light.png b/doc/source/_static/arch_light.png Binary files differnew file mode 100644 index 0000000..c210ecf --- /dev/null +++ b/doc/source/_static/arch_light.png diff --git a/doc/source/_static/arch_light.svg b/doc/source/_static/arch_light.svg new file mode 100644 index 0000000..e93694c --- /dev/null +++ b/doc/source/_static/arch_light.svg @@ -0,0 +1,499 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + width="226.45284mm" + height="347.16772mm" + viewBox="0 0 226.45283 347.16772" + version="1.1" + id="svg8" + inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)" + sodipodi:docname="arch_light.svg" + inkscape:export-filename="/home/matthias/projects/black_star/modules/bsfs/doc/source/_static/arch_light.png" + inkscape:export-xdpi="36.581741" + inkscape:export-ydpi="36.581741" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:dc="http://purl.org/dc/elements/1.1/"> + <defs + id="defs2"> + <marker + style="overflow:visible" + id="marker5768" + refX="0.0" + refY="0.0" + orient="auto" + inkscape:stockid="Arrow2Mstart" + inkscape:isstock="true"> + <path + transform="scale(0.6) translate(0,0)" + d="M 8.7185878,4.0337352 L -2.2072895,0.016013256 L 8.7185884,-4.0017078 C 6.9730900,-1.6296469 6.9831476,1.6157441 8.7185878,4.0337352 z " + style="stroke:context-stroke;fill-rule:evenodd;fill:context-stroke;stroke-width:0.62500000;stroke-linejoin:round" + id="path5499" /> + </marker> + <marker + style="overflow:visible;" + id="Arrow2Mend" + refX="0.0" + refY="0.0" + orient="auto" + inkscape:stockid="Arrow2Mend" + inkscape:isstock="true"> + <path + transform="scale(0.6) rotate(180) translate(0,0)" + d="M 8.7185878,4.0337352 L -2.2072895,0.016013256 L 8.7185884,-4.0017078 C 6.9730900,-1.6296469 6.9831476,1.6157441 8.7185878,4.0337352 z " + style="stroke:context-stroke;fill-rule:evenodd;fill:context-stroke;stroke-width:0.62500000;stroke-linejoin:round;" + id="path5502" /> + </marker> + <marker + style="overflow:visible;" + id="Arrow1Send" + refX="0.0" + refY="0.0" + orient="auto" + inkscape:stockid="Arrow1Send" + inkscape:isstock="true"> + <path + transform="scale(0.2) rotate(180) translate(6,0)" + style="fill-rule:evenodd;fill:context-stroke;stroke:context-stroke;stroke-width:1.0pt;" + d="M 0.0,0.0 L 5.0,-5.0 L -12.5,0.0 L 5.0,5.0 L 0.0,0.0 z " + id="path5490" /> + </marker> + <marker + style="overflow:visible;" + id="Arrow1Mend" + refX="0.0" + refY="0.0" + orient="auto" + inkscape:stockid="Arrow1Mend" + inkscape:isstock="true"> + <path + transform="scale(0.4) rotate(180) translate(10,0)" + style="fill-rule:evenodd;fill:context-stroke;stroke:context-stroke;stroke-width:1.0pt;" + d="M 0.0,0.0 L 5.0,-5.0 L -12.5,0.0 L 5.0,5.0 L 0.0,0.0 z " + id="path5484" /> + </marker> + <marker + style="overflow:visible" + id="Arrow1Mstart" + refX="0.0" + refY="0.0" + orient="auto" + inkscape:stockid="Arrow1Mstart" + inkscape:isstock="true"> + <path + transform="scale(0.4) translate(10,0)" + style="fill-rule:evenodd;fill:context-stroke;stroke:context-stroke;stroke-width:1.0pt" + d="M 0.0,0.0 L 5.0,-5.0 L -12.5,0.0 L 5.0,5.0 L 0.0,0.0 z " + id="path5481" /> + </marker> + <marker + inkscape:stockid="Arrow2Mstart" + orient="auto" + refY="0" + refX="0" + id="Arrow2Mstart" + style="overflow:visible" + inkscape:isstock="true"> + <path + id="path1226" + style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.625;stroke-linejoin:round;stroke-opacity:1" + d="M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z" + transform="scale(0.6)" + inkscape:connector-curvature="0" /> + </marker> + <marker + inkscape:isstock="true" + style="overflow:visible" + id="marker2003" + refX="0" + refY="0" + orient="auto" + inkscape:stockid="Arrow2Mend"> + <path + transform="scale(-0.6)" + d="M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z" + style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.625;stroke-linejoin:round;stroke-opacity:1" + id="path2001" + inkscape:connector-curvature="0" /> + </marker> + <marker + inkscape:isstock="true" + style="overflow:visible" + id="marker1943" + refX="0" + refY="0" + orient="auto" + inkscape:stockid="Arrow1Mend"> + <path + transform="matrix(-0.4,0,0,-0.4,-4,0)" + style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.00000003pt;stroke-opacity:1" + d="M 0,0 5,-5 -12.5,0 5,5 Z" + id="path1941" + inkscape:connector-curvature="0" /> + </marker> + <marker + inkscape:isstock="true" + style="overflow:visible" + id="marker1883" + refX="0" + refY="0" + orient="auto" + inkscape:stockid="Arrow1Send"> + <path + transform="matrix(-0.2,0,0,-0.2,-1.2,0)" + style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.00000003pt;stroke-opacity:1" + d="M 0,0 5,-5 -12.5,0 5,5 Z" + id="path1881" + inkscape:connector-curvature="0" /> + </marker> + <inkscape:path-effect + effect="bspline" + id="path-effect1392" + is_visible="true" + weight="33.333333" + steps="2" + helper_size="0" + apply_no_weight="true" + apply_with_weight="true" + only_selected="false" /> + <clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath1104"> + <rect + ry="1.9402644e-06" + y="299.74707" + x="38.425957" + height="39.287846" + width="72.863937" + id="rect1106" + style="opacity:1;vector-effect:none;fill:none;fill-opacity:0.99141636;stroke:#ab0000;stroke-width:1.50988853;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" /> + </clipPath> + </defs> + <sodipodi:namedview + id="base" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageopacity="0.0" + inkscape:pageshadow="2" + inkscape:zoom="0.48410751" + inkscape:cx="532.93947" + inkscape:cy="378.01521" + inkscape:document-units="mm" + inkscape:current-layer="layer1" + showgrid="false" + inkscape:snap-bbox="true" + inkscape:bbox-paths="true" + inkscape:bbox-nodes="true" + inkscape:snap-bbox-edge-midpoints="true" + inkscape:snap-bbox-midpoints="true" + inkscape:object-paths="true" + inkscape:snap-intersection-paths="true" + inkscape:snap-smooth-nodes="true" + inkscape:snap-midpoints="true" + inkscape:snap-object-midpoints="true" + inkscape:snap-center="true" + inkscape:snap-text-baseline="true" + inkscape:snap-page="true" + showguides="true" + inkscape:guide-bbox="true" + inkscape:snap-global="false" + fit-margin-top="0" + fit-margin-left="0" + fit-margin-right="0" + fit-margin-bottom="0" + inkscape:window-width="1920" + inkscape:window-height="1147" + inkscape:window-x="0" + inkscape:window-y="29" + inkscape:window-maximized="1" + inkscape:pagecheckerboard="0" /> + <metadata + id="metadata5"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + </cc:Work> + </rdf:RDF> + </metadata> + <g + inkscape:label="Layer 1" + inkscape:groupmode="layer" + id="layer1" + transform="translate(62.276606,-425.46216)"> + <text + xml:space="preserve" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583" + x="32.604725" + y="629.79327" + id="text858"><tspan + sodipodi:role="line" + id="tspan856" + x="32.604725" + y="629.79327" + style="stroke-width:0.264583;fill:#000000;fill-opacity:1">Graph</tspan></text> + <text + xml:space="preserve" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583" + x="31.855417" + y="653.12" + id="text862"><tspan + sodipodi:role="line" + id="tspan860" + x="31.855417" + y="653.12" + style="stroke-width:0.264583;fill:#000000;fill-opacity:1">Nodes</tspan></text> + <text + xml:space="preserve" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583" + x="41.7075" + y="675.25732" + id="text866"><tspan + sodipodi:role="line" + id="tspan864" + x="41.7075" + y="675.25732" + style="stroke-width:0.264583;fill:#000000;fill-opacity:1">AC</tspan></text> + <rect + style="vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" + id="rect854-9-6" + width="58.738262" + height="66.901787" + x="19.325045" + y="615.82233" /> + <text + xml:space="preserve" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583" + x="26.010805" + y="742.52582" + id="text870"><tspan + sodipodi:role="line" + id="tspan868" + x="26.010805" + y="742.52582" + style="stroke-width:0.264583;fill:#000000;fill-opacity:1">Backend</tspan></text> + <rect + style="vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" + id="rect854-9-7" + width="58.738262" + height="66.901787" + x="19.325045" + y="705.22809" /> + <text + xml:space="preserve" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583" + x="38.606937" + y="541.41333" + id="text874"><tspan + sodipodi:role="line" + id="tspan872" + x="38.606937" + y="541.41333" + style="stroke-width:0.264583;fill:#000000;fill-opacity:1">App</tspan></text> + <text + xml:space="preserve" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583" + x="40.692081" + y="563.71997" + id="text878"><tspan + sodipodi:role="line" + id="tspan876" + x="40.692081" + y="563.71997" + style="stroke-width:0.264583;fill:#000000;fill-opacity:1">Lib</tspan></text> + <rect + style="vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" + id="rect854-9-9" + width="58.738262" + height="66.901787" + x="19.325045" + y="526.41656" /> + <text + xml:space="preserve" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583" + x="31.886417" + y="585.92139" + id="text882"><tspan + sodipodi:role="line" + id="tspan880" + x="31.886417" + y="585.92139" + style="stroke-width:0.264583;fill:#000000;fill-opacity:1">Config</tspan></text> + <rect + transform="rotate(-90)" + y="-34.720181" + x="-772.12939" + height="14.9375" + width="230.69272" + id="rect815" + style="vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" /> + <text + transform="rotate(-90)" + id="text819" + y="-23.857788" + x="-677.57135" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583" + xml:space="preserve"><tspan + style="stroke-width:0.264583;fill:#000000;fill-opacity:1" + y="-23.857788" + x="-677.57135" + id="tspan817" + sodipodi:role="line">Query AST</tspan></text> + <rect + transform="rotate(-90)" + y="-61.77615" + x="-772.12897" + height="15.004211" + width="230.69226" + id="rect815-3" + style="vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:1.00091;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" /> + <text + transform="rotate(-90)" + id="text819-6" + y="-50.361935" + x="-670.42908" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583" + xml:space="preserve"><tspan + style="stroke-width:0.264583;fill:#000000;fill-opacity:1" + y="-50.361935" + x="-670.42908" + id="tspan817-7" + sodipodi:role="line">Schema</tspan></text> + <rect + transform="rotate(-90)" + y="-7.7468448" + x="-772.1449" + height="15.036049" + width="230.70819" + id="rect815-3-7" + style="vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.969072;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" /> + <text + transform="matrix(0,-0.96614119,1.0350454,0,0,0)" + id="text819-6-5" + y="3.5665975" + x="-691.72668" + style="font-style:normal;font-weight:normal;font-size:10.2467px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.256167" + xml:space="preserve"><tspan + style="stroke-width:0.256167;fill:#000000;fill-opacity:1" + y="3.5665975" + x="-691.72668" + id="tspan817-7-9" + sodipodi:role="line">Utils</tspan></text> + <g + id="g1066-2" + transform="translate(79.507472,180.86615)" /> + <path + style="fill:none;stroke:#000000;stroke-width:0.98072147;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + d="m 97.390174,771.63946 c 23.866256,0 0,-33.21047 23.866256,-33.21047 -23.866256,0 0,-33.2105 -23.866256,-33.2105" + id="path1406-3-2-6" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + <text + xml:space="preserve" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583" + x="128.08542" + y="563.90021" + id="text1481"><tspan + sodipodi:role="line" + id="tspan1479" + x="128.08542" + y="563.90021" + style="stroke-width:0.264583;fill:#000000;fill-opacity:1">Front</tspan></text> + <text + xml:space="preserve" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583" + x="128.52983" + y="653.37573" + id="text1481-1"><tspan + sodipodi:role="line" + id="tspan1479-5" + x="128.52983" + y="653.37573" + style="stroke-width:0.264583;fill:#000000;fill-opacity:1">Center</tspan></text> + <text + xml:space="preserve" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583" + x="128.08542" + y="742.37445" + id="text1481-5"><tspan + sodipodi:role="line" + id="tspan1479-4" + x="128.08542" + y="742.37445" + style="stroke-width:0.264583;fill:#000000;fill-opacity:1">Back</tspan></text> + <text + xml:space="preserve" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583" + x="-51.901089" + y="536.83838" + id="text1481-7"><tspan + sodipodi:role="line" + id="tspan1479-6" + x="-51.901089" + y="536.83838" + style="stroke-width:0.264583;fill:#000000;fill-opacity:1">Envelope</tspan></text> + <path + style="fill:none;stroke:#000000;stroke-width:0.98072147;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + d="m 97.390174,682.73371 c 23.866246,0 0,-33.21047 23.866246,-33.21047 -23.866246,0 0,-33.2105 -23.866246,-33.2105" + id="path1406-3-2-6-1" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + <path + style="fill:none;stroke:#000000;stroke-width:0.98072147;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + d="m 97.390174,593.32795 c 23.866246,0 0,-33.21047 23.866246,-33.21047 -23.866246,0 0,-33.2105 -23.866246,-33.2105" + id="path1406-3-2-6-9" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + <text + id="text1593" + y="491.35275" + x="-4.7864752" + style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583" + xml:space="preserve"><tspan + style="stroke-width:0.264583;fill:#000000;fill-opacity:1" + y="491.35275" + x="-4.7864752" + id="tspan1591" + sodipodi:role="line">Client</tspan></text> + <g + transform="translate(-64.429786,152.46769)" + style="stroke:#000000;stroke-width:1;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="g1110"> + <circle + style="opacity:1;vector-effect:none;fill:none;fill-opacity:0.991416;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" + id="path1089" + cx="75.138489" + cy="287.25885" + r="13.76438" /> + <ellipse + style="opacity:1;vector-effect:none;fill:none;fill-opacity:0.991416;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" + id="path1091" + cx="75.138489" + cy="352.85028" + rx="30.794813" + ry="49.827057" + clip-path="url(#clipPath1104)" /> + </g> + <path + style="mix-blend-mode:normal;fill:none;fill-opacity:0.101961;stroke:#000000;stroke-width:0.718;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-start:url(#marker5768);marker-end:url(#Arrow2Mend);paint-order:normal" + d="m -26.95943,522.5725 c 0,-0.24385 0.07083,-4.09079 0.07878,-4.32466 0.761115,-22.40679 37.602027,0.47268 37.602027,-22.88944 0,23.36145 36.838813,0.48396 37.601969,22.88752 0.008,0.23448 0.0397,4.10164 0.0397,4.34615" + id="path1406-3-2" + inkscape:connector-curvature="0" + sodipodi:nodetypes="cscsc" /> + <rect + style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.96907479;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" + id="rect815-3-7-7" + width="69.081276" + height="15.036072" + x="-61.792068" + y="526.40063" /> + </g> +</svg> diff --git a/doc/source/architecture.rst b/doc/source/architecture.rst new file mode 100644 index 0000000..4cca49a --- /dev/null +++ b/doc/source/architecture.rst @@ -0,0 +1,87 @@ + +Architecture +============ + +The BSFS stack can be coarsely divided into four parts (see the image below). + +* Envelope: Essentials and utils used throughout the whole codebase. +* Front: End-user applications and APIs. +* Center: The core interfaces and functionality. +* Back: The triple store backends. + +Details of these components are given in the sections below. + + +.. image:: _static/arch_light.png + :class: only-light + +.. image:: _static/arch_dark.png + :class: only-dark + + +Envelope +-------- + +Most notably, the envelope covers the :class:`Schema <bsfs.schema.schema.Schema>` and the :mod:`Query syntax trees (AST) <bsfs.query.ast>`. +Both of them essential for all parts of the BSFS stack. +For example, the schema is specified by the user via the :func:`Migrate <bsfs.apps.migrate.main>` command, checked and extended by the :class:`Graph <bsfs.graph.graph.Graph>`, and ultimately stored by a :class:`Triple Store backend <bsfs.triple_store.base.TripleStoreBase>`. +Similarly, the Query AST may be provided by a caller and is translated to a database query by a backend. +In addition, the envelope also contains some classes to handle URIs: +:class:`URI <bsfs.utils.uri.URI>` defines the URI base class, +:class:`Namespace <bsfs.namespace.Namespace>` provides shortcuts to generate URIs, and +:mod:`UUID <bsfs.utils.uuid>` is used to generate unique URIs. + + +Front +----- + +The front consists of exposed interfaces such as end-user applications or APIs, +and all utils needed to offer this functionality. +See :mod:`bsfs.apps` and :mod:`bsfs.front`. + + +Center +------ + +The heart of BSFS is grouped around the :mod:`bsfs.graph` module. +These classes provide the interface to navigate and manipulate the file graph +in a safe and programmer friendly manner. +Some of them are indirectly exposed through the higher-level APIs. + +The two core design principles of BSFS are the focus on nodes and batch processing. +They are realized in the the Graph and Nodes classes. +The :class:`Graph class <bsfs.graph.graph.Graph>` manages the graph as a whole, +and offers methods to get a specific set of Nodes. +In turn, the :class:`Nodes class <bsfs.graph.nodes.Nodes>` represents such a set of nodes, +and performs operations on the whole node set at once. +Besides, the :mod:`bsfs.graph` module also comes with some syntactic sugar. + +Example:: + + # Open a file graph. + from bsfs import Open, ns + graph = Open(...) + # Get all nodes of type File. + nodes = graph.all(ns.bsfs.File) + # Set the author of all nodes at once. + nodes.set(ns.bse.author, 'Myself') + # Retrieve the author of all nodes at once. + set(nodes.get(ns.bse.author, node=False)) + # Same as above, but shorter. + set(nodes.comment(node=False)) + + +Back +---- + +There are various graph databases (e.g., `RDFLib`_, `Blazegraph`_, `Titan`_, etc.) +and it would be foolish to replicate the work that others have done. +Instead, we use third-party stores that take care of how to store and manage the data. +The :class:`Backend base class <bsfs.triple_store.base.TripleStoreBase>` defines the +interface to integrate any such third-party store to BSFS. +Besides storing the data, a triple store backend also need to track the current schema. + + +.. _RDFLib: https://rdflib.readthedocs.io/en/stable/index.html +.. _Blazegraph: https://blazegraph.com/ +.. _Titan: http://titan.thinkaurelius.com/ diff --git a/doc/source/concepts.rst b/doc/source/concepts.rst new file mode 100644 index 0000000..9c2ed43 --- /dev/null +++ b/doc/source/concepts.rst @@ -0,0 +1,98 @@ + +Core concepts +============= + +In the following, we present a few core concepts that should help in understanding the BSFS operations and codebase. + + +Graph storage +------------- + +`RDF`_ describes a network or graph like the file graph as a set of +*(subject, predicate, object)* triples. +*Subject* is the identifier of the source node, +*object* is the identifier of the target node (or a literal value), +and *predicate* is the type of relation between the source node and the target. +As suggested by `RDF`_, we use URIs to identify nodes and predicates. +For example, a triple that assigns me as the author of a file could look like this:: + + <http://example.com/file#1234> <https://bsfs.io/schema/Entity#author> <http://example.com/me> + +Note that alternatively, the *object* could also be a literal value ("me"):: + + <http://example.com/file#1234> <https://bsfs.io/schema/Entity#author> "me" + +There are a number of graph databases that support this or an analoguous paradigm, +such as `RDFLib`_, `Blazegraph`_, `TypeDB`_, `Titan`_, +and `many more <https://en.wikipedia.org/wiki/Graph_database#List_of_graph_databases>`_. +BSFS uses such a third-party graph database to store its file graph. + +As usual in database systems, +we have to distinguish schema data (that coverns the structure of the storage) +from instance data (the actual database content). +Similar to relational database systems, +both kinds of data can be represented as triples, +and subsequently stored within the same graph storage +(although one might need to separate them logically). +In BSFS, we employ an explicit schema (see next section) that is managed alongside the data. + + + +Schema +------ + +BSFS ensures consistency across multiple distributed client applications +by maintaining an explicit schema that governs node types and predicates. +Furthermore, exposing the schema allows client to run a number of compatibility and validity checks +locally, and a graph database may use the schema to optimize its storage or operations. + +In BSFS, the schema is initially provided by the system administrator +(usually in the `Turtle`_ format) +and subsequently stored by the backend. +The default schema defines three root types +(``bsfs:Node``, ``bsfs:Predicate``, and ``bsfs:Literal``), +and BSFS expects any node, literal, or predicate to be derived from these roots. + +For example, a new predicate can be defined like so:: + + # define some abbreviations + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix bsfs: <http://schema.bsfs.io/> + prefix bse: <http://schema.bsfs.io/Entity#> + + # define a node type + bsfs:Entity rdfs:subClassOf bsfs:Node . + + # define a literal type + xsd:string rdfs:subClassOf bsfs:Literal . + + # define a predicate ("author of a node") + bse:author rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range xsd:string . + +BSFS checks all requests and rejects queries or operations that violate the schema. + + +Querying +-------- + +BSFS at its core is not much more than a translator from a user query into a graph database query. +It operates directly on three abstract syntax trees (AST), +to run fetch, search, or sort, queries respectively. +By not using an existing query language, +we avoid an unnecessary and possibly expensive parsing step. +Some routines create an AST internally (e.g., :func:`bsfs.graph.graph.Graph.all`), +others accept an user-defined AST (e.g., :func:`bsfs.graph.graph.Graph.get`). +One way or another, the AST is validated against the schema, +and access control conditions are added. + + +.. _RDF: https://www.w3.org/RDF/ +.. _RDFLib: https://rdflib.readthedocs.io/en/stable/index.html +.. _Blazegraph: https://blazegraph.com/ +.. _Titan: http://titan.thinkaurelius.com/ +.. _TypeDB: https://vaticle.com/ +.. _Turtle: https://www.w3.org/TR/turtle/ + + diff --git a/doc/source/conf.py b/doc/source/conf.py new file mode 100644 index 0000000..6de4993 --- /dev/null +++ b/doc/source/conf.py @@ -0,0 +1,37 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'Black Star File System' +copyright = '2023, Matthias Baumgartner' +author = 'Matthias Baumgartner' +release = '0.5' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx_copybutton', + 'sphinx.ext.autodoc', + ] + +templates_path = ['_templates'] +exclude_patterns = [] + + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'furo' +html_static_path = ['_static'] + +html_title = 'bsfs' +html_theme_options = { + 'announcement': '<em>This project is under heavy development and subject to rapid changes. Use at your own discretion.</em>', + } + diff --git a/doc/source/index.rst b/doc/source/index.rst new file mode 100644 index 0000000..91d53f6 --- /dev/null +++ b/doc/source/index.rst @@ -0,0 +1,75 @@ + +The Black Star File System +========================== + +A file system has two roles: It has to specify how to write files to a medium, and it has to define how a user can access files. +Most file systems focus on the first role and adopt the standard directory tree approach for the second role. +It is of course necessary to solve the challenges of medium access, but we should not neglect the user's perspective. +As a user, I mostly care about how how conveniently I can organize my data, and quickly I can access relevant information. +The hierarchical approach is rather restrictive in this regard: +You can only organize files in a directory tree [#f1]_, and search tasks often require third-party tools like `find`_ or `locate`_. + +Tagging file systems proposed an alternative file organization model. +Instead of a placing files in directories, they assign one or more (user-defined) tags to each file. +This increases the flexibility over a hierarchical data model, +because you can group any combination of files, and each file can be a part of various groups. +Semantic file systems push this idea one step further by trying to understand +the data they're dealing with. +For example, files can be grouped by their data type (documents), file format (odt), +author (yourself), topic (information management), etc. +The benefit for the user is that they can browse their files by association rather than by location --- similar to how we nagivate the Web. + +Clearly, the hierarchical approach is insufficient to organize this variety of information. +Instead, we need a network of files, +where they can be connected to each other, their properties, or to auxiliary nodes +(such as tags, collections, etc.) under a given relationship. +We call this the file graph. +With the *Black Star File System (BSFS)*, you can store, manage, and query such a file graph. + +.. + TODO: Clarify + * Different relationships + * Properties and auxiliary nodes + + TODO: File graph image + TODO: SFS/TFS references + + TODO: BSFS features + Within BSFS, you can store the file content, file metadata, + and content-derived information (e.g., features) alike. + + Within the file graph, we link files directly, + through properties, or through intermediate nodes. + +The Black Star File System is designed with three query patterns in mind: +navigation, search, and browsing. + +The **navigation** pattern describes the case when the user knows exactly what they want, +and they already have an address or id of the target file. +BSFS identifies each file with a unique URI, +or you can quickly navigate to a file via its name or other file properties. + +A **search** occurs when the user lacks the specific address or identifier to a target file, +but they have relatively clear and narrow search criteria. +With BSFS, you can search by file properties (name, size), content (keywords, features), +or associations to other files and auxiliary nodes (tags, collections). + +**Browsing** takes place when the user has only vague query criteria but wants to quickly scan and compare many files. +In BSFS, you can browse along file associations and rank results by a variety of similarity metrics. + +.. toctree:: + :maxdepth: 1 + + installation + concepts + architecture + api/modules + + +.. [#f1] although links and similar techniques allow some deviation from this principle + +.. _find: https://www.gnu.org/software/findutils/manual/html_node/find_html/Invoking-find.html#Invoking-find + +.. _locate: https://www.gnu.org/software/findutils/manual/html_node/find_html/Invoking-locate.html + + diff --git a/doc/source/installation.rst b/doc/source/installation.rst new file mode 100644 index 0000000..4316136 --- /dev/null +++ b/doc/source/installation.rst @@ -0,0 +1,43 @@ + +Installation +============ + +Install *BSFS* via pip:: + + pip install --extra-index-url https://pip.bsfs.io bsfs + +This installs the `bsfs` python package as well as the `bsfs.app` command. +It is recommended to install *bsfs* in a virtual environment (via `virtualenv`). + + +License +------- + +This project is released under the terms of the 3-clause BSD License. +By downloading or using the application you agree to the license's terms and conditions. + +.. literalinclude:: ../../LICENSE + + +Source +------ + +Check out our git repository:: + + git clone https://git.bsfs.io/bsfs.git + +You can further install *bsfs* via the ususal `setuptools <https://setuptools.pypa.io/en/latest/index.html>`_ commands from your bsfs source directory:: + + python setup.py develop + +For development, you also need to install some additional dependencies:: + + # code style discipline + pip install mypy coverage pylint + + # documentation + pip install sphinx sphinx-copybutton furo + + # packaging + pip install build + @@ -1,23 +1,44 @@ -from setuptools import setup +from setuptools import setup, find_packages import os setup( + # package metadata name='bsfs', - version='0.0.1', + version='0.23.03', author='Matthias Baumgartner', - author_email='dev@igsor.net', - description='A content aware graph file system.', - long_description=open(os.path.join(os.path.dirname(__file__), 'README')).read(), + author_email='dev@bsfs.io', + description='A content-aware graph file system.', + long_description=open(os.path.join(os.path.dirname(__file__), 'README.md')).read(), license='BSD', license_files=('LICENSE', ), - url='https://www.igsor.net/projects/blackstar/bsfs/', - download_url='https://pip.igsor.net', - packages=('bsfs', ), - install_requires=('rdflib', ), - python_requires=">=3.7", -) + url='https://www.bsfs.io/bsfs/', + download_url='https://pip.bsfs.io', + + # packages + packages=find_packages(include=['bsfs']), + package_dir={'bsfs': 'bsfs'}, + # data files are included if mentioned in MANIFEST.in + include_package_data=True, -# FIXME: bsfs/graph/schema.nt -# FIXME: bsfs.app + # entrypoints + entry_points={ + 'console_scripts': [ + 'bsfs = bsfs.apps:main', + ], + }, + # dependencies + python_requires=">=3.7", + install_requires=( + 'rdflib', # schema and sparql storage + 'hopcroftkarp', # ast matching + 'numpy', # distance functions for sparql store + ), + extras_require={ + 'dev': ['coverage', 'mypy', 'pylint'], + 'doc': ['sphinx', 'furo', 'sphinx-copybutton'], + 'test': [], + 'build': ['build'], + }, +) diff --git a/test/apps/schema-1.nt b/test/apps/schema-1.nt index e57146d..4daf0ad 100644 --- a/test/apps/schema-1.nt +++ b/test/apps/schema-1.nt @@ -3,8 +3,8 @@ prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> prefix xsd: <http://www.w3.org/2001/XMLSchema#> # common bsfs prefixes -prefix bsfs: <http://bsfs.ai/schema/> -prefix bse: <http://bsfs.ai/schema/Entity#> +prefix bsfs: <http://schema.bsfs.io/core/> +prefix bse: <http://schema.bsfs.io/core/Node/Entity#> # essential nodes bsfs:Entity rdfs:subClassOf bsfs:Node . diff --git a/test/apps/schema-2.nt b/test/apps/schema-2.nt index 525ac99..4eb2467 100644 --- a/test/apps/schema-2.nt +++ b/test/apps/schema-2.nt @@ -3,14 +3,15 @@ prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> prefix xsd: <http://www.w3.org/2001/XMLSchema#> # common bsfs prefixes -prefix bsfs: <http://bsfs.ai/schema/> -prefix bse: <http://bsfs.ai/schema/Entity#> +prefix bsfs: <http://schema.bsfs.io/core/> +prefix bse: <http://schema.bsfs.io/core/Node/Entity#> # essential nodes bsfs:Entity rdfs:subClassOf bsfs:Node . # common definitions -xsd:integer rdfs:subClassOf bsfs:Literal . +bsfs:Number rdfs:subClassOf bsfs:Literal . +xsd:integer rdfs:subClassOf bsfs:Number . bse:filesize rdfs:subClassOf bsfs:Predicate ; rdfs:domain bsfs:Entity ; diff --git a/test/apps/test_init.py b/test/apps/test_init.py index bae6a68..59e10eb 100644 --- a/test/apps/test_init.py +++ b/test/apps/test_init.py @@ -1,9 +1,4 @@ -""" -Part of the bsfs test suite. -A copy of the license is provided with the project. -Author: Matthias Baumgartner, 2022 -""" # imports import contextlib import io diff --git a/test/apps/test_main.py b/test/apps/test_main.py new file mode 100644 index 0000000..d61372f --- /dev/null +++ b/test/apps/test_main.py @@ -0,0 +1,37 @@ + +# imports +import contextlib +import io +import json +import unittest + +# objects to test +from bsfs.apps import main + + +## code ## + +class TestMain(unittest.TestCase): + def test_main(self): + # must at least pass an app + with contextlib.redirect_stderr(io.StringIO()): + self.assertRaises(SystemExit, main, []) + # app takes over + with contextlib.redirect_stderr(io.StringIO()): + self.assertRaises(SystemExit, main, ['init']) + outbuf = io.StringIO() + with contextlib.redirect_stdout(outbuf): + main(['init', 'sparql']) + self.assertEqual(json.loads(outbuf.getvalue()), { + 'Graph': { + 'user': 'http://example.com/me', + 'backend': { + 'SparqlStore': {}}}}) + + +## main ## + +if __name__ == '__main__': + unittest.main() + +## EOF ## diff --git a/test/apps/test_migrate.py b/test/apps/test_migrate.py index 957509a..618cb37 100644 --- a/test/apps/test_migrate.py +++ b/test/apps/test_migrate.py @@ -1,9 +1,4 @@ -""" -Part of the bsfs test suite. -A copy of the license is provided with the project. -Author: Matthias Baumgartner, 2022 -""" # imports import contextlib import io @@ -13,7 +8,7 @@ import unittest import unittest.mock # bsie imports -from bsfs.schema import Schema +from bsfs import schema # objects to test from bsfs.apps.migrate import main @@ -33,21 +28,21 @@ class TestMigrate(unittest.TestCase): # read schema from file with open(schema_1) as ifile: - target = Schema.from_string(ifile.read()) + target = schema.from_string(ifile.read()) graph = main([config, schema_1]) self.assertTrue(target <= graph.schema) # read schema from multiple files with open(schema_1) as ifile: - target = Schema.from_string(ifile.read()) + target = schema.from_string(ifile.read()) with open(schema_2) as ifile: - target = target + Schema.from_string(ifile.read()) + target = target + schema.from_string(ifile.read()) graph = main([config, schema_1, schema_2]) self.assertTrue(target <= graph.schema) # read schema from stdin with open(schema_1, 'rt') as ifile: - target = Schema.from_string(ifile.read()) + target = schema.from_string(ifile.read()) with open(schema_1, 'rt') as ifile: with unittest.mock.patch('sys.stdin', ifile): graph = main([config]) diff --git a/test/front/test_bsfs.py b/test/front/test_bsfs.py index 0d7f383..8905bf8 100644 --- a/test/front/test_bsfs.py +++ b/test/front/test_bsfs.py @@ -1,14 +1,10 @@ -""" -Part of the bsfs test suite. -A copy of the license is provided with the project. -Author: Matthias Baumgartner, 2022 -""" # imports import unittest # bsie imports from bsfs.graph import Graph +from bsfs.graph.ac import NullAC from bsfs.triple_store import SparqlStore from bsfs.utils import errors, URI @@ -25,7 +21,7 @@ class TestBSFS(unittest.TestCase): graph = Open(config) self.assertIsInstance(graph, Graph) self.assertIsInstance(graph._backend, SparqlStore) - self.assertEqual(graph._user, URI('http://example.com/me')) + self.assertEqual(graph._ac, NullAC(graph._backend, URI('http://example.com/me'))) # invalid config raises an error self.assertRaises(errors.ConfigError, Open, {}) diff --git a/test/front/test_builder.py b/test/front/test_builder.py index 08f2027..875fa8a 100644 --- a/test/front/test_builder.py +++ b/test/front/test_builder.py @@ -1,14 +1,10 @@ -""" -Part of the bsfs test suite. -A copy of the license is provided with the project. -Author: Matthias Baumgartner, 2022 -""" # imports import unittest # bsie imports from bsfs.graph import Graph +from bsfs.graph.ac import NullAC from bsfs.triple_store import SparqlStore from bsfs.utils import errors, URI @@ -40,7 +36,7 @@ class TestBuilder(unittest.TestCase): graph = build_graph({'Graph': {'backend': {'SparqlStore': {}}, 'user': 'http://example.com/me'}}) self.assertIsInstance(graph, Graph) self.assertIsInstance(graph._backend, SparqlStore) - self.assertEqual(graph._user, URI('http://example.com/me')) + self.assertEqual(graph._ac, NullAC(graph._backend, URI('http://example.com/me'))) # cannot create an invalid graph self.assertRaises(errors.ConfigError, build_graph, {'MyGraph': {}}) # must pass a dict diff --git a/test/graph/ac/test_base.py b/test/graph/ac/test_base.py new file mode 100644 index 0000000..addecd4 --- /dev/null +++ b/test/graph/ac/test_base.py @@ -0,0 +1,78 @@ + +# imports +import unittest + +# bsie imports +from bsfs import schema as bsc +from bsfs.namespace import ns +from bsfs.query import ast +from bsfs.triple_store import SparqlStore +from bsfs.utils import URI + +# objects to test +from bsfs.graph.ac.base import AccessControlBase + + +## code ## + +class StubAC(AccessControlBase): + def is_protected_predicate(self, pred): + pass + def create(self, node_type, guids): + pass + def link_from_node(self, node_type, guids): + pass + def link_to_node(self, node_type, guids): + pass + def write_literal(self, node_type, guids): + pass + def createable(self, node_type, guids): + pass + def filter_read(self, node_type, query): + pass + def fetch_read(self, node_type, query): + pass + + +class TestAccessControlBase(unittest.TestCase): + def setUp(self): + self.backend = SparqlStore() + self.user = URI('http://www.example.com/me') + + def test_essentials(self): + ac = StubAC(self.backend, self.user) + # equal construction means equal instance + self.assertEqual(StubAC(self.backend, self.user), StubAC(self.backend, self.user)) + self.assertEqual(hash(StubAC(self.backend, self.user)), hash(StubAC(self.backend, self.user))) + self.assertEqual(ac, StubAC(self.backend, self.user)) + self.assertEqual(hash(ac), hash(StubAC(self.backend, self.user))) + # equivalence respects type + class Foo(): pass + self.assertNotEqual(ac, 1234) + self.assertNotEqual(hash(ac), hash(1234)) + self.assertNotEqual(ac, 'hello') + self.assertNotEqual(hash(ac), hash('hello')) + self.assertNotEqual(ac, Foo()) + self.assertNotEqual(hash(ac), hash(Foo())) + # equivalence respects backend + self.assertNotEqual(ac, StubAC(SparqlStore(), self.user)) + self.assertNotEqual(hash(ac), hash(StubAC(SparqlStore(), self.user))) + # equivalence respects user + self.assertNotEqual(ac, StubAC(self.backend, URI('http://www.example.com/you'))) + self.assertNotEqual(hash(ac), hash(StubAC(self.backend, URI('http://www.example.com/you')))) + # string conversion + self.assertEqual(str(ac), f'StubAC({self.user})') + self.assertEqual(repr(ac), f'StubAC({self.user})') + # string conversion respects user + self.assertEqual(str(StubAC(self.backend, URI('http://www.example.com/you'))), + f'StubAC(http://www.example.com/you)') + self.assertEqual(repr(StubAC(self.backend, URI('http://www.example.com/you'))), + f'StubAC(http://www.example.com/you)') + + +## main ## + +if __name__ == '__main__': + unittest.main() + +## EOF ## diff --git a/test/graph/ac/test_null.py b/test/graph/ac/test_null.py index f39c9be..142bc23 100644 --- a/test/graph/ac/test_null.py +++ b/test/graph/ac/test_null.py @@ -1,15 +1,11 @@ -""" -Part of the bsfs test suite. -A copy of the license is provided with the project. -Author: Matthias Baumgartner, 2022 -""" # imports import unittest # bsie imports -from bsfs import schema as _schema +from bsfs import schema as bsc from bsfs.namespace import ns +from bsfs.query import ast from bsfs.triple_store import SparqlStore from bsfs.utils import URI @@ -19,24 +15,28 @@ from bsfs.graph.ac.null import NullAC ## code ## +ns.bse = ns.bsfs.Entity() + class TestNullAC(unittest.TestCase): def setUp(self): self.backend = SparqlStore() - self.backend.schema = _schema.Schema.from_string(''' + self.backend.schema = bsc.from_string(''' prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> prefix xsd: <http://www.w3.org/2001/XMLSchema#> - prefix bsfs: <http://bsfs.ai/schema/> - prefix bsm: <http://bsfs.ai/schema/Meta#> - prefix bse: <http://bsfs.ai/schema/Entity#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bsl: <https://schema.bsfs.io/core/Literal/> + prefix bsn: <https://schema.bsfs.io/core/Node#> + prefix bse: <https://schema.bsfs.io/core/Entity#> bsfs:Entity rdfs:subClassOf bsfs:Node . bsfs:Tag rdfs:subClassOf bsfs:Node . xsd:string rdfs:subClassOf bsfs:Literal . - xsd:integer rdfs:subClassOf bsfs:Literal . + bsl:Number rdfs:subClassOf bsfs:Literal . + xsd:integer rdfs:subClassOf bsl:Number . # predicates mandated by Nodes - bsm:t_created rdfs:subClassOf bsfs:Predicate ; + bsn:t_created rdfs:subClassOf bsfs:Predicate ; rdfs:domain bsfs:Node ; rdfs:range xsd:integer ; bsfs:unique "true"^^xsd:boolean . @@ -62,10 +62,40 @@ class TestNullAC(unittest.TestCase): self.p_author = self.backend.schema.predicate(ns.bse.author) self.p_filesize = self.backend.schema.predicate(ns.bse.filesize) self.p_tag = self.backend.schema.predicate(ns.bse.tag) - self.p_created = self.backend.schema.predicate(ns.bsm.t_created) + self.p_created = self.backend.schema.predicate(ns.bsn.t_created) self.ent_type = self.backend.schema.node(ns.bsfs.Entity) self.ent_ids = {URI('http://www.example.com/me/entity#1234'), URI('http://www.example.com/me/entity#4321')} + def test_essentials(self): + ac = NullAC(self.backend, self.user) + # equal construction means equal instance + self.assertEqual(NullAC(self.backend, self.user), NullAC(self.backend, self.user)) + self.assertEqual(hash(NullAC(self.backend, self.user)), hash(NullAC(self.backend, self.user))) + self.assertEqual(ac, NullAC(self.backend, self.user)) + self.assertEqual(hash(ac), hash(NullAC(self.backend, self.user))) + # equivalence respects type + class Foo(): pass + self.assertNotEqual(ac, 1234) + self.assertNotEqual(hash(ac), hash(1234)) + self.assertNotEqual(ac, 'hello') + self.assertNotEqual(hash(ac), hash('hello')) + self.assertNotEqual(ac, Foo()) + self.assertNotEqual(hash(ac), hash(Foo())) + # equivalence respects backend + self.assertNotEqual(ac, NullAC(SparqlStore(), self.user)) + self.assertNotEqual(hash(ac), hash(NullAC(SparqlStore(), self.user))) + # equivalence respects user + self.assertNotEqual(ac, NullAC(self.backend, URI('http://www.example.com/you'))) + self.assertNotEqual(hash(ac), hash(NullAC(self.backend, URI('http://www.example.com/you')))) + # string conversion + self.assertEqual(str(ac), f'NullAC({self.user})') + self.assertEqual(repr(ac), f'NullAC({self.user})') + # string conversion respects user + self.assertEqual(str(NullAC(self.backend, URI('http://www.example.com/you'))), + f'NullAC(http://www.example.com/you)') + self.assertEqual(repr(NullAC(self.backend, URI('http://www.example.com/you'))), + f'NullAC(http://www.example.com/you)') + def test_is_protected_predicate(self): ac = NullAC(self.backend, self.user) self.assertTrue(ac.is_protected_predicate(self.p_created)) @@ -93,6 +123,26 @@ class TestNullAC(unittest.TestCase): ac = NullAC(self.backend, self.user) self.assertSetEqual(self.ent_ids, ac.createable(self.ent_type, self.ent_ids)) + def test_filter_read(self): + query = ast.filter.Or( + ast.filter.Any(ns.bse.tag, ast.filter.Is('http://example.com/tag#1234')), + ast.filter.Any(ns.bse.tag, ast.filter.Is('http://example.com/tag#4321')), + ast.filter.Any(ns.bse.author, ast.filter.Equals('Me, Myself, and I'))) + ac = NullAC(self.backend, self.user) + # NullAC returns query + self.assertEqual(query, ac.filter_read(self.ent_type, query)) + # query can be none + self.assertIsNone(ac.filter_read(self.ent_type, None)) + + def test_fetch_read(self): + query = ast.fetch.All( + ast.fetch.Fetch(ns.bse.tag, ast.fetch.Value(ns.bse.label, 'tag_label')), + ast.fetch.Node(ns.bse.tag, 'tag_node'), + ast.fetch.Value(ns.bse.iso, 'iso')) + ac = NullAC(self.backend, self.user) + # NullAC returns query + self.assertEqual(query, ac.fetch_read(self.ent_type, query)) + ## main ## diff --git a/test/graph/test_graph.py b/test/graph/test_graph.py index 33cf6aa..167168d 100644 --- a/test/graph/test_graph.py +++ b/test/graph/test_graph.py @@ -1,18 +1,17 @@ -""" -Part of the bsfs test suite. -A copy of the license is provided with the project. -Author: Matthias Baumgartner, 2022 -""" # imports +from functools import reduce +import operator import unittest # bsie imports from bsfs import schema +from bsfs.graph.ac import NullAC +from bsfs.graph.nodes import Nodes from bsfs.namespace import ns +from bsfs.query import ast from bsfs.triple_store import SparqlStore from bsfs.utils import URI, errors -from bsfs.graph.nodes import Nodes # objects to test from bsfs.graph.graph import Graph @@ -20,83 +19,95 @@ from bsfs.graph.graph import Graph ## code ## +ns.bse = ns.bsfs.Entity() + class TestGraph(unittest.TestCase): def setUp(self): - self.user = URI('http://example.com/me') self.backend = SparqlStore.Open() - self.backend.schema = schema.Schema.from_string(''' + self.backend.schema = schema.from_string(''' prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> - prefix bsfs: <http://bsfs.ai/schema/> + prefix bsfs: <https://schema.bsfs.io/core/> bsfs:Entity rdfs:subClassOf bsfs:Node . ''') + self.user = URI('http://example.com/me') + self.ac = NullAC(self.backend, self.user) def test_str(self): - self.assertEqual(str(Graph(self.backend, self.user)), - 'Graph(SparqlStore(uri=None), http://example.com/me)') - self.assertEqual(repr(Graph(self.backend, self.user)), - 'Graph(backend=SparqlStore(uri=None), user=http://example.com/me)') + self.assertEqual(str(Graph(self.backend, self.ac)), + 'Graph(SparqlStore(uri=None))') + self.assertEqual(repr(Graph(self.backend, self.ac)), + 'Graph(SparqlStore(uri=None), NullAC(http://example.com/me))') # str respects backend class Foo(SparqlStore): pass - self.assertEqual(str(Graph(Foo.Open(), self.user)), - 'Graph(Foo(uri=None), http://example.com/me)') - self.assertEqual(repr(Graph(Foo.Open(), self.user)), - 'Graph(backend=Foo(uri=None), user=http://example.com/me)') + self.assertEqual(str(Graph(Foo.Open(), self.ac)), + 'Graph(Foo(uri=None))') + self.assertEqual(repr(Graph(Foo.Open(), self.ac)), + 'Graph(Foo(uri=None), NullAC(http://example.com/me))') # str respect user - self.assertEqual(str(Graph(self.backend, URI('http://example.com/you'))), - 'Graph(SparqlStore(uri=None), http://example.com/you)') - self.assertEqual(repr(Graph(self.backend, URI('http://example.com/you'))), - 'Graph(backend=SparqlStore(uri=None), user=http://example.com/you)') + self.assertEqual(str(Graph(self.backend, NullAC(self.backend, URI('http://example.com/you')))), + 'Graph(SparqlStore(uri=None))') + self.assertEqual(repr(Graph(self.backend, NullAC(self.backend, URI('http://example.com/you')))), + 'Graph(SparqlStore(uri=None), NullAC(http://example.com/you))') # str respects type class Bar(Graph): pass - self.assertEqual(str(Bar(self.backend, self.user)), - 'Bar(SparqlStore(uri=None), http://example.com/me)') - self.assertEqual(repr(Bar(self.backend, self.user)), - 'Bar(backend=SparqlStore(uri=None), user=http://example.com/me)') + self.assertEqual(str(Bar(self.backend, self.ac)), + 'Bar(SparqlStore(uri=None))') + self.assertEqual(repr(Bar(self.backend, self.ac)), + 'Bar(SparqlStore(uri=None), NullAC(http://example.com/me))') def test_equality(self): - graph = Graph(self.backend, self.user) + graph = Graph(self.backend, self.ac) # instance is equal to itself self.assertEqual(graph, graph) self.assertEqual(hash(graph), hash(graph)) # instance is equal to a clone - self.assertEqual(graph, Graph(self.backend, self.user)) - self.assertEqual(hash(graph), hash(Graph(self.backend, self.user))) + self.assertEqual(graph, Graph(self.backend, self.ac)) + self.assertEqual(hash(graph), hash(Graph(self.backend, self.ac))) # equality respects backend - self.assertNotEqual(graph, Graph(SparqlStore.Open(), self.user)) - self.assertNotEqual(hash(graph), hash(Graph(SparqlStore.Open(), self.user))) + self.assertNotEqual(graph, Graph(SparqlStore.Open(), self.ac)) + self.assertNotEqual(hash(graph), hash(Graph(SparqlStore.Open(), self.ac))) # equality respects user self.assertNotEqual(graph, Graph(self.backend, URI('http://example.com/you'))) self.assertNotEqual(hash(graph), hash(Graph(self.backend, URI('http://example.com/you')))) def test_essentials(self): - graph = Graph(self.backend, self.user) + graph = Graph(self.backend, self.ac) # schema self.assertEqual(graph.schema, self.backend.schema) self.assertRaises(AttributeError, setattr, graph, 'schema', None) def test_node(self): - graph = Graph(self.backend, self.user) + graph = Graph(self.backend, self.ac) guid = URI('http://example.com/me/entity#1234') # returns a Nodes instance self.assertEqual( graph.node(ns.bsfs.Entity, guid), - Nodes(self.backend, self.user, graph.schema.node(ns.bsfs.Entity), {guid})) + Nodes(self.backend, self.ac, graph.schema.node(ns.bsfs.Entity), {guid})) # node_type must be in the schema self.assertRaises(KeyError, graph.node, ns.bsfs.Invalid, guid) def test_nodes(self): - graph = Graph(self.backend, self.user) + graph = Graph(self.backend, self.ac) guids = {URI('http://example.com/me/entity#1234'), URI('http://example.com/me/entity#4321')} # returns a Nodes instance self.assertEqual( graph.nodes(ns.bsfs.Entity, guids), - Nodes(self.backend, self.user, graph.schema.node(ns.bsfs.Entity), guids)) + Nodes(self.backend, self.ac, graph.schema.node(ns.bsfs.Entity), guids)) # node_type must be in the schema self.assertRaises(KeyError, graph.nodes, ns.bsfs.Invalid, guids) + def test_empty(self): + graph = Graph(self.backend, self.ac) + # returns a Nodes instance + self.assertEqual( + graph.empty(ns.bsfs.Entity), + Nodes(self.backend, self.ac, graph.schema.node(ns.bsfs.Entity), set())) + # node_type must be in the schema + self.assertRaises(KeyError, graph.empty, ns.bsfs.Invalid) + def test_migrate(self): # setup - graph = Graph(self.backend, self.user) + graph = Graph(self.backend, self.ac) # argument must be a schema class Foo(): pass @@ -117,14 +128,16 @@ class TestGraph(unittest.TestCase): schema.Node(ns.bsfs.Node, None)))}), append=False) # can migrate to compatible schema - target_1 = schema.Schema.from_string(''' + target_1 = schema.from_string(''' prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> prefix xsd: <http://www.w3.org/2001/XMLSchema#> - prefix bsfs: <http://bsfs.ai/schema/> - prefix bse: <http://bsfs.ai/schema/Entity#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + prefix bsl: <https://schema.bsfs.io/core/Literal/> bsfs:Entity rdfs:subClassOf bsfs:Node . xsd:string rdfs:subClassOf bsfs:Literal . - xsd:integer rdfs:subClassOf bsfs:Literal . + bsl:Number rdfs:subClassOf bsfs:Literal . + xsd:integer rdfs:subClassOf bsl:Number . bse:filename rdfs:subClassOf bsfs:Predicate ; rdfs:domain bsfs:Entity ; @@ -141,27 +154,31 @@ class TestGraph(unittest.TestCase): # new schema is applied self.assertLess(target_1, graph.schema) # graph appends its predicates - self.assertEqual(graph.schema, target_1 + schema.Schema.from_string(''' + self.assertEqual(graph.schema, target_1 + schema.from_string(''' prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> prefix xsd: <http://www.w3.org/2001/XMLSchema#> - prefix bsfs: <http://bsfs.ai/schema/> - prefix bsm: <http://bsfs.ai/schema/Meta#> - xsd:integer rdfs:subClassOf bsfs:Literal . - bsm:t_created rdfs:subClassOf bsfs:Predicate ; + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bsn: <https://schema.bsfs.io/core/Node#> + prefix bsl: <https://schema.bsfs.io/core/Literal/> + bsl:Number rdfs:subClassOf bsfs:Literal . + xsd:float rdfs:subClassOf bsl:Number . + bsn:t_created rdfs:subClassOf bsfs:Predicate ; rdfs:domain bsfs:Node ; - rdfs:range xsd:integer ; + rdfs:range xsd:float ; bsfs:unique "true"^^xsd:boolean . ''')) # can overwrite the current schema - target_2 = schema.Schema.from_string(''' + target_2 = schema.from_string(''' prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> prefix xsd: <http://www.w3.org/2001/XMLSchema#> - prefix bsfs: <http://bsfs.ai/schema/> - prefix bse: <http://bsfs.ai/schema/Entity#> + prefix bsfs: <http://schema.bsfs.io/core/> + prefix bse: <http://schema.bsfs.io/core/Node/Entity#> + prefix bsl: <https://schema.bsfs.io/core/Literal/> bsfs:Entity rdfs:subClassOf bsfs:Node . xsd:string rdfs:subClassOf bsfs:Literal . - xsd:integer rdfs:subClassOf bsfs:Literal . + bsl:Number rdfs:subClassOf bsfs:Literal . + xsd:integer rdfs:subClassOf bsl:Number . bse:filename rdfs:subClassOf bsfs:Predicate ; rdfs:domain bsfs:Entity ; @@ -180,18 +197,147 @@ class TestGraph(unittest.TestCase): # new schema is applied self.assertLess(target_2, graph.schema) # graph appends its predicates - self.assertEqual(graph.schema, target_2 + schema.Schema.from_string(''' + self.assertEqual(graph.schema, target_2 + schema.from_string(''' prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> prefix xsd: <http://www.w3.org/2001/XMLSchema#> - prefix bsfs: <http://bsfs.ai/schema/> - prefix bsm: <http://bsfs.ai/schema/Meta#> - xsd:integer rdfs:subClassOf bsfs:Literal . - bsm:t_created rdfs:subClassOf bsfs:Predicate ; + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bsn: <https://schema.bsfs.io/core/Node#> + prefix bsl: <https://schema.bsfs.io/core/Literal/> + bsl:Number rdfs:subClassOf bsfs:Literal . + xsd:float rdfs:subClassOf bsl:Number . + bsn:t_created rdfs:subClassOf bsfs:Predicate ; rdfs:domain bsfs:Node ; - rdfs:range xsd:integer ; + rdfs:range xsd:float ; bsfs:unique "true"^^xsd:boolean . ''')) + def test_get(self): + # setup + graph = Graph(self.backend, self.ac) + graph.migrate(schema.from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + bsfs:Tag rdfs:subClassOf bsfs:Node . + xsd:string rdfs:subClassOf bsfs:Literal . + + bse:tag rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range bsfs:Tag ; + bsfs:unique "false"^^xsd:boolean . + + bse:comment rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Node ; + rdfs:range xsd:string ; + bsfs:unique "false"^^xsd:boolean . + + ''')) + # add some instances + ents = graph.nodes(ns.bsfs.Entity, {URI('http://example.com/entity#1234'), URI('http://example.com/entity#4321')}) + tags = graph.nodes(ns.bsfs.Tag, {URI('http://example.com/tag#1234'), URI('http://example.com/tag#4321')}) + # add some node links + ents.set(ns.bse.tag, tags) + # add some literals + graph.node(ns.bsfs.Entity, URI('http://example.com/entity#1234')).set(ns.bse.comment, 'hello world') + graph.node(ns.bsfs.Entity, URI('http://example.com/entity#1234')).set(ns.bse.comment, 'foo') + graph.node(ns.bsfs.Entity, URI('http://example.com/entity#1234')).set(ns.bse.comment, 'foobar') + graph.node(ns.bsfs.Tag, URI('http://example.com/tag#1234')).set(ns.bse.comment, 'foo') + graph.node(ns.bsfs.Tag, URI('http://example.com/tag#4321')).set(ns.bse.comment, 'bar') + + # invalid query raises exception + self.assertRaises(errors.ConsistencyError, graph.get, ns.bsfs.Entity, ast.filter.Any(ns.bse.tag, ast.filter.Equals('hello world'))) + + # get returns nodes + self.assertEqual(graph.get(ns.bsfs.Entity, ast.filter.Any(ns.bse.tag, ast.filter.Is(tags))), ents) + self.assertEqual(graph.get(ns.bsfs.Entity, ast.filter.Any(ns.bse.comment, ast.filter.StartsWith('foo'))), + graph.node(ns.bsfs.Entity, URI('http://example.com/entity#1234'))) + self.assertEqual(graph.get(ns.bsfs.Node, ast.filter.Any(ns.bse.comment, ast.filter.StartsWith('foo'))), + graph.nodes(ns.bsfs.Node, {URI('http://example.com/entity#1234'), URI('http://example.com/tag#1234')})) + self.assertEqual(graph.get(ns.bsfs.Entity, ast.filter.Or( + ast.filter.Any(ns.bse.comment, ast.filter.EndsWith('bar')), + ast.filter.Any(ns.bse.tag, ast.filter.All(ns.bse.comment, ast.filter.Equals('bar'))))), + ents) + + # query can be None + self.assertEqual(graph.get(ns.bsfs.Entity, None), ents) + + def test_sorted(self): + # setup + graph = Graph(self.backend, self.ac) + graph.migrate(schema.from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + bsfs:Tag rdfs:subClassOf bsfs:Node . + xsd:string rdfs:subClassOf bsfs:Literal . + + bse:tag rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range bsfs:Tag ; + bsfs:unique "false"^^xsd:boolean . + + bse:comment rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Node ; + rdfs:range xsd:string ; + bsfs:unique "false"^^xsd:boolean . + + ''')) + # add some instances + ents = [ + # default is alphabetical order + graph.node(ns.bsfs.Entity, URI('http://example.com/entity#1234')), + graph.node(ns.bsfs.Entity, URI('http://example.com/entity#4321')), + ] + tags = graph.nodes(ns.bsfs.Tag, {URI('http://example.com/tag#1234'), URI('http://example.com/tag#4321')}) + # add some node links + reduce(operator.add, ents).set(ns.bse.tag, tags) + # add some literals + graph.node(ns.bsfs.Entity, URI('http://example.com/entity#1234')).set(ns.bse.comment, 'hello world') + graph.node(ns.bsfs.Entity, URI('http://example.com/entity#1234')).set(ns.bse.comment, 'foo') + graph.node(ns.bsfs.Entity, URI('http://example.com/entity#1234')).set(ns.bse.comment, 'foobar') + graph.node(ns.bsfs.Tag, URI('http://example.com/tag#1234')).set(ns.bse.comment, 'foo') + graph.node(ns.bsfs.Tag, URI('http://example.com/tag#4321')).set(ns.bse.comment, 'bar') + + # invalid query raises exception + self.assertRaises(errors.ConsistencyError, list, graph.sorted(ns.bsfs.Entity, ast.filter.Any(ns.bse.tag, ast.filter.Equals('hello world')))) + + # get returns nodes + self.assertListEqual(list(graph.sorted(ns.bsfs.Entity, ast.filter.Any(ns.bse.tag, ast.filter.Is(tags)))), ents) + self.assertListEqual(list(graph.sorted(ns.bsfs.Entity, ast.filter.Any(ns.bse.comment, ast.filter.StartsWith('foo')))), + [graph.node(ns.bsfs.Entity, URI('http://example.com/entity#1234'))]) + self.assertListEqual(list(graph.sorted(ns.bsfs.Node, ast.filter.Any(ns.bse.comment, ast.filter.StartsWith('foo')))), [ + graph.node(ns.bsfs.Node, URI('http://example.com/entity#1234')), + graph.node(ns.bsfs.Node, URI('http://example.com/tag#1234')), + ]) + self.assertListEqual(list(graph.sorted(ns.bsfs.Entity, ast.filter.Or( + ast.filter.Any(ns.bse.comment, ast.filter.EndsWith('bar')), + ast.filter.Any(ns.bse.tag, ast.filter.All(ns.bse.comment, ast.filter.Equals('bar')))))), + ents) + + # query can be None + self.assertListEqual(list(graph.sorted(ns.bsfs.Entity, None)), ents) + + + def test_all(self): + graph = Graph(self.backend, self.ac) + # resulting nodes can be empty + self.assertEqual(graph.all(ns.bsfs.Entity), + Nodes(self.backend, self.ac, graph.schema.node(ns.bsfs.Entity), set())) + # resulting nodes contains all nodes of the respective type + guids = {URI('http://example.com/me/entity#1234'), URI('http://example.com/me/entity#4321')} + self.backend.create(graph.schema.node(ns.bsfs.Entity), guids) + self.assertEqual(graph.all(ns.bsfs.Entity), + Nodes(self.backend, self.ac, graph.schema.node(ns.bsfs.Entity), guids)) + # node_type must be in the schema + self.assertRaises(KeyError, graph.all, ns.bsfs.Invalid) + + ## main ## diff --git a/test/graph/test_nodes.py b/test/graph/test_nodes.py index 43e7f6f..afe7522 100644 --- a/test/graph/test_nodes.py +++ b/test/graph/test_nodes.py @@ -1,16 +1,17 @@ -""" -Part of the bsfs test suite. -A copy of the license is provided with the project. -Author: Matthias Baumgartner, 2022 -""" -# imports -import rdflib +# standard imports +from functools import partial +import operator import unittest +# external imports +import rdflib + # bsie imports -from bsfs import schema as _schema -from bsfs.namespace import ns +from bsfs import schema as bsc +from bsfs.graph.ac import NullAC +from bsfs.graph.walk import Walk +from bsfs.namespace import Namespace, ns from bsfs.triple_store.sparql import SparqlStore from bsfs.utils import errors, URI @@ -20,27 +21,32 @@ from bsfs.graph.nodes import Nodes ## code ## +ns.bse = ns.bsfs.Entity() +ns.bst = ns.bsfs.Tag() + class TestNodes(unittest.TestCase): def setUp(self): # initialize backend self.backend = SparqlStore() - self.backend.schema = _schema.Schema.from_string(''' + self.backend.schema = bsc.from_string(''' prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> prefix xsd: <http://www.w3.org/2001/XMLSchema#> - prefix bsfs: <http://bsfs.ai/schema/> - prefix bsm: <http://bsfs.ai/schema/Meta#> - prefix bse: <http://bsfs.ai/schema/Entity#> - prefix bst: <http://bsfs.ai/schema/Tag#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bsl: <https://schema.bsfs.io/core/Literal/> + prefix bsn: <https://schema.bsfs.io/core/Node#> + prefix bse: <https://schema.bsfs.io/core/Entity#> + prefix bst: <https://schema.bsfs.io/core/Tag#> bsfs:Entity rdfs:subClassOf bsfs:Node . bsfs:Tag rdfs:subClassOf bsfs:Node . bsfs:User rdfs:subClassOf bsfs:Node . xsd:string rdfs:subClassOf bsfs:Literal . - xsd:integer rdfs:subClassOf bsfs:Literal . + bsl:Number rdfs:subClassOf bsfs:Literal . + xsd:integer rdfs:subClassOf bsl:Number . # predicates mandated by Nodes - bsm:t_created rdfs:subClassOf bsfs:Predicate ; + bsn:t_created rdfs:subClassOf bsfs:Predicate ; rdfs:domain bsfs:Node ; rdfs:range xsd:integer ; bsfs:unique "true"^^xsd:boolean . @@ -66,14 +72,40 @@ class TestNodes(unittest.TestCase): rdfs:range bsfs:User ; bsfs:unique "true"^^xsd:boolean . + bst:label rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Tag ; + rdfs:range xsd:string ; + bsfs:unique "true"^^xsd:boolean . + bst:representative rdfs:subClassOf bsfs:Predicate ; rdfs:domain bsfs:Tag ; rdfs:range bsfs:Entity ; bsfs:unique "true"^^xsd:boolean . ''') + self.schema_triples = { + # schema hierarchy + (rdflib.URIRef(ns.bsfs.Entity), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Node)), + (rdflib.URIRef(ns.bsfs.Tag), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Node)), + (rdflib.URIRef(ns.bsfs.User), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Node)), + (rdflib.URIRef(ns.xsd.string), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Literal)), + (rdflib.URIRef(ns.bsl.Array), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Literal)), + (rdflib.URIRef(ns.bsl.BinaryBlob), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Literal)), + (rdflib.URIRef(ns.bsl.Array.Feature), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsl.Array)), + (rdflib.URIRef(ns.bsl.Number), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Literal)), + (rdflib.URIRef(ns.bsl.Time), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Literal)), + (rdflib.URIRef(ns.xsd.integer), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsl.Number)), + (rdflib.URIRef(ns.bsn.t_created), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), + (rdflib.URIRef(ns.bse.comment), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), + (rdflib.URIRef(ns.bse.filesize), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), + (rdflib.URIRef(ns.bse.tag), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), + (rdflib.URIRef(ns.bse.author), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), + (rdflib.URIRef(ns.bst.representative), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), + (rdflib.URIRef(ns.bst.label), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), + } # Nodes constructor args self.user = URI('http://example.com/me') + self.ac = NullAC(self.backend, self.user) # set args self.tag_type = self.backend.schema.node(ns.bsfs.Tag) self.ent_type = self.backend.schema.node(ns.bsfs.Entity) @@ -81,8 +113,9 @@ class TestNodes(unittest.TestCase): self.p_filesize = self.backend.schema.predicate(ns.bse.filesize) self.p_author = self.backend.schema.predicate(ns.bse.author) self.p_tag = self.backend.schema.predicate(ns.bse.tag) - self.p_representative = self.backend.schema.predicate(URI('http://bsfs.ai/schema/Tag#representative')) - self.t_created = self.backend.schema.predicate(ns.bsm.t_created) + self.p_representative = self.backend.schema.predicate(ns.bst.representative) + self.p_label = self.backend.schema.predicate(ns.bst.label) + self.t_created = self.backend.schema.predicate(ns.bsn.t_created) self.ent_ids = { URI('http://example.com/me/entity#1234'), URI('http://example.com/me/entity#4321'), @@ -92,67 +125,71 @@ class TestNodes(unittest.TestCase): URI('http://example.com/me/tag#4321'), } + def test_construct(self): + self.assertIsInstance(Nodes(self.backend, self.ac, self.ent_type, {'http://example.com/me-and-you'}), Nodes) + self.assertRaises(ValueError, Nodes, self.backend, self.ac, self.ent_type, {'http://example.com/me and you'}) + def test_str(self): # str baseline - nodes = Nodes(self.backend, self.user, self.ent_type, self.ent_ids) - self.assertEqual(str(nodes), f'Nodes({self.ent_type}, {self.ent_ids})') - self.assertEqual(repr(nodes), f'Nodes({self.backend}, {self.user}, {self.ent_type}, {self.ent_ids})') + nodes = Nodes(self.backend, self.ac, self.ent_type, {URI('http://example.com/me/entity#1234')}) + self.assertEqual(str(nodes), f"Nodes({self.ent_type}, {{'http://example.com/me/entity#1234'}})") + self.assertEqual(repr(nodes), f"Nodes({self.backend}, {self.ac}, {self.ent_type}, {{'http://example.com/me/entity#1234'}})") # str respects node_type - nodes = Nodes(self.backend, self.user, self.tag_type, self.tag_ids) - self.assertEqual(str(nodes), f'Nodes({self.tag_type}, {self.tag_ids})') - self.assertEqual(repr(nodes), f'Nodes({self.backend}, {self.user}, {self.tag_type}, {self.tag_ids})') + nodes = Nodes(self.backend, self.ac, self.tag_type, {URI('http://example.com/me/tag#1234')}) + self.assertEqual(str(nodes), f"Nodes({self.tag_type}, {{'http://example.com/me/tag#1234'}})") + self.assertEqual(repr(nodes), f"Nodes({self.backend}, {self.ac}, {self.tag_type}, {{'http://example.com/me/tag#1234'}})") # str respects guids - nodes = Nodes(self.backend, self.user, self.ent_type, {URI('http://example.com/me/entity#foo')}) + nodes = Nodes(self.backend, self.ac, self.ent_type, {URI('http://example.com/me/entity#foo')}) self.assertEqual(str(nodes), f'Nodes({self.ent_type}, {{\'http://example.com/me/entity#foo\'}})') - self.assertEqual(repr(nodes), f'Nodes({self.backend}, {self.user}, {self.ent_type}, {{\'http://example.com/me/entity#foo\'}})') + self.assertEqual(repr(nodes), f'Nodes({self.backend}, {self.ac}, {self.ent_type}, {{\'http://example.com/me/entity#foo\'}})') # repr respects backend class Foo(SparqlStore): pass backend = Foo.Open() backend.schema = self.backend.schema - nodes = Nodes(backend, self.user, self.ent_type, self.ent_ids) - self.assertEqual(repr(nodes), f'Nodes({backend}, {self.user}, {self.ent_type}, {self.ent_ids})') + nodes = Nodes(backend, self.ac, self.ent_type, {URI('http://example.com/me/entity#1234')}) + self.assertEqual(repr(nodes), f"Nodes({backend}, {self.ac}, {self.ent_type}, {{'http://example.com/me/entity#1234'}})") # repr respects user - nodes = Nodes(self.backend, URI('http://example.com/you'), self.ent_type, self.ent_ids) - self.assertEqual(repr(nodes), f'Nodes({self.backend}, http://example.com/you, {self.ent_type}, {self.ent_ids})') + nodes = Nodes(self.backend, NullAC(self.backend, URI('http://example.com/you')), self.ent_type, {URI('http://example.com/me/entity#1234')}) + self.assertEqual(repr(nodes), f"Nodes({self.backend}, NullAC(http://example.com/you), {self.ent_type}, {{'http://example.com/me/entity#1234'}})") def test_equality(self): - nodes = Nodes(self.backend, self.user, self.ent_type, self.ent_ids) + nodes = Nodes(self.backend, self.ac, self.ent_type, self.ent_ids) # instance is equal to itself self.assertEqual(nodes, nodes) self.assertEqual(hash(nodes), hash(nodes)) # instance is equal to a clone - self.assertEqual(nodes, Nodes(self.backend, self.user, self.ent_type, self.ent_ids)) - self.assertEqual(Nodes(self.backend, self.user, self.ent_type, self.ent_ids), nodes) - self.assertEqual(hash(nodes), hash(Nodes(self.backend, self.user, self.ent_type, self.ent_ids))) + self.assertEqual(nodes, Nodes(self.backend, self.ac, self.ent_type, self.ent_ids)) + self.assertEqual(Nodes(self.backend, self.ac, self.ent_type, self.ent_ids), nodes) + self.assertEqual(hash(nodes), hash(Nodes(self.backend, self.ac, self.ent_type, self.ent_ids))) # equality respects backend backend = SparqlStore.Open() backend.schema = self.backend.schema - self.assertNotEqual(nodes, Nodes(backend, self.user, self.ent_type, self.ent_ids)) - self.assertNotEqual(hash(nodes), hash(Nodes(backend, self.user, self.ent_type, self.ent_ids))) + self.assertNotEqual(nodes, Nodes(backend, self.ac, self.ent_type, self.ent_ids)) + self.assertNotEqual(hash(nodes), hash(Nodes(backend, self.ac, self.ent_type, self.ent_ids))) # equality respects user - self.assertNotEqual(nodes, Nodes(self.backend, URI('http://example.com/you'), self.ent_type, self.ent_ids)) - self.assertNotEqual(hash(nodes), hash(Nodes(self.backend, URI('http://example.com/you'), self.ent_type, self.ent_ids))) + self.assertNotEqual(nodes, Nodes(self.backend, NullAC(self.backend, URI('http://example.com/you')), self.ent_type, self.ent_ids)) + self.assertNotEqual(hash(nodes), hash(Nodes(self.backend, NullAC(self.backend, URI('http://example.com/you')), self.ent_type, self.ent_ids))) # equality respects node_type - self.assertNotEqual(nodes, Nodes(self.backend, self.user, self.tag_type, self.ent_ids)) - self.assertNotEqual(hash(nodes), hash(Nodes(self.backend, self.user, self.tag_type, self.ent_ids))) + self.assertNotEqual(nodes, Nodes(self.backend, self.ac, self.tag_type, self.ent_ids)) + self.assertNotEqual(hash(nodes), hash(Nodes(self.backend, self.ac, self.tag_type, self.ent_ids))) # equality respects guids - self.assertNotEqual(nodes, Nodes(self.backend, self.user, self.ent_type, self.tag_ids)) - self.assertNotEqual(hash(nodes), hash(Nodes(self.backend, self.user, self.ent_type, self.tag_ids))) + self.assertNotEqual(nodes, Nodes(self.backend, self.ac, self.ent_type, self.tag_ids)) + self.assertNotEqual(hash(nodes), hash(Nodes(self.backend, self.ac, self.ent_type, self.tag_ids))) def test_properties(self): # node_type self.assertEqual(self.ent_type, Nodes( - self.backend, self.user, self.ent_type, self.ent_ids).node_type) + self.backend, self.ac, self.ent_type, self.ent_ids).node_type) self.assertEqual(self.tag_type, Nodes( - self.backend, self.user, self.tag_type, self.tag_ids).node_type) + self.backend, self.ac, self.tag_type, self.tag_ids).node_type) # guids self.assertSetEqual(self.ent_ids, set(Nodes( - self.backend, self.user, self.ent_type, self.ent_ids).guids)) + self.backend, self.ac, self.ent_type, self.ent_ids).guids)) self.assertSetEqual(self.tag_ids, set(Nodes( - self.backend, self.user, self.tag_type, self.tag_ids).guids)) + self.backend, self.ac, self.tag_type, self.tag_ids).guids)) def test__ensure_nodes(self): - nodes = Nodes(self.backend, self.user, self.ent_type, self.ent_ids) + nodes = Nodes(self.backend, self.ac, self.ent_type, self.ent_ids) # missing nodes are created self.assertSetEqual(self.ent_ids, nodes._ensure_nodes(self.ent_type, self.ent_ids)) @@ -160,10 +197,10 @@ class TestNodes(unittest.TestCase): time_triples = list(self.backend._graph.objects(rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.URIRef(self.t_created.uri))) t_ent_created = float(time_triples[0]) if len(time_triples) > 0 else 0.0 # check triples - self.assertSetEqual(set(self.backend._graph), { + self.assertSetEqual(set(self.backend._graph), self.schema_triples | { # entity definitions - (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.RDF.type, rdflib.URIRef('http://bsfs.ai/schema/Entity')), - (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.RDF.type, rdflib.URIRef('http://bsfs.ai/schema/Entity')), + (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Entity')), + (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Entity')), # bookkeeping (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.URIRef(self.t_created.uri), rdflib.Literal(t_ent_created, datatype=rdflib.XSD.integer)), (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.URIRef(self.t_created.uri), rdflib.Literal(t_ent_created, datatype=rdflib.XSD.integer)), @@ -171,10 +208,10 @@ class TestNodes(unittest.TestCase): # existing nodes remain unchanged self.assertSetEqual(self.ent_ids, nodes._ensure_nodes(self.ent_type, self.ent_ids)) - self.assertSetEqual(set(self.backend._graph), { + self.assertSetEqual(set(self.backend._graph), self.schema_triples | { # entity definitions - (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.RDF.type, rdflib.URIRef('http://bsfs.ai/schema/Entity')), - (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.RDF.type, rdflib.URIRef('http://bsfs.ai/schema/Entity')), + (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Entity')), + (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Entity')), # bookkeeping (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.URIRef(self.t_created.uri), rdflib.Literal(t_ent_created, datatype=rdflib.XSD.integer)), (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.URIRef(self.t_created.uri), rdflib.Literal(t_ent_created, datatype=rdflib.XSD.integer)), @@ -186,23 +223,23 @@ class TestNodes(unittest.TestCase): time_triples = list(self.backend._graph.objects(rdflib.URIRef('http://example.com/me/tag#1234'), rdflib.URIRef(self.t_created.uri))) t_tag_created = float(time_triples[0]) if len(time_triples) > 0 else 0.0 # check triples - self.assertSetEqual(set(self.backend._graph), { + self.assertSetEqual(set(self.backend._graph), self.schema_triples | { # previous triples - (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.RDF.type, rdflib.URIRef('http://bsfs.ai/schema/Entity')), - (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.RDF.type, rdflib.URIRef('http://bsfs.ai/schema/Entity')), + (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Entity')), + (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Entity')), (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.URIRef(self.t_created.uri), rdflib.Literal(t_ent_created, datatype=rdflib.XSD.integer)), (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.URIRef(self.t_created.uri), rdflib.Literal(t_ent_created, datatype=rdflib.XSD.integer)), # new triples - (rdflib.URIRef('http://example.com/me/tag#1234'), rdflib.RDF.type, rdflib.URIRef('http://bsfs.ai/schema/Tag')), - (rdflib.URIRef('http://example.com/me/tag#4321'), rdflib.RDF.type, rdflib.URIRef('http://bsfs.ai/schema/Tag')), + (rdflib.URIRef('http://example.com/me/tag#1234'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Tag')), + (rdflib.URIRef('http://example.com/me/tag#4321'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Tag')), (rdflib.URIRef('http://example.com/me/tag#1234'), rdflib.URIRef(self.t_created.uri), rdflib.Literal(t_tag_created, datatype=rdflib.XSD.integer)), (rdflib.URIRef('http://example.com/me/tag#4321'), rdflib.URIRef(self.t_created.uri), rdflib.Literal(t_tag_created, datatype=rdflib.XSD.integer)), }) def test___set(self): # setup - nodes = Nodes(self.backend, self.user, self.ent_type, {URI('http://example.com/me/entity#1234'), URI('http://example.com/me/entity#4321')}) - self.assertSetEqual(set(self.backend._graph), set()) + nodes = Nodes(self.backend, self.ac, self.ent_type, {URI('http://example.com/me/entity#1234'), URI('http://example.com/me/entity#4321')}) + self.assertSetEqual(set(self.backend._graph), self.schema_triples | set()) set_ = nodes._Nodes__set # node_type must match predicate's domain @@ -217,10 +254,10 @@ class TestNodes(unittest.TestCase): time_triples = list(self.backend._graph.objects(rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.URIRef(self.t_created.uri))) t_ent_created = float(time_triples[0]) if len(time_triples) > 0 else 0.0 # verify triples - self.assertSetEqual(set(self.backend._graph), { + self.assertSetEqual(set(self.backend._graph), self.schema_triples | { # entity definitions - (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.RDF.type, rdflib.URIRef('http://bsfs.ai/schema/Entity')), - (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.RDF.type, rdflib.URIRef('http://bsfs.ai/schema/Entity')), + (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Entity')), + (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Entity')), # bookkeeping (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.URIRef(self.t_created.uri), rdflib.Literal(t_ent_created, datatype=rdflib.XSD.integer)), (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.URIRef(self.t_created.uri), rdflib.Literal(t_ent_created, datatype=rdflib.XSD.integer)), @@ -230,23 +267,23 @@ class TestNodes(unittest.TestCase): }) # set node value - tags = Nodes(self.backend, self.user, self.tag_type, {URI('http://example.com/me/tag#1234'), URI('http://example.com/me/tag#4321')}) + tags = Nodes(self.backend, self.ac, self.tag_type, {URI('http://example.com/me/tag#1234'), URI('http://example.com/me/tag#4321')}) set_(self.p_tag.uri, tags) # get creation time from backend manually time_triples = list(self.backend._graph.objects(rdflib.URIRef('http://example.com/me/tag#1234'), rdflib.URIRef(self.t_created.uri))) t_tag_created = float(time_triples[0]) if len(time_triples) > 0 else 0.0 # verify triples - self.assertSetEqual(set(self.backend._graph), { + self.assertSetEqual(set(self.backend._graph), self.schema_triples | { # previous values - (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.RDF.type, rdflib.URIRef('http://bsfs.ai/schema/Entity')), - (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.RDF.type, rdflib.URIRef('http://bsfs.ai/schema/Entity')), + (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Entity')), + (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Entity')), (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.URIRef(self.p_filesize.uri), rdflib.Literal(1234, datatype=rdflib.XSD.integer)), (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.URIRef(self.p_filesize.uri), rdflib.Literal(1234, datatype=rdflib.XSD.integer)), (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.URIRef(self.t_created.uri), rdflib.Literal(t_ent_created, datatype=rdflib.XSD.integer)), (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.URIRef(self.t_created.uri), rdflib.Literal(t_ent_created, datatype=rdflib.XSD.integer)), # tag definitions - (rdflib.URIRef('http://example.com/me/tag#1234'), rdflib.RDF.type, rdflib.URIRef('http://bsfs.ai/schema/Tag')), - (rdflib.URIRef('http://example.com/me/tag#4321'), rdflib.RDF.type, rdflib.URIRef('http://bsfs.ai/schema/Tag')), + (rdflib.URIRef('http://example.com/me/tag#1234'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Tag')), + (rdflib.URIRef('http://example.com/me/tag#4321'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Tag')), # tag bookkeeping (rdflib.URIRef('http://example.com/me/tag#1234'), rdflib.URIRef(self.t_created.uri), rdflib.Literal(t_tag_created, datatype=rdflib.XSD.integer)), (rdflib.URIRef('http://example.com/me/tag#4321'), rdflib.URIRef(self.t_created.uri), rdflib.Literal(t_tag_created, datatype=rdflib.XSD.integer)), @@ -262,29 +299,29 @@ class TestNodes(unittest.TestCase): self.assertRaises(TypeError, set_, self.p_tag.uri, URI('http://example.com/me/tag#1234')) # value's node_type must match the predicate's range self.assertRaises(errors.ConsistencyError, set_, self.p_tag.uri, - Nodes(self.backend, self.user, self.ent_type, self.ent_ids)) + Nodes(self.backend, self.ac, self.ent_type, self.ent_ids)) def test_set(self): - self.assertSetEqual(set(self.backend._graph), set()) - nodes = Nodes(self.backend, self.user, self.ent_type, self.ent_ids) + self.assertSetEqual(set(self.backend._graph), self.schema_triples | set()) + nodes = Nodes(self.backend, self.ac, self.ent_type, self.ent_ids) # can set literal values self.assertEqual(nodes, nodes.set(self.p_filesize.uri, 1234)) self.assertTrue(set(self.backend._graph).issuperset({ # nodes exist - (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.RDF.type, rdflib.URIRef('http://bsfs.ai/schema/Entity')), - (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.RDF.type, rdflib.URIRef('http://bsfs.ai/schema/Entity')), + (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Entity')), + (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Entity')), # links exist (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.URIRef(self.p_filesize.uri), rdflib.Literal(1234, datatype=rdflib.XSD.integer)), (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.URIRef(self.p_filesize.uri), rdflib.Literal(1234, datatype=rdflib.XSD.integer)), })) # can set node values - self.assertEqual(nodes, nodes.set(self.p_tag.uri, Nodes(self.backend, self.user, self.tag_type, self.tag_ids))) + self.assertEqual(nodes, nodes.set(self.p_tag.uri, Nodes(self.backend, self.ac, self.tag_type, self.tag_ids))) self.assertTrue(set(self.backend._graph).issuperset({ # nodes exist - (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.RDF.type, rdflib.URIRef('http://bsfs.ai/schema/Entity')), - (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.RDF.type, rdflib.URIRef('http://bsfs.ai/schema/Entity')), - (rdflib.URIRef('http://example.com/me/tag#1234'), rdflib.RDF.type, rdflib.URIRef('http://bsfs.ai/schema/Tag')), - (rdflib.URIRef('http://example.com/me/tag#4321'), rdflib.RDF.type, rdflib.URIRef('http://bsfs.ai/schema/Tag')), + (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Entity')), + (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Entity')), + (rdflib.URIRef('http://example.com/me/tag#1234'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Tag')), + (rdflib.URIRef('http://example.com/me/tag#4321'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Tag')), # links exist (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.URIRef(self.p_tag.uri), rdflib.URIRef('http://example.com/me/tag#1234')), (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.URIRef(self.p_tag.uri), rdflib.URIRef('http://example.com/me/tag#4321')), @@ -307,24 +344,28 @@ class TestNodes(unittest.TestCase): self.assertSetEqual(curr, set(self.backend._graph)) # cannot assing multiple values to unique predicate self.assertRaises(ValueError, nodes.set, self.p_author.uri, - Nodes(self.backend, self.user, self.user_type, {URI('http://example.com/me/user#1234'), URI('http://example.com/me/user#4321')})) + Nodes(self.backend, self.ac, self.user_type, {URI('http://example.com/me/user#1234'), URI('http://example.com/me/user#4321')})) self.assertSetEqual(curr, set(self.backend._graph)) + # can set on empty nodes + nodes = Nodes(self.backend, self.ac, self.ent_type, {}) + self.assertEqual(nodes, nodes.set(self.p_filesize.uri, 1234)) + def test_set_from_iterable(self): - self.assertSetEqual(set(self.backend._graph), set()) - nodes = Nodes(self.backend, self.user, self.ent_type, self.ent_ids) + self.assertSetEqual(set(self.backend._graph), self.schema_triples | set()) + nodes = Nodes(self.backend, self.ac, self.ent_type, self.ent_ids) # can set literal and node values simultaneously self.assertEqual(nodes, nodes.set_from_iterable({ self.p_filesize.uri: 1234, - self.p_tag.uri: Nodes(self.backend, self.user, self.tag_type, self.tag_ids), + self.p_tag.uri: Nodes(self.backend, self.ac, self.tag_type, self.tag_ids), }.items())) self.assertTrue(set(self.backend._graph).issuperset({ # nodes exist - (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.RDF.type, rdflib.URIRef('http://bsfs.ai/schema/Entity')), - (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.RDF.type, rdflib.URIRef('http://bsfs.ai/schema/Entity')), - (rdflib.URIRef('http://example.com/me/tag#1234'), rdflib.RDF.type, rdflib.URIRef('http://bsfs.ai/schema/Tag')), - (rdflib.URIRef('http://example.com/me/tag#4321'), rdflib.RDF.type, rdflib.URIRef('http://bsfs.ai/schema/Tag')), + (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Entity')), + (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Entity')), + (rdflib.URIRef('http://example.com/me/tag#1234'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Tag')), + (rdflib.URIRef('http://example.com/me/tag#4321'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Tag')), # links exist (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.URIRef(self.p_filesize.uri), rdflib.Literal(1234, datatype=rdflib.XSD.integer)), (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.URIRef(self.p_filesize.uri), rdflib.Literal(1234, datatype=rdflib.XSD.integer)), @@ -349,9 +390,254 @@ class TestNodes(unittest.TestCase): self.assertSetEqual(curr, set(self.backend._graph)) # cannot assing multiple values to unique predicate self.assertRaises(ValueError, nodes.set_from_iterable, ((self.p_filesize.uri, 1234), - (self.p_author.uri, Nodes(self.backend, self.user, self.user_type, {URI('http://example.com/me/user#1234'), URI('http://example.com/me/user#4321')})))) + (self.p_author.uri, Nodes(self.backend, self.ac, self.user_type, {URI('http://example.com/me/user#1234'), URI('http://example.com/me/user#4321')})))) self.assertSetEqual(curr, set(self.backend._graph)) + # can set on empty nodes + nodes = Nodes(self.backend, self.ac, self.ent_type, {}) + self.assertEqual(nodes, nodes.set_from_iterable([(self.p_filesize.uri, 1234)])) + + + def test_get(self): + # setup: add some instances + Nodes(self.backend, self.ac, self.ent_type, {'http://example.com/me/entity#1234'}) \ + .set(ns.bse.comment, 'hello world') \ + .set(ns.bse.filesize, 1234) \ + .set(ns.bse.tag, Nodes(self.backend, self.ac, self.tag_type, {'http://example.com/me/tag#1234'})) + Nodes(self.backend, self.ac, self.ent_type, {'http://example.com/me/entity#4321'}) \ + .set(ns.bse.filesize, 4321) \ + .set(ns.bse.tag, Nodes(self.backend, self.ac, self.tag_type, {'http://example.com/me/tag#4321'})) + Nodes(self.backend, self.ac, self.tag_type, {'http://example.com/me/tag#1234'}) \ + .set(ns.bst.label, 'tag_label_1234') + Nodes(self.backend, self.ac, self.tag_type, {'http://example.com/me/tag#4321'}) \ + .set(ns.bst.label, 'tag_label_4321') + # setup: get nodes instance + nodes = Nodes(self.backend, self.ac, self.ent_type, self.ent_ids) + + # must pass at least one path + self.assertRaises(AttributeError, nodes.get) + # view must be list or dict + self.assertRaises(ValueError, nodes.get, ns.bse.filesize, view='hello') + self.assertRaises(ValueError, nodes.get, ns.bse.filesize, view=1234) + self.assertRaises(ValueError, nodes.get, ns.bse.filesize, view=tuple) + # can pass path as URI + self.assertDictEqual(nodes.get(ns.bse.filesize), { + Nodes(self.backend, self.ac, self.ent_type, {'http://example.com/me/entity#1234'}): 1234, + Nodes(self.backend, self.ac, self.ent_type, {'http://example.com/me/entity#4321'}): 4321, + }) + # can pass path as sequence of URI + self.assertDictEqual(nodes.get((ns.bse.tag, ns.bst.label)), { + Nodes(self.backend, self.ac, self.ent_type, {'http://example.com/me/entity#1234'}): {'tag_label_1234'}, + Nodes(self.backend, self.ac, self.ent_type, {'http://example.com/me/entity#4321'}): {'tag_label_4321'}, + }) + # get returns the same path that was passed + self.assertCountEqual(list(nodes.get((ns.bse.tag, ns.bst.label), path=True, view=list)), [ + (Nodes(self.backend, self.ac, self.ent_type, {'http://example.com/me/entity#1234'}), (ns.bse.tag, ns.bst.label), 'tag_label_1234'), + (Nodes(self.backend, self.ac, self.ent_type, {'http://example.com/me/entity#4321'}), (ns.bse.tag, ns.bst.label), 'tag_label_4321'), + ]) + self.assertCountEqual(list(nodes.get([ns.bse.tag, ns.bst.label], path=True, view=list)), [ + (Nodes(self.backend, self.ac, self.ent_type, {'http://example.com/me/entity#1234'}), [ns.bse.tag, ns.bst.label], 'tag_label_1234'), + (Nodes(self.backend, self.ac, self.ent_type, {'http://example.com/me/entity#4321'}), [ns.bse.tag, ns.bst.label], 'tag_label_4321'), + ]) + # paths must be URI or sequence thereof + self.assertRaises(TypeError, nodes.get, 1234) + self.assertRaises(TypeError, nodes.get, (ns.bse.tag, 1234)) + self.assertRaises(TypeError, nodes.get, (1234, ns.bse.tag)) + self.assertRaises(ValueError, nodes.get, 'hello world') + self.assertRaises(errors.ConsistencyError, nodes.get, 'hello_world') + self.assertRaises(errors.ConsistencyError, nodes.get, ns.bse.invalid) + self.assertRaises(errors.ConsistencyError, nodes.get, (ns.bse.tag, ns.bst.invalid)) + # can pass multiple paths + self.assertDictEqual(nodes.get(ns.bse.filesize, (ns.bse.tag, ns.bst.label)), { + Nodes(self.backend, self.ac, self.ent_type, {'http://example.com/me/entity#1234'}): { + ns.bse.filesize: 1234, + (ns.bse.tag, ns.bst.label): {'tag_label_1234'}, + }, + Nodes(self.backend, self.ac, self.ent_type, {'http://example.com/me/entity#4321'}): { + ns.bse.filesize: 4321, + (ns.bse.tag, ns.bst.label): {'tag_label_4321'}, + }, + }) + # get respects view + self.assertDictEqual(nodes.get(ns.bse.filesize, view=dict), { + Nodes(self.backend, self.ac, self.ent_type, {'http://example.com/me/entity#1234'}): 1234, + Nodes(self.backend, self.ac, self.ent_type, {'http://example.com/me/entity#4321'}): 4321, + }) + self.assertSetEqual(set(nodes.get(ns.bse.filesize, view=list)), { + (Nodes(self.backend, self.ac, self.ent_type, {'http://example.com/me/entity#1234'}), 1234), + (Nodes(self.backend, self.ac, self.ent_type, {'http://example.com/me/entity#4321'}), 4321), + }) + # get returns Nodes instance when fetching a node + self.assertDictEqual(nodes.get(ns.bse.tag), { + Nodes(self.backend, self.ac, self.ent_type, {'http://example.com/me/entity#1234'}): + {Nodes(self.backend, self.ac, self.tag_type, {'http://example.com/me/tag#1234'})}, + Nodes(self.backend, self.ac, self.ent_type, {'http://example.com/me/entity#4321'}): + {Nodes(self.backend, self.ac, self.tag_type, {'http://example.com/me/tag#4321'})}, + }) + # get returns a value when fetching a value and omits missing values + self.assertDictEqual(nodes.get(ns.bse.comment), { + Nodes(self.backend, self.ac, self.ent_type, {'http://example.com/me/entity#1234'}): {'hello world'}, + }) + + # results can be empty + nodes = Nodes(self.backend, self.ac, self.ent_type, {'http://example.com/me/entity#4321'}) # has filesize, tag but no comment + # unique paths return the default value + self.assertIsNone(nodes.get(ns.bse.author)) + self.assertEqual(nodes.get(ns.bse.author, default=1234), 1234) + # non-unique paths return an empty set + self.assertSetEqual(nodes.get(ns.bse.comment), set()) + + # nodes can have no guids + nodes = Nodes(self.backend, self.ac, self.ent_type, set()) + # empty nodes does not excuse an invalid request + self.assertRaises(TypeError, nodes.get, 1234) + self.assertRaises(errors.ConsistencyError, nodes.get, ns.bse.invalid) + # list view always returns an empty list + self.assertListEqual(list(nodes.get(ns.bse.comment, view=list)), []) + self.assertListEqual(list(nodes.get(ns.bse.comment, view=list, node=True)), []) + self.assertListEqual(list(nodes.get(ns.bse.comment, view=list, path=True)), []) + self.assertListEqual(list(nodes.get(ns.bse.comment, view=list, node=True, path=True, value=True)), []) + self.assertListEqual(list(nodes.get(ns.bse.comment, view=list, node=False)), []) + self.assertListEqual(list(nodes.get(ns.bse.comment, view=list, path=False)), []) + self.assertListEqual(list(nodes.get(ns.bse.comment, view=list, node=False, path=False, value=False)), []) + self.assertListEqual(list(nodes.get(ns.bse.comment, ns.bse.filesize, view=list)), []) + self.assertListEqual(list(nodes.get(ns.bse.comment, ns.bse.filesize, view=list, node=True)), []) + self.assertListEqual(list(nodes.get(ns.bse.comment, ns.bse.filesize, view=list, path=True)), []) + self.assertListEqual(list(nodes.get(ns.bse.comment, ns.bse.filesize, view=list, node=True, path=True, value=True)), []) + self.assertListEqual(list(nodes.get(ns.bse.comment, ns.bse.filesize, view=list, node=False)), []) + self.assertListEqual(list(nodes.get(ns.bse.comment, ns.bse.filesize, view=list, path=False)), []) + self.assertListEqual(list(nodes.get(ns.bse.comment, ns.bse.filesize, view=list, node=False, path=False, value=False)), []) + # dict view returns an empty dict or an empty set + self.assertDictEqual(nodes.get(ns.bse.comment, view=dict), {}) + self.assertDictEqual(nodes.get(ns.bse.comment, view=dict, node=True), {}) + self.assertDictEqual(nodes.get(ns.bse.comment, view=dict, path=True), {}) + self.assertDictEqual(nodes.get(ns.bse.comment, view=dict, node=True, path=True, value=True, default=None), {}) + self.assertSetEqual(nodes.get(ns.bse.comment, view=dict, node=False), set()) + self.assertDictEqual(nodes.get(ns.bse.comment, view=dict, path=False), {}) + self.assertSetEqual(nodes.get(ns.bse.comment, view=dict, node=False, path=False), set()) + self.assertSetEqual(nodes.get(ns.bse.comment, view=dict, node=False, path=False, value=False, default=None), set()) + self.assertDictEqual(nodes.get(ns.bse.comment, ns.bse.filesize, view=dict), {}) + self.assertDictEqual(nodes.get(ns.bse.comment, ns.bse.filesize, view=dict, node=True), {}) + self.assertDictEqual(nodes.get(ns.bse.comment, ns.bse.filesize, view=dict, path=True), {}) + self.assertDictEqual(nodes.get(ns.bse.comment, ns.bse.filesize, view=dict, node=True, path=True, value=True, default=None), {}) + self.assertDictEqual(nodes.get(ns.bse.comment, ns.bse.filesize, view=dict, node=False), {}) + self.assertDictEqual(nodes.get(ns.bse.comment, ns.bse.filesize, view=dict, path=False), {}) + self.assertSetEqual(nodes.get(ns.bse.comment, ns.bse.filesize, view=dict, node=False, path=False), set()) + self.assertSetEqual(nodes.get(ns.bse.comment, ns.bse.filesize, view=dict, node=False, path=False, value=False, default=None), set()) + + + def test_getattr(self): + nodes = Nodes(self.backend, self.ac, self.ent_type, {'http://example.com/me/entity#1234'}) + # can get walks to values + self.assertEqual(nodes.filesize, Walk(nodes, (self.p_filesize, ))) + # can get walks to nodes + self.assertEqual(nodes.tag, Walk(nodes, (self.p_tag, ))) + # can do multiple hops + self.assertEqual(nodes.tag.label, Walk(nodes, (self.p_tag, self.p_label))) + # invalid step raises an error + self.assertRaises(ValueError, getattr, nodes, 'foobar') + + def test_schema(self): + self.assertEqual(Nodes(self.backend, self.ac, self.ent_type, + {URI('http://example.com/me/entity#1234')}).schema, self.backend.schema) + + def test_operators(self): # __add__, __or__, __sub__, __and__ + gen = partial(Nodes, self.backend, self.ac, self.ent_type) + nodes = gen({URI('http://example.com/me/entity#1234')}) + # add/or concatenates guids + self.assertEqual( + gen({URI('http://example.com/me/entity#1234')}) + + gen({URI('http://example.com/me/entity#4321')}), + # target + gen({ + URI('http://example.com/me/entity#1234'), + URI('http://example.com/me/entity#4321')})) + self.assertEqual( + gen({URI('http://example.com/me/entity#1234')}) | + gen({URI('http://example.com/me/entity#4321')}), + # target + gen({ + URI('http://example.com/me/entity#1234'), + URI('http://example.com/me/entity#4321')})) + # repeated guids are ignored + self.assertEqual( + gen({URI('http://example.com/me/entity#1234')}) + + gen({URI('http://example.com/me/entity#1234')}), + # target + gen({URI('http://example.com/me/entity#1234')})) + self.assertEqual( + gen({URI('http://example.com/me/entity#1234')}) | + gen({URI('http://example.com/me/entity#1234')}), + # target + gen({URI('http://example.com/me/entity#1234')})) + + # sub substracts guids + self.assertEqual( + gen({URI('http://example.com/me/entity#1234'), + URI('http://example.com/me/entity#4321')}) - + gen({URI('http://example.com/me/entity#4321')}), + # target + gen({URI('http://example.com/me/entity#1234')})) + # missing guids are ignored + self.assertEqual( + gen({URI('http://example.com/me/entity#1234')}) - + gen({URI('http://example.com/me/entity#4321')}), + # target + gen({URI('http://example.com/me/entity#1234')})) + + # and intersects guids + self.assertEqual( + gen({URI('http://example.com/me/entity#1234'), + URI('http://example.com/me/entity#4321')}) & + gen({URI('http://example.com/me/entity#4321'), + URI('http://example.com/me/entity#5678')}), + # target + gen({URI('http://example.com/me/entity#4321')})) + + for op in (operator.add, operator.or_, operator.sub, operator.and_): + # type must match + self.assertRaises(TypeError, op, nodes, 1234) + self.assertRaises(TypeError, op, nodes, 'hello world') + # backend must match + self.assertRaises(ValueError, op, nodes, + Nodes(None, self.ac, self.ent_type, {URI('http://example.com/me/entity#1234')})) + # ac must match + self.assertRaises(ValueError, op, nodes, + Nodes(self.backend, NullAC(self.backend, ''), + self.ent_type, {URI('http://example.com/me/entity#1234')})) + # node type must match + self.assertRaises(ValueError, op, nodes, + Nodes(self.backend, self.ac, self.tag_type, {URI('http://example.com/me/entity#1234')})) + + def test_len(self): + self.assertEqual(1, len(Nodes(self.backend, self.ac, self.ent_type, { + URI('http://example.com/me/entity#1234'), + }))) + self.assertEqual(2, len(Nodes(self.backend, self.ac, self.ent_type, { + URI('http://example.com/me/entity#1234'), + URI('http://example.com/me/entity#4321'), + }))) + self.assertEqual(4, len(Nodes(self.backend, self.ac, self.ent_type, { + URI('http://example.com/me/entity#1234'), + URI('http://example.com/me/entity#4321'), + URI('http://example.com/me/entity#5678'), + URI('http://example.com/me/entity#8765'), + }))) + + def test_iter(self): # __iter__ + gen = partial(Nodes, self.backend, self.ac, self.ent_type) + self.assertSetEqual(set(Nodes(self.backend, self.ac, self.ent_type, { + URI('http://example.com/me/entity#1234'), + URI('http://example.com/me/entity#4321'), + URI('http://example.com/me/entity#5678'), + URI('http://example.com/me/entity#8765'), + })), { + gen({URI('http://example.com/me/entity#1234')}), + gen({URI('http://example.com/me/entity#4321')}), + gen({URI('http://example.com/me/entity#5678')}), + gen({URI('http://example.com/me/entity#8765')}), + }) + ## main ## diff --git a/test/graph/test_resolve.py b/test/graph/test_resolve.py new file mode 100644 index 0000000..e09b1cc --- /dev/null +++ b/test/graph/test_resolve.py @@ -0,0 +1,199 @@ + +# imports +import unittest + +# bsie imports +from bsfs import schema as bsc +from bsfs.graph import Graph, nodes +from bsfs.namespace import ns +from bsfs.query import ast +from bsfs.triple_store import SparqlStore +from bsfs.utils import URI, errors + +# objects to test +from bsfs.graph.resolve import Filter + + +## code ## + +ns.bse = ns.bsfs.Entity() + +class TestFilter(unittest.TestCase): + """ + + NOTE: The Filter resolver is relatively simple as it only checks and changes + ast.filter.Is instances. Hence, we don't test all methods individually but + all of them with respect to ast.filter.Is elements. + + """ + + def test_call(self): # tests resolve implicitly + schema = bsc.from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + prefix bsl: <https://schema.bsfs.io/core/Literal/> + prefix bsa: <https://schema.bsfs.io/core/Literal/Array/> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + bsfs:Tag rdfs:subClassOf bsfs:Node . + xsd:string rdfs:subClassOf bsfs:Literal . + bsl:Number rdfs:subClassOf bsfs:Literal . + bsl:Array rdfs:subClassOf bsfs:Literal . + bsa:Feature rdfs:subClassOf bsl:Array . + xsd:integer rdfs:subClassOf bsl:Number . + + bsfs:Colors rdfs:subClassOf bsa:Feature ; + bsfs:dimension "5"^^xsd:integer . + + bse:colors rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range bsfs:Colors . + + bse:comment rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range xsd:string ; + bsfs:unique "false"^^xsd:boolean . + + bse:filesize rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range xsd:integer ; + bsfs:unique "false"^^xsd:boolean . + + bse:tag rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range bsfs:Tag ; + bsfs:unique "false"^^xsd:boolean . + ''') + backend = SparqlStore.Open() + backend.schema = schema + graph = Graph(backend, URI('http://example.com/me')) + ents = graph.nodes(ns.bsfs.Entity, + {URI('http://example.com/me/entity#1234'), URI('http://example.com/me/entity#4321')}) + tags = graph.nodes(ns.bsfs.Tag, + {URI('http://example.com/me/tag#1234'), URI('http://example.com/me/tag#4321')}) + invalid = nodes.Nodes(None, '', schema.node(ns.bsfs.Node).child(ns.bsfs.Invalid), + {'http://example.com/you/invalid#1234', 'http://example.com/you/invalid#4321'}) + resolver = Filter(schema) + + # query can be None + self.assertIsNone(resolver(schema.node(ns.bsfs.Entity), None)) + + # immediate Is + self.assertEqual(resolver(schema.node(ns.bsfs.Entity), + ast.filter.Is(ents)), + ast.filter.Or( + ast.filter.Is('http://example.com/me/entity#1234'), + ast.filter.Is('http://example.com/me/entity#4321') + )) + # only resolves nodes instances, not URIs + self.assertEqual(resolver(schema.node(ns.bsfs.Entity), + ast.filter.Is('http://example.com/me/entity#1234')), + ast.filter.Is('http://example.com/me/entity#1234')) + self.assertEqual(resolver(schema.node(ns.bsfs.Entity), + ast.filter.Is(1234)), + ast.filter.Is(1234)) + + # within And (also checks _value) + self.assertEqual(resolver(schema.node(ns.bsfs.Entity), + ast.filter.And( + ast.filter.Is(ents), + ast.filter.Any(ns.bse.comment, ast.filter.Equals('hello world')), + )), + ast.filter.And( + ast.filter.Or( + ast.filter.Is('http://example.com/me/entity#1234'), + ast.filter.Is('http://example.com/me/entity#4321')), + ast.filter.Any(ns.bse.comment, ast.filter.Equals('hello world')) + )) + # within Or (checks _bounded) + self.assertEqual(resolver(schema.node(ns.bsfs.Entity), + ast.filter.Or( + ast.filter.Is(ents), + ast.filter.Any(ns.bse.filesize, ast.filter.LessThan(5)), + )), + ast.filter.Or( + ast.filter.Or( + ast.filter.Is('http://example.com/me/entity#1234'), + ast.filter.Is('http://example.com/me/entity#4321')), + ast.filter.Any(ns.bse.filesize, ast.filter.LessThan(5)) + )) + + # Any-branched Is + self.assertEqual(resolver(schema.node(ns.bsfs.Entity), + ast.filter.Any(ns.bse.tag, ast.filter.Is(tags))), + ast.filter.Any(ns.bse.tag, ast.filter.Or( + ast.filter.Is('http://example.com/me/tag#1234'), + ast.filter.Is('http://example.com/me/tag#4321')), + )) + # All-branched Is + self.assertEqual(resolver(schema.node(ns.bsfs.Entity), + ast.filter.All(ns.bse.tag, ast.filter.Is(tags))), + ast.filter.All(ns.bse.tag, ast.filter.Or( + ast.filter.Is('http://example.com/me/tag#1234'), + ast.filter.Is('http://example.com/me/tag#4321')), + )) + # Negated predicate + self.assertEqual(resolver(schema.node(ns.bsfs.Tag), + ast.filter.Any(ast.filter.Predicate(ns.bse.tag, reverse=True), ast.filter.Is(ents))), + ast.filter.Any(ast.filter.Predicate(ns.bse.tag, reverse=True), ast.filter.Or( + ast.filter.Is('http://example.com/me/entity#1234'), + ast.filter.Is('http://example.com/me/entity#4321')), + )) + + # negated Is + self.assertEqual(resolver(schema.node(ns.bsfs.Entity), + ast.filter.Not(ast.filter.Is(ents))), + ast.filter.Not( + ast.filter.Or( + ast.filter.Is('http://example.com/me/entity#1234'), + ast.filter.Is('http://example.com/me/entity#4321')), + )) + + # for sake of completeness: Has + self.assertEqual(resolver(schema.node(ns.bsfs.Entity), + ast.filter.Has(ns.bse.comment)), + ast.filter.Has(ns.bse.comment)) + # for sake of completeness: Distance + self.assertEqual(resolver(schema.node(ns.bsfs.Entity), + ast.filter.Any(ns.bse.colors, ast.filter.Distance([1,2,3,4,5], 1))), + ast.filter.Any(ns.bse.colors, ast.filter.Distance([1,2,3,4,5], 1))) + # route errors + self.assertRaises(errors.BackendError, resolver, schema.node(ns.bsfs.Tag), + ast.filter.Predicate(ns.bse.comment)) + self.assertRaises(errors.BackendError, resolver, schema.node(ns.bsfs.Tag), + ast.filter.Any(ast.filter.PredicateExpression(), ast.filter.Equals('foo'))) + self.assertRaises(errors.BackendError, resolver._one_of, ast.filter.OneOf(ast.filter.Predicate(ns.bsfs.Predicate))) + # for sake of coverage completeness: valid OneOf + self.assertIsNotNone(resolver._one_of(ast.filter.OneOf(ast.filter.Predicate(ns.bse.colors)))) + + # check schema consistency + self.assertRaises(errors.ConsistencyError, resolver, schema.node(ns.bsfs.Tag), + ast.filter.Is(invalid)) + # check immediate type compatibility + self.assertRaises(errors.ConsistencyError, resolver, schema.node(ns.bsfs.Tag), + ast.filter.Is(ents)) + self.assertRaises(errors.ConsistencyError, resolver, schema.node(ns.bsfs.Entity), + ast.filter.Is(tags)) + # check type compatibility through branches + self.assertRaises(errors.ConsistencyError, resolver, schema.node(ns.bsfs.Tag), + ast.filter.Any(ns.bse.comment, ast.filter.Is(tags))) + self.assertRaises(errors.ConsistencyError, resolver, schema.node(ns.bsfs.Tag), + ast.filter.Any(ns.bse.invalid, ast.filter.Is(tags))) + self.assertRaises(errors.ConsistencyError, resolver, schema.node(ns.bsfs.Tag), + ast.filter.Any(ast.filter.OneOf(ns.bse.comment, ns.bse.tag), ast.filter.Is(tags))) + self.assertRaises(errors.ConsistencyError, resolver, schema.node(ns.bsfs.Tag), + ast.filter.Any(ast.filter.OneOf(ns.bse.comment, ns.bse.filesize), ast.filter.Is(tags))) + self.assertRaises(errors.ConsistencyError, resolver, schema.node(ns.bsfs.Tag), + ast.filter.Any(ast.filter.Predicate(ns.bse.tag, reverse=True), ast.filter.Is(tags))) + + + +## main ## + +if __name__ == '__main__': + unittest.main() + +## EOF ## diff --git a/test/graph/test_result.py b/test/graph/test_result.py new file mode 100644 index 0000000..8960ef6 --- /dev/null +++ b/test/graph/test_result.py @@ -0,0 +1,429 @@ + +# imports +import unittest + +# bsie imports +from bsfs import schema as bsc +from bsfs.namespace import ns +from bsfs.utils import URI + +# objects to test +from bsfs.graph.result import to_list_view, to_dict_view + + +## code ## + +ns.bse = ns.bsfs.Entity() + +class TestListView(unittest.TestCase): + def setUp(self): + self.triples_111 = [('ent#1234', ns.bse.iso, 123)] + self.triples_11U = [('ent#1234', ns.bse.tag, 'tag#1234'), + ('ent#1234', ns.bse.tag, 'tag#5678')] + self.triples_1M1 = [('ent#1234', ns.bse.iso, 123), + ('ent#1234', ns.bse.t_created, '2010-01-02')] + self.triples_1MU = [('ent#1234', ns.bse.iso, 123), + ('ent#1234', ns.bse.tag, 'tag#1234'), + ('ent#1234', ns.bse.tag, 'tag#5678')] + self.triples_N11 = [('ent#1234', ns.bse.iso, 123), + ('ent#4321', ns.bse.iso, 321)] + self.triples_N1U = [('ent#1234', ns.bse.tag, 'tag#1234'), + ('ent#1234', ns.bse.tag, 'tag#5678'), + ('ent#4321', ns.bse.tag, 'tag#4321')] + self.triples_NM1 = [('ent#1234', ns.bse.iso, 123), + ('ent#1234', ns.bse.t_created, '2010-01-02'), + ('ent#4321', ns.bse.iso, 321), + ('ent#4321', ns.bse.t_created, '2022-02-22')] + self.triples_NMU = [('ent#1234', ns.bse.iso, 123), + ('ent#1234', ns.bse.tag, 'tag#1234'), + ('ent#1234', ns.bse.tag, 'tag#5678'), + ('ent#4321', ns.bse.iso, 321), + ('ent#4321', ns.bse.t_created, '2022-02-22')] + + def test_copy(self): + # iterator yields tuples + self.assertIsInstance(list(to_list_view([('subject', 'predicate', 'object')], node=True, path=True, value=True))[0], tuple) + # components are not changed + class Foo(): pass + foo = Foo() + self.assertListEqual(list(to_list_view([('subject', 'predicate', 'object')], node=True, path=True, value=True)), + [('subject', 'predicate', 'object')]) + self.assertListEqual(list(to_list_view([(foo, 'predicate', 'object')], node=True, path=True, value=True)), + [(foo, 'predicate', 'object')]) + self.assertListEqual(list(to_list_view([('subject', foo, 'object')], node=True, path=True, value=True)), + [('subject', foo, 'object')]) + self.assertListEqual(list(to_list_view([('subject', 'predicate', foo)], node=True, path=True, value=True)), + [('subject', 'predicate', foo)]) + + def test_agg_none(self): + self.assertListEqual(list(to_list_view(self.triples_111, node=True, path=True, value=True)), self.triples_111) + self.assertListEqual(list(to_list_view(self.triples_11U, node=True, path=True, value=True)), self.triples_11U) + self.assertListEqual(list(to_list_view(self.triples_1M1, node=True, path=True, value=True)), self.triples_1M1) + self.assertListEqual(list(to_list_view(self.triples_1MU, node=True, path=True, value=True)), self.triples_1MU) + self.assertListEqual(list(to_list_view(self.triples_N11, node=True, path=True, value=True)), self.triples_N11) + self.assertListEqual(list(to_list_view(self.triples_N1U, node=True, path=True, value=True)), self.triples_N1U) + self.assertListEqual(list(to_list_view(self.triples_NM1, node=True, path=True, value=True)), self.triples_NM1) + self.assertListEqual(list(to_list_view(self.triples_NMU, node=True, path=True, value=True)), self.triples_NMU) + + def test_agg_node(self): + self.assertListEqual(list(to_list_view(self.triples_111, node=False, path=True, value=True)), + [(ns.bse.iso, 123)]) + self.assertListEqual(list(to_list_view(self.triples_11U, node=False, path=True, value=True)), + [(ns.bse.tag, 'tag#1234'), (ns.bse.tag, 'tag#5678')]) + self.assertListEqual(list(to_list_view(self.triples_1M1, node=False, path=True, value=True)), + [(ns.bse.iso, 123), (ns.bse.t_created, '2010-01-02')]) + self.assertListEqual(list(to_list_view(self.triples_1MU, node=False, path=True, value=True)), + [(ns.bse.iso, 123), (ns.bse.tag, 'tag#1234'), (ns.bse.tag, 'tag#5678')]) + self.assertListEqual(list(to_list_view(self.triples_N11, node=False, path=True, value=True)), + [(ns.bse.iso, 123), (ns.bse.iso, 321)]) + self.assertListEqual(list(to_list_view(self.triples_N1U, node=False, path=True, value=True)), + [(ns.bse.tag, 'tag#1234'), (ns.bse.tag, 'tag#5678'), (ns.bse.tag, 'tag#4321')]) + self.assertListEqual(list(to_list_view(self.triples_NM1, node=False, path=True, value=True)), + [(ns.bse.iso, 123), (ns.bse.t_created, '2010-01-02'), (ns.bse.iso, 321), (ns.bse.t_created, '2022-02-22')]) + self.assertListEqual(list(to_list_view(self.triples_NMU, node=False, path=True, value=True)), + [(ns.bse.iso, 123), (ns.bse.tag, 'tag#1234'), (ns.bse.tag, 'tag#5678'), (ns.bse.iso, 321), (ns.bse.t_created, '2022-02-22')]) + + def test_agg_path(self): + self.assertListEqual(list(to_list_view(self.triples_111, node=True, path=False, value=True)), + [('ent#1234', 123)]) + self.assertListEqual(list(to_list_view(self.triples_11U, node=True, path=False, value=True)), + [('ent#1234', 'tag#1234'), ('ent#1234', 'tag#5678')]) + self.assertListEqual(list(to_list_view(self.triples_1M1, node=True, path=False, value=True)), + [('ent#1234', 123), ('ent#1234', '2010-01-02')]) + self.assertListEqual(list(to_list_view(self.triples_1MU, node=True, path=False, value=True)), + [('ent#1234', 123), ('ent#1234', 'tag#1234'), ('ent#1234', 'tag#5678')]) + self.assertListEqual(list(to_list_view(self.triples_N11, node=True, path=False, value=True)), + [('ent#1234', 123), ('ent#4321', 321)]) + self.assertListEqual(list(to_list_view(self.triples_N1U, node=True, path=False, value=True)), + [('ent#1234', 'tag#1234'), ('ent#1234', 'tag#5678'), ('ent#4321', 'tag#4321')]) + self.assertListEqual(list(to_list_view(self.triples_NM1, node=True, path=False, value=True)), + [('ent#1234', 123), ('ent#1234', '2010-01-02'), ('ent#4321', 321), ('ent#4321', '2022-02-22')]) + self.assertListEqual(list(to_list_view(self.triples_NMU, node=True, path=False, value=True)), + [('ent#1234', 123), ('ent#1234', 'tag#1234'), ('ent#1234', 'tag#5678'), ('ent#4321', 321), ('ent#4321', '2022-02-22')]) + + def test_agg_node_path(self): + self.assertListEqual(list(to_list_view(self.triples_111, node=False, path=False, value=True)), + [123]) + self.assertListEqual(list(to_list_view(self.triples_11U, node=False, path=False, value=True)), + ['tag#1234', 'tag#5678']) + self.assertListEqual(list(to_list_view(self.triples_1M1, node=False, path=False, value=True)), + [123, '2010-01-02']) + self.assertListEqual(list(to_list_view(self.triples_1MU, node=False, path=False, value=True)), + [123, 'tag#1234', 'tag#5678']) + self.assertListEqual(list(to_list_view(self.triples_N11, node=False, path=False, value=True)), + [123, 321]) + self.assertListEqual(list(to_list_view(self.triples_N1U, node=False, path=False, value=True)), + ['tag#1234', 'tag#5678', 'tag#4321']) + self.assertListEqual(list(to_list_view(self.triples_NM1, node=False, path=False, value=True)), + [123, '2010-01-02', 321, '2022-02-22']) + self.assertListEqual(list(to_list_view(self.triples_NMU, node=False, path=False, value=True)), + [123, 'tag#1234', 'tag#5678', 321, '2022-02-22']) + + def test_agg_value(self): + # value flag has no effect + self.assertListEqual(list(to_list_view(self.triples_111, node=True, path=True, value=True)), self.triples_111) + self.assertListEqual(list(to_list_view(self.triples_11U, node=True, path=True, value=True)), self.triples_11U) + self.assertListEqual(list(to_list_view(self.triples_1M1, node=True, path=True, value=True)), self.triples_1M1) + self.assertListEqual(list(to_list_view(self.triples_1MU, node=True, path=True, value=True)), self.triples_1MU) + self.assertListEqual(list(to_list_view(self.triples_N11, node=True, path=True, value=True)), self.triples_N11) + self.assertListEqual(list(to_list_view(self.triples_N1U, node=True, path=True, value=True)), self.triples_N1U) + self.assertListEqual(list(to_list_view(self.triples_NM1, node=True, path=True, value=True)), self.triples_NM1) + self.assertListEqual(list(to_list_view(self.triples_NMU, node=True, path=True, value=True)), self.triples_NMU) + + def test_agg_node_value(self): + # value flag has no effect -> same test as test_agg_node + self.assertListEqual(list(to_list_view(self.triples_111, node=False, path=True, value=False)), + [(ns.bse.iso, 123)]) + self.assertListEqual(list(to_list_view(self.triples_11U, node=False, path=True, value=False)), + [(ns.bse.tag, 'tag#1234'), (ns.bse.tag, 'tag#5678')]) + self.assertListEqual(list(to_list_view(self.triples_1M1, node=False, path=True, value=False)), + [(ns.bse.iso, 123), (ns.bse.t_created, '2010-01-02')]) + self.assertListEqual(list(to_list_view(self.triples_1MU, node=False, path=True, value=False)), + [(ns.bse.iso, 123), (ns.bse.tag, 'tag#1234'), (ns.bse.tag, 'tag#5678')]) + self.assertListEqual(list(to_list_view(self.triples_N11, node=False, path=True, value=False)), + [(ns.bse.iso, 123), (ns.bse.iso, 321)]) + self.assertListEqual(list(to_list_view(self.triples_N1U, node=False, path=True, value=False)), + [(ns.bse.tag, 'tag#1234'), (ns.bse.tag, 'tag#5678'), (ns.bse.tag, 'tag#4321')]) + self.assertListEqual(list(to_list_view(self.triples_NM1, node=False, path=True, value=False)), + [(ns.bse.iso, 123), (ns.bse.t_created, '2010-01-02'), (ns.bse.iso, 321), (ns.bse.t_created, '2022-02-22')]) + self.assertListEqual(list(to_list_view(self.triples_NMU, node=False, path=True, value=False)), + [(ns.bse.iso, 123), (ns.bse.tag, 'tag#1234'), (ns.bse.tag, 'tag#5678'), (ns.bse.iso, 321), (ns.bse.t_created, '2022-02-22')]) + + def test_agg_path_value(self): + # value flag has no effect -> same test as test_agg_path + self.assertListEqual(list(to_list_view(self.triples_111, node=True, path=False, value=False)), + [('ent#1234', 123)]) + self.assertListEqual(list(to_list_view(self.triples_11U, node=True, path=False, value=False)), + [('ent#1234', 'tag#1234'), ('ent#1234', 'tag#5678')]) + self.assertListEqual(list(to_list_view(self.triples_1M1, node=True, path=False, value=False)), + [('ent#1234', 123), ('ent#1234', '2010-01-02')]) + self.assertListEqual(list(to_list_view(self.triples_1MU, node=True, path=False, value=False)), + [('ent#1234', 123), ('ent#1234', 'tag#1234'), ('ent#1234', 'tag#5678')]) + self.assertListEqual(list(to_list_view(self.triples_N11, node=True, path=False, value=False)), + [('ent#1234', 123), ('ent#4321', 321)]) + self.assertListEqual(list(to_list_view(self.triples_N1U, node=True, path=False, value=False)), + [('ent#1234', 'tag#1234'), ('ent#1234', 'tag#5678'), ('ent#4321', 'tag#4321')]) + self.assertListEqual(list(to_list_view(self.triples_NM1, node=True, path=False, value=False)), + [('ent#1234', 123), ('ent#1234', '2010-01-02'), ('ent#4321', 321), ('ent#4321', '2022-02-22')]) + self.assertListEqual(list(to_list_view(self.triples_NMU, node=True, path=False, value=False)), + [('ent#1234', 123), ('ent#1234', 'tag#1234'), ('ent#1234', 'tag#5678'), ('ent#4321', 321), ('ent#4321', '2022-02-22')]) + + def test_agg_all(self): + # value flag has no effect -> same test as test_agg_node_path + self.assertListEqual(list(to_list_view(self.triples_111, node=False, path=False, value=False)), + [123]) + self.assertListEqual(list(to_list_view(self.triples_11U, node=False, path=False, value=False)), + ['tag#1234', 'tag#5678']) + self.assertListEqual(list(to_list_view(self.triples_1M1, node=False, path=False, value=False)), + [123, '2010-01-02']) + self.assertListEqual(list(to_list_view(self.triples_1MU, node=False, path=False, value=False)), + [123, 'tag#1234', 'tag#5678']) + self.assertListEqual(list(to_list_view(self.triples_N11, node=False, path=False, value=False)), + [123, 321]) + self.assertListEqual(list(to_list_view(self.triples_N1U, node=False, path=False, value=False)), + ['tag#1234', 'tag#5678', 'tag#4321']) + self.assertListEqual(list(to_list_view(self.triples_NM1, node=False, path=False, value=False)), + [123, '2010-01-02', 321, '2022-02-22']) + self.assertListEqual(list(to_list_view(self.triples_NMU, node=False, path=False, value=False)), + [123, 'tag#1234', 'tag#5678', 321, '2022-02-22']) + + +class TestDictView(unittest.TestCase): + def setUp(self): + self.unique_paths = {ns.bse.iso, ns.bse.t_created} + self.triples_111 = [('ent#1234', ns.bse.iso, 123)] + self.triples_11U = [('ent#1234', ns.bse.tag, 'tag#1234'), + ('ent#1234', ns.bse.tag, 'tag#5678')] + self.triples_1M1 = [('ent#1234', ns.bse.iso, 123), + ('ent#1234', ns.bse.t_created, '2010-01-02')] + self.triples_1MU = [('ent#1234', ns.bse.iso, 123), + ('ent#1234', ns.bse.tag, 'tag#1234'), + ('ent#1234', ns.bse.tag, 'tag#5678')] + self.triples_N11 = [('ent#1234', ns.bse.iso, 123), + ('ent#4321', ns.bse.iso, 321)] + self.triples_N1U = [('ent#1234', ns.bse.tag, 'tag#1234'), + ('ent#1234', ns.bse.tag, 'tag#5678'), + ('ent#4321', ns.bse.tag, 'tag#4321')] + self.triples_NM1 = [('ent#1234', ns.bse.iso, 123), + ('ent#1234', ns.bse.t_created, '2010-01-02'), + ('ent#4321', ns.bse.iso, 321), + ('ent#4321', ns.bse.t_created, '2022-02-22')] + self.triples_NMU = [('ent#1234', ns.bse.iso, 123), + ('ent#1234', ns.bse.tag, 'tag#1234'), + ('ent#1234', ns.bse.tag, 'tag#5678'), + ('ent#4321', ns.bse.iso, 321), + ('ent#4321', ns.bse.t_created, '2022-02-22')] + + def test_errounous_call(self): + # return set instead of value + self.assertSetEqual(to_dict_view(self.triples_111, one_node=False, one_path=True, unique_paths=self.unique_paths, node=False, path=False, value=False), + {123}) + self.assertSetEqual(to_dict_view(self.triples_111, one_node=True, one_path=False, unique_paths=self.unique_paths, node=False, path=False, value=False), + {123}) + # one_node mismatch: return set of values instead of value + self.assertDictEqual(to_dict_view(self.triples_111, one_node=False, one_path=True, unique_paths=self.unique_paths, node=False, path=True, value=False), + {ns.bse.iso: {123}}) + # one_path mismatch: return set of values instead of value + self.assertDictEqual(to_dict_view(self.triples_111, one_node=True, one_path=False, unique_paths=self.unique_paths, node=True, path=False, value=False), + {'ent#1234': {123}}) + # unique_paths mismatch: return set of values instead of value + self.assertSetEqual(to_dict_view(self.triples_111, one_node=True, one_path=False, unique_paths={}, node=False, path=False, value=False), + {123}) + self.assertDictEqual(to_dict_view(self.triples_111, one_node=True, one_path=False, unique_paths={}, node=False, path=True, value=False), + {ns.bse.iso: {123}}) + self.assertDictEqual(to_dict_view(self.triples_111, one_node=True, one_path=False, unique_paths={}, node=True, path=False, value=False), + {'ent#1234': {123}}) + self.assertDictEqual(to_dict_view(self.triples_111, one_node=True, one_path=False, unique_paths={}, node=True, path=True, value=False), + {'ent#1234': {ns.bse.iso: {123}}}) + + def test_agg_none(self): + self.assertDictEqual(to_dict_view(self.triples_111, one_node=True, one_path=True, unique_paths=self.unique_paths, node=True, path=True, value=True), + {'ent#1234': {ns.bse.iso: {123}}}) + self.assertDictEqual(to_dict_view(self.triples_11U, one_node=True, one_path=True, unique_paths=self.unique_paths, node=True, path=True, value=True), + {'ent#1234': {ns.bse.tag: {'tag#1234', 'tag#5678'}}}) + self.assertDictEqual(to_dict_view(self.triples_1M1, one_node=True, one_path=False, unique_paths=self.unique_paths, node=True, path=True, value=True), + {'ent#1234': {ns.bse.iso: {123}, ns.bse.t_created: {'2010-01-02'}}}) + self.assertDictEqual(to_dict_view(self.triples_1MU, one_node=True, one_path=False, unique_paths=self.unique_paths, node=True, path=True, value=True), + {'ent#1234': {ns.bse.iso: {123}, ns.bse.tag: {'tag#1234', 'tag#5678'}}}) + self.assertDictEqual(to_dict_view(self.triples_N11, one_node=False, one_path=True, unique_paths=self.unique_paths, node=True, path=True, value=True), + {'ent#1234': {ns.bse.iso: {123}}, 'ent#4321': {ns.bse.iso: {321}}}) + self.assertDictEqual(to_dict_view(self.triples_N1U, one_node=False, one_path=True, unique_paths=self.unique_paths, node=True, path=True, value=True), + {'ent#1234': {ns.bse.tag: {'tag#1234', 'tag#5678'}}, 'ent#4321': {ns.bse.tag: {'tag#4321'}}}) + self.assertDictEqual(to_dict_view(self.triples_NM1, one_node=False, one_path=False, unique_paths=self.unique_paths, node=True, path=True, value=True), + {'ent#1234': {ns.bse.iso: {123}, ns.bse.t_created: {'2010-01-02'}}, 'ent#4321': {ns.bse.iso: {321}, ns.bse.t_created: {'2022-02-22'}}}) + self.assertDictEqual(to_dict_view(self.triples_NMU, one_node=False, one_path=False, unique_paths=self.unique_paths, node=True, path=True, value=True), + {'ent#1234': {ns.bse.iso: {123}, ns.bse.tag: {'tag#1234', 'tag#5678'}}, 'ent#4321': {ns.bse.iso: {321}, ns.bse.t_created: {'2022-02-22'}}}) + # empty + self.assertDictEqual(to_dict_view([], one_node=True, one_path=True, unique_paths=self.unique_paths, node=True, path=True, value=True), {}) + self.assertDictEqual(to_dict_view([], one_node=True, one_path=False, unique_paths=self.unique_paths, node=True, path=True, value=True), {}) + self.assertDictEqual(to_dict_view([], one_node=False, one_path=True, unique_paths=self.unique_paths, node=True, path=True, value=True), {}) + self.assertDictEqual(to_dict_view([], one_node=False, one_path=False, unique_paths=self.unique_paths, node=True, path=True, value=True), {}) + + def test_agg_node(self): + self.assertDictEqual(to_dict_view(self.triples_111, one_node=True, one_path=True, unique_paths=self.unique_paths, node=False, path=True, value=True), + {ns.bse.iso: {123}}) + self.assertDictEqual(to_dict_view(self.triples_11U, one_node=True, one_path=True, unique_paths=self.unique_paths, node=False, path=True, value=True), + {ns.bse.tag: {'tag#1234', 'tag#5678'}}) + self.assertDictEqual(to_dict_view(self.triples_1M1, one_node=True, one_path=False, unique_paths=self.unique_paths, node=False, path=True, value=True), + {ns.bse.iso: {123}, ns.bse.t_created: {'2010-01-02'}}) + self.assertDictEqual(to_dict_view(self.triples_1MU, one_node=True, one_path=False, unique_paths=self.unique_paths, node=False, path=True, value=True), + {ns.bse.iso: {123}, ns.bse.tag: {'tag#1234', 'tag#5678'}}) + self.assertDictEqual(to_dict_view(self.triples_N11, one_node=False, one_path=True, unique_paths=self.unique_paths, node=False, path=True, value=True), + {ns.bse.iso: {123, 321}}) + self.assertDictEqual(to_dict_view(self.triples_N1U, one_node=False, one_path=True, unique_paths=self.unique_paths, node=False, path=True, value=True), + {ns.bse.tag: {'tag#1234', 'tag#5678', 'tag#4321'}}) + self.assertDictEqual(to_dict_view(self.triples_NM1, one_node=False, one_path=False, unique_paths=self.unique_paths, node=False, path=True, value=True), + {ns.bse.iso: {123, 321}, ns.bse.t_created: {'2010-01-02', '2022-02-22'}}) + self.assertDictEqual(to_dict_view(self.triples_NMU, one_node=False, one_path=False, unique_paths=self.unique_paths, node=False, path=True, value=True), + {ns.bse.iso: {123, 321}, ns.bse.tag: {'tag#1234', 'tag#5678'}, ns.bse.t_created: {'2022-02-22'}}) + # empty + self.assertDictEqual(to_dict_view([], one_node=True, one_path=True, unique_paths=self.unique_paths, node=False, path=True, value=True), {}) + self.assertDictEqual(to_dict_view([], one_node=True, one_path=False, unique_paths=self.unique_paths, node=False, path=True, value=True), {}) + self.assertDictEqual(to_dict_view([], one_node=False, one_path=True, unique_paths=self.unique_paths, node=False, path=True, value=True), {}) + self.assertDictEqual(to_dict_view([], one_node=False, one_path=False, unique_paths=self.unique_paths, node=False, path=True, value=True), {}) + + def test_agg_path(self): + self.assertDictEqual(to_dict_view(self.triples_111, one_node=True, one_path=True, unique_paths=self.unique_paths, node=True, path=False, value=True), + {'ent#1234': {123}}) + self.assertDictEqual(to_dict_view(self.triples_11U, one_node=True, one_path=True, unique_paths=self.unique_paths, node=True, path=False, value=True), + {'ent#1234': {'tag#1234', 'tag#5678'}}) + self.assertDictEqual(to_dict_view(self.triples_1M1, one_node=True, one_path=False, unique_paths=self.unique_paths, node=True, path=False, value=True), + {'ent#1234': {123, '2010-01-02'}}) + self.assertDictEqual(to_dict_view(self.triples_1MU, one_node=True, one_path=False, unique_paths=self.unique_paths, node=True, path=False, value=True), + {'ent#1234': {123, 'tag#1234', 'tag#5678'}}) + self.assertDictEqual(to_dict_view(self.triples_N11, one_node=False, one_path=True, unique_paths=self.unique_paths, node=True, path=False, value=True), + {'ent#1234': {123}, 'ent#4321': {321}}) + self.assertDictEqual(to_dict_view(self.triples_N1U, one_node=False, one_path=True, unique_paths=self.unique_paths, node=True, path=False, value=True), + {'ent#1234': {'tag#1234', 'tag#5678'}, 'ent#4321': {'tag#4321'}}) + self.assertDictEqual(to_dict_view(self.triples_NM1, one_node=False, one_path=False, unique_paths=self.unique_paths, node=True, path=False, value=True), + {'ent#1234': {123, '2010-01-02'}, 'ent#4321': {321, '2022-02-22'}}) + self.assertDictEqual(to_dict_view(self.triples_NMU, one_node=False, one_path=False, unique_paths=self.unique_paths, node=True, path=False, value=True), + {'ent#1234': {123, 'tag#1234', 'tag#5678'}, 'ent#4321': {321, '2022-02-22'}}) + # empty + self.assertDictEqual(to_dict_view([], one_node=True, one_path=True, unique_paths=self.unique_paths, node=True, path=False, value=True), {}) + self.assertDictEqual(to_dict_view([], one_node=True, one_path=False, unique_paths=self.unique_paths, node=True, path=False, value=True), {}) + self.assertDictEqual(to_dict_view([], one_node=False, one_path=True, unique_paths=self.unique_paths, node=True, path=False, value=True), {}) + self.assertDictEqual(to_dict_view([], one_node=False, one_path=False, unique_paths=self.unique_paths, node=True, path=False, value=True), {}) + + def test_agg_node_path(self): + self.assertSetEqual(to_dict_view(self.triples_111, one_node=True, one_path=True, unique_paths=self.unique_paths, node=False, path=False, value=True), + {123}) + self.assertSetEqual(to_dict_view(self.triples_11U, one_node=True, one_path=True, unique_paths=self.unique_paths, node=False, path=False, value=True), + {'tag#1234', 'tag#5678'}) + self.assertSetEqual(to_dict_view(self.triples_1M1, one_node=True, one_path=False, unique_paths=self.unique_paths, node=False, path=False, value=True), + {123, '2010-01-02'}) + self.assertSetEqual(to_dict_view(self.triples_1MU, one_node=True, one_path=False, unique_paths=self.unique_paths, node=False, path=False, value=True), + {123, 'tag#1234', 'tag#5678'}) + self.assertSetEqual(to_dict_view(self.triples_N11, one_node=False, one_path=True, unique_paths=self.unique_paths, node=False, path=False, value=True), + {123, 321}) + self.assertSetEqual(to_dict_view(self.triples_N1U, one_node=False, one_path=True, unique_paths=self.unique_paths, node=False, path=False, value=True), + {'tag#1234', 'tag#5678', 'tag#4321'}) + self.assertSetEqual(to_dict_view(self.triples_NM1, one_node=False, one_path=False, unique_paths=self.unique_paths, node=False, path=False, value=True), + {123, '2010-01-02', 321, '2022-02-22'}) + self.assertSetEqual(to_dict_view(self.triples_NMU, one_node=False, one_path=False, unique_paths=self.unique_paths, node=False, path=False, value=True), + {123, 'tag#1234', 'tag#5678', 321, '2022-02-22'}) + # empty + self.assertSetEqual(to_dict_view([], one_node=True, one_path=True, unique_paths=self.unique_paths, node=False, path=False, value=True), set()) + self.assertSetEqual(to_dict_view([], one_node=True, one_path=False, unique_paths=self.unique_paths, node=False, path=False, value=True), set()) + self.assertSetEqual(to_dict_view([], one_node=False, one_path=True, unique_paths=self.unique_paths, node=False, path=False, value=True), set()) + self.assertSetEqual(to_dict_view([], one_node=False, one_path=False, unique_paths=self.unique_paths, node=False, path=False, value=True), set()) + + def test_agg_value(self): + self.assertDictEqual(to_dict_view(self.triples_111, one_node=True, one_path=True, unique_paths=self.unique_paths, node=True, path=True, value=False), + {'ent#1234': {ns.bse.iso: 123}}) + self.assertDictEqual(to_dict_view(self.triples_11U, one_node=True, one_path=True, unique_paths=self.unique_paths, node=True, path=True, value=False), + {'ent#1234': {ns.bse.tag: {'tag#1234', 'tag#5678'}}}) + self.assertDictEqual(to_dict_view(self.triples_1M1, one_node=True, one_path=False, unique_paths=self.unique_paths, node=True, path=True, value=False), + {'ent#1234': {ns.bse.iso: 123, ns.bse.t_created: '2010-01-02'}}) + self.assertDictEqual(to_dict_view(self.triples_1MU, one_node=True, one_path=False, unique_paths=self.unique_paths, node=True, path=True, value=False), + {'ent#1234': {ns.bse.iso: 123, ns.bse.tag: {'tag#1234', 'tag#5678'}}}) + self.assertDictEqual(to_dict_view(self.triples_N11, one_node=False, one_path=True, unique_paths=self.unique_paths, node=True, path=True, value=False), + {'ent#1234': {ns.bse.iso: 123}, 'ent#4321': {ns.bse.iso: 321}}) + self.assertDictEqual(to_dict_view(self.triples_N1U, one_node=False, one_path=True, unique_paths=self.unique_paths, node=True, path=True, value=False), + {'ent#1234': {ns.bse.tag: {'tag#1234', 'tag#5678'}}, 'ent#4321': {ns.bse.tag: {'tag#4321'}}}) + self.assertDictEqual(to_dict_view(self.triples_NM1, one_node=False, one_path=False, unique_paths=self.unique_paths, node=True, path=True, value=False), + {'ent#1234': {ns.bse.iso: 123, ns.bse.t_created: '2010-01-02'}, 'ent#4321': {ns.bse.iso: 321, ns.bse.t_created: '2022-02-22'}}) + self.assertDictEqual(to_dict_view(self.triples_NMU, one_node=False, one_path=False, unique_paths=self.unique_paths, node=True, path=True, value=False), + {'ent#1234': {ns.bse.iso: 123, ns.bse.tag: {'tag#1234', 'tag#5678'}}, 'ent#4321': {ns.bse.iso: 321, ns.bse.t_created: '2022-02-22'}}) + # empty + self.assertDictEqual(to_dict_view([], one_node=True, one_path=True, unique_paths=self.unique_paths, node=True, path=True, value=False), {}) + self.assertDictEqual(to_dict_view([], one_node=True, one_path=False, unique_paths=self.unique_paths, node=True, path=True, value=False), {}) + self.assertDictEqual(to_dict_view([], one_node=False, one_path=True, unique_paths=self.unique_paths, node=True, path=True, value=False), {}) + self.assertDictEqual(to_dict_view([], one_node=False, one_path=False, unique_paths=self.unique_paths, node=True, path=True, value=False), {}) + + def test_agg_node_value(self): + self.assertDictEqual(to_dict_view(self.triples_111, one_node=True, one_path=True, unique_paths=self.unique_paths, node=False, path=True, value=False), + {ns.bse.iso: 123}) + self.assertDictEqual(to_dict_view(self.triples_11U, one_node=True, one_path=True, unique_paths=self.unique_paths, node=False, path=True, value=False), + {ns.bse.tag: {'tag#1234', 'tag#5678'}}) + self.assertDictEqual(to_dict_view(self.triples_1M1, one_node=True, one_path=False, unique_paths=self.unique_paths, node=False, path=True, value=False), + {ns.bse.iso: 123, ns.bse.t_created: '2010-01-02'}) + self.assertDictEqual(to_dict_view(self.triples_1MU, one_node=True, one_path=False, unique_paths=self.unique_paths, node=False, path=True, value=False), + {ns.bse.iso: 123, ns.bse.tag: {'tag#1234', 'tag#5678'}}) + self.assertDictEqual(to_dict_view(self.triples_N11, one_node=False, one_path=True, unique_paths=self.unique_paths, node=False, path=True, value=False), + {ns.bse.iso: {123, 321}}) + self.assertDictEqual(to_dict_view(self.triples_N1U, one_node=False, one_path=True, unique_paths=self.unique_paths, node=False, path=True, value=False), + {ns.bse.tag: {'tag#1234', 'tag#5678', 'tag#4321'}}) + self.assertDictEqual(to_dict_view(self.triples_NM1, one_node=False, one_path=False, unique_paths=self.unique_paths, node=False, path=True, value=False), + {ns.bse.iso: {123, 321}, ns.bse.t_created: {'2010-01-02', '2022-02-22'}}) + self.assertDictEqual(to_dict_view(self.triples_NMU, one_node=False, one_path=False, unique_paths=self.unique_paths, node=False, path=True, value=False), + {ns.bse.iso: {123, 321}, ns.bse.tag: {'tag#1234', 'tag#5678'}, ns.bse.t_created: {'2022-02-22'}}) + # empty + self.assertDictEqual(to_dict_view([], one_node=True, one_path=True, unique_paths=self.unique_paths, node=False, path=True, value=False), {}) + self.assertDictEqual(to_dict_view([], one_node=True, one_path=False, unique_paths=self.unique_paths, node=False, path=True, value=False), {}) + self.assertDictEqual(to_dict_view([], one_node=False, one_path=True, unique_paths=self.unique_paths, node=False, path=True, value=False), {}) + self.assertDictEqual(to_dict_view([], one_node=False, one_path=False, unique_paths=self.unique_paths, node=False, path=True, value=False), {}) + + def test_agg_path_value(self): + self.assertDictEqual(to_dict_view(self.triples_111, one_node=True, one_path=True, unique_paths=self.unique_paths, node=True, path=False, value=False), + {'ent#1234': 123}) + self.assertDictEqual(to_dict_view(self.triples_11U, one_node=True, one_path=True, unique_paths=self.unique_paths, node=True, path=False, value=False), + {'ent#1234': {'tag#1234', 'tag#5678'}}) + self.assertDictEqual(to_dict_view(self.triples_1M1, one_node=True, one_path=False, unique_paths=self.unique_paths, node=True, path=False, value=False), + {'ent#1234': {123, '2010-01-02'}}) + self.assertDictEqual(to_dict_view(self.triples_1MU, one_node=True, one_path=False, unique_paths=self.unique_paths, node=True, path=False, value=False), + {'ent#1234': {123, 'tag#1234', 'tag#5678'}}) + self.assertDictEqual(to_dict_view(self.triples_N11, one_node=False, one_path=True, unique_paths=self.unique_paths, node=True, path=False, value=False), + {'ent#1234': 123, 'ent#4321': 321}) + self.assertDictEqual(to_dict_view(self.triples_N1U, one_node=False, one_path=True, unique_paths=self.unique_paths, node=True, path=False, value=False), + {'ent#1234': {'tag#1234', 'tag#5678'}, 'ent#4321': {'tag#4321'}}) + self.assertDictEqual(to_dict_view(self.triples_NM1, one_node=False, one_path=False, unique_paths=self.unique_paths, node=True, path=False, value=False), + {'ent#1234': {123, '2010-01-02'}, 'ent#4321': {321, '2022-02-22'}}) + self.assertDictEqual(to_dict_view(self.triples_NMU, one_node=False, one_path=False, unique_paths=self.unique_paths, node=True, path=False, value=False), + {'ent#1234': {123, 'tag#1234', 'tag#5678'}, 'ent#4321': {321, '2022-02-22'}}) + # empty + self.assertDictEqual(to_dict_view([], one_node=True, one_path=True, unique_paths=self.unique_paths, node=True, path=False, value=False), {}) + self.assertDictEqual(to_dict_view([], one_node=True, one_path=False, unique_paths=self.unique_paths, node=True, path=False, value=False), {}) + self.assertDictEqual(to_dict_view([], one_node=False, one_path=True, unique_paths=self.unique_paths, node=True, path=False, value=False), {}) + self.assertDictEqual(to_dict_view([], one_node=False, one_path=False, unique_paths=self.unique_paths, node=True, path=False, value=False), {}) + + def test_agg_all(self): + self.assertEqual(to_dict_view(self.triples_111, one_node=True, one_path=True, unique_paths=self.unique_paths, node=False, path=False, value=False), + 123) + self.assertSetEqual(to_dict_view(self.triples_11U, one_node=True, one_path=True, unique_paths=self.unique_paths, node=False, path=False, value=False), + {'tag#1234', 'tag#5678'}) + self.assertSetEqual(to_dict_view(self.triples_1M1, one_node=True, one_path=False, unique_paths=self.unique_paths, node=False, path=False, value=False), + {123, '2010-01-02'}) + self.assertSetEqual(to_dict_view(self.triples_1MU, one_node=True, one_path=False, unique_paths=self.unique_paths, node=False, path=False, value=False), + {123, 'tag#1234', 'tag#5678'}) + self.assertSetEqual(to_dict_view(self.triples_N11, one_node=False, one_path=True, unique_paths=self.unique_paths, node=False, path=False, value=False), + {123, 321}) + self.assertSetEqual(to_dict_view(self.triples_N1U, one_node=False, one_path=True, unique_paths=self.unique_paths, node=False, path=False, value=False), + {'tag#1234', 'tag#5678', 'tag#4321'}) + self.assertSetEqual(to_dict_view(self.triples_NM1, one_node=False, one_path=False, unique_paths=self.unique_paths, node=False, path=False, value=False), + {123, '2010-01-02', 321, '2022-02-22'}) + self.assertSetEqual(to_dict_view(self.triples_NMU, one_node=False, one_path=False, unique_paths=self.unique_paths, node=False, path=False, value=False), + {123, 'tag#1234', 'tag#5678', 321, '2022-02-22'}) + # empty + self.assertEqual(to_dict_view([], one_node=True, one_path=True, unique_paths=self.unique_paths, node=False, path=False, value=False), None) + self.assertSetEqual(to_dict_view([], one_node=True, one_path=False, unique_paths=self.unique_paths, node=False, path=False, value=False), set()) + self.assertSetEqual(to_dict_view([], one_node=False, one_path=True, unique_paths=self.unique_paths, node=False, path=False, value=False), set()) + self.assertSetEqual(to_dict_view([], one_node=False, one_path=False, unique_paths=self.unique_paths, node=False, path=False, value=False), set()) + self.assertEqual(to_dict_view([], one_node=True, one_path=True, unique_paths=self.unique_paths, node=False, path=False, value=False, default=123), 123) + +## main ## + +if __name__ == '__main__': + unittest.main() + +## EOF ## diff --git a/test/graph/test_walk.py b/test/graph/test_walk.py new file mode 100644 index 0000000..4b844da --- /dev/null +++ b/test/graph/test_walk.py @@ -0,0 +1,170 @@ + +# imports +import unittest + +# bsfs imports +from bsfs import schema as bsc +from bsfs.graph import Graph +from bsfs.graph.ac import NullAC +from bsfs.namespace import Namespace, ns +from bsfs.triple_store.sparql import SparqlStore +from bsfs.utils import URI + +# symbol to test +from bsfs.graph.walk import Walk + +## code ## + +ns.bse = ns.bsfs.Entity() +ns.bst = ns.bsfs.Tag() + +class TestWalk(unittest.TestCase): + def setUp(self): + # backend setup + self.schema = bsc.from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + prefix bst: <https://schema.bsfs.io/core/Tag#> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + bsfs:Tag rdfs:subClassOf bsfs:Node . + bsfs:User rdfs:subClassOf bsfs:Node . + xsd:string rdfs:subClassOf bsfs:Literal . + + bse:author rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Node ; + rdfs:range bsfs:User . + + bse:tag rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range bsfs:Tag . + + bst:label rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Tag ; + rdfs:range xsd:string ; + bsfs:unique "true"^^xsd:boolean . + + bst:subTagOf rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Tag ; + rdfs:range bsfs:Tag . + + bst:main rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Tag ; + rdfs:range bsfs:Entity . + + bst:author rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Tag ; + rdfs:range xsd:string . + + ''') + self.backend = SparqlStore.Open() + self.user = URI('http://example.com/me') + self.ac = NullAC(self.backend, self.user) + self.graph = Graph(self.backend, self.ac) + self.graph.migrate(self.schema) + + # nodes setup + self.ents = self.graph.nodes(ns.bsfs.Entity, { + URI('http://example.com/me/entity#1234'), + URI('http://example.com/me/entity#4321')}) + self.tags = self.graph.nodes(ns.bsfs.Tag, { + URI('http://example.com/me/tag#1234'), + URI('http://example.com/me/tag#4321')}) + # add some instances + self.ents.set(ns.bse.tag, self.tags) + self.graph.node(ns.bsfs.Tag, URI('http://example.com/me/tag#1234')).set(ns.bst.label, 'hello') + self.graph.node(ns.bsfs.Tag, URI('http://example.com/me/tag#4321')).set(ns.bst.label, 'world') + + def test_essentials(self): # __eq__, __hash__, __str__, __repr__ + p_author = self.schema.predicate(ns.bse.author) + p_tag = self.schema.predicate(ns.bse.tag) + p_main = self.schema.predicate(ns.bst.main) + # comparison + self.assertEqual(Walk(self.ents, [p_tag]), Walk(self.ents, [p_tag])) + self.assertEqual(hash(Walk(self.ents, [p_tag])), hash(Walk(self.ents, [p_tag]))) + # comparison respects type + class Foo(Walk): pass + self.assertNotEqual(Walk(self.ents, [p_tag]), Foo(self.ents, [p_tag])) + self.assertNotEqual(hash(Walk(self.ents, [p_tag])), hash(Foo(self.ents, [p_tag]))) + # comparison respects root + self.assertNotEqual(Walk(self.ents, [p_author]), Walk(self.tags, [p_author])) + self.assertNotEqual(hash(Walk(self.ents, [p_author])), hash(Walk(self.tags, [p_author]))) + # comparison respects path + self.assertNotEqual(Walk(self.tags, [p_author]), Walk(self.tags, [p_main])) + self.assertNotEqual(hash(Walk(self.tags, [p_author])), hash(Walk(self.tags, [p_main]))) + # string conversion + self.assertEqual(str(Walk(self.ents, [p_tag, p_main])), + 'Walk(@https://schema.bsfs.io/core/Entity: https://schema.bsfs.io/core/Entity#tag, https://schema.bsfs.io/core/Tag#main)') + self.assertEqual(repr(Walk(self.ents, [p_tag, p_main])), + 'Walk(https://schema.bsfs.io/core/Entity, (https://schema.bsfs.io/core/Entity#tag, https://schema.bsfs.io/core/Tag#main))') + + def test_tail(self): + self.assertEqual(Walk(self.ents, ( + self.schema.predicate(ns.bse.tag), + )).tail, + self.schema.node(ns.bsfs.Tag)) + self.assertEqual(Walk(self.ents, ( + self.schema.predicate(ns.bse.tag), + self.schema.predicate(ns.bst.main), + )).tail, + self.schema.node(ns.bsfs.Entity)) + + def test_step(self): + tag_type = self.schema.node(ns.bsfs.Tag) + # step returns a predicate + self.assertEqual(Walk.step(self.schema, tag_type, 'subTagOf'), + (self.schema.predicate(ns.bst.subTagOf), )) + # invalid step raises an error + self.assertRaises(ValueError, Walk.step, self.schema, tag_type, 'foobar') + # ambiguous step raises an error + self.assertRaises(ValueError, Walk.step, self.schema, tag_type, 'author') + + def test_getattr(self): # __getattr__ + walk = Walk(self.ents, (self.schema.predicate(ns.bse.tag), )) + # first step + self.assertEqual(walk.subTagOf, Walk(self.ents, ( + self.schema.predicate(ns.bse.tag), + self.schema.predicate(ns.bst.subTagOf), + ))) + # second step + self.assertEqual(walk.subTagOf.main, Walk(self.ents, ( + self.schema.predicate(ns.bse.tag), + self.schema.predicate(ns.bst.subTagOf), + self.schema.predicate(ns.bst.main), + ))) + # invalid step raises an error + self.assertRaises(ValueError, getattr, walk, 'foobar') + # ambiguous step raises an error + self.assertRaises(ValueError, getattr, walk, 'author') + + def test_get(self): # get, __call__ + walk = Walk(self.ents, (self.schema.predicate(ns.bse.tag), )) + tags = { + self.graph.node(ns.bsfs.Tag, URI('http://example.com/me/tag#1234')), + self.graph.node(ns.bsfs.Tag, URI('http://example.com/me/tag#4321'))} + # get returns from Nodes.get + self.assertDictEqual(walk.get(), { + self.graph.node(ns.bsfs.Entity, URI('http://example.com/me/entity#1234')): tags, + self.graph.node(ns.bsfs.Entity, URI('http://example.com/me/entity#4321')): tags, + }) + self.assertDictEqual(walk(), { + self.graph.node(ns.bsfs.Entity, URI('http://example.com/me/entity#1234')): tags, + self.graph.node(ns.bsfs.Entity, URI('http://example.com/me/entity#4321')): tags, + }) + # get passes kwargs to Nodes.get + self.assertSetEqual(tags, walk.get(node=False)) + self.assertSetEqual(tags, walk(node=False)) + self.assertSetEqual(tags, set(walk.get(view=list, node=False))) + self.assertSetEqual(tags, set(walk(view=list, node=False))) + # get returns values if need be + self.assertSetEqual(walk.label(node=False), {'hello', 'world'}) + + +## main ## + +if __name__ == '__main__': + unittest.main() + +## EOF ## diff --git a/test/namespace/test_namespace.py b/test/namespace/test_namespace.py index f109653..f7bf02a 100644 --- a/test/namespace/test_namespace.py +++ b/test/namespace/test_namespace.py @@ -1,9 +1,4 @@ -""" -Part of the tagit test suite. -A copy of the license is provided with the project. -Author: Matthias Baumgartner, 2022 -""" # imports import operator import unittest @@ -12,7 +7,7 @@ import unittest from bsfs.utils import URI # objects to test -from bsfs.namespace.namespace import Namespace, ClosedNamespace +from bsfs.namespace.namespace import Namespace, FinalNamespace ## code ## @@ -20,108 +15,48 @@ from bsfs.namespace.namespace import Namespace, ClosedNamespace class TestNamespace(unittest.TestCase): def test_essentials(self): # string conversion - self.assertEqual(str(Namespace('http://example.org/')), 'Namespace(http://example.org)') - self.assertEqual(str(Namespace('http://example.org#')), 'Namespace(http://example.org)') - self.assertEqual(repr(Namespace('http://example.org/')), 'Namespace(http://example.org, #, /)') - self.assertEqual(repr(Namespace('http://example.org#')), 'Namespace(http://example.org, #, /)') - self.assertEqual(repr(Namespace('http://example.org', fsep='.')), 'Namespace(http://example.org, ., /)') - self.assertEqual(repr(Namespace('http://example.org', psep='.')), 'Namespace(http://example.org, #, .)') - # repeated separators are truncated - self.assertEqual(str(Namespace('http://example.org////')), 'Namespace(http://example.org)') - self.assertEqual(str(Namespace('http://example.org####')), 'Namespace(http://example.org)') - self.assertEqual(repr(Namespace('http://example.org///##')), 'Namespace(http://example.org, #, /)') + self.assertEqual(str(Namespace('http://example.org')), 'http://example.org') + self.assertEqual(repr(Namespace('http://example.org')), "'http://example.org'") # comparison - class Foo(Namespace): pass - self.assertEqual(Namespace('http://example.org/'), Namespace('http://example.org/')) - self.assertEqual(Namespace('http://example.org/'), Namespace('http://example.org')) - self.assertEqual(Namespace('http://example.org/'), Namespace('http://example.org#')) - self.assertNotEqual(Namespace('http://example.org'), Namespace('http://example.org', fsep='.')) - self.assertNotEqual(Namespace('http://example.org'), Namespace('http://example.org', psep='.')) - self.assertNotEqual(Namespace('http://example.org/'), Foo('http://example.org/')) - self.assertNotEqual(Foo('http://example.org/'), Namespace('http://example.org/')) - # hashing - self.assertEqual(hash(Namespace('http://example.org/')), hash(Namespace('http://example.org/'))) - self.assertEqual(hash(Namespace('http://example.org/')), hash(Namespace('http://example.org'))) - self.assertEqual(hash(Namespace('http://example.org/')), hash(Namespace('http://example.org#'))) + self.assertEqual(Namespace('http://example.org'), Namespace('http://example.org')) + self.assertEqual(hash(Namespace('http://example.org')), hash(Namespace('http://example.org'))) + # Namespace compares to string + self.assertEqual(Namespace('http://example.org'), 'http://example.org') + self.assertEqual(hash(Namespace('http://example.org')), hash('http://example.org')) + # URI must match + self.assertNotEqual(Namespace('http://example.org'), Namespace('http://example.com')) self.assertNotEqual(hash(Namespace('http://example.org')), hash(Namespace('http://example.com'))) - self.assertNotEqual(hash(Namespace('http://example.org')), hash(Namespace('http://example.org', fsep='.'))) - self.assertNotEqual(hash(Namespace('http://example.org')), hash(Namespace('http://example.org', psep='.'))) - self.assertNotEqual(hash(Namespace('http://example.org/')), hash(Foo('http://example.org/'))) - self.assertNotEqual(hash(Foo('http://example.org/')), hash(Namespace('http://example.org/'))) def test_getattr(self): - self.assertEqual(Namespace('http://example.org/').foo, 'http://example.org#foo') - self.assertEqual(Namespace('http://example.org/').bar, 'http://example.org#bar') - self.assertEqual(Namespace('http://example.org/', fsep='/').foo, 'http://example.org/foo') - self.assertEqual(Namespace('http://example.org/', fsep='/').bar, 'http://example.org/bar') - self.assertEqual(Namespace('http://example.org', fsep='/').foo, 'http://example.org/foo') - self.assertEqual(Namespace('http://example.org', fsep='/').bar, 'http://example.org/bar') - self.assertEqual(Namespace('http://example.org#', fsep='/').foo, 'http://example.org#/foo') - self.assertEqual(Namespace('http://example.org#', fsep='/').bar, 'http://example.org#/bar') - self.assertEqual(Namespace('http://example.org/me#').foo, 'http://example.org/me#foo') - self.assertEqual(Namespace('http://example.org/me#').bar, 'http://example.org/me#bar') + self.assertEqual(Namespace('http://example.org').foo, Namespace('http://example.org/foo')) + self.assertEqual(Namespace('http://example.org').bar, Namespace('http://example.org/bar')) - def test_getitem(self): - self.assertEqual(Namespace('http://example.org')['foo'], 'http://example.org#foo') - self.assertEqual(Namespace('http://example.org')['bar'], 'http://example.org#bar') - self.assertEqual(Namespace('http://example.org', fsep='/')['foo'], 'http://example.org/foo') - self.assertEqual(Namespace('http://example.org', fsep='/')['bar'], 'http://example.org/bar') - self.assertEqual(Namespace('http://example.org/me#')['foo'], 'http://example.org/me#foo') - self.assertEqual(Namespace('http://example.org/me#')['bar'], 'http://example.org/me#bar') + def test_call(self): + self.assertEqual(Namespace('http://example.org')(), FinalNamespace('http://example.org', sep='#')) + self.assertEqual(Namespace('http://example.org').foo(), FinalNamespace('http://example.org/foo', sep='#')) - def test_add(self): - self.assertEqual(Namespace('http://example.org') + 'foo', Namespace('http://example.org/foo')) - self.assertEqual(Namespace('http://example.org', psep='.') + 'foo', Namespace('http://example.org.foo', psep='.')) - self.assertEqual(Namespace('http://example.org') + 'foo' + 'bar', Namespace('http://example.org/foo/bar')) - # can add URIs - self.assertEqual(Namespace('http://example.org') + URI('foo'), Namespace('http://example.org/foo')) - # can only add strings - self.assertRaises(TypeError, operator.add, Namespace('http://example.org'), 1234) - self.assertRaises(TypeError, operator.add, Namespace('http://example.org'), Namespace('http://example.com')) - - -class TestClosedNamespace(unittest.TestCase): +class TestFinalNamespace(unittest.TestCase): def test_essentials(self): - # string conversion - self.assertEqual(str(ClosedNamespace('http://example.org/')), 'ClosedNamespace(http://example.org)') - self.assertEqual(str(ClosedNamespace('http://example.org#')), 'ClosedNamespace(http://example.org)') - self.assertEqual(repr(ClosedNamespace('http://example.org/')), 'ClosedNamespace(http://example.org, #, /)') - self.assertEqual(repr(ClosedNamespace('http://example.org#')), 'ClosedNamespace(http://example.org, #, /)') - self.assertEqual(repr(ClosedNamespace('http://example.org', fsep='.')), 'ClosedNamespace(http://example.org, ., /)') - self.assertEqual(repr(ClosedNamespace('http://example.org', psep='.')), 'ClosedNamespace(http://example.org, #, .)') + # string conversion + self.assertEqual(str(FinalNamespace('http://example.org')), 'http://example.org') + self.assertEqual(repr(FinalNamespace('http://example.org')), "'http://example.org'") # comparison - class Foo(ClosedNamespace): pass - self.assertEqual(ClosedNamespace('http://example.org'), ClosedNamespace('http://example.org#')) - self.assertEqual(ClosedNamespace('http://example.org'), ClosedNamespace('http://example.org')) - self.assertEqual(ClosedNamespace('http://example.org'), ClosedNamespace('http://example.org/')) - self.assertEqual(ClosedNamespace('http://example.org/', 'foo', 'bar'), ClosedNamespace('http://example.org/', 'foo', 'bar')) - self.assertNotEqual(ClosedNamespace('http://example.org/', 'foo'), ClosedNamespace('http://example.org/', 'bar')) - self.assertNotEqual(ClosedNamespace('http://example.org/'), Foo('http://example.org/')) - self.assertNotEqual(Foo('http://example.org/'), ClosedNamespace('http://example.org/')) - # hashing - self.assertEqual(hash(ClosedNamespace('http://example.org')), hash(ClosedNamespace('http://example.org'))) - self.assertEqual(hash(ClosedNamespace('http://example.org')), hash(ClosedNamespace('http://example.org/'))) - self.assertEqual(hash(ClosedNamespace('http://example.org')), hash(ClosedNamespace('http://example.org#'))) - self.assertEqual(hash(ClosedNamespace('http://example.org/', 'foo', 'bar')), hash(ClosedNamespace('http://example.org/', 'foo', 'bar'))) - self.assertNotEqual(hash(ClosedNamespace('http://example.org/', 'foo')), hash(ClosedNamespace('http://example.org/', 'bar'))) - self.assertNotEqual(hash(ClosedNamespace('http://example.org/')), hash(Foo('http://example.org/'))) - self.assertNotEqual(hash(Foo('http://example.org/')), hash(ClosedNamespace('http://example.org/'))) + self.assertEqual(FinalNamespace('http://example.org'), FinalNamespace('http://example.org')) + self.assertEqual(hash(FinalNamespace('http://example.org')), hash(FinalNamespace('http://example.org'))) + # FinalNamespace compares to string + self.assertEqual(FinalNamespace('http://example.org'), 'http://example.org') + self.assertEqual(hash(FinalNamespace('http://example.org')), hash('http://example.org')) + # URI must match + self.assertNotEqual(FinalNamespace('http://example.org'), FinalNamespace('http://example.com')) + self.assertNotEqual(hash(FinalNamespace('http://example.org')), hash(FinalNamespace('http://example.com'))) + # separator is ignored + self.assertEqual(FinalNamespace('http://example.org'), FinalNamespace('http://example.org', sep='/')) + self.assertEqual(hash(FinalNamespace('http://example.org')), hash(FinalNamespace('http://example.org', sep='/'))) def test_getattr(self): - self.assertEqual(ClosedNamespace('http://example.org/', 'foo', 'bar').foo, 'http://example.org#foo') - self.assertEqual(ClosedNamespace('http://example.org/', 'bar', 'bar').bar, 'http://example.org#bar') - self.assertEqual(ClosedNamespace('http://example.org/me#', 'foo', 'bar').foo, 'http://example.org/me#foo') - self.assertEqual(ClosedNamespace('http://example.org/me#', 'foo', 'bar').bar, 'http://example.org/me#bar') - self.assertRaises(KeyError, getattr, ClosedNamespace('http://example.org/', 'bar', 'bar'), 'foobar') - self.assertRaises(KeyError, getattr, ClosedNamespace('http://example.org#', 'bar', 'bar'), 'foobar') - - def test_getitem(self): - self.assertEqual(ClosedNamespace('http://example.org/', 'foo', 'bar')['foo'], 'http://example.org#foo') - self.assertEqual(ClosedNamespace('http://example.org/', 'foo', 'bar')['bar'], 'http://example.org#bar') - self.assertEqual(ClosedNamespace('http://example.org/me#', 'foo', 'bar')['foo'], 'http://example.org/me#foo') - self.assertEqual(ClosedNamespace('http://example.org/me#', 'foo', 'bar')['bar'], 'http://example.org/me#bar') - self.assertRaises(KeyError, ClosedNamespace('http://example.org/', 'bar', 'bar').__getitem__, 'foobar') - self.assertRaises(KeyError, ClosedNamespace('http://example.org#', 'bar', 'bar').__getitem__, 'foobar') + self.assertEqual(FinalNamespace('http://example.org').foo, FinalNamespace('http://example.org#foo')) + self.assertEqual(FinalNamespace('http://example.org').bar, FinalNamespace('http://example.org#bar')) + self.assertEqual(FinalNamespace('http://example.org', sep='/').bar, FinalNamespace('http://example.org/bar')) ## main ## diff --git a/test/query/__init__.py b/test/query/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/test/query/__init__.py diff --git a/test/query/ast_test/__init__.py b/test/query/ast_test/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/test/query/ast_test/__init__.py diff --git a/test/query/ast_test/test_fetch.py b/test/query/ast_test/test_fetch.py new file mode 100644 index 0000000..ccb680e --- /dev/null +++ b/test/query/ast_test/test_fetch.py @@ -0,0 +1,234 @@ + +# imports +import unittest + +# bsfs imports +from bsfs.namespace import ns +from bsfs.utils import URI + +# objects to test +from bsfs.query.ast.fetch import FetchExpression +from bsfs.query.ast.fetch import All, This +from bsfs.query.ast.fetch import _Branch, Fetch +from bsfs.query.ast.fetch import _Named, Node, Value + + +## code ## + +class TestExpression(unittest.TestCase): # FetchExpression + def test_essentials(self): + class Foo(FetchExpression): pass + # comparison + self.assertEqual(FetchExpression(), FetchExpression()) + self.assertEqual(hash(FetchExpression()), hash(FetchExpression())) + # comparison respects type + self.assertNotEqual(FetchExpression(), Foo()) + self.assertNotEqual(hash(FetchExpression()), hash(Foo())) + # string conversion + self.assertEqual(str(FetchExpression()), 'FetchExpression()') + self.assertEqual(repr(FetchExpression()), 'FetchExpression()') + self.assertEqual(str(Foo()), 'Foo()') + self.assertEqual(repr(Foo()), 'Foo()') + + +class TestAll(unittest.TestCase): # All + def test_essentials(self): + class Foo(All): pass + expr0 = This('hello') + expr1 = This('world') + # comparison + self.assertEqual(All(expr0), All(expr0)) + self.assertEqual(hash(All(expr0)), hash(All(expr0))) + # comparison respects type + self.assertNotEqual(All(expr0), Foo(expr0)) + self.assertNotEqual(hash(All(expr0)), hash(Foo(expr0))) + # comparison respects expressions + self.assertEqual(All(expr0, expr1), All(expr0, expr1)) + self.assertEqual(hash(All(expr0, expr1)), hash(All(expr0, expr1))) + self.assertNotEqual(All(expr0), All(expr1)) + self.assertNotEqual(hash(All(expr0)), hash(All(expr1))) + # expressions are unordered + self.assertEqual(All(expr0, expr1), All(expr1, expr0)) + self.assertEqual(hash(All(expr0, expr1)), hash(All(expr1, expr0))) + # string conversion + self.assertIn(str(All(expr0, expr1)), { + 'All({This(world), This(hello)})', + 'All({This(hello), This(world)})'}) + self.assertIn(repr(All(expr0, expr1)), { + 'All({This(world), This(hello)})', + 'All({This(hello), This(world)})'}) + + def test_members(self): + class Foo(): pass + expr0 = This('hello') + expr1 = This('world') + # requires at least one child expression + self.assertRaises(AttributeError, All) + # expr returns child expressions + self.assertEqual(All(expr0, expr1).expr, {expr0, expr1}) + # can pass expressions as arguments + self.assertEqual(All(expr0, expr1).expr, {expr0, expr1}) + # can pass a single expression as argument + self.assertEqual(All(expr0).expr, {expr0}) + # can pass expressions as list-like + self.assertEqual(All([expr0, expr1]).expr, {expr0, expr1}) + self.assertEqual(All((expr0, expr1)).expr, {expr0, expr1}) + self.assertEqual(All({expr0, expr1}).expr, {expr0, expr1}) + # can pass a single expression as list-like + self.assertEqual(All([expr0]).expr, {expr0}) + # must pass a FilterExpression + self.assertRaises(TypeError, All, Foo()) + self.assertRaises(TypeError, All, 1234) + self.assertRaises(TypeError, All, 'hello world') + # len returns the number of child expressions + self.assertEqual(len(All(expr0)), 1) + self.assertEqual(len(All(expr0, expr1)), 2) + # iter iterates over child expressions + self.assertSetEqual(set(All(expr0, expr1)), {expr0, expr1}) + + +class TestThis(unittest.TestCase): # This + def test_essentials(self): + class Foo(This): pass + # comparison + self.assertEqual(This('hello'), This('hello')) + self.assertEqual(hash(This('hello')), hash(This('hello'))) + # comparison respects type + self.assertNotEqual(This('hello'), Foo('hello')) + self.assertNotEqual(hash(This('hello')), hash(Foo('hello'))) + # comparison respects name + self.assertNotEqual(This('hello'), This('world')) + self.assertNotEqual(hash(This('hello')), hash(This('world'))) + # string conversion + self.assertEqual(str(This('hello')), 'This(hello)') + self.assertEqual(repr(This('hello')), 'This(hello)') + + def test_members(self): + class Foo(): pass + # name returns member + self.assertEqual(This('hello').name, 'hello') + self.assertEqual(This('world').name, 'world') + # name is converted to a string + self.assertEqual(This(1234).name, '1234') + foo = Foo() + self.assertEqual(This(foo).name, str(foo)) + + +class TestBranch(unittest.TestCase): # _Branch, Fetch + def test_essentials(self): + pred = ns.bse.tag + expr = FetchExpression() + # comparison + self.assertEqual(_Branch(pred), _Branch(pred)) + self.assertEqual(hash(_Branch(pred)), hash(_Branch(pred))) + self.assertEqual(Fetch(pred, expr), Fetch(pred, expr)) + self.assertEqual(hash(Fetch(pred, expr)), hash(Fetch(pred, expr))) + # comparison respects type + self.assertNotEqual(_Branch(pred), Fetch(pred, expr)) + self.assertNotEqual(hash(_Branch(pred)), hash(Fetch(pred, expr))) + self.assertNotEqual(Fetch(pred, expr), _Branch(pred)) + self.assertNotEqual(hash(Fetch(pred, expr)), hash(_Branch(pred))) + # comparison respects predicate + self.assertNotEqual(_Branch(pred), _Branch(ns.bse.filesize)) + self.assertNotEqual(hash(_Branch(pred)), hash(_Branch(ns.bse.filesize))) + self.assertNotEqual(Fetch(pred, expr), Fetch(ns.bse.filesize, expr)) + self.assertNotEqual(hash(Fetch(pred, expr)), hash(Fetch(ns.bse.filesize, expr))) + # comparison respects expression + self.assertNotEqual(Fetch(pred, expr), Fetch(pred, This('foo'))) + self.assertNotEqual(hash(Fetch(pred, expr)), hash(Fetch(pred, This('foo')))) + # string conversion + self.assertEqual(str(_Branch(pred)), f'_Branch({pred})') + self.assertEqual(repr(_Branch(pred)), f'_Branch({pred})') + self.assertEqual(str(Fetch(pred, expr)), f'Fetch({pred}, {expr})') + self.assertEqual(repr(Fetch(pred, expr)), f'Fetch({pred}, {expr})') + + def test_members(self): + class Foo(): pass + pred = ns.bse.tag + expr = FetchExpression() + + # predicate returns member + self.assertEqual(_Branch(pred).predicate, pred) + self.assertEqual(Fetch(pred, expr).predicate, pred) + # can pass an URI + self.assertEqual(_Branch(ns.bse.filename).predicate, ns.bse.filename) + self.assertEqual(Fetch(ns.bse.filename, expr).predicate, ns.bse.filename) + # must pass an URI + self.assertRaises(TypeError, _Branch, Foo()) + self.assertRaises(TypeError, Fetch, Foo(), expr) + # expression returns member + self.assertEqual(Fetch(pred, expr).expr, expr) + # expression must be a FilterExpression + self.assertRaises(TypeError, Fetch, ns.bse.filename, 'hello') + self.assertRaises(TypeError, Fetch, ns.bse.filename, 1234) + self.assertRaises(TypeError, Fetch, ns.bse.filename, Foo()) + + +class TestNamed(unittest.TestCase): # _Named, Node, Value + def test_essentials(self): + pred = ns.bse.tag + name = 'foobar' + # comparison + self.assertEqual(_Named(pred, name), _Named(pred, name)) + self.assertEqual(hash(_Named(pred, name)), hash(_Named(pred, name))) + # comparison respects type + self.assertNotEqual(_Named(pred, name), Node(pred, name)) + self.assertNotEqual(Node(pred, name), Value(pred, name)) + self.assertNotEqual(Value(pred, name), _Named(pred, name)) + self.assertNotEqual(hash(_Named(pred, name)), hash(Node(pred, name))) + self.assertNotEqual(hash(Node(pred, name)), hash(Value(pred, name))) + self.assertNotEqual(hash(Value(pred, name)), hash(_Named(pred, name))) + # comparison respects predicate + self.assertNotEqual(_Named(pred, name), _Named(ns.bse.filesize, name)) + self.assertNotEqual(hash(_Named(pred, name)), hash(_Named(ns.bse.filesize, name))) + self.assertNotEqual(Node(pred, name), Node(ns.bse.filesize, name)) + self.assertNotEqual(hash(Node(pred, name)), hash(Node(ns.bse.filesize, name))) + self.assertNotEqual(Value(pred, name), Value(ns.bse.filesize, name)) + self.assertNotEqual(hash(Value(pred, name)), hash(Value(ns.bse.filesize, name))) + # comparison respects name + self.assertNotEqual(_Named(pred, name), _Named(pred, 'foo')) + self.assertNotEqual(hash(_Named(pred, name)), hash(_Named(pred, 'foo'))) + self.assertNotEqual(Node(pred, name), Node(pred, 'foo')) + self.assertNotEqual(hash(Node(pred, name)), hash(Node(pred, 'foo'))) + self.assertNotEqual(Value(pred, name), Value(pred, 'foo')) + self.assertNotEqual(hash(Value(pred, name)), hash(Value(pred, 'foo'))) + # string conversion + self.assertEqual(str(_Named(pred, name)), f'_Named({pred}, {name})') + self.assertEqual(repr(_Named(pred, name)), f'_Named({pred}, {name})') + self.assertEqual(str(Node(pred, name)), f'Node({pred}, {name})') + self.assertEqual(repr(Node(pred, name)), f'Node({pred}, {name})') + self.assertEqual(str(Value(pred, name)), f'Value({pred}, {name})') + self.assertEqual(repr(Value(pred, name)), f'Value({pred}, {name})') + + def test_members(self): + class Foo(): pass + pred = ns.bse.tag + name = 'foobar' + # predicate returns member + self.assertEqual(_Named(pred, name).predicate, pred) + self.assertEqual(Node(pred, name).predicate, pred) + self.assertEqual(Value(pred, name).predicate, pred) + # can pass an URI as predicate + self.assertEqual(_Named(ns.bse.filename, name).predicate, ns.bse.filename) + self.assertEqual(Node(ns.bse.filename, name).predicate, ns.bse.filename) + self.assertEqual(Value(ns.bse.filename, name).predicate, ns.bse.filename) + # must pass an URI + self.assertRaises(TypeError, _Named, Foo(), name) + self.assertRaises(TypeError, Node, Foo(), name) + self.assertRaises(TypeError, Value, Foo(), name) + # name returns member + self.assertEqual(_Named(pred, name).name, name) + self.assertEqual(Node(pred, name).name, name) + self.assertEqual(Value(pred, name).name, name) + # name is converted to a string + self.assertEqual(_Named(pred, 1234).name, '1234') + self.assertEqual(Node(pred, 1234).name, '1234') + self.assertEqual(Value(pred, 1234).name, '1234') + + +## main ## + +if __name__ == '__main__': + unittest.main() + +## EOF ## diff --git a/test/query/ast_test/test_filter_.py b/test/query/ast_test/test_filter_.py new file mode 100644 index 0000000..d0d42ea --- /dev/null +++ b/test/query/ast_test/test_filter_.py @@ -0,0 +1,614 @@ + +# imports +import unittest + +# bsfs imports +from bsfs.namespace import ns +from bsfs.utils import URI + +# objects to test +from bsfs.query.ast.filter_ import _Expression, FilterExpression, PredicateExpression +from bsfs.query.ast.filter_ import _Branch, Any, All +from bsfs.query.ast.filter_ import _Agg, And, Or +from bsfs.query.ast.filter_ import Not, Has, Distance +from bsfs.query.ast.filter_ import _Value, Is, Equals, Substring, StartsWith, EndsWith +from bsfs.query.ast.filter_ import _Bounded, LessThan, GreaterThan +from bsfs.query.ast.filter_ import Predicate, OneOf +from bsfs.query.ast.filter_ import IsIn, IsNotIn +from bsfs.query.ast.filter_ import Includes, Excludes, Between + + +## code ## + +class TestExpression(unittest.TestCase): + def test_essentials(self): + # comparison + self.assertEqual(_Expression(), _Expression()) + self.assertEqual(FilterExpression(), FilterExpression()) + self.assertEqual(PredicateExpression(), PredicateExpression()) + self.assertEqual(hash(_Expression()), hash(_Expression())) + self.assertEqual(hash(FilterExpression()), hash(FilterExpression())) + self.assertEqual(hash(PredicateExpression()), hash(PredicateExpression())) + # comparison respects type + self.assertNotEqual(FilterExpression(), _Expression()) + self.assertNotEqual(_Expression(), PredicateExpression()) + self.assertNotEqual(PredicateExpression(), FilterExpression()) + self.assertNotEqual(hash(FilterExpression()), hash(_Expression())) + self.assertNotEqual(hash(_Expression()), hash(PredicateExpression())) + self.assertNotEqual(hash(PredicateExpression()), hash(FilterExpression())) + # string conversion + self.assertEqual(str(_Expression()), '_Expression()') + self.assertEqual(str(FilterExpression()), 'FilterExpression()') + self.assertEqual(str(PredicateExpression()), 'PredicateExpression()') + self.assertEqual(repr(_Expression()), '_Expression()') + self.assertEqual(repr(FilterExpression()), 'FilterExpression()') + self.assertEqual(repr(PredicateExpression()), 'PredicateExpression()') + + +class TestBranch(unittest.TestCase): # _Branch, Any, All + def test_essentials(self): + pred = PredicateExpression() + expr = FilterExpression() + + # comparison respects type + self.assertNotEqual(_Branch(pred, expr), Any(pred, expr)) + self.assertNotEqual(Any(pred, expr), All(pred, expr)) + self.assertNotEqual(All(pred, expr), _Branch(pred, expr)) + self.assertNotEqual(hash(_Branch(pred, expr)), hash(Any(pred, expr))) + self.assertNotEqual(hash(Any(pred, expr)), hash(All(pred, expr))) + self.assertNotEqual(hash(All(pred, expr)), hash(_Branch(pred, expr))) + + for cls in (_Branch, Any, All): + # comparison + self.assertEqual(cls(pred, expr), cls(pred, expr)) + self.assertEqual(hash(cls(pred, expr)), hash(cls(pred, expr))) + # comparison respects predicate + self.assertNotEqual(cls(ns.bse.filename, expr), cls(ns.bse.filesize, expr)) + self.assertNotEqual(hash(cls(ns.bse.filename, expr)), hash(cls(ns.bse.filesize, expr))) + # comparison respects expression + self.assertNotEqual(cls(pred, Equals('hello')), cls(pred, Equals('world'))) + self.assertNotEqual(hash(cls(pred, Equals('hello'))), hash(cls(pred, Equals('world')))) + + # string conversion + self.assertEqual(str(_Branch(pred, expr)), f'_Branch({pred}, {expr})') + self.assertEqual(repr(_Branch(pred, expr)), f'_Branch({pred}, {expr})') + self.assertEqual(str(Any(pred, expr)), f'Any({pred}, {expr})') + self.assertEqual(repr(Any(pred, expr)), f'Any({pred}, {expr})') + self.assertEqual(str(All(pred, expr)), f'All({pred}, {expr})') + self.assertEqual(repr(All(pred, expr)), f'All({pred}, {expr})') + + def test_members(self): + class Foo(): pass + pred = PredicateExpression() + expr = FilterExpression() + + for cls in (_Branch, Any, All): + # predicate returns member + self.assertEqual(cls(PredicateExpression(), expr).predicate, PredicateExpression()) + # can pass an URI + self.assertEqual(cls(ns.bse.filename, expr).predicate, Predicate(ns.bse.filename)) + # can pass a PredicateExpression + self.assertEqual(cls(Predicate(ns.bse.filename), expr).predicate, Predicate(ns.bse.filename)) + # must pass an URI or PredicateExpression + self.assertRaises(TypeError, cls, Foo(), expr) + # expression returns member + self.assertEqual(cls(pred, Equals('hello')).expr, Equals('hello')) + # expression must be a FilterExpression + self.assertRaises(TypeError, cls, ns.bse.filename, 'hello') + self.assertRaises(TypeError, cls, ns.bse.filename, 1234) + self.assertRaises(TypeError, cls, ns.bse.filename, Foo()) + + +class TestAgg(unittest.TestCase): # _Agg, And, Or + def test_essentials(self): + expr = {Equals('hello'), Equals('world')} + + # comparison respects type + self.assertNotEqual(_Agg(expr), And(expr)) + self.assertNotEqual(And(expr), Or(expr)) + self.assertNotEqual(Or(expr), _Agg(expr)) + self.assertNotEqual(hash(_Agg(expr)), hash(And(expr))) + self.assertNotEqual(hash(And(expr)), hash(Or(expr))) + self.assertNotEqual(hash(Or(expr)), hash(_Agg(expr))) + + for cls in (_Agg, And, Or): + # comparison + self.assertEqual(cls(expr), cls(expr)) + self.assertEqual(hash(cls(expr)), hash(cls(expr))) + # comparison respects expression + self.assertNotEqual(cls(expr), cls(Equals('world'))) + self.assertNotEqual(hash(cls(expr)), hash(cls(Equals('world')))) + self.assertNotEqual(cls(Equals('hello')), cls(Equals('world'))) + self.assertNotEqual(hash(cls(Equals('hello'))), hash(cls(Equals('world')))) + + # string conversion + self.assertEqual(str(_Agg(Equals('hello'))), '_Agg({Equals(hello)})') + self.assertEqual(repr(_Agg(Equals('hello'))), '_Agg({Equals(hello)})') + self.assertEqual(str(And(Equals('hello'))), 'And({Equals(hello)})') + self.assertEqual(repr(And(Equals('hello'))), 'And({Equals(hello)})') + self.assertEqual(str(Or(Equals('hello'))), 'Or({Equals(hello)})') + self.assertEqual(repr(Or(Equals('hello'))), 'Or({Equals(hello)})') + + def test_expression(self): + class Foo(): pass + + for cls in (_Agg, And, Or): + # can pass expressions as arguments + self.assertSetEqual(cls(Equals('hello'), Equals('world')).expr, {Equals('hello'), Equals('world')}) + # can pass one expressions as argument + self.assertSetEqual(cls(Equals('hello')).expr, {Equals('hello')}) + # can pass expressions as iterator + self.assertSetEqual(cls(iter((Equals('hello'), Equals('world')))).expr, {Equals('hello'), Equals('world')}) + # can pass expressions as generator + def gen(): + yield Equals('hello') + yield Equals('world') + self.assertSetEqual(cls(gen()).expr, {Equals('hello'), Equals('world')}) + # can pass expressions as list-like + self.assertSetEqual(cls((Equals('hello'), Equals('world'))).expr, {Equals('hello'), Equals('world')}) + # can pass one expression as list-like + self.assertSetEqual(cls([Equals('hello')]).expr, {Equals('hello')}) + # must pass expressions + self.assertRaises(TypeError, cls, Foo(), Foo()) + self.assertRaises(TypeError, cls, [Foo(), Foo()]) + + # iter + self.assertSetEqual(set(iter(cls(Equals('hello'), Equals('world')))), {Equals('hello'), Equals('world')}) + # contains + self.assertIn(Equals('world'), cls(Equals('hello'), Equals('world'))) + self.assertNotIn(Equals('foo'), cls(Equals('hello'), Equals('world'))) + # len + self.assertEqual(len(cls(Equals('hello'), Equals('world'))), 2) + self.assertEqual(len(cls(Equals('hello'), Equals('world'), Equals('foo'))), 3) + + + +class TestNot(unittest.TestCase): + def test_essentials(self): + expr = FilterExpression() + # comparison + self.assertEqual(Not(expr), Not(expr)) + self.assertEqual(hash(Not(expr)), hash(Not(expr))) + # comparison respects type + self.assertNotEqual(Not(expr), FilterExpression()) + self.assertNotEqual(hash(Not(expr)), hash(FilterExpression())) + # comparison respects expression + self.assertNotEqual(Not(Equals('hello')), Not(Equals('world'))) + self.assertNotEqual(hash(Not(Equals('hello'))), hash(Not(Equals('world')))) + # string conversion + self.assertEqual(str(Not(Equals('hello'))), 'Not(Equals(hello))') + self.assertEqual(repr(Not(Equals('hello'))), 'Not(Equals(hello))') + + def test_expression(self): + # Not requires an expression argument + self.assertRaises(TypeError, Not) + # expression must be a FilterExpression + self.assertRaises(TypeError, Not, 'hello') + self.assertRaises(TypeError, Not, 1234) + self.assertRaises(TypeError, Not, Predicate(ns.bse.filesize)) + # member returns expression + self.assertEqual(Not(Equals('hello')).expr, Equals('hello')) + + +class TestHas(unittest.TestCase): + def test_essentials(self): + pred = PredicateExpression() + count = FilterExpression() + # comparison + self.assertEqual(Has(pred, count), Has(pred, count)) + self.assertEqual(hash(Has(pred, count)), hash(Has(pred, count))) + # comparison respects type + self.assertNotEqual(Has(pred, count), FilterExpression()) + self.assertNotEqual(hash(Has(pred, count)), hash(FilterExpression())) + # comparison respects predicate + self.assertNotEqual(Has(pred, count), Has(Predicate(ns.bse.filesize), count)) + self.assertNotEqual(hash(Has(pred, count)), hash(Has(Predicate(ns.bse.filesize), count))) + # comparison respects count + self.assertNotEqual(Has(pred, count), Has(pred, LessThan(5))) + self.assertNotEqual(hash(Has(pred, count)), hash(Has(pred, LessThan(5)))) + # string conversion + self.assertEqual(str(Has(Predicate(ns.bse.filesize), LessThan(5))), + f'Has(Predicate({ns.bse.filesize}, False), LessThan(5.0, True))') + self.assertEqual(repr(Has(Predicate(ns.bse.filesize), LessThan(5))), + f'Has(Predicate({ns.bse.filesize}, False), LessThan(5.0, True))') + + def test_members(self): + pred = PredicateExpression() + count = FilterExpression() + # member returns expression + # predicate must be an URI or a PredicateExpression + self.assertEqual(Has(ns.bse.filesize, count).predicate, Predicate(ns.bse.filesize)) + self.assertEqual(Has(Predicate(ns.bse.filesize), count).predicate, Predicate(ns.bse.filesize)) + self.assertRaises(TypeError, Has, 1234, FilterExpression()) + self.assertRaises(TypeError, Has, FilterExpression(), FilterExpression()) + # member returns count + # count must be None, an integer, or a FilterExpression + self.assertEqual(Has(pred).count, GreaterThan(1, False)) + self.assertEqual(Has(pred, LessThan(5)).count, LessThan(5)) + self.assertEqual(Has(pred, 5).count, Equals(5)) + self.assertRaises(TypeError, Has, pred, 'hello') + self.assertRaises(TypeError, Has, pred, Predicate(ns.bse.filesize)) + + + +class TestValue(unittest.TestCase): + def test_essentials(self): + # comparison respects type + self.assertNotEqual(_Value('hello'), Equals('hello')) + self.assertNotEqual(Equals('hello'), Is('hello')) + self.assertNotEqual(Is('hello'), Substring('hello')) + self.assertNotEqual(Substring('hello'), StartsWith('hello')) + self.assertNotEqual(StartsWith('hello'), EndsWith('hello')) + self.assertNotEqual(EndsWith('hello'), _Value('hello')) + self.assertNotEqual(hash(_Value('hello')), hash(Equals('hello'))) + self.assertNotEqual(hash(Equals('hello')), hash(Is('hello'))) + self.assertNotEqual(hash(Is('hello')), hash(Substring('hello'))) + self.assertNotEqual(hash(Substring('hello')), hash(StartsWith('hello'))) + self.assertNotEqual(hash(StartsWith('hello')), hash(EndsWith('hello'))) + self.assertNotEqual(hash(EndsWith('hello')), hash(_Value('hello'))) + + for cls in (_Value, Is, Equals, Substring, StartsWith, EndsWith): + # comparison + self.assertEqual(cls('hello'), cls('hello')) + self.assertEqual(hash(cls('hello')), hash(cls('hello'))) + # comparison respects value + self.assertNotEqual(cls('hello'), cls('world')) + self.assertNotEqual(hash(cls('hello')), hash(cls('world'))) + + # string conversion + self.assertEqual(str(_Value('hello')), '_Value(hello)') + self.assertEqual(repr(_Value('hello')), '_Value(hello)') + self.assertEqual(str(Is('hello')), 'Is(hello)') + self.assertEqual(repr(Is('hello')), 'Is(hello)') + self.assertEqual(str(Equals('hello')), 'Equals(hello)') + self.assertEqual(repr(Equals('hello')), 'Equals(hello)') + self.assertEqual(str(Substring('hello')), 'Substring(hello)') + self.assertEqual(repr(Substring('hello')), 'Substring(hello)') + self.assertEqual(str(StartsWith('hello')), 'StartsWith(hello)') + self.assertEqual(repr(StartsWith('hello')), 'StartsWith(hello)') + self.assertEqual(str(EndsWith('hello')), 'EndsWith(hello)') + self.assertEqual(repr(EndsWith('hello')), 'EndsWith(hello)') + + def test_value(self): + class Foo(): pass + for cls in (_Value, Is, Equals, Substring, StartsWith, EndsWith): + # value can be anything + # value returns member + f = Foo() + self.assertEqual(cls('hello').value, 'hello') + self.assertEqual(cls(1234).value, 1234) + self.assertEqual(cls(f).value, f) + + +class TestDistance(unittest.TestCase): + def test_essentials(self): + ref = (1,2,3) + # comparison + self.assertEqual(Distance(ref, 3), Distance(ref, 3)) + self.assertEqual(hash(Distance(ref, 3)), hash(Distance(ref, 3))) + # comparison respects type + self.assertNotEqual(Distance(ref, 3), FilterExpression()) + self.assertNotEqual(hash(Distance(ref, 3)), hash(FilterExpression())) + # comparison respects reference + self.assertNotEqual(Distance((1,2,3), 3, False), Distance((1,2), 3, False)) + self.assertNotEqual(hash(Distance((1,2,3), 3, False)), hash(Distance((1,2), 3, False))) + self.assertNotEqual(Distance((1,2,3), 3, False), Distance((1,5,3), 3, False)) + self.assertNotEqual(hash(Distance((1,2,3), 3, False)), hash(Distance((1,5,3), 3, False))) + # comparison respects threshold + self.assertNotEqual(Distance((1,2,3), 3, False), Distance((1,2,3), 3.1, False)) + self.assertNotEqual(hash(Distance((1,2,3), 3, False)), hash(Distance((1,2,3), 3.1, False))) + # comparison respects strict flag + self.assertNotEqual(Distance((1,2,3), 3, False), Distance((1,2,3), 3, True)) + self.assertNotEqual(hash(Distance((1,2,3), 3, False)), hash(Distance((1,2,3), 3, True))) + # string conversion + self.assertEqual(str(Distance(ref, 3, False)), 'Distance((1, 2, 3), 3.0, False)') + self.assertEqual(repr(Distance(ref, 3, False)), 'Distance((1, 2, 3), 3.0, False)') + + def test_members(self): + self.assertEqual(Distance((1,2,3), 3, False).reference, (1,2,3)) + self.assertEqual(Distance((3,2,1), 3, False).reference, (3,2,1)) + self.assertEqual(Distance((1,2,3), 3, False).threshold, 3.0) + self.assertEqual(Distance((1,2,3), 53.45, False).threshold, 53.45) + self.assertEqual(Distance((1,2,3), 3, False).strict, False) + self.assertEqual(Distance((1,2,3), 3, True).strict, True) + + +class TestBounded(unittest.TestCase): + def test_essentials(self): + # comparison respects type + self.assertNotEqual(_Bounded(1234), LessThan(1234)) + self.assertNotEqual(LessThan(1234), GreaterThan(1234)) + self.assertNotEqual(GreaterThan(1234), _Bounded(1234)) + self.assertNotEqual(hash(_Bounded(1234)), hash(LessThan(1234))) + self.assertNotEqual(hash(LessThan(1234)), hash(GreaterThan(1234))) + self.assertNotEqual(hash(GreaterThan(1234)), hash(_Bounded(1234))) + + for cls in (_Bounded, LessThan, GreaterThan): + # comparison + self.assertEqual(cls(1234), cls(1234)) + self.assertEqual(hash(cls(1234)), hash(cls(1234))) + # comparison respects threshold + self.assertNotEqual(cls(1234), cls(4321)) + self.assertNotEqual(hash(cls(1234)), hash(cls(4321))) + # comparison respects strict + self.assertNotEqual(cls(1234, True), cls(1234, False)) + self.assertNotEqual(hash(cls(1234, True)), hash(cls(1234, False))) + + # string conversion + self.assertEqual(str(_Bounded(1234, False)), '_Bounded(1234.0, False)') + self.assertEqual(repr(_Bounded(1234, False)), '_Bounded(1234.0, False)') + self.assertEqual(str(LessThan(1234, False)), 'LessThan(1234.0, False)') + self.assertEqual(repr(LessThan(1234, False)), 'LessThan(1234.0, False)') + self.assertEqual(str(GreaterThan(1234, False)), 'GreaterThan(1234.0, False)') + self.assertEqual(repr(GreaterThan(1234, False)), 'GreaterThan(1234.0, False)') + + def test_members(self): + class Foo(): pass + for cls in (_Bounded, LessThan, GreaterThan): + # threshold becomes float + self.assertEqual(cls(1.234).threshold, 1.234) + self.assertEqual(cls(1234).threshold, 1234.0) + self.assertEqual(cls('1234').threshold, 1234) + self.assertRaises(TypeError, cls, Foo()) + # strict becomes bool + self.assertEqual(cls(1234, True).strict, True) + self.assertEqual(cls(1234, False).strict, False) + self.assertEqual(cls(1234, Foo()).strict, True) + + +class TestPredicate(unittest.TestCase): + def test_essentials(self): + # comparison + self.assertEqual(Predicate(ns.bse.filesize), Predicate(ns.bse.filesize)) + self.assertEqual(hash(Predicate(ns.bse.filesize)), hash(Predicate(ns.bse.filesize))) + # comparison respects type + self.assertNotEqual(Predicate(ns.bse.filesize), PredicateExpression()) + self.assertNotEqual(hash(Predicate(ns.bse.filesize)), hash(PredicateExpression())) + # comparison respects predicate + self.assertNotEqual(Predicate(ns.bse.filesize), Predicate(ns.bse.filename)) + self.assertNotEqual(hash(Predicate(ns.bse.filesize)), hash(Predicate(ns.bse.filename))) + # comparison respects reverse + self.assertNotEqual(Predicate(ns.bse.filesize, True), Predicate(ns.bse.filesize, False)) + self.assertNotEqual(hash(Predicate(ns.bse.filesize, True)), hash(Predicate(ns.bse.filesize, False))) + # string conversion + self.assertEqual(str(Predicate(ns.bse.filesize)), f'Predicate({ns.bse.filesize}, False)') + self.assertEqual(str(Predicate(ns.bse.filesize, True)), + f'Predicate({ns.bse.filesize}, True)') + self.assertEqual(repr(Predicate(ns.bse.filesize)), f'Predicate({ns.bse.filesize}, False)') + self.assertEqual(repr(Predicate(ns.bse.filesize, True)), + f'Predicate({ns.bse.filesize}, True)') + + def test_members(self): + # member returns predicate + # predicate must be an URI + self.assertEqual(Predicate(ns.bse.filesize).predicate, ns.bse.filesize) + self.assertEqual(Predicate(URI('hello_world')).predicate, URI('hello_world')) + self.assertRaises(TypeError, Predicate, 1234) + self.assertRaises(TypeError, Predicate, FilterExpression()) + self.assertRaises(TypeError, Predicate, FilterExpression()) + # reverse becomes a boolean + self.assertEqual(Predicate(ns.bse.filesize, True).reverse, True) + self.assertEqual(Predicate(ns.bse.filesize, False).reverse, False) + self.assertEqual(Predicate(ns.bse.filesize, 'abc').reverse, True) + + +class TestOneOf(unittest.TestCase): + def test_essentials(self): + expr = {Predicate(ns.bse.filename), Predicate(ns.bse.filesize)} + # comparison + self.assertEqual(OneOf(expr), OneOf(expr)) + self.assertEqual(hash(OneOf(expr)), hash(OneOf(expr))) + # comparison respects type + self.assertNotEqual(OneOf(expr), PredicateExpression()) + self.assertNotEqual(hash(OneOf(expr)), hash(PredicateExpression())) + # comparison respects expression + self.assertNotEqual(OneOf(expr), OneOf(Predicate(ns.bse.filename))) + self.assertNotEqual(hash(OneOf(expr)), hash(OneOf(Predicate(ns.bse.filename)))) + # string conversion + self.assertEqual(str(OneOf(Predicate(ns.bse.filesize))), + f'OneOf({{Predicate({ns.bse.filesize}, False)}})') + self.assertEqual(repr(OneOf(Predicate(ns.bse.filesize))), + f'OneOf({{Predicate({ns.bse.filesize}, False)}})') + + def test_expression(self): + class Foo(): pass + # can pass expressions as arguments + self.assertSetEqual(OneOf(Predicate(ns.bse.filesize), Predicate(ns.bse.filename)).expr, + {Predicate(ns.bse.filesize), Predicate(ns.bse.filename)}) + # can pass one expressions as argument + self.assertSetEqual(OneOf(Predicate(ns.bse.filesize)).expr, + {Predicate(ns.bse.filesize)}) + # can pass expressions as iterator + self.assertSetEqual(OneOf(iter((Predicate(ns.bse.filesize), Predicate(ns.bse.filename)))).expr, + {Predicate(ns.bse.filesize), Predicate(ns.bse.filename)}) + # can pass expressions as generator + def gen(): + yield Predicate(ns.bse.filesize) + yield Predicate(ns.bse.filename) + self.assertSetEqual(OneOf(gen()).expr, + {Predicate(ns.bse.filesize), Predicate(ns.bse.filename)}) + # can pass expressions as list-like + self.assertSetEqual(OneOf((Predicate(ns.bse.filesize), Predicate(ns.bse.filename))).expr, + {Predicate(ns.bse.filesize), Predicate(ns.bse.filename)}) + # can pass one expression as list-like + self.assertSetEqual(OneOf([Predicate(ns.bse.filesize)]).expr, + {Predicate(ns.bse.filesize)}) + # must pass expressions + self.assertRaises(TypeError, OneOf, Foo(), Foo()) + self.assertRaises(TypeError, OneOf, [Foo(), Foo()]) + # must pass at least one expression + self.assertRaises(AttributeError, OneOf) + + # iter + self.assertSetEqual(set(iter(OneOf(Predicate(ns.bse.filesize), Predicate(ns.bse.filename)))), + {Predicate(ns.bse.filesize), Predicate(ns.bse.filename)}) + # contains + self.assertIn(Predicate(ns.bse.filesize), + OneOf(Predicate(ns.bse.filesize), Predicate(ns.bse.filename))) + self.assertNotIn(Predicate(ns.bse.tag), + OneOf(Predicate(ns.bse.filesize), Predicate(ns.bse.filename))) + # len + self.assertEqual(len(OneOf(Predicate(ns.bse.filesize), Predicate(ns.bse.filename))), 2) + self.assertEqual(len(OneOf(Predicate(ns.bse.filesize), Predicate(ns.bse.filename), Predicate(ns.bse.tag))), 3) + + + def test_IsIn(self): + # cannot pass zero arguments + self.assertRaises(AttributeError, IsIn) + # can pass expressions as arguments + self.assertEqual(IsIn('http://example.com/entity#1234', 'http://example.com/entity#4321'), + Or(Is('http://example.com/entity#1234'), Is('http://example.com/entity#4321'))) + # can pass one expression as argument + self.assertEqual(IsIn('http://example.com/entity#1234'), + Is('http://example.com/entity#1234')) + # can pass expressions as iterator + self.assertEqual(IsIn(iter(('http://example.com/entity#1234', 'http://example.com/entity#4321'))), + Or(Is('http://example.com/entity#1234'), Is('http://example.com/entity#4321'))) + # can pass expressions as generator + def gen(): + yield 'http://example.com/entity#1234' + yield 'http://example.com/entity#4321' + self.assertEqual(IsIn(gen()), + Or(Is('http://example.com/entity#1234'), Is('http://example.com/entity#4321'))) + # can pass expressions as list-like + self.assertEqual(IsIn(['http://example.com/entity#1234', 'http://example.com/entity#4321']), + Or(Is('http://example.com/entity#1234'), Is('http://example.com/entity#4321'))) + # can pass one expression as list-like + self.assertEqual(IsIn(['http://example.com/entity#1234']), + Is('http://example.com/entity#1234')) + + + def test_IsNotIn(self): + # cannot pass zero arguments + self.assertRaises(AttributeError, IsNotIn) + # can pass expressions as arguments + self.assertEqual(IsNotIn('http://example.com/entity#1234', 'http://example.com/entity#4321'), + Not(Or(Is('http://example.com/entity#1234'), Is('http://example.com/entity#4321')))) + # can pass one expression as argument + self.assertEqual(IsNotIn('http://example.com/entity#1234'), + Not(Is('http://example.com/entity#1234'))) + # can pass expressions as iterator + self.assertEqual(IsNotIn(iter(('http://example.com/entity#1234', 'http://example.com/entity#4321'))), + Not(Or(Is('http://example.com/entity#1234'), Is('http://example.com/entity#4321')))) + # can pass expressions as generator + def gen(): + yield 'http://example.com/entity#1234' + yield 'http://example.com/entity#4321' + self.assertEqual(IsNotIn(gen()), + Not(Or(Is('http://example.com/entity#1234'), Is('http://example.com/entity#4321')))) + # can pass expressions as list-like + self.assertEqual(IsNotIn(['http://example.com/entity#1234', 'http://example.com/entity#4321']), + Not(Or(Is('http://example.com/entity#1234'), Is('http://example.com/entity#4321')))) + # can pass one expression as list-like + self.assertEqual(IsNotIn(['http://example.com/entity#1234']), + Not(Is('http://example.com/entity#1234'))) + + + def test_Includes(self): + # cannot pass zero arguments + self.assertRaises(AttributeError, Includes) + # can pass expressions as arguments + self.assertEqual(Includes('hello', 'world'), + Or(Equals('hello'), Equals('world'))) + self.assertEqual(Includes('hello', 'world', approx=True), + Or(Substring('hello'), Substring('world'))) + # can pass one expression as argument + self.assertEqual(Includes('hello'), + Equals('hello')) + self.assertEqual(Includes('hello', approx=True), + Substring('hello')) + # can pass expressions as iterator + self.assertEqual(Includes(iter(('hello', 'world'))), + Or(Equals('hello'), Equals('world'))) + self.assertEqual(Includes(iter(('hello', 'world')), approx=True), + Or(Substring('hello'), Substring('world'))) + # can pass expressions as generator + def gen(): + yield 'hello' + yield 'world' + self.assertEqual(Includes(gen()), + Or(Equals('hello'), Equals('world'))) + self.assertEqual(Includes(gen(), approx=True), + Or(Substring('hello'), Substring('world'))) + # can pass expressions as list-like + self.assertEqual(Includes(['hello', 'world']), + Or(Equals('hello'), Equals('world'))) + self.assertEqual(Includes(['hello', 'world'], approx=True), + Or(Substring('hello'), Substring('world'))) + # can pass one expression as list-like + self.assertEqual(Includes(['hello']), + Equals('hello')) + self.assertEqual(Includes(['hello'], approx=True), + Substring('hello')) + + + def test_Excludes(self): + # cannot pass zero arguments + self.assertRaises(AttributeError, Excludes) + # can pass expressions as arguments + self.assertEqual(Excludes('hello', 'world'), + Not(Or(Equals('hello'), Equals('world')))) + self.assertEqual(Excludes('hello', 'world', approx=True), + Not(Or(Substring('hello'), Substring('world')))) + # can pass one expression as argument + self.assertEqual(Excludes('hello'), + Not(Equals('hello'))) + self.assertEqual(Excludes('hello', approx=True), + Not(Substring('hello'))) + # can pass expressions as iterator + self.assertEqual(Excludes(iter(('hello', 'world'))), + Not(Or(Equals('hello'), Equals('world')))) + self.assertEqual(Excludes(iter(('hello', 'world')), approx=True), + Not(Or(Substring('hello'), Substring('world')))) + # can pass expressions as generator + def gen(): + yield 'hello' + yield 'world' + self.assertEqual(Excludes(gen()), + Not(Or(Equals('hello'), Equals('world')))) + self.assertEqual(Excludes(gen(), approx=True), + Not(Or(Substring('hello'), Substring('world')))) + # can pass expressions as list-like + self.assertEqual(Excludes(['hello', 'world']), + Not(Or(Equals('hello'), Equals('world')))) + self.assertEqual(Excludes(['hello', 'world'], approx=True), + Not(Or(Substring('hello'), Substring('world')))) + # can pass one expression as list-like + self.assertEqual(Excludes(['hello']), + Not(Equals('hello'))) + self.assertEqual(Excludes(['hello'], approx=True), + Not(Substring('hello'))) + + + def test_Between(self): + # must specify at least one bound + self.assertRaises(ValueError, Between, float('inf'), float('inf')) + # lower bound must be less than the upper bound + self.assertRaises(ValueError, Between, 321, 123) + # can set a lower bound only + self.assertEqual(Between(123), + GreaterThan(123, strict=True)) + self.assertEqual(Between(123, lo_strict=False), + GreaterThan(123, strict=False)) + # can set an upper bound only + self.assertEqual(Between(hi=123), + LessThan(123, strict=True)) + self.assertEqual(Between(hi=123, hi_strict=False), + LessThan(123, strict=False)) + # can set both bounds + self.assertEqual(Between(123, 321), + And(GreaterThan(123, strict=True), LessThan(321, strict=True))) + self.assertEqual(Between(123, 321, False, False), + And(GreaterThan(123, strict=False), LessThan(321, strict=False))) + # can set identical bounds + self.assertRaises(ValueError, Between, 123, 123) + self.assertEqual(Between(123, 123, False, False), + Equals(123)) + + +## main ## + +if __name__ == '__main__': + unittest.main() + +## EOF ## diff --git a/test/query/test_matcher.py b/test/query/test_matcher.py new file mode 100644 index 0000000..6b975b2 --- /dev/null +++ b/test/query/test_matcher.py @@ -0,0 +1,1177 @@ + +# imports +import operator +import unittest + +# bsfs imports +from bsfs.namespace import ns +from bsfs.query import ast +from bsfs.utils import errors + +# objects to test +from bsfs.query.matcher import Any, Filter, Partial, Rest, _set_matcher + + +## code ## + +class TestAny(unittest.TestCase): + def test_essentials(self): + # comparison + a = Any() + b = Any() + self.assertNotEqual(Any(), Any()) + self.assertNotEqual(hash(Any()), hash(Any())) + self.assertNotEqual(a, Any()) + self.assertNotEqual(hash(a), hash(Any())) + self.assertNotEqual(a, b) + self.assertNotEqual(hash(a), hash(b)) + # comparison within sets + self.assertEqual(len({Any(), Any(), Any(), Any()}), 4) + self.assertEqual(len({Any() for _ in range(1000)}), 1000) + # string representation + self.assertEqual(str(Any()), 'Any()') + self.assertEqual(repr(Any()), 'Any()') + + +class TestRest(unittest.TestCase): + def test_essentials(self): + expr = ast.filter.Equals('hello') + # comparison + self.assertEqual(Rest(expr), Rest(expr)) + self.assertEqual(hash(Rest(expr)), hash(Rest(expr))) + # comparison respects type + class Foo(): pass + self.assertNotEqual(Rest(expr), 1234) + self.assertNotEqual(hash(Rest(expr)), hash(1234)) + self.assertNotEqual(Rest(expr), Foo()) + self.assertNotEqual(hash(Rest(expr)), hash(Foo())) + # comparison respects expr + self.assertNotEqual(Rest(expr), Rest(ast.filter.Equals('world'))) + self.assertNotEqual(hash(Rest(expr)), hash(Rest(ast.filter.Equals('world')))) + # default constructor -> Any -> Not equal + self.assertNotEqual(Rest(), Rest()) + self.assertNotEqual(hash(Rest()), hash(Rest())) + # string representation + self.assertEqual(str(Rest()), 'Rest(Any())') + self.assertEqual(str(Rest(expr)), 'Rest(Equals(hello))') + self.assertEqual(repr(Rest()), 'Rest(Any())') + self.assertEqual(repr(Rest(expr)), 'Rest(Equals(hello))') + + + +class TestPartial(unittest.TestCase): + def test_match(self): + p0 = Partial(ast.filter.LessThan) + p1 = Partial(ast.filter.LessThan, threshold=3) + p2 = Partial(ast.filter.LessThan, strict=False) + p3 = Partial(ast.filter.LessThan, threshold=3, strict=False) + # match respects name + self.assertTrue(p0.match('foo', None)) + self.assertTrue(p1.match('foo', None)) + self.assertTrue(p2.match('foo', None)) + self.assertTrue(p3.match('foo', None)) + # match respects correct value + self.assertTrue(p0.match('threshold', 3)) + self.assertTrue(p1.match('threshold', 3)) + self.assertTrue(p2.match('threshold', 3)) + self.assertTrue(p3.match('threshold', 3)) + self.assertTrue(p0.match('strict', False)) + self.assertTrue(p1.match('strict', False)) + self.assertTrue(p2.match('strict', False)) + self.assertTrue(p3.match('strict', False)) + # match respects incorrect value + self.assertTrue(p0.match('threshold', 5)) + self.assertFalse(p1.match('threshold', 5)) + self.assertTrue(p2.match('threshold', 5)) + self.assertFalse(p3.match('threshold', 5)) + self.assertTrue(p0.match('strict', True)) + self.assertTrue(p1.match('strict', True)) + self.assertFalse(p2.match('strict', True)) + self.assertFalse(p3.match('strict', True)) + + def test_members(self): + # node returns expression + self.assertEqual(Partial(ast.filter.Equals).node, ast.filter.Equals) + self.assertEqual(Partial(ast.filter.LessThan).node, ast.filter.LessThan) + # kwargs returns arguments + self.assertDictEqual(Partial(ast.filter.Equals, value='hello').kwargs, + {'value': 'hello'}) + self.assertDictEqual(Partial(ast.filter.LessThan, threshold=3, strict=False).kwargs, + {'threshold': 3, 'strict': False}) + # Partial does not check about kwargs + self.assertDictEqual(Partial(ast.filter.LessThan, value='hello').kwargs, + {'value': 'hello'}) + self.assertDictEqual(Partial(ast.filter.Equals, threshold=3, strict=False).kwargs, + {'threshold': 3, 'strict': False}) + + def test_essentials(self): + # comparison respects type + class Foo(): pass + self.assertNotEqual(Partial(ast.filter.Equals), 1234) + self.assertNotEqual(hash(Partial(ast.filter.Equals)), hash(1234)) + self.assertNotEqual(Partial(ast.filter.Equals), Foo()) + self.assertNotEqual(hash(Partial(ast.filter.Equals)), hash(Foo())) + self.assertNotEqual(Partial(ast.filter.Equals), ast.filter.Equals) + self.assertNotEqual(hash(Partial(ast.filter.Equals)), hash(ast.filter.Equals)) + self.assertNotEqual(Partial(ast.filter.Equals), ast.filter.Equals('hello')) + self.assertNotEqual(hash(Partial(ast.filter.Equals)), hash(ast.filter.Equals('hello'))) + # comparison respects node + self.assertEqual(Partial(ast.filter.Equals), Partial(ast.filter.Equals)) + self.assertEqual(hash(Partial(ast.filter.Equals)), hash(Partial(ast.filter.Equals))) + self.assertEqual(Partial(ast.filter.LessThan), Partial(ast.filter.LessThan)) + self.assertEqual(hash(Partial(ast.filter.LessThan)), hash(Partial(ast.filter.LessThan))) + self.assertNotEqual(Partial(ast.filter.Equals), Partial(ast.filter.LessThan)) + self.assertNotEqual(hash(Partial(ast.filter.Equals)), hash(Partial(ast.filter.LessThan))) + # comparison respects kwargs + self.assertEqual( + Partial(ast.filter.Equals, value='hello'), + Partial(ast.filter.Equals, value='hello')) + self.assertEqual( + hash(Partial(ast.filter.Equals, value='hello')), + hash(Partial(ast.filter.Equals, value='hello'))) + self.assertEqual( + Partial(ast.filter.LessThan, threshold=3, strict=False), + Partial(ast.filter.LessThan, threshold=3, strict=False)) + self.assertEqual( + hash(Partial(ast.filter.LessThan, threshold=3, strict=False)), + hash(Partial(ast.filter.LessThan, threshold=3, strict=False))) + self.assertNotEqual( + Partial(ast.filter.Equals, value='hello'), + Partial(ast.filter.Equals)) + self.assertNotEqual( + hash(Partial(ast.filter.Equals, value='hello')), + hash(Partial(ast.filter.Equals))) + self.assertNotEqual( + Partial(ast.filter.Equals, value='hello'), + Partial(ast.filter.Equals, value='world')) + self.assertNotEqual( + hash(Partial(ast.filter.Equals, value='hello')), + hash(Partial(ast.filter.Equals, value='world'))) + self.assertNotEqual( + Partial(ast.filter.LessThan, threshold=3, strict=False), + Partial(ast.filter.LessThan)) + self.assertNotEqual( + hash(Partial(ast.filter.LessThan, threshold=3, strict=False)), + hash(Partial(ast.filter.LessThan))) + self.assertNotEqual( + Partial(ast.filter.LessThan, threshold=3, strict=False), + Partial(ast.filter.LessThan, threshold=5)) + self.assertNotEqual( + hash(Partial(ast.filter.LessThan, threshold=3, strict=False)), + hash(Partial(ast.filter.LessThan, threshold=5))) + self.assertNotEqual( + Partial(ast.filter.LessThan, threshold=3, strict=False), + Partial(ast.filter.LessThan, strict=False)) + self.assertNotEqual( + hash(Partial(ast.filter.LessThan, threshold=3, strict=False)), + hash(Partial(ast.filter.LessThan, strict=False))) + self.assertNotEqual( + Partial(ast.filter.LessThan, threshold=3, strict=False), + Partial(ast.filter.LessThan, threshold=3, strict=True)) + self.assertNotEqual( + hash(Partial(ast.filter.LessThan, threshold=3, strict=False)), + hash(Partial(ast.filter.LessThan, threshold=3, strict=True))) + self.assertNotEqual( + Partial(ast.filter.LessThan, threshold=3, strict=False), + Partial(ast.filter.LessThan, threshold=5, strict=False)) + self.assertNotEqual( + hash(Partial(ast.filter.LessThan, threshold=3, strict=False)), + hash(Partial(ast.filter.LessThan, threshold=5, strict=False))) + # string representation + self.assertEqual(str(Partial(ast.filter.Equals)), 'Partial(Equals, {})') + self.assertEqual(repr(Partial(ast.filter.Equals)), 'Partial(Equals, {})') + self.assertEqual(str(Partial(ast.filter.LessThan)), 'Partial(LessThan, {})') + self.assertEqual(repr(Partial(ast.filter.LessThan)), 'Partial(LessThan, {})') + self.assertEqual(str(Partial(ast.filter.Equals, value='hello')), "Partial(Equals, {'value': 'hello'})") + self.assertEqual(repr(Partial(ast.filter.Equals, value='hello')), "Partial(Equals, {'value': 'hello'})") + self.assertEqual(str(Partial(ast.filter.LessThan, threshold=3)), "Partial(LessThan, {'threshold': 3})") + self.assertEqual(repr(Partial(ast.filter.LessThan, threshold=3)), "Partial(LessThan, {'threshold': 3})") + self.assertEqual(str(Partial(ast.filter.LessThan, strict=False)), "Partial(LessThan, {'strict': False})") + self.assertEqual(repr(Partial(ast.filter.LessThan, strict=False)), "Partial(LessThan, {'strict': False})") + self.assertEqual(str(Partial(ast.filter.LessThan, threshold=3, strict=False)), "Partial(LessThan, {'threshold': 3, 'strict': False})") + self.assertEqual(repr(Partial(ast.filter.LessThan, threshold=3, strict=False)), "Partial(LessThan, {'threshold': 3, 'strict': False})") + + +class TestSetMatcher(unittest.TestCase): + def test_set_matcher(self): + # setup + A = ast.filter.Equals('A') + B = ast.filter.Equals('B') + C = ast.filter.Equals('C') + D = ast.filter.Equals('D') + matcher = Filter() + + # identical sets match + self.assertTrue(_set_matcher( + ast.filter.And(A, B, C), + ast.filter.And(A, B, C), + matcher._parse_filter_expression, + )) + + # order is irrelevant + self.assertTrue(_set_matcher( + ast.filter.And(A, B, C), + ast.filter.And(B, C, A), + matcher._parse_filter_expression, + )) + + # all reference items must be present + self.assertFalse(_set_matcher( + ast.filter.And(A, B), + ast.filter.And(A, B, C), + matcher._parse_filter_expression, + )) + + # all reference items must have a match + self.assertFalse(_set_matcher( + ast.filter.And(A, B, C), + ast.filter.And(D, B, C), + matcher._parse_filter_expression, + )) + self.assertFalse(_set_matcher( + ast.filter.And(A, B, C), + ast.filter.And(A, D, C), + matcher._parse_filter_expression, + )) + self.assertFalse(_set_matcher( + ast.filter.And(A, B, C), + ast.filter.And(A, B, D), + matcher._parse_filter_expression, + )) + + # Any matches every item + self.assertTrue(_set_matcher( + ast.filter.And(A, B, C), + ast.filter.And(Any(), B, C), + matcher._parse_filter_expression, + )) + self.assertTrue(_set_matcher( + ast.filter.And(A, B, C), + ast.filter.And(A, Any(), C), + matcher._parse_filter_expression, + )) + self.assertTrue(_set_matcher( + ast.filter.And(A, B, C), + ast.filter.And(A, B, Any()), + matcher._parse_filter_expression, + )) + self.assertTrue(_set_matcher( + ast.filter.And(A, B, D), + ast.filter.And(A, B, Any()), + matcher._parse_filter_expression, + )) + + # there can be multiple Any's + self.assertTrue(_set_matcher( + ast.filter.And(A, B, C), + ast.filter.And(A, Any(), Any()), + matcher._parse_filter_expression, + )) + self.assertTrue(_set_matcher( + ast.filter.And(A, B, C), + ast.filter.And(Any(), B, Any()), + matcher._parse_filter_expression, + )) + self.assertTrue(_set_matcher( + ast.filter.And(A, B, C), + ast.filter.And(Any(), Any(), C), + matcher._parse_filter_expression, + )) + + # Any covers exactly one element + self.assertTrue(_set_matcher( + ast.filter.And(A, B, C), + ast.filter.And(A, B, Any()), + matcher._parse_filter_expression, + )) + self.assertTrue(_set_matcher( + ast.filter.And(A, B, D), + ast.filter.And(A, B, Any()), + matcher._parse_filter_expression, + )) + self.assertFalse(_set_matcher( + ast.filter.And(A, B), + ast.filter.And(A, B, Any()), + matcher._parse_filter_expression, + )) + self.assertFalse(_set_matcher( + ast.filter.And(A, B, C, D), + ast.filter.And(A, B, Any()), + matcher._parse_filter_expression, + )) + + # each Any covers exactly one element + self.assertTrue(_set_matcher( + ast.filter.And(A, B, C), + ast.filter.And(Any(), Any(), Any()), + matcher._parse_filter_expression, + )) + self.assertFalse(_set_matcher( + ast.filter.And(A, B, C), + ast.filter.And(Any(), Any()), + matcher._parse_filter_expression, + )) + self.assertFalse(_set_matcher( + ast.filter.And(A, B), + ast.filter.And(Any(), Any(), Any()), + matcher._parse_filter_expression, + )) + + # Rest captures remainder + self.assertTrue(_set_matcher( + ast.filter.And(A, B, C), + ast.filter.And(A, B, Rest()), + matcher._parse_filter_expression, + )) + self.assertTrue(_set_matcher( + ast.filter.And(A, B, C, D), + ast.filter.And(A, B, Rest()), + matcher._parse_filter_expression, + )) + # remainder matches the empty set + self.assertTrue(_set_matcher( + ast.filter.And(A, B), + ast.filter.And(A, B, Rest()), + matcher._parse_filter_expression, + )) + # Rest does not absolve other refernce items from having a match + self.assertFalse(_set_matcher( + ast.filter.And(A, C, D), + ast.filter.And(A, B, Rest()), + matcher._parse_filter_expression, + )) + # Rest can be combined with Any ... + self.assertTrue(_set_matcher( + ast.filter.And(A, C, D), + ast.filter.And(A, Any(), Rest()), + matcher._parse_filter_expression, + )) + self.assertTrue(_set_matcher( + ast.filter.And(A, C, D), + ast.filter.And(A, Any(), Rest()), + matcher._parse_filter_expression, + )) + # ... explicit items still need to match + self.assertFalse(_set_matcher( + ast.filter.And(A, C, D), + ast.filter.And(B, Any(), Rest()), + matcher._parse_filter_expression, + )) + # ... Any still determines minimum element count + self.assertTrue(_set_matcher( + ast.filter.And(A, B), + ast.filter.And(A, Any(), Rest()), + matcher._parse_filter_expression, + )) + self.assertFalse(_set_matcher( + ast.filter.And(A, B), + ast.filter.And(A, Any(), Any(), Rest()), + matcher._parse_filter_expression, + )) + # Rest cannot be repeated ... + self.assertRaises(errors.BackendError, _set_matcher, + ast.filter.And(A, B, C), + ast.filter.And(A, Rest(), Rest(ast.filter.Equals('hello'))), + matcher._parse_filter_expression, + ) + # ... unless they are identical + self.assertRaises(errors.BackendError, _set_matcher, + ast.filter.And(A, B, C), + ast.filter.And(A, Rest(), Rest()), # Any instances are different! + matcher._parse_filter_expression, + ) + # ... unless they are identical + self.assertTrue(_set_matcher( + ast.filter.And(A, B, C), + ast.filter.And(A, B, Rest(C), Rest(C)), + matcher._parse_filter_expression, + )) + # Rest can mandate a specific expression + self.assertTrue(_set_matcher( + ast.filter.And(A, B, C), + ast.filter.And(A, B, Rest(C)), + matcher._parse_filter_expression, + )) + self.assertFalse(_set_matcher( + ast.filter.And(A, B, C), + ast.filter.And(A, B, Rest(D)), + matcher._parse_filter_expression, + )) + # Rest can mandate a partial expression + self.assertTrue(_set_matcher( + ast.filter.And(A, B, C), + ast.filter.And(A, B, Rest(Partial(ast.filter.Equals))), + matcher._parse_filter_expression, + )) + self.assertTrue(_set_matcher( + ast.filter.And(A, B, C), + ast.filter.And(A, Rest(Partial(ast.filter.Equals))), + matcher._parse_filter_expression, + )) + self.assertFalse(_set_matcher( + ast.filter.And(A, B, C), + ast.filter.And(A, B, Rest(Partial(ast.filter.Substring))), + matcher._parse_filter_expression, + )) + self.assertFalse(_set_matcher( + ast.filter.And(A, B, C), + ast.filter.And(A, B, Rest(Partial(ast.filter.Equals, value='D'))), + matcher._parse_filter_expression, + )) + # Rest can be the only expression + self.assertTrue(_set_matcher( + ast.filter.And(A, B, C), + ast.filter.And(Rest(Partial(ast.filter.Equals))), + matcher._parse_filter_expression, + )) + # Rest's expression defaults to Any + self.assertTrue(_set_matcher( + ast.filter.And(A, B, C), + ast.filter.And(Rest()), + matcher._parse_filter_expression, + )) + + +class TestFilter(unittest.TestCase): + def setUp(self): + self.match = Filter() + + def test_call(self): + # query must be a filter expression + self.assertRaises(errors.BackendError, self.match, 1234, Any()) + self.assertRaises(errors.BackendError, self.match, ast.filter.Predicate(ns.bse.filename), Any()) + # reference must be a filter expression + self.assertRaises(errors.BackendError, self.match, ast.filter.Equals('hello'), 1234) + self.assertRaises(errors.BackendError, self.match, ast.filter.Equals('hello'), ast.filter.Predicate(ns.bse.filename)) + # reference can be Any or Partial + self.assertTrue(self.match( + ast.filter.Equals('hello'), + Any(), + )) + self.assertTrue(self.match( + ast.filter.Equals('hello'), + Partial(ast.filter.Equals), + )) + # call parses expression + self.assertTrue(self.match( + # query + ast.filter.And( + ast.filter.Any(ns.bse.tag, + ast.filter.All(ns.bse.label, + ast.filter.Or( + ast.filter.Equals('hello'), + ast.filter.Equals('world'), + ast.filter.StartsWith('foo'), + ast.filter.EndsWith('bar'), + ) + ) + ), + ast.filter.Any(ns.bse.iso, + ast.filter.And( + ast.filter.GreaterThan(100, strict=True), + ast.filter.LessThan(200, strict=False), + ) + ), + ast.filter.Any(ast.filter.OneOf(ns.bse.featureA, ns.bse.featureB), + ast.filter.Distance([1,2,3], 1) + ), + ), + # reference + ast.filter.And( + ast.filter.Any(Any(), + ast.filter.All(Partial(ast.filter.Predicate, reverse=False), + ast.filter.Or( + Partial(ast.filter.StartsWith), + ast.filter.EndsWith('bar'), + Rest(Partial(ast.filter.Equals)), + ) + ) + ), + ast.filter.Any(ns.bse.iso, + ast.filter.And( + Partial(ast.filter.GreaterThan, strict=True), + Any(), + Rest(), + ) + ), + ast.filter.Any(ast.filter.OneOf(Rest()), + Partial(ast.filter.Distance) + ), + ), + )) + self.assertFalse(self.match( + # query + ast.filter.Any(ns.bse.tag, + ast.filter.And( + ast.filter.Any(ns.bse.label, ast.filter.Equals('hello')), + ast.filter.Any(ns.bse.collection, ast.filter.Is('http://example.com/col#123')), + ast.filter.Not(ast.filter.Has(ns.bse.label)), + ) + ), + # reference + ast.filter.Any(ns.bse.tag, + ast.filter.And( + Any(), + ast.filter.Any(Partial(ast.filter.Predicate, reverse=True), # reverse mismatch + Partial(ast.filter.Is)), + ast.filter.Not(ast.filter.Has(Any(), Any())), + ) + ) + )) + + def test_parse_filter_expression(self): + # Any matches every filter expression + self.assertTrue(self.match._parse_filter_expression( + ast.filter.Not(ast.filter.FilterExpression()), Any())) + self.assertTrue(self.match._parse_filter_expression( + ast.filter.Has(ns.bse.filename), Any())) + self.assertTrue(self.match._parse_filter_expression( + ast.filter.Distance([1,2,3], 1.0), Any())) + self.assertTrue(self.match._parse_filter_expression( + ast.filter.Any(ns.bse.filename, ast.filter.Equals('hello')), Any())) + self.assertTrue(self.match._parse_filter_expression( + ast.filter.All(ns.bse.filename, ast.filter.Equals('hello')), Any())) + self.assertTrue(self.match._parse_filter_expression( + ast.filter.And(ast.filter.Equals('hello')), Any())) + self.assertTrue(self.match._parse_filter_expression( + ast.filter.Or(ast.filter.Equals('hello')), Any())) + self.assertTrue(self.match._parse_filter_expression( + ast.filter.Equals('hello'), Any())) + self.assertTrue(self.match._parse_filter_expression( + ast.filter.Substring('hello'), Any())) + self.assertTrue(self.match._parse_filter_expression( + ast.filter.StartsWith('hello'), Any())) + self.assertTrue(self.match._parse_filter_expression( + ast.filter.EndsWith('hello'), Any())) + self.assertTrue(self.match._parse_filter_expression( + ast.filter.Is('hello'), Any())) + self.assertTrue(self.match._parse_filter_expression( + ast.filter.LessThan(3), Any())) + self.assertTrue(self.match._parse_filter_expression( + ast.filter.GreaterThan(3), Any())) + # Any matches invalid filter expressions + self.assertTrue(self.match._parse_filter_expression( + ast.filter.FilterExpression(), Any())) + # node must be an appropriate filter expression + self.assertRaises(errors.BackendError, self.match._parse_filter_expression, + ast.filter.FilterExpression(), ast.filter.FilterExpression()) + self.assertRaises(errors.BackendError, self.match._parse_filter_expression, + 1234, ast.filter.FilterExpression()) + + def test_parse_predicate_expression(self): + # Any matches every predicate expression + self.assertTrue(self.match._parse_predicate_expression( + ast.filter.Predicate(ns.bse.filename), Any())) + self.assertTrue(self.match._parse_predicate_expression( + ast.filter.OneOf(ns.bse.filename), Any())) + # Any matches invalid predicate expression + self.assertTrue(self.match._parse_predicate_expression( + ast.filter.FilterExpression(), Any())) + # node must be an appropriate predicate expression + self.assertRaises(errors.BackendError, self.match._parse_predicate_expression, + ast.filter.PredicateExpression(), ast.filter.PredicateExpression()) + self.assertRaises(errors.BackendError, self.match._parse_predicate_expression, + 1234, ast.filter.PredicateExpression()) + + def test_predicate(self): + # identical expressions match + self.assertTrue(self.match._predicate( + ast.filter.Predicate(ns.bse.filename, reverse=False), + ast.filter.Predicate(ns.bse.filename, reverse=False), + )) + # _predicate respects type + self.assertFalse(self.match._predicate( + ast.filter.Predicate(ns.bse.filename, reverse=False), + ast.filter.FilterExpression(), + )) + # _predicate respects predicate + self.assertFalse(self.match._predicate( + ast.filter.Predicate(ns.bse.filename, reverse=False), + ast.filter.Predicate(ns.bse.filesize, reverse=False), + )) + # _predicate respects reverse + self.assertFalse(self.match._predicate( + ast.filter.Predicate(ns.bse.filename, reverse=False), + ast.filter.Predicate(ns.bse.filename, reverse=True), + )) + # Partial requires ast.filter.Predicate + self.assertFalse(self.match._predicate( + ast.filter.Predicate(ns.bse.filename, reverse=False), + Partial(ast.filter.Equals), + )) + # predicate and reverse can be specified + self.assertTrue(self.match._predicate( + ast.filter.Predicate(ns.bse.filename, reverse=False), + Partial(ast.filter.Predicate, predicate=ns.bse.filename, reverse=False), + )) + self.assertFalse(self.match._predicate( + ast.filter.Predicate(ns.bse.filename, reverse=False), + Partial(ast.filter.Predicate, predicate=ns.bse.filesize, reverse=False), + )) + self.assertFalse(self.match._predicate( + ast.filter.Predicate(ns.bse.filename, reverse=False), + Partial(ast.filter.Predicate, predicate=ns.bse.filename, reverse=True), + )) + # predicate can remain unspecified + self.assertTrue(self.match._predicate( + ast.filter.Predicate(ns.bse.filename, reverse=False), + Partial(ast.filter.Predicate, reverse=False), + )) + self.assertTrue(self.match._predicate( + ast.filter.Predicate(ns.bse.filesize, reverse=False), + Partial(ast.filter.Predicate, reverse=False), + )) + self.assertFalse(self.match._predicate( + ast.filter.Predicate(ns.bse.filesize, reverse=False), + Partial(ast.filter.Predicate, reverse=True), + )) + # reverse can remain unspecified + self.assertTrue(self.match._predicate( + ast.filter.Predicate(ns.bse.filename, reverse=False), + Partial(ast.filter.Predicate, predicate=ns.bse.filename), + )) + self.assertTrue(self.match._predicate( + ast.filter.Predicate(ns.bse.filename, reverse=True), + Partial(ast.filter.Predicate, predicate=ns.bse.filename), + )) + self.assertFalse(self.match._predicate( + ast.filter.Predicate(ns.bse.filename, reverse=False), + Partial(ast.filter.Predicate, predicate=ns.bse.filesize), + )) + + def test_one_of(self): + A = ast.filter.Predicate(ns.bse.filename) + B = ast.filter.Predicate(ns.bse.filesize) + C = ast.filter.Predicate(ns.bse.filename, reverse=True) + # identical expressions match + self.assertTrue(self.match._one_of( + ast.filter.OneOf(A, B), + ast.filter.OneOf(A, B), + )) + # _one_of respects type + self.assertFalse(self.match._one_of( + ast.filter.OneOf(A, B), + ast.filter.Predicate(ns.bse.filesize, reverse=True), + )) + # _one_of respects child expressions + self.assertFalse(self.match._one_of( + ast.filter.OneOf(A, B), + ast.filter.OneOf(A, C), + )) + self.assertFalse(self.match._one_of( + ast.filter.OneOf(A, B), + ast.filter.OneOf(A), + )) + self.assertFalse(self.match._one_of( + ast.filter.OneOf(A, B), + ast.filter.OneOf(A, B, C), + )) + self.assertTrue(self.match._one_of( + ast.filter.OneOf(A, B), + ast.filter.OneOf(B, A), + )) + self.assertTrue(self.match._one_of( + ast.filter.OneOf(A, B), + ast.filter.OneOf(A, Any()), + )) + self.assertTrue(self.match._one_of( + ast.filter.OneOf(A, B), + ast.filter.OneOf(B, Rest()), + )) + + def test_branch(self): + # identical expressions match + self.assertTrue(self.match._branch( + ast.filter.Any(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.Any(ns.bse.filename, ast.filter.Equals('hello')), + )) + self.assertTrue(self.match._branch( + ast.filter.All(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.All(ns.bse.filename, ast.filter.Equals('hello')), + )) + # _agg respects type + self.assertFalse(self.match._branch( + ast.filter.Any(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.All(ns.bse.filename, ast.filter.Equals('hello')), + )) + self.assertFalse(self.match._branch( + ast.filter.All(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.Any(ns.bse.filename, ast.filter.Equals('hello')), + )) + self.assertFalse(self.match._branch( + ast.filter.Any(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.Equals('hello'), + )) + self.assertFalse(self.match._branch( + ast.filter.All(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.Equals('hello'), + )) + # _agg respects predicate expression + self.assertTrue(self.match._branch( + ast.filter.Any(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.Any(ast.filter.Predicate(ns.bse.filename), ast.filter.Equals('hello')), + )) + self.assertTrue(self.match._branch( + ast.filter.All(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.All(ast.filter.Predicate(ns.bse.filename), ast.filter.Equals('hello')), + )) + self.assertFalse(self.match._branch( + ast.filter.Any(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.Any(ns.bse.filesize, ast.filter.Equals('hello')), + )) + self.assertFalse(self.match._branch( + ast.filter.All(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.All(ns.bse.filesize, ast.filter.Equals('hello')), + )) + self.assertFalse(self.match._branch( + ast.filter.Any(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.Any(ast.filter.OneOf(ns.bse.filename), ast.filter.Equals('hello')), + )) + self.assertFalse(self.match._branch( + ast.filter.All(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.All(ast.filter.OneOf(ns.bse.filename), ast.filter.Equals('hello')), + )) + self.assertFalse(self.match._branch( + ast.filter.Any(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.Any(ast.filter.Predicate(ns.bse.filename, reverse=True), ast.filter.Equals('hello')), + )) + self.assertFalse(self.match._branch( + ast.filter.All(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.All(ast.filter.Predicate(ns.bse.filename, reverse=True), ast.filter.Equals('hello')), + )) + self.assertTrue(self.match._branch( + ast.filter.Any(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.Any(Any(), ast.filter.Equals('hello')), + )) + self.assertTrue(self.match._branch( + ast.filter.All(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.All(Any(), ast.filter.Equals('hello')), + )) + self.assertTrue(self.match._branch( + ast.filter.Any(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.Any(Partial(ast.filter.Predicate), ast.filter.Equals('hello')), + )) + self.assertTrue(self.match._branch( + ast.filter.All(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.All(Partial(ast.filter.Predicate), ast.filter.Equals('hello')), + )) + # _agg respects filter expression + self.assertFalse(self.match._branch( + ast.filter.Any(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.Any(ns.bse.filename, ast.filter.Substring('hello')), + )) + self.assertFalse(self.match._branch( + ast.filter.All(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.All(ns.bse.filename, ast.filter.Substring('hello')), + )) + self.assertFalse(self.match._branch( + ast.filter.Any(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.Any(ns.bse.filename, ast.filter.Any(Any(), Any())), + )) + self.assertFalse(self.match._branch( + ast.filter.All(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.All(ns.bse.filename, ast.filter.All(Any(), Any())), + )) + self.assertTrue(self.match._branch( + ast.filter.Any(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.Any(ns.bse.filename, Any()), + )) + self.assertTrue(self.match._branch( + ast.filter.All(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.All(ns.bse.filename, Any()), + )) + self.assertTrue(self.match._branch( + ast.filter.Any(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.Any(ns.bse.filename, Partial(ast.filter.Equals)), + )) + self.assertTrue(self.match._branch( + ast.filter.All(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.All(ns.bse.filename, Partial(ast.filter.Equals)), + )) + self.assertFalse(self.match._branch( + ast.filter.Any(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.Any(ns.bse.filename, Partial(ast.filter.Equals, value='world')), + )) + self.assertFalse(self.match._branch( + ast.filter.All(ns.bse.filename, ast.filter.Equals('hello')), + ast.filter.All(ns.bse.filename, Partial(ast.filter.Equals, value='world')), + )) + + def test_agg(self): + A = ast.filter.Equals('hello') + B = ast.filter.Equals('world') + C = ast.filter.Equals('foobar') + # identical expressions match + self.assertTrue(self.match._agg( + ast.filter.And(A, B), + ast.filter.And(A, B), + )) + self.assertTrue(self.match._agg( + ast.filter.Or(A, B), + ast.filter.Or(A, B), + )) + # _agg respects type + self.assertFalse(self.match._agg( + ast.filter.And(A, B), + ast.filter.Or(A, B), + )) + self.assertFalse(self.match._agg( + ast.filter.Or(A, B), + ast.filter.And(A, B), + )) + self.assertFalse(self.match._agg( + ast.filter.And(A, B), + ast.filter.Equals('hello'), + )) + self.assertFalse(self.match._agg( + ast.filter.Or(A, B), + ast.filter.Equals('hello'), + )) + # _agg respects child expressions + self.assertFalse(self.match._agg( + ast.filter.And(A, B), + ast.filter.And(A, ast.filter.Equals('bar')), + )) + self.assertFalse(self.match._agg( + ast.filter.Or(A, B), + ast.filter.Or(A, ast.filter.Equals('bar')), + )) + self.assertFalse(self.match._agg( + ast.filter.And(A, B), + ast.filter.And(A), + )) + self.assertFalse(self.match._agg( + ast.filter.Or(A, B), + ast.filter.Or(A), + )) + self.assertFalse(self.match._agg( + ast.filter.And(A, B), + ast.filter.And(A, B, C), + )) + self.assertFalse(self.match._agg( + ast.filter.Or(A, B), + ast.filter.Or(A, B, C), + )) + self.assertTrue(self.match._agg( + ast.filter.And(A, B), + ast.filter.And(B, A), + )) + self.assertTrue(self.match._agg( + ast.filter.Or(A, B), + ast.filter.Or(B, A), + )) + self.assertTrue(self.match._agg( + ast.filter.And(A, B), + ast.filter.And(A, Any()), + )) + self.assertTrue(self.match._agg( + ast.filter.Or(A, B), + ast.filter.Or(A, Any()), + )) + self.assertTrue(self.match._agg( + ast.filter.And(A, B), + ast.filter.And(B, Rest()), + )) + self.assertTrue(self.match._agg( + ast.filter.Or(A, B), + ast.filter.Or(B, Rest()), + )) + + def test_not(self): + # identical expressions match + self.assertTrue(self.match._not( + ast.filter.Not(ast.filter.Equals('hello')), + ast.filter.Not(ast.filter.Equals('hello')), + )) + # _not respects type + self.assertFalse(self.match._not( + ast.filter.Not(ast.filter.Equals('hello')), + ast.filter.Equals('hello'), + )) + # _not respects child expression + self.assertFalse(self.match._not( + ast.filter.Not(ast.filter.Equals('hello')), + ast.filter.Not(ast.filter.Equals('world')), + )) + self.assertFalse(self.match._not( + ast.filter.Not(ast.filter.Equals('hello')), + ast.filter.Not(ast.filter.Substring('hello')), + )) + self.assertTrue(self.match._not( + ast.filter.Not(ast.filter.Equals('hello')), + ast.filter.Not(Any()), + )) + + def test_has(self): + # identical expressions match + self.assertTrue(self.match._has( + ast.filter.Has(ns.bse.filesize), + ast.filter.Has(ns.bse.filesize), + )) + self.assertTrue(self.match._has( + ast.filter.Has(ns.bse.filesize, ast.filter.LessThan(3)), + ast.filter.Has(ns.bse.filesize, ast.filter.LessThan(3)), + )) + # _has respects type + self.assertFalse(self.match._has( + ast.filter.Has(ns.bse.filesize), + ast.filter.Equals('hello'), + )) + self.assertFalse(self.match._has( + ast.filter.Has(ns.bse.filesize), + ast.filter.Equals('hello'), + )) + # _has respects predicate + self.assertFalse(self.match._has( + ast.filter.Has(ns.bse.filesize, ast.filter.LessThan(3)), + ast.filter.Has(ns.bse.iso, ast.filter.LessThan(3)), + )) + self.assertTrue(self.match._has( + ast.filter.Has(ns.bse.filesize, ast.filter.LessThan(3)), + ast.filter.Has(Any(), ast.filter.LessThan(3)), + )) + self.assertTrue(self.match._has( + ast.filter.Has(ns.bse.filesize, ast.filter.LessThan(3)), + ast.filter.Has(Partial(ast.filter.Predicate), ast.filter.LessThan(3)), + )) + # _has respects count + self.assertFalse(self.match._has( + ast.filter.Has(ns.bse.filesize, ast.filter.LessThan(3)), + ast.filter.Has(ns.bse.filesize, ast.filter.GreaterThan(3)), + )) + self.assertFalse(self.match._has( + ast.filter.Has(ns.bse.filesize, ast.filter.LessThan(3)), + ast.filter.Has(ns.bse.filesize, ast.filter.LessThan(5)), + )) + self.assertTrue(self.match._has( + ast.filter.Has(ns.bse.filesize, ast.filter.LessThan(3)), + ast.filter.Has(ns.bse.filesize, Any()), + )) + self.assertTrue(self.match._has( + ast.filter.Has(ns.bse.filesize, ast.filter.LessThan(3)), + ast.filter.Has(ns.bse.filesize, Partial(ast.filter.LessThan)), + )) + + def test_distance(self): + # identical expressions match + self.assertTrue(self.match._distance( + ast.filter.Distance([1,2,3], 5, True), + ast.filter.Distance([1,2,3], 5, True), + )) + # _distance respects type + self.assertFalse(self.match._distance( + ast.filter.Distance([1,2,3], 5, True), + ast.filter.Equals('hello'), + )) + self.assertFalse(self.match._distance( + ast.filter.Distance([1,2,3], 5, True), + Partial(ast.filter.Equals), + )) + # _distance respects reference value + self.assertFalse(self.match._distance( + ast.filter.Distance([1,2,3], 5, True), + ast.filter.Distance([3,2,1], 5, True), + )) + self.assertTrue(self.match._distance( + ast.filter.Distance([1,2,3], 5, True), + Partial(ast.filter.Distance, threshold=5, strict=True), + )) + self.assertTrue(self.match._distance( + ast.filter.Distance([1,2,3], 5, True), + Partial(ast.filter.Distance, reference=[1,2,3], threshold=5, strict=True), + )) + self.assertFalse(self.match._distance( + ast.filter.Distance([1,2,3], 5, True), + Partial(ast.filter.Distance, reference=[3,2,1], threshold=5, strict=True), + )) + # _distance respects threshold + self.assertFalse(self.match._distance( + ast.filter.Distance([1,2,3], 5, True), + ast.filter.Distance([1,2,3], 8, True), + )) + self.assertTrue(self.match._distance( + ast.filter.Distance([1,2,3], 5, True), + Partial(ast.filter.Distance, reference=[1,2,3], strict=True), + )) + self.assertTrue(self.match._distance( + ast.filter.Distance([1,2,3], 5, True), + Partial(ast.filter.Distance, reference=[1,2,3], threshold=5, strict=True), + )) + self.assertFalse(self.match._distance( + ast.filter.Distance([1,2,3], 5, True), + Partial(ast.filter.Distance, reference=[1,2,3], threshold=8, strict=True), + )) + # _distance respects strict + self.assertFalse(self.match._distance( + ast.filter.Distance([1,2,3], 5, True), + ast.filter.Distance([1,2,3], 5, False), + )) + self.assertTrue(self.match._distance( + ast.filter.Distance([1,2,3], 5, True), + Partial(ast.filter.Distance, reference=[1,2,3], threshold=5), + )) + self.assertTrue(self.match._distance( + ast.filter.Distance([1,2,3], 5, True), + Partial(ast.filter.Distance, reference=[1,2,3], threshold=5, strict=True), + )) + self.assertFalse(self.match._distance( + ast.filter.Distance([1,2,3], 5, True), + Partial(ast.filter.Distance, reference=[1,2,3], threshold=5, strict=False), + )) + + def test_value(self): + # identical expressions match + self.assertTrue(self.match._value(ast.filter.Equals('hello'), ast.filter.Equals('hello'))) + self.assertTrue(self.match._value(ast.filter.Substring('hello'), ast.filter.Substring('hello'))) + self.assertTrue(self.match._value(ast.filter.StartsWith('hello'), ast.filter.StartsWith('hello'))) + self.assertTrue(self.match._value(ast.filter.EndsWith('hello'), ast.filter.EndsWith('hello'))) + self.assertTrue(self.match._value(ast.filter.Is('hello'), ast.filter.Is('hello'))) + # _value respects type + self.assertFalse(self.match._value(ast.filter.Equals('hello'), ast.filter.Is('hello'))) + self.assertFalse(self.match._value(ast.filter.Substring('hello'), ast.filter.Is('hello'))) + self.assertFalse(self.match._value(ast.filter.StartsWith('hello'), ast.filter.Is('hello'))) + self.assertFalse(self.match._value(ast.filter.EndsWith('hello'), ast.filter.Is('hello'))) + self.assertFalse(self.match._value(ast.filter.Is('hello'), ast.filter.Equals('hello'))) + # _value respects value + self.assertFalse(self.match._value(ast.filter.Equals('hello'), ast.filter.Equals('world'))) + self.assertFalse(self.match._value(ast.filter.Substring('hello'), ast.filter.Substring('world'))) + self.assertFalse(self.match._value(ast.filter.StartsWith('hello'), ast.filter.StartsWith('world'))) + self.assertFalse(self.match._value(ast.filter.EndsWith('hello'), ast.filter.EndsWith('world'))) + self.assertFalse(self.match._value(ast.filter.Is('hello'), ast.filter.Is('world'))) + # Partial requires correct type + self.assertFalse(self.match._value(ast.filter.Equals('hello'), Partial(ast.filter.Is))) + self.assertFalse(self.match._value(ast.filter.Substring('hello'), Partial(ast.filter.Is))) + self.assertFalse(self.match._value(ast.filter.StartsWith('hello'), Partial(ast.filter.Is))) + self.assertFalse(self.match._value(ast.filter.EndsWith('hello'), Partial(ast.filter.Is))) + self.assertFalse(self.match._value(ast.filter.Is('hello'), Partial(ast.filter.Equals))) + # value can be specified + self.assertTrue(self.match._value(ast.filter.Equals('hello'), Partial(ast.filter.Equals, value='hello'))) + self.assertFalse(self.match._value(ast.filter.Equals('hello'), Partial(ast.filter.Equals, value='world'))) + self.assertTrue(self.match._value(ast.filter.Substring('hello'), Partial(ast.filter.Substring, value='hello'))) + self.assertFalse(self.match._value(ast.filter.Substring('hello'), Partial(ast.filter.Substring, value='world'))) + self.assertTrue(self.match._value(ast.filter.StartsWith('hello'), Partial(ast.filter.StartsWith, value='hello'))) + self.assertFalse(self.match._value(ast.filter.StartsWith('hello'), Partial(ast.filter.StartsWith, value='world'))) + self.assertTrue(self.match._value(ast.filter.EndsWith('hello'), Partial(ast.filter.EndsWith, value='hello'))) + self.assertFalse(self.match._value(ast.filter.EndsWith('hello'), Partial(ast.filter.EndsWith, value='world'))) + self.assertTrue(self.match._value(ast.filter.Is('hello'), Partial(ast.filter.Is, value='hello'))) + self.assertFalse(self.match._value(ast.filter.Is('hello'), Partial(ast.filter.Is, value='world'))) + # value can remain unspecified + self.assertTrue(self.match._value(ast.filter.Equals('hello'), Partial(ast.filter.Equals))) + self.assertTrue(self.match._value(ast.filter.Substring('hello'), Partial(ast.filter.Substring))) + self.assertTrue(self.match._value(ast.filter.StartsWith('hello'), Partial(ast.filter.StartsWith))) + self.assertTrue(self.match._value(ast.filter.EndsWith('hello'), Partial(ast.filter.EndsWith))) + self.assertTrue(self.match._value(ast.filter.Is('hello'), Partial(ast.filter.Is))) + + def test_bounded(self): + # identical expressions match + self.assertTrue(self.match._bounded( + ast.filter.LessThan(threshold=3, strict=False), + ast.filter.LessThan(threshold=3, strict=False), + )) + self.assertTrue(self.match._bounded( + ast.filter.GreaterThan(threshold=3, strict=False), + ast.filter.GreaterThan(threshold=3, strict=False), + )) + # _bounded respects type + self.assertFalse(self.match._bounded( + ast.filter.LessThan(threshold=3, strict=False), + ast.filter.GreaterThan(threshold=3, strict=False), + )) + self.assertFalse(self.match._bounded( + ast.filter.GreaterThan(threshold=3, strict=False), + ast.filter.LessThan(threshold=3, strict=False), + )) + # _bounded respects threshold + self.assertFalse(self.match._bounded( + ast.filter.LessThan(threshold=3, strict=False), + ast.filter.LessThan(threshold=4, strict=False), + )) + self.assertFalse(self.match._bounded( + ast.filter.GreaterThan(threshold=3, strict=False), + ast.filter.GreaterThan(threshold=4, strict=False), + )) + # _bounded respects strict + self.assertFalse(self.match._bounded( + ast.filter.LessThan(threshold=3, strict=False), + ast.filter.LessThan(threshold=3, strict=True), + )) + self.assertFalse(self.match._bounded( + ast.filter.GreaterThan(threshold=3, strict=False), + ast.filter.GreaterThan(threshold=3, strict=True), + )) + # Partial requires correct type + self.assertFalse(self.match._bounded( + ast.filter.LessThan(threshold=3, strict=False), + Partial(ast.filter.GreaterThan), + )) + self.assertFalse(self.match._bounded( + ast.filter.GreaterThan(threshold=3, strict=False), + Partial(ast.filter.LessThan), + )) + # threshold and strict can be specified + self.assertTrue(self.match._bounded( + ast.filter.LessThan(threshold=3, strict=False), + Partial(ast.filter.LessThan, threshold=3, strict=False), + )) + self.assertTrue(self.match._bounded( + ast.filter.GreaterThan(threshold=3, strict=False), + Partial(ast.filter.GreaterThan, threshold=3, strict=False), + )) + self.assertFalse(self.match._bounded( + ast.filter.LessThan(threshold=3, strict=False), + Partial(ast.filter.LessThan, threshold=4, strict=False), + )) + self.assertFalse(self.match._bounded( + ast.filter.GreaterThan(threshold=3, strict=False), + Partial(ast.filter.GreaterThan, threshold=4, strict=False), + )) + self.assertFalse(self.match._bounded( + ast.filter.LessThan(threshold=3, strict=False), + Partial(ast.filter.LessThan, threshold=3, strict=True), + )) + self.assertFalse(self.match._bounded( + ast.filter.GreaterThan(threshold=3, strict=False), + Partial(ast.filter.GreaterThan, threshold=3, strict=True), + )) + # threshold can remain unspecified + self.assertTrue(self.match._bounded( + ast.filter.LessThan(threshold=3, strict=False), + Partial(ast.filter.LessThan, strict=False), + )) + self.assertTrue(self.match._bounded( + ast.filter.GreaterThan(threshold=3, strict=False), + Partial(ast.filter.GreaterThan, strict=False), + )) + self.assertFalse(self.match._bounded( + ast.filter.LessThan(threshold=3, strict=False), + Partial(ast.filter.LessThan, strict=True), + )) + self.assertFalse(self.match._bounded( + ast.filter.GreaterThan(threshold=3, strict=False), + Partial(ast.filter.GreaterThan, strict=True), + )) + # strict can remain unspecified + self.assertTrue(self.match._bounded( + ast.filter.LessThan(threshold=3, strict=False), + Partial(ast.filter.LessThan, threshold=3), + )) + self.assertTrue(self.match._bounded( + ast.filter.GreaterThan(threshold=3, strict=False), + Partial(ast.filter.GreaterThan, threshold=3), + )) + self.assertFalse(self.match._bounded( + ast.filter.LessThan(threshold=3, strict=False), + Partial(ast.filter.LessThan, threshold=4), + )) + self.assertFalse(self.match._bounded( + ast.filter.GreaterThan(threshold=3, strict=False), + Partial(ast.filter.GreaterThan, threshold=4), + )) + + +## main ## + +if __name__ == '__main__': + unittest.main() + +## EOF ## diff --git a/test/query/test_validator.py b/test/query/test_validator.py new file mode 100644 index 0000000..418463e --- /dev/null +++ b/test/query/test_validator.py @@ -0,0 +1,505 @@ + +# imports +import unittest + +# bsfs imports +from bsfs import schema as _schema +from bsfs.namespace import ns +from bsfs.query import ast +from bsfs.utils import errors + +# objects to test +from bsfs.query.validator import Filter, Fetch + + +## code ## + +ns.bse = ns.bsfs.Entity() + +class TestFilter(unittest.TestCase): + def setUp(self): + self.schema = _schema.from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + prefix bsl: <https://schema.bsfs.io/core/Literal/> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + bsfs:URI rdfs:subClassOf bsfs:Literal . + + bsfs:Tag rdfs:subClassOf bsfs:Node . + xsd:string rdfs:subClassOf bsfs:Literal . + bsl:Number rdfs:subClassOf bsfs:Literal . + bsl:Array rdfs:subClassOf bsfs:Literal . + <https://schema.bsfs.io/core/Literal/Array/Feature> rdfs:subClassOf bsl:Array . + xsd:integer rdfs:subClassOf bsl:Number . + + bsfs:Colors rdfs:subClassOf <https://schema.bsfs.io/core/Literal/Array/Feature> ; + bsfs:dimension "5"^^xsd:integer ; + bsfs:dtype bsfs:f32 . + + bse:color rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Node ; + rdfs:range bsfs:Colors ; + bsfs:unique "true"^^xsd:boolean . + + bse:comment rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Node ; + rdfs:range xsd:string ; + bsfs:unique "false"^^xsd:boolean . + + bse:filesize rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range xsd:integer ; + bsfs:unique "true"^^xsd:boolean . + + bse:tag rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range bsfs:Tag ; + bsfs:unique "false"^^xsd:boolean . + + bse:label rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Tag ; + rdfs:range xsd:string ; + bsfs:unique "true"^^xsd:boolean . + + bse:buddy rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range bsfs:Node ; + bsfs:unique "false"^^xsd:boolean . + + ''') + self.validate = Filter(self.schema) + + def test_call(self): # tests validate implicitly + # root_type must be a _schema.Node + self.assertRaises(TypeError, self.validate, 1234, None) + self.assertRaises(TypeError, self.validate, '1234', None) + self.assertRaises(TypeError, self.validate, self.schema.literal(ns.bsfs.URI), None) + # root_type must exist in the schema + self.assertRaises(errors.ConsistencyError, self.validate, self.schema.node(ns.bsfs.Node).child(ns.bsfs.Image), None) + self.assertRaises(errors.ConsistencyError, self.validate, self.schema.node(ns.bsfs.Entity).child(ns.bsfs.Image), None) + # valid query returns true + self.assertTrue(self.validate(self.schema.node(ns.bsfs.Entity), + ast.filter.Any(ast.filter.OneOf(ns.bse.tag, ns.bse.buddy), + ast.filter.Or( + ast.filter.Is('http://example.com/symbol#1234'), + ast.filter.All(ns.bse.comment, ast.filter.StartsWith('foo')), + ast.filter.And( + ast.filter.Has(ns.bse.comment, ast.filter.Or( + ast.filter.GreaterThan(5), + ast.filter.LessThan(1), + ) + ), + ast.filter.Not(ast.filter.Any(ns.bse.comment, + ast.filter.Not(ast.filter.Equals('hello world')))), + ast.filter.Any(ns.bse.color, ast.filter.Distance([1,2,3,4,5], 3)), + ))))) + # invalid paths raise consistency error + self.assertRaises(errors.ConsistencyError, self.validate, self.schema.node(ns.bsfs.Entity), + ast.filter.Any(ast.filter.OneOf(ns.bse.tag, ns.bse.buddy), + ast.filter.Or( + ast.filter.All(ns.bse.comment, ast.filter.Equals('hello world')), + ast.filter.All(ns.bse.label, ast.filter.Equals('hello world')), # domain mismatch + ))) + + def test_routing(self): + self.assertRaises(errors.BackendError, self.validate._parse_filter_expression, ast.filter.FilterExpression(), self.schema.node(ns.bsfs.Node)) + self.assertRaises(errors.BackendError, self.validate._parse_predicate_expression, ast.filter.PredicateExpression()) + + def test_predicate(self): + # predicate must be in the schema + self.assertRaises(errors.ConsistencyError, self.validate._predicate, ast.filter.Predicate(ns.bse.invalid)) + # predicate must have a range + self.assertRaises(errors.BackendError, self.validate._predicate, ast.filter.Predicate(ns.bsfs.Predicate)) + # predicate returns domain and range + self.assertEqual(self.validate._predicate(ast.filter.Predicate(ns.bse.tag)), + (self.schema.node(ns.bsfs.Entity), self.schema.node(ns.bsfs.Tag))) + # reverse is applied + self.assertEqual(self.validate._predicate(ast.filter.Predicate(ns.bse.tag, reverse=True)), + (self.schema.node(ns.bsfs.Tag), self.schema.node(ns.bsfs.Entity))) + + def test_one_of(self): + # domains must both be nodes or literals + self.assertRaises(errors.ConsistencyError, self.validate._one_of, ast.filter.OneOf(ns.bse.tag, ast.filter.Predicate(ns.bse.label, reverse=True))) + # domains must be related + self.assertRaises(errors.ConsistencyError, self.validate._one_of, ast.filter.OneOf(ns.bse.tag, ns.bse.label)) + # ranges must both be nodes or literals + self.assertRaises(errors.ConsistencyError, self.validate._one_of, ast.filter.OneOf(ns.bse.tag, ns.bse.comment)) + # ranges must be related + self.assertRaises(errors.ConsistencyError, self.validate._one_of, ast.filter.OneOf(ns.bse.tag, ast.filter.Predicate(ns.bse.buddy, reverse=True))) + # one_of returns most specific domain + self.assertEqual(self.validate._one_of(ast.filter.OneOf(ns.bse.comment, ns.bse.label)), + (self.schema.node(ns.bsfs.Tag), self.schema.literal(ns.xsd.string))) + # one_of returns the most generic range + self.assertEqual(self.validate._one_of(ast.filter.OneOf(ns.bse.tag, ns.bse.buddy)), + (self.schema.node(ns.bsfs.Entity), self.schema.node(ns.bsfs.Node))) + + def test_branch(self): + # type must be a node + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.literal(ns.bsfs.Literal), None) + # type must be in the schema + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.node(ns.bsfs.Node).child(ns.bsfs.Invalid), None) + # predicate is verified + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.node(ns.bsfs.Entity), + ast.filter.Any(ns.bsfs.Invalid, ast.filter.Is('http://example.com/entity#1234'))) + # predicate must match the domain + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.node(ns.bsfs.Node), + ast.filter.Any(ns.bse.tag, ast.filter.Is('http://example.com/tag#1234'))) + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.node(ns.bsfs.Tag), + ast.filter.Any(ns.bse.tag, ast.filter.Is('http://example.com/tag#1234'))) + # child expression must be valid + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.node(ns.bsfs.Entity), + ast.filter.Any(ns.bse.tag, ast.filter.Equals('hello world'))) + # branch accepts valid expressions + self.assertIsNone(self.validate._branch(self.schema.node(ns.bsfs.Entity), + ast.filter.Any(ns.bse.tag, ast.filter.Is('http://example.com/entity#1234')))) + self.assertIsNone(self.validate._branch(self.schema.node(ns.bsfs.Entity), + ast.filter.All(ns.bse.tag, ast.filter.Is('http://example.com/entity#1234')))) + + def test_agg(self): + # agg evaluates child expressions + self.assertRaises(errors.ConsistencyError, self.validate._agg, self.schema.node(ns.bsfs.Entity), + ast.filter.And(ast.filter.Is('http://example.com/entity#1234'), ast.filter.Equals('hello world'))) + self.assertRaises(errors.ConsistencyError, self.validate._agg, self.schema.literal(ns.xsd.string), + ast.filter.And(ast.filter.Is('http://example.com/entity#1234'), ast.filter.Equals('hello world'))) + self.assertRaises(errors.ConsistencyError, self.validate._agg, self.schema.node(ns.bsfs.Entity), + ast.filter.Or(ast.filter.Is('http://example.com/entity#1234'), ast.filter.Equals('hello world'))) + self.assertRaises(errors.ConsistencyError, self.validate._agg, self.schema.literal(ns.xsd.string), + ast.filter.Or(ast.filter.Is('http://example.com/entity#1234'), ast.filter.Equals('hello world'))) + # agg works on nodes + self.assertIsNone(self.validate._agg(self.schema.node(ns.bsfs.Entity), + ast.filter.And(ast.filter.Is('http://example.com/entity#1234'), ast.filter.Is('http://example.com/entity#4321')))) + self.assertIsNone(self.validate._agg(self.schema.node(ns.bsfs.Entity), + ast.filter.Or(ast.filter.Is('http://example.com/entity#1234'), ast.filter.Is('http://example.com/entity#4321')))) + # agg works on literals + self.assertIsNone(self.validate._agg(self.schema.literal(ns.xsd.string), + ast.filter.And(ast.filter.Equals('foobar'), ast.filter.Equals('hello world')))) + self.assertIsNone(self.validate._agg(self.schema.literal(ns.xsd.string), + ast.filter.Or(ast.filter.Equals('foobar'), ast.filter.Equals('hello world')))) + + def test_not(self): + # not evaluates child expressions + self.assertRaises(errors.ConsistencyError, self.validate._not, self.schema.node(ns.bsfs.Entity), + ast.filter.Not(ast.filter.Equals('hello world'))) + self.assertRaises(errors.ConsistencyError, self.validate._not, self.schema.literal(ns.xsd.string), + ast.filter.Not(ast.filter.Is('http://example.com/entity#1234'))) + # not works on nodes + self.assertIsNone(self.validate._not(self.schema.node(ns.bsfs.Entity), + ast.filter.Not(ast.filter.Is('http://example.com/entity#1234')))) + # not works on literals + self.assertIsNone(self.validate._not(self.schema.literal(ns.xsd.string), + ast.filter.Not(ast.filter.Equals('hello world')))) + + def test_has(self): + # type must be node + self.assertRaises(errors.ConsistencyError, self.validate._has, self.schema.literal(ns.bsfs.Literal), + ast.filter.Has(ns.bse.tag)) + # type must be in the schema + self.assertRaises(errors.ConsistencyError, self.validate._has, self.schema.node(ns.bsfs.Node).child(ns.bsfs.Invalid), + ast.filter.Has(ns.bse.tag)) + # has checks predicate + self.assertRaises(errors.ConsistencyError, self.validate._has, self.schema.node(ns.bsfs.Entity), + ast.filter.Has(ns.bse.invalid)) + # predicate must match domain + self.assertRaises(errors.ConsistencyError, self.validate._has, self.schema.node(ns.bsfs.Tag), + ast.filter.Has(ns.bse.tag)) + # has checks count expression + self.assertRaises(errors.ConsistencyError, self.validate._has, self.schema.node(ns.bsfs.Entity), + ast.filter.Has(ns.bse.tag, ast.filter.Is('http://example.com/entity#1234'))) + # has accepts correct expressions + self.assertIsNone(self.validate._has(self.schema.node(ns.bsfs.Entity), ast.filter.Has(ns.bse.tag, ast.filter.GreaterThan(5)))) + + def test_is(self): + # type must be node + self.assertRaises(errors.ConsistencyError, self.validate._is, self.schema.literal(ns.bsfs.Literal), + ast.filter.Is('http://example.com/foo')) + # type must be in the schema + self.assertRaises(errors.ConsistencyError, self.validate._is, self.schema.node(ns.bsfs.Node).child(ns.bsfs.Invalid), + ast.filter.Is('http://example.com/foo')) + # is accepts correct expressions + self.assertIsNone(self.validate._is(self.schema.node(ns.bsfs.Entity), ast.filter.Is('http://example.com/entity#1234'))) + + def test_value(self): + # type must be literal + self.assertRaises(errors.ConsistencyError, self.validate._value, self.schema.node(ns.bsfs.Node), + ast.filter.Equals('hello world')) + self.assertRaises(errors.ConsistencyError, self.validate._value, self.schema.node(ns.bsfs.Node), + ast.filter.Substring('hello world')) + self.assertRaises(errors.ConsistencyError, self.validate._value, self.schema.node(ns.bsfs.Node), + ast.filter.StartsWith('hello world')) + self.assertRaises(errors.ConsistencyError, self.validate._value, self.schema.node(ns.bsfs.Node), + ast.filter.EndsWith('hello world')) + # type must be in the schema + self.assertRaises(errors.ConsistencyError, self.validate._value, self.schema.literal(ns.bsfs.Literal).child(ns.bsfs.Invalid), + ast.filter.Equals('hello world')) + self.assertRaises(errors.ConsistencyError, self.validate._value, self.schema.literal(ns.bsfs.Literal).child(ns.bsfs.Invalid), + ast.filter.Substring('hello world')) + self.assertRaises(errors.ConsistencyError, self.validate._value, self.schema.literal(ns.bsfs.Literal).child(ns.bsfs.Invalid), + ast.filter.StartsWith('hello world')) + self.assertRaises(errors.ConsistencyError, self.validate._value, self.schema.literal(ns.bsfs.Literal).child(ns.bsfs.Invalid), + ast.filter.EndsWith('hello world')) + # value accepts correct expressions + self.assertIsNone(self.validate._value(self.schema.literal(ns.xsd.string), ast.filter.Equals('hello world'))) + self.assertIsNone(self.validate._value(self.schema.literal(ns.xsd.string), ast.filter.Substring('hello world'))) + self.assertIsNone(self.validate._value(self.schema.literal(ns.xsd.string), ast.filter.StartsWith('hello world'))) + self.assertIsNone(self.validate._value(self.schema.literal(ns.xsd.string), ast.filter.EndsWith('hello world'))) + + def test_bounded(self): + # type must be literal + self.assertRaises(errors.ConsistencyError, self.validate._bounded, self.schema.node(ns.bsfs.Node), + ast.filter.GreaterThan(0)) + self.assertRaises(errors.ConsistencyError, self.validate._bounded, self.schema.node(ns.bsfs.Node), + ast.filter.LessThan(0)) + # type must be in the schema + self.assertRaises(errors.ConsistencyError, self.validate._bounded, self.schema.literal(ns.bsfs.Literal).child(ns.bsfs.Invalid), + ast.filter.GreaterThan(0)) + self.assertRaises(errors.ConsistencyError, self.validate._bounded, self.schema.literal(ns.bsfs.Literal).child(ns.bsfs.Invalid), + ast.filter.LessThan(0)) + # type must be a number + self.assertRaises(errors.ConsistencyError, self.validate._bounded, self.schema.literal(ns.xsd.string), + ast.filter.LessThan(0)) + # bounded accepts correct expressions + self.assertIsNone(self.validate._bounded(self.schema.literal(ns.xsd.integer), ast.filter.LessThan(0))) + self.assertIsNone(self.validate._bounded(self.schema.literal(ns.xsd.integer), ast.filter.GreaterThan(0))) + + def test_distance(self): + # type must be a literal + self.assertRaises(errors.ConsistencyError, self.validate._distance, self.schema.node(ns.bsfs.Node), + ast.filter.Distance([1,2,3], 1, False)) + # type must be a feature + self.assertRaises(errors.ConsistencyError, self.validate._distance, self.schema.literal(ns.bsl.Array), + ast.filter.Distance([1,2,3], 1, False)) + # type must be in the schema + self.assertRaises(errors.ConsistencyError, self.validate._distance, self.schema.literal(ns.bsl.Array.Feature).child(ns.bsfs.Invalid), + ast.filter.Distance([1,2,3], 1, False)) + # FIXME: reference must be a numpy array + # reference must have the correct dimension + self.assertRaises(errors.ConsistencyError, self.validate._distance, self.schema.literal(ns.bsfs.Colors), + ast.filter.Distance([1,2,3], 1, False)) + # FIXME: reference must have the correct dtype + # distance accepts correct expressions + self.assertIsNone(self.validate._distance(self.schema.literal(ns.bsfs.Colors), ast.filter.Distance([1,2,3,4,5], 1, False))) + + +class TestFetch(unittest.TestCase): + def setUp(self): + self.schema = _schema.from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + bsfs:Tag rdfs:subClassOf bsfs:Node . + xsd:string rdfs:subClassOf bsfs:Literal . + + bse:filename rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Node ; + rdfs:range xsd:string . + + bse:tag rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range bsfs:Tag . + + bse:label rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Tag ; + rdfs:range xsd:string . + + ''') + self.validate = Fetch(self.schema) + + def test_call(self): # tests validate implicitly + # call accepts correct expressions + self.assertTrue(self.validate(self.schema.node(ns.bsfs.Entity), + ast.fetch.Fetch(ns.bse.tag, ast.fetch.Value(ns.bse.label, 'value')))) + self.assertTrue(self.validate(self.schema.node(ns.bsfs.Entity), + ast.fetch.Fetch(ns.bse.tag, ast.fetch.This('this')))) + self.assertTrue(self.validate(self.schema.node(ns.bsfs.Entity), + ast.fetch.This('this'))) + self.assertTrue(self.validate(self.schema.node(ns.bsfs.Entity), + ast.fetch.All(ast.fetch.This('this'), ast.fetch.Node(ns.bse.tag, 'node'), ast.fetch.Value(ns.bse.filename, 'value')))) + # type must be a Node + self.assertRaises(TypeError, self.validate, 1234, ast.fetch.This('this')) + self.assertRaises(TypeError, self.validate, 'foobar', ast.fetch.This('this')) + self.assertRaises(TypeError, self.validate, self.schema.literal(ns.bsfs.Literal), ast.fetch.This('this')) + # type must be in the schema + self.assertRaises(errors.ConsistencyError, self.validate, self.schema.node(ns.bsfs.Node).child(ns.bsfs.Invalid), + ast.fetch.FetchExpression()) + # expression must be a fetch expression + self.assertRaises(TypeError, self.validate, self.schema.node(ns.bsfs.Entity), 1234) + self.assertRaises(TypeError, self.validate, self.schema.node(ns.bsfs.Entity), 'hello') + self.assertRaises(TypeError, self.validate, self.schema.node(ns.bsfs.Entity), ast.filter.FilterExpression()) + # expression must be valid + self.assertRaises(errors.ConsistencyError, self.validate, self.schema.node(ns.bsfs.Entity), + ast.fetch.Fetch(ns.bse.tag, ast.fetch.Node(ns.bse.label, 'node'))) + self.assertRaises(errors.ConsistencyError, self.validate, self.schema.node(ns.bsfs.Entity), + ast.fetch.Value(ns.bse.tag, 'value')) + + def test_routing(self): + # Node passes _branch, _named, and _node checks + self.assertRaises(errors.ConsistencyError, self.validate._parse_fetch_expression, self.schema.node(ns.bsfs.Node), + ast.fetch.Node(ns.bse.tag, 'node')) # fails in _branch + self.assertRaises(errors.BackendError, self.validate._parse_fetch_expression, self.schema.node(ns.bsfs.Entity), + ast.fetch.Node(ns.bse.tag, '')) # fails in _named + self.assertRaises(errors.ConsistencyError, self.validate._parse_fetch_expression, self.schema.node(ns.bsfs.Entity), + ast.fetch.Node(ns.bse.label, 'node')) # fails in _node + # Value passes _branch, _named, and _value checks + self.assertRaises(errors.ConsistencyError, self.validate._parse_fetch_expression, self.schema.node(ns.bsfs.Node), + ast.fetch.Value(ns.bse.label, 'value')) # fails in _branch + self.assertRaises(errors.BackendError, self.validate._parse_fetch_expression, self.schema.node(ns.bsfs.Entity), + ast.fetch.Value(ns.bse.filename, '')) # fails in _named + self.assertRaises(errors.ConsistencyError, self.validate._parse_fetch_expression, self.schema.node(ns.bsfs.Entity), + ast.fetch.Value(ns.bse.tag, 'value')) # fails in _value + # Fetch passes _branch and _fetch checks + self.assertRaises(errors.ConsistencyError, self.validate._parse_fetch_expression, self.schema.node(ns.bsfs.Node), + ast.fetch.Fetch(ns.bse.tag, ast.fetch.This('this'))) # fails in _branch + self.assertRaises(errors.ConsistencyError, self.validate._parse_fetch_expression, self.schema.node(ns.bsfs.Node), + ast.fetch.Fetch(ns.bse.filename, ast.fetch.This('this'))) # fails in _fetch + # invalid expressions cannot be parsed + type_ = self.schema.node(ns.bsfs.Node) + self.assertRaises(errors.BackendError, self.validate._parse_fetch_expression, type_, + ast.filter.FilterExpression()) + self.assertRaises(errors.BackendError, self.validate._parse_fetch_expression, type_, + 1234) + self.assertRaises(errors.BackendError, self.validate._parse_fetch_expression, type_, + 'hello world') + + def test_all(self): + # all accepts correct expressions + self.assertIsNone(self.validate._all(self.schema.node(ns.bsfs.Entity), + ast.fetch.All(ast.fetch.Value(ns.bse.filename, 'value'), ast.fetch.Node(ns.bse.tag, 'node')))) + # child expressions must be valid + self.assertRaises(errors.ConsistencyError, self.validate._all, self.schema.node(ns.bsfs.Entity), + ast.fetch.All(ast.fetch.Value(ns.bse.tag, 'value'))) + self.assertRaises(errors.ConsistencyError, self.validate._all, self.schema.node(ns.bsfs.Entity), + ast.fetch.All(ast.fetch.Value(ns.bse.filename, 'value'), ast.fetch.Node(ns.bse.filename, 'node'))) + self.assertRaises(errors.ConsistencyError, self.validate._all, self.schema.node(ns.bsfs.Entity), + ast.fetch.All(ast.fetch.Value(ns.bse.tag, 'value'), ast.fetch.Node(ns.bse.tag, 'node'))) + self.assertRaises(errors.ConsistencyError, self.validate._all, self.schema.node(ns.bsfs.Entity), + ast.fetch.All(ast.fetch.Value(ns.bse.tag, 'value'), ast.fetch.Node(ns.bse.filename, 'node'))) + + def test_branch(self): + # branch accepts correct expressions + self.assertIsNone(self.validate._branch(self.schema.node(ns.bsfs.Entity), + ast.fetch._Branch(ns.bse.filename))) + self.assertIsNone(self.validate._branch(self.schema.node(ns.bsfs.Entity), + ast.fetch.Fetch(ns.bse.tag, ast.fetch.This('this')))) + self.assertIsNone(self.validate._branch(self.schema.node(ns.bsfs.Entity), + ast.fetch.Value(ns.bse.filename, 'value'))) + self.assertIsNone(self.validate._branch(self.schema.node(ns.bsfs.Entity), + ast.fetch.Node(ns.bse.tag, 'node'))) + # type must be a node + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.literal(ns.bsfs.Literal), + ast.fetch._Branch(ns.bse.filename)) + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.literal(ns.bsfs.Literal), + ast.fetch.Fetch(ns.bse.tag, ast.fetch.This('this'))) + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.literal(ns.bsfs.Literal), + ast.fetch.Value(ns.bse.filename, 'value')) + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.literal(ns.bsfs.Literal), + ast.fetch.Node(ns.bse.tag, 'node')) + # type must be in the schema + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.node(ns.bsfs.Node).child(ns.bsfs.Invalid), + ast.fetch._Branch(ns.bse.filename)) + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.node(ns.bsfs.Node).child(ns.bsfs.Invalid), + ast.fetch.Fetch(ns.bse.tag, ast.fetch.This('this'))) + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.node(ns.bsfs.Node).child(ns.bsfs.Invalid), + ast.fetch.Value(ns.bse.filename, 'value')) + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.node(ns.bsfs.Node).child(ns.bsfs.Invalid), + ast.fetch.Node(ns.bse.tag, 'node')) + # predicate must be in the schema + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.node(ns.bsfs.Entity), + ast.fetch._Branch(ns.bse.invalid)) + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.node(ns.bsfs.Entity), + ast.fetch.Fetch(ns.bse.invalid, ast.fetch.This('this'))) + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.node(ns.bsfs.Entity), + ast.fetch.Value(ns.bse.invalid, 'value')) + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.node(ns.bsfs.Entity), + ast.fetch.Node(ns.bse.invalid, 'node')) + # predicate's domain must be related to the type + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.node(ns.bsfs.Entity), + ast.fetch._Branch(ns.bse.label)) + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.node(ns.bsfs.Entity), + ast.fetch.Fetch(ns.bse.label, ast.fetch.This('this'))) + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.node(ns.bsfs.Entity), + ast.fetch.Node(ns.bse.label, 'node')) + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.node(ns.bsfs.Entity), + ast.fetch.Value(ns.bse.label, 'value')) + # predicate's domain cannot be a supertype + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.node(ns.bsfs.Node), + ast.fetch._Branch(ns.bse.tag)) + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.node(ns.bsfs.Node), + ast.fetch.Fetch(ns.bse.tag, ast.fetch.This('this'))) + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.node(ns.bsfs.Node), + ast.fetch.Node(ns.bse.tag, 'node')) + self.assertRaises(errors.ConsistencyError, self.validate._branch, self.schema.node(ns.bsfs.Node), + ast.fetch.Value(ns.bse.tag, 'value')) + # predicate's domain can be a subtype + self.assertIsNone(self.validate._branch(self.schema.node(ns.bsfs.Entity), + ast.fetch._Branch(ns.bse.filename))) + self.assertIsNone(self.validate._branch(self.schema.node(ns.bsfs.Entity), + ast.fetch.Value(ns.bse.filename, 'value'))) + + def test_fetch(self): + # fetch accepts correct expressions + self.assertIsNone(self.validate._fetch(self.schema.node(ns.bsfs.Node), + ast.fetch.Fetch(ns.bse.tag, ast.fetch.Value(ns.bse.label, 'value')))) + # range must be a node + self.assertRaises(errors.ConsistencyError, self.validate._fetch, self.schema.node(ns.bsfs.Node), + ast.fetch.Fetch(ns.bse.filename, ast.fetch.This('this'))) + # child expression must be valid + self.assertRaises(errors.ConsistencyError, self.validate._fetch, self.schema.node(ns.bsfs.Node), + ast.fetch.Fetch(ns.bse.tag, ast.fetch.Node(ns.bse.label, 'node'))) + + def test_named(self): + # named accepts correct expressions + self.assertIsNone(self.validate._named(self.schema.node(ns.bsfs.Entity), + ast.fetch.Node(ns.bse.tag, 'node'))) + self.assertIsNone(self.validate._named(self.schema.node(ns.bsfs.Entity), + ast.fetch.Value(ns.bse.filename, 'value'))) + # name must be non-empty + self.assertRaises(errors.BackendError, self.validate._named, self.schema.node(ns.bsfs.Entity), + ast.fetch.Node(ns.bse.tag, '')) + self.assertRaises(errors.BackendError, self.validate._named, self.schema.node(ns.bsfs.Entity), + ast.fetch.Value(ns.bse.filename, '')) + + def test_node(self): + # node accepts correct expressions + self.assertIsNone(self.validate._node(self.schema.node(ns.bsfs.Entity), + ast.fetch.Node(ns.bse.tag, 'node'))) + # range must be a node + self.assertRaises(errors.ConsistencyError, self.validate._node, self.schema.node(ns.bsfs.Entity), + ast.fetch.Node(ns.bse.filename, 'node')) + + def test_value(self): + # value accepts correct expressions + self.assertIsNone(self.validate._value(self.schema.node(ns.bsfs.Entity), + ast.fetch.Value(ns.bse.filename, 'value'))) + # range must be a literal + self.assertRaises(errors.ConsistencyError, self.validate._value, self.schema.node(ns.bsfs.Entity), + ast.fetch.Value(ns.bse.tag, 'value')) + + def test_this(self): + # this accepts correct expressions + self.assertIsNone(self.validate._this(self.schema.node(ns.bsfs.Entity), ast.fetch.This('this'))) + # type must be a node + self.assertRaises(errors.ConsistencyError, self.validate._this, self.schema.literal(ns.bsfs.Literal), + ast.fetch.This('this')) + self.assertRaises(errors.ConsistencyError, self.validate._this, self.schema.predicate(ns.bsfs.Predicate), + ast.fetch.This('this')) + # type must be in the schema + self.assertRaises(errors.ConsistencyError, self.validate._this, self.schema.node(ns.bsfs.Node).child(ns.bsfs.Invalid), + ast.fetch.This('this')) + # name must be non-empty + self.assertRaises(errors.BackendError, self.validate._this, self.schema.node(ns.bsfs.Entity), ast.fetch.This('')) + + +## main ## + +if __name__ == '__main__': + unittest.main() + +## EOF ## diff --git a/test/schema/test_schema.py b/test/schema/test_schema.py index 888cdca..f52cf95 100644 --- a/test/schema/test_schema.py +++ b/test/schema/test_schema.py @@ -1,16 +1,11 @@ -""" -Part of the tagit test suite. -A copy of the license is provided with the project. -Author: Matthias Baumgartner, 2022 -""" # imports import operator import unittest # bsfs imports from bsfs.namespace import ns -from bsfs.schema import types +from bsfs.schema import types, from_string from bsfs.utils import errors # objects to test @@ -19,6 +14,8 @@ from bsfs.schema.schema import Schema ## code ## +ns.bse = ns.bsfs.Entity() + class TestSchema(unittest.TestCase): def setUp(self): @@ -26,8 +23,9 @@ class TestSchema(unittest.TestCase): prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> prefix xsd: <http://www.w3.org/2001/XMLSchema#> - prefix bsfs: <http://bsfs.ai/schema/> - prefix bse: <http://bsfs.ai/schema/Entity#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + prefix bsl: <https://schema.bsfs.io/core/Literal> bsfs:Entity rdfs:subClassOf bsfs:Node . bsfs:Tag rdfs:subClassOf bsfs:Node . @@ -35,7 +33,8 @@ class TestSchema(unittest.TestCase): bsfs:Unused rdfs:subClassOf bsfs:Node . xsd:string rdfs:subClassOf bsfs:Literal . - xsd:integer rdfs:subClassOf bsfs:Literal . + bsfs:Number rdfs:subClassOf bsfs:Literal . + xsd:integer rdfs:subClassOf bsl:Number . xsd:boolean rdfs:subClassOf bsfs:Literal . bse:tag rdfs:subClassOf bsfs:Predicate ; @@ -55,32 +54,43 @@ class TestSchema(unittest.TestCase): ''' # nodes - self.n_root = types.Node(ns.bsfs.Node, None) - self.n_ent = types.Node(ns.bsfs.Entity, types.Node(ns.bsfs.Node, None)) - self.n_img = types.Node(ns.bsfs.Image, types.Node(ns.bsfs.Entity, types.Node(ns.bsfs.Node, None))) - self.n_tag = types.Node(ns.bsfs.Tag, types.Node(ns.bsfs.Node, None)) - self.n_unused = types.Node(ns.bsfs.Unused, types.Node(ns.bsfs.Node, None)) + self.n_root = types.ROOT_NODE + self.n_ent = self.n_root.child(ns.bsfs.Entity) + self.n_img = self.n_ent.child(ns.bsfs.Image) + self.n_tag = self.n_root.child(ns.bsfs.Tag) + self.n_unused = self.n_root.child(ns.bsfs.Unused) self.nodes = [self.n_root, self.n_ent, self.n_img, self.n_tag, self.n_unused] # literals - self.l_root = types.Literal(ns.bsfs.Literal, None) - self.l_string = types.Literal(ns.xsd.string, types.Literal(ns.bsfs.Literal, None)) - self.l_integer = types.Literal(ns.xsd.integer, types.Literal(ns.bsfs.Literal, None)) - self.l_unused = types.Literal(ns.xsd.boolean, types.Literal(ns.bsfs.Literal, None)) - self.literals = [self.l_root, self.l_string, self.l_integer, self.l_unused] + self.l_root = types.ROOT_LITERAL + self.l_number = types.ROOT_NUMBER + self.l_blob = types.ROOT_BLOB + self.l_array = types.ROOT_ARRAY + self.l_time = types.ROOT_TIME + self.l_string = self.l_root.child(ns.xsd.string) + self.l_integer = self.l_root.child(ns.xsd.integer) + self.l_unused = self.l_root.child(ns.xsd.boolean) + self.f_root = types.ROOT_FEATURE + self.literals = [self.l_root, self.l_array, self.f_root, self.l_number, self.l_time, self.l_string, self.l_integer, self.l_unused, self.l_blob] # predicates - self.p_root = types.Predicate(ns.bsfs.Predicate, None, types.Node(ns.bsfs.Node, None), None, False) - self.p_tag = self.p_root.get_child(ns.bse.tag, self.n_ent, self.n_tag, False) - self.p_group = self.p_tag.get_child(ns.bse.group, self.n_img, self.n_tag, False) - self.p_comment = self.p_root.get_child(ns.bse.comment, self.n_root, self.l_string, True) + self.p_root = types.ROOT_PREDICATE + self.p_tag = self.p_root.child(ns.bse.tag, self.n_ent, self.n_tag, False) + self.p_group = self.p_tag.child(ns.bse.group, self.n_img, self.n_tag, False) + self.p_comment = self.p_root.child(ns.bse.comment, self.n_root, self.l_string, True) self.predicates = [self.p_root, self.p_tag, self.p_group, self.p_comment] def test_construction(self): + # no args yields a minimal schema + schema = Schema() + self.assertSetEqual(set(schema.nodes()), {self.n_root}) + self.assertSetEqual(set(schema.literals()), {self.l_root, self.l_number, self.l_array, self.l_time, self.f_root, self.l_blob}) + self.assertSetEqual(set(schema.predicates()), {self.p_root}) + # nodes and literals are optional schema = Schema(self.predicates) self.assertSetEqual(set(schema.nodes()), {self.n_root, self.n_ent, self.n_img, self.n_tag}) - self.assertSetEqual(set(schema.literals()), {self.l_root, self.l_string}) + self.assertSetEqual(set(schema.literals()), {self.l_root, self.l_string, self.l_number, self.l_time, self.l_array, self.f_root, self.l_blob}) self.assertSetEqual(set(schema.predicates()), set(self.predicates)) # predicates, nodes, and literals are respected @@ -101,19 +111,19 @@ class TestSchema(unittest.TestCase): # literals are complete schema = Schema(self.predicates, self.nodes, None) - self.assertSetEqual(set(schema.literals()), {self.l_root, self.l_string}) + self.assertSetEqual(set(schema.literals()), {self.l_root, self.l_string, self.l_number, self.l_array, self.l_time, self.f_root, self.l_blob}) schema = Schema(self.predicates, self.nodes, []) - self.assertSetEqual(set(schema.literals()), {self.l_root, self.l_string}) + self.assertSetEqual(set(schema.literals()), {self.l_root, self.l_string, self.l_number, self.l_array, self.l_time, self.f_root, self.l_blob}) schema = Schema(self.predicates, self.nodes, [self.l_string]) - self.assertSetEqual(set(schema.literals()), {self.l_root, self.l_string}) + self.assertSetEqual(set(schema.literals()), {self.l_root, self.l_string, self.l_number, self.l_array, self.l_time, self.f_root, self.l_blob}) schema = Schema(self.predicates, self.nodes, [self.l_integer]) - self.assertSetEqual(set(schema.literals()), {self.l_root, self.l_string, self.l_integer}) + self.assertSetEqual(set(schema.literals()), {self.l_root, self.l_string, self.l_integer, self.l_number, self.l_array, self.l_time, self.f_root, self.l_blob}) schema = Schema(self.predicates, self.nodes, [self.l_integer, self.l_unused]) self.assertSetEqual(set(schema.literals()), set(self.literals)) # predicates are complete schema = Schema([], self.nodes, self.literals) - self.assertSetEqual(set(schema.predicates()), set()) + self.assertSetEqual(set(schema.predicates()), {self.p_root}) schema = Schema([self.p_group], self.nodes, self.literals) self.assertSetEqual(set(schema.predicates()), {self.p_root, self.p_tag, self.p_group}) schema = Schema([self.p_group, self.p_comment], self.nodes, self.literals) @@ -153,20 +163,27 @@ class TestSchema(unittest.TestCase): self.assertRaises(errors.ConsistencyError, Schema, {}, {types.Node(ns.bsfs.Foo, None)}, {types.Node(ns.bsfs.Foo, None)}) self.assertRaises(errors.ConsistencyError, Schema, - {types.Predicate(ns.bsfs.Foo, None, types.Node(ns.bsfs.Node, None), None, False)}, {}, {types.Node(ns.bsfs.Foo, None)}) + {types.Predicate(ns.bsfs.Foo, None, types.Node(ns.bsfs.Node, None), types.ROOT_VERTEX, False)}, {}, {types.Node(ns.bsfs.Foo, None)}) self.assertRaises(errors.ConsistencyError, Schema, - {types.Predicate(ns.bsfs.Foo, None, types.Node(ns.bsfs.Node, None), None, False)}, {types.Node(ns.bsfs.Foo, None)}, {}) + {types.Predicate(ns.bsfs.Foo, None, types.Node(ns.bsfs.Node, None), types.ROOT_VERTEX, False)}, {types.Node(ns.bsfs.Foo, None)}, {}) self.assertRaises(errors.ConsistencyError, Schema, - {types.Predicate(ns.bsfs.Foo, None, types.Node(ns.bsfs.Node, None), None, False)}, {types.Node(ns.bsfs.Foo, None)}, {types.Node(ns.bsfs.Foo, None)}) + {types.Predicate(ns.bsfs.Foo, None, types.Node(ns.bsfs.Node, None), types.ROOT_VERTEX, False)}, {types.Node(ns.bsfs.Foo, None)}, {types.Node(ns.bsfs.Foo, None)}) + def test_str(self): + # string conversion self.assertEqual(str(Schema([])), 'Schema()') self.assertEqual(str(Schema([], [], [])), 'Schema()') self.assertEqual(str(Schema(self.predicates, self.nodes, self.literals)), 'Schema()') - self.assertEqual(repr(Schema([])), 'Schema([], [], [])') - self.assertEqual(repr(Schema([], [], [])), 'Schema([], [], [])') + # repr conversion with only default nodes, literals, and predicates + n = [ns.bsfs.Node] + l = [ns.bsfs.Literal, ns.bsl.Array, ns.bsl.Array.Feature, ns.bsl.BinaryBlob, ns.bsl.Number, ns.bsl.Time] + p = [ns.bsfs.Predicate] + self.assertEqual(repr(Schema()), f'Schema({n}, {l}, {p})') + self.assertEqual(repr(Schema([], [], [])), f'Schema({n}, {l}, {p})') + # repr conversion n = [ns.bsfs.Entity, ns.bsfs.Image, ns.bsfs.Node, ns.bsfs.Tag, ns.bsfs.Unused] - l = [ns.bsfs.Literal, ns.xsd.boolean, ns.xsd.integer, ns.xsd.string] + l = [ns.xsd.boolean, ns.xsd.integer, ns.xsd.string, ns.bsfs.Literal, ns.bsl.Array, ns.bsl.Array.Feature, ns.bsl.BinaryBlob, ns.bsl.Number, ns.bsl.Time] p = [ns.bse.comment, ns.bse.group, ns.bse.tag, ns.bsfs.Predicate] self.assertEqual(repr(Schema(self.predicates, self.nodes, self.literals)), f'Schema({n}, {l}, {p})') @@ -202,16 +219,16 @@ class TestSchema(unittest.TestCase): self.assertNotEqual(hash(schema), hash(Schema([self.p_group, self.p_tag, self.p_root], self.nodes, self.literals))) self.assertNotEqual(schema, - Schema(self.predicates + [self.p_root.get_child(ns.bse.filesize, self.n_ent, self.l_integer)], self.nodes, self.literals)) + Schema(self.predicates + [self.p_root.child(ns.bse.filesize, self.n_ent, self.l_integer)], self.nodes, self.literals)) self.assertNotEqual(hash(schema), - hash(Schema(self.predicates + [self.p_root.get_child(ns.bse.filesize, self.n_ent, self.l_integer)], self.nodes, self.literals))) + hash(Schema(self.predicates + [self.p_root.child(ns.bse.filesize, self.n_ent, self.l_integer)], self.nodes, self.literals))) def test_order(self): # setup class Foo(): pass - p_foo = self.p_root.get_child(ns.bse.foo, self.n_ent, self.l_string, True) - p_sub = p_foo.get_child(ns.bse.sub, self.n_ent, self.l_string, True) - p_bar = self.p_root.get_child(ns.bse.bar, self.n_ent, self.l_string, True) + p_foo = self.p_root.child(ns.bse.foo, self.n_ent, self.l_string, True) + p_sub = p_foo.child(ns.bse.sub, self.n_ent, self.l_string, True) + p_bar = self.p_root.child(ns.bse.bar, self.n_ent, self.l_string, True) # can only compare schema to other schema # < @@ -258,11 +275,11 @@ class TestSchema(unittest.TestCase): self.assertTrue(operator.lt(Schema({self.p_tag}), Schema({self.p_group}))) self.assertTrue(operator.le(Schema({self.p_tag}), Schema({self.p_group}))) # subset considers differences in predicates and literals - self.assertTrue(operator.lt(Schema.Empty(), Schema({self.p_comment}))) + self.assertTrue(operator.lt(Schema(), Schema({self.p_comment}))) # subset considers differences in predicates, nodes, and literals - self.assertTrue(operator.lt(Schema({}), Schema.Empty())) - self.assertTrue(operator.lt(Schema({self.p_tag}), Schema.from_string(self.schema_str))) - self.assertTrue(operator.le(Schema({self.p_tag}), Schema.from_string(self.schema_str))) + self.assertTrue(operator.le(Schema({}), Schema())) + self.assertTrue(operator.lt(Schema({self.p_tag}), from_string(self.schema_str))) + self.assertTrue(operator.le(Schema({self.p_tag}), from_string(self.schema_str))) self.assertFalse(operator.lt(Schema({self.p_comment}), Schema({self.p_tag}))) self.assertFalse(operator.le(Schema({self.p_comment}), Schema({self.p_tag}))) @@ -280,54 +297,54 @@ class TestSchema(unittest.TestCase): self.assertTrue(operator.gt(Schema({self.p_group}), Schema({self.p_tag}))) self.assertTrue(operator.ge(Schema({self.p_group}), Schema({self.p_tag}))) # superset considers differences in predicates and literals - self.assertTrue(operator.gt(Schema({self.p_comment}), Schema.Empty())) + self.assertTrue(operator.gt(Schema({self.p_comment}), Schema())) # superset considers differences in predicates, nodes, and literals - self.assertTrue(operator.gt(Schema.Empty(), Schema({}))) - self.assertTrue(operator.gt(Schema.from_string(self.schema_str), Schema({self.p_tag}))) - self.assertTrue(operator.ge(Schema.from_string(self.schema_str), Schema({self.p_tag}))) + self.assertTrue(operator.ge(Schema(), Schema({}))) + self.assertTrue(operator.gt(from_string(self.schema_str), Schema({self.p_tag}))) + self.assertTrue(operator.ge(from_string(self.schema_str), Schema({self.p_tag}))) self.assertFalse(operator.gt(Schema({self.p_tag}), Schema({self.p_comment}))) self.assertFalse(operator.ge(Schema({self.p_tag}), Schema({self.p_comment}))) # inconsistent schema cannot be a subset self.assertFalse(operator.le(Schema({p_foo}), Schema({ - self.p_root.get_child(ns.bse.foo, self.n_ent, self.l_integer, True)}))) # inconsistent w.r.t. literal + self.p_root.child(ns.bse.foo, self.n_ent, self.l_integer, True)}))) # inconsistent w.r.t. literal self.assertFalse(operator.le(Schema({p_foo}), Schema({ - self.p_root.get_child(ns.bse.foo, self.n_img, self.l_string, True)}))) # inconsistent w.r.t. node + self.p_root.child(ns.bse.foo, self.n_img, self.l_string, True)}))) # inconsistent w.r.t. node self.assertFalse(operator.le(Schema({p_foo}), Schema({ - self.p_root.get_child(ns.bse.foo, self.n_ent, self.l_string, False)}))) # inconsistent w.r.t. unique + self.p_root.child(ns.bse.foo, self.n_ent, self.l_string, False)}))) # inconsistent w.r.t. unique self.assertFalse(operator.le(Schema({}, {self.n_img}), Schema({}, { types.Node(ns.bsfs.Image, types.Node(ns.bsfs.Node, None))}))) self.assertFalse(operator.le(Schema({}, {}, {self.l_integer}), Schema({}, {}, { types.Literal(ns.xsd.integer, types.Literal(ns.xsd.number, types.Literal(ns.bsfs.Literal, None)))}))) # inconsistent schema cannot be a true subset self.assertFalse(operator.lt(Schema({p_foo}), Schema({ - self.p_root.get_child(ns.bse.foo, self.n_ent, self.l_integer, True)}))) # inconsistent w.r.t. literal + self.p_root.child(ns.bse.foo, self.n_ent, self.l_integer, True)}))) # inconsistent w.r.t. literal self.assertFalse(operator.lt(Schema({p_foo}), Schema({ - self.p_root.get_child(ns.bse.foo, self.n_img, self.l_string, True)}))) # inconsistent w.r.t. node + self.p_root.child(ns.bse.foo, self.n_img, self.l_string, True)}))) # inconsistent w.r.t. node self.assertFalse(operator.lt(Schema({p_foo}), Schema({ - self.p_root.get_child(ns.bse.foo, self.n_ent, self.l_string, False)}))) # inconsistent w.r.t. unique + self.p_root.child(ns.bse.foo, self.n_ent, self.l_string, False)}))) # inconsistent w.r.t. unique self.assertFalse(operator.lt(Schema({}, {self.n_img}), Schema({}, { types.Node(ns.bsfs.Image, types.Node(ns.bsfs.Node, None))}))) self.assertFalse(operator.lt(Schema({}, {}, {self.l_integer}), Schema({}, {}, { types.Literal(ns.xsd.integer, types.Literal(ns.xsd.number, types.Literal(ns.bsfs.Literal, None)))}))) # inconsistent schema cannot be a superset self.assertFalse(operator.ge(Schema({p_foo}), Schema({ - self.p_root.get_child(ns.bse.foo, self.n_ent, self.l_integer, True)}))) # inconsistent w.r.t. literal + self.p_root.child(ns.bse.foo, self.n_ent, self.l_integer, True)}))) # inconsistent w.r.t. literal self.assertFalse(operator.ge(Schema({p_foo}), Schema({ - self.p_root.get_child(ns.bse.foo, self.n_img, self.l_string, True)}))) # inconsistent w.r.t. node + self.p_root.child(ns.bse.foo, self.n_img, self.l_string, True)}))) # inconsistent w.r.t. node self.assertFalse(operator.ge(Schema({p_foo}), Schema({ - self.p_root.get_child(ns.bse.foo, self.n_ent, self.l_string, False)}))) # inconsistent w.r.t. unique + self.p_root.child(ns.bse.foo, self.n_ent, self.l_string, False)}))) # inconsistent w.r.t. unique self.assertFalse(operator.ge(Schema({}, {self.n_img}), Schema({}, { types.Node(ns.bsfs.Image, types.Node(ns.bsfs.Node, None))}))) self.assertFalse(operator.ge(Schema({}, {}, {self.l_integer}), Schema({}, {}, { types.Literal(ns.xsd.integer, types.Literal(ns.xsd.number, types.Literal(ns.bsfs.Literal, None)))}))) # inconsistent schema cannot be a true superset self.assertFalse(operator.gt(Schema({p_foo}), Schema({ - self.p_root.get_child(ns.bse.foo, self.n_ent, self.l_integer, True)}))) # inconsistent w.r.t. literal + self.p_root.child(ns.bse.foo, self.n_ent, self.l_integer, True)}))) # inconsistent w.r.t. literal self.assertFalse(operator.gt(Schema({p_foo}), Schema({ - self.p_root.get_child(ns.bse.foo, self.n_img, self.l_string, True)}))) # inconsistent w.r.t. node + self.p_root.child(ns.bse.foo, self.n_img, self.l_string, True)}))) # inconsistent w.r.t. node self.assertFalse(operator.gt(Schema({p_foo}), Schema({ - self.p_root.get_child(ns.bse.foo, self.n_ent, self.l_string, False)}))) # inconsistent w.r.t. unique + self.p_root.child(ns.bse.foo, self.n_ent, self.l_string, False)}))) # inconsistent w.r.t. unique self.assertFalse(operator.gt(Schema({}, {self.n_img}), Schema({}, { types.Node(ns.bsfs.Image, types.Node(ns.bsfs.Node, None))}))) self.assertFalse(operator.gt(Schema({}, {}, {self.l_integer}), Schema({}, {}, { @@ -351,26 +368,26 @@ class TestSchema(unittest.TestCase): # difference does not contain predicates from the RHS diff = Schema({self.p_tag, self.p_comment}).diff(Schema({self.p_group})) self.assertSetEqual(set(diff.nodes), set()) - self.assertSetEqual(set(diff.literals), {self.l_root, self.l_string}) + self.assertSetEqual(set(diff.literals), {self.l_string}) self.assertSetEqual(set(diff.predicates), {self.p_comment}) # difference considers extra nodes and literals diff = Schema({self.p_tag}, {self.n_unused}, {self.l_unused}).diff(Schema({self.p_tag})) self.assertSetEqual(set(diff.nodes), {self.n_unused}) - self.assertSetEqual(set(diff.literals), {self.l_root, self.l_unused}) + self.assertSetEqual(set(diff.literals), {self.l_unused}) self.assertSetEqual(set(diff.predicates), set()) # difference considers inconsistent types diff = Schema({self.p_tag}, {self.n_unused}, {self.l_unused}).diff( Schema({self.p_tag}, {types.Node(ns.bsfs.Unused, None)}, {types.Literal(ns.xsd.boolean, None)})) self.assertSetEqual(set(diff.nodes), {self.n_unused}) - self.assertSetEqual(set(diff.literals), {self.l_root, self.l_unused}) + self.assertSetEqual(set(diff.literals), {self.l_unused}) self.assertSetEqual(set(diff.predicates), set()) # __sub__ is an alias for diff diff = Schema({self.p_comment}, {self.n_unused}, {self.l_unused}) - Schema({self.p_group}) self.assertSetEqual(set(diff.nodes), {self.n_unused}) - self.assertSetEqual(set(diff.literals), {self.l_root, self.l_string, self.l_unused}) + self.assertSetEqual(set(diff.literals), {self.l_string, self.l_unused}) self.assertSetEqual(set(diff.predicates), {self.p_comment}) # __sub__ only accepts Schema instances class Foo(): pass @@ -547,196 +564,6 @@ class TestSchema(unittest.TestCase): self.assertFalse(schema.has_predicate(ns.bse.mimetype)) self.assertFalse(schema.has_predicate(self.p_root)) - def test_empty(self): - self.assertEqual(Schema.Empty(), Schema( - [types.Predicate(ns.bsfs.Predicate, None, types.Node(ns.bsfs.Node, None), None, False)], - [types.Node(ns.bsfs.Node, None)], - [types.Literal(ns.bsfs.Literal, None)], - )) - - def test_from_string(self): - # from_string creates a schema - self.assertEqual( - Schema(self.predicates, self.nodes, self.literals), - Schema.from_string(self.schema_str)) - - # schema contains at least the root types - self.assertEqual(Schema.from_string(''), Schema({self.p_root}, {self.n_root}, {self.l_root})) - - # custom example - self.assertEqual( - Schema({types.Predicate(ns.bsfs.Predicate, None, self.n_root, None, False).get_child( - ns.bse.filename, self.n_ent, self.l_string, False)}), - Schema.from_string(''' - prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> - prefix xsd: <http://www.w3.org/2001/XMLSchema#> - prefix bsfs: <http://bsfs.ai/schema/> - prefix bse: <http://bsfs.ai/schema/Entity#> - - bsfs:Entity rdfs:subClassOf bsfs:Node . - xsd:string rdfs:subClassOf bsfs:Literal . - - bse:filename rdfs:subClassOf bsfs:Predicate ; - rdfs:domain bsfs:Entity ; - rdfs:range xsd:string ; - bsfs:unique "false"^^xsd:boolean . - ''')) - - # all nodes must be defined - self.assertRaises(errors.ConsistencyError, Schema.from_string, ''' - prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> - prefix xsd: <http://www.w3.org/2001/XMLSchema#> - prefix bsfs: <http://bsfs.ai/schema/> - prefix bse: <http://bsfs.ai/schema/Entity#> - - xsd:string rdfs:subClassOf bsfs:Literal . - - bse:filename rdfs:subClassOf bsfs:Predicate ; - rdfs:domain bsfs:Entity ; - rdfs:range xsd:string ; - bsfs:unique "false"^^xsd:boolean . - ''') - - # all literals must be defined - self.assertRaises(errors.ConsistencyError, Schema.from_string, ''' - prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> - prefix xsd: <http://www.w3.org/2001/XMLSchema#> - prefix bsfs: <http://bsfs.ai/schema/> - prefix bse: <http://bsfs.ai/schema/Entity#> - - bsfs:Entity rdfs:subClassOf bsfs:Node . - - bse:filename rdfs:subClassOf bsfs:Predicate ; - rdfs:domain bsfs:Entity ; - rdfs:range xsd:string ; - bsfs:unique "false"^^xsd:boolean . - ''') - - # must not have circular dependencies - self.assertRaises(errors.ConsistencyError, Schema.from_string, ''' - prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> - prefix bsfs: <http://bsfs.ai/schema/> - bsfs:Entity rdfs:subClassOf bsfs:Node . - # ah, a nice circular dependency - bsfs:Entity rdfs:subClassOf bsfs:Document . - bsfs:Document rdfs:subClassOf bsfs:Entity . - bsfs:PDF rdfs:subClassOf bsfs:Document . - ''') - - # range must be a node or literal - self.assertRaises(errors.ConsistencyError, Schema.from_string, ''' - prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> - prefix xsd: <http://www.w3.org/2001/XMLSchema#> - prefix bsfs: <http://bsfs.ai/schema/> - prefix bse: <http://bsfs.ai/schema/Entity#> - - bsfs:Entity rdfs:subClassOf bsfs:Node . - - bse:filename rdfs:subClassOf bsfs:Predicate ; - rdfs:domain bsfs:Entity ; - rdfs:range xsd:string ; - bsfs:unique "false"^^xsd:boolean . - ''') - self.assertRaises(errors.ConsistencyError, Schema.from_string, ''' - prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> - prefix xsd: <http://www.w3.org/2001/XMLSchema#> - prefix bsfs: <http://bsfs.ai/schema/> - prefix bse: <http://bsfs.ai/schema/Entity#> - - bsfs:Entity rdfs:subClassOf bsfs:Node . - - bse:filename rdfs:subClassOf bsfs:Predicate ; - rdfs:domain bsfs:Entity ; - rdfs:range bsfs:Foo ; - bsfs:unique "false"^^xsd:boolean . - ''') - self.assertRaises(errors.ConsistencyError, Schema.from_string, ''' - prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> - prefix xsd: <http://www.w3.org/2001/XMLSchema#> - prefix bsfs: <http://bsfs.ai/schema/> - prefix bse: <http://bsfs.ai/schema/Entity#> - - bsfs:Entity rdfs:subClassOf bsfs:Node . - - bse:filename rdfs:subClassOf bsfs:Predicate ; - rdfs:domain bsfs:Entity ; - rdfs:range bsfs:Predicate ; - bsfs:unique "false"^^xsd:boolean . - ''') - - # must be consistent - self.assertRaises(errors.ConsistencyError, Schema.from_string, ''' - prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> - prefix xsd: <http://www.w3.org/2001/XMLSchema#> - prefix bsfs: <http://bsfs.ai/schema/> - - bsfs:Entity rdfs:subClassOf bsfs:Node . - bsfs:Document rdfs:subClassOf bsfs:Node . - bsfs:Document rdfs:subClassOf bsfs:Entity. - ''') - self.assertRaises(errors.ConsistencyError, Schema.from_string, ''' - prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> - prefix xsd: <http://www.w3.org/2001/XMLSchema#> - prefix bsfs: <http://bsfs.ai/schema/> - - xsd:string rdfs:subClassOf bsfs:Literal . - xsd:name rdfs:subClassOf bsfs:Literal . - xsd:name rdfs:subClassOf xsd:string . - ''') - self.assertRaises(errors.ConsistencyError, Schema.from_string, ''' - prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> - prefix xsd: <http://www.w3.org/2001/XMLSchema#> - prefix bsfs: <http://bsfs.ai/schema/> - prefix bse: <http://bsfs.ai/schema/Entity#> - - bsfs:Entity rdfs:subClassOf bsfs:Node . - - bse:foo rdfs:subClassOf bsfs:Predicate ; - rdfs:domain bsfs:Node ; - rdfs:range bsfs:Node ; - bsfs:unique "false"^^xsd:boolean . - - bse:foo rdfs:subClassOf bsfs:Predicate ; - rdfs:domain bsfs:Entity . - - ''') - self.assertRaises(errors.ConsistencyError, Schema.from_string, ''' - prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> - prefix xsd: <http://www.w3.org/2001/XMLSchema#> - prefix bsfs: <http://bsfs.ai/schema/> - prefix bse: <http://bsfs.ai/schema/Entity#> - - bsfs:Entity rdfs:subClassOf bsfs:Node . - - bse:foo rdfs:subClassOf bsfs:Predicate ; - rdfs:domain bsfs:Node ; - rdfs:range bsfs:Node ; - bsfs:unique "false"^^xsd:boolean . - - bse:foo rdfs:subClassOf bsfs:Predicate ; - rdfs:range bsfs:Entity . - - ''') - self.assertRaises(errors.ConsistencyError, Schema.from_string, ''' - prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> - prefix xsd: <http://www.w3.org/2001/XMLSchema#> - prefix bsfs: <http://bsfs.ai/schema/> - prefix bse: <http://bsfs.ai/schema/Entity#> - - bsfs:Entity rdfs:subClassOf bsfs:Node . - - bse:foo rdfs:subClassOf bsfs:Predicate ; - rdfs:domain bsfs:Node ; - rdfs:range bsfs:Node ; - bsfs:unique "false"^^xsd:boolean . - - bse:foo rdfs:subClassOf bsfs:Predicate ; - bsfs:unique "true"^^xsd:boolean . - - ''') - - - ## main ## if __name__ == '__main__': diff --git a/test/schema/test_serialize.py b/test/schema/test_serialize.py new file mode 100644 index 0000000..7d5d3ae --- /dev/null +++ b/test/schema/test_serialize.py @@ -0,0 +1,1048 @@ + +# imports +import re +import unittest + +# bsfs imports +from bsfs.namespace import ns +from bsfs.schema import Schema, types +from bsfs.utils import errors, URI + +# objects to test +from bsfs.schema.serialize import from_string, to_string + + +## code ## + +ns.bse = ns.bsfs.Entity() + +class TestFromString(unittest.TestCase): + + def test_empty(self): + # schema contains at least the root types + self.assertEqual(from_string(''), Schema()) + + + def test_circular_dependency(self): + # must not have circular dependencies + self.assertRaises(errors.ConsistencyError, from_string, ''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix bsfs: <https://schema.bsfs.io/core/> + bsfs:Entity rdfs:subClassOf bsfs:Node . + # ah, a nice circular dependency + bsfs:Entity rdfs:subClassOf bsfs:Document . + bsfs:Document rdfs:subClassOf bsfs:Entity . + bsfs:PDF rdfs:subClassOf bsfs:Document . + ''') + + + def test_node(self): + # all nodes must be defined + self.assertRaises(errors.ConsistencyError, from_string, ''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + xsd:string rdfs:subClassOf bsfs:Literal . + + bse:filename rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range xsd:string ; + bsfs:unique "false"^^xsd:boolean . + ''') + + # node definitions must be consistent (cannot re-use a node uri) + self.assertRaises(errors.ConsistencyError, from_string, ''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + bsfs:Document rdfs:subClassOf bsfs:Node . + bsfs:Document rdfs:subClassOf bsfs:Entity . # conflicting parent + ''') + + # additional nodes can be defined + n_unused = types.ROOT_NODE.child(ns.bsfs.unused) + self.assertEqual(Schema({}, {n_unused}), from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bsfs:unused rdfs:subClassOf bsfs:Node . # unused symbol + ''')) + + # a node can have multiple children + n_ent = types.ROOT_NODE.child(ns.bsfs.Entity) + n_tag = types.ROOT_NODE.child(ns.bsfs.Tag) + n_doc = n_ent.child(ns.bsfs.Document) + n_image = n_ent.child(ns.bsfs.Image) + self.assertEqual(Schema({}, {n_ent, n_tag, n_doc, n_image}), from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + # nodes inherit from same parent + bsfs:Entity rdfs:subClassOf bsfs:Node . + bsfs:Tag rdfs:subClassOf bsfs:Node . + + # nodes inherit from same parent + bsfs:Document rdfs:subClassOf bsfs:Entity . + bsfs:Image rdfs:subClassOf bsfs:Entity . + ''')) + + # additional nodes can be defined and used + n_ent = types.ROOT_NODE.child(ns.bsfs.Entity) + l_string = types.ROOT_LITERAL.child(ns.xsd.string) + p_filename = types.ROOT_PREDICATE.child(ns.bse.filename, + n_ent, l_string, False) + self.assertEqual(Schema({p_filename}), from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + xsd:string rdfs:subClassOf bsfs:Literal . + + bse:filename rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range xsd:string ; + bsfs:unique "false"^^xsd:boolean . + ''')) + + # nodes can have annotations + self.assertDictEqual(from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + + ''').node(ns.bsfs.Entity).annotations, {}) + self.assertDictEqual(from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + + bsfs:Entity rdfs:subClassOf bsfs:Node ; + rdfs:label "hello world"^^xsd:string ; + bsfs:foo "1234"^^xsd:integer . + + ''').node(ns.bsfs.Entity).annotations, { + ns.rdfs.label: 'hello world', + ns.bsfs.foo: 1234, + }) + + + def test_literal(self): + # all literals must be defined + self.assertRaises(errors.ConsistencyError, from_string, ''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + + bse:filename rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range xsd:string ; # undefined symbol + bsfs:unique "false"^^xsd:boolean . + ''') + + # literal definitions must be consistent (cannot re-use a literal uri) + self.assertRaises(errors.ConsistencyError, from_string, ''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + + xsd:string rdfs:subClassOf bsfs:Literal . + xsd:name rdfs:subClassOf bsfs:Literal . + xsd:name rdfs:subClassOf xsd:string . # conflicting parent + ''') + + # additional literals can be defined + l_unused = types.ROOT_LITERAL.child(ns.xsd.unused) + self.assertEqual(Schema({}, {}, {l_unused}), from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + xsd:unused rdfs:subClassOf bsfs:Literal . # unused symbol + ''')) + + # a literal can have multiple children + l_string = types.ROOT_LITERAL.child(ns.xsd.string) + l_integer = types.ROOT_NUMBER.child(ns.xsd.integer) + l_unsigned = l_integer.child(ns.xsd.unsigned) + l_signed = l_integer.child(ns.xsd.signed) + self.assertEqual(Schema({}, {}, {l_string, l_integer, l_unsigned, l_signed}), from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + prefix bsl: <https://schema.bsfs.io/core/Literal/> + + # literals inherit from same parent + xsd:string rdfs:subClassOf bsfs:Literal . + bsl:Number rdfs:subClassOf bsfs:Literal . + xsd:integer rdfs:subClassOf bsl:Number . + + # literals inherit from same parent + xsd:unsigned rdfs:subClassOf xsd:integer . + xsd:signed rdfs:subClassOf xsd:integer . + ''')) + + # additional literals can be defined and used + n_ent = types.ROOT_NODE.child(ns.bsfs.Entity) + l_string = types.ROOT_LITERAL.child(ns.xsd.string) + p_filename = types.ROOT_PREDICATE.child(ns.bse.filename, + n_ent, l_string, False) + self.assertEqual(Schema({p_filename}), from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + xsd:string rdfs:subClassOf bsfs:Literal . + + bse:filename rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range xsd:string ; + bsfs:unique "false"^^xsd:boolean . + ''')) + + # literals can have annotations + self.assertDictEqual(from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + + xsd:string rdfs:subClassOf bsfs:Literal . + + ''').literal(ns.xsd.string).annotations, {}) + self.assertDictEqual(from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + + xsd:string rdfs:subClassOf bsfs:Literal ; + rdfs:label "hello world"^^xsd:string ; + bsfs:foo "1234"^^xsd:integer . + + ''').literal(ns.xsd.string).annotations, { + ns.rdfs.label: 'hello world', + ns.bsfs.foo: 1234, + }) + + + def test_predicate(self): + # domain must be defined + self.assertRaises(errors.ConsistencyError, from_string, ''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + xsd:string rdfs:subClassOf bsfs:Literal . + + bse:filename rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; # undefined symbol + rdfs:range xsd:string ; + bsfs:unique "false"^^xsd:boolean . + ''') + # domain cannot be a literal + self.assertRaises(errors.ConsistencyError, from_string, ''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bsfs:Entity rdfs:subClassOf bsfs:Literal . + xsd:string rdfs:subClassOf bsfs:Literal . + + bse:filename rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; # literal instead of node + rdfs:range xsd:string ; + bsfs:unique "false"^^xsd:boolean . + ''') + + # range must be defined + self.assertRaises(errors.ConsistencyError, from_string, ''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + + bse:filename rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range xsd:string ; # undefined symbol + bsfs:unique "false"^^xsd:boolean . + ''') + # range must be defined + self.assertRaises(errors.ConsistencyError, from_string, ''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + + bse:filename rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range bsfs:Foo ; # undefined symbol + bsfs:unique "false"^^xsd:boolean . + ''') + # range must be a node or a literal + self.assertRaises(errors.ConsistencyError, from_string, ''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + + bse:filename rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range bsfs:Predicate ; # invalid symbol + bsfs:unique "false"^^xsd:boolean . + ''') + + # additional predicates can be defined + n_ent = types.ROOT_NODE.child(ns.bsfs.Entity) + l_string = types.ROOT_LITERAL.child(ns.xsd.string) + p_comment = types.ROOT_PREDICATE.child(ns.bse.comment, domain=n_ent, range=l_string, unique=False) + self.assertEqual(Schema({p_comment}), from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + xsd:string rdfs:subClassOf bsfs:Literal . + + bse:comment rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range xsd:string ; + bsfs:unique "false"^^xsd:boolean . + ''')) + + # predicates inherit properties from parents + n_ent = types.ROOT_NODE.child(ns.bsfs.Entity) + l_string = types.ROOT_LITERAL.child(ns.xsd.string) + p_annotation = types.ROOT_PREDICATE.child(ns.bsfs.Annotation, domain=n_ent, range=l_string) + p_comment = p_annotation.child(ns.bse.comment, unique=True) + self.assertEqual(Schema({p_comment}), from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + xsd:string rdfs:subClassOf bsfs:Literal . + + bsfs:Annotation rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range xsd:string . + + bse:comment rdfs:subClassOf bsfs:Annotation ; # inherits domain/range from bsfs:Annotation + bsfs:unique "true"^^xsd:boolean . + ''')) + + # we can define partial predicates (w/o specifying a usable range) + n_ent = types.ROOT_NODE.child(ns.bsfs.Entity) + l_string = types.ROOT_LITERAL.child(ns.xsd.string) + p_annotation = types.ROOT_PREDICATE.child(ns.bsfs.Annotation, domain=n_ent) + p_comment = p_annotation.child(ns.bse.comment, range=l_string, unique=False) + self.assertEqual(Schema({p_comment}), from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + xsd:string rdfs:subClassOf bsfs:Literal . + + bsfs:Annotation rdfs:subClassOf bsfs:Predicate ; # derive predicate w/o setting range + rdfs:domain bsfs:Entity . + + bse:comment rdfs:subClassOf bsfs:Annotation ; # derived predicate w/ setting range + rdfs:range xsd:string ; + bsfs:unique "false"^^xsd:boolean . + ''')) + + # predicate definition can be split across multiple statements. + # statements can be repeated + n_ent = types.ROOT_NODE.child(ns.bsfs.Entity) + p_foo = types.ROOT_PREDICATE.child(ns.bse.foo, domain=n_ent, range=types.ROOT_NODE, unique=True) + self.assertEqual(Schema({p_foo}), from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + + bse:foo rdfs:subClassOf bsfs:Predicate ; + rdfs:range bsfs:Node ; + bsfs:unique "true"^^xsd:boolean . + + bse:foo rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity. + ''')) + + # domain must be a subtype of parent's domain + n_ent = types.ROOT_NODE.child(ns.bsfs.Entity) + n_image = n_ent.child(ns.bsfs.Image) + p_foo = types.ROOT_PREDICATE.child(ns.bse.foo, domain=types.ROOT_NODE) + p_bar = p_foo.child(ns.bse.bar, domain=n_ent) + p_foobar = p_bar.child(ns.bse.foobar, domain=n_image) + self.assertEqual(Schema({p_foobar}), from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + bsfs:Image rdfs:subClassOf bsfs:Entity . + + bse:foo rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Node . + bse:bar rdfs:subClassOf bse:foo ; + rdfs:domain bsfs:Entity . + bse:foobar rdfs:subClassOf bse:bar ; + rdfs:domain bsfs:Image . + ''')) + self.assertRaises(errors.ConsistencyError, from_string, ''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + bsfs:Image rdfs:subClassOf bsfs:Entity . + + bse:foo rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Image . + bse:bar rdfs:subClassOf bse:foo ; + rdfs:domain bsfs:Entity . + bse:foobar rdfs:subClassOf bse:bar ; + rdfs:domain bsfs:Node . + ''') + + # range must be a subtype of parent's range + n_ent = types.ROOT_NODE.child(ns.bsfs.Entity) + n_image = n_ent.child(ns.bsfs.Image) + p_foo = types.ROOT_PREDICATE.child(ns.bse.foo, range=types.ROOT_NODE) + p_bar = p_foo.child(ns.bse.bar, range=n_ent) + p_foobar = p_bar.child(ns.bse.foobar, range=n_image) + self.assertEqual(Schema({p_foobar}), from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + bsfs:Image rdfs:subClassOf bsfs:Entity . + + bse:foo rdfs:subClassOf bsfs:Predicate ; + rdfs:range bsfs:Node . + bse:bar rdfs:subClassOf bse:foo ; + rdfs:range bsfs:Entity . + bse:foobar rdfs:subClassOf bse:bar ; + rdfs:range bsfs:Image . + ''')) + self.assertRaises(errors.ConsistencyError, from_string, ''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + bsfs:Image rdfs:subClassOf bsfs:Entity . + + bse:foo rdfs:subClassOf bsfs:Predicate ; + rdfs:range bsfs:Image . + bse:bar rdfs:subClassOf bse:foo ; + rdfs:range bsfs:Entity . + bse:foobar rdfs:subClassOf bse:bar ; + rdfs:range bsfs:Node . + ''') + + # cannot define the same predicate from multiple parents + self.assertRaises(errors.ConsistencyError, from_string, ''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + bsfs:Annotation rdfs:subClassOf bsfs:Predicate . + + bse:foo rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Node ; + rdfs:range bsfs:Node ; + bsfs:unique "false"^^xsd:boolean . + + bse:foo rdfs:subClassOf bsfs:Annotation ; + rdfs:domain bsfs:Node ; + rdfs:range bsfs:Node ; + bsfs:unique "false"^^xsd:boolean . + + ''') + # cannot assign multiple conflicting domains to the same predicate + self.assertRaises(errors.ConsistencyError, from_string, ''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + + bse:foo rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Node ; + rdfs:range bsfs:Node ; + bsfs:unique "false"^^xsd:boolean . + + bse:foo rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity . # conflicting domain + ''') + # cannot assign multiple conflicting ranges to the same predicate + self.assertRaises(errors.ConsistencyError, from_string, ''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + + bse:foo rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Node ; + rdfs:range bsfs:Node ; + bsfs:unique "false"^^xsd:boolean . + + bse:foo rdfs:subClassOf bsfs:Predicate ; + rdfs:range bsfs:Entity . # conflicting range + ''') + # cannot assign multiple conflicting uniques to the same predicate + self.assertRaises(errors.ConsistencyError, from_string, ''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + + bse:foo rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Node ; + rdfs:range bsfs:Node ; + bsfs:unique "false"^^xsd:boolean . + + bse:foo rdfs:subClassOf bsfs:Predicate ; + bsfs:unique "true"^^xsd:boolean . # conflicting unique + ''') + + # predicates can have annotations + self.assertDictEqual(from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bse:comment rdfs:subClassOf bsfs:Predicate ; + rdfs:range bsfs:Node . + + ''').predicate(ns.bse.comment).annotations, {}) + self.assertDictEqual(from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + + bse:comment rdfs:subClassOf bsfs:Predicate ; + rdfs:range bsfs:Node ; + rdfs:label "hello world"^^xsd:string ; + bsfs:foo "1234"^^xsd:integer . + + ''').predicate(ns.bse.comment).annotations, { + ns.rdfs.label: 'hello world', + ns.bsfs.foo: 1234, + }) + + + def test_feature(self): + # additional features can be defined + f_colors = types.ROOT_FEATURE.child(ns.bsfs.Colors) + self.assertEqual(Schema(literals={f_colors}), from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + prefix bsl: <https://schema.bsfs.io/core/Literal/> + prefix bsa: <https://schema.bsfs.io/core/Literal/Array/> + + bsl:Array rdfs:subClassOf bsfs:Literal . + bsa:Feature rdfs:subClassOf bsl:Array. + + bsfs:Colors rdfs:subClassOf bsa:Feature . + + ''')) + + # features inherit properties from parents + f_colors = types.ROOT_FEATURE.child(ns.bsfs.Colors, dimension=1234, dtype=ns.bsfs.dtype().i32) + f_main_colors = f_colors.child(ns.bsfs.MainColor, distance=ns.bsfs.cosine, dtype=ns.bsfs.dtype().f16) + self.assertEqual(Schema(literals={f_colors, f_main_colors}), from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + prefix bsl: <https://schema.bsfs.io/core/Literal/> + prefix bsa: <https://schema.bsfs.io/core/Literal/Array/> + + bsl:Array rdfs:subClassOf bsfs:Literal . + bsa:Feature rdfs:subClassOf bsl:Array. + + bsfs:Colors rdfs:subClassOf bsa:Feature ; # inherits distance from bsa:Feature + bsfs:dimension "1234"^^xsd:integer ; # overwrites bsa:Feature + bsfs:dtype <https://schema.bsfs.io/core/dtype#i32> . # overwrites bsa:Feature + + bsfs:MainColor rdfs:subClassOf bsfs:Colors ; # inherits dimension from bsfs:Colors + bsfs:distance bsfs:cosine ; # overwrites bsa:Feature + bsfs:dtype <https://schema.bsfs.io/core/dtype#f16> . # overwrites bsfs:Colors + + ''')) + + # feature definition can be split across multiple statements. + # statements can be repeated + f_colors = types.ROOT_FEATURE.child(ns.bsfs.Colors, dimension=1234, dtype=ns.bsfs.dtype().f32) + self.assertEqual(Schema(literals={f_colors}), from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + prefix bsl: <https://schema.bsfs.io/core/Literal/> + prefix bsa: <https://schema.bsfs.io/core/Literal/Array/> + + bsl:Array rdfs:subClassOf bsfs:Literal . + bsa:Feature rdfs:subClassOf bsl:Array. + + bsfs:Colors rdfs:subClassOf bsa:Feature ; + bsfs:dimension "1234"^^xsd:integer . + + bsfs:Colors rdfs:subClassOf bsa:Feature ; + bsfs:dimension "1234"^^xsd:integer ; # non-conflicting repetition + bsfs:dtype <https://schema.bsfs.io/core/dtype#f32> . + ''')) + + # cannot define the same feature from multiple parents + self.assertRaises(errors.ConsistencyError, from_string, ''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + prefix bsl: <https://schema.bsfs.io/core/Literal/> + prefix bsa: <https://schema.bsfs.io/core/Literal/Array/> + + bsl:Array rdfs:subClassOf bsfs:Literal . + bsa:Feature rdfs:subClassOf bsl:Array. + bsfs:ColorSpace rdfs:subClassOf bsa:Feature . + + bsfs:Colors rdfs:subClassOf bsa:Feature . + bsfs:Colors rdfs:subClassOf bsfs:ColorSpace . + + ''') + # cannot assign multiple conflicting dimensions to the same feature + self.assertRaises(errors.ConsistencyError, from_string, ''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + prefix bsl: <https://schema.bsfs.io/core/Literal/> + prefix bsa: <https://schema.bsfs.io/core/Literal/Array/> + + bsl:Array rdfs:subClassOf bsfs:Literal . + bsa:Feature rdfs:subClassOf bsl:Array. + + bsfs:Colors rdfs:subClassOf bsa:Feature ; + bsfs:dimension "1234"^^xsd:integer . + + bsfs:Colors rdfs:subClassOf bsa:Feature ; + bsfs:dimension "4321"^^xsd:integer . # conflicting dimension + + ''') + # cannot assign multiple conflicting dtypes to the same feature + self.assertRaises(errors.ConsistencyError, from_string, ''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + prefix bsl: <https://schema.bsfs.io/core/Literal/> + prefix bsa: <https://schema.bsfs.io/core/Literal/Array/> + + bsl:Array rdfs:subClassOf bsfs:Literal . + bsa:Feature rdfs:subClassOf bsl:Array. + + bsfs:Colors rdfs:subClassOf bsa:Feature ; + bsfs:dtype <https://schema.bsfs.io/core/dtype#i32> . + + bsfs:Colors rdfs:subClassOf bsa:Feature ; + bsfs:dtype <https://schema.bsfs.io/core/dtype#f16> . # conflicting dtype + ''') + # cannot assign multiple conflicting distance metrics to the same feature + self.assertRaises(errors.ConsistencyError, from_string, ''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + prefix bsl: <https://schema.bsfs.io/core/Literal/> + prefix bsa: <https://schema.bsfs.io/core/Literal/Array/> + + bsl:Array rdfs:subClassOf bsfs:Literal . + bsa:Feature rdfs:subClassOf bsl:Array. + + bsfs:Colors rdfs:subClassOf bsa:Feature ; + bsfs:distance bsfs:euclidean . + + bsfs:Colors rdfs:subClassOf bsa:Feature ; + bsfs:distance bsfs:cosine . # conflicting distance + ''') + + # features can have annotations + self.assertDictEqual(from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + prefix bsl: <https://schema.bsfs.io/core/Literal/> + prefix bsa: <https://schema.bsfs.io/core/Literal/Array/> + + bsl:Array rdfs:subClassOf bsfs:Literal . + bsa:Feature rdfs:subClassOf bsl:Array. + + bsfs:Colors rdfs:subClassOf bsa:Feature ; + bsfs:dimension "1234"^^xsd:integer . + + ''').literal(ns.bsfs.Colors).annotations, {}) + self.assertDictEqual(from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + prefix bsl: <https://schema.bsfs.io/core/Literal/> + prefix bsa: <https://schema.bsfs.io/core/Literal/Array/> + + bsl:Array rdfs:subClassOf bsfs:Literal . + bsa:Feature rdfs:subClassOf bsl:Array. + + bsfs:Colors rdfs:subClassOf bsa:Feature ; + bsfs:dimension "1234"^^xsd:integer ; + rdfs:label "hello world"^^xsd:string ; + bsfs:foo "1234"^^xsd:integer . + + ''').literal(ns.bsfs.Colors).annotations, { + ns.rdfs.label: 'hello world', + ns.bsfs.foo: 1234, + }) + + + def test_integration(self): + # nodes + n_ent = types.ROOT_NODE.child(ns.bsfs.Entity) + n_tag = types.ROOT_NODE.child(ns.bsfs.Tag) + n_image = n_ent.child(ns.bsfs.Image) + # literals + l_string = types.ROOT_LITERAL.child(ns.xsd.string) + l_array = types.ROOT_LITERAL.child(ns.bsfs.array) + l_integer = types.ROOT_NUMBER.child(ns.xsd.integer) + l_boolean = types.ROOT_LITERAL.child(ns.xsd.boolean) + # predicates + p_annotation = types.ROOT_PREDICATE.child(ns.bsfs.Annotation) + p_tag = types.ROOT_PREDICATE.child(ns.bse.tag, domain=n_ent, range=n_tag) + p_group = p_tag.child(ns.bse.group, domain=n_image, unique=True) + p_comment = p_annotation.child(ns.bse.comment, range=l_string) + # features + f_colors = types.ROOT_FEATURE.child(URI('https://schema.bsfs.io/core/Feature/colors_spatial'), + dtype=ns.bsfs.dtype().f16, distance=ns.bsfs.euclidean) + f_colors1234 = f_colors.child(URI('https://schema.bsfs.io/core/Feature/colors_spatial#1234'), dimension=1024) + f_colors4321 = f_colors.child(URI('https://schema.bsfs.io/core/Feature/colors_spatial#4321'), dimension=2048) + # schema + ref = Schema( + {p_annotation, p_tag, p_group, p_comment}, + {n_ent, n_tag, n_image}, + {l_string, l_integer, l_boolean, f_colors, f_colors1234, f_colors4321}) + # load from string + gen = from_string(''' + # generic prefixes + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + + # bsfs prefixes + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + prefix bsl: <https://schema.bsfs.io/core/Literal/> + prefix bsa: <https://schema.bsfs.io/core/Literal/Array/> + + # nodes + bsfs:Entity rdfs:subClassOf bsfs:Node ; + rdfs:label "Principal node"^^xsd:string . + bsfs:Tag rdfs:subClassOf bsfs:Node ; + rdfs:label "Tag"^^xsd:string . + bsfs:Image rdfs:subClassOf bsfs:Entity . + + # literals + xsd:string rdfs:subClassOf bsfs:Literal ; + rdfs:label "A sequence of characters"^^xsd:string . + bsl:Array rdfs:subClassOf bsfs:Literal . + bsa:Feature rdfs:subClassOf bsl:Array. + bsl:Number rdfs:subClassOf bsfs:Literal . + xsd:integer rdfs:subClassOf bsl:Number . + xsd:boolean rdfs:subClassOf bsfs:Literal . + + + # abstract predicates + bsfs:Annotation rdfs:subClassOf bsfs:Predicate ; + rdfs:label "node annotation"^^xsd:string . + + # feature instances + <https://schema.bsfs.io/core/Feature/colors_spatial> rdfs:subClassOf bsa:Feature ; + bsfs:dtype <https://schema.bsfs.io/core/dtype#f16> ; + bsfs:distance bsfs:euclidean ; + # annotations + rdfs:label "ColorsSpatial instances. Dimension depends on instance."^^xsd:string ; + bsfs:first_arg "1234"^^xsd:integer ; + bsfs:second_arg "hello world"^^xsd:string . + + <https://schema.bsfs.io/core/Feature/colors_spatial#1234> rdfs:subClassOf <https://schema.bsfs.io/core/Feature/colors_spatial> ; + bsfs:dimension "1024"^^xsd:integer ; + rdfs:label "Main colors spatial instance"^^xsd:string . + + <https://schema.bsfs.io/core/Feature/colors_spatial#4321> rdfs:subClassOf <https://schema.bsfs.io/core/Feature/colors_spatial> ; + bsfs:dimension "2048"^^xsd:integer . + + # predicate instances + bse:tag rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range bsfs:Tag ; + bsfs:unique "false"^^xsd:boolean ; + # annotations + rdfs:label "connect entity to a tag"^^xsd:string . + + bse:group rdfs:subClassOf bse:tag ; # subtype of another predicate + rdfs:domain bsfs:Image ; + bsfs:unique "true"^^xsd:boolean . + + bse:comment rdfs:subClassOf bsfs:Annotation ; # subtype of abstract predicate + rdfs:domain bsfs:Node ; + rdfs:range xsd:string ; + bsfs:unique "false"^^xsd:boolean . + + ''') + # schemas are equal + self.assertEqual(ref, gen) + # check annotations + self.assertDictEqual(gen.node(ns.bsfs.Entity).annotations, {ns.rdfs.label: 'Principal node'}) + self.assertDictEqual(gen.node(ns.bsfs.Tag).annotations, {ns.rdfs.label: 'Tag'}) + self.assertDictEqual(gen.literal(ns.xsd.string).annotations, {ns.rdfs.label: 'A sequence of characters'}) + self.assertDictEqual(gen.predicate(ns.bsfs.Annotation).annotations, {ns.rdfs.label: 'node annotation'}) + self.assertDictEqual(gen.literal(URI('https://schema.bsfs.io/core/Feature/colors_spatial')).annotations, { + ns.rdfs.label: 'ColorsSpatial instances. Dimension depends on instance.', + ns.bsfs.first_arg: 1234, + ns.bsfs.second_arg: 'hello world', + }) + self.assertDictEqual(gen.literal(URI('https://schema.bsfs.io/core/Feature/colors_spatial#1234')).annotations, { + ns.rdfs.label: 'Main colors spatial instance'}) + self.assertDictEqual(gen.predicate(ns.bse.tag).annotations, {ns.rdfs.label: 'connect entity to a tag'}) + + # blank nodes result in an error + self.assertRaises(errors.BackendError, from_string, ''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix bsfs: <https://schema.bsfs.io/core/> + bsfs:Entity rdfs:subClassOf bsfs:Node ; + bsfs:foo _:bar . + ''') + + +class TestToString(unittest.TestCase): + + def test_empty(self): + self.assertEqual(Schema(), from_string(to_string(Schema()))) + + def test_parse(self): + schema = Schema() + schema._nodes[ns.bsfs.Invalid] = 123 # NOTE: Access protected to force an invalid schema + self.assertRaises(TypeError, to_string, schema) + + def test_literal(self): + # root literals + l_str = types.ROOT_LITERAL.child(ns.xsd.string) + # derived literals + l_int = types.ROOT_NUMBER.child(ns.xsd.integer) + l_unsigned = l_int.child(ns.xsd.unsigned) + # create schema + schema = Schema(literals={l_int, l_str, l_unsigned}) + + schema_str = to_string(schema) + # all symbols are serialized + self.assertIn('xsd:string', schema_str) + self.assertIn('xsd:integer', schema_str) + self.assertIn('xsd:unsigned', schema_str) + # unserialize yields the original schema + self.assertEqual(schema, from_string(schema_str)) + + # literals that have no parent are ignored + schema = Schema(literals={types.Literal(ns.bsfs.Invalid, None)}) + self.assertEqual(Schema(), from_string(to_string(schema))) + self.assertNotIn('Invalid', to_string(schema)) + + # literal annotations are serialized + annotations = { + ns.rdfs.label: 'hello world', + ns.schema.description: 'some text', + ns.bsfs.foo: 1234, + ns.bsfs.bar: True, + } + l_str = types.ROOT_LITERAL.child(ns.xsd.string, **annotations) + self.assertDictEqual( + annotations, + from_string(to_string(Schema(literals={l_str}))).literal(ns.xsd.string).annotations) + + + def test_node(self): + # root nodes + n_ent = types.ROOT_NODE.child(ns.bsfs.Entity) + n_tag = types.ROOT_NODE.child(ns.bsfs.Tag) + # derived nodes + n_img = n_ent.child(ns.bsfs.Image) + n_doc = n_ent.child(ns.bsfs.Document) + n_grp = n_tag.child(ns.bsfs.Group) + # create schema + schema = Schema(nodes={n_ent, n_img, n_doc, n_tag, n_grp}) + + schema_str = to_string(schema) + # all symbols are serialized + self.assertIn('bsfs:Entity', schema_str) + self.assertIn('bsfs:Tag', schema_str) + self.assertIn('bsfs:Image', schema_str) + self.assertIn('bsfs:Document', schema_str) + self.assertIn('bsfs:Group', schema_str) + # unserialize yields the original schema + self.assertEqual(schema, from_string(schema_str)) + + # nodes that have no parent are ignored + schema = Schema(nodes={types.Node(ns.bsfs.Invalid, None)}) + self.assertEqual(Schema(), from_string(to_string(schema))) + self.assertNotIn('Invalid', to_string(schema)) + + # node annotations are serialized + annotations = { + ns.rdfs.label: 'hello world', + ns.schema.description: 'some text', + ns.bsfs.foo: 1234, + ns.bsfs.bar: True, + } + n_ent = types.ROOT_NODE.child(ns.bsfs.Entity, **annotations) + self.assertDictEqual( + annotations, + from_string(to_string(Schema(nodes={n_ent}))).node(ns.bsfs.Entity).annotations) + + + def test_predicate(self): + # auxiliary types + n_ent = types.ROOT_NODE.child(ns.bsfs.Entity) + l_str = types.ROOT_LITERAL.child(ns.xsd.string) + # root predicates + p_annotation = types.ROOT_PREDICATE.child(ns.bsfs.Annotation, domain=n_ent) + p_owner = types.ROOT_PREDICATE.child(ns.bse.owner, range=l_str, unique=True) + # derived predicates + p_comment = p_annotation.child(ns.bse.comment, range=l_str) # inherits domain + p_note = p_comment.child(ns.bse.note, unique=True) # inherits domain/range + # create schema + schema = Schema({p_owner, p_comment, p_note}) + + schema_str = to_string(schema) + # all symbols are serialized + self.assertIn('bsfs:Entity', schema_str) + self.assertIn('xsd:string', schema_str) + self.assertIn('bsfs:Annotation', schema_str) + self.assertIn('bse:comment', schema_str) + self.assertIn('bse:owner', schema_str) + self.assertIn('bse:note', schema_str) + # inherited properties are not serialized + self.assertIsNotNone(re.search(r'bse:comment[^\.]*rdfs:range[^\.]', schema_str)) + self.assertIsNone(re.search(r'bse:comment[^\.]*rdfs:domain[^\.]', schema_str)) + #p_note has no domain/range + self.assertIsNone(re.search(r'bse:note[^\.]*rdfs:domain[^\.]', schema_str)) + self.assertIsNone(re.search(r'bse:note[^\.]*rdfs:range[^\.]', schema_str)) + # unserialize yields the original schema + self.assertEqual(schema, from_string(schema_str)) + + # predicate annotations are serialized + annotations = { + ns.rdfs.label: 'hello world', + ns.schema.description: 'some text', + ns.bsfs.foo: 1234, + ns.bsfs.bar: False, + } + p_annotation = types.ROOT_PREDICATE.child(ns.bsfs.Annotation, **annotations) + self.assertDictEqual( + annotations, + from_string(to_string(Schema({p_annotation}))).predicate(ns.bsfs.Annotation).annotations) + + + def test_feature(self): + # root features + f_colors = types.ROOT_FEATURE.child(URI('https://schema.bsfs.io/core/Feature/colors'), + distance=ns.bsfs.cosine) + # derived features + f_colors1234 = f_colors.child(URI('https://schema.bsfs.io/core/Feature/colors#1234'), + dimension=1024) # inherits dtype, distance + f_colors4321 = f_colors.child(URI('https://schema.bsfs.io/core/Feature/colors#4321'), + dimension=2048, distance=ns.bsfs.euclidean) # inherits dtype + # create schema + schema = Schema(literals={f_colors, f_colors1234, f_colors4321}) + + schema_str = to_string(schema) + # all symbols are serialized + self.assertIn('bsl:Array', schema_str) + self.assertIn('<https://schema.bsfs.io/core/Feature/colors', schema_str) + self.assertIn('<https://schema.bsfs.io/core/Feature/colors#1234', schema_str) + self.assertIn('<https://schema.bsfs.io/core/Feature/colors#4321', schema_str) + # inherited properties are not serialized + self.assertIsNotNone(re.search(r'<https://schema.bsfs\.io/core/Feature/colors#1234>.*[^\.]*bsfs:dimension[^\.]', schema_str)) + self.assertIsNone(re.search(r'<https://schema\.bsfs\.io/core/Feature/colors#1234>.*[^\.]*bsfs:dtype[^\.]', schema_str)) + self.assertIsNone(re.search(r'<https://schema\.bsfs\.io/core/Feature/colors#1234>.*[^\.]*bsfs:distance[^\.]', schema_str)) + self.assertIsNotNone(re.search(r'<https://schema\.bsfs\.io/core/Feature/colors#4321>.*[^\.]*bsfs:dimension[^\.]', schema_str)) + self.assertIsNotNone(re.search(r'<https://schema\.bsfs\.io/core/Feature/colors#4321>.*[^\.]*bsfs:distance[^\.]', schema_str)) + self.assertIsNone(re.search(r'<https://schema\.bsfs\.io/core/Feature/colors#4321>.*[^\.]*bsfs:dtype[^\.]', schema_str)) + # unserialize yields the original schema + self.assertEqual(schema, from_string(schema_str)) + + # predicate annotations are serialized + annotations = { + ns.rdfs.label: 'hello world', + ns.schema.description: 'some text', + ns.bsfs.foo: 1234, + ns.bsfs.bar: False, + } + f_colors = types.ROOT_FEATURE.child(URI('https://schema.bsfs.io/core/Feature/colors'), + dtype=ns.bsfs.dtype().f16, distance=ns.bsfs.euclidean, + **annotations) + self.assertDictEqual( + annotations, + from_string(to_string(Schema(literals={f_colors}))).literal(URI('https://schema.bsfs.io/core/Feature/colors')).annotations) + + +## main ## + +if __name__ == '__main__': + unittest.main() + +## EOF ## diff --git a/test/schema/test_types.py b/test/schema/test_types.py index 4a49e6e..9bfa8c5 100644 --- a/test/schema/test_types.py +++ b/test/schema/test_types.py @@ -1,24 +1,23 @@ -""" -Part of the tagit test suite. -A copy of the license is provided with the project. -Author: Matthias Baumgartner, 2022 -""" # imports import operator import unittest # bsfs imports from bsfs.namespace import ns +from bsfs.schema.types import ROOT_PREDICATE, ROOT_VERTEX, ROOT_FEATURE from bsfs.utils import errors # objects to test -from bsfs.schema.types import _Type, _Vertex, Node, Literal, Predicate +from bsfs.schema.types import _Type, Vertex, Node, Literal, Predicate, Feature ## code ## +ns.bse = ns.bsfs.Entity() + class TestType(unittest.TestCase): + def test_parents(self): # create some types fst = _Type('First') @@ -31,7 +30,25 @@ class TestType(unittest.TestCase): self.assertListEqual(list(trd.parents()), [snd, fst]) self.assertListEqual(list(frd.parents()), [trd, snd, fst]) - def test_essentials(self): + def test_annotations(self): + # annotations can be empty + self.assertDictEqual(_Type('Foo', None).annotations, {}) + # annotations are stored + self.assertDictEqual(_Type('Foo', None, foo='bar', bar=123).annotations, { + 'foo': 'bar', + 'bar': 123}) + # comparison ignores annotations + self.assertEqual( + _Type('Foo', None, foo='bar', bar='foo'), + _Type('Foo', None, hello='world', foobar=1234)) + self.assertEqual( + hash(_Type('Foo', None, foo='bar', bar='foo')), + hash(_Type('Foo', None, hello='world', foobar=1234))) + # annotations can be passed to child + self.assertDictEqual(_Type('First', foo='bar').child('Second', bar='foo').annotations, { + 'bar': 'foo'}) + + def test_string_conversion(self): # type w/o parent self.assertEqual(str(_Type('Foo')), '_Type(Foo)') self.assertEqual(repr(_Type('Foo')), '_Type(Foo, None)') @@ -51,14 +68,17 @@ class TestType(unittest.TestCase): self.assertEqual(str(_Type('Foo', SubType('Bar'))), '_Type(Foo)') self.assertEqual(repr(_Type('Foo', SubType('Bar'))), '_Type(Foo, SubType(Bar, None))') - def test_get_child(self): + def test_child(self): # callee is used as parent - self.assertEqual(_Type('First').get_child('Second'), _Type('Second', _Type('First'))) + self.assertEqual(_Type('First').child('Second'), _Type('Second', _Type('First'))) # works with multiple parents - self.assertEqual(_Type('First').get_child('Second').get_child('Third'), _Type('Third', _Type('Second', _Type('First')))) + self.assertEqual(_Type('First').child('Second').child('Third'), _Type('Third', _Type('Second', _Type('First')))) # type persists class Foo(_Type): pass - self.assertEqual(Foo('First').get_child('Second'), Foo('Second', Foo('First'))) + self.assertEqual(Foo('First').child('Second'), Foo('Second', Foo('First'))) + # annotations are respected + self.assertDictEqual(_Type('First', foo='bar').child('Second', bar='foo').annotations, { + 'bar': 'foo'}) def test_equality(self): # equality depends on uri @@ -76,6 +96,13 @@ class TestType(unittest.TestCase): # comparison respects parent self.assertNotEqual(_Type('Foo', _Type('Bar')), _Type('Foo')) self.assertNotEqual(hash(_Type('Foo', _Type('Bar'))), hash(_Type('Foo'))) + # comparison ignores annotations + self.assertEqual( + _Type('Foo', None, foo='bar', bar='foo'), + _Type('Foo', None, hello='world', foobar=1234)) + self.assertEqual( + hash(_Type('Foo', None, foo='bar', bar='foo')), + hash(_Type('Foo', None, hello='world', foobar=1234))) def test_order(self): # create some types. @@ -109,27 +136,43 @@ class TestType(unittest.TestCase): self.assertFalse(bike > bicycle) self.assertFalse(bike >= bicycle) self.assertFalse(bike == bicycle) + + # comparing different classes returns False ... + # ... when classes are hierarchically related class Foo(_Type): pass - foo = Foo(bike.uri, bike.parent) - # cannot compare different types - self.assertRaises(TypeError, operator.lt, foo, bike) - self.assertRaises(TypeError, operator.le, foo, bike) - self.assertRaises(TypeError, operator.gt, foo, bike) - self.assertRaises(TypeError, operator.ge, foo, bike) + foo = Foo('Foo', bike) + self.assertFalse(foo < bike) + self.assertFalse(foo <= bike) + self.assertFalse(foo > bike) + self.assertFalse(foo >= bike) # goes both ways - self.assertRaises(TypeError, operator.lt, bike, foo) - self.assertRaises(TypeError, operator.le, bike, foo) - self.assertRaises(TypeError, operator.gt, bike, foo) - self.assertRaises(TypeError, operator.ge, bike, foo) + self.assertFalse(bike < foo) + self.assertFalse(bike <= foo) + self.assertFalse(bike > foo) + self.assertFalse(bike >= foo) + # ... when classes are unrelated + class Bar(_Type): pass + bar = Bar('Bar', bike) + self.assertFalse(foo < bar) + self.assertFalse(foo <= bar) + self.assertFalse(foo > bar) + self.assertFalse(foo >= bar) + # goes both ways + self.assertFalse(bar < foo) + self.assertFalse(bar <= foo) + self.assertFalse(bar > foo) + self.assertFalse(bar >= foo) + class TestPredicate(unittest.TestCase): def test_construction(self): # domain must be a node self.assertRaises(TypeError, Predicate, ns.bse.foo, 1234, None, True) self.assertRaises(TypeError, Predicate, ns.bse.foo, None, Literal(ns.bsfs.Foo, None), None, True) - # range must be None, a Literal, or a Node + # range must be a Literal, a Node, or the root Vertex + self.assertRaises(TypeError, Predicate, ns.bse.foo, None, Node(ns.bsfs.Node, None), None, True) self.assertRaises(TypeError, Predicate, ns.bse.foo, None, Node(ns.bsfs.Node, None), 1234, True) - self.assertRaises(TypeError, Predicate, ns.bse.foo, None, Node(ns.bsfs.Node, None), _Vertex(ns.bsfs.Foo, None), True) + self.assertRaises(TypeError, Predicate, ns.bse.foo, None, Node(ns.bsfs.Node, None), Vertex(ns.bsfs.Foo, None), True) self.assertRaises(TypeError, Predicate, ns.bse.foo, None, Node(ns.bsfs.Node, None), _Type(ns.bsfs.Foo, None), True) class Foo(): pass self.assertRaises(TypeError, Predicate, ns.bse.foo, None, Node(ns.bsfs.Node, None), Foo(), True) @@ -138,82 +181,160 @@ class TestPredicate(unittest.TestCase): n_root = Node(ns.bsfs.Node, None) n_ent = Node(ns.bsfs.Entity, Node(ns.bsfs.Node, None)) n_tag = Node(ns.bsfs.Tag, Node(ns.bsfs.Tag, None)) - root = Predicate( - uri=ns.bsfs.Predicate, - parent=None, + root = ROOT_PREDICATE + tag = Predicate( + uri=ns.bse.tag, + parent=root, domain=n_root, - range=None, + range=n_tag, unique=False, ) # instance is equal to itself - self.assertEqual(root, root) - self.assertEqual(hash(root), hash(root)) + self.assertEqual(tag, tag) + self.assertEqual(hash(tag), hash(tag)) # instance is equal to a clone - self.assertEqual(root, Predicate(ns.bsfs.Predicate, None, n_root, None, False)) - self.assertEqual(hash(root), hash(Predicate(ns.bsfs.Predicate, None, n_root, None, False))) + self.assertEqual(tag, Predicate(ns.bse.tag, root, n_root, n_tag, False)) + self.assertEqual(hash(tag), hash(Predicate(ns.bse.tag, root, n_root, n_tag, False))) # equality respects uri - self.assertNotEqual(root, Predicate(ns.bsfs.Alternative, None, n_root, None, False)) - self.assertNotEqual(hash(root), hash(Predicate(ns.bsfs.Alternative, None, n_root, None, False))) + self.assertNotEqual(tag, Predicate(ns.bsfs.Alternative, root, n_root, n_tag, False)) + self.assertNotEqual(hash(tag), hash(Predicate(ns.bsfs.Alternative, root, n_root, n_tag, False))) # equality respects parent - self.assertNotEqual(root, Predicate(ns.bsfs.Predicate, n_root, n_root, None, False)) - self.assertNotEqual(hash(root), hash(Predicate(ns.bsfs.Predicate, n_root, n_root, None, False))) + self.assertNotEqual(tag, Predicate(ns.bse.tag, n_root, n_root, n_tag, False)) + self.assertNotEqual(hash(tag), hash(Predicate(ns.bse.tag, n_root, n_root, n_tag, False))) # equality respects domain - self.assertNotEqual(root, Predicate(ns.bsfs.Predicate, None, n_ent, None, False)) - self.assertNotEqual(hash(root), hash(Predicate(ns.bsfs.Predicate, None, n_ent, None, False))) + self.assertNotEqual(tag, Predicate(ns.bse.tag, root, n_ent, n_tag, False)) + self.assertNotEqual(hash(tag), hash(Predicate(ns.bse.tag, root, n_ent, n_tag, False))) # equality respects range - self.assertNotEqual(root, Predicate(ns.bsfs.Predicate, None, n_root, n_root, False)) - self.assertNotEqual(hash(root), hash(Predicate(ns.bsfs.Predicate, None, n_root, n_root, False))) + self.assertNotEqual(tag, Predicate(ns.bse.tag, root, n_root, n_root, False)) + self.assertNotEqual(hash(tag), hash(Predicate(ns.bse.tag, root, n_root, n_root, False))) # equality respects unique - self.assertNotEqual(root, Predicate(ns.bsfs.Predicate, None, n_root, None, True)) - self.assertNotEqual(hash(root), hash(Predicate(ns.bsfs.Predicate, None, n_root, None, True))) + self.assertNotEqual(tag, Predicate(ns.bse.tag, root, n_root, n_tag, True)) + self.assertNotEqual(hash(tag), hash(Predicate(ns.bse.tag, root, n_root, n_tag, True))) - def test_get_child(self): + def test_child(self): n_root = Node(ns.bsfs.Node, None) + l_root = Literal(ns.bsfs.Literal, None) n_ent = Node(ns.bsfs.Entity, Node(ns.bsfs.Node, None)) n_tag = Node(ns.bsfs.Tag, Node(ns.bsfs.Tag, None)) - root = Predicate( - uri=ns.bsfs.Predicate, - parent=None, - domain=n_root, - range=None, - unique=False, - ) + root = ROOT_PREDICATE tag = Predicate( - uri=ns.bsfs.Entity, + uri=ns.bse.tag, parent=root, domain=n_ent, range=n_tag, unique=False, ) + # child returns Predicate + self.assertIsInstance(tag.child(ns.bse.foo), Predicate) # uri is respected - self.assertEqual(ns.bse.foo, tag.get_child(ns.bse.foo).uri) + self.assertEqual(ns.bse.foo, tag.child(ns.bse.foo).uri) # domain is respected dom = Node(ns.bsfs.Image, n_ent) - self.assertEqual(dom, tag.get_child(ns.bse.foo, domain=dom).domain) + self.assertEqual(dom, tag.child(ns.bse.foo, domain=dom).domain) # range is respected rng = Node(ns.bsfs.Group, n_tag) - self.assertEqual(rng, tag.get_child(ns.bse.foo, range=rng).range) + self.assertEqual(rng, tag.child(ns.bse.foo, range=rng).range) # cannot set range to None - self.assertEqual(n_tag, tag.get_child(ns.bse.foo, range=None).range) + self.assertEqual(n_tag, tag.child(ns.bse.foo, range=None).range) # unique is respected - self.assertTrue(tag.get_child(ns.bse.foo, unique=True).unique) + self.assertTrue(tag.child(ns.bse.foo, unique=True).unique) + # annotations are respected + self.assertDictEqual(tag.child(ns.bse.foo, foo='bar', bar=123).annotations, { + 'foo': 'bar', + 'bar': 123, + }) # domain is inherited from parent - self.assertEqual(n_ent, tag.get_child(ns.bse.foo).domain) + self.assertEqual(n_root, root.child(ns.bse.foo).domain) + self.assertEqual(n_ent, tag.child(ns.bse.foo).domain) # range is inherited from parent - self.assertEqual(n_tag, tag.get_child(ns.bse.foo).range) + self.assertEqual(ROOT_VERTEX, root.child(ns.bse.foo).range) + self.assertEqual(n_tag, tag.child(ns.bse.foo).range) # uniqueness is inherited from parent - self.assertFalse(tag.get_child(ns.bse.foo).unique) + self.assertFalse(tag.child(ns.bse.foo).unique) # domain must be subtype of parent's domain - self.assertRaises(errors.ConsistencyError, tag.get_child, ns.bse.foo, domain=n_root) - self.assertRaises(errors.ConsistencyError, tag.get_child, ns.bse.foo, domain=Node(ns.bsfs.Image, n_root)) - # range cannot be None - self.assertRaises(ValueError, root.get_child, ns.bse.foo) + self.assertRaises(errors.ConsistencyError, tag.child, ns.bse.foo, domain=n_root) + self.assertRaises(errors.ConsistencyError, tag.child, ns.bse.foo, domain=Node(ns.bsfs.Image, n_root)) # range must be subtype of parent's range - self.assertRaises(errors.ConsistencyError, tag.get_child, ns.bse.foo, range=n_root) - self.assertRaises(errors.ConsistencyError, tag.get_child, ns.bse.foo, range=Node(ns.bsfs.Image, n_root)) + self.assertRaises(errors.ConsistencyError, tag.child, ns.bse.foo, range=n_root) + self.assertRaises(errors.ConsistencyError, tag.child, ns.bse.foo, range=Node(ns.bsfs.Image, n_root)) + self.assertRaises(errors.ConsistencyError, tag.child, ns.bse.foo, range=Literal(ns.bsfs.Tag, l_root)) + # range can be subtyped from ROOT_VERTEX to Node or Literal + self.assertEqual(n_root, root.child(ns.bse.foo, range=n_root).range) + self.assertEqual(l_root, root.child(ns.bse.foo, range=l_root).range) + + +class TestFeature(unittest.TestCase): + def test_construction(self): + n_root = Node(ns.bsfs.Node, None) + l_root = Literal(ns.bsfs.Literal, None) + # dimension, dtype, and distance are respected + feat = Feature(ns.bsfs.Feature, None, 1234, ns.bsfs.float, ns.bsfs.euclidean) + self.assertEqual(1234, feat.dimension) + self.assertEqual(ns.bsfs.float, feat.dtype) + self.assertEqual(ns.bsfs.euclidean, feat.distance) + + def test_equality(self): + n_ent = Node(ns.bsfs.Entity, Node(ns.bsfs.Node, None)) + colors = Feature( + uri=ns.bse.colors, + parent=ROOT_FEATURE, + dimension=1234, + dtype=ns.bsfs.float, + distance=ns.bsfs.euclidean, + ) + # instance is equal to itself + self.assertEqual(colors, colors) + self.assertEqual(hash(colors), hash(colors)) + # instance is equal to a clone + self.assertEqual(colors, Feature(ns.bse.colors, ROOT_FEATURE, 1234, ns.bsfs.float, ns.bsfs.euclidean)) + self.assertEqual(hash(colors), hash(Feature(ns.bse.colors, ROOT_FEATURE, 1234, ns.bsfs.float, ns.bsfs.euclidean))) + # equality respects dimension + self.assertNotEqual(colors, Feature(ns.bse.colors, ROOT_FEATURE, 4321, ns.bsfs.float, ns.bsfs.euclidean)) + self.assertNotEqual(hash(colors), hash(Feature(ns.bse.colors, ROOT_FEATURE, 4321, ns.bsfs.float, ns.bsfs.euclidean))) + # equality respects dtype + self.assertNotEqual(colors, Feature(ns.bse.colors, ROOT_FEATURE, 1234, ns.bsfs.integer, ns.bsfs.euclidean)) + self.assertNotEqual(hash(colors), hash(Feature(ns.bse.colors, ROOT_FEATURE, 1234, ns.bsfs.integer, ns.bsfs.euclidean))) + # equality respects distance + self.assertNotEqual(colors, Feature(ns.bse.colors, ROOT_FEATURE, 1234, ns.bsfs.float, ns.bsfs.cosine)) + self.assertNotEqual(hash(colors), hash(Feature(ns.bse.colors, ROOT_FEATURE, 1234, ns.bsfs.float, ns.bsfs.cosine))) + + def test_child(self): + n_root = Node(ns.bsfs.Node, None) + n_ent = Node(ns.bsfs.Entity, n_root) + l_root = Literal(ns.bsfs.Literal, None) + colors = Feature( + uri=ns.bse.colors, + parent=ROOT_FEATURE, + dimension=1234, + dtype=ns.bsfs.float, + distance=ns.bsfs.euclidean, + ) + + # child returns Feature + self.assertIsInstance(colors.child(ns.bse.foo), Feature) + # uri is respected + self.assertEqual(ns.bse.foo, colors.child(ns.bse.foo).uri) + # dimension is respected + self.assertEqual(4321, colors.child(ns.bse.foo, dimension=4321).dimension) + # dtype is respected + self.assertEqual(ns.bsfs.integer, colors.child(ns.bse.foo, dtype=ns.bsfs.integer).dtype) + # distance is respected + self.assertEqual(ns.bsfs.cosine, colors.child(ns.bse.foo, distance=ns.bsfs.cosine).distance) + # annotations are respected + self.assertDictEqual(colors.child(ns.bse.foo, foo='bar', bar=123).annotations, { + 'foo': 'bar', + 'bar': 123, + }) + + # dimension is inherited from parent + self.assertEqual(1234, colors.child(ns.bse.foo).dimension) + # dtype is inherited from parent + self.assertEqual(ns.bsfs.float, colors.child(ns.bse.foo).dtype) + # distance is inherited from parent + self.assertEqual(ns.bsfs.euclidean, colors.child(ns.bse.foo).distance) ## main ## @@ -222,4 +343,3 @@ if __name__ == '__main__': unittest.main() ## EOF ## - diff --git a/test/triple_store/sparql/__init__.py b/test/triple_store/sparql/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/test/triple_store/sparql/__init__.py diff --git a/test/triple_store/sparql/test_distance.py b/test/triple_store/sparql/test_distance.py new file mode 100644 index 0000000..e95be5a --- /dev/null +++ b/test/triple_store/sparql/test_distance.py @@ -0,0 +1,56 @@ + +# imports +import numpy as np +import unittest + +# objects to test +from bsfs.triple_store.sparql import distance + + +## code ## + +class TestDistance(unittest.TestCase): + + def test_euclid(self): + # self-distance is zero + self.assertEqual(distance.euclid([1,2,3,4], [1,2,3,4]), 0.0) + # accepts list-like arguments + self.assertAlmostEqual(distance.euclid([1,2,3,4], [2,3,4,5]), 2.0, 3) + self.assertAlmostEqual(distance.euclid((1,2,3,4), (2,3,4,5)), 2.0, 3) + # dimension can vary + self.assertAlmostEqual(distance.euclid([1,2,3], [2,3,4]), 1.732, 3) + self.assertAlmostEqual(distance.euclid([1,2,3,4,5], [2,3,4,5,6]), 2.236, 3) + # vector can be zero + self.assertAlmostEqual(distance.euclid([0,0,0], [1,2,3]), 3.742, 3) + + def test_cosine(self): + # self-distance is zero + self.assertEqual(distance.cosine([1,2,3,4], [1,2,3,4]), 0.0) + # accepts list-like arguments + self.assertAlmostEqual(distance.cosine([1,2,3,4], [4,3,2,1]), 0.333, 3) + self.assertAlmostEqual(distance.cosine((1,2,3,4), (4,3,2,1)), 0.333, 3) + # dimension can vary + self.assertAlmostEqual(distance.cosine([1,2,3], [3,2,1]), 0.286, 3) + self.assertAlmostEqual(distance.cosine([1,2,3,4,5], [5,4,3,2,1]), 0.364, 3) + # vector can be zero + self.assertAlmostEqual(distance.cosine([0,0,0], [1,2,3]), 1.0, 3) + + def test_manhatten(self): + # self-distance is zero + self.assertEqual(distance.manhatten([1,2,3,4], [1,2,3,4]), 0.0) + # accepts list-like arguments + self.assertAlmostEqual(distance.manhatten([1,2,3,4], [2,3,4,5]), 4.0, 3) + self.assertAlmostEqual(distance.manhatten((1,2,3,4), (2,3,4,5)), 4.0, 3) + # dimension can vary + self.assertAlmostEqual(distance.manhatten([1,2,3], [2,3,4]), 3.0, 3) + self.assertAlmostEqual(distance.manhatten([1,2,3,4,5], [2,3,4,5,6]), 5.0, 3) + # vector can be zero + self.assertAlmostEqual(distance.manhatten([0,0,0], [1,2,3]), 6.0, 3) + + +## main ## + +if __name__ == '__main__': + unittest.main() + +## EOF ## diff --git a/test/triple_store/sparql/test_parse_fetch.py b/test/triple_store/sparql/test_parse_fetch.py new file mode 100644 index 0000000..1d793e7 --- /dev/null +++ b/test/triple_store/sparql/test_parse_fetch.py @@ -0,0 +1,257 @@ + +# imports +import rdflib +import unittest + +# bsie imports +from bsfs import schema +from bsfs.namespace import Namespace, ns +from bsfs.query import ast +from bsfs.utils import errors, URI + +# objects to test +from bsfs.triple_store.sparql.parse_fetch import Fetch + + +## code ## + +ns.bse = ns.bsfs.Entity() +ns.bst = ns.bsfs.Tag() +ns.bsc = ns.bsfs.Collection() + +class TestParseFetch(unittest.TestCase): + + def setUp(self): + self.schema = schema.from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + prefix bst: <https://schema.bsfs.io/core/Tag#> + prefix bsc: <https://schema.bsfs.io/core/Collection#> + + # nodes + bsfs:Entity rdfs:subClassOf bsfs:Node . + bsfs:Tag rdfs:subClassOf bsfs:Node . + bsfs:Collection rdfs:subClassOf bsfs:Node . + + # literals + xsd:integer rdfs:subClassOf bsfs:Literal . + xsd:string rdfs:subClassOf bsfs:Literal . + + # predicates + bse:tag rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range bsfs:Tag . + + bse:collection rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range bsfs:Collection . + + bse:filename rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range xsd:string . + + bse:rank rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range xsd:integer . + + bst:main rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Tag ; + rdfs:range bsfs:Entity . + + bst:label rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Tag ; + rdfs:range xsd:string . + + bsc:tag rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Collection ; + rdfs:range bsfs:Tag . + + bsc:label rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Collection ; + rdfs:range xsd:string . + + bsc:rating rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Collection ; + rdfs:range xsd:integer . + + ''') + + # graph to test queries + self.graph = rdflib.Graph() + # schema hierarchies + self.graph.add((rdflib.URIRef('https://schema.bsfs.io/core/Entity'), rdflib.RDFS.subClassOf, rdflib.URIRef('https://schema.bsfs.io/core/Node'))) + self.graph.add((rdflib.URIRef('https://schema.bsfs.io/core/Collection'), rdflib.RDFS.subClassOf, rdflib.URIRef('https://schema.bsfs.io/core/Node'))) + self.graph.add((rdflib.URIRef('https://schema.bsfs.io/core/Tag'), rdflib.RDFS.subClassOf, rdflib.URIRef('https://schema.bsfs.io/core/Node'))) + # entities + self.graph.add((rdflib.URIRef('http://example.com/entity#1234'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Entity'))) + self.graph.add((rdflib.URIRef('http://example.com/entity#4321'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Entity'))) + # tags + self.graph.add((rdflib.URIRef('http://example.com/tag#1234'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Tag'))) + self.graph.add((rdflib.URIRef('http://example.com/tag#4321'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Tag'))) + # collections + self.graph.add((rdflib.URIRef('http://example.com/collection#1234'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Collection'))) + self.graph.add((rdflib.URIRef('http://example.com/collection#4321'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Collection'))) + # entity literals + self.graph.add((rdflib.URIRef('http://example.com/entity#1234'), rdflib.URIRef(ns.bse.rank), rdflib.Literal('1234', datatype=rdflib.XSD.integer))) + self.graph.add((rdflib.URIRef('http://example.com/entity#1234'), rdflib.URIRef(ns.bse.filename), rdflib.Literal('filename_1234', datatype=rdflib.XSD.string))) + #self.graph.add((rdflib.URIRef('http://example.com/entity#4321'), rdflib.URIRef(ns.bse.rank), rdflib.Literal('4321', datatype=rdflib.XSD.integer))) + self.graph.add((rdflib.URIRef('http://example.com/entity#4321'), rdflib.URIRef(ns.bse.filename), rdflib.Literal('filename_4321', datatype=rdflib.XSD.string))) + # tag literals + self.graph.add((rdflib.URIRef('http://example.com/tag#1234'), rdflib.URIRef(ns.bst.label), rdflib.Literal('tag_label_1234', datatype=rdflib.XSD.string))) + self.graph.add((rdflib.URIRef('http://example.com/tag#4321'), rdflib.URIRef(ns.bst.label), rdflib.Literal('tag_label_4321', datatype=rdflib.XSD.string))) + # collection literals + self.graph.add((rdflib.URIRef('http://example.com/collection#1234'), rdflib.URIRef(ns.bsc.label), rdflib.Literal('collection_label_1234', datatype=rdflib.XSD.string))) + self.graph.add((rdflib.URIRef('http://example.com/collection#1234'), rdflib.URIRef(ns.bsc.rating), rdflib.Literal('1234', datatype=rdflib.XSD.integer))) + self.graph.add((rdflib.URIRef('http://example.com/collection#4321'), rdflib.URIRef(ns.bsc.label), rdflib.Literal('collection_label_4321', datatype=rdflib.XSD.string))) + self.graph.add((rdflib.URIRef('http://example.com/collection#4321'), rdflib.URIRef(ns.bsc.rating), rdflib.Literal('4321', datatype=rdflib.XSD.integer))) + # entity-tag links + self.graph.add((rdflib.URIRef('http://example.com/entity#1234'), rdflib.URIRef(ns.bse.tag), rdflib.URIRef('http://example.com/tag#1234'))) + self.graph.add((rdflib.URIRef('http://example.com/entity#4321'), rdflib.URIRef(ns.bse.tag), rdflib.URIRef('http://example.com/tag#4321'))) + # entity-collection links + self.graph.add((rdflib.URIRef('http://example.com/entity#1234'), rdflib.URIRef(ns.bse.collection), rdflib.URIRef('http://example.com/collection#1234'))) + self.graph.add((rdflib.URIRef('http://example.com/entity#4321'), rdflib.URIRef(ns.bse.collection), rdflib.URIRef('http://example.com/collection#4321'))) + # collection-tag links + self.graph.add((rdflib.URIRef('http://example.com/collection#1234'), rdflib.URIRef(ns.bsc.tag), rdflib.URIRef('http://example.com/tag#1234'))) + self.graph.add((rdflib.URIRef('http://example.com/collection#4321'), rdflib.URIRef(ns.bsc.tag), rdflib.URIRef('http://example.com/tag#4321'))) + # tag-entity links # NOTE: cross-over + self.graph.add((rdflib.URIRef('http://example.com/tag#1234'), rdflib.URIRef(ns.bst.main), rdflib.URIRef('http://example.com/entity#4321'))) + self.graph.add((rdflib.URIRef('http://example.com/tag#4321'), rdflib.URIRef(ns.bst.main), rdflib.URIRef('http://example.com/entity#1234'))) + + # default parser + self.parser = Fetch(self.schema) + self.ent = self.schema.node(ns.bsfs.Entity) + + + def test_call(self): + # NOTE: The individual ast components are considered in the respective tests. Here, we test __call__ specifics. + + # __call__ requires a valid root type + self.assertRaises(errors.BackendError, self.parser, self.schema.literal(ns.bsfs.Literal), ast.fetch.This('this')) + self.assertRaises(errors.ConsistencyError, self.parser, self.schema.node(ns.bsfs.Node).child(ns.bsfs.Invalid), ast.fetch.This('this')) + # __call__ requires a parseable root + self.assertRaises(errors.BackendError, self.parser, self.ent, ast.filter.FilterExpression()) + # __call__ returns an executable query + q = self.parser(self.ent, ast.fetch.Fetch(ns.bse.tag, ast.fetch.Value(ns.bst.label, 'label'))) + self.assertSetEqual(set(q(self.graph)), { + (rdflib.URIRef('http://example.com/entity#1234'), rdflib.Literal('tag_label_1234', datatype=rdflib.XSD.string)), + (rdflib.URIRef('http://example.com/entity#4321'), rdflib.Literal('tag_label_4321', datatype=rdflib.XSD.string)), + }) + + + def test_routing(self): + self.assertRaises(errors.BackendError, self.parser._parse_fetch_expression, self.ent, ast.fetch.FetchExpression(), '?head') + + + def test_all(self): + # multiple values query + q = self.parser(self.ent, ast.fetch.All( + ast.fetch.Value(ns.bse.filename, name='filename'), + ast.fetch.Value(ns.bse.rank, name='rank')), + ) + self.assertSetEqual(set(q.names), {'filename', 'rank'}) + if q.names == ('filename', 'rank'): + self.assertSetEqual(set(q(self.graph)), { + (rdflib.URIRef('http://example.com/entity#1234'), rdflib.Literal('filename_1234', datatype=rdflib.XSD.string), rdflib.Literal('1234', datatype=rdflib.XSD.integer)), + (rdflib.URIRef('http://example.com/entity#4321'), rdflib.Literal('filename_4321', datatype=rdflib.XSD.string), None), + }) + else: + self.assertSetEqual(set(q(self.graph)), { + (rdflib.URIRef('http://example.com/entity#1234'), rdflib.Literal('1234', datatype=rdflib.XSD.integer), rdflib.Literal('filename_1234', datatype=rdflib.XSD.string)), + (rdflib.URIRef('http://example.com/entity#4321'), None, rdflib.Literal('filename_4321', datatype=rdflib.XSD.string)), + }) + # mixed values and node query + q = self.parser(self.ent, ast.fetch.All( + ast.fetch.Value(ns.bse.filename, name='filename'), + ast.fetch.Node(ns.bse.tag, name='tag'), + )) + self.assertSetEqual(set(q.names), {'filename', 'tag'}) + if q.names == ('filename', 'tag'): + self.assertSetEqual(set(q(self.graph)), { + (rdflib.URIRef('http://example.com/entity#1234'), rdflib.Literal('filename_1234', datatype=rdflib.XSD.string), rdflib.URIRef('http://example.com/tag#1234')), + (rdflib.URIRef('http://example.com/entity#4321'), rdflib.Literal('filename_4321', datatype=rdflib.XSD.string), rdflib.URIRef('http://example.com/tag#4321')), + }) + else: + self.assertSetEqual(set(q(self.graph)), { + (rdflib.URIRef('http://example.com/entity#1234'), rdflib.URIRef('http://example.com/tag#1234'), rdflib.Literal('filename_1234', datatype=rdflib.XSD.string)), + (rdflib.URIRef('http://example.com/entity#4321'), rdflib.URIRef('http://example.com/tag#4321'), rdflib.Literal('filename_4321', datatype=rdflib.XSD.string)), + }) + # multiple values and second hop + q = self.parser(self.ent, ast.fetch.Fetch(ns.bse.tag, ast.fetch.All( + ast.fetch.This(name='tag'), + ast.fetch.Value(ns.bst.label, name='label'), + ))) + self.assertSetEqual(set(q.names), {'tag', 'label'}) + if q.names == ('tag', 'label'): + self.assertSetEqual(set(q(self.graph)), { + (rdflib.URIRef('http://example.com/entity#1234'), rdflib.URIRef('http://example.com/tag#1234'), rdflib.Literal('tag_label_1234', datatype=rdflib.XSD.string)), + (rdflib.URIRef('http://example.com/entity#4321'), rdflib.URIRef('http://example.com/tag#4321'), rdflib.Literal('tag_label_4321', datatype=rdflib.XSD.string)), + }) + else: + self.assertSetEqual(set(q(self.graph)), { + (rdflib.URIRef('http://example.com/entity#1234'), rdflib.Literal('tag_label_1234', datatype=rdflib.XSD.string), rdflib.URIRef('http://example.com/tag#1234')), + (rdflib.URIRef('http://example.com/entity#4321'), rdflib.Literal('tag_label_4321', datatype=rdflib.XSD.string), rdflib.URIRef('http://example.com/tag#4321')), + }) + + + + def test_fetch(self): + # two-hop query + q = self.parser(self.ent, ast.fetch.Fetch(ns.bse.tag, ast.fetch.Value(ns.bst.label, 'tag_label'))) + self.assertSetEqual(set(q(self.graph)), { + (rdflib.URIRef('http://example.com/entity#1234'), rdflib.Literal('tag_label_1234', datatype=rdflib.XSD.string)), + (rdflib.URIRef('http://example.com/entity#4321'), rdflib.Literal('tag_label_4321', datatype=rdflib.XSD.string)), + }) + # three-hop-query + q = self.parser(self.ent, ast.fetch.Fetch(ns.bse.tag, ast.fetch.Fetch(ns.bst.main, ast.fetch.Value(ns.bse.rank, 'entity_rank')))) + self.assertSetEqual(set(q(self.graph)), { + (rdflib.URIRef('http://example.com/entity#1234'), None), + (rdflib.URIRef('http://example.com/entity#4321'), rdflib.Literal('1234', datatype=rdflib.XSD.integer)), + }) + + + def test_node(self): + # cannot use the internal hop name + self.assertRaises(errors.BackendError, self.parser, self.ent, ast.fetch.Node(ns.bse.tag, self.parser.ngen.prefix[1:] + '123')) + # a simple Node statement + q = self.parser(self.ent, ast.fetch.Node(ns.bse.tag, 'tag')) + self.assertSetEqual(set(q.names), {'tag'}) + self.assertSetEqual(set(q(self.graph)), { + (rdflib.URIRef('http://example.com/entity#1234'), rdflib.URIRef('http://example.com/tag#1234')), + (rdflib.URIRef('http://example.com/entity#4321'), rdflib.URIRef('http://example.com/tag#4321')), + }) + + + def test_value(self): + # cannot use the internal hop name + self.assertRaises(errors.BackendError, self.parser, self.schema.node(ns.bsfs.Entity), ast.fetch.Value(ns.bse.filename, self.parser.ngen.prefix[1:] + '123')) + # a simple Value statement + q = self.parser(self.ent, ast.fetch.Value(ns.bse.filename, 'filename')) + self.assertSetEqual(set(q.names), {'filename'}) + self.assertSetEqual(set(q(self.graph)), { + (rdflib.URIRef('http://example.com/entity#1234'), rdflib.Literal('filename_1234', datatype=rdflib.XSD.string)), + (rdflib.URIRef('http://example.com/entity#4321'), rdflib.Literal('filename_4321', datatype=rdflib.XSD.string)), + }) + + + def test_this(self): + # cannot use the internal hop name + self.assertRaises(errors.BackendError, self.parser, self.ent, ast.fetch.This(self.parser.ngen.prefix[1:] + '123')) + # a simple This statement + self.assertEqual(self.parser._this(self.ent, ast.fetch.This('this'), '?head'), + ({('?head', 'this')}, '')) + q = self.parser(self.ent, ast.fetch.This('this')) + self.assertSetEqual(set(q(self.graph)), { + (rdflib.URIRef('http://example.com/entity#1234'), rdflib.URIRef('http://example.com/entity#1234')), + (rdflib.URIRef('http://example.com/entity#4321'), rdflib.URIRef('http://example.com/entity#4321')), + }) + + +## main ## + +if __name__ == '__main__': + unittest.main() + +## EOF ## diff --git a/test/triple_store/sparql/test_parse_filter.py b/test/triple_store/sparql/test_parse_filter.py new file mode 100644 index 0000000..a45f2ef --- /dev/null +++ b/test/triple_store/sparql/test_parse_filter.py @@ -0,0 +1,777 @@ + +# imports +import rdflib +import unittest + +# bsie imports +from bsfs import schema as bsc +from bsfs.namespace import ns +from bsfs.query import ast +from bsfs.utils import errors + +# objects to test +from bsfs.triple_store.sparql.parse_filter import Filter + + +## code ## + +ns.bse = ns.bsfs.Entity() + +class TestParseFilter(unittest.TestCase): + def setUp(self): + # schema + self.schema = bsc.from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + prefix bsl: <https://schema.bsfs.io/core/Literal/> + prefix bsd: <https://schema.bsfs.io/core/distance#> + prefix bsa: <https://schema.bsfs.io/core/Literal/Array/> + + bsl:Array rdfs:subClassOf bsfs:Literal . + bsa:Feature rdfs:subClassOf bsl:Array . + bsl:Number rdfs:subClassOf bsfs:Literal . + + bsfs:Entity rdfs:subClassOf bsfs:Node . + bsfs:Image rdfs:subClassOf bsfs:Entity . + bsfs:Tag rdfs:subClassOf bsfs:Node . + + xsd:string rdfs:subClassOf bsfs:Literal . + xsd:integer rdfs:subClassOf bsl:Number . + bsfs:URI rdfs:subClassOf bsfs:Literal . + + bsfs:Colors rdfs:subClassOf bsa:Feature ; + bsfs:dimension "4"^^xsd:integer ; + bsfs:dtype xsd:integer ; + bsfs:distance bsd:euclidean . + + bse:colors rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range bsfs:Colors . + + bse:comment rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Node ; + rdfs:range xsd:string ; + bsfs:unique "false"^^xsd:boolean . + + bse:filesize rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range xsd:integer ; + bsfs:unique "true"^^xsd:boolean . + + bse:buddy rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range bsfs:Node ; + bsfs:unique "false"^^xsd:boolean . + + bse:tag rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range bsfs:Tag ; + bsfs:unique "false"^^xsd:boolean . + + bse:representative rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Tag ; + rdfs:range bsfs:Image ; + bsfs:unique "false"^^xsd:boolean . + + bse:iso rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Image ; + rdfs:range xsd:integer ; + bsfs:unique "true"^^xsd:boolean . + + ''') + + # graph to test queries + self.graph = rdflib.Graph() + # schema hierarchies + self.graph.add((rdflib.URIRef('https://schema.bsfs.io/core/Entity'), rdflib.RDFS.subClassOf, rdflib.URIRef('https://schema.bsfs.io/core/Node'))) + self.graph.add((rdflib.URIRef('https://schema.bsfs.io/core/Image'), rdflib.RDFS.subClassOf, rdflib.URIRef('https://schema.bsfs.io/core/Entity'))) + self.graph.add((rdflib.URIRef('https://schema.bsfs.io/core/Tag'), rdflib.RDFS.subClassOf, rdflib.URIRef('https://schema.bsfs.io/core/Node'))) + # entities + self.graph.add((rdflib.URIRef('http://example.com/entity#1234'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Entity'))) + self.graph.add((rdflib.URIRef('http://example.com/entity#4321'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Entity'))) + # tags + self.graph.add((rdflib.URIRef('http://example.com/tag#1234'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Tag'))) + self.graph.add((rdflib.URIRef('http://example.com/tag#4321'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Tag'))) + # images + self.graph.add((rdflib.URIRef('http://example.com/image#1234'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Image'))) + self.graph.add((rdflib.URIRef('http://example.com/image#4321'), rdflib.RDF.type, rdflib.URIRef('https://schema.bsfs.io/core/Image'))) + # node comments + self.graph.add((rdflib.URIRef('http://example.com/entity#1234'), rdflib.URIRef(ns.bse.comment), rdflib.Literal('Me, Myself, and I', datatype=rdflib.XSD.string))) + self.graph.add((rdflib.URIRef('http://example.com/entity#1234'), rdflib.URIRef(ns.bse.comment), rdflib.Literal('hello world', datatype=rdflib.XSD.string))) + self.graph.add((rdflib.URIRef('http://example.com/entity#4321'), rdflib.URIRef(ns.bse.comment), rdflib.Literal('hello world', datatype=rdflib.XSD.string))) + self.graph.add((rdflib.URIRef('http://example.com/image#1234'), rdflib.URIRef(ns.bse.comment), rdflib.Literal('Me, Myself, and I', datatype=rdflib.XSD.string))) + self.graph.add((rdflib.URIRef('http://example.com/tag#1234'), rdflib.URIRef(ns.bse.comment), rdflib.Literal('Me, Myself, and I', datatype=rdflib.XSD.string))) + self.graph.add((rdflib.URIRef('http://example.com/tag#4321'), rdflib.URIRef(ns.bse.comment), rdflib.Literal('4321', datatype=rdflib.XSD.string))) + # entity filesizes + self.graph.add((rdflib.URIRef('http://example.com/entity#1234'), rdflib.URIRef(ns.bse.filesize), rdflib.Literal(1234, datatype=rdflib.XSD.integer))) + self.graph.add((rdflib.URIRef('http://example.com/entity#4321'), rdflib.URIRef(ns.bse.filesize), rdflib.Literal(4321, datatype=rdflib.XSD.integer))) + self.graph.add((rdflib.URIRef('http://example.com/image#1234'), rdflib.URIRef(ns.bse.filesize), rdflib.Literal(1234, datatype=rdflib.XSD.integer))) + self.graph.add((rdflib.URIRef('http://example.com/image#4321'), rdflib.URIRef(ns.bse.filesize), rdflib.Literal(4321, datatype=rdflib.XSD.integer))) + # entity tags + self.graph.add((rdflib.URIRef('http://example.com/entity#1234'), rdflib.URIRef(ns.bse.tag), rdflib.URIRef('http://example.com/tag#1234'))) + self.graph.add((rdflib.URIRef('http://example.com/entity#4321'), rdflib.URIRef(ns.bse.tag), rdflib.URIRef('http://example.com/tag#4321'))) + self.graph.add((rdflib.URIRef('http://example.com/image#1234'), rdflib.URIRef(ns.bse.tag), rdflib.URIRef('http://example.com/tag#1234'))) + # tag representatives + self.graph.add((rdflib.URIRef('http://example.com/tag#1234'), rdflib.URIRef(ns.bse.representative), rdflib.URIRef('http://example.com/image#1234'))) + self.graph.add((rdflib.URIRef('http://example.com/tag#4321'), rdflib.URIRef(ns.bse.representative), rdflib.URIRef('http://example.com/image#4321'))) + # entity buddies + self.graph.add((rdflib.URIRef('http://example.com/entity#1234'), rdflib.URIRef(ns.bse.buddy), rdflib.URIRef('http://example.com/image#1234'))) + self.graph.add((rdflib.URIRef('http://example.com/entity#4321'), rdflib.URIRef(ns.bse.buddy), rdflib.URIRef('http://example.com/image#4321'))) + # image iso + self.graph.add((rdflib.URIRef('http://example.com/image#1234'), rdflib.URIRef(ns.bse.iso), rdflib.Literal(1234, datatype=rdflib.XSD.integer))) + self.graph.add((rdflib.URIRef('http://example.com/image#4321'), rdflib.URIRef(ns.bse.iso), rdflib.Literal(4321, datatype=rdflib.XSD.integer))) + # color features + self.graph.add((rdflib.URIRef('http://example.com/entity#1234'), rdflib.URIRef(ns.bse.colors), rdflib.Literal([1,2,3,4], datatype=rdflib.URIRef(ns.bsfs.Colors)))) + self.graph.add((rdflib.URIRef('http://example.com/entity#4321'), rdflib.URIRef(ns.bse.colors), rdflib.Literal([4,3,2,1], datatype=rdflib.URIRef(ns.bsfs.Colors)))) + self.graph.add((rdflib.URIRef('http://example.com/image#1234'), rdflib.URIRef(ns.bse.colors), rdflib.Literal([3,4,2,1], datatype=rdflib.URIRef(ns.bsfs.Colors)))) + + # parser instance + self.parser = Filter(self.graph, self.schema) + + + def test_routing(self): + self.assertRaises(errors.BackendError, self.parser._parse_filter_expression, '1234', None, '') + self.assertRaises(errors.BackendError, self.parser._parse_predicate_expression, '1234', None) + + def test_call(self): + # NOTE: The individual ast components are considered in the respective tests. Here, we test __call__ specifics. + + # __call__ requires a valid root type + self.assertRaises(errors.BackendError, self.parser, self.schema.literal(ns.bsfs.Literal), None) + self.assertRaises(errors.ConsistencyError, self.parser, self.schema.node(ns.bsfs.Node).child(ns.bsfs.Invalid), None) + # __call__ requires a parseable root + self.assertRaises(errors.BackendError, self.parser, self.schema.node(ns.bsfs.Entity), ast.filter.FilterExpression()) + # __call__ returns an executable query + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.Or( + ast.filter.Is('http://example.com/entity#1234'), + ast.filter.Is('http://example.com/entity#5678'))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, {'http://example.com/entity#1234'}) + # root is optional + q = self.parser(self.schema.node(ns.bsfs.Entity)) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/entity#4321', 'http://example.com/image#1234', 'http://example.com/image#4321'}) + q = self.parser(self.schema.node(ns.bsfs.Tag)) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/tag#1234', 'http://example.com/tag#4321'}) + + + def test_is(self): + # _is requires a node + self.assertRaises(errors.BackendError, self.parser._is, self.schema.literal(ns.bsfs.Literal), ast.filter.Is('http://example.com/entity#1234'), '?ent') + # _is requires a serializable guid + self.assertRaises(ValueError, self.parser._is, self.schema.node(ns.bsfs.Entity), ast.filter.Is('http://example.com/entity#foo and bar'), '?ent') + # a single Is statement + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Is('http://example.com/entity#1234')) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234'}) + # an aggregate of Is statements + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.Or( + ast.filter.Is('http://example.com/entity#1234'), + ast.filter.Is('http://example.com/entity#4321'), + )) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/entity#4321'}) + # combined with other filters + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.And( + ast.filter.Or( + ast.filter.Is('http://example.com/entity#1234'), + ast.filter.Is('http://example.com/entity#4321'), + ), + ast.filter.Any(ns.bse.comment, + ast.filter.Equals('Me, Myself, and I') + ), + )) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234'}) + # as argument of Any/All + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.Any(ns.bse.tag, ast.filter.Is('http://example.com/tag#1234'))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/image#1234'}) + + + def test_equals(self): + # _equals requires a literal + self.assertRaises(errors.BackendError, self.parser._equals, self.schema.node(ns.bsfs.Entity), ast.filter.Equals('hello world'), '?ent') + # a single Equals statement + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Any(ns.bse.comment, ast.filter.Equals('hello world'))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/entity#4321'}) + # a single Equals statement that includes subtypes + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Any(ns.bse.comment, ast.filter.Equals('Me, Myself, and I'))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/image#1234'}) + # an Equals statement on an integer + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Any(ns.bse.filesize, ast.filter.Equals(4321))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#4321', 'http://example.com/image#4321'}) + + + def test_substring(self): + # _substring requires a literal + self.assertRaises(errors.BackendError, self.parser._substring, self.schema.node(ns.bsfs.Entity), ast.filter.Substring('hello world'), '?ent') + # a single Substring statement + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Any(ns.bse.comment, ast.filter.Substring('hello'))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/entity#4321'}) + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Any(ns.bse.comment, ast.filter.Substring('lo wo'))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/entity#4321'}) + # a single Substring statement that includes subtypes + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Any(ns.bse.comment, ast.filter.Substring('Myself'))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/image#1234'}) + # an Substring statement on an integer + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Any(ns.bse.filesize, ast.filter.Substring('32'))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#4321', 'http://example.com/image#4321'}) + + + def test_starts_with(self): + # _starts_with requires a literal + self.assertRaises(errors.BackendError, self.parser._starts_with, self.schema.node(ns.bsfs.Entity), ast.filter.StartsWith('hello world'), '?ent') + # a single StartsWith statement + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Any(ns.bse.comment, ast.filter.StartsWith('hello'))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/entity#4321'}) + # a single StartsWith statement that includes subtypes + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Any(ns.bse.comment, ast.filter.StartsWith('Me, Mys'))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/image#1234'}) + # an StartsWith statement on an integer + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Any(ns.bse.filesize, ast.filter.StartsWith(432))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#4321', 'http://example.com/image#4321'}) + + + def test_ends_with(self): + # _ends_with requires a literal + self.assertRaises(errors.BackendError, self.parser._ends_with, self.schema.node(ns.bsfs.Entity), ast.filter.EndsWith('hello world'), '?ent') + # a single EndsWith statement + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Any(ns.bse.comment, ast.filter.EndsWith('orld'))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/entity#4321'}) + # a single EndsWith statement that includes subtypes + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Any(ns.bse.comment, ast.filter.EndsWith('and I'))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/image#1234'}) + # an EndsWith statement on an integer + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Any(ns.bse.filesize, ast.filter.EndsWith(321))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#4321', 'http://example.com/image#4321'}) + + + def test_less_than(self): + # _less_than requires a literal + self.assertRaises(errors.BackendError, self.parser._less_than, self.schema.node(ns.bsfs.Entity), ast.filter.LessThan(2000), '?ent') + # a single LessThan statement + q = self.parser(self.schema.node(ns.bsfs.Image), ast.filter.Any(ns.bse.iso, ast.filter.LessThan(2000))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/image#1234'}) + # _less_than respects boundary + q = self.parser(self.schema.node(ns.bsfs.Image), ast.filter.Any(ns.bse.iso, ast.filter.LessThan(1234, strict=True))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, set()) + q = self.parser(self.schema.node(ns.bsfs.Image), ast.filter.Any(ns.bse.iso, ast.filter.LessThan(1234, strict=False))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/image#1234'}) + # a single LessThan statement that includes subtypes + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Any(ns.bse.filesize, ast.filter.LessThan(2000))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/image#1234'}) + # an LessThan statement on a string + # always negative; note that http://example.com/tag#4321 is also not returned although its comment is a pure number + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Any(ns.bse.comment, ast.filter.LessThan(10_000))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, set()) + + + def test_greater_than(self): + # _greater_than requires a literal + self.assertRaises(errors.BackendError, self.parser._greater_than, self.schema.node(ns.bsfs.Entity), ast.filter.GreaterThan(2000), '?ent') + # a single GreaterThan statement + q = self.parser(self.schema.node(ns.bsfs.Image), ast.filter.Any(ns.bse.iso, ast.filter.GreaterThan(2000))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/image#4321'}) + # _greater_than respects boundary + q = self.parser(self.schema.node(ns.bsfs.Image), ast.filter.Any(ns.bse.iso, ast.filter.GreaterThan(4321, strict=True))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, set()) + q = self.parser(self.schema.node(ns.bsfs.Image), ast.filter.Any(ns.bse.iso, ast.filter.GreaterThan(4321, strict=False))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/image#4321'}) + # a single GreaterThan statement that includes subtypes + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Any(ns.bse.filesize, ast.filter.GreaterThan(2000))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#4321', 'http://example.com/image#4321'}) + # an GreaterThan statement on a string + # always positive + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Any(ns.bse.comment, ast.filter.GreaterThan(0))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/entity#4321', 'http://example.com/image#1234'}) + + + def test_and(self): + # And childs have to match the node type + self.assertRaises(errors.BackendError, self.parser, + self.schema.node(ns.bsfs.Entity), + ast.filter.And( + ast.filter.StartsWith('hello'), + ast.filter.EndsWith('world'), + )) + # no child produces an empty query + self.assertEqual(self.parser._and( + self.schema.node(ns.bsfs.Entity), + ast.filter.And(), '?ent'), '') + # And can mix different conditions + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.And( + ast.filter.Is('http://example.com/entity#1234'), + ast.filter.Any(ns.bse.filesize, ast.filter.Equals(1234)), + ast.filter.Any(ns.bse.comment, ast.filter.Equals('Me, Myself, and I')), + )) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234'}) + # all conditions have to match + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.And( + ast.filter.Is('http://example.com/entity#4321'), + ast.filter.Any(ns.bse.filesize, ast.filter.Equals(1234)), + ast.filter.Any(ns.bse.comment, ast.filter.Equals('Me, Myself, and I')), + )) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, set()) + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.And( + ast.filter.Is('http://example.com/entity#1234'), + ast.filter.Any(ns.bse.filesize, ast.filter.Equals(4321)), + ast.filter.Any(ns.bse.comment, ast.filter.Equals('Me, Myself, and I')), + )) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, set()) + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.And( + ast.filter.Is('http://example.com/entity#1234'), + ast.filter.Any(ns.bse.filesize, ast.filter.Equals(1234)), + ast.filter.Any(ns.bse.comment, ast.filter.Equals('foobar')), + )) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, set()) + # And can be nested + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.And( + ast.filter.Is('http://example.com/entity#1234'), + ast.filter.And( + ast.filter.Any(ns.bse.filesize, ast.filter.Equals(1234)), + ast.filter.Any(ns.bse.comment, ast.filter.Equals('Me, Myself, and I')), + ), + )) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234'}) + + + def test_or(self): + # Or childs have to match the node type + self.assertRaises(errors.BackendError, self.parser, + self.schema.node(ns.bsfs.Entity), + ast.filter.Or( + ast.filter.StartsWith('hello'), + ast.filter.EndsWith('world'), + )) + # no child produces an empty query + self.assertEqual(self.parser._and( + self.schema.node(ns.bsfs.Entity), + ast.filter.Or(), '?ent'), '') + # Or can mix different conditions + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.Or( + ast.filter.Is('http://example.com/entity#1234'), + ast.filter.Any(ns.bse.filesize, ast.filter.Equals(4321)), + ast.filter.Any(ns.bse.comment, ast.filter.Equals('Me, Myself, and I')), + )) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/image#1234', 'http://example.com/entity#4321', 'http://example.com/image#4321'}) + # at least one condition has to match + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.Or( + ast.filter.Is('http://example.com/entity#5678'), + ast.filter.Any(ns.bse.filesize, ast.filter.Equals(8765)), + ast.filter.Any(ns.bse.comment, ast.filter.Equals('foobar')), + )) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, set()) + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.Or( + ast.filter.Is('http://example.com/entity#1234'), + ast.filter.Any(ns.bse.filesize, ast.filter.Equals(8765)), + ast.filter.Any(ns.bse.comment, ast.filter.Equals('foobar')), + )) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234'}) + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.Or( + ast.filter.Is('http://example.com/entity#5678'), + ast.filter.Any(ns.bse.filesize, ast.filter.Equals(4321)), + ast.filter.Any(ns.bse.comment, ast.filter.Equals('foobar')), + )) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#4321', 'http://example.com/image#4321'}) + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.Or( + ast.filter.Is('http://example.com/entity#5678'), + ast.filter.Any(ns.bse.filesize, ast.filter.Equals(8765)), + ast.filter.Any(ns.bse.comment, ast.filter.Equals('Me, Myself, and I')), + )) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/image#1234'}) + # Or can be nested + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.Or( + ast.filter.Is('http://example.com/entity#1234'), + ast.filter.Or( + ast.filter.Any(ns.bse.filesize, ast.filter.Equals(4321)), + ast.filter.Any(ns.bse.comment, ast.filter.Equals('Me, Myself, and I')), + ), + )) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/image#1234', 'http://example.com/entity#4321', 'http://example.com/image#4321'}) + + + + def test_any(self): + # _any requires a node + self.assertRaises(errors.BackendError, self.parser._any, + self.schema.literal(ns.bsfs.Literal), + ast.filter.Any(ns.bse.filesize, ast.filter.Equals(1234)), '?ent') + # node type must match predicate's domain + self.assertRaises(errors.ConsistencyError, self.parser._any, + self.schema.node(ns.bsfs.Tag), + ast.filter.Any(ns.bse.filesize, ast.filter.Equals(1234)), '?ent') + # predicate must be valid + self.assertRaises(errors.ConsistencyError, self.parser._any, + self.schema.node(ns.bsfs.Entity), + ast.filter.Any(ns.bse.invalid, ast.filter.Equals(1234)), '?ent') + # _any returns a valid query + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.Any(ns.bse.filesize, ast.filter.Equals(1234))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/image#1234'}) + # _any can be nested + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.Any(ns.bse.tag, + ast.filter.Any(ns.bse.representative, + ast.filter.Is('http://example.com/image#1234')))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/image#1234'}) + + + def test_all(self): + # All requires a Node + self.assertRaises(errors.BackendError, self.parser._all, self.schema.literal(ns.bsfs.Literal), None, '') + # All Nodes + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.All(ns.bse.tag, ast.filter.Is('http://example.com/tag#1234'))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/image#1234'}) + # All values + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.All(ns.bse.comment, ast.filter.Equals('hello world'))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#4321'}) + # All on value within Or branch + # entity#1234 is selected because all of its comments are in ("hello world", "Me, Myself, and I") + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.All(ns.bse.comment, ast.filter.Or( + ast.filter.Equals('hello world'), + ast.filter.Equals('Me, Myself, and I')))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/entity#4321', 'http://example.com/image#1234'}) + # All requires at least one predicate/value + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.All(ns.bse.comment, ast.filter.Equals('Me, Myself, and I'))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/image#1234'}) + # All within a statement + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.And( + ast.filter.All(ns.bse.tag, ast.filter.Is('http://example.com/tag#1234')), # entity#1234, image#1234 + ast.filter.All(ns.bse.comment, ast.filter.Or( # entity#1234, entity#4321, image#1234 + ast.filter.Equals('hello world'), + ast.filter.Equals('Me, Myself, and I'), + )) + ) + ) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/image#1234'}) + # All with reversed Predicate + q = self.parser(self.schema.node(ns.bsfs.Tag), + ast.filter.All(ast.filter.Predicate(ns.bse.tag, reverse=True), ast.filter.Is('http://example.com/entity#4321'))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/tag#4321'}) + # All with multiple predicates + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.All(ast.filter.OneOf(ns.bse.tag, ns.bse.buddy), # entity#1234 (tag:tag#1234), entity#1234 (buddy:image#1234), image#1234(tag:tag#1234) + ast.filter.Any(ns.bse.comment, ast.filter.Equals('Me, Myself, and I')))) # entity#1234, image#1234, tag#1234 + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/image#1234'}) + + + + def test_not(self): + # Not applies on conditions + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.Not(ast.filter.Is('http://example.com/entity#1234'))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/image#1234', 'http://example.com/entity#4321', 'http://example.com/image#4321'}) + # Not applies on conditions within branches + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.Any(ns.bse.comment, ast.filter.Not(ast.filter.Equals('Me, Myself, and I')))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/entity#4321'}) + # Not applies on branches + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.Not(ast.filter.Any(ns.bse.comment, ast.filter.Equals('Me, Myself, and I')))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#4321', 'http://example.com/image#4321'}) + # Double Not cancel each other + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.Not(ast.filter.Not(ast.filter.Is('http://example.com/entity#1234')))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234'}) + # Not works within aggregation (and) + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.And( + ast.filter.Not(ast.filter.Is('http://example.com/entity#1234')), + ast.filter.Any(ns.bse.comment, ast.filter.Equals('hello world')), + )) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#4321'}) + # Not works within aggregation (or) + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.Or( + ast.filter.Not(ast.filter.Is('http://example.com/entity#1234')), + ast.filter.Any(ns.bse.comment, ast.filter.Equals('Me, Myself, and I')), + )) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/entity#4321', 'http://example.com/image#1234', 'http://example.com/image#4321'}) + # Not works outside aggregation (and) + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.Not( + ast.filter.And( + ast.filter.Is('http://example.com/entity#1234'), + ast.filter.Any(ns.bse.comment, ast.filter.Equals('hello world')), + ))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#4321', 'http://example.com/image#1234', 'http://example.com/image#4321'}) + # Not works outside aggregation (or) + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.Not( + ast.filter.Or( + ast.filter.Is('http://example.com/entity#4321'), + ast.filter.Any(ns.bse.comment, ast.filter.Equals('Me, Myself, and I')), + ))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/image#4321'}) + # Not mixed with branch, aggregation, id, and value + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.And( + ast.filter.Not( # image#1234, image#4321 + ast.filter.Or( # entity#4321, entity#1234 + ast.filter.Is('http://example.com/entity#4321'), + ast.filter.Any(ns.bse.comment, ast.filter.Equals('hello world')), + ) + ), + ast.filter.Any(ns.bse.comment, ast.filter.Not(ast.filter.Equals('foobar'))), # entity#1234, entity#4321, image#1234 + )) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/image#1234'}) + + + def test_has(self): + # Has requires Node + self.assertRaises(errors.BackendError, self.parser._has, self.schema.literal(ns.bsfs.Literal), None, '') + # Has with GreaterThan constraint + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.Has(ns.bse.comment, ast.filter.GreaterThan(0))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/entity#4321', 'http://example.com/image#1234'}) + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.Has(ns.bse.comment, ast.filter.GreaterThan(1))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234'}) + # Has with Equals constraint + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.Has(ns.bse.comment, 1)) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#4321', 'http://example.com/image#1234'}) + # Has with LessThan constraint + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.Has(ns.bse.comment, ast.filter.LessThan(2))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#4321', 'http://example.com/image#1234', 'http://example.com/image#4321'}) + # Has with multiple constraints + self.graph.add((rdflib.URIRef('http://example.com/entity#1234'), rdflib.URIRef(ns.bse.comment), rdflib.Literal('extra1', datatype=rdflib.XSD.string))) + self.graph.add((rdflib.URIRef('http://example.com/entity#1234'), rdflib.URIRef(ns.bse.comment), rdflib.Literal('extra2', datatype=rdflib.XSD.string))) + self.graph.add((rdflib.URIRef('http://example.com/entity#1234'), rdflib.URIRef(ns.bse.comment), rdflib.Literal('extra3', datatype=rdflib.XSD.string))) + self.graph.add((rdflib.URIRef('http://example.com/entity#1234'), rdflib.URIRef(ns.bse.comment), rdflib.Literal('extra4', datatype=rdflib.XSD.string))) + self.graph.add((rdflib.URIRef('http://example.com/entity#1234'), rdflib.URIRef(ns.bse.comment), rdflib.Literal('extra5', datatype=rdflib.XSD.string))) + self.graph.add((rdflib.URIRef('http://example.com/entity#4321'), rdflib.URIRef(ns.bse.comment), rdflib.Literal('extra1', datatype=rdflib.XSD.string))) + self.graph.add((rdflib.URIRef('http://example.com/entity#4321'), rdflib.URIRef(ns.bse.comment), rdflib.Literal('extra2', datatype=rdflib.XSD.string))) + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Has(ns.bse.comment, + ast.filter.And(ast.filter.GreaterThan(1), ast.filter.LessThan(5)))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#4321'}) + # Has with OneOf predicate + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Has(ast.filter.OneOf(ns.bse.tag, ns.bse.buddy), + ast.filter.GreaterThan(1))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/entity#4321'}) + # Has with reversed predicate + q = self.parser(self.schema.node(ns.bsfs.Tag), ast.filter.Has(ast.filter.Predicate(ns.bse.tag, reverse=True), + ast.filter.GreaterThan(1))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/tag#1234'}) + + + def test_distance(self): + # node colors distance to [2,4,3,1] + # entity#1234 [1,2,3,4] 3.742 + # entity#4321 [4,3,2,1] 2.449 + # image#1234 [3,4,2,1] 1.414 + + # _distance expects a feature + self.assertRaises(errors.BackendError, self.parser._distance, self.schema.node(ns.bsfs.Entity), ast.filter.Distance([1,2,3,4], 1), '') + # reference must have the correct dimension + self.assertRaises(errors.ConsistencyError, self.parser._distance, self.schema.literal(ns.bsfs.Colors), ast.filter.Distance([1,2,3], 1), '') + self.assertRaises(errors.ConsistencyError, self.parser._distance, self.schema.literal(ns.bsfs.Colors), ast.filter.Distance([1,2,3,4,5], 1), '') + # _distance respects threshold + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Any(ns.bse.colors, ast.filter.Distance([2,4,3,1], 4))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/entity#4321', 'http://example.com/image#1234'}) + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Any(ns.bse.colors, ast.filter.Distance([2,4,3,1], 3))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#4321', 'http://example.com/image#1234'}) + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Any(ns.bse.colors, ast.filter.Distance([2,4,3,1], 2))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/image#1234'}) + # result set can be empty + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Any(ns.bse.colors, ast.filter.Distance([2,4,3,1], 1))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, set()) + # _distance respects strict + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Any(ns.bse.colors, ast.filter.Distance([1,2,3,4], 0, False))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234'}) + q = self.parser(self.schema.node(ns.bsfs.Entity), ast.filter.Any(ns.bse.colors, ast.filter.Distance([1,2,3,4], 0, True))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, set()) + + def test_one_of(self): + # _one_of expects a node + self.assertRaises(errors.BackendError, self.parser._one_of, + self.schema.literal(ns.bsfs.Literal), + ast.filter.OneOf(ast.filter.Predicate(ns.bse.filesize))) + # invalid predicate for node type raises an error + self.assertRaises(errors.ConsistencyError, self.parser._one_of, + self.schema.node(ns.bsfs.Node), + ast.filter.OneOf(ast.filter.Predicate(ns.bse.filesize))) + self.assertRaises(errors.ConsistencyError, self.parser, + self.schema.node(ns.bsfs.Tag), + ast.filter.Any(ast.filter.OneOf(ast.filter.Predicate(ns.bse.filesize)), ast.filter.Equals(1234))) + self.assertRaises(errors.BackendError, self.parser._one_of, + self.schema.node(ns.bsfs.Node), + ast.filter.OneOf(ast.filter.Predicate(ns.bsfs.Predicate))) + # invalid predicate combinations raise an error + self.assertRaises(errors.ConsistencyError, self.parser._one_of, + self.schema.node(ns.bsfs.Node), + ast.filter.OneOf( + ast.filter.Predicate(ns.bse.filesize), + ast.filter.Predicate(ns.bse.representative))) + # _one_of returns the URI and range + q = self.parser._one_of(self.schema.node(ns.bsfs.Image), + ast.filter.OneOf( + ast.filter.Predicate(ns.bse.iso), + ast.filter.Predicate(ns.bse.filesize))) + self.assertTrue(q[0] == f'<{ns.bse.iso}>|<{ns.bse.filesize}>' or q[0] == f'<{ns.bse.filesize}>|<{ns.bse.iso}>') + self.assertEqual(q[1], self.schema.literal(ns.xsd.integer)) + # OneOf can be nested + q = self.parser._one_of(self.schema.node(ns.bsfs.Image), + ast.filter.OneOf( + ast.filter.Predicate(ns.bse.iso), + ast.filter.OneOf( + ast.filter.Predicate(ns.bse.filesize)))) + self.assertTrue(q[0] == f'<{ns.bse.iso}>|<{ns.bse.filesize}>' or q[0] == f'<{ns.bse.filesize}>|<{ns.bse.iso}>') + self.assertEqual(q[1], self.schema.literal(ns.xsd.integer)) + # _one_of returns the most generic range + q = self.parser._one_of(self.schema.node(ns.bsfs.Entity), + ast.filter.OneOf( + ast.filter.Predicate(ns.bse.tag), + ast.filter.Predicate(ns.bse.buddy))) + self.assertTrue(q[0] == f'<{ns.bse.tag}>|<{ns.bse.buddy}>' or q[0] == f'<{ns.bse.buddy}>|<{ns.bse.tag}>') + self.assertEqual(q[1], self.schema.node(ns.bsfs.Node)) + # domains must match the given type + self.assertRaises(errors.ConsistencyError, self.parser, + self.schema.node(ns.bsfs.Entity), + ast.filter.Any(ast.filter.OneOf(ns.bse.tag, ns.bse.buddy), + ast.filter.Any(ast.filter.OneOf(ns.bse.filesize), + ast.filter.Equals(1234)))) + # ranges must have the same type (Node/Literal) + self.assertRaises(errors.ConsistencyError, self.parser, + self.schema.node(ns.bsfs.Entity), + ast.filter.Any(ast.filter.OneOf(ns.bse.tag, ns.bse.filesize), + ast.filter.Equals(1234))) + # ranges must be related + self.assertRaises(errors.ConsistencyError, self.parser, + self.schema.node(ns.bsfs.Entity), + ast.filter.Any(ast.filter.OneOf(ns.bse.comment, ns.bse.filesize), + ast.filter.Equals(1234))) + # integration: _one_of returns a valid sparql query + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.Any(ast.filter.OneOf(ns.bse.tag, ns.bse.buddy), + ast.filter.Any(ast.filter.OneOf(ns.bse.comment), + ast.filter.Equals('Me, Myself, and I')))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/image#1234'}) + + + def test_predicate(self): + # predicate cannot be the root predicate (ns.bsfs.Predicate) + self.assertRaises(errors.BackendError, self.parser._predicate, self.schema.node(ns.bsfs.Node), ast.filter.Predicate(ns.bsfs.Predicate)) + # _predicate expects a node + self.assertRaises(errors.BackendError, self.parser._predicate, + self.schema.literal(ns.bsfs.Literal), + ast.filter.Predicate(ns.bse.filesize)) + # invalid predicate for node type raises an error + self.assertRaises(errors.ConsistencyError, self.parser._predicate, + self.schema.node(ns.bsfs.Node), + ast.filter.Predicate(ns.bse.filesize)) + self.assertRaises(errors.ConsistencyError, self.parser, + self.schema.node(ns.bsfs.Tag), + ast.filter.Any(ast.filter.Predicate(ns.bse.filesize), ast.filter.Equals(1234))) + # _predicate returns the URI and range + self.assertEqual(self.parser._predicate(self.schema.node(ns.bsfs.Entity), ast.filter.Predicate(ns.bse.filesize)), + (f'<{ns.bse.filesize}>', self.schema.literal(ns.xsd.integer))) + self.assertEqual(self.parser._predicate(self.schema.node(ns.bsfs.Entity), ast.filter.Predicate(ns.bse.tag)), + (f'<{ns.bse.tag}>', self.schema.node(ns.bsfs.Tag))) + # _predicate respects reverse flag + self.assertEqual(self.parser._predicate(self.schema.node(ns.bsfs.Tag), ast.filter.Predicate(ns.bse.tag, reverse=True)), + ('^<' + ns.bse.tag + '>', self.schema.node(ns.bsfs.Entity))) + # integration: _predicate returns a valid sparql query + q = self.parser(self.schema.node(ns.bsfs.Entity), + ast.filter.Any(ns.bse.tag, + ast.filter.Any(ns.bse.representative, + ast.filter.Any(ns.bse.filesize, + ast.filter.Equals(1234))))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/entity#1234', 'http://example.com/image#1234'}) + q = self.parser(self.schema.node(ns.bsfs.Tag), + ast.filter.Any(ast.filter.Predicate(ns.bse.tag, reverse=True), + ast.filter.Any(ns.bse.filesize, + ast.filter.LessThan(2000)))) + self.assertSetEqual({str(guid) for guid, in q(self.graph)}, + {'http://example.com/tag#1234'}) + + +## main ## + +if __name__ == '__main__': + unittest.main() + +## EOF ## diff --git a/test/triple_store/test_sparql.py b/test/triple_store/sparql/test_sparql.py index 8d98749..a7e7d37 100644 --- a/test/triple_store/test_sparql.py +++ b/test/triple_store/sparql/test_sparql.py @@ -1,38 +1,39 @@ -""" -Part of the bsfs test suite. -A copy of the license is provided with the project. -Author: Matthias Baumgartner, 2022 -""" # imports import rdflib import unittest # bsie imports -from bsfs import schema as _schema +from bsfs import schema as bsc from bsfs.namespace import ns +from bsfs.query import ast from bsfs.utils import errors, URI # objects to test -from bsfs.triple_store.sparql import SparqlStore +from bsfs.triple_store.sparql.sparql import SparqlStore ## code ## +ns.bse = ns.bsfs.Entity() + class TestSparqlStore(unittest.TestCase): def setUp(self): - self.schema = _schema.Schema.from_string(''' + self.schema = bsc.from_string(''' prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> prefix xsd: <http://www.w3.org/2001/XMLSchema#> - prefix bsfs: <http://bsfs.ai/schema/> - prefix bse: <http://bsfs.ai/schema/Entity#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + prefix bsl: <https://schema.bsfs.io/core/Literal/> bsfs:Entity rdfs:subClassOf bsfs:Node . bsfs:Tag rdfs:subClassOf bsfs:Node . bsfs:User rdfs:subClassOf bsfs:Node . xsd:string rdfs:subClassOf bsfs:Literal . - xsd:integer rdfs:subClassOf bsfs:Literal . + bsl:Number rdfs:subClassOf bsfs:Literal . + bsl:BinaryBlob rdfs:subClassOf bsfs:Literal . + xsd:integer rdfs:subClassOf bsl:Number . # non-unique literal bse:comment rdfs:subClassOf bsfs:Predicate ; @@ -58,7 +59,30 @@ class TestSparqlStore(unittest.TestCase): rdfs:range bsfs:User ; bsfs:unique "true"^^xsd:boolean . + # binary range + bse:asset rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range bsl:BinaryBlob . + ''') + self.schema_triples = { + # schema hierarchy + (rdflib.URIRef(ns.bsfs.Entity), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Node)), + (rdflib.URIRef(ns.bsfs.Tag), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Node)), + (rdflib.URIRef(ns.bsfs.User), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Node)), + (rdflib.URIRef(ns.xsd.string), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Literal)), + (rdflib.URIRef(ns.bsl.Array), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Literal)), + (rdflib.URIRef(ns.bsl.BinaryBlob), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Literal)), + (rdflib.URIRef(ns.bsl.Array.Feature), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsl.Array)), + (rdflib.URIRef(ns.bsl.Number), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Literal)), + (rdflib.URIRef(ns.bsl.Time), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Literal)), + (rdflib.URIRef(ns.xsd.integer), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsl.Number)), + (rdflib.URIRef(ns.bse.comment), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), + (rdflib.URIRef(ns.bse.filesize), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), + (rdflib.URIRef(ns.bse.tag), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), + (rdflib.URIRef(ns.bse.author), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), + (rdflib.URIRef(ns.bse.asset), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), + } def test_essentials(self): store = SparqlStore.Open() @@ -77,10 +101,10 @@ class TestSparqlStore(unittest.TestCase): def test__has_type(self): # setup store store = SparqlStore.Open() - store.schema = _schema.Schema.from_string(''' + store.schema = bsc.from_string(''' prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> prefix xsd: <http://www.w3.org/2001/XMLSchema#> - prefix bsfs: <http://bsfs.ai/schema/> + prefix bsfs: <https://schema.bsfs.io/core/> bsfs:Entity rdfs:subClassOf bsfs:Node . bsfs:Document rdfs:subClassOf bsfs:Entity . @@ -95,7 +119,7 @@ class TestSparqlStore(unittest.TestCase): store.create(store.schema.node(ns.bsfs.PDF), {URI('http://example.com/me/pdf#1234')}) # node_type must be in the schema - self.assertRaises(errors.ConsistencyError, store._has_type, URI('http://example.com/me/entity#1234'), store.schema.node(ns.bsfs.Node).get_child(ns.bsfs.invalid)) + self.assertRaises(errors.ConsistencyError, store._has_type, URI('http://example.com/me/entity#1234'), store.schema.node(ns.bsfs.Node).child(ns.bsfs.invalid)) # returns False on inexistent nodes self.assertFalse(store._has_type(URI('http://example.com/me/entity#4321'), store.schema.node(ns.bsfs.Entity))) @@ -155,7 +179,7 @@ class TestSparqlStore(unittest.TestCase): store.set(curr.node(ns.bsfs.Entity), ent_ids, p_author, {URI('http://example.com/me')}) # check instances - instances = { + instances = self.schema_triples | { # node instances (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.RDF.type, rdflib.URIRef(ns.bsfs.Entity)), (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.RDF.type, rdflib.URIRef(ns.bsfs.Entity)), @@ -182,13 +206,13 @@ class TestSparqlStore(unittest.TestCase): self.assertSetEqual(set(store._graph), instances) # add some classes to the schema - curr = curr + _schema.Schema.from_string(''' + curr = curr + bsc.from_string(''' prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> prefix xsd: <http://www.w3.org/2001/XMLSchema#> - prefix bsfs: <http://bsfs.ai/schema/> - prefix bse: <http://bsfs.ai/schema/Entity#> - prefix bst: <http://bsfs.ai/schema/Tag#> - prefix bsc: <http://bsfs.ai/schema/Collection#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + prefix bst: <https://schema.bsfs.io/core/Tag#> + prefix bsc: <https://schema.bsfs.io/core/Collection#> bsfs:Entity rdfs:subClassOf bsfs:Node . bsfs:Tag rdfs:subClassOf bsfs:Node . @@ -228,13 +252,22 @@ class TestSparqlStore(unittest.TestCase): store.schema = curr self.assertEqual(store.schema, curr) # instances have not changed - self.assertSetEqual(set(store._graph), instances) + self.assertSetEqual(set(store._graph), instances | { + # schema hierarchy + (rdflib.URIRef(ns.bsfs.Collection), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Node)), + (rdflib.URIRef(ns.xsd.boolean), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Literal)), + (rdflib.URIRef(ns.bse.shared), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), + (rdflib.URIRef(ns.bse.partOf), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), + (rdflib.URIRef('https://schema.bsfs.io/core/Tag#usedIn'), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), + (rdflib.URIRef('https://schema.bsfs.io/core/Collection#tag'), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), + (rdflib.URIRef('https://schema.bsfs.io/core/Tag#principal'), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), + }) # add some instances of the new classes p_partOf = curr.predicate(ns.bse.partOf) p_shared = curr.predicate(ns.bse.shared) - p_usedIn = curr.predicate('http://bsfs.ai/schema/Tag#usedIn') - p_ctag = curr.predicate('http://bsfs.ai/schema/Collection#tag') - p_principal = curr.predicate('http://bsfs.ai/schema/Tag#principal') + p_usedIn = curr.predicate('https://schema.bsfs.io/core/Tag#usedIn') + p_ctag = curr.predicate('https://schema.bsfs.io/core/Collection#tag') + p_principal = curr.predicate('https://schema.bsfs.io/core/Tag#principal') store.create(curr.node(ns.bsfs.Collection), {URI('http://example.com/me/collection#1234'), URI('http://example.com/me/collection#4321')}) # add some more triples store.set(curr.node(ns.bsfs.Entity), ent_ids, p_shared, {True}) @@ -248,6 +281,14 @@ class TestSparqlStore(unittest.TestCase): {URI('http://example.com/me/collection#1234')}) # new instances are now in the graph self.assertSetEqual(set(store._graph), instances | { + # same old schema hierarchy + (rdflib.URIRef(ns.bsfs.Collection), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Node)), + (rdflib.URIRef(ns.xsd.boolean), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Literal)), + (rdflib.URIRef(ns.bse.shared), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), + (rdflib.URIRef(ns.bse.partOf), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), + (rdflib.URIRef('https://schema.bsfs.io/core/Tag#usedIn'), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), + (rdflib.URIRef('https://schema.bsfs.io/core/Collection#tag'), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), + (rdflib.URIRef('https://schema.bsfs.io/core/Tag#principal'), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), # collections (rdflib.URIRef('http://example.com/me/collection#1234'), rdflib.RDF.type, rdflib.URIRef(ns.bsfs.Collection)), (rdflib.URIRef('http://example.com/me/collection#4321'), rdflib.RDF.type, rdflib.URIRef(ns.bsfs.Collection)), @@ -268,19 +309,21 @@ class TestSparqlStore(unittest.TestCase): # remove some classes from the schema - curr = _schema.Schema.from_string(''' + curr = bsc.from_string(''' prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> prefix xsd: <http://www.w3.org/2001/XMLSchema#> - prefix bsfs: <http://bsfs.ai/schema/> - prefix bse: <http://bsfs.ai/schema/Entity#> - prefix bst: <http://bsfs.ai/schema/Tag#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + prefix bst: <https://schema.bsfs.io/core/Tag#> + prefix bsl: <https://schema.bsfs.io/core/Literal/> bsfs:Entity rdfs:subClassOf bsfs:Node . bsfs:Tag rdfs:subClassOf bsfs:Node . bsfs:User rdfs:subClassOf bsfs:Node . xsd:boolean rdfs:subClassOf bsfs:Literal . - xsd:integer rdfs:subClassOf bsfs:Literal . + bsl:Number rdfs:subClassOf bsfs:Literal . + xsd:integer rdfs:subClassOf bsl:Number . bse:filesize rdfs:subClassOf bsfs:Predicate ; rdfs:domain bsfs:Entity ; @@ -316,6 +359,21 @@ class TestSparqlStore(unittest.TestCase): self.assertEqual(store.schema, curr) # instances of old classes were removed self.assertSetEqual(set(store._graph), { + # schema hierarchy + (rdflib.URIRef(ns.bsfs.Entity), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Node)), + (rdflib.URIRef(ns.bsfs.Tag), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Node)), + (rdflib.URIRef(ns.bsfs.User), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Node)), + (rdflib.URIRef(ns.xsd.boolean), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Literal)), + (rdflib.URIRef(ns.bsl.Array), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Literal)), + (rdflib.URIRef(ns.bsl.BinaryBlob), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Literal)), + (rdflib.URIRef(ns.bsl.Array.Feature), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsl.Array)), + (rdflib.URIRef(ns.bsl.Number), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Literal)), + (rdflib.URIRef(ns.bsl.Time), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Literal)), + (rdflib.URIRef(ns.xsd.integer), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsl.Number)), + (rdflib.URIRef(ns.bse.shared), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), + (rdflib.URIRef(ns.bse.tag), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), + (rdflib.URIRef(ns.bse.filesize), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), + (rdflib.URIRef('https://schema.bsfs.io/core/Tag#principal'), rdflib.RDFS.subClassOf, rdflib.URIRef(ns.bsfs.Predicate)), # node instances (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.RDF.type, rdflib.URIRef(ns.bsfs.Entity)), (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.RDF.type, rdflib.URIRef(ns.bsfs.Entity)), @@ -342,12 +400,31 @@ class TestSparqlStore(unittest.TestCase): class Foo(): pass self.assertRaises(TypeError, setattr, store, 'schema', Foo()) + # cannot define features w/o known distance function + invalid = bsc.from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> + prefix bsl: <https://schema.bsfs.io/core/Literal/> + prefix bsa: <https://schema.bsfs.io/core/Literal/Array/> + + bsl:Array rdfs:subClassOf bsfs:Literal . + bsa:Feature rdfs:subClassOf bsl:Array . + + bsfs:Colors rdfs:subClassOf bsa:Feature ; + bsfs:dimension "4"^^xsd:integer ; + bsfs:distance bsfs:foobar . + + ''') + self.assertRaises(errors.UnsupportedError, setattr, store, 'schema', invalid) + # cannot migrate to incompatible schema - invalid = _schema.Schema.from_string(''' + invalid = bsc.from_string(''' prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> prefix xsd: <http://www.w3.org/2001/XMLSchema#> - prefix bsfs: <http://bsfs.ai/schema/> - prefix bse: <http://bsfs.ai/schema/Entity#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> bsfs:Entity rdfs:subClassOf bsfs:Node . bsfs:Tag rdfs:subClassOf bsfs:Entity . # inconsistent with previous tag definition @@ -359,11 +436,11 @@ class TestSparqlStore(unittest.TestCase): ''') self.assertRaises(errors.ConsistencyError, setattr, store, 'schema', invalid) - invalid = _schema.Schema.from_string(''' + invalid = bsc.from_string(''' prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> prefix xsd: <http://www.w3.org/2001/XMLSchema#> - prefix bsfs: <http://bsfs.ai/schema/> - prefix bse: <http://bsfs.ai/schema/Entity#> + prefix bsfs: <https://schema.bsfs.io/core/> + prefix bse: <https://schema.bsfs.io/core/Entity#> bsfs:Entity rdfs:subClassOf bsfs:Node . bsfs:User rdfs:subClassOf bsfs:Node . @@ -390,7 +467,7 @@ class TestSparqlStore(unittest.TestCase): ent_ids = {URI('http://example.com/me/entity#1234'), URI('http://example.com/me/entity#4321')} tag_ids = {URI('http://example.com/me/tag#1234'), URI('http://example.com/me/tag#4321')} # target instances - instances = { + instances = self.schema_triples | { # node instances (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.RDF.type, rdflib.URIRef(ns.bsfs.Entity)), (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.RDF.type, rdflib.URIRef(ns.bsfs.Entity)), @@ -416,7 +493,7 @@ class TestSparqlStore(unittest.TestCase): # rollback undoes previous changes store.rollback() - self.assertSetEqual(set(store._graph), set()) + self.assertSetEqual(set(store._graph), self.schema_triples) # add some data once more store.create(ent_type, ent_ids) @@ -455,6 +532,109 @@ class TestSparqlStore(unittest.TestCase): (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.URIRef(p_filesize.uri), rdflib.Literal(1234, datatype=rdflib.XSD.integer)), }) + def test_get(self): + # store setup + store = SparqlStore.Open() + store.schema = self.schema + ent_type = self.schema.node(ns.bsfs.Entity) + tag_type = self.schema.node(ns.bsfs.Tag) + ent_ids = {URI('http://example.com/me/entity#1234'), URI('http://example.com/me/entity#4321')} + tag_ids = {URI('http://example.com/me/tag#1234'), URI('http://example.com/me/tag#4321')} + store.create(ent_type, ent_ids) + store.create(tag_type, tag_ids) + store.set(ent_type, ent_ids, self.schema.predicate(ns.bse.tag), tag_ids) + store.set(ent_type, {URI('http://example.com/me/entity#1234')}, self.schema.predicate(ns.bse.filesize), {1234}) + store.set(ent_type, {URI('http://example.com/me/entity#4321')}, self.schema.predicate(ns.bse.filesize), {4321}) + # node_type must be in the schema + self.assertRaises(errors.ConsistencyError, set, store.get(self.schema.node(ns.bsfs.Node).child(ns.bsfs.Invalid), ast.filter.IsIn(ent_ids))) + # query must be a filter expression + class Foo(): pass + self.assertRaises(TypeError, set, store.get(ent_type, 1234)) + self.assertRaises(TypeError, set, store.get(ent_type, '1234')) + self.assertRaises(TypeError, set, store.get(ent_type, Foo())) + # run some queries + self.assertSetEqual(set(store.get(tag_type, ast.filter.IsIn(tag_ids))), tag_ids) + self.assertSetEqual(set(store.get(ent_type, ast.filter.Any(ns.bse.tag, ast.filter.IsIn(tag_ids)))), ent_ids) + self.assertSetEqual(set(store.get(ent_type, ast.filter.IsIn(tag_ids))), set()) + # invalid queries raise error + self.assertRaises(errors.ConsistencyError, set, store.get(tag_type, ast.filter.Any(ns.bse.filesize, ast.filter.Equals(1234)))) + self.assertRaises(errors.BackendError, set, store.get(ent_type, ast.filter.Equals('http://example.com/me/entity#1234'))) + # run some more complex query + q = store.get(tag_type, ast.filter.Any(ast.filter.Predicate(ns.bse.tag, reverse=True), + ast.filter.Any(ns.bse.filesize, + ast.filter.LessThan(2000)))) + self.assertSetEqual(set(q), tag_ids) + + + def test_fetch(self): + # store setup + store = SparqlStore.Open() + store.schema = self.schema + # add instances + ent_type = self.schema.node(ns.bsfs.Entity) + tag_type = self.schema.node(ns.bsfs.Tag) + ent_ids = {URI('http://example.com/me/entity#1234'), URI('http://example.com/me/entity#4321')} + tag_ids = {URI('http://example.com/me/tag#1234'), URI('http://example.com/me/tag#4321')} + store.create(ent_type, ent_ids) + store.create(tag_type, tag_ids) + store.set(ent_type, ent_ids, self.schema.predicate(ns.bse.tag), tag_ids) + store.set(ent_type, {URI('http://example.com/me/entity#1234')}, self.schema.predicate(ns.bse.filesize), {1234}) + store.set(ent_type, {URI('http://example.com/me/entity#4321')}, self.schema.predicate(ns.bse.filesize), {4321}) + store.set(ent_type, {URI('http://example.com/me/entity#1234')}, self.schema.predicate(ns.bse.comment), {'hello world'}) + # node_type must be a node from the schema + self.assertRaises(errors.ConsistencyError, list, store.fetch(self.schema.literal(ns.bsfs.Literal), + ast.filter.FilterExpression(), ast.fetch.FetchExpression())) + self.assertRaises(errors.ConsistencyError, list, store.fetch(self.schema.node(ns.bsfs.Node).child(ns.bsfs.Invalid), + ast.filter.FilterExpression(), ast.fetch.FetchExpression())) + # requires a filter and a fetch query + self.assertRaises(TypeError, list, store.fetch(self.schema.node(ns.bsfs.Entity), None, ast.fetch.FetchExpression())) + self.assertRaises(TypeError, list, store.fetch(self.schema.node(ns.bsfs.Entity), 1234, ast.fetch.FetchExpression())) + self.assertRaises(TypeError, list, store.fetch(self.schema.node(ns.bsfs.Entity), 'hello', ast.fetch.FetchExpression())) + self.assertRaises(TypeError, list, store.fetch(self.schema.node(ns.bsfs.Entity), ast.filter.FilterExpression(), None)) + self.assertRaises(TypeError, list, store.fetch(self.schema.node(ns.bsfs.Entity), ast.filter.FilterExpression(), 1234)) + self.assertRaises(TypeError, list, store.fetch(self.schema.node(ns.bsfs.Entity), ast.filter.FilterExpression(), 'hello')) + # fetch emits triples + self.assertSetEqual(set(store.fetch(self.schema.node(ns.bsfs.Entity), + ast.filter.Is('http://example.com/me/entity#1234'), + ast.fetch.Value(ns.bse.filesize, 'filesize'), + )), { + (URI('http://example.com/me/entity#1234'), 'filesize', 1234), + }) + # fetch respects filter query + self.assertSetEqual(set(store.fetch(self.schema.node(ns.bsfs.Entity), + ast.filter.IsIn('http://example.com/me/entity#1234', 'http://example.com/me/entity#4321'), + ast.fetch.Value(ns.bse.filesize, 'filesize'), + )), { + (URI('http://example.com/me/entity#1234'), 'filesize', 1234), + (URI('http://example.com/me/entity#4321'), 'filesize', 4321), + }) + # fetch ignores missing data + self.assertSetEqual(set(store.fetch(self.schema.node(ns.bsfs.Entity), + ast.filter.IsIn('http://example.com/me/entity#1234', 'http://example.com/me/entity#4321'), + ast.fetch.Value(ns.bse.comment, 'comment'), + )), { + (URI('http://example.com/me/entity#1234'), 'comment', 'hello world'), + }) + # fetch emits all triples + self.assertSetEqual(set(store.fetch(self.schema.node(ns.bsfs.Entity), + ast.filter.Is('http://example.com/me/entity#1234'), + ast.fetch.All( + ast.fetch.Value(ns.bse.filesize, 'filesize'), + ast.fetch.Node(ns.bse.tag, 'tag'), + ) + )), { + (URI('http://example.com/me/entity#1234'), 'filesize', 1234), + (URI('http://example.com/me/entity#1234'), 'tag', URI('http://example.com/me/tag#1234')), + (URI('http://example.com/me/entity#1234'), 'tag', URI('http://example.com/me/tag#4321')), + }) + # triples do not repeat + triples = list(store.fetch(self.schema.node(ns.bsfs.Entity), ast.filter.Is('http://example.com/me/entity#1234'), + ast.fetch.All( + ast.fetch.Value(ns.bse.filesize, 'filesize'), + ast.fetch.Node(ns.bse.tag, 'tag'), + ) + )) + self.assertEqual(len(triples), 3) def test_exists(self): # store setup @@ -501,20 +681,24 @@ class TestSparqlStore(unittest.TestCase): store.schema = self.schema # node type must be valid - self.assertRaises(errors.ConsistencyError, store.create, self.schema.node(ns.bsfs.Entity).get_child(ns.bsfs.invalid), { + self.assertRaises(errors.ConsistencyError, store.create, self.schema.node(ns.bsfs.Entity).child(ns.bsfs.invalid), { URI('http://example.com/me/entity#1234'), URI('http://example.com/me/entity#4321')}) + # guid must be valid + self.assertRaises(ValueError, store.create, self.schema.node(ns.bsfs.Entity), {'http://example.com/me/foo and bar'}) + # can create some nodes ent_type = store.schema.node(ns.bsfs.Entity) store.create(ent_type, {URI('http://example.com/me/entity#1234'), URI('http://example.com/me/entity#4321')}) - self.assertSetEqual(set(store._graph), { + self.assertSetEqual(set(store._graph), self.schema_triples | { + # instances (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.RDF.type, rdflib.URIRef(ns.bsfs.Entity)), (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.RDF.type, rdflib.URIRef(ns.bsfs.Entity)), }) # existing nodes are skipped store.create(ent_type, {URI('http://example.com/me/entity#1234'), URI('http://example.com/me/entity#5678')}) - self.assertSetEqual(set(store._graph), { + self.assertSetEqual(set(store._graph), self.schema_triples | { # previous triples (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.RDF.type, rdflib.URIRef(ns.bsfs.Entity)), (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.RDF.type, rdflib.URIRef(ns.bsfs.Entity)), @@ -525,7 +709,7 @@ class TestSparqlStore(unittest.TestCase): # can create nodes of a different type tag_type = store.schema.node(ns.bsfs.Tag) store.create(tag_type, {URI('http://example.com/me/tag#1234'), URI('http://example.com/me/tag#4321')}) - self.assertSetEqual(set(store._graph), { + self.assertSetEqual(set(store._graph), self.schema_triples | { # previous triples (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.RDF.type, rdflib.URIRef(ns.bsfs.Entity)), (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.RDF.type, rdflib.URIRef(ns.bsfs.Entity)), @@ -538,7 +722,7 @@ class TestSparqlStore(unittest.TestCase): # creation does not change types of existing nodes tag_type = store.schema.node(ns.bsfs.Tag) store.create(tag_type, {URI('http://example.com/me/entity#1234'), URI('http://example.com/me/entity#4321')}) - self.assertSetEqual(set(store._graph), { + self.assertSetEqual(set(store._graph), self.schema_triples | { # previous triples (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.RDF.type, rdflib.URIRef(ns.bsfs.Entity)), (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.RDF.type, rdflib.URIRef(ns.bsfs.Entity)), @@ -562,7 +746,7 @@ class TestSparqlStore(unittest.TestCase): p_comment = store.schema.predicate(ns.bse.comment) p_author = store.schema.predicate(ns.bse.author) p_tag = store.schema.predicate(ns.bse.tag) - p_invalid = store.schema.predicate(ns.bsfs.Predicate).get_child(ns.bsfs.foo, range=store.schema.node(ns.bsfs.Tag)) + p_invalid = store.schema.predicate(ns.bsfs.Predicate).child(ns.bsfs.foo, range=store.schema.node(ns.bsfs.Tag)) # create node instances ent_ids = { URI('http://example.com/me/entity#1234'), @@ -585,12 +769,15 @@ class TestSparqlStore(unittest.TestCase): store.create(user_type, user_ids) # invalid node_type is not permitted - self.assertRaises(errors.ConsistencyError, store.set, self.schema.node(ns.bsfs.Node).get_child(ns.bse.foo), + self.assertRaises(errors.ConsistencyError, store.set, self.schema.node(ns.bsfs.Node).child(ns.bse.foo), ent_ids, p_comment, {'hello world'}) # invalid predicate is not permitted self.assertRaises(errors.ConsistencyError, store.set, ent_type, ent_ids, p_invalid, {'http://example.com/me/tag#1234'}) + # invalid guid is not permitted + self.assertRaises(ValueError, store.set, ent_type, {'http://example.com/me/foo and bar'}, p_filesize, {1234}) + # predicate must match node_type self.assertRaises(errors.ConsistencyError, store.set, tag_type, tag_ids, p_filesize, {1234}) @@ -760,6 +947,23 @@ class TestSparqlStore(unittest.TestCase): # inexistent guids self.assertRaises(errors.InstanceError, store.set, ent_type, {URI('http://example.com/me/entity#foobar')}, p_comment, {'xyz'}) + # BinaryBlob values are base64 encoded + p_asset = store.schema.predicate(ns.bse.asset) + store.set(ent_type, ent_ids, p_asset, {bytes(range(128)), bytes(range(128, 256))}) + blob1 = rdflib.Literal('AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn8=', + datatype=rdflib.URIRef(ns.bsl.BinaryBlob)) + blob2 = rdflib.Literal('gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp+goaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2+v8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4+Tl5ufo6err7O3u7/Dx8vP09fb3+Pn6+/z9/v8=', + datatype=rdflib.URIRef(ns.bsl.BinaryBlob)) + self.assertTrue(set(store._graph).issuperset({ + (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.URIRef(p_asset.uri), blob1), + (rdflib.URIRef('http://example.com/me/entity#1234'), rdflib.URIRef(p_asset.uri), blob2), + (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.URIRef(p_asset.uri), blob1), + (rdflib.URIRef('http://example.com/me/entity#4321'), rdflib.URIRef(p_asset.uri), blob2), + })) + # lit.value returns the original bytes value + self.assertSetEqual({lit.value for lit in store._graph.objects(None, rdflib.URIRef(p_asset.uri))}, + {bytes(range(128)), bytes(range(128, 256))}) + ## main ## diff --git a/test/triple_store/sparql/test_utils.py b/test/triple_store/sparql/test_utils.py new file mode 100644 index 0000000..44a1299 --- /dev/null +++ b/test/triple_store/sparql/test_utils.py @@ -0,0 +1,152 @@ + +# standard imports +import operator +import re +import unittest + +# external imports +import rdflib + +# bsie imports +from bsfs.namespace import ns + +# objects to test +from bsfs.triple_store.sparql.utils import GenHopName, Query + + +## code ## + +ns.bse = ns.bsfs.Entity() + +class TestGenHopName(unittest.TestCase): + def test_next(self): + # baseline + self.assertEqual(next(GenHopName(prefix='?foo', start=123)), '?foo123') + # respects prefix + self.assertEqual(next(GenHopName(prefix='?bar', start=123)), '?bar123') + # respects start + self.assertEqual(next(GenHopName(prefix='?foo', start=321)), '?foo321') + # counts up + cnt = GenHopName(prefix='?foo', start=998) + self.assertEqual(next(cnt), '?foo998') + self.assertEqual(next(cnt), '?foo999') + self.assertEqual(next(cnt), '?foo1000') + self.assertEqual(next(cnt), '?foo1001') + + def test_essentials(self): + # can get the prefix + self.assertEqual(GenHopName(prefix='?foo', start=123).prefix, '?foo') + # can get the counter + self.assertEqual(GenHopName(prefix='?foo', start=123).curr, 122) + + +class TestQuery(unittest.TestCase): + def setUp(self): + self.root_type = 'https://schema.bsfs.io/core/Entity' + self.root_head = '?root' + self.select = (('?head', 'name'), ) + self.where = f'?root <{ns.bse.tag}> ?head' + + def test_essentials(self): + # can access members + q = Query(self.root_type, self.root_head, self.select, self.where) + self.assertEqual(q.root_type, self.root_type) + self.assertEqual(q.root_head, self.root_head) + self.assertEqual(q.select, self.select) + self.assertEqual(q.where, self.where) + # comparison + self.assertEqual(q, Query(self.root_type, self.root_head, self.select, self.where)) + self.assertEqual(hash(q), hash(Query(self.root_type, self.root_head, self.select, self.where))) + # comparison respects root_type + self.assertNotEqual(q, Query('https://schema.bsfs.io/core/Tag', self.root_head, self.select, self.where)) + self.assertNotEqual(hash(q), hash(Query('https://schema.bsfs.io/core/Tag', self.root_head, self.select, self.where))) + # comparison respects root_head + self.assertNotEqual(q, Query(self.root_type, '?foo', self.select, self.where)) + self.assertNotEqual(hash(q), hash(Query(self.root_type, '?foo', self.select, self.where))) + # comparison respects select + self.assertNotEqual(q, Query(self.root_type, self.root_head, (('?head', 'foo'), ), self.where)) + self.assertNotEqual(hash(q), hash(Query(self.root_type, self.root_head, (('?head', 'foo'), ), self.where))) + # comparison respects where + self.assertNotEqual(q, Query(self.root_type, self.root_head, self.select, '?root bse:filename ?head')) + self.assertNotEqual(hash(q), hash(Query(self.root_type, self.root_head, self.select, '?root bse:filename ?head'))) + # string conversion + self.assertEqual(str(q), q.query) + self.assertEqual(repr(q), "Query(https://schema.bsfs.io/core/Entity, ?root, (('?head', 'name'),), ?root <https://schema.bsfs.io/core/Entity#tag> ?head)") + + def test_add(self): + q = Query(self.root_type, self.root_head, self.select, self.where) + # can only add a query + self.assertRaises(TypeError, operator.add, q, 1234) + self.assertRaises(TypeError, operator.add, q, 'foobar') + # root type and head must match + self.assertRaises(ValueError, operator.add, q, Query('https://schema.bsfs.io/core/Node/Tag', self.root_head)) + self.assertRaises(ValueError, operator.add, q, Query(self.root_type, '?foobar')) + # select and were are combined + combo = q + Query(self.root_type, self.root_head, (('?foo', 'bar'), ), f'?root <{ns.bse.filename}> ?foo') + self.assertEqual(combo.select, (('?head', 'name'), ('?foo', 'bar'))) + self.assertEqual(combo.where, f'?root <{ns.bse.tag}> ?head . ?root <{ns.bse.filename}> ?foo') + # select can be empty + combo = q + Query(self.root_type, self.root_head, None, f'?root <{ns.bse.filename}> ?foo') + self.assertEqual(combo.select, (('?head', 'name'), )) + combo = Query(self.root_type, self.root_head, None, f'?root <{ns.bse.filename}> ?foo') + q + self.assertEqual(combo.select, (('?head', 'name'), )) + combo = Query(self.root_type, self.root_head, None, self.where) + Query(self.root_type, self.root_head, None, f'?root <{ns.bse.filename}> ?foo') + self.assertEqual(combo.select, tuple()) + # where can be empty + combo = q + Query(self.root_type, self.root_head, (('?foo', 'bar'), )) + self.assertEqual(combo.where, self.where) + combo = Query(self.root_type, self.root_head, (('?foo', 'bar'), )) + q + self.assertEqual(combo.where, self.where) + combo = Query(self.root_type, self.root_head, self.select) + Query(self.root_type, self.root_head, (('?foo', 'bar'), )) + self.assertEqual(combo.where, '') + + def test_names(self): + self.assertEqual(Query(self.root_type, self.root_head, (('?head', 'name'), ), self.where).names, + ('name', )) + self.assertEqual(Query(self.root_type, self.root_head, (('?head', 'name'), ('?foo', 'bar')), self.where).names, + ('name', 'bar')) + + def test_query(self): + def normalize(value): + value = value.strip() + value = value.lower() + value = value.replace(r'\n', ' ') + value, _ = re.subn('\s\s+', ' ', value) + return value + # query composes a valid query + q = Query(self.root_type, self.root_head, self.select, self.where) + self.assertEqual(normalize(q.query), normalize(f'select distinct ?root (?head as ?name) where {{ ?root <{ns.rdf.type}>/<{ns.rdfs.subClassOf}>* <https://schema.bsfs.io/core/Entity> . ?root <{ns.bse.tag}> ?head }} order by str(?root)')) + # select and where are optional + q = Query(self.root_type, self.root_head) + self.assertEqual(normalize(q.query), normalize(f'select distinct ?root where {{ ?root <{ns.rdf.type}>/<{ns.rdfs.subClassOf}>* <https://schema.bsfs.io/core/Entity> . }} order by str(?root)')) + # select and where need not to correspond + q = Query(self.root_type, self.root_head, (('?head', 'name'), )) + self.assertEqual(normalize(q.query), normalize(f'select distinct ?root (?head as ?name) where {{ ?root <{ns.rdf.type}>/<{ns.rdfs.subClassOf}>* <https://schema.bsfs.io/core/Entity> . }} order by str(?root)')) + # query is used for string representation + self.assertEqual(str(q), q.query) + + def test_call(self): + graph = rdflib.Graph() + # schema + graph.add((rdflib.URIRef('https://schema.bsfs.io/core/Document'), rdflib.URIRef(ns.rdfs.subClassOf), rdflib.URIRef('https://schema.bsfs.io/core/Entity'))) + # nodes + graph.add((rdflib.URIRef('http://example.com/entity#1234'), rdflib.URIRef(ns.rdf.type), rdflib.URIRef('https://schema.bsfs.io/core/Entity'))) + graph.add((rdflib.URIRef('http://example.com/doc#1234'), rdflib.URIRef(ns.rdf.type), rdflib.URIRef('https://schema.bsfs.io/core/Document'))) + # links + graph.add((rdflib.URIRef('http://example.com/entity#1234'), rdflib.URIRef(ns.bse.tag), rdflib.Literal('tag#1234', datatype=rdflib.XSD.string))) + graph.add((rdflib.URIRef('http://example.com/doc#1234'), rdflib.URIRef(ns.bse.tag), rdflib.Literal('tag#1234', datatype=rdflib.XSD.string))) + # run query on a given graph + query = Query(self.root_type, self.root_head, self.select, self.where) + self.assertSetEqual(set(query(graph)), { + (rdflib.URIRef('http://example.com/entity#1234'), rdflib.Literal('tag#1234', datatype=rdflib.XSD.string)), + (rdflib.URIRef('http://example.com/doc#1234'), rdflib.Literal('tag#1234', datatype=rdflib.XSD.string)), + }) + # query actually considers the passed graph + self.assertSetEqual(set(query(rdflib.Graph())), set()) + +## main ## + +if __name__ == '__main__': + unittest.main() + +## EOF ## diff --git a/test/triple_store/test_base.py b/test/triple_store/test_base.py index a4b0559..4c4a9b6 100644 --- a/test/triple_store/test_base.py +++ b/test/triple_store/test_base.py @@ -1,9 +1,4 @@ -""" -Part of the bsfs test suite. -A copy of the license is provided with the project. -Author: Matthias Baumgartner, 2022 -""" # imports import unittest @@ -35,6 +30,12 @@ class DummyBase(TripleStoreBase): def schema(self, schema): pass + def get(self, node_type, query): + pass + + def fetch(self, node_type, filter, fetch): + pass + def exists(self, node_type, guids): pass diff --git a/test/utils/test_commons.py b/test/utils/test_commons.py index ce73788..29e3046 100644 --- a/test/utils/test_commons.py +++ b/test/utils/test_commons.py @@ -1,14 +1,9 @@ -""" -Part of the tagit test suite. -A copy of the license is provided with the project. -Author: Matthias Baumgartner, 2022 -""" # imports import unittest # objects to test -from bsfs.utils.commons import typename +from bsfs.utils.commons import typename, normalize_args ## code ## @@ -21,6 +16,21 @@ class TestCommons(unittest.TestCase): self.assertEqual(typename(123), 'int') self.assertEqual(typename(None), 'NoneType') + def test_normalize_args(self): + # one argument + self.assertEqual(normalize_args(1), (1, )) + # pass as arguments + self.assertEqual(normalize_args(1,2,3), (1,2,3)) + # pass as iterator + self.assertEqual(normalize_args(iter([1,2,3])), (1,2,3)) + # pass as generator + self.assertEqual(normalize_args((i for i in range(1, 4))), (1,2,3)) + self.assertEqual(normalize_args(i for i in range(1, 4)), (1,2,3)) # w/o brackets + # pass as iterable + self.assertEqual(normalize_args([1,2,3]), (1,2,3)) + # pass an iterable with a single item + self.assertEqual(normalize_args([1]), (1, )) + ## main ## diff --git a/test/utils/test_uri.py b/test/utils/test_uri.py index 770e65a..1c4c9f9 100644 --- a/test/utils/test_uri.py +++ b/test/utils/test_uri.py @@ -1,9 +1,4 @@ -""" -Part of the tagit test suite. -A copy of the license is provided with the project. -Author: Matthias Baumgartner, 2022 -""" # imports import operator import unittest @@ -40,6 +35,16 @@ class TestURI(unittest.TestCase): self.assertTrue(URI.is_parseable('telnet://192.0.2.16:80/')) self.assertTrue(URI.is_parseable('urn:oasis:names:specification:docbook:dtd:xml:4.1.2')) + # some characters are prohibited + self.assertFalse(URI.is_parseable('http://example.com/foo<bar')) + self.assertFalse(URI.is_parseable('http://example.com/foo>bar')) + self.assertFalse(URI.is_parseable('http://example.com/foo bar')) + self.assertFalse(URI.is_parseable('http://example.com/foo{bar')) + self.assertFalse(URI.is_parseable('http://example.com/foo}bar')) + self.assertFalse(URI.is_parseable('http://example.com/foo|bar')) + self.assertFalse(URI.is_parseable('http://example.com/foo^bar')) + self.assertFalse(URI.is_parseable('http://example.com/foo\\bar')) + # uri cannot end with a scheme delimiter self.assertFalse(URI.is_parseable('http://')) # port must be a number @@ -164,10 +169,10 @@ class TestURI(unittest.TestCase): def test_overloaded(self): # composition - self.assertIsInstance(URI('http://user@www.example.com:1234/{}/path1?{}#fragment') + 'hello', URI) - self.assertIsInstance(URI('http://user@www.example.com:1234/{}/path1?{}#fragment') * 2, URI) - self.assertIsInstance(2 * URI('http://user@www.example.com:1234/{}/path1?{}#fragment'), URI) # rmul - self.assertIsInstance(URI('http://user@www.example.com:1234/{}/path1?{}#fragment').join(['hello', 'world']) , URI) + self.assertIsInstance(URI('http://user@www.example.com:1234/path0/path1?query#fragment') + 'hello', URI) + self.assertIsInstance(URI('http://user@www.example.com:1234/path0/path1?query#fragment') * 2, URI) + self.assertIsInstance(2 * URI('http://user@www.example.com:1234/path0/path1?query#fragment'), URI) # rmul + self.assertIsInstance(URI('http://user@www.example.com:1234/path0/path1?query#fragment').join(['hello', 'world']) , URI) # stripping self.assertIsInstance(URI('http://user@www.example.com:1234/path0/path1?query#fragment').strip(), URI) self.assertIsInstance(URI('http://user@www.example.com:1234/path0/path1?query#fragment').lstrip(), URI) @@ -176,7 +181,6 @@ class TestURI(unittest.TestCase): self.assertIsInstance(URI('http://user@www.example.com:1234/path0/path1?query#fragment').lower(), URI) self.assertIsInstance(URI('http://user@www.example.com:1234/path0/path1?query#fragment').upper(), URI) # formatting - self.assertIsInstance(URI('http://user@www.example.com:1234/{}/path1?{}#fragment').format('hello', 'world'), URI) self.assertIsInstance(URI('http://user@www.example.com:1234/%s/path1?%s#fragment') % ('hello', 'world'), URI) self.assertIsInstance(URI('http://user@www.example.com:1234/path0/path1?query#fragment').replace('path0', 'pathX'), URI) diff --git a/test/utils/test_uuid.py b/test/utils/test_uuid.py index 49176d4..8f519d9 100644 --- a/test/utils/test_uuid.py +++ b/test/utils/test_uuid.py @@ -1,9 +1,4 @@ -""" -Part of the tagit test suite. -A copy of the license is provided with the project. -Author: Matthias Baumgartner, 2022 -""" # imports import os import re @@ -83,6 +78,20 @@ class TestUCID(unittest.TestCase): def test_from_path(self): self.assertEqual(UCID.from_path(self._path), self._checksum) + def test_from_buffer(self): + with open(self._path, 'rb') as ifile: + self.assertEqual(UCID.from_buffer(ifile), self._checksum) + with open(self._path) as ifile: + self.assertEqual(UCID.from_buffer(ifile), self._checksum) + + def test_from_bytes(self): + with open(self._path, 'rb') as ifile: + self.assertEqual(UCID.from_bytes(ifile.read()), self._checksum) + + def test_from_dict(self): + self.assertEqual(UCID.from_dict({'hello': 'world', 'foo': 1234, 'bar': False}), + '8d2544395a0d2827e3d9ce8cd619d5e3f801e8126bf3f93ee5abd38158959585') + ## main ## |