sqlspec 0.25.0__py3-none-any.whl → 0.26.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 (84) hide show
  1. sqlspec/_serialization.py +223 -21
  2. sqlspec/_sql.py +12 -50
  3. sqlspec/_typing.py +9 -0
  4. sqlspec/adapters/adbc/config.py +8 -1
  5. sqlspec/adapters/adbc/data_dictionary.py +290 -0
  6. sqlspec/adapters/adbc/driver.py +127 -18
  7. sqlspec/adapters/adbc/type_converter.py +159 -0
  8. sqlspec/adapters/aiosqlite/config.py +3 -0
  9. sqlspec/adapters/aiosqlite/data_dictionary.py +117 -0
  10. sqlspec/adapters/aiosqlite/driver.py +17 -3
  11. sqlspec/adapters/asyncmy/_types.py +1 -1
  12. sqlspec/adapters/asyncmy/config.py +11 -8
  13. sqlspec/adapters/asyncmy/data_dictionary.py +122 -0
  14. sqlspec/adapters/asyncmy/driver.py +31 -7
  15. sqlspec/adapters/asyncpg/config.py +3 -0
  16. sqlspec/adapters/asyncpg/data_dictionary.py +134 -0
  17. sqlspec/adapters/asyncpg/driver.py +19 -4
  18. sqlspec/adapters/bigquery/config.py +3 -0
  19. sqlspec/adapters/bigquery/data_dictionary.py +109 -0
  20. sqlspec/adapters/bigquery/driver.py +21 -3
  21. sqlspec/adapters/bigquery/type_converter.py +93 -0
  22. sqlspec/adapters/duckdb/_types.py +1 -1
  23. sqlspec/adapters/duckdb/config.py +2 -0
  24. sqlspec/adapters/duckdb/data_dictionary.py +124 -0
  25. sqlspec/adapters/duckdb/driver.py +32 -5
  26. sqlspec/adapters/duckdb/pool.py +1 -1
  27. sqlspec/adapters/duckdb/type_converter.py +103 -0
  28. sqlspec/adapters/oracledb/config.py +6 -0
  29. sqlspec/adapters/oracledb/data_dictionary.py +442 -0
  30. sqlspec/adapters/oracledb/driver.py +63 -9
  31. sqlspec/adapters/oracledb/migrations.py +51 -67
  32. sqlspec/adapters/oracledb/type_converter.py +132 -0
  33. sqlspec/adapters/psqlpy/config.py +3 -0
  34. sqlspec/adapters/psqlpy/data_dictionary.py +133 -0
  35. sqlspec/adapters/psqlpy/driver.py +23 -179
  36. sqlspec/adapters/psqlpy/type_converter.py +73 -0
  37. sqlspec/adapters/psycopg/config.py +6 -0
  38. sqlspec/adapters/psycopg/data_dictionary.py +257 -0
  39. sqlspec/adapters/psycopg/driver.py +40 -5
  40. sqlspec/adapters/sqlite/config.py +3 -0
  41. sqlspec/adapters/sqlite/data_dictionary.py +117 -0
  42. sqlspec/adapters/sqlite/driver.py +18 -3
  43. sqlspec/adapters/sqlite/pool.py +13 -4
  44. sqlspec/builder/_base.py +82 -42
  45. sqlspec/builder/_column.py +57 -24
  46. sqlspec/builder/_ddl.py +84 -34
  47. sqlspec/builder/_insert.py +30 -52
  48. sqlspec/builder/_parsing_utils.py +104 -8
  49. sqlspec/builder/_select.py +147 -2
  50. sqlspec/builder/mixins/_cte_and_set_ops.py +1 -2
  51. sqlspec/builder/mixins/_join_operations.py +14 -30
  52. sqlspec/builder/mixins/_merge_operations.py +167 -61
  53. sqlspec/builder/mixins/_order_limit_operations.py +3 -10
  54. sqlspec/builder/mixins/_select_operations.py +3 -9
  55. sqlspec/builder/mixins/_update_operations.py +3 -22
  56. sqlspec/builder/mixins/_where_clause.py +4 -10
  57. sqlspec/cli.py +246 -140
  58. sqlspec/config.py +33 -19
  59. sqlspec/core/cache.py +2 -2
  60. sqlspec/core/compiler.py +56 -1
  61. sqlspec/core/parameters.py +7 -3
  62. sqlspec/core/statement.py +5 -0
  63. sqlspec/core/type_conversion.py +234 -0
  64. sqlspec/driver/__init__.py +6 -3
  65. sqlspec/driver/_async.py +106 -3
  66. sqlspec/driver/_common.py +156 -4
  67. sqlspec/driver/_sync.py +106 -3
  68. sqlspec/exceptions.py +5 -0
  69. sqlspec/migrations/__init__.py +4 -3
  70. sqlspec/migrations/base.py +153 -14
  71. sqlspec/migrations/commands.py +34 -96
  72. sqlspec/migrations/context.py +145 -0
  73. sqlspec/migrations/loaders.py +25 -8
  74. sqlspec/migrations/runner.py +352 -82
  75. sqlspec/typing.py +2 -0
  76. sqlspec/utils/config_resolver.py +153 -0
  77. sqlspec/utils/serializers.py +50 -2
  78. {sqlspec-0.25.0.dist-info → sqlspec-0.26.0.dist-info}/METADATA +1 -1
  79. sqlspec-0.26.0.dist-info/RECORD +157 -0
  80. sqlspec-0.25.0.dist-info/RECORD +0 -139
  81. {sqlspec-0.25.0.dist-info → sqlspec-0.26.0.dist-info}/WHEEL +0 -0
  82. {sqlspec-0.25.0.dist-info → sqlspec-0.26.0.dist-info}/entry_points.txt +0 -0
  83. {sqlspec-0.25.0.dist-info → sqlspec-0.26.0.dist-info}/licenses/LICENSE +0 -0
  84. {sqlspec-0.25.0.dist-info → sqlspec-0.26.0.dist-info}/licenses/NOTICE +0 -0
@@ -5,6 +5,7 @@ SQL conditions with parameter binding.
5
5
  """
6
6
 
7
7
  from collections.abc import Iterable
8
+ from datetime import date, datetime
8
9
  from typing import Any, Optional, cast
9
10
 
10
11
  from sqlglot import exp
@@ -14,6 +15,24 @@ from sqlspec.utils.type_guards import has_sql_method
14
15
  __all__ = ("Column", "ColumnExpression", "FunctionColumn")
15
16
 
16
17
 
18
+ def _convert_value(value: Any) -> exp.Expression:
19
+ """Convert a Python value to a SQLGlot expression.
20
+
21
+ Special handling for datetime objects to prevent SQLGlot from
22
+ converting them to TIME_STR_TO_TIME function calls. Datetime
23
+ objects should be passed as parameters, not converted to SQL functions.
24
+
25
+ Args:
26
+ value: The value to convert
27
+
28
+ Returns:
29
+ A SQLGlot expression representing the value
30
+ """
31
+ if isinstance(value, (datetime, date)):
32
+ return exp.Literal(this=value, is_string=False)
33
+ return exp.convert(value)
34
+
35
+
17
36
  class ColumnExpression:
18
37
  """Base class for column expressions that can be combined with operators."""
19
38
 
@@ -67,33 +86,37 @@ class Column:
67
86
  else:
68
87
  self._expression = exp.Column(this=exp.Identifier(this=name))
69
88
 
89
+ def _convert_value(self, value: Any) -> exp.Expression:
90
+ """Convert a Python value to a SQLGlot expression."""
91
+ return _convert_value(value)
92
+
70
93
  def __eq__(self, other: object) -> ColumnExpression: # type: ignore[override]
71
94
  """Equal to (==)."""
72
95
  if other is None:
73
96
  return ColumnExpression(exp.Is(this=self._expression, expression=exp.Null()))
74
- return ColumnExpression(exp.EQ(this=self._expression, expression=exp.convert(other)))
97
+ return ColumnExpression(exp.EQ(this=self._expression, expression=self._convert_value(other)))
75
98
 
76
99
  def __ne__(self, other: object) -> ColumnExpression: # type: ignore[override]
77
100
  """Not equal to (!=)."""
78
101
  if other is None:
79
102
  return ColumnExpression(exp.Not(this=exp.Is(this=self._expression, expression=exp.Null())))
80
- return ColumnExpression(exp.NEQ(this=self._expression, expression=exp.convert(other)))
103
+ return ColumnExpression(exp.NEQ(this=self._expression, expression=self._convert_value(other)))
81
104
 
82
105
  def __gt__(self, other: Any) -> ColumnExpression:
83
106
  """Greater than (>)."""
84
- return ColumnExpression(exp.GT(this=self._expression, expression=exp.convert(other)))
107
+ return ColumnExpression(exp.GT(this=self._expression, expression=self._convert_value(other)))
85
108
 
86
109
  def __ge__(self, other: Any) -> ColumnExpression:
87
110
  """Greater than or equal (>=)."""
88
- return ColumnExpression(exp.GTE(this=self._expression, expression=exp.convert(other)))
111
+ return ColumnExpression(exp.GTE(this=self._expression, expression=self._convert_value(other)))
89
112
 
90
113
  def __lt__(self, other: Any) -> ColumnExpression:
91
114
  """Less than (<)."""
92
- return ColumnExpression(exp.LT(this=self._expression, expression=exp.convert(other)))
115
+ return ColumnExpression(exp.LT(this=self._expression, expression=self._convert_value(other)))
93
116
 
94
117
  def __le__(self, other: Any) -> ColumnExpression:
95
118
  """Less than or equal (<=)."""
96
- return ColumnExpression(exp.LTE(this=self._expression, expression=exp.convert(other)))
119
+ return ColumnExpression(exp.LTE(this=self._expression, expression=self._convert_value(other)))
97
120
 
98
121
  def __invert__(self) -> ColumnExpression:
99
122
  """Apply NOT operator (~)."""
@@ -102,18 +125,20 @@ class Column:
102
125
  def like(self, pattern: str, escape: Optional[str] = None) -> ColumnExpression:
103
126
  """SQL LIKE pattern matching."""
104
127
  if escape:
105
- like_expr = exp.Like(this=self._expression, expression=exp.convert(pattern), escape=exp.convert(escape))
128
+ like_expr = exp.Like(
129
+ this=self._expression, expression=self._convert_value(pattern), escape=self._convert_value(escape)
130
+ )
106
131
  else:
107
- like_expr = exp.Like(this=self._expression, expression=exp.convert(pattern))
132
+ like_expr = exp.Like(this=self._expression, expression=self._convert_value(pattern))
108
133
  return ColumnExpression(like_expr)
109
134
 
110
135
  def ilike(self, pattern: str) -> ColumnExpression:
111
136
  """Case-insensitive LIKE."""
112
- return ColumnExpression(exp.ILike(this=self._expression, expression=exp.convert(pattern)))
137
+ return ColumnExpression(exp.ILike(this=self._expression, expression=self._convert_value(pattern)))
113
138
 
114
139
  def in_(self, values: Iterable[Any]) -> ColumnExpression:
115
140
  """SQL IN clause."""
116
- converted_values = [exp.convert(v) for v in values]
141
+ converted_values = [self._convert_value(v) for v in values]
117
142
  return ColumnExpression(exp.In(this=self._expression, expressions=converted_values))
118
143
 
119
144
  def not_in(self, values: Iterable[Any]) -> ColumnExpression:
@@ -122,7 +147,9 @@ class Column:
122
147
 
123
148
  def between(self, start: Any, end: Any) -> ColumnExpression:
124
149
  """SQL BETWEEN clause."""
125
- return ColumnExpression(exp.Between(this=self._expression, low=exp.convert(start), high=exp.convert(end)))
150
+ return ColumnExpression(
151
+ exp.Between(this=self._expression, low=self._convert_value(start), high=self._convert_value(end))
152
+ )
126
153
 
127
154
  def is_null(self) -> ColumnExpression:
128
155
  """SQL IS NULL."""
@@ -142,12 +169,12 @@ class Column:
142
169
 
143
170
  def any_(self, values: Iterable[Any]) -> ColumnExpression:
144
171
  """SQL = ANY(...) clause."""
145
- converted_values = [exp.convert(v) for v in values]
172
+ converted_values = [self._convert_value(v) for v in values]
146
173
  return ColumnExpression(exp.EQ(this=self._expression, expression=exp.Any(expressions=converted_values)))
147
174
 
148
175
  def not_any_(self, values: Iterable[Any]) -> ColumnExpression:
149
176
  """SQL <> ANY(...) clause."""
150
- converted_values = [exp.convert(v) for v in values]
177
+ converted_values = [self._convert_value(v) for v in values]
151
178
  return ColumnExpression(exp.NEQ(this=self._expression, expression=exp.Any(expressions=converted_values)))
152
179
 
153
180
  def lower(self) -> "FunctionColumn":
@@ -186,14 +213,14 @@ class Column:
186
213
 
187
214
  def substring(self, start: int, length: Optional[int] = None) -> "FunctionColumn":
188
215
  """SQL SUBSTRING() function."""
189
- args = [exp.convert(start)]
216
+ args = [self._convert_value(start)]
190
217
  if length is not None:
191
- args.append(exp.convert(length))
218
+ args.append(self._convert_value(length))
192
219
  return FunctionColumn(exp.Substring(this=self._expression, expressions=args))
193
220
 
194
221
  def coalesce(self, *values: Any) -> "FunctionColumn":
195
222
  """SQL COALESCE() function."""
196
- expressions = [self._expression] + [exp.convert(v) for v in values]
223
+ expressions = [self._expression] + [self._convert_value(v) for v in values]
197
224
  return FunctionColumn(exp.Coalesce(expressions=expressions))
198
225
 
199
226
  def cast(self, data_type: str) -> "FunctionColumn":
@@ -272,22 +299,26 @@ class FunctionColumn:
272
299
  def __init__(self, expression: exp.Expression) -> None:
273
300
  self._expression = expression
274
301
 
302
+ def _convert_value(self, value: Any) -> exp.Expression:
303
+ """Convert a Python value to a SQLGlot expression."""
304
+ return _convert_value(value)
305
+
275
306
  def __eq__(self, other: object) -> ColumnExpression: # type: ignore[override]
276
- return ColumnExpression(exp.EQ(this=self._expression, expression=exp.convert(other)))
307
+ return ColumnExpression(exp.EQ(this=self._expression, expression=self._convert_value(other)))
277
308
 
278
309
  def __ne__(self, other: object) -> ColumnExpression: # type: ignore[override]
279
- return ColumnExpression(exp.NEQ(this=self._expression, expression=exp.convert(other)))
310
+ return ColumnExpression(exp.NEQ(this=self._expression, expression=self._convert_value(other)))
280
311
 
281
312
  def like(self, pattern: str) -> ColumnExpression:
282
- return ColumnExpression(exp.Like(this=self._expression, expression=exp.convert(pattern)))
313
+ return ColumnExpression(exp.Like(this=self._expression, expression=self._convert_value(pattern)))
283
314
 
284
315
  def ilike(self, pattern: str) -> ColumnExpression:
285
316
  """Case-insensitive LIKE."""
286
- return ColumnExpression(exp.ILike(this=self._expression, expression=exp.convert(pattern)))
317
+ return ColumnExpression(exp.ILike(this=self._expression, expression=self._convert_value(pattern)))
287
318
 
288
319
  def in_(self, values: Iterable[Any]) -> ColumnExpression:
289
320
  """SQL IN clause."""
290
- converted_values = [exp.convert(v) for v in values]
321
+ converted_values = [self._convert_value(v) for v in values]
291
322
  return ColumnExpression(exp.In(this=self._expression, expressions=converted_values))
292
323
 
293
324
  def not_in_(self, values: Iterable[Any]) -> ColumnExpression:
@@ -304,7 +335,9 @@ class FunctionColumn:
304
335
 
305
336
  def between(self, start: Any, end: Any) -> ColumnExpression:
306
337
  """SQL BETWEEN clause."""
307
- return ColumnExpression(exp.Between(this=self._expression, low=exp.convert(start), high=exp.convert(end)))
338
+ return ColumnExpression(
339
+ exp.Between(this=self._expression, low=self._convert_value(start), high=self._convert_value(end))
340
+ )
308
341
 
309
342
  def is_null(self) -> ColumnExpression:
310
343
  """SQL IS NULL."""
@@ -316,12 +349,12 @@ class FunctionColumn:
316
349
 
317
350
  def any_(self, values: Iterable[Any]) -> ColumnExpression:
318
351
  """SQL = ANY(...) clause."""
319
- converted_values = [exp.convert(v) for v in values]
352
+ converted_values = [self._convert_value(v) for v in values]
320
353
  return ColumnExpression(exp.EQ(this=self._expression, expression=exp.Any(expressions=converted_values)))
321
354
 
322
355
  def not_any_(self, values: Iterable[Any]) -> ColumnExpression:
323
356
  """SQL <> ANY(...) clause."""
324
- converted_values = [exp.convert(v) for v in values]
357
+ converted_values = [self._convert_value(v) for v in values]
325
358
  return ColumnExpression(exp.NEQ(this=self._expression, expression=exp.Any(expressions=converted_values)))
326
359
 
327
360
  def alias(self, alias_name: str) -> exp.Expression:
sqlspec/builder/_ddl.py CHANGED
@@ -11,12 +11,14 @@ from sqlglot.dialects.dialect import DialectType
11
11
  from typing_extensions import Self
12
12
 
13
13
  from sqlspec.builder._base import QueryBuilder, SafeQuery
14
+ from sqlspec.builder._select import Select
14
15
  from sqlspec.core.result import SQLResult
16
+ from sqlspec.core.statement import SQL
15
17
  from sqlspec.utils.type_guards import has_sqlglot_expression, has_with_method
16
18
 
17
19
  if TYPE_CHECKING:
18
20
  from sqlspec.builder._column import ColumnExpression
19
- from sqlspec.core.statement import SQL, StatementConfig
21
+ from sqlspec.core.statement import StatementConfig
20
22
 
21
23
  __all__ = (
22
24
  "AlterOperation",
@@ -39,6 +41,37 @@ __all__ = (
39
41
  "Truncate",
40
42
  )
41
43
 
44
+ CONSTRAINT_TYPE_PRIMARY_KEY = "PRIMARY KEY"
45
+ CONSTRAINT_TYPE_FOREIGN_KEY = "FOREIGN KEY"
46
+ CONSTRAINT_TYPE_UNIQUE = "UNIQUE"
47
+ CONSTRAINT_TYPE_CHECK = "CHECK"
48
+
49
+ FOREIGN_KEY_ACTION_CASCADE = "CASCADE"
50
+ FOREIGN_KEY_ACTION_SET_NULL = "SET NULL"
51
+ FOREIGN_KEY_ACTION_SET_DEFAULT = "SET DEFAULT"
52
+ FOREIGN_KEY_ACTION_RESTRICT = "RESTRICT"
53
+ FOREIGN_KEY_ACTION_NO_ACTION = "NO ACTION"
54
+
55
+ VALID_FOREIGN_KEY_ACTIONS = {
56
+ FOREIGN_KEY_ACTION_CASCADE,
57
+ FOREIGN_KEY_ACTION_SET_NULL,
58
+ FOREIGN_KEY_ACTION_SET_DEFAULT,
59
+ FOREIGN_KEY_ACTION_RESTRICT,
60
+ FOREIGN_KEY_ACTION_NO_ACTION,
61
+ None,
62
+ }
63
+
64
+ VALID_CONSTRAINT_TYPES = {
65
+ CONSTRAINT_TYPE_PRIMARY_KEY,
66
+ CONSTRAINT_TYPE_FOREIGN_KEY,
67
+ CONSTRAINT_TYPE_UNIQUE,
68
+ CONSTRAINT_TYPE_CHECK,
69
+ }
70
+
71
+ CURRENT_TIMESTAMP_KEYWORD = "CURRENT_TIMESTAMP"
72
+ CURRENT_DATE_KEYWORD = "CURRENT_DATE"
73
+ CURRENT_TIME_KEYWORD = "CURRENT_TIME"
74
+
42
75
 
43
76
  def build_column_expression(col: "ColumnDefinition") -> "exp.Expression":
44
77
  """Build SQLGlot expression for a column definition."""
@@ -59,11 +92,11 @@ def build_column_expression(col: "ColumnDefinition") -> "exp.Expression":
59
92
  default_expr: Optional[exp.Expression] = None
60
93
  if isinstance(col.default, str):
61
94
  default_upper = col.default.upper()
62
- if default_upper == "CURRENT_TIMESTAMP":
95
+ if default_upper == CURRENT_TIMESTAMP_KEYWORD:
63
96
  default_expr = exp.CurrentTimestamp()
64
- elif default_upper == "CURRENT_DATE":
97
+ elif default_upper == CURRENT_DATE_KEYWORD:
65
98
  default_expr = exp.CurrentDate()
66
- elif default_upper == "CURRENT_TIME":
99
+ elif default_upper == CURRENT_TIME_KEYWORD:
67
100
  default_expr = exp.CurrentTime()
68
101
  elif "(" in col.default:
69
102
  default_expr = exp.maybe_parse(col.default)
@@ -96,14 +129,14 @@ def build_column_expression(col: "ColumnDefinition") -> "exp.Expression":
96
129
 
97
130
  def build_constraint_expression(constraint: "ConstraintDefinition") -> "Optional[exp.Expression]":
98
131
  """Build SQLGlot expression for a table constraint."""
99
- if constraint.constraint_type == "PRIMARY KEY":
132
+ if constraint.constraint_type == CONSTRAINT_TYPE_PRIMARY_KEY:
100
133
  pk_constraint = exp.PrimaryKey(expressions=[exp.to_identifier(col) for col in constraint.columns])
101
134
 
102
135
  if constraint.name:
103
136
  return exp.Constraint(this=exp.to_identifier(constraint.name), expression=pk_constraint)
104
137
  return pk_constraint
105
138
 
106
- if constraint.constraint_type == "FOREIGN KEY":
139
+ if constraint.constraint_type == CONSTRAINT_TYPE_FOREIGN_KEY:
107
140
  fk_constraint = exp.ForeignKey(
108
141
  expressions=[exp.to_identifier(col) for col in constraint.columns],
109
142
  reference=exp.Reference(
@@ -118,14 +151,14 @@ def build_constraint_expression(constraint: "ConstraintDefinition") -> "Optional
118
151
  return exp.Constraint(this=exp.to_identifier(constraint.name), expression=fk_constraint)
119
152
  return fk_constraint
120
153
 
121
- if constraint.constraint_type == "UNIQUE":
154
+ if constraint.constraint_type == CONSTRAINT_TYPE_UNIQUE:
122
155
  unique_constraint = exp.UniqueKeyProperty(expressions=[exp.to_identifier(col) for col in constraint.columns])
123
156
 
124
157
  if constraint.name:
125
158
  return exp.Constraint(this=exp.to_identifier(constraint.name), expression=unique_constraint)
126
159
  return unique_constraint
127
160
 
128
- if constraint.constraint_type == "CHECK":
161
+ if constraint.constraint_type == CONSTRAINT_TYPE_CHECK:
129
162
  check_expr = exp.Check(this=exp.maybe_parse(constraint.condition) if constraint.condition else None)
130
163
 
131
164
  if constraint.name:
@@ -356,7 +389,7 @@ class CreateTable(DDLBuilder):
356
389
 
357
390
  self._columns.append(column_def)
358
391
 
359
- if primary_key and not any(c.constraint_type == "PRIMARY KEY" for c in self._constraints):
392
+ if primary_key and not self._has_primary_key_constraint():
360
393
  self.primary_key_constraint([name])
361
394
 
362
395
  return self
@@ -368,13 +401,13 @@ class CreateTable(DDLBuilder):
368
401
  if not col_list:
369
402
  self._raise_sql_builder_error("Primary key must include at least one column")
370
403
 
371
- existing_pk = next((c for c in self._constraints if c.constraint_type == "PRIMARY KEY"), None)
404
+ existing_pk = self._find_primary_key_constraint()
372
405
  if existing_pk:
373
406
  for col in col_list:
374
407
  if col not in existing_pk.columns:
375
408
  existing_pk.columns.append(col)
376
409
  else:
377
- constraint = ConstraintDefinition(constraint_type="PRIMARY KEY", name=name, columns=col_list)
410
+ constraint = ConstraintDefinition(constraint_type=CONSTRAINT_TYPE_PRIMARY_KEY, name=name, columns=col_list)
378
411
  self._constraints.append(constraint)
379
412
 
380
413
  return self
@@ -398,14 +431,11 @@ class CreateTable(DDLBuilder):
398
431
  if len(col_list) != len(ref_col_list):
399
432
  self._raise_sql_builder_error("Foreign key columns and referenced columns must have same length")
400
433
 
401
- valid_actions = {"CASCADE", "SET NULL", "SET DEFAULT", "RESTRICT", "NO ACTION", None}
402
- if on_delete and on_delete.upper() not in valid_actions:
403
- self._raise_sql_builder_error(f"Invalid ON DELETE action: {on_delete}")
404
- if on_update and on_update.upper() not in valid_actions:
405
- self._raise_sql_builder_error(f"Invalid ON UPDATE action: {on_update}")
434
+ self._validate_foreign_key_action(on_delete, "ON DELETE")
435
+ self._validate_foreign_key_action(on_update, "ON UPDATE")
406
436
 
407
437
  constraint = ConstraintDefinition(
408
- constraint_type="FOREIGN KEY",
438
+ constraint_type=CONSTRAINT_TYPE_FOREIGN_KEY,
409
439
  name=name,
410
440
  columns=col_list,
411
441
  references_table=references_table,
@@ -426,7 +456,7 @@ class CreateTable(DDLBuilder):
426
456
  if not col_list:
427
457
  self._raise_sql_builder_error("Unique constraint must include at least one column")
428
458
 
429
- constraint = ConstraintDefinition(constraint_type="UNIQUE", name=name, columns=col_list)
459
+ constraint = ConstraintDefinition(constraint_type=CONSTRAINT_TYPE_UNIQUE, name=name, columns=col_list)
430
460
 
431
461
  self._constraints.append(constraint)
432
462
  return self
@@ -443,7 +473,7 @@ class CreateTable(DDLBuilder):
443
473
  else:
444
474
  condition_str = str(condition)
445
475
 
446
- constraint = ConstraintDefinition(constraint_type="CHECK", name=name, condition=condition_str)
476
+ constraint = ConstraintDefinition(constraint_type=CONSTRAINT_TYPE_CHECK, name=name, condition=condition_str)
447
477
 
448
478
  self._constraints.append(constraint)
449
479
  return self
@@ -484,10 +514,8 @@ class CreateTable(DDLBuilder):
484
514
  column_defs.append(col_expr)
485
515
 
486
516
  for constraint in self._constraints:
487
- if constraint.constraint_type == "PRIMARY KEY" and len(constraint.columns) == 1:
488
- col_name = constraint.columns[0]
489
- if any(c.name == col_name and c.primary_key for c in self._columns):
490
- continue
517
+ if self._is_redundant_single_column_primary_key(constraint):
518
+ continue
491
519
 
492
520
  constraint_expr = build_constraint_expression(constraint)
493
521
  if constraint_expr:
@@ -531,6 +559,27 @@ class CreateTable(DDLBuilder):
531
559
  like=like_expr,
532
560
  )
533
561
 
562
+ def _has_primary_key_constraint(self) -> bool:
563
+ """Check if table already has a primary key constraint."""
564
+ return any(c.constraint_type == CONSTRAINT_TYPE_PRIMARY_KEY for c in self._constraints)
565
+
566
+ def _find_primary_key_constraint(self) -> "Optional[ConstraintDefinition]":
567
+ """Find existing primary key constraint."""
568
+ return next((c for c in self._constraints if c.constraint_type == CONSTRAINT_TYPE_PRIMARY_KEY), None)
569
+
570
+ def _validate_foreign_key_action(self, action: "Optional[str]", action_type: str) -> None:
571
+ """Validate foreign key action (ON DELETE or ON UPDATE)."""
572
+ if action and action.upper() not in VALID_FOREIGN_KEY_ACTIONS:
573
+ self._raise_sql_builder_error(f"Invalid {action_type} action: {action}")
574
+
575
+ def _is_redundant_single_column_primary_key(self, constraint: "ConstraintDefinition") -> bool:
576
+ """Check if constraint is a redundant single-column primary key."""
577
+ if constraint.constraint_type != CONSTRAINT_TYPE_PRIMARY_KEY or len(constraint.columns) != 1:
578
+ return False
579
+
580
+ col_name = constraint.columns[0]
581
+ return any(c.name == col_name and c.primary_key for c in self._columns)
582
+
534
583
 
535
584
  class DropTable(DDLBuilder):
536
585
  """Builder for DROP TABLE [IF EXISTS] ... [CASCADE|RESTRICT]."""
@@ -966,8 +1015,6 @@ class CreateTableAsSelect(DDLBuilder):
966
1015
 
967
1016
  select_expr = None
968
1017
  select_parameters = None
969
- from sqlspec.builder._select import Select
970
- from sqlspec.core.statement import SQL
971
1018
 
972
1019
  if isinstance(self._select_query, SQL):
973
1020
  select_expr = self._select_query.expression
@@ -1093,8 +1140,6 @@ class CreateMaterializedView(DDLBuilder):
1093
1140
 
1094
1141
  select_expr = None
1095
1142
  select_parameters = None
1096
- from sqlspec.builder._select import Select
1097
- from sqlspec.core.statement import SQL
1098
1143
 
1099
1144
  if isinstance(self._select_query, SQL):
1100
1145
  select_expr = self._select_query.expression
@@ -1191,8 +1236,6 @@ class CreateView(DDLBuilder):
1191
1236
 
1192
1237
  select_expr = None
1193
1238
  select_parameters = None
1194
- from sqlspec.builder._select import Select
1195
- from sqlspec.core.statement import SQL
1196
1239
 
1197
1240
  if isinstance(self._select_query, SQL):
1198
1241
  select_expr = self._select_query.expression
@@ -1347,8 +1390,7 @@ class AlterTable(DDLBuilder):
1347
1390
  on_delete: Foreign key ON DELETE action
1348
1391
  on_update: Foreign key ON UPDATE action
1349
1392
  """
1350
- valid_types = {"PRIMARY KEY", "FOREIGN KEY", "UNIQUE", "CHECK"}
1351
- if constraint_type.upper() not in valid_types:
1393
+ if constraint_type.upper() not in VALID_CONSTRAINT_TYPES:
1352
1394
  self._raise_sql_builder_error(f"Invalid constraint type: {constraint_type}")
1353
1395
 
1354
1396
  col_list = None
@@ -1361,8 +1403,8 @@ class AlterTable(DDLBuilder):
1361
1403
 
1362
1404
  condition_str: Optional[str] = None
1363
1405
  if condition is not None:
1364
- if hasattr(condition, "sqlglot_expression"):
1365
- sqlglot_expr = getattr(condition, "sqlglot_expression", None)
1406
+ if has_sqlglot_expression(condition):
1407
+ sqlglot_expr = condition.sqlglot_expression
1366
1408
  condition_str = sqlglot_expr.sql(dialect=self.dialect) if sqlglot_expr else str(condition)
1367
1409
  else:
1368
1410
  condition_str = str(condition)
@@ -1474,7 +1516,7 @@ class AlterTable(DDLBuilder):
1474
1516
  default_val = op.column_definition.default
1475
1517
  default_expr: Optional[exp.Expression]
1476
1518
  if isinstance(default_val, str):
1477
- if default_val.upper() in {"CURRENT_TIMESTAMP", "CURRENT_DATE", "CURRENT_TIME"} or "(" in default_val:
1519
+ if self._is_sql_function_default(default_val):
1478
1520
  default_expr = exp.maybe_parse(default_val)
1479
1521
  else:
1480
1522
  default_expr = exp.convert(default_val)
@@ -1494,6 +1536,14 @@ class AlterTable(DDLBuilder):
1494
1536
  self._raise_sql_builder_error(f"Unknown operation type: {op.operation_type}")
1495
1537
  raise AssertionError
1496
1538
 
1539
+ def _is_sql_function_default(self, default_val: str) -> bool:
1540
+ """Check if default value is a SQL function or expression."""
1541
+ default_upper = default_val.upper()
1542
+ return (
1543
+ default_upper in {CURRENT_TIMESTAMP_KEYWORD, CURRENT_DATE_KEYWORD, CURRENT_TIME_KEYWORD}
1544
+ or "(" in default_val
1545
+ )
1546
+
1497
1547
 
1498
1548
  class CommentOn(DDLBuilder):
1499
1549
  """Builder for COMMENT ON ... IS ... statements."""
@@ -10,6 +10,7 @@ from sqlglot import exp
10
10
  from typing_extensions import Self
11
11
 
12
12
  from sqlspec.builder._base import QueryBuilder
13
+ from sqlspec.builder._parsing_utils import extract_sql_object_expression
13
14
  from sqlspec.builder.mixins import InsertFromSelectMixin, InsertIntoClauseMixin, InsertValuesMixin, ReturningClauseMixin
14
15
  from sqlspec.core.result import SQLResult
15
16
  from sqlspec.exceptions import SQLBuilderError
@@ -46,7 +47,6 @@ class Insert(QueryBuilder, ReturningClauseMixin, InsertValuesMixin, InsertFromSe
46
47
  """
47
48
  super().__init__(**kwargs)
48
49
 
49
- # Initialize Insert-specific attributes
50
50
  self._table: Optional[str] = None
51
51
  self._columns: list[str] = []
52
52
  self._values_added_count: int = 0
@@ -130,7 +130,7 @@ class Insert(QueryBuilder, ReturningClauseMixin, InsertValuesMixin, InsertFromSe
130
130
 
131
131
  if len(values) == 1:
132
132
  values_0 = values[0]
133
- if hasattr(values_0, "items") and hasattr(values_0, "keys"):
133
+ if isinstance(values_0, dict):
134
134
  return self.values_from_dict(values_0)
135
135
 
136
136
  insert_expr = self.get_insert_expression()
@@ -144,22 +144,8 @@ class Insert(QueryBuilder, ReturningClauseMixin, InsertValuesMixin, InsertFromSe
144
144
  if isinstance(value, exp.Expression):
145
145
  value_placeholders.append(value)
146
146
  elif has_expression_and_sql(value):
147
- # Handle SQL objects (from sql.raw with parameters)
148
- expression = getattr(value, "expression", None)
149
- if expression is not None and isinstance(expression, exp.Expression):
150
- # Merge parameters from SQL object into builder
151
- self._merge_sql_object_parameters(value)
152
- value_placeholders.append(expression)
153
- else:
154
- # If expression is None, fall back to parsing the raw SQL
155
- sql_text = getattr(value, "sql", "")
156
- # Merge parameters even when parsing raw SQL
157
- self._merge_sql_object_parameters(value)
158
- # Check if sql_text is callable (like Expression.sql method)
159
- if callable(sql_text):
160
- sql_text = str(value)
161
- value_expr = exp.maybe_parse(sql_text) or exp.convert(str(sql_text))
162
- value_placeholders.append(value_expr)
147
+ value_expr = extract_sql_object_expression(value, builder=self)
148
+ value_placeholders.append(value_expr)
163
149
  else:
164
150
  if self._columns and i < len(self._columns):
165
151
  column_str = str(self._columns[i])
@@ -258,17 +244,14 @@ class Insert(QueryBuilder, ReturningClauseMixin, InsertValuesMixin, InsertFromSe
258
244
 
259
245
  Example:
260
246
  ```python
261
- # ON CONFLICT (id) DO NOTHING
262
247
  sql.insert("users").values(id=1, name="John").on_conflict(
263
248
  "id"
264
249
  ).do_nothing()
265
250
 
266
- # ON CONFLICT (email, username) DO UPDATE SET updated_at = NOW()
267
251
  sql.insert("users").values(...).on_conflict(
268
252
  "email", "username"
269
253
  ).do_update(updated_at=sql.raw("NOW()"))
270
254
 
271
- # ON CONFLICT DO NOTHING (catches all conflicts)
272
255
  sql.insert("users").values(...).on_conflict().do_nothing()
273
256
  ```
274
257
  """
@@ -290,22 +273,41 @@ class Insert(QueryBuilder, ReturningClauseMixin, InsertValuesMixin, InsertFromSe
290
273
  return self.on_conflict(*columns).do_nothing()
291
274
 
292
275
  def on_duplicate_key_update(self, **kwargs: Any) -> "Insert":
293
- """Adds conflict resolution using the ON CONFLICT syntax (cross-database compatible).
276
+ """Adds MySQL-style ON DUPLICATE KEY UPDATE clause.
294
277
 
295
278
  Args:
296
- **kwargs: Column-value pairs to update on conflict.
279
+ **kwargs: Column-value pairs to update on duplicate key.
297
280
 
298
281
  Returns:
299
282
  The current builder instance for method chaining.
300
283
 
301
284
  Note:
302
- This method uses PostgreSQL-style ON CONFLICT syntax but SQLGlot will
303
- transpile it to the appropriate syntax for each database (MySQL's
304
- ON DUPLICATE KEY UPDATE, etc.).
285
+ This method creates MySQL-specific ON DUPLICATE KEY UPDATE syntax.
286
+ For PostgreSQL, use on_conflict() instead.
305
287
  """
306
288
  if not kwargs:
307
289
  return self
308
- return self.on_conflict().do_update(**kwargs)
290
+
291
+ insert_expr = self._get_insert_expression()
292
+
293
+ set_expressions = []
294
+ for col, val in kwargs.items():
295
+ if has_expression_and_sql(val):
296
+ value_expr = extract_sql_object_expression(val, builder=self)
297
+ elif isinstance(val, exp.Expression):
298
+ value_expr = val
299
+ else:
300
+ param_name = self.generate_unique_parameter_name(col)
301
+ _, param_name = self.add_parameter(val, name=param_name)
302
+ value_expr = exp.Placeholder(this=param_name)
303
+
304
+ set_expressions.append(exp.EQ(this=exp.column(col), expression=value_expr))
305
+
306
+ on_conflict = exp.OnConflict(duplicate=True, action=exp.var("UPDATE"), expressions=set_expressions or None)
307
+
308
+ insert_expr.set("conflict", on_conflict)
309
+
310
+ return self
309
311
 
310
312
 
311
313
  class ConflictBuilder:
@@ -342,7 +344,6 @@ class ConflictBuilder:
342
344
  """
343
345
  insert_expr = self._insert_builder.get_insert_expression()
344
346
 
345
- # Create ON CONFLICT with proper structure
346
347
  conflict_keys = [exp.to_identifier(col) for col in self._columns] if self._columns else None
347
348
  on_conflict = exp.OnConflict(conflict_keys=conflict_keys, action=exp.var("DO NOTHING"))
348
349
 
@@ -369,42 +370,19 @@ class ConflictBuilder:
369
370
  """
370
371
  insert_expr = self._insert_builder.get_insert_expression()
371
372
 
372
- # Create SET expressions for the UPDATE
373
373
  set_expressions = []
374
374
  for col, val in kwargs.items():
375
375
  if has_expression_and_sql(val):
376
- # Handle SQL objects (from sql.raw with parameters)
377
- expression = getattr(val, "expression", None)
378
- if expression is not None and isinstance(expression, exp.Expression):
379
- # Merge parameters from SQL object into builder
380
- if hasattr(val, "parameters"):
381
- sql_parameters = getattr(val, "parameters", {})
382
- for param_name, param_value in sql_parameters.items():
383
- self._insert_builder.add_parameter(param_value, name=param_name)
384
- value_expr = expression
385
- else:
386
- # If expression is None, fall back to parsing the raw SQL
387
- sql_text = getattr(val, "sql", "")
388
- # Merge parameters even when parsing raw SQL
389
- if hasattr(val, "parameters"):
390
- sql_parameters = getattr(val, "parameters", {})
391
- for param_name, param_value in sql_parameters.items():
392
- self._insert_builder.add_parameter(param_value, name=param_name)
393
- # Check if sql_text is callable (like Expression.sql method)
394
- if callable(sql_text):
395
- sql_text = str(val)
396
- value_expr = exp.maybe_parse(sql_text) or exp.convert(str(sql_text))
376
+ value_expr = extract_sql_object_expression(val, builder=self._insert_builder)
397
377
  elif isinstance(val, exp.Expression):
398
378
  value_expr = val
399
379
  else:
400
- # Create parameter for regular values
401
380
  param_name = self._insert_builder.generate_unique_parameter_name(col)
402
381
  _, param_name = self._insert_builder.add_parameter(val, name=param_name)
403
382
  value_expr = exp.Placeholder(this=param_name)
404
383
 
405
384
  set_expressions.append(exp.EQ(this=exp.column(col), expression=value_expr))
406
385
 
407
- # Create ON CONFLICT with proper structure
408
386
  conflict_keys = [exp.to_identifier(col) for col in self._columns] if self._columns else None
409
387
  on_conflict = exp.OnConflict(
410
388
  conflict_keys=conflict_keys, action=exp.var("DO UPDATE"), expressions=set_expressions or None