ferro-orm 0.2.1__tar.gz → 0.3.0__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 (118) hide show
  1. ferro_orm-0.3.0/.github/workflows/release.yml +491 -0
  2. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/.gitignore +2 -0
  3. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/CHANGELOG.md +54 -0
  4. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/Cargo.lock +1 -1
  5. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/Cargo.toml +1 -1
  6. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/PKG-INFO +1 -1
  7. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/concepts/architecture.md +1 -1
  8. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/guide/migrations.md +4 -0
  9. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/guide/models-and-fields.md +36 -2
  10. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/guide/relationships.md +12 -3
  11. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/pyproject.toml +3 -2
  12. ferro_orm-0.3.0/src/ferro/_shadow_fk_types.py +137 -0
  13. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/src/ferro/base.py +5 -2
  14. ferro_orm-0.3.0/src/ferro/composite_uniques.py +97 -0
  15. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/src/ferro/fields.py +2 -1
  16. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/src/ferro/metaclass.py +15 -4
  17. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/src/ferro/migrations/alembic.py +23 -1
  18. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/src/ferro/models.py +10 -0
  19. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/src/ferro/relations/__init__.py +14 -2
  20. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/src/schema.rs +51 -0
  21. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_alembic_bridge.py +22 -0
  22. ferro_orm-0.3.0/tests/test_composite_unique.py +324 -0
  23. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_metaclass_internals.py +20 -1
  24. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_relationship_engine.py +2 -0
  25. ferro_orm-0.3.0/tests/test_shadow_fk_types.py +258 -0
  26. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/uv.lock +1 -1
  27. ferro_orm-0.2.1/.github/workflows/release.yml +0 -320
  28. ferro_orm-0.2.1/DOCS_COMPLETE.md +0 -226
  29. ferro_orm-0.2.1/DOCS_RESTRUCTURE_SUMMARY.md +0 -213
  30. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  31. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  32. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/.github/PERMISSIONS.md +0 -0
  33. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/.github/PYPI_CHECKLIST.md +0 -0
  34. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/.github/PYPI_SETUP.md +0 -0
  35. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/.github/generated/wheels.generated.yml +0 -0
  36. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/.github/pull_request_template.md +0 -0
  37. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/.github/workflows/ci.yml +0 -0
  38. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/.github/workflows/packaging-smoke.yml +0 -0
  39. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/.github/workflows/publish-docs.yml +0 -0
  40. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/.github/workflows/publish.yml +0 -0
  41. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/.pre-commit-config.yaml +0 -0
  42. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/.python-version +0 -0
  43. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/CONTRIBUTING.md +0 -0
  44. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/LICENSE +0 -0
  45. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/README.md +0 -0
  46. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/TEST_RESULTS.md +0 -0
  47. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/api/fields.md +0 -0
  48. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/api/model.md +0 -0
  49. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/api/query.md +0 -0
  50. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/api/relationships.md +0 -0
  51. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/api/transactions.md +0 -0
  52. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/api/utilities.md +0 -0
  53. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/changelog.md +0 -0
  54. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/coming-soon.md +0 -0
  55. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/concepts/identity-map.md +0 -0
  56. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/concepts/performance.md +0 -0
  57. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/concepts/type-safety.md +0 -0
  58. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/contributing.md +0 -0
  59. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/faq.md +0 -0
  60. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/getting-started/installation.md +0 -0
  61. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/getting-started/next-steps.md +0 -0
  62. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/getting-started/tutorial.md +0 -0
  63. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/guide/database.md +0 -0
  64. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/guide/mutations.md +0 -0
  65. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/guide/queries.md +0 -0
  66. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/guide/transactions.md +0 -0
  67. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/howto/multiple-databases.md +0 -0
  68. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/howto/pagination.md +0 -0
  69. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/howto/soft-deletes.md +0 -0
  70. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/howto/testing.md +0 -0
  71. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/howto/timestamps.md +0 -0
  72. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/index.md +0 -0
  73. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/migration-sqlalchemy.md +0 -0
  74. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/stylesheets/extra.css +0 -0
  75. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/docs/why-ferro.md +0 -0
  76. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/justfile +0 -0
  77. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/mkdocs.yml +0 -0
  78. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/scripts/demo_queries.py +0 -0
  79. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/src/connection.rs +0 -0
  80. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/src/ferro/__init__.py +0 -0
  81. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/src/ferro/_core.pyi +0 -0
  82. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/src/ferro/migrations/__init__.py +0 -0
  83. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/src/ferro/py.typed +0 -0
  84. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/src/ferro/query/__init__.py +0 -0
  85. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/src/ferro/query/builder.py +0 -0
  86. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/src/ferro/query/nodes.py +0 -0
  87. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/src/ferro/relations/descriptors.py +0 -0
  88. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/src/ferro/state.py +0 -0
  89. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/src/lib.rs +0 -0
  90. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/src/operations.rs +0 -0
  91. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/src/query.rs +0 -0
  92. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/src/state.rs +0 -0
  93. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/conftest.py +0 -0
  94. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_aggregation.py +0 -0
  95. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_alembic_autogenerate.py +0 -0
  96. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_alembic_type_mapping.py +0 -0
  97. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_auto_migrate.py +0 -0
  98. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_bulk_update.py +0 -0
  99. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_connection.py +0 -0
  100. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_constraints.py +0 -0
  101. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_crud.py +0 -0
  102. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_deletion.py +0 -0
  103. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_docs_examples.py +0 -0
  104. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_documentation_features.py +0 -0
  105. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_field_wrapper.py +0 -0
  106. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_helpers.py +0 -0
  107. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_hydration.py +0 -0
  108. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_metadata.py +0 -0
  109. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_models.py +0 -0
  110. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_one_to_one.py +0 -0
  111. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_query_builder.py +0 -0
  112. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_refresh.py +0 -0
  113. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_schema.py +0 -0
  114. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_schema_constraints.py +0 -0
  115. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_string_search.py +0 -0
  116. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_structural_types.py +0 -0
  117. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_temporal_types.py +0 -0
  118. {ferro_orm-0.2.1 → ferro_orm-0.3.0}/tests/test_transactions.py +0 -0
@@ -0,0 +1,491 @@
1
+ name: Release
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ prerelease:
7
+ description: 'Create a pre-release'
8
+ required: false
9
+ type: boolean
10
+ default: false
11
+
12
+ concurrency:
13
+ group: release-${{ github.workflow }}-${{ github.ref }}
14
+ cancel-in-progress: true
15
+
16
+ jobs:
17
+ ci-gate:
18
+ name: CI Gate
19
+ uses: ./.github/workflows/ci.yml
20
+
21
+ packaging-smoke:
22
+ name: Packaging Smoke Gate
23
+ uses: ./.github/workflows/packaging-smoke.yml
24
+
25
+ # Bump versions and changelog locally only; export a patch so every later job
26
+ # validates the exact tree that would be released. Nothing is pushed until
27
+ # publish-github-release after wheels, sdist, tests, and docs all pass.
28
+ prepare-release:
29
+ name: Prepare release (no push)
30
+ needs: [ci-gate, packaging-smoke]
31
+ runs-on: ubuntu-latest
32
+ outputs:
33
+ release_created: ${{ steps.verify_release.outputs.release_created }}
34
+ release_base_sha: ${{ steps.base_sha.outputs.release_base_sha }}
35
+ release_tag: ${{ steps.verify_release.outputs.release_tag }}
36
+ release_version: ${{ steps.verify_release.outputs.release_version }}
37
+ permissions:
38
+ contents: read
39
+ issues: read
40
+ pull-requests: read
41
+
42
+ steps:
43
+ - name: Record workflow base SHA
44
+ id: base_sha
45
+ run: echo "release_base_sha=${{ github.sha }}" >> "$GITHUB_OUTPUT"
46
+
47
+ - name: Checkout repository
48
+ uses: actions/checkout@v4
49
+ with:
50
+ fetch-depth: 0
51
+
52
+ - name: Set up Python
53
+ uses: actions/setup-python@v5
54
+ with:
55
+ python-version: '3.13'
56
+
57
+ - name: Install UV
58
+ uses: astral-sh/setup-uv@v5
59
+ with:
60
+ enable-cache: true
61
+
62
+ - name: Install dependencies
63
+ run: |
64
+ uv sync --only-group release --no-install-project --python 3.13
65
+
66
+ - name: Run semantic-release (local bump only)
67
+ env:
68
+ GH_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
69
+ run: |
70
+ PRERELEASE_FLAG=""
71
+ if [ "${{ github.event.inputs.prerelease }}" == "true" ]; then
72
+ PRERELEASE_FLAG="--prerelease"
73
+ fi
74
+
75
+ # Writes version files + CHANGELOG only; no commit, push, tag, or VCS
76
+ # release. --skip-build defers packaging to validation jobs.
77
+ uv run semantic-release version $PRERELEASE_FLAG \
78
+ --no-vcs-release --no-tag --no-commit --no-push --skip-build
79
+
80
+ - name: Verify release candidate and write patch
81
+ id: verify_release
82
+ shell: bash
83
+ run: |
84
+ set -euo pipefail
85
+ # Normalize to a single index-vs-HEAD patch (stable for git apply on runners).
86
+ git add -A
87
+ git diff --cached HEAD > release.patch
88
+
89
+ if [[ ! -s release.patch ]]; then
90
+ echo "No semantic version bump in this run (index matches HEAD)."
91
+ echo "release_created=false" >> "$GITHUB_OUTPUT"
92
+ echo "changelog_validated=skipped" >> "$GITHUB_OUTPUT"
93
+ echo "release_tag=" >> "$GITHUB_OUTPUT"
94
+ echo "release_version=" >> "$GITHUB_OUTPUT"
95
+ exit 0
96
+ fi
97
+
98
+ if ! grep -qE '^diff --git a/CHANGELOG\.md b/CHANGELOG\.md' release.patch; then
99
+ echo "WARNING: expected CHANGELOG.md to change for a release bump."
100
+ echo "release_created=false" >> "$GITHUB_OUTPUT"
101
+ echo "changelog_validated=missing" >> "$GITHUB_OUTPUT"
102
+ echo "release_tag=" >> "$GITHUB_OUTPUT"
103
+ echo "release_version=" >> "$GITHUB_OUTPUT"
104
+ exit 0
105
+ fi
106
+
107
+ VERSION="$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")"
108
+ echo "release_created=true" >> "$GITHUB_OUTPUT"
109
+ echo "changelog_validated=passed" >> "$GITHUB_OUTPUT"
110
+ echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
111
+ echo "release_version=${VERSION}" >> "$GITHUB_OUTPUT"
112
+ echo "Prepared v${VERSION} as patch against ${{ github.sha }}"
113
+
114
+ - name: Upload release patch
115
+ if: steps.verify_release.outputs.release_created == 'true'
116
+ uses: actions/upload-artifact@v4
117
+ with:
118
+ name: release-patch
119
+ path: release.patch
120
+
121
+ - name: Release summary
122
+ if: always()
123
+ shell: bash
124
+ run: |
125
+ {
126
+ echo "## Release workflow summary"
127
+ echo ""
128
+ echo "- Trigger: \`${{ github.event_name }}\`"
129
+ echo "- Base ref: \`${{ github.ref_name }}\` @ \`${{ github.sha }}\`"
130
+ echo "- Release candidate prepared: \`${{ steps.verify_release.outputs.release_created || 'unknown' }}\`"
131
+ echo "- Changelog validation: \`${{ steps.verify_release.outputs.changelog_validated || 'unknown' }}\`"
132
+ echo "- Tag if published: \`${{ steps.verify_release.outputs.release_tag || 'n/a' }}\`"
133
+ } >> "$GITHUB_STEP_SUMMARY"
134
+
135
+ # Build and publish jobs are inlined here (not in a reusable workflow) so that
136
+ # PyPI Trusted Publishing sees workflow_ref = release.yml. Reusable workflows
137
+ # are not supported by PyPI Trusted Publishing.
138
+ build-wheels:
139
+ name: Build wheels (${{ matrix.platform.name }})
140
+ needs: [prepare-release]
141
+ if: needs.prepare-release.outputs.release_created == 'true'
142
+ runs-on: ${{ matrix.platform.runner }}
143
+ strategy:
144
+ fail-fast: false
145
+ matrix:
146
+ platform:
147
+ - name: linux-x86_64
148
+ runner: ubuntu-latest
149
+ target: x86_64
150
+ manylinux: auto
151
+ - name: linux-aarch64
152
+ runner: ubuntu-latest
153
+ target: aarch64
154
+ manylinux: auto
155
+ - name: macos-aarch64
156
+ runner: macos-latest
157
+ target: aarch64
158
+ manylinux: ''
159
+ - name: windows-x64
160
+ runner: windows-latest
161
+ target: x64
162
+ manylinux: ''
163
+
164
+ steps:
165
+ - name: Disable Git CRLF conversion before checkout
166
+ shell: bash
167
+ run: git config --global core.autocrlf false
168
+ - name: Checkout repository
169
+ uses: actions/checkout@v4
170
+ with:
171
+ ref: ${{ needs.prepare-release.outputs.release_base_sha }}
172
+
173
+ - name: Download release patch
174
+ uses: actions/download-artifact@v4
175
+ with:
176
+ name: release-patch
177
+ path: release-patch
178
+
179
+ - name: Apply release patch
180
+ shell: bash
181
+ run: |
182
+ git config core.autocrlf false
183
+ git apply release-patch/release.patch
184
+ rm -rf release-patch
185
+
186
+ - name: Set up Python
187
+ uses: actions/setup-python@v5
188
+ with:
189
+ python-version: '3.13'
190
+
191
+ - name: Build wheels
192
+ uses: PyO3/maturin-action@v1.48.0
193
+ with:
194
+ target: ${{ matrix.platform.target }}
195
+ args: --release --out dist --find-interpreter
196
+ manylinux: ${{ matrix.platform.manylinux }}
197
+ sccache: 'true'
198
+
199
+ - name: Upload wheels
200
+ uses: actions/upload-artifact@v4
201
+ with:
202
+ name: wheels-${{ matrix.platform.name }}
203
+ path: dist/*.whl
204
+
205
+ build-sdist:
206
+ name: Build sdist
207
+ needs: [prepare-release]
208
+ if: needs.prepare-release.outputs.release_created == 'true'
209
+ runs-on: ubuntu-latest
210
+ steps:
211
+ - name: Disable Git CRLF conversion before checkout
212
+ shell: bash
213
+ run: git config --global core.autocrlf false
214
+ - name: Checkout repository
215
+ uses: actions/checkout@v4
216
+ with:
217
+ ref: ${{ needs.prepare-release.outputs.release_base_sha }}
218
+
219
+ - name: Download release patch
220
+ uses: actions/download-artifact@v4
221
+ with:
222
+ name: release-patch
223
+ path: release-patch
224
+
225
+ - name: Apply release patch
226
+ shell: bash
227
+ run: |
228
+ git config core.autocrlf false
229
+ git apply release-patch/release.patch
230
+ rm -rf release-patch
231
+
232
+ - name: Build sdist
233
+ uses: PyO3/maturin-action@v1.48.0
234
+ with:
235
+ command: sdist
236
+ args: --out dist
237
+
238
+ - name: Upload sdist
239
+ uses: actions/upload-artifact@v4
240
+ with:
241
+ name: sdist
242
+ path: dist/*.tar.gz
243
+
244
+ test-wheels:
245
+ name: Test wheels (${{ matrix.os }})
246
+ needs: [prepare-release, build-wheels]
247
+ if: needs.prepare-release.outputs.release_created == 'true'
248
+ runs-on: ${{ matrix.os }}
249
+ strategy:
250
+ fail-fast: false
251
+ matrix:
252
+ os: [ubuntu-latest, windows-latest, macos-latest]
253
+ steps:
254
+ - name: Disable Git CRLF conversion before checkout
255
+ shell: bash
256
+ run: git config --global core.autocrlf false
257
+ - name: Checkout repository
258
+ uses: actions/checkout@v4
259
+ with:
260
+ ref: ${{ needs.prepare-release.outputs.release_base_sha }}
261
+
262
+ - name: Download release patch
263
+ uses: actions/download-artifact@v4
264
+ with:
265
+ name: release-patch
266
+ path: release-patch
267
+
268
+ - name: Apply release patch
269
+ shell: bash
270
+ run: |
271
+ git config core.autocrlf false
272
+ git apply release-patch/release.patch
273
+ rm -rf release-patch
274
+
275
+ - name: Set up Python
276
+ uses: actions/setup-python@v5
277
+ with:
278
+ python-version: '3.13'
279
+
280
+ - name: Download wheels
281
+ uses: actions/download-artifact@v4
282
+ with:
283
+ path: dist
284
+ pattern: wheels-*
285
+ merge-multiple: true
286
+
287
+ - name: Install wheel
288
+ shell: bash
289
+ run: |
290
+ python -m pip install --upgrade pip
291
+ python -m pip install --find-links dist ferro-orm
292
+
293
+ - name: Test import
294
+ shell: bash
295
+ run: |
296
+ python -c "import ferro; print('Ferro imported successfully')"
297
+
298
+ - name: Run basic smoke test
299
+ shell: bash
300
+ run: |
301
+ python -c "
302
+ import asyncio
303
+ from ferro import Model, FerroField, connect
304
+ from typing import Annotated
305
+
306
+ class TestModel(Model):
307
+ id: Annotated[int, FerroField(primary_key=True)]
308
+ name: str
309
+
310
+ async def test():
311
+ await connect('sqlite::memory:')
312
+ print('Connection test passed')
313
+
314
+ asyncio.run(test())
315
+ "
316
+
317
+ verify-docs:
318
+ name: Verify documentation build
319
+ needs: [prepare-release]
320
+ if: needs.prepare-release.outputs.release_created == 'true'
321
+ runs-on: ubuntu-latest
322
+ steps:
323
+ - name: Disable Git CRLF conversion before checkout
324
+ shell: bash
325
+ run: git config --global core.autocrlf false
326
+ - name: Checkout repository
327
+ uses: actions/checkout@v4
328
+ with:
329
+ ref: ${{ needs.prepare-release.outputs.release_base_sha }}
330
+
331
+ - name: Download release patch
332
+ uses: actions/download-artifact@v4
333
+ with:
334
+ name: release-patch
335
+ path: release-patch
336
+
337
+ - name: Apply release patch
338
+ shell: bash
339
+ run: |
340
+ git config core.autocrlf false
341
+ git apply release-patch/release.patch
342
+ rm -rf release-patch
343
+
344
+ - name: Set up Python
345
+ uses: actions/setup-python@v5
346
+ with:
347
+ python-version: '3.13'
348
+
349
+ - name: Install UV
350
+ uses: astral-sh/setup-uv@v5
351
+ with:
352
+ enable-cache: true
353
+
354
+ - name: Install documentation dependencies
355
+ run: |
356
+ uv sync --only-group docs --no-install-project --python 3.13
357
+
358
+ - name: Build MkDocs site
359
+ run: |
360
+ uv run --no-sync mkdocs build
361
+
362
+ publish-github-release:
363
+ name: Commit, tag, and create GitHub Release
364
+ needs: [prepare-release, build-wheels, build-sdist, test-wheels, verify-docs]
365
+ if: needs.prepare-release.outputs.release_created == 'true'
366
+ runs-on: ubuntu-latest
367
+ permissions:
368
+ contents: write
369
+ issues: write
370
+ pull-requests: write
371
+ steps:
372
+ - name: Disable Git CRLF conversion before checkout
373
+ shell: bash
374
+ run: git config --global core.autocrlf false
375
+ - name: Checkout repository
376
+ uses: actions/checkout@v4
377
+ with:
378
+ ref: ${{ needs.prepare-release.outputs.release_base_sha }}
379
+ fetch-depth: 0
380
+ ssh-key: ${{ secrets.RELEASE_DEPLOY_KEY }}
381
+ # Required so the deploy key stays loaded for git push in the next step.
382
+ persist-credentials: true
383
+
384
+ - name: Download release patch
385
+ uses: actions/download-artifact@v4
386
+ with:
387
+ name: release-patch
388
+ path: release-patch
389
+
390
+ - name: Apply release patch and push release commit
391
+ env:
392
+ VERSION: ${{ needs.prepare-release.outputs.release_version }}
393
+ TAG: ${{ needs.prepare-release.outputs.release_tag }}
394
+ run: |
395
+ set -euo pipefail
396
+ git config core.autocrlf false
397
+ git apply release-patch/release.patch
398
+ rm -rf release-patch
399
+
400
+ git config user.email "github-actions[bot]@users.noreply.github.com"
401
+ git config user.name "github-actions[bot]"
402
+ git remote set-url origin "git@github.com:${{ github.repository }}.git"
403
+
404
+ if git ls-remote --tags origin "refs/tags/${TAG}" | grep -q .; then
405
+ echo "ERROR: tag ${TAG} already exists on the remote."
406
+ exit 1
407
+ fi
408
+
409
+ git add -A
410
+ git commit --author="semantic-release <semantic-release@github.com>" \
411
+ -m "chore(release): ${VERSION}" \
412
+ -m "Automatically generated by python-semantic-release"
413
+ git push origin "HEAD:refs/heads/${{ github.ref_name }}"
414
+
415
+ git tag "${TAG}"
416
+ git push origin "refs/tags/${TAG}"
417
+
418
+ # semantic-release matches branch config (e.g. branches.main); detached HEAD fails with
419
+ # "Detached HEAD state cannot match any release groups".
420
+ - name: Attach HEAD to branch for semantic-release
421
+ run: git switch -C "${{ github.ref_name }}"
422
+
423
+ - name: Set up Python
424
+ uses: actions/setup-python@v5
425
+ with:
426
+ python-version: '3.13'
427
+
428
+ - name: Install UV
429
+ uses: astral-sh/setup-uv@v5
430
+ with:
431
+ enable-cache: true
432
+
433
+ - name: Set up Rust
434
+ uses: dtolnay/rust-toolchain@stable
435
+
436
+ - name: Cache Rust dependencies
437
+ uses: Swatinem/rust-cache@v2
438
+ with:
439
+ prefix-key: "v1-rust"
440
+
441
+ - name: Install dependencies
442
+ run: |
443
+ uv sync --only-group release --no-install-project --python 3.13
444
+
445
+ - name: Publish GitHub release (assets + VCS release)
446
+ env:
447
+ GH_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
448
+ run: |
449
+ uv run semantic-release publish --tag "${{ needs.prepare-release.outputs.release_tag }}"
450
+
451
+ publish-pypi:
452
+ name: Publish to PyPI
453
+ needs: [prepare-release, publish-github-release]
454
+ if: needs.prepare-release.outputs.release_created == 'true'
455
+ runs-on: ubuntu-latest
456
+ environment:
457
+ name: pypi
458
+ url: https://pypi.org/p/ferro-orm
459
+ permissions:
460
+ id-token: write
461
+ steps:
462
+ - name: Download sdist
463
+ uses: actions/download-artifact@v4
464
+ with:
465
+ name: sdist
466
+ path: dist
467
+
468
+ - name: Download wheels
469
+ uses: actions/download-artifact@v4
470
+ with:
471
+ pattern: wheels-*
472
+ path: dist
473
+ merge-multiple: true
474
+
475
+ - name: Publish to PyPI
476
+ uses: pypa/gh-action-pypi-publish@release/v1
477
+ with:
478
+ skip-existing: true
479
+ verbose: true
480
+
481
+ deploy-docs:
482
+ name: Deploy Documentation
483
+ needs: [prepare-release, publish-pypi]
484
+ if: needs.prepare-release.outputs.release_created == 'true'
485
+ uses: ./.github/workflows/publish-docs.yml
486
+ permissions:
487
+ contents: read
488
+ pages: write
489
+ id-token: write
490
+ with:
491
+ ref: ${{ needs.prepare-release.outputs.release_tag }}
@@ -243,3 +243,5 @@ playground.ipynb
243
243
  IMPLEMENTATION.md
244
244
  Cargo.lock
245
245
  demo.db
246
+ docs/superpowers/plans/2026-04-22-shadow-fk-types.md
247
+ docs/superpowers/specs/2026-04-22-shadow-fk-types-design.md
@@ -1,6 +1,60 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.3.0 (2026-04-22)
5
+
6
+ ### Bug Fixes
7
+
8
+ - Align composite unique index names and harden Alembic/Rust handling
9
+ ([`3350481`](https://github.com/syn54x/ferro-orm/commit/33504812d37d93bf69c2be8f6bee6f390803a460))
10
+
11
+ - Refresh Pydantic FieldInfo when reconciling shadow FK types
12
+ ([`6cf1ac8`](https://github.com/syn54x/ferro-orm/commit/6cf1ac8c2e361df8de11795a8151c34d17a39445))
13
+
14
+ ### Chores
15
+
16
+ - Remove doc
17
+ ([`16e4028`](https://github.com/syn54x/ferro-orm/commit/16e4028f72fc47109b2511d1feb23811c831f32c))
18
+
19
+ ### Continuous Integration
20
+
21
+ - Fix release
22
+ ([`688d01b`](https://github.com/syn54x/ferro-orm/commit/688d01bdae0aff1f82e4a1bb60dd1b8ab35e1d01))
23
+
24
+ - Fix release
25
+ ([`249e460`](https://github.com/syn54x/ferro-orm/commit/249e46058bac87215920083a9d45557f3c58b62f))
26
+
27
+ - Fix release
28
+ ([`888e15e`](https://github.com/syn54x/ferro-orm/commit/888e15eff693d1e1bfa279d809e790e52cd7ce25))
29
+
30
+ - Fix release
31
+ ([`58bb5b2`](https://github.com/syn54x/ferro-orm/commit/58bb5b2b0962481b3cfbf3ffbe6c6a2653b213c0))
32
+
33
+ - Reorder release steps to prevent tagging before checks are complete
34
+ ([`ad1fd8d`](https://github.com/syn54x/ferro-orm/commit/ad1fd8d5ba08bdd7a1bcd257fff3fc12ff458c12))
35
+
36
+ ### Documentation
37
+
38
+ - Complete documentation restructure and implementation summary
39
+ ([`937e75e`](https://github.com/syn54x/ferro-orm/commit/937e75ee7b5c526aca776dd8409f9e0df5f0e892))
40
+
41
+ - Enhance shadow field documentation and clarify relationship resolution process
42
+ ([`1d350fd`](https://github.com/syn54x/ferro-orm/commit/1d350fd728310a5b9a24f129986f873a84a8592f))
43
+
44
+ ### Features
45
+
46
+ - Composite unique constraints and default M2M pair uniqueness
47
+ ([`dc12880`](https://github.com/syn54x/ferro-orm/commit/dc12880b7b8676c088183edf1f32b48a36314448))
48
+
49
+ - Derive shadow FK types from related PK and reconcile after resolve
50
+ ([`d3ae486`](https://github.com/syn54x/ferro-orm/commit/d3ae4862858ccd51f62d62a939e6a90b8efb8980))
51
+
52
+ ### Testing
53
+
54
+ - UUID FK save reparenting and bulk_create coverage
55
+ ([`6c93cea`](https://github.com/syn54x/ferro-orm/commit/6c93cea7906ac264b266342ecf71602c7aff6ed6))
56
+
57
+
4
58
  ## v0.2.1 (2026-04-20)
5
59
 
6
60
  ### Bug Fixes
@@ -294,7 +294,7 @@ dependencies = [
294
294
 
295
295
  [[package]]
296
296
  name = "ferro"
297
- version = "0.2.1"
297
+ version = "0.3.0"
298
298
  dependencies = [
299
299
  "dashmap",
300
300
  "once_cell",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ferro"
3
- version = "0.2.1"
3
+ version = "0.3.0"
4
4
  edition = "2024"
5
5
  readme = "README.md"
6
6
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ferro-orm
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Requires-Dist: pydantic>=2.0
5
5
  License-File: LICENSE
6
6
  Summary: A high-performance, Rust-backed ORM for Python.
@@ -68,7 +68,7 @@ graph TB
68
68
  - Async runtime integration
69
69
 
70
70
  **Data formats:**
71
- - JSON schema (models → Rust)
71
+ - JSON schema (models → Rust), including Ferro-specific table-level keys such as `ferro_composite_uniques` alongside per-column metadata in `properties`
72
72
  - Query AST (filters, joins → Rust)
73
73
  - Binary rows (Rust → Python)
74
74
 
@@ -61,6 +61,10 @@ alembic revision --autogenerate -m "Initial schema"
61
61
 
62
62
  Alembic compares your models to the database and generates a migration script in `migrations/versions/`.
63
63
 
64
+ ### Composite uniques and Alembic
65
+
66
+ When you declare `__ferro_composite_uniques__` on a model, Ferro’s `get_metadata()` bridge adds matching SQLAlchemy `UniqueConstraint` objects to the reflected `Table`. Autogenerated revisions will therefore include those constraints (and the same for default many-to-many join tables, which get a composite unique on the two FK columns). Review generated migrations as usual before applying them in production.
67
+
64
68
  ### 4. Review the Migration
65
69
 
66
70
  Open the generated file in `migrations/versions/xxxx_initial_schema.py`:
@@ -76,7 +76,7 @@ Both styles support the same database constraint parameters:
76
76
  | :--- | :--- | :--- | :--- |
77
77
  | `primary_key` | `bool` | `False` | Marks the field as the primary key for the table. |
78
78
  | `autoincrement` | `bool \| None` | `None` | If `True`, the database generates values automatically. Defaults to `True` for integer primary keys. |
79
- | `unique` | `bool` | `False` | Enforces a uniqueness constraint on the column. |
79
+ | `unique` | `bool` | `False` | Enforces a **single-column** uniqueness constraint on that column only. For uniqueness on a combination of columns, see [Composite unique constraints](#composite-unique-constraints) below. |
80
80
  | `index` | `bool` | `False` | Creates a database index for this column to improve query performance. |
81
81
 
82
82
  #### Examples
@@ -113,6 +113,40 @@ slug: str = Field(unique=True, index=True)
113
113
  email: Annotated[str, FerroField(unique=True)]
114
114
  ```
115
115
 
116
+ ### Composite unique constraints
117
+
118
+ Sometimes a row should be unique **across several columns together** (for example one membership per `(user_id, org_id)` pair). That is a *composite* unique: in SQL this is typically expressed as `UNIQUE (user_id, org_id)` on the table, or an equivalent unique index on those columns.
119
+
120
+ Ferro does **not** use `FerroField(unique=True)` for that case (`unique=True` is only per column). Instead, set the **`typing.ClassVar`** `__ferro_composite_uniques__` on your model (the base `Model` defines it as `()` so IDEs and type checkers know the hook exists; subclasses override when needed):
121
+
122
+ ```python
123
+ from typing import Annotated, ClassVar
124
+ import uuid
125
+
126
+ from ferro import Model, FerroField
127
+
128
+ class OrgMembership(Model):
129
+ __ferro_composite_uniques__: ClassVar[tuple[tuple[str, ...], ...]] = (
130
+ ("user_id", "org_id"),
131
+ )
132
+
133
+ id: Annotated[uuid.UUID | None, FerroField(primary_key=True)] = None
134
+ user_id: Annotated[uuid.UUID, FerroField()]
135
+ org_id: Annotated[uuid.UUID, FerroField()]
136
+ ```
137
+
138
+ - Each inner tuple lists **database column names** as they appear in the generated schema (the same names as your Pydantic fields for scalar columns; for `ForeignKey("user")` use `user_id` in the tuple).
139
+ - You can list several groups for multiple composite uniques on one model.
140
+ - Invalid or unknown column names raise when the model is registered.
141
+
142
+ **Null semantics (SQLite):** With the default local SQLite engine, `UNIQUE` treats `NULL` as distinct from other `NULL` values for multi-column constraints unless columns are `NOT NULL`. Ferro maps nullability from your types and defaults like other fields; optional composite columns can therefore allow multiple rows that differ only by `NULL` in a nullable column. Prefer `NOT NULL` on composite members when you need strict “at most one row per pair” semantics. Other databases can differ; consult your backend’s documentation when you target PostgreSQL, MySQL, and so on.
143
+
144
+ **Wire format:** Declarations use nested tuples in Python; the schema JSON sent to the Rust engine uses nested lists (`ferro_composite_uniques`) because JSON has no tuple type.
145
+
146
+ **Many-to-many join tables:** When you use `ManyToManyField` without a custom `through` table, Ferro creates a default join table with two foreign-key columns. That table automatically gets a composite unique on those two columns so the same link cannot be stored twice. If you already have duplicate rows in such a table, adding this constraint in a migration may require a data cleanup step first.
147
+
148
+ See also [Schema management / migrations](migrations.md) for how composite uniques appear in Alembic metadata.
149
+
116
150
  **Indexes:**
117
151
 
118
152
  ```python
@@ -171,7 +205,7 @@ class Product(Model):
171
205
  Ferro uses a custom `ModelMetaclass` to bridge Python and Rust:
172
206
 
173
207
  1. **Schema Capture**: When you define a class, the metaclass inspects its fields and constraints.
174
- 2. **Rust Registration**: The schema is serialized to a JSON-AST and passed to the Rust core's `MODEL_REGISTRY`.
208
+ 2. **Rust Registration**: The schema is serialized to a JSON-AST (including Ferro-specific keys such as `ferro_composite_uniques` when declared) and passed to the Rust core's `MODEL_REGISTRY`.
175
209
  3. **Table Generation**: When `auto_migrate=True` is used or `create_tables()` is called, the Rust engine generates the appropriate SQL `CREATE TABLE` statements.
176
210
 
177
211
  This architecture allows Ferro to leverage Rust's performance for SQL generation and row hydration while maintaining a pure Python interface.