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
33 changes: 33 additions & 0 deletions src/core/src/Database/Eloquent/Attributes/CollectedBy.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 collection class for a model using an attribute.
*
* When placed on a model class, the model will use the specified collection
* class when creating new collection instances via newCollection().
*
* @example
* ```php
* #[CollectedBy(CustomCollection::class)]
* class User extends Model {}
* ```
*/
#[Attribute(Attribute::TARGET_CLASS)]
class CollectedBy
{
/**
* Create a new attribute instance.
*
* @param class-string<\Hypervel\Database\Eloquent\Collection<array-key, \Hypervel\Database\Eloquent\Model>> $collectionClass
*/
public function __construct(
public string $collectionClass,
) {
}
}
57 changes: 57 additions & 0 deletions src/core/src/Database/Eloquent/Concerns/HasCollection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace Hypervel\Database\Eloquent\Concerns;

use Hypervel\Database\Eloquent\Attributes\CollectedBy;
use Hypervel\Database\Eloquent\Collection;
use ReflectionClass;

/**
* Provides support for the CollectedBy attribute on models.
*
* This trait allows models to declare their collection class using the
* #[CollectedBy] attribute instead of overriding the newCollection method.
*/
trait HasCollection
{
/**
* The resolved collection class names by model.
*
* @var array<class-string<static>, class-string<Collection<array-key, static>>>
*/
protected static array $resolvedCollectionClasses = [];

/**
* Create a new Eloquent Collection instance.
*
* @param array<array-key, static> $models
* @return Collection<array-key, static>
*/
public function newCollection(array $models = []): Collection
{
static::$resolvedCollectionClasses[static::class] ??= ($this->resolveCollectionFromAttribute() ?? Collection::class);

return new static::$resolvedCollectionClasses[static::class]($models);
}

/**
* Resolve the collection class name from the CollectedBy attribute.
*
* @return null|class-string<Collection<array-key, static>>
*/
protected function resolveCollectionFromAttribute(): ?string
{
$reflectionClass = new ReflectionClass(static::class);

$attributes = $reflectionClass->getAttributes(CollectedBy::class);

if (! isset($attributes[0])) {
return null;
}

// @phpstan-ignore return.type (attribute stores generic Model type, but we know it's compatible with static)
return $attributes[0]->newInstance()->collectionClass;
}
}
13 changes: 3 additions & 10 deletions src/core/src/Database/Eloquent/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Hypervel\Context\Context;
use Hypervel\Database\Eloquent\Concerns\HasAttributes;
use Hypervel\Database\Eloquent\Concerns\HasCallbacks;
use Hypervel\Database\Eloquent\Concerns\HasCollection;
use Hypervel\Database\Eloquent\Concerns\HasObservers;
use Hypervel\Database\Eloquent\Concerns\HasRelations;
use Hypervel\Database\Eloquent\Concerns\HasRelationships;
Expand Down Expand Up @@ -68,9 +69,10 @@ abstract class Model extends BaseModel implements UrlRoutable, HasBroadcastChann
{
use HasAttributes;
use HasCallbacks;
use HasCollection;
use HasObservers;
use HasRelations;
use HasRelationships;
use HasObservers;

protected ?string $connection = null;

Expand All @@ -89,15 +91,6 @@ public function newModelBuilder($query)
return new Builder($query);
}

/**
* @param array<array-key, static> $models
* @return \Hypervel\Database\Eloquent\Collection<array-key, static>
*/
public function newCollection(array $models = [])
{
return new Collection($models);
}

public function broadcastChannelRoute(): string
{
return str_replace('\\', '.', get_class($this)) . '.{' . Str::camel(class_basename($this)) . '}';
Expand Down
212 changes: 212 additions & 0 deletions tests/Core/Database/Eloquent/Concerns/HasCollectionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
<?php

declare(strict_types=1);

namespace Hypervel\Tests\Core\Database\Eloquent\Concerns;

use Hypervel\Database\Eloquent\Attributes\CollectedBy;
use Hypervel\Database\Eloquent\Collection;
use Hypervel\Database\Eloquent\Model;
use Hypervel\Testbench\TestCase;

/**
* @internal
* @coversNothing
*/
class HasCollectionTest extends TestCase
{
protected function tearDown(): void
{
// Clear the static cache between tests
HasCollectionTestModel::clearResolvedCollectionClasses();
HasCollectionTestModelWithAttribute::clearResolvedCollectionClasses();
HasCollectionTestChildModel::clearResolvedCollectionClasses();
HasCollectionTestChildModelWithOwnAttribute::clearResolvedCollectionClasses();

parent::tearDown();
}

public function testNewCollectionReturnsDefaultCollectionWhenNoAttribute(): void
{
$model = new HasCollectionTestModel();

$collection = $model->newCollection([]);

$this->assertInstanceOf(Collection::class, $collection);
$this->assertNotInstanceOf(CustomTestCollection::class, $collection);
}

public function testNewCollectionReturnsCustomCollectionWhenAttributePresent(): void
{
$model = new HasCollectionTestModelWithAttribute();

$collection = $model->newCollection([]);

$this->assertInstanceOf(CustomTestCollection::class, $collection);
}

public function testNewCollectionPassesModelsToCollection(): void
{
$model1 = new HasCollectionTestModel();
$model2 = new HasCollectionTestModel();

$collection = $model1->newCollection([$model1, $model2]);

$this->assertCount(2, $collection);
$this->assertSame($model1, $collection[0]);
$this->assertSame($model2, $collection[1]);
}

public function testNewCollectionCachesResolvedCollectionClass(): void
{
$model1 = new HasCollectionTestModelWithAttribute();
$model2 = new HasCollectionTestModelWithAttribute();

// First call should resolve and cache
$collection1 = $model1->newCollection([]);

// Second call should use cache
$collection2 = $model2->newCollection([]);

// Both should be CustomTestCollection
$this->assertInstanceOf(CustomTestCollection::class, $collection1);
$this->assertInstanceOf(CustomTestCollection::class, $collection2);
}

public function testResolveCollectionFromAttributeReturnsNullWhenNoAttribute(): void
{
$model = new HasCollectionTestModel();

$result = $model->testResolveCollectionFromAttribute();

$this->assertNull($result);
}

public function testResolveCollectionFromAttributeReturnsCollectionClassWhenAttributePresent(): void
{
$model = new HasCollectionTestModelWithAttribute();

$result = $model->testResolveCollectionFromAttribute();

$this->assertSame(CustomTestCollection::class, $result);
}

public function testDifferentModelsUseDifferentCaches(): void
{
$modelWithoutAttribute = new HasCollectionTestModel();
$modelWithAttribute = new HasCollectionTestModelWithAttribute();

$collection1 = $modelWithoutAttribute->newCollection([]);
$collection2 = $modelWithAttribute->newCollection([]);

$this->assertInstanceOf(Collection::class, $collection1);
$this->assertNotInstanceOf(CustomTestCollection::class, $collection1);
$this->assertInstanceOf(CustomTestCollection::class, $collection2);
}

public function testChildModelWithoutAttributeUsesDefaultCollection(): void
{
$model = new HasCollectionTestChildModel();

$collection = $model->newCollection([]);

// PHP attributes are not inherited - child needs its own attribute
$this->assertInstanceOf(Collection::class, $collection);
$this->assertNotInstanceOf(CustomTestCollection::class, $collection);
}

public function testChildModelWithOwnAttributeUsesOwnCollection(): void
{
$model = new HasCollectionTestChildModelWithOwnAttribute();

$collection = $model->newCollection([]);

$this->assertInstanceOf(AnotherCustomTestCollection::class, $collection);
}
}

// Test fixtures

class HasCollectionTestModel extends Model
{
protected ?string $table = 'test_models';

/**
* Expose protected method for testing.
*/
public function testResolveCollectionFromAttribute(): ?string
{
return $this->resolveCollectionFromAttribute();
}

/**
* Clear the static cache for testing.
*/
public static function clearResolvedCollectionClasses(): void
{
static::$resolvedCollectionClasses = [];
}
}

#[CollectedBy(CustomTestCollection::class)]
class HasCollectionTestModelWithAttribute extends Model
{
protected ?string $table = 'test_models';

/**
* Expose protected method for testing.
*/
public function testResolveCollectionFromAttribute(): ?string
{
return $this->resolveCollectionFromAttribute();
}

/**
* Clear the static cache for testing.
*/
public static function clearResolvedCollectionClasses(): void
{
static::$resolvedCollectionClasses = [];
}
}

class HasCollectionTestChildModel extends HasCollectionTestModelWithAttribute
{
/**
* Clear the static cache for testing.
*/
public static function clearResolvedCollectionClasses(): void
{
static::$resolvedCollectionClasses = [];
}
}

#[CollectedBy(AnotherCustomTestCollection::class)]
class HasCollectionTestChildModelWithOwnAttribute extends HasCollectionTestModelWithAttribute
{
/**
* Clear the static cache for testing.
*/
public static function clearResolvedCollectionClasses(): void
{
static::$resolvedCollectionClasses = [];
}
}

/**
* @template TKey of array-key
* @template TModel of Model
* @extends Collection<TKey, TModel>
*/
class CustomTestCollection extends Collection
{
}

/**
* @template TKey of array-key
* @template TModel of Model
* @extends Collection<TKey, TModel>
*/
class AnotherCustomTestCollection extends Collection
{
}