diff --git a/.github/PERMISSIONS.md b/.github/PERMISSIONS.md index 6002a42..bbac035 100644 --- a/.github/PERMISSIONS.md +++ b/.github/PERMISSIONS.md @@ -133,12 +133,12 @@ permissions: **Why These Permissions:** - `pages: write` - Allows the workflow to: - - Deploy the generated MkDocs static site to GitHub Pages + - Deploy the generated Zensical static site to GitHub Pages - Update the Pages deployment for the repository - `id-token: write` - Allows secure OIDC auth for Pages deployment **What It Does:** -- Builds MkDocs docs +- Builds Zensical docs - Deploys docs to GitHub Pages --- diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index c92add2..b3fbe97 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -18,7 +18,7 @@ permissions: jobs: build-docs: - name: Build docs (MkDocs) + name: Build docs (Zensical) runs-on: ubuntu-latest steps: - name: Checkout repository @@ -40,9 +40,9 @@ jobs: run: | uv sync --only-group docs --no-install-project --python 3.13 - - name: Build MkDocs site + - name: Build Zensical site run: | - uv run --no-sync mkdocs build + uv run --no-sync zensical build --clean - name: Upload Pages artifact uses: actions/upload-pages-artifact@v3 diff --git a/.gitignore b/.gitignore index 12fb6cc..d3a1879 100644 --- a/.gitignore +++ b/.gitignore @@ -189,7 +189,7 @@ venv.bak/ # Rope project settings .ropeproject -# mkdocs documentation +# zensical documentation /site # mypy diff --git a/AGENTS.md b/AGENTS.md index 8aea30a..82de987 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -190,3 +190,71 @@ What this means in practice: This rule binds human contributors and AI agents equally, and overrides any agent default that biases toward minimal or expedient changes. + +--- + +## I-8: Docs examples show both field-declaration styles + +Ferro supports two equivalent ways to declare model fields: assignment +(`name: str = Field(unique=True)`) and `Annotated` metadata +(`name: Annotated[str, Field(unique=True)]`). Every documentation example +that declares model fields with `Field()`/`FerroField()` options must show +**both** styles, side by side, as content tabs: + + === "Assignment" + + ```python + --8<-- "docs/examples/.py:models" + ``` + + === "Annotated" + + ```python + --8<-- "docs/examples/_annotated.py:models" + ``` + +Rules: + +- Both tabs must be backed by real, runnable code. Snippet-embedded model + definitions get a runnable `_annotated.py` companion in + `docs/examples/` (exercised by `tests/test_docs_examples.py`); inline + blocks are written in both styles and compile-checked by the same test. +- Constructs with only one valid form appear identically in both tabs and + are not tabbed on their own: forward FKs are always + `Annotated[Target, ForeignKey(...)]`, and `BackRef()` / `ManyToMany()` + are always assignments. +- Code blocks that do not declare fields (queries, mutations, transactions, + usage snippets) are not affected by this rule. + +This keeps users from ever wondering whether something is possible in their +preferred declaration style. + +--- + +## I-9: Lambda predicates are the official query style + +Documentation and examples use the lambda predicate style for all queries: + +```python +adults = await User.where(lambda t: t.age >= 18).all() +``` + +Rules: + +- **Every query example** in docs, docstring `Examples:` sections, and + `docs/examples/` scripts uses lambda predicates. +- When the predicate styles themselves are documented, present them in + order **lambda > `col()` > operator**, with lambda labeled the officially + recommended style. +- **Operator style** (`User.where(User.age >= 18)`) is compatible today but + is slated for deprecation in a future release and fails static type + checking (`User.age >= 18` types as `bool`; `where()` expects + `QueryNode | Predicate`). Docs say so explicitly wherever the style is + shown. +- **`order_by` is not a predicate** and keeps attribute style + (`order_by(User.age, "desc")`). Passing a lambda to `order_by` silently + produces a junk column name — never show it. + +The canonical comparisons live in `docs/pages/guide/queries.md` +("Predicate Styles") and `docs/pages/concepts/query-typing.md`; everywhere +else uses lambda without restating the trade-offs. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c3ef25a..853ae44 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,7 @@ This will install all development dependencies including: - Testing tools (pytest, pytest-asyncio, pytest-cov) - Linting and formatting tools (ruff, prek) - Build tools (maturin) -- Documentation tools (mkdocs-material) +- Documentation tools (zensical) - Release tools (commitizen, python-semantic-release) ### 3. Install Pre-commit Hooks @@ -102,10 +102,10 @@ cargo clippy # Rust linting ```bash # Serve documentation locally (with live reload) -uv run mkdocs serve +uv run zensical serve # Build documentation -uv run mkdocs build +uv run zensical build # Documentation will be available at http://127.0.0.1:8000/ ``` diff --git a/README.md b/README.md index 5181c49..5fb5ecc 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![PyTest](https://img.shields.io/badge/Pytest-0A9EDC?style=for-the-badge&logo=pytest&logoColor=white)](https://docs.pytest.org/) [![Ruff](https://img.shields.io/badge/Ruff-FFC107?style=for-the-badge&logo=python&logoColor=black)](https://docs.astral.sh/ruff/) -[![MkDocs](https://img.shields.io/badge/MkDocs-000000?style=for-the-badge&logo=markdown&logoColor=white)](https://www.mkdocs.org/) +[![Zensical](https://img.shields.io/badge/Zensical-4051B5?style=for-the-badge&logo=markdown&logoColor=white)](https://zensical.org/) [![UV](https://img.shields.io/badge/UV-2C2C2C?style=for-the-badge&logo=python&logoColor=white)](https://github.com/astral-sh/uv) [![Rust](https://img.shields.io/badge/Rust-000000?style=for-the-badge&logo=rust&logoColor=white)](https://www.rust-lang.org/) [![Python](https://img.shields.io/badge/Python-3.13%20|%203.14-3776AB?style=for-the-badge&logo=python&logoColor=white)](https://www.python.org/) diff --git a/docs/TEST_RESULTS.md b/docs/TEST_RESULTS.md deleted file mode 100644 index 4fd9841..0000000 --- a/docs/TEST_RESULTS.md +++ /dev/null @@ -1,210 +0,0 @@ -# Documentation Validation Test Results - -**Date**: 2026-02-15 -**Test File**: `tests/test_documentation_features.py` -**Total Tests**: 40 -**Passed**: 36 (90%) -**Skipped**: 4 (10%) -**Failed**: 0 - -## Summary - -I created a comprehensive test suite to validate all documented features in Ferro. The test suite covers: - -1. **Models & Fields** (6 tests) - ✅ All passed -2. **CRUD Operations** (9 tests) - ✅ All passed -3. **Query Operations** (13 tests) - ✅ All passed -4. **Relationships** (8 tests) - ✅ 4 passed, 4 skipped (M2M) -5. **Transactions** (3 tests) - ✅ All passed -6. **Tutorial Example** (1 test) - ✅ Passed - -## Test Results by Category - -### ✅ Models & Fields (6/6 passed) - -All field types and constraints work as documented: - -- Basic model definition ✅ -- All documented field types (str, int, Decimal, date, dict, Enum) ✅ -- Enum field type with proper serialization/deserialization ✅ -- Field() Pydantic-style constraints ✅ (preferred in user-facing docs) -- FerroField() Annotated-style constraints ✅ -- Unique constraints ✅ - -### ✅ CRUD Operations (9/9 passed) - -All documented CRUD operations work correctly: - -- `Model.create()` ✅ -- `Model.get()` / `Model.get_or_none()` ✅ -- `Model.all()` ✅ -- `instance.save()` ✅ -- `instance.delete()` ✅ -- `instance.refresh()` ✅ -- `Model.bulk_create()` ✅ -- `Model.get_or_create()` ✅ -- `Model.update_or_create()` ✅ - -### ✅ Query Operations (13/13 passed) - -All documented query features work: - -- `.where()` with equality operator ✅ -- Comparison operators (`>`, `>=`, `<`, `<=`, `!=`) ✅ -- `.like()` pattern matching ✅ -- `.in_()` operator ✅ -- Logical AND (`&`) operator ✅ -- Logical OR (`|`) operator ✅ -- `.order_by()` ascending and descending ✅ -- `.limit()` and `.offset()` pagination ✅ -- `.first()` single result retrieval ✅ -- `.count()` aggregation ✅ -- `.exists()` existence checking ✅ -- `.update()` bulk updates ✅ -- `.delete()` bulk deletes ✅ - -### ⚠️ Relationships (4/8 passed, 4 skipped) - -**Working Features:** -- ForeignKey creation with model instances ✅ -- Forward relation access (`await post.author`) ✅ -- Reverse relation access (`await author.posts.all()`) ✅ -- Reverse relation filtering ✅ -- Shadow field access (`post.author_id`) ✅ - -**Skipped Tests (M2M Join Tables Not Auto-Created):** -- Many-to-many `.add()` ⏭️ -- Many-to-many `.remove()` ⏭️ -- Many-to-many `.clear()` ⏭️ -- Many-to-many reverse access ⏭️ - -**Finding**: Many-to-many relationships are documented but join tables are not automatically created with `auto_migrate=True`. This needs investigation or documentation update. - -### ✅ Transactions (3/3 passed) - -All transaction features work: - -- Transaction commits on success ✅ -- Transaction rollbacks on exception ✅ -- Transaction isolation between concurrent tasks ✅ - -### ✅ Tutorial Example (1/1 passed) - -The complete tutorial blog example from the documentation works end-to-end ✅ - -## Important Findings - -### 1. Enum Field Queries ⚠️ - -**Issue**: When querying enum fields, you must use `.value`: - -```python -# ❌ Does NOT work (JSON serialization error) -await User.where(User.role == UserRole.ADMIN).all() - -# ✅ Works correctly -await User.where(User.role == UserRole.ADMIN.value).all() -await User.where(User.role.in_([UserRole.ADMIN.value, UserRole.MODERATOR.value])).all() -``` - -**Recommendation**: Update documentation to clarify enum query syntax or fix the query builder to handle enum instances. - -### 2. Many-to-Many Join Tables 🔍 - -**Issue**: Many-to-many relationship join tables are not automatically created during `auto_migrate=True`. - -**Error**: `no such table: post_tags` - -**Tests Skipped**: -- test_many_to_many_add -- test_many_to_many_remove -- test_many_to_many_clear -- test_many_to_many_reverse - -**Recommendation**: Either: -1. Implement automatic join table creation in Rust engine -2. Document that manual join table creation is required -3. Update the coming-soon.md to note current M2M limitations - -### 3. Primary Key Fields ✅ - -**Working Pattern**: Primary keys should be optional with None default: - -```python -from ferro import Field, Model - -class User(Model): - id: int | None = Field(default=None, primary_key=True) - username: str -``` - -This allows `.create()` to work without requiring id to be passed. - -## Code Coverage - -The test suite achieved **71% coverage** of the Ferro codebase: - -- `src/ferro/__init__.py`: 100% -- `src/ferro/base.py`: 100% -- `src/ferro/models.py`: 83% -- `src/ferro/query/builder.py`: 72% -- `src/ferro/relations/__init__.py`: 89% -- `src/ferro/relations/descriptors.py`: 86% - -Areas not covered: -- `src/ferro/migrations/` (0% - Alembic integration not tested) -- Some edge cases in models and query builder - -## Recommendations for Documentation Updates - -### High Priority - -1. **Enum Query Syntax**: Update all enum query examples to use `.value` - - Files: `docs/guide/queries.md`, `docs/guide/relationships.md` - -2. **Many-to-Many Status**: Add warning about M2M join table creation - - Files: `docs/guide/relationships.md`, `docs/coming-soon.md` - -### Medium Priority - -3. **Primary Key Pattern**: Document the optional primary key pattern - - Files: `docs/guide/models-and-fields.md` - -4. **Model.count()**: Clarify that `.select().count()` is the correct syntax - - Files: All documentation (already partially fixed) - -## Running the Tests - -```bash -# Run all documentation feature tests -uv run pytest tests/test_documentation_features.py -v - -# Run specific test category -uv run pytest tests/test_documentation_features.py::test_where_equality -v - -# Run with coverage -uv run pytest tests/test_documentation_features.py --cov=src/ferro --cov-report=term-missing -``` - -## Conclusion - -✅ **90% of documented features work correctly** (36/40 tests passed) - -The comprehensive test suite validates that: -- All core CRUD operations work as documented -- All query operations work as documented -- ForeignKey relationships work as documented -- Transactions work as documented -- The tutorial example works end-to-end - -**Action Items**: -1. Investigate and fix many-to-many join table creation -2. Update documentation for enum query syntax -3. Add these tests to CI/CD pipeline for regression prevention -4. Update coming-soon.md with M2M findings - ---- - -**Test File**: Created at `tests/test_documentation_features.py` -**Last Run**: 2026-02-15 -**Next Review**: After each documentation update or feature addition diff --git a/docs/api/exceptions.md b/docs/api/exceptions.md deleted file mode 100644 index 7adbf26..0000000 --- a/docs/api/exceptions.md +++ /dev/null @@ -1,15 +0,0 @@ -# Exceptions - -Public exception types raised by Ferro’s ORM layer. - -::: ferro.exceptions.ModelDoesNotExist - options: - show_source: false - heading_level: 2 - show_root_heading: true - -## See Also - -- [Queries](../guide/queries.md) — `Model.get` vs `Model.get_or_none` -- [Model API](model.md) -- [Mutations](../guide/mutations.md) — error handling patterns diff --git a/docs/api/fields.md b/docs/api/fields.md deleted file mode 100644 index 085ca3a..0000000 --- a/docs/api/fields.md +++ /dev/null @@ -1,21 +0,0 @@ -# Fields API - -Complete reference for field types and metadata. In application models, use **`ferro.Field`** in either the **assignment** or **annotation** pattern described in the [models & fields guide](../guide/models-and-fields.md#field-constraints). **`FerroField`** is the lower-level metadata type; `Field(...)` is normalized into the same shape internally. - -## Field - -::: ferro.fields.Field - options: - show_source: false - heading_level: 3 - -## FerroField - -::: ferro.base.FerroField - options: - show_source: false - heading_level: 3 - -## See Also - -- [Models & Fields Guide](../guide/models-and-fields.md) diff --git a/docs/api/model.md b/docs/api/model.md deleted file mode 100644 index a10b3f1..0000000 --- a/docs/api/model.md +++ /dev/null @@ -1,31 +0,0 @@ -# Model API - -Complete reference for the `Model` base class and related methods. - -::: ferro.Model - options: - members: - - create - - bulk_create - - get - - get_or_none - - where - - select - - all - - first - - count - - exists - - update - - delete - - save - - refresh - show_source: false - heading_level: 2 - show_root_heading: true - -## See Also - -- [Models & Fields Guide](../guide/models-and-fields.md) -- [Queries Guide](../guide/queries.md) — fetch by primary key (`get` / `get_or_none`) -- [Exceptions](exceptions.md) — `ModelDoesNotExist` -- [Mutations Guide](../guide/mutations.md) diff --git a/docs/api/query.md b/docs/api/query.md deleted file mode 100644 index 2e76eb1..0000000 --- a/docs/api/query.md +++ /dev/null @@ -1,97 +0,0 @@ -# Query API - -Complete reference for the Query Builder API. - -## `Query` - -`Query.where` accepts either a `QueryNode` (the operator and `col()` paths) or a lambda predicate of shape `Callable[[QueryProxy[TModel]], QueryNode]`. See [Typed Query Predicates](../concepts/query-typing.md) for a full treatment of the three predicate styles. - -::: ferro.query.Query - options: - members: - - where - - order_by - - limit - - offset - - all - - first - - count - - exists - - update - - delete - show_source: false - heading_level: 3 - -## `Relation` - -`Relation` is the lazy collection-relationship subclass of `Query` returned by `BackRef` and `ManyToMany` fields. It accepts the same three predicate styles on `where`. - -::: ferro.query.Relation - options: - members: - - where - - order_by - - limit - - offset - - all - - first - - add - - remove - - clear - show_source: false - heading_level: 3 - -## `col` - -Runtime-identity wrapper that statically narrows a model class attribute back to `FieldProxy[T]`. Use it when a single attribute on an existing chain trips your type checker; for new code prefer the lambda predicate style. - -::: ferro.query.col - options: - show_source: false - heading_level: 3 - -## `FieldProxy` - -The typed proxy installed by Ferro's metaclass on every model class field. Generic over the column's Python type; operator overloads accept `T | FieldProxy[T]` and return `QueryNode`. - -::: ferro.query.FieldProxy - options: - members: - - in_ - - like - show_source: false - heading_level: 3 - -## `QueryProxy` - -The attribute proxy passed to lambda predicates. Each attribute access returns a fresh `FieldProxy` for the accessed name. - -::: ferro.query.QueryProxy - options: - show_source: false - heading_level: 3 - -## `QueryNode` - -The serializable AST node produced by every predicate style. You normally do not construct these directly. - -::: ferro.query.QueryNode - options: - members: - - to_dict - show_source: false - heading_level: 3 - -## `Predicate` - -Type alias for lambda predicates accepted by `Query.where`, `Relation.where`, and `Model.where`. - -```python -Predicate[TModel] = Callable[[QueryProxy[TModel]], QueryNode] -``` - -## See Also - -- [Queries Guide](../guide/queries.md) -- [Typed Query Predicates](../concepts/query-typing.md) -- [How-To: Pagination](../howto/pagination.md) diff --git a/docs/api/raw-sql.md b/docs/api/raw-sql.md deleted file mode 100644 index f79a84e..0000000 --- a/docs/api/raw-sql.md +++ /dev/null @@ -1,124 +0,0 @@ -# Raw SQL - -Ferro exposes a raw SQL escape hatch for statements that don't fit a `Model` — -Postgres GUCs (`set_config`, `SET LOCAL`), advisory locks, `LISTEN/NOTIFY`, -or any one-off query. - -!!! warning "Raw SQL is an escape hatch" - Bind values cross the FFI as wire-close primitives, and rows come back as - `dict[str, str | int | float | bool | bytes | None]`. UUID/datetime/JSON - columns are returned as **strings**. **If you want typed rows, use the - ORM.** - -## Two surfaces, same plumbing - -### Transaction-bound (preferred) - -```python -from ferro import transaction - -async with transaction() as tx: - await tx.execute( - "select set_config('request.jwt.claims', $1, true)", - claims_json, - ) - rows = await tx.fetch_all( - "select id, name from users where org_id = $1 limit 50", - org.id, - ) - row = await tx.fetch_one( - "select count(*) as n from users where org_id = $1", - org.id, - ) -``` - -The `tx` handle owns the transaction's connection. You cannot misuse it — -calling `tx.execute(...)` after the `async with` block exits raises -`RuntimeError`. - -### Top-level (`using` or active transaction) - -```python -from ferro import execute, fetch_all, fetch_one - -# Outside any tx — runs on the default connection. -await execute("select pg_advisory_unlock_all()") - -# Route explicitly to a named connection. -await execute("select run_pipeline_job($1)", job_id, using="service") - -# Inside a tx — auto-picked up via the same ContextVar that Model.create() uses. -async with transaction(using="service"): - await execute("select set_config('request.jwt.claims', $1, true)", claims_json) - rows = await fetch_all("select * from foo where org_id = $1", org_id) -``` - -Passing `using=...` inside an active transaction raises. A transaction is pinned to -one connection, and unqualified raw SQL inherits that connection. - -## Placeholders are native to the backend - -| Backend | Placeholder syntax | Example | -| -------- | ------------------ | -------------------------------------- | -| Postgres | `$1, $2, …` | `select set_config('k', $1, true)` | -| SQLite | `?` (positional) | `select * from users where id = ?` | - -There is no translation layer. What you write is what `sqlx::query(sql)` runs. -Mismatches surface as the database driver's own error. - -## Bind type table - -| Python type | Sent as | Postgres cast you must write | -| ----------------------- | ---------------------- | ---------------------------- | -| `None` | `NULL` | — | -| `bool` | bool | — | -| `int` | `i64` | — | -| `float` | `f64` | — | -| `str` | text | — | -| `bytes` / `bytearray` | bytea / blob | — | -| `uuid.UUID` | text | `$N::uuid` | -| `datetime.datetime` | ISO 8601 text | `$N::timestamptz` | -| `datetime.date` | ISO 8601 text | `$N::date` | -| `datetime.time` | ISO 8601 text | `$N::time` | -| `decimal.Decimal` | text | `$N::numeric` | -| `enum.Enum` | recursive on `.value` | (depends on `.value` type) | -| `dict` / `list` | `json.dumps(v)` text | `$N::jsonb` | -| anything else | `TypeError` is raised | — | - -Raw SQL has no schema map, so Ferro does not auto-cast bind values. This -matches asyncpg / psycopg / pgx behavior. **Never** f-string user input into -the `sql` argument — use placeholders and pass values as positional args. - -### Postgres cast cheat-sheet - -```python -"... where id = $1::uuid" # uuid.UUID -"... where created_at = $1::timestamptz" # datetime -"... where day = $1::date" # date -"... where amount = $1::numeric" # Decimal -"... set data = $1::jsonb" # dict / list -``` - -## Connection affinity - -Outside a `transaction()` block, each top-level `execute` / `fetch_all` / -`fetch_one` call runs on the selected named pool (`using=...`) or the default -pool. Consecutive calls may use different physical connections from that pool. -Wrap in `transaction(using=...)` for connection-affinity-sensitive operations -like `SET LOCAL`, advisory locks, or `LISTEN/NOTIFY`. - -## What raw SQL doesn't do - -- **No typed rows.** Rows are always plain dicts of primitives. If you want - `uuid.UUID` / `datetime` / `Decimal` objects, use `Model.fetch_*`. -- **No multi-statement strings.** One statement per call. -- **No string-interpolation guard.** The API forces placeholders by shape; - detecting f-strings at runtime is not possible. -- **No auto type-casts on Postgres.** Write `$N::uuid` / `$N::jsonb` yourself. - -## API reference - -::: ferro.execute -::: ferro.fetch_all -::: ferro.fetch_one -::: ferro.Transaction diff --git a/docs/api/relationships.md b/docs/api/relationships.md deleted file mode 100644 index e21e912..0000000 --- a/docs/api/relationships.md +++ /dev/null @@ -1,35 +0,0 @@ -# Relationships API - -Complete reference for relationship types. - -## ForeignKey - -::: ferro.base.ForeignKey - options: - show_source: false - heading_level: 3 - -## Relation - -::: ferro.query.builder.Relation - options: - show_source: false - heading_level: 3 - -## BackRef - -::: ferro.fields.BackRef - options: - show_source: false - heading_level: 3 - -## ManyToMany - -::: ferro.fields.ManyToMany - options: - show_source: false - heading_level: 3 - -## See Also - -- [Relationships Guide](../guide/relationships.md) diff --git a/docs/api/transactions.md b/docs/api/transactions.md deleted file mode 100644 index 4ee4654..0000000 --- a/docs/api/transactions.md +++ /dev/null @@ -1,36 +0,0 @@ -# Transactions API - -Complete reference for transaction management. - -## transaction() - -Context manager for atomic database transactions. - -```python -from ferro import transaction - -async with transaction(): - # All operations are atomic - user = await User.create(username="alice") - await Post.create(title="Hello", author=user) - # Auto-commits on success, auto-rolls back on exception -``` - -See the [Transactions Guide](../guide/transactions.md) for comprehensive usage patterns and examples. - -## Manual Control - -For advanced use cases requiring fine-grained control, Ferro provides low-level transaction management functions. Check your Ferro version's API documentation for availability: - -- `begin_transaction()` - Manually start a new transaction -- `commit_transaction(tx_id)` - Commit a transaction by ID -- `rollback_transaction(tx_id)` - Roll back a transaction by ID - -!!! warning - Manual transaction control is advanced usage. The `transaction()` context manager is recommended for most use cases. - -## See Also - -- [Transactions Guide](../guide/transactions.md) - Complete usage guide with patterns -- [Mutations Guide](../guide/mutations.md) - Creating, updating, deleting records -- [Database Setup](../guide/database.md) - Connection management diff --git a/docs/api/utilities.md b/docs/api/utilities.md deleted file mode 100644 index b9d2185..0000000 --- a/docs/api/utilities.md +++ /dev/null @@ -1,73 +0,0 @@ -# Utilities API - -Utility functions and helpers. - -## Connection Management - -### connect() - -Establish a connection to the database. - -```python -from ferro import connect - -# SQLite -await connect("sqlite:example.db?mode=rwc") - -# PostgreSQL -await connect("postgresql://user:password@localhost/dbname") - -# Auto-migrate during development -await connect("postgresql://localhost/dbname", auto_migrate=True) -``` - -See [Database Setup Guide](../guide/database.md) for complete connection options. - -### disconnect() - -This function is not implemented yet. - -```python -# Current pattern: connect once during startup -await connect("sqlite:example.db?mode=rwc") -``` - -### create_tables() - -Manually create all registered model tables. - -```python -from ferro import create_tables - -await create_tables() -``` - -!!! note - With `auto_migrate=True`, tables are created automatically on connect. - -## Identity Map Management - -### evict_instance() - -Remove an instance from the identity map, forcing a fresh database fetch on next access. - -```python -from ferro import evict_instance - -# Evict user with ID=1 -evict_instance("User", 1) - -# Next fetch will hit database (raises ModelDoesNotExist if row was removed) -user = await User.get(1) -``` - -If the row may no longer exist, use `User.get_or_none(1)` or handle [`ModelDoesNotExist`](exceptions.md). - -See [Identity Map Concept](../concepts/identity-map.md) for when and why to evict instances. - -## See Also - -- [Database Setup Guide](../guide/database.md) - Connection configuration -- [Identity Map Concept](../concepts/identity-map.md) - Instance caching details -- [Exceptions](exceptions.md) - `ModelDoesNotExist` and related types -- [Schema Management](../guide/migrations.md) - Production migrations diff --git a/docs/changelog.md b/docs/changelog.md deleted file mode 100644 index bece655..0000000 --- a/docs/changelog.md +++ /dev/null @@ -1,46 +0,0 @@ -# Changelog - -All notable changes to Ferro ORM will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -### Breaking - -- `Model.get` and `Model.using(...).get` now return the concrete model type and raise `ModelDoesNotExist` when no row exists (previously they returned `T | None`). Use the new `get_or_none` for the old optional behavior. - -### Added - -- `Model.get_or_none` and `Model.using(...).get_or_none` for primary-key lookup without raising. -- `ModelDoesNotExist` (`LookupError` subclass with `.model` and `.pk`), exported from `ferro`. Documented under [Exceptions](api/exceptions.md). -- Typed query predicates: `col()` wrapper and lambda predicate API on `Query.where`, `Relation.where`, and `Model.where` for static-typing-clean predicates without model annotation changes ([#48](https://github.com/syn54x/ferro-orm/pull/48)). See [Typed Query Predicates](concepts/query-typing.md). -- `FieldProxy` is now generic (`FieldProxy[T]`); operator overloads are typed `T | FieldProxy[T] -> QueryNode`, `.like()` is gated to `FieldProxy[str]`. -- New public symbols re-exported from `ferro.query`: `col`, `QueryProxy`, `Predicate`. -- Comprehensive documentation restructure -- Tutorial for new users -- How-to guides for common patterns -- Concept pages explaining architecture - -## Release History - -For the complete release history, see [GitHub Releases](https://github.com/syn54x/ferro-orm/releases). - -### Version Format - -- **Major** (X.0.0): Breaking changes -- **Minor** (0.X.0): New features, backwards compatible -- **Patch** (0.0.X): Bug fixes - -### Upgrade Guide - -When upgrading between major versions, see the migration guide in the release notes. - -## Reporting Issues - -Found a bug? [Report it on GitHub](https://github.com/syn54x/ferro-orm/issues). - -## Contributing - -See [Contributing Guide](contributing.md) for how to contribute to Ferro. diff --git a/docs/coming-soon.md b/docs/coming-soon.md deleted file mode 100644 index fb03236..0000000 --- a/docs/coming-soon.md +++ /dev/null @@ -1,484 +0,0 @@ -# Coming Soon - -This page lists features that are documented but not yet fully implemented in Ferro. These features are planned for future releases. - -!!! info "Work in Progress" - The features listed below are referenced in the documentation but are not currently available. Check back for updates or follow the [GitHub repository](https://github.com/syn54x/ferro-orm) for progress. - -## Query Features - -### Case-Insensitive LIKE (ilike) - -**Status:** Not Implemented - -**Documentation References:** -- `docs/guide/queries.md` (lines 248-249) - -**Description:** -Case-insensitive pattern matching with `ilike()` method. - -**Example (Not Working):** -```python -# This does not work yet -users = await User.where(User.email.ilike("%EXAMPLE.COM")).all() -``` - -**Workaround:** -Use standard `like()` with lowercase conversion or database-specific functions. - ---- - -### NOT IN Operator (not_in) - -**Status:** Not Implemented - -**Documentation References:** -- `docs/guide/queries.md` (lines 235-236) - -**Description:** -A `not_in_()` method for excluding values from a list. - -**Example (Not Working):** -```python -# This does not work yet -banned_users = await User.where(User.status.not_in_(["banned", "suspended"])).all() -``` - -**Workaround:** -Use negation with `&` and `!=` operators: -```python -banned_users = await User.where( - (User.status != "banned") & (User.status != "suspended") -).all() -``` - ---- - -### Raw SQL Queries - -**Status:** Implemented for `execute`, `fetch_all`, and `fetch_one` - -**Documentation References:** -- `docs/guide/queries.md` (lines 252-266) - -**Description:** -Direct raw SQL execution with parameterization is available. Raw rows are plain dictionaries of wire-close primitive values; typed model hydration still belongs to the ORM. - -**Example:** -```python -from ferro import execute, fetch_all - -results = await fetch_all( - "SELECT * FROM users WHERE age > $1 AND status = $2", - 18, - "active", - using="app", -) -``` - -Additional raw helpers beyond these functions remain out of scope. - ---- - -### Eager Loading / Prefetch Related - -**Status:** Not Implemented - -**Documentation References:** -- `docs/guide/queries.md` (lines 211-214, 318-321) - -**Description:** -Eager loading of relationships to avoid N+1 queries. - -**Example (Not Working):** -```python -# This does not work yet -posts = await Post.select().prefetch_related("author").all() -``` - -**Workaround:** -Manually load relationships as needed. Be mindful of N+1 query patterns. - ---- - -### Select Specific Fields - -**Status:** Not Implemented - -**Documentation References:** -- `docs/guide/queries.md` (lines 174-181) - -**Description:** -Loading only specific fields instead of all model fields. - -**Example (Not Working):** -```python -# This does not work yet -users = await User.select(User.id, User.username).all() -``` - -**Workaround:** -Load full models and access only the fields you need. - ---- - -### Aggregation Functions - -**Status:** Partially Implemented - -**Documentation References:** -- `docs/guide/queries.md` (lines 166-172) - -**Description:** -Only `.count()` is implemented. Other aggregations like `sum()`, `avg()`, `min()`, `max()` are not available. - -**Example (Partially Working):** -```python -# Works -total_users = await User.count() - -# Does NOT work yet -total_sales = await Order.sum(Order.amount) -avg_price = await Product.avg(Product.price) -``` - -**Workaround:** -Use `.count()` or load all records and compute aggregations in Python. - ---- - -### Atomic Field Updates - -**Status:** Not Implemented - -**Documentation References:** -- `docs/guide/mutations.md` (lines 129-139) - -**Description:** -Database-level atomic increment/decrement operations. - -**Example (Not Working):** -```python -# This does not work yet -await Post.where(Post.id == post_id).update( - view_count=Post.view_count + 1 -) -``` - -**Workaround:** -Load the instance, modify it, and save: -```python -post = await Post.where(Post.id == post_id).first() -if post: - post.view_count += 1 - await post.save() -``` - ---- - -## Database Connection Features - -### disconnect() - -**Status:** Not Implemented - -**Documentation References:** -- `docs/guide/database.md` (lines 217-232) - -**Description:** -Graceful database disconnection for shutdown hooks. - -**Example (Not Working):** -```python -# This does not work yet -await ferro.disconnect() -``` - -**Workaround:** -Connection cleanup is handled automatically on process exit. - ---- - -### check_connection() - -**Status:** Not Implemented - -**Documentation References:** -- `docs/guide/database.md` (lines 151-164) - -**Description:** -Health check function to verify database connectivity. - -**Example (Not Working):** -```python -# This does not work yet -from ferro import check_connection - -is_connected = await check_connection() -``` - -**Workaround:** -Attempt a simple query to verify connectivity: -```python -try: - await User.select().limit(1).all() - is_connected = True -except Exception: - is_connected = False -``` - ---- - -### connection_context() - -**Status:** Not Implemented - -**Documentation References:** -- `docs/guide/database.md` (lines 166-179) - -**Description:** -Request-scoped connection context manager. - -**Example (Not Working):** -```python -# This does not work yet -from ferro import connection_context - -async def handle_request(): - async with connection_context(): - user = await User.create(username="alice") - await Post.create(title="Hello", author=user) -``` - -**Workaround:** -Use `transaction()` context manager for scoped database operations. - ---- - -### Connection Pool Configuration - -**Status:** Partially Implemented - -**Documentation References:** -- `docs/guide/database.md` (lines 76-104) - -**Description:** -`PoolConfig(max_connections=..., min_connections=...)` is implemented per connection. Additional pool options like acquire timeout, idle timeout, max lifetime, and health-check toggles are still future work. - -**Example (Partially Working):** -```python -await ferro.connect( - "postgresql://user:password@localhost/dbname", - pool=ferro.PoolConfig(max_connections=20, min_connections=5), -) -``` - -For unsupported advanced pool options, use backend defaults. - ---- - -### Multiple Database Support - -**Status:** Implemented for explicit named connections - -**Documentation References:** -- `docs/guide/database.md` (lines 123-149) -- `docs/howto/multiple-databases.md` (entire file) - -**Description:** -Connecting to and querying multiple databases or roles with explicit named connections is supported. Automatic router policies, read/write splitting, and distributed transactions remain out of scope. - -**Example:** -```python -await ferro.connect("postgresql://localhost/main_db", name="primary", default=True) -await ferro.connect("postgresql://localhost/replica_db", name="replica") - -# Query specific database -users = await User.using("replica").all() -``` - ---- - -## Transaction Features - -### Nested Transactions / Savepoints - -**Status:** Not Implemented - -**Documentation References:** -- `docs/guide/transactions.md` (lines 91-106) - -**Description:** -True nested transaction support with savepoints. - -**Current Behavior:** -Nested `transaction()` blocks participate in the outermost transaction. - -**Example (Not Working as Described):** -```python -async with transaction(): # Outer transaction - await User.create(username="alice") - - async with transaction(): # Should be a savepoint, but isn't - await Post.create(title="Hello") - # Partial rollback not supported -``` - -**Workaround:** -Avoid nesting transactions. Structure code to use a single transaction scope. - ---- - -## Migration Features - -### Alembic Integration Details - -**Status:** Partially Documented - -**Documentation References:** -- `docs/guide/migrations.md` (entire file) - -**Description:** -The migration guide references `ferro.migrations.get_metadata()` and assumes full Alembic integration. - -**Verification Needed:** -```python -# Check if this import works -from ferro.migrations import get_metadata - -target_metadata = get_metadata() -``` - -**Note:** Verify that `ferro-orm[alembic]` installation provides the necessary migration bridge. - ---- - -## Model Features - -### Model.count() Class Method - -**Status:** Implemented, but Usage Unclear - -**Documentation References:** -- `docs/getting-started/tutorial.md` (line 135) - -**Description:** -The tutorial shows `await Post.count()` being called directly on the model class. - -**Verification:** -```python -# This should work -total_posts = await Post.select().count() - -# Check if this shorthand works -total_posts = await Post.count() -``` - ---- - -## Error Handling - -### Specific Exception Types - -**Status:** Not Confirmed - -**Documentation References:** -- `docs/guide/mutations.md` (lines 380-408) -- `docs/guide/database.md` (lines 266-276) - -**Description:** -Documentation references `IntegrityError`, `ValidationError`, and `ConnectionError` without imports. - -**Example (Import Path Unknown):** -```python -# Import path not documented -try: - await User.create(username="alice", email="duplicate@example.com") -except IntegrityError as e: # Where does this come from? - print(f"Integrity error: {e}") -``` - -**Clarification Needed:** -Document the exception hierarchy and import paths: -- Are these from `ferro` package? -- Re-exported from Pydantic? -- Database-driver specific? - ---- - -## Relationship Features - -### Many-to-Many Join Table Creation - -**Status:** Implemented - -Many-to-many join tables are registered alongside their models and created by -`auto_migrate=True` / `create_tables()` like any other table (they also -participate in `migrate_updates` diffing). Covered by -`tests/test_auto_migrate.py::test_m2m_join_table_created_during_auto_migrate`. - ---- - -### One-to-One Automatic Behavior - -**Status:** Documented but Verify - -**Documentation References:** -- `docs/guide/relationships.md` (lines 154-162) - -**Description:** -Documentation states that one-to-one reverse relations automatically return a single object instead of a Query. - -**Example (Verify Behavior):** -```python -class User(Model): - id: int - profile: "Profile" = BackRef() - -class Profile(Model): - id: int - user: Annotated[User, ForeignKey(related_name="profile", unique=True)] - -user = await User.where(User.id == 1).first() - -# Does this return Profile | None directly? -# Or does it return Query[Profile]? -profile = await user.profile -``` - -**Action:** Verify with tests that unique ForeignKey creates this behavior. - ---- - -## Summary - -### Definitely Not Implemented -1. `ilike()` - case-insensitive LIKE -2. `not_in_()` - NOT IN operator -3. Additional raw SQL helper APIs beyond `execute` / `fetch_all` / `fetch_one` -4. Eager loading (`prefetch_related`) -5. Select specific fields (partial model loading) -6. Aggregation functions (sum, avg, min, max) -7. Atomic field updates (e.g., `view_count + 1`) -8. `disconnect()` function -9. `check_connection()` function -10. `connection_context()` context manager -11. Additional connection pool parameters -12. Automatic routing policies for multiple databases -13. Nested transactions / savepoints - -### Needs Verification -1. `Model.count()` class method shorthand -2. Exception types and import paths -3. One-to-one automatic single object return -4. Alembic integration (ferro.migrations module) - -### Next Steps - -If you encounter any issues with documented features: - -1. **Check GitHub Issues**: [ferro-orm/issues](https://github.com/syn54x/ferro-orm/issues) -2. **Report Missing Features**: Open an issue if a documented feature doesn't work -3. **Use Workarounds**: See the workarounds provided above for each feature - -**Want to contribute?** Check the [Contributing Guide](contributing.md) to help implement these features. diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md deleted file mode 100644 index 6e4971d..0000000 --- a/docs/concepts/architecture.md +++ /dev/null @@ -1,332 +0,0 @@ -# Architecture - -Ferro's performance comes from its unique dual-layer architecture that moves expensive operations out of Python and into Rust. - -## High-Level Overview - -```mermaid -graph TB - subgraph python [Python Layer] - Models[Pydantic Models] - Metaclass[ModelMetaclass] - QueryBuilder[Query Builder] - end - - subgraph bridge [PyO3 FFI Bridge] - JSON[JSON Schema] - AST[Query AST] - end - - subgraph rust [Rust Engine] - Registry[Model Registry] - SeaQuery[Sea-Query] - SQLx[SQLx Driver] - end - - subgraph db [Database] - SQL[SQL Queries] - Rows[Row Data] - end - - Models -->|Register Schema| Metaclass - Metaclass -->|Serialize| JSON - JSON -->|FFI| Registry - - QueryBuilder -->|Build AST| AST - AST -->|FFI| SeaQuery - SeaQuery -->|Generate| SQL - SQL --> db - - db -->|Return| Rows - Rows --> SQLx - SQLx -->|Parse & Hydrate| bridge - bridge -->|Zero-Copy| Models -``` - -## The Layers - -### Python Layer - -**Responsibilities:** -- Model definition via Pydantic -- Query builder API -- Schema introspection -- Application logic - -**What stays in Python:** -- Class definitions -- Type annotations -- Business logic -- Query construction (not execution) - -### FFI Bridge (PyO3) - -**Responsibilities:** -- Type conversion (Python ↔ Rust) -- Memory management -- Error handling -- Async runtime integration - -**Data formats:** -- JSON schema (models → Rust), including Ferro-specific table-level keys such as `ferro_composite_uniques` and `ferro_composite_indexes` alongside per-column metadata in `properties` -- Query AST (filters, joins → Rust) -- Binary rows (Rust → Python) - -### Rust Engine - -**Responsibilities:** -- SQL generation (Sea-Query) -- Database connectivity (SQLx) -- Row parsing and hydration -- Connection pooling -- Identity map - -**Why Rust:** -- No GIL (parallel execution) -- Zero-cost abstractions -- Memory safety -- Performance - -## Query Lifecycle - -When you execute a query, here's what happens: - -```mermaid -sequenceDiagram - participant App as Application - participant QB as Query Builder - participant Rust as Rust Engine - participant DB as Database - - App->>QB: User.where(age > 18).all() - QB->>QB: Build filter AST - QB->>Rust: Send AST via FFI - Rust->>Rust: Generate SQL with Sea-Query - Rust->>DB: Execute: SELECT * FROM users WHERE age > $1 - DB-->>Rust: Return rows - Rust->>Rust: Parse rows with SQLx - Rust->>Rust: Hydrate to memory layout - Rust-->>QB: Return via zero-copy - QB-->>App: List[User] instances -``` - -### Step-by-Step - -1. **Query Construction** (Python) - ```python - query = User.where(User.age > 18) - ``` - - Pure Python, no database interaction - - Builds filter AST in memory - -2. **Execution Trigger** (Python → Rust) - ```python - users = await query.all() - ``` - - `.all()` triggers FFI call - - AST serialized to JSON - - Sent to Rust engine - -3. **SQL Generation** (Rust) - ```rust - // Sea-Query generates parameterized SQL - "SELECT * FROM users WHERE age > $1" - ``` - - Sea-Query builds SQL AST - - Generates database-specific SQL - - Parameters bound safely - -4. **Query Execution** (Rust) - ```rust - // SQLx executes with connection pool - let rows = sqlx::query(&sql).bind(18).fetch_all(&pool).await?; - ``` - - SQLx manages connections - - Async I/O (no GIL) - - Returns raw rows - -5. **Row Hydration** (Rust) - ```rust - // Parse rows into Pydantic-compatible layout - for row in rows { - let user = hydrate_user(&row)?; - results.push(user); - } - ``` - - Reads column values - - Converts types (SQL → Python) - - Allocates memory - -6. **Return to Python** (Rust → Python) - - Zero-copy transfer where possible - - Pydantic validates and wraps - - Returns `List[User]` - -## Model Registration - -When you define a model, Ferro registers it with the Rust engine: - -```mermaid -sequenceDiagram - participant Code as Your Code - participant Meta as ModelMetaclass - participant Rust as Rust Registry - participant DB as Database - - Code->>Meta: class User(Model): ... - Meta->>Meta: Inspect fields & constraints - Meta->>Meta: Build JSON schema - Meta->>Rust: Register model via FFI - Rust->>Rust: Store in MODEL_REGISTRY - - Note over Code,DB: Later, when connecting... - - Code->>Rust: connect(url, auto_migrate=True) - Rust->>Rust: Generate CREATE TABLE from registry - Rust->>DB: Execute DDL - DB-->>Rust: Success - Rust-->>Code: Connected -``` - -### Schema Example - -Python model: -```python -from ferro import Field, Model - -class User(Model): - id: int | None = Field(default=None, primary_key=True) - username: str = Field(unique=True) - email: str -``` - -JSON schema sent to Rust: -```json -{ - "name": "User", - "fields": [ - {"name": "id", "type": "Int", "primary_key": true}, - {"name": "username", "type": "String", "unique": true}, - {"name": "email", "type": "String"} - ] -} -``` - -Rust generates SQL: -```sql -CREATE TABLE users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL UNIQUE, - email TEXT NOT NULL -); -``` - -## Identity Map - -Ferro maintains an identity map in the Rust layer for object consistency: - -```mermaid -graph LR - Q1[Query 1: User.get 1] --> IM[Identity Map] - Q2[Query 2: User.get 1] --> IM - IM --> Same[Same Instance] -``` - -**Benefits:** -- Object consistency (same ID = same instance) -- Reduced hydration cost -- In-place updates visible everywhere - -See [Identity Map](identity-map.md) for details. - -## Why This Design? - -### Performance - -**Traditional ORM** (e.g., SQLAlchemy): -``` -SQL Generation (Python) → Row Parsing (Python) → Object Creation (Python) - ↑ ↑ - GIL held GIL held -``` - -**Ferro:** -``` -SQL Generation (Rust) → Row Parsing (Rust) → Object Creation (Rust) → Python - ↑ ↑ - No GIL Minimal Python -``` - -### Benchmarks - -Typical performance characteristics: - -| Operation | Traditional ORM | Ferro | Improvement | -|-----------|----------------|-------|-------------| -| Bulk Insert (1K rows) | 500ms | 20ms | **25x faster** | -| Complex Query | 100ms | 10ms | **10x faster** | -| Single Row Fetch | 5ms | 3ms | **1.7x faster** | - -(Exact numbers vary by database, hardware, and query complexity) - -## Memory Layout - -Ferro uses Pydantic's memory layout for compatibility: - -``` -┌─────────────────────────────────────┐ -│ Pydantic Instance │ -│ ┌─────────────────────────────┐ │ -│ │ Python Dict │ │ -│ │ {"id": 1, "name": "Alice"} │ │ -│ └─────────────────────────────┘ │ -└─────────────────────────────────────┘ - ↑ - │ Zero-copy injection - │ - ┌────────────┐ - │ Rust Buffer│ - └────────────┘ -``` - -Rust allocates memory, Python wraps it — minimal copying. - -## Async Architecture - -Ferro uses `tokio` (Rust async runtime) with `pyo3-asyncio` bridge: - -```python -# Python async -users = await User.all() - ↓ -# PyO3 async bridge - ↓ -# Rust async (tokio) -let users = query.fetch_all(&pool).await?; - ↓ -# Back to Python -return users -``` - -**Benefits:** -- True async (no sync wrappers) -- Efficient connection pooling -- Concurrent query execution - -## Trade-offs - -**Pros:** -- Extremely fast (10-100x for bulk ops) -- GIL-free I/O -- Memory efficient - -**Cons:** -- Complex to debug (crosses languages) -- Limited runtime introspection -- Rust compilation required for custom extensions - -## See Also - -- [Performance](performance.md) - Optimization techniques -- [Identity Map](identity-map.md) - Instance caching -- [Type Safety](type-safety.md) - Pydantic integration diff --git a/docs/concepts/identity-map.md b/docs/concepts/identity-map.md deleted file mode 100644 index 4fe789f..0000000 --- a/docs/concepts/identity-map.md +++ /dev/null @@ -1,285 +0,0 @@ -# Identity Map - -The Identity Map pattern ensures that within a single application process, the same database record always returns the same Python object instance. - -## How It Works - -```mermaid -sequenceDiagram - participant App - participant IdentityMap - participant DB - - App->>IdentityMap: User.get(1) - IdentityMap->>IdentityMap: Check cache for User:1 - IdentityMap->>DB: SELECT * FROM users WHERE id=1 - DB-->>IdentityMap: Row data - IdentityMap->>IdentityMap: Create User instance - IdentityMap->>IdentityMap: Cache User:1 → instance - IdentityMap-->>App: Return User instance - - App->>IdentityMap: User.get(1) again - IdentityMap->>IdentityMap: Found User:1 in cache - IdentityMap-->>App: Return SAME instance -``` - -## Benefits - -### Object Consistency - -```python -# Fetch user twice -user_a = await User.where(User.id == 1).first() -user_b = await User.get(1) - -# Same instance -assert user_a is user_b # True (same object in memory) - -# Modify one -user_a.username = "new_name" - -# Other reference sees the change -print(user_b.username) # "new_name" -``` - -### Performance - -Second fetch from cache is nearly free: -```python -# First fetch: database hit -user = await User.get(1) # ~3ms - -# Second fetch: cache hit -user = await User.get(1) # ~0.01ms (300x faster) -``` - -### In-Place Updates - -```python -# Fetch user in one part of code -user = await User.get(1) - -# Modify in another part -async def update_user(user_id): - u = await User.get(user_id) - u.email = "new@example.com" - await u.save() - -await update_user(1) - -# Original reference sees the change -print(user.email) # "new@example.com" -``` - -## Implementation - -Ferro's identity map is implemented in the Rust layer using `DashMap` (concurrent hash map): - -```rust -// Simplified representation -type IdentityMap = DashMap<(String, Value), Arc>; - -// Key: (model_name, primary_key) -// Value: Shared reference to instance -``` - -**Thread-safe:** Multiple async tasks can safely access the identity map concurrently. - -## Cache Behavior - -### When Objects are Cached - -- After `.get(pk)` -- After `.first()`, `.all()` queries -- After `.create()` -- After `.refresh()` - -### When Objects are NOT Cached - -- During bulk operations (`.bulk_create()`) -- After explicit eviction - -### Cache Lifetime - -Objects stay in cache until: -1. Application restarts -2. Explicit eviction (`ferro.evict_instance()`) -3. Memory pressure (future: LRU eviction) - -## Manual Eviction - -Force re-fetch from database: - -```python -from ferro import evict_instance - -# Evict user from cache -evict_instance("User", 1) - -# Next fetch will hit database -user = await User.get(1) -``` - -Use cases: -- External database changes -- Testing -- Memory management - -## Batch Operations and Identity Map - -### Regular Queries (cached) - -```python -users = await User.where(User.is_active == True).all() -# All users added to identity map - -# Second query returns same instances -users_again = await User.where(User.is_active == True).all() -assert users[0] is users_again[0] # Same object -``` - -### Bulk Operations (not cached) - -```python -users = [User(username=f"user_{i}") for i in range(1000)] -await User.bulk_create(users) - -# Bulk-created instances are NOT in identity map -# This is intentional for memory efficiency -``` - -## Memory Implications - -### Memory Usage - -Each cached instance consumes memory: -``` -Instance size ≈ 1-10 KB (depends on fields) -1000 cached instances ≈ 1-10 MB -``` - -For most applications, this is negligible. - -### Large Datasets - -For applications processing millions of records: - -```python -# Bad: Caches all 1M users -all_users = await User.all() # 1M instances cached! - -# Good: Process in batches, cache only active batch -async def process_users(): - page = 0 - per_page = 1000 - - while True: - users = await User.limit(per_page).offset(page * per_page).all() - if not users: - break - - for user in users: - await process(user) - - # Evict processed batch - for user in users: - evict_instance("User", user.id) - - page += 1 -``` - -## Consistency Guarantees - -### Within Process - -Identity map guarantees consistency within a single process: - -```python -# Process A -user = await User.get(1) -user.email = "new@example.com" -await user.save() - -# Elsewhere in Process A -user2 = await User.get(1) -print(user2.email) # "new@example.com" (same instance) -``` - -### Across Processes - -Identity map does NOT guarantee consistency across processes: - -```python -# Process A -user = await User.get(1) -user.email = "a@example.com" -await user.save() - -# Process B (separate application instance) -user = await User.get(1) -print(user.email) # "a@example.com" (reads from database) - -# But if Process A still has the instance cached: -# Process A's instance is NOT automatically updated if Process B changes it -``` - -For multi-process consistency, use database transactions and explicit refreshes. - -## Refresh from Database - -Force reload from database: - -```python -user = await User.get(1) - -# ... time passes, external changes ... - -# Refresh from database -await user.refresh() -print(user.email) # Updated from database -``` - -## Debugging Identity Map - -Check if instance is cached: - -```python -from ferro import is_cached - -# Check if User with ID=1 is cached -cached = is_cached("User", 1) -print(f"User 1 cached: {cached}") -``` - -Get cache statistics: - -```python -from ferro import cache_stats - -stats = cache_stats() -print(f"Cached instances: {stats['count']}") -print(f"Cache hits: {stats['hits']}") -print(f"Cache misses: {stats['misses']}") -``` - -## Best Practices - -1. **Don't worry about it** - Identity map works automatically -2. **Use `.refresh()`** when external changes are expected -3. **Evict in long-running batch jobs** to control memory -4. **Don't bypass** - always use Ferro's query API - -## Comparison with Other ORMs - -| ORM | Identity Map | -|-----|--------------| -| **Ferro** | ✅ Automatic, Rust-based | -| **SQLAlchemy** | ✅ Session-scoped | -| **Django ORM** | ❌ No identity map | -| **Tortoise ORM** | ✅ Automatic | - -## See Also - -- [Architecture](architecture.md) - How identity map fits in the system -- [Performance](performance.md) - Memory optimization -- [Queries](../guide/queries.md) - Query behavior with caching diff --git a/docs/concepts/performance.md b/docs/concepts/performance.md deleted file mode 100644 index 0cf4085..0000000 --- a/docs/concepts/performance.md +++ /dev/null @@ -1,222 +0,0 @@ -# Performance - -Understanding where Ferro is fast, where to optimize, and how to get the best performance. - -## Where Ferro Excels - -### Bulk Operations - -Ferro's Rust engine shines with large datasets: - -```python -# Create 10,000 users -users = [User(username=f"user_{i}", email=f"user{i}@example.com") - for i in range(10000)] - -await User.bulk_create(users) -# Ferro: ~100-300ms -# Traditional Python ORM: ~5-10 seconds -``` - -**Why:** Rust handles serialization, parameter binding, and SQL generation without the GIL. - -### Complex Queries - -Multi-join queries with filtering: - -```python -posts = await Post.where( - (Post.published == True) & - (Post.author.username.like("a%")) & - (Post.created_at > cutoff_date) -).order_by(Post.views, "desc").limit(100).all() - -# Ferro: ~10-50ms -# Traditional ORM: ~50-200ms -``` - -**Why:** Sea-Query generates optimized SQL, SQLx parses rows efficiently. - -### Row Hydration - -Converting database rows to Python objects: - -```python -users = await User.all() # 1000 users - -# Ferro: ~20ms (Rust hydration) -# Traditional ORM: ~100-200ms (Python hydration) -``` - -**Why:** Rust parses rows and populates memory directly, Python just wraps the result. - -## Where Ferro is Similar - -### Single Row Operations - -```python -user = await User.get(1) -# Ferro: ~3ms -# Traditional ORM: ~3-5ms -``` - -**Why:** Network latency dominates. Both ORMs spend similar time waiting for the database. - -### Schema Introspection - -```python -from ferro import get_metadata -metadata = get_metadata() -# Similar speed to SQLAlchemy -``` - -**Why:** Schema introspection happens infrequently (mostly at startup). - -## Optimization Techniques - -### 1. Use Bulk Operations - -```python -# Slow (N queries) -for i in range(1000): - await User.create(username=f"user_{i}") - -# Fast (1 query) -users = [User(username=f"user_{i}") for i in range(1000)] -await User.bulk_create(users) -``` - -### 2. Use Batch Updates - -```python -# Slow (N queries) -users = await User.where(User.is_active == False).all() -for user in users: - user.status = "archived" - await user.save() - -# Fast (1 query) -await User.where(User.is_active == False).update(status="archived") -``` - -### 3. Index Frequently Filtered Fields - -```python -from datetime import datetime - -from ferro import Field, Model - -class User(Model): - email: str = Field(unique=True, index=True) - status: str = Field(index=True) - created_at: datetime = Field(index=True) -``` - -### 4. Use `.exists()` Instead of `.count()` - -```python -# Slow -if await User.where(User.email == email).count() > 0: - raise ValueError("Email taken") - -# Fast -if await User.where(User.email == email).exists(): - raise ValueError("Email taken") -``` - -### 5. Avoid N+1 Queries - -```python -# Bad (N+1 queries) -posts = await Post.all() # 1 query -for post in posts: - author = await post.author # N queries! - -# Good (prefetch if supported) -# Check your Ferro version for eager loading support -posts = await Post.select().prefetch_related("author").all() -``` - -### 6. Reuse a Long-Lived Connection - -```python -# Current API: connect once during startup and reuse it. -await ferro.connect("postgresql://localhost/db") -``` - -### 7. Keep Transactions Short - -```python -# Bad: Long transaction -async with transaction(): - users = await User.all() - for user in users: - await external_api_call(user) # Slow! - await user.save() - -# Good: Minimize transaction scope -users = await User.all() -for user in users: - await external_api_call(user) - async with transaction(): - await user.save() -``` - -## Profiling - -### Query Timing - -```python -import time - -start = time.time() -users = await User.where(User.is_active == True).all() -elapsed = time.time() - start - -print(f"Query took {elapsed*1000:.2f}ms") -``` - -### Enable SQL Logging - -```python -# Check your Ferro version for SQL logging configuration -import logging - -logging.basicConfig(level=logging.DEBUG) -# SQL queries will be logged -``` - -## Benchmarking - -Compare operations: - -```python -import asyncio -import time - -async def benchmark(): - # Bulk create - users = [User(username=f"user_{i}") for i in range(1000)] - - start = time.time() - await User.bulk_create(users) - print(f"Bulk create: {time.time() - start:.3f}s") - - # Query all - start = time.time() - all_users = await User.all() - print(f"Query all: {time.time() - start:.3f}s") - - # Update all - start = time.time() - await User.where(User.id > 0).update(is_active=True) - print(f"Batch update: {time.time() - start:.3f}s") - -asyncio.run(benchmark()) -``` - -## See Also - -- [Architecture](architecture.md) - How Ferro achieves performance -- [Queries](../guide/queries.md) - Query optimization -- [How-To: Pagination](../howto/pagination.md) - Efficient pagination diff --git a/docs/concepts/type-safety.md b/docs/concepts/type-safety.md deleted file mode 100644 index b2881df..0000000 --- a/docs/concepts/type-safety.md +++ /dev/null @@ -1,183 +0,0 @@ -# Type Safety - -Ferro provides comprehensive type safety through deep integration with Pydantic V2 and Python's type system. - -## Pydantic Integration - -Ferro models ARE Pydantic models: - -```python -from ferro import Model -from pydantic import BaseModel - -class User(Model): - username: str - age: int - -# User inherits from BaseModel -assert issubclass(User, BaseModel) # True - -# All Pydantic features work -user = User(username="alice", age=30) -print(user.model_dump()) # {"username": "alice", "age": 30} -print(user.model_dump_json()) # '{"username":"alice","age":30}' -``` - -## Runtime Validation - -Pydantic validates all data at runtime: - -```python -# Valid -user = User(username="alice", age=30) - -# Invalid: type error -try: - user = User(username="alice", age="thirty") -except ValidationError as e: - print(e) - # age: Input should be a valid integer - -# Invalid: missing required field -try: - user = User(username="alice") -except ValidationError as e: - print(e) - # age: Field required -``` - -## Static Type Checking - -Ferro provides full type hints for static analyzers (mypy, pyright, pylance): - -```python -from ferro import Model - -class User(Model): - username: str - age: int - -# Fetch by PK: definite model instance (raises ModelDoesNotExist if missing) -user: User = await User.get(1) - -# Optional PK lookup -maybe_user: User | None = await User.get_or_none(999) - -# Autocomplete works -user.username # ✓ Known attribute -user.invalid # ✗ Type error - -# Query results are typed -users: list[User] = await User.all() -first: User | None = await User.where(User.username == "alice").first() -``` - -## IDE Autocomplete - -Full IDE support with intelligent completions: - -```python -user = await User.get(1) - -# IDE suggests: username, age, save, delete, refresh, etc. -user. # - -# Query builder is typed -User.where( - User. # -) -``` - -## Query Predicates and the Type Checker - -Static checkers see your Pydantic annotations, not the `FieldProxy` instances Ferro's metaclass installs at class-creation time. That means `User.archived == False` is statically a `bool` even though it is a `QueryNode` at runtime, and Pyright or `ty` will flag it where `Query.where` expects a `QueryNode`. - -Ferro ships three predicate styles to address this without any model-annotation changes or type-checker plugins: - -```python -from ferro.query import col - -# Operator (unchanged) -await User.where(User.id == 1).all() - -# col() — runtime identity, statically narrows back to FieldProxy[T] -await User.where(col(User.archived) == False).all() - -# Lambda — receives a QueryProxy whose attributes return FieldProxy -await User.where(lambda t: t.archived == False).all() -``` - -See [Typed Query Predicates](query-typing.md) for the full discussion, including when to reach for each style and how they compose. - -## Field Type Validation - -Ferro validates field types match database types: - -```python -from datetime import datetime -from decimal import Decimal -from uuid import UUID - -class Order(Model): - id: UUID # Validated as UUID - amount: Decimal # Validated as Decimal - created_at: datetime # Validated as datetime -``` - -## Custom Validators - -Use Pydantic validators: - -```python -from pydantic import field_validator - -class User(Model): - username: str - email: str - - @field_validator('email') - @classmethod - def validate_email(cls, v: str) -> str: - if '@' not in v: - raise ValueError('Invalid email') - return v.lower() - -# Validation runs automatically -user = await User.create( - username="alice", - email="ALICE@EXAMPLE.COM" # Normalized to lowercase -) -``` - -## Type Coercion - -Pydantic coerces compatible types: - -```python -class User(Model): - age: int - score: float - -# Strings are coerced -user = User(age="30", score="95.5") -assert user.age == 30 # int -assert user.score == 95.5 # float -``` - -## Generic Types - -Ferro supports complex generic types: - -```python -from typing import Dict, List, Optional - -class User(Model): - tags: List[str] # List of strings - metadata: Dict[str, Any] # Dictionary - bio: Optional[str] = None # Nullable -``` - -## See Also - -- [Models & Fields](../guide/models-and-fields.md) -- [Architecture](architecture.md) diff --git a/docs/contributing.md b/docs/contributing.md deleted file mode 100644 index b1c358b..0000000 --- a/docs/contributing.md +++ /dev/null @@ -1,3 +0,0 @@ -# Contributing to Ferro - ---8<-- "CONTRIBUTING.md:contributing" diff --git a/docs/examples/multiple_databases.py b/docs/examples/multiple_databases.py new file mode 100644 index 0000000..538e406 --- /dev/null +++ b/docs/examples/multiple_databases.py @@ -0,0 +1,45 @@ +"""Runnable companion to the Multiple Databases how-to (docs/pages/howto/multiple-databases.md).""" + +import asyncio + +from ferro import Field, Model, connect, transaction + + +class Metric(Model): + id: int | None = Field(default=None, primary_key=True) + name: str + value: float = 0.0 + + +async def main() -> None: + # --8<-- [start:connect] + await connect("sqlite::memory:", name="app", default=True, auto_migrate=True) + await connect("sqlite::memory:", name="analytics", auto_migrate=True) + # --8<-- [end:connect] + + # --8<-- [start:routing] + # Writes go to the default ("app") connection unless routed + await Metric.create(name="signups", value=1) + + # Route reads and writes to a named connection with .using() + await Metric.using("analytics").create(name="page_views", value=100) + + app_metrics = await Metric.all() + analytics_metrics = await Metric.using("analytics").all() + # --8<-- [end:routing] + assert len(app_metrics) == 1 + assert len(analytics_metrics) == 1 + assert analytics_metrics[0].name == "page_views" + + # --8<-- [start:transaction] + async with transaction(using="analytics"): + await Metric.using("analytics").create(name="clicks", value=42) + # --8<-- [end:transaction] + assert len(await Metric.using("analytics").all()) == 2 + assert len(await Metric.all()) == 1 + + print("multiple_databases example ran successfully") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/examples/mutations.py b/docs/examples/mutations.py new file mode 100644 index 0000000..53e9dad --- /dev/null +++ b/docs/examples/mutations.py @@ -0,0 +1,49 @@ +"""Runnable companion to the Mutations guide (docs/pages/guide/mutations.md).""" + +import asyncio + +from ferro import Field, Model, connect + + +class Customer(Model): + id: int | None = Field(default=None, primary_key=True) + email: str = Field(unique=True) + name: str = "" + plan: str = "free" + + +async def main() -> None: + await connect("sqlite::memory:", auto_migrate=True) + + # --8<-- [start:get-or-create] + customer, created = await Customer.get_or_create( + email="alice@example.com", + defaults={"name": "Alice"}, + ) + assert created is True + + # Second call finds the existing row instead of inserting + same, created = await Customer.get_or_create(email="alice@example.com") + assert created is False and same.id == customer.id + # --8<-- [end:get-or-create] + + # --8<-- [start:update-or-create] + customer, created = await Customer.update_or_create( + email="alice@example.com", + defaults={"plan": "pro"}, + ) + assert created is False and customer.plan == "pro" + # --8<-- [end:update-or-create] + + # --8<-- [start:refresh] + # Reload an instance from the database, discarding local state + await Customer.where(lambda t: t.email == "alice@example.com").update(name="Alice L.") + await customer.refresh() + assert customer.name == "Alice L." + # --8<-- [end:refresh] + + print("mutations example ran successfully") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/examples/pagination.py b/docs/examples/pagination.py new file mode 100644 index 0000000..c5d2d5e --- /dev/null +++ b/docs/examples/pagination.py @@ -0,0 +1,47 @@ +"""Runnable companion to the Pagination how-to (docs/pages/howto/pagination.md).""" + +import asyncio + +from ferro import Field, Model, connect + + +class Article(Model): + id: int | None = Field(default=None, primary_key=True) + title: str + + +# --8<-- [start:offset] +async def get_page(page: int, per_page: int = 20) -> list[Article]: + return ( + await Article.select() + .order_by(Article.id) + .limit(per_page) + .offset((page - 1) * per_page) + .all() + ) +# --8<-- [end:offset] + + +# --8<-- [start:keyset] +async def get_after(after_id: int | None, limit: int = 20) -> list[Article]: + query = Article.select() if after_id is None else Article.where(lambda t: t.id > after_id) + return await query.order_by(Article.id).limit(limit).all() +# --8<-- [end:keyset] + + +async def main() -> None: + await connect("sqlite::memory:", auto_migrate=True) + await Article.bulk_create([Article(title=f"Article {i}") for i in range(1, 51)]) + + page_two = await get_page(page=2, per_page=10) + assert [a.title for a in page_two][:2] == ["Article 11", "Article 12"] + + first_batch = await get_after(after_id=None, limit=10) + next_batch = await get_after(after_id=first_batch[-1].id, limit=10) + assert next_batch[0].title == "Article 11" + + print("pagination example ran successfully") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/examples/predicates.py b/docs/examples/predicates.py new file mode 100644 index 0000000..e474490 --- /dev/null +++ b/docs/examples/predicates.py @@ -0,0 +1,91 @@ +"""Runnable companion to the Queries guide (docs/pages/guide/queries.md).""" + +import asyncio + +# --8<-- [start:setup] +from ferro import Field, Model, connect +from ferro.query import col + + +class User(Model): + id: int | None = Field(default=None, primary_key=True) + name: str + age: int + role: str = "member" + archived: bool = False +# --8<-- [end:setup] + + +async def main() -> None: + await connect("sqlite::memory:", auto_migrate=True) + await User.bulk_create( + [ + User(name="alice", age=34, role="admin"), + User(name="bob", age=19), + User(name="carol", age=42, archived=True), + User(name="dave", age=17), + ] + ) + + # --8<-- [start:filtering] + adults = await User.where(lambda t: t.age >= 18).all() + # --8<-- [end:filtering] + assert len(adults) == 3 + + # --8<-- [start:operator-style] + adults = await User.where(User.age >= 18).all() + # --8<-- [end:operator-style] + assert len(adults) == 3 + + # --8<-- [start:col-style] + active = await User.where(col(User.archived) == False).all() # noqa: E712 + # --8<-- [end:col-style] + assert len(active) == 3 + + # --8<-- [start:lambda-style] + admins = await User.where(lambda t: (t.role == "admin") & (t.archived == False)).all() # noqa: E712 + # --8<-- [end:lambda-style] + assert len(admins) == 1 + + # --8<-- [start:operators] + teens = await User.where(lambda t: (t.age >= 13) & (t.age <= 19)).all() + a_names = await User.where(lambda t: t.name.like("a%")).all() + staff = await User.where(lambda t: t.role.in_(["admin", "moderator"])).all() + # --8<-- [end:operators] + assert len(teens) == 2 + assert len(a_names) == 1 + assert len(staff) == 1 + + # --8<-- [start:combining] + # & is AND, | is OR — parenthesize each side + flagged = await User.where(lambda t: (t.age < 18) | (t.archived == True)).all() # noqa: E712 + + # Chained .where() calls also AND together + young_members = await User.where(lambda t: t.role == "member").where(lambda t: t.age < 21).all() + # --8<-- [end:combining] + assert len(flagged) == 2 + assert len(young_members) == 2 + + # --8<-- [start:ordering-slicing] + oldest_first = await User.select().order_by(User.age, "desc").all() + second_page = await User.select().order_by(User.id).limit(2).offset(2).all() + # --8<-- [end:ordering-slicing] + assert oldest_first[0].name == "carol" + assert len(second_page) == 2 + + # --8<-- [start:terminals] + everyone = await User.all() + first_admin = await User.where(lambda t: t.role == "admin").first() + headcount = await User.select().count() + any_minors = await User.where(lambda t: t.age < 18).exists() + # --8<-- [end:terminals] + assert len(everyone) == 4 + assert first_admin is not None + assert headcount == 4 + assert any_minors + + print("predicates example ran successfully") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/examples/predicates_annotated.py b/docs/examples/predicates_annotated.py new file mode 100644 index 0000000..79891ce --- /dev/null +++ b/docs/examples/predicates_annotated.py @@ -0,0 +1,36 @@ +"""Annotated-style companion to predicates.py (AGENTS.md I-8).""" + +import asyncio + +# --8<-- [start:setup] +from typing import Annotated + +from ferro import Field, Model, connect + + +class User(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + name: str + age: int + role: str = "member" + archived: bool = False +# --8<-- [end:setup] + + +async def main() -> None: + await connect("sqlite::memory:", auto_migrate=True) + await User.bulk_create( + [ + User(name="alice", age=34, role="admin"), + User(name="bob", age=19), + ] + ) + + adults = await User.where(lambda t: t.age >= 18).all() + assert len(adults) == 2 + + print("predicates_annotated example ran successfully") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/examples/quickstart.py b/docs/examples/quickstart.py new file mode 100644 index 0000000..c925e19 --- /dev/null +++ b/docs/examples/quickstart.py @@ -0,0 +1,111 @@ +"""Runnable companion to the Quickstart tutorial (docs/pages/getting-started/quickstart.md).""" + +import asyncio + +# --8<-- [start:models] +from datetime import datetime +from typing import Annotated + +from ferro import BackRef, Field, ForeignKey, Model, Relation, connect, transaction + + +class Author(Model): + id: int | None = Field(default=None, primary_key=True) + name: str + email: str = Field(unique=True) + posts: Relation[list["Post"]] = BackRef() + + +class Post(Model): + id: int | None = Field(default=None, primary_key=True) + title: str + body: str + published: bool = False + created_at: datetime = Field(default_factory=datetime.now) + author: Annotated[Author, ForeignKey(related_name="posts")] +# --8<-- [end:models] + + +async def main() -> None: + # --8<-- [start:connect] + await connect("sqlite::memory:", auto_migrate=True) + # --8<-- [end:connect] + + # --8<-- [start:create] + alice = await Author.create(name="Alice", email="alice@example.com") + + post = await Post.create( + title="Why Ferro is Fast", + body="Ferro hands SQL generation and row hydration to a Rust engine...", + published=True, + author=alice, + ) + + # Insert many rows in a single statement + await Post.bulk_create( + [ + Post(title="Async Patterns", body="...", published=True, author_id=alice.id), + Post(title="Unfinished Draft", body="...", author_id=alice.id), + ] + ) + # --8<-- [end:create] + assert post.id is not None + + # --8<-- [start:query] + # Fetch by primary key + same_post = await Post.get(post.id) + + # Filter, order, and slice + published = ( + await Post.where(lambda t: t.published == True) # noqa: E712 + .order_by(Post.created_at, "desc") + .limit(10) + .all() + ) + + # Aggregate terminals + total = await Post.select().count() + has_drafts = await Post.where(lambda t: t.published == False).exists() # noqa: E712 + # --8<-- [end:query] + assert same_post is post # identity map: same Python object + assert len(published) == 2 + assert total == 3 + assert has_drafts + + # --8<-- [start:relationships] + # Forward access: awaiting the foreign key loads the related instance + author = await same_post.author + + # Reverse access: the BackRef is a chainable query + alice_posts = await author.posts.where(lambda t: t.published == True).all() # noqa: E712 + # --8<-- [end:relationships] + assert author.name == "Alice" + assert len(alice_posts) == 2 + + # --8<-- [start:update-delete] + # Update one instance + post.title = "Why Ferro is *Really* Fast" + await post.save() + + # Update many rows at once + updated = await Post.where(lambda t: t.published == False).update(published=True) # noqa: E712 + + # Delete + deleted = await Post.where(lambda t: t.title == "Unfinished Draft").delete() + # --8<-- [end:update-delete] + assert updated == 1 + assert deleted == 1 + + # --8<-- [start:transaction] + async with transaction(): + bob = await Author.create(name="Bob", email="bob@example.com") + await Post.create(title="Hello", body="...", author=bob) + # Commits on success, rolls back if the block raises + # --8<-- [end:transaction] + assert await Author.select().count() == 2 + + print("quickstart example ran successfully") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/examples/quickstart_annotated.py b/docs/examples/quickstart_annotated.py new file mode 100644 index 0000000..74a7fd1 --- /dev/null +++ b/docs/examples/quickstart_annotated.py @@ -0,0 +1,48 @@ +"""Annotated-style companion to quickstart.py (AGENTS.md I-8). + +Same models as docs/examples/quickstart.py, declared with ``Annotated`` +metadata instead of assignment. Field options move into ``Annotated[...]``; +relationship declarations are identical in both styles. +""" + +import asyncio + +# --8<-- [start:models] +from datetime import datetime +from typing import Annotated + +from ferro import BackRef, Field, ForeignKey, Model, Relation, connect + + +class Author(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + name: str + email: Annotated[str, Field(unique=True)] + posts: Relation[list["Post"]] = BackRef() + + +class Post(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + title: str + body: str + published: bool = False + created_at: Annotated[datetime, Field(default_factory=datetime.now)] + author: Annotated[Author, ForeignKey(related_name="posts")] +# --8<-- [end:models] + + +async def main() -> None: + await connect("sqlite::memory:", auto_migrate=True) + + alice = await Author.create(name="Alice", email="alice@example.com") + post = await Post.create(title="Hello", body="...", published=True, author=alice) + + assert post.id is not None + assert (await post.author).email == "alice@example.com" + assert len(await alice.posts.where(lambda t: t.published == True).all()) == 1 # noqa: E712 + + print("quickstart_annotated example ran successfully") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/examples/raw_sql.py b/docs/examples/raw_sql.py new file mode 100644 index 0000000..5378fbb --- /dev/null +++ b/docs/examples/raw_sql.py @@ -0,0 +1,43 @@ +"""Runnable companion to the Raw SQL guide (docs/pages/guide/raw-sql.md).""" + +import asyncio + +from ferro import Field, Model, connect, execute, fetch_all, fetch_one, transaction + + +class Event(Model): + id: int | None = Field(default=None, primary_key=True) + kind: str + payload: str = "" + + +async def main() -> None: + await connect("sqlite::memory:", auto_migrate=True) + await Event.bulk_create( + [Event(kind="click"), Event(kind="click"), Event(kind="signup")] + ) + + # --8<-- [start:execute] + affected = await execute("UPDATE event SET payload = ? WHERE kind = ?", "{}", "click") + # --8<-- [end:execute] + assert affected == 2 + + # --8<-- [start:fetch] + rows = await fetch_all("SELECT kind, COUNT(*) AS n FROM event GROUP BY kind ORDER BY n DESC") + top = await fetch_one("SELECT kind FROM event GROUP BY kind ORDER BY COUNT(*) DESC LIMIT 1") + # --8<-- [end:fetch] + assert rows[0]["n"] == 2 + assert top is not None and top["kind"] == "click" + + # --8<-- [start:in-transaction] + async with transaction() as tx: + await tx.execute("DELETE FROM event WHERE kind = ?", "click") + remaining = await tx.fetch_all("SELECT * FROM event") + # --8<-- [end:in-transaction] + assert len(remaining) == 1 + + print("raw_sql example ran successfully") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/examples/relationships.py b/docs/examples/relationships.py new file mode 100644 index 0000000..b8c61f6 --- /dev/null +++ b/docs/examples/relationships.py @@ -0,0 +1,128 @@ +"""Runnable companion to the Relationships guide (docs/pages/guide/relationships.md).""" + +import asyncio +from typing import Annotated + +from ferro import BackRef, Field, ForeignKey, ManyToMany, Model, Relation, connect + + +# --8<-- [start:one-to-many] +class Team(Model): + id: int | None = Field(default=None, primary_key=True) + name: str + members: Relation[list["Player"]] = BackRef() + + +class Player(Model): + id: int | None = Field(default=None, primary_key=True) + name: str + team: Annotated[Team, ForeignKey(related_name="members")] +# --8<-- [end:one-to-many] + + +# --8<-- [start:one-to-one] +class User(Model): + id: int | None = Field(default=None, primary_key=True) + username: str + profile: "Profile" = BackRef() + + +class Profile(Model): + id: int | None = Field(default=None, primary_key=True) + bio: str + user: Annotated[User, ForeignKey(related_name="profile", unique=True)] +# --8<-- [end:one-to-one] + + +# --8<-- [start:many-to-many] +class Student(Model): + id: int | None = Field(default=None, primary_key=True) + name: str + courses: Relation[list["Course"]] = ManyToMany(related_name="students") + + +class Course(Model): + id: int | None = Field(default=None, primary_key=True) + title: str + students: Relation[list["Student"]] = BackRef() +# --8<-- [end:many-to-many] + + +# --8<-- [start:self-referential] +class Employee(Model): + id: int | None = Field(default=None, primary_key=True) + name: str + manager: Annotated["Employee", ForeignKey(related_name="reports", nullable=True)] = None + reports: Relation[list["Employee"]] = BackRef() +# --8<-- [end:self-referential] + + +# --8<-- [start:on-delete] +class Library(Model): + id: int | None = Field(default=None, primary_key=True) + name: str + documents: Relation[list["Document"]] = BackRef() + + +class Document(Model): + id: int | None = Field(default=None, primary_key=True) + title: str + library: Annotated[Library, ForeignKey(related_name="documents", on_delete="CASCADE")] +# --8<-- [end:on-delete] + + +async def main() -> None: + await connect("sqlite::memory:", auto_migrate=True) + + # --8<-- [start:one-to-many-usage] + team = await Team.create(name="Rustaceans") + crab = await Player.create(name="Ferris", team=team) + + # Forward: awaiting the FK field loads the related instance + assert (await crab.team).name == "Rustaceans" + + # The shadow column is available for direct reads and filters + assert crab.team_id == team.id + + # Reverse: the BackRef is a chainable query + roster = await team.members.order_by(Player.name).all() + # --8<-- [end:one-to-many-usage] + assert len(roster) == 1 + + # --8<-- [start:one-to-one-usage] + user = await User.create(username="alice") + await Profile.create(bio="Pythonista", user=user) + + profile = await user.profile # single instance, not a list + # --8<-- [end:one-to-one-usage] + assert profile.bio == "Pythonista" + + # --8<-- [start:m2m-usage] + sam = await Student.create(name="Sam") + rust101 = await Course.create(title="Rust 101") + python201 = await Course.create(title="Python 201") + + await sam.courses.add(rust101, python201) + assert len(await sam.courses.all()) == 2 + + # The reverse side works the same way + assert len(await rust101.students.all()) == 1 + + await sam.courses.remove(python201) + await sam.courses.clear() + # --8<-- [end:m2m-usage] + assert len(await sam.courses.all()) == 0 + + # --8<-- [start:self-referential-usage] + boss = await Employee.create(name="Grace") + dev = await Employee.create(name="Linus", manager=boss) + + assert (await dev.manager).name == "Grace" + assert len(await boss.reports.all()) == 1 + # --8<-- [end:self-referential-usage] + + print("relationships example ran successfully") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/examples/relationships_annotated.py b/docs/examples/relationships_annotated.py new file mode 100644 index 0000000..825dd85 --- /dev/null +++ b/docs/examples/relationships_annotated.py @@ -0,0 +1,108 @@ +"""Annotated-style companion to relationships.py (AGENTS.md I-8). + +Field options move into ``Annotated[...]``. The relationship declarations +themselves are identical in both styles: forward FKs are always +``Annotated[Target, ForeignKey(...)]`` and ``BackRef()``/``ManyToMany()`` +are always assignments. +""" + +import asyncio +from typing import Annotated + +from ferro import BackRef, Field, ForeignKey, ManyToMany, Model, Relation, connect + + +# --8<-- [start:one-to-many] +class Team(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + name: str + members: Relation[list["Player"]] = BackRef() + + +class Player(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + name: str + team: Annotated[Team, ForeignKey(related_name="members")] +# --8<-- [end:one-to-many] + + +# --8<-- [start:one-to-one] +class User(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + username: str + profile: "Profile" = BackRef() + + +class Profile(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + bio: str + user: Annotated[User, ForeignKey(related_name="profile", unique=True)] +# --8<-- [end:one-to-one] + + +# --8<-- [start:many-to-many] +class Student(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + name: str + courses: Relation[list["Course"]] = ManyToMany(related_name="students") + + +class Course(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + title: str + students: Relation[list["Student"]] = BackRef() +# --8<-- [end:many-to-many] + + +# --8<-- [start:self-referential] +class Employee(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + name: str + manager: Annotated["Employee", ForeignKey(related_name="reports", nullable=True)] = None + reports: Relation[list["Employee"]] = BackRef() +# --8<-- [end:self-referential] + + +# --8<-- [start:on-delete] +class Library(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + name: str + documents: Relation[list["Document"]] = BackRef() + + +class Document(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + title: str + library: Annotated[Library, ForeignKey(related_name="documents", on_delete="CASCADE")] +# --8<-- [end:on-delete] + + +async def main() -> None: + await connect("sqlite::memory:", auto_migrate=True) + + team = await Team.create(name="Rustaceans") + player = await Player.create(name="Ferris", team=team) + assert (await player.team).id == team.id + + user = await User.create(username="alice") + await Profile.create(bio="Pythonista", user=user) + assert (await user.profile).bio == "Pythonista" + + sam = await Student.create(name="Sam") + rust101 = await Course.create(title="Rust 101") + await sam.courses.add(rust101) + assert len(await sam.courses.all()) == 1 + + boss = await Employee.create(name="Grace") + dev = await Employee.create(name="Linus", manager=boss) + assert (await dev.manager).name == "Grace" + + lib = await Library.create(name="Main") + await Document.create(title="Charter", library=lib) + assert len(await lib.documents.all()) == 1 + + print("relationships_annotated example ran successfully") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/examples/soft_deletes.py b/docs/examples/soft_deletes.py new file mode 100644 index 0000000..1f4647c --- /dev/null +++ b/docs/examples/soft_deletes.py @@ -0,0 +1,61 @@ +"""Runnable companion to the Soft Deletes how-to (docs/pages/howto/soft-deletes.md).""" + +import asyncio + +# --8<-- [start:model] +from datetime import UTC, datetime + +from ferro import Field, Model, connect +from ferro.query import Query + + +class SoftDeleteMixin: + """Soft-delete behavior as a plain mixin. + + Declare ``is_deleted`` and ``deleted_at`` on each concrete model; + the mixin supplies the lifecycle methods. + """ + + async def soft_delete(self) -> None: + self.is_deleted = True + self.deleted_at = datetime.now(UTC) + await self.save() + + async def restore(self) -> None: + self.is_deleted = False + self.deleted_at = None + await self.save() + + @classmethod + def active(cls) -> Query: + return cls.where(lambda t: t.is_deleted == False) # noqa: E712 + + +class Invoice(SoftDeleteMixin, Model): + id: int | None = Field(default=None, primary_key=True) + number: str + is_deleted: bool = False + deleted_at: datetime | None = None +# --8<-- [end:model] + + +async def main() -> None: + await connect("sqlite::memory:", auto_migrate=True) + + # --8<-- [start:usage] + invoice = await Invoice.create(number="INV-001") + await Invoice.create(number="INV-002") + + await invoice.soft_delete() + assert await Invoice.active().count() == 1 + assert await Invoice.select().count() == 2 # row still exists + + await invoice.restore() + assert await Invoice.active().count() == 2 + # --8<-- [end:usage] + + print("soft_deletes example ran successfully") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/examples/soft_deletes_annotated.py b/docs/examples/soft_deletes_annotated.py new file mode 100644 index 0000000..9117d30 --- /dev/null +++ b/docs/examples/soft_deletes_annotated.py @@ -0,0 +1,58 @@ +"""Annotated-style companion to soft_deletes.py (AGENTS.md I-8).""" + +import asyncio + +# --8<-- [start:model] +from datetime import UTC, datetime +from typing import Annotated + +from ferro import Field, Model, connect +from ferro.query import Query + + +class SoftDeleteMixin: + """Soft-delete behavior as a plain mixin. + + Declare ``is_deleted`` and ``deleted_at`` on each concrete model; + the mixin supplies the lifecycle methods. + """ + + async def soft_delete(self) -> None: + self.is_deleted = True + self.deleted_at = datetime.now(UTC) + await self.save() + + async def restore(self) -> None: + self.is_deleted = False + self.deleted_at = None + await self.save() + + @classmethod + def active(cls) -> Query: + return cls.where(lambda t: t.is_deleted == False) # noqa: E712 + + +class Invoice(SoftDeleteMixin, Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + number: str + is_deleted: bool = False + deleted_at: datetime | None = None +# --8<-- [end:model] + + +async def main() -> None: + await connect("sqlite::memory:", auto_migrate=True) + + invoice = await Invoice.create(number="INV-001") + await invoice.soft_delete() + assert await Invoice.active().count() == 0 + assert await Invoice.select().count() == 1 + + await invoice.restore() + assert await Invoice.active().count() == 1 + + print("soft_deletes_annotated example ran successfully") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/examples/testing_conftest.py b/docs/examples/testing_conftest.py new file mode 100644 index 0000000..7528c31 --- /dev/null +++ b/docs/examples/testing_conftest.py @@ -0,0 +1,33 @@ +"""Example pytest fixtures for the Testing how-to (docs/pages/howto/testing.md). + +This file is snippeted into the docs; it is not meant to be executed directly. +""" + +# --8<-- [start:fixtures] +import pytest + +from ferro import connect, reset_engine, transaction + + +@pytest.fixture +async def db(): + """Fresh in-memory database per test.""" + await connect("sqlite::memory:", auto_migrate=True) + yield + reset_engine() + + +@pytest.fixture +async def db_transaction(db): + """Run a test inside a transaction sharing one connection.""" + async with transaction(): + yield +# --8<-- [end:fixtures] + + +def main() -> None: # pragma: no cover - import smoke check only + """No-op: fixtures are exercised by pytest, not by running this file.""" + + +if __name__ == "__main__": + main() diff --git a/docs/examples/timestamps.py b/docs/examples/timestamps.py new file mode 100644 index 0000000..16cadae --- /dev/null +++ b/docs/examples/timestamps.py @@ -0,0 +1,53 @@ +"""Runnable companion to the Timestamps how-to (docs/pages/howto/timestamps.md).""" + +import asyncio + +# --8<-- [start:model] +from datetime import UTC, datetime + +from ferro import Field, Model, connect + + +def utcnow() -> datetime: + return datetime.now(UTC) + + +class TimestampMixin: + """Touch ``updated_at`` on every save. + + A plain mixin (not a Model subclass): declare the timestamp fields on + each concrete model, and the mixin keeps them fresh. + """ + + async def save(self, **kwargs) -> None: + self.updated_at = utcnow() + await super().save(**kwargs) + + +class Note(TimestampMixin, Model): + id: int | None = Field(default=None, primary_key=True) + text: str + created_at: datetime = Field(default_factory=utcnow) + updated_at: datetime = Field(default_factory=utcnow) +# --8<-- [end:model] + + +async def main() -> None: + await connect("sqlite::memory:", auto_migrate=True) + + # --8<-- [start:usage] + note = await Note.create(text="first draft") + original = note.updated_at + + note.text = "second draft" + await note.save() + + assert note.updated_at > original + assert note.created_at <= note.updated_at + # --8<-- [end:usage] + + print("timestamps example ran successfully") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/examples/timestamps_annotated.py b/docs/examples/timestamps_annotated.py new file mode 100644 index 0000000..35e48ea --- /dev/null +++ b/docs/examples/timestamps_annotated.py @@ -0,0 +1,50 @@ +"""Annotated-style companion to timestamps.py (AGENTS.md I-8).""" + +import asyncio + +# --8<-- [start:model] +from datetime import UTC, datetime +from typing import Annotated + +from ferro import Field, Model, connect + + +def utcnow() -> datetime: + return datetime.now(UTC) + + +class TimestampMixin: + """Touch ``updated_at`` on every save. + + A plain mixin (not a Model subclass): declare the timestamp fields on + each concrete model, and the mixin keeps them fresh. + """ + + async def save(self, **kwargs) -> None: + self.updated_at = utcnow() + await super().save(**kwargs) + + +class Note(TimestampMixin, Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + text: str + created_at: Annotated[datetime, Field(default_factory=utcnow)] + updated_at: Annotated[datetime, Field(default_factory=utcnow)] +# --8<-- [end:model] + + +async def main() -> None: + await connect("sqlite::memory:", auto_migrate=True) + + note = await Note.create(text="first draft") + original = note.updated_at + + note.text = "second draft" + await note.save() + assert note.updated_at > original + + print("timestamps_annotated example ran successfully") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/examples/transactions.py b/docs/examples/transactions.py new file mode 100644 index 0000000..06dd0d3 --- /dev/null +++ b/docs/examples/transactions.py @@ -0,0 +1,58 @@ +"""Runnable companion to the Transactions guide (docs/pages/guide/transactions.md).""" + +import asyncio + +from ferro import Field, Model, connect, transaction + + +class Account(Model): + id: int | None = Field(default=None, primary_key=True) + owner: str + balance: int = 0 + + +async def main() -> None: + await connect("sqlite::memory:", auto_migrate=True) + + checking = await Account.create(owner="alice", balance=100) + savings = await Account.create(owner="alice", balance=0) + + # --8<-- [start:basic] + async with transaction(): + checking.balance -= 25 + await checking.save() + + savings.balance += 25 + await savings.save() + # Both writes commit together when the block exits cleanly + # --8<-- [end:basic] + await checking.refresh() + assert checking.balance == 75 + + # --8<-- [start:rollback] + try: + async with transaction(): + checking.balance -= 1000 + await checking.save() + raise RuntimeError("insufficient funds") + except RuntimeError: + pass + + await checking.refresh() + # The failed write was rolled back + assert checking.balance == 75 + # --8<-- [end:rollback] + + # --8<-- [start:handle] + async with transaction() as tx: + await Account.create(owner="bob") + # Raw SQL on the same connection, inside the same transaction + rows = await tx.fetch_all("SELECT COUNT(*) AS n FROM account") + # --8<-- [end:handle] + assert rows[0]["n"] == 3 + + print("transactions example ran successfully") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/faq.md b/docs/faq.md deleted file mode 100644 index b54d576..0000000 --- a/docs/faq.md +++ /dev/null @@ -1,262 +0,0 @@ -# Frequently Asked Questions - -## General - -### What is Ferro? - -Ferro is a high-performance async ORM for Python with a Rust engine. It provides Pydantic-native models with 10-100x faster bulk operations compared to traditional Python ORMs. - -### How does Ferro compare to SQLAlchemy? - -**Ferro:** -- Faster (Rust engine) -- Simpler API (Pydantic-based) -- Async-first -- Smaller ecosystem - -**SQLAlchemy:** -- More mature and battle-tested -- Larger ecosystem -- More flexible (multiple APIs) -- Steeper learning curve - -Choose Ferro for performance and simplicity. Choose SQLAlchemy for maturity and maximum flexibility. - -See [Why Ferro?](why-ferro.md) for detailed comparison. - -### Do I need to know Rust to use Ferro? - -**No.** Ferro is a pure Python API. The Rust engine is completely transparent. You write 100% Python code. - -You only need Rust if you want to: -- Build Ferro from source -- Contribute to the Rust engine -- Create custom extensions - -### Can I use Ferro with FastAPI? - -**Yes!** Ferro works great with FastAPI: - -```python -from fastapi import FastAPI -from ferro import connect -from myapp.models import User - -app = FastAPI() - -@app.on_event("startup") -async def startup(): - await connect("postgresql://localhost/db") - -@app.get("/users") -async def list_users(): - return await User.all() -``` - -### Can I use Ferro with Django? - -Ferro is a standalone ORM and doesn't integrate with Django's ORM system. You can use Ferro in a Django project as a separate database layer, but you'll lose Django admin, migrations, and other Django ORM features. - -For Django projects, we recommend using Django ORM. - -### Is Ferro production-ready? - -Ferro is suitable for production use, but consider: - -**Pros:** -- Fast and reliable -- Well-tested -- Active development - -**Cons:** -- Newer than SQLAlchemy/Django ORM -- Smaller community -- Fewer integrations - -We recommend thorough testing before deploying to production. - -## Performance - -### How much faster is Ferro? - -Typical improvements: -- Bulk inserts: **10-100x faster** -- Complex queries: **5-10x faster** -- Single row operations: **1.5-2x faster** - -Exact numbers depend on database, hardware, and workload. See [Performance](concepts/performance.md). - -### Will Ferro make my API faster? - -**Maybe.** Ferro helps most when: -- Processing large datasets -- Bulk operations -- Complex queries - -Ferro helps less when: -- Network latency dominates -- Business logic is the bottleneck -- Database is slow (Ferro can't fix slow queries) - -Profile your application to identify bottlenecks. - -### How do I benchmark Ferro vs other ORMs? - -```python -import time - -# Test bulk insert -users = [User(username=f"user_{i}") for i in range(10000)] - -start = time.time() -await User.bulk_create(users) -print(f"Ferro: {time.time() - start:.2f}s") - -# Compare with other ORM using same data -``` - -## Features - -### Does Ferro support migrations? - -**Yes!** Ferro integrates with Alembic for production migrations: - -```bash -pip install "ferro-orm[alembic]" -alembic init migrations -# Configure env.py -alembic revision --autogenerate -m "Initial" -alembic upgrade head -``` - -See [Schema Management](guide/migrations.md). - -### Does Ferro support raw SQL? - -Check your Ferro version's API for raw SQL support. Most versions provide an escape hatch for complex queries not supported by the query builder. - -### Does Ferro support multiple databases? - -Yes. Register each pool with a name and route explicitly with `using`: - -```python -await ferro.connect(APP_DATABASE_URL, name="app", default=True) -await ferro.connect(SERVICE_DATABASE_URL, name="service") - -users = await User.all() # app/default -jobs = await Job.using("service").all() -``` - -Ferro does not provide automatic routers, cross-database joins, or distributed transactions in v1. - -See [How-To: Multiple Databases](howto/multiple-databases.md). - -### Does Ferro support async? - -**Yes!** Ferro is async-first. All database operations are asynchronous: - -```python -users = await User.all() # Async -user = await User.create(username="alice") # Async -``` - -### Can I use Ferro with sync code? - -Ferro requires async/await. For sync code, use `asyncio.run()`: - -```python -import asyncio - -def sync_function(): - users = asyncio.run(User.all()) - return users -``` - -## Troubleshooting - -### Why is my query slow? - -Common causes: -1. Missing indexes -2. N+1 queries -3. Large result sets without pagination -4. Slow database -5. Network latency - -See [Performance](concepts/performance.md) for optimization tips. - -### How do I debug SQL queries? - -Enable SQL logging (check your version's API): - -```python -import logging -logging.basicConfig(level=logging.DEBUG) -# SQL queries will be logged -``` - -### Why am I getting IntegrityError? - -Common causes: -- Duplicate values in unique fields -- Missing required fields -- Foreign key violations -- Primary key conflicts - -Check the error message for details. - -### How do I reset the database? - -```python -# Reconnect to a fresh SQLite test database -await ferro.connect("sqlite::memory:", auto_migrate=True) -``` - -For persistent environments, use Alembic migrations: - -```bash -alembic downgrade base -alembic upgrade head -``` - -## Development - -### How do I contribute? - -See [Contributing](contributing.md) for guidelines. - -### Where do I report bugs? - -[GitHub Issues](https://github.com/syn54x/ferro-orm/issues) - -### Where do I ask questions? - -[GitHub Discussions](https://github.com/syn54x/ferro-orm/discussions) - -### Is there a Discord/Slack? - -Not yet. Use GitHub Discussions for now. - -## Migration - -### How do I migrate from SQLAlchemy? - -See [Migrating from SQLAlchemy](migration-sqlalchemy.md) for a detailed guide. - -### How do I migrate from Django ORM? - -Migration guide coming soon. Key differences: -- Replace Django models with Ferro models -- Use async/await -- Replace Django's migration system with Alembic - -### How do I migrate from Tortoise ORM? - -Ferro and Tortoise have similar APIs. Key changes: -- Replace Tortoise models with Ferro models -- Update relationship syntax -- Use Alembic instead of Aerich - -## Still Have Questions? - -Ask on [GitHub Discussions](https://github.com/syn54x/ferro-orm/discussions)! diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md deleted file mode 100644 index 6b202b3..0000000 --- a/docs/getting-started/installation.md +++ /dev/null @@ -1,86 +0,0 @@ -# Installation - -## Requirements - -- Python 3.10 or higher -- Supported platforms: macOS, Linux, Windows -- Database: SQLite or PostgreSQL - -## Install Ferro - -Ferro is distributed as pre-compiled wheels for all major platforms: - -```bash -# UV -uv add ferro-orm - -# Or pip -pip install ferro-orm -``` - -### With Migration Support - -For production use with Alembic migrations: - -```bash -uv add "ferro-orm[alembic]" -``` - -This installs Alembic and SQLAlchemy (used only for migration generation, not at runtime). - -## Database Drivers - -Ferro uses SQLx under the hood. SQLite and PostgreSQL support are built into Ferro's published packages, so no additional database-specific packages are required for those backends. - -### SQLite - -No additional setup needed. SQLite is embedded in Ferro. - -### PostgreSQL - -No additional setup needed. PostgreSQL support is built into Ferro. - - -## Optional Dependencies - -### Development Tools - -For running tests and linting: - -```bash -uv sync --group dev -``` - -This workspace group includes pytest, maturin, docs tooling, and other development dependencies used in this repository. - -## Building from Source - -!!! note - Most users don't need to build from source. Pre-compiled wheels are available for all common platforms. - -If you need to build from source (e.g., for an unsupported platform): - -**Requirements:** -- Rust 1.70 or higher -- Python development headers - -```bash -# Install Rust -curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh - -# Install maturin (Rust/Python build tool) -pip install maturin - -# Clone and build -git clone https://github.com/syn54x/ferro-orm.git -cd ferro-orm -maturin develop --release -``` - -Build time is typically 2-5 minutes depending on your machine. - -## Next Steps - -Ready to build your first Ferro application? - -[:octicons-arrow-right-24: Start the tutorial](tutorial.md){ .md-button .md-button--primary } diff --git a/docs/getting-started/next-steps.md b/docs/getting-started/next-steps.md deleted file mode 100644 index 74ac1c0..0000000 --- a/docs/getting-started/next-steps.md +++ /dev/null @@ -1,159 +0,0 @@ -# Next Steps - -Congratulations on completing the tutorial! You now have a solid foundation in Ferro. Here's where to go next based on your goals. - -## Learn by Use Case - -### Building an API - -If you're building a REST API with FastAPI, Starlette, or similar: - -1. **[Models & Fields](../guide/models-and-fields.md)** — Learn about all field types and constraints -2. **[Relationships](../guide/relationships.md)** — Master one-to-many, one-to-one, and many-to-many -3. **[Queries](../guide/queries.md)** — Advanced filtering, ordering, and pagination -4. **[How-To: Pagination](../howto/pagination.md)** — Implement efficient pagination -5. **[Transactions](../guide/transactions.md)** — Ensure data consistency - -### Data Processing - -If you're processing large datasets or building ETL pipelines: - -1. **[Mutations](../guide/mutations.md)** — Bulk operations for high throughput -2. **[Queries](../guide/queries.md)** — Efficient filtering and aggregation -3. **[Performance](../concepts/performance.md)** — Optimization techniques -4. **[Transactions](../guide/transactions.md)** — Atomic operations - -### Production Deployment - -If you're ready to deploy to production: - -1. **[Database Setup](../guide/database.md)** — Connection pooling and configuration -2. **[Schema Management](../guide/migrations.md)** — Alembic migrations workflow -3. **[How-To: Testing](../howto/testing.md)** — Build a comprehensive test suite -4. **[How-To: Multiple Databases](../howto/multiple-databases.md)** — Read replicas and sharding - -### Understanding Internals - -If you want to understand how Ferro works: - -1. **[Architecture](../concepts/architecture.md)** — The Rust bridge and data flow -2. **[Identity Map](../concepts/identity-map.md)** — Instance caching and consistency -3. **[Type Safety](../concepts/type-safety.md)** — Pydantic integration details -4. **[Performance](../concepts/performance.md)** — Where Ferro is fast and why - -## Common Patterns - -### Timestamps - -Add `created_at` and `updated_at` to all models: - -```python -from datetime import datetime -from ferro import Model, Field - -class BaseModel(Model): - created_at: datetime = Field(default_factory=datetime.now) - updated_at: datetime = Field(default_factory=datetime.now) - -class User(BaseModel): - username: str - email: str -``` - -[Learn more →](../howto/timestamps.md) - -### Soft Deletes - -Implement "soft delete" pattern: - -```python -class User(Model): - username: str - is_deleted: bool = False - deleted_at: datetime | None = None - -# Query only non-deleted -active_users = await User.where(User.is_deleted == False).all() -``` - -[Learn more →](../howto/soft-deletes.md) - -### Pagination - -Implement cursor-based pagination for large datasets: - -```python -def paginate_users(after_id: int | None = None, limit: int = 20): - query = User.select() - if after_id: - query = query.where(User.id > after_id) - return query.order_by(User.id).limit(limit) - -users = await paginate_users(after_id=100, limit=20).all() -``` - -[Learn more →](../howto/pagination.md) - -## Reference Material - -### API Reference - -Complete reference for all classes and methods: - -- [Model API](../api/model.md) -- [Query API](../api/query.md) -- [Field API](../api/fields.md) -- [Relationship API](../api/relationships.md) - -### User Guide - -In-depth guides for all features: - -- [Models & Fields](../guide/models-and-fields.md) -- [Relationships](../guide/relationships.md) -- [Queries](../guide/queries.md) -- [Mutations](../guide/mutations.md) -- [Transactions](../guide/transactions.md) -- [Database Setup](../guide/database.md) -- [Schema Management](../guide/migrations.md) - -## Get Help - -### Community - -- **GitHub Discussions**: Ask questions, share projects -- **Issues**: Report bugs or request features -- **Contributing**: Help improve Ferro - -[Join the community →](https://github.com/syn54x/ferro-orm/discussions) - -### FAQ - -Common questions and answers: - -- How does Ferro compare to SQLAlchemy? -- Do I need to know Rust? -- Can I use Ferro with FastAPI? -- Is Ferro production-ready? - -[Read the FAQ →](../faq.md) - -## Stay Updated - -- **GitHub**: Star [syn54x/ferro-orm](https://github.com/syn54x/ferro-orm) for updates -- **Changelog**: Track new features and fixes -- **Twitter**: Follow [@ferroorm](https://twitter.com/ferroorm) for announcements - -## Start Building - -The best way to learn is by building something real. Pick a project and dive in! - -Need inspiration? Here are some project ideas: - -- 📝 **Blog Platform** — Users, posts, comments, tags -- 🛒 **E-commerce API** — Products, orders, inventory -- 📊 **Analytics Dashboard** — Events, metrics, aggregations -- 💬 **Chat Application** — Users, messages, channels -- 🎫 **Ticket System** — Issues, comments, attachments - -Happy coding with Ferro! 🚀 diff --git a/docs/getting-started/tutorial.md b/docs/getting-started/tutorial.md deleted file mode 100644 index a323375..0000000 --- a/docs/getting-started/tutorial.md +++ /dev/null @@ -1,388 +0,0 @@ -# Tutorial: Build a Blog API - -In this tutorial, you'll build a simple blog API with Ferro in about 10 minutes. You'll learn how to: - -- Define models with relationships -- Connect to a database -- Create, query, update, and delete records -- Work with one-to-many relationships - -## Step 1: Install Ferro - -First, install Ferro: - -```bash -pip install ferro-orm -``` - -Create a new file called `blog.py`. - -## Step 2: Define Your Models - -Let's create a blog with users, posts, and comments: - -```python -# blog.py -import asyncio -from datetime import datetime -from typing import Annotated -from ferro import Model, Field, ForeignKey, BackRef, Relation, connect - -class User(Model): - id: int | None = Field(default=None, primary_key=True) - username: str = Field(unique=True) - email: str = Field(unique=True) - posts: Relation[list["Post"]] = BackRef() - comments: Relation[list["Comment"]] = BackRef() - -class Post(Model): - id: int | None = Field(default=None, primary_key=True) - title: str - content: str - published: bool = False - created_at: datetime = datetime.now() - author: Annotated[User, ForeignKey(related_name="posts")] - comments: Relation[list["Comment"]] = BackRef() - -class Comment(Model): - id: int | None = Field(default=None, primary_key=True) - text: str - created_at: datetime = datetime.now() - author: Annotated[User, ForeignKey(related_name="comments")] - post: Annotated[Post, ForeignKey(related_name="comments")] - -async def main(): - # We'll add code here - pass - -if __name__ == "__main__": - asyncio.run(main()) -``` - -**What you just did:** - -- Created three models: `User`, `Post`, and `Comment` -- Defined relationships: Users have posts and comments, posts have comments -- Used `BackRef` for the reverse side of relationships -- Set primary keys and unique constraints - -## Step 3: Connect to the Database - -Add the connection code to `main()`: - -```python -async def main(): - # Connect to SQLite with auto-migration - await connect("sqlite:blog.db?mode=rwc", auto_migrate=True) - print("✅ Connected to database") -``` - -Run it: - -```bash -python blog.py -``` - -Output: -``` -✅ Connected to database -``` - -**What happened:** - -- Ferro connected to a SQLite database (creates `blog.db` if it doesn't exist) -- `auto_migrate=True` automatically created all tables based on your models -- The Rust engine generated `CREATE TABLE` statements for all three models - -## Step 4: Create Some Data - -Let's add users, posts, and comments: - -```python -async def main(): - await connect("sqlite:blog.db?mode=rwc", auto_migrate=True) - - # Create users - alice = await User.create( - username="alice", - email="alice@example.com" - ) - bob = await User.create( - username="bob", - email="bob@example.com" - ) - print(f"✅ Created users: {alice.username}, {bob.username}") - - # Create posts - post1 = await Post.create( - title="Why Ferro is Fast", - content="Ferro uses a Rust engine for SQL generation...", - published=True, - author=alice - ) - post2 = await Post.create( - title="Getting Started with Async Python", - content="Async programming can be tricky...", - published=True, - author=alice - ) - draft = await Post.create( - title="Draft Post", - content="This is not published yet", - published=False, - author=bob - ) - print(f"✅ Created {await Post.select().count()} posts") - - # Create comments - comment1 = await Comment.create( - text="Great article!", - author=bob, - post=post1 - ) - comment2 = await Comment.create( - text="Thanks for sharing", - author=alice, - post=post1 - ) - print(f"✅ Created {await Comment.select().count()} comments") -``` - -Run it again: - -```bash -python blog.py -``` - -Output: -``` -✅ Connected to database -✅ Created users: alice, bob -✅ Created 3 posts -✅ Created 2 comments -``` - -**What you learned:** - -- `.create()` inserts a record and returns the model instance -- Foreign keys accept model instances (e.g., `author=alice`) -- `.count()` returns the total number of records - -## Step 5: Query Your Data - -Add query examples: - -```python -async def main(): - await connect("sqlite:blog.db?mode=rwc", auto_migrate=True) - - # ... (previous create code) ... - - # Query: Find all published posts - published = await Post.where(Post.published == True).all() - print(f"\n📚 Found {len(published)} published posts:") - for post in published: - print(f" - {post.title}") - - # Query: Find posts by a specific author - alice = await User.where(User.username == "alice").first() - alice_posts = await Post.where(Post.author_id == alice.id).all() - print(f"\n✍️ Alice wrote {len(alice_posts)} posts") - - # Query: Get a post with its author - post = await Post.where(Post.title.like("%Fast%")).first() - if post: - author = await post.author - print(f"\n📝 Post: '{post.title}' by {author.username}") - - # Query: Get comments for a post - post_comments = await post.comments.all() - print(f"💬 This post has {len(post_comments)} comments:") - for comment in post_comments: - comment_author = await comment.author - print(f" - {comment_author.username}: {comment.text}") -``` - -Run it: - -```bash -python blog.py -``` - -Output: -``` -✅ Connected to database -✅ Created users: alice, bob -✅ Created 3 posts -✅ Created 2 comments - -📚 Found 2 published posts: - - Why Ferro is Fast - - Getting Started with Async Python - -✍️ Alice wrote 2 posts - -📝 Post: 'Why Ferro is Fast' by alice -💬 This post has 2 comments: - - bob: Great article! - - alice: Thanks for sharing -``` - -**What you learned:** - -- `.where()` filters records with Python comparison operators -- `.all()` returns a list, `.first()` returns one or None -- `.like()` for pattern matching -- Access forward relationships with `await post.author` -- Access reverse relationships with `await post.comments.all()` - -## Step 6: Update Records - -Add update examples: - -```python -async def main(): - await connect("sqlite:blog.db?mode=rwc", auto_migrate=True) - - # ... (previous code) ... - - # Update: Publish Bob's draft - draft = await Post.where( - (Post.author_id == bob.id) & (Post.published == False) - ).first() - - if draft: - draft.published = True - await draft.save() - print(f"\n✅ Published draft: {draft.title}") - - # Batch update: Mark all posts as needing review - updated = await Post.where(Post.published == True).update( - title=Post.title + " [REVIEWED]" - ) - print(f"✅ Updated {updated} posts") -``` - -**What you learned:** - -- Update individual records with `.save()` -- Batch update with `.update()` (more efficient for multiple records) -- Combine filters with `&` (AND) and `|` (OR) - -## Step 7: Delete Records - -Add delete examples: - -```python -async def main(): - await connect("sqlite:blog.db?mode=rwc", auto_migrate=True) - - # ... (previous code) ... - - # Delete: Remove a specific comment - spam_comment = await Comment.where(Comment.text.like("%spam%")).first() - if spam_comment: - await spam_comment.delete() - print(f"\n🗑️ Deleted spam comment") - - # Batch delete: Remove all unpublished posts - deleted = await Post.where(Post.published == False).delete() - print(f"🗑️ Deleted {deleted} unpublished posts") -``` - -**What you learned:** - -- `.delete()` on an instance removes that record -- `.delete()` on a query removes all matching records -- Ferro handles cascade deletes based on foreign key constraints - -## Complete Code - -Here's the full tutorial code: - -```python -# blog.py -import asyncio -from datetime import datetime -from typing import Annotated -from ferro import Model, Field, ForeignKey, BackRef, Relation, connect - -class User(Model): - id: int | None = Field(default=None, primary_key=True) - username: str = Field(unique=True) - email: str = Field(unique=True) - posts: Relation[list["Post"]] = BackRef() - comments: Relation[list["Comment"]] = BackRef() - -class Post(Model): - id: int | None = Field(default=None, primary_key=True) - title: str - content: str - published: bool = False - created_at: datetime = datetime.now() - author: Annotated[User, ForeignKey(related_name="posts")] - comments: Relation[list["Comment"]] = BackRef() - -class Comment(Model): - id: int | None = Field(default=None, primary_key=True) - text: str - created_at: datetime = datetime.now() - author: Annotated[User, ForeignKey(related_name="comments")] - post: Annotated[Post, ForeignKey(related_name="comments")] - -async def main(): - # Connect - await connect("sqlite:blog.db?mode=rwc", auto_migrate=True) - - # Create - alice = await User.create(username="alice", email="alice@example.com") - bob = await User.create(username="bob", email="bob@example.com") - - post1 = await Post.create( - title="Why Ferro is Fast", - content="Ferro uses a Rust engine...", - published=True, - author=alice - ) - - await Comment.create(text="Great article!", author=bob, post=post1) - - # Query - published = await Post.where(Post.published == True).all() - print(f"Found {len(published)} published posts") - - # Relationships - post_author = await post1.author - print(f"Post by: {post_author.username}") - - author_posts = await alice.posts.all() - print(f"Alice has {len(author_posts)} posts") - -if __name__ == "__main__": - asyncio.run(main()) -``` - -## What You Learned - -In this tutorial, you learned: - -✅ How to define models with `Model` and type hints -✅ How to add constraints with `Field()` (assignment or `Annotated[..., Field(...)]`) -✅ How to create relationships with `ForeignKey` and `BackRef` -✅ How to connect to a database with `connect()` -✅ How to create records with `.create()` -✅ How to query with `.where()`, `.all()`, `.first()` -✅ How to update with `.save()` and `.update()` -✅ How to delete with `.delete()` -✅ How to access relationships with `await` - -## Next Steps - -Now that you understand the basics: - -- **[User Guide](../guide/models-and-fields.md)** — Deep dive into models, fields, and relationships -- **[Queries](../guide/queries.md)** — Learn advanced filtering, ordering, and aggregation -- **[How-To: Testing](../howto/testing.md)** — Set up a test suite for your Ferro app -- **[Migrations](../guide/migrations.md)** — Use Alembic for production schema management - -Happy coding! 🎉 diff --git a/docs/guide/backend.md b/docs/guide/backend.md deleted file mode 100644 index 99a2719..0000000 --- a/docs/guide/backend.md +++ /dev/null @@ -1,404 +0,0 @@ -# Backend Guide - -Ferro supports SQLite and PostgreSQL through one Python API and an explicit Rust backend layer. Application code still calls `connect()`, defines Pydantic-style models, and uses the query builder. The Rust core decides which typed SQLx driver, SeaQuery dialect, transaction connection, and value conversion rules apply for the active database. - -This guide starts with the user-facing behavior, then explains the implementation details that maintainers need when changing the backend. - -## What The Backend Is - -The backend is the runtime database engine behind Ferro's Python API. It owns: - -- the active database kind, currently SQLite or PostgreSQL -- the typed SQLx connection pool -- SQL execution and row materialization -- transaction-bound typed connections -- backend-specific SQL generation choices -- value binding and hydration rules - -The backend supports a registry of named connections. The common case still uses one default engine per process, while advanced applications can register multiple pools and route ORM, raw SQL, transaction, and schema operations with `using="name"`. - -## Supported Backends - -Ferro currently treats these URL schemes as first-class runtime targets: - -```python -await connect("sqlite:app.db?mode=rwc") -await connect("sqlite::memory:") -await connect("postgresql://user:password@localhost:5432/app") -await connect("postgres://user:password@localhost:5432/app") -``` - -Unsupported schemes fail during connection setup: - -```python -await connect("mysql://user:password@localhost/app") -# raises a connection error: supported schemes are sqlite, postgres, postgresql -``` - -The important implementation detail is that URL detection happens once during `connect()`. After that, the active `EngineHandle` carries the backend kind and typed pool, so operations do not need to rediscover the database from global state or URL strings. - -## Connection Lifecycle - -`ferro.connect()` is the public entry point. Internally, the Rust connection layer does four things: - -1. Splits Ferro-only query parameters from the database URL. -2. Classifies the backend from the URL scheme. -3. Creates a typed SQLx pool for that backend. -4. Registers an `Arc` under a connection name and optionally selects it as the default. - -SQLite uses `SqlitePoolOptions` and PostgreSQL uses `PgPoolOptions`. `PoolConfig(max_connections=..., min_connections=...)` is applied per named connection, so app-role and service-role pools can have different sizes. - -```text -connect(url, name, default, auto_migrate, pool) - -> split ferro_search_path - -> BackendKind::from_url(url) - -> connect typed pool - -> optionally create tables - -> store EngineHandle in the named registry - -> optionally update the default connection -``` - -Connection resolution is centralized. Explicit `using` wins outside a transaction; active transactions pin all work to their selected connection; instance methods prefer the instance's origin connection; unqualified calls then fall back to the selected default connection. - -### PostgreSQL Search Paths - -Ferro supports a private `ferro_search_path` URL parameter for test isolation: - -```python -await connect( - "postgresql://localhost/ferro?ferro_search_path=ferro_test_schema", - auto_migrate=True, -) -``` - -The parameter is removed before SQLx connects. If present, Ferro installs an `after_connect` hook that runs: - -```sql -SET search_path TO ferro_test_schema -``` - -Search path names must be ASCII alphanumeric or `_`. This keeps the test helper ergonomic without allowing arbitrary SQL in the connection URL. - -Use this when several test runs share one PostgreSQL database, but each test should see its own tables. Instead of creating and dropping a whole database for every test, create a temporary schema, connect with that schema as the search path, and let `auto_migrate=True` create the model tables there: - -```python -import uuid - -import psycopg -from ferro import connect, reset_engine - - -async def run_isolated_postgres_test(base_url: str): - schema_name = f"ferro_{uuid.uuid4().hex[:16]}" - - with psycopg.connect(base_url, autocommit=True) as conn: - conn.execute(f'CREATE SCHEMA "{schema_name}"') - - try: - await connect( - f"{base_url}?ferro_search_path={schema_name}", - auto_migrate=True, - ) - - # Test code now reads and writes tables in only this schema. - # A second test can use the same database with a different schema. - finally: - reset_engine() - with psycopg.connect(base_url, autocommit=True) as conn: - conn.execute(f'DROP SCHEMA IF EXISTS "{schema_name}" CASCADE') -``` - -This is how Ferro's PostgreSQL matrix keeps tests isolated while still supporting both local `pytest-postgresql` databases and externally managed databases such as Supabase. - -## Typed Engine Internals - -The core backend types live in `src/backend.rs`. - -```text -BackendKind - Sqlite - Postgres - -EngineHandle - backend: BackendKind - pool: BackendPool - -BackendPool - Sqlite(Arc) - Postgres(Arc) - -EngineConnection - Sqlite(PoolConnection) - Postgres(PoolConnection) -``` - -This replaced the old `sqlx::Any`-centered execution path. Instead of one generic pool that tries to behave like every database, Ferro stores exactly the pool it connected: - -- SQLite connections are executed through SQLx's SQLite driver. -- PostgreSQL connections are executed through SQLx's PostgreSQL driver. -- Transaction connections keep the same typed distinction. -- Backend dispatch is a small enum match at the boundary where SQL actually runs. - -This gives Ferro access to backend-specific SQLx behavior without making the Python API backend-specific. - -## Query And Mutation Execution - -Most ORM operations follow the same high-level pipeline: - -```text -Python Query / Model API - -> JSON query or mutation payload - -> Rust operation function - -> SeaQuery statement - -> backend-specific SQL builder - -> EngineBindValue list - -> EngineHandle or EngineConnection execution - -> EngineRow values - -> RustValue values - -> Python model instances -``` - -SeaQuery remains the SQL construction layer. The backend controls which SeaQuery builder lowers the statement: - -- SQLite uses `SqliteQueryBuilder` -- PostgreSQL uses `PostgresQueryBuilder` - -Bind values are converted into a backend-neutral Ferro enum before execution: - -```text -EngineBindValue - Bool - I64 - F64 - String - Bytes - Null -``` - -The backend then binds those values to the typed SQLx query. This keeps most operation code independent of SQLx's generic types, while still executing through real SQLite or PostgreSQL drivers. - -### Reads - -Read operations fetch typed rows through the engine, materialize each SQLx row into `EngineRow`, then convert the values into Ferro's internal `RustValue` representation. `RustValue` is the final GIL-free representation before Python objects are created. - -This split matters because database values are not the same as Python field values. For example: - -- a PostgreSQL `integer` may decode as `i32`, but Ferro model IDs use Python `int` -- PostgreSQL UUIDs are selected as text before becoming Python `uuid.UUID` -- Decimal values are selected as text before becoming Python `Decimal` -- JSON values are selected as text before becoming Python dicts or lists - -### Writes - -Create, update, relationship, and delete operations build SeaQuery statements and execute them through either: - -- the active `EngineHandle`, if no transaction is active -- the transaction's `EngineConnection`, if a transaction ID is present - -SQLite insert results can report `last_insert_rowid()`. PostgreSQL insert paths rely on explicit `RETURNING` where Ferro needs generated values. - -## Schema Metadata And DDL - -The backend depends on normalized schema metadata from Python. `src/ferro/schema_metadata.py` enriches Pydantic's JSON schema with Ferro-specific keys before Rust consumes it. - -Important metadata includes: - -- `primary_key` -- `autoincrement` -- `unique` -- `index` -- `foreign_key` -- `ferro_nullable` -- `format: "decimal"` -- `enum_type_name` - -That metadata is shared by: - -- Rust runtime DDL in `src/schema.rs` -- Alembic metadata generation in `src/ferro/migrations/alembic.py` -- query and mutation casting decisions in `src/operations.rs` -- relationship join-table generation in `src/ferro/relations/__init__.py` - -The goal is to make the Python schema the contract. Runtime DDL and Alembic may lower it differently, but they should not infer conflicting meanings from the same model. - -### Auto-Migration - -When `auto_migrate=True`, `connect()` creates the typed engine first, then asks Rust to create tables for all registered models. - -```python -await connect("sqlite:dev.db?mode=rwc", auto_migrate=True) -await connect("postgresql://localhost/ferro", auto_migrate=True) -``` - -Runtime DDL uses the active backend: - -- SQLite gets SQLite-compatible column definitions and index SQL. -- PostgreSQL gets PostgreSQL-compatible column definitions, native casts, and SQL syntax. - -## Type Handling Across SQLite And Postgres - -SQLite and PostgreSQL do not store or decode every logical type the same way. Ferro's backend layer aims to preserve the Python model contract while allowing backend-specific SQL where needed. - -### Integer Primary Keys - -SQLite autoincrement IDs come from `last_insert_rowid()`. PostgreSQL `SERIAL` / integer values may decode as `i32`; Ferro materializes them as `i64` and then Python `int`. - -### UUID - -UUIDs are a bridge-boundary type. They can appear as: - -- Python `uuid.UUID` -- JSON query payload strings -- SQL bind values -- PostgreSQL `uuid` columns -- SQLite text-like columns - -Ferro serializes UUIDs before JSON query payloads cross the Python/Rust boundary. For PostgreSQL SQL expressions, Ferro adds explicit `uuid` casts where SQLx or PostgreSQL would otherwise see text. Many-to-many add, remove, and clear operations use the same backend-aware cast path for UUID join-table columns. - -### Decimal - -Python `Decimal` fields are marked with `format: "decimal"` in schema metadata. PostgreSQL can use numeric storage, while SQLite remains more flexible. On reads, Ferro selects Decimal values as text when needed so Python can reconstruct an exact `Decimal`. - -### JSON Objects And Arrays - -Python `dict` and `list` fields are represented as JSON object or array schema types. PostgreSQL writes cast JSON strings to `json` so inserts and updates target native JSON columns correctly. Reads select JSON values as text when required, then parse them back into Python values. - -### Dates And Datetimes - -Temporal values cross the bridge as ISO strings and are reconstructed into Python `date` or `datetime` objects. PostgreSQL SQL generation applies explicit casts for temporal comparisons and nulls where needed. - -### Enums - -Enums are represented through schema metadata, including the enum type name. PostgreSQL-specific enum casts are applied where the column uses a native enum type. Portable text-like enum behavior remains available through the same Python model shape. - -## Transactions - -Transactions use the same typed backend model as normal operations. - -When a root transaction begins: - -```text -active EngineHandle - -> acquire typed pool connection - -> BEGIN - -> TransactionHandle::root(EngineConnection) -``` - -Nested transactions reuse the same typed connection and create savepoints: - -```text -parent TransactionConnection - -> SAVEPOINT sp_ - -> TransactionHandle::nested(parent_conn, savepoint_name) -``` - -The transaction registry stores a transaction ID mapped to: - -- a shared `Arc>` -- an optional savepoint name - -This means all operations inside a transaction execute on the same typed database connection. Commit and rollback dispatch through the `EngineConnection` enum, not through a generic SQLx connection. - -## Testing The Backend Matrix - -Backend correctness is tested with the same public API users call. Tests that should run on both databases use the backend matrix fixtures: - -```python -@pytest.mark.backend_matrix -async def test_create_and_fetch(db_url): - await connect(db_url, auto_migrate=True) - ... -``` - -Run the SQLite default suite: - -```bash -uv run pytest -q -``` - -Run the SQLite/PostgreSQL matrix: - -```bash -uv run pytest -m "backend_matrix or postgres_only" --db-backends=sqlite,postgres -q -``` - -Run only the PostgreSQL side: - -```bash -uv run pytest -m "backend_matrix or postgres_only" --db-backends=postgres -q -``` - -### Local PostgreSQL Provider - -The test harness supports local ephemeral PostgreSQL through `pytest-postgresql`. - -Install PostgreSQL server binaries, then force the local provider: - -```bash -brew install postgresql@16 -FERRO_POSTGRES_PROVIDER=local uv run pytest -m "backend_matrix or postgres_only" --db-backends=postgres -q -``` - -If `FERRO_POSTGRES_PROVIDER=local` is not set, tests prefer an external URL: - -1. `FERRO_POSTGRES_URL` -2. legacy `FERRO_SUPABASE_URL` -3. local `pytest-postgresql` fallback - -Each PostgreSQL test gets an isolated schema through `ferro_search_path`, so externally managed databases can still run isolated test cases. - -## How To Extend This Later - -The current backend design makes a future backend, such as MySQL, more approachable but not automatic. A new backend would need: - -1. A new `BackendKind` variant. -2. A typed SQLx pool and connection variant. -3. URL classification. -4. SeaQuery builder dispatch. -5. DDL type mapping in `src/schema.rs`. -6. bind and row materialization support in `src/backend.rs`. -7. schema-value casting rules in `src/operations.rs`. -8. backend-matrix test coverage. -9. docs that clearly state support level and known differences. - -Avoid adding a backend by sprinkling one-off branches through query, schema, and operation code. The maintainable path is to make the backend identity explicit first, then lower shared ORM semantics through that backend. - -## Troubleshooting And Gotchas - -### `Engine not initialized` - -You called a model or query method before `await connect(...)`. Importing models registers schema, but it does not connect to the database. - -### Unsupported URL scheme - -Only `sqlite:`, `postgres://`, and `postgresql://` are supported. MySQL is planned for later, not accepted by this backend. - -### PostgreSQL tests use the wrong database - -If `.env` contains `FERRO_POSTGRES_URL` or `FERRO_SUPABASE_URL`, the test harness will use it by default. Set `FERRO_POSTGRES_PROVIDER=local` to force `pytest-postgresql`. - -### Local PostgreSQL tests skip or fail to start - -`pytest-postgresql` needs server binaries such as `pg_ctl`, `postgres`, and `initdb` on `PATH`. On macOS with Homebrew, installing `postgresql@16` usually provides them. - -### UUID or Decimal values fail only on PostgreSQL - -Check whether the value crosses the Python/Rust boundary as JSON or as a direct PyO3 argument. Query payloads must serialize non-JSON-native Python values before `json.dumps`; direct relationship operations must preserve typed values long enough for backend-aware SQL casts. - -### Runtime DDL and Alembic disagree - -Start with schema metadata. If `ferro_nullable`, `format`, `primary_key`, or relationship metadata is missing from the normalized Python schema, Rust DDL and Alembic may lower the same model differently. Fix the metadata source before adding more backend-specific lowering rules. - -## Mental Model - -The shortest way to understand the backend is: - -```text -Python owns the model contract. -Rust owns execution. -SeaQuery owns SQL shape. -SQLx owns typed database I/O. -BackendKind decides which database-specific path is legal. -``` - -When changing backend behavior, preserve that separation. Put shared ORM meaning in schema/query metadata, then make the backend choose the correct SQLite or PostgreSQL lowering at the execution boundary. diff --git a/docs/guide/database.md b/docs/guide/database.md deleted file mode 100644 index 8fa42a9..0000000 --- a/docs/guide/database.md +++ /dev/null @@ -1,385 +0,0 @@ -# Database Setup - -Ferro requires an explicit connection to a database before any operations can be performed. Connectivity is managed by the high-performance Rust core using SQLx. - -## Establishing a Connection - -Use the `ferro.connect()` function to initialize the database engine. This is an asynchronous operation and must be awaited: - -```python -import ferro - -async def main(): - await ferro.connect("sqlite:example.db?mode=rwc") -``` - -## Connection Strings - -Ferro currently supports SQLite and PostgreSQL. The connection string format follows standard URL patterns: - -### SQLite - -```python -# File database -await ferro.connect("sqlite:path/to/database.db") - -# With create mode (recommended) -await ferro.connect("sqlite:example.db?mode=rwc") - -# In-memory database -await ferro.connect("sqlite::memory:") -``` - -**Modes:** - -- `rwc` - Read/Write/Create (creates database if it doesn't exist) -- `rw` - Read/Write (database must exist) -- `ro` - Read-only - -### PostgreSQL - -```python -# Basic connection -await ferro.connect("postgresql://user:password@localhost:5432/dbname") - -# With SSL -await ferro.connect("postgresql://user:password@localhost:5432/dbname?sslmode=require") - -# Development connection with auto-migrate -await ferro.connect( - "postgresql://user:password@localhost:5432/dbname", - auto_migrate=True, -) -``` - -### Supabase (managed PostgreSQL) - -[Supabase](https://supabase.com/) hosts PostgreSQL behind TLS. Ferro’s Rust driver stack connects with **Rustls** and the **webpki** CA bundle, so TLS to public Supabase endpoints works out of the box in published wheels and in normal `maturin` / `uv` builds from this repository. - -**Connection string** - -1. In the Supabase project dashboard, open **Project Settings → Database** and copy the URI (direct or pooler—use the string Supabase gives you for your client type). -2. Append TLS if it is not already present: - -```python -import os - -url = os.environ["DATABASE_URL"] -if "sslmode=" not in url: - sep = "&" if "?" in url else "?" - url = f"{url}{sep}sslmode=require" - -await ferro.connect(url) -``` - -Supabase’s pooler hostname often looks like `*.pooler.supabase.com`; the database name is usually `postgres`, and the username may include the project ref (for example `postgres.`). Prefer the **exact** URI from the dashboard so host, port, and user stay correct when Supabase changes defaults. - -**Secrets and shells** - -- Load the URI from an environment variable or secret manager—never commit it to git. -- Passwords can contain characters that shells treat specially (for example `$`). In POSIX shells, wrap the value in **single quotes** when exporting, or put the URL in a `.env` file read by your app instead of the shell. - -**Password characters in the URL** - -If you assemble the URI yourself, percent-encode reserved characters in the password (for example `%24` for `$`, `%5E` for `^`) per [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986#section-2.1) userinfo rules. Many drivers accept unencoded passwords until one character breaks parsing; encoding avoids surprises. - -## Connection Options - -### Named Connections - -Ferro can keep multiple active pools in one process. Unnamed `connect()` calls register and select the `"default"` connection. Named connections are explicit and only become the default when `default=True` is passed. - -```python -import os -import ferro - -await ferro.connect( - os.environ["APP_DATABASE_URL"], - name="app", - default=True, - pool=ferro.PoolConfig(max_connections=10, min_connections=1), -) -await ferro.connect( - os.environ["SERVICE_DATABASE_URL"], - name="service", - pool=ferro.PoolConfig(max_connections=3), -) - -# Default app role -users = await User.all() - -# Explicit service role -job = await Job.using("service").create(kind="backfill") -await ferro.execute("select run_internal_job(?)", job.id, using="service") -``` - -Use constants or trusted server-side code to choose `using` values. Do not bind connection names directly from request parameters, headers, GraphQL arguments, or other untrusted input. - -### Transaction Inheritance - -Transactions are bound to one connection. Operations inside the block inherit that connection; trying to switch to another connection inside the transaction raises. - -```python -async with ferro.transaction(using="service"): - await Job.create(kind="backfill") # runs on service - await ferro.execute("select set_config('role_context', ?, true)", "pipeline") -``` - -### Auto-Migration (Development) - -During development, automatically align the database schema with your models: - -```python -await ferro.connect("sqlite:dev.db?mode=rwc", auto_migrate=True) -``` - -`auto_migrate=True` creates missing tables (including many-to-many join tables) and never touches existing ones. Two opt-in flags extend it: - -```python -await ferro.connect( - "sqlite:dev.db?mode=rwc", - auto_migrate=True, - migrate_updates=True, # update existing tables to match the models - migrate_destructive=True, # also drop columns removed from the models -) -``` - -The flags form a ladder — `migrate_destructive` implies `migrate_updates`, which implies `auto_migrate` — so passing just the strongest flag you want is enough. - -#### Schema updates with `migrate_updates` - -When a model gains fields between releases, `migrate_updates=True` reconciles the live table on connect. What it can do is capability-relative per backend: - -| Change | SQLite | Postgres | -|---|---|---| -| Add missing column | ✅ `ADD COLUMN` | ✅ `ADD COLUMN` | -| Add missing column's index (`index=True`) | ✅ `CREATE INDEX` | ✅ `CREATE INDEX` | -| Add unique column (`unique=True`) | ✅ via explicit `uq_` unique index + warning | ✅ inline `UNIQUE` | -| Add foreign-key column | ✅ column only, no FK constraint + warning | ✅ column + FK constraint | -| Change column type | ⚠️ warning, no DDL (type affinity makes drift mostly cosmetic) | ✅ `ALTER COLUMN ... TYPE ... USING` cast | -| Change nullability | ⚠️ warning, no DDL | ✅ `SET NOT NULL` / `DROP NOT NULL` | -| Drop removed column (`migrate_destructive`) | ✅ dependency-aware | ✅ | -| Rename column/table, change primary key, drop table | ❌ never — [Alembic](migrations.md) territory | ❌ never | - -Rules to know: - -- **NOT NULL additions need a literal default.** Existing rows must be backfilled, so a new required field without a literal default fails the connect with a clear error. Make the field nullable, give it a default, or use Alembic. On Postgres the backfill default is dropped immediately after the add (a fresh `CREATE TABLE` carries no server default); SQLite cannot drop a column default, so it remains — harmless, and invisible to Alembic autogenerate's defaults. -- **Added columns reuse the exact `CREATE TABLE` DDL.** A database brought forward by `migrate_updates` is byte-identical to one created fresh, so `alembic revision --autogenerate` stays clean (this is pinned by the cross-emitter parity tests). -- **Destructive drops are dependency-aware and fail loudly.** Explicit indexes covering a dropped column are dropped first (they are orphaned anyway). Columns that are primary keys, enforced by UNIQUE/CHECK constraints, or referenced by other tables' foreign keys abort the migration with an error pointing at Alembic — nothing is skipped silently. Tables are never dropped. -- **Postgres native enum columns are left alone.** They only exist via Alembic, which remains their owner. -- **Postgres type changes take an exclusive lock** and fail the connect if existing data does not cast cleanly — acceptable for a development flag, but worth knowing. -- **The pool refreshes after any schema change.** No connection can serve a statement prepared against the pre-migration schema (on SQLite stale statements panic the sqlx worker and silently return zero rows; on Postgres they raise `cached plan must not change result type`), and identity-mapped instances hydrated before the migration are evicted so loads return fresh, complete objects. - -The same pass can be run explicitly on a live connection instead of at connect time: - -```python -await ferro.migrate() # create + update (default) -await ferro.migrate(destructive=True) # also drop removed columns -await ferro.migrate(using="service") # against a named connection -``` - -!!! danger "Production Warning" - `auto_migrate=True` and its extension flags are intended for development and local-first apps whose schema is still moving. For production, use [Alembic migrations](migrations.md) — renames, primary-key changes, and data transforms are deliberately out of auto-migrate's scope. - -## Manual Table Creation - -Create tables manually without `auto_migrate`: - -```python -import ferro - -async def main(): - # Connect without auto-migrate - await ferro.connect("sqlite::memory:") - - # Import models to register them - from myapp.models import User, Post, Comment - - # Create all tables on the default connection - await ferro.create_tables() -``` - -## Multiple Databases - -Use named connections for multiple databases, roles, or pools: - -```python -await ferro.connect(os.environ["APP_DATABASE_URL"], name="app", default=True) -await ferro.connect(os.environ["SERVICE_DATABASE_URL"], name="service") - -await ferro.create_tables(using="service") -service_users = await User.using("service").all() -``` - -Ferro does not provide automatic router policies, read/write splitting, distributed transactions, or cross-connection joins in v1. Route each operation explicitly when it should not use the default connection. - -### Supabase Role Guidance - -For Supabase/Postgres deployments, keep elevated service credentials server-side. Prefer least-privileged custom roles where possible, and avoid making a service-role connection the default in user-facing runtimes. - -Named connections isolate pools and roles, not per-request RLS/JWT/session context inside one shared pool. If you set Postgres session state, prefer transaction-local settings and keep the work inside `transaction(using=...)`. - -Service-origin objects can contain data unavailable to the app role. Treat them as elevated data and filter them deliberately before returning user-facing responses. - -## Health Checks - -!!! warning "Feature Not Implemented" - `check_connection()` is not yet available. See [Coming Soon](../coming-soon.md#check_connection) for workarounds. - -**Workaround:** - -```python -# Attempt a simple query to verify connectivity -try: - await User.select().limit(1).all() - is_connected = True -except Exception: - is_connected = False -``` - -## Connection Context - -!!! warning "Feature Not Implemented" - `connection_context()` is not yet available. See [Coming Soon](../coming-soon.md#connection_context) for more information. Use `transaction()` for scoped database operations. - -## Environment Variables - -Common pattern for configuration: - -```python -import os -from ferro import connect - -DATABASE_URL = os.getenv( - "DATABASE_URL", - "sqlite:dev.db?mode=rwc" # Default for development -) - -async def init_db(): - await connect( - DATABASE_URL, - auto_migrate=os.getenv("ENV") != "production" - ) -``` - -## Best Practices - -### Single Connection at Startup - -Connect once when your application starts: - -```python -# main.py -import ferro -from myapp.models import * # Import all models - -async def startup(): - await ferro.connect(DATABASE_URL) - print("Database connected") - -async def shutdown(): - # Graceful shutdown (manual cleanup if needed) - print("Database connection will be cleaned up on process exit") - -# FastAPI example -from fastapi import FastAPI - -app = FastAPI() - -@app.on_event("startup") -async def on_startup(): - await startup() - -@app.on_event("shutdown") -async def on_shutdown(): - await shutdown() -``` - -!!! note "disconnect() Not Available" - The `disconnect()` function is not yet implemented. Connection cleanup happens automatically on process exit. See [Coming Soon](../coming-soon.md#disconnect) for more information. - -### Use Long-Lived Pools - -For web applications, connect once at startup and reuse those pools: - -```python -await ferro.connect("postgresql://localhost/proddb", name="app", default=True) -``` - -### Separate Dev/Prod Configs - -```python -import os - -if os.getenv("ENV") == "production": - await ferro.connect("postgresql://prodhost/proddb") -else: - await ferro.connect( - "sqlite:dev.db?mode=rwc", - auto_migrate=True - ) -``` - -### Handle Connection Errors - -```python -# Connection errors will raise exceptions -try: - await ferro.connect("postgresql://localhost/dbname") -except Exception as e: - logger.error(f"Failed to connect: {e}") - sys.exit(1) -``` - -## Troubleshooting - -### Connection Refused - -```python -# Error: Connection refused at localhost:5432 -# Solution: Check database is running -# PostgreSQL: sudo service postgresql start -``` - -### Authentication Failed - -```python -# Error: password authentication failed -# Solution: Check username/password in connection string -await ferro.connect("postgresql://correct_user:correct_pass@localhost/dbname") -``` - -### Database Does Not Exist - -```python -# Error: database "dbname" does not exist -# Solution: Create database first -# PostgreSQL: createdb dbname -# Or use SQLite which auto-creates -``` - -### TLS / SSL errors (PostgreSQL, Supabase) - -```text -# Error: TLS upgrade required ... SQLx was built without TLS support -``` - -Ferro’s default build enables PostgreSQL TLS via SQLx (`tls-rustls-ring-webpki` in `Cargo.toml`). If you see the message above, you are using an extension built **without** that feature (for example a stripped-down local `cargo build`). Reinstall the published wheel or rebuild from this repo’s `Cargo.toml` without removing TLS features. - -If the server requires TLS but the URL omits it, add `?sslmode=require` (or `&sslmode=require` after other query parameters) as shown in the Supabase subsection above. - -### Unsupported connect() kwargs - -```python -# Example of kwargs Ferro does not currently accept: -# await ferro.connect("postgresql://localhost/dbname", max_connections=100) -``` - -If you need custom pool sizing or timeout controls today, Ferro does not expose them yet through `connect()`. - -## See Also - -- [Schema Management](migrations.md) - Alembic migrations -- [Transactions](transactions.md) - Connection affinity -- [How-To: Multiple Databases](../howto/multiple-databases.md) - Multi-database patterns -- [How-To: Testing](../howto/testing.md) - Test database setup diff --git a/docs/guide/migrations.md b/docs/guide/migrations.md deleted file mode 100644 index d0281de..0000000 --- a/docs/guide/migrations.md +++ /dev/null @@ -1,445 +0,0 @@ -# Schema Management - -Ferro integrates with **Alembic**, the industry-standard migration tool for Python, to provide robust and reliable schema management for production environments. - -## Why Alembic? - -Instead of reinventing migrations, Ferro leverages Alembic's battle-tested workflow. Ferro provides a bridge that translates your models into SQLAlchemy metadata, which Alembic uses to detect schema changes. - -## Installation - -Install Ferro with Alembic support: - -```bash -pip install "ferro-orm[alembic]" -``` - -This installs Alembic and SQLAlchemy (used only for migration generation, not at runtime). - -## Quick Start - -### 1. Initialize Alembic - -In your project root: - -```bash -alembic init migrations -``` - -This creates: -``` -your_project/ -├── migrations/ -│ ├── env.py -│ ├── script.py.mako -│ └── versions/ -└── alembic.ini -``` - -### 2. Configure env.py - -Edit `migrations/env.py` to connect Ferro models to Alembic: - -```python -# migrations/env.py -from ferro.migrations import get_metadata - -# Import all models to register them -from myapp.models import User, Post, Comment - -# Ferro generates SQLAlchemy metadata from registered models -target_metadata = get_metadata() - -# Rest of env.py remains unchanged -``` - -### Column nullability (`get_metadata()`) - -Ferro maps each model field to a SQLAlchemy `Column` with a `nullable` flag used by autogenerate. - -- With the default **`nullable="infer"`** on `FerroField`, `ForeignKey`, and `ferro.Field(...)`, the column is nullable if and only if the field’s **Python annotation** allows `None` (for example `T | None`). Having a default or `default_factory` does **not** by itself make a column nullable in the migration metadata. -- Shadow **`{name}_id`** foreign-key columns infer from the **forward relation** field’s annotation, not from the synthetic `*_id` field (which often uses `| None` for assignment convenience). -- `ForeignKey(..., on_delete="SET NULL")` implies a nullable shadow FK column unless you explicitly override it; `nullable=False` is rejected for that combination. -- Set **`nullable=False`** or **`nullable=True`** on `FerroField`, `ForeignKey`, or `ferro.Field(...)` to force NOT NULL or NULL when you intentionally diverge from the type (for example `int | None` for a type checker while keeping a NOT NULL column). - -Primary keys are always emitted as `nullable=False`. - -### 3. Generate Your First Migration - -```bash -alembic revision --autogenerate -m "Initial schema" -``` - -Alembic compares your models to the database and generates a migration script in `migrations/versions/`. - -### Composite uniques and Alembic - -When you declare `__ferro_composite_uniques__` on a model, Ferro’s `get_metadata()` bridge adds matching SQLAlchemy `UniqueConstraint` objects to the reflected `Table`. Autogenerated revisions will therefore include those constraints (and the same for default many-to-many join tables, which get a composite unique on the two FK columns). Review generated migrations as usual before applying them in production. - -The same applies to `__ferro_composite_indexes__`: autogen emits matching non-unique `sa.Index` objects, including the default reverse-direction index on M2M join tables (opt out per-relation with `ManyToMany(reverse_index=False)`). - -For a one-to-one forward relation, declare `ForeignKey(..., unique=True)` on the annotated field. Ferro’s `get_metadata()` maps that to `unique=True` on the shadow foreign-key column so autogenerate emits the same UNIQUE constraint as `connect(..., auto_migrate=True)` does at runtime. - -### 4. Review the Migration - -Open the generated file in `migrations/versions/xxxx_initial_schema.py`: - -```python -def upgrade(): - op.create_table('users', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('username', sa.String(), nullable=False), - sa.Column('email', sa.String(), nullable=False), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('username'), - sa.UniqueConstraint('email') - ) - # ... more tables - -def downgrade(): - op.drop_table('users') - # ... reverse operations -``` - -**Always review generated migrations** for correctness. - -### 5. Apply the Migration - -```bash -alembic upgrade head -``` - -Your database now matches your models. - -## Workflow - -The typical development workflow: - -1. **Modify models** in Python -2. **Generate migration**: `alembic revision --autogenerate -m "Description"` -3. **Review migration** in `migrations/versions/` -4. **Apply migration**: `alembic upgrade head` -5. **Commit migration** to version control - -## Common Operations - -### Check Current Version - -```bash -alembic current -``` - -### View Migration History - -```bash -alembic history --verbose -``` - -### Upgrade to Specific Version - -```bash -alembic upgrade - -# Examples -alembic upgrade +1 # Upgrade one version -alembic upgrade abc123 # Upgrade to specific revision -alembic upgrade head # Upgrade to latest -``` - -### Downgrade (Rollback) - -```bash -alembic downgrade -1 # Downgrade one version -alembic downgrade abc123 # Downgrade to specific revision -alembic downgrade base # Downgrade to empty database -``` - -### Create Empty Migration - -For custom SQL or data migrations: - -```bash -alembic revision -m "Add admin user" -``` - -Edit the generated file: - -```python -def upgrade(): - # Custom SQL - op.execute(""" - INSERT INTO users (username, email, role) - VALUES ('admin', 'admin@example.com', 'admin') - """) - -def downgrade(): - op.execute("DELETE FROM users WHERE username = 'admin'") -``` - -## Production Workflow - -### Development - -1. Develop features with models -2. Generate migrations -3. Test migrations locally -4. Commit migrations to git - -### Staging - -1. Pull latest code -2. Run `alembic upgrade head` -3. Test application - -### Production - -1. **Backup database** before migrations -2. Review migration scripts -3. Run migrations: - ```bash - alembic upgrade head - ``` -4. Monitor application - -### Rollback Strategy - -Keep rollback migrations tested: - -```bash -# Test upgrade -alembic upgrade head - -# Test downgrade -alembic downgrade -1 - -# Upgrade again -alembic upgrade head -``` - -## Precision Mapping - -Ferro's migration bridge ensures high fidelity between your models and the database: - -### Nullability - -```python -# Required field -username: str -# → NOT NULL column - -# Optional field -bio: str | None = None -# → NULL allowed -``` - -### Complex Types - -```python -from decimal import Decimal -from datetime import datetime -from uuid import UUID -from enum import Enum, StrEnum - -class UserRole(StrEnum): - USER = "user" - ADMIN = "admin" - -class User(Model): - # Maps to DECIMAL/NUMERIC - balance: Decimal - - # Maps to TIMESTAMP - created_at: datetime - - # Maps to UUID (or TEXT in SQLite) - id: UUID - - # Maps to a named ENUM on PostgreSQL (or VARCHAR + CHECK on SQLite) - role: UserRole - - # Maps to JSON/JSONB - metadata: dict -``` - -**Enums and PostgreSQL:** `get_metadata()` maps Python `Enum` / `StrEnum` fields to -`sqlalchemy.Enum` with an explicit type name derived from the enum class name -(lowercased), for example `UserRole` → `userrole`. Autogenerated Alembic -revisions can then compile on PostgreSQL, which rejects anonymous enum types. -Integer-valued enums use string labels in the database type (for example -`"1"`, `"2"`) so the column remains a string-backed enum. - -### Constraints - -```python -from ferro import Field, Model - -class Product(Model): - # PRIMARY KEY - id: int | None = Field(default=None, primary_key=True) - - # UNIQUE constraint - sku: str = Field(unique=True) - - # INDEX - category: str = Field(index=True) -``` - -### Foreign Keys - -```python -class Post(Model): - author: Annotated[User, ForeignKey(related_name="posts")] - # → FOREIGN KEY (author_id) REFERENCES users(id) - - # With cascade - author: Annotated[User, ForeignKey( - related_name="posts", - on_delete="CASCADE" - )] - # → FOREIGN KEY ... ON DELETE CASCADE -``` - -### Many-to-Many - -```python -class Student(Model): - courses: Relation[list["Course"]] = ManyToMany(related_name="students") - -# Automatically generates join table: -# CREATE TABLE student_courses ( -# student_id INT REFERENCES students(id), -# course_id INT REFERENCES courses(id), -# PRIMARY KEY (student_id, course_id) -# ) -``` - -## Data Migrations - -For migrations that modify data (not just schema): - -```bash -alembic revision -m "Migrate user roles" -``` - -```python -from alembic import op -import sqlalchemy as sa - -def upgrade(): - # Schema change - op.add_column('users', sa.Column('role', sa.String(), nullable=True)) - - # Data migration - connection = op.get_bind() - connection.execute( - "UPDATE users SET role = 'user' WHERE role IS NULL" - ) - - # Make non-nullable after populating - op.alter_column('users', 'role', nullable=False) - -def downgrade(): - op.drop_column('users', 'role') -``` - -## Zero-Downtime Migrations - -For production systems that can't tolerate downtime: - -### 1. Additive Changes First - -```python -# Step 1: Add new column (nullable) -def upgrade(): - op.add_column('users', sa.Column('new_email', sa.String(), nullable=True)) - -# Deploy application that writes to both old and new columns -# Wait for all instances to deploy - -# Step 2: Migrate data -def upgrade(): - connection = op.get_bind() - connection.execute("UPDATE users SET new_email = email WHERE new_email IS NULL") - -# Step 3: Make non-nullable, drop old column -def upgrade(): - op.alter_column('users', 'new_email', nullable=False) - op.drop_column('users', 'email') - op.alter_column('users', 'new_email', new_column_name='email') -``` - -### 2. Feature Flags - -Use feature flags to control when code uses new schema: - -```python -if feature_enabled("new_email_column"): - user.new_email = email -else: - user.email = email -``` - -## Troubleshooting - -### PostgreSQL ENUM type requires a name - -If you see `CompileError: PostgreSQL ENUM type requires a name` when applying a -migration, upgrade Ferro so `get_metadata()` emits named enum types, then -regenerate the revision. Older migrations can be fixed by adding `name=...` to -each `sa.Enum(...)` call (often matching the Python enum class name in -lowercase). - -### Migration Not Detected - -```python -# Ensure models are imported in env.py -from myapp.models import * # Import all models - -# Verify metadata generation -target_metadata = get_metadata() -print(target_metadata.tables) # Should list your tables -``` - -### Conflicting Migrations - -```bash -# Error: Multiple head revisions -# Solution: Merge migrations -alembic merge heads -m "Merge migrations" -``` - -### Manual Schema Changes - -```bash -# If you manually modified the database, stamp it -alembic stamp head -``` - -### Reset Migrations - -```bash -# Delete all migration files -rm migrations/versions/*.py - -# Drop all tables -# Then regenerate from scratch -alembic revision --autogenerate -m "Initial schema" -alembic upgrade head -``` - -## Best Practices - -1. **Always review** generated migrations -2. **Test migrations** locally before production -3. **Backup database** before running migrations -4. **Keep migrations small** and focused -5. **Don't edit** applied migrations (create new ones) -6. **Version control** all migration files -7. **Test rollback** (downgrade) functionality -8. **Use descriptive names** for migrations - -## See Also - -- [Database Setup](database.md) - Connection configuration -- [Models & Fields](models-and-fields.md) - Model definitions -- [How-To: Testing](../howto/testing.md) - Testing with migrations diff --git a/docs/guide/models-and-fields.md b/docs/guide/models-and-fields.md deleted file mode 100644 index 7a6cc21..0000000 --- a/docs/guide/models-and-fields.md +++ /dev/null @@ -1,272 +0,0 @@ -# Models & Fields - -Models are the central building blocks of Ferro. They define your data schema in Python and are automatically mapped to database tables by the Rust engine. - -## Defining a Model - -To create a model, inherit from `ferro.Model`. Models use standard Python type hints, leveraging Pydantic V2 for validation and serialization. - -### Basic model example - -```python -from ferro import Model - -class User(Model): - id: int - username: str - is_active: bool = True -``` - -## Field Types - -Ferro supports a wide range of Python types, automatically mapping them to the most efficient database types available in the Rust engine. - -| Python Type | Database Type (General) | Notes | -| :--- | :--- | :--- | -| `int` | `INTEGER` | | -| `str` | `TEXT` / `VARCHAR` | | -| `bool` | `BOOLEAN` / `INTEGER` | Stored as 0/1 in SQLite. | -| `float` | `DOUBLE` / `FLOAT` | | -| `datetime` | `DATETIME` / `TIMESTAMP` | Use `datetime.datetime` with timezone awareness. | -| `date` | `DATE` | Use `datetime.date`. | -| `UUID` | `UUID` / `TEXT` | Stored as a 36-character string if native UUID is unavailable. | -| `Decimal` | `NUMERIC` / `DECIMAL` | Use `decimal.Decimal` for high-precision financial data. | -| `bytes` | `BLOB` / `BYTEA` | Stored as binary data. | -| `Enum` | `ENUM` / `TEXT` | Python `enum.Enum` (typically string-backed). | -| `dict` / `list` | `JSON` / `JSONB` | Stored as JSON strings in SQLite. | - -## Field Constraints - -Use **`ferro.Field`** for database constraints (primary key, unique, index, and Pydantic validation). Pydantic merges `Field()` the same way whether you attach it on the right-hand side of `=` or inside `typing.Annotated[...]`; Ferro reads the resulting `FieldInfo` and does not require you to know internal details. - -**Recommended patterns (pick one and stay consistent in a project):** - -### Assignment pattern - -Put defaults and Ferro options on the **assignment** side (classic Pydantic model field): - -```python -from decimal import Decimal -from ferro import Field, Model - -class Product(Model): - sku: str = Field(primary_key=True) - slug: str = Field(unique=True, index=True) - name: str = Field(max_length=200, description="Display name") - price: Decimal = Field(ge=0, decimal_places=2) -``` - -### Annotation pattern - -Keep the **plain type** on the left and pass **`Field(...)`** as `Annotated` metadata (all defaults and DB flags live inside `Field`): - -```python -from typing import Annotated -from decimal import Decimal -from ferro import Field, Model - -class Product(Model): - sku: Annotated[str, Field(primary_key=True)] - slug: Annotated[str, Field(unique=True, index=True)] - name: Annotated[str, Field(max_length=200, description="Display name")] - price: Annotated[Decimal, Field(ge=0, decimal_places=2)] -``` - -### Advanced: `FerroField` in `Annotated` - -For a type-first style without going through `Field()`, you can attach **`FerroField(...)`** as metadata. This is equivalent for Ferro’s database flags; you lose the single-call surface that combines Pydantic validation kwargs on `Field`. - -```python -from typing import Annotated -from decimal import Decimal -from ferro import FerroField, Model - -class Product(Model): - sku: Annotated[str, FerroField(primary_key=True)] - slug: Annotated[str, FerroField(unique=True, index=True)] - price: Annotated[Decimal, FerroField(index=True)] -``` - -### Constraint parameters - -All of the above support the same database constraint parameters on `Field` / `FerroField`: - -| Parameter | Type | Default | Description | -| :--- | :--- | :--- | :--- | -| `primary_key` | `bool` | `False` | Marks the field as the primary key for the table. | -| `autoincrement` | `bool \| None` | `None` | If `True`, the database generates values automatically. Defaults to `True` for integer primary keys. | -| `unique` | `bool` | `False` | Enforces a **single-column** uniqueness constraint on that column only. For uniqueness on a combination of columns, see [Composite unique constraints](#composite-unique-constraints) below. | -| `index` | `bool` | `False` | Creates a database index for this column to improve query performance. | -| `nullable` | `"infer" \| bool` | `"infer"` | Controls Alembic `Column.nullable` emitted by `get_metadata()`. `"infer"` follows whether the Python annotation allows `None`; `True` / `False` force NULL / NOT NULL. | - -#### Examples - -**Primary key:** - -```python -# Pydantic-style (preferred in docs) -id: int = Field(primary_key=True) -sku: str = Field(primary_key=True) # natural key -``` - -For the **`Annotated[..., Field(...)]`** form, see the [Annotation pattern](#annotation-pattern) above. - -**Autoincrement:** - -```python -# Autoincrement is implied for integer primary keys -id: int = Field(primary_key=True) - -# Explicit manual key (no autoincrement) -id: int = Field(primary_key=True, autoincrement=False) -``` - -**Unique constraints:** - -```python -# Pydantic-style (preferred in docs) -email: str = Field(unique=True) -slug: str = Field(unique=True, index=True) -``` - -### Composite unique constraints - -Sometimes a row should be unique **across several columns together** (for example one membership per `(user_id, org_id)` pair). That is a *composite* unique: in SQL this is typically expressed as `UNIQUE (user_id, org_id)` on the table, or an equivalent unique index on those columns. - -Ferro does **not** use per-column `Field(unique=True)` (assignment or `Annotated[..., Field(unique=True)]`) or `FerroField(unique=True)` for that case—`unique=True` is only for a **single** column. Instead, set the **`typing.ClassVar`** `__ferro_composite_uniques__` on your model (the base `Model` defines it as `()` so IDEs and type checkers know the hook exists; subclasses override when needed): - -```python -from typing import ClassVar -import uuid - -from ferro import Field, Model - -class OrgMembership(Model): - __ferro_composite_uniques__: ClassVar[tuple[tuple[str, ...], ...]] = ( - ("user_id", "org_id"), - ) - - id: uuid.UUID | None = Field(default=None, primary_key=True) - user_id: uuid.UUID = Field() - org_id: uuid.UUID = Field() -``` - -- Each inner tuple lists **database column names** as they appear in the generated schema (the same names as your Pydantic fields for scalar columns; for `ForeignKey("user")` use `user_id` in the tuple). -- You can list several groups for multiple composite uniques on one model. -- Invalid or unknown column names raise when the model is registered. - -**Null semantics (SQLite):** With the default local SQLite engine, `UNIQUE` treats `NULL` as distinct from other `NULL` values for multi-column constraints unless columns are `NOT NULL`. Ferro maps nullability from your types and defaults like other fields; optional composite columns can therefore allow multiple rows that differ only by `NULL` in a nullable column. Prefer `NOT NULL` on composite members when you need strict “at most one row per pair” semantics. Other databases can differ; consult your backend's documentation when you target PostgreSQL or another backend with different unique/null behavior. - -**Wire format:** Declarations use nested tuples in Python; the schema JSON sent to the Rust engine uses nested lists (`ferro_composite_uniques`) because JSON has no tuple type. - -**Many-to-many join tables:** When you use `ManyToMany(...)` without a custom `through` table, Ferro creates a default join table with two foreign-key columns. That table automatically gets a composite unique on those two columns so the same link cannot be stored twice. If you already have duplicate rows in such a table, adding this constraint in a migration may require a data cleanup step first. - -See also [Schema management / migrations](migrations.md) for how composite uniques appear in Alembic metadata. - -### Composite (non-unique) indexes - -For multi-column **non-unique** indexes — useful for read-path optimization on common filter combinations like `(tenant_id, role)` or `(user_id, created_at)` — declare a `typing.ClassVar` named `__ferro_composite_indexes__` as a tuple of tuples of column names. Validation rules mirror `__ferro_composite_uniques__`: each inner tuple must contain at least two columns, columns must exist on the model, and order is preserved (it matters for leftmost-prefix optimization). For single-column indexes, use `Field(index=True)`. - -```python -from typing import ClassVar -from datetime import datetime - -from ferro import Field, Model - -class Comment(Model): - __ferro_composite_indexes__: ClassVar[tuple[tuple[str, ...], ...]] = ( - ("user_id", "created_at"), - ) - - id: int | None = Field(default=None, primary_key=True) - user_id: int - created_at: datetime - body: str -``` - -**Index naming:** `idx___...`, capped at 63 characters with an `_idx` suffix on truncation (parallels `_uq` for composite uniques). - -**Out of scope:** Partial indexes (`WHERE ...`), expression indexes (e.g., `ON lower(email)`), and included columns (`INCLUDE (...)`) remain hand-edits to generated migrations. - -**Overlap with composite uniques:** Declaring the same ordered column tuple in both `__ferro_composite_uniques__` and `__ferro_composite_indexes__` emits a `UserWarning` at class-definition time and drops the redundant index entry (the unique constraint already provides an underlying index). Reordered tuples (`("a","b")` unique + `("b","a")` index) are kept as distinct, since they serve different leftmost-prefix queries. - -### Default many-to-many reverse-direction index - -By default, `ManyToMany(...)` injects a reverse-direction composite index on its synthesized join table — so queries from either side hit an index. To opt out (e.g., write-heavy join tables where the extra index cost is unwanted), pass `reverse_index=False`: - -```python -class Actor(Model): - movies: Relation[list["Movie"]] = ManyToMany( - related_name="actors", - reverse_index=False, - ) -``` - -The kwarg lives on the **forward** `ManyToMany(...)` declaration; passing it to `BackRef()` raises `TypeError`. - -**Indexes:** - -```python -# Pydantic-style (preferred in docs) -created_at: datetime = Field(index=True) -status: str = Field(index=True) -``` - -## Pydantic Validation - -With the **assignment** or **`Annotated[..., Field(...)]`** pattern, you can combine Ferro's database constraints with Pydantic's validation options in a single `Field()` call: - -```python -from ferro import Field, Model - -class User(Model): - username: str = Field( - unique=True, # Ferro: database constraint - min_length=3, # Pydantic: validation - max_length=50, - description="Public handle" - ) - age: int = Field(ge=0, le=150) - email: str = Field( - unique=True, - pattern=r'^[\w\.-]+@[\w\.-]+\.\w+$' - ) -``` - -All Pydantic `Field` parameters work as expected. See [Pydantic's Field documentation](https://docs.pydantic.dev/latest/api/fields/#pydantic.fields.Field) for the complete list. - -## Model Configuration - -Since Ferro models are Pydantic models, you can use the `model_config` attribute to control validation and serialization behaviors: - -```python -from pydantic import ConfigDict -from ferro import Model - -class Product(Model): - model_config = ConfigDict( - str_strip_whitespace=True, - validate_assignment=True, - extra='forbid' - ) - - sku: str - name: str -``` - -## Internal Mechanics - -Ferro uses a custom `ModelMetaclass` to bridge Python and Rust: - -1. **Schema Capture**: When you define a class, the metaclass inspects its fields and constraints. -2. **Rust Registration**: The schema is serialized to a JSON-AST (including Ferro-specific keys such as `ferro_composite_uniques` when declared) and passed to the Rust core's `MODEL_REGISTRY`. -3. **Table Generation**: When `auto_migrate=True` is used or `create_tables()` is called, the Rust engine generates the appropriate SQL `CREATE TABLE` statements. - -This architecture allows Ferro to leverage Rust's performance for SQL generation and row hydration while maintaining a pure Python interface. - -## See Also - -- [Relationships](relationships.md) - Foreign keys, one-to-many, many-to-many -- [Queries](queries.md) - Fetching and filtering data -- [Mutations](mutations.md) - Creating, updating, and deleting records -- [Identity Map](../concepts/identity-map.md) - Understanding instance caching diff --git a/docs/guide/mutations.md b/docs/guide/mutations.md deleted file mode 100644 index d3142ff..0000000 --- a/docs/guide/mutations.md +++ /dev/null @@ -1,478 +0,0 @@ -# Mutations - -Ferro provides efficient methods for creating, updating, and deleting records. All mutation operations are executed by the Rust engine for maximum performance. - -## Creating Records - -### Single Record - -Use `.create()` to insert a single record: - -```python -# Basic creation -user = await User.create( - username="alice", - email="alice@example.com", - is_active=True -) - -# Returns the created instance with populated fields (including generated IDs) -print(f"Created user ID: {user.id}") -``` - -### With Relationships - -Create records with foreign key relationships: - -```python -# Create author first -author = await User.create(username="bob", email="bob@example.com") - -# Create post with relationship -post = await Post.create( - title="My First Post", - content="Hello world!", - author=author # Pass the model instance -) - -# Or use the foreign key ID directly -post2 = await Post.create( - title="Second Post", - content="More content", - author_id=author.id # Use the shadow field -) -``` - -### Bulk Creation - -For inserting many records efficiently, use `.bulk_create()`: - -```python -# Create list of model instances -users = [ - User(username=f"user_{i}", email=f"user{i}@example.com") - for i in range(1000) -] - -# Insert all at once (single transaction) -await User.bulk_create(users) -``` - -**Performance benefits:** - -- Single round-trip to database -- Batched INSERT statements -- Significantly faster than looping with `.create()` - -!!! tip - For optimal performance with very large batches (>10K records), consider breaking into chunks of 1K-5K records each. - -### Default Values - -Fields with default values are handled automatically: - -```python -class User(Model): - username: str - is_active: bool = True # Default value - created_at: datetime = Field(default_factory=datetime.now) - -# Don't need to specify defaults -user = await User.create(username="charlie") -# user.is_active is True -# user.created_at is set to current time -``` - -## Updating Records - -### Instance-Level Updates - -Modify an instance and call `.save()`: - -```python -# Fetch a user -user = await User.where(User.username == "alice").first() - -# Modify fields -user.email = "alice.new@example.com" -user.is_active = False - -# Save changes -await user.save() -``` - -This generates an `UPDATE` statement for the modified record. - -### Batch Updates - -Update multiple records without loading them into memory: - -```python -# Update all inactive users -count = await User.where(User.is_active == False).update( - status="archived" -) -print(f"Updated {count} users") - -# Update with expressions (if supported) -await Product.where(Product.category == "Electronics").update( - price=Product.price * 0.9 # 10% discount -) -``` - -**Performance benefits:** - -- No model instantiation overhead -- Single UPDATE query -- Efficient for large batches - -### Atomic Operations - -!!! warning "Feature Not Implemented" - Atomic field increment/decrement operations are not yet available. See [Coming Soon](../coming-soon.md#atomic-field-updates) for workarounds. - -**Workaround:** -```python -# Load, modify, and save -post = await Post.where(Post.id == post_id).first() -if post: - post.view_count += 1 - await post.save() -``` - -### Updating Relationships - -Change foreign key relationships: - -```python -post = await Post.where(Post.id == 1).first() - -# Change the author -new_author = await User.where(User.username == "carol").first() -post.author = new_author -await post.save() - -# Or set the foreign key ID directly -post.author_id = new_author.id -await post.save() -``` - -## Deleting Records - -### Single Record - -Delete an instance: - -```python -user = await User.where(User.username == "alice").first() -await user.delete() -``` - -### Batch Delete - -Delete multiple records matching a query: - -```python -# Delete all inactive users -count = await User.where(User.is_active == False).delete() -print(f"Deleted {count} users") - -# Delete with multiple conditions -await Post.where( - (Post.published == False) & (Post.created_at < old_date) -).delete() -``` - -### Cascade Behavior - -Foreign key cascade behavior determines what happens to related records: - -```python -from ferro import ForeignKey - -# CASCADE (default): Delete related records -class Post(Model): - author: Annotated[User, ForeignKey(related_name="posts", on_delete="CASCADE")] - -# SET NULL: Set foreign key to NULL -class Post(Model): - author: Annotated[ - User | None, - ForeignKey(related_name="posts", on_delete="SET NULL") - ] = None - -# RESTRICT: Prevent deletion if related records exist -class Post(Model): - author: Annotated[User, ForeignKey(related_name="posts", on_delete="RESTRICT")] -``` - -Examples: - -```python -# CASCADE: Deleting user deletes all their posts -await user.delete() # Posts are deleted automatically - -# SET NULL: Deleting user sets post.author_id to NULL -await user.delete() # Posts remain, author_id becomes NULL - -# RESTRICT: Deleting user fails if they have posts -try: - await user.delete() -except Exception: # Use specific exception type from your driver - print("Cannot delete user with existing posts") -``` - -### Soft Deletes - -For a "soft delete" pattern (marking as deleted instead of removing): - -```python -class User(Model): - username: str - is_deleted: bool = False - deleted_at: datetime | None = None - -# Soft delete -user.is_deleted = True -user.deleted_at = datetime.now() -await user.save() - -# Query only non-deleted -active_users = await User.where(User.is_deleted == False).all() -``` - -See [How-To: Soft Deletes](../howto/soft-deletes.md) for full implementation patterns. - -## Many-to-Many Operations - -Many-to-many relationships have specialized mutators: - -### Adding Links - -```python -student = await Student.where(Student.name == "Alice").first() -math_course = await Course.where(Course.title == "Mathematics").first() -physics_course = await Course.where(Course.title == "Physics").first() - -# Add single relationship -await student.courses.add(math_course) - -# Add multiple relationships -await student.courses.add(math_course, physics_course) -``` - -### Removing Links - -```python -# Remove single relationship -await student.courses.remove(math_course) - -# Remove multiple relationships -await student.courses.remove(math_course, physics_course) -``` - -### Clearing All Links - -```python -# Remove all relationships for this student -await student.courses.clear() -``` - -## Transaction Safety - -All mutations are transaction-safe when used within a transaction context: - -```python -from ferro import transaction - -async with transaction(): - # Create user - user = await User.create(username="dave", email="dave@example.com") - - # Create posts - for i in range(3): - await Post.create( - title=f"Post {i}", - content=f"Content {i}", - author=user - ) - - # If any operation fails, all changes are rolled back -``` - -See [Transactions](transactions.md) for details. - -## Best Practices - -### Use Bulk Operations - -```python -# Bad (N queries) -for i in range(100): - await User.create(username=f"user_{i}", email=f"user{i}@example.com") - -# Good (1 query) -users = [ - User(username=f"user_{i}", email=f"user{i}@example.com") - for i in range(100) -] -await User.bulk_create(users) -``` - -### Avoid Unnecessary Saves - -```python -# Bad (2 database hits) -user = await User.create(username="alice", email="alice@example.com") -user.is_active = True -await user.save() - -# Good (1 database hit) -user = await User.create( - username="alice", - email="alice@example.com", - is_active=True -) -``` - -### Use Batch Updates for Multiple Records - -```python -# Bad (N queries) -users = await User.where(User.status == "pending").all() -for user in users: - user.status = "active" - await user.save() - -# Good (1 query) -count = await User.where(User.status == "pending").update(status="active") -``` - -### Check Cascade Behavior - -Always consider what happens to related records: - -```python -# Before deleting, check for related records -post_count = await author.posts.count() -if post_count > 0: - print(f"Warning: Deleting author will affect {post_count} posts") - -await author.delete() -``` - -### Validate Before Bulk Operations - -```python -# Validate all instances before bulk insert -users = [ - User(username=f"user_{i}", email=f"user{i}@example.com") - for i in range(100) -] - -# Pydantic validation happens automatically on model creation -# If any instance is invalid, an exception is raised before the database hit - -await User.bulk_create(users) -``` - -## Error Handling - -!!! note "Exception Types" - The documentation references exception types like `IntegrityError` and `ValidationError`. These exceptions come from the underlying database driver or Pydantic. Import paths may vary. Catch general `Exception` or check your specific database driver's exceptions. - -### Primary key lookup (`Model.get`) - -`Model.get(pk)` and `Model.using(...).get(pk)` raise [`ModelDoesNotExist`](../api/exceptions.md) when no row exists. Import it from `ferro`. For “maybe there is a row” flows (for example verifying a delete), use `get_or_none` instead of catching the exception. - -```python -from ferro import ModelDoesNotExist - -try: - user = await User.get(user_id) -except ModelDoesNotExist: - # e.model, e.pk - ... - -# Or, when None is the natural result: -user = await User.get_or_none(user_id) -``` - -### Unique Constraint Violations - -```python -try: - await User.create(username="alice", email="existing@example.com") -except Exception as e: # Use specific exception type from your driver - print(f"User with this email already exists: {e}") -``` - -### Foreign Key Violations - -```python -try: - await Post.create( - title="Orphan Post", - author_id=99999 # Non-existent user - ) -except Exception as e: # Use specific exception type from your driver - print(f"Invalid author ID: {e}") -``` - -### Not Null Violations - -```python -from pydantic import ValidationError - -try: - await User.create(username="bob") # Missing required 'email' -except ValidationError as e: - print(f"Validation failed: {e}") -``` - -## Performance Considerations - -### Bulk Operations are Fast - -Ferro's Rust engine optimizes bulk operations: - -- 1K inserts: ~10-50ms (vs 500-1000ms looping) -- 10K inserts: ~100-300ms (vs 5-10 seconds looping) - -### Batch Updates are Efficient - -Updating via query is much faster than loading instances: - -```python -# Slow: Loads 10K users into memory, updates each -users = await User.where(User.status == "old").all() # 10K users -for user in users: - user.status = "new" - await user.save() # 10K UPDATE queries - -# Fast: Single UPDATE query, no memory overhead -await User.where(User.status == "old").update(status="new") -``` - -### Identity Map Awareness - -Modified instances in the identity map are automatically synchronized: - -```python -# Fetch user (stored in identity map) -user = await User.where(User.id == 1).first() - -# Batch update -await User.where(User.id == 1).update(email="newemail@example.com") - -# The in-memory instance is NOT automatically updated -# Refresh if needed: -await user.refresh() -``` - -## See Also - -- [Queries](queries.md) - Fetching and filtering data -- [Transactions](transactions.md) - Atomic operations -- [Relationships](relationships.md) - Working with related records -- [How-To: Testing](../howto/testing.md) - Testing mutation operations diff --git a/docs/guide/queries.md b/docs/guide/queries.md deleted file mode 100644 index 096fc52..0000000 --- a/docs/guide/queries.md +++ /dev/null @@ -1,395 +0,0 @@ -# Queries - -Ferro provides a fluent, type-safe API for constructing and executing database queries. All queries are constructed in Python and executed by the high-performance Rust engine. - -## Fetch by primary key - -`Model.get(pk)` loads exactly one row by primary key and returns **your model type** (not `YourModel | None`). If no row exists, Ferro raises [`ModelDoesNotExist`](../api/exceptions.md), a subclass of `LookupError` with `.model` and `.pk` set—useful for HTTP 404s or structured logging. - -When a missing row is a normal outcome, use `Model.get_or_none(pk)`, which returns `YourModel | None` and never raises for “not found”. The same pair exists on [`Model.using("name")`](../howto/multiple-databases.md) for named connections. - -```python -from ferro import ModelDoesNotExist - -user = await User.get(42) - -try: - user = await User.get(client_supplied_id) -except ModelDoesNotExist: - ... # e.g. return 404 from your HTTP layer - -draft = await User.get_or_none(999) # None if no such row - -replica_view = await User.using("replica").get_or_none(1) -``` - -Lazy forward relations (e.g. `await post.author`) use optional fetch internally so a broken or missing FK still resolves to `None` instead of raising. - -## Basic Filtering - -Use standard Python comparison operators on model fields to create filter conditions: - -```python -# Equality -users = await User.where(User.is_active == True).all() - -# Comparison -adults = await User.where(User.age >= 18).all() -seniors = await User.where(User.age > 65).all() - -# String matching -alice_users = await User.where(User.name.like("Alice%")).all() -``` - -### Supported Operators - -| Operator | SQL Equivalent | Example | -|----------|----------------|---------| -| `==` | `=` | `User.status == "active"` | -| `!=` | `!=` or `<>` | `User.role != "admin"` | -| `>` | `>` | `User.age > 18` | -| `>=` | `>=` | `User.age >= 21` | -| `<` | `<` | `User.score < 100` | -| `<=` | `<=` | `User.score <= 50` | -| `.like()` | `LIKE` | `User.email.like("%@example.com")` | -| `.in_()` | `IN` | `User.status.in_(["active", "pending"])` | - -## Predicate Styles - -`where()` accepts three interchangeable predicate styles. They share one runtime path and one dispatcher, and you can mix them on a single chain. - -```python -from ferro.query import col - -# 1. Operator (the original) -await User.where(User.id == 1).all() - -# 2. col() wrapper — runtime identity, statically narrows to FieldProxy[T] -await User.where(col(User.archived) == False).all() - -# 3. Lambda predicate — receives a QueryProxy with FieldProxy attributes -await User.where(lambda t: t.archived == False).all() -``` - -The operator style works at runtime for every column type, but static type checkers (Pyright, `ty`) flag boolean and other value-type comparisons because they see your Pydantic annotations rather than the runtime `FieldProxy`. Reach for `col()` when one attribute trips the checker, or write new code with the lambda style to sidestep the issue entirely. - -!!! tip "When to use which" - - **Operator** — existing code that already type-checks; quick filters where the column type isn't `bool`. - - **`col()`** — one attribute on an existing chain trips your type checker and you want minimal diff. - - **Lambda** — new code, especially boolean comparisons or compound predicates. Recommended idiom going forward. - -See [Typed Query Predicates](../concepts/query-typing.md) for the full treatment, including combined-style chains and the deliberate scope boundaries. - -## Logical Operators - -Combine conditions with `&` (AND) and `|` (OR). **Always use parentheses** around each condition: - -```python -# AND -query = User.where((User.age > 21) & (User.status == "active")) - -# OR -query = User.where((User.role == "admin") | (User.role == "moderator")) - -# Complex: (age > 21 AND status == 'active') OR role == 'admin' -query = User.where( - ((User.age > 21) & (User.status == "active")) | (User.role == "admin") -) - -# NOT with != -inactive_users = await User.where(User.is_active != True).all() -``` - -The same compound expressions work inside lambda predicates — useful when you want the whole expression to type-check without `col()`: - -```python -admins = await User.where( - lambda t: (t.role == "admin") & (t.active == True) -).all() -``` - -## Chaining - -Methods can be chained to build complex queries incrementally: - -```python -results = await Product.select() \ - .where(Product.category == "Electronics") \ - .where(Product.price < 1000) \ - .order_by(Product.price, "desc") \ - .limit(10) \ - .offset(5) \ - .all() -``` - -Multiple `.where()` calls are combined with AND. - -## Ordering - -Sort results with `.order_by()`: - -```python -# Single field, ascending (default) -users = await User.order_by(User.created_at).all() - -# Single field, descending -users = await User.order_by(User.created_at, "desc").all() - -# Multiple fields -products = await Product.order_by(Product.category) \ - .order_by(Product.price, "desc") \ - .all() -``` - -## Limiting and Offsetting - -### Limit - -Restrict the number of results: - -```python -# Get first 10 users -users = await User.limit(10).all() - -# Get top 5 highest-scoring players -top_players = await Player.order_by(Player.score, "desc").limit(5).all() -``` - -### Offset - -Skip a number of results (useful for pagination): - -```python -# Skip first 10, get next 10 -users = await User.order_by(User.id).offset(10).limit(10).all() - -# Page 3 (20 per page): skip 40, take 20 -page_3 = await Product.offset(40).limit(20).all() -``` - -For better pagination patterns, see [How-To: Pagination](../howto/pagination.md). - -## Terminal Operations - -These methods execute the query and return results: - -### `.all()` - -Returns all matching records as a list: - -```python -all_users = await User.where(User.is_active == True).all() -# Returns: list[User] -``` - -### `.first()` - -Returns the first matching record or `None`: - -```python -admin = await User.where(User.role == "admin").first() -# Returns: User | None - -if admin: - print(f"Admin: {admin.username}") -``` - -### `.count()` - -Returns the total number of matching records: - -```python -active_count = await User.where(User.is_active == True).count() -# Returns: int - -print(f"Active users: {active_count}") -``` - -### `.exists()` - -Returns `True` if at least one matching record exists: - -```python -has_admin = await User.where(User.role == "admin").exists() -# Returns: bool - -if not has_admin: - print("Warning: No admin users found!") -``` - -!!! tip "Performance" - Use `.exists()` instead of `.count() > 0`. It's more efficient because the database can stop after finding the first match. - -## Aggregations - -Currently, only `.count()` is implemented: - -```python -# Count (implemented) -total_users = await User.where(User.active == True).count() -``` - -!!! warning "Feature Not Implemented" - Aggregation functions like `sum()`, `avg()`, `min()`, `max()` are not yet available. See [Coming Soon](../coming-soon.md#aggregation-functions) for more information. - -## Selecting Specific Fields - -!!! warning "Feature Not Implemented" - Selecting specific fields is not yet available. Ferro currently loads all model fields. See [Coming Soon](../coming-soon.md#select-specific-fields) for more information. - -## Working with Relationships - -### Forward Relations - -Access foreign keys: - -```python -post = await Post.where(Post.id == 1).first() -author = await post.author # Fetches the related User -``` - -### Reverse Relations - -Query the reverse side: - -```python -author = await User.where(User.username == "alice").first() - -# Get all posts by author -author_posts = await author.posts.all() - -# Filter reverse relation (any of the three predicate styles works) -published_posts = await author.posts.where(Post.published == True).all() -published_posts = await author.posts.where(lambda t: t.published == True).all() - -# Count reverse relation -post_count = await author.posts.count() -``` - -### Eager Loading - -!!! warning "Feature Not Implemented" - Eager loading with `prefetch_related()` is not yet available. See [Coming Soon](../coming-soon.md#eager-loading--prefetch-related) for current workarounds. - -## Advanced Filtering - -### NULL Checks - -```python -# Find records with NULL field -users_no_phone = await User.where(User.phone == None).all() - -# Find records with non-NULL field -users_with_phone = await User.where(User.phone != None).all() -``` - -### IN Queries - -```python -# Using .in_() -active_statuses = ["active", "pending", "verified"] -users = await User.where(User.status.in_(active_statuses)).all() -``` - -!!! warning "Feature Not Implemented" - The `not_in_()` method is not yet available. See [Coming Soon](../coming-soon.md#not-in-operator-not_in) for workarounds using `!=` with `&`. - -### LIKE Patterns - -```python -# Starts with -gmail_users = await User.where(User.email.like("%.gmail.com")).all() - -# Contains -smith_users = await User.where(User.name.like("%Smith%")).all() -``` - -!!! warning "Feature Not Implemented" - Case-insensitive `ilike()` is not yet available. See [Coming Soon](../coming-soon.md#case-insensitive-like-ilike) for workarounds. - -## Raw SQL - -Ferro exposes `execute`, `fetch_all`, and `fetch_one` for raw SQL escape hatches. Raw SQL uses backend-native placeholders and can route to named connections: - -```python -from ferro import execute, fetch_all - -await execute("select run_pipeline_job($1)", job_id, using="service") -rows = await fetch_all("select id, name from users where org_id = $1", org_id) -``` - -Inside `transaction(using="service")`, raw SQL inherits the transaction connection. See [Raw SQL](../api/raw-sql.md) for bind-type details and caveats. - -## Performance Tips - -### Use `.exists()` for Checks - -```python -# Bad (loads full count) -if await User.where(User.email == email).count() > 0: - raise ValueError("Email already exists") - -# Good (stops at first match) -if await User.where(User.email == email).exists(): - raise ValueError("Email already exists") -``` - -### Use Indexes - -Add indexes to frequently filtered fields: - -```python -from ferro import Field, Model - -class User(Model): - email: str = Field(unique=True, index=True) - status: str = Field(index=True) -``` - -### Batch Operations - -Use bulk methods instead of loops: - -```python -# Bad (N queries) -for user in users: - user.is_active = False - await user.save() - -# Good (1 query) -await User.where(User.id.in_([u.id for u in users])).update(is_active=False) -``` - -### Avoid N+1 Queries - -Be aware of N+1 query patterns: - -```python -# This causes N+1 queries (one for posts, then one per post for author) -posts = await Post.all() -for post in posts: - author = await post.author # Separate query for each post! -``` - -!!! warning "Feature Not Implemented" - Eager loading / prefetching is not yet available. See [Coming Soon](../coming-soon.md#eager-loading--prefetch-related) for more information. Be mindful of N+1 patterns and load relationships efficiently. - -## SQL Injection Protection - -All values passed to the query API are automatically parameterized by the Rust engine. User input is never concatenated into SQL strings: - -```python -# Safe - parameterized automatically -username = request.get("username") # User input -user = await User.where(User.username == username).first() - -# Generates: SELECT * FROM users WHERE username = $1 -# With parameter: [username] -``` - -## See Also - -- [Mutations](mutations.md) - Creating, updating, and deleting records -- [Relationships](relationships.md) - Working with foreign keys -- [How-To: Pagination](../howto/pagination.md) - Efficient pagination patterns -- [Performance](../concepts/performance.md) - Query optimization techniques diff --git a/docs/guide/relationships.md b/docs/guide/relationships.md deleted file mode 100644 index 4edf920..0000000 --- a/docs/guide/relationships.md +++ /dev/null @@ -1,393 +0,0 @@ -# Relationships - -Ferro provides a robust system for connecting models, supporting standard relational patterns with zero-boilerplate reverse lookups and automated join table management. - -## Overview - -Relationships in Ferro are **lazy** — data is never fetched until you explicitly request it. This prevents N+1 query problems and gives you fine-grained control over when database hits occur. - -### API Styles - -Like scalar field constraints ([assignment vs `Annotated[..., Field(...)]`](models-and-fields.md#field-constraints)), relationship metadata can be declared in two equivalent styles: - -- **Helper-style** (`BackRef()`, `ManyToMany(...)`): Recommended relationship helpers -- **Field-style** (`Field(back_ref=True)`, `Field(many_to_many=True, ...)`): Lower-level `Field()` syntax - -Collection relationships are typed with `Relation[list[T]]`, which reflects the lazy query-like object returned at runtime. - -### Lazy Loading Behavior - -**Forward relations** (accessing a `ForeignKey`): - -```python -author = await post.author # Database hit, returns Author instance -``` - -**Reverse/M2M relations** (accessing the "other side"): - -```python -# Returns a Query object — no database hit yet -query = author.posts - -# Chain filters before executing -published_posts = await author.posts.where(Post.published == True).all() -``` - -## One-to-Many - -The most common relationship type: a `ForeignKey` on the "child" model and a reverse-relation field on the "parent" model. - -```mermaid -erDiagram - AUTHOR ||--o{ POST : writes - AUTHOR { - int id - string name - } - POST { - int id - string title - int author_id - } -``` - -### Helper-style (with `BackRef()`) - -```python -from typing import Annotated -from ferro import Model, ForeignKey, BackRef, Relation - -class Author(Model): - id: int - name: str - posts: Relation[list["Post"]] = BackRef() - -class Post(Model): - id: int - title: str - author: Annotated[Author, ForeignKey(related_name="posts")] -``` - -### Field-style (with `Field(back_ref=True)`) - -```python -from ferro import Model, ForeignKey, Field, Relation - -class Author(Model): - id: int - name: str - posts: Relation[list["Post"]] = Field(back_ref=True) - -class Post(Model): - id: int - title: str - author: Annotated[Author, ForeignKey(related_name="posts")] -``` - -You can also use `Annotated` with `Field`: `posts: Annotated[Relation[list["Post"]], Field(back_ref=True)]` - -### Shadow Fields - -For every `ForeignKey` field (e.g., `author`), Ferro automatically creates a **shadow** scalar column and a matching Pydantic field named `{field}_id` (e.g., `author_id`). It holds the related row’s primary key value. You can read or filter on it like any other column: - -**Typing:** The shadow field’s Python type **follows the related model’s primary key annotation**, wrapped as optional (`| None`) for ORM defaults (the value starts as `None` until you set the relation or the ID). If the parent uses `UUID` for its PK, `author_id` is `UUID | None`; if the parent uses `int | None`, the shadow field matches that shape. - -**Forward references:** When the FK target is only a **string** or `ForwardRef` (e.g., `Annotated["Author", ForeignKey(...)]`) because the parent class is not defined yet, Ferro may start with a **broad fallback** union for the shadow field until the target class exists. After **`resolve_relationships()`** runs—which **`connect()`** calls for you, or which you can call explicitly in tests once every model is registered—the shadow type is **reconciled** to the real PK type and Pydantic’s schema is rebuilt so validation and serialization match the resolved model graph. - -```python -# Read the stored FK value (same logical type as the parent's PK) -post_author_id = post.author_id - -# Filter — use the same value type as Author.id (integer PK example) -recent_posts = await Post.where(Post.author_id == 123).all() - -# With a UUID (or other non-int) primary key on Author, compare using that type -# recent_posts = await Post.where(Post.author_id == author.id).all() -``` - -Nullable relations such as `Annotated[Author | None, ForeignKey(...)]` are supported: the inner target type is normalized so metadata and shadow columns behave consistently. - -### Usage Examples - -```python -# Create with relationship -author = await Author.create(name="Jane Doe") -post = await Post.create(title="Hello World", author=author) - -# Access forward relation -post_author = await post.author # Returns Author instance - -# Access reverse relation (returns Query) -author_posts = await author.posts.all() - -# Filter reverse relation -published = await author.posts.where(Post.published == True).all() -recent = await author.posts.order_by(Post.created_at, "desc").limit(10).all() -``` - -## One-to-One - -A strict 1:1 link created by adding `unique=True` to a `ForeignKey`. - -```mermaid -erDiagram - USER ||--|| PROFILE : has - USER { - int id - string username - } - PROFILE { - int id - int user_id - string bio - } -``` - -### Declaration - -```python -from typing import Annotated -from ferro import Model, ForeignKey, BackRef - -class User(Model): - id: int - username: str - profile: "Profile" = BackRef() # Note: singular relationships do not use Relation - -class Profile(Model): - id: int - bio: str - user: Annotated[User, ForeignKey(related_name="profile", unique=True)] -``` - -### Behavior - -One-to-one relationships have special behavior on the reverse side: - -- **Forward**: `await profile.user` returns a single `User` object -- **Reverse**: `await user.profile` returns a single `Profile` object (or `None`), not a `Query` - -Ferro automatically calls `.first()` on the reverse side, so you don't need to manually execute the query. - -### Usage Examples - -```python -# Create with relationship -user = await User.create(username="alice") -profile = await Profile.create(user=user, bio="Software engineer") - -# Access either direction -user_profile = await user.profile # Returns Profile instance or None -profile_user = await profile.user # Returns User instance -``` - -## Many-to-Many - -Defined using `ManyToMany(...)`. Ferro automatically manages the hidden join table required for this relationship. - -```mermaid -erDiagram - STUDENT }o--o{ COURSE : enrolls - STUDENT { - int id - string name - } - COURSE { - int id - string title - } -``` - -### Helper-style (with `ManyToMany()` / `BackRef()`) - -```python -from ferro import Model, ManyToMany, BackRef, Relation - -class Student(Model): - id: int - name: str - courses: Relation[list["Course"]] = ManyToMany(related_name="students") - -class Course(Model): - id: int - title: str - students: Relation[list["Student"]] = BackRef() -``` - -### Field-style (with `Field(...)`) - -```python -from ferro import Model, Field, Relation - -class Student(Model): - id: int - name: str - courses: Relation[list["Course"]] = Field(many_to_many=True, related_name="students") - -class Course(Model): - id: int - title: str - students: Relation[list["Student"]] = Field(back_ref=True) -``` - -### Join Table - -The Rust engine automatically creates a join table (e.g., `student_courses`) when models are initialized. The table contains foreign keys to both sides of the relationship. - -You do not need to define a "through" model manually unless you need custom fields on the join table (e.g., enrollment date, grade). - -### Relationship Mutators - -Many-to-many relationships provide specialized methods for managing links: - -#### `.add(*instances)` - -Create new links in the join table: - -```python -# Add single course -await student.courses.add(math_101) - -# Add multiple courses -await student.courses.add(math_101, physics_202, chemistry_301) -``` - -#### `.remove(*instances)` - -Remove specific links: - -```python -# Remove single course -await student.courses.remove(math_101) - -# Remove multiple courses -await student.courses.remove(math_101, physics_202) -``` - -#### `.clear()` - -Remove all links for the current instance: - -```python -# Unenroll student from all courses -await student.courses.clear() -``` - -### Usage Examples - -```python -# Create records -student = await Student.create(name="Alice") -math = await Course.create(title="Mathematics") -physics = await Course.create(title="Physics") - -# Add relationships -await student.courses.add(math, physics) - -# Query with filters -math_students = await math.students.where(Student.name.like("A%")).all() - -# Access from either side -student_courses = await student.courses.all() -course_students = await math.students.all() - -# Remove relationships -await student.courses.remove(physics) -await student.courses.clear() -``` - -## Advanced Patterns - -### Self-Referential Relationships - -You can create relationships where a model references itself: - -```python -class Employee(Model): - id: int - name: str - manager: Annotated["Employee", ForeignKey(related_name="reports")] | None = None - reports: Relation[list["Employee"]] = BackRef() - -# Usage -manager = await Employee.create(name="Jane") -employee = await Employee.create(name="John", manager=manager) - -# Access -employee_manager = await employee.manager -manager_reports = await manager.reports.all() -``` - -### Cascade Behavior - -Configure what happens when related objects are deleted: - -```python -# Cascade delete (default for most databases) -author: Annotated[Author, ForeignKey(related_name="posts", on_delete="CASCADE")] - -# Set to NULL -author: Annotated[Author, ForeignKey(related_name="posts", on_delete="SET NULL")] - -# Restrict deletion -author: Annotated[Author, ForeignKey(related_name="posts", on_delete="RESTRICT")] -``` - -### Indexing FK columns - -Postgres does not auto-index foreign-key columns, so tenant-scoped tables that -filter by a FK on every read should declare the index on the model: - -```python -from typing import Annotated -from ferro import Model, ForeignKey, FerroField - -class Org(Model): - id: Annotated[int, FerroField(primary_key=True)] - -class Project(Model): - id: Annotated[int, FerroField(primary_key=True)] - org: Annotated[Org, ForeignKey(related_name="projects", index=True)] -``` - -`index=True` requests a non-unique index on the shadow `*_id` column. Both -the Alembic autogen bridge and the Rust runtime DDL emitter produce the same -index name, `idx_project_org_id`, so schemas generated by either path are -identical (this is the cross-emitter DDL parity invariant — see -[`AGENTS.md`](https://github.com/syn54x/ferro-orm/blob/main/AGENTS.md)). - -`unique=True` already creates an implicit unique index, so combining -`unique=True` with `index=True` is redundant: `index=True` is silently dropped -and a `UserWarning` is raised at class-definition time. - -#### Postgres: index creation locks the table - -When you add `index=True` to an existing model and run the resulting Alembic -migration on a large Postgres table, the default `CREATE INDEX` takes an -`ACCESS EXCLUSIVE` lock and blocks reads and writes until the index is -built. For production tables with millions of rows, this is rarely -acceptable. - -The standard mitigation is `CREATE INDEX CONCURRENTLY`, which Alembic -exposes through `op.create_index(..., postgresql_concurrently=True)` inside -an autocommit block: - -```python -def upgrade(): - with op.get_context().autocommit_block(): - op.create_index( - "idx_project_org_id", - "project", - ["org_id"], - postgresql_concurrently=True, - ) -``` - -Edit the auto-generated migration before running it. SQLite, MySQL, and -new tables on any backend don't need this treatment. - -## See Also - -- [Models & Fields](models-and-fields.md) - Defining models and field types -- [Queries](queries.md) - Filtering and fetching related data -- [Mutations](mutations.md) - Creating and updating with relationships diff --git a/docs/guide/transactions.md b/docs/guide/transactions.md deleted file mode 100644 index 035e576..0000000 --- a/docs/guide/transactions.md +++ /dev/null @@ -1,380 +0,0 @@ -# Transactions - -Ferro provides a simple and robust way to ensure data integrity through atomic transactions using an asynchronous context manager. - -## Basic Usage - -To group multiple database operations into a single atomic unit, use the `ferro.transaction()` context manager: - -```python -from ferro import transaction - -async def transfer_funds(from_user, to_user, amount): - async with transaction(): - # Deduct from source - from_user.balance -= amount - await from_user.save() - - # Add to destination - to_user.balance += amount - await to_user.save() - - # Record transfer - await Transfer.create( - from_user=from_user, - to_user=to_user, - amount=amount - ) - - # If we reach here, all operations succeeded and were committed -``` - -## Atomicity and Rollbacks - -When you enter a transaction block: - -1. **Automatic Commit**: If the block finishes without an exception, Ferro automatically commits all changes to the database. -2. **Automatic Rollback**: If an exception is raised within the block, Ferro immediately rolls back all operations performed during that transaction, ensuring the database remains in a consistent state. - -```python -try: - async with transaction(): - user = await User.create(username="alice", email="alice@example.com") - - # This raises an exception - raise ValueError("Something went wrong") - - # This line never executes - await Post.create(title="Hello", author=user) - -except ValueError: - # The user creation was rolled back - # Database is unchanged - print("Transaction rolled back") - -# Verify rollback -user = await User.where(User.username == "alice").first() -assert user is None # User was not created -``` - -## Connection Affinity - -Ferro's transaction engine uses **Connection Affinity** to guarantee correctness: - -- **Shared Connection**: All operations performed within a `transaction()` block are guaranteed to use the same underlying database connection. -- **Task Safety**: Connection affinity is managed via `contextvars`, making it safe to use in highly concurrent asynchronous environments. - -This ensures that: - -1. All queries see the same transaction state -2. Rollbacks affect only operations within the transaction -3. Concurrent tasks use separate transactions - -```python -import asyncio - -async def task_a(): - async with transaction(): - await User.create(username="task_a_user") - await asyncio.sleep(1) - # Still in the same transaction - -async def task_b(): - async with transaction(): - await User.create(username="task_b_user") - # Separate transaction from task_a - -# These run concurrently with separate transactions -await asyncio.gather(task_a(), task_b()) -``` - -## Nested Transactions - -!!! warning "Feature Not Implemented" - Ferro currently supports single-level transactions only. Nested `transaction()` calls participate in the outermost transaction. True nested transactions with savepoints are not yet available. See [Coming Soon](../coming-soon.md#nested-transactions--savepoints) for more information. - -```python -async with transaction(): # Outer transaction - await User.create(username="alice") - - async with transaction(): # Participates in outer transaction (no savepoint) - await Post.create(title="Hello") - - # If an exception occurs here, both User and Post are rolled back -``` - -## Error Handling Patterns - -### Catch and Handle - -```python -async with transaction(): - try: - user = await User.create(username="alice", email="existing@example.com") - except IntegrityError: - # Handle duplicate email - user = await User.where(User.email == "existing@example.com").first() - - # Continue with transaction - await Post.create(title="Welcome", author=user) -``` - -### Conditional Rollback - -```python -async with transaction(): - user = await User.create(username="bob") - - if not is_valid_email(user.email): - # Explicitly raise to trigger rollback - raise ValueError("Invalid email") - - await send_welcome_email(user.email) -``` - -### Cleanup After Rollback - -```python -try: - async with transaction(): - file_path = await save_file(uploaded_file) - user = await User.create(username="alice", avatar=file_path) - - # This might fail - await send_confirmation_email(user.email) - -except EmailError: - # Transaction rolled back, but file still exists - if file_path: - await delete_file(file_path) # Clean up -``` - -## Performance Implications - -### Transactions Have Overhead - -Transactions involve database locks and logging. For read-only operations, transactions are unnecessary: - -```python -# Don't wrap read-only operations -user = await User.where(User.id == 1).first() # No transaction needed - -# Do wrap writes -async with transaction(): - user.email = "new@example.com" - await user.save() -``` - -### Keep Transactions Short - -Long-running transactions can block other operations: - -```python -# Bad: Long transaction holds locks -async with transaction(): - users = await User.all() # Fetch data - - for user in users: - # Slow external API call - await send_email(user.email) # Blocks other transactions! - await user.save() - -# Good: Minimize transaction scope -users = await User.all() # Outside transaction - -for user in users: - await send_email(user.email) # No locks held - - async with transaction(): # Short, focused transaction - await user.save() -``` - -### Batch Operations in Transactions - -Bulk operations are efficient within transactions: - -```python -async with transaction(): - # These are batched and fast - users = [User(username=f"user_{i}") for i in range(1000)] - await User.bulk_create(users) -``` - -## Testing with Transactions - -A common pattern for test isolation is to wrap each test in a transaction and roll it back: - -```python -import pytest - -@pytest.fixture -async def db_transaction(): - """Wraps each test in a transaction that rolls back after test.""" - from ferro import transaction, rollback_transaction, begin_transaction - - tx_id = await begin_transaction() - try: - yield - finally: - await rollback_transaction(tx_id) - -async def test_user_creation(db_transaction): - # Create user (will be rolled back after test) - user = await User.create(username="test_user") - assert user.id is not None - - # After test: rollback happens automatically -``` - -See [How-To: Testing](../howto/testing.md) for more patterns. - -## Manual Transaction Control - -While the context manager is recommended, you can use the low-level API for finer control: - -### begin_transaction() - -Manually start a new transaction: - -```python -from ferro import begin_transaction, commit_transaction, rollback_transaction - -tx_id = await begin_transaction() -``` - -Returns a unique transaction ID. - -### commit_transaction(tx_id) - -Commit changes for the given transaction: - -```python -try: - await User.create(username="alice") - await commit_transaction(tx_id) -except Exception: - await rollback_transaction(tx_id) -``` - -### rollback_transaction(tx_id) - -Roll back changes for the given transaction: - -```python -await rollback_transaction(tx_id) -``` - -### Example - -```python -tx_id = await begin_transaction() - -try: - user = await User.create(username="alice") - post = await Post.create(title="Hello", author=user) - - if not validate(post): - raise ValidationError("Invalid post") - - await commit_transaction(tx_id) - -except Exception as e: - await rollback_transaction(tx_id) - print(f"Transaction rolled back: {e}") -``` - -!!! warning - Always ensure rollback happens in a `finally` block or exception handler. Unreleased transactions can cause connection leaks. - -## Common Patterns - -### Idempotent Operations - -```python -async def create_or_update_user(username, email): - async with transaction(): - user = await User.where(User.username == username).first() - - if user: - user.email = email - await user.save() - else: - user = await User.create(username=username, email=email) - - return user -``` - -### Multi-Step Processing - -```python -async def process_order(order_id): - async with transaction(): - order = await Order.where(Order.id == order_id).first() - - if order.status != "pending": - raise ValueError("Order already processed") - - # Update inventory - for item in await order.items.all(): - product = await item.product - product.stock -= item.quantity - await product.save() - - # Update order status - order.status = "completed" - await order.save() - - # Create invoice - await Invoice.create(order=order, amount=order.total) -``` - -### Batch with Validation - -```python -async def import_users(user_data_list): - async with transaction(): - created = [] - - for data in user_data_list: - # Validate each record - if not is_valid_email(data["email"]): - # Rollback entire batch - raise ValueError(f"Invalid email: {data['email']}") - - user = await User.create(**data) - created.append(user) - - return created - - # If any validation fails, no users are created -``` - -## Running raw SQL inside a transaction - -For statements that don't fit a `Model` — Postgres GUCs, advisory locks, -`LISTEN/NOTIFY` — `transaction()` yields a handle with `execute`, -`fetch_all`, and `fetch_one` methods that run on the transaction's -connection: - -```python -from ferro import transaction - -async with transaction() as tx: - await tx.execute( - "select set_config('request.jwt.claims', $1, true)", - claims_json, - ) - # All subsequent ORM and raw calls in this block see the GUC. - user = await User.create(name="Taylor") -``` - -See the [raw SQL API page](../api/raw-sql.md) for the full bind type table -and Postgres cast cheat-sheet. - -## See Also - -- [Raw SQL](../api/raw-sql.md) - The escape hatch for one-off statements -- [Mutations](mutations.md) - Creating, updating, and deleting records -- [Queries](queries.md) - Fetching data -- [How-To: Testing](../howto/testing.md) - Test isolation with transactions -- [Database Setup](database.md) - Connection management diff --git a/docs/howto/multiple-databases.md b/docs/howto/multiple-databases.md deleted file mode 100644 index 71d7464..0000000 --- a/docs/howto/multiple-databases.md +++ /dev/null @@ -1,74 +0,0 @@ -# How-To: Multiple Databases - -Use named connections when one process needs more than one database, role, or pool. The common case still works with `await ferro.connect(url)`, which registers and selects `"default"`. Named connections are explicit. - -## Basic Configuration - -```python -import ferro - -async def setup(): - await ferro.connect( - "postgresql://localhost/main_db", - name="primary", - default=True, - ) - await ferro.connect( - "postgresql://localhost/replica_db", - name="replica", - ) - await ferro.connect( - "postgresql://localhost/analytics_db", - name="analytics", - pool=ferro.PoolConfig(max_connections=3), - ) -``` - -## Using Specific Databases - -```python -# Default database (primary) -users = await User.all() - -# Specific database -replica_users = await User.using("replica").all() -analytics_data = await Metric.using("analytics").all() - -# Primary-key fetch on a named connection (same semantics as Model.get / get_or_none) -user = await User.using("replica").get(1) # raises ModelDoesNotExist if missing -maybe = await User.using("replica").get_or_none(unknown_id) -``` - -## Transactions - -```python -async with ferro.transaction(using="analytics"): - await Metric.create(name="daily-active-users") - await ferro.execute("select refresh_metric(?)", "daily-active-users") -``` - -The transaction pins work to one named connection. Nested transactions inherit that same connection. Ferro does not support distributed transactions across named connections. - -## Schema Setup - -Schema creation targets one connection: - -```python -await ferro.create_tables(using="primary") -``` - -Do not run schema creation concurrently through multiple roles that point at the same physical database. Prefer one migration-capable connection and Alembic for production migrations. - -## Security Notes - -- Keep elevated service credentials server-side and out of source control. -- Do not choose `using` directly from untrusted request input. -- Do not make a service-role connection the default in user-facing runtimes. -- Named connections isolate pools and roles, not per-request RLS/JWT context inside one shared pool. -- Objects loaded through an elevated connection can contain elevated data; filter them before returning user-facing responses. - -Automatic routing policies, read/write splitting, cross-connection joins, and two-phase transactions are not part of v1. Use explicit `using` calls where routing matters. - -## See Also - -- [Database Setup](../guide/database.md) diff --git a/docs/howto/pagination.md b/docs/howto/pagination.md deleted file mode 100644 index 24c4db1..0000000 --- a/docs/howto/pagination.md +++ /dev/null @@ -1,355 +0,0 @@ -# How-To: Pagination - -Efficient pagination is essential for handling large datasets. Ferro supports multiple pagination strategies. - -## Offset-Based Pagination - -The simplest approach uses `limit()` and `offset()`: - -```python -from ferro import Model - -class Product(Model): - id: int - name: str - price: float - -async def paginate_products(page: int = 1, per_page: int = 20): - """Get a page of products.""" - offset = (page - 1) * per_page - - products = await Product.select() \ - .order_by(Product.id) \ - .limit(per_page) \ - .offset(offset) \ - .all() - - total = await Product.count() - - return { - "items": products, - "page": page, - "per_page": per_page, - "total": total, - "pages": (total + per_page - 1) // per_page - } - -# Usage -result = await paginate_products(page=2, per_page=50) -print(f"Showing {len(result['items'])} of {result['total']} products") -``` - -### With Filtering - -```python -async def search_products( - query: str, - page: int = 1, - per_page: int = 20 -): - """Search and paginate products.""" - base_query = Product.where(Product.name.like(f"%{query}%")) - - products = await base_query \ - .order_by(Product.name) \ - .limit(per_page) \ - .offset((page - 1) * per_page) \ - .all() - - total = await base_query.count() - - return {"items": products, "total": total} -``` - -### Pros and Cons - -**Pros:** -- Simple to implement -- Works with any column -- Users can jump to any page - -**Cons:** -- Slow for large offsets (OFFSET 10000 is expensive) -- Inconsistent results if data changes between requests -- Database must scan and skip offset rows - -## Cursor-Based Pagination - -More efficient for large datasets. Uses the last seen ID as a cursor: - -```python -async def paginate_cursor(after_id: int | None = None, limit: int = 20): - """Cursor-based pagination using ID.""" - query = Product.select().order_by(Product.id) - - if after_id is not None: - query = query.where(Product.id > after_id) - - products = await query.limit(limit).all() - - next_cursor = products[-1].id if products else None - - return { - "items": products, - "next_cursor": next_cursor, - "has_more": len(products) == limit - } - -# Usage -page1 = await paginate_cursor(after_id=None, limit=20) -print(f"First page: {len(page1['items'])} items") - -# Get next page -page2 = await paginate_cursor(after_id=page1['next_cursor'], limit=20) -print(f"Next page: {len(page2['items'])} items") -``` - -### With Multiple Sort Fields - -```python -from datetime import datetime - -async def paginate_cursor_advanced( - after_timestamp: datetime | None = None, - after_id: int | None = None, - limit: int = 20 -): - """Cursor pagination with timestamp and ID.""" - query = Product.select() \ - .order_by(Product.created_at, "desc") \ - .order_by(Product.id, "desc") - - if after_timestamp and after_id: - query = query.where( - (Product.created_at < after_timestamp) | - ((Product.created_at == after_timestamp) & (Product.id < after_id)) - ) - - products = await query.limit(limit).all() - - if products: - last = products[-1] - return { - "items": products, - "next_cursor": { - "timestamp": last.created_at, - "id": last.id - }, - "has_more": len(products) == limit - } - - return {"items": [], "next_cursor": None, "has_more": False} -``` - -### Pros and Cons - -**Pros:** -- Constant performance regardless of position -- Consistent results even if data changes -- Efficient for infinite scroll - -**Cons:** -- Can't jump to arbitrary page -- More complex to implement -- Requires unique, sortable field - -## Keyset Pagination - -Similar to cursor-based, but uses any unique key: - -```python -async def paginate_keyset( - after_email: str | None = None, - limit: int = 20 -): - """Keyset pagination using email.""" - query = User.select().order_by(User.email) - - if after_email: - query = query.where(User.email > after_email) - - users = await query.limit(limit).all() - - return { - "items": users, - "next_key": users[-1].email if users else None, - "has_more": len(users) == limit - } -``` - -## FastAPI Integration - -### Offset-Based - -```python -from fastapi import FastAPI, Query -from pydantic import BaseModel - -app = FastAPI() - -class PaginatedResponse(BaseModel): - items: list[Product] - page: int - per_page: int - total: int - pages: int - -@app.get("/products", response_model=PaginatedResponse) -async def list_products( - page: int = Query(1, ge=1), - per_page: int = Query(20, ge=1, le=100) -): - return await paginate_products(page, per_page) -``` - -### Cursor-Based - -```python -class CursorPaginatedResponse(BaseModel): - items: list[Product] - next_cursor: int | None - has_more: bool - -@app.get("/products/cursor", response_model=CursorPaginatedResponse) -async def list_products_cursor( - cursor: int | None = Query(None), - limit: int = Query(20, ge=1, le=100) -): - return await paginate_cursor(after_id=cursor, limit=limit) -``` - -## Pagination Helper Class - -Reusable pagination utility: - -```python -from typing import Generic, TypeVar -from pydantic import BaseModel - -T = TypeVar('T') - -class Page(BaseModel, Generic[T]): - items: list[T] - page: int - per_page: int - total: int - pages: int - has_next: bool - has_prev: bool - -async def paginate( - query, - page: int = 1, - per_page: int = 20 -) -> Page: - """Generic pagination helper.""" - total = await query.count() - - items = await query \ - .limit(per_page) \ - .offset((page - 1) * per_page) \ - .all() - - pages = (total + per_page - 1) // per_page - - return Page( - items=items, - page=page, - per_page=per_page, - total=total, - pages=pages, - has_next=page < pages, - has_prev=page > 1 - ) - -# Usage -products_page = await paginate( - Product.where(Product.active == True), - page=2, - per_page=50 -) -``` - -## Performance Tips - -### Always Order - -```python -# Bad: Unpredictable results -products = await Product.limit(20).offset(40).all() - -# Good: Consistent, predictable -products = await Product.order_by(Product.id).limit(20).offset(40).all() -``` - -### Index Sort Columns - -```python -from datetime import datetime - -from ferro import Field, Model - -class Product(Model): - id: int | None = Field(default=None, primary_key=True) - created_at: datetime = Field(index=True) # Index for sorting -``` - -### Cache Counts - -```python -from functools import lru_cache -from datetime import datetime, timedelta - -_count_cache = {} - -async def get_cached_count(model, cache_seconds=60): - """Cache total count for pagination.""" - cache_key = model.__name__ - - if cache_key in _count_cache: - count, timestamp = _count_cache[cache_key] - if datetime.now() - timestamp < timedelta(seconds=cache_seconds): - return count - - count = await model.count() - _count_cache[cache_key] = (count, datetime.now()) - return count - -# Use in pagination -total = await get_cached_count(Product, cache_seconds=120) -``` - -### Limit Maximum Page Size - -```python -MAX_PAGE_SIZE = 100 - -async def safe_paginate(page: int, per_page: int): - """Enforce maximum page size.""" - per_page = min(per_page, MAX_PAGE_SIZE) - # ... rest of pagination -``` - -## Which Strategy to Use? - -**Use offset-based when:** -- Dataset is small (<10K records) -- Users need page numbers -- Jumping to specific pages is required -- Simplicity is prioritized - -**Use cursor-based when:** -- Dataset is large (>10K records) -- Infinite scroll UI -- Real-time data feeds -- Performance is critical - -**Use keyset when:** -- Sorting by non-ID fields -- Need stable pagination with filters -- Custom ordering requirements - -## See Also - -- [Queries](../guide/queries.md) - Filtering and ordering -- [Performance](../concepts/performance.md) - Query optimization diff --git a/docs/howto/soft-deletes.md b/docs/howto/soft-deletes.md deleted file mode 100644 index 711bfa2..0000000 --- a/docs/howto/soft-deletes.md +++ /dev/null @@ -1,86 +0,0 @@ -# How-To: Soft Deletes - -Implement soft deletes to mark records as deleted without removing them from the database. - -## Basic Implementation - -```python -from datetime import datetime -from ferro import Model, Field - -class SoftDeleteModel(Model): - is_deleted: bool = False - deleted_at: datetime | None = None - - async def soft_delete(self): - """Mark as deleted instead of removing.""" - self.is_deleted = True - self.deleted_at = datetime.now() - await self.save() - - async def restore(self): - """Restore a soft-deleted record.""" - self.is_deleted = False - self.deleted_at = None - await self.save() - -class User(SoftDeleteModel): - username: str - email: str - -# Usage -user = await User.create(username="alice", email="alice@example.com") - -# Soft delete -await user.soft_delete() - -# Restore -await user.restore() -``` - -## Query Only Active Records - -```python -class User(SoftDeleteModel): - username: str - - @classmethod - def active(cls): - """Query only non-deleted records.""" - return cls.where(cls.is_deleted == False) - -# Usage -active_users = await User.active().all() -deleted_users = await User.where(User.is_deleted == True).all() -``` - -## Manager Pattern - -```python -class SoftDeleteManager: - def __init__(self, model): - self.model = model - - def active(self): - return self.model.where(self.model.is_deleted == False) - - def deleted(self): - return self.model.where(self.model.is_deleted == True) - - def all_with_deleted(self): - return self.model.select() - -class User(SoftDeleteModel): - username: str - - objects = SoftDeleteManager(lambda: User) - -# Usage -active = await User.objects.active().all() -deleted = await User.objects.deleted().all() -``` - -## See Also - -- [Mutations](../guide/mutations.md) -- [Queries](../guide/queries.md) diff --git a/docs/howto/testing.md b/docs/howto/testing.md deleted file mode 100644 index 066f8a4..0000000 --- a/docs/howto/testing.md +++ /dev/null @@ -1,205 +0,0 @@ -# How-To: Testing - -Test your Ferro applications with pytest and test database isolation strategies. - -## Ferro Test Matrix - -The repository test suite supports two database modes: - -- **Default SQLite run** for the full fast suite -- **Dual-backend matrix** for ORM coverage on both SQLite and PostgreSQL - -The matrix is opt-in so day-to-day test runs stay quick and deterministic. - -### Local Setup - -Install the development dependencies used by the matrix: - -```bash -uv sync --group dev -uv run maturin develop -``` - -For local PostgreSQL matrix runs, install PostgreSQL server binaries so `pytest-postgresql` can start an ephemeral database: - -```bash -brew install postgresql@16 -``` - -You can also point the suite at an externally managed PostgreSQL database. A root `.env` file works well for local development: - -```bash -FERRO_POSTGRES_URL='postgresql://...' -``` - -The Postgres matrix first reads `FERRO_POSTGRES_URL` from either the environment or the project `.env` file. It still accepts the older `FERRO_SUPABASE_URL` name as a compatibility fallback. Tests create a dedicated schema per test and use that schema as the search path so one shared external database can still run isolated tests safely. - -To force the local `pytest-postgresql` provider even when `.env` contains an external URL: - -```bash -FERRO_POSTGRES_PROVIDER=local uv run pytest -m "backend_matrix or postgres_only" --db-backends=postgres -q -``` - -### Run The Default Suite - -Run the normal SQLite-first suite: - -```bash -uv run pytest -q -``` - -### Run The Dual-Backend ORM Matrix - -Run the backend-matrix and Postgres-specific tests on both SQLite and PostgreSQL: - -```bash -uv run pytest -m "backend_matrix or postgres_only" --db-backends=sqlite,postgres -q -``` - -If you only want the PostgreSQL side of the matrix: - -```bash -uv run pytest -m "backend_matrix or postgres_only" --db-backends=postgres -q -``` - -### Test Markers - -The repository uses three database markers: - -- `backend_matrix`: run this test once per selected backend -- `sqlite_only`: keep SQLite-specific catalog, file-path, or pragma assertions on SQLite -- `postgres_only`: run Postgres-specific assertions when either an external Postgres URL is configured or `pytest-postgresql` can start a local server - -If no external Postgres URL is set and local PostgreSQL server binaries are unavailable, `postgres_only` tests are skipped and `backend_matrix` tests run only on SQLite. - -### Bridge-Boundary Regressions - -When a bug involves values crossing the Python/Rust bridge, preserve the public API shape in the regression test. These issues often depend on whether a value travels as JSON (`Query.all()`, `Query.count()`, `Query.update()`, `Query.delete()`) or as a typed Python value passed directly to Rust (`ManyToMany(...).add()`, `.remove()`, `.clear()`). - -Use these conventions: - -- Put relationship and auto-migration regressions in `tests/test_auto_migrate.py` when they strengthen the backend matrix. -- Put structural type regressions in `tests/test_structural_types.py` when they involve UUID, Decimal, JSON, enum, binary, date, or datetime behavior. -- Use `backend_matrix` when the public behavior should work on both SQLite and PostgreSQL. -- Use `postgres_only` when the assertion depends on native PostgreSQL types, catalogs, or casts. -- Convert user repro scripts with minimal translation: keep the same model shape and public method sequence, trim incidental setup, and assert the original failure mode is gone. -- Add a fast serializer or static-contract test when the bug is caused by a Python boundary rule, such as raw `json.dumps(query_def)` bypassing Ferro's query serializer. - -## Basic Setup - -```python -# conftest.py -import pytest -import ferro - -@pytest.fixture -async def db(): - """Connect to a fresh test database for one test.""" - await ferro.connect("sqlite::memory:", auto_migrate=True) - yield - ferro.reset_engine() - -@pytest.fixture -async def db_transaction(db): - """Wrap each test in Ferro's transaction() helper.""" - from ferro import transaction - - async with transaction(): - yield -``` - -For backend-matrix tests, Ferro's own suite uses `--db-backends=sqlite,postgres` together with `backend_matrix` / `postgres_only` markers. Postgres coverage uses `pytest-postgresql` locally, or `FERRO_POSTGRES_URL` / `FERRO_SUPABASE_URL` when an external database is configured. - -## Test Example - -```python -# test_users.py -import pytest -from myapp.models import User - -@pytest.mark.asyncio -async def test_create_user(db_transaction): - """Test user creation.""" - user = await User.create( - username="testuser", - email="test@example.com" - ) - - assert user.id is not None - assert user.username == "testuser" - - # Verify in database - found = await User.where(User.username == "testuser").first() - assert found is not None - assert found.id == user.id - -@pytest.mark.asyncio -async def test_user_unique_email(db_transaction): - """Test unique email constraint.""" - await User.create(username="user1", email="same@example.com") - - # Use general Exception or your database driver's specific exception - with pytest.raises(Exception): # Or use specific exception from driver - await User.create(username="user2", email="same@example.com") -``` - -## Factory Pattern - -```python -# factories.py -from typing import Any -from myapp.models import User, Post - -class UserFactory: - _counter = 0 - - @classmethod - async def create(cls, **kwargs: Any) -> User: - cls._counter += 1 - defaults = { - "username": f"user_{cls._counter}", - "email": f"user{cls._counter}@example.com" - } - defaults.update(kwargs) - return await User.create(**defaults) - -class PostFactory: - _counter = 0 - - @classmethod - async def create(cls, **kwargs: Any) -> Post: - cls._counter += 1 - - # Auto-create author if not provided - if "author" not in kwargs: - kwargs["author"] = await UserFactory.create() - - defaults = { - "title": f"Post {cls._counter}", - "content": "Test content" - } - defaults.update(kwargs) - return await Post.create(**defaults) - -# Usage in tests -async def test_post_with_author(db_transaction): - post = await PostFactory.create(title="Custom Title") - assert post.author is not None -``` - -## Pytest-AsyncIO Configuration - -```ini -# pytest.ini -[pytest] -asyncio_mode = auto -testpaths = tests -python_files = test_*.py -python_classes = Test* -python_functions = test_* -``` - -## See Also - -- [Transactions](../guide/transactions.md) -- [Database Setup](../guide/database.md) diff --git a/docs/howto/timestamps.md b/docs/howto/timestamps.md deleted file mode 100644 index b93a11b..0000000 --- a/docs/howto/timestamps.md +++ /dev/null @@ -1,57 +0,0 @@ -# How-To: Timestamps - -Add automatic timestamp tracking to your models. - -## Basic Pattern - -```python -from datetime import datetime -from ferro import Model, Field - -class TimestampedModel(Model): - created_at: datetime = Field(default_factory=datetime.now) - updated_at: datetime = Field(default_factory=datetime.now) - -class User(TimestampedModel): - username: str - email: str - -# Usage -user = await User.create(username="alice", email="alice@example.com") -print(f"Created at: {user.created_at}") -``` - -## Auto-Updating updated_at - -```python -class TimestampedModel(Model): - created_at: datetime = Field(default_factory=datetime.now) - updated_at: datetime = Field(default_factory=datetime.now) - - async def save(self): - """Override save to update timestamp.""" - self.updated_at = datetime.now() - await super().save() - -# Usage -user = await User.where(User.id == 1).first() -user.username = "new_name" -await user.save() # updated_at automatically set -``` - -## Timezone-Aware Timestamps - -```python -from datetime import datetime, timezone - -def utc_now(): - return datetime.now(timezone.utc) - -class Model(Model): - created_at: datetime = Field(default_factory=utc_now) - updated_at: datetime = Field(default_factory=utc_now) -``` - -## See Also - -- [Models & Fields](../guide/models-and-fields.md) diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 180809f..0000000 --- a/docs/index.md +++ /dev/null @@ -1,142 +0,0 @@ -# Ferro ORM - -**The async Python ORM with Rust speed and Pydantic ergonomics.** - -
- -- :zap:{ .lg .middle } **Rust-Powered** - - --- - - All SQL generation and row hydration handled by a high-performance Rust engine. Minimize the "Python tax" on data-heavy operations. - -- :snake:{ .lg .middle } **Pydantic-Native** - - --- - - Leverage Pydantic V2 for schema definition and validation. Full IDE support, type safety, and familiar syntax. - -- :rocket:{ .lg .middle } **Async-First** - - --- - - Built from the ground up for asynchronous applications. Non-blocking I/O with SQLx and `pyo3-async-runtimes`. - -
- -## Quick Example - -```python -import asyncio -from typing import Annotated -from ferro import Model, Field, ForeignKey, BackRef, Relation, connect - -class Author(Model): - id: int | None = Field(default=None, primary_key=True) - name: str - posts: Relation[list["Post"]] = BackRef() - -class Post(Model): - id: int | None = Field(default=None, primary_key=True) - title: str - published: bool = False - author: Annotated[Author, ForeignKey(related_name="posts")] - -async def main(): - # Connect with auto-migration for development - await connect("sqlite:blog.db?mode=rwc", auto_migrate=True) - - # Create records - author = await Author.create(name="Jane Doe") - post = await Post.create( - title="Why Ferro is Fast", - author=author, - published=True - ) - - # Query with filters - published_posts = await Post.where( - Post.published == True - ).order_by(Post.id, "desc").all() - - # Access relationships - post_author = await post.author - author_posts = await author.posts.all() - -if __name__ == "__main__": - asyncio.run(main()) -``` - -## Why Ferro? - -Traditional Python ORMs pay a **performance tax** for SQL generation, row parsing, and object instantiation — all happening in Python with the GIL held. Ferro moves these operations to a dedicated Rust core, delivering: - -- **10-100x faster** bulk operations and complex queries -- **Zero-copy data paths** for maximum throughput -- **GIL-free I/O** for true async concurrency -- **Type-safe** with full IDE autocomplete - -Still skeptical? [See the benchmarks](why-ferro.md#benchmarks) or read about [how it works](concepts/architecture.md). - -## Key Features - -### High-Performance Core - -All SQL generation and row hydration are handled by a dedicated Rust engine. Row data flows from SQLx → Rust → Python with minimal copying, bypassing the Python interpreter's overhead entirely. - -### Identity Map - -Ensures object consistency across your application. Fetch the same record twice, get the exact same Python object instance. Changes are immediately visible everywhere. - -### Async Everything - -Built on SQLx and `pyo3-async-runtimes`. No sync wrappers, no thread pools — true non-blocking database I/O from the ground up. - -### Pydantic Integration - -Define schemas with standard Pydantic models. Get validation, serialization, and JSON schema generation for free. Ferro extends Pydantic with database-specific constraints. - -### Alembic Migrations - -Production-ready schema management through Alembic. Ferro generates SQLAlchemy metadata automatically — no duplicate schema definitions. - -## Ready to Start? - -
- -- :material-clock-fast:{ .lg .middle } **5-Minute Tutorial** - - --- - - Build a working blog API with models, queries, and relationships. - - [:octicons-arrow-right-24: Get started](getting-started/tutorial.md) - -- :books:{ .lg .middle } **User Guide** - - --- - - Learn about models, relationships, queries, and transactions. - - [:octicons-arrow-right-24: Read the guide](guide/models-and-fields.md) - -- :material-api:{ .lg .middle } **API Reference** - - --- - - Complete reference for all classes, methods, and types. - - [:octicons-arrow-right-24: Browse API docs](api/model.md) - -
- -## Trusted By - -Ferro is used in production by teams that need both developer ergonomics and runtime performance. [Read case studies →](https://github.com/syn54x/ferro-orm/discussions) - -## Community - -- **GitHub**: [syn54x/ferro-orm](https://github.com/syn54x/ferro-orm) -- **Discussions**: Ask questions and share projects -- **Contributing**: [Contribution guide](contributing.md) -- **License**: Apache 2.0 diff --git a/docs/migration-sqlalchemy.md b/docs/migration-sqlalchemy.md deleted file mode 100644 index e68c152..0000000 --- a/docs/migration-sqlalchemy.md +++ /dev/null @@ -1,173 +0,0 @@ -# Migrating from SQLAlchemy - -This guide helps you migrate from SQLAlchemy to Ferro. - -## Quick Comparison - -| Feature | SQLAlchemy 2.0 | Ferro | -|---------|----------------|-------| -| Model Definition | Declarative Base | Pydantic Model | -| Queries | `select()` | `.where()` | -| Sessions | Required | Not needed | -| Async | Native | Native | -| Migrations | Alembic | Alembic | - -## Model Definition - -### SQLAlchemy - -```python -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column - -class Base(DeclarativeBase): - pass - -class User(Base): - __tablename__ = "users" - - id: Mapped[int] = mapped_column(primary_key=True) - username: Mapped[str] = mapped_column(unique=True) - email: Mapped[str] -``` - -### Ferro - -```python -from ferro import Field, Model - -class User(Model): - id: int | None = Field(default=None, primary_key=True) - username: str = Field(unique=True) - email: str -``` - -## Queries - -### Fetch All - -```python -# SQLAlchemy -from sqlalchemy import select - -async with session() as db: - result = await db.execute(select(User)) - users = result.scalars().all() - -# Ferro -users = await User.all() -``` - -### Filtering - -```python -# SQLAlchemy -stmt = select(User).where(User.age >= 18) -result = await db.execute(stmt) -users = result.scalars().all() - -# Ferro -users = await User.where(User.age >= 18).all() -``` - -### Get by primary key - -SQLAlchemy’s `session.get(User, pk)` returns `None` when the row is missing. Ferro’s `await User.get(pk)` returns `User` and raises `ModelDoesNotExist` when absent. Use `await User.get_or_none(pk)` for the optional pattern. - -```python -# SQLAlchemy -user = await session.get(User, 1) - -# Ferro — raises if missing -from ferro import ModelDoesNotExist - -try: - user = await User.get(1) -except ModelDoesNotExist: - user = None - -# Ferro — optional (like session.get when no row) -user = await User.get_or_none(1) -``` - -## Relationships - -### One-to-Many - -```python -# SQLAlchemy -class User(Base): - __tablename__ = "users" - id: Mapped[int] = mapped_column(primary_key=True) - posts: Mapped[List["Post"]] = relationship(back_populates="author") - -class Post(Base): - __tablename__ = "posts" - id: Mapped[int] = mapped_column(primary_key=True) - author_id: Mapped[int] = mapped_column(ForeignKey("users.id")) - author: Mapped["User"] = relationship(back_populates="posts") - -# Ferro -from typing import Annotated - -from ferro import BackRef, Field, ForeignKey, Model, Relation - -class User(Model): - id: int | None = Field(default=None, primary_key=True) - posts: Relation[list["Post"]] = BackRef() - -class Post(Model): - id: int | None = Field(default=None, primary_key=True) - author: Annotated[User, ForeignKey(related_name="posts")] -``` - -## Creating Records - -```python -# SQLAlchemy -async with session() as db: - user = User(username="alice", email="alice@example.com") - db.add(user) - await db.commit() - -# Ferro -user = await User.create(username="alice", email="alice@example.com") -``` - -## Transactions - -```python -# SQLAlchemy -async with session.begin(): - user = User(username="alice") - db.add(user) - # Auto-commits on exit - -# Ferro -from ferro import transaction - -async with transaction(): - user = await User.create(username="alice") - # Auto-commits on exit -``` - -## Migration Checklist - -- [ ] Install Ferro: `pip install ferro-orm` -- [ ] Replace SQLAlchemy models with Ferro models -- [ ] Update queries to use Ferro's `.where()` API -- [ ] Remove session management (Ferro doesn't use sessions) -- [ ] Update relationship syntax -- [ ] Test thoroughly -- [ ] Update Alembic `env.py` to use Ferro's `get_metadata()` - -## Key Differences - -1. **No Sessions**: Ferro manages connections automatically -2. **Pydantic Models**: Ferro models are Pydantic, get validation for free -3. **Simpler API**: Fewer concepts to learn -4. **Better Performance**: Rust engine for bulk operations - -## Getting Help - -- [Ferro Documentation](index.md) -- [GitHub Discussions](https://github.com/syn54x/ferro-orm/discussions) diff --git a/docs/pages/api/connection.md b/docs/pages/api/connection.md new file mode 100644 index 0000000..18ff1f9 --- /dev/null +++ b/docs/pages/api/connection.md @@ -0,0 +1,21 @@ +# Connection & Registry + +Functions for managing database connections and the global model registry. `connect()` registers a (optionally named) connection pool; `reset_engine()` tears everything down; the registry helpers control schema creation and the identity map. See the [Connections & Databases guide](../guide/connections.md). + +::: ferro.connect + +::: ferro.PoolConfig + +::: ferro.set_default_connection + +::: ferro.reset_engine + +::: ferro.create_tables + +::: ferro.migrate + +::: ferro.clear_registry + +::: ferro.evict_instance + +::: ferro.version diff --git a/docs/pages/api/exceptions.md b/docs/pages/api/exceptions.md new file mode 100644 index 0000000..d2dba49 --- /dev/null +++ b/docs/pages/api/exceptions.md @@ -0,0 +1,5 @@ +# Exceptions + +Exceptions raised by Ferro's public API. `ModelDoesNotExist` is raised by primary-key lookups like `Model.get(pk)` when no row matches; use `Model.get_or_none(pk)` if you prefer `None` over an exception. + +::: ferro.ModelDoesNotExist diff --git a/docs/pages/api/fields.md b/docs/pages/api/fields.md new file mode 100644 index 0000000..ae043be --- /dev/null +++ b/docs/pages/api/fields.md @@ -0,0 +1,15 @@ +# Fields & Types + +Fields are declared as type annotations on a [`Model`](model.md), with `Field(...)` supplying database options (primary key, unique, index, defaults). The lower-level types on this page — `FerroField`, `DbType`, and friends — power `Annotated[...]` declarations and explicit column-type overrides. + +::: ferro.Field + +::: ferro.FerroField + +::: ferro.FerroNullable + +::: ferro.varchar + +::: ferro.DbType + +::: ferro.DbTypeToken diff --git a/docs/pages/api/migrations.md b/docs/pages/api/migrations.md new file mode 100644 index 0000000..bc03855 --- /dev/null +++ b/docs/pages/api/migrations.md @@ -0,0 +1,5 @@ +# Migrations + +The Alembic bridge. `get_metadata()` builds a SQLAlchemy `MetaData` describing all registered Ferro models, so Alembic's `--autogenerate` can diff your models against the live database and emit migration scripts. Assign it to `target_metadata` in your Alembic `env.py` (requires the `ferro-orm[alembic]` extra). See the [Schema Migrations guide](../guide/migrations.md) for the full workflow. + +::: ferro.migrations.alembic.get_metadata diff --git a/docs/pages/api/model.md b/docs/pages/api/model.md new file mode 100644 index 0000000..d55bfad --- /dev/null +++ b/docs/pages/api/model.md @@ -0,0 +1,5 @@ +# Model + +`Model` is the base class every Ferro model inherits from. The lifecycle is: define a subclass with annotated fields (which registers its table schema), [`connect()`](connection.md) to a database, then perform CRUD through classmethods (`create`, `get`, `where`, ...) and instance methods (`save`, `delete`, `refresh`). Because `Model` is a Pydantic model, instances validate on construction and serialize like any other Pydantic object. + +::: ferro.Model diff --git a/docs/pages/api/queries.md b/docs/pages/api/queries.md new file mode 100644 index 0000000..e4941d8 --- /dev/null +++ b/docs/pages/api/queries.md @@ -0,0 +1,11 @@ +# Queries + +`Model.where(...)` and `Model.select()` return a `Query` — an immutable, chainable builder that executes when awaited via `all()`, `first()`, `count()`, `exists()`, `update()`, or `delete()`. Predicates are written against the typed field proxies on the model class (`User.age >= 18`); `col()` is the untyped escape hatch for dynamic field names. + +::: ferro.query.builder.Query + +::: ferro.query.col + +::: ferro.query.QueryProxy + +::: ferro.query.Predicate diff --git a/docs/pages/api/raw-sql.md b/docs/pages/api/raw-sql.md new file mode 100644 index 0000000..7192438 --- /dev/null +++ b/docs/pages/api/raw-sql.md @@ -0,0 +1,9 @@ +# Raw SQL + +Escape hatches for SQL the query builder doesn't cover. All three functions take a SQL string plus positional bind parameters — placeholders are `?` on SQLite and `$1`, `$2`, ... on Postgres — and honor an active `transaction()` block. See the [Raw SQL guide](../guide/raw-sql.md). + +::: ferro.execute + +::: ferro.fetch_all + +::: ferro.fetch_one diff --git a/docs/pages/api/relationships.md b/docs/pages/api/relationships.md new file mode 100644 index 0000000..b2a4918 --- /dev/null +++ b/docs/pages/api/relationships.md @@ -0,0 +1,11 @@ +# Relationships + +Relationships are declared with annotations: `Annotated[Other, ForeignKey(...)]` for the owning side, `BackRef()` for the reverse side, and `ManyToMany(...)` for join-table relations. At runtime, related data is accessed through `Relation` — an awaitable, chainable query bound to the instance. See the [Relationships guide](../guide/relationships.md) for usage patterns. + +::: ferro.ForeignKey + +::: ferro.BackRef + +::: ferro.ManyToMany + +::: ferro.Relation diff --git a/docs/pages/api/transactions.md b/docs/pages/api/transactions.md new file mode 100644 index 0000000..b1ea387 --- /dev/null +++ b/docs/pages/api/transactions.md @@ -0,0 +1,7 @@ +# Transactions + +`transaction()` is an async context manager that runs everything inside the block on one connection, committing on exit and rolling back on exception. It yields a `Transaction` handle for raw SQL that must run on the transaction's connection. See the [Transactions guide](../guide/transactions.md) for semantics and patterns. + +::: ferro.transaction + +::: ferro.Transaction diff --git a/docs/pages/changelog.md b/docs/pages/changelog.md new file mode 100644 index 0000000..786b75d --- /dev/null +++ b/docs/pages/changelog.md @@ -0,0 +1 @@ +--8<-- "CHANGELOG.md" diff --git a/docs/pages/concepts/architecture.md b/docs/pages/concepts/architecture.md new file mode 100644 index 0000000..0871c91 --- /dev/null +++ b/docs/pages/concepts/architecture.md @@ -0,0 +1,191 @@ +# Architecture + +Ferro is a Python ORM with a Rust core. You write Pydantic models and async Python; SQL generation, database I/O, and row hydration happen in compiled Rust. This page explains how the pieces fit together and what actually happens when you run a query. + +## Overview + +```mermaid +graph TB + subgraph python [Python Layer] + Models[Pydantic Models] + Metaclass[ModelMetaclass] + QueryBuilder[Query Builder] + end + + subgraph bridge [PyO3 FFI Bridge] + Schema[JSON Schema] + Payload[JSON Query Payload] + end + + subgraph rust [Rust Engine] + Registry[Model Registry] + SeaQuery[Sea-Query SQL Generation] + SQLx[SQLx Pools & Execution] + Hydration[Row Hydration & Identity Map] + end + + subgraph db [Database] + SQLite[(SQLite)] + Postgres[(PostgreSQL)] + end + + Models --> Metaclass + Metaclass -->|Register schema| Schema + Schema --> Registry + + QueryBuilder -->|Serialize query| Payload + Payload --> SeaQuery + SeaQuery -->|Parameterized SQL| SQLx + SQLx --> SQLite + SQLx --> Postgres + SQLite -->|Rows| Hydration + Postgres -->|Rows| Hydration + Hydration -->|Model instances| Models +``` + +The shortest way to hold the whole design in your head: + +```text +Python owns the model contract. +Rust owns execution. +Sea-Query owns SQL shape. +SQLx owns typed database I/O. +The backend kind decides which database-specific path is legal. +``` + +## The Layers + +### Python + +Ferro models are real Pydantic V2 `BaseModel` subclasses. The Python layer owns: + +- **Model definition.** Annotated fields become columns; Pydantic handles validation, defaults, serialization, and JSON schema generation. +- **Metaclass registration.** `ModelMetaclass` inspects each model at class-creation time, builds an enriched JSON schema (primary keys, uniques, indexes, foreign keys, nullability, composite constraints), registers it with the Rust engine, and replaces class-level field access with `FieldProxy` objects so `User.email == "a@b.com"` builds a query node instead of comparing values. +- **Query building.** Chains like `User.where(...).order_by(...).limit(10)` are pure Python — they accumulate an in-memory query definition. Nothing touches the database until you await a terminal method (`.all()`, `.first()`, `.count()`, `.exists()`, `.update()`, `.delete()`). + +### The Bridge + +The FFI boundary is built on [PyO3](https://pyo3.rs) with `pyo3-async-runtimes` bridging Python's asyncio event loop to Rust's tokio runtime. Two kinds of data cross it: + +- **Schemas** travel Python → Rust once, at class-creation time, as JSON — Pydantic's JSON schema enriched with Ferro-specific keys (`primary_key`, `ferro_nullable`, `foreign_key`, `ferro_composite_uniques`, and so on). +- **Queries and mutations** travel as compact JSON payloads per operation; rows travel back as typed values that Rust assembles into Python objects. + +Crucially, the GIL is released while Rust waits on the database. An awaited Ferro query does not block other Python coroutines or threads. + +### The Rust Engine + +The engine owns everything between the JSON payload and the database: + +- **SQL generation** via [Sea-Query](https://github.com/SeaQL/sea-query), which lowers each operation through the dialect-specific builder (SQLite or PostgreSQL) with safely bound parameters. +- **Connection pooling and execution** via [SQLx](https://github.com/launchbadge/sqlx) typed pools — a real SQLite pool or a real PostgreSQL pool, not a generic abstraction pretending to be both. +- **Row hydration** — decoding database values (which are not the same as Python field values; a PostgreSQL `uuid` or `numeric` needs reconstruction) into the exact shapes Pydantic expects. +- **The identity map**, a per-connection cache ensuring one row maps to one Python instance. See [Identity Map](identity-map.md). + +## Life of a Query + +```mermaid +sequenceDiagram + participant App as Your Code + participant QB as Query Builder (Python) + participant Rust as Rust Engine + participant DB as Database + + App->>QB: User.where(lambda t: t.age > 18).all() + QB->>QB: Build query definition (no I/O) + QB->>Rust: await — JSON payload via FFI + Note over Rust: GIL released + Rust->>Rust: Sea-Query generates parameterized SQL + Rust->>DB: SELECT ... WHERE age > $1 + DB-->>Rust: Rows + Rust->>Rust: Decode and hydrate rows + Rust->>Rust: Reconcile with identity map + Rust-->>QB: Python objects + QB-->>App: list[User] (validated Pydantic instances) +``` + +Step by step: + +1. **Construction** — `User.where(lambda t: t.age > 18)` builds a `QueryNode` tree in Python. No database interaction. +2. **Execution trigger** — awaiting `.all()` serializes the query definition to JSON and calls into Rust. +3. **SQL generation** — Sea-Query lowers the definition into dialect-correct, parameterized SQL. Values are bound, never interpolated. +4. **Execution** — SQLx runs the statement on a pooled connection (or on the pinned connection if a [transaction](../guide/transactions.md) is active). +5. **Hydration** — Rust decodes each row's values into Python-compatible shapes and constructs instances, consulting the identity map so a primary key you've already loaded resolves to the existing object. +6. **Return** — your code receives a plain `list[User]` of fully validated Pydantic instances. + +## Model Registration + +Defining a model is itself a registration step: + +=== "Assignment" + + ```python + from ferro import Field, Model + + + class User(Model): + id: int | None = Field(default=None, primary_key=True) + username: str = Field(unique=True) + email: str + ``` + +=== "Annotated" + + ```python + from typing import Annotated + + from ferro import Field, Model + + + class User(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + username: Annotated[str, Field(unique=True)] + email: str + ``` + +At class-creation time the metaclass builds the enriched schema and registers it with the Rust engine's model registry. Importing your models is therefore enough for Ferro to know your full schema — but it does **not** connect to a database. + +Schema DDL happens later, when you connect: + +```python +from ferro import connect + +await connect("sqlite::memory:", auto_migrate=True) +``` + +- `auto_migrate=True` creates missing tables for every registered model. +- `migrate_updates=True` (0.11.0) additionally adds missing columns to existing tables, and on PostgreSQL reconciles type and nullability drift. +- `migrate_destructive=True` (0.11.0) additionally drops live columns no longer on the model (never whole tables). + +For renames, primary-key changes, and complex transforms, use the [Alembic bridge](../guide/migrations.md). The same registered schema metadata drives runtime DDL, Alembic autogeneration, and query casting decisions — the Python schema is the single contract. + +## Async Architecture + +Ferro is async end to end. Python's `await` hands off to Rust's tokio runtime; there are no thread pools wrapping a synchronous driver, and no sync API with async bolted on. + +```mermaid +graph LR + A[Python asyncio] -->|pyo3-async-runtimes| B[Rust tokio] + B -->|SQLx async I/O| C[(Database)] +``` + +Consequences: + +- Database I/O never holds the GIL, so other coroutines keep running while queries are in flight. +- Concurrent queries from multiple tasks share the connection pool naturally. +- Transactions pin all enclosed work to a single typed connection via a context variable, so everything inside `async with transaction():` sees the same database state. + +## Trade-offs + +This design buys speed and type fidelity, but it is honest to name what it costs: + +- **Compiled wheel dependency.** Ferro ships a compiled extension module. Prebuilt wheels cover common platforms; anything else means building from source with a Rust toolchain. +- **Debugging crosses a language boundary.** A stack trace stops at the FFI. Ferro works to surface clear Python exceptions, but stepping a debugger *into* SQL generation or hydration is not possible the way it is with a pure-Python ORM. +- **Limited runtime introspection.** You cannot monkeypatch the engine's internals or hook arbitrary points of the execution pipeline from Python. +- **Young ecosystem.** Ferro is pre-1.0 with a small community, fewer integrations, and fewer Stack Overflow answers than SQLAlchemy or Django ORM. + +## See Also + +- [Identity Map](identity-map.md) — how instance caching works +- [Type Safety](type-safety.md) — the Pydantic integration in depth +- [Backends](backends.md) — SQLite and PostgreSQL specifics +- [Performance](performance.md) — where the Rust core pays off diff --git a/docs/pages/concepts/backends.md b/docs/pages/concepts/backends.md new file mode 100644 index 0000000..864ecbb --- /dev/null +++ b/docs/pages/concepts/backends.md @@ -0,0 +1,107 @@ +# Backends + +Ferro supports SQLite and PostgreSQL through one Python API. Your models, queries, and transactions look identical on both; the Rust core selects the right SQLx driver, SQL dialect, and value conversion rules based on the connection URL. + +## Supported Backends + +The URL scheme passed to `connect()` decides the backend: + +```python +from ferro import connect + +# SQLite — file-backed (rwc = read/write/create) or in-memory +await connect("sqlite:app.db?mode=rwc") +await connect("sqlite::memory:") + +# PostgreSQL — both scheme spellings work +await connect("postgresql://user:password@localhost:5432/app") +await connect("postgres://user:password@localhost:5432/app") +``` + +Hosted PostgreSQL providers such as **Supabase** work with a standard `postgresql://` connection string — Ferro's test matrix runs against Supabase-managed databases as well as local PostgreSQL. + +Unsupported schemes (e.g. `mysql://`) fail at `connect()` time with an error naming the supported schemes. Backend detection happens once, during connection; after that the engine carries its backend kind and typed connection pool, so no operation rediscovers the database from URL strings. + +!!! tip "Schema isolation for PostgreSQL tests" + Ferro supports a private `ferro_search_path` URL parameter (stripped before SQLx connects) that runs `SET search_path TO ` on every pooled connection. Combined with `auto_migrate=True`, this lets many test runs share one PostgreSQL database while each sees only its own schema. Names must be ASCII alphanumeric or `_`. + +## Type Mapping + +Ferro maps Python field types to backend column types from your model annotations. SQLite uses type *affinity* (declared types are advisory), so the practical difference is mostly on the PostgreSQL side, where columns are strictly typed. + +| Python type | SQLite | PostgreSQL | Notes | +|---|---|---|---| +| `int` | `INTEGER` | `INTEGER`/`BIGINT` | Autoincrement PKs come from `last_insert_rowid()` on SQLite; PostgreSQL uses `RETURNING`. Values always materialize as Python `int`. | +| `str` | text affinity | `VARCHAR` | Use `varchar(n)` / `db_type` tokens to pin an explicit length or `TEXT`. | +| `float` | numeric affinity | double precision | | +| `bool` | stored as integer 0/1 | `BOOLEAN` | Hydrates back to Python `bool` on both. | +| `uuid.UUID` | stored as text | native `uuid` | PostgreSQL expressions get explicit `::uuid` casts where needed; values round-trip as `uuid.UUID`. | +| `Decimal` | flexible (text/numeric affinity) | `NUMERIC` | Read as text on the wire so Python reconstructs an exact `Decimal` — no float precision loss. | +| `dict` / `list` | stored as JSON text | `JSON` | PostgreSQL writes cast JSON strings to `json`; reads parse back into Python values. | +| `datetime` / `date` | stored as ISO text | `TIMESTAMPTZ` / `DATE` | Temporal values cross the bridge as ISO strings and reconstruct into `datetime`/`date`. `db_type` tokens select `timestamp` vs `timestamptz`. | +| `Enum` | stored as text | text, or native enum type | Schema metadata carries the enum type name; PostgreSQL applies enum casts where the column uses a native enum type. | +| `bytes` | `BLOB` | `BYTEA` | | + +The guiding rule: **the Python model is the contract**. Backends may store a value differently, but it must hydrate back into the annotated Python type exactly. + +## Backend Differences + +### Placeholders: `?` vs `$1` + +The ORM handles parameter binding for you, but [raw SQL](../guide/queries.md) is passed through to the driver verbatim — placeholders are native to the backend, with no translation layer: + +| Backend | Placeholder syntax | Example | +|---|---|---| +| PostgreSQL | `$1, $2, ...` | `select * from users where id = $1` | +| SQLite | `?` (positional) | `select * from users where id = ?` | + +A mismatch surfaces as the database driver's own error. + +### Type fidelity in raw SQL + +Raw SQL has no schema map, so Ferro does not auto-cast bind values. Rich Python types are marshalled to wire-close primitives (`UUID`, `datetime`, `Decimal` → text; `dict`/`list` → JSON text), and rows come back as plain dicts of primitives. On PostgreSQL you write casts yourself where the column type demands them: + +```python +from ferro import fetch_all + +rows = await fetch_all( + "select id, name from orders where id = $1::uuid and total > $2::numeric", + order_id, + minimum, +) +``` + +SQLite's flexible affinity usually needs no casts. Either way: if you want typed rows (`UUID`, `datetime`, `Decimal` objects), use the ORM — raw SQL is an escape hatch. + +### Migration capabilities + +`connect(auto_migrate=True)` creates missing tables identically on both backends, and `migrate_updates=True` adds missing columns on both. Beyond that, capabilities diverge with what each database can do in place: + +- **PostgreSQL** supports in-place column **type changes** (`ALTER COLUMN ... TYPE ... USING` cast) and **nullability changes** (`SET`/`DROP NOT NULL`) when the live column disagrees with the model. +- **SQLite** cannot alter column types or nullability in place. Ferro emits a `UserWarning` naming the drifted column and pointing you at the [Alembic bridge](../guide/migrations.md). In practice SQLite's type affinity makes declared-type drift mostly cosmetic. + +`migrate_destructive=True` drops model-removed columns on both backends (dependency-aware: covering indexes are dropped first; primary-key or constraint-enforced columns fail with a clear error instead). After any schema change the pool is refreshed so no cached statement observes the pre-migration schema. + +## Troubleshooting + +### `Engine not initialized` + +You called a model or query method before `await connect(...)`. Importing models registers their schemas, but it does not connect to a database — call `connect()` during application startup. + +### Unsupported URL scheme + +Only `sqlite:`, `postgres://`, and `postgresql://` are accepted. Other databases (e.g. MySQL) are not currently supported. + +### UUID or Decimal comparisons fail only on PostgreSQL + +SQLite's affinity hides type mismatches that PostgreSQL enforces. In raw SQL, add explicit casts (`$1::uuid`, `$1::numeric`). Through the ORM this is handled for you — if you hit a case where it isn't, that's a bug worth [reporting](https://github.com/syn54x/ferro-orm/issues). + +### Type or nullability drift warning on SQLite + +`migrate_updates=True` detected that a live column's declared type or nullability disagrees with your model, and SQLite can't change it in place. Use the [Alembic bridge](../guide/migrations.md) for a table-rebuild migration, or ignore it if the drift is cosmetic. + +## See Also + +- [Architecture](architecture.md) — how the backend layer fits into the engine +- [Migrations](../guide/migrations.md) — `auto_migrate` flags and the Alembic bridge +- [Queries](../guide/queries.md) — the query builder and raw SQL escape hatch diff --git a/docs/pages/concepts/identity-map.md b/docs/pages/concepts/identity-map.md new file mode 100644 index 0000000..9188b23 --- /dev/null +++ b/docs/pages/concepts/identity-map.md @@ -0,0 +1,151 @@ +# Identity Map + +Ferro keeps an identity map: within a single process and connection, the same database row always resolves to the same Python object. + +## What It Is + +The identity map is a per-connection cache in the Rust engine, keyed by `(model name, primary key)`. When a query hydrates a row whose primary key is already in the map, Ferro returns the existing instance instead of building a duplicate. + +```mermaid +graph LR + Q1["User.get(1)"] --> IM[Identity Map] + Q2["User.where(...).first()"] --> IM + IM --> Same[Same Python instance] +``` + +Each named connection has its own map — instances loaded through `using("replica")` are tracked separately from instances loaded through the default connection. + +## Why It Matters + +Without an identity map, two queries that return the same row give you two disconnected copies; an update to one is invisible through the other. With it, "row 1" means one object everywhere in your process: + +=== "Assignment" + + ```python + from ferro import Field, Model, connect + + + class User(Model): + id: int | None = Field(default=None, primary_key=True) + username: str + email: str + + + await connect("sqlite::memory:", auto_migrate=True) + + created = await User.create(username="alice", email="alice@example.com") + fetched = await User.get(created.id) + filtered = await User.where(lambda t: t.username == "alice").first() + + # One row, one instance. + assert fetched is created + assert filtered is created + + # A change made through any reference is visible through all of them. + fetched.email = "new@example.com" + assert created.email == "new@example.com" + ``` + +=== "Annotated" + + ```python + from typing import Annotated + + from ferro import Field, Model, connect + + + class User(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + username: str + email: str + + + await connect("sqlite::memory:", auto_migrate=True) + + created = await User.create(username="alice", email="alice@example.com") + fetched = await User.get(created.id) + filtered = await User.where(lambda t: t.username == "alice").first() + + # One row, one instance. + assert fetched is created + assert filtered is created + + # A change made through any reference is visible through all of them. + fetched.email = "new@example.com" + assert created.email == "new@example.com" + ``` + +This guarantee holds within one process and one connection. It does **not** synchronize across processes — if another process (or another tool entirely) updates the database, your cached instance is stale until you refresh or evict it. + +## What Gets Cached + +Instances enter the identity map whenever Ferro hydrates or persists a full model: + +- `Model.get(pk)` and `Model.get_or_none(pk)` +- `.first()` and `.all()` query results +- `Model.create(...)` +- `instance.save()` and `instance.refresh()` + +What does **not** populate the map: + +- `Model.bulk_create([...])` — bulk inserts return a row count, not instances, and deliberately skip the map for memory efficiency. Re-query if you need tracked instances afterward. +- Raw SQL (`fetch_all` / `fetch_one`) — raw rows are plain dicts and never touch the map. + +## Eviction and Refresh + +When you know the database has changed underneath you, you have two tools. + +**Refresh** reloads an instance you hold, in place: + +```python +user = await User.get(1) +# ... something external updates the row ... +await user.refresh() +``` + +**Evict** removes an entry from the map so the *next* fetch hydrates fresh from the database. The primary key is passed as a string: + +```python +from ferro import evict_instance + +evict_instance("User", str(user.id)) + +fresh = await User.get(user.id) # hits the database, new instance +``` + +Eviction is also the lever for long-running batch jobs: cached instances live until evicted or until the engine is reset, so evict processed records if you sweep millions of rows in one process. + +## Opting Out + +The identity map is on by default and can be disabled per connection: + +```python +from ferro import connect + +await connect("sqlite::memory:", auto_migrate=True, identity_map=False) +``` + +With `identity_map=False`, every load returns a fresh instance and the map is never consulted. You give up the `a is b` guarantee across loads in exchange for lower memory use — appropriate for read-heavy ETL-style workloads where instance identity carries no meaning. + +## Identity Map in Tests + +Because the map persists for the lifetime of a connection, tests that share a process must reset state between cases or earlier tests' instances will leak into later ones. `reset_engine()` tears down all connections and their identity maps: + +```python +import pytest + +from ferro import connect, reset_engine + + +@pytest.fixture +async def db(): + await connect("sqlite::memory:", auto_migrate=True) + yield + reset_engine() +``` + +## See Also + +- [Architecture](architecture.md) — where the identity map lives in the engine +- [Performance](performance.md) — memory implications of caching +- [Queries](../guide/queries.md) — query behavior in depth diff --git a/docs/pages/concepts/performance.md b/docs/pages/concepts/performance.md new file mode 100644 index 0000000..d72d05a --- /dev/null +++ b/docs/pages/concepts/performance.md @@ -0,0 +1,108 @@ +# Performance + +Ferro moves SQL generation, parameter binding, and row hydration out of Python and into compiled Rust, with the GIL released during database I/O. This page is honest about where that helps, where it doesn't, and how to get the most out of it. + +## Where the Rust Core Pays Off + +The Rust core helps most where a traditional ORM spends significant CPU time in Python: + +**Bulk inserts.** `bulk_create` serializes and binds an entire batch in Rust and writes it as a single statement. The per-row Python overhead — building parameter lists, driver round-trips, object bookkeeping — largely disappears: + +```python +users = [ + User(username=f"user_{i}", email=f"user{i}@example.com") + for i in range(10_000) +] +await User.bulk_create(users) +``` + +**Hydrating large result sets.** Converting thousands of database rows into model instances is CPU work. Ferro decodes rows and assembles instances in Rust, so queries returning many rows spend far less time in pure-Python parsing loops than a traditional ORM does. + +**Concurrent workloads.** Because the GIL is released while queries are in flight, other coroutines keep running. Many in-flight queries don't serialize behind Python's interpreter lock. + +## Where It Doesn't + +Be skeptical of any ORM benchmark — including ours — that ignores these: + +**Network and disk dominate small operations.** A single-row `get()` spends most of its wall-clock time waiting for the database. Rust can't make the network faster; for point lookups, Ferro performs in the same neighborhood as any async ORM. + +**Slow queries stay slow.** A missing index or a full-table scan costs the same regardless of which library sends the SQL. Ferro doesn't fix query plans. + +**Application logic bottlenecks.** If your endpoint spends its time in business logic, template rendering, or external API calls, swapping the ORM moves nothing. Profile first. + +## Getting the Most Out of Ferro + +**Use `bulk_create` instead of create loops.** One statement beats N statements: + +```python +# Slow: N round-trips +for i in range(1000): + await User.create(username=f"user_{i}") + +# Fast: one bulk statement +await User.bulk_create([User(username=f"user_{i}") for i in range(1000)]) +``` + +Batches in the low thousands (roughly 1,000–5,000 rows) are a good unit of work — large enough to amortize overhead, small enough to keep statements and memory reasonable. Note that `bulk_create` skips the [identity map](identity-map.md) by design. + +**Use batch update/delete instead of instance loops.** Push the work into one SQL statement: + +```python +# Slow: fetch everything, save row by row +users = await User.where(lambda t: t.active == False).all() +for user in users: + user.status = "archived" + await user.save() + +# Fast: one UPDATE +await User.where(lambda t: t.active == False).update(status="archived") +``` + +**Wrap write bursts in a transaction.** Each standalone write commits individually; a transaction amortizes the commit cost across the batch and pins all work to one connection: + +```python +from ferro import transaction + +async with transaction(): + order = await Order.create(total=total) + await OrderLine.bulk_create(lines) +``` + +Keep transactions short — don't hold one open across external API calls. + +**Prefer `.exists()` over `.count()` for presence checks**, and add `index=True` to fields you filter on frequently: + +```python +if await User.where(lambda t: t.email == email).exists(): + raise ValueError("Email taken") +``` + +**Know your identity map effects.** Repeated fetches of the same row return the cached instance rather than re-hydrating, which is a win for hot rows. The flip side: every hydrated instance stays cached for the connection's lifetime, so long-running jobs sweeping huge tables should paginate and evict as they go, or connect with `identity_map=False`. See [Identity Map](identity-map.md). + +**Watch for N+1 relationship access.** Awaiting `post.author` in a loop issues one query per post. Eager loading (`prefetch_related`-style) is [on the roadmap](../roadmap.md); until then, restructure hot paths to filter on the parent side or batch by IDs with `in_()`. + +## Benchmark It Yourself + +We deliberately don't publish "Nx faster" tables here: results vary enormously with database engine, hardware, network latency, batch size, and row shape. If performance matters to your decision, measure your own workload: + +```python +import time + +rows = [User(username=f"user_{i}") for i in range(5000)] + +start = time.perf_counter() +await User.bulk_create(rows) +print(f"bulk_create: {time.perf_counter() - start:.3f}s") + +start = time.perf_counter() +fetched = await User.all() +print(f"all() with hydration: {time.perf_counter() - start:.3f}s") +``` + +For a fair comparison against another ORM: use the same database, the same schema, warmed connections, realistic batch sizes, and run each measurement several times. Compare the operations your application actually performs — not just the ones that flatter either library. + +## See Also + +- [Architecture](architecture.md) — why the engine is shaped this way +- [Identity Map](identity-map.md) — caching behavior and memory +- [Queries](../guide/queries.md) — batch update and delete diff --git a/docs/concepts/query-typing.md b/docs/pages/concepts/query-typing.md similarity index 56% rename from docs/concepts/query-typing.md rename to docs/pages/concepts/query-typing.md index c202317..e5d2bd9 100644 --- a/docs/concepts/query-typing.md +++ b/docs/pages/concepts/query-typing.md @@ -1,23 +1,27 @@ # Typed Query Predicates -Ferro's query DSL accepts three predicate styles on `Model.where`, `Query.where`, and `Relation.where`. They are interchangeable, run on the same code path, and can be mixed freely in the same chain. Pick the one that reads best for the call site you're writing. +Ferro's query DSL accepts three predicate styles on `Model.where`, `Query.where`, and `Relation.where`. They run on the same code path and can be mixed in the same chain, but they are not equal: **lambda predicates are the officially recommended style**, `col()` is the type-safe escape hatch, and the operator style is slated for deprecation. -## Why this exists +## Why Three Predicate Styles Exist Ferro's metaclass replaces every model field with a `FieldProxy` at class-creation time, so `User.archived` is a `FieldProxy` at runtime — and `User.archived == False` builds a `QueryNode`, not a Python `bool`. Static type checkers (Pyright, `ty`, mypy, basedpyright) only see your Pydantic annotations, though, so they read `User.archived` as a `bool` and reject the same expression they would happily run. -The two new predicate styles below give you the runtime ergonomics back without forcing a model-annotation rewrite, a type-checker plugin, or any change to the existing operator path. +The lambda and `col()` styles give you the runtime ergonomics back without forcing a model-annotation rewrite, a type-checker plugin, or any change to the existing operator path. -## The three styles +## The Styles -### 1. Operator (the original) +### 1. Lambda predicate (recommended) ```python -rows = await User.where(User.id == 1).all() -rows = await User.where(User.email.like("%@example.com")).all() +rows = await User.where(lambda t: t.archived == False).all() +rows = await User.where( + lambda t: (t.role == "admin") & (t.active == True) +).all() ``` -Works at runtime, always has, always will. Type checkers may flag boolean-column comparisons (`User.archived == False` resolves statically to `bool`) — when that bites, reach for one of the styles below. +This is the officially recommended style — use it for all new code. The lambda receives a `QueryProxy` whose attribute access yields a fresh `FieldProxy` for each name, so `t.archived == False` is a `QueryNode` from the type checker's point of view as well as at runtime. The call site stays free of `# type: ignore` even when comparing booleans, integers, or any other value type, and the full operator surface is available: `.like()`, `.in_()`, `&`, `|`, `== None`, and shadow FK columns (`t.author_id`). + +The proxy attribute type is currently `FieldProxy[Any]`, which is a deliberate scope decision (see [Scope boundaries](#scope-boundaries) below). Pyright still resolves the predicate's *return* type as `QueryNode` correctly. ### 2. `col()` wrapper @@ -27,40 +31,39 @@ from ferro.query import col rows = await User.where(col(User.archived) == False).all() ``` -`col()` is a runtime-identity helper that statically narrows its argument back to `FieldProxy[T]`. It does no work at runtime beyond an `isinstance` guard (and raises `TypeError` if you accidentally hand it a literal). Reach for it when a single attribute trips your type checker and you don't want to restructure the call site. +`col()` is a runtime-identity helper that statically narrows its argument back to `FieldProxy[T]`. It does no work at runtime beyond an `isinstance` guard (and raises `TypeError` if you accidentally hand it a literal). Reach for it when you want to keep the operator shape on an existing call site while staying type-safe. -### 3. Lambda predicate +### 3. Operator (legacy) ```python -rows = await User.where(lambda t: t.archived == False).all() -rows = await User.where( - lambda t: (t.role == "admin") & (t.active == True) -).all() +rows = await User.where(User.id == 1).all() +rows = await User.where(User.email.like("%@example.com")).all() ``` -The lambda receives a `QueryProxy` whose attribute access yields a fresh `FieldProxy` for each name — so `t.archived == False` is a `QueryNode` from the type checker's point of view as well as at runtime. This is the recommended style for new code: it keeps the call site free of `# type: ignore` even when comparing booleans, integers, or any other value type. +!!! warning "Operator style will be deprecated" + The operator style is compatible today but slated for deprecation in a future release. It also fails static type checking: checkers read `User.id == 1` through your Pydantic annotations as a `bool`, while `where()` expects a `QueryNode | Predicate`. Use lambda predicates for new code, or `col()` when migrating existing operator-style call sites with minimal diff. -The proxy attribute type is currently `FieldProxy[Any]`, which is a deliberate scope decision (see [Scope boundaries](#scope-boundaries) below). Pyright still resolves the predicate's *return* type as `QueryNode` correctly. - -## When to use which +## When to Use Which | Style | Use when | |------|----------| -| Operator | Existing code that already type-checks; quick filters where the value type isn't `bool`. | -| `col()` | One attribute on an existing chain trips your type checker and you want minimal diff. | -| Lambda | New code, especially boolean comparisons or compound predicates; preferred idiom. | +| Lambda | All new code — the official default. Fully type-checked, full operator surface. | +| `col()` | Migrating existing operator-style call sites with minimal diff while staying type-safe. | +| Operator | Legacy/untyped codebases only. Slated for deprecation; fails static type checking. | -All three are equally efficient at runtime — every one of them produces a `QueryNode` and appends it to `where_clause`. +All three are equally efficient at runtime — every one of them produces a `QueryNode` and appends it to the query's where clause. -## Combining styles +## Combining Styles -You can mix all three on a single chain. They compose because they all funnel through the same dispatch in `Query.where`: +You can mix all three on a single chain — useful mid-migration. They compose because they all funnel through the same dispatch in `Query.where`: ```python +from ferro.query import col + rows = await ( - User.where(User.id == 1) # operator + User.where(lambda t: t.role == "admin") # lambda (recommended) .where(col(User.archived) == False) # col() - .where(lambda t: t.role == "admin") # lambda + .where(User.id == 1) # operator (legacy) .all() ) ``` @@ -71,7 +74,7 @@ rows = await ( published = await author.posts.where(lambda t: t.published == True).all() ``` -## What this does not change +## What This Doesn't Change - Your model annotations. `archived: bool = False` stays exactly as it is. - The metaclass's `FieldProxy` injection. Class attribute access is unchanged. @@ -79,11 +82,11 @@ published = await author.posts.where(lambda t: t.published == True).all() - The Rust FFI bridge or how `QueryNode`s are serialized for the engine. - The operator-path runtime. Existing `Model.field == value` calls take the same code path they always have. -## Scope boundaries +## Scope Boundaries The current implementation deliberately stops short of: -- **Per-field types on the lambda proxy.** `t.archived` resolves to `FieldProxy[Any]`, not `FieldProxy[bool]`. Wiring per-field types through the proxy needs `@dataclass_transform` plumbing on the metaclass; that's a future PR. +- **Per-field types on the lambda proxy.** `t.archived` resolves to `FieldProxy[Any]`, not `FieldProxy[bool]`. Wiring per-field types through the proxy needs `@dataclass_transform` plumbing on the metaclass; that's future work. - **A type-checker plugin.** Ferro stays plugin-free. - **A kwargs-style or template-string predicate API.** Both have been considered; neither shipped here. @@ -96,10 +99,7 @@ If `t.archived` resolving as `FieldProxy[Any]` ever bites you statically, drop b - `ferro.query.Predicate` — `Callable[[QueryProxy[TModel]], QueryNode]`, the type of any lambda predicate. - `ferro.query.FieldProxy` — generic over the column's Python type (`FieldProxy[T]`). -See the [Query API reference](../api/query.md) for full signatures. - ## See Also - [Queries Guide](../guide/queries.md) - [Type Safety](type-safety.md) -- [Query API](../api/query.md) diff --git a/docs/pages/concepts/type-safety.md b/docs/pages/concepts/type-safety.md new file mode 100644 index 0000000..b22e8e6 --- /dev/null +++ b/docs/pages/concepts/type-safety.md @@ -0,0 +1,110 @@ +# Type Safety + +Ferro is built on Pydantic V2 and Python's type system. Models validate at runtime, queries return precisely typed results, and the Rust boundary ships with type stubs — so both your IDE and your type checker understand Ferro code. + +## Pydantic at the Core + +Ferro models *are* Pydantic models, not Pydantic-flavored lookalikes: + +```python +from pydantic import BaseModel + +from ferro import Model + + +class User(Model): + username: str + age: int + + +assert issubclass(User, BaseModel) + +user = User(username="alice", age=30) +print(user.model_dump()) # {'username': 'alice', 'age': 30} +print(user.model_dump_json()) # '{"username":"alice","age":30}' +``` + +Everything Pydantic gives you — `model_dump`, `model_validate`, JSON schema generation, serialization config — works unchanged. And validation runs on the write paths: `Model(...)` construction, `Model.create(...)`, and `instance.save()` all go through Pydantic before any data reaches the database. + +```python +from pydantic import ValidationError + +try: + User(username="alice", age="not a number") +except ValidationError as e: + print(e) # age: Input should be a valid integer +``` + +## Static Typing + +Ferro's public API carries full type hints, so static checkers (mypy, Pyright, Pylance) and your IDE know exactly what each call returns: + +```python +# get() returns the model — and raises ModelDoesNotExist if missing +user: User = await User.get(1) + +# get_or_none() makes absence explicit +maybe_user: User | None = await User.get_or_none(999) + +# Query terminals are typed +users: list[User] = await User.all() +first: User | None = await User.where(lambda t: t.age >= 18).first() +n: int = await User.where(lambda t: t.age >= 18).count() +present: bool = await User.where(lambda t: t.username == "alice").exists() +``` + +Because results are real model instances with annotated fields, downstream code is checked too: `user.username` is a `str`, `user.age` is an `int`, and `user.nonexistent` is a type error. + +## IDE Support + +The same annotations drive autocomplete: + +- Model instances complete their fields and methods (`save`, `delete`, `refresh`, ...). +- `Model.where(...)`, `.order_by(...)`, `.limit(...)` chains preserve the model type, so `await User.where(...).first()` completes `User` attributes on the result. +- Field names complete when you type `User.` inside a query expression. + +No plugins are required — Ferro deliberately stays plugin-free and relies on standard typing constructs (`Self`, generics, overloads). + +## The Rust Boundary + +The compiled extension module can't be introspected by type checkers, so Ferro ships a stub file (`src/ferro/_core.pyi`) describing every FFI function — `connect`, `create_tables`, `migrate`, the fetch/save primitives, transaction control, and raw SQL entry points. The package is also marked with `py.typed`, so type checkers pick all of this up automatically when you depend on `ferro-orm`. + +In practice you rarely touch `ferro._core` directly; the typed Python layer (`Model`, `Query`, `connect`, `transaction`) is the public API, and the stub exists so that even the boundary itself is checkable. + +## Validators and Coercion + +Because models are Pydantic models, the full validator toolbox applies: + +```python +from pydantic import field_validator + +from ferro import Model + + +class Account(Model): + username: str + email: str + + @field_validator("email") + @classmethod + def normalize_email(cls, v: str) -> str: + if "@" not in v: + raise ValueError("Invalid email") + return v.lower() +``` + +Validators run on construction and on `create`/`save`, so invalid data is rejected before it is written. Pydantic's coercion rules also apply — `Account(username="a", email="A@B.COM")` stores `"a@b.com"`, and numeric strings coerce to `int`/`float` fields under Pydantic's standard (non-strict) mode. + +Rich field types validate end to end: `datetime`, `date`, `Decimal`, `UUID`, enums, and JSON-shaped `dict`/`list` fields all round-trip through the database back into their proper Python types. See [Backends](backends.md) for how each maps to column types. + +## Limits + +One place where static typing is weaker than the runtime: **query predicates**. The metaclass replaces `User.age` with a `FieldProxy` at runtime, but type checkers see the annotation (`int`), so an expression like `User.archived == False` types as `bool` rather than as a query node. Ferro provides two additional predicate styles — `col()` and lambda predicates — that restore full static cleanliness without `# type: ignore`. + +This is a deep enough topic to get its own page: see [Typed Query Predicates](query-typing.md). + +## See Also + +- [Typed Query Predicates](query-typing.md) — the three predicate styles +- [Models & Fields](../guide/models-and-fields.md) +- [Architecture](architecture.md) diff --git a/docs/pages/contributing.md b/docs/pages/contributing.md new file mode 100644 index 0000000..c54b169 --- /dev/null +++ b/docs/pages/contributing.md @@ -0,0 +1,5 @@ +# Contributing + +Ferro welcomes contributions — bug reports, docs fixes, and code across both the Python and Rust layers. The guide below is the canonical contributor reference from the repository. + +--8<-- "CONTRIBUTING.md:contributing" diff --git a/docs/pages/faq.md b/docs/pages/faq.md new file mode 100644 index 0000000..b262c5e --- /dev/null +++ b/docs/pages/faq.md @@ -0,0 +1,103 @@ +# Frequently Asked Questions + +## General + +### What is Ferro? + +Ferro is an async Python ORM with a Rust core. Models are Pydantic V2 `BaseModel` subclasses; SQL generation, connection pooling, query execution, and row hydration happen in compiled Rust (PyO3 + SQLx + Sea-Query). You write ordinary async Python — the Rust engine is invisible at the API level. See [Architecture](concepts/architecture.md). + +### Is Ferro production-ready? + +Ferro is pre-1.0. The core feature set — models, queries, relationships, transactions, named multi-database connections, raw SQL, auto-migration, and the Alembic bridge — is implemented and tested against both SQLite and PostgreSQL on every change. But pre-1.0 means what it says: APIs may still shift between minor versions, the community is small, and you'll find fewer battle scars documented than for SQLAlchemy or Django ORM. + +A reasonable posture: well suited to new projects and services where you control the upgrade cadence and have good test coverage; pin your version, read the [changelog](changelog.md) before upgrading, and report what you hit. + +### What license is Ferro under? + +Apache-2.0. + +### Why does Ferro require Python 3.13+? + +Ferro is a young project and deliberately targets current Python rather than carrying compatibility shims for older interpreters. That keeps the codebase on modern typing features and current Pydantic and PyO3 releases. The floor may widen as the project matures, but supporting older Pythons is not a near-term goal. + +### Do I need to know Rust? + +No. Ferro's API is 100% Python and ships as a prebuilt wheel. Rust only enters the picture if you build from source or contribute to the engine itself. + +## Features + +### Does Ferro support synchronous code? + +No — Ferro is async-only. Every database operation is a coroutine (`await User.all()`). There is no sync facade and none is planned; if your application is synchronous, you'd be running an event loop per call (`asyncio.run(...)`), which works for scripts but defeats the purpose in a server. Ferro is designed for async frameworks like FastAPI, Litestar, and aiohttp. + +### Which databases are supported? + +SQLite and PostgreSQL (including hosted providers such as Supabase). MySQL and other databases are not currently supported — unsupported URL schemes fail at `connect()` time. See [Backends](concepts/backends.md). + +### What's the migrations story? + +Two tiers: + +- **Auto-migration at connect time** — `connect(url, auto_migrate=True)` creates missing tables. As of 0.11.0, `migrate_updates=True` also adds missing columns (and reconciles type/nullability drift on PostgreSQL), and `migrate_destructive=True` drops model-removed columns. Great for development and simple deployments. +- **Alembic bridge** — for versioned, reviewable production migrations and anything auto-migrate can't express (renames, primary-key changes, complex transforms). Install with `pip install "ferro-orm[alembic]"`. + +See [Migrations](guide/migrations.md). + +### Does Ferro support multiple databases? + +Yes — register each pool under a name and route explicitly: + +```python +import ferro + +await ferro.connect(APP_DATABASE_URL, name="app", default=True) +await ferro.connect(SERVICE_DATABASE_URL, name="service") + +users = await User.all() # default connection +jobs = await Job.using("service").all() # explicit routing +``` + +Automatic read/write splitting, cross-database joins, and distributed transactions are out of scope. + +### Does Ferro support raw SQL? + +Yes. `execute`, `fetch_all`, and `fetch_one` (top-level or on the handle yielded by `transaction()`) run parameterized raw SQL. Rows come back as plain dicts of primitives — it's an escape hatch, not a typed query path. See [Queries](guide/queries.md). + +### Does Ferro have eager loading (`prefetch_related` / `select_related`)? + +Not yet — it's on the [roadmap](roadmap.md). Today, awaiting a relationship attribute issues a query, so be deliberate about relationship access inside loops (the classic N+1 pattern). + +## Performance + +### Is Ferro faster than other Python ORMs? + +For CPU-bound ORM work — bulk inserts, hydrating large result sets, heavy concurrent query loads — yes, meaningfully: that work runs in Rust with the GIL released. For network- or disk-bound operations (single-row fetches, slow queries, remote databases), wait time dominates and any async ORM performs similarly. We intentionally don't publish benchmark multipliers; measure your own workload. See [Performance](concepts/performance.md) for what to optimize and how to benchmark fairly. + +## Troubleshooting + +### I get "Engine not initialized" + +You ran a query before connecting. Importing models registers their schemas but does not open a database connection — call `await ferro.connect(...)` during application startup, before the first query. + +### I get "Relationship resolution failed" or an error about `related_name` + +Relationships are declared on both sides and cross-validated when you connect. Two common causes: + +- The model named in a string/forward reference (`"Author"`) was never imported, so it isn't registered — import all your model modules before `connect()`. +- The target model is missing the reverse field: a `ForeignKey(related_name="posts")` requires the target to declare `posts: Relation[list["Post"]] = BackRef()`. The error message names the model and field to add. + +See [Relationships](guide/relationships.md). + +### I get `ModelDoesNotExist` + +`Model.get(pk)` raises `ferro.ModelDoesNotExist` (a `LookupError` subclass) when no row matches the primary key. If absence is an expected case, use `Model.get_or_none(pk)`, which returns `None` instead: + +```python +user = await User.get_or_none(user_id) +if user is None: + ... # handle missing row +``` + +### Where do I report bugs or ask questions? + +Bugs and feature requests: [GitHub Issues](https://github.com/syn54x/ferro-orm/issues). Questions and discussion: [GitHub Discussions](https://github.com/syn54x/ferro-orm/discussions). diff --git a/docs/pages/getting-started/installation.md b/docs/pages/getting-started/installation.md new file mode 100644 index 0000000..52b65a6 --- /dev/null +++ b/docs/pages/getting-started/installation.md @@ -0,0 +1,76 @@ +# Installation + +## Requirements + +- **Python 3.13 or newer** +- macOS, Linux, or Windows — pre-compiled wheels are published for all three, so no Rust toolchain is needed for a normal install + +## Install + +=== "uv" + + ```bash + uv add ferro-orm + ``` + +=== "pip" + + ```bash + pip install ferro-orm + ``` + +### Migration support + +Schema migrations use Alembic. Install the optional extra to get it: + +=== "uv" + + ```bash + uv add "ferro-orm[alembic]" + ``` + +=== "pip" + + ```bash + pip install "ferro-orm[alembic]" + ``` + +This pulls in Alembic (and SQLAlchemy, which Alembic uses for migration generation only — Ferro never uses it at runtime). See [Migrations](../guide/migrations.md) for the workflow. + +## Database Drivers + +You don't need any. Ferro's Rust engine bundles SQLite and PostgreSQL support via SQLx, so there are no driver packages to install or configure — `pip install ferro-orm` is enough for both backends. + +## Building from Source + +!!! note + Most users never need this. Pre-compiled wheels cover all common platforms. + +Building from source (for example, on an unsupported platform) requires a recent Rust toolchain and [maturin](https://www.maturin.rs/): + +```bash +# Install Rust +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + +# Clone and build +git clone https://github.com/syn54x/ferro-orm.git +cd ferro-orm +pip install maturin +maturin develop --release +``` + +Expect the first compile to take a few minutes. + +## Verify Your Installation + +```python +import ferro + +print(ferro.version()) +``` + +If this prints a version number, you're ready to go. + +## Next Steps + +Build your first app with the [Quickstart Tutorial](quickstart.md). diff --git a/docs/pages/getting-started/next-steps.md b/docs/pages/getting-started/next-steps.md new file mode 100644 index 0000000..9f09349 --- /dev/null +++ b/docs/pages/getting-started/next-steps.md @@ -0,0 +1,56 @@ +# Next Steps + +You've finished the [Quickstart](quickstart.md) and have a working Ferro app. Here's where to go next, based on what you're building. + +## Learn by Use Case + +### Building an API + +For REST APIs with FastAPI, Starlette, or similar: + +1. **[Models & Fields](../guide/models-and-fields.md)** — field types, constraints, and defaults +2. **[Relationships](../guide/relationships.md)** — foreign keys, back-references, many-to-many +3. **[Queries](../guide/queries.md)** — filtering with lambda predicates, ordering, and pagination +4. **[How-To: Pagination](../howto/pagination.md)** — efficient offset and cursor pagination +5. **[How-To: Testing](../howto/testing.md)** — fast, isolated tests with in-memory SQLite + +### Data Processing + +For ETL pipelines and bulk workloads: + +1. **[Mutations](../guide/mutations.md)** — `bulk_create` and bulk update/delete for throughput +2. **[Transactions](../guide/transactions.md)** — make multi-step writes atomic +3. **[Queries](../guide/queries.md)** — filter in the database, not in Python + +### Production Deployment + +When you're ready to ship: + +1. **[Connections & Databases](../guide/connections.md)** — connection URLs and pool configuration +2. **[Migrations](../guide/migrations.md)** — the Alembic workflow for evolving schemas +3. **[How-To: Multiple Databases](../howto/multiple-databases.md)** — working with more than one database +4. **[How-To: Testing](../howto/testing.md)** — a test suite you can trust before deploying + +## Common Patterns + +Recipes for things most applications need: + +- **[Testing](../howto/testing.md)** — per-test database isolation and fixtures +- **[Pagination](../howto/pagination.md)** — offset- and cursor-based pagination +- **[Timestamps](../howto/timestamps.md)** — `created_at` / `updated_at` on every model +- **[Soft Deletes](../howto/soft-deletes.md)** — flag rows as deleted instead of removing them +- **[Multiple Databases](../howto/multiple-databases.md)** — connecting to more than one database +- **[Migrating from SQLAlchemy](../howto/migrate-from-sqlalchemy.md)** — a side-by-side translation guide + +## Reference Material + +- **API Reference** — complete documentation for every public class and method: [Model](../api/model.md), [Queries](../api/queries.md), [Fields & Types](../api/fields.md), [Relationships](../api/relationships.md) +- **Concepts** — how Ferro works under the hood: [Architecture](../concepts/architecture.md), [Identity Map](../concepts/identity-map.md), [Type Safety](../concepts/type-safety.md) +- **[Roadmap](../roadmap.md)** — what's implemented today and what's planned + +## Get Help + +- **[GitHub Issues](https://github.com/syn54x/ferro-orm/issues)** — report bugs or request features +- **[GitHub](https://github.com/syn54x/ferro-orm)** — star the repo to follow releases + +The best way to learn from here is to build something real — a blog, a ticket tracker, an inventory API — and reach for the guides above as questions come up. diff --git a/docs/pages/getting-started/quickstart.md b/docs/pages/getting-started/quickstart.md new file mode 100644 index 0000000..51a09f0 --- /dev/null +++ b/docs/pages/getting-started/quickstart.md @@ -0,0 +1,103 @@ +# Quickstart + +In about 10 minutes you'll build a small blog backend: two related models (`Author` and `Post`), an in-memory SQLite database, and every core Ferro operation — create, query, traverse relationships, update, delete, and transactions. + +Every code block on this page comes from one runnable script, shown in full at the [bottom of the page](#complete-script). Follow along in a file of your own, or just run the script. + +## Define Your Models + +Ferro supports two equivalent field-declaration styles — options on the assignment side, or inside `typing.Annotated`. Every model example in these docs shows both; pick one and stay consistent in your project. + +=== "Assignment" + + ```python + --8<-- "docs/examples/quickstart.py:models" + ``` + +=== "Annotated" + + ```python + --8<-- "docs/examples/quickstart_annotated.py:models" + ``` + +A Ferro model is a Pydantic model — annotated fields become columns, and defaults work exactly as in Pydantic: + +- `Field(default=None, primary_key=True)` marks `id` as the primary key. It's `int | None` because the database assigns it on insert. +- `Field(unique=True)` adds a unique constraint; `default_factory=datetime.now` gives each post a creation timestamp. +- `Annotated[Author, ForeignKey(related_name="posts")]` declares the many-to-one side: each `Post` stores an `author_id` column pointing at an `Author`. +- `Relation[list["Post"]] = BackRef()` is the reverse side: `author.posts` becomes a chainable query for that author's posts. `related_name="posts"` is what links the two. + +## Connect + +```python +--8<-- "docs/examples/quickstart.py:connect" +``` + +`connect()` takes a database URL. `sqlite::memory:` gives you a throwaway in-memory database — perfect for this tutorial and for tests. For a file-backed database use `sqlite:app.db?mode=rwc` (`rwc` = read/write/create), or a `postgres://...` URL for PostgreSQL. + +`auto_migrate=True` creates tables for every registered model on connect. It's great for development; for production schemas, use [Alembic migrations](../guide/migrations.md). + +## Create Data + +```python +--8<-- "docs/examples/quickstart.py:create" +``` + +- `Model.create(...)` validates the data, inserts one row, and returns the instance with its database-assigned `id` populated. Notice you can pass a model instance (`author=alice`) for the foreign key. +- `Model.bulk_create([...])` inserts many rows in a single statement — use it whenever you're loading more than a handful of rows. Here we set `author_id` directly instead of passing the instance. + +## Query + +```python +--8<-- "docs/examples/quickstart.py:query" +``` + +- `Post.get(pk)` fetches one row by primary key. +- `where(...)` filters, `order_by(...)` sorts, `limit(...)` slices — and nothing touches the database until a terminal like `.all()`, `.first()`, `.count()`, or `.exists()` runs the query. +- `lambda t: t.published == True` is a lambda predicate — the officially recommended query style. Two other styles exist for compatibility; see [Queries](../guide/queries.md#predicate-styles) for the comparison. + +!!! note "What happened" + Thanks to Ferro's identity map, `Post.get(post.id)` returns the *same Python object* as the `post` you created earlier — not a duplicate copy. One row, one instance. + +## Work with Relationships + +```python +--8<-- "docs/examples/quickstart.py:relationships" +``` + +Two directions, two idioms: + +- **Forward** (`post.author`): awaiting the foreign key attribute loads the related `Author`. +- **Reverse** (`author.posts`): the `BackRef` is a query, so you can chain `.where()`, `.order_by()`, and friends before awaiting it. + +## Update & Delete + +```python +--8<-- "docs/examples/quickstart.py:update-delete" +``` + +- For a single instance: mutate attributes, then `await post.save()`. +- For many rows: chain `.update(field=value)` or `.delete()` onto a `where()` query. Both return the number of affected rows. + +## Wrap It in a Transaction + +```python +--8<-- "docs/examples/quickstart.py:transaction" +``` + +Everything inside `async with transaction():` commits together when the block exits cleanly — and rolls back entirely if it raises. Use it whenever multiple writes must succeed or fail as one. More in [Transactions](../guide/transactions.md). + +## Complete Script + +The whole tutorial as one runnable file — it lives in the repo at `docs/examples/quickstart.py`: + +```python +--8<-- "docs/examples/quickstart.py" +``` + +## What's Next + +- [Next Steps](next-steps.md) — pick a path based on what you're building +- [Models & Fields](../guide/models-and-fields.md) — every field type and constraint +- [Queries](../guide/queries.md) — the lambda predicate style in depth, ordering, slicing, terminals +- [Relationships](../guide/relationships.md) — foreign keys, back-references, many-to-many diff --git a/docs/pages/guide/connections.md b/docs/pages/guide/connections.md new file mode 100644 index 0000000..4c4b405 --- /dev/null +++ b/docs/pages/guide/connections.md @@ -0,0 +1,176 @@ +# Connections & Databases + +Ferro talks to SQLite and PostgreSQL through connection pools managed by the Rust core (SQLx). Connect once at application startup with `await ferro.connect(url, ...)` before performing any database operation. + +## Connecting + +### SQLite + +```python +import ferro + +# File database — `mode=rwc` creates the file if it doesn't exist (recommended) +await ferro.connect("sqlite:app.db?mode=rwc") + +# In-memory database (great for tests) +await ferro.connect("sqlite::memory:") +``` + +Modes: `rwc` (read/write/create), `rw` (read/write, file must exist), `ro` (read-only). + +### PostgreSQL + +```python +import ferro + +await ferro.connect("postgresql://user:password@localhost:5432/dbname") + +# Require TLS +await ferro.connect("postgresql://user:password@localhost:5432/dbname?sslmode=require") +``` + +Load credentials from the environment rather than hard-coding them, and percent-encode reserved characters in passwords (`%24` for `$`, etc.) if you assemble URLs yourself. + +### Supabase + +Supabase hosts PostgreSQL behind TLS; Ferro's driver stack ships with Rustls and the webpki CA bundle, so published wheels connect out of the box. Copy the exact URI from **Project Settings → Database** in the Supabase dashboard (pooler hostnames look like `*.pooler.supabase.com`, and the username may include the project ref), then ensure TLS is requested: + +```python +import os + +import ferro + +url = os.environ["DATABASE_URL"] +if "sslmode=" not in url: + sep = "&" if "?" in url else "?" + url = f"{url}{sep}sslmode=require" + +await ferro.connect(url) +``` + +Keep service-role credentials server-side, and never make an elevated connection the default in user-facing runtimes. + +## Connection Options + +`connect()` accepts: + +| Parameter | Default | Description | +| :--- | :--- | :--- | +| `url` | required | Database connection string. | +| `auto_migrate` | `False` | Create tables for all registered models; existing tables are left untouched. | +| `name` | `None` | Connection name. Omitted connections register as `"default"`. | +| `default` | `False` | Make this named connection the default for unqualified operations. | +| `pool` | `None` | A [`PoolConfig`](#connection-pooling); defaults apply when omitted. | +| `identity_map` | `True` | Keep a per-connection [identity map](../concepts/identity-map.md) so one primary key maps to one Python instance. `False` trades the `a is b` guarantee for lower memory use. | +| `migrate_updates` | `False` | Also `ALTER` existing tables to match the models (implies `auto_migrate`). See [Schema Migrations](migrations.md#applying-column-changes-with-migrate_updates). *Added in 0.11.0.* | +| `migrate_destructive` | `False` | Also drop live columns removed from the models (implies `migrate_updates`). See [Schema Migrations](migrations.md#destructive-drops-with-migrate_destructive). *Added in 0.11.0.* | + +## Connection Pooling + +Size each connection's pool with `PoolConfig`: + +```python +import ferro + +await ferro.connect( + "postgresql://localhost/app", + pool=ferro.PoolConfig(max_connections=10, min_connections=1), +) +``` + +`max_connections` defaults to 5, `min_connections` to 0, and `min_connections` may not exceed `max_connections`. For web applications, connect once at startup and let the long-lived pool serve all requests. + +## Named Connections + +Ferro can hold multiple live pools in one process — separate databases, roles, or pool sizes: + +```python +--8<-- "docs/examples/multiple_databases.py:connect" +``` + +Route individual operations with `Model.using("name")`, which exposes the full ORM surface (`create`, `get`, `where`, `bulk_create`, `get_or_create`, ...) bound to that connection: + +```python +--8<-- "docs/examples/multiple_databases.py:routing" +``` + +Raw SQL routes the same way via `using=`: `await ferro.execute("...", using="analytics")`. Transactions opened with `transaction(using="analytics")` pin everything inside to that connection — see [Transactions](transactions.md#using-named-connections). + +!!! warning "Connection names are code, not input" + Choose `using` values from constants or trusted server-side logic. Never bind connection names from request parameters, headers, or other untrusted input. + +Ferro does not provide automatic router policies, read/write splitting, distributed transactions, or cross-connection joins — route each operation explicitly. See [Multiple Databases](../howto/multiple-databases.md) for fuller patterns. + +## The Default Connection + +Unqualified operations (`User.all()`, top-level `execute(...)`) use the default connection. It is established three ways: + +1. `connect(url)` without a `name` registers as `"default"`. +2. `connect(url, name="app", default=True)` makes a named connection the default. +3. `ferro.set_default_connection("app")` switches the default at runtime. + +```python +import ferro + +await ferro.connect("sqlite:app.db?mode=rwc", name="app", default=True) +await ferro.connect("sqlite:analytics.db?mode=rwc", name="analytics") + +ferro.set_default_connection("analytics") # unqualified ops now hit analytics +``` + +## Creating Tables Manually + +To control table creation yourself instead of `auto_migrate=True`, make sure your models are imported (importing registers them), then call `create_tables()`: + +```python +import ferro + + +async def init() -> None: + await ferro.connect("sqlite::memory:") + + from myapp.models import Post, User # noqa: F401 — importing registers models + + await ferro.create_tables() # default connection + await ferro.create_tables(using="analytics") # a named connection +``` + +`create_tables()` creates missing tables (including many-to-many join tables) and never modifies existing ones. + +## Postgres Schema Isolation + +Append the Ferro-specific `ferro_search_path` URL parameter to pin a connection to a PostgreSQL schema. Ferro strips the parameter from the URL before handing it to the driver and applies `SET search_path` on every pooled connection: + +```python +import ferro + +await ferro.connect( + "postgresql://user:password@localhost:5432/app?ferro_search_path=tenant_a" +) +``` + +The value must be a single identifier of ASCII letters, digits, and underscores — anything else raises `ValueError`. This is handy for schema-per-tenant setups and for isolating test runs against one physical database. + +## Resetting the Engine + +`ferro.reset_engine()` tears down all pools and connection state — primarily for test suites that reconnect with a fresh database per test: + +```python +import ferro + +ferro.reset_engine() +await ferro.connect("sqlite::memory:", auto_migrate=True) +``` + +See [Testing](../howto/testing.md) for a ready-made pytest fixture. + +!!! note "No `disconnect()` yet" + An explicit `disconnect()` is **not yet implemented** — pools are cleaned up on process exit. See the [Roadmap](../roadmap.md). + +## See Also + +- [Schema Migrations](migrations.md) — `auto_migrate` flags and Alembic +- [Transactions](transactions.md) — connection affinity +- [Multiple Databases](../howto/multiple-databases.md) — multi-connection patterns +- [Testing](../howto/testing.md) — test database setup +- [Database Backends](../concepts/backends.md) — SQLite vs PostgreSQL specifics diff --git a/docs/pages/guide/migrations.md b/docs/pages/guide/migrations.md new file mode 100644 index 0000000..65e3dac --- /dev/null +++ b/docs/pages/guide/migrations.md @@ -0,0 +1,162 @@ +# Schema Migrations + +Ferro offers a ladder of schema-management options: zero-config auto-migration for development, opt-in schema updates for fast iteration, and an Alembic bridge for production. + +## Three Ways to Manage Schema + +| Approach | Flag / tool | What it does | Best for | +| :--- | :--- | :--- | :--- | +| Auto-create | `connect(..., auto_migrate=True)` | Creates missing tables; never touches existing ones. | Development, tests, local-first apps | +| Auto-update | `connect(..., migrate_updates=True)` and optionally `migrate_destructive=True` | Additionally `ALTER`s existing tables to match the models. *0.11.0+* | Development while the schema is moving | +| Alembic | `ferro-orm[alembic]` + `alembic` CLI | Versioned, reviewable migration scripts. | Production | + +The flags form a ladder — `migrate_destructive` implies `migrate_updates`, which implies `auto_migrate` — so passing just the strongest flag you want is enough. + +## Auto-Migration + +### Creating tables with `auto_migrate=True` + +```python +import ferro + +await ferro.connect("sqlite:dev.db?mode=rwc", auto_migrate=True) +``` + +Creates tables for every registered model (including many-to-many join tables) and leaves existing tables untouched. + +### Applying column changes with `migrate_updates` + +*Added in 0.11.0.* When models gain or change fields between runs, `migrate_updates=True` reconciles existing tables at connect time: + +```python +import ferro + +await ferro.connect("sqlite:dev.db?mode=rwc", migrate_updates=True) +``` + +What it covers is capability-relative per backend: + +| Change | SQLite | PostgreSQL | +| :--- | :--- | :--- | +| Add missing column | ✅ `ADD COLUMN` | ✅ `ADD COLUMN` | +| Add the column's index (`index=True`) | ✅ `CREATE INDEX` | ✅ `CREATE INDEX` | +| Add unique column (`unique=True`) | ✅ via explicit unique index + warning | ✅ inline `UNIQUE` | +| Add foreign-key column | ✅ column only, no FK constraint + warning | ✅ column + FK constraint | +| Change column type | ⚠️ `UserWarning`, no DDL (SQLite type affinity makes drift mostly cosmetic) | ✅ `ALTER COLUMN ... TYPE ... USING` cast | +| Change nullability | ⚠️ `UserWarning`, no DDL | ✅ `SET NOT NULL` / `DROP NOT NULL` | +| Rename column/table, change primary key, drop table | ❌ never — Alembic territory | ❌ never | + +Rules worth knowing: + +- **NOT NULL additions need a literal default.** Existing rows must be backfilled, so a new required field without a literal default fails the connect with a clear error. Make it nullable, give it a default, or use Alembic. +- **Added columns reuse the exact `CREATE TABLE` DDL**, so a database brought forward by `migrate_updates` matches one created fresh, and `alembic revision --autogenerate` stays clean afterwards. +- **Postgres type changes take an exclusive lock** and fail the connect if existing data does not cast cleanly — fine for a development flag, but worth knowing. +- **The pool refreshes after any schema change**, so no cached statement or stale identity-mapped instance can observe the pre-migration schema. + +### Destructive drops with `migrate_destructive` + +*Added in 0.11.0.* Also **drop** live columns that no longer exist on the model (never whole tables): + +```python +import ferro + +await ferro.connect("sqlite:dev.db?mode=rwc", migrate_destructive=True) +``` + +Dropping is dependency-aware and fails loudly rather than skipping silently: + +- Explicit indexes covering a dropped column are dropped first (they would be orphaned anyway). +- Columns that are **primary keys**, enforced by table constraints, or referenced by other tables' **foreign keys** abort with a clear error pointing at Alembic. + +### On-demand `migrate()` + +Run the same pass explicitly on a live connection instead of at connect time: + +```python +import ferro + +await ferro.migrate() # create missing tables + apply updates (default) +await ferro.migrate(destructive=True) # also drop removed columns +await ferro.migrate(using="service") # against a named connection +``` + +### Safety guidance + +!!! danger "Never use destructive auto-migration in production" + `auto_migrate` and its extension flags are for development and local-first apps whose schema is still moving. `migrate_destructive` deletes data the moment a field is removed from a model. For production, use [Alembic](#alembic-for-production) — renames, primary-key changes, and data transforms are deliberately out of auto-migrate's scope. + +## Alembic for Production + +Ferro doesn't reinvent migrations: it bridges your models into SQLAlchemy metadata that [Alembic](https://alembic.sqlalchemy.org/) — the industry-standard migration tool — uses to autogenerate versioned, reviewable migration scripts. + +### Install + +```bash +pip install "ferro-orm[alembic]" +``` + +This adds Alembic and SQLAlchemy (used only for migration generation, not at runtime). + +### Initialize + +```bash +alembic init migrations +``` + +This scaffolds `alembic.ini` plus a `migrations/` directory containing `env.py` and `versions/`. + +### Configure env.py + +Point Alembic's `target_metadata` at Ferro's bridge. Models must be imported so they register: + +```python +# migrations/env.py +from ferro.migrations import get_metadata + +from myapp.models import Comment, Post, User # noqa: F401 — importing registers models + +target_metadata = get_metadata() + +# The rest of env.py stays as generated. +``` + +`get_metadata()` produces a faithful SQLAlchemy reflection of your models: + +- **Nullability** follows the same rules as the runtime schema: with the default `nullable="infer"`, a column is nullable iff its annotation allows `None` (a default alone does not make it nullable); shadow `*_id` columns infer from the *relation* annotation; `on_delete="SET NULL"` implies nullable; explicit `nullable=True/False` overrides. Primary keys are always `NOT NULL`. +- **Composite constraints** (`__ferro_composite_uniques__`, `__ferro_composite_indexes__`) emit matching `UniqueConstraint` / `Index` objects, including the automatic constraints on many-to-many join tables. +- **One-to-one** relations (`ForeignKey(unique=True)`) emit the same `UNIQUE` on the shadow column that `auto_migrate` creates at runtime. +- **Enums** map to named `sqlalchemy.Enum` types (class name lowercased, e.g. `UserRole` → `userrole`) so revisions compile on PostgreSQL, which rejects anonymous enum types. + +### Autogenerate + +```bash +alembic revision --autogenerate -m "add posts table" +``` + +Alembic diffs the metadata against the live database and writes a script to `migrations/versions/`. + +### Review & apply + +**Always review generated migrations** before applying them — autogenerate is a diff tool, not a judgment tool: + +```bash +alembic upgrade head # apply +alembic current # show the applied revision +alembic downgrade -1 # roll back one revision +``` + +The day-to-day loop: change models → `alembic revision --autogenerate` → review → `alembic upgrade head` → commit the migration file. For data migrations and zero-downtime patterns (additive change → backfill → tighten), create empty revisions with `alembic revision -m "..."` and write the `op.execute(...)` steps yourself. + +## Choosing a Workflow + +- **Development**: `connect(..., migrate_updates=True)` (add `migrate_destructive=True` if you also want column drops). Your schema follows your models with zero ceremony, and warnings tell you when a change exceeds what in-place DDL can do. +- **Production**: Alembic, exclusively. Migrations are reviewed, versioned, reversible, and can express everything auto-migrate refuses to touch (renames, PK changes, data transforms). Back up before upgrading, and test `downgrade` paths. + +Because `migrate_updates` emits the same DDL as a fresh `CREATE TABLE`, you can develop with auto-migration and switch to Alembic when the schema stabilizes — the first `--autogenerate` against an auto-migrated database produces a clean baseline. + +## See Also + +- [Connections & Databases](connections.md) — `connect()` options +- [Models & Fields](models-and-fields.md) — how fields map to columns +- [Relationships](relationships.md) — FK constraints and join tables +- [Migrations API reference](../api/migrations.md) — `get_metadata()` details diff --git a/docs/pages/guide/models-and-fields.md b/docs/pages/guide/models-and-fields.md new file mode 100644 index 0000000..5ff14f7 --- /dev/null +++ b/docs/pages/guide/models-and-fields.md @@ -0,0 +1,477 @@ +# Models & Fields + +Models are the central building blocks of Ferro. They define your schema in plain Python type hints, validate data with Pydantic, and are mapped to database tables by the Rust engine. + +## Defining a Model + +Inherit from `ferro.Model` and declare fields with standard type annotations: + +=== "Assignment" + + ```python + --8<-- "docs/examples/quickstart.py:models" + ``` + +=== "Annotated" + + ```python + --8<-- "docs/examples/quickstart_annotated.py:models" + ``` + +Every Ferro model is a full [Pydantic](https://docs.pydantic.dev/latest/) `BaseModel`, so validation, serialization (`model_dump()`, `model_dump_json()`), and `model_config` all work as you'd expect. + +## Declaration Styles + +Ferro's `Field()` merges database options (`primary_key`, `unique`, `index`, ...) with Pydantic's validation options in a single call. You can attach it in two equivalent ways: + +=== "Assignment with Field()" + + Put defaults and options on the assignment side — the classic Pydantic style: + + ```python + from decimal import Decimal + + from ferro import Field, Model + + + class Product(Model): + id: int | None = Field(default=None, primary_key=True) + slug: str = Field(unique=True, index=True) + name: str = Field(max_length=200, description="Display name") + price: Decimal = Field(ge=0, decimal_places=2) + ``` + +=== "Annotated metadata" + + Keep the plain type on the left and pass `Field(...)` inside `typing.Annotated`: + + ```python + from decimal import Decimal + from typing import Annotated + + from ferro import Field, Model + + + class Product(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + slug: Annotated[str, Field(unique=True, index=True)] + name: Annotated[str, Field(max_length=200, description="Display name")] + price: Annotated[Decimal, Field(ge=0, decimal_places=2)] + ``` + +Both produce identical schemas — every example in these docs shows both styles in tabs; pick one and stay consistent within a project. The `Annotated` style keeps the bare type visible at a glance and is also how forward relationships are declared (`Annotated[Author, ForeignKey(...)]` — see [Relationships](relationships.md)). + +!!! note "Advanced: `FerroField`" + For a type-first style that carries only database flags (no Pydantic validation kwargs), you can attach `ferro.FerroField(...)` as `Annotated` metadata instead: `id: Annotated[int, FerroField(primary_key=True)]`. It accepts the same database options as `Field()`. + +## Field Types + +Ferro maps Python annotations to backend-appropriate column types: + +| Python type | SQLite | PostgreSQL | Notes | +| :--- | :--- | :--- | :--- | +| `int` | `INTEGER` | `INTEGER` / `BIGINT` | | +| `str` | `TEXT` | `TEXT` | Override with `db_type=varchar(n)` | +| `bool` | `BOOLEAN` (0/1) | `BOOLEAN` | | +| `float` | `DOUBLE` | `DOUBLE PRECISION` | | +| `datetime.datetime` | `TEXT` (ISO 8601) | `TIMESTAMP` / `TIMESTAMPTZ` | | +| `datetime.date` | `TEXT` (ISO 8601) | `DATE` | | +| `datetime.time` | `TEXT` (ISO 8601) | `TIME` | | +| `uuid.UUID` | `TEXT` (36 chars) | `UUID` | | +| `decimal.Decimal` | `NUMERIC` | `NUMERIC` | For money and other exact values | +| `bytes` | `BLOB` | `BYTEA` | | +| `enum.Enum` | `TEXT` | native `ENUM` | See note below | +| `dict` / `list` | `TEXT` (JSON) | `JSON` / `JSONB` | | + +### Overriding the column type with `db_type` + +When the inferred type isn't what you want — for example `varchar(255)` instead of unbounded `TEXT` — pass a `db_type` token: + +=== "Assignment" + + ```python + from ferro import Field, Model, varchar + + + class Document(Model): + id: int | None = Field(default=None, primary_key=True) + title: str = Field(db_type=varchar(255)) + body: str = Field(db_type="text") + ``` + +=== "Annotated" + + ```python + from typing import Annotated + + from ferro import Field, Model, varchar + + + class Document(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + title: Annotated[str, Field(db_type=varchar(255))] + body: Annotated[str, Field(db_type="text")] + ``` + +Valid values are the `DbTypeToken` literals — `"text"`, `"smallint"`, `"int"`, `"bigint"`, `"uuid"`, `"timestamp"`, `"timestamptz"`, `"date"`, `"time"` — plus `varchar(n)` built with `ferro.varchar`. Prefer `varchar(255)` over the raw string `"varchar(255)"` so type checkers see a deliberate vocabulary choice. The override is validated against the Python annotation at class-definition time, so an incompatible combination fails immediately. + +!!! note "Enum storage" + `enum.Enum` fields are stored as text on SQLite and as named native `ENUM` types on PostgreSQL (via the [Alembic bridge](migrations.md)). For closed-domain string columns with a DB-side `CHECK` constraint, combine `db_type` with `db_check=True`. + +## Field Options + +Database options accepted by `Field()` (and `FerroField()`): + +| Option | Type | Default | Description | +| :--- | :--- | :--- | :--- | +| `primary_key` | `bool` | `False` | Mark this column as the table's primary key. | +| `autoincrement` | `bool \| None` | `None` | Database-generated values. Inferred `True` for integer primary keys; pass `False` for manual integer keys. | +| `unique` | `bool` | `False` | Single-column uniqueness constraint. For multi-column uniqueness see [Composite Constraints](#composite-constraints). | +| `index` | `bool` | `False` | Create a non-unique index on this column. | +| `nullable` | `"infer" \| bool` | `"infer"` | Column nullability. `"infer"` follows whether the annotation allows `None`; `True`/`False` force it (useful when the Python type diverges from the column on purpose). | +| `default` | any | — | Pydantic default value (also used to backfill when [`migrate_updates`](migrations.md) adds a NOT NULL column). | +| `default_factory` | callable | — | Pydantic default factory, e.g. `default_factory=datetime.now`. | +| `db_type` | `DbType \| None` | `None` | Column-type override (see above). | +| `db_check` | `bool` | `False` | Emit a DB-side `CHECK` constraint for closed-domain types; only valid with `db_type`. | + +On top of these, every Pydantic validation option works in the same call: `min_length`, `max_length`, `pattern`, `gt`, `ge`, `lt`, `le`, `multiple_of`, `decimal_places`, `description`, and the rest — see [Pydantic's Field docs](https://docs.pydantic.dev/latest/api/fields/#pydantic.fields.Field). + +## Primary Keys + +### Auto-increment + +Integer primary keys auto-increment by default. Declare them as `int | None` with `default=None` so unsaved instances can exist before the database assigns an ID: + +=== "Assignment" + + ```python + from ferro import Field, Model + + + class User(Model): + id: int | None = Field(default=None, primary_key=True) + name: str + ``` + +=== "Annotated" + + ```python + from typing import Annotated + + from ferro import Field, Model + + + class User(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + name: str + ``` + +After `await user.save()` (or `User.create(...)`), the generated ID is written back onto the instance. + +### Manual + +For natural keys, or integer keys you assign yourself, disable auto-increment: + +=== "Assignment" + + ```python + from ferro import Field, Model + + + class Country(Model): + code: str = Field(primary_key=True) # natural key, e.g. "US" + name: str + + + class LegacyRecord(Model): + id: int = Field(primary_key=True, autoincrement=False) + payload: str + ``` + +=== "Annotated" + + ```python + from typing import Annotated + + from ferro import Field, Model + + + class Country(Model): + code: Annotated[str, Field(primary_key=True)] # natural key, e.g. "US" + name: str + + + class LegacyRecord(Model): + id: Annotated[int, Field(primary_key=True, autoincrement=False)] + payload: str + ``` + +### UUID primary keys + +Generate UUIDs client-side with `default_factory`: + +=== "Assignment" + + ```python + import uuid + + from ferro import Field, Model + + + class Order(Model): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + total_cents: int = 0 + ``` + +=== "Annotated" + + ```python + import uuid + from typing import Annotated + + from ferro import Field, Model + + + class Order(Model): + id: Annotated[uuid.UUID, Field(default_factory=uuid.uuid4, primary_key=True)] + total_cents: int = 0 + ``` + +On PostgreSQL this is a native `UUID` column; on SQLite it is stored as a 36-character string. + +## Composite Constraints + +### Composite uniques + +When a row must be unique across several columns *together* (e.g. one membership per `(user_id, org_id)` pair), per-column `unique=True` is not what you want. Declare the `ClassVar` `__ferro_composite_uniques__` instead: + +=== "Assignment" + + ```python + import uuid + from typing import ClassVar + + from ferro import Field, Model + + + class OrgMembership(Model): + __ferro_composite_uniques__: ClassVar[tuple[tuple[str, ...], ...]] = ( + ("user_id", "org_id"), + ) + + id: int | None = Field(default=None, primary_key=True) + user_id: uuid.UUID + org_id: uuid.UUID + ``` + +=== "Annotated" + + ```python + import uuid + from typing import Annotated, ClassVar + + from ferro import Field, Model + + + class OrgMembership(Model): + __ferro_composite_uniques__: ClassVar[tuple[tuple[str, ...], ...]] = ( + ("user_id", "org_id"), + ) + + id: Annotated[int | None, Field(default=None, primary_key=True)] + user_id: uuid.UUID + org_id: uuid.UUID + ``` + +- Each inner tuple lists **database column names** (for a `ForeignKey` field named `user`, use the shadow column `user_id`). +- Declare several tuples for several independent composite uniques. +- Unknown column names raise at model registration time. + +!!! note "NULL semantics" + SQL `UNIQUE` treats `NULL` values as distinct from each other, so nullable members of a composite unique can admit multiple rows that differ only by `NULL`. Prefer `NOT NULL` columns in composite uniques when you need strict "at most one row per pair" semantics. + +### Composite indexes + +For non-unique multi-column indexes — read-path optimization on common filter combinations like `(user_id, created_at)` — declare `__ferro_composite_indexes__` with the same shape: + +=== "Assignment" + + ```python + from datetime import datetime + from typing import ClassVar + + from ferro import Field, Model + + + class Comment(Model): + __ferro_composite_indexes__: ClassVar[tuple[tuple[str, ...], ...]] = ( + ("user_id", "created_at"), + ) + + id: int | None = Field(default=None, primary_key=True) + user_id: int + created_at: datetime + body: str + ``` + +=== "Annotated" + + ```python + from datetime import datetime + from typing import Annotated, ClassVar + + from ferro import Field, Model + + + class Comment(Model): + __ferro_composite_indexes__: ClassVar[tuple[tuple[str, ...], ...]] = ( + ("user_id", "created_at"), + ) + + id: Annotated[int | None, Field(default=None, primary_key=True)] + user_id: int + created_at: datetime + body: str + ``` + +Validation mirrors composite uniques: at least two columns per tuple, columns must exist, and **order is preserved** (it matters for leftmost-prefix optimization). For single-column indexes use `Field(index=True)`. Declaring the same ordered tuple in both `__ferro_composite_uniques__` and `__ferro_composite_indexes__` emits a `UserWarning` and drops the redundant index. + +Both ClassVars flow through to [Alembic autogenerate](migrations.md) as matching `UniqueConstraint` / `Index` objects. + +## Pydantic Validation + +Ferro models *are* Pydantic models, so validation runs whenever an instance is constructed — including inside `Model.create(...)` and before `bulk_create(...)` hits the database: + +=== "Assignment" + + ```python + from ferro import Field, Model + + + class Account(Model): + id: int | None = Field(default=None, primary_key=True) + username: str = Field(unique=True, min_length=3, max_length=50) + email: str = Field(unique=True, pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$") + age: int = Field(ge=0, le=150) + ``` + +=== "Annotated" + + ```python + from typing import Annotated + + from ferro import Field, Model + + + class Account(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + username: Annotated[str, Field(unique=True, min_length=3, max_length=50)] + email: Annotated[str, Field(unique=True, pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$")] + age: Annotated[int, Field(ge=0, le=150)] + ``` + +```python +from pydantic import ValidationError + +try: + await Account.create(username="ab", email="not-an-email", age=-1) +except ValidationError as exc: + print(exc.error_count(), "validation errors — nothing was written") +``` + +Custom `@field_validator` / `@model_validator` methods and `model_config` settings (e.g. `validate_assignment=True`, `str_strip_whitespace=True`) work too, since they are plain Pydantic features. + +## Reusing Fields Across Models + +!!! warning "Model subclasses cannot inherit fields" + You **cannot** declare fields on a `Model` base class and inherit them in subclasses. `Model`'s metaclass replaces field names with query proxies at the class level, so a "base model with shared columns" silently produces broken subclass defaults. Don't do this: + + ```python + class TimestampedModel(Model): # WRONG — do not inherit fields from a Model + created_at: datetime = Field(default_factory=datetime.now) + + + class Article(TimestampedModel): # broken defaults + title: str + ``` + +The supported pattern is a **plain mixin** (not a `Model` subclass) for shared *behavior*, with the fields declared on each concrete model: + +=== "Assignment" + + ```python + from datetime import UTC, datetime + + from ferro import Field, Model + + + def utcnow() -> datetime: + return datetime.now(UTC) + + + class TimestampMixin: + """Touch ``updated_at`` on every save. A plain class, not a Model.""" + + async def save(self, **kwargs) -> None: + self.updated_at = utcnow() + await super().save(**kwargs) + + + class Note(TimestampMixin, Model): + id: int | None = Field(default=None, primary_key=True) + text: str + created_at: datetime = Field(default_factory=utcnow) + updated_at: datetime = Field(default_factory=utcnow) + + + class Task(TimestampMixin, Model): + id: int | None = Field(default=None, primary_key=True) + title: str + created_at: datetime = Field(default_factory=utcnow) + updated_at: datetime = Field(default_factory=utcnow) + ``` + +=== "Annotated" + + ```python + from datetime import UTC, datetime + from typing import Annotated + + from ferro import Field, Model + + + def utcnow() -> datetime: + return datetime.now(UTC) + + + class TimestampMixin: + """Touch ``updated_at`` on every save. A plain class, not a Model.""" + + async def save(self, **kwargs) -> None: + self.updated_at = utcnow() + await super().save(**kwargs) + + + class Note(TimestampMixin, Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + text: str + created_at: Annotated[datetime, Field(default_factory=utcnow)] + updated_at: Annotated[datetime, Field(default_factory=utcnow)] + + + class Task(TimestampMixin, Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + title: str + created_at: Annotated[datetime, Field(default_factory=utcnow)] + updated_at: Annotated[datetime, Field(default_factory=utcnow)] + ``` + +The few repeated field lines are deliberate: each concrete model owns its full schema, and the mixin contributes behavior only. For complete worked examples, see [Timestamps](../howto/timestamps.md) and [Soft Deletes](../howto/soft-deletes.md). + +## See Also + +- [Relationships](relationships.md) — foreign keys, one-to-many, many-to-many +- [Queries](queries.md) — fetching and filtering data +- [Mutations](mutations.md) — creating, updating, and deleting records +- [Schema Migrations](migrations.md) — how fields become DDL +- [Identity Map](../concepts/identity-map.md) — instance caching semantics diff --git a/docs/pages/guide/mutations.md b/docs/pages/guide/mutations.md new file mode 100644 index 0000000..de43abe --- /dev/null +++ b/docs/pages/guide/mutations.md @@ -0,0 +1,145 @@ +# Mutations + +Creating, updating, and deleting records. All mutations are executed by the Rust engine, and all of them participate in an active [transaction](transactions.md) automatically. + +## Creating Records + +### create + +`Model.create(**fields)` validates, inserts, and returns the persisted instance in one call. For inserting many rows, `Model.bulk_create(instances)` batches them into a single statement and returns the inserted count: + +```python +--8<-- "docs/examples/quickstart.py:create" +``` + +Pass related instances directly (`author=alice`) or set the shadow foreign-key column (`author_id=alice.id`) — see [Relationships](relationships.md). Pydantic validation runs when each instance is constructed, so invalid data raises *before* the database is touched. + +### Defaults + +Fields with `default` or `default_factory` fill themselves in: + +=== "Assignment" + + ```python + from datetime import datetime + + from ferro import Field, Model + + + class Article(Model): + id: int | None = Field(default=None, primary_key=True) + title: str + draft: bool = True + created_at: datetime = Field(default_factory=datetime.now) + ``` + +=== "Annotated" + + ```python + from datetime import datetime + from typing import Annotated + + from ferro import Field, Model + + + class Article(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + title: str + draft: bool = True + created_at: Annotated[datetime, Field(default_factory=datetime.now)] + ``` + +```python +article = await Article.create(title="Hello") +# article.draft is True, article.created_at is set +``` + +## Get-or-Create + +`get_or_create(defaults={...}, **filters)` looks up a row by exact-match filters and creates it when missing. It returns an `(instance, created)` tuple; `defaults` are applied **only** on the create path: + +```python +--8<-- "docs/examples/mutations.py:get-or-create" +``` + +## Update-or-Create + +`update_or_create(defaults={...}, **filters)` has the same shape, but when a match exists it applies `defaults` to the instance and saves it: + +```python +--8<-- "docs/examples/mutations.py:update-or-create" +``` + +!!! note "Concurrency" + Both helpers are a read followed by a write, not a single atomic upsert. Under concurrent writers, two processes can race past the lookup; a unique constraint on the filter columns turns that race into an integrity error you can handle. + +## Updating + +### Instance save() + +Mutate fields on an instance and persist with `save()`: + +```python +user = await User.get(1) +user.email = "new@example.com" +await user.save() +``` + +### Batch updates + +Update many rows in one statement — no instances are loaded — and delete the same way. `update(**values)` and `delete()` are query terminals that return the affected row count: + +```python +--8<-- "docs/examples/quickstart.py:update-delete" +``` + +!!! warning "Batch updates bypass in-memory instances" + A `where(...).update(...)` writes directly to the database. Instances you already hold (including identity-mapped ones) are **not** mutated — call `refresh()` on them if you need the new values. + +## Refreshing from the Database + +`refresh()` reloads an instance from its primary key, discarding local state: + +```python +--8<-- "docs/examples/mutations.py:refresh" +``` + +It raises `RuntimeError` if the instance has no primary key or the row no longer exists. + +## Deleting + +Delete a single instance, or batch-delete via a query: + +```python +user = await User.get_or_none(42) +if user is not None: + await user.delete() + +removed = await User.where(lambda t: t.archived == True).delete() # noqa: E712 +``` + +Deleting a parent row triggers the `on_delete` behavior of any foreign keys pointing at it — `CASCADE` by default. See [Delete Behavior](relationships.md#delete-behavior) before deleting rows with children. + +## Bulk Operations and the Identity Map + +By default Ferro keeps a per-connection [identity map](../concepts/identity-map.md): loading the same primary key twice yields the same Python object, and `create()`/`save()` register instances in it. + +`bulk_create()` is the deliberate exception — it serializes the given instances straight to the engine and **skips the identity map** for throughput. The instances you passed in are not registered (and auto-generated IDs are not written back onto them); re-query the rows when you need tracked instances. + +```python +inserted = await User.bulk_create([User(name="a", age=1), User(name="b", age=2)]) +fresh = await User.where(lambda t: t.name.in_(["a", "b"])).all() +``` + +## Not Yet Supported + +!!! note "On the roadmap" + Atomic update expressions — e.g. `update(views=Post.views + 1)` or `update(price=Product.price * 0.9)` — are **not yet implemented**; see the [Roadmap](../roadmap.md). In the meantime, load–modify–`save()` (last write wins), or use [raw SQL](raw-sql.md) for a truly atomic `UPDATE ... SET views = views + 1`. + +## See Also + +- [Queries](queries.md) — fetching and filtering data +- [Transactions](transactions.md) — grouping mutations atomically +- [Relationships](relationships.md) — creating related records, cascade rules +- [Identity Map](../concepts/identity-map.md) — instance caching semantics +- [Testing](../howto/testing.md) — testing code that mutates data diff --git a/docs/pages/guide/queries.md b/docs/pages/guide/queries.md new file mode 100644 index 0000000..934a45f --- /dev/null +++ b/docs/pages/guide/queries.md @@ -0,0 +1,176 @@ +# Queries + +Ferro provides a fluent, type-safe API for building queries in Python and executing them on the Rust engine. All values are parameterized — user input is never concatenated into SQL. + +The examples on this page use this model: + +=== "Assignment" + + ```python + --8<-- "docs/examples/predicates.py:setup" + ``` + +=== "Annotated" + + ```python + --8<-- "docs/examples/predicates_annotated.py:setup" + ``` + +## Fetching by Primary Key + +`Model.get(pk)` loads exactly one row and returns your model type — not `YourModel | None`. If no row exists it raises `ModelDoesNotExist`, a `LookupError` subclass carrying `.model` and `.pk` (handy for HTTP 404s and structured logging). When a missing row is a normal outcome, use `Model.get_or_none(pk)` instead: + +```python +from ferro import ModelDoesNotExist + +user = await User.get(42) # User — raises if missing + +try: + user = await User.get(client_supplied_id) +except ModelDoesNotExist: + ... # e.g. return 404 from your HTTP layer + +maybe = await User.get_or_none(999) # User | None — never raises for "not found" +``` + +Both methods also exist on `Model.using("name")` for [named connections](connections.md#named-connections). + +## Filtering with where() + +`Model.where(...)` starts a chainable query; terminals like `.all()` execute it. Predicates are written as lambdas — the parameter (`t` by convention) is a query proxy whose attributes stand in for your model's columns: + +```python +--8<-- "docs/examples/predicates.py:filtering" +``` + +`Model.select()` starts an unfiltered query — useful when you only want ordering, slicing, or a count. + +## Predicate Styles + +`where()` accepts three predicate styles. **Lambda predicates are the officially recommended style** — use them for all new code. The other two exist for compatibility and share the same runtime path, so you can mix them within a single chain. + +=== "Lambda (recommended)" + + The official style. Write the predicate against a query proxy — fully type-checked, works for every column and operator: + + ```python + --8<-- "docs/examples/predicates.py:lambda-style" + ``` + +=== "col()" + + Wrap one attribute in `col()` to keep the operator shape while staying type-safe (it statically narrows to the runtime `FieldProxy` type): + + ```python + --8<-- "docs/examples/predicates.py:col-style" + ``` + +=== "Operator" + + Compare class attributes directly — the original style: + + ```python + --8<-- "docs/examples/predicates.py:operator-style" + ``` + + !!! warning "Operator style will be deprecated" + The operator style is compatible today but slated for deprecation in a future release. It is also incompatible with static type checkers (ty, mypy, Pyright): they see `User.age >= 18` as a `bool` from your Pydantic annotations, while `where()` expects a `QueryNode | Predicate`. Prefer the lambda style. + +Lambda predicates keep the call site fully type-checked because the proxy's attributes are real `FieldProxy` objects in the type checker's eyes, not your Pydantic annotations. Reach for `col()` only when you want to preserve the operator shape on a single attribute. See [Typed Query Predicates](../concepts/query-typing.md) for the full reasoning. + +## Operators + +| Python | SQL | Example | +| :--- | :--- | :--- | +| `==` | `=` | `User.role == "admin"` | +| `!=` | `!=` | `User.role != "admin"` | +| `>` | `>` | `User.age > 18` | +| `>=` | `>=` | `User.age >= 21` | +| `<` | `<` | `User.age < 100` | +| `<=` | `<=` | `User.age <= 65` | +| `.like(pattern)` | `LIKE` | `User.name.like("a%")` | +| `.in_(values)` | `IN` | `User.role.in_(["admin", "moderator"])` | +| `== None` | `IS NULL` | `User.deleted_at == None` | +| `!= None` | `IS NOT NULL` | `User.deleted_at != None` | + +```python +--8<-- "docs/examples/predicates.py:operators" +``` + +## Combining Conditions + +Combine predicates with `&` (AND) and `|` (OR), or chain multiple `.where()` calls (which AND together): + +```python +--8<-- "docs/examples/predicates.py:combining" +``` + +!!! warning "Always parenthesize `&` and `|` operands" + Python's `&` and `|` bind tighter than comparison operators, so `User.age < 18 | User.archived == True` parses as `User.age < (18 | User.archived) == True` — not what you meant. Wrap each condition in parentheses: `(User.age < 18) | (User.archived == True)`. + +## Ordering, Limit & Offset + +Sort with `.order_by(field, direction)` (direction defaults to ascending; pass `"desc"` to reverse) and slice with `.limit()` / `.offset()`. Unlike `where()`, `order_by` is not a predicate: pass the column attribute itself (`User.age`), not a lambda. Its parameter is typed `Any`, so it raises no type-checker friction: + +```python +--8<-- "docs/examples/predicates.py:ordering-slicing" +``` + +Chain `.order_by()` multiple times for multi-column sorts. For robust pagination patterns, see [Pagination](../howto/pagination.md). + +## Executing Queries + +Queries are lazy — nothing hits the database until you await a terminal: + +```python +--8<-- "docs/examples/predicates.py:terminals" +``` + +| Terminal | Returns | Semantics | +| :--- | :--- | :--- | +| `.all()` | `list[Model]` | All matching rows, hydrated to instances. | +| `.first()` | `Model \| None` | First matching row, or `None` if there are no matches. | +| `.count()` | `int` | `COUNT(*)` of matching rows — no instances hydrated. | +| `.exists()` | `bool` | `True` if at least one row matches; stops at the first match. | + +!!! tip "Prefer `.exists()` over `.count() > 0`" + `.exists()` lets the database stop at the first match instead of counting every row. + +`Model.all()` is shorthand for `Model.select().all()`. + +## Querying Across Relationships + +Every `ForeignKey` field gets a shadow `*_id` column you can filter on like any scalar: + +```python +posts = await Post.where(lambda t: t.author_id == user.id).all() +``` + +Reverse relations (`BackRef`) are chainable queries themselves — filter, order, and slice them before executing: + +```python +published = await author.posts.where(lambda t: t.published == True).all() # noqa: E712 +latest = await author.posts.order_by(Post.created_at, "desc").limit(5).all() +n = await author.posts.count() +``` + +Joins across relations inside a single `where()` are not supported — filter on shadow FK columns or use the reverse-relation query. See [Relationships](relationships.md) for the full picture. + +## Not Yet Supported + +!!! note "On the roadmap" + The following query features are **not yet implemented** — see the [Roadmap](../roadmap.md): + + - Aggregations beyond `count()` / `exists()` (`sum`, `avg`, `min`, `max`, `GROUP BY`) + - Partial selects (selecting specific columns; queries always load all model fields) + - Eager loading (`prefetch_related` / `select_related`) — be mindful of N+1 patterns when looping over relations + - Case-insensitive `ilike()` + - `not_in()` (negate with `!=` conditions combined with `&` in the meantime) + +## See Also + +- [Mutations](mutations.md) — creating, updating, and deleting records +- [Relationships](relationships.md) — forward and reverse relations +- [Typed Query Predicates](../concepts/query-typing.md) — why three predicate styles exist +- [Raw SQL](raw-sql.md) — the escape hatch for queries the ORM can't express +- [Pagination](../howto/pagination.md) — efficient pagination patterns diff --git a/docs/pages/guide/raw-sql.md b/docs/pages/guide/raw-sql.md new file mode 100644 index 0000000..f3a2ed1 --- /dev/null +++ b/docs/pages/guide/raw-sql.md @@ -0,0 +1,109 @@ +# Raw SQL + +Ferro exposes a raw SQL escape hatch — `execute`, `fetch_all`, `fetch_one` — for statements that don't fit a `Model`. + +## When to Reach for Raw SQL + +Reach for raw SQL when the ORM can't express what you need: aggregations and reports, Postgres GUCs (`set_config`, `SET LOCAL`), advisory locks, `LISTEN/NOTIFY`, database-side functions, or one-off maintenance statements. For everyday CRUD, prefer the ORM — it returns typed, validated instances; raw SQL returns plain dicts of primitives. + +!!! warning "Raw SQL is an escape hatch" + Bind values cross the FFI as wire-close primitives, and rows come back as `dict[str, str | int | float | bool | bytes | None]`. UUID, datetime, and JSON columns are returned as **strings**. If you want typed rows, use the ORM. + +## Executing Statements + +`execute(sql, *args)` runs a statement and returns the number of affected rows: + +```python +--8<-- "docs/examples/raw_sql.py:execute" +``` + +One statement per call — multi-statement strings are not supported. **Never** f-string user input into the `sql` argument; pass values as positional args so they are bound as parameters. + +## Fetching Rows + +`fetch_all(sql, *args)` returns a list of dicts; `fetch_one(sql, *args)` returns the first row or `None` (add `LIMIT 1` when more rows could match): + +```python +--8<-- "docs/examples/raw_sql.py:fetch" +``` + +All three functions accept `using="name"` to route to a [named connection](connections.md#named-connections). + +## Placeholders + +Placeholders are **native to the backend** — there is no translation layer. What you write is what the driver runs, and mismatches surface as the database's own error. + +=== "SQLite" + + Positional `?` placeholders: + + ```python + from ferro import fetch_all + + rows = await fetch_all("SELECT * FROM users WHERE role = ? AND age >= ?", "admin", 18) + ``` + +=== "PostgreSQL" + + Numbered `$1, $2, ...` placeholders: + + ```python + from ferro import fetch_all + + rows = await fetch_all("SELECT * FROM users WHERE role = $1 AND age >= $2", "admin", 18) + ``` + +## Type Caveats + +Raw SQL has no schema map, so Ferro does not auto-cast bind values (matching asyncpg / psycopg behavior). Python values are marshalled to wire-close primitives: + +| Python type | Sent as | Postgres cast you must write | +| :--- | :--- | :--- | +| `None` | `NULL` | — | +| `bool` | bool | — | +| `int` | `i64` | — | +| `float` | `f64` | — | +| `str` | text | — | +| `bytes` / `bytearray` | bytea / blob | — | +| `uuid.UUID` | text | `$N::uuid` | +| `datetime.datetime` | ISO 8601 text | `$N::timestamptz` | +| `datetime.date` | ISO 8601 text | `$N::date` | +| `datetime.time` | ISO 8601 text | `$N::time` | +| `decimal.Decimal` | text | `$N::numeric` | +| `enum.Enum` | recursive on `.value` | depends on `.value` type | +| `dict` / `list` | `json.dumps(...)` text | `$N::jsonb` | +| anything else | raises `TypeError` | — | + +On PostgreSQL, write the casts in the SQL when the column type is stricter than text: + +```python +from ferro import execute + +sql = ( + "UPDATE events SET payload = $1::jsonb, occurred_at = $2::timestamptz " + "WHERE id = $3::uuid" +) +await execute(sql, payload_dict, occurred_at, event_id) +``` + +The same caveat applies on the way out: UUID, datetime, and JSON **result columns** come back as strings — parse them yourself, or load through the ORM for typed values. + +## Raw SQL in Transactions + +Inside an `async with transaction()` block, top-level `execute` / `fetch_all` / `fetch_one` automatically run on the transaction's connection. The yielded `Transaction` handle (`as tx`) offers the same three methods bound explicitly, which is the hard-to-misuse path: + +```python +--8<-- "docs/examples/raw_sql.py:in-transaction" +``` + +Passing `using=...` for a *different* connection inside an active transaction raises — a transaction is pinned to one connection. + +!!! tip "Connection affinity" + Outside a transaction, consecutive top-level calls may use **different pooled connections**. Wrap connection-affinity-sensitive sequences — `SET LOCAL`, advisory locks, `LISTEN/NOTIFY` — in `transaction()` so they share one connection. + +## See Also + +- [Transactions](transactions.md) — the `Transaction` handle and nesting behavior +- [Connections & Databases](connections.md) — named connections and routing +- [Queries](queries.md) — what the ORM can express without raw SQL +- [Raw SQL API reference](../api/raw-sql.md) — full signatures diff --git a/docs/pages/guide/relationships.md b/docs/pages/guide/relationships.md new file mode 100644 index 0000000..04d65c0 --- /dev/null +++ b/docs/pages/guide/relationships.md @@ -0,0 +1,240 @@ +# Relationships + +Ferro connects models with foreign keys, zero-boilerplate reverse lookups, and automatically managed join tables. + +## Overview + +Relationships are **lazy** — nothing is fetched until you ask for it: + +- **Forward relations** (a `ForeignKey` field): `await post.author` performs one query and returns the related instance. +- **Reverse relations** (a `BackRef` field): `author.posts` is a chainable query — filter, order, and slice it before awaiting a terminal. + +A forward `ForeignKey(related_name="x")` always pairs with a reverse field named `x` on the target model. The pairing is **required and checked at `connect()`** — a `ForeignKey` whose `related_name` has no matching `BackRef()` on the target raises at connect time. + +## One-to-Many + +The most common shape: a `ForeignKey` on the "child" model, declared as `Annotated` metadata, plus a `Relation[list[...]] = BackRef()` on the "parent": + +=== "Assignment" + + ```python + --8<-- "docs/examples/relationships.py:one-to-many" + ``` + +=== "Annotated" + + ```python + --8<-- "docs/examples/relationships_annotated.py:one-to-many" + ``` + +```python +--8<-- "docs/examples/relationships.py:one-to-many-usage" +``` + +### Shadow FK columns + +For every `ForeignKey` field (e.g. `team`), Ferro creates a shadow scalar column and matching Pydantic field named `{field}_id` (e.g. `team_id`) holding the related row's primary key. Its Python type follows the target model's primary-key annotation. Read it or filter on it like any other column — `Player.where(lambda t: t.team_id == team.id)` — with no extra query. + +## One-to-One + +Add `unique=True` to the `ForeignKey` to enforce at most one child per parent. The reverse side is a plain (non-list) `BackRef` that resolves to a single instance: + +=== "Assignment" + + ```python + --8<-- "docs/examples/relationships.py:one-to-one" + ``` + +=== "Annotated" + + ```python + --8<-- "docs/examples/relationships_annotated.py:one-to-one" + ``` + +```python +--8<-- "docs/examples/relationships.py:one-to-one-usage" +``` + +`unique=True` implies an index on the shadow column (combining it with `index=True` is redundant and emits a `UserWarning`). + +## Many-to-Many + +Declare `ManyToMany(related_name=...)` on one side and a `BackRef()` collection on the other. Ferro synthesizes the join table automatically: + +=== "Assignment" + + ```python + --8<-- "docs/examples/relationships.py:many-to-many" + ``` + +=== "Annotated" + + ```python + --8<-- "docs/examples/relationships_annotated.py:many-to-many" + ``` + +```python +--8<-- "docs/examples/relationships.py:m2m-usage" +``` + +Both sides expose the chainable query API plus the link mutators `add(*instances)`, `remove(*instances)`, and `clear()`. + +`ManyToMany` accepts: + +| Parameter | Default | Description | +| :--- | :--- | :--- | +| `related_name` | required | Name of the reverse field on the related model. | +| `through` | `None` | Explicit join-table name; auto-generated when omitted. | +| `reverse_index` | `True` | Add a non-unique composite index on `(target_col, source_col)` in the join table so reverse queries also hit an index. Pass `False` to opt out on write-heavy join tables. | + +The default join table gets a **composite unique** on its two foreign-key columns, so the same link can never be stored twice. `reverse_index` lives on the forward `ManyToMany(...)` declaration — passing it to `BackRef()` raises `TypeError`. + +## Self-Referential + +A model can reference itself — org charts, threaded comments, category trees. The forward reference must be the **quoted class name**, and because a string annotation cannot carry `| None`, nullability must be declared explicitly with `nullable=True`: + +=== "Assignment" + + ```python + --8<-- "docs/examples/relationships.py:self-referential" + ``` + +=== "Annotated" + + ```python + --8<-- "docs/examples/relationships_annotated.py:self-referential" + ``` + +```python +--8<-- "docs/examples/relationships.py:self-referential-usage" +``` + +Without `nullable=True` the root of the tree (an `Employee` with no manager) could never be stored. + +## Nullable Relationships + +For an optional relation to *another* model, put the union **inside** `Annotated` and default the field to `None`: + +=== "Assignment" + + ```python + from typing import Annotated + + from ferro import BackRef, Field, ForeignKey, Model, Relation + + + class Category(Model): + id: int | None = Field(default=None, primary_key=True) + name: str + products: Relation[list["Product"]] = BackRef() + + + class Product(Model): + id: int | None = Field(default=None, primary_key=True) + name: str + category: Annotated[Category | None, ForeignKey(related_name="products")] = None + ``` + +=== "Annotated" + + ```python + from typing import Annotated + + from ferro import BackRef, Field, ForeignKey, Model, Relation + + + class Category(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + name: str + products: Relation[list["Product"]] = BackRef() + + + class Product(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + name: str + category: Annotated[Category | None, ForeignKey(related_name="products")] = None + ``` + +!!! warning "Union placement matters" + Write `Annotated[Category | None, ForeignKey(...)] = None` — the union goes **inside** `Annotated`. The form `Annotated[Category, ForeignKey(...)] | None` is not supported and will not produce a nullable foreign key. + +With the default `nullable="infer"`, Ferro derives column nullability from whether the relation annotation allows `None`. `on_delete="SET NULL"` also implies a nullable column (and explicitly combining it with `nullable=False` raises). + +## Delete Behavior + +`ForeignKey(on_delete=...)` controls what happens to child rows when their parent is deleted: + +| `on_delete` | Effect when the parent row is deleted | +| :--- | :--- | +| `"CASCADE"` (default) | Child rows are deleted too. | +| `"RESTRICT"` | Deletion fails while child rows exist. | +| `"SET NULL"` | The shadow FK column is set to `NULL` (requires a nullable relation). | +| `"SET DEFAULT"` | The shadow FK column is reset to its column default. | +| `"NO ACTION"` | The constraint is not enforced at delete time (backend semantics apply). | + +=== "Assignment" + + ```python + --8<-- "docs/examples/relationships.py:on-delete" + ``` + +=== "Annotated" + + ```python + --8<-- "docs/examples/relationships_annotated.py:on-delete" + ``` + +Because `CASCADE` is the default, deleting a parent silently removes its children unless you choose otherwise — pick `RESTRICT` when orphan deletion would be a bug you want surfaced. + +## Indexing Foreign Keys + +PostgreSQL does not automatically index foreign-key columns. For FKs that appear in hot query paths (tenant IDs on every list endpoint, for instance), request a non-unique index on the shadow column with `index=True`: + +=== "Assignment" + + ```python + from typing import Annotated + + from ferro import BackRef, Field, ForeignKey, Model, Relation + + + class Org(Model): + id: int | None = Field(default=None, primary_key=True) + name: str + projects: Relation[list["Project"]] = BackRef() + + + class Project(Model): + id: int | None = Field(default=None, primary_key=True) + name: str + org: Annotated[Org, ForeignKey(related_name="projects", index=True)] + ``` + +=== "Annotated" + + ```python + from typing import Annotated + + from ferro import BackRef, Field, ForeignKey, Model, Relation + + + class Org(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + name: str + projects: Relation[list["Project"]] = BackRef() + + + class Project(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + name: str + org: Annotated[Org, ForeignKey(related_name="projects", index=True)] + ``` + +One-to-one relations (`unique=True`) already get an index; for multi-column indexes that start with the FK column, use [composite indexes](models-and-fields.md#composite-indexes). + +## See Also + +- [Models & Fields](models-and-fields.md) — field declaration styles and constraints +- [Queries](queries.md) — filtering on shadow FK columns and reverse relations +- [Mutations](mutations.md) — creating related records, cascade implications +- [Schema Migrations](migrations.md) — how relationships appear in Alembic metadata diff --git a/docs/pages/guide/transactions.md b/docs/pages/guide/transactions.md new file mode 100644 index 0000000..4884ece --- /dev/null +++ b/docs/pages/guide/transactions.md @@ -0,0 +1,107 @@ +# Transactions + +Group multiple operations into a single atomic unit with the `ferro.transaction()` async context manager: commit on clean exit, rollback if the block raises. + +## Basic Usage + +```python +--8<-- "docs/examples/transactions.py:basic" +``` + +Every ORM operation inside the block — `create`, `save`, `delete`, batch `update`, `bulk_create`, queries — runs on the **same database connection** (connection affinity is tracked through `contextvars`, so it is safe under concurrent asyncio tasks: each task gets its own transaction). + +## Rollback on Error + +If any exception escapes the block, everything performed inside it is rolled back and the exception re-raises: + +```python +--8<-- "docs/examples/transactions.py:rollback" +``` + +## Using Named Connections + +A transaction is pinned to one connection. Open it against a [named connection](connections.md#named-connections) with `using=`, and everything inside inherits that connection — unqualified operations included. Trying to route to a *different* connection inside the block raises: + +```python +--8<-- "docs/examples/multiple_databases.py:transaction" +``` + +## Raw SQL Inside a Transaction + +`transaction()` yields a `Transaction` handle exposing `execute` / `fetch_all` / `fetch_one` for raw SQL on the transaction's own connection — useful for Postgres GUCs (`set_config`, `SET LOCAL`), advisory locks, or any one-off statement that doesn't fit a model: + +```python +--8<-- "docs/examples/transactions.py:handle" +``` + +The handle becomes invalid when the block exits — calling it afterwards raises `RuntimeError`. If you don't need it, simply write `async with transaction():` and the handle is discarded. The top-level `ferro.execute` / `fetch_all` / `fetch_one` functions also automatically join the active transaction — see [Raw SQL](raw-sql.md#raw-sql-in-transactions). + +## Nesting Behavior + +Nested `transaction()` blocks map to **savepoints** on the outer transaction's connection: + +- If an **inner** block raises, only its work is rolled back (to the savepoint); the outer transaction can continue and commit. +- If the **outer** block raises, everything rolls back — including inner blocks that completed "successfully". + +```python +from ferro import transaction + + +async def import_rows(rows: list[dict]) -> int: + imported = 0 + async with transaction(): + await AuditLog.create(event="import-started") + for row in rows: + try: + async with transaction(): # savepoint per row + await Record.create(**row) + imported += 1 + except ValueError: + continue # this row rolled back; the import continues + return imported +``` + +A nested block never commits independently of its parent — only the outermost block's clean exit commits to the database. + +## Patterns + +### Keep transactions short + +A transaction holds a pooled connection (and database locks) for its entire duration. Do slow work — HTTP calls, file I/O, expensive computation — *outside* the block, and keep only the database writes inside: + +```python +async def publish(post_id: int) -> None: + rendered = await render_markdown(post_id) # slow work first, no tx held + + async with transaction(): + post = await Post.get(post_id) + post.body_html = rendered + post.published = True + await post.save() +``` + +### Error handling + +Catch exceptions *outside* the block when the whole unit should roll back, and *inside* it only for work you genuinely want to keep partial (paired with a nested block, as above). Catching an exception inside the block and continuing means the surviving operations **will commit**: + +```python +async def transfer(src: Account, dst: Account, amount: int) -> bool: + try: + async with transaction(): + src.balance -= amount + await src.save() + if src.balance < 0: + raise ValueError("insufficient funds") + dst.balance += amount + await dst.save() + except ValueError: + return False # nothing was committed + return True +``` + +## See Also + +- [Raw SQL](raw-sql.md) — placeholders, bind types, and the `Transaction` handle +- [Connections & Databases](connections.md) — named connections and pools +- [Mutations](mutations.md) — the operations you'll wrap in transactions +- [Multiple Databases](../howto/multiple-databases.md) — routing patterns diff --git a/docs/pages/howto/migrate-from-sqlalchemy.md b/docs/pages/howto/migrate-from-sqlalchemy.md new file mode 100644 index 0000000..e3020cf --- /dev/null +++ b/docs/pages/howto/migrate-from-sqlalchemy.md @@ -0,0 +1,270 @@ +# Migrating from SQLAlchemy + +Ferro's model-centric API replaces SQLAlchemy's session/statement split: models are Pydantic classes, queries hang off the model, and there is no session to manage. This page maps each SQLAlchemy concept to its Ferro equivalent. + +## Quick Comparison + +| Concept | SQLAlchemy 2.0 | Ferro | +|---|---|---| +| Model definition | `DeclarativeBase` + `Mapped` / `mapped_column` | Pydantic `Model` + type annotations | +| Validation | Separate (e.g. Pydantic on top) | Built in — models *are* Pydantic models | +| Querying | `select(User).where(...)` executed via a session | `User.where(...)` awaited directly | +| Get by primary key | `session.get(User, pk)` → `None` if missing | `User.get(pk)` raises; `User.get_or_none(pk)` → `None` | +| Sessions | Required (`async_sessionmaker`, `session.add`, `commit`) | None — connections are managed for you | +| Transactions | `async with session.begin():` | `async with transaction():` | +| Relationships | `relationship()` + `ForeignKey` columns | `Annotated[..., ForeignKey(...)]` + `BackRef` / `ManyToMany` | +| Async | Native | Native (async-only) | +| Migrations | Alembic | Alembic (via `ferro.migrations.get_metadata`) | + +## Models + +SQLAlchemy: + +```python +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + username: Mapped[str] = mapped_column(unique=True) + email: Mapped[str] +``` + +Ferro: + +=== "Assignment" + + ```python + from ferro import Field, Model + + + class User(Model): + id: int | None = Field(default=None, primary_key=True) + username: str = Field(unique=True) + email: str + ``` + +=== "Annotated" + + ```python + from typing import Annotated + + from ferro import Field, Model + + + class User(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + username: Annotated[str, Field(unique=True)] + email: str + ``` + +Differences worth noting: + +- No `__tablename__` — the table name is derived from the class name (`user`). +- The auto-increment primary key is annotated `int | None` with `default=None`: it is `None` until the row is inserted. +- Because Ferro models are Pydantic models, you get validation, serialization, and FastAPI integration for free — no separate schema classes. +- Declare fields on each concrete model. Ferro does not support inheriting fields from a `Model` base class (the ORM registers query proxies per model class); shared behavior goes in plain mixins instead — see the [Timestamps how-to](timestamps.md). + +## Queries + +Fetch all: + +```python +# SQLAlchemy +from sqlalchemy import select + +async with session_factory() as session: + result = await session.execute(select(User)) + users = result.scalars().all() +``` + +```python +# Ferro +users = await User.all() +``` + +Filtering, ordering, limiting: + +```python +# SQLAlchemy +stmt = select(User).where(User.age >= 18).order_by(User.age).limit(10) +result = await session.execute(stmt) +adults = result.scalars().all() +``` + +```python +# Ferro +adults = await User.where(lambda t: t.age >= 18).order_by(User.age).limit(10).all() +``` + +Get by primary key — the semantics differ. `session.get(User, pk)` returns `None` when the row is missing; Ferro's `User.get(pk)` raises `ModelDoesNotExist`, and `User.get_or_none(pk)` is the optional variant: + +```python +# SQLAlchemy +user = await session.get(User, 1) +``` + +```python +# Ferro — raises if missing +from ferro import ModelDoesNotExist + +try: + user = await User.get(1) +except ModelDoesNotExist: + user = None + +# Ferro — optional (like session.get when no row) +user = await User.get_or_none(1) +``` + +See the [Queries guide](../guide/queries.md) for the full predicate and builder API. + +## Creating Records + +```python +# SQLAlchemy +async with session_factory() as session: + user = User(username="alice", email="alice@example.com") + session.add(user) + await session.commit() +``` + +```python +# Ferro +user = await User.create(username="alice", email="alice@example.com") +``` + +There is no unit of work to flush: `create()` inserts immediately and returns the instance with its primary key set. Ferro also covers the common session idioms directly: `bulk_create([...])` for batch inserts, `get_or_create(...)` and `update_or_create(...)` for upsert-style flows, and `instance.refresh()` to re-read from the database (the rough analog of `session.refresh`). + +## Relationships + +```python +# SQLAlchemy +from sqlalchemy import ForeignKey +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + posts: Mapped[list["Post"]] = relationship(back_populates="author") + + +class Post(Base): + __tablename__ = "posts" + + id: Mapped[int] = mapped_column(primary_key=True) + author_id: Mapped[int] = mapped_column(ForeignKey("users.id")) + author: Mapped["User"] = relationship(back_populates="posts") +``` + +=== "Assignment" + + ```python + # Ferro + from typing import Annotated + + from ferro import BackRef, Field, ForeignKey, Model, Relation + + + class User(Model): + id: int | None = Field(default=None, primary_key=True) + posts: Relation[list["Post"]] = BackRef() + + + class Post(Model): + id: int | None = Field(default=None, primary_key=True) + author: Annotated[User, ForeignKey(related_name="posts")] + ``` + +=== "Annotated" + + ```python + # Ferro + from typing import Annotated + + from ferro import BackRef, Field, ForeignKey, Model, Relation + + + class User(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + posts: Relation[list["Post"]] = BackRef() + + + class Post(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + author: Annotated[User, ForeignKey(related_name="posts")] + ``` + +The `ForeignKey` annotation declares both the relation and the underlying `author_id` column (Ferro creates the shadow column for you). Accessing relations is lazy and awaitable: + +```python +author = await post.author # forward FK → instance +posts = await user.posts.all() # BackRef → chainable query +recent = await user.posts.order_by(Post.id, "desc").limit(5).all() +``` + +Many-to-many uses `ManyToMany(related_name=...)` on one side and `BackRef()` on the other — see the [Relationships guide](../guide/relationships.md). + +## Transactions + +```python +# SQLAlchemy +async with session.begin(): + session.add(User(username="alice")) + # commits on exit, rolls back on exception +``` + +```python +# Ferro +from ferro import transaction + +async with transaction(): + await User.create(username="alice") + # commits on exit, rolls back on exception +``` + +Same shape, no session: everything inside the block runs on one connection and commits or rolls back together. See the [Transactions guide](../guide/transactions.md). + +## Migrations + +Alembic works for both — Ferro ships a bridge that builds a SQLAlchemy `MetaData` from your registered Ferro models, so `alembic revision --autogenerate` keeps working after the switch. Point your `env.py` at it: + +```python +# migrations/env.py +import myapp.models # noqa: F401 — import so all models register + +from ferro.migrations import get_metadata + +target_metadata = get_metadata() +``` + +Install the extra with `pip install "ferro-orm[alembic]"`. For development, `connect(url, auto_migrate=True)` creates tables without any migration files. See the [Schema Migrations guide](../guide/migrations.md) and the [Migrations API](../api/migrations.md). + +## What Has No Ferro Equivalent Yet + +Some SQLAlchemy features have no Ferro counterpart today: + +- **Eager loading** (`selectinload` / `joinedload`) — relations load lazily per access; there is no prefetch API yet. +- **Partial column selects** — queries always hydrate full model instances; there is no `select(User.id, User.name)` equivalent. +- **Aggregations beyond `count()` / `exists()`** — no `func.sum`/`avg`/`min`/`max` or `GROUP BY` builder; use [raw SQL](../guide/raw-sql.md) for those. +- **Atomic update expressions** — no `update().values(count=Model.count + 1)`; batch `update()` sets literal values. + +For what's planned, see the [Roadmap](../roadmap.md). Where you hit a gap, `execute()` / `fetch_all()` give you full SQL with bound parameters. + +## See Also + +- [Quickstart Tutorial](../getting-started/quickstart.md) — Ferro end to end in a few minutes +- [Queries guide](../guide/queries.md) — the full query-building API +- [Schema Migrations guide](../guide/migrations.md) — the Alembic bridge in depth diff --git a/docs/pages/howto/multiple-databases.md b/docs/pages/howto/multiple-databases.md new file mode 100644 index 0000000..7e209d8 --- /dev/null +++ b/docs/pages/howto/multiple-databases.md @@ -0,0 +1,62 @@ +# Multiple Databases + +One process can hold several named connections — a primary application database, a read replica, an analytics warehouse — each with its own pool. The common case still works untouched: `await connect(url)` registers and selects `"default"`. Named connections make everything else explicit. + +## Registering Connections + +Give each connection a `name`; mark one as `default` for unqualified operations: + +```python +--8<-- "docs/examples/multiple_databases.py:connect" +``` + +Each `connect()` call creates an independent pool, configurable per connection with `pool=PoolConfig(max_connections=...)`. You can change which named connection is the default later with [`set_default_connection(name)`](../api/connection.md). + +## Routing Queries + +Everything runs on the default connection unless routed. `Model.using(name)` returns a handle exposing the same API (`create`, `all`, `select`, `where`, `get`, `get_or_none`, `bulk_create`, `get_or_create`, `update_or_create`) pinned to the named connection: + +```python +--8<-- "docs/examples/multiple_databases.py:routing" +``` + +Routing is per-call: `using()` doesn't change any global state, so two coroutines can talk to different databases concurrently without interfering. + +## Transactions on Named Connections + +`transaction(using=...)` pins a transaction to one named connection: + +```python +--8<-- "docs/examples/multiple_databases.py:transaction" +``` + +Everything inside the block — including raw SQL via `execute()` / `fetch_all()` — inherits that connection. Nested `transaction()` blocks inherit it too. Ferro does not support distributed transactions: one `transaction()` spans exactly one named connection, so writes to two databases are never atomic together. + +## Per-Connection Schema Setup + +Schema creation targets one connection at a time: + +- `connect(url, name=..., auto_migrate=True)` runs auto-migration **on that connection** as part of connecting — each database gets tables for all registered models, as the example above shows. +- [`create_tables(using=...)`](../api/connection.md) creates tables explicitly on a named connection after the fact: + +```python +from ferro import create_tables + +await create_tables(using="analytics") +``` + +Don't run schema creation concurrently through multiple names that point at the same physical database. For production schema changes, prefer one migration-capable connection and the [Alembic bridge](../guide/migrations.md). + +## Practical Notes + +- **Typical roles.** A read replica for expensive list endpoints (`User.using("replica")`), an analytics database that receives event rows, or a separate service database owned by another team. +- **Keep credentials server-side.** Elevated service-role connections belong in configuration, not source control — and never make a service-role connection the default in a user-facing runtime. +- **Never route from untrusted input.** Don't pick the `using` name from request data. +- **Pools isolate roles, not request context.** A named connection isolates credentials and pooling; it does not provide per-request RLS/JWT context inside one shared pool. Objects loaded through an elevated connection can contain elevated data — filter before returning them to users. +- **No automatic routing.** Read/write splitting, cross-connection joins, and two-phase commit are not features; routing is always an explicit `using` call. + +## See Also + +- [Connections & Databases guide](../guide/connections.md) — URLs, pools, and connection lifecycle +- [Transactions guide](../guide/transactions.md) — transaction semantics and nesting +- [Connection & Registry API](../api/connection.md) — `connect`, `PoolConfig`, `set_default_connection`, `create_tables` diff --git a/docs/pages/howto/pagination.md b/docs/pages/howto/pagination.md new file mode 100644 index 0000000..ab6fd09 --- /dev/null +++ b/docs/pages/howto/pagination.md @@ -0,0 +1,122 @@ +# Pagination + +Ferro supports the two standard pagination strategies: offset pagination with `limit()` / `offset()`, and keyset (cursor) pagination with a `where` filter on the sort key. This page shows both and when to pick each. + +## Offset Pagination + +The simplest approach: order the rows, skip `(page - 1) * per_page`, take `per_page`. + +```python +--8<-- "docs/examples/pagination.py:offset" +``` + +Offset pagination is easy to implement and lets users jump to an arbitrary page number. It has two well-known costs: + +- **Drift.** If rows are inserted or deleted between requests, page boundaries shift — a user paging through results can see duplicates or miss rows. +- **Deep offsets are expensive.** The database still scans and discards every skipped row, so `OFFSET 100000` does real work before returning anything. Latency grows with page depth. + +For small datasets and admin-style page numbers, neither matters much. For feeds and large tables, use keyset pagination instead. + +## Keyset (Cursor) Pagination + +Instead of skipping rows, remember the last value seen and filter past it: + +```python +--8<-- "docs/examples/pagination.py:keyset" +``` + +The client passes back the `id` of the last item it received (the cursor); the next query is a plain indexed range scan (`WHERE id > ? ORDER BY id LIMIT ?`). This makes keyset pagination: + +- **Stable** — inserts and deletes elsewhere in the table don't shift the window, so no duplicates or gaps. +- **Fast at any depth** — page 1 and page 10,000 cost the same, since the database seeks directly to the cursor instead of scanning skipped rows. + +The trade-off: clients can only walk forward (or backward) from a cursor — there is no "jump to page 57". + +## Using It in an API + +A keyset endpoint returns the items plus the cursor for the next request: + +=== "Assignment" + + ```python + from fastapi import FastAPI, Query + + from ferro import Field, Model + + + class Article(Model): + id: int | None = Field(default=None, primary_key=True) + title: str + + + app = FastAPI() + + + @app.get("/articles") + async def list_articles( + cursor: int | None = Query(None), + limit: int = Query(20, ge=1, le=100), + ): + query = Article.select() if cursor is None else Article.where(lambda t: t.id > cursor) + items = await query.order_by(Article.id).limit(limit).all() + return { + "items": items, + "next_cursor": items[-1].id if items else None, + "has_more": len(items) == limit, + } + ``` + +=== "Annotated" + + ```python + from typing import Annotated + + from fastapi import FastAPI, Query + + from ferro import Field, Model + + + class Article(Model): + id: Annotated[int | None, Field(default=None, primary_key=True)] + title: str + + + app = FastAPI() + + + @app.get("/articles") + async def list_articles( + cursor: int | None = Query(None), + limit: int = Query(20, ge=1, le=100), + ): + query = Article.select() if cursor is None else Article.where(lambda t: t.id > cursor) + items = await query.order_by(Article.id).limit(limit).all() + return { + "items": items, + "next_cursor": items[-1].id if items else None, + "has_more": len(items) == limit, + } + ``` + +Clients call `/articles`, then `/articles?cursor=` until `has_more` is false. Ferro models are Pydantic models, so FastAPI serializes them directly. + +## Tips + +- **Always `order_by`.** Without an explicit ordering, the database returns rows in whatever order it likes, and `limit`/`offset` windows become non-deterministic. Order by a unique column (or end the ordering with one) so ties can't straddle a page boundary. +- **Index your sort columns.** Both strategies turn into an ordered scan over the sort key. Declare an index on non-primary-key sort columns, e.g. `created_at: datetime = Field(index=True)`. Primary keys are already indexed. +- **Cap page sizes.** Enforce a maximum `limit` at the API boundary (as the FastAPI example does with `le=100`) so a single request can't ask for the whole table. + +## Choosing a Strategy + +| | Offset | Keyset (cursor) | +|---|---|---| +| Jump to arbitrary page | Yes | No | +| Stable under concurrent writes | No (drift) | Yes | +| Cost of deep pages | Grows with depth | Constant | +| Implementation effort | Trivial | Small (track a cursor) | +| Best for | Small tables, admin UIs, page numbers | Feeds, infinite scroll, large tables, APIs | + +## See Also + +- [Queries guide](../guide/queries.md) — filtering, ordering, `limit` and `offset` +- [Queries API](../api/queries.md) — the full `Query` builder reference diff --git a/docs/pages/howto/soft-deletes.md b/docs/pages/howto/soft-deletes.md new file mode 100644 index 0000000..5bd45ea --- /dev/null +++ b/docs/pages/howto/soft-deletes.md @@ -0,0 +1,63 @@ +# Soft Deletes + +Soft deletes mark rows as deleted instead of removing them, so records can be audited or restored later. In Ferro this is two flag fields plus a small mixin that supplies the lifecycle methods. + +## The Pattern + +=== "Assignment" + + ```python + --8<-- "docs/examples/soft_deletes.py:model" + ``` + +=== "Annotated" + + ```python + --8<-- "docs/examples/soft_deletes_annotated.py:model" + ``` + +- **Fields on the concrete model.** `is_deleted` and `deleted_at` are declared on `Invoice` itself. Every model that wants soft deletes repeats these two declarations. +- **`SoftDeleteMixin` for behavior.** `soft_delete()` and `restore()` flip the flags and `save()`; `active()` is a classmethod returning a normal [`Query`](../api/queries.md) filtered to non-deleted rows, so it chains like any other query. + +The mixin is a plain class, not a `Model` subclass. Ferro registers table schemas and query proxies on each model class as it is defined, so a `Model` base class cannot contribute fields to subclasses — declare fields on each concrete model and keep shared behavior in the mixin. (See the [Timestamps how-to](timestamps.md) for the same pattern.) + +## Usage + +```python +--8<-- "docs/examples/soft_deletes.py:usage" +``` + +`soft_delete()` keeps the row in the table — `Invoice.select().count()` still sees it — while `Invoice.active()` excludes it. `restore()` brings it back. + +## Querying + +Use `active()` as the entry point everywhere you would otherwise use `select()` or `where()`: + +```python +unpaid = await Invoice.active().where(lambda t: t.number.like("INV-%")).all() +trashed = await Invoice.where(lambda t: t.is_deleted == True).all() # noqa: E712 +``` + +Two things to remember: + +- **Nothing is filtered automatically.** `Invoice.all()`, `Invoice.select()`, `Invoice.get(pk)` and relationship traversals still return soft-deleted rows. The `active()` discipline is a convention your code must follow. +- **Batch and instance deletes bypass soft delete.** `await invoice.delete()` and `await Invoice.where(...).delete()` issue real `DELETE` statements — the mixin only adds `soft_delete()`, it does not intercept the built-in delete paths. Reach for the hard delete deliberately (e.g. retention cleanup), not by accident. + +If you want soft-deleted rows to age out, a periodic job can purge them for real: + +```python +async def purge_deleted() -> int: + return await Invoice.where(lambda t: t.is_deleted == True).delete() # noqa: E712 +``` + +## Trade-offs + +- **Unique constraints see soft-deleted rows.** A unique column like `number: str = Field(unique=True)` still holds the value after a soft delete, so creating a replacement with the same number fails. Options: hard-delete in that flow, rename the value on soft delete (e.g. suffix the primary key), or drop the database-level constraint and enforce uniqueness among active rows in application code. +- **Data growth.** Soft-deleted rows stay in the table and its indexes, so table scans, backups, and index sizes grow forever unless you purge. Pair soft deletes with a retention policy. +- **Privacy.** "Deleted" data is still data. If users expect deletion to remove personal information, soft delete alone does not satisfy that — schedule a real purge. + +## See Also + +- [Timestamps how-to](timestamps.md) — the mixin pattern explained in detail +- [Queries guide](../guide/queries.md) — building filtered queries +- [Mutations guide](../guide/mutations.md) — `save()`, `delete()`, and batch operations diff --git a/docs/pages/howto/testing.md b/docs/pages/howto/testing.md new file mode 100644 index 0000000..0f157cc --- /dev/null +++ b/docs/pages/howto/testing.md @@ -0,0 +1,169 @@ +# Testing + +Ferro applications are easy to test: connect to a fresh in-memory SQLite database per test, run your code, and reset the engine on teardown. This page shows the standard pytest setup, a factory pattern for test data, and how to isolate tests against a real PostgreSQL database. + +## Test Setup + +Put two fixtures in your `conftest.py`: + +```python +--8<-- "docs/examples/testing_conftest.py:fixtures" +``` + +How this works: + +- **`db`** connects to a fresh in-memory SQLite database for each test. `auto_migrate=True` creates tables for every registered model, so there is no schema setup to maintain. On teardown, [`reset_engine()`](../api/connection.md) closes the pool and clears the identity map, guaranteeing no state leaks between tests. +- **`db_transaction`** layers a [`transaction()`](../guide/transactions.md) on top. Everything inside the test shares one connection, which gives you connection affinity for the duration of the test. Use it when a test mixes ORM calls with raw SQL that must observe the same uncommitted state. + +Since each test gets its own database, most tests only need `db`. + +## Configuring pytest-asyncio + +Ferro is async, so tests are `async def` functions. With `asyncio_mode = auto`, pytest-asyncio runs them without per-test decorators: + +```ini +# pytest.ini +[pytest] +asyncio_mode = auto +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +``` + +Or in `pyproject.toml`: + +```toml +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +``` + +If you prefer `asyncio_mode = strict`, mark each test with `@pytest.mark.asyncio`. + +## Writing Your First Test + +Request the `db` fixture and use your models directly: + +```python +from myapp.models import User + + +async def test_create_user(db): + user = await User.create(username="testuser", email="test@example.com") + + assert user.id is not None + assert user.username == "testuser" + + # Verify it round-trips through the database + found = await User.where(lambda t: t.username == "testuser").first() + assert found is not None + assert found.id == user.id +``` + +Constraint violations surface as exceptions from the engine: + +```python +import pytest + +from myapp.models import User + + +async def test_user_unique_email(db): + await User.create(username="user1", email="same@example.com") + + with pytest.raises(Exception): + await User.create(username="user2", email="same@example.com") +``` + +## Factories + +For tests that need realistic object graphs, a small factory class keeps setup terse without pulling in a library: + +```python +from typing import Any + +from myapp.models import Post, User + + +class UserFactory: + _counter = 0 + + @classmethod + async def create(cls, **kwargs: Any) -> User: + cls._counter += 1 + defaults = { + "username": f"user_{cls._counter}", + "email": f"user{cls._counter}@example.com", + } + defaults.update(kwargs) + return await User.create(**defaults) + + +class PostFactory: + _counter = 0 + + @classmethod + async def create(cls, **kwargs: Any) -> Post: + cls._counter += 1 + + # Auto-create an author when none is provided + if "author" not in kwargs: + kwargs["author"] = await UserFactory.create() + + defaults = {"title": f"Post {cls._counter}", "content": "Test content"} + defaults.update(kwargs) + return await Post.create(**defaults) + + +async def test_post_with_author(db): + post = await PostFactory.create(title="Custom Title") + assert (await post.author) is not None +``` + +Override only the fields the test cares about; the counters keep unique-constrained fields distinct. + +## Testing Against Postgres + +SQLite in memory covers most logic, but behavior that depends on native Postgres types, casts, or constraints should run against a real PostgreSQL database. + +### Schema isolation with `?ferro_search_path=...` + +Appending `ferro_search_path=` to a Postgres connection URL makes Ferro run `SET search_path TO ` on every pooled connection. All tables created by `auto_migrate` (and all queries) then live in that schema, so many test runs can share one physical database without colliding. + +The schema must already exist — create it before connecting, and drop it on teardown: + +```python +import uuid + +import pytest + +from ferro import connect, execute, reset_engine + +POSTGRES_URL = "postgresql://localhost:5432/app_test" + + +@pytest.fixture +async def pg_db(): + schema = f"test_{uuid.uuid4().hex[:8]}" + + # Create the schema with a throwaway connection + await connect(POSTGRES_URL) + await execute(f'CREATE SCHEMA "{schema}"') + reset_engine() + + # Reconnect with the schema as the search path + await connect(f"{POSTGRES_URL}?ferro_search_path={schema}", auto_migrate=True) + yield + + await execute(f'DROP SCHEMA "{schema}" CASCADE') + reset_engine() +``` + +Schema names passed through `ferro_search_path` must contain only ASCII letters, digits, and underscores; anything else is rejected at connect time. + +## See Also + +- [Transactions guide](../guide/transactions.md) — semantics of `transaction()` and connection affinity +- [Connections & Databases guide](../guide/connections.md) — connection URLs and `auto_migrate` +- [Connection & Registry API](../api/connection.md) — `connect`, `reset_engine`, and friends diff --git a/docs/pages/howto/timestamps.md b/docs/pages/howto/timestamps.md new file mode 100644 index 0000000..7ff7252 --- /dev/null +++ b/docs/pages/howto/timestamps.md @@ -0,0 +1,56 @@ +# Timestamps + +Track when rows are created and last modified with `created_at` / `updated_at` fields: `created_at` is filled by a field default, and `updated_at` is refreshed by a small mixin that hooks `save()`. + +## The Pattern + +=== "Assignment" + + ```python + --8<-- "docs/examples/timestamps.py:model" + ``` + +=== "Annotated" + + ```python + --8<-- "docs/examples/timestamps_annotated.py:model" + ``` + +Two pieces work together: + +- **Field defaults on the concrete model.** `created_at` and `updated_at` are declared on `Note` itself with `default_factory=utcnow`, so both are set when an instance is constructed. +- **`TimestampMixin` for behavior.** The mixin overrides `save()` to touch `updated_at` before delegating to `Model.save()`. It is a *plain class* — not a `Model` subclass — and contributes only behavior, never fields. + +Every model that wants timestamps repeats the same two field declarations and adds the mixin to its bases (mixin first, so its `save()` wins in the MRO). + +!!! note "Why a mixin instead of a `Model` base class?" + + You might expect to declare the fields once on a shared base, e.g. `class Timestamped(Model)` with `created_at` / `updated_at`, and inherit from it. Ferro does not support this: the ORM registers a table schema and query proxies (the class attributes behind `Note.created_at > ...`) on each model class as it is defined, so fields declared on a `Model` base class are not contributed to its subclasses' tables. Keep shared *behavior* in a plain mixin and declare *fields* on each concrete model. + +## Usage + +```python +--8<-- "docs/examples/timestamps.py:usage" +``` + +`created_at` is set once when the instance is created; every subsequent `save()` advances `updated_at`. Note that the mixin only hooks instance `save()` (which `create()` paths go through) — batch updates like `Note.where(...).update(...)` write columns directly and will not touch `updated_at` unless you set it explicitly in the update. + +## Timezone Notes + +Store UTC, always. The example's `utcnow()` helper returns a timezone-aware datetime: + +```python +from datetime import UTC, datetime + + +def utcnow() -> datetime: + return datetime.now(UTC) +``` + +Avoid naive `datetime.now()` — it captures the server's local clock, which makes values ambiguous and breaks comparisons across hosts and DST changes. Keep storage in UTC and convert to the user's timezone only at the display layer. + +## See Also + +- [Models & Fields guide](../guide/models-and-fields.md) — field defaults and `default_factory` +- [Soft Deletes how-to](soft-deletes.md) — the same mixin pattern applied to deletion +- [Mutations guide](../guide/mutations.md) — instance `save()` vs batch `update()` diff --git a/docs/pages/index.md b/docs/pages/index.md new file mode 100644 index 0000000..e9355da --- /dev/null +++ b/docs/pages/index.md @@ -0,0 +1,12 @@ +# Ferro ORM + +--8<-- "README.md:main" + +## Where to go next + +- **[Quickstart Tutorial](getting-started/quickstart.md)** — Build a working app with models, queries, relationships, and transactions in about 10 minutes. +- **[Why Ferro](why-ferro.md)** — How Ferro differs from SQLAlchemy, Django ORM, and Tortoise, and the trade-offs of a Rust-core ORM. +- **[Guide](guide/models-and-fields.md)** — In-depth coverage of models, fields, queries, mutations, relationships, transactions, and migrations. +- **[How-To](howto/testing.md)** — Recipes for testing, pagination, timestamps, soft deletes, and multiple databases. +- **[API Reference](api/model.md)** — Complete reference for every public class and method. +- **[Roadmap](roadmap.md)** — What's implemented today and what's coming next. diff --git a/docs/pages/roadmap.md b/docs/pages/roadmap.md new file mode 100644 index 0000000..417046a --- /dev/null +++ b/docs/pages/roadmap.md @@ -0,0 +1,22 @@ +# Roadmap + +Ferro is pre-1.0 and under active development. The items below are known gaps we intend to close; priorities are driven by what users actually hit, so [issues](https://github.com/syn54x/ferro-orm/issues) move things up this list. + +## Query Features + +- **Aggregations beyond `count()`/`exists()`** — `sum`, `avg`, `min`, `max` on the query builder. Today you either compute in Python after fetching or drop to raw SQL. +- **Partial selects** — loading a subset of columns (`User.select(User.id, User.username)`-style) instead of full models, for wide tables and hot read paths. +- **Eager loading** — `prefetch_related`/`select_related`-style relationship loading to eliminate N+1 query patterns. Today each awaited relationship attribute is its own query. +- **`ilike()`** — case-insensitive pattern matching. Workaround: `like()` with normalized case. +- **`not_in_()`** — NOT IN exclusion lists. Workaround: combine `!=` comparisons with `&`. +- **Atomic update expressions** — database-side expressions in batch updates, e.g. `update(view_count=Post.view_count + 1)`, avoiding the read-modify-write race. Workaround today: load, mutate, `save()` (or raw SQL). + +## Connections + +- **`disconnect()`** — graceful pool shutdown for application shutdown hooks. Today cleanup happens at process exit. +- **Health checks** — a `check_connection()`-style probe for readiness endpoints. Workaround: run a trivial query and catch the failure. +- **Richer pool configuration** — `PoolConfig` covers `max_connections`/`min_connections` today; acquire timeouts, idle timeouts, and max connection lifetime are future work. + +## Influencing Priorities + +None of this is on a promised schedule. If one of these gaps blocks you, say so on the [issue tracker](https://github.com/syn54x/ferro-orm/issues) — a concrete use case is the strongest signal we get, and [contributions](contributing.md) are welcome. diff --git a/docs/stylesheets/extra.css b/docs/pages/stylesheets/extra.css similarity index 100% rename from docs/stylesheets/extra.css rename to docs/pages/stylesheets/extra.css diff --git a/docs/pages/why-ferro.md b/docs/pages/why-ferro.md new file mode 100644 index 0000000..7592bb4 --- /dev/null +++ b/docs/pages/why-ferro.md @@ -0,0 +1,89 @@ +# Why Ferro? + +## The Problem + +Python ORMs are convenient, but they come with a performance tax. Traditional ORMs like SQLAlchemy, Django ORM, and Tortoise spend significant CPU time in Python code: + +- **SQL generation** — building query strings, escaping values, assembling JOINs +- **Row parsing** — converting database rows into Python objects +- **Object instantiation** — calling `__init__`, running validators, populating attributes +- **GIL contention** — all of the above happens while holding the Global Interpreter Lock + +For simple CRUD this overhead is acceptable. But when you process thousands of rows per request, run high-concurrency workloads, or care about tail latency in services, the Python tax becomes the bottleneck. + +## How Ferro is Different + +Ferro moves the expensive parts out of Python and into a Rust engine, connected to Python through a PyO3 FFI bridge. + +### Rust Core + +- **SQL generation**: Sea-Query builds parameterized SQL in Rust +- **Row hydration**: SQLx executes queries and parses rows GIL-free +- **Minimal copying**: data flows from database → Rust → Python with zero-copy intent +- **Bundled drivers**: SQLite and PostgreSQL support is compiled into the engine — no separate driver packages + +When you call `User.where(lambda t: t.age >= 18).all()`, Python only builds a small filter AST. SQL generation, execution, and row parsing all happen in Rust; Python receives hydrated `User` objects at the end. + +### Pydantic-Native + +Unlike ORMs that wrap Pydantic or use it as a serialization layer, Ferro models *are* Pydantic models: + +- Models inherit directly from `pydantic.BaseModel` +- Validation runs in `pydantic-core` (also Rust) +- Type hints work exactly as your IDE and type checker expect +- No adapter layer between your ORM models and your API schemas + +If you already use FastAPI or any Pydantic-heavy stack, your database models and your validation models are the same objects. + +### Async-First + +Ferro is built on `sqlx-core` and `pyo3-async-runtimes`: + +- True async from Rust to Python — no sync wrappers or thread pools +- Connection pooling handled by SQLx +- Concurrent query execution without blocking the event loop + +## What You Give Up + +Ferro is not the right choice for every project. Be honest with yourself about these trade-offs: + +- **Python 3.13+ only.** Ferro targets modern Python and does not support older interpreters. +- **Async-only API.** There is no synchronous interface. If your application is sync (e.g., classic Flask or scripts without an event loop), Ferro is a poor fit. +- **Young feature set.** Ferro covers models, queries, mutations, relationships, transactions, and Alembic-based migrations — but some features common in mature ORMs are not implemented yet, including eager loading (`prefetch`/`select_related`), aggregations beyond `count()` and `exists()`, and partial column selects. See the [Roadmap](roadmap.md) for what's planned. +- **Smaller ecosystem.** Fewer third-party integrations, plugins, and Stack Overflow answers than SQLAlchemy or Django. +- **Rust at the bottom.** You never need Rust to *use* Ferro, but contributing to or extending the engine requires it, and building from source needs a Rust toolchain. + +## Comparison + +| | Ferro | SQLAlchemy 2.0 | Django ORM | Tortoise ORM | +|---|---|---|---|---| +| **Core** | Rust (SQLx + Sea-Query) | Python | Python | Python | +| **Async support** | Native, async-only | Native (opt-in) | Limited | Native | +| **Type safety** | Pydantic models | Typed declarative API | Dynamic | Basic Pydantic integration | +| **Learning curve** | Low | High | Low | Low | +| **Migrations** | Alembic (optional extra) | Alembic | Built-in | Aerich | +| **Runtime dependencies** | Pydantic only | Several | Django | Several | +| **Ecosystem maturity** | Young | Very mature | Very mature | Moderate | +| **Backends** | SQLite, PostgreSQL | Many dialects | Many | Several | + +Ferro's architecture is designed to make bulk reads, large result sets, and row hydration fast by keeping that work in Rust and outside the GIL. For single-row operations, network and disk latency dominate and every ORM performs similarly — choose based on ergonomics and ecosystem, not microbenchmarks. + +## When to Choose Ferro + +Choose Ferro when: + +- You're building **async services** — FastAPI, Starlette, Litestar, or anything on asyncio +- Your codebase is **Pydantic-heavy** and you want one model class for validation and persistence +- You move **lots of rows** — data pipelines, bulk ingestion, read-heavy APIs +- You want a **small dependency footprint** (Pydantic is the only runtime dependency) +- You're on **SQLite or PostgreSQL** and Python 3.13+ + +Choose something else when: + +- You need a **sync API** or support for Python < 3.13 +- You need **dialects beyond SQLite/PostgreSQL** (MySQL, MSSQL, Oracle) — use SQLAlchemy +- You're inside a **Django project** — the integrated Django ORM is the pragmatic choice +- You rely on features Ferro hasn't shipped yet — check the [Roadmap](roadmap.md) before committing +- You need **maximum query flexibility** for deeply complex SQL — SQLAlchemy Core is hard to beat + +Migrating from SQLAlchemy? There's a [dedicated guide](howto/migrate-from-sqlalchemy.md). Otherwise, the best way to evaluate Ferro is the [Quickstart Tutorial](getting-started/quickstart.md) — it takes about 10 minutes. diff --git a/docs/why-ferro.md b/docs/why-ferro.md deleted file mode 100644 index b191e50..0000000 --- a/docs/why-ferro.md +++ /dev/null @@ -1,206 +0,0 @@ -# Why Ferro? - -## The Problem - -Python ORMs are convenient but come with a **performance tax**. Traditional ORMs like Django ORM, SQLAlchemy, and Tortoise spend significant CPU time in Python code: - -- **SQL generation**: Building query strings, escaping values, assembling complex JOINs -- **Row parsing**: Converting database rows into Python objects -- **Object instantiation**: Calling `__init__`, running validators, populating attributes -- **GIL contention**: All of this happens while holding the Global Interpreter Lock - -For simple CRUD operations, this overhead is acceptable. But when you need to: - -- Process thousands of rows in a request -- Run complex aggregations -- Handle high-concurrency workloads -- Minimize latency in microservices - -...the Python tax becomes a bottleneck. - -## How Ferro is Different - -Ferro moves the expensive parts out of Python and into a high-performance Rust engine: - -### Rust Core - -- **SQL Generation**: Sea-Query builds optimized SQL queries in Rust -- **Row Hydration**: SQLx parses database rows directly into memory -- **Zero-Copy**: Data flows from database → Rust → Python with minimal copying -- **GIL-Free**: All I/O and parsing happens outside the GIL - -### Pydantic-Native - -Unlike ORMs that wrap Pydantic or use it as a serialization layer, Ferro **is** Pydantic: - -- Models inherit directly from `pydantic.BaseModel` -- Validation uses `pydantic-core` (Rust) -- Type hints work exactly as expected -- No adapter layer, no conversion overhead - -### Async-First - -Built on `sqlx-core` and `pyo3-async-runtimes`: - -- True async from Rust → Python -- No sync wrappers or thread pools -- Efficient connection pooling -- Concurrent query execution - -## Architecture - -```mermaid -graph LR - Python[Python Layer
Pydantic Models] -->|PyO3 FFI| Rust[Rust Engine
SQLx + Sea-Query] - Rust -->|SQL| DB[(Database)] - DB -->|Rows| Rust - Rust -->|Zero-Copy| Python -``` - -When you call `User.where(User.age >= 18).all()`: - -1. **Python**: Query builder creates a filter AST and passes it to Rust -2. **Rust**: Sea-Query generates `SELECT * FROM users WHERE age >= $1` -3. **SQLx**: Executes the query, receives rows -4. **Rust**: Parses rows into a memory layout compatible with Pydantic -5. **Python**: Receives hydrated `User` objects via zero-copy transfer - -[Learn more about the architecture →](concepts/architecture.md) - -## Benchmarks - -!!! note "Benchmark Status" - Comprehensive benchmarks comparing Ferro to other Python ORMs are in progress. Results will be published here and in the [benchmarks repository](https://github.com/syn54x/ferro-benchmarks). - -### Expected Performance Characteristics - -Based on Ferro's architecture: - -**Fast:** -- ✅ Bulk inserts (1K+ rows) -- ✅ Complex queries with JOINs -- ✅ Filtering large result sets -- ✅ Row hydration and object creation -- ✅ Connection pooling overhead - -**Similar to other ORMs:** -- Single-row operations (connection latency dominates) -- Schema introspection -- Migration generation - -**Slower (by design):** -- Initial import time (Rust extension loading) - -## Comparison - -| Feature | Ferro | SQLAlchemy 2.0 | Tortoise ORM | Django ORM | -|---------|-------|----------------|--------------|------------| -| **Performance** | ⚡⚡⚡ Rust core | ⚡ Python | ⚡ Python | ⚡ Python | -| **Async Support** | ✅ Native | ✅ Native | ✅ Native | ⚠️ Limited | -| **Type Safety** | ✅ Pydantic | ✅ Typing | ⚡ Basic | ❌ Dynamic | -| **Learning Curve** | Low | High | Low | Low | -| **Ecosystem** | 🌱 Growing | 🌳 Mature | 🌿 Medium | 🌳 Mature | -| **Migrations** | ✅ Alembic | ✅ Alembic | ✅ Aerich | ✅ Built-in | -| **Dependencies** | Pydantic only | Many | Many | Django | - -### SQLAlchemy 2.0 - -**Pros:** -- Battle-tested, mature ecosystem -- Extremely flexible (multiple APIs: Core, ORM, hybrid) -- Extensive dialect support -- Rich plugin ecosystem - -**Cons:** -- Complex API surface (steep learning curve) -- Python-based performance ceiling -- Verbosity (especially Core API) - -**Choose SQLAlchemy if:** You need maximum flexibility, have complex requirements, or are building a long-lived enterprise application where maturity matters more than raw performance. - -### Tortoise ORM - -**Pros:** -- Django-like API (familiar for Django devs) -- Good async support -- Simpler than SQLAlchemy - -**Cons:** -- Smaller community -- Python-based performance -- Less flexible than SQLAlchemy - -**Choose Tortoise if:** You want Django-style ORM ergonomics with async support and don't need cutting-edge performance. - -### Django ORM - -**Pros:** -- Huge ecosystem and community -- Excellent documentation -- Integrated with Django framework -- Admin interface integration - -**Cons:** -- Sync-first (async support is limited) -- Tied to Django (can't use standalone easily) -- Python-based performance - -**Choose Django if:** You're building a full Django application and need the integrated ecosystem (admin, auth, forms, etc.). - -## Trade-offs - -Ferro is **not** the best choice for every use case. Be aware of these trade-offs: - -### ❌ Not Battle-Tested - -Ferro is newer than SQLAlchemy (2006), Django ORM (2005), or Tortoise (2018). While it's production-ready, you may encounter edge cases that more mature ORMs have already solved. - -### ❌ Smaller Ecosystem - -Fewer third-party integrations, plugins, and extensions. If you need specialized adapters (GraphQL, Admin UIs, etc.), you may need to build them yourself. - -### ❌ Rust Dependency - -While most users never touch Rust code, custom extensions require Rust knowledge. SQLAlchemy's pure-Python codebase is easier to fork and modify. - -### ❌ Compile Time - -Ferro is distributed as pre-compiled wheels, but if you build from source (e.g., for an unsupported platform), compile times can be 2-5 minutes. - -### ✅ When to Choose Ferro - -- High-throughput APIs (>1K requests/sec) -- Data processing pipelines (bulk operations) -- Real-time applications (low latency requirements) -- Microservices (startup time and memory efficiency matter) -- FastAPI/Starlette apps (async-first, type-safe) -- Pydantic-heavy codebases (seamless integration) - -### ❌ When NOT to Choose Ferro - -- Prototypes (use Django ORM for speed of development) -- Enterprise apps with strict vendor support requirements -- Complex query requirements (SQLAlchemy Core is more flexible) -- Django-integrated projects (use Django ORM) - -## Migration Paths - -Planning to switch from another ORM? - -- [Migrating from SQLAlchemy](migration-sqlalchemy.md) - Available now -- Migrating from Django ORM - Coming soon -- Migrating from Tortoise ORM - Coming soon - -## Try It Yourself - -The best way to evaluate Ferro is to build something with it: - -[:octicons-arrow-right-24: Follow the 5-minute tutorial](getting-started/tutorial.md){ .md-button .md-button--primary } - -Or jump into the [User Guide](guide/models-and-fields.md) if you prefer learning by reading. - -## Still Have Questions? - -- [Check the FAQ](faq.md) -- [Read about the architecture](concepts/architecture.md) -- [Ask on GitHub Discussions](https://github.com/syn54x/ferro-orm/discussions) diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index f1fbd0c..0000000 --- a/mkdocs.yml +++ /dev/null @@ -1,170 +0,0 @@ -site_name: Ferro ORM -site_description: A high-performance, Rust-backed, Pydantic driven ORM for Python. -site_url: https://syn54x.github.io/ferro-orm/ -repo_url: https://github.com/syn54x/ferro-orm -edit_uri: edit/main/docs/ - -watch: - - src - - docs - -exclude_docs: | - superpowers/ - -extra_css: - - stylesheets/extra.css - -theme: - name: material - palette: - - media: "(prefers-color-scheme: light)" - scheme: light-rust - primary: custom - accent: amber - toggle: - icon: material/brightness-7 - name: Switch to dark mode - - media: "(prefers-color-scheme: dark)" - scheme: slate - primary: custom - accent: amber - toggle: - icon: material/brightness-4 - name: Switch to light mode - features: - - navigation.tabs - - navigation.sections - - navigation.expand - - navigation.top - - search.suggest - - search.highlight - - content.code.copy - - content.code.annotate - -plugins: - - search - - llmstxt: - markdown_description: Ferro is a high-performance, Rust-backed ORM for Python with a familiar Pydantic-style API. - full_output: llms-full.txt - sections: - Getting Started: - - getting-started/installation.md - - getting-started/tutorial.md - - getting-started/next-steps.md - User Guide: - - guide/models-and-fields.md - - guide/relationships.md - - guide/queries.md - - guide/mutations.md - - guide/transactions.md - - guide/backend.md - - guide/database.md - - guide/migrations.md - How-To: - - howto/pagination.md - - howto/testing.md - - howto/timestamps.md - - howto/soft-deletes.md - - howto/multiple-databases.md - Concepts: - - concepts/architecture.md - - concepts/identity-map.md - - concepts/type-safety.md - - concepts/query-typing.md - - concepts/performance.md - API Reference: - - api/*.md - Community: - - faq.md - - contributing.md - - changelog.md - - coming-soon.md - - mkdocstrings: - handlers: - python: - paths: [src] - options: - show_source: true - show_root_heading: true - show_root_full_path: false - show_category_heading: true - show_if_no_docstring: true - members_order: source - -markdown_extensions: - - attr_list - - md_in_html - - admonition - - pymdownx.details - - pymdownx.superfences: - custom_fences: - - name: mermaid - class: mermaid - format: !!python/name:pymdownx.superfences.fence_code_format - - pymdownx.snippets: - base_path: ["."] - - pymdownx.highlight: - anchor_linenums: true - line_spans: __span - pygments_lang_class: true - - pymdownx.inlinehilite - - pymdownx.keys - - pymdownx.mark - - pymdownx.smartsymbols - - pymdownx.tabbed: - alternate_style: true - - pymdownx.emoji: - emoji_index: !!python/name:material.extensions.emoji.twemoji - emoji_generator: !!python/name:material.extensions.emoji.to_svg - - tables - - toc: - permalink: true - -nav: - - Home: index.md - - Why Ferro?: why-ferro.md - - - Getting Started: - - Installation: getting-started/installation.md - - Tutorial: getting-started/tutorial.md - - Next Steps: getting-started/next-steps.md - - - User Guide: - - Models & Fields: guide/models-and-fields.md - - Relationships: guide/relationships.md - - Queries: guide/queries.md - - Mutations: guide/mutations.md - - Transactions: guide/transactions.md - - Backend: guide/backend.md - - Database Setup: guide/database.md - - Schema Management: guide/migrations.md - - - How-To: - - Pagination: howto/pagination.md - - Testing: howto/testing.md - - Timestamps: howto/timestamps.md - - Soft Deletes: howto/soft-deletes.md - - Multiple Databases: howto/multiple-databases.md - - - Concepts: - - Architecture: concepts/architecture.md - - Identity Map: concepts/identity-map.md - - Type Safety: concepts/type-safety.md - - Typed Query Predicates: concepts/query-typing.md - - Performance: concepts/performance.md - - - API Reference: - - Model: api/model.md - - Exceptions: api/exceptions.md - - Query: api/query.md - - Fields: api/fields.md - - Relationships: api/relationships.md - - Transactions: api/transactions.md - - Raw SQL: api/raw-sql.md - - Utilities: api/utilities.md - - - Community: - - FAQ: faq.md - - Contributing: contributing.md - - Changelog: changelog.md - - Coming Soon: coming-soon.md diff --git a/pyproject.toml b/pyproject.toml index 24ccd2a..03c0bbf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,9 +47,8 @@ ci-test = [ "greenlet>=3.3.1", ] docs = [ - "mkdocs-material>=9.5.0", - "mkdocs-llmstxt>=0.3.0", - "mkdocstrings[python]>=0.24.0", + "zensical>=0.0.45", + "mkdocstrings-python>=1.16.0", "pymdown-extensions>=10.7.0", ] release = [ @@ -68,9 +67,8 @@ dev = [ "python-semantic-release>=9.0.0", "alembic>=1.18.1", "sqlalchemy>=2.0.46", - "mkdocs-material>=9.5.0", - "mkdocs-llmstxt>=0.3.0", - "mkdocstrings[python]>=0.24.0", + "zensical>=0.0.45", + "mkdocstrings-python>=1.16.0", "pymdown-extensions>=10.7.0", "pytest-examples>=0.0.18", "psycopg[binary]>=3.3.3", diff --git a/src/ferro/models.py b/src/ferro/models.py index 79bc123..4affc9a 100644 --- a/src/ferro/models.py +++ b/src/ferro/models.py @@ -432,21 +432,30 @@ def where(cls, node: "Predicate[Self]") -> Query[Self]: ... def where(cls, node: "QueryNode | Predicate[Self]") -> Query[Self]: """Start a fluent query with an initial condition. - Accepts either a :class:`QueryNode` (built with operator syntax or - with :func:`ferro.query.col`) or a lambda predicate of shape - ``Callable[[QueryProxy[Self]], QueryNode]``. See + The recommended style is a lambda predicate of shape + ``Callable[[QueryProxy[Self]], QueryNode]``, e.g. + ``User.where(lambda t: t.age >= 18)``. The lambda receives a + :class:`QueryProxy` whose attributes build comparisons as + :class:`QueryNode` instances, so predicates type-check cleanly. + A prebuilt :class:`QueryNode` is also accepted, built either with + :func:`ferro.query.col` (the type-safe escape hatch that preserves + operator shape) or with operator syntax on class attributes. The + bare operator form (``User.where(User.age >= 18)``) is planned for + deprecation in a future release and does not type-check statically: + the class attribute types as the field type, so the comparison + resolves to ``bool``, not ``QueryNode``. See ``docs/concepts/query-typing.md`` for the trade-offs between the three styles. Args: - node: A ``QueryNode`` or a predicate callable. + node: A predicate callable or a ``QueryNode``. Returns: A query object scoped to this model class. Examples: - >>> q1 = User.where(User.id == 1) - >>> q2 = User.where(lambda t: t.archived == False) # noqa: E712 + >>> q1 = User.where(lambda t: t.archived == False) # noqa: E712 + >>> q2 = User.where(User.id == 1) >>> isinstance(q1, Query) and isinstance(q2, Query) True """ diff --git a/src/ferro/query/builder.py b/src/ferro/query/builder.py index 7a83b8b..3243e47 100644 --- a/src/ferro/query/builder.py +++ b/src/ferro/query/builder.py @@ -110,15 +110,21 @@ def where(self, node: "Predicate[T]") -> "Query[T]": ... def where(self, node: "QueryNode | Predicate[T]") -> "Query[T]": """Add a filter condition to the query. - Accepts either a :class:`QueryNode` (built directly with operator - syntax or with :func:`ferro.query.col`) or a lambda predicate of - shape ``Callable[[QueryProxy[T]], QueryNode]``. The lambda receives - a fresh :class:`QueryProxy` whose attributes return + The recommended style is a lambda predicate of shape + ``Callable[[QueryProxy[T]], QueryNode]``. The lambda receives a + fresh :class:`QueryProxy` whose attributes return :class:`FieldProxy` instances, so ``lambda t: t.archived == False`` - builds a comparison without static-typing friction. + builds a comparison without static-typing friction. A prebuilt + :class:`QueryNode` is also accepted, built either with + :func:`ferro.query.col` (the type-safe escape hatch that preserves + operator shape) or with operator syntax on class attributes. The + bare operator form (``User.where(User.age >= 18)``) is planned for + deprecation in a future release and does not type-check statically: + the class attribute types as the field type, so the comparison + resolves to ``bool``, not ``QueryNode``. Args: - node: A ``QueryNode`` or a predicate callable. + node: A predicate callable or a ``QueryNode``. Returns: The current Query instance for chaining. @@ -128,8 +134,8 @@ def where(self, node: "QueryNode | Predicate[T]") -> "Query[T]": or if the callable does not return a ``QueryNode``. Examples: - >>> q1 = User.where(User.id == 1) - >>> q2 = User.where(lambda t: t.archived == False) # noqa: E712 + >>> q1 = User.where(lambda t: t.archived == False) # noqa: E712 + >>> q2 = User.where(User.id == 1) >>> isinstance(q1, Query) and isinstance(q2, Query) True """ @@ -204,7 +210,7 @@ async def all(self) -> list[T]: A list of model instances. Examples: - >>> users = await User.where(User.active == True).all() + >>> users = await User.where(lambda t: t.active == True).all() # noqa: E712 >>> isinstance(users, list) True """ @@ -232,7 +238,7 @@ async def count(self) -> int: The count of matching records. Examples: - >>> total = await User.where(User.active == True).count() + >>> total = await User.where(lambda t: t.active == True).count() # noqa: E712 >>> isinstance(total, int) True """ @@ -256,7 +262,7 @@ async def update(self, **fields) -> int: The number of records updated. Examples: - >>> updated = await User.where(User.id == 1).update(name="Taylor") + >>> updated = await User.where(lambda t: t.id == 1).update(name="Taylor") >>> isinstance(updated, int) True """ @@ -304,7 +310,7 @@ async def delete(self) -> int: The number of records deleted. Examples: - >>> deleted = await User.where(User.disabled == True).delete() + >>> deleted = await User.where(lambda t: t.disabled == True).delete() # noqa: E712 >>> isinstance(deleted, int) True """ @@ -326,7 +332,7 @@ async def exists(self) -> bool: True if records exist, otherwise False. Examples: - >>> found = await User.where(User.email == "a@b.com").exists() + >>> found = await User.where(lambda t: t.email == "a@b.com").exists() >>> isinstance(found, bool) True """ diff --git a/tests/test_docs_examples.py b/tests/test_docs_examples.py index 4abf6a5..dd9e7e4 100644 --- a/tests/test_docs_examples.py +++ b/tests/test_docs_examples.py @@ -1,18 +1,29 @@ from __future__ import annotations import ast +import subprocess +import sys from pathlib import Path import pytest from pytest_examples import CodeExample, EvalExample, find_examples -DOCS_ROOT = Path(__file__).resolve().parents[1] / "docs" +REPO_ROOT = Path(__file__).resolve().parents[1] +DOCS_PAGES = REPO_ROOT / "docs" / "pages" +DOCS_EXAMPLES = REPO_ROOT / "docs" / "examples" -@pytest.skip( - reason="Issue parsing architecture.md for some reason.", allow_module_level=True -) -@pytest.mark.parametrize("example", find_examples(str(DOCS_ROOT)), ids=str) +def _inline_examples() -> list[CodeExample]: + # Snippet directives (--8<--) are expanded by the docs build, not by + # pytest-examples, so blocks containing them are not valid Python here. + return [ + example + for example in find_examples(str(DOCS_PAGES)) + if "--8<--" not in example.source + ] + + +@pytest.mark.parametrize("example", _inline_examples(), ids=str) def test_docs_examples(example: CodeExample, eval_example: EvalExample) -> None: """Validate docs snippets, with opt-in linting/execution.""" # Baseline for all snippets: parse + compile as valid Python syntax. @@ -45,3 +56,26 @@ def test_docs_examples(example: CodeExample, eval_example: EvalExample) -> None: if settings.get("test") == "run": eval_example.run_print_check(example) eval_example.run(example) + + +@pytest.mark.parametrize( + "script", + sorted(DOCS_EXAMPLES.glob("*.py")), + ids=lambda p: p.name, +) +def test_docs_example_scripts(script: Path) -> None: + """Every docs example script must run end to end. + + Each script runs in a subprocess so model registries and engine state + never leak between examples (or into other tests). + """ + result = subprocess.run( + [sys.executable, str(script)], + capture_output=True, + text=True, + timeout=120, + cwd=REPO_ROOT, + ) + assert result.returncode == 0, ( + f"{script.name} failed\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) diff --git a/uv.lock b/uv.lock index 0bf8e3c..d7a3946 100644 --- a/uv.lock +++ b/uv.lock @@ -160,20 +160,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, ] -[[package]] -name = "backrefs" -version = "6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/e3/bb3a439d5cb255c4774724810ad8073830fac9c9dee123555820c1bcc806/backrefs-6.1.tar.gz", hash = "sha256:3bba1749aafe1db9b915f00e0dd166cba613b6f788ffd63060ac3485dc9be231", size = 7011962, upload-time = "2025-11-15T14:52:08.323Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ee/c216d52f58ea75b5e1841022bbae24438b19834a29b163cb32aa3a2a7c6e/backrefs-6.1-py310-none-any.whl", hash = "sha256:2a2ccb96302337ce61ee4717ceacfbf26ba4efb1d55af86564b8bbaeda39cac1", size = 381059, upload-time = "2025-11-15T14:51:59.758Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9a/8da246d988ded941da96c7ed945d63e94a445637eaad985a0ed88787cb89/backrefs-6.1-py311-none-any.whl", hash = "sha256:e82bba3875ee4430f4de4b6db19429a27275d95a5f3773c57e9e18abc23fd2b7", size = 392854, upload-time = "2025-11-15T14:52:01.194Z" }, - { url = "https://files.pythonhosted.org/packages/37/c9/fd117a6f9300c62bbc33bc337fd2b3c6bfe28b6e9701de336b52d7a797ad/backrefs-6.1-py312-none-any.whl", hash = "sha256:c64698c8d2269343d88947c0735cb4b78745bd3ba590e10313fbf3f78c34da5a", size = 398770, upload-time = "2025-11-15T14:52:02.584Z" }, - { url = "https://files.pythonhosted.org/packages/eb/95/7118e935b0b0bd3f94dfec2d852fd4e4f4f9757bdb49850519acd245cd3a/backrefs-6.1-py313-none-any.whl", hash = "sha256:4c9d3dc1e2e558965202c012304f33d4e0e477e1c103663fd2c3cc9bb18b0d05", size = 400726, upload-time = "2025-11-15T14:52:04.093Z" }, - { url = "https://files.pythonhosted.org/packages/1d/72/6296bad135bfafd3254ae3648cd152980a424bd6fed64a101af00cc7ba31/backrefs-6.1-py314-none-any.whl", hash = "sha256:13eafbc9ccd5222e9c1f0bec563e6d2a6d21514962f11e7fc79872fd56cbc853", size = 412584, upload-time = "2025-11-15T14:52:05.233Z" }, - { url = "https://files.pythonhosted.org/packages/02/e3/a4fa1946722c4c7b063cc25043a12d9ce9b4323777f89643be74cef2993c/backrefs-6.1-py39-none-any.whl", hash = "sha256:a9e99b8a4867852cad177a6430e31b0f6e495d65f8c6c134b68c14c3c95bf4b0", size = 381058, upload-time = "2025-11-15T14:52:06.698Z" }, -] - [[package]] name = "beautifulsoup4" version = "4.14.3" @@ -487,6 +473,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] +[[package]] +name = "deepmerge" +version = "2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/3a/b0ba594708f1ad0bc735884b3ad854d3ca3bdc1d741e56e40bbda6263499/deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20", size = 19890, upload-time = "2024-08-30T05:31:50.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" }, +] + [[package]] name = "defusedxml" version = "0.7.1" @@ -546,7 +541,7 @@ wheels = [ [[package]] name = "ferro-orm" -version = "0.10.5" +version = "0.11.0" source = { editable = "." } dependencies = [ { name = "pydantic" }, @@ -580,9 +575,7 @@ dev = [ { name = "greenlet" }, { name = "jupyter" }, { name = "maturin" }, - { name = "mkdocs-llmstxt" }, - { name = "mkdocs-material" }, - { name = "mkdocstrings", extra = ["python"] }, + { name = "mkdocstrings-python" }, { name = "prek" }, { name = "psycopg", extra = ["binary"] }, { name = "pymdown-extensions" }, @@ -595,12 +588,12 @@ dev = [ { name = "python-semantic-release" }, { name = "rich" }, { name = "sqlalchemy" }, + { name = "zensical" }, ] docs = [ - { name = "mkdocs-llmstxt" }, - { name = "mkdocs-material" }, - { name = "mkdocstrings", extra = ["python"] }, + { name = "mkdocstrings-python" }, { name = "pymdown-extensions" }, + { name = "zensical" }, ] release = [ { name = "maturin" }, @@ -635,9 +628,7 @@ dev = [ { name = "greenlet", specifier = ">=3.3.1" }, { name = "jupyter", specifier = ">=1.1.1" }, { name = "maturin", specifier = ">=1.11.5" }, - { name = "mkdocs-llmstxt", specifier = ">=0.3.0" }, - { name = "mkdocs-material", specifier = ">=9.5.0" }, - { name = "mkdocstrings", extras = ["python"], specifier = ">=0.24.0" }, + { name = "mkdocstrings-python", specifier = ">=1.16.0" }, { name = "prek", specifier = ">=0.2.25" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3.3" }, { name = "pymdown-extensions", specifier = ">=10.7.0" }, @@ -650,12 +641,12 @@ dev = [ { name = "python-semantic-release", specifier = ">=9.0.0" }, { name = "rich", specifier = ">=14.2.0" }, { name = "sqlalchemy", specifier = ">=2.0.46" }, + { name = "zensical", specifier = ">=0.0.45" }, ] docs = [ - { name = "mkdocs-llmstxt", specifier = ">=0.3.0" }, - { name = "mkdocs-material", specifier = ">=9.5.0" }, - { name = "mkdocstrings", extras = ["python"], specifier = ">=0.24.0" }, + { name = "mkdocstrings-python", specifier = ">=1.16.0" }, { name = "pymdown-extensions", specifier = ">=10.7.0" }, + { name = "zensical", specifier = ">=0.0.45" }, ] release = [ { name = "maturin", specifier = ">=1.11.5" }, @@ -1224,19 +1215,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, ] -[[package]] -name = "markdownify" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beautifulsoup4" }, - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09", size = 18816, upload-time = "2025-11-16T19:21:18.565Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/ce/f1e3e9d959db134cedf06825fae8d5b294bd368aacdd0831a3975b7c4d55/markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a", size = 15724, upload-time = "2025-11-16T19:21:17.622Z" }, -] - [[package]] name = "markupsafe" version = "3.0.3" @@ -1322,31 +1300,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/67/c94f8f5440bc42d54113a2d99de0d6107f06b5a33f31823e52b2715d856f/maturin-1.11.5-py3-none-win_arm64.whl", hash = "sha256:9348f7f0a346108e0c96e6719be91da4470bd43c15802435e9f4157f5cca43d4", size = 7624029, upload-time = "2026-01-09T11:06:08.728Z" }, ] -[[package]] -name = "mdformat" -version = "0.7.22" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fc/eb/b5cbf2484411af039a3d4aeb53a5160fae25dd8c84af6a4243bc2f3fedb3/mdformat-0.7.22.tar.gz", hash = "sha256:eef84fa8f233d3162734683c2a8a6222227a229b9206872e6139658d99acb1ea", size = 34610, upload-time = "2025-01-30T18:00:51.418Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/6f/94a7344f6d634fe3563bea8b33bccedee37f2726f7807e9a58440dc91627/mdformat-0.7.22-py3-none-any.whl", hash = "sha256:61122637c9e1d9be1329054f3fa216559f0d1f722b7919b060a8c2a4ae1850e5", size = 34447, upload-time = "2025-01-30T18:00:48.708Z" }, -] - -[[package]] -name = "mdformat-tables" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdformat" }, - { name = "wcwidth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/64/fc/995ba209096bdebdeb8893d507c7b32b7e07d9a9f2cdc2ec07529947794b/mdformat_tables-1.0.0.tar.gz", hash = "sha256:a57db1ac17c4a125da794ef45539904bb8a9592e80557d525e1f169c96daa2c8", size = 6106, upload-time = "2024-08-23T23:41:33.413Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/37/d78e37d14323da3f607cd1af7daf262cb87fe614a245c15ad03bb03a2706/mdformat_tables-1.0.0-py3-none-any.whl", hash = "sha256:94cd86126141b2adc3b04c08d1441eb1272b36c39146bab078249a41c7240a9a", size = 5104, upload-time = "2024-08-23T23:41:31.863Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -1438,52 +1391,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, ] -[[package]] -name = "mkdocs-llmstxt" -version = "0.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beautifulsoup4" }, - { name = "markdownify" }, - { name = "mdformat" }, - { name = "mdformat-tables" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7f/f5/4c31cdffa7c09bf48d8c7a50d8342dc100abac98ac4150826bc11afc0c9f/mkdocs_llmstxt-0.5.0.tar.gz", hash = "sha256:b2fa9e6d68df41d7467e948a4745725b6c99434a36b36204857dbd7bb3dfe041", size = 33909, upload-time = "2025-11-20T14:02:24.861Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/2b/82928cc9e8d9269cd79e7ebf015efdc4945e6c646e86ec1d4dba1707f215/mkdocs_llmstxt-0.5.0-py3-none-any.whl", hash = "sha256:753c699913d2d619a9072604b26b6dc9f5fb6d257d9b107857f80c8a0b787533", size = 12040, upload-time = "2025-11-20T14:02:23.483Z" }, -] - -[[package]] -name = "mkdocs-material" -version = "9.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "babel" }, - { name = "backrefs" }, - { name = "colorama" }, - { name = "jinja2" }, - { name = "markdown" }, - { name = "mkdocs" }, - { name = "mkdocs-material-extensions" }, - { name = "paginate" }, - { name = "pygments" }, - { name = "pymdown-extensions" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/27/e2/2ffc356cd72f1473d07c7719d82a8f2cbd261666828614ecb95b12169f41/mkdocs_material-9.7.1.tar.gz", hash = "sha256:89601b8f2c3e6c6ee0a918cc3566cb201d40bf37c3cd3c2067e26fadb8cce2b8", size = 4094392, upload-time = "2025-12-18T09:49:00.308Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/32/ed071cb721aca8c227718cffcf7bd539620e9799bbf2619e90c757bfd030/mkdocs_material-9.7.1-py3-none-any.whl", hash = "sha256:3f6100937d7d731f87f1e3e3b021c97f7239666b9ba1151ab476cabb96c60d5c", size = 9297166, upload-time = "2025-12-18T09:48:56.664Z" }, -] - -[[package]] -name = "mkdocs-material-extensions" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, -] - [[package]] name = "mkdocstrings" version = "1.0.2" @@ -1501,11 +1408,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/32/407a9a5fdd7d8ecb4af8d830b9bcdf47ea68f916869b3f44bac31f081250/mkdocstrings-1.0.2-py3-none-any.whl", hash = "sha256:41897815a8026c3634fe5d51472c3a569f92ded0ad8c7a640550873eea3b6817", size = 35443, upload-time = "2026-01-24T15:57:23.933Z" }, ] -[package.optional-dependencies] -python = [ - { name = "mkdocstrings-python" }, -] - [[package]] name = "mkdocstrings-python" version = "2.0.1" @@ -1630,15 +1532,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] -[[package]] -name = "paginate" -version = "0.5.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, -] - [[package]] name = "pandocfilters" version = "1.5.1" @@ -1921,24 +1814,24 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pymdown-extensions" -version = "10.20.1" +version = "10.21.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1e/6c/9e370934bfa30e889d12e61d0dae009991294f40055c238980066a7fbd83/pymdown_extensions-10.20.1.tar.gz", hash = "sha256:e7e39c865727338d434b55f1dd8da51febcffcaebd6e1a0b9c836243f660740a", size = 852860, upload-time = "2026-01-24T05:56:56.758Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/6d/b6ee155462a0156b94312bdd82d2b92ea56e909740045a87ccb98bf52405/pymdown_extensions-10.20.1-py3-none-any.whl", hash = "sha256:24af7feacbca56504b313b7b418c4f5e1317bb5fea60f03d57be7fcc40912aa0", size = 268768, upload-time = "2026-01-24T05:56:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, ] [[package]] @@ -2538,6 +2431,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + [[package]] name = "tomlkit" version = "0.13.3" @@ -2745,3 +2674,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/99/8a06b8e17dddbf321325ae4eb12465804120f699cd1b8a355718300c62da/wrapt-2.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:35cdbd478607036fee40273be8ed54a451f5f23121bd9d4be515158f9498f7ad", size = 60634, upload-time = "2025-11-07T00:45:02.087Z" }, { url = "https://files.pythonhosted.org/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca", size = 44046, upload-time = "2025-11-07T00:45:32.116Z" }, ] + +[[package]] +name = "zensical" +version = "0.0.45" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "deepmerge" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "pyyaml" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/d1/ecb1889fd2208b2d577e6ff952d9bee201302eec7966b5b61cc64adfd8f5/zensical-0.0.45.tar.gz", hash = "sha256:315bce4ab0470338dd3588add38fb325f840856c375722e6802bd58a06446266", size = 3935947, upload-time = "2026-06-09T11:23:32.349Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/fd/6b84115e3bbe6b76ebb1265e8ff2161c0bc88dcd6499eaf29c61a66421e9/zensical-0.0.45-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c4cb2e11132f02ae824e246e016e073458e12e9de1eaf86fd39f01890d41204c", size = 12698844, upload-time = "2026-06-09T11:22:56.537Z" }, + { url = "https://files.pythonhosted.org/packages/e2/dc/4ddf05d77c1455c32cb26da71f2a19d355927a45a3db5b26fb258a07ce8f/zensical-0.0.45-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:799a01de2102b5f731744ad31bdbc464d0c07d484e67ba148f6923679afa6ce6", size = 12571590, upload-time = "2026-06-09T11:23:00.192Z" }, + { url = "https://files.pythonhosted.org/packages/4c/53/60c6cc7b2ce8b1a83eb87bff3f7289447995552fd9a30ca76ffba22ca9d5/zensical-0.0.45-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6201e79ea8a64bd3ced3f05ef4b1529da0e675d67b1395987c0ba942e4e10dc4", size = 12939590, upload-time = "2026-06-09T11:23:02.721Z" }, + { url = "https://files.pythonhosted.org/packages/9f/1e/e9217ed75dba323a6f9a4eee28eb40416eff99932cd0ee6c394bf07b9ead/zensical-0.0.45-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:854aaf500e4a3ce64adea1faa7a1820c7cf9a4f66be1043e4e9ba727fe9cf2b5", size = 12911669, upload-time = "2026-06-09T11:23:05.407Z" }, + { url = "https://files.pythonhosted.org/packages/71/3c/6fc9fe2334bb4460a8a8d732e23a30d2ddc2ecf63c2eb3487d9e7405e70d/zensical-0.0.45-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a80c57fd50fc60415914388286ac10a7d8b6f70b8ca7235597d09fb12c3171b0", size = 13267643, upload-time = "2026-06-09T11:23:07.915Z" }, + { url = "https://files.pythonhosted.org/packages/be/f9/5696114af4ede5f1bd01e641a4ff24ee8ca49810bfaa28e5be12d930c0ef/zensical-0.0.45-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58c3510f69e08b6ed8bb9596fc9393e4687f90394aa0ef2d6118b1375ad97be5", size = 12972147, upload-time = "2026-06-09T11:23:12.069Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9e/5c6acde480c43f8c993b13260925df8db31d51ab8a9977618e9efdd98d45/zensical-0.0.45-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:01c484bb2ee85e98e21e24b397ff52ffc31101f7485935eee5d3afa6cca6cc08", size = 13117360, upload-time = "2026-06-09T11:23:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/3d/31/ea21f102049b35a8fe5218c5331857a15eeb60deb1bb21823a4c0701e274/zensical-0.0.45-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:3654b708830303759e866a58a60c483cd2a1c56a44acdaae5bbb341a3f40ebce", size = 13185593, upload-time = "2026-06-09T11:23:18.166Z" }, + { url = "https://files.pythonhosted.org/packages/b4/97/6ded39fe27fa8a292d17d9af713b018e4919315233b60fa4b4b0aca737a6/zensical-0.0.45-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:c4da1c37eca1474b487def0ef40d7ac2aff31a9d7a029cb7479ef7c354437361", size = 13326882, upload-time = "2026-06-09T11:23:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/79/80/075975032a9e20f319c0134f8ca659d295ee4908f15ab212702a2728247f/zensical-0.0.45-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f8a1966c186feebd3b795f9d420000bfd582e16eefdd9bc7a286d878faabae52", size = 13253961, upload-time = "2026-06-09T11:23:23.99Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6a/0eab0eb311af6a07cde15ca58d5d720cbfa02cd509e4c7fb5fa20cda0b46/zensical-0.0.45-cp310-abi3-win32.whl", hash = "sha256:a1dd63a5efb8d0e5f2fadf862f02771a279dc5cbe9a982700194650065758f01", size = 12257083, upload-time = "2026-06-09T11:23:26.769Z" }, + { url = "https://files.pythonhosted.org/packages/1f/cd/b117e749c60b1d1e16b8450db1355f69f38376f783b8c6c8815202988933/zensical-0.0.45-cp310-abi3-win_amd64.whl", hash = "sha256:1f2c0e69839ce4274bde34d18139d3b0d96bbf02b245ada46243590c9eedebc1", size = 12498335, upload-time = "2026-06-09T11:23:29.702Z" }, +] diff --git a/zensical.toml b/zensical.toml new file mode 100644 index 0000000..08e4d10 --- /dev/null +++ b/zensical.toml @@ -0,0 +1,144 @@ +[project] +site_name = "Ferro ORM" +site_description = "A high-performance, Rust-backed, Pydantic-driven async ORM for Python." +site_url = "https://syn54x.github.io/ferro-orm/" +repo_url = "https://github.com/syn54x/ferro-orm" +repo_name = "syn54x/ferro-orm" +edit_uri = "edit/main/docs/pages/" +copyright = "© 2026 Ferro ORM contributors" +docs_dir = "docs/pages" +site_dir = "site" +watch = ["src", "docs/examples", "README.md", "CHANGELOG.md", "CONTRIBUTING.md"] +extra_css = ["stylesheets/extra.css"] + +nav = [ + { "Home" = "index.md" }, + { "Why Ferro" = "why-ferro.md" }, + { "Getting Started" = [ + { "Installation" = "getting-started/installation.md" }, + { "Quickstart Tutorial" = "getting-started/quickstart.md" }, + { "Next Steps" = "getting-started/next-steps.md" }, + ] }, + { "Guide" = [ + { "Models & Fields" = "guide/models-and-fields.md" }, + { "Queries" = "guide/queries.md" }, + { "Mutations" = "guide/mutations.md" }, + { "Relationships" = "guide/relationships.md" }, + { "Transactions" = "guide/transactions.md" }, + { "Raw SQL" = "guide/raw-sql.md" }, + { "Connections & Databases" = "guide/connections.md" }, + { "Schema Migrations" = "guide/migrations.md" }, + ] }, + { "How-To" = [ + { "Testing" = "howto/testing.md" }, + { "Pagination" = "howto/pagination.md" }, + { "Timestamps" = "howto/timestamps.md" }, + { "Soft Deletes" = "howto/soft-deletes.md" }, + { "Multiple Databases" = "howto/multiple-databases.md" }, + { "Migrating from SQLAlchemy" = "howto/migrate-from-sqlalchemy.md" }, + ] }, + { "Concepts" = [ + { "Architecture" = "concepts/architecture.md" }, + { "Identity Map" = "concepts/identity-map.md" }, + { "Type Safety" = "concepts/type-safety.md" }, + { "Typed Query Predicates" = "concepts/query-typing.md" }, + { "Database Backends" = "concepts/backends.md" }, + { "Performance" = "concepts/performance.md" }, + ] }, + { "API Reference" = [ + { "Model" = "api/model.md" }, + { "Fields & Types" = "api/fields.md" }, + { "Queries" = "api/queries.md" }, + { "Relationships" = "api/relationships.md" }, + { "Transactions" = "api/transactions.md" }, + { "Raw SQL" = "api/raw-sql.md" }, + { "Connection & Registry" = "api/connection.md" }, + { "Migrations" = "api/migrations.md" }, + { "Exceptions" = "api/exceptions.md" }, + ] }, + { "Community" = [ + { "FAQ" = "faq.md" }, + { "Roadmap" = "roadmap.md" }, + { "Contributing" = "contributing.md" }, + { "Changelog" = "changelog.md" }, + ] }, +] + +[project.theme] +features = [ + "navigation.tabs", + "navigation.sections", + "navigation.expand", + "navigation.top", + "toc.follow", + "search.suggest", + "search.highlight", + "content.code.copy", + "content.code.annotate", + "content.action.edit", + "content.action.view", +] + +[[project.theme.palette]] +media = "(prefers-color-scheme: light)" +scheme = "light-rust" +primary = "custom" +accent = "amber" +toggle.icon = "lucide/sun" +toggle.name = "Switch to dark mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: dark)" +scheme = "slate" +primary = "custom" +accent = "amber" +toggle.icon = "lucide/moon" +toggle.name = "Switch to light mode" + +[project.plugins.mkdocstrings.handlers.python] +paths = ["src"] +inventories = ["https://docs.python.org/3/objects.inv"] + +[project.plugins.mkdocstrings.handlers.python.options] +docstring_style = "google" +show_source = true +show_root_heading = true +show_root_full_path = false +show_category_heading = true +show_if_no_docstring = true +members_order = "source" + +[project.markdown_extensions.attr_list] +[project.markdown_extensions.md_in_html] +[project.markdown_extensions.admonition] +[project.markdown_extensions.tables] + +[project.markdown_extensions.toc] +permalink = true + +[project.markdown_extensions.pymdownx.details] +[project.markdown_extensions.pymdownx.inlinehilite] +[project.markdown_extensions.pymdownx.keys] +[project.markdown_extensions.pymdownx.mark] +[project.markdown_extensions.pymdownx.smartsymbols] + +[project.markdown_extensions.pymdownx.superfences] +custom_fences = [ + { name = "mermaid", class = "mermaid", format = "pymdownx.superfences.fence_code_format" }, +] + +[project.markdown_extensions.pymdownx.snippets] +base_path = ["."] +check_paths = true + +[project.markdown_extensions.pymdownx.highlight] +anchor_linenums = true +line_spans = "__span" +pygments_lang_class = true + +[project.markdown_extensions.pymdownx.tabbed] +alternate_style = true + +[project.markdown_extensions.pymdownx.emoji] +emoji_index = "zensical.extensions.emoji.twemoji" +emoji_generator = "zensical.extensions.emoji.to_svg"