From daae67c0f732e37571553f3283ccaf824f68a468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 11 May 2020 07:32:20 +0200 Subject: [PATCH] Synced resources - proof of concept --- src/Contracts/SyncMaster.php | 14 ++ src/Contracts/Syncable.php | 12 + .../Models/Concerns/ResourceSyncing.php | 16 ++ src/Events/Listeners/UpdateSyncedResource.php | 54 +++++ src/Events/SyncedResourceSaved.php | 22 ++ ...05_11_000002_create_tenant_users_table.php | 32 +++ .../2020_05_11_000001_create_users_table.php | 33 +++ tests/v3/ResourceSyncingTest.php | 210 ++++++++++++++++++ 8 files changed, 393 insertions(+) create mode 100644 src/Contracts/SyncMaster.php create mode 100644 src/Contracts/Syncable.php create mode 100644 src/Database/Models/Concerns/ResourceSyncing.php create mode 100644 src/Events/Listeners/UpdateSyncedResource.php create mode 100644 src/Events/SyncedResourceSaved.php create mode 100644 tests/Etc/synced_resource_migrations/2020_05_11_000002_create_tenant_users_table.php create mode 100644 tests/Etc/synced_resource_migrations/users/2020_05_11_000001_create_users_table.php create mode 100644 tests/v3/ResourceSyncingTest.php diff --git a/src/Contracts/SyncMaster.php b/src/Contracts/SyncMaster.php new file mode 100644 index 00000000..e9cf30c3 --- /dev/null +++ b/src/Contracts/SyncMaster.php @@ -0,0 +1,14 @@ +model->only($event->model->getSyncedAttributeNames()); + + // We update the central record only if the event comes from tenant context. + if ($event->tenant) { + /** @var Model|SyncMaster $centralModel */ + $centralModel = $event->model->getCentralModelName() + ::where($event->model->getGlobalIdentifierKeyName(), $event->model->getGlobalIdentifierKey()) + ->first(); + + // We disable events for this call, to avoid triggering this event & listener again. + $centralModel->withoutEvents(function () use ($centralModel, $syncedAttributes) { + $centralModel->update($syncedAttributes); + }); + + $tenants = $centralModel->tenants->except($event->tenant->getTenantKey()); + } else { + $centralModel = $event->model; + $tenants = $centralModel->tenants; + } + + foreach ($tenants as $tenant) { + // todo: performance optimization - $tenant->run() does tenancy()->end() after each call. + // we dont want that when we want to initialize for the next tenant afterwards rather than for the previous tenant + // so we should write a method like run() for running things on multiple tenants efficiently + + $tenant->run(function () use ($event, $syncedAttributes) { + // Forget instance state and find the model, + // again in the current tenant's context. + + /** @var Tenant|Model $model */ + $localModel = $event->model::find($event->model->getKey()); + + // Also: We're syncing attributes, not columns, which is + // why we're using Eloquent instead of direct DB queries. + + // We disable events for this call, to avoid triggering this event & listener again. + $localModel->update($syncedAttributes); + }); + } + } +} diff --git a/src/Events/SyncedResourceSaved.php b/src/Events/SyncedResourceSaved.php new file mode 100644 index 00000000..d80bff2e --- /dev/null +++ b/src/Events/SyncedResourceSaved.php @@ -0,0 +1,22 @@ +model = $model; + $this->tenant = $tenant; + } +} diff --git a/tests/Etc/synced_resource_migrations/2020_05_11_000002_create_tenant_users_table.php b/tests/Etc/synced_resource_migrations/2020_05_11_000002_create_tenant_users_table.php new file mode 100644 index 00000000..b7ad55e1 --- /dev/null +++ b/tests/Etc/synced_resource_migrations/2020_05_11_000002_create_tenant_users_table.php @@ -0,0 +1,32 @@ +increments('id'); + $table->string('tenant_id'); + $table->string('global_user_id'); + + $table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade'); + $table->foreign('global_user_id')->references('global_id')->on('users')->onUpdate('cascade')->onDelete('cascade'); + }); + } + + public function down() + { + Schema::dropIfExists('tenant_users'); + } +} diff --git a/tests/Etc/synced_resource_migrations/users/2020_05_11_000001_create_users_table.php b/tests/Etc/synced_resource_migrations/users/2020_05_11_000001_create_users_table.php new file mode 100644 index 00000000..38234e41 --- /dev/null +++ b/tests/Etc/synced_resource_migrations/users/2020_05_11_000001_create_users_table.php @@ -0,0 +1,33 @@ +increments('id'); + $table->string('global_id')->unique(); + $table->string('name'); + $table->string('email'); + $table->string('password'); + + $table->string('role'); + }); + } + + public function down() + { + Schema::dropIfExists('users'); + } +} diff --git a/tests/v3/ResourceSyncingTest.php b/tests/v3/ResourceSyncingTest.php new file mode 100644 index 00000000..07846870 --- /dev/null +++ b/tests/v3/ResourceSyncingTest.php @@ -0,0 +1,210 @@ + [ + DatabaseTenancyBootstrapper::class + ]]); + + Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + + Event::listen(TenancyInitialized::class, BootstrapTenancy::class); + Event::listen(TenancyEnded::class, RevertToCentralContext::class); + } + + /** @test */ + public function an_event_is_triggered_when_a_synced_resource_is_changed() + { + $this->loadLaravelMigrations(); + + Event::fake([SyncedResourceSaved::class]); + + $user = User::create([ + 'name' => 'Foo', + 'email' => 'foo@email.com', + 'password' => 'secret', + ]); + + Event::assertDispatched(SyncedResourceSaved::class, function (SyncedResourceSaved $event) use ($user) { + return $event->model === $user; + }); + } + + /** @test */ + public function only_the_synced_columns_are_updated_in_the_central_db() + { + Event::listen(SyncedResourceSaved::class, UpdateSyncedResource::class); + + $this->artisan('migrate', [ + '--path' => [ + __DIR__ . '/../Etc/synced_resource_migrations', + __DIR__ . '/../Etc/synced_resource_migrations/users' + ], + '--realpath' => true, + ])->assertExitCode(0); + + // Create user in central DB + $user = CentralUser::create([ + 'global_id' => 'acme', + 'name' => 'John Doe', + 'email' => 'john@localhost', + 'password' => 'secret', + 'role' => 'superadmin', // unsynced + ]); + + $tenant = Tenant::create(); + $this->artisan('tenants:migrate', [ + '--path' => __DIR__ . '/../Etc/synced_resource_migrations/users', + '--realpath' => true, + ])->assertExitCode(0); + + tenancy()->initialize($tenant); + + // Create the same user in tenant DB + $user = User::create([ + 'global_id' => 'acme', + 'name' => 'John Doe', + 'email' => 'john@localhost', + 'password' => 'secret', + 'role' => 'commenter', // unsynced + ]); + + // Update user in tenant DB + $user->update([ + 'role' => 'admin', // unsynced + 'name' => 'John Foo', // synceed + 'email' => 'john@foreignhost', // synceed + ]); + + // Assert new values + $this->assertEquals([ + 'id' => 1, + 'global_id' => 'acme', + 'name' => 'John Foo', + 'email' => 'john@foreignhost', + 'password' => 'secret', + 'role' => 'admin', + ], $user->getAttributes()); + + tenancy()->end(); + + // Assert changes bubbled up + $this->assertEquals([ + 'id' => 1, + 'global_id' => 'acme', + 'name' => 'John Foo', // synced + 'email' => 'john@foreignhost', // synced + 'password' => 'secret', // no changes + 'role' => 'superadmin', // unsynced + ], User::first()->getAttributes()); + } + + /** @test */ + public function the_synced_columns_are_updated_in_other_tenant_dbs_where_the_resource_exists() + { + // todo + } + + /** @test */ + public function global_id_is_generated_using_id_generatr_when_its_not_supplied() + { + // todo + } +} + +class CentralUser extends Model implements SyncMaster +{ + use ResourceSyncing, CentralConnection; + + protected $guarded = []; + public $timestamps = false; + public $table = 'users'; + + public function tenants() + { + return $this->belongsToMany(Tenant::class, 'tenant_users', 'global_user_id', 'global_user_id'); + } + + public function getGlobalIdentifierKey(): string + { + return $this->getAttribute($this->getGlobalIdentifierKeyName()); + } + + public function getGlobalIdentifierKeyName(): string + { + return 'global_id'; + } + + public function getCentralModelName(): string + { + return static::class; + } + + public function getSyncedAttributeNames(): array + { + return [ + 'name', + 'password', + 'email', + ]; + } +} + +class User extends Model implements Syncable +{ + use ResourceSyncing; + + protected $guarded = []; + public $timestamps = false; + + public function getGlobalIdentifierKey(): string + { + return $this->getAttribute($this->getGlobalIdentifierKeyName()); + } + + public function getGlobalIdentifierKeyName(): string + { + return 'global_id'; + } + + public function getCentralModelName(): string + { + return CentralUser::class; + } + + public function getSyncedAttributeNames(): array + { + return [ + 'name', + 'password', + 'email', + ]; + } +} \ No newline at end of file