snake-orm 0.1.0b1__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 (198) hide show
  1. snake_orm-0.1.0b1.dist-info/METADATA +366 -0
  2. snake_orm-0.1.0b1.dist-info/RECORD +198 -0
  3. snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
  4. snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
  5. snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
  6. snakeorm/__init__.py +214 -0
  7. snakeorm/advisor.py +109 -0
  8. snakeorm/cli/__init__.py +3 -0
  9. snakeorm/cli/app.py +992 -0
  10. snakeorm/cli/discovery.py +179 -0
  11. snakeorm/cli/hooks.py +86 -0
  12. snakeorm/compiler/__init__.py +3 -0
  13. snakeorm/compiler/compiler.py +330 -0
  14. snakeorm/connection.py +228 -0
  15. snakeorm/contrib/__init__.py +17 -0
  16. snakeorm/contrib/asgi.py +192 -0
  17. snakeorm/contrib/config.py +264 -0
  18. snakeorm/contrib/deliver.py +256 -0
  19. snakeorm/contrib/django.py +245 -0
  20. snakeorm/contrib/sidecar.py +44 -0
  21. snakeorm/contrib/wsgi.py +176 -0
  22. snakeorm/core/__init__.py +1 -0
  23. snakeorm/core/config.py +173 -0
  24. snakeorm/core/converters.py +172 -0
  25. snakeorm/core/exceptions.py +175 -0
  26. snakeorm/core/placement.py +15 -0
  27. snakeorm/core/sentinels.py +37 -0
  28. snakeorm/core/signals.py +114 -0
  29. snakeorm/debug/__init__.py +42 -0
  30. snakeorm/debug/assets/css/panel.css +616 -0
  31. snakeorm/debug/assets/css/snake_orm_app.css +293 -0
  32. snakeorm/debug/assets/img/icon.svg +5 -0
  33. snakeorm/debug/assets/js/history.js +425 -0
  34. snakeorm/debug/assets/js/language.js +283 -0
  35. snakeorm/debug/assets/js/panel.js +241 -0
  36. snakeorm/debug/assets/js/snake_orm_app.js +82 -0
  37. snakeorm/debug/assets/pages/cli.html +28 -0
  38. snakeorm/debug/assets/pages/config.html +34 -0
  39. snakeorm/debug/assets/pages/database_first.html +29 -0
  40. snakeorm/debug/assets/pages/help.html +21 -0
  41. snakeorm/debug/assets/pages/history.html +27 -0
  42. snakeorm/debug/capture.py +234 -0
  43. snakeorm/debug/channel.py +131 -0
  44. snakeorm/debug/collector.py +144 -0
  45. snakeorm/debug/config.py +149 -0
  46. snakeorm/debug/html.py +578 -0
  47. snakeorm/debug/matrix.py +60 -0
  48. snakeorm/debug/origin.py +42 -0
  49. snakeorm/debug/otel/__init__.py +68 -0
  50. snakeorm/debug/otel/context.py +43 -0
  51. snakeorm/debug/otel/exporter.py +277 -0
  52. snakeorm/debug/otel/payload.py +97 -0
  53. snakeorm/debug/otel/report.py +29 -0
  54. snakeorm/debug/otel/spans.py +393 -0
  55. snakeorm/debug/otel/summary.py +59 -0
  56. snakeorm/debug/record.py +54 -0
  57. snakeorm/debug/report.py +311 -0
  58. snakeorm/debug/testing.py +23 -0
  59. snakeorm/decorators/__init__.py +13 -0
  60. snakeorm/decorators/abstract.py +26 -0
  61. snakeorm/decorators/db_first.py +104 -0
  62. snakeorm/decorators/function.py +28 -0
  63. snakeorm/decorators/model.py +268 -0
  64. snakeorm/decorators/polymorphic.py +140 -0
  65. snakeorm/decorators/result.py +167 -0
  66. snakeorm/decorators/row.py +90 -0
  67. snakeorm/decorators/trigger.py +47 -0
  68. snakeorm/decorators/view.py +149 -0
  69. snakeorm/dialects/__init__.py +6 -0
  70. snakeorm/dialects/base.py +373 -0
  71. snakeorm/dialects/capabilities.py +571 -0
  72. snakeorm/dialects/literals.py +46 -0
  73. snakeorm/dialects/matrix.py +384 -0
  74. snakeorm/dialects/mysql.py +519 -0
  75. snakeorm/dialects/postgres.py +504 -0
  76. snakeorm/dialects/sqlite.py +417 -0
  77. snakeorm/drivers/__init__.py +18 -0
  78. snakeorm/drivers/asyncbase.py +73 -0
  79. snakeorm/drivers/asyncdecorators.py +244 -0
  80. snakeorm/drivers/asyncpool.py +228 -0
  81. snakeorm/drivers/asyncpsycopg.py +124 -0
  82. snakeorm/drivers/asyncpymysql.py +50 -0
  83. snakeorm/drivers/asyncsqlite.py +30 -0
  84. snakeorm/drivers/base.py +76 -0
  85. snakeorm/drivers/failures.py +181 -0
  86. snakeorm/drivers/logging.py +167 -0
  87. snakeorm/drivers/pool.py +352 -0
  88. snakeorm/drivers/psycopg.py +188 -0
  89. snakeorm/drivers/pymysql.py +176 -0
  90. snakeorm/drivers/savepoints.py +46 -0
  91. snakeorm/drivers/sqlite.py +170 -0
  92. snakeorm/drivers/threaded.py +143 -0
  93. snakeorm/drivers/timeout.py +87 -0
  94. snakeorm/dto/__init__.py +55 -0
  95. snakeorm/dto/read.py +231 -0
  96. snakeorm/dto/region.py +326 -0
  97. snakeorm/dto/resolve.py +354 -0
  98. snakeorm/dto/spec.py +201 -0
  99. snakeorm/expressions/__init__.py +88 -0
  100. snakeorm/expressions/conditional.py +178 -0
  101. snakeorm/expressions/expression.py +810 -0
  102. snakeorm/expressions/functions.py +71 -0
  103. snakeorm/expressions/keys.py +299 -0
  104. snakeorm/expressions/paths.py +43 -0
  105. snakeorm/expressions/scalar.py +293 -0
  106. snakeorm/expressions/window.py +248 -0
  107. snakeorm/fields/__init__.py +68 -0
  108. snakeorm/fields/check.py +79 -0
  109. snakeorm/fields/column.py +316 -0
  110. snakeorm/fields/enum.py +64 -0
  111. snakeorm/fields/index.py +46 -0
  112. snakeorm/fields/relationship.py +738 -0
  113. snakeorm/fields/typed.py +736 -0
  114. snakeorm/helpers/__init__.py +1 -0
  115. snakeorm/helpers/annotations.py +21 -0
  116. snakeorm/helpers/inheritance.py +41 -0
  117. snakeorm/helpers/pyliteral.py +52 -0
  118. snakeorm/helpers/pytype.py +50 -0
  119. snakeorm/introspection/__init__.py +13 -0
  120. snakeorm/introspection/base.py +30 -0
  121. snakeorm/introspection/drift.py +78 -0
  122. snakeorm/introspection/models.py +633 -0
  123. snakeorm/introspection/mysql.py +367 -0
  124. snakeorm/introspection/postgres.py +397 -0
  125. snakeorm/introspection/sqlite.py +217 -0
  126. snakeorm/introspection/unsupported.py +94 -0
  127. snakeorm/linker/__init__.py +3 -0
  128. snakeorm/linker/linker.py +521 -0
  129. snakeorm/metadata/__init__.py +41 -0
  130. snakeorm/metadata/check.py +37 -0
  131. snakeorm/metadata/column.py +201 -0
  132. snakeorm/metadata/enum_storage.py +33 -0
  133. snakeorm/metadata/fk_action.py +19 -0
  134. snakeorm/metadata/foreign_key.py +26 -0
  135. snakeorm/metadata/index.py +43 -0
  136. snakeorm/metadata/index_method.py +19 -0
  137. snakeorm/metadata/int_size.py +20 -0
  138. snakeorm/metadata/json_storage.py +21 -0
  139. snakeorm/metadata/polymorphic.py +30 -0
  140. snakeorm/metadata/primary_key.py +23 -0
  141. snakeorm/metadata/relationship.py +60 -0
  142. snakeorm/metadata/relationship_kind.py +43 -0
  143. snakeorm/metadata/routine.py +26 -0
  144. snakeorm/metadata/server_default.py +20 -0
  145. snakeorm/metadata/table.py +97 -0
  146. snakeorm/metadata/table_kind.py +23 -0
  147. snakeorm/metadata/trigger.py +62 -0
  148. snakeorm/metadata/type_params.py +348 -0
  149. snakeorm/migration/__init__.py +88 -0
  150. snakeorm/migration/asyncrunner.py +168 -0
  151. snakeorm/migration/autodetect.py +96 -0
  152. snakeorm/migration/ddl.py +1172 -0
  153. snakeorm/migration/diff.py +701 -0
  154. snakeorm/migration/loader.py +77 -0
  155. snakeorm/migration/operations.py +952 -0
  156. snakeorm/migration/planner.py +78 -0
  157. snakeorm/migration/realize.py +420 -0
  158. snakeorm/migration/renames.py +153 -0
  159. snakeorm/migration/render.py +1095 -0
  160. snakeorm/migration/runner.py +314 -0
  161. snakeorm/migration/squash.py +60 -0
  162. snakeorm/migration/state.py +107 -0
  163. snakeorm/model.py +162 -0
  164. snakeorm/py.typed +0 -0
  165. snakeorm/query/__init__.py +9 -0
  166. snakeorm/query/compound.py +288 -0
  167. snakeorm/query/join_kind.py +19 -0
  168. snakeorm/query/joined.py +143 -0
  169. snakeorm/query/query.py +1001 -0
  170. snakeorm/query/recursive.py +244 -0
  171. snakeorm/registry/__init__.py +5 -0
  172. snakeorm/registry/by_module.py +163 -0
  173. snakeorm/registry/registry.py +166 -0
  174. snakeorm/session/__init__.py +8 -0
  175. snakeorm/session/asyncsession.py +638 -0
  176. snakeorm/session/coercion.py +306 -0
  177. snakeorm/session/factory.py +29 -0
  178. snakeorm/session/guards.py +269 -0
  179. snakeorm/session/isolation.py +23 -0
  180. snakeorm/session/mapper.py +213 -0
  181. snakeorm/session/planning.py +815 -0
  182. snakeorm/session/retry.py +96 -0
  183. snakeorm/session/session.py +871 -0
  184. snakeorm/session/shared.py +355 -0
  185. snakeorm/sql/__init__.py +18 -0
  186. snakeorm/sql/adapt.py +104 -0
  187. snakeorm/sql/aggregate.py +232 -0
  188. snakeorm/sql/condition.py +359 -0
  189. snakeorm/sql/delete.py +46 -0
  190. snakeorm/sql/insert.py +149 -0
  191. snakeorm/sql/joins.py +158 -0
  192. snakeorm/sql/pk_subquery.py +54 -0
  193. snakeorm/sql/refs.py +19 -0
  194. snakeorm/sql/resolver.py +28 -0
  195. snakeorm/sql/select.py +175 -0
  196. snakeorm/sql/update.py +82 -0
  197. snakeorm/sql/value.py +552 -0
  198. snakeorm/times.py +276 -0
snakeorm/sql/insert.py ADDED
@@ -0,0 +1,149 @@
1
+ """Emission of parametrised INSERTs (single row, bulk, upsert): `INSERT INTO ... (cols) VALUES (...)`.
2
+
3
+ They delegate to the dialect the quoting, the placeholders, the conflict clause and `RETURNING`,
4
+ which returns ALL the columns so as to recover whatever the server puts there (autoincrement PK,
5
+ DEFAULT, triggers). Values go into `params`; never interpolated.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping, Sequence
11
+
12
+ from snakeorm.dialects import SnakeDialect
13
+ from snakeorm.core.exceptions import SnakeEmitError
14
+ from snakeorm.dialects.capabilities import EmptyInsertStyle
15
+ from snakeorm.metadata import SnakeTableInfo
16
+ from snakeorm.sql.refs import qualified
17
+
18
+
19
+ def emit_insert(
20
+ table: SnakeTableInfo,
21
+ dialect: SnakeDialect,
22
+ values: Mapping[str, object],
23
+ ) -> tuple[str, tuple[object, ...]]:
24
+ """Emits `INSERT INTO ... VALUES ...` with RETURNING where applicable.
25
+
26
+ With no values the row is all defaults, and there each engine writes something different:
27
+ `DEFAULT VALUES` is the standard and MySQL does NOT have it —it wants `() VALUES ()`—, so the
28
+ shape is decided by `dialect.syntax`. A PK-only table is legitimate and must be insertable on all
29
+ three.
30
+ """
31
+ if not values:
32
+ table_ref = qualified(table.schema, table.name, dialect)
33
+ empty_row = (
34
+ "DEFAULT VALUES"
35
+ if dialect.syntax.empty_insert_style is EmptyInsertStyle.DEFAULT_VALUES
36
+ else "() VALUES ()"
37
+ )
38
+ return f"INSERT INTO {table_ref} {empty_row}{_returning(table, dialect)}", ()
39
+
40
+ columns = list(values)
41
+ params: list[object] = []
42
+ row_sql = _emit_row(columns, values, dialect, params)
43
+ sql = f"{_insert_head(table, dialect, columns)} VALUES {row_sql}"
44
+ return f"{sql}{_returning(table, dialect)}", tuple(params)
45
+
46
+
47
+ def emit_insert_many(
48
+ table: SnakeTableInfo,
49
+ dialect: SnakeDialect,
50
+ rows: Sequence[Mapping[str, object]],
51
+ ) -> tuple[str, tuple[object, ...]]:
52
+ """Emits a single `INSERT ... VALUES (...), (...), ...` for several rows.
53
+
54
+ One single INSERT (not executemany) so that `RETURNING` recovers every row in one go. They must
55
+ all have the same columns in the same order, or `SnakeEmitError`. The chunking lives in the
56
+ session.
57
+ """
58
+ if not rows:
59
+ raise SnakeEmitError("A bulk INSERT needs at least one row")
60
+ columns = list(rows[0])
61
+ if not columns:
62
+ raise SnakeEmitError("An INSERT needs at least one column with a value")
63
+ expected = set(columns)
64
+ for row in rows:
65
+ if set(row) != expected:
66
+ raise SnakeEmitError(
67
+ "Every row of a bulk INSERT must have the same columns; "
68
+ f"{sorted(expected)} were expected."
69
+ )
70
+
71
+ params: list[object] = []
72
+ tuples = ", ".join(_emit_row(columns, row, dialect, params) for row in rows)
73
+ sql = f"{_insert_head(table, dialect, columns)} VALUES {tuples}"
74
+ return f"{sql}{_returning(table, dialect)}", tuple(params)
75
+
76
+
77
+ def emit_upsert(
78
+ table: SnakeTableInfo,
79
+ dialect: SnakeDialect,
80
+ values: Mapping[str, object],
81
+ *,
82
+ conflict_columns: Sequence[str],
83
+ update_columns: Sequence[str] = (),
84
+ ) -> tuple[str, tuple[object, ...]]:
85
+ """Emits an INSERT idempotent under conflict: `INSERT ... <conflict clause> [RETURNING]`.
86
+
87
+ The clause (`ON CONFLICT`) is supplied by the dialect. Without `update_columns` it does not touch
88
+ the existing row. An empty `conflict_columns` is an error. Checking upsert support is the
89
+ session's business.
90
+ """
91
+ if not conflict_columns:
92
+ raise SnakeEmitError(
93
+ "An upsert needs at least one conflict column (on_conflict)"
94
+ )
95
+ if not values:
96
+ # Here it does NOT fall back to `DEFAULT VALUES`: SQLite forbids combining it with `ON
97
+ # CONFLICT`, and an upsert with no values makes no sense (the conflict would be on the
98
+ # autogenerated PK, which never collides).
99
+ raise SnakeEmitError(
100
+ "An upsert needs at least one value to insert: conflict resolution is meaningless "
101
+ "for a row without columns (the conflict would be on the autogenerated PK, which "
102
+ "never collides). Use `add` if you only want to insert."
103
+ )
104
+
105
+ conflict = dialect.on_conflict_clause(conflict_columns, update_columns)
106
+ columns = list(values)
107
+ params: list[object] = []
108
+ row_sql = _emit_row(columns, values, dialect, params)
109
+ sql = f"{_insert_head(table, dialect, columns)} VALUES {row_sql} {conflict}"
110
+ return f"{sql}{_returning(table, dialect)}", tuple(params)
111
+
112
+
113
+ def _insert_head(
114
+ table: SnakeTableInfo, dialect: SnakeDialect, columns: Sequence[str]
115
+ ) -> str:
116
+ """`INSERT INTO <schema.table> (<quoted cols>)`: header shared by the three emitters."""
117
+ quoted_columns = ", ".join(dialect.quote_ident(column) for column in columns)
118
+ table_ref = qualified(table.schema, table.name, dialect)
119
+ return f"INSERT INTO {table_ref} ({quoted_columns})"
120
+
121
+
122
+ def _emit_row(
123
+ columns: Sequence[str],
124
+ values: Mapping[str, object],
125
+ dialect: SnakeDialect,
126
+ params: list[object],
127
+ ) -> str:
128
+ """`(ph, ph, ...)` for one row: every value enters `params` and numbers its placeholder.
129
+
130
+ It walks `columns` (not `values`) to fix the order shared by every row; the numbering runs
131
+ continuously across rows (which matters on positional dialects, `$1`, `$2`).
132
+ """
133
+ placeholders: list[str] = []
134
+ for column in columns:
135
+ params.append(values[column])
136
+ placeholders.append(dialect.placeholder(len(params)))
137
+ return f"({', '.join(placeholders)})"
138
+
139
+
140
+ def _returning(table: SnakeTableInfo, dialect: SnakeDialect) -> str:
141
+ """` RETURNING <every column>` if the engine supports it, otherwise empty.
142
+
143
+ ALL of them (not just the PK): it recovers whatever the server puts there (autoincrement,
144
+ DEFAULT).
145
+ """
146
+ if not dialect.supports_returning:
147
+ return ""
148
+ columns = ", ".join(dialect.quote_ident(column.name) for column in table.columns)
149
+ return f" RETURNING {columns}"
snakeorm/sql/joins.py ADDED
@@ -0,0 +1,158 @@
1
+ """JOIN planning for deep navigation: relationship paths → aliases + JOIN clauses.
2
+
3
+ A path is `("rel1", ..., "column")`. It assigns an alias to the root (t0) and to every unique prefix
4
+ (t1...), one JOIN per prefix, ordered from parent to child; shared prefixes reuse an alias. It lives
5
+ in `sql/` because it emits SQL; each relationship's target is resolved by an injected
6
+ `resolve_target`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterable
12
+
13
+ from snakeorm.dialects import SnakeDialect
14
+ from snakeorm.sql.resolver import SnakeRelationshipResolver
15
+ from snakeorm.core.exceptions import (
16
+ SnakeRegistryError,
17
+ SnakeUnknownRelationship,
18
+ SnakeUnsupportedFeature,
19
+ )
20
+ from snakeorm.metadata import (
21
+ SnakeRelationshipKind,
22
+ SnakeRelationshipInfo,
23
+ SnakeTableInfo,
24
+ )
25
+ from snakeorm.sql.refs import qualified
26
+
27
+
28
+ class JoinPlan:
29
+ """Resolves a set of relationship paths against the graph → aliases + JOIN clauses."""
30
+
31
+ def __init__(
32
+ self,
33
+ root: SnakeTableInfo,
34
+ paths: Iterable[tuple[str, ...]],
35
+ dialect: SnakeDialect,
36
+ resolver: SnakeRelationshipResolver,
37
+ relationship_paths: Iterable[tuple[str, ...]] = (),
38
+ explicit_joins: Iterable[tuple[tuple[str, ...], bool]] = (),
39
+ ) -> None:
40
+ self._dialect = dialect
41
+ self._resolver = resolver
42
+ self._alias: dict[tuple[str, ...], str] = {(): "t0"}
43
+ self._table: dict[tuple[str, ...], SnakeTableInfo] = {(): root}
44
+ self._joins: list[str] = []
45
+
46
+ # FILTER/order prefixes: paths ending in a column → INNER JOIN.
47
+ filter_prefixes: set[tuple[str, ...]] = set()
48
+ for path in paths:
49
+ steps = path[:-1] # the last element is the column, not a relationship
50
+ for length in range(1, len(steps) + 1):
51
+ filter_prefixes.add(steps[:length])
52
+ # INCLUDE prefixes: these are relationships to load → LEFT JOIN (does not lose rows with a
53
+ # null FK).
54
+ include_prefixes: set[tuple[str, ...]] = set()
55
+ for path in relationship_paths:
56
+ for length in range(1, len(path) + 1):
57
+ include_prefixes.add(path[:length])
58
+ # EXPLICIT JOINs (`.join()`): a to-many IS allowed (it multiplies rows on purpose) and its
59
+ # `how` overrides the inference. It is the ONLY route by which a to-many gets joined.
60
+ explicit: dict[tuple[str, ...], bool] = dict(explicit_joins)
61
+ # Sort by length (parent before child) and then lexicographically (deterministic).
62
+ every = filter_prefixes | include_prefixes | set(explicit)
63
+ for prefix in sorted(every, key=lambda p: (len(p), p)):
64
+ if prefix in explicit:
65
+ # The explicit `how` wins; the to-many is allowed (it is what was asked for).
66
+ self._add_join(prefix, left=explicit[prefix], allow_to_many=True)
67
+ continue
68
+ # LEFT only if it is a pure include; if it is also filtered, INNER (the filter already
69
+ # demands a match).
70
+ left = prefix in include_prefixes and prefix not in filter_prefixes
71
+ self._add_join(prefix, left=left)
72
+
73
+ @property
74
+ def root_alias(self) -> str:
75
+ """Alias of the query's root table."""
76
+ return "t0"
77
+
78
+ @property
79
+ def has_joins(self) -> bool:
80
+ """Tells whether the plan generated any JOIN (there is relationship navigation)."""
81
+ return bool(self._joins)
82
+
83
+ @property
84
+ def joins(self) -> tuple[str, ...]:
85
+ """The JOIN clauses in parent-to-child order."""
86
+ return tuple(self._joins)
87
+
88
+ def alias_for(self, prefix: tuple[str, ...]) -> str:
89
+ """Alias of the table reached by a relationship prefix (`()` = root)."""
90
+ return self._alias[prefix]
91
+
92
+ def table_for(self, prefix: tuple[str, ...]) -> SnakeTableInfo:
93
+ """Table reached by a relationship prefix (`()` = root). For the SELECT of includes."""
94
+ return self._table[prefix]
95
+
96
+ def _add_join(
97
+ self, prefix: tuple[str, ...], left: bool = False, allow_to_many: bool = False
98
+ ) -> None:
99
+ """Registers the prefix's JOIN leaning on its parent (already present thanks to the order).
100
+
101
+ A to-many is only joined with `allow_to_many` (an explicit `.join()`): in any other case
102
+ duplicating rows would be a silent bug, so it is rejected.
103
+ """
104
+ parent = prefix[:-1]
105
+ relationship = self._relationship(self._table[parent], prefix[-1])
106
+ if relationship.kind is SnakeRelationshipKind.TO_MANY and not allow_to_many:
107
+ # A to-many would duplicate rows and its ON would go the other way round (the FK lives
108
+ # in the child). The type net already forbids it; this is the runtime one.
109
+ raise SnakeUnsupportedFeature(
110
+ f"'{prefix[-1]}' is a to-many relationship and changes the number of rows. "
111
+ f"Use <Model>.{prefix[-1]}.any(...) to filter by existence, "
112
+ f"or .join(...) if you want the child rows."
113
+ )
114
+ # Through the RELATIONSHIP, not through its class name. `.target` is the class name, and
115
+ # the qualified name the linker computed —`relationship.target_table`— sits on this very
116
+ # object; asking by name went through the index `register()` overwrites in silence, so two
117
+ # apps with their own `Customer` in one process produced a JOIN onto the wrong table. Valid
118
+ # SQL, real rows, no warning. `resolve_relationship` already preferred the qualified name
119
+ # and its docstring already said it existed "so that fixing the wrong target is ONE change
120
+ # and not twelve copies" — twelve places used it and these six did not.
121
+ target, _ = self._resolver.resolve_relationship(relationship)
122
+ if target is None:
123
+ raise SnakeRegistryError(
124
+ f"The target '{relationship.target}' of relationship "
125
+ f"'{prefix[-1]}' is not registered."
126
+ )
127
+ alias = f"t{len(self._alias)}"
128
+ self._alias[prefix] = alias
129
+ self._table[prefix] = target
130
+
131
+ quote = self._dialect.quote_ident
132
+ parent_alias = self._alias[parent]
133
+ if relationship.kind is SnakeRelationshipKind.TO_MANY:
134
+ # In a to-many the FK lives in the CHILD, so the ON is written the other way round:
135
+ # `child.fk = parent.pk`. Its pairs are (child_column, parent_column), precisely so the
136
+ # direction is not flipped.
137
+ on = " AND ".join(
138
+ f"{alias}.{quote(child_col)} = {parent_alias}.{quote(parent_col)}"
139
+ for child_col, parent_col in relationship.foreign_key.pairs
140
+ )
141
+ else:
142
+ on = " AND ".join(
143
+ f"{parent_alias}.{quote(local)} = {alias}.{quote(remote)}"
144
+ for local, remote in relationship.foreign_key.pairs
145
+ )
146
+ table_ref = qualified(target.schema, target.name, self._dialect)
147
+ keyword = "LEFT JOIN" if left else "JOIN"
148
+ self._joins.append(f"{keyword} {table_ref} AS {alias} ON {on}")
149
+
150
+ @staticmethod
151
+ def _relationship(table: SnakeTableInfo, name: str) -> SnakeRelationshipInfo:
152
+ """Looks the relationship up by name on the table; fails clearly if it does not exist."""
153
+ for relationship in table.relationships:
154
+ if relationship.name == name:
155
+ return relationship
156
+ raise SnakeUnknownRelationship(
157
+ f"'{table.name}' does not have a relationship named '{name}'."
158
+ )
@@ -0,0 +1,54 @@
1
+ """Rewriting a bulk write with a deep WHERE into `pk IN (subquery)`.
2
+
3
+ An UPDATE/DELETE whose filter navigates a relationship does not fit in a flat WHERE. Instead of
4
+ `... FROM` (Postgres jargon, not portable), it is rewritten into a subquery that projects the PK
5
+ using the `JoinPlan` of the deep paths. A composite PK uses a row constructor `(a, b) IN (...)`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from snakeorm.dialects import SnakeDialect
11
+ from snakeorm.expressions import SnakeCondition
12
+ from snakeorm.metadata import SnakeTableInfo
13
+ from snakeorm.sql.condition import emit_condition_into
14
+ from snakeorm.sql.joins import JoinPlan
15
+ from snakeorm.sql.refs import qualified
16
+ from snakeorm.sql.value import Correlation, ExistsAliases
17
+
18
+
19
+ def emit_pk_subquery(
20
+ table: SnakeTableInfo,
21
+ dialect: SnakeDialect,
22
+ plan: JoinPlan,
23
+ where: SnakeCondition,
24
+ params: list[object],
25
+ ) -> str:
26
+ """Emits `SELECT t0."pk"... FROM <table> AS t0 [JOINs] WHERE <deep cond>` (the PK ONLY).
27
+
28
+ Reuses the `JoinPlan` and `emit_condition_into` with alias qualification. The WHERE params are
29
+ accumulated (numbering continues from the UPDATE's SET, positional).
30
+ """
31
+ quote = dialect.quote_ident
32
+ root_alias = plan.root_alias
33
+ pk_columns = ", ".join(
34
+ f"{root_alias}.{quote(column.name)}" for column in table.primary_key.columns
35
+ )
36
+ table_ref = f"{qualified(table.schema, table.name, dialect)} AS {root_alias}"
37
+ sql = f"SELECT {pk_columns} FROM {table_ref}"
38
+ if plan.has_joins:
39
+ sql = f"{sql} {' '.join(plan.joins)}"
40
+ correlate = Correlation(parent_ref=root_alias, aliases=ExistsAliases())
41
+ where_sql = emit_condition_into(where, dialect, params, plan.alias_for, correlate)
42
+ return f"{sql} WHERE {where_sql}"
43
+
44
+
45
+ def emit_pk_in(table: SnakeTableInfo, dialect: SnakeDialect, subquery: str) -> str:
46
+ """Emits `<pk> IN (subquery)` over the base table (NO alias).
47
+
48
+ Simple PK → `"id" IN (...)`; composite → row constructor `("a","b") IN (...)`. Unqualified: the
49
+ outer UPDATE/DELETE operates on the base table, which carries no alias.
50
+ """
51
+ quote = dialect.quote_ident
52
+ columns = [quote(column.name) for column in table.primary_key.columns]
53
+ left = columns[0] if len(columns) == 1 else f"({', '.join(columns)})"
54
+ return f"{left} IN ({subquery})"
snakeorm/sql/refs.py ADDED
@@ -0,0 +1,19 @@
1
+ """How a table is named in the SQL: `"schema"."table"` or just `"table"`.
2
+
3
+ Centralised because twenty-five call sites build it. SQLite has no named schemas (a `"public"."x"`
4
+ gives `unknown database "public"`), so qualifying per engine has to live in ONE place.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from snakeorm.dialects import SnakeDialect
10
+
11
+
12
+ def qualified(schema: str, name: str, dialect: SnakeDialect) -> str:
13
+ """Table reference, qualified by schema ONLY if the engine has schemas.
14
+
15
+ On an engine without schemas, qualifying is not cosmetic: it is invalid SQL.
16
+ """
17
+ if dialect.supports_schemas:
18
+ return f"{dialect.quote_ident(schema)}.{dialect.quote_ident(name)}"
19
+ return dialect.quote_ident(name)
@@ -0,0 +1,28 @@
1
+ """What the SQL emitter needs from a registry: resolving a relationship to its target.
2
+
3
+ A Protocol and not `SnakeRegistry` itself, for the reason the whole `sql/` package exists: emitting
4
+ must not depend on the thing that stores models, only on the one question it has to ask. It also
5
+ keeps the tests honest — a double can answer this without standing up a registry, which is what lets
6
+ the negative test re-run the OLD resolution and demand the wrong answer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Protocol
12
+
13
+ from snakeorm.metadata import SnakeRelationshipInfo, SnakeTableInfo
14
+
15
+
16
+ class SnakeRelationshipResolver(Protocol):
17
+ """Answers WHERE a relationship points, unambiguously."""
18
+
19
+ def resolve_relationship(
20
+ self, relationship: SnakeRelationshipInfo
21
+ ) -> tuple[SnakeTableInfo | None, type | None]:
22
+ """The target's `(table, class)`, preferring the qualified name over the class name.
23
+
24
+ The class name is NOT unique —two apps can each declare a `Customer`— and the index keyed by
25
+ it is kept by whichever registered last. The qualified name is protected by the collision
26
+ guard, which is why it is the one that answers.
27
+ """
28
+ ...
snakeorm/sql/select.py ADDED
@@ -0,0 +1,175 @@
1
+ """Emission of a parametrised SELECT: orchestrates the grammar, delegates engine bits to the dialect.
2
+
3
+ Params in a single list (WHERE before LIMIT/OFFSET) for continuous numbering. Without a `plan` →
4
+ single-table, no alias (backwards compatible). With a `plan` → deep navigation: root aliased as t0,
5
+ JOINs and qualified columns. The result is still the root (the JOINs only filter/order).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+
12
+ from snakeorm.dialects import SnakeDialect
13
+ from snakeorm.core.exceptions import SnakeUnsupportedFeature
14
+ from snakeorm.expressions import SnakeCondition, SnakeLock, SnakeOrder
15
+ from snakeorm.metadata import SnakeColumnInfo, SnakeTableInfo
16
+ from snakeorm.sql.condition import emit_condition_into
17
+ from snakeorm.sql.joins import JoinPlan
18
+ from snakeorm.sql.refs import qualified
19
+ from snakeorm.sql.value import (
20
+ Correlation,
21
+ ExistsAliases,
22
+ Qualify,
23
+ emit_order_key,
24
+ )
25
+
26
+
27
+ def _correlation(
28
+ table: SnakeTableInfo, dialect: SnakeDialect, plan: JoinPlan | None
29
+ ) -> Correlation:
30
+ """Correlation context for the WHERE's subqueries (how to reference the root).
31
+
32
+ With JOINs the root has an alias (`t0`); without them, the table name (the FROM's implicit
33
+ correlation).
34
+ """
35
+ parent_ref = (
36
+ plan.root_alias if plan is not None else dialect.quote_ident(table.name)
37
+ )
38
+ return Correlation(parent_ref=parent_ref, aliases=ExistsAliases())
39
+
40
+
41
+ def selected_columns(
42
+ table: SnakeTableInfo, columns: frozenset[str] | None
43
+ ) -> tuple[SnakeColumnInfo, ...]:
44
+ """The columns to project, in the TABLE's declaration order and never the caller's.
45
+
46
+ The order is not a detail: hydration walks the same declaration to build its plan, so a
47
+ projection sorted by what the user typed would line the row up against the wrong attributes. Two
48
+ orders is two chances to be wrong, and one of them is silent.
49
+
50
+ `None` means every column, which is what every path but `only()`/`defer()` passes.
51
+ """
52
+ if columns is None:
53
+ return table.columns
54
+ return tuple(column for column in table.columns if column.name in columns)
55
+
56
+
57
+ def emit_select(
58
+ table: SnakeTableInfo,
59
+ dialect: SnakeDialect,
60
+ where: SnakeCondition | None = None,
61
+ order_by: tuple[SnakeOrder, ...] = (),
62
+ limit: int | None = None,
63
+ offset: int | None = None,
64
+ plan: JoinPlan | None = None,
65
+ distinct: bool = False,
66
+ lock: SnakeLock | None = None,
67
+ columns: frozenset[str] | None = None,
68
+ ) -> tuple[str, tuple[object, ...]]:
69
+ """Emits a parametrised `SELECT [DISTINCT] ... FROM ... [JOINs] [WHERE] [ORDER BY] [LIMIT/OFFSET]`.
70
+
71
+ `columns` is the subset `only()`/`defer()` asked for, or `None` for every column. The order is
72
+ the TABLE's, never the caller's: the hydration plan is built by walking the same declaration, so
73
+ two orders would be two chances to line the row up wrong.
74
+ """
75
+ params: list[object] = []
76
+ qualify: Qualify | None = plan.alias_for if plan is not None else None
77
+ root_alias = plan.root_alias if plan is not None else None
78
+
79
+ projected = selected_columns(table, columns)
80
+ columns_sql = ", ".join(
81
+ _column_ref(root_alias, column.name, dialect) for column in projected
82
+ )
83
+ table_ref = qualified(table.schema, table.name, dialect)
84
+ if root_alias is not None:
85
+ table_ref = f"{table_ref} AS {root_alias}"
86
+ keyword = "SELECT DISTINCT" if distinct else "SELECT"
87
+ sql = f"{keyword} {columns_sql} FROM {table_ref}"
88
+
89
+ if plan is not None and plan.has_joins:
90
+ sql = f"{sql} {' '.join(plan.joins)}"
91
+
92
+ if where is not None:
93
+ correlate = _correlation(table, dialect, plan)
94
+ sql = f"{sql} WHERE {emit_condition_into(where, dialect, params, qualify, correlate)}"
95
+
96
+ if order_by:
97
+ keys = ", ".join(
98
+ emit_order_key(key, dialect, params, qualify) for key in order_by
99
+ )
100
+ sql = f"{sql} ORDER BY {keys}"
101
+
102
+ clause = dialect.limit_offset(limit, offset, params)
103
+ if clause:
104
+ sql = f"{sql} {clause}"
105
+
106
+ # The lock goes AT THE END of the grammar, after LIMIT/OFFSET: it locks the rows the SELECT
107
+ # really returns, not the ones it looked at to get to them.
108
+ if lock is not None and not dialect.supports_row_locking:
109
+ # It is cut off at COMPILE time (before touching the DB) and the alternative is given,
110
+ # rather than emitting a `FOR UPDATE` the engine does not understand so that it blows up.
111
+ raise SnakeUnsupportedFeature(
112
+ "This engine does not support row locking (`SELECT ... FOR UPDATE`): SQLite locks the "
113
+ "whole file, not individual rows. Use an exclusive transaction on this engine, or "
114
+ "PostgreSQL if you need to reserve specific rows."
115
+ )
116
+ if lock is not None:
117
+ sql = f"{sql} {_LOCK_CLAUSES[lock]}"
118
+
119
+ return sql, tuple(params)
120
+
121
+
122
+ # Lock clause per mode. `WAIT` is the plain `FOR UPDATE` (waiting is its default behaviour), so it
123
+ # carries no suffix.
124
+ _LOCK_CLAUSES: dict[SnakeLock, str] = {
125
+ SnakeLock.WAIT: "FOR UPDATE",
126
+ SnakeLock.NOWAIT: "FOR UPDATE NOWAIT",
127
+ SnakeLock.SKIP_LOCKED: "FOR UPDATE SKIP LOCKED",
128
+ }
129
+
130
+
131
+ def emit_select_with_includes(
132
+ dialect: SnakeDialect,
133
+ segments: Sequence[tuple[tuple[str, ...], SnakeTableInfo]],
134
+ plan: JoinPlan,
135
+ where: SnakeCondition | None = None,
136
+ order_by: tuple[SnakeOrder, ...] = (),
137
+ limit: int | None = None,
138
+ offset: int | None = None,
139
+ ) -> tuple[str, tuple[object, ...]]:
140
+ """Emits the SELECT loading the root + included relationships (columns qualified by alias).
141
+
142
+ `segments` in order: root `((), table)` and then each relationship `(prefix, table)`. All their
143
+ columns enter in that order, so the session slices each row by segment and rebuilds the object.
144
+ """
145
+ params: list[object] = []
146
+ qualify: Qualify = plan.alias_for
147
+ root = segments[0][1]
148
+
149
+ columns = ", ".join(
150
+ f"{plan.alias_for(prefix)}.{dialect.quote_ident(column.name)}"
151
+ for prefix, segment_table in segments
152
+ for column in segment_table.columns
153
+ )
154
+ table_ref = qualified(root.schema, root.name, dialect)
155
+ sql = f"SELECT {columns} FROM {table_ref} AS {plan.root_alias}"
156
+ if plan.has_joins:
157
+ sql = f"{sql} {' '.join(plan.joins)}"
158
+ if where is not None:
159
+ correlate = _correlation(root, dialect, plan)
160
+ sql = f"{sql} WHERE {emit_condition_into(where, dialect, params, qualify, correlate)}"
161
+ if order_by:
162
+ keys = ", ".join(
163
+ emit_order_key(key, dialect, params, qualify) for key in order_by
164
+ )
165
+ sql = f"{sql} ORDER BY {keys}"
166
+ clause = dialect.limit_offset(limit, offset, params)
167
+ if clause:
168
+ sql = f"{sql} {clause}"
169
+ return sql, tuple(params)
170
+
171
+
172
+ def _column_ref(root_alias: str | None, name: str, dialect: SnakeDialect) -> str:
173
+ """Quotes a column of the root table, with an alias if the query carries JOINs."""
174
+ column = dialect.quote_ident(name)
175
+ return f"{root_alias}.{column}" if root_alias is not None else column
snakeorm/sql/update.py ADDED
@@ -0,0 +1,82 @@
1
+ """Emission of a parametrised UPDATE: `UPDATE ... SET ... [WHERE ...]`.
2
+
3
+ The SET consumes the first params; the WHERE continues the numbering over the same list
4
+ (`emit_condition_into`). `where=None` affects the whole table: the guard-rail lives in query/session.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping
10
+
11
+ from snakeorm.dialects import SnakeDialect
12
+ from snakeorm.core.exceptions import SnakeEmitError
13
+ from snakeorm.expressions import SnakeCondition, SnakeValue
14
+ from snakeorm.metadata import SnakeTableInfo
15
+ from snakeorm.sql.condition import emit_condition_into
16
+ from snakeorm.sql.joins import JoinPlan
17
+ from snakeorm.sql.pk_subquery import emit_pk_in, emit_pk_subquery
18
+ from snakeorm.sql.refs import qualified
19
+ from snakeorm.sql.value import emit_operand, emit_value
20
+
21
+
22
+ def _emit_set(
23
+ values: Mapping[str, object], dialect: SnakeDialect, params: list[object]
24
+ ) -> str:
25
+ """Emits the SET's `col = <value>` assignments, accumulating literals into `params`.
26
+
27
+ A `SnakeValue` (e.g. `views + 1`) is emitted as an unqualified expression; a literal, as a
28
+ placeholder. This is what enables `SET views = views + 1`.
29
+ """
30
+ assignments = []
31
+ for column, value in values.items():
32
+ if isinstance(value, SnakeValue):
33
+ rendered = emit_value(value, dialect, params, None)
34
+ else:
35
+ rendered = emit_operand(value, dialect, params)
36
+ assignments.append(f"{dialect.quote_ident(column)} = {rendered}")
37
+ return ", ".join(assignments)
38
+
39
+
40
+ def emit_update(
41
+ table: SnakeTableInfo,
42
+ dialect: SnakeDialect,
43
+ values: Mapping[str, object],
44
+ where: SnakeCondition | None = None,
45
+ ) -> tuple[str, tuple[object, ...]]:
46
+ """Emits `UPDATE ... SET ... [WHERE ...]` with params apart (SET first, then WHERE)."""
47
+ if not values:
48
+ raise SnakeEmitError("An UPDATE needs at least one column in the SET")
49
+
50
+ params: list[object] = []
51
+ set_sql = _emit_set(values, dialect, params)
52
+ table_ref = qualified(table.schema, table.name, dialect)
53
+ sql = f"UPDATE {table_ref} SET {set_sql}"
54
+
55
+ if where is not None:
56
+ where_sql = emit_condition_into(where, dialect, params)
57
+ sql = f"{sql} WHERE {where_sql}"
58
+
59
+ return sql, tuple(params)
60
+
61
+
62
+ def emit_update_pk_in_subquery(
63
+ table: SnakeTableInfo,
64
+ dialect: SnakeDialect,
65
+ values: Mapping[str, object],
66
+ plan: JoinPlan,
67
+ where: SnakeCondition,
68
+ ) -> tuple[str, tuple[object, ...]]:
69
+ """Emits `UPDATE ... SET ... WHERE <pk> IN (SELECT <pk> FROM ... [JOINs] WHERE <deep cond>)`.
70
+
71
+ For a WHERE that navigates a relationship (avoids `UPDATE ... FROM`). The SET consumes the first
72
+ params and the subquery continues from there; the SET still targets the base table.
73
+ """
74
+ if not values:
75
+ raise SnakeEmitError("An UPDATE needs at least one column in the SET")
76
+
77
+ params: list[object] = []
78
+ set_sql = _emit_set(values, dialect, params)
79
+ table_ref = qualified(table.schema, table.name, dialect)
80
+ subquery = emit_pk_subquery(table, dialect, plan, where, params)
81
+ pk_in = emit_pk_in(table, dialect, subquery)
82
+ return f"UPDATE {table_ref} SET {set_sql} WHERE {pk_in}", tuple(params)