# 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`:

```sql
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email VARCHAR(255));
```

```toml
[tool.sqlty]
schema = "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:

```python
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](<https://sqlty.kuncar.dev/guide/row-types/index.md>)), including types defined in the same file. A scalar takes one column and `tuple[A, B]` one column per item:

```python
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`](<https://sqlty.kuncar.dev/reference/rules/#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:

```python
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`.

```python
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.

```python
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:

```toml
[[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 `@overload`ed helper, positions come from the implementation, or from the first overload naming both parameters when the implementation does not.

### Re-exported names

`import-path` may name a function or class where your code imports it from, a package that re-exports it (`from .core import Db`): its parameters are read from the module that defines it.

```toml
[[tool.sqlty.functions]]
import-path = "store.Db.execute"
query-param = "sql"
```

`store/__init__.py`:

```python
from .core import Db
```

`store/core.py`:

```python
from typing import Any

class Db:
    async def execute(self, sql: str, parameters: Any = None) -> Any: ...
```

```python
import store

async def save(db: store.Db) -> None:
    # error: [param-count-mismatch] "query has 2 parameters, the call passes 1"
    await db.execute("--sql\nINSERT INTO users (id, name) VALUES (%s, %s)", (1,))
```

### Query strings of registered helpers

`stale-rewrite` doesn't report a `--sql` string passed to a registered helper, with or without a row type: the helper's signature is not yours to change, so wrapping the query in `SQL[T]` would type nothing.

```toml
[[tool.sqlty.functions]]
import-path = "pkg.db.pg_fetchone"
query-param = "query"
```

`pkg/db.py`:

```python
from typing import Any

async def pg_fetchone(db: Any, query: str, *args: Any) -> Any: ...
```

```python
from typing import Any

from pkg.db import pg_fetchone

async def first(db: Any) -> Any:
    # error:
    return await pg_fetchone(db, "--sql\nSELECT 1")
```

## Queries stored in a name

A helper call that passes a name bound to a `SQL[R](...)` or `SQL[R, P](...)` call is checked as if it passed the query. The name can be a module-level constant or imported from a checked module.

```python
from typing import Annotated, Any, LiteralString, TypedDict

from sqlty import SQL


def fetch_one[T](query: Annotated[LiteralString, SQL], form: type[T], /, **params: Any) -> T | None: ...


class ById(TypedDict):
    id: int


BY_ID = SQL[tuple[str], ById]("--sql\nSELECT name FROM users WHERE id = %(id)s")

# error: [param-type-mismatch] "parameter 'id' expects int, got str"
fetch_one(BY_ID, tuple[str], id="1")
```

With a `P`, each argument is compared with its type in `P`, as a type checker would: `"1"` is rejected for an `int`, although the server would convert it. This works without changing the helper's signature, so it also covers a helper registered in `[[tool.sqlty.functions]]`.

## 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.

## Completion

`sqlty server` completes table and column names in the strings passed to a helper, as it does in `--sql` strings: a string completes exactly when `sqlty check` checks it.

```python
from typing import Annotated, LiteralString
from sqlty import SQL

def fetch_all[T](query: Annotated[LiteralString, SQL], form: type[T]) -> list[T]: ...

fetch_all("SELECT u.<CURSOR> FROM users u", tuple[int, str])
# completions: email column u
# completions: id column u
# completions: name column u
```

A call the index does not resolve to a helper, an f-string or a variable completes nothing. See [VS Code](<https://sqlty.kuncar.dev/recipes/vscode/#completion>) for the editor side.

## 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.

```python
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.
