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,143 @@
1
+ """`define_query` and `define_queries`: named queries the query endpoint resolves by name.
2
+
3
+ Ported from rocicorp/mono `packages/zql/src/query/query-registry.ts`, `validate-input.ts` and
4
+ `error.ts` at zero/v1.10.0 (905d08516ba0caaf314cd28e93a5a47e8acdd482). Apache-2.0, Copyright Rocicorp.
5
+
6
+ A validator is anything with Pydantic's `validate_python` (a `TypeAdapter`, or a model class), or a
7
+ plain callable that returns the parsed value and raises `ValueError` on bad input. Upstream takes a
8
+ Standard Schema validator; the error it raises has the same shape.
9
+ """
10
+
11
+ import re
12
+ from collections.abc import Callable, Mapping
13
+ from dataclasses import dataclass
14
+ from typing import Any, overload
15
+
16
+ from pydantic import ValidationError
17
+
18
+ from zero_server.query.builder import Query
19
+ from zero_server.shared.json import JSONValue
20
+ from zero_server.shared.registry import Registry, build, merge, value_at_path
21
+
22
+ type QueryFn = Callable[..., Query]
23
+ """Called as `fn(args=..., ctx=...)`."""
24
+
25
+ _SEPARATOR = re.compile(r"[.|]")
26
+
27
+
28
+ class QueryParseError(Exception):
29
+ """The query endpoint reports this as a `parse` error rather than an `app` error."""
30
+
31
+ def __init__(self, cause: BaseException | None = None) -> None:
32
+ message = (
33
+ f"Failed to parse arguments for query: {cause}"
34
+ if isinstance(cause, Exception) and str(cause)
35
+ else "Failed to parse arguments for query"
36
+ )
37
+ super().__init__(message)
38
+ self.__cause__ = cause
39
+
40
+
41
+ class InputValidationError(Exception):
42
+ def __init__(self, message: str, issues: list[dict[str, Any]]) -> None:
43
+ super().__init__(message)
44
+ self.message = message
45
+ self.details: dict[str, Any] = {"type": "InputValidationError", "result": {"issues": issues}}
46
+
47
+
48
+ def _issues(error: Exception) -> list[dict[str, Any]]:
49
+ if isinstance(error, ValidationError):
50
+ return [{"message": e["msg"], "path": list(e["loc"])} for e in error.errors()]
51
+ return [{"message": str(error)}]
52
+
53
+
54
+ def validate_input(name: str, value: Any, validator: Any, kind: str) -> Any:
55
+ if validator is None:
56
+ return value
57
+ try:
58
+ if hasattr(validator, "validate_python"):
59
+ return validator.validate_python(value)
60
+ if hasattr(validator, "model_validate"):
61
+ return validator.model_validate(value)
62
+ return validator(value)
63
+ except (ValidationError, ValueError, TypeError) as error:
64
+ issues = _issues(error)
65
+ joined = ", ".join(issue["message"] for issue in issues)
66
+ raise InputValidationError(f"Validation failed for {kind} {name}: {joined}", issues) from error
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class QueryDefinition:
71
+ fn: QueryFn
72
+ validator: Any = None
73
+
74
+
75
+ def is_schema_validator(value: object) -> bool:
76
+ """A Pydantic model class or `TypeAdapter`, as opposed to a function."""
77
+ return hasattr(value, "validate_python") or (isinstance(value, type) and hasattr(value, "model_validate"))
78
+
79
+
80
+ @overload
81
+ def define_query(fn_or_validator: QueryFn, fn: None = None) -> QueryDefinition: ...
82
+ @overload
83
+ def define_query(fn_or_validator: Any, fn: QueryFn) -> QueryDefinition: ...
84
+ @overload
85
+ def define_query(fn_or_validator: Any, fn: None = None) -> Callable[[QueryFn], QueryDefinition]: ...
86
+ def define_query(fn_or_validator: Any, fn: QueryFn | None = None) -> Any:
87
+ """Define a query, called as `fn(args=..., ctx=...)`. Three forms:
88
+
89
+ - `define_query(fn)`, or `@define_query` on `fn`: no validation, `args` as the client sent them.
90
+ - `define_query(validator, fn)`: `args` parsed by `validator` first.
91
+ - `@define_query(ArgsModel)` on `fn`: the same, for a Pydantic model or `TypeAdapter`. A plain
92
+ function validator needs the two-argument form, since it cannot be told apart from `fn`.
93
+ """
94
+ if fn is not None:
95
+ return QueryDefinition(fn=fn, validator=fn_or_validator)
96
+ if is_schema_validator(fn_or_validator):
97
+ return lambda decorated: QueryDefinition(fn=decorated, validator=fn_or_validator)
98
+ return QueryDefinition(fn=fn_or_validator)
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class QueryRequest:
103
+ query: "CustomQuery"
104
+ args: JSONValue | None
105
+
106
+
107
+ class CustomQuery:
108
+ def __init__(self, name: str, definition: QueryDefinition) -> None:
109
+ self.query_name = name
110
+ self._definition = definition
111
+
112
+ def fn(self, *, args: JSONValue | None = None, ctx: Any = None) -> Query:
113
+ validated = validate_input(self.query_name, args, self._definition.validator, "query")
114
+ return self._definition.fn(args=validated, ctx=ctx)
115
+
116
+ def __call__(self, args: JSONValue | None = None) -> QueryRequest:
117
+ return QueryRequest(query=self, args=args)
118
+
119
+
120
+ type QueryRegistry = Registry
121
+ type QueryDefinitions = Mapping[str, "QueryDefinition | QueryDefinitions"]
122
+
123
+
124
+ def define_queries(definitions: "QueryDefinitions | Registry", overrides: "QueryDefinitions | None" = None) -> Registry:
125
+ """Name each query by its path through `definitions`, dot-separated: `{"issue": {"byID": ...}}` is
126
+ `issue.byID`. `overrides` replaces or adds entries in a base registry."""
127
+ is_definition = lambda value: isinstance(value, QueryDefinition) # noqa: E731
128
+ base = definitions if isinstance(definitions, Registry) else build(definitions, is_definition, CustomQuery)
129
+ if overrides is None:
130
+ return base
131
+ return merge(base, build(overrides, is_definition, CustomQuery))
132
+
133
+
134
+ def get_query(queries: Registry, name: str) -> CustomQuery | None:
135
+ found = value_at_path(queries, name, _SEPARATOR)
136
+ return found if isinstance(found, CustomQuery) else None
137
+
138
+
139
+ def must_get_query(queries: Registry, name: str) -> CustomQuery:
140
+ query = get_query(queries, name)
141
+ if query is None:
142
+ raise Exception(f"Query not found: {name}")
143
+ return query
zero_server/schema.py ADDED
@@ -0,0 +1,142 @@
1
+ """The Zero schema, loaded from the JSON of the TypeScript `schema.ts`, and its name mapping.
2
+
3
+ The schema stays authored in TypeScript because the client needs it. Emit it with
4
+ `JSON.stringify(schema)` and load that here; this module has no schema DSL of its own.
5
+
6
+ Ported from rocicorp/mono `packages/zero-types/src/schema.ts`, `schema-value.ts`, `name-mapper.ts` and
7
+ `packages/zero-schema/src/name-mapper.ts` at zero/v1.10.0 (905d08516ba0caaf314cd28e93a5a47e8acdd482).
8
+ Apache-2.0, Copyright Rocicorp.
9
+ """
10
+
11
+ import json
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any, Literal
16
+
17
+ from pydantic import BaseModel, ConfigDict, Field
18
+
19
+ from zero_server.shared.js import js_stringify
20
+
21
+ type ValueType = Literal["string", "number", "boolean", "null", "json"]
22
+ type Cardinality = Literal["one", "many"]
23
+
24
+
25
+ class _SchemaModel(BaseModel):
26
+ model_config = ConfigDict(frozen=True, populate_by_name=True, extra="ignore")
27
+
28
+
29
+ class ColumnSchema(_SchemaModel):
30
+ type: ValueType
31
+ optional: bool = False
32
+ server_name: str | None = Field(default=None, alias="serverName")
33
+
34
+
35
+ class TableSchema(_SchemaModel):
36
+ name: str
37
+ server_name: str | None = Field(default=None, alias="serverName")
38
+ columns: dict[str, ColumnSchema]
39
+ primary_key: tuple[str, ...] = Field(alias="primaryKey", min_length=1)
40
+
41
+
42
+ class Connection(_SchemaModel):
43
+ source_field: tuple[str, ...] = Field(alias="sourceField")
44
+ dest_field: tuple[str, ...] = Field(alias="destField")
45
+ dest_schema: str = Field(alias="destSchema")
46
+ cardinality: Cardinality
47
+
48
+
49
+ type Relationship = tuple[Connection] | tuple[Connection, Connection]
50
+
51
+
52
+ class Schema(_SchemaModel):
53
+ tables: dict[str, TableSchema]
54
+ # Upstream only has entries for tables that declare relationships.
55
+ relationships: dict[str, dict[str, Relationship]] = Field(default_factory=dict)
56
+ enable_legacy_queries: bool | None = Field(default=None, alias="enableLegacyQueries")
57
+ enable_legacy_mutators: bool | None = Field(default=None, alias="enableLegacyMutators")
58
+
59
+
60
+ def load_schema(source: str | Path | Mapping[str, Any]) -> Schema:
61
+ """A schema from `JSON.stringify(schema)`: the JSON text, a path to it, or the parsed object."""
62
+ if isinstance(source, Path):
63
+ source = source.read_text()
64
+ if isinstance(source, str):
65
+ source = json.loads(source)
66
+ return Schema.model_validate(source)
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class DestNames:
71
+ table_name: str
72
+ columns: dict[str, str]
73
+ all_columns_same: bool
74
+
75
+
76
+ def _in_context(context: Any) -> str:
77
+ return f"in {js_stringify(context)}" if context else ""
78
+
79
+
80
+ class NameMapper:
81
+ def __init__(self, tables: dict[str, DestNames]) -> None:
82
+ self._tables = tables
83
+
84
+ def _table(self, src: str, context: Any = None) -> DestNames:
85
+ table = self._tables.get(src)
86
+ if table is None:
87
+ raise Exception(f'unknown table "{src}" {_in_context(context)}')
88
+ return table
89
+
90
+ def table_name(self, src: str, context: Any = None) -> str:
91
+ return self._table(src, context).table_name
92
+
93
+ def table_name_if_known(self, src: str) -> str | None:
94
+ table = self._tables.get(src)
95
+ return table.table_name if table is not None else None
96
+
97
+ def column_name(self, table: str, src: str, context: Any = None) -> str:
98
+ dest = self._table(table, context).columns.get(src)
99
+ if not dest:
100
+ raise Exception(f'unknown column "{src}" of "{table}" table {_in_context(context)}')
101
+ return dest
102
+
103
+ def row[V](self, table: str, row: Mapping[str, V]) -> dict[str, V]:
104
+ """Rename a row's columns. Columns the schema does not know pass through."""
105
+ dest = self._table(table)
106
+ if dest.all_columns_same:
107
+ return dict(row)
108
+ return {dest.columns.get(column, column): value for column, value in row.items()}
109
+
110
+ def columns(self, table: str, columns: tuple[str, ...]) -> tuple[str, ...]:
111
+ dest = self._table(table)
112
+ if dest.all_columns_same:
113
+ return columns
114
+ return tuple(dest.columns.get(column, column) for column in columns)
115
+
116
+
117
+ def client_to_server(tables: Mapping[str, TableSchema]) -> NameMapper:
118
+ return _mapper("client", tables)
119
+
120
+
121
+ def server_to_client(tables: Mapping[str, TableSchema]) -> NameMapper:
122
+ return _mapper("server", tables)
123
+
124
+
125
+ def _mapper(src: Literal["client", "server"], tables: Mapping[str, TableSchema]) -> NameMapper:
126
+ mapping: dict[str, DestNames] = {}
127
+ for table_name, table in tables.items():
128
+ all_same = True
129
+ names: dict[str, str] = {}
130
+ for name, column in table.columns.items():
131
+ if column.server_name and column.server_name != name:
132
+ all_same = False
133
+ server_column = column.server_name if column.server_name is not None else name
134
+ if src == "client":
135
+ names[name] = server_column
136
+ else:
137
+ names[server_column] = name
138
+ server_table = table.server_name if table.server_name is not None else table_name
139
+ key = table_name if src == "client" else server_table
140
+ dest = server_table if src == "client" else table_name
141
+ mapping[key] = DestNames(table_name=dest, columns=names, all_columns_same=all_same)
142
+ return NameMapper(mapping)
@@ -0,0 +1 @@
1
+ """The server SDK: what `@rocicorp/zero/server` gives a TypeScript backend."""
@@ -0,0 +1 @@
1
+ """One `ZQLDatabase` per supported Postgres driver, as upstream has one per JavaScript driver."""
@@ -0,0 +1,43 @@
1
+ """asyncpg, directly. Needs the `zero-server[asyncpg]` extra."""
2
+
3
+ from collections.abc import Awaitable, Callable, Sequence
4
+ from typing import Any
5
+
6
+ import asyncpg
7
+
8
+ from zero_server.schema import Schema
9
+ from zero_server.server.transaction import DBTransaction, Row
10
+ from zero_server.server.zql_database import PostgresError, ZQLDatabase
11
+
12
+
13
+ class AsyncpgTransaction:
14
+ def __init__(self, connection: asyncpg.Connection) -> None:
15
+ self._connection = connection
16
+
17
+ @property
18
+ def wrapped_transaction(self) -> asyncpg.Connection:
19
+ return self._connection
20
+
21
+ async def query(self, sql: str, params: Sequence[Any] = ()) -> list[Row]:
22
+ try:
23
+ records = await self._connection.fetch(sql, *params)
24
+ except asyncpg.PostgresError as error:
25
+ # asyncpg sets `message` on its error classes at runtime: the primary message alone.
26
+ raise PostgresError(getattr(error, "message", str(error))) from error
27
+ return [dict(record) for record in records]
28
+
29
+
30
+ class AsyncpgConnection:
31
+ def __init__(self, pool: asyncpg.Pool) -> None:
32
+ self.pool = pool
33
+
34
+ async def transaction[R](self, fn: Callable[[DBTransaction[asyncpg.Connection]], Awaitable[R]]) -> R:
35
+ async with self.pool.acquire() as connection, connection.transaction():
36
+ return await fn(AsyncpgTransaction(connection))
37
+
38
+
39
+ class AsyncpgDatabase(ZQLDatabase[asyncpg.Connection]):
40
+ """A `ZQLDatabase` on an asyncpg pool (`await asyncpg.create_pool(dsn)`)."""
41
+
42
+ def __init__(self, pool: asyncpg.Pool, schema: Schema | None = None) -> None:
43
+ super().__init__(AsyncpgConnection(pool), schema)
@@ -0,0 +1,55 @@
1
+ """psycopg 3, async. Needs the `zero-server[psycopg]` extra.
2
+
3
+ Statements run through a raw cursor, which takes Postgres's own `$1` placeholders, so the SQL is the
4
+ same text the other adapters send.
5
+ """
6
+
7
+ from collections.abc import Awaitable, Callable, Sequence
8
+ from typing import Any
9
+
10
+ import psycopg
11
+ from psycopg.rows import dict_row
12
+ from psycopg_pool import AsyncConnectionPool
13
+
14
+ from zero_server.schema import Schema
15
+ from zero_server.server.transaction import DBTransaction, Row
16
+ from zero_server.server.zql_database import PostgresError, ZQLDatabase
17
+
18
+
19
+ class PsycopgTransaction:
20
+ def __init__(self, connection: psycopg.AsyncConnection[Any]) -> None:
21
+ self._connection = connection
22
+
23
+ @property
24
+ def wrapped_transaction(self) -> psycopg.AsyncConnection[Any]:
25
+ return self._connection
26
+
27
+ async def query(self, sql: str, params: Sequence[Any] = ()) -> list[Row]:
28
+ cursor = psycopg.AsyncRawCursor(self._connection, row_factory=dict_row)
29
+ try:
30
+ await cursor.execute(sql, list(params)) # ty: ignore[invalid-argument-type]
31
+ return await cursor.fetchall() if cursor.description is not None else []
32
+ except psycopg.errors.Error as error:
33
+ if error.diag.sqlstate is None and "NUL (0x00)" in str(error):
34
+ # psycopg refuses a NUL in a text value before sending it. Report what Postgres reports
35
+ # for the same value through the other drivers.
36
+ raise PostgresError('invalid byte sequence for encoding "UTF8": 0x00') from error
37
+ raise PostgresError(error.diag.message_primary or str(error)) from error
38
+ finally:
39
+ await cursor.close()
40
+
41
+
42
+ class PsycopgConnection:
43
+ def __init__(self, pool: AsyncConnectionPool) -> None:
44
+ self.pool = pool
45
+
46
+ async def transaction[R](self, fn: Callable[[DBTransaction[psycopg.AsyncConnection[Any]]], Awaitable[R]]) -> R:
47
+ async with self.pool.connection() as connection, connection.transaction():
48
+ return await fn(PsycopgTransaction(connection))
49
+
50
+
51
+ class PsycopgDatabase(ZQLDatabase[psycopg.AsyncConnection[Any]]):
52
+ """A `ZQLDatabase` on a psycopg pool (`AsyncConnectionPool(conninfo, open=False)`, then `await pool.open()`)."""
53
+
54
+ def __init__(self, pool: AsyncConnectionPool, schema: Schema | None = None) -> None:
55
+ super().__init__(PsycopgConnection(pool), schema)
@@ -0,0 +1,49 @@
1
+ """SQLAlchemy's asyncio engine, with the asyncpg driver.
2
+
3
+ Needs the `zero-server[sqlalchemy]` extra.
4
+ """
5
+
6
+ from collections.abc import Awaitable, Callable, Sequence
7
+ from typing import Any
8
+
9
+ from sqlalchemy.exc import DBAPIError
10
+ from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine
11
+
12
+ from zero_server.schema import Schema
13
+ from zero_server.server.transaction import DBTransaction, Row
14
+ from zero_server.server.zql_database import PostgresError, ZQLDatabase
15
+
16
+
17
+ class SQLAlchemyTransaction:
18
+ def __init__(self, connection: AsyncConnection) -> None:
19
+ self._connection = connection
20
+
21
+ @property
22
+ def wrapped_transaction(self) -> AsyncConnection:
23
+ return self._connection
24
+
25
+ async def query(self, sql: str, params: Sequence[Any] = ()) -> list[Row]:
26
+ try:
27
+ result = await self._connection.exec_driver_sql(sql, tuple(params))
28
+ except DBAPIError as error:
29
+ cause = error.orig.__cause__ if error.orig is not None and error.orig.__cause__ is not None else error.orig
30
+ # asyncpg's `message` is the primary message alone, which is all postgres.js reports.
31
+ message = getattr(cause, "message", None)
32
+ raise PostgresError(message if isinstance(message, str) else str(cause)) from error
33
+ return [dict(row) for row in result.mappings()] if result.returns_rows else []
34
+
35
+
36
+ class SQLAlchemyConnection:
37
+ def __init__(self, engine: AsyncEngine) -> None:
38
+ self.engine = engine
39
+
40
+ async def transaction[R](self, fn: Callable[[DBTransaction[AsyncConnection]], Awaitable[R]]) -> R:
41
+ async with self.engine.begin() as connection:
42
+ return await fn(SQLAlchemyTransaction(connection))
43
+
44
+
45
+ class SQLAlchemyDatabase(ZQLDatabase[AsyncConnection]):
46
+ """A `ZQLDatabase` on `engine`, which must use the asyncpg driver (`postgresql+asyncpg://`)."""
47
+
48
+ def __init__(self, engine: AsyncEngine, schema: Schema | None = None) -> None:
49
+ super().__init__(SQLAlchemyConnection(engine), schema)
@@ -0,0 +1,134 @@
1
+ """`tx.mutate.<table>.insert / upsert / update / delete` on the server, as SQL.
2
+
3
+ Ported from rocicorp/mono `packages/zero-server/src/custom.ts` (`makeServerTableCRUD`),
4
+ `packages/zql/src/mutate/crud.ts` and the column conversions of `packages/z2s/src/sql.ts` at zero/v1.10.0
5
+ (905d08516ba0caaf314cd28e93a5a47e8acdd482). Apache-2.0, Copyright Rocicorp.
6
+
7
+ Values are bound as `zero_server.z2s.sql` binds them.
8
+ """
9
+
10
+ from collections.abc import Mapping
11
+ from typing import Any, Generic
12
+
13
+ from typing_extensions import TypeVar
14
+
15
+ from zero_server.schema import Schema, TableSchema
16
+ from zero_server.server.server_schema import ServerSchema, ServerTableSchema
17
+ from zero_server.server.transaction import DBTransaction
18
+ from zero_server.shared.asserts import check
19
+ from zero_server.shared.js import js_keys
20
+ from zero_server.z2s.sql import Params, ident
21
+
22
+
23
+ def _as_row(value: object) -> dict[str, Any]:
24
+ """The row a value stands for, by `Object.entries`: upstream reads every row that way first."""
25
+ match value:
26
+ case None:
27
+ raise TypeError("Cannot convert undefined or null to object")
28
+ case Mapping():
29
+ return dict(value)
30
+ case str() | list() | tuple():
31
+ return {str(i): item for i, item in enumerate(value)}
32
+ case _:
33
+ return {}
34
+
35
+
36
+ def _server_name(table: TableSchema) -> str:
37
+ return table.server_name if table.server_name is not None else table.name
38
+
39
+
40
+ def _server_column(table: TableSchema, column: str) -> str:
41
+ schema = table.columns.get(column)
42
+ check(schema, f"Column {column} was not found in the Zero schema for the table {table.name}")
43
+ assert schema is not None
44
+ return schema.server_name if schema.server_name is not None else column
45
+
46
+
47
+ I = TypeVar("I", default=Mapping[str, Any]) # noqa: E741 - the insert row type
48
+ U = TypeVar("U", default=Mapping[str, Any])
49
+ K = TypeVar("K", default=Mapping[str, Any])
50
+
51
+
52
+ class TableCRUD(Generic[I, U, K]):
53
+ """Writes to one table. Generated schema types narrow the row types: `I` to insert or upsert, `U` to
54
+ update (the key and any other columns), `K` to delete (the key)."""
55
+
56
+ def __init__(self, table: TableSchema, tx: DBTransaction[Any], server_table: ServerTableSchema) -> None:
57
+ self._table = table
58
+ self._tx = tx
59
+ self._server_table = server_table
60
+
61
+ def _pk_clause(self, params: Params, row: Mapping[str, Any]) -> str:
62
+ return " AND ".join(
63
+ f"{ident(server)} = {params.column(self._server_table[server], row.get(name), comparison=True)}"
64
+ for name, server in ((n, _server_column(self._table, n)) for n in self._table.primary_key)
65
+ )
66
+
67
+ def _non_pk(self, value: dict[str, Any]) -> list[str]:
68
+ return [k for k in js_keys(value) if k not in self._table.primary_key]
69
+
70
+ async def insert(self, value: I) -> None:
71
+ await self._insert(_as_row(value), upsert=False)
72
+
73
+ async def upsert(self, value: I) -> None:
74
+ await self._insert(_as_row(value), upsert=True)
75
+
76
+ async def _insert(self, value: dict[str, Any], *, upsert: bool) -> None:
77
+ keys = js_keys(value)
78
+ targeted = [_server_column(self._table, k) for k in keys]
79
+ pk = [_server_column(self._table, k) for k in self._table.primary_key]
80
+ params = Params()
81
+ values = ", ".join(
82
+ params.column(self._server_table[server], value[k], comparison=False)
83
+ for k, server in zip(keys, targeted, strict=True)
84
+ )
85
+ action = "DO NOTHING"
86
+ updated = self._non_pk(value)
87
+ if upsert and updated:
88
+ sets = ", ".join(
89
+ f"{ident(server)} = {params.column(self._server_table[server], value[k], comparison=False)}"
90
+ for k, server in ((k, _server_column(self._table, k)) for k in updated)
91
+ )
92
+ action = f"DO UPDATE SET {sets}"
93
+ sql = (
94
+ f"INSERT INTO {ident(_server_name(self._table))} ({','.join(ident(c) for c in targeted)}) "
95
+ f"VALUES ({values}) ON CONFLICT ({', '.join(ident(c) for c in pk)}) {action}"
96
+ )
97
+ await self._tx.query(sql, params.values)
98
+
99
+ async def update(self, value: U) -> None:
100
+ row = _as_row(value)
101
+ targeted = [(k, _server_column(self._table, k)) for k in self._non_pk(row)]
102
+ if not targeted:
103
+ return
104
+ params = Params()
105
+ sets = ", ".join(
106
+ f"{ident(server)} = {params.column(self._server_table[server], row[k], comparison=False)}"
107
+ for k, server in targeted
108
+ )
109
+ where = self._pk_clause(params, row)
110
+ await self._tx.query(f"UPDATE {ident(_server_name(self._table))} SET {sets} WHERE {where}", params.values)
111
+
112
+ async def delete(self, value: K) -> None:
113
+ params = Params()
114
+ where = self._pk_clause(params, _as_row(value))
115
+ await self._tx.query(f"DELETE FROM {ident(_server_name(self._table))} WHERE {where}", params.values)
116
+
117
+
118
+ class Mutate:
119
+ """`tx.mutate.issue` (or `tx.mutate["issue"]`) is the CRUD for the `issue` table, in client names."""
120
+
121
+ def __init__(self, schema: Schema, tx: DBTransaction[Any], server_schema: ServerSchema) -> None:
122
+ self._schema = schema
123
+ self._tx = tx
124
+ self._server_schema = server_schema
125
+
126
+ def __getitem__(self, table: str) -> TableCRUD[Any, Any, Any]:
127
+ table_schema = self._schema.tables[table]
128
+ return TableCRUD(table_schema, self._tx, self._server_schema[_server_name(table_schema)])
129
+
130
+ def __getattr__(self, table: str) -> TableCRUD[Any, Any, Any]:
131
+ try:
132
+ return self[table]
133
+ except KeyError:
134
+ raise AttributeError(table) from None
@@ -0,0 +1,70 @@
1
+ """Run a ZQL query against Postgres and return what the client-side query would have.
2
+
3
+ Ported from rocicorp/mono `packages/zero-server/src/pg-query-executor.ts` and `extractZqlResult` in
4
+ `packages/z2s/src/compiler.ts` at zero/v1.10.0 (905d08516ba0caaf314cd28e93a5a47e8acdd482).
5
+ Apache-2.0, Copyright Rocicorp.
6
+ """
7
+
8
+ import json
9
+ from typing import Any
10
+
11
+ from zero_server.protocol.ast import AST
12
+ from zero_server.query.builder import Format
13
+ from zero_server.schema import Schema
14
+ from zero_server.server.server_schema import ServerSchema
15
+ from zero_server.server.transaction import DBTransaction, Row
16
+ from zero_server.shared.js import js_keys
17
+ from zero_server.z2s.compiler import ZQL_RESULT_KEY, compile_query
18
+
19
+ _MAX_SAFE = 2**53 - 1
20
+
21
+
22
+ class _Unsafe(int):
23
+ """An integer JavaScript cannot hold exactly. Upstream parses it as a bigint and refuses it."""
24
+
25
+
26
+ def _parse_int(text: str) -> int:
27
+ value = int(text)
28
+ return _Unsafe(value) if abs(value) > _MAX_SAFE else value
29
+
30
+
31
+ def _parse_float(text: str) -> int | float:
32
+ """A whole number written with a fraction, as `numeric` epoch milliseconds are, comes back an `int`.
33
+
34
+ JavaScript has one number type, so upstream cannot tell `1.0` from `1`; Python callers can.
35
+ """
36
+ value = float(text)
37
+ return int(value) if value.is_integer() and abs(value) <= _MAX_SAFE else value
38
+
39
+
40
+ def _path_to_unsafe(value: Any) -> str | None:
41
+ match value:
42
+ case _Unsafe():
43
+ return f" = {int(value)}"
44
+ case list():
45
+ for i, item in enumerate(value):
46
+ if (path := _path_to_unsafe(item)) is not None:
47
+ return f"[{i}]{path}"
48
+ case dict():
49
+ for key in js_keys(value):
50
+ if (path := _path_to_unsafe(value[key])) is not None:
51
+ return f"['{key}']{path}"
52
+ return None
53
+
54
+
55
+ def extract_zql_result(rows: list[Row]) -> Any:
56
+ result = json.loads(rows[0][ZQL_RESULT_KEY], parse_int=_parse_int, parse_float=_parse_float)
57
+ if (path := _path_to_unsafe(result)) is not None:
58
+ raise Exception(f"Value exceeds safe Number range. {path}")
59
+ return result
60
+
61
+
62
+ async def execute_postgres_query(
63
+ tx: DBTransaction[Any], ast: AST, format: Format, schema: Schema, server_schema: ServerSchema
64
+ ) -> Any:
65
+ """The query's rows, as lists and dicts in client names. A singular query with no row returns `None`."""
66
+ text, values = compile_query(server_schema, schema, ast, format)
67
+ rows = await tx.query(text, values)
68
+ if not rows and format.singular:
69
+ return None
70
+ return extract_zql_result(rows)