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.
Files changed (267) hide show
  1. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/.github/workflows/ci.yml +2 -1
  2. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/CHANGELOG.md +141 -8
  3. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/PKG-INFO +7 -6
  4. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/README.md +6 -5
  5. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/README_zh.md +6 -5
  6. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/ai-rules/AGENTS.md +4 -0
  7. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/cached-table.md +3 -3
  8. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/metaclass.md +10 -10
  9. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/polymorphic-internals.md +1 -1
  10. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/transactional-cache-transparency.md +5 -6
  11. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/cache-queries.md +1 -1
  12. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/extra-mixins.md +3 -2
  13. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/integrate-with-fastapi.md +4 -11
  14. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/keyset-pagination.md +3 -3
  15. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/paginate-a-list-endpoint.md +15 -20
  16. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/reference/crud-methods.md +4 -4
  17. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/reference/decorators.md +1 -1
  18. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/reference/field-types.md +1 -1
  19. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/reference/index.md +2 -2
  20. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/reference/mixins.md +1 -1
  21. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/reference/pagination-types.md +26 -5
  22. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/tutorials/02-building-a-blog-api.md +3 -3
  23. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/cached-table.md +3 -3
  24. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/metaclass.md +10 -10
  25. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/polymorphic-internals.md +1 -1
  26. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/transactional-cache-transparency.md +5 -6
  27. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/cache-queries.md +1 -1
  28. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/extra-mixins.md +3 -2
  29. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/integrate-with-fastapi.md +4 -11
  30. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/keyset-pagination.md +3 -3
  31. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/paginate-a-list-endpoint.md +15 -20
  32. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/reference/crud-methods.md +4 -4
  33. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/reference/decorators.md +1 -1
  34. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/reference/field-types.md +1 -1
  35. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/reference/index.md +2 -2
  36. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/reference/mixins.md +1 -1
  37. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/reference/pagination-types.md +26 -5
  38. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/tutorials/02-building-a-blog-api.md +3 -3
  39. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/pyproject.toml +1 -1
  40. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/pyrightconfig.json +5 -5
  41. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/__init__.py +4 -1
  42. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/_compat.py +4 -2
  43. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/_exceptions.py +1 -1
  44. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/_sa_type.py +20 -8
  45. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/_type_unwrap.py +10 -2
  46. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/base.py +267 -119
  47. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/check_derived.py +8 -8
  48. sqlmodel_ext-0.5.2/src/sqlmodel_ext/dependencies.py +178 -0
  49. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/__init__.py +27 -39
  50. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/_internal/path.py +22 -10
  51. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/dialects/postgresql/array.py +27 -13
  52. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/dialects/postgresql/jsonb_types.py +4 -5
  53. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/dialects/postgresql/numpy_vector.py +40 -24
  54. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/mixins/module_name_mixin.py +2 -2
  55. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/url.py +6 -6
  56. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/cached_table.py +239 -266
  57. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/migration_cache_invalidation.py +6 -6
  58. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/polymorphic.py +36 -23
  59. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/relation_preload.py +16 -16
  60. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/resource_quota.py +9 -6
  61. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/table.py +44 -28
  62. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/trgm_searchable.py +1 -1
  63. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/pagination.py +72 -39
  64. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/relation_load_checker.py +21 -15
  65. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/session.py +60 -33
  66. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/unset.py +1 -3
  67. sqlmodel_ext-0.5.2/tests/test_cached_commit_paths.py +265 -0
  68. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_enhanced_session.py +6 -3
  69. sqlmodel_ext-0.5.2/tests/test_final_051_rereview.py +52 -0
  70. sqlmodel_ext-0.5.2/tests/test_final_051_review.py +262 -0
  71. sqlmodel_ext-0.5.2/tests/test_final_051_review2.py +72 -0
  72. sqlmodel_ext-0.5.2/tests/test_inherited_column_attributes.py +666 -0
  73. sqlmodel_ext-0.5.2/tests/test_metaclass_field_resolution_052.py +406 -0
  74. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_pagination.py +23 -0
  75. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_pg_dialect_types.py +1 -1
  76. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_polymorphic_jti.py +1 -1
  77. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_polymorphic_sti_query.py +1 -1
  78. sqlmodel_ext-0.5.2/tests/test_query_dependency.py +326 -0
  79. sqlmodel_ext-0.5.2/tests/test_query_dependency_metadata.py +154 -0
  80. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_smoke_imports.py +2 -0
  81. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/.gitignore +0 -0
  82. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/LICENSE +0 -0
  83. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/ai-rules/CLAUDE.md +0 -0
  84. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/ai-rules/README.md +0 -0
  85. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/.vitepress/config.mts +0 -0
  86. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/.vitepress/en.mts +0 -0
  87. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/.vitepress/theme/index.ts +0 -0
  88. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/.vitepress/theme/style/custom.css +0 -0
  89. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/.vitepress/zh.mts +0 -0
  90. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/cascade-delete-semantics.md +0 -0
  91. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/crud-pipeline.md +0 -0
  92. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/index.md +0 -0
  93. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/optimistic-lock.md +0 -0
  94. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/prerequisites.md +0 -0
  95. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/relation-load-checker.md +0 -0
  96. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/relation-preload.md +0 -0
  97. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/single-source-of-truth.md +0 -0
  98. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/explanation/unset-three-state.md +0 -0
  99. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/guide/cached-table.md +0 -0
  100. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/guide/crud.md +0 -0
  101. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/guide/field-types.md +0 -0
  102. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/guide/index.md +0 -0
  103. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/guide/optimistic-lock.md +0 -0
  104. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/guide/pagination.md +0 -0
  105. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/guide/polymorphic.md +0 -0
  106. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/guide/relation-load-checker.md +0 -0
  107. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/guide/relation-preload.md +0 -0
  108. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/aggregate-queries.md +0 -0
  109. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/check-partial-dtos.md +0 -0
  110. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/configure-cascade-delete.md +0 -0
  111. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/define-jti-models.md +0 -0
  112. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/define-sti-models.md +0 -0
  113. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/enforce-locking-and-isolation.md +0 -0
  114. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/handle-concurrent-updates.md +0 -0
  115. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/handle-referenced-deletes.md +0 -0
  116. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/index.md +0 -0
  117. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/migrate-to-0-5.md +0 -0
  118. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/prevent-missing-greenlet.md +0 -0
  119. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/release-connection-during-long-io.md +0 -0
  120. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/type-check-with-basedpyright.md +0 -0
  121. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/use-with-ai-assistants.md +0 -0
  122. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/how-to/write-patch-endpoints.md +0 -0
  123. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/index.md +0 -0
  124. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/internals/cached-table.md +0 -0
  125. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/internals/crud.md +0 -0
  126. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/internals/index.md +0 -0
  127. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/internals/metaclass.md +0 -0
  128. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/internals/optimistic-lock.md +0 -0
  129. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/internals/polymorphic.md +0 -0
  130. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/internals/prerequisites.md +0 -0
  131. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/internals/relation-load-checker.md +0 -0
  132. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/internals/relation-preload.md +0 -0
  133. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/reference/base-classes.md +0 -0
  134. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/tutorials/01-getting-started.md +0 -0
  135. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/tutorials/03-adding-redis-cache.md +0 -0
  136. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/en/tutorials/index.md +0 -0
  137. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/cascade-delete-semantics.md +0 -0
  138. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/crud-pipeline.md +0 -0
  139. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/index.md +0 -0
  140. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/optimistic-lock.md +0 -0
  141. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/prerequisites.md +0 -0
  142. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/relation-load-checker.md +0 -0
  143. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/relation-preload.md +0 -0
  144. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/single-source-of-truth.md +0 -0
  145. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/explanation/unset-three-state.md +0 -0
  146. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/guide/cached-table.md +0 -0
  147. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/guide/crud.md +0 -0
  148. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/guide/field-types.md +0 -0
  149. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/guide/index.md +0 -0
  150. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/guide/optimistic-lock.md +0 -0
  151. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/guide/pagination.md +0 -0
  152. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/guide/polymorphic.md +0 -0
  153. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/guide/relation-load-checker.md +0 -0
  154. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/guide/relation-preload.md +0 -0
  155. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/aggregate-queries.md +0 -0
  156. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/check-partial-dtos.md +0 -0
  157. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/configure-cascade-delete.md +0 -0
  158. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/define-jti-models.md +0 -0
  159. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/define-sti-models.md +0 -0
  160. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/enforce-locking-and-isolation.md +0 -0
  161. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/handle-concurrent-updates.md +0 -0
  162. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/handle-referenced-deletes.md +0 -0
  163. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/index.md +0 -0
  164. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/migrate-to-0-5.md +0 -0
  165. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/prevent-missing-greenlet.md +0 -0
  166. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/release-connection-during-long-io.md +0 -0
  167. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/type-check-with-basedpyright.md +0 -0
  168. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/use-with-ai-assistants.md +0 -0
  169. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/how-to/write-patch-endpoints.md +0 -0
  170. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/index.md +0 -0
  171. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/internals/cached-table.md +0 -0
  172. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/internals/crud.md +0 -0
  173. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/internals/index.md +0 -0
  174. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/internals/metaclass.md +0 -0
  175. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/internals/optimistic-lock.md +0 -0
  176. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/internals/polymorphic.md +0 -0
  177. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/internals/prerequisites.md +0 -0
  178. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/internals/relation-load-checker.md +0 -0
  179. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/internals/relation-preload.md +0 -0
  180. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/reference/base-classes.md +0 -0
  181. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/tutorials/01-getting-started.md +0 -0
  182. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/tutorials/03-adding-redis-cache.md +0 -0
  183. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/docs/tutorials/index.md +0 -0
  184. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/01_unset_patch.py +0 -0
  185. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/02_single_source_types.py +0 -0
  186. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/03_keyset_pagination.py +0 -0
  187. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/04_locking_and_isolation.py +0 -0
  188. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/05_aggregates.py +0 -0
  189. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/06_referenced_delete.py +0 -0
  190. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/07_transactional_cache.py +0 -0
  191. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/08_optimistic_lock.py +0 -0
  192. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/09_relation_preload_bulk.py +0 -0
  193. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/10_typed_select.py +0 -0
  194. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/11_type_errors_caught_by_basedpyright.py +0 -0
  195. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/README.md +0 -0
  196. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/check_derived_demo/pyrightconfig.json +0 -0
  197. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/check_derived_demo/shop/__init__.py +0 -0
  198. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/check_derived_demo/shop/handlers.py +0 -0
  199. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/check_derived_demo/shop/models.py +0 -0
  200. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/examples/consumer_integration/run.py +0 -0
  201. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/package.json +0 -0
  202. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/pnpm-lock.yaml +0 -0
  203. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/_utils.py +0 -0
  204. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/constants.py +0 -0
  205. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/derived_decls.py +0 -0
  206. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/_internal/__init__.py +0 -0
  207. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/_ssrf.py +0 -0
  208. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/dialects/__init__.py +0 -0
  209. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/dialects/postgresql/__init__.py +0 -0
  210. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/dialects/postgresql/exceptions.py +0 -0
  211. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/ip_address.py +0 -0
  212. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/field_types/mixins/__init__.py +0 -0
  213. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/__init__.py +0 -0
  214. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/_uuid.py +0 -0
  215. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/constants.py +0 -0
  216. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/exceptions.py +0 -0
  217. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/info_response.py +0 -0
  218. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/mixin_table_scan.py +0 -0
  219. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/mixins/optimistic_lock.py +0 -0
  220. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/py.typed +0 -0
  221. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/src/sqlmodel_ext/select.py +0 -0
  222. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/__init__.py +0 -0
  223. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/_models.py +0 -0
  224. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/conftest.py +0 -0
  225. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_annotated_default_factory_inheritance.py +0 -0
  226. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_array_explicit_field_sa_type.py +0 -0
  227. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_basic_crud.py +0 -0
  228. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_cached_table.py +0 -0
  229. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_check_derived.py +0 -0
  230. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_crud_extended.py +0 -0
  231. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_decimal_types.py +0 -0
  232. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_deferred_index.py +0 -0
  233. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_derived_decls.py +0 -0
  234. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_examples.py +0 -0
  235. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_field_types_aliases_extra.py +0 -0
  236. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_field_types_jsonb_limits.py +0 -0
  237. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_field_types_misc.py +0 -0
  238. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_field_types_str_num.py +0 -0
  239. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_field_types_url_ssrf.py +0 -0
  240. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_final_check_derived_review2.py +0 -0
  241. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_final_check_derived_temp_root.py +0 -0
  242. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_final_nested_duplicate_names.py +0 -0
  243. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_final_omitted_sentinel_alias_modes.py +0 -0
  244. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_final_partial_alias_generator.py +0 -0
  245. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_final_partial_annotated_field_warnings.py +0 -0
  246. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_final_partial_discriminator.py +0 -0
  247. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_final_partial_frozen_field.py +0 -0
  248. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_final_partial_rhs_field_metadata.py +0 -0
  249. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_final_review4_followup.py +0 -0
  250. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_final_review5.py +0 -0
  251. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_metaclass_extra.py +0 -0
  252. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_mixins_extra.py +0 -0
  253. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_optimistic_lock.py +0 -0
  254. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_partial.py +0 -0
  255. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_relation_load_checker.py +0 -0
  256. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_relation_load_checker_load_resolution.py +0 -0
  257. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_relation_load_checker_sessions.py +0 -0
  258. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_relation_preload.py +0 -0
  259. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_relationship_pep604.py +0 -0
  260. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_review_mixed_version_cache.py +0 -0
  261. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_review_raw_insert_cache.py +0 -0
  262. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_review_raw_insert_edges.py +0 -0
  263. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_review_raw_insert_late_model.py +0 -0
  264. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_review_raw_insert_sti.py +0 -0
  265. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_select.py +0 -0
  266. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.2}/tests/test_sti_default_isolation.py +0 -0
  267. {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; warnings are printed but do not fail the job.
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.0
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
- # TableViewRequest is a Pydantic model → FastAPI binds its fields as
419
- # query params automatically. No hand-written offset/limit/order plumbing.
420
- TableViewRequestDep = Annotated[TableViewRequest, Depends()]
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._is_joined_table_inheritance() # True for JTI, False for STI
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
- # TableViewRequest is a Pydantic model → FastAPI binds its fields as
363
- # query params automatically. No hand-written offset/limit/order plumbing.
364
- TableViewRequestDep = Annotated[TableViewRequest, Depends()]
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._is_joined_table_inheritance() # True for JTI, False for STI
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
- # TableViewRequest 是 Pydantic 模型 → FastAPI 自动把字段绑定为查询参数。
363
- # 无需手写 offset/limit/order 的管道代码。
364
- TableViewRequestDep = Annotated[TableViewRequest, Depends()]
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._is_joined_table_inheritance() # JTI 为 True,STI 为 False
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()` | auto-registered before commit: new → query cache only; modified / deleted → row level + query cache |
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()`): snapshots the registered items before commit; after commit it `await`s invalidation of the snapshot items and cascade children.
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 whatever the synchronous path did not cover (deduplicated by ID against the `synced` record). It catches commits that did not go through the enhanced `commit()`; there is a very short stale window, and TTL provides eventual consistency.
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 _classes_with_custom_table_args after super().__new__
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 = _resolve_annotations(attrs)
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. When several `FieldInfo`s are merged, an explicit `default=None` is kept as a real value (not "unset") — otherwise the field would silently become required.
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 = _extract_sa_type_from_annotation(field_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(...)": prefer recovering the user's FieldInfo from inside Annotated,
186
- # keeping default_factory / max_length etc., and only add sa_type
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
- #### `_extract_sa_type_from_annotation()` — three extraction methods
198
+ #### `extract_sa_type_from_annotation()` — three extraction methods
199
199
 
200
200
  ```python
201
- def _extract_sa_type_from_annotation(annotation):
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], _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.
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
- While merging, boolean flags are never overwritten with `False` (`unique=False` never switches off an inherited `unique=True`), and `FieldInfoMetadata` carriers are folded into one (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).
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 _is_joined_table_inheritance(cls) -> bool:
48
+ def is_joined_table_inheritance(cls) -> bool:
49
49
  """Subclass table name differs from parent → JTI"""
50
50
 
51
51
  @classmethod
@@ -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. Auto-register every change of cached models in the session (covering bare `add` / attribute mutation / `delete` that bypass the CRUD methods)
98
- 2. Snapshot the pending invalidations (the `after_commit` event pops them during commit)
99
- 3. Actually commit
100
- 4. Synchronously invalidate the snapshot items + the children cascade-deleted in this flush
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
- A fire-and-forget compensation task is also attached to the `after_commit` event, covering commit paths that don't go through the enhanced `commit()` (e.g. a plain sqlmodel session); it deduplicates by ID and only fills in what the synchronous path didn't cover.
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 enhanced `commit()` registers them before committing) |
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
- TableViewDep = Annotated[TableViewRequest, Depends()]
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
- Why the first handler is needed: FastAPI validates the query parameters of a `Depends()` class dependency one by one (single-field errors are 422), but a cross-field `model_validator` only fires when FastAPI calls `TableViewRequest(...)` to construct the object — left unhandled, that is a 500.
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` (remember to register the handler that maps `ValidationError` to 422, see the constraints table below).
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` (in a FastAPI dependency it must be mapped to 422, see [Paginate a list endpoint](./paginate-a-list-endpoint#map-cross-field-validation-errors-to-422)) |
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`). FastAPI's `Depends()` parses the query string into this object automatically.
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
- ### Map cross-field validation errors to 422
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(...)` to construct the dependency object. They raise a Pydantic `ValidationError`, which is a **500** if left unhandled. Register a handler that maps it to 422:
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
- ```python
28
- from fastapi import Request
29
- from fastapi.encoders import jsonable_encoder
30
- from fastapi.responses import JSONResponse
31
- from pydantic import ValidationError
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
- ::: warning Don't switch to `Annotated[TableViewRequest, Query()]` to "fix" this
42
- A Pydantic query model does turn cross-field errors into 422, but only 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 a cache-busting `?_=123`) into a 422.
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 once you register the handler above).
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