Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

#### Laravel

- **Scopes keep custom Eloquent builders.** Chaining a local scope preserves the custom builder and its model type, including with ide-helper docblocks, so its methods remain available and valid returns are no longer reported as type mismatches. Contributed by @shuvroroy.
- **Route groups in `bootstrap/app.php`.** Routes registered from the `then:` or `using:` callback of `withRouting()`, including route files grouped there under a name prefix, are now known by their full names and are no longer reported as unknown routes.
- **Imported `#[Table]` and `#[Connection]` attributes.** A model's table and connection attributes are now read when the attribute is imported with `use`, not only when written fully qualified, in files open in the editor and in `analyze`. Contributed by @shuvroroy.
- **Nullable columns stay nullable through a cast.** A model attribute cast to `boolean`, `integer`, a date, an enum or another cast that hands `null` back untouched is now nullable when its column is, and so is a nullable `updated_at` or other timestamp column. A null check on a nullable date column, the soft-delete column included, leaves the date class with all its members.
Expand Down
9 changes: 9 additions & 0 deletions examples/laravel/app/Demo.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use App\Http\Requests\StoreBakeryRequest;
use App\Http\Requests\UpdateBakeryRequest;
use App\Models\Baker;
use App\Models\BakerBuilder;
use App\Models\Bakery;
use App\Models\BakeryOrder;
use App\Models\BlogAuthor;
Expand Down Expand Up @@ -186,6 +187,14 @@ public function classCasts(CastSample $sample): void

// ── Eloquent Query Builder ──────────────────────────────────────────────

/** @return BakerBuilder<Baker> */
public function scopedBakers(): BakerBuilder
{
// Try: hover employed() and available(), then complete after either.
// Both scopes keep BakerBuilder<Baker>, including its active() method.
return Baker::query()->employed()->available()->active();
}

public function eloquentQuery(): void
{
// Builder-as-static forwarding
Expand Down
14 changes: 14 additions & 0 deletions examples/laravel/app/Models/Baker.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,24 @@
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Attributes\UseEloquentBuilder;

/** @method static BakerBuilder<static>|Baker employed() */
#[UseEloquentBuilder(BakerBuilder::class)]
class Baker extends Model
{
public function getName(): string { return ''; }

protected function scopeEmployed(Builder $query): Builder
{
return $query->whereNotNull('hired_at');
}

#[Scope]
protected function available(Builder $query): void
{
$query->where('available', true);
}
}
1 change: 1 addition & 0 deletions examples/laravel/app/Models/BakerBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
/**
* @template TModel of \App\Models\Baker
* @extends Builder<TModel>
* @mixin Baker
*/
class BakerBuilder extends Builder
{
Expand Down
14 changes: 14 additions & 0 deletions examples/laravel/assertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,20 @@ function assertMethodReturnType(string $class, string $method, string $expected)
'BakerBuilder holds the Baker model it queries',
$result->getModel() instanceof \App\Models\Baker
);
check(
'Convention and attribute scopes preserve BakerBuilder and its model',
$result->employed()->available()->active() === $result
&& $result->getModel() instanceof \App\Models\Baker
);
check(
'Static scope forwarding preserves BakerBuilder',
\App\Models\Baker::employed()->available()->active() instanceof \App\Models\BakerBuilder
);
check(
'The scoped builder demo returns BakerBuilder<Baker>',
($scopedBakers = (new \App\Demo())->scopedBakers()) instanceof \App\Models\BakerBuilder
&& $scopedBakers->getModel() instanceof \App\Models\Baker
);

check(
'Inherited where/orderBy calls preserve the LoafBuilder instance',
Expand Down
29 changes: 26 additions & 3 deletions src/type_engine/variable/closure_resolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -880,14 +880,15 @@ fn extract_generic_args_from_methods(class: &ClassInfo, class_fqn: &str) -> Opti
None
}

/// Extract the model type from a resolved `Builder<Model>` class by
/// scanning its method return types for `Builder<X>` and returning `X`.
/// Recover the model from a resolved builder's fluent return types,
/// including scopes returning the custom builder itself.
pub(in crate::type_engine) fn extract_model_from_builder(builder: &ClassInfo) -> Option<PhpType> {
let builder_fqn = builder.fqn();
for method in &builder.methods {
if let Some(ref ret) = method.return_type
&& let TypeKind::Generic(g) = ret.kind()
&& !g.args.is_empty()
&& (g.name == ELOQUENT_BUILDER_FQN || g.name == "Builder")
&& (g.name == ELOQUENT_BUILDER_FQN || g.name == "Builder" || g.name == builder_fqn)
{
// Skip unsubstituted template params like "TModel".
if !g.args[0].is_empty() && !g.args[0].is_named("TModel") {
Expand All @@ -897,3 +898,25 @@ pub(in crate::type_engine) fn extract_model_from_builder(builder: &ClassInfo) ->
}
None
}

#[cfg(test)]
mod tests {
use super::*;
use crate::test_fixtures::{make_class, make_method};

#[test]
fn recover_model_from_custom_builder_uses_its_exact_class() {
let mut builder = make_class("App\\Builders\\Query");
for (name, ret) in [
("other", "Other\\Builders\\Query<App\\Models\\Other>"),
("unbound", "App\\Builders\\Query<TModel>"),
("scoped", "App\\Builders\\Query<App\\Models\\Post>"),
] {
builder.methods.push(Arc::new(make_method(name, Some(ret))));
}
assert_eq!(
extract_model_from_builder(&builder),
Some(PhpType::named(atom("App\\Models\\Post"))),
);
}
}
15 changes: 12 additions & 3 deletions src/virtual_members/laravel/builder_injection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,11 +279,20 @@ fn inject_scopes_and_model_methods(
// 1. Inject scope methods.
let scope_methods = build_scope_methods_for_builder(model_arg, class_loader);
for method in scope_methods {
let already_exists = result
let existing = result
.methods
.iter()
.any(|m| m.name == method.name && m.is_static == method.is_static);
if !already_exists {
.position(|m| m.name.eq_ignore_ascii_case(&method.name));
if let Some(index) = existing {
// Builder::__call invokes the model's scope ahead of any
// @method or @mixin description of that scope. Real builder
// methods and registered macros still win at runtime.
let previous = &result.methods[index];
if previous.is_virtual && !previous.is_macro {
result.methods.make_mut()[index] =
forward_builder_return(&method, forwarded, class_loader);
}
} else {
result
.methods
.push(forward_builder_return(&method, forwarded, class_loader));
Expand Down
9 changes: 7 additions & 2 deletions src/virtual_members/laravel/model_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use super::relationships::{
RelationshipKind, build_property_type, classify_relationship_typed, count_property_name,
extract_pivot_accessor_typed, extract_related_type_typed,
};
use super::scopes::{build_scope_methods, is_scope_method};
use super::scopes::{build_scope_methods, is_scope_method, rebind_scope_builder_return};
use super::table_names::model_schema_table;
use super::where_property::{build_where_property_methods_with_properties, lowercase_method_names};
use super::{
Expand Down Expand Up @@ -463,6 +463,7 @@ impl<'a> ModelMembers<'a> {
/// relationship method loads.
fn add_method_members(&mut self) {
let class = self.class;
let mut builder_type = None;
for method in &class.methods {
if self.is_base_model_method(method) {
continue;
Expand All @@ -473,7 +474,11 @@ impl<'a> ModelMembers<'a> {
// A method that is both `#[Scope]`-attributed and named
// `scopeX` keeps the attribute's name as-is; the prefix is
// only stripped for convention-based scope methods.
let [instance_method, static_method] = build_scope_methods(method);
let builder = builder_type
.get_or_insert_with(|| super::model_builder_type(class, self.class_loader));
let [mut instance_method, mut static_method] = build_scope_methods(method);
rebind_scope_builder_return(&mut instance_method, builder);
rebind_scope_builder_return(&mut static_method, builder);
self.methods.push(Arc::new(instance_method));
self.methods.push(Arc::new(static_method));
continue;
Expand Down
55 changes: 27 additions & 28 deletions src/virtual_members/laravel/scopes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ use std::sync::Arc;

use crate::php_type::{PhpType, TypeKind};
use crate::types::{MethodExtras, MethodInfo};
use crate::util::short_name;

use super::ELOQUENT_BUILDER_FQN;
use super::helpers::extends_eloquent_model;
Expand Down Expand Up @@ -94,6 +93,29 @@ pub(super) fn scope_return_type(method: &MethodInfo) -> PhpType {
}
}

/// Keep a fluent scope on the model's builder, while preserving a return
/// that explicitly queries a different model or names another result type.
pub(super) fn rebind_scope_builder_return(method: &mut MethodInfo, builder: &PhpType) {
let Some(ret) = method.return_type.as_ref() else {
return;
};
let should_rebind = match ret.kind() {
TypeKind::Named(name) => Fqn::new(name).as_str() == ELOQUENT_BUILDER_FQN,
TypeKind::Generic(g) => {
Fqn::new(&g.name).as_str() == ELOQUENT_BUILDER_FQN
&& builder.base_name() != Some(ELOQUENT_BUILDER_FQN)
&& g.args.first().is_some_and(|model| {
model.is_self_ref()
|| matches!(builder.kind(), TypeKind::Generic(b) if b.args.first() == Some(model))
})
}
_ => false,
};
if should_rebind {
method.return_type = Some(builder.clone());
}
}

/// Build virtual methods for a scope method.
///
/// Returns two `MethodInfo` values: one static and one instance. Both
Expand Down Expand Up @@ -189,6 +211,7 @@ pub fn build_scope_methods_for_builder(
let subs = super::self_ref_subs(model_type.clone());

let mut methods = Vec::new();
let mut builder_type = None;

// The rewrite below depends only on the model, so the produced
// copies are interned and shared by every Builder and relation
Expand All @@ -203,6 +226,8 @@ pub fn build_scope_methods_for_builder(
if !is_scope_method(method) {
continue;
}
let builder = builder_type
.get_or_insert_with(|| super::model_builder_type(&model_class, class_loader));

let transformed = crate::virtual_members::intern_transformed_method(method, fp, || {
// Build an instance method (scopes are called as instance
Expand Down Expand Up @@ -235,21 +260,8 @@ pub fn build_scope_methods_for_builder(

if let Some(ref mut ret) = m.return_type {
*ret = ret.substitute(&subs);

// When a scope method declares a bare `Builder` return type
// (without generic args), the chain loses track of the
// concrete model. Subsequent calls on the returned Builder
// would not find model-specific scope methods because
// `type_hint_to_classes_typed` only injects scopes when
// generic args are present. Wrap bare Builder return types
// as `Builder<ModelName>` to preserve the chain.
if is_bare_builder_type(ret) {
*ret = PhpType::generic(
ELOQUENT_BUILDER_FQN,
vec![PhpType::named(atom(model_name))],
);
}
}
rebind_scope_builder_return(&mut m, builder);

m
});
Expand All @@ -259,19 +271,6 @@ pub fn build_scope_methods_for_builder(
methods
}

/// Check whether a `PhpType` is a bare Eloquent Builder reference
/// without generic arguments.
///
/// Matches both the FQN (`Illuminate\Database\Eloquent\Builder`) and
/// the short name (`Builder`) since scope methods in user code
/// typically use the imported short name.
fn is_bare_builder_type(ty: &PhpType) -> bool {
match ty.kind() {
TypeKind::Named(name) => name == ELOQUENT_BUILDER_FQN || short_name(name) == "Builder",
_ => false,
}
}

#[cfg(test)]
#[path = "scopes_tests.rs"]
mod tests;
57 changes: 57 additions & 0 deletions src/virtual_members/laravel/scopes_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,63 @@ fn scope_return_type_custom() {
);
}

#[test]
fn rebind_scope_builder_return_preserves_explicit_results() {
let custom = PhpType::parse("App\\Builders\\PostBuilder<App\\Models\\Post>");
let base = PhpType::parse("Illuminate\\Database\\Eloquent\\Builder<App\\Models\\Post>");
for (ret, builder, expected) in [
(None, &custom, None),
(Some("int"), &custom, Some("int")),
(Some("int|string"), &custom, Some("int|string")),
(Some("App\\Builder"), &custom, Some("App\\Builder")),
(Some("Builder"), &custom, Some("Builder")),
(
Some("Illuminate\\Database\\Eloquent\\Builder"),
&custom,
Some("App\\Builders\\PostBuilder<App\\Models\\Post>"),
),
(
Some("\\Illuminate\\Database\\Eloquent\\Builder"),
&custom,
Some("App\\Builders\\PostBuilder<App\\Models\\Post>"),
),
(
Some("Illuminate\\Database\\Eloquent\\Builder"),
&base,
Some("Illuminate\\Database\\Eloquent\\Builder<App\\Models\\Post>"),
),
(
Some("Illuminate\\Database\\Eloquent\\Builder<static>"),
&custom,
Some("App\\Builders\\PostBuilder<App\\Models\\Post>"),
),
(
Some("Illuminate\\Database\\Eloquent\\Builder<App\\Models\\Post>"),
&custom,
Some("App\\Builders\\PostBuilder<App\\Models\\Post>"),
),
(
Some("Illuminate\\Database\\Eloquent\\Builder<App\\Models\\Other>"),
&custom,
Some("Illuminate\\Database\\Eloquent\\Builder<App\\Models\\Other>"),
),
(
Some("App\\Builders\\OtherBuilder<static>"),
&custom,
Some("App\\Builders\\OtherBuilder<static>"),
),
(
Some("Illuminate\\Database\\Eloquent\\Builder<static>"),
&base,
Some("Illuminate\\Database\\Eloquent\\Builder<static>"),
),
] {
let mut method = make_method("scopePublished", ret);
rebind_scope_builder_return(&mut method, builder);
assert_eq!(method.return_type, expected.map(PhpType::parse), "{ret:?}");
}
}

// ── build_scope_methods (convention) ─────────────────────────────────

#[test]
Expand Down
Loading
Loading