diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..fc293b36 Binary files /dev/null and b/.DS_Store differ diff --git a/assets/config.php b/assets/config.php index 85592d14..d9e363bf 100644 --- a/assets/config.php +++ b/assets/config.php @@ -35,6 +35,37 @@ return [ // Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed ], + + /** + * Readied tenancy config. + * This is useful if you're looking for a way to always have a tenant ready to be used. + */ + 'readied' => [ + /** + * If disabled, readied tenants will be excluded from all tenant queries. Unless if + * told otherwise with ::withReadied() or ::onlyReadied(). + * Note: when disabled, this will also ignore tenants when runnings any tenants commands (migration, seed, etc.) + */ + 'include_in_scope' => true, + /** + * Defines how many tenants you want to be in a readied state. + * This value should be changed depending on how often a new tenant is created + * and how often you run the `tenancy:readied` command via the scheduler. + */ + 'count' => env('TENANCY_READIED_COUNT', 5), + + /** + * If needed, you can define a time limite after when an unused readied tenant + * will automatically be deleted. + * For this to work automatically, make sure to call the `tenancy:readied-clear` command in the scheduler. + * + * If both values are set to null, not time limit will be set and all readied tenants will be deleted. + */ + 'older_than_days' => env('TENANCY_READIED_OLDER_THAN_DAYS', null), + + 'older_than_hours' => env('TENANCY_READIED_OLDER_THAN_HOURS', null), + ], + /** * Database tenancy config. Used by DatabaseTenancyBootstrapper. */ diff --git a/src/Commands/ClearReadiedTenants.php b/src/Commands/ClearReadiedTenants.php new file mode 100644 index 00000000..05b0adcb --- /dev/null +++ b/src/Commands/ClearReadiedTenants.php @@ -0,0 +1,67 @@ +info('Cleaning readied 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.readied.older_than_days')) { + $expireDate->subDays($olderThanDays); + } + + if ($olderThanHours = $this->option('older-hours') ?? config('tenancy.readied.older_than_hours')) { + $expireDate->subHours($olderThanHours); + } + } + + + $readiedTenantsDeletedCount = tenancy() + ->query() + ->onlyReadied() + ->when($savedExpiredDate->notEqualTo($expireDate), function (Builder $query) use ($expireDate) { + $query->where('data->readied', '<', $expireDate->timestamp); + }) + ->get() + ->each // This makes sure the events or triggered on the model + ->delete() + ->count(); + + $this->info("$readiedTenantsDeletedCount readied tenant(s) deleted."); + } +} diff --git a/src/Commands/CreateReadiedTenants.php b/src/Commands/CreateReadiedTenants.php new file mode 100644 index 00000000..90304198 --- /dev/null +++ b/src/Commands/CreateReadiedTenants.php @@ -0,0 +1,62 @@ +info('Deploying readied tenants.'); + + $readiedCountObjectif = (int)config('tenancy.readied.count'); + + $readiedTenantCount = $this->getReadiedTenantCount(); + + $deployedCount = 0; + while ($readiedTenantCount < $readiedCountObjectif) { + tenancy()->model()::createReadied(); + // We update the number of readied tenant every time with a query to get a live count. + // this prevents to deploy too many tenants if readied tenants have been deployed + // while this command is running. + $readiedTenantCount = $this->getReadiedTenantCount(); + $deployedCount++; + } + + $this->info("$deployedCount tenants deployed, $readiedCountObjectif tenant(s) are ready to be used."); + } + + /** + * Calculates the number of readied tenants currently deployed + * @return int + */ + private function getReadiedTenantCount(): int + { + return tenancy() + ->query() + ->onlyReadied() + ->count(); + } +} diff --git a/src/Database/Concerns/ReadiedScope.php b/src/Database/Concerns/ReadiedScope.php new file mode 100644 index 00000000..98b38106 --- /dev/null +++ b/src/Database/Concerns/ReadiedScope.php @@ -0,0 +1,95 @@ +when(!config('tenancy.readied.include_in_scope'), function (Builder $builder){ + $builder->whereNull('data->readied'); + }); + } + + /** + * 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-readied extension to the builder. + * + * @param \Illuminate\Database\Eloquent\Builder $builder + * @return void + */ + protected function addWithReadied(Builder $builder) + { + $builder->macro('withReadied', function (Builder $builder, $withReadied = true) { + if (! $withReadied) { + return $builder->withoutReadied(); + } + + return $builder->withoutGlobalScope($this); + }); + } + + /** + * Add the without-readied extension to the builder. + * + * @param \Illuminate\Database\Eloquent\Builder $builder + * @return void + */ + protected function addWithoutReadied(Builder $builder) + { + $builder->macro('withoutReadied', function (Builder $builder) { + + $builder->withoutGlobalScope($this)->whereNull('data->readied'); + + return $builder; + }); + } + + /** + * Add the only-readied extension to the builder. + * + * @param \Illuminate\Database\Eloquent\Builder $builder + * @return void + */ + protected function addOnlyReadied(Builder $builder) + { + $builder->macro('onlyReadied', function (Builder $builder) { + + $builder->withoutGlobalScope($this)->whereNotNull('data->readied'); + + return $builder; + }); + } +} diff --git a/src/Database/Concerns/WithReadied.php b/src/Database/Concerns/WithReadied.php new file mode 100644 index 00000000..ead2dfe2 --- /dev/null +++ b/src/Database/Concerns/WithReadied.php @@ -0,0 +1,79 @@ +casts['readied'] = 'datetime'; + } + + + /** + * Determine if the model instance is in a readied state. + * + * @return bool + */ + public function readied() + { + return !is_null($this->readied); + } + + public static function createReadied($attributes = []): void + { + $tenant = static::create($attributes); + + // We add the readied value only after the model has then been created. + // this ensures the model is not marked as readied until the migrations, seeders, etc. are done + $tenant->update([ + 'readied' => now()->timestamp + ]); + } + + public static function pullReadiedTenant(bool $firstOrCreate = false): ?Tenant + { + if (!static::onlyReadied()->exists()) { + if (!$firstOrCreate) { + return null; + } + static::createReadied(); + } + + // At this point we can guarantee a readied tenant is free and can be called + $tenant = static::onlyReadied()->first(); + + $tenant->update([ + 'readied' => null + ]); + + return $tenant; + } +} diff --git a/src/Database/Models/Tenant.php b/src/Database/Models/Tenant.php index 4ec685b7..b575ff8f 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\WithReadied; protected $table = 'tenants'; protected $primaryKey = 'id'; diff --git a/src/Jobs/ClearReadiedTenants.php b/src/Jobs/ClearReadiedTenants.php new file mode 100644 index 00000000..6ca3d4ab --- /dev/null +++ b/src/Jobs/ClearReadiedTenants.php @@ -0,0 +1,28 @@ +publishes([ diff --git a/tests/Etc/Tenant.php b/tests/Etc/Tenant.php index 83840280..bc801f3b 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\WithReadied; use Stancl\Tenancy\Database\Models; class Tenant extends Models\Tenant implements TenantWithDatabase { - use HasDatabase, HasDomains; + use HasDatabase, HasDomains, WithReadied; } diff --git a/tests/ReadiedTenantsTest.php b/tests/ReadiedTenantsTest.php new file mode 100644 index 00000000..70e439d4 --- /dev/null +++ b/tests/ReadiedTenantsTest.php @@ -0,0 +1,130 @@ +assertCount(1, Tenant::onlyReadied()->get()); + + Tenant::onlyReadied()->first()->update([ + 'readied' => null + ]); + + $this->assertCount(0, Tenant::onlyReadied()->get()); + } + + /** @test */ + public function readied_tenants_are_created_and_deleted_from_the_commands() + { + config(['tenancy.readied.count' => 4]); + + Artisan::call(CreateReadiedTenants::class); + + $this->assertCount(4, Tenant::onlyReadied()->get()); + + Artisan::call(ClearReadiedTenants::class); + + $this->assertCount(0, Tenant::onlyReadied()->get()); + } + + /** @test */ + public function clear_readied_tenants_command_only_delete_readied_tenants_older_than() + { + config(['tenancy.readied.count' => 2]); + + Artisan::call(CreateReadiedTenants::class); + + config(['tenancy.readied.older_than_days' => 4]); + + tenancy()->model()->query()->onlyReadied()->first()->update([ + 'readied' => now()->subDays() + ]); + + Artisan::call(ClearReadiedTenants::class); + + $this->assertCount(1, Tenant::onlyReadied()->get()); + } + + /** @test */ + public function clear_readied_tenants_command_all_option_overrides_config() + { + Tenant::createReadied(); + Tenant::createReadied(); + + tenancy()->model()->query()->onlyReadied()->first()->update([ + 'readied' => now()->subDays(10) + ]); + + config(['tenancy.readied.older_than_days' => 4]); + + Artisan::call(ClearReadiedTenants::class, [ + '--all' => true + ]); + + $this->assertCount(0, Tenant::onlyReadied()->get()); + } + + /** @test */ + public function tenancy_can_check_for_readied_tenants() + { + Tenant::query()->delete(); + + $this->assertFalse(Tenant::onlyReadied()->exists()); + + Tenant::createReadied(); + + $this->assertTrue(Tenant::onlyReadied()->exists()); + } + + /** @test */ + public function tenancy_can_pull_a_readied_tenant() + { + $this->assertNull(Tenant::pullReadiedTenant()); + + Tenant::createReadied(); + + $this->assertInstanceOf(Tenant::class, Tenant::pullReadiedTenant(true)); + } + + /** @test */ + public function tenancy_can_create_if_none_are_readied() + { + $this->assertDatabaseCount(Tenant::class, 0); + + Tenant::pullReadiedTenant(true); + + $this->assertDatabaseCount(Tenant::class, 1); + } + + /** @test */ + public function readied_tenants_global_scope_config_can_include_or_exclude() + { + Tenant::createReadied(); + + config(['tenancy.readied.include_in_scope' => false]); + + $this->assertCount(0, Tenant::all()); + + config(['tenancy.readied.include_in_scope' => true]); + + $this->assertCount(1, Tenant::all()); + Tenant::all(); + } +}