diff --git a/.gitignore b/.gitignore index f470ba75..2fa4d033 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 1d15f418..9bc2cad0 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -49,6 +49,12 @@ class TenancyServiceProvider extends ServiceProvider })->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production. ], + // Pending 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 85592d14..8f389710 100644 --- a/assets/config.php +++ b/assets/config.php @@ -35,6 +35,37 @@ return [ // Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed ], + + /** + * Pending tenancy 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. Unless if + * told otherwise with ::withPending() or ::onlyPending(). + * Note: when disabled, this will also ignore tenants when runnings any tenants commands (migration, seed, etc.) + */ + 'include_in_queries' => true, + /** + * Defines how many tenants you want to be in a pending state. + * This value should be changed depending on how often a new tenant is created + * and how often you run the `tenancy:pending` command via the scheduler. + */ + 'count' => env('TENANCY_PENDING_COUNT', 5), + + /** + * If needed, you can define a time limite after when an unused pending tenant + * will automatically be deleted. + * For this to work automatically, make sure to call the `tenancy:pending-clear` command in the scheduler. + * + * If both values are set to null, not time limit will be set and all pending tenants will be deleted. + */ + 'older_than_days' => env('TENANCY_PENDING_OLDER_THAN_DAYS', null), + + 'older_than_hours' => env('TENANCY_PENDING_OLDER_THAN_HOURS', null), + ], + /** * Database tenancy config. Used by DatabaseTenancyBootstrapper. */ diff --git a/src/Commands/ClearPendingTenants.php b/src/Commands/ClearPendingTenants.php new file mode 100644 index 00000000..67d77d56 --- /dev/null +++ b/src/Commands/ClearPendingTenants.php @@ -0,0 +1,67 @@ +info('Cleaning pending tenants.'); + + $expireDate = now(); + // At the end, we will check if the value has been changed by comparing the two dates + $savedExpiredDate = $expireDate->copy()->toImmutable(); + + // If the all option is given, skip the expiry date configuration + if (! $this->option('all')) { + if ($olderThanDays = $this->option('older-days') ?? config('tenancy.pending.older_than_days')) { + $expireDate->subDays($olderThanDays); + } + + if ($olderThanHours = $this->option('older-hours') ?? config('tenancy.pending.older_than_hours')) { + $expireDate->subHours($olderThanHours); + } + } + + + $deletedPendingCount = tenancy() + ->query() + ->onlyPending() + ->when($savedExpiredDate->notEqualTo($expireDate), function (Builder $query) use ($expireDate) { + $query->where('data->pending_since', '<', $expireDate->timestamp); + }) + ->get() + ->each // This makes sure the events or triggered on the model + ->delete() + ->count(); + + $this->info("$deletedPendingCount pending tenant(s) deleted."); + } +} diff --git a/src/Commands/CreatePendingTenants.php b/src/Commands/CreatePendingTenants.php new file mode 100644 index 00000000..76c461b1 --- /dev/null +++ b/src/Commands/CreatePendingTenants.php @@ -0,0 +1,64 @@ +info('Deploying pending tenants.'); + + $pendingObjectifCount = (int) ($this->option('count') ?? config('tenancy.pending.count')); + + $pendingCurrentCount = $this->getPendingTenantCount(); + + $deployedCount = 0; + while ($pendingCurrentCount < $pendingObjectifCount) { + tenancy()->model()::createPending(); + // We update the number of pending tenants every time with a query to get a live count. + // this prevents to deploy too many tenants if pending tenants are being created simultaneous somewhere else + // during the runtime of this command. + $pendingCurrentCount = $this->getPendingTenantCount(); + $deployedCount++; + } + + $this->info("$deployedCount tenants deployed, $pendingObjectifCount tenant(s) are ready to be used."); + + return 1; + } + + /** + * Calculates the number of pending tenants currently deployed + * @return int + */ + private function getPendingTenantCount(): int + { + return tenancy() + ->query() + ->onlyPending() + ->count(); + } +} diff --git a/src/Commands/Migrate.php b/src/Commands/Migrate.php index c67d3598..f79958ea 100644 --- a/src/Commands/Migrate.php +++ b/src/Commands/Migrate.php @@ -57,6 +57,6 @@ class Migrate extends MigrateCommand parent::handle(); event(new DatabaseMigrated($tenant)); - }); + }, $this->withPending()); } } diff --git a/src/Commands/MigrateFresh.php b/src/Commands/MigrateFresh.php index 283d70b0..dc4fc6f2 100644 --- a/src/Commands/MigrateFresh.php +++ b/src/Commands/MigrateFresh.php @@ -49,7 +49,7 @@ final class MigrateFresh extends Command '--tenants' => [$tenant->getTenantKey()], '--force' => true, ]); - }); + }, $this->withPending()); $this->info('Done.'); } diff --git a/src/Commands/Rollback.php b/src/Commands/Rollback.php index e60d974b..9b8a4c47 100644 --- a/src/Commands/Rollback.php +++ b/src/Commands/Rollback.php @@ -66,6 +66,6 @@ class Rollback extends RollbackCommand parent::handle(); event(new DatabaseRolledBack($tenant)); - }); + }, $this->withPending()); } } diff --git a/src/Commands/Run.php b/src/Commands/Run.php index aa518d7a..f3b2197f 100644 --- a/src/Commands/Run.php +++ b/src/Commands/Run.php @@ -5,9 +5,11 @@ declare(strict_types=1); namespace Stancl\Tenancy\Commands; use Illuminate\Console\Command; +use Stancl\Tenancy\Concerns\HasATenantsOption; class Run extends Command { + use HasATenantsOption; /** * The console command description. * @@ -21,7 +23,6 @@ class Run extends Command * @var string */ protected $signature = "tenants:run {commandname : The command's name.} - {--tenants=* : The tenant(s) to run the command for. Default: all} {--argument=* : The arguments to pass to the command. Default: none} {--option=* : The options to pass to the command. Default: none}"; @@ -52,6 +53,6 @@ class Run extends Command // Run command $this->call($this->argument('commandname'), array_merge($arguments, $options)); - }); + }, $this->withPending()); } } diff --git a/src/Commands/Seed.php b/src/Commands/Seed.php index dc97ae71..11cdaf48 100644 --- a/src/Commands/Seed.php +++ b/src/Commands/Seed.php @@ -59,6 +59,6 @@ class Seed extends SeedCommand parent::handle(); event(new DatabaseSeeded($tenant)); - }); + }, $this->withPending()); } } diff --git a/src/Concerns/HasATenantsOption.php b/src/Concerns/HasATenantsOption.php index a2b94ac5..120bb21c 100644 --- a/src/Concerns/HasATenantsOption.php +++ b/src/Concerns/HasATenantsOption.php @@ -13,6 +13,7 @@ trait HasATenantsOption { return array_merge([ ['tenants', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL, '', null], + ['with-pending', null, InputOption::VALUE_NONE, 'include pending tenants in query', null], ], parent::getOptions()); } @@ -26,6 +27,11 @@ trait HasATenantsOption ->cursor(); } + protected function withPending(): ?bool + { + return $this->option('with-pending') ? true : null; + } + public function __construct() { parent::__construct(); diff --git a/src/Database/Concerns/HasPending.php b/src/Database/Concerns/HasPending.php new file mode 100644 index 00000000..5187733e --- /dev/null +++ b/src/Database/Concerns/HasPending.php @@ -0,0 +1,90 @@ +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); + } + + public static function createPending($attributes = []): void + { + $tenant = static::create($attributes); + + event(new CreatingPendingTenant($tenant)); + + // We add the pending value only after the model has then been created. + // this ensures the model is not marked as pending until the migrations, seeders, etc. are done + $tenant->update([ + 'pending_since' => now()->timestamp + ]); + + event(new PendingTenantCreated($tenant)); + } + + public static function pullPendingTenant(bool $firstOrCreate = false): ?Tenant + { + if (!static::onlyPending()->exists()) { + if (!$firstOrCreate) { + return null; + } + static::createPending(); + } + + // At this point we can guarantee a pending tenant is free and can be called. + $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..aaa48bfe --- /dev/null +++ b/src/Database/Concerns/PendingScope.php @@ -0,0 +1,101 @@ +when(!config('tenancy.pending.include_in_queries'), function (Builder $builder){ + $builder->whereNull('data->pending_since'); + }); + } + + /** + * Extend the query builder with the needed functions. + * + * @param \Illuminate\Database\Eloquent\Builder $builder + * @return void + */ + public function extend(Builder $builder) + { + foreach ($this->extensions as $extension) { + $this->{"add{$extension}"}($builder); + } + } + /** + * Add the with-pending extension to the builder. + * + * @param \Illuminate\Database\Eloquent\Builder $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. + * + * @param \Illuminate\Database\Eloquent\Builder $builder + * @return void + */ + protected function addWithoutPending(Builder $builder) + { + $builder->macro('withoutPending', function (Builder $builder) { + + // Only use whereNull('data->pending_since') when Laravel 6 support is dropped + // Issue fixed in Laravel 7 https://github.com/laravel/framework/pull/32417 + $builder->withoutGlobalScope($this) + ->where('data->pending_since', 'like', 'null') + ->orWhereNull('data'); + + return $builder; + }); + } + + /** + * Add the only-pending extension to the builder. + * + * @param \Illuminate\Database\Eloquent\Builder $builder + * @return void + */ + protected function addOnlyPending(Builder $builder) + { + $builder->macro('onlyPending', function (Builder $builder) { + + // Use whereNotNull when Laravel 6 is dropped + // Issue fixed in Laravel 7 https://github.com/laravel/framework/pull/32417 + $builder->withoutGlobalScope($this)->where('data->pending_since', 'not like', 'null'); + + return $builder; + }); + } +} diff --git a/src/Database/Models/Tenant.php b/src/Database/Models/Tenant.php index 4ec685b7..625ec71c 100644 --- a/src/Database/Models/Tenant.php +++ b/src/Database/Models/Tenant.php @@ -26,7 +26,8 @@ class Tenant extends Model implements Contracts\Tenant Concerns\HasDataColumn, Concerns\HasInternalKeys, Concerns\TenantRun, - Concerns\InvalidatesResolverCache; + Concerns\InvalidatesResolverCache, + Concerns\HasPending; protected $table = 'tenants'; protected $primaryKey = 'id'; diff --git a/src/Database/TenantCollection.php b/src/Database/TenantCollection.php index ba3a8fab..c4d09784 100644 --- a/src/Database/TenantCollection.php +++ b/src/Database/TenantCollection.php @@ -16,9 +16,9 @@ use Stancl\Tenancy\Contracts\Tenant; */ class TenantCollection extends Collection { - public function runForEach(callable $callable): self + public function runForEach(callable $callable, bool $withPending = null): self { - tenancy()->runForMultiple($this->items, $callable); + tenancy()->runForMultiple($this->items, $callable, $withPending); return $this; } 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()->newQuery(); + + if (is_bool($withPending) && $this->model()::hasGlobalScope(PendingScope::class)){ + $query->withPending($withPending); + } // Convert null to all tenants - $tenants = is_null($tenants) ? $this->model()->cursor() : $tenants; + $tenants = is_null($tenants) ? $query->cursor() : $tenants; // Convert incrementing int ids to strings $tenants = is_int($tenants) ? (string) $tenants : $tenants; @@ -146,8 +152,8 @@ class Tenancy // Wrap string in array $tenants = is_string($tenants) ? [$tenants] : $tenants; - // Use all tenants if $tenants is falsey - $tenants = $tenants ?: $this->model()->cursor(); + // Use all tenants if $tenants is false + $tenants = $tenants ?: $query->cursor(); $originalTenant = $this->tenant; diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index 4faaccf3..a7e09b48 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -89,6 +89,8 @@ class TenancyServiceProvider extends ServiceProvider Commands\Rollback::class, Commands\TenantList::class, Commands\MigrateFresh::class, + Commands\CreatePendingTenants::class, + Commands\ClearPendingTenants::class, ]); $this->publishes([ diff --git a/tests/Etc/Tenant.php b/tests/Etc/Tenant.php index 83840280..8e817d97 100644 --- a/tests/Etc/Tenant.php +++ b/tests/Etc/Tenant.php @@ -7,9 +7,10 @@ namespace Stancl\Tenancy\Tests\Etc; use Stancl\Tenancy\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\Concerns\HasDatabase; use Stancl\Tenancy\Database\Concerns\HasDomains; +use Stancl\Tenancy\Database\Concerns\HasPending; use Stancl\Tenancy\Database\Models; class Tenant extends Models\Tenant implements TenantWithDatabase { - use HasDatabase, HasDomains; + use HasDatabase, HasDomains, HasPending; } diff --git a/tests/Etc/tmp/queuetest.json b/tests/Etc/tmp/queuetest.json new file mode 100644 index 00000000..00cf7c37 --- /dev/null +++ b/tests/Etc/tmp/queuetest.json @@ -0,0 +1 @@ +{"tenant_id":"The current tenant id is: acme"} \ No newline at end of file diff --git a/tests/PendingTenantsTest.php b/tests/PendingTenantsTest.php new file mode 100644 index 00000000..9dc5c097 --- /dev/null +++ b/tests/PendingTenantsTest.php @@ -0,0 +1,196 @@ +assertCount(1, Tenant::onlyPending()->get()); + + Tenant::onlyPending()->first()->update([ + 'pending_since' => null + ]); + + $this->assertCount(0, Tenant::onlyPending()->get()); + } + + /** @test */ + public function pending_trait_imports_query_scopes() + { + Tenant::createPending(); + Tenant::create(); + Tenant::create(); + + $this->assertCount(1, Tenant::onlyPending()->get()); + + $this->assertCount(3, Tenant::withPending(true)->get()); + + $this->assertCount(2, Tenant::withPending(false)->get()); + + $this->assertCount(2, Tenant::withoutPending()->get()); + } + + /** @test */ + public function pending_tenants_are_created_and_deleted_from_the_commands() + { + config(['tenancy.pending.count' => 4]); + + Artisan::call(CreatePendingTenants::class); + + $this->assertCount(4, Tenant::onlyPending()->get()); + + Artisan::call(ClearPendingTenants::class); + + $this->assertCount(0, Tenant::onlyPending()->get()); + } + + /** @test */ + public function clear_pending_tenants_command_only_delete_pending_tenants_older_than() + { + config(['tenancy.pending.count' => 2]); + + Artisan::call(CreatePendingTenants::class); + + config(['tenancy.pending.older_than_days' => 2]); + + tenancy()->model()->query()->onlyPending()->first()->update([ + 'pending_since' => now()->subDays(5)->timestamp + ]); + + Artisan::call(ClearPendingTenants::class); + + $this->assertCount(1, Tenant::onlyPending()->get()); + } + + /** @test */ + public function clear_pending_tenants_command_all_option_overrides_config() + { + 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 + ]); + + $this->assertCount(0, Tenant::onlyPending()->get()); + } + + /** @test */ + public function tenancy_can_check_for_rpending_tenants() + { + Tenant::query()->delete(); + + $this->assertFalse(Tenant::onlyPending()->exists()); + + Tenant::createPending(); + + $this->assertTrue(Tenant::onlyPending()->exists()); + } + + /** @test */ + public function tenancy_can_pull_a_pending_tenant() + { + $this->assertNull(Tenant::pullPendingTenant()); + + Tenant::createPending(); + + $this->assertInstanceOf(Tenant::class, Tenant::pullPendingTenant(true)); + } + + /** @test */ + public function tenancy_can_create_if_none_are_pending() + { + $this->assertCount(0, Tenant::all()); + + Tenant::pullPendingTenant(true); + + $this->assertCount(1, Tenant::all()); + } + + /** @test */ + public function pending_tenants_global_scope_config_can_include_or_exclude() + { + Tenant::createPending(); + + config(['tenancy.pending.include_in_queries' => false]); + + $this->assertCount(0, Tenant::all()); + + config(['tenancy.pending.include_in_queries' => true]); + + $this->assertCount(1, Tenant::all()); + Tenant::all(); + } + + /** @test */ + public function pending_events_are_triggerred() + { + Event::fake([ + CreatingPendingTenant::class, + PendingTenantCreated::class, + PullingPendingTenant::class, + PendingTenantPulled::class, + ]); + + Tenant::createPending(); + + Event::assertDispatched(CreatingPendingTenant::class); + Event::assertDispatched(PendingTenantCreated::class); + + Tenant::pullPendingTenant(); + + Event::assertDispatched(PullingPendingTenant::class); + Event::assertDispatched(PendingTenantPulled::class); + } + + /** @test */ + public function tenancy_run_for_multiple_can_scope_pending_tenants() + { + config(['tenancy.pending.include_in_queries' => false]); + + Tenant::createPending(); + Tenant::create(); + + $executedCount = 0; + tenancy()->runForMultiple([], function () use (&$executedCount){ + $executedCount++; + }, false); + + self::assertEquals(1, $executedCount); + + $executedCount = 0; + + tenancy()->runForMultiple([], function () use (&$executedCount){ + $executedCount++; + }, true); + + self::assertEquals(2, $executedCount); + } +}