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

Compare commits

...

3 commits

Author SHA1 Message Date
3974ad046e
Syncing: move global ID generation logic to an overridable method
Also make all resource syncing-related listener closures static.

Also correct return type for getGlobalIdentifierKey to string|int.
(We intentionally do not support returning null like many other
"get x key" methods would since such a case might break resource
syncing logic. This is also why we use inline getAttribute() in the
creating listener instead of calling the method.)
2025-11-21 02:03:36 +01:00
lukinovec
cfae527c93 Syncing: Add DeleteAllTenantMappings listener 2025-11-21 02:03:36 +01:00
lukinovec
ee0b45d800 Syncing: SyncedResourceDeleted event and DeleteResourceMapping listener
Also move pivot record deletion to that listener and improve tests

The 'tenant pivot records are deleted along with the tenants to which
they belong to' test is failing in this commit -- the listener
for deleting mappings when a *tenant* is deleted is only implemented
in the next commit. The only change done here is to re-add FKs
(necessary for passing *in this commit* in that specific dataset
variant) that were removed from the default test migration as we now
have the the DeleteResourceMapping listener that's enabled by default.
2025-11-21 02:03:36 +01:00
12 changed files with 249 additions and 33 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;
@ -19,36 +20,33 @@ trait ResourceSyncing
{ {
public static function bootResourceSyncing(): void public static function bootResourceSyncing(): void
{ {
static::saved(function (Syncable&Model $model) { static::saved(static function (Syncable&Model $model) {
if ($model->shouldSync() && ($model->wasRecentlyCreated || $model->wasChanged($model->getSyncedAttributeNames()))) { if ($model->shouldSync() && ($model->wasRecentlyCreated || $model->wasChanged($model->getSyncedAttributeNames()))) {
$model->triggerSyncEvent(); $model->triggerSyncEvent();
} }
}); });
static::deleting(function (Syncable&Model $model) { static::deleted(static function (Syncable&Model $model) {
if ($model->shouldSync() && $model instanceof SyncMaster) { if ($model->shouldSync()) {
$model->triggerDeleteEvent(); $model->triggerDeleteEvent();
} }
}); });
static::creating(function (Syncable&Model $model) { static::creating(static function (Syncable&Model $model) {
if (! $model->getAttribute($model->getGlobalIdentifierKeyName()) && app()->bound(UniqueIdentifierGenerator::class)) { if (! $model->getAttribute($model->getGlobalIdentifierKeyName())) {
$model->setAttribute( $model->generateGlobalIdentifierKey();
$model->getGlobalIdentifierKeyName(),
app(UniqueIdentifierGenerator::class)->generate($model)
);
} }
}); });
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(static 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(static function (Syncable&Model $model) {
if ($model->shouldSync() && $model instanceof SyncMaster) { if ($model->shouldSync()) {
$model->triggerRestoredEvent(); $model->triggerRestoredEvent();
} }
}); });
@ -67,6 +65,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
@ -116,8 +116,18 @@ trait ResourceSyncing
return 'global_id'; return 'global_id';
} }
public function getGlobalIdentifierKey(): string public function getGlobalIdentifierKey(): string|int
{ {
return $this->getAttribute($this->getGlobalIdentifierKeyName()); return $this->getAttribute($this->getGlobalIdentifierKeyName());
} }
protected function generateGlobalIdentifierKey(): void
{
if (! app()->bound(UniqueIdentifierGenerator::class)) return;
$this->setAttribute(
$this->getGlobalIdentifierKeyName(),
app(UniqueIdentifierGenerator::class)->generate($this),
);
}
} }

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

@ -20,14 +20,14 @@ trait TriggerSyncingEvents
{ {
public static function bootTriggerSyncingEvents(): void public static function bootTriggerSyncingEvents(): void
{ {
static::saving(function (self $pivot) { static::saving(static function (self $pivot) {
// Try getting the central resource to see if it is available // Try getting the central resource to see if it is available
// If it is not available, throw an exception to interrupt the saving process // If it is not available, throw an exception to interrupt the saving process
// And prevent creating a pivot record without a central resource // And prevent creating a pivot record without a central resource
$pivot->getCentralResourceAndTenant(); $pivot->getCentralResourceAndTenant();
}); });
static::saved(function (self $pivot) { static::saved(static function (self $pivot) {
/** /**
* @var static&Pivot $pivot * @var static&Pivot $pivot
* @var SyncMaster|null $centralResource * @var SyncMaster|null $centralResource
@ -40,7 +40,7 @@ trait TriggerSyncingEvents
} }
}); });
static::deleting(function (self $pivot) { static::deleting(static function (self $pivot) {
/** /**
* @var static&Pivot $pivot * @var static&Pivot $pivot
* @var SyncMaster|null $centralResource * @var SyncMaster|null $centralResource

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,10 +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(); expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(1);
// Deleting tenant deletes its pivot records $tenant->run(function () {
expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->getTenantKey()]))->toHaveCount(0); TenantUser::firstWhere('global_id', 'cascade_user')->delete();
});
// Deleting tenant resource deletes its pivot record
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 () {
@ -1265,6 +1342,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.
* *