Skip to content

feat: SQL-file parameter metadata annotations (-- param:)#497

Merged
cofin merged 12 commits into
mainfrom
feat/param-types-sql
Jun 9, 2026
Merged

feat: SQL-file parameter metadata annotations (-- param:)#497
cofin merged 12 commits into
mainfrom
feat/param-types-sql

Conversation

@cofin

@cofin cofin commented Jun 8, 2026

Copy link
Copy Markdown
Member

Summary

Implements #491 by adding -- param: metadata support for SQL files. SQLSpec now parses declared parameters from loaded SQL, exposes those declarations for introspection, carries them on SQL objects, and validates declared parameter contracts at load time and execute time.

-- name: get_offers_by_status
-- dialect: oracle
-- param: status_cd str?        Optional status filter
-- 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 is null or status_cd = :status_cd)
  and offer_id in (:offer_ids)
fetch first :limit rows only
params = spec.get_query_parameters("get_offers_by_status")
query = spec.get_sql("get_offers_by_status")
assert query.declared_parameters == params

What changed

  • Added ParameterDeclaration metadata for SQL-file parameters: name, declared type string, description, and required/optional status.
  • Added -- param: parsing in SQL file headers alongside -- name: and -- dialect:.
  • Added introspection via SQLFileLoader.get_query_parameters(), SQLSpec.get_query_parameters(), and SQL.declared_parameters.
  • Added load-time validation so declared names must match named placeholders, and positional declarations must match positional placeholder count.
  • Added execute-time validation in the shared driver preparation path so adapters do not need per-driver validation logic.
  • Preserved declared metadata when SQL objects are rebuilt, copied, deep-copied, pickled, or prepared with filters.
  • Fixed declared-parameter validation for SQL-object batch execution paths so declarations are not dropped before execute_many() dispatch.
  • Added optional named parameter support with type? or trailing (optional) in the description.
  • Bound missing optional named parameters as None, so SQL receives NULL. Queries still need to express nullable behavior explicitly, for example (:status_cd is null or status_cd = :status_cd).
  • Kept required parameters strict: missing required declarations raise before dispatch.
  • Kept None valid for SQL NULL and skipped type checks for None values.

Declared type support

Declared types resolve through the existing core declared-parameter registry. Built-in declared types now include:

  • str, int, float, bool, bytes
  • date, datetime, time, Decimal
  • uuid, uuid.UUID
  • dict, dict[str, Any]
  • json, jsonb
  • list, list[int], list[str], list[float], list[bool], tuple

json and jsonb validate through SQLSpec's existing JSON serializer instead of a separate recursive JSON implementation. Unknown type strings remain documentation-only and skip runtime type validation. Custom matchers can still be registered with register_param_type(...).

Batch behavior

For execute_many() with named mappings:

  • Missing optional named declarations are filled with None on each row.
  • Required declarations are validated.
  • Type validation keeps the existing first-row batch validation cost model.

Positional placeholders remain arity-based and cannot be omitted by name.

Docs and examples

Updated SQL file loader docs and examples to cover:

  • Declared parameter introspection.
  • Required vs optional declarations.
  • SQL NULL semantics for omitted optional named parameters.
  • UUID, dict, JSON, and custom declared type support.

Out of scope

Typed query artifact generation is not part of this PR. The follow-up brainstorming issue is #509 and covers possible generated runtime accessors, .pyi stubs, query-shape/result models, TypedDict, msgspec, Pydantic, import generation, and data-dictionary-backed result inference.

Validation

Local verification performed on this branch:

  • uv run pytest tests/unit -q
  • make lint
  • make type-check
  • make docs
  • uv run pytest tests/unit/utils/test_mypyc_inventory.py tests/unit/utils/test_mypyc_smoke.py -q
  • env HATCH_BUILD_HOOKS_ENABLE=1 uv build

The mypyc-enabled build produced a compiled wheel containing compiled extensions for the touched compiled modules: sqlspec/loader, sqlspec/core/statement, sqlspec/core/parameters/_declared, and sqlspec/driver/_common.

make test was also run. It failed only in Oracle Docker fixture setup with a RootlessKit port bind conflict (address already in use); the run reported 9374 passed, 367 skipped, 2 xfailed, 308 Oracle setup errors.

Closes #491

@codecov-commenter

codecov-commenter commented Jun 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.44970% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.30%. Comparing base (44fadb0) to head (0daf628).

Files with missing lines Patch % Lines
sqlspec/core/parameters/_declared.py 95.55% 1 Missing and 1 partial ⚠️
sqlspec/driver/_common.py 94.87% 0 Missing and 2 partials ⚠️
sqlspec/base.py 75.00% 1 Missing ⚠️
sqlspec/loader.py 98.52% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #497      +/-   ##
==========================================
- Coverage   74.46%   74.30%   -0.16%     
==========================================
  Files         438      439       +1     
  Lines       53134    53273     +139     
  Branches     8424     8451      +27     
==========================================
+ Hits        39566    39585      +19     
- Misses      11032    11061      +29     
- Partials     2536     2627      +91     
Flag Coverage Δ
integration 58.24% <46.74%> (+0.06%) ⬆️
py3.10 71.08% <96.44%> (+0.06%) ⬆️
py3.11 71.10% <96.44%> (+0.07%) ⬆️
py3.12 71.09% <96.44%> (+0.07%) ⬆️
py3.13 71.09% <96.44%> (+0.06%) ⬆️
py3.14 73.48% <96.44%> (+0.06%) ⬆️
unit 61.47% <94.67%> (+0.08%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
sqlspec/__init__.py 100.00% <ø> (ø)
sqlspec/core/__init__.py 100.00% <ø> (ø)
sqlspec/core/parameters/__init__.py 100.00% <100.00%> (ø)
sqlspec/core/statement.py 84.91% <100.00%> (+0.14%) ⬆️
sqlspec/base.py 72.52% <75.00%> (-0.15%) ⬇️
sqlspec/loader.py 93.45% <98.52%> (+0.92%) ⬆️
sqlspec/core/parameters/_declared.py 95.55% <95.55%> (ø)
sqlspec/driver/_common.py 73.64% <94.87%> (+0.72%) ⬆️

... and 19 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@cofin cofin force-pushed the feat/param-types-sql branch from fb847b8 to 9a865cf Compare June 8, 2026 19:12
@cofin cofin marked this pull request as ready for review June 8, 2026 19:44
cofin added 9 commits June 8, 2026 14:53
Add a _declared_parameters slot to SQL and thread it through all seven
construction/reset paths (__init__, _init_from_sql_object, copy full + fast
path via _create_empty_copy, reset, _create_cached_direct, plus the
declared_parameters property). Proven mypyc-safe and pool-leak-free against
the compiled module.

Spike smgc.7 for sqlspec-smgc (gh-491).
Introduce core/parameters/_declared.py with the mypyc-safe ParameterDeclaration
value object and an extensible type registry (register_param_type /
resolve_param_type) that resolves declared type strings to Python types via a
fixed allowlist plus user registration, never evaluating the string. Exported
through sqlspec.core and the top-level package.

Ch1 sqlspec-smgc.1 (gh-491).
Scan each named statement's leading comment block for -- param: <name>
<type>[?] [description] directives (alongside -- dialect:), storing them on
NamedStatement.parameters. Malformed directives warn and skip by default, or
raise when strict_parameter_annotations is set. Surface declarations via
SQLFileLoader.get_query_parameters / SQLSpec.get_query_parameters and accept
them in add_named_sql. Declarations ride the SQLFileCacheEntry, surviving the
file-cache roundtrip.

Ch2 sqlspec-smgc.2 (gh-491).
…iver

Tighten the SQL._declared_parameters slot to tuple[ParameterDeclaration, ...],
add a declared_parameters constructor keyword, and populate it from get_sql().
Thread the slot through every self-derivation (as_script, add_named_parameter,
_create_modified_copy_with_expression) and through the driver's _prepare_from_sql
rebuild so declarations survive prepare_statement + filter application across all
adapters. Fold the spike proof into a permanent carriage test.

Ch3 sqlspec-smgc.3 (gh-491).
When a query declares params, cross-check them against the SQL's actual
placeholders at load time: declared names must be a subset of the named
placeholders (drift), or for positional binding the declared count must equal
the placeholder count. Raises SQLFileParseError early; declaration-driven, so
queries without -- param: directives are unaffected.

Ch4 sqlspec-smgc.4 (gh-491).
Auto-generated parameter names (builder where/in/between helpers) now use the
param_ prefix instead of parameter_, aligning with the param_{ordinal} fallback
already used in the converter/processor and with the new -- param: file
annotations.

gh-491.
Declared parameters are now strictly binary: a declared param is always
validated (presence + type); undeclared params are untouched. Removes the
?-suffix grammar and ParameterDeclaration.required, which had an empty
domain for loaded SQL files (every declared placeholder is static and
always bound, so required=False could never legitimately fire).
Single shared hook in prepare_statement validates declared params on the
original user params before driver style conversion, covering all adapters
and execution methods. Declared params must be present (named binding);
present non-None values whose declared type resolves via the registry must
satisfy isinstance. None is allowed (SQL NULL); unresolved types are
documentation-only; extra params (filter-injected limit/offset) are never
rejected. execute_many checks the first row only; positional binding is
skipped (arity validated at load). No-op unless declarations are present.
Adds a Declared Parameters section to the SQL file loader usage guide (grammar,
type vocabulary, register_param_type, load/execute-time validation timing,
strict_parameter_annotations, introspection), a runnable declared_params.py
example, and ParameterDeclaration/register_param_type/resolve_param_type
autodoc in the loader reference. Documents the binary declaration model (no
optional marker). Docs build clean under -W.
@cofin cofin force-pushed the feat/param-types-sql branch from 2b6ddac to 65294aa Compare June 8, 2026 19:53
cofin added 3 commits June 8, 2026 20:03
refactor(error-handling): improve error messages for missing and type mismatch parameters
feat(loader): add strict parameter annotations for SQL file loading
chore(dependencies): update package versions in lock file
@cofin cofin merged commit 0dc2787 into main Jun 9, 2026
29 checks passed
@cofin cofin deleted the feat/param-types-sql branch June 9, 2026 02:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Support metadata annotations (e.g., -- param:) in SQL files

2 participants