mizzle 0.1.0__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.
- mizzle/__init__.py +124 -0
- mizzle/__main__.py +5 -0
- mizzle/core/__init__.py +0 -0
- mizzle/core/compiler.py +365 -0
- mizzle/core/errors.py +78 -0
- mizzle/core/naming.py +13 -0
- mizzle/core/query/__init__.py +0 -0
- mizzle/core/query/base.py +82 -0
- mizzle/core/query/builder.py +205 -0
- mizzle/core/query/insert.py +131 -0
- mizzle/core/query/relational.py +271 -0
- mizzle/core/query/returning.py +22 -0
- mizzle/core/query/select.py +214 -0
- mizzle/core/query/update.py +78 -0
- mizzle/core/schema/__init__.py +0 -0
- mizzle/core/schema/column.py +231 -0
- mizzle/core/schema/constraints.py +85 -0
- mizzle/core/schema/relations.py +191 -0
- mizzle/core/schema/table.py +144 -0
- mizzle/core/schema/types.py +61 -0
- mizzle/core/snapshot.py +98 -0
- mizzle/core/sql/__init__.py +0 -0
- mizzle/core/sql/nodes.py +158 -0
- mizzle/core/sql/operators.py +213 -0
- mizzle/core/sql/raw.py +83 -0
- mizzle/core/steps.py +283 -0
- mizzle/db.py +237 -0
- mizzle/dialects/__init__.py +0 -0
- mizzle/dialects/base.py +62 -0
- mizzle/dialects/postgres/__init__.py +87 -0
- mizzle/dialects/postgres/compiler.py +128 -0
- mizzle/dialects/postgres/ddl.py +280 -0
- mizzle/dialects/postgres/driver.py +166 -0
- mizzle/dialects/postgres/introspect.py +209 -0
- mizzle/dialects/postgres/types.py +117 -0
- mizzle/kit/__init__.py +27 -0
- mizzle/kit/cli.py +424 -0
- mizzle/kit/codegen.py +309 -0
- mizzle/kit/config.py +182 -0
- mizzle/kit/diff.py +385 -0
- mizzle/kit/init.py +105 -0
- mizzle/kit/journal.py +183 -0
- mizzle/kit/migrate.py +96 -0
- mizzle/kit/snapshot.py +156 -0
- mizzle/pg.py +57 -0
- mizzle/py.typed +0 -0
- mizzle/pydantic.py +158 -0
- mizzle/studio/__init__.py +0 -0
- mizzle/studio/assets.py +276 -0
- mizzle/studio/server.py +458 -0
- mizzle/studio/ui.json +5 -0
- mizzle-0.1.0.dist-info/METADATA +175 -0
- mizzle-0.1.0.dist-info/RECORD +56 -0
- mizzle-0.1.0.dist-info/WHEEL +4 -0
- mizzle-0.1.0.dist-info/entry_points.txt +2 -0
- mizzle-0.1.0.dist-info/licenses/LICENSE +21 -0
mizzle/__init__.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""mizzle: a fully typed async ORM for PostgreSQL."""
|
|
2
|
+
|
|
3
|
+
from mizzle.core.errors import (
|
|
4
|
+
CheckViolation,
|
|
5
|
+
ConnectionError,
|
|
6
|
+
DatabaseError,
|
|
7
|
+
DataLossBlocked,
|
|
8
|
+
ForeignKeyViolation,
|
|
9
|
+
MigrationError,
|
|
10
|
+
MigrationLockTimeout,
|
|
11
|
+
MigrationTampered,
|
|
12
|
+
MizzleError,
|
|
13
|
+
NotNullViolation,
|
|
14
|
+
QueryError,
|
|
15
|
+
RelationNotLoaded,
|
|
16
|
+
SchemaError,
|
|
17
|
+
SerializationFailure,
|
|
18
|
+
UniqueViolation,
|
|
19
|
+
)
|
|
20
|
+
from mizzle.core.schema.column import Column
|
|
21
|
+
from mizzle.core.schema.constraints import (
|
|
22
|
+
check,
|
|
23
|
+
foreign_key,
|
|
24
|
+
index,
|
|
25
|
+
primary_key,
|
|
26
|
+
unique,
|
|
27
|
+
unique_index,
|
|
28
|
+
)
|
|
29
|
+
from mizzle.core.schema.relations import many, one
|
|
30
|
+
from mizzle.core.schema.table import Table
|
|
31
|
+
from mizzle.core.sql.operators import (
|
|
32
|
+
and_,
|
|
33
|
+
asc,
|
|
34
|
+
avg,
|
|
35
|
+
between,
|
|
36
|
+
count,
|
|
37
|
+
desc,
|
|
38
|
+
eq,
|
|
39
|
+
excluded,
|
|
40
|
+
exists,
|
|
41
|
+
gt,
|
|
42
|
+
gte,
|
|
43
|
+
ilike,
|
|
44
|
+
in_,
|
|
45
|
+
is_not_null,
|
|
46
|
+
is_null,
|
|
47
|
+
like,
|
|
48
|
+
lt,
|
|
49
|
+
lte,
|
|
50
|
+
max_,
|
|
51
|
+
min_,
|
|
52
|
+
ne,
|
|
53
|
+
not_,
|
|
54
|
+
not_exists,
|
|
55
|
+
not_in,
|
|
56
|
+
nullable,
|
|
57
|
+
or_,
|
|
58
|
+
placeholder,
|
|
59
|
+
sum_,
|
|
60
|
+
)
|
|
61
|
+
from mizzle.core.sql.raw import sql
|
|
62
|
+
from mizzle.db import Database, QueryEvent, Session
|
|
63
|
+
|
|
64
|
+
__version__ = "0.1.0"
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
"CheckViolation",
|
|
68
|
+
"Column",
|
|
69
|
+
"ConnectionError",
|
|
70
|
+
"DataLossBlocked",
|
|
71
|
+
"Database",
|
|
72
|
+
"DatabaseError",
|
|
73
|
+
"ForeignKeyViolation",
|
|
74
|
+
"MigrationError",
|
|
75
|
+
"MigrationLockTimeout",
|
|
76
|
+
"MigrationTampered",
|
|
77
|
+
"MizzleError",
|
|
78
|
+
"NotNullViolation",
|
|
79
|
+
"QueryError",
|
|
80
|
+
"QueryEvent",
|
|
81
|
+
"RelationNotLoaded",
|
|
82
|
+
"SchemaError",
|
|
83
|
+
"SerializationFailure",
|
|
84
|
+
"Session",
|
|
85
|
+
"Table",
|
|
86
|
+
"UniqueViolation",
|
|
87
|
+
"and_",
|
|
88
|
+
"asc",
|
|
89
|
+
"avg",
|
|
90
|
+
"between",
|
|
91
|
+
"check",
|
|
92
|
+
"count",
|
|
93
|
+
"desc",
|
|
94
|
+
"eq",
|
|
95
|
+
"excluded",
|
|
96
|
+
"exists",
|
|
97
|
+
"foreign_key",
|
|
98
|
+
"gt",
|
|
99
|
+
"gte",
|
|
100
|
+
"ilike",
|
|
101
|
+
"in_",
|
|
102
|
+
"index",
|
|
103
|
+
"is_not_null",
|
|
104
|
+
"is_null",
|
|
105
|
+
"like",
|
|
106
|
+
"lt",
|
|
107
|
+
"lte",
|
|
108
|
+
"many",
|
|
109
|
+
"max_",
|
|
110
|
+
"min_",
|
|
111
|
+
"ne",
|
|
112
|
+
"not_",
|
|
113
|
+
"not_exists",
|
|
114
|
+
"not_in",
|
|
115
|
+
"nullable",
|
|
116
|
+
"one",
|
|
117
|
+
"or_",
|
|
118
|
+
"placeholder",
|
|
119
|
+
"primary_key",
|
|
120
|
+
"sql",
|
|
121
|
+
"sum_",
|
|
122
|
+
"unique",
|
|
123
|
+
"unique_index",
|
|
124
|
+
]
|
mizzle/__main__.py
ADDED
mizzle/core/__init__.py
ADDED
|
File without changes
|
mizzle/core/compiler.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Base SQL compiler. Walks a node tree and emits SQL text plus bind parameters.
|
|
2
|
+
|
|
3
|
+
Dialects subclass ``Compiler`` and override hooks (``param_marker``, ``returning_clause``...).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import datetime
|
|
9
|
+
import enum
|
|
10
|
+
import json
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Callable, Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from decimal import Decimal
|
|
15
|
+
from typing import TYPE_CHECKING, cast
|
|
16
|
+
|
|
17
|
+
from mizzle.core.errors import QueryError
|
|
18
|
+
from mizzle.core.naming import Casing
|
|
19
|
+
from mizzle.core.schema.column import MISSING, ColumnBase
|
|
20
|
+
from mizzle.core.sql.nodes import (
|
|
21
|
+
Between,
|
|
22
|
+
BinaryOp,
|
|
23
|
+
BoolOp,
|
|
24
|
+
Excluded,
|
|
25
|
+
Exists,
|
|
26
|
+
Func,
|
|
27
|
+
Identifier,
|
|
28
|
+
InList,
|
|
29
|
+
Node,
|
|
30
|
+
Not,
|
|
31
|
+
Nullable,
|
|
32
|
+
OrderBy,
|
|
33
|
+
Param,
|
|
34
|
+
Placeholder,
|
|
35
|
+
Postfix,
|
|
36
|
+
Raw,
|
|
37
|
+
Star,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from mizzle.core.query.insert import Insert, OnConflict
|
|
42
|
+
from mizzle.core.query.returning import Returning
|
|
43
|
+
from mizzle.core.query.select import CteColumn, SelectBase, Slot, Source
|
|
44
|
+
from mizzle.core.query.update import Delete, Update
|
|
45
|
+
from mizzle.core.schema.table import Table
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class CompiledQuery:
|
|
50
|
+
sql: str
|
|
51
|
+
params: tuple[object, ...]
|
|
52
|
+
placeholders: tuple[Placeholder | None, ...] # per param slot; set where a placeholder sits
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def has_placeholders(self) -> bool:
|
|
56
|
+
return any(p is not None for p in self.placeholders)
|
|
57
|
+
|
|
58
|
+
def bind(self, values: Mapping[str, object]) -> CompiledQuery:
|
|
59
|
+
"""Fill placeholder slots with ``values`` (by placeholder name)."""
|
|
60
|
+
names = {p.name for p in self.placeholders if p is not None}
|
|
61
|
+
if missing := names - values.keys():
|
|
62
|
+
raise QueryError(f"missing values for placeholders: {', '.join(sorted(missing))}")
|
|
63
|
+
if extra := values.keys() - names:
|
|
64
|
+
raise QueryError(f"unknown placeholders: {', '.join(sorted(extra))}")
|
|
65
|
+
params = tuple(
|
|
66
|
+
value
|
|
67
|
+
if p is None
|
|
68
|
+
else (p.column.encode(values[p.name]) if p.column else values[p.name])
|
|
69
|
+
for value, p in zip(self.params, self.placeholders, strict=True)
|
|
70
|
+
)
|
|
71
|
+
return CompiledQuery(self.sql, params, (None,) * len(params))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Compiler:
|
|
75
|
+
"""Single-use per ``compile()`` call: it accumulates params while walking the tree."""
|
|
76
|
+
|
|
77
|
+
casing: Casing | None
|
|
78
|
+
inline_literals: bool
|
|
79
|
+
qualify_columns: bool
|
|
80
|
+
_params: list[object]
|
|
81
|
+
_placeholders: list[Placeholder | None]
|
|
82
|
+
_scopes: list[dict[type[Table], str]] # table -> alias, for aliased (relational) queries
|
|
83
|
+
_alias_count: int
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
casing: Casing | None = None,
|
|
88
|
+
*,
|
|
89
|
+
inline_literals: bool = False,
|
|
90
|
+
qualify_columns: bool = True,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""``inline_literals`` and ``qualify_columns=False`` produce DDL-safe SQL (defaults,
|
|
93
|
+
CHECK constraints, index predicates), where bind parameters are not allowed."""
|
|
94
|
+
self.casing = casing
|
|
95
|
+
self.inline_literals = inline_literals
|
|
96
|
+
self.qualify_columns = qualify_columns
|
|
97
|
+
self._params = []
|
|
98
|
+
self._placeholders = []
|
|
99
|
+
self._scopes = []
|
|
100
|
+
self._alias_count = 0
|
|
101
|
+
|
|
102
|
+
def compile(self, node: Node) -> CompiledQuery:
|
|
103
|
+
self._params, self._placeholders = [], []
|
|
104
|
+
text = self.visit(node)
|
|
105
|
+
return CompiledQuery(text, tuple(self._params), tuple(self._placeholders))
|
|
106
|
+
|
|
107
|
+
def visit(self, node: Node) -> str:
|
|
108
|
+
# Walk the MRO so subclasses (e.g. jsonb -> Column) reuse their parent's visitor.
|
|
109
|
+
for cls in type(node).__mro__:
|
|
110
|
+
method = getattr(self, f"visit_{cls.__name__}", None)
|
|
111
|
+
if method is not None:
|
|
112
|
+
return cast(Callable[[Node], str], method)(node)
|
|
113
|
+
raise QueryError(f"{type(self).__name__} cannot compile {type(node).__name__}")
|
|
114
|
+
|
|
115
|
+
# -- hooks ------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def param_marker(self, _index: int, /) -> str:
|
|
118
|
+
return "?"
|
|
119
|
+
|
|
120
|
+
def quote(self, ident: str) -> str:
|
|
121
|
+
return '"' + ident.replace('"', '""') + '"'
|
|
122
|
+
|
|
123
|
+
def bind(self, value: object, placeholder: Placeholder | None = None) -> str:
|
|
124
|
+
if self.inline_literals:
|
|
125
|
+
if placeholder is not None:
|
|
126
|
+
raise QueryError("placeholders cannot be used in DDL")
|
|
127
|
+
return self.literal(value)
|
|
128
|
+
self._params.append(value)
|
|
129
|
+
self._placeholders.append(placeholder)
|
|
130
|
+
return self.param_marker(len(self._params))
|
|
131
|
+
|
|
132
|
+
def literal(self, value: object) -> str:
|
|
133
|
+
"""An SQL literal. Only for DDL; queries always use bind parameters."""
|
|
134
|
+
match value:
|
|
135
|
+
case None:
|
|
136
|
+
return "NULL"
|
|
137
|
+
case bool():
|
|
138
|
+
return "true" if value else "false"
|
|
139
|
+
case enum.Enum():
|
|
140
|
+
member_value: object = value.value # pyright: ignore[reportAny] # Enum.value is Any in typeshed
|
|
141
|
+
return self.literal(member_value)
|
|
142
|
+
case int() | float() | Decimal():
|
|
143
|
+
return str(value)
|
|
144
|
+
case str():
|
|
145
|
+
return "'" + value.replace("'", "''") + "'"
|
|
146
|
+
case datetime.date() | datetime.time() | uuid.UUID():
|
|
147
|
+
return self.literal(str(value))
|
|
148
|
+
case dict() | list():
|
|
149
|
+
return self.literal(json.dumps(value))
|
|
150
|
+
case _:
|
|
151
|
+
raise QueryError(f"cannot write {type(value).__name__} as an SQL literal")
|
|
152
|
+
|
|
153
|
+
def new_alias(self) -> str:
|
|
154
|
+
self._alias_count += 1
|
|
155
|
+
return f"t{self._alias_count}"
|
|
156
|
+
|
|
157
|
+
def table_name(self, table: type[Table]) -> str:
|
|
158
|
+
info = table.__table__
|
|
159
|
+
name = self.quote(info.name)
|
|
160
|
+
return f"{self.quote(info.schema)}.{name}" if info.schema else name
|
|
161
|
+
|
|
162
|
+
def column_name(self, column: ColumnBase) -> str:
|
|
163
|
+
return self.quote(column.db_name(self.casing))
|
|
164
|
+
|
|
165
|
+
def operand(self, node: Node) -> str:
|
|
166
|
+
"""An expression used inside a larger one; boolean groups get parentheses."""
|
|
167
|
+
text = self.visit(node)
|
|
168
|
+
return f"({text})" if isinstance(node, BoolOp | Not) else text
|
|
169
|
+
|
|
170
|
+
def value(self, column: ColumnBase, value: object) -> str:
|
|
171
|
+
"""A value written into a column (INSERT / UPDATE SET)."""
|
|
172
|
+
return self.visit(value) if isinstance(value, Node) else self.bind(column.encode(value))
|
|
173
|
+
|
|
174
|
+
# -- expressions ------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def visit_Column(self, node: ColumnBase) -> str:
|
|
177
|
+
if node.table is None:
|
|
178
|
+
raise QueryError(f"column {node.attr!r} is not attached to a table")
|
|
179
|
+
if not self.qualify_columns:
|
|
180
|
+
return self.column_name(node)
|
|
181
|
+
for scope in reversed(self._scopes):
|
|
182
|
+
if node.table in scope:
|
|
183
|
+
return f"{self.quote(scope[node.table])}.{self.column_name(node)}"
|
|
184
|
+
return f"{self.quote(node.table.__table__.name)}.{self.column_name(node)}"
|
|
185
|
+
|
|
186
|
+
def visit_Param(self, node: Param) -> str:
|
|
187
|
+
value = node.column.encode(node.value) if node.column is not None else node.value
|
|
188
|
+
return self.bind(value)
|
|
189
|
+
|
|
190
|
+
def visit_Placeholder(self, node: Placeholder) -> str:
|
|
191
|
+
return self.bind(None, node)
|
|
192
|
+
|
|
193
|
+
def visit_BinaryOp(self, node: BinaryOp) -> str:
|
|
194
|
+
return f"{self.operand(node.left)} {node.op} {self.operand(node.right)}"
|
|
195
|
+
|
|
196
|
+
def visit_BoolOp(self, node: BoolOp) -> str:
|
|
197
|
+
return f" {node.op} ".join(self.operand(item) for item in node.items)
|
|
198
|
+
|
|
199
|
+
def visit_Not(self, node: Not) -> str:
|
|
200
|
+
return f"NOT ({self.visit(node.item)})"
|
|
201
|
+
|
|
202
|
+
def visit_Postfix(self, node: Postfix) -> str:
|
|
203
|
+
return f"{self.operand(node.item)} {node.op}"
|
|
204
|
+
|
|
205
|
+
def visit_InList(self, node: InList) -> str:
|
|
206
|
+
if isinstance(node.values, tuple):
|
|
207
|
+
if not node.values: # IN () is a syntax error; the meaning is "matches nothing"
|
|
208
|
+
return "true" if node.negate else "false"
|
|
209
|
+
inner = ", ".join(self.visit(v) for v in node.values)
|
|
210
|
+
else:
|
|
211
|
+
inner = self.visit(node.values)
|
|
212
|
+
op = "NOT IN" if node.negate else "IN"
|
|
213
|
+
return f"{self.operand(node.item)} {op} ({inner})"
|
|
214
|
+
|
|
215
|
+
def visit_Between(self, node: Between) -> str:
|
|
216
|
+
item, low, high = self.operand(node.item), self.operand(node.low), self.operand(node.high)
|
|
217
|
+
return f"{item} BETWEEN {low} AND {high}"
|
|
218
|
+
|
|
219
|
+
def visit_Exists(self, node: Exists) -> str:
|
|
220
|
+
return f"{'NOT ' if node.negate else ''}EXISTS ({self.visit(node.query)})"
|
|
221
|
+
|
|
222
|
+
def visit_Func(self, node: Func) -> str:
|
|
223
|
+
args = ", ".join(self.visit(a) for a in node.args)
|
|
224
|
+
return f"{node.name}({'DISTINCT ' if node.distinct else ''}{args})"
|
|
225
|
+
|
|
226
|
+
def visit_Star(self, _node: Star) -> str:
|
|
227
|
+
return "*"
|
|
228
|
+
|
|
229
|
+
def visit_OrderBy(self, node: OrderBy) -> str:
|
|
230
|
+
text = f"{self.visit(node.expr)} {'DESC' if node.descending else 'ASC'}"
|
|
231
|
+
return f"{text} NULLS {node.nulls.upper()}" if node.nulls else text
|
|
232
|
+
|
|
233
|
+
def visit_Raw(self, node: Raw) -> str:
|
|
234
|
+
return "".join(p if isinstance(p, str) else self.visit(p) for p in node.parts)
|
|
235
|
+
|
|
236
|
+
def visit_Nullable(self, node: Nullable) -> str:
|
|
237
|
+
return self.visit(node.expr)
|
|
238
|
+
|
|
239
|
+
def visit_Identifier(self, node: Identifier) -> str:
|
|
240
|
+
return self.quote(node.name)
|
|
241
|
+
|
|
242
|
+
def visit_Excluded(self, node: Excluded) -> str:
|
|
243
|
+
return f"excluded.{self.column_name(node.column)}"
|
|
244
|
+
|
|
245
|
+
# -- SELECT -----------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
def source(self, source: Source) -> str:
|
|
248
|
+
if isinstance(source, type):
|
|
249
|
+
return self.table_name(source)
|
|
250
|
+
return self.quote(source.name)
|
|
251
|
+
|
|
252
|
+
def select_slot(self, slot: Slot) -> str:
|
|
253
|
+
if isinstance(slot, type):
|
|
254
|
+
return ", ".join(self.visit(c) for c in slot.__table__.columns.values())
|
|
255
|
+
return self.visit(slot)
|
|
256
|
+
|
|
257
|
+
def count_value(self, value: int | Placeholder) -> str:
|
|
258
|
+
return self.visit(value) if isinstance(value, Placeholder) else self.bind(value)
|
|
259
|
+
|
|
260
|
+
def visit_CteColumn(self, node: CteColumn) -> str:
|
|
261
|
+
return f"{self.quote(node.cte.name)}.{self.quote(node.name)}"
|
|
262
|
+
|
|
263
|
+
def visit_SelectBase(self, node: SelectBase) -> str:
|
|
264
|
+
# Clauses are emitted strictly left to right so $n numbering follows the text.
|
|
265
|
+
parts: list[str] = []
|
|
266
|
+
if node.ctes:
|
|
267
|
+
ctes = ", ".join(f"{self.quote(c.name)} AS ({self.visit(c.query)})" for c in node.ctes)
|
|
268
|
+
parts.append(f"WITH {ctes}")
|
|
269
|
+
parts.append("SELECT DISTINCT" if node.distinct_ else "SELECT")
|
|
270
|
+
if isinstance(node.projection, dict):
|
|
271
|
+
items = [f"{self.visit(e)} AS {self.quote(k)}" for k, e in node.projection.items()]
|
|
272
|
+
else:
|
|
273
|
+
items = [self.select_slot(slot) for slot in node.slots()]
|
|
274
|
+
parts.append(", ".join(items))
|
|
275
|
+
parts.append(f"FROM {self.source(node.source)}")
|
|
276
|
+
for join in node.joins:
|
|
277
|
+
parts.append(f"{join.kind} JOIN {self.source(join.source)} ON {self.visit(join.on)}")
|
|
278
|
+
if node.where_ is not None:
|
|
279
|
+
parts.append(f"WHERE {self.visit(node.where_)}")
|
|
280
|
+
if node.group_by_:
|
|
281
|
+
parts.append("GROUP BY " + ", ".join(self.visit(e) for e in node.group_by_))
|
|
282
|
+
if node.having_ is not None:
|
|
283
|
+
parts.append(f"HAVING {self.visit(node.having_)}")
|
|
284
|
+
if node.order_by_:
|
|
285
|
+
parts.append("ORDER BY " + ", ".join(self.visit(e) for e in node.order_by_))
|
|
286
|
+
if node.limit_ is not None:
|
|
287
|
+
parts.append(f"LIMIT {self.count_value(node.limit_)}")
|
|
288
|
+
if node.offset_ is not None:
|
|
289
|
+
parts.append(f"OFFSET {self.count_value(node.offset_)}")
|
|
290
|
+
if node.lock is not None:
|
|
291
|
+
lock = "FOR UPDATE"
|
|
292
|
+
if node.lock.of:
|
|
293
|
+
lock += " OF " + ", ".join(self.quote(t.__table__.name) for t in node.lock.of)
|
|
294
|
+
if node.lock.skip_locked:
|
|
295
|
+
lock += " SKIP LOCKED"
|
|
296
|
+
if node.lock.nowait:
|
|
297
|
+
lock += " NOWAIT"
|
|
298
|
+
parts.append(lock)
|
|
299
|
+
return " ".join(parts)
|
|
300
|
+
|
|
301
|
+
# -- INSERT / UPDATE / DELETE -----------------------------------------------------
|
|
302
|
+
|
|
303
|
+
def returning_clause(self, _table: type[Table], /) -> str:
|
|
304
|
+
raise QueryError(f"{type(self).__name__} does not support RETURNING")
|
|
305
|
+
|
|
306
|
+
def on_conflict_clause(self, _table: type[Table], _conflict: OnConflict, /) -> str:
|
|
307
|
+
raise QueryError(f"{type(self).__name__} does not support ON CONFLICT")
|
|
308
|
+
|
|
309
|
+
def set_list(self, table: type[Table], values: Mapping[str, object]) -> str:
|
|
310
|
+
columns = table.__table__.columns
|
|
311
|
+
return ", ".join(
|
|
312
|
+
f"{self.column_name(columns[k])} = {self.value(columns[k], v)}"
|
|
313
|
+
for k, v in values.items()
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def visit_Insert(self, node: Insert[Table]) -> str:
|
|
317
|
+
if not node.rows:
|
|
318
|
+
raise QueryError(f"insert into {node.table.__name__} has no values()")
|
|
319
|
+
columns = node.table.__table__.columns
|
|
320
|
+
used = [attr for attr in columns if any(attr in row for row in node.rows)]
|
|
321
|
+
head = f"INSERT INTO {self.table_name(node.table)}"
|
|
322
|
+
if not used and len(node.rows) == 1:
|
|
323
|
+
text = f"{head} DEFAULT VALUES"
|
|
324
|
+
else:
|
|
325
|
+
used = used or [next(iter(columns))] # several all-default rows: VALUES (DEFAULT)
|
|
326
|
+
names = ", ".join(self.column_name(columns[a]) for a in used)
|
|
327
|
+
rows = ", ".join(
|
|
328
|
+
"("
|
|
329
|
+
+ ", ".join(self._insert_value(columns[a], row.get(a, MISSING)) for a in used)
|
|
330
|
+
+ ")"
|
|
331
|
+
for row in node.rows
|
|
332
|
+
)
|
|
333
|
+
text = f"{head} ({names}) VALUES {rows}"
|
|
334
|
+
if node.conflict is not None:
|
|
335
|
+
text += " " + self.on_conflict_clause(node.table, node.conflict)
|
|
336
|
+
return text
|
|
337
|
+
|
|
338
|
+
def _insert_value(self, column: ColumnBase, value: object) -> str:
|
|
339
|
+
return "DEFAULT" if value is MISSING else self.value(column, value)
|
|
340
|
+
|
|
341
|
+
def _require_filter(self, node: Update[Table] | Delete[Table], verb: str) -> None:
|
|
342
|
+
if node.where_ is None and not node.all_rows_:
|
|
343
|
+
raise QueryError(
|
|
344
|
+
f"{verb} on {node.table.__name__} has no .where(); "
|
|
345
|
+
"call .all_rows() to affect every row"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
def visit_Update(self, node: Update[Table]) -> str:
|
|
349
|
+
if not node.values_:
|
|
350
|
+
raise QueryError(f"update({node.table.__name__}) needs .set(...)")
|
|
351
|
+
self._require_filter(node, "update")
|
|
352
|
+
text = f"UPDATE {self.table_name(node.table)} SET {self.set_list(node.table, node.values_)}"
|
|
353
|
+
if node.where_ is not None:
|
|
354
|
+
text += f" WHERE {self.visit(node.where_)}"
|
|
355
|
+
return text
|
|
356
|
+
|
|
357
|
+
def visit_Delete(self, node: Delete[Table]) -> str:
|
|
358
|
+
self._require_filter(node, "delete")
|
|
359
|
+
text = f"DELETE FROM {self.table_name(node.table)}"
|
|
360
|
+
if node.where_ is not None:
|
|
361
|
+
text += f" WHERE {self.visit(node.where_)}"
|
|
362
|
+
return text
|
|
363
|
+
|
|
364
|
+
def visit_Returning(self, node: Returning[Table]) -> str:
|
|
365
|
+
return f"{self.visit(node.query)} {self.returning_clause(node.table)}"
|
mizzle/core/errors.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Every error mizzle raises. Catch ``MizzleError`` to catch them all."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MizzleError(Exception):
|
|
7
|
+
"""Base class of all mizzle errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SchemaError(MizzleError):
|
|
11
|
+
"""A table definition is invalid."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class QueryError(MizzleError):
|
|
15
|
+
"""A query cannot be built or compiled."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RelationNotLoaded(MizzleError):
|
|
19
|
+
"""A relation was accessed that the query did not load."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DatabaseError(MizzleError):
|
|
23
|
+
"""The database rejected a statement. Messages never include parameter values."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
message: str,
|
|
28
|
+
*,
|
|
29
|
+
sql: str | None = None,
|
|
30
|
+
original: BaseException | None = None,
|
|
31
|
+
constraint: str | None = None,
|
|
32
|
+
column: str | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
super().__init__(message if sql is None else f"{message}\nSQL: {sql}")
|
|
35
|
+
self.sql: str | None = sql
|
|
36
|
+
self.original: BaseException | None = original
|
|
37
|
+
self.constraint: str | None = constraint
|
|
38
|
+
self.column: str | None = column
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class UniqueViolation(DatabaseError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ForeignKeyViolation(DatabaseError):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NotNullViolation(DatabaseError):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CheckViolation(DatabaseError):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SerializationFailure(DatabaseError):
|
|
58
|
+
"""The transaction conflicted with another one. Safe to retry."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ConnectionError(DatabaseError):
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class MigrationError(MizzleError):
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class MigrationLockTimeout(MigrationError):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class MigrationTampered(MigrationError):
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class DataLossBlocked(MigrationError):
|
|
78
|
+
pass
|
mizzle/core/naming.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
type Casing = Literal["snake_case"]
|
|
7
|
+
|
|
8
|
+
_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def snake_case(name: str) -> str:
|
|
12
|
+
"""``BlogPost`` -> ``blog_post``, ``HTTPLog`` -> ``http_log``, ``userId`` -> ``user_id``."""
|
|
13
|
+
return _BOUNDARY.sub("_", name).lower()
|
|
File without changes
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Shared machinery for executable statements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Generator
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from mizzle.core.compiler import CompiledQuery
|
|
11
|
+
from mizzle.core.errors import QueryError
|
|
12
|
+
from mizzle.core.schema.table import Table
|
|
13
|
+
from mizzle.core.sql.nodes import Node
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Row(Protocol):
|
|
17
|
+
"""A result row. Only positional access is needed (asyncpg Record, tuple...)."""
|
|
18
|
+
|
|
19
|
+
def __getitem__(self, index: int, /) -> object: ...
|
|
20
|
+
def __len__(self) -> int: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Runner(Protocol):
|
|
24
|
+
"""What a statement needs to run: a compiler and a connection. ``Session`` implements it."""
|
|
25
|
+
|
|
26
|
+
def compile(self, node: Node) -> CompiledQuery: ...
|
|
27
|
+
async def fetch(self, query: CompiledQuery) -> list[Row]: ...
|
|
28
|
+
async def run(self, query: CompiledQuery) -> int: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, eq=False)
|
|
32
|
+
class Executable[R](Node, ABC):
|
|
33
|
+
"""A statement that can be awaited, compiled with ``to_sql()``, or prepared."""
|
|
34
|
+
|
|
35
|
+
runner: Runner = field(repr=False)
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
async def run_compiled(self, compiled: CompiledQuery) -> R: ...
|
|
39
|
+
|
|
40
|
+
def to_sql(self) -> tuple[str, tuple[object, ...]]:
|
|
41
|
+
compiled = self.runner.compile(self)
|
|
42
|
+
return compiled.sql, compiled.params
|
|
43
|
+
|
|
44
|
+
def prepare(self) -> Prepared[R]:
|
|
45
|
+
"""Compile once; run many times with ``await prepared.execute(name=value)``."""
|
|
46
|
+
return Prepared(self, self.runner.compile(self))
|
|
47
|
+
|
|
48
|
+
async def execute(self) -> R:
|
|
49
|
+
compiled = self.runner.compile(self)
|
|
50
|
+
if compiled.has_placeholders:
|
|
51
|
+
raise QueryError(
|
|
52
|
+
"query uses placeholder(); call .prepare() and pass values to execute()"
|
|
53
|
+
)
|
|
54
|
+
return await self.run_compiled(compiled)
|
|
55
|
+
|
|
56
|
+
def __await__(self) -> Generator[object, None, R]:
|
|
57
|
+
return self.execute().__await__()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class Prepared[R]:
|
|
62
|
+
query: Executable[R]
|
|
63
|
+
compiled: CompiledQuery
|
|
64
|
+
|
|
65
|
+
async def execute(self, **values: object) -> R:
|
|
66
|
+
return await self.query.run_compiled(self.compiled.bind(values))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def decode_table[T: Table](table: type[T], row: Row, start: int) -> T | None:
|
|
70
|
+
"""Decode ``table``'s columns from ``row`` starting at ``start``.
|
|
71
|
+
|
|
72
|
+
Returns None when every column is NULL, which is how an outer join reports "no match".
|
|
73
|
+
"""
|
|
74
|
+
# ponytail: a real row whose columns are all NULL (only possible in a table with no
|
|
75
|
+
# NOT NULL column) is indistinguishable from "no match" and also decodes to None.
|
|
76
|
+
columns = table.__table__.columns
|
|
77
|
+
raw = [row[start + i] for i in range(len(columns))]
|
|
78
|
+
if all(v is None for v in raw):
|
|
79
|
+
return None
|
|
80
|
+
return table.from_db(
|
|
81
|
+
{attr: col.decode(v) for (attr, col), v in zip(columns.items(), raw, strict=True)}
|
|
82
|
+
)
|