ferro-orm 0.10.4__tar.gz → 0.11.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/AGENTS.md +44 -1
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/CHANGELOG.md +29 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/Cargo.lock +42 -43
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/Cargo.toml +1 -1
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/PKG-INFO +1 -1
- ferro_orm-0.11.0/docs/brainstorms/2026-05-25-annotated-strenum-cold-hydration-requirements.md +112 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/coming-soon.md +5 -30
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/guide/database.md +46 -1
- ferro_orm-0.11.0/docs/plans/2026-05-25-001-fix-annotated-strenum-cold-hydration-plan.md +228 -0
- ferro_orm-0.11.0/docs/solutions/issues/sqlite-integer-decimal-hydrates-as-none.md +87 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md +1 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/patterns/configurable-column-storage-types.md +8 -6
- ferro_orm-0.11.0/docs/solutions/patterns/ddl-on-live-engine.md +68 -0
- ferro_orm-0.11.0/docs/solutions/patterns/sqlite-alembic-reconnect-hydration-tests.md +58 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/patterns/typed-null-binds.md +13 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/pyproject.toml +1 -1
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/backend.rs +349 -11
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/connection.rs +26 -37
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/__init__.py +35 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/_core.pyi +34 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/metaclass.py +20 -1
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/models.py +19 -41
- ferro_orm-0.11.0/src/introspect.rs +334 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/lib.rs +35 -2
- ferro_orm-0.11.0/src/migrate.rs +1267 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/operations.rs +64 -68
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/query.rs +11 -15
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/schema.rs +338 -210
- ferro_orm-0.11.0/tests/test_auto_migrate.py +582 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_connection.py +7 -4
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_cross_emitter_parity.py +99 -3
- ferro_orm-0.11.0/tests/test_enum_cold_hydration.py +64 -0
- ferro_orm-0.11.0/tests/test_migrate_plan.py +225 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/uv.lock +1 -1
- ferro_orm-0.10.4/tests/test_auto_migrate.py +0 -200
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.github/PERMISSIONS.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.github/PYPI_CHECKLIST.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.github/PYPI_SETUP.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.github/generated/wheels.generated.yml +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.github/pull_request_template.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.github/workflows/ci.yml +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.github/workflows/packaging-smoke.yml +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.github/workflows/publish-docs.yml +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.github/workflows/publish.yml +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.github/workflows/release.yml +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.gitignore +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.pre-commit-config.yaml +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.python-version +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/CONTRIBUTING.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/LICENSE +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/README.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/TEST_RESULTS.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/api/exceptions.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/api/fields.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/api/model.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/api/query.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/api/raw-sql.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/api/relationships.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/api/transactions.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/api/utilities.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/brainstorms/2026-05-14-autogenerate-support-for-db-type-requirements.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/changelog.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/concepts/architecture.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/concepts/identity-map.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/concepts/performance.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/concepts/query-typing.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/concepts/type-safety.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/contributing.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/faq.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/getting-started/installation.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/getting-started/next-steps.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/getting-started/tutorial.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/guide/backend.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/guide/migrations.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/guide/models-and-fields.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/guide/mutations.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/guide/queries.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/guide/relationships.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/guide/transactions.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/howto/multiple-databases.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/howto/pagination.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/howto/soft-deletes.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/howto/testing.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/howto/timestamps.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/index.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/migration-sqlalchemy.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/plans/2026-05-13-001-feat-configurable-column-storage-types-plan.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/README.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/issues/typed-where-null-panics-is-null.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/patterns/foreign-key-index.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/stylesheets/extra.css +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/why-ferro.md +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/justfile +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/mkdocs.yml +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/scripts/demo_queries.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/_annotation_utils.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/_shadow_fk_types.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/base.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/composite_indexes.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/composite_uniques.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/exceptions.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/fields.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/migrations/__init__.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/migrations/alembic.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/py.typed +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/query/__init__.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/query/builder.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/query/nodes.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/raw.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/relations/__init__.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/relations/descriptors.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/schema_metadata.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/state.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/schema_bind.rs +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/state.rs +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/__init__.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/conftest.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/db_backends.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_aggregation.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_alembic_autogenerate.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_alembic_bridge.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_alembic_db_type.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_alembic_nullability.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_alembic_type_mapping.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_bulk_update.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_composite_index.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_composite_unique.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_connection_redaction.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_constraints.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_crud.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_db_backends.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_db_type_cross_emitter_parity.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_db_type_integration.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_db_type_typing.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_db_type_validation.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_deletion.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_docs_examples.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_documentation_features.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_field_wrapper.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_helpers.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_hydration.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_metaclass_internals.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_metadata.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_models.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_named_connections_integration.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_one_to_one.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_query_builder.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_query_typing.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_raw_sql.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_refresh.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_relationship_engine.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_schema.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_schema_constraints.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_schema_db_type_metadata.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_schema_enum_annotations.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_shadow_fk_types.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_sqlite_alembic_reconnect_hydration.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_static_contracts.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_string_search.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_structural_types.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_temporal_types.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_transactions.py +0 -0
- {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_typed_null_binds.py +0 -0
|
@@ -32,7 +32,7 @@ For a single model, every emitter must agree on:
|
|
|
32
32
|
includes the canonical `db_type` vocabulary (`text`, `varchar(N)`,
|
|
33
33
|
`smallint`, `int`, `bigint`, `uuid`, `timestamp`, `timestamptz`, `date`,
|
|
34
34
|
`time`) — duplicated in `_db_type_to_sa_type` (Python) and
|
|
35
|
-
`
|
|
35
|
+
`db_type_token_to_canonical` (Rust), pinned by
|
|
36
36
|
`tests/test_db_type_cross_emitter_parity.py`.
|
|
37
37
|
4. **Index names** — `idx_<table>_<col>` for single-column indexes,
|
|
38
38
|
`idx_<table>_<col1>_<col2>...` for composite indexes.
|
|
@@ -147,3 +147,46 @@ search this directory before starting work.
|
|
|
147
147
|
`docs/solutions/issues/` — debugging stories and known footguns.
|
|
148
148
|
|
|
149
149
|
See `docs/solutions/README.md` for the frontmatter conventions.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## I-6: No AI attribution in commits or PRs
|
|
154
|
+
|
|
155
|
+
Never sign commits or pull requests with AI/agent attribution. No
|
|
156
|
+
`Co-Authored-By: Claude ...` trailers, no "Generated with Claude Code"
|
|
157
|
+
footers, no robot emoji bylines — in commit messages, PR titles, or PR
|
|
158
|
+
bodies. This applies even when an agent's default behavior is to add them:
|
|
159
|
+
this rule overrides those defaults for this repository.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## I-7: No stop-gap solutions
|
|
164
|
+
|
|
165
|
+
Every feature, bug fix, and improvement must be designed as the best,
|
|
166
|
+
well-thought-out solution for the project with the library's future in
|
|
167
|
+
mind — as if time and money were no object. No stop-gaps, hacks,
|
|
168
|
+
quick-fixes, or otherwise lesser solves.
|
|
169
|
+
|
|
170
|
+
What this means in practice:
|
|
171
|
+
|
|
172
|
+
- **Prefer first-class, reusable primitives over local patches.** If a fix
|
|
173
|
+
only works for the immediate symptom while leaving the underlying
|
|
174
|
+
capability gap in place, build the capability instead. (Precedent:
|
|
175
|
+
`EngineHandle::refresh_pool()` was built as an engine-level schema-epoch
|
|
176
|
+
primitive rather than a migration-local statement-cache flush.)
|
|
177
|
+
- **Fail loudly over degrading silently.** "Skip with a warning and
|
|
178
|
+
continue", "best effort", and "documented residual risk" are not
|
|
179
|
+
acceptable resolutions for correctness gaps. Either the operation
|
|
180
|
+
succeeds completely or it aborts with a clear, actionable error.
|
|
181
|
+
- **Treat certain phrases as redesign triggers.** If a plan, comment, or PR
|
|
182
|
+
description contains "best-effort", "partial mitigation", "documented
|
|
183
|
+
residual risk", "good enough for now", "temporary workaround", or
|
|
184
|
+
"fallback if X turns out to be hard" — that part of the design is not
|
|
185
|
+
finished. Redesign it before presenting or implementing it.
|
|
186
|
+
- **Scoped-down is fine; hollowed-out is not.** Deliberately excluding
|
|
187
|
+
something from scope (with the boundary stated and a real path for the
|
|
188
|
+
excluded case, e.g. "renames are Alembic territory") is good design.
|
|
189
|
+
Shipping a half-working version of something that is *in* scope is not.
|
|
190
|
+
|
|
191
|
+
This rule binds human contributors and AI agents equally, and overrides any
|
|
192
|
+
agent default that biases toward minimal or expedient changes.
|
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.11.0 (2026-06-11)
|
|
5
|
+
|
|
6
|
+
### Chores
|
|
7
|
+
|
|
8
|
+
- Solution quality requirements
|
|
9
|
+
([`c62770b`](https://github.com/syn54x/ferro-orm/commit/c62770b6d3073e8d596c30f29895bccddc2a4d7f))
|
|
10
|
+
|
|
11
|
+
### Documentation
|
|
12
|
+
|
|
13
|
+
- Compound SQLite hydration learnings (#56, #58)
|
|
14
|
+
([#60](https://github.com/syn54x/ferro-orm/pull/60),
|
|
15
|
+
[`b609e72`](https://github.com/syn54x/ferro-orm/commit/b609e7274e1886cc5d8564c7adb52fc3c79ffc8d))
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
- Extend auto_migrate with column updates and destructive drops
|
|
20
|
+
([#69](https://github.com/syn54x/ferro-orm/pull/69),
|
|
21
|
+
[`a76cb0f`](https://github.com/syn54x/ferro-orm/commit/a76cb0f2451dab79303353f2c77d74ca3e1ad4a5))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
## v0.10.5 (2026-05-25)
|
|
25
|
+
|
|
26
|
+
### Bug Fixes
|
|
27
|
+
|
|
28
|
+
- Coerce Annotated StrEnum fields on cold hydration
|
|
29
|
+
([#66](https://github.com/syn54x/ferro-orm/pull/66),
|
|
30
|
+
[`c17c13b`](https://github.com/syn54x/ferro-orm/commit/c17c13b56e100028a42fa431ca59c9729a22daeb))
|
|
31
|
+
|
|
32
|
+
|
|
4
33
|
## v0.10.4 (2026-05-24)
|
|
5
34
|
|
|
6
35
|
### Bug Fixes
|
|
@@ -43,9 +43,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
|
|
43
43
|
|
|
44
44
|
[[package]]
|
|
45
45
|
name = "bitflags"
|
|
46
|
-
version = "2.
|
|
46
|
+
version = "2.13.0"
|
|
47
47
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
48
|
-
checksum = "
|
|
48
|
+
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
|
49
49
|
dependencies = [
|
|
50
50
|
"serde_core",
|
|
51
51
|
]
|
|
@@ -79,9 +79,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
|
|
79
79
|
|
|
80
80
|
[[package]]
|
|
81
81
|
name = "cc"
|
|
82
|
-
version = "1.2.
|
|
82
|
+
version = "1.2.63"
|
|
83
83
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
84
|
-
checksum = "
|
|
84
|
+
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
|
85
85
|
dependencies = [
|
|
86
86
|
"find-msvc-tools",
|
|
87
87
|
"shlex",
|
|
@@ -230,9 +230,9 @@ dependencies = [
|
|
|
230
230
|
|
|
231
231
|
[[package]]
|
|
232
232
|
name = "displaydoc"
|
|
233
|
-
version = "0.2.
|
|
233
|
+
version = "0.2.6"
|
|
234
234
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
235
|
-
checksum = "
|
|
235
|
+
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
|
236
236
|
dependencies = [
|
|
237
237
|
"proc-macro2",
|
|
238
238
|
"quote",
|
|
@@ -294,7 +294,7 @@ dependencies = [
|
|
|
294
294
|
|
|
295
295
|
[[package]]
|
|
296
296
|
name = "ferro"
|
|
297
|
-
version = "0.
|
|
297
|
+
version = "0.11.0"
|
|
298
298
|
dependencies = [
|
|
299
299
|
"dashmap",
|
|
300
300
|
"once_cell",
|
|
@@ -711,13 +711,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
|
|
711
711
|
|
|
712
712
|
[[package]]
|
|
713
713
|
name = "js-sys"
|
|
714
|
-
version = "0.3.
|
|
714
|
+
version = "0.3.100"
|
|
715
715
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
716
|
-
checksum = "
|
|
716
|
+
checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
|
|
717
717
|
dependencies = [
|
|
718
718
|
"cfg-if",
|
|
719
719
|
"futures-util",
|
|
720
|
-
"once_cell",
|
|
721
720
|
"wasm-bindgen",
|
|
722
721
|
]
|
|
723
722
|
|
|
@@ -750,14 +749,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
|
|
750
749
|
|
|
751
750
|
[[package]]
|
|
752
751
|
name = "libredox"
|
|
753
|
-
version = "0.1.
|
|
752
|
+
version = "0.1.17"
|
|
754
753
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
755
|
-
checksum = "
|
|
754
|
+
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
|
|
756
755
|
dependencies = [
|
|
757
756
|
"bitflags",
|
|
758
757
|
"libc",
|
|
759
758
|
"plain",
|
|
760
|
-
"redox_syscall 0.
|
|
759
|
+
"redox_syscall 0.8.1",
|
|
761
760
|
]
|
|
762
761
|
|
|
763
762
|
[[package]]
|
|
@@ -788,9 +787,9 @@ dependencies = [
|
|
|
788
787
|
|
|
789
788
|
[[package]]
|
|
790
789
|
name = "log"
|
|
791
|
-
version = "0.4.
|
|
790
|
+
version = "0.4.32"
|
|
792
791
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
793
|
-
checksum = "
|
|
792
|
+
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
|
794
793
|
|
|
795
794
|
[[package]]
|
|
796
795
|
name = "md-5"
|
|
@@ -804,9 +803,9 @@ dependencies = [
|
|
|
804
803
|
|
|
805
804
|
[[package]]
|
|
806
805
|
name = "memchr"
|
|
807
|
-
version = "2.8.
|
|
806
|
+
version = "2.8.1"
|
|
808
807
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
809
|
-
checksum = "
|
|
808
|
+
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
|
810
809
|
|
|
811
810
|
[[package]]
|
|
812
811
|
name = "memoffset"
|
|
@@ -819,9 +818,9 @@ dependencies = [
|
|
|
819
818
|
|
|
820
819
|
[[package]]
|
|
821
820
|
name = "mio"
|
|
822
|
-
version = "1.2.
|
|
821
|
+
version = "1.2.1"
|
|
823
822
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
824
|
-
checksum = "
|
|
823
|
+
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
|
825
824
|
dependencies = [
|
|
826
825
|
"libc",
|
|
827
826
|
"wasi",
|
|
@@ -1136,9 +1135,9 @@ dependencies = [
|
|
|
1136
1135
|
|
|
1137
1136
|
[[package]]
|
|
1138
1137
|
name = "redox_syscall"
|
|
1139
|
-
version = "0.
|
|
1138
|
+
version = "0.8.1"
|
|
1140
1139
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1141
|
-
checksum = "
|
|
1140
|
+
checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7"
|
|
1142
1141
|
dependencies = [
|
|
1143
1142
|
"bitflags",
|
|
1144
1143
|
]
|
|
@@ -1339,9 +1338,9 @@ dependencies = [
|
|
|
1339
1338
|
|
|
1340
1339
|
[[package]]
|
|
1341
1340
|
name = "shlex"
|
|
1342
|
-
version = "
|
|
1341
|
+
version = "2.0.1"
|
|
1343
1342
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1344
|
-
checksum = "
|
|
1343
|
+
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
|
1345
1344
|
|
|
1346
1345
|
[[package]]
|
|
1347
1346
|
name = "signal-hook-registry"
|
|
@@ -1380,9 +1379,9 @@ dependencies = [
|
|
|
1380
1379
|
|
|
1381
1380
|
[[package]]
|
|
1382
1381
|
name = "socket2"
|
|
1383
|
-
version = "0.6.
|
|
1382
|
+
version = "0.6.4"
|
|
1384
1383
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1385
|
-
checksum = "
|
|
1384
|
+
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
|
1386
1385
|
dependencies = [
|
|
1387
1386
|
"libc",
|
|
1388
1387
|
"windows-sys 0.61.2",
|
|
@@ -1770,9 +1769,9 @@ dependencies = [
|
|
|
1770
1769
|
|
|
1771
1770
|
[[package]]
|
|
1772
1771
|
name = "typenum"
|
|
1773
|
-
version = "1.20.
|
|
1772
|
+
version = "1.20.1"
|
|
1774
1773
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1775
|
-
checksum = "
|
|
1774
|
+
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
|
1776
1775
|
|
|
1777
1776
|
[[package]]
|
|
1778
1777
|
name = "unicode-bidi"
|
|
@@ -1839,9 +1838,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
|
|
1839
1838
|
|
|
1840
1839
|
[[package]]
|
|
1841
1840
|
name = "uuid"
|
|
1842
|
-
version = "1.23.
|
|
1841
|
+
version = "1.23.3"
|
|
1843
1842
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1844
|
-
checksum = "
|
|
1843
|
+
checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
|
|
1845
1844
|
dependencies = [
|
|
1846
1845
|
"getrandom 0.4.2",
|
|
1847
1846
|
"js-sys",
|
|
@@ -1892,9 +1891,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
|
|
1892
1891
|
|
|
1893
1892
|
[[package]]
|
|
1894
1893
|
name = "wasm-bindgen"
|
|
1895
|
-
version = "0.2.
|
|
1894
|
+
version = "0.2.123"
|
|
1896
1895
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1897
|
-
checksum = "
|
|
1896
|
+
checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
|
|
1898
1897
|
dependencies = [
|
|
1899
1898
|
"cfg-if",
|
|
1900
1899
|
"once_cell",
|
|
@@ -1905,9 +1904,9 @@ dependencies = [
|
|
|
1905
1904
|
|
|
1906
1905
|
[[package]]
|
|
1907
1906
|
name = "wasm-bindgen-macro"
|
|
1908
|
-
version = "0.2.
|
|
1907
|
+
version = "0.2.123"
|
|
1909
1908
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1910
|
-
checksum = "
|
|
1909
|
+
checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
|
|
1911
1910
|
dependencies = [
|
|
1912
1911
|
"quote",
|
|
1913
1912
|
"wasm-bindgen-macro-support",
|
|
@@ -1915,9 +1914,9 @@ dependencies = [
|
|
|
1915
1914
|
|
|
1916
1915
|
[[package]]
|
|
1917
1916
|
name = "wasm-bindgen-macro-support"
|
|
1918
|
-
version = "0.2.
|
|
1917
|
+
version = "0.2.123"
|
|
1919
1918
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1920
|
-
checksum = "
|
|
1919
|
+
checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
|
|
1921
1920
|
dependencies = [
|
|
1922
1921
|
"bumpalo",
|
|
1923
1922
|
"proc-macro2",
|
|
@@ -1928,9 +1927,9 @@ dependencies = [
|
|
|
1928
1927
|
|
|
1929
1928
|
[[package]]
|
|
1930
1929
|
name = "wasm-bindgen-shared"
|
|
1931
|
-
version = "0.2.
|
|
1930
|
+
version = "0.2.123"
|
|
1932
1931
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1933
|
-
checksum = "
|
|
1932
|
+
checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
|
|
1934
1933
|
dependencies = [
|
|
1935
1934
|
"unicode-ident",
|
|
1936
1935
|
]
|
|
@@ -2253,9 +2252,9 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
|
|
2253
2252
|
|
|
2254
2253
|
[[package]]
|
|
2255
2254
|
name = "yoke"
|
|
2256
|
-
version = "0.8.
|
|
2255
|
+
version = "0.8.3"
|
|
2257
2256
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
2258
|
-
checksum = "
|
|
2257
|
+
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
|
2259
2258
|
dependencies = [
|
|
2260
2259
|
"stable_deref_trait",
|
|
2261
2260
|
"yoke-derive",
|
|
@@ -2276,18 +2275,18 @@ dependencies = [
|
|
|
2276
2275
|
|
|
2277
2276
|
[[package]]
|
|
2278
2277
|
name = "zerocopy"
|
|
2279
|
-
version = "0.8.
|
|
2278
|
+
version = "0.8.52"
|
|
2280
2279
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
2281
|
-
checksum = "
|
|
2280
|
+
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
|
|
2282
2281
|
dependencies = [
|
|
2283
2282
|
"zerocopy-derive",
|
|
2284
2283
|
]
|
|
2285
2284
|
|
|
2286
2285
|
[[package]]
|
|
2287
2286
|
name = "zerocopy-derive"
|
|
2288
|
-
version = "0.8.
|
|
2287
|
+
version = "0.8.52"
|
|
2289
2288
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
2290
|
-
checksum = "
|
|
2289
|
+
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
|
|
2291
2290
|
dependencies = [
|
|
2292
2291
|
"proc-macro2",
|
|
2293
2292
|
"quote",
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
date: 2026-05-25
|
|
3
|
+
topic: annotated-strenum-cold-hydration
|
|
4
|
+
issue: https://github.com/syn54x/ferro-orm/issues/65
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Annotated StrEnum Cold Hydration
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
Register enum field types once at model class definition (using the same annotation-unwrapping logic as schema registration), then coerce string values from the database into enum members on every hydration path. This fixes cold fetches for `Annotated[StrEnum, FerroField(db_type="text")]` under PEP 563/649 deferred annotations without duplicating discovery logic in `_fix_types`.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Problem Frame
|
|
16
|
+
|
|
17
|
+
Ferro hydrates rows through a zero-copy Rust path that populates instance `__dict__` with raw column values. String-valued columns therefore arrive as `str`, not as the declared Python enum. A post-hydration pass (`Model._fix_types`) is responsible for coercing those strings into enum members.
|
|
18
|
+
|
|
19
|
+
That pass discovers enum fields lazily on first use. Discovery calls `get_type_hints` with the **ferro.models** module namespace, not the user model’s defining module. With `from __future__ import annotations` (common on Python 3.14+), deferred annotation strings never resolve in that namespace; the fallback reads `__annotations__`, which are still strings and do not match `isinstance(hint, type)`. Result: `_enum_fields` stays empty, coercion is skipped, and cold reads return plain `str` even though `__ferro_schema__` already records `enum_type_name` correctly.
|
|
20
|
+
|
|
21
|
+
`create()` in the same process often still yields enum instances because Pydantic validates on construction. The bug surfaces on **cold** `all()` / `get()` / query results after `reset_engine()` or a fresh connection — exactly when apps rely on `.value`, `match`/`case`, or strict `isinstance` checks.
|
|
22
|
+
|
|
23
|
+
Equality with the raw string (`instance.mode == "hourly"`) still works; the failure mode is **type fidelity** and enum APIs, not storage or SQL.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Requirements
|
|
28
|
+
|
|
29
|
+
**Enum registration (canonical, class-definition time)**
|
|
30
|
+
|
|
31
|
+
- R1. Each concrete `Model` subclass exposes a stable `_enum_fields: dict[str, type[Enum]]` populated during metaclass setup (Phase 3), not on first `_fix_types` call.
|
|
32
|
+
- R2. Registration uses the shared `_enum_subclass_from_annotation` helper already used when building `__ferro_schema__`, so `Annotated[...]`, optional unions, and plain enum annotations are handled identically for schema and hydration.
|
|
33
|
+
- R3. The source of truth for which fields are enums is Pydantic’s resolved `model_fields[<name>].annotation` (with `get_type_hints(cls, include_extras=True)` as fallback only when a field annotation is missing), never `get_type_hints` with ferro-internal `globals()` / `locals()`.
|
|
34
|
+
- R4. `_fix_types` performs **coercion only** against the pre-built `_enum_fields` map; it must not re-scan annotations or mutate discovery state on fetch.
|
|
35
|
+
|
|
36
|
+
**Hydration behavior**
|
|
37
|
+
|
|
38
|
+
- R5. After any Rust-backed fetch (`all`, `get`, `fetch_filtered`, query builder `first`/`all`, `ModelConnection.get_or_none`, etc.), every non-null enum column whose stored value is not already an instance of the registered enum class is coerced via `enum_cls(raw_value)` (preserving current tolerant failure behavior for invalid DB values).
|
|
39
|
+
- R6. Cold fetch after `reset_engine()` + reconnect must return enum members, not `str`, for models using `Annotated[StrEnum, FerroField(db_type="text")]` with PEP 563/649 deferred annotations.
|
|
40
|
+
- R7. Behavior for plain `StrEnum` fields (no `Annotated`), native Postgres enum columns, and `IntEnum` with integer storage remains unchanged — no regression in existing round-trip tests.
|
|
41
|
+
|
|
42
|
+
**Testing**
|
|
43
|
+
|
|
44
|
+
- R8. Add an integration regression test that defines a model with `from __future__ import annotations`, `Annotated[StrEnum, FerroField(db_type="text")]`, inserts via `create()`, calls `reset_engine()`, reconnects, fetches via `all()` (or `get`), and asserts `isinstance(field, EnumSubclass)` and `.value` access works.
|
|
45
|
+
- R9. Optionally assert `_enum_fields` is non-empty and includes the field name after class creation (unit-level guard against rediscovering the #65 failure mode).
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Acceptance Examples
|
|
50
|
+
|
|
51
|
+
- **AE1 — Cold fetch with deferred annotations**
|
|
52
|
+
Covers: R5, R6, R8
|
|
53
|
+
Given `from __future__ import annotations` and `billing_mode: Annotated[Mode, FerroField(db_type="text")]`, when a row is created with `Mode.HOURLY`, then `reset_engine()` and reconnect, then `Row.all()[0]`, then `type(row.billing_mode)` is `Mode` and `row.billing_mode.value == "hourly"`.
|
|
54
|
+
|
|
55
|
+
- **AE2 — Schema parity unchanged**
|
|
56
|
+
Covers: R2, R7
|
|
57
|
+
Given the same model class, `__ferro_schema__["properties"]["billing_mode"]["enum_type_name"]` remains `"mode"` (lowercased class name) before and after the fix.
|
|
58
|
+
|
|
59
|
+
- **AE3 — Invalid DB value tolerance**
|
|
60
|
+
Covers: R5
|
|
61
|
+
Given a text column containing a string that is not a valid enum member, when fetched, the instance field remains a non-enum value (or coercion fails silently as today) without raising during `_fix_types` — no change to defensive behavior unless separately specified.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Success Criteria
|
|
66
|
+
|
|
67
|
+
- Issue #65 reproduction script exits 0 on main after the fix.
|
|
68
|
+
- New regression test fails on current `main` without the fix and passes with it.
|
|
69
|
+
- No new phantom DDL or cross-emitter drift (hydration-only change).
|
|
70
|
+
- `_enum_fields` discovery logic exists in exactly one conceptual place (metaclass + shared helper), not duplicated in `_fix_types`.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Scope Boundaries
|
|
75
|
+
|
|
76
|
+
**In scope**
|
|
77
|
+
|
|
78
|
+
- Python-side enum registration and post-hydration coercion for all existing fetch entry points.
|
|
79
|
+
- Regression test under PEP 563/649 + `Annotated` + `db_type="text"`.
|
|
80
|
+
- Closes GitHub issue #65.
|
|
81
|
+
|
|
82
|
+
**Out of scope**
|
|
83
|
+
|
|
84
|
+
- Rust-side enum coercion during dict population (duplicate logic across FFI; defer unless profiling proves Python coercion is a bottleneck).
|
|
85
|
+
- Changing default storage away from native Postgres enums in Alembic (separate product decision; `db_type="text"` on StrEnum remains valid).
|
|
86
|
+
- Pydantic `model_validate` on hydration (violates direct-to-dict / zero-copy invariant I-2).
|
|
87
|
+
- Broader refactors of `_fix_types` for non-enum types (UUID, Decimal, etc.) unless already planned elsewhere.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Key Decisions
|
|
92
|
+
|
|
93
|
+
| Decision | Choice | Rationale |
|
|
94
|
+
|----------|--------|-----------|
|
|
95
|
+
| Where to register enums | Metaclass Phase 3 | Same lifecycle as `ferro_fields` and schema registration; immune to wrong `get_type_hints` namespaces. |
|
|
96
|
+
| Shared helper | Reuse `_enum_subclass_from_annotation` | Schema and hydration must agree on what counts as an enum field (AGENTS.md parity spirit). |
|
|
97
|
+
| `_fix_types` role | Coercion only | Eliminates lazy discovery bug class; cheaper on hot path. |
|
|
98
|
+
| Minimal patch alternative | Repair `_fix_types` discovery only | **Rejected as lesser solve** — fixes #65 but leaves two discovery paths and repeats failure modes for the next annotation shape. |
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Dependencies / Assumptions
|
|
103
|
+
|
|
104
|
+
- Pydantic v2 continues to resolve `model_fields[].annotation` to real enum types even when `__annotations__` on the class remain strings under PEP 563.
|
|
105
|
+
- `reset_engine()` remains a valid way to simulate cold identity-map clears in tests.
|
|
106
|
+
- Issue reporter environment (ferro 0.10.3, Python 3.14+, SQLite) matches current test matrix support.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Outstanding Questions
|
|
111
|
+
|
|
112
|
+
- None blocking implementation. If PEP 649-only models without `__annotate_func__` resolution in metaclass appear in the wild, confirm `_resolve_deferred_annotations` still leaves `model_fields` authoritative (spot-check in plan phase).
|
|
@@ -410,37 +410,12 @@ Document the exception hierarchy and import paths:
|
|
|
410
410
|
|
|
411
411
|
### Many-to-Many Join Table Creation
|
|
412
412
|
|
|
413
|
-
**Status:**
|
|
414
|
-
|
|
415
|
-
**Documentation References:**
|
|
416
|
-
- `docs/guide/relationships.md` (lines 176-289)
|
|
417
|
-
|
|
418
|
-
**Description:**
|
|
419
|
-
Many-to-many relationships are defined with `ManyToMany(...)`, but the join tables are not automatically created during `auto_migrate=True`.
|
|
420
|
-
|
|
421
|
-
**Example (Partially Working):**
|
|
422
|
-
```python
|
|
423
|
-
from typing import Annotated
|
|
424
|
-
|
|
425
|
-
from ferro import BackRef, Field, ManyToMany, Model, Relation
|
|
426
|
-
|
|
427
|
-
class Post(Model):
|
|
428
|
-
id: int | None = Field(default=None, primary_key=True)
|
|
429
|
-
tags: Relation[list["Tag"]] = ManyToMany(related_name="posts")
|
|
430
|
-
|
|
431
|
-
class Tag(Model):
|
|
432
|
-
id: int | None = Field(default=None, primary_key=True)
|
|
433
|
-
posts: Relation[list["Post"]] = BackRef()
|
|
434
|
-
|
|
435
|
-
# Models created, but join table 'post_tags' is NOT auto-created
|
|
436
|
-
# This causes errors when trying to use M2M methods:
|
|
437
|
-
await post.tags.add(tag) # RuntimeError: no such table: post_tags
|
|
438
|
-
```
|
|
439
|
-
|
|
440
|
-
**Workaround:**
|
|
441
|
-
Manual join table creation may be required, or use Alembic migrations. Further investigation needed.
|
|
413
|
+
**Status:** Implemented
|
|
442
414
|
|
|
443
|
-
|
|
415
|
+
Many-to-many join tables are registered alongside their models and created by
|
|
416
|
+
`auto_migrate=True` / `create_tables()` like any other table (they also
|
|
417
|
+
participate in `migrate_updates` diffing). Covered by
|
|
418
|
+
`tests/test_auto_migrate.py::test_m2m_join_table_created_during_auto_migrate`.
|
|
444
419
|
|
|
445
420
|
---
|
|
446
421
|
|
|
@@ -133,8 +133,53 @@ During development, automatically align the database schema with your models:
|
|
|
133
133
|
await ferro.connect("sqlite:dev.db?mode=rwc", auto_migrate=True)
|
|
134
134
|
```
|
|
135
135
|
|
|
136
|
+
`auto_migrate=True` creates missing tables (including many-to-many join tables) and never touches existing ones. Two opt-in flags extend it:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
await ferro.connect(
|
|
140
|
+
"sqlite:dev.db?mode=rwc",
|
|
141
|
+
auto_migrate=True,
|
|
142
|
+
migrate_updates=True, # update existing tables to match the models
|
|
143
|
+
migrate_destructive=True, # also drop columns removed from the models
|
|
144
|
+
)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The flags form a ladder — `migrate_destructive` implies `migrate_updates`, which implies `auto_migrate` — so passing just the strongest flag you want is enough.
|
|
148
|
+
|
|
149
|
+
#### Schema updates with `migrate_updates`
|
|
150
|
+
|
|
151
|
+
When a model gains fields between releases, `migrate_updates=True` reconciles the live table on connect. What it can do is capability-relative per backend:
|
|
152
|
+
|
|
153
|
+
| Change | SQLite | Postgres |
|
|
154
|
+
|---|---|---|
|
|
155
|
+
| Add missing column | ✅ `ADD COLUMN` | ✅ `ADD COLUMN` |
|
|
156
|
+
| Add missing column's index (`index=True`) | ✅ `CREATE INDEX` | ✅ `CREATE INDEX` |
|
|
157
|
+
| Add unique column (`unique=True`) | ✅ via explicit `uq_` unique index + warning | ✅ inline `UNIQUE` |
|
|
158
|
+
| Add foreign-key column | ✅ column only, no FK constraint + warning | ✅ column + FK constraint |
|
|
159
|
+
| Change column type | ⚠️ warning, no DDL (type affinity makes drift mostly cosmetic) | ✅ `ALTER COLUMN ... TYPE ... USING` cast |
|
|
160
|
+
| Change nullability | ⚠️ warning, no DDL | ✅ `SET NOT NULL` / `DROP NOT NULL` |
|
|
161
|
+
| Drop removed column (`migrate_destructive`) | ✅ dependency-aware | ✅ |
|
|
162
|
+
| Rename column/table, change primary key, drop table | ❌ never — [Alembic](migrations.md) territory | ❌ never |
|
|
163
|
+
|
|
164
|
+
Rules to know:
|
|
165
|
+
|
|
166
|
+
- **NOT NULL additions need a literal default.** Existing rows must be backfilled, so a new required field without a literal default fails the connect with a clear error. Make the field nullable, give it a default, or use Alembic. On Postgres the backfill default is dropped immediately after the add (a fresh `CREATE TABLE` carries no server default); SQLite cannot drop a column default, so it remains — harmless, and invisible to Alembic autogenerate's defaults.
|
|
167
|
+
- **Added columns reuse the exact `CREATE TABLE` DDL.** A database brought forward by `migrate_updates` is byte-identical to one created fresh, so `alembic revision --autogenerate` stays clean (this is pinned by the cross-emitter parity tests).
|
|
168
|
+
- **Destructive drops are dependency-aware and fail loudly.** Explicit indexes covering a dropped column are dropped first (they are orphaned anyway). Columns that are primary keys, enforced by UNIQUE/CHECK constraints, or referenced by other tables' foreign keys abort the migration with an error pointing at Alembic — nothing is skipped silently. Tables are never dropped.
|
|
169
|
+
- **Postgres native enum columns are left alone.** They only exist via Alembic, which remains their owner.
|
|
170
|
+
- **Postgres type changes take an exclusive lock** and fail the connect if existing data does not cast cleanly — acceptable for a development flag, but worth knowing.
|
|
171
|
+
- **The pool refreshes after any schema change.** No connection can serve a statement prepared against the pre-migration schema (on SQLite stale statements panic the sqlx worker and silently return zero rows; on Postgres they raise `cached plan must not change result type`), and identity-mapped instances hydrated before the migration are evicted so loads return fresh, complete objects.
|
|
172
|
+
|
|
173
|
+
The same pass can be run explicitly on a live connection instead of at connect time:
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
await ferro.migrate() # create + update (default)
|
|
177
|
+
await ferro.migrate(destructive=True) # also drop removed columns
|
|
178
|
+
await ferro.migrate(using="service") # against a named connection
|
|
179
|
+
```
|
|
180
|
+
|
|
136
181
|
!!! danger "Production Warning"
|
|
137
|
-
`auto_migrate=True`
|
|
182
|
+
`auto_migrate=True` and its extension flags are intended for development and local-first apps whose schema is still moving. For production, use [Alembic migrations](migrations.md) — renames, primary-key changes, and data transforms are deliberately out of auto-migrate's scope.
|
|
138
183
|
|
|
139
184
|
## Manual Table Creation
|
|
140
185
|
|