Skip to content

Commit ccf81c7

Browse files
committed
Add support for Eloquent where{PropertyName} dynamic methods
1 parent 12606d9 commit ccf81c7

8 files changed

Lines changed: 1053 additions & 41 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 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+
- **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.
1213
- **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.
1314
- **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.
1415
- **`fix` CLI subcommand.** `phpantom_lsp fix` applies automated code fixes across a project, modeled after php-cs-fixer. Specify rules with `--rule unused_import` (multiple allowed) or omit to run all preferred native fixers. `--dry-run` reports what would change without writing files (exit code 2). `--with-phpstan` enables PHPStan-based fixers (not yet implemented). The first shipped rule, `unused_import`, removes all unused `use` statements project-wide, handling simple imports, group imports, and blank-line collapsing. Supports path filtering (`fix src/`) and single-file mode.

docs/todo.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ within the same impact tier.
2323

2424
| # | Item | Impact | Effort |
2525
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------ |
26-
| L13 | [`where{PropertyName}()` dynamic methods on Builder](todo/laravel.md#l13-wherepropertyname-dynamic-methods-on-builder) | High | Medium |
2726
| H17 | [`missingType.iterableValue` — add `@return` with inferred element type](todo/phpstan-actions.md#h17-missingtype-iterablevalue-return-type--add-return-with-iterable-type) | Medium | High |
2827
| H10 | [`return.unusedType` — remove unused type from return union](todo/phpstan-actions.md#h10-returnunusedtype--remove-unused-type-from-return-union) | Medium | Medium |
2928
| H6 | `return.type` — update return type to match actual returns | Medium | Medium |

docs/todo/laravel.md

Lines changed: 0 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -152,46 +152,6 @@ today and what is still missing.
152152

153153
---
154154

155-
#### L13. `where{PropertyName}()` dynamic methods on Builder
156-
157-
**Impact: High · Effort: Medium**
158-
159-
**Depends on:** All model property gathering gaps being resolved first
160-
(L3, L11, and any others that feed into `column_names` or
161-
`casts_definitions`). The `where{PropertyName}` methods are derived
162-
from the model's known properties, so incomplete property lists produce
163-
incomplete method coverage.
164-
165-
Eloquent's `Builder::__call()` translates calls like
166-
`whereLangCode($value)` into `where('lang_code', $value)` and returns
167-
`$this`. This is a dynamic convention based on the model's column
168-
names: `where` + StudlyCase column name.
169-
170-
PHPantom currently has no knowledge of this pattern. The `__call`
171-
return type preservation (shipped in 0.5.0) keeps chains alive when
172-
`__call` exists, but the analyzer still reports `unknown_member` for
173-
each `where{PropertyName}` call because no actual method by that name
174-
exists on the Builder or Model.
175-
176-
From the luxplus/shared triage, this pattern accounts for ~17
177-
diagnostics (29% of remaining errors): 11 direct `unknown_member`
178-
errors plus ~6 cascading `unresolved_member_access` failures.
179-
180-
Affected examples: `whereBrandId()`, `whereSubcategoryId()`,
181-
`whereLangCode()`, `whereLanguage()`, `whereIsLuxury()`,
182-
`whereIsDerma()`, `whereIsProHairCare()`, `whereIsVisible()`.
183-
184-
**Implementation approach:** In the `LaravelModelProvider` (or a new
185-
virtual-member pass), for each model that extends
186-
`Illuminate\Database\Eloquent\Model`, synthesize `where{StudlyCase}()`
187-
methods on the associated Builder class. Each method should accept one
188-
parameter (the value) and return `$this` (the Builder). The column
189-
names come from all property sources: `$casts`, `$attributes`,
190-
`$fillable`/`$guarded`/`$hidden`/`$visible`, `$dates`, timestamps,
191-
relationship `*_count`, and `@property` annotations.
192-
193-
---
194-
195155
#### L2. `morphedByMany` missing from relationship method map
196156

197157
**Impact: Low-Medium · Effort: Low**

example.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1690,6 +1690,16 @@ public function demo(): void
16901690
$query = BlogAuthor::where('genre', 'fiction');
16911691
$query->active();
16921692
$query->orderBy('name')->get();
1693+
1694+
// where{PropertyName}() dynamic methods (from $fillable, $casts, etc.)
1695+
Bakery::whereFlour('whole wheat'); // from $fillable
1696+
Bakery::whereApricot(true); // from $casts
1697+
Bakery::whereDefrostedAt('2024-01-01'); // from $dates
1698+
Bakery::whereCroissant('almond'); // from $attributes
1699+
Bakery::whereKitchenId(42); // from $guarded
1700+
Bakery::whereOvenCode('X9'); // from $hidden
1701+
Bakery::whereFlour('rye')->whereApricot(true)->get();
1702+
Bakery::where('open', true)->whereFlour('spelt')->fresh()->first();
16931703
}
16941704
}
16951705

src/virtual_members/laravel/mod.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,19 @@
6161
//! `$guarded`, `$hidden`, and `$appends` produce `mixed`-typed
6262
//! virtual properties as a last-resort fallback. Columns already
6363
//! covered by `$casts` or `$attributes` are skipped.
64+
//!
65+
//! - **`where{PropertyName}()` dynamic methods.** Laravel's
66+
//! `Builder::__call()` translates calls like `whereBrandId($value)`
67+
//! into `where('brand_id', $value)`. For each known column on the
68+
//! model (from all property sources: `$casts`, `$attributes`,
69+
//! `$fillable`/`$guarded`/`$hidden`/`$appends`, `$dates`, timestamps,
70+
//! relationship `*_count` properties, `@property` annotations, and
71+
//! accessor-derived properties), a virtual `where{StudlyCase}()`
72+
//! method is synthesized. The method accepts a `mixed` value
73+
//! parameter and returns `Builder<ConcreteModel>`. These methods
74+
//! appear as both instance methods on the Builder (for chaining:
75+
//! `$query->whereBrandId(42)`) and static methods on the model
76+
//! (for `User::whereName('Alice')`).
6477
6578
mod accessors;
6679
mod builder;
@@ -69,6 +82,7 @@ mod factory;
6982
mod helpers;
7083
mod relationships;
7184
mod scopes;
85+
mod where_property;
7286

7387
pub use helpers::extends_eloquent_model;
7488
pub(crate) use helpers::{accessor_method_candidates, camel_to_snake};
@@ -88,6 +102,7 @@ use relationships::{
88102

89103
pub use scopes::build_scope_methods_for_builder;
90104
use scopes::{build_scope_methods, is_scope_method};
105+
use where_property::{build_where_property_methods_for_class, lowercase_method_names};
91106

92107
use std::sync::Arc;
93108

@@ -377,6 +392,23 @@ fn inject_scopes_and_model_methods(
377392

378393
// 2. Inject @method virtual methods from the model.
379394
inject_model_virtual_methods(result, model_arg, class_loader);
395+
396+
// 3. Inject where{PropertyName}() dynamic methods from the model's
397+
// known columns. These are instance methods on the Builder so
398+
// that `$query->whereBrandId(42)` resolves.
399+
if let Some(model_class) = class_loader(model_arg) {
400+
let existing = lowercase_method_names(&result.methods);
401+
let where_methods = build_where_property_methods_for_class(&model_class, &existing);
402+
for method in where_methods {
403+
if !result
404+
.methods
405+
.iter()
406+
.any(|m| m.name.eq_ignore_ascii_case(&method.name))
407+
{
408+
result.methods.push(method);
409+
}
410+
}
411+
}
380412
}
381413

382414
/// Inject `@method`-declared virtual methods from a model onto a Builder.
@@ -715,6 +747,17 @@ impl VirtualMemberProvider for LaravelModelProvider {
715747
let forwarded = build_builder_forwarded_methods(class, class_loader, cache);
716748
methods.extend(forwarded);
717749

750+
// ── where{PropertyName}() static forwarding ─────────────────
751+
// Laravel's Model::__callStatic() delegates to Builder, which
752+
// handles where{Column}() calls. Synthesize these as static
753+
// methods on the model so that User::whereName('Alice') resolves.
754+
let existing = lowercase_method_names(&methods);
755+
let where_static = build_where_property_methods_for_class(class, &existing);
756+
for mut m in where_static {
757+
m.is_static = true;
758+
methods.push(m);
759+
}
760+
718761
VirtualMembers {
719762
methods,
720763
properties,
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
//! Eloquent `where{PropertyName}()` dynamic method synthesis.
2+
//!
3+
//! Laravel's `Builder::__call()` translates calls like
4+
//! `whereBrandId($value)` into `where('brand_id', $value)` and returns
5+
//! `$this`. This module synthesizes those dynamic methods from a
6+
//! model's known column names so that PHPantom resolves them instead of
7+
//! reporting `unknown_member` diagnostics.
8+
//!
9+
//! Column names are gathered directly from the raw `ClassInfo` and its
10+
//! `LaravelMetadata` (without full resolution) to avoid recursive
11+
//! cycles with `LaravelModelProvider::provide()`. The sources are:
12+
//!
13+
//! - `$casts` definitions
14+
//! - `$dates` definitions
15+
//! - `$attributes` defaults
16+
//! - `$fillable`/`$guarded`/`$hidden`/`$appends` column names
17+
//! - Timestamp columns (`created_at`, `updated_at` unless disabled)
18+
//! - `@property` annotations (already on the raw `ClassInfo`)
19+
//! - Declared (non-static, non-private) properties on the class itself
20+
//!
21+
//! Each column `foo_bar` produces a method `whereFooBar($value)` that
22+
//! accepts one parameter and returns `Builder<ConcreteModel>`.
23+
24+
use std::collections::HashSet;
25+
26+
use crate::php_type::PhpType;
27+
use crate::types::{ClassInfo, MethodInfo, ParameterInfo};
28+
29+
use super::ELOQUENT_BUILDER_FQN;
30+
use super::helpers::snake_to_pascal;
31+
32+
/// Collect all known column names from a raw (unresolved) model class.
33+
///
34+
/// This reads directly from `LaravelMetadata` fields and from the
35+
/// class's own declared/virtual properties, avoiding a full
36+
/// `resolve_class_fully` call (which would recurse through
37+
/// `LaravelModelProvider`).
38+
fn collect_column_names(class: &ClassInfo) -> Vec<String> {
39+
let mut seen = HashSet::new();
40+
let mut columns = Vec::new();
41+
42+
// Helper to insert a column if not already seen.
43+
let mut push = |name: &str| {
44+
if seen.insert(name.to_string()) {
45+
columns.push(name.to_string());
46+
}
47+
};
48+
49+
// ── LaravelMetadata sources ─────────────────────────────────────
50+
if let Some(laravel) = class.laravel() {
51+
// $casts
52+
for (col, _) in &laravel.casts_definitions {
53+
push(col);
54+
}
55+
56+
// $dates
57+
for col in &laravel.dates_definitions {
58+
push(col);
59+
}
60+
61+
// $attributes defaults
62+
for (col, _) in &laravel.attributes_definitions {
63+
push(col);
64+
}
65+
66+
// $fillable, $guarded, $hidden, $appends
67+
for col in &laravel.column_names {
68+
push(col);
69+
}
70+
71+
// Timestamp columns (unless explicitly disabled).
72+
let timestamps_enabled = laravel.timestamps.unwrap_or(true);
73+
if timestamps_enabled {
74+
let created_col = match &laravel.created_at_name {
75+
Some(Some(name)) => Some(name.as_str()),
76+
Some(None) => None, // explicitly null
77+
None => Some("created_at"), // default
78+
};
79+
let updated_col = match &laravel.updated_at_name {
80+
Some(Some(name)) => Some(name.as_str()),
81+
Some(None) => None, // explicitly null
82+
None => Some("updated_at"), // default
83+
};
84+
for col in [created_col, updated_col].into_iter().flatten() {
85+
push(col);
86+
}
87+
}
88+
}
89+
90+
// ── Properties already on the class ─────────────────────────────
91+
// This catches @property annotations (parsed into `properties` by
92+
// the PHPDoc parser) and any explicitly declared properties.
93+
// We skip properties that look like relationship properties (they
94+
// are objects, not columns) by checking for uppercase first char,
95+
// but we include everything — the worst case is an extra `where`
96+
// method that doesn't hurt.
97+
for prop in class.properties.iter() {
98+
push(&prop.name);
99+
}
100+
101+
columns
102+
}
103+
104+
/// Build `where{PropertyName}()` virtual methods for a model's columns.
105+
///
106+
/// Reads column names directly from the raw `class` (no recursive
107+
/// resolution) and synthesizes a `where{StudlyCase}()` method for each.
108+
/// Each method accepts a single `$value` parameter (typed `mixed`) and
109+
/// returns `Builder<ConcreteModel>`.
110+
///
111+
/// Methods whose name would collide with an entry in
112+
/// `existing_method_names` are skipped.
113+
pub fn build_where_property_methods_for_class(
114+
class: &ClassInfo,
115+
existing_method_names: &HashSet<String>,
116+
) -> Vec<MethodInfo> {
117+
let columns = collect_column_names(class);
118+
119+
if columns.is_empty() {
120+
return Vec::new();
121+
}
122+
123+
// Build the return type: Builder<ConcreteModel>.
124+
let return_type = PhpType::Generic(
125+
ELOQUENT_BUILDER_FQN.to_string(),
126+
vec![PhpType::Named(class.name.clone())],
127+
)
128+
.to_string();
129+
130+
let value_param = ParameterInfo {
131+
name: "$value".to_string(),
132+
is_required: true,
133+
type_hint: Some(PhpType::Named("mixed".to_string())),
134+
native_type_hint: None,
135+
description: None,
136+
default_value: None,
137+
is_variadic: false,
138+
is_reference: false,
139+
closure_this_type: None,
140+
};
141+
142+
let mut methods = Vec::new();
143+
let mut seen_methods = HashSet::new();
144+
145+
for col in &columns {
146+
let method_name = format!("where{}", snake_to_pascal(col));
147+
148+
// Skip if a method with this name already exists on the target.
149+
if existing_method_names.contains(&method_name.to_ascii_lowercase()) {
150+
continue;
151+
}
152+
153+
// Skip if we already synthesized a method with this name
154+
// (can happen when a column appears in multiple sources).
155+
if !seen_methods.insert(method_name.clone()) {
156+
continue;
157+
}
158+
159+
let method = MethodInfo {
160+
parameters: vec![value_param.clone()],
161+
description: Some(format!("Find models where `{col}` equals the given value.",)),
162+
..MethodInfo::virtual_method(&method_name, Some(&return_type))
163+
};
164+
165+
methods.push(method);
166+
}
167+
168+
methods
169+
}
170+
171+
/// Collect lowercase method names from a slice for dedup lookups.
172+
pub fn lowercase_method_names(methods: &[MethodInfo]) -> HashSet<String> {
173+
methods
174+
.iter()
175+
.map(|m| m.name.to_ascii_lowercase())
176+
.collect()
177+
}
178+
179+
// ─── Tests ──────────────────────────────────────────────────────────────────
180+
181+
#[cfg(test)]
182+
#[path = "where_property_tests.rs"]
183+
mod tests;

0 commit comments

Comments
 (0)