From 41c18cfe148d131be0786781dbb1cea7c2e27eb7 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 4 Nov 2025 16:52:39 +0100 Subject: [PATCH] Add and test DeleteAllTenantMappings, use the listener on TenantDeleted in TSP stub by default --- assets/TenancyServiceProvider.stub.php | 1 + .../Listeners/DeleteAllTenantMappings.php | 68 +++++++++++++++++++ tests/ResourceSyncingTest.php | 57 +++++++++++++--- 3 files changed, 116 insertions(+), 10 deletions(-) create mode 100644 src/ResourceSyncing/Listeners/DeleteAllTenantMappings.php diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 9d667d2c..5bd51bca 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -78,6 +78,7 @@ class TenancyServiceProvider extends ServiceProvider Events\TenantDeleted::class => [ JobPipeline::make([ Jobs\DeleteDatabase::class, + ResourceSyncing\Listeners\DeleteAllTenantMappings::class, ])->send(function (Events\TenantDeleted $event) { return $event->tenant; })->shouldBeQueued(false), diff --git a/src/ResourceSyncing/Listeners/DeleteAllTenantMappings.php b/src/ResourceSyncing/Listeners/DeleteAllTenantMappings.php new file mode 100644 index 00000000..6ff9e6c5 --- /dev/null +++ b/src/ResourceSyncing/Listeners/DeleteAllTenantMappings.php @@ -0,0 +1,68 @@ +onDelete('cascade') in the constraint definition + * for the pivot records to be deleted automatically. Without ->onDelete('cascade'), + * the constraint will prevent tenant deletion before this listener can clean up the pivot records, + * causing an integrity constraint violation. + * + * With ->onDelete('cascade'), the database will handle the cleanup automatically, + * so there's no need to use this listener (it won't break anything, but it's redundant). + * + * By default, this listener only cleans up the 'tenant_resources' polymorphic pivot table, + * and the records to delete are found by the 'tenant_id' column. + * + * To customize which pivot tables to clean up (or which column has the tenant key), + * set DeleteAllTenantMappings::$pivotTables to an array of table names as the keys, + * and their values should be tenant key column names (e.g. 'tenant_id'). + * + * For example (e.g. in TenancyServiceProvider): + * DeleteAllTenantMappings::$pivotTables = [ + * 'tenant_users' => 'tenant_id', + * ]; + * + * Tables that do not exist will be skipped. + */ +class DeleteAllTenantMappings extends QueueableListener +{ + /** + * Pivot tables to clean up after a tenant is deleted, + * formatted like ['table_name' => 'tenant_key_column']. + * E.g. ['tenant_users' => 'tenant_id']. + * + * If empty, the listener defaults to cleaning only + * the default pivot ('tenant_resources' with 'tenant_id' as the tenant key column). + * + * Set this property, e.g. in your TenancyServiceProvider, + * for this listener to clean up specific pivot tables. + */ + public static array $pivotTables = []; + + public function handle(TenantDeleted $event): void + { + $pivotTables = static::$pivotTables; + + if (! $pivotTables) { + $pivotTables = ['tenant_resources' => 'tenant_id']; + } + + foreach ($pivotTables as $table => $tenantKeyColumn) { + if (Schema::hasTable($table)) { + DB::table($table)->where($tenantKeyColumn, $event->tenant->getTenantKey())->delete(); + } + } + } +} diff --git a/tests/ResourceSyncingTest.php b/tests/ResourceSyncingTest.php index d41ed7ee..6b9b595d 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 = []; // Reset global scopes on models (should happen automatically but to make this more explicit) Model::clearBootedModels(); @@ -101,6 +104,7 @@ beforeEach(function () { Event::listen(SyncMasterRestored::class, RestoreResourcesInTenants::class); Event::listen(CentralResourceAttachedToTenant::class, CreateTenantResource::class); Event::listen(CentralResourceDetachedFromTenant::class, DeleteResourceInTenant::class); + Event::listen(TenantDeleted::class, DeleteAllTenantMappings::class); // Run migrations on central connection pest()->artisan('migrate', [ @@ -895,19 +899,34 @@ 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 to', 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'; + + DeleteAllTenantMappings::$pivotTables = [$pivotTable => 'tenant_id']; } - $syncMaster = CentralUser::create([ - 'global_id' => 'cascade_user', + if ($dbLevelOnCascadeDelete) { + addTenantIdConstraintToPivot($pivotTable); + } + + $syncMaster = $centralUserModel::create([ + 'global_id' => 'user', 'name' => 'Central user', 'email' => 'central@localhost', 'password' => 'password', - 'role' => 'cascade_user', + 'role' => 'user', ]); $syncMaster->tenants()->attach($tenant); @@ -915,10 +934,13 @@ test('tenant pivot records are deleted along with the tenants to which they belo $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() { @@ -942,6 +964,22 @@ test('pivot record is automatically deleted with the tenant resource', function( expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->getTenantKey()]))->toHaveCount(0); }); +test('DeleteAllTenantMappings handles incorrect configuration correctly', function() { + [$tenant1, $tenant2] = createTenantsAndRunMigrations(); + + // Existing table, non-existent tenant key column + // The listener should throw an 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, the listener skips it, no exception to throw + DeleteAllTenantMappings::$pivotTables = ['nonexistent_pivot' => 'non_existent_column']; + + expect(fn() => $tenant2->delete())->not()->toThrow(Exception::class); +}); + test('trashed resources are synced correctly', function () { [$tenant1, $tenant2] = createTenantsAndRunMigrations(); migrateUsersTableForTenants(); @@ -1298,11 +1336,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'); }); }