pyoq-sql 1.0.2__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 (267) hide show
  1. pyoq/__init__.py +10 -0
  2. pyoq/__main__.py +5 -0
  3. pyoq/_native.pyi +5 -0
  4. pyoq/cli/__init__.py +5 -0
  5. pyoq/cli/commands.py +270 -0
  6. pyoq/cli/defaults.py +98 -0
  7. pyoq/cli/services.py +97 -0
  8. pyoq/config/__init__.py +31 -0
  9. pyoq/config/connection.py +161 -0
  10. pyoq/config/loader.py +289 -0
  11. pyoq/config/models.py +245 -0
  12. pyoq/config/values.py +142 -0
  13. pyoq/descriptors.py +165 -0
  14. pyoq/diagnostics/__init__.py +68 -0
  15. pyoq/diagnostics/budget.py +136 -0
  16. pyoq/diagnostics/events.py +137 -0
  17. pyoq/diagnostics/fingerprint.py +267 -0
  18. pyoq/diagnostics/instrumented.py +237 -0
  19. pyoq/diagnostics/metrics.py +61 -0
  20. pyoq/diagnostics/observation.py +227 -0
  21. pyoq/diagnostics/scoped.py +103 -0
  22. pyoq/django/__init__.py +15 -0
  23. pyoq/django/apps.py +17 -0
  24. pyoq/django/execution.py +317 -0
  25. pyoq/django/generation.py +59 -0
  26. pyoq/django/management/__init__.py +0 -0
  27. pyoq/django/management/commands/__init__.py +0 -0
  28. pyoq/django/management/commands/makemigrations.py +53 -0
  29. pyoq/django/management/commands/pyoq_codegen.py +75 -0
  30. pyoq/django/parameters.py +101 -0
  31. pyoq/django/schema.py +379 -0
  32. pyoq/django/settings.py +87 -0
  33. pyoq/django/timeouts.py +105 -0
  34. pyoq/dsl/__init__.py +64 -0
  35. pyoq/dsl/aio/__init__.py +31 -0
  36. pyoq/dsl/aio/context.py +295 -0
  37. pyoq/dsl/aio/queries.py +335 -0
  38. pyoq/dsl/aio/writes.py +368 -0
  39. pyoq/dsl/context.py +326 -0
  40. pyoq/dsl/entry.py +37 -0
  41. pyoq/dsl/labels.py +36 -0
  42. pyoq/dsl/queries.py +339 -0
  43. pyoq/dsl/result.py +164 -0
  44. pyoq/dsl/writes.py +360 -0
  45. pyoq/errors.py +317 -0
  46. pyoq/fastapi/__init__.py +32 -0
  47. pyoq/fastapi/dependencies.py +167 -0
  48. pyoq/fastapi/lifespan.py +119 -0
  49. pyoq/fetching/__init__.py +55 -0
  50. pyoq/fetching/collections.py +136 -0
  51. pyoq/fetching/execution.py +587 -0
  52. pyoq/fetching/joined.py +79 -0
  53. pyoq/fetching/nesting.py +183 -0
  54. pyoq/fetching/plans.py +541 -0
  55. pyoq/fetching/select_in.py +149 -0
  56. pyoq/fetching/tables.py +110 -0
  57. pyoq/generation/__init__.py +54 -0
  58. pyoq/generation/cleanup.py +44 -0
  59. pyoq/generation/contracts.py +248 -0
  60. pyoq/generation/drift.py +169 -0
  61. pyoq/generation/lock.py +33 -0
  62. pyoq/generation/manifest.py +114 -0
  63. pyoq/generation/model.py +1001 -0
  64. pyoq/generation/pipeline.py +119 -0
  65. pyoq/generation/rendering/__init__.py +5 -0
  66. pyoq/generation/rendering/domains.py +51 -0
  67. pyoq/generation/rendering/enums.py +29 -0
  68. pyoq/generation/rendering/exports.py +70 -0
  69. pyoq/generation/rendering/imports.py +63 -0
  70. pyoq/generation/rendering/package.py +56 -0
  71. pyoq/generation/rendering/relations.py +133 -0
  72. pyoq/generation/rendering/routines.py +396 -0
  73. pyoq/generation/rendering/rows.py +79 -0
  74. pyoq/generation/rendering/source.py +121 -0
  75. pyoq/generation/rendering/tables.py +300 -0
  76. pyoq/generation/rendering/writes.py +514 -0
  77. pyoq/generation/validation.py +27 -0
  78. pyoq/generation/writer.py +184 -0
  79. pyoq/hydration/__init__.py +24 -0
  80. pyoq/hydration/engine.py +155 -0
  81. pyoq/hydration/identity.py +194 -0
  82. pyoq/hydration/plan.py +116 -0
  83. pyoq/migrations/__init__.py +9 -0
  84. pyoq/migrations/alembic.py +106 -0
  85. pyoq/migrations/hooks.py +75 -0
  86. pyoq/naming.py +261 -0
  87. pyoq/policies/__init__.py +47 -0
  88. pyoq/policies/bypass.py +122 -0
  89. pyoq/policies/governed.py +430 -0
  90. pyoq/policies/model.py +242 -0
  91. pyoq/policies/rewriting.py +263 -0
  92. pyoq/py.typed +1 -0
  93. pyoq/query/__init__.py +312 -0
  94. pyoq/query/aggregates.py +172 -0
  95. pyoq/query/arrays.py +65 -0
  96. pyoq/query/binding.py +52 -0
  97. pyoq/query/capabilities.py +317 -0
  98. pyoq/query/casts.py +73 -0
  99. pyoq/query/choices.py +185 -0
  100. pyoq/query/decoding.py +360 -0
  101. pyoq/query/documents.py +56 -0
  102. pyoq/query/execution/__init__.py +63 -0
  103. pyoq/query/execution/aio/__init__.py +31 -0
  104. pyoq/query/execution/aio/operations.py +228 -0
  105. pyoq/query/execution/aio/pooling.py +233 -0
  106. pyoq/query/execution/aio/streaming.py +161 -0
  107. pyoq/query/execution/aio/transactions.py +105 -0
  108. pyoq/query/execution/batch.py +96 -0
  109. pyoq/query/execution/binding_style.py +30 -0
  110. pyoq/query/execution/compilation.py +48 -0
  111. pyoq/query/execution/context.py +61 -0
  112. pyoq/query/execution/control.py +50 -0
  113. pyoq/query/execution/operations.py +224 -0
  114. pyoq/query/execution/planning.py +107 -0
  115. pyoq/query/execution/pooling.py +279 -0
  116. pyoq/query/execution/results.py +36 -0
  117. pyoq/query/execution/streaming.py +178 -0
  118. pyoq/query/execution/transactions.py +95 -0
  119. pyoq/query/expressions.py +1200 -0
  120. pyoq/query/fields.py +60 -0
  121. pyoq/query/mysql/__init__.py +59 -0
  122. pyoq/query/mysql/aio/__init__.py +38 -0
  123. pyoq/query/mysql/aio/commands.py +389 -0
  124. pyoq/query/mysql/aio/driver.py +196 -0
  125. pyoq/query/mysql/aio/executor.py +123 -0
  126. pyoq/query/mysql/aio/factory.py +26 -0
  127. pyoq/query/mysql/aio/operations.py +38 -0
  128. pyoq/query/mysql/aio/pool.py +53 -0
  129. pyoq/query/mysql/aio/transactions.py +313 -0
  130. pyoq/query/mysql/commands.py +354 -0
  131. pyoq/query/mysql/compiler.py +134 -0
  132. pyoq/query/mysql/context.py +20 -0
  133. pyoq/query/mysql/executor.py +126 -0
  134. pyoq/query/mysql/expressions.py +244 -0
  135. pyoq/query/mysql/factory.py +46 -0
  136. pyoq/query/mysql/health.py +66 -0
  137. pyoq/query/mysql/identifiers.py +9 -0
  138. pyoq/query/mysql/model.py +79 -0
  139. pyoq/query/mysql/operations.py +43 -0
  140. pyoq/query/mysql/parameters.py +69 -0
  141. pyoq/query/mysql/planning.py +20 -0
  142. pyoq/query/mysql/pool.py +67 -0
  143. pyoq/query/mysql/transactions.py +331 -0
  144. pyoq/query/mysql/writes.py +73 -0
  145. pyoq/query/nodes.py +750 -0
  146. pyoq/query/postgres/__init__.py +48 -0
  147. pyoq/query/postgres/aio/__init__.py +25 -0
  148. pyoq/query/postgres/aio/bulk.py +56 -0
  149. pyoq/query/postgres/aio/commands.py +264 -0
  150. pyoq/query/postgres/aio/executor.py +152 -0
  151. pyoq/query/postgres/aio/factory.py +26 -0
  152. pyoq/query/postgres/aio/operations.py +26 -0
  153. pyoq/query/postgres/aio/pool.py +40 -0
  154. pyoq/query/postgres/aio/transactions.py +295 -0
  155. pyoq/query/postgres/bulk.py +62 -0
  156. pyoq/query/postgres/commands.py +238 -0
  157. pyoq/query/postgres/compiler.py +114 -0
  158. pyoq/query/postgres/context.py +20 -0
  159. pyoq/query/postgres/executor.py +147 -0
  160. pyoq/query/postgres/expressions.py +311 -0
  161. pyoq/query/postgres/factory.py +24 -0
  162. pyoq/query/postgres/health.py +24 -0
  163. pyoq/query/postgres/identifiers.py +9 -0
  164. pyoq/query/postgres/model.py +81 -0
  165. pyoq/query/postgres/operations.py +25 -0
  166. pyoq/query/postgres/parameters.py +71 -0
  167. pyoq/query/postgres/planning.py +20 -0
  168. pyoq/query/postgres/pool.py +52 -0
  169. pyoq/query/postgres/transactions.py +295 -0
  170. pyoq/query/postgres/writes.py +37 -0
  171. pyoq/query/projections.py +105 -0
  172. pyoq/query/raw.py +90 -0
  173. pyoq/query/recursion.py +265 -0
  174. pyoq/query/rendering/__init__.py +1 -0
  175. pyoq/query/rendering/expressions.py +913 -0
  176. pyoq/query/rendering/identifiers.py +40 -0
  177. pyoq/query/rendering/projections.py +63 -0
  178. pyoq/query/rendering/queries.py +334 -0
  179. pyoq/query/rendering/sources.py +66 -0
  180. pyoq/query/rendering/writes.py +176 -0
  181. pyoq/query/results.py +459 -0
  182. pyoq/query/routines.py +196 -0
  183. pyoq/query/rows.py +156 -0
  184. pyoq/query/select.py +793 -0
  185. pyoq/query/select_nodes.py +277 -0
  186. pyoq/query/sources.py +236 -0
  187. pyoq/query/sqlite/__init__.py +43 -0
  188. pyoq/query/sqlite/commands.py +201 -0
  189. pyoq/query/sqlite/compiler.py +139 -0
  190. pyoq/query/sqlite/context.py +20 -0
  191. pyoq/query/sqlite/executor.py +119 -0
  192. pyoq/query/sqlite/expressions.py +224 -0
  193. pyoq/query/sqlite/factory.py +32 -0
  194. pyoq/query/sqlite/health.py +28 -0
  195. pyoq/query/sqlite/identifiers.py +9 -0
  196. pyoq/query/sqlite/model.py +73 -0
  197. pyoq/query/sqlite/operations.py +36 -0
  198. pyoq/query/sqlite/parameters.py +50 -0
  199. pyoq/query/sqlite/planning.py +20 -0
  200. pyoq/query/sqlite/pool.py +50 -0
  201. pyoq/query/sqlite/streaming.py +13 -0
  202. pyoq/query/sqlite/transactions.py +274 -0
  203. pyoq/query/sqlite/writes.py +35 -0
  204. pyoq/query/statements.py +27 -0
  205. pyoq/query/values.py +23 -0
  206. pyoq/query/vendor.py +162 -0
  207. pyoq/query/windows.py +424 -0
  208. pyoq/query/write_nodes.py +174 -0
  209. pyoq/query/writes.py +628 -0
  210. pyoq/relations/__init__.py +66 -0
  211. pyoq/relations/batching.py +219 -0
  212. pyoq/relations/derivation.py +111 -0
  213. pyoq/relations/fetching.py +355 -0
  214. pyoq/relations/graph.py +245 -0
  215. pyoq/relations/loading.py +74 -0
  216. pyoq/relations/model.py +75 -0
  217. pyoq/relations/planning.py +206 -0
  218. pyoq/runtime/__init__.py +9 -0
  219. pyoq/runtime/kernels.py +25 -0
  220. pyoq/runtime/python.py +43 -0
  221. pyoq/runtime/selection.py +73 -0
  222. pyoq/sanic/__init__.py +32 -0
  223. pyoq/sanic/scope.py +197 -0
  224. pyoq/sanic/workers.py +129 -0
  225. pyoq/schema/__init__.py +108 -0
  226. pyoq/schema/codec.py +711 -0
  227. pyoq/schema/models.py +604 -0
  228. pyoq/schema/mysql/__init__.py +16 -0
  229. pyoq/schema/mysql/connection.py +73 -0
  230. pyoq/schema/mysql/dsn.py +72 -0
  231. pyoq/schema/mysql/records.py +354 -0
  232. pyoq/schema/mysql/reflection.py +309 -0
  233. pyoq/schema/mysql/source.py +30 -0
  234. pyoq/schema/mysql/sql.py +128 -0
  235. pyoq/schema/mysql/types.py +105 -0
  236. pyoq/schema/postgres/__init__.py +13 -0
  237. pyoq/schema/postgres/connection.py +63 -0
  238. pyoq/schema/postgres/records.py +384 -0
  239. pyoq/schema/postgres/reflection.py +466 -0
  240. pyoq/schema/postgres/source.py +30 -0
  241. pyoq/schema/postgres/sql.py +246 -0
  242. pyoq/schema/postgres/types.py +98 -0
  243. pyoq/schema/registry.py +45 -0
  244. pyoq/schema/source.py +15 -0
  245. pyoq/schema/sqlite/__init__.py +6 -0
  246. pyoq/schema/sqlite/connection.py +54 -0
  247. pyoq/schema/sqlite/records.py +167 -0
  248. pyoq/schema/sqlite/reflection.py +393 -0
  249. pyoq/schema/sqlite/source.py +30 -0
  250. pyoq/schema/sqlite/sql.py +254 -0
  251. pyoq/schema/sqlite/types.py +74 -0
  252. pyoq/serving/__init__.py +23 -0
  253. pyoq/serving/databases.py +107 -0
  254. pyoq/serving/opening.py +331 -0
  255. pyoq/snapshots/__init__.py +20 -0
  256. pyoq/snapshots/drift.py +312 -0
  257. pyoq/snapshots/files.py +96 -0
  258. pyoq/snapshots/routing.py +40 -0
  259. pyoq/snapshots/source.py +33 -0
  260. pyoq/tracing/__init__.py +5 -0
  261. pyoq/tracing/spans.py +89 -0
  262. pyoq/unset.py +14 -0
  263. pyoq_sql-1.0.2.dist-info/METADATA +3050 -0
  264. pyoq_sql-1.0.2.dist-info/RECORD +267 -0
  265. pyoq_sql-1.0.2.dist-info/WHEEL +4 -0
  266. pyoq_sql-1.0.2.dist-info/entry_points.txt +3 -0
  267. pyoq_sql-1.0.2.dist-info/licenses/LICENSE +373 -0
@@ -0,0 +1,40 @@
1
+ """Dialect identifier quoting and qualification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from pyoq.errors import UnsupportedQueryError
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class IdentifierQuoting:
12
+ dialect: str
13
+ delimiter: str = '"'
14
+ allow_catalog: bool = False
15
+
16
+ def quote(self, value: str) -> str:
17
+ escaped = value.replace(self.delimiter, self.delimiter * 2)
18
+ return f"{self.delimiter}{escaped}{self.delimiter}"
19
+
20
+ def qualified(
21
+ self,
22
+ name: str,
23
+ /,
24
+ *,
25
+ table: str | None = None,
26
+ schema: str | None = None,
27
+ catalog: str | None = None,
28
+ ) -> str:
29
+ if catalog is not None and not self.allow_catalog:
30
+ message = f"{self.dialect} does not support catalog-qualified identifiers"
31
+ raise UnsupportedQueryError(message)
32
+ parts = tuple(
33
+ self.quote(part)
34
+ for part in (catalog, schema, table, name)
35
+ if part is not None
36
+ )
37
+ return ".".join(parts)
38
+
39
+
40
+ __all__ = ("IdentifierQuoting",)
@@ -0,0 +1,63 @@
1
+ """Shared projection rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol
6
+
7
+ from pyoq.query.nodes import ExpressionNode, WindowSpecificationNode
8
+ from pyoq.query.rendering.identifiers import IdentifierQuoting
9
+ from pyoq.query.rendering.sources import OrderTextRenderer, QueryTextRenderer
10
+ from pyoq.query.select_nodes import JsonElementsSourceNode, ProjectionNode
11
+
12
+
13
+ class ExpressionText(Protocol):
14
+ def compile(self, node: ExpressionNode) -> str: ...
15
+
16
+ def compile_window_specification(self, node: WindowSpecificationNode) -> str:
17
+ """How a window divides and orders the rows it covers."""
18
+ ...
19
+
20
+ def render_json_elements(self, node: JsonElementsSourceNode) -> str:
21
+ """A JSON array read as rows."""
22
+ ...
23
+
24
+ def routine_name(
25
+ self,
26
+ name: str,
27
+ schema: str | None = None,
28
+ catalog: str | None = None,
29
+ ) -> str:
30
+ """A routine's name as this dialect writes it."""
31
+ ...
32
+
33
+ def declares_windows(self, names: frozenset[str], /) -> frozenset[str]:
34
+ """The windows named by the query being written, and the ones before it."""
35
+ ...
36
+
37
+ def reads_queries_with(self, render_query: QueryTextRenderer, /) -> None:
38
+ """How to write a query that appears inside an expression."""
39
+
40
+ def orders_rows_with(self, render_order: OrderTextRenderer, /) -> None:
41
+ """How to write one term of an ORDER BY, direction and nulls included."""
42
+
43
+
44
+ def compile_projection(
45
+ expressions: ExpressionText,
46
+ node: ProjectionNode,
47
+ quoting: IdentifierQuoting,
48
+ ) -> str:
49
+ rendered = expressions.compile(node.expression)
50
+ if node.alias is None:
51
+ return rendered
52
+ return f"{rendered} AS {quoting.quote(node.alias)}"
53
+
54
+
55
+ def compile_projections(
56
+ expressions: ExpressionText,
57
+ nodes: tuple[ProjectionNode, ...],
58
+ quoting: IdentifierQuoting,
59
+ ) -> str:
60
+ return ", ".join(compile_projection(expressions, node, quoting) for node in nodes)
61
+
62
+
63
+ __all__ = ("ExpressionText", "compile_projection", "compile_projections")
@@ -0,0 +1,334 @@
1
+ """Shared SELECT and compound query rendering with dialect hooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import Mapping
7
+
8
+ from pyoq.errors import CompilationError, UnsupportedQueryError
9
+ from pyoq.query.nodes import FieldNode, NamedWindowNode
10
+ from pyoq.query.rendering.identifiers import IdentifierQuoting
11
+ from pyoq.query.rendering.projections import ExpressionText, compile_projection
12
+ from pyoq.query.rendering.sources import SourceRenderer
13
+ from pyoq.query.select_nodes import (
14
+ CommonTableNode,
15
+ JoinKind,
16
+ JoinNode,
17
+ LockNode,
18
+ LockStrength,
19
+ LockWait,
20
+ NullOrder,
21
+ OrderDirection,
22
+ OrderNode,
23
+ QueryNode,
24
+ SelectNode,
25
+ SetNode,
26
+ SetOperator,
27
+ )
28
+
29
+ _LOCK_STRENGTHS: Mapping[LockStrength, str] = {
30
+ LockStrength.UPDATE: "UPDATE",
31
+ LockStrength.NO_KEY_UPDATE: "NO KEY UPDATE",
32
+ LockStrength.SHARE: "SHARE",
33
+ LockStrength.KEY_SHARE: "KEY SHARE",
34
+ }
35
+ """Verified against a running PostgreSQL server, which has all four."""
36
+
37
+ _LOCK_WAITS: Mapping[LockWait, str] = {
38
+ LockWait.NOWAIT: "NOWAIT",
39
+ LockWait.SKIP_LOCKED: "SKIP LOCKED",
40
+ }
41
+ """Waiting is what a lock does unless it is told otherwise, so it is unwritten."""
42
+
43
+ _ORDER_DIRECTIONS = {
44
+ OrderDirection.ASCENDING: "ASC",
45
+ OrderDirection.DESCENDING: "DESC",
46
+ }
47
+ _NULL_ORDERS = {
48
+ NullOrder.FIRST: "FIRST",
49
+ NullOrder.LAST: "LAST",
50
+ }
51
+ _SET_OPERATORS = {
52
+ SetOperator.UNION: "UNION",
53
+ SetOperator.UNION_ALL: "UNION ALL",
54
+ SetOperator.INTERSECT: "INTERSECT",
55
+ SetOperator.EXCEPT: "EXCEPT",
56
+ }
57
+ _JOIN_NAMES = {
58
+ JoinKind.INNER: "INNER JOIN",
59
+ JoinKind.LEFT: "LEFT JOIN",
60
+ JoinKind.RIGHT: "RIGHT JOIN",
61
+ JoinKind.FULL: "FULL JOIN",
62
+ }
63
+
64
+ _NATURAL_NAMES = {
65
+ JoinKind.INNER: "NATURAL JOIN",
66
+ JoinKind.LEFT: "NATURAL LEFT JOIN",
67
+ JoinKind.RIGHT: "NATURAL RIGHT JOIN",
68
+ JoinKind.FULL: "NATURAL FULL JOIN",
69
+ }
70
+ """A natural join is inner unless it says otherwise, and reads that way."""
71
+
72
+
73
+ class QueryRenderer(ABC):
74
+ __slots__ = ("_expressions", "_sources")
75
+
76
+ def __init__(self, expressions: ExpressionText) -> None:
77
+ self._expressions = expressions
78
+ self._sources = SourceRenderer(
79
+ self.compile,
80
+ self.quoting,
81
+ self.require_lateral_sources,
82
+ expressions.render_json_elements,
83
+ )
84
+ expressions.reads_queries_with(self.compile)
85
+ expressions.orders_rows_with(self._compile_order)
86
+
87
+ @property
88
+ @abstractmethod
89
+ def dialect(self) -> str: ...
90
+
91
+ @property
92
+ @abstractmethod
93
+ def quoting(self) -> IdentifierQuoting: ...
94
+
95
+ @abstractmethod
96
+ def require_join_capability(self, kind: JoinKind) -> None: ...
97
+
98
+ @abstractmethod
99
+ def require_recursive_common_tables(self) -> None: ...
100
+
101
+ @abstractmethod
102
+ def require_lateral_sources(self) -> None: ...
103
+
104
+ @abstractmethod
105
+ def append_pagination(
106
+ self,
107
+ fragments: list[str],
108
+ limit: int | None,
109
+ offset: int | None,
110
+ ) -> None: ...
111
+
112
+ def compile(self, node: QueryNode) -> str:
113
+ if isinstance(node, SelectNode):
114
+ return self._compile_select(node)
115
+ return self._compile_set(node)
116
+
117
+ def _compile_select(self, node: SelectNode) -> str:
118
+ previous = self._expressions.declares_windows(
119
+ frozenset(declared.name for declared in node.windows)
120
+ )
121
+ try:
122
+ return self._compile_select_clauses(node)
123
+ finally:
124
+ self._expressions.declares_windows(previous)
125
+
126
+ def _compile_select_clauses(self, node: SelectNode) -> str:
127
+ fragments: list[str] = []
128
+ if node.common_tables:
129
+ fragments.append(self._compile_common_tables(node.common_tables))
130
+ projections = (
131
+ "*"
132
+ if node.every_column
133
+ else ", ".join(
134
+ compile_projection(self._expressions, projection, self.quoting)
135
+ for projection in node.projections
136
+ )
137
+ )
138
+ distinct = " DISTINCT" if node.distinct else ""
139
+ fragments.append(f"SELECT{distinct} {projections}")
140
+ if node.source is not None:
141
+ fragments.append(f"FROM {self._sources.compile(node.source)}")
142
+ fragments.extend(self._compile_join(join) for join in node.joins)
143
+ where = self._compile_where(node)
144
+ if where is not None:
145
+ fragments.append(where)
146
+ if node.groups:
147
+ groups = ", ".join(
148
+ self._expressions.compile(group) for group in node.groups
149
+ )
150
+ fragments.append(f"GROUP BY {groups}")
151
+ if node.having is not None:
152
+ fragments.append(f"HAVING {self._expressions.compile(node.having)}")
153
+ if node.windows:
154
+ fragments.append(self._compile_windows(node.windows))
155
+ self._append_ordering(fragments, node.orders)
156
+ self.append_pagination(fragments, node.limit, node.offset)
157
+ if node.lock is not None:
158
+ fragments.append(self.locking(node.lock))
159
+ return " ".join(fragments)
160
+
161
+ def _compile_windows(self, nodes: tuple[NamedWindowNode, ...]) -> str:
162
+ """The windows this query names, written once for every reader."""
163
+ declarations = ", ".join(
164
+ f"{declared.name} AS "
165
+ f"({self._expressions.compile_window_specification(declared.specification)})"
166
+ for declared in nodes
167
+ )
168
+ return f"WINDOW {declarations}"
169
+
170
+ def _compile_set(self, node: SetNode) -> str:
171
+ left = self._compile_compound_operand(node.left)
172
+ right = self._compile_compound_operand(node.right)
173
+ operator = _SET_OPERATORS.get(node.operator)
174
+ if operator is None:
175
+ message = f"unsupported {self.dialect} set operator: {node.operator}"
176
+ raise UnsupportedQueryError(message)
177
+ fragments = [left, operator, right]
178
+ self._append_set_ordering(fragments, node.orders)
179
+ self.append_pagination(fragments, node.limit, node.offset)
180
+ return " ".join(fragments)
181
+
182
+ def _compile_compound_operand(self, node: QueryNode) -> str:
183
+ if isinstance(node, SelectNode) and _is_plain_compound_operand(node):
184
+ return self.compile(node)
185
+ nested = f"({self.compile(node)})"
186
+ return " ".join(("SELECT", "*", "FROM", nested))
187
+
188
+ def _compile_where(self, node: SelectNode) -> str | None:
189
+ """The condition and whatever the semi joins ask, joined by AND."""
190
+ parts = [
191
+ self._expressions.compile(expression)
192
+ for expression in (node.condition, *node.matches)
193
+ if expression is not None
194
+ ]
195
+ if not parts:
196
+ return None
197
+ return "WHERE " + " AND ".join(parts)
198
+
199
+ def _compile_join(self, node: JoinNode) -> str:
200
+ self.require_join_capability(node.kind)
201
+ source = self._sources.compile(node.source)
202
+ if node.kind is JoinKind.CROSS:
203
+ return f"CROSS JOIN {source}"
204
+ join_name = _JOIN_NAMES.get(node.kind)
205
+ if join_name is None:
206
+ message = f"unsupported {self.dialect} join kind: {node.kind}"
207
+ raise UnsupportedQueryError(message)
208
+ if node.natural:
209
+ return f"{_NATURAL_NAMES[node.kind]} {source}"
210
+ if node.using:
211
+ columns = ", ".join(self.quoting.quote(name) for name in node.using)
212
+ return f"{join_name} {source} USING ({columns})"
213
+ if node.condition is None:
214
+ message = f"qualified {self.dialect} join requires an ON condition"
215
+ raise CompilationError(message)
216
+ condition = self._expressions.compile(node.condition)
217
+ return f"{join_name} {source} ON {condition}"
218
+
219
+ def _compile_common_tables(
220
+ self,
221
+ common_tables: tuple[CommonTableNode, ...],
222
+ ) -> str:
223
+ recursive = any(common_table.recursive for common_table in common_tables)
224
+ if recursive:
225
+ self.require_recursive_common_tables()
226
+ keyword = "WITH RECURSIVE" if recursive else "WITH"
227
+ definitions = ", ".join(
228
+ self._compile_common_table(common_table) for common_table in common_tables
229
+ )
230
+ return f"{keyword} {definitions}"
231
+
232
+ def _compile_common_table(self, node: CommonTableNode) -> str:
233
+ columns = ""
234
+ if node.columns:
235
+ rendered = ", ".join(self.quoting.quote(column) for column in node.columns)
236
+ columns = f" ({rendered})"
237
+ name = self.quoting.quote(node.name)
238
+ return f"{name}{columns} AS ({self.compile(node.query)})"
239
+
240
+ def _append_ordering(
241
+ self,
242
+ fragments: list[str],
243
+ orders: tuple[OrderNode, ...],
244
+ ) -> None:
245
+ if not orders:
246
+ return
247
+ rendered = ", ".join(self._compile_order(order) for order in orders)
248
+ fragments.append(f"ORDER BY {rendered}")
249
+
250
+ def _append_set_ordering(
251
+ self,
252
+ fragments: list[str],
253
+ orders: tuple[OrderNode, ...],
254
+ ) -> None:
255
+ if not orders:
256
+ return
257
+ rendered = ", ".join(self._compile_set_order(order) for order in orders)
258
+ fragments.append(f"ORDER BY {rendered}")
259
+
260
+ def locking(self, node: LockNode) -> str:
261
+ """How this dialect holds the rows a statement reads.
262
+
263
+ A dialect that cannot hold rows at all says so, because returning
264
+ them unlocked would answer a question nobody asked.
265
+ """
266
+ strength = self.lock_strength_sql(node.strength)
267
+ clauses = [f"FOR {strength}"]
268
+ if node.tables:
269
+ names = ", ".join(self.quoting.quote(table) for table in node.tables)
270
+ clauses.append(f"OF {names}")
271
+ wait = _LOCK_WAITS.get(node.wait)
272
+ if wait is not None:
273
+ clauses.append(wait)
274
+ return " ".join(clauses)
275
+
276
+ def lock_strength_sql(self, strength: LockStrength) -> str:
277
+ """What this dialect calls a lock of this strength."""
278
+ name = _LOCK_STRENGTHS.get(strength)
279
+ if name is None:
280
+ message = f"unsupported {self.dialect} lock strength: {strength}"
281
+ raise UnsupportedQueryError(message)
282
+ return name
283
+
284
+ def _compile_order(self, node: OrderNode) -> str:
285
+ return self._ordered(self._expressions.compile(node.expression), node)
286
+
287
+ def _compile_set_order(self, node: OrderNode) -> str:
288
+ """A set operation orders by what it returned, not by where it came from.
289
+
290
+ Its operands may read different tables, so a table-qualified name means
291
+ nothing once they are combined and a database refuses it. Only a column
292
+ the operation selected can be named here.
293
+ """
294
+ if not isinstance(node.expression, FieldNode):
295
+ message = (
296
+ f"a {self.dialect} set operation can only be ordered by a "
297
+ f"column it selected"
298
+ )
299
+ raise UnsupportedQueryError(message)
300
+ return self._ordered(self.quoting.quote(node.expression.name), node)
301
+
302
+ def _ordered(self, expression: str, node: OrderNode) -> str:
303
+ direction = _ORDER_DIRECTIONS.get(node.direction)
304
+ if direction is None:
305
+ message = f"unsupported {self.dialect} order direction: {node.direction}"
306
+ raise UnsupportedQueryError(message)
307
+ if node.nulls is None:
308
+ return f"{expression} {direction}"
309
+ return self.null_ordered(expression, direction, node.nulls)
310
+
311
+ def null_ordered(
312
+ self,
313
+ expression: str,
314
+ direction: str,
315
+ nulls: NullOrder,
316
+ ) -> str:
317
+ """Where this dialect puts nulls when it is told where to put them."""
318
+ placement = _NULL_ORDERS.get(nulls)
319
+ if placement is None:
320
+ message = f"unsupported {self.dialect} null order: {nulls}"
321
+ raise UnsupportedQueryError(message)
322
+ return f"{expression} {direction} NULLS {placement}"
323
+
324
+
325
+ def _is_plain_compound_operand(node: SelectNode) -> bool:
326
+ return not (
327
+ node.common_tables
328
+ or node.orders
329
+ or node.limit is not None
330
+ or node.offset is not None
331
+ )
332
+
333
+
334
+ __all__ = ("QueryRenderer",)
@@ -0,0 +1,66 @@
1
+ """Shared query-source rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ from pyoq.query.rendering.identifiers import IdentifierQuoting
8
+ from pyoq.query.select_nodes import (
9
+ JsonElementsSourceNode,
10
+ OrderNode,
11
+ QueryNode,
12
+ SourceNode,
13
+ SubquerySourceNode,
14
+ TableSourceNode,
15
+ )
16
+
17
+ QueryTextRenderer = Callable[[QueryNode], str]
18
+ OrderTextRenderer = Callable[[OrderNode], str]
19
+ JsonElementsTextRenderer = Callable[[JsonElementsSourceNode], str]
20
+
21
+
22
+ class SourceRenderer:
23
+ __slots__ = (
24
+ "_quoting",
25
+ "_render_elements",
26
+ "_render_query",
27
+ "_require_lateral",
28
+ )
29
+
30
+ def __init__(
31
+ self,
32
+ render_query: QueryTextRenderer,
33
+ quoting: IdentifierQuoting,
34
+ require_lateral: Callable[[], None],
35
+ render_elements: JsonElementsTextRenderer,
36
+ ) -> None:
37
+ self._render_query = render_query
38
+ self._quoting = quoting
39
+ self._require_lateral = require_lateral
40
+ self._render_elements = render_elements
41
+
42
+ def compile(self, node: SourceNode) -> str:
43
+ if isinstance(node, TableSourceNode):
44
+ return self._compile_table(node)
45
+ if isinstance(node, JsonElementsSourceNode):
46
+ return self._render_elements(node)
47
+ if isinstance(node, SubquerySourceNode):
48
+ query = self._render_query(node.query)
49
+ if node.lateral:
50
+ self._require_lateral()
51
+ lateral = "LATERAL " if node.lateral else ""
52
+ return f"{lateral}({query}) AS {self._quoting.quote(node.alias)}"
53
+ return self._quoting.quote(node.name)
54
+
55
+ def _compile_table(self, node: TableSourceNode) -> str:
56
+ table = self._quoting.qualified(
57
+ node.name,
58
+ schema=node.schema,
59
+ catalog=node.catalog,
60
+ )
61
+ if node.alias is None:
62
+ return table
63
+ return f"{table} AS {self._quoting.quote(node.alias)}"
64
+
65
+
66
+ __all__ = ("JsonElementsTextRenderer", "QueryTextRenderer", "SourceRenderer")
@@ -0,0 +1,176 @@
1
+ """Shared write statement rendering with dialect hooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+ from pyoq.errors import CompilationError
8
+ from pyoq.query.execution import StatementKind
9
+ from pyoq.query.nodes import ExpressionNode
10
+ from pyoq.query.rendering.identifiers import IdentifierQuoting
11
+ from pyoq.query.rendering.projections import ExpressionText, compile_projections
12
+ from pyoq.query.select_nodes import ProjectionNode
13
+ from pyoq.query.write_nodes import (
14
+ AssignmentNode,
15
+ CallNode,
16
+ ConflictAction,
17
+ ConflictNode,
18
+ DeleteNode,
19
+ InsertNode,
20
+ UpdateNode,
21
+ WriteNode,
22
+ WriteTargetNode,
23
+ )
24
+
25
+
26
+ class WriteRenderer(ABC):
27
+ __slots__ = ("_expressions",)
28
+
29
+ def __init__(self, expressions: ExpressionText) -> None:
30
+ self._expressions = expressions
31
+
32
+ @property
33
+ @abstractmethod
34
+ def quoting(self) -> IdentifierQuoting: ...
35
+
36
+ def qualified_target(self, node: WriteTargetNode) -> str:
37
+ return self.quoting.qualified(
38
+ node.name,
39
+ schema=node.schema,
40
+ catalog=node.catalog,
41
+ )
42
+
43
+ @abstractmethod
44
+ def require_returning(self) -> None: ...
45
+
46
+ @abstractmethod
47
+ def require_conflict_resolution(self) -> None: ...
48
+
49
+ def compile(self, node: WriteNode) -> tuple[str, StatementKind]:
50
+ if isinstance(node, InsertNode):
51
+ return self._compile_insert(node), StatementKind.INSERT
52
+ if isinstance(node, UpdateNode):
53
+ return self._compile_update(node), StatementKind.UPDATE
54
+ return self._compile_delete(node), StatementKind.DELETE
55
+
56
+ def compile_call(self, node: CallNode) -> str:
57
+ """How this dialect runs a stored procedure.
58
+
59
+ A dialect that keeps no procedures says so, because there is nothing
60
+ for it to run and nothing to fall back on.
61
+ """
62
+ arguments = ", ".join(
63
+ self._expressions.compile(argument) for argument in node.arguments
64
+ )
65
+ name = self._expressions.routine_name(node.name, node.schema, node.catalog)
66
+ return f"CALL {name}({arguments})"
67
+
68
+ def _compile_insert(self, node: InsertNode) -> str:
69
+ if not node.rows:
70
+ message = "INSERT requires at least one row of values"
71
+ raise CompilationError(message)
72
+ columns = ", ".join(self.quoting.quote(column) for column in node.columns)
73
+ fragments = [
74
+ "INSERT INTO",
75
+ self.qualified_target(node.target),
76
+ f"({columns})",
77
+ "VALUES",
78
+ ", ".join(self._compile_row(row) for row in node.rows),
79
+ ]
80
+ alias = self.insert_row_alias(node)
81
+ if alias is not None:
82
+ fragments.append(f"AS {self.quoting.quote(alias)}")
83
+ if node.conflict is not None:
84
+ fragments.append(self.render_conflict(node.conflict))
85
+ self._append_returning(fragments, node.returning)
86
+ return " ".join(fragments)
87
+
88
+ def _compile_update(self, node: UpdateNode) -> str:
89
+ if not node.assignments:
90
+ message = "UPDATE requires at least one assignment"
91
+ raise CompilationError(message)
92
+ assignments = ", ".join(
93
+ self._compile_assignment(assignment) for assignment in node.assignments
94
+ )
95
+ fragments = ["UPDATE", self.qualified_target(node.target), "SET", assignments]
96
+ self._append_scope(fragments, node.condition, node.unconditional, "UPDATE")
97
+ self._append_returning(fragments, node.returning)
98
+ return " ".join(fragments)
99
+
100
+ def _compile_delete(self, node: DeleteNode) -> str:
101
+ fragments = ["DELETE FROM", self.qualified_target(node.target)]
102
+ self._append_scope(fragments, node.condition, node.unconditional, "DELETE")
103
+ self._append_returning(fragments, node.returning)
104
+ return " ".join(fragments)
105
+
106
+ def insert_row_alias(self, node: InsertNode) -> str | None:
107
+ del node
108
+ return None
109
+
110
+ def compile_assignment(self, node: AssignmentNode) -> str:
111
+ return self._compile_assignment(node)
112
+
113
+ def render_conflict(self, node: ConflictNode) -> str:
114
+ self.require_conflict_resolution()
115
+ if node.action is ConflictAction.UPDATE and not node.targets:
116
+ message = "conflict resolution that updates requires a conflict target"
117
+ raise CompilationError(message)
118
+ fragments = ["ON CONFLICT"]
119
+ if node.targets:
120
+ targets = ", ".join(self.quoting.quote(target) for target in node.targets)
121
+ fragments.append(f"({targets})")
122
+ if node.action is ConflictAction.NOTHING:
123
+ fragments.append("DO NOTHING")
124
+ return " ".join(fragments)
125
+ if not node.assignments:
126
+ message = "conflict resolution that updates requires an assignment"
127
+ raise CompilationError(message)
128
+ assignments = ", ".join(
129
+ self._compile_assignment(assignment) for assignment in node.assignments
130
+ )
131
+ fragments.extend(("DO UPDATE SET", assignments))
132
+ if node.condition is not None:
133
+ fragments.append("WHERE")
134
+ fragments.append(self._expressions.compile(node.condition))
135
+ return " ".join(fragments)
136
+
137
+ def _compile_assignment(self, node: AssignmentNode) -> str:
138
+ value = self._expressions.compile(node.value)
139
+ return f"{self.quoting.quote(node.column)} = {value}"
140
+
141
+ def _compile_row(self, row: tuple[ExpressionNode, ...]) -> str:
142
+ rendered = ", ".join(self._expressions.compile(value) for value in row)
143
+ return f"({rendered})"
144
+
145
+ def _append_scope(
146
+ self,
147
+ fragments: list[str],
148
+ condition: ExpressionNode | None,
149
+ unconditional: bool,
150
+ clause: str,
151
+ ) -> None:
152
+ if condition is not None:
153
+ fragments.append("WHERE")
154
+ fragments.append(self._expressions.compile(condition))
155
+ return
156
+ if not unconditional:
157
+ message = (
158
+ f"{clause} without a WHERE condition must opt in through all_rows()"
159
+ )
160
+ raise CompilationError(message)
161
+
162
+ def _append_returning(
163
+ self,
164
+ fragments: list[str],
165
+ returning: tuple[ProjectionNode, ...],
166
+ ) -> None:
167
+ if not returning:
168
+ return
169
+ self.require_returning()
170
+ fragments.append("RETURNING")
171
+ fragments.append(
172
+ compile_projections(self._expressions, returning, self.quoting)
173
+ )
174
+
175
+
176
+ __all__ = ("WriteRenderer",)