1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2025-12-12 19:04:02 +00:00

Syncing: Add DeleteAllTenantMappings listener

This commit is contained in:
lukinovec 2025-11-04 16:52:39 +01:00 committed by Samuel Stancl
parent 5b15c67d9e
commit ade6e4182c
3 changed files with 92 additions and 11 deletions

View file

@ -81,6 +81,8 @@ class TenancyServiceProvider extends ServiceProvider
])->send(function (Events\TenantDeleted $event) { ])->send(function (Events\TenantDeleted $event) {
return $event->tenant; return $event->tenant;
})->shouldBeQueued(false), })->shouldBeQueued(false),
// ResourceSyncing\Listeners\DeleteAllTenantMappings::class,
], ],
Events\TenantMaintenanceModeEnabled::class => [], Events\TenantMaintenanceModeEnabled::class => [],

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Listeners;
use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Events\TenantDeleted;
use Stancl\Tenancy\Listeners\QueueableListener;
/**
* Cleans up pivot records related to the deleted tenant.
*
* The listener only cleans up the pivot tables specified
* in the $pivotTables property (see the property for details),
* and is intended for use with tables that do not have tenant
* foreign key constraints with onDelete('cascade').
*/
class DeleteAllTenantMappings extends QueueableListener
{
/**
* Pivot tables to clean up after a tenant is deleted, in the
* ['table_name' => '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();
}
}
}

View file

@ -48,7 +48,9 @@ use Illuminate\Database\QueryException;
use function Stancl\Tenancy\Tests\pest; use function Stancl\Tenancy\Tests\pest;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Stancl\Tenancy\Events\TenantDeleted;
use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceDeleted; use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceDeleted;
use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteAllTenantMappings;
use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteResourceMapping; use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteResourceMapping;
beforeEach(function () { beforeEach(function () {
@ -73,6 +75,7 @@ beforeEach(function () {
CreateTenantResource::$shouldQueue = false; CreateTenantResource::$shouldQueue = false;
DeleteResourceInTenant::$shouldQueue = false; DeleteResourceInTenant::$shouldQueue = false;
UpdateOrCreateSyncedResource::$scopeGetModelQuery = null; UpdateOrCreateSyncedResource::$scopeGetModelQuery = null;
DeleteAllTenantMappings::$pivotTables = ['tenant_resources' => 'tenant_id'];
// Reset global scopes on models (should happen automatically but to make this more explicit) // Reset global scopes on models (should happen automatically but to make this more explicit)
Model::clearBootedModels(); Model::clearBootedModels();
@ -895,30 +898,51 @@ test('deleting SyncMaster automatically deletes its Syncables', function (bool $
'basic pivot' => false, '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(); [$tenant] = createTenantsAndRunMigrations();
if ($dbLevelOnCascadeDelete) { if ($morphPivot) {
addFkConstraintsToTenantUsersPivot(); 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([ if ($dbLevelOnCascadeDelete) {
'global_id' => 'cascade_user', 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', 'name' => 'Central user',
'email' => 'central@localhost', 'email' => 'central@localhost',
'password' => 'password', 'password' => 'password',
'role' => 'cascade_user', 'role' => 'user',
]); ]);
$syncMaster->tenants()->attach($tenant); $syncMaster->tenants()->attach($tenant);
// Pivot records should be deleted along with the tenant
$tenant->delete(); $tenant->delete();
// Deleting tenant deletes its pivot records expect(DB::select("SELECT * FROM {$pivotTable} WHERE tenant_id = ?", [$tenant->getTenantKey()]))->toHaveCount(0);
expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->getTenantKey()]))->toHaveCount(0);
})->with([ })->with([
'db level on cascade delete' => true, 'db level on cascade delete' => true,
'event-based on cascade delete' => false, 'event-based on cascade delete' => false,
])->with([
'polymorphic pivot' => true,
'basic pivot' => false,
]); ]);
test('pivot record is automatically deleted with the tenant resource', function() { test('pivot record is automatically deleted with the tenant resource', function() {
@ -944,6 +968,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); 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 () { test('trashed resources are synced correctly', function () {
[$tenant1, $tenant2] = createTenantsAndRunMigrations(); [$tenant1, $tenant2] = createTenantsAndRunMigrations();
migrateUsersTableForTenants(); migrateUsersTableForTenants();
@ -1300,11 +1342,10 @@ test('global scopes on syncable models can break resource syncing', function ()
expect($tenant1->run(fn () => TenantUser::first()->name))->toBe('tenant2 user'); 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('tenant_id')->references('id')->on('tenants')->onDelete('cascade');
$table->foreign('global_user_id')->references('global_id')->on('users')->onDelete('cascade');
}); });
} }