sqlspec 0.71.0__py3-none-any.whl → 0.71.1__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.
@@ -776,7 +776,7 @@ class OracleSyncDriver(OraclePipelineMixin, SyncDriverAdapterBase):
776
776
  """Execute query and return results as Apache Arrow format using Oracle native support.
777
777
 
778
778
  This implementation uses Oracle's native execute_df()/fetch_df_all() methods
779
- which return OracleDataFrame objects with Arrow PyCapsule interface, providing
779
+ which return OracleDataFrame objects with Arrow C Stream interface, providing
780
780
  zero-copy data transfer and 5-10x performance improvement over dict conversion.
781
781
  If native Arrow is unavailable and native_only is False, it falls back to the
782
782
  conversion path.
@@ -1709,7 +1709,7 @@ class OracleAsyncDriver(OraclePipelineMixin, AsyncDriverAdapterBase):
1709
1709
  """Execute query and return results as Apache Arrow format using Oracle native support.
1710
1710
 
1711
1711
  This implementation uses Oracle's native execute_df()/fetch_df_all() methods
1712
- which return OracleDataFrame objects with Arrow PyCapsule interface, providing
1712
+ which return OracleDataFrame objects with Arrow C Stream interface, providing
1713
1713
  zero-copy data transfer and 5-10x performance improvement over dict conversion.
1714
1714
  If native Arrow is unavailable and native_only is False, it falls back to the
1715
1715
  conversion path.
@@ -6,6 +6,7 @@ from sqlspec.adapters.pymysql.config import (
6
6
  PyMysqlConnectionParams,
7
7
  PyMysqlDriverFeatures,
8
8
  PyMysqlPoolParams,
9
+ PyMysqlSslParams,
9
10
  build_connection_config,
10
11
  )
11
12
  from sqlspec.adapters.pymysql.core import default_statement_config
@@ -20,6 +21,7 @@ __all__ = (
20
21
  "PyMysqlDriverFeatures",
21
22
  "PyMysqlExceptionHandler",
22
23
  "PyMysqlPoolParams",
24
+ "PyMysqlSslParams",
23
25
  "build_connection_config",
24
26
  "default_statement_config",
25
27
  )
sqlspec/builder/_base.py CHANGED
@@ -28,7 +28,7 @@ from sqlglot.schema import MappingSchema
28
28
 
29
29
  from sqlspec.builder._locking import register_lock_generator
30
30
  from sqlspec.builder._parsing_utils import _normalize_dialect, _resolve_dialect
31
- from sqlspec.builder._vector_distance import has_vector_distance_ancestor
31
+ from sqlspec.builder._vector_distance import has_vector_distance_ancestor, register_vector_distance_generator
32
32
  from sqlspec.core import (
33
33
  SQL,
34
34
  ParameterStyle,
@@ -46,13 +46,21 @@ from sqlspec.utils.logging import get_logger
46
46
  from sqlspec.utils.type_guards import has_expression_and_parameters, is_expression
47
47
  from sqlspec.utils.uuids import uuid4
48
48
 
49
- __all__ = ("BuiltQuery", "ExpressionBuilder", "QueryBuilder")
49
+ __all__ = (
50
+ "SUBQUERY_PARAMETERS_META_KEY",
51
+ "BuiltQuery",
52
+ "ExpressionBuilder",
53
+ "QueryBuilder",
54
+ "register_subquery_fragment",
55
+ )
50
56
 
51
57
  MAX_PARAMETER_COLLISION_ATTEMPTS = 1000
52
58
  PARAMETER_INDEX_PATTERN = re.compile(r"^param_(?P<index>\d+)$")
53
59
  _PARAMETER_SUFFIX_PATTERN = re.compile(r"^(?P<base>.+)_(?P<index>\d+)$")
54
60
  _UPPER_FOLDING_DIALECTS: Final[frozenset[str]] = frozenset({"oracle", "db2"})
55
61
  _UNQUOTED_IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z_][\w$#]*$")
62
+ SUBQUERY_PARAMETERS_META_KEY: Final[str] = "sqlspec_subquery_parameters"
63
+ _SUBQUERY_FRAGMENTS_ISSUED = False
56
64
 
57
65
 
58
66
  logger = get_logger(__name__)
@@ -350,6 +358,7 @@ class QueryBuilder:
350
358
  identify = self._should_identify(target_dialect)
351
359
  if normalized_expression.find(exp.Lock) and target_dialect != "db2":
352
360
  register_lock_generator(target_dialect)
361
+ register_vector_distance_generator(target_dialect)
353
362
  sql_string = normalized_expression.sql(
354
363
  dialect=target_dialect, pretty=True, identify=identify, copy=False
355
364
  )
@@ -367,7 +376,9 @@ class QueryBuilder:
367
376
 
368
377
  Args:
369
378
  show_parameters: If True, replace parameter placeholders with actual values (for debugging).
370
- If False (default), return SQL with parameter placeholders.
379
+ If False (default), return SQL with parameter placeholders. Every named
380
+ placeholder style the builder renders is substituted (``:name``, ``%(name)s``,
381
+ ``@name``, and ``$name``).
371
382
  dialect: Optional dialect override. If provided, generates SQL for this dialect
372
383
  instead of the builder's default dialect.
373
384
 
@@ -389,10 +400,11 @@ class QueryBuilder:
389
400
  return sql
390
401
 
391
402
  sorted_names = sorted(parameters, key=len, reverse=True)
392
- pattern = re.compile(r":(" + "|".join(re.escape(name) for name in sorted_names) + r")\b")
403
+ names_pattern = "|".join(re.escape(name) for name in sorted_names)
404
+ pattern = re.compile(rf"%\(({names_pattern})\)s|(?<![:\w])[:@$]({names_pattern})\b")
393
405
 
394
406
  def _replace_match(match: "re.Match[str]") -> str:
395
- param_name = match.group(1)
407
+ param_name = match.group(1) or match.group(2)
396
408
  param_value = parameters[param_name]
397
409
  if isinstance(param_value, str):
398
410
  escaped = param_value.replace("'", "''")
@@ -534,6 +546,7 @@ class QueryBuilder:
534
546
  identify = self._should_identify(target_dialect)
535
547
  if expr.find(exp.Lock) and target_dialect != "db2":
536
548
  register_lock_generator(target_dialect)
549
+ register_vector_distance_generator(target_dialect)
537
550
  sql_string = expr.sql(dialect=target_dialect, pretty=True, identify=identify, copy=not copy)
538
551
  sql_string = self._postprocess_built_sql(sql_string, dialect=dialect)
539
552
  return BuiltQuery(
@@ -838,6 +851,49 @@ class QueryBuilder:
838
851
  else:
839
852
  self._parameter_name_counters.setdefault(name, 0)
840
853
 
854
+ def _merge_subquery_fragments(self, expression: exp.Expr) -> exp.Expr:
855
+ """Merge parameters carried by ``sql.exists_`` / ``sql.in_`` / ``sql.any_`` / ``sql.all_`` fragments.
856
+
857
+ Fragments built from parameterized subquery builders carry their parameters in
858
+ ``SUBQUERY_PARAMETERS_META_KEY`` metadata. The expression is copied before
859
+ placeholders are renamed, so one fragment can be reused across builders. The
860
+ tree is only scanned once any fragment has been created in the process.
861
+
862
+ Args:
863
+ expression: Condition expression about to be attached to this builder.
864
+
865
+ Returns:
866
+ The expression with fragment parameters merged into this builder.
867
+ """
868
+ if not _SUBQUERY_FRAGMENTS_ISSUED:
869
+ return expression
870
+ has_carriers = False
871
+ for node in expression.walk():
872
+ if isinstance(node, exp.In) and isinstance(node.this, exp.Var) and not node.this.name:
873
+ self._raise_builder_error(
874
+ "sql.in_() returns an IN fragment without a left operand; use .where_in(column, subquery) instead."
875
+ )
876
+ if node.meta_get(SUBQUERY_PARAMETERS_META_KEY) is not None:
877
+ has_carriers = True
878
+ if not has_carriers:
879
+ return expression
880
+
881
+ merged = expression.copy()
882
+ for node in list(merged.walk()):
883
+ parameters = node.meta_get(SUBQUERY_PARAMETERS_META_KEY)
884
+ if parameters is None:
885
+ continue
886
+ del node.meta[SUBQUERY_PARAMETERS_META_KEY]
887
+ param_mapping: dict[str, str] = {}
888
+ for name, value in parameters.items():
889
+ target_name = self._next_parameter_name(name) if name in self._parameters else name
890
+ _, actual_name = self.add_parameter(value, name=target_name)
891
+ if actual_name != name:
892
+ param_mapping[name] = actual_name
893
+ if param_mapping:
894
+ self._update_placeholders(node, param_mapping)
895
+ return merged
896
+
841
897
  def _next_parameter_name(self, base_name: str) -> str:
842
898
  """Generate unique parameter name when collision occurs.
843
899
 
@@ -1192,6 +1248,7 @@ class QueryBuilder:
1192
1248
 
1193
1249
  if statement_expression.find(exp.Lock) and resolved_dialect != "db2":
1194
1250
  register_lock_generator(resolved_dialect)
1251
+ register_vector_distance_generator(resolved_dialect)
1195
1252
  if self._folds_unquoted_to_upper(resolved_dialect):
1196
1253
  statement_expression = self._unquote_identifiers(statement_expression)
1197
1254
  return _BuilderCacheEntry(statement_expression, resolved_dialect)
@@ -1415,3 +1472,20 @@ def _unquote_identifier(node: exp.Expr) -> exp.Expr:
1415
1472
  ):
1416
1473
  node.set("quoted", False)
1417
1474
  return node
1475
+
1476
+
1477
+ def register_subquery_fragment(expression: exp.Expr, parameters: "Mapping[str, Any]") -> exp.Expr:
1478
+ """Mark a subquery fragment and attach its bound parameters for later merging.
1479
+
1480
+ Args:
1481
+ expression: The ``EXISTS`` / ``IN`` / ``ANY`` / ``ALL`` fragment.
1482
+ parameters: Parameters bound by the fragment's subquery builder.
1483
+
1484
+ Returns:
1485
+ The same expression, carrying ``parameters`` in its metadata when any exist.
1486
+ """
1487
+ global _SUBQUERY_FRAGMENTS_ISSUED
1488
+ _SUBQUERY_FRAGMENTS_ISSUED = True
1489
+ if parameters:
1490
+ expression.meta[SUBQUERY_PARAMETERS_META_KEY] = dict(parameters)
1491
+ return expression
@@ -25,10 +25,19 @@ class ColumnExpression:
25
25
  self._expression = expression
26
26
 
27
27
  def __and__(self, other: "ColumnExpression") -> "ColumnExpression":
28
- """Combine with AND operator (&)."""
28
+ """Combine with AND operator (&).
29
+
30
+ ``OR`` operands are wrapped in parentheses because ``AND`` binds tighter
31
+ than ``OR`` in SQL, so ``a & (b | c)`` must not render as ``a AND b OR c``.
32
+ """
29
33
  if not isinstance(other, ColumnExpression):
30
34
  return NotImplemented
31
- return ColumnExpression(exp.And(this=self._expression.copy(), expression=other._expression.copy()))
35
+ return ColumnExpression(
36
+ exp.And(
37
+ this=_wrap_operand(self._expression.copy(), exp.Or),
38
+ expression=_wrap_operand(other._expression.copy(), exp.Or),
39
+ )
40
+ )
32
41
 
33
42
  def __or__(self, other: "ColumnExpression") -> "ColumnExpression":
34
43
  """Combine with OR operator (|)."""
@@ -37,8 +46,12 @@ class ColumnExpression:
37
46
  return ColumnExpression(exp.Or(this=self._expression.copy(), expression=other._expression.copy()))
38
47
 
39
48
  def __invert__(self) -> "ColumnExpression":
40
- """Apply NOT operator (~)."""
41
- return ColumnExpression(exp.Not(this=self._expression.copy()))
49
+ """Apply NOT operator (~).
50
+
51
+ ``AND`` / ``OR`` operands are wrapped in parentheses because ``NOT`` binds
52
+ tighter than both connectors, so ``~(a & b)`` must render as ``NOT (a AND b)``.
53
+ """
54
+ return ColumnExpression(exp.Not(this=_wrap_operand(self._expression.copy(), exp.Connector)))
42
55
 
43
56
  def __bool__(self) -> bool:
44
57
  """Prevent accidental use of 'and'/'or' keywords."""
@@ -165,13 +178,12 @@ class _ScalarExpressionMixin:
165
178
  )
166
179
 
167
180
  def not_any_(self, values: Iterable[Any]) -> ColumnExpression:
168
- """SQL <> ANY(...) clause."""
169
- converted_values = [self._convert_value(v) for v in values]
170
- return ColumnExpression(
171
- exp.NEQ(
172
- this=self._expression, expression=exp.Any(this=exp.Paren(this=exp.Array(expressions=converted_values)))
173
- )
174
- )
181
+ """SQL NOT (column = ANY(...)) clause.
182
+
183
+ ``column <> ANY(...)`` is true when any element differs, so the negation
184
+ wraps the ``= ANY`` comparison, matching ``NotAnyCollectionFilter``.
185
+ """
186
+ return ColumnExpression(exp.Not(this=self.any_(values)._expression))
175
187
 
176
188
  def cast(self, data_type: str) -> "FunctionColumn":
177
189
  """SQL CAST() function."""
@@ -388,6 +400,21 @@ class FunctionColumn(_ScalarExpressionMixin):
388
400
  return hash(id(self._expression))
389
401
 
390
402
 
403
+ def _wrap_operand(expression: exp.Expr, lower_precedence: type[exp.Expr]) -> exp.Expr:
404
+ """Parenthesize an operand whose operator binds looser than its new parent.
405
+
406
+ Args:
407
+ expression: The operand expression.
408
+ lower_precedence: Expression type that needs parentheses under the parent operator.
409
+
410
+ Returns:
411
+ The operand, wrapped in ``exp.Paren`` when required.
412
+ """
413
+ if isinstance(expression, lower_precedence):
414
+ return exp.Paren(this=expression)
415
+ return expression
416
+
417
+
391
418
  def _convert_value(value: Any) -> exp.Expr:
392
419
  """Convert a Python value to a SQLGlot expression.
393
420
 
@@ -13,7 +13,8 @@ from sqlglot.dialects.dialect import DialectType
13
13
  from sqlglot.errors import ParseError as SQLGlotParseError
14
14
 
15
15
  from sqlspec.builder._base import QueryBuilder
16
- from sqlspec.builder._column import Column
16
+ from sqlspec.builder._column import Column, ColumnExpression, FunctionColumn
17
+ from sqlspec.builder._column import _convert_value as convert_column_value
17
18
  from sqlspec.builder._ddl import (
18
19
  AlterTable,
19
20
  CommentOn,
@@ -55,6 +56,7 @@ from sqlspec.builder._parsing_utils import (
55
56
  _resolve_dialect,
56
57
  _rollup_expression,
57
58
  extract_expression,
59
+ parse_column_expression,
58
60
  to_expression,
59
61
  )
60
62
  from sqlspec.builder._select import Case, Select, SubqueryBuilder, WindowFunctionBuilder
@@ -77,7 +79,9 @@ __all__ = ("SQLFactory", "build_copy_from_statement", "build_copy_statement", "b
77
79
  logger = get_logger("sqlspec.builder.factory")
78
80
 
79
81
  BuilderT = TypeVar("BuilderT", bound=QueryBuilder)
80
- ColumnLike: TypeAlias = Union[str, exp.Expr, "ExpressionWrapper", "Case", "Column"]
82
+ ColumnLike: TypeAlias = Union[
83
+ str, exp.Expr, "ExpressionWrapper", "Case", "Column", "FunctionColumn", "ColumnExpression"
84
+ ]
81
85
 
82
86
  MIN_SQL_LIKE_STRING_LENGTH = 6
83
87
  MIN_DECODE_ARGS = 2
@@ -358,7 +362,11 @@ class SQLFactory:
358
362
  return create_join_builder("cross join", lateral=True)
359
363
 
360
364
  def select(
361
- self, *columns_or_sql: Union[str, exp.Expr, Column, "SQL", "Case"], dialect: DialectType = None
365
+ self,
366
+ *columns_or_sql: Union[
367
+ str, exp.Expr, Column, FunctionColumn, ColumnExpression, "ExpressionWrapper", "SQL", "Case"
368
+ ],
369
+ dialect: DialectType = None,
362
370
  ) -> "Select":
363
371
  builder_dialect = _resolve_dialect(dialect, self.dialect)
364
372
  if len(columns_or_sql) == 1 and isinstance(columns_or_sql[0], str):
@@ -1101,16 +1109,35 @@ class SQLFactory:
1101
1109
  return FunctionExpression(exp.Any(this=values))
1102
1110
 
1103
1111
  @staticmethod
1104
- def not_any_(values: list[Any] | exp.Expr | str) -> FunctionExpression:
1105
- """Create a NOT ANY expression for use with comparison operators.
1112
+ def not_any_(column: "str | Column | exp.Expr", values: Any) -> ColumnExpression:
1113
+ """Create a ``NOT (column = ANY(...))`` condition.
1106
1114
 
1107
- Args:
1108
- values: Values, expression, or subquery for the NOT ANY clause.
1115
+ ``column <> ANY(...)`` is true whenever any element differs, so the negation
1116
+ wraps the ``= ANY`` comparison. The result matches ``Column.not_any_`` and
1117
+ ``NotAnyCollectionFilter`` and renders as ``column <> ALL (...)`` on optimizing
1118
+ dialects such as PostgreSQL.
1109
1119
 
1110
- Returns:
1111
- NOT ANY expression.
1112
- """
1113
- return FunctionExpression(exp.Not(this=SQLFactory.any(values).expression))
1120
+ Args:
1121
+ column: Column name (``"status"`` or ``"t.status"``), ``Column``, or expression.
1122
+ values: Iterable of values bound as parameters, or a subquery given as a
1123
+ query builder, sqlglot expression, or SQL string. Parameters bound by a
1124
+ subquery builder are merged into the outer query. Subqueries stay
1125
+ parenthesized so the optimizer keeps ``<> ALL (subquery)`` instead of
1126
+ rewriting it into an anti-join, which would change the result when the
1127
+ subquery returns ``NULL``.
1128
+
1129
+ Returns:
1130
+ Condition usable with ``.where()``, ``.having()``, ``&``, ``|``, and ``~``.
1131
+ """
1132
+ left = column.sqlglot_expression if isinstance(column, Column) else parse_column_expression(column)
1133
+ any_expression: exp.Expr
1134
+ if isinstance(values, (list, tuple, set, frozenset)):
1135
+ converted = [convert_column_value(value) for value in values]
1136
+ any_expression = exp.Any(this=exp.Paren(this=exp.Array(expressions=converted)))
1137
+ else:
1138
+ any_expression = SubqueryBuilder("any")(values)
1139
+ any_expression.set("this", exp.paren(any_expression.this, copy=False))
1140
+ return ColumnExpression(exp.Not(this=exp.EQ(this=left, expression=any_expression)))
1114
1141
 
1115
1142
  @staticmethod
1116
1143
  def concat(*expressions: str | exp.Expr) -> StringExpression:
@@ -1325,19 +1352,25 @@ class SQLFactory:
1325
1352
  Args:
1326
1353
  table_name: Name of the table to insert into
1327
1354
  column_count: Number of columns (for placeholder generation)
1328
- placeholder_style: Placeholder style ("?" for SQLite/PostgreSQL, "%s" for MySQL, ":1" for Oracle)
1355
+ placeholder_style: Positional placeholder style: ``"?"`` (qmark), ``"%s"`` (format),
1356
+ ``":1"`` (Oracle numeric), or ``"$1"`` (PostgreSQL numeric). Numeric styles
1357
+ number the placeholders from 1.
1329
1358
 
1330
1359
  Returns:
1331
1360
  INSERT expression with placeholders for bulk operations
1332
1361
  """
1362
+ placeholders: list[exp.Expr]
1363
+ if placeholder_style == "?":
1364
+ placeholders = [exp.Placeholder() for _ in range(column_count)]
1365
+ elif placeholder_style[:1] in {":", "$"} and placeholder_style[1:].isdigit():
1366
+ prefix = placeholder_style[0]
1367
+ placeholders = [exp.Var(this=f"{prefix}{index}") for index in range(1, column_count + 1)]
1368
+ else:
1369
+ placeholders = [exp.Var(this=placeholder_style) for _ in range(column_count)]
1333
1370
  return FunctionExpression(
1334
1371
  exp.Insert(
1335
1372
  this=exp.Table(this=exp.to_identifier(table_name)),
1336
- expression=exp.Values(
1337
- expressions=[
1338
- exp.Tuple(expressions=[exp.Placeholder(this=placeholder_style) for _ in range(column_count)])
1339
- ]
1340
- ),
1373
+ expression=exp.Values(expressions=[exp.Tuple(expressions=placeholders)]),
1341
1374
  )
1342
1375
  )
1343
1376
 
@@ -2,10 +2,38 @@
2
2
 
3
3
  from typing import TYPE_CHECKING, cast
4
4
 
5
+ from sqlglot import Dialect
6
+ from sqlglot.generator import Generator
7
+
5
8
  if TYPE_CHECKING:
6
- from sqlglot.generator import Generator
9
+ from sqlglot.dialects.dialect import DialectType
10
+
11
+ __all__ = ("generator_class_for_dialect", "invalidate_generator_dispatch")
12
+
13
+
14
+ def generator_class_for_dialect(dialect: "DialectType | str | None") -> "type[Generator]":
15
+ """Resolve the SQLGlot generator class that renders a dialect.
7
16
 
8
- __all__ = ("invalidate_generator_dispatch",)
17
+ Args:
18
+ dialect: Dialect name, class, or instance; ``None`` selects the base generator.
19
+
20
+ Returns:
21
+ The dialect's generator class.
22
+ """
23
+ if dialect is None:
24
+ return Generator
25
+
26
+ dialect_class: type[Dialect]
27
+ if isinstance(dialect, str):
28
+ dialect_class = type(Dialect.get_or_raise(dialect))
29
+ elif isinstance(dialect, type) and issubclass(dialect, Dialect):
30
+ dialect_class = dialect
31
+ elif isinstance(dialect, Dialect):
32
+ dialect_class = type(dialect)
33
+ else:
34
+ dialect_class = type(Dialect.get_or_raise(str(dialect)))
35
+
36
+ return dialect_class.generator_class
9
37
 
10
38
 
11
39
  def invalidate_generator_dispatch(*generator_classes: "type[object]") -> None:
sqlspec/builder/_join.py CHANGED
@@ -350,19 +350,54 @@ def _join_for_type(table_expr: exp.Expr, on_expr: exp.Expr | None, join_type: st
350
350
 
351
351
 
352
352
  def _apply_lateral_modifier(join_expr: exp.Join) -> None:
353
- current_kind = join_expr.args.get("kind")
354
- current_side = join_expr.args.get("side")
355
-
356
- if current_kind == "CROSS":
357
- join_expr.set("kind", "CROSS LATERAL")
358
- elif current_kind == "OUTER" and current_side == "FULL":
359
- join_expr.set("side", "FULL")
360
- join_expr.set("kind", "OUTER LATERAL")
361
- elif current_side:
362
- join_expr.set("kind", f"{current_side} LATERAL")
363
- join_expr.set("side", None)
353
+ """Turn a join into a LATERAL join.
354
+
355
+ The join target is wrapped in ``exp.Lateral`` so sqlglot renders the standard
356
+ ``JOIN LATERAL`` / ``LEFT JOIN LATERAL`` / ``CROSS JOIN LATERAL`` form accepted by
357
+ PostgreSQL, MySQL, DuckDB, and Snowflake. Non-cross lateral joins without a
358
+ condition get ``ON TRUE`` because PostgreSQL requires a join qualifier for them.
359
+ """
360
+ join_expr.set("this", _lateral_source(join_expr.this))
361
+ if join_expr.args.get("kind") != "CROSS" and join_expr.args.get("on") is None:
362
+ join_expr.set("on", exp.true())
363
+
364
+
365
+ def _lateral_source(table_expr: exp.Expr) -> exp.Expr:
366
+ """Wrap a join target in ``exp.Lateral``, moving its alias onto the LATERAL node.
367
+
368
+ Args:
369
+ table_expr: Parsed join target (aliased subquery, table function, or table).
370
+
371
+ Returns:
372
+ The ``exp.Lateral`` join target.
373
+ """
374
+ if isinstance(table_expr, exp.Lateral):
375
+ return table_expr
376
+
377
+ source = table_expr
378
+ alias_node: exp.Expr | None = None
379
+ if isinstance(source, exp.Alias):
380
+ alias_node = exp.TableAlias(this=exp.to_identifier(source.alias))
381
+ source = source.this
364
382
  else:
365
- join_expr.set("kind", "LATERAL")
383
+ raw_alias = source.args.get("alias")
384
+ if raw_alias is not None:
385
+ source = source.copy()
386
+ source.set("alias", None)
387
+ if isinstance(raw_alias, exp.TableAlias):
388
+ alias_node = raw_alias.copy()
389
+ else:
390
+ alias_identifier = (
391
+ raw_alias.copy() if isinstance(raw_alias, exp.Identifier) else exp.to_identifier(str(raw_alias))
392
+ )
393
+ alias_node = exp.TableAlias(this=alias_identifier)
394
+
395
+ if isinstance(source, exp.Paren) and isinstance(source.this, exp.Query):
396
+ source = exp.Subquery(this=source.this)
397
+ elif isinstance(source, exp.Table) and isinstance(source.this, exp.Func):
398
+ source = source.this
399
+
400
+ return exp.Lateral(this=source, alias=alias_node)
366
401
 
367
402
 
368
403
  def _attach_as_of_version(
@@ -377,9 +412,9 @@ def _attach_as_of_version(
377
412
  target_alias = inner_table.alias
378
413
  inner_table = inner_table.this
379
414
  elif isinstance(inner_table, exp.Table):
380
- alias_expr = inner_table.args.get("alias")
381
- if alias_expr is not None:
382
- target_alias = alias_expr.this
415
+ table_alias = inner_table.alias
416
+ if table_alias:
417
+ target_alias = table_alias
383
418
  inner_table.set("alias", None)
384
419
 
385
420
  version = exp.Version(this=as_of_type or "TIMESTAMP", kind="AS OF", expression=exp.convert(as_of))
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, ClassVar, Protocol, cast
6
6
  from sqlglot import Dialect, exp
7
7
  from sqlglot.generator import Generator
8
8
 
9
- from sqlspec.builder._generation import invalidate_generator_dispatch
9
+ from sqlspec.builder._generation import generator_class_for_dialect, invalidate_generator_dispatch
10
10
 
11
11
  if TYPE_CHECKING:
12
12
  from collections.abc import Iterable
@@ -20,16 +20,12 @@ class _GeneratorClass(Protocol):
20
20
  TRANSFORMS: ClassVar[MutableMapping[type[exp.Lock], Callable[[Generator, exp.Lock], str]]]
21
21
 
22
22
 
23
- class _DialectClass(Protocol):
24
- Generator: ClassVar[type[_GeneratorClass]]
25
-
26
-
27
23
  _REGISTERED_LOCK_GENERATORS: set[type[_GeneratorClass]] = set()
28
24
 
29
25
 
30
26
  def register_lock_generator(dialect: "DialectType | str | None") -> None:
31
27
  """Register lock-clause rendering for the dialect being rendered."""
32
- generator_class = _generator_class_for_dialect(dialect)
28
+ generator_class = cast("type[_GeneratorClass]", generator_class_for_dialect(dialect))
33
29
  if generator_class in _REGISTERED_LOCK_GENERATORS:
34
30
  return
35
31
 
@@ -50,7 +46,14 @@ def _render_lock_targets(generator: "Generator", expressions: "Iterable[exp.Expr
50
46
 
51
47
 
52
48
  def _lock_sql(generator: "Generator", expression: exp.Lock) -> str:
53
- if not generator.LOCKING_READS_SUPPORTED and type(generator.dialect).__name__ != "Spanner":
49
+ """Render a lock clause.
50
+
51
+ The dialect-neutral base generator keeps lock clauses so builds without a
52
+ dialect preserve ``FOR UPDATE`` / ``FOR SHARE``; dialects that cannot express
53
+ locking reads still drop them.
54
+ """
55
+ dialect_class = type(generator.dialect)
56
+ if not generator.LOCKING_READS_SUPPORTED and dialect_class is not Dialect and dialect_class.__name__ != "Spanner":
54
57
  generator.unsupported("Locking reads using 'FOR UPDATE/SHARE' are not supported")
55
58
  return ""
56
59
 
@@ -72,20 +75,3 @@ def _lock_sql(generator: "Generator", expression: exp.Lock) -> str:
72
75
  wait = " NOWAIT" if wait else " SKIP LOCKED"
73
76
 
74
77
  return f"{lock_type}{target_sql}{wait or ''}"
75
-
76
-
77
- def _generator_class_for_dialect(dialect: "DialectType | str | None") -> "type[_GeneratorClass]":
78
- if dialect is None:
79
- return cast("type[_GeneratorClass]", Generator)
80
-
81
- dialect_class: type[Dialect]
82
- if isinstance(dialect, str):
83
- dialect_class = type(Dialect.get_or_raise(dialect))
84
- elif isinstance(dialect, type) and issubclass(dialect, Dialect):
85
- dialect_class = dialect
86
- elif isinstance(dialect, Dialect):
87
- dialect_class = type(dialect)
88
- else:
89
- dialect_class = type(Dialect.get_or_raise(str(dialect)))
90
-
91
- return cast("_DialectClass", dialect_class).Generator
@@ -80,6 +80,7 @@ _BARE_KEYWORDS: Final[frozenset[str]] = frozenset({
80
80
  "where",
81
81
  })
82
82
  _PARAMETER_VALIDATOR = ParameterValidator()
83
+ _NON_IDENTIFIER_PATTERN: Final["re.Pattern[str]"] = re.compile(r"\W+")
83
84
  _DIALECT_ALIASES: Final[dict[str, str]] = {
84
85
  "ase": "sybase",
85
86
  "cockroach": "postgres",
@@ -94,6 +95,9 @@ _DIALECT_ALIASES: Final[dict[str, str]] = {
94
95
  def extract_column_name(column: str | exp.Column) -> str:
95
96
  """Extract column name from column expression for parameter naming.
96
97
 
98
+ Non-column expressions such as ``COUNT(*)`` or ``SUM(o.amount)`` become
99
+ identifier-safe names (``count``, ``sum_o_amount``) so placeholders stay valid.
100
+
97
101
  Args:
98
102
  column: Column expression (string or SQLGlot Column)
99
103
 
@@ -104,7 +108,7 @@ def extract_column_name(column: str | exp.Column) -> str:
104
108
  col_expr: exp.Expr | None = exp.maybe_parse(column)
105
109
  if isinstance(col_expr, exp.Column):
106
110
  return col_expr.name
107
- return column.split(".")[-1] if "." in column else column
111
+ return _NON_IDENTIFIER_PATTERN.sub("_", column).strip("_").lower() or "column"
108
112
  if isinstance(column, exp.Column):
109
113
  return column.name
110
114
  return "column"