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.
- sqlspec/adapters/oracledb/driver.py +2 -2
- sqlspec/adapters/pymysql/__init__.py +2 -0
- sqlspec/builder/_base.py +79 -5
- sqlspec/builder/_column.py +38 -11
- sqlspec/builder/_factory.py +50 -17
- sqlspec/builder/_generation.py +30 -2
- sqlspec/builder/_join.py +50 -15
- sqlspec/builder/_locking.py +10 -24
- sqlspec/builder/_parsing_utils.py +5 -1
- sqlspec/builder/_select.py +86 -48
- sqlspec/builder/_vector_distance.py +28 -1
- sqlspec/config.py +24 -21
- sqlspec/core/statement.py +8 -0
- sqlspec/data_dictionary/_types.py +4 -0
- sqlspec/data_dictionary/dialects/bigquery/config.py +1 -0
- sqlspec/data_dictionary/dialects/cockroachdb/config.py +1 -0
- sqlspec/data_dictionary/dialects/duckdb/config.py +1 -0
- sqlspec/data_dictionary/dialects/mssql/config.py +8 -1
- sqlspec/data_dictionary/dialects/mysql/config.py +1 -0
- sqlspec/data_dictionary/dialects/oracle/config.py +11 -3
- sqlspec/data_dictionary/dialects/spanner/config.py +8 -0
- sqlspec/loader.py +9 -5
- sqlspec/migrations/squash.py +12 -15
- sqlspec/utils/arrow_helpers.py +2 -5
- {sqlspec-0.71.0.dist-info → sqlspec-0.71.1.dist-info}/METADATA +2 -2
- {sqlspec-0.71.0.dist-info → sqlspec-0.71.1.dist-info}/RECORD +29 -29
- {sqlspec-0.71.0.dist-info → sqlspec-0.71.1.dist-info}/WHEEL +0 -0
- {sqlspec-0.71.0.dist-info → sqlspec-0.71.1.dist-info}/entry_points.txt +0 -0
- {sqlspec-0.71.0.dist-info → sqlspec-0.71.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
|
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
|
|
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__ = (
|
|
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
|
-
|
|
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
|
sqlspec/builder/_column.py
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
sqlspec/builder/_factory.py
CHANGED
|
@@ -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[
|
|
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,
|
|
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_(
|
|
1105
|
-
"""Create a NOT
|
|
1112
|
+
def not_any_(column: "str | Column | exp.Expr", values: Any) -> ColumnExpression:
|
|
1113
|
+
"""Create a ``NOT (column = ANY(...))`` condition.
|
|
1106
1114
|
|
|
1107
|
-
|
|
1108
|
-
|
|
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
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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:
|
|
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
|
|
sqlspec/builder/_generation.py
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
join_expr.set("
|
|
363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
381
|
-
if
|
|
382
|
-
target_alias =
|
|
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))
|
sqlspec/builder/_locking.py
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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"
|