aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.pylintrc4
-rw-r--r--bsfs/query/ast/__init__.py2
-rw-r--r--bsfs/query/ast/filter_.py405
-rw-r--r--bsfs/query/validator.py336
-rw-r--r--bsfs/utils/__init__.py3
-rw-r--r--bsfs/utils/commons.py34
-rw-r--r--bsfs/utils/errors.py3
-rw-r--r--test/query/ast/test_filter_.py456
-rw-r--r--test/query/test_validator.py237
-rw-r--r--test/utils/test_commons.py17
10 files changed, 1326 insertions, 171 deletions
diff --git a/.pylintrc b/.pylintrc
index 7885c4e..bcb2a86 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -88,7 +88,7 @@ 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
@@ -164,7 +164,7 @@ score=yes
[SIMILARITIES]
# Minimum lines number of a similarity.
-min-similarity-lines=4
+min-similarity-lines=5
[STRING]
diff --git a/bsfs/query/ast/__init__.py b/bsfs/query/ast/__init__.py
index 0ee7385..704d051 100644
--- a/bsfs/query/ast/__init__.py
+++ b/bsfs/query/ast/__init__.py
@@ -14,7 +14,7 @@ Author: Matthias Baumgartner, 2022
import typing
# inner-module imports
-from . import filter_ as filter
+from . import filter_ as filter # pylint: disable=redefined-builtin
# exports
__all__: typing.Sequence[str] = (
diff --git a/bsfs/query/ast/filter_.py b/bsfs/query/ast/filter_.py
index 4086fc1..b129ded 100644
--- a/bsfs/query/ast/filter_.py
+++ b/bsfs/query/ast/filter_.py
@@ -1,5 +1,27 @@
"""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'),
+... Any(ns.bsm.guid, Any(ns.bsm.guid, Equals('hello'))),
+... 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.
+
Part of the BlackStar filesystem (bsfs) module.
A copy of the license is provided with the project.
Author: Matthias Baumgartner, 2022
@@ -8,12 +30,45 @@ Author: Matthias Baumgartner, 2022
from collections import abc
import typing
+# bsfs imports
+from bsfs.utils import URI, typename, normalize_args
+
+# inner-module imports
+#from . import utils
+
# exports
-__all__ : typing.Sequence[str] = []
+__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."""
@@ -27,4 +82,352 @@ class _Expression(abc.Hashable):
"""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)
+ # 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(self.expr))) # FIXME: Unique hash of different orders over self.expr
+
+ 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):
+ """
+ """
+
+ # 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 format must correspond to literal type; can be a string, a number, or a Node
+ """
+
+
+class Substring(_Value):
+ """Value matches a substring
+ NOTE: value format must be a string
+ """
+
+
+class StartsWith(_Value):
+ """Value begins with a given string."""
+
+
+class EndsWith(_Value):
+ """Value ends with a given string."""
+
+
+class _Bounded(FilterExpression):
+ """
+ """
+
+ # 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.
+ NOTE: only on numerical literals
+ """
+
+
+class GreaterThan(_Bounded):
+ """Value is (strictly) larger than threshold
+ NOTE: only on numerical literals
+ """
+
+
+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(self.expr))) # FIXME: Unique hash of different orders over self.expr
+
+ def __eq__(self, other) -> bool:
+ return super().__eq__(other) and self.expr == other.expr
+
+
+# Helpers
+
+def IsIn(*values): # pylint: disable=invalid-name # explicitly mimics an expression
+ """Match any of the given URIs."""
+ return Or(Is(value) for value in normalize_args(*values))
+
+def IsNotIn(*values): # pylint: disable=invalid-name # explicitly mimics an expression
+ """Match none of the given URIs."""
+ return Not(IsIn(*values))
+
## EOF ##
diff --git a/bsfs/query/validator.py b/bsfs/query/validator.py
index 123b947..352203a 100644
--- a/bsfs/query/validator.py
+++ b/bsfs/query/validator.py
@@ -9,6 +9,8 @@ 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
@@ -22,6 +24,18 @@ __all__ : typing.Sequence[str] = (
## 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
+
+ """
+
+ # vertex types
+ T_VERTEX = typing.Union[bsc.Node, bsc.Literal] # FIXME: Shouldn't this be in the schema?
# schema to validate against.
schema: bsc.Schema
@@ -29,180 +43,182 @@ class Filter():
def __init__(self, schema: bsc.Schema):
self.schema = schema
- def parse(self, node: ast.filter.FilterExpression, subject: bsc.types._Vertex):
- # subject is a node type
- if not isinstance(subject, bsc.Node):
- raise errors.ConsistencyError(f'Expected a node, found {subject}')
- # subject exists in the schema
- if subject not in self.schema.nodes:
- raise errors.ConsistencyError(f'Invalid node type {subject}')
- # root expression is valid
- self._parse(node, subject)
+ def __call__(self, root_type: bsc.Node, query: ast.filter.FilterExpression):
+ """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
- def _parse_numerical_expression(self, node: ast.filter.FilterExpression, subject: bsc.types._Vertex):
- if isinstance(node, ast.filter.And):
- return self._and(node, subject)
- elif isinstance(node, ast.filter.Or):
- return self._or(node, subject)
- elif isinstance(node, ast.filter.LessThan):
- return self._lessThan(node, subject)
- elif isinstance(node, ast.filter.GreaterThan):
- return self._greaterThan(node, subject)
- elif isinstance(node, ast.filter.Equals):
- return self._equals(node, subject, numerical=True)
- else:
- raise errors.ConsistencyError(f'Expected a numerical expression, found {node}')
-
-
- def __branch(self, node: typing.Union[ast.filter.Any, ast.filter.And], subject: bsc.types._Vertex):
- # subject is a node type
- if not isinstance(subject, bsc.Node):
- raise errors.ConsistencyError(f'Expected a node, found {subject}')
- # subject exists in the schema
- if subject not in self.schema.nodes:
- raise errors.ConsistencyError(f'Invalid node type {subject}')
- # predicate is valid
- dom, rng = self._parse_predicate_expression(node.predicate)
- # subject is a subtype of the predicate's domain
- if not subject <= dom:
- raise errors.ConsistencyError(f'Expected type {dom}, found {subject}')
- # child expression is valid
- self._parse_filter_expression(node.expr, rng)
+ ## routing methods
+
+ def _parse_filter_expression(self, type_: T_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.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[T_VERTEX, T_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[T_VERTEX, T_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)
+ dom, rng = pred.domain, pred.range
+ if rng is None:
+ # FIXME: It is a design error that Predicates can have a None range...
+ raise errors.BackendError(f'predicate {pred} has no range')
+ if node.reverse:
+ dom, rng = rng, dom # type: ignore [assignment] # variable re-use confuses mypy
+ # return domain and range
+ return dom, rng
- def _any(self, node: ast.filter.Any, subject: bsc.types._Vertex):
- return self.__branch(node, subject)
+ def _one_of(self, node: ast.filter.OneOf) -> typing.Tuple[T_VERTEX, T_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)
+ try:
+ # 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')
+ except TypeError as err: # compared literal vs. node
+ raise errors.ConsistencyError(f'domains {subdom} and {dom} are not of the same type') from err
- def _all(self, node: ast.filter.All, subject: bsc.types._Vertex):
- return self.__branch(node, subject)
+ try:
+ # 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')
+ except TypeError as err: # compared literal vs. node
+ raise errors.ConsistencyError(f'ranges {subrng} and {rng} are not of the same type') from err
+ # check domain and range
+ if dom is None or rng is None:
+ # OneOf guarantees at least one expression, these two cases cannot happen
+ raise errors.UnreachableError()
+ # return domain and range
+ return dom, rng
- def __agg(self, node: typing.Union[ast.filter.And, ast.filter.Or], subject: bsc.types._Vertex):
+ ## intermediates
+
+ def _branch(self, type_: T_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_: T_VERTEX, node: ast.filter._Agg):
for expr in node:
# child expression is valid
- self._parse_filter_expression(expr, subject)
-
- def _and(self, node: ast.filter.And, subject: bsc.types._Vertex):
- return self.__agg(node, subject)
-
- def _or(self, node: ast.filter.Or, subject: bsc.types._Vertex):
- return self.__agg(node, subject)
-
+ self._parse_filter_expression(type_, expr)
- def _not(self, node: ast.filter.Not, subject: bsc.types._Vertex):
+ def _not(self, type_: T_VERTEX, node: ast.filter.Not):
# child expression is valid
- self._parse_filter_expression(node.expr, subject)
-
-
- def _has(self, node: ast.filter.Has, subject: bsc.types._Vertex):
- # subject is a node type
- if not isinstance(subject, bsc.Node):
- raise errors.ConsistencyError(f'Expected a node, found {subject}')
- # subject exists in the schema
- if subject not in self.schema.nodes:
- raise errors.ConsistencyError(f'Invalid node type {subject}')
+ self._parse_filter_expression(type_, node.expr)
+
+ def _has(self, type_: T_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, rng = self._parse_predicate_expression(node.predicate)
- # subject is a subtype of the predicate's domain
- if not subject <= dom:
- raise errors.ConsistencyError(f'Expected type {dom}, found {subject}')
+ 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_numerical_expression(node.count, self.schema.literal(ns.xsd.numerical))
-
-
- def _equals(self, node: ast.filter.Equals, subject: bsc.types._Vertex, numerical: bool = False):
- # subject is a literal
- #if not isinstance(subject, bsc.Literal):
- # raise errors.ConsistencyError(f'Expected a literal, found {subject}')
- if isinstance(subject, bsc.Node):
- # FIXME: How to handle this case?
- # FIXME: How to check if a NodeType is acceptable?
- # FIXME: Maybe use flags to control what is expected as node identifiers?
- from bsfs.graph.nodes import Nodes # FIXME
- if not isinstance(node.value, Nodes) and not isinstance(node.value, URI):
- raise errors.ConsistencyError(f'Expected a Nodes or URI, found {node.value}')
- elif isinstance(subject, bsc.Literal):
- # literal exists in the schema
- if subject not in self.schema.literals:
- raise errors.ConsistencyError(f'Invalid literal type {subject}')
- else:
- # FIXME:
- raise errors.ConsistencyError(f'Expected a literal, found {subject}')
- # node.value is numeric (if requested)
- if numerical and not isinstance(node.value, float) and not isinstance(node.value, int):
- raise errors.ConsistencyError(f'Expected a numerical value (int or float), found {node.value}')
- # NOTE: We cannot check if node.value agrees with the subject since we don't know
- # all literal types, their hierarchy, and how the backend converts datatypes.
-
-
- def _substring(self, node: ast.filter.Substring, subject: bsc.types._Vertex):
- # subject is a literal
- if not isinstance(subject, bsc.Literal):
- raise errors.ConsistencyError(f'Expected a literal, found {subject}')
- # literal exists in the schema
- if subject not in self.schema.literals:
- raise errors.ConsistencyError(f'Invalid literal type {subject}')
- # node.value matches literal datatype
- if not subject.is_a(ns.xsd.string):
- raise errors.ConsistencyError(f'Expected a string literal, found {subject}')
-
-
- def _lessThan(self, node: ast.filter.LessThan, subject: bsc.types._Vertex):
- # subject is a literal
- if not isinstance(subject, bsc.Literal):
- raise errors.ConsistencyError(f'Expected a literal, found {subject}')
- # literal exists in the schema
- if subject not in self.schema.literals:
- raise errors.ConsistencyError(f'Invalid literal type {subject}')
- # subject is numerical
- if not subject.is_a(ns.xsd.numerical):
- raise errors.ConsistencyError(f'Expected a numerical literal, found {subject}')
-
-
- def _greaterThan(self, node: ast.filter.GreaterThan, subject: bsc.types._Vertex):
- # subject is a literal
- if not isinstance(subject, bsc.Literal):
- raise errors.ConsistencyError(f'Expected a literal, found {subject}')
- # literal exists in the schema
- if subject not in self.schema.literals:
- raise errors.ConsistencyError(f'Invalid literal type {subject}')
- # subject is numerical
- if not subject.is_a(ns.xsd.numerical):
- raise errors.ConsistencyError(f'Expected a numerical literal, found {subject}')
-
-
- def _predicate(self, node: ast.filter.Predicate):
- try:
- # predicate exists in the schema
- pred = self.schema.predicate(node.predicate)
- except KeyError:
- raise errors.ConsistencyError(f'') # FIXME
- if node.reverse:
- return pred.range, pred.domain
- else:
- return pred.domain, pred.range
-
+ # FIXME: We have to ensure that ns.xsd.integer is always known in the schema!
+ self._parse_filter_expression(self.schema.literal(ns.xsd.integer), node.count)
+
+
+ ## conditions
+
+ def _is(self, type_: T_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_: T_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_: T_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')
+ # FIXME: Check if node.value corresponds to type_
- def _oneOf(self, node: ast.filter.OneOf):
- dom, rng = None, None
- for pred in node:
- try:
- # parse child expression
- subdom, subrng = self._parse_predicate_expression(pred)
- # domain and range must be related across all child expressions
- if not subdom <= dom and not subdom >= dom:
- raise errors.ConsistencyError(f'') # FIXME
- if not subrng <= rng and not subrng >= rng:
- raise errors.ConsistencyError(f'') # FIXME
- # determine overall domain and range
- if dom is None or subdom < dom: # pick most specific domain
- dom = subdom
- if rng is None or subrng > rng: # pick most generic range
- rng = subrng
- except KeyError:
- raise errors.ConsistencyError(f'')
- return dom, rng
## EOF ##
diff --git a/bsfs/utils/__init__.py b/bsfs/utils/__init__.py
index 94680ee..6737cef 100644
--- a/bsfs/utils/__init__.py
+++ b/bsfs/utils/__init__.py
@@ -9,7 +9,7 @@ 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 +19,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..e9f0b7f 100644
--- a/bsfs/utils/commons.py
+++ b/bsfs/utils/commons.py
@@ -5,10 +5,12 @@ 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 +21,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..be9d40e 100644
--- a/bsfs/utils/errors.py
+++ b/bsfs/utils/errors.py
@@ -38,4 +38,7 @@ class UnreachableError(ProgrammingError):
class ConfigError(_BSFSError):
"""User config issue."""
+class BackendError(_BSFSError):
+ """Could not parse an AST structure."""
+
## EOF ##
diff --git a/test/query/ast/test_filter_.py b/test/query/ast/test_filter_.py
index cc815e3..4f69bdc 100644
--- a/test/query/ast/test_filter_.py
+++ b/test/query/ast/test_filter_.py
@@ -8,16 +8,468 @@ Author: Matthias Baumgartner, 2022
import unittest
# bsfs imports
+from bsfs.namespace import ns
+from bsfs.utils import URI
# objects to test
-from bsfs.query.ast.filter_ import _Expression
+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
+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
## code ##
class TestExpression(unittest.TestCase):
def test_essentials(self):
- raise NotImplementedError()
+ # 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 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 testIsIn(self):
+ # 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'),
+ Or(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']),
+ Or(Is('http://example.com/entity#1234')))
+
+
+ def testIsNotIn(self):
+ # 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(Or(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(Or(Is('http://example.com/entity#1234'))))
+
## main ##
diff --git a/test/query/test_validator.py b/test/query/test_validator.py
index 0e88ad3..4f8364a 100644
--- a/test/query/test_validator.py
+++ b/test/query/test_validator.py
@@ -8,6 +8,10 @@ Author: Matthias Baumgartner, 2022
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
@@ -16,10 +20,237 @@ from bsfs.query.validator import Filter
## code ##
class TestFilter(unittest.TestCase):
- def test_parse(self):
- raise NotImplementedError()
+ def setUp(self):
+ self.schema = _schema.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 .
+ bsfs:URI rdfs:subClassOf bsfs:Literal .
+
+ bsfs:Tag rdfs:subClassOf bsfs:Node .
+ xsd:string rdfs:subClassOf bsfs:Literal .
+ xsd:integer rdfs:subClassOf bsfs:Literal .
+
+ 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):
+ # 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).get_child(ns.bsfs.Image), None)
+ self.assertRaises(errors.ConsistencyError, self.validate, self.schema.node(ns.bsfs.Entity).get_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')))),
+ )))))
+ # 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).get_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).get_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).get_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).get_child(ns.bsfs.Invalid),
+ ast.filter.Equals('hello world'))
+ self.assertRaises(errors.ConsistencyError, self.validate._value, self.schema.literal(ns.bsfs.Literal).get_child(ns.bsfs.Invalid),
+ ast.filter.Substring('hello world'))
+ self.assertRaises(errors.ConsistencyError, self.validate._value, self.schema.literal(ns.bsfs.Literal).get_child(ns.bsfs.Invalid),
+ ast.filter.StartsWith('hello world'))
+ self.assertRaises(errors.ConsistencyError, self.validate._value, self.schema.literal(ns.bsfs.Literal).get_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).get_child(ns.bsfs.Invalid),
+ ast.filter.GreaterThan(0))
+ self.assertRaises(errors.ConsistencyError, self.validate._bounded, self.schema.literal(ns.bsfs.Literal).get_child(ns.bsfs.Invalid),
+ 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)))
- # FIXME: subtests for individual functions
## main ##
diff --git a/test/utils/test_commons.py b/test/utils/test_commons.py
index ce73788..3ad6dea 100644
--- a/test/utils/test_commons.py
+++ b/test/utils/test_commons.py
@@ -8,7 +8,7 @@ Author: Matthias Baumgartner, 2022
import unittest
# objects to test
-from bsfs.utils.commons import typename
+from bsfs.utils.commons import typename, normalize_args
## code ##
@@ -21,6 +21,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 ##