Column not found error - #215
Conversation
The chain walker treated a relationship method call
(`User::query()->competitions()->whereIn('type', …)`) as a no-op, so
column validation kept using the parent model's table. Columns valid on
the related table but absent from the parent's (e.g. `type` on
`competitions`, not on `users`) were false-flagged "Column not found".
In `EloquentBuilder` mode, an unrecognised method call (not in any known
builder-method table) is now treated as a candidate relationship hop:
its name is queued on `ChainContext::pending_relation_hops` and the async
finalize step (diagnostics, completion, goto) walks it via
`resolve_related_model`, advancing `effective_model` to the related
class. A name that isn't a relationship resolves to `None` and is
skipped — the existing `ChainEffect::None` fallback, with no regression
on unrecognised calls.
Also handles the property-access receiver form
(`$user->competitions->where('type', …)`): `member_chain_receiver` now
recognises a `member_access_expression` base, resolves the base
variable's type synchronously, and emits the new
`EloquentReceiver::RelationProperty` variant, which starts the chain in
`EloquentCollection` mode with the property name seeded as the first
pending hop (the related FQCN needs a model-file read, so it's resolved
async like the method-call hops).
Tests: method-call hop validates against the related table and still
flags genuinely-unknown columns; property-access receiver validates and
offers the related collection's columns; walker unit tests cover hop
queueing, mode, and the unresolved-base no-op.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
🔄 Changes Requested
Genuinely strong PR — the fix reuses the existing chain-walker machinery instead of forking a parallel path, the three finalize sites (diagnostics, completion, goto-def) all wire the hop in the same order, and the tests are real (they fail if the fix is reverted). I fanned out four blind lens reviewers over the checkout, then sent every blocker-class finding to an adversarial verifier — three were refuted and dropped (details at the bottom). One survives, and it's in the gate function this PR wrote. CI is green (LSP test/fmt/clippy, wasm, CodeQL); this is a coverage gap CI can't catch.
Issues Found
1. 🔴 is_known_builder_method omits the ELOQUENT_STATIC_STARTERS table — common builder methods are treated as relation-hop candidates (laravel-lsp/src/query_chain/methods.rs:459-473)
AC #1 asks the gate to fire only on "a method call whose name is NOT in any known builder-method table." is_known_builder_method checks 14 tables — but not ELOQUENT_STATIC_STARTERS, which is unmistakably a known builder-method table. The methods that live only there and in no other checked table are:
limit, take, skip, offset, forPage, withTrashed, onlyTrashed, withoutTrashed, inRandomOrder
All of these have chain_effect == ChainEffect::None, so when they appear mid-chain in EloquentBuilder mode, pending_relation_hops_through (cursor.rs:450-454) queues them as relation-hop candidates. Each then triggers a spurious resolve_related_model filesystem lookup in apply_relation_method_hops. Trace for an utterly ordinary chain:
User::query()->withTrashed()->limit(10)->competitions()->where('type', $t)→ withTrashed and limit are both queued as hops → each does a wasted model-file resolution that returns None → skipped. The result stays correct (skip-on-miss saves it), but the gate is doing real work on the hot completion/diagnostics path for methods it should have recognised outright. Your own comment at eloquent_completion.rs:752 literally names ->limit() as the false-positive scenario you're leaning on skip-on-miss to absorb — which is the tell that the gate, not the fallback, is the right place to handle it. There's also a latent correctness hazard: a model defining a relationship method colliding with one of these names would be silently mis-hopped.
This blocks because it's an actionable gap in the gate function this PR introduced, and it makes the gate unfaithful to AC #1's "not in any known builder-method table."
Fix: add the table to the gate —
pub fn is_known_builder_method(name: &str) -> bool {
COLUMN_METHODS.contains(&name)
// … the existing 13 …
|| TRANSPARENT.contains(&name)
|| ELOQUENT_STATIC_STARTERS.contains(&name) // ← add
}Adding it breaks nothing — none of limit/take/skip/offset/forPage/withTrashed/onlyTrashed/withoutTrashed/inRandomOrder can legitimately be a relation-hop candidate. Please add a cursor-level negative test mirroring unknown_relation_method_call_queues_a_pending_hop: assert that User::query()->withTrashed()->limit(10)->competitions() queues pending_relation_hops == ["competitions"] and not ["withTrashed", "limit", "competitions"]. It would fail today.
(Note for accuracy: orderBy, latest, oldest, groupBy, having, select, addSelect are already covered via COLUMN_METHODS, and with/withCount via RELATION_METHODS — they are not part of this gap. The gap is exactly the nine methods listed above.)
What's Good
Verified against the tree, not the doc comments:
- ✅ AC #5's divergence is the right call, not a dropped requirement. The AC's wording (
EloquentReceiver::InstanceVarcarrying the related FQCN, synchronously) is architecturally infeasible —member_access_receiveris a sync fn and resolving relation→model needs theasyncresolve_related_model(aspawn_blockingfile read). The newRelationPropertyvariant seeds the relation as the first pending hop and reaches the identical end state (effective_model = Competition,EloquentCollectionmode), proven byrelation_property_receiver_starts_collection_with_pending_hopandrelation_property_receiver_offers_related_collection_columns. Nothing the criterion cared about is dropped — good engineering, and thank you for flagging it explicitly in the PR body. - ✅ Ordering is correct and consistent across diagnostics (
diagnostics.rs:300), completion (main.rs:3621) and goto-def (main.rs:15126) — closure hop →apply_relation_method_hops→ post-toBase()table resolution. No site missed. - ✅ Skip-on-miss is right —
apply_relation_method_hopsadvancescurrentonly onSome(related), writeseffective_modelonce after the loop, andmem::takes the hops so a second call is idempotent.apply_relation_method_hops_skips_unresolvable_namesproves["query", "competitions"]lands onCompetition. - ✅ The negative-direction test exists where it matters —
relationship_method_hop_still_flags_unknown_column_on_related_tableasserts the diagnostic message namescompetitions, proving validation actually moved tables rather than getting blanket-suppressed.
📋 Non-blocking follow-ups
- Add a symmetric negative diagnostics test for the property-access form —
$user->competitions->where('emial', …)asserting one diagnostic namingcompetitions—laravel-lsp/src/query_chain/diagnostics/tests.rs. Low priority: the sharedfinalize_contextpath is already negatively tested via the method-call form and the completion test proves the model genuinely advances toCompetition, so no regression can slip through — this is belt-and-suspenders symmetry, not a real gap. Since you'll be back in this file for the blocker test, drop it in.
(Watson: you're already in the code for the blocker — implement the follow-up above in this same PR too. No separate issue.)
Please add ELOQUENT_STATIC_STARTERS to the gate (+ the negative test), and re-request review.
`is_known_builder_method` checked 14 tables but omitted `ELOQUENT_STATIC_STARTERS`. Methods living only there — `limit`, `take`, `skip`, `offset`, `forPage`, `withTrashed`, `onlyTrashed`, `withoutTrashed`, `inRandomOrder` — were mis-queued as relation-hop candidates mid-chain, each triggering a wasted `resolve_related_model` filesystem lookup on the hot completion/diagnostics path (skip-on-miss kept the result correct but did the work needlessly), with a latent mis-hop hazard if a relationship method ever collided with one of those names. Add the table to the gate so only genuinely-unknown calls remain hop candidates, faithful to AC #1's "not in any known builder-method table". Tests: - cursor: `eloquent_static_starter_methods_do_not_queue_relation_hops` asserts `User::query()->withTrashed()->limit(10)->competitions()` queues only `["competitions"]`, not the static starters. - diagnostics: `relationship_property_receiver_still_flags_unknown_column_on_related_table` — symmetric negative for the property-access form (Holmes follow-up).
|
Blocker fixed and the non-blocking follow-up implemented in this same PR:
|
There was a problem hiding this comment.
✅ Approved
Round 2, and the blocker from my last pass is closed: is_known_builder_method now includes ELOQUENT_STATIC_STARTERS (methods.rs), so limit/take/withTrashed/… are no longer mis-queued as relation hops — and there's a dedicated test (eloquent_static_starter_methods_do_not_queue_relation_hops) nailing it shut. I re-ran the full fan-out: four blind lens reviewers over the checkout, then every in-PR finding through an adversarial verifier. Two minor in-PR observations surfaced and both were refuted (details below). Nothing survives as a blocker. CI is green (LSP test/fmt/clippy, wasm, CodeQL).
Review Summary
- Reviewed the relationship-hop fix across the chain walker — diagnostics, completion, and goto-def all wire
apply_relation_method_hopsin the same order (closure hop → pending hops → table resolution),effective_modeladvances before the column check so the post-hop validation lands on the related table, and the skip path keeps the model unchanged onresolve_related_model → None. - All 7 acceptance criteria met. One deliberate divergence worth recording: AC #5 anticipated reusing
EloquentReceiver::InstanceVar; Watson instead added a dedicatedEloquentReceiver::RelationPropertyvariant (chain.rs,extractor.rs) that seedseffective_model = base_typeand pushes the relation as the first pending hop withBuilderMode::EloquentCollection(cursor.rs). Same end state, routed through the same hop machinery as the method-call path — cleaner, nothing the criterion cared about dropped. Approved as met-by-better-path. - Tests verified and non-vacuous — they fail if the fix is reverted. Method-call hop:
relationship_method_hop_validates_against_related_table(typevalid only oncompetitions, asserts no diagnostic) with a companion that still flags a typo column on the related table (proves it moved the table rather than blanket-suppressing). Property-access:relationship_property_receiver_validates_against_related_table+relation_property_receiver_offers_related_collection_columns(asserts exactcompetitionscolumns offered). Skip path covered byapply_relation_method_hops_skips_unresolvable_names.
Refuted in-PR observations (transparency)
- "No
MAX_HOPScap on pending hops" → refuted: hop count is bounded by the developer's own authored chain length, further reduced by theis_known_builder_methodfilter and the mode-flip guard; no untrusted input boundary, and the existing walker already iterates all links uncapped. Not a blocker. - "goto-def calls
apply_relation_method_hopsunconditionally" → refuted: the function fast-returns on an empty vec (eloquent_completion.rs:760-762), so the call is a zero-cost no-op; the completion path's guard gates a second lock acquisition that the goto-def path can't reach. Pure no-op, no asymmetry that matters.
📋 Non-blocking follow-ups
..-path-traversal hardening infind_php_class_file_by_fqcn—laravel-lsp/src/class_locator.rs:120-137joins FQCN segments without filtering.., so a crafted::classreference could resolve a read outside the project root. Low practical severity (LSP threat model = the developer's own trusted tree, read-only probe), but it's the next FS-touching resolution surface in the #130→#143→#148→#194→#199→#214path_within_rootcontainment lineage and worth closing for uniformity. Tracking as a new anchor.- End-to-end integration test for the completion-handler wiring — the
main.rscompletion dispatch ofapply_relation_method_hopsis covered only at unit level (the diagnostics path is end-to-end tested). A bad re-wire inmain.rswould slip past the current tests. Coverage-breadth, not a defect. Tracking as a new anchor.
Ready for @mikebronner to merge.
Summary
Implements #211 — the chain walker resolved past a relationship instead of into it, so columns valid on the related table but absent from the parent model's table were false-flagged "Column not found".
User::…->competitions()->whereIn('type', …)validatedtypeagainstusers(where it doesn't exist) instead ofcompetitions(where it does).Changes
EloquentBuildermode, an unrecognised method call (not in any known builder-method table) is queued on the newChainContext::pending_relation_hopsfield. The async finalize step (diagnostics, completion, goto) walks each viaresolve_related_model, advancingeffective_modelto the related class; the mode staysEloquentBuilder. Addedmethods::is_known_builder_methodto gate the check.Noneand is skipped, leavingeffective_modelunchanged (the existingChainEffect::Nonebehaviour) — no regression on unrecognised calls.member_chain_receivernow recognises amember_access_expressionbase ($user->competitions->where(…)), resolves the base variable's type synchronously, and emits the newEloquentReceiver::RelationPropertyvariant, which starts the chain inEloquentCollectionmode with the property name seeded as the first pending hop. (The related FQCN needs a model-file read, so — like the method-call hops — it's resolved in the async finalize step rather than in the synchronous extractor;effective_modelends up as the related class, per the AC's intent.)diagnostics::finalize_context, the completion handler, and goto-definition — each after the closure-relation hop and before the post-toBase()table resolution.Acceptance Criteria
EloquentBuildermode, a method whose name is in no known builder-method table is resolved as a relationship oneffective_modelviaresolve_related_modeleffective_modelupdates to the related model; mode staysEloquentBuilderChainEffect::None) whenresolve_related_modelreturnsNonemember_chain_receiveras amember_access_expression, producing the related model +EloquentCollectionmodeUser::…->competitions()->whereIn('type', …)produces no diagnostic$user->competitions->where('type', …)offers competitions columns and emits no diagnosticImplementation note
AC5 specified
EloquentReceiver::InstanceVarfor the property-access form. The synchronous extractor can't resolve the relation→related-model hop (it needs a model-file read), so I added a dedicatedRelationPropertyvariant that carries the base type + relation name and defers resolution to the async finalize step — reaching the same observable result (related model,EloquentCollectionmode). Flagging the wording deviation for review.Test Plan
cargo test --lib— all query_chain tests pass (incl. 9 new)cargo clippy --lib --tests— cleancargo fmt.env/vendorfixtures absent in a fresh clone) are unrelated to this diff, which is confined toquery_chain+main.rs.Fixes #211