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
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![PyPI - Version](https://img.shields.io/pypi/v/iron-sql)](https://pypi.org/project/iron-sql/)


`iron_sql` is a typed SQL code generator and async runtime for PostgreSQL. Write SQL where you use it, run `generate_sql_package`, and get a module with typed dataclasses, query helpers, and pooled connections without hand-written boilerplate.
`iron_sql` is a typed SQL code generator and async runtime for PostgreSQL. Write SQL where you use it, run `generate_sql_module`, and get a module with typed dataclasses, query helpers, and pooled connections without hand-written boilerplate.

## Installation

Expand All @@ -17,7 +17,7 @@ pip install iron-sql[codegen] # + inflection for code generation
The `sqlc` binary is bundled automatically via the `sqlc` Python package.

## Key Features
- **Query discovery.** `generate_sql_package` scans your codebase for calls like `<package>_sql("SELECT ...")`, runs `sqlc` for type analysis, and emits a typed module.
- **Query discovery.** `generate_sql_module` scans your codebase for calls like `<module>_sql("SELECT ...")`, runs `sqlc` for type analysis, and emits a typed module.
- **Strong typing.** Generated dataclasses and method signatures flow through your IDE and type checker.
- **Async runtime.** Built on `psycopg` v3 with pooled connections, context-based connection reuse, and transaction helpers.
- **Streaming.** `query_stream()` uses server-side cursors for memory-efficient iteration over large result sets.
Expand All @@ -44,12 +44,12 @@ The `sqlc` binary is bundled automatically via the `sqlc` Python package.
```python
from pathlib import Path

from iron_sql.codegen import generate_sql_package
from iron_sql.codegen import generate_sql_module

generate_sql_package(
generate_sql_module(
schema_path=Path("schema.sql"),
package_full_name="myapp.db.mydb",
dsn_import="myapp.config:DSN",
module_full_name="myapp.db.mydb",
dsn_expr="myapp.config:DSN",
src_path=Path("."),
)
```
Expand All @@ -66,7 +66,7 @@ The `sqlc` binary is bundled automatically via the `sqlc` Python package.
- **Type overrides.** `type_overrides={"custom_type": "int"}` maps database type names to Python type strings.
- **JSON model overrides.** `json_model_overrides={"users.metadata": "myapp.models:UserMeta"}` adds Pydantic validation for JSON/JSONB columns.
- **Naming conventions.** Supply `to_pascal_fn` and `to_snake_fn` callables to control generated names.
- **DSN configuration.** `dsn_import` is written verbatim into the generated module; point it at a config variable, env var lookup, or function call.
- **Connection settings.** `dsn_expr` and `pool_options_expr` are written verbatim into the generated module; point them at config variables, env var lookups, or function calls.
- **Debug artifacts.** Pass `debug_path` to save sqlc inputs and outputs for inspection.

## Runtime Highlights
Expand Down
3 changes: 3 additions & 0 deletions example/config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import os

from iron_sql import PoolOptions

DSN = os.environ.get("DATABASE_URL", "")
POOL_OPTIONS: PoolOptions = {"min_size": 1, "max_size": 10, "timeout": 15.0}
34 changes: 18 additions & 16 deletions example/db/mydb.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@
from iron_sql import runtime

from example.config import DSN

from example.config import POOL_OPTIONS
import example.models


MYDB_POOL = runtime.ConnectionPool(
DSN,
name="mydb",
application_name=None,
pool_options=POOL_OPTIONS,
)

_mydb_connection = ContextVar[psycopg.AsyncConnection | None](
Expand Down Expand Up @@ -295,31 +297,31 @@ def query_stream(self, *, status: MydbTaskStatus) -> AbstractAsyncContextManager


@overload
def mydb_sql(stmt: Literal['\n INSERT INTO users (id, username, email)\n VALUES (@id, @username, @email)\n ']) -> Query_3ee53b6909da8b4496346dda36c9f442: ...
def mydb_sql(sql: Literal['\n INSERT INTO users (id, username, email)\n VALUES (@id, @username, @email)\n ']) -> Query_3ee53b6909da8b4496346dda36c9f442: ...
@overload
def mydb_sql(stmt: Literal['\n INSERT INTO projects (id, name, owner_id, settings)\n VALUES (@id, @name, @owner_id, @settings)\n ']) -> Query_67ac0768d48a654b1a305124c92372e8: ...
def mydb_sql(sql: Literal['\n INSERT INTO projects (id, name, owner_id, settings)\n VALUES (@id, @name, @owner_id, @settings)\n ']) -> Query_67ac0768d48a654b1a305124c92372e8: ...
@overload
def mydb_sql(stmt: Literal['\n INSERT INTO tasks (id, project_id, title, priority, assignee_id, metadata, due_date)\n VALUES (@id, @project_id, @title, @priority, @assignee_id?, @metadata?, @due_date?)\n ']) -> Query_bd4c62c78a942bfd1f087f87a19f2743: ...
def mydb_sql(sql: Literal['\n INSERT INTO tasks (id, project_id, title, priority, assignee_id, metadata, due_date)\n VALUES (@id, @project_id, @title, @priority, @assignee_id?, @metadata?, @due_date?)\n ']) -> Query_bd4c62c78a942bfd1f087f87a19f2743: ...
@overload
def mydb_sql(stmt: Literal['UPDATE tasks SET status = @status WHERE id = @task_id']) -> Query_12e061f7aa94bf484295ab0018520059: ...
def mydb_sql(sql: Literal['UPDATE tasks SET status = @status WHERE id = @task_id']) -> Query_12e061f7aa94bf484295ab0018520059: ...
@overload
def mydb_sql(stmt: Literal['SELECT id, username, email, created_at FROM users ORDER BY created_at']) -> Query_46242a02ffe365dc17851a034fdc1d30: ...
def mydb_sql(sql: Literal['SELECT id, username, email, created_at FROM users ORDER BY created_at']) -> Query_46242a02ffe365dc17851a034fdc1d30: ...
@overload
def mydb_sql(stmt: Literal['SELECT id, username, email, created_at FROM users WHERE id = @user_id']) -> Query_41cb2f3cea216a76ba87b6ddb70e6be5: ...
def mydb_sql(sql: Literal['SELECT id, username, email, created_at FROM users WHERE id = @user_id']) -> Query_41cb2f3cea216a76ba87b6ddb70e6be5: ...
@overload
def mydb_sql(stmt: Literal["\n SELECT id, project_id, assignee_id, title, status, priority, metadata, due_date, created_at\n FROM tasks\n WHERE project_id = @project_id AND (sqlc.narg('status')::task_status IS NULL OR status = @status?)\n "]) -> Query_ce9822661c2a7e0e716755087929ebd9: ...
def mydb_sql(sql: Literal["\n SELECT id, project_id, assignee_id, title, status, priority, metadata, due_date, created_at\n FROM tasks\n WHERE project_id = @project_id AND (sqlc.narg('status')::task_status IS NULL OR status = @status?)\n "]) -> Query_ce9822661c2a7e0e716755087929ebd9: ...
@overload
def mydb_sql(stmt: Literal['\n SELECT status, count(*) AS task_count\n FROM tasks WHERE project_id = @project_id\n GROUP BY status ORDER BY status\n '], row_type: Literal['TaskStatusCount']) -> Query_cabe6d4d91163f6aadc739bf765777db_TaskStatusCount: ...
def mydb_sql(sql: Literal['\n SELECT status, count(*) AS task_count\n FROM tasks WHERE project_id = @project_id\n GROUP BY status ORDER BY status\n '], row_type: Literal['TaskStatusCount']) -> Query_cabe6d4d91163f6aadc739bf765777db_TaskStatusCount: ...
@overload
def mydb_sql(stmt: Literal['SELECT id FROM tasks WHERE project_id = @project_id AND title = @title']) -> Query_07cbb3e5226e35adbd17171f38ab7216: ...
def mydb_sql(sql: Literal['SELECT id FROM tasks WHERE project_id = @project_id AND title = @title']) -> Query_07cbb3e5226e35adbd17171f38ab7216: ...
@overload
def mydb_sql(stmt: Literal['SELECT count(*) FROM tasks WHERE status = @status']) -> Query_29c838280e39383dd6b0760431eb3e60: ...
def mydb_sql(sql: Literal['SELECT count(*) FROM tasks WHERE status = @status']) -> Query_29c838280e39383dd6b0760431eb3e60: ...
@overload
def mydb_sql(stmt: str) -> Query: ...
def mydb_sql(sql: str) -> Query: ...


def mydb_sql(stmt: str, row_type: str | None = None) -> Query:
if stmt in _QUERIES:
return _QUERIES[stmt]()
msg = f"Unknown statement: {stmt!r}"
def mydb_sql(sql: str, row_type: str | None = None) -> Query:
if sql in _QUERIES:
return _QUERIES[sql]()
msg = f"Unknown statement: {sql!r}"
raise KeyError(msg)
15 changes: 8 additions & 7 deletions example/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,23 @@
import psycopg
from testcontainers.postgres import PostgresContainer

from iron_sql.codegen import generate_sql_package
from iron_sql.codegen import generate_sql_module


def init_db(dsn: str, schema_path: Path):
with psycopg.connect(dsn, autocommit=True) as conn:
conn.execute(schema_path.read_text(encoding="utf-8")) # pyright: ignore[reportCallIssue, reportArgumentType]


def generate_db_package(dsn: str, schema_path: Path, src_path: Path) -> bool:
def generate_db_module(dsn: str, schema_path: Path, src_path: Path) -> bool:
# For example.config:DSN
os.environ["DATABASE_URL"] = dsn

return generate_sql_package(
return generate_sql_module(
schema_path=schema_path,
package_full_name="example.db.mydb",
dsn_import="example.config:DSN",
module_full_name="example.db.mydb",
dsn_expr="example.config:DSN",
pool_options_expr="example.config:POOL_OPTIONS",
src_path=src_path,
json_model_overrides={
"projects.settings": "example.models:ProjectSettings",
Expand All @@ -36,5 +37,5 @@ def generate_db_package(dsn: str, schema_path: Path, src_path: Path) -> bool:
with PostgresContainer("postgres:17-alpine") as postgres:
dsn = postgres.get_connection_url(driver=None)
init_db(dsn, schema_path)
changed = generate_db_package(dsn, schema_path, src_path)
print("Updated SQL package:", changed)
changed = generate_db_module(dsn, schema_path, src_path)
print("Updated SQL module:", changed)
2 changes: 2 additions & 0 deletions src/iron_sql/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""iron_sql: Typed SQL client generator for Python."""

from iron_sql.runtime import NoRowsError
from iron_sql.runtime import PoolOptions
from iron_sql.runtime import TooManyRowsError

__all__ = [
"NoRowsError",
"PoolOptions",
"TooManyRowsError",
]
4 changes: 2 additions & 2 deletions src/iron_sql/codegen/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from iron_sql.codegen.generator import UnknownSQLTypeWarning
from iron_sql.codegen.generator import generate_sql_package
from iron_sql.codegen.generator import generate_sql_module

__all__ = [
"UnknownSQLTypeWarning",
"generate_sql_package",
"generate_sql_module",
]
Loading
Loading