sqlspec 0.13.1__py3-none-any.whl → 0.16.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.

Potentially problematic release.


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

Files changed (185) hide show
  1. sqlspec/__init__.py +71 -8
  2. sqlspec/__main__.py +12 -0
  3. sqlspec/__metadata__.py +1 -3
  4. sqlspec/_serialization.py +1 -2
  5. sqlspec/_sql.py +930 -136
  6. sqlspec/_typing.py +278 -142
  7. sqlspec/adapters/adbc/__init__.py +4 -3
  8. sqlspec/adapters/adbc/_types.py +12 -0
  9. sqlspec/adapters/adbc/config.py +116 -285
  10. sqlspec/adapters/adbc/driver.py +462 -340
  11. sqlspec/adapters/aiosqlite/__init__.py +18 -3
  12. sqlspec/adapters/aiosqlite/_types.py +13 -0
  13. sqlspec/adapters/aiosqlite/config.py +202 -150
  14. sqlspec/adapters/aiosqlite/driver.py +226 -247
  15. sqlspec/adapters/asyncmy/__init__.py +18 -3
  16. sqlspec/adapters/asyncmy/_types.py +12 -0
  17. sqlspec/adapters/asyncmy/config.py +80 -199
  18. sqlspec/adapters/asyncmy/driver.py +257 -215
  19. sqlspec/adapters/asyncpg/__init__.py +19 -4
  20. sqlspec/adapters/asyncpg/_types.py +17 -0
  21. sqlspec/adapters/asyncpg/config.py +81 -214
  22. sqlspec/adapters/asyncpg/driver.py +284 -359
  23. sqlspec/adapters/bigquery/__init__.py +17 -3
  24. sqlspec/adapters/bigquery/_types.py +12 -0
  25. sqlspec/adapters/bigquery/config.py +191 -299
  26. sqlspec/adapters/bigquery/driver.py +474 -634
  27. sqlspec/adapters/duckdb/__init__.py +14 -3
  28. sqlspec/adapters/duckdb/_types.py +12 -0
  29. sqlspec/adapters/duckdb/config.py +414 -397
  30. sqlspec/adapters/duckdb/driver.py +342 -393
  31. sqlspec/adapters/oracledb/__init__.py +19 -5
  32. sqlspec/adapters/oracledb/_types.py +14 -0
  33. sqlspec/adapters/oracledb/config.py +123 -458
  34. sqlspec/adapters/oracledb/driver.py +505 -531
  35. sqlspec/adapters/psqlpy/__init__.py +13 -3
  36. sqlspec/adapters/psqlpy/_types.py +11 -0
  37. sqlspec/adapters/psqlpy/config.py +93 -307
  38. sqlspec/adapters/psqlpy/driver.py +504 -213
  39. sqlspec/adapters/psycopg/__init__.py +19 -5
  40. sqlspec/adapters/psycopg/_types.py +17 -0
  41. sqlspec/adapters/psycopg/config.py +143 -472
  42. sqlspec/adapters/psycopg/driver.py +704 -825
  43. sqlspec/adapters/sqlite/__init__.py +14 -3
  44. sqlspec/adapters/sqlite/_types.py +11 -0
  45. sqlspec/adapters/sqlite/config.py +208 -142
  46. sqlspec/adapters/sqlite/driver.py +263 -278
  47. sqlspec/base.py +105 -9
  48. sqlspec/{statement/builder → builder}/__init__.py +12 -14
  49. sqlspec/{statement/builder/base.py → builder/_base.py} +184 -86
  50. sqlspec/{statement/builder/column.py → builder/_column.py} +97 -60
  51. sqlspec/{statement/builder/ddl.py → builder/_ddl.py} +61 -131
  52. sqlspec/{statement/builder → builder}/_ddl_utils.py +4 -10
  53. sqlspec/{statement/builder/delete.py → builder/_delete.py} +10 -30
  54. sqlspec/builder/_insert.py +421 -0
  55. sqlspec/builder/_merge.py +71 -0
  56. sqlspec/{statement/builder → builder}/_parsing_utils.py +49 -26
  57. sqlspec/builder/_select.py +170 -0
  58. sqlspec/{statement/builder/update.py → builder/_update.py} +16 -20
  59. sqlspec/builder/mixins/__init__.py +55 -0
  60. sqlspec/builder/mixins/_cte_and_set_ops.py +222 -0
  61. sqlspec/{statement/builder/mixins/_delete_from.py → builder/mixins/_delete_operations.py} +8 -1
  62. sqlspec/builder/mixins/_insert_operations.py +244 -0
  63. sqlspec/{statement/builder/mixins/_join.py → builder/mixins/_join_operations.py} +45 -13
  64. sqlspec/{statement/builder/mixins/_merge_clauses.py → builder/mixins/_merge_operations.py} +188 -30
  65. sqlspec/builder/mixins/_order_limit_operations.py +135 -0
  66. sqlspec/builder/mixins/_pivot_operations.py +153 -0
  67. sqlspec/builder/mixins/_select_operations.py +604 -0
  68. sqlspec/builder/mixins/_update_operations.py +202 -0
  69. sqlspec/builder/mixins/_where_clause.py +644 -0
  70. sqlspec/cli.py +247 -0
  71. sqlspec/config.py +183 -138
  72. sqlspec/core/__init__.py +63 -0
  73. sqlspec/core/cache.py +871 -0
  74. sqlspec/core/compiler.py +417 -0
  75. sqlspec/core/filters.py +830 -0
  76. sqlspec/core/hashing.py +310 -0
  77. sqlspec/core/parameters.py +1237 -0
  78. sqlspec/core/result.py +677 -0
  79. sqlspec/{statement → core}/splitter.py +321 -191
  80. sqlspec/core/statement.py +676 -0
  81. sqlspec/driver/__init__.py +7 -10
  82. sqlspec/driver/_async.py +422 -163
  83. sqlspec/driver/_common.py +545 -287
  84. sqlspec/driver/_sync.py +426 -160
  85. sqlspec/driver/mixins/__init__.py +2 -13
  86. sqlspec/driver/mixins/_result_tools.py +193 -0
  87. sqlspec/driver/mixins/_sql_translator.py +65 -14
  88. sqlspec/exceptions.py +5 -252
  89. sqlspec/extensions/aiosql/adapter.py +93 -96
  90. sqlspec/extensions/litestar/__init__.py +2 -1
  91. sqlspec/extensions/litestar/cli.py +48 -0
  92. sqlspec/extensions/litestar/config.py +0 -1
  93. sqlspec/extensions/litestar/handlers.py +15 -26
  94. sqlspec/extensions/litestar/plugin.py +21 -16
  95. sqlspec/extensions/litestar/providers.py +17 -52
  96. sqlspec/loader.py +423 -104
  97. sqlspec/migrations/__init__.py +35 -0
  98. sqlspec/migrations/base.py +414 -0
  99. sqlspec/migrations/commands.py +443 -0
  100. sqlspec/migrations/loaders.py +402 -0
  101. sqlspec/migrations/runner.py +213 -0
  102. sqlspec/migrations/tracker.py +140 -0
  103. sqlspec/migrations/utils.py +129 -0
  104. sqlspec/protocols.py +51 -186
  105. sqlspec/storage/__init__.py +1 -1
  106. sqlspec/storage/backends/base.py +37 -40
  107. sqlspec/storage/backends/fsspec.py +136 -112
  108. sqlspec/storage/backends/obstore.py +138 -160
  109. sqlspec/storage/capabilities.py +5 -4
  110. sqlspec/storage/registry.py +57 -106
  111. sqlspec/typing.py +136 -115
  112. sqlspec/utils/__init__.py +2 -2
  113. sqlspec/utils/correlation.py +0 -3
  114. sqlspec/utils/deprecation.py +6 -6
  115. sqlspec/utils/fixtures.py +6 -6
  116. sqlspec/utils/logging.py +0 -2
  117. sqlspec/utils/module_loader.py +7 -12
  118. sqlspec/utils/singleton.py +0 -1
  119. sqlspec/utils/sync_tools.py +17 -38
  120. sqlspec/utils/text.py +12 -51
  121. sqlspec/utils/type_guards.py +482 -235
  122. {sqlspec-0.13.1.dist-info → sqlspec-0.16.2.dist-info}/METADATA +7 -2
  123. sqlspec-0.16.2.dist-info/RECORD +134 -0
  124. sqlspec-0.16.2.dist-info/entry_points.txt +2 -0
  125. sqlspec/driver/connection.py +0 -207
  126. sqlspec/driver/mixins/_csv_writer.py +0 -91
  127. sqlspec/driver/mixins/_pipeline.py +0 -512
  128. sqlspec/driver/mixins/_result_utils.py +0 -140
  129. sqlspec/driver/mixins/_storage.py +0 -926
  130. sqlspec/driver/mixins/_type_coercion.py +0 -130
  131. sqlspec/driver/parameters.py +0 -138
  132. sqlspec/service/__init__.py +0 -4
  133. sqlspec/service/_util.py +0 -147
  134. sqlspec/service/base.py +0 -1131
  135. sqlspec/service/pagination.py +0 -26
  136. sqlspec/statement/__init__.py +0 -21
  137. sqlspec/statement/builder/insert.py +0 -288
  138. sqlspec/statement/builder/merge.py +0 -95
  139. sqlspec/statement/builder/mixins/__init__.py +0 -65
  140. sqlspec/statement/builder/mixins/_aggregate_functions.py +0 -250
  141. sqlspec/statement/builder/mixins/_case_builder.py +0 -91
  142. sqlspec/statement/builder/mixins/_common_table_expr.py +0 -90
  143. sqlspec/statement/builder/mixins/_from.py +0 -63
  144. sqlspec/statement/builder/mixins/_group_by.py +0 -118
  145. sqlspec/statement/builder/mixins/_having.py +0 -35
  146. sqlspec/statement/builder/mixins/_insert_from_select.py +0 -47
  147. sqlspec/statement/builder/mixins/_insert_into.py +0 -36
  148. sqlspec/statement/builder/mixins/_insert_values.py +0 -67
  149. sqlspec/statement/builder/mixins/_limit_offset.py +0 -53
  150. sqlspec/statement/builder/mixins/_order_by.py +0 -46
  151. sqlspec/statement/builder/mixins/_pivot.py +0 -79
  152. sqlspec/statement/builder/mixins/_returning.py +0 -37
  153. sqlspec/statement/builder/mixins/_select_columns.py +0 -61
  154. sqlspec/statement/builder/mixins/_set_ops.py +0 -122
  155. sqlspec/statement/builder/mixins/_unpivot.py +0 -77
  156. sqlspec/statement/builder/mixins/_update_from.py +0 -55
  157. sqlspec/statement/builder/mixins/_update_set.py +0 -94
  158. sqlspec/statement/builder/mixins/_update_table.py +0 -29
  159. sqlspec/statement/builder/mixins/_where.py +0 -401
  160. sqlspec/statement/builder/mixins/_window_functions.py +0 -86
  161. sqlspec/statement/builder/select.py +0 -221
  162. sqlspec/statement/filters.py +0 -596
  163. sqlspec/statement/parameter_manager.py +0 -220
  164. sqlspec/statement/parameters.py +0 -867
  165. sqlspec/statement/pipelines/__init__.py +0 -210
  166. sqlspec/statement/pipelines/analyzers/__init__.py +0 -9
  167. sqlspec/statement/pipelines/analyzers/_analyzer.py +0 -646
  168. sqlspec/statement/pipelines/context.py +0 -115
  169. sqlspec/statement/pipelines/transformers/__init__.py +0 -7
  170. sqlspec/statement/pipelines/transformers/_expression_simplifier.py +0 -88
  171. sqlspec/statement/pipelines/transformers/_literal_parameterizer.py +0 -1247
  172. sqlspec/statement/pipelines/transformers/_remove_comments_and_hints.py +0 -76
  173. sqlspec/statement/pipelines/validators/__init__.py +0 -23
  174. sqlspec/statement/pipelines/validators/_dml_safety.py +0 -290
  175. sqlspec/statement/pipelines/validators/_parameter_style.py +0 -370
  176. sqlspec/statement/pipelines/validators/_performance.py +0 -718
  177. sqlspec/statement/pipelines/validators/_security.py +0 -967
  178. sqlspec/statement/result.py +0 -435
  179. sqlspec/statement/sql.py +0 -1704
  180. sqlspec/statement/sql_compiler.py +0 -140
  181. sqlspec/utils/cached_property.py +0 -25
  182. sqlspec-0.13.1.dist-info/RECORD +0 -150
  183. {sqlspec-0.13.1.dist-info → sqlspec-0.16.2.dist-info}/WHEEL +0 -0
  184. {sqlspec-0.13.1.dist-info → sqlspec-0.16.2.dist-info}/licenses/LICENSE +0 -0
  185. {sqlspec-0.13.1.dist-info → sqlspec-0.16.2.dist-info}/licenses/NOTICE +0 -0
@@ -1,5 +1,8 @@
1
+ """Merge operation mixins for SQL builders."""
2
+
1
3
  from typing import Any, Optional, Union
2
4
 
5
+ from mypy_extensions import trait
3
6
  from sqlglot import exp
4
7
  from typing_extensions import Self
5
8
 
@@ -16,10 +19,12 @@ __all__ = (
16
19
  )
17
20
 
18
21
 
22
+ @trait
19
23
  class MergeIntoClauseMixin:
20
24
  """Mixin providing INTO clause for MERGE builders."""
21
25
 
22
- _expression: Optional[exp.Expression] = None
26
+ __slots__ = ()
27
+ _expression: Optional[exp.Expression]
23
28
 
24
29
  def into(self, table: Union[str, exp.Expression], alias: Optional[str] = None) -> Self:
25
30
  """Set the target table for the MERGE operation (INTO clause).
@@ -33,17 +38,24 @@ class MergeIntoClauseMixin:
33
38
  The current builder instance for method chaining.
34
39
  """
35
40
  if self._expression is None:
36
- self._expression = exp.Merge(this=None, using=None, on=None, whens=exp.Whens(expressions=[])) # pyright: ignore
37
- if not isinstance(self._expression, exp.Merge): # pyright: ignore
38
- self._expression = exp.Merge(this=None, using=None, on=None, whens=exp.Whens(expressions=[])) # pyright: ignore
41
+ self._expression = exp.Merge(this=None, using=None, on=None, whens=exp.Whens(expressions=[]))
42
+ if not isinstance(self._expression, exp.Merge):
43
+ self._expression = exp.Merge(this=None, using=None, on=None, whens=exp.Whens(expressions=[]))
39
44
  self._expression.set("this", exp.to_table(table, alias=alias) if isinstance(table, str) else table)
40
45
  return self
41
46
 
42
47
 
48
+ @trait
43
49
  class MergeUsingClauseMixin:
44
50
  """Mixin providing USING clause for MERGE builders."""
45
51
 
46
- _expression: Optional[exp.Expression] = None
52
+ __slots__ = ()
53
+ _expression: Optional[exp.Expression]
54
+
55
+ def add_parameter(self, value: Any, name: Optional[str] = None) -> tuple[Any, str]:
56
+ """Add parameter - provided by QueryBuilder."""
57
+ msg = "Method must be provided by QueryBuilder subclass"
58
+ raise NotImplementedError(msg)
47
59
 
48
60
  def using(self, source: Union[str, exp.Expression, Any], alias: Optional[str] = None) -> Self:
49
61
  """Set the source data for the MERGE operation (USING clause).
@@ -68,11 +80,10 @@ class MergeUsingClauseMixin:
68
80
  if isinstance(source, str):
69
81
  source_expr = exp.to_table(source, alias=alias)
70
82
  elif has_query_builder_parameters(source) and hasattr(source, "_expression"):
71
- # Merge parameters from the SELECT builder or other builder
72
- subquery_builder_params = source.parameters
73
- if subquery_builder_params:
74
- for p_name, p_value in subquery_builder_params.items():
75
- self.add_parameter(p_value, name=p_name) # type: ignore[attr-defined]
83
+ subquery_builder_parameters = source.parameters
84
+ if subquery_builder_parameters:
85
+ for p_name, p_value in subquery_builder_parameters.items():
86
+ self.add_parameter(p_value, name=p_name)
76
87
 
77
88
  subquery_exp = exp.paren(getattr(source, "_expression", exp.select()))
78
89
  source_expr = exp.alias_(subquery_exp, alias) if alias else subquery_exp
@@ -88,10 +99,12 @@ class MergeUsingClauseMixin:
88
99
  return self
89
100
 
90
101
 
102
+ @trait
91
103
  class MergeOnClauseMixin:
92
104
  """Mixin providing ON clause for MERGE builders."""
93
105
 
94
- _expression: Optional[exp.Expression] = None
106
+ __slots__ = ()
107
+ _expression: Optional[exp.Expression]
95
108
 
96
109
  def on(self, condition: Union[str, exp.Expression]) -> Self:
97
110
  """Set the join condition for the MERGE operation (ON clause).
@@ -130,10 +143,22 @@ class MergeOnClauseMixin:
130
143
  return self
131
144
 
132
145
 
146
+ @trait
133
147
  class MergeMatchedClauseMixin:
134
148
  """Mixin providing WHEN MATCHED THEN ... clauses for MERGE builders."""
135
149
 
136
- _expression: Optional[exp.Expression] = None
150
+ __slots__ = ()
151
+ _expression: Optional[exp.Expression]
152
+
153
+ def add_parameter(self, value: Any, name: Optional[str] = None) -> tuple[Any, str]:
154
+ """Add parameter - provided by QueryBuilder."""
155
+ msg = "Method must be provided by QueryBuilder subclass"
156
+ raise NotImplementedError(msg)
157
+
158
+ def _generate_unique_parameter_name(self, base_name: str) -> str:
159
+ """Generate unique parameter name - provided by QueryBuilder."""
160
+ msg = "Method must be provided by QueryBuilder subclass"
161
+ raise NotImplementedError(msg)
137
162
 
138
163
  def _add_when_clause(self, when_clause: exp.When) -> None:
139
164
  """Helper to add a WHEN clause to the MERGE statement.
@@ -142,9 +167,9 @@ class MergeMatchedClauseMixin:
142
167
  when_clause: The WHEN clause to add.
143
168
  """
144
169
  if self._expression is None:
145
- self._expression = exp.Merge(this=None, using=None, on=None, whens=exp.Whens(expressions=[]))
170
+ self._expression = exp.Merge(this=None, using=None, on=None, whens=exp.Whens(expressions=[])) # type: ignore[misc]
146
171
  if not isinstance(self._expression, exp.Merge):
147
- self._expression = exp.Merge(this=None, using=None, on=None, whens=exp.Whens(expressions=[]))
172
+ self._expression = exp.Merge(this=None, using=None, on=None, whens=exp.Whens(expressions=[])) # type: ignore[misc]
148
173
 
149
174
  whens = self._expression.args.get("whens")
150
175
  if not whens:
@@ -154,14 +179,23 @@ class MergeMatchedClauseMixin:
154
179
  whens.append("expressions", when_clause)
155
180
 
156
181
  def when_matched_then_update(
157
- self, set_values: dict[str, Any], condition: Optional[Union[str, exp.Expression]] = None
182
+ self,
183
+ set_values: Optional[dict[str, Any]] = None,
184
+ condition: Optional[Union[str, exp.Expression]] = None,
185
+ **kwargs: Any,
158
186
  ) -> Self:
159
187
  """Define the UPDATE action for matched rows.
160
188
 
189
+ Supports:
190
+ - when_matched_then_update({"column": value})
191
+ - when_matched_then_update(column=value, other_column=other_value)
192
+ - when_matched_then_update({"column": value}, other_column=other_value)
193
+
161
194
  Args:
162
195
  set_values: A dictionary of column names and their new values to set.
163
196
  The values will be parameterized.
164
197
  condition: An optional additional condition for this specific action.
198
+ **kwargs: Column-value pairs to update on match.
165
199
 
166
200
  Raises:
167
201
  SQLBuilderError: If the condition type is unsupported.
@@ -169,10 +203,48 @@ class MergeMatchedClauseMixin:
169
203
  Returns:
170
204
  The current builder instance for method chaining.
171
205
  """
206
+ # Combine set_values dict and kwargs
207
+ all_values = dict(set_values or {}, **kwargs)
208
+
209
+ if not all_values:
210
+ msg = "No update values provided. Use set_values dict or kwargs."
211
+ raise SQLBuilderError(msg)
212
+
172
213
  update_expressions: list[exp.EQ] = []
173
- for col, val in set_values.items():
174
- param_name = self.add_parameter(val)[1] # type: ignore[attr-defined]
175
- update_expressions.append(exp.EQ(this=exp.column(col), expression=exp.var(param_name)))
214
+ for col, val in all_values.items():
215
+ if hasattr(val, "expression") and hasattr(val, "sql"):
216
+ # Handle SQL objects (from sql.raw with parameters)
217
+ expression = getattr(val, "expression", None)
218
+ if expression is not None and isinstance(expression, exp.Expression):
219
+ # Merge parameters from SQL object into builder
220
+ if hasattr(val, "parameters"):
221
+ sql_parameters = getattr(val, "parameters", {})
222
+ for param_name, param_value in sql_parameters.items():
223
+ self.add_parameter(param_value, name=param_name)
224
+ value_expr = expression
225
+ else:
226
+ # If expression is None, fall back to parsing the raw SQL
227
+ sql_text = getattr(val, "sql", "")
228
+ # Merge parameters even when parsing raw SQL
229
+ if hasattr(val, "parameters"):
230
+ sql_parameters = getattr(val, "parameters", {})
231
+ for param_name, param_value in sql_parameters.items():
232
+ self.add_parameter(param_value, name=param_name)
233
+ # Check if sql_text is callable (like Expression.sql method)
234
+ if callable(sql_text):
235
+ sql_text = str(val)
236
+ value_expr = exp.maybe_parse(sql_text) or exp.convert(str(sql_text))
237
+ elif isinstance(val, exp.Expression):
238
+ value_expr = val
239
+ else:
240
+ column_name = col if isinstance(col, str) else str(col)
241
+ if "." in column_name:
242
+ column_name = column_name.split(".")[-1]
243
+ param_name = self._generate_unique_parameter_name(column_name)
244
+ param_name = self.add_parameter(val, name=param_name)[1]
245
+ value_expr = exp.Placeholder(this=param_name)
246
+
247
+ update_expressions.append(exp.EQ(this=exp.column(col), expression=value_expr))
176
248
 
177
249
  when_args: dict[str, Any] = {"matched": True, "then": exp.Update(expressions=update_expressions)}
178
250
 
@@ -233,10 +305,28 @@ class MergeMatchedClauseMixin:
233
305
  return self
234
306
 
235
307
 
308
+ @trait
236
309
  class MergeNotMatchedClauseMixin:
237
310
  """Mixin providing WHEN NOT MATCHED THEN ... clauses for MERGE builders."""
238
311
 
239
- _expression: Optional[exp.Expression] = None
312
+ __slots__ = ()
313
+
314
+ _expression: Optional[exp.Expression]
315
+
316
+ def add_parameter(self, value: Any, name: Optional[str] = None) -> tuple[Any, str]:
317
+ """Add parameter - provided by QueryBuilder."""
318
+ msg = "Method must be provided by QueryBuilder subclass"
319
+ raise NotImplementedError(msg)
320
+
321
+ def _generate_unique_parameter_name(self, base_name: str) -> str:
322
+ """Generate unique parameter name - provided by QueryBuilder."""
323
+ msg = "Method must be provided by QueryBuilder subclass"
324
+ raise NotImplementedError(msg)
325
+
326
+ def _add_when_clause(self, when_clause: exp.When) -> None:
327
+ """Helper to add a WHEN clause to the MERGE statement - provided by QueryBuilder."""
328
+ msg = "Method must be provided by QueryBuilder subclass"
329
+ raise NotImplementedError(msg)
240
330
 
241
331
  def when_not_matched_then_insert(
242
332
  self,
@@ -269,8 +359,12 @@ class MergeNotMatchedClauseMixin:
269
359
  raise SQLBuilderError(msg)
270
360
 
271
361
  parameterized_values: list[exp.Expression] = []
272
- for val in values:
273
- param_name = self.add_parameter(val)[1] # type: ignore[attr-defined]
362
+ for i, val in enumerate(values):
363
+ column_name = columns[i] if isinstance(columns[i], str) else str(columns[i])
364
+ if "." in column_name:
365
+ column_name = column_name.split(".")[-1]
366
+ param_name = self._generate_unique_parameter_name(column_name)
367
+ param_name = self.add_parameter(val, name=param_name)[1]
274
368
  parameterized_values.append(exp.var(param_name))
275
369
 
276
370
  insert_args["this"] = exp.Tuple(expressions=[exp.column(c) for c in columns])
@@ -279,7 +373,6 @@ class MergeNotMatchedClauseMixin:
279
373
  msg = "Specifying columns without values for INSERT action is complex and not fully supported yet. Consider providing full expressions."
280
374
  raise SQLBuilderError(msg)
281
375
  elif not columns and not values:
282
- # INSERT DEFAULT VALUES case
283
376
  pass
284
377
  else:
285
378
  msg = "Cannot specify values without columns for INSERT action."
@@ -308,25 +401,52 @@ class MergeNotMatchedClauseMixin:
308
401
  when_args["this"] = condition_expr
309
402
 
310
403
  when_clause = exp.When(**when_args)
311
- self._add_when_clause(when_clause) # type: ignore[attr-defined]
404
+ self._add_when_clause(when_clause)
312
405
  return self
313
406
 
314
407
 
408
+ @trait
315
409
  class MergeNotMatchedBySourceClauseMixin:
316
410
  """Mixin providing WHEN NOT MATCHED BY SOURCE THEN ... clauses for MERGE builders."""
317
411
 
318
- _expression: Optional[exp.Expression] = None
412
+ __slots__ = ()
413
+
414
+ _expression: Optional[exp.Expression]
415
+
416
+ def add_parameter(self, value: Any, name: Optional[str] = None) -> tuple[Any, str]:
417
+ """Add parameter - provided by QueryBuilder."""
418
+ msg = "Method must be provided by QueryBuilder subclass"
419
+ raise NotImplementedError(msg)
420
+
421
+ def _generate_unique_parameter_name(self, base_name: str) -> str:
422
+ """Generate unique parameter name - provided by QueryBuilder."""
423
+ msg = "Method must be provided by QueryBuilder subclass"
424
+ raise NotImplementedError(msg)
425
+
426
+ def _add_when_clause(self, when_clause: exp.When) -> None:
427
+ """Helper to add a WHEN clause to the MERGE statement - provided by QueryBuilder."""
428
+ msg = "Method must be provided by QueryBuilder subclass"
429
+ raise NotImplementedError(msg)
319
430
 
320
431
  def when_not_matched_by_source_then_update(
321
- self, set_values: dict[str, Any], condition: Optional[Union[str, exp.Expression]] = None
432
+ self,
433
+ set_values: Optional[dict[str, Any]] = None,
434
+ condition: Optional[Union[str, exp.Expression]] = None,
435
+ **kwargs: Any,
322
436
  ) -> Self:
323
437
  """Define the UPDATE action for rows not matched by source.
324
438
 
325
439
  This is useful for handling rows that exist in the target but not in the source.
326
440
 
441
+ Supports:
442
+ - when_not_matched_by_source_then_update({"column": value})
443
+ - when_not_matched_by_source_then_update(column=value, other_column=other_value)
444
+ - when_not_matched_by_source_then_update({"column": value}, other_column=other_value)
445
+
327
446
  Args:
328
447
  set_values: A dictionary of column names and their new values to set.
329
448
  condition: An optional additional condition for this specific action.
449
+ **kwargs: Column-value pairs to update when not matched by source.
330
450
 
331
451
  Raises:
332
452
  SQLBuilderError: If the condition type is unsupported.
@@ -334,10 +454,48 @@ class MergeNotMatchedBySourceClauseMixin:
334
454
  Returns:
335
455
  The current builder instance for method chaining.
336
456
  """
457
+ # Combine set_values dict and kwargs
458
+ all_values = dict(set_values or {}, **kwargs)
459
+
460
+ if not all_values:
461
+ msg = "No update values provided. Use set_values dict or kwargs."
462
+ raise SQLBuilderError(msg)
463
+
337
464
  update_expressions: list[exp.EQ] = []
338
- for col, val in set_values.items():
339
- param_name = self.add_parameter(val)[1] # type: ignore[attr-defined]
340
- update_expressions.append(exp.EQ(this=exp.column(col), expression=exp.var(param_name)))
465
+ for col, val in all_values.items():
466
+ if hasattr(val, "expression") and hasattr(val, "sql"):
467
+ # Handle SQL objects (from sql.raw with parameters)
468
+ expression = getattr(val, "expression", None)
469
+ if expression is not None and isinstance(expression, exp.Expression):
470
+ # Merge parameters from SQL object into builder
471
+ if hasattr(val, "parameters"):
472
+ sql_parameters = getattr(val, "parameters", {})
473
+ for param_name, param_value in sql_parameters.items():
474
+ self.add_parameter(param_value, name=param_name)
475
+ value_expr = expression
476
+ else:
477
+ # If expression is None, fall back to parsing the raw SQL
478
+ sql_text = getattr(val, "sql", "")
479
+ # Merge parameters even when parsing raw SQL
480
+ if hasattr(val, "parameters"):
481
+ sql_parameters = getattr(val, "parameters", {})
482
+ for param_name, param_value in sql_parameters.items():
483
+ self.add_parameter(param_value, name=param_name)
484
+ # Check if sql_text is callable (like Expression.sql method)
485
+ if callable(sql_text):
486
+ sql_text = str(val)
487
+ value_expr = exp.maybe_parse(sql_text) or exp.convert(str(sql_text))
488
+ elif isinstance(val, exp.Expression):
489
+ value_expr = val
490
+ else:
491
+ column_name = col if isinstance(col, str) else str(col)
492
+ if "." in column_name:
493
+ column_name = column_name.split(".")[-1]
494
+ param_name = self._generate_unique_parameter_name(column_name)
495
+ param_name = self.add_parameter(val, name=param_name)[1]
496
+ value_expr = exp.Placeholder(this=param_name)
497
+
498
+ update_expressions.append(exp.EQ(this=exp.column(col), expression=value_expr))
341
499
 
342
500
  when_args: dict[str, Any] = {
343
501
  "matched": False,
@@ -363,7 +521,7 @@ class MergeNotMatchedBySourceClauseMixin:
363
521
  when_args["this"] = condition_expr
364
522
 
365
523
  when_clause = exp.When(**when_args)
366
- self._add_when_clause(when_clause) # type: ignore[attr-defined]
524
+ self._add_when_clause(when_clause)
367
525
  return self
368
526
 
369
527
  def when_not_matched_by_source_then_delete(self, condition: Optional[Union[str, exp.Expression]] = None) -> Self:
@@ -400,5 +558,5 @@ class MergeNotMatchedBySourceClauseMixin:
400
558
  when_args["this"] = condition_expr
401
559
 
402
560
  when_clause = exp.When(**when_args)
403
- self._add_when_clause(when_clause) # type: ignore[attr-defined]
561
+ self._add_when_clause(when_clause)
404
562
  return self
@@ -0,0 +1,135 @@
1
+ """Order, Limit, Offset and Returning operations mixins for SQL builders."""
2
+
3
+ from typing import TYPE_CHECKING, Optional, Union, cast
4
+
5
+ from mypy_extensions import trait
6
+ from sqlglot import exp
7
+ from typing_extensions import Self
8
+
9
+ from sqlspec.builder._parsing_utils import parse_order_expression
10
+ from sqlspec.exceptions import SQLBuilderError
11
+
12
+ if TYPE_CHECKING:
13
+ from sqlspec.protocols import SQLBuilderProtocol
14
+
15
+ __all__ = ("LimitOffsetClauseMixin", "OrderByClauseMixin", "ReturningClauseMixin")
16
+
17
+
18
+ @trait
19
+ class OrderByClauseMixin:
20
+ """Mixin providing ORDER BY clause."""
21
+
22
+ __slots__ = ()
23
+
24
+ # Type annotation for PyRight - this will be provided by the base class
25
+ _expression: Optional[exp.Expression]
26
+
27
+ def order_by(self, *items: Union[str, exp.Ordered], desc: bool = False) -> Self:
28
+ """Add ORDER BY clause.
29
+
30
+ Args:
31
+ *items: Columns to order by. Can be strings (column names) or sqlglot.exp.Ordered instances for specific directions (e.g., exp.column("name").desc()).
32
+ desc: Whether to order in descending order (applies to all items if they are strings).
33
+
34
+ Raises:
35
+ SQLBuilderError: If the current expression is not a SELECT statement or if the item type is unsupported.
36
+
37
+ Returns:
38
+ The current builder instance for method chaining.
39
+ """
40
+ builder = cast("SQLBuilderProtocol", self)
41
+ if not isinstance(builder._expression, exp.Select):
42
+ msg = "ORDER BY is only supported for SELECT statements."
43
+ raise SQLBuilderError(msg)
44
+
45
+ current_expr = builder._expression
46
+ for item in items:
47
+ if isinstance(item, str):
48
+ order_item = parse_order_expression(item)
49
+ if desc:
50
+ order_item = order_item.desc()
51
+ else:
52
+ order_item = item
53
+ current_expr = current_expr.order_by(order_item, copy=False)
54
+ builder._expression = current_expr
55
+ return cast("Self", builder)
56
+
57
+
58
+ @trait
59
+ class LimitOffsetClauseMixin:
60
+ """Mixin providing LIMIT and OFFSET clauses."""
61
+
62
+ __slots__ = ()
63
+
64
+ # Type annotation for PyRight - this will be provided by the base class
65
+ _expression: Optional[exp.Expression]
66
+
67
+ def limit(self, value: int) -> Self:
68
+ """Add LIMIT clause.
69
+
70
+ Args:
71
+ value: The maximum number of rows to return.
72
+
73
+ Raises:
74
+ SQLBuilderError: If the current expression is not a SELECT statement.
75
+
76
+ Returns:
77
+ The current builder instance for method chaining.
78
+ """
79
+ builder = cast("SQLBuilderProtocol", self)
80
+ if not isinstance(builder._expression, exp.Select):
81
+ msg = "LIMIT is only supported for SELECT statements."
82
+ raise SQLBuilderError(msg)
83
+ builder._expression = builder._expression.limit(exp.convert(value), copy=False)
84
+ return cast("Self", builder)
85
+
86
+ def offset(self, value: int) -> Self:
87
+ """Add OFFSET clause.
88
+
89
+ Args:
90
+ value: The number of rows to skip before starting to return rows.
91
+
92
+ Raises:
93
+ SQLBuilderError: If the current expression is not a SELECT statement.
94
+
95
+ Returns:
96
+ The current builder instance for method chaining.
97
+ """
98
+ builder = cast("SQLBuilderProtocol", self)
99
+ if not isinstance(builder._expression, exp.Select):
100
+ msg = "OFFSET is only supported for SELECT statements."
101
+ raise SQLBuilderError(msg)
102
+ builder._expression = builder._expression.offset(exp.convert(value), copy=False)
103
+ return cast("Self", builder)
104
+
105
+
106
+ @trait
107
+ class ReturningClauseMixin:
108
+ """Mixin providing RETURNING clause."""
109
+
110
+ __slots__ = ()
111
+ # Type annotation for PyRight - this will be provided by the base class
112
+ _expression: Optional[exp.Expression]
113
+
114
+ def returning(self, *columns: Union[str, exp.Expression]) -> Self:
115
+ """Add RETURNING clause to the statement.
116
+
117
+ Args:
118
+ *columns: Columns to return. Can be strings or sqlglot expressions.
119
+
120
+ Raises:
121
+ SQLBuilderError: If the current expression is not INSERT, UPDATE, or DELETE.
122
+
123
+ Returns:
124
+ The current builder instance for method chaining.
125
+ """
126
+ if self._expression is None:
127
+ msg = "Cannot add RETURNING: expression is not initialized."
128
+ raise SQLBuilderError(msg)
129
+ valid_types = (exp.Insert, exp.Update, exp.Delete)
130
+ if not isinstance(self._expression, valid_types):
131
+ msg = "RETURNING is only supported for INSERT, UPDATE, and DELETE statements."
132
+ raise SQLBuilderError(msg)
133
+ returning_exprs = [exp.column(c) if isinstance(c, str) else c for c in columns]
134
+ self._expression.set("returning", exp.Returning(expressions=returning_exprs))
135
+ return self
@@ -0,0 +1,153 @@
1
+ """Pivot and Unpivot operations mixins for SQL builders."""
2
+
3
+ from typing import TYPE_CHECKING, Optional, Union, cast
4
+
5
+ from mypy_extensions import trait
6
+ from sqlglot import exp
7
+
8
+ if TYPE_CHECKING:
9
+ from sqlglot.dialects.dialect import DialectType
10
+
11
+ from sqlspec.builder._select import Select
12
+
13
+ __all__ = ("PivotClauseMixin", "UnpivotClauseMixin")
14
+
15
+
16
+ @trait
17
+ class PivotClauseMixin:
18
+ """Mixin class to add PIVOT functionality to a Select."""
19
+
20
+ __slots__ = ()
21
+ # Type annotation for PyRight - this will be provided by the base class
22
+ _expression: Optional[exp.Expression]
23
+
24
+ dialect: "DialectType" = None
25
+
26
+ def pivot(
27
+ self: "PivotClauseMixin",
28
+ aggregate_function: Union[str, exp.Expression],
29
+ aggregate_column: Union[str, exp.Expression],
30
+ pivot_column: Union[str, exp.Expression],
31
+ pivot_values: list[Union[str, int, float, exp.Expression]],
32
+ alias: Optional[str] = None,
33
+ ) -> "Select":
34
+ """Adds a PIVOT clause to the SELECT statement.
35
+
36
+ Example:
37
+ `query.pivot(aggregate_function="SUM", aggregate_column="Sales", pivot_column="Quarter", pivot_values=["Q1", "Q2", "Q3", "Q4"], alias="PivotTable")`
38
+
39
+ Args:
40
+ aggregate_function: The aggregate function to use (e.g., "SUM", "AVG").
41
+ aggregate_column: The column to be aggregated.
42
+ pivot_column: The column whose unique values will become new column headers.
43
+ pivot_values: A list of specific values from the pivot_column to be turned into columns.
44
+ alias: Optional alias for the pivoted table/subquery.
45
+
46
+ Returns:
47
+ The SelectBuilder instance for chaining.
48
+ """
49
+ current_expr = self._expression
50
+ if not isinstance(current_expr, exp.Select):
51
+ msg = "Pivot can only be applied to a Select expression managed by SelectBuilder."
52
+ raise TypeError(msg)
53
+
54
+ agg_func_name = aggregate_function if isinstance(aggregate_function, str) else aggregate_function.name
55
+ agg_col_expr = exp.column(aggregate_column) if isinstance(aggregate_column, str) else aggregate_column
56
+ pivot_col_expr = exp.column(pivot_column) if isinstance(pivot_column, str) else pivot_column
57
+
58
+ pivot_agg_expr = exp.func(agg_func_name, agg_col_expr)
59
+
60
+ pivot_value_exprs: list[exp.Expression] = []
61
+ for val in pivot_values:
62
+ if isinstance(val, exp.Expression):
63
+ pivot_value_exprs.append(val)
64
+ elif isinstance(val, (str, int, float)):
65
+ pivot_value_exprs.append(exp.convert(val))
66
+ else:
67
+ pivot_value_exprs.append(exp.convert(str(val)))
68
+
69
+ in_expr = exp.In(this=pivot_col_expr, expressions=pivot_value_exprs)
70
+
71
+ pivot_node = exp.Pivot(expressions=[pivot_agg_expr], fields=[in_expr], unpivot=False)
72
+
73
+ if alias:
74
+ pivot_node.set("alias", exp.TableAlias(this=exp.to_identifier(alias)))
75
+
76
+ from_clause = current_expr.args.get("from")
77
+ if from_clause and isinstance(from_clause, exp.From):
78
+ table = from_clause.this
79
+ if isinstance(table, exp.Table):
80
+ existing_pivots = table.args.get("pivots", [])
81
+ existing_pivots.append(pivot_node)
82
+ table.set("pivots", existing_pivots)
83
+
84
+ return cast("Select", self)
85
+
86
+
87
+ @trait
88
+ class UnpivotClauseMixin:
89
+ """Mixin class to add UNPIVOT functionality to a Select."""
90
+
91
+ __slots__ = ()
92
+ # Type annotation for PyRight - this will be provided by the base class
93
+ _expression: Optional[exp.Expression]
94
+
95
+ dialect: "DialectType" = None
96
+
97
+ def unpivot(
98
+ self: "UnpivotClauseMixin",
99
+ value_column_name: str,
100
+ name_column_name: str,
101
+ columns_to_unpivot: list[Union[str, exp.Expression]],
102
+ alias: Optional[str] = None,
103
+ ) -> "Select":
104
+ """Adds an UNPIVOT clause to the SELECT statement.
105
+
106
+ Example:
107
+ `query.unpivot(value_column_name="Sales", name_column_name="Quarter", columns_to_unpivot=["Q1Sales", "Q2Sales"], alias="UnpivotTable")`
108
+
109
+ Args:
110
+ value_column_name: The name for the new column that will hold the values from the unpivoted columns.
111
+ name_column_name: The name for the new column that will hold the names of the original unpivoted columns.
112
+ columns_to_unpivot: A list of columns to be unpivoted into rows.
113
+ alias: Optional alias for the unpivoted table/subquery.
114
+
115
+ Raises:
116
+ TypeError: If the current expression is not a Select expression.
117
+
118
+ Returns:
119
+ The Select instance for chaining.
120
+ """
121
+ current_expr = self._expression
122
+ if not isinstance(current_expr, exp.Select):
123
+ msg = "Unpivot can only be applied to a Select expression managed by Select."
124
+ raise TypeError(msg)
125
+
126
+ value_col_ident = exp.to_identifier(value_column_name)
127
+ name_col_ident = exp.to_identifier(name_column_name)
128
+
129
+ unpivot_cols_exprs: list[exp.Expression] = []
130
+ for col_name_or_expr in columns_to_unpivot:
131
+ if isinstance(col_name_or_expr, exp.Expression):
132
+ unpivot_cols_exprs.append(col_name_or_expr)
133
+ elif isinstance(col_name_or_expr, str):
134
+ unpivot_cols_exprs.append(exp.column(col_name_or_expr))
135
+ else:
136
+ unpivot_cols_exprs.append(exp.column(str(col_name_or_expr)))
137
+
138
+ in_expr = exp.In(this=name_col_ident, expressions=unpivot_cols_exprs)
139
+
140
+ unpivot_node = exp.Pivot(expressions=[value_col_ident], fields=[in_expr], unpivot=True)
141
+
142
+ if alias:
143
+ unpivot_node.set("alias", exp.TableAlias(this=exp.to_identifier(alias)))
144
+
145
+ from_clause = current_expr.args.get("from")
146
+ if from_clause and isinstance(from_clause, exp.From):
147
+ table = from_clause.this
148
+ if isinstance(table, exp.Table):
149
+ existing_pivots = table.args.get("pivots", [])
150
+ existing_pivots.append(unpivot_node)
151
+ table.set("pivots", existing_pivots)
152
+
153
+ return cast("Select", self)