sqlspec 0.25.0__py3-none-any.whl → 0.27.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of sqlspec might be problematic. Click here for more details.

Files changed (199) hide show
  1. sqlspec/__init__.py +7 -15
  2. sqlspec/_serialization.py +256 -24
  3. sqlspec/_typing.py +71 -52
  4. sqlspec/adapters/adbc/_types.py +1 -1
  5. sqlspec/adapters/adbc/adk/__init__.py +5 -0
  6. sqlspec/adapters/adbc/adk/store.py +870 -0
  7. sqlspec/adapters/adbc/config.py +69 -12
  8. sqlspec/adapters/adbc/data_dictionary.py +340 -0
  9. sqlspec/adapters/adbc/driver.py +266 -58
  10. sqlspec/adapters/adbc/litestar/__init__.py +5 -0
  11. sqlspec/adapters/adbc/litestar/store.py +504 -0
  12. sqlspec/adapters/adbc/type_converter.py +153 -0
  13. sqlspec/adapters/aiosqlite/_types.py +1 -1
  14. sqlspec/adapters/aiosqlite/adk/__init__.py +5 -0
  15. sqlspec/adapters/aiosqlite/adk/store.py +527 -0
  16. sqlspec/adapters/aiosqlite/config.py +88 -15
  17. sqlspec/adapters/aiosqlite/data_dictionary.py +149 -0
  18. sqlspec/adapters/aiosqlite/driver.py +143 -40
  19. sqlspec/adapters/aiosqlite/litestar/__init__.py +5 -0
  20. sqlspec/adapters/aiosqlite/litestar/store.py +281 -0
  21. sqlspec/adapters/aiosqlite/pool.py +7 -7
  22. sqlspec/adapters/asyncmy/__init__.py +7 -1
  23. sqlspec/adapters/asyncmy/_types.py +2 -2
  24. sqlspec/adapters/asyncmy/adk/__init__.py +5 -0
  25. sqlspec/adapters/asyncmy/adk/store.py +493 -0
  26. sqlspec/adapters/asyncmy/config.py +68 -23
  27. sqlspec/adapters/asyncmy/data_dictionary.py +161 -0
  28. sqlspec/adapters/asyncmy/driver.py +313 -58
  29. sqlspec/adapters/asyncmy/litestar/__init__.py +5 -0
  30. sqlspec/adapters/asyncmy/litestar/store.py +296 -0
  31. sqlspec/adapters/asyncpg/__init__.py +2 -1
  32. sqlspec/adapters/asyncpg/_type_handlers.py +71 -0
  33. sqlspec/adapters/asyncpg/_types.py +11 -7
  34. sqlspec/adapters/asyncpg/adk/__init__.py +5 -0
  35. sqlspec/adapters/asyncpg/adk/store.py +450 -0
  36. sqlspec/adapters/asyncpg/config.py +59 -35
  37. sqlspec/adapters/asyncpg/data_dictionary.py +173 -0
  38. sqlspec/adapters/asyncpg/driver.py +170 -25
  39. sqlspec/adapters/asyncpg/litestar/__init__.py +5 -0
  40. sqlspec/adapters/asyncpg/litestar/store.py +253 -0
  41. sqlspec/adapters/bigquery/_types.py +1 -1
  42. sqlspec/adapters/bigquery/adk/__init__.py +5 -0
  43. sqlspec/adapters/bigquery/adk/store.py +576 -0
  44. sqlspec/adapters/bigquery/config.py +27 -10
  45. sqlspec/adapters/bigquery/data_dictionary.py +149 -0
  46. sqlspec/adapters/bigquery/driver.py +368 -142
  47. sqlspec/adapters/bigquery/litestar/__init__.py +5 -0
  48. sqlspec/adapters/bigquery/litestar/store.py +327 -0
  49. sqlspec/adapters/bigquery/type_converter.py +125 -0
  50. sqlspec/adapters/duckdb/_types.py +1 -1
  51. sqlspec/adapters/duckdb/adk/__init__.py +14 -0
  52. sqlspec/adapters/duckdb/adk/store.py +553 -0
  53. sqlspec/adapters/duckdb/config.py +80 -20
  54. sqlspec/adapters/duckdb/data_dictionary.py +163 -0
  55. sqlspec/adapters/duckdb/driver.py +167 -45
  56. sqlspec/adapters/duckdb/litestar/__init__.py +5 -0
  57. sqlspec/adapters/duckdb/litestar/store.py +332 -0
  58. sqlspec/adapters/duckdb/pool.py +4 -4
  59. sqlspec/adapters/duckdb/type_converter.py +133 -0
  60. sqlspec/adapters/oracledb/_numpy_handlers.py +133 -0
  61. sqlspec/adapters/oracledb/_types.py +20 -2
  62. sqlspec/adapters/oracledb/adk/__init__.py +5 -0
  63. sqlspec/adapters/oracledb/adk/store.py +1745 -0
  64. sqlspec/adapters/oracledb/config.py +122 -32
  65. sqlspec/adapters/oracledb/data_dictionary.py +509 -0
  66. sqlspec/adapters/oracledb/driver.py +353 -91
  67. sqlspec/adapters/oracledb/litestar/__init__.py +5 -0
  68. sqlspec/adapters/oracledb/litestar/store.py +767 -0
  69. sqlspec/adapters/oracledb/migrations.py +348 -73
  70. sqlspec/adapters/oracledb/type_converter.py +207 -0
  71. sqlspec/adapters/psqlpy/_type_handlers.py +44 -0
  72. sqlspec/adapters/psqlpy/_types.py +2 -1
  73. sqlspec/adapters/psqlpy/adk/__init__.py +5 -0
  74. sqlspec/adapters/psqlpy/adk/store.py +482 -0
  75. sqlspec/adapters/psqlpy/config.py +46 -17
  76. sqlspec/adapters/psqlpy/data_dictionary.py +172 -0
  77. sqlspec/adapters/psqlpy/driver.py +123 -209
  78. sqlspec/adapters/psqlpy/litestar/__init__.py +5 -0
  79. sqlspec/adapters/psqlpy/litestar/store.py +272 -0
  80. sqlspec/adapters/psqlpy/type_converter.py +102 -0
  81. sqlspec/adapters/psycopg/_type_handlers.py +80 -0
  82. sqlspec/adapters/psycopg/_types.py +2 -1
  83. sqlspec/adapters/psycopg/adk/__init__.py +5 -0
  84. sqlspec/adapters/psycopg/adk/store.py +944 -0
  85. sqlspec/adapters/psycopg/config.py +69 -35
  86. sqlspec/adapters/psycopg/data_dictionary.py +331 -0
  87. sqlspec/adapters/psycopg/driver.py +238 -81
  88. sqlspec/adapters/psycopg/litestar/__init__.py +5 -0
  89. sqlspec/adapters/psycopg/litestar/store.py +554 -0
  90. sqlspec/adapters/sqlite/__init__.py +2 -1
  91. sqlspec/adapters/sqlite/_type_handlers.py +86 -0
  92. sqlspec/adapters/sqlite/_types.py +1 -1
  93. sqlspec/adapters/sqlite/adk/__init__.py +5 -0
  94. sqlspec/adapters/sqlite/adk/store.py +572 -0
  95. sqlspec/adapters/sqlite/config.py +87 -15
  96. sqlspec/adapters/sqlite/data_dictionary.py +149 -0
  97. sqlspec/adapters/sqlite/driver.py +137 -54
  98. sqlspec/adapters/sqlite/litestar/__init__.py +5 -0
  99. sqlspec/adapters/sqlite/litestar/store.py +318 -0
  100. sqlspec/adapters/sqlite/pool.py +18 -9
  101. sqlspec/base.py +45 -26
  102. sqlspec/builder/__init__.py +73 -4
  103. sqlspec/builder/_base.py +162 -89
  104. sqlspec/builder/_column.py +62 -29
  105. sqlspec/builder/_ddl.py +180 -121
  106. sqlspec/builder/_delete.py +5 -4
  107. sqlspec/builder/_dml.py +388 -0
  108. sqlspec/{_sql.py → builder/_factory.py} +53 -94
  109. sqlspec/builder/_insert.py +32 -131
  110. sqlspec/builder/_join.py +375 -0
  111. sqlspec/builder/_merge.py +446 -11
  112. sqlspec/builder/_parsing_utils.py +111 -17
  113. sqlspec/builder/_select.py +1457 -24
  114. sqlspec/builder/_update.py +11 -42
  115. sqlspec/cli.py +307 -194
  116. sqlspec/config.py +252 -67
  117. sqlspec/core/__init__.py +5 -4
  118. sqlspec/core/cache.py +17 -17
  119. sqlspec/core/compiler.py +62 -9
  120. sqlspec/core/filters.py +37 -37
  121. sqlspec/core/hashing.py +9 -9
  122. sqlspec/core/parameters.py +83 -48
  123. sqlspec/core/result.py +102 -46
  124. sqlspec/core/splitter.py +16 -17
  125. sqlspec/core/statement.py +36 -30
  126. sqlspec/core/type_conversion.py +235 -0
  127. sqlspec/driver/__init__.py +7 -6
  128. sqlspec/driver/_async.py +188 -151
  129. sqlspec/driver/_common.py +285 -80
  130. sqlspec/driver/_sync.py +188 -152
  131. sqlspec/driver/mixins/_result_tools.py +20 -236
  132. sqlspec/driver/mixins/_sql_translator.py +4 -4
  133. sqlspec/exceptions.py +75 -7
  134. sqlspec/extensions/adk/__init__.py +53 -0
  135. sqlspec/extensions/adk/_types.py +51 -0
  136. sqlspec/extensions/adk/converters.py +172 -0
  137. sqlspec/extensions/adk/migrations/0001_create_adk_tables.py +144 -0
  138. sqlspec/extensions/adk/migrations/__init__.py +0 -0
  139. sqlspec/extensions/adk/service.py +181 -0
  140. sqlspec/extensions/adk/store.py +536 -0
  141. sqlspec/extensions/aiosql/adapter.py +73 -53
  142. sqlspec/extensions/litestar/__init__.py +21 -4
  143. sqlspec/extensions/litestar/cli.py +54 -10
  144. sqlspec/extensions/litestar/config.py +59 -266
  145. sqlspec/extensions/litestar/handlers.py +46 -17
  146. sqlspec/extensions/litestar/migrations/0001_create_session_table.py +137 -0
  147. sqlspec/extensions/litestar/migrations/__init__.py +3 -0
  148. sqlspec/extensions/litestar/plugin.py +324 -223
  149. sqlspec/extensions/litestar/providers.py +25 -25
  150. sqlspec/extensions/litestar/store.py +265 -0
  151. sqlspec/loader.py +30 -49
  152. sqlspec/migrations/__init__.py +4 -3
  153. sqlspec/migrations/base.py +302 -39
  154. sqlspec/migrations/commands.py +611 -144
  155. sqlspec/migrations/context.py +142 -0
  156. sqlspec/migrations/fix.py +199 -0
  157. sqlspec/migrations/loaders.py +68 -23
  158. sqlspec/migrations/runner.py +543 -107
  159. sqlspec/migrations/tracker.py +237 -21
  160. sqlspec/migrations/utils.py +51 -3
  161. sqlspec/migrations/validation.py +177 -0
  162. sqlspec/protocols.py +66 -36
  163. sqlspec/storage/_utils.py +98 -0
  164. sqlspec/storage/backends/fsspec.py +134 -106
  165. sqlspec/storage/backends/local.py +78 -51
  166. sqlspec/storage/backends/obstore.py +278 -162
  167. sqlspec/storage/registry.py +75 -39
  168. sqlspec/typing.py +16 -84
  169. sqlspec/utils/config_resolver.py +153 -0
  170. sqlspec/utils/correlation.py +4 -5
  171. sqlspec/utils/data_transformation.py +3 -2
  172. sqlspec/utils/deprecation.py +9 -8
  173. sqlspec/utils/fixtures.py +4 -4
  174. sqlspec/utils/logging.py +46 -6
  175. sqlspec/utils/module_loader.py +2 -2
  176. sqlspec/utils/schema.py +288 -0
  177. sqlspec/utils/serializers.py +50 -2
  178. sqlspec/utils/sync_tools.py +21 -17
  179. sqlspec/utils/text.py +1 -2
  180. sqlspec/utils/type_guards.py +111 -20
  181. sqlspec/utils/version.py +433 -0
  182. {sqlspec-0.25.0.dist-info → sqlspec-0.27.0.dist-info}/METADATA +40 -21
  183. sqlspec-0.27.0.dist-info/RECORD +207 -0
  184. sqlspec/builder/mixins/__init__.py +0 -55
  185. sqlspec/builder/mixins/_cte_and_set_ops.py +0 -254
  186. sqlspec/builder/mixins/_delete_operations.py +0 -50
  187. sqlspec/builder/mixins/_insert_operations.py +0 -282
  188. sqlspec/builder/mixins/_join_operations.py +0 -389
  189. sqlspec/builder/mixins/_merge_operations.py +0 -592
  190. sqlspec/builder/mixins/_order_limit_operations.py +0 -152
  191. sqlspec/builder/mixins/_pivot_operations.py +0 -157
  192. sqlspec/builder/mixins/_select_operations.py +0 -936
  193. sqlspec/builder/mixins/_update_operations.py +0 -218
  194. sqlspec/builder/mixins/_where_clause.py +0 -1304
  195. sqlspec-0.25.0.dist-info/RECORD +0 -139
  196. sqlspec-0.25.0.dist-info/licenses/NOTICE +0 -29
  197. {sqlspec-0.25.0.dist-info → sqlspec-0.27.0.dist-info}/WHEEL +0 -0
  198. {sqlspec-0.25.0.dist-info → sqlspec-0.27.0.dist-info}/entry_points.txt +0 -0
  199. {sqlspec-0.25.0.dist-info → sqlspec-0.27.0.dist-info}/licenses/LICENSE +0 -0
sqlspec/builder/_merge.py CHANGED
@@ -4,24 +4,459 @@ Provides a fluent interface for building SQL MERGE queries with
4
4
  parameter binding and validation.
5
5
  """
6
6
 
7
- from typing import Any, Optional
7
+ from collections.abc import Mapping, Sequence
8
+ from itertools import starmap
9
+ from typing import Any
8
10
 
11
+ from mypy_extensions import trait
9
12
  from sqlglot import exp
13
+ from typing_extensions import Self
10
14
 
11
15
  from sqlspec.builder._base import QueryBuilder
12
- from sqlspec.builder.mixins import (
13
- MergeIntoClauseMixin,
14
- MergeMatchedClauseMixin,
15
- MergeNotMatchedBySourceClauseMixin,
16
- MergeNotMatchedClauseMixin,
17
- MergeOnClauseMixin,
18
- MergeUsingClauseMixin,
19
- )
16
+ from sqlspec.builder._parsing_utils import extract_sql_object_expression
20
17
  from sqlspec.core.result import SQLResult
18
+ from sqlspec.exceptions import SQLBuilderError
19
+ from sqlspec.utils.type_guards import has_query_builder_parameters
21
20
 
22
21
  __all__ = ("Merge",)
23
22
 
24
23
 
24
+ class _MergeAssignmentMixin:
25
+ """Shared assignment helpers for MERGE clause mixins."""
26
+
27
+ __slots__ = ()
28
+
29
+ def add_parameter(self, value: Any, name: str | None = None) -> tuple[Any, str]:
30
+ msg = "Method must be provided by QueryBuilder subclass"
31
+ raise NotImplementedError(msg)
32
+
33
+ def _generate_unique_parameter_name(self, base_name: str) -> str:
34
+ msg = "Method must be provided by QueryBuilder subclass"
35
+ raise NotImplementedError(msg)
36
+
37
+ def _is_column_reference(self, value: str) -> bool:
38
+ if not isinstance(value, str):
39
+ return False
40
+ candidate = value.strip()
41
+ sql_keywords = {"NULL", "CURRENT_TIMESTAMP", "CURRENT_DATE", "CURRENT_TIME", "DEFAULT"}
42
+ if candidate.upper() in sql_keywords:
43
+ return True
44
+ if "(" not in candidate and ")" not in candidate:
45
+ return False
46
+ try:
47
+ parsed: exp.Expression | None = exp.maybe_parse(candidate)
48
+ except Exception:
49
+ return False
50
+ if parsed is None:
51
+ return False
52
+ return isinstance(
53
+ parsed, (exp.Dot, exp.Anonymous, exp.Func, exp.Null, exp.CurrentTimestamp, exp.CurrentDate, exp.CurrentTime)
54
+ )
55
+
56
+ def _process_assignment(self, target_column: str, value: Any) -> exp.Expression:
57
+ column_identifier = exp.column(target_column) if isinstance(target_column, str) else target_column
58
+
59
+ if hasattr(value, "expression") and hasattr(value, "sql"):
60
+ value_expr = extract_sql_object_expression(value, builder=self)
61
+ return exp.EQ(this=column_identifier, expression=value_expr)
62
+ if isinstance(value, exp.Expression):
63
+ return exp.EQ(this=column_identifier, expression=value)
64
+ if isinstance(value, str) and self._is_column_reference(value):
65
+ parsed_expression: exp.Expression | None = exp.maybe_parse(value)
66
+ if parsed_expression is None:
67
+ msg = f"Could not parse assignment expression: {value}"
68
+ raise SQLBuilderError(msg)
69
+ return exp.EQ(this=column_identifier, expression=parsed_expression)
70
+
71
+ column_name = target_column if isinstance(target_column, str) else str(target_column)
72
+ column_leaf = column_name.split(".")[-1]
73
+ param_name = self._generate_unique_parameter_name(column_leaf)
74
+ _, param_name = self.add_parameter(value, name=param_name)
75
+ placeholder = exp.Placeholder(this=param_name)
76
+ return exp.EQ(this=column_identifier, expression=placeholder)
77
+
78
+
79
+ @trait
80
+ class MergeIntoClauseMixin:
81
+ """Mixin providing INTO clause for MERGE builders."""
82
+
83
+ __slots__ = ()
84
+
85
+ def get_expression(self) -> exp.Expression | None: ...
86
+ def set_expression(self, expression: exp.Expression) -> None: ...
87
+
88
+ def into(self, table: str | exp.Expression, alias: str | None = None) -> Self:
89
+ current_expr = self.get_expression()
90
+ if current_expr is None or not isinstance(current_expr, exp.Merge):
91
+ self.set_expression(exp.Merge(this=None, using=None, on=None, whens=exp.Whens(expressions=[])))
92
+ current_expr = self.get_expression()
93
+
94
+ assert current_expr is not None
95
+ current_expr.set("this", exp.to_table(table, alias=alias) if isinstance(table, str) else table)
96
+ return self
97
+
98
+
99
+ @trait
100
+ class MergeUsingClauseMixin(_MergeAssignmentMixin):
101
+ """Mixin providing USING clause for MERGE builders."""
102
+
103
+ __slots__ = ()
104
+
105
+ def get_expression(self) -> exp.Expression | None: ...
106
+ def set_expression(self, expression: exp.Expression) -> None: ...
107
+
108
+ def add_parameter(self, value: Any, name: str | None = None) -> tuple[Any, str]:
109
+ msg = "Method must be provided by QueryBuilder subclass"
110
+ raise NotImplementedError(msg)
111
+
112
+ def _generate_unique_parameter_name(self, base_name: str) -> str:
113
+ msg = "Method must be provided by QueryBuilder subclass"
114
+ raise NotImplementedError(msg)
115
+
116
+ def using(self, source: str | exp.Expression | Any, alias: str | None = None) -> Self:
117
+ current_expr = self.get_expression()
118
+ if current_expr is None or not isinstance(current_expr, exp.Merge):
119
+ self.set_expression(exp.Merge(this=None, using=None, on=None, whens=exp.Whens(expressions=[])))
120
+ current_expr = self.get_expression()
121
+
122
+ assert current_expr is not None
123
+ source_expr: exp.Expression
124
+ if isinstance(source, str):
125
+ source_expr = exp.to_table(source, alias=alias)
126
+ elif isinstance(source, dict):
127
+ columns = list(source.keys())
128
+ values = list(source.values())
129
+
130
+ parameterized_values: list[exp.Expression] = []
131
+ for column, value in zip(columns, values, strict=False):
132
+ column_name = column if isinstance(column, str) else str(column)
133
+ if "." in column_name:
134
+ column_name = column_name.split(".")[-1]
135
+ param_name = self._generate_unique_parameter_name(column_name)
136
+ _, param_name = self.add_parameter(value, name=param_name)
137
+ parameterized_values.append(exp.Placeholder(this=param_name))
138
+
139
+ select_expr = exp.Select()
140
+ select_expr.set(
141
+ "expressions", [exp.alias_(parameterized_values[index], column) for index, column in enumerate(columns)]
142
+ )
143
+ select_expr.set("from", exp.From(this=exp.to_table("DUAL")))
144
+
145
+ source_expr = exp.paren(select_expr)
146
+ if alias:
147
+ source_expr = exp.alias_(source_expr, alias, table=False)
148
+ elif has_query_builder_parameters(source) and hasattr(source, "_expression"):
149
+ parameters_obj = getattr(source, "parameters", None)
150
+ if isinstance(parameters_obj, dict):
151
+ for param_name, param_value in parameters_obj.items():
152
+ self.add_parameter(param_value, name=param_name)
153
+ elif isinstance(parameters_obj, (list, tuple)):
154
+ for param_value in parameters_obj:
155
+ self.add_parameter(param_value)
156
+ elif parameters_obj is not None:
157
+ self.add_parameter(parameters_obj)
158
+ subquery_expression_source = getattr(source, "_expression", None)
159
+ subquery_expression = (
160
+ exp.paren(subquery_expression_source)
161
+ if isinstance(subquery_expression_source, exp.Expression)
162
+ else exp.paren(exp.select())
163
+ )
164
+ source_expr = exp.alias_(subquery_expression, alias) if alias else subquery_expression
165
+ elif isinstance(source, exp.Expression):
166
+ source_expr = exp.alias_(source, alias) if alias else source
167
+ else:
168
+ msg = f"Unsupported source type for USING clause: {type(source)}"
169
+ raise SQLBuilderError(msg)
170
+
171
+ current_expr.set("using", source_expr)
172
+ return self
173
+
174
+
175
+ @trait
176
+ class MergeOnClauseMixin:
177
+ """Mixin providing ON clause for MERGE builders."""
178
+
179
+ __slots__ = ()
180
+
181
+ def get_expression(self) -> exp.Expression | None: ...
182
+ def set_expression(self, expression: exp.Expression) -> None: ...
183
+
184
+ def on(self, condition: str | exp.Expression) -> Self:
185
+ current_expr = self.get_expression()
186
+ if current_expr is None or not isinstance(current_expr, exp.Merge):
187
+ self.set_expression(exp.Merge(this=None, using=None, on=None, whens=exp.Whens(expressions=[])))
188
+ current_expr = self.get_expression()
189
+
190
+ assert current_expr is not None
191
+ if isinstance(condition, str):
192
+ parsed_condition: exp.Expression | None = exp.maybe_parse(condition, dialect=getattr(self, "dialect", None))
193
+ if parsed_condition is None:
194
+ msg = f"Could not parse ON condition: {condition}"
195
+ raise SQLBuilderError(msg)
196
+ condition_expr = parsed_condition
197
+ elif isinstance(condition, exp.Expression):
198
+ condition_expr = condition
199
+ else:
200
+ msg = f"Unsupported condition type for ON clause: {type(condition)}"
201
+ raise SQLBuilderError(msg)
202
+
203
+ current_expr.set("on", condition_expr)
204
+ return self
205
+
206
+
207
+ @trait
208
+ class MergeMatchedClauseMixin(_MergeAssignmentMixin):
209
+ """Mixin providing WHEN MATCHED THEN ... clauses for MERGE builders."""
210
+
211
+ __slots__ = ()
212
+
213
+ def get_expression(self) -> exp.Expression | None: ...
214
+ def set_expression(self, expression: exp.Expression) -> None: ...
215
+
216
+ def add_parameter(self, value: Any, name: str | None = None) -> tuple[Any, str]:
217
+ msg = "Method must be provided by QueryBuilder subclass"
218
+ raise NotImplementedError(msg)
219
+
220
+ def _generate_unique_parameter_name(self, base_name: str) -> str:
221
+ msg = "Method must be provided by QueryBuilder subclass"
222
+ raise NotImplementedError(msg)
223
+
224
+ def when_matched_then_update(
225
+ self,
226
+ set_values: dict[str, Any] | None = None,
227
+ condition: str | exp.Expression | None = None,
228
+ **assignments: Any,
229
+ ) -> Self:
230
+ current_expr = self.get_expression()
231
+ if current_expr is None or not isinstance(current_expr, exp.Merge):
232
+ self.set_expression(exp.Merge(this=None, using=None, on=None, whens=exp.Whens(expressions=[])))
233
+ current_expr = self.get_expression()
234
+
235
+ assert current_expr is not None
236
+ combined_assignments: dict[str, Any] = {}
237
+ if set_values:
238
+ combined_assignments.update(set_values)
239
+ if assignments:
240
+ combined_assignments.update(assignments)
241
+
242
+ if not combined_assignments:
243
+ msg = "No update values provided. Use set_values or keyword arguments."
244
+ raise SQLBuilderError(msg)
245
+
246
+ set_expressions = list(starmap(self._process_assignment, combined_assignments.items()))
247
+ update_expression = exp.Update(expressions=set_expressions)
248
+
249
+ when_kwargs: dict[str, Any] = {"matched": True, "then": update_expression}
250
+ if condition is not None:
251
+ if isinstance(condition, str):
252
+ parsed_condition: exp.Expression | None = exp.maybe_parse(
253
+ condition, dialect=getattr(self, "dialect", None)
254
+ )
255
+ if parsed_condition is None:
256
+ msg = f"Could not parse WHEN clause condition: {condition}"
257
+ raise SQLBuilderError(msg)
258
+ when_kwargs["this"] = parsed_condition
259
+ elif isinstance(condition, exp.Expression):
260
+ when_kwargs["this"] = condition
261
+ else:
262
+ msg = f"Unsupported condition type for WHEN clause: {type(condition)}"
263
+ raise SQLBuilderError(msg)
264
+
265
+ whens = current_expr.args.get("whens")
266
+ if not isinstance(whens, exp.Whens):
267
+ whens = exp.Whens(expressions=[])
268
+ current_expr.set("whens", whens)
269
+ whens.append("expressions", exp.When(**when_kwargs))
270
+ return self
271
+
272
+ def when_matched_then_delete(self, condition: str | exp.Expression | None = None) -> Self:
273
+ current_expr = self.get_expression()
274
+ if current_expr is None or not isinstance(current_expr, exp.Merge):
275
+ self.set_expression(exp.Merge(this=None, using=None, on=None, whens=exp.Whens(expressions=[])))
276
+ current_expr = self.get_expression()
277
+
278
+ assert current_expr is not None
279
+ when_kwargs: dict[str, Any] = {"matched": True, "then": exp.Delete()}
280
+ if condition is not None:
281
+ if isinstance(condition, str):
282
+ parsed_condition: exp.Expression | None = exp.maybe_parse(
283
+ condition, dialect=getattr(self, "dialect", None)
284
+ )
285
+ if parsed_condition is None:
286
+ msg = f"Could not parse WHEN clause condition: {condition}"
287
+ raise SQLBuilderError(msg)
288
+ when_kwargs["this"] = parsed_condition
289
+ elif isinstance(condition, exp.Expression):
290
+ when_kwargs["this"] = condition
291
+ else:
292
+ msg = f"Unsupported condition type for WHEN clause: {type(condition)}"
293
+ raise SQLBuilderError(msg)
294
+
295
+ whens = current_expr.args.get("whens")
296
+ if not isinstance(whens, exp.Whens):
297
+ whens = exp.Whens(expressions=[])
298
+ current_expr.set("whens", whens)
299
+ whens.append("expressions", exp.When(**when_kwargs))
300
+ return self
301
+
302
+
303
+ @trait
304
+ class MergeNotMatchedClauseMixin(_MergeAssignmentMixin):
305
+ """Mixin providing WHEN NOT MATCHED THEN ... clauses for MERGE builders."""
306
+
307
+ __slots__ = ()
308
+
309
+ def get_expression(self) -> exp.Expression | None: ...
310
+ def set_expression(self, expression: exp.Expression) -> None: ...
311
+
312
+ def add_parameter(self, value: Any, name: str | None = None) -> tuple[Any, str]:
313
+ msg = "Method must be provided by QueryBuilder subclass"
314
+ raise NotImplementedError(msg)
315
+
316
+ def _generate_unique_parameter_name(self, base_name: str) -> str:
317
+ msg = "Method must be provided by QueryBuilder subclass"
318
+ raise NotImplementedError(msg)
319
+
320
+ def when_not_matched_then_insert(
321
+ self,
322
+ columns: Mapping[str, Any] | Sequence[str] | None = None,
323
+ values: Sequence[Any] | None = None,
324
+ **value_kwargs: Any,
325
+ ) -> Self:
326
+ current_expr = self.get_expression()
327
+ if current_expr is None or not isinstance(current_expr, exp.Merge):
328
+ self.set_expression(exp.Merge(this=None, using=None, on=None, whens=exp.Whens(expressions=[])))
329
+ current_expr = self.get_expression()
330
+
331
+ assert current_expr is not None
332
+ insert_expr = exp.Insert()
333
+ column_names: list[str]
334
+ column_values: list[Any]
335
+
336
+ if isinstance(columns, Mapping):
337
+ combined = dict(columns)
338
+ if value_kwargs:
339
+ combined.update(value_kwargs)
340
+ column_names = list(combined.keys())
341
+ column_values = list(combined.values())
342
+ elif value_kwargs:
343
+ column_names = list(value_kwargs.keys())
344
+ column_values = list(value_kwargs.values())
345
+ else:
346
+ if columns is None or values is None:
347
+ msg = "Columns and values must be provided when not using keyword arguments."
348
+ raise SQLBuilderError(msg)
349
+ column_names = [str(column) for column in columns]
350
+ column_values = list(values)
351
+ if len(column_names) != len(column_values):
352
+ msg = "Number of columns must match number of values for MERGE insert"
353
+ raise SQLBuilderError(msg)
354
+
355
+ insert_columns = [exp.column(name) for name in column_names]
356
+
357
+ insert_values: list[exp.Expression] = []
358
+ for column_name, value in zip(column_names, column_values, strict=True):
359
+ if hasattr(value, "expression") and hasattr(value, "sql"):
360
+ insert_values.append(extract_sql_object_expression(value, builder=self))
361
+ elif isinstance(value, exp.Expression):
362
+ insert_values.append(value)
363
+ elif isinstance(value, str):
364
+ param_name = self._generate_unique_parameter_name(column_name.split(".")[-1])
365
+ _, param_name = self.add_parameter(value, name=param_name)
366
+ insert_values.append(exp.Placeholder(this=param_name))
367
+ else:
368
+ param_name = self._generate_unique_parameter_name(column_name.split(".")[-1])
369
+ _, param_name = self.add_parameter(value, name=param_name)
370
+ insert_values.append(exp.Placeholder(this=param_name))
371
+
372
+ insert_expr.set("this", exp.Tuple(expressions=insert_columns))
373
+ insert_expr.set("expression", exp.Tuple(expressions=insert_values))
374
+ whens = current_expr.args.get("whens")
375
+ if not isinstance(whens, exp.Whens):
376
+ whens = exp.Whens(expressions=[])
377
+ current_expr.set("whens", whens)
378
+ whens.append("expressions", exp.When(matched=False, then=insert_expr))
379
+ return self
380
+
381
+
382
+ @trait
383
+ class MergeNotMatchedBySourceClauseMixin(_MergeAssignmentMixin):
384
+ """Mixin providing WHEN NOT MATCHED BY SOURCE THEN ... clauses."""
385
+
386
+ __slots__ = ()
387
+
388
+ def get_expression(self) -> exp.Expression | None: ...
389
+ def set_expression(self, expression: exp.Expression) -> None: ...
390
+
391
+ def add_parameter(self, value: Any, name: str | None = None) -> tuple[Any, str]:
392
+ msg = "Method must be provided by QueryBuilder subclass"
393
+ raise NotImplementedError(msg)
394
+
395
+ def _generate_unique_parameter_name(self, base_name: str) -> str:
396
+ msg = "Method must be provided by QueryBuilder subclass"
397
+ raise NotImplementedError(msg)
398
+
399
+ def when_not_matched_by_source_then_update(
400
+ self, set_values: dict[str, Any] | None = None, **assignments: Any
401
+ ) -> Self:
402
+ current_expr = self.get_expression()
403
+ if current_expr is None or not isinstance(current_expr, exp.Merge):
404
+ self.set_expression(exp.Merge(this=None, using=None, on=None, whens=exp.Whens(expressions=[])))
405
+ current_expr = self.get_expression()
406
+
407
+ assert current_expr is not None
408
+ combined_assignments: dict[str, Any] = {}
409
+ if set_values:
410
+ combined_assignments.update(set_values)
411
+ if assignments:
412
+ combined_assignments.update(assignments)
413
+
414
+ if not combined_assignments:
415
+ msg = "No update values provided. Use set_values or keyword arguments."
416
+ raise SQLBuilderError(msg)
417
+
418
+ set_expressions: list[exp.Expression] = []
419
+ for column_name, value in combined_assignments.items():
420
+ column_identifier = exp.column(column_name)
421
+ if hasattr(value, "expression") and hasattr(value, "sql"):
422
+ value_expr = extract_sql_object_expression(value, builder=self)
423
+ elif isinstance(value, exp.Expression):
424
+ value_expr = value
425
+ elif isinstance(value, str) and self._is_column_reference(value):
426
+ parsed_value: exp.Expression | None = exp.maybe_parse(value)
427
+ if parsed_value is None:
428
+ msg = f"Could not parse assignment expression: {value}"
429
+ raise SQLBuilderError(msg)
430
+ value_expr = parsed_value
431
+ else:
432
+ param_name = self._generate_unique_parameter_name(column_name)
433
+ _, param_name = self.add_parameter(value, name=param_name)
434
+ value_expr = exp.Placeholder(this=param_name)
435
+ set_expressions.append(exp.EQ(this=column_identifier, expression=value_expr))
436
+
437
+ update_expr = exp.Update(expressions=set_expressions)
438
+ whens = current_expr.args.get("whens")
439
+ if not isinstance(whens, exp.Whens):
440
+ whens = exp.Whens(expressions=[])
441
+ current_expr.set("whens", whens)
442
+ whens.append("expressions", exp.When(matched=False, source=True, then=update_expr))
443
+ return self
444
+
445
+ def when_not_matched_by_source_then_delete(self) -> Self:
446
+ current_expr = self.get_expression()
447
+ if current_expr is None or not isinstance(current_expr, exp.Merge):
448
+ self.set_expression(exp.Merge(this=None, using=None, on=None, whens=exp.Whens(expressions=[])))
449
+ current_expr = self.get_expression()
450
+
451
+ assert current_expr is not None
452
+ whens = current_expr.args.get("whens")
453
+ if not isinstance(whens, exp.Whens):
454
+ whens = exp.Whens(expressions=[])
455
+ current_expr.set("whens", whens)
456
+ whens.append("expressions", exp.When(matched=False, source=True, then=exp.Delete()))
457
+ return self
458
+
459
+
25
460
  class Merge(
26
461
  QueryBuilder,
27
462
  MergeUsingClauseMixin,
@@ -38,9 +473,9 @@ class Merge(
38
473
  """
39
474
 
40
475
  __slots__ = ()
41
- _expression: Optional[exp.Expression]
476
+ _expression: exp.Expression | None
42
477
 
43
- def __init__(self, target_table: Optional[str] = None, **kwargs: Any) -> None:
478
+ def __init__(self, target_table: str | None = None, **kwargs: Any) -> None:
44
479
  """Initialize MERGE with optional target table.
45
480
 
46
481
  Args:
@@ -5,11 +5,11 @@ passed as strings to builder methods.
5
5
  """
6
6
 
7
7
  import contextlib
8
- from typing import Any, Final, Optional, Union, cast
8
+ from typing import Any, Final, cast
9
9
 
10
10
  from sqlglot import exp, maybe_parse, parse_one
11
11
 
12
- from sqlspec.core.parameters import ParameterStyle
12
+ from sqlspec.core.parameters import ParameterStyle, ParameterValidator
13
13
  from sqlspec.utils.type_guards import (
14
14
  has_expression_and_parameters,
15
15
  has_expression_and_sql,
@@ -18,7 +18,7 @@ from sqlspec.utils.type_guards import (
18
18
  )
19
19
 
20
20
 
21
- def extract_column_name(column: Union[str, exp.Column]) -> str:
21
+ def extract_column_name(column: str | exp.Column) -> str:
22
22
  """Extract column name from column expression for parameter naming.
23
23
 
24
24
  Args:
@@ -39,9 +39,7 @@ def extract_column_name(column: Union[str, exp.Column]) -> str:
39
39
  return "column"
40
40
 
41
41
 
42
- def parse_column_expression(
43
- column_input: Union[str, exp.Expression, Any], builder: Optional[Any] = None
44
- ) -> exp.Expression:
42
+ def parse_column_expression(column_input: str | exp.Expression | Any, builder: Any | None = None) -> exp.Expression:
45
43
  """Parse a column input that might be a complex expression.
46
44
 
47
45
  Handles cases like:
@@ -63,9 +61,7 @@ def parse_column_expression(
63
61
  if isinstance(column_input, exp.Expression):
64
62
  return column_input
65
63
 
66
- # Handle SQL objects (from sql.raw with parameters)
67
64
  if has_expression_and_sql(column_input):
68
- # This is likely a SQL object
69
65
  expression = getattr(column_input, "expression", None)
70
66
  if expression is not None and isinstance(expression, exp.Expression):
71
67
  # Merge parameters from SQL object into builder if available
@@ -74,9 +70,7 @@ def parse_column_expression(
74
70
  for param_name, param_value in sql_parameters.items():
75
71
  builder.add_parameter(param_value, name=param_name)
76
72
  return cast("exp.Expression", expression)
77
- # If expression is None, fall back to parsing the raw SQL
78
73
  sql_text = getattr(column_input, "sql", "")
79
- # Merge parameters even when parsing raw SQL
80
74
  if builder and has_expression_and_parameters(column_input) and hasattr(builder, "add_parameter"):
81
75
  sql_parameters = getattr(column_input, "parameters", {})
82
76
  for param_name, param_value in sql_parameters.items():
@@ -91,7 +85,7 @@ def parse_column_expression(
91
85
  return exp.maybe_parse(column_input) or exp.column(str(column_input))
92
86
 
93
87
 
94
- def parse_table_expression(table_input: str, explicit_alias: Optional[str] = None) -> exp.Expression:
88
+ def parse_table_expression(table_input: str, explicit_alias: str | None = None) -> exp.Expression:
95
89
  """Parses a table string that can be a name, a name with an alias, or a subquery string."""
96
90
  with contextlib.suppress(Exception):
97
91
  parsed = parse_one(f"SELECT * FROM {table_input}")
@@ -106,7 +100,7 @@ def parse_table_expression(table_input: str, explicit_alias: Optional[str] = Non
106
100
  return exp.to_table(table_input, alias=explicit_alias)
107
101
 
108
102
 
109
- def parse_order_expression(order_input: Union[str, exp.Expression]) -> exp.Expression:
103
+ def parse_order_expression(order_input: str | exp.Expression) -> exp.Expression:
110
104
  """Parse an ORDER BY expression that might include direction.
111
105
 
112
106
  Handles cases like:
@@ -133,7 +127,7 @@ def parse_order_expression(order_input: Union[str, exp.Expression]) -> exp.Expre
133
127
 
134
128
 
135
129
  def parse_condition_expression(
136
- condition_input: Union[str, exp.Expression, tuple[str, Any]], builder: "Any" = None
130
+ condition_input: str | exp.Expression | tuple[str, Any], builder: "Any" = None
137
131
  ) -> exp.Expression:
138
132
  """Parse a condition that might be complex SQL.
139
133
 
@@ -175,8 +169,6 @@ def parse_condition_expression(
175
169
 
176
170
  # Convert database-specific parameter styles to SQLGlot-compatible format
177
171
  # This ensures that placeholders like $1, %s, :1 are properly recognized as parameters
178
- from sqlspec.core.parameters import ParameterValidator
179
-
180
172
  validator = ParameterValidator()
181
173
  param_info = validator.extract_parameters(condition_input)
182
174
 
@@ -199,10 +191,112 @@ def parse_condition_expression(
199
191
  )
200
192
  condition_input = converted_condition
201
193
 
202
- parsed: Optional[exp.Expression] = exp.maybe_parse(condition_input)
194
+ parsed: exp.Expression | None = exp.maybe_parse(condition_input)
203
195
  if parsed:
204
196
  return parsed
205
197
  return exp.condition(condition_input)
206
198
 
207
199
 
208
- __all__ = ("parse_column_expression", "parse_condition_expression", "parse_order_expression", "parse_table_expression")
200
+ def extract_sql_object_expression(value: Any, builder: Any | None = None) -> exp.Expression:
201
+ """Extract SQLGlot expression from SQL object value with parameter merging.
202
+
203
+ Handles the common pattern of:
204
+ 1. Check if value has expression and SQL attributes
205
+ 2. Try to get expression first, merge parameters if available
206
+ 3. Fall back to parsing raw SQL text if expression is None
207
+ 4. Merge parameters in both cases
208
+ 5. Handle callable SQL text
209
+
210
+ This consolidates duplicated logic across builder files that process
211
+ SQL objects (like those from sql.raw() calls).
212
+
213
+ Args:
214
+ value: The SQL object value to process
215
+ builder: Optional builder instance for parameter merging (must have add_parameter method)
216
+
217
+ Returns:
218
+ SQLGlot Expression extracted from the SQL object
219
+
220
+ Raises:
221
+ ValueError: If the value doesn't appear to be a SQL object
222
+ """
223
+ if not has_expression_and_sql(value):
224
+ msg = f"Value does not have both expression and sql attributes: {type(value)}"
225
+ raise ValueError(msg)
226
+
227
+ # Try expression attribute first
228
+ expression = getattr(value, "expression", None)
229
+ if expression is not None and isinstance(expression, exp.Expression):
230
+ # Merge parameters if available and builder supports it
231
+ if builder and hasattr(value, "parameters") and hasattr(builder, "add_parameter"):
232
+ sql_parameters = getattr(value, "parameters", {})
233
+ for param_name, param_value in sql_parameters.items():
234
+ builder.add_parameter(param_value, name=param_name)
235
+ return cast("exp.Expression", expression)
236
+
237
+ # Fall back to parsing raw SQL text
238
+ sql_text = getattr(value, "sql", "")
239
+
240
+ # Merge parameters even when parsing raw SQL
241
+ if builder and hasattr(value, "parameters") and hasattr(builder, "add_parameter"):
242
+ sql_parameters = getattr(value, "parameters", {})
243
+ for param_name, param_value in sql_parameters.items():
244
+ builder.add_parameter(param_value, name=param_name)
245
+
246
+ # Handle callable SQL text
247
+ if callable(sql_text):
248
+ sql_text = str(value)
249
+
250
+ # Parse SQL text and return as expression
251
+ return exp.maybe_parse(sql_text) or exp.convert(str(sql_text))
252
+
253
+
254
+ def extract_expression(value: Any) -> exp.Expression:
255
+ """Extract SQLGlot expression from value, handling wrapper types.
256
+
257
+ Args:
258
+ value: String, SQLGlot expression, or wrapper type.
259
+
260
+ Returns:
261
+ Raw SQLGlot expression.
262
+ """
263
+ from sqlspec.builder._column import Column
264
+ from sqlspec.builder._expression_wrappers import ExpressionWrapper
265
+ from sqlspec.builder._select import Case
266
+
267
+ if isinstance(value, str):
268
+ return exp.column(value)
269
+ if isinstance(value, Column):
270
+ return value.sqlglot_expression
271
+ if isinstance(value, ExpressionWrapper):
272
+ return value.expression
273
+ if isinstance(value, Case):
274
+ return exp.Case(ifs=value.conditions, default=value.default)
275
+ if isinstance(value, exp.Expression):
276
+ return value
277
+ return exp.convert(value)
278
+
279
+
280
+ def to_expression(value: Any) -> exp.Expression:
281
+ """Convert a Python value to a raw SQLGlot expression.
282
+
283
+ Args:
284
+ value: Python value or SQLGlot expression to convert.
285
+
286
+ Returns:
287
+ Raw SQLGlot expression.
288
+ """
289
+ if isinstance(value, exp.Expression):
290
+ return value
291
+ return exp.convert(value)
292
+
293
+
294
+ __all__ = (
295
+ "extract_expression",
296
+ "extract_sql_object_expression",
297
+ "parse_column_expression",
298
+ "parse_condition_expression",
299
+ "parse_order_expression",
300
+ "parse_table_expression",
301
+ "to_expression",
302
+ )