Query variants¶
A query built from names bound in branches is checked once per path that builds it.
schema.sql:
from typing import Annotated, LiteralString
from sqlty import SQL
def fetch_all[T](query: Annotated[LiteralString, SQL], form: type[T], /) -> list[T]: ...
def users(named: bool) -> list[tuple[int, str]]:
if named:
where = "name IS NOT NULL"
else:
where = "TRUE"
# error: [nullable-mismatch] "(variant: line 13)"
return fetch_all("SELECT id, name FROM users WHERE " + where, tuple[int, str])
Without preview, the query is not a string literal, so check skips it as
dynamic-query, which is off by default. With it,
check reads both queries and checks each against tuple[int, str]. The one ending in
WHERE TRUE returns users without a name, which str does not admit.
A preview feature
Query variants are off unless preview = true is set in [tool.sqlty]. They may
change before they are on by default.
What is followed¶
check walks the function around the call, without running it. It forks at if,
elif, else, match and conditional expressions (a if flag else b), and drops the
paths that return or raise before the call. Every part of the query must be static
on every path:
| Part | Example |
|---|---|
| A string literal | "SELECT id FROM users" |
+ and += of static parts |
query += " AND active" |
| A name whose every binding is static | where = "TRUE" in one branch, where = "active" in the other |
A Literal parameter |
def find(column: Literal["id", "name"]) |
| A lookup in a dict of literals | ORDER[direction] with ORDER = {"asc": "ASC", "desc": "DESC"} |
| The return of a function of the module built the same way | "SELECT id FROM users WHERE " + where(active) |
A psycopg.sql composition of these |
sql.SQL("... {}").format(sql.SQL(where)) |
A condition tested twice takes the same branch both times, as long as nothing it reads
is assigned in between. Names bound together in one branch stay together. A case of
string literals keeps only the values it matches, so case "foo": return drops foo
from the variants after the match. A function called without an argument reads the
default of its parameter.
What is not followed¶
A part that is not static leaves the query unchecked, all of its variants included: one
unchecked path could hide any query. The call is then dynamic-query, or
unchecked-query for a psycopg.sql composition. These are not static:
- an f-string, or a value from a call to anything but a function of the module;
- a name bound in a loop, a
tryor awithblock, or by a walrus; - a parameter whose type is not a
Literalof strings, unless the argument is static.
from typing import Annotated, LiteralString
from sqlty import SQL
def fetch_all[T](query: Annotated[LiteralString, SQL], form: type[T], /) -> list[T]: ...
def users(columns: list[LiteralString]) -> list[tuple[int]]:
query: LiteralString = "SELECT id FROM users WHERE TRUE"
for column in columns:
query += " AND " + column
# error: [dynamic-query] "query is not a string literal, so it is not checked"
return fetch_all(query, tuple[int])
Reporting¶
A diagnostic every variant has is reported once, as for a single query. Any other names
the lines of the bindings that select the first variant having it, such as
(variant: line 13): the line of where = "TRUE" above.
A call with more variants than max-query-variants (default 64) is reported with
their count, query has 128 variants, more than max-query-variants (64), rather than
checked on some of them.
sqlty check --statistics counts each variant as a query.