sqlmodel-ext 0.3.2__tar.gz → 0.3.3__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 (152) hide show
  1. sqlmodel_ext-0.3.3/.github/workflows/ci.yml +100 -0
  2. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/PKG-INFO +1 -1
  3. sqlmodel_ext-0.3.3/examples/consumer_integration/run.py +148 -0
  4. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/pyproject.toml +1 -1
  5. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/__init__.py +1 -1
  6. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/_compat.py +13 -3
  7. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/base.py +123 -7
  8. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/field_types/__init__.py +23 -0
  9. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/mixins/cached_table.py +71 -36
  10. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/mixins/table.py +182 -33
  11. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/pagination.py +2 -2
  12. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/relation_load_checker.py +97 -9
  13. sqlmodel_ext-0.3.3/tests/__init__.py +0 -0
  14. sqlmodel_ext-0.3.3/tests/_models.py +45 -0
  15. sqlmodel_ext-0.3.3/tests/conftest.py +58 -0
  16. sqlmodel_ext-0.3.3/tests/test_annotated_default_factory_inheritance.py +194 -0
  17. sqlmodel_ext-0.3.3/tests/test_basic_crud.py +61 -0
  18. sqlmodel_ext-0.3.3/tests/test_smoke_imports.py +90 -0
  19. sqlmodel_ext-0.3.3/tests/test_sti_default_isolation.py +132 -0
  20. sqlmodel_ext-0.3.3/uv.lock +585 -0
  21. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/.gitignore +0 -0
  22. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/LICENSE +0 -0
  23. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/README.md +0 -0
  24. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/README_zh.md +0 -0
  25. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/.vitepress/config.mts +0 -0
  26. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/.vitepress/en.mts +0 -0
  27. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/.vitepress/theme/index.ts +0 -0
  28. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/.vitepress/theme/style/custom.css +0 -0
  29. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/.vitepress/zh.mts +0 -0
  30. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/explanation/cached-table.md +0 -0
  31. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/explanation/cascade-delete-semantics.md +0 -0
  32. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/explanation/crud-pipeline.md +0 -0
  33. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/explanation/index.md +0 -0
  34. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/explanation/metaclass.md +0 -0
  35. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/explanation/optimistic-lock.md +0 -0
  36. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/explanation/polymorphic-internals.md +0 -0
  37. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/explanation/prerequisites.md +0 -0
  38. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/explanation/relation-load-checker.md +0 -0
  39. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/explanation/relation-preload.md +0 -0
  40. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/guide/cached-table.md +0 -0
  41. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/guide/crud.md +0 -0
  42. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/guide/field-types.md +0 -0
  43. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/guide/index.md +0 -0
  44. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/guide/optimistic-lock.md +0 -0
  45. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/guide/pagination.md +0 -0
  46. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/guide/polymorphic.md +0 -0
  47. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/guide/relation-load-checker.md +0 -0
  48. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/guide/relation-preload.md +0 -0
  49. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/how-to/cache-queries.md +0 -0
  50. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/how-to/configure-cascade-delete.md +0 -0
  51. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/how-to/define-jti-models.md +0 -0
  52. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/how-to/define-sti-models.md +0 -0
  53. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/how-to/handle-concurrent-updates.md +0 -0
  54. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/how-to/index.md +0 -0
  55. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/how-to/integrate-with-fastapi.md +0 -0
  56. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/how-to/paginate-a-list-endpoint.md +0 -0
  57. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/how-to/prevent-missing-greenlet.md +0 -0
  58. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/how-to/release-connection-during-long-io.md +0 -0
  59. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/index.md +0 -0
  60. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/internals/cached-table.md +0 -0
  61. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/internals/crud.md +0 -0
  62. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/internals/index.md +0 -0
  63. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/internals/metaclass.md +0 -0
  64. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/internals/optimistic-lock.md +0 -0
  65. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/internals/polymorphic.md +0 -0
  66. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/internals/prerequisites.md +0 -0
  67. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/internals/relation-load-checker.md +0 -0
  68. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/internals/relation-preload.md +0 -0
  69. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/reference/base-classes.md +0 -0
  70. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/reference/crud-methods.md +0 -0
  71. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/reference/decorators.md +0 -0
  72. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/reference/field-types.md +0 -0
  73. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/reference/index.md +0 -0
  74. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/reference/mixins.md +0 -0
  75. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/reference/pagination-types.md +0 -0
  76. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/tutorials/01-getting-started.md +0 -0
  77. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/tutorials/02-building-a-blog-api.md +0 -0
  78. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/tutorials/03-adding-redis-cache.md +0 -0
  79. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/en/tutorials/index.md +0 -0
  80. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/explanation/cached-table.md +0 -0
  81. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/explanation/cascade-delete-semantics.md +0 -0
  82. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/explanation/crud-pipeline.md +0 -0
  83. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/explanation/index.md +0 -0
  84. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/explanation/metaclass.md +0 -0
  85. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/explanation/optimistic-lock.md +0 -0
  86. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/explanation/polymorphic-internals.md +0 -0
  87. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/explanation/prerequisites.md +0 -0
  88. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/explanation/relation-load-checker.md +0 -0
  89. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/explanation/relation-preload.md +0 -0
  90. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/guide/cached-table.md +0 -0
  91. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/guide/crud.md +0 -0
  92. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/guide/field-types.md +0 -0
  93. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/guide/index.md +0 -0
  94. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/guide/optimistic-lock.md +0 -0
  95. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/guide/pagination.md +0 -0
  96. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/guide/polymorphic.md +0 -0
  97. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/guide/relation-load-checker.md +0 -0
  98. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/guide/relation-preload.md +0 -0
  99. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/how-to/cache-queries.md +0 -0
  100. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/how-to/configure-cascade-delete.md +0 -0
  101. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/how-to/define-jti-models.md +0 -0
  102. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/how-to/define-sti-models.md +0 -0
  103. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/how-to/handle-concurrent-updates.md +0 -0
  104. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/how-to/index.md +0 -0
  105. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/how-to/integrate-with-fastapi.md +0 -0
  106. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/how-to/paginate-a-list-endpoint.md +0 -0
  107. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/how-to/prevent-missing-greenlet.md +0 -0
  108. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/how-to/release-connection-during-long-io.md +0 -0
  109. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/index.md +0 -0
  110. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/internals/cached-table.md +0 -0
  111. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/internals/crud.md +0 -0
  112. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/internals/index.md +0 -0
  113. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/internals/metaclass.md +0 -0
  114. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/internals/optimistic-lock.md +0 -0
  115. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/internals/polymorphic.md +0 -0
  116. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/internals/prerequisites.md +0 -0
  117. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/internals/relation-load-checker.md +0 -0
  118. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/internals/relation-preload.md +0 -0
  119. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/reference/base-classes.md +0 -0
  120. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/reference/crud-methods.md +0 -0
  121. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/reference/decorators.md +0 -0
  122. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/reference/field-types.md +0 -0
  123. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/reference/index.md +0 -0
  124. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/reference/mixins.md +0 -0
  125. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/reference/pagination-types.md +0 -0
  126. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/tutorials/01-getting-started.md +0 -0
  127. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/tutorials/02-building-a-blog-api.md +0 -0
  128. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/tutorials/03-adding-redis-cache.md +0 -0
  129. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/docs/tutorials/index.md +0 -0
  130. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/package.json +0 -0
  131. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/pnpm-lock.yaml +0 -0
  132. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/_exceptions.py +0 -0
  133. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/_sa_type.py +0 -0
  134. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/_utils.py +0 -0
  135. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/field_types/_internal/__init__.py +0 -0
  136. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/field_types/_internal/path.py +0 -0
  137. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/field_types/_ssrf.py +0 -0
  138. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/field_types/dialects/__init__.py +0 -0
  139. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/field_types/dialects/postgresql/__init__.py +0 -0
  140. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/field_types/dialects/postgresql/array.py +0 -0
  141. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/field_types/dialects/postgresql/exceptions.py +0 -0
  142. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/field_types/dialects/postgresql/jsonb_types.py +0 -0
  143. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/field_types/dialects/postgresql/numpy_vector.py +0 -0
  144. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/field_types/ip_address.py +0 -0
  145. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/field_types/mixins/__init__.py +0 -0
  146. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/field_types/mixins/module_name_mixin.py +0 -0
  147. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/field_types/url.py +0 -0
  148. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/mixins/__init__.py +0 -0
  149. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/mixins/info_response.py +0 -0
  150. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/mixins/optimistic_lock.py +0 -0
  151. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/mixins/polymorphic.py +0 -0
  152. {sqlmodel_ext-0.3.2 → sqlmodel_ext-0.3.3}/src/sqlmodel_ext/mixins/relation_preload.py +0 -0
@@ -0,0 +1,100 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ pull_request:
7
+ branches: [master]
8
+ workflow_dispatch:
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ concurrency:
14
+ group: ci-${{ github.ref }}
15
+ cancel-in-progress: true
16
+
17
+ jobs:
18
+ # -------------------------------------------------------------------
19
+ # Job 1: source-tree tests across supported Python versions.
20
+ # Uses uv sync so the lockfile is honoured and runs are reproducible.
21
+ # -------------------------------------------------------------------
22
+ test:
23
+ name: test (py${{ matrix.python-version }})
24
+ runs-on: ubuntu-latest
25
+ strategy:
26
+ fail-fast: false
27
+ matrix:
28
+ python-version: ["3.12", "3.13", "3.14"]
29
+ steps:
30
+ - name: Checkout
31
+ uses: actions/checkout@v4
32
+
33
+ - name: Install uv
34
+ uses: astral-sh/setup-uv@v5
35
+ with:
36
+ enable-cache: true
37
+ cache-dependency-glob: "uv.lock"
38
+
39
+ - name: Set up Python ${{ matrix.python-version }}
40
+ run: uv python install ${{ matrix.python-version }}
41
+
42
+ - name: Create venv and install project with dev extras
43
+ # Dev extras: pytest, pytest-asyncio, aiosqlite, ruff. Editable install
44
+ # keeps the wheel build separate for the consumer-integration job.
45
+ run: |
46
+ uv venv --python ${{ matrix.python-version }} .venv
47
+ uv pip install --python .venv/bin/python -e ".[dev]"
48
+
49
+ - name: Run pytest
50
+ run: .venv/bin/python -m pytest tests/ -v
51
+
52
+ # -------------------------------------------------------------------
53
+ # Job 2: build wheel + sdist, then install the wheel into a clean
54
+ # virtualenv with zero dev deps and run the consumer integration
55
+ # script. This simulates an external PyPI consumer.
56
+ # -------------------------------------------------------------------
57
+ consumer-integration:
58
+ name: consumer integration (py${{ matrix.python-version }})
59
+ runs-on: ubuntu-latest
60
+ needs: test
61
+ strategy:
62
+ fail-fast: false
63
+ matrix:
64
+ python-version: ["3.12", "3.13", "3.14"]
65
+ steps:
66
+ - name: Checkout
67
+ uses: actions/checkout@v4
68
+
69
+ - name: Install uv
70
+ uses: astral-sh/setup-uv@v5
71
+ with:
72
+ enable-cache: true
73
+
74
+ - name: Set up Python ${{ matrix.python-version }}
75
+ run: uv python install ${{ matrix.python-version }}
76
+
77
+ - name: Build wheel and sdist
78
+ run: uv tool run --from build python -m build
79
+
80
+ - name: Create isolated consumer venv
81
+ run: uv venv --python ${{ matrix.python-version }} /tmp/consumer-env
82
+
83
+ - name: Install built wheel (no dev deps, only the wheel + aiosqlite driver)
84
+ run: uv pip install --python /tmp/consumer-env/bin/python dist/*.whl aiosqlite
85
+
86
+ - name: Confirm installed version matches wheel
87
+ run: |
88
+ /tmp/consumer-env/bin/python -c "import sqlmodel_ext; print('installed:', sqlmodel_ext.__version__ if hasattr(sqlmodel_ext, '__version__') else '(no __version__)')"
89
+
90
+ - name: Run consumer integration scenario
91
+ run: /tmp/consumer-env/bin/python examples/consumer_integration/run.py
92
+
93
+ - name: Upload distribution artifacts
94
+ if: matrix.python-version == '3.12'
95
+ uses: actions/upload-artifact@v4
96
+ with:
97
+ name: dist
98
+ path: dist/
99
+ if-no-files-found: error
100
+ retention-days: 14
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlmodel-ext
3
- Version: 0.3.2
3
+ Version: 0.3.3
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
@@ -0,0 +1,148 @@
1
+ """
2
+ Consumer-side integration test.
3
+
4
+ Runs from a clean virtualenv that has ONLY the built sqlmodel-ext wheel
5
+ installed (plus aiosqlite for the async SQLite driver). Simulates what an
6
+ external PyPI consumer experiences: if this script passes, the wheel is
7
+ well-formed and the STI default-isolation fix behaves end-to-end.
8
+
9
+ Exits with status 0 on success, non-zero on any failed assertion. Intended
10
+ to be invoked directly by CI:
11
+
12
+ python -m pip install dist/*.whl aiosqlite
13
+ python examples/consumer_integration/run.py
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import sys
19
+
20
+ from sqlalchemy import select
21
+ from sqlalchemy.ext.asyncio import create_async_engine
22
+ from sqlalchemy.orm import configure_mappers
23
+ from sqlmodel import SQLModel
24
+ from sqlmodel.ext.asyncio.session import AsyncSession
25
+
26
+ from sqlmodel_ext import (
27
+ AutoPolymorphicIdentityMixin,
28
+ PolymorphicBaseMixin,
29
+ SQLModelBase,
30
+ UUIDTableBaseMixin,
31
+ register_sti_column_properties_for_all_subclasses,
32
+ register_sti_columns_for_all_subclasses,
33
+ )
34
+
35
+
36
+ # ---------- STI model hierarchy ----------
37
+
38
+ class Tool(SQLModelBase, UUIDTableBaseMixin, PolymorphicBaseMixin, table=True):
39
+ """Shared STI parent table."""
40
+ name: str
41
+
42
+
43
+ class ExportFunction(Tool, AutoPolymorphicIdentityMixin, table=True):
44
+ """Declares ``max_size`` with a large realistic default."""
45
+ # 5 GiB chosen because the real-world production bug used exactly this value.
46
+ max_size: int = 5 * 1024 * 1024 * 1024
47
+
48
+
49
+ class UploadFunction(Tool, AutoPolymorphicIdentityMixin, table=True):
50
+ """Declares a different default for a different field."""
51
+ allow_overwrite: bool = True
52
+
53
+
54
+ class NoOpFunction(Tool, AutoPolymorphicIdentityMixin, table=True):
55
+ """The sibling with zero own fields - the purest victim of default leaks."""
56
+ pass
57
+
58
+
59
+ # ---------- Integration scenario ----------
60
+
61
+ FIVE_GIB = 5 * 1024 * 1024 * 1024
62
+
63
+
64
+ def fail(msg: str) -> None:
65
+ print(f"[FAIL] {msg}", file=sys.stderr)
66
+ sys.exit(1)
67
+
68
+
69
+ async def main() -> None:
70
+ # STI two-phase registration (public API contract).
71
+ register_sti_columns_for_all_subclasses()
72
+ configure_mappers()
73
+ register_sti_column_properties_for_all_subclasses()
74
+
75
+ # --- Layer 1: metadata assertions ---
76
+ shared = Tool.__table__.columns
77
+ if shared["max_size"].default is not None:
78
+ fail(
79
+ f"tool.max_size.default should be None (fix cleared the shared SA "
80
+ f"default). Got: {shared['max_size'].default!r}"
81
+ )
82
+ if shared["max_size"].server_default is not None:
83
+ fail(f"tool.max_size.server_default should be None")
84
+ if shared["allow_overwrite"].default is not None:
85
+ fail(
86
+ f"tool.allow_overwrite.default should be None. "
87
+ f"Got: {shared['allow_overwrite'].default!r}"
88
+ )
89
+
90
+ # Pydantic field-level defaults must still be intact.
91
+ if ExportFunction.model_fields["max_size"].default != FIVE_GIB:
92
+ fail(
93
+ f"Pydantic default for ExportFunction.max_size was wiped by the fix: "
94
+ f"{ExportFunction.model_fields['max_size'].default!r}"
95
+ )
96
+
97
+ # --- Layer 2: behavioural assertions via real async SQLite ---
98
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:")
99
+ try:
100
+ async with engine.begin() as conn:
101
+ await conn.run_sync(SQLModel.metadata.create_all)
102
+
103
+ async with AsyncSession(engine) as session:
104
+ # NoOpFunction does not declare max_size; the shared column must be NULL.
105
+ noop = NoOpFunction(name="noop")
106
+ noop = await noop.save(session)
107
+ result = await session.execute(
108
+ select(Tool.__table__.c.max_size).where(Tool.__table__.c.id == noop.id)
109
+ )
110
+ stored = result.scalar_one()
111
+ if stored is not None:
112
+ fail(
113
+ f"NoOpFunction.max_size should be NULL (bug: sibling default "
114
+ f"leaked from ExportFunction). Got: {stored!r}"
115
+ )
116
+
117
+ # UploadFunction also does not declare max_size.
118
+ upload = UploadFunction(name="upload")
119
+ upload = await upload.save(session)
120
+ result = await session.execute(
121
+ select(Tool.__table__.c.max_size).where(Tool.__table__.c.id == upload.id)
122
+ )
123
+ stored = result.scalar_one()
124
+ if stored is not None:
125
+ fail(
126
+ f"UploadFunction.max_size should be NULL. Got: {stored!r}"
127
+ )
128
+
129
+ # Happy path: declaring subclass still persists its own Pydantic default.
130
+ export = ExportFunction(name="export")
131
+ export = await export.save(session)
132
+ result = await session.execute(
133
+ select(Tool.__table__.c.max_size).where(Tool.__table__.c.id == export.id)
134
+ )
135
+ stored = result.scalar_one()
136
+ if stored != FIVE_GIB:
137
+ fail(
138
+ f"ExportFunction.max_size should be {FIVE_GIB}. Got: {stored!r} "
139
+ f"- fix may have broken the declaring subclass's default path."
140
+ )
141
+ finally:
142
+ await engine.dispose()
143
+
144
+ print("[OK] sqlmodel-ext consumer integration test passed")
145
+
146
+
147
+ if __name__ == "__main__":
148
+ asyncio.run(main())
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sqlmodel-ext"
7
- version = "0.3.2"
7
+ version = "0.3.3"
8
8
  description = "Extended SQLModel infrastructure: smart metaclass, async CRUD mixins, polymorphic inheritance, optimistic locking, relation preloading, and reusable field types."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -20,7 +20,7 @@ Quick start::
20
20
  user = await user.save(session)
21
21
  users = await User.get(session, fetch_mode="all")
22
22
  """
23
- __version__ = "0.3.0"
23
+ __version__ = "0.3.3"
24
24
 
25
25
  # Base
26
26
  from sqlmodel_ext.base import SQLModelBase, ExtraIgnoreModelBase
@@ -67,10 +67,15 @@ if sys.version_info >= (3, 14):
67
67
  # Check field.metadata (Pydantic-processed Annotated types)
68
68
  metadata = getattr(field, 'metadata', None)
69
69
  if metadata:
70
+ # Pass field.annotation as source_type so handlers that need the element type
71
+ # (e.g. _PendingArrayHandler extracting T from list[T] to compute ARRAY(T))
72
+ # can resolve. handler=None is fine because the sa_type extraction path only
73
+ # reads metadata['sa_type'] and never invokes the schema itself.
74
+ field_annotation = getattr(field, 'annotation', None)
70
75
  for metadata_item in metadata:
71
76
  if hasattr(metadata_item, '__get_pydantic_core_schema__'):
72
77
  try:
73
- schema = metadata_item.__get_pydantic_core_schema__(None, None)
78
+ schema = metadata_item.__get_pydantic_core_schema__(field_annotation, None)
74
79
  if isinstance(schema, dict) and 'metadata' in schema:
75
80
  sa_type = schema['metadata'].get('sa_type')
76
81
  if sa_type is not None:
@@ -105,7 +110,9 @@ if sys.version_info >= (3, 14):
105
110
  for md in inner_args[1:]:
106
111
  if hasattr(md, '__get_pydantic_core_schema__'):
107
112
  try:
108
- schema = md.__get_pydantic_core_schema__(None, None)
113
+ # Pass first_arg (the full Annotated alias) as source_type so
114
+ # handlers like _PendingArrayHandler can extract the element type.
115
+ schema = md.__get_pydantic_core_schema__(first_arg, None)
109
116
  if isinstance(schema, dict) and 'metadata' in schema:
110
117
  sa_type = schema['metadata'].get('sa_type')
111
118
  if sa_type is not None:
@@ -153,7 +160,10 @@ if sys.version_info >= (3, 14):
153
160
  for md in args[1:]:
154
161
  if hasattr(md, '__get_pydantic_core_schema__'):
155
162
  try:
156
- schema = md.__get_pydantic_core_schema__(None, None)
163
+ # Pass annotation (the full Annotated form) as source_type so
164
+ # handlers needing the element type (_PendingArrayHandler etc.)
165
+ # can extract T from list[T].
166
+ schema = md.__get_pydantic_core_schema__(annotation, None)
157
167
  if isinstance(schema, dict) and 'metadata' in schema:
158
168
  sa_type = schema['metadata'].get('sa_type')
159
169
  if sa_type is not None:
@@ -15,7 +15,7 @@ import re
15
15
  import sys
16
16
  import types
17
17
  import typing
18
- from typing import Any, get_args, get_origin
18
+ from typing import Any, Self, Sequence, get_args, get_origin
19
19
 
20
20
  from pydantic import ConfigDict, model_validator
21
21
  from pydantic.fields import FieldInfo
@@ -102,6 +102,47 @@ def _merge_field_info_attrs(target: SQLModelFieldInfo, source: SQLModelFieldInfo
102
102
  target.metadata = list(target_meta) + list(source_meta)
103
103
 
104
104
 
105
+ def _find_field_info_in_annotated(annotation: Any) -> SQLModelFieldInfo | None:
106
+ """
107
+ Extract SQLModel ``FieldInfo`` embedded in ``Annotated[T, Field(...)]`` metadata.
108
+
109
+ Used by the metaclass sa_type injection loop. When a field is declared
110
+ ``Annotated[X, Field(default_factory=list, ...)]`` *without* an explicit
111
+ ``= ...`` assignment, ``attrs[field_name]`` is ``Undefined``. Replacing
112
+ it with a fresh ``Field(sa_type=sa_type)`` would discard the user's
113
+ Field metadata (``default_factory``, ``max_length``, validators, ...)
114
+ that lives inside the Annotated args. This helper recovers that
115
+ FieldInfo so the caller can attach ``sa_type`` to the user's Field
116
+ instead of clobbering it. The bug only surfaces after 2+ levels of
117
+ inheritance: single-class instantiation goes through Pydantic's native
118
+ Annotated path and works, but child classes rebuild ``model_fields``
119
+ from the clobbered ``attrs`` and the field becomes silently
120
+ ``is_required=True``.
121
+
122
+ Multiple FieldInfo args (e.g. ``Annotated[Str64, Field(unique=True)]``
123
+ expands to ``Annotated[str, Field(max_length=64), Field(unique=True)]``)
124
+ are merged into a single shallow copy so the shared Annotated metadata
125
+ singletons are never mutated.
126
+
127
+ :param annotation: Field type annotation
128
+ :returns: Merged SQLModelFieldInfo (shallow copy, safe to mutate), or None
129
+ """
130
+ if get_origin(annotation) is not typing.Annotated:
131
+ return None
132
+ args = get_args(annotation)
133
+ if len(args) < 2:
134
+ return None
135
+ sqlmodel_fis: list[SQLModelFieldInfo] = [
136
+ arg for arg in args[1:] if isinstance(arg, SQLModelFieldInfo)
137
+ ]
138
+ if not sqlmodel_fis:
139
+ return None
140
+ merged = copy.copy(sqlmodel_fis[0])
141
+ for extra_fi in sqlmodel_fis[1:]:
142
+ _merge_field_info_attrs(merged, extra_fi)
143
+ return merged
144
+
145
+
105
146
  def _make_annotation_optional(annotation: typing.Any) -> typing.Any:
106
147
  """
107
148
  Convert type annotation to optional: ``T → T | None``
@@ -179,6 +220,27 @@ def _apply_all_fields_optional(
179
220
  if original_ann is None:
180
221
  continue
181
222
 
223
+ # Bug fix: when a field is declared as ``field: T = Field(gt=..., le=...)`` (non-Annotated form),
224
+ # MRO ``__annotations__`` only stores the bare ``T``. The ``Field(...)`` constraint metadata
225
+ # lives on the right-hand-side assignment, so all_fields_optional-derived UpdateRequest classes
226
+ # lose those constraints (e.g. UFT-style "missing range" warnings fire on derived fields that
227
+ # had constraints in the source class). Patch: when ``original_ann`` is not already Annotated,
228
+ # pull constraints from the base class's resolved ``model_fields[name].metadata`` (Pydantic
229
+ # stores them there as ``[Gt(0), Le(600), ...]``) and re-wrap into ``Annotated[T, *metadata]``,
230
+ # letting downstream ``_make_annotation_optional`` preserve the constraints when converting
231
+ # to ``T | None``. The root recommended fix is still to declare fields in Annotated form at
232
+ # the source; this patch is defense-in-depth for legacy ``field: T = Field(...)`` declarations.
233
+ if get_origin(original_ann) is not typing.Annotated:
234
+ for base in bases:
235
+ base_model_fields = getattr(base, 'model_fields', None)
236
+ if not base_model_fields:
237
+ continue
238
+ base_field_info = base_model_fields.get(field_name)
239
+ if base_field_info is None or not base_field_info.metadata:
240
+ continue
241
+ original_ann = typing.Annotated[original_ann, *base_field_info.metadata]
242
+ break
243
+
182
244
  # Skip Literal type fields (e.g. discriminator):
183
245
  # Literal['text'] | None breaks Pydantic discriminated union
184
246
  raw_type = original_ann
@@ -268,10 +330,33 @@ def _recover_annotated_sqlmodel_fields(
268
330
  all_annotated.update(annotations)
269
331
 
270
332
  for field_name, field_type in all_annotated.items():
271
- if get_origin(field_type) is not typing.Annotated:
333
+ # Unwrap Optional/Union wrappers: forms like ``Annotated[X, Field(sa_type=BigInteger)] | None``
334
+ # have ``get_origin() == Union`` (or ``types.UnionType`` for PEP 604 ``|``); we must
335
+ # peel back to find the inner Annotated to extract the SQLModel FieldInfo. Otherwise
336
+ # nullable aliases like ``PositiveBigInt | None`` lose ``sa_type`` and the SA column
337
+ # silently degrades (e.g. BigInteger → Integer → asyncpg int32 overflow on large defaults).
338
+ annotated_type = field_type
339
+ union_args: list[typing.Any] | None = None
340
+ union_origin = get_origin(field_type)
341
+ # ``X | None`` (PEP 604) has origin ``types.UnionType``; ``Union[X, None]`` is ``typing.Union``.
342
+ # Match both without depending on ``types`` import inside any version block.
343
+ is_union = union_origin is typing.Union or (
344
+ union_origin is not None and getattr(union_origin, '__name__', '') == 'UnionType'
345
+ )
346
+ if is_union:
347
+ union_args = list(get_args(field_type))
348
+ annotated_in_union = next(
349
+ (arg for arg in union_args if get_origin(arg) is typing.Annotated),
350
+ None,
351
+ )
352
+ if annotated_in_union is None:
353
+ continue
354
+ annotated_type = annotated_in_union
355
+
356
+ if get_origin(annotated_type) is not typing.Annotated:
272
357
  continue
273
358
 
274
- args = get_args(field_type)
359
+ args = get_args(annotated_type)
275
360
  if len(args) < 2:
276
361
  continue
277
362
 
@@ -313,10 +398,24 @@ def _recover_annotated_sqlmodel_fields(
313
398
  base_type = args[0]
314
399
  remaining_metadata = [a for a in args[1:] if not isinstance(a, SQLModelFieldInfo)]
315
400
  if remaining_metadata:
316
- new_ann = typing.Annotated[tuple([base_type] + remaining_metadata)]
401
+ new_inner: typing.Any = typing.Annotated[tuple([base_type] + remaining_metadata)]
317
402
  else:
318
- new_ann = base_type
319
- annotations[field_name] = new_ann
403
+ new_inner = base_type
404
+
405
+ if union_args is not None:
406
+ # Rebuild Union: replace the inner Annotated with the stripped version, keeping
407
+ # the other Union members (e.g. ``| None``) intact. Use PEP 604 ``|`` to accumulate
408
+ # so we don't trip the deprecated-typing.Union warning.
409
+ new_union_args = [
410
+ new_inner if get_origin(arg) is typing.Annotated else arg
411
+ for arg in union_args
412
+ ]
413
+ rebuilt: typing.Any = new_union_args[0]
414
+ for extra in new_union_args[1:]:
415
+ rebuilt = rebuilt | extra
416
+ annotations[field_name] = rebuilt
417
+ else:
418
+ annotations[field_name] = new_inner
320
419
 
321
420
 
322
421
  def _make_sti_fk_resolver(
@@ -495,7 +594,19 @@ class __DeclarativeMeta(SQLModelMetaclass):
495
594
  field_value = attrs.get(field_name, Undefined)
496
595
 
497
596
  if field_value is Undefined:
498
- attrs[field_name] = Field(sa_type=sa_type)
597
+ # No explicit ``= Field(...)`` assignment. Prefer recovering
598
+ # FieldInfo from inside Annotated[X, Field(default_factory=..., ...)]
599
+ # so user-supplied default_factory / max_length / constraints
600
+ # survive — clobbering with a fresh Field(sa_type=sa_type)
601
+ # would silently make the field required after multi-level
602
+ # inheritance.
603
+ annotated_fi = _find_field_info_in_annotated(field_type)
604
+ if annotated_fi is not None:
605
+ if getattr(annotated_fi, 'sa_type', Undefined) is Undefined:
606
+ setattr(annotated_fi, 'sa_type', sa_type)
607
+ attrs[field_name] = annotated_fi
608
+ else:
609
+ attrs[field_name] = Field(sa_type=sa_type)
499
610
  elif isinstance(field_value, FieldInfo):
500
611
  if getattr(field_value, 'sa_type', Undefined) is Undefined:
501
612
  setattr(field_value, 'sa_type', sa_type)
@@ -975,6 +1086,11 @@ class SQLModelBase(SQLModel, metaclass=__DeclarativeMeta):
975
1086
  prop['description'] = finfo.description
976
1087
  return json_schema
977
1088
 
1089
+ @classmethod
1090
+ def validate_list(cls, items: Sequence[Any]) -> list[Self]:
1091
+ """Batch-convert a sequence of ORM instances (or dicts) to this model type."""
1092
+ return [cls.model_validate(item, from_attributes=True) for item in items]
1093
+
978
1094
  @classmethod
979
1095
  def get_computed_field_names(cls) -> set[str]:
980
1096
  """Get the set of computed_field names for this model class."""
@@ -108,6 +108,9 @@ Text5K: TypeAlias = Annotated[str, Field(max_length=5000), _NO_NULL_BYTE]
108
108
  Text10K: TypeAlias = Annotated[str, Field(max_length=10000), _NO_NULL_BYTE]
109
109
  """10000-character text field"""
110
110
 
111
+ Text16K: TypeAlias = Annotated[str, Field(max_length=16000), _NO_NULL_BYTE]
112
+ """16000-character text field (long-form descriptions, tool docs)"""
113
+
111
114
  Text32K: TypeAlias = Annotated[str, Field(max_length=32000), _NO_NULL_BYTE]
112
115
  """32000-character text field"""
113
116
 
@@ -120,9 +123,29 @@ Text64K: TypeAlias = Annotated[str, Field(max_length=65536), _NO_NULL_BYTE]
120
123
  Text100K: TypeAlias = Annotated[str, Field(max_length=100000), _NO_NULL_BYTE]
121
124
  """100000-character text field"""
122
125
 
126
+ Text128K: TypeAlias = Annotated[str, Field(max_length=131072), _NO_NULL_BYTE]
127
+ """131072-character (128 * 1024) text field (large markdown documents etc.)"""
128
+
123
129
  Text1M: TypeAlias = Annotated[str, Field(max_length=1000000), _NO_NULL_BYTE]
124
130
  """1000000-character text field (tool call parameters, tool responses, etc.)"""
125
131
 
132
+ # NonEmptyStr* — same as Str* but also requires ``min_length=1``, rejecting empty
133
+ # ``""`` strings with a 422 ValidationError. Use for naming fields where the empty
134
+ # string is semantically invalid (e.g. UserFolder.name).
135
+ NonEmptyStr64: TypeAlias = Annotated[str, Field(min_length=1, max_length=64), _NO_NULL_BYTE]
136
+ """1-64 character non-empty string field"""
137
+
138
+ NonEmptyStr128: TypeAlias = Annotated[str, Field(min_length=1, max_length=128), _NO_NULL_BYTE]
139
+ """1-128 character non-empty string field"""
140
+
141
+ NonEmptyStr256: TypeAlias = Annotated[str, Field(min_length=1, max_length=256), _NO_NULL_BYTE]
142
+ """1-256 character non-empty string field"""
143
+
144
+ # Sha256Hex — exactly 64 lowercase hex characters, the canonical SHA-256 hex digest form.
145
+ # The strict regex also implicitly rejects NUL bytes, so no separate _NO_NULL_BYTE is needed.
146
+ Sha256Hex: TypeAlias = Annotated[str, StringConstraints(min_length=64, max_length=64, pattern=r'^[0-9a-f]{64}$')]
147
+ """64-char lowercase-hex SHA-256 digest (e.g. content hashes)"""
148
+
126
149
  # Numeric range constraints
127
150
  Port: TypeAlias = Annotated[int, Field(ge=1, le=65535)]
128
151
  """Port number (1-65535)"""