zero-server 0.0.1__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 (50) hide show
  1. zero_server/__init__.py +36 -0
  2. zero_server/codegen.py +133 -0
  3. zero_server/integrations/__init__.py +1 -0
  4. zero_server/integrations/starlette.py +115 -0
  5. zero_server/mutators.py +87 -0
  6. zero_server/protocol/__init__.py +1 -0
  7. zero_server/protocol/application_error.py +36 -0
  8. zero_server/protocol/ast.py +342 -0
  9. zero_server/protocol/custom_queries.py +51 -0
  10. zero_server/protocol/error.py +119 -0
  11. zero_server/protocol/mutate_server.py +33 -0
  12. zero_server/protocol/mutation.py +127 -0
  13. zero_server/protocol/mutation_id.py +12 -0
  14. zero_server/protocol/push.py +25 -0
  15. zero_server/protocol/query_server.py +29 -0
  16. zero_server/protocol/wire.py +88 -0
  17. zero_server/py.typed +0 -0
  18. zero_server/query/__init__.py +1 -0
  19. zero_server/query/builder.py +338 -0
  20. zero_server/query/expression.py +167 -0
  21. zero_server/query/registry.py +143 -0
  22. zero_server/schema.py +142 -0
  23. zero_server/server/__init__.py +1 -0
  24. zero_server/server/adapters/__init__.py +1 -0
  25. zero_server/server/adapters/asyncpg.py +43 -0
  26. zero_server/server/adapters/psycopg.py +55 -0
  27. zero_server/server/adapters/sqlalchemy.py +49 -0
  28. zero_server/server/crud.py +134 -0
  29. zero_server/server/pg_query_executor.py +70 -0
  30. zero_server/server/pg_types.py +69 -0
  31. zero_server/server/process_mutations.py +412 -0
  32. zero_server/server/process_queries.py +109 -0
  33. zero_server/server/push_processor.py +49 -0
  34. zero_server/server/server_schema.py +165 -0
  35. zero_server/server/transaction.py +70 -0
  36. zero_server/server/zql_database.py +129 -0
  37. zero_server/shared/__init__.py +1 -0
  38. zero_server/shared/asserts.py +19 -0
  39. zero_server/shared/error.py +97 -0
  40. zero_server/shared/js.py +121 -0
  41. zero_server/shared/json.py +27 -0
  42. zero_server/shared/registry.py +64 -0
  43. zero_server/z2s/__init__.py +1 -0
  44. zero_server/z2s/compiler.py +375 -0
  45. zero_server/z2s/sql.py +130 -0
  46. zero_server-0.0.1.dist-info/METADATA +134 -0
  47. zero_server-0.0.1.dist-info/RECORD +50 -0
  48. zero_server-0.0.1.dist-info/WHEEL +4 -0
  49. zero_server-0.0.1.dist-info/entry_points.txt +3 -0
  50. zero_server-0.0.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,25 @@
1
+ """What zero-cache POSTs to a mutate endpoint.
2
+
3
+ Ported from rocicorp/mono `packages/zero-protocol/src/push.ts` at zero/v1.10.0
4
+ (905d08516ba0caaf314cd28e93a5a47e8acdd482). Apache-2.0, Copyright Rocicorp.
5
+ """
6
+
7
+ from pydantic import Field, StrictStr
8
+
9
+ from zero_server.protocol.mutation import Mutation
10
+ from zero_server.protocol.wire import Number, WireModel
11
+
12
+
13
+ class PushBody(WireModel):
14
+ """Parsed in passthrough mode upstream, so keys it does not declare are ignored, not refused."""
15
+
16
+ client_group_id: StrictStr = Field(alias="clientGroupID")
17
+ mutations: list[Mutation]
18
+ push_version: Number = Field(alias="pushVersion")
19
+ # Only CRUD mutations are tied to a schema version.
20
+ schema_version: Number | None = Field(default=None, alias="schemaVersion")
21
+ timestamp: Number
22
+ request_id: StrictStr = Field(alias="requestID")
23
+ # Deprecated upstream: auth is per client group now, through connect and updateAuth.
24
+ auth: StrictStr | None = None
25
+ traceparent: StrictStr | None = None
@@ -0,0 +1,29 @@
1
+ """The query endpoint's response.
2
+
3
+ Ported from rocicorp/mono `packages/zero-protocol/src/query-server.ts` at zero/v1.10.0
4
+ (905d08516ba0caaf314cd28e93a5a47e8acdd482). Apache-2.0, Copyright Rocicorp.
5
+ """
6
+
7
+ from typing import Literal
8
+
9
+ from pydantic import Field, StrictStr
10
+
11
+ from zero_server.protocol.custom_queries import TransformResponseBody
12
+ from zero_server.protocol.error import TransformFailedBody
13
+ from zero_server.protocol.wire import WireModel
14
+
15
+
16
+ class QuerySuccess(WireModel):
17
+ """`user_id` is reported to zero-cache only when the caller set it, `None` included.
18
+
19
+ Reporting it is what makes zero-cache trust the connection's user as server-validated.
20
+ """
21
+
22
+ nullable = frozenset({"user_id"})
23
+
24
+ kind: Literal["QueryResponse"] = "QueryResponse"
25
+ user_id: StrictStr | None = Field(default=None, alias="userID")
26
+ queries: TransformResponseBody
27
+
28
+
29
+ type QueryResponse = QuerySuccess | TransformFailedBody
@@ -0,0 +1,88 @@
1
+ """The base every wire model shares.
2
+
3
+ Upstream's optional fields are absent when unset, never `null`. zero-cache hashes the ASTs it receives,
4
+ so `"limit": null` and no `limit` are two different queries to it. A model therefore drops a `None`
5
+ field when it serializes, except a field listed in `nullable`, where `None` is a real JSON `null` and
6
+ survives whenever the caller set it.
7
+
8
+ Parsing is the mirror image: an optional field upstream may be absent but not `null`, so a `null` is
9
+ refused unless the field is `nullable`.
10
+ """
11
+
12
+ from typing import Any, ClassVar, Self
13
+
14
+ from pydantic import (
15
+ BaseModel,
16
+ ConfigDict,
17
+ SerializerFunctionWrapHandler,
18
+ StrictFloat,
19
+ StrictInt,
20
+ TypeAdapter,
21
+ ValidationInfo,
22
+ model_serializer,
23
+ model_validator,
24
+ )
25
+
26
+ from zero_server.shared.json import JSONValue
27
+
28
+ WIRE: Any = {"source": "wire"}
29
+ """Validation context marking a value as arriving off the wire rather than built in Python."""
30
+
31
+
32
+ def parse_wire[T](adapter: TypeAdapter[T], value: object) -> T:
33
+ return adapter.validate_python(value, context=WIRE)
34
+
35
+
36
+ class WireModel(BaseModel):
37
+ model_config = ConfigDict(
38
+ frozen=True,
39
+ populate_by_name=True,
40
+ serialize_by_alias=True,
41
+ extra="ignore",
42
+ )
43
+
44
+ nullable: ClassVar[frozenset[str]] = frozenset()
45
+
46
+ @model_validator(mode="before")
47
+ @classmethod
48
+ def _refuse_null_for_optional(cls, data: Any, info: ValidationInfo) -> Any:
49
+ if info.context == WIRE and isinstance(data, dict):
50
+ for name, field in cls.model_fields.items():
51
+ if name in cls.nullable or field.is_required():
52
+ continue
53
+ for key in {name, field.alias or name}:
54
+ if key in data and data[key] is None:
55
+ raise ValueError(f"{key} may be absent but not null")
56
+ return data
57
+
58
+ @model_serializer(mode="wrap")
59
+ def _omit_unset(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]:
60
+ dumped: dict[str, Any] = handler(self)
61
+ out: dict[str, Any] = {}
62
+ for name, info in type(self).model_fields.items():
63
+ key = info.serialization_alias or info.alias or name
64
+ if key not in dumped:
65
+ continue
66
+ value = dumped[key]
67
+ if value is None and not (name in self.nullable and name in self.model_fields_set):
68
+ continue
69
+ out[key] = value
70
+ return out
71
+
72
+ def to_json(self) -> dict[str, JSONValue]:
73
+ """The JSON object upstream would send for this model."""
74
+ return self.model_dump(mode="json")
75
+
76
+ @classmethod
77
+ def from_json(cls, value: object) -> Self:
78
+ return cls.model_validate(value, context=WIRE)
79
+
80
+
81
+ type Number = StrictInt | StrictFloat
82
+ """A JSON number. `bool` is not one, though Python says otherwise."""
83
+
84
+
85
+ class StrictWireModel(WireModel):
86
+ """Refuses keys it does not declare, as upstream's default (strict) parse mode does."""
87
+
88
+ model_config = ConfigDict(extra="forbid")
zero_server/py.typed ADDED
File without changes
@@ -0,0 +1 @@
1
+ """ZQL's query builder and query registry, from rocicorp/mono `packages/zql/src/query`."""
@@ -0,0 +1,338 @@
1
+ """The ZQL query builder: a `Query` is an immutable AST under construction, in client names.
2
+
3
+ Ported from rocicorp/mono `packages/zql/src/query/query-impl.ts` and `create-builder.ts` at zero/v1.10.0
4
+ (905d08516ba0caaf314cd28e93a5a47e8acdd482). Apache-2.0, Copyright Rocicorp.
5
+
6
+ The errors match upstream's message for message, because a query endpoint reports them to the client.
7
+ """
8
+
9
+ from collections.abc import Callable, Mapping
10
+ from dataclasses import dataclass, field, replace
11
+ from typing import Any, Generic
12
+
13
+ from typing_extensions import TypeVar
14
+
15
+ from zero_server.protocol.ast import (
16
+ AST,
17
+ SUBQ_PREFIX,
18
+ Bound,
19
+ Condition,
20
+ CorrelatedSubquery,
21
+ CorrelatedSubqueryCondition,
22
+ Correlation,
23
+ Direction,
24
+ System,
25
+ )
26
+ from zero_server.query.expression import MISSING, ExpressionBuilder, and_, cmp, simplify_condition
27
+ from zero_server.schema import Connection, Schema
28
+ from zero_server.shared.asserts import check
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Format:
33
+ """The shape of a query's result: a row or a list, and the same for each relationship."""
34
+
35
+ singular: bool = False
36
+ relationships: dict[str, "Format"] = field(default_factory=dict)
37
+
38
+
39
+ R = TypeVar("R", default=dict[str, Any])
40
+ """A row of the query's table, as `tx.run` returns it. Generated schema types narrow it."""
41
+ C = TypeVar("C", bound=str, default=str)
42
+ """The table's column names. Generated schema types narrow it to a `Literal`."""
43
+
44
+ type AnyQuery = "Query[Any, Any]"
45
+ type SubqueryFn = Callable[[Any], Any]
46
+
47
+
48
+ class UnknownColumnError(Exception):
49
+ """A strict builder refused a column its table does not have."""
50
+
51
+
52
+ class Query(Generic[R, C]):
53
+ """An immutable query on one table. Every method returns a new query."""
54
+
55
+ def __init__(
56
+ self,
57
+ schema: Schema,
58
+ table: str,
59
+ ast: AST,
60
+ system: System = "client",
61
+ current_junction: str | None = None,
62
+ format: Format | None = None,
63
+ *,
64
+ strict: bool = False,
65
+ ) -> None:
66
+ self._schema = schema
67
+ self._table = table
68
+ self._ast = ast
69
+ self._system: System = system
70
+ self._junction = current_junction
71
+ self._format = format or Format()
72
+ self._strict = strict
73
+
74
+ def __repr__(self) -> str:
75
+ return f"{type(self).__name__}({self._ast.to_json()!r})"
76
+
77
+ def _check_column(self, column: str) -> None:
78
+ if self._strict and column not in self._schema.tables[self._table].columns:
79
+ raise UnknownColumnError(f'unknown column "{column}" of "{self._table}" table')
80
+
81
+ @property
82
+ def format(self) -> Format:
83
+ return self._format
84
+
85
+ @property
86
+ def ast(self) -> AST:
87
+ """The AST in client names. The query endpoint maps it to server names."""
88
+ return self._ast
89
+
90
+ @property
91
+ def table(self) -> str:
92
+ return self._table
93
+
94
+ def _new(self, table: str, ast: AST, junction: str | None, format: Format | None = None) -> AnyQuery:
95
+ return Query(self._schema, table, ast, self._system, junction, format, strict=self._strict)
96
+
97
+ def _with[Q: Query[Any, Any]](self: Q, format: Format | None = None, **update: Any) -> Q:
98
+ return type(self)(
99
+ self._schema,
100
+ self._table,
101
+ self._ast.model_copy(update=update),
102
+ self._system,
103
+ self._junction,
104
+ format or self._format,
105
+ strict=self._strict,
106
+ )
107
+
108
+ def _with_relationship[Q: Query[Any, Any]](self: Q, name: str, sub: AnyQuery, entry: CorrelatedSubquery) -> Q:
109
+ format = replace(self._format, relationships={**self._format.relationships, name: sub.format})
110
+ return self._with(format=format, related=(*(self._ast.related or ()), entry))
111
+
112
+ def one(self) -> "OneQuery[R, C]":
113
+ """At most one row: `tx.run` returns it, or `None`."""
114
+ query = self._with(format=replace(self._format, singular=True), limit=1)
115
+ return OneQuery(
116
+ query._schema, query._table, query._ast, query._system, query._junction, query._format, strict=self._strict
117
+ )
118
+
119
+ def where[Q: Query[Any, Any]](
120
+ self: Q, field_or_factory: C | Callable[[ExpressionBuilder], Condition], *args: Any
121
+ ) -> Q:
122
+ """`where(field, value)`, `where(field, op, value)`, or `where(lambda eb: ...)`."""
123
+ cond: Condition
124
+ if isinstance(field_or_factory, str):
125
+ self._check_column(field_or_factory)
126
+ check(len(args) >= 1, "Invalid condition. Too few arguments.")
127
+ cond = cmp(field_or_factory, args[0]) if len(args) == 1 else cmp(field_or_factory, args[0], args[1])
128
+ else:
129
+ cond = field_or_factory(self.expression_builder())
130
+ if self._ast.where is not None:
131
+ cond = and_(self._ast.where, cond)
132
+ return self._with(where=simplify_condition(cond))
133
+
134
+ def where_exists[Q: Query[Any, Any]](
135
+ self: Q,
136
+ relationship: str,
137
+ cb: SubqueryFn | None = None,
138
+ *,
139
+ flip: bool | None = None,
140
+ scalar: bool | None = None,
141
+ ) -> Q:
142
+ return self.where(lambda eb: eb.exists(relationship, cb, flip=flip, scalar=scalar))
143
+
144
+ def _relationship(self, relationship: str) -> tuple[Connection, ...]:
145
+ by_table = self._schema.relationships.get(self._table)
146
+ if by_table is None:
147
+ raise TypeError(f"Cannot read properties of undefined (reading '{relationship}')")
148
+ related = by_table.get(relationship)
149
+ check(related, "Invalid relationship")
150
+ assert related is not None
151
+ return related
152
+
153
+ def related[Q: Query[Any, Any]](self: Q, relationship: str, cb: SubqueryFn | None = None) -> Q:
154
+ """Nest the rows of `relationship` under each row, shaped by `cb` if given."""
155
+ if relationship.startswith(SUBQ_PREFIX):
156
+ raise Exception(f'Relationship names may not start with "{SUBQ_PREFIX}". That is a reserved prefix.')
157
+ cb = cb or (lambda q: q)
158
+ related = self._relationship(relationship)
159
+ if len(related) == 1:
160
+ (hop,) = related
161
+ q = self._new(
162
+ hop.dest_schema,
163
+ AST(table=hop.dest_schema, alias=relationship),
164
+ None,
165
+ Format(singular=hop.cardinality == "one"),
166
+ )
167
+ sub = _as_query(cb(q))
168
+ check(len(hop.source_field) >= 1, "The source of a relationship must specify at last 1 field")
169
+ check(len(hop.dest_field) >= 1, "The destination of a relationship must specify at last 1 field")
170
+ check(
171
+ len(hop.source_field) == len(hop.dest_field),
172
+ "The source and destination of a relationship must have the same number of fields",
173
+ )
174
+ entry = CorrelatedSubquery(
175
+ system=self._system,
176
+ correlation=Correlation(parent_field=hop.source_field, child_field=hop.dest_field),
177
+ subquery=sub.ast,
178
+ )
179
+ return self._with_relationship(relationship, sub, entry)
180
+
181
+ first, second = related
182
+ q = self._new(
183
+ second.dest_schema,
184
+ AST(table=second.dest_schema, alias=relationship),
185
+ relationship,
186
+ Format(singular=second.cardinality == "one"),
187
+ )
188
+ sub = _as_query(cb(q))
189
+ for key in (first.source_field, first.dest_field, second.source_field, second.dest_field):
190
+ check(len(key) >= 1, "Invalid relationship")
191
+ entry = CorrelatedSubquery(
192
+ system=self._system,
193
+ correlation=Correlation(parent_field=first.source_field, child_field=first.dest_field),
194
+ hidden=True,
195
+ subquery=AST(
196
+ table=first.dest_schema,
197
+ alias=relationship,
198
+ related=(
199
+ CorrelatedSubquery(
200
+ system=self._system,
201
+ correlation=Correlation(parent_field=second.source_field, child_field=second.dest_field),
202
+ subquery=sub.ast,
203
+ ),
204
+ ),
205
+ ),
206
+ )
207
+ return self._with_relationship(relationship, sub, entry)
208
+
209
+ def start[Q: Query[Any, Any]](self: Q, row: Mapping[str, Any], *, inclusive: bool = False) -> Q:
210
+ for column in row:
211
+ self._check_column(column)
212
+ return self._with(start=Bound(row=dict(row), exclusive=not inclusive))
213
+
214
+ def limit[Q: Query[Any, Any]](self: Q, limit: int | float) -> Q:
215
+ if limit < 0:
216
+ raise Exception("Limit must be non-negative")
217
+ if not (isinstance(limit, int | float) and limit == int(limit) and -(2**31) <= limit < 2**31):
218
+ raise Exception("Limit must be an integer")
219
+ if self._junction:
220
+ raise NotImplementedError(
221
+ "Limit is not supported in junction relationships yet. Junction relationship being limited: "
222
+ + self._junction
223
+ )
224
+ return self._with(limit=limit)
225
+
226
+ def order_by[Q: Query[Any, Any]](self: Q, field: C, direction: Direction) -> Q:
227
+ self._check_column(field)
228
+ if self._junction:
229
+ raise NotImplementedError(
230
+ "Order by is not supported in junction relationships yet. Junction relationship being ordered: "
231
+ + self._junction
232
+ )
233
+ return self._with(order_by=(*(self._ast.order_by or ()), (field, direction)))
234
+
235
+ def _exists(
236
+ self,
237
+ relationship: str,
238
+ cb: SubqueryFn | None,
239
+ *,
240
+ flip: bool | None = None,
241
+ scalar: bool | None = None,
242
+ ) -> Condition:
243
+ cb = cb or (lambda q: q)
244
+ related = self._relationship(relationship)
245
+ if len(related) == 1:
246
+ (hop,) = related
247
+ check(len(hop.source_field) >= 1, "Invalid relationship")
248
+ check(len(hop.dest_field) >= 1, "Invalid relationship")
249
+ sub = _as_query(
250
+ cb(self._new(hop.dest_schema, AST(table=hop.dest_schema, alias=f"{SUBQ_PREFIX}{relationship}"), None))
251
+ )
252
+ return CorrelatedSubqueryCondition(
253
+ related=CorrelatedSubquery(
254
+ system=self._system,
255
+ correlation=Correlation(parent_field=hop.source_field, child_field=hop.dest_field),
256
+ subquery=sub.ast,
257
+ ),
258
+ op="EXISTS",
259
+ flip=flip,
260
+ scalar=scalar,
261
+ )
262
+
263
+ first, second = related
264
+ for key in (first.source_field, first.dest_field, second.source_field, second.dest_field):
265
+ check(len(key) >= 1, "Invalid relationship")
266
+ to_dest = _as_query(
267
+ cb(
268
+ self._new(
269
+ second.dest_schema,
270
+ AST(table=second.dest_schema, alias=f"{SUBQ_PREFIX}zhidden_{relationship}"),
271
+ relationship,
272
+ )
273
+ )
274
+ )
275
+ return CorrelatedSubqueryCondition(
276
+ related=CorrelatedSubquery(
277
+ system=self._system,
278
+ correlation=Correlation(parent_field=first.source_field, child_field=first.dest_field),
279
+ subquery=AST(
280
+ table=first.dest_schema,
281
+ alias=f"{SUBQ_PREFIX}{relationship}",
282
+ where=CorrelatedSubqueryCondition(
283
+ related=CorrelatedSubquery(
284
+ system=self._system,
285
+ correlation=Correlation(parent_field=second.source_field, child_field=second.dest_field),
286
+ subquery=to_dest.ast,
287
+ ),
288
+ op="EXISTS",
289
+ flip=flip,
290
+ scalar=scalar,
291
+ ),
292
+ ),
293
+ ),
294
+ op="EXISTS",
295
+ flip=flip,
296
+ )
297
+
298
+ def expression_builder(self) -> ExpressionBuilder:
299
+ return ExpressionBuilder(self._exists, self._check_column)
300
+
301
+
302
+ class OneQuery(Query[R, C]):
303
+ """A query `one()` narrowed to a single row. Its methods keep it singular."""
304
+
305
+
306
+ def _as_query(q: object) -> AnyQuery:
307
+ check(isinstance(q, Query), "Expected QueryImpl instance")
308
+ assert isinstance(q, Query)
309
+ return q
310
+
311
+
312
+ class Builder:
313
+ """`create_builder(schema).issue` starts a query on the `issue` table.
314
+
315
+ With `strict=True`, a column the table does not have is refused when the query is built, as
316
+ `UnknownColumnError`. Upstream refuses it only when the query endpoint maps the AST, failing the
317
+ whole batch, so strict mode is for catching mistakes in your own tests rather than for production
318
+ parity.
319
+ """
320
+
321
+ def __init__(self, schema: Schema, *, strict: bool = False) -> None:
322
+ self._schema = schema
323
+ self._strict = strict
324
+
325
+ def __getattr__(self, table: str) -> Query[Any, Any]:
326
+ return self.table(table)
327
+
328
+ def table(self, name: str) -> Query[Any, Any]:
329
+ if name not in self._schema.tables:
330
+ raise AttributeError(name)
331
+ return Query(self._schema, name, AST(table=name), strict=self._strict)
332
+
333
+
334
+ def create_builder(schema: Schema, *, strict: bool = False) -> Builder:
335
+ return Builder(schema, strict=strict)
336
+
337
+
338
+ __all__ = ["MISSING", "Builder", "OneQuery", "Query", "UnknownColumnError", "create_builder"]
@@ -0,0 +1,167 @@
1
+ """Conditions for `Query.where`: `cmp`, `and_`, `or_`, `not_` and `exists`.
2
+
3
+ Ported from rocicorp/mono `packages/zql/src/query/expression.ts` at zero/v1.10.0
4
+ (905d08516ba0caaf314cd28e93a5a47e8acdd482). Apache-2.0, Copyright Rocicorp.
5
+ """
6
+
7
+ from collections.abc import Callable, Sequence
8
+ from typing import TYPE_CHECKING, Any, Final
9
+
10
+ from zero_server.protocol.ast import (
11
+ ColumnReference,
12
+ Condition,
13
+ Conjunction,
14
+ CorrelatedSubqueryCondition,
15
+ Disjunction,
16
+ LiteralReference,
17
+ Parameter,
18
+ SimpleCondition,
19
+ SimpleOperator,
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from zero_server.query.builder import Query
24
+
25
+ MISSING: Final[Any] = object()
26
+ """Marks an argument the caller left out, which upstream tells apart from an explicit `undefined`."""
27
+
28
+ TRUE: Final[Condition] = Conjunction(conditions=())
29
+ FALSE: Final[Condition] = Disjunction(conditions=())
30
+
31
+ _NEGATED: Final[dict[str, str]] = {
32
+ "=": "!=",
33
+ "!=": "=",
34
+ "<": ">=",
35
+ ">": "<=",
36
+ ">=": "<",
37
+ "<=": ">",
38
+ "IN": "NOT IN",
39
+ "NOT IN": "IN",
40
+ "LIKE": "NOT LIKE",
41
+ "NOT LIKE": "LIKE",
42
+ "ILIKE": "NOT ILIKE",
43
+ "NOT ILIKE": "ILIKE",
44
+ "IS": "IS NOT",
45
+ "IS NOT": "IS",
46
+ "EXISTS": "NOT EXISTS",
47
+ "NOT EXISTS": "EXISTS",
48
+ }
49
+
50
+
51
+ def _position(value: Any) -> LiteralReference | Parameter:
52
+ return value if isinstance(value, Parameter) else LiteralReference(value=value)
53
+
54
+
55
+ def cmp(field: str, op_or_value: Any, value: Any = MISSING) -> Condition:
56
+ """`cmp(field, value)` compares with `=`; `cmp(field, op, value)` with `op`."""
57
+ if value is MISSING:
58
+ op, actual = "=", op_or_value
59
+ else:
60
+ op, actual = op_or_value, value
61
+ return SimpleCondition(op=op, left=ColumnReference(name=field), right=_position(actual))
62
+
63
+
64
+ def _is_always_true(condition: Condition) -> bool:
65
+ return isinstance(condition, Conjunction) and not condition.conditions
66
+
67
+
68
+ def _is_always_false(condition: Condition) -> bool:
69
+ return isinstance(condition, Disjunction) and not condition.conditions
70
+
71
+
72
+ def and_(*conditions: Condition | None) -> Condition:
73
+ expressions = [c for c in conditions if c is not None and not _is_always_true(c)]
74
+ if len(expressions) == 1:
75
+ return expressions[0]
76
+ if any(_is_always_false(c) for c in expressions):
77
+ return FALSE
78
+ return Conjunction(conditions=tuple(expressions))
79
+
80
+
81
+ def or_(*conditions: Condition | None) -> Condition:
82
+ expressions = [c for c in conditions if c is not None and not _is_always_false(c)]
83
+ if len(expressions) == 1:
84
+ return expressions[0]
85
+ if any(_is_always_true(c) for c in expressions):
86
+ return TRUE
87
+ return Disjunction(conditions=tuple(expressions))
88
+
89
+
90
+ def not_(expression: Condition) -> Condition:
91
+ match expression:
92
+ case Conjunction():
93
+ return Disjunction(conditions=tuple(not_(c) for c in expression.conditions))
94
+ case Disjunction():
95
+ return Conjunction(conditions=tuple(not_(c) for c in expression.conditions))
96
+ case CorrelatedSubqueryCondition():
97
+ return CorrelatedSubqueryCondition(
98
+ related=expression.related,
99
+ op=_NEGATED[expression.op], # ty: ignore[invalid-argument-type]
100
+ flip=expression.flip,
101
+ scalar=expression.scalar,
102
+ )
103
+ case _:
104
+ return SimpleCondition(
105
+ op=_NEGATED[expression.op], # ty: ignore[invalid-argument-type]
106
+ left=expression.left,
107
+ right=expression.right,
108
+ )
109
+
110
+
111
+ def _flatten(kind: str, conditions: Sequence[Condition]) -> list[Condition]:
112
+ out: list[Condition] = []
113
+ for c in conditions:
114
+ if isinstance(c, Conjunction | Disjunction) and c.type == kind:
115
+ out.extend(c.conditions)
116
+ else:
117
+ out.append(c)
118
+ return out
119
+
120
+
121
+ def simplify_condition(c: Condition) -> Condition:
122
+ if isinstance(c, SimpleCondition | CorrelatedSubqueryCondition):
123
+ return c
124
+ if len(c.conditions) == 1:
125
+ return simplify_condition(c.conditions[0])
126
+ conditions = _flatten(c.type, [simplify_condition(x) for x in c.conditions])
127
+ if c.type == "and" and any(_is_always_false(x) for x in conditions):
128
+ return FALSE
129
+ if c.type == "or" and any(_is_always_true(x) for x in conditions):
130
+ return TRUE
131
+ return type(c)(conditions=tuple(conditions))
132
+
133
+
134
+ type ExistsFn = Callable[..., Condition]
135
+
136
+
137
+ class ExpressionBuilder:
138
+ """What a `where` callback receives. `and`, `or` and `not` gain a trailing underscore in Python."""
139
+
140
+ def __init__(self, exists: ExistsFn, check_column: Callable[[str], None] | None = None) -> None:
141
+ self._exists = exists
142
+ self._check_column = check_column or (lambda _column: None)
143
+
144
+ @property
145
+ def eb(self) -> "ExpressionBuilder":
146
+ return self
147
+
148
+ def cmp(self, field: str, op_or_value: Any, value: Any = MISSING) -> Condition:
149
+ self._check_column(field)
150
+ return cmp(field, op_or_value, value)
151
+
152
+ def cmp_lit(self, left: Any, op: SimpleOperator, right: Any) -> Condition:
153
+ return SimpleCondition(op=op, left=_position(left), right=_position(right))
154
+
155
+ and_ = staticmethod(and_)
156
+ or_ = staticmethod(or_)
157
+ not_ = staticmethod(not_)
158
+
159
+ def exists(
160
+ self,
161
+ relationship: str,
162
+ cb: "Callable[[Query], Query] | None" = None,
163
+ *,
164
+ flip: bool | None = None,
165
+ scalar: bool | None = None,
166
+ ) -> Condition:
167
+ return self._exists(relationship, cb, flip=flip, scalar=scalar)