Skip to content

Commit de9fd4e

Browse files
committed
Infer closure param type for whereHas/whereDoesntHave relation chain
1 parent 085b94c commit de9fd4e

15 files changed

Lines changed: 1335 additions & 183 deletions

File tree

docs/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **`whereHas` / `whereDoesntHave` closure parameter from relation traversal.** The closure parameter in `whereHas`, `orWhereHas`, `whereDoesntHave`, `orWhereDoesntHave`, `withWhereHas`, and related methods is now typed as `Builder<RelatedModel>` instead of `Builder<CallingModel>`. The relation name string (first argument) is resolved by walking the model's relationship methods. Dot-notation chains like `whereHas('category.articles', fn($q) => ...)` follow each segment to the final related model. Works for static calls (`Brand::whereHas(...)`), builder instance calls (`$query->whereHas(...)`), and body-inferred relationships (no `@return` annotation). Falls back to the calling model's builder when the relation cannot be resolved.
1213
- **Eloquent `where{PropertyName}()` dynamic methods.** Calls like `User::whereBrandId(42)` and `$query->whereLangCode('en')` now resolve instead of producing `unknown_member` diagnostics. For each known column on an Eloquent model (from `$casts`, `$attributes`, `$fillable`/`$guarded`/`$hidden`/`$appends`, `$dates`, timestamps, and `@property` annotations), a virtual `where{StudlyCase}()` method is synthesized on both the model (as a static method) and the Builder (as an instance method). Each method accepts a `mixed` value parameter and returns `Builder<ConcreteModel>`, so chains like `Bakery::whereFlour('rye')->whereApricot(true)->get()` resolve end-to-end.
1314
- **Eloquent timestamp properties.** Models extending `Illuminate\Database\Eloquent\Model` now automatically get `created_at` and `updated_at` virtual properties typed as `Carbon\Carbon`. Setting `$timestamps = false` suppresses both. Overriding `CREATED_AT` or `UPDATED_AT` constants changes the column name; setting either to `null` disables that column. Explicit `$casts` entries (e.g. `'created_at' => 'immutable_datetime'`) take priority over the timestamp defaults.
1415
- **Eloquent `$dates` array.** Legacy models using `protected $dates = [...]` now get `Carbon\Carbon`-typed virtual properties for each listed column. Explicit `$casts` entries take priority when both define the same column.
@@ -64,6 +65,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6465
- **Diagnostics now work for vendor files open in the editor.** Projects using `--prefer-source` or monorepo setups where libraries are edited from the `vendor/` directory no longer have diagnostics suppressed. Previously all vendor files were unconditionally skipped, hiding syntax errors, deprecation warnings, and all other diagnostics. The `analyze` command also accepts vendor paths as explicit overrides (e.g. `phpantom_lsp analyze vendor/some-package/src/`).
6566
- **Full-line diagnostic suppression no longer requires a code mapping.** Full-line diagnostics (from tools like PHPStan that only report line numbers) are now suppressed whenever any precise diagnostic exists on the same line, regardless of error codes. Previously suppression relied on a hand-maintained mapping between error codes from different tools, which was incomplete and allowed redundant full-line underlines to obscure the precise location of the issue.
6667

68+
- **Hover on closure parameters lost inferred generic arguments.** When a closure parameter had an explicit bare class type hint (e.g. `function (Builder $q)`) and the callable signature inferred the same class with generic arguments (e.g. `Builder<Article>`), hover displayed the bare class name instead of the generic version. The inferred type string is now preserved through the resolution pipeline so that hover, completion, and diagnostics all see the same type.
69+
- **Hover on variables assigned from chained methods lost generic parameters.** When a method returned `static`, `$this`, or `self` and the receiver carried generic parameters (e.g. `Builder<Article>`), hover on a variable assigned from the chain showed the bare class name (`Builder`) instead of the generic version. The receiver's type string is now carried through method call resolution so that self-referencing return types preserve generic parameters across chains.
70+
- **Hover on the `$` sign of a variable at its assignment site showed no type.** Hovering on the `$` of `$order = new Order()` displayed a bare `$order` with no type information, while hovering one character to the right on `o` worked correctly. The variable's byte offset coincided with the assignment statement's start offset, causing the resolver to skip the assignment entirely. The cursor offset is now nudged past the statement boundary so the assignment is included.
6771
- **Method chains through `__call` no longer lose the return type.** When a class defines `__call` and an unrecognized method is called (e.g. dynamic `whereColumn()` on an Eloquent Builder), the return type of `__call` is now used as a fallback. If `__call` returns `$this`, `static`, or `self`, the chain type is preserved so subsequent calls continue resolving. Previously the type was lost at the first dynamic call, breaking every link after it.
6872
- **Null narrowing from `!== null` checks in conditions.** When a null-initialized variable was guarded by `$var !== null` in an `if` or `while` condition, the variable still showed `null` in its type inside the condition's `&&` operands and inside the then-body. The `!== null` check (and `!is_null()`, bare truthy guards) now narrows away `null` both for subsequent `&&` operands and inside the corresponding body block. Chained conditions like `$a !== null && $b !== null && $a->method()` narrow all checked variables. Conditions wrapped inside ternary expressions and return statements are also handled.
6973
- **Variables assigned inside `if`/`while` conditions now resolve in the body.** `if ($admin = AdminUser::first())` and `while ($row = nextRow())` now register the assignment so the variable has a type inside the loop or branch body. Assignments wrapped in comparisons like `if (($conn = getConn()) !== null)` are also recognized.

docs/todo.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ within the same impact tier.
2424
| # | Item | Impact | Effort |
2525
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | ------ |
2626
| B6 | [Scope methods not found on Builder in analyzer chains](todo/bugs.md#b6-scope-methods-not-found-on-builder-in-analyzer-chains) | High | Medium |
27-
| L11 | [`whereHas` closure parameter type from relation traversal](todo/laravel.md#l11-wherehas--wheredoesnthave-closure-parameter-type-from-relation-traversal) | Medium | Medium |
2827
| B7 | [PHPDoc `@param` generic array type not merged with native `array` hint](todo/bugs.md#b7-phpdoc-param-generic-array-type-not-merged-with-native-array-hint) | Low | Medium |
2928
| B9 | [Eloquent relationship property lookup is case-sensitive](todo/bugs.md#b9-eloquent-relationship-property-lookup-is-case-sensitive) | Low | Low |
3029
| H10 | [`return.unusedType` — remove unused type from return union](todo/phpstan-actions.md#h10-returnunusedtype--remove-unused-type-from-return-union) | Medium | Medium |

docs/todo/bugs.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,5 @@ properties on Eloquent models.
9898
**Impact:** 1 direct diagnostic (`FlowService:477`) plus 1 cascading
9999
(`FlowService:517` — compound with `Collection::reduce()` type loss).
100100

101+
102+

docs/todo/laravel.md

Lines changed: 0 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -428,122 +428,6 @@ hard-coding the two known classes. A simpler approach: add
428428
`@method` tags to bundled stubs for the most common dynamic `with*`
429429
methods, or document this as a known limitation.
430430

431-
#### L11. `whereHas` / `whereDoesntHave` closure parameter type from relation traversal
432431

433-
**Impact: Medium · Effort: Medium**
434-
435-
When a closure is passed to `whereHas`, `orWhereHas`,
436-
`whereDoesntHave`, `orWhereDoesntHave`, `withWhereHas`, or `has`
437-
(with a callback), the closure parameter `$query` should be typed as
438-
`Builder<TRelatedModel>` where `TRelatedModel` is the model at the
439-
end of the relation chain.
440-
441-
Simple case (already works via builder forwarding and callable param
442-
inference):
443-
444-
```php
445-
Brand::whereHas('orders', function ($q) {
446-
$q-> // $q is Builder<Brand> — TModel substituted by builder forwarding
447-
});
448-
```
449-
450-
This works because the scaffolding `Builder::whereHas` has
451-
`@param (\Closure(Builder<TModel>): mixed)|null $callback`, and
452-
builder forwarding substitutes `TModel` with the concrete model. The
453-
result is `Builder<Brand>` — correct for the receiver model but not
454-
for the related model.
455-
456-
Dot-notation case (does not work):
457-
458-
```php
459-
ArticleCategoryTranslation::whereHas('category.articles', function ($q) {
460-
$q-> // should be Builder<Article>, currently Builder<ArticleCategoryTranslation>
461-
});
462-
```
463-
464-
To resolve this properly, PHPantom needs to:
465-
466-
1. Detect that `whereHas` (and related methods) is being called on a
467-
model or builder.
468-
2. Extract the first argument (the relation name string).
469-
3. Walk the dot-separated relation chain: start from the calling
470-
model, look up each segment as a relationship method, extract the
471-
`TRelatedModel` from the relationship return type's generic args.
472-
4. Use the final related model as the `TRelatedModel` for the
473-
closure parameter.
474-
475-
The real Laravel `QueriesRelationships` trait declares `whereHas` as:
476-
477-
```php
478-
/**
479-
* @template TRelatedModel of \Illuminate\Database\Eloquent\Model
480-
* @param Relation<TRelatedModel, *, *>|string $relation
481-
* @param (\Closure(Builder<TRelatedModel>): mixed)|null $callback
482-
* @return $this
483-
*/
484-
public function whereHas($relation, ?Closure $callback = null, ...);
485-
```
486-
487-
But `TRelatedModel` cannot be inferred from the string argument via
488-
normal template binding because there is no `::class` reference — it
489-
requires semantic knowledge of the model's relationship methods.
490-
491-
### Implementation approach
492-
493-
Add a Laravel-specific hook in `infer_callable_params_from_receiver`
494-
(or in `find_callable_params_on_method`) that intercepts calls to the
495-
known relation-existence methods. When the method is one of
496-
`whereHas`, `orWhereHas`, `whereDoesntHave`, `orWhereDoesntHave`,
497-
`withWhereHas`, or `has`:
498-
499-
1. Resolve the receiver to a model class (or a `Builder<Model>`).
500-
2. Extract the string literal from the first argument.
501-
3. Call a new helper `resolve_relation_chain(model, "category.articles")`
502-
that splits on `.` and follows each segment:
503-
- Find the method on the model.
504-
- Extract the `TRelated` type from its relationship return type
505-
(using the existing `extract_related_type` from
506-
`relationships.rs`).
507-
- Load that class and continue with the next segment.
508-
4. Return `Builder<FinalRelatedModel>` as the inferred closure
509-
parameter type, overriding the default callable param inference.
510-
511-
The `resolve_relation_chain` helper can reuse
512-
`classify_relationship` and `extract_related_type` from
513-
`src/virtual_members/laravel/relationships.rs`, plus
514-
`infer_relationship_from_body` as a fallback when no `@return`
515-
annotation is present.
516-
517-
### Affected methods
518-
519-
All methods from the `QueriesRelationships` trait that accept a
520-
relation name and a closure callback:
521-
522-
| Method | Callback arg position |
523-
|--------|-----------------------|
524-
| `has` | 1 (optional) |
525-
| `orHas` | 1 (optional) |
526-
| `doesntHave` | 1 (optional) |
527-
| `orDoesntHave` | 1 (optional) |
528-
| `whereHas` | 1 |
529-
| `orWhereHas` | 1 |
530-
| `withWhereHas` | 1 |
531-
| `whereDoesntHave` | 1 |
532-
| `orWhereDoesntHave` | 1 |
533-
| `whereRelation` | 1 (Builder in 2nd arg) |
534-
535-
### Where to change
536-
537-
- `src/virtual_members/laravel/relationships.rs` — add
538-
`resolve_relation_chain(model, chain_str, class_loader)` helper.
539-
- `src/completion/variable/closure_resolution.rs` — in
540-
`infer_callable_params_from_receiver` or
541-
`find_callable_params_on_method`, add a check: if the method is one
542-
of the known relation-existence methods and the receiver extends
543-
`Model` (or is `Builder<Model>`), run `resolve_relation_chain` and
544-
return `Builder<FinalModel>` instead of the raw callable param types.
545-
- Tests: `tests/integration/completion_laravel.rs` — test simple
546-
relation, dot-notation chain, body-inferred relations, and missing
547-
relation (should fall back to `Builder<Model>`).
548432

549433

example.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1783,9 +1783,16 @@ public function demo(): void
17831783
}
17841784
});
17851785

1786-
// Eloquent whereHas — $query inferred as Builder
1786+
// Eloquent whereHas — $query inferred as Builder<BlogPost> (the related model)
17871787
BlogAuthor::whereHas('posts', function ($query) {
1788-
$query->where('published', true); // resolves to Builder
1788+
$query->where('published', true); // resolves to Builder<BlogPost>
1789+
});
1790+
1791+
// Dot-notation relation chain: category.articles resolves through each segment
1792+
// BlogPost::whereHas('author.profile', fn($q) => $q->)
1793+
// => $q is Builder<AuthorProfile>, not Builder<BlogPost>
1794+
BlogPost::whereHas('author', function ($q) {
1795+
$q->where('active', true); // resolves to Builder<BlogAuthor>
17891796
});
17901797

17911798
// $this in callable param resolves to receiver, not current class

0 commit comments

Comments
 (0)