ferro-orm 0.8.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 (163) hide show
  1. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/.gitignore +1 -0
  2. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/CHANGELOG.md +19 -0
  3. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/Cargo.lock +1 -1
  4. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/Cargo.toml +1 -1
  5. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/PKG-INFO +1 -1
  6. {ferro_orm-0.8.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.8.0 → ferro_orm-0.9.0}/docs/api/model.md +3 -1
  9. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/api/utilities.md +4 -1
  10. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/changelog.md +7 -0
  11. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/concepts/type-safety.md +5 -2
  12. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/guide/mutations.md +17 -0
  13. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/guide/queries.md +23 -0
  14. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/howto/multiple-databases.md +4 -0
  15. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/migration-sqlalchemy.md +20 -0
  16. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/mkdocs.yml +1 -0
  17. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/pyproject.toml +1 -1
  18. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/ferro/__init__.py +2 -0
  19. ferro_orm-0.9.0/src/ferro/exceptions.py +17 -0
  20. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/ferro/models.py +40 -10
  21. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/ferro/relations/descriptors.py +2 -2
  22. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_alembic_bridge.py +74 -0
  23. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_connection.py +1 -1
  24. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_crud.py +7 -4
  25. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_deletion.py +2 -2
  26. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_documentation_features.py +1 -2
  27. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_named_connections_integration.py +3 -2
  28. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_transactions.py +1 -1
  29. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/uv.lock +1 -1
  30. ferro_orm-0.8.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/agent-native.json +0 -87
  31. ferro_orm-0.8.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/api-contract.json +0 -40
  32. ferro_orm-0.8.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/correctness.json +0 -15
  33. ferro_orm-0.8.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/data-migrations.json +0 -72
  34. ferro_orm-0.8.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/kieran-python.json +0 -44
  35. ferro_orm-0.8.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/maintainability.json +0 -79
  36. ferro_orm-0.8.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/project-standards.json +0 -45
  37. ferro_orm-0.8.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/testing.json +0 -108
  38. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  39. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  40. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/.github/PERMISSIONS.md +0 -0
  41. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/.github/PYPI_CHECKLIST.md +0 -0
  42. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/.github/PYPI_SETUP.md +0 -0
  43. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/.github/generated/wheels.generated.yml +0 -0
  44. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/.github/pull_request_template.md +0 -0
  45. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/.github/workflows/ci.yml +0 -0
  46. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/.github/workflows/packaging-smoke.yml +0 -0
  47. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/.github/workflows/publish-docs.yml +0 -0
  48. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/.github/workflows/publish.yml +0 -0
  49. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/.github/workflows/release.yml +0 -0
  50. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/.pre-commit-config.yaml +0 -0
  51. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/.python-version +0 -0
  52. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/AGENTS.md +0 -0
  53. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/CONTRIBUTING.md +0 -0
  54. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/LICENSE +0 -0
  55. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/README.md +0 -0
  56. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/api/fields.md +0 -0
  57. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/api/query.md +0 -0
  58. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/api/raw-sql.md +0 -0
  59. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/api/relationships.md +0 -0
  60. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/api/transactions.md +0 -0
  61. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
  62. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
  63. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/coming-soon.md +0 -0
  64. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/concepts/architecture.md +0 -0
  65. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/concepts/identity-map.md +0 -0
  66. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/concepts/performance.md +0 -0
  67. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/concepts/query-typing.md +0 -0
  68. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/contributing.md +0 -0
  69. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/faq.md +0 -0
  70. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/getting-started/installation.md +0 -0
  71. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/getting-started/next-steps.md +0 -0
  72. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/getting-started/tutorial.md +0 -0
  73. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/guide/backend.md +0 -0
  74. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/guide/database.md +0 -0
  75. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/guide/migrations.md +0 -0
  76. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/guide/models-and-fields.md +0 -0
  77. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/guide/relationships.md +0 -0
  78. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/guide/transactions.md +0 -0
  79. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/howto/pagination.md +0 -0
  80. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/howto/soft-deletes.md +0 -0
  81. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/howto/testing.md +0 -0
  82. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/howto/timestamps.md +0 -0
  83. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/index.md +0 -0
  84. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
  85. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
  86. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
  87. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
  88. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
  89. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/solutions/README.md +0 -0
  90. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
  91. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
  92. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
  93. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
  94. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/solutions/patterns/foreign-key-index.md +0 -0
  95. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
  96. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
  97. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/solutions/patterns/typed-null-binds.md +0 -0
  98. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/stylesheets/extra.css +0 -0
  99. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/docs/why-ferro.md +0 -0
  100. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/justfile +0 -0
  101. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/scripts/demo_queries.py +0 -0
  102. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/backend.rs +0 -0
  103. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/connection.rs +0 -0
  104. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/ferro/_annotation_utils.py +0 -0
  105. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/ferro/_core.pyi +0 -0
  106. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/ferro/_shadow_fk_types.py +0 -0
  107. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/ferro/base.py +0 -0
  108. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/ferro/composite_indexes.py +0 -0
  109. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/ferro/composite_uniques.py +0 -0
  110. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/ferro/fields.py +0 -0
  111. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/ferro/metaclass.py +0 -0
  112. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/ferro/migrations/__init__.py +0 -0
  113. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/ferro/migrations/alembic.py +0 -0
  114. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/ferro/py.typed +0 -0
  115. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/ferro/query/__init__.py +0 -0
  116. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/ferro/query/builder.py +0 -0
  117. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/ferro/query/nodes.py +0 -0
  118. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/ferro/raw.py +0 -0
  119. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/ferro/relations/__init__.py +0 -0
  120. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/ferro/schema_metadata.py +0 -0
  121. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/ferro/state.py +0 -0
  122. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/lib.rs +0 -0
  123. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/operations.rs +0 -0
  124. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/query.rs +0 -0
  125. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/schema.rs +0 -0
  126. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/src/state.rs +0 -0
  127. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/__init__.py +0 -0
  128. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/conftest.py +0 -0
  129. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/db_backends.py +0 -0
  130. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_aggregation.py +0 -0
  131. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_alembic_autogenerate.py +0 -0
  132. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_alembic_nullability.py +0 -0
  133. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_alembic_type_mapping.py +0 -0
  134. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_auto_migrate.py +0 -0
  135. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_bulk_update.py +0 -0
  136. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_composite_index.py +0 -0
  137. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_composite_unique.py +0 -0
  138. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_connection_redaction.py +0 -0
  139. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_constraints.py +0 -0
  140. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_cross_emitter_parity.py +0 -0
  141. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_db_backends.py +0 -0
  142. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_docs_examples.py +0 -0
  143. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_field_wrapper.py +0 -0
  144. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_helpers.py +0 -0
  145. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_hydration.py +0 -0
  146. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_metaclass_internals.py +0 -0
  147. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_metadata.py +0 -0
  148. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_models.py +0 -0
  149. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_one_to_one.py +0 -0
  150. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_query_builder.py +0 -0
  151. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_query_typing.py +0 -0
  152. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_raw_sql.py +0 -0
  153. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_refresh.py +0 -0
  154. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_relationship_engine.py +0 -0
  155. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_schema.py +0 -0
  156. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_schema_constraints.py +0 -0
  157. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_schema_enum_annotations.py +0 -0
  158. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_shadow_fk_types.py +0 -0
  159. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_static_contracts.py +0 -0
  160. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_string_search.py +0 -0
  161. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_structural_types.py +0 -0
  162. {ferro_orm-0.8.0 → ferro_orm-0.9.0}/tests/test_temporal_types.py +0 -0
  163. {ferro_orm-0.8.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,25 @@
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
+
4
23
  ## v0.8.0 (2026-05-09)
5
24
 
6
25
  ### Features
@@ -294,7 +294,7 @@ dependencies = [
294
294
 
295
295
  [[package]]
296
296
  name = "ferro"
297
- version = "0.8.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.8.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.8.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)
@@ -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
@@ -7,7 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
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
+
10
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).
11
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).
12
19
  - `FieldProxy` is now generic (`FieldProxy[T]`); operator overloads are typed `T | FieldProxy[T] -> QueryNode`, `.like()` is gated to `FieldProxy[str]`.
13
20
  - New public symbols re-exported from `ferro.query`: `col`, `QueryProxy`, `Predicate`.
@@ -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
@@ -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:
@@ -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
@@ -69,6 +69,26 @@ users = result.scalars().all()
69
69
  users = await User.where(User.age >= 18).all()
70
70
  ```
71
71
 
72
+ ### Get by primary key
73
+
74
+ SQLAlchemy’s `session.get(User, pk)` returns `None` when the row is missing. Ferro’s `await User.get(pk)` returns `User` and raises `ModelDoesNotExist` when absent. Use `await User.get_or_none(pk)` for the optional pattern.
75
+
76
+ ```python
77
+ # SQLAlchemy
78
+ user = await session.get(User, 1)
79
+
80
+ # Ferro — raises if missing
81
+ from ferro import ModelDoesNotExist
82
+
83
+ try:
84
+ user = await User.get(1)
85
+ except ModelDoesNotExist:
86
+ user = None
87
+
88
+ # Ferro — optional (like session.get when no row)
89
+ user = await User.get_or_none(1)
90
+ ```
91
+
72
92
  ## Relationships
73
93
 
74
94
  ### One-to-Many
@@ -155,6 +155,7 @@ nav:
155
155
 
156
156
  - API Reference:
157
157
  - Model: api/model.md
158
+ - Exceptions: api/exceptions.md
158
159
  - Query: api/query.md
159
160
  - Fields: api/fields.md
160
161
  - Relationships: api/relationships.md
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ferro-orm"
3
- version = "0.8.0"
3
+ version = "0.9.0"
4
4
  description = "A high-performance, Rust-backed ORM for Python."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -22,6 +22,7 @@ from ._core import (
22
22
  connect as _core_connect,
23
23
  )
24
24
  from .base import FerroField, FerroNullable, ForeignKey
25
+ from .exceptions import ModelDoesNotExist
25
26
  from .fields import BackRef, Field, ManyToMany
26
27
  from .models import Model, transaction
27
28
  from .query import Relation
@@ -96,6 +97,7 @@ __all__ = [
96
97
  "connect",
97
98
  "PoolConfig",
98
99
  "Model",
100
+ "ModelDoesNotExist",
99
101
  "FerroField",
100
102
  "FerroNullable",
101
103
  "Field",
@@ -0,0 +1,17 @@
1
+ """ORM-specific exceptions."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ class ModelDoesNotExist(LookupError):
7
+ """Raised when :meth:`~ferro.models.Model.get` finds no row for the primary key."""
8
+
9
+ model: type
10
+ pk: Any
11
+
12
+ def __init__(self, model_cls: type, pk: Any) -> None:
13
+ self.model = model_cls
14
+ self.pk = pk
15
+ super().__init__(
16
+ f"No {model_cls.__name__} record found for primary key {pk!r}"
17
+ )
@@ -31,6 +31,7 @@ from ._core import (
31
31
  transaction_connection_name,
32
32
  )
33
33
  from .base import ForeignKey, foreign_key_allows_none
34
+ from .exceptions import ModelDoesNotExist
34
35
  from .metaclass import ModelMetaclass
35
36
  from .query import Query, QueryNode
36
37
  from .state import _CURRENT_TRANSACTION, _CURRENT_TRANSACTION_CONNECTION
@@ -260,7 +261,7 @@ class Model(BaseModel, metaclass=ModelMetaclass):
260
261
  None
261
262
 
262
263
  Examples:
263
- >>> user = await User.get(1)
264
+ >>> user = await User.get_or_none(1)
264
265
  >>> if user:
265
266
  ... await user.delete()
266
267
  """
@@ -361,20 +362,39 @@ class Model(BaseModel, metaclass=ModelMetaclass):
361
362
  return results
362
363
 
363
364
  @classmethod
364
- async def get(cls, pk: Any) -> Self | None:
365
- """Fetch one record by primary key value
365
+ async def get(cls, pk: Any) -> Self:
366
+ """Fetch one record by primary key value.
366
367
 
367
368
  Args:
368
369
  pk: Primary key value to fetch a single record.
369
370
 
370
371
  Returns:
371
- The matching model instance, or None when no record exists.
372
+ The matching model instance.
373
+
374
+ Raises:
375
+ ModelDoesNotExist: When no row exists for this primary key. Use
376
+ :meth:`get_or_none` if you need optional lookup without raising.
372
377
 
373
378
  Examples:
374
379
  >>> user = await User.get(1)
375
- >>> user is None or isinstance(user, User)
380
+ >>> isinstance(user, User)
376
381
  True
377
382
  """
383
+ instance = await cls.get_or_none(pk)
384
+ if instance is None:
385
+ raise ModelDoesNotExist(cls, pk)
386
+ return instance
387
+
388
+ @classmethod
389
+ async def get_or_none(cls, pk: Any) -> Self | None:
390
+ """Fetch one record by primary key, or return None if no row exists.
391
+
392
+ Args:
393
+ pk: Primary key value to fetch a single record.
394
+
395
+ Returns:
396
+ The matching model instance, or None when no record exists.
397
+ """
378
398
  pk_field_name = cls._primary_key_field_name()
379
399
  if pk_field_name is None:
380
400
  raise RuntimeError(f"Model {cls.__name__} does not define a primary key")
@@ -395,8 +415,7 @@ class Model(BaseModel, metaclass=ModelMetaclass):
395
415
 
396
416
  Examples:
397
417
  >>> user = await User.get(1)
398
- >>> if user:
399
- ... await user.refresh()
418
+ >>> await user.refresh()
400
419
  """
401
420
  pk_field_name = self.__class__._primary_key_field_name()
402
421
  pk_val = getattr(self, pk_field_name) if pk_field_name is not None else None
@@ -579,7 +598,7 @@ class ModelConnection[M: Model]:
579
598
 
580
599
  Generic over the concrete model class so that every accessor preserves
581
600
  the bound type — e.g. ``Transcript.using("service").get(pk)`` resolves
582
- to ``Transcript | None`` rather than ``Model | None``.
601
+ to ``Transcript`` rather than ``Model``.
583
602
  """
584
603
 
585
604
  def __init__(self, model_cls: type[M], connection_name: str) -> None:
@@ -600,14 +619,25 @@ class ModelConnection[M: Model]:
600
619
  def where(self, node: QueryNode) -> Query[M]:
601
620
  return self.select().where(node)
602
621
 
603
- async def get(self, pk: Any) -> M | None:
622
+ async def get(self, pk: Any) -> M:
623
+ instance = await self.get_or_none(pk)
624
+ if instance is None:
625
+ raise ModelDoesNotExist(self.model_cls, pk)
626
+ return instance
627
+
628
+ async def get_or_none(self, pk: Any) -> M | None:
604
629
  pk_field_name = self.model_cls._primary_key_field_name()
605
630
  if pk_field_name is None:
606
631
  raise RuntimeError(
607
632
  f"Model {self.model_cls.__name__} does not define a primary key"
608
633
  )
609
634
 
610
- return await self.where(getattr(self.model_cls, pk_field_name) == pk).first()
635
+ instance = await self.where(
636
+ getattr(self.model_cls, pk_field_name) == pk
637
+ ).first()
638
+ if instance:
639
+ self.model_cls._fix_types(instance)
640
+ return instance
611
641
 
612
642
  async def bulk_create(self, instances: list[M]) -> int:
613
643
  return await self.model_cls.bulk_create(instances, using=self._connection_name)
@@ -109,7 +109,7 @@ class ForwardDescriptor(BaseModel):
109
109
 
110
110
  origin = _instance_origin_outside_transaction(instance)
111
111
  if origin is not None:
112
- return await self._target_model.using(origin).get(id_val)
113
- return await self._target_model.get(id_val)
112
+ return await self._target_model.using(origin).get_or_none(id_val)
113
+ return await self._target_model.get_or_none(id_val)
114
114
 
115
115
  return _fetch()
@@ -16,6 +16,7 @@ from ferro import (
16
16
  reset_engine,
17
17
  )
18
18
  from ferro.migrations import get_metadata
19
+ from ferro.schema_metadata import build_model_schema
19
20
 
20
21
 
21
22
  @pytest.fixture(autouse=True)
@@ -179,3 +180,76 @@ def test_on_delete_translation():
179
180
  fk = list(product_table.c.category_id.foreign_keys)[0]
180
181
  assert fk.ondelete == "SET NULL"
181
182
  assert product_table.c.category_id.nullable is True
183
+
184
+
185
+ def test_explicit_foreign_key_shadow_id_no_duplicate_alembic_columns():
186
+ """Declaring ``{relation}_id`` for static checkers must not duplicate DDL columns.
187
+
188
+ Ferro injects a shadow ``*_id`` for every :class:`~ferro.base.ForeignKey`. Some
189
+ type checkers (for example Ty) do not see metaclass-injected fields, so users may
190
+ duplicate the declaration in the class body. That must still produce exactly one
191
+ JSON-schema property and one SQLAlchemy column for Alembic autogenerate.
192
+ """
193
+
194
+ class TyShadowFkJobRole(Model):
195
+ id: Annotated[int | None, FerroField(primary_key=True)] = None
196
+ name: str
197
+ scorecards: Relation[list["TyShadowFkScorecard"]] = BackRef()
198
+
199
+ class TyShadowFkScorecard(Model):
200
+ id: Annotated[int | None, FerroField(primary_key=True)] = None
201
+ title: str
202
+ job_role: Annotated[TyShadowFkJobRole, ForeignKey(related_name="scorecards")]
203
+ job_role_id: int | None = None
204
+
205
+ assert list(TyShadowFkScorecard.model_fields).count("job_role_id") == 1
206
+
207
+ schema = build_model_schema(TyShadowFkScorecard)
208
+ props = schema["properties"]
209
+ assert "job_role_id" in props
210
+ assert sum(1 for k in props if k == "job_role_id") == 1
211
+ fk_meta = props["job_role_id"].get("foreign_key") or {}
212
+ assert fk_meta.get("to_table") == "tyshadowfkjobrole"
213
+
214
+ metadata = get_metadata()
215
+ tbl = metadata.tables["tyshadowfkscorecard"]
216
+ assert list(tbl.columns.keys()).count("job_role_id") == 1
217
+ col = tbl.c.job_role_id
218
+ fks = list(col.foreign_keys)
219
+ assert len(fks) == 1
220
+ assert fks[0].target_fullname == "tyshadowfkjobrole.id"
221
+
222
+
223
+ @pytest.mark.asyncio
224
+ @pytest.mark.backend_matrix
225
+ async def test_explicit_foreign_key_shadow_id_auto_migrate_roundtrip(db_url):
226
+ """Runtime migrate + ORM must treat explicit ``*_id`` as the single FK column."""
227
+
228
+ from ferro import connect
229
+
230
+ class TyRoundJobRole(Model):
231
+ id: Annotated[int | None, FerroField(primary_key=True)] = None
232
+ name: str
233
+ scorecards: Relation[list["TyRoundScorecard"]] = BackRef()
234
+
235
+ class TyRoundScorecard(Model):
236
+ id: Annotated[int | None, FerroField(primary_key=True)] = None
237
+ title: str
238
+ job_role: Annotated[TyRoundJobRole, ForeignKey(related_name="scorecards")]
239
+ job_role_id: int | None = None
240
+
241
+ await connect(db_url, auto_migrate=True)
242
+
243
+ role = await TyRoundJobRole.create(name="ic")
244
+ card = await TyRoundScorecard.create(title="card-a", job_role=role)
245
+ assert card.job_role_id == role.id
246
+
247
+ by_attr = await TyRoundScorecard.where(
248
+ TyRoundScorecard.job_role_id == role.id
249
+ ).first()
250
+ assert by_attr is not None and by_attr.id == card.id
251
+
252
+ by_lambda = await TyRoundScorecard.where(
253
+ lambda s: s.job_role_id == role.id
254
+ ).first()
255
+ assert by_lambda is not None and by_lambda.id == card.id
@@ -289,7 +289,7 @@ async def test_service_loaded_instance_delete_uses_origin_connection(tmp_path):
289
289
  await service_row.delete()
290
290
 
291
291
  assert await ConnectionRouteMarker.get(1) is not None
292
- assert await ConnectionRouteMarker.using("service").get(1) is None
292
+ assert await ConnectionRouteMarker.using("service").get_or_none(1) is None
293
293
 
294
294
 
295
295
  @pytest.mark.asyncio
@@ -1,7 +1,7 @@
1
1
  import pytest
2
2
  from pydantic import Field
3
3
  import ferro
4
- from ferro import Model
4
+ from ferro import Model, ModelDoesNotExist
5
5
 
6
6
  pytestmark = pytest.mark.backend_matrix
7
7
 
@@ -158,7 +158,7 @@ async def test_model_get_invalid_usage(db_url):
158
158
 
159
159
  @pytest.mark.asyncio
160
160
  async def test_model_get_not_found(db_url):
161
- """Test that get() returns None if the record does not exist."""
161
+ """Test that get() raises when the record does not exist."""
162
162
 
163
163
  class CrudUser(Model):
164
164
  id: int = Field(default=None, json_schema_extra={"primary_key": True})
@@ -166,5 +166,8 @@ async def test_model_get_not_found(db_url):
166
166
  email: str
167
167
 
168
168
  await ferro.connect(db_url, auto_migrate=True)
169
- user = await CrudUser.get(9999)
170
- assert user is None
169
+ with pytest.raises(ModelDoesNotExist) as exc_info:
170
+ await CrudUser.get(9999)
171
+ assert exc_info.value.model is CrudUser
172
+ assert exc_info.value.pk == 9999
173
+ assert await CrudUser.get_or_none(9999) is None
@@ -27,7 +27,7 @@ async def test_instance_delete(db_url):
27
27
  await user.delete()
28
28
 
29
29
  # Verify it's gone from DB
30
- assert await DeletableUser.get(user_id) is None
30
+ assert await DeletableUser.get_or_none(user_id) is None
31
31
 
32
32
  # Verify it's gone from Identity Map (fetching again should return None)
33
33
  # Note: the 'user' object still exists in Python memory, but it's disconnected from the DB.
@@ -84,4 +84,4 @@ async def test_delete_evicts_identity_map(db_url):
84
84
  await DeletableUser.where(DeletableUser.id == user_id).delete()
85
85
 
86
86
  # A fresh 'get' should NOT return the old 'user' object (it should be None)
87
- assert await DeletableUser.get(user_id) is None
87
+ assert await DeletableUser.get_or_none(user_id) is None
@@ -254,8 +254,7 @@ async def test_delete_method(db_url):
254
254
 
255
255
  await user.delete()
256
256
 
257
- fetched = await User.get(user_id)
258
- assert fetched is None
257
+ assert await User.get_or_none(user_id) is None
259
258
 
260
259
 
261
260
  @pytest.mark.asyncio
@@ -35,7 +35,8 @@ if TYPE_CHECKING:
35
35
  bound.where(NamedSmokeMarker.id == 1), # type: ignore[arg-type]
36
36
  "Query[NamedSmokeMarker]",
37
37
  )
38
- assert_type(await bound.get(1), NamedSmokeMarker | None)
38
+ assert_type(await bound.get(1), NamedSmokeMarker)
39
+ assert_type(await bound.get_or_none(1), NamedSmokeMarker | None)
39
40
  assert_type(await bound.bulk_create([]), int)
40
41
  assert_type(
41
42
  await bound.get_or_create(label="x"),
@@ -96,7 +97,7 @@ async def test_named_connections_smoke_matrix_sqlite(tmp_path):
96
97
 
97
98
  assert await delete_record("NamedSmokeMarker", "1", using="service") is True
98
99
  assert await NamedSmokeMarker.get(1) is app_row
99
- assert await NamedSmokeMarker.using("service").get(1) is None
100
+ assert await NamedSmokeMarker.using("service").get_or_none(1) is None
100
101
 
101
102
 
102
103
  @pytest.mark.asyncio
@@ -135,7 +135,7 @@ async def test_instance_methods_loaded_inside_transaction_inherit_transaction(db
135
135
 
136
136
  await loaded.delete()
137
137
 
138
- assert await TxInstanceMethodUser.get(1) is None
138
+ assert await TxInstanceMethodUser.get_or_none(1) is None
139
139
 
140
140
 
141
141
  @pytest.mark.asyncio
@@ -537,7 +537,7 @@ wheels = [
537
537
 
538
538
  [[package]]
539
539
  name = "ferro-orm"
540
- version = "0.7.0"
540
+ version = "0.8.0"
541
541
  source = { editable = "." }
542
542
  dependencies = [
543
543
  { name = "pydantic" },