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,134 @@
1
+ """MySQL query and statement compiler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pyoq.errors import UnsupportedQueryError
6
+ from pyoq.query.execution import CompiledQuery, StatementKind
7
+ from pyoq.query.mysql.context import MySQLCompilationContext
8
+ from pyoq.query.mysql.expressions import MySQLExpressionCompiler
9
+ from pyoq.query.mysql.identifiers import QUOTING
10
+ from pyoq.query.mysql.model import MySQLCapabilities
11
+ from pyoq.query.mysql.writes import MySQLWriteCompilation
12
+ from pyoq.query.rendering.identifiers import IdentifierQuoting
13
+ from pyoq.query.rendering.queries import QueryRenderer
14
+ from pyoq.query.select_nodes import (
15
+ JoinKind,
16
+ LockStrength,
17
+ NullOrder,
18
+ SelectNode,
19
+ SetNode,
20
+ )
21
+ from pyoq.query.statements import StatementNode, StatementProvider
22
+ from pyoq.query.write_nodes import CallNode, DeleteNode, InsertNode, UpdateNode
23
+
24
+ _STATEMENT_NODES = (
25
+ SelectNode,
26
+ SetNode,
27
+ InsertNode,
28
+ UpdateNode,
29
+ DeleteNode,
30
+ CallNode,
31
+ )
32
+ _UNBOUNDED_ROWS = 18446744073709551615
33
+
34
+
35
+ class MySQLCompiler:
36
+ __slots__ = ("_capabilities",)
37
+
38
+ def __init__(self, capabilities: MySQLCapabilities | None = None) -> None:
39
+ self._capabilities = capabilities or MySQLCapabilities()
40
+
41
+ @property
42
+ def capabilities(self) -> MySQLCapabilities:
43
+ return self._capabilities
44
+
45
+ def compile(
46
+ self,
47
+ statement: StatementProvider | StatementNode,
48
+ /,
49
+ ) -> CompiledQuery:
50
+ node = statement if isinstance(statement, _STATEMENT_NODES) else statement.node
51
+ context = MySQLCompilationContext(self.capabilities)
52
+ if isinstance(node, CallNode):
53
+ sql = MySQLWriteCompilation(context).compile_call(node)
54
+ return context.result(sql, StatementKind.CALL, node)
55
+ if isinstance(node, (InsertNode, UpdateNode, DeleteNode)):
56
+ sql, statement_kind = MySQLWriteCompilation(context).compile(node)
57
+ return context.result(sql, statement_kind, node)
58
+ sql = _MySQLQueryCompilation(context).compile(node)
59
+ return context.result(sql, StatementKind.SELECT, node)
60
+
61
+
62
+ class _MySQLQueryCompilation(QueryRenderer):
63
+ __slots__ = ("_context",)
64
+
65
+ def __init__(self, context: MySQLCompilationContext) -> None:
66
+ self._context = context
67
+ super().__init__(MySQLExpressionCompiler(context))
68
+
69
+ @property
70
+ def dialect(self) -> str:
71
+ return "MySQL"
72
+
73
+ @property
74
+ def quoting(self) -> IdentifierQuoting:
75
+ return QUOTING
76
+
77
+ def require_join_capability(self, kind: JoinKind) -> None:
78
+ if kind is JoinKind.RIGHT and not self._context.capabilities.right_join:
79
+ message = "MySQL RIGHT JOIN is disabled by compiler capabilities"
80
+ raise UnsupportedQueryError(message)
81
+ if kind is JoinKind.FULL and not self._context.capabilities.full_join:
82
+ message = "MySQL does not support FULL JOIN"
83
+ raise UnsupportedQueryError(message)
84
+
85
+ def lock_strength_sql(self, strength: LockStrength) -> str:
86
+ """MySQL holds a row or shares it, and has no lock weaker than either."""
87
+ if strength in {LockStrength.NO_KEY_UPDATE, LockStrength.KEY_SHARE}:
88
+ message = (
89
+ f"MySQL has no {strength.value.replace('-', ' ')} lock; "
90
+ f"lock for update or for share instead"
91
+ )
92
+ raise UnsupportedQueryError(message)
93
+ return super().lock_strength_sql(strength)
94
+
95
+ def null_ordered(
96
+ self,
97
+ expression: str,
98
+ direction: str,
99
+ nulls: NullOrder,
100
+ ) -> str:
101
+ """MySQL has no NULLS clause, so it is told where nulls go by order.
102
+
103
+ It sorts nulls first in either direction, so ordering on whether a
104
+ value is null at all, before ordering on the value, puts them where
105
+ they were asked to go.
106
+ """
107
+ nulls_first = "DESC" if nulls is NullOrder.FIRST else "ASC"
108
+ return f"{expression} IS NULL {nulls_first}, {expression} {direction}"
109
+
110
+ def require_recursive_common_tables(self) -> None:
111
+ if not self._context.capabilities.recursive_common_tables:
112
+ message = "MySQL recursive common tables are disabled"
113
+ raise UnsupportedQueryError(message)
114
+
115
+ def require_lateral_sources(self) -> None:
116
+ if not self._context.capabilities.lateral_sources:
117
+ message = "MySQL has no lateral sources"
118
+ raise UnsupportedQueryError(message)
119
+
120
+ def append_pagination(
121
+ self,
122
+ fragments: list[str],
123
+ limit: int | None,
124
+ offset: int | None,
125
+ ) -> None:
126
+ if limit is not None:
127
+ fragments.append(f"LIMIT {self._context.bind_page_value(limit)}")
128
+ elif offset is not None:
129
+ fragments.append(f"LIMIT {_UNBOUNDED_ROWS}")
130
+ if offset is not None:
131
+ fragments.append(f"OFFSET {self._context.bind_page_value(offset)}")
132
+
133
+
134
+ __all__ = ("MySQLCompiler",)
@@ -0,0 +1,20 @@
1
+ """MySQL compilation context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pyoq.query.execution import CompilationContext
6
+ from pyoq.query.mysql.model import MySQLCapabilities
7
+
8
+
9
+ class MySQLCompilationContext(CompilationContext):
10
+ __slots__ = ("capabilities",)
11
+
12
+ def __init__(self, capabilities: MySQLCapabilities) -> None:
13
+ super().__init__(
14
+ capabilities.maximum_parameters,
15
+ capabilities.parameter_style,
16
+ )
17
+ self.capabilities = capabilities
18
+
19
+
20
+ __all__ = ("MySQLCompilationContext",)
@@ -0,0 +1,126 @@
1
+ """Synchronous typed MySQL query execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import TypeVar
7
+
8
+ from pymysql.cursors import Cursor
9
+
10
+ from pyoq.query.execution import (
11
+ CompiledQuery,
12
+ ExecutionControl,
13
+ RowStream,
14
+ StatementKind,
15
+ StreamPolicy,
16
+ StreamResource,
17
+ )
18
+ from pyoq.query.execution.pooling import ConnectionLease
19
+ from pyoq.query.mysql.commands import (
20
+ Connection,
21
+ ExecutionEnvironment,
22
+ FailureHandler,
23
+ open_cursor,
24
+ run_sql,
25
+ )
26
+ from pyoq.query.mysql.compiler import MySQLCompiler
27
+ from pyoq.query.mysql.operations import MySQLQueryOperations
28
+ from pyoq.query.mysql.parameters import adapt_parameters
29
+ from pyoq.query.mysql.pool import MySQLPool
30
+ from pyoq.query.mysql.transactions import MySQLTransaction, TransactionMode
31
+ from pyoq.query.select import Query
32
+
33
+ OperationResult = TypeVar("OperationResult")
34
+ ResultRow = TypeVar("ResultRow")
35
+
36
+
37
+ class MySQLExecutor(MySQLQueryOperations):
38
+ __slots__ = ("_pool",)
39
+
40
+ def __init__(
41
+ self,
42
+ pool: MySQLPool,
43
+ compiler: MySQLCompiler | None = None,
44
+ ) -> None:
45
+ self._pool = pool
46
+ super().__init__(compiler or MySQLCompiler())
47
+
48
+ @property
49
+ def pool(self) -> MySQLPool:
50
+ return self._pool
51
+
52
+ def stream(
53
+ self,
54
+ query: Query[ResultRow],
55
+ /,
56
+ *,
57
+ policy: StreamPolicy | None = None,
58
+ ) -> RowStream[ResultRow]:
59
+ selected = policy or StreamPolicy()
60
+ statement = self.compiler.compile(query)
61
+ return RowStream(
62
+ lambda: self._open_stream(statement, selected),
63
+ selected,
64
+ statement.row_types,
65
+ )
66
+
67
+ def transaction(
68
+ self,
69
+ mode: TransactionMode = TransactionMode.REPEATABLE_READ,
70
+ *,
71
+ control: ExecutionControl | None = None,
72
+ ) -> MySQLTransaction:
73
+ return MySQLTransaction(self.pool, self.compiler, mode, control)
74
+
75
+ def _environment(
76
+ self,
77
+ connection: Connection,
78
+ lease: ConnectionLease[Connection],
79
+ ) -> ExecutionEnvironment:
80
+ return ExecutionEnvironment(
81
+ connection,
82
+ self.pool.connection_factory,
83
+ FailureHandler(lease.invalidate),
84
+ )
85
+
86
+ def _run(
87
+ self,
88
+ statement: CompiledQuery,
89
+ operation: Callable[[Cursor], OperationResult],
90
+ control: ExecutionControl | None,
91
+ ) -> OperationResult:
92
+ parameters = adapt_parameters(statement.parameters)
93
+ lease = self.pool.checkout()
94
+ with lease as connection:
95
+ return run_sql(
96
+ self._environment(connection, lease),
97
+ statement.sql,
98
+ parameters,
99
+ operation,
100
+ control,
101
+ one_result_set=statement.statement_kind is StatementKind.CALL,
102
+ )
103
+
104
+ def _open_stream(
105
+ self,
106
+ statement: CompiledQuery,
107
+ policy: StreamPolicy,
108
+ ) -> StreamResource:
109
+ parameters = adapt_parameters(statement.parameters)
110
+ lease = self.pool.checkout()
111
+ connection = lease.__enter__()
112
+ try:
113
+ session = open_cursor(
114
+ self._environment(connection, lease),
115
+ statement.sql,
116
+ parameters,
117
+ policy.control,
118
+ unbuffered=True,
119
+ )
120
+ except BaseException:
121
+ lease.__exit__(None, None, None)
122
+ raise
123
+ return StreamResource(session, lambda: lease.__exit__(None, None, None))
124
+
125
+
126
+ __all__ = ("MySQLExecutor",)
@@ -0,0 +1,244 @@
1
+ """MySQL scalar expression compilation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ from pyoq.config import DatabaseDialect
8
+ from pyoq.errors import UnsupportedQueryError
9
+ from pyoq.query.mysql.context import MySQLCompilationContext
10
+ from pyoq.query.mysql.identifiers import QUOTING
11
+ from pyoq.query.nodes import (
12
+ BinaryNode,
13
+ BinaryOperator,
14
+ DatePart,
15
+ ExtractNode,
16
+ FieldNode,
17
+ FrameKind,
18
+ FrameNode,
19
+ FunctionName,
20
+ FunctionNode,
21
+ JsonContainsNode,
22
+ JsonKeysNode,
23
+ JsonNode,
24
+ JsonOperation,
25
+ JsonWrite,
26
+ JsonWriteNode,
27
+ )
28
+ from pyoq.query.rendering.expressions import (
29
+ CollectionFunctions,
30
+ ExpressionRenderer,
31
+ )
32
+ from pyoq.query.rendering.identifiers import IdentifierQuoting
33
+ from pyoq.query.select_nodes import ELEMENT_COLUMN, JsonElementsSourceNode
34
+ from pyoq.schema.models import TypeKind
35
+
36
+ _EXCLUDED_ROW = "excluded"
37
+ _ROW_ALIAS = "new"
38
+
39
+ _DATE_FIELD = {
40
+ DatePart.YEAR: "YEAR",
41
+ DatePart.MONTH: "MONTH",
42
+ DatePart.DAY: "DAY",
43
+ DatePart.HOUR: "HOUR",
44
+ DatePart.MINUTE: "MINUTE",
45
+ DatePart.SECOND: "SECOND",
46
+ }
47
+
48
+
49
+ _MYSQL_CAST_SQL: Mapping[TypeKind, str] = {
50
+ TypeKind.SMALL_INTEGER: "SIGNED",
51
+ TypeKind.INTEGER: "SIGNED",
52
+ TypeKind.BIG_INTEGER: "SIGNED",
53
+ TypeKind.DECIMAL: "DECIMAL(65, 30)",
54
+ TypeKind.REAL: "FLOAT",
55
+ TypeKind.DOUBLE: "DOUBLE",
56
+ TypeKind.STRING: "CHAR",
57
+ TypeKind.BINARY: "BINARY",
58
+ TypeKind.DATE: "DATE",
59
+ TypeKind.TIME: "TIME",
60
+ TypeKind.DATETIME: "DATETIME",
61
+ TypeKind.JSON: "JSON",
62
+ }
63
+ """MySQL casts to its own short list, and refuses the ANSI names outright.
64
+
65
+ Verified against a running server: `INTEGER`, `TEXT`, `VARCHAR`, `NUMERIC`,
66
+ `TIMESTAMP`, `INTERVAL`, and `UUID` are all syntax errors there, so the names
67
+ here are the ones it accepts rather than the ones it stores.
68
+
69
+ A bare `DECIMAL` means `DECIMAL(10, 0)` to MySQL, so casting `123.45` to one
70
+ answers `123`. The largest precision it has is used instead, because a cast
71
+ that quietly drops the fractional part of money is worse than no cast.
72
+ """
73
+
74
+
75
+ _MYSQL_JSON_WRITES: Mapping[JsonWrite, str] = {
76
+ JsonWrite.SET: "JSON_SET",
77
+ JsonWrite.INSERT: "JSON_INSERT",
78
+ JsonWrite.REPLACE: "JSON_REPLACE",
79
+ }
80
+
81
+
82
+ class MySQLExpressionCompiler(ExpressionRenderer[MySQLCompilationContext]):
83
+ __slots__ = ()
84
+
85
+ @property
86
+ def collection_functions(self) -> CollectionFunctions:
87
+ # Measured: MySQL rejects ORDER BY inside JSON_ARRAYAGG.
88
+ return CollectionFunctions(
89
+ "JSON_ARRAYAGG",
90
+ "JSON_OBJECT",
91
+ "JSON_ARRAY()",
92
+ "JSON_ARRAY",
93
+ "JSON_OBJECTAGG",
94
+ ordered=False,
95
+ )
96
+
97
+ @property
98
+ def dialect(self) -> str:
99
+ return "MySQL"
100
+
101
+ def qualified_field(self, node: FieldNode) -> str:
102
+ table = _ROW_ALIAS if node.table == _EXCLUDED_ROW else node.table
103
+ schema = None if table == _ROW_ALIAS else node.schema
104
+ catalog = None if table == _ROW_ALIAS else node.catalog
105
+ return QUOTING.qualified(
106
+ node.name,
107
+ table=table,
108
+ schema=schema,
109
+ catalog=catalog,
110
+ )
111
+
112
+ def render_binary(self, node: BinaryNode, left: str, right: str) -> str:
113
+ if node.operator is BinaryOperator.CONCAT:
114
+ return f"CONCAT({left}, {right})"
115
+ if node.operator is BinaryOperator.IS_NOT_DISTINCT_FROM:
116
+ return f"({left} <=> {right})"
117
+ if node.operator is BinaryOperator.IS_DISTINCT_FROM:
118
+ return f"(NOT ({left} <=> {right}))"
119
+ return super().render_binary(node, left, right)
120
+
121
+ def validate_binary(self, node: BinaryNode) -> None:
122
+ del node
123
+
124
+ @property
125
+ def cast_type_names(self) -> Mapping[TypeKind, str]:
126
+ return _MYSQL_CAST_SQL
127
+
128
+ def render_json_elements(self, node: JsonElementsSourceNode) -> str:
129
+ """MySQL expands an array only through a table whose columns it states."""
130
+ subject = self.compile(node.expression)
131
+ alias = QUOTING.quote(node.alias)
132
+ return (
133
+ f"JSON_TABLE({subject}, '$[*]' "
134
+ f"COLUMNS ({ELEMENT_COLUMN} JSON PATH '$')) AS {alias}"
135
+ )
136
+
137
+ def render_json_keys(self, node: JsonKeysNode) -> str:
138
+ """MySQL names the members of an object itself."""
139
+ return f"JSON_KEYS({self.compile(node.expression)})"
140
+
141
+ def render_json(self, node: JsonNode) -> str:
142
+ """MySQL walks a written path, and refuses one written as a bare key."""
143
+ subject = self.compile(node.expression)
144
+ path = self.json_path_text(node.path)
145
+ if node.operation is JsonOperation.REMOVE:
146
+ return f"JSON_REMOVE({subject}, {path})"
147
+ if node.operation is JsonOperation.EXISTS:
148
+ return f"JSON_CONTAINS_PATH({subject}, 'one', {path})"
149
+ if node.operation is JsonOperation.LENGTH:
150
+ return f"JSON_LENGTH({subject}, {path})"
151
+ extracted = f"JSON_EXTRACT({subject}, {path})"
152
+ if node.operation is JsonOperation.TEXT:
153
+ return self._json_text(node, extracted)
154
+ return extracted
155
+
156
+ def _json_text(self, node: JsonNode, extracted: str) -> str:
157
+ """A JSON null read as text is no text, the way the others read it.
158
+
159
+ MySQL unquotes a JSON null into the four letters of its name, so a
160
+ member holding null and a member holding "null" answer alike and both
161
+ differ from what PostgreSQL and SQLite answer. The value is asked for
162
+ a second time because each asking binds the path again.
163
+ """
164
+ again = (
165
+ f"JSON_EXTRACT({self.compile(node.expression)}, "
166
+ f"{self.json_path_text(node.path)})"
167
+ )
168
+ return (
169
+ f"CASE WHEN JSON_TYPE({extracted}) = 'NULL' THEN NULL "
170
+ f"ELSE JSON_UNQUOTE({again}) END"
171
+ )
172
+
173
+ def render_json_write(self, node: JsonWriteNode) -> str:
174
+ """Compiled in the order it is written, because binding follows that.
175
+
176
+ A value compiled before the path it is written at would take the
177
+ earlier placeholder, and the two would swap.
178
+ """
179
+ subject = self.compile(node.expression)
180
+ if node.operation is JsonWrite.MERGE:
181
+ return f"JSON_MERGE_PATCH({subject}, {self.compile(node.value)})"
182
+ if node.operation is JsonWrite.CONCAT:
183
+ raise UnsupportedQueryError(_NO_CONCAT)
184
+ path = self.json_path_text(node.path)
185
+ value = self.compile(node.value)
186
+ return f"{_MYSQL_JSON_WRITES[node.operation]}({subject}, {path}, {value})"
187
+
188
+ def render_json_contains(self, node: JsonContainsNode) -> str:
189
+ subject = self.compile(node.expression)
190
+ return f"JSON_CONTAINS({subject}, {self.compile(node.value)})"
191
+
192
+ @property
193
+ def quoting(self) -> IdentifierQuoting:
194
+ return QUOTING
195
+
196
+ @property
197
+ def database_dialect(self) -> DatabaseDialect:
198
+ return DatabaseDialect.MYSQL
199
+
200
+ def require_frame(self, node: FrameNode) -> None:
201
+ """MySQL counts rows and values, and has no peer groups to count."""
202
+ if node.kind is FrameKind.GROUPS:
203
+ message = "MySQL has no GROUPS frame; count rows or values instead"
204
+ raise UnsupportedQueryError(message)
205
+ if node.exclusion is not None:
206
+ message = "MySQL cannot exclude rows from a frame"
207
+ raise UnsupportedQueryError(message)
208
+
209
+ def render_extract(self, node: ExtractNode) -> str:
210
+ field = _DATE_FIELD.get(node.part)
211
+ if field is None:
212
+ message = f"unsupported MySQL date part: {node.part}"
213
+ raise UnsupportedQueryError(message)
214
+ return f"CAST(EXTRACT({field} FROM {self.compile(node.expression)}) AS SIGNED)"
215
+
216
+ def render_string_predicate(self, node: FunctionNode) -> str:
217
+ left_node, right_node = node.arguments
218
+ left = self.compile(left_node)
219
+ right = self.compile(right_node)
220
+ if node.name is FunctionName.CONTAINS:
221
+ return f"(INSTR({left}, {right}) > 0)"
222
+ if node.name is FunctionName.STARTS_WITH:
223
+ comparison = self.compile(right_node)
224
+ return f"(LEFT({left}, CHAR_LENGTH({right})) = {comparison})"
225
+ if node.name is FunctionName.ENDS_WITH:
226
+ comparison = self.compile(right_node)
227
+ return f"(RIGHT({left}, CHAR_LENGTH({right})) = {comparison})"
228
+ message = f"unsupported MySQL function: {node.name}"
229
+ raise UnsupportedQueryError(message)
230
+
231
+ def require_raw_expressions(self) -> None:
232
+ if not self._context.capabilities.raw_expressions:
233
+ message = "MySQL raw expressions are disabled by compiler capabilities"
234
+ raise UnsupportedQueryError(message)
235
+
236
+
237
+ __all__ = ("MySQLExpressionCompiler",)
238
+
239
+
240
+ _NO_CONCAT = (
241
+ "MySQL merges a document all the way down and has no one-level "
242
+ "concatenation; json_merge says what it does"
243
+ )
244
+ """Refused rather than emulated, because all the way down is not one level."""
@@ -0,0 +1,46 @@
1
+ """MySQL runtime connection creation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import pymysql
8
+ from pymysql.constants import CLIENT
9
+
10
+ from pyoq.errors import ConnectionPoolError
11
+ from pyoq.query.mysql.commands import Connection
12
+ from pyoq.schema.mysql.dsn import parse_data_source_name
13
+
14
+ _CHARSET = "utf8mb4"
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class MySQLConnectionFactory:
19
+ """Opens MySQL connections that count matched rows, not changed rows.
20
+
21
+ ``CLIENT.FOUND_ROWS`` makes an affected row count mean the same thing it
22
+ means for every other dialect, so an update that writes a column's existing
23
+ value still reports the row it matched.
24
+ """
25
+
26
+ dsn: str
27
+
28
+ def __call__(self) -> Connection:
29
+ settings = parse_data_source_name(self.dsn)
30
+ try:
31
+ return pymysql.connect(
32
+ host=settings.host,
33
+ port=settings.port,
34
+ user=settings.user,
35
+ password=settings.password,
36
+ database=settings.database,
37
+ charset=_CHARSET,
38
+ autocommit=True,
39
+ client_flag=CLIENT.FOUND_ROWS,
40
+ )
41
+ except pymysql.Error as error:
42
+ message = f"unable to open MySQL connection: {error}"
43
+ raise ConnectionPoolError(message) from error
44
+
45
+
46
+ __all__ = ("MySQLConnectionFactory",)
@@ -0,0 +1,66 @@
1
+ """MySQL failure classification, by server error code.
2
+
3
+ Every MySQL driver reports the same numeric codes while raising exceptions from
4
+ its own hierarchy, so classification lives here and each driver contributes only
5
+ the exception types it raises.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ _CANCELLED_CODE = 1317
11
+ _TIMEOUT_CODE = 3024
12
+ _CONNECTION_FAILURE_CODES = frozenset(
13
+ {
14
+ 1053, # server shutdown in progress
15
+ 1152, # aborted connection
16
+ 1927, # connection killed
17
+ 2002, # client cannot reach the server socket
18
+ 2003, # client cannot reach the server host
19
+ 2006, # server has gone away
20
+ 2013, # connection lost during a query
21
+ 2055, # connection lost with a system error
22
+ }
23
+ )
24
+ _TRANSACTION_ABORT_CODES = frozenset(
25
+ {
26
+ 1213, # deadlock; the server rolled the whole transaction back
27
+ 1614, # distributed transaction rolled back on deadlock
28
+ }
29
+ )
30
+
31
+
32
+ def error_code(error: BaseException) -> int | None:
33
+ arguments = error.args
34
+ if not arguments or not isinstance(arguments[0], int):
35
+ return None
36
+ return arguments[0]
37
+
38
+
39
+ def is_cancellation(error: BaseException) -> bool:
40
+ return error_code(error) == _CANCELLED_CODE
41
+
42
+
43
+ def is_timeout(error: BaseException) -> bool:
44
+ return error_code(error) == _TIMEOUT_CODE
45
+
46
+
47
+ def is_connection_failure(error: BaseException) -> bool:
48
+ """Report whether the server said the connection itself is gone.
49
+
50
+ A driver also signals a locally closed connection through its own interface
51
+ error, which carries no server code, so each driver checks for that itself.
52
+ """
53
+ return error_code(error) in _CONNECTION_FAILURE_CODES
54
+
55
+
56
+ def is_transaction_abort(error: BaseException) -> bool:
57
+ return error_code(error) in _TRANSACTION_ABORT_CODES
58
+
59
+
60
+ __all__ = (
61
+ "error_code",
62
+ "is_cancellation",
63
+ "is_connection_failure",
64
+ "is_timeout",
65
+ "is_transaction_abort",
66
+ )
@@ -0,0 +1,9 @@
1
+ """MySQL identifier rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pyoq.query.rendering.identifiers import IdentifierQuoting
6
+
7
+ QUOTING = IdentifierQuoting("MySQL", delimiter="`")
8
+
9
+ __all__ = ("QUOTING",)