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.
Files changed (166) hide show
  1. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.gitignore +1 -0
  2. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/CHANGELOG.md +28 -0
  3. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/Cargo.lock +1 -1
  4. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/Cargo.toml +1 -1
  5. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/PKG-INFO +1 -1
  6. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/TEST_RESULTS.md +1 -1
  7. ferro_orm-0.9.0/docs/api/exceptions.md +15 -0
  8. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/api/model.md +3 -1
  9. ferro_orm-0.9.0/docs/api/query.md +97 -0
  10. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/api/utilities.md +4 -1
  11. ferro_orm-0.9.0/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +95 -0
  12. ferro_orm-0.9.0/docs/changelog.md +46 -0
  13. ferro_orm-0.9.0/docs/concepts/query-typing.md +105 -0
  14. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/concepts/type-safety.md +26 -2
  15. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/guide/mutations.md +17 -0
  16. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/guide/queries.md +59 -1
  17. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/howto/multiple-databases.md +4 -0
  18. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/migration-sqlalchemy.md +20 -0
  19. ferro_orm-0.9.0/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +321 -0
  20. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/mkdocs.yml +3 -0
  21. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/pyproject.toml +1 -1
  22. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/__init__.py +2 -0
  23. ferro_orm-0.9.0/src/ferro/exceptions.py +17 -0
  24. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/models.py +65 -15
  25. ferro_orm-0.9.0/src/ferro/query/__init__.py +14 -0
  26. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/query/builder.py +57 -10
  27. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/query/nodes.py +99 -11
  28. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/relations/descriptors.py +2 -2
  29. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_alembic_bridge.py +74 -0
  30. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_connection.py +1 -1
  31. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_crud.py +7 -4
  32. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_deletion.py +2 -2
  33. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_documentation_features.py +1 -2
  34. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_named_connections_integration.py +3 -2
  35. ferro_orm-0.9.0/tests/test_query_typing.py +285 -0
  36. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_transactions.py +1 -1
  37. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/uv.lock +1 -1
  38. ferro_orm-0.7.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/agent-native.json +0 -87
  39. ferro_orm-0.7.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/api-contract.json +0 -40
  40. ferro_orm-0.7.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/correctness.json +0 -15
  41. ferro_orm-0.7.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/data-migrations.json +0 -72
  42. ferro_orm-0.7.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/kieran-python.json +0 -44
  43. ferro_orm-0.7.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/maintainability.json +0 -79
  44. ferro_orm-0.7.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/project-standards.json +0 -45
  45. ferro_orm-0.7.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/testing.json +0 -108
  46. ferro_orm-0.7.0/docs/api/query.md +0 -24
  47. ferro_orm-0.7.0/docs/changelog.md +0 -36
  48. ferro_orm-0.7.0/src/ferro/query/__init__.py +0 -6
  49. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  50. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  51. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.github/PERMISSIONS.md +0 -0
  52. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.github/PYPI_CHECKLIST.md +0 -0
  53. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.github/PYPI_SETUP.md +0 -0
  54. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.github/generated/wheels.generated.yml +0 -0
  55. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.github/pull_request_template.md +0 -0
  56. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.github/workflows/ci.yml +0 -0
  57. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.github/workflows/packaging-smoke.yml +0 -0
  58. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.github/workflows/publish-docs.yml +0 -0
  59. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.github/workflows/publish.yml +0 -0
  60. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.github/workflows/release.yml +0 -0
  61. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.pre-commit-config.yaml +0 -0
  62. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/.python-version +0 -0
  63. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/AGENTS.md +0 -0
  64. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/CONTRIBUTING.md +0 -0
  65. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/LICENSE +0 -0
  66. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/README.md +0 -0
  67. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/api/fields.md +0 -0
  68. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/api/raw-sql.md +0 -0
  69. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/api/relationships.md +0 -0
  70. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/api/transactions.md +0 -0
  71. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
  72. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/coming-soon.md +0 -0
  73. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/concepts/architecture.md +0 -0
  74. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/concepts/identity-map.md +0 -0
  75. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/concepts/performance.md +0 -0
  76. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/contributing.md +0 -0
  77. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/faq.md +0 -0
  78. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/getting-started/installation.md +0 -0
  79. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/getting-started/next-steps.md +0 -0
  80. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/getting-started/tutorial.md +0 -0
  81. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/guide/backend.md +0 -0
  82. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/guide/database.md +0 -0
  83. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/guide/migrations.md +0 -0
  84. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/guide/models-and-fields.md +0 -0
  85. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/guide/relationships.md +0 -0
  86. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/guide/transactions.md +0 -0
  87. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/howto/pagination.md +0 -0
  88. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/howto/soft-deletes.md +0 -0
  89. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/howto/testing.md +0 -0
  90. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/howto/timestamps.md +0 -0
  91. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/index.md +0 -0
  92. {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
  93. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
  94. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
  95. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
  96. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/solutions/README.md +0 -0
  97. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
  98. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
  99. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
  100. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
  101. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/solutions/patterns/foreign-key-index.md +0 -0
  102. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
  103. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
  104. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/solutions/patterns/typed-null-binds.md +0 -0
  105. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/stylesheets/extra.css +0 -0
  106. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/docs/why-ferro.md +0 -0
  107. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/justfile +0 -0
  108. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/scripts/demo_queries.py +0 -0
  109. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/backend.rs +0 -0
  110. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/connection.rs +0 -0
  111. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/_annotation_utils.py +0 -0
  112. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/_core.pyi +0 -0
  113. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/_shadow_fk_types.py +0 -0
  114. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/base.py +0 -0
  115. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/composite_indexes.py +0 -0
  116. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/composite_uniques.py +0 -0
  117. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/fields.py +0 -0
  118. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/metaclass.py +0 -0
  119. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/migrations/__init__.py +0 -0
  120. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/migrations/alembic.py +0 -0
  121. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/py.typed +0 -0
  122. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/raw.py +0 -0
  123. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/relations/__init__.py +0 -0
  124. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/schema_metadata.py +0 -0
  125. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/ferro/state.py +0 -0
  126. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/lib.rs +0 -0
  127. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/operations.rs +0 -0
  128. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/query.rs +0 -0
  129. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/schema.rs +0 -0
  130. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/src/state.rs +0 -0
  131. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/__init__.py +0 -0
  132. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/conftest.py +0 -0
  133. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/db_backends.py +0 -0
  134. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_aggregation.py +0 -0
  135. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_alembic_autogenerate.py +0 -0
  136. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_alembic_nullability.py +0 -0
  137. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_alembic_type_mapping.py +0 -0
  138. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_auto_migrate.py +0 -0
  139. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_bulk_update.py +0 -0
  140. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_composite_index.py +0 -0
  141. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_composite_unique.py +0 -0
  142. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_connection_redaction.py +0 -0
  143. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_constraints.py +0 -0
  144. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_cross_emitter_parity.py +0 -0
  145. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_db_backends.py +0 -0
  146. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_docs_examples.py +0 -0
  147. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_field_wrapper.py +0 -0
  148. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_helpers.py +0 -0
  149. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_hydration.py +0 -0
  150. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_metaclass_internals.py +0 -0
  151. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_metadata.py +0 -0
  152. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_models.py +0 -0
  153. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_one_to_one.py +0 -0
  154. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_query_builder.py +0 -0
  155. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_raw_sql.py +0 -0
  156. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_refresh.py +0 -0
  157. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_relationship_engine.py +0 -0
  158. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_schema.py +0 -0
  159. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_schema_constraints.py +0 -0
  160. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_schema_enum_annotations.py +0 -0
  161. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_shadow_fk_types.py +0 -0
  162. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_static_contracts.py +0 -0
  163. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_string_search.py +0 -0
  164. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_structural_types.py +0 -0
  165. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_temporal_types.py +0 -0
  166. {ferro_orm-0.7.0 → ferro_orm-0.9.0}/tests/test_typed_null_binds.py +0 -0
@@ -242,6 +242,7 @@ __marimo__/
242
242
  ferro_test.db
243
243
  .cursorrules
244
244
  .cursor
245
+ .context/
245
246
  playground.ipynb
246
247
  IMPLEMENTATION.md
247
248
  Cargo.lock
@@ -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
@@ -294,7 +294,7 @@ dependencies = [
294
294
 
295
295
  [[package]]
296
296
  name = "ferro"
297
- version = "0.7.0"
297
+ version = "0.9.0"
298
298
  dependencies = [
299
299
  "dashmap",
300
300
  "once_cell",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ferro"
3
- version = "0.7.0"
3
+ version = "0.9.0"
4
4
  edition = "2024"
5
5
  readme = "README.md"
6
6
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ferro-orm
3
- Version: 0.7.0
3
+ Version: 0.9.0
4
4
  Requires-Dist: pydantic>=2.0
5
5
  Requires-Dist: alembic>=1.18.1 ; extra == 'alembic'
6
6
  Requires-Dist: sqlalchemy>=2.0.46 ; extra == 'alembic'
@@ -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
- # Type checker knows return type
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