From 44746712555ae2032094db7a841f01fb89d271c1 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 3 Nov 2025 17:33:12 +0100 Subject: [PATCH 1/4] Syncing: SyncedResourceDeleted event and DeleteResourceMapping listener Also move pivot record deletion to that listener and improve tests The 'tenant pivot records are deleted along with the tenants to which they belong to' test is failing in this commit -- the listener for deleting mappings when a *tenant* is deleted is only implemented in the next commit. The only change done here is to re-add FKs (necessary for passing *in this commit* in that specific dataset variant) that were removed from the default test migration as we now have the the DeleteResourceMapping listener that's enabled by default. --- assets/TenancyServiceProvider.stub.php | 3 + .../Events/SyncedResourceDeleted.php | 18 +++++ .../Listeners/DeleteResourceMapping.php | 60 +++++++++++++++++ .../Listeners/DeleteResourcesInTenants.php | 7 -- src/ResourceSyncing/ResourceSyncing.php | 15 +++-- src/ResourceSyncing/SyncMaster.php | 4 +- src/ResourceSyncing/Syncable.php | 2 + tests/Etc/ResourceSyncing/CentralUser.php | 1 + ...05_11_000002_create_tenant_users_table.php | 3 - tests/ResourceSyncingTest.php | 67 ++++++++++++++++++- 10 files changed, 160 insertions(+), 20 deletions(-) create mode 100644 src/ResourceSyncing/Events/SyncedResourceDeleted.php create mode 100644 src/ResourceSyncing/Listeners/DeleteResourceMapping.php diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 46f35515..2e7819a5 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -129,6 +129,9 @@ class TenancyServiceProvider extends ServiceProvider ResourceSyncing\Events\SyncedResourceSaved::class => [ ResourceSyncing\Listeners\UpdateOrCreateSyncedResource::class, ], + ResourceSyncing\Events\SyncedResourceDeleted::class => [ + ResourceSyncing\Listeners\DeleteResourceMapping::class, + ], ResourceSyncing\Events\SyncMasterDeleted::class => [ ResourceSyncing\Listeners\DeleteResourcesInTenants::class, ], diff --git a/src/ResourceSyncing/Events/SyncedResourceDeleted.php b/src/ResourceSyncing/Events/SyncedResourceDeleted.php new file mode 100644 index 00000000..941e1841 --- /dev/null +++ b/src/ResourceSyncing/Events/SyncedResourceDeleted.php @@ -0,0 +1,18 @@ +getCentralResource($event->model); + + if (! $centralResource) { + return; + } + + // Delete pivot records if the central resource doesn't use soft deletes + // or the central resource was deleted using forceDelete() + if ($event->forceDelete || ! in_array(SoftDeletes::class, class_uses_recursive($centralResource::class), true)) { + Pivot::withoutEvents(function () use ($centralResource, $event) { + // If detach() is called with null -- if $event->tenant is null -- this means a central resource was deleted and detaches all tenants. + // If detach() is called with a specific tenant, it means the resource was deleted in that tenant, and we only delete that single mapping. + $centralResource->tenants()->detach($event->tenant); + }); + } + } + + public function getCentralResource(Syncable&Model $resource): SyncMaster|null + { + if ($resource instanceof SyncMaster) { + return $resource; + } + + $centralResourceClass = $resource->getCentralModelName(); + + /** @var (SyncMaster&Model)|null $centralResource */ + $centralResource = $centralResourceClass::firstWhere( + $resource->getGlobalIdentifierKeyName(), + $resource->getGlobalIdentifierKey() + ); + + return $centralResource; + } +} diff --git a/src/ResourceSyncing/Listeners/DeleteResourcesInTenants.php b/src/ResourceSyncing/Listeners/DeleteResourcesInTenants.php index 6876f476..7b071a27 100644 --- a/src/ResourceSyncing/Listeners/DeleteResourcesInTenants.php +++ b/src/ResourceSyncing/Listeners/DeleteResourcesInTenants.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Stancl\Tenancy\ResourceSyncing\Listeners; -use Illuminate\Database\Eloquent\SoftDeletes; use Stancl\Tenancy\Listeners\QueueableListener; use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterDeleted; @@ -21,12 +20,6 @@ class DeleteResourcesInTenants extends QueueableListener tenancy()->runForMultiple($centralResource->tenants()->cursor(), function () use ($centralResource, $forceDelete) { $this->deleteSyncedResource($centralResource, $forceDelete); - - // Delete pivot records if the central resource doesn't use soft deletes - // or the central resource was deleted using forceDelete() - if ($forceDelete || ! in_array(SoftDeletes::class, class_uses_recursive($centralResource::class), true)) { - $centralResource->tenants()->detach(tenant()); - } }); } } diff --git a/src/ResourceSyncing/ResourceSyncing.php b/src/ResourceSyncing/ResourceSyncing.php index f0d8cc12..fb008966 100644 --- a/src/ResourceSyncing/ResourceSyncing.php +++ b/src/ResourceSyncing/ResourceSyncing.php @@ -11,6 +11,7 @@ use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceAttachedToTenant; use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceDetachedFromTenant; +use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceDeleted; use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSaved; use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterDeleted; use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterRestored; @@ -25,8 +26,8 @@ trait ResourceSyncing } }); - static::deleting(function (Syncable&Model $model) { - if ($model->shouldSync() && $model instanceof SyncMaster) { + static::deleted(function (Syncable&Model $model) { + if ($model->shouldSync()) { $model->triggerDeleteEvent(); } }); @@ -42,14 +43,14 @@ trait ResourceSyncing if (in_array(SoftDeletes::class, class_uses_recursive(static::class), true)) { static::forceDeleting(function (Syncable&Model $model) { - if ($model->shouldSync() && $model instanceof SyncMaster) { + if ($model->shouldSync()) { $model->triggerDeleteEvent(true); } }); static::restoring(function (Syncable&Model $model) { - if ($model->shouldSync() && $model instanceof SyncMaster) { - $model->triggerRestoredEvent(); + if ($model instanceof SyncMaster && $model->shouldSync()) { + $model->triggerRestoreEvent(); } }); } @@ -67,9 +68,11 @@ trait ResourceSyncing /** @var SyncMaster&Model $this */ event(new SyncMasterDeleted($this, $forceDelete)); } + + event(new SyncedResourceDeleted($this, tenant(), $forceDelete)); } - public function triggerRestoredEvent(): void + public function triggerRestoreEvent(): void { if ($this instanceof SyncMaster && in_array(SoftDeletes::class, class_uses_recursive($this), true)) { /** @var SyncMaster&Model $this */ diff --git a/src/ResourceSyncing/SyncMaster.php b/src/ResourceSyncing/SyncMaster.php index 882aeb54..290546cb 100644 --- a/src/ResourceSyncing/SyncMaster.php +++ b/src/ResourceSyncing/SyncMaster.php @@ -25,7 +25,5 @@ interface SyncMaster extends Syncable public function triggerAttachEvent(TenantWithDatabase&Model $tenant): void; - public function triggerDeleteEvent(bool $forceDelete = false): void; - - public function triggerRestoredEvent(): void; + public function triggerRestoreEvent(): void; } diff --git a/src/ResourceSyncing/Syncable.php b/src/ResourceSyncing/Syncable.php index 3d5288f1..c38b02ea 100644 --- a/src/ResourceSyncing/Syncable.php +++ b/src/ResourceSyncing/Syncable.php @@ -16,6 +16,8 @@ interface Syncable public function triggerSyncEvent(): void; + public function triggerDeleteEvent(bool $forceDelete = false): void; + /** * Get the attributes used for creating the *other* model (i.e. tenant if this is the central one, and central if this is the tenant one). * diff --git a/tests/Etc/ResourceSyncing/CentralUser.php b/tests/Etc/ResourceSyncing/CentralUser.php index 1533bd21..ece09550 100644 --- a/tests/Etc/ResourceSyncing/CentralUser.php +++ b/tests/Etc/ResourceSyncing/CentralUser.php @@ -12,6 +12,7 @@ use Stancl\Tenancy\ResourceSyncing\SyncMaster; class CentralUser extends Model implements SyncMaster { use ResourceSyncing, CentralConnection; + protected $guarded = []; public $timestamps = false; diff --git a/tests/Etc/synced_resource_migrations/2020_05_11_000002_create_tenant_users_table.php b/tests/Etc/synced_resource_migrations/2020_05_11_000002_create_tenant_users_table.php index 0aafd23c..dcd667a6 100644 --- a/tests/Etc/synced_resource_migrations/2020_05_11_000002_create_tenant_users_table.php +++ b/tests/Etc/synced_resource_migrations/2020_05_11_000002_create_tenant_users_table.php @@ -16,9 +16,6 @@ class CreateTenantUsersTable extends Migration $table->string('global_user_id'); $table->unique(['tenant_id', 'global_user_id']); - - $table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade'); - $table->foreign('global_user_id')->references('global_id')->on('users')->onUpdate('cascade')->onDelete('cascade'); }); } diff --git a/tests/ResourceSyncingTest.php b/tests/ResourceSyncingTest.php index 3250c37a..c64a9806 100644 --- a/tests/ResourceSyncingTest.php +++ b/tests/ResourceSyncingTest.php @@ -46,6 +46,10 @@ use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase; use Illuminate\Database\Eloquent\Scope; use Illuminate\Database\QueryException; use function Stancl\Tenancy\Tests\pest; +use Illuminate\Support\Facades\Schema; +use Illuminate\Database\Schema\Blueprint; +use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceDeleted; +use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteResourceMapping; beforeEach(function () { config(['tenancy.bootstrappers' => [ @@ -92,6 +96,7 @@ beforeEach(function () { CentralUser::$creationAttributes = $creationAttributes; Event::listen(SyncedResourceSaved::class, UpdateOrCreateSyncedResource::class); + Event::listen(SyncedResourceDeleted::class, DeleteResourceMapping::class); Event::listen(SyncMasterDeleted::class, DeleteResourcesInTenants::class); Event::listen(SyncMasterRestored::class, RestoreResourcesInTenants::class); Event::listen(CentralResourceAttachedToTenant::class, CreateTenantResource::class); @@ -890,9 +895,13 @@ test('deleting SyncMaster automatically deletes its Syncables', function (bool $ 'basic pivot' => false, ]); -test('tenant pivot records are deleted along with the tenants to which they belong to', function() { +test('tenant pivot records are deleted along with the tenants to which they belong to', function(bool $dbLevelOnCascadeDelete) { [$tenant] = createTenantsAndRunMigrations(); + if ($dbLevelOnCascadeDelete) { + addFkConstraintsToTenantUsersPivot(); + } + $syncMaster = CentralUser::create([ 'global_id' => 'cascade_user', 'name' => 'Central user', @@ -907,6 +916,54 @@ test('tenant pivot records are deleted along with the tenants to which they belo // Deleting tenant deletes its pivot records expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->getTenantKey()]))->toHaveCount(0); +})->with([ + 'db level on cascade delete' => true, + 'event-based on cascade delete' => false, +]); + +test('pivot record is automatically deleted with the tenant resource', function() { + [$tenant] = createTenantsAndRunMigrations(); + + $syncMaster = CentralUser::create([ + 'global_id' => 'cascade_user', + 'name' => 'Central user', + 'email' => 'central@localhost', + 'password' => 'password', + 'role' => 'cascade_user', + ]); + + $syncMaster->tenants()->attach($tenant); + + expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(1); + + $tenant->run(function () { + TenantUser::firstWhere('global_id', 'cascade_user')->delete(); + }); + + // Deleting tenant resource deletes its pivot record + expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(0); + + // The same works with forceDelete + addExtraColumns(true); + + $syncMaster = CentralUserWithSoftDeletes::create([ + 'global_id' => 'force_cascade_user', + 'name' => 'Central user', + 'email' => 'central2@localhost', + 'password' => 'password', + 'role' => 'force_cascade_user', + 'foo' => 'bar', + ]); + + $syncMaster->tenants()->attach($tenant); + + expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(1); + + $tenant->run(function () { + TenantUserWithSoftDeletes::firstWhere('global_id', 'force_cascade_user')->forceDelete(); + }); + + expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(0); }); test('trashed resources are synced correctly', function () { @@ -1265,6 +1322,14 @@ test('global scopes on syncable models can break resource syncing', function () expect($tenant1->run(fn () => TenantUser::first()->name))->toBe('tenant2 user'); }); +function addFkConstraintsToTenantUsersPivot(): void +{ + Schema::table('tenant_users', function (Blueprint $table) { + $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade'); + $table->foreign('global_user_id')->references('global_id')->on('users')->onDelete('cascade'); + }); +} + /** * Create two tenants and run migrations for those tenants. * From 628f357f32d9c2d50c4e9fc24de5572fc8399147 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 4 Nov 2025 16:52:39 +0100 Subject: [PATCH 2/4] Syncing: Add DeleteAllTenantMappings listener --- assets/TenancyServiceProvider.stub.php | 2 + .../Listeners/DeleteAllTenantMappings.php | 38 +++++++++++ tests/ResourceSyncingTest.php | 63 +++++++++++++++---- 3 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 src/ResourceSyncing/Listeners/DeleteAllTenantMappings.php diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 2e7819a5..a1e681d7 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -81,6 +81,8 @@ class TenancyServiceProvider extends ServiceProvider ])->send(function (Events\TenantDeleted $event) { return $event->tenant; })->shouldBeQueued(false), + + // ResourceSyncing\Listeners\DeleteAllTenantMappings::class, ], Events\TenantMaintenanceModeEnabled::class => [], diff --git a/src/ResourceSyncing/Listeners/DeleteAllTenantMappings.php b/src/ResourceSyncing/Listeners/DeleteAllTenantMappings.php new file mode 100644 index 00000000..9a206872 --- /dev/null +++ b/src/ResourceSyncing/Listeners/DeleteAllTenantMappings.php @@ -0,0 +1,38 @@ + 'tenant_key_column'] format. + * + * Since we cannot automatically detect which pivot tables + * are being used, they have to be specified here manually. + * + * The default value follows the polymorphic table used by default. + */ + public static array $pivotTables = ['tenant_resources' => 'tenant_id']; + + public function handle(TenantDeleted $event): void + { + foreach (static::$pivotTables as $table => $tenantKeyColumn) { + DB::table($table)->where($tenantKeyColumn, $event->tenant->getKey())->delete(); + } + } +} diff --git a/tests/ResourceSyncingTest.php b/tests/ResourceSyncingTest.php index c64a9806..2f7417b0 100644 --- a/tests/ResourceSyncingTest.php +++ b/tests/ResourceSyncingTest.php @@ -48,7 +48,9 @@ use Illuminate\Database\QueryException; use function Stancl\Tenancy\Tests\pest; use Illuminate\Support\Facades\Schema; use Illuminate\Database\Schema\Blueprint; +use Stancl\Tenancy\Events\TenantDeleted; use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceDeleted; +use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteAllTenantMappings; use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteResourceMapping; beforeEach(function () { @@ -73,6 +75,7 @@ beforeEach(function () { CreateTenantResource::$shouldQueue = false; DeleteResourceInTenant::$shouldQueue = false; UpdateOrCreateSyncedResource::$scopeGetModelQuery = null; + DeleteAllTenantMappings::$pivotTables = ['tenant_resources' => 'tenant_id']; // Reset global scopes on models (should happen automatically but to make this more explicit) Model::clearBootedModels(); @@ -895,30 +898,51 @@ test('deleting SyncMaster automatically deletes its Syncables', function (bool $ 'basic pivot' => false, ]); -test('tenant pivot records are deleted along with the tenants to which they belong to', function(bool $dbLevelOnCascadeDelete) { +test('tenant pivot records are deleted along with the tenants to which they belong', function (bool $dbLevelOnCascadeDelete, bool $morphPivot) { [$tenant] = createTenantsAndRunMigrations(); - if ($dbLevelOnCascadeDelete) { - addFkConstraintsToTenantUsersPivot(); + if ($morphPivot) { + config(['tenancy.models.tenant' => MorphTenant::class]); + $centralUserModel = BaseCentralUser::class; + + // The default pivot table, no need to configure the listener + $pivotTable = 'tenant_resources'; + } else { + $centralUserModel = CentralUser::class; + + // Custom pivot table + $pivotTable = 'tenant_users'; } - $syncMaster = CentralUser::create([ - 'global_id' => 'cascade_user', + if ($dbLevelOnCascadeDelete) { + addTenantIdConstraintToPivot($pivotTable); + } else { + // Event-based cleanup + Event::listen(TenantDeleted::class, DeleteAllTenantMappings::class); + + DeleteAllTenantMappings::$pivotTables = [$pivotTable => 'tenant_id']; + } + + $syncMaster = $centralUserModel::create([ + 'global_id' => 'user', 'name' => 'Central user', 'email' => 'central@localhost', 'password' => 'password', - 'role' => 'cascade_user', + 'role' => 'user', ]); $syncMaster->tenants()->attach($tenant); + // Pivot records should be deleted along with the tenant $tenant->delete(); - // Deleting tenant deletes its pivot records - expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->getTenantKey()]))->toHaveCount(0); + expect(DB::select("SELECT * FROM {$pivotTable} WHERE tenant_id = ?", [$tenant->getTenantKey()]))->toHaveCount(0); })->with([ 'db level on cascade delete' => true, 'event-based on cascade delete' => false, +])->with([ + 'polymorphic pivot' => true, + 'basic pivot' => false, ]); test('pivot record is automatically deleted with the tenant resource', function() { @@ -966,6 +990,24 @@ test('pivot record is automatically deleted with the tenant resource', function( expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(0); }); +test('DeleteAllTenantMappings handles incorrect configuration correctly', function() { + Event::listen(TenantDeleted::class, DeleteAllTenantMappings::class); + + [$tenant1, $tenant2] = createTenantsAndRunMigrations(); + + // Existing table, non-existent tenant key column + // The listener should throw an 'unknown column' exception + DeleteAllTenantMappings::$pivotTables = ['tenant_users' => 'non_existent_column']; + + // Should throw an exception when tenant is deleted + expect(fn() => $tenant1->delete())->toThrow(QueryException::class, "Unknown column 'non_existent_column' in 'where clause'"); + + // Non-existent table + DeleteAllTenantMappings::$pivotTables = ['nonexistent_pivot' => 'non_existent_column']; + + expect(fn() => $tenant2->delete())->toThrow(QueryException::class, "Table 'main.nonexistent_pivot' doesn't exist"); +}); + test('trashed resources are synced correctly', function () { [$tenant1, $tenant2] = createTenantsAndRunMigrations(); migrateUsersTableForTenants(); @@ -1322,11 +1364,10 @@ test('global scopes on syncable models can break resource syncing', function () expect($tenant1->run(fn () => TenantUser::first()->name))->toBe('tenant2 user'); }); -function addFkConstraintsToTenantUsersPivot(): void +function addTenantIdConstraintToPivot(string $pivotTable): void { - Schema::table('tenant_users', function (Blueprint $table) { + Schema::table($pivotTable, function (Blueprint $table) { $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade'); - $table->foreign('global_user_id')->references('global_id')->on('users')->onDelete('cascade'); }); } From e7f460090f00a9493eea2a0030664321a9273870 Mon Sep 17 00:00:00 2001 From: Samuel Stancl Date: Tue, 18 Nov 2025 04:02:48 +0100 Subject: [PATCH 3/4] Syncing: move global ID generation logic to an overridable method Also make all resource syncing-related listener closures static. Also correct return type for getGlobalIdentifierKey to string|int. (We intentionally do not support returning null like many other "get x key" methods would since such a case might break resource syncing logic. This is also why we use inline getAttribute() in the creating listener instead of calling the method.) --- assets/TenancyServiceProvider.stub.php | 4 ++- src/ResourceSyncing/ResourceSyncing.php | 29 ++++++++++++-------- src/ResourceSyncing/TriggerSyncingEvents.php | 6 ++-- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index a1e681d7..f1b00c88 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -146,7 +146,9 @@ class TenancyServiceProvider extends ServiceProvider ResourceSyncing\Events\CentralResourceDetachedFromTenant::class => [ ResourceSyncing\Listeners\DeleteResourceInTenant::class, ], - // Fired only when a synced resource is changed in a different DB than the origin DB (to avoid infinite loops) + + // Fired only when a synced resource is changed (as a result of syncing) + // in a different DB than DB from which the change originates (to avoid infinite loops) ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase::class => [], // Storage symlinks diff --git a/src/ResourceSyncing/ResourceSyncing.php b/src/ResourceSyncing/ResourceSyncing.php index fb008966..272b7bd7 100644 --- a/src/ResourceSyncing/ResourceSyncing.php +++ b/src/ResourceSyncing/ResourceSyncing.php @@ -20,35 +20,32 @@ trait ResourceSyncing { public static function bootResourceSyncing(): void { - static::saved(function (Syncable&Model $model) { + static::saved(static function (Syncable&Model $model) { if ($model->shouldSync() && ($model->wasRecentlyCreated || $model->wasChanged($model->getSyncedAttributeNames()))) { $model->triggerSyncEvent(); } }); - static::deleted(function (Syncable&Model $model) { + static::deleted(static function (Syncable&Model $model) { if ($model->shouldSync()) { $model->triggerDeleteEvent(); } }); - static::creating(function (Syncable&Model $model) { - if (! $model->getAttribute($model->getGlobalIdentifierKeyName()) && app()->bound(UniqueIdentifierGenerator::class)) { - $model->setAttribute( - $model->getGlobalIdentifierKeyName(), - app(UniqueIdentifierGenerator::class)->generate($model) - ); + static::creating(static function (Syncable&Model $model) { + if (! $model->getAttribute($model->getGlobalIdentifierKeyName())) { + $model->generateGlobalIdentifierKey(); } }); if (in_array(SoftDeletes::class, class_uses_recursive(static::class), true)) { - static::forceDeleting(function (Syncable&Model $model) { + static::forceDeleting(static function (Syncable&Model $model) { if ($model->shouldSync()) { $model->triggerDeleteEvent(true); } }); - static::restoring(function (Syncable&Model $model) { + static::restoring(static function (Syncable&Model $model) { if ($model instanceof SyncMaster && $model->shouldSync()) { $model->triggerRestoreEvent(); } @@ -119,8 +116,18 @@ trait ResourceSyncing return 'global_id'; } - public function getGlobalIdentifierKey(): string + public function getGlobalIdentifierKey(): string|int { return $this->getAttribute($this->getGlobalIdentifierKeyName()); } + + protected function generateGlobalIdentifierKey(): void + { + if (! app()->bound(UniqueIdentifierGenerator::class)) return; + + $this->setAttribute( + $this->getGlobalIdentifierKeyName(), + app(UniqueIdentifierGenerator::class)->generate($this), + ); + } } diff --git a/src/ResourceSyncing/TriggerSyncingEvents.php b/src/ResourceSyncing/TriggerSyncingEvents.php index eec1b13d..da79df3a 100644 --- a/src/ResourceSyncing/TriggerSyncingEvents.php +++ b/src/ResourceSyncing/TriggerSyncingEvents.php @@ -20,14 +20,14 @@ trait TriggerSyncingEvents { public static function bootTriggerSyncingEvents(): void { - static::saving(function (self $pivot) { + static::saving(static function (self $pivot) { // Try getting the central resource to see if it is available // If it is not available, throw an exception to interrupt the saving process // And prevent creating a pivot record without a central resource $pivot->getCentralResourceAndTenant(); }); - static::saved(function (self $pivot) { + static::saved(static function (self $pivot) { /** * @var static&Pivot $pivot * @var SyncMaster|null $centralResource @@ -40,7 +40,7 @@ trait TriggerSyncingEvents } }); - static::deleting(function (self $pivot) { + static::deleting(static function (self $pivot) { /** * @var static&Pivot $pivot * @var SyncMaster|null $centralResource From 66fdf96c1ce7cc8715277bf4c39e2e5f9edb7f0f Mon Sep 17 00:00:00 2001 From: Samuel Stancl Date: Tue, 25 Nov 2025 04:29:28 +0100 Subject: [PATCH 4/4] [MINOR BC BREAK] Syncing: PivotWithRelation -> PivotWithCentralResource The old names of the class and method were misleading. We don't actually need any relation. And we don't even need a model instance as we were returning previously -- the only use of that method was in TriggerSyncingEvents which would immediately use ::class on the returned value. Therefore, all we are asking for in this interface is just the central resource class. --- ...entralResourceNotAvailableInPivotException.php | 2 +- src/ResourceSyncing/PivotWithCentralResource.php | 11 +++++++++++ src/ResourceSyncing/PivotWithRelation.php | 15 --------------- src/ResourceSyncing/TriggerSyncingEvents.php | 6 +++--- tests/Etc/ResourceSyncing/CustomPivot.php | 15 ++++----------- tests/ResourceSyncingTest.php | 8 ++++---- 6 files changed, 23 insertions(+), 34 deletions(-) create mode 100644 src/ResourceSyncing/PivotWithCentralResource.php delete mode 100644 src/ResourceSyncing/PivotWithRelation.php diff --git a/src/ResourceSyncing/CentralResourceNotAvailableInPivotException.php b/src/ResourceSyncing/CentralResourceNotAvailableInPivotException.php index d20415be..fbb918dd 100644 --- a/src/ResourceSyncing/CentralResourceNotAvailableInPivotException.php +++ b/src/ResourceSyncing/CentralResourceNotAvailableInPivotException.php @@ -13,7 +13,7 @@ class CentralResourceNotAvailableInPivotException extends Exception parent::__construct( 'Central resource is not accessible in pivot model. To attach a resource to a tenant, use $centralResource->tenants()->attach($tenant) instead of $tenant->resources()->attach($centralResource) (same for detaching). - To make this work both ways, you can make your pivot implement PivotWithRelation and return the related model in getRelatedModel() or extend MorphPivot.' + To make this work both ways, you can make your pivot implement PivotWithCentralResource and return the related model in getCentralResourceClass() or extend MorphPivot.' ); } } diff --git a/src/ResourceSyncing/PivotWithCentralResource.php b/src/ResourceSyncing/PivotWithCentralResource.php new file mode 100644 index 00000000..07efcc2e --- /dev/null +++ b/src/ResourceSyncing/PivotWithCentralResource.php @@ -0,0 +1,11 @@ + */ + public function getCentralResourceClass(): string; +} diff --git a/src/ResourceSyncing/PivotWithRelation.php b/src/ResourceSyncing/PivotWithRelation.php deleted file mode 100644 index 4936d1fe..00000000 --- a/src/ResourceSyncing/PivotWithRelation.php +++ /dev/null @@ -1,15 +0,0 @@ -users()->getModel(). - */ - public function getRelatedModel(): Model; -} diff --git a/src/ResourceSyncing/TriggerSyncingEvents.php b/src/ResourceSyncing/TriggerSyncingEvents.php index da79df3a..2f8914b5 100644 --- a/src/ResourceSyncing/TriggerSyncingEvents.php +++ b/src/ResourceSyncing/TriggerSyncingEvents.php @@ -79,9 +79,9 @@ trait TriggerSyncingEvents */ protected function getResourceClass(): string { - /** @var $this&(Pivot|MorphPivot|((Pivot|MorphPivot)&PivotWithRelation)) $this */ - if ($this instanceof PivotWithRelation) { - return $this->getRelatedModel()::class; + /** @var $this&(Pivot|MorphPivot|((Pivot|MorphPivot)&PivotWithCentralResource)) $this */ + if ($this instanceof PivotWithCentralResource) { + return $this->getCentralResourceClass(); } if ($this instanceof MorphPivot) { diff --git a/tests/Etc/ResourceSyncing/CustomPivot.php b/tests/Etc/ResourceSyncing/CustomPivot.php index 00a019c9..2ffca4c0 100644 --- a/tests/Etc/ResourceSyncing/CustomPivot.php +++ b/tests/Etc/ResourceSyncing/CustomPivot.php @@ -4,20 +4,13 @@ declare(strict_types=1); namespace Stancl\Tenancy\Tests\Etc\ResourceSyncing; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsToMany; -use Stancl\Tenancy\ResourceSyncing\PivotWithRelation; +use Stancl\Tenancy\ResourceSyncing\PivotWithCentralResource; use Stancl\Tenancy\ResourceSyncing\TenantPivot; -class CustomPivot extends TenantPivot implements PivotWithRelation +class CustomPivot extends TenantPivot implements PivotWithCentralResource { - public function users(): BelongsToMany + public function getCentralResourceClass(): string { - return $this->belongsToMany(CentralUser::class); - } - - public function getRelatedModel(): Model - { - return $this->users()->getModel(); + return CentralUser::class; } } diff --git a/tests/ResourceSyncingTest.php b/tests/ResourceSyncingTest.php index 2f7417b0..826ed780 100644 --- a/tests/ResourceSyncingTest.php +++ b/tests/ResourceSyncingTest.php @@ -263,7 +263,7 @@ test('attaching central resources to tenants or vice versa creates synced tenant expect(TenantUser::all())->toHaveCount(0); }); - // Attaching resources to tenants requires using a pivot that implements the PivotWithRelation interface + // Attaching resources to tenants requires using a pivot that implements the PivotWithCentralResource interface $tenant->customPivotUsers()->attach($createCentralUser()); $createCentralUser()->tenants()->attach($tenant); @@ -287,7 +287,7 @@ test('detaching central users from tenants or vice versa force deletes the synce migrateUsersTableForTenants(); if ($attachUserToTenant) { - // Attaching resources to tenants requires using a pivot that implements the PivotWithRelation interface + // Attaching resources to tenants requires using a pivot that implements the PivotWithCentralResource interface $tenant->customPivotUsers()->attach($centralUser); } else { $centralUser->tenants()->attach($tenant); @@ -298,7 +298,7 @@ test('detaching central users from tenants or vice versa force deletes the synce }); if ($attachUserToTenant) { - // Detaching resources from tenants requires using a pivot that implements the PivotWithRelation interface + // Detaching resources from tenants requires using a pivot that implements the PivotWithCentralResource interface $tenant->customPivotUsers()->detach($centralUser); } else { $centralUser->tenants()->detach($tenant); @@ -333,7 +333,7 @@ test('detaching central users from tenants or vice versa force deletes the synce }); if ($attachUserToTenant) { - // Detaching resources from tenants requires using a pivot that implements the PivotWithRelation interface + // Detaching resources from tenants requires using a pivot that implements the PivotWithCentralResource interface $tenant->customPivotUsers()->detach($centralUserWithSoftDeletes); } else { $centralUserWithSoftDeletes->tenants()->detach($tenant);