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,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)
|