diff --git a/src/core/src/Database/Eloquent/Attributes/UseEloquentBuilder.php b/src/core/src/Database/Eloquent/Attributes/UseEloquentBuilder.php new file mode 100644 index 00000000..8c8c130b --- /dev/null +++ b/src/core/src/Database/Eloquent/Attributes/UseEloquentBuilder.php @@ -0,0 +1,33 @@ +> $builderClass + */ + public function __construct( + public string $builderClass, + ) { + } +} diff --git a/src/core/src/Database/Eloquent/Model.php b/src/core/src/Database/Eloquent/Model.php index 0a627ed0..8537ab96 100644 --- a/src/core/src/Database/Eloquent/Model.php +++ b/src/core/src/Database/Eloquent/Model.php @@ -8,6 +8,7 @@ use Hyperf\Stringable\Str; use Hypervel\Broadcasting\Contracts\HasBroadcastChannel; use Hypervel\Context\Context; +use Hypervel\Database\Eloquent\Attributes\UseEloquentBuilder; use Hypervel\Database\Eloquent\Concerns\HasAttributes; use Hypervel\Database\Eloquent\Concerns\HasBootableTraits; use Hypervel\Database\Eloquent\Concerns\HasCallbacks; @@ -20,6 +21,7 @@ use Hypervel\Database\Eloquent\Relations\Pivot; use Hypervel\Router\Contracts\UrlRoutable; use Psr\EventDispatcher\EventDispatcherInterface; +use ReflectionClass; /** * @method static \Hypervel\Database\Eloquent\Collection all(array|string $columns = ['*']) @@ -80,6 +82,13 @@ abstract class Model extends BaseModel implements UrlRoutable, HasBroadcastChann use HasRelationships; use TransformsToResource; + /** + * The resolved builder class names by model. + * + * @var array, class-string>|false> + */ + protected static array $resolvedBuilderClasses = []; + protected ?string $connection = null; public function resolveRouteBinding($value) @@ -88,15 +97,43 @@ public function resolveRouteBinding($value) } /** + * Create a new Eloquent query builder for the model. + * * @param \Hypervel\Database\Query\Builder $query * @return \Hypervel\Database\Eloquent\Builder */ public function newModelBuilder($query) { - // @phpstan-ignore-next-line + $builderClass = static::$resolvedBuilderClasses[static::class] + ??= $this->resolveCustomBuilderClass(); + + if ($builderClass !== false && is_subclass_of($builderClass, Builder::class)) { // @phpstan-ignore function.alreadyNarrowedType (validates attribute returns valid Builder subclass) + // @phpstan-ignore new.static + return new $builderClass($query); + } + + // @phpstan-ignore return.type return new Builder($query); } + /** + * Resolve the custom Eloquent builder class from the model attributes. + * + * @return class-string<\Hypervel\Database\Eloquent\Builder>|false + */ + protected function resolveCustomBuilderClass(): string|false + { + $attributes = (new ReflectionClass(static::class)) + ->getAttributes(UseEloquentBuilder::class); + + if ($attributes === []) { + return false; + } + + // @phpstan-ignore return.type (attribute stores generic Model type, but we know it's compatible with static) + return $attributes[0]->newInstance()->builderClass; + } + /** * @param array $models * @return \Hypervel\Database\Eloquent\Collection diff --git a/tests/Core/Database/Eloquent/UseEloquentBuilderTest.php b/tests/Core/Database/Eloquent/UseEloquentBuilderTest.php new file mode 100644 index 00000000..206bda8c --- /dev/null +++ b/tests/Core/Database/Eloquent/UseEloquentBuilderTest.php @@ -0,0 +1,205 @@ +newModelBuilder($query); + + $this->assertInstanceOf(Builder::class, $builder); + $this->assertNotInstanceOf(CustomTestBuilder::class, $builder); + } + + public function testNewModelBuilderReturnsCustomBuilderWhenAttributePresent(): void + { + $model = new UseEloquentBuilderTestModelWithAttribute(); + $query = m::mock(\Hypervel\Database\Query\Builder::class); + + $builder = $model->newModelBuilder($query); + + $this->assertInstanceOf(CustomTestBuilder::class, $builder); + } + + public function testNewModelBuilderCachesResolvedBuilderClass(): void + { + $model1 = new UseEloquentBuilderTestModelWithAttribute(); + $model2 = new UseEloquentBuilderTestModelWithAttribute(); + $query = m::mock(\Hypervel\Database\Query\Builder::class); + + // First call should resolve and cache + $builder1 = $model1->newModelBuilder($query); + + // Second call should use cache + $builder2 = $model2->newModelBuilder($query); + + // Both should be CustomTestBuilder + $this->assertInstanceOf(CustomTestBuilder::class, $builder1); + $this->assertInstanceOf(CustomTestBuilder::class, $builder2); + } + + public function testResolveCustomBuilderClassReturnsFalseWhenNoAttribute(): void + { + $model = new UseEloquentBuilderTestModel(); + + $result = $model->testResolveCustomBuilderClass(); + + $this->assertFalse($result); + } + + public function testResolveCustomBuilderClassReturnsBuilderClassWhenAttributePresent(): void + { + $model = new UseEloquentBuilderTestModelWithAttribute(); + + $result = $model->testResolveCustomBuilderClass(); + + $this->assertSame(CustomTestBuilder::class, $result); + } + + public function testDifferentModelsUseDifferentCaches(): void + { + $modelWithoutAttribute = new UseEloquentBuilderTestModel(); + $modelWithAttribute = new UseEloquentBuilderTestModelWithAttribute(); + $query = m::mock(\Hypervel\Database\Query\Builder::class); + + $builder1 = $modelWithoutAttribute->newModelBuilder($query); + $builder2 = $modelWithAttribute->newModelBuilder($query); + + $this->assertInstanceOf(Builder::class, $builder1); + $this->assertNotInstanceOf(CustomTestBuilder::class, $builder1); + $this->assertInstanceOf(CustomTestBuilder::class, $builder2); + } + + public function testChildModelWithoutAttributeUsesDefaultBuilder(): void + { + $model = new UseEloquentBuilderTestChildModel(); + $query = m::mock(\Hypervel\Database\Query\Builder::class); + + $builder = $model->newModelBuilder($query); + + // PHP attributes are not inherited - child needs its own attribute + $this->assertInstanceOf(Builder::class, $builder); + $this->assertNotInstanceOf(CustomTestBuilder::class, $builder); + } + + public function testChildModelWithOwnAttributeUsesOwnBuilder(): void + { + $model = new UseEloquentBuilderTestChildModelWithOwnAttribute(); + $query = m::mock(\Hypervel\Database\Query\Builder::class); + + $builder = $model->newModelBuilder($query); + + $this->assertInstanceOf(AnotherCustomTestBuilder::class, $builder); + } +} + +// Test fixtures + +class UseEloquentBuilderTestModel extends Model +{ + protected ?string $table = 'test_models'; + + /** + * Expose protected method for testing. + */ + public function testResolveCustomBuilderClass(): string|false + { + return $this->resolveCustomBuilderClass(); + } + + /** + * Clear the static cache for testing. + */ + public static function clearResolvedBuilderClasses(): void + { + static::$resolvedBuilderClasses = []; + } +} + +#[UseEloquentBuilder(CustomTestBuilder::class)] +class UseEloquentBuilderTestModelWithAttribute extends Model +{ + protected ?string $table = 'test_models'; + + /** + * Expose protected method for testing. + */ + public function testResolveCustomBuilderClass(): string|false + { + return $this->resolveCustomBuilderClass(); + } + + /** + * Clear the static cache for testing. + */ + public static function clearResolvedBuilderClasses(): void + { + static::$resolvedBuilderClasses = []; + } +} + +class UseEloquentBuilderTestChildModel extends UseEloquentBuilderTestModelWithAttribute +{ + /** + * Clear the static cache for testing. + */ + public static function clearResolvedBuilderClasses(): void + { + static::$resolvedBuilderClasses = []; + } +} + +#[UseEloquentBuilder(AnotherCustomTestBuilder::class)] +class UseEloquentBuilderTestChildModelWithOwnAttribute extends UseEloquentBuilderTestModelWithAttribute +{ + /** + * Clear the static cache for testing. + */ + public static function clearResolvedBuilderClasses(): void + { + static::$resolvedBuilderClasses = []; + } +} + +/** + * @template TModel of Model + * @extends Builder + */ +class CustomTestBuilder extends Builder +{ +} + +/** + * @template TModel of Model + * @extends Builder + */ +class AnotherCustomTestBuilder extends Builder +{ +}