Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions docs/examples/sql_files/declared_params.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from pathlib import Path

__all__ = ("test_declared_params",)


def test_declared_params(tmp_path: "Path") -> None:
# start-example
from sqlspec import SQLSpec
from sqlspec.adapters.sqlite import SqliteConfig
from sqlspec.exceptions import SQLSpecError

sql_file = tmp_path / "teams.sql"
sql_file.write_text(
"-- name: get_team_by_name\n"
"-- param: name str The team name to look up\n"
"select id, name from teams where name = :name\n"
"\n"
"-- name: list_teams\n"
"-- param: name str? Optional team name filter\n"
"select id, name from teams where (:name is null or name = :name) order by id\n"
)

spec = SQLSpec()
config = spec.add_config(SqliteConfig(connection_config={"database": ":memory:"}))
spec.load_sql_files(sql_file)

# Introspect declared parameters without executing.
declarations = spec.get_query_parameters("get_team_by_name")
assert declarations[0].name == "name"
assert declarations[0].type_str == "str"
assert declarations[0].description == "The team name to look up"

# The declarations also ride on the SQL object returned by get_sql().
query = spec.get_sql("get_team_by_name")
assert query.declared_parameters == declarations

with spec.provide_session(config) as session:
session.execute("create table teams (id integer primary key, name text)")
session.execute("insert into teams (name) values ('Litestar'), ('SQLSpec')")

# A declared query validates supplied parameters automatically.
row = session.execute(query, {"name": "SQLSpec"}).one()

# Optional named parameters are bound as NULL when omitted.
optional_rows = session.execute(spec.get_sql("list_teams")).all()

# Omitting a declared parameter raises before the query reaches the driver.
try:
session.execute(spec.get_sql("get_team_by_name"), {})
except SQLSpecError as exc:
missing_error = str(exc)
# end-example

assert row["name"] == "SQLSpec"
assert [team["name"] for team in optional_rows] == ["Litestar", "SQLSpec"]
assert "name" in missing_error
15 changes: 15 additions & 0 deletions docs/reference/loader.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,18 @@ NamedStatement
.. autoclass:: NamedStatement
:members:
:show-inheritance:

Declared Parameters
===================

Parameters declared in SQL files via ``-- param:`` directives are exposed as
:class:`ParameterDeclaration` objects. See :ref:`Declared Parameters <declared-parameters>`
for the grammar and validation behavior.

.. autoclass:: sqlspec.ParameterDeclaration
:members:
:show-inheritance:

.. autofunction:: sqlspec.register_param_type

.. autofunction:: sqlspec.resolve_param_type
96 changes: 96 additions & 0 deletions docs/usage/sql_files.rst
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,106 @@ Available where helpers:

Each call returns a new ``SQL`` object (immutable chaining).

.. _declared-parameters:

Declared Parameters
-------------------

Declare a query's parameters inline with ``-- param:`` directives in the header
block. Declared queries become self-documenting, introspectable, and
self-validating -- without SQLSpec becoming an ORM.

.. code-block:: sql

-- name: get_offers_by_status
-- dialect: oracle
-- param: status_cd str The status code to filter by
-- param: offer_ids list[int] List of offer IDs to include
-- param: limit int Maximum number of rows to return

select offer_id, offer_name from offers
where status_cd = :status_cd and offer_id in (:offer_ids)
fetch first :limit rows only

The grammar is ``-- param: <name> <type> [description]``, placed alongside
``-- name:`` and ``-- dialect:`` in the leading comment block. Append ``?`` to
the declared type, or end the description with ``(optional)``, to mark a named
parameter as optional.

.. literalinclude:: /examples/sql_files/declared_params.py
:language: python
:caption: ``declared parameters``
:start-after: # start-example
:end-before: # end-example
:dedent: 4
:no-upgrade:

**Declaration is opt-in.** A query with **no** ``-- param:`` lines behaves
exactly as before -- same code path, zero overhead. Declaring a parameter opts
*that* query into validation:

- Required declarations must be **supplied** when the query executes.
- Missing optional named declarations are bound as ``None``, so SQL receives
``NULL``. The query must still express the intended nullable behavior, for
example ``(:status_cd is null or status_cd = :status_cd)``.
- Positional placeholders still rely on arity and cannot be omitted by name.
- If its declared type resolves to a Python type, the supplied value must match
(``isinstance``). Pass ``None`` for SQL ``NULL`` -- the key is still present and
the type check is skipped.
- Extra parameters are never rejected -- statement filters legitimately inject
``limit``/``offset``, so only *declared* names are checked.

.. code-block:: sql

-- name: list_offers
-- param: status_cd str? Optional status filter
select offer_id, offer_name from offers
where (:status_cd is null or status_cd = :status_cd)

**Type vocabulary.** Declared types resolve through a fixed allowlist --
``str``, ``int``, ``float``, ``bool``, ``bytes``, ``date``, ``datetime``,
``time``, ``Decimal``, ``uuid`` / ``uuid.UUID``, ``dict``, ``dict[str, Any]``,
``json`` / ``jsonb``, and the container forms ``list``, ``list[int]``,
``list[str]``, ``list[float]``, ``list[bool]``, ``tuple``. ``json`` and
``jsonb`` use SQLSpec's existing JSON serializer to validate that values can be
encoded. The raw string is always stored and **never** evaluated. Register
custom mappings with :func:`~sqlspec.register_param_type`:

.. code-block:: python

from decimal import Decimal

from sqlspec import register_param_type

register_param_type("Money", Decimal) # -- param: price Money

Type strings that do not resolve are documentation-only -- their values are not
type-checked.

**Validation timing.**

- *Load time* -- declared names are cross-checked against the actual
``:placeholders`` (name drift), and declared count against placeholder count for
positionally-bound queries. Mismatches raise :exc:`~sqlspec.exceptions.SQLSpecError`.
- *Execute time* -- presence and type are enforced for every declared parameter,
uniformly across every adapter. ``execute_many`` binds missing optional named
values on each row, then checks the first row only.

A **malformed** ``-- param:`` line (a typo or wrong arity) is a soft warning and
the line is skipped, preserving backward compatibility. Pass
``strict_parameter_annotations=True`` to :class:`~sqlspec.loader.SQLFileLoader`
to escalate malformed annotations to an error. (A genuine *validation mismatch*
-- drift, count, missing, or wrong type -- always raises.)

**Introspection.** Read declarations without executing via
``spec.get_query_parameters(name)`` or the ``declared_parameters`` tuple on the
``SQL`` object returned by ``spec.get_sql(name)``.

How Query Names Work
--------------------

- Name queries with ``-- name: query_name`` comments.
- SQLSpec normalizes names to snake_case for Python access.
- Add ``-- dialect: postgres`` on the first line of a block to bind SQL to a dialect.
- Declare parameters with ``-- param: <name> <type>[?] [description]`` (see `Declared Parameters`_).
- Directory structures become namespaces when you load directories (``reports/daily.sql`` -> ``reports.<query>``).
10 changes: 10 additions & 0 deletions sqlspec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,21 @@
CacheConfig,
CacheStats,
ParameterConverter,
ParameterDeclaration,
ParameterProcessor,
ParameterStyle,
ParameterStyleConfig,
ParamTypeMatcher,
ProcessedState,
SQLResult,
StackOperation,
StackResult,
Statement,
StatementConfig,
StatementStack,
matches_param_type,
register_param_type,
resolve_param_type,
)
from sqlspec.core import filters as filters
from sqlspec.driver import AsyncDriverAdapterBase, ExecutionResult, SyncDriverAdapterBase
Expand Down Expand Up @@ -112,7 +117,9 @@
"Merge",
"ObservabilityConfig",
"ObservabilityRuntime",
"ParamTypeMatcher",
"ParameterConverter",
"ParameterDeclaration",
"ParameterProcessor",
"ParameterStyle",
"ParameterStyleConfig",
Expand Down Expand Up @@ -158,7 +165,10 @@
"filters",
"format_statement_event",
"loader",
"matches_param_type",
"migrations",
"register_param_type",
"resolve_param_type",
"sql",
"typing",
"utils",
Expand Down
26 changes: 23 additions & 3 deletions sqlspec/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,11 @@
from sqlspec.utils.type_guards import has_name

if TYPE_CHECKING:
from collections.abc import Sequence
from pathlib import Path
from types import TracebackType

from sqlspec.core import SQL
from sqlspec.core import SQL, ParameterDeclaration
from sqlspec.typing import PoolT


Expand Down Expand Up @@ -512,16 +513,23 @@ def load_sql_files(self, *paths: "str | Path") -> None:
loader.load_sql(*paths)
logger.debug("Loaded SQL files: %s", paths)

def add_named_sql(self, name: str, sql: str, dialect: "str | None" = None) -> None:
def add_named_sql(
self,
name: str,
sql: str,
dialect: "str | None" = None,
parameters: "Sequence[ParameterDeclaration] | None" = None,
) -> None:
"""Add a named SQL query directly.

Args:
name: Name for the SQL query.
sql: Raw SQL content.
dialect: Optional dialect for the SQL statement.
parameters: Optional declared parameter metadata for the query.
"""
loader = self._ensure_loader()
loader.add_named_sql(name, sql, dialect)
loader.add_named_sql(name, sql, dialect, parameters)
logger.debug("Added named SQL: %s", name)

def get_sql(self, name: str) -> "SQL":
Expand All @@ -536,6 +544,18 @@ def get_sql(self, name: str) -> "SQL":
"""
return self._ensure_loader().get_sql(name)

def get_query_parameters(self, name: str) -> "tuple[ParameterDeclaration, ...]":
"""Get declared parameter metadata for a query.

Args:
name: Name of the statement from SQL file comments.
Hyphens in names are converted to underscores.

Returns:
Tuple of declared parameters; empty if the query declares none.
"""
return self._ensure_loader().get_query_parameters(name)

def list_sql_queries(self) -> "list[str]":
"""List all available query names.

Expand Down
10 changes: 10 additions & 0 deletions sqlspec/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,15 @@
PARAMETER_REGEX,
DriverParameterProfile,
ParameterConverter,
ParameterDeclaration,
ParameterInfo,
ParameterProcessingResult,
ParameterProcessor,
ParameterProfile,
ParameterStyle,
ParameterStyleConfig,
ParameterValidator,
ParamTypeMatcher,
TypedParameter,
build_literal_inlining_transform,
build_null_pruning_transform,
Expand All @@ -168,10 +170,13 @@
get_driver_profile,
is_iterable_parameters,
looks_like_execute_many,
matches_param_type,
normalize_parameter_key,
register_driver_profile,
register_param_type,
replace_null_parameters_with_literals,
replace_placeholders_with_literals,
resolve_param_type,
validate_parameter_alignment,
wrap_with_type,
)
Expand Down Expand Up @@ -278,7 +283,9 @@
"OperationProfile",
"OperationType",
"OrderByFilter",
"ParamTypeMatcher",
"ParameterConverter",
"ParameterDeclaration",
"ParameterInfo",
"ParameterProcessingResult",
"ParameterProcessor",
Expand Down Expand Up @@ -362,14 +369,17 @@
"is_iterable_parameters",
"log_cache_stats",
"looks_like_execute_many",
"matches_param_type",
"normalize_parameter_key",
"parse_column_for_condition",
"parse_datetime_rfc3339",
"register_driver_profile",
"register_param_type",
"replace_null_parameters_with_literals",
"replace_placeholders_with_literals",
"reset_pipeline_registry",
"reset_stats_only",
"resolve_param_type",
"safe_modify_with_cte",
"split_sql_script",
"update_cache_config",
Expand Down
12 changes: 12 additions & 0 deletions sqlspec/core/parameters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@
validate_parameter_alignment,
)
from sqlspec.core.parameters._converter import ParameterConverter
from sqlspec.core.parameters._declared import (
ParameterDeclaration,
ParamTypeMatcher,
matches_param_type,
register_param_type,
resolve_param_type,
)
from sqlspec.core.parameters._processor import ParameterProcessor, structural_fingerprint, value_fingerprint
from sqlspec.core.parameters._registry import (
DRIVER_PARAMETER_PROFILES,
Expand Down Expand Up @@ -42,7 +49,9 @@
"EXECUTE_MANY_MIN_ROWS",
"PARAMETER_REGEX",
"DriverParameterProfile",
"ParamTypeMatcher",
"ParameterConverter",
"ParameterDeclaration",
"ParameterInfo",
"ParameterMapping",
"ParameterPayload",
Expand All @@ -61,10 +70,13 @@
"get_driver_profile",
"is_iterable_parameters",
"looks_like_execute_many",
"matches_param_type",
"normalize_parameter_key",
"register_driver_profile",
"register_param_type",
"replace_null_parameters_with_literals",
"replace_placeholders_with_literals",
"resolve_param_type",
"structural_fingerprint",
"validate_parameter_alignment",
"value_fingerprint",
Expand Down
Loading
Loading