Skip to content
Merged
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
33 changes: 33 additions & 0 deletions src/core/src/Database/Eloquent/Attributes/UseEloquentBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Hypervel\Database\Eloquent\Attributes;

use Attribute;

/**
* Declare the Eloquent builder class for a model using an attribute.
*
* When placed on a model class, the model will use the specified builder
* class when creating new query builder instances via newModelBuilder().
*
* @example
* ```php
* #[UseEloquentBuilder(UserBuilder::class)]
* class User extends Model {}
* ```
*/
#[Attribute(Attribute::TARGET_CLASS)]
class UseEloquentBuilder
{
/**
* Create a new attribute instance.
*
* @param class-string<\Hypervel\Database\Eloquent\Builder<\Hypervel\Database\Eloquent\Model>> $builderClass
*/
public function __construct(
public string $builderClass,
) {
}
}
39 changes: 38 additions & 1 deletion src/core/src/Database/Eloquent/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<int, static> all(array|string $columns = ['*'])
Expand Down Expand Up @@ -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<static>, class-string<Builder<static>>|false>
*/
protected static array $resolvedBuilderClasses = [];

protected ?string $connection = null;

public function resolveRouteBinding($value)
Expand All @@ -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<static>
*/
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<static>>|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<array-key, static> $models
* @return \Hypervel\Database\Eloquent\Collection<array-key, static>
Expand Down
205 changes: 205 additions & 0 deletions tests/Core/Database/Eloquent/UseEloquentBuilderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
<?php

declare(strict_types=1);

namespace Hypervel\Tests\Core\Database\Eloquent;

use Hypervel\Database\Eloquent\Attributes\UseEloquentBuilder;
use Hypervel\Database\Eloquent\Builder;
use Hypervel\Database\Eloquent\Model;
use Hypervel\Testbench\TestCase;
use Mockery as m;

/**
* @internal
* @coversNothing
*/
class UseEloquentBuilderTest extends TestCase
{
protected function tearDown(): void
{
// Clear the static cache between tests
UseEloquentBuilderTestModel::clearResolvedBuilderClasses();
UseEloquentBuilderTestModelWithAttribute::clearResolvedBuilderClasses();
UseEloquentBuilderTestChildModel::clearResolvedBuilderClasses();
UseEloquentBuilderTestChildModelWithOwnAttribute::clearResolvedBuilderClasses();

parent::tearDown();
}

public function testNewModelBuilderReturnsDefaultBuilderWhenNoAttribute(): void
{
$model = new UseEloquentBuilderTestModel();
$query = m::mock(\Hypervel\Database\Query\Builder::class);

$builder = $model->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<TModel>
*/
class CustomTestBuilder extends Builder
{
}

/**
* @template TModel of Model
* @extends Builder<TModel>
*/
class AnotherCustomTestBuilder extends Builder
{
}