sqlmodel-mypy-plugin 0.12.2__tar.gz → 0.16.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 (98) hide show
  1. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/PKG-INFO +36 -5
  2. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/README.md +35 -4
  3. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/ROADMAP.md +110 -1
  4. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/pyproject.toml +1 -1
  5. sqlmodel_mypy_plugin-0.16.0/src/sqlmodel_mypy/__init__.py +5 -0
  6. sqlmodel_mypy_plugin-0.16.0/src/sqlmodel_mypy/persisted.py +35 -0
  7. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/src/sqlmodel_mypy/plugin.py +342 -4
  8. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/src/sqlmodel_mypy/transform.py +267 -2
  9. sqlmodel_mypy_plugin-0.16.0/tests/mypy/modules/annotated_field_metadata.py +34 -0
  10. sqlmodel_mypy_plugin-0.16.0/tests/mypy/modules/docs_parity_many_to_many_create_data.py +51 -0
  11. sqlmodel_mypy_plugin-0.16.0/tests/mypy/modules/docs_parity_read_connected_data.py +48 -0
  12. sqlmodel_mypy_plugin-0.16.0/tests/mypy/modules/docs_parity_relationship_attributes.py +48 -0
  13. sqlmodel_mypy_plugin-0.16.0/tests/mypy/modules/docs_parity_update_patterns.py +40 -0
  14. sqlmodel_mypy_plugin-0.16.0/tests/mypy/modules/getattr_column_property.py +45 -0
  15. {sqlmodel_mypy_plugin-0.12.2/tests/mypy/outputs/mypy-plugin_ini → sqlmodel_mypy_plugin-0.16.0/tests/mypy/modules}/joins.py +19 -2
  16. sqlmodel_mypy_plugin-0.16.0/tests/mypy/modules/persisted_helpers.py +27 -0
  17. sqlmodel_mypy_plugin-0.16.0/tests/mypy/outputs/mypy-plugin-strict_ini/annotated_field_metadata.py +38 -0
  18. sqlmodel_mypy_plugin-0.16.0/tests/mypy/outputs/mypy-plugin-strict_ini/docs_parity_many_to_many_create_data.py +52 -0
  19. sqlmodel_mypy_plugin-0.16.0/tests/mypy/outputs/mypy-plugin-strict_ini/docs_parity_read_connected_data.py +49 -0
  20. sqlmodel_mypy_plugin-0.16.0/tests/mypy/outputs/mypy-plugin-strict_ini/docs_parity_relationship_attributes.py +49 -0
  21. sqlmodel_mypy_plugin-0.16.0/tests/mypy/outputs/mypy-plugin-strict_ini/docs_parity_update_patterns.py +41 -0
  22. sqlmodel_mypy_plugin-0.16.0/tests/mypy/outputs/mypy-plugin-strict_ini/joins.py +55 -0
  23. sqlmodel_mypy_plugin-0.16.0/tests/mypy/outputs/mypy-plugin-strict_ini/persisted_helpers.py +28 -0
  24. sqlmodel_mypy_plugin-0.16.0/tests/mypy/outputs/mypy-plugin_ini/joins.py +55 -0
  25. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/test_mypy.py +8 -0
  26. sqlmodel_mypy_plugin-0.16.0/tests/test_persisted_helpers.py +24 -0
  27. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/test_plugin_unit.py +598 -0
  28. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/test_transform_unit.py +105 -0
  29. sqlmodel_mypy_plugin-0.12.2/src/sqlmodel_mypy/__init__.py +0 -3
  30. sqlmodel_mypy_plugin-0.12.2/tests/mypy/modules/joins.py +0 -34
  31. sqlmodel_mypy_plugin-0.12.2/tests/mypy/outputs/mypy-plugin-strict_ini/joins.py +0 -35
  32. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/.gitignore +0 -0
  33. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/.pre-commit-config.yaml +0 -0
  34. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/AGENTS.md +0 -0
  35. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/LICENSE +0 -0
  36. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/src/sqlmodel_mypy/py.typed +0 -0
  37. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/conftest.py +0 -0
  38. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/configs/mypy-plugin-no-untyped.ini +0 -0
  39. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/configs/mypy-plugin-pydantic-first.ini +0 -0
  40. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/configs/mypy-plugin-pydantic-last.ini +0 -0
  41. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/configs/mypy-plugin-strict-no-explicit-any.ini +0 -0
  42. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/configs/mypy-plugin-strict.ini +0 -0
  43. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/configs/mypy-plugin.ini +0 -0
  44. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/configs/pyproject-plugin.toml +0 -0
  45. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/aliases.py +0 -0
  46. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/constructor.py +0 -0
  47. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/execution_options.py +0 -0
  48. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/expression_typing_strict.py +0 -0
  49. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/expressions.py +0 -0
  50. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/field_sa_type.py +0 -0
  51. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/generics.py +0 -0
  52. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/inheritance.py +0 -0
  53. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/label.py +0 -0
  54. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/many_to_many_relationships.py +0 -0
  55. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/mapped_fields.py +0 -0
  56. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/model_config.py +0 -0
  57. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/model_construct.py +0 -0
  58. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/nullable_defaults.py +0 -0
  59. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/relationship_comparators.py +0 -0
  60. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/relationship_expressions.py +0 -0
  61. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/relationships.py +0 -0
  62. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/select_varargs.py +0 -0
  63. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/selectinload.py +0 -0
  64. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/session_exec.py +0 -0
  65. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/session_get.py +0 -0
  66. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/table_dunders.py +0 -0
  67. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/table_model_awareness.py +0 -0
  68. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/untyped_fields.py +0 -0
  69. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-pydantic-first_ini/relationships.py +0 -0
  70. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-pydantic-last_ini/relationships.py +0 -0
  71. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/aliases.py +0 -0
  72. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/constructor.py +0 -0
  73. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/execution_options.py +0 -0
  74. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/generics.py +0 -0
  75. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/label.py +0 -0
  76. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/many_to_many_relationships.py +0 -0
  77. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/mapped_fields.py +0 -0
  78. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/model_construct.py +0 -0
  79. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/nullable_defaults.py +0 -0
  80. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/relationship_comparators.py +0 -0
  81. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/relationships.py +0 -0
  82. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/table_model_awareness.py +0 -0
  83. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/aliases.py +0 -0
  84. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/constructor.py +0 -0
  85. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/execution_options.py +0 -0
  86. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/expressions.py +0 -0
  87. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/inheritance.py +0 -0
  88. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/label.py +0 -0
  89. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/many_to_many_relationships.py +0 -0
  90. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/model_construct.py +0 -0
  91. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/nullable_defaults.py +0 -0
  92. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/relationship_comparators.py +0 -0
  93. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/relationship_expressions.py +0 -0
  94. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/relationships.py +0 -0
  95. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/table_model_awareness.py +0 -0
  96. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/untyped_fields.py +0 -0
  97. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/pyproject-plugin_toml/constructor.py +0 -0
  98. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.16.0}/tests/smoke_test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlmodel-mypy-plugin
3
- Version: 0.12.2
3
+ Version: 0.16.0
4
4
  Summary: Mypy plugin for SQLModel (better constructor/Field typing).
5
5
  Project-URL: Homepage, https://github.com/sertione/sqlmodel-mypy-plugin
6
6
  Project-URL: Repository, https://github.com/sertione/sqlmodel-mypy-plugin
@@ -168,6 +168,18 @@ You can also use SQLAlchemy table metadata attributes without `attr-defined` noi
168
168
  tbl = User.__table__
169
169
  ```
170
170
 
171
+ ### `getattr(Model, "field")` support
172
+
173
+ If you build query filters dynamically, `getattr(Model, "field")` with a **string literal** is typed the same as
174
+ direct `Model.field` access on `table=True` models:
175
+
176
+ ```py
177
+ stmt = select(User).where(getattr(User, "name").like("%x%"))
178
+ ```
179
+
180
+ Non-literal names (runtime strings) are intentionally left as `Any` (you’ll still need a cast or a different
181
+ pattern).
182
+
171
183
  ### Relationship comparator typing
172
184
 
173
185
  Relationship attributes declared via `Relationship(...)` are typed as SQLAlchemy expressions at class level,
@@ -181,9 +193,11 @@ stmt = select(Team).where(Team.heroes.contains(hero_obj))
181
193
 
182
194
  ### Out of scope
183
195
 
184
- - **`column_property(...)` and similar SQLAlchemy helpers**: this plugin doesn’t try to “fix” the *declaration-site*
185
- typing for SQLAlchemy-only APIs that aren’t part of SQLModel’s surface area. Prefer SQLAlchemy-typed patterns like
186
- `Mapped[...]` / `InstrumentedAttribute[...]` (or local casts/`# type: ignore` if needed).
196
+ - **Most SQLAlchemy-only declaration helpers**: this plugin doesn’t try to “fix” the full *declaration-site* typing
197
+ for SQLAlchemy ORM APIs that aren’t part of SQLModel’s surface area.
198
+ - Exception: best-effort return typing for `column_property(...)` (infers `Mapped[T]` from `ScalarSelect[T]` /
199
+ `ColumnElement[T]`) to support patterns like `Model._foo = column_property(...)` combined with
200
+ `getattr(Model, "_foo")` in query builders.
187
201
  - **Pydantic `@computed_field`**: not touched by this plugin (it should type-check via normal property typing / Pydantic
188
202
  typing).
189
203
 
@@ -206,6 +220,23 @@ Limitations:
206
220
  - Mypy can’t express “either field name or alias is required”, so aliased required fields may not be reported as
207
221
  missing at type-check time.
208
222
 
223
+ ## Persisted model helpers (opt-in)
224
+
225
+ Strict typing + SQLModel often means table-model IDs start as `None` and are populated after flush/commit/refresh
226
+ (see [SQLModel: Automatic IDs, None, and Refresh](https://sqlmodel.tiangolo.com/tutorial/automatic-id-none-refresh/)).
227
+ Mypy can’t infer those runtime effects, so you can end up with repeated `assert obj.id is not None`.
228
+
229
+ This package ships small helpers you can import (they do **not** affect plugin behavior):
230
+
231
+ ```py
232
+ from sqlmodel_mypy import has_id, require_id
233
+
234
+ hero_id: int = require_id(hero) # raises ValueError if hero.id is None
235
+
236
+ if has_id(hero):
237
+ ok_id: int = hero.id
238
+ ```
239
+
209
240
  ## Development
210
241
 
211
242
  - List available commands: `make help`
@@ -213,7 +244,7 @@ Limitations:
213
244
  - Typecheck plugin code: `make typecheck`
214
245
  - Run tests: `make test`
215
246
  - Run all quality gates: `make check`
216
- - Update mypy \u201cgolden\u201d outputs: `make update-mypy`
247
+ - Update mypy "golden" outputs: `make update-mypy`
217
248
 
218
249
  See [`AGENTS.md`](AGENTS.md) for repo conventions and workflow notes.
219
250
 
@@ -122,6 +122,18 @@ You can also use SQLAlchemy table metadata attributes without `attr-defined` noi
122
122
  tbl = User.__table__
123
123
  ```
124
124
 
125
+ ### `getattr(Model, "field")` support
126
+
127
+ If you build query filters dynamically, `getattr(Model, "field")` with a **string literal** is typed the same as
128
+ direct `Model.field` access on `table=True` models:
129
+
130
+ ```py
131
+ stmt = select(User).where(getattr(User, "name").like("%x%"))
132
+ ```
133
+
134
+ Non-literal names (runtime strings) are intentionally left as `Any` (you’ll still need a cast or a different
135
+ pattern).
136
+
125
137
  ### Relationship comparator typing
126
138
 
127
139
  Relationship attributes declared via `Relationship(...)` are typed as SQLAlchemy expressions at class level,
@@ -135,9 +147,11 @@ stmt = select(Team).where(Team.heroes.contains(hero_obj))
135
147
 
136
148
  ### Out of scope
137
149
 
138
- - **`column_property(...)` and similar SQLAlchemy helpers**: this plugin doesn’t try to “fix” the *declaration-site*
139
- typing for SQLAlchemy-only APIs that aren’t part of SQLModel’s surface area. Prefer SQLAlchemy-typed patterns like
140
- `Mapped[...]` / `InstrumentedAttribute[...]` (or local casts/`# type: ignore` if needed).
150
+ - **Most SQLAlchemy-only declaration helpers**: this plugin doesn’t try to “fix” the full *declaration-site* typing
151
+ for SQLAlchemy ORM APIs that aren’t part of SQLModel’s surface area.
152
+ - Exception: best-effort return typing for `column_property(...)` (infers `Mapped[T]` from `ScalarSelect[T]` /
153
+ `ColumnElement[T]`) to support patterns like `Model._foo = column_property(...)` combined with
154
+ `getattr(Model, "_foo")` in query builders.
141
155
  - **Pydantic `@computed_field`**: not touched by this plugin (it should type-check via normal property typing / Pydantic
142
156
  typing).
143
157
 
@@ -160,6 +174,23 @@ Limitations:
160
174
  - Mypy can’t express “either field name or alias is required”, so aliased required fields may not be reported as
161
175
  missing at type-check time.
162
176
 
177
+ ## Persisted model helpers (opt-in)
178
+
179
+ Strict typing + SQLModel often means table-model IDs start as `None` and are populated after flush/commit/refresh
180
+ (see [SQLModel: Automatic IDs, None, and Refresh](https://sqlmodel.tiangolo.com/tutorial/automatic-id-none-refresh/)).
181
+ Mypy can’t infer those runtime effects, so you can end up with repeated `assert obj.id is not None`.
182
+
183
+ This package ships small helpers you can import (they do **not** affect plugin behavior):
184
+
185
+ ```py
186
+ from sqlmodel_mypy import has_id, require_id
187
+
188
+ hero_id: int = require_id(hero) # raises ValueError if hero.id is None
189
+
190
+ if has_id(hero):
191
+ ok_id: int = hero.id
192
+ ```
193
+
163
194
  ## Development
164
195
 
165
196
  - List available commands: `make help`
@@ -167,7 +198,7 @@ Limitations:
167
198
  - Typecheck plugin code: `make typecheck`
168
199
  - Run tests: `make test`
169
200
  - Run all quality gates: `make check`
170
- - Update mypy \u201cgolden\u201d outputs: `make update-mypy`
201
+ - Update mypy "golden" outputs: `make update-mypy`
171
202
 
172
203
  See [`AGENTS.md`](AGENTS.md) for repo conventions and workflow notes.
173
204
 
@@ -22,7 +22,12 @@
22
22
  - Ensure common SQLModel re-exports (`select`, `and_`, `or_`, `col`, etc.) preserve SQLAlchemy typing.
23
23
  - Decide approach: plugin hooks vs stub overlays vs relying on SQLAlchemy typing (documented trade-offs).
24
24
  - Compatibility matrix CI across mypy/SQLModel versions (and pin policy).
25
- - Avoid forcing users to order plugins (`sqlmodel_mypy.plugin` vs `pydantic.mypy`) for correct SQLModel resolution.
25
+ - **Plugin ordering guardrails (mypy limitation)**
26
+ - **Reality check**: mypy generally uses the **first plugin that claims a hook**, so true “order independence”
27
+ between `sqlmodel_mypy.plugin` and `pydantic.mypy` is not realistically achievable today.
28
+ - Enforce and clearly explain that `sqlmodel_mypy.plugin` must be listed **before** `pydantic.mypy`:
29
+ - Source: `src/sqlmodel_mypy/plugin.py` (`SQLModelMypyPlugin.__init__`, `_sqlmodel_metaclass_callback()`).
30
+ - User-facing docs: `README.md` (plugin ordering section).
26
31
 
27
32
  ## v0.4 (Docs-aligned relationship constructors) - DONE
28
33
 
@@ -200,3 +205,107 @@ models do provide this at runtime for `table=True`, but type checkers can miss i
200
205
  - **Tests**
201
206
  - Add mypy integration coverage for `Model.__table__` and `Session.get` / `AsyncSession.get` with SQLModel
202
207
  table models.
208
+
209
+ ## v0.12.4 (Dynamic query-builder helpers) - DONE
210
+
211
+ - Type `getattr(Model, "field")` like `Model.field` for `table=True` SQLModel models (string-literal names only).
212
+ - Best-effort typing for `column_property(...)` return type (infer `Mapped[T]`) to support
213
+ `Model._foo = column_property(...)` patterns.
214
+
215
+ ## v0.14 (`typing.Annotated[...]` field metadata support) - DONE
216
+
217
+ **Motivation**: SQLModel (Pydantic v2) supports `Annotated`-driven typing patterns, and users will naturally try to
218
+ use them to reduce `= Field(...)` assignment noise. Under `--strict`, we should still generate correct constructor
219
+ signatures (required/optional + alias kwargs) when `Field(...)` metadata lives inside `Annotated[...]`.
220
+
221
+ - **Support `Annotated[T, Field(...)]` for constructor/signature generation**
222
+ - Examples we should handle (at least best-effort):
223
+ - `id: Annotated[int | None, Field(default=None, primary_key=True)]`
224
+ - `name: Annotated[str, Field(alias="full_name")]`
225
+ - The historical SQLModel edge-case: `Optional[Annotated[T, ...]]` (see upstream release notes).
226
+ - Implementation sketch:
227
+ - Extend semantic-phase collection in `src/sqlmodel_mypy/transform.py` to extract Field metadata from the
228
+ annotation when there is no `= Field(...)` call in the class body.
229
+ - Mirror the same logic in checker-time signature collection in `src/sqlmodel_mypy/plugin.py`
230
+ (`_collect_member_from_stmt`) so class-call signatures stay consistent with generated `__init__`.
231
+ - Source references:
232
+ - SQLModel `Field()` overloads + alias precedence: `https://github.com/fastapi/sqlmodel/blob/main/sqlmodel/main.py`
233
+ - `Annotated` handling in SQLModel’s type parsing: `https://github.com/fastapi/sqlmodel/blob/main/sqlmodel/_compat.py`
234
+ - Release note: “Fix support for types with `Optional[Annotated[x, f()]]`…” (PR #1093):
235
+ `https://github.com/fastapi/sqlmodel/pull/1093`
236
+
237
+ ## v0.15 (Outer-join `None` propagation for relationship-attribute joins) - DONE
238
+
239
+ **Motivation**: we currently propagate `None` on outer-joins only when the join target is a direct model class
240
+ (e.g. `.join(Team, isouter=True)`). A common ORM pattern is to join via relationship attributes
241
+ (e.g. `.join(Hero.team, isouter=True)`), and the result tuple should still reflect that the joined entity may be
242
+ `None`.
243
+
244
+ - **Expand join-target detection**
245
+ - Extend `src/sqlmodel_mypy/plugin.py` (`_sqlalchemy_select_join_like_return_type`) to also detect join targets
246
+ expressed as relationship attributes (e.g. `Hero.team`, `Team.heroes`) and map them to the target model type
247
+ for `None` propagation.
248
+ - References:
249
+ - SQLModel outer-join behavior (baseline example): `https://sqlmodel.tiangolo.com/tutorial/connect/read-connected-data/`
250
+ - SQLAlchemy relationship-attribute joins (`select(User).join(User.addresses)`): `https://docs.sqlalchemy.org/en/20/orm/queryguide/select.html#simple-relationship-joins`
251
+ - Add mypy integration coverage under `tests/mypy/modules/` for relationship-attribute joins, including the SQLModel
252
+ docs pattern of `Team: None` on outer-joins.
253
+
254
+ ## v0.16 (Strict typing ergonomics add-ons, opt-in) - DONE
255
+
256
+ **Motivation**: strict typing + SQLModel often means the database populates values “later” (after flush/commit/
257
+ refresh), especially primary keys (`id: int | None`). Mypy can’t infer those runtime effects, and users end up with
258
+ repeated `assert obj.id is not None` (type clutter).
259
+
260
+ - **Provide a tiny opt-in narrowing helper**
261
+ - Docs context (why `id` starts as `None` and is populated after commit/refresh):
262
+ - `https://sqlmodel.tiangolo.com/tutorial/automatic-id-none-refresh/`
263
+ - Ship a small helper in this package (runtime-free or near-zero runtime) that helps users narrow “persisted” models
264
+ without `cast(...)`/`# type: ignore`, e.g.:
265
+ - `assert_persisted(obj)` / `require_id(obj)` / `ensure_persisted(obj)` returning a `TypeGuard[...]` or using
266
+ overloads to narrow `id` to `int`.
267
+ - Keep it optional and well-documented; default plugin behavior should remain conservative.
268
+
269
+ ## v0.17 (`tuple_()` should return a SQL expression, not a Python tuple)
270
+
271
+ **Motivation**: SQLModel re-exports `tuple_` from `sqlmodel.sql.expression`, but its typing currently says it returns a
272
+ built-in `tuple[Any, ...]`. In SQLAlchemy, `tuple_()` returns a SQL expression object (`sqlalchemy.sql.elements.Tuple`)
273
+ which supports expression methods like `.in_(...)`. Under `--strict` this mismatch forces `cast(...)` / `# type: ignore`
274
+ for otherwise normal SQLAlchemy patterns.
275
+
276
+ - **Fix `sqlmodel.tuple_` / `sqlmodel.sql.expression.tuple_` return type**
277
+ - Add a plugin return-type hook for:
278
+ - `sqlmodel.sql.expression.tuple_`
279
+ - `sqlmodel.tuple_` (re-export)
280
+ - Prefer returning `sqlalchemy.sql.elements.Tuple` (or fall back to `ColumnElement[tuple[Any, ...]]` if stubs vary).
281
+ - Source references:
282
+ - SQLModel wrapper: `https://github.com/fastapi/sqlmodel/blob/main/sqlmodel/sql/expression.py`
283
+ - SQLAlchemy `tuple_` constructor: `https://github.com/sqlalchemy/sqlalchemy/blob/main/lib/sqlalchemy/sql/_elements_constructors.py`
284
+ - SQLAlchemy `Tuple` expression class: `https://github.com/sqlalchemy/sqlalchemy/blob/main/lib/sqlalchemy/sql/elements.py`
285
+ - **Tests**
286
+ - Add a mypy integration module demonstrating composite IN typing without ignores, e.g.:
287
+ - `tuple_(User.id, User.team_id).in_([(1, 2)])`
288
+
289
+ ## v0.18 (Recognize `table=True` via `model_config["table"]`, not just class kwargs)
290
+
291
+ **Motivation**: SQLModel’s runtime `is_table_model_class()` checks `model_config.get("table")`, so a class can be a
292
+ table model even when it isn’t declared as `class Model(SQLModel, table=True)`. Today our plugin/transformer only
293
+ looks at the class keyword, so “table-only” behaviors may not activate (expression typing, relationship kwargs in
294
+ constructor signatures, `__table__`, etc.).
295
+
296
+ - **Detect table models via `model_config` (metadata-driven)**
297
+ - Extend semantic analysis in `src/sqlmodel_mypy/transform.py` to detect a statically-known class-body assignment like:
298
+ - `model_config = ConfigDict(table=True)` or equivalent SQLModel config helpers
299
+ - Persist the result in `TypeInfo.metadata["sqlmodel-mypy-metadata"]`, then update `_is_table_model(...)` in:
300
+ - `src/sqlmodel_mypy/plugin.py`
301
+ - `src/sqlmodel_mypy/transform.py`
302
+ to consult metadata first (then fall back to `table=True` in the class definition).
303
+ - Source references:
304
+ - SQLModel metaclass reads config: `https://github.com/fastapi/sqlmodel/blob/main/sqlmodel/main.py` (`SQLModelMetaclass.__new__`)
305
+ - SQLModel runtime table detection: `https://github.com/fastapi/sqlmodel/blob/main/sqlmodel/_compat.py` (`is_table_model_class`)
306
+ - **Tests**
307
+ - Add mypy integration coverage for a table model enabled via `model_config` and assert the same behavior as
308
+ `table=True`:
309
+ - class attribute expression typing (e.g. `User.id.in_([1])`)
310
+ - constructor accepts relationship kwargs when configured (`init_forbid_extra=true`)
311
+ - `User.__table__` is typed and present
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sqlmodel-mypy-plugin"
7
- version = "0.12.2"
7
+ version = "0.16.0"
8
8
  description = "Mypy plugin for SQLModel (better constructor/Field typing)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,5 @@
1
+ __version__ = "0.16.0"
2
+
3
+ from .persisted import HasId, HasOptionalId, has_id, require_id
4
+
5
+ __all__ = ["__version__", "HasId", "HasOptionalId", "has_id", "require_id"]
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol, TypeVar
4
+
5
+ from typing_extensions import TypeIs
6
+
7
+ TId_co = TypeVar("TId_co", covariant=True)
8
+
9
+
10
+ class HasOptionalId(Protocol[TId_co]):
11
+ """An object with an optional primary key (populated later by the DB)."""
12
+
13
+ @property
14
+ def id(self) -> TId_co | None: ...
15
+
16
+
17
+ class HasId(HasOptionalId[TId_co], Protocol[TId_co]):
18
+ """An object with a non-optional primary key."""
19
+
20
+ @property
21
+ def id(self) -> TId_co: ...
22
+
23
+
24
+ def has_id(obj: HasOptionalId[TId_co]) -> TypeIs[HasId[TId_co]]:
25
+ """Return True if `obj.id` is not None, and narrow `obj.id` accordingly."""
26
+
27
+ return obj.id is not None
28
+
29
+
30
+ def require_id(obj: HasOptionalId[TId_co]) -> TId_co:
31
+ """Return `obj.id` if present; raise if it's None."""
32
+
33
+ if obj.id is None:
34
+ raise ValueError("Expected object to have a non-None `id` (persisted object).")
35
+ return obj.id