Skip to content

Commit 1d92d9d

Browse files
committed
feat(builder): AC3+AC5+AC6 - extract newModelCachingEloquentBuilder, trait-collision fixtures, phpstan config
- Extract core builder logic into public newModelCachingEloquentBuilder() helper so models with a newEloquentBuilder trait collision can call it directly from an override (AC6 / #535) - Add docblock documenting the insteadof and as resolution patterns for trait collisions - Add phpstan.neon configured at level 5 to verify no false-positive undefined method errors on custom builder call sites (AC5) - Add FakeNodeTrait fixture and AuthorWithTraitCollision model to reproduce the AC6 collision - Add two new integration tests: testTraitCollisionResolvesWithoutFatalError and testTraitCollisionModelStillCachesResults - All 9 custom-builder tests pass (7 original + 2 new) AC coverage: AC1 through AC6 all addressed
1 parent 20b5737 commit 1d92d9d

File tree

6 files changed

+193
-31
lines changed

6 files changed

+193
-31
lines changed

composer.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,17 @@
2828
"require-dev": {
2929
"doctrine/dbal": "^3.3|^4.2",
3030
"fakerphp/faker": "^1.11",
31-
"laravel/nova": "^5.0",
3231
"orchestra/testbench-browser-kit": "^9.0|^10.0",
3332
"orchestra/testbench": "^9.0|^10.0",
3433
"php-coveralls/php-coveralls": "^2.2",
3534
"phpunit/phpunit": "^10.5|^11.5.3",
3635
"slevomat/coding-standard": "^7.0|^8.14",
3736
"squizlabs/php_codesniffer": "^3.6",
3837
"symfony/thanks": "^1.2",
39-
"laravel/legacy-factories": "^1.3"
38+
"laravel/legacy-factories": "^1.3",
39+
"larastan/larastan": "^2.0|^3.0",
40+
"laravel/pint": "^1.27",
41+
"laravel/nova": "^5.0"
4042
},
4143
"autoload": {
4244
"psr-4": {

phpstan.neon

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
includes:
2+
- vendor/larastan/larastan/extension.neon
3+
4+
parameters:
5+
level: 5
6+
paths:
7+
- src/
8+
- tests/Fixtures/

src/Traits/ModelCaching.php

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
<?php namespace GeneaLabs\LaravelModelCaching\Traits;
1+
<?php
2+
3+
namespace GeneaLabs\LaravelModelCaching\Traits;
24

35
use GeneaLabs\LaravelModelCaching\CachedBelongsToMany;
46
use GeneaLabs\LaravelModelCaching\CachedBuilder;
@@ -48,7 +50,7 @@ public static function all($columns = ['*'])
4850
$class = get_called_class();
4951
$instance = new $class;
5052

51-
if (!$instance->isCachable()) {
53+
if (! $instance->isCachable()) {
5254
return parent::all($columns);
5355
}
5456

@@ -106,7 +108,69 @@ public static function destroy($ids)
106108
return parent::destroy($ids);
107109
}
108110

111+
/**
112+
* Create a new Eloquent query builder for the model.
113+
*
114+
* When caching is disabled the model's custom builder (if any) is returned
115+
* as-is. When caching is enabled the method delegates to
116+
* {@see newModelCachingEloquentBuilder()} so that custom-builder support and
117+
* caching are composed correctly.
118+
*
119+
* **Trait collision (AC6 / #535):** If another trait used on your model also
120+
* defines `newEloquentBuilder` you will encounter a PHP fatal "collision"
121+
* error. Resolve it by explicitly overriding the method on the model class
122+
* and calling `newModelCachingEloquentBuilder()`:
123+
*
124+
* ```php
125+
* use Cachable, NodeTrait {
126+
* Cachable::newEloquentBuilder insteadof NodeTrait;
127+
* }
128+
* ```
129+
*
130+
* Or, if you need *both* trait builders composed:
131+
*
132+
* ```php
133+
* use Cachable, NodeTrait {
134+
* Cachable::newEloquentBuilder as newCachableEloquentBuilder;
135+
* NodeTrait::newEloquentBuilder as newNodeTraitEloquentBuilder;
136+
* }
137+
*
138+
* public function newEloquentBuilder($query)
139+
* {
140+
* return $this->newModelCachingEloquentBuilder($query);
141+
* }
142+
* ```
143+
*/
109144
public function newEloquentBuilder($query)
145+
{
146+
return $this->newModelCachingEloquentBuilder($query);
147+
}
148+
149+
/**
150+
* Core implementation for building a caching-aware Eloquent builder.
151+
*
152+
* Extracted from {@see newEloquentBuilder()} so that it can be called
153+
* directly from model classes that need to resolve a trait collision by
154+
* overriding `newEloquentBuilder` themselves (AC6).
155+
*
156+
* Behaviour:
157+
* - Caching disabled → delegate to parent (custom builder returned as-is, AC1).
158+
* - Caching enabled + custom builder already extends CachedBuilder → return it
159+
* directly so both custom query methods and caching are preserved (AC2).
160+
* - Caching enabled + custom builder does NOT extend CachedBuilder → wrap it
161+
* inside a CachedBuilder via composition; the wrapper's `__call` proxy
162+
* delegates unknown method calls to the inner builder so custom methods
163+
* remain callable at runtime (AC3).
164+
* - No custom builder → plain CachedBuilder (existing behaviour).
165+
*
166+
* **Larastan / PHPStan (AC5):** When a custom builder is wrapped rather than
167+
* returned directly, static analysis tools cannot infer the custom methods
168+
* from the `CachedBuilder` return type. Add a `@return CustomBuilder`
169+
* override annotation on your model's `newQuery()` (or `query()`) call-site,
170+
* or use the `@mixin` approach described in the package README to suppress
171+
* false-positive "undefined method" errors at level 5+.
172+
*/
173+
public function newModelCachingEloquentBuilder($query)
110174
{
111175
if (! $this->isCachable()) {
112176
$this->isCachable = false;
@@ -135,7 +199,7 @@ protected function newBelongsToMany(
135199
$relatedPivotKey,
136200
$parentKey,
137201
$relatedKey,
138-
$relationName = null
202+
$relationName = null,
139203
) {
140204
if (method_exists($query->getModel(), "isCachable")
141205
&& $query->getModel()->isCachable()
@@ -148,7 +212,7 @@ protected function newBelongsToMany(
148212
$relatedPivotKey,
149213
$parentKey,
150214
$relatedKey,
151-
$relationName
215+
$relationName,
152216
);
153217
}
154218

@@ -160,11 +224,11 @@ protected function newBelongsToMany(
160224
$relatedPivotKey,
161225
$parentKey,
162226
$relatedKey,
163-
$relationName
227+
$relationName,
164228
);
165229
}
166230

167-
public function scopeDisableCache(EloquentBuilder $query) : EloquentBuilder
231+
public function scopeDisableCache(EloquentBuilder $query): EloquentBuilder
168232
{
169233
if ($this->isCachable()) {
170234
$query = $query->disableModelCaching();
@@ -175,8 +239,8 @@ public function scopeDisableCache(EloquentBuilder $query) : EloquentBuilder
175239

176240
public function scopeWithCacheCooldownSeconds(
177241
EloquentBuilder $query,
178-
?int $seconds = null
179-
) : EloquentBuilder {
242+
?int $seconds = null,
243+
): EloquentBuilder {
180244
if (! $seconds) {
181245
$seconds = $this->cacheCooldownSeconds;
182246
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
namespace GeneaLabs\LaravelModelCaching\Tests\Fixtures;
4+
5+
use GeneaLabs\LaravelModelCaching\Traits\Cachable;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\SoftDeletes;
8+
9+
/**
10+
* An Author model that combines Cachable with a second trait (FakeNodeTrait)
11+
* that also defines newEloquentBuilder — the classic AC6 / #535 collision.
12+
*
13+
* The collision is resolved by explicitly preferring Cachable's implementation
14+
* via the `insteadof` keyword; caching is still active because we delegate to
15+
* newModelCachingEloquentBuilder().
16+
*/
17+
class AuthorWithTraitCollision extends Model
18+
{
19+
use Cachable, FakeNodeTrait {
20+
Cachable::newEloquentBuilder insteadof FakeNodeTrait;
21+
}
22+
use SoftDeletes;
23+
24+
protected $table = 'authors';
25+
protected $fillable = [
26+
'name',
27+
'email',
28+
'is_famous',
29+
];
30+
}

tests/Fixtures/FakeNodeTrait.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace GeneaLabs\LaravelModelCaching\Tests\Fixtures;
4+
5+
/**
6+
* A minimal stand-in for third-party traits (e.g. Kalnoy\Nestedset\NodeTrait)
7+
* that also define `newEloquentBuilder`. Used to test AC6: no fatal collision
8+
* when `Cachable` is combined with another such trait.
9+
*/
10+
trait FakeNodeTrait
11+
{
12+
public function newEloquentBuilder($query)
13+
{
14+
return parent::newEloquentBuilder($query);
15+
}
16+
}

0 commit comments

Comments
 (0)