sqlmodel-mypy-plugin 0.12.2__tar.gz → 0.12.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/PKG-INFO +18 -4
  2. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/README.md +17 -3
  3. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/ROADMAP.md +88 -1
  4. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/pyproject.toml +1 -1
  5. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/src/sqlmodel_mypy/__init__.py +1 -1
  6. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/src/sqlmodel_mypy/plugin.py +205 -1
  7. sqlmodel_mypy_plugin-0.12.3/tests/mypy/modules/getattr_column_property.py +45 -0
  8. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/test_mypy.py +2 -0
  9. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/test_plugin_unit.py +293 -0
  10. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/.gitignore +0 -0
  11. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/.pre-commit-config.yaml +0 -0
  12. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/AGENTS.md +0 -0
  13. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/LICENSE +0 -0
  14. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/src/sqlmodel_mypy/py.typed +0 -0
  15. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/src/sqlmodel_mypy/transform.py +0 -0
  16. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/conftest.py +0 -0
  17. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/configs/mypy-plugin-no-untyped.ini +0 -0
  18. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/configs/mypy-plugin-pydantic-first.ini +0 -0
  19. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/configs/mypy-plugin-pydantic-last.ini +0 -0
  20. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/configs/mypy-plugin-strict-no-explicit-any.ini +0 -0
  21. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/configs/mypy-plugin-strict.ini +0 -0
  22. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/configs/mypy-plugin.ini +0 -0
  23. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/configs/pyproject-plugin.toml +0 -0
  24. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/aliases.py +0 -0
  25. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/constructor.py +0 -0
  26. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/execution_options.py +0 -0
  27. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/expression_typing_strict.py +0 -0
  28. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/expressions.py +0 -0
  29. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/field_sa_type.py +0 -0
  30. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/generics.py +0 -0
  31. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/inheritance.py +0 -0
  32. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/joins.py +0 -0
  33. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/label.py +0 -0
  34. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/many_to_many_relationships.py +0 -0
  35. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/mapped_fields.py +0 -0
  36. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/model_config.py +0 -0
  37. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/model_construct.py +0 -0
  38. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/nullable_defaults.py +0 -0
  39. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/relationship_comparators.py +0 -0
  40. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/relationship_expressions.py +0 -0
  41. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/relationships.py +0 -0
  42. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/select_varargs.py +0 -0
  43. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/selectinload.py +0 -0
  44. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/session_exec.py +0 -0
  45. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/session_get.py +0 -0
  46. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/table_dunders.py +0 -0
  47. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/table_model_awareness.py +0 -0
  48. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/untyped_fields.py +0 -0
  49. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-pydantic-first_ini/relationships.py +0 -0
  50. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-pydantic-last_ini/relationships.py +0 -0
  51. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/aliases.py +0 -0
  52. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/constructor.py +0 -0
  53. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/execution_options.py +0 -0
  54. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/generics.py +0 -0
  55. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/joins.py +0 -0
  56. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/label.py +0 -0
  57. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/many_to_many_relationships.py +0 -0
  58. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/mapped_fields.py +0 -0
  59. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/model_construct.py +0 -0
  60. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/nullable_defaults.py +0 -0
  61. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/relationship_comparators.py +0 -0
  62. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/relationships.py +0 -0
  63. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/table_model_awareness.py +0 -0
  64. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/aliases.py +0 -0
  65. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/constructor.py +0 -0
  66. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/execution_options.py +0 -0
  67. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/expressions.py +0 -0
  68. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/inheritance.py +0 -0
  69. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/joins.py +0 -0
  70. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/label.py +0 -0
  71. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/many_to_many_relationships.py +0 -0
  72. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/model_construct.py +0 -0
  73. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/nullable_defaults.py +0 -0
  74. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/relationship_comparators.py +0 -0
  75. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/relationship_expressions.py +0 -0
  76. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/relationships.py +0 -0
  77. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/table_model_awareness.py +0 -0
  78. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/untyped_fields.py +0 -0
  79. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/pyproject-plugin_toml/constructor.py +0 -0
  80. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/smoke_test.py +0 -0
  81. {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/test_transform_unit.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.12.3
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
 
@@ -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
 
@@ -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,85 @@ 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.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
238
+
239
+ **Motivation**: SQLModel (Pydantic v2) supports `Annotated`-driven typing patterns, and users will naturally try to
240
+ use them to reduce `= Field(...)` assignment noise. Under `--strict`, we should still generate correct constructor
241
+ signatures (required/optional + alias kwargs) when `Field(...)` metadata lives inside `Annotated[...]`.
242
+
243
+ - **Support `Annotated[T, Field(...)]` for constructor/signature generation**
244
+ - Examples we should handle (at least best-effort):
245
+ - `id: Annotated[int | None, Field(default=None, primary_key=True)]`
246
+ - `name: Annotated[str, Field(alias="full_name")]`
247
+ - The historical SQLModel edge-case: `Optional[Annotated[T, ...]]` (see upstream release notes).
248
+ - Implementation sketch:
249
+ - Extend semantic-phase collection in `src/sqlmodel_mypy/transform.py` to extract Field metadata from the
250
+ annotation when there is no `= Field(...)` call in the class body.
251
+ - Mirror the same logic in checker-time signature collection in `src/sqlmodel_mypy/plugin.py`
252
+ (`_collect_member_from_stmt`) so class-call signatures stay consistent with generated `__init__`.
253
+ - Source references:
254
+ - 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):
257
+ `https://github.com/fastapi/sqlmodel/pull/1093`
258
+
259
+ ## v0.15 (Outer-join `None` propagation for relationship-attribute joins) - TODO
260
+
261
+ **Motivation**: we currently propagate `None` on outer-joins only when the join target is a direct model class
262
+ (e.g. `.join(Team, isouter=True)`). A common ORM pattern is to join via relationship attributes
263
+ (e.g. `.join(Hero.team, isouter=True)`), and the result tuple should still reflect that the joined entity may be
264
+ `None`.
265
+
266
+ - **Expand join-target detection**
267
+ - Extend `src/sqlmodel_mypy/plugin.py` (`_sqlalchemy_select_join_like_return_type`) to also detect join targets
268
+ expressed as relationship attributes (e.g. `Hero.team`, `Team.heroes`) and map them to the target model type
269
+ for `None` propagation.
270
+ - References:
271
+ - SQLModel outer-join behavior (baseline example): `https://sqlmodel.tiangolo.com/tutorial/connect/read-connected-data/`
272
+ - SQLAlchemy relationship-attribute joins (`select(User).join(User.addresses)`): `https://docs.sqlalchemy.org/en/20/orm/queryguide/select.html#simple-relationship-joins`
273
+ - Add mypy integration coverage under `tests/mypy/modules/` for relationship-attribute joins, including the SQLModel
274
+ docs pattern of `Team: None` on outer-joins.
275
+
276
+ ## v0.16 (Strict typing ergonomics add-ons, opt-in) - TODO
277
+
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
280
+ repeated `assert obj.id is not None` (type clutter).
281
+
282
+ - **Provide a tiny opt-in narrowing helper**
283
+ - Docs context (why `id` starts as `None` and is populated after commit/refresh):
284
+ - `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
286
+ without `cast(...)`/`# type: ignore`, e.g.:
287
+ - `assert_persisted(obj)` / `require_id(obj)` / `ensure_persisted(obj)` returning a `TypeGuard[...]` or using
288
+ overloads to narrow `id` to `int`.
289
+ - Keep it optional and well-documented; default plugin behavior should remain conservative.
@@ -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.12.3"
8
8
  description = "Mypy plugin for SQLModel (better constructor/Field typing)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "0.12.2"
3
+ __version__ = "0.12.3"
@@ -27,6 +27,7 @@ from mypy.nodes import (
27
27
  MemberExpr,
28
28
  NameExpr,
29
29
  RefExpr,
30
+ StrExpr,
30
31
  SymbolTableNode,
31
32
  TypeInfo,
32
33
  Var,
@@ -81,6 +82,8 @@ ERROR_PLUGIN_ORDER = ErrorCode(
81
82
  "SQLModel",
82
83
  )
83
84
 
85
+ BUILTINS_GETATTR_FULLNAME = "builtins.getattr"
86
+
84
87
  SQLMODEL_COL_FULLNAME = "sqlmodel.sql.expression.col"
85
88
  # SQLModel's `select()` is implemented in a generated module and re-exported.
86
89
  SQLMODEL_SELECT_GEN_FULLNAME = "sqlmodel.sql._expression_select_gen.select"
@@ -95,6 +98,13 @@ SQLALCHEMY_INSTRUMENTED_ATTRIBUTE_FULLNAMES = {
95
98
  "sqlalchemy.orm.attributes.InstrumentedAttribute",
96
99
  }
97
100
 
101
+ SQLALCHEMY_COLUMN_PROPERTY_FULLNAMES = {
102
+ # Imported from `sqlalchemy.orm` (common user import).
103
+ "sqlalchemy.orm.column_property",
104
+ # May resolve to the internal defining module depending on stubs/version.
105
+ "sqlalchemy.orm._orm_constructors.column_property",
106
+ }
107
+
98
108
  SQLALCHEMY_SELECT_FULLNAME = "sqlalchemy.sql.selectable.Select"
99
109
  SQLALCHEMY_SELECT_JOIN_FULLNAME = f"{SQLALCHEMY_SELECT_FULLNAME}.join"
100
110
  SQLALCHEMY_SELECT_JOIN_FROM_FULLNAME = f"{SQLALCHEMY_SELECT_FULLNAME}.join_from"
@@ -144,7 +154,7 @@ SQLMODEL_SESSION_EXEC_FULLNAME = "sqlmodel.orm.session.Session.exec"
144
154
  SQLMODEL_ASYNC_SESSION_EXEC_FULLNAME = "sqlmodel.ext.asyncio.session.AsyncSession.exec"
145
155
 
146
156
  # Increment when plugin changes should invalidate mypy cache.
147
- __version__ = 16
157
+ __version__ = 17
148
158
 
149
159
 
150
160
  class _CollectedField(NamedTuple):
@@ -456,6 +466,10 @@ class SQLModelMypyPlugin(Plugin):
456
466
  def get_function_hook(self, fullname: str) -> Callable[[FunctionContext], Type] | None:
457
467
  if fullname == SQLMODEL_COL_FULLNAME:
458
468
  return self._sqlmodel_col_return_type_callback
469
+ if fullname == BUILTINS_GETATTR_FULLNAME:
470
+ return self._sqlmodel_getattr_return_type_callback
471
+ if fullname in SQLALCHEMY_COLUMN_PROPERTY_FULLNAMES:
472
+ return self._sqlalchemy_column_property_return_type_callback
459
473
  return None
460
474
 
461
475
  def report_config_data(self, ctx: ReportConfigContext) -> dict[str, Any]:
@@ -1159,6 +1173,196 @@ class SQLModelMypyPlugin(Plugin):
1159
1173
  return Instance(info, [value_type])
1160
1174
  return ctx.default_return_type
1161
1175
 
1176
+ @staticmethod
1177
+ def _lookup_var_in_mro(info: TypeInfo, name: str) -> Var | None:
1178
+ """Find a `Var` named `name` in `info` or its MRO (best-effort)."""
1179
+ for base in info.mro:
1180
+ sym = base.names.get(name)
1181
+ if sym is not None and isinstance(sym.node, Var):
1182
+ return sym.node
1183
+ return None
1184
+
1185
+ @staticmethod
1186
+ def _is_sqlalchemy_expressionish(tp: Type) -> bool:
1187
+ """Return True if `tp` already looks like a SQLAlchemy expression/descriptor type."""
1188
+ proper = get_proper_type(tp)
1189
+ if not isinstance(proper, Instance):
1190
+ return False
1191
+ if proper.type.fullname in SQLALCHEMY_MAPPED_FULLNAMES:
1192
+ return True
1193
+ if proper.type.fullname in SQLALCHEMY_INSTRUMENTED_ATTRIBUTE_FULLNAMES:
1194
+ return True
1195
+ if proper.type.fullname == "sqlalchemy.orm.attributes.QueryableAttribute":
1196
+ return True
1197
+ if proper.type.fullname in {
1198
+ "sqlalchemy.sql.elements.ColumnElement",
1199
+ "sqlalchemy.sql.expression.ColumnElement",
1200
+ }:
1201
+ return True
1202
+ return False
1203
+
1204
+ def _sqlalchemy_instrumented_attribute_type(self, api: Any, value_type: Type) -> Type | None:
1205
+ for fullname in (
1206
+ "sqlalchemy.orm.attributes.InstrumentedAttribute",
1207
+ "sqlalchemy.orm.InstrumentedAttribute",
1208
+ ):
1209
+ try:
1210
+ return cast(Type, api.named_generic_type(fullname, [value_type]))
1211
+ except Exception:
1212
+ continue
1213
+ return None
1214
+
1215
+ def _sqlalchemy_column_element_type(self, api: Any, value_type: Type) -> Type | None:
1216
+ for fullname in (
1217
+ "sqlalchemy.sql.elements.ColumnElement",
1218
+ "sqlalchemy.sql.expression.ColumnElement",
1219
+ ):
1220
+ try:
1221
+ return cast(Type, api.named_generic_type(fullname, [value_type]))
1222
+ except Exception:
1223
+ continue
1224
+ return None
1225
+
1226
+ def _sqlalchemy_expr_type_for_class_attr(self, api: Any, declared_type: Type) -> Type:
1227
+ """Return the type of `Model.attr` in SQL expressions, given declared instance type.
1228
+
1229
+ This mirrors `_sqlmodel_class_attr_type_callback` but works from a `Type`.
1230
+ """
1231
+ proper = get_proper_type(declared_type)
1232
+ if isinstance(proper, Instance):
1233
+ # If the attribute is already typed as an ORM descriptor/expression, keep it.
1234
+ if proper.type.fullname in SQLALCHEMY_INSTRUMENTED_ATTRIBUTE_FULLNAMES:
1235
+ return declared_type
1236
+ if proper.type.fullname == "sqlalchemy.orm.attributes.QueryableAttribute":
1237
+ return declared_type
1238
+ if proper.type.fullname in {
1239
+ "sqlalchemy.sql.elements.ColumnElement",
1240
+ "sqlalchemy.sql.expression.ColumnElement",
1241
+ }:
1242
+ return declared_type
1243
+
1244
+ # `Mapped[T]` is a descriptor; class-level access behaves like an instrumented attribute.
1245
+ if proper.type.fullname in SQLALCHEMY_MAPPED_FULLNAMES and proper.args:
1246
+ value_type = proper.args[0]
1247
+ inst = self._sqlalchemy_instrumented_attribute_type(api, value_type)
1248
+ if inst is not None:
1249
+ return inst
1250
+
1251
+ inst = self._sqlalchemy_instrumented_attribute_type(api, declared_type)
1252
+ if inst is not None:
1253
+ return inst
1254
+ col = self._sqlalchemy_column_element_type(api, declared_type)
1255
+ if col is not None:
1256
+ return col
1257
+ return declared_type
1258
+
1259
+ def _sqlmodel_getattr_return_type_callback(self, ctx: FunctionContext) -> Type:
1260
+ """Type `getattr(Model, \"field\")` like `Model.field` for SQLModel table models."""
1261
+ if not ctx.args or len(ctx.args) < 2:
1262
+ return ctx.default_return_type
1263
+ if not ctx.args[0] or not ctx.args[1]:
1264
+ return ctx.default_return_type
1265
+
1266
+ name_expr = ctx.args[1][0]
1267
+ if not isinstance(name_expr, StrExpr):
1268
+ return ctx.default_return_type
1269
+ attr_name = name_expr.value
1270
+
1271
+ if not ctx.arg_types or not ctx.arg_types[0]:
1272
+ return ctx.default_return_type
1273
+ obj_type = get_proper_type(ctx.arg_types[0][0])
1274
+
1275
+ owner_info: TypeInfo | None = None
1276
+ if isinstance(obj_type, TypeType):
1277
+ item = get_proper_type(obj_type.item)
1278
+ if isinstance(item, Instance):
1279
+ owner_info = item.type
1280
+ if owner_info is None:
1281
+ return ctx.default_return_type
1282
+ if owner_info.fullname == SQLMODEL_BASEMODEL_FULLNAME:
1283
+ return ctx.default_return_type
1284
+ if not owner_info.has_base(SQLMODEL_BASEMODEL_FULLNAME):
1285
+ return ctx.default_return_type
1286
+ if not _is_table_model(owner_info):
1287
+ return ctx.default_return_type
1288
+
1289
+ # Mirror `Model.__table__` support.
1290
+ if attr_name == "__table__":
1291
+ return self._sqlalchemy_table_type(ctx.api)
1292
+
1293
+ # Prefer SQLModel members (fields/relationships): treat as SQLAlchemy expressions.
1294
+ var = self._lookup_var_in_mro(owner_info, attr_name)
1295
+ if (
1296
+ var is not None
1297
+ and var.type is not None
1298
+ and self._declares_sqlmodel_member(owner_info, attr_name)
1299
+ ):
1300
+ tp = self._sqlalchemy_expr_type_for_class_attr(ctx.api, var.type)
1301
+ # For non-SQLModel attributes, only special-case SQLAlchemy expression-like descriptors.
1302
+ elif (
1303
+ var is not None and var.type is not None and self._is_sqlalchemy_expressionish(var.type)
1304
+ ):
1305
+ tp = self._sqlalchemy_expr_type_for_class_attr(ctx.api, var.type)
1306
+ else:
1307
+ return ctx.default_return_type
1308
+
1309
+ # If a default value is provided, union it in (best-effort).
1310
+ if len(ctx.arg_types) >= 3 and ctx.arg_types[2]:
1311
+ default_t = ctx.arg_types[2][0]
1312
+ return UnionType.make_union([tp, default_t])
1313
+ return tp
1314
+
1315
+ def _sqlalchemy_mapped_type(self, api: Any, value_type: Type) -> Type | None:
1316
+ for fullname in ("sqlalchemy.orm.base.Mapped", "sqlalchemy.orm.Mapped"):
1317
+ try:
1318
+ return cast(Type, api.named_generic_type(fullname, [value_type]))
1319
+ except Exception:
1320
+ continue
1321
+ return None
1322
+
1323
+ def _sqlalchemy_column_property_return_type_callback(self, ctx: FunctionContext) -> Type:
1324
+ """Best-effort typing for `sqlalchemy.orm.column_property(...)`.
1325
+
1326
+ This is primarily used to support patterns like:
1327
+ `Model._ticketscount = column_property(select(...).scalar_subquery())`
1328
+ where users later want `getattr(Model, "_ticketscount")` to behave like an ORM attribute.
1329
+ """
1330
+ default = ctx.default_return_type
1331
+ if not ctx.arg_types or not ctx.arg_types[0]:
1332
+ return default
1333
+
1334
+ # If SQLAlchemy stubs already provide a precise mapped type, keep it.
1335
+ proper_default = get_proper_type(default)
1336
+ if (
1337
+ isinstance(proper_default, Instance)
1338
+ and proper_default.type.fullname in SQLALCHEMY_MAPPED_FULLNAMES
1339
+ and proper_default.args
1340
+ and not isinstance(get_proper_type(proper_default.args[0]), AnyType)
1341
+ ):
1342
+ return default
1343
+
1344
+ # Infer `T` from the first argument type.
1345
+ arg0 = get_proper_type(ctx.arg_types[0][0])
1346
+ value_type: Type | None = None
1347
+ if isinstance(arg0, Instance) and arg0.args:
1348
+ if arg0.type.fullname in {
1349
+ "sqlalchemy.sql.selectable.ScalarSelect",
1350
+ "sqlalchemy.sql.expression.ScalarSelect",
1351
+ "sqlalchemy.sql.selectable.ScalarSubquery",
1352
+ }:
1353
+ value_type = arg0.args[0]
1354
+ if arg0.type.fullname in {
1355
+ "sqlalchemy.sql.elements.ColumnElement",
1356
+ "sqlalchemy.sql.expression.ColumnElement",
1357
+ }:
1358
+ value_type = arg0.args[0]
1359
+
1360
+ if value_type is None:
1361
+ return default
1362
+
1363
+ mapped = self._sqlalchemy_mapped_type(ctx.api, value_type)
1364
+ return mapped or default
1365
+
1162
1366
  def _sqlmodel_model_class_callback(self, ctx: ClassDefContext) -> None:
1163
1367
  # SQLModel is built on Pydantic v2; users commonly override `model_config`
1164
1368
  # with `ConfigDict(...)`. SQLModel's own config typing may be narrower
@@ -0,0 +1,45 @@
1
+ # ruff: noqa: B009
2
+ from __future__ import annotations
3
+
4
+ from sqlalchemy import func
5
+ from sqlalchemy.orm import column_property, selectinload
6
+ from sqlmodel import Field, Relationship, SQLModel, select
7
+
8
+
9
+ class Team(SQLModel, table=True):
10
+ id: int | None = Field(default=None, primary_key=True)
11
+ name: str = Field()
12
+ heroes: list[Hero] = Relationship(back_populates="team")
13
+
14
+
15
+ class Hero(SQLModel, table=True):
16
+ id: int | None = Field(default=None, primary_key=True)
17
+ name: str = Field()
18
+ team_id: int | None = Field(default=None, foreign_key="team.id")
19
+ team: Team | None = Relationship(back_populates="heroes")
20
+
21
+
22
+ stmt_team = select(Team).options(selectinload(getattr(Team, "heroes")))
23
+
24
+ # `getattr(Model, "rel")` should be an ORM attribute (not the plain annotation type).
25
+ bad_heroes: list[Hero] = getattr(Team, "heroes")
26
+
27
+
28
+ class User(SQLModel, table=True):
29
+ id: int = Field(primary_key=True)
30
+ name: str = Field()
31
+
32
+
33
+ stmt_user = select(User).where(getattr(User, "name").like("%x%"))
34
+
35
+ # `getattr(Model, "field")` should be a SQL expression (not the plain value type).
36
+ bad_id: int = getattr(User, "id")
37
+
38
+
39
+ # Dynamic column_property assigned to a SQLModel class (common pattern for computed columns).
40
+ User._order_count = column_property( # noqa: SLF001
41
+ select(func.count(Hero.id)).where(Hero.team_id == Team.id).scalar_subquery()
42
+ )
43
+
44
+ stmt_count = select(User).where(getattr(User, "_order_count").between(1, 2))
45
+ bad_count: int = getattr(User, "_order_count")
@@ -53,6 +53,8 @@ cases: list[tuple[str, str]] = [
53
53
  ("mypy-plugin.ini", "relationship_comparators.py"),
54
54
  ("mypy-plugin.ini", "selectinload.py"),
55
55
  ("mypy-plugin-strict.ini", "selectinload.py"),
56
+ ("mypy-plugin.ini", "getattr_column_property.py"),
57
+ ("mypy-plugin-strict.ini", "getattr_column_property.py"),
56
58
  ("mypy-plugin.ini", "execution_options.py"),
57
59
  ("mypy-plugin-strict.ini", "execution_options.py"),
58
60
  ("mypy-plugin.ini", "aliases.py"),
@@ -17,6 +17,7 @@ from mypy.nodes import (
17
17
  IfStmt,
18
18
  MemberExpr,
19
19
  NameExpr,
20
+ StrExpr,
20
21
  SymbolTable,
21
22
  SymbolTableNode,
22
23
  TypeInfo,
@@ -905,6 +906,298 @@ def test_col_function_hook_returns_mapped_value_type() -> None:
905
906
  assert t.args and isinstance(t.args[0], Instance) and t.args[0].type.fullname == "builtins.int"
906
907
 
907
908
 
909
+ def test_getattr_function_hook_types_sqlmodel_table_members() -> None:
910
+ p = plugin_mod.SQLModelMypyPlugin(Options())
911
+ api = DummyCheckerAPI()
912
+
913
+ model_info = make_sqlmodel_class("m.User")
914
+ model_info.defn.keywords["table"] = NameExpr("True")
915
+ model_info.metadata[plugin_mod.METADATA_KEY] = {"fields": {"id": {}}, "relationships": {}}
916
+
917
+ int_t = Instance(make_typeinfo("builtins.int"), [])
918
+ model_info.names["id"] = SymbolTableNode(0, Var("id", int_t))
919
+
920
+ hook = p.get_function_hook(plugin_mod.BUILTINS_GETATTR_FULLNAME)
921
+ assert hook is not None
922
+
923
+ user_type = TypeType(Instance(model_info, []))
924
+ ctx = FunctionContext(
925
+ arg_types=[[user_type], [Instance(make_typeinfo("builtins.str"), [])]],
926
+ arg_kinds=[[ARG_POS], [ARG_POS]],
927
+ callee_arg_names=["object", "name"],
928
+ arg_names=[[None], [None]],
929
+ default_return_type=AnyType(TypeOfAny.explicit),
930
+ args=[[NameExpr("User")], [StrExpr("id")]],
931
+ context=NameExpr("x"),
932
+ api=api,
933
+ )
934
+ t = hook(ctx)
935
+ assert isinstance(t, Instance)
936
+ assert t.type.fullname == "sqlalchemy.orm.attributes.InstrumentedAttribute"
937
+ assert t.args and isinstance(get_proper_type(t.args[0]), Instance)
938
+
939
+
940
+ def test_getattr_function_hook_handles_table_dunder_table() -> None:
941
+ p = plugin_mod.SQLModelMypyPlugin(Options())
942
+ api = DummyCheckerAPI()
943
+
944
+ model_info = make_sqlmodel_class("m.User")
945
+ model_info.defn.keywords["table"] = NameExpr("True")
946
+ model_info.metadata[plugin_mod.METADATA_KEY] = {"fields": {"id": {}}, "relationships": {}}
947
+ model_info.names["id"] = SymbolTableNode(
948
+ 0, Var("id", Instance(make_typeinfo("builtins.int"), []))
949
+ )
950
+
951
+ user_type = TypeType(Instance(model_info, []))
952
+ ctx = FunctionContext(
953
+ arg_types=[[user_type], [Instance(make_typeinfo("builtins.str"), [])]],
954
+ arg_kinds=[[ARG_POS], [ARG_POS]],
955
+ callee_arg_names=["object", "name"],
956
+ arg_names=[[None], [None]],
957
+ default_return_type=AnyType(TypeOfAny.explicit),
958
+ args=[[NameExpr("User")], [StrExpr("__table__")]],
959
+ context=NameExpr("x"),
960
+ api=api,
961
+ )
962
+ t = p._sqlmodel_getattr_return_type_callback(ctx)
963
+ assert isinstance(t, Instance)
964
+ assert t.type.fullname == "sqlalchemy.sql.schema.Table"
965
+
966
+
967
+ def test_getattr_function_hook_types_expressionish_non_member() -> None:
968
+ p = plugin_mod.SQLModelMypyPlugin(Options())
969
+ api = DummyCheckerAPI()
970
+
971
+ model_info = make_sqlmodel_class("m.User")
972
+ model_info.defn.keywords["table"] = NameExpr("True")
973
+ model_info.metadata[plugin_mod.METADATA_KEY] = {"fields": {}, "relationships": {}}
974
+
975
+ int_t = Instance(make_typeinfo("builtins.int"), [])
976
+ mapped_t = Instance(make_typeinfo("sqlalchemy.orm.base.Mapped"), [int_t])
977
+ model_info.names["_ticketscount"] = SymbolTableNode(0, Var("_ticketscount", mapped_t))
978
+
979
+ user_type = TypeType(Instance(model_info, []))
980
+ ctx = FunctionContext(
981
+ arg_types=[[user_type], [Instance(make_typeinfo("builtins.str"), [])]],
982
+ arg_kinds=[[ARG_POS], [ARG_POS]],
983
+ callee_arg_names=["object", "name"],
984
+ arg_names=[[None], [None]],
985
+ default_return_type=AnyType(TypeOfAny.explicit),
986
+ args=[[NameExpr("User")], [StrExpr("_ticketscount")]],
987
+ context=NameExpr("x"),
988
+ api=api,
989
+ )
990
+ t = p._sqlmodel_getattr_return_type_callback(ctx)
991
+ assert isinstance(t, Instance)
992
+ assert t.type.fullname == "sqlalchemy.orm.attributes.InstrumentedAttribute"
993
+
994
+
995
+ def test_column_property_return_type_callback_infers_scalar_select() -> None:
996
+ p = plugin_mod.SQLModelMypyPlugin(Options())
997
+ api = DummyCheckerAPI()
998
+
999
+ int_t = Instance(make_typeinfo("builtins.int"), [])
1000
+ scalar_select_t = Instance(make_typeinfo("sqlalchemy.sql.selectable.ScalarSelect"), [int_t])
1001
+ ctx = FunctionContext(
1002
+ arg_types=[[scalar_select_t]],
1003
+ arg_kinds=[[ARG_OPT]],
1004
+ callee_arg_names=["column"],
1005
+ arg_names=[[None]],
1006
+ default_return_type=AnyType(TypeOfAny.explicit),
1007
+ args=[[NameExpr("x")]],
1008
+ context=NameExpr("x"),
1009
+ api=api,
1010
+ )
1011
+ t = p._sqlalchemy_column_property_return_type_callback(ctx)
1012
+ assert isinstance(t, Instance)
1013
+ assert t.type.fullname == "sqlalchemy.orm.base.Mapped"
1014
+ assert t.args and isinstance(get_proper_type(t.args[0]), Instance)
1015
+
1016
+
1017
+ def test_column_property_return_type_callback_infers_column_element() -> None:
1018
+ p = plugin_mod.SQLModelMypyPlugin(Options())
1019
+ api = DummyCheckerAPI()
1020
+
1021
+ int_t = Instance(make_typeinfo("builtins.int"), [])
1022
+ col_t = Instance(make_typeinfo("sqlalchemy.sql.elements.ColumnElement"), [int_t])
1023
+ ctx = FunctionContext(
1024
+ arg_types=[[col_t]],
1025
+ arg_kinds=[[ARG_OPT]],
1026
+ callee_arg_names=["column"],
1027
+ arg_names=[[None]],
1028
+ default_return_type=AnyType(TypeOfAny.explicit),
1029
+ args=[[NameExpr("x")]],
1030
+ context=NameExpr("x"),
1031
+ api=api,
1032
+ )
1033
+ t = p._sqlalchemy_column_property_return_type_callback(ctx)
1034
+ assert isinstance(t, Instance)
1035
+ assert t.type.fullname == "sqlalchemy.orm.base.Mapped"
1036
+
1037
+
1038
+ def test_column_property_return_type_callback_preserves_precise_mapped_default() -> None:
1039
+ p = plugin_mod.SQLModelMypyPlugin(Options())
1040
+ api = DummyCheckerAPI()
1041
+
1042
+ int_t = Instance(make_typeinfo("builtins.int"), [])
1043
+ scalar_select_t = Instance(make_typeinfo("sqlalchemy.sql.selectable.ScalarSelect"), [int_t])
1044
+ default_mapped = Instance(make_typeinfo("sqlalchemy.orm.base.Mapped"), [int_t])
1045
+ ctx = FunctionContext(
1046
+ arg_types=[[scalar_select_t]],
1047
+ arg_kinds=[[ARG_OPT]],
1048
+ callee_arg_names=["column"],
1049
+ arg_names=[[None]],
1050
+ default_return_type=default_mapped,
1051
+ args=[[NameExpr("x")]],
1052
+ context=NameExpr("x"),
1053
+ api=api,
1054
+ )
1055
+ assert p._sqlalchemy_column_property_return_type_callback(ctx) is default_mapped
1056
+
1057
+
1058
+ def test_getattr_return_type_callback_returns_default_for_missing_member() -> None:
1059
+ p = plugin_mod.SQLModelMypyPlugin(Options())
1060
+ api = DummyCheckerAPI()
1061
+
1062
+ model_info = make_sqlmodel_class("m.User")
1063
+ model_info.defn.keywords["table"] = NameExpr("True")
1064
+ model_info.metadata[plugin_mod.METADATA_KEY] = {"fields": {"id": {}}, "relationships": {}}
1065
+ model_info.names["id"] = SymbolTableNode(
1066
+ 0, Var("id", Instance(make_typeinfo("builtins.int"), []))
1067
+ )
1068
+
1069
+ user_type = TypeType(Instance(model_info, []))
1070
+ ctx = FunctionContext(
1071
+ arg_types=[[user_type], [Instance(make_typeinfo("builtins.str"), [])]],
1072
+ arg_kinds=[[ARG_POS], [ARG_POS]],
1073
+ callee_arg_names=["object", "name"],
1074
+ arg_names=[[None], [None]],
1075
+ default_return_type=AnyType(TypeOfAny.explicit),
1076
+ args=[[NameExpr("User")], [StrExpr("missing")]],
1077
+ context=NameExpr("x"),
1078
+ api=api,
1079
+ )
1080
+ assert p._sqlmodel_getattr_return_type_callback(ctx).type_of_any == TypeOfAny.explicit # type: ignore[attr-defined]
1081
+
1082
+
1083
+ def test_is_sqlalchemy_expressionish_covers_non_instance_and_other_fullnames() -> None:
1084
+ p = plugin_mod.SQLModelMypyPlugin(Options())
1085
+
1086
+ union = UnionType.make_union([Instance(make_typeinfo("builtins.int"), []), NoneType()])
1087
+ assert p._is_sqlalchemy_expressionish(union) is False
1088
+
1089
+ inst_attr = Instance(
1090
+ make_typeinfo("sqlalchemy.orm.attributes.InstrumentedAttribute"), [plugin_mod._plugin_any()]
1091
+ )
1092
+ assert p._is_sqlalchemy_expressionish(inst_attr) is True
1093
+
1094
+ queryable = Instance(
1095
+ make_typeinfo("sqlalchemy.orm.attributes.QueryableAttribute"), [plugin_mod._plugin_any()]
1096
+ )
1097
+ assert p._is_sqlalchemy_expressionish(queryable) is True
1098
+
1099
+ col = Instance(
1100
+ make_typeinfo("sqlalchemy.sql.elements.ColumnElement"), [plugin_mod._plugin_any()]
1101
+ )
1102
+ assert p._is_sqlalchemy_expressionish(col) is True
1103
+
1104
+
1105
+ def test_sqlalchemy_expr_type_for_class_attr_keeps_existing_expression_types() -> None:
1106
+ p = plugin_mod.SQLModelMypyPlugin(Options())
1107
+ api = DummyCheckerAPI()
1108
+
1109
+ inst_attr = Instance(
1110
+ make_typeinfo("sqlalchemy.orm.attributes.InstrumentedAttribute"), [plugin_mod._plugin_any()]
1111
+ )
1112
+ assert p._sqlalchemy_expr_type_for_class_attr(api, inst_attr) is inst_attr
1113
+
1114
+ queryable = Instance(
1115
+ make_typeinfo("sqlalchemy.orm.attributes.QueryableAttribute"), [plugin_mod._plugin_any()]
1116
+ )
1117
+ assert p._sqlalchemy_expr_type_for_class_attr(api, queryable) is queryable
1118
+
1119
+ col = Instance(
1120
+ make_typeinfo("sqlalchemy.sql.elements.ColumnElement"), [plugin_mod._plugin_any()]
1121
+ )
1122
+ assert p._sqlalchemy_expr_type_for_class_attr(api, col) is col
1123
+
1124
+
1125
+ def test_sqlalchemy_expr_type_for_class_attr_falls_back_to_column_element() -> None:
1126
+ p = plugin_mod.SQLModelMypyPlugin(Options())
1127
+
1128
+ class NoInstrumentedAPI(DummyCheckerAPI):
1129
+ def named_generic_type(self, name: str, args: list[Type]) -> Instance:
1130
+ if name in {
1131
+ "sqlalchemy.orm.attributes.InstrumentedAttribute",
1132
+ "sqlalchemy.orm.InstrumentedAttribute",
1133
+ }:
1134
+ raise RuntimeError("missing")
1135
+ return super().named_generic_type(name, args)
1136
+
1137
+ api = NoInstrumentedAPI()
1138
+ int_t = Instance(make_typeinfo("builtins.int"), [])
1139
+ t = p._sqlalchemy_expr_type_for_class_attr(api, int_t)
1140
+ assert isinstance(t, Instance)
1141
+ assert t.type.fullname == "sqlalchemy.sql.elements.ColumnElement"
1142
+
1143
+
1144
+ def test_getattr_return_type_callback_unions_default_value() -> None:
1145
+ p = plugin_mod.SQLModelMypyPlugin(Options())
1146
+ api = DummyCheckerAPI()
1147
+
1148
+ model_info = make_sqlmodel_class("m.User")
1149
+ model_info.defn.keywords["table"] = NameExpr("True")
1150
+ model_info.metadata[plugin_mod.METADATA_KEY] = {"fields": {"id": {}}, "relationships": {}}
1151
+ model_info.names["id"] = SymbolTableNode(
1152
+ 0, Var("id", Instance(make_typeinfo("builtins.int"), []))
1153
+ )
1154
+
1155
+ user_type = TypeType(Instance(model_info, []))
1156
+ ctx = FunctionContext(
1157
+ arg_types=[[user_type], [Instance(make_typeinfo("builtins.str"), [])], [NoneType()]],
1158
+ arg_kinds=[[ARG_POS], [ARG_POS], [ARG_OPT]],
1159
+ callee_arg_names=["object", "name", "default"],
1160
+ arg_names=[[None], [None], [None]],
1161
+ default_return_type=AnyType(TypeOfAny.explicit),
1162
+ args=[[NameExpr("User")], [StrExpr("id")], [NameExpr("None")]],
1163
+ context=NameExpr("x"),
1164
+ api=api,
1165
+ )
1166
+ t = p._sqlmodel_getattr_return_type_callback(ctx)
1167
+ assert isinstance(get_proper_type(t), UnionType)
1168
+
1169
+
1170
+ def test_column_property_return_type_callback_guard_and_value_type_none_paths() -> None:
1171
+ p = plugin_mod.SQLModelMypyPlugin(Options())
1172
+ api = DummyCheckerAPI()
1173
+
1174
+ # No args -> no-op.
1175
+ ctx = FunctionContext(
1176
+ arg_types=[],
1177
+ arg_kinds=[],
1178
+ callee_arg_names=[],
1179
+ arg_names=[],
1180
+ default_return_type=AnyType(TypeOfAny.explicit),
1181
+ args=[],
1182
+ context=NameExpr("x"),
1183
+ api=api,
1184
+ )
1185
+ assert isinstance(p._sqlalchemy_column_property_return_type_callback(ctx), AnyType)
1186
+
1187
+ # Unknown arg type -> value_type stays None -> no-op.
1188
+ ctx = FunctionContext(
1189
+ arg_types=[[Instance(make_typeinfo("builtins.int"), [])]],
1190
+ arg_kinds=[[ARG_OPT]],
1191
+ callee_arg_names=["column"],
1192
+ arg_names=[[None]],
1193
+ default_return_type=AnyType(TypeOfAny.explicit),
1194
+ args=[[NameExpr("x")]],
1195
+ context=NameExpr("x"),
1196
+ api=api,
1197
+ )
1198
+ assert isinstance(p._sqlalchemy_column_property_return_type_callback(ctx), AnyType)
1199
+
1200
+
908
1201
  def test_get_function_signature_hook_returns_none_for_missing_symbol() -> None:
909
1202
  p = plugin_mod.SQLModelMypyPlugin(Options())
910
1203
  p.lookup_fully_qualified = lambda _full: None # type: ignore[method-assign]