python-corekit 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. corekit/api/__init__.py +18 -3
  2. corekit/api/application.py +237 -0
  3. corekit/api/lifespan.py +210 -0
  4. corekit/api/middleware.py +93 -0
  5. corekit/api/routers.py +109 -1
  6. corekit/concurrency/worker.py +65 -65
  7. corekit/config/settings.py +3 -3
  8. corekit/connections/sql/__init__.py +31 -3
  9. corekit/connections/sql/connection.py +19 -0
  10. corekit/connections/sql/migration/__init__.py +5 -5
  11. corekit/connections/sql/migration/base.py +3 -3
  12. corekit/connections/sql/migration/operations.py +66 -42
  13. corekit/connections/sql/migration/registry.py +2 -2
  14. corekit/connections/sql/operations/__init__.py +24 -0
  15. corekit/connections/sql/operations/base.py +102 -0
  16. corekit/connections/sql/operations/statements.py +150 -0
  17. corekit/connections/sql/query.py +4 -62
  18. corekit/connections/sql/table.py +30 -4
  19. corekit/constants.py +45 -45
  20. corekit/crypto/constants.py +4 -4
  21. corekit/data/__init__.py +8 -0
  22. corekit/data/expressions/__init__.py +10 -2
  23. corekit/data/expressions/comparison.py +184 -104
  24. corekit/data/expressions/expression.py +103 -98
  25. corekit/data/expressions/operator.py +54 -0
  26. corekit/data/expressions/target.py +21 -0
  27. corekit/data/record.py +147 -147
  28. corekit/data/stats.py +159 -157
  29. corekit/decorators/__init__.py +2 -2
  30. corekit/decorators/exception_handling.py +2 -1
  31. corekit/etl/connection.py +44 -44
  32. corekit/events/websocket.py +3 -2
  33. corekit/exceptions/__init__.py +18 -0
  34. corekit/http/__init__.py +13 -0
  35. corekit/jobs/__init__.py +26 -0
  36. corekit/jobs/registry.py +87 -0
  37. corekit/jobs/runner.py +69 -0
  38. corekit/jobs/task.py +152 -0
  39. corekit/observability/__init__.py +5 -3
  40. corekit/observability/request_context.py +135 -0
  41. corekit/registry/__init__.py +11 -6
  42. corekit/registry/ordered.py +86 -0
  43. corekit/schemas/__init__.py +10 -0
  44. corekit/schemas/enum.py +49 -49
  45. corekit/schemas/models/arbitrary.py +11 -11
  46. corekit/schemas/pydantic/fields.py +35 -35
  47. corekit/schemas/types.py +40 -40
  48. corekit/serialization/__init__.py +22 -0
  49. corekit/serialization/serializer.py +1 -1
  50. corekit/utils/__init__.py +59 -5
  51. corekit/utils/coercion.py +118 -0
  52. corekit/utils/collections.py +115 -0
  53. corekit/utils/ids.py +61 -5
  54. corekit/utils/payload.py +100 -0
  55. corekit/utils/raise_exc.py +8 -8
  56. corekit/utils/text.py +56 -0
  57. corekit/utils/time.py +74 -21
  58. corekit/utils/validators.py +15 -15
  59. corekit/utils/void.py +8 -8
  60. {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/METADATA +105 -100
  61. {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/RECORD +64 -46
  62. {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/WHEEL +0 -0
  63. {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/licenses/LICENSE +0 -0
  64. {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/top_level.txt +0 -0
@@ -4,13 +4,24 @@ Base table type.
4
4
 
5
5
  import json
6
6
  import logging
7
- from typing import Any
7
+ from typing import Any, ClassVar
8
8
 
9
+ from pydantic import BaseModel
9
10
  from sqlmodel import SQLModel
10
11
 
11
12
  from corekit.registry import SmartRegistry
12
13
 
13
- __all__ = ["NamedTable"]
14
+ __all__ = ["NamedTable", "TableExportItem"]
15
+
16
+
17
+ class TableExportItem(BaseModel):
18
+ """
19
+ One exported row, tagged with the table it came from.
20
+ """
21
+
22
+ table_name: str
23
+ data: dict[str, Any]
24
+
14
25
 
15
26
  logger = logging.getLogger(__name__)
16
27
 
@@ -26,6 +37,10 @@ class NamedTable(SQLModel):
26
37
 
27
38
  __registry__: SmartRegistry = SmartRegistry()
28
39
 
40
+ #: A human-readable name for the table. Declared as a ClassVar so pydantic
41
+ #: leaves a subclass's value as a plain string rather than wrapping it.
42
+ _table_display_name: ClassVar[str | None] = None
43
+
29
44
  def __init_subclass__(cls, **kwargs: Any) -> None:
30
45
  """
31
46
  Register every subclass under its class name.
@@ -57,9 +72,14 @@ class NamedTable(SQLModel):
57
72
  @classmethod
58
73
  def get_display_name(cls) -> str:
59
74
  """
60
- A human-readable name for the table.
75
+ A human-readable name for the table, falling back to the class name.
61
76
  """
62
- return getattr(cls, "__display_name__", None) or cls.__name__
77
+ name = cls._table_display_name
78
+ if name is not None and not isinstance(name, str):
79
+ # A subclass declared outside this module's ClassVar annotation
80
+ # gets its value wrapped, and the wrapper's repr is not a label.
81
+ name = getattr(name, "default", None) or str(name)
82
+ return name or cls.__name__
63
83
 
64
84
  @classmethod
65
85
  def create_default(cls, identifier: str, **kwargs: Any) -> "NamedTable":
@@ -70,6 +90,12 @@ class NamedTable(SQLModel):
70
90
  logger.info(f"Creating default {cls.__name__} with id {kwargs['id']!r}")
71
91
  return cls(**kwargs)
72
92
 
93
+ def export(self) -> TableExportItem:
94
+ """
95
+ This row as a portable record, tagged with its table.
96
+ """
97
+ return TableExportItem(table_name=self.table_name, data=self.model_dump())
98
+
73
99
  @staticmethod
74
100
  def _serialize(value: Any) -> str:
75
101
  return json.dumps(value)
corekit/constants.py CHANGED
@@ -1,45 +1,45 @@
1
- # Minutes
2
- ONE_MINUTE = 60
3
- TWO_MINUTES = ONE_MINUTE * 2
4
- THREE_MINUTES = ONE_MINUTE * 3
5
- FIVE_MINUTES = ONE_MINUTE * 5
6
- TEN_MINUTES = ONE_MINUTE * 10
7
- FIFTEEN_MINUTES = ONE_MINUTE * 15
8
- THIRTY_MINUTES = ONE_MINUTE * 30
9
- FORTY_FIVE_MINUTES = ONE_MINUTE * 45
10
-
11
- # Hours
12
- ONE_HOUR = ONE_MINUTE * 60
13
- TWO_HOURS = ONE_HOUR * 2
14
- THREE_HOURS = ONE_HOUR * 3
15
- FIVE_HOURS = ONE_HOUR * 5
16
- SIX_HOURS = ONE_HOUR * 6
17
- NINE_HOURS = ONE_HOUR * 9
18
- TWELVE_HOURS = ONE_HOUR * 12
19
- FIFTEEN_HOURS = ONE_HOUR * 15
20
- EIGHTEEN_HOURS = ONE_HOUR * 18
21
-
22
- # Days
23
- ONE_DAY = ONE_HOUR * 24
24
- TWO_DAYS = ONE_DAY * 2
25
- THREE_DAYS = ONE_DAY * 3
26
- FOUR_DAYS = ONE_DAY * 4
27
- FIVE_DAYS = ONE_DAY * 5
28
- SIX_DAYS = ONE_DAY * 6
29
- TEN_DAYS = ONE_DAY * 10
30
- FIFTEEN_DAYS = ONE_DAY * 15
31
- THIRTY_DAYS = ONE_DAY * 30
32
- FORTY_FIVE_DAYS = ONE_DAY * 45
33
- SIXTY_FIVE_DAYS = ONE_DAY * 60
34
- NINETY_FIVE_DAYS = ONE_DAY * 90
35
- ONE_HUNDRED_TWENTY_DAYS = ONE_DAY * 120
36
- ONE_HUNDRED_EIGHTY_DAYS = ONE_DAY * 180
37
-
38
- # Weeks
39
- ONE_WEEK = ONE_DAY * 7
40
- TWO_WEEKS = ONE_WEEK * 2
41
- THREE_WEEKS = ONE_WEEK * 3
42
- FOUR_WEEKS = ONE_WEEK * 4
43
-
44
- # Years
45
- ONE_YEAR = ONE_DAY * 365
1
+ # Minutes
2
+ ONE_MINUTE = 60
3
+ TWO_MINUTES = ONE_MINUTE * 2
4
+ THREE_MINUTES = ONE_MINUTE * 3
5
+ FIVE_MINUTES = ONE_MINUTE * 5
6
+ TEN_MINUTES = ONE_MINUTE * 10
7
+ FIFTEEN_MINUTES = ONE_MINUTE * 15
8
+ THIRTY_MINUTES = ONE_MINUTE * 30
9
+ FORTY_FIVE_MINUTES = ONE_MINUTE * 45
10
+
11
+ # Hours
12
+ ONE_HOUR = ONE_MINUTE * 60
13
+ TWO_HOURS = ONE_HOUR * 2
14
+ THREE_HOURS = ONE_HOUR * 3
15
+ FIVE_HOURS = ONE_HOUR * 5
16
+ SIX_HOURS = ONE_HOUR * 6
17
+ NINE_HOURS = ONE_HOUR * 9
18
+ TWELVE_HOURS = ONE_HOUR * 12
19
+ FIFTEEN_HOURS = ONE_HOUR * 15
20
+ EIGHTEEN_HOURS = ONE_HOUR * 18
21
+
22
+ # Days
23
+ ONE_DAY = ONE_HOUR * 24
24
+ TWO_DAYS = ONE_DAY * 2
25
+ THREE_DAYS = ONE_DAY * 3
26
+ FOUR_DAYS = ONE_DAY * 4
27
+ FIVE_DAYS = ONE_DAY * 5
28
+ SIX_DAYS = ONE_DAY * 6
29
+ TEN_DAYS = ONE_DAY * 10
30
+ FIFTEEN_DAYS = ONE_DAY * 15
31
+ THIRTY_DAYS = ONE_DAY * 30
32
+ FORTY_FIVE_DAYS = ONE_DAY * 45
33
+ SIXTY_FIVE_DAYS = ONE_DAY * 60
34
+ NINETY_FIVE_DAYS = ONE_DAY * 90
35
+ ONE_HUNDRED_TWENTY_DAYS = ONE_DAY * 120
36
+ ONE_HUNDRED_EIGHTY_DAYS = ONE_DAY * 180
37
+
38
+ # Weeks
39
+ ONE_WEEK = ONE_DAY * 7
40
+ TWO_WEEKS = ONE_WEEK * 2
41
+ THREE_WEEKS = ONE_WEEK * 3
42
+ FOUR_WEEKS = ONE_WEEK * 4
43
+
44
+ # Years
45
+ ONE_YEAR = ONE_DAY * 365
@@ -1,7 +1,7 @@
1
1
  HASH_JOINER = ":"
2
2
  EMPTY_HASH = ""
3
3
 
4
- # NOTE: a hardcoded CRYPTO_SALT previously lived here and is still present in this
5
- # repository's git history. It must be considered compromised and must never be
6
- # reintroduced. The salt is now supplied through configuration -- see
7
- # `corekit.config.CorekitSettings.crypto_salt` and `corekit.crypto.hasher.Hasher`.
4
+ # There is deliberately no default salt. A salt shipped with a library is shared
5
+ # by every install, which defeats the purpose of salting. Supply one through
6
+ # configuration -- see `corekit.config.CorekitSettings.crypto_salt` and
7
+ # `corekit.crypto.hasher.Hasher`.
corekit/data/__init__.py CHANGED
@@ -24,6 +24,7 @@ from corekit.data.expressions import (
24
24
  And,
25
25
  Comparison,
26
26
  Contains,
27
+ Dialect,
27
28
  Equals,
28
29
  Expression,
29
30
  FieldExpression,
@@ -34,7 +35,10 @@ from corekit.data.expressions import (
34
35
  LessThanOrEquals,
35
36
  Not,
36
37
  NotEquals,
38
+ Operator,
37
39
  Or,
40
+ Target,
41
+ UnknownFieldError,
38
42
  ValueExpression,
39
43
  )
40
44
  from corekit.data.record import BaseRecord, is_valid_key, resolve_default
@@ -74,6 +78,10 @@ __all__ = [
74
78
  "NotEquals",
75
79
  "NumericFieldStats",
76
80
  "Or",
81
+ "Dialect",
82
+ "Operator",
83
+ "Target",
84
+ "UnknownFieldError",
77
85
  "ValueCounts",
78
86
  "ValueExpression",
79
87
  "is_valid_key",
@@ -8,8 +8,9 @@ values or against each other::
8
8
  Field("first_name") == Field("nickname")
9
9
  (Field("age") > 20) & ~Field("name").contains("test")
10
10
 
11
- Every node can also emit a backend query via ``to_sql``, ``to_mongo`` or
12
- ``to_elasticsearch``.
11
+ Every node can also emit a backend query: ``to_mongo``, ``to_elasticsearch`` or
12
+ ``to_sqlalchemy``. ``Target`` names the context an operand is resolved for, so a
13
+ new backend needs one more translation method and no changes to the tree.
13
14
  """
14
15
 
15
16
  from corekit.data.expressions.comparison import (
@@ -23,9 +24,12 @@ from corekit.data.expressions.comparison import (
23
24
  LessThan,
24
25
  LessThanOrEquals,
25
26
  NotEquals,
27
+ UnknownFieldError,
26
28
  ValueExpression,
27
29
  )
28
30
  from corekit.data.expressions.expression import And, Expression, Not, Or
31
+ from corekit.data.expressions.operator import Dialect, Operator
32
+ from corekit.data.expressions.target import Target
29
33
 
30
34
  __all__ = [
31
35
  "And",
@@ -41,6 +45,10 @@ __all__ = [
41
45
  "LessThanOrEquals",
42
46
  "Not",
43
47
  "NotEquals",
48
+ "UnknownFieldError",
44
49
  "Or",
50
+ "Dialect",
51
+ "Operator",
52
+ "Target",
45
53
  "ValueExpression",
46
54
  ]
@@ -1,22 +1,32 @@
1
+ import re
1
2
  from typing import Any, Iterable
2
3
 
3
4
  from corekit.data.expressions.expression import Expression
5
+ from corekit.data.expressions.operator import Operator
6
+ from corekit.data.expressions.target import Target
7
+
8
+ UNSET = object()
9
+
10
+
11
+ class UnknownFieldError(AttributeError):
12
+ """
13
+ Raised when an expression names a field the target does not have.
14
+ """
4
15
 
5
16
 
6
17
  class Comparison(Expression):
7
18
  """
8
19
  Base for comparing two values (Equals, GreaterThan, IsIn, ...).
9
20
 
10
- `left`, `right`, and `symbol` are public so external code --
11
- e.g. a future translator to SQL/Mongo/Elasticsearch -- can walk the
12
- tree and read out what each node represents without evaluating it.
13
- `symbol` is a human-readable operator string; the class itself
21
+ `left`, `right`, and `operator` are public so external code -- e.g. a
22
+ translator to SQL/Mongo/Elasticsearch -- can walk the tree and read out
23
+ what each node represents without evaluating it. The class itself
14
24
  (Equals, GreaterThan, etc.) is the canonical, type-safe identifier of
15
25
  *which* operation a node represents, so a translator can dispatch on
16
26
  isinstance(node, GreaterThan) rather than string-matching on symbol.
17
27
  """
18
28
 
19
- symbol: str
29
+ operator: Operator
20
30
 
21
31
  def __init__(self, left: Any, right: Any) -> None:
22
32
  self.left = left
@@ -25,154 +35,205 @@ class Comparison(Expression):
25
35
  def __repr__(self) -> str:
26
36
  return f"({self.left!r} {self.symbol} {self.right!r})"
27
37
 
38
+ @property
39
+ def symbol(self) -> str:
40
+ """
41
+ The human-readable operator, e.g. ``>=``.
42
+ """
43
+ return self.operator.symbol
44
+
28
45
  @staticmethod
29
- def _resolve_value(value: Any, *args: Any, **kwargs: Any) -> Any:
46
+ def resolve(value: Any, *args: Any, **kwargs: Any) -> Any:
30
47
  """
31
- Resolves a value to a concrete value. This method utilizes a neat trick
32
- to be able to resolve the final value of complex expressions without
33
- having to evaluate the entire tree.
48
+ Resolve one operand, passing everything through to the expression.
34
49
  """
35
50
  if isinstance(value, ValueExpression):
36
- return value.get(*args, **kwargs)
51
+ return value.resolve(*args, **kwargs)
37
52
  return value
38
53
 
39
54
  def get_left(self, *args: Any, **kwargs: Any) -> Any:
40
- return self._resolve_value(self.left, *args, **kwargs)
55
+ """
56
+ Whatever is on the left of this comparison, resolved.
57
+ """
58
+ return self.resolve(self.left, *args, **kwargs)
41
59
 
42
- def get_right(self, *args, **kwargs) -> Any:
60
+ def get_right(self, *args: Any, **kwargs: Any) -> Any:
43
61
  """
44
- Returns the right value of the comparison.
62
+ Whatever is on the right of this comparison, resolved.
45
63
  """
46
- return self._resolve_value(self.right, *args, **kwargs)
64
+ return self.resolve(self.right, *args, **kwargs)
47
65
 
66
+ @property
67
+ def compares_fields(self) -> bool:
68
+ """
69
+ Whether the right side names a field rather than holding a value.
48
70
 
49
- class Equals(Comparison):
50
- symbol = "=="
71
+ A dialect that only compares a field to a value needs a different form
72
+ for this, so each renderer has to ask.
73
+ """
74
+ return isinstance(self.right, FieldExpression)
75
+
76
+ def _reject_field_comparison(self) -> None:
77
+ """
78
+ Guard a dialect whose clauses only compare a field to a literal.
51
79
 
52
- def __call__(self, *args: Any, **kwargs: Any) -> bool:
53
- return self.get_left(*args, **kwargs) == self.get_right(*args, **kwargs)
80
+ Rendering the right side would emit a field's *name* where a value was
81
+ meant, which is a silently wrong query rather than a failed one.
82
+ """
83
+ if self.compares_fields:
84
+ raise NotImplementedError(f"Elasticsearch cannot express `{self.symbol}` between two fields")
54
85
 
55
- def to_sql(self, *args: Any, **kwargs: Any) -> str:
56
- return f"{self.get_left(*args, **kwargs)} = {self.get_right(*args, **kwargs)}"
86
+ def _reference(self, side: Any) -> str:
87
+ """
88
+ One side as a Mongo field reference, e.g. ``$age``.
89
+ """
90
+ return f"${self.resolve(side, target=Target.NAME)}"
57
91
 
58
92
  def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
59
- return {self.get_left(*args, **kwargs): self.get_right(*args, **kwargs)}
93
+ if self.compares_fields:
94
+ return {"$expr": {self.operator.mongo: [self._reference(self.left), self._reference(self.right)]}}
95
+ return {self.get_left(target=Target.NAME): {self.operator.mongo: self.get_right()}}
60
96
 
61
97
  def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
62
- return {"term": {self.get_left(*args, **kwargs): self.get_right(*args, **kwargs)}}
98
+ self._reject_field_comparison()
99
+ # Elasticsearch range operators are the Mongo spelling without the `$`.
100
+ return {
101
+ self.operator.elasticsearch: {
102
+ self.get_left(target=Target.NAME): {self.operator.mongo.lstrip("$"): self.get_right()}
103
+ }
104
+ }
63
105
 
64
106
 
65
- class NotEquals(Comparison):
66
- symbol = "!="
67
-
68
- def __call__(self, *args: Any, **kwargs: Any) -> bool:
69
- return self.get_left(*args, **kwargs) != self.get_right(*args, **kwargs)
107
+ class Equals(Comparison):
108
+ operator = Operator.EQUALS
70
109
 
71
- def to_sql(self, *args: Any, **kwargs: Any) -> str:
72
- return f"{self.get_left(*args, **kwargs)} != {self.get_right(*args, **kwargs)}"
110
+ def __call__(self, context: Any, *args: Any, **kwargs: Any) -> bool:
111
+ return self.get_left(context, *args, **kwargs) == self.get_right(context, *args, **kwargs)
73
112
 
74
113
  def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
75
- return {self.get_left(*args, **kwargs): {"$ne": self.get_right(*args, **kwargs)}}
114
+ if self.compares_fields:
115
+ return super().to_mongo(*args, **kwargs)
116
+ # Equality is the one operator Mongo spells as a bare value.
117
+ return {self.get_left(target=Target.NAME): self.get_right()}
76
118
 
77
119
  def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
78
- return {"term": {self.get_left(*args, **kwargs): {"$ne": self.get_right(*args, **kwargs)}}}
120
+ self._reject_field_comparison()
121
+ return {self.operator.elasticsearch: {self.get_left(target=Target.NAME): self.get_right()}}
79
122
 
123
+ def to_sqlalchemy(self, context: Any) -> Any:
124
+ return self.get_left(context) == self.get_right(context)
80
125
 
81
- class LessThan(Comparison):
82
- symbol = "<"
83
-
84
- def __call__(self, *args: Any, **kwargs: Any) -> bool:
85
- return self.get_left(*args, **kwargs) < self.get_right(*args, **kwargs)
86
126
 
87
- def to_sql(self, *args: Any, **kwargs: Any) -> str:
88
- return f"{self.get_left(*args, **kwargs)} < {self.get_right(*args, **kwargs)}"
127
+ class NotEquals(Comparison):
128
+ operator = Operator.NOT_EQUALS
89
129
 
90
- def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
91
- return {self.get_left(*args, **kwargs): {"$lt": self.get_right(*args, **kwargs)}}
130
+ def __call__(self, context: Any, *args: Any, **kwargs: Any) -> bool:
131
+ return self.get_left(context, *args, **kwargs) != self.get_right(context, *args, **kwargs)
92
132
 
93
133
  def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
94
- return {"range": {self.get_left(*args, **kwargs): {"lt": self.get_right(*args, **kwargs)}}}
134
+ self._reject_field_comparison()
135
+ return {
136
+ self.operator.elasticsearch: {self.get_left(target=Target.NAME): {self.operator.mongo: self.get_right()}}
137
+ }
95
138
 
139
+ def to_sqlalchemy(self, context: Any) -> Any:
140
+ return self.get_left(context) != self.get_right(context)
96
141
 
97
- class LessThanOrEquals(Comparison):
98
- symbol = "<="
99
-
100
- def __call__(self, *args: Any, **kwargs: Any) -> bool:
101
- return self.get_left(*args, **kwargs) <= self.get_right(*args, **kwargs)
102
142
 
103
- def to_sql(self, *args: Any, **kwargs: Any) -> str:
104
- return f"{self.get_left(*args, **kwargs)} <= {self.get_right(*args, **kwargs)}"
143
+ class LessThan(Comparison):
144
+ operator = Operator.LESS_THAN
105
145
 
106
- def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
107
- return {self.get_left(*args, **kwargs): {"$lte": self.get_right(*args, **kwargs)}}
146
+ def __call__(self, context: Any, *args: Any, **kwargs: Any) -> bool:
147
+ return self.get_left(context, *args, **kwargs) < self.get_right(context, *args, **kwargs)
108
148
 
109
- def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
110
- return {"range": {self.get_left(*args, **kwargs): {"lte": self.get_right(*args, **kwargs)}}}
149
+ def to_sqlalchemy(self, context: Any) -> Any:
150
+ return self.get_left(context) < self.get_right(context)
111
151
 
112
152
 
113
- class GreaterThan(Comparison):
114
- symbol = ">"
153
+ class LessThanOrEquals(Comparison):
154
+ operator = Operator.LESS_THAN_OR_EQUALS
115
155
 
116
- def __call__(self, *args: Any, **kwargs: Any) -> bool:
117
- return self.get_left(*args, **kwargs) > self.get_right(*args, **kwargs)
156
+ def __call__(self, context: Any, *args: Any, **kwargs: Any) -> bool:
157
+ return self.get_left(context, *args, **kwargs) <= self.get_right(context, *args, **kwargs)
118
158
 
119
- def to_sql(self, *args: Any, **kwargs: Any) -> str:
120
- return f"{self.get_left(*args, **kwargs)} > {self.get_right(*args, **kwargs)}"
159
+ def to_sqlalchemy(self, context: Any) -> Any:
160
+ return self.get_left(context) <= self.get_right(context)
121
161
 
122
- def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
123
- return {self.get_left(*args, **kwargs): {"$gt": self.get_right(*args, **kwargs)}}
124
162
 
125
- def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
126
- return {"range": {self.get_left(*args, **kwargs): {"gt": self.get_right(*args, **kwargs)}}}
163
+ class GreaterThan(Comparison):
164
+ operator = Operator.GREATER_THAN
127
165
 
166
+ def __call__(self, context: Any, *args: Any, **kwargs: Any) -> bool:
167
+ return self.get_left(context, *args, **kwargs) > self.get_right(context, *args, **kwargs)
128
168
 
129
- class GreaterThanOrEquals(Comparison):
130
- symbol = ">="
169
+ def to_sqlalchemy(self, context: Any) -> Any:
170
+ return self.get_left(context) > self.get_right(context)
131
171
 
132
- def __call__(self, *args: Any, **kwargs: Any) -> bool:
133
- return self.get_left(*args, **kwargs) >= self.get_right(*args, **kwargs)
134
172
 
135
- def to_sql(self, *args: Any, **kwargs: Any) -> str:
136
- return f"{self.get_left(*args, **kwargs)} >= {self.get_right(*args, **kwargs)}"
173
+ class GreaterThanOrEquals(Comparison):
174
+ operator = Operator.GREATER_THAN_OR_EQUALS
137
175
 
138
- def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
139
- return {self.get_left(*args, **kwargs): {"$gte": self.get_right(*args, **kwargs)}}
176
+ def __call__(self, context: Any, *args: Any, **kwargs: Any) -> bool:
177
+ return self.get_left(context, *args, **kwargs) >= self.get_right(context, *args, **kwargs)
140
178
 
141
- def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
142
- return {"range": {self.get_left(*args, **kwargs): {"gte": self.get_right(*args, **kwargs)}}}
179
+ def to_sqlalchemy(self, context: Any) -> Any:
180
+ return self.get_left(context) >= self.get_right(context)
143
181
 
144
182
 
145
183
  class IsIn(Comparison):
146
- symbol = "is in"
184
+ operator = Operator.IS_IN
147
185
 
148
- def __call__(self, *args: Any, **kwargs: Any) -> bool:
149
- return self.get_left(*args, **kwargs) in self.get_right(*args, **kwargs)
150
-
151
- def to_sql(self, *args: Any, **kwargs: Any) -> str:
152
- # TODO: this might not actually be valid SQL
153
- return f"{self.get_left(*args, **kwargs)} IN {self.get_right(*args, **kwargs)}"
186
+ def __call__(self, context: Any, *args: Any, **kwargs: Any) -> bool:
187
+ return self.get_left(context, *args, **kwargs) in self.get_right(context, *args, **kwargs)
154
188
 
155
189
  def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
156
- return {self.get_left(*args, **kwargs): {"$in": self.get_right(*args, **kwargs)}}
190
+ if self.compares_fields:
191
+ return {"$expr": {self.operator.mongo: [self._reference(self.left), self._reference(self.right)]}}
192
+ # A list, not the stored frozenset: no driver can serialize a set.
193
+ return {self.get_left(target=Target.NAME): {self.operator.mongo: self._values()}}
157
194
 
158
195
  def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
159
- return {"terms": {self.get_left(*args, **kwargs): self.get_right(*args, **kwargs)}}
196
+ self._reject_field_comparison()
197
+ return {self.operator.elasticsearch: {self.get_left(target=Target.NAME): self._values()}}
198
+
199
+ def to_sqlalchemy(self, context: Any) -> Any:
200
+ return self.get_left(context).in_(self._values(context))
201
+
202
+ def _values(self, context: Any = None) -> list[Any]:
203
+ """
204
+ The candidate values as a sorted list, so rendering is deterministic.
205
+ """
206
+ operand = self.get_right(context)
207
+ if isinstance(operand, (set, frozenset)):
208
+ return sorted(operand, key=repr)
209
+ return list(operand)
160
210
 
161
211
 
162
212
  class Contains(Comparison):
163
- symbol = "contains"
213
+ """
214
+ Substring or membership: the left side contains the right.
215
+ """
164
216
 
165
- def __call__(self, *args: Any, **kwargs: Any) -> bool:
166
- return self.get_right(*args, **kwargs) in self.get_left(*args, **kwargs)
217
+ operator = Operator.CONTAINS
167
218
 
168
- def to_sql(self, *args: Any, **kwargs: Any) -> str:
169
- return ""
219
+ def __call__(self, context: Any, *args: Any, **kwargs: Any) -> bool:
220
+ return self.get_right(context, *args, **kwargs) in self.get_left(context, *args, **kwargs)
170
221
 
171
222
  def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
172
- return
223
+ if self.compares_fields:
224
+ # $regex is a query operator, not an aggregation one, so it cannot
225
+ # appear inside the $expr form that comparing two fields needs.
226
+ raise NotImplementedError("Mongo cannot express `contains` between two fields")
227
+ # $regex with the operand escaped, so a value containing regex
228
+ # metacharacters is matched literally rather than as a pattern.
229
+ return {self.get_left(target=Target.NAME): {self.operator.mongo: re.escape(str(self.get_right()))}}
173
230
 
174
231
  def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
175
- return
232
+ self._reject_field_comparison()
233
+ return {self.operator.elasticsearch: {self.get_left(target=Target.NAME): f"*{self.get_right()}*"}}
234
+
235
+ def to_sqlalchemy(self, context: Any) -> Any:
236
+ return self.get_left(context).contains(self.get_right(context))
176
237
 
177
238
 
178
239
  class ValueExpression(Expression):
@@ -214,11 +275,14 @@ class ValueExpression(Expression):
214
275
  def contains(self, substring: Any) -> "Comparison":
215
276
  return Contains(self, substring)
216
277
 
217
- def get(self, *args: Any, **kwargs: Any) -> Any:
218
- return self.value
278
+ def resolve(self, context: Any = None, target: Target = Target.VALUE, *args: Any, **kwargs: Any) -> Any:
279
+ """
280
+ Resolve this literal, which is its own value for every target.
219
281
 
220
- def to_sql(self, *args: Any, **kwargs: Any) -> str:
221
- return str(self.value)
282
+ The signature matches ``FieldExpression.resolve`` so a caller can resolve
283
+ either side of a comparison without knowing which it holds.
284
+ """
285
+ return self.value
222
286
 
223
287
  def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
224
288
  return self.value
@@ -230,23 +294,39 @@ class ValueExpression(Expression):
230
294
  class FieldExpression(ValueExpression):
231
295
  """
232
296
  A value expression representing a named field reference for building filter predicates.
233
- FieldExpression instances will evaluate against anything that supports __getitem__ by field name.
297
+
298
+ FieldExpression resolves against anything that holds fields by name: a mapping,
299
+ a dataclass, a pydantic model, a plain object, or a SQLAlchemy model class.
234
300
  """
235
301
 
236
- def __call__(self, record: Any, *args, **kwargs) -> bool:
237
- return bool(record[self.value])
302
+ def __call__(self, context: Any, *args: Any, **kwargs: Any) -> bool:
303
+ return bool(self.resolve(context))
238
304
 
239
305
  def __repr__(self) -> str:
240
306
  return f"Field({self.value!r})"
241
307
 
242
- def get(self, record: Any, *args: Any, **kwargs: Any) -> Any:
243
- return record[self.value]
244
-
245
- def to_sql(self, record: Any, *args: Any, **kwargs: Any) -> str:
246
- return record[self.value]
308
+ def resolve(self, context: Any = None, target: Target = Target.VALUE, *args: Any, **kwargs: Any) -> Any:
309
+ """
310
+ Resolve this field against ``context``.
247
311
 
248
- def to_mongo(self, record: Any, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
249
- return record[self.value]
312
+ Args:
313
+ context: Whatever holds the field -- a mapping, an object, or a model class.
314
+ target: ``NAME`` for the field's name, ``VALUE`` for what ``context`` holds for it.
250
315
 
251
- def to_elasticsearch(self, record: Any, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
252
- return record[self.value]
316
+ Raises:
317
+ UnknownFieldError: If ``context`` has no such field.
318
+ """
319
+ if target is Target.NAME:
320
+ return self.value
321
+
322
+ getter = getattr(context, "__getitem__", None)
323
+ if getter is not None:
324
+ try:
325
+ return getter(self.value)
326
+ except (KeyError, IndexError, TypeError):
327
+ pass
328
+
329
+ value = getattr(context, self.value, UNSET)
330
+ if value is UNSET:
331
+ raise UnknownFieldError(f"{context!r} has no field {self.value!r}")
332
+ return value