diff --git a/.gitignore b/.gitignore index 64d9dc21..5a5960b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .env +.DS_Store composer.lock vendor/ .vscode/ diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 7c52e295..a38aee42 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -61,6 +61,12 @@ class TenancyServiceProvider extends ServiceProvider Events\TenantMaintenanceModeEnabled::class => [], Events\TenantMaintenanceModeDisabled::class => [], + // Pending tenant events + Events\CreatingPendingTenant::class => [], + Events\PendingTenantCreated::class => [], + Events\PullingPendingTenant::class => [], + Events\PendingTenantPulled::class => [], + // Domain events Events\CreatingDomain::class => [], Events\DomainCreated::class => [], diff --git a/assets/config.php b/assets/config.php index 0e035953..20826d7d 100644 --- a/assets/config.php +++ b/assets/config.php @@ -86,6 +86,25 @@ return [ // Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed ], + + /** + * Pending tenants config. + * This is useful if you're looking for a way to always have a tenant ready to be used. + */ + 'pending' => [ + /** + * If disabled, pending tenants will be excluded from all tenant queries. + * You can still use ::withPending(), ::withoutPending() and ::onlyPending() to include or exclude the pending tenants regardless of this setting. + * Note: when disabled, this will also ignore pending tenants when running the tenant commands (migration, seed, etc.) + */ + 'include_in_queries' => true, + /** + * Defines how many pending tenants you want to have ready in the pending tenant pool. + * This depends on the volume of tenants you're creating. + */ + 'count' => env('TENANCY_PENDING_COUNT', 5), + ], + /** * Database tenancy config. Used by DatabaseTenancyBootstrapper. */ @@ -98,6 +117,11 @@ return [ */ 'template_tenant_connection' => null, + /** + * The name of the temporary connection used for creating and deleting tenant databases. + */ + 'tenant_host_connection_name' => 'tenant_host_connection', + /** * Tenant database names are created like this: * prefix + tenant_id + suffix. diff --git a/composer.json b/composer.json index bbca1e14..587bbb06 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "spatie/ignition": "^1.4", "ramsey/uuid": "^4.0", "stancl/jobpipeline": "^1.0", - "stancl/virtualcolumn": "^1.0" + "stancl/virtualcolumn": "^1.3" }, "require-dev": { "laravel/framework": "^9.0", diff --git a/src/Commands/ClearPendingTenants.php b/src/Commands/ClearPendingTenants.php new file mode 100644 index 00000000..18d9fa42 --- /dev/null +++ b/src/Commands/ClearPendingTenants.php @@ -0,0 +1,74 @@ +info('Removing pending tenants.'); + + $expirationDate = now(); + // We compare the original expiration date to the new one to check if the new one is different later + $originalExpirationDate = $expirationDate->copy()->toImmutable(); + + // Skip the time constraints if the 'all' option is given + if (! $this->option('all')) { + $olderThanDays = $this->option('older-than-days'); + $olderThanHours = $this->option('older-than-hours'); + + if ($olderThanDays && $olderThanHours) { + $this->line(" Cannot use '--older-than-days' and '--older-than-hours' together \n"); // todo@cli refactor all of these styled command outputs to use $this->components + $this->line('Please, choose only one of these options.'); + + return 1; // Exit code for failure + } + + if ($olderThanDays) { + $expirationDate->subDays($olderThanDays); + } + + if ($olderThanHours) { + $expirationDate->subHours($olderThanHours); + } + } + + $deletedTenantCount = tenancy() + ->query() + ->onlyPending() + ->when($originalExpirationDate->notEqualTo($expirationDate), function (Builder $query) use ($expirationDate) { + $query->where($query->getModel()->getColumnForQuery('pending_since'), '<', $expirationDate->timestamp); + }) + ->get() + ->each // Trigger the model events by deleting the tenants one by one + ->delete() + ->count(); + + $this->info($deletedTenantCount . ' pending ' . str('tenant')->plural($deletedTenantCount) . ' deleted.'); + } +} diff --git a/src/Commands/CreatePendingTenants.php b/src/Commands/CreatePendingTenants.php new file mode 100644 index 00000000..88202093 --- /dev/null +++ b/src/Commands/CreatePendingTenants.php @@ -0,0 +1,62 @@ +info('Creating pending tenants.'); + + $maxPendingTenantCount = (int) ($this->option('count') ?? config('tenancy.pending.count')); + $pendingTenantCount = $this->getPendingTenantCount(); + $createdCount = 0; + + while ($pendingTenantCount < $maxPendingTenantCount) { + tenancy()->model()::createPending(); + + // Fetching the pending tenant count in each iteration prevents creating too many tenants + // If pending tenants are being created somewhere else while running this command + $pendingTenantCount = $this->getPendingTenantCount(); + + $createdCount++; + } + + $this->info($createdCount . ' ' . str('tenant')->plural($createdCount) . ' created.'); + $this->info($maxPendingTenantCount . ' ' . str('tenant')->plural($maxPendingTenantCount) . ' ready to be used.'); + + return 1; + } + + /** + * Calculate the number of currently available pending tenants. + */ + private function getPendingTenantCount(): int + { + return tenancy() + ->query() + ->onlyPending() + ->count(); + } +} diff --git a/src/Commands/Down.php b/src/Commands/Down.php index e7341d7f..3b68bcb2 100644 --- a/src/Commands/Down.php +++ b/src/Commands/Down.php @@ -5,11 +5,11 @@ declare(strict_types=1); namespace Stancl\Tenancy\Commands; use Illuminate\Foundation\Console\DownCommand; -use Stancl\Tenancy\Concerns\HasATenantsOption; +use Stancl\Tenancy\Concerns\HasTenantOptions; class Down extends DownCommand { - use HasATenantsOption; + use HasTenantOptions; protected $signature = 'tenants:down {--redirect= : The path that users should be redirected to} diff --git a/src/Commands/Link.php b/src/Commands/Link.php index 0a587122..a6dd6c5f 100644 --- a/src/Commands/Link.php +++ b/src/Commands/Link.php @@ -9,11 +9,11 @@ use Illuminate\Console\Command; use Illuminate\Support\LazyCollection; use Stancl\Tenancy\Actions\CreateStorageSymlinksAction; use Stancl\Tenancy\Actions\RemoveStorageSymlinksAction; -use Stancl\Tenancy\Concerns\HasATenantsOption; +use Stancl\Tenancy\Concerns\HasTenantOptions; class Link extends Command { - use HasATenantsOption; + use HasTenantOptions; protected $signature = 'tenants:link {--tenants=* : The tenant(s) to run the command for. Default: all} diff --git a/src/Commands/Migrate.php b/src/Commands/Migrate.php index 82395fcc..0d2fceaa 100644 --- a/src/Commands/Migrate.php +++ b/src/Commands/Migrate.php @@ -7,14 +7,15 @@ namespace Stancl\Tenancy\Commands; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\Console\Migrations\MigrateCommand; use Illuminate\Database\Migrations\Migrator; +use Stancl\Tenancy\Concerns\DealsWithMigrations; use Stancl\Tenancy\Concerns\ExtendsLaravelCommand; -use Stancl\Tenancy\Concerns\HasATenantsOption; +use Stancl\Tenancy\Concerns\HasTenantOptions; use Stancl\Tenancy\Events\DatabaseMigrated; use Stancl\Tenancy\Events\MigratingDatabase; class Migrate extends MigrateCommand { - use HasATenantsOption, ExtendsLaravelCommand; + use HasTenantOptions, DealsWithMigrations, ExtendsLaravelCommand; protected $description = 'Run migrations for tenant(s)'; diff --git a/src/Commands/MigrateFresh.php b/src/Commands/MigrateFresh.php index 657c4990..45a93115 100644 --- a/src/Commands/MigrateFresh.php +++ b/src/Commands/MigrateFresh.php @@ -5,12 +5,13 @@ declare(strict_types=1); namespace Stancl\Tenancy\Commands; use Illuminate\Console\Command; -use Stancl\Tenancy\Concerns\HasATenantsOption; +use Stancl\Tenancy\Concerns\DealsWithMigrations; +use Stancl\Tenancy\Concerns\HasTenantOptions; use Symfony\Component\Console\Input\InputOption; class MigrateFresh extends Command { - use HasATenantsOption; + use HasTenantOptions, DealsWithMigrations; protected $description = 'Drop all tables and re-run all migrations for tenant(s)'; diff --git a/src/Commands/Rollback.php b/src/Commands/Rollback.php index 1e84ab12..f9d9dac0 100644 --- a/src/Commands/Rollback.php +++ b/src/Commands/Rollback.php @@ -6,14 +6,15 @@ namespace Stancl\Tenancy\Commands; use Illuminate\Database\Console\Migrations\RollbackCommand; use Illuminate\Database\Migrations\Migrator; +use Stancl\Tenancy\Concerns\DealsWithMigrations; use Stancl\Tenancy\Concerns\ExtendsLaravelCommand; -use Stancl\Tenancy\Concerns\HasATenantsOption; +use Stancl\Tenancy\Concerns\HasTenantOptions; use Stancl\Tenancy\Events\DatabaseRolledBack; use Stancl\Tenancy\Events\RollingBackDatabase; class Rollback extends RollbackCommand { - use HasATenantsOption, ExtendsLaravelCommand; + use HasTenantOptions, DealsWithMigrations, ExtendsLaravelCommand; protected $description = 'Rollback migrations for tenant(s).'; diff --git a/src/Commands/Run.php b/src/Commands/Run.php index 5ecc7c77..afc9871a 100644 --- a/src/Commands/Run.php +++ b/src/Commands/Run.php @@ -6,13 +6,13 @@ namespace Stancl\Tenancy\Commands; use Illuminate\Console\Command; use Illuminate\Contracts\Console\Kernel; -use Stancl\Tenancy\Concerns\HasATenantsOption; +use Stancl\Tenancy\Concerns\HasTenantOptions; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Output\ConsoleOutput; class Run extends Command { - use HasATenantsOption; + use HasTenantOptions; protected $description = 'Run a command for tenant(s)'; diff --git a/src/Commands/Seed.php b/src/Commands/Seed.php index 8ed0b6d9..5cf468e9 100644 --- a/src/Commands/Seed.php +++ b/src/Commands/Seed.php @@ -6,13 +6,13 @@ namespace Stancl\Tenancy\Commands; use Illuminate\Database\ConnectionResolverInterface; use Illuminate\Database\Console\Seeds\SeedCommand; -use Stancl\Tenancy\Concerns\HasATenantsOption; +use Stancl\Tenancy\Concerns\HasTenantOptions; use Stancl\Tenancy\Events\DatabaseSeeded; use Stancl\Tenancy\Events\SeedingDatabase; class Seed extends SeedCommand { - use HasATenantsOption; + use HasTenantOptions; protected $description = 'Seed tenant database(s).'; diff --git a/src/Commands/Up.php b/src/Commands/Up.php index 08c935c3..cf005251 100644 --- a/src/Commands/Up.php +++ b/src/Commands/Up.php @@ -5,11 +5,11 @@ declare(strict_types=1); namespace Stancl\Tenancy\Commands; use Illuminate\Console\Command; -use Stancl\Tenancy\Concerns\HasATenantsOption; +use Stancl\Tenancy\Concerns\HasTenantOptions; class Up extends Command { - use HasATenantsOption; + use HasTenantOptions; protected $signature = 'tenants:up'; diff --git a/src/Concerns/HasATenantsOption.php b/src/Concerns/HasTenantOptions.php similarity index 63% rename from src/Concerns/HasATenantsOption.php rename to src/Concerns/HasTenantOptions.php index 32d508ec..f8a763a7 100644 --- a/src/Concerns/HasATenantsOption.php +++ b/src/Concerns/HasTenantOptions.php @@ -5,14 +5,19 @@ declare(strict_types=1); namespace Stancl\Tenancy\Concerns; use Illuminate\Support\LazyCollection; +use Stancl\Tenancy\Database\Concerns\PendingScope; use Symfony\Component\Console\Input\InputOption; -trait HasATenantsOption +/** + * Adds 'tenants' and 'with-pending' options. + */ +trait HasTenantOptions { protected function getOptions() { return array_merge([ ['tenants', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_OPTIONAL, '', null], + ['with-pending', null, InputOption::VALUE_NONE, 'include pending tenants in query'], ], parent::getOptions()); } @@ -23,6 +28,9 @@ trait HasATenantsOption ->when($this->option('tenants'), function ($query) { $query->whereIn(tenancy()->model()->getTenantKeyName(), $this->option('tenants')); }) + ->when(tenancy()->model()::hasGlobalScope(PendingScope::class), function ($query) { + $query->withPending(config('tenancy.pending.include_in_queries') ?: $this->option('with-pending')); + }) ->cursor(); } diff --git a/src/Database/Concerns/HasPending.php b/src/Database/Concerns/HasPending.php new file mode 100644 index 00000000..3fa9399d --- /dev/null +++ b/src/Database/Concerns/HasPending.php @@ -0,0 +1,103 @@ +casts['pending_since'] = 'timestamp'; + } + + /** + * Determine if the model instance is in a pending state. + * + * @return bool + */ + public function pending() + { + return ! is_null($this->pending_since); + } + + /** Create a pending tenant. */ + public static function createPending($attributes = []): Tenant + { + $tenant = static::create($attributes); + + event(new CreatingPendingTenant($tenant)); + + // Update the pending_since value only after the tenant is created so it's + // Not marked as pending until finishing running the migrations, seeders, etc. + $tenant->update([ + 'pending_since' => now()->timestamp, + ]); + + event(new PendingTenantCreated($tenant)); + + return $tenant; + } + + /** Pull a pending tenant. */ + public static function pullPending(): Tenant + { + return static::pullPendingFromPool(true); + } + + /** Try to pull a tenant from the pool of pending tenants. */ + public static function pullPendingFromPool(bool $firstOrCreate = false): ?Tenant + { + if (! static::onlyPending()->exists()) { + if (! $firstOrCreate) { + return null; + } + + static::createPending(); + } + + // A pending tenant is surely available at this point + $tenant = static::onlyPending()->first(); + + event(new PullingPendingTenant($tenant)); + + $tenant->update([ + 'pending_since' => null, + ]); + + event(new PendingTenantPulled($tenant)); + + return $tenant; + } +} diff --git a/src/Database/Concerns/PendingScope.php b/src/Database/Concerns/PendingScope.php new file mode 100644 index 00000000..8a6ad913 --- /dev/null +++ b/src/Database/Concerns/PendingScope.php @@ -0,0 +1,88 @@ +when(! config('tenancy.pending.include_in_queries'), function (Builder $builder) { + $builder->whereNull($builder->getModel()->getColumnForQuery('pending_since')); + }); + } + + /** + * Extend the query builder with the needed functions. + * + * @return void + */ + public function extend(Builder $builder) + { + foreach ($this->extensions as $extension) { + $this->{"add{$extension}"}($builder); + } + } + /** + * Add the with-pending extension to the builder. + * + * @return void + */ + protected function addWithPending(Builder $builder) + { + $builder->macro('withPending', function (Builder $builder, $withPending = true) { + if (! $withPending) { + return $builder->withoutPending(); + } + + return $builder->withoutGlobalScope($this); + }); + } + + /** + * Add the without-pending extension to the builder. + * + * @return void + */ + protected function addWithoutPending(Builder $builder) + { + $builder->macro('withoutPending', function (Builder $builder) { + $builder->withoutGlobalScope($this) + ->whereNull($builder->getModel()->getColumnForQuery('pending_since')) + ->orWhereNull($builder->getModel()->getDataColumn()); + + return $builder; + }); + } + + /** + * Add the only-pending extension to the builder. + * + * @return void + */ + protected function addOnlyPending(Builder $builder) + { + $builder->macro('onlyPending', function (Builder $builder) { + $builder->withoutGlobalScope($this)->whereNotNull($builder->getModel()->getColumnForQuery('pending_since')); + + return $builder; + }); + } +} diff --git a/src/Database/DatabaseConfig.php b/src/Database/DatabaseConfig.php index 6c4df0d8..309d828f 100644 --- a/src/Database/DatabaseConfig.php +++ b/src/Database/DatabaseConfig.php @@ -5,10 +5,14 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database; use Closure; +use Illuminate\Database; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase as Tenant; +use Stancl\Tenancy\Database\Exceptions\DatabaseManagerNotRegisteredException; +use Stancl\Tenancy\Database\Exceptions\NoConnectionSetException; class DatabaseConfig { @@ -83,7 +87,7 @@ class DatabaseConfig { $this->tenant->setInternal('db_name', $this->getName()); - if ($this->manager() instanceof Contracts\ManagesDatabaseUsers) { + if ($this->connectionDriverManager($this->getTemplateConnectionName()) instanceof Contracts\ManagesDatabaseUsers) { $this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($this->tenant)); $this->tenant->setInternal('db_password', $this->getPassword() ?? (static::$passwordGenerator)($this->tenant)); } @@ -100,6 +104,11 @@ class DatabaseConfig ?? config('tenancy.database.central_connection'); } + public function getTenantHostConnectionName(): string + { + return config('tenancy.database.tenant_host_connection_name', 'tenant_host_connection'); + } + /** * Tenant's own database connection config. */ @@ -114,6 +123,40 @@ class DatabaseConfig ); } + /** + * Tenant's host database connection config. + */ + public function hostConnection(): array + { + $config = $this->tenantConfig(); + $template = $this->getTemplateConnectionName(); + $templateConnection = config("database.connections.{$template}"); + + if ($this->connectionDriverManager($template) instanceof Contracts\ManagesDatabaseUsers) { + // We're removing the username and password because user with these credentials is not created yet + // If you need to provide username and password when using PermissionControlledMySQLDatabaseManager, + // consider creating a new connection and use it as `tenancy_db_connection` tenant config key + unset($config['username'], $config['password']); + } + + if (! $config) { + return $templateConnection; + } + + return array_replace($templateConnection, $config); + } + + /** + * Purge host database connection. + * + * It's possible database has previous tenant connection. + * This will clean up the previous connection before creating it for the current tenant. + */ + public function purgeHostConnection(): void + { + DB::purge($this->getTenantHostConnectionName()); + } + /** * Additional config for the database connection, specific to this tenant. */ @@ -140,10 +183,37 @@ class DatabaseConfig }, []); } - /** Get the TenantDatabaseManager for this tenant's connection. */ + /** Get the TenantDatabaseManager for this tenant's connection. + * + * @throws NoConnectionSetException|DatabaseManagerNotRegisteredException + */ public function manager(): Contracts\TenantDatabaseManager { - $driver = config("database.connections.{$this->getTemplateConnectionName()}.driver"); + // Laravel caches the previous PDO connection, so we purge it to be able to change the connection details + $this->purgeHostConnection(); // todo come up with a better name + + // Create the tenant host connection config + $tenantHostConnectionName = $this->getTenantHostConnectionName(); + config(["database.connections.{$tenantHostConnectionName}" => $this->hostConnection()]); + + $manager = $this->connectionDriverManager($tenantHostConnectionName); + + if ($manager instanceof Contracts\StatefulTenantDatabaseManager) { + $manager->setConnection($tenantHostConnectionName); + } + + return $manager; + } + + /** + * todo come up with a better name + * Get database manager class from the given connection config's driver. + * + * @throws DatabaseManagerNotRegisteredException + */ + protected function connectionDriverManager(string $connectionName): Contracts\TenantDatabaseManager + { + $driver = config("database.connections.{$connectionName}.driver"); $databaseManagers = config('tenancy.database.managers'); @@ -151,13 +221,6 @@ class DatabaseConfig throw new Exceptions\DatabaseManagerNotRegisteredException($driver); } - /** @var Contracts\TenantDatabaseManager $databaseManager */ - $databaseManager = app($databaseManagers[$driver]); - - if ($databaseManager instanceof Contracts\StatefulTenantDatabaseManager) { - $databaseManager->setConnection($this->getTemplateConnectionName()); - } - - return $databaseManager; + return app($databaseManagers[$driver]); } } diff --git a/src/Database/Models/Tenant.php b/src/Database/Models/Tenant.php index 9cb5f5f3..37c2af2d 100644 --- a/src/Database/Models/Tenant.php +++ b/src/Database/Models/Tenant.php @@ -28,6 +28,7 @@ class Tenant extends Model implements Contracts\Tenant Concerns\GeneratesIds, Concerns\HasInternalKeys, Concerns\TenantRun, + Concerns\HasPending, Concerns\InitializationHelpers, Concerns\InvalidatesResolverCache; diff --git a/src/Events/CreatingPendingTenant.php b/src/Events/CreatingPendingTenant.php new file mode 100644 index 00000000..dfbe6c70 --- /dev/null +++ b/src/Events/CreatingPendingTenant.php @@ -0,0 +1,9 @@ +model()->cursor(); // todo1 phpstan thinks this isn't needed, but tests fail without it $originalTenant = $this->tenant; diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index 63a22a11..01770cda 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -78,7 +78,9 @@ class TenancyServiceProvider extends ServiceProvider public function boot(): void { $this->commands([ + Commands\Up::class, Commands\Run::class, + Commands\Down::class, Commands\Link::class, Commands\Seed::class, Commands\Install::class, @@ -87,8 +89,8 @@ class TenancyServiceProvider extends ServiceProvider Commands\TenantList::class, Commands\TenantDump::class, Commands\MigrateFresh::class, - Commands\Down::class, - Commands\Up::class, + Commands\ClearPendingTenants::class, + Commands\CreatePendingTenants::class, ]); $this->app->extend(FreshCommand::class, function () { diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index 9a9f0bc5..95672753 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -24,16 +24,15 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; - beforeEach(function () { + if (file_exists($schemaPath = database_path('schema/tenant-schema.dump'))) { + unlink($schemaPath); + } + Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { return $event->tenant; })->toListener()); - config(['tenancy.bootstrappers' => [ - DatabaseTenancyBootstrapper::class, - ]]); - config([ 'tenancy.bootstrappers' => [ DatabaseTenancyBootstrapper::class, @@ -131,7 +130,6 @@ test('tenant dump file gets created as tenant-schema.dump in the database schema Artisan::call('tenants:dump'); expect($schemaPath)->toBeFile(); - unlink($schemaPath); }); test('migrate command uses the correct schema path by default', function () { diff --git a/tests/Etc/Console/AddUserCommand.php b/tests/Etc/Console/AddUserCommand.php index f102bae6..9b421f95 100644 --- a/tests/Etc/Console/AddUserCommand.php +++ b/tests/Etc/Console/AddUserCommand.php @@ -6,13 +6,13 @@ namespace Stancl\Tenancy\Tests\Etc\Console; use Illuminate\Console\Command; use Illuminate\Support\Str; -use Stancl\Tenancy\Concerns\HasATenantsOption; +use Stancl\Tenancy\Concerns\HasTenantOptions; use Stancl\Tenancy\Concerns\TenantAwareCommand; use Stancl\Tenancy\Tests\Etc\User; class AddUserCommand extends Command { - use TenantAwareCommand, HasATenantsOption; + use TenantAwareCommand, HasTenantOptions; /** * The name and signature of the console command. diff --git a/tests/Etc/Tenant.php b/tests/Etc/Tenant.php index 9b59dedb..f9a11d95 100644 --- a/tests/Etc/Tenant.php +++ b/tests/Etc/Tenant.php @@ -7,6 +7,7 @@ namespace Stancl\Tenancy\Tests\Etc; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\Concerns\HasDatabase; use Stancl\Tenancy\Database\Concerns\HasDomains; +use Stancl\Tenancy\Database\Concerns\HasPending; use Stancl\Tenancy\Database\Concerns\MaintenanceMode; use Stancl\Tenancy\Database\Models; @@ -15,5 +16,5 @@ use Stancl\Tenancy\Database\Models; */ class Tenant extends Models\Tenant implements TenantWithDatabase { - use HasDatabase, HasDomains, MaintenanceMode; + use HasDatabase, HasDomains, HasPending, MaintenanceMode; } diff --git a/tests/PendingTenantsTest.php b/tests/PendingTenantsTest.php new file mode 100644 index 00000000..8dbda9ee --- /dev/null +++ b/tests/PendingTenantsTest.php @@ -0,0 +1,209 @@ +count())->toBe(1); + + Tenant::onlyPending()->first()->update([ + 'pending_since' => null + ]); + + expect(Tenant::onlyPending()->count())->toBe(0); +}); + +test('pending trait adds query scopes', function () { + Tenant::createPending(); + Tenant::create(); + Tenant::create(); + + expect(Tenant::onlyPending()->count())->toBe(1) + ->and(Tenant::withPending(true)->count())->toBe(3) + ->and(Tenant::withPending(false)->count())->toBe(2) + ->and(Tenant::withoutPending()->count())->toBe(2); + +}); + +test('pending tenants can be created and deleted using commands', function () { + config(['tenancy.pending.count' => 4]); + + Artisan::call(CreatePendingTenants::class); + + expect(Tenant::onlyPending()->count())->toBe(4); + + Artisan::call(ClearPendingTenants::class); + + expect(Tenant::onlyPending()->count())->toBe(0); +}); + +test('CreatePendingTenants command can have an older than constraint', function () { + config(['tenancy.pending.count' => 2]); + + Artisan::call(CreatePendingTenants::class); + + tenancy()->model()->query()->onlyPending()->first()->update([ + 'pending_since' => now()->subDays(5)->timestamp + ]); + + Artisan::call('tenants:pending-clear --older-than-days=2'); + + expect(Tenant::onlyPending()->count())->toBe(1); +}); + +test('CreatePendingTenants command cannot run with both time constraints', function () { + pest()->artisan('tenants:pending-clear --older-than-days=2 --older-than-hours=2') + ->assertFailed(); +}); + +test('CreatePendingTenants commands all option overrides any config constraints', function () { + Tenant::createPending(); + Tenant::createPending(); + + tenancy()->model()->query()->onlyPending()->first()->update([ + 'pending_since' => now()->subDays(10) + ]); + + config(['tenancy.pending.older_than_days' => 4]); + + Artisan::call(ClearPendingTenants::class, [ + '--all' => true + ]); + + expect(Tenant::onlyPending()->count())->toBe(0); +}); + +test('tenancy can check if there are any pending tenants', function () { + expect(Tenant::onlyPending()->exists())->toBeFalse(); + + Tenant::createPending(); + + expect(Tenant::onlyPending()->exists())->toBeTrue(); +}); + +test('tenancy can pull a pending tenant', function () { + Tenant::createPending(); + + expect(Tenant::pullPendingFromPool())->toBeInstanceOf(Tenant::class); +}); + +test('pulling a tenant from the pending tenant pool removes it from the pool', function () { + Tenant::createPending(); + + expect(Tenant::onlyPending()->count())->toEqual(1); + + Tenant::pullPendingFromPool(); + + expect(Tenant::onlyPending()->count())->toEqual(0); +}); + +test('a new tenant gets created while pulling a pending tenant if the pending pool is empty', function () { + expect(Tenant::withPending()->get()->count())->toBe(0); // All tenants + + Tenant::pullPending(); + + expect(Tenant::withPending()->get()->count())->toBe(1); // All tenants +}); + +test('pending tenants are included in all queries based on the include_in_queries config', function () { + Tenant::createPending(); + + config(['tenancy.pending.include_in_queries' => false]); + + expect(Tenant::all()->count())->toBe(0); + + config(['tenancy.pending.include_in_queries' => true]); + + expect(Tenant::all()->count())->toBe(1); +}); + +test('pending events are dispatched', function () { + Event::fake([ + CreatingPendingTenant::class, + PendingTenantCreated::class, + PullingPendingTenant::class, + PendingTenantPulled::class, + ]); + + Tenant::createPending(); + + Event::assertDispatched(CreatingPendingTenant::class); + Event::assertDispatched(PendingTenantCreated::class); + + Tenant::pullPending(); + + Event::assertDispatched(PullingPendingTenant::class); + Event::assertDispatched(PendingTenantPulled::class); +}); + +test('commands do not run for pending tenants if tenancy.pending.include_in_queries is false and the with pending option does not get passed', function() { + config(['tenancy.pending.include_in_queries' => false]); + + $tenants = collect([ + Tenant::create(), + Tenant::create(), + Tenant::createPending(), + Tenant::createPending(), + ]); + + pest()->artisan('tenants:migrate --with-pending'); + + $artisan = pest()->artisan("tenants:run 'foo foo --b=bar --c=xyz'"); + + $pendingTenants = $tenants->filter->pending(); + $readyTenants = $tenants->reject->pending(); + + $pendingTenants->each(fn ($tenant) => $artisan->doesntExpectOutputToContain("Tenant: {$tenant->getTenantKey()}")); + $readyTenants->each(fn ($tenant) => $artisan->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}")); + + $artisan->assertExitCode(0); +}); + +test('commands run for pending tenants too if tenancy.pending.include_in_queries is true', function() { + config(['tenancy.pending.include_in_queries' => true]); + + $tenants = collect([ + Tenant::create(), + Tenant::create(), + Tenant::createPending(), + Tenant::createPending(), + ]); + + pest()->artisan('tenants:migrate --with-pending'); + + $artisan = pest()->artisan("tenants:run 'foo foo --b=bar --c=xyz'"); + + $tenants->each(fn ($tenant) => $artisan->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}")); + + $artisan->assertExitCode(0); +}); + +test('commands run for pending tenants too if the with pending option is passed', function() { + config(['tenancy.pending.include_in_queries' => false]); + + $tenants = collect([ + Tenant::create(), + Tenant::create(), + Tenant::createPending(), + Tenant::createPending(), + ]); + + pest()->artisan('tenants:migrate --with-pending'); + + $artisan = pest()->artisan("tenants:run 'foo foo --b=bar --c=xyz' --with-pending"); + + $tenants->each(fn ($tenant) => $artisan->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}")); + + $artisan->assertExitCode(0); +}); diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 33a3158f..19b74e21 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; @@ -52,7 +53,7 @@ test('databases can be created and deleted', function ($driver, $databaseManager expect($manager->databaseExists($name))->toBeTrue(); $manager->deleteDatabase($tenant); expect($manager->databaseExists($name))->toBeFalse(); -})->with('database_manager_provider'); +})->with('database_managers'); test('dbs can be created when another driver is used for the central db', function () { expect(config('database.default'))->toBe('central'); @@ -104,7 +105,7 @@ test('the tenant connection is fully removed', function () { $tenant = Tenant::create(); - expect(array_keys(app('db')->getConnections()))->toBe(['central']); + expect(array_keys(app('db')->getConnections()))->toBe(['central', 'tenant_host_connection']); pest()->assertArrayNotHasKey('tenant', config('database.connections')); tenancy()->initialize($tenant); @@ -183,7 +184,7 @@ test('a tenants database cannot be created when the database already exists', fu ]); }); -test('tenant database can be created on a foreign server', function () { +test('tenant database can be created and deleted on a foreign server', function () { config([ 'tenancy.database.managers.mysql' => PermissionControlledMySQLDatabaseManager::class, 'database.connections.mysql2' => [ @@ -219,10 +220,151 @@ test('tenant database can be created on a foreign server', function () { /** @var PermissionControlledMySQLDatabaseManager $manager */ $manager = $tenant->database()->manager(); - $manager->setConnection('mysql'); - expect($manager->databaseExists($name))->toBeFalse(); + expect($manager->databaseExists($name))->toBeTrue(); // mysql2 - $manager->setConnection('mysql2'); + $manager->setConnection('mysql'); + expect($manager->databaseExists($name))->toBeFalse(); // check that the DB doesn't exist in 'mysql' + + $manager->setConnection('mysql2'); // set the connection back + $manager->deleteDatabase($tenant); + + expect($manager->databaseExists($name))->toBeFalse(); +}); + +test('tenant database can be created on a foreign server by using the host from tenant config', function () { + config([ + 'tenancy.database.managers.mysql' => MySQLDatabaseManager::class, + 'tenancy.database.template_tenant_connection' => 'mysql', // This will be overridden by tenancy_db_host + 'database.connections.mysql2' => [ + 'driver' => 'mysql', + 'host' => 'mysql2', + 'port' => 3306, + 'database' => 'main', + 'username' => 'root', + 'password' => 'password', + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + ]); + + Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + + $name = 'foo' . Str::random(8); + $tenant = Tenant::create([ + 'tenancy_db_name' => $name, + 'tenancy_db_host' => 'mysql2', + ]); + + /** @var MySQLDatabaseManager $manager */ + $manager = $tenant->database()->manager(); + + expect($manager->databaseExists($name))->toBeTrue(); +}); + +test('database credentials can be provided to PermissionControlledMySQLDatabaseManager by specifying a connection', function () { + config([ + 'tenancy.database.managers.mysql' => PermissionControlledMySQLDatabaseManager::class, + 'tenancy.database.template_tenant_connection' => 'mysql', + 'database.connections.mysql2' => [ + 'driver' => 'mysql', + 'host' => 'mysql2', + 'port' => 3306, + 'database' => 'main', + 'username' => 'root', + 'password' => 'password', + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + ]); + + // Create a new random database user with privileges to use with mysql2 connection + $username = 'dbuser' . Str::random(4); + $password = Str::random('8'); + $mysql2DB = DB::connection('mysql2'); + $mysql2DB->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}';"); + $mysql2DB->statement("GRANT ALL PRIVILEGES ON *.* TO `{$username}`@`%` identified by '{$password}' WITH GRANT OPTION;"); + $mysql2DB->statement("FLUSH PRIVILEGES;"); + + DB::purge('mysql2'); // forget the mysql2 connection so that it uses the new credentials the next time + + config(['database.connections.mysql2.username' => $username]); + config(['database.connections.mysql2.password' => $password]); + + Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + + $name = 'foo' . Str::random(8); + $usernameForNewDB = 'user_for_new_db' . Str::random(4); + $passwordForNewDB = Str::random(8); + $tenant = Tenant::create([ + 'tenancy_db_name' => $name, + 'tenancy_db_connection' => 'mysql2', + 'tenancy_db_username' => $usernameForNewDB, + 'tenancy_db_password' => $passwordForNewDB, + ]); + + /** @var PermissionControlledMySQLDatabaseManager $manager */ + $manager = $tenant->database()->manager(); + + expect($manager->database()->getConfig('username'))->toBe($username); // user created for the HOST connection + expect($manager->userExists($usernameForNewDB))->toBeTrue(); + expect($manager->databaseExists($name))->toBeTrue(); +}); + +test('tenant database can be created by using the username and password from tenant config', 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', + ]); + + // Create a new random database user with privileges to use with `mysql` connection + $username = 'dbuser' . Str::random(4); + $password = Str::random('8'); + $mysqlDB = DB::connection('mysql'); + $mysqlDB->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}';"); + $mysqlDB->statement("GRANT ALL PRIVILEGES ON *.* TO `{$username}`@`%` identified by '{$password}' WITH GRANT OPTION;"); + $mysqlDB->statement("FLUSH PRIVILEGES;"); + + DB::purge('mysql2'); // forget the mysql2 connection so that it uses the new credentials the next time + + // Remove `mysql` credentials to make sure we will be using the credentials from the tenant config + config(['database.connections.mysql.username' => null]); + config(['database.connections.mysql.password' => null]); + + $name = 'foo' . Str::random(8); + $tenant = Tenant::create([ + 'tenancy_db_name' => $name, + 'tenancy_db_username' => $username, + 'tenancy_db_password' => $password, + ]); + + /** @var MySQLDatabaseManager $manager */ + $manager = $tenant->database()->manager(); + + expect($manager->database()->getConfig('username'))->toBe($username); // user created for the HOST connection expect($manager->databaseExists($name))->toBeTrue(); }); @@ -245,11 +387,11 @@ test('path used by sqlite manager can be customized', function () { 'tenancy_db_connection' => 'sqlite', ]); - expect(file_exists( $customPath . '/' . $name))->toBeTrue(); + expect(file_exists($customPath . '/' . $name))->toBeTrue(); }); // Datasets -dataset('database_manager_provider', [ +dataset('database_managers', [ ['mysql', MySQLDatabaseManager::class], ['mysql', PermissionControlledMySQLDatabaseManager::class], ['sqlite', SQLiteDatabaseManager::class],