ferro-orm 0.7.0__tar.gz → 0.8.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.8.0}/CHANGELOG.md +9 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/Cargo.lock +1 -1
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/Cargo.toml +1 -1
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/PKG-INFO +1 -1
- ferro_orm-0.8.0/docs/api/query.md +97 -0
- ferro_orm-0.8.0/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +95 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/changelog.md +3 -0
- ferro_orm-0.8.0/docs/concepts/query-typing.md +105 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/concepts/type-safety.md +21 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/guide/queries.md +36 -1
- ferro_orm-0.8.0/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +321 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/mkdocs.yml +2 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/pyproject.toml +1 -1
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/ferro/models.py +25 -5
- ferro_orm-0.8.0/src/ferro/query/__init__.py +14 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/ferro/query/builder.py +57 -10
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/ferro/query/nodes.py +99 -11
- ferro_orm-0.8.0/tests/test_query_typing.py +285 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/uv.lock +1 -1
- ferro_orm-0.7.0/docs/api/query.md +0 -24
- ferro_orm-0.7.0/src/ferro/query/__init__.py +0 -6
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/agent-native.json +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/api-contract.json +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/correctness.json +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/data-migrations.json +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/kieran-python.json +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/maintainability.json +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/project-standards.json +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/testing.json +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/.github/PERMISSIONS.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/.github/PYPI_CHECKLIST.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/.github/PYPI_SETUP.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/.github/generated/wheels.generated.yml +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/.github/pull_request_template.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/.github/workflows/ci.yml +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/.github/workflows/packaging-smoke.yml +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/.github/workflows/publish-docs.yml +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/.github/workflows/publish.yml +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/.github/workflows/release.yml +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/.gitignore +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/.pre-commit-config.yaml +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/.python-version +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/AGENTS.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/CONTRIBUTING.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/LICENSE +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/README.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/TEST_RESULTS.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/api/fields.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/api/model.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/api/raw-sql.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/api/relationships.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/api/transactions.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/api/utilities.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/coming-soon.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/concepts/architecture.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/concepts/identity-map.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/concepts/performance.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/contributing.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/faq.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/getting-started/installation.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/getting-started/next-steps.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/getting-started/tutorial.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/guide/backend.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/guide/database.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/guide/migrations.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/guide/models-and-fields.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/guide/mutations.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/guide/relationships.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/guide/transactions.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/howto/multiple-databases.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/howto/pagination.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/howto/soft-deletes.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/howto/testing.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/howto/timestamps.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/index.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/migration-sqlalchemy.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/solutions/README.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/solutions/patterns/foreign-key-index.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/solutions/patterns/typed-null-binds.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/stylesheets/extra.css +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/docs/why-ferro.md +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/justfile +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/scripts/demo_queries.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/backend.rs +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/connection.rs +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/ferro/__init__.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/ferro/_annotation_utils.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/ferro/_core.pyi +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/ferro/_shadow_fk_types.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/ferro/base.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/ferro/composite_indexes.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/ferro/composite_uniques.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/ferro/fields.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/ferro/metaclass.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/ferro/migrations/__init__.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/ferro/migrations/alembic.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/ferro/py.typed +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/ferro/raw.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/ferro/relations/__init__.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/ferro/relations/descriptors.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/ferro/schema_metadata.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/ferro/state.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/lib.rs +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/operations.rs +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/query.rs +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/schema.rs +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/src/state.rs +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/__init__.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/conftest.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/db_backends.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_aggregation.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_alembic_autogenerate.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_alembic_bridge.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_alembic_nullability.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_alembic_type_mapping.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_auto_migrate.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_bulk_update.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_composite_index.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_composite_unique.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_connection.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_connection_redaction.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_constraints.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_cross_emitter_parity.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_crud.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_db_backends.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_deletion.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_docs_examples.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_documentation_features.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_field_wrapper.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_helpers.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_hydration.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_metaclass_internals.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_metadata.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_models.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_named_connections_integration.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_one_to_one.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_query_builder.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_raw_sql.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_refresh.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_relationship_engine.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_schema.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_schema_constraints.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_schema_enum_annotations.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_shadow_fk_types.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_static_contracts.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_string_search.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_structural_types.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_temporal_types.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_transactions.py +0 -0
- {ferro_orm-0.7.0 → ferro_orm-0.8.0}/tests/test_typed_null_binds.py +0 -0
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.8.0 (2026-05-09)
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
- **query**: Typed query predicates via col() and lambda
|
|
9
|
+
([#48](https://github.com/syn54x/ferro-orm/pull/48),
|
|
10
|
+
[`e34e3ca`](https://github.com/syn54x/ferro-orm/commit/e34e3ca43adfc851d757d8232d5736fb453db55e))
|
|
11
|
+
|
|
12
|
+
|
|
4
13
|
## v0.7.0 (2026-05-08)
|
|
5
14
|
|
|
6
15
|
### Features
|
|
@@ -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)
|
|
@@ -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.
|
|
@@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
10
|
### Added
|
|
11
|
+
- 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).
|
|
12
|
+
- `FieldProxy` is now generic (`FieldProxy[T]`); operator overloads are typed `T | FieldProxy[T] -> QueryNode`, `.like()` is gated to `FieldProxy[str]`.
|
|
13
|
+
- New public symbols re-exported from `ferro.query`: `col`, `QueryProxy`, `Predicate`.
|
|
11
14
|
- Comprehensive documentation restructure
|
|
12
15
|
- Tutorial for new users
|
|
13
16
|
- How-to guides for common patterns
|
|
@@ -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)
|
|
@@ -85,6 +85,27 @@ User.where(
|
|
|
85
85
|
)
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
+
## Query Predicates and the Type Checker
|
|
89
|
+
|
|
90
|
+
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`.
|
|
91
|
+
|
|
92
|
+
Ferro ships three predicate styles to address this without any model-annotation changes or type-checker plugins:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from ferro.query import col
|
|
96
|
+
|
|
97
|
+
# Operator (unchanged)
|
|
98
|
+
await User.where(User.id == 1).all()
|
|
99
|
+
|
|
100
|
+
# col() — runtime identity, statically narrows back to FieldProxy[T]
|
|
101
|
+
await User.where(col(User.archived) == False).all()
|
|
102
|
+
|
|
103
|
+
# Lambda — receives a QueryProxy whose attributes return FieldProxy
|
|
104
|
+
await User.where(lambda t: t.archived == False).all()
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
See [Typed Query Predicates](query-typing.md) for the full discussion, including when to reach for each style and how they compose.
|
|
108
|
+
|
|
88
109
|
## Field Type Validation
|
|
89
110
|
|
|
90
111
|
Ferro validates field types match database types:
|
|
@@ -31,6 +31,32 @@ alice_users = await User.where(User.name.like("Alice%")).all()
|
|
|
31
31
|
| `.like()` | `LIKE` | `User.email.like("%@example.com")` |
|
|
32
32
|
| `.in_()` | `IN` | `User.status.in_(["active", "pending"])` |
|
|
33
33
|
|
|
34
|
+
## Predicate Styles
|
|
35
|
+
|
|
36
|
+
`where()` accepts three interchangeable predicate styles. They share one runtime path and one dispatcher, and you can mix them on a single chain.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from ferro.query import col
|
|
40
|
+
|
|
41
|
+
# 1. Operator (the original)
|
|
42
|
+
await User.where(User.id == 1).all()
|
|
43
|
+
|
|
44
|
+
# 2. col() wrapper — runtime identity, statically narrows to FieldProxy[T]
|
|
45
|
+
await User.where(col(User.archived) == False).all()
|
|
46
|
+
|
|
47
|
+
# 3. Lambda predicate — receives a QueryProxy with FieldProxy attributes
|
|
48
|
+
await User.where(lambda t: t.archived == False).all()
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
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.
|
|
52
|
+
|
|
53
|
+
!!! tip "When to use which"
|
|
54
|
+
- **Operator** — existing code that already type-checks; quick filters where the column type isn't `bool`.
|
|
55
|
+
- **`col()`** — one attribute on an existing chain trips your type checker and you want minimal diff.
|
|
56
|
+
- **Lambda** — new code, especially boolean comparisons or compound predicates. Recommended idiom going forward.
|
|
57
|
+
|
|
58
|
+
See [Typed Query Predicates](../concepts/query-typing.md) for the full treatment, including combined-style chains and the deliberate scope boundaries.
|
|
59
|
+
|
|
34
60
|
## Logical Operators
|
|
35
61
|
|
|
36
62
|
Combine conditions with `&` (AND) and `|` (OR). **Always use parentheses** around each condition:
|
|
@@ -51,6 +77,14 @@ query = User.where(
|
|
|
51
77
|
inactive_users = await User.where(User.is_active != True).all()
|
|
52
78
|
```
|
|
53
79
|
|
|
80
|
+
The same compound expressions work inside lambda predicates — useful when you want the whole expression to type-check without `col()`:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
admins = await User.where(
|
|
84
|
+
lambda t: (t.role == "admin") & (t.active == True)
|
|
85
|
+
).all()
|
|
86
|
+
```
|
|
87
|
+
|
|
54
88
|
## Chaining
|
|
55
89
|
|
|
56
90
|
Methods can be chained to build complex queries incrementally:
|
|
@@ -201,8 +235,9 @@ author = await User.where(User.username == "alice").first()
|
|
|
201
235
|
# Get all posts by author
|
|
202
236
|
author_posts = await author.posts.all()
|
|
203
237
|
|
|
204
|
-
# Filter reverse relation
|
|
238
|
+
# Filter reverse relation (any of the three predicate styles works)
|
|
205
239
|
published_posts = await author.posts.where(Post.published == True).all()
|
|
240
|
+
published_posts = await author.posts.where(lambda t: t.published == True).all()
|
|
206
241
|
|
|
207
242
|
# Count reverse relation
|
|
208
243
|
post_count = await author.posts.count()
|