diff options
-rw-r--r-- | bsfs/query/ast/filter_.py | 58 | ||||
-rw-r--r-- | test/query/ast_test/test_filter_.py | 118 |
2 files changed, 165 insertions, 11 deletions
diff --git a/bsfs/query/ast/filter_.py b/bsfs/query/ast/filter_.py index 798d37f..44490fc 100644 --- a/bsfs/query/ast/filter_.py +++ b/bsfs/query/ast/filter_.py @@ -31,10 +31,7 @@ from collections import abc import typing # bsfs imports -from bsfs.utils import URI, typename, normalize_args - -# inner-module imports -#from . import utils +from bsfs.utils import URI, errors, typename, normalize_args # exports __all__ : typing.Sequence[str] = ( @@ -460,10 +457,61 @@ class OneOf(PredicateExpression, abc.Collection): 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)) + 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): # pylint: disable=invalid-name # explicitly mimics an expression """Match none of the given URIs.""" return Not(IsIn(*values)) + +def Between( + lo: float = float('-inf'), + hi: float = float('inf'), + lo_strict: bool = True, + hi_strict: bool = True, + ): + """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(f'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): + """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): + """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/test/query/ast_test/test_filter_.py b/test/query/ast_test/test_filter_.py index 9eb92e2..39b98f8 100644 --- a/test/query/ast_test/test_filter_.py +++ b/test/query/ast_test/test_filter_.py @@ -20,6 +20,7 @@ from bsfs.query.ast.filter_ import _Value, Is, Equals, Substring, StartsWith, En 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 ## @@ -456,13 +457,15 @@ class TestOneOf(unittest.TestCase): self.assertEqual(len(OneOf(Predicate(ns.bse.filesize), Predicate(ns.bse.filename), Predicate(ns.bse.tag))), 3) - def testIsIn(self): + 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'), - Or(Is('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'))) @@ -477,16 +480,18 @@ class TestOneOf(unittest.TestCase): 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'))) + Is('http://example.com/entity#1234')) - def testIsNotIn(self): + 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(Or(Is('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')))) @@ -501,9 +506,110 @@ class TestOneOf(unittest.TestCase): 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')))) + 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 ## |