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.
Files changed (56) hide show
  1. mizzle/__init__.py +124 -0
  2. mizzle/__main__.py +5 -0
  3. mizzle/core/__init__.py +0 -0
  4. mizzle/core/compiler.py +365 -0
  5. mizzle/core/errors.py +78 -0
  6. mizzle/core/naming.py +13 -0
  7. mizzle/core/query/__init__.py +0 -0
  8. mizzle/core/query/base.py +82 -0
  9. mizzle/core/query/builder.py +205 -0
  10. mizzle/core/query/insert.py +131 -0
  11. mizzle/core/query/relational.py +271 -0
  12. mizzle/core/query/returning.py +22 -0
  13. mizzle/core/query/select.py +214 -0
  14. mizzle/core/query/update.py +78 -0
  15. mizzle/core/schema/__init__.py +0 -0
  16. mizzle/core/schema/column.py +231 -0
  17. mizzle/core/schema/constraints.py +85 -0
  18. mizzle/core/schema/relations.py +191 -0
  19. mizzle/core/schema/table.py +144 -0
  20. mizzle/core/schema/types.py +61 -0
  21. mizzle/core/snapshot.py +98 -0
  22. mizzle/core/sql/__init__.py +0 -0
  23. mizzle/core/sql/nodes.py +158 -0
  24. mizzle/core/sql/operators.py +213 -0
  25. mizzle/core/sql/raw.py +83 -0
  26. mizzle/core/steps.py +283 -0
  27. mizzle/db.py +237 -0
  28. mizzle/dialects/__init__.py +0 -0
  29. mizzle/dialects/base.py +62 -0
  30. mizzle/dialects/postgres/__init__.py +87 -0
  31. mizzle/dialects/postgres/compiler.py +128 -0
  32. mizzle/dialects/postgres/ddl.py +280 -0
  33. mizzle/dialects/postgres/driver.py +166 -0
  34. mizzle/dialects/postgres/introspect.py +209 -0
  35. mizzle/dialects/postgres/types.py +117 -0
  36. mizzle/kit/__init__.py +27 -0
  37. mizzle/kit/cli.py +424 -0
  38. mizzle/kit/codegen.py +309 -0
  39. mizzle/kit/config.py +182 -0
  40. mizzle/kit/diff.py +385 -0
  41. mizzle/kit/init.py +105 -0
  42. mizzle/kit/journal.py +183 -0
  43. mizzle/kit/migrate.py +96 -0
  44. mizzle/kit/snapshot.py +156 -0
  45. mizzle/pg.py +57 -0
  46. mizzle/py.typed +0 -0
  47. mizzle/pydantic.py +158 -0
  48. mizzle/studio/__init__.py +0 -0
  49. mizzle/studio/assets.py +276 -0
  50. mizzle/studio/server.py +458 -0
  51. mizzle/studio/ui.json +5 -0
  52. mizzle-0.1.0.dist-info/METADATA +175 -0
  53. mizzle-0.1.0.dist-info/RECORD +56 -0
  54. mizzle-0.1.0.dist-info/WHEEL +4 -0
  55. mizzle-0.1.0.dist-info/entry_points.txt +2 -0
  56. 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
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from mizzle.kit.cli import main
4
+
5
+ sys.exit(main())
File without changes
@@ -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
+ )