sqlmodel-ext 0.5.0__tar.gz → 0.5.2__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.
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/.github/workflows/ci.yml +2 -1
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/CHANGELOG.md +141 -8
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/PKG-INFO +7 -6
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/README.md +6 -5
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/README_zh.md +6 -5
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/ai-rules/AGENTS.md +4 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/cached-table.md +3 -3
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/metaclass.md +10 -10
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/polymorphic-internals.md +1 -1
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/transactional-cache-transparency.md +5 -6
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/cache-queries.md +1 -1
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/extra-mixins.md +3 -2
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/integrate-with-fastapi.md +4 -11
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/keyset-pagination.md +3 -3
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/paginate-a-list-endpoint.md +15 -20
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/reference/crud-methods.md +4 -4
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/reference/decorators.md +1 -1
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/reference/field-types.md +1 -1
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/reference/index.md +2 -2
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/reference/mixins.md +1 -1
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/reference/pagination-types.md +26 -5
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/tutorials/02-building-a-blog-api.md +3 -3
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/cached-table.md +3 -3
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/metaclass.md +10 -10
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/polymorphic-internals.md +1 -1
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/transactional-cache-transparency.md +5 -6
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/cache-queries.md +1 -1
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/extra-mixins.md +3 -2
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/integrate-with-fastapi.md +4 -11
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/keyset-pagination.md +3 -3
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/paginate-a-list-endpoint.md +15 -20
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/reference/crud-methods.md +4 -4
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/reference/decorators.md +1 -1
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/reference/field-types.md +1 -1
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/reference/index.md +2 -2
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/reference/mixins.md +1 -1
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/reference/pagination-types.md +26 -5
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/tutorials/02-building-a-blog-api.md +3 -3
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/pyproject.toml +1 -1
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/pyrightconfig.json +5 -5
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/__init__.py +4 -1
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/_compat.py +4 -2
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/_exceptions.py +1 -1
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/_sa_type.py +20 -8
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/_type_unwrap.py +10 -2
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/base.py +267 -119
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/check_derived.py +8 -8
- sqlmodel_ext-0.5.2/src/sqlmodel_ext/dependencies.py +178 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/__init__.py +27 -39
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/_internal/path.py +22 -10
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/dialects/postgresql/array.py +27 -13
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/dialects/postgresql/jsonb_types.py +4 -5
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/dialects/postgresql/numpy_vector.py +40 -24
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/mixins/module_name_mixin.py +2 -2
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/url.py +6 -6
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/cached_table.py +239 -266
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/migration_cache_invalidation.py +6 -6
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/polymorphic.py +36 -23
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/relation_preload.py +16 -16
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/resource_quota.py +9 -6
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/table.py +44 -28
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/trgm_searchable.py +1 -1
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/pagination.py +72 -39
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/relation_load_checker.py +21 -15
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/session.py +60 -33
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/unset.py +1 -3
- sqlmodel_ext-0.5.2/tests/test_cached_commit_paths.py +265 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_enhanced_session.py +6 -3
- sqlmodel_ext-0.5.2/tests/test_final_051_rereview.py +52 -0
- sqlmodel_ext-0.5.2/tests/test_final_051_review.py +262 -0
- sqlmodel_ext-0.5.2/tests/test_final_051_review2.py +72 -0
- sqlmodel_ext-0.5.2/tests/test_inherited_column_attributes.py +666 -0
- sqlmodel_ext-0.5.2/tests/test_metaclass_field_resolution_052.py +406 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_pagination.py +23 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_pg_dialect_types.py +1 -1
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_polymorphic_jti.py +1 -1
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_polymorphic_sti_query.py +1 -1
- sqlmodel_ext-0.5.2/tests/test_query_dependency.py +326 -0
- sqlmodel_ext-0.5.2/tests/test_query_dependency_metadata.py +154 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_smoke_imports.py +2 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/.gitignore +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/LICENSE +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/ai-rules/CLAUDE.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/ai-rules/README.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/.vitepress/config.mts +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/.vitepress/en.mts +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/.vitepress/theme/index.ts +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/.vitepress/theme/style/custom.css +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/.vitepress/zh.mts +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/cascade-delete-semantics.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/crud-pipeline.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/index.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/optimistic-lock.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/prerequisites.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/relation-load-checker.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/relation-preload.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/single-source-of-truth.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/unset-three-state.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/guide/cached-table.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/guide/crud.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/guide/field-types.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/guide/index.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/guide/optimistic-lock.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/guide/pagination.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/guide/polymorphic.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/guide/relation-load-checker.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/guide/relation-preload.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/aggregate-queries.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/check-partial-dtos.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/configure-cascade-delete.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/define-jti-models.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/define-sti-models.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/enforce-locking-and-isolation.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/handle-concurrent-updates.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/handle-referenced-deletes.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/index.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/migrate-to-0-5.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/prevent-missing-greenlet.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/release-connection-during-long-io.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/type-check-with-basedpyright.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/use-with-ai-assistants.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/write-patch-endpoints.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/index.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/internals/cached-table.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/internals/crud.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/internals/index.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/internals/metaclass.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/internals/optimistic-lock.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/internals/polymorphic.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/internals/prerequisites.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/internals/relation-load-checker.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/internals/relation-preload.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/reference/base-classes.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/tutorials/01-getting-started.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/tutorials/03-adding-redis-cache.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/tutorials/index.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/cascade-delete-semantics.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/crud-pipeline.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/index.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/optimistic-lock.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/prerequisites.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/relation-load-checker.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/relation-preload.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/single-source-of-truth.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/unset-three-state.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/guide/cached-table.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/guide/crud.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/guide/field-types.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/guide/index.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/guide/optimistic-lock.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/guide/pagination.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/guide/polymorphic.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/guide/relation-load-checker.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/guide/relation-preload.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/aggregate-queries.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/check-partial-dtos.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/configure-cascade-delete.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/define-jti-models.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/define-sti-models.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/enforce-locking-and-isolation.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/handle-concurrent-updates.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/handle-referenced-deletes.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/index.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/migrate-to-0-5.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/prevent-missing-greenlet.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/release-connection-during-long-io.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/type-check-with-basedpyright.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/use-with-ai-assistants.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/write-patch-endpoints.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/index.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/internals/cached-table.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/internals/crud.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/internals/index.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/internals/metaclass.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/internals/optimistic-lock.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/internals/polymorphic.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/internals/prerequisites.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/internals/relation-load-checker.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/internals/relation-preload.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/reference/base-classes.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/tutorials/01-getting-started.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/tutorials/03-adding-redis-cache.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/tutorials/index.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/01_unset_patch.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/02_single_source_types.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/03_keyset_pagination.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/04_locking_and_isolation.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/05_aggregates.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/06_referenced_delete.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/07_transactional_cache.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/08_optimistic_lock.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/09_relation_preload_bulk.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/10_typed_select.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/11_type_errors_caught_by_basedpyright.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/README.md +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/check_derived_demo/pyrightconfig.json +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/check_derived_demo/shop/__init__.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/check_derived_demo/shop/handlers.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/check_derived_demo/shop/models.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/consumer_integration/run.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/package.json +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/pnpm-lock.yaml +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/_utils.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/constants.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/derived_decls.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/_internal/__init__.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/_ssrf.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/dialects/__init__.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/dialects/postgresql/__init__.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/dialects/postgresql/exceptions.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/ip_address.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/mixins/__init__.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/__init__.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/_uuid.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/constants.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/exceptions.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/info_response.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/mixin_table_scan.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/optimistic_lock.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/py.typed +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/select.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/__init__.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/_models.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/conftest.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_annotated_default_factory_inheritance.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_array_explicit_field_sa_type.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_basic_crud.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_cached_table.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_check_derived.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_crud_extended.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_decimal_types.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_deferred_index.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_derived_decls.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_examples.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_field_types_aliases_extra.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_field_types_jsonb_limits.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_field_types_misc.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_field_types_str_num.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_field_types_url_ssrf.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_final_check_derived_review2.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_final_check_derived_temp_root.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_final_nested_duplicate_names.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_final_omitted_sentinel_alias_modes.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_final_partial_alias_generator.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_final_partial_annotated_field_warnings.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_final_partial_discriminator.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_final_partial_frozen_field.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_final_partial_rhs_field_metadata.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_final_review4_followup.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_final_review5.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_metaclass_extra.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_mixins_extra.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_optimistic_lock.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_partial.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_relation_load_checker.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_relation_load_checker_load_resolution.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_relation_load_checker_sessions.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_relation_preload.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_relationship_pep604.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_review_mixed_version_cache.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_review_raw_insert_cache.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_review_raw_insert_edges.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_review_raw_insert_late_model.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_review_raw_insert_sti.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_select.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_sti_default_isolation.py +0 -0
- {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_unset.py +0 -0
|
@@ -52,7 +52,8 @@ jobs:
|
|
|
52
52
|
# -------------------------------------------------------------------
|
|
53
53
|
# Job 1b: static type check. basedpyright reads pyrightconfig.json from
|
|
54
54
|
# the repository root (src only, pythonVersion 3.12 = lowest supported).
|
|
55
|
-
# The gate is 0 errors
|
|
55
|
+
# The gate is 0 errors and 0 warnings: pyrightconfig.json sets failOnWarnings,
|
|
56
|
+
# so any warning makes basedpyright exit non-zero and fails the job.
|
|
56
57
|
# The alembic extra is installed so the optional-import module
|
|
57
58
|
# `mixins/migration_cache_invalidation.py` resolves its imports.
|
|
58
59
|
# "0 errors" is also what an empty scan prints, so the job additionally
|
|
@@ -7,6 +7,147 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
7
7
|
> there is no stability or backward-compatibility guarantee between versions, and
|
|
8
8
|
> you use it at your own risk. Pin the exact version you depend on.
|
|
9
9
|
|
|
10
|
+
## [0.5.2]
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- A table class field declared with a type alias and a plain right-hand value
|
|
15
|
+
(`x: NonNegativeDecimal38_18 = Decimal(0)`, `s: Str64 = 'a'`) had its default only in
|
|
16
|
+
`model_fields`: the JSON schema listed the field as `required` with no `default`, and
|
|
17
|
+
`model_validate({})` rejected the payload with "Field required". With an alias that
|
|
18
|
+
carries its own default (`opt: OptionalNonNegativeDecimal38_18 = Decimal(1)`),
|
|
19
|
+
`model_fields` even showed the alias's default (`None`) instead of the declared one.
|
|
20
|
+
The metaclass set the value on the field after constructing it, so Pydantic did not
|
|
21
|
+
count it as set when it built the core schema. A metaclass rebuild (an inherited field
|
|
22
|
+
description, a relationship) hid the defect in some classes. The same applied to a
|
|
23
|
+
field re-declared in the table class body without a right-hand side, which takes the
|
|
24
|
+
base's default. The table class now matches a non-table class with the same
|
|
25
|
+
declaration (schema, validation and `model_fields`). Non-table classes and fields the
|
|
26
|
+
table class inherits without re-declaring were not affected. Also present in 0.5.0
|
|
27
|
+
and 0.5.1.
|
|
28
|
+
- **Behavior change.** A table class field declared with **several** `Field(...)` in one
|
|
29
|
+
`Annotated` and no right-hand side (`value: Legacy` with
|
|
30
|
+
`Legacy = Annotated[str, Field(alias='legacy', title='T'), Field(unique=True)]`, or a
|
|
31
|
+
nested alias such as `Annotated[Str64, Field(unique=True)]`) now gets its Pydantic
|
|
32
|
+
attributes as Pydantic merges them -- the same field a non-table class, plain
|
|
33
|
+
SQLModel and a table class inheriting the declaration already got. A later `Field`
|
|
34
|
+
overrides an earlier one, `None` included, and sqlmodel's `Field()` passes
|
|
35
|
+
`alias` / `validation_alias` / `serialization_alias` / `title` / `description` /
|
|
36
|
+
`default_factory` / `discriminator` / `exclude` explicitly as `None` and `repr` as
|
|
37
|
+
`True`. So when an earlier `Field` of the annotation sets any of these and a later
|
|
38
|
+
one does not, the table class no longer keeps the earlier value: the alias / title /
|
|
39
|
+
description are gone, and a `default_factory` is dropped, which makes the field
|
|
40
|
+
**required**. A `default` is not affected (sqlmodel's `Field()` does not pass it).
|
|
41
|
+
None of the bundled aliases combines several `Field`s or sets such an attribute. To keep the value, set it in the last `Field` or on a right-hand
|
|
42
|
+
`= Field(...)`. The same holds for a non-table class whose field needs an injected
|
|
43
|
+
`sa_type` (`Annotated[FilePathType, Field(alias='p'), Field(index=True)]`), which kept
|
|
44
|
+
the earlier `alias` too. The same path also applied the `Field` of an alias nested in a
|
|
45
|
+
union with no right-hand side, which Pydantic does not merge at field level:
|
|
46
|
+
`x: OptionalNonNegativeDecimal38_18 | None` declared in a table class had
|
|
47
|
+
`default=None`; it is now **required**, as it already was in a non-table class, when
|
|
48
|
+
inherited, and with a right-hand `Field` that sets no default (0.5.1). Drop the
|
|
49
|
+
redundant `| None` or write `= None`. The column attributes of all the declaration's `Field`s are
|
|
50
|
+
still folded into the column, now with the precedence Pydantic uses for the Pydantic
|
|
51
|
+
attributes: the right-hand `Field` first, then the annotation's `Field`s from the last
|
|
52
|
+
to the first; `False` still never switches off `True`. So a table class **inheriting**
|
|
53
|
+
such a declaration agrees with one declaring it directly:
|
|
54
|
+
`Annotated[NonNegativeDecimal38_18, Field(sa_type=Numeric(20, 2))]` gives
|
|
55
|
+
`NUMERIC(20, 2)` in both (inheriting it gave `NUMERIC(38, 18)`, the alias's
|
|
56
|
+
`sa_type`). Plain SQLModel, which reads only the first carrier, still gives
|
|
57
|
+
`NUMERIC(38, 18)`. Also present in 0.5.0 and 0.5.1. This only changes the metadata
|
|
58
|
+
the models build: existing databases are not altered. Tables created from now on
|
|
59
|
+
(`create_all`) get the new column type; to align an existing table, write a migration.
|
|
60
|
+
|
|
61
|
+
## [0.5.1]
|
|
62
|
+
|
|
63
|
+
### Added
|
|
64
|
+
|
|
65
|
+
- **`query_dependency(Model)`**: FastAPI dependency for query-parameter DTOs
|
|
66
|
+
(`TableViewRequest`, `PaginationRequest`, `PageWindowRequest`, `TimeFilterRequest`,
|
|
67
|
+
`TrgmSearchRequest`, your subclasses):
|
|
68
|
+
`Annotated[TableViewRequest, Depends(query_dependency(TableViewRequest))]`. It declares
|
|
69
|
+
one query parameter per field carrying the field's type, constraints, default, `title`,
|
|
70
|
+
description, `examples`, `deprecated`, `json_schema_extra` and `discriminator` (each
|
|
71
|
+
parameter's OpenAPI schema is the field's JSON schema; a callable `json_schema_extra`
|
|
72
|
+
or a `Discriminator` object is a `TypeError`), builds the model itself and
|
|
73
|
+
re-raises a failed construction as `RequestValidationError` with locations prefixed by
|
|
74
|
+
`'query'`, so FastAPI answers 422. Requires the `fastapi` extra when called. See
|
|
75
|
+
[Paginate a list endpoint](docs/en/how-to/paginate-a-list-endpoint.md).
|
|
76
|
+
|
|
77
|
+
### Fixed
|
|
78
|
+
|
|
79
|
+
- A bare mutation of a cached model that was flushed before the enhanced `commit()` -- inside
|
|
80
|
+
a savepoint (`begin_nested()` + `flush()`), by a manual `session.flush()`, or by an
|
|
81
|
+
autoflush -- was never invalidated: the cache kept serving the pre-commit row after the
|
|
82
|
+
commit. Pending invalidations were collected by scanning `new` / `dirty` / `deleted`
|
|
83
|
+
right before the commit, and a flushed object is no longer in those sets. Every flush
|
|
84
|
+
now registers the cached instances it writes (`after_flush`); an outermost rollback
|
|
85
|
+
drops them, a savepoint rollback keeps them (at most one extra invalidation). Also
|
|
86
|
+
present in 0.5.0.
|
|
87
|
+
- A table class inheriting a field declared with a type alias and a right-hand
|
|
88
|
+
`Field(...)` (`language: Str64 = Field(index=True)` in a non-table base) lost that
|
|
89
|
+
`Field`'s attributes: the column had no index / unique constraint / primary key /
|
|
90
|
+
foreign key / `nullable` / `sa_type` / `sa_column_kwargs` / `ondelete`, and the
|
|
91
|
+
Pydantic field could lose attributes set only there (e.g. `title`). The metaclass
|
|
92
|
+
rebuilt the inherited field from the alias's `Field` alone. The inherited field is now
|
|
93
|
+
the base class's resolved field (`Base.model_fields[name]`, in which Pydantic has
|
|
94
|
+
already merged the alias's `Field` and the right-hand one): its Pydantic attributes
|
|
95
|
+
(`default`, `alias`, `validation_alias`, `title`, `description`, ...) are taken as-is,
|
|
96
|
+
`None` included, so a base `Field(alias=None)` still clears the alias's `alias` in the
|
|
97
|
+
table class. Only the SQLModel column attributes are completed: the alias's and the
|
|
98
|
+
right-hand `Field`'s column settings are folded into one (the right-hand side wins on
|
|
99
|
+
conflicts; `False` never switches off `True`), and constraints of an alias nested in
|
|
100
|
+
a union (`Alias | None = Field(...)`), which Pydantic does not merge at field level,
|
|
101
|
+
are added. Plain-typed inherited fields (`int = Field(index=True)`) were not affected.
|
|
102
|
+
Also present in 0.5.0.
|
|
103
|
+
- A cross-field validation failure of `TableViewRequest` (and the other query DTOs) used
|
|
104
|
+
as a FastAPI dependency (`after_id` with a non-zero `offset`, `after_id` with a mutable
|
|
105
|
+
`order`, an inverted time range) was a 500. Declared through `query_dependency()` it is
|
|
106
|
+
a 422 located at the offending query parameter; the documented `ValidationError`
|
|
107
|
+
exception-handler workaround is no longer needed.
|
|
108
|
+
- Every commit of a cached model through the enhanced `AsyncSession` logged a spurious
|
|
109
|
+
`WARNING` "fallback compensation triggered: ..." and invalidated the cache a second
|
|
110
|
+
time. The `after_commit` event now hands the committed pendings over to the enhanced
|
|
111
|
+
`commit()`, which invalidates each of them once. Commits that bypass the enhanced
|
|
112
|
+
`commit()` (a plain SQLAlchemy / SQLModel session, `run_sync(lambda s: s.commit())`)
|
|
113
|
+
keep the fire-and-forget compensation and its `WARNING`; it now also takes over when
|
|
114
|
+
the enhanced `commit()` fails or is cancelled after the database committed.
|
|
115
|
+
|
|
116
|
+
### Changed
|
|
117
|
+
|
|
118
|
+
- A table class field declared with a type alias **and** a right-hand `Field(...)`
|
|
119
|
+
(`name: Alias = Field(...)`) now gets its Pydantic attributes exactly as Pydantic
|
|
120
|
+
resolves that declaration -- the same field plain SQLModel builds, and the same field
|
|
121
|
+
a table class gets when it inherits the declaration from a base. The right-hand
|
|
122
|
+
`Field` overrides the alias's Pydantic attributes, `None` included; sqlmodel's
|
|
123
|
+
`Field()` passes every one of them explicitly, so with a right-hand `Field` the
|
|
124
|
+
alias's `alias` / `validation_alias` / `serialization_alias` / `title` /
|
|
125
|
+
`description` no longer apply (`Field(alias=None)` clears the alias's `alias`;
|
|
126
|
+
previously the metaclass skipped `None` and kept the alias's values). The alias's
|
|
127
|
+
`default` still applies unless the right-hand `Field` sets one -- except for an alias
|
|
128
|
+
nested in a union (`Alias | None = Field(...)`), whose `Field` Pydantic does not
|
|
129
|
+
merge: such a field is required unless the right-hand `Field` sets a default. Of the
|
|
130
|
+
bundled aliases only the `Optional*Decimal*` ones carry a Pydantic attribute
|
|
131
|
+
(`default=None`), so `OptionalNonNegativeDecimal38_18 | None = Field(index=True)` is
|
|
132
|
+
now required; drop the redundant `| None` or add `default=None`. Column attributes are
|
|
133
|
+
unchanged: the alias's and the right-hand `Field`'s column settings are still folded
|
|
134
|
+
together.
|
|
135
|
+
- The cross-field rules of `PaginationRequest` and `TimeFilterRequest` are field validators:
|
|
136
|
+
a violation is still a `ValidationError` with the same message, but located at the
|
|
137
|
+
field the rule constrains (`after_id`, `created_before_datetime`,
|
|
138
|
+
`updated_before_datetime`) instead of the model root, and independent violations are
|
|
139
|
+
reported together. `TimeFilterRequest` no longer overrides `model_post_init`.
|
|
140
|
+
- `PolymorphicBaseMixin._is_joined_table_inheritance()` is renamed
|
|
141
|
+
`is_joined_table_inheritance()` (it was documented and called across modules; the
|
|
142
|
+
underscore was not a real boundary). Update direct callers.
|
|
143
|
+
- `get(table_view=...)` is annotated `TableViewArgument` (`TimeFilterRequest |
|
|
144
|
+
PageWindowRequest`), matching what it already accepted at runtime (e.g. a bare
|
|
145
|
+
`PageWindowRequest`). The `join=(Model, onclause)` ON clause is annotated with the
|
|
146
|
+
new `OnClauseArgument` alias (SQLAlchemy's public `OnClauseRole`) instead of
|
|
147
|
+
SQLAlchemy's private `_OnClauseArgument`.
|
|
148
|
+
- The type-check gate of the repository is now 0 errors **and 0 warnings**
|
|
149
|
+
(`failOnWarnings: true`).
|
|
150
|
+
|
|
10
151
|
## [0.5.0]
|
|
11
152
|
|
|
12
153
|
Upgrade guide: [Migrate from 0.4.x to 0.5.0](docs/en/how-to/migrate-to-0-5.md)
|
|
@@ -164,14 +305,6 @@ Upgrade guide: [Migrate from 0.4.x to 0.5.0](docs/en/how-to/migrate-to-0-5.md)
|
|
|
164
305
|
database has already committed): the affected cache entries keep serving
|
|
165
306
|
pre-commit data until their TTL expires. Use `no_cache=True` for reads that must
|
|
166
307
|
not rely on the cache.
|
|
167
|
-
- Every commit of a cached model through the enhanced `AsyncSession` logs a spurious
|
|
168
|
-
`WARNING` "fallback compensation triggered: ..." (pre-existing in 0.4.x). The only
|
|
169
|
-
effect is one redundant cache invalidation; correctness is unaffected.
|
|
170
|
-
- When `TableViewRequest` is used as a `Depends()` dependency, a cross-field validation
|
|
171
|
-
failure (e.g. `after_id` with a non-zero `offset`) surfaces as a 500 instead of a 422,
|
|
172
|
-
because FastAPI constructs the object outside its per-parameter validation. Register
|
|
173
|
-
the `ValidationError` → 422 handler shown in
|
|
174
|
-
[Paginate a list endpoint](docs/en/how-to/paginate-a-list-endpoint.md).
|
|
175
308
|
- On Python 3.12, `JSON100K | None` as a `table=True` field raises
|
|
176
309
|
`has no matching SQLAlchemy type`; declare the column explicitly with
|
|
177
310
|
`Field(default=None, sa_type=JSONB)` (pre-existing).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sqlmodel-ext
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: Extended SQLModel infrastructure: smart metaclass, async CRUD mixins, polymorphic inheritance, optimistic locking, relation preloading, and reusable field types.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Foxerine/sqlmodel-ext
|
|
6
6
|
Project-URL: Repository, https://github.com/Foxerine/sqlmodel-ext
|
|
@@ -399,7 +399,7 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
399
399
|
from sqlmodel import Field
|
|
400
400
|
from sqlmodel_ext import (
|
|
401
401
|
AsyncSession, SQLModelBase, UUIDTableBaseMixin, Str64, Text10K,
|
|
402
|
-
ListResponse, TableViewRequest, UUIDIdDatetimeInfoMixin,
|
|
402
|
+
ListResponse, TableViewRequest, UUIDIdDatetimeInfoMixin, query_dependency,
|
|
403
403
|
)
|
|
404
404
|
|
|
405
405
|
# ── Dependency-injection layer: declare once (e.g. in a shared
|
|
@@ -415,9 +415,10 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|
|
415
415
|
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
|
416
416
|
"""Request-scoped AsyncSession. The single way endpoints touch the DB."""
|
|
417
417
|
|
|
418
|
-
#
|
|
419
|
-
#
|
|
420
|
-
|
|
418
|
+
# query_dependency() binds every TableViewRequest field as a query param
|
|
419
|
+
# (no hand-written offset/limit/order plumbing) and turns cross-field errors
|
|
420
|
+
# (e.g. after_id + offset) into a 422 instead of a 500.
|
|
421
|
+
TableViewRequestDep = Annotated[TableViewRequest, Depends(query_dependency(TableViewRequest))]
|
|
421
422
|
|
|
422
423
|
async def get_current_user(session: SessionDep) -> "User":
|
|
423
424
|
... # decode the bearer token, load the user — your auth, unchanged
|
|
@@ -1177,7 +1178,7 @@ mapping = Tool.get_identity_to_class_map()
|
|
|
1177
1178
|
# {'websearchtool': WebSearchTool, 'calculatortool': CalculatorTool}
|
|
1178
1179
|
|
|
1179
1180
|
# Check inheritance type
|
|
1180
|
-
Tool.
|
|
1181
|
+
Tool.is_joined_table_inheritance() # True for JTI, False for STI
|
|
1181
1182
|
```
|
|
1182
1183
|
|
|
1183
1184
|
---
|
|
@@ -343,7 +343,7 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
343
343
|
from sqlmodel import Field
|
|
344
344
|
from sqlmodel_ext import (
|
|
345
345
|
AsyncSession, SQLModelBase, UUIDTableBaseMixin, Str64, Text10K,
|
|
346
|
-
ListResponse, TableViewRequest, UUIDIdDatetimeInfoMixin,
|
|
346
|
+
ListResponse, TableViewRequest, UUIDIdDatetimeInfoMixin, query_dependency,
|
|
347
347
|
)
|
|
348
348
|
|
|
349
349
|
# ── Dependency-injection layer: declare once (e.g. in a shared
|
|
@@ -359,9 +359,10 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|
|
359
359
|
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
|
360
360
|
"""Request-scoped AsyncSession. The single way endpoints touch the DB."""
|
|
361
361
|
|
|
362
|
-
#
|
|
363
|
-
#
|
|
364
|
-
|
|
362
|
+
# query_dependency() binds every TableViewRequest field as a query param
|
|
363
|
+
# (no hand-written offset/limit/order plumbing) and turns cross-field errors
|
|
364
|
+
# (e.g. after_id + offset) into a 422 instead of a 500.
|
|
365
|
+
TableViewRequestDep = Annotated[TableViewRequest, Depends(query_dependency(TableViewRequest))]
|
|
365
366
|
|
|
366
367
|
async def get_current_user(session: SessionDep) -> "User":
|
|
367
368
|
... # decode the bearer token, load the user — your auth, unchanged
|
|
@@ -1121,7 +1122,7 @@ mapping = Tool.get_identity_to_class_map()
|
|
|
1121
1122
|
# {'websearchtool': WebSearchTool, 'calculatortool': CalculatorTool}
|
|
1122
1123
|
|
|
1123
1124
|
# Check inheritance type
|
|
1124
|
-
Tool.
|
|
1125
|
+
Tool.is_joined_table_inheritance() # True for JTI, False for STI
|
|
1125
1126
|
```
|
|
1126
1127
|
|
|
1127
1128
|
---
|
|
@@ -343,7 +343,7 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
343
343
|
from sqlmodel import Field
|
|
344
344
|
from sqlmodel_ext import (
|
|
345
345
|
AsyncSession, SQLModelBase, UUIDTableBaseMixin, Str64, Text10K,
|
|
346
|
-
ListResponse, TableViewRequest, UUIDIdDatetimeInfoMixin,
|
|
346
|
+
ListResponse, TableViewRequest, UUIDIdDatetimeInfoMixin, query_dependency,
|
|
347
347
|
)
|
|
348
348
|
|
|
349
349
|
# ── 依赖注入层:声明一次(例如放在共享的 deps 模块),
|
|
@@ -359,9 +359,10 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|
|
359
359
|
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
|
360
360
|
"""请求级 AsyncSession。端点访问数据库的唯一途径。"""
|
|
361
361
|
|
|
362
|
-
#
|
|
363
|
-
#
|
|
364
|
-
|
|
362
|
+
# query_dependency() 把 TableViewRequest 的每个字段绑定为查询参数(无需手写
|
|
363
|
+
# offset/limit/order 的管道代码),并把跨字段错误(如 after_id + offset)变成
|
|
364
|
+
# 422 而不是 500。
|
|
365
|
+
TableViewRequestDep = Annotated[TableViewRequest, Depends(query_dependency(TableViewRequest))]
|
|
365
366
|
|
|
366
367
|
async def get_current_user(session: SessionDep) -> "User":
|
|
367
368
|
... # 解析 bearer token、加载用户——沿用你自己的认证逻辑
|
|
@@ -1121,7 +1122,7 @@ mapping = Tool.get_identity_to_class_map()
|
|
|
1121
1122
|
# {'websearchtool': WebSearchTool, 'calculatortool': CalculatorTool}
|
|
1122
1123
|
|
|
1123
1124
|
# 检查继承类型
|
|
1124
|
-
Tool.
|
|
1125
|
+
Tool.is_joined_table_inheritance() # JTI 为 True,STI 为 False
|
|
1125
1126
|
```
|
|
1126
1127
|
|
|
1127
1128
|
---
|
|
@@ -199,6 +199,10 @@ data = patch.model_dump(exclude_unset=True)
|
|
|
199
199
|
- List endpoints take a `TableViewRequest` (`offset` / `limit` / `order` /
|
|
200
200
|
`desc` / `after_id` + time bounds). `after_id` cannot be combined with
|
|
201
201
|
`offset`, custom `order_by` or `join`.
|
|
202
|
+
- Inject query-parameter DTOs with
|
|
203
|
+
`Annotated[TableViewRequest, Depends(query_dependency(TableViewRequest))]`,
|
|
204
|
+
never a bare `Depends()` (a failed cross-field rule would be a 500 instead
|
|
205
|
+
of a 422).
|
|
202
206
|
|
|
203
207
|
## 7. Relationships: always preload
|
|
204
208
|
|
|
@@ -118,7 +118,7 @@ With `commit=False` nothing is invalidated (the data is not committed yet); the
|
|
|
118
118
|
| `delete(instances)` | per-instance `DEL id:...` + `INCR ver:{cls}` |
|
|
119
119
|
| `delete(condition)` | model-level `SCAN+DEL id:{cls}:*` + `INCR ver:{cls}` |
|
|
120
120
|
| `add()` | `INCR ver:{cls}`; instances with an explicitly specified id also have their ID cache invalidated (guards against id reuse) |
|
|
121
|
-
| bare `session.add()` / attribute mutation / `session.delete()` + `commit()` |
|
|
121
|
+
| bare `session.add()` / attribute mutation / `session.delete()` + `commit()` | registered by the flush that sends them (a savepoint flush, a manual `flush()`, an autoflush or the commit's own flush): row level (the flushed `id`) + query cache |
|
|
122
122
|
|
|
123
123
|
### Cascade deletes
|
|
124
124
|
|
|
@@ -140,8 +140,8 @@ The query cache `INCR`s the version keys of itself and all ancestors in one pipe
|
|
|
140
140
|
|
|
141
141
|
## The two invalidation paths
|
|
142
142
|
|
|
143
|
-
1. **Synchronous path** (enhanced `AsyncSession.commit()`):
|
|
144
|
-
2. **Compensation path** (`after_commit` event): the event handler is synchronous and cannot `await`, so it schedules a fire-and-forget task that invalidates
|
|
143
|
+
1. **Synchronous path** (enhanced `AsyncSession.commit()`): it marks the session before the real commit; the `after_commit` event sees the mark and hands the complete set of registered items (including those registered during the commit's own flush, e.g. cascade children) over to `commit()`, which `await`s their invalidation before returning. Each item is invalidated once; no warning is logged.
|
|
144
|
+
2. **Compensation path** (`after_commit` event without the mark): the event handler is synchronous and cannot `await`, so it schedules a fire-and-forget task that invalidates the items and logs a `WARNING` "fallback compensation triggered: ...". It catches commits that did not go through the enhanced `commit()` (a plain SQLAlchemy / SQLModel session, `run_sync(lambda s: s.commit())`), and an enhanced `commit()` that failed or was cancelled after the database committed; there is a very short stale window, and TTL provides eventual consistency.
|
|
145
145
|
|
|
146
146
|
Sentinel objects:
|
|
147
147
|
|
|
@@ -126,7 +126,7 @@ real_table_args, custom_table_args = [], []
|
|
|
126
126
|
for arg in raw_table_args:
|
|
127
127
|
(custom_table_args if isinstance(arg, CustomTableArg) else real_table_args).append(arg)
|
|
128
128
|
attrs['__table_args__'] = tuple(real_table_args)
|
|
129
|
-
# appended to the module-level queue
|
|
129
|
+
# appended to the module-level queue classes_with_custom_table_args after super().__new__
|
|
130
130
|
```
|
|
131
131
|
|
|
132
132
|
**Why**: SQLAlchemy's `Table.__init__` consumes every element of `__table_args__` immediately — an `Index` referencing a not-yet-existing column raises on the spot. `CustomTableArg` is a generic "defer this" marker: the metaclass only intercepts and enqueues, knowing nothing about the semantics; the current consumer is `mixins.polymorphic.DeferredIndex` (deferred indexes on STI subclass columns). `table_name=` / `abstract=` become `__tablename__` / `__abstract__`.
|
|
@@ -134,7 +134,7 @@ attrs['__table_args__'] = tuple(real_table_args)
|
|
|
134
134
|
### Step 4: Resolve annotations and record "which fields this class declares itself"
|
|
135
135
|
|
|
136
136
|
```python
|
|
137
|
-
annotations, annotation_strings, eval_globals, eval_locals =
|
|
137
|
+
annotations, annotation_strings, eval_globals, eval_locals = resolve_annotations(attrs)
|
|
138
138
|
_own_annotation_names = frozenset(annotations)
|
|
139
139
|
```
|
|
140
140
|
|
|
@@ -142,7 +142,7 @@ This snapshot must be taken **before** any annotation injection: later steps inj
|
|
|
142
142
|
|
|
143
143
|
### Step 4.5: Recover SQLModel attributes from `Annotated[T, Field(...)]`
|
|
144
144
|
|
|
145
|
-
When Pydantic v2 processes `Annotated` metadata it replaces `sqlmodel.main.FieldInfo` with `pydantic.fields.FieldInfo`, which knows nothing about SQLModel attributes such as `foreign_key` or `sa_type`. For **table classes** (including `Annotated` fields inherited from parents), `_recover_annotated_sqlmodel_fields()` converts them back into the `= Field(...)` form; non-table classes are left as-is so child table classes can inherit them.
|
|
145
|
+
When Pydantic v2 processes `Annotated` metadata it replaces `sqlmodel.main.FieldInfo` with `pydantic.fields.FieldInfo`, which knows nothing about SQLModel attributes such as `foreign_key` or `sa_type`. For **table classes** (including `Annotated` fields inherited from parents), `_recover_annotated_sqlmodel_fields()` converts them back into the `= Field(...)` form; non-table classes are left as-is so child table classes can inherit them. Every such field is rebuilt from **Pydantic's own resolution of its declaration** — the field a non-table class (or plain SQLModel) gets for the same declaration: `FieldInfo.from_annotated_attribute()` when the table class declares it with a right-hand side (`= Field(...)` or a plain value such as `= 'a'`), `FieldInfo.from_annotation()` when it declares it without one, and the base's resolved field `Base.model_fields[name]` when the table class **inherits** it (without re-declaring it). Its Pydantic attributes are taken as-is. Several `Field`s in one `Annotated` merge as Pydantic merges them: a later `Field` overrides an earlier one, `None` included — sqlmodel's `Field()` passes `alias` / `validation_alias` / `serialization_alias` / `title` / `description` / `default_factory` / `discriminator` / `exclude` explicitly as `None` (and `repr` as `True`), so `Annotated[str, Field(alias='a', title='T'), Field(unique=True)]` has neither alias nor title; a right-hand `Field` overrides them all. The `Field` of an alias nested in a union (`OptionalNonNegativeDecimal38_18 | None`) is not merged at field level, so its `default=None` does not apply — only its constraints and column attributes are carried over. The attributes are passed to the constructor, so Pydantic records them as set and they reach the core schema: a default of `x: Str64 = 'a'` is in the JSON schema and in validation, not only in `model_fields`. Only the column attributes are completed — the `FieldInfoMetadata` carriers of all the declaration's `Field`s are folded into one, since SQLModel reads only the first: the right-hand `Field`'s first, then the annotation's from the last to the first (`Annotated[NonNegativeDecimal38_18, Field(sa_type=Numeric(20, 2))]` gets `NUMERIC(20, 2)`), and `False` never switches off `True`. One library rule departs from Pydantic: a field re-declared in the table class body without a right-hand side keeps the base's default (Pydantic would make it required). Metadata that stays in the class annotation (an `AfterValidator` next to the alias's `Field`, for example) is not copied into the field, so it is applied once.
|
|
146
146
|
|
|
147
147
|
### Step 4.5.b: `oplock_version` is a reserved name
|
|
148
148
|
|
|
@@ -178,12 +178,12 @@ The generated annotations exist at runtime; static type checkers still see the b
|
|
|
178
178
|
|
|
179
179
|
```python
|
|
180
180
|
for field_name, field_type in annotations.items():
|
|
181
|
-
sa_type =
|
|
181
|
+
sa_type = extract_sa_type_from_annotation(field_type)
|
|
182
182
|
if sa_type is not None:
|
|
183
183
|
field_value = attrs.get(field_name, Undefined)
|
|
184
184
|
if field_value is Undefined:
|
|
185
|
-
# no "= Field(...)":
|
|
186
|
-
#
|
|
185
|
+
# no "= Field(...)": a right-hand Field carrying Pydantic's own resolution of the
|
|
186
|
+
# Annotated (default_factory etc. kept, nothing changed) plus sa_type
|
|
187
187
|
annotated_fi = _find_field_info_in_annotated(field_type)
|
|
188
188
|
attrs[field_name] = annotated_fi if annotated_fi is not None else Field(sa_type=sa_type)
|
|
189
189
|
elif isinstance(field_value, FieldInfo):
|
|
@@ -195,10 +195,10 @@ for field_name, field_type in annotations.items():
|
|
|
195
195
|
|
|
196
196
|
`_durably_set_sa_type()` writes `sa_type` into a `FieldInfoMetadata` entry of `FieldInfo.metadata` — the channel SQLModel's own `Field(sa_type=...)` uses and that survives Pydantic's `model_fields` rebuild; a plain `setattr` would be lost before the column is built. An explicitly set `sa_type` is never overwritten.
|
|
197
197
|
|
|
198
|
-
#### `
|
|
198
|
+
#### `extract_sa_type_from_annotation()` — three extraction methods
|
|
199
199
|
|
|
200
200
|
```python
|
|
201
|
-
def
|
|
201
|
+
def extract_sa_type_from_annotation(annotation):
|
|
202
202
|
# Method 1: the type itself has a __sqlmodel_sa_type__ attribute
|
|
203
203
|
# Method 2: an Annotated metadata item has __sqlmodel_sa_type__, or the schema returned by
|
|
204
204
|
# its __get_pydantic_core_schema__ carries 'sa_type' in its metadata
|
|
@@ -206,7 +206,7 @@ def _extract_sa_type_from_annotation(annotation):
|
|
|
206
206
|
...
|
|
207
207
|
```
|
|
208
208
|
|
|
209
|
-
Take `Array[str]`: `__class_getitem__` returns `Annotated[list[str],
|
|
209
|
+
Take `Array[str]`: `__class_getitem__` returns `Annotated[list[str], ArrayTypeHandler(str)]`, and the `ArrayTypeHandler` schema carries `metadata={'sa_type': ARRAY(String)}`; the `JSON100K` schema carries `metadata={'sa_type': JSONB}`. **The type declares its own column type**, and the metaclass delivers it to the column builder.
|
|
210
210
|
|
|
211
211
|
### Steps 5–7: Save SQLModel `FieldInfo`s, call the parent, restore
|
|
212
212
|
|
|
@@ -218,7 +218,7 @@ result = super().__new__(cls, name, bases, attrs, **kwargs)
|
|
|
218
218
|
# merge the saved SQLModelFieldInfo back and rebuild the Column
|
|
219
219
|
```
|
|
220
220
|
|
|
221
|
-
|
|
221
|
+
Step 7 only copies back what Pydantic filled in; there `None` and `False` never overwrite a saved value. The `FieldInfoMetadata` carriers of one declaration were already folded into one in step 4.5 (SQLModel reads only the first; otherwise an all-unset carrier inside a type alias shadows `= Field(primary_key=True)` — which is exactly why `id: NonNegativeInt = Field(primary_key=True)` lost its primary key on sqlmodel ≥ 0.0.32).
|
|
222
222
|
|
|
223
223
|
### Steps 8–9: Relationship fields under inheritance
|
|
224
224
|
|
|
@@ -45,7 +45,7 @@ def __init_subclass__(cls, polymorphic_on=None, polymorphic_abstract=None, **kwa
|
|
|
45
45
|
|
|
46
46
|
```python
|
|
47
47
|
@classmethod
|
|
48
|
-
def
|
|
48
|
+
def is_joined_table_inheritance(cls) -> bool:
|
|
49
49
|
"""Subclass table name differs from parent → JTI"""
|
|
50
50
|
|
|
51
51
|
@classmethod
|
{sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/transactional-cache-transparency.md
RENAMED
|
@@ -94,15 +94,14 @@ Register first, then execute, and the warning won't fire (the table already has
|
|
|
94
94
|
|
|
95
95
|
CRUD methods (`save` / `update` / `delete` / `add`) **don't invalidate** themselves; they only register pending invalidations into `session.info`. The enhanced `AsyncSession.commit()` orchestrates:
|
|
96
96
|
|
|
97
|
-
1.
|
|
98
|
-
2.
|
|
99
|
-
3.
|
|
100
|
-
4.
|
|
101
|
-
5. Run post-commit callbacks
|
|
97
|
+
1. Every flush registers the cached models it writes (the `after_flush` event), covering bare `add` / attribute mutation / `delete` that bypass the CRUD methods -- whether they were flushed earlier (inside a savepoint, by a manual `flush()`, by an autoflush) or by this commit
|
|
98
|
+
2. Actually commit. The `after_commit` event pops the complete set of registered items — including those registered by this commit's own flush, such as cascade-deleted children — and hands it over to `commit()`
|
|
99
|
+
3. Synchronously invalidate the handed-over items, each exactly once
|
|
100
|
+
4. Run post-commit callbacks
|
|
102
101
|
|
|
103
102
|
With `commit=False` nothing is invalidated (the data isn't committed yet); the registered items wait for your final `session.commit()` — "several `commit=False` operations + one commit" is naturally correct. Rollback discards the registered items.
|
|
104
103
|
|
|
105
|
-
|
|
104
|
+
For a commit that doesn't go through the enhanced `commit()` (e.g. a plain sqlmodel session, or `run_sync(lambda s: s.commit())`), the `after_commit` event instead schedules a fire-and-forget compensation task that invalidates the popped items and logs a `WARNING` "fallback compensation triggered: ..." — there is a brief stale window, TTL provides eventual consistency. The same fallback takes over if the enhanced `commit()` fails or is cancelled after the database has committed. A commit through the enhanced session never logs that warning.
|
|
106
105
|
|
|
107
106
|
### Savepoints
|
|
108
107
|
|
|
@@ -79,7 +79,7 @@ char = await char.save(session)
|
|
|
79
79
|
| `delete(instance)` | same |
|
|
80
80
|
| `delete(condition=...)` | model-wide ID cleanup + version `+1` |
|
|
81
81
|
| `add()` | version `+1` (instances with an explicitly specified id also clear their ID cache) |
|
|
82
|
-
| Bare `session.add()` / attribute changes / `session.delete()` followed by `session.commit()` | Also invalidated automatically (the
|
|
82
|
+
| Bare `session.add()` / attribute changes / `session.delete()` followed by `session.commit()` | Also invalidated automatically (the flush that writes them registers them, including a savepoint flush, a manual `flush()` or an autoflush earlier in the transaction) |
|
|
83
83
|
|
|
84
84
|
## 4. Correct inside transactions too
|
|
85
85
|
|
|
@@ -138,13 +138,14 @@ class Tag(SQLModelBase, UUIDTableBaseMixin, TrgmSearchableMixin, table=True):
|
|
|
138
138
|
```python
|
|
139
139
|
from typing import Annotated
|
|
140
140
|
from fastapi import Depends
|
|
141
|
+
from sqlmodel_ext import query_dependency
|
|
141
142
|
from sqlmodel_ext.mixins import TrgmSearchRequest
|
|
142
143
|
|
|
143
144
|
@router.get("", response_model=ListResponse[TagResponse])
|
|
144
145
|
async def list_tags(
|
|
145
146
|
session: SessionDep,
|
|
146
|
-
table_view: Annotated[TableViewRequest, Depends()],
|
|
147
|
-
search: Annotated[TrgmSearchRequest, Depends()],
|
|
147
|
+
table_view: Annotated[TableViewRequest, Depends(query_dependency(TableViewRequest))],
|
|
148
|
+
search: Annotated[TrgmSearchRequest, Depends(query_dependency(TrgmSearchRequest))],
|
|
148
149
|
) -> ListResponse[Tag]:
|
|
149
150
|
condition = search.apply_condition(Tag, col(Tag.name) != 'hidden') # ANDed into your existing scope condition
|
|
150
151
|
return await Tag.get_with_count(session, condition, table_view=table_view)
|
|
@@ -64,10 +64,11 @@ if patch.subtitle is not Unset:
|
|
|
64
64
|
from typing import Annotated
|
|
65
65
|
from uuid import UUID
|
|
66
66
|
from fastapi import APIRouter, Depends
|
|
67
|
-
from sqlmodel_ext import ListResponse, TableViewRequest
|
|
67
|
+
from sqlmodel_ext import ListResponse, TableViewRequest, query_dependency
|
|
68
68
|
|
|
69
69
|
router = APIRouter(prefix="/articles", tags=["articles"])
|
|
70
|
-
|
|
70
|
+
# query_dependency(): a failed cross-field rule (after_id + offset, ...) is a 422, not a 500 -- see "Paginate a list endpoint"
|
|
71
|
+
TableViewDep = Annotated[TableViewRequest, Depends(query_dependency(TableViewRequest))]
|
|
71
72
|
|
|
72
73
|
@router.post("", response_model=ArticleResponse)
|
|
73
74
|
async def create_article(
|
|
@@ -117,20 +118,12 @@ The `with_for_update=True` in the `delete` endpoint closes the TOCTOU window bet
|
|
|
117
118
|
|
|
118
119
|
```python
|
|
119
120
|
from fastapi import FastAPI, Request
|
|
120
|
-
from fastapi.encoders import jsonable_encoder
|
|
121
121
|
from fastapi.responses import JSONResponse
|
|
122
|
-
from pydantic import ValidationError
|
|
123
122
|
from sqlmodel_ext import OptimisticLockError
|
|
124
123
|
from sqlmodel_ext.mixins import KeysetCursorError, ResourceReferencedError
|
|
125
124
|
|
|
126
125
|
app = FastAPI()
|
|
127
126
|
|
|
128
|
-
@app.exception_handler(ValidationError) # cross-field validation of query-parameter DTOs (after_id + offset, etc.)
|
|
129
|
-
async def dto_validation_error_handler(request: Request, exc: ValidationError) -> JSONResponse:
|
|
130
|
-
return JSONResponse(status_code=422, content={
|
|
131
|
-
"detail": jsonable_encoder(exc.errors(include_url=False, include_context=False)),
|
|
132
|
-
})
|
|
133
|
-
|
|
134
127
|
@app.exception_handler(KeysetCursorError) # cursor invalid / unsupported, status_code = 422
|
|
135
128
|
async def keyset_error_handler(request: Request, exc: KeysetCursorError) -> JSONResponse:
|
|
136
129
|
return JSONResponse(status_code=exc.status_code, content={"detail": str(exc)})
|
|
@@ -144,7 +137,7 @@ async def oplock_handler(request: Request, exc: OptimisticLockError) -> JSONResp
|
|
|
144
137
|
return JSONResponse(status_code=409, content={"detail": "Record was modified by someone else. Please refresh and retry."})
|
|
145
138
|
```
|
|
146
139
|
|
|
147
|
-
|
|
140
|
+
Cross-field validation of query-parameter DTOs needs no handler: `query_dependency()` already turns it into FastAPI's `RequestValidationError` (422, locations starting with `query`). With a bare `Depends()` the same error would be a 500 — the `ValidationError` raised when FastAPI itself calls `TableViewRequest(...)` is outside its request-validation path.
|
|
148
141
|
|
|
149
142
|
## Key conventions
|
|
150
143
|
|
|
@@ -37,7 +37,7 @@ next_page = await Article.get(
|
|
|
37
37
|
|
|
38
38
|
Repeat until the returned list is empty. `get_with_count()` accepts `after_id` as well; the `count` it returns is the size of the whole filtered set and is **not affected by the cursor**.
|
|
39
39
|
|
|
40
|
-
In FastAPI the endpoint code doesn't change: with `TableViewRequest` as a dependency, the client simply passes `?after_id=<id of the last row of the previous page>&limit=20` (
|
|
40
|
+
In FastAPI the endpoint code doesn't change: with `TableViewRequest` as a dependency, the client simply passes `?after_id=<id of the last row of the previous page>&limit=20` (declare the dependency with `query_dependency(TableViewRequest)` so the rejected combinations below are a 422, see [Paginate a list endpoint](./paginate-a-list-endpoint#why-query-dependency)).
|
|
41
41
|
|
|
42
42
|
## How it guarantees no duplicates and no gaps
|
|
43
43
|
|
|
@@ -49,8 +49,8 @@ In FastAPI the endpoint code doesn't change: with `TableViewRequest` as a depend
|
|
|
49
49
|
|
|
50
50
|
| Combination | When rejected | Error |
|
|
51
51
|
|------|---------|------|
|
|
52
|
-
| `after_id` + non-zero `offset` | When constructing `TableViewRequest` | `ValidationError`
|
|
53
|
-
| `after_id` + `order='updated_at'` (or any mutable domain sort column) | At construction | `ValidationError` |
|
|
52
|
+
| `after_id` + non-zero `offset` | When constructing `TableViewRequest` | `ValidationError` at `after_id` (through `query_dependency()`: 422 at `["query", "after_id"]`) |
|
|
53
|
+
| `after_id` + `order='updated_at'` (or any mutable domain sort column) | At construction | `ValidationError` at `after_id` (422 through `query_dependency()`) |
|
|
54
54
|
| `after_id` + explicit `order_by=` | At `get()` | `KeysetCursorUnsupportedError` |
|
|
55
55
|
| `after_id` + `join=` | At `get()` | `KeysetCursorUnsupportedError` |
|
|
56
56
|
| Anchor does not exist, or is outside this query's visible scope (`condition` + `filter` + STI filter) | At `get()` | `KeysetCursorInvalidError` |
|
|
@@ -13,33 +13,28 @@
|
|
|
13
13
|
```python
|
|
14
14
|
from typing import Annotated
|
|
15
15
|
from fastapi import Depends
|
|
16
|
-
from sqlmodel_ext import TableViewRequest
|
|
16
|
+
from sqlmodel_ext import TableViewRequest, query_dependency
|
|
17
17
|
|
|
18
|
-
TableViewDep = Annotated[TableViewRequest, Depends()]
|
|
18
|
+
TableViewDep = Annotated[TableViewRequest, Depends(query_dependency(TableViewRequest))]
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
`TableViewRequest` carries pagination (`offset` / `limit` / `desc` / `order`), a keyset cursor (`after_id`) and time filtering (`created_after_datetime` / `created_before_datetime` / `updated_after_datetime` / `updated_before_datetime`).
|
|
21
|
+
`TableViewRequest` carries pagination (`offset` / `limit` / `desc` / `order`), a keyset cursor (`after_id`) and time filtering (`created_after_datetime` / `created_before_datetime` / `updated_after_datetime` / `updated_before_datetime`). `query_dependency()` declares one query parameter per model field (same type, constraints, default, title, description, examples, deprecation and `json_schema_extra` as the model field, so each parameter's OpenAPI schema is the field's JSON schema) and constructs the `TableViewRequest` itself.
|
|
22
22
|
|
|
23
|
-
###
|
|
23
|
+
### Why `query_dependency()`
|
|
24
24
|
|
|
25
|
-
Single-field errors (`limit=0`, a time without a timezone) are validated per parameter by FastAPI and are a 422 directly. But **cross-field** rules — `after_id` and `offset` are mutually exclusive, `after_id` can't be combined with `order=updated_at`, time ranges must be in order — only fire when FastAPI calls `TableViewRequest(...)`
|
|
25
|
+
Single-field errors (`limit=0`, a time without a timezone) are validated per parameter by FastAPI and are a 422 directly. But **cross-field** rules — `after_id` and `offset` are mutually exclusive, `after_id` can't be combined with `order=updated_at`, time ranges must be in order — only fire when the `TableViewRequest` is constructed. With a bare `Depends()`, FastAPI calls `TableViewRequest(...)` itself; the Pydantic `ValidationError` raised there is not a `RequestValidationError`, FastAPI does not catch it, and the request fails with a **500**.
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
@app.exception_handler(ValidationError)
|
|
34
|
-
async def dto_validation_error_handler(request: Request, exc: ValidationError) -> JSONResponse:
|
|
35
|
-
return JSONResponse(
|
|
36
|
-
status_code=422,
|
|
37
|
-
content={"detail": jsonable_encoder(exc.errors(include_url=False, include_context=False))},
|
|
38
|
-
)
|
|
27
|
+
`query_dependency()` re-raises that `ValidationError` as a `RequestValidationError` whose error locations start with `query`, so FastAPI's default handler answers **422** in the same shape as any other query-parameter error — no exception handler to register:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{"detail": [{"type": "value_error", "loc": ["query", "after_id"],
|
|
31
|
+
"msg": "Value error, after_id and offset cannot be combined: ...", "input": "..."}]}
|
|
39
32
|
```
|
|
40
33
|
|
|
41
|
-
|
|
42
|
-
|
|
34
|
+
A cross-field error is located at the field its rule constrains: both `after_id` rules report `after_id`, the time-range rules report the `*_before_datetime` field. The same form works for `PageWindowRequest`, `PaginationRequest`, `TimeFilterRequest`, `TrgmSearchRequest` and your own subclasses (`query_dependency(MyTableViewRequest)`). The endpoint can still declare further query parameters next to it; undeclared query parameters (such as a cache-busting `?_=123`) are ignored.
|
|
35
|
+
|
|
36
|
+
::: warning Don't switch to `Annotated[TableViewRequest, Query()]`
|
|
37
|
+
A Pydantic query model only works when it is the endpoint's **only** query parameter: add one more ordinary query parameter to the endpoint (e.g. `status: str | None = None`) and FastAPI no longer hands the whole query string to the model, so requests fail outright with 422 `Field required`. On top of that, `SQLModelBase`'s `extra='forbid'` turns any undeclared query parameter (such as `?_=123`) into a 422.
|
|
43
38
|
:::
|
|
44
39
|
|
|
45
40
|
## 2. Call `get_with_count()` in the endpoint
|
|
@@ -98,7 +93,7 @@ Sorting always appends `id` in the same direction as a tiebreaker column, so row
|
|
|
98
93
|
- **Time parameters must carry a timezone** (`...Z` or `+08:00`). All time bounds are `AwareDatetime`; a value without a timezone is a 422 outright — otherwise it would be silently interpreted in the database's timezone.
|
|
99
94
|
- **Time intervals are half-open** `[after, before)`. `created_after_datetime=2026-01-01T00:00:00Z` + `created_before_datetime=2026-02-01T00:00:00Z` means "all of January (UTC)".
|
|
100
95
|
- **`order` only accepts values in the `Literal`**; any other string makes FastAPI return `422`.
|
|
101
|
-
- **`after_id` can't be used with a non-zero `offset`**, nor with `order=updated_at` — both are rejected when `TableViewRequest` is constructed (a 422
|
|
96
|
+
- **`after_id` can't be used with a non-zero `offset`**, nor with `order=updated_at` — both are rejected when `TableViewRequest` is constructed (a 422 at `["query", "after_id"]` through `query_dependency()`).
|
|
102
97
|
|
|
103
98
|
## Related reference
|
|
104
99
|
|