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,64 @@
1
+ """Nested, dot-named namespaces of queries or mutators, as `defineQueries` and `defineMutators` build them.
2
+
3
+ Ported from rocicorp/mono `packages/shared/src/object-traversal.ts` and `deep-merge.ts` at zero/v1.10.0
4
+ (905d08516ba0caaf314cd28e93a5a47e8acdd482). Apache-2.0, Copyright Rocicorp.
5
+ """
6
+
7
+ import re
8
+ from collections.abc import Callable, Iterator, Mapping
9
+ from typing import Any
10
+
11
+
12
+ class Registry(Mapping[str, Any]):
13
+ """`registry.issue.create` and `registry["issue"]["create"]` both work."""
14
+
15
+ def __init__(self, entries: dict[str, Any]) -> None:
16
+ self._entries = entries
17
+
18
+ def __getitem__(self, key: str) -> Any:
19
+ return self._entries[key]
20
+
21
+ def __getattr__(self, key: str) -> Any:
22
+ try:
23
+ return self._entries[key]
24
+ except KeyError:
25
+ raise AttributeError(key) from None
26
+
27
+ def __iter__(self) -> Iterator[str]:
28
+ return iter(self._entries)
29
+
30
+ def __len__(self) -> int:
31
+ return len(self._entries)
32
+
33
+
34
+ def build[D, L](
35
+ definitions: Mapping[str, Any], is_definition: Callable[[Any], bool], make: Callable[[str, Any], L]
36
+ ) -> Registry:
37
+ def process(defs: Mapping[str, Any], path: list[str]) -> Registry:
38
+ return Registry(
39
+ {
40
+ key: make(".".join([*path, key]), value) if is_definition(value) else process(value, [*path, key])
41
+ for key, value in defs.items()
42
+ }
43
+ )
44
+
45
+ return process(definitions, [])
46
+
47
+
48
+ def merge(base: Registry, overrides: Registry) -> Registry:
49
+ merged = dict(base._entries)
50
+ for key, value in overrides._entries.items():
51
+ current = merged.get(key)
52
+ merged[key] = merge(current, value) if isinstance(current, Registry) and isinstance(value, Registry) else value
53
+ return Registry(merged)
54
+
55
+
56
+ def value_at_path(root: object, path: str, separator: str | re.Pattern[str]) -> Any:
57
+ """The value at a separated `path` through nested registries or mappings, or `None`."""
58
+ parts = separator.split(path) if isinstance(separator, re.Pattern) else path.split(separator)
59
+ current: Any = root
60
+ for part in parts:
61
+ if not isinstance(current, Mapping) or part not in current:
62
+ return None
63
+ current = current[part]
64
+ return current
@@ -0,0 +1 @@
1
+ """ZQL compiled to Postgres SQL, from rocicorp/mono `packages/z2s`."""
@@ -0,0 +1,375 @@
1
+ """A ZQL AST compiled to one Postgres statement that returns the whole result as JSON.
2
+
3
+ Ported from rocicorp/mono `packages/z2s/src/compiler.ts` and `packages/zql/src/query/complete-ordering.ts`
4
+ at zero/v1.10.0 (905d08516ba0caaf314cd28e93a5a47e8acdd482). Apache-2.0, Copyright Rocicorp.
5
+
6
+ Relationships nest as `json_agg` subqueries, so the statement returns one row whose `zql_result` column
7
+ holds the rows the client-side query would have produced, in client names.
8
+ """
9
+
10
+ from collections.abc import Callable, Sequence
11
+ from dataclasses import dataclass
12
+
13
+ from zero_server.protocol.ast import (
14
+ AST,
15
+ Bound,
16
+ ColumnReference,
17
+ Condition,
18
+ Conjunction,
19
+ CorrelatedSubquery,
20
+ CorrelatedSubqueryCondition,
21
+ Correlation,
22
+ Disjunction,
23
+ LiteralReference,
24
+ Ordering,
25
+ Parameter,
26
+ SimpleCondition,
27
+ )
28
+ from zero_server.query.builder import Format
29
+ from zero_server.schema import NameMapper, Schema, TableSchema, client_to_server
30
+ from zero_server.server.server_schema import ServerColumnSchema, ServerSchema
31
+ from zero_server.shared.asserts import check
32
+ from zero_server.shared.js import js_string
33
+ from zero_server.z2s.sql import Params, ident, literal_type
34
+
35
+ ZQL_RESULT_KEY = "zql_result"
36
+ _ROOT = "zql_root"
37
+ _STATIC = "Static parameters must be bound to a value before compiling to SQL"
38
+
39
+
40
+ def complete_ordering(ast: AST, primary_key: Callable[[str], Sequence[str]]) -> AST:
41
+ """Append each table's primary key to its ordering, so every result has one total order."""
42
+ pk = primary_key(ast.table)
43
+ check(pk is not None, "Unexpected undefined value")
44
+ update: dict[str, object] = {"order_by": _add_primary_keys(pk, ast.order_by)}
45
+ if ast.related is not None:
46
+ update["related"] = tuple(
47
+ r.model_copy(update={"subquery": complete_ordering(r.subquery, primary_key)}) for r in ast.related
48
+ )
49
+ if ast.where is not None:
50
+ update["where"] = _complete_condition(ast.where, primary_key)
51
+ return ast.model_copy(update=update)
52
+
53
+
54
+ def _complete_condition(condition: Condition, primary_key: Callable[[str], Sequence[str]]) -> Condition:
55
+ match condition:
56
+ case SimpleCondition():
57
+ return condition
58
+ case CorrelatedSubqueryCondition():
59
+ related = condition.related.model_copy(
60
+ update={"subquery": complete_ordering(condition.related.subquery, primary_key)}
61
+ )
62
+ return condition.model_copy(update={"related": related})
63
+ case _:
64
+ return condition.model_copy(
65
+ update={"conditions": tuple(_complete_condition(c, primary_key) for c in condition.conditions)}
66
+ )
67
+
68
+
69
+ def _add_primary_keys(primary_key: Sequence[str], order_by: Ordering | None) -> Ordering:
70
+ order_by = order_by or ()
71
+ ordered = {field for field, _ in order_by}
72
+ missing = [key for key in dict.fromkeys(primary_key) if key not in ordered]
73
+ return (*order_by, *((key, "asc") for key in missing))
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class _Table:
78
+ zql: str
79
+ alias: str
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class _Column:
84
+ table: _Table
85
+ zql: str
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class _JunctionTable:
90
+ table: _Table
91
+ correlation: Correlation
92
+ limit: int | float | None
93
+
94
+
95
+ type _Correlate = Callable[[_Table], str]
96
+
97
+
98
+ def compile_query(
99
+ server_schema: ServerSchema, schema: Schema, ast: AST, format: Format | None = None
100
+ ) -> tuple[str, list[str | None]]:
101
+ """The SQL text and its bound values. The statement returns the result in column `zql_result`."""
102
+ compiler = _Compiler(server_schema, schema)
103
+ ast = complete_ordering(ast, lambda table: schema.tables[table].primary_key)
104
+ body = compiler.select(ast, format)
105
+ text = (
106
+ f"SELECT {_to_json(_ROOT, format.singular if format else False)}::text AS {ident(ZQL_RESULT_KEY)} "
107
+ f"FROM ({body}) {ident(_ROOT)}"
108
+ )
109
+ return text, compiler.params.values
110
+
111
+
112
+ def _to_json(table: str, singular: bool) -> str:
113
+ if singular:
114
+ return f"(row_to_json({ident(table)}))"
115
+ return f"COALESCE(json_agg(row_to_json({ident(table)})), '[]'::json)"
116
+
117
+
118
+ def limit_clause(params: Params, limit: int | float | None, singular: bool) -> str:
119
+ if limit == 0:
120
+ return "LIMIT 0"
121
+ if singular:
122
+ return "LIMIT 1"
123
+ if limit is None:
124
+ return ""
125
+ return f"LIMIT {params.literal(limit)}"
126
+
127
+
128
+ class _Compiler:
129
+ def __init__(self, server_schema: ServerSchema, schema: Schema) -> None:
130
+ self.server_schema = server_schema
131
+ self.mapper: NameMapper = client_to_server(schema.tables)
132
+ self.tables: dict[str, TableSchema] = schema.tables
133
+ self.alias_count = 0
134
+ self.params = Params()
135
+
136
+ def _table(self, zql: str) -> _Table:
137
+ alias = f"{zql}_{self.alias_count}"
138
+ self.alias_count += 1
139
+ return _Table(zql=zql, alias=alias)
140
+
141
+ def _col(self, column: _Column) -> str:
142
+ return ident(column.table.alias, self.mapper.column_name(column.table.zql, column.zql))
143
+
144
+ def _from(self, table: _Table) -> str:
145
+ return f"{ident(self.mapper.table_name(table.zql))} AS {ident(table.alias)}"
146
+
147
+ def _server_column(self, table: _Table, zql_column: str) -> ServerColumnSchema:
148
+ # The schema check on the first transaction guarantees the column exists.
149
+ return self.server_schema[self.mapper.table_name(table.zql)][self.mapper.column_name(table.zql, zql_column)]
150
+
151
+ def _select_ident(self, column: _Column) -> str:
152
+ server = self._server_column(column.table, column.zql)
153
+ if not server.is_enum and server.type in _DATE_TIME_TYPES:
154
+ normalize = server.type in ("timestamptz", "timetz")
155
+
156
+ def to_ms(epoch: str) -> str:
157
+ # EXTRACT(EPOCH FROM timetz) is negative when the offset is positive; wrap to 0..86400000.
158
+ return f"(({epoch})::bigint + 86400000) % 86400000" if normalize else epoch
159
+
160
+ col = self._col(column)
161
+ if server.is_array:
162
+ return (
163
+ f"CASE WHEN {col} IS NULL THEN NULL ELSE "
164
+ f"ARRAY(SELECT {to_ms(f'EXTRACT(EPOCH FROM unnest({col})) * 1000')}) END as {ident(column.zql)}"
165
+ )
166
+ return f"{to_ms(f'EXTRACT(EPOCH FROM {col}) * 1000')} as {ident(column.zql)}"
167
+ return f"{self._col(column)} as {ident(column.zql)}"
168
+
169
+ def select(self, ast: AST, format: Format | None, correlate: _Correlate | None = None) -> str:
170
+ table = self._table(ast.table)
171
+ selection = self._related(ast.related or (), format, table)
172
+ used = {r.subquery.alias or "" for r in ast.related or ()}
173
+ selection += [
174
+ self._select_ident(_Column(table, column))
175
+ for column in self.tables[ast.table].columns
176
+ if column not in used
177
+ ]
178
+
179
+ clauses: list[str] = []
180
+ if ast.where is not None:
181
+ clauses.append(self._where(ast.where, table))
182
+ if ast.start is not None:
183
+ clauses.append(self._start(ast.start, ast.order_by, table))
184
+ if correlate is not None:
185
+ clauses.append(correlate(table))
186
+ where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
187
+ return (
188
+ f"SELECT {','.join(selection)} FROM {self._from(table)} {where} "
189
+ f"{self._order_by(ast.order_by, table)} "
190
+ f"{limit_clause(self.params, ast.limit, format.singular if format else False)}"
191
+ )
192
+
193
+ def _order_by(self, order_by: Ordering | None, table: _Table) -> str:
194
+ # Never empty: `complete_ordering` has appended the primary key.
195
+ assert order_by
196
+ return "ORDER BY " + ", ".join(
197
+ f"{self._col(_Column(table, col))} ASC NULLS FIRST"
198
+ if direction == "asc"
199
+ else f"{self._col(_Column(table, col))} DESC NULLS LAST"
200
+ for col, direction in order_by
201
+ )
202
+
203
+ def _start(self, bound: Bound, order_by: Ordering | None, table: _Table) -> str:
204
+ check(order_by is not None and len(order_by) > 0, "start requires ordering")
205
+ assert order_by is not None
206
+ constraints: list[str] = []
207
+ for i, (i_field, i_direction) in enumerate(order_by):
208
+ group = [self._start_equality(table, field, bound.row.get(field)) for field, _ in order_by[:i]]
209
+ group.append(
210
+ self._start_range(table, i_field, bound.row.get(i_field), ">" if i_direction == "asc" else "<")
211
+ )
212
+ constraints.append(f"({' AND '.join(group)})")
213
+ if not bound.exclusive:
214
+ equal = [self._start_equality(table, field, bound.row.get(field)) for field, _ in order_by]
215
+ constraints.append(f"({' AND '.join(equal)})")
216
+ return f"({' OR '.join(constraints)})"
217
+
218
+ def _start_value(self, table: _Table, field: str, value: object) -> str:
219
+ return self.params.column(self._server_column(table, field), value, comparison=True)
220
+
221
+ def _start_equality(self, table: _Table, field: str, value: object) -> str:
222
+ return f"{self._col(_Column(table, field))} IS NOT DISTINCT FROM {self._start_value(table, field, value)}"
223
+
224
+ def _start_range(self, table: _Table, field: str, value: object, operator: str) -> str:
225
+ column = self._col(_Column(table, field))
226
+ if value is None:
227
+ return f"{column} IS NOT NULL" if operator == ">" else "FALSE"
228
+ typed = self._start_value(table, field, value)
229
+ return f"{column} > {typed}" if operator == ">" else f"({column} IS NULL OR {column} < {typed})"
230
+
231
+ def _related(self, relationships: Sequence[CorrelatedSubquery], format: Format | None, parent: _Table) -> list[str]:
232
+ return [
233
+ self._relationship(r, format.relationships.get(_must(r.subquery.alias)) if format else None, parent)
234
+ for r in relationships
235
+ ]
236
+
237
+ def _correlator(self, parent_fields: Sequence[_Column], child_fields: Sequence[str]) -> _Correlate:
238
+ def correlate(child: _Table) -> str:
239
+ return " AND ".join(
240
+ f"{self._col(parent)} = {self._col(_Column(child, field))}"
241
+ for parent, field in zip(parent_fields, child_fields, strict=False)
242
+ )
243
+
244
+ return correlate
245
+
246
+ def _parent_correlator(self, relationship: CorrelatedSubquery, parent: _Table) -> _Correlate:
247
+ return self._correlator(
248
+ [_Column(parent, f) for f in relationship.correlation.parent_field],
249
+ relationship.correlation.child_field,
250
+ )
251
+
252
+ def _relationship(self, relationship: CorrelatedSubquery, format: Format | None, parent: _Table) -> str:
253
+ alias = _must(relationship.subquery.alias)
254
+ inner = f"inner_{relationship.subquery.alias}"
255
+ singular = format.singular if format else False
256
+ if relationship.hidden:
257
+ join, tables = self._junction_join(relationship)
258
+ last = tables[-1].table
259
+ check(relationship.subquery.related, "hidden relationship must be a junction")
260
+ assert relationship.subquery.related is not None
261
+ nested = relationship.subquery.related[0].subquery
262
+ selection = self._related(nested.related or (), format, last)
263
+ selection += [self._select_ident(_Column(last, column)) for column in self.tables[nested.table].columns]
264
+ correlate = self._parent_correlator(relationship, parent)(tables[0].table)
265
+ where = f" AND {self._where(nested.where, last)}" if nested.where is not None else ""
266
+ start = f" AND {self._start(nested.start, nested.order_by, last)}" if nested.start is not None else ""
267
+ return (
268
+ f"(SELECT {_to_json(inner, singular)} FROM (SELECT {','.join(selection)} FROM {join} "
269
+ f"WHERE ({correlate}){where}{start} {self._order_by(nested.order_by, last)} "
270
+ f"{limit_clause(self.params, tables[-1].limit, singular)} ) {ident(inner)}) as {ident(alias)}"
271
+ )
272
+ body = self.select(relationship.subquery, format, self._parent_correlator(relationship, parent))
273
+ return f"(SELECT {_to_json(inner, singular)} FROM ({body}) {ident(inner)}) as {ident(alias)}"
274
+
275
+ def _junction_join(self, relationship: CorrelatedSubquery) -> tuple[str, list[_JunctionTable]]:
276
+ related = relationship.subquery.related
277
+ check(related is not None and len(related) == 1, "Too many related tables for a junction edge")
278
+ assert related is not None
279
+ other = related[0]
280
+ check(not other.hidden, "Expected junction edge relationship to not be hidden")
281
+ tables = [
282
+ _JunctionTable(
283
+ self._table(relationship.subquery.table), relationship.correlation, relationship.subquery.limit
284
+ ),
285
+ _JunctionTable(self._table(other.subquery.table), other.correlation, other.subquery.limit),
286
+ ]
287
+ first, second = tables
288
+ on = self._correlator(
289
+ [_Column(first.table, f) for f in second.correlation.parent_field], second.correlation.child_field
290
+ )(second.table)
291
+ return f"{self._from(first.table)} JOIN {self._from(second.table)} ON {on}", tables
292
+
293
+ def _where(self, condition: Condition, table: _Table) -> str:
294
+ match condition:
295
+ case Conjunction():
296
+ return f"({' AND '.join(self._where(c, table) for c in condition.conditions)})"
297
+ case Disjunction():
298
+ return f"({' OR '.join(self._where(c, table) for c in condition.conditions)})"
299
+ case CorrelatedSubqueryCondition():
300
+ # `scalar` is a planner hint for the IVM engines; Postgres decorrelates EXISTS on its own.
301
+ body = self.select(condition.related.subquery, None, self._parent_correlator(condition.related, table))
302
+ return f"{'NOT ' if condition.op == 'NOT EXISTS' else ''}EXISTS ({body})"
303
+ case _:
304
+ return self._simple(condition, table)
305
+
306
+ def _simple(self, condition: SimpleCondition, table: _Table) -> str:
307
+ left, right = condition.left, condition.right
308
+ match condition.op:
309
+ case "IN" | "NOT IN":
310
+ lhs = self._value(left, table, right, plural=False)
311
+ rhs = self._value(right, table, left, plural=True)
312
+ return f"{'NOT' if condition.op == 'NOT IN' else ''} ({lhs} = ANY ({rhs}))"
313
+ case "IS" | "IS NOT":
314
+ lhs = self._value(left, table, right, plural=False)
315
+ rhs = self._value(right, table, left, plural=False)
316
+ distinct = "IS NOT DISTINCT FROM" if condition.op == "IS" else "IS DISTINCT FROM"
317
+ return f"{lhs} {distinct} {rhs}"
318
+ case _:
319
+ lhs = self._value(left, table, right, plural=False)
320
+ rhs = self._value(right, table, left, plural=False)
321
+ return f"{lhs} {condition.op} {rhs}"
322
+
323
+ def _value(
324
+ self,
325
+ position: LiteralReference | ColumnReference | Parameter,
326
+ table: _Table,
327
+ other: LiteralReference | ColumnReference | Parameter,
328
+ *,
329
+ plural: bool,
330
+ ) -> str:
331
+ match position:
332
+ case ColumnReference():
333
+ return self._col(_Column(table, position.name))
334
+ case Parameter():
335
+ raise Exception(_STATIC)
336
+ case _:
337
+ return self._literal(position, table, other, plural=plural)
338
+
339
+ def _literal(
340
+ self,
341
+ position: LiteralReference,
342
+ table: _Table,
343
+ other: LiteralReference | ColumnReference | Parameter,
344
+ *,
345
+ plural: bool,
346
+ ) -> str:
347
+ value = position.value
348
+ match other:
349
+ case ColumnReference():
350
+ return self.params.column(self._server_column(table, other.name), value, plural=plural, comparison=True)
351
+ case Parameter():
352
+ raise Exception(_STATIC)
353
+ case _:
354
+ check(plural == isinstance(value, tuple), "Expected plural flag to match whether value is an array")
355
+ if isinstance(value, tuple):
356
+ # Only the right side of IN is plural, and the left side was compiled first, so it is
357
+ # a non-null scalar here. An empty list takes its element type from it.
358
+ return self.params.plural_literal(literal_type(value[0] if value else other.value), list(value))
359
+ if value is None:
360
+ raise Exception(f"Literal of unexpected type. {js_string(value)} of type object")
361
+ return self.params.literal(value)
362
+
363
+
364
+ _DATE_TIME_TYPES = frozenset(
365
+ {
366
+ "timestamptz", "timetz", "date", "time", "time without time zone", "time with time zone", "timestamp",
367
+ "timestamp without time zone", "timestamp with time zone",
368
+ }
369
+ ) # fmt: skip
370
+
371
+
372
+ def _must(alias: str | None) -> str:
373
+ check(alias is not None, "Unexpected undefined value")
374
+ assert alias is not None
375
+ return alias
zero_server/z2s/sql.py ADDED
@@ -0,0 +1,130 @@
1
+ """Identifiers and bound values for the SQL that z2s and `tx.mutate` write.
2
+
3
+ Ported from rocicorp/mono `packages/z2s/src/sql.ts` (`formatPgInternalConvert` and its argument
4
+ conversions) at zero/v1.10.0 (905d08516ba0caaf314cd28e93a5a47e8acdd482). Apache-2.0, Copyright Rocicorp.
5
+
6
+ Every value is bound as text and cast in SQL, so the driver never coerces it: a number is its JSON
7
+ text, a timestamp epoch milliseconds, a JSON value its `JSON.stringify` output.
8
+ """
9
+
10
+ from typing import Any, Literal
11
+
12
+ from zero_server.server.pg_types import is_native_string, is_number, is_string, is_text_represented
13
+ from zero_server.server.server_schema import ServerColumnSchema
14
+ from zero_server.shared.js import js_string, js_stringify
15
+
16
+ type LiteralType = Literal["boolean", "number", "string"]
17
+
18
+ _LITERAL_PG_TYPE: dict[str, str] = {"boolean": "boolean", "number": "double precision", "string": "text"}
19
+
20
+
21
+ def ident(*names: str) -> str:
22
+ """Quote an identifier as upstream's `sql.ident` does. A single dotted name is split into its parts."""
23
+ parts = names[0].split(".") if len(names) == 1 else list(names)
24
+ return ".".join('"' + part.replace('"', '""') + '"' for part in parts)
25
+
26
+
27
+ def literal_type(value: object) -> LiteralType:
28
+ """`typeof value` for a scalar literal."""
29
+ match value:
30
+ case bool():
31
+ return "boolean"
32
+ case int() | float():
33
+ return "number"
34
+ case _:
35
+ return "string"
36
+
37
+
38
+ def _cast(placeholder: str, column: ServerColumnSchema, comparison: bool) -> str:
39
+ match column.type:
40
+ case "timestamptz" | "timestamp with time zone":
41
+ return f"to_timestamp({placeholder}::text::numeric / 1000.0)"
42
+ case "date" | "timestamp" | "timestamp without time zone":
43
+ return f"to_timestamp({placeholder}::text::numeric / 1000.0) AT TIME ZONE 'UTC'"
44
+ case "timetz" | "time with time zone":
45
+ return f"({placeholder}::text::int * interval'1ms')::time"
46
+ case "time" | "time without time zone":
47
+ return f"({placeholder}::text::int * interval'1ms')::time AT TIME ZONE 'UTC'"
48
+ case "uuid":
49
+ return f"{placeholder}::text::uuid"
50
+ if column.is_enum:
51
+ return f'{placeholder}::text::"{column.type}"'
52
+ if is_native_string(column.type):
53
+ # Compared as plain text, so a value longer than a varchar column still compares.
54
+ return f"{placeholder}::text" if comparison else f"{placeholder}::text::{column.type}"
55
+ if is_text_represented(column.type):
56
+ return f"{placeholder}::text::{column.type}"
57
+ if is_number(column.type):
58
+ # Compared as a double, the representation every ZQL number has.
59
+ return f"{placeholder}::text::double precision" if comparison else f"{placeholder}::text::{column.type}"
60
+ return f"{placeholder}::text::{column.type}"
61
+
62
+
63
+ def _plural(index: int, select: str) -> str:
64
+ return f"ARRAY(SELECT {select} FROM jsonb_array_elements_text(${index}::text::jsonb))"
65
+
66
+
67
+ def _same_value_key(value: object) -> object:
68
+ """A key equal for values a JavaScript `Map` treats as one key: equal primitives, the same object."""
69
+ match value:
70
+ case None:
71
+ return ("null",)
72
+ case bool():
73
+ return ("boolean", value)
74
+ case int() | float():
75
+ return ("number", float(value))
76
+ case str():
77
+ return ("string", value)
78
+ case _:
79
+ return ("object", id(value))
80
+
81
+
82
+ class Params:
83
+ """The values bound to one statement, in placeholder order.
84
+
85
+ Like upstream's formatter, a value already bound with the same type reuses its placeholder. That is
86
+ not only cosmetic: Postgres infers a reused placeholder's type from its first use, so whether a
87
+ bare `$n` works can depend on it. Values must be bound in the order they appear in the SQL text.
88
+ """
89
+
90
+ def __init__(self) -> None:
91
+ self.values: list[str | None] = []
92
+ self._seen: dict[tuple[object, str], int] = {}
93
+ # Objects keyed by identity are kept alive so their ids stay unique for the statement.
94
+ self._held: list[object] = []
95
+
96
+ def _bind(self, value: object, type_key: str, bound: str | None) -> int:
97
+ key = (_same_value_key(value), type_key)
98
+ if key in self._seen:
99
+ return self._seen[key]
100
+ self._held.append(value)
101
+ self.values.append(bound)
102
+ self._seen[key] = len(self.values)
103
+ return len(self.values)
104
+
105
+ def column(self, column: ServerColumnSchema, value: Any, *, plural: bool = False, comparison: bool) -> str:
106
+ """A value compared with or written to `column`, cast to the column's type."""
107
+ plural = plural or column.is_array
108
+ if value is None:
109
+ bound = None
110
+ elif plural:
111
+ bound = js_stringify(value)
112
+ elif column.is_enum or is_string(column.type):
113
+ bound = value if isinstance(value, str) else js_string(value)
114
+ else:
115
+ bound = js_stringify(value)
116
+ index = self._bind(value, column.type, bound)
117
+ if value is None and plural:
118
+ return f"${index}"
119
+ common = _cast("value" if plural else f"${index}", column, comparison)
120
+ return _plural(index, common) if plural else common
121
+
122
+ def literal(self, value: str | int | float | bool) -> str:
123
+ """A scalar compared with another literal, typed by its own JSON type."""
124
+ kind = literal_type(value)
125
+ index = self._bind(value, kind, value if isinstance(value, str) else js_stringify(value))
126
+ return f"${index}::text::{_LITERAL_PG_TYPE[kind]}"
127
+
128
+ def plural_literal(self, kind: LiteralType, values: list[Any]) -> str:
129
+ index = self._bind(values, kind, js_stringify(values))
130
+ return _plural(index, f"value::{_LITERAL_PG_TYPE[kind]}")