Skip to content

Fix QueryBuilder generic typing and annotate Model/QueryBuilder params - #260

Merged
tmgbedu merged 2 commits into
mainfrom
task/first-columns-typing-2139
Sep 27, 2026
Merged

tmgbedu merged 2 commits into
mainfrom
task/first-columns-typing-2139

Conversation

@tmgbedu

@tmgbedu tmgbedu commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

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 untyped columns parameter (Unknown | None). Now list[str] | str | None.
  • where_in() / where_not_in() had an untyped values parameter. Now Iterable[Any].
  • where() (and every other chainable method) returned a bare, unparameterized QueryBuilder, which overrides pyright's body inference and forces QueryBuilder[Unknown] on every call site, including the nested-closure where(lambda q: ...) overload. Fixed by returning QueryBuilder[Self] from Model classmethods and Self from QueryBuilder instance methods, so User.where(...), User.where(...).first(), User.find(1), and User.where_in(...) now resolve to QueryBuilder[User] / User | None instead of Unknown.
  • update() / create() / fill() / dict-based where() overload had bare dict/list annotations producing Unknown type args. Replaced with parameterized dict[str, Any] / list[dict[str, Any]].
  • A few internal QueryBuilder details (_columns, where_in's duck-typed Collection unwrap, select()'s *args) needed narrower/consistent typing to keep the above changes from surfacing new pyright errors, plus a columns normalization guard added to get_models() matching the existing pattern already used in first()/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(): replaced values._items with getattr(values, "_items") for the duck-typed Collection unwrap. Same attribute access at runtime (the preceding hasattr check already guarantees it exists) — the rewrite exists only so basedpyright doesn't flag a static attribute-access error on the Iterable[Any] parameter type.
  • get_models(): added if not columns: columns = [] before calling select(columns), mirroring the identical guard already in first()/get(). get_models() has exactly one caller (get()), which already normalizes columns before 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(): changed self.new_model_instance([], exists=True) to self.new_model_instance({}, exists=True). The [] is immediately overwritten by set_raw_attributes() on the next line before it's ever read, so this has no observable effect — it just matches the parameter's actual dict[str, Any] | None shape.

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 — the BaseGrammar import was dropped rather than forced into use on grammar/processor, since typing those as type[BaseGrammar] | None to match Connection.get_query_grammar()'s base signature reintroduces reportOptionalCall on get_grammar()/insert_get_id()/update() plus unrelated cascading diagnostics on _bindings/_limit/_offset, for no net typing benefit — Any keeps both grammar and processor at 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/reportMissingParameterType enabled — zero diagnostics in both:

Type of "await User.where("name", "x").first()" is "User | None"
Type of "await User.find(1)" is "User | None"
Type of "User.where("name", "x")" is "QueryBuilder[User]"
Type of "User.where("name", "=", "x")" is "QueryBuilder[User]"
Type of "User.where({ "name": "x" })" is "QueryBuilder[User]"
Type of "User.where(lambda q: q.where("name", "x"))" is "QueryBuilder[User]"
Type of "User.where_in("name", ["a", "b"])" is "QueryBuilder[User]"
Type of "user" is "User"
Type of "await user.update({ "name": "x" })" is "bool"

Full test suite: uv run pytest --ignore=tests/masoniteorm/postgres -q → 2482 passed, 6 skipped, 10 deselected, 95 subtests passed.

Test plan

  • uv run basedpyright on the full project — 0 errors/warnings/notes
  • uv run ruff check on the full project — all checks passed
  • Strict-config basedpyright pass against a scratch file covering first, find, where, where_in, update, create — 0 unknown-type diagnostics
  • uv run pytest --ignore=tests/masoniteorm/postgres — all passing, no regressions

🤖 Generated with Claude Code

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

codecov Bot commented Sep 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@tmgbedu tmgbedu left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py:25: the new from ...query.grammars.BaseGrammar import BaseGrammar under TYPE_CHECKING is 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: new if not columns: columns = [] guard. The only caller (get()) already normalizes the value. Calling get_models() directly with no argument used to crash on None.split, and the guard fixes that.
  • new_from_builder: new_model_instance([], ...) → {}. set_raw_attributes overwrites it right after.

Verified

  • I ran basedpyright on a scratch file with the reportUnknown* rules set to error: 0 diagnostics. reveal_type gives concrete types: QueryBuilder[User] for where, where(lambda q: ...), where_in and chains including .when; User | None for first/find; User for find_or_fail/first_or_fail/create; Collection[User] for get(); bool for update.
  • Self comes from typing, 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 tmgbedu left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@tmgbedu
tmgbedu merged commit 7a6391d into main Sep 27, 2026
6 checks passed
@tmgbedu
tmgbedu deleted the task/first-columns-typing-2139 branch September 27, 2026 19:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant