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

Use polymorphic table for mapping resources to tenants (#997)

* wip

* Fix code style (php-cs-fixer)

* adjust tests

* Update ResourceSyncingPolymorphicTest.php

* Update SyncMaster.php

* correct method name

* Update ResourceSyncingPolymorphicTest.php

* use BelongsToMany return type

* separate pivot model for each approach

* ability to publish migrations

* remove unsed import

* use resource migrations from asset

* anonymous migration for `tenant_resources` table

* rename file

* rename classes

* trait

* add back using statement

* revert to unset change

* use unset approach

* use unset approach

* Assert `tenants` are accessible

* Update ResourceSyncingUsingPolymorphicTest.php

* improve `tenants` assertions

* improve assertions

* remove `getResourceTenantModelName` method and use config

* use `BelongsToMany` for `tenants` method return type

* Fix code style (php-cs-fixer)

* revert type

* use correct key

* test right resources are accessible from the tenant

* Update tests/ResourceSyncingUsingPolymorphicTest.php

---------

Co-authored-by: PHP CS Fixer <phpcsfixer@example.com>
Co-authored-by: Samuel Štancl <samuel@archte.ch>
This commit is contained in:
Abrar Ahmad 2023-02-02 10:39:35 +05:00 committed by GitHub
parent 087733d5db
commit 758fbc8a75
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 502 additions and 12 deletions

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('tenant_resources', function (Blueprint $table) {
$table->increments('id');
$table->string('tenant_id');
$table->string('resource_global_id');
$table->string('tenant_resources_type');
});
}
public function down(): void
{
Schema::dropIfExists('tenant_resources');
}
};

View file

@ -4,8 +4,10 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns; namespace Stancl\Tenancy\Database\Concerns;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Stancl\Tenancy\Contracts\Syncable; use Stancl\Tenancy\Contracts\Syncable;
use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator; use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
use Stancl\Tenancy\Database\Models\TenantMorphPivot;
use Stancl\Tenancy\Events\SyncedResourceSaved; use Stancl\Tenancy\Events\SyncedResourceSaved;
trait ResourceSyncing trait ResourceSyncing
@ -43,4 +45,10 @@ trait ResourceSyncing
{ {
return true; 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

@ -0,0 +1,21 @@
<?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

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

View file

@ -5,18 +5,9 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\Models; namespace Stancl\Tenancy\Database\Models;
use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Database\Eloquent\Relations\Pivot;
use Stancl\Tenancy\Contracts\Syncable; use Stancl\Tenancy\Database\Concerns\TriggerSyncEvent;
class TenantPivot extends Pivot class TenantPivot extends Pivot
{ {
public static function booted(): void use TriggerSyncEvent;
{
static::saved(function (self $pivot) {
$parent = $pivot->pivotParent;
if ($parent instanceof Syncable && $parent->shouldSync()) {
$parent->triggerSyncEvent();
}
});
}
} }

View file

@ -106,6 +106,10 @@ class TenancyServiceProvider extends ServiceProvider
__DIR__ . '/../assets/impersonation-migrations/' => database_path('migrations'), __DIR__ . '/../assets/impersonation-migrations/' => database_path('migrations'),
], 'impersonation-migrations'); ], 'impersonation-migrations');
$this->publishes([
__DIR__ . '/../assets/resource-syncing-migrations/' => database_path('migrations'),
], 'resource-syncing-migrations');
$this->publishes([ $this->publishes([
__DIR__ . '/../assets/tenant_routes.stub.php' => base_path('routes/tenant.php'), __DIR__ . '/../assets/tenant_routes.stub.php' => base_path('routes/tenant.php'),
], 'routes'); ], 'routes');

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class TestCreateCompaniesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('companies', function (Blueprint $table) {
$table->increments('id');
$table->string('global_id')->unique();
$table->string('name');
$table->string('email');
});
}
public function down()
{
Schema::dropIfExists('companies');
}
}

View file

@ -832,7 +832,7 @@ function migrateUsersTableForTenants(): void
// Tenant model used for resource syncing setup // Tenant model used for resource syncing setup
class ResourceTenant extends Tenant class ResourceTenant extends Tenant
{ {
public function users() public function users(): BelongsToMany
{ {
return $this->belongsToMany(CentralUser::class, 'tenant_users', 'tenant_id', 'global_user_id', 'id', 'global_id') return $this->belongsToMany(CentralUser::class, 'tenant_users', 'tenant_id', 'global_user_id', 'id', 'global_id')
->using(TenantPivot::class); ->using(TenantPivot::class);

View file

@ -0,0 +1,398 @@
<?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);
UpdateSyncedResource::$shouldQueue = false; // Global state cleanup
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',
];
}
}