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

Compare commits

...

2 commits

Author SHA1 Message Date
lukinovec
a75528e228 Syncing: Add DeleteAllTenantMappings listener 2025-11-19 20:33:24 +01:00
lukinovec
cd08becf3d Syncing: SyncedResourceDeleted event and DeleteResourceMapping listener
Also move pivot record deletion to that listener and improve tests
2025-11-19 20:33:13 +01:00
11 changed files with 226 additions and 19 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 => [],
@ -129,6 +131,9 @@ class TenancyServiceProvider extends ServiceProvider
ResourceSyncing\Events\SyncedResourceSaved::class => [ ResourceSyncing\Events\SyncedResourceSaved::class => [
ResourceSyncing\Listeners\UpdateOrCreateSyncedResource::class, ResourceSyncing\Listeners\UpdateOrCreateSyncedResource::class,
], ],
ResourceSyncing\Events\SyncedResourceDeleted::class => [
ResourceSyncing\Listeners\DeleteResourceMapping::class,
],
ResourceSyncing\Events\SyncMasterDeleted::class => [ ResourceSyncing\Events\SyncMasterDeleted::class => [
ResourceSyncing\Listeners\DeleteResourcesInTenants::class, ResourceSyncing\Listeners\DeleteResourcesInTenants::class,
], ],

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Events;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\ResourceSyncing\Syncable;
class SyncedResourceDeleted
{
public function __construct(
public Syncable&Model $model,
public TenantWithDatabase|null $tenant,
public bool $forceDelete,
) {}
}

View file

@ -0,0 +1,48 @@
<?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;
/**
* Clean 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.
*
* When using foreign key constraints, you'll still have to use ->onDelete('cascade')
* on the constraint (otherwise, deleting a tenant will throw a foreign key constraint violation).
* That way, the cleanup will happen on the database level, and this listener will essentially
* just perform an extra 'where' query.
*/
class DeleteAllTenantMappings extends QueueableListener
{
/**
* Pivot tables to clean up after a tenant is deleted,
* formatted like ['table_name' => 'tenant_key_column'].
*
* Since we cannot automatically detect which pivot tables
* you want to clean up, they have to be specified here.
*
* By default, resource syncing uses the tenant_resources table, and the records are associated
* to tenants by the tenant_id column (thus the ['tenant_resources' => 'tenant_id'] default).
*
* To customize this, set this property, e.g. in TenancyServiceProvider:
* DeleteAllTenantMappings::$pivotTables = [
* 'tenant_users' => 'tenant_id',
* // You can also add more pivot tables here
* ];
*/
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->getTenantKey())->delete();
}
}
}

View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Listeners;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Database\Eloquent\SoftDeletes;
use Stancl\Tenancy\Listeners\QueueableListener;
use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceDeleted;
use Stancl\Tenancy\ResourceSyncing\Syncable;
use Stancl\Tenancy\ResourceSyncing\SyncMaster;
/**
* Deletes pivot records when a synced resource is deleted.
*
* If a SyncMaster (central resource) is deleted, all pivot records for that resource are deleted.
* If a Syncable (tenant resource) is deleted, only delete the pivot record for that tenant.
*/
class DeleteResourceMapping extends QueueableListener
{
public static bool $shouldQueue = false;
public function handle(SyncedResourceDeleted $event): void
{
$centralResource = $this->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) {
// $event->tenant is null when the deleted resource is a SyncMaster - all mappings are deleted in that case
// When $event->tenant is not null (= a Syncable was deleted), only delete the mapping for that tenant
$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;
}
}

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Listeners; namespace Stancl\Tenancy\ResourceSyncing\Listeners;
use Illuminate\Database\Eloquent\SoftDeletes;
use Stancl\Tenancy\Listeners\QueueableListener; use Stancl\Tenancy\Listeners\QueueableListener;
use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterDeleted; use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterDeleted;
@ -21,12 +20,6 @@ class DeleteResourcesInTenants extends QueueableListener
tenancy()->runForMultiple($centralResource->tenants()->cursor(), function () use ($centralResource, $forceDelete) { tenancy()->runForMultiple($centralResource->tenants()->cursor(), function () use ($centralResource, $forceDelete) {
$this->deleteSyncedResource($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());
}
}); });
} }
} }

View file

@ -11,6 +11,7 @@ use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceAttachedToTenant; use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceAttachedToTenant;
use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceDetachedFromTenant; use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceDetachedFromTenant;
use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceDeleted;
use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSaved; use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSaved;
use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterDeleted; use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterDeleted;
use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterRestored; use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterRestored;
@ -25,8 +26,8 @@ trait ResourceSyncing
} }
}); });
static::deleting(function (Syncable&Model $model) { static::deleted(function (Syncable&Model $model) {
if ($model->shouldSync() && $model instanceof SyncMaster) { if ($model->shouldSync()) {
$model->triggerDeleteEvent(); $model->triggerDeleteEvent();
} }
}); });
@ -42,13 +43,13 @@ trait ResourceSyncing
if (in_array(SoftDeletes::class, class_uses_recursive(static::class), true)) { if (in_array(SoftDeletes::class, class_uses_recursive(static::class), true)) {
static::forceDeleting(function (Syncable&Model $model) { static::forceDeleting(function (Syncable&Model $model) {
if ($model->shouldSync() && $model instanceof SyncMaster) { if ($model->shouldSync()) {
$model->triggerDeleteEvent(true); $model->triggerDeleteEvent(true);
} }
}); });
static::restoring(function (Syncable&Model $model) { static::restoring(function (Syncable&Model $model) {
if ($model->shouldSync() && $model instanceof SyncMaster) { if ($model->shouldSync()) {
$model->triggerRestoredEvent(); $model->triggerRestoredEvent();
} }
}); });
@ -67,6 +68,8 @@ trait ResourceSyncing
/** @var SyncMaster&Model $this */ /** @var SyncMaster&Model $this */
event(new SyncMasterDeleted($this, $forceDelete)); event(new SyncMasterDeleted($this, $forceDelete));
} }
event(new SyncedResourceDeleted($this, tenant(), $forceDelete));
} }
public function triggerRestoredEvent(): void public function triggerRestoredEvent(): void

View file

@ -25,7 +25,5 @@ interface SyncMaster extends Syncable
public function triggerAttachEvent(TenantWithDatabase&Model $tenant): void; public function triggerAttachEvent(TenantWithDatabase&Model $tenant): void;
public function triggerDeleteEvent(bool $forceDelete = false): void;
public function triggerRestoredEvent(): void; public function triggerRestoredEvent(): void;
} }

View file

@ -16,6 +16,8 @@ interface Syncable
public function triggerSyncEvent(): void; 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). * 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).
* *

View file

@ -12,6 +12,7 @@ use Stancl\Tenancy\ResourceSyncing\SyncMaster;
class CentralUser extends Model implements SyncMaster class CentralUser extends Model implements SyncMaster
{ {
use ResourceSyncing, CentralConnection; use ResourceSyncing, CentralConnection;
protected $guarded = []; protected $guarded = [];
public $timestamps = false; public $timestamps = false;

View file

@ -16,9 +16,6 @@ class CreateTenantUsersTable extends Migration
$table->string('global_user_id'); $table->string('global_user_id');
$table->unique(['tenant_id', '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');
}); });
} }

View file

@ -46,6 +46,12 @@ use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase;
use Illuminate\Database\Eloquent\Scope; use Illuminate\Database\Eloquent\Scope;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use function Stancl\Tenancy\Tests\pest; 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 () { beforeEach(function () {
config(['tenancy.bootstrappers' => [ config(['tenancy.bootstrappers' => [
@ -69,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();
@ -92,6 +99,7 @@ beforeEach(function () {
CentralUser::$creationAttributes = $creationAttributes; CentralUser::$creationAttributes = $creationAttributes;
Event::listen(SyncedResourceSaved::class, UpdateOrCreateSyncedResource::class); Event::listen(SyncedResourceSaved::class, UpdateOrCreateSyncedResource::class);
Event::listen(SyncedResourceDeleted::class, DeleteResourceMapping::class);
Event::listen(SyncMasterDeleted::class, DeleteResourcesInTenants::class); Event::listen(SyncMasterDeleted::class, DeleteResourcesInTenants::class);
Event::listen(SyncMasterRestored::class, RestoreResourcesInTenants::class); Event::listen(SyncMasterRestored::class, RestoreResourcesInTenants::class);
Event::listen(CentralResourceAttachedToTenant::class, CreateTenantResource::class); Event::listen(CentralResourceAttachedToTenant::class, CreateTenantResource::class);
@ -890,7 +898,54 @@ 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() { test('tenant pivot records are deleted along with the tenants to which they belong to', function (bool $dbLevelOnCascadeDelete, bool $morphPivot) {
[$tenant] = createTenantsAndRunMigrations();
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';
}
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' => 'user',
]);
$syncMaster->tenants()->attach($tenant);
// Pivot records should be deleted along with the tenant
$tenant->delete();
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() {
[$tenant] = createTenantsAndRunMigrations(); [$tenant] = createTenantsAndRunMigrations();
$syncMaster = CentralUser::create([ $syncMaster = CentralUser::create([
@ -903,12 +958,32 @@ test('tenant pivot records are deleted along with the tenants to which they belo
$syncMaster->tenants()->attach($tenant); $syncMaster->tenants()->attach($tenant);
$tenant->delete(); $tenant->run(function () {
TenantUser::firstWhere('global_id', 'cascade_user')->delete();
});
// Deleting tenant deletes its pivot records // Deleting tenant resource deletes its pivot record
expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->getTenantKey()]))->toHaveCount(0); expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->getTenantKey()]))->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();
@ -1265,6 +1340,13 @@ 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 addTenantIdConstraintToPivot(string $pivotTable): void
{
Schema::table($pivotTable, function (Blueprint $table) {
$table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade');
});
}
/** /**
* Create two tenants and run migrations for those tenants. * Create two tenants and run migrations for those tenants.
* *