sqlmodel-mypy-plugin 0.12.3__tar.gz → 0.18.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 (111) hide show
  1. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/AGENTS.md +5 -2
  2. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/PKG-INFO +44 -4
  3. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/README.md +43 -3
  4. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/ROADMAP.md +111 -51
  5. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/pyproject.toml +1 -1
  6. sqlmodel_mypy_plugin-0.18.0/src/sqlmodel_mypy/__init__.py +5 -0
  7. sqlmodel_mypy_plugin-0.18.0/src/sqlmodel_mypy/persisted.py +35 -0
  8. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/src/sqlmodel_mypy/plugin.py +663 -48
  9. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/src/sqlmodel_mypy/transform.py +339 -2
  10. sqlmodel_mypy_plugin-0.18.0/tests/mypy/configs/mypy-plugin-strict-typed-execute.ini +20 -0
  11. sqlmodel_mypy_plugin-0.18.0/tests/mypy/modules/aliased_joins.py +32 -0
  12. sqlmodel_mypy_plugin-0.18.0/tests/mypy/modules/annotated_field_metadata.py +34 -0
  13. sqlmodel_mypy_plugin-0.18.0/tests/mypy/modules/docs_parity_many_to_many_create_data.py +51 -0
  14. sqlmodel_mypy_plugin-0.18.0/tests/mypy/modules/docs_parity_read_connected_data.py +48 -0
  15. sqlmodel_mypy_plugin-0.18.0/tests/mypy/modules/docs_parity_relationship_attributes.py +48 -0
  16. sqlmodel_mypy_plugin-0.18.0/tests/mypy/modules/docs_parity_update_patterns.py +40 -0
  17. sqlmodel_mypy_plugin-0.18.0/tests/mypy/modules/field_validation_alias.py +10 -0
  18. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/getattr_column_property.py +16 -0
  19. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/joins.py +19 -1
  20. sqlmodel_mypy_plugin-0.18.0/tests/mypy/modules/persisted_helpers.py +27 -0
  21. sqlmodel_mypy_plugin-0.18.0/tests/mypy/modules/select_varargs.py +36 -0
  22. sqlmodel_mypy_plugin-0.18.0/tests/mypy/modules/session_execute_typed.py +32 -0
  23. sqlmodel_mypy_plugin-0.18.0/tests/mypy/modules/table_via_model_config.py +35 -0
  24. sqlmodel_mypy_plugin-0.18.0/tests/mypy/modules/tuple_expression.py +14 -0
  25. sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin-strict-typed-execute_ini/session_execute_typed.py +35 -0
  26. sqlmodel_mypy_plugin-0.12.3/tests/mypy/outputs/mypy-plugin-strict_ini/joins.py → sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin-strict_ini/aliased_joins.py +11 -13
  27. sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin-strict_ini/annotated_field_metadata.py +38 -0
  28. sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin-strict_ini/docs_parity_many_to_many_create_data.py +52 -0
  29. sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin-strict_ini/docs_parity_read_connected_data.py +49 -0
  30. sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin-strict_ini/docs_parity_relationship_attributes.py +49 -0
  31. sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin-strict_ini/docs_parity_update_patterns.py +41 -0
  32. sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin-strict_ini/joins.py +55 -0
  33. sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin-strict_ini/persisted_helpers.py +28 -0
  34. sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin-strict_ini/select_varargs.py +37 -0
  35. sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin-strict_ini/table_via_model_config.py +37 -0
  36. sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin-strict_ini/tuple_expression.py +14 -0
  37. sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin_ini/aliased_joins.py +33 -0
  38. sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin_ini/joins.py +55 -0
  39. sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin_ini/select_varargs.py +37 -0
  40. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/test_mypy.py +13 -0
  41. sqlmodel_mypy_plugin-0.18.0/tests/test_persisted_helpers.py +24 -0
  42. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/test_plugin_unit.py +1336 -116
  43. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/test_transform_unit.py +147 -0
  44. sqlmodel_mypy_plugin-0.12.3/src/sqlmodel_mypy/__init__.py +0 -3
  45. sqlmodel_mypy_plugin-0.12.3/tests/mypy/modules/select_varargs.py +0 -18
  46. sqlmodel_mypy_plugin-0.12.3/tests/mypy/outputs/mypy-plugin_ini/joins.py +0 -35
  47. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/.gitignore +0 -0
  48. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/.pre-commit-config.yaml +0 -0
  49. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/LICENSE +0 -0
  50. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/src/sqlmodel_mypy/py.typed +0 -0
  51. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/conftest.py +0 -0
  52. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/configs/mypy-plugin-no-untyped.ini +0 -0
  53. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/configs/mypy-plugin-pydantic-first.ini +0 -0
  54. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/configs/mypy-plugin-pydantic-last.ini +0 -0
  55. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/configs/mypy-plugin-strict-no-explicit-any.ini +0 -0
  56. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/configs/mypy-plugin-strict.ini +0 -0
  57. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/configs/mypy-plugin.ini +0 -0
  58. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/configs/pyproject-plugin.toml +0 -0
  59. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/aliases.py +0 -0
  60. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/constructor.py +0 -0
  61. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/execution_options.py +0 -0
  62. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/expression_typing_strict.py +0 -0
  63. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/expressions.py +0 -0
  64. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/field_sa_type.py +0 -0
  65. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/generics.py +0 -0
  66. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/inheritance.py +0 -0
  67. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/label.py +0 -0
  68. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/many_to_many_relationships.py +0 -0
  69. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/mapped_fields.py +0 -0
  70. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/model_config.py +0 -0
  71. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/model_construct.py +0 -0
  72. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/nullable_defaults.py +0 -0
  73. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/relationship_comparators.py +0 -0
  74. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/relationship_expressions.py +0 -0
  75. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/relationships.py +0 -0
  76. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/selectinload.py +0 -0
  77. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/session_exec.py +0 -0
  78. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/session_get.py +0 -0
  79. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/table_dunders.py +0 -0
  80. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/table_model_awareness.py +0 -0
  81. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/untyped_fields.py +0 -0
  82. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-pydantic-first_ini/relationships.py +0 -0
  83. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-pydantic-last_ini/relationships.py +0 -0
  84. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-strict_ini/aliases.py +0 -0
  85. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-strict_ini/constructor.py +0 -0
  86. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-strict_ini/execution_options.py +0 -0
  87. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-strict_ini/generics.py +0 -0
  88. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-strict_ini/label.py +0 -0
  89. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-strict_ini/many_to_many_relationships.py +0 -0
  90. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-strict_ini/mapped_fields.py +0 -0
  91. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-strict_ini/model_construct.py +0 -0
  92. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-strict_ini/nullable_defaults.py +0 -0
  93. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-strict_ini/relationship_comparators.py +0 -0
  94. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-strict_ini/relationships.py +0 -0
  95. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-strict_ini/table_model_awareness.py +0 -0
  96. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/aliases.py +0 -0
  97. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/constructor.py +0 -0
  98. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/execution_options.py +0 -0
  99. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/expressions.py +0 -0
  100. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/inheritance.py +0 -0
  101. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/label.py +0 -0
  102. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/many_to_many_relationships.py +0 -0
  103. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/model_construct.py +0 -0
  104. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/nullable_defaults.py +0 -0
  105. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/relationship_comparators.py +0 -0
  106. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/relationship_expressions.py +0 -0
  107. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/relationships.py +0 -0
  108. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/table_model_awareness.py +0 -0
  109. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/untyped_fields.py +0 -0
  110. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/pyproject-plugin_toml/constructor.py +0 -0
  111. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/smoke_test.py +0 -0
@@ -47,8 +47,11 @@ make update-mypy
47
47
  ## What the plugin does (scope)
48
48
 
49
49
  - Collects SQLModel fields and determines required/optional kwargs based on `sqlmodel.Field(...)`.
50
- - **Ignores** `sqlmodel.Relationship(...)` when generating constructor kwargs.
50
+ - For `table=True` models, includes `sqlmodel.Relationship(...)` members as **optional** constructor kwargs.
51
51
  - Synthesizes `__init__` and `model_construct` signatures for SQLModel subclasses during mypy semantic analysis.
52
+ - Adjusts `table=True` SQLModel class attribute types to behave like SQLAlchemy expressions (e.g. `User.id` and relationship expression/comparator patterns).
53
+ - Exposes SQLAlchemy table metadata on `table=True` models (e.g. `Model.__table__`).
54
+ - Adds targeted typing fixes for common query patterns/helpers (`select(...)` 5+ entities fallback, `sqlmodel.tuple_(...)` as an expression, outer-join `None` propagation, broadened `Session.exec(...)`, optional typed `Session.execute(...)`, and best-effort `column_property(...)` return typing).
52
55
 
53
56
  ## Repo layout (where things live)
54
57
 
@@ -83,4 +86,4 @@ make update-mypy
83
86
  - To release:
84
87
  - Bump the version.
85
88
  - Tag `vX.Y.Z` and push the tag.
86
- - GitHub Actions publishes to PyPI via **trusted publishing** (requires configuring the PyPI project + GitHub environment `pypi`).
89
+ - GitHub Actions publishes to PyPI via **trusted publishing**
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlmodel-mypy-plugin
3
- Version: 0.12.3
3
+ Version: 0.18.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
@@ -68,11 +68,18 @@ Planned work is tracked in [`ROADMAP.md`](ROADMAP.md).
68
68
  - Outer-join `None` propagation in `select(A, B).join(B, isouter=True)` result tuples.
69
69
  - Broaden `Session.exec()` / `AsyncSession.exec()` typing to accept SQLAlchemy `Executable` statements (e.g.
70
70
  `text(...)`), not just `select(...)`.
71
+ - Optional typed `Session.execute()` / `AsyncSession.execute()` for `select(...)` (opt-in; see `typed_execute`).
71
72
  - Accept SQLAlchemy TypeEngine instances in `Field(sa_type=...)` (e.g. `DateTime(timezone=True)`, `String(50)`)
72
73
  without `# type: ignore` in strict mode.
73
74
  - Accept `model_config = ConfigDict(...)` overrides on SQLModel subclasses in strict mode.
74
75
  - Compatible with `pydantic.mypy` **when `sqlmodel_mypy.plugin` is listed first**.
75
76
 
77
+ ## Install
78
+
79
+ ```bash
80
+ uv add sqlmodel-mypy-plugin
81
+ ```
82
+
76
83
  ## Install (dev)
77
84
 
78
85
  Prereqs: **Python 3.10+** and [`uv`](https://docs.astral.sh/uv/).
@@ -119,6 +126,9 @@ Supported options:
119
126
  `__init__` / `model_construct` / constructor signatures, so mypy reports unexpected keyword arguments.
120
127
  - **`warn_untyped_fields`** (default: `true`): When `true`, emit error code `sqlmodel-field` for untyped
121
128
  declarations like `x = Field(...)` / `x = Relationship(...)` (use `x: T = ...` instead).
129
+ - **`typed_execute`** (default: `false`): When `true`, type SQLModel’s deprecated `Session.execute()` /
130
+ `AsyncSession.execute()` for common `select(...)` statements by recovering `Result[...]` generics. This is meant to
131
+ reduce “type clutter” during migrations; prefer `Session.exec()` for new code.
122
132
  - **`debug_dataclass_transform`** (default: `false`): **Advanced/debug-only**. When `true`, keep SQLModel’s
123
133
  `__dataclass_transform__` handling enabled in mypy (useful for debugging plugin interactions; not recommended
124
134
  for normal use).
@@ -130,6 +140,7 @@ Supported options:
130
140
  init_typed = false
131
141
  init_forbid_extra = false
132
142
  warn_untyped_fields = true
143
+ typed_execute = false
133
144
  debug_dataclass_transform = false
134
145
  ```
135
146
 
@@ -140,12 +151,15 @@ debug_dataclass_transform = false
140
151
  init_typed = false
141
152
  init_forbid_extra = false
142
153
  warn_untyped_fields = true
154
+ typed_execute = false
143
155
  debug_dataclass_transform = false
144
156
  ```
145
157
 
146
158
  ## Error codes
147
159
 
148
160
  - `sqlmodel-field`: field-related plugin errors (e.g. untyped `x = Field(...)`).
161
+ - `sqlmodel-plugin-order`: plugin ordering errors (e.g. `pydantic.mypy` enabled before `sqlmodel_mypy.plugin`).
162
+ Fix: list `sqlmodel_mypy.plugin` first (see [Enable in mypy](#enable-in-mypy)).
149
163
 
150
164
  ## SQL expression typing
151
165
 
@@ -170,13 +184,22 @@ tbl = User.__table__
170
184
 
171
185
  ### `getattr(Model, "field")` support
172
186
 
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:
187
+ If you build query filters dynamically, `getattr(Model, "field")` with a **string literal** (or a `Final` / string
188
+ `Literal[...]` name) is typed the same as direct `Model.field` access on `table=True` models:
175
189
 
176
190
  ```py
177
191
  stmt = select(User).where(getattr(User, "name").like("%x%"))
178
192
  ```
179
193
 
194
+ Example using a `Final` constant:
195
+
196
+ ```py
197
+ from typing import Final
198
+
199
+ FIELD: Final = "name"
200
+ stmt = select(User).where(getattr(User, FIELD).like("%x%"))
201
+ ```
202
+
180
203
  Non-literal names (runtime strings) are intentionally left as `Any` (you’ll still need a cast or a different
181
204
  pattern).
182
205
 
@@ -220,6 +243,23 @@ Limitations:
220
243
  - Mypy can’t express “either field name or alias is required”, so aliased required fields may not be reported as
221
244
  missing at type-check time.
222
245
 
246
+ ## Persisted model helpers (opt-in)
247
+
248
+ Strict typing + SQLModel often means table-model IDs start as `None` and are populated after flush/commit/refresh
249
+ (see [SQLModel: Automatic IDs, None, and Refresh](https://sqlmodel.tiangolo.com/tutorial/automatic-id-none-refresh/)).
250
+ Mypy can’t infer those runtime effects, so you can end up with repeated `assert obj.id is not None`.
251
+
252
+ This package ships small helpers you can import (they do **not** affect plugin behavior):
253
+
254
+ ```py
255
+ from sqlmodel_mypy import has_id, require_id
256
+
257
+ hero_id: int = require_id(hero) # raises ValueError if hero.id is None
258
+
259
+ if has_id(hero):
260
+ ok_id: int = hero.id
261
+ ```
262
+
223
263
  ## Development
224
264
 
225
265
  - List available commands: `make help`
@@ -227,7 +267,7 @@ Limitations:
227
267
  - Typecheck plugin code: `make typecheck`
228
268
  - Run tests: `make test`
229
269
  - Run all quality gates: `make check`
230
- - Update mypy \u201cgolden\u201d outputs: `make update-mypy`
270
+ - Update mypy "golden" outputs: `make update-mypy`
231
271
 
232
272
  See [`AGENTS.md`](AGENTS.md) for repo conventions and workflow notes.
233
273
 
@@ -22,11 +22,18 @@ Planned work is tracked in [`ROADMAP.md`](ROADMAP.md).
22
22
  - Outer-join `None` propagation in `select(A, B).join(B, isouter=True)` result tuples.
23
23
  - Broaden `Session.exec()` / `AsyncSession.exec()` typing to accept SQLAlchemy `Executable` statements (e.g.
24
24
  `text(...)`), not just `select(...)`.
25
+ - Optional typed `Session.execute()` / `AsyncSession.execute()` for `select(...)` (opt-in; see `typed_execute`).
25
26
  - Accept SQLAlchemy TypeEngine instances in `Field(sa_type=...)` (e.g. `DateTime(timezone=True)`, `String(50)`)
26
27
  without `# type: ignore` in strict mode.
27
28
  - Accept `model_config = ConfigDict(...)` overrides on SQLModel subclasses in strict mode.
28
29
  - Compatible with `pydantic.mypy` **when `sqlmodel_mypy.plugin` is listed first**.
29
30
 
31
+ ## Install
32
+
33
+ ```bash
34
+ uv add sqlmodel-mypy-plugin
35
+ ```
36
+
30
37
  ## Install (dev)
31
38
 
32
39
  Prereqs: **Python 3.10+** and [`uv`](https://docs.astral.sh/uv/).
@@ -73,6 +80,9 @@ Supported options:
73
80
  `__init__` / `model_construct` / constructor signatures, so mypy reports unexpected keyword arguments.
74
81
  - **`warn_untyped_fields`** (default: `true`): When `true`, emit error code `sqlmodel-field` for untyped
75
82
  declarations like `x = Field(...)` / `x = Relationship(...)` (use `x: T = ...` instead).
83
+ - **`typed_execute`** (default: `false`): When `true`, type SQLModel’s deprecated `Session.execute()` /
84
+ `AsyncSession.execute()` for common `select(...)` statements by recovering `Result[...]` generics. This is meant to
85
+ reduce “type clutter” during migrations; prefer `Session.exec()` for new code.
76
86
  - **`debug_dataclass_transform`** (default: `false`): **Advanced/debug-only**. When `true`, keep SQLModel’s
77
87
  `__dataclass_transform__` handling enabled in mypy (useful for debugging plugin interactions; not recommended
78
88
  for normal use).
@@ -84,6 +94,7 @@ Supported options:
84
94
  init_typed = false
85
95
  init_forbid_extra = false
86
96
  warn_untyped_fields = true
97
+ typed_execute = false
87
98
  debug_dataclass_transform = false
88
99
  ```
89
100
 
@@ -94,12 +105,15 @@ debug_dataclass_transform = false
94
105
  init_typed = false
95
106
  init_forbid_extra = false
96
107
  warn_untyped_fields = true
108
+ typed_execute = false
97
109
  debug_dataclass_transform = false
98
110
  ```
99
111
 
100
112
  ## Error codes
101
113
 
102
114
  - `sqlmodel-field`: field-related plugin errors (e.g. untyped `x = Field(...)`).
115
+ - `sqlmodel-plugin-order`: plugin ordering errors (e.g. `pydantic.mypy` enabled before `sqlmodel_mypy.plugin`).
116
+ Fix: list `sqlmodel_mypy.plugin` first (see [Enable in mypy](#enable-in-mypy)).
103
117
 
104
118
  ## SQL expression typing
105
119
 
@@ -124,13 +138,22 @@ tbl = User.__table__
124
138
 
125
139
  ### `getattr(Model, "field")` support
126
140
 
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:
141
+ If you build query filters dynamically, `getattr(Model, "field")` with a **string literal** (or a `Final` / string
142
+ `Literal[...]` name) is typed the same as direct `Model.field` access on `table=True` models:
129
143
 
130
144
  ```py
131
145
  stmt = select(User).where(getattr(User, "name").like("%x%"))
132
146
  ```
133
147
 
148
+ Example using a `Final` constant:
149
+
150
+ ```py
151
+ from typing import Final
152
+
153
+ FIELD: Final = "name"
154
+ stmt = select(User).where(getattr(User, FIELD).like("%x%"))
155
+ ```
156
+
134
157
  Non-literal names (runtime strings) are intentionally left as `Any` (you’ll still need a cast or a different
135
158
  pattern).
136
159
 
@@ -174,6 +197,23 @@ Limitations:
174
197
  - Mypy can’t express “either field name or alias is required”, so aliased required fields may not be reported as
175
198
  missing at type-check time.
176
199
 
200
+ ## Persisted model helpers (opt-in)
201
+
202
+ Strict typing + SQLModel often means table-model IDs start as `None` and are populated after flush/commit/refresh
203
+ (see [SQLModel: Automatic IDs, None, and Refresh](https://sqlmodel.tiangolo.com/tutorial/automatic-id-none-refresh/)).
204
+ Mypy can’t infer those runtime effects, so you can end up with repeated `assert obj.id is not None`.
205
+
206
+ This package ships small helpers you can import (they do **not** affect plugin behavior):
207
+
208
+ ```py
209
+ from sqlmodel_mypy import has_id, require_id
210
+
211
+ hero_id: int = require_id(hero) # raises ValueError if hero.id is None
212
+
213
+ if has_id(hero):
214
+ ok_id: int = hero.id
215
+ ```
216
+
177
217
  ## Development
178
218
 
179
219
  - List available commands: `make help`
@@ -181,7 +221,7 @@ Limitations:
181
221
  - Typecheck plugin code: `make typecheck`
182
222
  - Run tests: `make test`
183
223
  - Run all quality gates: `make check`
184
- - Update mypy \u201cgolden\u201d outputs: `make update-mypy`
224
+ - Update mypy "golden" outputs: `make update-mypy`
185
225
 
186
226
  See [`AGENTS.md`](AGENTS.md) for repo conventions and workflow notes.
187
227
 
@@ -23,7 +23,7 @@
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
25
  - **Plugin ordering guardrails (mypy limitation)**
26
- - **Reality check**: mypy generally uses the **first plugin that claims a hook**, so true “order independence”
26
+ - **Reality check**: mypy generally uses the **first plugin that claims a hook**, so true "order independence"
27
27
  between `sqlmodel_mypy.plugin` and `pydantic.mypy` is not realistically achievable today.
28
28
  - Enforce and clearly explain that `sqlmodel_mypy.plugin` must be listed **before** `pydantic.mypy`:
29
29
  - Source: `src/sqlmodel_mypy/plugin.py` (`SQLModelMypyPlugin.__init__`, `_sqlmodel_metaclass_callback()`).
@@ -31,9 +31,9 @@
31
31
 
32
32
  ## v0.4 (Docs-aligned relationship constructors) - DONE
33
33
 
34
- **Motivation**: SQLModel’s official docs pass relationship attributes in constructors (e.g. `Hero(team=team)`,
34
+ **Motivation**: SQLModel's official docs pass relationship attributes in constructors (e.g. `Hero(team=team)`,
35
35
  `Team(heroes=[...])`). v0.4 accepts relationship kwargs in generated `__init__` / `model_construct` for
36
- `table=True` models, so `init_forbid_extra=true` no longer causes false-positive “unexpected keyword argument”
36
+ `table=True` models, so `init_forbid_extra=true` no longer causes false-positive "unexpected keyword argument"
37
37
  errors for documented patterns.
38
38
 
39
39
  - **Accept relationship kwargs in generated `__init__` / `model_construct` (table models)**
@@ -63,17 +63,17 @@ errors for documented patterns.
63
63
  pattern (and keep types conservative).
64
64
  - Update/add mypy integration tests to match the chosen strategy.
65
65
 
66
- ## v0.5 (Model-kind awareness + reduce “surprising” typing) - DONE
66
+ ## v0.5 (Model-kind awareness + reduce "surprising" typing) - DONE
67
67
 
68
68
  - **Table-model awareness for SQLAlchemy expression typing**
69
69
  - Only treat class attributes as SQLAlchemy expressions (e.g. wrap to `InstrumentedAttribute[T]`) for `table=True`
70
70
  SQLModel models.
71
- - Avoid changing attribute types for “data models” (non-table SQLModel subclasses) used for FastAPI schemas, e.g.
71
+ - Avoid changing attribute types for "data models" (non-table SQLModel subclasses) used for FastAPI schemas, e.g.
72
72
  `TeamBase`, `HeroCreate`, `HeroPublic`.
73
73
  - Docs: `https://sqlmodel.tiangolo.com/tutorial/fastapi/relationships/`
74
74
 
75
75
  - **Pydantic/Field alias & config parity checkpoints**
76
- - Verify our generated signatures stay compatible with SQLModel’s `Field(...)` alias behavior (alias propagation
76
+ - Verify our generated signatures stay compatible with SQLModel's `Field(...)` alias behavior (alias propagation
77
77
  and precedence in `validation_alias` / `serialization_alias`).
78
78
  - Source: `https://github.com/fastapi/sqlmodel/blob/main/sqlmodel/main.py` (`Field()` implementation).
79
79
  - Document what is supported vs intentionally out of scope when users rely on aliases in constructor kwargs.
@@ -94,20 +94,20 @@ errors for documented patterns.
94
94
  - **More query-shape coverage**
95
95
  - Multi-entity selects, joins/outer-joins, and relationship-based filters across common SQLModel tutorial patterns.
96
96
  - **Document trade-offs (and pick a default strategy)**
97
- - Plugin hooks vs stub overlays vs relying purely on SQLAlchemy typing (with “when to use what” guidance).
97
+ - Plugin hooks vs stub overlays vs relying purely on SQLAlchemy typing (with "when to use what" guidance).
98
98
 
99
99
  ## v0.8 (Field `sa_type=` supports SQLAlchemy TypeEngine instances) - DONE
100
100
 
101
- **Motivation**: SQLModel’s `Field(sa_type=...)` is a supported API, but real-world usage commonly passes
101
+ **Motivation**: SQLModel's `Field(sa_type=...)` is a supported API, but real-world usage commonly passes
102
102
  **instantiated SQLAlchemy types** (e.g. `DateTime(timezone=True)`, `String(50)`), which triggers mypy
103
103
  `call-overload` errors under `--strict`. Users currently work around this with `sa_column=Column(...)` or
104
- `# type: ignore`, which is exactly the “types clutter” we want to remove.
104
+ `# type: ignore`, which is exactly the "types clutter" we want to remove.
105
105
 
106
106
  - **Fix mypy `call-overload` for `Field(sa_type=<TypeEngine instance>)`**
107
107
  - Upstream issue reports:
108
108
  - `https://github.com/fastapi/sqlmodel/discussions/955`
109
109
  - `https://github.com/fastapi/sqlmodel/discussions/1228`
110
- - Upstream pending fix (adjust `sa_type` annotation to match SQLAlchemy’s `Column` type argument rules):
110
+ - Upstream pending fix (adjust `sa_type` annotation to match SQLAlchemy's `Column` type argument rules):
111
111
  - `https://github.com/fastapi/sqlmodel/pull/1345`
112
112
  - Source references:
113
113
  - SQLModel `Field()` signature: `https://github.com/fastapi/sqlmodel/blob/main/sqlmodel/main.py`
@@ -122,7 +122,7 @@ errors for documented patterns.
122
122
  touching runtime behavior.
123
123
 
124
124
  - **Tests**
125
- - Add a mypy integration module that reproduces the failure in strict mode and asserts it’s fixed without
125
+ - Add a mypy integration module that reproduces the failure in strict mode and asserts it's fixed without
126
126
  `# type: ignore`, e.g.:
127
127
  - `Field(sa_type=DateTime(timezone=True))`
128
128
  - `Field(sa_type=String(50))`
@@ -133,7 +133,7 @@ errors for documented patterns.
133
133
 
134
134
  **Motivation**: SQLModel is built on Pydantic v2, and users often want to set things like `extra="forbid"` on
135
135
  schema-like models (e.g. `HeroCreate`, `HeroUpdate`) via `model_config = ConfigDict(...)`. Today this can fail mypy
136
- strict with an “incompatible types in assignment” error (`ConfigDict` vs `SQLModelConfig`), forcing users into
136
+ strict with an "incompatible types in assignment" error (`ConfigDict` vs `SQLModelConfig`), forcing users into
137
137
  `# type: ignore` or `cast(...)`.
138
138
 
139
139
  - **Fix mypy incompatibility for `model_config` overrides**
@@ -148,7 +148,7 @@ strict with an “incompatible types in assignment” error (`ConfigDict` vs `SQ
148
148
  so assignment from `ConfigDict(...)` is always accepted.
149
149
  - **Plugin adjustment**: during semantic analysis, rewrite the inferred type of `model_config` on SQLModel subclasses
150
150
  to a compatible supertype (while keeping runtime untouched).
151
- - Ensure we don’t regress table-model behavior: SQLModel uses `model_config["table"]` and `model_config["registry"]`
151
+ - Ensure we don't regress table-model behavior: SQLModel uses `model_config["table"]` and `model_config["registry"]`
152
152
  at runtime (see `SQLModelMetaclass.__new__`).
153
153
 
154
154
  - **Tests**
@@ -160,8 +160,8 @@ strict with an “incompatible types in assignment” error (`ConfigDict` vs `SQ
160
160
  ## v0.10 (`select()` varargs: avoid overload ceiling) - DONE
161
161
 
162
162
  **Motivation**: SQLModel provides its own typed `select()` overloads, but they are generated only up to **4**
163
- entities. In real code it’s common to select more than 4 columns/entities (especially with joins/aggregates), which
164
- can trigger “No overload variant of `select` matches argument types …” even though runtime works.
163
+ entities. In real code it's common to select more than 4 columns/entities (especially with joins/aggregates), which
164
+ can trigger "No overload variant of `select` matches argument types …" even though runtime works.
165
165
 
166
166
  - **Fix overload ceiling for `sqlmodel.select(...)`**
167
167
  - Source reference (generated overloads; note the 4-entity cap):
@@ -171,26 +171,26 @@ can trigger “No overload variant of `select` matches argument types …” eve
171
171
  - `https://github.com/fastapi/sqlmodel/issues/271`
172
172
 
173
173
  - **Implementation options (pick default; document trade-offs)**
174
- - **Stub overlay (likely simplest)**: add a final “varargs fallback” overload, e.g.:
174
+ - **Stub overlay (likely simplest)**: add a final "varargs fallback" overload, e.g.:
175
175
  - `@overload`
176
176
  - `def select(__ent0: Any, __ent1: Any, __ent2: Any, __ent3: Any, __ent4: Any, *entities: Any) -> Select[tuple[Any, ...]]: ...`
177
177
  so mypy always has a matching overload beyond 4 args.
178
178
  - **Plugin signature hook**: provide a mypy hook for `sqlmodel.sql.expression.select` that adds the same fallback
179
179
  overload, keeping the existing 1–4 entity overload precision.
180
- - Optionally, generate more precise overloads (e.g. up to 8) if we’re willing to carry the maintenance cost.
180
+ - Optionally, generate more precise overloads (e.g. up to 8) if we're willing to carry the maintenance cost.
181
181
 
182
182
  - **Tests**
183
183
  - Add a mypy integration module that selects 5+ entities and asserts no `call-overload` errors under strict mode.
184
184
  - Decide and lock down the fallback return type (likely `Select[tuple[Any, ...]]`) to keep behavior predictable.
185
185
 
186
- ## v0.11 (SQLAlchemy “extras” coverage + SQLModel `Select` join parity) - DONE
186
+ ## v0.11 (SQLAlchemy "extras" coverage + SQLModel `Select` join parity) - DONE
187
187
 
188
188
  - **Lock down common SQLAlchemy helpers used with SQLModel**
189
189
  - `.label(...)` on `table=True` model fields typed as SQLAlchemy expressions.
190
190
  - ORM loader options like `selectinload(Model.relationship)` on `table=True` relationships.
191
191
  - `Select.execution_options(...)` chaining without losing the `Select[...]` generic (including outer-join `None` propagation).
192
- - **Fix outer-join `None` propagation for SQLModel’s own `Select` wrapper**
193
- - Apply the join/outerjoin return-type adjustment to both SQLAlchemy `Select` and SQLModel’s `sqlmodel.sql._expression_select_cls.Select`.
192
+ - **Fix outer-join `None` propagation for SQLModel's own `Select` wrapper**
193
+ - Apply the join/outerjoin return-type adjustment to both SQLAlchemy `Select` and SQLModel's `sqlmodel.sql._expression_select_cls.Select`.
194
194
  - **Tests**
195
195
  - Add/extend mypy integration coverage and commit golden outputs for the new cases.
196
196
 
@@ -201,7 +201,7 @@ models do provide this at runtime for `table=True`, but type checkers can miss i
201
201
 
202
202
  - **Expose and type `__table__` on `table=True` SQLModel models**
203
203
  - Provide a plugin-generated `__table__` attribute typed as `sqlalchemy.sql.schema.Table` (with safe fallbacks
204
- when stubs don’t expose `Table`).
204
+ when stubs don't expose `Table`).
205
205
  - **Tests**
206
206
  - Add mypy integration coverage for `Model.__table__` and `Session.get` / `AsyncSession.get` with SQLModel
207
207
  table models.
@@ -212,29 +212,7 @@ models do provide this at runtime for `table=True`, but type checkers can miss i
212
212
  - Best-effort typing for `column_property(...)` return type (infer `Mapped[T]`) to support
213
213
  `Model._foo = column_property(...)` patterns.
214
214
 
215
- ## v0.13 (SQLModel docs parity suite) - TODO
216
-
217
- **Motivation**: the north star is “SQLModel as documented works under `mypy --strict` with minimal casts/ignores”.
218
- We should lock this down with a dedicated docs-parity integration suite (beyond the current focused unit-style
219
- mypy modules).
220
-
221
- - **Add docs-derived mypy integration modules**
222
- - Create minimal, type-focused modules under `tests/mypy/modules/` that mirror official docs patterns (as close
223
- as possible, but stripped of runtime/demo noise).
224
- - Add them to `tests/mypy/test_mypy.py` and commit golden outputs under `tests/mypy/outputs/**`.
225
- - Target doc pages / patterns:
226
- - Connected-data joins and outer-joins:
227
- - Docs: `https://sqlmodel.tiangolo.com/tutorial/connect/read-connected-data/`
228
- - Relationship attribute creation/update patterns (constructor kwargs + mutation):
229
- - Docs: `https://sqlmodel.tiangolo.com/tutorial/relationship-attributes/create-and-update-relationships/`
230
- - Many-to-many relationship creation patterns:
231
- - Docs: `https://sqlmodel.tiangolo.com/tutorial/many-to-many/create-data/`
232
- - Update patterns using `sqlmodel_update()` / `model_dump(exclude_unset=True)`:
233
- - Docs: `https://sqlmodel.tiangolo.com/tutorial/fastapi/update/`
234
- - Source: `https://github.com/fastapi/sqlmodel/blob/main/sqlmodel/main.py` (`SQLModel.sqlmodel_update`)
235
- - Ensure strict-mode expectations are explicit (via `# MYPY:` comments and/or `reveal_type(...)` assertions).
236
-
237
- ## v0.14 (`typing.Annotated[...]` field metadata support) - TODO
215
+ ## v0.14 (`typing.Annotated[...]` field metadata support) - DONE
238
216
 
239
217
  **Motivation**: SQLModel (Pydantic v2) supports `Annotated`-driven typing patterns, and users will naturally try to
240
218
  use them to reduce `= Field(...)` assignment noise. Under `--strict`, we should still generate correct constructor
@@ -252,11 +230,11 @@ signatures (required/optional + alias kwargs) when `Field(...)` metadata lives i
252
230
  (`_collect_member_from_stmt`) so class-call signatures stay consistent with generated `__init__`.
253
231
  - Source references:
254
232
  - SQLModel `Field()` overloads + alias precedence: `https://github.com/fastapi/sqlmodel/blob/main/sqlmodel/main.py`
255
- - `Annotated` handling in SQLModel’s type parsing: `https://github.com/fastapi/sqlmodel/blob/main/sqlmodel/_compat.py`
256
- - Release note: “Fix support for types with `Optional[Annotated[x, f()]]`…” (PR #1093):
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):
257
235
  `https://github.com/fastapi/sqlmodel/pull/1093`
258
236
 
259
- ## v0.15 (Outer-join `None` propagation for relationship-attribute joins) - TODO
237
+ ## v0.15 (Outer-join `None` propagation for relationship-attribute joins) - DONE
260
238
 
261
239
  **Motivation**: we currently propagate `None` on outer-joins only when the join target is a direct model class
262
240
  (e.g. `.join(Team, isouter=True)`). A common ORM pattern is to join via relationship attributes
@@ -273,17 +251,99 @@ signatures (required/optional + alias kwargs) when `Field(...)` metadata lives i
273
251
  - Add mypy integration coverage under `tests/mypy/modules/` for relationship-attribute joins, including the SQLModel
274
252
  docs pattern of `Team: None` on outer-joins.
275
253
 
276
- ## v0.16 (Strict typing ergonomics add-ons, opt-in) - TODO
254
+ ## v0.16 (Strict typing ergonomics add-ons, opt-in) - DONE
277
255
 
278
- **Motivation**: strict typing + SQLModel often means the database populates values “later” (after flush/commit/
279
- refresh), especially primary keys (`id: int | None`). Mypy can’t infer those runtime effects, and users end up with
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
280
258
  repeated `assert obj.id is not None` (type clutter).
281
259
 
282
260
  - **Provide a tiny opt-in narrowing helper**
283
261
  - Docs context (why `id` starts as `None` and is populated after commit/refresh):
284
262
  - `https://sqlmodel.tiangolo.com/tutorial/automatic-id-none-refresh/`
285
- - Ship a small helper in this package (runtime-free or near-zero runtime) that helps users narrow “persisted” models
263
+ - Ship a small helper in this package (runtime-free or near-zero runtime) that helps users narrow "persisted" models
286
264
  without `cast(...)`/`# type: ignore`, e.g.:
287
265
  - `assert_persisted(obj)` / `require_id(obj)` / `ensure_persisted(obj)` returning a `TypeGuard[...]` or using
288
266
  overloads to narrow `id` to `int`.
289
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) - DONE
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 (Comprehensive typing polish) - DONE
290
+
291
+ This release consolidates several enhancements to round out SQLModel/mypy strict-mode ergonomics.
292
+
293
+ ### Recognize `table=True` via `model_config["table"]`
294
+
295
+ SQLModel's runtime `is_table_model_class()` checks `model_config.get("table")`, so a class can be a table model even
296
+ without `class Model(SQLModel, table=True)`.
297
+
298
+ - Extend semantic analysis in `src/sqlmodel_mypy/transform.py` to detect `model_config = ConfigDict(table=True)`.
299
+ - Persist the result in `TypeInfo.metadata["sqlmodel-mypy-metadata"]`, then update `_is_table_model(...)` in both
300
+ `plugin.py` and `transform.py` to consult metadata first.
301
+ - Source references:
302
+ - SQLModel metaclass: `https://github.com/fastapi/sqlmodel/blob/main/sqlmodel/main.py` (`SQLModelMetaclass.__new__`)
303
+ - SQLModel runtime table detection: `https://github.com/fastapi/sqlmodel/blob/main/sqlmodel/_compat.py`
304
+
305
+ ### `select(5+)`: recover tuple element types
306
+
307
+ v0.10 fixed the overload ceiling by accepting `select(5+)`, but the fallback return type was `Select[tuple[Any, ...]]`.
308
+
309
+ - Add a best-effort return-type hook that recovers element types from inferred argument expression types.
310
+ - Code: `_sqlmodel_select_signature_callback()`, `_sqlmodel_select_return_type_callback()`, `_sqlmodel_select_entity_result_type()`
311
+ - Tests: `tests/mypy/modules/select_varargs.py`
312
+
313
+ ### Outer-join `None` propagation for aliased/variable join targets
314
+
315
+ Outer-join `None` propagation previously only worked when the join target was a direct model ref or relationship
316
+ attribute, not when users join via an alias variable (e.g. `team_alias = aliased(Team)`).
317
+
318
+ - Extend join target type inference to handle typed variables (including `aliased(Model)` results).
319
+ - Code: `_typeinfo_from_join_target_expr()`, `_typeinfo_from_typed_join_target_expr()`, `_typeinfo_from_typed_join_target_type()`
320
+ - Tests: `tests/mypy/modules/aliased_joins.py`
321
+
322
+ ### `getattr(Model, NAME)`: support `Final`/`Literal[...]` names
323
+
324
+ Dynamic query builders often use constants for attribute names; without this, `getattr(...)` falls back to `Any`.
325
+
326
+ - Extend `getattr(Model, NAME)` typing beyond string literals by accepting name args typed as string `Literal[...]`
327
+ (and best-effort `Final` constants where mypy retains the literal).
328
+ - Code: `_sqlmodel_getattr_return_type_callback()`
329
+ - Tests: `tests/mypy/modules/getattr_column_property.py`
330
+
331
+ ### `Field(validation_alias=...)`: Pydantic v2 alias parity
332
+
333
+ Pydantic v2 supports `validation_alias=AliasPath(...)` / `AliasChoices(...)`, but SQLModel's `Field(...)` typing is
334
+ narrower; strict mypy users otherwise need `cast(...)`/`# type: ignore`.
335
+
336
+ - Widen `Field(validation_alias=...)` to accept `pydantic.aliases.AliasPath | AliasChoices` (idempotent if upstream
337
+ is already widened).
338
+ - Code: `_sqlmodel_field_signature_callback()`
339
+ - Tests: `tests/mypy/modules/field_validation_alias.py`
340
+
341
+ ### Optional typed `Session.execute()` / `AsyncSession.execute()`
342
+
343
+ SQLModel overrides `execute()` and types it as `Result[Any]`, shadowing SQLAlchemy's typed overloads. Even though
344
+ `execute()` is deprecated in SQLModel docs, users still rely on it during migrations.
345
+
346
+ - Add an opt-in mypy plugin behavior that types `execute(select(...))` as `Result[...]` (and preserves async wrapping).
347
+ - Config: `typed_execute = true` under `[sqlmodel-mypy]`
348
+ - Code: `SQLModelPluginConfig.typed_execute`, `_sqlmodel_session_execute_return_type_callback()`, `_row_tuple_type_from_typed_statement()`
349
+ - Tests: `tests/mypy/configs/mypy-plugin-strict-typed-execute.ini`, `tests/mypy/modules/session_execute_typed.py`
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sqlmodel-mypy-plugin"
7
- version = "0.12.3"
7
+ version = "0.18.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.18.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