ferro-orm 0.9.2__tar.gz → 0.10.1__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.9.2 → ferro_orm-0.10.1}/AGENTS.md +9 -2
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/CHANGELOG.md +24 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/Cargo.lock +3 -3
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/Cargo.toml +1 -1
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/PKG-INFO +1 -1
- ferro_orm-0.10.1/docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md +128 -0
- ferro_orm-0.10.1/docs/brainstorms/2026-05-14-autogenerate-support-for-db-type-requirements.md +116 -0
- ferro_orm-0.10.1/docs/plans/2026-05-13-001-feat-configurable-column-storage-types-plan.md +409 -0
- ferro_orm-0.10.1/docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md +92 -0
- ferro_orm-0.10.1/docs/solutions/patterns/configurable-column-storage-types.md +189 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/solutions/patterns/cross-emitter-ddl-parity.md +10 -2
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/solutions/patterns/shadow-fk-columns.md +3 -1
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/solutions/patterns/typed-null-binds.md +30 -2
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/pyproject.toml +5 -1
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/backend.rs +94 -15
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/ferro/__init__.py +4 -1
- ferro_orm-0.10.1/src/ferro/_annotation_utils.py +185 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/ferro/_core.pyi +9 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/ferro/base.py +44 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/ferro/fields.py +19 -1
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/ferro/metaclass.py +75 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/ferro/migrations/alembic.py +74 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/ferro/schema_metadata.py +6 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/lib.rs +1 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/operations.rs +11 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/schema.rs +422 -21
- ferro_orm-0.10.1/tests/test_alembic_db_type.py +254 -0
- ferro_orm-0.10.1/tests/test_db_type_cross_emitter_parity.py +274 -0
- ferro_orm-0.10.1/tests/test_db_type_integration.py +383 -0
- ferro_orm-0.10.1/tests/test_db_type_typing.py +34 -0
- ferro_orm-0.10.1/tests/test_db_type_validation.py +282 -0
- ferro_orm-0.10.1/tests/test_schema_db_type_metadata.py +84 -0
- ferro_orm-0.10.1/tests/test_sqlite_null_hydration.py +71 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/uv.lock +21 -1
- ferro_orm-0.9.2/src/ferro/_annotation_utils.py +0 -28
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/.github/PERMISSIONS.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/.github/PYPI_CHECKLIST.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/.github/PYPI_SETUP.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/.github/generated/wheels.generated.yml +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/.github/pull_request_template.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/.github/workflows/ci.yml +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/.github/workflows/packaging-smoke.yml +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/.github/workflows/publish-docs.yml +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/.github/workflows/publish.yml +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/.github/workflows/release.yml +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/.gitignore +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/.pre-commit-config.yaml +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/.python-version +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/CONTRIBUTING.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/LICENSE +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/README.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/TEST_RESULTS.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/api/exceptions.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/api/fields.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/api/model.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/api/query.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/api/raw-sql.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/api/relationships.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/api/transactions.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/api/utilities.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/changelog.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/coming-soon.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/concepts/architecture.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/concepts/identity-map.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/concepts/performance.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/concepts/query-typing.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/concepts/type-safety.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/contributing.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/faq.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/getting-started/installation.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/getting-started/next-steps.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/getting-started/tutorial.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/guide/backend.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/guide/database.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/guide/migrations.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/guide/models-and-fields.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/guide/mutations.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/guide/queries.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/guide/relationships.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/guide/transactions.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/howto/multiple-databases.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/howto/pagination.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/howto/soft-deletes.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/howto/testing.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/howto/timestamps.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/index.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/migration-sqlalchemy.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/solutions/README.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/solutions/patterns/foreign-key-index.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/stylesheets/extra.css +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/docs/why-ferro.md +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/justfile +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/mkdocs.yml +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/scripts/demo_queries.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/connection.rs +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/ferro/_shadow_fk_types.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/ferro/composite_indexes.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/ferro/composite_uniques.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/ferro/exceptions.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/ferro/migrations/__init__.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/ferro/models.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/ferro/py.typed +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/ferro/query/__init__.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/ferro/query/builder.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/ferro/query/nodes.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/ferro/raw.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/ferro/relations/__init__.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/ferro/relations/descriptors.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/ferro/state.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/query.rs +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/src/state.rs +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/__init__.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/conftest.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/db_backends.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_aggregation.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_alembic_autogenerate.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_alembic_bridge.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_alembic_nullability.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_alembic_type_mapping.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_auto_migrate.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_bulk_update.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_composite_index.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_composite_unique.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_connection.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_connection_redaction.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_constraints.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_cross_emitter_parity.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_crud.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_db_backends.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_deletion.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_docs_examples.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_documentation_features.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_field_wrapper.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_helpers.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_hydration.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_metaclass_internals.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_metadata.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_models.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_named_connections_integration.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_one_to_one.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_query_builder.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_query_typing.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_raw_sql.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_refresh.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_relationship_engine.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_schema.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_schema_constraints.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_schema_enum_annotations.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_shadow_fk_types.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_static_contracts.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_string_search.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_structural_types.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_temporal_types.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_transactions.py +0 -0
- {ferro_orm-0.9.2 → ferro_orm-0.10.1}/tests/test_typed_null_binds.py +0 -0
|
@@ -28,14 +28,21 @@ For a single model, every emitter must agree on:
|
|
|
28
28
|
1. **Table name** — already handled by `model_name.lower()`.
|
|
29
29
|
2. **Column names** — including shadow `*_id` columns from `ForeignKey`.
|
|
30
30
|
3. **Column types** — pydantic JSON schema → SQL type mapping must be one
|
|
31
|
-
function (or two functions whose outputs are tested for parity).
|
|
31
|
+
function (or two functions whose outputs are tested for parity). This
|
|
32
|
+
includes the canonical `db_type` vocabulary (`text`, `varchar(N)`,
|
|
33
|
+
`smallint`, `int`, `bigint`, `uuid`, `timestamp`, `timestamptz`, `date`,
|
|
34
|
+
`time`) — duplicated in `_db_type_to_sa_type` (Python) and
|
|
35
|
+
`apply_db_type_to_column_def` (Rust), pinned by
|
|
36
|
+
`tests/test_db_type_cross_emitter_parity.py`.
|
|
32
37
|
4. **Index names** — `idx_<table>_<col>` for single-column indexes,
|
|
33
38
|
`idx_<table>_<col1>_<col2>...` for composite indexes.
|
|
34
39
|
5. **Unique constraint names** — `uq_<table>_<col>` for single-column,
|
|
35
40
|
`uq_<table>_<col1>_<col2>...` for composite.
|
|
36
41
|
6. **Foreign key constraint names** — when explicitly named.
|
|
37
42
|
7. **Primary key constraint names** — when explicitly named.
|
|
38
|
-
8. **Check constraint names** —
|
|
43
|
+
8. **Check constraint names** — `ck_<table>_<col>` for the single-column
|
|
44
|
+
`db_check=True` constraint; generated by `_ck_constraint_name` (Python)
|
|
45
|
+
and `db_check_constraint_name` (Rust).
|
|
39
46
|
9. **Default values** — server-side defaults must serialize identically.
|
|
40
47
|
10. **Nullability** — must agree.
|
|
41
48
|
|
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.10.1 (2026-05-19)
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- **sqlite**: Hydrate SQL NULL as None instead of int 0
|
|
9
|
+
([#57](https://github.com/syn54x/ferro-orm/pull/57),
|
|
10
|
+
[`249c81f`](https://github.com/syn54x/ferro-orm/commit/249c81f4b37f117c3ca80f44a9682511154ab9ec))
|
|
11
|
+
|
|
12
|
+
### Testing
|
|
13
|
+
|
|
14
|
+
- **schema**: Integration coverage for db_type / db_check
|
|
15
|
+
([#55](https://github.com/syn54x/ferro-orm/pull/55),
|
|
16
|
+
[`b97d596`](https://github.com/syn54x/ferro-orm/commit/b97d59667d520c026d8a6fbb73ad1de7f71593b4))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
## v0.10.0 (2026-05-18)
|
|
20
|
+
|
|
21
|
+
### Features
|
|
22
|
+
|
|
23
|
+
- Configurable column storage types (db_type / db_check)
|
|
24
|
+
([#53](https://github.com/syn54x/ferro-orm/pull/53),
|
|
25
|
+
[`bd5feee`](https://github.com/syn54x/ferro-orm/commit/bd5feee970eda6031eca742ff18ab3a863d7abe4))
|
|
26
|
+
|
|
27
|
+
|
|
4
28
|
## v0.9.2 (2026-05-14)
|
|
5
29
|
|
|
6
30
|
### Bug Fixes
|
|
@@ -193,9 +193,9 @@ dependencies = [
|
|
|
193
193
|
|
|
194
194
|
[[package]]
|
|
195
195
|
name = "dashmap"
|
|
196
|
-
version = "6.1
|
|
196
|
+
version = "6.2.1"
|
|
197
197
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
198
|
-
checksum = "
|
|
198
|
+
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
|
|
199
199
|
dependencies = [
|
|
200
200
|
"cfg-if",
|
|
201
201
|
"crossbeam-utils",
|
|
@@ -294,7 +294,7 @@ dependencies = [
|
|
|
294
294
|
|
|
295
295
|
[[package]]
|
|
296
296
|
name = "ferro"
|
|
297
|
-
version = "0.
|
|
297
|
+
version = "0.10.1"
|
|
298
298
|
dependencies = [
|
|
299
299
|
"dashmap",
|
|
300
300
|
"once_cell",
|
ferro_orm-0.10.1/docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
---
|
|
2
|
+
date: 2026-05-13
|
|
3
|
+
topic: configurable-column-storage-types
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Configurable Column Storage Types
|
|
7
|
+
|
|
8
|
+
## Summary
|
|
9
|
+
|
|
10
|
+
Add an opt-in `db_type=` option to `Field()` that lets users override the SQL column type Ferro emits for a given Python field — most commonly to escape native DB enum types (back to `text` or `int`), but also to pick `BIGINT` over `INT` and `text` over native `UUID`. A companion `db_check=` adds DB-side `CHECK` validation for closed-domain types when native enum storage is declined.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Problem Frame
|
|
15
|
+
|
|
16
|
+
Ferro currently maps every Python `Enum`/`StrEnum`/`IntEnum` field to a named SQL enum type — `sa.Enum(python_enum, name=...)` on the Alembic side, and a matching `CREATE TYPE` on the Rust runtime side. Native enum types are storage-efficient and self-documenting, but they make schema evolution expensive: adding a variant on Postgres requires `ALTER TYPE ... ADD VALUE` (historically not transactional), renaming or removing a variant requires a multi-step migration with shadow columns, and the same enum is often referenced by multiple tables which complicates `DROP TYPE`. SQLite has no native enum type at all, so the current behavior already diverges across dialects in practice.
|
|
17
|
+
|
|
18
|
+
The same shape of problem applies to other column types where Ferro's default does not match the user's intent. A user with a counter that will exceed two billion rows wants `BIGINT`, not `INT`. A user who runs on Postgres in production but SQLite locally wants `UUID` columns stored as text so the schema is portable. Today there is no Pythonic way to express those overrides — users either change Pydantic annotations away from their preferred semantic types or hand-edit migrations after Ferro generates them.
|
|
19
|
+
|
|
20
|
+
The cost is real and recurring: every team that hits the enum-evolution wall, every team that needs `BIGINT`, every team that wants portable UUIDs has to either fight the framework or abandon it. The feature lets the user keep writing standard Pydantic models while picking a column type that fits their operational reality.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Requirements
|
|
25
|
+
|
|
26
|
+
**Field-level API**
|
|
27
|
+
|
|
28
|
+
- R1. `Field()` accepts a new `db_type: str | None = None` kwarg that overrides Ferro's default SQL type for that column. The value is a canonical token from a fixed Ferro vocabulary (see R4).
|
|
29
|
+
- R2. `Field()` accepts a new `db_check: bool = False` kwarg. When `True`, Ferro emits a DB-side `CHECK` constraint enforcing the allowed values. Only valid when the Python annotation has a finite domain (`enum.Enum` subclasses, `typing.Literal[...]`); any other use raises `TypeError` at class definition.
|
|
30
|
+
- R3. Both kwargs flow through the existing `FERRO_FIELD_EXTRA_KEY` plumbing and are reflected in `__ferro_schema__["properties"][col]` so every downstream emitter sees them.
|
|
31
|
+
|
|
32
|
+
**Canonical vocabulary**
|
|
33
|
+
|
|
34
|
+
- R4. The canonical `db_type` tokens shipped in this phase are:
|
|
35
|
+
- String/enum storage: `text`, `varchar(N)` where `N` is an integer literal
|
|
36
|
+
- Integer storage: `smallint`, `int`, `bigint`
|
|
37
|
+
- UUID storage: `uuid`, plus `text` and `varchar(N)` as cross-references from the string family
|
|
38
|
+
- Temporal storage: `timestamp`, `timestamptz`, `date`, `time`
|
|
39
|
+
- R5. Ferro owns the canonical-token → per-dialect SQL mapping. SQLite's `INTEGER` is the canonical translation for `smallint`, `int`, and `bigint` because SQLite has no fixed-width integer types; the parity test asserts both emitters agree.
|
|
40
|
+
|
|
41
|
+
**Validation (strict at class definition)**
|
|
42
|
+
|
|
43
|
+
- R6. Ferro rejects incoherent combinations at metaclass time with `TypeError`. Incoherent combinations include but are not limited to:
|
|
44
|
+
- `db_type="int"`/`"bigint"`/`"smallint"` on a `str`, `StrEnum`, or `UUID` field
|
|
45
|
+
- `db_type="text"`/`"varchar(N)"` on an `int`, `IntEnum`, `datetime`, `date`, or `time` field where Ferro cannot losslessly serialize the Python value
|
|
46
|
+
- `db_type="uuid"` on any field whose Python type is not `UUID`
|
|
47
|
+
- `db_type` set to a token outside the canonical vocabulary
|
|
48
|
+
- `db_check=True` on a field whose annotation is not a closed-domain type (`Enum` subclass or `Literal[...]`)
|
|
49
|
+
- `db_check=True` combined with default (native enum) storage — redundant; native enum already enforces values
|
|
50
|
+
|
|
51
|
+
**Cross-emitter parity (AGENTS.md I-1)**
|
|
52
|
+
|
|
53
|
+
- R7. Both the Alembic autogenerate bridge (`src/ferro/migrations/alembic.py`) and the Rust runtime emitter (`src/operations.rs`/`src/schema.rs`) produce byte-identical DDL for the same `(field annotation, db_type, db_check, dialect)` tuple.
|
|
54
|
+
- R8. The canonical-token → SQL-type translation table is the single source of truth; both emitters consume it (e.g. a shared static table expressed in both runtimes, with a parity test that walks every `(canonical, dialect)` pair and asserts equality).
|
|
55
|
+
- R9. `db_check` constraints follow the existing naming convention `ck_<table>_<col>` and the new artifact is added to the `AGENTS.md` § I-1 table.
|
|
56
|
+
|
|
57
|
+
**Autogenerate and migrations**
|
|
58
|
+
|
|
59
|
+
- R10. When `db_type` changes between revisions, Alembic autogenerate produces a real `ALTER COLUMN` migration with an explicit `USING` clause where the dialect requires it (`col::text`, `col::bigint`, etc.).
|
|
60
|
+
- R11. When the change drops the last reference to a native DB enum type, autogenerate emits a deferred `DROP TYPE <enum_name>` ordered after every column that uses it has been migrated.
|
|
61
|
+
- R12. When `db_check=True` is added or removed, autogenerate emits `ADD CONSTRAINT ck_<table>_<col>` / `DROP CONSTRAINT ck_<table>_<col>` operations with names consistent across both emitters.
|
|
62
|
+
- R13. Storage-type changes whose autogenerate output would risk silent data loss or require a full table rewrite (e.g. shrinking `bigint → int` on MySQL) emit a clear warning in the generated migration script that the user can choose to keep or remove.
|
|
63
|
+
|
|
64
|
+
**Backward compatibility**
|
|
65
|
+
|
|
66
|
+
- R14. Models that do not set `db_type` or `db_check` produce identical DDL to today. No existing model definition, migration, or test needs modification.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Acceptance Examples
|
|
71
|
+
|
|
72
|
+
- AE1. **Covers R1, R6, R7.** Given a `StrEnum`-typed `format` field declared with `Field(db_type="text")`, when the schema is emitted on Postgres by either the Alembic bridge or the Rust runtime, the column is `format TEXT NOT NULL` with no `CREATE TYPE` statement, no `CHECK` constraint, and both emitters produce identical SQL.
|
|
73
|
+
- AE2. **Covers R2, R9, R12.** Given the same field declared with `Field(db_type="text", db_check=True)`, the emitted DDL includes `format TEXT NOT NULL` and a separate `CHECK (format IN ('pdf','json'))` constraint named `ck_<table>_format`. Removing `db_check=True` between revisions produces an autogenerate diff with a single `DROP CONSTRAINT ck_<table>_format` operation.
|
|
74
|
+
- AE3. **Covers R6.** Given a `StrEnum` field declared with `Field(db_type="int")`, importing the module raises `TypeError` with a message naming the field and the conflict between `StrEnum` and integer storage.
|
|
75
|
+
- AE4. **Covers R5, R7, R8.** Given an `int` field declared with `Field(db_type="bigint")`, the column emits `BIGINT` on Postgres and MySQL and `INTEGER` on SQLite, and the parity test confirms both emitters agree on each dialect.
|
|
76
|
+
- AE5. **Covers R10, R11.** Given a model that previously used native enum storage for `format` and now declares `Field(db_type="text")`, `alembic revision --autogenerate` produces a migration containing `ALTER COLUMN format TYPE TEXT USING format::text` and a deferred `DROP TYPE fileformat` after all columns of that type have been migrated.
|
|
77
|
+
- AE6. **Covers R14.** Given every existing test model in the repo, running both emitters before and after this change produces byte-identical DDL.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Success Criteria
|
|
82
|
+
|
|
83
|
+
- A user with a `StrEnum` field can switch to TEXT storage with a one-line change (`Field(db_type="text")`) and run autogenerate to produce a clean migration off the native enum type.
|
|
84
|
+
- A user with a counter column can declare `db_type="bigint"` and trust that Postgres, MySQL, and SQLite all produce the right column without manual migration edits.
|
|
85
|
+
- Phantom-diff regressions stay at zero: the existing `test_index_name_matches_rust_runtime_*` parity tests are joined by `test_db_type_matches_rust_runtime_*` covering every canonical token on every supported dialect.
|
|
86
|
+
- A downstream agent or implementer can read this document, find the canonical vocabulary in R4, the parity contract in R7-R9, and the autogenerate rules in R10-R13, and proceed to planning without inventing product behavior.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Scope Boundaries
|
|
91
|
+
|
|
92
|
+
- **Per-dialect dict escape hatch** (`db_type={"postgres": "JSONB", "sqlite": "TEXT"}`) — deferred to Phase 2; ship canonical tokens first and prove the parity model.
|
|
93
|
+
- **Canonical vocabulary beyond R4** — `numeric(p,s)`, `char(N)`, `bytea`/`blob`, `jsonb`, array types, and other dialect-specific types are out of scope for Phase 1.
|
|
94
|
+
- **Offline data backfills or shadow-column dances** — Ferro's autogenerate output relies on `ALTER COLUMN ... USING`; storage-type changes that need multi-step user-orchestrated migrations remain the user's responsibility.
|
|
95
|
+
- **Changing the default for existing models** — models that do not set `db_type` continue to receive today's behavior unchanged.
|
|
96
|
+
- **Runtime-level coercion** — `db_type` affects DDL only. Pydantic validation, hydration, and the Rust core's value handling are unchanged.
|
|
97
|
+
- **Cross-dialect storage normalization** — Ferro will not silently rewrite values between dialects (e.g. converting `UUID` to lowercase canonical form on read). Users who want that write a Pydantic validator.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Key Decisions
|
|
102
|
+
|
|
103
|
+
- **API shape is `Field(db_type=..., db_check=...)`, not a new annotation wrapper or model-level config.** Rationale: matches the existing pattern (`primary_key`, `unique`, `index`, `nullable` all live on `Field`), composes with the existing `FERRO_FIELD_EXTRA_KEY` plumbing, and keeps the user's mental model "I am writing Pydantic with extra knobs."
|
|
104
|
+
- **Canonical vocabulary, not pass-through SQL strings.** Rationale: Ferro must guarantee cross-emitter parity per AGENTS.md I-1. A canonical vocabulary makes the translation table testable; pass-through SQL would push parity onto users and open the door to dialect-specific breakage. Phase 2 may add an escape hatch on top.
|
|
105
|
+
- **Strict validation at metaclass time, no `db_type_force` escape.** Rationale: incoherent combinations are bugs, not preferences. The cost of a clear error at import is much lower than the cost of a silent miscoercion at runtime.
|
|
106
|
+
- **`db_check=True` on a default-storage enum field is a `TypeError`, not a no-op.** Rationale: redundancy hides intent. A user who writes both is either confused about what native enum storage does or has a different goal that deserves a clearer API.
|
|
107
|
+
- **SQLite's `INTEGER` is the canonical translation for `smallint`, `int`, and `bigint`.** Rationale: SQLite has no fixed-width integer types — `INTEGER` is dynamically sized to hold any 64-bit value. Refusing `bigint` on SQLite would force every cross-dialect user to branch in their model definition, which defeats the point.
|
|
108
|
+
- **`uuid → text` on Postgres canonicalizes to `TEXT`, not `VARCHAR(36)` or `CHAR(36)`.** Rationale: `TEXT` is the simplest portable choice and matches Ferro's existing string default. Users who specifically want length-bounded storage can declare `db_type="varchar(36)"` explicitly.
|
|
109
|
+
- **`db_check` constraints follow the existing naming convention `ck_<table>_<col>`** and are added to the AGENTS.md § I-1 table as a new tracked artifact.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Dependencies / Assumptions
|
|
114
|
+
|
|
115
|
+
- The translation table for `(canonical_token, dialect) → SQL type` lives in both the Python and Rust runtimes and is kept in sync by a parity test, following the recipe in `docs/solutions/patterns/cross-emitter-ddl-parity.md`. Planning will decide whether to express it as a shared data file or duplicated constants with a parity assertion.
|
|
116
|
+
- A new `docs/solutions/patterns/` entry captures the canonical vocabulary, the translation table, the autogenerate comparator rules for storage-type changes, and the `db_check`-on-closed-domain validation rule. This is institutional memory per AGENTS.md I-5.
|
|
117
|
+
- This work assumes the existing `__ferro_schema__` JSON representation can carry both `db_type` and `db_check` without disrupting consumers that ignore unknown keys. Planning verifies this against current consumers.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Outstanding Questions
|
|
122
|
+
|
|
123
|
+
### Deferred to Planning
|
|
124
|
+
|
|
125
|
+
- [Affects R8][Technical] Should the canonical-token → dialect-SQL table be expressed as a shared JSON file consumed by both Python and Rust, or as duplicated constants in each runtime with a parity test enforcing equality? The shared-file option is more DRY but adds a new build dependency; duplicated constants follow the existing pattern in `cross-emitter-ddl-parity.md`.
|
|
126
|
+
- [Affects R11][Technical] What's the cleanest way to express "deferred `DROP TYPE` ordered after all column migrations" in Alembic autogenerate's output? Alembic operations are normally per-column; this needs a post-pass.
|
|
127
|
+
- [Affects R6][Needs research] Are there `Literal[...]` patterns Ferro currently accepts that we have not classified as closed-domain? Planning should grep for `Literal` usage in `_annotation_utils.py` and decide whether `db_check` extends to all of them or only string/int literals.
|
|
128
|
+
- [Affects R13][Technical] What's the right surface for the data-loss warning in R13 — a comment in the generated migration script, an Alembic-level warning, or both? The answer depends on what `alembic revision --autogenerate` already does today for similar cases.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Autogenerate support for db_type / db_check"
|
|
3
|
+
type: brainstorm
|
|
4
|
+
status: draft
|
|
5
|
+
date: 2026-05-14
|
|
6
|
+
parent: docs/plans/2026-05-13-001-feat-configurable-column-storage-types-plan.md
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Autogenerate support for db_type / db_check
|
|
10
|
+
|
|
11
|
+
> Phase 2 of the configurable-column-storage feature. The DDL-emission half
|
|
12
|
+
> shipped in `feat/configurable-column-storage-types` (U1–U5 + trimmed U7).
|
|
13
|
+
> This brainstorm captures the deferred migration-detection work so the
|
|
14
|
+
> follow-up plan has a clean starting point.
|
|
15
|
+
|
|
16
|
+
## Problem frame
|
|
17
|
+
|
|
18
|
+
After Phase 1, a user can declare `Field(db_type="text", db_check=True)` on
|
|
19
|
+
a previously native-enum column and `connect(auto_migrate=True)` will create
|
|
20
|
+
the table correctly. But when the user switches an **existing** model from
|
|
21
|
+
default storage to `db_type="text"` and runs
|
|
22
|
+
`alembic revision --autogenerate`, three things happen that should be
|
|
23
|
+
automated:
|
|
24
|
+
|
|
25
|
+
1. The generated `op.alter_column(...)` lacks a `postgresql_using` clause.
|
|
26
|
+
For enum→text the right answer is almost always `USING <col>::text`, but
|
|
27
|
+
autogen can't know that without a hook.
|
|
28
|
+
2. After the alter, the previously-used Postgres `CREATE TYPE` is orphaned.
|
|
29
|
+
No `DROP TYPE` is emitted, so the type lingers in the database forever.
|
|
30
|
+
3. Narrowing changes (`bigint → int`, `text → varchar(N)` with short N)
|
|
31
|
+
silently risk data loss with no warning in the generated script.
|
|
32
|
+
|
|
33
|
+
`db_check` add/drop already works via standard Alembic autogen because U3
|
|
34
|
+
attaches named `CheckConstraint`s to the SA `MetaData` (R11 is essentially
|
|
35
|
+
free) — but this should be confirmed end-to-end with a test before the
|
|
36
|
+
follow-up plan declares it shipped.
|
|
37
|
+
|
|
38
|
+
## Acceptance examples
|
|
39
|
+
|
|
40
|
+
Carried forward from the original brainstorm
|
|
41
|
+
(`docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md`):
|
|
42
|
+
|
|
43
|
+
- **AE5**: A model that previously declared `format: FileFormat` (native
|
|
44
|
+
enum) and now declares `Field(db_type="text")` produces a migration
|
|
45
|
+
containing exactly one
|
|
46
|
+
`op.alter_column("doc", "format", existing_type=sa.Enum(...),
|
|
47
|
+
type_=sa.Text(), postgresql_using="format::text")`
|
|
48
|
+
followed by `op.execute("DROP TYPE fileformat")` after that alter.
|
|
49
|
+
- **Multi-table enum sharing**: when two tables reference the same enum and
|
|
50
|
+
only one changes, no `DROP TYPE` is emitted. When both change, exactly
|
|
51
|
+
one `DROP TYPE` is emitted after the last alter.
|
|
52
|
+
- **db_check add**:
|
|
53
|
+
`op.create_check_constraint("ck_doc_format", "doc", "format IN (...)")`.
|
|
54
|
+
- **db_check remove**:
|
|
55
|
+
`op.drop_constraint("ck_doc_format", "doc", type_="check")`.
|
|
56
|
+
- **Narrowing change**: `bigint → int` on Postgres surfaces a warning
|
|
57
|
+
(`warnings.warn` at hook time, or a `-- WARNING:` comment in the
|
|
58
|
+
generated script — TBD during planning).
|
|
59
|
+
- **Regression**: a model with no `db_type` change produces an empty
|
|
60
|
+
autogenerate diff.
|
|
61
|
+
|
|
62
|
+
## Recommended approach
|
|
63
|
+
|
|
64
|
+
A `process_revision_directives` hook factory in
|
|
65
|
+
`src/ferro/migrations/alembic.py`:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from ferro.migrations import process_revision_directives
|
|
69
|
+
|
|
70
|
+
context.configure(
|
|
71
|
+
target_metadata=...,
|
|
72
|
+
process_revision_directives=process_revision_directives,
|
|
73
|
+
compare_type=True,
|
|
74
|
+
)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Three passes over `directives[0].upgrade_ops.ops`:
|
|
78
|
+
|
|
79
|
+
1. **`_inject_using_clauses`** — for `AlterColumnOp` where the type change
|
|
80
|
+
is in a known-safe set (enum→text, etc.), set `kw["postgresql_using"]`.
|
|
81
|
+
2. **`_inject_drop_type_for_orphaned_enums`** — collect all
|
|
82
|
+
`AlterColumnOp` whose `existing_type` is a `sa.Enum`. For each enum name,
|
|
83
|
+
check if any column in the target `MetaData` still references it. If not,
|
|
84
|
+
append `ExecuteSQLOp("DROP TYPE <name>")` after the last alter that used
|
|
85
|
+
it.
|
|
86
|
+
3. **`_inject_data_loss_warning`** — for narrowing alters, `warnings.warn()`
|
|
87
|
+
or inject a comment-only `ExecuteSQLOp`.
|
|
88
|
+
|
|
89
|
+
The hook is purely additive and idempotent — running it twice on the same
|
|
90
|
+
script must produce the same script.
|
|
91
|
+
|
|
92
|
+
## Open questions for planning
|
|
93
|
+
|
|
94
|
+
- **`USING` clause matrix**: which `(from_type, to_type, dialect)` pairs get
|
|
95
|
+
automatic `USING` and which surface as a diff for the user to refine? Start
|
|
96
|
+
with the obvious ones (enum→text gets `::text`; numeric widening doesn't
|
|
97
|
+
need USING) and grow from there.
|
|
98
|
+
- **Warning surface for R12**: `warnings.warn(UserWarning, ...)` (visible in
|
|
99
|
+
alembic CLI) vs. injected `-- WARNING:` comment in the generated script
|
|
100
|
+
(visible in code review). Both? Decided when implementing.
|
|
101
|
+
- **`Literal[...]` extension for `db_check`**: still deferred from Phase 1.
|
|
102
|
+
The xfail in `tests/test_db_type_validation.py::test_db_check_on_literal_is_accepted`
|
|
103
|
+
is the placeholder.
|
|
104
|
+
- **Hook composition**: if the user already has their own
|
|
105
|
+
`process_revision_directives`, document the chaining pattern (call
|
|
106
|
+
Ferro's hook from theirs, or chain ours through theirs).
|
|
107
|
+
|
|
108
|
+
## References
|
|
109
|
+
|
|
110
|
+
- Phase 1 plan: `docs/plans/2026-05-13-001-feat-configurable-column-storage-types-plan.md`
|
|
111
|
+
- Phase 1 pattern doc:
|
|
112
|
+
`docs/solutions/patterns/configurable-column-storage-types.md` §
|
|
113
|
+
"Autogenerate support (deferred)"
|
|
114
|
+
- Original brainstorm:
|
|
115
|
+
`docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md`
|
|
116
|
+
- Alembic docs: `process_revision_directives`, `MigrationScript`
|