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.
- zero_server/__init__.py +36 -0
- zero_server/codegen.py +133 -0
- zero_server/integrations/__init__.py +1 -0
- zero_server/integrations/starlette.py +115 -0
- zero_server/mutators.py +87 -0
- zero_server/protocol/__init__.py +1 -0
- zero_server/protocol/application_error.py +36 -0
- zero_server/protocol/ast.py +342 -0
- zero_server/protocol/custom_queries.py +51 -0
- zero_server/protocol/error.py +119 -0
- zero_server/protocol/mutate_server.py +33 -0
- zero_server/protocol/mutation.py +127 -0
- zero_server/protocol/mutation_id.py +12 -0
- zero_server/protocol/push.py +25 -0
- zero_server/protocol/query_server.py +29 -0
- zero_server/protocol/wire.py +88 -0
- zero_server/py.typed +0 -0
- zero_server/query/__init__.py +1 -0
- zero_server/query/builder.py +338 -0
- zero_server/query/expression.py +167 -0
- zero_server/query/registry.py +143 -0
- zero_server/schema.py +142 -0
- zero_server/server/__init__.py +1 -0
- zero_server/server/adapters/__init__.py +1 -0
- zero_server/server/adapters/asyncpg.py +43 -0
- zero_server/server/adapters/psycopg.py +55 -0
- zero_server/server/adapters/sqlalchemy.py +49 -0
- zero_server/server/crud.py +134 -0
- zero_server/server/pg_query_executor.py +70 -0
- zero_server/server/pg_types.py +69 -0
- zero_server/server/process_mutations.py +412 -0
- zero_server/server/process_queries.py +109 -0
- zero_server/server/push_processor.py +49 -0
- zero_server/server/server_schema.py +165 -0
- zero_server/server/transaction.py +70 -0
- zero_server/server/zql_database.py +129 -0
- zero_server/shared/__init__.py +1 -0
- zero_server/shared/asserts.py +19 -0
- zero_server/shared/error.py +97 -0
- zero_server/shared/js.py +121 -0
- zero_server/shared/json.py +27 -0
- zero_server/shared/registry.py +64 -0
- zero_server/z2s/__init__.py +1 -0
- zero_server/z2s/compiler.py +375 -0
- zero_server/z2s/sql.py +130 -0
- zero_server-0.0.1.dist-info/METADATA +134 -0
- zero_server-0.0.1.dist-info/RECORD +50 -0
- zero_server-0.0.1.dist-info/WHEEL +4 -0
- zero_server-0.0.1.dist-info/entry_points.txt +3 -0
- 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)
|