Skip to content

Commit aa7a472

Browse files
authored
fix: bound loader missing statement errors (#440)
## Summary - Add `SQLStatementNotFoundError`, a `SQLFileNotFoundError` subclass for missing named SQL statements. - Bound missing-statement messages to the requested name and loaded statement count instead of dumping available statement names. - Update loader exception docs, changelog, and regression coverage for `get_sql()` and `get_query_text()`. Fixes #437.
1 parent b756cd6 commit aa7a472

8 files changed

Lines changed: 116 additions & 10 deletions

File tree

docs/changelog.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ v0.46.2 - Framework Filter ``orderBy`` Aliases (Unreleased)
1515

1616
**Fixed:**
1717

18+
* Missing named SQL statements now raise ``SQLStatementNotFoundError``, a
19+
``SQLFileNotFoundError`` subclass with structured lookup context and bounded
20+
messages that report the loaded statement count instead of dumping available
21+
statement names. (`#437
22+
<https://github.com/litestar-org/sqlspec/issues/437>`_)
23+
1824
* Litestar ``create_filter_dependencies()`` and FastAPI ``provide_filters()``
1925
now accept camel-case API-facing sort aliases for configured ``orderBy``
2026
fields by default. Endpoints can accept values such as

docs/reference/exceptions.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,10 @@ SQL Files
165165
:members:
166166
:show-inheritance:
167167

168+
.. autoclass:: SQLStatementNotFoundError
169+
:members:
170+
:show-inheritance:
171+
168172
.. autoclass:: SQLFileParseError
169173
:members:
170174
:show-inheritance:
@@ -241,6 +245,7 @@ Inheritance Tree
241245
| +-- FileNotFoundInStorageError
242246
+-- StorageCapabilityError
243247
+-- SQLFileNotFoundError
248+
| +-- SQLStatementNotFoundError
244249
+-- SQLFileParseError
245250
+-- MigrationError
246251
+-- InvalidVersionFormatError

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ maintainers = [{ name = "Litestar Developers", email = "hello@litestar.dev" }]
2424
name = "sqlspec"
2525
readme = "README.md"
2626
requires-python = ">=3.10, <4.0"
27-
version = "0.46.2"
27+
version = "0.46.3"
2828

2929
[project.urls]
3030
Discord = "https://discord.gg/litestar"
@@ -264,7 +264,7 @@ opt_level = "3" # Maximum optimization (0-3)
264264
allow_dirty = true
265265
commit = false
266266
commit_args = "--no-verify"
267-
current_version = "0.46.2"
267+
current_version = "0.46.3"
268268
ignore_missing_files = false
269269
ignore_missing_version = false
270270
message = "chore(release): bump to v{new_version}"

sqlspec/exceptions.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"SQLFileParseError",
3636
"SQLParsingError",
3737
"SQLSpecError",
38+
"SQLStatementNotFoundError",
3839
"SerializationConflictError",
3940
"SerializationError",
4041
"SquashValidationError",
@@ -323,6 +324,31 @@ def __init__(self, name: str, path: "str | None" = None) -> None:
323324
self.path = path
324325

325326

327+
class SQLStatementNotFoundError(SQLFileNotFoundError):
328+
"""Raised when a named SQL statement is not loaded."""
329+
330+
def __init__(self, name: str, normalized_name: str, query_count: int) -> None:
331+
"""Initialize the error.
332+
333+
Args:
334+
name: Name requested by the caller.
335+
normalized_name: Normalized statement name used for lookup.
336+
query_count: Number of SQL statements loaded in the registry.
337+
"""
338+
if query_count == 0:
339+
message = f"SQL statement '{name}' not found. No SQL statements are loaded."
340+
else:
341+
message = (
342+
f"SQL statement '{name}' not found. {query_count} SQL statements are loaded. "
343+
"Use list_queries() to inspect available statement names."
344+
)
345+
SQLSpecError.__init__(self, message)
346+
self.name = name
347+
self.path = None
348+
self.normalized_name = normalized_name
349+
self.query_count = query_count
350+
351+
326352
class SQLFileParseError(SQLSpecError):
327353
"""Raised when a SQL file cannot be parsed."""
328354

sqlspec/loader.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
FileNotFoundInStorageError,
2222
SQLFileNotFoundError,
2323
SQLFileParseError,
24+
SQLStatementNotFoundError,
2425
StorageOperationFailedError,
2526
)
2627
from sqlspec.storage.registry import storage_registry as default_storage_registry
@@ -206,6 +207,18 @@ def _raise_file_not_found(self, path: str) -> None:
206207
"""
207208
raise SQLFileNotFoundError(path)
208209

210+
def _raise_statement_not_found(self, name: str, normalized_name: str) -> None:
211+
"""Raise SQLStatementNotFoundError for nonexistent statements.
212+
213+
Args:
214+
name: Name requested by the caller.
215+
normalized_name: Normalized statement name used for lookup.
216+
217+
Raises:
218+
SQLStatementNotFoundError: Always raised.
219+
"""
220+
raise SQLStatementNotFoundError(name=name, normalized_name=normalized_name, query_count=len(self._queries))
221+
209222
def _generate_file_cache_key(self, path: str | Path) -> str:
210223
"""Generate cache key for a file path.
211224
@@ -676,11 +689,11 @@ def get_query_text(self, name: str) -> str:
676689
Raw SQL text.
677690
678691
Raises:
679-
SQLFileNotFoundError: If query not found.
692+
SQLStatementNotFoundError: If query not found.
680693
"""
681694
safe_name = _normalize_query_name(name)
682695
if safe_name not in self._queries:
683-
raise SQLFileNotFoundError(name)
696+
self._raise_statement_not_found(name, safe_name)
684697
return self._queries[safe_name].sql
685698

686699
def get_sql(self, name: str) -> "SQL":
@@ -694,13 +707,12 @@ def get_sql(self, name: str) -> "SQL":
694707
SQL object ready for execution.
695708
696709
Raises:
697-
SQLFileNotFoundError: If statement name not found.
710+
SQLStatementNotFoundError: If statement name not found.
698711
"""
699712
safe_name = _normalize_query_name(name)
700713

701714
if safe_name not in self._queries:
702-
available = ", ".join(sorted(self._queries.keys())) if self._queries else "none"
703-
raise SQLFileNotFoundError(name, path=f"Statement '{name}' not found. Available statements: {available}")
715+
self._raise_statement_not_found(name, safe_name)
704716

705717
parsed_statement = self._queries[safe_name]
706718
sqlglot_dialect = None

tests/unit/exceptions/test_exceptions.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
NotFoundError,
88
NotNullViolationError,
99
OperationalError,
10+
SQLFileNotFoundError,
1011
SQLSpecError,
12+
SQLStatementNotFoundError,
1113
StackExecutionError,
1214
TransactionError,
1315
UniqueViolationError,
@@ -25,6 +27,7 @@ def test_new_exception_hierarchy() -> None:
2527
assert issubclass(TransactionError, SQLSpecError)
2628
assert issubclass(DataError, SQLSpecError)
2729
assert issubclass(OperationalError, SQLSpecError)
30+
assert issubclass(SQLStatementNotFoundError, SQLFileNotFoundError)
2831

2932

3033
def test_exception_instantiation() -> None:
@@ -149,6 +152,21 @@ def test_subclass_inherits_args_behavior() -> None:
149152
assert str(exc) == "item not found"
150153

151154

155+
def test_sql_statement_not_found_exposes_lookup_context() -> None:
156+
"""Test SQLStatementNotFoundError exposes bounded lookup context."""
157+
exc = SQLStatementNotFoundError(name="missing-query", normalized_name="missing_query", query_count=12)
158+
159+
assert isinstance(exc, SQLFileNotFoundError)
160+
assert exc.name == "missing-query"
161+
assert exc.path is None
162+
assert exc.normalized_name == "missing_query"
163+
assert exc.query_count == 12
164+
assert str(exc) == (
165+
"SQL statement 'missing-query' not found. 12 SQL statements are loaded. "
166+
"Use list_queries() to inspect available statement names."
167+
)
168+
169+
152170
def test_stack_execution_error_preserves_args() -> None:
153171
"""Test StackExecutionError correctly populates args."""
154172
original = ValueError("boom")

tests/unit/loader/test_sql_file_loader.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@
1010
"""
1111

1212
import time
13+
from collections.abc import Callable
1314
from pathlib import Path
1415
from typing import Any
1516

1617
import pytest
1718

1819
from sqlspec.core import SQL
19-
from sqlspec.exceptions import SQLFileNotFoundError, SQLFileParseError
20+
from sqlspec.exceptions import SQLFileNotFoundError, SQLFileParseError, SQLStatementNotFoundError
2021
from sqlspec.loader import (
2122
NamedStatement,
2223
SQLFile,
@@ -510,6 +511,44 @@ def test_get_query_text_not_found() -> None:
510511
loader.get_query_text("nonexistent")
511512

512513

514+
@pytest.mark.parametrize("accessor", [SQLFileLoader.get_sql, SQLFileLoader.get_query_text])
515+
def test_missing_statement_empty_loader_message(accessor: Callable[[SQLFileLoader, str], object]) -> None:
516+
"""Missing statements in an empty loader should return a bounded message."""
517+
loader = SQLFileLoader()
518+
519+
with pytest.raises(SQLStatementNotFoundError) as exc_info:
520+
accessor(loader, "missing-secret")
521+
522+
exc = exc_info.value
523+
assert str(exc) == "SQL statement 'missing-secret' not found. No SQL statements are loaded."
524+
assert exc.name == "missing-secret"
525+
assert exc.normalized_name == "missing_secret"
526+
assert exc.query_count == 0
527+
528+
529+
@pytest.mark.parametrize("accessor", [SQLFileLoader.get_sql, SQLFileLoader.get_query_text])
530+
def test_missing_statement_loaded_registry_message_does_not_leak_names(
531+
accessor: Callable[[SQLFileLoader, str], object],
532+
) -> None:
533+
"""Missing statements should not dump the loaded statement registry."""
534+
loader = SQLFileLoader()
535+
loaded_query_names = [f"tenant_{index}_private_query" for index in range(20)]
536+
for query_name in loaded_query_names:
537+
loader.add_named_sql(query_name, "SELECT 1")
538+
539+
with pytest.raises(SQLStatementNotFoundError) as exc_info:
540+
accessor(loader, "missing-secret")
541+
542+
message = str(exc_info.value)
543+
assert message == (
544+
"SQL statement 'missing-secret' not found. 20 SQL statements are loaded. "
545+
"Use list_queries() to inspect available statement names."
546+
)
547+
assert "Available statements" not in message
548+
for query_name in loaded_query_names:
549+
assert query_name not in message
550+
551+
513552
def test_clear_cache() -> None:
514553
"""Test clearing loader cache."""
515554
loader = SQLFileLoader()
@@ -582,7 +621,7 @@ def test_get_sql_not_found() -> None:
582621
with pytest.raises(SQLFileNotFoundError) as exc_info:
583622
loader.get_sql("nonexistent")
584623

585-
assert "Statement 'nonexistent' not found" in str(exc_info.value)
624+
assert "SQL statement 'nonexistent' not found" in str(exc_info.value)
586625

587626

588627
def test_get_sql_name_normalization() -> None:

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)