sqlmodel-ext 0.5.0__tar.gz → 0.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/.github/workflows/ci.yml +2 -1
  2. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/CHANGELOG.md +90 -8
  3. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/PKG-INFO +7 -6
  4. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/README.md +6 -5
  5. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/README_zh.md +6 -5
  6. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/ai-rules/AGENTS.md +4 -0
  7. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/explanation/cached-table.md +3 -3
  8. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/explanation/metaclass.md +7 -7
  9. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/explanation/polymorphic-internals.md +1 -1
  10. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/explanation/transactional-cache-transparency.md +5 -6
  11. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/how-to/cache-queries.md +1 -1
  12. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/how-to/extra-mixins.md +3 -2
  13. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/how-to/integrate-with-fastapi.md +4 -11
  14. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/how-to/keyset-pagination.md +3 -3
  15. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/how-to/paginate-a-list-endpoint.md +15 -20
  16. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/reference/crud-methods.md +4 -4
  17. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/reference/decorators.md +1 -1
  18. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/reference/field-types.md +1 -1
  19. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/reference/index.md +2 -2
  20. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/reference/mixins.md +1 -1
  21. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/reference/pagination-types.md +26 -5
  22. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/tutorials/02-building-a-blog-api.md +3 -3
  23. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/explanation/cached-table.md +3 -3
  24. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/explanation/metaclass.md +7 -7
  25. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/explanation/polymorphic-internals.md +1 -1
  26. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/explanation/transactional-cache-transparency.md +5 -6
  27. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/how-to/cache-queries.md +1 -1
  28. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/how-to/extra-mixins.md +3 -2
  29. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/how-to/integrate-with-fastapi.md +4 -11
  30. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/how-to/keyset-pagination.md +3 -3
  31. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/how-to/paginate-a-list-endpoint.md +15 -20
  32. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/reference/crud-methods.md +4 -4
  33. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/reference/decorators.md +1 -1
  34. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/reference/field-types.md +1 -1
  35. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/reference/index.md +2 -2
  36. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/reference/mixins.md +1 -1
  37. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/reference/pagination-types.md +26 -5
  38. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/tutorials/02-building-a-blog-api.md +3 -3
  39. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/pyproject.toml +1 -1
  40. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/pyrightconfig.json +5 -5
  41. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/__init__.py +4 -1
  42. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/_compat.py +4 -2
  43. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/_exceptions.py +1 -1
  44. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/_sa_type.py +20 -8
  45. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/_type_unwrap.py +10 -2
  46. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/base.py +245 -86
  47. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/check_derived.py +8 -8
  48. sqlmodel_ext-0.5.1/src/sqlmodel_ext/dependencies.py +178 -0
  49. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/field_types/__init__.py +27 -39
  50. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/field_types/_internal/path.py +22 -10
  51. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/field_types/dialects/postgresql/array.py +27 -13
  52. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/field_types/dialects/postgresql/jsonb_types.py +4 -5
  53. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/field_types/dialects/postgresql/numpy_vector.py +40 -24
  54. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/field_types/mixins/module_name_mixin.py +2 -2
  55. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/field_types/url.py +6 -6
  56. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/mixins/cached_table.py +239 -266
  57. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/mixins/migration_cache_invalidation.py +6 -6
  58. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/mixins/polymorphic.py +36 -23
  59. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/mixins/relation_preload.py +16 -16
  60. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/mixins/resource_quota.py +9 -6
  61. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/mixins/table.py +44 -28
  62. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/mixins/trgm_searchable.py +1 -1
  63. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/pagination.py +72 -39
  64. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/relation_load_checker.py +21 -15
  65. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/session.py +60 -33
  66. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/unset.py +1 -3
  67. sqlmodel_ext-0.5.1/tests/test_cached_commit_paths.py +265 -0
  68. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_enhanced_session.py +6 -3
  69. sqlmodel_ext-0.5.1/tests/test_final_051_rereview.py +52 -0
  70. sqlmodel_ext-0.5.1/tests/test_final_051_review.py +262 -0
  71. sqlmodel_ext-0.5.1/tests/test_final_051_review2.py +72 -0
  72. sqlmodel_ext-0.5.1/tests/test_inherited_column_attributes.py +666 -0
  73. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_pagination.py +23 -0
  74. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_pg_dialect_types.py +1 -1
  75. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_polymorphic_jti.py +1 -1
  76. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_polymorphic_sti_query.py +1 -1
  77. sqlmodel_ext-0.5.1/tests/test_query_dependency.py +326 -0
  78. sqlmodel_ext-0.5.1/tests/test_query_dependency_metadata.py +154 -0
  79. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_smoke_imports.py +2 -0
  80. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/.gitignore +0 -0
  81. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/LICENSE +0 -0
  82. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/ai-rules/CLAUDE.md +0 -0
  83. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/ai-rules/README.md +0 -0
  84. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/.vitepress/config.mts +0 -0
  85. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/.vitepress/en.mts +0 -0
  86. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/.vitepress/theme/index.ts +0 -0
  87. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/.vitepress/theme/style/custom.css +0 -0
  88. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/.vitepress/zh.mts +0 -0
  89. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/explanation/cascade-delete-semantics.md +0 -0
  90. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/explanation/crud-pipeline.md +0 -0
  91. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/explanation/index.md +0 -0
  92. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/explanation/optimistic-lock.md +0 -0
  93. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/explanation/prerequisites.md +0 -0
  94. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/explanation/relation-load-checker.md +0 -0
  95. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/explanation/relation-preload.md +0 -0
  96. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/explanation/single-source-of-truth.md +0 -0
  97. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/explanation/unset-three-state.md +0 -0
  98. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/guide/cached-table.md +0 -0
  99. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/guide/crud.md +0 -0
  100. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/guide/field-types.md +0 -0
  101. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/guide/index.md +0 -0
  102. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/guide/optimistic-lock.md +0 -0
  103. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/guide/pagination.md +0 -0
  104. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/guide/polymorphic.md +0 -0
  105. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/guide/relation-load-checker.md +0 -0
  106. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/guide/relation-preload.md +0 -0
  107. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/how-to/aggregate-queries.md +0 -0
  108. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/how-to/check-partial-dtos.md +0 -0
  109. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/how-to/configure-cascade-delete.md +0 -0
  110. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/how-to/define-jti-models.md +0 -0
  111. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/how-to/define-sti-models.md +0 -0
  112. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/how-to/enforce-locking-and-isolation.md +0 -0
  113. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/how-to/handle-concurrent-updates.md +0 -0
  114. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/how-to/handle-referenced-deletes.md +0 -0
  115. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/how-to/index.md +0 -0
  116. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/how-to/migrate-to-0-5.md +0 -0
  117. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/how-to/prevent-missing-greenlet.md +0 -0
  118. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/how-to/release-connection-during-long-io.md +0 -0
  119. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/how-to/type-check-with-basedpyright.md +0 -0
  120. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/how-to/use-with-ai-assistants.md +0 -0
  121. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/how-to/write-patch-endpoints.md +0 -0
  122. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/index.md +0 -0
  123. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/internals/cached-table.md +0 -0
  124. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/internals/crud.md +0 -0
  125. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/internals/index.md +0 -0
  126. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/internals/metaclass.md +0 -0
  127. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/internals/optimistic-lock.md +0 -0
  128. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/internals/polymorphic.md +0 -0
  129. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/internals/prerequisites.md +0 -0
  130. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/internals/relation-load-checker.md +0 -0
  131. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/internals/relation-preload.md +0 -0
  132. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/reference/base-classes.md +0 -0
  133. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/tutorials/01-getting-started.md +0 -0
  134. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/tutorials/03-adding-redis-cache.md +0 -0
  135. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/en/tutorials/index.md +0 -0
  136. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/explanation/cascade-delete-semantics.md +0 -0
  137. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/explanation/crud-pipeline.md +0 -0
  138. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/explanation/index.md +0 -0
  139. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/explanation/optimistic-lock.md +0 -0
  140. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/explanation/prerequisites.md +0 -0
  141. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/explanation/relation-load-checker.md +0 -0
  142. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/explanation/relation-preload.md +0 -0
  143. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/explanation/single-source-of-truth.md +0 -0
  144. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/explanation/unset-three-state.md +0 -0
  145. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/guide/cached-table.md +0 -0
  146. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/guide/crud.md +0 -0
  147. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/guide/field-types.md +0 -0
  148. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/guide/index.md +0 -0
  149. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/guide/optimistic-lock.md +0 -0
  150. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/guide/pagination.md +0 -0
  151. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/guide/polymorphic.md +0 -0
  152. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/guide/relation-load-checker.md +0 -0
  153. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/guide/relation-preload.md +0 -0
  154. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/how-to/aggregate-queries.md +0 -0
  155. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/how-to/check-partial-dtos.md +0 -0
  156. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/how-to/configure-cascade-delete.md +0 -0
  157. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/how-to/define-jti-models.md +0 -0
  158. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/how-to/define-sti-models.md +0 -0
  159. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/how-to/enforce-locking-and-isolation.md +0 -0
  160. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/how-to/handle-concurrent-updates.md +0 -0
  161. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/how-to/handle-referenced-deletes.md +0 -0
  162. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/how-to/index.md +0 -0
  163. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/how-to/migrate-to-0-5.md +0 -0
  164. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/how-to/prevent-missing-greenlet.md +0 -0
  165. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/how-to/release-connection-during-long-io.md +0 -0
  166. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/how-to/type-check-with-basedpyright.md +0 -0
  167. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/how-to/use-with-ai-assistants.md +0 -0
  168. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/how-to/write-patch-endpoints.md +0 -0
  169. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/index.md +0 -0
  170. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/internals/cached-table.md +0 -0
  171. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/internals/crud.md +0 -0
  172. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/internals/index.md +0 -0
  173. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/internals/metaclass.md +0 -0
  174. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/internals/optimistic-lock.md +0 -0
  175. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/internals/polymorphic.md +0 -0
  176. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/internals/prerequisites.md +0 -0
  177. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/internals/relation-load-checker.md +0 -0
  178. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/internals/relation-preload.md +0 -0
  179. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/reference/base-classes.md +0 -0
  180. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/tutorials/01-getting-started.md +0 -0
  181. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/tutorials/03-adding-redis-cache.md +0 -0
  182. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/docs/tutorials/index.md +0 -0
  183. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/examples/01_unset_patch.py +0 -0
  184. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/examples/02_single_source_types.py +0 -0
  185. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/examples/03_keyset_pagination.py +0 -0
  186. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/examples/04_locking_and_isolation.py +0 -0
  187. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/examples/05_aggregates.py +0 -0
  188. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/examples/06_referenced_delete.py +0 -0
  189. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/examples/07_transactional_cache.py +0 -0
  190. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/examples/08_optimistic_lock.py +0 -0
  191. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/examples/09_relation_preload_bulk.py +0 -0
  192. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/examples/10_typed_select.py +0 -0
  193. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/examples/11_type_errors_caught_by_basedpyright.py +0 -0
  194. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/examples/README.md +0 -0
  195. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/examples/check_derived_demo/pyrightconfig.json +0 -0
  196. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/examples/check_derived_demo/shop/__init__.py +0 -0
  197. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/examples/check_derived_demo/shop/handlers.py +0 -0
  198. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/examples/check_derived_demo/shop/models.py +0 -0
  199. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/examples/consumer_integration/run.py +0 -0
  200. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/package.json +0 -0
  201. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/pnpm-lock.yaml +0 -0
  202. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/_utils.py +0 -0
  203. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/constants.py +0 -0
  204. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/derived_decls.py +0 -0
  205. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/field_types/_internal/__init__.py +0 -0
  206. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/field_types/_ssrf.py +0 -0
  207. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/field_types/dialects/__init__.py +0 -0
  208. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/field_types/dialects/postgresql/__init__.py +0 -0
  209. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/field_types/dialects/postgresql/exceptions.py +0 -0
  210. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/field_types/ip_address.py +0 -0
  211. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/field_types/mixins/__init__.py +0 -0
  212. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/mixins/__init__.py +0 -0
  213. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/mixins/_uuid.py +0 -0
  214. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/mixins/constants.py +0 -0
  215. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/mixins/exceptions.py +0 -0
  216. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/mixins/info_response.py +0 -0
  217. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/mixins/mixin_table_scan.py +0 -0
  218. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/mixins/optimistic_lock.py +0 -0
  219. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/py.typed +0 -0
  220. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/src/sqlmodel_ext/select.py +0 -0
  221. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/__init__.py +0 -0
  222. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/_models.py +0 -0
  223. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/conftest.py +0 -0
  224. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_annotated_default_factory_inheritance.py +0 -0
  225. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_array_explicit_field_sa_type.py +0 -0
  226. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_basic_crud.py +0 -0
  227. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_cached_table.py +0 -0
  228. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_check_derived.py +0 -0
  229. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_crud_extended.py +0 -0
  230. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_decimal_types.py +0 -0
  231. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_deferred_index.py +0 -0
  232. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_derived_decls.py +0 -0
  233. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_examples.py +0 -0
  234. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_field_types_aliases_extra.py +0 -0
  235. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_field_types_jsonb_limits.py +0 -0
  236. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_field_types_misc.py +0 -0
  237. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_field_types_str_num.py +0 -0
  238. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_field_types_url_ssrf.py +0 -0
  239. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_final_check_derived_review2.py +0 -0
  240. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_final_check_derived_temp_root.py +0 -0
  241. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_final_nested_duplicate_names.py +0 -0
  242. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_final_omitted_sentinel_alias_modes.py +0 -0
  243. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_final_partial_alias_generator.py +0 -0
  244. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_final_partial_annotated_field_warnings.py +0 -0
  245. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_final_partial_discriminator.py +0 -0
  246. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_final_partial_frozen_field.py +0 -0
  247. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_final_partial_rhs_field_metadata.py +0 -0
  248. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_final_review4_followup.py +0 -0
  249. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_final_review5.py +0 -0
  250. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_metaclass_extra.py +0 -0
  251. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_mixins_extra.py +0 -0
  252. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_optimistic_lock.py +0 -0
  253. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_partial.py +0 -0
  254. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_relation_load_checker.py +0 -0
  255. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_relation_load_checker_load_resolution.py +0 -0
  256. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_relation_load_checker_sessions.py +0 -0
  257. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_relation_preload.py +0 -0
  258. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_relationship_pep604.py +0 -0
  259. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_review_mixed_version_cache.py +0 -0
  260. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_review_raw_insert_cache.py +0 -0
  261. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_review_raw_insert_edges.py +0 -0
  262. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_review_raw_insert_late_model.py +0 -0
  263. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_review_raw_insert_sti.py +0 -0
  264. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_select.py +0 -0
  265. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/tests/test_sti_default_isolation.py +0 -0
  266. {sqlmodel_ext-0.5.0 → sqlmodel_ext-0.5.1}/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,96 @@ 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.1]
11
+
12
+ ### Added
13
+
14
+ - **`query_dependency(Model)`**: FastAPI dependency for query-parameter DTOs
15
+ (`TableViewRequest`, `PaginationRequest`, `PageWindowRequest`, `TimeFilterRequest`,
16
+ `TrgmSearchRequest`, your subclasses):
17
+ `Annotated[TableViewRequest, Depends(query_dependency(TableViewRequest))]`. It declares
18
+ one query parameter per field carrying the field's type, constraints, default, `title`,
19
+ description, `examples`, `deprecated`, `json_schema_extra` and `discriminator` (each
20
+ parameter's OpenAPI schema is the field's JSON schema; a callable `json_schema_extra`
21
+ or a `Discriminator` object is a `TypeError`), builds the model itself and
22
+ re-raises a failed construction as `RequestValidationError` with locations prefixed by
23
+ `'query'`, so FastAPI answers 422. Requires the `fastapi` extra when called. See
24
+ [Paginate a list endpoint](docs/en/how-to/paginate-a-list-endpoint.md).
25
+
26
+ ### Fixed
27
+
28
+ - A bare mutation of a cached model that was flushed before the enhanced `commit()` -- inside
29
+ a savepoint (`begin_nested()` + `flush()`), by a manual `session.flush()`, or by an
30
+ autoflush -- was never invalidated: the cache kept serving the pre-commit row after the
31
+ commit. Pending invalidations were collected by scanning `new` / `dirty` / `deleted`
32
+ right before the commit, and a flushed object is no longer in those sets. Every flush
33
+ now registers the cached instances it writes (`after_flush`); an outermost rollback
34
+ drops them, a savepoint rollback keeps them (at most one extra invalidation). Also
35
+ present in 0.5.0.
36
+ - A table class inheriting a field declared with a type alias and a right-hand
37
+ `Field(...)` (`language: Str64 = Field(index=True)` in a non-table base) lost that
38
+ `Field`'s attributes: the column had no index / unique constraint / primary key /
39
+ foreign key / `nullable` / `sa_type` / `sa_column_kwargs` / `ondelete`, and the
40
+ Pydantic field could lose attributes set only there (e.g. `title`). The metaclass
41
+ rebuilt the inherited field from the alias's `Field` alone. The inherited field is now
42
+ the base class's resolved field (`Base.model_fields[name]`, in which Pydantic has
43
+ already merged the alias's `Field` and the right-hand one): its Pydantic attributes
44
+ (`default`, `alias`, `validation_alias`, `title`, `description`, ...) are taken as-is,
45
+ `None` included, so a base `Field(alias=None)` still clears the alias's `alias` in the
46
+ table class. Only the SQLModel column attributes are completed: the alias's and the
47
+ right-hand `Field`'s column settings are folded into one (the right-hand side wins on
48
+ conflicts; `False` never switches off `True`), and constraints of an alias nested in
49
+ a union (`Alias | None = Field(...)`), which Pydantic does not merge at field level,
50
+ are added. Plain-typed inherited fields (`int = Field(index=True)`) were not affected.
51
+ Also present in 0.5.0.
52
+ - A cross-field validation failure of `TableViewRequest` (and the other query DTOs) used
53
+ as a FastAPI dependency (`after_id` with a non-zero `offset`, `after_id` with a mutable
54
+ `order`, an inverted time range) was a 500. Declared through `query_dependency()` it is
55
+ a 422 located at the offending query parameter; the documented `ValidationError`
56
+ exception-handler workaround is no longer needed.
57
+ - Every commit of a cached model through the enhanced `AsyncSession` logged a spurious
58
+ `WARNING` "fallback compensation triggered: ..." and invalidated the cache a second
59
+ time. The `after_commit` event now hands the committed pendings over to the enhanced
60
+ `commit()`, which invalidates each of them once. Commits that bypass the enhanced
61
+ `commit()` (a plain SQLAlchemy / SQLModel session, `run_sync(lambda s: s.commit())`)
62
+ keep the fire-and-forget compensation and its `WARNING`; it now also takes over when
63
+ the enhanced `commit()` fails or is cancelled after the database committed.
64
+
65
+ ### Changed
66
+
67
+ - A table class field declared with a type alias **and** a right-hand `Field(...)`
68
+ (`name: Alias = Field(...)`) now gets its Pydantic attributes exactly as Pydantic
69
+ resolves that declaration -- the same field plain SQLModel builds, and the same field
70
+ a table class gets when it inherits the declaration from a base. The right-hand
71
+ `Field` overrides the alias's Pydantic attributes, `None` included; sqlmodel's
72
+ `Field()` passes every one of them explicitly, so with a right-hand `Field` the
73
+ alias's `alias` / `validation_alias` / `serialization_alias` / `title` /
74
+ `description` no longer apply (`Field(alias=None)` clears the alias's `alias`;
75
+ previously the metaclass skipped `None` and kept the alias's values). The alias's
76
+ `default` still applies unless the right-hand `Field` sets one -- except for an alias
77
+ nested in a union (`Alias | None = Field(...)`), whose `Field` Pydantic does not
78
+ merge: such a field is required unless the right-hand `Field` sets a default. Of the
79
+ bundled aliases only the `Optional*Decimal*` ones carry a Pydantic attribute
80
+ (`default=None`), so `OptionalNonNegativeDecimal38_18 | None = Field(index=True)` is
81
+ now required; drop the redundant `| None` or add `default=None`. Column attributes are
82
+ unchanged: the alias's and the right-hand `Field`'s column settings are still folded
83
+ together.
84
+ - The cross-field rules of `PaginationRequest` and `TimeFilterRequest` are field validators:
85
+ a violation is still a `ValidationError` with the same message, but located at the
86
+ field the rule constrains (`after_id`, `created_before_datetime`,
87
+ `updated_before_datetime`) instead of the model root, and independent violations are
88
+ reported together. `TimeFilterRequest` no longer overrides `model_post_init`.
89
+ - `PolymorphicBaseMixin._is_joined_table_inheritance()` is renamed
90
+ `is_joined_table_inheritance()` (it was documented and called across modules; the
91
+ underscore was not a real boundary). Update direct callers.
92
+ - `get(table_view=...)` is annotated `TableViewArgument` (`TimeFilterRequest |
93
+ PageWindowRequest`), matching what it already accepted at runtime (e.g. a bare
94
+ `PageWindowRequest`). The `join=(Model, onclause)` ON clause is annotated with the
95
+ new `OnClauseArgument` alias (SQLAlchemy's public `OnClauseRole`) instead of
96
+ SQLAlchemy's private `_OnClauseArgument`.
97
+ - The type-check gate of the repository is now 0 errors **and 0 warnings**
98
+ (`failOnWarnings: true`).
99
+
10
100
  ## [0.5.0]
11
101
 
12
102
  Upgrade guide: [Migrate from 0.4.x to 0.5.0](docs/en/how-to/migrate-to-0-5.md)
@@ -164,14 +254,6 @@ Upgrade guide: [Migrate from 0.4.x to 0.5.0](docs/en/how-to/migrate-to-0-5.md)
164
254
  database has already committed): the affected cache entries keep serving
165
255
  pre-commit data until their TTL expires. Use `no_cache=True` for reads that must
166
256
  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
257
  - On Python 3.12, `JSON100K | None` as a `table=True` field raises
176
258
  `has no matching SQLAlchemy type`; declare the column explicitly with
177
259
  `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.1
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. 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. A field with a right-hand `Field` (`name: Alias = Field(...)`) is rebuilt from Pydantic's resolution of that declaration — `FieldInfo.from_annotated_attribute()` when the table class declares it, the base's resolved field `Base.model_fields[name]` when the table class **inherits** it (without re-declaring it). Both give the same field: its Pydantic attributes are taken as-is (an explicit `alias=None` stays `None`, as in plain SQLModel), and only the column attributes are completed — the `FieldInfoMetadata` carriers of the alias's and the right-hand `Field` are folded into one, since SQLModel reads only the first. 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,7 +178,7 @@ 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:
@@ -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
 
@@ -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
 
@@ -154,14 +154,14 @@ async def get(
154
154
  offset: int | None = None,
155
155
  limit: int | None = None,
156
156
  fetch_mode: Literal["one", "first", "all"] = "first",
157
- join: type[TableBaseMixin] | tuple[type[TableBaseMixin], _OnClauseArgument] | None = None,
157
+ join: type[TableBaseMixin] | tuple[type[TableBaseMixin], OnClauseArgument] | None = None,
158
158
  options: list[ExecutableOption] | None = None,
159
159
  load: QueryableAttribute[Any] | list[QueryableAttribute[Any]] | None = None,
160
160
  order_by: list[ColumnElement[Any]] | None = None,
161
161
  filter: ColumnElement[bool] | bool | None = None,
162
162
  with_for_update: bool = False,
163
163
  skip_locked: bool = False,
164
- table_view: TableViewRequest | None = None,
164
+ table_view: TableViewArgument | None = None, # TimeFilterRequest | PageWindowRequest
165
165
  jti_subclasses: list[type[PolymorphicBaseMixin]] | Literal['all'] | None = None,
166
166
  populate_existing: bool = False,
167
167
  authoritative: bool = False,
@@ -195,7 +195,7 @@ On `CachedTableBaseMixin` models, `get()` has one extra parameter, `no_cache: bo
195
195
  | `filter` | `ColumnElement[bool]` | Additional WHERE condition |
196
196
  | `with_for_update` | `bool` | `SELECT ... FOR UPDATE` row lock. Instance `id()`s are written to `session.info[SESSION_FOR_UPDATE_KEY]`; **forces** `populate_existing` (cannot be turned off), guaranteeing you get the latest database values rather than a stale object from the identity map |
197
197
  | `skip_locked` | `bool` | `FOR UPDATE SKIP LOCKED`: skip rows locked by other transactions instead of waiting. Only effective when `with_for_update=True`. "0 rows" may also mean "every candidate is locked by someone else", so **do not** use it for existence checks |
198
- | `table_view` | `TableViewRequest` | Bundle of pagination + ordering + time filtering + keyset cursor parameters. `order` always appends `id` in the same direction as a tiebreaker; `after_id` applies the keyset cursor |
198
+ | `table_view` | `TableViewRequest` (or any `TimeFilterRequest` / `PageWindowRequest`; each part is applied when present) | Bundle of pagination + ordering + time filtering + keyset cursor parameters. `order` always appends `id` in the same direction as a tiebreaker; `after_id` applies the keyset cursor |
199
199
  | `jti_subclasses` | `list[type] \| 'all'` | Subclass loading for JTI polymorphic relationships (requires `load`) |
200
200
  | `populate_existing` | `bool` | Lock-free forced overwrite of identity-map objects with database data |
201
201
  | `authoritative` | `bool` | The single switch for **authorization reads** (the result decides whether something is allowed, so it must be authoritative): at this layer equivalent to `populate_existing=True`; cached models additionally bypass Redis. Monotonically merged with `populate_existing` (`or`) |
@@ -346,7 +346,7 @@ async def get_with_count(
346
346
  session: AsyncSession,
347
347
  condition: ColumnElement[bool] | bool | None = None,
348
348
  *,
349
- join: type[TableBaseMixin] | tuple[type[TableBaseMixin], _OnClauseArgument] | None = None,
349
+ join: type[TableBaseMixin] | tuple[type[TableBaseMixin], OnClauseArgument] | None = None,
350
350
  options: list[ExecutableOption] | None = None,
351
351
  load: QueryableAttribute[Any] | list[QueryableAttribute[Any]] | None = None,
352
352
  order_by: list[ColumnElement[Any]] | None = None,
@@ -190,7 +190,7 @@ from sqlmodel_ext.session import SessionFactory
190
190
 
191
191
  | Member | Description |
192
192
  |------|------|
193
- | `commit(*, fail_soft_when_observed=False)` | Before commit, automatically registers changes to all cached models (including bare `session.add()` / attribute changes / `session.delete()`); after commit, invalidates **synchronously**, then runs the post-commit callbacks in order. `fail_soft_when_observed=True`: a failure of the commit itself is raised as usual; every step **after** the commit (invalidation, each callback) is individually fault-tolerant (catches `BaseException` including cancellation, logs, continues). Default: errors raised by the invalidation step and cancellation propagate; a callback's `Exception` is logged and skipped. **Redis failures during invalidation are logged, not raised, in both modes** — the database has committed, so the affected cache entries serve pre-commit data until their TTL expires; use `no_cache=True` for reads that must not rely on the cache |
193
+ | `commit(*, fail_soft_when_observed=False)` | Changes to cached models (including bare `session.add()` / attribute changes / `session.delete()`) are registered by the flush that writes them -- earlier in the transaction or during this commit; after commit, invalidates **synchronously**, then runs the post-commit callbacks in order. `fail_soft_when_observed=True`: a failure of the commit itself is raised as usual; every step **after** the commit (invalidation, each callback) is individually fault-tolerant (catches `BaseException` including cancellation, logs, continues). Default: errors raised by the invalidation step and cancellation propagate; a callback's `Exception` is logged and skipped. **Redis failures during invalidation are logged, not raised, in both modes** — the database has committed, so the affected cache entries serve pre-commit data until their TTL expires; use `no_cache=True` for reads that must not rely on the cache |
194
194
  | `commit_count` (property) | Number of successful commits on this session. The single source of truth for "was it committed": record a baseline before a critical write, and `commit_count > baseline` afterwards means the database has committed (a sufficient but not necessary signal) |
195
195
  | `add_post_commit_callback(callback)` | Registers an `async` zero-argument callback, run **only after the next real commit**; discarded by `rollback()` / `reset()` / `close()`; registering inside a savepoint → `RuntimeError`; a failing callback doesn't block later callbacks nor change the commit result |
196
196
  | `rollback(*, best_effort_budget_seconds=None)` | Rolls back + discards pending callbacks. Passing a budget enters **bounded abandon** mode: rolls back under `asyncio.timeout`, and on failure or timeout does a best-effort `invalidate()` of the connection, **without raising** (`CancelledError` still propagates). Not a hard cap (`invalidate` may still wait inside the driver) |
@@ -358,7 +358,7 @@ from sqlmodel_ext.field_types.dialects.postgresql import JSON100K, JSONList100K,
358
358
 
359
359
  Contract: **object in, object out**.
360
360
 
361
- - **Inbound**: accepts a JSON object / array (preferred) or a JSON **string** (compatibility form). Both forms must be encodable (nesting within the limits of both serializers, orjson and Pydantic — Pydantic's is about 98 levels) with a canonical encoding of at most 100K **characters** (counted in characters, not UTF-8 bytes, so CJK text is not rejected 3x too early).
361
+ - **Inbound**: accepts a JSON object / array (preferred) or a JSON **string** (compatibility form). Both forms must be encodable (nesting within the limits of both serializers, orjson and Pydantic — Pydantic's is platform-dependent, about 98 levels on Windows builds and higher on Linux) with a canonical encoding of at most 100K **characters** (counted in characters, not UTF-8 bytes, so CJK text is not rejected 3x too early).
362
362
  - **Outbound**: `model_dump()`, `model_dump(mode='json')` and `model_dump_json()` all output the object / array itself (since 0.5.0; it used to be a JSON string). The serialization schema declares a pure object / array; the validation schema honestly declares `anyOf[object, string]`.
363
363
  - **Table models are checked too**: `table=True` models skip Pydantic validation, so `SQLModelBase.model_post_init` calls `ensure_json_within_limits` on these fields (call `super()` when overriding `model_post_init`). Code paths that bypass models can call `ensure_json_within_limits(value)` directly.
364
364
  - The 100K limit does not appear in the JSON Schema (JSON Schema cannot express an encoded length for objects); document it in the field docstring.
@@ -15,7 +15,7 @@ from sqlmodel_ext import (
15
15
  PolymorphicBaseMixin, AutoPolymorphicIdentityMixin,
16
16
  create_subclass_id_mixin,
17
17
  RelationPreloadMixin, requires_relations, requires_for_update,
18
- ListResponse, TableViewRequest, PaginationRequest, TimeFilterRequest,
18
+ ListResponse, TableViewRequest, PaginationRequest, TimeFilterRequest, query_dependency,
19
19
  Str64, Port, HttpUrl, SafeHttpUrl, IPAddress, ...
20
20
  )
21
21
  ```
@@ -29,7 +29,7 @@ from sqlmodel_ext import (
29
29
  | [Field types](./field-types) | `Str16`–`Text1M`, `Port`, `Percentage`, `PositiveInt`, `HttpUrl`, `SafeHttpUrl`, `IPAddress`, `Array[T]`, `JSON100K`, `NumpyVector` |
30
30
  | [Mixins](./mixins) | `CachedTableBaseMixin`, `OptimisticLockMixin`, `PolymorphicBaseMixin`, `AutoPolymorphicIdentityMixin`, `RelationPreloadMixin`, info response mixins |
31
31
  | [Decorators & helpers](./decorators) | `@requires_relations`, `@requires_for_update`, `rel()`, `cond()`, enhanced `session.reset()` |
32
- | [Pagination types](./pagination-types) | `ListResponse[T]`, `TableViewRequest`, `PaginationRequest`, `TimeFilterRequest` |
32
+ | [Pagination types](./pagination-types) | `ListResponse[T]`, `TableViewRequest`, `PaginationRequest`, `TimeFilterRequest`, `query_dependency()` |
33
33
 
34
34
  ## Constants
35
35
 
@@ -171,7 +171,7 @@ from sqlmodel_ext import PolymorphicBaseMixin
171
171
 
172
172
  ```python
173
173
  @classmethod
174
- def _is_joined_table_inheritance(cls) -> bool
174
+ def is_joined_table_inheritance(cls) -> bool
175
175
 
176
176
  @classmethod
177
177
  def get_concrete_subclasses(cls) -> list[type[PolymorphicBaseMixin]]