ferro-orm 0.5.0__tar.gz → 0.6.0__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.5.0 → ferro_orm-0.6.0}/CHANGELOG.md +9 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/Cargo.lock +8 -3
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/Cargo.toml +3 -3
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/PKG-INFO +1 -1
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/api/raw-sql.md +13 -6
- ferro_orm-0.6.0/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +218 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/coming-soon.md +18 -27
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/faq.md +11 -1
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/guide/backend.md +8 -5
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/guide/database.md +63 -10
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/guide/queries.md +10 -2
- ferro_orm-0.6.0/docs/howto/multiple-databases.md +70 -0
- ferro_orm-0.6.0/docs/plans/2026-04-29-001-typed-null-binds-plan.md +984 -0
- ferro_orm-0.6.0/docs/plans/2026-04-29-002-feat-named-connections-plan.md +750 -0
- ferro_orm-0.6.0/docs/solutions/patterns/typed-null-binds.md +171 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/justfile +3 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/pyproject.toml +2 -1
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/backend.rs +99 -2
- ferro_orm-0.6.0/src/connection.rs +375 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/__init__.py +40 -4
- ferro_orm-0.6.0/src/ferro/_core.pyi +121 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/models.py +155 -31
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/query/builder.py +28 -19
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/raw.py +23 -7
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/relations/descriptors.py +21 -4
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/state.py +4 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/lib.rs +2 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/operations.rs +839 -179
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/query.rs +262 -30
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/schema.rs +6 -14
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/state.rs +100 -7
- ferro_orm-0.6.0/tests/test_connection.py +522 -0
- ferro_orm-0.6.0/tests/test_connection_redaction.py +38 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_cross_emitter_parity.py +8 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_documentation_features.py +12 -2
- ferro_orm-0.6.0/tests/test_named_connections_integration.py +78 -0
- ferro_orm-0.6.0/tests/test_transactions.py +384 -0
- ferro_orm-0.6.0/tests/test_typed_null_binds.py +343 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/uv.lock +25 -1
- ferro_orm-0.5.0/docs/howto/multiple-databases.md +0 -74
- ferro_orm-0.5.0/src/connection.rs +0 -176
- ferro_orm-0.5.0/src/ferro/_core.pyi +0 -66
- ferro_orm-0.5.0/tests/test_connection.py +0 -37
- ferro_orm-0.5.0/tests/test_transactions.py +0 -161
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/agent-native.json +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/api-contract.json +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/correctness.json +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/data-migrations.json +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/kieran-python.json +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/maintainability.json +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/project-standards.json +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/testing.json +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.github/PERMISSIONS.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.github/PYPI_CHECKLIST.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.github/PYPI_SETUP.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.github/generated/wheels.generated.yml +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.github/pull_request_template.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.github/workflows/ci.yml +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.github/workflows/packaging-smoke.yml +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.github/workflows/publish-docs.yml +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.github/workflows/publish.yml +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.github/workflows/release.yml +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.gitignore +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.pre-commit-config.yaml +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.python-version +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/AGENTS.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/CONTRIBUTING.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/LICENSE +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/README.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/TEST_RESULTS.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/api/fields.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/api/model.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/api/query.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/api/relationships.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/api/transactions.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/api/utilities.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/changelog.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/concepts/architecture.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/concepts/identity-map.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/concepts/performance.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/concepts/type-safety.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/contributing.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/getting-started/installation.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/getting-started/next-steps.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/getting-started/tutorial.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/guide/migrations.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/guide/models-and-fields.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/guide/mutations.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/guide/relationships.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/guide/transactions.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/howto/pagination.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/howto/soft-deletes.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/howto/testing.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/howto/timestamps.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/index.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/migration-sqlalchemy.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/solutions/README.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/solutions/patterns/foreign-key-index.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/stylesheets/extra.css +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/why-ferro.md +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/mkdocs.yml +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/scripts/demo_queries.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/_annotation_utils.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/_shadow_fk_types.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/base.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/composite_indexes.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/composite_uniques.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/fields.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/metaclass.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/migrations/__init__.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/migrations/alembic.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/py.typed +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/query/__init__.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/query/nodes.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/relations/__init__.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/schema_metadata.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/__init__.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/conftest.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/db_backends.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_aggregation.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_alembic_autogenerate.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_alembic_bridge.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_alembic_nullability.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_alembic_type_mapping.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_auto_migrate.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_bulk_update.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_composite_index.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_composite_unique.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_constraints.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_crud.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_db_backends.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_deletion.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_docs_examples.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_field_wrapper.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_helpers.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_hydration.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_metaclass_internals.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_metadata.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_models.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_one_to_one.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_query_builder.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_raw_sql.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_refresh.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_relationship_engine.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_schema.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_schema_constraints.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_schema_enum_annotations.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_shadow_fk_types.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_static_contracts.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_string_search.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_structural_types.py +0 -0
- {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_temporal_types.py +0 -0
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.6.0 (2026-04-30)
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
- Support typed binds and named database routing
|
|
9
|
+
([#45](https://github.com/syn54x/ferro-orm/pull/45),
|
|
10
|
+
[`e3fc930`](https://github.com/syn54x/ferro-orm/commit/e3fc9300178dce7ba763b744b92acf0385b9e90e))
|
|
11
|
+
|
|
12
|
+
|
|
4
13
|
## v0.5.0 (2026-04-28)
|
|
5
14
|
|
|
6
15
|
### Bug Fixes
|
|
@@ -294,7 +294,7 @@ dependencies = [
|
|
|
294
294
|
|
|
295
295
|
[[package]]
|
|
296
296
|
name = "ferro"
|
|
297
|
-
version = "0.
|
|
297
|
+
version = "0.6.0"
|
|
298
298
|
dependencies = [
|
|
299
299
|
"dashmap",
|
|
300
300
|
"once_cell",
|
|
@@ -1179,9 +1179,9 @@ dependencies = [
|
|
|
1179
1179
|
|
|
1180
1180
|
[[package]]
|
|
1181
1181
|
name = "rustls"
|
|
1182
|
-
version = "0.23.
|
|
1182
|
+
version = "0.23.40"
|
|
1183
1183
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1184
|
-
checksum = "
|
|
1184
|
+
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
|
1185
1185
|
dependencies = [
|
|
1186
1186
|
"once_cell",
|
|
1187
1187
|
"ring",
|
|
@@ -1237,6 +1237,7 @@ checksum = "8a5d1c518eaf5eda38e5773f902b26ab6d5e9e9e2bb2349ca6c64cf96f80448c"
|
|
|
1237
1237
|
dependencies = [
|
|
1238
1238
|
"inherent",
|
|
1239
1239
|
"sea-query-derive",
|
|
1240
|
+
"uuid",
|
|
1240
1241
|
]
|
|
1241
1242
|
|
|
1242
1243
|
[[package]]
|
|
@@ -1452,6 +1453,7 @@ dependencies = [
|
|
|
1452
1453
|
"tokio-stream",
|
|
1453
1454
|
"tracing",
|
|
1454
1455
|
"url",
|
|
1456
|
+
"uuid",
|
|
1455
1457
|
"webpki-roots 0.26.11",
|
|
1456
1458
|
]
|
|
1457
1459
|
|
|
@@ -1532,6 +1534,7 @@ dependencies = [
|
|
|
1532
1534
|
"stringprep",
|
|
1533
1535
|
"thiserror",
|
|
1534
1536
|
"tracing",
|
|
1537
|
+
"uuid",
|
|
1535
1538
|
"whoami",
|
|
1536
1539
|
]
|
|
1537
1540
|
|
|
@@ -1569,6 +1572,7 @@ dependencies = [
|
|
|
1569
1572
|
"stringprep",
|
|
1570
1573
|
"thiserror",
|
|
1571
1574
|
"tracing",
|
|
1575
|
+
"uuid",
|
|
1572
1576
|
"whoami",
|
|
1573
1577
|
]
|
|
1574
1578
|
|
|
@@ -1594,6 +1598,7 @@ dependencies = [
|
|
|
1594
1598
|
"thiserror",
|
|
1595
1599
|
"tracing",
|
|
1596
1600
|
"url",
|
|
1601
|
+
"uuid",
|
|
1597
1602
|
]
|
|
1598
1603
|
|
|
1599
1604
|
[[package]]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "ferro"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.6.0"
|
|
4
4
|
edition = "2024"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
|
|
@@ -27,8 +27,8 @@ serde = { version = "1.0", features = ["derive"] }
|
|
|
27
27
|
serde_json = "1.0"
|
|
28
28
|
once_cell = "1.21"
|
|
29
29
|
dashmap = "6.1"
|
|
30
|
-
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "postgres", "any", "tls-rustls-ring-webpki"]}
|
|
31
|
-
sea-query = "0.32"
|
|
30
|
+
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "postgres", "any", "tls-rustls-ring-webpki", "uuid"]}
|
|
31
|
+
sea-query = { version = "0.32", features = ["with-uuid"] }
|
|
32
32
|
tokio = { version = "1.49", features = ["full"] }
|
|
33
33
|
pyo3-async-runtimes = { version = "0.27", features = ["tokio-runtime"] }
|
|
34
34
|
uuid = { version = "1.11", features = ["v4"] }
|
|
@@ -36,20 +36,26 @@ The `tx` handle owns the transaction's connection. You cannot misuse it —
|
|
|
36
36
|
calling `tx.execute(...)` after the `async with` block exits raises
|
|
37
37
|
`RuntimeError`.
|
|
38
38
|
|
|
39
|
-
### Top-level (
|
|
39
|
+
### Top-level (`using` or active transaction)
|
|
40
40
|
|
|
41
41
|
```python
|
|
42
42
|
from ferro import execute, fetch_all, fetch_one
|
|
43
43
|
|
|
44
|
-
# Outside any tx — runs on
|
|
44
|
+
# Outside any tx — runs on the default connection.
|
|
45
45
|
await execute("select pg_advisory_unlock_all()")
|
|
46
46
|
|
|
47
|
+
# Route explicitly to a named connection.
|
|
48
|
+
await execute("select run_pipeline_job($1)", job_id, using="service")
|
|
49
|
+
|
|
47
50
|
# Inside a tx — auto-picked up via the same ContextVar that Model.create() uses.
|
|
48
|
-
async with transaction():
|
|
51
|
+
async with transaction(using="service"):
|
|
49
52
|
await execute("select set_config('request.jwt.claims', $1, true)", claims_json)
|
|
50
53
|
rows = await fetch_all("select * from foo where org_id = $1", org_id)
|
|
51
54
|
```
|
|
52
55
|
|
|
56
|
+
Passing `using=...` inside an active transaction raises. A transaction is pinned to
|
|
57
|
+
one connection, and unqualified raw SQL inherits that connection.
|
|
58
|
+
|
|
53
59
|
## Placeholders are native to the backend
|
|
54
60
|
|
|
55
61
|
| Backend | Placeholder syntax | Example |
|
|
@@ -96,9 +102,10 @@ the `sql` argument — use placeholders and pass values as positional args.
|
|
|
96
102
|
## Connection affinity
|
|
97
103
|
|
|
98
104
|
Outside a `transaction()` block, each top-level `execute` / `fetch_all` /
|
|
99
|
-
`fetch_one` call
|
|
100
|
-
|
|
101
|
-
|
|
105
|
+
`fetch_one` call runs on the selected named pool (`using=...`) or the default
|
|
106
|
+
pool. Consecutive calls may use different physical connections from that pool.
|
|
107
|
+
Wrap in `transaction(using=...)` for connection-affinity-sensitive operations
|
|
108
|
+
like `SET LOCAL`, advisory locks, or `LISTEN/NOTIFY`.
|
|
102
109
|
|
|
103
110
|
## What raw SQL doesn't do
|
|
104
111
|
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
---
|
|
2
|
+
|
|
3
|
+
## date: 2026-04-29
|
|
4
|
+
|
|
5
|
+
topic: named-connections-role-routing
|
|
6
|
+
|
|
7
|
+
# Named Connections and Role-Safe Routing
|
|
8
|
+
|
|
9
|
+
## Problem Frame
|
|
10
|
+
|
|
11
|
+
Ferro currently presents a single active database engine to Python callers. That keeps the API simple, but it blocks users who need to use the same database through different Postgres roles, such as a Supabase application role for user-facing data access and a service or pipeline role for trusted internal work.
|
|
12
|
+
|
|
13
|
+
The feature should let users register multiple named connections and choose the intended connection at the operation or transaction boundary. The default experience should stay ergonomic for single-database apps, while multi-role apps get explicit routing, role-safe transaction behavior, separate pool settings, and clear guardrails against accidental privilege mixing.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Actors
|
|
18
|
+
|
|
19
|
+
- A1. Application developer: Configures Ferro connections and writes model/query code.
|
|
20
|
+
- A2. User-facing application runtime: Handles normal product requests through the least-privileged app connection.
|
|
21
|
+
- A3. Internal service or pipeline runtime: Runs trusted background work through a separate elevated connection.
|
|
22
|
+
- A4. Migration or setup process: Creates or updates schema using an explicitly chosen connection.
|
|
23
|
+
- A5. Downstream implementation agent: Plans and builds the feature without inventing public API semantics.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Key Flows
|
|
28
|
+
|
|
29
|
+
- F1. Single-connection app startup
|
|
30
|
+
- **Trigger:** A developer uses Ferro as they do today with one database URL.
|
|
31
|
+
- **Actors:** A1, A2
|
|
32
|
+
- **Steps:** The app calls `ferro.connect(url)`. Ferro registers that connection as `"default"` and makes it the default connection. Existing unqualified model, query, transaction, and raw SQL calls continue to work.
|
|
33
|
+
- **Outcome:** Existing apps do not need to learn named routing unless they add more connections.
|
|
34
|
+
- **Covered by:** R1, R2, R5, R14
|
|
35
|
+
- F2. Multi-role Supabase startup
|
|
36
|
+
- **Trigger:** A developer needs separate app and service-role database access.
|
|
37
|
+
- **Actors:** A1, A2, A3
|
|
38
|
+
- **Steps:** The app registers an app connection with `default=True` and a service connection with its own name and pool settings. Normal model calls use the app connection. Pipeline code opts into the service connection explicitly.
|
|
39
|
+
- **Outcome:** Both roles can coexist in one process without reconnecting global state or sharing a pool.
|
|
40
|
+
- **Covered by:** R1, R2, R3, R4, R6, R12
|
|
41
|
+
- F3. Service transaction with inherited routing
|
|
42
|
+
- **Trigger:** A pipeline needs a unit of work to run through the service connection.
|
|
43
|
+
- **Actors:** A3
|
|
44
|
+
- **Steps:** Pipeline code enters `async with ferro.transaction(using="service")`. Unqualified model and raw SQL operations inside the block inherit the transaction connection. Any attempt to route part of the transaction to another connection fails clearly.
|
|
45
|
+
- **Outcome:** The transaction is ergonomic and cannot silently become a cross-connection pseudo-transaction.
|
|
46
|
+
- **Covered by:** R7, R8, R9, R10, R11
|
|
47
|
+
- F4. Schema setup on an explicit connection
|
|
48
|
+
- **Trigger:** A developer wants Ferro to create tables or run setup against a specific role.
|
|
49
|
+
- **Actors:** A1, A4
|
|
50
|
+
- **Steps:** The developer chooses the connection when enabling `auto_migrate` or calling schema creation APIs. Ferro does not assume that the default app connection should have migration privileges.
|
|
51
|
+
- **Outcome:** Schema writes are deliberate and can be restricted to a migration-capable connection.
|
|
52
|
+
- **Covered by:** R13, R15
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Requirements
|
|
57
|
+
|
|
58
|
+
**Connection registration and defaults**
|
|
59
|
+
|
|
60
|
+
- R1. Ferro must support registering more than one active connection in a process, each identified by a stable string name.
|
|
61
|
+
- R2. Calling `ferro.connect(url)` without a name must remain valid and must register the connection as `"default"`.
|
|
62
|
+
- R3. `ferro.connect(url, name="...", default=True)` must make that named connection the default for unqualified operations.
|
|
63
|
+
- R4. Ferro must provide a way to change the default connection after registration, such as `ferro.set_default_connection("app")`.
|
|
64
|
+
- R5. If more than one connection exists and no default has been selected, unqualified operations must fail with a clear error instead of guessing.
|
|
65
|
+
|
|
66
|
+
**Pool configuration**
|
|
67
|
+
|
|
68
|
+
- R6. Pool configuration must belong to the named connection, because app, service, pipeline, replica, and test connections can have different concurrency and lifetime needs.
|
|
69
|
+
- R7. Ferro should expose pool configuration as a Ferro API object or explicit keyword arguments rather than overloading database URL query parameters.
|
|
70
|
+
- R8. Native database URL settings, such as Postgres TLS parameters, must remain in the connection URL when they are part of the database driver's normal URL contract.
|
|
71
|
+
- R9. The initial pool configuration surface should cover the common operational needs: maximum connections, minimum connections if supported, acquire timeout, idle timeout, max lifetime, and connection health checking if supported by the backend.
|
|
72
|
+
- R10. Pool configuration must be optional; a connection with no explicit pool settings should use conservative Ferro defaults.
|
|
73
|
+
|
|
74
|
+
**Routing and operation ergonomics**
|
|
75
|
+
|
|
76
|
+
- R11. Ferro must support explicit per-operation routing through a named connection, using a concise API such as `Model.using("service")`, query-level `using`, or an equivalent fluent surface.
|
|
77
|
+
- R12. The connection resolution order must be: explicit operation routing first, active transaction connection second, default connection third, and clear error last.
|
|
78
|
+
- R13. Raw SQL APIs must participate in the same routing model as ORM APIs, including transaction inheritance.
|
|
79
|
+
|
|
80
|
+
**Transactions**
|
|
81
|
+
|
|
82
|
+
- R14. `ferro.transaction(using="name")` must bind the transaction to exactly one named connection for its lifetime.
|
|
83
|
+
- R15. Unqualified ORM and raw SQL calls inside a transaction must inherit the transaction's named connection.
|
|
84
|
+
- R16. Explicitly routing an operation to a different connection inside an active transaction must fail clearly unless Ferro later introduces an explicit cross-connection transaction feature.
|
|
85
|
+
- R17. Nested transactions must inherit the parent transaction connection unless the nested call specifies the same connection; specifying a different connection must fail clearly.
|
|
86
|
+
|
|
87
|
+
**Identity map and object safety**
|
|
88
|
+
|
|
89
|
+
- R18. Ferro's identity map must isolate instances by connection name as well as model and primary key, so an object loaded through an elevated role cannot be reused to satisfy an app-role query.
|
|
90
|
+
- R19. Model instances loaded from a named connection should carry enough internal state for later saves or relationship operations to prefer the same connection when no stronger routing context exists.
|
|
91
|
+
|
|
92
|
+
**Schema management and migrations**
|
|
93
|
+
|
|
94
|
+
- R20. `auto_migrate` and explicit schema creation APIs must run against a specific named connection.
|
|
95
|
+
- R21. Documentation must recommend using a migration-capable connection for schema changes rather than assuming the default app connection has DDL privileges.
|
|
96
|
+
- R22. Ferro must not silently run migrations across all registered connections.
|
|
97
|
+
|
|
98
|
+
**Security and Supabase guidance**
|
|
99
|
+
|
|
100
|
+
- R23. Documentation must warn that elevated service credentials must stay server-side and should not be exposed in public clients.
|
|
101
|
+
- R24. Documentation must recommend least-privileged custom Postgres roles where possible, with service-style privileges reserved for trusted internal processes.
|
|
102
|
+
- R25. Ferro must redact connection credentials in logs and user-facing errors.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Acceptance Examples
|
|
107
|
+
|
|
108
|
+
- AE1. **Covers R1, R2, R14.** Given an existing app calls `await ferro.connect(url)`, when it calls `await User.create(...)`, the operation uses the implicitly registered `"default"` connection and does not require `using`.
|
|
109
|
+
- AE2. **Covers R3, R5, R11.** Given `app` and `service` connections are registered and `app` is marked default, when code calls `await User.all()`, it uses `app`; when code calls `await PipelineEvent.using("service").create(...)`, it uses `service`.
|
|
110
|
+
- AE3. **Covers R12, R14, R15.** Given code is inside `async with ferro.transaction(using="service")`, when it calls `await PipelineEvent.create(...)`, the model call uses the service transaction connection without repeating `using="service"`.
|
|
111
|
+
- AE4. **Covers R16, R17.** Given code is inside `async with ferro.transaction(using="service")`, when it attempts `await User.using("app").create(...)`, Ferro raises an error explaining that a transaction cannot switch from `service` to `app`.
|
|
112
|
+
- AE5. **Covers R18.** Given row `User(id=1)` is loaded through `service`, when the same row is later queried through `app`, Ferro must not return the service-loaded Python instance from the identity map.
|
|
113
|
+
- AE6. **Covers R6, R7, R10.** Given the app connection has `max_connections=20` and the service connection has `max_connections=5`, each named connection uses its own pool settings and neither setting affects the other.
|
|
114
|
+
- AE7. **Covers R20, R22.** Given `app` and `service` connections are registered, when `create_tables(using="service")` runs, Ferro creates schema only through `service` and does not run DDL on `app`.
|
|
115
|
+
- AE8. **Covers R23, R25.** Given a Supabase connection fails, the raised error and logs do not reveal passwords, service credentials, or full secret-bearing URLs.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Success Criteria
|
|
120
|
+
|
|
121
|
+
- Existing single-connection Ferro apps continue to work without API changes.
|
|
122
|
+
- A user can run app-role and service-role Supabase/Postgres work in the same process without resetting global engine state.
|
|
123
|
+
- The happy path for a service transaction is concise enough that users do not repeat `using="service"` on every call inside the block.
|
|
124
|
+
- The API makes privilege boundaries visible at connection setup and transaction entry, not hidden in model definitions or global magic.
|
|
125
|
+
- Transaction behavior never implies atomicity across more than one named connection.
|
|
126
|
+
- A downstream planner can implement the feature without deciding the public API precedence rules, default behavior, pool ownership model, or Supabase safety posture from scratch.
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Scope Boundaries
|
|
131
|
+
|
|
132
|
+
- The first version does not need automatic read/write splitting or policy routers like Django's `DATABASE_ROUTERS`.
|
|
133
|
+
- The first version does not need distributed or two-phase transactions across named connections.
|
|
134
|
+
- The first version does not need cross-database relationships or joins.
|
|
135
|
+
- The first version does not need dynamic tenant connection creation beyond the same named registration primitives.
|
|
136
|
+
- The first version does not need per-model static binding, though the API should not preclude it later.
|
|
137
|
+
- Supabase is the motivating Postgres deployment target, but the feature should stay framed as named connection support rather than a Supabase-only capability.
|
|
138
|
+
- The feature should not add support for new database backends beyond Ferro's current supported backend contract.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Key Decisions
|
|
143
|
+
|
|
144
|
+
- Named connections are the core abstraction: They cover same-database different-role access, multiple databases, replicas, and future routing without inventing a Supabase-only concept.
|
|
145
|
+
- Pool settings live on the connection: This matches operational reality and avoids global pool settings that are wrong for either app traffic or pipeline work.
|
|
146
|
+
- A default connection is allowed: It preserves Ferro's ergonomic model and avoids forcing `using` everywhere in normal application code.
|
|
147
|
+
- Transactions create an ambient routing context: This makes service-role units of work concise while keeping all operations on one connection.
|
|
148
|
+
- Explicit cross-connection operations inside a transaction are errors: This prevents accidental pseudo-atomic workflows.
|
|
149
|
+
- Identity-map keys include connection identity: This is necessary for role safety when different roles can see different rows or columns.
|
|
150
|
+
- Migrations are connection-specific: Schema writes should be deliberate and not fan out across registered connections.
|
|
151
|
+
- Credentials must be redacted: Multi-role support increases the chance that elevated credentials pass through Ferro configuration.
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## Proposed UX Shape
|
|
156
|
+
|
|
157
|
+
This section is illustrative product UX, not an implementation prescription.
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
await ferro.connect(
|
|
161
|
+
app_url,
|
|
162
|
+
name="app",
|
|
163
|
+
default=True,
|
|
164
|
+
pool=ferro.PoolConfig(max_connections=20, min_connections=2),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
await ferro.connect(
|
|
168
|
+
service_url,
|
|
169
|
+
name="service",
|
|
170
|
+
pool=ferro.PoolConfig(max_connections=5, acquire_timeout=30),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
await User.create(email="user@example.com") # Uses app.
|
|
174
|
+
|
|
175
|
+
async with ferro.transaction(using="service"):
|
|
176
|
+
await PipelineEvent.create(kind="sync_started") # Uses service.
|
|
177
|
+
await ferro.execute("select set_config('app.pipeline', $1, true)", "sync")
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Connection resolution:
|
|
181
|
+
|
|
182
|
+
```text
|
|
183
|
+
explicit operation using
|
|
184
|
+
-> active transaction connection
|
|
185
|
+
-> default named connection
|
|
186
|
+
-> clear "no connection selected" error
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## Dependencies / Assumptions
|
|
192
|
+
|
|
193
|
+
- Ferro's typed backend work is the right foundation for this feature; named connections should build on explicit engine handles rather than revive URL-string or generic-pool dispatch.
|
|
194
|
+
- Direct Supabase/Postgres connections can represent the needed role boundary through distinct database URLs or credentials.
|
|
195
|
+
- Users who need multiple roles in one process value explicitness over fully automatic routing.
|
|
196
|
+
- Keeping routers out of v1 reduces API and testing complexity without blocking the app/service-role use case.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Outstanding Questions
|
|
201
|
+
|
|
202
|
+
### Resolve Before Planning
|
|
203
|
+
|
|
204
|
+
- None.
|
|
205
|
+
|
|
206
|
+
### Deferred to Planning
|
|
207
|
+
|
|
208
|
+
- [Affects R7, R9][Technical] Which pool settings are supported uniformly across SQLite and Postgres, and which need backend-specific validation?
|
|
209
|
+
- [Affects R11, R12][Technical] Should the primary explicit routing API be `Model.using("name")`, query terminal `using="name"` arguments, or both for parity with the current query builder shape?
|
|
210
|
+
- [Affects R19][Technical] How should instance stickiness interact with an active transaction when they disagree?
|
|
211
|
+
- [Affects R20][Technical] Should `auto_migrate=True` remain on `connect()` only, or should schema setup move toward an explicit `create_tables(using="name")` first-class UX?
|
|
212
|
+
- [Affects R26][Needs research] What exact redaction behavior should be shared across Rust logs, Python exceptions, and test diagnostics?
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## Next Steps
|
|
217
|
+
|
|
218
|
+
-> /ce-plan for structured implementation planning.
|
|
@@ -56,28 +56,27 @@ banned_users = await User.where(
|
|
|
56
56
|
|
|
57
57
|
### Raw SQL Queries
|
|
58
58
|
|
|
59
|
-
**Status:**
|
|
59
|
+
**Status:** Implemented for `execute`, `fetch_all`, and `fetch_one`
|
|
60
60
|
|
|
61
61
|
**Documentation References:**
|
|
62
62
|
- `docs/guide/queries.md` (lines 252-266)
|
|
63
63
|
|
|
64
64
|
**Description:**
|
|
65
|
-
Direct raw SQL
|
|
65
|
+
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.
|
|
66
66
|
|
|
67
|
-
**Example
|
|
67
|
+
**Example:**
|
|
68
68
|
```python
|
|
69
|
-
|
|
70
|
-
from ferro import raw_query
|
|
69
|
+
from ferro import execute, fetch_all
|
|
71
70
|
|
|
72
|
-
results = await
|
|
71
|
+
results = await fetch_all(
|
|
73
72
|
"SELECT * FROM users WHERE age > $1 AND status = $2",
|
|
74
73
|
18,
|
|
75
|
-
"active"
|
|
74
|
+
"active",
|
|
75
|
+
using="app",
|
|
76
76
|
)
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
Use the query builder API for all queries.
|
|
79
|
+
Additional raw helpers beyond these functions remain out of scope.
|
|
81
80
|
|
|
82
81
|
---
|
|
83
82
|
|
|
@@ -264,48 +263,40 @@ Use `transaction()` context manager for scoped database operations.
|
|
|
264
263
|
- `docs/guide/database.md` (lines 76-104)
|
|
265
264
|
|
|
266
265
|
**Description:**
|
|
267
|
-
|
|
266
|
+
`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.
|
|
268
267
|
|
|
269
268
|
**Example (Partially Working):**
|
|
270
269
|
```python
|
|
271
|
-
# Support for these parameters is not confirmed
|
|
272
270
|
await ferro.connect(
|
|
273
271
|
"postgresql://user:password@localhost/dbname",
|
|
274
|
-
max_connections=20,
|
|
275
|
-
min_connections=5, # May not work
|
|
276
|
-
connect_timeout=30 # May not work
|
|
272
|
+
pool=ferro.PoolConfig(max_connections=20, min_connections=5),
|
|
277
273
|
)
|
|
278
274
|
```
|
|
279
275
|
|
|
280
|
-
|
|
281
|
-
Use basic connection string without advanced pool parameters.
|
|
276
|
+
For unsupported advanced pool options, use backend defaults.
|
|
282
277
|
|
|
283
278
|
---
|
|
284
279
|
|
|
285
280
|
### Multiple Database Support
|
|
286
281
|
|
|
287
|
-
**Status:**
|
|
282
|
+
**Status:** Implemented for explicit named connections
|
|
288
283
|
|
|
289
284
|
**Documentation References:**
|
|
290
285
|
- `docs/guide/database.md` (lines 123-149)
|
|
291
286
|
- `docs/howto/multiple-databases.md` (entire file)
|
|
292
287
|
|
|
293
288
|
**Description:**
|
|
294
|
-
Connecting to and querying multiple databases with named connections.
|
|
289
|
+
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.
|
|
295
290
|
|
|
296
|
-
**Example
|
|
291
|
+
**Example:**
|
|
297
292
|
```python
|
|
298
|
-
|
|
299
|
-
await ferro.connect("postgresql://localhost/main_db", name="primary")
|
|
293
|
+
await ferro.connect("postgresql://localhost/main_db", name="primary", default=True)
|
|
300
294
|
await ferro.connect("postgresql://localhost/replica_db", name="replica")
|
|
301
295
|
|
|
302
296
|
# Query specific database
|
|
303
297
|
users = await User.using("replica").all()
|
|
304
298
|
```
|
|
305
299
|
|
|
306
|
-
**Workaround:**
|
|
307
|
-
Ferro currently supports only a single database connection per application.
|
|
308
|
-
|
|
309
300
|
---
|
|
310
301
|
|
|
311
302
|
## Transaction Features
|
|
@@ -489,7 +480,7 @@ profile = await user.profile
|
|
|
489
480
|
### Definitely Not Implemented
|
|
490
481
|
1. `ilike()` - case-insensitive LIKE
|
|
491
482
|
2. `not_in_()` - NOT IN operator
|
|
492
|
-
3.
|
|
483
|
+
3. Additional raw SQL helper APIs beyond `execute` / `fetch_all` / `fetch_one`
|
|
493
484
|
4. Eager loading (`prefetch_related`)
|
|
494
485
|
5. Select specific fields (partial model loading)
|
|
495
486
|
6. Aggregation functions (sum, avg, min, max)
|
|
@@ -497,8 +488,8 @@ profile = await user.profile
|
|
|
497
488
|
8. `disconnect()` function
|
|
498
489
|
9. `check_connection()` function
|
|
499
490
|
10. `connection_context()` context manager
|
|
500
|
-
11.
|
|
501
|
-
12.
|
|
491
|
+
11. Additional connection pool parameters
|
|
492
|
+
12. Automatic routing policies for multiple databases
|
|
502
493
|
13. Nested transactions / savepoints
|
|
503
494
|
|
|
504
495
|
### Needs Verification
|
|
@@ -137,7 +137,17 @@ Check your Ferro version's API for raw SQL support. Most versions provide an esc
|
|
|
137
137
|
|
|
138
138
|
### Does Ferro support multiple databases?
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
Yes. Register each pool with a name and route explicitly with `using`:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
await ferro.connect(APP_DATABASE_URL, name="app", default=True)
|
|
144
|
+
await ferro.connect(SERVICE_DATABASE_URL, name="service")
|
|
145
|
+
|
|
146
|
+
users = await User.all() # app/default
|
|
147
|
+
jobs = await Job.using("service").all()
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Ferro does not provide automatic routers, cross-database joins, or distributed transactions in v1.
|
|
141
151
|
|
|
142
152
|
See [How-To: Multiple Databases](howto/multiple-databases.md).
|
|
143
153
|
|
|
@@ -15,7 +15,7 @@ The backend is the runtime database engine behind Ferro's Python API. It owns:
|
|
|
15
15
|
- backend-specific SQL generation choices
|
|
16
16
|
- value binding and hydration rules
|
|
17
17
|
|
|
18
|
-
The backend
|
|
18
|
+
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"`.
|
|
19
19
|
|
|
20
20
|
## Supported Backends
|
|
21
21
|
|
|
@@ -44,19 +44,22 @@ The important implementation detail is that URL detection happens once during `c
|
|
|
44
44
|
1. Splits Ferro-only query parameters from the database URL.
|
|
45
45
|
2. Classifies the backend from the URL scheme.
|
|
46
46
|
3. Creates a typed SQLx pool for that backend.
|
|
47
|
-
4.
|
|
47
|
+
4. Registers an `Arc<EngineHandle>` under a connection name and optionally selects it as the default.
|
|
48
48
|
|
|
49
|
-
SQLite uses `SqlitePoolOptions` and PostgreSQL uses `PgPoolOptions`.
|
|
49
|
+
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.
|
|
50
50
|
|
|
51
51
|
```text
|
|
52
|
-
connect(url, auto_migrate)
|
|
52
|
+
connect(url, name, default, auto_migrate, pool)
|
|
53
53
|
-> split ferro_search_path
|
|
54
54
|
-> BackendKind::from_url(url)
|
|
55
55
|
-> connect typed pool
|
|
56
56
|
-> optionally create tables
|
|
57
|
-
-> store EngineHandle
|
|
57
|
+
-> store EngineHandle in the named registry
|
|
58
|
+
-> optionally update the default connection
|
|
58
59
|
```
|
|
59
60
|
|
|
61
|
+
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.
|
|
62
|
+
|
|
60
63
|
### PostgreSQL Search Paths
|
|
61
64
|
|
|
62
65
|
Ferro supports a private `ferro_search_path` URL parameter for test isolation:
|
|
@@ -85,6 +85,46 @@ If you assemble the URI yourself, percent-encode reserved characters in the pass
|
|
|
85
85
|
|
|
86
86
|
## Connection Options
|
|
87
87
|
|
|
88
|
+
### Named Connections
|
|
89
|
+
|
|
90
|
+
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.
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
import os
|
|
94
|
+
import ferro
|
|
95
|
+
|
|
96
|
+
await ferro.connect(
|
|
97
|
+
os.environ["APP_DATABASE_URL"],
|
|
98
|
+
name="app",
|
|
99
|
+
default=True,
|
|
100
|
+
pool=ferro.PoolConfig(max_connections=10, min_connections=1),
|
|
101
|
+
)
|
|
102
|
+
await ferro.connect(
|
|
103
|
+
os.environ["SERVICE_DATABASE_URL"],
|
|
104
|
+
name="service",
|
|
105
|
+
pool=ferro.PoolConfig(max_connections=3),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Default app role
|
|
109
|
+
users = await User.all()
|
|
110
|
+
|
|
111
|
+
# Explicit service role
|
|
112
|
+
job = await Job.using("service").create(kind="backfill")
|
|
113
|
+
await ferro.execute("select run_internal_job(?)", job.id, using="service")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
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.
|
|
117
|
+
|
|
118
|
+
### Transaction Inheritance
|
|
119
|
+
|
|
120
|
+
Transactions are bound to one connection. Operations inside the block inherit that connection; trying to switch to another connection inside the transaction raises.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
async with ferro.transaction(using="service"):
|
|
124
|
+
await Job.create(kind="backfill") # runs on service
|
|
125
|
+
await ferro.execute("select set_config('role_context', ?, true)", "pipeline")
|
|
126
|
+
```
|
|
127
|
+
|
|
88
128
|
### Auto-Migration (Development)
|
|
89
129
|
|
|
90
130
|
During development, automatically align the database schema with your models:
|
|
@@ -110,14 +150,31 @@ async def main():
|
|
|
110
150
|
# Import models to register them
|
|
111
151
|
from myapp.models import User, Post, Comment
|
|
112
152
|
|
|
113
|
-
# Create all tables
|
|
153
|
+
# Create all tables on the default connection
|
|
114
154
|
await ferro.create_tables()
|
|
115
155
|
```
|
|
116
156
|
|
|
117
157
|
## Multiple Databases
|
|
118
158
|
|
|
119
|
-
|
|
120
|
-
|
|
159
|
+
Use named connections for multiple databases, roles, or pools:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
await ferro.connect(os.environ["APP_DATABASE_URL"], name="app", default=True)
|
|
163
|
+
await ferro.connect(os.environ["SERVICE_DATABASE_URL"], name="service")
|
|
164
|
+
|
|
165
|
+
await ferro.create_tables(using="service")
|
|
166
|
+
service_users = await User.using("service").all()
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
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.
|
|
170
|
+
|
|
171
|
+
### Supabase Role Guidance
|
|
172
|
+
|
|
173
|
+
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.
|
|
174
|
+
|
|
175
|
+
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=...)`.
|
|
176
|
+
|
|
177
|
+
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.
|
|
121
178
|
|
|
122
179
|
## Health Checks
|
|
123
180
|
|
|
@@ -196,16 +253,12 @@ async def on_shutdown():
|
|
|
196
253
|
!!! note "disconnect() Not Available"
|
|
197
254
|
The `disconnect()` function is not yet implemented. Connection cleanup happens automatically on process exit. See [Coming Soon](../coming-soon.md#disconnect) for more information.
|
|
198
255
|
|
|
199
|
-
### Use
|
|
200
|
-
|
|
201
|
-
!!! note
|
|
202
|
-
Advanced pool configuration such as `max_connections`, `min_connections`, and `connect_timeout` is not exposed by Ferro's current Python API. See [Coming Soon](../coming-soon.md#connection-pool-configuration).
|
|
256
|
+
### Use Long-Lived Pools
|
|
203
257
|
|
|
204
|
-
For web applications, connect once at startup and reuse
|
|
258
|
+
For web applications, connect once at startup and reuse those pools:
|
|
205
259
|
|
|
206
260
|
```python
|
|
207
|
-
|
|
208
|
-
await ferro.connect("postgresql://localhost/proddb")
|
|
261
|
+
await ferro.connect("postgresql://localhost/proddb", name="app", default=True)
|
|
209
262
|
```
|
|
210
263
|
|
|
211
264
|
### Separate Dev/Prod Configs
|
|
@@ -251,8 +251,16 @@ smith_users = await User.where(User.name.like("%Smith%")).all()
|
|
|
251
251
|
|
|
252
252
|
## Raw SQL
|
|
253
253
|
|
|
254
|
-
|
|
255
|
-
|
|
254
|
+
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:
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
from ferro import execute, fetch_all
|
|
258
|
+
|
|
259
|
+
await execute("select run_pipeline_job($1)", job_id, using="service")
|
|
260
|
+
rows = await fetch_all("select id, name from users where org_id = $1", org_id)
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Inside `transaction(using="service")`, raw SQL inherits the transaction connection. See [Raw SQL](../api/raw-sql.md) for bind-type details and caveats.
|
|
256
264
|
|
|
257
265
|
## Performance Tips
|
|
258
266
|
|