ferro-orm 0.3.3__tar.gz → 0.3.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/.gitignore +3 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/CHANGELOG.md +34 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/Cargo.lock +1 -1
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/Cargo.toml +1 -1
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/PKG-INFO +1 -1
- ferro_orm-0.3.4/docs/guide/backend.md +401 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/howto/testing.md +32 -7
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/mkdocs.yml +2 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/pyproject.toml +3 -1
- ferro_orm-0.3.4/src/backend.rs +650 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/connection.rs +67 -19
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/ferro/migrations/alembic.py +10 -30
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/ferro/query/builder.py +10 -4
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/ferro/relations/__init__.py +3 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/ferro/schema_metadata.py +16 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/operations.rs +755 -649
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/query.rs +103 -42
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/schema.rs +175 -160
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/state.rs +10 -23
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/conftest.py +38 -10
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/db_backends.py +50 -2
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_alembic_bridge.py +1 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_alembic_nullability.py +5 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_alembic_type_mapping.py +2 -1
- ferro_orm-0.3.4/tests/test_auto_migrate.py +189 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_db_backends.py +49 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_query_builder.py +74 -1
- ferro_orm-0.3.4/tests/test_schema.py +121 -0
- ferro_orm-0.3.4/tests/test_static_contracts.py +8 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_structural_types.py +70 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/uv.lock +42 -1
- ferro_orm-0.3.3/src/backend.rs +0 -108
- ferro_orm-0.3.3/tests/test_auto_migrate.py +0 -80
- ferro_orm-0.3.3/tests/test_schema.py +0 -36
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/.github/PERMISSIONS.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/.github/PYPI_CHECKLIST.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/.github/PYPI_SETUP.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/.github/generated/wheels.generated.yml +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/.github/pull_request_template.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/.github/workflows/ci.yml +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/.github/workflows/packaging-smoke.yml +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/.github/workflows/publish-docs.yml +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/.github/workflows/publish.yml +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/.github/workflows/release.yml +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/.pre-commit-config.yaml +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/.python-version +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/CONTRIBUTING.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/LICENSE +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/README.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/TEST_RESULTS.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/api/fields.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/api/model.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/api/query.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/api/relationships.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/api/transactions.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/api/utilities.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/changelog.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/coming-soon.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/concepts/architecture.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/concepts/identity-map.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/concepts/performance.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/concepts/type-safety.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/contributing.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/faq.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/getting-started/installation.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/getting-started/next-steps.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/getting-started/tutorial.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/guide/database.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/guide/migrations.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/guide/models-and-fields.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/guide/mutations.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/guide/queries.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/guide/relationships.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/guide/transactions.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/howto/multiple-databases.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/howto/pagination.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/howto/soft-deletes.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/howto/timestamps.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/index.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/migration-sqlalchemy.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/stylesheets/extra.css +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/docs/why-ferro.md +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/justfile +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/scripts/demo_queries.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/ferro/__init__.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/ferro/_annotation_utils.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/ferro/_core.pyi +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/ferro/_shadow_fk_types.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/ferro/base.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/ferro/composite_uniques.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/ferro/fields.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/ferro/metaclass.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/ferro/migrations/__init__.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/ferro/models.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/ferro/py.typed +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/ferro/query/__init__.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/ferro/query/nodes.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/ferro/relations/descriptors.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/ferro/state.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/src/lib.rs +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/__init__.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_aggregation.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_alembic_autogenerate.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_bulk_update.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_composite_unique.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_connection.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_constraints.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_crud.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_deletion.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_docs_examples.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_documentation_features.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_field_wrapper.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_helpers.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_hydration.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_metaclass_internals.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_metadata.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_models.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_one_to_one.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_refresh.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_relationship_engine.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_schema_constraints.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_schema_enum_annotations.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_shadow_fk_types.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_string_search.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_temporal_types.py +0 -0
- {ferro_orm-0.3.3 → ferro_orm-0.3.4}/tests/test_transactions.py +0 -0
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.3.4 (2026-04-25)
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- Serialize UUID M2M query contexts
|
|
9
|
+
([`f53b3ca`](https://github.com/syn54x/ferro-orm/commit/f53b3ca4219d3cd21174d1cb2215bda717c0ac3d))
|
|
10
|
+
|
|
11
|
+
### Chores
|
|
12
|
+
|
|
13
|
+
- Gitignore .worktrees/ for local worktrees
|
|
14
|
+
([`142cd3f`](https://github.com/syn54x/ferro-orm/commit/142cd3fc1240e2e0ce5597b170455e4355ac98b9))
|
|
15
|
+
|
|
16
|
+
- Update lock file
|
|
17
|
+
([`fa1c003`](https://github.com/syn54x/ferro-orm/commit/fa1c003efd3960c4c7a647ddf0f8ba166c731e01))
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
|
|
21
|
+
- Add backend guide
|
|
22
|
+
([`78f1e29`](https://github.com/syn54x/ferro-orm/commit/78f1e295052663416e37ce2bef81be06ec602ba0))
|
|
23
|
+
|
|
24
|
+
### Refactoring
|
|
25
|
+
|
|
26
|
+
- Replace Any backend with typed engine
|
|
27
|
+
([`71628a7`](https://github.com/syn54x/ferro-orm/commit/71628a7281e7f6d8ec6a4640eb2512a7589a634d))
|
|
28
|
+
|
|
29
|
+
### Testing
|
|
30
|
+
|
|
31
|
+
- Add local Postgres test provider
|
|
32
|
+
([`f8601a5`](https://github.com/syn54x/ferro-orm/commit/f8601a54b414baefd5f1078470c60b3ee85782db))
|
|
33
|
+
|
|
34
|
+
- Harden bridge-boundary coverage
|
|
35
|
+
([`f1a6064`](https://github.com/syn54x/ferro-orm/commit/f1a60647a799a17ad8adf75c86e9635dd192cc55))
|
|
36
|
+
|
|
37
|
+
|
|
4
38
|
## v0.3.3 (2026-04-24)
|
|
5
39
|
|
|
6
40
|
### Bug Fixes
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
# Backend Guide
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
This guide starts with the user-facing behavior, then explains the implementation details that maintainers need when changing the backend.
|
|
6
|
+
|
|
7
|
+
## What The Backend Is
|
|
8
|
+
|
|
9
|
+
The backend is the runtime database engine behind Ferro's Python API. It owns:
|
|
10
|
+
|
|
11
|
+
- the active database kind, currently SQLite or PostgreSQL
|
|
12
|
+
- the typed SQLx connection pool
|
|
13
|
+
- SQL execution and row materialization
|
|
14
|
+
- transaction-bound typed connections
|
|
15
|
+
- backend-specific SQL generation choices
|
|
16
|
+
- value binding and hydration rules
|
|
17
|
+
|
|
18
|
+
The backend does not introduce a new public routing API. Ferro still uses one active engine per process. Named databases, replicas, and `using("name")`-style routing are intentionally deferred.
|
|
19
|
+
|
|
20
|
+
## Supported Backends
|
|
21
|
+
|
|
22
|
+
Ferro currently treats these URL schemes as first-class runtime targets:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
await connect("sqlite:app.db?mode=rwc")
|
|
26
|
+
await connect("sqlite::memory:")
|
|
27
|
+
await connect("postgresql://user:password@localhost:5432/app")
|
|
28
|
+
await connect("postgres://user:password@localhost:5432/app")
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Unsupported schemes fail during connection setup:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
await connect("mysql://user:password@localhost/app")
|
|
35
|
+
# raises a connection error: supported schemes are sqlite, postgres, postgresql
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
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.
|
|
39
|
+
|
|
40
|
+
## Connection Lifecycle
|
|
41
|
+
|
|
42
|
+
`ferro.connect()` is the public entry point. Internally, the Rust connection layer does four things:
|
|
43
|
+
|
|
44
|
+
1. Splits Ferro-only query parameters from the database URL.
|
|
45
|
+
2. Classifies the backend from the URL scheme.
|
|
46
|
+
3. Creates a typed SQLx pool for that backend.
|
|
47
|
+
4. Stores an `Arc<EngineHandle>` in global engine state.
|
|
48
|
+
|
|
49
|
+
SQLite uses `SqlitePoolOptions` and PostgreSQL uses `PgPoolOptions`. Both currently use a fixed pool size of 5 connections.
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
connect(url, auto_migrate)
|
|
53
|
+
-> split ferro_search_path
|
|
54
|
+
-> BackendKind::from_url(url)
|
|
55
|
+
-> connect typed pool
|
|
56
|
+
-> optionally create tables
|
|
57
|
+
-> store EngineHandle globally
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### PostgreSQL Search Paths
|
|
61
|
+
|
|
62
|
+
Ferro supports a private `ferro_search_path` URL parameter for test isolation:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
await connect(
|
|
66
|
+
"postgresql://localhost/ferro?ferro_search_path=ferro_test_schema",
|
|
67
|
+
auto_migrate=True,
|
|
68
|
+
)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The parameter is removed before SQLx connects. If present, Ferro installs an `after_connect` hook that runs:
|
|
72
|
+
|
|
73
|
+
```sql
|
|
74
|
+
SET search_path TO ferro_test_schema
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Search path names must be ASCII alphanumeric or `_`. This keeps the test helper ergonomic without allowing arbitrary SQL in the connection URL.
|
|
78
|
+
|
|
79
|
+
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:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
import uuid
|
|
83
|
+
|
|
84
|
+
import psycopg
|
|
85
|
+
from ferro import connect, reset_engine
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def run_isolated_postgres_test(base_url: str):
|
|
89
|
+
schema_name = f"ferro_{uuid.uuid4().hex[:16]}"
|
|
90
|
+
|
|
91
|
+
with psycopg.connect(base_url, autocommit=True) as conn:
|
|
92
|
+
conn.execute(f'CREATE SCHEMA "{schema_name}"')
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
await connect(
|
|
96
|
+
f"{base_url}?ferro_search_path={schema_name}",
|
|
97
|
+
auto_migrate=True,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Test code now reads and writes tables in only this schema.
|
|
101
|
+
# A second test can use the same database with a different schema.
|
|
102
|
+
finally:
|
|
103
|
+
reset_engine()
|
|
104
|
+
with psycopg.connect(base_url, autocommit=True) as conn:
|
|
105
|
+
conn.execute(f'DROP SCHEMA IF EXISTS "{schema_name}" CASCADE')
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
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.
|
|
109
|
+
|
|
110
|
+
## Typed Engine Internals
|
|
111
|
+
|
|
112
|
+
The core backend types live in `src/backend.rs`.
|
|
113
|
+
|
|
114
|
+
```text
|
|
115
|
+
BackendKind
|
|
116
|
+
Sqlite
|
|
117
|
+
Postgres
|
|
118
|
+
|
|
119
|
+
EngineHandle
|
|
120
|
+
backend: BackendKind
|
|
121
|
+
pool: BackendPool
|
|
122
|
+
|
|
123
|
+
BackendPool
|
|
124
|
+
Sqlite(Arc<SqlitePool>)
|
|
125
|
+
Postgres(Arc<PgPool>)
|
|
126
|
+
|
|
127
|
+
EngineConnection
|
|
128
|
+
Sqlite(PoolConnection<Sqlite>)
|
|
129
|
+
Postgres(PoolConnection<Postgres>)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
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:
|
|
133
|
+
|
|
134
|
+
- SQLite connections are executed through SQLx's SQLite driver.
|
|
135
|
+
- PostgreSQL connections are executed through SQLx's PostgreSQL driver.
|
|
136
|
+
- Transaction connections keep the same typed distinction.
|
|
137
|
+
- Backend dispatch is a small enum match at the boundary where SQL actually runs.
|
|
138
|
+
|
|
139
|
+
This gives Ferro access to backend-specific SQLx behavior without making the Python API backend-specific.
|
|
140
|
+
|
|
141
|
+
## Query And Mutation Execution
|
|
142
|
+
|
|
143
|
+
Most ORM operations follow the same high-level pipeline:
|
|
144
|
+
|
|
145
|
+
```text
|
|
146
|
+
Python Query / Model API
|
|
147
|
+
-> JSON query or mutation payload
|
|
148
|
+
-> Rust operation function
|
|
149
|
+
-> SeaQuery statement
|
|
150
|
+
-> backend-specific SQL builder
|
|
151
|
+
-> EngineBindValue list
|
|
152
|
+
-> EngineHandle or EngineConnection execution
|
|
153
|
+
-> EngineRow values
|
|
154
|
+
-> RustValue values
|
|
155
|
+
-> Python model instances
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
SeaQuery remains the SQL construction layer. The backend controls which SeaQuery builder lowers the statement:
|
|
159
|
+
|
|
160
|
+
- SQLite uses `SqliteQueryBuilder`
|
|
161
|
+
- PostgreSQL uses `PostgresQueryBuilder`
|
|
162
|
+
|
|
163
|
+
Bind values are converted into a backend-neutral Ferro enum before execution:
|
|
164
|
+
|
|
165
|
+
```text
|
|
166
|
+
EngineBindValue
|
|
167
|
+
Bool
|
|
168
|
+
I64
|
|
169
|
+
F64
|
|
170
|
+
String
|
|
171
|
+
Bytes
|
|
172
|
+
Null
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
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.
|
|
176
|
+
|
|
177
|
+
### Reads
|
|
178
|
+
|
|
179
|
+
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.
|
|
180
|
+
|
|
181
|
+
This split matters because database values are not the same as Python field values. For example:
|
|
182
|
+
|
|
183
|
+
- a PostgreSQL `integer` may decode as `i32`, but Ferro model IDs use Python `int`
|
|
184
|
+
- PostgreSQL UUIDs are selected as text before becoming Python `uuid.UUID`
|
|
185
|
+
- Decimal values are selected as text before becoming Python `Decimal`
|
|
186
|
+
- JSON values are selected as text before becoming Python dicts or lists
|
|
187
|
+
|
|
188
|
+
### Writes
|
|
189
|
+
|
|
190
|
+
Create, update, relationship, and delete operations build SeaQuery statements and execute them through either:
|
|
191
|
+
|
|
192
|
+
- the active `EngineHandle`, if no transaction is active
|
|
193
|
+
- the transaction's `EngineConnection`, if a transaction ID is present
|
|
194
|
+
|
|
195
|
+
SQLite insert results can report `last_insert_rowid()`. PostgreSQL insert paths rely on explicit `RETURNING` where Ferro needs generated values.
|
|
196
|
+
|
|
197
|
+
## Schema Metadata And DDL
|
|
198
|
+
|
|
199
|
+
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.
|
|
200
|
+
|
|
201
|
+
Important metadata includes:
|
|
202
|
+
|
|
203
|
+
- `primary_key`
|
|
204
|
+
- `autoincrement`
|
|
205
|
+
- `unique`
|
|
206
|
+
- `index`
|
|
207
|
+
- `foreign_key`
|
|
208
|
+
- `ferro_nullable`
|
|
209
|
+
- `format: "decimal"`
|
|
210
|
+
- `enum_type_name`
|
|
211
|
+
|
|
212
|
+
That metadata is shared by:
|
|
213
|
+
|
|
214
|
+
- Rust runtime DDL in `src/schema.rs`
|
|
215
|
+
- Alembic metadata generation in `src/ferro/migrations/alembic.py`
|
|
216
|
+
- query and mutation casting decisions in `src/operations.rs`
|
|
217
|
+
- relationship join-table generation in `src/ferro/relations/__init__.py`
|
|
218
|
+
|
|
219
|
+
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.
|
|
220
|
+
|
|
221
|
+
### Auto-Migration
|
|
222
|
+
|
|
223
|
+
When `auto_migrate=True`, `connect()` creates the typed engine first, then asks Rust to create tables for all registered models.
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
await connect("sqlite:dev.db?mode=rwc", auto_migrate=True)
|
|
227
|
+
await connect("postgresql://localhost/ferro", auto_migrate=True)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Runtime DDL uses the active backend:
|
|
231
|
+
|
|
232
|
+
- SQLite gets SQLite-compatible column definitions and index SQL.
|
|
233
|
+
- PostgreSQL gets PostgreSQL-compatible column definitions, native casts, and SQL syntax.
|
|
234
|
+
|
|
235
|
+
## Type Handling Across SQLite And Postgres
|
|
236
|
+
|
|
237
|
+
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.
|
|
238
|
+
|
|
239
|
+
### Integer Primary Keys
|
|
240
|
+
|
|
241
|
+
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`.
|
|
242
|
+
|
|
243
|
+
### UUID
|
|
244
|
+
|
|
245
|
+
UUIDs are a bridge-boundary type. They can appear as:
|
|
246
|
+
|
|
247
|
+
- Python `uuid.UUID`
|
|
248
|
+
- JSON query payload strings
|
|
249
|
+
- SQL bind values
|
|
250
|
+
- PostgreSQL `uuid` columns
|
|
251
|
+
- SQLite text-like columns
|
|
252
|
+
|
|
253
|
+
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.
|
|
254
|
+
|
|
255
|
+
### Decimal
|
|
256
|
+
|
|
257
|
+
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`.
|
|
258
|
+
|
|
259
|
+
### JSON Objects And Arrays
|
|
260
|
+
|
|
261
|
+
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.
|
|
262
|
+
|
|
263
|
+
### Dates And Datetimes
|
|
264
|
+
|
|
265
|
+
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.
|
|
266
|
+
|
|
267
|
+
### Enums
|
|
268
|
+
|
|
269
|
+
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.
|
|
270
|
+
|
|
271
|
+
## Transactions
|
|
272
|
+
|
|
273
|
+
Transactions use the same typed backend model as normal operations.
|
|
274
|
+
|
|
275
|
+
When a root transaction begins:
|
|
276
|
+
|
|
277
|
+
```text
|
|
278
|
+
active EngineHandle
|
|
279
|
+
-> acquire typed pool connection
|
|
280
|
+
-> BEGIN
|
|
281
|
+
-> TransactionHandle::root(EngineConnection)
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Nested transactions reuse the same typed connection and create savepoints:
|
|
285
|
+
|
|
286
|
+
```text
|
|
287
|
+
parent TransactionConnection
|
|
288
|
+
-> SAVEPOINT sp_<tx_id>
|
|
289
|
+
-> TransactionHandle::nested(parent_conn, savepoint_name)
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
The transaction registry stores a transaction ID mapped to:
|
|
293
|
+
|
|
294
|
+
- a shared `Arc<Mutex<EngineConnection>>`
|
|
295
|
+
- an optional savepoint name
|
|
296
|
+
|
|
297
|
+
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.
|
|
298
|
+
|
|
299
|
+
## Testing The Backend Matrix
|
|
300
|
+
|
|
301
|
+
Backend correctness is tested with the same public API users call. Tests that should run on both databases use the backend matrix fixtures:
|
|
302
|
+
|
|
303
|
+
```python
|
|
304
|
+
@pytest.mark.backend_matrix
|
|
305
|
+
async def test_create_and_fetch(db_url):
|
|
306
|
+
await connect(db_url, auto_migrate=True)
|
|
307
|
+
...
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Run the SQLite default suite:
|
|
311
|
+
|
|
312
|
+
```bash
|
|
313
|
+
uv run pytest -q
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Run the SQLite/PostgreSQL matrix:
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
uv run pytest -m "backend_matrix or postgres_only" --db-backends=sqlite,postgres -q
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Run only the PostgreSQL side:
|
|
323
|
+
|
|
324
|
+
```bash
|
|
325
|
+
uv run pytest -m "backend_matrix or postgres_only" --db-backends=postgres -q
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### Local PostgreSQL Provider
|
|
329
|
+
|
|
330
|
+
The test harness supports local ephemeral PostgreSQL through `pytest-postgresql`.
|
|
331
|
+
|
|
332
|
+
Install PostgreSQL server binaries, then force the local provider:
|
|
333
|
+
|
|
334
|
+
```bash
|
|
335
|
+
brew install postgresql@16
|
|
336
|
+
FERRO_POSTGRES_PROVIDER=local uv run pytest -m "backend_matrix or postgres_only" --db-backends=postgres -q
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
If `FERRO_POSTGRES_PROVIDER=local` is not set, tests prefer an external URL:
|
|
340
|
+
|
|
341
|
+
1. `FERRO_POSTGRES_URL`
|
|
342
|
+
2. legacy `FERRO_SUPABASE_URL`
|
|
343
|
+
3. local `pytest-postgresql` fallback
|
|
344
|
+
|
|
345
|
+
Each PostgreSQL test gets an isolated schema through `ferro_search_path`, so externally managed databases can still run isolated test cases.
|
|
346
|
+
|
|
347
|
+
## How To Extend This Later
|
|
348
|
+
|
|
349
|
+
The current backend design makes a future backend, such as MySQL, more approachable but not automatic. A new backend would need:
|
|
350
|
+
|
|
351
|
+
1. A new `BackendKind` variant.
|
|
352
|
+
2. A typed SQLx pool and connection variant.
|
|
353
|
+
3. URL classification.
|
|
354
|
+
4. SeaQuery builder dispatch.
|
|
355
|
+
5. DDL type mapping in `src/schema.rs`.
|
|
356
|
+
6. bind and row materialization support in `src/backend.rs`.
|
|
357
|
+
7. schema-value casting rules in `src/operations.rs`.
|
|
358
|
+
8. backend-matrix test coverage.
|
|
359
|
+
9. docs that clearly state support level and known differences.
|
|
360
|
+
|
|
361
|
+
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.
|
|
362
|
+
|
|
363
|
+
## Troubleshooting And Gotchas
|
|
364
|
+
|
|
365
|
+
### `Engine not initialized`
|
|
366
|
+
|
|
367
|
+
You called a model or query method before `await connect(...)`. Importing models registers schema, but it does not connect to the database.
|
|
368
|
+
|
|
369
|
+
### Unsupported URL scheme
|
|
370
|
+
|
|
371
|
+
Only `sqlite:`, `postgres://`, and `postgresql://` are supported. MySQL is planned for later, not accepted by this backend.
|
|
372
|
+
|
|
373
|
+
### PostgreSQL tests use the wrong database
|
|
374
|
+
|
|
375
|
+
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`.
|
|
376
|
+
|
|
377
|
+
### Local PostgreSQL tests skip or fail to start
|
|
378
|
+
|
|
379
|
+
`pytest-postgresql` needs server binaries such as `pg_ctl`, `postgres`, and `initdb` on `PATH`. On macOS with Homebrew, installing `postgresql@16` usually provides them.
|
|
380
|
+
|
|
381
|
+
### UUID or Decimal values fail only on PostgreSQL
|
|
382
|
+
|
|
383
|
+
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.
|
|
384
|
+
|
|
385
|
+
### Runtime DDL and Alembic disagree
|
|
386
|
+
|
|
387
|
+
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.
|
|
388
|
+
|
|
389
|
+
## Mental Model
|
|
390
|
+
|
|
391
|
+
The shortest way to understand the backend is:
|
|
392
|
+
|
|
393
|
+
```text
|
|
394
|
+
Python owns the model contract.
|
|
395
|
+
Rust owns execution.
|
|
396
|
+
SeaQuery owns SQL shape.
|
|
397
|
+
SQLx owns typed database I/O.
|
|
398
|
+
BackendKind decides which database-specific path is legal.
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
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.
|
|
@@ -7,7 +7,7 @@ Test your Ferro applications with pytest and test database isolation strategies.
|
|
|
7
7
|
The repository test suite supports two database modes:
|
|
8
8
|
|
|
9
9
|
- **Default SQLite run** for the full fast suite
|
|
10
|
-
- **Dual-backend matrix** for ORM coverage on both SQLite and PostgreSQL
|
|
10
|
+
- **Dual-backend matrix** for ORM coverage on both SQLite and PostgreSQL
|
|
11
11
|
|
|
12
12
|
The matrix is opt-in so day-to-day test runs stay quick and deterministic.
|
|
13
13
|
|
|
@@ -20,13 +20,25 @@ uv sync --group dev
|
|
|
20
20
|
uv run maturin develop
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
For local PostgreSQL matrix runs, install PostgreSQL server binaries so `pytest-postgresql` can start an ephemeral database:
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
|
|
26
|
+
brew install postgresql@16
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
You can also point the suite at an externally managed PostgreSQL database. A root `.env` file works well for local development:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
FERRO_POSTGRES_URL='postgresql://...'
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
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.
|
|
36
|
+
|
|
37
|
+
To force the local `pytest-postgresql` provider even when `.env` contains an external URL:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
FERRO_POSTGRES_PROVIDER=local uv run pytest -m "backend_matrix or postgres_only" --db-backends=postgres -q
|
|
41
|
+
```
|
|
30
42
|
|
|
31
43
|
### Run The Default Suite
|
|
32
44
|
|
|
@@ -56,9 +68,22 @@ The repository uses three database markers:
|
|
|
56
68
|
|
|
57
69
|
- `backend_matrix`: run this test once per selected backend
|
|
58
70
|
- `sqlite_only`: keep SQLite-specific catalog, file-path, or pragma assertions on SQLite
|
|
59
|
-
- `postgres_only`: run Postgres
|
|
71
|
+
- `postgres_only`: run Postgres-specific assertions when either an external Postgres URL is configured or `pytest-postgresql` can start a local server
|
|
72
|
+
|
|
73
|
+
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.
|
|
74
|
+
|
|
75
|
+
### Bridge-Boundary Regressions
|
|
76
|
+
|
|
77
|
+
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 (`ManyToManyField.add()`, `.remove()`, `.clear()`).
|
|
78
|
+
|
|
79
|
+
Use these conventions:
|
|
60
80
|
|
|
61
|
-
|
|
81
|
+
- Put relationship and auto-migration regressions in `tests/test_auto_migrate.py` when they strengthen the backend matrix.
|
|
82
|
+
- Put structural type regressions in `tests/test_structural_types.py` when they involve UUID, Decimal, JSON, enum, binary, date, or datetime behavior.
|
|
83
|
+
- Use `backend_matrix` when the public behavior should work on both SQLite and PostgreSQL.
|
|
84
|
+
- Use `postgres_only` when the assertion depends on native PostgreSQL types, catalogs, or casts.
|
|
85
|
+
- 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.
|
|
86
|
+
- 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.
|
|
62
87
|
|
|
63
88
|
## Basic Setup
|
|
64
89
|
|
|
@@ -83,7 +108,7 @@ async def db_transaction(db):
|
|
|
83
108
|
yield
|
|
84
109
|
```
|
|
85
110
|
|
|
86
|
-
For backend-matrix tests, Ferro's own suite uses `--db-backends=sqlite,postgres` together with `backend_matrix` / `postgres_only` markers
|
|
111
|
+
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.
|
|
87
112
|
|
|
88
113
|
## Test Example
|
|
89
114
|
|
|
@@ -54,6 +54,7 @@ plugins:
|
|
|
54
54
|
- guide/queries.md
|
|
55
55
|
- guide/mutations.md
|
|
56
56
|
- guide/transactions.md
|
|
57
|
+
- guide/backend.md
|
|
57
58
|
- guide/database.md
|
|
58
59
|
- guide/migrations.md
|
|
59
60
|
How-To:
|
|
@@ -130,6 +131,7 @@ nav:
|
|
|
130
131
|
- Queries: guide/queries.md
|
|
131
132
|
- Mutations: guide/mutations.md
|
|
132
133
|
- Transactions: guide/transactions.md
|
|
134
|
+
- Backend: guide/backend.md
|
|
133
135
|
- Database Setup: guide/database.md
|
|
134
136
|
- Schema Management: guide/migrations.md
|
|
135
137
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "ferro-orm"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.4"
|
|
4
4
|
description = "A high-performance, Rust-backed ORM for Python."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -42,6 +42,7 @@ ci-test = [
|
|
|
42
42
|
"pytest-asyncio>=0.23.0",
|
|
43
43
|
"pytest-cov>=7.0.0",
|
|
44
44
|
"pytest-examples>=0.0.18",
|
|
45
|
+
"pytest-postgresql>=8.0.0",
|
|
45
46
|
]
|
|
46
47
|
docs = [
|
|
47
48
|
"mkdocs-material>=9.5.0",
|
|
@@ -71,6 +72,7 @@ dev = [
|
|
|
71
72
|
"pymdown-extensions>=10.7.0",
|
|
72
73
|
"pytest-examples>=0.0.18",
|
|
73
74
|
"psycopg[binary]>=3.3.3",
|
|
75
|
+
"pytest-postgresql>=8.0.0",
|
|
74
76
|
]
|
|
75
77
|
|
|
76
78
|
[tool.pytest.ini_options]
|