diff --git a/src/core/src/Database/Eloquent/Attributes/CollectedBy.php b/src/core/src/Database/Eloquent/Attributes/CollectedBy.php new file mode 100644 index 00000000..a7dcbbf0 --- /dev/null +++ b/src/core/src/Database/Eloquent/Attributes/CollectedBy.php @@ -0,0 +1,33 @@ +> $collectionClass + */ + public function __construct( + public string $collectionClass, + ) { + } +} diff --git a/src/core/src/Database/Eloquent/Collection.php b/src/core/src/Database/Eloquent/Collection.php index 5533fd68..a3dbeead 100644 --- a/src/core/src/Database/Eloquent/Collection.php +++ b/src/core/src/Database/Eloquent/Collection.php @@ -11,7 +11,7 @@ /** * @template TKey of array-key - * @template TModel of \Hypervel\Database\Eloquent\Model + * @template TModel of \Hyperf\Database\Model\Model * * @extends \Hyperf\Database\Model\Collection * diff --git a/src/core/src/Database/Eloquent/Concerns/HasCollection.php b/src/core/src/Database/Eloquent/Concerns/HasCollection.php new file mode 100644 index 00000000..bb3aff66 --- /dev/null +++ b/src/core/src/Database/Eloquent/Concerns/HasCollection.php @@ -0,0 +1,60 @@ +> + */ + protected static array $resolvedCollectionClasses = []; + + /** + * Create a new Eloquent Collection instance. + * + * @param array $models + * @return \Hypervel\Database\Eloquent\Collection + */ + public function newCollection(array $models = []): Collection + { + static::$resolvedCollectionClasses[static::class] ??= ($this->resolveCollectionFromAttribute() ?? static::$collectionClass); + + return new static::$resolvedCollectionClasses[static::class]($models); + } + + /** + * Resolve the collection class name from the CollectedBy attribute. + * + * @return null|class-string + */ + 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; + } +} diff --git a/src/core/src/Database/Eloquent/Model.php b/src/core/src/Database/Eloquent/Model.php index 0a627ed0..7bceee94 100644 --- a/src/core/src/Database/Eloquent/Model.php +++ b/src/core/src/Database/Eloquent/Model.php @@ -11,6 +11,7 @@ use Hypervel\Database\Eloquent\Concerns\HasAttributes; use Hypervel\Database\Eloquent\Concerns\HasBootableTraits; use Hypervel\Database\Eloquent\Concerns\HasCallbacks; +use Hypervel\Database\Eloquent\Concerns\HasCollection; use Hypervel\Database\Eloquent\Concerns\HasGlobalScopes; use Hypervel\Database\Eloquent\Concerns\HasLocalScopes; use Hypervel\Database\Eloquent\Concerns\HasObservers; @@ -73,6 +74,7 @@ abstract class Model extends BaseModel implements UrlRoutable, HasBroadcastChann use HasAttributes; use HasBootableTraits; use HasCallbacks; + use HasCollection; use HasGlobalScopes; use HasLocalScopes; use HasObservers; @@ -80,6 +82,16 @@ abstract class Model extends BaseModel implements UrlRoutable, HasBroadcastChann use HasRelationships; use TransformsToResource; + /** + * The default collection class for this model. + * + * Override this property to use a custom collection class. Alternatively, + * use the #[CollectedBy] attribute for a more declarative approach. + * + * @var class-string + */ + protected static string $collectionClass = Collection::class; + protected ?string $connection = null; public function resolveRouteBinding($value) @@ -97,15 +109,6 @@ public function newModelBuilder($query) return new Builder($query); } - /** - * @param array $models - * @return \Hypervel\Database\Eloquent\Collection - */ - public function newCollection(array $models = []) - { - return new Collection($models); - } - public function broadcastChannelRoute(): string { return str_replace('\\', '.', get_class($this)) . '.{' . Str::camel(class_basename($this)) . '}'; diff --git a/src/core/src/Database/Eloquent/Relations/MorphPivot.php b/src/core/src/Database/Eloquent/Relations/MorphPivot.php index ac4a89b1..f0366e8b 100644 --- a/src/core/src/Database/Eloquent/Relations/MorphPivot.php +++ b/src/core/src/Database/Eloquent/Relations/MorphPivot.php @@ -5,8 +5,10 @@ namespace Hypervel\Database\Eloquent\Relations; use Hyperf\DbConnection\Model\Relations\MorphPivot as BaseMorphPivot; +use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Concerns\HasAttributes; use Hypervel\Database\Eloquent\Concerns\HasCallbacks; +use Hypervel\Database\Eloquent\Concerns\HasCollection; use Hypervel\Database\Eloquent\Concerns\HasGlobalScopes; use Hypervel\Database\Eloquent\Concerns\HasObservers; use Psr\EventDispatcher\StoppableEventInterface; @@ -15,9 +17,20 @@ class MorphPivot extends BaseMorphPivot { use HasAttributes; use HasCallbacks; + use HasCollection; use HasGlobalScopes; use HasObservers; + /** + * The default collection class for this model. + * + * Override this property to use a custom collection class. Alternatively, + * use the #[CollectedBy] attribute for a more declarative approach. + * + * @var class-string + */ + protected static string $collectionClass = Collection::class; + /** * Delete the pivot model record from the database. * diff --git a/src/core/src/Database/Eloquent/Relations/Pivot.php b/src/core/src/Database/Eloquent/Relations/Pivot.php index 47d9468a..0741637c 100644 --- a/src/core/src/Database/Eloquent/Relations/Pivot.php +++ b/src/core/src/Database/Eloquent/Relations/Pivot.php @@ -5,8 +5,10 @@ namespace Hypervel\Database\Eloquent\Relations; use Hyperf\DbConnection\Model\Relations\Pivot as BasePivot; +use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Concerns\HasAttributes; use Hypervel\Database\Eloquent\Concerns\HasCallbacks; +use Hypervel\Database\Eloquent\Concerns\HasCollection; use Hypervel\Database\Eloquent\Concerns\HasGlobalScopes; use Hypervel\Database\Eloquent\Concerns\HasObservers; use Psr\EventDispatcher\StoppableEventInterface; @@ -15,9 +17,20 @@ class Pivot extends BasePivot { use HasAttributes; use HasCallbacks; + use HasCollection; use HasGlobalScopes; use HasObservers; + /** + * The default collection class for this model. + * + * Override this property to use a custom collection class. Alternatively, + * use the #[CollectedBy] attribute for a more declarative approach. + * + * @var class-string + */ + protected static string $collectionClass = Collection::class; + /** * Delete the pivot model record from the database. * diff --git a/tests/Core/Database/Eloquent/Concerns/HasCollectionTest.php b/tests/Core/Database/Eloquent/Concerns/HasCollectionTest.php new file mode 100644 index 00000000..37fb8e6c --- /dev/null +++ b/tests/Core/Database/Eloquent/Concerns/HasCollectionTest.php @@ -0,0 +1,275 @@ +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); + } + + public function testNewCollectionUsesCollectionClassPropertyWhenNoAttribute(): void + { + $model = new HasCollectionTestModelWithProperty(); + + $collection = $model->newCollection([]); + + $this->assertInstanceOf(PropertyTestCollection::class, $collection); + } + + public function testAttributeTakesPrecedenceOverCollectionClassProperty(): void + { + $model = new HasCollectionTestModelWithAttributeAndProperty(); + + $collection = $model->newCollection([]); + + // Attribute should win over property + $this->assertInstanceOf(CustomTestCollection::class, $collection); + $this->assertNotInstanceOf(PropertyTestCollection::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 + */ +class CustomTestCollection extends Collection +{ +} + +/** + * @template TKey of array-key + * @template TModel of Model + * @extends Collection + */ +class AnotherCustomTestCollection extends Collection +{ +} + +class HasCollectionTestModelWithProperty extends Model +{ + protected ?string $table = 'test_models'; + + protected static string $collectionClass = PropertyTestCollection::class; + + /** + * Clear the static cache for testing. + */ + public static function clearResolvedCollectionClasses(): void + { + static::$resolvedCollectionClasses = []; + } +} + +#[CollectedBy(CustomTestCollection::class)] +class HasCollectionTestModelWithAttributeAndProperty extends Model +{ + protected ?string $table = 'test_models'; + + // Property should be ignored when attribute is present + protected static string $collectionClass = PropertyTestCollection::class; + + /** + * Clear the static cache for testing. + */ + public static function clearResolvedCollectionClasses(): void + { + static::$resolvedCollectionClasses = []; + } +} + +/** + * @template TKey of array-key + * @template TModel of Model + * @extends Collection + */ +class PropertyTestCollection extends Collection +{ +} diff --git a/tests/Core/Database/Eloquent/Relations/PivotCollectionTest.php b/tests/Core/Database/Eloquent/Relations/PivotCollectionTest.php new file mode 100644 index 00000000..7a9fb0f8 --- /dev/null +++ b/tests/Core/Database/Eloquent/Relations/PivotCollectionTest.php @@ -0,0 +1,256 @@ +newCollection([]); + + $this->assertInstanceOf(Collection::class, $collection); + } + + public function testPivotNewCollectionReturnsCustomCollectionWhenAttributePresent(): void + { + $pivot = new PivotCollectionTestPivotWithAttribute(); + + $collection = $pivot->newCollection([]); + + $this->assertInstanceOf(CustomPivotCollection::class, $collection); + } + + public function testPivotNewCollectionUsesCollectionClassPropertyWhenNoAttribute(): void + { + $pivot = new PivotCollectionTestPivotWithProperty(); + + $collection = $pivot->newCollection([]); + + $this->assertInstanceOf(PropertyPivotCollection::class, $collection); + } + + public function testPivotAttributeTakesPrecedenceOverCollectionClassProperty(): void + { + $pivot = new PivotCollectionTestPivotWithAttributeAndProperty(); + + $collection = $pivot->newCollection([]); + + // Attribute should win over property + $this->assertInstanceOf(CustomPivotCollection::class, $collection); + $this->assertNotInstanceOf(PropertyPivotCollection::class, $collection); + } + + public function testPivotNewCollectionPassesModelsToCollection(): void + { + $pivot1 = new PivotCollectionTestPivot(); + $pivot2 = new PivotCollectionTestPivot(); + + $collection = $pivot1->newCollection([$pivot1, $pivot2]); + + $this->assertCount(2, $collection); + $this->assertSame($pivot1, $collection[0]); + $this->assertSame($pivot2, $collection[1]); + } + + // ========================================================================= + // MorphPivot Tests + // ========================================================================= + + public function testMorphPivotNewCollectionReturnsHypervelCollectionByDefault(): void + { + $pivot = new PivotCollectionTestMorphPivot(); + + $collection = $pivot->newCollection([]); + + $this->assertInstanceOf(Collection::class, $collection); + } + + public function testMorphPivotNewCollectionReturnsCustomCollectionWhenAttributePresent(): void + { + $pivot = new PivotCollectionTestMorphPivotWithAttribute(); + + $collection = $pivot->newCollection([]); + + $this->assertInstanceOf(CustomMorphPivotCollection::class, $collection); + } + + public function testMorphPivotNewCollectionUsesCollectionClassPropertyWhenNoAttribute(): void + { + $pivot = new PivotCollectionTestMorphPivotWithProperty(); + + $collection = $pivot->newCollection([]); + + $this->assertInstanceOf(PropertyMorphPivotCollection::class, $collection); + } + + public function testMorphPivotAttributeTakesPrecedenceOverCollectionClassProperty(): void + { + $pivot = new PivotCollectionTestMorphPivotWithAttributeAndProperty(); + + $collection = $pivot->newCollection([]); + + // Attribute should win over property + $this->assertInstanceOf(CustomMorphPivotCollection::class, $collection); + $this->assertNotInstanceOf(PropertyMorphPivotCollection::class, $collection); + } +} + +// ========================================================================= +// Pivot Test Fixtures +// ========================================================================= + +class PivotCollectionTestPivot extends Pivot +{ + public static function clearResolvedCollectionClasses(): void + { + static::$resolvedCollectionClasses = []; + } +} + +#[CollectedBy(CustomPivotCollection::class)] +class PivotCollectionTestPivotWithAttribute extends Pivot +{ + public static function clearResolvedCollectionClasses(): void + { + static::$resolvedCollectionClasses = []; + } +} + +class PivotCollectionTestPivotWithProperty extends Pivot +{ + protected static string $collectionClass = PropertyPivotCollection::class; + + public static function clearResolvedCollectionClasses(): void + { + static::$resolvedCollectionClasses = []; + } +} + +#[CollectedBy(CustomPivotCollection::class)] +class PivotCollectionTestPivotWithAttributeAndProperty extends Pivot +{ + protected static string $collectionClass = PropertyPivotCollection::class; + + public static function clearResolvedCollectionClasses(): void + { + static::$resolvedCollectionClasses = []; + } +} + +// ========================================================================= +// MorphPivot Test Fixtures +// ========================================================================= + +class PivotCollectionTestMorphPivot extends MorphPivot +{ + public static function clearResolvedCollectionClasses(): void + { + static::$resolvedCollectionClasses = []; + } +} + +#[CollectedBy(CustomMorphPivotCollection::class)] +class PivotCollectionTestMorphPivotWithAttribute extends MorphPivot +{ + public static function clearResolvedCollectionClasses(): void + { + static::$resolvedCollectionClasses = []; + } +} + +class PivotCollectionTestMorphPivotWithProperty extends MorphPivot +{ + protected static string $collectionClass = PropertyMorphPivotCollection::class; + + public static function clearResolvedCollectionClasses(): void + { + static::$resolvedCollectionClasses = []; + } +} + +#[CollectedBy(CustomMorphPivotCollection::class)] +class PivotCollectionTestMorphPivotWithAttributeAndProperty extends MorphPivot +{ + protected static string $collectionClass = PropertyMorphPivotCollection::class; + + public static function clearResolvedCollectionClasses(): void + { + static::$resolvedCollectionClasses = []; + } +} + +// ========================================================================= +// Custom Collection Classes +// ========================================================================= + +/** + * @template TKey of array-key + * @template TModel + * @extends Collection + */ +class CustomPivotCollection extends Collection +{ +} + +/** + * @template TKey of array-key + * @template TModel + * @extends Collection + */ +class PropertyPivotCollection extends Collection +{ +} + +/** + * @template TKey of array-key + * @template TModel + * @extends Collection + */ +class CustomMorphPivotCollection extends Collection +{ +} + +/** + * @template TKey of array-key + * @template TModel + * @extends Collection + */ +class PropertyMorphPivotCollection extends Collection +{ +}