Skip to content

Commit bf25948

Browse files
authored
Merge pull request hypervel#332 from binaryfire/feature/use-eloquent-builder-attribute
feat: Add `#[UseEloquentBuilder]` attribute
2 parents bf38aba + 176f0e6 commit bf25948

File tree

3 files changed

+276
-1
lines changed

3 files changed

+276
-1
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Hypervel\Database\Eloquent\Attributes;
6+
7+
use Attribute;
8+
9+
/**
10+
* Declare the Eloquent builder class for a model using an attribute.
11+
*
12+
* When placed on a model class, the model will use the specified builder
13+
* class when creating new query builder instances via newModelBuilder().
14+
*
15+
* @example
16+
* ```php
17+
* #[UseEloquentBuilder(UserBuilder::class)]
18+
* class User extends Model {}
19+
* ```
20+
*/
21+
#[Attribute(Attribute::TARGET_CLASS)]
22+
class UseEloquentBuilder
23+
{
24+
/**
25+
* Create a new attribute instance.
26+
*
27+
* @param class-string<\Hypervel\Database\Eloquent\Builder<\Hypervel\Database\Eloquent\Model>> $builderClass
28+
*/
29+
public function __construct(
30+
public string $builderClass,
31+
) {
32+
}
33+
}

src/core/src/Database/Eloquent/Model.php

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Hyperf\Stringable\Str;
99
use Hypervel\Broadcasting\Contracts\HasBroadcastChannel;
1010
use Hypervel\Context\Context;
11+
use Hypervel\Database\Eloquent\Attributes\UseEloquentBuilder;
1112
use Hypervel\Database\Eloquent\Concerns\HasAttributes;
1213
use Hypervel\Database\Eloquent\Concerns\HasBootableTraits;
1314
use Hypervel\Database\Eloquent\Concerns\HasCallbacks;
@@ -20,6 +21,7 @@
2021
use Hypervel\Database\Eloquent\Relations\Pivot;
2122
use Hypervel\Router\Contracts\UrlRoutable;
2223
use Psr\EventDispatcher\EventDispatcherInterface;
24+
use ReflectionClass;
2325

2426
/**
2527
* @method static \Hypervel\Database\Eloquent\Collection<int, static> all(array|string $columns = ['*'])
@@ -80,6 +82,13 @@ abstract class Model extends BaseModel implements UrlRoutable, HasBroadcastChann
8082
use HasRelationships;
8183
use TransformsToResource;
8284

85+
/**
86+
* The resolved builder class names by model.
87+
*
88+
* @var array<class-string<static>, class-string<Builder<static>>|false>
89+
*/
90+
protected static array $resolvedBuilderClasses = [];
91+
8392
protected ?string $connection = null;
8493

8594
public function resolveRouteBinding($value)
@@ -88,15 +97,43 @@ public function resolveRouteBinding($value)
8897
}
8998

9099
/**
100+
* Create a new Eloquent query builder for the model.
101+
*
91102
* @param \Hypervel\Database\Query\Builder $query
92103
* @return \Hypervel\Database\Eloquent\Builder<static>
93104
*/
94105
public function newModelBuilder($query)
95106
{
96-
// @phpstan-ignore-next-line
107+
$builderClass = static::$resolvedBuilderClasses[static::class]
108+
??= $this->resolveCustomBuilderClass();
109+
110+
if ($builderClass !== false && is_subclass_of($builderClass, Builder::class)) { // @phpstan-ignore function.alreadyNarrowedType (validates attribute returns valid Builder subclass)
111+
// @phpstan-ignore new.static
112+
return new $builderClass($query);
113+
}
114+
115+
// @phpstan-ignore return.type
97116
return new Builder($query);
98117
}
99118

119+
/**
120+
* Resolve the custom Eloquent builder class from the model attributes.
121+
*
122+
* @return class-string<\Hypervel\Database\Eloquent\Builder<static>>|false
123+
*/
124+
protected function resolveCustomBuilderClass(): string|false
125+
{
126+
$attributes = (new ReflectionClass(static::class))
127+
->getAttributes(UseEloquentBuilder::class);
128+
129+
if ($attributes === []) {
130+
return false;
131+
}
132+
133+
// @phpstan-ignore return.type (attribute stores generic Model type, but we know it's compatible with static)
134+
return $attributes[0]->newInstance()->builderClass;
135+
}
136+
100137
/**
101138
* @param array<array-key, static> $models
102139
* @return \Hypervel\Database\Eloquent\Collection<array-key, static>
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Hypervel\Tests\Core\Database\Eloquent;
6+
7+
use Hypervel\Database\Eloquent\Attributes\UseEloquentBuilder;
8+
use Hypervel\Database\Eloquent\Builder;
9+
use Hypervel\Database\Eloquent\Model;
10+
use Hypervel\Testbench\TestCase;
11+
use Mockery as m;
12+
13+
/**
14+
* @internal
15+
* @coversNothing
16+
*/
17+
class UseEloquentBuilderTest extends TestCase
18+
{
19+
protected function tearDown(): void
20+
{
21+
// Clear the static cache between tests
22+
UseEloquentBuilderTestModel::clearResolvedBuilderClasses();
23+
UseEloquentBuilderTestModelWithAttribute::clearResolvedBuilderClasses();
24+
UseEloquentBuilderTestChildModel::clearResolvedBuilderClasses();
25+
UseEloquentBuilderTestChildModelWithOwnAttribute::clearResolvedBuilderClasses();
26+
27+
parent::tearDown();
28+
}
29+
30+
public function testNewModelBuilderReturnsDefaultBuilderWhenNoAttribute(): void
31+
{
32+
$model = new UseEloquentBuilderTestModel();
33+
$query = m::mock(\Hypervel\Database\Query\Builder::class);
34+
35+
$builder = $model->newModelBuilder($query);
36+
37+
$this->assertInstanceOf(Builder::class, $builder);
38+
$this->assertNotInstanceOf(CustomTestBuilder::class, $builder);
39+
}
40+
41+
public function testNewModelBuilderReturnsCustomBuilderWhenAttributePresent(): void
42+
{
43+
$model = new UseEloquentBuilderTestModelWithAttribute();
44+
$query = m::mock(\Hypervel\Database\Query\Builder::class);
45+
46+
$builder = $model->newModelBuilder($query);
47+
48+
$this->assertInstanceOf(CustomTestBuilder::class, $builder);
49+
}
50+
51+
public function testNewModelBuilderCachesResolvedBuilderClass(): void
52+
{
53+
$model1 = new UseEloquentBuilderTestModelWithAttribute();
54+
$model2 = new UseEloquentBuilderTestModelWithAttribute();
55+
$query = m::mock(\Hypervel\Database\Query\Builder::class);
56+
57+
// First call should resolve and cache
58+
$builder1 = $model1->newModelBuilder($query);
59+
60+
// Second call should use cache
61+
$builder2 = $model2->newModelBuilder($query);
62+
63+
// Both should be CustomTestBuilder
64+
$this->assertInstanceOf(CustomTestBuilder::class, $builder1);
65+
$this->assertInstanceOf(CustomTestBuilder::class, $builder2);
66+
}
67+
68+
public function testResolveCustomBuilderClassReturnsFalseWhenNoAttribute(): void
69+
{
70+
$model = new UseEloquentBuilderTestModel();
71+
72+
$result = $model->testResolveCustomBuilderClass();
73+
74+
$this->assertFalse($result);
75+
}
76+
77+
public function testResolveCustomBuilderClassReturnsBuilderClassWhenAttributePresent(): void
78+
{
79+
$model = new UseEloquentBuilderTestModelWithAttribute();
80+
81+
$result = $model->testResolveCustomBuilderClass();
82+
83+
$this->assertSame(CustomTestBuilder::class, $result);
84+
}
85+
86+
public function testDifferentModelsUseDifferentCaches(): void
87+
{
88+
$modelWithoutAttribute = new UseEloquentBuilderTestModel();
89+
$modelWithAttribute = new UseEloquentBuilderTestModelWithAttribute();
90+
$query = m::mock(\Hypervel\Database\Query\Builder::class);
91+
92+
$builder1 = $modelWithoutAttribute->newModelBuilder($query);
93+
$builder2 = $modelWithAttribute->newModelBuilder($query);
94+
95+
$this->assertInstanceOf(Builder::class, $builder1);
96+
$this->assertNotInstanceOf(CustomTestBuilder::class, $builder1);
97+
$this->assertInstanceOf(CustomTestBuilder::class, $builder2);
98+
}
99+
100+
public function testChildModelWithoutAttributeUsesDefaultBuilder(): void
101+
{
102+
$model = new UseEloquentBuilderTestChildModel();
103+
$query = m::mock(\Hypervel\Database\Query\Builder::class);
104+
105+
$builder = $model->newModelBuilder($query);
106+
107+
// PHP attributes are not inherited - child needs its own attribute
108+
$this->assertInstanceOf(Builder::class, $builder);
109+
$this->assertNotInstanceOf(CustomTestBuilder::class, $builder);
110+
}
111+
112+
public function testChildModelWithOwnAttributeUsesOwnBuilder(): void
113+
{
114+
$model = new UseEloquentBuilderTestChildModelWithOwnAttribute();
115+
$query = m::mock(\Hypervel\Database\Query\Builder::class);
116+
117+
$builder = $model->newModelBuilder($query);
118+
119+
$this->assertInstanceOf(AnotherCustomTestBuilder::class, $builder);
120+
}
121+
}
122+
123+
// Test fixtures
124+
125+
class UseEloquentBuilderTestModel extends Model
126+
{
127+
protected ?string $table = 'test_models';
128+
129+
/**
130+
* Expose protected method for testing.
131+
*/
132+
public function testResolveCustomBuilderClass(): string|false
133+
{
134+
return $this->resolveCustomBuilderClass();
135+
}
136+
137+
/**
138+
* Clear the static cache for testing.
139+
*/
140+
public static function clearResolvedBuilderClasses(): void
141+
{
142+
static::$resolvedBuilderClasses = [];
143+
}
144+
}
145+
146+
#[UseEloquentBuilder(CustomTestBuilder::class)]
147+
class UseEloquentBuilderTestModelWithAttribute extends Model
148+
{
149+
protected ?string $table = 'test_models';
150+
151+
/**
152+
* Expose protected method for testing.
153+
*/
154+
public function testResolveCustomBuilderClass(): string|false
155+
{
156+
return $this->resolveCustomBuilderClass();
157+
}
158+
159+
/**
160+
* Clear the static cache for testing.
161+
*/
162+
public static function clearResolvedBuilderClasses(): void
163+
{
164+
static::$resolvedBuilderClasses = [];
165+
}
166+
}
167+
168+
class UseEloquentBuilderTestChildModel extends UseEloquentBuilderTestModelWithAttribute
169+
{
170+
/**
171+
* Clear the static cache for testing.
172+
*/
173+
public static function clearResolvedBuilderClasses(): void
174+
{
175+
static::$resolvedBuilderClasses = [];
176+
}
177+
}
178+
179+
#[UseEloquentBuilder(AnotherCustomTestBuilder::class)]
180+
class UseEloquentBuilderTestChildModelWithOwnAttribute extends UseEloquentBuilderTestModelWithAttribute
181+
{
182+
/**
183+
* Clear the static cache for testing.
184+
*/
185+
public static function clearResolvedBuilderClasses(): void
186+
{
187+
static::$resolvedBuilderClasses = [];
188+
}
189+
}
190+
191+
/**
192+
* @template TModel of Model
193+
* @extends Builder<TModel>
194+
*/
195+
class CustomTestBuilder extends Builder
196+
{
197+
}
198+
199+
/**
200+
* @template TModel of Model
201+
* @extends Builder<TModel>
202+
*/
203+
class AnotherCustomTestBuilder extends Builder
204+
{
205+
}

0 commit comments

Comments
 (0)