Skip to content

Commit bc06da8

Browse files
lukinovecstancl
authored andcommitted
Syncing: Add DeleteAllTenantMappings listener
1 parent ee0b45d commit bc06da8

File tree

3 files changed

+91
-10
lines changed

3 files changed

+91
-10
lines changed

assets/TenancyServiceProvider.stub.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ public function events()
8181
])->send(function (Events\TenantDeleted $event) {
8282
return $event->tenant;
8383
})->shouldBeQueued(false),
84+
85+
// ResourceSyncing\Listeners\DeleteAllTenantMappings::class,
8486
],
8587

8688
Events\TenantMaintenanceModeEnabled::class => [],
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Stancl\Tenancy\ResourceSyncing\Listeners;
6+
7+
use Illuminate\Support\Facades\DB;
8+
use Stancl\Tenancy\Events\TenantDeleted;
9+
use Stancl\Tenancy\Listeners\QueueableListener;
10+
11+
/**
12+
* Cleans up pivot records related to the deleted tenant.
13+
*
14+
* The listener only cleans up the pivot tables specified
15+
* in the $pivotTables property (see the property for details),
16+
* and is intended for use with tables that do not have tenant
17+
* foreign key constraints with onDelete('cascade').
18+
*/
19+
class DeleteAllTenantMappings extends QueueableListener
20+
{
21+
/**
22+
* Pivot tables to clean up after a tenant is deleted, in the
23+
* ['table_name' => 'tenant_key_column'] format.
24+
*
25+
* Since we cannot automatically detect which pivot tables
26+
* are being used, they have to be specified here manually.
27+
*
28+
* The default value follows the polymorphic table used by default.
29+
*/
30+
public static array $pivotTables = ['tenant_resources' => 'tenant_id'];
31+
32+
public function handle(TenantDeleted $event): void
33+
{
34+
foreach (static::$pivotTables as $table => $tenantKeyColumn) {
35+
DB::table($table)->where($tenantKeyColumn, $event->tenant->getKey())->delete();
36+
}
37+
}
38+
}

tests/ResourceSyncingTest.php

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@
4848
use function Stancl\Tenancy\Tests\pest;
4949
use Illuminate\Support\Facades\Schema;
5050
use Illuminate\Database\Schema\Blueprint;
51+
use Stancl\Tenancy\Events\TenantDeleted;
5152
use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceDeleted;
53+
use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteAllTenantMappings;
5254
use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteResourceMapping;
5355

5456
beforeEach(function () {
@@ -73,6 +75,7 @@
7375
CreateTenantResource::$shouldQueue = false;
7476
DeleteResourceInTenant::$shouldQueue = false;
7577
UpdateOrCreateSyncedResource::$scopeGetModelQuery = null;
78+
DeleteAllTenantMappings::$pivotTables = ['tenant_resources' => 'tenant_id'];
7679

7780
// Reset global scopes on models (should happen automatically but to make this more explicit)
7881
Model::clearBootedModels();
@@ -895,30 +898,51 @@
895898
'basic pivot' => false,
896899
]);
897900

898-
test('tenant pivot records are deleted along with the tenants to which they belong to', function(bool $dbLevelOnCascadeDelete) {
901+
test('tenant pivot records are deleted along with the tenants to which they belong', function (bool $dbLevelOnCascadeDelete, bool $morphPivot) {
899902
[$tenant] = createTenantsAndRunMigrations();
900903

904+
if ($morphPivot) {
905+
config(['tenancy.models.tenant' => MorphTenant::class]);
906+
$centralUserModel = BaseCentralUser::class;
907+
908+
// The default pivot table, no need to configure the listener
909+
$pivotTable = 'tenant_resources';
910+
} else {
911+
$centralUserModel = CentralUser::class;
912+
913+
// Custom pivot table
914+
$pivotTable = 'tenant_users';
915+
}
916+
901917
if ($dbLevelOnCascadeDelete) {
902-
addFkConstraintsToTenantUsersPivot();
918+
addTenantIdConstraintToPivot($pivotTable);
919+
} else {
920+
// Event-based cleanup
921+
Event::listen(TenantDeleted::class, DeleteAllTenantMappings::class);
922+
923+
DeleteAllTenantMappings::$pivotTables = [$pivotTable => 'tenant_id'];
903924
}
904925

905-
$syncMaster = CentralUser::create([
906-
'global_id' => 'cascade_user',
926+
$syncMaster = $centralUserModel::create([
927+
'global_id' => 'user',
907928
'name' => 'Central user',
908929
'email' => 'central@localhost',
909930
'password' => 'password',
910-
'role' => 'cascade_user',
931+
'role' => 'user',
911932
]);
912933

913934
$syncMaster->tenants()->attach($tenant);
914935

936+
// Pivot records should be deleted along with the tenant
915937
$tenant->delete();
916938

917-
// Deleting tenant deletes its pivot records
918-
expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->getTenantKey()]))->toHaveCount(0);
939+
expect(DB::select("SELECT * FROM {$pivotTable} WHERE tenant_id = ?", [$tenant->getTenantKey()]))->toHaveCount(0);
919940
})->with([
920941
'db level on cascade delete' => true,
921942
'event-based on cascade delete' => false,
943+
])->with([
944+
'polymorphic pivot' => true,
945+
'basic pivot' => false,
922946
]);
923947

924948
test('pivot record is automatically deleted with the tenant resource', function() {
@@ -944,6 +968,24 @@
944968
expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(0);
945969
});
946970

971+
test('DeleteAllTenantMappings handles incorrect configuration correctly', function() {
972+
Event::listen(TenantDeleted::class, DeleteAllTenantMappings::class);
973+
974+
[$tenant1, $tenant2] = createTenantsAndRunMigrations();
975+
976+
// Existing table, non-existent tenant key column
977+
// The listener should throw an 'unknown column' exception
978+
DeleteAllTenantMappings::$pivotTables = ['tenant_users' => 'non_existent_column'];
979+
980+
// Should throw an exception when tenant is deleted
981+
expect(fn() => $tenant1->delete())->toThrow(QueryException::class, "Unknown column 'non_existent_column' in 'where clause'");
982+
983+
// Non-existent table
984+
DeleteAllTenantMappings::$pivotTables = ['nonexistent_pivot' => 'non_existent_column'];
985+
986+
expect(fn() => $tenant2->delete())->toThrow(QueryException::class, "Table 'main.nonexistent_pivot' doesn't exist");
987+
});
988+
947989
test('trashed resources are synced correctly', function () {
948990
[$tenant1, $tenant2] = createTenantsAndRunMigrations();
949991
migrateUsersTableForTenants();
@@ -1300,11 +1342,10 @@
13001342
expect($tenant1->run(fn () => TenantUser::first()->name))->toBe('tenant2 user');
13011343
});
13021344

1303-
function addFkConstraintsToTenantUsersPivot(): void
1345+
function addTenantIdConstraintToPivot(string $pivotTable): void
13041346
{
1305-
Schema::table('tenant_users', function (Blueprint $table) {
1347+
Schema::table($pivotTable, function (Blueprint $table) {
13061348
$table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade');
1307-
$table->foreign('global_user_id')->references('global_id')->on('users')->onDelete('cascade');
13081349
});
13091350
}
13101351

0 commit comments

Comments
 (0)