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

Merge branch 'master' of github.com:archtechx/tenancy into cache-prefix

This commit is contained in:
lukinovec 2023-02-02 10:51:09 +01:00
commit 93aaba3e11
22 changed files with 709 additions and 49 deletions

View file

@ -50,6 +50,8 @@ test('context is switched when tenancy is reinitialized', function () {
});
test('central helper runs callbacks in the central state', function () {
withTenantDatabases();
tenancy()->initialize($tenant = Tenant::create());
tenancy()->central(function () {
@ -60,6 +62,8 @@ test('central helper runs callbacks in the central state', function () {
});
test('central helper returns the value from the callback', function () {
withTenantDatabases();
tenancy()->initialize(Tenant::create());
pest()->assertSame('foo', tenancy()->central(function () {
@ -68,6 +72,8 @@ test('central helper returns the value from the callback', function () {
});
test('central helper reverts back to tenant context', function () {
withTenantDatabases();
tenancy()->initialize($tenant = Tenant::create());
tenancy()->central(function () {

View file

@ -23,6 +23,8 @@ beforeEach(function () {
});
test('batch repository is set to tenant connection and reverted', function () {
withTenantDatabases();
$tenant = Tenant::create();
$tenant2 = Tenant::create();

View file

@ -18,11 +18,13 @@ use Stancl\Tenancy\Events\TenantCreated;
use Stancl\Tenancy\Events\TenantDeleted;
use Stancl\Tenancy\Tests\Etc\TestSeeder;
use Stancl\Tenancy\Events\DeletingTenant;
use Stancl\Tenancy\Events\DatabaseMigrated;
use Stancl\Tenancy\Tests\Etc\ExampleSeeder;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException;
beforeEach(function () {
if (file_exists($schemaPath = 'tests/Etc/tenant-schema-test.dump')) {
@ -109,6 +111,46 @@ test('migrate command loads schema state', function () {
expect(Schema::hasTable('users'))->toBeTrue();
});
test('migrate command only throws exceptions if skip-failing is not passed', function() {
Tenant::create();
$tenantWithoutDatabase = Tenant::create();
$databaseToDrop = $tenantWithoutDatabase->run(fn() => DB::connection()->getDatabaseName());
DB::statement("DROP DATABASE `$databaseToDrop`");
Tenant::create();
expect(fn() => pest()->artisan('tenants:migrate --schema-path="tests/Etc/tenant-schema.dump"'))->toThrow(TenantDatabaseDoesNotExistException::class);
expect(fn() => pest()->artisan('tenants:migrate --schema-path="tests/Etc/tenant-schema.dump" --skip-failing'))->not()->toThrow(TenantDatabaseDoesNotExistException::class);
});
test('migrate command does not stop after the first failure if skip-failing is passed', function() {
$tenants = collect([
Tenant::create(),
$tenantWithoutDatabase = Tenant::create(),
Tenant::create(),
]);
$migratedTenants = 0;
Event::listen(DatabaseMigrated::class, function() use (&$migratedTenants) {
$migratedTenants++;
});
$databaseToDrop = $tenantWithoutDatabase->run(fn() => DB::connection()->getDatabaseName());
DB::statement("DROP DATABASE `$databaseToDrop`");
Artisan::call('tenants:migrate', [
'--schema-path' => '"tests/Etc/tenant-schema.dump"',
'--skip-failing' => true,
'--tenants' => $tenants->pluck('id')->toArray(),
]);
expect($migratedTenants)->toBe(2);
});
test('dump command works', function () {
$tenant = Tenant::create();
$schemaPath = 'tests/Etc/tenant-schema-test.dump';

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

@ -27,6 +27,8 @@ function assertMailerTransportUsesPassword(string|null $password) {
};
test('mailer transport uses the correct credentials', function() {
withTenantDatabases();
config(['mail.default' => 'smtp', 'mail.mailers.smtp.password' => $defaultPassword = 'DEFAULT']);
MailTenancyBootstrapper::$credentialsMap = ['mail.mailers.smtp.password' => 'smtp_password'];
@ -52,6 +54,8 @@ test('mailer transport uses the correct credentials', function() {
test('initializing and ending tenancy binds a fresh MailManager instance without cached mailers', function() {
withTenantDatabases();
$mailers = fn() => invade(app(MailManager::class))->mailers;
app(MailManager::class)->mailer('smtp');

View file

@ -1,6 +1,10 @@
<?php
use Stancl\Tenancy\Tests\TestCase;
use Stancl\JobPipeline\JobPipeline;
use Illuminate\Support\Facades\Event;
use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Events\TenantCreated;
uses(TestCase::class)->in(__DIR__);
@ -8,3 +12,10 @@ function pest(): TestCase
{
return Pest\TestSuite::getInstance()->test;
}
function withTenantDatabases()
{
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
}

View file

@ -3,23 +3,23 @@
declare(strict_types=1);
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Spatie\Valuestore\Valuestore;
use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Tests\Etc\User;
use Stancl\JobPipeline\JobPipeline;
use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Support\Facades\Event;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Schema;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Jobs\CreateDatabase;
use Illuminate\Queue\InteractsWithQueue;
use Stancl\Tenancy\Events\TenantCreated;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Queue\Events\JobProcessed;
use Illuminate\Queue\Events\JobProcessing;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
@ -48,6 +48,8 @@ afterEach(function () {
});
test('tenant id is passed to tenant queues', function () {
withTenantDatabases();
config(['queue.default' => 'sync']);
$tenant = Tenant::create();
@ -64,6 +66,8 @@ test('tenant id is passed to tenant queues', function () {
});
test('tenant id is not passed to central queues', function () {
withTenantDatabases();
$tenant = Tenant::create();
tenancy()->initialize($tenant);
@ -156,6 +160,8 @@ test('tenancy is initialized when retrying jobs', function (bool $shouldEndTenan
})->with([true, false]);
test('the tenant used by the job doesnt change when the current tenant changes', function () {
withTenantDatabases();
$tenant1 = Tenant::create([
'id' => 'acme',
]);
@ -217,13 +223,6 @@ function withUsers()
});
}
function withTenantDatabases()
{
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
}
class TestJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

View file

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

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',
];
}
}

View file

@ -390,6 +390,81 @@ test('path used by sqlite manager can be customized', function () {
expect(file_exists($customPath . '/' . $name))->toBeTrue();
});
test('the tenant connection template can be specified either by name or as a connection array', function () {
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
config([
'tenancy.database.managers.mysql' => MySQLDatabaseManager::class,
'tenancy.database.template_tenant_connection' => 'mysql',
]);
$name = 'foo' . Str::random(8);
$tenant = Tenant::create([
'tenancy_db_name' => $name,
]);
/** @var MySQLDatabaseManager $manager */
$manager = $tenant->database()->manager();
expect($manager->databaseExists($name))->toBeTrue();
expect($manager->database()->getConfig('host'))->toBe('mysql');
config([
'tenancy.database.template_tenant_connection' => [
'driver' => 'mysql',
'url' => null,
'host' => 'mysql2',
'port' => '3306',
'database' => 'main',
'username' => 'root',
'password' => 'password',
'unix_socket' => '',
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => [],
],
]);
$tenant = Tenant::create([
'tenancy_db_name' => $name,
]);
/** @var MySQLDatabaseManager $manager */
$manager = $tenant->database()->manager();
expect($manager->databaseExists($name))->toBeTrue(); // tenant connection works
expect($manager->database()->getConfig('host'))->toBe('mysql2');
});
test('partial tenant connection templates get merged into the central connection template', function () {
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
config([
'database.connections.central.url' => 'example.com',
'tenancy.database.template_tenant_connection' => [
'url' => null,
'host' => 'mysql2',
],
]);
$name = 'foo' . Str::random(8);
$tenant = Tenant::create([
'tenancy_db_name' => $name,
]);
/** @var MySQLDatabaseManager $manager */
$manager = $tenant->database()->manager();
expect($manager->databaseExists($name))->toBeTrue(); // tenant connection works
expect($manager->database()->getConfig('host'))->toBe('mysql2');
expect($manager->database()->getConfig('url'))->toBeNull();
});
// Datasets
dataset('database_managers', [
['mysql', MySQLDatabaseManager::class],