From 198f34f5e1524232d661d5a24e64f2ba2566721b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 31 Oct 2022 12:14:44 +0100 Subject: [PATCH] [4.x] Add pending tenants (modified #782) (#869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add readied tenants Add config for readied tenants Add `create` and `clear` command Add Readied scope and static functions Add tests * Fix initialize function name * Add readied events * Fix readied column cast * Laravel 6 compatible * Add readied scope tests * Rename config from include_in_scope to include_in_queries * Change terminology to pending * Update CreatePendingTenants.php * Laravel 6 compatible * Update CreatePendingTenants.php * runForMultiple can scope pending tenants * Fix issues * Code and comment style improvements * Change 'tenant' to 'tenants' in command signature * Fix code style (php-cs-fixer) * Rename variables in CreatePendingTenants * Remove withPending from runForMultiple * Update tenants option trait * Update command that use tenants * Fix code style (php-cs-fixer) * Improve getTenants condition * Update config comments * Minor config comment corrections * Grammar fix * Update comments and naming * Correct comments * Improve writing * Remove pending tenant clearing time constraints * Allow using only one time constraint for clearing the pending tenants * phpunit to pest * Fix code style (php-cs-fixer) * Fix code style (php-cs-fixer) * [4.x] Optionally delete storage after tenant deletion (#938) * Add test for deleting storage after tenant deletion * Save `storage_path()` in a variable after initializing tenant in test Co-authored-by: Samuel Štancl * Add DeleteTenantStorage listener * Update test name * Remove storage deletion config key * Remove tenant storage deletion events * Move tenant storage deletion to the DeletingTenant event Co-authored-by: Samuel Štancl * [4.x] Finish incomplete and missing tests (#947) * complete test sqlite manager customize path * complete test seed command works * complete uniqe exists test * Update SingleDatabaseTenancyTest.php * refactor the ternary into if condition * custom path * simplify if condition * random dir name * Update SingleDatabaseTenancyTest.php * Update CommandsTest.php * prefix random DB name with custom_ Co-authored-by: Samuel Štancl * [4.x] Add batch tenancy queue bootstrapper (#874) * exclude master from CI * Add batch tenancy queue bootstrapper * add test case * skip tests for old versions * variable docblocks * use Laravel's connection getter and setter * convert test to pest * bottom space * singleton regis in TestCase * Update src/Bootstrappers/BatchTenancyBootstrapper.php Co-authored-by: Samuel Štancl * convert batch class resolution to property level * enabled BatchTenancyBootstrapper by default * typehint DatabaseBatchRepository * refactore name * DI DB manager * typehint * Update config.php * use initialize() twice without end()ing tenancy to assert that previousConnection logic works correctly Co-authored-by: Samuel Štancl Co-authored-by: Abrar Ahmad Co-authored-by: Samuel Štancl * [4.x] Storage::url() support (modified #689) (#909) * This adds support for tenancy aware Storage::url() method * Trigger CI build * Fixed Link command for Laravel v6, added StorageLink Events, more StorageLink tests, added RemoveStorageSymlinks Job, added Storage Jobs to TenancyServiceProvider stub, renamed misleading config example. * Fix typo * Fix code style (php-cs-fixer) * Update config comments * Format code in Link command, make writing more concise * Change "symLinks" to "symlinks" * Refactor Link command * Fix test name typo * Test fetching files using the public URL * Extract Link command logic into actions * Fix code style (php-cs-fixer) * Check if closure is null in CreateStorageSymlinksAction * Stop using command terminology in CreateStorageSymlinksAction * Separate the Storage::url() test cases * Update url_override comments * Remove afterLink closures, add types, move actions, add usage explanation to the symlink trait * Fix code style (php-cs-fixer) * Update public storage URL test * Fix issue with using str() * Improve url_override comment, add todos * add todo comment * fix docblock style * Add link command tests back * Add types to $tenants in the action handle() methods * Fix typo, update variable name formatting * Add tests for the symlink actions * Change possibleTenantSymlinks not to prefix the paths twice while tenancy is initialized * Fix code style (php-cs-fixer) * Stop testing storage directory existence in symlink test * Don't specify full namespace for Tenant model annotation * Don't specify full namespace in ActionTest * Remove "change to DI" todo * Remove possibleTenantSymlinks return annotation * Remove symlink-related jobs, instantiate and use actions * Revert "Remove symlink-related jobs, instantiate and use actions" This reverts commit 547440c887dd86d75c7a5543fec576e233487eff. * Add a comment line about the possible tenant symlinks * Correct storagePath and publicPath variables * Revert "Correct storagePath and publicPath variables" This reverts commit e3aa8e208686e5fdf8e15a3bdb88d6f9853316fe. * add a todo Co-authored-by: Martin Vlcek Co-authored-by: lukinovec Co-authored-by: PHP CS Fixer * Use HasTenantOptions in Link * Correct the tenant order in Run command * Fix code style (php-cs-fixer) * Fix formatting issue * Add missing imports * Fix code style (php-cs-fixer) * Use HasTenantOptions instead of the old trait name in Up/Down commands * Fix test name typo * Remove redundant passing of $withPending to runForMultiple in TenantCollection's runForEach * Make `with-pending` default to `config('tenancy.pending.include_in_queries')` in HasTenantOptions * Make `createPending()` return the created tenant * Fix code style (php-cs-fixer) * Remove tenant ordering * Fix code style (php-cs-fixer) * Remove duplicate tenancy bootstrappers config setting * Add and use getWithPendingOption method * Fix code style (php-cs-fixer) * Add optionNotPassedValue property * Test using --with-pending and the include_in_queries config value * Make with-pending VALUE_NONE * use plural in test names * fix test names * add pullPendingTenantFromPool * Add docblock type * Import commands * Fix code style (php-cs-fixer) * Move pending tenant tests to a more appropriate file * Delete queuetest from gitignore * Delete queuetest file * Add queuetest to gitignore * Rename pullPendingTenant to pullPending and don't pass bool to that method * Add a test that checks if pulling a pending tenant removes it from the pool * bump stancl/virtualcolumn to ^1.3 * Update pending tenant pulling test * Dynamically get columns for pending queries * Dynamically get virtual column name in ClearPendingTenants * Fix ClearPendingTenants bug * Make test name more accurate * Update test name * add a todo * Update include in queries test name * Remove `Tenant::query()->delete()` from pending tenant check test * Rename the pending tenant check test name * Update HasPending.php * fix all() call * code style * all() -> get() * Remove redundant `Tenant::all()` call Co-authored-by: j.stein Co-authored-by: lukinovec Co-authored-by: PHP CS Fixer Co-authored-by: Abrar Ahmad Co-authored-by: Riley19280 Co-authored-by: Martin Vlcek --- .gitignore | 1 + assets/TenancyServiceProvider.stub.php | 6 + assets/config.php | 19 ++ composer.json | 2 +- src/Commands/ClearPendingTenants.php | 74 +++++++ src/Commands/CreatePendingTenants.php | 62 ++++++ src/Commands/Down.php | 4 +- src/Commands/Link.php | 4 +- src/Commands/Migrate.php | 5 +- src/Commands/MigrateFresh.php | 5 +- src/Commands/Rollback.php | 5 +- src/Commands/Run.php | 4 +- src/Commands/Seed.php | 4 +- src/Commands/Up.php | 4 +- ...TenantsOption.php => HasTenantOptions.php} | 10 +- src/Database/Concerns/HasPending.php | 103 +++++++++ src/Database/Concerns/PendingScope.php | 88 ++++++++ src/Database/Models/Tenant.php | 1 + src/Events/CreatingPendingTenant.php | 9 + src/Events/PendingTenantCreated.php | 9 + src/Events/PendingTenantPulled.php | 9 + src/Events/PullingPendingTenant.php | 9 + src/Jobs/ClearPendingTenants.php | 28 +++ src/Jobs/CreatePendingTenants.php | 28 +++ src/Tenancy.php | 2 +- src/TenancyServiceProvider.php | 6 +- tests/CommandsTest.php | 5 - tests/Etc/Console/AddUserCommand.php | 4 +- tests/Etc/Tenant.php | 3 +- tests/PendingTenantsTest.php | 209 ++++++++++++++++++ 30 files changed, 693 insertions(+), 29 deletions(-) create mode 100644 src/Commands/ClearPendingTenants.php create mode 100644 src/Commands/CreatePendingTenants.php rename src/Concerns/{HasATenantsOption.php => HasTenantOptions.php} (63%) create mode 100644 src/Database/Concerns/HasPending.php create mode 100644 src/Database/Concerns/PendingScope.php create mode 100644 src/Events/CreatingPendingTenant.php create mode 100644 src/Events/PendingTenantCreated.php create mode 100644 src/Events/PendingTenantPulled.php create mode 100644 src/Events/PullingPendingTenant.php create mode 100644 src/Jobs/ClearPendingTenants.php create mode 100644 src/Jobs/CreatePendingTenants.php create mode 100644 tests/PendingTenantsTest.php 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 82a4c722..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. */ diff --git a/composer.json b/composer.json index b30ea94c..49657912 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/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 ea973070..95672753 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -24,7 +24,6 @@ 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); @@ -34,10 +33,6 @@ beforeEach(function () { return $event->tenant; })->toListener()); - config(['tenancy.bootstrappers' => [ - DatabaseTenancyBootstrapper::class, - ]]); - config([ 'tenancy.bootstrappers' => [ DatabaseTenancyBootstrapper::class, 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); +});