diff --git a/assets/resource-syncing-migrations/2020_05_11_000002_create_tenant_resources_table.php b/assets/resource-syncing-migrations/2020_05_11_000002_create_tenant_resources_table.php new file mode 100644 index 00000000..3e8ef18f --- /dev/null +++ b/assets/resource-syncing-migrations/2020_05_11_000002_create_tenant_resources_table.php @@ -0,0 +1,25 @@ +increments('id'); + $table->string('tenant_id'); + $table->string('resource_global_id'); + $table->string('tenant_resources_type'); + }); + } + + public function down(): void + { + Schema::dropIfExists('tenant_resources'); + } +}; diff --git a/src/Database/Concerns/ResourceSyncing.php b/src/Database/Concerns/ResourceSyncing.php index ea9f83b4..9caacda5 100644 --- a/src/Database/Concerns/ResourceSyncing.php +++ b/src/Database/Concerns/ResourceSyncing.php @@ -4,8 +4,10 @@ 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 @@ -43,4 +45,10 @@ trait ResourceSyncing { return true; } + + public function tenants(): MorphToMany + { + return $this->morphToMany(config('tenancy.models.tenant'), 'tenant_resources', 'tenant_resources', 'resource_global_id', 'tenant_id', 'global_id') + ->using(TenantMorphPivot::class); + } } diff --git a/src/Database/Concerns/TriggerSyncEvent.php b/src/Database/Concerns/TriggerSyncEvent.php new file mode 100644 index 00000000..13207762 --- /dev/null +++ b/src/Database/Concerns/TriggerSyncEvent.php @@ -0,0 +1,21 @@ +pivotParent; + + if ($parent instanceof Syncable && $parent->shouldSync()) { + $parent->triggerSyncEvent(); + } + }); + } +} diff --git a/src/Database/Models/TenantMorphPivot.php b/src/Database/Models/TenantMorphPivot.php new file mode 100644 index 00000000..b10d9d32 --- /dev/null +++ b/src/Database/Models/TenantMorphPivot.php @@ -0,0 +1,13 @@ +pivotParent; - - if ($parent instanceof Syncable && $parent->shouldSync()) { - $parent->triggerSyncEvent(); - } - }); - } + use TriggerSyncEvent; } diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index ee34ef1e..23fb6473 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -106,6 +106,10 @@ class TenancyServiceProvider extends ServiceProvider __DIR__ . '/../assets/impersonation-migrations/' => database_path('migrations'), ], 'impersonation-migrations'); + $this->publishes([ + __DIR__ . '/../assets/resource-syncing-migrations/' => database_path('migrations'), + ], 'resource-syncing-migrations'); + $this->publishes([ __DIR__ . '/../assets/tenant_routes.stub.php' => base_path('routes/tenant.php'), ], 'routes'); diff --git a/tests/Etc/synced_resource_migrations/companies/2020_05_11_000001_test_create_companies_table.php b/tests/Etc/synced_resource_migrations/companies/2020_05_11_000001_test_create_companies_table.php new file mode 100644 index 00000000..2d61a45d --- /dev/null +++ b/tests/Etc/synced_resource_migrations/companies/2020_05_11_000001_test_create_companies_table.php @@ -0,0 +1,30 @@ +increments('id'); + $table->string('global_id')->unique(); + $table->string('name'); + $table->string('email'); + }); + } + + public function down() + { + Schema::dropIfExists('companies'); + } +} diff --git a/tests/ResourceSyncingTest.php b/tests/ResourceSyncingTest.php index 811b8d1a..a988178e 100644 --- a/tests/ResourceSyncingTest.php +++ b/tests/ResourceSyncingTest.php @@ -832,7 +832,7 @@ function migrateUsersTableForTenants(): void // Tenant model used for resource syncing setup 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') ->using(TenantPivot::class); diff --git a/tests/ResourceSyncingUsingPolymorphicTest.php b/tests/ResourceSyncingUsingPolymorphicTest.php new file mode 100644 index 00000000..408fd4ef --- /dev/null +++ b/tests/ResourceSyncingUsingPolymorphicTest.php @@ -0,0 +1,398 @@ + [ + 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', + ]; + } +} +