ferro-orm 0.7.0__tar.gz → 0.9.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.7.0 → ferro_orm-0.9.0}/.gitignore +1 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/CHANGELOG.md +28 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/Cargo.lock +1 -1
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/Cargo.toml +1 -1
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/PKG-INFO +1 -1
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/TEST_RESULTS.md +1 -1
- ferro_orm-0.9.0/docs/api/exceptions.md +15 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/api/model.md +3 -1
- ferro_orm-0.9.0/docs/api/query.md +97 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/api/utilities.md +4 -1
- ferro_orm-0.9.0/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +95 -0
- ferro_orm-0.9.0/docs/changelog.md +46 -0
- ferro_orm-0.9.0/docs/concepts/query-typing.md +105 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/concepts/type-safety.md +26 -2
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/guide/mutations.md +17 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/guide/queries.md +59 -1
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/howto/multiple-databases.md +4 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/migration-sqlalchemy.md +20 -0
- ferro_orm-0.9.0/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +321 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/mkdocs.yml +3 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/pyproject.toml +1 -1
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/__init__.py +2 -0
- ferro_orm-0.9.0/src/ferro/exceptions.py +17 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/models.py +65 -15
- ferro_orm-0.9.0/src/ferro/query/__init__.py +14 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/query/builder.py +57 -10
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/query/nodes.py +99 -11
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/relations/descriptors.py +2 -2
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_alembic_bridge.py +74 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_connection.py +1 -1
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_crud.py +7 -4
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_deletion.py +2 -2
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_documentation_features.py +1 -2
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_named_connections_integration.py +3 -2
- ferro_orm-0.9.0/tests/test_query_typing.py +285 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_transactions.py +1 -1
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/uv.lock +1 -1
- ferro_orm-0.7.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/agent-native.json +0 -87
- ferro_orm-0.7.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/api-contract.json +0 -40
- ferro_orm-0.7.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/correctness.json +0 -15
- ferro_orm-0.7.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/data-migrations.json +0 -72
- ferro_orm-0.7.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/kieran-python.json +0 -44
- ferro_orm-0.7.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/maintainability.json +0 -79
- ferro_orm-0.7.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/project-standards.json +0 -45
- ferro_orm-0.7.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/testing.json +0 -108
- ferro_orm-0.7.0/docs/api/query.md +0 -24
- ferro_orm-0.7.0/docs/changelog.md +0 -36
- ferro_orm-0.7.0/src/ferro/query/__init__.py +0 -6
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.github/PERMISSIONS.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.github/PYPI_CHECKLIST.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.github/PYPI_SETUP.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.github/generated/wheels.generated.yml +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.github/pull_request_template.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.github/workflows/ci.yml +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.github/workflows/packaging-smoke.yml +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.github/workflows/publish-docs.yml +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.github/workflows/publish.yml +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.github/workflows/release.yml +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.pre-commit-config.yaml +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.python-version +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/AGENTS.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/CONTRIBUTING.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/LICENSE +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/README.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/api/fields.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/api/raw-sql.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/api/relationships.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/api/transactions.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/coming-soon.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/concepts/architecture.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/concepts/identity-map.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/concepts/performance.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/contributing.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/faq.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/getting-started/installation.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/getting-started/next-steps.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/getting-started/tutorial.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/guide/backend.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/guide/database.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/guide/migrations.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/guide/models-and-fields.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/guide/relationships.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/guide/transactions.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/howto/pagination.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/howto/soft-deletes.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/howto/testing.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/howto/timestamps.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/index.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/solutions/README.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/solutions/patterns/foreign-key-index.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/solutions/patterns/typed-null-binds.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/stylesheets/extra.css +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/why-ferro.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/justfile +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/scripts/demo_queries.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/backend.rs +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/connection.rs +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/_annotation_utils.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/_core.pyi +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/_shadow_fk_types.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/base.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/composite_indexes.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/composite_uniques.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/fields.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/metaclass.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/migrations/__init__.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/migrations/alembic.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/py.typed +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/raw.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/relations/__init__.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/schema_metadata.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/state.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/lib.rs +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/operations.rs +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/query.rs +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/schema.rs +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/state.rs +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/__init__.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/conftest.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/db_backends.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_aggregation.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_alembic_autogenerate.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_alembic_nullability.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_alembic_type_mapping.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_auto_migrate.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_bulk_update.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_composite_index.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_composite_unique.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_connection_redaction.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_constraints.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_cross_emitter_parity.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_db_backends.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_docs_examples.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_field_wrapper.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_helpers.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_hydration.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_metaclass_internals.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_metadata.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_models.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_one_to_one.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_query_builder.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_raw_sql.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_refresh.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_relationship_engine.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_schema.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_schema_constraints.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_schema_enum_annotations.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_shadow_fk_types.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_static_contracts.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_string_search.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_structural_types.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_temporal_types.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_typed_null_binds.py +0 -0
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.9.0 (2026-05-09)
|
|
5
|
+
|
|
6
|
+
### Chores
|
|
7
|
+
|
|
8
|
+
- Gitignore .context and untrack committed artifacts
|
|
9
|
+
([#49](https://github.com/syn54x/ferro-orm/pull/49),
|
|
10
|
+
[`cac6330`](https://github.com/syn54x/ferro-orm/commit/cac63304bbcf6bb3fad22037c33e6e60dcb8946e))
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
- Add get_or_none method
|
|
15
|
+
([`0c81e9f`](https://github.com/syn54x/ferro-orm/commit/0c81e9f414074c9a3631eb94f3a8077d16671e41))
|
|
16
|
+
|
|
17
|
+
### Testing
|
|
18
|
+
|
|
19
|
+
- Add tests for explicit shadow fields
|
|
20
|
+
([`78a3471`](https://github.com/syn54x/ferro-orm/commit/78a3471d55afec3b9b7da9f44e497ec521f7d1c0))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
## v0.8.0 (2026-05-09)
|
|
24
|
+
|
|
25
|
+
### Features
|
|
26
|
+
|
|
27
|
+
- **query**: Typed query predicates via col() and lambda
|
|
28
|
+
([#48](https://github.com/syn54x/ferro-orm/pull/48),
|
|
29
|
+
[`e34e3ca`](https://github.com/syn54x/ferro-orm/commit/e34e3ca43adfc851d757d8232d5736fb453db55e))
|
|
30
|
+
|
|
31
|
+
|
|
4
32
|
## v0.7.0 (2026-05-08)
|
|
5
33
|
|
|
6
34
|
### Features
|
|
@@ -36,7 +36,7 @@ All field types and constraints work as documented:
|
|
|
36
36
|
All documented CRUD operations work correctly:
|
|
37
37
|
|
|
38
38
|
- `Model.create()` ✅
|
|
39
|
-
- `Model.get()` ✅
|
|
39
|
+
- `Model.get()` / `Model.get_or_none()` ✅
|
|
40
40
|
- `Model.all()` ✅
|
|
41
41
|
- `instance.save()` ✅
|
|
42
42
|
- `instance.delete()` ✅
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Exceptions
|
|
2
|
+
|
|
3
|
+
Public exception types raised by Ferro’s ORM layer.
|
|
4
|
+
|
|
5
|
+
::: ferro.exceptions.ModelDoesNotExist
|
|
6
|
+
options:
|
|
7
|
+
show_source: false
|
|
8
|
+
heading_level: 2
|
|
9
|
+
show_root_heading: true
|
|
10
|
+
|
|
11
|
+
## See Also
|
|
12
|
+
|
|
13
|
+
- [Queries](../guide/queries.md) — `Model.get` vs `Model.get_or_none`
|
|
14
|
+
- [Model API](model.md)
|
|
15
|
+
- [Mutations](../guide/mutations.md) — error handling patterns
|
|
@@ -8,6 +8,7 @@ Complete reference for the `Model` base class and related methods.
|
|
|
8
8
|
- create
|
|
9
9
|
- bulk_create
|
|
10
10
|
- get
|
|
11
|
+
- get_or_none
|
|
11
12
|
- where
|
|
12
13
|
- select
|
|
13
14
|
- all
|
|
@@ -25,5 +26,6 @@ Complete reference for the `Model` base class and related methods.
|
|
|
25
26
|
## See Also
|
|
26
27
|
|
|
27
28
|
- [Models & Fields Guide](../guide/models-and-fields.md)
|
|
28
|
-
- [Queries Guide](../guide/queries.md)
|
|
29
|
+
- [Queries Guide](../guide/queries.md) — fetch by primary key (`get` / `get_or_none`)
|
|
30
|
+
- [Exceptions](exceptions.md) — `ModelDoesNotExist`
|
|
29
31
|
- [Mutations Guide](../guide/mutations.md)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Query API
|
|
2
|
+
|
|
3
|
+
Complete reference for the Query Builder API.
|
|
4
|
+
|
|
5
|
+
## `Query`
|
|
6
|
+
|
|
7
|
+
`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.
|
|
8
|
+
|
|
9
|
+
::: ferro.query.Query
|
|
10
|
+
options:
|
|
11
|
+
members:
|
|
12
|
+
- where
|
|
13
|
+
- order_by
|
|
14
|
+
- limit
|
|
15
|
+
- offset
|
|
16
|
+
- all
|
|
17
|
+
- first
|
|
18
|
+
- count
|
|
19
|
+
- exists
|
|
20
|
+
- update
|
|
21
|
+
- delete
|
|
22
|
+
show_source: false
|
|
23
|
+
heading_level: 3
|
|
24
|
+
|
|
25
|
+
## `Relation`
|
|
26
|
+
|
|
27
|
+
`Relation` is the lazy collection-relationship subclass of `Query` returned by `BackRef` and `ManyToMany` fields. It accepts the same three predicate styles on `where`.
|
|
28
|
+
|
|
29
|
+
::: ferro.query.Relation
|
|
30
|
+
options:
|
|
31
|
+
members:
|
|
32
|
+
- where
|
|
33
|
+
- order_by
|
|
34
|
+
- limit
|
|
35
|
+
- offset
|
|
36
|
+
- all
|
|
37
|
+
- first
|
|
38
|
+
- add
|
|
39
|
+
- remove
|
|
40
|
+
- clear
|
|
41
|
+
show_source: false
|
|
42
|
+
heading_level: 3
|
|
43
|
+
|
|
44
|
+
## `col`
|
|
45
|
+
|
|
46
|
+
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.
|
|
47
|
+
|
|
48
|
+
::: ferro.query.col
|
|
49
|
+
options:
|
|
50
|
+
show_source: false
|
|
51
|
+
heading_level: 3
|
|
52
|
+
|
|
53
|
+
## `FieldProxy`
|
|
54
|
+
|
|
55
|
+
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`.
|
|
56
|
+
|
|
57
|
+
::: ferro.query.FieldProxy
|
|
58
|
+
options:
|
|
59
|
+
members:
|
|
60
|
+
- in_
|
|
61
|
+
- like
|
|
62
|
+
show_source: false
|
|
63
|
+
heading_level: 3
|
|
64
|
+
|
|
65
|
+
## `QueryProxy`
|
|
66
|
+
|
|
67
|
+
The attribute proxy passed to lambda predicates. Each attribute access returns a fresh `FieldProxy` for the accessed name.
|
|
68
|
+
|
|
69
|
+
::: ferro.query.QueryProxy
|
|
70
|
+
options:
|
|
71
|
+
show_source: false
|
|
72
|
+
heading_level: 3
|
|
73
|
+
|
|
74
|
+
## `QueryNode`
|
|
75
|
+
|
|
76
|
+
The serializable AST node produced by every predicate style. You normally do not construct these directly.
|
|
77
|
+
|
|
78
|
+
::: ferro.query.QueryNode
|
|
79
|
+
options:
|
|
80
|
+
members:
|
|
81
|
+
- to_dict
|
|
82
|
+
show_source: false
|
|
83
|
+
heading_level: 3
|
|
84
|
+
|
|
85
|
+
## `Predicate`
|
|
86
|
+
|
|
87
|
+
Type alias for lambda predicates accepted by `Query.where`, `Relation.where`, and `Model.where`.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
Predicate[TModel] = Callable[[QueryProxy[TModel]], QueryNode]
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## See Also
|
|
94
|
+
|
|
95
|
+
- [Queries Guide](../guide/queries.md)
|
|
96
|
+
- [Typed Query Predicates](../concepts/query-typing.md)
|
|
97
|
+
- [How-To: Pagination](../howto/pagination.md)
|
|
@@ -57,14 +57,17 @@ from ferro import evict_instance
|
|
|
57
57
|
# Evict user with ID=1
|
|
58
58
|
evict_instance("User", 1)
|
|
59
59
|
|
|
60
|
-
# Next fetch will hit database
|
|
60
|
+
# Next fetch will hit database (raises ModelDoesNotExist if row was removed)
|
|
61
61
|
user = await User.get(1)
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
+
If the row may no longer exist, use `User.get_or_none(1)` or handle [`ModelDoesNotExist`](exceptions.md).
|
|
65
|
+
|
|
64
66
|
See [Identity Map Concept](../concepts/identity-map.md) for when and why to evict instances.
|
|
65
67
|
|
|
66
68
|
## See Also
|
|
67
69
|
|
|
68
70
|
- [Database Setup Guide](../guide/database.md) - Connection configuration
|
|
69
71
|
- [Identity Map Concept](../concepts/identity-map.md) - Instance caching details
|
|
72
|
+
- [Exceptions](exceptions.md) - `ModelDoesNotExist` and related types
|
|
70
73
|
- [Schema Management](../guide/migrations.md) - Production migrations
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
---
|
|
2
|
+
date: 2026-05-08
|
|
3
|
+
topic: typed-query-predicates
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Typed Query Predicates
|
|
7
|
+
|
|
8
|
+
## Summary
|
|
9
|
+
|
|
10
|
+
Add two opt-in query-predicate styles to Ferro — a `col()` wrapper and a lambda predicate API — so that `Model.field` comparisons type-check cleanly under Pyright and `ty` without changing user model annotations or breaking the current operator API. A concept doc covers when to reach for each.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Problem Frame
|
|
15
|
+
|
|
16
|
+
Ferro models are Pydantic models, so user fields carry plain-type annotations like `archived: bool` and `transcript_id: int`. At runtime the metaclass replaces each class attribute with a `FieldProxy` whose operator overloads return `QueryNode`, so `User.archived == False` builds a query predicate that `Query.where()` accepts.
|
|
17
|
+
|
|
18
|
+
Static type checkers don't see that runtime swap. Pyright and `ty` resolve `User.archived` from the source annotation as `bool`, so `User.archived == False` is interpreted as `bool.__eq__(bool)` and returns `bool`. `Query.where()` then complains that it expected a `QueryNode`. Users hitting this on every typed predicate currently silence each line with a `# type: ignore` pragma.
|
|
19
|
+
|
|
20
|
+
Other Python ORMs work around this by changing what users annotate (SQLAlchemy 2.x uses `Mapped[T]`), changing the API away from class-attribute predicates (Django and Tortoise use kwargs), or shipping a mypy plugin. None of those fit Ferro: `Mapped[T]`-style breaks the "user types directly" stance and adds Pydantic interop work, and a mypy plugin doesn't help Pyright or `ty` users — Pyright has no public plugin API.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Requirements
|
|
25
|
+
|
|
26
|
+
**`col()` wrapper**
|
|
27
|
+
|
|
28
|
+
- R1. Ferro exposes a `col()` function whose static signature is `(value: T) -> FieldProxy[T]`. At runtime it is identity, with a guard that raises `TypeError` if the input is not a `FieldProxy`.
|
|
29
|
+
- R2. `col(Model.field) == value` (and the other comparison operators) produces a `QueryNode` that `Query.where()` accepts in mypy, Pyright, basedpyright, and `ty` without ignore pragmas.
|
|
30
|
+
- R3. `FieldProxy` becomes generic over its column type (`FieldProxy[T]`), with operator overloads typed to accept `T | FieldProxy[T]` and return `QueryNode`. Runtime behavior is unchanged; users do not write the generic parameter directly.
|
|
31
|
+
|
|
32
|
+
**Lambda predicate API**
|
|
33
|
+
|
|
34
|
+
- R4. `Query.where()` accepts a callable that takes a typed query proxy and returns a `QueryNode`. The proxy's static type is parameterized on the model type; attribute access on the proxy is statically typed as `FieldProxy[Any]`.
|
|
35
|
+
- R5. At runtime, when `where()` receives a callable that is not a `QueryNode`, it constructs a per-call proxy whose `__getattr__` returns `FieldProxy(name)`, invokes the callable with the proxy, and appends the returned node to the query.
|
|
36
|
+
- R6. Lambda predicates support compound expressions via the existing `&` and `|` operators on `QueryNode`.
|
|
37
|
+
|
|
38
|
+
**Backward compatibility**
|
|
39
|
+
|
|
40
|
+
- R7. The existing `Model.field == value` operator path continues to work unchanged at runtime. No existing test, query, or user model needs modification.
|
|
41
|
+
- R8. Adding the lambda overload does not change the runtime semantics of any current `where()` call. Dispatch checks `isinstance(QueryNode)` first, then `callable()`.
|
|
42
|
+
- R9. No changes to the model metaclass, Pydantic schema generation, JSON schema bridge, or the Rust hydration paths.
|
|
43
|
+
|
|
44
|
+
**Documentation**
|
|
45
|
+
|
|
46
|
+
- R10. A concept document under `docs/concepts/` covers the three predicate styles (operator, `col()`, lambda), shows them combined in one query chain, and recommends when to reach for each. Lambda is positioned as the recommended idiom for new code; `col()` is the lower-ceremony fallback for users who want to keep the operator shape.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Acceptance Examples
|
|
51
|
+
|
|
52
|
+
- AE1. **Covers R1, R2.** Given `archived: bool` declared on a model and a strict type checker run, when the user writes `.where(col(T.archived) == False)`, the line type-checks without ignore pragmas in mypy, Pyright, basedpyright, and `ty`.
|
|
53
|
+
- AE2. **Covers R7, R8.** Given an existing test that uses `.where(T.field == value)`, when the new code lands, the test continues to pass with no modification.
|
|
54
|
+
- AE3. **Covers R4, R5, R6.** Given the predicate `lambda t: (t.archived == False) & (t.org_id == 42)`, when passed to `.where(...)`, the runtime invokes the lambda with a model proxy, appends a single compound `QueryNode` to the query, and produces the same SQL as the equivalent operator-form predicate.
|
|
55
|
+
- AE4. **Covers R1.** Given a value that is not a `FieldProxy` (e.g., a raw string), when passed to `col()` at runtime, `col()` raises `TypeError` whose message names the actual argument type.
|
|
56
|
+
- AE5. **Covers R2, R7, R4.** Given a single `Query` chain that combines `.where(T.a == 1)`, `.where(col(T.b) == 2)`, and `.where(lambda t: t.c == 3)` in any order, when executed, the resulting SQL contains all three predicates and the result hydrates correctly.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Success Criteria
|
|
61
|
+
|
|
62
|
+
- Ferro users hitting the original `ty` complaint can fix it by wrapping the attribute reference with `col(...)` — a single small change per call site, with no model edits and no plugin installation.
|
|
63
|
+
- New Ferro code in user projects can adopt the lambda predicate style and have those predicates type-check cleanly in every supported checker.
|
|
64
|
+
- Downstream agents (planning, future API work) and human reviewers have a clear definition of which `where()` shapes are supported and which were deliberately deferred.
|
|
65
|
+
- Backward-compat tests in `tests/` pass without modification, demonstrating the operator-path runtime is unchanged.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Scope Boundaries
|
|
70
|
+
|
|
71
|
+
- `Mapped[T]` / descriptor-based field annotations are deferred indefinitely; they conflict with Ferro's "user types directly" stance and would require Pydantic interop work disproportionate to the typing win.
|
|
72
|
+
- A mypy plugin for Ferro is out of scope; Pyright and `ty` are the target checkers and neither has an equivalent plugin API.
|
|
73
|
+
- Per-field type precision in the lambda proxy via `@dataclass_transform` is deferred; this work uses `FieldProxy[Any]` for proxy attribute types.
|
|
74
|
+
- A kwargs-style filter API (e.g., `.where_kw(field=value)`) is deferred until user demand surfaces.
|
|
75
|
+
- A `t""` template-string predicate API is deferred until Ferro raises its minimum Python to 3.14.
|
|
76
|
+
- Per-model code generation, distributed `.pyi` stubs for user models, and `if TYPE_CHECKING` shadow declarations as a documented pattern are not adopted.
|
|
77
|
+
- No changes to the metaclass, Pydantic interop, JSON schema generation, or the Rust FFI bridge.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Key Decisions
|
|
82
|
+
|
|
83
|
+
- **Single PR over staged PRs.** The three pieces (`col()`, lambda API, doc) ship together on `feat/col-and-lambda-queries`. They are small, low-risk, and meaningfully more useful as a unit than incrementally.
|
|
84
|
+
- **`col()` is identity at runtime, not a constructor.** `Model.field` is already a `FieldProxy` thanks to the metaclass, so `col()` is a typed pass-through, not a wrapping layer. This keeps the runtime cost zero and avoids a parallel class hierarchy.
|
|
85
|
+
- **Lambda predicate proxy is per-call, not stored on the model.** Avoids touching the metaclass and keeps the lambda path opt-in. Users who never call `where(lambda ...)` never construct the proxy.
|
|
86
|
+
- **`FieldProxy[Any]` over per-field precision for the lambda proxy.** Per-field types would require `@dataclass_transform` plumbing and tighter user-facing typing semantics. Deferred to a follow-up if real demand materializes.
|
|
87
|
+
- **Lambda is the recommended idiom; `col()` is the fallback.** Lambda has lower per-call ceremony for chains with several predicates; `col()` is one wrapper per reference but doesn't ask users to rewrite their query shape.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Dependencies / Assumptions
|
|
92
|
+
|
|
93
|
+
- The current metaclass behavior of replacing each `model_fields` attribute with a `FieldProxy` is a stable invariant the runtime no-op `col()` relies on.
|
|
94
|
+
- `QueryNode` does not currently expose `__call__`; the runtime dispatch rule "isinstance(QueryNode) first, then callable()" depends on this remaining true.
|
|
95
|
+
- Generic parameters on `FieldProxy[T]` are runtime-erased (standard Python typing semantics); existing `isinstance(x, FieldProxy)` checks remain valid.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to Ferro ORM will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Breaking
|
|
11
|
+
|
|
12
|
+
- `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.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- `Model.get_or_none` and `Model.using(...).get_or_none` for primary-key lookup without raising.
|
|
17
|
+
- `ModelDoesNotExist` (`LookupError` subclass with `.model` and `.pk`), exported from `ferro`. Documented under [Exceptions](api/exceptions.md).
|
|
18
|
+
- 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).
|
|
19
|
+
- `FieldProxy` is now generic (`FieldProxy[T]`); operator overloads are typed `T | FieldProxy[T] -> QueryNode`, `.like()` is gated to `FieldProxy[str]`.
|
|
20
|
+
- New public symbols re-exported from `ferro.query`: `col`, `QueryProxy`, `Predicate`.
|
|
21
|
+
- Comprehensive documentation restructure
|
|
22
|
+
- Tutorial for new users
|
|
23
|
+
- How-to guides for common patterns
|
|
24
|
+
- Concept pages explaining architecture
|
|
25
|
+
|
|
26
|
+
## Release History
|
|
27
|
+
|
|
28
|
+
For the complete release history, see [GitHub Releases](https://github.com/syn54x/ferro-orm/releases).
|
|
29
|
+
|
|
30
|
+
### Version Format
|
|
31
|
+
|
|
32
|
+
- **Major** (X.0.0): Breaking changes
|
|
33
|
+
- **Minor** (0.X.0): New features, backwards compatible
|
|
34
|
+
- **Patch** (0.0.X): Bug fixes
|
|
35
|
+
|
|
36
|
+
### Upgrade Guide
|
|
37
|
+
|
|
38
|
+
When upgrading between major versions, see the migration guide in the release notes.
|
|
39
|
+
|
|
40
|
+
## Reporting Issues
|
|
41
|
+
|
|
42
|
+
Found a bug? [Report it on GitHub](https://github.com/syn54x/ferro-orm/issues).
|
|
43
|
+
|
|
44
|
+
## Contributing
|
|
45
|
+
|
|
46
|
+
See [Contributing Guide](contributing.md) for how to contribute to Ferro.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Typed Query Predicates
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
## Why this exists
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
|
|
11
|
+
## The three styles
|
|
12
|
+
|
|
13
|
+
### 1. Operator (the original)
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
rows = await User.where(User.id == 1).all()
|
|
17
|
+
rows = await User.where(User.email.like("%@example.com")).all()
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
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.
|
|
21
|
+
|
|
22
|
+
### 2. `col()` wrapper
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from ferro.query import col
|
|
26
|
+
|
|
27
|
+
rows = await User.where(col(User.archived) == False).all()
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`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.
|
|
31
|
+
|
|
32
|
+
### 3. Lambda predicate
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
rows = await User.where(lambda t: t.archived == False).all()
|
|
36
|
+
rows = await User.where(
|
|
37
|
+
lambda t: (t.role == "admin") & (t.active == True)
|
|
38
|
+
).all()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
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.
|
|
42
|
+
|
|
43
|
+
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.
|
|
44
|
+
|
|
45
|
+
## When to use which
|
|
46
|
+
|
|
47
|
+
| Style | Use when |
|
|
48
|
+
|------|----------|
|
|
49
|
+
| Operator | Existing code that already type-checks; quick filters where the value type isn't `bool`. |
|
|
50
|
+
| `col()` | One attribute on an existing chain trips your type checker and you want minimal diff. |
|
|
51
|
+
| Lambda | New code, especially boolean comparisons or compound predicates; preferred idiom. |
|
|
52
|
+
|
|
53
|
+
All three are equally efficient at runtime — every one of them produces a `QueryNode` and appends it to `where_clause`.
|
|
54
|
+
|
|
55
|
+
## Combining styles
|
|
56
|
+
|
|
57
|
+
You can mix all three on a single chain. They compose because they all funnel through the same dispatch in `Query.where`:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
rows = await (
|
|
61
|
+
User.where(User.id == 1) # operator
|
|
62
|
+
.where(col(User.archived) == False) # col()
|
|
63
|
+
.where(lambda t: t.role == "admin") # lambda
|
|
64
|
+
.all()
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`Relation.where` (used on `BackRef` collections) accepts the same three shapes:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
published = await author.posts.where(lambda t: t.published == True).all()
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## What this does not change
|
|
75
|
+
|
|
76
|
+
- Your model annotations. `archived: bool = False` stays exactly as it is.
|
|
77
|
+
- The metaclass's `FieldProxy` injection. Class attribute access is unchanged.
|
|
78
|
+
- Pydantic schema generation, JSON schema output, or model validation.
|
|
79
|
+
- The Rust FFI bridge or how `QueryNode`s are serialized for the engine.
|
|
80
|
+
- The operator-path runtime. Existing `Model.field == value` calls take the same code path they always have.
|
|
81
|
+
|
|
82
|
+
## Scope boundaries
|
|
83
|
+
|
|
84
|
+
The current implementation deliberately stops short of:
|
|
85
|
+
|
|
86
|
+
- **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.
|
|
87
|
+
- **A type-checker plugin.** Ferro stays plugin-free.
|
|
88
|
+
- **A kwargs-style or template-string predicate API.** Both have been considered; neither shipped here.
|
|
89
|
+
|
|
90
|
+
If `t.archived` resolving as `FieldProxy[Any]` ever bites you statically, drop back to `col(Model.archived) == ...` for that one comparison — that's exactly the role `col()` plays.
|
|
91
|
+
|
|
92
|
+
## Reference
|
|
93
|
+
|
|
94
|
+
- `ferro.query.col` — runtime-identity wrapper, raises `TypeError` for non-`FieldProxy` input.
|
|
95
|
+
- `ferro.query.QueryProxy` — attribute proxy passed to lambda predicates.
|
|
96
|
+
- `ferro.query.Predicate` — `Callable[[QueryProxy[TModel]], QueryNode]`, the type of any lambda predicate.
|
|
97
|
+
- `ferro.query.FieldProxy` — generic over the column's Python type (`FieldProxy[T]`).
|
|
98
|
+
|
|
99
|
+
See the [Query API reference](../api/query.md) for full signatures.
|
|
100
|
+
|
|
101
|
+
## See Also
|
|
102
|
+
|
|
103
|
+
- [Queries Guide](../guide/queries.md)
|
|
104
|
+
- [Type Safety](type-safety.md)
|
|
105
|
+
- [Query API](../api/query.md)
|
|
@@ -57,16 +57,19 @@ class User(Model):
|
|
|
57
57
|
username: str
|
|
58
58
|
age: int
|
|
59
59
|
|
|
60
|
-
#
|
|
60
|
+
# Fetch by PK: definite model instance (raises ModelDoesNotExist if missing)
|
|
61
61
|
user: User = await User.get(1)
|
|
62
62
|
|
|
63
|
+
# Optional PK lookup
|
|
64
|
+
maybe_user: User | None = await User.get_or_none(999)
|
|
65
|
+
|
|
63
66
|
# Autocomplete works
|
|
64
67
|
user.username # ✓ Known attribute
|
|
65
68
|
user.invalid # ✗ Type error
|
|
66
69
|
|
|
67
70
|
# Query results are typed
|
|
68
71
|
users: list[User] = await User.all()
|
|
69
|
-
first: User | None = await User.first()
|
|
72
|
+
first: User | None = await User.where(User.username == "alice").first()
|
|
70
73
|
```
|
|
71
74
|
|
|
72
75
|
## IDE Autocomplete
|
|
@@ -85,6 +88,27 @@ User.where(
|
|
|
85
88
|
)
|
|
86
89
|
```
|
|
87
90
|
|
|
91
|
+
## Query Predicates and the Type Checker
|
|
92
|
+
|
|
93
|
+
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`.
|
|
94
|
+
|
|
95
|
+
Ferro ships three predicate styles to address this without any model-annotation changes or type-checker plugins:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from ferro.query import col
|
|
99
|
+
|
|
100
|
+
# Operator (unchanged)
|
|
101
|
+
await User.where(User.id == 1).all()
|
|
102
|
+
|
|
103
|
+
# col() — runtime identity, statically narrows back to FieldProxy[T]
|
|
104
|
+
await User.where(col(User.archived) == False).all()
|
|
105
|
+
|
|
106
|
+
# Lambda — receives a QueryProxy whose attributes return FieldProxy
|
|
107
|
+
await User.where(lambda t: t.archived == False).all()
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
See [Typed Query Predicates](query-typing.md) for the full discussion, including when to reach for each style and how they compose.
|
|
111
|
+
|
|
88
112
|
## Field Type Validation
|
|
89
113
|
|
|
90
114
|
Ferro validates field types match database types:
|
|
@@ -381,6 +381,23 @@ await User.bulk_create(users)
|
|
|
381
381
|
!!! note "Exception Types"
|
|
382
382
|
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.
|
|
383
383
|
|
|
384
|
+
### Primary key lookup (`Model.get`)
|
|
385
|
+
|
|
386
|
+
`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.
|
|
387
|
+
|
|
388
|
+
```python
|
|
389
|
+
from ferro import ModelDoesNotExist
|
|
390
|
+
|
|
391
|
+
try:
|
|
392
|
+
user = await User.get(user_id)
|
|
393
|
+
except ModelDoesNotExist:
|
|
394
|
+
# e.model, e.pk
|
|
395
|
+
...
|
|
396
|
+
|
|
397
|
+
# Or, when None is the natural result:
|
|
398
|
+
user = await User.get_or_none(user_id)
|
|
399
|
+
```
|
|
400
|
+
|
|
384
401
|
### Unique Constraint Violations
|
|
385
402
|
|
|
386
403
|
```python
|
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
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.
|
|
4
4
|
|
|
5
|
+
## Fetch by primary key
|
|
6
|
+
|
|
7
|
+
`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.
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from ferro import ModelDoesNotExist
|
|
13
|
+
|
|
14
|
+
user = await User.get(42)
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
user = await User.get(client_supplied_id)
|
|
18
|
+
except ModelDoesNotExist:
|
|
19
|
+
... # e.g. return 404 from your HTTP layer
|
|
20
|
+
|
|
21
|
+
draft = await User.get_or_none(999) # None if no such row
|
|
22
|
+
|
|
23
|
+
replica_view = await User.using("replica").get_or_none(1)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
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.
|
|
27
|
+
|
|
5
28
|
## Basic Filtering
|
|
6
29
|
|
|
7
30
|
Use standard Python comparison operators on model fields to create filter conditions:
|
|
@@ -31,6 +54,32 @@ alice_users = await User.where(User.name.like("Alice%")).all()
|
|
|
31
54
|
| `.like()` | `LIKE` | `User.email.like("%@example.com")` |
|
|
32
55
|
| `.in_()` | `IN` | `User.status.in_(["active", "pending"])` |
|
|
33
56
|
|
|
57
|
+
## Predicate Styles
|
|
58
|
+
|
|
59
|
+
`where()` accepts three interchangeable predicate styles. They share one runtime path and one dispatcher, and you can mix them on a single chain.
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from ferro.query import col
|
|
63
|
+
|
|
64
|
+
# 1. Operator (the original)
|
|
65
|
+
await User.where(User.id == 1).all()
|
|
66
|
+
|
|
67
|
+
# 2. col() wrapper — runtime identity, statically narrows to FieldProxy[T]
|
|
68
|
+
await User.where(col(User.archived) == False).all()
|
|
69
|
+
|
|
70
|
+
# 3. Lambda predicate — receives a QueryProxy with FieldProxy attributes
|
|
71
|
+
await User.where(lambda t: t.archived == False).all()
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
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.
|
|
75
|
+
|
|
76
|
+
!!! tip "When to use which"
|
|
77
|
+
- **Operator** — existing code that already type-checks; quick filters where the column type isn't `bool`.
|
|
78
|
+
- **`col()`** — one attribute on an existing chain trips your type checker and you want minimal diff.
|
|
79
|
+
- **Lambda** — new code, especially boolean comparisons or compound predicates. Recommended idiom going forward.
|
|
80
|
+
|
|
81
|
+
See [Typed Query Predicates](../concepts/query-typing.md) for the full treatment, including combined-style chains and the deliberate scope boundaries.
|
|
82
|
+
|
|
34
83
|
## Logical Operators
|
|
35
84
|
|
|
36
85
|
Combine conditions with `&` (AND) and `|` (OR). **Always use parentheses** around each condition:
|
|
@@ -51,6 +100,14 @@ query = User.where(
|
|
|
51
100
|
inactive_users = await User.where(User.is_active != True).all()
|
|
52
101
|
```
|
|
53
102
|
|
|
103
|
+
The same compound expressions work inside lambda predicates — useful when you want the whole expression to type-check without `col()`:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
admins = await User.where(
|
|
107
|
+
lambda t: (t.role == "admin") & (t.active == True)
|
|
108
|
+
).all()
|
|
109
|
+
```
|
|
110
|
+
|
|
54
111
|
## Chaining
|
|
55
112
|
|
|
56
113
|
Methods can be chained to build complex queries incrementally:
|
|
@@ -201,8 +258,9 @@ author = await User.where(User.username == "alice").first()
|
|
|
201
258
|
# Get all posts by author
|
|
202
259
|
author_posts = await author.posts.all()
|
|
203
260
|
|
|
204
|
-
# Filter reverse relation
|
|
261
|
+
# Filter reverse relation (any of the three predicate styles works)
|
|
205
262
|
published_posts = await author.posts.where(Post.published == True).all()
|
|
263
|
+
published_posts = await author.posts.where(lambda t: t.published == True).all()
|
|
206
264
|
|
|
207
265
|
# Count reverse relation
|
|
208
266
|
post_count = await author.posts.count()
|
|
@@ -33,6 +33,10 @@ users = await User.all()
|
|
|
33
33
|
# Specific database
|
|
34
34
|
replica_users = await User.using("replica").all()
|
|
35
35
|
analytics_data = await Metric.using("analytics").all()
|
|
36
|
+
|
|
37
|
+
# Primary-key fetch on a named connection (same semantics as Model.get / get_or_none)
|
|
38
|
+
user = await User.using("replica").get(1) # raises ModelDoesNotExist if missing
|
|
39
|
+
maybe = await User.using("replica").get_or_none(unknown_id)
|
|
36
40
|
```
|
|
37
41
|
|
|
38
42
|
## Transactions
|