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

Add and test DeleteAllTenantMappings, use the listener on TenantDeleted in TSP stub by default

This commit is contained in:
lukinovec 2025-11-04 16:52:39 +01:00
parent de6249216a
commit 41c18cfe14
3 changed files with 116 additions and 10 deletions

View file

@ -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),

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Listeners;
use Stancl\Tenancy\Listeners\QueueableListener;
use Stancl\Tenancy\Events\TenantDeleted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* When a tenant is deleted, clean up pivot records related to that tenant.
* Only use this listener for cleaning up tables without tenant foreign key constraints.
*
* If you're using foreign key constraints on the tenant key columns in your pivot tables,
* you still must include ->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();
}
}
}
}

View file

@ -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');
});
}