Skip to content

feat(orm): where_column / or_where_column column comparison with operators#168

Open
tmgbedu wants to merge 1 commit into
mainfrom
task/orm-subquery-parity-881
Open

feat(orm): where_column / or_where_column column comparison with operators#168
tmgbedu wants to merge 1 commit into
mainfrom
task/orm-subquery-parity-881

Conversation

@tmgbedu

@tmgbedu tmgbedu commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Scope reduced (per review of the original bundled PR): this PR now contains only the foundational column-comparison primitive. order_by(builder) and select_sub/add_select/table() have been split into their own follow-up PRs so each reviews independently.

Extends where_column() with an optional operator form and adds the OR-joined companion or_where_column():

where_column("first_name", "last_name")          # WHERE first_name = last_name        (2-arg equality)
where_column("updated_at", ">", "created_at")     # WHERE updated_at > created_at        (3-arg operator)
or_where_column("first_name", "last_name")        # ... OR first_name = last_name
or_where_column("updated_at", ">", "created_at")  # ... OR updated_at > created_at

Behavior

  • Arity detection: 2 args → operator defaults to =; 3 args → (col1, operator, col2).
  • Operator validation: one of =, !=, <>, >, >=, <, <=; anything else raises ValueError.
  • Both column identifiers stay identifiers — never bound values. Only literal values from other clauses are parameterized.
  • The 2-arg equality form is 100% backward compatible.
  • Grammar value_equal_string() renders the operator across all four dialects (SQLite, MySQL, Postgres, MSSQL).

Tests

  • tests/masoniteorm/query/grammars/test_where_column_grammar.py — grammar-level SQL assertions for both arities, every operator, and the OR combinations, across all 4 dialects; plus invalid-operator rejection and a binding check (only the literal is bound).
  • tests/masoniteorm/sqlite/builder/test_sqlite_where_column.py — real sqlite-backed filtering for =, >, <, !=, and OR-combined conditions.

Gates

  • uv run pytest --ignore=tests/masoniteorm/postgres --cov → 1814 passed / 7 skipped, coverage 78.81% (≥ fail_under 68).
  • ruff check + ruff format --check clean.
  • Zero regression to existing string-based usage.

Related (follow-ups, review independently)

  • Follow-up A: order_by(builder) correlated-subquery ordering.
  • Follow-up B: select_sub / add_select / table() subquery-as-column.

Recommended merge order: this PR first (foundational primitive), then A and B.

@codecov

codecov Bot commented Jul 12, 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 commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

✅ Approve — full Laravel subquery parity, verified independently

Reviewed the complete diff, traced the surrounding grammar/builder code, ran the gates, and probed the critical paths adversarially. This is clean, well-tested work.

Verified

  • Binding ORDER correctness (the critical concern). In _compile_select, Python evaluates the .format() kwargs left-to-right: columnswheresorder_by. So SELECT-subquery bindings are appended before WHERE bindings before ORDER-BY-subquery bindings. Confirmed with a combined query carrying a binding in all three positions at once — bindings came out [SELECT-sub, WHERE, ORDER-sub] in exactly that order.
  • Injection safety. In the qmark path every subquery compiles via to_qmark() (parameterized ?) and propagates bindings through add_binding(). Probed with a x'; DROP TABLE posts;-- value in the WHERE clause — the literal never appears in the SQL, it stays a bound parameter. No raw value interpolation introduced.
  • Per-grammar SQL incl. MSSQL TOP n. Grammar tests assert exact to_sql()/to_qmark() output for all 4 dialects (quoting + LIMIT/TOP differences explicit). MSSQL correctly renders (SELECT TOP 1 ...) for the select_sub + limit(1) case.
  • End-to-end order_by(subquery).paginate() on real sqlite. Categories deliberately seeded out of id order; correlated-name ordering + paginate meta (total/last_page/next/previous/data) all assert correctly across both pages.
  • where_column: 3-arg operator form works, invalid operator raises ValueError, 2-arg equality form is byte-for-byte unchanged. Backward-compatible with all existing relationship call sites (HasOne/HasMany/BelongsTo/*Through/BelongsToMany), which pass 2 positional args.
  • select_sub/add_select incl. the callable form; SELECT-position bindings correct. Note: add_select/select_sub/table did not exist on base but were already referenced by the *Through/BelongsToMany with-count paths — this PR makes those dangling references resolvable (a latent fix), and the new signatures match those call sites.
  • Zero regression / gates. uv run pytest --ignore=tests/masoniteorm/postgres --cov1816 passed / 7 skipped, 78.99% (matches report exactly). ruff check + ruff format --check clean.
  • Test quality (PR Coverage: masoniteorm relationships (BelongsToMany, Morph*) #159 bar). Real sqlite-backed behavioral suite + grammar-level exact-SQL assertions across 4 dialects + explicit binding-order assertions. connection=None is used only in the pure to_sql()/to_qmark() grammar tests where no DB is needed — behavioral tests use a real connection. No hollow mocks.

Minor, non-blocking (optional follow-ups)

  1. (subquery) AS {alias} inserts the alias unquoted/unescaped — consistent with the existing SubGroupExpression handling in where(), and aliases are developer-supplied identifiers, so acceptable. Could quote it for defense-in-depth later.
  2. The now-activated HasManyThrough/BelongsToMany with-count paths would benefit from a dedicated test in a future task, since this PR makes them executable for the first time. Out of this PR's stated scope.

Neither blocks merge. Approving.

Extend where_column() with an optional 3-arg operator form and add the
OR-joined or_where_column() companion:

- where_column(a, b)            -> a = b        (2-arg equality, unchanged)
- where_column(a, op, b)        -> a op b       (op in =, !=, <>, >, >=, <, <=)
- or_where_column(...)          -> same forms, joined with OR

Both column identifiers stay identifiers (never bound values); an invalid
operator raises ValueError. Grammar value_equal_string() now renders the
operator across all four dialects (SQLite, MySQL, Postgres, MSSQL).

Covered by grammar-level SQL assertions for both arities and the OR
combinations across all four grammars, plus a real sqlite-backed suite.
@tmgbedu tmgbedu force-pushed the task/orm-subquery-parity-881 branch from b48a02c to b84c971 Compare July 12, 2026 06:26
@tmgbedu tmgbedu changed the title feat(orm): Laravel-style subquery parity — order_by(builder), where_column operators, select_sub/add_select feat(orm): where_column / or_where_column column comparison with operators Jul 12, 2026
@tmgbedu

tmgbedu commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

✅ Approve (reshaped scope) — where_column / or_where_column column comparison

Re-reviewed the force-pushed PR (now scoped to column-comparison primitives only). Read the full diff, ran the gates, and probed adversarially. Clean and correct.

Verified

  • Both arities, both methods, all 4 dialects. Grammar tests assert exact to_sql() for 2-arg (WHERE a = b) and 3-arg (WHERE a <op> b) across sqlite/mysql/postgres/mssql, for every operator =,!=,<>,>,>=,<,<=. or_where_column mirrors with OR joining (... OR a > b) — confirmed against a preceding where().
  • Columns are identifiers, never bound. to_qmark() on a where_column query yields zero bindings; only real literal values (e.g. the where("active",1)) are parameterized. Verified directly.
  • Operator validation is real. Invalid/typo/injection operators (BAD, LIKE, ==, =>, = 1 OR 1=1 --) all raise ValueError at build time for both methods.
  • 2-arg byte-compatible with existing callers. New signature where_column(c1, operator, c2=None) resolves a 2-arg call to ("=", c2), producing QueryExpression(c1, "=", c2, "value_equals") — identical to the old hardcoded =. All relationship callers (HasOne/HasMany/BelongsTo/*Through/BelongsToMany, model.where_column) pass 2 positional args and are unaffected; full suite green.
  • Scope descoped cleanly. Diff contains no order_by(builder)/select_sub/add_select/table/OrderByExpression/SubGroupExpression changes (the only order_by hits are pre-existing .order_by("id") used for deterministic test ordering).
  • Gates reproduced from fastapi_startkit/: pytest --ignore=postgres --cov1814 passed / 7 skipped, 78.81%; ruff check + ruff format --check clean. Matches the report.
  • Test quality: grammar-level exact-SQL across 4 dialects + real sqlite behavioral suite (rows crafted so =, >, <, !=, and OR-joining each select a distinct known id set) + binding assertions. Solid.

Maintainer question — is _normalize_where_column needed, or over-engineered?

Recommendation: keep as-is. Reasoning:

  • (a) Arity handling is correct, and not a footgun in practice. The 2-arg branch returning ("=", operator) is right: in the 2-arg form the second positional is the column being compared, so promoting it to column2 with a default = operator is exactly the intended semantics (where_column('first_name','last_name')first_name = last_name). Keying on column2 is None is a safe sentinel because a real column is never None. The only degenerate case — an explicit where_column('a','=',None) producing WHERE a = = — requires deliberately passing None as the third arg, which no real caller does; it mirrors Laravel's own whereColumn arity behavior.
  • (b) The helper is justified by DRY, not ceremony. It is shared by two public methods (where_column and or_where_column) with identical normalize+validate logic. Inlining would duplicate ~6 lines across both. If there were a single method I'd say inline it — but with two call sites, the extraction is the right call.
  • (c) The validation adds real safety. The operator is interpolated directly into SQL (it is not a bound parameter). Without validation, a typo like '=>' yields a silent SQL syntax error surfacing as a confusing runtime 500, and a crafted operator ('= 1 OR 1=1 --') is an injection vector. The check converts both into a clear build-time ValueError naming the allowed set. That is genuine defense, not ceremony.

So: keep the helper and the validation as written. Not over-engineered.

Approving. (Posting as a comment because the CLI is authenticated as the PR author and GitHub blocks a formal self---approve; content is an approval.)

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