1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2025-12-12 14:34:04 +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

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