sqlspec 0.11.0__py3-none-any.whl → 0.12.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.
- sqlspec/__init__.py +16 -3
- sqlspec/_serialization.py +3 -10
- sqlspec/_sql.py +1147 -0
- sqlspec/_typing.py +343 -41
- sqlspec/adapters/adbc/__init__.py +2 -6
- sqlspec/adapters/adbc/config.py +474 -149
- sqlspec/adapters/adbc/driver.py +330 -644
- sqlspec/adapters/aiosqlite/__init__.py +2 -6
- sqlspec/adapters/aiosqlite/config.py +143 -57
- sqlspec/adapters/aiosqlite/driver.py +269 -462
- sqlspec/adapters/asyncmy/__init__.py +3 -8
- sqlspec/adapters/asyncmy/config.py +247 -202
- sqlspec/adapters/asyncmy/driver.py +217 -451
- sqlspec/adapters/asyncpg/__init__.py +4 -7
- sqlspec/adapters/asyncpg/config.py +329 -176
- sqlspec/adapters/asyncpg/driver.py +418 -498
- sqlspec/adapters/bigquery/__init__.py +2 -2
- sqlspec/adapters/bigquery/config.py +407 -0
- sqlspec/adapters/bigquery/driver.py +592 -634
- sqlspec/adapters/duckdb/__init__.py +4 -1
- sqlspec/adapters/duckdb/config.py +432 -321
- sqlspec/adapters/duckdb/driver.py +393 -436
- sqlspec/adapters/oracledb/__init__.py +3 -8
- sqlspec/adapters/oracledb/config.py +625 -0
- sqlspec/adapters/oracledb/driver.py +549 -942
- sqlspec/adapters/psqlpy/__init__.py +4 -7
- sqlspec/adapters/psqlpy/config.py +372 -203
- sqlspec/adapters/psqlpy/driver.py +197 -550
- sqlspec/adapters/psycopg/__init__.py +3 -8
- sqlspec/adapters/psycopg/config.py +741 -0
- sqlspec/adapters/psycopg/driver.py +732 -733
- sqlspec/adapters/sqlite/__init__.py +2 -6
- sqlspec/adapters/sqlite/config.py +146 -81
- sqlspec/adapters/sqlite/driver.py +243 -426
- sqlspec/base.py +220 -825
- sqlspec/config.py +354 -0
- sqlspec/driver/__init__.py +22 -0
- sqlspec/driver/_async.py +252 -0
- sqlspec/driver/_common.py +338 -0
- sqlspec/driver/_sync.py +261 -0
- sqlspec/driver/mixins/__init__.py +17 -0
- sqlspec/driver/mixins/_pipeline.py +523 -0
- sqlspec/driver/mixins/_result_utils.py +122 -0
- sqlspec/driver/mixins/_sql_translator.py +35 -0
- sqlspec/driver/mixins/_storage.py +993 -0
- sqlspec/driver/mixins/_type_coercion.py +131 -0
- sqlspec/exceptions.py +299 -7
- sqlspec/extensions/aiosql/__init__.py +10 -0
- sqlspec/extensions/aiosql/adapter.py +474 -0
- sqlspec/extensions/litestar/__init__.py +1 -6
- sqlspec/extensions/litestar/_utils.py +1 -5
- sqlspec/extensions/litestar/config.py +5 -6
- sqlspec/extensions/litestar/handlers.py +13 -12
- sqlspec/extensions/litestar/plugin.py +22 -24
- sqlspec/extensions/litestar/providers.py +37 -55
- sqlspec/loader.py +528 -0
- sqlspec/service/__init__.py +3 -0
- sqlspec/service/base.py +24 -0
- sqlspec/service/pagination.py +26 -0
- sqlspec/statement/__init__.py +21 -0
- sqlspec/statement/builder/__init__.py +54 -0
- sqlspec/statement/builder/_ddl_utils.py +119 -0
- sqlspec/statement/builder/_parsing_utils.py +135 -0
- sqlspec/statement/builder/base.py +328 -0
- sqlspec/statement/builder/ddl.py +1379 -0
- sqlspec/statement/builder/delete.py +80 -0
- sqlspec/statement/builder/insert.py +274 -0
- sqlspec/statement/builder/merge.py +95 -0
- sqlspec/statement/builder/mixins/__init__.py +65 -0
- sqlspec/statement/builder/mixins/_aggregate_functions.py +151 -0
- sqlspec/statement/builder/mixins/_case_builder.py +91 -0
- sqlspec/statement/builder/mixins/_common_table_expr.py +91 -0
- sqlspec/statement/builder/mixins/_delete_from.py +34 -0
- sqlspec/statement/builder/mixins/_from.py +61 -0
- sqlspec/statement/builder/mixins/_group_by.py +119 -0
- sqlspec/statement/builder/mixins/_having.py +35 -0
- sqlspec/statement/builder/mixins/_insert_from_select.py +48 -0
- sqlspec/statement/builder/mixins/_insert_into.py +36 -0
- sqlspec/statement/builder/mixins/_insert_values.py +69 -0
- sqlspec/statement/builder/mixins/_join.py +110 -0
- sqlspec/statement/builder/mixins/_limit_offset.py +53 -0
- sqlspec/statement/builder/mixins/_merge_clauses.py +405 -0
- sqlspec/statement/builder/mixins/_order_by.py +46 -0
- sqlspec/statement/builder/mixins/_pivot.py +82 -0
- sqlspec/statement/builder/mixins/_returning.py +37 -0
- sqlspec/statement/builder/mixins/_select_columns.py +60 -0
- sqlspec/statement/builder/mixins/_set_ops.py +122 -0
- sqlspec/statement/builder/mixins/_unpivot.py +80 -0
- sqlspec/statement/builder/mixins/_update_from.py +54 -0
- sqlspec/statement/builder/mixins/_update_set.py +91 -0
- sqlspec/statement/builder/mixins/_update_table.py +29 -0
- sqlspec/statement/builder/mixins/_where.py +374 -0
- sqlspec/statement/builder/mixins/_window_functions.py +86 -0
- sqlspec/statement/builder/protocols.py +20 -0
- sqlspec/statement/builder/select.py +206 -0
- sqlspec/statement/builder/update.py +178 -0
- sqlspec/statement/filters.py +571 -0
- sqlspec/statement/parameters.py +736 -0
- sqlspec/statement/pipelines/__init__.py +67 -0
- sqlspec/statement/pipelines/analyzers/__init__.py +9 -0
- sqlspec/statement/pipelines/analyzers/_analyzer.py +649 -0
- sqlspec/statement/pipelines/base.py +315 -0
- sqlspec/statement/pipelines/context.py +119 -0
- sqlspec/statement/pipelines/result_types.py +41 -0
- sqlspec/statement/pipelines/transformers/__init__.py +8 -0
- sqlspec/statement/pipelines/transformers/_expression_simplifier.py +256 -0
- sqlspec/statement/pipelines/transformers/_literal_parameterizer.py +623 -0
- sqlspec/statement/pipelines/transformers/_remove_comments.py +66 -0
- sqlspec/statement/pipelines/transformers/_remove_hints.py +81 -0
- sqlspec/statement/pipelines/validators/__init__.py +23 -0
- sqlspec/statement/pipelines/validators/_dml_safety.py +275 -0
- sqlspec/statement/pipelines/validators/_parameter_style.py +297 -0
- sqlspec/statement/pipelines/validators/_performance.py +703 -0
- sqlspec/statement/pipelines/validators/_security.py +990 -0
- sqlspec/statement/pipelines/validators/base.py +67 -0
- sqlspec/statement/result.py +527 -0
- sqlspec/statement/splitter.py +701 -0
- sqlspec/statement/sql.py +1198 -0
- sqlspec/storage/__init__.py +15 -0
- sqlspec/storage/backends/__init__.py +0 -0
- sqlspec/storage/backends/base.py +166 -0
- sqlspec/storage/backends/fsspec.py +315 -0
- sqlspec/storage/backends/obstore.py +464 -0
- sqlspec/storage/protocol.py +170 -0
- sqlspec/storage/registry.py +315 -0
- sqlspec/typing.py +157 -36
- sqlspec/utils/correlation.py +155 -0
- sqlspec/utils/deprecation.py +3 -6
- sqlspec/utils/fixtures.py +6 -11
- sqlspec/utils/logging.py +135 -0
- sqlspec/utils/module_loader.py +45 -43
- sqlspec/utils/serializers.py +4 -0
- sqlspec/utils/singleton.py +6 -8
- sqlspec/utils/sync_tools.py +15 -27
- sqlspec/utils/text.py +58 -26
- {sqlspec-0.11.0.dist-info → sqlspec-0.12.0.dist-info}/METADATA +100 -26
- sqlspec-0.12.0.dist-info/RECORD +145 -0
- sqlspec/adapters/bigquery/config/__init__.py +0 -3
- sqlspec/adapters/bigquery/config/_common.py +0 -40
- sqlspec/adapters/bigquery/config/_sync.py +0 -87
- sqlspec/adapters/oracledb/config/__init__.py +0 -9
- sqlspec/adapters/oracledb/config/_asyncio.py +0 -186
- sqlspec/adapters/oracledb/config/_common.py +0 -131
- sqlspec/adapters/oracledb/config/_sync.py +0 -186
- sqlspec/adapters/psycopg/config/__init__.py +0 -19
- sqlspec/adapters/psycopg/config/_async.py +0 -169
- sqlspec/adapters/psycopg/config/_common.py +0 -56
- sqlspec/adapters/psycopg/config/_sync.py +0 -168
- sqlspec/filters.py +0 -330
- sqlspec/mixins.py +0 -306
- sqlspec/statement.py +0 -378
- sqlspec-0.11.0.dist-info/RECORD +0 -69
- {sqlspec-0.11.0.dist-info → sqlspec-0.12.0.dist-info}/WHEEL +0 -0
- {sqlspec-0.11.0.dist-info → sqlspec-0.12.0.dist-info}/licenses/LICENSE +0 -0
- {sqlspec-0.11.0.dist-info → sqlspec-0.12.0.dist-info}/licenses/NOTICE +0 -0
sqlspec/_sql.py
ADDED
|
@@ -0,0 +1,1147 @@
|
|
|
1
|
+
"""Unified SQL factory for creating SQL builders and column expressions with a clean API.
|
|
2
|
+
|
|
3
|
+
This module provides the `sql` factory object for easy SQL construction:
|
|
4
|
+
- `sql` provides both statement builders (select, insert, update, etc.) and column expressions
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any, Optional, Union
|
|
9
|
+
|
|
10
|
+
import sqlglot
|
|
11
|
+
from sqlglot import exp
|
|
12
|
+
from sqlglot.dialects.dialect import DialectType
|
|
13
|
+
from sqlglot.errors import ParseError as SQLGlotParseError
|
|
14
|
+
|
|
15
|
+
from sqlspec.exceptions import SQLBuilderError
|
|
16
|
+
from sqlspec.statement.builder import DeleteBuilder, InsertBuilder, MergeBuilder, SelectBuilder, UpdateBuilder
|
|
17
|
+
|
|
18
|
+
__all__ = ("SQLFactory",)
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("sqlspec")
|
|
21
|
+
|
|
22
|
+
MIN_SQL_LIKE_STRING_LENGTH = 6
|
|
23
|
+
MIN_DECODE_ARGS = 2
|
|
24
|
+
SQL_STARTERS = {
|
|
25
|
+
"SELECT",
|
|
26
|
+
"INSERT",
|
|
27
|
+
"UPDATE",
|
|
28
|
+
"DELETE",
|
|
29
|
+
"MERGE",
|
|
30
|
+
"WITH",
|
|
31
|
+
"CALL",
|
|
32
|
+
"DECLARE",
|
|
33
|
+
"BEGIN",
|
|
34
|
+
"END",
|
|
35
|
+
"CREATE",
|
|
36
|
+
"DROP",
|
|
37
|
+
"ALTER",
|
|
38
|
+
"TRUNCATE",
|
|
39
|
+
"RENAME",
|
|
40
|
+
"GRANT",
|
|
41
|
+
"REVOKE",
|
|
42
|
+
"SET",
|
|
43
|
+
"SHOW",
|
|
44
|
+
"USE",
|
|
45
|
+
"EXPLAIN",
|
|
46
|
+
"OPTIMIZE",
|
|
47
|
+
"VACUUM",
|
|
48
|
+
"COPY",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SQLFactory:
|
|
53
|
+
"""Unified factory for creating SQL builders and column expressions with a fluent API.
|
|
54
|
+
|
|
55
|
+
Provides both statement builders and column expressions through a single, clean interface.
|
|
56
|
+
Now supports parsing raw SQL strings into appropriate builders for enhanced flexibility.
|
|
57
|
+
|
|
58
|
+
Example:
|
|
59
|
+
```python
|
|
60
|
+
from sqlspec import sql
|
|
61
|
+
|
|
62
|
+
# Traditional builder usage (unchanged)
|
|
63
|
+
query = (
|
|
64
|
+
sql.select(sql.id, sql.name)
|
|
65
|
+
.from_("users")
|
|
66
|
+
.where("age > 18")
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# New: Raw SQL parsing
|
|
70
|
+
insert_sql = sql.insert(
|
|
71
|
+
"INSERT INTO users (name, email) VALUES ('John', 'john@example.com')"
|
|
72
|
+
)
|
|
73
|
+
select_sql = sql.select(
|
|
74
|
+
"SELECT * FROM users WHERE active = 1"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# RETURNING clause detection
|
|
78
|
+
returning_insert = sql.insert(
|
|
79
|
+
"INSERT INTO users (name) VALUES ('John') RETURNING id"
|
|
80
|
+
)
|
|
81
|
+
# → When executed, will return SelectResult instead of ExecuteResult
|
|
82
|
+
|
|
83
|
+
# Smart INSERT FROM SELECT
|
|
84
|
+
insert_from_select = sql.insert(
|
|
85
|
+
"SELECT id, name FROM source WHERE active = 1"
|
|
86
|
+
)
|
|
87
|
+
# → Will prompt for target table or convert to INSERT FROM SELECT pattern
|
|
88
|
+
```
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def detect_sql_type(cls, sql: str, dialect: DialectType = None) -> str:
|
|
93
|
+
try:
|
|
94
|
+
# Minimal parsing just to get the command type
|
|
95
|
+
parsed_expr = sqlglot.parse_one(sql, read=dialect)
|
|
96
|
+
if parsed_expr and parsed_expr.key:
|
|
97
|
+
return parsed_expr.key.upper()
|
|
98
|
+
# Fallback for expressions that might not have a direct 'key'
|
|
99
|
+
# or where key is None (e.g. some DDL without explicit command like SET)
|
|
100
|
+
if parsed_expr:
|
|
101
|
+
# Attempt to get the class name as a fallback, e.g., "Set", "Command"
|
|
102
|
+
command_type = type(parsed_expr).__name__.upper()
|
|
103
|
+
# Handle specific cases like "COMMAND" which might be too generic
|
|
104
|
+
if command_type == "COMMAND" and parsed_expr.this:
|
|
105
|
+
return str(parsed_expr.this).upper() # e.g. "SET", "ALTER"
|
|
106
|
+
return command_type
|
|
107
|
+
except SQLGlotParseError:
|
|
108
|
+
logger.debug("Failed to parse SQL for type detection: %s", sql[:100])
|
|
109
|
+
except (ValueError, TypeError, AttributeError) as e:
|
|
110
|
+
logger.warning("Unexpected error during SQL type detection for '%s...': %s", sql[:50], e)
|
|
111
|
+
return "UNKNOWN"
|
|
112
|
+
|
|
113
|
+
def __init__(self, dialect: DialectType = None) -> None:
|
|
114
|
+
"""Initialize the SQL factory.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
dialect: Default SQL dialect to use for all builders.
|
|
118
|
+
"""
|
|
119
|
+
self.dialect = dialect
|
|
120
|
+
|
|
121
|
+
# ===================
|
|
122
|
+
# Callable Interface
|
|
123
|
+
# ===================
|
|
124
|
+
def __call__(
|
|
125
|
+
self,
|
|
126
|
+
statement: str,
|
|
127
|
+
parameters: Optional[Any] = None,
|
|
128
|
+
*filters: Any,
|
|
129
|
+
config: Optional[Any] = None,
|
|
130
|
+
dialect: DialectType = None,
|
|
131
|
+
**kwargs: Any,
|
|
132
|
+
) -> "Any":
|
|
133
|
+
"""Create a SelectBuilder from a SQL string, only allowing SELECT/CTE queries.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
statement: The SQL statement string.
|
|
137
|
+
parameters: Optional parameters for the query.
|
|
138
|
+
*filters: Optional filters.
|
|
139
|
+
config: Optional config.
|
|
140
|
+
dialect: Optional SQL dialect.
|
|
141
|
+
**kwargs: Additional parameters.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
SelectBuilder instance.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
SQLBuilderError: If the SQL is not a SELECT/CTE statement.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
parsed_expr = sqlglot.parse_one(statement, read=dialect or self.dialect)
|
|
152
|
+
except Exception as e:
|
|
153
|
+
msg = f"Failed to parse SQL: {e}"
|
|
154
|
+
raise SQLBuilderError(msg) from e
|
|
155
|
+
actual_type = type(parsed_expr).__name__.upper()
|
|
156
|
+
# Map sqlglot expression class to type string
|
|
157
|
+
expr_type_map = {
|
|
158
|
+
"SELECT": "SELECT",
|
|
159
|
+
"INSERT": "INSERT",
|
|
160
|
+
"UPDATE": "UPDATE",
|
|
161
|
+
"DELETE": "DELETE",
|
|
162
|
+
"MERGE": "MERGE",
|
|
163
|
+
"WITH": "WITH",
|
|
164
|
+
}
|
|
165
|
+
actual_type_str = expr_type_map.get(actual_type, actual_type)
|
|
166
|
+
# Only allow SELECT or WITH (if WITH wraps SELECT)
|
|
167
|
+
if actual_type_str == "SELECT" or (
|
|
168
|
+
actual_type_str == "WITH" and parsed_expr.this and isinstance(parsed_expr.this, exp.Select)
|
|
169
|
+
):
|
|
170
|
+
builder = SelectBuilder(dialect=dialect or self.dialect)
|
|
171
|
+
builder._expression = parsed_expr
|
|
172
|
+
return builder
|
|
173
|
+
# If not SELECT, raise with helpful message
|
|
174
|
+
msg = (
|
|
175
|
+
f"sql(...) only supports SELECT statements. Detected type: {actual_type_str}. "
|
|
176
|
+
f"Use sql.{actual_type_str.lower()}() instead."
|
|
177
|
+
)
|
|
178
|
+
raise SQLBuilderError(msg)
|
|
179
|
+
|
|
180
|
+
# ===================
|
|
181
|
+
# Statement Builders
|
|
182
|
+
# ===================
|
|
183
|
+
def select(self, *columns_or_sql: Union[str, exp.Expression], dialect: DialectType = None) -> "SelectBuilder":
|
|
184
|
+
builder_dialect = dialect or self.dialect
|
|
185
|
+
if len(columns_or_sql) == 1 and isinstance(columns_or_sql[0], str):
|
|
186
|
+
sql_candidate = columns_or_sql[0].strip()
|
|
187
|
+
# Check if it actually looks like SQL before parsing
|
|
188
|
+
if self._looks_like_sql(sql_candidate):
|
|
189
|
+
# Validate type
|
|
190
|
+
detected = self.detect_sql_type(sql_candidate, dialect=builder_dialect)
|
|
191
|
+
if detected not in {"SELECT", "WITH"}:
|
|
192
|
+
msg = (
|
|
193
|
+
f"sql.select() expects a SELECT or WITH statement, got {detected}. "
|
|
194
|
+
f"Use sql.{detected.lower()}() if a dedicated builder exists, or ensure the SQL is SELECT/WITH."
|
|
195
|
+
)
|
|
196
|
+
raise SQLBuilderError(msg)
|
|
197
|
+
select_builder = SelectBuilder(dialect=builder_dialect)
|
|
198
|
+
if select_builder._expression is None:
|
|
199
|
+
select_builder.__post_init__()
|
|
200
|
+
return self._populate_select_from_sql(select_builder, sql_candidate)
|
|
201
|
+
# Otherwise treat as column name and fall through to normal column handling
|
|
202
|
+
select_builder = SelectBuilder(dialect=builder_dialect)
|
|
203
|
+
if select_builder._expression is None:
|
|
204
|
+
select_builder.__post_init__()
|
|
205
|
+
if columns_or_sql:
|
|
206
|
+
select_builder.select(*columns_or_sql)
|
|
207
|
+
return select_builder
|
|
208
|
+
|
|
209
|
+
def insert(self, table_or_sql: Optional[str] = None, dialect: DialectType = None) -> "InsertBuilder":
|
|
210
|
+
builder_dialect = dialect or self.dialect
|
|
211
|
+
builder = InsertBuilder(dialect=builder_dialect)
|
|
212
|
+
if builder._expression is None:
|
|
213
|
+
builder.__post_init__()
|
|
214
|
+
if table_or_sql:
|
|
215
|
+
if self._looks_like_sql(table_or_sql):
|
|
216
|
+
detected = self.detect_sql_type(table_or_sql, dialect=builder_dialect)
|
|
217
|
+
if detected not in {"INSERT", "SELECT"}:
|
|
218
|
+
msg = (
|
|
219
|
+
f"sql.insert() expects INSERT or SELECT (for insert-from-select), got {detected}. "
|
|
220
|
+
f"Use sql.{detected.lower()}() if a dedicated builder exists, "
|
|
221
|
+
f"or ensure the SQL is INSERT/SELECT."
|
|
222
|
+
)
|
|
223
|
+
raise SQLBuilderError(msg)
|
|
224
|
+
return self._populate_insert_from_sql(builder, table_or_sql)
|
|
225
|
+
return builder.into(table_or_sql)
|
|
226
|
+
return builder
|
|
227
|
+
|
|
228
|
+
def update(self, table_or_sql: Optional[str] = None, dialect: DialectType = None) -> "UpdateBuilder":
|
|
229
|
+
builder_dialect = dialect or self.dialect
|
|
230
|
+
builder = UpdateBuilder(dialect=builder_dialect)
|
|
231
|
+
if builder._expression is None:
|
|
232
|
+
builder.__post_init__()
|
|
233
|
+
if table_or_sql:
|
|
234
|
+
if self._looks_like_sql(table_or_sql):
|
|
235
|
+
detected = self.detect_sql_type(table_or_sql, dialect=builder_dialect)
|
|
236
|
+
if detected != "UPDATE":
|
|
237
|
+
msg = f"sql.update() expects UPDATE statement, got {detected}. Use sql.{detected.lower()}() if a dedicated builder exists."
|
|
238
|
+
raise SQLBuilderError(msg)
|
|
239
|
+
return self._populate_update_from_sql(builder, table_or_sql)
|
|
240
|
+
return builder.table(table_or_sql)
|
|
241
|
+
return builder
|
|
242
|
+
|
|
243
|
+
def delete(self, table_or_sql: Optional[str] = None, dialect: DialectType = None) -> "DeleteBuilder":
|
|
244
|
+
builder_dialect = dialect or self.dialect
|
|
245
|
+
builder = DeleteBuilder(dialect=builder_dialect)
|
|
246
|
+
if builder._expression is None:
|
|
247
|
+
builder.__post_init__()
|
|
248
|
+
if table_or_sql and self._looks_like_sql(table_or_sql):
|
|
249
|
+
detected = self.detect_sql_type(table_or_sql, dialect=builder_dialect)
|
|
250
|
+
if detected != "DELETE":
|
|
251
|
+
msg = f"sql.delete() expects DELETE statement, got {detected}. Use sql.{detected.lower()}() if a dedicated builder exists."
|
|
252
|
+
raise SQLBuilderError(msg)
|
|
253
|
+
return self._populate_delete_from_sql(builder, table_or_sql)
|
|
254
|
+
return builder
|
|
255
|
+
|
|
256
|
+
def merge(self, table_or_sql: Optional[str] = None, dialect: DialectType = None) -> "MergeBuilder":
|
|
257
|
+
builder_dialect = dialect or self.dialect
|
|
258
|
+
builder = MergeBuilder(dialect=builder_dialect)
|
|
259
|
+
if builder._expression is None:
|
|
260
|
+
builder.__post_init__()
|
|
261
|
+
if table_or_sql:
|
|
262
|
+
if self._looks_like_sql(table_or_sql):
|
|
263
|
+
detected = self.detect_sql_type(table_or_sql, dialect=builder_dialect)
|
|
264
|
+
if detected != "MERGE":
|
|
265
|
+
msg = f"sql.merge() expects MERGE statement, got {detected}. Use sql.{detected.lower()}() if a dedicated builder exists."
|
|
266
|
+
raise SQLBuilderError(msg)
|
|
267
|
+
return self._populate_merge_from_sql(builder, table_or_sql)
|
|
268
|
+
return builder.into(table_or_sql)
|
|
269
|
+
return builder
|
|
270
|
+
|
|
271
|
+
# ===================
|
|
272
|
+
# SQL Analysis Helpers
|
|
273
|
+
# ===================
|
|
274
|
+
|
|
275
|
+
@staticmethod
|
|
276
|
+
def _looks_like_sql(candidate: str, expected_type: Optional[str] = None) -> bool:
|
|
277
|
+
"""Efficiently determine if a string looks like SQL.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
candidate: String to check
|
|
281
|
+
expected_type: Expected SQL statement type (SELECT, INSERT, etc.)
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
True if the string appears to be SQL
|
|
285
|
+
"""
|
|
286
|
+
if not candidate or len(candidate.strip()) < MIN_SQL_LIKE_STRING_LENGTH:
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
candidate_upper = candidate.strip().upper()
|
|
290
|
+
|
|
291
|
+
# Check for SQL keywords at the beginning
|
|
292
|
+
if expected_type:
|
|
293
|
+
return candidate_upper.startswith(expected_type.upper())
|
|
294
|
+
|
|
295
|
+
# More sophisticated check for SQL vs column names
|
|
296
|
+
# Column names that start with SQL keywords are common (user_id, insert_date, etc.)
|
|
297
|
+
if any(candidate_upper.startswith(starter) for starter in SQL_STARTERS):
|
|
298
|
+
# Additional checks to distinguish real SQL from column names:
|
|
299
|
+
# 1. Real SQL typically has spaces (SELECT ... FROM, INSERT INTO, etc.)
|
|
300
|
+
# 2. Check for common SQL syntax patterns
|
|
301
|
+
return " " in candidate
|
|
302
|
+
|
|
303
|
+
return False
|
|
304
|
+
|
|
305
|
+
def _populate_insert_from_sql(self, builder: "InsertBuilder", sql_string: str) -> "InsertBuilder":
|
|
306
|
+
"""Parse SQL string and populate INSERT builder using SQLGlot directly."""
|
|
307
|
+
try:
|
|
308
|
+
# Use SQLGlot directly for parsing - no validation here
|
|
309
|
+
parsed_expr = exp.maybe_parse(sql_string, dialect=self.dialect) # type: ignore[var-annotated]
|
|
310
|
+
if parsed_expr is None:
|
|
311
|
+
parsed_expr = sqlglot.parse_one(sql_string, read=self.dialect)
|
|
312
|
+
|
|
313
|
+
if isinstance(parsed_expr, exp.Insert):
|
|
314
|
+
# Set the internal expression to the parsed one
|
|
315
|
+
builder._expression = parsed_expr
|
|
316
|
+
return builder
|
|
317
|
+
|
|
318
|
+
if isinstance(parsed_expr, exp.Select):
|
|
319
|
+
# Handle INSERT FROM SELECT case - just return builder for now
|
|
320
|
+
# The actual conversion logic can be handled by the builder itself
|
|
321
|
+
logger.info("Detected SELECT statement for INSERT - may need target table specification")
|
|
322
|
+
return builder
|
|
323
|
+
|
|
324
|
+
# For other statement types, just return the builder as-is
|
|
325
|
+
logger.warning("Cannot create INSERT from %s statement", type(parsed_expr).__name__)
|
|
326
|
+
|
|
327
|
+
except Exception as e:
|
|
328
|
+
logger.warning("Failed to parse INSERT SQL, falling back to traditional mode: %s", e)
|
|
329
|
+
return builder
|
|
330
|
+
|
|
331
|
+
def _populate_select_from_sql(self, builder: "SelectBuilder", sql_string: str) -> "SelectBuilder":
|
|
332
|
+
"""Parse SQL string and populate SELECT builder using SQLGlot directly."""
|
|
333
|
+
try:
|
|
334
|
+
# Use SQLGlot directly for parsing - no validation here
|
|
335
|
+
parsed_expr = exp.maybe_parse(sql_string, dialect=self.dialect) # type: ignore[var-annotated]
|
|
336
|
+
if parsed_expr is None:
|
|
337
|
+
parsed_expr = sqlglot.parse_one(sql_string, read=self.dialect)
|
|
338
|
+
|
|
339
|
+
if isinstance(parsed_expr, exp.Select):
|
|
340
|
+
# Set the internal expression to the parsed one
|
|
341
|
+
builder._expression = parsed_expr
|
|
342
|
+
return builder
|
|
343
|
+
|
|
344
|
+
logger.warning("Cannot create SELECT from %s statement", type(parsed_expr).__name__)
|
|
345
|
+
|
|
346
|
+
except Exception as e:
|
|
347
|
+
logger.warning("Failed to parse SELECT SQL, falling back to traditional mode: %s", e)
|
|
348
|
+
return builder
|
|
349
|
+
|
|
350
|
+
def _populate_update_from_sql(self, builder: "UpdateBuilder", sql_string: str) -> "UpdateBuilder":
|
|
351
|
+
"""Parse SQL string and populate UPDATE builder using SQLGlot directly."""
|
|
352
|
+
try:
|
|
353
|
+
# Use SQLGlot directly for parsing - no validation here
|
|
354
|
+
parsed_expr = exp.maybe_parse(sql_string, dialect=self.dialect) # type: ignore[var-annotated]
|
|
355
|
+
if parsed_expr is None:
|
|
356
|
+
parsed_expr = sqlglot.parse_one(sql_string, read=self.dialect)
|
|
357
|
+
|
|
358
|
+
if isinstance(parsed_expr, exp.Update):
|
|
359
|
+
# Set the internal expression to the parsed one
|
|
360
|
+
builder._expression = parsed_expr
|
|
361
|
+
return builder
|
|
362
|
+
|
|
363
|
+
logger.warning("Cannot create UPDATE from %s statement", type(parsed_expr).__name__)
|
|
364
|
+
|
|
365
|
+
except Exception as e:
|
|
366
|
+
logger.warning("Failed to parse UPDATE SQL, falling back to traditional mode: %s", e)
|
|
367
|
+
return builder
|
|
368
|
+
|
|
369
|
+
def _populate_delete_from_sql(self, builder: "DeleteBuilder", sql_string: str) -> "DeleteBuilder":
|
|
370
|
+
"""Parse SQL string and populate DELETE builder using SQLGlot directly."""
|
|
371
|
+
try:
|
|
372
|
+
# Use SQLGlot directly for parsing - no validation here
|
|
373
|
+
parsed_expr = exp.maybe_parse(sql_string, dialect=self.dialect) # type: ignore[var-annotated]
|
|
374
|
+
if parsed_expr is None:
|
|
375
|
+
parsed_expr = sqlglot.parse_one(sql_string, read=self.dialect)
|
|
376
|
+
|
|
377
|
+
if isinstance(parsed_expr, exp.Delete):
|
|
378
|
+
# Set the internal expression to the parsed one
|
|
379
|
+
builder._expression = parsed_expr
|
|
380
|
+
return builder
|
|
381
|
+
|
|
382
|
+
logger.warning("Cannot create DELETE from %s statement", type(parsed_expr).__name__)
|
|
383
|
+
|
|
384
|
+
except Exception as e:
|
|
385
|
+
logger.warning("Failed to parse DELETE SQL, falling back to traditional mode: %s", e)
|
|
386
|
+
return builder
|
|
387
|
+
|
|
388
|
+
def _populate_merge_from_sql(self, builder: "MergeBuilder", sql_string: str) -> "MergeBuilder":
|
|
389
|
+
"""Parse SQL string and populate MERGE builder using SQLGlot directly."""
|
|
390
|
+
try:
|
|
391
|
+
# Use SQLGlot directly for parsing - no validation here
|
|
392
|
+
parsed_expr = exp.maybe_parse(sql_string, dialect=self.dialect) # type: ignore[var-annotated]
|
|
393
|
+
if parsed_expr is None:
|
|
394
|
+
parsed_expr = sqlglot.parse_one(sql_string, read=self.dialect)
|
|
395
|
+
|
|
396
|
+
if isinstance(parsed_expr, exp.Merge):
|
|
397
|
+
# Set the internal expression to the parsed one
|
|
398
|
+
builder._expression = parsed_expr
|
|
399
|
+
return builder
|
|
400
|
+
|
|
401
|
+
logger.warning("Cannot create MERGE from %s statement", type(parsed_expr).__name__)
|
|
402
|
+
|
|
403
|
+
except Exception as e:
|
|
404
|
+
logger.warning("Failed to parse MERGE SQL, falling back to traditional mode: %s", e)
|
|
405
|
+
return builder
|
|
406
|
+
|
|
407
|
+
# ===================
|
|
408
|
+
# Column References
|
|
409
|
+
# ===================
|
|
410
|
+
|
|
411
|
+
def __getattr__(self, name: str) -> exp.Column:
|
|
412
|
+
"""Dynamically create column references.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
name: Column name.
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
Column expression for the specified column name.
|
|
419
|
+
"""
|
|
420
|
+
return exp.column(name)
|
|
421
|
+
|
|
422
|
+
# ===================
|
|
423
|
+
# Aggregate Functions
|
|
424
|
+
# ===================
|
|
425
|
+
|
|
426
|
+
@staticmethod
|
|
427
|
+
def count(column: Union[str, exp.Expression] = "*", distinct: bool = False) -> exp.Expression:
|
|
428
|
+
"""Create a COUNT expression.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
column: Column to count (default "*").
|
|
432
|
+
distinct: Whether to use COUNT DISTINCT.
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
COUNT expression.
|
|
436
|
+
"""
|
|
437
|
+
if column == "*":
|
|
438
|
+
return exp.Count(this=exp.Star(), distinct=distinct)
|
|
439
|
+
col_expr = exp.column(column) if isinstance(column, str) else column
|
|
440
|
+
return exp.Count(this=col_expr, distinct=distinct)
|
|
441
|
+
|
|
442
|
+
def count_distinct(self, column: Union[str, exp.Expression]) -> exp.Expression:
|
|
443
|
+
"""Create a COUNT(DISTINCT column) expression.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
column: Column to count distinct values.
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
COUNT DISTINCT expression.
|
|
450
|
+
"""
|
|
451
|
+
return self.count(column, distinct=True)
|
|
452
|
+
|
|
453
|
+
@staticmethod
|
|
454
|
+
def sum(column: Union[str, exp.Expression], distinct: bool = False) -> exp.Expression:
|
|
455
|
+
"""Create a SUM expression.
|
|
456
|
+
|
|
457
|
+
Args:
|
|
458
|
+
column: Column to sum.
|
|
459
|
+
distinct: Whether to use SUM DISTINCT.
|
|
460
|
+
|
|
461
|
+
Returns:
|
|
462
|
+
SUM expression.
|
|
463
|
+
"""
|
|
464
|
+
col_expr = exp.column(column) if isinstance(column, str) else column
|
|
465
|
+
return exp.Sum(this=col_expr, distinct=distinct)
|
|
466
|
+
|
|
467
|
+
@staticmethod
|
|
468
|
+
def avg(column: Union[str, exp.Expression]) -> exp.Expression:
|
|
469
|
+
"""Create an AVG expression.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
column: Column to average.
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
AVG expression.
|
|
476
|
+
"""
|
|
477
|
+
col_expr = exp.column(column) if isinstance(column, str) else column
|
|
478
|
+
return exp.Avg(this=col_expr)
|
|
479
|
+
|
|
480
|
+
@staticmethod
|
|
481
|
+
def max(column: Union[str, exp.Expression]) -> exp.Expression:
|
|
482
|
+
"""Create a MAX expression.
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
column: Column to find maximum.
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
MAX expression.
|
|
489
|
+
"""
|
|
490
|
+
col_expr = exp.column(column) if isinstance(column, str) else column
|
|
491
|
+
return exp.Max(this=col_expr)
|
|
492
|
+
|
|
493
|
+
@staticmethod
|
|
494
|
+
def min(column: Union[str, exp.Expression]) -> exp.Expression:
|
|
495
|
+
"""Create a MIN expression.
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
column: Column to find minimum.
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
MIN expression.
|
|
502
|
+
"""
|
|
503
|
+
col_expr = exp.column(column) if isinstance(column, str) else column
|
|
504
|
+
return exp.Min(this=col_expr)
|
|
505
|
+
|
|
506
|
+
# ===================
|
|
507
|
+
# Advanced SQL Operations
|
|
508
|
+
# ===================
|
|
509
|
+
|
|
510
|
+
@staticmethod
|
|
511
|
+
def rollup(*columns: Union[str, exp.Expression]) -> exp.Expression:
|
|
512
|
+
"""Create a ROLLUP expression for GROUP BY clauses.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
*columns: Columns to include in the rollup.
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
ROLLUP expression.
|
|
519
|
+
|
|
520
|
+
Example:
|
|
521
|
+
```python
|
|
522
|
+
# GROUP BY ROLLUP(product, region)
|
|
523
|
+
query = (
|
|
524
|
+
sql.select("product", "region", sql.sum("sales"))
|
|
525
|
+
.from_("sales_data")
|
|
526
|
+
.group_by(sql.rollup("product", "region"))
|
|
527
|
+
)
|
|
528
|
+
```
|
|
529
|
+
"""
|
|
530
|
+
column_exprs = [exp.column(col) if isinstance(col, str) else col for col in columns]
|
|
531
|
+
return exp.Rollup(expressions=column_exprs)
|
|
532
|
+
|
|
533
|
+
@staticmethod
|
|
534
|
+
def cube(*columns: Union[str, exp.Expression]) -> exp.Expression:
|
|
535
|
+
"""Create a CUBE expression for GROUP BY clauses.
|
|
536
|
+
|
|
537
|
+
Args:
|
|
538
|
+
*columns: Columns to include in the cube.
|
|
539
|
+
|
|
540
|
+
Returns:
|
|
541
|
+
CUBE expression.
|
|
542
|
+
|
|
543
|
+
Example:
|
|
544
|
+
```python
|
|
545
|
+
# GROUP BY CUBE(product, region)
|
|
546
|
+
query = (
|
|
547
|
+
sql.select("product", "region", sql.sum("sales"))
|
|
548
|
+
.from_("sales_data")
|
|
549
|
+
.group_by(sql.cube("product", "region"))
|
|
550
|
+
)
|
|
551
|
+
```
|
|
552
|
+
"""
|
|
553
|
+
column_exprs = [exp.column(col) if isinstance(col, str) else col for col in columns]
|
|
554
|
+
return exp.Cube(expressions=column_exprs)
|
|
555
|
+
|
|
556
|
+
@staticmethod
|
|
557
|
+
def grouping_sets(*column_sets: Union[tuple[str, ...], list[str]]) -> exp.Expression:
|
|
558
|
+
"""Create a GROUPING SETS expression for GROUP BY clauses.
|
|
559
|
+
|
|
560
|
+
Args:
|
|
561
|
+
*column_sets: Sets of columns to group by.
|
|
562
|
+
|
|
563
|
+
Returns:
|
|
564
|
+
GROUPING SETS expression.
|
|
565
|
+
|
|
566
|
+
Example:
|
|
567
|
+
```python
|
|
568
|
+
# GROUP BY GROUPING SETS ((product), (region), ())
|
|
569
|
+
query = (
|
|
570
|
+
sql.select("product", "region", sql.sum("sales"))
|
|
571
|
+
.from_("sales_data")
|
|
572
|
+
.group_by(
|
|
573
|
+
sql.grouping_sets(("product",), ("region",), ())
|
|
574
|
+
)
|
|
575
|
+
)
|
|
576
|
+
```
|
|
577
|
+
"""
|
|
578
|
+
set_expressions = []
|
|
579
|
+
for column_set in column_sets:
|
|
580
|
+
if isinstance(column_set, (tuple, list)):
|
|
581
|
+
if len(column_set) == 0:
|
|
582
|
+
# Empty set for grand total
|
|
583
|
+
set_expressions.append(exp.Tuple(expressions=[]))
|
|
584
|
+
else:
|
|
585
|
+
columns = [exp.column(col) for col in column_set]
|
|
586
|
+
set_expressions.append(exp.Tuple(expressions=columns))
|
|
587
|
+
else:
|
|
588
|
+
set_expressions.append(exp.column(column_set))
|
|
589
|
+
|
|
590
|
+
return exp.GroupingSets(expressions=set_expressions)
|
|
591
|
+
|
|
592
|
+
@staticmethod
|
|
593
|
+
def any(values: Union[list[Any], exp.Expression, str]) -> exp.Expression:
|
|
594
|
+
"""Create an ANY expression for use with comparison operators.
|
|
595
|
+
|
|
596
|
+
Args:
|
|
597
|
+
values: Values, expression, or subquery for the ANY clause.
|
|
598
|
+
|
|
599
|
+
Returns:
|
|
600
|
+
ANY expression.
|
|
601
|
+
|
|
602
|
+
Example:
|
|
603
|
+
```python
|
|
604
|
+
# WHERE id = ANY(subquery)
|
|
605
|
+
subquery = sql.select("user_id").from_("active_users")
|
|
606
|
+
query = (
|
|
607
|
+
sql.select("*")
|
|
608
|
+
.from_("users")
|
|
609
|
+
.where(sql.id.eq(sql.any(subquery)))
|
|
610
|
+
)
|
|
611
|
+
```
|
|
612
|
+
"""
|
|
613
|
+
if isinstance(values, list):
|
|
614
|
+
# Convert list to array literal
|
|
615
|
+
literals = [exp.Literal.string(str(v)) if isinstance(v, str) else exp.Literal.number(v) for v in values]
|
|
616
|
+
return exp.Any(this=exp.Array(expressions=literals))
|
|
617
|
+
if isinstance(values, str):
|
|
618
|
+
# Parse as SQL
|
|
619
|
+
parsed = exp.maybe_parse(values) # type: ignore[var-annotated]
|
|
620
|
+
if parsed:
|
|
621
|
+
return exp.Any(this=parsed)
|
|
622
|
+
return exp.Any(this=exp.Literal.string(values))
|
|
623
|
+
return exp.Any(this=values)
|
|
624
|
+
|
|
625
|
+
# ===================
|
|
626
|
+
# String Functions
|
|
627
|
+
# ===================
|
|
628
|
+
|
|
629
|
+
@staticmethod
|
|
630
|
+
def concat(*expressions: Union[str, exp.Expression]) -> exp.Expression:
|
|
631
|
+
"""Create a CONCAT expression.
|
|
632
|
+
|
|
633
|
+
Args:
|
|
634
|
+
*expressions: Expressions to concatenate.
|
|
635
|
+
|
|
636
|
+
Returns:
|
|
637
|
+
CONCAT expression.
|
|
638
|
+
"""
|
|
639
|
+
exprs = [exp.column(expr) if isinstance(expr, str) else expr for expr in expressions]
|
|
640
|
+
return exp.Concat(expressions=exprs)
|
|
641
|
+
|
|
642
|
+
@staticmethod
|
|
643
|
+
def upper(column: Union[str, exp.Expression]) -> exp.Expression:
|
|
644
|
+
"""Create an UPPER expression.
|
|
645
|
+
|
|
646
|
+
Args:
|
|
647
|
+
column: Column to convert to uppercase.
|
|
648
|
+
|
|
649
|
+
Returns:
|
|
650
|
+
UPPER expression.
|
|
651
|
+
"""
|
|
652
|
+
col_expr = exp.column(column) if isinstance(column, str) else column
|
|
653
|
+
return exp.Upper(this=col_expr)
|
|
654
|
+
|
|
655
|
+
@staticmethod
|
|
656
|
+
def lower(column: Union[str, exp.Expression]) -> exp.Expression:
|
|
657
|
+
"""Create a LOWER expression.
|
|
658
|
+
|
|
659
|
+
Args:
|
|
660
|
+
column: Column to convert to lowercase.
|
|
661
|
+
|
|
662
|
+
Returns:
|
|
663
|
+
LOWER expression.
|
|
664
|
+
"""
|
|
665
|
+
col_expr = exp.column(column) if isinstance(column, str) else column
|
|
666
|
+
return exp.Lower(this=col_expr)
|
|
667
|
+
|
|
668
|
+
@staticmethod
|
|
669
|
+
def length(column: Union[str, exp.Expression]) -> exp.Expression:
|
|
670
|
+
"""Create a LENGTH expression.
|
|
671
|
+
|
|
672
|
+
Args:
|
|
673
|
+
column: Column to get length of.
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
LENGTH expression.
|
|
677
|
+
"""
|
|
678
|
+
col_expr = exp.column(column) if isinstance(column, str) else column
|
|
679
|
+
return exp.Length(this=col_expr)
|
|
680
|
+
|
|
681
|
+
# ===================
|
|
682
|
+
# Math Functions
|
|
683
|
+
# ===================
|
|
684
|
+
|
|
685
|
+
@staticmethod
|
|
686
|
+
def round(column: Union[str, exp.Expression], decimals: int = 0) -> exp.Expression:
|
|
687
|
+
"""Create a ROUND expression.
|
|
688
|
+
|
|
689
|
+
Args:
|
|
690
|
+
column: Column to round.
|
|
691
|
+
decimals: Number of decimal places.
|
|
692
|
+
|
|
693
|
+
Returns:
|
|
694
|
+
ROUND expression.
|
|
695
|
+
"""
|
|
696
|
+
col_expr = exp.column(column) if isinstance(column, str) else column
|
|
697
|
+
if decimals == 0:
|
|
698
|
+
return exp.Round(this=col_expr)
|
|
699
|
+
return exp.Round(this=col_expr, expression=exp.Literal.number(decimals))
|
|
700
|
+
|
|
701
|
+
# ===================
|
|
702
|
+
# Conversion Functions
|
|
703
|
+
# ===================
|
|
704
|
+
|
|
705
|
+
@staticmethod
|
|
706
|
+
def decode(column: Union[str, exp.Expression], *args: Union[str, exp.Expression, Any]) -> exp.Expression:
|
|
707
|
+
"""Create a DECODE expression (Oracle-style conditional logic).
|
|
708
|
+
|
|
709
|
+
DECODE compares column to each search value and returns the corresponding result.
|
|
710
|
+
If no match is found, returns the default value (if provided) or NULL.
|
|
711
|
+
|
|
712
|
+
Args:
|
|
713
|
+
column: Column to compare.
|
|
714
|
+
*args: Alternating search values and results, with optional default at the end.
|
|
715
|
+
Format: search1, result1, search2, result2, ..., [default]
|
|
716
|
+
|
|
717
|
+
Raises:
|
|
718
|
+
ValueError: If fewer than two search/result pairs are provided.
|
|
719
|
+
|
|
720
|
+
Returns:
|
|
721
|
+
CASE expression equivalent to DECODE.
|
|
722
|
+
|
|
723
|
+
Example:
|
|
724
|
+
```python
|
|
725
|
+
# DECODE(status, 'A', 'Active', 'I', 'Inactive', 'Unknown')
|
|
726
|
+
sql.decode(
|
|
727
|
+
"status", "A", "Active", "I", "Inactive", "Unknown"
|
|
728
|
+
)
|
|
729
|
+
```
|
|
730
|
+
"""
|
|
731
|
+
col_expr = exp.column(column) if isinstance(column, str) else column
|
|
732
|
+
|
|
733
|
+
if len(args) < MIN_DECODE_ARGS:
|
|
734
|
+
msg = "DECODE requires at least one search/result pair"
|
|
735
|
+
raise ValueError(msg)
|
|
736
|
+
|
|
737
|
+
# Build CASE expression
|
|
738
|
+
conditions = []
|
|
739
|
+
default = None
|
|
740
|
+
|
|
741
|
+
# Process search/result pairs
|
|
742
|
+
for i in range(0, len(args) - 1, 2):
|
|
743
|
+
if i + 1 >= len(args):
|
|
744
|
+
# Odd number of args means last one is default
|
|
745
|
+
default = exp.Literal.string(str(args[i])) if not isinstance(args[i], exp.Expression) else args[i]
|
|
746
|
+
break
|
|
747
|
+
|
|
748
|
+
search_val = args[i]
|
|
749
|
+
result_val = args[i + 1]
|
|
750
|
+
|
|
751
|
+
# Create search expression
|
|
752
|
+
if isinstance(search_val, str):
|
|
753
|
+
search_expr = exp.Literal.string(search_val)
|
|
754
|
+
elif isinstance(search_val, (int, float)):
|
|
755
|
+
search_expr = exp.Literal.number(search_val)
|
|
756
|
+
elif isinstance(search_val, exp.Expression):
|
|
757
|
+
search_expr = search_val # type: ignore[assignment]
|
|
758
|
+
else:
|
|
759
|
+
search_expr = exp.Literal.string(str(search_val))
|
|
760
|
+
|
|
761
|
+
# Create result expression
|
|
762
|
+
if isinstance(result_val, str):
|
|
763
|
+
result_expr = exp.Literal.string(result_val)
|
|
764
|
+
elif isinstance(result_val, (int, float)):
|
|
765
|
+
result_expr = exp.Literal.number(result_val)
|
|
766
|
+
elif isinstance(result_val, exp.Expression):
|
|
767
|
+
result_expr = result_val # type: ignore[assignment]
|
|
768
|
+
else:
|
|
769
|
+
result_expr = exp.Literal.string(str(result_val))
|
|
770
|
+
|
|
771
|
+
# Create WHEN condition
|
|
772
|
+
condition = exp.EQ(this=col_expr, expression=search_expr)
|
|
773
|
+
conditions.append(exp.When(this=condition, then=result_expr))
|
|
774
|
+
|
|
775
|
+
return exp.Case(ifs=conditions, default=default)
|
|
776
|
+
|
|
777
|
+
@staticmethod
|
|
778
|
+
def to_date(date_string: Union[str, exp.Expression], format_mask: Optional[str] = None) -> exp.Expression:
|
|
779
|
+
"""Create a TO_DATE expression for converting strings to dates.
|
|
780
|
+
|
|
781
|
+
Args:
|
|
782
|
+
date_string: String or expression containing the date to convert.
|
|
783
|
+
format_mask: Optional format mask (e.g., 'YYYY-MM-DD', 'DD/MM/YYYY').
|
|
784
|
+
|
|
785
|
+
Returns:
|
|
786
|
+
TO_DATE function expression.
|
|
787
|
+
"""
|
|
788
|
+
date_expr = exp.column(date_string) if isinstance(date_string, str) else date_string
|
|
789
|
+
|
|
790
|
+
if format_mask:
|
|
791
|
+
format_expr = exp.Literal.string(format_mask)
|
|
792
|
+
return exp.Anonymous(this="TO_DATE", expressions=[date_expr, format_expr])
|
|
793
|
+
return exp.Anonymous(this="TO_DATE", expressions=[date_expr])
|
|
794
|
+
|
|
795
|
+
@staticmethod
|
|
796
|
+
def to_char(column: Union[str, exp.Expression], format_mask: Optional[str] = None) -> exp.Expression:
|
|
797
|
+
"""Create a TO_CHAR expression for converting values to strings.
|
|
798
|
+
|
|
799
|
+
Args:
|
|
800
|
+
column: Column or expression to convert to string.
|
|
801
|
+
format_mask: Optional format mask for dates/numbers.
|
|
802
|
+
|
|
803
|
+
Returns:
|
|
804
|
+
TO_CHAR function expression.
|
|
805
|
+
"""
|
|
806
|
+
col_expr = exp.column(column) if isinstance(column, str) else column
|
|
807
|
+
|
|
808
|
+
if format_mask:
|
|
809
|
+
format_expr = exp.Literal.string(format_mask)
|
|
810
|
+
return exp.Anonymous(this="TO_CHAR", expressions=[col_expr, format_expr])
|
|
811
|
+
return exp.Anonymous(this="TO_CHAR", expressions=[col_expr])
|
|
812
|
+
|
|
813
|
+
@staticmethod
|
|
814
|
+
def to_string(column: Union[str, exp.Expression]) -> exp.Expression:
|
|
815
|
+
"""Create a TO_STRING expression for converting values to strings.
|
|
816
|
+
|
|
817
|
+
Args:
|
|
818
|
+
column: Column or expression to convert to string.
|
|
819
|
+
|
|
820
|
+
Returns:
|
|
821
|
+
TO_STRING or CAST AS STRING expression.
|
|
822
|
+
"""
|
|
823
|
+
col_expr = exp.column(column) if isinstance(column, str) else column
|
|
824
|
+
# Use CAST for broader compatibility
|
|
825
|
+
return exp.Cast(this=col_expr, to=exp.DataType.build("STRING"))
|
|
826
|
+
|
|
827
|
+
@staticmethod
|
|
828
|
+
def to_number(column: Union[str, exp.Expression], format_mask: Optional[str] = None) -> exp.Expression:
|
|
829
|
+
"""Create a TO_NUMBER expression for converting strings to numbers.
|
|
830
|
+
|
|
831
|
+
Args:
|
|
832
|
+
column: Column or expression to convert to number.
|
|
833
|
+
format_mask: Optional format mask for the conversion.
|
|
834
|
+
|
|
835
|
+
Returns:
|
|
836
|
+
TO_NUMBER function expression.
|
|
837
|
+
"""
|
|
838
|
+
col_expr = exp.column(column) if isinstance(column, str) else column
|
|
839
|
+
|
|
840
|
+
if format_mask:
|
|
841
|
+
format_expr = exp.Literal.string(format_mask)
|
|
842
|
+
return exp.Anonymous(this="TO_NUMBER", expressions=[col_expr, format_expr])
|
|
843
|
+
return exp.Anonymous(this="TO_NUMBER", expressions=[col_expr])
|
|
844
|
+
|
|
845
|
+
@staticmethod
|
|
846
|
+
def cast(column: Union[str, exp.Expression], data_type: str) -> exp.Expression:
|
|
847
|
+
"""Create a CAST expression for type conversion.
|
|
848
|
+
|
|
849
|
+
Args:
|
|
850
|
+
column: Column or expression to cast.
|
|
851
|
+
data_type: Target data type (e.g., 'INT', 'VARCHAR(100)', 'DECIMAL(10,2)').
|
|
852
|
+
|
|
853
|
+
Returns:
|
|
854
|
+
CAST expression.
|
|
855
|
+
"""
|
|
856
|
+
col_expr = exp.column(column) if isinstance(column, str) else column
|
|
857
|
+
return exp.Cast(this=col_expr, to=exp.DataType.build(data_type))
|
|
858
|
+
|
|
859
|
+
# ===================
|
|
860
|
+
# JSON Functions
|
|
861
|
+
# ===================
|
|
862
|
+
|
|
863
|
+
@staticmethod
|
|
864
|
+
def to_json(column: Union[str, exp.Expression]) -> exp.Expression:
|
|
865
|
+
"""Create a TO_JSON expression for converting values to JSON.
|
|
866
|
+
|
|
867
|
+
Args:
|
|
868
|
+
column: Column or expression to convert to JSON.
|
|
869
|
+
|
|
870
|
+
Returns:
|
|
871
|
+
TO_JSON function expression.
|
|
872
|
+
"""
|
|
873
|
+
col_expr = exp.column(column) if isinstance(column, str) else column
|
|
874
|
+
return exp.Anonymous(this="TO_JSON", expressions=[col_expr])
|
|
875
|
+
|
|
876
|
+
@staticmethod
|
|
877
|
+
def from_json(json_column: Union[str, exp.Expression], schema: Optional[str] = None) -> exp.Expression:
|
|
878
|
+
"""Create a FROM_JSON expression for parsing JSON strings.
|
|
879
|
+
|
|
880
|
+
Args:
|
|
881
|
+
json_column: Column or expression containing JSON string.
|
|
882
|
+
schema: Optional schema specification for the JSON structure.
|
|
883
|
+
|
|
884
|
+
Returns:
|
|
885
|
+
FROM_JSON function expression.
|
|
886
|
+
"""
|
|
887
|
+
json_expr = exp.column(json_column) if isinstance(json_column, str) else json_column
|
|
888
|
+
|
|
889
|
+
if schema:
|
|
890
|
+
schema_expr = exp.Literal.string(schema)
|
|
891
|
+
return exp.Anonymous(this="FROM_JSON", expressions=[json_expr, schema_expr])
|
|
892
|
+
return exp.Anonymous(this="FROM_JSON", expressions=[json_expr])
|
|
893
|
+
|
|
894
|
+
@staticmethod
|
|
895
|
+
def json_extract(json_column: Union[str, exp.Expression], path: str) -> exp.Expression:
|
|
896
|
+
"""Create a JSON_EXTRACT expression for extracting values from JSON.
|
|
897
|
+
|
|
898
|
+
Args:
|
|
899
|
+
json_column: Column or expression containing JSON.
|
|
900
|
+
path: JSON path to extract (e.g., '$.field', '$.array[0]').
|
|
901
|
+
|
|
902
|
+
Returns:
|
|
903
|
+
JSON_EXTRACT function expression.
|
|
904
|
+
"""
|
|
905
|
+
json_expr = exp.column(json_column) if isinstance(json_column, str) else json_column
|
|
906
|
+
path_expr = exp.Literal.string(path)
|
|
907
|
+
return exp.Anonymous(this="JSON_EXTRACT", expressions=[json_expr, path_expr])
|
|
908
|
+
|
|
909
|
+
@staticmethod
|
|
910
|
+
def json_value(json_column: Union[str, exp.Expression], path: str) -> exp.Expression:
|
|
911
|
+
"""Create a JSON_VALUE expression for extracting scalar values from JSON.
|
|
912
|
+
|
|
913
|
+
Args:
|
|
914
|
+
json_column: Column or expression containing JSON.
|
|
915
|
+
path: JSON path to extract scalar value.
|
|
916
|
+
|
|
917
|
+
Returns:
|
|
918
|
+
JSON_VALUE function expression.
|
|
919
|
+
"""
|
|
920
|
+
json_expr = exp.column(json_column) if isinstance(json_column, str) else json_column
|
|
921
|
+
path_expr = exp.Literal.string(path)
|
|
922
|
+
return exp.Anonymous(this="JSON_VALUE", expressions=[json_expr, path_expr])
|
|
923
|
+
|
|
924
|
+
# ===================
|
|
925
|
+
# NULL Functions
|
|
926
|
+
# ===================
|
|
927
|
+
|
|
928
|
+
@staticmethod
|
|
929
|
+
def coalesce(*expressions: Union[str, exp.Expression]) -> exp.Expression:
|
|
930
|
+
"""Create a COALESCE expression.
|
|
931
|
+
|
|
932
|
+
Args:
|
|
933
|
+
*expressions: Expressions to coalesce.
|
|
934
|
+
|
|
935
|
+
Returns:
|
|
936
|
+
COALESCE expression.
|
|
937
|
+
"""
|
|
938
|
+
exprs = [exp.column(expr) if isinstance(expr, str) else expr for expr in expressions]
|
|
939
|
+
return exp.Coalesce(expressions=exprs)
|
|
940
|
+
|
|
941
|
+
@staticmethod
|
|
942
|
+
def nvl(column: Union[str, exp.Expression], substitute_value: Union[str, exp.Expression, Any]) -> exp.Expression:
|
|
943
|
+
"""Create an NVL (Oracle-style) expression using COALESCE.
|
|
944
|
+
|
|
945
|
+
Args:
|
|
946
|
+
column: Column to check for NULL.
|
|
947
|
+
substitute_value: Value to use if column is NULL.
|
|
948
|
+
|
|
949
|
+
Returns:
|
|
950
|
+
COALESCE expression equivalent to NVL.
|
|
951
|
+
"""
|
|
952
|
+
col_expr = exp.column(column) if isinstance(column, str) else column
|
|
953
|
+
|
|
954
|
+
if isinstance(substitute_value, str):
|
|
955
|
+
sub_expr = exp.Literal.string(substitute_value)
|
|
956
|
+
elif isinstance(substitute_value, (int, float)):
|
|
957
|
+
sub_expr = exp.Literal.number(substitute_value)
|
|
958
|
+
elif isinstance(substitute_value, exp.Expression):
|
|
959
|
+
sub_expr = substitute_value # type: ignore[assignment]
|
|
960
|
+
else:
|
|
961
|
+
sub_expr = exp.Literal.string(str(substitute_value))
|
|
962
|
+
|
|
963
|
+
return exp.Coalesce(expressions=[col_expr, sub_expr])
|
|
964
|
+
|
|
965
|
+
# ===================
|
|
966
|
+
# Case Expressions
|
|
967
|
+
# ===================
|
|
968
|
+
|
|
969
|
+
@staticmethod
|
|
970
|
+
def case() -> "CaseExpressionBuilder":
|
|
971
|
+
"""Create a CASE expression builder.
|
|
972
|
+
|
|
973
|
+
Returns:
|
|
974
|
+
CaseExpressionBuilder for building CASE expressions.
|
|
975
|
+
"""
|
|
976
|
+
return CaseExpressionBuilder()
|
|
977
|
+
|
|
978
|
+
# ===================
|
|
979
|
+
# Window Functions
|
|
980
|
+
# ===================
|
|
981
|
+
|
|
982
|
+
def row_number(
|
|
983
|
+
self,
|
|
984
|
+
partition_by: Optional[Union[str, list[str], exp.Expression]] = None,
|
|
985
|
+
order_by: Optional[Union[str, list[str], exp.Expression]] = None,
|
|
986
|
+
) -> exp.Expression:
|
|
987
|
+
"""Create a ROW_NUMBER() window function.
|
|
988
|
+
|
|
989
|
+
Args:
|
|
990
|
+
partition_by: Columns to partition by.
|
|
991
|
+
order_by: Columns to order by.
|
|
992
|
+
|
|
993
|
+
Returns:
|
|
994
|
+
ROW_NUMBER window function expression.
|
|
995
|
+
"""
|
|
996
|
+
return self._create_window_function("ROW_NUMBER", [], partition_by, order_by)
|
|
997
|
+
|
|
998
|
+
def rank(
|
|
999
|
+
self,
|
|
1000
|
+
partition_by: Optional[Union[str, list[str], exp.Expression]] = None,
|
|
1001
|
+
order_by: Optional[Union[str, list[str], exp.Expression]] = None,
|
|
1002
|
+
) -> exp.Expression:
|
|
1003
|
+
"""Create a RANK() window function.
|
|
1004
|
+
|
|
1005
|
+
Args:
|
|
1006
|
+
partition_by: Columns to partition by.
|
|
1007
|
+
order_by: Columns to order by.
|
|
1008
|
+
|
|
1009
|
+
Returns:
|
|
1010
|
+
RANK window function expression.
|
|
1011
|
+
"""
|
|
1012
|
+
return self._create_window_function("RANK", [], partition_by, order_by)
|
|
1013
|
+
|
|
1014
|
+
def dense_rank(
|
|
1015
|
+
self,
|
|
1016
|
+
partition_by: Optional[Union[str, list[str], exp.Expression]] = None,
|
|
1017
|
+
order_by: Optional[Union[str, list[str], exp.Expression]] = None,
|
|
1018
|
+
) -> exp.Expression:
|
|
1019
|
+
"""Create a DENSE_RANK() window function.
|
|
1020
|
+
|
|
1021
|
+
Args:
|
|
1022
|
+
partition_by: Columns to partition by.
|
|
1023
|
+
order_by: Columns to order by.
|
|
1024
|
+
|
|
1025
|
+
Returns:
|
|
1026
|
+
DENSE_RANK window function expression.
|
|
1027
|
+
"""
|
|
1028
|
+
return self._create_window_function("DENSE_RANK", [], partition_by, order_by)
|
|
1029
|
+
|
|
1030
|
+
@staticmethod
|
|
1031
|
+
def _create_window_function(
|
|
1032
|
+
func_name: str,
|
|
1033
|
+
func_args: list[exp.Expression],
|
|
1034
|
+
partition_by: Optional[Union[str, list[str], exp.Expression]] = None,
|
|
1035
|
+
order_by: Optional[Union[str, list[str], exp.Expression]] = None,
|
|
1036
|
+
) -> exp.Expression:
|
|
1037
|
+
"""Helper to create window function expressions.
|
|
1038
|
+
|
|
1039
|
+
Args:
|
|
1040
|
+
func_name: Name of the window function.
|
|
1041
|
+
func_args: Arguments to the function.
|
|
1042
|
+
partition_by: Columns to partition by.
|
|
1043
|
+
order_by: Columns to order by.
|
|
1044
|
+
|
|
1045
|
+
Returns:
|
|
1046
|
+
Window function expression.
|
|
1047
|
+
"""
|
|
1048
|
+
# Create the function call
|
|
1049
|
+
func_expr = exp.Anonymous(this=func_name, expressions=func_args)
|
|
1050
|
+
|
|
1051
|
+
# Build OVER clause
|
|
1052
|
+
over_args: dict[str, Any] = {}
|
|
1053
|
+
|
|
1054
|
+
if partition_by:
|
|
1055
|
+
if isinstance(partition_by, str):
|
|
1056
|
+
over_args["partition_by"] = [exp.column(partition_by)]
|
|
1057
|
+
elif isinstance(partition_by, list):
|
|
1058
|
+
over_args["partition_by"] = [exp.column(col) for col in partition_by]
|
|
1059
|
+
elif isinstance(partition_by, exp.Expression):
|
|
1060
|
+
over_args["partition_by"] = [partition_by]
|
|
1061
|
+
|
|
1062
|
+
if order_by:
|
|
1063
|
+
if isinstance(order_by, str):
|
|
1064
|
+
over_args["order"] = [exp.column(order_by).asc()]
|
|
1065
|
+
elif isinstance(order_by, list):
|
|
1066
|
+
over_args["order"] = [exp.column(col).asc() for col in order_by]
|
|
1067
|
+
elif isinstance(order_by, exp.Expression):
|
|
1068
|
+
over_args["order"] = [order_by]
|
|
1069
|
+
|
|
1070
|
+
return exp.Window(this=func_expr, **over_args)
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
class CaseExpressionBuilder:
|
|
1074
|
+
"""Builder for CASE expressions using the SQL factory.
|
|
1075
|
+
|
|
1076
|
+
Example:
|
|
1077
|
+
```python
|
|
1078
|
+
from sqlspec import sql
|
|
1079
|
+
|
|
1080
|
+
case_expr = (
|
|
1081
|
+
sql.case()
|
|
1082
|
+
.when(sql.age < 18, "Minor")
|
|
1083
|
+
.when(sql.age < 65, "Adult")
|
|
1084
|
+
.else_("Senior")
|
|
1085
|
+
.end()
|
|
1086
|
+
)
|
|
1087
|
+
```
|
|
1088
|
+
"""
|
|
1089
|
+
|
|
1090
|
+
def __init__(self) -> None:
|
|
1091
|
+
"""Initialize the CASE expression builder."""
|
|
1092
|
+
self._conditions: list[exp.When] = []
|
|
1093
|
+
self._default: Optional[exp.Expression] = None
|
|
1094
|
+
|
|
1095
|
+
def when(
|
|
1096
|
+
self, condition: Union[str, exp.Expression], value: Union[str, exp.Expression, Any]
|
|
1097
|
+
) -> "CaseExpressionBuilder":
|
|
1098
|
+
"""Add a WHEN clause.
|
|
1099
|
+
|
|
1100
|
+
Args:
|
|
1101
|
+
condition: Condition to test.
|
|
1102
|
+
value: Value to return if condition is true.
|
|
1103
|
+
|
|
1104
|
+
Returns:
|
|
1105
|
+
Self for method chaining.
|
|
1106
|
+
"""
|
|
1107
|
+
cond_expr = exp.maybe_parse(condition) or exp.column(condition) if isinstance(condition, str) else condition
|
|
1108
|
+
|
|
1109
|
+
if isinstance(value, str):
|
|
1110
|
+
val_expr = exp.Literal.string(value)
|
|
1111
|
+
elif isinstance(value, (int, float)):
|
|
1112
|
+
val_expr = exp.Literal.number(value)
|
|
1113
|
+
elif isinstance(value, exp.Expression):
|
|
1114
|
+
val_expr = value # type: ignore[assignment]
|
|
1115
|
+
else:
|
|
1116
|
+
val_expr = exp.Literal.string(str(value))
|
|
1117
|
+
|
|
1118
|
+
when_clause = exp.When(this=cond_expr, then=val_expr)
|
|
1119
|
+
self._conditions.append(when_clause)
|
|
1120
|
+
return self
|
|
1121
|
+
|
|
1122
|
+
def else_(self, value: Union[str, exp.Expression, Any]) -> "CaseExpressionBuilder":
|
|
1123
|
+
"""Add an ELSE clause.
|
|
1124
|
+
|
|
1125
|
+
Args:
|
|
1126
|
+
value: Default value to return.
|
|
1127
|
+
|
|
1128
|
+
Returns:
|
|
1129
|
+
Self for method chaining.
|
|
1130
|
+
"""
|
|
1131
|
+
if isinstance(value, str):
|
|
1132
|
+
self._default = exp.Literal.string(value)
|
|
1133
|
+
elif isinstance(value, (int, float)):
|
|
1134
|
+
self._default = exp.Literal.number(value)
|
|
1135
|
+
elif isinstance(value, exp.Expression):
|
|
1136
|
+
self._default = value
|
|
1137
|
+
else:
|
|
1138
|
+
self._default = exp.Literal.string(str(value))
|
|
1139
|
+
return self
|
|
1140
|
+
|
|
1141
|
+
def end(self) -> exp.Expression:
|
|
1142
|
+
"""Complete the CASE expression.
|
|
1143
|
+
|
|
1144
|
+
Returns:
|
|
1145
|
+
Complete CASE expression.
|
|
1146
|
+
"""
|
|
1147
|
+
return exp.Case(ifs=self._conditions, default=self._default)
|