ferro-orm 0.16.2__tar.gz → 0.16.3__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 (370) hide show
  1. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/CHANGELOG.md +16 -0
  2. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/CONTEXT.md +12 -0
  3. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/Cargo.lock +5 -5
  4. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/Cargo.toml +1 -1
  5. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/PKG-INFO +1 -1
  6. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/pyproject.toml +1 -1
  7. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/__init__.py +25 -30
  8. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/_shadow_fk_types.py +5 -6
  9. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/columns.py +30 -2
  10. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/ir/compiler.py +35 -40
  11. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/metaclass.py +7 -6
  12. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/models.py +32 -27
  13. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/query/builder.py +17 -82
  14. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/query/wire.py +85 -28
  15. ferro_orm-0.16.3/src/ferro/registry.py +328 -0
  16. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/relations/__init__.py +19 -25
  17. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/relations/descriptors.py +13 -10
  18. ferro_orm-0.16.3/src/ferro/state.py +176 -0
  19. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/conftest.py +8 -29
  20. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_alembic_autogenerate.py +6 -12
  21. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_alembic_bridge.py +2 -4
  22. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_alembic_db_type.py +3 -9
  23. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_alembic_nullability.py +2 -4
  24. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_alembic_type_mapping.py +2 -4
  25. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_auto_migrate.py +25 -46
  26. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_catalog_cache.py +2 -4
  27. ferro_orm-0.16.3/tests/test_compiled_query.py +81 -0
  28. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_composite_index.py +3 -7
  29. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_composite_unique.py +3 -7
  30. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_connection.py +2 -2
  31. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_cross_emitter_parity.py +5 -11
  32. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_db_type_cross_emitter_parity.py +3 -11
  33. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_db_type_integration.py +4 -6
  34. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_documentation_features.py +4 -4
  35. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_enum_cold_hydration.py +6 -6
  36. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_fk_target_pk.py +6 -14
  37. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_generation_counter_dirty_tracking.py +16 -14
  38. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_hydration_equivalence.py +3 -7
  39. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_include.py +1 -1
  40. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_ir_vectors_contract.py +13 -10
  41. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_model_identity.py +13 -25
  42. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_mutation_pagination_guard.py +1 -1
  43. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_named_connections_integration.py +2 -2
  44. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_naming_single_source.py +8 -12
  45. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_one_to_one.py +2 -3
  46. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_operation_seam_sync.py +3 -3
  47. ferro_orm-0.16.3/tests/test_primary_key_facts.py +77 -0
  48. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_provisional_import.py +9 -9
  49. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_query_builder.py +3 -3
  50. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_query_joins.py +2 -2
  51. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_query_typing.py +2 -3
  52. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_query_wire_vectors.py +5 -5
  53. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_registry_entrypoints.py +69 -74
  54. ferro_orm-0.16.3/tests/test_registry_owner.py +119 -0
  55. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_relationship_engine.py +2 -3
  56. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_relationship_resolution_errors.py +3 -7
  57. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_session.py +2 -2
  58. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_shadow_fk_types.py +4 -6
  59. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_sqlite_alembic_reconnect_hydration.py +2 -4
  60. ferro_orm-0.16.2/src/ferro/state.py +0 -355
  61. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  62. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  63. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/.github/PERMISSIONS.md +0 -0
  64. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/.github/PYPI_CHECKLIST.md +0 -0
  65. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/.github/PYPI_SETUP.md +0 -0
  66. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/.github/generated/wheels.generated.yml +0 -0
  67. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/.github/pull_request_template.md +0 -0
  68. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/.github/workflows/ci.yml +0 -0
  69. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/.github/workflows/packaging-smoke.yml +0 -0
  70. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/.github/workflows/publish-docs.yml +0 -0
  71. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/.github/workflows/publish.yml +0 -0
  72. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/.github/workflows/release.yml +0 -0
  73. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/.gitignore +0 -0
  74. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/.pre-commit-config.yaml +0 -0
  75. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/.python-version +0 -0
  76. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/AGENTS.md +0 -0
  77. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/CONTRIBUTING.md +0 -0
  78. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/LICENSE +0 -0
  79. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/README.md +0 -0
  80. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/benchmarks/README.md +0 -0
  81. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/benchmarks/__init__.py +0 -0
  82. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/benchmarks/__main__.py +0 -0
  83. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/benchmarks/backends.py +0 -0
  84. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/benchmarks/baselines/postgres.json +0 -0
  85. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/benchmarks/baselines/sqlite.json +0 -0
  86. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/benchmarks/compare.py +0 -0
  87. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/benchmarks/harness.py +0 -0
  88. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/benchmarks/model.py +0 -0
  89. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/benchmarks/run.py +0 -0
  90. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/crates/ferro-ddl-lowering/Cargo.toml +0 -0
  91. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/crates/ferro-ddl-lowering/src/lib.rs +0 -0
  92. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/crates/ferro-migrate/Cargo.toml +0 -0
  93. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/crates/ferro-migrate/src/emit.rs +0 -0
  94. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/crates/ferro-migrate/src/lib.rs +0 -0
  95. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/crates/ferro-migrate/src/order.rs +0 -0
  96. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/crates/ferro-migrate/src/tests.rs +0 -0
  97. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/crates/ferro-schema-ir/Cargo.toml +0 -0
  98. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/crates/ferro-schema-ir/src/lib.rs +0 -0
  99. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/adr/0001-resolved-epoch-registration.md +0 -0
  100. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/adr/0002-unified-column-fact-derivation.md +0 -0
  101. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/adr/0003-path-blind-db-type-validation.md +0 -0
  102. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/adr/0004-jsonb-postgres-only-canonical.md +0 -0
  103. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/adr/0005-jsonb-default-on-postgres.md +0 -0
  104. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/adr/0006-relation-traversal-inner-joins.md +0 -0
  105. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/adr/0007-materialization-plan-complete-instances.md +0 -0
  106. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/adr/0008-populated-relations-include.md +0 -0
  107. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/adr/0009-aggregate-projections-derived-grouping.md +0 -0
  108. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/agents/domain.md +0 -0
  109. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/agents/issue-tracker.md +0 -0
  110. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/agents/triage-labels.md +0 -0
  111. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/architecture-review-2026-07-08.html +0 -0
  112. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
  113. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
  114. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md +0 -0
  115. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/brainstorms/2026-05-14-autogenerate-support-for-db-type-requirements.md +0 -0
  116. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/brainstorms/2026-05-25-annotated-strenum-cold-hydration-requirements.md +0 -0
  117. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/brainstorms/2026-06-24-ir-p8-119-wire-automigrate-requirements.md +0 -0
  118. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/brainstorms/2026-06-25-ir-p8-120-parity-gate-requirements.md +0 -0
  119. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/brainstorms/2026-06-26-ir-p8.5-140-shared-lowering-requirements.md +0 -0
  120. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/brainstorms/2026-06-26-ir-p8.5-143-db-type-drop-requirements.md +0 -0
  121. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/brainstorms/2026-06-26-ir-p8.5-144-reconcile-indexes-requirements.md +0 -0
  122. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/brainstorms/2026-06-28-ir-p8.5-141-single-schema-ir-producer-requirements.md +0 -0
  123. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/brainstorms/2026-06-29-ir-p8.6-146-dialect-enum-unification-requirements.md +0 -0
  124. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/brainstorms/2026-06-29-ir-p8.6-153-create-path-ir-requirements.md +0 -0
  125. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/brainstorms/2026-06-30-ir-p8.6-155-deferred-annotations-requirements.md +0 -0
  126. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/brainstorms/2026-06-30-ir-p8.6-158-check-renderer-requirements.md +0 -0
  127. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/brainstorms/2026-07-01-ir-p8.6-154-datetime-tz-coarseness-requirements.md +0 -0
  128. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/brainstorms/2026-07-01-ir-p8.6-162-typed-save-bind-requirements.md +0 -0
  129. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/brainstorms/2026-07-01-ir-p8.6-165-blob-introspection-false-positive-requirements.md +0 -0
  130. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/brainstorms/2026-07-02-ff-c1-codec-plan-design.md +0 -0
  131. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/brainstorms/2026-07-06-upgrade-guide-consolidation-requirements.md +0 -0
  132. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/examples/aggregations.py +0 -0
  133. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/examples/aggregations_annotated.py +0 -0
  134. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/examples/multiple_databases.py +0 -0
  135. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/examples/mutations.py +0 -0
  136. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/examples/pagination.py +0 -0
  137. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/examples/partial_selects.py +0 -0
  138. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/examples/partial_selects_annotated.py +0 -0
  139. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/examples/populated_relations.py +0 -0
  140. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/examples/populated_relations_annotated.py +0 -0
  141. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/examples/predicates.py +0 -0
  142. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/examples/predicates_annotated.py +0 -0
  143. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/examples/quickstart.py +0 -0
  144. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/examples/quickstart_annotated.py +0 -0
  145. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/examples/raw_sql.py +0 -0
  146. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/examples/relationships.py +0 -0
  147. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/examples/relationships_annotated.py +0 -0
  148. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/examples/soft_deletes.py +0 -0
  149. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/examples/soft_deletes_annotated.py +0 -0
  150. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/examples/testing_conftest.py +0 -0
  151. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/examples/timestamps.py +0 -0
  152. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/examples/timestamps_annotated.py +0 -0
  153. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/examples/transactions.py +0 -0
  154. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/examples/traversal.py +0 -0
  155. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/examples/traversal_annotated.py +0 -0
  156. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/api/connection.md +0 -0
  157. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/api/exceptions.md +0 -0
  158. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/api/fields.md +0 -0
  159. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/api/migrations.md +0 -0
  160. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/api/model.md +0 -0
  161. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/api/queries.md +0 -0
  162. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/api/raw-sql.md +0 -0
  163. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/api/relationships.md +0 -0
  164. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/api/transactions.md +0 -0
  165. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/changelog.md +0 -0
  166. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/concepts/architecture.md +0 -0
  167. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/concepts/backends.md +0 -0
  168. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/concepts/identity-map.md +0 -0
  169. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/concepts/performance.md +0 -0
  170. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/concepts/query-typing.md +0 -0
  171. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/concepts/type-safety.md +0 -0
  172. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/contributing.md +0 -0
  173. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/faq.md +0 -0
  174. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/getting-started/installation.md +0 -0
  175. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/getting-started/next-steps.md +0 -0
  176. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/getting-started/quickstart.md +0 -0
  177. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/guide/aggregations.md +0 -0
  178. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/guide/connections.md +0 -0
  179. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/guide/migrations.md +0 -0
  180. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/guide/models-and-fields.md +0 -0
  181. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/guide/mutations.md +0 -0
  182. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/guide/queries.md +0 -0
  183. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/guide/raw-sql.md +0 -0
  184. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/guide/relationships.md +0 -0
  185. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/guide/transactions.md +0 -0
  186. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/howto/migrate-from-sqlalchemy.md +0 -0
  187. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/howto/multiple-databases.md +0 -0
  188. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/howto/pagination.md +0 -0
  189. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/howto/soft-deletes.md +0 -0
  190. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/howto/testing.md +0 -0
  191. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/howto/timestamps.md +0 -0
  192. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/howto/upgrade-guide.md +0 -0
  193. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/index.md +0 -0
  194. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/roadmap.md +0 -0
  195. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/stylesheets/extra.css +0 -0
  196. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/pages/why-ferro.md +0 -0
  197. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
  198. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
  199. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
  200. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
  201. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
  202. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-05-13-001-feat-configurable-column-storage-types-plan.md +0 -0
  203. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-05-25-001-fix-annotated-strenum-cold-hydration-plan.md +0 -0
  204. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-06-19-001-ir-first-roadmap.md +0 -0
  205. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-06-24-001-feat-ir-p8-119-wire-automigrate-plan.md +0 -0
  206. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-06-25-001-feat-ir-p8-120-parity-gate-plan.md +0 -0
  207. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-06-26-001-feat-ir-p8.5-140-shared-lowering-plan.md +0 -0
  208. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-06-26-002-feat-ir-p8.5-143-db-type-drop-plan.md +0 -0
  209. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-06-26-003-feat-ir-p8.5-144-reconcile-indexes-plan.md +0 -0
  210. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-06-28-001-feat-ir-p8.5-141-single-schema-ir-producer-plan.md +0 -0
  211. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-06-29-001-refactor-ir-p8.6-153-create-path-unification-plan.md +0 -0
  212. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-06-29-002-refactor-ir-p8.6-146-dialect-enum-unification-plan.md +0 -0
  213. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-06-30-001-refactor-ir-p8.6-158-check-renderer-plan.md +0 -0
  214. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-06-30-002-fix-ir-p8.6-155-deferred-annotations-plan.md +0 -0
  215. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-07-01-001-feat-ir-p8.6-162-typed-save-bind-plan.md +0 -0
  216. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-07-01-002-fix-ir-p8.6-154-datetime-tz-coarseness-plan.md +0 -0
  217. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-07-01-003-fix-ir-p8.6-165-blob-introspection-plan.md +0 -0
  218. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-07-02-001-fable-fixes-roadmap.md +0 -0
  219. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-07-02-002-feat-ff-a-172-typed-exceptions-plan.md +0 -0
  220. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-07-02-003-ff-c-native-decode-design.md +0 -0
  221. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-07-02-004-ff-c-c2-catalog-cache-design.md +0 -0
  222. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-07-02-005-ff-d-identity-routing-design.md +0 -0
  223. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-07-02-006-ff-d-identity-routing-plan.md +0 -0
  224. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-07-03-007-ff-e-registry-identity-design.md +0 -0
  225. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-07-03-008-ff-e-registry-identity-plan.md +0 -0
  226. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-07-06-009-ff-f-query-builder-1.0-design.md +0 -0
  227. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-07-06-010-ff-f-query-builder-1.0-plan.md +0 -0
  228. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-07-06-011-ff-g-hardening-design.md +0 -0
  229. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-07-06-012-ff-g-hardening-plan.md +0 -0
  230. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-07-06-013-ff-g2-operations-dedup-design.md +0 -0
  231. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-07-06-014-ff-g2-operations-dedup-plan.md +0 -0
  232. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/2026-07-06-015-docs-upgrade-guide-consolidation-plan.md +0 -0
  233. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/ir-first-migration-guide.md +0 -0
  234. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/plans/ir-first-release-checklist.md +0 -0
  235. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/rfc/ir-contracts-v1.md +0 -0
  236. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/solutions/README.md +0 -0
  237. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/solutions/architecture-patterns/ir-first-lowering-consolidation-audit.md +0 -0
  238. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/solutions/architecture-patterns/ir-first-merge-readiness-review.md +0 -0
  239. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md +0 -0
  240. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
  241. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
  242. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
  243. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/solutions/issues/self-fk-evicts-component-from-create-order.md +0 -0
  244. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/solutions/issues/sqlite-integer-decimal-hydrates-as-none.md +0 -0
  245. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md +0 -0
  246. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/solutions/issues/typed-where-null-panics-is-null.md +0 -0
  247. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/solutions/patterns/configurable-column-storage-types.md +0 -0
  248. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
  249. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/solutions/patterns/ddl-on-live-engine.md +0 -0
  250. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/solutions/patterns/derived-type-and-naming-decision-table.md +0 -0
  251. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/solutions/patterns/foreign-key-index.md +0 -0
  252. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
  253. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/solutions/patterns/ir-invariants.md +0 -0
  254. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/solutions/patterns/persistence-state.md +0 -0
  255. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
  256. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/solutions/patterns/sqlite-alembic-reconnect-hydration-tests.md +0 -0
  257. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/docs/solutions/patterns/typed-null-binds.md +0 -0
  258. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/justfile +0 -0
  259. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/scripts/demo_queries.py +0 -0
  260. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/scripts/demo_queries_pre_v012.py +0 -0
  261. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/backend.rs +0 -0
  262. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/codec.rs +0 -0
  263. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/codec_plan.rs +0 -0
  264. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/connection.rs +0 -0
  265. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/errors.rs +0 -0
  266. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/_annotation_utils.py +0 -0
  267. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/_bind_payload.py +0 -0
  268. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/_core.pyi +0 -0
  269. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/_deprecations.py +0 -0
  270. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/base.py +0 -0
  271. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/composite_indexes.py +0 -0
  272. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/composite_uniques.py +0 -0
  273. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/exceptions.py +0 -0
  274. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/fields.py +0 -0
  275. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/ir/__init__.py +0 -0
  276. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/migrations/__init__.py +0 -0
  277. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/migrations/alembic.py +0 -0
  278. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/py.typed +0 -0
  279. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/query/__init__.py +0 -0
  280. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/query/nodes.py +0 -0
  281. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/query/rows.py +0 -0
  282. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/raw.py +0 -0
  283. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/ferro/session.py +0 -0
  284. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/hydration.rs +0 -0
  285. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/introspect.rs +0 -0
  286. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/lib.rs +0 -0
  287. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/migrate.rs +0 -0
  288. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/naming_ffi.rs +0 -0
  289. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/operations.rs +0 -0
  290. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/query.rs +0 -0
  291. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/schema.rs +0 -0
  292. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/schema_bind.rs +0 -0
  293. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/src/state.rs +0 -0
  294. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/__init__.py +0 -0
  295. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/db_backends.py +0 -0
  296. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/fixtures/ir_vectors/README.md +0 -0
  297. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/fixtures/ir_vectors/codec_registry_core_v1.json +0 -0
  298. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/fixtures/ir_vectors/query_transaction_aggregate_v5.json +0 -0
  299. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/fixtures/ir_vectors/query_transaction_global_aggregate_v5.json +0 -0
  300. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/fixtures/ir_vectors/query_transaction_include_v5.json +0 -0
  301. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/fixtures/ir_vectors/query_transaction_left_join_v5.json +0 -0
  302. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/fixtures/ir_vectors/query_transaction_record_v5.json +0 -0
  303. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/fixtures/ir_vectors/query_transaction_traversal_v5.json +0 -0
  304. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/fixtures/ir_vectors/query_transaction_traversed_record_v5.json +0 -0
  305. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/fixtures/ir_vectors/query_user_compound_v5.json +0 -0
  306. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/fixtures/ir_vectors/schema_invoice_baseline_v1.json +0 -0
  307. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/fixtures/ir_vectors/schema_phase1_fixture_models_v1.json +0 -0
  308. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/fixtures/ir_vectors/schema_raw_str_pk_autoincrement_v1.json +0 -0
  309. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/static_fixtures/bad_includes.py +0 -0
  310. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/static_fixtures/bad_predicates.py +0 -0
  311. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/static_fixtures/bad_projections.py +0 -0
  312. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/static_fixtures/good_includes.py +0 -0
  313. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/static_fixtures/good_predicates.py +0 -0
  314. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/static_fixtures/good_projections.py +0 -0
  315. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_aggregation.py +0 -0
  316. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_annotation_utils.py +0 -0
  317. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_bulk_create_chunking.py +0 -0
  318. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_bulk_install.py +0 -0
  319. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_bulk_update.py +0 -0
  320. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_codec_plan.py +0 -0
  321. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_column_specs.py +0 -0
  322. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_connection_redaction.py +0 -0
  323. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_constraints.py +0 -0
  324. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_crud.py +0 -0
  325. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_db_backends.py +0 -0
  326. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_db_type_typing.py +0 -0
  327. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_db_type_validation.py +0 -0
  328. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_deletion.py +0 -0
  329. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_deprecations.py +0 -0
  330. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_docs_examples.py +0 -0
  331. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_exception_mapping.py +0 -0
  332. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_exceptions.py +0 -0
  333. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_field_wrapper.py +0 -0
  334. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_global_aggregates.py +0 -0
  335. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_grouped_aggregates.py +0 -0
  336. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_helpers.py +0 -0
  337. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_hydration.py +0 -0
  338. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_identity_memory.py +0 -0
  339. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_identity_refresh.py +0 -0
  340. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_identity_scoped_invalidation.py +0 -0
  341. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_identity_weakref.py +0 -0
  342. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_import_budget.py +0 -0
  343. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_metaclass_internals.py +0 -0
  344. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_metadata.py +0 -0
  345. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_migrate_plan.py +0 -0
  346. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_models.py +0 -0
  347. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_partial_selects.py +0 -0
  348. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_query_column_validation.py +0 -0
  349. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_query_immutability.py +0 -0
  350. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_raw_sql.py +0 -0
  351. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_refresh.py +0 -0
  352. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_relation_specs.py +0 -0
  353. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_route_single_site.py +0 -0
  354. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_routing_errors.py +0 -0
  355. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_save_pk_edges.py +0 -0
  356. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_save_semantics.py +0 -0
  357. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_schema.py +0 -0
  358. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_schema_constraints.py +0 -0
  359. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_schema_db_type_metadata.py +0 -0
  360. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_schema_enum_annotations.py +0 -0
  361. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_static_contracts.py +0 -0
  362. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_string_search.py +0 -0
  363. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_structural_types.py +0 -0
  364. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_temporal_types.py +0 -0
  365. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_transactions.py +0 -0
  366. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_traversed_projection.py +0 -0
  367. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_typed_null_binds.py +0 -0
  368. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/tests/test_typed_save_bind.py +0 -0
  369. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/uv.lock +0 -0
  370. {ferro_orm-0.16.2 → ferro_orm-0.16.3}/zensical.toml +0 -0
@@ -1,6 +1,22 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.16.3 (2026-07-16)
5
+
6
+ ### Bug Fixes
7
+
8
+ - Derive the primary-key fact once and reject multi-PK models at class definition
9
+ ([`ef91fa0`](https://github.com/syn54x/ferro-orm/commit/ef91fa09e09d5e7444e8d659b831921d40bcb365))
10
+
11
+ ### Refactoring
12
+
13
+ - Compile_query returns one CompiledQuery artifact carrying the hop-class map
14
+ ([`fc7084c`](https://github.com/syn54x/ferro-orm/commit/fc7084c4ad89b135815f74852263989f23868143))
15
+
16
+ - Single Registry owner for Python-side registration state
17
+ ([`1179fcc`](https://github.com/syn54x/ferro-orm/commit/1179fcc07cc9e4305b7e7a8c141b8b29264dcd26))
18
+
19
+
4
20
  ## v0.16.2 (2026-07-15)
5
21
 
6
22
  ### Bug Fixes
@@ -84,6 +84,10 @@ _Avoid_: Select list, projection spec, hydration mode flag
84
84
  The single typed wire artifact a query ships to the Rust runtime — model identity, predicates, ordering, paging, joins, and exactly one materialization plan, inside a versioned envelope. Compiled only by `compile_query`; no other code assembles query wire shape.
85
85
  _Avoid_: Query dict, query def, payload dict
86
86
 
87
+ **Compiled query**:
88
+ The single artifact `compile_query` returns: the QueryIR payload, its wire JSON, and the plan-scoped hop-class map, all views of one compile. The map is collected from the hop facts the payload itself carries, so wire and hop classes can never disagree; it is `None` unless the materialization plan decodes or hydrates through a hop model's class (mirroring the Rust `needs_hop_classes` guard — a both-sides double-check). No other code assembles hop classes for the FFI.
89
+ _Avoid_: payload + kwargs, hop-class side-channel, wire tuple
90
+
87
91
  **Golden vector**:
88
92
  A hand-authored JSON fixture pinning one wire shape — the independent authority both the Python emitter and the Rust decoder assert against, never regenerated from either side's output. Updating one by hand is the contract-review moment for a wire change.
89
93
  _Avoid_: Snapshot, fixture dump, example payload
@@ -100,6 +104,14 @@ _Avoid_: Nested select, join column, related-field pull
100
104
  A user-chosen name for one field of a projected record, given as the key in a dict-returning selector (`select(lambda t: {"account_name": t.account.name})`). Aliases name output fields only — never joins or tables; the relation path remains the sole join identity.
101
105
  _Avoid_: Column alias, AS label, join alias
102
106
 
107
+ **Primary key fact**:
108
+ The single cached answer to "which column is this model's primary key" — derived once at the compile choke point alongside column specs, stored as `__ferro_pk__` (`None` for a PK-less model). At most one `primary_key=True` column may be declared; a violation raises at class definition time. Operations that need a PK read the cached fact and raise clearly when it is `None` — never guess.
109
+ _Avoid_: PK lookup, PK scan, first primary-key column
110
+
111
+ **Registry**:
112
+ The single owner of Python-side registration state (`ferro.registry.REGISTRY`): model classes, per-model SchemaIR envelopes + fingerprints, join-table bundles, pending relations, the modelset artifact, and the generation counters. Its stores are private; the agreement invariants (fingerprint pairs its envelope, join-table eviction clears envelopes, one-call test reset) live inside the interface, not in caller convention. Routing/session state is not the Registry — that is `ferro.state`.
113
+ _Avoid_: state module, global dicts, model cache
114
+
103
115
  **Provisional registration**:
104
116
  The per-model state installed when a class body finishes executing — enough for runtime codec and PK metadata, but relationships may still be pending and the modelset is not yet authoritative for DDL.
105
117
  _Avoid_: Import-time registration, partial registry
@@ -59,9 +59,9 @@ dependencies = [
59
59
 
60
60
  [[package]]
61
61
  name = "bitflags"
62
- version = "2.13.0"
62
+ version = "2.13.1"
63
63
  source = "registry+https://github.com/rust-lang/crates.io-index"
64
- checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
64
+ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
65
65
  dependencies = [
66
66
  "serde_core",
67
67
  ]
@@ -327,7 +327,7 @@ dependencies = [
327
327
 
328
328
  [[package]]
329
329
  name = "ferro"
330
- version = "0.16.2"
330
+ version = "0.16.3"
331
331
  dependencies = [
332
332
  "chrono",
333
333
  "dashmap",
@@ -1905,9 +1905,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
1905
1905
 
1906
1906
  [[package]]
1907
1907
  name = "uuid"
1908
- version = "1.23.5"
1908
+ version = "1.24.0"
1909
1909
  source = "registry+https://github.com/rust-lang/crates.io-index"
1910
- checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a"
1910
+ checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
1911
1911
  dependencies = [
1912
1912
  "getrandom 0.4.3",
1913
1913
  "js-sys",
@@ -3,7 +3,7 @@ members = ["crates/ferro-schema-ir", "crates/ferro-ddl-lowering", "crates/ferro-
3
3
 
4
4
  [package]
5
5
  name = "ferro"
6
- version = "0.16.2"
6
+ version = "0.16.3"
7
7
  edition = "2024"
8
8
  readme = "README.md"
9
9
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ferro-orm
3
- Version: 0.16.2
3
+ Version: 0.16.3
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'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ferro-orm"
3
- version = "0.16.2"
3
+ version = "0.16.3"
4
4
  description = "A high-performance, Rust-backed ORM for Python."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -73,13 +73,13 @@ async def _ensure_rust_registration_synced_for_operation() -> None:
73
73
  contention the event loop blocks for one resolve+install per generation; do
74
74
  not offload this to a thread pool — that would reintroduce interleaving.
75
75
  """
76
- from .state import is_modelset_dirty
76
+ from .registry import REGISTRY
77
77
 
78
- if not is_modelset_dirty():
78
+ if not REGISTRY.is_dirty():
79
79
  return
80
80
 
81
81
  with _OPERATION_REGISTRATION_SYNC_LOCK:
82
- if is_modelset_dirty():
82
+ if REGISTRY.is_dirty():
83
83
  _ensure_rust_registration_synced()
84
84
 
85
85
 
@@ -97,25 +97,26 @@ def ensure_resolved_modelset() -> dict[str, Any]:
97
97
  path.
98
98
  """
99
99
  from .ir.compiler import compile_registry_schema_ir
100
+ from .registry import REGISTRY
100
101
  from .relations import resolve_relationships
101
- from .state import is_modelset_dirty
102
102
 
103
- if not is_modelset_dirty():
103
+ if not REGISTRY.is_dirty():
104
104
  return compile_registry_schema_ir()
105
105
 
106
106
  with _RESOLVED_MODELSET_LOCK:
107
- if is_modelset_dirty():
107
+ if REGISTRY.is_dirty():
108
108
  resolve_relationships()
109
109
  return compile_registry_schema_ir()
110
110
 
111
111
 
112
112
  def _ensure_rust_registration_synced() -> None:
113
113
  """Resolve the modelset if needed, then push it to the Rust runtime."""
114
- from . import state as ferro_state
114
+ from .registry import REGISTRY
115
115
 
116
116
  envelope = ensure_resolved_modelset()
117
+ cached = REGISTRY.modelset()
117
118
  _push_registration_to_rust(
118
- envelope, fingerprint=ferro_state._SCHEMA_IR_MODELSET_FINGERPRINT
119
+ envelope, fingerprint=cached[1] if cached is not None else None
119
120
  )
120
121
 
121
122
 
@@ -131,24 +132,21 @@ def clear_registry() -> None:
131
132
  tolerated by SQLite but rejected by Postgres (``relation ... does not
132
133
  exist``). (#153)
133
134
 
134
- The Python model registry (``_MODEL_REGISTRY_PY``) is intentionally **not**
135
- cleared here: clearing the Rust registry while keeping the declared Python
136
- models is what allows cold re-hydration after ``reset_engine`` (see
137
- ``tests/test_enum_cold_hydration.py``). Callers that want a full Python-side
138
- reset clear ``_MODEL_REGISTRY_PY`` / ``_PENDING_RELATIONS`` themselves.
139
-
140
- Each purged join table's compiled SchemaIR envelope is also evicted from the
141
- per-model envelope cache (via ``evict_model_envelope`` — envelope-only, so the
142
- model registry stays intact per the promise above), keeping the two stores the
143
- #153 guard depends on in agreement: a lingering join envelope would let a
144
- future assemble step resurrect the stale join table.
135
+ The Python model registry is intentionally **not** cleared here: clearing
136
+ the Rust registry while keeping the declared Python models is what allows
137
+ cold re-hydration after ``reset_engine`` (see
138
+ ``tests/test_enum_cold_hydration.py``). Callers that want a full
139
+ Python-side reset use ``REGISTRY.reset_for_test()``.
140
+
141
+ Each purged join table's compiled SchemaIR envelope is evicted with it —
142
+ ``Registry.clear_join_tables`` owns that agreement (#153): a lingering
143
+ join envelope would let a future assemble step resurrect the stale join
144
+ table.
145
145
  """
146
146
  _core_clear_registry()
147
- from .state import _JOIN_TABLE_REGISTRY, evict_model_envelope
147
+ from .registry import REGISTRY
148
148
 
149
- for join_table in list(_JOIN_TABLE_REGISTRY):
150
- evict_model_envelope(join_table)
151
- _JOIN_TABLE_REGISTRY.clear()
149
+ REGISTRY.clear_join_tables()
152
150
 
153
151
 
154
152
  def _push_registration_to_rust(
@@ -172,18 +170,15 @@ def _push_registration_to_rust(
172
170
  """
173
171
  import json as _json
174
172
 
175
- from . import state as ferro_state
176
173
  from .ir.compiler import compile_registry_schema_ir, schema_ir_fingerprint
174
+ from .registry import REGISTRY
177
175
 
178
176
  if envelope is None:
179
177
  envelope = compile_registry_schema_ir()
180
178
  if fingerprint is None:
181
- cached = ferro_state._SCHEMA_IR_MODELSET
182
- if (
183
- cached is envelope
184
- and ferro_state._SCHEMA_IR_MODELSET_FINGERPRINT is not None
185
- ):
186
- fingerprint = ferro_state._SCHEMA_IR_MODELSET_FINGERPRINT
179
+ cached = REGISTRY.modelset()
180
+ if cached is not None and cached[0] is envelope:
181
+ fingerprint = cached[1]
187
182
  else:
188
183
  fingerprint = schema_ir_fingerprint(envelope)
189
184
  _install_registration(_json.dumps(envelope), fingerprint)
@@ -27,13 +27,12 @@ def _scalar_part_of_annotation(ann: Any) -> Any:
27
27
 
28
28
  def pk_python_type_for_model(target: type[Any]) -> Any | None:
29
29
  """Return the PK column's scalar python type from the target's specs."""
30
- specs = getattr(target, "__ferro_columns__", None)
31
- if not specs:
30
+ pk_field = getattr(target, "__ferro_pk__", None)
31
+ if pk_field is None:
32
32
  return None
33
- for spec in specs.values():
34
- if spec.primary_key:
35
- return spec.python_type
36
- return None
33
+ specs = getattr(target, "__ferro_columns__", None) or {}
34
+ spec = specs.get(pk_field)
35
+ return spec.python_type if spec is not None else None
37
36
 
38
37
 
39
38
  def shadow_annotation_for_pk(pk_ann: Any) -> Any:
@@ -31,9 +31,37 @@ __all__ = [
31
31
  "build_relation_specs",
32
32
  "fk_shadow_spec",
33
33
  "pk_spec",
34
+ "primary_key_field_name",
34
35
  ]
35
36
 
36
37
 
38
+ def primary_key_field_name(model_name: str, specs: dict[str, ColumnSpec]) -> str | None:
39
+ """Derive the model's single PK field name from its column specs.
40
+
41
+ The one derivation site for "which column is the primary key" — cached on
42
+ the class as ``__ferro_pk__`` at the compile choke point, so every
43
+ consumer (save, descriptors, shadow-FK typing, traversal) reads the cached
44
+ fact instead of re-looping specs. At most one ``primary_key=True`` column
45
+ is legal; zero is a valid declaration (``None``) and the operations that
46
+ need a PK raise their own errors.
47
+
48
+ Raises:
49
+ TypeError: If more than one column declares ``primary_key=True`` —
50
+ at class definition time, naming the model and offending fields,
51
+ rather than resolving the ambiguity silently by declaration order
52
+ (the follow-up ADR-0002 asked for). ``TypeError`` is the
53
+ declaration-error contract the metaclass surfaces unwrapped
54
+ (ADR-0003's db_type validation sets the precedent).
55
+ """
56
+ pks = [name for name, spec in specs.items() if spec.primary_key]
57
+ if len(pks) > 1:
58
+ raise TypeError(
59
+ f"Model {model_name!r} declares multiple primary-key columns: "
60
+ f"{sorted(pks)!r}. At most one primary_key=True column is supported."
61
+ )
62
+ return pks[0] if pks else None
63
+
64
+
37
65
  @dataclass(frozen=True, slots=True)
38
66
  class ForeignKeyRef:
39
67
  to_table: str
@@ -126,13 +154,13 @@ def _property_is_integer(prop: dict[str, Any]) -> bool:
126
154
 
127
155
 
128
156
  def _target_table_name(target: Any) -> str:
129
- from .state import resolve_model_reference
157
+ from .registry import REGISTRY
130
158
 
131
159
  if isinstance(target, ForwardRef):
132
160
  target = target.__forward_arg__
133
161
  if isinstance(target, str):
134
162
  try:
135
- model = resolve_model_reference(target, default=None)
163
+ model = REGISTRY.resolve_reference(target, default=None)
136
164
  except RuntimeError:
137
165
  # Ambiguous short ref during first-pass schema build: defer the
138
166
  # loud, candidate-listing error to resolve_relationships (the
@@ -10,7 +10,6 @@ from __future__ import annotations
10
10
  from collections.abc import Sequence
11
11
  from typing import Any
12
12
 
13
- from .. import state as ferro_state
14
13
  from .._core import (
15
14
  _ddl_check_constraint_name,
16
15
  _ddl_composite_index_name,
@@ -19,14 +18,15 @@ from .._core import (
19
18
  _ddl_single_index_name,
20
19
  _ddl_single_unique_name,
21
20
  )
22
- from ..columns import ColumnSpec, build_column_specs, build_relation_specs
21
+ from ..columns import (
22
+ ColumnSpec,
23
+ build_column_specs,
24
+ build_relation_specs,
25
+ primary_key_field_name,
26
+ )
23
27
  from ..composite_indexes import drop_overlap_with_uniques, normalized_composite_indexes
24
28
  from ..composite_uniques import normalized_composite_uniques
25
- from ..state import (
26
- _JOIN_TABLE_REGISTRY,
27
- _MODEL_REGISTRY_PY,
28
- _SCHEMA_IR_BY_MODEL,
29
- )
29
+ from ..registry import REGISTRY, ir_fingerprint
30
30
 
31
31
  _IR_VERSION = 1
32
32
 
@@ -214,11 +214,11 @@ def compile_schema_ir_payload(
214
214
  def _persist_schema_ir_envelope(model_name: str, envelope: dict[str, Any]) -> None:
215
215
  """Store one model's compiled SchemaIR envelope and fingerprint.
216
216
 
217
- Routes through the single ``ferro.state`` envelope-write entrypoint so every
218
- per-model store mutation lives at the store-owning layer (#243); the
219
- fingerprint is derived from the envelope inside that entrypoint.
217
+ Routes through the Registry's envelope-write entrypoint so every per-model
218
+ store mutation lives at the store-owning layer (#243); the fingerprint is
219
+ derived from the envelope inside that entrypoint.
220
220
  """
221
- ferro_state.persist_model_envelope(model_name, envelope)
221
+ REGISTRY.persist_envelope(model_name, envelope)
222
222
 
223
223
 
224
224
  def _compile_and_persist_model_envelope(
@@ -300,6 +300,10 @@ def compile_model_schema_ir(
300
300
  """
301
301
  if specs is None:
302
302
  specs = build_column_specs(model_cls)
303
+ # Derive the PK fact (and run the at-most-one guard) before compile +
304
+ # persist: a multi-PK model raises here, at class definition time, and
305
+ # never persists an envelope or publishes specs.
306
+ pk_field = primary_key_field_name(model_name, specs)
303
307
  column_names = frozenset(specs)
304
308
  uniques = normalized_composite_uniques(model_cls, column_names)
305
309
  indexes = drop_overlap_with_uniques(
@@ -318,6 +322,9 @@ def compile_model_schema_ir(
318
322
  # "``__ferro_columns__`` can never go stale relative to the envelope"
319
323
  # invariant, made atomic.
320
324
  model_cls.__ferro_columns__ = specs
325
+ # Same choke point caches the PK fact: every ``__ferro_columns__`` refresh
326
+ # re-derives ``__ferro_pk__``, so it can never go stale relative to specs.
327
+ model_cls.__ferro_pk__ = pk_field
321
328
  # Same choke point compiles relation-traversal facts (#268): every
322
329
  # ``__ferro_columns__`` refresh (provisional class-body registration and
323
330
  # the resolved second pass) refreshes ``__ferro_relation_specs__`` too, so
@@ -329,12 +336,12 @@ def compile_model_schema_ir(
329
336
  def _model_payload_from_envelope(name: str) -> dict[str, Any]:
330
337
  """Return one model's SchemaIR payload object from the envelope cache.
331
338
 
332
- The returned dict is a live reference into ``_SCHEMA_IR_BY_MODEL`` — the
333
- assembled modelset shares these payload objects. Treat them as read-only;
334
- in-place mutation corrupts the per-model cache and survives clean-path
335
- re-assembles indefinitely (#245).
339
+ The returned dict is a live reference into the Registry's envelope cache —
340
+ the assembled modelset shares these payload objects. Treat them as
341
+ read-only; in-place mutation corrupts the per-model cache and survives
342
+ clean-path re-assembles indefinitely (#245).
336
343
  """
337
- envelope = _SCHEMA_IR_BY_MODEL.get(name)
344
+ envelope = REGISTRY.envelope(name)
338
345
  if envelope is None:
339
346
  raise RuntimeError(
340
347
  f"Missing SchemaIR envelope for '{name}'. "
@@ -347,11 +354,11 @@ def _model_payload_from_envelope(name: str) -> dict[str, Any]:
347
354
  def _assemble_modelset_envelope() -> dict[str, Any]:
348
355
  """Stitch per-model envelopes into one sorted modelset envelope."""
349
356
  models: list[dict[str, Any]] = []
350
- for model_name, _model_cls in sorted(_MODEL_REGISTRY_PY.items(), key=lambda item: item[0]):
357
+ for model_name in sorted(REGISTRY.models()):
351
358
  models.append(_model_payload_from_envelope(model_name))
352
359
 
353
360
  for table_name, bundle in sorted(
354
- _JOIN_TABLE_REGISTRY.items(), key=lambda item: item[0]
361
+ REGISTRY.join_tables().items(), key=lambda item: item[0]
355
362
  ):
356
363
  if not isinstance(bundle, dict):
357
364
  continue
@@ -366,22 +373,10 @@ def _assemble_modelset_envelope() -> dict[str, Any]:
366
373
  },
367
374
  }
368
375
 
369
- ferro_state._SCHEMA_IR_MODELSET = envelope
370
- ferro_state._SCHEMA_IR_MODELSET_FINGERPRINT = ferro_state.ir_fingerprint(envelope)
376
+ REGISTRY.set_modelset(envelope)
371
377
  return envelope
372
378
 
373
379
 
374
- def _missing_registry_envelope_names() -> list[str]:
375
- missing: list[str] = []
376
- for name in _MODEL_REGISTRY_PY:
377
- if name not in _SCHEMA_IR_BY_MODEL:
378
- missing.append(name)
379
- for table_name, bundle in _JOIN_TABLE_REGISTRY.items():
380
- if isinstance(bundle, dict) and table_name not in _SCHEMA_IR_BY_MODEL:
381
- missing.append(table_name)
382
- return missing
383
-
384
-
385
380
  def _register_join_table_bundle(table_name: str, bundle: dict[str, Any]) -> None:
386
381
  register_model_with_ir(
387
382
  table_name,
@@ -393,12 +388,12 @@ def _register_join_table_bundle(table_name: str, bundle: dict[str, Any]) -> None
393
388
 
394
389
 
395
390
  def _recompile_missing_registry_envelopes() -> None:
396
- for model_name in _missing_registry_envelope_names():
397
- model_cls = _MODEL_REGISTRY_PY.get(model_name)
391
+ for model_name in REGISTRY.missing_envelope_names():
392
+ model_cls = REGISTRY.models().get(model_name)
398
393
  if model_cls is not None:
399
394
  compile_model_schema_ir(model_name, model_cls)
400
395
  continue
401
- bundle = _JOIN_TABLE_REGISTRY.get(model_name)
396
+ bundle = REGISTRY.join_tables().get(model_name)
402
397
  if isinstance(bundle, dict):
403
398
  _register_join_table_bundle(model_name, bundle)
404
399
 
@@ -411,10 +406,10 @@ def _recompile_all_registered_envelopes() -> None:
411
406
  Alembic-adjacent tooling) historically relied on compile-not-assemble here.
412
407
  The connect/reconnect clean path never hits this (#245).
413
408
  """
414
- for model_name, model_cls in sorted(_MODEL_REGISTRY_PY.items(), key=lambda item: item[0]):
409
+ for model_name, model_cls in sorted(REGISTRY.models().items()):
415
410
  compile_model_schema_ir(model_name, model_cls)
416
411
  for table_name, bundle in sorted(
417
- _JOIN_TABLE_REGISTRY.items(), key=lambda item: item[0]
412
+ REGISTRY.join_tables().items(), key=lambda item: item[0]
418
413
  ):
419
414
  if isinstance(bundle, dict):
420
415
  _register_join_table_bundle(table_name, bundle)
@@ -423,8 +418,8 @@ def _recompile_all_registered_envelopes() -> None:
423
418
  def compile_registry_schema_ir() -> dict[str, Any]:
424
419
  """Assemble and persist a deterministic SchemaIR envelope for all models.
425
420
 
426
- Stitches already-compiled per-model envelopes from ``_SCHEMA_IR_BY_MODEL``
427
- for every entry in ``_MODEL_REGISTRY_PY`` and ``_JOIN_TABLE_REGISTRY``.
421
+ Stitches already-compiled per-model envelopes from the Registry's envelope
422
+ cache for every registered model and join table.
428
423
  On a dirty registry, recompiles envelopes first so direct callers that
429
424
  bypass ``resolve_relationships()`` still observe current registry state.
430
425
  After ``ensure_resolved_modelset()`` on the clean path, this is
@@ -433,7 +428,7 @@ def compile_registry_schema_ir() -> dict[str, Any]:
433
428
  Returns:
434
429
  The assembled model-set SchemaIR envelope, sorted by model name.
435
430
  """
436
- if ferro_state.is_modelset_dirty():
431
+ if REGISTRY.is_dirty():
437
432
  _recompile_all_registered_envelopes()
438
433
  else:
439
434
  _recompile_missing_registry_envelopes()
@@ -442,4 +437,4 @@ def compile_registry_schema_ir() -> dict[str, Any]:
442
437
 
443
438
  def schema_ir_fingerprint(ir_envelope: dict[str, Any]) -> str:
444
439
  """Return a deterministic SHA-256 fingerprint for a SchemaIR envelope."""
445
- return ferro_state.ir_fingerprint(ir_envelope)
440
+ return ir_fingerprint(ir_envelope)
@@ -23,8 +23,8 @@ from .base import FerroField, ForeignKey, ManyToManyRelation
23
23
  from .fields import FERRO_FIELD_EXTRA_KEY
24
24
  from .ir import compile_model_schema_ir
25
25
  from .query import Relation
26
+ from .registry import REGISTRY
26
27
  from .relations.descriptors import ForwardDescriptor
27
- from .state import _MODEL_REGISTRY_PY, _PENDING_RELATIONS, register_model
28
28
 
29
29
  _TABLE_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*\Z")
30
30
 
@@ -79,7 +79,7 @@ class ModelMetaclass(type(BaseModel)):
79
79
  cls.__ferro_identity__ = f"{cls.__module__}.{cls.__qualname__}"
80
80
  cls.__ferro_table__ = mcs._resolve_table_name(name, namespace)
81
81
  for field_name, metadata in pending_relations:
82
- _PENDING_RELATIONS.append((cls.__ferro_identity__, field_name, metadata))
82
+ REGISTRY.defer_relation(cls.__ferro_identity__, field_name, metadata)
83
83
 
84
84
  mcs._register_model(cls, cls.__ferro_identity__, local_relations)
85
85
  ferro_fields = mcs._parse_ferro_field_metadata(cls)
@@ -278,8 +278,9 @@ class ModelMetaclass(type(BaseModel)):
278
278
  Returns:
279
279
  (local_relations, fields_to_remove, pending_relations): Relationship
280
280
  metadata, fields to hide from Pydantic, and ``(field_name, metadata)``
281
- entries deferred to ``_PENDING_RELATIONS`` (flushed under the model's
282
- resolved ``__ferro_identity__`` after the class object exists).
281
+ entries deferred to the Registry's pending-relation queue (flushed
282
+ under the model's resolved ``__ferro_identity__`` after the class
283
+ object exists).
283
284
  """
284
285
  local_relations = {}
285
286
  fields_to_remove = []
@@ -405,7 +406,7 @@ class ModelMetaclass(type(BaseModel)):
405
406
  qualified identity is idempotent.
406
407
  """
407
408
  table_name = cls.__ferro_table__
408
- for key, other in _MODEL_REGISTRY_PY.items():
409
+ for key, other in REGISTRY.models().items():
409
410
  if key == identity:
410
411
  continue
411
412
  if getattr(other, "__ferro_table__", None) == table_name:
@@ -415,7 +416,7 @@ class ModelMetaclass(type(BaseModel)):
415
416
  "models cannot share a table. Set __ferro_table__ on one of "
416
417
  "them to give it a distinct table name."
417
418
  )
418
- register_model(cls)
419
+ REGISTRY.register(cls)
419
420
  cls.ferro_relations = local_relations
420
421
 
421
422
  @staticmethod
@@ -126,9 +126,9 @@ def evict_instance(
126
126
  directly with their already-resolved route instead of going through this
127
127
  wrapper, so a route is never resolved twice for one operation.
128
128
  """
129
- from .state import resolve_model_reference
129
+ from .registry import REGISTRY
130
130
 
131
- model_cls = resolve_model_reference(model) if isinstance(model, str) else model
131
+ model_cls = REGISTRY.resolve_reference(model) if isinstance(model, str) else model
132
132
  route = resolve_operation_scope(using=using, session=session)
133
133
  _core_evict_instance(model_cls.__ferro_identity__, pk, route)
134
134
 
@@ -211,6 +211,9 @@ class Model(BaseModel, metaclass=ModelMetaclass):
211
211
  __ferro_composite_uniques__: ClassVar[tuple[tuple[str, ...], ...]] = ()
212
212
  __ferro_composite_indexes__: ClassVar[tuple[tuple[str, ...], ...]] = ()
213
213
  __ferro_columns__: ClassVar[dict[str, "ColumnSpec"]] = {}
214
+ #: The single PK field name, derived once at the compile choke point
215
+ #: alongside ``__ferro_columns__``; ``None`` for a PK-less model.
216
+ __ferro_pk__: ClassVar[str | None] = None
214
217
  __ferro_relation_specs__: ClassVar[dict[str, "RelationSpec"]] = {}
215
218
  _enum_fields: ClassVar[dict[str, type[Enum]]] = {}
216
219
 
@@ -247,9 +250,17 @@ class Model(BaseModel, metaclass=ModelMetaclass):
247
250
  if isinstance(val, Model):
248
251
  # Read the *target* model's PK (FF-D D5) — the source
249
252
  # model's PK name is irrelevant to the related instance.
250
- pk_field = val.__class__._primary_key_field_name() or "id"
251
- id_val = getattr(val, pk_field, None)
252
- data[f"{field_name}_id"] = id_val
253
+ pk_field = val.__class__.__ferro_pk__
254
+ if pk_field is None:
255
+ raise ValueError(
256
+ f"Cannot assign a {val.__class__.__name__} instance "
257
+ f"to relationship field {field_name!r}: "
258
+ f"{val.__class__.__name__} declares no primary-key "
259
+ "column, so there is no value to store in "
260
+ f"{field_name!r}_id. Pass the scalar value directly "
261
+ "or declare a primary_key=True column."
262
+ )
263
+ data[f"{field_name}_id"] = getattr(val, pk_field, None)
253
264
  else:
254
265
  # It's already an ID or something else
255
266
  data[f"{field_name}_id"] = val
@@ -323,7 +334,7 @@ class Model(BaseModel, metaclass=ModelMetaclass):
323
334
  mode="upsert",
324
335
  )
325
336
  elif _is_persisted(self):
326
- pk_field_name = self.__class__._primary_key_field_name()
337
+ pk_field_name = self.__class__.__ferro_pk__
327
338
  pk_val = getattr(self, pk_field_name) if pk_field_name is not None else None
328
339
  if pk_val is None:
329
340
  raise ValueError(
@@ -346,16 +357,17 @@ class Model(BaseModel, metaclass=ModelMetaclass):
346
357
  )
347
358
 
348
359
  pk_val = None
349
- pk_field_name = None
350
-
351
- for field_name, spec in self.__class__.__ferro_columns__.items():
352
- if not spec.primary_key:
353
- continue
354
- pk_field_name = field_name
355
- if spec.autoincrement and getattr(self, field_name) is None and new_id is not None:
356
- setattr(self, field_name, new_id)
357
- pk_val = getattr(self, field_name)
358
- break
360
+ pk_field_name = self.__class__.__ferro_pk__
361
+
362
+ if pk_field_name is not None:
363
+ spec = self.__class__.__ferro_columns__[pk_field_name]
364
+ if (
365
+ spec.autoincrement
366
+ and getattr(self, pk_field_name) is None
367
+ and new_id is not None
368
+ ):
369
+ setattr(self, pk_field_name, new_id)
370
+ pk_val = getattr(self, pk_field_name)
359
371
 
360
372
  if pk_val is not None:
361
373
  register_instance(
@@ -380,7 +392,7 @@ class Model(BaseModel, metaclass=ModelMetaclass):
380
392
  >>> if user:
381
393
  ... await user.delete()
382
394
  """
383
- pk_field_name = self.__class__._primary_key_field_name()
395
+ pk_field_name = self.__class__.__ferro_pk__
384
396
  pk_val = getattr(self, pk_field_name) if pk_field_name is not None else None
385
397
  route, _identity_using = await _instance_transaction_route(self, using, session)
386
398
 
@@ -394,13 +406,6 @@ class Model(BaseModel, metaclass=ModelMetaclass):
394
406
  # The instance is transient again: a later save() re-INSERTs.
395
407
  _set_persisted(self, False)
396
408
 
397
- @classmethod
398
- def _primary_key_field_name(cls) -> str | None:
399
- for field_name, spec in cls.__ferro_columns__.items():
400
- if spec.primary_key:
401
- return field_name
402
- return None
403
-
404
409
  @classmethod
405
410
  async def all(
406
411
  cls, *, using: str | None = None, session: "Session | None" = None
@@ -454,7 +459,7 @@ class Model(BaseModel, metaclass=ModelMetaclass):
454
459
  Returns:
455
460
  The matching model instance, or None when no record exists.
456
461
  """
457
- pk_field_name = cls._primary_key_field_name()
462
+ pk_field_name = cls.__ferro_pk__
458
463
  if pk_field_name is None:
459
464
  raise RuntimeError(f"Model {cls.__name__} does not define a primary key")
460
465
 
@@ -475,7 +480,7 @@ class Model(BaseModel, metaclass=ModelMetaclass):
475
480
  >>> user = await User.get(1)
476
481
  >>> await user.refresh()
477
482
  """
478
- pk_field_name = self.__class__._primary_key_field_name()
483
+ pk_field_name = self.__class__.__ferro_pk__
479
484
  pk_val = getattr(self, pk_field_name) if pk_field_name is not None else None
480
485
 
481
486
  if pk_val is None:
@@ -772,7 +777,7 @@ class ModelConnection[M: Model]:
772
777
  return instance
773
778
 
774
779
  async def get_or_none(self, pk: Any) -> M | None:
775
- pk_field_name = self.model_cls._primary_key_field_name()
780
+ pk_field_name = self.model_cls.__ferro_pk__
776
781
  if pk_field_name is None:
777
782
  raise RuntimeError(
778
783
  f"Model {self.model_cls.__name__} does not define a primary key"