From f4d8c99ad436ccb0f66ecfeb76e8715fcafc75db Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 30 Dec 2025 18:35:01 +0000 Subject: [PATCH 1/2] Add #[ObservedBy] attribute support - Add ObservedBy attribute for declaring observers on model classes - Add bootHasObservers() and resolveObserveAttributes() to HasObservers trait - Support inheritance: observers on parent classes apply to children - Add HasObservers trait to Pivot and MorphPivot (enables ::observe() on pivot models) - Add comprehensive tests for all scenarios --- .../Eloquent/Attributes/ObservedBy.php | 37 +++ .../Eloquent/Concerns/HasObservers.php | 47 +++ .../Eloquent/Relations/MorphPivot.php | 2 + .../src/Database/Eloquent/Relations/Pivot.php | 2 + .../Eloquent/Concerns/HasObserversTest.php | 285 ++++++++++++++++++ 5 files changed, 373 insertions(+) create mode 100644 src/core/src/Database/Eloquent/Attributes/ObservedBy.php create mode 100644 tests/Core/Database/Eloquent/Concerns/HasObserversTest.php diff --git a/src/core/src/Database/Eloquent/Attributes/ObservedBy.php b/src/core/src/Database/Eloquent/Attributes/ObservedBy.php new file mode 100644 index 000000000..1a33f8142 --- /dev/null +++ b/src/core/src/Database/Eloquent/Attributes/ObservedBy.php @@ -0,0 +1,37 @@ + + */ + public static function resolveObserveAttributes(): array + { + $reflectionClass = new ReflectionClass(static::class); + + $parentClass = get_parent_class(static::class); + $hasParentWithTrait = $parentClass + && $parentClass !== HyperfModel::class + && method_exists($parentClass, 'resolveObserveAttributes'); + + return (new Collection($reflectionClass->getAttributes(ObservedBy::class))) + ->map(fn ($attribute) => $attribute->getArguments()) + ->flatten() + ->when($hasParentWithTrait, function (Collection $attributes) use ($parentClass) { + /** @var class-string $parentClass */ + return (new Collection($parentClass::resolveObserveAttributes())) + ->merge($attributes); + }) + ->all(); + } + /** * Register observers with the model. * diff --git a/src/core/src/Database/Eloquent/Relations/MorphPivot.php b/src/core/src/Database/Eloquent/Relations/MorphPivot.php index 340221468..34b3b952a 100644 --- a/src/core/src/Database/Eloquent/Relations/MorphPivot.php +++ b/src/core/src/Database/Eloquent/Relations/MorphPivot.php @@ -5,7 +5,9 @@ namespace Hypervel\Database\Eloquent\Relations; use Hyperf\Database\Model\Relations\MorphPivot as BaseMorphPivot; +use Hypervel\Database\Eloquent\Concerns\HasObservers; class MorphPivot extends BaseMorphPivot { + use HasObservers; } diff --git a/src/core/src/Database/Eloquent/Relations/Pivot.php b/src/core/src/Database/Eloquent/Relations/Pivot.php index 0ef2639b1..fc1583456 100644 --- a/src/core/src/Database/Eloquent/Relations/Pivot.php +++ b/src/core/src/Database/Eloquent/Relations/Pivot.php @@ -5,7 +5,9 @@ namespace Hypervel\Database\Eloquent\Relations; use Hyperf\Database\Model\Relations\Pivot as BasePivot; +use Hypervel\Database\Eloquent\Concerns\HasObservers; class Pivot extends BasePivot { + use HasObservers; } diff --git a/tests/Core/Database/Eloquent/Concerns/HasObserversTest.php b/tests/Core/Database/Eloquent/Concerns/HasObserversTest.php new file mode 100644 index 000000000..e22d79fb8 --- /dev/null +++ b/tests/Core/Database/Eloquent/Concerns/HasObserversTest.php @@ -0,0 +1,285 @@ +assertSame([], $result); + } + + public function testResolveObserveAttributesReturnsSingleObserver(): void + { + $result = ModelWithSingleObserver::resolveObserveAttributes(); + + $this->assertSame([SingleObserver::class], $result); + } + + public function testResolveObserveAttributesReturnsMultipleObserversFromArray(): void + { + $result = ModelWithMultipleObserversInArray::resolveObserveAttributes(); + + $this->assertSame([FirstObserver::class, SecondObserver::class], $result); + } + + public function testResolveObserveAttributesReturnsMultipleObserversFromRepeatableAttribute(): void + { + $result = ModelWithRepeatableObservedBy::resolveObserveAttributes(); + + $this->assertSame([FirstObserver::class, SecondObserver::class], $result); + } + + public function testResolveObserveAttributesInheritsFromParentClass(): void + { + $result = ChildModelWithOwnObserver::resolveObserveAttributes(); + + // Parent's observer comes first, then child's + $this->assertSame([ParentObserver::class, ChildObserver::class], $result); + } + + public function testResolveObserveAttributesInheritsFromParentWhenChildHasNoAttributes(): void + { + $result = ChildModelWithoutOwnObserver::resolveObserveAttributes(); + + $this->assertSame([ParentObserver::class], $result); + } + + public function testResolveObserveAttributesInheritsFromGrandparent(): void + { + $result = GrandchildModel::resolveObserveAttributes(); + + // Should have grandparent's, parent's, and own observer + $this->assertSame([ParentObserver::class, MiddleObserver::class, GrandchildObserver::class], $result); + } + + public function testResolveObserveAttributesDoesNotInheritFromModelBaseClass(): void + { + // Models that directly extend Model should not try to resolve + // parent attributes since Model itself has no ObservedBy attribute + $result = ModelWithSingleObserver::resolveObserveAttributes(); + + $this->assertSame([SingleObserver::class], $result); + } + + public function testBootHasObserversRegistersObservers(): void + { + $container = m::mock(ContainerInterface::class); + $container->shouldReceive('get') + ->with(SingleObserver::class) + ->once() + ->andReturn(new SingleObserver()); + + $listener = m::mock(ModelListener::class); + $listener->shouldReceive('getModelEvents') + ->once() + ->andReturn([ + 'created' => Created::class, + 'updated' => Updated::class, + ]); + $listener->shouldReceive('register') + ->once() + ->with(ModelWithSingleObserver::class, 'created', m::type('callable')); + + $manager = new ObserverManager($container, $listener); + + // Simulate what bootHasObservers does + $observers = ModelWithSingleObserver::resolveObserveAttributes(); + foreach ($observers as $observer) { + $manager->register(ModelWithSingleObserver::class, $observer); + } + + $this->assertCount(1, $manager->getObservers(ModelWithSingleObserver::class)); + } + + public function testBootHasObserversDoesNothingWhenNoObservers(): void + { + // This test verifies the empty check in bootHasObservers + $result = ModelWithoutObservedBy::resolveObserveAttributes(); + + $this->assertEmpty($result); + } + + public function testPivotModelSupportsObservedByAttribute(): void + { + $result = PivotWithObserver::resolveObserveAttributes(); + + $this->assertSame([PivotObserver::class], $result); + } + + public function testPivotModelInheritsObserversFromParent(): void + { + $result = ChildPivotWithObserver::resolveObserveAttributes(); + + // Parent's observer comes first, then child's + $this->assertSame([PivotObserver::class, ChildPivotObserver::class], $result); + } + + public function testMorphPivotModelSupportsObservedByAttribute(): void + { + $result = MorphPivotWithObserver::resolveObserveAttributes(); + + $this->assertSame([MorphPivotObserver::class], $result); + } +} + +// Test observer classes +class SingleObserver +{ + public function created(Model $model): void + { + } +} + +class FirstObserver +{ + public function created(Model $model): void + { + } +} + +class SecondObserver +{ + public function created(Model $model): void + { + } +} + +class ParentObserver +{ + public function created(Model $model): void + { + } +} + +class ChildObserver +{ + public function created(Model $model): void + { + } +} + +class MiddleObserver +{ + public function created(Model $model): void + { + } +} + +class GrandchildObserver +{ + public function created(Model $model): void + { + } +} + +// Test model classes +class ModelWithoutObservedBy extends Model +{ + protected ?string $table = 'test_models'; +} + +#[ObservedBy(SingleObserver::class)] +class ModelWithSingleObserver extends Model +{ + protected ?string $table = 'test_models'; +} + +#[ObservedBy([FirstObserver::class, SecondObserver::class])] +class ModelWithMultipleObserversInArray extends Model +{ + protected ?string $table = 'test_models'; +} + +#[ObservedBy(FirstObserver::class)] +#[ObservedBy(SecondObserver::class)] +class ModelWithRepeatableObservedBy extends Model +{ + protected ?string $table = 'test_models'; +} + +// Inheritance test models +#[ObservedBy(ParentObserver::class)] +class ParentModelWithObserver extends Model +{ + protected ?string $table = 'test_models'; +} + +#[ObservedBy(ChildObserver::class)] +class ChildModelWithOwnObserver extends ParentModelWithObserver +{ +} + +class ChildModelWithoutOwnObserver extends ParentModelWithObserver +{ +} + +#[ObservedBy(MiddleObserver::class)] +class MiddleModel extends ParentModelWithObserver +{ +} + +#[ObservedBy(GrandchildObserver::class)] +class GrandchildModel extends MiddleModel +{ +} + +// Pivot test observers +class PivotObserver +{ + public function created(Pivot $pivot): void + { + } +} + +class ChildPivotObserver +{ + public function created(Pivot $pivot): void + { + } +} + +class MorphPivotObserver +{ + public function created(MorphPivot $pivot): void + { + } +} + +// Pivot test models +#[ObservedBy(PivotObserver::class)] +class PivotWithObserver extends Pivot +{ + protected ?string $table = 'test_pivots'; +} + +#[ObservedBy(ChildPivotObserver::class)] +class ChildPivotWithObserver extends PivotWithObserver +{ +} + +#[ObservedBy(MorphPivotObserver::class)] +class MorphPivotWithObserver extends MorphPivot +{ + protected ?string $table = 'test_morph_pivots'; +} From af4f69eb8cc35cb96e7d411f6a7beff39633f9a9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 31 Dec 2025 01:25:44 +0000 Subject: [PATCH 2/2] Add trait support for ObservedBy attribute resolution Extends resolveObserveAttributes() to collect #[ObservedBy] attributes from traits in addition to parent classes and the class itself. The resolution order is: parent class observers -> trait observers -> class observers. This allows traits to declare their own observers that will be automatically registered on any model using the trait, e.g.: #[ObservedBy(AuditObserver::class)] trait Auditable { } class Invoice extends Model { use Auditable; // Automatically gets AuditObserver } --- .../Eloquent/Concerns/HasObservers.php | 34 ++++- .../Eloquent/Concerns/HasObserversTest.php | 144 ++++++++++++++++++ 2 files changed, 170 insertions(+), 8 deletions(-) diff --git a/src/core/src/Database/Eloquent/Concerns/HasObservers.php b/src/core/src/Database/Eloquent/Concerns/HasObservers.php index 150e28ae0..9a5eb89e1 100644 --- a/src/core/src/Database/Eloquent/Concerns/HasObservers.php +++ b/src/core/src/Database/Eloquent/Concerns/HasObservers.php @@ -10,6 +10,7 @@ use Hypervel\Context\ApplicationContext; use Hypervel\Database\Eloquent\Attributes\ObservedBy; use Hypervel\Database\Eloquent\ObserverManager; +use ReflectionAttribute; use ReflectionClass; use RuntimeException; @@ -32,9 +33,9 @@ public static function bootHasObservers(): void /** * Resolve the observer class names from the ObservedBy attributes. * - * Collects ObservedBy attributes from the current class and all parent - * classes (excluding the base Model class), merging them together so - * that observers declared on parent classes are inherited by children. + * Collects ObservedBy attributes from parent classes, traits, and the + * current class itself, merging them together. The order is: + * parent class observers -> trait observers -> class observers. * * @return array */ @@ -47,13 +48,30 @@ public static function resolveObserveAttributes(): array && $parentClass !== HyperfModel::class && method_exists($parentClass, 'resolveObserveAttributes'); - return (new Collection($reflectionClass->getAttributes(ObservedBy::class))) - ->map(fn ($attribute) => $attribute->getArguments()) - ->flatten() - ->when($hasParentWithTrait, function (Collection $attributes) use ($parentClass) { + // Collect attributes from traits, then from the class itself + $attributes = new Collection(); + + foreach ($reflectionClass->getTraits() as $trait) { + foreach ($trait->getAttributes(ObservedBy::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) { + $attributes->push($attribute); + } + } + + foreach ($reflectionClass->getAttributes(ObservedBy::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) { + $attributes->push($attribute); + } + + // Process all collected attributes + $observers = $attributes + ->map(fn (ReflectionAttribute $attribute) => $attribute->getArguments()) + ->flatten(); + + // Prepend parent's observers if applicable + return $observers + ->when($hasParentWithTrait, function (Collection $attrs) use ($parentClass) { /** @var class-string $parentClass */ return (new Collection($parentClass::resolveObserveAttributes())) - ->merge($attributes); + ->merge($attrs); }) ->all(); } diff --git a/tests/Core/Database/Eloquent/Concerns/HasObserversTest.php b/tests/Core/Database/Eloquent/Concerns/HasObserversTest.php index e22d79fb8..206545f80 100644 --- a/tests/Core/Database/Eloquent/Concerns/HasObserversTest.php +++ b/tests/Core/Database/Eloquent/Concerns/HasObserversTest.php @@ -141,6 +141,54 @@ public function testMorphPivotModelSupportsObservedByAttribute(): void $this->assertSame([MorphPivotObserver::class], $result); } + + public function testResolveObserveAttributesCollectsFromTrait(): void + { + $result = ModelUsingTraitWithObserver::resolveObserveAttributes(); + + $this->assertSame([TraitObserver::class], $result); + } + + public function testResolveObserveAttributesCollectsMultipleObserversFromTrait(): void + { + $result = ModelUsingTraitWithMultipleObservers::resolveObserveAttributes(); + + $this->assertSame([TraitFirstObserver::class, TraitSecondObserver::class], $result); + } + + public function testResolveObserveAttributesCollectsFromMultipleTraits(): void + { + $result = ModelUsingMultipleTraitsWithObservers::resolveObserveAttributes(); + + // Both traits' observers should be collected + $this->assertSame([TraitObserver::class, AnotherTraitObserver::class], $result); + } + + public function testResolveObserveAttributesMergesTraitAndClassObservers(): void + { + $result = ModelWithTraitAndOwnObserver::resolveObserveAttributes(); + + // Trait observers come first, then class observers + $this->assertSame([TraitObserver::class, SingleObserver::class], $result); + } + + public function testResolveObserveAttributesMergesParentTraitAndChildObservers(): void + { + $result = ChildModelWithTraitParent::resolveObserveAttributes(); + + // Parent's trait observer -> child's class observer + $this->assertSame([TraitObserver::class, ChildObserver::class], $result); + } + + public function testResolveObserveAttributesCorrectOrderWithParentTraitsAndChild(): void + { + $result = ChildModelWithAllSources::resolveObserveAttributes(); + + // Order: parent class -> parent trait -> child trait -> child class + // ParentModelWithObserver has ParentObserver + // ChildModelWithAllSources uses TraitWithObserver (TraitObserver) and has ChildObserver + $this->assertSame([ParentObserver::class, TraitObserver::class, ChildObserver::class], $result); + } } // Test observer classes @@ -283,3 +331,99 @@ class MorphPivotWithObserver extends MorphPivot { protected ?string $table = 'test_morph_pivots'; } + +// Trait test observers +class TraitObserver +{ + public function created(Model $model): void + { + } +} + +class TraitFirstObserver +{ + public function created(Model $model): void + { + } +} + +class TraitSecondObserver +{ + public function created(Model $model): void + { + } +} + +class AnotherTraitObserver +{ + public function created(Model $model): void + { + } +} + +// Traits with ObservedBy attributes +#[ObservedBy(TraitObserver::class)] +trait TraitWithObserver +{ +} + +#[ObservedBy([TraitFirstObserver::class, TraitSecondObserver::class])] +trait TraitWithMultipleObservers +{ +} + +#[ObservedBy(AnotherTraitObserver::class)] +trait AnotherTraitWithObserver +{ +} + +// Models using traits with observers +class ModelUsingTraitWithObserver extends Model +{ + use TraitWithObserver; + + protected ?string $table = 'test_models'; +} + +class ModelUsingTraitWithMultipleObservers extends Model +{ + use TraitWithMultipleObservers; + + protected ?string $table = 'test_models'; +} + +class ModelUsingMultipleTraitsWithObservers extends Model +{ + use TraitWithObserver; + use AnotherTraitWithObserver; + + protected ?string $table = 'test_models'; +} + +#[ObservedBy(SingleObserver::class)] +class ModelWithTraitAndOwnObserver extends Model +{ + use TraitWithObserver; + + protected ?string $table = 'test_models'; +} + +// Parent model that uses a trait with observer +class ParentModelUsingTrait extends Model +{ + use TraitWithObserver; + + protected ?string $table = 'test_models'; +} + +#[ObservedBy(ChildObserver::class)] +class ChildModelWithTraitParent extends ParentModelUsingTrait +{ +} + +// Child model with parent class observer, own trait, and own observer +#[ObservedBy(ChildObserver::class)] +class ChildModelWithAllSources extends ParentModelWithObserver +{ + use TraitWithObserver; +}