sqlspec 0.16.1__py3-none-any.whl → 0.17.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 (32) hide show
  1. sqlspec/__init__.py +11 -1
  2. sqlspec/_sql.py +18 -412
  3. sqlspec/adapters/aiosqlite/__init__.py +11 -1
  4. sqlspec/adapters/aiosqlite/config.py +137 -165
  5. sqlspec/adapters/aiosqlite/driver.py +21 -10
  6. sqlspec/adapters/aiosqlite/pool.py +492 -0
  7. sqlspec/adapters/duckdb/__init__.py +2 -0
  8. sqlspec/adapters/duckdb/config.py +11 -235
  9. sqlspec/adapters/duckdb/pool.py +243 -0
  10. sqlspec/adapters/sqlite/__init__.py +2 -0
  11. sqlspec/adapters/sqlite/config.py +4 -115
  12. sqlspec/adapters/sqlite/pool.py +140 -0
  13. sqlspec/base.py +147 -26
  14. sqlspec/builder/__init__.py +6 -0
  15. sqlspec/builder/_insert.py +177 -12
  16. sqlspec/builder/_parsing_utils.py +53 -2
  17. sqlspec/builder/mixins/_join_operations.py +148 -7
  18. sqlspec/builder/mixins/_merge_operations.py +102 -16
  19. sqlspec/builder/mixins/_select_operations.py +311 -6
  20. sqlspec/builder/mixins/_update_operations.py +49 -34
  21. sqlspec/builder/mixins/_where_clause.py +85 -13
  22. sqlspec/core/compiler.py +7 -5
  23. sqlspec/driver/_common.py +9 -1
  24. sqlspec/loader.py +27 -54
  25. sqlspec/storage/registry.py +2 -2
  26. sqlspec/typing.py +53 -99
  27. {sqlspec-0.16.1.dist-info → sqlspec-0.17.0.dist-info}/METADATA +1 -1
  28. {sqlspec-0.16.1.dist-info → sqlspec-0.17.0.dist-info}/RECORD +32 -29
  29. {sqlspec-0.16.1.dist-info → sqlspec-0.17.0.dist-info}/WHEEL +0 -0
  30. {sqlspec-0.16.1.dist-info → sqlspec-0.17.0.dist-info}/entry_points.txt +0 -0
  31. {sqlspec-0.16.1.dist-info → sqlspec-0.17.0.dist-info}/licenses/LICENSE +0 -0
  32. {sqlspec-0.16.1.dist-info → sqlspec-0.17.0.dist-info}/licenses/NOTICE +0 -0
@@ -9,10 +9,13 @@ from typing import Any, Final, Optional, Union, cast
9
9
 
10
10
  from sqlglot import exp, maybe_parse, parse_one
11
11
 
12
+ from sqlspec.core.parameters import ParameterStyle
12
13
  from sqlspec.utils.type_guards import has_expression_attr, has_parameter_builder
13
14
 
14
15
 
15
- def parse_column_expression(column_input: Union[str, exp.Expression, Any]) -> exp.Expression:
16
+ def parse_column_expression(
17
+ column_input: Union[str, exp.Expression, Any], builder: Optional[Any] = None
18
+ ) -> exp.Expression:
16
19
  """Parse a column input that might be a complex expression.
17
20
 
18
21
  Handles cases like:
@@ -22,9 +25,11 @@ def parse_column_expression(column_input: Union[str, exp.Expression, Any]) -> ex
22
25
  - Function calls: "MAX(price)" -> Max(this=Column(price))
23
26
  - Complex expressions: "CASE WHEN ... END" -> Case(...)
24
27
  - Custom Column objects from our builder
28
+ - SQL objects with raw SQL expressions
25
29
 
26
30
  Args:
27
- column_input: String, SQLGlot expression, or Column object
31
+ column_input: String, SQLGlot expression, SQL object, or Column object
32
+ builder: Optional builder instance for parameter merging
28
33
 
29
34
  Returns:
30
35
  exp.Expression: Parsed SQLGlot expression
@@ -32,6 +37,26 @@ def parse_column_expression(column_input: Union[str, exp.Expression, Any]) -> ex
32
37
  if isinstance(column_input, exp.Expression):
33
38
  return column_input
34
39
 
40
+ # Handle SQL objects (from sql.raw with parameters)
41
+ if hasattr(column_input, "expression") and hasattr(column_input, "sql"):
42
+ # This is likely a SQL object
43
+ expression = getattr(column_input, "expression", None)
44
+ if expression is not None and isinstance(expression, exp.Expression):
45
+ # Merge parameters from SQL object into builder if available
46
+ if builder and hasattr(column_input, "parameters") and hasattr(builder, "add_parameter"):
47
+ sql_parameters = getattr(column_input, "parameters", {})
48
+ for param_name, param_value in sql_parameters.items():
49
+ builder.add_parameter(param_value, name=param_name)
50
+ return cast("exp.Expression", expression)
51
+ # If expression is None, fall back to parsing the raw SQL
52
+ sql_text = getattr(column_input, "sql", "")
53
+ # Merge parameters even when parsing raw SQL
54
+ if builder and hasattr(column_input, "parameters") and hasattr(builder, "add_parameter"):
55
+ sql_parameters = getattr(column_input, "parameters", {})
56
+ for param_name, param_value in sql_parameters.items():
57
+ builder.add_parameter(param_value, name=param_name)
58
+ return exp.maybe_parse(sql_text) or exp.column(str(sql_text))
59
+
35
60
  if has_expression_attr(column_input):
36
61
  try:
37
62
  attr_value = column_input._expression
@@ -127,6 +152,32 @@ def parse_condition_expression(
127
152
  if not isinstance(condition_input, str):
128
153
  condition_input = str(condition_input)
129
154
 
155
+ # Convert database-specific parameter styles to SQLGlot-compatible format
156
+ # This ensures that placeholders like $1, %s, :1 are properly recognized as parameters
157
+ from sqlspec.core.parameters import ParameterValidator
158
+
159
+ validator = ParameterValidator()
160
+ param_info = validator.extract_parameters(condition_input)
161
+
162
+ # If we found parameters, convert incompatible ones to SQLGlot-compatible format
163
+ if param_info:
164
+ # Convert problematic parameter styles to :param_N format for SQLGlot
165
+ converted_condition = condition_input
166
+ for param in reversed(param_info): # Reverse to preserve positions
167
+ if param.style in {
168
+ ParameterStyle.NUMERIC,
169
+ ParameterStyle.POSITIONAL_PYFORMAT,
170
+ ParameterStyle.POSITIONAL_COLON,
171
+ }:
172
+ # Convert $1, %s, :1 to :param_0, :param_1, etc.
173
+ placeholder = f":param_{param.ordinal}"
174
+ converted_condition = (
175
+ converted_condition[: param.position]
176
+ + placeholder
177
+ + converted_condition[param.position + len(param.placeholder_text) :]
178
+ )
179
+ condition_input = converted_condition
180
+
130
181
  try:
131
182
  return exp.condition(condition_input)
132
183
  except Exception:
@@ -9,9 +9,11 @@ from sqlspec.exceptions import SQLBuilderError
9
9
  from sqlspec.utils.type_guards import has_query_builder_parameters
10
10
 
11
11
  if TYPE_CHECKING:
12
+ from sqlspec.builder._column import ColumnExpression
13
+ from sqlspec.core.statement import SQL
12
14
  from sqlspec.protocols import SQLBuilderProtocol
13
15
 
14
- __all__ = ("JoinClauseMixin",)
16
+ __all__ = ("JoinBuilder", "JoinClauseMixin")
15
17
 
16
18
 
17
19
  @trait
@@ -26,7 +28,7 @@ class JoinClauseMixin:
26
28
  def join(
27
29
  self,
28
30
  table: Union[str, exp.Expression, Any],
29
- on: Optional[Union[str, exp.Expression]] = None,
31
+ on: Optional[Union[str, exp.Expression, "SQL"]] = None,
30
32
  alias: Optional[str] = None,
31
33
  join_type: str = "INNER",
32
34
  ) -> Self:
@@ -56,7 +58,33 @@ class JoinClauseMixin:
56
58
  table_expr = table
57
59
  on_expr: Optional[exp.Expression] = None
58
60
  if on is not None:
59
- on_expr = exp.condition(on) if isinstance(on, str) else on
61
+ if isinstance(on, str):
62
+ on_expr = exp.condition(on)
63
+ elif hasattr(on, "expression") and hasattr(on, "sql"):
64
+ # Handle SQL objects (from sql.raw with parameters)
65
+ expression = getattr(on, "expression", None)
66
+ if expression is not None and isinstance(expression, exp.Expression):
67
+ # Merge parameters from SQL object into builder
68
+ if hasattr(on, "parameters") and hasattr(builder, "add_parameter"):
69
+ sql_parameters = getattr(on, "parameters", {})
70
+ for param_name, param_value in sql_parameters.items():
71
+ builder.add_parameter(param_value, name=param_name)
72
+ on_expr = expression
73
+ else:
74
+ # If expression is None, fall back to parsing the raw SQL
75
+ sql_text = getattr(on, "sql", "")
76
+ # Merge parameters even when parsing raw SQL
77
+ if hasattr(on, "parameters") and hasattr(builder, "add_parameter"):
78
+ sql_parameters = getattr(on, "parameters", {})
79
+ for param_name, param_value in sql_parameters.items():
80
+ builder.add_parameter(param_value, name=param_name)
81
+ on_expr = exp.maybe_parse(sql_text) or exp.condition(str(sql_text))
82
+ # For other types (should be exp.Expression)
83
+ elif isinstance(on, exp.Expression):
84
+ on_expr = on
85
+ else:
86
+ # Last resort - convert to string and parse
87
+ on_expr = exp.condition(str(on))
60
88
  join_type_upper = join_type.upper()
61
89
  if join_type_upper == "INNER":
62
90
  join_expr = exp.Join(this=table_expr, on=on_expr)
@@ -73,22 +101,22 @@ class JoinClauseMixin:
73
101
  return cast("Self", builder)
74
102
 
75
103
  def inner_join(
76
- self, table: Union[str, exp.Expression, Any], on: Union[str, exp.Expression], alias: Optional[str] = None
104
+ self, table: Union[str, exp.Expression, Any], on: Union[str, exp.Expression, "SQL"], alias: Optional[str] = None
77
105
  ) -> Self:
78
106
  return self.join(table, on, alias, "INNER")
79
107
 
80
108
  def left_join(
81
- self, table: Union[str, exp.Expression, Any], on: Union[str, exp.Expression], alias: Optional[str] = None
109
+ self, table: Union[str, exp.Expression, Any], on: Union[str, exp.Expression, "SQL"], alias: Optional[str] = None
82
110
  ) -> Self:
83
111
  return self.join(table, on, alias, "LEFT")
84
112
 
85
113
  def right_join(
86
- self, table: Union[str, exp.Expression, Any], on: Union[str, exp.Expression], alias: Optional[str] = None
114
+ self, table: Union[str, exp.Expression, Any], on: Union[str, exp.Expression, "SQL"], alias: Optional[str] = None
87
115
  ) -> Self:
88
116
  return self.join(table, on, alias, "RIGHT")
89
117
 
90
118
  def full_join(
91
- self, table: Union[str, exp.Expression, Any], on: Union[str, exp.Expression], alias: Optional[str] = None
119
+ self, table: Union[str, exp.Expression, Any], on: Union[str, exp.Expression, "SQL"], alias: Optional[str] = None
92
120
  ) -> Self:
93
121
  return self.join(table, on, alias, "FULL")
94
122
 
@@ -120,3 +148,116 @@ class JoinClauseMixin:
120
148
  join_expr = exp.Join(this=table_expr, kind="CROSS")
121
149
  builder._expression = builder._expression.join(join_expr, copy=False)
122
150
  return cast("Self", builder)
151
+
152
+
153
+ @trait
154
+ class JoinBuilder:
155
+ """Builder for JOIN operations with fluent syntax.
156
+
157
+ Example:
158
+ ```python
159
+ from sqlspec import sql
160
+
161
+ # sql.left_join_("posts").on("users.id = posts.user_id")
162
+ join_clause = sql.left_join_("posts").on(
163
+ "users.id = posts.user_id"
164
+ )
165
+
166
+ # Or with query builder
167
+ query = (
168
+ sql.select("users.name", "posts.title")
169
+ .from_("users")
170
+ .join(
171
+ sql.left_join_("posts").on(
172
+ "users.id = posts.user_id"
173
+ )
174
+ )
175
+ )
176
+ ```
177
+ """
178
+
179
+ def __init__(self, join_type: str) -> None:
180
+ """Initialize the join builder.
181
+
182
+ Args:
183
+ join_type: Type of join (inner, left, right, full, cross)
184
+ """
185
+ self._join_type = join_type.upper()
186
+ self._table: Optional[Union[str, exp.Expression]] = None
187
+ self._condition: Optional[exp.Expression] = None
188
+ self._alias: Optional[str] = None
189
+
190
+ def __eq__(self, other: object) -> "ColumnExpression": # type: ignore[override]
191
+ """Equal to (==) - not typically used but needed for type consistency."""
192
+ from sqlspec.builder._column import ColumnExpression
193
+
194
+ # JoinBuilder doesn't have a direct expression, so this is a placeholder
195
+ # In practice, this shouldn't be called as joins are used differently
196
+ placeholder_expr = exp.Literal.string(f"join_{self._join_type.lower()}")
197
+ if other is None:
198
+ return ColumnExpression(exp.Is(this=placeholder_expr, expression=exp.Null()))
199
+ return ColumnExpression(exp.EQ(this=placeholder_expr, expression=exp.convert(other)))
200
+
201
+ def __hash__(self) -> int:
202
+ """Make JoinBuilder hashable."""
203
+ return hash(id(self))
204
+
205
+ def __call__(self, table: Union[str, exp.Expression], alias: Optional[str] = None) -> Self:
206
+ """Set the table to join.
207
+
208
+ Args:
209
+ table: Table name or expression to join
210
+ alias: Optional alias for the table
211
+
212
+ Returns:
213
+ Self for method chaining
214
+ """
215
+ self._table = table
216
+ self._alias = alias
217
+ return self
218
+
219
+ def on(self, condition: Union[str, exp.Expression]) -> exp.Expression:
220
+ """Set the join condition and build the JOIN expression.
221
+
222
+ Args:
223
+ condition: JOIN condition (e.g., "users.id = posts.user_id")
224
+
225
+ Returns:
226
+ Complete JOIN expression
227
+ """
228
+ if not self._table:
229
+ msg = "Table must be set before calling .on()"
230
+ raise SQLBuilderError(msg)
231
+
232
+ # Parse the condition
233
+ condition_expr: exp.Expression
234
+ if isinstance(condition, str):
235
+ parsed: Optional[exp.Expression] = exp.maybe_parse(condition)
236
+ condition_expr = parsed or exp.condition(condition)
237
+ else:
238
+ condition_expr = condition
239
+
240
+ # Build table expression
241
+ table_expr: exp.Expression
242
+ if isinstance(self._table, str):
243
+ table_expr = exp.to_table(self._table)
244
+ if self._alias:
245
+ table_expr = exp.alias_(table_expr, self._alias)
246
+ else:
247
+ table_expr = self._table
248
+ if self._alias:
249
+ table_expr = exp.alias_(table_expr, self._alias)
250
+
251
+ # Create the appropriate join type using same pattern as existing JoinClauseMixin
252
+ if self._join_type == "INNER JOIN":
253
+ return exp.Join(this=table_expr, on=condition_expr)
254
+ if self._join_type == "LEFT JOIN":
255
+ return exp.Join(this=table_expr, on=condition_expr, side="LEFT")
256
+ if self._join_type == "RIGHT JOIN":
257
+ return exp.Join(this=table_expr, on=condition_expr, side="RIGHT")
258
+ if self._join_type == "FULL JOIN":
259
+ return exp.Join(this=table_expr, on=condition_expr, side="FULL", kind="OUTER")
260
+ if self._join_type == "CROSS JOIN":
261
+ # CROSS JOIN doesn't use ON condition
262
+ return exp.Join(this=table_expr, kind="CROSS")
263
+ return exp.Join(this=table_expr, on=condition_expr)
@@ -179,14 +179,23 @@ class MergeMatchedClauseMixin:
179
179
  whens.append("expressions", when_clause)
180
180
 
181
181
  def when_matched_then_update(
182
- 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,
183
186
  ) -> Self:
184
187
  """Define the UPDATE action for matched rows.
185
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
+
186
194
  Args:
187
195
  set_values: A dictionary of column names and their new values to set.
188
196
  The values will be parameterized.
189
197
  condition: An optional additional condition for this specific action.
198
+ **kwargs: Column-value pairs to update on match.
190
199
 
191
200
  Raises:
192
201
  SQLBuilderError: If the condition type is unsupported.
@@ -194,14 +203,48 @@ class MergeMatchedClauseMixin:
194
203
  Returns:
195
204
  The current builder instance for method chaining.
196
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
+
197
213
  update_expressions: list[exp.EQ] = []
198
- for col, val in set_values.items():
199
- column_name = col if isinstance(col, str) else str(col)
200
- if "." in column_name:
201
- column_name = column_name.split(".")[-1]
202
- param_name = self._generate_unique_parameter_name(column_name)
203
- param_name = self.add_parameter(val, name=param_name)[1]
204
- 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))
205
248
 
206
249
  when_args: dict[str, Any] = {"matched": True, "then": exp.Update(expressions=update_expressions)}
207
250
 
@@ -386,15 +429,24 @@ class MergeNotMatchedBySourceClauseMixin:
386
429
  raise NotImplementedError(msg)
387
430
 
388
431
  def when_not_matched_by_source_then_update(
389
- 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,
390
436
  ) -> Self:
391
437
  """Define the UPDATE action for rows not matched by source.
392
438
 
393
439
  This is useful for handling rows that exist in the target but not in the source.
394
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
+
395
446
  Args:
396
447
  set_values: A dictionary of column names and their new values to set.
397
448
  condition: An optional additional condition for this specific action.
449
+ **kwargs: Column-value pairs to update when not matched by source.
398
450
 
399
451
  Raises:
400
452
  SQLBuilderError: If the condition type is unsupported.
@@ -402,14 +454,48 @@ class MergeNotMatchedBySourceClauseMixin:
402
454
  Returns:
403
455
  The current builder instance for method chaining.
404
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
+
405
464
  update_expressions: list[exp.EQ] = []
406
- for col, val in set_values.items():
407
- column_name = col if isinstance(col, str) else str(col)
408
- if "." in column_name:
409
- column_name = column_name.split(".")[-1]
410
- param_name = self._generate_unique_parameter_name(column_name)
411
- param_name = self.add_parameter(val, name=param_name)[1]
412
- 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))
413
499
 
414
500
  when_args: dict[str, Any] = {
415
501
  "matched": False,