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
@@ -12,10 +12,11 @@ from sqlspec.exceptions import SQLBuilderError
12
12
  from sqlspec.utils.type_guards import has_query_builder_parameters, is_expression
13
13
 
14
14
  if TYPE_CHECKING:
15
- from sqlspec.builder._column import Column, FunctionColumn
15
+ from sqlspec.builder._column import Column, ColumnExpression, FunctionColumn
16
+ from sqlspec.core.statement import SQL
16
17
  from sqlspec.protocols import SelectBuilderProtocol, SQLBuilderProtocol
17
18
 
18
- __all__ = ("CaseBuilder", "SelectClauseMixin")
19
+ __all__ = ("Case", "CaseBuilder", "SelectClauseMixin", "SubqueryBuilder", "WindowFunctionBuilder")
19
20
 
20
21
 
21
22
  @trait
@@ -27,7 +28,7 @@ class SelectClauseMixin:
27
28
  # Type annotation for PyRight - this will be provided by the base class
28
29
  _expression: Optional[exp.Expression]
29
30
 
30
- def select(self, *columns: Union[str, exp.Expression, "Column", "FunctionColumn"]) -> Self:
31
+ def select(self, *columns: Union[str, exp.Expression, "Column", "FunctionColumn", "SQL", "Case"]) -> Self:
31
32
  """Add columns to SELECT clause.
32
33
 
33
34
  Raises:
@@ -43,10 +44,10 @@ class SelectClauseMixin:
43
44
  msg = "Cannot add select columns to a non-SELECT expression."
44
45
  raise SQLBuilderError(msg)
45
46
  for column in columns:
46
- builder._expression = builder._expression.select(parse_column_expression(column), copy=False)
47
+ builder._expression = builder._expression.select(parse_column_expression(column, builder), copy=False)
47
48
  return cast("Self", builder)
48
49
 
49
- def distinct(self, *columns: Union[str, exp.Expression, "Column", "FunctionColumn"]) -> Self:
50
+ def distinct(self, *columns: Union[str, exp.Expression, "Column", "FunctionColumn", "SQL"]) -> Self:
50
51
  """Add DISTINCT clause to SELECT.
51
52
 
52
53
  Args:
@@ -67,7 +68,7 @@ class SelectClauseMixin:
67
68
  if not columns:
68
69
  builder._expression.set("distinct", exp.Distinct())
69
70
  else:
70
- distinct_columns = [parse_column_expression(column) for column in columns]
71
+ distinct_columns = [parse_column_expression(column, builder) for column in columns]
71
72
  builder._expression.set("distinct", exp.Distinct(expressions=distinct_columns))
72
73
  return cast("Self", builder)
73
74
 
@@ -601,3 +602,307 @@ class CaseBuilder:
601
602
  """
602
603
  select_expr = exp.alias_(self._case_expr, self._alias) if self._alias else self._case_expr
603
604
  return self._parent.select(select_expr)
605
+
606
+
607
+ @trait
608
+ class WindowFunctionBuilder:
609
+ """Builder for window functions with fluent syntax.
610
+
611
+ Example:
612
+ ```python
613
+ from sqlspec import sql
614
+
615
+ # sql.row_number_.partition_by("department").order_by("salary")
616
+ window_func = (
617
+ sql.row_number_.partition_by("department")
618
+ .order_by("salary")
619
+ .as_("row_num")
620
+ )
621
+ ```
622
+ """
623
+
624
+ def __init__(self, function_name: str) -> None:
625
+ """Initialize the window function builder.
626
+
627
+ Args:
628
+ function_name: Name of the window function (row_number, rank, etc.)
629
+ """
630
+ self._function_name = function_name
631
+ self._partition_by_cols: list[exp.Expression] = []
632
+ self._order_by_cols: list[exp.Expression] = []
633
+ self._alias: Optional[str] = None
634
+
635
+ def __eq__(self, other: object) -> "ColumnExpression": # type: ignore[override]
636
+ """Equal to (==) - convert to expression then compare."""
637
+ from sqlspec.builder._column import ColumnExpression
638
+
639
+ window_expr = self._build_expression()
640
+ if other is None:
641
+ return ColumnExpression(exp.Is(this=window_expr, expression=exp.Null()))
642
+ return ColumnExpression(exp.EQ(this=window_expr, expression=exp.convert(other)))
643
+
644
+ def __hash__(self) -> int:
645
+ """Make WindowFunctionBuilder hashable."""
646
+ return hash(id(self))
647
+
648
+ def partition_by(self, *columns: Union[str, exp.Expression]) -> "WindowFunctionBuilder":
649
+ """Add PARTITION BY clause.
650
+
651
+ Args:
652
+ *columns: Columns to partition by.
653
+
654
+ Returns:
655
+ Self for method chaining.
656
+ """
657
+ for col in columns:
658
+ col_expr = exp.column(col) if isinstance(col, str) else col
659
+ self._partition_by_cols.append(col_expr)
660
+ return self
661
+
662
+ def order_by(self, *columns: Union[str, exp.Expression]) -> "WindowFunctionBuilder":
663
+ """Add ORDER BY clause.
664
+
665
+ Args:
666
+ *columns: Columns to order by.
667
+
668
+ Returns:
669
+ Self for method chaining.
670
+ """
671
+ for col in columns:
672
+ if isinstance(col, str):
673
+ col_expr = exp.column(col).asc()
674
+ self._order_by_cols.append(col_expr)
675
+ else:
676
+ # Convert to ordered expression
677
+ self._order_by_cols.append(exp.Ordered(this=col, desc=False))
678
+ return self
679
+
680
+ def as_(self, alias: str) -> exp.Alias:
681
+ """Complete the window function with an alias.
682
+
683
+ Args:
684
+ alias: Alias name for the window function.
685
+
686
+ Returns:
687
+ Aliased window function expression.
688
+ """
689
+ window_expr = self._build_expression()
690
+ return cast("exp.Alias", exp.alias_(window_expr, alias))
691
+
692
+ def build(self) -> exp.Expression:
693
+ """Complete the window function without an alias.
694
+
695
+ Returns:
696
+ Window function expression.
697
+ """
698
+ return self._build_expression()
699
+
700
+ def _build_expression(self) -> exp.Expression:
701
+ """Build the complete window function expression."""
702
+ # Create the function expression
703
+ func_expr = exp.Anonymous(this=self._function_name.upper(), expressions=[])
704
+
705
+ # Build the OVER clause arguments
706
+ over_args: dict[str, Any] = {}
707
+
708
+ if self._partition_by_cols:
709
+ over_args["partition_by"] = self._partition_by_cols
710
+
711
+ if self._order_by_cols:
712
+ over_args["order"] = exp.Order(expressions=self._order_by_cols)
713
+
714
+ return exp.Window(this=func_expr, **over_args)
715
+
716
+
717
+ @trait
718
+ class SubqueryBuilder:
719
+ """Builder for subquery operations with fluent syntax.
720
+
721
+ Example:
722
+ ```python
723
+ from sqlspec import sql
724
+
725
+ # sql.exists_(subquery)
726
+ exists_check = sql.exists_(
727
+ sql.select("1")
728
+ .from_("orders")
729
+ .where_eq("user_id", sql.users.id)
730
+ )
731
+
732
+ # sql.in_(subquery)
733
+ in_check = sql.in_(
734
+ sql.select("category_id")
735
+ .from_("categories")
736
+ .where_eq("active", True)
737
+ )
738
+ ```
739
+ """
740
+
741
+ def __init__(self, operation: str) -> None:
742
+ """Initialize the subquery builder.
743
+
744
+ Args:
745
+ operation: Type of subquery operation (exists, in, any, all)
746
+ """
747
+ self._operation = operation
748
+
749
+ def __eq__(self, other: object) -> "ColumnExpression": # type: ignore[override]
750
+ """Equal to (==) - not typically used but needed for type consistency."""
751
+ from sqlspec.builder._column import ColumnExpression
752
+
753
+ # SubqueryBuilder doesn't have a direct expression, so this is a placeholder
754
+ # In practice, this shouldn't be called as subqueries are used differently
755
+ placeholder_expr = exp.Literal.string(f"subquery_{self._operation}")
756
+ if other is None:
757
+ return ColumnExpression(exp.Is(this=placeholder_expr, expression=exp.Null()))
758
+ return ColumnExpression(exp.EQ(this=placeholder_expr, expression=exp.convert(other)))
759
+
760
+ def __hash__(self) -> int:
761
+ """Make SubqueryBuilder hashable."""
762
+ return hash(id(self))
763
+
764
+ def __call__(self, subquery: Union[str, exp.Expression, Any]) -> exp.Expression:
765
+ """Build the subquery expression.
766
+
767
+ Args:
768
+ subquery: The subquery - can be a SQL string, SelectBuilder, or expression
769
+
770
+ Returns:
771
+ The subquery expression (EXISTS, IN, ANY, ALL, etc.)
772
+ """
773
+ subquery_expr: exp.Expression
774
+ if isinstance(subquery, str):
775
+ # Parse as SQL
776
+ parsed: Optional[exp.Expression] = exp.maybe_parse(subquery)
777
+ if not parsed:
778
+ msg = f"Could not parse subquery SQL: {subquery}"
779
+ raise SQLBuilderError(msg)
780
+ subquery_expr = parsed
781
+ elif hasattr(subquery, "build") and callable(getattr(subquery, "build", None)):
782
+ # It's a query builder - build it to get the SQL and parse
783
+ built_query = subquery.build() # pyright: ignore[reportAttributeAccessIssue]
784
+ subquery_expr = exp.maybe_parse(built_query.sql)
785
+ if not subquery_expr:
786
+ msg = f"Could not parse built query: {built_query.sql}"
787
+ raise SQLBuilderError(msg)
788
+ elif isinstance(subquery, exp.Expression):
789
+ subquery_expr = subquery
790
+ else:
791
+ # Try to convert to expression
792
+ parsed = exp.maybe_parse(str(subquery))
793
+ if not parsed:
794
+ msg = f"Could not convert subquery to expression: {subquery}"
795
+ raise SQLBuilderError(msg)
796
+ subquery_expr = parsed
797
+
798
+ # Build the appropriate expression based on operation
799
+ if self._operation == "exists":
800
+ return exp.Exists(this=subquery_expr)
801
+ if self._operation == "in":
802
+ # For IN, we create a subquery that can be used with WHERE column IN (subquery)
803
+ return exp.In(expressions=[subquery_expr])
804
+ if self._operation == "any":
805
+ return exp.Any(this=subquery_expr)
806
+ if self._operation == "all":
807
+ return exp.All(this=subquery_expr)
808
+ msg = f"Unknown subquery operation: {self._operation}"
809
+ raise SQLBuilderError(msg)
810
+
811
+
812
+ @trait
813
+ class Case:
814
+ """Builder for CASE expressions using the SQL factory.
815
+
816
+ Example:
817
+ ```python
818
+ from sqlspec import sql
819
+
820
+ case_expr = (
821
+ sql.case()
822
+ .when(sql.age < 18, "Minor")
823
+ .when(sql.age < 65, "Adult")
824
+ .else_("Senior")
825
+ .end()
826
+ )
827
+ ```
828
+ """
829
+
830
+ def __init__(self) -> None:
831
+ """Initialize the CASE expression builder."""
832
+ self._conditions: list[exp.If] = []
833
+ self._default: Optional[exp.Expression] = None
834
+
835
+ def __eq__(self, other: object) -> "ColumnExpression": # type: ignore[override]
836
+ """Equal to (==) - convert to expression then compare."""
837
+ from sqlspec.builder._column import ColumnExpression
838
+
839
+ case_expr = exp.Case(ifs=self._conditions, default=self._default)
840
+ if other is None:
841
+ return ColumnExpression(exp.Is(this=case_expr, expression=exp.Null()))
842
+ return ColumnExpression(exp.EQ(this=case_expr, expression=exp.convert(other)))
843
+
844
+ def __hash__(self) -> int:
845
+ """Make Case hashable."""
846
+ return hash(id(self))
847
+
848
+ def when(self, condition: Union[str, exp.Expression], value: Union[str, exp.Expression, Any]) -> Self:
849
+ """Add a WHEN clause.
850
+
851
+ Args:
852
+ condition: Condition to test.
853
+ value: Value to return if condition is true.
854
+
855
+ Returns:
856
+ Self for method chaining.
857
+ """
858
+ from sqlspec._sql import SQLFactory
859
+
860
+ cond_expr = exp.maybe_parse(condition) or exp.column(condition) if isinstance(condition, str) else condition
861
+ val_expr = SQLFactory._to_literal(value)
862
+
863
+ # SQLGlot uses exp.If for CASE WHEN clauses, not exp.When
864
+ when_clause = exp.If(this=cond_expr, true=val_expr)
865
+ self._conditions.append(when_clause)
866
+ return self
867
+
868
+ def else_(self, value: Union[str, exp.Expression, Any]) -> Self:
869
+ """Add an ELSE clause.
870
+
871
+ Args:
872
+ value: Default value to return.
873
+
874
+ Returns:
875
+ Self for method chaining.
876
+ """
877
+ from sqlspec._sql import SQLFactory
878
+
879
+ self._default = SQLFactory._to_literal(value)
880
+ return self
881
+
882
+ def end(self) -> Self:
883
+ """Complete the CASE expression.
884
+
885
+ Returns:
886
+ Complete CASE expression.
887
+ """
888
+ return self
889
+
890
+ @property
891
+ def _expression(self) -> exp.Case:
892
+ """Get the sqlglot expression for this case builder.
893
+
894
+ This allows the CaseBuilder to be used wherever expressions are expected.
895
+ """
896
+ return exp.Case(ifs=self._conditions, default=self._default)
897
+
898
+ def as_(self, alias: str) -> exp.Alias:
899
+ """Complete the CASE expression with an alias.
900
+
901
+ Args:
902
+ alias: Alias name for the CASE expression.
903
+
904
+ Returns:
905
+ Aliased CASE expression.
906
+ """
907
+ case_expr = exp.Case(ifs=self._conditions, default=self._default)
908
+ return cast("exp.Alias", exp.alias_(case_expr, alias))
@@ -1,7 +1,7 @@
1
1
  """Update operation mixins for SQL builders."""
2
2
 
3
3
  from collections.abc import Mapping
4
- from typing import Any, Optional, Union
4
+ from typing import Any, Optional, Union, cast
5
5
 
6
6
  from mypy_extensions import trait
7
7
  from sqlglot import exp
@@ -61,6 +61,52 @@ class UpdateSetClauseMixin:
61
61
  msg = "Method must be provided by QueryBuilder subclass"
62
62
  raise NotImplementedError(msg)
63
63
 
64
+ def _process_update_value(self, val: Any, col: Any) -> exp.Expression:
65
+ """Process a value for UPDATE assignment, handling SQL objects and parameters.
66
+
67
+ Args:
68
+ val: The value to process
69
+ col: The column name for parameter naming
70
+
71
+ Returns:
72
+ The processed expression for the value
73
+ """
74
+ if isinstance(val, exp.Expression):
75
+ return val
76
+ if has_query_builder_parameters(val):
77
+ subquery = val.build()
78
+ sql_str = subquery.sql if hasattr(subquery, "sql") and not callable(subquery.sql) else str(subquery)
79
+ value_expr = exp.paren(exp.maybe_parse(sql_str, dialect=getattr(self, "dialect", None)))
80
+ if has_query_builder_parameters(val):
81
+ for p_name, p_value in val.parameters.items():
82
+ self.add_parameter(p_value, name=p_name)
83
+ return value_expr
84
+ if hasattr(val, "expression") and hasattr(val, "sql"):
85
+ # Handle SQL objects (from sql.raw with parameters)
86
+ expression = getattr(val, "expression", None)
87
+ if expression is not None and isinstance(expression, exp.Expression):
88
+ # Merge parameters from SQL object into builder
89
+ if hasattr(val, "parameters"):
90
+ sql_parameters = getattr(val, "parameters", {})
91
+ for param_name, param_value in sql_parameters.items():
92
+ self.add_parameter(param_value, name=param_name)
93
+ return cast("exp.Expression", expression)
94
+ # If expression is None, fall back to parsing the raw SQL
95
+ sql_text = getattr(val, "sql", "")
96
+ # Merge parameters even when parsing raw SQL
97
+ if hasattr(val, "parameters"):
98
+ sql_parameters = getattr(val, "parameters", {})
99
+ for param_name, param_value in sql_parameters.items():
100
+ self.add_parameter(param_value, name=param_name)
101
+ parsed_expr = exp.maybe_parse(sql_text)
102
+ return parsed_expr if parsed_expr is not None else exp.convert(str(sql_text))
103
+ column_name = col if isinstance(col, str) else str(col)
104
+ if "." in column_name:
105
+ column_name = column_name.split(".")[-1]
106
+ param_name = self._generate_unique_parameter_name(column_name)
107
+ param_name = self.add_parameter(val, name=param_name)[1]
108
+ return exp.Placeholder(this=param_name)
109
+
64
110
  def set(self, *args: Any, **kwargs: Any) -> Self:
65
111
  """Set columns and values for the UPDATE statement.
66
112
 
@@ -80,7 +126,6 @@ class UpdateSetClauseMixin:
80
126
  Returns:
81
127
  The current builder instance for method chaining.
82
128
  """
83
-
84
129
  if self._expression is None:
85
130
  self._expression = exp.Update()
86
131
  if not isinstance(self._expression, exp.Update):
@@ -90,42 +135,12 @@ class UpdateSetClauseMixin:
90
135
  if len(args) == MIN_SET_ARGS and not kwargs:
91
136
  col, val = args
92
137
  col_expr = col if isinstance(col, exp.Column) else exp.column(col)
93
- if isinstance(val, exp.Expression):
94
- value_expr = val
95
- elif has_query_builder_parameters(val):
96
- subquery = val.build()
97
- sql_str = subquery.sql if hasattr(subquery, "sql") and not callable(subquery.sql) else str(subquery)
98
- value_expr = exp.paren(exp.maybe_parse(sql_str, dialect=getattr(self, "dialect", None)))
99
- if has_query_builder_parameters(val):
100
- for p_name, p_value in val.parameters.items():
101
- self.add_parameter(p_value, name=p_name)
102
- else:
103
- column_name = col if isinstance(col, str) else str(col)
104
- if "." in column_name:
105
- column_name = column_name.split(".")[-1]
106
- param_name = self._generate_unique_parameter_name(column_name)
107
- param_name = self.add_parameter(val, name=param_name)[1]
108
- value_expr = exp.Placeholder(this=param_name)
138
+ value_expr = self._process_update_value(val, col)
109
139
  assignments.append(exp.EQ(this=col_expr, expression=value_expr))
110
140
  elif (len(args) == 1 and isinstance(args[0], Mapping)) or kwargs:
111
141
  all_values = dict(args[0] if args else {}, **kwargs)
112
142
  for col, val in all_values.items():
113
- if isinstance(val, exp.Expression):
114
- value_expr = val
115
- elif has_query_builder_parameters(val):
116
- subquery = val.build()
117
- sql_str = subquery.sql if hasattr(subquery, "sql") and not callable(subquery.sql) else str(subquery)
118
- value_expr = exp.paren(exp.maybe_parse(sql_str, dialect=getattr(self, "dialect", None)))
119
- if has_query_builder_parameters(val):
120
- for p_name, p_value in val.parameters.items():
121
- self.add_parameter(p_value, name=p_name)
122
- else:
123
- column_name = col if isinstance(col, str) else str(col)
124
- if "." in column_name:
125
- column_name = column_name.split(".")[-1]
126
- param_name = self._generate_unique_parameter_name(column_name)
127
- param_name = self.add_parameter(val, name=param_name)[1]
128
- value_expr = exp.Placeholder(this=param_name)
143
+ value_expr = self._process_update_value(val, col)
129
144
  assignments.append(exp.EQ(this=exp.column(col), expression=value_expr))
130
145
  else:
131
146
  msg = "Invalid arguments for set(): use (column, value), mapping, or kwargs."
@@ -3,6 +3,9 @@
3
3
 
4
4
  from typing import TYPE_CHECKING, Any, Optional, Union, cast
5
5
 
6
+ if TYPE_CHECKING:
7
+ from sqlspec.core.statement import SQL
8
+
6
9
  from mypy_extensions import trait
7
10
  from sqlglot import exp
8
11
  from typing_extensions import Self
@@ -208,21 +211,25 @@ class WhereClauseMixin:
208
211
 
209
212
  def where(
210
213
  self,
211
- condition: Union[str, exp.Expression, exp.Condition, tuple[str, Any], tuple[str, str, Any], "ColumnExpression"],
212
- value: Optional[Any] = None,
214
+ condition: Union[
215
+ str, exp.Expression, exp.Condition, tuple[str, Any], tuple[str, str, Any], "ColumnExpression", "SQL"
216
+ ],
217
+ *values: Any,
213
218
  operator: Optional[str] = None,
219
+ **kwargs: Any,
214
220
  ) -> Self:
215
221
  """Add a WHERE clause to the statement.
216
222
 
217
223
  Args:
218
224
  condition: The condition for the WHERE clause. Can be:
219
- - A string condition (when value is None)
220
- - A string column name (when value is provided)
225
+ - A string condition with or without parameter placeholders
226
+ - A string column name (when values are provided)
221
227
  - A sqlglot Expression or Condition
222
228
  - A 2-tuple (column, value) for equality comparison
223
229
  - A 3-tuple (column, operator, value) for custom comparison
224
- value: Value for comparison (when condition is a column name)
225
- operator: Operator for comparison (when both condition and value provided)
230
+ *values: Positional values for parameter binding (when condition contains placeholders or is a column name)
231
+ operator: Operator for comparison (when condition is a column name)
232
+ **kwargs: Named parameters for parameter binding (when condition contains named placeholders)
226
233
 
227
234
  Raises:
228
235
  SQLBuilderError: If the current expression is not a supported statement type.
@@ -243,16 +250,63 @@ class WhereClauseMixin:
243
250
  msg = "WHERE clause requires a table to be set. Use from() to set the table first."
244
251
  raise SQLBuilderError(msg)
245
252
 
246
- if value is not None:
253
+ # Handle string conditions with external parameters
254
+ if values or kwargs:
247
255
  if not isinstance(condition, str):
248
- msg = "When value is provided, condition must be a column name (string)"
256
+ msg = "When values are provided, condition must be a string"
249
257
  raise SQLBuilderError(msg)
250
258
 
251
- if operator is not None:
252
- where_expr = self._process_tuple_condition((condition, operator, value))
259
+ # Check if condition contains parameter placeholders
260
+ from sqlspec.core.parameters import ParameterStyle, ParameterValidator
261
+
262
+ validator = ParameterValidator()
263
+ param_info = validator.extract_parameters(condition)
264
+
265
+ if param_info:
266
+ # String condition with placeholders - create SQL object with parameters
267
+ from sqlspec import sql as sql_factory
268
+
269
+ # Create parameter mapping based on the detected parameter info
270
+ param_dict = dict(kwargs) # Start with named parameters
271
+
272
+ # Handle positional parameters - these are ordinal-based ($1, $2, :1, :2, ?)
273
+ positional_params = [
274
+ param
275
+ for param in param_info
276
+ if param.style in {ParameterStyle.NUMERIC, ParameterStyle.POSITIONAL_COLON, ParameterStyle.QMARK}
277
+ ]
278
+
279
+ # Map positional values to positional parameters
280
+ if len(values) != len(positional_params):
281
+ msg = f"Parameter count mismatch: condition has {len(positional_params)} positional placeholders, got {len(values)} values"
282
+ raise SQLBuilderError(msg)
283
+
284
+ for i, value in enumerate(values):
285
+ param_dict[f"param_{i}"] = value
286
+
287
+ # Create SQL object with parameters that will be processed correctly
288
+ condition = sql_factory.raw(condition, **param_dict)
289
+ # Fall through to existing SQL object handling logic
290
+
291
+ elif len(values) == 1 and not kwargs:
292
+ # Single value - treat as column = value
293
+ if operator is not None:
294
+ where_expr = self._process_tuple_condition((condition, operator, values[0]))
295
+ else:
296
+ where_expr = self._process_tuple_condition((condition, values[0]))
297
+ # Process this condition and skip the rest
298
+ if isinstance(builder._expression, (exp.Select, exp.Update, exp.Delete)):
299
+ builder._expression = builder._expression.where(where_expr, copy=False)
300
+ else:
301
+ msg = f"WHERE clause not supported for {type(builder._expression).__name__}"
302
+ raise SQLBuilderError(msg)
303
+ return self
253
304
  else:
254
- where_expr = self._process_tuple_condition((condition, value))
255
- elif isinstance(condition, str):
305
+ msg = f"Cannot bind parameters to condition without placeholders: {condition}"
306
+ raise SQLBuilderError(msg)
307
+
308
+ # Handle all condition types (including SQL objects created above)
309
+ if isinstance(condition, str):
256
310
  where_expr = parse_condition_expression(condition)
257
311
  elif isinstance(condition, (exp.Expression, exp.Condition)):
258
312
  where_expr = condition
@@ -267,6 +321,25 @@ class WhereClauseMixin:
267
321
  where_expr = builder._parameterize_expression(raw_expr)
268
322
  else:
269
323
  where_expr = parse_condition_expression(str(condition))
324
+ elif hasattr(condition, "expression") and hasattr(condition, "sql"):
325
+ # Handle SQL objects (from sql.raw with parameters)
326
+ expression = getattr(condition, "expression", None)
327
+ if expression is not None and isinstance(expression, exp.Expression):
328
+ # Merge parameters from SQL object into builder
329
+ if hasattr(condition, "parameters") and hasattr(builder, "add_parameter"):
330
+ sql_parameters = getattr(condition, "parameters", {})
331
+ for param_name, param_value in sql_parameters.items():
332
+ builder.add_parameter(param_value, name=param_name)
333
+ where_expr = expression
334
+ else:
335
+ # If expression is None, fall back to parsing the raw SQL
336
+ sql_text = getattr(condition, "sql", "")
337
+ # Merge parameters even when parsing raw SQL
338
+ if hasattr(condition, "parameters") and hasattr(builder, "add_parameter"):
339
+ sql_parameters = getattr(condition, "parameters", {})
340
+ for param_name, param_value in sql_parameters.items():
341
+ builder.add_parameter(param_value, name=param_name)
342
+ where_expr = parse_condition_expression(sql_text)
270
343
  else:
271
344
  msg = f"Unsupported condition type: {type(condition).__name__}"
272
345
  raise SQLBuilderError(msg)
@@ -596,7 +669,6 @@ class HavingClauseMixin:
596
669
 
597
670
  __slots__ = ()
598
671
 
599
- # Type annotation for PyRight - this will be provided by the base class
600
672
  _expression: Optional[exp.Expression]
601
673
 
602
674
  def having(self, condition: Union[str, exp.Expression]) -> Self:
sqlspec/core/compiler.py CHANGED
@@ -277,11 +277,13 @@ class SQLProcessor:
277
277
  if self._config.parameter_config.needs_static_script_compilation and processed_params is None:
278
278
  final_sql, final_params = processed_sql, processed_params
279
279
  elif ast_was_transformed and expression is not None:
280
- final_sql = expression.sql(dialect=dialect_str)
281
- final_params = final_parameters
282
- logger.debug("AST was transformed - final SQL: %s, final params: %s", final_sql, final_params)
283
-
284
- # Apply output transformer if configured
280
+ final_sql, final_params = self._parameter_processor.process(
281
+ sql=expression.sql(dialect=dialect_str),
282
+ parameters=final_parameters,
283
+ config=self._config.parameter_config,
284
+ dialect=dialect_str,
285
+ is_many=is_many,
286
+ )
285
287
  output_transformer = self._config.output_transformer
286
288
  if output_transformer:
287
289
  final_sql, final_params = output_transformer(final_sql, final_params)
sqlspec/driver/_common.py CHANGED
@@ -275,7 +275,15 @@ class CommonDriverAttributesMixin:
275
275
  kwargs = kwargs or {}
276
276
 
277
277
  if isinstance(statement, QueryBuilder):
278
- return statement.to_statement(statement_config)
278
+ sql_statement = statement.to_statement(statement_config)
279
+ if parameters or kwargs:
280
+ merged_parameters = (
281
+ (*sql_statement._positional_parameters, *parameters)
282
+ if parameters
283
+ else sql_statement._positional_parameters
284
+ )
285
+ return SQL(sql_statement.sql, *merged_parameters, statement_config=statement_config, **kwargs)
286
+ return sql_statement
279
287
  if isinstance(statement, SQL):
280
288
  if parameters or kwargs:
281
289
  merged_parameters = (