sqlmodel-mypy-plugin 0.12.3__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.3 → sqlmodel_mypy_plugin-0.16.0}/PKG-INFO +19 -2
  2. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/README.md +18 -1
  3. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/ROADMAP.md +47 -25
  4. {sqlmodel_mypy_plugin-0.12.3 → 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.3 → sqlmodel_mypy_plugin-0.16.0}/src/sqlmodel_mypy/plugin.py +138 -4
  8. {sqlmodel_mypy_plugin-0.12.3 → 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.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/joins.py +19 -1
  15. sqlmodel_mypy_plugin-0.16.0/tests/mypy/modules/persisted_helpers.py +27 -0
  16. sqlmodel_mypy_plugin-0.16.0/tests/mypy/outputs/mypy-plugin-strict_ini/annotated_field_metadata.py +38 -0
  17. sqlmodel_mypy_plugin-0.16.0/tests/mypy/outputs/mypy-plugin-strict_ini/docs_parity_many_to_many_create_data.py +52 -0
  18. sqlmodel_mypy_plugin-0.16.0/tests/mypy/outputs/mypy-plugin-strict_ini/docs_parity_read_connected_data.py +49 -0
  19. sqlmodel_mypy_plugin-0.16.0/tests/mypy/outputs/mypy-plugin-strict_ini/docs_parity_relationship_attributes.py +49 -0
  20. sqlmodel_mypy_plugin-0.16.0/tests/mypy/outputs/mypy-plugin-strict_ini/docs_parity_update_patterns.py +41 -0
  21. sqlmodel_mypy_plugin-0.16.0/tests/mypy/outputs/mypy-plugin-strict_ini/joins.py +55 -0
  22. sqlmodel_mypy_plugin-0.16.0/tests/mypy/outputs/mypy-plugin-strict_ini/persisted_helpers.py +28 -0
  23. sqlmodel_mypy_plugin-0.16.0/tests/mypy/outputs/mypy-plugin_ini/joins.py +55 -0
  24. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/test_mypy.py +6 -0
  25. sqlmodel_mypy_plugin-0.16.0/tests/test_persisted_helpers.py +24 -0
  26. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/test_plugin_unit.py +305 -0
  27. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/test_transform_unit.py +105 -0
  28. sqlmodel_mypy_plugin-0.12.3/src/sqlmodel_mypy/__init__.py +0 -3
  29. sqlmodel_mypy_plugin-0.12.3/tests/mypy/outputs/mypy-plugin-strict_ini/joins.py +0 -35
  30. sqlmodel_mypy_plugin-0.12.3/tests/mypy/outputs/mypy-plugin_ini/joins.py +0 -35
  31. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/.gitignore +0 -0
  32. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/.pre-commit-config.yaml +0 -0
  33. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/AGENTS.md +0 -0
  34. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/LICENSE +0 -0
  35. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/src/sqlmodel_mypy/py.typed +0 -0
  36. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/conftest.py +0 -0
  37. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/configs/mypy-plugin-no-untyped.ini +0 -0
  38. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/configs/mypy-plugin-pydantic-first.ini +0 -0
  39. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/configs/mypy-plugin-pydantic-last.ini +0 -0
  40. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/configs/mypy-plugin-strict-no-explicit-any.ini +0 -0
  41. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/configs/mypy-plugin-strict.ini +0 -0
  42. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/configs/mypy-plugin.ini +0 -0
  43. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/configs/pyproject-plugin.toml +0 -0
  44. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/aliases.py +0 -0
  45. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/constructor.py +0 -0
  46. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/execution_options.py +0 -0
  47. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/expression_typing_strict.py +0 -0
  48. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/expressions.py +0 -0
  49. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/field_sa_type.py +0 -0
  50. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/generics.py +0 -0
  51. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/getattr_column_property.py +0 -0
  52. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/inheritance.py +0 -0
  53. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/label.py +0 -0
  54. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/many_to_many_relationships.py +0 -0
  55. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/mapped_fields.py +0 -0
  56. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/model_config.py +0 -0
  57. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/model_construct.py +0 -0
  58. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/nullable_defaults.py +0 -0
  59. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/relationship_comparators.py +0 -0
  60. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/relationship_expressions.py +0 -0
  61. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/relationships.py +0 -0
  62. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/select_varargs.py +0 -0
  63. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/selectinload.py +0 -0
  64. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/session_exec.py +0 -0
  65. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/session_get.py +0 -0
  66. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/table_dunders.py +0 -0
  67. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/table_model_awareness.py +0 -0
  68. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/untyped_fields.py +0 -0
  69. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-pydantic-first_ini/relationships.py +0 -0
  70. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-pydantic-last_ini/relationships.py +0 -0
  71. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/aliases.py +0 -0
  72. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/constructor.py +0 -0
  73. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/execution_options.py +0 -0
  74. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/generics.py +0 -0
  75. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/label.py +0 -0
  76. {sqlmodel_mypy_plugin-0.12.3 → 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.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/mapped_fields.py +0 -0
  78. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/model_construct.py +0 -0
  79. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/nullable_defaults.py +0 -0
  80. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/relationship_comparators.py +0 -0
  81. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/relationships.py +0 -0
  82. {sqlmodel_mypy_plugin-0.12.3 → 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.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/aliases.py +0 -0
  84. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/constructor.py +0 -0
  85. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/execution_options.py +0 -0
  86. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/expressions.py +0 -0
  87. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/inheritance.py +0 -0
  88. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/label.py +0 -0
  89. {sqlmodel_mypy_plugin-0.12.3 → 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.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/model_construct.py +0 -0
  91. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/nullable_defaults.py +0 -0
  92. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/relationship_comparators.py +0 -0
  93. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/relationship_expressions.py +0 -0
  94. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/relationships.py +0 -0
  95. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/table_model_awareness.py +0 -0
  96. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/untyped_fields.py +0 -0
  97. {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/pyproject-plugin_toml/constructor.py +0 -0
  98. {sqlmodel_mypy_plugin-0.12.3 → 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.3
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
@@ -220,6 +220,23 @@ Limitations:
220
220
  - Mypy can’t express “either field name or alias is required”, so aliased required fields may not be reported as
221
221
  missing at type-check time.
222
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
+
223
240
  ## Development
224
241
 
225
242
  - List available commands: `make help`
@@ -227,7 +244,7 @@ Limitations:
227
244
  - Typecheck plugin code: `make typecheck`
228
245
  - Run tests: `make test`
229
246
  - Run all quality gates: `make check`
230
- - Update mypy \u201cgolden\u201d outputs: `make update-mypy`
247
+ - Update mypy "golden" outputs: `make update-mypy`
231
248
 
232
249
  See [`AGENTS.md`](AGENTS.md) for repo conventions and workflow notes.
233
250
 
@@ -174,6 +174,23 @@ Limitations:
174
174
  - Mypy can’t express “either field name or alias is required”, so aliased required fields may not be reported as
175
175
  missing at type-check time.
176
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
+
177
194
  ## Development
178
195
 
179
196
  - List available commands: `make help`
@@ -181,7 +198,7 @@ Limitations:
181
198
  - Typecheck plugin code: `make typecheck`
182
199
  - Run tests: `make test`
183
200
  - Run all quality gates: `make check`
184
- - Update mypy \u201cgolden\u201d outputs: `make update-mypy`
201
+ - Update mypy "golden" outputs: `make update-mypy`
185
202
 
186
203
  See [`AGENTS.md`](AGENTS.md) for repo conventions and workflow notes.
187
204
 
@@ -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
@@ -256,7 +234,7 @@ signatures (required/optional + alias kwargs) when `Field(...)` metadata lives i
256
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,7 +251,7 @@ 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
256
  **Motivation**: strict typing + SQLModel often means the database populates values “later” (after flush/commit/
279
257
  refresh), especially primary keys (`id: int | None`). Mypy can’t infer those runtime effects, and users end up with
@@ -287,3 +265,47 @@ repeated `assert obj.id is not None` (type clutter).
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)
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.3"
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
@@ -154,7 +154,7 @@ SQLMODEL_SESSION_EXEC_FULLNAME = "sqlmodel.orm.session.Session.exec"
154
154
  SQLMODEL_ASYNC_SESSION_EXEC_FULLNAME = "sqlmodel.ext.asyncio.session.AsyncSession.exec"
155
155
 
156
156
  # Increment when plugin changes should invalidate mypy cache.
157
- __version__ = 17
157
+ __version__ = 20
158
158
 
159
159
 
160
160
  class _CollectedField(NamedTuple):
@@ -187,6 +187,19 @@ def _is_table_model(info: TypeInfo) -> bool:
187
187
  if kw is not None:
188
188
  return _is_bool_nameexpr(kw, True)
189
189
 
190
+ # Prefer persisted metadata when the class-body AST isn't available
191
+ # (common in incremental mode when loading modules from cache).
192
+ metadata = getattr(info, "metadata", None)
193
+ md = metadata.get(METADATA_KEY) if isinstance(metadata, dict) else None
194
+ if isinstance(md, dict):
195
+ md_table = md.get("is_table_model")
196
+ if isinstance(md_table, bool):
197
+ return md_table
198
+ # Backwards-compatible alias for older metadata shapes.
199
+ md_table = md.get("table")
200
+ if isinstance(md_table, bool):
201
+ return md_table
202
+
190
203
  # Inherit `table=True` from bases if present.
191
204
  for base in info.mro[1:]:
192
205
  if base.fullname == SQLMODEL_BASEMODEL_FULLNAME:
@@ -194,6 +207,10 @@ def _is_table_model(info: TypeInfo) -> bool:
194
207
  kw = base.defn.keywords.get("table")
195
208
  if kw is not None and _is_bool_nameexpr(kw, True):
196
209
  return True
210
+ base_metadata = getattr(base, "metadata", None)
211
+ base_md = base_metadata.get(METADATA_KEY) if isinstance(base_metadata, dict) else None
212
+ if isinstance(base_md, dict) and base_md.get("is_table_model") is True:
213
+ return True
197
214
  return False
198
215
 
199
216
 
@@ -628,9 +645,24 @@ class SQLModelMypyPlugin(Plugin):
628
645
  if sym.node.is_classvar:
629
646
  return None
630
647
 
631
- has_default = SQLModelTransformer.get_has_default(stmt)
632
- aliases = tuple(SQLModelTransformer.get_field_aliases(stmt))
633
648
  init_type = sym.node.type
649
+ has_default = SQLModelTransformer.get_has_default(stmt)
650
+ aliases: tuple[str, ...] = tuple(SQLModelTransformer.get_field_aliases(stmt))
651
+
652
+ # Prefer semantic-phase metadata when available (keeps checker-time fallback
653
+ # consistent with plugin-generated signatures, including Annotated Field metadata).
654
+ md = defining_info.metadata.get(METADATA_KEY)
655
+ if isinstance(md, dict):
656
+ fields = md.get("fields")
657
+ if isinstance(fields, dict):
658
+ data = fields.get(name)
659
+ if isinstance(data, dict):
660
+ md_has_default = data.get("has_default")
661
+ if isinstance(md_has_default, bool):
662
+ has_default = md_has_default
663
+ md_aliases = data.get("aliases")
664
+ if isinstance(md_aliases, list):
665
+ aliases = tuple(a for a in md_aliases if isinstance(a, str))
634
666
  if init_type is not None and defining_info is not current_info:
635
667
  with state.strict_optional_set(api.options.strict_optional):
636
668
  init_type = map_type_from_supertype(init_type, current_info, defining_info)
@@ -1064,6 +1096,108 @@ class SQLModelMypyPlugin(Plugin):
1064
1096
  except Exception:
1065
1097
  return _plugin_any()
1066
1098
 
1099
+ def _typeinfo_from_join_target_expr(self, expr: Expression | None, api: Any) -> TypeInfo | None:
1100
+ """Return a best-effort `TypeInfo` for a join target expression.
1101
+
1102
+ Supports direct model-class targets (e.g. `join(Team)`) and relationship
1103
+ attribute targets (e.g. `join(Hero.team)`).
1104
+ """
1105
+ info = _typeinfo_from_ref_expr(expr)
1106
+ if info is not None:
1107
+ return info
1108
+ return self._typeinfo_from_relationship_join_target(expr, api)
1109
+
1110
+ def _typeinfo_from_relationship_join_target(
1111
+ self, expr: Expression | None, api: Any
1112
+ ) -> TypeInfo | None:
1113
+ """Infer join target model type from `Model.relationship` expressions."""
1114
+ if not isinstance(expr, MemberExpr):
1115
+ return None
1116
+
1117
+ owner_info = _typeinfo_from_ref_expr(expr.expr)
1118
+ if owner_info is None:
1119
+ return None
1120
+ if owner_info.fullname == SQLMODEL_BASEMODEL_FULLNAME:
1121
+ return None
1122
+ if not owner_info.has_base(SQLMODEL_BASEMODEL_FULLNAME):
1123
+ return None
1124
+ if not _is_table_model(owner_info):
1125
+ return None
1126
+
1127
+ rel_name = expr.name
1128
+ try:
1129
+ _fields, relationships = self._collect_members_for_signature(owner_info, api)
1130
+ except Exception:
1131
+ return None
1132
+
1133
+ rel_type: Type | None = None
1134
+ for rel in relationships:
1135
+ if rel.name == rel_name:
1136
+ rel_type = rel.type
1137
+ break
1138
+ if rel_type is None:
1139
+ return None
1140
+
1141
+ entity_type = self._unwrap_relationship_entity_type(rel_type)
1142
+ if entity_type is None:
1143
+ return None
1144
+ proper = get_proper_type(entity_type)
1145
+ if not isinstance(proper, Instance):
1146
+ return None
1147
+
1148
+ target_info = proper.type
1149
+ if not target_info.has_base(SQLMODEL_BASEMODEL_FULLNAME):
1150
+ return None
1151
+ return target_info
1152
+
1153
+ @classmethod
1154
+ def _unwrap_relationship_entity_type(cls, typ: Type) -> Type | None:
1155
+ """Return the related entity type for relationship annotations.
1156
+
1157
+ Examples:
1158
+ - `Team | None` -> `Team`
1159
+ - `list[Hero]` -> `Hero`
1160
+ - `Mapped[list[Hero]]` -> `Hero`
1161
+ """
1162
+ typ = _unwrap_mapped_type(typ)
1163
+ proper = get_proper_type(typ)
1164
+
1165
+ if isinstance(proper, UnionType):
1166
+ non_none: list[Type] = []
1167
+ for item in proper.items:
1168
+ if isinstance(get_proper_type(item), NoneType):
1169
+ continue
1170
+ non_none.append(item)
1171
+ if len(non_none) != 1:
1172
+ return None
1173
+ return cls._unwrap_relationship_entity_type(non_none[0])
1174
+
1175
+ if isinstance(proper, Instance):
1176
+ # Relationship types may appear wrapped as ORM descriptor/expression types.
1177
+ if proper.type.fullname in SQLALCHEMY_INSTRUMENTED_ATTRIBUTE_FULLNAMES and proper.args:
1178
+ return cls._unwrap_relationship_entity_type(proper.args[0])
1179
+ if (
1180
+ proper.type.fullname == "sqlalchemy.orm.attributes.QueryableAttribute"
1181
+ and proper.args
1182
+ ):
1183
+ return cls._unwrap_relationship_entity_type(proper.args[0])
1184
+
1185
+ # One-to-many / many-to-many relationships are commonly annotated as `list[T]`.
1186
+ if proper.args and proper.type.fullname in {
1187
+ "builtins.list",
1188
+ "builtins.set",
1189
+ "builtins.frozenset",
1190
+ "collections.abc.Sequence",
1191
+ "typing.Sequence",
1192
+ "typing.List",
1193
+ "typing.Set",
1194
+ "typing.FrozenSet",
1195
+ }:
1196
+ return cls._unwrap_relationship_entity_type(proper.args[0])
1197
+ return typ
1198
+
1199
+ return None
1200
+
1067
1201
  def _sqlalchemy_select_join_like_return_type(
1068
1202
  self, ctx: MethodContext, *, target_positional_index: int, isouter_always: bool
1069
1203
  ) -> Type:
@@ -1080,7 +1214,7 @@ class SQLModelMypyPlugin(Plugin):
1080
1214
  return ctx.default_return_type
1081
1215
 
1082
1216
  target_expr = _call_get_arg(call, "target", target_positional_index)
1083
- target_info = _typeinfo_from_ref_expr(target_expr)
1217
+ target_info = self._typeinfo_from_join_target_expr(target_expr, ctx.api)
1084
1218
  if target_info is None:
1085
1219
  return ctx.default_return_type
1086
1220