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

Resource syncing rework (#30)

* 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 <samuel.stancl@gmail.com>

* 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 <samuel.stancl@gmail.com>

* 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 <samuel.stancl@gmail.com>

* 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 <samuel.stancl@gmail.com>

* 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 <lukinovec@gmail.com>
Co-authored-by: PHP CS Fixer <phpcsfixer@example.com>
This commit is contained in:
Samuel Štancl 2024-02-10 19:08:37 +01:00 committed by GitHub
parent aa1437fb5e
commit 6784685054
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1903 additions and 1325 deletions

View file

@ -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.
*

View file

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Contracts;
interface Syncable
{
public function getGlobalIdentifierKeyName(): string;
public function getGlobalIdentifierKey(): string|int;
public function getCentralModelName(): string;
public function getSyncedAttributeNames(): array;
public function triggerSyncEvent(): void;
/** Get the attributes used for creating the *other* model (i.e. tenant if this is the central one, and central if this is the tenant one). */
public function getSyncedCreationAttributes(): array|null; // todo come up with a better name
public function shouldSync(): bool;
}

View file

@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Stancl\Tenancy\Contracts\Syncable;
use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
use Stancl\Tenancy\Database\Models\TenantMorphPivot;
use Stancl\Tenancy\Events\SyncedResourceSaved;
trait ResourceSyncing
{
public static function bootResourceSyncing(): void
{
static::saved(function (Syncable $model) {
if ($model->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);
}
}

View file

@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns;
use Stancl\Tenancy\Contracts\Syncable;
trait TriggerSyncEvent
{
public static function booted(): void
{
static::saved(function (self $pivot) {
$parent = $pivot->pivotParent;
if ($parent instanceof Syncable && $parent->shouldSync()) {
$parent->triggerSyncEvent();
}
});
}
}

View file

@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Database\Models;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Stancl\Tenancy\Database\Concerns\TriggerSyncEvent;
class TenantPivot extends Pivot
{
use TriggerSyncEvent;
}

View file

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Events;
use Stancl\Tenancy\Contracts\Syncable;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
class SyncedResourceChangedInForeignDatabase
{
/** @var Syncable */
public $model;
/** @var TenantWithDatabase|null */
public $tenant;
public function __construct(Syncable $model, ?TenantWithDatabase $tenant)
{
$this->model = $model;
$this->tenant = $tenant;
}
}

View file

@ -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
{

View file

@ -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
{

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing;
use Exception;
class CentralResourceNotAvailableInPivotException extends Exception
{
public function __construct()
{
parent::__construct(
'Central resource is not accessible in pivot model.
To attach a resource to a tenant, use $centralResource->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.'
);
}
}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Events;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\ResourceSyncing\SyncMaster;
class CentralResourceAttachedToTenant
{
public function __construct(
public SyncMaster&Model $centralResource,
public TenantWithDatabase $tenant,
) {
}
}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Events;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\ResourceSyncing\SyncMaster;
class CentralResourceDetachedFromTenant
{
public function __construct(
public SyncMaster&Model $centralResource,
public TenantWithDatabase $tenant,
) {
}
}

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Events;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\ResourceSyncing\SyncMaster;
class SyncMasterDeleted
{
public function __construct(
public SyncMaster&Model $centralResource,
public bool $forceDelete = false,
) {
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Events;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\ResourceSyncing\SyncMaster;
class SyncMasterRestored
{
public function __construct(
public SyncMaster&Model $centralResource
) {
}
}

View file

@ -2,11 +2,11 @@
declare(strict_types=1);
namespace Stancl\Tenancy\Events;
namespace Stancl\Tenancy\ResourceSyncing\Events;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Contracts\Syncable;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\ResourceSyncing\Syncable;
class SyncedResourceSaved
{

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Events;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\ResourceSyncing\Syncable;
/** @internal Only used for test assertions, this event does not trigger any syncing logic. */
class SyncedResourceSavedInForeignDatabase
{
public function __construct(
public Syncable&Model $model,
public TenantWithDatabase|null $tenant
) {
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Listeners;
use Stancl\Tenancy\Listeners\QueueableListener;
use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceAttachedToTenant;
use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase;
use Stancl\Tenancy\ResourceSyncing\ParsesCreationAttributes;
/**
* Create tenant resource synced to the central resource.
*/
class CreateTenantResource extends QueueableListener
{
use ParsesCreationAttributes;
public static bool $shouldQueue = false;
public function handle(CentralResourceAttachedToTenant $event): void
{
$tenantResourceClass = $event->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));
});
});
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Listeners;
use Stancl\Tenancy\Listeners\QueueableListener;
use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceDetachedFromTenant;
/**
* When a central resource is detached from a tenant, delete the tenant resource.
*/
class DeleteResourceInTenant extends QueueableListener
{
use DeletesSyncedResources;
public static bool $shouldQueue = false;
public function handle(CentralResourceDetachedFromTenant $event): void
{
$event->tenant->run(fn () => $this->deleteSyncedResource($event->centralResource, true));
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Listeners;
use Stancl\Tenancy\Listeners\QueueableListener;
use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterDeleted;
class DeleteResourcesInTenants extends QueueableListener
{
use DeletesSyncedResources;
public static bool $shouldQueue = false;
public function handle(SyncMasterDeleted $event): void
{
$centralResource = $event->centralResource;
$forceDelete = $event->forceDelete;
tenancy()->runForMultiple($centralResource->tenants()->cursor(), function () use ($centralResource, $forceDelete) {
$this->deleteSyncedResource($centralResource, $forceDelete);
});
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Listeners;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\ResourceSyncing\Syncable;
use Stancl\Tenancy\ResourceSyncing\SyncMaster;
trait DeletesSyncedResources
{
protected function deleteSyncedResource(SyncMaster&Model $centralResource, bool $force = false): void
{
$tenantResourceClass = $centralResource->getTenantModelName();
/** @var (Syncable&Model)|null $tenantResource */
$tenantResource = $tenantResourceClass::firstWhere(
$centralResource->getGlobalIdentifierKeyName(),
$centralResource->getGlobalIdentifierKey()
);
if ($force) {
$tenantResource?->forceDelete();
} else {
$tenantResource?->delete();
}
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Listeners;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Stancl\Tenancy\Listeners\QueueableListener;
use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterRestored;
use Stancl\Tenancy\ResourceSyncing\Syncable;
use Stancl\Tenancy\ResourceSyncing\SyncMaster;
class RestoreResourcesInTenants extends QueueableListener
{
public static bool $shouldQueue = false;
public function handle(SyncMasterRestored $event): void
{
/** @var SyncMaster&Model $centralResource */
$centralResource = $event->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();
}
});
}
}

View file

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

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Stancl\Tenancy\Exceptions;
namespace Stancl\Tenancy\ResourceSyncing;
use Exception;

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
trait ParsesCreationAttributes
{
protected function parseCreationAttributes(Syncable&Model $resource): array
{
$creationAttributes = $resource->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);
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing;
use Illuminate\Database\Eloquent\Model;
interface PivotWithRelation
{
/**
* E.g. return $this->users()->getModel().
*/
public function getRelatedModel(): Model;
}

View file

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceAttachedToTenant;
use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceDetachedFromTenant;
use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSaved;
use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterDeleted;
use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterRestored;
trait ResourceSyncing
{
public static function bootResourceSyncing(): void
{
static::saved(function (Syncable&Model $model) {
if ($model->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());
}
}

View file

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

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing;
interface Syncable
{
public function getGlobalIdentifierKeyName(): string;
public function getGlobalIdentifierKey(): string|int;
public function getCentralModelName(): string;
public function getSyncedAttributeNames(): array;
public function triggerSyncEvent(): void;
/**
* Get the attributes used for creating the *other* model (i.e. tenant if this is the central one, and central if this is the tenant one).
*
* You can also specify the default values for the attributes.
*
* E.g. return [
* 'attribute',
* 'attribute2' => '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;
}

View file

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

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing;
use Illuminate\Database\Eloquent\Relations\Pivot;
class TenantPivot extends Pivot
{
use TriggerSyncingEvents;
}

View file

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing;
use Stancl\Tenancy\Contracts\Tenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Database\Eloquent\Relations\MorphPivot;
/**
* Used on pivot models.
*
* @see TenantPivot
* @see MorphPivot
*/
trait TriggerSyncingEvents
{
public static function bootTriggerSyncingEvents(): void
{
static::saving(function (self $pivot) {
// Try getting the central resource to see if it is available
// If it is not available, throw an exception to interrupt the saving process
// And prevent creating a pivot record without a central resource
$pivot->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;
}
}

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Tests\Etc\ResourceSyncing;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Database\Concerns\CentralConnection;
use Stancl\Tenancy\ResourceSyncing\ResourceSyncing;
use Stancl\Tenancy\ResourceSyncing\SyncMaster;
class CentralUser extends Model implements SyncMaster
{
use ResourceSyncing, CentralConnection;
protected $guarded = [];
public $timestamps = false;
public $table = 'users';
public static array $syncedAttributes = [];
public static array $creationAttributes = [];
public static bool $shouldSync = true;
public function getTenantModelName(): string
{
return TenantUser::class;
}
public function shouldSync(): bool
{
return static::$shouldSync;
}
public function getCentralModelName(): string
{
return static::class;
}
public function getSyncedAttributeNames(): array
{
return static::$syncedAttributes;
}
public function getCreationAttributes(): array
{
return count(static::$creationAttributes) ? static::$creationAttributes : $this->getSyncedAttributeNames();
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Tests\Etc\ResourceSyncing;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Stancl\Tenancy\ResourceSyncing\PivotWithRelation;
use Stancl\Tenancy\ResourceSyncing\TenantPivot;
class CustomPivot extends TenantPivot implements PivotWithRelation
{
public function users(): BelongsToMany
{
return $this->belongsToMany(CentralUser::class);
}
public function getRelatedModel(): Model
{
return $this->users()->getModel();
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Tests\Etc\ResourceSyncing;
use CentralUserWithSoftDeletes;
use Stancl\Tenancy\ResourceSyncing\TenantPivot;
use Stancl\Tenancy\Tests\Etc\Tenant as BaseTenant;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Tenant extends BaseTenant
{
public function users(): BelongsToMany
{
return $this->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);
}
}

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Tests\Etc\ResourceSyncing;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\ResourceSyncing\ResourceSyncing;
use Stancl\Tenancy\ResourceSyncing\Syncable;
class TenantUser extends Model implements Syncable
{
use ResourceSyncing;
protected $table = 'users';
protected $guarded = [];
public $timestamps = false;
public static array $syncedAttributes = [];
public static array $creationAttributes = [];
public static bool $shouldSync = true;
public function shouldSync(): bool
{
return static::$shouldSync;
}
public function getCentralModelName(): string
{
return CentralUser::class;
}
public function getSyncedAttributeNames(): array
{
return static::$syncedAttributes;
}
public function getCreationAttributes(): array
{
return count(static::$creationAttributes) ? static::$creationAttributes : $this->getSyncedAttributeNames();
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddDeletedAtToUsersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->dateTime('deleted_at')->nullable();
});
}
public function down()
{
}
}

View file

@ -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;

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,395 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Support\Facades\Event;
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\DatabaseConfig;
use Stancl\Tenancy\Database\Models\TenantMorphPivot;
use Stancl\Tenancy\Database\Models\TenantPivot;
use Stancl\Tenancy\Events\SyncedResourceSaved;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Events\TenantCreated;
use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Listeners\UpdateSyncedResource;
use Stancl\Tenancy\Tests\Etc\Tenant;
beforeEach(function () {
config([
'tenancy.bootstrappers' => [
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',
];
}
}