sqlspec 0.11.1__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 -621
- sqlspec/adapters/aiosqlite/__init__.py +2 -6
- sqlspec/adapters/aiosqlite/config.py +143 -57
- sqlspec/adapters/aiosqlite/driver.py +269 -431
- sqlspec/adapters/asyncmy/__init__.py +3 -8
- sqlspec/adapters/asyncmy/config.py +247 -202
- sqlspec/adapters/asyncmy/driver.py +218 -436
- sqlspec/adapters/asyncpg/__init__.py +4 -7
- sqlspec/adapters/asyncpg/config.py +329 -176
- sqlspec/adapters/asyncpg/driver.py +417 -487
- sqlspec/adapters/bigquery/__init__.py +2 -2
- sqlspec/adapters/bigquery/config.py +407 -0
- sqlspec/adapters/bigquery/driver.py +600 -553
- sqlspec/adapters/duckdb/__init__.py +4 -1
- sqlspec/adapters/duckdb/config.py +432 -321
- sqlspec/adapters/duckdb/driver.py +392 -406
- sqlspec/adapters/oracledb/__init__.py +3 -8
- sqlspec/adapters/oracledb/config.py +625 -0
- sqlspec/adapters/oracledb/driver.py +548 -921
- sqlspec/adapters/psqlpy/__init__.py +4 -7
- sqlspec/adapters/psqlpy/config.py +372 -203
- sqlspec/adapters/psqlpy/driver.py +197 -533
- sqlspec/adapters/psycopg/__init__.py +3 -8
- sqlspec/adapters/psycopg/config.py +741 -0
- sqlspec/adapters/psycopg/driver.py +734 -694
- sqlspec/adapters/sqlite/__init__.py +2 -6
- sqlspec/adapters/sqlite/config.py +146 -81
- sqlspec/adapters/sqlite/driver.py +242 -405
- sqlspec/base.py +220 -784
- 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.1.dist-info → sqlspec-0.12.0.dist-info}/METADATA +97 -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 -331
- sqlspec/mixins.py +0 -305
- sqlspec/statement.py +0 -378
- sqlspec-0.11.1.dist-info/RECORD +0 -69
- {sqlspec-0.11.1.dist-info → sqlspec-0.12.0.dist-info}/WHEEL +0 -0
- {sqlspec-0.11.1.dist-info → sqlspec-0.12.0.dist-info}/licenses/LICENSE +0 -0
- {sqlspec-0.11.1.dist-info → sqlspec-0.12.0.dist-info}/licenses/NOTICE +0 -0
sqlspec/loader.py
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
"""SQL file loader module for managing SQL statements from files.
|
|
2
|
+
|
|
3
|
+
This module provides functionality to load, cache, and manage SQL statements
|
|
4
|
+
from files using aiosql-style named queries.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Optional, Union
|
|
14
|
+
|
|
15
|
+
from sqlspec.exceptions import SQLFileNotFoundError, SQLFileParseError
|
|
16
|
+
from sqlspec.statement.sql import SQL
|
|
17
|
+
from sqlspec.storage import storage_registry
|
|
18
|
+
from sqlspec.storage.registry import StorageRegistry
|
|
19
|
+
from sqlspec.utils.correlation import CorrelationContext
|
|
20
|
+
from sqlspec.utils.logging import get_logger
|
|
21
|
+
|
|
22
|
+
__all__ = ("SQLFile", "SQLFileLoader")
|
|
23
|
+
|
|
24
|
+
logger = get_logger("loader")
|
|
25
|
+
|
|
26
|
+
# Matches: -- name: query_name (supports hyphens and special suffixes)
|
|
27
|
+
# We capture the name plus any trailing special characters
|
|
28
|
+
QUERY_NAME_PATTERN = re.compile(r"^\s*--\s*name\s*:\s*([\w-]+[^\w\s]*)\s*$", re.MULTILINE | re.IGNORECASE)
|
|
29
|
+
|
|
30
|
+
MIN_QUERY_PARTS = 3
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _normalize_query_name(name: str) -> str:
|
|
34
|
+
"""Normalize query name to be a valid Python identifier.
|
|
35
|
+
|
|
36
|
+
- Strips trailing special characters (like $, !, etc from aiosql)
|
|
37
|
+
- Replaces hyphens with underscores
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
name: Raw query name from SQL file
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Normalized query name suitable as Python identifier
|
|
44
|
+
"""
|
|
45
|
+
# First strip any trailing special characters
|
|
46
|
+
name = re.sub(r"[^\w-]+$", "", name)
|
|
47
|
+
# Then replace hyphens with underscores
|
|
48
|
+
return name.replace("-", "_")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class SQLFile:
|
|
53
|
+
"""Represents a loaded SQL file with metadata.
|
|
54
|
+
|
|
55
|
+
This class holds the SQL content along with metadata about the file
|
|
56
|
+
such as its location, timestamps, and content hash.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
content: str
|
|
60
|
+
"""The raw SQL content from the file."""
|
|
61
|
+
|
|
62
|
+
path: str
|
|
63
|
+
"""Path where the SQL file was loaded from."""
|
|
64
|
+
|
|
65
|
+
metadata: "dict[str, Any]" = field(default_factory=dict)
|
|
66
|
+
"""Optional metadata associated with the SQL file."""
|
|
67
|
+
|
|
68
|
+
checksum: str = field(init=False)
|
|
69
|
+
"""MD5 checksum of the SQL content for cache invalidation."""
|
|
70
|
+
|
|
71
|
+
loaded_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
72
|
+
"""Timestamp when the file was loaded."""
|
|
73
|
+
|
|
74
|
+
def __post_init__(self) -> None:
|
|
75
|
+
"""Calculate checksum after initialization."""
|
|
76
|
+
self.checksum = hashlib.md5(self.content.encode(), usedforsecurity=False).hexdigest()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class SQLFileLoader:
|
|
80
|
+
"""Loads and parses SQL files with aiosql-style named queries.
|
|
81
|
+
|
|
82
|
+
This class provides functionality to load SQL files containing
|
|
83
|
+
named queries (using -- name: syntax) and retrieve them by name.
|
|
84
|
+
|
|
85
|
+
Example:
|
|
86
|
+
```python
|
|
87
|
+
# Initialize loader
|
|
88
|
+
loader = SQLFileLoader()
|
|
89
|
+
|
|
90
|
+
# Load SQL files
|
|
91
|
+
loader.load_sql("queries/users.sql")
|
|
92
|
+
loader.load_sql(
|
|
93
|
+
"queries/products.sql", "queries/orders.sql"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Get SQL by query name
|
|
97
|
+
sql = loader.get_sql("get_user_by_id", user_id=123)
|
|
98
|
+
```
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, *, encoding: str = "utf-8", storage_registry: StorageRegistry = storage_registry) -> None:
|
|
102
|
+
"""Initialize the SQL file loader.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
encoding: Text encoding for reading SQL files.
|
|
106
|
+
storage_registry: Storage registry for handling file URIs.
|
|
107
|
+
"""
|
|
108
|
+
self.encoding = encoding
|
|
109
|
+
self.storage_registry = storage_registry
|
|
110
|
+
# Instance-level storage for loaded queries and files
|
|
111
|
+
self._queries: dict[str, str] = {}
|
|
112
|
+
self._files: dict[str, SQLFile] = {}
|
|
113
|
+
self._query_to_file: dict[str, str] = {} # Maps query name to file path
|
|
114
|
+
|
|
115
|
+
def _read_file_content(self, path: Union[str, Path]) -> str:
|
|
116
|
+
"""Read file content using appropriate backend.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
path: File path (can be local path or URI).
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
File content as string.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
SQLFileParseError: If file cannot be read.
|
|
126
|
+
"""
|
|
127
|
+
path_str = str(path)
|
|
128
|
+
|
|
129
|
+
# Use storage backend for URIs (anything with a scheme)
|
|
130
|
+
if "://" in path_str:
|
|
131
|
+
try:
|
|
132
|
+
backend = self.storage_registry.get(path_str)
|
|
133
|
+
return backend.read_text(path_str, encoding=self.encoding)
|
|
134
|
+
except KeyError as e:
|
|
135
|
+
raise SQLFileNotFoundError(path_str) from e
|
|
136
|
+
except Exception as e:
|
|
137
|
+
raise SQLFileParseError(path_str, path_str, e) from e
|
|
138
|
+
|
|
139
|
+
# Handle local file paths
|
|
140
|
+
local_path = Path(path_str)
|
|
141
|
+
self._check_file_path(local_path)
|
|
142
|
+
content_bytes = self._read_file_content_bytes(local_path)
|
|
143
|
+
return content_bytes.decode(self.encoding)
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _read_file_content_bytes(path: Path) -> bytes:
|
|
147
|
+
try:
|
|
148
|
+
return path.read_bytes()
|
|
149
|
+
except Exception as e:
|
|
150
|
+
raise SQLFileParseError(str(path), str(path), e) from e
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _check_file_path(path: Union[str, Path]) -> None:
|
|
154
|
+
"""Ensure the file exists and is a valid path."""
|
|
155
|
+
path_obj = Path(path).resolve()
|
|
156
|
+
if not path_obj.exists():
|
|
157
|
+
raise SQLFileNotFoundError(str(path_obj))
|
|
158
|
+
if not path_obj.is_file():
|
|
159
|
+
raise SQLFileParseError(str(path_obj), str(path_obj), ValueError("Path is not a file"))
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _strip_leading_comments(sql_text: str) -> str:
|
|
163
|
+
"""Remove leading comment lines from a SQL string."""
|
|
164
|
+
lines = sql_text.strip().split("\n")
|
|
165
|
+
first_sql_line_index = -1
|
|
166
|
+
for i, line in enumerate(lines):
|
|
167
|
+
if line.strip() and not line.strip().startswith("--"):
|
|
168
|
+
first_sql_line_index = i
|
|
169
|
+
break
|
|
170
|
+
if first_sql_line_index == -1:
|
|
171
|
+
return "" # All comments or empty
|
|
172
|
+
return "\n".join(lines[first_sql_line_index:]).strip()
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _parse_sql_content(content: str, file_path: str) -> dict[str, str]:
|
|
176
|
+
"""Parse SQL content and extract named queries.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
content: SQL file content.
|
|
180
|
+
file_path: Path to the file (for error messages).
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Dictionary mapping query names to SQL text.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
SQLFileParseError: If no named queries found.
|
|
187
|
+
"""
|
|
188
|
+
queries: dict[str, str] = {}
|
|
189
|
+
|
|
190
|
+
# Split content by query name patterns
|
|
191
|
+
parts = QUERY_NAME_PATTERN.split(content)
|
|
192
|
+
|
|
193
|
+
if len(parts) < MIN_QUERY_PARTS:
|
|
194
|
+
# No named queries found
|
|
195
|
+
raise SQLFileParseError(
|
|
196
|
+
file_path, file_path, ValueError("No named SQL statements found (-- name: query_name)")
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Process each named query
|
|
200
|
+
for i in range(1, len(parts), 2):
|
|
201
|
+
if i + 1 >= len(parts):
|
|
202
|
+
break
|
|
203
|
+
|
|
204
|
+
raw_query_name = parts[i].strip()
|
|
205
|
+
sql_text = parts[i + 1].strip()
|
|
206
|
+
|
|
207
|
+
if not raw_query_name or not sql_text:
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
clean_sql = SQLFileLoader._strip_leading_comments(sql_text)
|
|
211
|
+
|
|
212
|
+
if clean_sql:
|
|
213
|
+
# Normalize to Python-compatible identifier
|
|
214
|
+
query_name = _normalize_query_name(raw_query_name)
|
|
215
|
+
|
|
216
|
+
if query_name in queries:
|
|
217
|
+
# Duplicate query name
|
|
218
|
+
raise SQLFileParseError(file_path, file_path, ValueError(f"Duplicate query name: {raw_query_name}"))
|
|
219
|
+
queries[query_name] = clean_sql
|
|
220
|
+
|
|
221
|
+
if not queries:
|
|
222
|
+
raise SQLFileParseError(file_path, file_path, ValueError("No valid SQL queries found after parsing"))
|
|
223
|
+
|
|
224
|
+
return queries
|
|
225
|
+
|
|
226
|
+
def load_sql(self, *paths: Union[str, Path]) -> None:
|
|
227
|
+
"""Load SQL files and parse named queries.
|
|
228
|
+
|
|
229
|
+
Supports both individual files and directories. When loading directories,
|
|
230
|
+
automatically namespaces queries based on subdirectory structure.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
*paths: One or more file paths or directory paths to load.
|
|
234
|
+
"""
|
|
235
|
+
correlation_id = CorrelationContext.get()
|
|
236
|
+
start_time = time.perf_counter()
|
|
237
|
+
|
|
238
|
+
logger.info("Loading SQL files", extra={"file_count": len(paths), "correlation_id": correlation_id})
|
|
239
|
+
|
|
240
|
+
loaded_count = 0
|
|
241
|
+
query_count_before = len(self._queries)
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
for path in paths:
|
|
245
|
+
path_str = str(path)
|
|
246
|
+
|
|
247
|
+
# Check if it's a URI
|
|
248
|
+
if "://" in path_str:
|
|
249
|
+
# URIs are always treated as files, not directories
|
|
250
|
+
self._load_single_file(path, None)
|
|
251
|
+
loaded_count += 1
|
|
252
|
+
else:
|
|
253
|
+
# Local path - check if it's a directory or file
|
|
254
|
+
path_obj = Path(path)
|
|
255
|
+
if path_obj.is_dir():
|
|
256
|
+
file_count_before = len(self._files)
|
|
257
|
+
self._load_directory(path_obj)
|
|
258
|
+
loaded_count += len(self._files) - file_count_before
|
|
259
|
+
else:
|
|
260
|
+
self._load_single_file(path_obj, None)
|
|
261
|
+
loaded_count += 1
|
|
262
|
+
|
|
263
|
+
duration = time.perf_counter() - start_time
|
|
264
|
+
new_queries = len(self._queries) - query_count_before
|
|
265
|
+
|
|
266
|
+
logger.info(
|
|
267
|
+
"Loaded %d SQL files with %d new queries in %.3fms",
|
|
268
|
+
loaded_count,
|
|
269
|
+
new_queries,
|
|
270
|
+
duration * 1000,
|
|
271
|
+
extra={
|
|
272
|
+
"files_loaded": loaded_count,
|
|
273
|
+
"new_queries": new_queries,
|
|
274
|
+
"duration_ms": duration * 1000,
|
|
275
|
+
"correlation_id": correlation_id,
|
|
276
|
+
},
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
except Exception as e:
|
|
280
|
+
duration = time.perf_counter() - start_time
|
|
281
|
+
logger.exception(
|
|
282
|
+
"Failed to load SQL files after %.3fms",
|
|
283
|
+
duration * 1000,
|
|
284
|
+
extra={
|
|
285
|
+
"error_type": type(e).__name__,
|
|
286
|
+
"duration_ms": duration * 1000,
|
|
287
|
+
"correlation_id": correlation_id,
|
|
288
|
+
},
|
|
289
|
+
)
|
|
290
|
+
raise
|
|
291
|
+
|
|
292
|
+
def _load_directory(self, dir_path: Path) -> None:
|
|
293
|
+
"""Load all SQL files from a directory with namespacing.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
dir_path: Directory path to scan for SQL files.
|
|
297
|
+
|
|
298
|
+
Raises:
|
|
299
|
+
SQLFileParseError: If directory contains no SQL files.
|
|
300
|
+
"""
|
|
301
|
+
sql_files = list(dir_path.rglob("*.sql"))
|
|
302
|
+
|
|
303
|
+
if not sql_files:
|
|
304
|
+
raise SQLFileParseError(
|
|
305
|
+
str(dir_path), str(dir_path), ValueError(f"No SQL files found in directory: {dir_path}")
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
for file_path in sql_files:
|
|
309
|
+
# Calculate namespace based on relative path from base directory
|
|
310
|
+
relative_path = file_path.relative_to(dir_path)
|
|
311
|
+
namespace_parts = relative_path.parent.parts
|
|
312
|
+
|
|
313
|
+
# Create namespace (empty for root-level files)
|
|
314
|
+
namespace = ".".join(namespace_parts) if namespace_parts else None
|
|
315
|
+
|
|
316
|
+
self._load_single_file(file_path, namespace)
|
|
317
|
+
|
|
318
|
+
def _load_single_file(self, file_path: Union[str, Path], namespace: Optional[str]) -> None:
|
|
319
|
+
"""Load a single SQL file with optional namespace.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
file_path: Path to the SQL file (can be string for URIs or Path for local files).
|
|
323
|
+
namespace: Optional namespace prefix for queries.
|
|
324
|
+
"""
|
|
325
|
+
path_str = str(file_path)
|
|
326
|
+
|
|
327
|
+
# Check if already loaded
|
|
328
|
+
if path_str in self._files:
|
|
329
|
+
# File already loaded, just ensure queries are in the main dict
|
|
330
|
+
file_obj = self._files[path_str]
|
|
331
|
+
queries = self._parse_sql_content(file_obj.content, path_str)
|
|
332
|
+
for name in queries:
|
|
333
|
+
namespaced_name = f"{namespace}.{name}" if namespace else name
|
|
334
|
+
if namespaced_name not in self._queries:
|
|
335
|
+
self._queries[namespaced_name] = queries[name]
|
|
336
|
+
self._query_to_file[namespaced_name] = path_str
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
# Read file content
|
|
340
|
+
content = self._read_file_content(file_path)
|
|
341
|
+
|
|
342
|
+
# Create SQLFile object
|
|
343
|
+
sql_file = SQLFile(content=content, path=path_str)
|
|
344
|
+
|
|
345
|
+
# Cache the file
|
|
346
|
+
self._files[path_str] = sql_file
|
|
347
|
+
|
|
348
|
+
# Parse and cache queries
|
|
349
|
+
queries = self._parse_sql_content(content, path_str)
|
|
350
|
+
|
|
351
|
+
# Merge into main query dictionary with namespace
|
|
352
|
+
for name, sql in queries.items():
|
|
353
|
+
namespaced_name = f"{namespace}.{name}" if namespace else name
|
|
354
|
+
|
|
355
|
+
if namespaced_name in self._queries and self._query_to_file.get(namespaced_name) != path_str:
|
|
356
|
+
# Query name exists from a different file
|
|
357
|
+
existing_file = self._query_to_file.get(namespaced_name, "unknown")
|
|
358
|
+
raise SQLFileParseError(
|
|
359
|
+
path_str,
|
|
360
|
+
path_str,
|
|
361
|
+
ValueError(f"Query name '{namespaced_name}' already exists in file: {existing_file}"),
|
|
362
|
+
)
|
|
363
|
+
self._queries[namespaced_name] = sql
|
|
364
|
+
self._query_to_file[namespaced_name] = path_str
|
|
365
|
+
|
|
366
|
+
def add_named_sql(self, name: str, sql: str) -> None:
|
|
367
|
+
"""Add a named SQL query directly without loading from a file.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
name: Name for the SQL query.
|
|
371
|
+
sql: Raw SQL content.
|
|
372
|
+
|
|
373
|
+
Raises:
|
|
374
|
+
ValueError: If query name already exists.
|
|
375
|
+
"""
|
|
376
|
+
if name in self._queries:
|
|
377
|
+
existing_source = self._query_to_file.get(name, "<directly added>")
|
|
378
|
+
msg = f"Query name '{name}' already exists (source: {existing_source})"
|
|
379
|
+
raise ValueError(msg)
|
|
380
|
+
|
|
381
|
+
self._queries[name] = sql.strip()
|
|
382
|
+
# Use special marker for directly added queries
|
|
383
|
+
self._query_to_file[name] = "<directly added>"
|
|
384
|
+
|
|
385
|
+
def get_sql(self, name: str, parameters: "Optional[Any]" = None, **kwargs: "Any") -> "SQL":
|
|
386
|
+
"""Get a SQL object by query name.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
name: Name of the query (from -- name: in SQL file).
|
|
390
|
+
Hyphens in names are automatically converted to underscores.
|
|
391
|
+
parameters: Parameters for the SQL query (aiosql-compatible).
|
|
392
|
+
**kwargs: Additional parameters to pass to the SQL object.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
SQL object ready for execution.
|
|
396
|
+
|
|
397
|
+
Raises:
|
|
398
|
+
SQLFileNotFoundError: If query name not found.
|
|
399
|
+
"""
|
|
400
|
+
correlation_id = CorrelationContext.get()
|
|
401
|
+
|
|
402
|
+
# Normalize query name for lookup
|
|
403
|
+
safe_name = _normalize_query_name(name)
|
|
404
|
+
|
|
405
|
+
logger.debug(
|
|
406
|
+
"Retrieving SQL query: %s",
|
|
407
|
+
name,
|
|
408
|
+
extra={
|
|
409
|
+
"query_name": name,
|
|
410
|
+
"safe_name": safe_name,
|
|
411
|
+
"has_parameters": parameters is not None,
|
|
412
|
+
"correlation_id": correlation_id,
|
|
413
|
+
},
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
if safe_name not in self._queries:
|
|
417
|
+
available = ", ".join(sorted(self._queries.keys())) if self._queries else "none"
|
|
418
|
+
logger.error(
|
|
419
|
+
"Query not found: %s",
|
|
420
|
+
name,
|
|
421
|
+
extra={
|
|
422
|
+
"query_name": name,
|
|
423
|
+
"safe_name": safe_name,
|
|
424
|
+
"available_queries": len(self._queries),
|
|
425
|
+
"correlation_id": correlation_id,
|
|
426
|
+
},
|
|
427
|
+
)
|
|
428
|
+
raise SQLFileNotFoundError(name, path=f"Query '{name}' not found. Available queries: {available}")
|
|
429
|
+
|
|
430
|
+
# Merge parameters and kwargs for SQL object creation
|
|
431
|
+
sql_kwargs = dict(kwargs)
|
|
432
|
+
if parameters is not None:
|
|
433
|
+
sql_kwargs["parameters"] = parameters
|
|
434
|
+
|
|
435
|
+
# Get source file for additional context
|
|
436
|
+
source_file = self._query_to_file.get(safe_name, "unknown")
|
|
437
|
+
|
|
438
|
+
logger.debug(
|
|
439
|
+
"Found query %s from %s",
|
|
440
|
+
name,
|
|
441
|
+
source_file,
|
|
442
|
+
extra={
|
|
443
|
+
"query_name": name,
|
|
444
|
+
"safe_name": safe_name,
|
|
445
|
+
"source_file": source_file,
|
|
446
|
+
"sql_length": len(self._queries[safe_name]),
|
|
447
|
+
"correlation_id": correlation_id,
|
|
448
|
+
},
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
return SQL(self._queries[safe_name], **sql_kwargs)
|
|
452
|
+
|
|
453
|
+
def get_file(self, path: Union[str, Path]) -> "Optional[SQLFile]":
|
|
454
|
+
"""Get a loaded SQLFile object by path.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
path: Path of the file.
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
SQLFile object if loaded, None otherwise.
|
|
461
|
+
"""
|
|
462
|
+
return self._files.get(str(path))
|
|
463
|
+
|
|
464
|
+
def get_file_for_query(self, name: str) -> "Optional[SQLFile]":
|
|
465
|
+
"""Get the SQLFile object that contains a query.
|
|
466
|
+
|
|
467
|
+
Args:
|
|
468
|
+
name: Query name (hyphens are converted to underscores).
|
|
469
|
+
|
|
470
|
+
Returns:
|
|
471
|
+
SQLFile object if query exists, None otherwise.
|
|
472
|
+
"""
|
|
473
|
+
safe_name = _normalize_query_name(name)
|
|
474
|
+
if safe_name in self._query_to_file:
|
|
475
|
+
file_path = self._query_to_file[safe_name]
|
|
476
|
+
return self._files.get(file_path)
|
|
477
|
+
return None
|
|
478
|
+
|
|
479
|
+
def list_queries(self) -> "list[str]":
|
|
480
|
+
"""List all available query names.
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
Sorted list of query names.
|
|
484
|
+
"""
|
|
485
|
+
return sorted(self._queries.keys())
|
|
486
|
+
|
|
487
|
+
def list_files(self) -> "list[str]":
|
|
488
|
+
"""List all loaded file paths.
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
Sorted list of file paths.
|
|
492
|
+
"""
|
|
493
|
+
return sorted(self._files.keys())
|
|
494
|
+
|
|
495
|
+
def has_query(self, name: str) -> bool:
|
|
496
|
+
"""Check if a query exists.
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
name: Query name to check (hyphens are converted to underscores).
|
|
500
|
+
|
|
501
|
+
Returns:
|
|
502
|
+
True if query exists.
|
|
503
|
+
"""
|
|
504
|
+
safe_name = _normalize_query_name(name)
|
|
505
|
+
return safe_name in self._queries
|
|
506
|
+
|
|
507
|
+
def clear_cache(self) -> None:
|
|
508
|
+
"""Clear all cached files and queries."""
|
|
509
|
+
self._files.clear()
|
|
510
|
+
self._queries.clear()
|
|
511
|
+
self._query_to_file.clear()
|
|
512
|
+
|
|
513
|
+
def get_query_text(self, name: str) -> str:
|
|
514
|
+
"""Get raw SQL text for a query.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
name: Query name (hyphens are converted to underscores).
|
|
518
|
+
|
|
519
|
+
Returns:
|
|
520
|
+
Raw SQL text.
|
|
521
|
+
|
|
522
|
+
Raises:
|
|
523
|
+
SQLFileNotFoundError: If query not found.
|
|
524
|
+
"""
|
|
525
|
+
safe_name = _normalize_query_name(name)
|
|
526
|
+
if safe_name not in self._queries:
|
|
527
|
+
raise SQLFileNotFoundError(name)
|
|
528
|
+
return self._queries[safe_name]
|
sqlspec/service/base.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from typing import Generic, TypeVar
|
|
2
|
+
|
|
3
|
+
from sqlspec.config import DriverT
|
|
4
|
+
|
|
5
|
+
__all__ = ("SqlspecService",)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SqlspecService(Generic[DriverT]):
|
|
12
|
+
"""Base Service for a Query repo"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, driver: "DriverT") -> None:
|
|
15
|
+
self._driver = driver
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def new(cls, driver: "DriverT") -> "SqlspecService[DriverT]":
|
|
19
|
+
return cls(driver=driver)
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def driver(self) -> "DriverT":
|
|
23
|
+
"""Get the driver instance."""
|
|
24
|
+
return self._driver
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Generic, TypeVar
|
|
4
|
+
|
|
5
|
+
T = TypeVar("T")
|
|
6
|
+
|
|
7
|
+
__all__ = ("OffsetPagination",)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class OffsetPagination(Generic[T]):
|
|
12
|
+
"""Container for data returned using limit/offset pagination."""
|
|
13
|
+
|
|
14
|
+
__slots__ = ("items", "limit", "offset", "total")
|
|
15
|
+
|
|
16
|
+
items: Sequence[T]
|
|
17
|
+
"""List of data being sent as part of the response."""
|
|
18
|
+
limit: int
|
|
19
|
+
"""Maximal number of items to send."""
|
|
20
|
+
offset: int
|
|
21
|
+
"""Offset from the beginning of the query.
|
|
22
|
+
|
|
23
|
+
Identical to an index.
|
|
24
|
+
"""
|
|
25
|
+
total: int
|
|
26
|
+
"""Total number of items."""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""SQL utilities, validation, and parameter handling."""
|
|
2
|
+
|
|
3
|
+
from sqlspec.statement import builder, filters, parameters, result, sql
|
|
4
|
+
from sqlspec.statement.filters import StatementFilter
|
|
5
|
+
from sqlspec.statement.result import ArrowResult, SQLResult, StatementResult
|
|
6
|
+
from sqlspec.statement.sql import SQL, SQLConfig, Statement
|
|
7
|
+
|
|
8
|
+
__all__ = (
|
|
9
|
+
"SQL",
|
|
10
|
+
"ArrowResult",
|
|
11
|
+
"SQLConfig",
|
|
12
|
+
"SQLResult",
|
|
13
|
+
"Statement",
|
|
14
|
+
"StatementFilter",
|
|
15
|
+
"StatementResult",
|
|
16
|
+
"builder",
|
|
17
|
+
"filters",
|
|
18
|
+
"parameters",
|
|
19
|
+
"result",
|
|
20
|
+
"sql",
|
|
21
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""SQL query builders for safe SQL construction.
|
|
2
|
+
|
|
3
|
+
This package provides fluent interfaces for building SQL queries with automatic
|
|
4
|
+
parameter binding and validation.
|
|
5
|
+
|
|
6
|
+
# SelectBuilder is now generic and supports as_schema for type-safe schema integration.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from sqlspec.exceptions import SQLBuilderError
|
|
10
|
+
from sqlspec.statement.builder.base import QueryBuilder, SafeQuery
|
|
11
|
+
from sqlspec.statement.builder.ddl import (
|
|
12
|
+
AlterTableBuilder,
|
|
13
|
+
CreateIndexBuilder,
|
|
14
|
+
CreateMaterializedViewBuilder,
|
|
15
|
+
CreateSchemaBuilder,
|
|
16
|
+
CreateTableAsSelectBuilder,
|
|
17
|
+
CreateViewBuilder,
|
|
18
|
+
DDLBuilder,
|
|
19
|
+
DropIndexBuilder,
|
|
20
|
+
DropSchemaBuilder,
|
|
21
|
+
DropTableBuilder,
|
|
22
|
+
DropViewBuilder,
|
|
23
|
+
TruncateTableBuilder,
|
|
24
|
+
)
|
|
25
|
+
from sqlspec.statement.builder.delete import DeleteBuilder
|
|
26
|
+
from sqlspec.statement.builder.insert import InsertBuilder
|
|
27
|
+
from sqlspec.statement.builder.merge import MergeBuilder
|
|
28
|
+
from sqlspec.statement.builder.mixins import WhereClauseMixin
|
|
29
|
+
from sqlspec.statement.builder.select import SelectBuilder
|
|
30
|
+
from sqlspec.statement.builder.update import UpdateBuilder
|
|
31
|
+
|
|
32
|
+
__all__ = (
|
|
33
|
+
"AlterTableBuilder",
|
|
34
|
+
"CreateIndexBuilder",
|
|
35
|
+
"CreateMaterializedViewBuilder",
|
|
36
|
+
"CreateSchemaBuilder",
|
|
37
|
+
"CreateTableAsSelectBuilder",
|
|
38
|
+
"CreateViewBuilder",
|
|
39
|
+
"DDLBuilder",
|
|
40
|
+
"DeleteBuilder",
|
|
41
|
+
"DropIndexBuilder",
|
|
42
|
+
"DropSchemaBuilder",
|
|
43
|
+
"DropTableBuilder",
|
|
44
|
+
"DropViewBuilder",
|
|
45
|
+
"InsertBuilder",
|
|
46
|
+
"MergeBuilder",
|
|
47
|
+
"QueryBuilder",
|
|
48
|
+
"SQLBuilderError",
|
|
49
|
+
"SafeQuery",
|
|
50
|
+
"SelectBuilder",
|
|
51
|
+
"TruncateTableBuilder",
|
|
52
|
+
"UpdateBuilder",
|
|
53
|
+
"WhereClauseMixin",
|
|
54
|
+
)
|