From 6784685054348ee32e37fe73c18e6549e650ffd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Sat, 10 Feb 2024 19:08:37 +0100 Subject: [PATCH] Resource syncing rework (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add &Model to docblock * Fix code style (php-cs-fixer) * Only delete synced resource if the central resource shouldSync * Add central resource detached event and listener * Add SyncedTenant interface * Use the event & listener in the test file * Add getGlobalIdentifierKey(Name) to TenantMorphPivot * Refactor TriggerSyncingEvents * Fix code style (php-cs-fixer) * Test queueing the detaching listener * Move finding the central resource into the event, naming changes * Fix code style (php-cs-fixer) * Simplify listener code * Refactor detaching logic * Create tenant resource after attaching central to tenant, test queueing related listener * Delete dd() * Fix code style (php-cs-fixer) * Move triggerAttachEvent from SyncMaster * Update attach event-related code * Move findResource from SyncedTenant to the pivot trait * Add annotation * Update annotation * Simplify getAttributesForCreation in CreateTenantResourceFromSyncMaster * Update naming * Add tenant trait for attaching/detaching resources * Update test names * Move creation attribute parsing method to trait * Rename variable * Fix code style (php-cs-fixer) * Delete complete to-do * Delete event comment * Rename event property * Find tenant resource in detach listener * Use global ID key of tenant resource in cascade deletes listener * Use global ID key name of the central resource while creating/deleting tenant resources * Add getSyncedCreationAttributes example in the annotation * Fix inconsistencies in SyncedTenant methods * Improve annotation * Don't return the query in `$scopeGetModelQuery` Co-authored-by: Samuel Štancl * Fix code style (php-cs-fixer) * Update scoping getModel query * Only use detach event instead of using both detach and delete events, refactor code * Test that detaching tenant from a central resource doesn't affect other tenants * Delete extra imports * Fix code style (php-cs-fixer) * Add PivotWithRelation, test attaching/detaching resources without polymorphic relations * Refactor TriggerSyncingEvents to work with non-polymorphic relations too * Fix code style (php-cs-fixer) * Rename synced resource changed event, fix tests * Enforce passing Tenant&Model to attach/detach events * Prevent firing saved event automatically in CreateTenantResource * Improve TriggerSyncingEvents trait * Delete unused import * Make TriggerSyncingEvents methods non-static, improve annotations * Pass saved model to event * Move attach/detach queueing tests to ResourceSyncingTest, pass models instead of IDs to attach/detach * Move events to ResourceSyncing\Events * Fix code style (php-cs-fixer) * Use SerializesModels in queueable listeners instead of events * Delete redundant $shouldQueue setting * Rename listener, test cascade deletes from both sides of many-to-many * Move creation attributes-related code to a separate test file, improve comments (wip) * Improve comments, fix variable name capitalization * Delete tracing comma * Extract duplicate code into a trait * Don't accept nullable tenant in SyncMasterDeleted * Fix annotation * Fix code style (php-cs-fixer) * Update annotation * Fix PHPStan error * Fix annotation * Update comments and test naming * Move triggerDeleteEvent to CascadeDeletes interface * Rename test file * Import TenantPivot in Tenant class (tests/Etc) * Add central resource not available in pivot exception * Rename SaveSyncedResource to UpdateOrCreateSyncedResource * Add new events and listeners to TSP stub * Improve comments and naming * Only keep SerializesModels in classes that utilize it * Use tenant->run() * Import events in stub * Move RS listeners to separate namespace, use `Event/`Listener/` in stub for consistency * Fix code style (php-cs-fixer) * Fix namespace changes * Use cursor instead of get * Update src/ResourceSyncing/ParsesCreationAttributes.php Co-authored-by: Samuel Štancl * Update naming, structure (discussed on Discord) * Update uses in in test file * remove double ;; * Add comments * Test if static properties work fine with queueable listeners * Update $shouldQuery test * Update creation attributes * Work on updating the tests * Make synced attributes configurable using static properties * Update resource syncing tests * Get rid of mixed attribute classes * Get rid of TenantUserWIthCreationAttributes * Fix imports * Get rid of the conditionally synced classes, improve tests * Simplify resource creation tests (only test the more complex cases instead of each case - if the complex case works, the simpler cases work too) * Clean up ResourceSyncingTest (mostly duplicate tests that were already in AutomaticResourceCreationTest) * Simplify class naming in polymorhpic tests * Move automatic resource creation tests to ResourceSyncingTest * Test that the sync event doesn't get triggered excessively * Only trigger the sync event if the synced attributes were changed or if the resource was recently created * Update synced attribute instead of unsynced in test * Fix sync event test * Update static property redefining test * Use getGlobalIdentifierKeyName() instead of hardcoding the key name * Delete static properties from the ResourceSyncing trait * Reuse user classes in polymorphic tests * Update tests/ResourceSyncingTest.php Co-authored-by: Samuel Štancl * Use the default tenants() method in central user, override the default in ResourceSyncingTest * Use BelongsToMany as tenants() return type * Fix code style (php-cs-fixer) * Delete extra static property from trait * Delete duplicate events/listeners from TSP stub * Delete weird expectation, use $model->trashed() * Change ResourceUser to TenantUser * Add defaults for getGlobalIdentifierKey(Name) * Use singular tenant in DeleteResourceInTenants name * Rename getSyncedCreationAttributes to getCreationAttributes * Fix comma position in comment * minor fixes in traits and interfaces * Fix code style (php-cs-fixer) * Correct comment * Use $tenant->run() * Update scopeGetModelQuery annotation * Use static property for testing shouldSync * Improve test * Get rid of datasets * Add trashed assertions * Always merge synced attributes with the creation attributes during parsing * Update creation attributes in test's beforeEach * Use only the necessary creation attributes (no need to include the synced attributes because they get merged automatically) * Rename ResourceTenant to MorphTenant * Add TriggerSyncingEvents docblock Co-authored-by: Samuel Štancl * Add force deletes test * Fix code style (php-cs-fixer) * Delete pivot if it can't access the resource class * Make parseCreationAttributes more readable * Comment out setting $scopeGetModelQuery in the stub * Add @var annotations to bootTriggerSyncingEvents * Fix attach()/detach() exception test * Interrupt creation of broken pivots instead of deleting the pivots at a later point * Add more comments * Update CreateTenantResource comment * Assert that forceDelete() doesn't affect other tenant resources * Rename test * Correct with() array formatting * Expand test with soft deletes * Merge SyncedResourceSaved tests * Improve naming, comments and minor details in the assertions * Move test * Fix failing test * Delete duplicate test * Minor test improvement * Delete duplicate test * Improve old test * Minor test improvement * Improve event test * Improve tests (naming, code, comments) * Delete extra test, add comments to the larger test * Refactor central -> tenant attach() test * Apply changes from central -> tenant attach() test on tenant -> central test * Fix assertions in central -> tenant * Correct comment and assertion * Refactor tenant -> central attach() test * Fix inconsistency * Delete unused import * Add comments * Update polymorphic test names * Rename polymorphic tests * Update listener test name * Delete redundant tenant ID assignments * Improve test names * Move polymorphic tests to ResourceSyncingTest * Mention alternative solutions in CentralResourceNotAvailableInPivotException * Add comments * Update test comments * minor changes to tests + review comments * Delete extra tests, update comments * Remove unneeded part of test * Fix comment * Improve comments * Add test for companies() realationship accessibility * Update test name * Complete to-do, add comment * Improve naming and comments (resolve some priority reviews) * Move test * Comment, resolve to-dos * Add low-level pivot assertions * Restore trashed resources if the central resource got restored, try improving tests * Fix code style (php-cs-fixer) * Dekete redundnat unsynced comments * Add to-do, test WIP * Fix restoring logic * Update todo * Add todo to fix phpdoc * Fix code style (php-cs-fixer) * PHPStan error fix wip * Fix PHPStan error * Add regression test * Delete unused trait * Add and test restoring WIP * Fix code style (php-cs-fixer) * Add to-do * Delete comment from test * Focus on restoring in the restore test * Improve maming * Fix stub * Delete redundant part of test * Delete incorrect test leftover * Add triggerRestoredEvent * Fix restore test * Correct tests and restore(() logic * Fix code style (php-cs-fixer) * Check if SoftDeletes are used before firing SyncMasterRestored * Fix comment * Revert restore action changes (phpstan errors) * Delete CascadeDeletes interface * Remove CascadeDeletes from most of the tests * Fix code style (php-cs-fixer) * Rename tests * Fix restoring + tests WIP * Fix restoring * Fix restoring tests * Fix code style (php-cs-fixer) * Test that detaching force deletes the tenant resources * Implement cacscade force deleting * Delete redundant changes * Fix typo * Fix SyncMaster * Improve test * Add force deleting logic back and fix tests * Improve comment * Delete extra assertion * Improve restoring test * Simplify assertion * Delete redundant query scoping from test * Test restore listener queueing * use strict in_array() checks * fix phpstan errors --------- Co-authored-by: lukinovec Co-authored-by: PHP CS Fixer --- assets/TenancyServiceProvider.stub.php | 37 +- src/Contracts/Syncable.php | 23 - src/Database/Concerns/ResourceSyncing.php | 54 - src/Database/Concerns/TriggerSyncEvent.php | 21 - src/Database/Models/TenantPivot.php | 13 - ...SyncedResourceChangedInForeignDatabase.php | 23 - src/Jobs/ClearPendingTenants.php | 3 +- src/Jobs/CreatePendingTenants.php | 3 +- ...alResourceNotAvailableInPivotException.php | 19 + .../CentralResourceAttachedToTenant.php | 18 + .../CentralResourceDetachedFromTenant.php | 18 + .../Events/SyncMasterDeleted.php | 17 + .../Events/SyncMasterRestored.php | 16 + .../Events/SyncedResourceSaved.php | 4 +- .../SyncedResourceSavedInForeignDatabase.php | 19 + .../Listeners/CreateTenantResource.php | 35 + .../Listeners/DeleteResourceInTenant.php | 23 + .../Listeners/DeleteResourcesInTenants.php | 25 + .../Listeners/DeletesSyncedResources.php | 29 + .../Listeners/RestoreResourcesInTenants.php | 43 + .../UpdateOrCreateSyncedResource.php} | 105 +- .../ModelNotSyncMasterException.php | 2 +- .../ParsesCreationAttributes.php | 27 + src/ResourceSyncing/PivotWithRelation.php | 15 + src/ResourceSyncing/ResourceSyncing.php | 123 ++ .../SyncMaster.php | 12 +- src/ResourceSyncing/Syncable.php | 36 + .../TenantMorphPivot.php | 5 +- src/ResourceSyncing/TenantPivot.php | 12 + src/ResourceSyncing/TriggerSyncingEvents.php | 109 ++ tests/Etc/ResourceSyncing/CentralUser.php | 51 + tests/Etc/ResourceSyncing/CustomPivot.php | 23 + tests/Etc/ResourceSyncing/Tenant.php | 31 + tests/Etc/ResourceSyncing/TenantUser.php | 46 + ...8_000009_add_deleted_at_to_users_table.php | 26 + tests/EventListenerTest.php | 22 +- tests/QueueTest.php | 54 +- tests/ResourceSyncingTest.php | 1691 ++++++++++------- tests/ResourceSyncingUsingPolymorphicTest.php | 395 ---- 39 files changed, 1903 insertions(+), 1325 deletions(-) delete mode 100644 src/Contracts/Syncable.php delete mode 100644 src/Database/Concerns/ResourceSyncing.php delete mode 100644 src/Database/Concerns/TriggerSyncEvent.php delete mode 100644 src/Database/Models/TenantPivot.php delete mode 100644 src/Events/SyncedResourceChangedInForeignDatabase.php create mode 100644 src/ResourceSyncing/CentralResourceNotAvailableInPivotException.php create mode 100644 src/ResourceSyncing/Events/CentralResourceAttachedToTenant.php create mode 100644 src/ResourceSyncing/Events/CentralResourceDetachedFromTenant.php create mode 100644 src/ResourceSyncing/Events/SyncMasterDeleted.php create mode 100644 src/ResourceSyncing/Events/SyncMasterRestored.php rename src/{ => ResourceSyncing}/Events/SyncedResourceSaved.php (75%) create mode 100644 src/ResourceSyncing/Events/SyncedResourceSavedInForeignDatabase.php create mode 100644 src/ResourceSyncing/Listeners/CreateTenantResource.php create mode 100644 src/ResourceSyncing/Listeners/DeleteResourceInTenant.php create mode 100644 src/ResourceSyncing/Listeners/DeleteResourcesInTenants.php create mode 100644 src/ResourceSyncing/Listeners/DeletesSyncedResources.php create mode 100644 src/ResourceSyncing/Listeners/RestoreResourcesInTenants.php rename src/{Listeners/UpdateSyncedResource.php => ResourceSyncing/Listeners/UpdateOrCreateSyncedResource.php} (58%) rename src/{Exceptions => ResourceSyncing}/ModelNotSyncMasterException.php (90%) create mode 100644 src/ResourceSyncing/ParsesCreationAttributes.php create mode 100644 src/ResourceSyncing/PivotWithRelation.php create mode 100644 src/ResourceSyncing/ResourceSyncing.php rename src/{Contracts => ResourceSyncing}/SyncMaster.php (51%) create mode 100644 src/ResourceSyncing/Syncable.php rename src/{Database/Models => ResourceSyncing}/TenantMorphPivot.php (52%) create mode 100644 src/ResourceSyncing/TenantPivot.php create mode 100644 src/ResourceSyncing/TriggerSyncingEvents.php create mode 100644 tests/Etc/ResourceSyncing/CentralUser.php create mode 100644 tests/Etc/ResourceSyncing/CustomPivot.php create mode 100644 tests/Etc/ResourceSyncing/Tenant.php create mode 100644 tests/Etc/ResourceSyncing/TenantUser.php create mode 100644 tests/Etc/synced_resource_migrations/users_extra/2023_08_28_000009_add_deleted_at_to_users_table.php delete mode 100644 tests/ResourceSyncingUsingPolymorphicTest.php diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 2840b07c..4be342ee 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -7,14 +7,16 @@ namespace App\Providers; use Illuminate\Routing\Route; use Stancl\Tenancy\Jobs; use Stancl\Tenancy\Events; +use Stancl\Tenancy\ResourceSyncing; use Stancl\Tenancy\Listeners; use Stancl\Tenancy\Middleware; use Stancl\JobPipeline\JobPipeline; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; -use Stancl\Tenancy\Overrides\TenancyUrlGenerator; -use Illuminate\Support\Facades\Route as RouteFacade; use Stancl\Tenancy\Actions\CloneRoutesAsTenant; +use Stancl\Tenancy\Overrides\TenancyUrlGenerator; +use Illuminate\Contracts\Database\Eloquent\Builder; +use Illuminate\Support\Facades\Route as RouteFacade; use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData; @@ -108,18 +110,29 @@ class TenancyServiceProvider extends ServiceProvider Events\RevertedToCentralContext::class => [], // Resource syncing - Events\SyncedResourceSaved::class => [ - Listeners\UpdateSyncedResource::class, + ResourceSyncing\Events\SyncedResourceSaved::class => [ + ResourceSyncing\Listeners\UpdateOrCreateSyncedResource::class, ], + ResourceSyncing\Events\SyncMasterDeleted::class => [ + ResourceSyncing\Listeners\DeleteResourcesInTenants::class, + ], + ResourceSyncing\Events\SyncMasterRestored::class => [ + ResourceSyncing\Listeners\RestoreResourcesInTenants::class, + ], + ResourceSyncing\Events\CentralResourceAttachedToTenant::class => [ + ResourceSyncing\Listeners\CreateTenantResource::class, + ], + ResourceSyncing\Events\CentralResourceDetachedFromTenant::class => [ + ResourceSyncing\Listeners\DeleteResourceInTenant::class, + ], + // Fired only when a synced resource is changed in a different DB than the origin DB (to avoid infinite loops) + ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase::class => [], // Storage symlinks Events\CreatingStorageSymlink::class => [], Events\StorageSymlinkCreated::class => [], Events\RemovingStorageSymlink::class => [], Events\StorageSymlinkRemoved::class => [], - - // Fired only when a synced resource is changed in a different DB than the origin DB (to avoid infinite loops) - Events\SyncedResourceChangedInForeignDatabase::class => [], ]; } @@ -151,6 +164,16 @@ class TenancyServiceProvider extends ServiceProvider $this->makeTenancyMiddlewareHighestPriority(); $this->overrideUrlInTenantContext(); + /** + * Include soft deleted resources in synced resource queries. + * + * ResourceSyncing\Listeners\UpdateOrCreateSyncedResource::$scopeGetModelQuery = function (Builder $query) { + * if ($query->hasMacro('withTrashed')) { + * $query->withTrashed(); + * } + * }; + */ + /** * To make Livewire v3 work with Tenancy, make the update route universal. * diff --git a/src/Contracts/Syncable.php b/src/Contracts/Syncable.php deleted file mode 100644 index f8e7fd84..00000000 --- a/src/Contracts/Syncable.php +++ /dev/null @@ -1,23 +0,0 @@ -shouldSync()) { - $model->triggerSyncEvent(); - } - }); - - static::creating(function (self $model) { - if (! $model->getAttribute($model->getGlobalIdentifierKeyName()) && app()->bound(UniqueIdentifierGenerator::class)) { - $model->setAttribute( - $model->getGlobalIdentifierKeyName(), - app(UniqueIdentifierGenerator::class)->generate($model) - ); - } - }); - } - - public function triggerSyncEvent(): void - { - /** @var Syncable $this */ - event(new SyncedResourceSaved($this, tenant())); - } - - public function getSyncedCreationAttributes(): array|null - { - return null; - } - - public function shouldSync(): bool - { - return true; - } - - public function tenants(): MorphToMany - { - return $this->morphToMany(config('tenancy.models.tenant'), 'tenant_resources', 'tenant_resources', 'resource_global_id', 'tenant_id', 'global_id') - ->using(TenantMorphPivot::class); - } -} diff --git a/src/Database/Concerns/TriggerSyncEvent.php b/src/Database/Concerns/TriggerSyncEvent.php deleted file mode 100644 index 13207762..00000000 --- a/src/Database/Concerns/TriggerSyncEvent.php +++ /dev/null @@ -1,21 +0,0 @@ -pivotParent; - - if ($parent instanceof Syncable && $parent->shouldSync()) { - $parent->triggerSyncEvent(); - } - }); - } -} diff --git a/src/Database/Models/TenantPivot.php b/src/Database/Models/TenantPivot.php deleted file mode 100644 index 42c66122..00000000 --- a/src/Database/Models/TenantPivot.php +++ /dev/null @@ -1,13 +0,0 @@ -model = $model; - $this->tenant = $tenant; - } -} diff --git a/src/Jobs/ClearPendingTenants.php b/src/Jobs/ClearPendingTenants.php index 773e3e93..d353bd5e 100644 --- a/src/Jobs/ClearPendingTenants.php +++ b/src/Jobs/ClearPendingTenants.php @@ -8,13 +8,12 @@ use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Artisan; use Stancl\Tenancy\Commands\ClearPendingTenants as ClearPendingTenantsCommand; class ClearPendingTenants implements ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + use Dispatchable, InteractsWithQueue, Queueable; public function handle(): void { diff --git a/src/Jobs/CreatePendingTenants.php b/src/Jobs/CreatePendingTenants.php index 81199761..0163ac3a 100644 --- a/src/Jobs/CreatePendingTenants.php +++ b/src/Jobs/CreatePendingTenants.php @@ -8,13 +8,12 @@ use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Artisan; use Stancl\Tenancy\Commands\CreatePendingTenants as CreatePendingTenantsCommand; class CreatePendingTenants implements ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + use Dispatchable, InteractsWithQueue, Queueable; public function handle(): void { diff --git a/src/ResourceSyncing/CentralResourceNotAvailableInPivotException.php b/src/ResourceSyncing/CentralResourceNotAvailableInPivotException.php new file mode 100644 index 00000000..d20415be --- /dev/null +++ b/src/ResourceSyncing/CentralResourceNotAvailableInPivotException.php @@ -0,0 +1,19 @@ +tenants()->attach($tenant) instead of $tenant->resources()->attach($centralResource) (same for detaching). + To make this work both ways, you can make your pivot implement PivotWithRelation and return the related model in getRelatedModel() or extend MorphPivot.' + ); + } +} diff --git a/src/ResourceSyncing/Events/CentralResourceAttachedToTenant.php b/src/ResourceSyncing/Events/CentralResourceAttachedToTenant.php new file mode 100644 index 00000000..017cdbbd --- /dev/null +++ b/src/ResourceSyncing/Events/CentralResourceAttachedToTenant.php @@ -0,0 +1,18 @@ +centralResource->getTenantModelName(); + + $event->tenant->run(function () use ($event, $tenantResourceClass) { + // Prevent $tenantResourceClass::create() from firing the SyncedResourceSaved event + // Manually fire the SyncedResourceSavedInForeignDatabase event instead + $tenantResourceClass::withoutEvents(function () use ($event, $tenantResourceClass) { + $tenantResource = $tenantResourceClass::create($this->parseCreationAttributes($event->centralResource)); + + event(new SyncedResourceSavedInForeignDatabase($tenantResource, $event->tenant)); + }); + }); + } +} diff --git a/src/ResourceSyncing/Listeners/DeleteResourceInTenant.php b/src/ResourceSyncing/Listeners/DeleteResourceInTenant.php new file mode 100644 index 00000000..14e43da8 --- /dev/null +++ b/src/ResourceSyncing/Listeners/DeleteResourceInTenant.php @@ -0,0 +1,23 @@ +tenant->run(fn () => $this->deleteSyncedResource($event->centralResource, true)); + } +} diff --git a/src/ResourceSyncing/Listeners/DeleteResourcesInTenants.php b/src/ResourceSyncing/Listeners/DeleteResourcesInTenants.php new file mode 100644 index 00000000..7b071a27 --- /dev/null +++ b/src/ResourceSyncing/Listeners/DeleteResourcesInTenants.php @@ -0,0 +1,25 @@ +centralResource; + $forceDelete = $event->forceDelete; + + tenancy()->runForMultiple($centralResource->tenants()->cursor(), function () use ($centralResource, $forceDelete) { + $this->deleteSyncedResource($centralResource, $forceDelete); + }); + } +} diff --git a/src/ResourceSyncing/Listeners/DeletesSyncedResources.php b/src/ResourceSyncing/Listeners/DeletesSyncedResources.php new file mode 100644 index 00000000..fc136b68 --- /dev/null +++ b/src/ResourceSyncing/Listeners/DeletesSyncedResources.php @@ -0,0 +1,29 @@ +getTenantModelName(); + + /** @var (Syncable&Model)|null $tenantResource */ + $tenantResource = $tenantResourceClass::firstWhere( + $centralResource->getGlobalIdentifierKeyName(), + $centralResource->getGlobalIdentifierKey() + ); + + if ($force) { + $tenantResource?->forceDelete(); + } else { + $tenantResource?->delete(); + } + } +} diff --git a/src/ResourceSyncing/Listeners/RestoreResourcesInTenants.php b/src/ResourceSyncing/Listeners/RestoreResourcesInTenants.php new file mode 100644 index 00000000..1ad862c3 --- /dev/null +++ b/src/ResourceSyncing/Listeners/RestoreResourcesInTenants.php @@ -0,0 +1,43 @@ +centralResource; + + if (! $centralResource::hasMacro('withTrashed')) { + return; + } + + tenancy()->runForMultiple($centralResource->tenants()->cursor(), function () use ($centralResource) { + $tenantResourceClass = $centralResource->getTenantModelName(); + /** + * @var Syncable $centralResource + * @var (SoftDeletes&Syncable)|null $tenantResource + */ + $tenantResource = $tenantResourceClass::withTrashed()->firstWhere( + $centralResource->getGlobalIdentifierKeyName(), + $centralResource->getGlobalIdentifierKey() + ); + + if ($tenantResource) { + $tenantResource->restore(); + } + }); + } +} diff --git a/src/Listeners/UpdateSyncedResource.php b/src/ResourceSyncing/Listeners/UpdateOrCreateSyncedResource.php similarity index 58% rename from src/Listeners/UpdateSyncedResource.php rename to src/ResourceSyncing/Listeners/UpdateOrCreateSyncedResource.php index 595aaf7e..ff64a782 100644 --- a/src/Listeners/UpdateSyncedResource.php +++ b/src/ResourceSyncing/Listeners/UpdateOrCreateSyncedResource.php @@ -2,24 +2,44 @@ declare(strict_types=1); -namespace Stancl\Tenancy\Listeners; +namespace Stancl\Tenancy\ResourceSyncing\Listeners; +use Closure; +use Illuminate\Contracts\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Pivot; -use Illuminate\Support\Arr; -use Stancl\Tenancy\Contracts\Syncable; -use Stancl\Tenancy\Contracts\SyncMaster; +use Illuminate\Queue\SerializesModels; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Database\TenantCollection; -use Stancl\Tenancy\Events\SyncedResourceChangedInForeignDatabase; -use Stancl\Tenancy\Events\SyncedResourceSaved; -use Stancl\Tenancy\Exceptions\ModelNotSyncMasterException; +use Stancl\Tenancy\Listeners\QueueableListener; +use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSaved; +use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase; +use Stancl\Tenancy\ResourceSyncing\ModelNotSyncMasterException; +use Stancl\Tenancy\ResourceSyncing\ParsesCreationAttributes; +use Stancl\Tenancy\ResourceSyncing\Syncable; +use Stancl\Tenancy\ResourceSyncing\SyncMaster; use Stancl\Tenancy\Tenancy; -class UpdateSyncedResource extends QueueableListener +class UpdateOrCreateSyncedResource extends QueueableListener { + use SerializesModels, ParsesCreationAttributes; + public static bool $shouldQueue = false; + /** + * This static property allows you to scope the "get model query" + * that's responsible for finding the resources that should get synced (in the getModel() method). + * + * For example, to include soft deleted records while syncing (excluded by default), you can use this closure: + * + * UpdateOrCreateSyncedResource::$scopeGetModelQuery = function (Builder $query) { + * if ($query->hasMacro('withTrashed')) { + * $query->withTrashed(); + * } + * }; + */ + public static Closure|null $scopeGetModelQuery = null; + public function handle(SyncedResourceSaved $event): void { $syncedAttributes = $event->model->only($event->model->getSyncedAttributeNames()); @@ -55,20 +75,20 @@ class UpdateSyncedResource extends QueueableListener protected function updateResourceInCentralDatabaseAndGetTenants(SyncedResourceSaved $event, array $syncedAttributes): TenantCollection { - /** @var (Model&SyncMaster)|null $centralModel */ - $centralModel = $event->model->getCentralModelName()::where($event->model->getGlobalIdentifierKeyName(), $event->model->getGlobalIdentifierKey()) - ->first(); + $centralModelClass = $event->model->getCentralModelName(); + + $centralModel = $this->getModel($centralModelClass, $event->model); // We disable events for this call, to avoid triggering this event & listener again. - $event->model->getCentralModelName()::withoutEvents(function () use (&$centralModel, $syncedAttributes, $event) { + $centralModelClass::withoutEvents(function () use (&$centralModel, $syncedAttributes, $event, $centralModelClass) { if ($centralModel) { $centralModel->update($syncedAttributes); - event(new SyncedResourceChangedInForeignDatabase($event->model, null)); } else { - // If the resource doesn't exist at all in the central DB,we create - $centralModel = $event->model->getCentralModelName()::create($this->getAttributesForCreation($event->model)); - event(new SyncedResourceChangedInForeignDatabase($event->model, null)); + // If the resource doesn't exist at all in the central DB, we create it + $centralModel = $centralModelClass::create($this->parseCreationAttributes($event->model)); } + + event(new SyncedResourceSavedInForeignDatabase($centralModel, null)); }); // If the model was just created, the mapping of the tenant to the user likely doesn't exist, so we create it. @@ -104,8 +124,8 @@ class UpdateSyncedResource extends QueueableListener protected function updateResourceInTenantDatabases(TenantCollection $tenants, SyncedResourceSaved $event, array $syncedAttributes): void { tenancy()->runForMultiple($tenants, function ($tenant) use ($event, $syncedAttributes) { - // Forget instance state and find the model, - // again in the current tenant's context. + // Forget instance state and find the model again, + // in the current tenant's context. /** @var Model&Syncable $eventModel */ $eventModel = $event->model; @@ -117,8 +137,7 @@ class UpdateSyncedResource extends QueueableListener $localModelClass = get_class($eventModel); } - /** @var Model|null */ - $localModel = $localModelClass::firstWhere($event->model->getGlobalIdentifierKeyName(), $event->model->getGlobalIdentifierKey()); + $localModel = $this->getModel($localModelClass, $eventModel); // Also: We're syncing attributes, not columns, which is // why we're using Eloquent instead of direct DB queries. @@ -128,53 +147,23 @@ class UpdateSyncedResource extends QueueableListener if ($localModel) { $localModel->update($syncedAttributes); } else { - $localModel = $localModelClass::create($this->getAttributesForCreation($eventModel)); + $localModel = $localModelClass::create($this->parseCreationAttributes($eventModel)); } - event(new SyncedResourceChangedInForeignDatabase($localModel, $tenant)); + event(new SyncedResourceSavedInForeignDatabase($localModel, $tenant)); }); }); } - protected function getAttributesForCreation(Model&Syncable $model): array + protected function getModel(string $modelClass, Syncable $eventModel): Model|null { - if (! $model->getSyncedCreationAttributes()) { - // Creation attributes are not specified so create the model as 1:1 copy - // exclude the "primary key" because we want primary key to handle by the target model to avoid duplication errors - $attributes = $model->getAttributes(); - unset($attributes[$model->getKeyName()]); + /** @var Builder */ + $query = $modelClass::where($eventModel->getGlobalIdentifierKeyName(), $eventModel->getGlobalIdentifierKey()); - return $attributes; + if (static::$scopeGetModelQuery) { + (static::$scopeGetModelQuery)($query); } - if (Arr::isAssoc($model->getSyncedCreationAttributes())) { - // Developer provided the default values (key => value) or mix of default values and attribute names (values only) - // We will merge the default values with provided attributes and sync attributes - [$attributeNames, $defaultValues] = $this->getAttributeNamesAndDefaultValues($model); - $attributes = $model->only(array_merge($model->getSyncedAttributeNames(), $attributeNames)); - - return array_merge($attributes, $defaultValues); - } - - // Developer provided the attribute names, so we'll use them to pick model attributes - return $model->only($model->getSyncedCreationAttributes()); - } - - /** - * Split the attribute names (sequential index items) and default values (key => values). - */ - protected function getAttributeNamesAndDefaultValues(Model&Syncable $model): array - { - $syncedCreationAttributes = $model->getSyncedCreationAttributes() ?? []; - - $attributes = Arr::where($syncedCreationAttributes, function ($value, $key) { - return is_numeric($key); - }); - - $defaultValues = Arr::where($syncedCreationAttributes, function ($value, $key) { - return is_string($key); - }); - - return [$attributes, $defaultValues]; + return $query->first(); } } diff --git a/src/Exceptions/ModelNotSyncMasterException.php b/src/ResourceSyncing/ModelNotSyncMasterException.php similarity index 90% rename from src/Exceptions/ModelNotSyncMasterException.php rename to src/ResourceSyncing/ModelNotSyncMasterException.php index 5ae35a68..1022b054 100644 --- a/src/Exceptions/ModelNotSyncMasterException.php +++ b/src/ResourceSyncing/ModelNotSyncMasterException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Stancl\Tenancy\Exceptions; +namespace Stancl\Tenancy\ResourceSyncing; use Exception; diff --git a/src/ResourceSyncing/ParsesCreationAttributes.php b/src/ResourceSyncing/ParsesCreationAttributes.php new file mode 100644 index 00000000..aa388caf --- /dev/null +++ b/src/ResourceSyncing/ParsesCreationAttributes.php @@ -0,0 +1,27 @@ +getCreationAttributes(); + + // Merge the provided attribute names (['attribute']) with the provided defaults (['attribute2' => 'default_value']) + // This allows mixing the two formats of providing the creation attributes (['attribute', 'attribute2' => 'default_value']) + [$creationAttributeNames, $defaults] = [ + Arr::where($creationAttributes, fn ($value, $key) => is_numeric($key)), + Arr::where($creationAttributes, fn ($value, $key) => is_string($key)), + ]; + + $attributeNames = array_merge($resource->getSyncedAttributeNames(), $creationAttributeNames); + + return array_merge($resource->only($attributeNames), $defaults); + } +} diff --git a/src/ResourceSyncing/PivotWithRelation.php b/src/ResourceSyncing/PivotWithRelation.php new file mode 100644 index 00000000..4936d1fe --- /dev/null +++ b/src/ResourceSyncing/PivotWithRelation.php @@ -0,0 +1,15 @@ +users()->getModel(). + */ + public function getRelatedModel(): Model; +} diff --git a/src/ResourceSyncing/ResourceSyncing.php b/src/ResourceSyncing/ResourceSyncing.php new file mode 100644 index 00000000..8b0aedfb --- /dev/null +++ b/src/ResourceSyncing/ResourceSyncing.php @@ -0,0 +1,123 @@ +shouldSync() && ($model->wasRecentlyCreated || $model->wasChanged($model->getSyncedAttributeNames()))) { + $model->triggerSyncEvent(); + } + }); + + static::deleting(function (Syncable&Model $model) { + if ($model->shouldSync() && $model instanceof SyncMaster) { + $model->triggerDeleteEvent(); + } + }); + + static::creating(function (Syncable&Model $model) { + if (! $model->getAttribute($model->getGlobalIdentifierKeyName()) && app()->bound(UniqueIdentifierGenerator::class)) { + $model->setAttribute( + $model->getGlobalIdentifierKeyName(), + app(UniqueIdentifierGenerator::class)->generate($model) + ); + } + }); + + if (in_array(SoftDeletes::class, class_uses_recursive(static::class), true)) { + static::forceDeleting(function (Syncable&Model $model) { + if ($model->shouldSync() && $model instanceof SyncMaster) { + $model->triggerDeleteEvent(true); + } + }); + + static::restoring(function (Syncable&Model $model) { + if ($model->shouldSync() && $model instanceof SyncMaster) { + $model->triggerRestoredEvent(); + } + }); + } + } + + public function triggerSyncEvent(): void + { + /** @var Syncable&Model $this */ + event(new SyncedResourceSaved($this, tenant())); + } + + public function triggerDeleteEvent(bool $forceDelete = false): void + { + if ($this instanceof SyncMaster) { + /** @var SyncMaster&Model $this */ + event(new SyncMasterDeleted($this, $forceDelete)); + } + } + + public function triggerRestoredEvent(): void + { + if ($this instanceof SyncMaster && in_array(SoftDeletes::class, class_uses_recursive($this), true)) { + /** @var SyncMaster&Model $this */ + event(new SyncMasterRestored($this)); + } + } + + /** Default implementation for \Stancl\Tenancy\ResourceSyncing\SyncMaster */ + public function triggerAttachEvent(Tenant&Model $tenant): void + { + if ($this instanceof SyncMaster) { + /** @var SyncMaster&Model $this */ + event(new CentralResourceAttachedToTenant($this, $tenant)); + } + } + + /** Default implementation for \Stancl\Tenancy\ResourceSyncing\SyncMaster */ + public function triggerDetachEvent(Tenant&Model $tenant): void + { + if ($this instanceof SyncMaster) { + /** @var SyncMaster&Model $this */ + event(new CentralResourceDetachedFromTenant($this, $tenant)); + } + } + + public function getCreationAttributes(): array + { + return $this->getSyncedAttributeNames(); + } + + public function shouldSync(): bool + { + return true; + } + + public function tenants(): BelongsToMany + { + return $this->morphToMany(config('tenancy.models.tenant'), 'tenant_resources', 'tenant_resources', 'resource_global_id', 'tenant_id', $this->getGlobalIdentifierKeyName()) + ->using(TenantMorphPivot::class); + } + + public function getGlobalIdentifierKeyName(): string + { + return 'global_id'; + } + + public function getGlobalIdentifierKey(): string + { + return $this->getAttribute($this->getGlobalIdentifierKeyName()); + } +} diff --git a/src/Contracts/SyncMaster.php b/src/ResourceSyncing/SyncMaster.php similarity index 51% rename from src/Contracts/SyncMaster.php rename to src/ResourceSyncing/SyncMaster.php index 28fafa91..75723af4 100644 --- a/src/Contracts/SyncMaster.php +++ b/src/ResourceSyncing/SyncMaster.php @@ -2,10 +2,12 @@ declare(strict_types=1); -namespace Stancl\Tenancy\Contracts; +namespace Stancl\Tenancy\ResourceSyncing; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Stancl\Tenancy\Contracts\Tenant; // todo move all resource syncing-related things to a separate namespace? @@ -17,4 +19,12 @@ interface SyncMaster extends Syncable public function tenants(): BelongsToMany; public function getTenantModelName(): string; + + public function triggerDetachEvent(Tenant&Model $tenant): void; + + public function triggerAttachEvent(Tenant&Model $tenant): void; + + public function triggerDeleteEvent(bool $forceDelete = false): void; + + public function triggerRestoredEvent(): void; } diff --git a/src/ResourceSyncing/Syncable.php b/src/ResourceSyncing/Syncable.php new file mode 100644 index 00000000..3d5288f1 --- /dev/null +++ b/src/ResourceSyncing/Syncable.php @@ -0,0 +1,36 @@ + 'default value', + * ]; + * + * In the ResourceSyncing trait, this method defaults to getSyncedAttributeNames(). + * + * Note: These values are *merged into* getSyncedAttributeNames(). + */ + public function getCreationAttributes(): array; + + public function shouldSync(): bool; +} diff --git a/src/Database/Models/TenantMorphPivot.php b/src/ResourceSyncing/TenantMorphPivot.php similarity index 52% rename from src/Database/Models/TenantMorphPivot.php rename to src/ResourceSyncing/TenantMorphPivot.php index b10d9d32..3c599df8 100644 --- a/src/Database/Models/TenantMorphPivot.php +++ b/src/ResourceSyncing/TenantMorphPivot.php @@ -2,12 +2,11 @@ declare(strict_types=1); -namespace Stancl\Tenancy\Database\Models; +namespace Stancl\Tenancy\ResourceSyncing; use Illuminate\Database\Eloquent\Relations\MorphPivot; -use Stancl\Tenancy\Database\Concerns\TriggerSyncEvent; class TenantMorphPivot extends MorphPivot { - use TriggerSyncEvent; + use TriggerSyncingEvents; } diff --git a/src/ResourceSyncing/TenantPivot.php b/src/ResourceSyncing/TenantPivot.php new file mode 100644 index 00000000..875e79b4 --- /dev/null +++ b/src/ResourceSyncing/TenantPivot.php @@ -0,0 +1,12 @@ +getCentralResourceAndTenant(); + }); + + static::saved(function (self $pivot) { + /** + * @var static&Pivot $pivot + * @var SyncMaster|null $centralResource + * @var (Tenant&Model)|null $tenant + */ + [$centralResource, $tenant] = $pivot->getCentralResourceAndTenant(); + + if ($tenant && $centralResource?->shouldSync()) { + $centralResource->triggerAttachEvent($tenant); + } + }); + + static::deleting(function (self $pivot) { + /** + * @var static&Pivot $pivot + * @var SyncMaster|null $centralResource + * @var (Tenant&Model)|null $tenant + */ + [$centralResource, $tenant] = $pivot->getCentralResourceAndTenant(); + + if ($tenant && $centralResource?->shouldSync()) { + $centralResource->triggerDetachEvent($tenant); + } + }); + } + + public function getCentralResourceAndTenant(): array + { + /** @var static&Pivot $this */ + $parent = $this->pivotParent; + + if ($parent instanceof Tenant) { + // Tenant is the parent + // $tenant->attach($resource) / $tenant->detach($resource) + return [$this->findCentralResource(), $parent]; + } + + // Central resource is the parent + // $centralResource->attach($tenant) / $centralResource->detach($tenant) + return [$parent, tenancy()->find($this->{$this->getOtherKey()})]; + } + + /** + * Get the resource class if available. Otherwise, throw an exception. + * + * Used in the `findCentralResource` method. + * + * @throws CentralResourceNotAvailableInPivotException + */ + protected function getResourceClass(): string + { + /** @var Pivot|MorphPivot|((Pivot|MorphPivot)&PivotWithRelation) $this */ + if ($this instanceof PivotWithRelation) { + return $this->getRelatedModel()::class; + } + + if ($this instanceof MorphPivot) { + return $this->morphClass; + } + + throw new CentralResourceNotAvailableInPivotException; + } + + protected function findCentralResource(): (SyncMaster&Model)|null + { + /** + * Create an instance of the central resource class so that we can get the global identifier key name properly. + * + * @var SyncMaster&Model $centralResourceModel + */ + $centralResourceModel = new ($this->getResourceClass()); + + $globalId = $this->{$this->getOtherKey()}; + + /** @var (SyncMaster&Model)|null $centralResource */ + $centralResource = $centralResourceModel::firstWhere($centralResourceModel->getGlobalIdentifierKeyName(), $globalId); + + return $centralResource; + } +} diff --git a/tests/Etc/ResourceSyncing/CentralUser.php b/tests/Etc/ResourceSyncing/CentralUser.php new file mode 100644 index 00000000..1533bd21 --- /dev/null +++ b/tests/Etc/ResourceSyncing/CentralUser.php @@ -0,0 +1,51 @@ +getSyncedAttributeNames(); + } +} diff --git a/tests/Etc/ResourceSyncing/CustomPivot.php b/tests/Etc/ResourceSyncing/CustomPivot.php new file mode 100644 index 00000000..00a019c9 --- /dev/null +++ b/tests/Etc/ResourceSyncing/CustomPivot.php @@ -0,0 +1,23 @@ +belongsToMany(CentralUser::class); + } + + public function getRelatedModel(): Model + { + return $this->users()->getModel(); + } +} diff --git a/tests/Etc/ResourceSyncing/Tenant.php b/tests/Etc/ResourceSyncing/Tenant.php new file mode 100644 index 00000000..ca638e02 --- /dev/null +++ b/tests/Etc/ResourceSyncing/Tenant.php @@ -0,0 +1,31 @@ +belongsToMany(CentralUser::class, 'tenant_users', 'tenant_id', 'global_user_id', 'id', 'global_id') + ->using(TenantPivot::class); + } + + public function customPivotUsers(): BelongsToMany + { + return $this->belongsToMany(CentralUser::class, 'tenant_users', 'tenant_id', 'global_user_id', 'id', 'global_id', 'users') + ->using(CustomPivot::class); + } + + public function softDeletesUsers(): BelongsToMany + { + return $this->belongsToMany(CentralUserWithSoftDeletes::class, 'tenant_users', 'tenant_id', 'global_user_id', 'id', 'global_id', 'users') + ->using(CustomPivot::class); + } +} diff --git a/tests/Etc/ResourceSyncing/TenantUser.php b/tests/Etc/ResourceSyncing/TenantUser.php new file mode 100644 index 00000000..b76d2ff2 --- /dev/null +++ b/tests/Etc/ResourceSyncing/TenantUser.php @@ -0,0 +1,46 @@ +getSyncedAttributeNames(); + } +} diff --git a/tests/Etc/synced_resource_migrations/users_extra/2023_08_28_000009_add_deleted_at_to_users_table.php b/tests/Etc/synced_resource_migrations/users_extra/2023_08_28_000009_add_deleted_at_to_users_table.php new file mode 100644 index 00000000..95c7c57b --- /dev/null +++ b/tests/Etc/synced_resource_migrations/users_extra/2023_08_28_000009_add_deleted_at_to_users_table.php @@ -0,0 +1,26 @@ +dateTime('deleted_at')->nullable(); + }); + } + + public function down() + { + } +} diff --git a/tests/EventListenerTest.php b/tests/EventListenerTest.php index 7c82d92d..d88d63de 100644 --- a/tests/EventListenerTest.php +++ b/tests/EventListenerTest.php @@ -2,23 +2,23 @@ declare(strict_types=1); -use Illuminate\Events\CallQueuedListener; +use Stancl\JobPipeline\JobPipeline; +use Stancl\Tenancy\Tests\Etc\Tenant; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Queue; -use Stancl\JobPipeline\JobPipeline; -use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; -use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; -use Stancl\Tenancy\Events\BootstrappingTenancy; -use Stancl\Tenancy\Events\CreatingDatabase; -use Stancl\Tenancy\Events\CreatingTenant; -use Stancl\Tenancy\Events\TenancyInitialized; -use Stancl\Tenancy\Events\TenantCreated; -use Stancl\Tenancy\Events\UpdatingDomain; use Stancl\Tenancy\Jobs\CreateDatabase; +use Stancl\Tenancy\Events\TenantCreated; use Stancl\Tenancy\Jobs\MigrateDatabase; +use Illuminate\Events\CallQueuedListener; +use Stancl\Tenancy\Events\CreatingTenant; +use Stancl\Tenancy\Events\UpdatingDomain; +use Stancl\Tenancy\Events\CreatingDatabase; +use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Listeners\BootstrapTenancy; +use Stancl\Tenancy\Events\BootstrappingTenancy; use Stancl\Tenancy\Listeners\QueueableListener; -use Stancl\Tenancy\Tests\Etc\Tenant; +use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; +use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; beforeEach(function () { FooListener::$shouldQueue = false; diff --git a/tests/QueueTest.php b/tests/QueueTest.php index 2ff72bb7..211e9526 100644 --- a/tests/QueueTest.php +++ b/tests/QueueTest.php @@ -25,6 +25,7 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; +use Stancl\Tenancy\Listeners\QueueableListener; beforeEach(function () { $this->mockConsoleOutput = false; @@ -117,7 +118,30 @@ test('tenancy is initialized inside queues', function (bool $shouldEndTenancy) { $tenant->run(function () use ($user) { expect($user->fresh()->name)->toBe('Bar'); }); -})->with([true, false]);; +})->with([true, false]); + +test('changing the shouldQueue static property in parent class affects child classes unless the property is redefined', function () { + // Parent – $shouldQueue is true + expect(app(ShouldQueueListener::class)->shouldQueue(new stdClass()))->toBeTrue(); + + // Child – $shouldQueue is redefined and set to false + expect(app(ShouldNotQueueListener::class)->shouldQueue(new stdClass()))->toBeFalse(); + + // Child – inherits $shouldQueue from ShouldQueueListener (true) + expect(app(InheritedQueueListener::class)->shouldQueue(new stdClass()))->toBeTrue(); + + // Update $shouldQueue of InheritedQueueListener's parent to see if it affects the child + ShouldQueueListener::$shouldQueue = false; + + // Parent's $shouldQueue changed to false + expect(app(InheritedQueueListener::class)->shouldQueue(new stdClass()))->toBeFalse(); + + ShouldQueueListener::$shouldQueue = true; + + // Parent's $shouldQueue changed back to true + // Child's $shouldQueue is still false because it was redefined + expect(app(ShouldNotQueueListener::class)->shouldQueue(new stdClass()))->toBeFalse(); +}); test('tenancy is initialized when retrying jobs', function (bool $shouldEndTenancy) { withFailedJobs(); @@ -254,3 +278,31 @@ class TestJob implements ShouldQueue } } } + +class ShouldQueueListener extends QueueableListener +{ + public static bool $shouldQueue = true; + + public function handle() + { + return static::$shouldQueue; + } +} + +class ShouldNotQueueListener extends ShouldQueueListener +{ + public static bool $shouldQueue = false; + + public function handle() + { + return static::$shouldQueue; + } +} + +class InheritedQueueListener extends ShouldQueueListener +{ + public function handle() + { + return static::$shouldQueue; + } +} diff --git a/tests/ResourceSyncingTest.php b/tests/ResourceSyncingTest.php index 322645dd..a421a8f7 100644 --- a/tests/ResourceSyncingTest.php +++ b/tests/ResourceSyncingTest.php @@ -2,31 +2,47 @@ declare(strict_types=1); -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsToMany; -use Illuminate\Events\CallQueuedListener; +use Illuminate\Support\Str; +use Illuminate\Support\Facades\DB; +use Stancl\JobPipeline\JobPipeline; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Queue; -use Illuminate\Support\Str; -use Stancl\JobPipeline\JobPipeline; -use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; -use Stancl\Tenancy\Contracts\Syncable; -use Stancl\Tenancy\Contracts\SyncMaster; -use Stancl\Tenancy\Database\Concerns\CentralConnection; -use Stancl\Tenancy\Database\Concerns\ResourceSyncing; -use Stancl\Tenancy\Database\Models\TenantPivot; -use Stancl\Tenancy\Database\DatabaseConfig; -use Stancl\Tenancy\Events\SyncedResourceChangedInForeignDatabase; -use Stancl\Tenancy\Events\SyncedResourceSaved; +use Illuminate\Database\Eloquent\Model; use Stancl\Tenancy\Events\TenancyEnded; -use Stancl\Tenancy\Events\TenancyInitialized; -use Stancl\Tenancy\Events\TenantCreated; -use Stancl\Tenancy\Exceptions\ModelNotSyncMasterException; use Stancl\Tenancy\Jobs\CreateDatabase; +use Stancl\Tenancy\Events\TenantCreated; +use Illuminate\Events\CallQueuedListener; +use Stancl\Tenancy\Database\DatabaseConfig; +use Stancl\Tenancy\ResourceSyncing\Syncable; +use Illuminate\Database\Eloquent\SoftDeletes; +use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Listeners\BootstrapTenancy; +use Stancl\Tenancy\ResourceSyncing\SyncMaster; +use Illuminate\Contracts\Database\Eloquent\Builder; +use Stancl\Tenancy\ResourceSyncing\ResourceSyncing; use Stancl\Tenancy\Listeners\RevertToCentralContext; -use Stancl\Tenancy\Listeners\UpdateSyncedResource; -use Stancl\Tenancy\Tests\Etc\Tenant; +use Stancl\Tenancy\ResourceSyncing\TenantMorphPivot; +use Stancl\Tenancy\Tests\Etc\ResourceSyncing\Tenant; +use Illuminate\Database\Eloquent\Relations\MorphToMany; +use Stancl\Tenancy\Database\Concerns\CentralConnection; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterDeleted; +use Stancl\Tenancy\ResourceSyncing\TenantPivot as BasePivot; +use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; +use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterRestored; +use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSaved; +use Stancl\Tenancy\ResourceSyncing\ModelNotSyncMasterException; +use Stancl\Tenancy\ResourceSyncing\Listeners\CreateTenantResource; +use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteResourceInTenant; +use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteResourcesInTenants; +use Stancl\Tenancy\ResourceSyncing\Listeners\RestoreResourcesInTenants; +use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceAttachedToTenant; +use Stancl\Tenancy\ResourceSyncing\Listeners\UpdateOrCreateSyncedResource; +use Stancl\Tenancy\Tests\Etc\ResourceSyncing\TenantUser as BaseTenantUser; +use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceDetachedFromTenant; +use Stancl\Tenancy\Tests\Etc\ResourceSyncing\CentralUser as BaseCentralUser; +use Stancl\Tenancy\ResourceSyncing\CentralResourceNotAvailableInPivotException; +use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase; beforeEach(function () { config(['tenancy.bootstrappers' => [ @@ -44,23 +60,58 @@ beforeEach(function () { Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class); - UpdateSyncedResource::$shouldQueue = false; // Global state cleanup - Event::listen(SyncedResourceSaved::class, UpdateSyncedResource::class); + // Global state cleanup + UpdateOrCreateSyncedResource::$shouldQueue = false; + DeleteResourcesInTenants::$shouldQueue = false; + CreateTenantResource::$shouldQueue = false; + DeleteResourceInTenant::$shouldQueue = false; + UpdateOrCreateSyncedResource::$scopeGetModelQuery = null; + + $syncedAttributes = [ + 'global_id', + 'name', + 'password', + 'email', + ]; + + TenantUser::$shouldSync = true; + CentralUser::$shouldSync = true; + + TenantUser::$syncedAttributes = $syncedAttributes; + CentralUser::$syncedAttributes = $syncedAttributes; + + $creationAttributes = ['role' => 'commenter']; + + TenantUser::$creationAttributes = $creationAttributes; + CentralUser::$creationAttributes = $creationAttributes; + + Event::listen(SyncedResourceSaved::class, UpdateOrCreateSyncedResource::class); + Event::listen(SyncMasterDeleted::class, DeleteResourcesInTenants::class); + Event::listen(SyncMasterRestored::class, RestoreResourcesInTenants::class); + Event::listen(CentralResourceAttachedToTenant::class, CreateTenantResource::class); + Event::listen(CentralResourceDetachedFromTenant::class, DeleteResourceInTenant::class); // Run migrations on central connection pest()->artisan('migrate', [ '--path' => [ + __DIR__ . '/../assets/resource-syncing-migrations', __DIR__ . '/Etc/synced_resource_migrations', __DIR__ . '/Etc/synced_resource_migrations/users', + __DIR__ . '/Etc/synced_resource_migrations/companies', ], '--realpath' => true, ])->assertExitCode(0); }); -test('an event is triggered when a synced resource is changed', function () { - Event::fake([SyncedResourceSaved::class]); +afterEach(function () { + UpdateOrCreateSyncedResource::$scopeGetModelQuery = null; +}); - $user = ResourceUser::create([ +test('SyncedResourceSaved event gets triggered when resource gets created or when its synced attributes get updated', function () { + Event::fake(SyncedResourceSaved::class); + + // Create resource + $user = TenantUser::create([ 'name' => 'Foo', 'email' => 'foo@email.com', 'password' => 'secret', @@ -71,100 +122,81 @@ test('an event is triggered when a synced resource is changed', function () { Event::assertDispatched(SyncedResourceSaved::class, function (SyncedResourceSaved $event) use ($user) { return $event->model === $user; }); + + // Flush + Event::fake(SyncedResourceSaved::class); + + // Update resource's synced attribute + $user->update(['name' => 'Bar']); + + Event::assertDispatched(SyncedResourceSaved::class, function (SyncedResourceSaved $event) use ($user) { + return $event->model === $user; + }); + + // Flush + Event::fake(SyncedResourceSaved::class); + + // Refetch the model to reset $user->wasRecentlyCreated + $user = TenantUser::firstWhere('name', 'Bar'); + + // Update resource's unsynced attribute + $user->update(['role' => 'bar']); + + Event::assertNotDispatched(SyncedResourceSaved::class); // regression test for #1168 }); -test('only the synced columns are updated in the central db', function () { +test('only synced columns get updated by the syncing logic', function () { // Create user in central DB $user = CentralUser::create([ 'global_id' => 'acme', 'name' => 'John Doe', 'email' => 'john@localhost', 'password' => 'secret', - 'role' => 'superadmin', // unsynced + 'role' => 'superadmin', // Unsynced ]); - $tenant = ResourceTenant::create(); + $tenant = Tenant::create(); migrateUsersTableForTenants(); tenancy()->initialize($tenant); // Create the same user in tenant DB - $user = ResourceUser::create([ + $user = TenantUser::create([ 'global_id' => 'acme', 'name' => 'John Doe', 'email' => 'john@localhost', 'password' => 'secret', - 'role' => 'commenter', // unsynced + 'role' => 'commenter', // Unsynced ]); // Update user in tenant DB $user->update([ - 'name' => 'John Foo', // synced - 'email' => 'john@foreignhost', // synced - 'role' => 'admin', // unsynced + 'name' => 'John Foo', // Synced + 'email' => 'john@foreignhost', // Synced + 'role' => 'admin', // Unsynced ]); - // Assert new values - pest()->assertEquals([ - 'id' => 1, - 'global_id' => 'acme', - 'name' => 'John Foo', - 'email' => 'john@foreignhost', - 'password' => 'secret', - 'role' => 'admin', - ], $user->getAttributes()); - tenancy()->end(); // Assert changes bubbled up pest()->assertEquals([ 'id' => 1, 'global_id' => 'acme', - 'name' => 'John Foo', // synced - 'email' => 'john@foreignhost', // synced - 'password' => 'secret', // no changes - 'role' => 'superadmin', // unsynced - ], ResourceUser::first()->getAttributes()); + 'name' => 'John Foo', // Synced + 'email' => 'john@foreignhost', // Synced + 'password' => 'secret', + 'role' => 'superadmin', // Unsynced + ], TenantUser::first()->getAttributes()); }); -// This tests attribute list on the central side, and default values on the tenant side -// Those two don't depend on each other, we're just testing having each option on each side -// using tests that combine the two, to avoid having an excessively long and complex test suite -test('sync resource creation works when central model provides attributes and tenant model provides default values', function () { - [$tenant1, $tenant2] = createTenantsAndRunMigrations(); +test('updating tenant resources from central context throws an exception', function () { + $tenant = Tenant::create(); + migrateUsersTableForTenants(); - addExtraColumnToCentralDB(); + tenancy()->initialize($tenant); - $centralUser = CentralUserProvidingAttributeNames::create([ - 'global_id' => 'acme', - 'name' => 'John Doe', - 'email' => 'john@localhost', - 'password' => 'secret', - 'role' => 'commenter', - 'foo' => 'bar', // foo does not exist in resource model - ]); - - $tenant1->run(function () { - expect(TenantUserProvidingDefaultValues::all())->toHaveCount(0); - }); - - // When central model provides the list of attributes, resource model will be created from the provided list of attributes' values - $centralUser->tenants()->attach('t1'); - - $tenant1->run(function () { - $resourceUser = TenantUserProvidingDefaultValues::all(); - expect($resourceUser)->toHaveCount(1); - expect($resourceUser->first()->global_id)->toBe('acme'); - expect($resourceUser->first()->email)->toBe('john@localhost'); - // 'foo' attribute is not provided by central model - expect($resourceUser->first()->foo)->toBeNull(); - }); - - tenancy()->initialize($tenant2); - - // When resource model provides the list of default values, central model will be created from the provided list of default values - TenantUserProvidingDefaultValues::create([ - 'global_id' => 'asdf', + TenantUser::create([ + 'global_id' => 'foo', 'name' => 'John Doe', 'email' => 'john@localhost', 'password' => 'secret', @@ -173,390 +205,393 @@ test('sync resource creation works when central model provides attributes and te tenancy()->end(); - // Assert central user was created using the list of default values - $centralUser = CentralUserProvidingAttributeNames::whereGlobalId('asdf')->first(); - expect($centralUser)->not()->toBeNull(); - expect($centralUser->name)->toBe('Default Name'); - expect($centralUser->email)->toBe('default@localhost'); - expect($centralUser->password)->toBe('password'); - expect($centralUser->role)->toBe('admin'); - expect($centralUser->foo)->toBe('bar'); -}); - -// This tests default values on the central side, and attribute list on the tenant side -// Those two don't depend on each other, we're just testing having each option on each side -// using tests that combine the two, to avoid having an excessively long and complex test suite -test('sync resource creation works when central model provides default values and tenant model provides attributes', function () { - [$tenant1, $tenant2] = createTenantsAndRunMigrations(); - - addExtraColumnToCentralDB(); - - $centralUser = CentralUserProvidingDefaultValues::create([ - 'global_id' => 'acme', - 'name' => 'John Doe', - 'email' => 'john@localhost', - 'password' => 'secret', - 'role' => 'commenter', - 'foo' => 'bar', // foo does not exist in resource model - ]); - - $tenant1->run(function () { - expect(TenantUserProvidingDefaultValues::all())->toHaveCount(0); - }); - - // When central model provides the list of default values, resource model will be created from the provided list of default values - $centralUser->tenants()->attach('t1'); - - $tenant1->run(function () { - // Assert resource user was created using the list of default values - $resourceUser = TenantUserProvidingDefaultValues::first(); - expect($resourceUser)->not()->toBeNull(); - expect($resourceUser->global_id)->toBe('acme'); - expect($resourceUser->email)->toBe('default@localhost'); - expect($resourceUser->password)->toBe('password'); - expect($resourceUser->role)->toBe('admin'); - }); - - tenancy()->initialize($tenant2); - - // When resource model provides the list of attributes, central model will be created from the provided list of attributes' values - TenantUserProvidingAttributeNames::create([ - 'global_id' => 'asdf', - 'name' => 'John Doe', - 'email' => 'john@localhost', - 'password' => 'secret', - 'role' => 'commenter', - ]); - - tenancy()->end(); - - // Assert central user was created using the list of provided attributes - $centralUser = CentralUserProvidingAttributeNames::whereGlobalId('asdf')->first(); - expect($centralUser)->not()->toBeNull(); - expect($centralUser->email)->toBe('john@localhost'); - expect($centralUser->password)->toBe('secret'); - expect($centralUser->role)->toBe('commenter'); -}); - -// This tests mixed attribute list/defaults on the central side, and no specified attributes on the tenant side -// Those two don't depend on each other, we're just testing having each option on each side -// using tests that combine the two, to avoid having an excessively long and complex test suite -test('sync resource creation works when central model provides mixture and tenant model provides nothing', function () { - [$tenant1, $tenant2] = createTenantsAndRunMigrations(); - - $centralUser = CentralUserProvidingMixture::create([ - 'global_id' => 'acme', - 'name' => 'John Doe', - 'email' => 'john@localhost', - 'password' => 'password', - 'role' => 'commentator' - ]); - - $tenant1->run(function () { - expect(ResourceUser::all())->toHaveCount(0); - }); - - // When central model provides the list of a mixture (attributes and default values), resource model will be created from the provided list of mixture (attributes and default values) - $centralUser->tenants()->attach('t1'); - - $tenant1->run(function () { - $resourceUser = ResourceUser::first(); - - // Assert resource user was created using the provided attributes and default values - expect($resourceUser->global_id)->toBe('acme'); - expect($resourceUser->name)->toBe('John Doe'); - expect($resourceUser->email)->toBe('john@localhost'); - // default values - expect($resourceUser->role)->toBe('admin'); - expect($resourceUser->password)->toBe('secret'); - }); - - tenancy()->initialize($tenant2); - - // When resource model provides nothing/null, the central model will be created as a 1:1 copy of resource model - $resourceUser = ResourceUser::create([ - 'global_id' => 'acmey', - 'name' => 'John Doe', - 'email' => 'john@localhost', - 'password' => 'password', - 'role' => 'commentator' - ]); - - tenancy()->end(); - - $centralUser = CentralUserProvidingMixture::whereGlobalId('acmey')->first(); - expect($resourceUser->getSyncedCreationAttributes())->toBeNull(); - - $centralUser = $centralUser->toArray(); - $resourceUser = $resourceUser->toArray(); - unset($centralUser['id']); - unset($resourceUser['id']); - - // Assert central user created as 1:1 copy of resource model except "id" - expect($centralUser)->toBe($resourceUser); -}); - -// This tests no specified attributes on the central side, and mixed attribute list/defaults on the tenant side -// Those two don't depend on each other, we're just testing having each option on each side -// using tests that combine the two, to avoid having an excessively long and complex test suite -test('sync resource creation works when central model provides nothing and tenant model provides mixture', function () { - [$tenant1, $tenant2] = createTenantsAndRunMigrations(); - - $centralUser = CentralUser::create([ - 'global_id' => 'acme', - 'name' => 'John Doe', - 'email' => 'john@localhost', - 'password' => 'password', - 'role' => 'commenter', - ]); - - $tenant1->run(function () { - expect(TenantUserProvidingMixture::all())->toHaveCount(0); - }); - - // When central model provides nothing/null, the resource model will be created as a 1:1 copy of central model - $centralUser->tenants()->attach('t1'); - - expect($centralUser->getSyncedCreationAttributes())->toBeNull(); - $tenant1->run(function () use ($centralUser) { - $resourceUser = TenantUserProvidingMixture::first(); - expect($resourceUser)->not()->toBeNull(); - $resourceUser = $resourceUser->toArray(); - $centralUser = $centralUser->withoutRelations()->toArray(); - unset($resourceUser['id']); - unset($centralUser['id']); - - expect($resourceUser)->toBe($centralUser); - }); - - tenancy()->initialize($tenant2); - - // When resource model provides the list of a mixture (attributes and default values), central model will be created from the provided list of mixture (attributes and default values) - TenantUserProvidingMixture::create([ - 'global_id' => 'absd', - 'name' => 'John Doe', - 'email' => 'john@localhost', - 'password' => 'password', - 'role' => 'commenter', - ]); - - tenancy()->end(); - - $centralUser = CentralUser::whereGlobalId('absd')->first(); - - // Assert central user was created using the provided list of attributes and default values - expect($centralUser->name)->toBe('John Doe'); - expect($centralUser->email)->toBe('john@localhost'); - // default values - expect($centralUser->role)->toBe('admin'); - expect($centralUser->password)->toBe('secret'); -}); - -test('creating the resource in tenant database creates it in central database and creates the mapping', function () { - creatingResourceInTenantDatabaseCreatesAndMapInCentralDatabase(); -}); - -test('trying to update synced resources from central context using tenant models results in an exception', function () { - creatingResourceInTenantDatabaseCreatesAndMapInCentralDatabase(); - - tenancy()->end(); - expect(tenancy()->initialized)->toBeFalse(); - pest()->expectException(ModelNotSyncMasterException::class); - ResourceUser::first()->update(['role' => 'foobar']); + TenantUser::first()->update(['password' => 'foobar']); }); -test('attaching a tenant to the central resource triggers a pull from the tenant db', function () { +test('attaching central resources to tenants or vice versa creates synced tenant resource', function () { + $createCentralUser = fn () => CentralUser::create([ + 'name' => 'John Doe', + 'email' => 'john@localhost', + 'password' => 'secret', + 'role' => 'commenter', // Unsynced + ]); + + $tenant = Tenant::create(); + + migrateUsersTableForTenants(); + + $tenant->run(function () { + expect(TenantUser::all())->toHaveCount(0); + }); + + // Attaching resources to tenants requires using a pivot that implements the PivotWithRelation interface + $tenant->customPivotUsers()->attach($createCentralUser()); + $createCentralUser()->tenants()->attach($tenant); + + $tenant->run(function () { + // two (separate) central users were created, so there are now two separate tenant users in the tenant's database + expect(TenantUser::all())->toHaveCount(2); + }); +}); + +test('detaching central users from tenants or vice versa force deletes the synced tenant resource', function (bool $attachUserToTenant) { $centralUser = CentralUser::create([ 'global_id' => 'acme', 'name' => 'John Doe', 'email' => 'john@localhost', 'password' => 'secret', - 'role' => 'commenter', // unsynced + 'role' => 'commenter', // Unsynced ]); - $tenant = ResourceTenant::create([ - 'id' => 't1', - ]); + $tenant = Tenant::create(); + migrateUsersTableForTenants(); + if ($attachUserToTenant) { + // Attaching resources to tenants requires using a pivot that implements the PivotWithRelation interface + $tenant->customPivotUsers()->attach($centralUser); + } else { + $centralUser->tenants()->attach($tenant); + } + $tenant->run(function () { - expect(ResourceUser::all())->toHaveCount(0); + expect(TenantUser::all())->toHaveCount(1); }); - $centralUser->tenants()->attach('t1'); + if ($attachUserToTenant) { + // Detaching resources from tenants requires using a pivot that implements the PivotWithRelation interface + $tenant->customPivotUsers()->detach($centralUser); + } else { + $centralUser->tenants()->detach($tenant); + } $tenant->run(function () { - expect(ResourceUser::all())->toHaveCount(1); + expect(TenantUser::all())->toHaveCount(0); + }); + + addExtraColumns(true); + + // Detaching *force deletes* the tenant resource + CentralUserWithSoftDeletes::$creationAttributes = ['role' => 'commenter', 'foo' => 'bar']; + + $centralUserWithSoftDeletes = CentralUserWithSoftDeletes::create([ + 'global_id' => 'bar', + 'name' => 'John Doe', + 'email' => 'john@localhost', + 'password' => 'secret', + 'role' => 'commenter', // Unsynced + 'foo' => 'bar', + ]); + + if ($attachUserToTenant) { + $tenant->customPivotUsers()->attach($centralUserWithSoftDeletes); + } else { + $centralUserWithSoftDeletes->tenants()->attach($tenant); + } + + $tenant->run(function () { + expect(TenantUserWithSoftDeletes::withTrashed()->count())->toBe(1); + }); + + if ($attachUserToTenant) { + // Detaching resources from tenants requires using a pivot that implements the PivotWithRelation interface + $tenant->customPivotUsers()->detach($centralUserWithSoftDeletes); + } else { + $centralUserWithSoftDeletes->tenants()->detach($tenant); + } + + $tenant->run(function () { + expect(TenantUserWithSoftDeletes::withTrashed()->count())->toBe(0); + }); +})->with([ + true, + false, +]); + +test('attaching tenant to central resource works correctly even when using a single pivot table for multiple models', function () { + config(['tenancy.models.tenant' => MorphTenant::class]); + + $tenant1 = MorphTenant::create(); + $tenant2 = MorphTenant::create(); + + migrateUsersTableForTenants(); + migrateCompaniesTableForTenants(); + + // Use BaseCentralUser and BaseTenantUser in this test + // These models use the polymorphic relationship for tenants(), which is the default + $centralUser = BaseCentralUser::create([ + 'global_id' => 'acme', + 'name' => 'John Doe', + 'email' => 'john@localhost', + 'password' => 'password', + 'role' => 'commenter', + ]); + + $tenant1->run(function () { + expect(BaseTenantUser::count())->toBe(0); + }); + + // Create tenant resource from central resource + // By attaching a tenant to the central resource + $centralUser->tenants()->attach($tenant1); + + // Central users are accessible from the tenant using the relationship method + expect($tenant1->users()->count())->toBe(1); + + // Tenants are accessible from the central resource using the relationship method + expect($centralUser->tenants()->count())->toBe(1); + + // The tenant resource got created with the correct attributes + $tenant1->run(function () use ($centralUser) { + $tenantUser = BaseTenantUser::first()->getAttributes(); + $centralUser = $centralUser->getAttributes(); + + expect($tenantUser)->toEqualCanonicalizing($centralUser); + }); + + // Test that the company resource can use the same pivot as the user resource + $centralCompany = CentralCompany::create([ + 'global_id' => 'acme', + 'name' => 'ArchTech', + 'email' => 'archtech@localhost', + ]); + + // Central company wasn't attached yet + $tenant2->run(function () { + expect(TenantCompany::count())->toBe(0); + }); + + $centralCompany->tenants()->attach($tenant2); + + // Tenant company got created during the attaching + $tenant2->run(function () { + expect(TenantCompany::count())->toBe(1); + }); + + // The tenant companies are accessible from tenant using the relationship method + expect($tenant2->companies()->count())->toBe(1); + + // Tenants are accessible from the central resource using the relationship method + expect($centralCompany->tenants()->count())->toBe(1); + + // The TenantCompany resource got created with the correct attributes + $tenant2->run(function () use ($centralCompany) { + $tenantCompany = TenantCompany::first()->getAttributes(); + $centralCompany = $centralCompany->getAttributes(); + + expect($tenantCompany)->toEqualCanonicalizing($centralCompany); + }); + + + // Detaching tenant from a central resource deletes the resource of that tenant + $centralUser->tenants()->detach($tenant1); + $tenant1->run(function () { + expect(BaseTenantUser::count())->toBe(0); + }); + + $centralUser->tenants()->attach($tenant2); + + // Detaching tenant from a central resource doesn't affect resources of other tenants + $tenant2->run(function () { + expect(BaseTenantUser::count())->toBe(1); + }); + + $centralCompany->tenants()->detach($tenant2); + + $tenant1->run(function () { + expect(TenantCompany::count())->toBe(0); }); }); -test('attaching users to tenants does not do anything', function () { +test('attaching central resource to tenant works correctly even when using a single pivot table for multiple models', function () { + config(['tenancy.models.tenant' => MorphTenant::class]); + + $tenant1 = MorphTenant::create(); + migrateUsersTableForTenants(); + + // Use BaseCentralUser and BaseTenantUser in this test + // These models use the polymorphic relationship for tenants(), which is the default + $centralUser = BaseCentralUser::create([ + 'name' => 'John Doe', + 'email' => 'john@localhost', + 'password' => 'password', + 'role' => 'commenter', + ]); + + expect(DB::table('tenant_resources')->count())->toBe(0); + + // Create tenant resource from central resource + // By attaching a central resource to the tenant + $tenant1->users()->attach($centralUser); + + // The tenant resource got created + // And with the correct attributes + $tenantUser = $tenant1->run(function () { + return BaseTenantUser::first(); + }); + + expect($tenantUser?->getAttributes())->toEqualCanonicalizing($centralUser->getAttributes()); + + // Tenants are accessible from the central resource using the relationship method + expect($centralUser->tenants()->count())->toBe(1); + expect(DB::table('tenant_resources')->count())->toBe(1); + + // Central users are accessible from the tenant using the relationship method + expect($tenant1->users()->count())->toBe(1); + + $tenant1->users()->detach($centralUser); + + $tenant1->run(function () { + expect(BaseTenantUser::count())->toBe(0); + }); + + expect(DB::table('tenant_resources')->count())->toBe(0); + + // Test that the company resource can use the same pivot as the user resource + $tenant2 = MorphTenant::create(); + migrateCompaniesTableForTenants(); + expect($tenant2->companies()->count())->toBe(0); + expect(DB::table('tenant_resources')->count())->toBe(0); + + // Company resource uses the same pivot as the user resource + // Creating a tenant resource creates the central resource automatically if it doesn't exist + $tenantCompany = $tenant2->run(function () { + return TenantCompany::create([ + 'global_id' => 'acme', + 'name' => 'tenant comp', + 'email' => 'company@localhost', + ]); + }); + + $centralCompany = CentralCompany::first(); + + // The central resource got created + // And with the correct attributes + expect($centralCompany?->getAttributes())->toEqualCanonicalizing($tenantCompany->getAttributes()); + + // The pivot table got created + expect(DB::table('tenant_resources')->count())->toBe(1); + + // Tenants are accessible from the central resource using the relationship method + expect($centralCompany->tenants()->count())->toBe(1); + + // Central companies are accessible from the tenant using the relationship method + expect($tenant2->companies()->count())->toBe(1); + + // Detaching central resource from a tenant deletes the resource of that tenant + $tenant2->companies()->detach($centralCompany); + + expect($tenant2->companies()->count())->toBe(0); + + // Tenant resource got deleted + $tenant2->run(function () { + expect(TenantCompany::count())->toBe(0); + }); +}); + +test('attaching or detaching users to or from tenant throws an exception when the pivot cannot access the central resource', function() { + $tenant = Tenant::create(); + + migrateUsersTableForTenants(); + $centralUser = CentralUser::create([ 'global_id' => 'acme', 'name' => 'John Doe', 'email' => 'john@localhost', 'password' => 'secret', - 'role' => 'commenter', // unsynced + 'role' => 'commenter', ]); - $tenant = ResourceTenant::create([ - 'id' => 't1', - ]); - migrateUsersTableForTenants(); + expect(TenantPivot::count())->toBe(0); - $tenant->run(function () { - expect(ResourceUser::all())->toHaveCount(0); - }); + expect(fn () => $tenant->users()->attach($centralUser))->toThrow(CentralResourceNotAvailableInPivotException::class); + expect(TenantPivot::count())->toBe(0); - // The child model is inaccessible in the Pivot Model, so we can't fire any events. - $tenant->users()->attach($centralUser); + $centralUser->tenants()->attach($tenant); // central->tenants() direction works + expect(TenantPivot::count())->toBe(1); - $tenant->run(function () { - // Still zero - expect(ResourceUser::all())->toHaveCount(0); - }); + expect(fn () => $tenant->users()->detach($centralUser))->toThrow(CentralResourceNotAvailableInPivotException::class); + expect(TenantPivot::count())->toBe(1); }); -test('resources are synced only to workspaces that have the resource', function () { +test('resources only get created in tenant databases they were attached to', function () { $centralUser = CentralUser::create([ 'global_id' => 'acme', 'name' => 'John Doe', 'email' => 'john@localhost', 'password' => 'secret', - 'role' => 'commenter', // unsynced + 'role' => 'commenter', // Unsynced ]); - $t1 = ResourceTenant::create([ - 'id' => 't1', - ]); + $t1 = Tenant::create(); + $t2 = Tenant::create(); + $t3 = Tenant::create(); - $t2 = ResourceTenant::create([ - 'id' => 't2', - ]); - - $t3 = ResourceTenant::create([ - 'id' => 't3', - ]); migrateUsersTableForTenants(); - $centralUser->tenants()->attach('t1'); - $centralUser->tenants()->attach('t2'); + $centralUser->tenants()->attach($t1); + $centralUser->tenants()->attach($t2); // t3 is not attached - $t1->run(function () { - // assert user exists - expect(ResourceUser::all())->toHaveCount(1); - }); - - $t2->run(function () { - // assert user exists - expect(ResourceUser::all())->toHaveCount(1); - }); - - $t3->run(function () { - // assert user does NOT exist - expect(ResourceUser::all())->toHaveCount(0); - }); + $t1->run(fn () => expect(TenantUser::count())->toBe(1)); // assert user exists + $t2->run(fn () => expect(TenantUser::count())->toBe(1)); // assert user exists + $t3->run(fn () => expect(TenantUser::count())->toBe(0)); // assert user does NOT exist }); -test('when a resource exists in other tenant dbs but is created in a tenant db the synced columns are updated in the other dbs', function () { - // create shared resource +test('synced columns are updated in other tenant dbs where the resource exists', function () { + // Create central resource $centralUser = CentralUser::create([ 'global_id' => 'acme', 'name' => 'John Doe', 'email' => 'john@localhost', 'password' => 'secret', - 'role' => 'commenter', // unsynced + 'role' => 'commenter', // Unsynced ]); - $t1 = ResourceTenant::create([ - 'id' => 't1', - ]); - $t2 = ResourceTenant::create([ - 'id' => 't2', - ]); + $tenant1 = Tenant::create(); + $tenant2 = Tenant::create(); + $tenant3 = Tenant::create(); migrateUsersTableForTenants(); - // Copy (cascade) user to t1 DB - $centralUser->tenants()->attach('t1'); + // Create tenant users by attaching tenants to the central user + $centralUser->tenants()->attach($tenant1); + $centralUser->tenants()->attach($tenant2); + $centralUser->tenants()->attach($tenant3); - $t2->run(function () { - // Create user with the same global ID in t2 database - ResourceUser::create([ - 'global_id' => 'acme', - 'name' => 'John Foo', // changed - 'email' => 'john@foo', // changed - 'password' => 'secret', - 'role' => 'superadmin', // unsynced - ]); - }); - - $centralUser = CentralUser::first(); - expect($centralUser->name)->toBe('John Foo'); // name changed - expect($centralUser->email)->toBe('john@foo'); // email changed - expect($centralUser->role)->toBe('commenter'); // role didn't change - - $t1->run(function () { - $user = ResourceUser::first(); - expect($user->name)->toBe('John Foo'); // name changed - expect($user->email)->toBe('john@foo'); // email changed - expect($user->role)->toBe('commenter'); // role didn't change, i.e. is the same as from the original copy from central - }); -}); - -test('the synced columns are updated in other tenant dbs where the resource exists', function () { - // create shared resource - $centralUser = CentralUser::create([ - 'global_id' => 'acme', - 'name' => 'John Doe', - 'email' => 'john@localhost', - 'password' => 'secret', - 'role' => 'commenter', // unsynced - ]); - - $t1 = ResourceTenant::create([ - 'id' => 't1', - ]); - $t2 = ResourceTenant::create([ - 'id' => 't2', - ]); - $t3 = ResourceTenant::create([ - 'id' => 't3', - ]); - migrateUsersTableForTenants(); - - // Copy (cascade) user to t1 DB - $centralUser->tenants()->attach('t1'); - $centralUser->tenants()->attach('t2'); - $centralUser->tenants()->attach('t3'); - - $t3->run(function () { - ResourceUser::first()->update([ - 'name' => 'John 3', - 'role' => 'employee', // unsynced + // Update first tenant's resource + $tenant1->run(function () { + TenantUser::first()->update([ + 'name' => 'John 1', + 'role' => 'employee', // Unsynced ]); - expect(ResourceUser::first()->role)->toBe('employee'); + expect(TenantUser::first()->role)->toBe('employee'); }); - // Check that change was cascaded to other tenants - $t1->run($check = function () { - $user = ResourceUser::first(); + // Check that the resources of the other tenants got updated too + tenancy()->runForMultiple([$tenant2, $tenant3], function () { + $user = TenantUser::first(); - expect($user->name)->toBe('John 3'); // synced - expect($user->role)->toBe('commenter'); // unsynced + expect($user->name)->toBe('John 1'); + expect($user->role)->toBe('commenter'); }); - $t2->run($check); // Check that change bubbled up to central DB expect(CentralUser::count())->toBe(1); $centralUser = CentralUser::first(); - expect($centralUser->name)->toBe('John 3'); // synced - expect($centralUser->role)->toBe('commenter'); // unsynced + expect($centralUser->name)->toBe('John 1'); // Synced + expect($centralUser->role)->toBe('commenter'); // Unsynced + + // This works when the change comes from the central DB – all tenant resources get updated + $centralUser->update(['name' => 'John 0']); + + tenancy()->runForMultiple([$tenant1, $tenant2, $tenant3], function () { + expect(TenantUser::first()->name)->toBe('John 0'); + }); }); -test('global id is generated using id generator when its not supplied', function () { +test('the global id is generated using the id generator when the global id is not supplied when creating the resource', function () { $user = CentralUser::create([ 'name' => 'John Doe', 'email' => 'john@doe', @@ -567,60 +602,18 @@ test('global id is generated using id generator when its not supplied', function pest()->assertNotNull($user->global_id); }); -test('when the resource doesnt exist in the tenant db non synced columns will cascade too', function () { - $centralUser = CentralUser::create([ - 'name' => 'John Doe', - 'email' => 'john@doe', - 'password' => 'secret', - 'role' => 'employee', - ]); - - $t1 = ResourceTenant::create([ - 'id' => 't1', - ]); - - migrateUsersTableForTenants(); - - $centralUser->tenants()->attach('t1'); - - $t1->run(function () { - expect(ResourceUser::first()->role)->toBe('employee'); - }); -}); - -test('when the resource doesnt exist in the central db non synced columns will bubble up too', function () { - $t1 = ResourceTenant::create([ - 'id' => 't1', - ]); - - migrateUsersTableForTenants(); - - $t1->run(function () { - ResourceUser::create([ - 'name' => 'John Doe', - 'email' => 'john@doe', - 'password' => 'secret', - 'role' => 'employee', - ]); - }); - - expect(CentralUser::first()->role)->toBe('employee'); -}); - -test('the listener can be queued', function () { +test('the update or create listener can be queued', function () { Queue::fake(); - UpdateSyncedResource::$shouldQueue = true; + UpdateOrCreateSyncedResource::$shouldQueue = true; - $t1 = ResourceTenant::create([ - 'id' => 't1', - ]); + $tenant = Tenant::create(); migrateUsersTableForTenants(); Queue::assertNothingPushed(); - $t1->run(function () { - ResourceUser::create([ + $tenant->run(function () { + TenantUser::create([ 'name' => 'John Doe', 'email' => 'john@doe', 'password' => 'secret', @@ -629,151 +622,181 @@ test('the listener can be queued', function () { }); Queue::assertPushed(CallQueuedListener::class, function (CallQueuedListener $job) { - return $job->class === UpdateSyncedResource::class; + return $job->class === UpdateOrCreateSyncedResource::class; }); - UpdateSyncedResource::$shouldQueue = false; + UpdateOrCreateSyncedResource::$shouldQueue = false; }); -test('an event is fired for all touched resources', function () { - Event::fake([SyncedResourceChangedInForeignDatabase::class]); +test('the cascade deletes and restore listeners can be queued', function () { + Queue::fake(); + DeleteResourcesInTenants::$shouldQueue = true; + RestoreResourcesInTenants::$shouldQueue = true; + CentralUserWithSoftDeletes::$creationAttributes = [ + 'foo' => 'foo', + 'role' => 'role', + ]; - // create shared resource + $tenant = Tenant::create(); + + migrateUsersTableForTenants(); + addExtraColumns(true); + + Queue::assertNothingPushed(); + + $centralUser = fn () => CentralUserWithSoftDeletes::create([ + 'name' => 'John Doe', + 'email' => 'john@doe', + 'password' => 'secret', + 'role' => 'employee', + 'foo' => 'foo', + ]); + + [$user1, $user2] = [$centralUser(), $centralUser()]; + + $tenant->softDeletesUsers()->attach($user1); // Custom pivot method + $user2->tenants()->attach($tenant); + + $user1->delete(); + $user2->delete(); + + Queue::assertPushed(CallQueuedListener::class, function (CallQueuedListener $job) { + return $job->class === DeleteResourcesInTenants::class; + }); + + $user1->restore(); + $user2->restore(); + + Queue::assertPushed(CallQueuedListener::class, function (CallQueuedListener $job) { + return $job->class === RestoreResourcesInTenants::class; + }); + + DeleteResourcesInTenants::$shouldQueue = false; + RestoreResourcesInTenants::$shouldQueue = false; +}); + +test('the attach and detach listeners can be queued', function () { + Queue::fake(); + CreateTenantResource::$shouldQueue = true; + DeleteResourceInTenant::$shouldQueue = true; + + $tenant = Tenant::create(); + + migrateUsersTableForTenants(); + + $centralUser = fn () => CentralUser::create([ + 'name' => 'John Doe', + 'email' => 'john@doe', + 'password' => 'secret', + 'role' => 'employee', + ]); + + $user1 = $centralUser(); + $user2 = $centralUser(); + + $tenant->customPivotUsers()->attach($user1); + $user2->tenants()->attach($tenant); + + Queue::assertPushed(CallQueuedListener::class, function (CallQueuedListener $job) { + return $job->class === CreateTenantResource::class; + }); + + $tenant->customPivotUsers()->detach($user1); + $user2->tenants()->detach($tenant); + + Queue::assertPushed(CallQueuedListener::class, function (CallQueuedListener $job) { + return $job->class === DeleteResourceInTenant::class; + }); + + CreateTenantResource::$shouldQueue = false; + DeleteResourceInTenant::$shouldQueue = false; +}); + +test('the SyncedResourceSavedInForeignDatabase event is fired for all touched resources', function () { + Event::fake([SyncedResourceSavedInForeignDatabase::class]); + + // Create central resource $centralUser = CentralUser::create([ 'global_id' => 'acme', 'name' => 'John Doe', 'email' => 'john@localhost', 'password' => 'secret', - 'role' => 'commenter', // unsynced + 'role' => 'commenter', // Unsynced ]); - $t1 = ResourceTenant::create([ - 'id' => 't1', - ]); - $t2 = ResourceTenant::create([ - 'id' => 't2', - ]); - $t3 = ResourceTenant::create([ - 'id' => 't3', - ]); + $t1 = Tenant::create(['id' => 't1']); + $t2 = Tenant::create(['id' => 't2']); + $t3 = Tenant::create(['id' => 't3']); + migrateUsersTableForTenants(); - // Copy (cascade) user to t1 DB - $centralUser->tenants()->attach('t1'); - Event::assertDispatched(SyncedResourceChangedInForeignDatabase::class, function (SyncedResourceChangedInForeignDatabase $event) { - return $event->tenant->getTenantKey() === 't1'; - }); + // Create tenant resources by attaching tenants to the central user + foreach ([$t1, $t2, $t3] as $tenant) { + $centralUser->tenants()->attach($tenant); - $centralUser->tenants()->attach('t2'); - Event::assertDispatched(SyncedResourceChangedInForeignDatabase::class, function (SyncedResourceChangedInForeignDatabase $event) { - return $event->tenant->getTenantKey() === 't2'; - }); + Event::assertDispatched(SyncedResourceSavedInForeignDatabase::class, function (SyncedResourceSavedInForeignDatabase $event) use ($tenant) { + return $event->tenant->getTenantKey() === $tenant->getTenantKey(); + }); + } - $centralUser->tenants()->attach('t3'); - Event::assertDispatched(SyncedResourceChangedInForeignDatabase::class, function (SyncedResourceChangedInForeignDatabase $event) { - return $event->tenant->getTenantKey() === 't3'; - }); - - // Assert no event for central - Event::assertNotDispatched(SyncedResourceChangedInForeignDatabase::class, function (SyncedResourceChangedInForeignDatabase $event) { + // Event wasn't dispatched in the central app (No tenant present in the event) + Event::assertNotDispatched(SyncedResourceSavedInForeignDatabase::class, function (SyncedResourceSavedInForeignDatabase $event) { return $event->tenant === null; }); - // Flush - Event::fake([SyncedResourceChangedInForeignDatabase::class]); + // Flush event log + Event::fake([SyncedResourceSavedInForeignDatabase::class]); $t3->run(function () { - ResourceUser::first()->update([ - 'name' => 'John 3', - 'role' => 'employee', // unsynced - ]); - - expect(ResourceUser::first()->role)->toBe('employee'); + TenantUser::first()->update(['name' => 'John 3']); }); - Event::assertDispatched(SyncedResourceChangedInForeignDatabase::class, function (SyncedResourceChangedInForeignDatabase $event) { - return optional($event->tenant)->getTenantKey() === 't1'; + Event::assertDispatched(SyncedResourceSavedInForeignDatabase::class, function (SyncedResourceSavedInForeignDatabase $event) { + return $event->tenant?->getTenantKey() === 't1'; }); - Event::assertDispatched(SyncedResourceChangedInForeignDatabase::class, function (SyncedResourceChangedInForeignDatabase $event) { - return optional($event->tenant)->getTenantKey() === 't2'; + Event::assertDispatched(SyncedResourceSavedInForeignDatabase::class, function (SyncedResourceSavedInForeignDatabase $event) { + return $event->tenant?->getTenantKey() === 't2'; }); - // Assert NOT dispatched in t3 - Event::assertNotDispatched(SyncedResourceChangedInForeignDatabase::class, function (SyncedResourceChangedInForeignDatabase $event) { - return optional($event->tenant)->getTenantKey() === 't3'; + // Event wasn't dispatched for t3 + Event::assertNotDispatched(SyncedResourceSavedInForeignDatabase::class, function (SyncedResourceSavedInForeignDatabase $event) { + return $event->tenant?->getTenantKey() === 't3'; }); - // Assert dispatched in central - Event::assertDispatched(SyncedResourceChangedInForeignDatabase::class, function (SyncedResourceChangedInForeignDatabase $event) { - return $event->tenant === null; + // Event wasn't dispatched in the central app (No tenant present in the event) + Event::assertDispatched(SyncedResourceSavedInForeignDatabase::class, function (SyncedResourceSavedInForeignDatabase $event) { + return is_null($event->tenant); }); // Flush - Event::fake([SyncedResourceChangedInForeignDatabase::class]); + Event::fake([SyncedResourceSavedInForeignDatabase::class]); $centralUser->update([ 'name' => 'John Central', ]); - Event::assertDispatched(SyncedResourceChangedInForeignDatabase::class, function (SyncedResourceChangedInForeignDatabase $event) { - return optional($event->tenant)->getTenantKey() === 't1'; - }); - Event::assertDispatched(SyncedResourceChangedInForeignDatabase::class, function (SyncedResourceChangedInForeignDatabase $event) { - return optional($event->tenant)->getTenantKey() === 't2'; - }); - Event::assertDispatched(SyncedResourceChangedInForeignDatabase::class, function (SyncedResourceChangedInForeignDatabase $event) { - return optional($event->tenant)->getTenantKey() === 't3'; - }); - // Assert NOT dispatched in central - Event::assertNotDispatched(SyncedResourceChangedInForeignDatabase::class, function (SyncedResourceChangedInForeignDatabase $event) { - return $event->tenant === null; + foreach ([$t1, $t2, $t3] as $tenant) { + Event::assertDispatched(SyncedResourceSavedInForeignDatabase::class, function (SyncedResourceSavedInForeignDatabase $event) use ($tenant) { + return $event->tenant->getTenantKey() === $tenant->getTenantKey(); + }); + } + + // Event wasn't dispatched in the central app + Event::assertNotDispatched(SyncedResourceSavedInForeignDatabase::class, function (SyncedResourceSavedInForeignDatabase $event) { + return is_null($event->tenant); }); }); -// todo@tests -function creatingResourceInTenantDatabaseCreatesAndMapInCentralDatabase() -{ - // Assert no user in central DB - expect(ResourceUser::all())->toHaveCount(0); - - $tenant = ResourceTenant::create(); - migrateUsersTableForTenants(); - - tenancy()->initialize($tenant); - - // Create the same user in tenant DB - ResourceUser::create([ - 'global_id' => 'acme', - 'name' => 'John Doe', - 'email' => 'john@localhost', - 'password' => 'secret', - 'role' => 'commenter', // unsynced - ]); - - tenancy()->end(); - - // Assert user was created - expect(CentralUser::first()->global_id)->toBe('acme'); - expect(CentralUser::first()->role)->toBe('commenter'); - - // Assert mapping was created - expect(CentralUser::first()->tenants)->toHaveCount(1); - - // Assert role change doesn't cascade - CentralUser::first()->update(['role' => 'central superadmin']); - tenancy()->initialize($tenant); - expect(ResourceUser::first()->role)->toBe('commenter'); -} - -test('resources are synced only when sync is enabled', function (bool $enabled) { - app()->instance('_tenancy_test_shouldSync', $enabled); +test('resources are synced only when the shouldSync method returns true', function (bool $enabled) { + TenantUser::$shouldSync = $enabled; + CentralUser::$shouldSync = $enabled; [$tenant1, $tenant2] = createTenantsAndRunMigrations(); migrateUsersTableForTenants(); tenancy()->initialize($tenant1); - TenantUserWithConditionalSync::create([ + TenantUser::create([ 'global_id' => 'absd', 'name' => 'John Doe', 'email' => 'john@localhost', @@ -783,10 +806,10 @@ test('resources are synced only when sync is enabled', function (bool $enabled) tenancy()->end(); - expect(CentralUserWithConditionalSync::all())->toHaveCount($enabled ? 1 : 0); - expect(CentralUserWithConditionalSync::whereGlobalId('absd')->exists())->toBe($enabled); + expect(CentralUser::all())->toHaveCount($enabled ? 1 : 0); + expect(CentralUser::whereGlobalId('absd')->exists())->toBe($enabled); - $centralUser = CentralUserWithConditionalSync::create([ + $centralUser = CentralUser::create([ 'global_id' => 'acme', 'name' => 'John Doe', 'email' => 'john@localhost', @@ -794,33 +817,358 @@ test('resources are synced only when sync is enabled', function (bool $enabled) 'role' => 'commenter', ]); - $centralUser->tenants()->attach('t2'); + $centralUser->tenants()->attach($tenant2); $tenant2->run(function () use ($enabled) { - expect(TenantUserWithConditionalSync::all())->toHaveCount($enabled ? 1 : 0); - expect(TenantUserWithConditionalSync::whereGlobalId('acme')->exists())->toBe($enabled); + expect(TenantUser::all())->toHaveCount($enabled ? 1 : 0); + expect(TenantUser::whereGlobalId('acme')->exists())->toBe($enabled); }); -})->with([[true], [false]]); +})->with([ + true, + false, +]); + +test('deleting SyncMaster automatically deletes its Syncables', function () { + [$tenant1] = createTenantsAndRunMigrations(); + + $syncMaster = CentralUser::create([ + 'global_id' => 'cascade_user', + 'name' => 'Central user', + 'email' => 'central@localhost', + 'password' => 'password', + 'role' => 'cascade_user', + ]); + + $syncMaster->tenants()->attach($tenant1); + + $syncMaster->delete(); + + tenancy()->initialize($tenant1); + + expect(TenantUser::firstWhere('global_id', 'cascade_user'))->toBeNull(); // Delete cascaded +}); + +test('trashed resources are synced correctly', function () { + [$tenant1, $tenant2] = createTenantsAndRunMigrations(); + migrateUsersTableForTenants(); + addExtraColumns(true); + + // Include trashed resources in syncing queries + UpdateOrCreateSyncedResource::$scopeGetModelQuery = function (Builder $query) { + if ($query->hasMacro('withTrashed')) { + $query->withTrashed(); + } + }; + + $centralUser = CentralUserWithSoftDeletes::create([ + 'global_id' => 'user', + 'name' => 'Central user', + 'email' => 'central@localhost', + 'password' => 'password', + 'role' => 'commenter', + 'foo' => 'foo', + ]); + + $centralUser->tenants()->attach($tenant1); + $centralUser->tenants()->attach($tenant2); + + tenancy()->initialize($tenant1); + + // Synced resources aren't soft deleted from other tenants + TenantUserWithSoftDeletes::first()->delete(); + + tenancy()->initialize($tenant2); + + expect(TenantUserWithSoftDeletes::withTrashed()->first()->trashed())->toBeFalse(); + + tenancy()->end(); + + // Synced resources are soft deleted from tenants when the central resource gets deleted + expect(CentralUserWithSoftDeletes::first())->delete(); + + tenancy()->initialize($tenant2); + + expect(TenantUserWithSoftDeletes::withTrashed()->first()->trashed())->toBeTrue(); + + // Update soft deleted synced resource + TenantUserWithSoftDeletes::withTrashed()->first()->update(['name' => $newName = 'Updated name']); + + tenancy()->initialize($tenant1); + + $tenantResource = TenantUserWithSoftDeletes::withTrashed()->first(); + expect($tenantResource->name)->toBe($newName); // Value synced + expect($tenantResource->trashed())->toBeTrue(); + + tenancy()->end(); + + $centralResource = CentralUserWithSoftDeletes::withTrashed()->first(); + expect($centralResource->name)->toBe($newName); // Value synced + expect($centralResource->trashed())->toBeTrue(); + + tenancy()->initialize($tenant2); + + $tenantResource = TenantUserWithSoftDeletes::withTrashed()->first(); + expect($tenantResource->name)->toBe($newName); + // The trashed status is not synced even after updating another tenant resource + expect($tenantResource->trashed())->toBeTrue(); +}); + +test('restoring soft deleted resources works', function () { + [$tenant1, $tenant2] = createTenantsAndRunMigrations(); + migrateUsersTableForTenants(); + addExtraColumns(true); + + CentralUserWithSoftDeletes::create([ + 'global_id' => 'user', + 'name' => 'Central user', + 'email' => 'central@localhost', + 'password' => 'password', + 'role' => 'commenter', + 'foo' => 'foo', + ]); + + tenancy()->initialize($tenant1); + + TenantUserWithSoftDeletes::create([ + 'global_id' => 'user', + 'name' => 'Tenant user', + 'email' => 'tenant@localhost', + 'password' => 'password', + 'role' => 'commenter', + 'foo' => 'foo', + ]); + + tenancy()->initialize($tenant2); + + TenantUserWithSoftDeletes::create([ + 'global_id' => 'user', + 'name' => 'Tenant user', + 'email' => 'tenant@localhost', + 'password' => 'password', + 'role' => 'commenter', + 'foo' => 'foo', + ]); + + tenancy()->end(); + + // Synced resources are deleted from all tenants if the central resource gets deleted + CentralUserWithSoftDeletes::first()->delete(); + + expect(CentralUserWithSoftDeletes::withTrashed()->first()->trashed())->toBeTrue(); + + tenancy()->runForMultiple([$tenant1, $tenant2], function () { + expect(TenantUserWithSoftDeletes::withTrashed()->first()->trashed())->toBeTrue(); + }); + + // Restoring a central resource restores tenant resources + CentralUserWithSoftDeletes::withTrashed()->first()->restore(); + + tenancy()->runForMultiple([$tenant1, $tenant2], function () { + expect(TenantUserWithSoftDeletes::withTrashed()->first()->trashed())->toBeFalse(); + }); +}); + +test('using forceDelete on a central resource with soft deletes force deletes the tenant resources', function () { + [$tenant1, $tenant2] = createTenantsAndRunMigrations(); + migrateUsersTableForTenants(); + addExtraColumns(true); + + $centralUser = CentralUserWithSoftDeletes::create([ + 'global_id' => 'user', + 'name' => 'Central user', + 'email' => 'central@localhost', + 'password' => 'password', + 'role' => 'commenter', + 'foo' => 'foo', + ]); + + $centralUser->tenants()->attach($tenant1); + $centralUser->tenants()->attach($tenant2); + + // Force deleting a tenant resource does not affect resources of other tenants + tenancy()->initialize($tenant1); + + TenantUserWithSoftDeletes::firstWhere('global_id', 'user')->forceDelete(); + + tenancy()->initialize($tenant2); + + expect(TenantUserWithSoftDeletes::firstWhere('global_id', 'user'))->not()->toBeNull(); + + tenancy()->end(); + + // Synced resources are deleted from all tenants if the central resource gets force deleted + CentralUserWithSoftDeletes::firstWhere('global_id', 'user')->forceDelete(); + + expect(CentralUserWithSoftDeletes::withTrashed()->firstWhere('global_id', 'user'))->toBeNull(); + + tenancy()->initialize($tenant2); + + expect(TenantUserWithSoftDeletes::withTrashed()->firstWhere('global_id', 'user'))->toBeNull(); +}); + +test('resource creation works correctly when tenant resource provides defaults in the creation attributes', function () { + [$tenant1, $tenant2] = createTenantsAndRunMigrations(); + + addExtraColumns(); + + // Attribute names + CentralUser::$creationAttributes = ['role']; + + // Mixed (attribute name + defaults) + TenantUser::$creationAttributes = [ + 'name' => 'Default Name', + 'email' => 'default@localhost', + 'password' => 'password', + 'role' => 'admin', + 'foo' => 'bar', + ]; + + $centralUser = CentralUser::create([ + 'global_id' => 'acme', + 'name' => 'John Doe', + 'email' => 'john@localhost', + 'password' => 'secret', + 'role' => 'commenter', + 'foo' => 'bar', // foo does not exist in tenant resource + ]); + + $tenant1->run(function () { + expect(TenantUser::all())->toHaveCount(0); + }); + + // When central resource provides the attribute names in $creationAttributes + // The tenant resource will be created using them + $centralUser->tenants()->attach($tenant1); + + $tenant1->run(function () { + $tenantUser = TenantUser::all(); + expect($tenantUser)->toHaveCount(1); + expect($tenantUser->first()->global_id)->toBe('acme'); + expect($tenantUser->first()->email)->toBe('john@localhost'); + // 'foo' attribute is not provided by central model + expect($tenantUser->first()->foo)->toBeNull(); + }); + + tenancy()->initialize($tenant2); + + // Creating a tenant resource creates a central resource + // Using resource's creation attributes + TenantUser::create([ + 'global_id' => 'asdf', + 'name' => 'John Doe', + 'email' => 'john@localhost', + 'password' => 'secret', + 'role' => 'commenter', + ]); + + tenancy()->end(); + + // Central user was created using the defaults provided in the tenant resource + // Creating TenantUser created a CentralUser with the same global_id + $centralUser = CentralUser::whereGlobalId('asdf')->first(); + expect($centralUser)->not()->toBeNull(); + expect($centralUser->name)->toBe('Default Name'); + expect($centralUser->email)->toBe('default@localhost'); + expect($centralUser->password)->toBe('password'); + expect($centralUser->role)->toBe('admin'); + expect($centralUser->foo)->toBe('bar'); +}); + +test('resource creation works correctly when central resource provides defaults in the creation attributes', function () { + [$tenant1, $tenant2] = createTenantsAndRunMigrations(); + + addExtraColumns(); + + CentralUser::$creationAttributes = [ + 'name' => 'Default User', + 'email' => 'default@localhost', + 'password' => 'password', + 'role' => 'admin', + ]; + + TenantUser::$creationAttributes = [ + // Central user requires 'foo', but tenant user does not have it + // So we provide a default here in order for the central resource to be created when creating the tenant resource + 'foo' => 'bar', + 'role', + ]; + + $centralUser = CentralUser::create([ + 'global_id' => 'acme', + 'name' => 'John Doe', + 'email' => 'john@localhost', + 'password' => 'secret', + 'role' => 'commenter', + 'foo' => 'bar', + ]); + + $tenant1->run(function () { + expect(TenantUser::all())->toHaveCount(0); + }); + + $centralUser->tenants()->attach($tenant1); + + $tenant1->run(function () { + // Assert tenant resource was created using the defaults provided in the central resource + $tenantUser = TenantUser::first(); + expect($tenantUser)->not()->toBeNull(); + expect($tenantUser->global_id)->toBe('acme'); + expect($tenantUser->email)->toBe('default@localhost'); + expect($tenantUser->password)->toBe('password'); + expect($tenantUser->role)->toBe('admin'); + }); + + tenancy()->initialize($tenant2); + + // The creation attributes provided in the tenant resource will be used in the newly created central resource + TenantUser::create([ + 'global_id' => 'asdf', + 'name' => 'John Doe', + 'email' => 'john@localhost', + 'password' => 'secret', + 'role' => 'commenter', + ]); + + tenancy()->end(); + + // Assert central resource was created using the provided attributes + $centralUser = CentralUser::whereGlobalId('asdf')->first(); + + expect($centralUser)->not()->toBeNull(); + expect($centralUser->email)->toBe('john@localhost'); + expect($centralUser->password)->toBe('secret'); + expect($centralUser->role)->toBe('commenter'); + expect($centralUser->foo)->toBe('bar'); +}); /** * Create two tenants and run migrations for those tenants. + * + * @return Tenant[] */ function createTenantsAndRunMigrations(): array { - [$tenant1, $tenant2] = [ResourceTenant::create(['id' => 't1']), ResourceTenant::create(['id' => 't2'])]; + [$tenant1, $tenant2] = [Tenant::create(), Tenant::create()]; migrateUsersTableForTenants(); return [$tenant1, $tenant2]; } -function addExtraColumnToCentralDB(): void +function addExtraColumns(bool $tenantDbs = false): void { - // migrate extra column "foo" in central DB + // Migrate extra column "foo" in central DB pest()->artisan('migrate', [ '--path' => __DIR__ . '/Etc/synced_resource_migrations/users_extra', '--realpath' => true, ])->assertExitCode(0); + + if ($tenantDbs) { + pest()->artisan('tenants:migrate', [ + '--path' => __DIR__ . '/Etc/synced_resource_migrations/users_extra', + '--realpath' => true, + ])->assertExitCode(0); + } } function migrateUsersTableForTenants(): void @@ -831,17 +1179,86 @@ function migrateUsersTableForTenants(): void ])->assertExitCode(0); } -// Tenant model used for resource syncing setup -class ResourceTenant extends Tenant +function migrateCompaniesTableForTenants(): void { - public function users(): BelongsToMany + pest()->artisan('tenants:migrate', [ + '--path' => __DIR__ . '/Etc/synced_resource_migrations/companies', + '--realpath' => true, + ])->assertExitCode(0); +} + +class CentralUser extends BaseCentralUser +{ + public function tenants(): BelongsToMany { - return $this->belongsToMany(CentralUser::class, 'tenant_users', 'tenant_id', 'global_user_id', 'id', 'global_id') + return $this->belongsToMany(Tenant::class, 'tenant_users', 'global_user_id', 'tenant_id', 'global_id') ->using(TenantPivot::class); } } -class CentralUser extends Model implements SyncMaster +class TenantUser extends BaseTenantUser +{ + public function getCentralModelName(): string + { + return CentralUser::class; + } + + public function tenants(): BelongsToMany + { + return $this->belongsToMany(Tenant::class, 'tenant_users', 'global_user_id', 'tenant_id', 'global_id') + ->using(TenantPivot::class); + } +} + +class TenantPivot extends BasePivot +{ + public $table = 'tenant_users'; +} + +class CentralUserWithSoftDeletes extends CentralUser +{ + use SoftDeletes; + + public function getTenantModelName(): string + { + return TenantUserWithSoftDeletes::class; + } + + public function getCreationAttributes(): array + { + return array_merge($this->getSyncedAttributeNames(), [ + 'role' => 'role', + 'foo' => 'foo', // extra column + ]); + } +} + +class TenantUserWithSoftDeletes extends TenantUser +{ + use SoftDeletes; + + public function getCentralModelName(): string + { + return CentralUserWithSoftDeletes::class; + } +} + +class MorphTenant extends Tenant +{ + public function users(): MorphToMany + { + return $this->morphedByMany(BaseCentralUser::class, 'tenant_resources', 'tenant_resources', 'tenant_id', 'resource_global_id', 'id', 'global_id') + ->using(TenantMorphPivot::class); + } + + public function companies(): MorphToMany + { + return $this->morphedByMany(CentralCompany::class, 'tenant_resources', 'tenant_resources', 'tenant_id', 'resource_global_id', 'id', 'global_id') + ->using(TenantMorphPivot::class); + } +} + +class CentralCompany extends Model implements SyncMaster { use ResourceSyncing, CentralConnection; @@ -849,27 +1266,11 @@ class CentralUser extends Model implements SyncMaster public $timestamps = false; - public $table = 'users'; - - public function tenants(): BelongsToMany - { - return $this->belongsToMany(ResourceTenant::class, 'tenant_users', 'global_user_id', 'tenant_id', 'global_id') - ->using(TenantPivot::class); - } + public $table = 'companies'; public function getTenantModelName(): string { - return ResourceUser::class; - } - - public function getGlobalIdentifierKey(): string|int - { - return $this->getAttribute($this->getGlobalIdentifierKeyName()); - } - - public function getGlobalIdentifierKeyName(): string - { - return 'global_id'; + return TenantCompany::class; } public function getCentralModelName(): string @@ -882,36 +1283,23 @@ class CentralUser extends Model implements SyncMaster return [ 'global_id', 'name', - 'password', 'email', ]; } } - -// Tenant users -class ResourceUser extends Model implements Syncable +class TenantCompany extends Model implements Syncable { use ResourceSyncing; - protected $table = 'users'; + protected $table = 'companies'; protected $guarded = []; public $timestamps = false; - public function getGlobalIdentifierKey(): string|int - { - return $this->getAttribute($this->getGlobalIdentifierKeyName()); - } - - public function getGlobalIdentifierKeyName(): string - { - return 'global_id'; - } - public function getCentralModelName(): string { - return CentralUser::class; + return CentralCompany::class; } public function getSyncedAttributeNames(): array @@ -919,120 +1307,7 @@ class ResourceUser extends Model implements Syncable return [ 'global_id', 'name', - 'password', 'email', ]; } } - -// override method in ResourceUser class to return default attribute values -class TenantUserProvidingDefaultValues extends ResourceUser -{ - public function getSyncedCreationAttributes(): array - { - // Default values when creating resources from tenant to central DB - return - [ - 'name' => 'Default Name', - 'email' => 'default@localhost', - 'password' => 'password', - 'role' => 'admin', - 'foo' => 'bar' - ]; - } -} - -// override method in ResourceUser class to return attribute names -class TenantUserProvidingAttributeNames extends ResourceUser -{ - public function getSyncedCreationAttributes(): array - { - // Attributes used when creating resources from tenant to central DB - // Notice here we are not adding "code" filed because it doesn't - // exist in central model - return - [ - 'name', - 'password', - 'email', - 'role', - 'foo' => 'bar' - ]; - } - -} - -// override method in CentralUser class to return attribute default values -class CentralUserProvidingDefaultValues extends CentralUser -{ - public function getSyncedCreationAttributes(): array - { - // Attributes default values when creating resources from central to tenant model - return - [ - 'name' => 'Default User', - 'email' => 'default@localhost', - 'password' => 'password', - 'role' => 'admin', - ]; - } -} - -// override method in CentralUser class to return attribute names -class CentralUserProvidingAttributeNames extends CentralUser -{ - public function getSyncedCreationAttributes(): array - { - // Attributes used when creating resources from central to tenant DB - return - [ - 'global_id', - 'name', - 'password', - 'email', - 'role', - ]; - } -} - -class CentralUserProvidingMixture extends CentralUser -{ - public function getSyncedCreationAttributes(): array - { - return [ - 'name', - 'email', - 'role' => 'admin', - 'password' => 'secret', - ]; - } -} - -class TenantUserProvidingMixture extends ResourceUser -{ - public function getSyncedCreationAttributes(): array - { - return [ - 'name', - 'email', - 'role' => 'admin', - 'password' => 'secret', - ]; - } -} - -class CentralUserWithConditionalSync extends CentralUser -{ - public function shouldSync(): bool - { - return app('_tenancy_test_shouldSync'); - } -} - -class TenantUserWithConditionalSync extends ResourceUser -{ - public function shouldSync(): bool - { - return app('_tenancy_test_shouldSync'); - } -} diff --git a/tests/ResourceSyncingUsingPolymorphicTest.php b/tests/ResourceSyncingUsingPolymorphicTest.php deleted file mode 100644 index f3d5b30a..00000000 --- a/tests/ResourceSyncingUsingPolymorphicTest.php +++ /dev/null @@ -1,395 +0,0 @@ - [ - DatabaseTenancyBootstrapper::class, - ], - 'tenancy.models.tenant' => ResourceTenantUsingPolymorphic::class, - ]); - - Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { - return $event->tenant; - })->toListener()); - - DatabaseConfig::generateDatabaseNamesUsing(function () { - return 'db' . Str::random(16); - }); - - Event::listen(TenancyInitialized::class, BootstrapTenancy::class); - Event::listen(TenancyEnded::class, RevertToCentralContext::class); - Event::listen(SyncedResourceSaved::class, UpdateSyncedResource::class); - - // Run migrations on central connection - pest()->artisan('migrate', [ - '--path' => [ - __DIR__ . '/../assets/resource-syncing-migrations', - __DIR__ . '/Etc/synced_resource_migrations/users', - __DIR__ . '/Etc/synced_resource_migrations/companies', - ], - '--realpath' => true, - ])->assertExitCode(0); -}); - -test('resource syncing works using a single pivot table for multiple models when syncing from central to tenant', function () { - $tenant1 = ResourceTenantUsingPolymorphic::create(['id' => 't1']); - migrateUsersTableForTenants(); - - $centralUser = CentralUserUsingPolymorphic::create([ - 'global_id' => 'acme', - 'name' => 'John Doe', - 'email' => 'john@localhost', - 'password' => 'password', - 'role' => 'commenter', - ]); - - $tenant1->run(function () { - expect(TenantUserUsingPolymorphic::all())->toHaveCount(0); - }); - - $centralUser->tenants()->attach('t1'); - - // Assert `tenants` are accessible - expect($centralUser->tenants->pluck('id')->toArray())->toBe(['t1']); - - // Users are accessible from tenant - expect($tenant1->users()->pluck('email')->toArray())->toBe(['john@localhost']); - - // Assert User resource is synced - $tenant1->run(function () use ($centralUser) { - $tenantUser = TenantUserUsingPolymorphic::first()->toArray(); - $centralUser = $centralUser->withoutRelations()->toArray(); - unset($centralUser['id'], $tenantUser['id']); - - expect($tenantUser)->toBe($centralUser); - }); - - $tenant2 = ResourceTenantUsingPolymorphic::create(['id' => 't2']); - migrateCompaniesTableForTenants(); - - $centralCompany = CentralCompanyUsingPolymorphic::create([ - 'global_id' => 'acme', - 'name' => 'ArchTech', - 'email' => 'archtech@localhost', - ]); - - $tenant2->run(function () { - expect(TenantCompanyUsingPolymorphic::all())->toHaveCount(0); - }); - - $centralCompany->tenants()->attach('t2'); - - // Assert `tenants` are accessible - expect($centralCompany->tenants->pluck('id')->toArray())->toBe(['t2']); - - // Companies are accessible from tenant - expect($tenant2->companies()->pluck('email')->toArray())->toBe(['archtech@localhost']); - - // Assert Company resource is synced - $tenant2->run(function () use ($centralCompany) { - $tenantCompany = TenantCompanyUsingPolymorphic::first()->toArray(); - $centralCompany = $centralCompany->withoutRelations()->toArray(); - - unset($centralCompany['id'], $tenantCompany['id']); - - expect($tenantCompany)->toBe($centralCompany); - }); -}); - -test('resource syncing works using a single pivot table for multiple models when syncing from tenant to central', function () { - $tenant1 = ResourceTenantUsingPolymorphic::create(['id' => 't1']); - migrateUsersTableForTenants(); - - tenancy()->initialize($tenant1); - - $tenantUser = TenantUserUsingPolymorphic::create([ - 'name' => 'John Doe', - 'email' => 'john@localhost', - 'password' => 'password', - 'role' => 'commenter', - ]); - - tenancy()->end(); - - // Assert User resource is synced - $centralUser = CentralUserUsingPolymorphic::first(); - - // Assert `tenants` are accessible - expect($centralUser->tenants->pluck('id')->toArray())->toBe(['t1']); - - // Users are accessible from tenant - expect($tenant1->users()->pluck('email')->toArray())->toBe(['john@localhost']); - - $centralUser = $centralUser->withoutRelations()->toArray(); - $tenantUser = $tenantUser->toArray(); - unset($centralUser['id'], $tenantUser['id']); - - // array keys use a different order here - expect($tenantUser)->toEqualCanonicalizing($centralUser); - - $tenant2 = ResourceTenantUsingPolymorphic::create(['id' => 't2']); - migrateCompaniesTableForTenants(); - - tenancy()->initialize($tenant2); - - $tenantCompany = TenantCompanyUsingPolymorphic::create([ - 'global_id' => 'acme', - 'name' => 'tenant comp', - 'email' => 'company@localhost', - ]); - - tenancy()->end(); - - // Assert Company resource is synced - $centralCompany = CentralCompanyUsingPolymorphic::first(); - - // Assert `tenants` are accessible - expect($centralCompany->tenants->pluck('id')->toArray())->toBe(['t2']); - - // Companies are accessible from tenant - expect($tenant2->companies()->pluck('email')->toArray())->toBe(['company@localhost']); - - $centralCompany = $centralCompany->withoutRelations()->toArray(); - $tenantCompany = $tenantCompany->toArray(); - unset($centralCompany['id'], $tenantCompany['id']); - - expect($tenantCompany)->toBe($centralCompany); -}); - -test('right resources are accessible from the tenant', function () { - $tenant1 = ResourceTenantUsingPolymorphic::create(['id' => 't1']); - $tenant2 = ResourceTenantUsingPolymorphic::create(['id' => 't2']); - migrateUsersTableForTenants(); - - $user1 = CentralUserUsingPolymorphic::create([ - 'global_id' => 'user1', - 'name' => 'user1', - 'email' => 'user1@localhost', - 'password' => 'password', - 'role' => 'commenter', - ]); - - $user2 = CentralUserUsingPolymorphic::create([ - 'global_id' => 'user2', - 'name' => 'user2', - 'email' => 'user2@localhost', - 'password' => 'password', - 'role' => 'commenter', - ]); - - $user3 = CentralUserUsingPolymorphic::create([ - 'global_id' => 'user3', - 'name' => 'user3', - 'email' => 'user3@localhost', - 'password' => 'password', - 'role' => 'commenter', - ]); - - $user1->tenants()->attach('t1'); - $user2->tenants()->attach('t1'); - $user3->tenants()->attach('t2'); - - expect($tenant1->users()->pluck('email')->toArray())->toBe([$user1->email, $user2->email]); - expect($tenant2->users()->pluck('email')->toArray())->toBe([$user3->email]); -}); - -function migrateCompaniesTableForTenants(): void -{ - pest()->artisan('tenants:migrate', [ - '--path' => __DIR__ . '/Etc/synced_resource_migrations/companies', - '--realpath' => true, - ])->assertExitCode(0); -} - -// Tenant model used for resource syncing setup -class ResourceTenantUsingPolymorphic extends Tenant -{ - public function users(): MorphToMany - { - return $this->morphedByMany(CentralUserUsingPolymorphic::class, 'tenant_resources', 'tenant_resources', 'tenant_id', 'resource_global_id', 'id', 'global_id') - ->using(TenantMorphPivot::class); - } - - public function companies(): MorphToMany - { - return $this->morphedByMany(CentralCompanyUsingPolymorphic::class, 'tenant_resources', 'tenant_resources', 'tenant_id', 'resource_global_id', 'id', 'global_id') - ->using(TenantMorphPivot::class); - } -} - -class CentralUserUsingPolymorphic extends Model implements SyncMaster -{ - use ResourceSyncing, CentralConnection; - - protected $guarded = []; - - public $timestamps = false; - - public $table = 'users'; - - public function getTenantModelName(): string - { - return TenantUserUsingPolymorphic::class; - } - - public function getGlobalIdentifierKey(): string|int - { - return $this->getAttribute($this->getGlobalIdentifierKeyName()); - } - - public function getGlobalIdentifierKeyName(): string - { - return 'global_id'; - } - - public function getCentralModelName(): string - { - return static::class; - } - - public function getSyncedAttributeNames(): array - { - return [ - 'global_id', - 'name', - 'password', - 'email', - ]; - } -} - -class TenantUserUsingPolymorphic extends Model implements Syncable -{ - use ResourceSyncing; - - protected $table = 'users'; - - protected $guarded = []; - - public $timestamps = false; - - public function getGlobalIdentifierKey(): string|int - { - return $this->getAttribute($this->getGlobalIdentifierKeyName()); - } - - public function getGlobalIdentifierKeyName(): string - { - return 'global_id'; - } - - public function getCentralModelName(): string - { - return CentralUserUsingPolymorphic::class; - } - - public function getSyncedAttributeNames(): array - { - return [ - 'global_id', - 'name', - 'password', - 'email', - ]; - } -} - -class CentralCompanyUsingPolymorphic extends Model implements SyncMaster -{ - use ResourceSyncing, CentralConnection; - - protected $guarded = []; - - public $timestamps = false; - - public $table = 'companies'; - - public function getTenantModelName(): string - { - return TenantCompanyUsingPolymorphic::class; - } - - public function getGlobalIdentifierKey(): string|int - { - return $this->getAttribute($this->getGlobalIdentifierKeyName()); - } - - public function getGlobalIdentifierKeyName(): string - { - return 'global_id'; - } - - public function getCentralModelName(): string - { - return static::class; - } - - public function getSyncedAttributeNames(): array - { - return [ - 'global_id', - 'name', - 'email', - ]; - } -} - -class TenantCompanyUsingPolymorphic extends Model implements Syncable -{ - use ResourceSyncing; - - protected $table = 'companies'; - - protected $guarded = []; - - public $timestamps = false; - - public function getGlobalIdentifierKey(): string|int - { - return $this->getAttribute($this->getGlobalIdentifierKeyName()); - } - - public function getGlobalIdentifierKeyName(): string - { - return 'global_id'; - } - - public function getCentralModelName(): string - { - return CentralCompanyUsingPolymorphic::class; - } - - public function getSyncedAttributeNames(): array - { - return [ - 'global_id', - 'name', - 'email', - ]; - } -}