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 fb6c06e3..7c223ccd 100644 --- a/src/core/src/Database/Eloquent/Model.php +++ b/src/core/src/Database/Eloquent/Model.php @@ -12,6 +12,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; @@ -76,6 +77,7 @@ abstract class Model extends BaseModel implements UrlRoutable, HasBroadcastChann use HasAttributes; use HasBootableTraits; use HasCallbacks; + use HasCollection; use HasGlobalScopes; use HasLocalScopes; use HasObservers; @@ -84,6 +86,16 @@ abstract class Model extends BaseModel implements UrlRoutable, HasBroadcastChann use HasTimestamps; 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; + /** * The resolved builder class names by model. * diff --git a/src/core/src/Database/Eloquent/Relations/MorphPivot.php b/src/core/src/Database/Eloquent/Relations/MorphPivot.php index cdfa47a3..eaa021eb 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 Hypervel\Database\Eloquent\Concerns\HasTimestamps; @@ -16,10 +18,21 @@ class MorphPivot extends BaseMorphPivot { use HasAttributes; use HasCallbacks; + use HasCollection; use HasGlobalScopes; use HasObservers; use HasTimestamps; + /** + * 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 085b717e..c5d74fdf 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 Hypervel\Database\Eloquent\Concerns\HasTimestamps; @@ -16,10 +18,21 @@ class Pivot extends BasePivot { use HasAttributes; use HasCallbacks; + use HasCollection; use HasGlobalScopes; use HasObservers; use HasTimestamps; + /** + * 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 +{ +}