aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMatthias Baumgartner <dev@igsor.net>2023-02-08 20:47:18 +0100
committerMatthias Baumgartner <dev@igsor.net>2023-02-08 20:47:18 +0100
commit64f3ac76a2f8d6b51380c06233accfcc19dca228 (patch)
tree785819f986e56eabb723309dacd74d6b7353f604
parentcb819b8c268908b5f6cc680173db86e172847c46 (diff)
downloadbsfs-64f3ac76a2f8d6b51380c06233accfcc19dca228.tar.gz
bsfs-64f3ac76a2f8d6b51380c06233accfcc19dca228.tar.bz2
bsfs-64f3ac76a2f8d6b51380c06233accfcc19dca228.zip
filter query convenience functions
-rw-r--r--bsfs/query/ast/filter_.py58
-rw-r--r--test/query/ast_test/test_filter_.py118
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 ##