sqlspec 0.11.1__py3-none-any.whl → 0.12.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.

Potentially problematic release.


This version of sqlspec might be problematic. Click here for more details.

Files changed (155) hide show
  1. sqlspec/__init__.py +16 -3
  2. sqlspec/_serialization.py +3 -10
  3. sqlspec/_sql.py +1147 -0
  4. sqlspec/_typing.py +343 -41
  5. sqlspec/adapters/adbc/__init__.py +2 -6
  6. sqlspec/adapters/adbc/config.py +474 -149
  7. sqlspec/adapters/adbc/driver.py +330 -621
  8. sqlspec/adapters/aiosqlite/__init__.py +2 -6
  9. sqlspec/adapters/aiosqlite/config.py +143 -57
  10. sqlspec/adapters/aiosqlite/driver.py +269 -431
  11. sqlspec/adapters/asyncmy/__init__.py +3 -8
  12. sqlspec/adapters/asyncmy/config.py +247 -202
  13. sqlspec/adapters/asyncmy/driver.py +218 -436
  14. sqlspec/adapters/asyncpg/__init__.py +4 -7
  15. sqlspec/adapters/asyncpg/config.py +329 -176
  16. sqlspec/adapters/asyncpg/driver.py +417 -487
  17. sqlspec/adapters/bigquery/__init__.py +2 -2
  18. sqlspec/adapters/bigquery/config.py +407 -0
  19. sqlspec/adapters/bigquery/driver.py +600 -553
  20. sqlspec/adapters/duckdb/__init__.py +4 -1
  21. sqlspec/adapters/duckdb/config.py +432 -321
  22. sqlspec/adapters/duckdb/driver.py +392 -406
  23. sqlspec/adapters/oracledb/__init__.py +3 -8
  24. sqlspec/adapters/oracledb/config.py +625 -0
  25. sqlspec/adapters/oracledb/driver.py +548 -921
  26. sqlspec/adapters/psqlpy/__init__.py +4 -7
  27. sqlspec/adapters/psqlpy/config.py +372 -203
  28. sqlspec/adapters/psqlpy/driver.py +197 -533
  29. sqlspec/adapters/psycopg/__init__.py +3 -8
  30. sqlspec/adapters/psycopg/config.py +725 -0
  31. sqlspec/adapters/psycopg/driver.py +734 -694
  32. sqlspec/adapters/sqlite/__init__.py +2 -6
  33. sqlspec/adapters/sqlite/config.py +146 -81
  34. sqlspec/adapters/sqlite/driver.py +242 -405
  35. sqlspec/base.py +220 -784
  36. sqlspec/config.py +354 -0
  37. sqlspec/driver/__init__.py +22 -0
  38. sqlspec/driver/_async.py +252 -0
  39. sqlspec/driver/_common.py +338 -0
  40. sqlspec/driver/_sync.py +261 -0
  41. sqlspec/driver/mixins/__init__.py +17 -0
  42. sqlspec/driver/mixins/_pipeline.py +523 -0
  43. sqlspec/driver/mixins/_result_utils.py +122 -0
  44. sqlspec/driver/mixins/_sql_translator.py +35 -0
  45. sqlspec/driver/mixins/_storage.py +993 -0
  46. sqlspec/driver/mixins/_type_coercion.py +131 -0
  47. sqlspec/exceptions.py +299 -7
  48. sqlspec/extensions/aiosql/__init__.py +10 -0
  49. sqlspec/extensions/aiosql/adapter.py +474 -0
  50. sqlspec/extensions/litestar/__init__.py +1 -6
  51. sqlspec/extensions/litestar/_utils.py +1 -5
  52. sqlspec/extensions/litestar/config.py +5 -6
  53. sqlspec/extensions/litestar/handlers.py +13 -12
  54. sqlspec/extensions/litestar/plugin.py +22 -24
  55. sqlspec/extensions/litestar/providers.py +37 -55
  56. sqlspec/loader.py +528 -0
  57. sqlspec/service/__init__.py +3 -0
  58. sqlspec/service/base.py +24 -0
  59. sqlspec/service/pagination.py +26 -0
  60. sqlspec/statement/__init__.py +21 -0
  61. sqlspec/statement/builder/__init__.py +54 -0
  62. sqlspec/statement/builder/_ddl_utils.py +119 -0
  63. sqlspec/statement/builder/_parsing_utils.py +135 -0
  64. sqlspec/statement/builder/base.py +328 -0
  65. sqlspec/statement/builder/ddl.py +1379 -0
  66. sqlspec/statement/builder/delete.py +80 -0
  67. sqlspec/statement/builder/insert.py +274 -0
  68. sqlspec/statement/builder/merge.py +95 -0
  69. sqlspec/statement/builder/mixins/__init__.py +65 -0
  70. sqlspec/statement/builder/mixins/_aggregate_functions.py +151 -0
  71. sqlspec/statement/builder/mixins/_case_builder.py +91 -0
  72. sqlspec/statement/builder/mixins/_common_table_expr.py +91 -0
  73. sqlspec/statement/builder/mixins/_delete_from.py +34 -0
  74. sqlspec/statement/builder/mixins/_from.py +61 -0
  75. sqlspec/statement/builder/mixins/_group_by.py +119 -0
  76. sqlspec/statement/builder/mixins/_having.py +35 -0
  77. sqlspec/statement/builder/mixins/_insert_from_select.py +48 -0
  78. sqlspec/statement/builder/mixins/_insert_into.py +36 -0
  79. sqlspec/statement/builder/mixins/_insert_values.py +69 -0
  80. sqlspec/statement/builder/mixins/_join.py +110 -0
  81. sqlspec/statement/builder/mixins/_limit_offset.py +53 -0
  82. sqlspec/statement/builder/mixins/_merge_clauses.py +405 -0
  83. sqlspec/statement/builder/mixins/_order_by.py +46 -0
  84. sqlspec/statement/builder/mixins/_pivot.py +82 -0
  85. sqlspec/statement/builder/mixins/_returning.py +37 -0
  86. sqlspec/statement/builder/mixins/_select_columns.py +60 -0
  87. sqlspec/statement/builder/mixins/_set_ops.py +122 -0
  88. sqlspec/statement/builder/mixins/_unpivot.py +80 -0
  89. sqlspec/statement/builder/mixins/_update_from.py +54 -0
  90. sqlspec/statement/builder/mixins/_update_set.py +91 -0
  91. sqlspec/statement/builder/mixins/_update_table.py +29 -0
  92. sqlspec/statement/builder/mixins/_where.py +374 -0
  93. sqlspec/statement/builder/mixins/_window_functions.py +86 -0
  94. sqlspec/statement/builder/protocols.py +20 -0
  95. sqlspec/statement/builder/select.py +206 -0
  96. sqlspec/statement/builder/update.py +178 -0
  97. sqlspec/statement/filters.py +571 -0
  98. sqlspec/statement/parameters.py +736 -0
  99. sqlspec/statement/pipelines/__init__.py +67 -0
  100. sqlspec/statement/pipelines/analyzers/__init__.py +9 -0
  101. sqlspec/statement/pipelines/analyzers/_analyzer.py +649 -0
  102. sqlspec/statement/pipelines/base.py +315 -0
  103. sqlspec/statement/pipelines/context.py +119 -0
  104. sqlspec/statement/pipelines/result_types.py +41 -0
  105. sqlspec/statement/pipelines/transformers/__init__.py +8 -0
  106. sqlspec/statement/pipelines/transformers/_expression_simplifier.py +256 -0
  107. sqlspec/statement/pipelines/transformers/_literal_parameterizer.py +623 -0
  108. sqlspec/statement/pipelines/transformers/_remove_comments.py +66 -0
  109. sqlspec/statement/pipelines/transformers/_remove_hints.py +81 -0
  110. sqlspec/statement/pipelines/validators/__init__.py +23 -0
  111. sqlspec/statement/pipelines/validators/_dml_safety.py +275 -0
  112. sqlspec/statement/pipelines/validators/_parameter_style.py +297 -0
  113. sqlspec/statement/pipelines/validators/_performance.py +703 -0
  114. sqlspec/statement/pipelines/validators/_security.py +990 -0
  115. sqlspec/statement/pipelines/validators/base.py +67 -0
  116. sqlspec/statement/result.py +527 -0
  117. sqlspec/statement/splitter.py +701 -0
  118. sqlspec/statement/sql.py +1198 -0
  119. sqlspec/storage/__init__.py +15 -0
  120. sqlspec/storage/backends/__init__.py +0 -0
  121. sqlspec/storage/backends/base.py +166 -0
  122. sqlspec/storage/backends/fsspec.py +315 -0
  123. sqlspec/storage/backends/obstore.py +464 -0
  124. sqlspec/storage/protocol.py +170 -0
  125. sqlspec/storage/registry.py +315 -0
  126. sqlspec/typing.py +157 -36
  127. sqlspec/utils/correlation.py +155 -0
  128. sqlspec/utils/deprecation.py +3 -6
  129. sqlspec/utils/fixtures.py +6 -11
  130. sqlspec/utils/logging.py +135 -0
  131. sqlspec/utils/module_loader.py +45 -43
  132. sqlspec/utils/serializers.py +4 -0
  133. sqlspec/utils/singleton.py +6 -8
  134. sqlspec/utils/sync_tools.py +15 -27
  135. sqlspec/utils/text.py +58 -26
  136. {sqlspec-0.11.1.dist-info → sqlspec-0.12.1.dist-info}/METADATA +97 -26
  137. sqlspec-0.12.1.dist-info/RECORD +145 -0
  138. sqlspec/adapters/bigquery/config/__init__.py +0 -3
  139. sqlspec/adapters/bigquery/config/_common.py +0 -40
  140. sqlspec/adapters/bigquery/config/_sync.py +0 -87
  141. sqlspec/adapters/oracledb/config/__init__.py +0 -9
  142. sqlspec/adapters/oracledb/config/_asyncio.py +0 -186
  143. sqlspec/adapters/oracledb/config/_common.py +0 -131
  144. sqlspec/adapters/oracledb/config/_sync.py +0 -186
  145. sqlspec/adapters/psycopg/config/__init__.py +0 -19
  146. sqlspec/adapters/psycopg/config/_async.py +0 -169
  147. sqlspec/adapters/psycopg/config/_common.py +0 -56
  148. sqlspec/adapters/psycopg/config/_sync.py +0 -168
  149. sqlspec/filters.py +0 -331
  150. sqlspec/mixins.py +0 -305
  151. sqlspec/statement.py +0 -378
  152. sqlspec-0.11.1.dist-info/RECORD +0 -69
  153. {sqlspec-0.11.1.dist-info → sqlspec-0.12.1.dist-info}/WHEEL +0 -0
  154. {sqlspec-0.11.1.dist-info → sqlspec-0.12.1.dist-info}/licenses/LICENSE +0 -0
  155. {sqlspec-0.11.1.dist-info → sqlspec-0.12.1.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]
@@ -0,0 +1,3 @@
1
+ from sqlspec.service.base import SqlspecService
2
+
3
+ __all__ = ("SqlspecService",)
@@ -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
+ )