Fix QueryBuilder generic typing and annotate Model/QueryBuilder params - #260
Conversation
Parameterize every unannotated columns/values/dict/list parameter on the public Model and QueryBuilder API (first, first_or_fail, find, find_or_fail, get, get_models, where_in, where_not_in, update, create, insert, fill, etc.). The systemic QueryBuilder[Unknown] issue was caused by bare, unparameterized "QueryBuilder" return annotations overriding pyright's body-inferred type. Replace them with QueryBuilder[Self] (Model classmethods) and Self (QueryBuilder instance methods) so chained calls like User.where(...).first(), User.find(1), and User.where_in(...) resolve to QueryBuilder[User] / User | None instead of Unknown. Also tighten a few internal QueryBuilder annotations (_columns, where_in's duck-typed Collection unwrap, select()'s *args) that the wider parameter types would otherwise leave inconsistent, and add a missing columns normalization guard in get_models() matching the existing pattern in first()/get(). No runtime behavior changes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
tmgbedu
left a comment
There was a problem hiding this comment.
Verdict: REQUEST CHANGES. GitHub doesn't allow a formal request-changes review on your own PR, so I'm posting this as a comment.
Blocking
fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py:25: the newfrom ...query.grammars.BaseGrammar import BaseGrammarunderTYPE_CHECKINGis never used, so Ruff reports F401 and the Ruff CI check fails. Remove the import, or use it (e.g.grammar: "type[BaseGrammar]").
Non-blocking notes (verified safe)
The diff is not strictly annotation-only. There are three small runtime edits, and each one keeps the old behavior:
where_in:values._items→getattr(values, "_items"). Same behavior.get_models: newif not columns: columns = []guard. The only caller (get()) already normalizes the value. Callingget_models()directly with no argument used to crash onNone.split, and the guard fixes that.new_from_builder:new_model_instance([], ...)→{}.set_raw_attributesoverwrites it right after.
Verified
- I ran basedpyright on a scratch file with the reportUnknown* rules set to error: 0 diagnostics.
reveal_typegives concrete types:QueryBuilder[User]forwhere,where(lambda q: ...),where_inand chains including.when;User | Noneforfirst/find;Userforfind_or_fail/first_or_fail/create;Collection[User]forget();boolforupdate. Selfcomes fromtyping, which is fine for Python >=3.12.- Project basedpyright: 0 errors. Relationships and scopes still type-check.
- pytest (without postgres): 2482 passed, 6 skipped.
grammar/processor on QueryBuilder.__init__ are kept as Any: the base Connection.get_query_grammar()/get_post_processor() type as type[BaseGrammar] | None (only concrete in subclass overrides), and threading that Optional through reintroduces reportOptionalCall on get_grammar()/insert_get_id()/update() plus unrelated cascading diagnostics on _bindings/_limit/_offset with no net typing benefit. Any keeps the diagnostics at the confirmed 0-error baseline, so the now-unused BaseGrammar import is dropped instead of forcing it into use. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
tmgbedu
left a comment
There was a problem hiding this comment.
Verdict: APPROVE. GitHub doesn't allow the PR author to approve their own PR, so I'm posting this as a comment.
Re-review of the new commit: the only change is removing the unused BaseGrammar import from TYPE_CHECKING in builder.py, so the blocker is fixed. Keeping grammar/processor typed as Any is fine, since typing them as type[BaseGrammar] | None would set off reportOptionalCall errors elsewhere.
Checked locally: ruff check src/ passes and project basedpyright reports 0 errors. CI is all green: Ruff, Basedpyright, Pytest and codecov/patch. The results from my earlier review still stand: strict basedpyright gives concrete types with 0 diagnostics, and 2482 tests pass. The three small code changes are now listed in the PR description and don't change behavior.
Summary
Resolves the basedpyright typing gaps reported on the ORM's public Model/QueryBuilder API (task #2139, expanded through several follow-up reports):
first()/first_or_fail()/find()/find_or_fail()/get()/get_models()had an untypedcolumnsparameter (Unknown | None). Nowlist[str] | str | None.where_in()/where_not_in()had an untypedvaluesparameter. NowIterable[Any].where()(and every other chainable method) returned a bare, unparameterizedQueryBuilder, which overrides pyright's body inference and forcesQueryBuilder[Unknown]on every call site, including the nested-closurewhere(lambda q: ...)overload. Fixed by returningQueryBuilder[Self]from Model classmethods andSelffrom QueryBuilder instance methods, soUser.where(...),User.where(...).first(),User.find(1), andUser.where_in(...)now resolve toQueryBuilder[User]/User | Noneinstead ofUnknown.update()/create()/fill()/ dict-basedwhere()overload had baredict/listannotations producingUnknowntype args. Replaced with parameterizeddict[str, Any]/list[dict[str, Any]]._columns,where_in's duck-typedCollectionunwrap,select()'s*args) needed narrower/consistent typing to keep the above changes from surfacing new pyright errors, plus acolumnsnormalization guard added toget_models()matching the existing pattern already used infirst()/get().No runtime behavior changes — this PR is typing-only. Three small edits are worth calling out explicitly since they touch code paths, not just annotations:
where_in(): replacedvalues._itemswithgetattr(values, "_items")for the duck-typedCollectionunwrap. Same attribute access at runtime (the precedinghasattrcheck already guarantees it exists) — the rewrite exists only so basedpyright doesn't flag a static attribute-access error on theIterable[Any]parameter type.get_models(): addedif not columns: columns = []before callingselect(columns), mirroring the identical guard already infirst()/get().get_models()has exactly one caller (get()), which already normalizescolumnsbefore calling it, so this is a no-op at every real call site — it only makes the (previously implicit) invariant explicit enough for the type checker to see it.new_from_builder(): changedself.new_model_instance([], exists=True)toself.new_model_instance({}, exists=True). The[]is immediately overwritten byset_raw_attributes()on the next line before it's ever read, so this has no observable effect — it just matches the parameter's actualdict[str, Any] | Noneshape.Verification
Before (pre-existing baseline, confirmed via
git stash):uv run basedpyright→0 errors, 0 warnings, 0 notes.After this change, full project:
uv run basedpyright→0 errors, 0 warnings, 0 notes(same clean baseline, no regressions).uv run ruff check→ all checks passed (fixes the F401 unused-import flag from review — theBaseGrammarimport was dropped rather than forced into use ongrammar/processor, since typing those astype[BaseGrammar] | Noneto matchConnection.get_query_grammar()'s base signature reintroducesreportOptionalCallonget_grammar()/insert_get_id()/update()plus unrelated cascading diagnostics on_bindings/_limit/_offset, for no net typing benefit —Anykeeps bothgrammarandprocessorat the confirmed 0-error baseline).Scratch verification file exercising the exact reported call sites, checked against both the project's default config and a stricter config with
reportUnknownMemberType/reportUnknownParameterType/reportUnknownArgumentType/reportUnknownVariableType/reportMissingParameterTypeenabled — zero diagnostics in both:Full test suite:
uv run pytest --ignore=tests/masoniteorm/postgres -q→2482 passed, 6 skipped, 10 deselected, 95 subtests passed.Test plan
uv run basedpyrighton the full project — 0 errors/warnings/notesuv run ruff checkon the full project — all checks passedfirst,find,where,where_in,update,create— 0 unknown-type diagnosticsuv run pytest --ignore=tests/masoniteorm/postgres— all passing, no regressions🤖 Generated with Claude Code