Query helpers¶
Code that sends queries through a helper function needs no --sql marker and
no SQL[T] wrapper at the call sites. Mark the helper's query parameter with
Annotated[LiteralString, SQL] and sqlty check checks every string literal
passed to it. The row type comes from the helper's type[T] parameter.
schema.sql:
Marking the helper¶
The query parameter is annotated with the sqlty.SQL class as Annotated
metadata. The parameter annotated type[T] (or TypeForm[T]), where T also
appears in the return type, is the row type:
from typing import Annotated, LiteralString, TypedDict
from sqlty import SQL
def fetch_all[T](query: Annotated[LiteralString, SQL], form: type[T], /, **params: object) -> list[T]: ...
class User(TypedDict):
id: int
name: str
users = fetch_all("SELECT id, name FROM users", User)
# error: [column-type-mismatch] "User.name is str but query column 'name' infers int"
broken = fetch_all("SELECT id, id AS name FROM users", User)
Calls pass the query and the row type by position or by keyword, and a query may be several adjacent string literals. A query that is not a literal (a variable, an f-string) is skipped.
Row types¶
The row type is checked like the one in SQL[T] (see Row types),
including types defined in the same file. A scalar takes one column and
tuple[A, B] one column per item:
from typing import Annotated, LiteralString
from sqlty import SQL
def fetch_all[T](query: Annotated[LiteralString, SQL], form: type[T], /) -> list[T]: ...
names = fetch_all("SELECT name FROM users", str)
# error: [column-type-mismatch] "tuple[int, int] item 1 is int but query column 'name' infers str"
rows = fetch_all("SELECT id, name FROM users", tuple[int, int])
A --sql string passed with a row type is typed by it, so
stale-rewrite doesn't ask to wrap it in
SQL[T], and sqlty check --fix leaves it as it is. Without a row type it is
still reported:
from typing import Annotated, LiteralString
from sqlty import SQL
def fetch_all[T](query: Annotated[LiteralString, SQL], form: type[T] | None = None, /) -> list[T]: ...
names = fetch_all("--sql\nSELECT name FROM users", str)
# error: [stale-rewrite]
rows = fetch_all("--sql\nSELECT name FROM users")
A helper without such a parameter is still parsed, and select-star applies.
Row types that are not known (a class from another module, dict) are not
checked.
Import styles¶
The marker and Annotated are recognized through any import form, whatever
import-style says: from sqlty import SQL as Q, import sqlty as sy
(sy.SQL), from typing import Annotated as A, import typing as t and
typing_extensions.
import typing as t
import sqlty as sy
def fetch_all[T](query: t.Annotated[t.LiteralString, sy.SQL], form: type[T], /) -> list[T]: ...
# error: [column-type-mismatch]
ids = fetch_all("SELECT name FROM users", int)
Methods¶
Methods are marked the same way. self and cls are not counted when the
arguments are bound. A call is checked when the receiver is evident: the class
(Db.fetch_one(...), for static and class methods), self inside the class,
a variable assigned Db(...) or annotated db: Db in the same scope (or the
module), and self.db when the class body annotates it or __init__ assigns
Db(...). Other receivers, and inherited methods, are skipped.
from typing import Annotated, LiteralString
from sqlty import SQL
class Db:
def fetch_all[T](self, query: Annotated[LiteralString, SQL], form: type[T], /) -> list[T]: ...
@staticmethod
def fetch_one[T](query: Annotated[LiteralString, SQL], form: type[T], /) -> T | None: ...
db = Db()
# error: [column-type-mismatch]
ids = db.fetch_all("SELECT name FROM users", int)
# error: [column-type-mismatch]
one = Db.fetch_one("SELECT name FROM users", int)
Helpers you cannot annotate¶
A helper whose signature you cannot edit (a library, generated code) is
registered in pyproject.toml instead:
[[tool.sqlty.functions]]
import-path = "pkg.db.fetch_all" # or "pkg.db.Db.fetch_all" for a method
query-param = "query"
return-type-param = "form"
query-param names the parameter that holds the query and return-type-param
the one holding the row type. Both are read from the definition of pkg.db, so
calls may pass them by position. sqlty reads pkg/db.py (or pkg/db/__init__.py)
from the checked paths, the project directory's src/ or the project directory
itself, or sqlty's own sys.path, and never imports it. Without a definition,
they are checked only when passed by keyword (fetch_all(query="...", form=int)).
For an @overloaded helper, positions come from the implementation, or from the
first overload naming both parameters when the implementation does not.
Helpers in other modules¶
sqlty check indexes the helpers of every file it is given before checking
calls, so a helper imported from another module of the checked paths is
resolved by its module name: from pkg.db import fetch_all [as fa],
import pkg.db as db with db.fetch_all(...), and relative imports.
Only files that mention Annotated and SQL are parsed for the index.
Pass the directory that holds the helper together with the code that calls it
(sqlty check ., not just the calling file). A helper re-exported by a package
__init__ (from .db import fetch_all) is found through the package too:
from pkg import fetch_all reaches it.
From Python, sqlty.core.check_source(source, helpers=index, module="pkg.app")
takes a sqlty.core.HelperIndex filled with index.add("pkg.db", db_source).
sqlty rewrite and sqlty strip never touch these calls.
psycopg sql.SQL queries¶
With psycopg installed (sqlty[psycopg]), a psycopg.sql composition passed
to the query parameter is checked too, so helpers annotated
Annotated[LiteralString | sql.Composable, SQL] cover queries with dynamic
identifiers. sql.SQL(...), .format(...), .join(...), sql.Composed([...]),
sql.Identifier, sql.Placeholder and sql.Literal are recognized through
any import form (from psycopg import sql, from psycopg.sql import SQL as
PgSQL, import psycopg.sql).
When every part is a literal, sqlty rebuilds the object from the source with
the real psycopg.sql classes (your code is never run) and renders it with
as_string(None), which needs no connection since psycopg 3.2. A name bound
once in the same function or module is followed, so shared fragments and
module-level constants work. sql.Literal(x) renders as NULL, like a
placeholder, whatever x is.
from typing import Annotated, LiteralString
from psycopg import sql
from sqlty import SQL
def fetch_all[T](query: Annotated[LiteralString | sql.Composable, SQL], form: type[T], /) -> list[T]: ...
USERS = sql.Identifier("users")
query = sql.SQL("SELECT {cols} FROM {table} WHERE id = {id}").format(
cols=sql.SQL(", ").join([sql.Identifier("id"), sql.Identifier("name")]),
table=USERS,
id=sql.Placeholder("id"),
)
# error: [column-type-mismatch]
rows = fetch_all(query, tuple[int, int])
A name imported from another checked module (from .columns import
USER_COLUMNS, or columns.USER_COLUMNS after import pkg.columns as columns)
resolves the same way when that module binds it once at module level to a
literal string or a composition built only from literals. Each module is parsed
once, however many files import from it.
A part that is not a literal (sql.Identifier(column) with a variable) makes
the query unchecked-query. Without psycopg installed these calls are skipped.
A --sql marker inside sql.SQL("--sql\n...") is fine; rewrite never wraps
a string inside sql.SQL(...) and stale-rewrite does not report it.