diff --git a/.gitignore b/.gitignore index 80c45d9f8..7e1625b0f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ composer.lock .phpunit.result.cache !tests/Foundation/fixtures/hyperf1/composer.lock tests/Http/fixtures +.env diff --git a/src/core/src/Database/Eloquent/Relations/BelongsToMany.php b/src/core/src/Database/Eloquent/Relations/BelongsToMany.php index 7ea4703d5..6cebfa606 100644 --- a/src/core/src/Database/Eloquent/Relations/BelongsToMany.php +++ b/src/core/src/Database/Eloquent/Relations/BelongsToMany.php @@ -6,6 +6,7 @@ use Closure; use Hyperf\Database\Model\Relations\BelongsToMany as BaseBelongsToMany; +use Hypervel\Database\Eloquent\Relations\Concerns\InteractsWithPivotTable; use Hypervel\Database\Eloquent\Relations\Concerns\WithoutAddConstraints; use Hypervel\Database\Eloquent\Relations\Contracts\Relation as RelationContract; @@ -44,6 +45,7 @@ */ class BelongsToMany extends BaseBelongsToMany implements RelationContract { + use InteractsWithPivotTable; use WithoutAddConstraints; /** diff --git a/src/core/src/Database/Eloquent/Relations/Concerns/InteractsWithPivotTable.php b/src/core/src/Database/Eloquent/Relations/Concerns/InteractsWithPivotTable.php new file mode 100644 index 000000000..2a1011561 --- /dev/null +++ b/src/core/src/Database/Eloquent/Relations/Concerns/InteractsWithPivotTable.php @@ -0,0 +1,184 @@ +using()`, operations like + * attach/detach/update use model methods (save/delete) instead of raw queries, + * enabling model events (creating, created, deleting, deleted, etc.) to fire. + * + * Without `->using()`, the parent's performant bulk query behavior is preserved. + */ +trait InteractsWithPivotTable +{ + /** + * Attach a model to the parent. + * + * @param mixed $id + * @param bool $touch + */ + public function attach($id, array $attributes = [], $touch = true) + { + if ($this->using) { + $this->attachUsingCustomClass($id, $attributes); + } else { + parent::attach($id, $attributes, $touch); + + return; + } + + if ($touch) { + $this->touchIfTouching(); + } + } + + /** + * Attach a model to the parent using a custom class. + * + * @param mixed $id + */ + protected function attachUsingCustomClass($id, array $attributes) + { + $records = $this->formatAttachRecords( + $this->parseIds($id), + $attributes + ); + + foreach ($records as $record) { + $this->newPivot($record, false)->save(); + } + } + + /** + * Detach models from the relationship. + * + * @param mixed $ids + * @param bool $touch + */ + public function detach($ids = null, $touch = true) + { + if ($this->using) { + $results = $this->detachUsingCustomClass($ids); + } else { + return parent::detach($ids, $touch); + } + + if ($touch) { + $this->touchIfTouching(); + } + + return $results; + } + + /** + * Detach models from the relationship using a custom class. + * + * @param mixed $ids + * @return int + */ + protected function detachUsingCustomClass($ids) + { + $results = 0; + + $pivots = $this->getCurrentlyAttachedPivots($ids); + + foreach ($pivots as $pivot) { + $results += $pivot->delete(); + } + + return $results; + } + + /** + * Update an existing pivot record on the table. + * + * @param mixed $id + * @param bool $touch + */ + public function updateExistingPivot($id, array $attributes, $touch = true) + { + if ($this->using) { + return $this->updateExistingPivotUsingCustomClass($id, $attributes, $touch); + } + + return parent::updateExistingPivot($id, $attributes, $touch); + } + + /** + * Update an existing pivot record on the table via a custom class. + * + * @param mixed $id + * @return int + */ + protected function updateExistingPivotUsingCustomClass($id, array $attributes, bool $touch) + { + $pivot = $this->getCurrentlyAttachedPivots($id)->first(); + + $updated = $pivot ? $pivot->fill($attributes)->isDirty() : false; + + if ($updated) { + $pivot->save(); + } + + if ($touch) { + $this->touchIfTouching(); + } + + return (int) $updated; + } + + /** + * Get the pivot models that are currently attached. + * + * @param mixed $ids + */ + protected function getCurrentlyAttachedPivots($ids = null): Collection + { + $query = $this->newPivotQuery(); + + if ($ids !== null) { + $query->whereIn($this->relatedPivotKey, $this->parseIds($ids)); + } + + return $query->get()->map(function ($record) { + /** @var class-string $class */ + $class = $this->using ?: Pivot::class; + + return $class::fromRawAttributes($this->parent, (array) $record, $this->getTable(), true) + ->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey); + }); + } + + /** + * Create a new pivot model instance. + * + * Overrides parent to include pivotValues in the attributes. + * + * @param bool $exists + */ + public function newPivot(array $attributes = [], $exists = false) + { + $attributes = array_merge( + array_column($this->pivotValues, 'value', 'column'), + $attributes + ); + + /** @var Pivot $pivot */ + $pivot = $this->related->newPivot( + $this->parent, + $attributes, + $this->table, + $exists, + $this->using + ); + + return $pivot->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey); + } +} diff --git a/src/core/src/Database/Eloquent/Relations/MorphPivot.php b/src/core/src/Database/Eloquent/Relations/MorphPivot.php index 340221468..c806817d1 100644 --- a/src/core/src/Database/Eloquent/Relations/MorphPivot.php +++ b/src/core/src/Database/Eloquent/Relations/MorphPivot.php @@ -4,8 +4,46 @@ namespace Hypervel\Database\Eloquent\Relations; -use Hyperf\Database\Model\Relations\MorphPivot as BaseMorphPivot; +use Hyperf\DbConnection\Model\Relations\MorphPivot as BaseMorphPivot; +use Hypervel\Database\Eloquent\Concerns\HasAttributes; +use Hypervel\Database\Eloquent\Concerns\HasCallbacks; +use Hypervel\Database\Eloquent\Concerns\HasObservers; class MorphPivot extends BaseMorphPivot { + use HasAttributes; + use HasCallbacks; + use HasObservers; + + /** + * Delete the pivot model record from the database. + * + * Overrides parent to fire deleting/deleted events even for composite key pivots, + * while maintaining the morph type constraint. + */ + public function delete(): mixed + { + // If pivot has a primary key, use parent's delete which fires events + if (isset($this->attributes[$this->getKeyName()])) { + return parent::delete(); + } + + // For composite key pivots, manually fire events around the raw delete + if ($this->fireModelEvent('deleting') === false) { + return 0; + } + + $query = $this->getDeleteQuery(); + + // Add morph type constraint (from Hyperf's MorphPivot::delete()) + $query->where($this->morphType, $this->morphClass); + + $result = $query->delete(); + + $this->exists = false; + + $this->fireModelEvent('deleted'); + + return $result; + } } diff --git a/src/core/src/Database/Eloquent/Relations/MorphToMany.php b/src/core/src/Database/Eloquent/Relations/MorphToMany.php index 0469a3a0a..c0ca92a7c 100644 --- a/src/core/src/Database/Eloquent/Relations/MorphToMany.php +++ b/src/core/src/Database/Eloquent/Relations/MorphToMany.php @@ -4,7 +4,9 @@ namespace Hypervel\Database\Eloquent\Relations; +use Hyperf\Collection\Collection; use Hyperf\Database\Model\Relations\MorphToMany as BaseMorphToMany; +use Hypervel\Database\Eloquent\Relations\Concerns\InteractsWithPivotTable; use Hypervel\Database\Eloquent\Relations\Concerns\WithoutAddConstraints; use Hypervel\Database\Eloquent\Relations\Contracts\Relation as RelationContract; @@ -42,14 +44,55 @@ */ class MorphToMany extends BaseMorphToMany implements RelationContract { + use InteractsWithPivotTable; use WithoutAddConstraints; /** + * Get the pivot models that are currently attached. + * + * Overrides trait to use MorphPivot and set morph type/class on the pivot models. + * + * @param mixed $ids + */ + protected function getCurrentlyAttachedPivots($ids = null): Collection + { + $query = $this->newPivotQuery(); + + if ($ids !== null) { + $query->whereIn($this->relatedPivotKey, $this->parseIds($ids)); + } + + return $query->get()->map(function ($record) { + /** @var class-string $class */ + $class = $this->using ?: MorphPivot::class; + + $pivot = $class::fromRawAttributes($this->parent, (array) $record, $this->getTable(), true) + ->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey); + + if ($pivot instanceof MorphPivot) { + $pivot->setMorphType($this->morphType) + ->setMorphClass($this->morphClass); + } + + return $pivot; + }); + } + + /** + * Create a new pivot model instance. + * + * Overrides parent to include pivotValues and set morph type/class. + * * @param bool $exists * @return TPivotModel */ public function newPivot(array $attributes = [], $exists = false) { + $attributes = array_merge( + array_column($this->pivotValues, 'value', 'column'), + $attributes + ); + $using = $this->using; $pivot = $using ? $using::fromRawAttributes($this->parent, $attributes, $this->table, $exists) diff --git a/src/core/src/Database/Eloquent/Relations/Pivot.php b/src/core/src/Database/Eloquent/Relations/Pivot.php index 0ef2639b1..3112b64ee 100644 --- a/src/core/src/Database/Eloquent/Relations/Pivot.php +++ b/src/core/src/Database/Eloquent/Relations/Pivot.php @@ -4,8 +4,40 @@ namespace Hypervel\Database\Eloquent\Relations; -use Hyperf\Database\Model\Relations\Pivot as BasePivot; +use Hyperf\DbConnection\Model\Relations\Pivot as BasePivot; +use Hypervel\Database\Eloquent\Concerns\HasAttributes; +use Hypervel\Database\Eloquent\Concerns\HasCallbacks; +use Hypervel\Database\Eloquent\Concerns\HasObservers; class Pivot extends BasePivot { + use HasAttributes; + use HasCallbacks; + use HasObservers; + + /** + * Delete the pivot model record from the database. + * + * Overrides parent to fire deleting/deleted events even for composite key pivots. + */ + public function delete(): mixed + { + // If pivot has a primary key, use parent's delete which fires events + if (isset($this->attributes[$this->getKeyName()])) { + return parent::delete(); + } + + // For composite key pivots, manually fire events around the raw delete + if ($this->fireModelEvent('deleting') === false) { + return 0; + } + + $result = $this->getDeleteQuery()->delete(); + + $this->exists = false; + + $this->fireModelEvent('deleted'); + + return $result; + } } diff --git a/tests/Core/Database/Eloquent/Relations/BelongsToManyPivotEventsTest.php b/tests/Core/Database/Eloquent/Relations/BelongsToManyPivotEventsTest.php new file mode 100644 index 000000000..888c1ba83 --- /dev/null +++ b/tests/Core/Database/Eloquent/Relations/BelongsToManyPivotEventsTest.php @@ -0,0 +1,368 @@ +using(). + * + * @internal + * @coversNothing + */ +class BelongsToManyPivotEventsTest extends TestCase +{ + use RefreshDatabase; + + protected bool $migrateRefresh = true; + + protected function migrateFreshUsing(): array + { + return [ + '--database' => $this->getRefreshConnection(), + '--realpath' => true, + '--path' => __DIR__ . '/migrations', + ]; + } + + protected function setUp(): void + { + parent::setUp(); + + // Clear event log between tests + PivotEventsTestCollaborator::$eventsCalled = []; + } + + // ========================================================================= + // Tests for attach() + // ========================================================================= + + public function testAttachFiresCreatingAndCreatedEventsWithCustomPivot(): void + { + $user = PivotEventsTestUser::forceCreate(['name' => 'Test User']); + $role = PivotEventsTestRole::forceCreate(['name' => 'Admin']); + + $user->rolesWithPivot()->attach($role); + + $this->assertEquals( + ['saving', 'creating', 'created', 'saved'], + PivotEventsTestCollaborator::$eventsCalled + ); + } + + public function testAttachMultipleFiresEventsForEachRecord(): void + { + $user = PivotEventsTestUser::forceCreate(['name' => 'Test User']); + $role1 = PivotEventsTestRole::forceCreate(['name' => 'Admin']); + $role2 = PivotEventsTestRole::forceCreate(['name' => 'Editor']); + $role3 = PivotEventsTestRole::forceCreate(['name' => 'Viewer']); + + $user->rolesWithPivot()->attach([$role1->id, $role2->id, $role3->id]); + + // 3 creates = 3x (saving, creating, created, saved) + $this->assertCount(12, PivotEventsTestCollaborator::$eventsCalled); + $this->assertEquals(3, substr_count(implode(',', PivotEventsTestCollaborator::$eventsCalled), 'creating')); + $this->assertEquals(3, substr_count(implode(',', PivotEventsTestCollaborator::$eventsCalled), 'created')); + } + + public function testAttachWithoutCustomPivotDoesNotFireEvents(): void + { + $user = PivotEventsTestUser::forceCreate(['name' => 'Test User']); + $role = PivotEventsTestRole::forceCreate(['name' => 'Admin']); + + // Using rolesWithoutPivot which doesn't use ->using() + $user->rolesWithoutPivot()->attach($role->id); + + $this->assertEquals([], PivotEventsTestCollaborator::$eventsCalled); + + $this->assertDatabaseHas('pivot_events_role_user', [ + 'user_id' => $user->id, + 'role_id' => $role->id, + ]); + } + + // ========================================================================= + // Tests for detach() + // ========================================================================= + + public function testDetachFiresDeletingAndDeletedEventsWithCustomPivot(): void + { + $user = PivotEventsTestUser::forceCreate(['name' => 'Test User']); + $role = PivotEventsTestRole::forceCreate(['name' => 'Admin']); + $user->rolesWithPivot()->attach($role->id); + + PivotEventsTestCollaborator::$eventsCalled = []; + + $deleted = $user->rolesWithPivot()->detach($role->id); + + $this->assertSame(1, $deleted); + $this->assertEquals(['deleting', 'deleted'], PivotEventsTestCollaborator::$eventsCalled); + } + + public function testDetachMultipleFiresEventsForEachRecord(): void + { + $user = PivotEventsTestUser::forceCreate(['name' => 'Test User']); + $role1 = PivotEventsTestRole::forceCreate(['name' => 'Admin']); + $role2 = PivotEventsTestRole::forceCreate(['name' => 'Editor']); + $user->rolesWithPivot()->attach([$role1->id, $role2->id]); + + PivotEventsTestCollaborator::$eventsCalled = []; + + $deleted = $user->rolesWithPivot()->detach([$role1->id, $role2->id]); + + $this->assertSame(2, $deleted); + $this->assertEquals(['deleting', 'deleted', 'deleting', 'deleted'], PivotEventsTestCollaborator::$eventsCalled); + } + + public function testDetachAllFiresEventsForAllRecords(): void + { + $user = PivotEventsTestUser::forceCreate(['name' => 'Test User']); + $role1 = PivotEventsTestRole::forceCreate(['name' => 'Admin']); + $role2 = PivotEventsTestRole::forceCreate(['name' => 'Editor']); + $user->rolesWithPivot()->attach([$role1->id, $role2->id]); + + PivotEventsTestCollaborator::$eventsCalled = []; + + $deleted = $user->rolesWithPivot()->detach(); + + $this->assertSame(2, $deleted); + $this->assertEquals(['deleting', 'deleted', 'deleting', 'deleted'], PivotEventsTestCollaborator::$eventsCalled); + } + + public function testDetachWithoutCustomPivotDoesNotFireEvents(): void + { + $user = PivotEventsTestUser::forceCreate(['name' => 'Test User']); + $role = PivotEventsTestRole::forceCreate(['name' => 'Admin']); + $user->rolesWithoutPivot()->attach($role->id); + + PivotEventsTestCollaborator::$eventsCalled = []; + + $user->rolesWithoutPivot()->detach($role->id); + + $this->assertEquals([], PivotEventsTestCollaborator::$eventsCalled); + } + + // ========================================================================= + // Tests for updateExistingPivot() + // ========================================================================= + + public function testUpdateExistingPivotFiresSavingAndSavedEventsWithCustomPivot(): void + { + $user = PivotEventsTestUser::forceCreate(['name' => 'Test User']); + $role = PivotEventsTestRole::forceCreate(['name' => 'Admin']); + $user->rolesWithPivot()->attach($role->id, ['is_active' => false]); + + PivotEventsTestCollaborator::$eventsCalled = []; + + $updated = $user->rolesWithPivot()->updateExistingPivot($role->id, ['is_active' => true]); + + $this->assertSame(1, $updated); + $this->assertEquals(['saving', 'updating', 'updated', 'saved'], PivotEventsTestCollaborator::$eventsCalled); + } + + public function testUpdateExistingPivotDoesNotFireEventsWhenNotDirty(): void + { + $user = PivotEventsTestUser::forceCreate(['name' => 'Test User']); + $role = PivotEventsTestRole::forceCreate(['name' => 'Admin']); + $user->rolesWithPivot()->attach($role->id, ['is_active' => true]); + + PivotEventsTestCollaborator::$eventsCalled = []; + + // Update with same value - should not be dirty + $updated = $user->rolesWithPivot()->updateExistingPivot($role->id, ['is_active' => true]); + + $this->assertSame(0, $updated); + $this->assertEquals([], PivotEventsTestCollaborator::$eventsCalled); + } + + public function testUpdateExistingPivotWithoutCustomPivotDoesNotFireEvents(): void + { + $user = PivotEventsTestUser::forceCreate(['name' => 'Test User']); + $role = PivotEventsTestRole::forceCreate(['name' => 'Admin']); + $user->rolesWithoutPivot()->attach($role->id, ['is_active' => false]); + + PivotEventsTestCollaborator::$eventsCalled = []; + + $user->rolesWithoutPivot()->updateExistingPivot($role->id, ['is_active' => true]); + + $this->assertEquals([], PivotEventsTestCollaborator::$eventsCalled); + } + + // ========================================================================= + // Tests for sync() + // ========================================================================= + + public function testSyncFiresEventsForAttachAndDetach(): void + { + $user = PivotEventsTestUser::forceCreate(['name' => 'Test User']); + $role1 = PivotEventsTestRole::forceCreate(['name' => 'Admin']); + $role2 = PivotEventsTestRole::forceCreate(['name' => 'Editor']); + $role3 = PivotEventsTestRole::forceCreate(['name' => 'Viewer']); + + // Attach role1 and role2 + $user->rolesWithPivot()->attach([$role1->id, $role2->id]); + + PivotEventsTestCollaborator::$eventsCalled = []; + + // Sync to role2 and role3 (detaches role1, attaches role3, keeps role2) + $changes = $user->rolesWithPivot()->sync([$role2->id, $role3->id]); + + $this->assertSame([$role1->id], $changes['detached']); + $this->assertSame([$role3->id], $changes['attached']); + + $this->assertEquals( + ['deleting', 'deleted', 'saving', 'creating', 'created', 'saved'], + PivotEventsTestCollaborator::$eventsCalled + ); + } + + public function testSyncWithPivotValuesFiresEventsForUpdates(): void + { + $user = PivotEventsTestUser::forceCreate(['name' => 'Test User']); + $role = PivotEventsTestRole::forceCreate(['name' => 'Admin']); + $user->rolesWithPivot()->attach($role->id, ['is_active' => false]); + + PivotEventsTestCollaborator::$eventsCalled = []; + + // Sync with updated pivot value + $changes = $user->rolesWithPivot()->sync([ + $role->id => ['is_active' => true], + ]); + + $this->assertSame([$role->id], $changes['updated']); + $this->assertEquals(['saving', 'updating', 'updated', 'saved'], PivotEventsTestCollaborator::$eventsCalled); + } + + // ========================================================================= + // Tests for toggle() + // ========================================================================= + + public function testToggleFiresEventsForAttachAndDetach(): void + { + $user = PivotEventsTestUser::forceCreate(['name' => 'Test User']); + $role1 = PivotEventsTestRole::forceCreate(['name' => 'Admin']); + $role2 = PivotEventsTestRole::forceCreate(['name' => 'Editor']); + + // Attach role1 + $user->rolesWithPivot()->attach($role1->id); + + PivotEventsTestCollaborator::$eventsCalled = []; + + // Toggle role1 (detach) and role2 (attach) + $changes = $user->rolesWithPivot()->toggle([$role1->id, $role2->id]); + + $this->assertSame([$role1->id], $changes['detached']); + $this->assertContains($role2->id, $changes['attached']); + + $this->assertEquals( + ['deleting', 'deleted', 'saving', 'creating', 'created', 'saved'], + PivotEventsTestCollaborator::$eventsCalled + ); + } +} + +// ============================================================================= +// Test Models +// ============================================================================= + +class PivotEventsTestUser extends Model +{ + protected ?string $table = 'pivot_events_users'; + + protected array $guarded = []; + + /** + * Relationship WITH custom pivot class - should fire events. + * + * @return BelongsToMany + */ + public function rolesWithPivot(): BelongsToMany + { + return $this->belongsToMany( + PivotEventsTestRole::class, + 'pivot_events_role_user', + 'user_id', + 'role_id' + )->using(PivotEventsTestCollaborator::class)->withPivot('is_active')->withTimestamps(); + } + + /** + * Relationship WITHOUT custom pivot class - should NOT fire events (uses raw queries). + * + * @return BelongsToMany + */ + public function rolesWithoutPivot(): BelongsToMany + { + return $this->belongsToMany( + PivotEventsTestRole::class, + 'pivot_events_role_user', + 'user_id', + 'role_id' + )->withPivot('is_active')->withTimestamps(); + } +} + +class PivotEventsTestRole extends Model +{ + protected ?string $table = 'pivot_events_roles'; + + protected array $guarded = []; +} + +class PivotEventsTestCollaborator extends Pivot +{ + protected ?string $table = 'pivot_events_role_user'; + + public bool $incrementing = false; + + public bool $timestamps = true; + + protected array $casts = [ + 'is_active' => 'boolean', + ]; + + public static array $eventsCalled = []; + + protected function boot(): void + { + parent::boot(); + + static::registerCallback('creating', function ($model) { + static::$eventsCalled[] = 'creating'; + }); + + static::registerCallback('created', function ($model) { + static::$eventsCalled[] = 'created'; + }); + + static::registerCallback('updating', function ($model) { + static::$eventsCalled[] = 'updating'; + }); + + static::registerCallback('updated', function ($model) { + static::$eventsCalled[] = 'updated'; + }); + + static::registerCallback('saving', function ($model) { + static::$eventsCalled[] = 'saving'; + }); + + static::registerCallback('saved', function ($model) { + static::$eventsCalled[] = 'saved'; + }); + + static::registerCallback('deleting', function ($model) { + static::$eventsCalled[] = 'deleting'; + }); + + static::registerCallback('deleted', function ($model) { + static::$eventsCalled[] = 'deleted'; + }); + } +} diff --git a/tests/Core/Database/Eloquent/Relations/MorphToManyPivotEventsTest.php b/tests/Core/Database/Eloquent/Relations/MorphToManyPivotEventsTest.php new file mode 100644 index 000000000..451daa43e --- /dev/null +++ b/tests/Core/Database/Eloquent/Relations/MorphToManyPivotEventsTest.php @@ -0,0 +1,399 @@ +using() + * on MorphToMany relationships. + * + * @internal + * @coversNothing + */ +class MorphToManyPivotEventsTest extends TestCase +{ + use RefreshDatabase; + + protected bool $migrateRefresh = true; + + protected function migrateFreshUsing(): array + { + return [ + '--database' => $this->getRefreshConnection(), + '--realpath' => true, + '--path' => __DIR__ . '/migrations', + ]; + } + + protected function setUp(): void + { + parent::setUp(); + + // Clear event log between tests + MorphPivotEventsTestTaggable::$eventsCalled = []; + } + + // ========================================================================= + // Tests for attach() + // ========================================================================= + + public function testAttachFiresCreatingAndCreatedEventsWithCustomMorphPivot(): void + { + $post = MorphPivotEventsTestPost::forceCreate(['title' => 'Test Post']); + $tag = MorphPivotEventsTestTag::forceCreate(['name' => 'PHP']); + + $post->tagsWithPivot()->attach($tag); + + $this->assertEquals( + ['saving', 'creating', 'created', 'saved'], + MorphPivotEventsTestTaggable::$eventsCalled + ); + } + + public function testAttachMultipleFiresEventsForEachRecord(): void + { + $post = MorphPivotEventsTestPost::forceCreate(['title' => 'Test Post']); + $tag1 = MorphPivotEventsTestTag::forceCreate(['name' => 'PHP']); + $tag2 = MorphPivotEventsTestTag::forceCreate(['name' => 'Laravel']); + $tag3 = MorphPivotEventsTestTag::forceCreate(['name' => 'Hypervel']); + + $post->tagsWithPivot()->attach([$tag1->id, $tag2->id, $tag3->id]); + + // 3 creates = 3x (saving, creating, created, saved) + $this->assertCount(12, MorphPivotEventsTestTaggable::$eventsCalled); + $this->assertEquals(3, substr_count(implode(',', MorphPivotEventsTestTaggable::$eventsCalled), 'creating')); + $this->assertEquals(3, substr_count(implode(',', MorphPivotEventsTestTaggable::$eventsCalled), 'created')); + } + + public function testAttachWithoutCustomMorphPivotDoesNotFireEvents(): void + { + $post = MorphPivotEventsTestPost::forceCreate(['title' => 'Test Post']); + $tag = MorphPivotEventsTestTag::forceCreate(['name' => 'PHP']); + + // Using tagsWithoutPivot which doesn't use ->using() + $post->tagsWithoutPivot()->attach($tag->id); + + $this->assertEquals([], MorphPivotEventsTestTaggable::$eventsCalled); + + $this->assertDatabaseHas('pivot_events_taggables', [ + 'taggable_id' => $post->id, + 'taggable_type' => MorphPivotEventsTestPost::class, + 'tag_id' => $tag->id, + ]); + } + + // ========================================================================= + // Tests for detach() + // ========================================================================= + + public function testDetachFiresDeletingAndDeletedEventsWithCustomMorphPivot(): void + { + $post = MorphPivotEventsTestPost::forceCreate(['title' => 'Test Post']); + $tag = MorphPivotEventsTestTag::forceCreate(['name' => 'PHP']); + $post->tagsWithPivot()->attach($tag->id); + + MorphPivotEventsTestTaggable::$eventsCalled = []; + + $deleted = $post->tagsWithPivot()->detach($tag->id); + + $this->assertSame(1, $deleted); + $this->assertEquals(['deleting', 'deleted'], MorphPivotEventsTestTaggable::$eventsCalled); + } + + public function testDetachMultipleFiresEventsForEachRecord(): void + { + $post = MorphPivotEventsTestPost::forceCreate(['title' => 'Test Post']); + $tag1 = MorphPivotEventsTestTag::forceCreate(['name' => 'PHP']); + $tag2 = MorphPivotEventsTestTag::forceCreate(['name' => 'Laravel']); + $post->tagsWithPivot()->attach([$tag1->id, $tag2->id]); + + MorphPivotEventsTestTaggable::$eventsCalled = []; + + $deleted = $post->tagsWithPivot()->detach([$tag1->id, $tag2->id]); + + $this->assertSame(2, $deleted); + $this->assertEquals(['deleting', 'deleted', 'deleting', 'deleted'], MorphPivotEventsTestTaggable::$eventsCalled); + } + + public function testDetachAllFiresEventsForAllRecords(): void + { + $post = MorphPivotEventsTestPost::forceCreate(['title' => 'Test Post']); + $tag1 = MorphPivotEventsTestTag::forceCreate(['name' => 'PHP']); + $tag2 = MorphPivotEventsTestTag::forceCreate(['name' => 'Laravel']); + $post->tagsWithPivot()->attach([$tag1->id, $tag2->id]); + + MorphPivotEventsTestTaggable::$eventsCalled = []; + + $deleted = $post->tagsWithPivot()->detach(); + + $this->assertSame(2, $deleted); + $this->assertEquals(['deleting', 'deleted', 'deleting', 'deleted'], MorphPivotEventsTestTaggable::$eventsCalled); + } + + public function testDetachWithoutCustomMorphPivotDoesNotFireEvents(): void + { + $post = MorphPivotEventsTestPost::forceCreate(['title' => 'Test Post']); + $tag = MorphPivotEventsTestTag::forceCreate(['name' => 'PHP']); + $post->tagsWithoutPivot()->attach($tag->id); + + MorphPivotEventsTestTaggable::$eventsCalled = []; + + $post->tagsWithoutPivot()->detach($tag->id); + + $this->assertEquals([], MorphPivotEventsTestTaggable::$eventsCalled); + } + + // ========================================================================= + // Tests for updateExistingPivot() + // ========================================================================= + + public function testUpdateExistingPivotFiresSavingAndSavedEventsWithCustomMorphPivot(): void + { + $post = MorphPivotEventsTestPost::forceCreate(['title' => 'Test Post']); + $tag = MorphPivotEventsTestTag::forceCreate(['name' => 'PHP']); + $post->tagsWithPivot()->attach($tag->id, ['is_primary' => false]); + + MorphPivotEventsTestTaggable::$eventsCalled = []; + + $updated = $post->tagsWithPivot()->updateExistingPivot($tag->id, ['is_primary' => true]); + + $this->assertSame(1, $updated); + $this->assertEquals(['saving', 'updating', 'updated', 'saved'], MorphPivotEventsTestTaggable::$eventsCalled); + } + + public function testUpdateExistingPivotDoesNotFireEventsWhenNotDirty(): void + { + $post = MorphPivotEventsTestPost::forceCreate(['title' => 'Test Post']); + $tag = MorphPivotEventsTestTag::forceCreate(['name' => 'PHP']); + $post->tagsWithPivot()->attach($tag->id, ['is_primary' => true]); + + MorphPivotEventsTestTaggable::$eventsCalled = []; + + // Update with same value - should not be dirty + $updated = $post->tagsWithPivot()->updateExistingPivot($tag->id, ['is_primary' => true]); + + $this->assertSame(0, $updated); + $this->assertEquals([], MorphPivotEventsTestTaggable::$eventsCalled); + } + + // ========================================================================= + // Tests for sync() + // ========================================================================= + + public function testSyncFiresEventsForAttachAndDetach(): void + { + $post = MorphPivotEventsTestPost::forceCreate(['title' => 'Test Post']); + $tag1 = MorphPivotEventsTestTag::forceCreate(['name' => 'PHP']); + $tag2 = MorphPivotEventsTestTag::forceCreate(['name' => 'Laravel']); + $tag3 = MorphPivotEventsTestTag::forceCreate(['name' => 'Hypervel']); + + // Attach tag1 and tag2 + $post->tagsWithPivot()->attach([$tag1->id, $tag2->id]); + + MorphPivotEventsTestTaggable::$eventsCalled = []; + + // Sync to tag2 and tag3 (detaches tag1, attaches tag3, keeps tag2) + $changes = $post->tagsWithPivot()->sync([$tag2->id, $tag3->id]); + + $this->assertSame([$tag1->id], $changes['detached']); + $this->assertSame([$tag3->id], $changes['attached']); + + $this->assertEquals( + ['deleting', 'deleted', 'saving', 'creating', 'created', 'saved'], + MorphPivotEventsTestTaggable::$eventsCalled + ); + } + + // ========================================================================= + // Tests for toggle() + // ========================================================================= + + public function testToggleFiresEventsForAttachAndDetach(): void + { + $post = MorphPivotEventsTestPost::forceCreate(['title' => 'Test Post']); + $tag1 = MorphPivotEventsTestTag::forceCreate(['name' => 'PHP']); + $tag2 = MorphPivotEventsTestTag::forceCreate(['name' => 'Laravel']); + + // Attach tag1 + $post->tagsWithPivot()->attach($tag1->id); + + MorphPivotEventsTestTaggable::$eventsCalled = []; + + // Toggle tag1 (detach) and tag2 (attach) + $changes = $post->tagsWithPivot()->toggle([$tag1->id, $tag2->id]); + + $this->assertSame([$tag1->id], $changes['detached']); + $this->assertContains($tag2->id, $changes['attached']); + + $this->assertEquals( + ['deleting', 'deleted', 'saving', 'creating', 'created', 'saved'], + MorphPivotEventsTestTaggable::$eventsCalled + ); + } + + // ========================================================================= + // Tests for morph type constraint in delete + // ========================================================================= + + public function testDetachOnlyDeletesForCorrectMorphType(): void + { + // Create a post and a video, both with the same tag + $post = MorphPivotEventsTestPost::forceCreate(['title' => 'Test Post']); + $video = MorphPivotEventsTestVideo::forceCreate(['title' => 'Test Video']); + $tag = MorphPivotEventsTestTag::forceCreate(['name' => 'PHP']); + + $post->tagsWithPivot()->attach($tag->id); + $video->tagsWithPivot()->attach($tag->id); + + MorphPivotEventsTestTaggable::$eventsCalled = []; + + // Detach from post only + $deleted = $post->tagsWithPivot()->detach($tag->id); + + $this->assertSame(1, $deleted); + + // Video should still have the tag + $this->assertDatabaseHas('pivot_events_taggables', [ + 'taggable_id' => $video->id, + 'taggable_type' => MorphPivotEventsTestVideo::class, + 'tag_id' => $tag->id, + ]); + + // Post should not have the tag + $this->assertDatabaseMissing('pivot_events_taggables', [ + 'taggable_id' => $post->id, + 'taggable_type' => MorphPivotEventsTestPost::class, + 'tag_id' => $tag->id, + ]); + } +} + +// ============================================================================= +// Test Models +// ============================================================================= + +class MorphPivotEventsTestPost extends Model +{ + protected ?string $table = 'pivot_events_posts'; + + protected array $guarded = []; + + /** + * Relationship WITH custom pivot class - should fire events. + * + * @return MorphToMany + */ + public function tagsWithPivot(): MorphToMany + { + return $this->morphToMany( + MorphPivotEventsTestTag::class, + 'taggable', + 'pivot_events_taggables', + 'taggable_id', + 'tag_id' + )->using(MorphPivotEventsTestTaggable::class)->withPivot('is_primary')->withTimestamps(); + } + + /** + * Relationship WITHOUT custom pivot class - should NOT fire events (uses raw queries). + * + * @return MorphToMany + */ + public function tagsWithoutPivot(): MorphToMany + { + return $this->morphToMany( + MorphPivotEventsTestTag::class, + 'taggable', + 'pivot_events_taggables', + 'taggable_id', + 'tag_id' + )->withPivot('is_primary')->withTimestamps(); + } +} + +class MorphPivotEventsTestVideo extends Model +{ + protected ?string $table = 'pivot_events_videos'; + + protected array $guarded = []; + + /** + * @return MorphToMany + */ + public function tagsWithPivot(): MorphToMany + { + return $this->morphToMany( + MorphPivotEventsTestTag::class, + 'taggable', + 'pivot_events_taggables', + 'taggable_id', + 'tag_id' + )->using(MorphPivotEventsTestTaggable::class)->withPivot('is_primary')->withTimestamps(); + } +} + +class MorphPivotEventsTestTag extends Model +{ + protected ?string $table = 'pivot_events_tags'; + + protected array $guarded = []; +} + +class MorphPivotEventsTestTaggable extends MorphPivot +{ + protected ?string $table = 'pivot_events_taggables'; + + public bool $incrementing = false; + + public bool $timestamps = true; + + protected array $casts = [ + 'is_primary' => 'boolean', + ]; + + public static array $eventsCalled = []; + + protected function boot(): void + { + parent::boot(); + + static::registerCallback('creating', function ($model) { + static::$eventsCalled[] = 'creating'; + }); + + static::registerCallback('created', function ($model) { + static::$eventsCalled[] = 'created'; + }); + + static::registerCallback('updating', function ($model) { + static::$eventsCalled[] = 'updating'; + }); + + static::registerCallback('updated', function ($model) { + static::$eventsCalled[] = 'updated'; + }); + + static::registerCallback('saving', function ($model) { + static::$eventsCalled[] = 'saving'; + }); + + static::registerCallback('saved', function ($model) { + static::$eventsCalled[] = 'saved'; + }); + + static::registerCallback('deleting', function ($model) { + static::$eventsCalled[] = 'deleting'; + }); + + static::registerCallback('deleted', function ($model) { + static::$eventsCalled[] = 'deleted'; + }); + } +} diff --git a/tests/Core/Database/Eloquent/Relations/migrations/2025_01_01_000000_create_pivot_events_test_tables.php b/tests/Core/Database/Eloquent/Relations/migrations/2025_01_01_000000_create_pivot_events_test_tables.php new file mode 100644 index 000000000..12ce24a9e --- /dev/null +++ b/tests/Core/Database/Eloquent/Relations/migrations/2025_01_01_000000_create_pivot_events_test_tables.php @@ -0,0 +1,77 @@ +id(); + $table->string('name'); + $table->timestamps(); + }); + + Schema::create('pivot_events_roles', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->timestamps(); + }); + + Schema::create('pivot_events_role_user', function (Blueprint $table) { + $table->unsignedBigInteger('user_id'); + $table->unsignedBigInteger('role_id'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->primary(['user_id', 'role_id']); + + $table->foreign('user_id') + ->references('id') + ->on('pivot_events_users') + ->onDelete('cascade'); + + $table->foreign('role_id') + ->references('id') + ->on('pivot_events_roles') + ->onDelete('cascade'); + }); + + // MorphToMany test tables + Schema::create('pivot_events_posts', function (Blueprint $table) { + $table->id(); + $table->string('title'); + $table->timestamps(); + }); + + Schema::create('pivot_events_videos', function (Blueprint $table) { + $table->id(); + $table->string('title'); + $table->timestamps(); + }); + + Schema::create('pivot_events_tags', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->timestamps(); + }); + + Schema::create('pivot_events_taggables', function (Blueprint $table) { + $table->unsignedBigInteger('tag_id'); + $table->unsignedBigInteger('taggable_id'); + $table->string('taggable_type'); + $table->boolean('is_primary')->default(false); + $table->timestamps(); + + $table->primary(['tag_id', 'taggable_id', 'taggable_type']); + + $table->foreign('tag_id') + ->references('id') + ->on('pivot_events_tags') + ->onDelete('cascade'); + }); + } +};