From 3542b3f028fa8f233cdfd3c0b141a2e38cee087d Mon Sep 17 00:00:00 2001 From: Abrar Ahmad Date: Tue, 30 Aug 2022 18:21:19 +0500 Subject: [PATCH 01/48] update spatie/ignition to support L9 (#930) --- composer.json | 2 +- src/Contracts/TenantCouldNotBeIdentifiedException.php | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index ff5befd9..cc213add 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "php": "^8.1", "ext-json": "*", "illuminate/support": "^9.0", - "facade/ignition-contracts": "^1.0", + "spatie/ignition": "^1.4", "ramsey/uuid": "^4.0", "stancl/jobpipeline": "^1.0", "stancl/virtualcolumn": "^1.0" diff --git a/src/Contracts/TenantCouldNotBeIdentifiedException.php b/src/Contracts/TenantCouldNotBeIdentifiedException.php index 0066291f..011d974b 100644 --- a/src/Contracts/TenantCouldNotBeIdentifiedException.php +++ b/src/Contracts/TenantCouldNotBeIdentifiedException.php @@ -5,9 +5,8 @@ declare(strict_types=1); namespace Stancl\Tenancy\Contracts; use Exception; -use Facade\IgnitionContracts\BaseSolution; -use Facade\IgnitionContracts\ProvidesSolution; -use Facade\IgnitionContracts\Solution; +use Spatie\Ignition\Contracts\BaseSolution; +use Spatie\Ignition\Contracts\ProvidesSolution; abstract class TenantCouldNotBeIdentifiedException extends Exception implements ProvidesSolution { @@ -42,7 +41,7 @@ abstract class TenantCouldNotBeIdentifiedException extends Exception implements } /** Get the Ignition description. */ - public function getSolution(): Solution + public function getSolution(): BaseSolution { return BaseSolution::create($this->solutionTitle) ->setSolutionDescription($this->solutionDescription) From 2b02198dda8ea365456f0f71bc5ea837a051547f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 30 Aug 2022 16:23:50 +0200 Subject: [PATCH 02/48] Move to archtechx/tenancy:latest --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9da7221..db2b0ffc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ on: jobs: tests: runs-on: ubuntu-latest - container: abrardev/tenancy:latest + container: archtechx/tenancy:latest strategy: matrix: From 62d19c5f5d5f46b28ec95c7bc0c4e97393c58244 Mon Sep 17 00:00:00 2001 From: emargareten <46111162+emargareten@users.noreply.github.com> Date: Tue, 30 Aug 2022 17:24:50 +0300 Subject: [PATCH 03/48] Reverse bootstrappers when reverting to central (#932) Some of my bootstrappers are depending on previous bootstrappers but when reverting it needs to run in reverse order. Submitting to v4 since this might be a breaking change (currently I am overriding this file). --- src/Listeners/RevertToCentralContext.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Listeners/RevertToCentralContext.php b/src/Listeners/RevertToCentralContext.php index ac746ed4..0a680532 100644 --- a/src/Listeners/RevertToCentralContext.php +++ b/src/Listeners/RevertToCentralContext.php @@ -14,7 +14,7 @@ class RevertToCentralContext { event(new RevertingToCentralContext($event->tenancy)); - foreach ($event->tenancy->getBootstrappers() as $bootstrapper) { + foreach (array_reverse($event->tenancy->getBootstrappers()) as $bootstrapper) { $bootstrapper->revert(); } From f941df3a82e6856a1dc299ff89e063c2baaed4a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 1 Sep 2022 19:06:54 +0200 Subject: [PATCH 04/48] minor improvements for phpstan --- src/Features/CrossDomainRedirect.php | 11 ++++++++--- src/Tenancy.php | 2 +- src/helpers.php | 12 ++++++++---- tests/TestCase.php | 2 +- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/Features/CrossDomainRedirect.php b/src/Features/CrossDomainRedirect.php index 0b6d7682..4efc767b 100644 --- a/src/Features/CrossDomainRedirect.php +++ b/src/Features/CrossDomainRedirect.php @@ -15,11 +15,16 @@ class CrossDomainRedirect implements Feature RedirectResponse::macro('domain', function (string $domain) { /** @var RedirectResponse $this */ - // Replace first occurrence of the hostname fragment with $domain $url = $this->getTargetUrl(); + + /** + * The original hostname in the redirect response. + * + * @var string $hostname + */ $hostname = parse_url($url, PHP_URL_HOST); - $position = strpos($url, $hostname); - $this->setTargetUrl(substr_replace($url, $domain, $position, strlen($hostname))); + + $this->setTargetUrl((string) str($url)->replace($hostname, $domain)); return $this; }); diff --git a/src/Tenancy.php b/src/Tenancy.php index 012881ae..0a8d4542 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -146,7 +146,7 @@ class Tenancy $tenants = is_string($tenants) ? [$tenants] : $tenants; // Use all tenants if $tenants is falsey - $tenants = $tenants ?: $this->model()->cursor(); + $tenants = $tenants ?: $this->model()->cursor(); // todo0 phpstan thinks this isn't needed, but tests fail without it $originalTenant = $this->tenant; diff --git a/src/helpers.php b/src/helpers.php index 23b5a627..ac805aa5 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -58,11 +58,15 @@ if (! function_exists('global_cache')) { if (! function_exists('tenant_route')) { function tenant_route(string $domain, string $route, array $parameters = [], bool $absolute = true): string { - // replace the first occurrence of the hostname fragment with $domain $url = route($route, $parameters, $absolute); - $hostname = parse_url($url, PHP_URL_HOST); - $position = strpos($url, $hostname); - return substr_replace($url, $domain, $position, strlen($hostname)); + /** + * The original hostname in the generated route. + * + * @var string $hostname + */ + $hostname = parse_url($url, PHP_URL_HOST); + + return (string) str($url)->replace($hostname, $domain); } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 1c6c6d8a..67029422 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -96,7 +96,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase '--realpath' => true, '--force' => true, ], - 'tenancy.bootstrappers.redis' => \Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, + 'tenancy.bootstrappers.redis' => \Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // todo0 change this to []? two tests in TenantDatabaseManagerTest are failing with that 'queue.connections.central' => [ 'driver' => 'sync', 'central' => true, From 020039bf89f6836c3c948ecc913ac9808cfee0f6 Mon Sep 17 00:00:00 2001 From: PHP CS Fixer Date: Thu, 1 Sep 2022 17:07:17 +0000 Subject: [PATCH 05/48] Fix code style (php-cs-fixer) --- src/Features/CrossDomainRedirect.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Features/CrossDomainRedirect.php b/src/Features/CrossDomainRedirect.php index 4efc767b..a48be6ea 100644 --- a/src/Features/CrossDomainRedirect.php +++ b/src/Features/CrossDomainRedirect.php @@ -14,7 +14,6 @@ class CrossDomainRedirect implements Feature { RedirectResponse::macro('domain', function (string $domain) { /** @var RedirectResponse $this */ - $url = $this->getTargetUrl(); /** From f83504ac6f61e260d5382a5feaac0fa98ef98664 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 2 Sep 2022 17:24:37 +0200 Subject: [PATCH 06/48] [4.x] Add ScoutTenancyBootstrapper (#936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add ScoutTenancyBootstrapper * Fix code style (php-cs-fixer) * extract getTenantPrefix method * Fix code style (php-cs-fixer) Co-authored-by: PHP CS Fixer Co-authored-by: Samuel Štancl --- .../Integrations/ScoutTenancyBootstrapper.php | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/Bootstrappers/Integrations/ScoutTenancyBootstrapper.php diff --git a/src/Bootstrappers/Integrations/ScoutTenancyBootstrapper.php b/src/Bootstrappers/Integrations/ScoutTenancyBootstrapper.php new file mode 100644 index 00000000..49869bb5 --- /dev/null +++ b/src/Bootstrappers/Integrations/ScoutTenancyBootstrapper.php @@ -0,0 +1,42 @@ +config = $config; + } + + public function bootstrap(Tenant $tenant) + { + if (! isset($this->originalScoutPrefix)) { + $this->originalScoutPrefix = $this->config->get('scout.prefix'); + } + + $this->config->set('scout.prefix', $this->getTenantPrefix($tenant)); + } + + public function revert() + { + $this->config->set('scout.prefix', $this->originalScoutPrefix); + } + + protected function getTenantPrefix(Tenant $tenant): string + { + return (string) $tenant->getTenantKey(); + } +} From 3bf2c39e1a38672a2a9345ab46a9076609deb901 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 2 Sep 2022 17:46:27 +0200 Subject: [PATCH 07/48] [4.x] Make impersonation tokens require stateful guards (#935) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Throw an exception on attempt to create impersonation token with a non-stateful guard * Test that impersonation tokens can only be created with a stateful guard * Fix code style (php-cs-fixer) * Escape backslashes in the exception's message Co-authored-by: Samuel Štancl * Make the exception only about requiring a stateful guard Co-authored-by: PHP CS Fixer Co-authored-by: Samuel Štancl --- src/Database/Models/ImpersonationToken.php | 11 +++- .../StatefulGuardRequiredException.php | 15 +++++ tests/TenantUserImpersonationTest.php | 62 ++++++++++++++++--- 3 files changed, 77 insertions(+), 11 deletions(-) create mode 100644 src/Exceptions/StatefulGuardRequiredException.php diff --git a/src/Database/Models/ImpersonationToken.php b/src/Database/Models/ImpersonationToken.php index 8161aca7..05d17ad4 100644 --- a/src/Database/Models/ImpersonationToken.php +++ b/src/Database/Models/ImpersonationToken.php @@ -5,9 +5,12 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\Models; use Carbon\Carbon; +use Illuminate\Contracts\Auth\StatefulGuard; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Str; use Stancl\Tenancy\Database\Concerns\CentralConnection; +use Stancl\Tenancy\Exceptions\StatefulGuardRequiredException; /** * @property string $token @@ -38,9 +41,15 @@ class ImpersonationToken extends Model public static function booted(): void { static::creating(function ($model) { + $authGuard = $model->auth_guard ?? config('auth.defaults.guard'); + + if (! Auth::guard($authGuard) instanceof StatefulGuard) { + throw new StatefulGuardRequiredException($authGuard); + } + $model->created_at = $model->created_at ?? $model->freshTimestamp(); $model->token = $model->token ?? Str::random(128); - $model->auth_guard = $model->auth_guard ?? config('auth.defaults.guard'); + $model->auth_guard = $authGuard; }); } } diff --git a/src/Exceptions/StatefulGuardRequiredException.php b/src/Exceptions/StatefulGuardRequiredException.php new file mode 100644 index 00000000..fe8bef6e --- /dev/null +++ b/src/Exceptions/StatefulGuardRequiredException.php @@ -0,0 +1,15 @@ +artisan('migrate', [ @@ -223,6 +225,46 @@ test('impersonation works with multiple models and guards', function () { }); }); +test('impersonation tokens can be created only with stateful guards', function () { + config([ + 'auth.guards' => [ + 'nonstateful' => [ + 'driver' => 'nonstateful', + 'provider' => 'provider', + ], + 'stateful' => [ + 'driver' => 'session', + 'provider' => 'provider', + ], + ], + 'auth.providers.provider' => [ + 'driver' => 'eloquent', + 'model' => ImpersonationUser::class, + ], + ]); + + $tenant = Tenant::create(); + migrateTenants(); + + $user = $tenant->run(function () { + return ImpersonationUser::create([ + 'name' => 'Joe', + 'email' => 'joe@local', + 'password' => bcrypt('secret'), + ]); + }); + + Auth::extend('nonstateful', fn($app, $name, array $config) => new TokenGuard(Auth::createUserProvider($config['provider']), request())); + + expect(fn() => tenancy()->impersonate($tenant, $user->id, '/dashboard', 'nonstateful')) + ->toThrow(StatefulGuardRequiredException::class); + + Auth::extend('stateful', fn ($app, $name, array $config) => new SessionGuard($name, Auth::createUserProvider($config['provider']), session())); + + expect(tenancy()->impersonate($tenant, $user->id, '/dashboard', 'stateful')) + ->toBeInstanceOf(ImpersonationToken::class); +}); + function migrateTenants() { pest()->artisan('tenants:migrate')->assertExitCode(0); From 409190fae1584d17ee6ed86318c0055d9f239ce6 Mon Sep 17 00:00:00 2001 From: Abrar Ahmad Date: Fri, 2 Sep 2022 21:46:13 +0500 Subject: [PATCH 08/48] [4.x] Improve `tenants:run` command to execute Input\Output commands (#923) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * run command useable for questions asking commands * move console classes to Console directory * fix styling * Update src/Commands/Run.php Co-authored-by: Samuel Štancl * remove tenant migration line * assert command executed in tenant context * improve test * cleanup code * Update CommandsTest.php * remove irrelevant assertions Co-authored-by: Samuel Štancl --- src/Commands/Run.php | 27 +++++++++--- tests/CommandsTest.php | 24 ++++++++++ tests/Etc/{ => Console}/AddUserCommand.php | 3 +- tests/Etc/{ => Console}/ConsoleKernel.php | 3 +- tests/Etc/{ => Console}/ExampleCommand.php | 2 +- tests/Etc/Console/ExampleQuestionCommand.php | 46 ++++++++++++++++++++ tests/TestCase.php | 2 +- 7 files changed, 98 insertions(+), 9 deletions(-) rename tests/Etc/{ => Console}/AddUserCommand.php (91%) rename tests/Etc/{ => Console}/ConsoleKernel.php (72%) rename tests/Etc/{ => Console}/ExampleCommand.php (94%) create mode 100644 tests/Etc/Console/ExampleQuestionCommand.php diff --git a/src/Commands/Run.php b/src/Commands/Run.php index 075f9116..a24fb9c7 100644 --- a/src/Commands/Run.php +++ b/src/Commands/Run.php @@ -5,7 +5,9 @@ declare(strict_types=1); namespace Stancl\Tenancy\Commands; use Illuminate\Console\Command; -use Illuminate\Support\Facades\Artisan; +use Illuminate\Contracts\Console\Kernel; +use Symfony\Component\Console\Input\ArgvInput; +use Symfony\Component\Console\Output\ConsoleOutput; class Run extends Command { @@ -29,12 +31,27 @@ class Run extends Command */ public function handle() { - tenancy()->runForMultiple($this->option('tenants'), function ($tenant) { + $argvInput = $this->ArgvInput(); + tenancy()->runForMultiple($this->option('tenants'), function ($tenant) use ($argvInput) { $this->line("Tenant: {$tenant->getTenantKey()}"); - Artisan::call($this->argument('commandname')); - $this->comment('Command output:'); - $this->info(Artisan::output()); + $this->getLaravel() + ->make(Kernel::class) + ->handle($argvInput, new ConsoleOutput); }); } + + /** + * Get command as ArgvInput instance. + */ + protected function ArgvInput(): ArgvInput + { + // Convert string command to array + $subCommand = explode(' ', $this->argument('commandname')); + + // Add "artisan" as first parameter because ArgvInput expects "artisan" as first parameter and later removes it + array_unshift($subCommand, 'artisan'); + + return new ArgvInput($subCommand); + } } diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index 7415b74f..19018c9a 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -16,6 +16,7 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Tests\Etc\ExampleSeeder; use Stancl\Tenancy\Tests\Etc\Tenant; +use Stancl\Tenancy\Tests\Etc\User; beforeEach(function () { Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { @@ -179,6 +180,29 @@ test('run command with array of tenants works', function () { ->expectsOutput('Tenant: ' . $tenantId2); }); +test('run command works when sub command asks questions and accepts arguments', function () { + $tenant = Tenant::create(); + $id = $tenant->getTenantKey(); + + Artisan::call('tenants:migrate'); + + pest()->artisan("tenants:run --tenants=$id 'user:addwithname Abrar' ") + ->expectsQuestion('What is your email?', 'email@localhost') + ->expectsOutput("Tenant: $id") + ->expectsOutput("User created: Abrar(email@localhost)"); + + // Assert we are in central context + expect(tenancy()->initialized)->toBeFalse(); + + // Assert user was created in tenant context + tenancy()->initialize($tenant); + $user = User::first(); + + // Assert user is same as provided using the command + expect($user->name)->toBe('Abrar'); + expect($user->email)->toBe('email@localhost'); +}); + // todo@tests function runCommandWorks(): void { diff --git a/tests/Etc/AddUserCommand.php b/tests/Etc/Console/AddUserCommand.php similarity index 91% rename from tests/Etc/AddUserCommand.php rename to tests/Etc/Console/AddUserCommand.php index 46e1fcbb..f102bae6 100644 --- a/tests/Etc/AddUserCommand.php +++ b/tests/Etc/Console/AddUserCommand.php @@ -2,12 +2,13 @@ declare(strict_types=1); -namespace Stancl\Tenancy\Tests\Etc; +namespace Stancl\Tenancy\Tests\Etc\Console; use Illuminate\Console\Command; use Illuminate\Support\Str; use Stancl\Tenancy\Concerns\HasATenantsOption; use Stancl\Tenancy\Concerns\TenantAwareCommand; +use Stancl\Tenancy\Tests\Etc\User; class AddUserCommand extends Command { diff --git a/tests/Etc/ConsoleKernel.php b/tests/Etc/Console/ConsoleKernel.php similarity index 72% rename from tests/Etc/ConsoleKernel.php rename to tests/Etc/Console/ConsoleKernel.php index a548f113..c5e5ee85 100644 --- a/tests/Etc/ConsoleKernel.php +++ b/tests/Etc/Console/ConsoleKernel.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Stancl\Tenancy\Tests\Etc; +namespace Stancl\Tenancy\Tests\Etc\Console; use Orchestra\Testbench\Foundation\Console\Kernel; @@ -10,6 +10,7 @@ class ConsoleKernel extends Kernel { protected $commands = [ ExampleCommand::class, + ExampleQuestionCommand::class, AddUserCommand::class, ]; } diff --git a/tests/Etc/ExampleCommand.php b/tests/Etc/Console/ExampleCommand.php similarity index 94% rename from tests/Etc/ExampleCommand.php rename to tests/Etc/Console/ExampleCommand.php index 49e7189b..72263b37 100644 --- a/tests/Etc/ExampleCommand.php +++ b/tests/Etc/Console/ExampleCommand.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Stancl\Tenancy\Tests\Etc; +namespace Stancl\Tenancy\Tests\Etc\Console; use Illuminate\Console\Command; diff --git a/tests/Etc/Console/ExampleQuestionCommand.php b/tests/Etc/Console/ExampleQuestionCommand.php new file mode 100644 index 00000000..9a967054 --- /dev/null +++ b/tests/Etc/Console/ExampleQuestionCommand.php @@ -0,0 +1,46 @@ +ask('What is your email?'); + + User::create([ + 'name' => $this->argument('name'), + 'email' => $email, + 'email_verified_at' => now(), + 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password + 'remember_token' => Str::random(10), + ]); + + $this->line("User created: ". $this->argument('name') . "($email)"); + + return 0; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 67029422..ed567497 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -142,7 +142,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase */ protected function resolveApplicationConsoleKernel($app) { - $app->singleton('Illuminate\Contracts\Console\Kernel', Etc\ConsoleKernel::class); + $app->singleton('Illuminate\Contracts\Console\Kernel', Etc\Console\ConsoleKernel::class); } public function randomString(int $length = 10) From f2c64088ed950f85787e7489b7d56cf40f02c96c Mon Sep 17 00:00:00 2001 From: Abrar Ahmad Date: Fri, 2 Sep 2022 22:04:00 +0500 Subject: [PATCH 09/48] [4.x] Set tenant as a default parameter for the URLs when using Path identification (#925) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * set tenant as default url parameter * Update PathIdentificationTest.php * assertion * test rename * fix tests * fix string Co-authored-by: Samuel Štancl --- src/Middleware/InitializeTenancyByPath.php | 8 +++++++ tests/PathIdentificationTest.php | 26 +++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/Middleware/InitializeTenancyByPath.php b/src/Middleware/InitializeTenancyByPath.php index e66400c5..ae15323c 100644 --- a/src/Middleware/InitializeTenancyByPath.php +++ b/src/Middleware/InitializeTenancyByPath.php @@ -7,6 +7,9 @@ namespace Stancl\Tenancy\Middleware; use Closure; use Illuminate\Http\Request; use Illuminate\Routing\Route; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\URL; +use Stancl\Tenancy\Events\InitializingTenancy; use Stancl\Tenancy\Exceptions\RouteIsMissingTenantParameterException; use Stancl\Tenancy\Resolvers\PathTenantResolver; use Stancl\Tenancy\Tenancy; @@ -37,6 +40,11 @@ class InitializeTenancyByPath extends IdentificationMiddleware // We don't want to initialize tenancy if the tenant is // simply injected into some route controller action. if ($route->parameterNames()[0] === PathTenantResolver::$tenantParameterName) { + // Set tenant as a default parameter for the URLs in the current request + Event::listen(InitializingTenancy::class, function (InitializingTenancy $event) { + URL::defaults([PathTenantResolver::$tenantParameterName => $event->tenancy->tenant->getTenantKey()]); + }); + return $this->initializeTenancy( $request, $next, diff --git a/tests/PathIdentificationTest.php b/tests/PathIdentificationTest.php index bda0cfcb..bfa8f8ad 100644 --- a/tests/PathIdentificationTest.php +++ b/tests/PathIdentificationTest.php @@ -18,7 +18,11 @@ beforeEach(function () { ], function () { Route::get('/foo/{a}/{b}', function ($a, $b) { return "$a + $b"; - }); + })->name('foo'); + + Route::get('/baz/{a}/{b}', function ($a, $b) { + return "$a - $b"; + })->name('baz'); }); }); @@ -123,3 +127,23 @@ test('tenant parameter name can be customized', function () { ->withoutExceptionHandling() ->get('/acme/foo/abc/xyz'); }); + +test('tenant parameter is set for all routes as the default parameter once the tenancy initialized', function () { + Tenant::create([ + 'id' => 'acme', + ]); + + expect(tenancy()->initialized)->toBeFalse(); + + // make a request that will initialize tenancy + pest()->get(route('foo', ['tenant' => 'acme', 'a' => 1, 'b' => 2])); + + expect(tenancy()->initialized)->toBeTrue(); + expect(tenant('id'))->toBe('acme'); + + // assert that the route WITHOUT the tenant parameter matches the route WITH the tenant parameter + expect(route('baz', ['a' => 1, 'b' => 2]))->toBe(route('baz', ['tenant' => 'acme', 'a' => 1, 'b' => 2])); + + expect(route('baz', ['a' => 1, 'b' => 2]))->toBe('http://localhost/acme/baz/1/2'); // assert the full route string + pest()->get(route('baz', ['a' => 1, 'b' => 2]))->assertOk(); // Assert route don't need tenant parameter +}); From abd17f83a18070d70e08567d10dcaa1983f6806e Mon Sep 17 00:00:00 2001 From: Abrar Ahmad Date: Thu, 8 Sep 2022 21:18:59 +0500 Subject: [PATCH 10/48] add mssql health checks (#939) --- docker-compose.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 7b635637..116b48f1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,8 @@ services: condition: service_healthy redis: condition: service_healthy + # mssql: + # condition: service_healthy volumes: - .:/var/www/html:delegated environment: @@ -74,4 +76,8 @@ services: environment: - ACCEPT_EULA=Y - SA_PASSWORD=P@ssword # todo reuse values from env above - # todo missing health check + healthcheck: + test: /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P P@ssword -Q "SELECT 1" -b -o /dev/null + interval: 10s + timeout: 10s + retries: 10 From ab5fa7a2479816feb46a95306d7e4a9da5e654ff Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 20 Sep 2022 19:42:00 +0200 Subject: [PATCH 11/48] [4.x] Optionally delete storage after tenant deletion (#938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- assets/TenancyServiceProvider.stub.php | 2 ++ src/Listeners/DeleteTenantStorage.php | 16 +++++++++++++++ tests/BootstrapperTest.php | 28 +++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 src/Listeners/DeleteTenantStorage.php diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 865bb93d..d8e76e6f 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -46,6 +46,8 @@ class TenancyServiceProvider extends ServiceProvider ])->send(function (Events\DeletingTenant $event) { return $event->tenant; })->shouldBeQueued(false), + + // Listeners\DeleteTenantStorage::class, ], Events\TenantDeleted::class => [ JobPipeline::make([ diff --git a/src/Listeners/DeleteTenantStorage.php b/src/Listeners/DeleteTenantStorage.php new file mode 100644 index 00000000..ce1a4203 --- /dev/null +++ b/src/Listeners/DeleteTenantStorage.php @@ -0,0 +1,16 @@ +tenant->run(fn () => storage_path())); + } +} diff --git a/tests/BootstrapperTest.php b/tests/BootstrapperTest.php index 96afbc83..ada6b964 100644 --- a/tests/BootstrapperTest.php +++ b/tests/BootstrapperTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Support\Str; use Illuminate\Support\Facades\DB; use Stancl\JobPipeline\JobPipeline; +use Illuminate\Support\Facades\File; use Stancl\Tenancy\Tests\Etc\Tenant; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Event; @@ -14,6 +14,7 @@ use Illuminate\Support\Facades\Storage; use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Jobs\CreateDatabase; use Stancl\Tenancy\Events\TenantCreated; +use Illuminate\Filesystem\FilesystemAdapter; use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\RevertToCentralContext; @@ -21,6 +22,8 @@ use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; +use Stancl\Tenancy\Events\DeletingTenant; +use Stancl\Tenancy\Listeners\DeleteTenantStorage; beforeEach(function () { $this->mockConsoleOutput = false; @@ -184,6 +187,29 @@ test('filesystem data is separated', function () { expect($new_storage_path)->toEqual($expected_storage_path); }); +test('tenant storage can get deleted after the tenant when DeletingTenant listens to DeleteTenantStorage', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + ]); + + Event::listen(DeletingTenant::class, DeleteTenantStorage::class); + + tenancy()->initialize(Tenant::create()); + $tenantStoragePath = storage_path(); + + Storage::fake('test'); + + expect(File::isDirectory($tenantStoragePath))->toBeTrue(); + + Storage::put('test.txt', 'testing file'); + + tenant()->delete(); + + expect(File::isDirectory($tenantStoragePath))->toBeFalse(); +}); + function getDiskPrefix(string $disk): string { /** @var FilesystemAdapter $disk */ From 8e3b74f9d13a7ec6fb413e70631764c2da47fd22 Mon Sep 17 00:00:00 2001 From: Abrar Ahmad Date: Sat, 24 Sep 2022 07:08:44 +0500 Subject: [PATCH 12/48] [4.x] Finish incomplete and missing tests (#947) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../SQLiteDatabaseManager.php | 20 +++++++++++++--- tests/CommandsTest.php | 24 +++++++++++++++---- tests/SingleDatabaseTenancyTest.php | 8 +++---- tests/TenantDatabaseManagerTest.php | 20 +++++++++++++++- 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index 59c373a9..ada5d642 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -10,10 +10,15 @@ use Throwable; class SQLiteDatabaseManager implements TenantDatabaseManager { + /** + * SQLite Database path without ending slash. + */ + public static string|null $path = null; + public function createDatabase(TenantWithDatabase $tenant): bool { try { - return file_put_contents(database_path($tenant->database()->getName()), ''); + return (bool) file_put_contents($this->getPath($tenant->database()->getName()), ''); } catch (Throwable) { return false; } @@ -22,7 +27,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager public function deleteDatabase(TenantWithDatabase $tenant): bool { try { - return unlink(database_path($tenant->database()->getName())); + return unlink($this->getPath($tenant->database()->getName())); } catch (Throwable) { return false; } @@ -30,7 +35,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager public function databaseExists(string $name): bool { - return file_exists(database_path($name)); + return file_exists($this->getPath($name)); } public function makeConnectionConfig(array $baseConfig, string $databaseName): array @@ -44,4 +49,13 @@ class SQLiteDatabaseManager implements TenantDatabaseManager { // } + + public function getPath(string $name): string + { + if (static::$path) { + return static::$path . DIRECTORY_SEPARATOR . $name; + } + + return database_path($name); + } } diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index 19018c9a..ebabdb36 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Illuminate\Database\DatabaseManager; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; @@ -16,6 +17,7 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Tests\Etc\ExampleSeeder; use Stancl\Tenancy\Tests\Etc\Tenant; +use Stancl\Tenancy\Tests\Etc\TestSeeder; use Stancl\Tenancy\Tests\Etc\User; beforeEach(function () { @@ -41,9 +43,9 @@ afterEach(function () { test('migrate command doesnt change the db connection', function () { expect(Schema::hasTable('users'))->toBeFalse(); - $old_connection_name = app(\Illuminate\Database\DatabaseManager::class)->connection()->getName(); + $old_connection_name = app(DatabaseManager::class)->connection()->getName(); Artisan::call('tenants:migrate'); - $new_connection_name = app(\Illuminate\Database\DatabaseManager::class)->connection()->getName(); + $new_connection_name = app(DatabaseManager::class)->connection()->getName(); expect(Schema::hasTable('users'))->toBeFalse(); expect($new_connection_name)->toEqual($old_connection_name); @@ -116,8 +118,22 @@ test('rollback command works', function () { expect(Schema::hasTable('users'))->toBeFalse(); }); -// Incomplete test -test('seed command works'); +test('seed command works', function (){ + $tenant = Tenant::create(); + Artisan::call('tenants:migrate'); + + $tenant->run(function (){ + expect(DB::table('users')->count())->toBe(0); + }); + + Artisan::call('tenants:seed', ['--class' => TestSeeder::class]); + + $tenant->run(function (){ + $user = DB::table('users'); + expect($user->count())->toBe(1) + ->and($user->first()->email)->toBe('seeded@user'); + }); +}); test('database connection is switched to default', function () { databaseConnectionSwitchedToDefault(); diff --git a/tests/SingleDatabaseTenancyTest.php b/tests/SingleDatabaseTenancyTest.php index 34b12383..e980e4eb 100644 --- a/tests/SingleDatabaseTenancyTest.php +++ b/tests/SingleDatabaseTenancyTest.php @@ -207,13 +207,13 @@ test('the model returned by the tenant helper has unique and exists validation r $uniqueFails = Validator::make($data, [ 'slug' => 'unique:posts', ])->fails(); - $existsFails = Validator::make($data, [ + $existsPass = Validator::make($data, [ 'slug' => 'exists:posts', - ])->fails(); + ])->passes(); // Assert that 'unique' and 'exists' aren't scoped by default - // pest()->assertFalse($uniqueFails); // todo get these two assertions to pass. for some reason, the validator is passing for both 'unique' and 'exists' - // pest()->assertTrue($existsFails); // todo get these two assertions to pass. for some reason, the validator is passing for both 'unique' and 'exists' + expect($uniqueFails)->toBeTrue(); // Expect unique rule failed to pass because slug 'foo' already exists + expect($existsPass)->toBeTrue(); // Expect exists rule pass because slug 'foo' exists $uniqueFails = Validator::make($data, [ 'slug' => tenant()->unique('posts'), diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index ab25310c..d6a5b369 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -225,7 +225,25 @@ test('tenant database can be created on a foreign server', function () { }); test('path used by sqlite manager can be customized', function () { - pest()->markTestIncomplete(); + Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + + // Set custom path for SQLite file + SQLiteDatabaseManager::$path = $customPath = database_path('custom_' . Str::random(8)); + + if (! is_dir($customPath)) { + // Create custom directory + mkdir($customPath); + } + + $name = Str::random(8). '.sqlite'; + Tenant::create([ + 'tenancy_db_name' => $name, + 'tenancy_db_connection' => 'sqlite', + ]); + + expect(file_exists( $customPath . '/' . $name))->toBeTrue(); }); // Datasets From b78320b882a1836a704eb5b83ebf6538ef026868 Mon Sep 17 00:00:00 2001 From: Riley19280 Date: Mon, 26 Sep 2022 08:13:58 -0400 Subject: [PATCH 13/48] [4.x] Add batch tenancy queue bootstrapper (#874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- assets/config.php | 1 + .../BatchTenancyBootstrapper.php | 41 +++++++++++++++++ tests/BatchTest.php | 44 +++++++++++++++++++ tests/TestCase.php | 25 +++++++---- 4 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 src/Bootstrappers/BatchTenancyBootstrapper.php create mode 100644 tests/BatchTest.php diff --git a/assets/config.php b/assets/config.php index 2a54e0b9..68a95440 100644 --- a/assets/config.php +++ b/assets/config.php @@ -32,6 +32,7 @@ return [ Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper::class, Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class, Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class, + Stancl\Tenancy\Bootstrappers\BatchTenancyBootstrapper::class, // Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed ], diff --git a/src/Bootstrappers/BatchTenancyBootstrapper.php b/src/Bootstrappers/BatchTenancyBootstrapper.php new file mode 100644 index 00000000..ccd1c00a --- /dev/null +++ b/src/Bootstrappers/BatchTenancyBootstrapper.php @@ -0,0 +1,41 @@ +previousConnection = $this->batchRepository->getConnection(); + $this->batchRepository->setConnection($this->databaseManager->connection('tenant')); + } + + public function revert() + { + if ($this->previousConnection) { + // Replace batch repository connection with the previously replaced one + $this->batchRepository->setConnection($this->previousConnection); + $this->previousConnection = null; + } + } +} diff --git a/tests/BatchTest.php b/tests/BatchTest.php new file mode 100644 index 00000000..a168deb2 --- /dev/null +++ b/tests/BatchTest.php @@ -0,0 +1,44 @@ + [ + DatabaseTenancyBootstrapper::class, + BatchTenancyBootstrapper::class, + ], + ]); + + Event::listen(TenancyInitialized::class, BootstrapTenancy::class); + Event::listen(TenancyEnded::class, RevertToCentralContext::class); +}); + +test('batch repository is set to tenant connection and reverted', function () { + $tenant = Tenant::create(); + $tenant2 = Tenant::create(); + + expect(getBatchRepositoryConnectionName())->toBe('central'); + + tenancy()->initialize($tenant); + expect(getBatchRepositoryConnectionName())->toBe('tenant'); + + tenancy()->initialize($tenant2); + expect(getBatchRepositoryConnectionName())->toBe('tenant'); + + tenancy()->end(); + expect(getBatchRepositoryConnectionName())->toBe('central'); +})->skip(fn() => version_compare(app()->version(), '8.0', '<'), 'Job batches are only supported in Laravel 8+'); + +function getBatchRepositoryConnectionName() +{ + return app(BatchRepository::class)->getConnection()->getName(); +} diff --git a/tests/TestCase.php b/tests/TestCase.php index ed567497..f7f8b9ad 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,8 +4,15 @@ declare(strict_types=1); namespace Stancl\Tenancy\Tests; +use Dotenv\Dotenv; +use Illuminate\Foundation\Application; use Illuminate\Support\Facades\Redis; use PDO; +use Stancl\Tenancy\Bootstrappers\BatchTenancyBootstrapper; +use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; +use Stancl\Tenancy\Facades\GlobalCache; +use Stancl\Tenancy\Facades\Tenancy; +use Stancl\Tenancy\TenancyServiceProvider; use Stancl\Tenancy\Tests\Etc\Tenant; abstract class TestCase extends \Orchestra\Testbench\TestCase @@ -42,13 +49,13 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase /** * Define environment setup. * - * @param \Illuminate\Foundation\Application $app + * @param Application $app * @return void */ protected function getEnvironmentSetUp($app) { if (file_exists(__DIR__ . '/../.env')) { - \Dotenv\Dotenv::createImmutable(__DIR__ . '/..')->load(); + Dotenv::createImmutable(__DIR__ . '/..')->load(); } $app['config']->set([ @@ -96,7 +103,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase '--realpath' => true, '--force' => true, ], - 'tenancy.bootstrappers.redis' => \Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // todo0 change this to []? two tests in TenantDatabaseManagerTest are failing with that + 'tenancy.bootstrappers.redis' => RedisTenancyBootstrapper::class, // todo0 change this to []? two tests in TenantDatabaseManagerTest are failing with that 'queue.connections.central' => [ 'driver' => 'sync', 'central' => true, @@ -105,28 +112,28 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase 'tenancy.tenant_model' => Tenant::class, // Use test tenant w/ DBs & domains ]); - $app->singleton(\Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class); + $app->singleton(RedisTenancyBootstrapper::class); // todo (Samuel) use proper approach eg config for singleton registration } protected function getPackageProviders($app) { return [ - \Stancl\Tenancy\TenancyServiceProvider::class, + TenancyServiceProvider::class, ]; } protected function getPackageAliases($app) { return [ - 'Tenancy' => \Stancl\Tenancy\Facades\Tenancy::class, - 'GlobalCache' => \Stancl\Tenancy\Facades\GlobalCache::class, + 'Tenancy' => Tenancy::class, + 'GlobalCache' => GlobalCache::class, ]; } /** * Resolve application HTTP Kernel implementation. * - * @param \Illuminate\Foundation\Application $app + * @param Application $app * @return void */ protected function resolveApplicationHttpKernel($app) @@ -137,7 +144,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase /** * Resolve application Console Kernel implementation. * - * @param \Illuminate\Foundation\Application $app + * @param Application $app * @return void */ protected function resolveApplicationConsoleKernel($app) From 7bacc50b27993ea70812201ba51c0697177c4420 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Wed, 28 Sep 2022 05:09:45 +0200 Subject: [PATCH 14/48] [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 --- assets/TenancyServiceProvider.stub.php | 8 ++ assets/config.php | 18 +++ src/Actions/CreateStorageSymlinksAction.php | 55 +++++++ src/Actions/RemoveStorageSymlinksAction.php | 40 ++++++ .../FilesystemTenancyBootstrapper.php | 30 +++- src/Commands/Link.php | 73 ++++++++++ src/Concerns/DealsWithTenantSymlinks.php | 44 ++++++ src/Events/CreatingStorageSymlink.php | 9 ++ src/Events/RemovingStorageSymlink.php | 9 ++ src/Events/StorageSymlinkCreated.php | 9 ++ src/Events/StorageSymlinkRemoved.php | 9 ++ src/Jobs/CreateStorageSymlinks.php | 40 ++++++ src/Jobs/RemoveStorageSymlinks.php | 40 ++++++ src/TenancyServiceProvider.php | 1 + tests/ActionTest.php | 69 +++++++++ tests/BootstrapperTest.php | 134 ++++++++++++++++-- tests/CommandsTest.php | 46 ++++++ tests/SingleDatabaseTenancyTest.php | 2 +- 18 files changed, 622 insertions(+), 14 deletions(-) create mode 100644 src/Actions/CreateStorageSymlinksAction.php create mode 100644 src/Actions/RemoveStorageSymlinksAction.php create mode 100644 src/Commands/Link.php create mode 100644 src/Concerns/DealsWithTenantSymlinks.php create mode 100644 src/Events/CreatingStorageSymlink.php create mode 100644 src/Events/RemovingStorageSymlink.php create mode 100644 src/Events/StorageSymlinkCreated.php create mode 100644 src/Events/StorageSymlinkRemoved.php create mode 100644 src/Jobs/CreateStorageSymlinks.php create mode 100644 src/Jobs/RemoveStorageSymlinks.php create mode 100644 tests/ActionTest.php diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index d8e76e6f..a3626225 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -28,6 +28,7 @@ class TenancyServiceProvider extends ServiceProvider Jobs\CreateDatabase::class, Jobs\MigrateDatabase::class, // Jobs\SeedDatabase::class, + Jobs\CreateStorageSymlinks::class, // Your own jobs to prepare the tenant. // Provision API keys, create S3 buckets, anything you want! @@ -52,6 +53,7 @@ class TenancyServiceProvider extends ServiceProvider Events\TenantDeleted::class => [ JobPipeline::make([ Jobs\DeleteDatabase::class, + Jobs\RemoveStorageSymlinks::class, ])->send(function (Events\TenantDeleted $event) { return $event->tenant; })->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production. @@ -95,6 +97,12 @@ class TenancyServiceProvider extends ServiceProvider Listeners\UpdateSyncedResource::class, ], + // Storage symlinks + Events\CreatingStorageSymlink::class => [], + Events\StorageSymlinkCreated::class => [], + Events\RemovingStorageSymlink::class => [], + Events\StorageSymlinkRemoved::class => [], + // Fired only when a synced resource is changed in a different DB than the origin DB (to avoid infinite loops) Events\SyncedResourceChangedInForeignDatabase::class => [], ]; diff --git a/assets/config.php b/assets/config.php index 68a95440..7aff2b65 100644 --- a/assets/config.php +++ b/assets/config.php @@ -119,6 +119,24 @@ return [ 'public' => '%storage_path%/app/public/', ], + /* + * Tenant-aware Storage::disk()->url() can be enabled for specific local disks here + * by mapping the disk's name to a name with '%tenant_id%' (this will be used as the public name of the disk). + * Doing that will override the disk's default URL with a URL containing the current tenant's key. + * + * For example, Storage::disk('public')->url('') will return https://your-app.test/storage/ by default. + * After adding 'public' => 'public-%tenant_id%' to 'url_override', + * the returned URL will be https://your-app.test/public-1/ (%tenant_id% gets substitued by the current tenant's ID). + * + * Use `php artisan tenants:link` to create a symbolic link from the tenant's storage to its public directory. + */ + 'url_override' => [ + // Note that the local disk you add must exist in the tenancy.filesystem.root_override config + // todo@v4 Rename %tenant_id% to %tenant_key% + // todo@v4 Rename url_override to something that describes the config key better + 'public' => 'public-%tenant_id%', + ], + /** * Should storage_path() be suffixed. * diff --git a/src/Actions/CreateStorageSymlinksAction.php b/src/Actions/CreateStorageSymlinksAction.php new file mode 100644 index 00000000..779a42af --- /dev/null +++ b/src/Actions/CreateStorageSymlinksAction.php @@ -0,0 +1,55 @@ + $storagePath) { + static::createLink($publicPath, $storagePath, $tenant, $relativeLink, $force); + } + } + } + + protected static function createLink(string $publicPath, string $storagePath, Tenant $tenant, bool $relativeLink, bool $force): void + { + event(new CreatingStorageSymlink($tenant)); + + if (static::symlinkExists($publicPath)) { + // If $force isn't passed, don't overwrite the existing symlink + throw_if(! $force, new Exception("The [$publicPath] link already exists.")); + + app()->make('files')->delete($publicPath); + } + + // Make sure the storage path exists before we create a symlink + if (! is_dir($storagePath)) { + mkdir($storagePath, 0777, true); + } + + if ($relativeLink) { + app()->make('files')->relativeLink($storagePath, $publicPath); + } else { + app()->make('files')->link($storagePath, $publicPath); + } + + event((new StorageSymlinkCreated($tenant))); + } +} diff --git a/src/Actions/RemoveStorageSymlinksAction.php b/src/Actions/RemoveStorageSymlinksAction.php new file mode 100644 index 00000000..bfbcfa0a --- /dev/null +++ b/src/Actions/RemoveStorageSymlinksAction.php @@ -0,0 +1,40 @@ + $storagePath) { + static::removeLink($publicPath, $tenant); + } + } + } + + protected static function removeLink(string $publicPath, Tenant $tenant): void + { + if (static::symlinkExists($publicPath)) { + event(new RemovingStorageSymlink($tenant)); + + app()->make('files')->delete($publicPath); + + event(new StorageSymlinkRemoved($tenant)); + } + } +} diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index 6f720e7c..d90d36d0 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -57,9 +57,10 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper foreach ($this->app['config']['tenancy.filesystem.disks'] as $disk) { // todo@v4 \League\Flysystem\PathPrefixer is making this a lot more painful in flysystem v2 + $diskConfig = $this->app['config']["filesystems.disks.{$disk}"]; + $originalRoot = $diskConfig['root'] ?? null; - $originalRoot = $this->app['config']["filesystems.disks.{$disk}.root"]; - $this->originalPaths['disks'][$disk] = $originalRoot; + $this->originalPaths['disks']['path'][$disk] = $originalRoot; $finalPrefix = str_replace( ['%storage_path%', '%tenant%'], @@ -74,6 +75,19 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper } $this->app['config']["filesystems.disks.{$disk}.root"] = $finalPrefix; + + // Storage Url + if ($diskConfig['driver'] === 'local') { + $this->originalPaths['disks']['url'][$disk] = $diskConfig['url'] ?? null; + + if ($url = str_replace( + '%tenant_id%', + $tenant->getTenantKey(), + $this->app['config']["tenancy.filesystem.url_override.{$disk}"] ?? '' + )) { + $this->app['config']["filesystems.disks.{$disk}.url"] = url($url); + } + } } } @@ -88,8 +102,16 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper // Storage facade Storage::forgetDisk($this->app['config']['tenancy.filesystem.disks']); - foreach ($this->app['config']['tenancy.filesystem.disks'] as $disk) { - $this->app['config']["filesystems.disks.{$disk}.root"] = $this->originalPaths['disks'][$disk]; + foreach ($this->app['config']['tenancy.filesystem.disks'] as $diskName) { + $this->app['config']["filesystems.disks.$diskName.root"] = $this->originalPaths['disks']['path'][$diskName]; + $diskConfig = $this->app['config']['filesystems.disks.' . $diskName]; + + // Storage Url + $url = $this->originalPaths['disks.url.' . $diskName] ?? null; + + if ($diskConfig['driver'] === 'local' && ! is_null($url)) { + $$this->app['config']["filesystems.disks.$diskName.url"] = $url; + } } } } diff --git a/src/Commands/Link.php b/src/Commands/Link.php new file mode 100644 index 00000000..061f2d3d --- /dev/null +++ b/src/Commands/Link.php @@ -0,0 +1,73 @@ +getTenants(); + + try { + if ($this->option('remove')) { + $this->removeLinks($tenants); + } else { + $this->createLinks($tenants); + } + } catch (Exception $exception) { + $this->error($exception->getMessage()); + } + } + + protected function removeLinks(LazyCollection $tenants): void + { + RemoveStorageSymlinksAction::handle($tenants); + + $this->info('The links have been removed.'); + } + + protected function createLinks(LazyCollection $tenants): void + { + CreateStorageSymlinksAction::handle( + $tenants, + $this->option('relative') ?? false, + $this->option('force') ?? false, + ); + + $this->info('The links have been created.'); + } +} diff --git a/src/Concerns/DealsWithTenantSymlinks.php b/src/Concerns/DealsWithTenantSymlinks.php new file mode 100644 index 00000000..a4c972bb --- /dev/null +++ b/src/Concerns/DealsWithTenantSymlinks.php @@ -0,0 +1,44 @@ + 'storage path']). + * + * Tenants can have a symlink for each disk registered in the tenancy.filesystem.url_override config. + * + * This is used for creating all possible tenant symlinks and removing all existing tenant symlinks. + */ + protected static function possibleTenantSymlinks(Tenant $tenant): Collection + { + $diskUrls = config('tenancy.filesystem.url_override'); + $disks = config('tenancy.filesystem.root_override'); + $suffixBase = config('tenancy.filesystem.suffix_base'); + $symlinks = collect(); + $tenantKey = $tenant->getTenantKey(); + + foreach ($diskUrls as $disk => $publicPath) { + $storagePath = str_replace('%storage_path%', $suffixBase . $tenantKey, $disks[$disk]); + $publicPath = str_replace('%tenant_id%', $tenantKey, $publicPath); + + tenancy()->central(function () use ($symlinks, $publicPath, $storagePath) { + $symlinks->push([public_path($publicPath) => storage_path($storagePath)]); + }); + } + + return $symlinks->mapWithKeys(fn ($item) => $item); + } + + /** Determine if the provided path is an existing symlink. */ + protected static function symlinkExists(string $link): bool + { + return file_exists($link) && is_link($link); + } +} diff --git a/src/Events/CreatingStorageSymlink.php b/src/Events/CreatingStorageSymlink.php new file mode 100644 index 00000000..13937174 --- /dev/null +++ b/src/Events/CreatingStorageSymlink.php @@ -0,0 +1,9 @@ +tenant = $tenant; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + CreateStorageSymlinksAction::handle($this->tenant); + } +} diff --git a/src/Jobs/RemoveStorageSymlinks.php b/src/Jobs/RemoveStorageSymlinks.php new file mode 100644 index 00000000..3022da79 --- /dev/null +++ b/src/Jobs/RemoveStorageSymlinks.php @@ -0,0 +1,40 @@ +tenant = $tenant; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + RemoveStorageSymlinksAction::handle($this->tenant); + } +} diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index 3850720c..b8eee487 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -78,6 +78,7 @@ class TenancyServiceProvider extends ServiceProvider { $this->commands([ Commands\Run::class, + Commands\Link::class, Commands\Seed::class, Commands\Install::class, Commands\Migrate::class, diff --git a/tests/ActionTest.php b/tests/ActionTest.php new file mode 100644 index 00000000..cc0950ea --- /dev/null +++ b/tests/ActionTest.php @@ -0,0 +1,69 @@ + [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.filesystem.suffix_base' => 'tenant-', + 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + 'tenancy.filesystem.url_override.public' => 'public-%tenant_id%' + ]); + + /** @var Tenant $tenant */ + $tenant = Tenant::create(); + $tenantKey = $tenant->getTenantKey(); + + tenancy()->initialize($tenant); + + $this->assertDirectoryDoesNotExist($publicPath = public_path("public-$tenantKey")); + + CreateStorageSymlinksAction::handle($tenant); + + $this->assertDirectoryExists($publicPath); + $this->assertEquals(storage_path("app/public/"), readlink($publicPath)); +}); + +test('remove storage symlinks action works', function() { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.filesystem.suffix_base' => 'tenant-', + 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + 'tenancy.filesystem.url_override.public' => 'public-%tenant_id%' + ]); + + /** @var Tenant $tenant */ + $tenant = Tenant::create(); + $tenantKey = $tenant->getTenantKey(); + + tenancy()->initialize($tenant); + + CreateStorageSymlinksAction::handle($tenant); + + $this->assertDirectoryExists($publicPath = public_path("public-$tenantKey")); + + RemoveStorageSymlinksAction::handle($tenant); + + $this->assertDirectoryDoesNotExist($publicPath); +}); diff --git a/tests/BootstrapperTest.php b/tests/BootstrapperTest.php index ada6b964..a610fbd2 100644 --- a/tests/BootstrapperTest.php +++ b/tests/BootstrapperTest.php @@ -14,16 +14,19 @@ use Illuminate\Support\Facades\Storage; use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Jobs\CreateDatabase; use Stancl\Tenancy\Events\TenantCreated; +use Stancl\Tenancy\Events\TenantDeleted; +use Stancl\Tenancy\Events\DeletingTenant; use Illuminate\Filesystem\FilesystemAdapter; use Stancl\Tenancy\Events\TenancyInitialized; +use Stancl\Tenancy\Jobs\CreateStorageSymlinks; +use Stancl\Tenancy\Jobs\RemoveStorageSymlinks; use Stancl\Tenancy\Listeners\BootstrapTenancy; +use Stancl\Tenancy\Listeners\DeleteTenantStorage; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; -use Stancl\Tenancy\Events\DeletingTenant; -use Stancl\Tenancy\Listeners\DeleteTenantStorage; beforeEach(function () { $this->mockConsoleOutput = false; @@ -192,8 +195,121 @@ test('tenant storage can get deleted after the tenant when DeletingTenant listen 'tenancy.bootstrappers' => [ FilesystemTenancyBootstrapper::class, ], + 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + 'tenancy.filesystem.url_override.public' => 'public-%tenant_id%' ]); + $tenant1 = Tenant::create(); + $tenant2 = Tenant::create(); + $tenant1StorageUrl = 'http://localhost/public-' . $tenant1->getKey().'/'; + $tenant2StorageUrl = 'http://localhost/public-' . $tenant2->getKey().'/'; + + tenancy()->initialize($tenant1); + + $this->assertEquals( + $tenant1StorageUrl, + Storage::disk('public')->url('') + ); + + Storage::disk('public')->put($tenant1FileName = 'tenant1.txt', 'text'); + + $this->assertEquals( + $tenant1StorageUrl . $tenant1FileName, + Storage::disk('public')->url($tenant1FileName) + ); + + tenancy()->initialize($tenant2); + + $this->assertEquals( + $tenant2StorageUrl, + Storage::disk('public')->url('') + ); + + Storage::disk('public')->put($tenant2FileName = 'tenant2.txt', 'text'); + + $this->assertEquals( + $tenant2StorageUrl . $tenant2FileName, + Storage::disk('public')->url($tenant2FileName) + ); +}); + +test('files can get fetched using the storage url', function() { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + 'tenancy.filesystem.url_override.public' => 'public-%tenant_id%' + ]); + + $tenant1 = Tenant::create(); + $tenant2 = Tenant::create(); + + pest()->artisan('tenants:link'); + + // First tenant + tenancy()->initialize($tenant1); + Storage::disk('public')->put($tenantFileName = 'tenant1.txt', $tenantKey = $tenant1->getTenantKey()); + + $url = Storage::disk('public')->url($tenantFileName); + $tenantDiskName = Str::of(config('tenancy.filesystem.url_override.public'))->replace('%tenant_id%', $tenantKey); + $hostname = Str::of($url)->before($tenantDiskName); + $parsedUrl = Str::of($url)->after($hostname); + + expect(file_get_contents(public_path($parsedUrl)))->toBe($tenantKey); + + // Second tenant + tenancy()->initialize($tenant2); + Storage::disk('public')->put($tenantFileName = 'tenant2.txt', $tenantKey = $tenant2->getTenantKey()); + + $url = Storage::disk('public')->url($tenantFileName); + $tenantDiskName = Str::of(config('tenancy.filesystem.url_override.public'))->replace('%tenant_id%', $tenantKey); + $hostname = Str::of($url)->before($tenantDiskName); + $parsedUrl = Str::of($url)->after($hostname); + + expect(file_get_contents(public_path($parsedUrl)))->toBe($tenantKey); +}); + +test('create and delete storage symlinks jobs work', function() { + Event::listen( + TenantCreated::class, + JobPipeline::make([CreateStorageSymlinks::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener() + ); + + Event::listen( + TenantDeleted::class, + JobPipeline::make([RemoveStorageSymlinks::class])->send(function (TenantDeleted $event) { + return $event->tenant; + })->toListener() + ); + + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.filesystem.suffix_base' => 'tenant-', + 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + 'tenancy.filesystem.url_override.public' => 'public-%tenant_id%' + ]); + + /** @var Tenant $tenant */ + $tenant = Tenant::create(); + + tenancy()->initialize($tenant); + + $tenantKey = $tenant->getTenantKey(); + + $this->assertDirectoryExists(storage_path("app/public")); + $this->assertEquals(storage_path("app/public/"), readlink(public_path("public-$tenantKey"))); + + $tenant->delete(); + + $this->assertDirectoryDoesNotExist(public_path("public-$tenantKey")); +}); + +test('local storage public urls are generated correctly', function() { Event::listen(DeletingTenant::class, DeleteTenantStorage::class); tenancy()->initialize(Tenant::create()); @@ -220,14 +336,14 @@ function getDiskPrefix(string $disk): string return $adapter->getPathPrefix(); } - $prefixer = (new ReflectionObject($adapter))->getProperty('prefixer'); - $prefixer->setAccessible(true); + $prefixer = (new ReflectionObject($adapter))->getProperty('prefixer'); + $prefixer->setAccessible(true); - // reflection -> instance - $prefixer = $prefixer->getValue($adapter); + // reflection -> instance + $prefixer = $prefixer->getValue($adapter); - $prefix = (new ReflectionProperty($prefixer, 'prefix')); - $prefix->setAccessible(true); + $prefix = (new ReflectionProperty($prefixer, 'prefix')); + $prefix->setAccessible(true); - return $prefix->getValue($prefixer); + return $prefix->getValue($prefixer); } diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index ebabdb36..f7785bf2 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -29,6 +29,15 @@ beforeEach(function () { DatabaseTenancyBootstrapper::class, ]]); + config([ + 'tenancy.bootstrappers' => [ + DatabaseTenancyBootstrapper::class, + ], + 'tenancy.filesystem.suffix_base' => 'tenant-', + 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + 'tenancy.filesystem.url_override.public' => 'public-%tenant_id%' + ]); + Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class); }); @@ -196,6 +205,43 @@ test('run command with array of tenants works', function () { ->expectsOutput('Tenant: ' . $tenantId2); }); +test('link command works', function() { + $tenantId1 = Tenant::create()->getTenantKey(); + $tenantId2 = Tenant::create()->getTenantKey(); + pest()->artisan('tenants:link'); + + $this->assertDirectoryExists(storage_path("tenant-$tenantId1/app/public")); + $this->assertEquals(storage_path("tenant-$tenantId1/app/public/"), readlink(public_path("public-$tenantId1"))); + + $this->assertDirectoryExists(storage_path("tenant-$tenantId2/app/public")); + $this->assertEquals(storage_path("tenant-$tenantId2/app/public/"), readlink(public_path("public-$tenantId2"))); + + pest()->artisan('tenants:link', [ + '--remove' => true, + ]); + + $this->assertDirectoryDoesNotExist(public_path("public-$tenantId1")); + $this->assertDirectoryDoesNotExist(public_path("public-$tenantId2")); +}); + +test('link command works with a specified tenant', function() { + $tenantKey = Tenant::create()->getTenantKey(); + + pest()->artisan('tenants:link', [ + '--tenants' => [$tenantKey], + ]); + + $this->assertDirectoryExists(storage_path("tenant-$tenantKey/app/public")); + $this->assertEquals(storage_path("tenant-$tenantKey/app/public/"), readlink(public_path("public-$tenantKey"))); + + pest()->artisan('tenants:link', [ + '--remove' => true, + '--tenants' => [$tenantKey], + ]); + + $this->assertDirectoryDoesNotExist(public_path("public-$tenantKey")); +}); + test('run command works when sub command asks questions and accepts arguments', function () { $tenant = Tenant::create(); $id = $tenant->getTenantKey(); diff --git a/tests/SingleDatabaseTenancyTest.php b/tests/SingleDatabaseTenancyTest.php index e980e4eb..8914a6d7 100644 --- a/tests/SingleDatabaseTenancyTest.php +++ b/tests/SingleDatabaseTenancyTest.php @@ -61,7 +61,7 @@ test('secondary models are not scoped to the current tenant when accessed direct expect(Comment::count())->toBe(2); }); -test('secondary models a r e scoped to the current tenant when accessed directly and parent relationship traitis used', function () { +test('secondary models ARE scoped to the current tenant when accessed directly and parent relationship trait is used', function () { $acme = Tenant::create([ 'id' => 'acme', ]); From fd65cf1754f78f07da8a1a40b3b7ef74333fa23c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Wed, 28 Sep 2022 16:35:10 +0200 Subject: [PATCH 15/48] add todo --- src/Controllers/TenantAssetsController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controllers/TenantAssetsController.php b/src/Controllers/TenantAssetsController.php index 03d600d0..7e032c2c 100644 --- a/src/Controllers/TenantAssetsController.php +++ b/src/Controllers/TenantAssetsController.php @@ -8,7 +8,7 @@ use Closure; use Illuminate\Routing\Controller; use Throwable; -class TenantAssetsController extends Controller +class TenantAssetsController extends Controller // todo rename this to TenantAssetController & update references in docs { public static string|array|Closure $tenancyMiddleware = Stancl\Tenancy\Middleware\InitializeTenancyByDomain::class; From 87212e5390a463b78eb629e55728265ea8ea791d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 29 Sep 2022 02:47:13 +0200 Subject: [PATCH 16/48] phpstan, global_cache, resolver improvements, InitializationHelpers trait --- phpstan.neon | 9 +++++ src/Commands/Install.php | 15 +------- src/Commands/Link.php | 17 +--------- src/Commands/Migrate.php | 14 ++++---- src/Commands/MigrateFresh.php | 15 ++------ src/Commands/Rollback.php | 32 ++++++----------- src/Commands/Run.php | 22 ++---------- src/Commands/Seed.php | 19 +++-------- src/Commands/TenantList.php | 15 +------- src/Concerns/DealsWithMigrations.php | 4 +-- src/Contracts/UniqueIdentifierGenerator.php | 6 ++-- src/Database/Concerns/HasDomains.php | 2 ++ .../Concerns/InitializationHelpers.php | 19 +++++++++++ src/Database/Models/Tenant.php | 1 + src/Events/Contracts/TenancyEvent.php | 10 ++---- src/Middleware/IdentificationMiddleware.php | 14 ++++---- src/Middleware/InitializeTenancyByDomain.php | 27 +++++---------- .../InitializeTenancyByDomainOrSubdomain.php | 9 ++--- src/Middleware/InitializeTenancyByPath.php | 21 ++++-------- .../InitializeTenancyByRequestData.php | 34 +++++-------------- .../InitializeTenancyBySubdomain.php | 12 +++---- .../PreventAccessFromCentralDomains.php | 7 ++-- src/Middleware/ScopeSessions.php | 3 +- .../Contracts/CachedTenantResolver.php | 2 +- src/Resolvers/DomainTenantResolver.php | 3 +- src/Resolvers/PathTenantResolver.php | 4 +-- src/Resolvers/RequestDataTenantResolver.php | 4 +-- src/UUIDGenerator.php | 3 +- src/helpers.php | 34 +++++++++++++++++-- tests/DomainTest.php | 2 +- tests/Etc/Tenant.php | 3 ++ tests/GlobalCacheTest.php | 14 ++++++++ tests/PathIdentificationTest.php | 2 +- tests/RequestDataIdentificationTest.php | 1 - tests/SubdomainTest.php | 2 +- 35 files changed, 170 insertions(+), 231 deletions(-) create mode 100644 src/Database/Concerns/InitializationHelpers.php diff --git a/phpstan.neon b/phpstan.neon index 9ff082dd..f325f3ec 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -10,6 +10,7 @@ parameters: universalObjectCratesClasses: - Illuminate\Routing\Route + - Illuminate\Database\Eloquent\Model ignoreErrors: - @@ -20,6 +21,14 @@ parameters: message: '#invalid type Laravel\\Telescope\\IncomingEntry#' paths: - src/Features/TelescopeTags.php + - + message: '#Parameter \#1 \$key of method Illuminate\\Contracts\\Cache\\Repository::put\(\) expects string#' + paths: + - src/helpers.php + - + message: '#PHPDoc tag \@param has invalid value \(dynamic#' + paths: + - src/helpers.php checkMissingIterableValueType: false treatPhpDocTypesAsCertain: false diff --git a/src/Commands/Install.php b/src/Commands/Install.php index 41492b26..12a2c2c9 100644 --- a/src/Commands/Install.php +++ b/src/Commands/Install.php @@ -8,24 +8,11 @@ use Illuminate\Console\Command; class Install extends Command { - /** - * The name and signature of the console command. - * - * @var string - */ protected $signature = 'tenancy:install'; - /** - * The console command description. - * - * @var string - */ protected $description = 'Install stancl/tenancy.'; - /** - * Execute the console command. - */ - public function handle() + public function handle(): void { $this->comment('Installing stancl/tenancy...'); $this->callSilent('vendor:publish', [ diff --git a/src/Commands/Link.php b/src/Commands/Link.php index 061f2d3d..2b9ee4cf 100644 --- a/src/Commands/Link.php +++ b/src/Commands/Link.php @@ -15,30 +15,15 @@ class Link extends Command { use HasATenantsOption; - /** - * The console command signature. - * - * @var string - */ protected $signature = 'tenants:link {--tenants=* : The tenant(s) to run the command for. Default: all} {--relative : Create the symbolic link using relative paths} {--force : Recreate existing symbolic links} {--remove : Remove symbolic links}'; - /** - * The console command description. - * - * @var string - */ protected $description = 'Create or remove tenant symbolic links.'; - /** - * Execute the console command. - * - * @return void - */ - public function handle() + public function handle(): void { $tenants = $this->getTenants(); diff --git a/src/Commands/Migrate.php b/src/Commands/Migrate.php index 52ecd47f..739b56de 100644 --- a/src/Commands/Migrate.php +++ b/src/Commands/Migrate.php @@ -7,7 +7,6 @@ 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\Events\DatabaseMigrated; @@ -15,7 +14,7 @@ use Stancl\Tenancy\Events\MigratingDatabase; class Migrate extends MigrateCommand { - use HasATenantsOption, DealsWithMigrations, ExtendsLaravelCommand; + use HasATenantsOption, ExtendsLaravelCommand; protected $description = 'Run migrations for tenant(s)'; @@ -31,10 +30,7 @@ class Migrate extends MigrateCommand $this->specifyParameters(); } - /** - * Execute the console command. - */ - public function handle() + public function handle(): int { foreach (config('tenancy.migration_parameters') as $parameter => $value) { if (! $this->input->hasParameterOption($parameter)) { @@ -43,10 +39,10 @@ class Migrate extends MigrateCommand } if (! $this->confirmToProceed()) { - return; + return 1; } - tenancy()->runForMultiple($this->option('tenants'), function ($tenant) { + tenancy()->runForMultiple($this->getTenants(), function ($tenant) { $this->line("Tenant: {$tenant->getTenantKey()}"); event(new MigratingDatabase($tenant)); @@ -56,5 +52,7 @@ class Migrate extends MigrateCommand event(new DatabaseMigrated($tenant)); }); + + return 0; } } diff --git a/src/Commands/MigrateFresh.php b/src/Commands/MigrateFresh.php index 63860153..56a6047f 100644 --- a/src/Commands/MigrateFresh.php +++ b/src/Commands/MigrateFresh.php @@ -5,19 +5,13 @@ declare(strict_types=1); namespace Stancl\Tenancy\Commands; use Illuminate\Console\Command; -use Stancl\Tenancy\Concerns\DealsWithMigrations; use Stancl\Tenancy\Concerns\HasATenantsOption; use Symfony\Component\Console\Input\InputOption; final class MigrateFresh extends Command { - use HasATenantsOption, DealsWithMigrations; + use HasATenantsOption; - /** - * The console command description. - * - * @var string - */ protected $description = 'Drop all tables and re-run all migrations for tenant(s)'; public function __construct() @@ -29,12 +23,9 @@ final class MigrateFresh extends Command $this->setName('tenants:migrate-fresh'); } - /** - * Execute the console command. - */ - public function handle() + public function handle(): void { - tenancy()->runForMultiple($this->option('tenants'), function ($tenant) { + tenancy()->runForMultiple($this->getTenants(), function ($tenant) { $this->info('Dropping tables.'); $this->call('db:wipe', array_filter([ '--database' => 'tenant', diff --git a/src/Commands/Rollback.php b/src/Commands/Rollback.php index 1c434189..d61083d4 100644 --- a/src/Commands/Rollback.php +++ b/src/Commands/Rollback.php @@ -6,7 +6,6 @@ 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\Events\DatabaseRolledBack; @@ -14,25 +13,10 @@ use Stancl\Tenancy\Events\RollingBackDatabase; class Rollback extends RollbackCommand { - use HasATenantsOption, DealsWithMigrations, ExtendsLaravelCommand; + use HasATenantsOption, ExtendsLaravelCommand; - protected static function getTenantCommandName(): string - { - return 'tenants:rollback'; - } - - /** - * The console command description. - * - * @var string - */ protected $description = 'Rollback migrations for tenant(s).'; - /** - * Create a new command instance. - * - * @return void - */ public function __construct(Migrator $migrator) { parent::__construct($migrator); @@ -40,10 +24,7 @@ class Rollback extends RollbackCommand $this->specifyTenantSignature(); } - /** - * Execute the console command. - */ - public function handle() + public function handle(): int { foreach (config('tenancy.migration_parameters') as $parameter => $value) { if (! $this->input->hasParameterOption($parameter)) { @@ -52,7 +33,7 @@ class Rollback extends RollbackCommand } if (! $this->confirmToProceed()) { - return; + return 1; } tenancy()->runForMultiple($this->option('tenants'), function ($tenant) { @@ -65,5 +46,12 @@ class Rollback extends RollbackCommand event(new DatabaseRolledBack($tenant)); }); + + return 0; + } + + protected static function getTenantCommandName(): string + { + return 'tenants:rollback'; } } diff --git a/src/Commands/Run.php b/src/Commands/Run.php index a24fb9c7..403ffd1b 100644 --- a/src/Commands/Run.php +++ b/src/Commands/Run.php @@ -11,27 +11,14 @@ use Symfony\Component\Console\Output\ConsoleOutput; class Run extends Command { - /** - * The console command description. - * - * @var string - */ protected $description = 'Run a command for tenant(s)'; - /** - * The name and signature of the console command. - * - * @var string - */ protected $signature = 'tenants:run {commandname : The artisan command.} {--tenants=* : The tenant(s) to run the command for. Default: all}'; - /** - * Execute the console command. - */ - public function handle() + public function handle(): void { - $argvInput = $this->ArgvInput(); + $argvInput = $this->argvInput(); tenancy()->runForMultiple($this->option('tenants'), function ($tenant) use ($argvInput) { $this->line("Tenant: {$tenant->getTenantKey()}"); @@ -41,10 +28,7 @@ class Run extends Command }); } - /** - * Get command as ArgvInput instance. - */ - protected function ArgvInput(): ArgvInput + protected function argvInput(): ArgvInput { // Convert string command to array $subCommand = explode(' ', $this->argument('commandname')); diff --git a/src/Commands/Seed.php b/src/Commands/Seed.php index 8c525208..b59e0062 100644 --- a/src/Commands/Seed.php +++ b/src/Commands/Seed.php @@ -14,29 +14,16 @@ class Seed extends SeedCommand { use HasATenantsOption; - /** - * The console command description. - * - * @var string - */ protected $description = 'Seed tenant database(s).'; protected $name = 'tenants:seed'; - /** - * Create a new command instance. - * - * @return void - */ public function __construct(ConnectionResolverInterface $resolver) { parent::__construct($resolver); } - /** - * Execute the console command. - */ - public function handle() + public function handle(): int { foreach (config('tenancy.seeder_parameters') as $parameter => $value) { if (! $this->input->hasParameterOption($parameter)) { @@ -45,7 +32,7 @@ class Seed extends SeedCommand } if (! $this->confirmToProceed()) { - return; + return 1; } tenancy()->runForMultiple($this->option('tenants'), function ($tenant) { @@ -58,5 +45,7 @@ class Seed extends SeedCommand event(new DatabaseSeeded($tenant)); }); + + return 0; } } diff --git a/src/Commands/TenantList.php b/src/Commands/TenantList.php index 13775676..ce8dfcec 100644 --- a/src/Commands/TenantList.php +++ b/src/Commands/TenantList.php @@ -9,24 +9,11 @@ use Stancl\Tenancy\Contracts\Tenant; class TenantList extends Command { - /** - * The name and signature of the console command. - * - * @var string - */ protected $signature = 'tenants:list'; - /** - * The console command description. - * - * @var string - */ protected $description = 'List tenants.'; - /** - * Execute the console command. - */ - public function handle() + public function handle(): void { $this->info('Listing all tenants.'); tenancy() diff --git a/src/Concerns/DealsWithMigrations.php b/src/Concerns/DealsWithMigrations.php index 4bb6b44c..3129c68d 100644 --- a/src/Concerns/DealsWithMigrations.php +++ b/src/Concerns/DealsWithMigrations.php @@ -6,12 +6,12 @@ namespace Stancl\Tenancy\Concerns; trait DealsWithMigrations { - protected function getMigrationPaths() + protected function getMigrationPaths(): array { if ($this->input->hasOption('path') && $this->input->getOption('path')) { return parent::getMigrationPaths(); } - return database_path('migrations/tenant'); + return [database_path('migrations/tenant')]; } } diff --git a/src/Contracts/UniqueIdentifierGenerator.php b/src/Contracts/UniqueIdentifierGenerator.php index b21d6028..14d91ae0 100644 --- a/src/Contracts/UniqueIdentifierGenerator.php +++ b/src/Contracts/UniqueIdentifierGenerator.php @@ -4,10 +4,12 @@ declare(strict_types=1); namespace Stancl\Tenancy\Contracts; +use Illuminate\Database\Eloquent\Model; + interface UniqueIdentifierGenerator { /** - * Generate a unique identifier. + * Generate a unique identifier for a model. */ - public static function generate($resource): string; + public static function generate(Model $model): string; } diff --git a/src/Database/Concerns/HasDomains.php b/src/Database/Concerns/HasDomains.php index 594e9a81..bd512e23 100644 --- a/src/Database/Concerns/HasDomains.php +++ b/src/Database/Concerns/HasDomains.php @@ -10,6 +10,8 @@ use Stancl\Tenancy\Contracts\Domain; /** * @property-read Domain[]|\Illuminate\Database\Eloquent\Collection $domains + * @mixin \Illuminate\Database\Eloquent\Model + * @mixin \Stancl\Tenancy\Contracts\Tenant */ trait HasDomains { diff --git a/src/Database/Concerns/InitializationHelpers.php b/src/Database/Concerns/InitializationHelpers.php new file mode 100644 index 00000000..ff142c5a --- /dev/null +++ b/src/Database/Concerns/InitializationHelpers.php @@ -0,0 +1,19 @@ +initialize($this); + } + + public function leave(): void + { + tenancy()->end(); + } +} diff --git a/src/Database/Models/Tenant.php b/src/Database/Models/Tenant.php index 4518e7b7..88c34146 100644 --- a/src/Database/Models/Tenant.php +++ b/src/Database/Models/Tenant.php @@ -26,6 +26,7 @@ class Tenant extends Model implements Contracts\Tenant Concerns\HasDataColumn, Concerns\HasInternalKeys, Concerns\TenantRun, + Concerns\InitializationHelpers, Concerns\InvalidatesResolverCache; protected $table = 'tenants'; diff --git a/src/Events/Contracts/TenancyEvent.php b/src/Events/Contracts/TenancyEvent.php index f292049d..9a85085d 100644 --- a/src/Events/Contracts/TenancyEvent.php +++ b/src/Events/Contracts/TenancyEvent.php @@ -8,11 +8,7 @@ use Stancl\Tenancy\Tenancy; abstract class TenancyEvent { - /** @var Tenancy */ - public $tenancy; - - public function __construct(Tenancy $tenancy) - { - $this->tenancy = $tenancy; - } + public function __construct( + public Tenancy $tenancy, + ) {} } diff --git a/src/Middleware/IdentificationMiddleware.php b/src/Middleware/IdentificationMiddleware.php index 38f4684d..ed582c93 100644 --- a/src/Middleware/IdentificationMiddleware.php +++ b/src/Middleware/IdentificationMiddleware.php @@ -4,20 +4,18 @@ declare(strict_types=1); namespace Stancl\Tenancy\Middleware; +use Closure; use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException; use Stancl\Tenancy\Contracts\TenantResolver; use Stancl\Tenancy\Tenancy; +/** + * @property Tenancy $tenancy + * @property TenantResolver $resolver + */ abstract class IdentificationMiddleware { - /** @var callable */ - public static $onFail; - - /** @var Tenancy */ - protected $tenancy; - - /** @var TenantResolver */ - protected $resolver; + public static ?Closure $onFail = null; public function initializeTenancy($request, $next, ...$resolverArguments) { diff --git a/src/Middleware/InitializeTenancyByDomain.php b/src/Middleware/InitializeTenancyByDomain.php index 5a07112d..8e4423fd 100644 --- a/src/Middleware/InitializeTenancyByDomain.php +++ b/src/Middleware/InitializeTenancyByDomain.php @@ -5,32 +5,21 @@ declare(strict_types=1); namespace Stancl\Tenancy\Middleware; use Closure; +use Illuminate\Http\Request; use Stancl\Tenancy\Resolvers\DomainTenantResolver; use Stancl\Tenancy\Tenancy; class InitializeTenancyByDomain extends IdentificationMiddleware { - /** @var callable|null */ - public static $onFail; + public static ?Closure $onFail = null; - /** @var Tenancy */ - protected $tenancy; + public function __construct( + protected Tenancy $tenancy, + protected DomainTenantResolver $resolver, + ) {} - /** @var DomainTenantResolver */ - protected $resolver; - - public function __construct(Tenancy $tenancy, DomainTenantResolver $resolver) - { - $this->tenancy = $tenancy; - $this->resolver = $resolver; - } - - /** - * Handle an incoming request. - * - * @param \Illuminate\Http\Request $request - */ - public function handle($request, Closure $next) + /** @return \Illuminate\Http\Response|mixed */ + public function handle(Request $request, Closure $next): mixed { return $this->initializeTenancy( $request, diff --git a/src/Middleware/InitializeTenancyByDomainOrSubdomain.php b/src/Middleware/InitializeTenancyByDomainOrSubdomain.php index 9b153db3..1a30001a 100644 --- a/src/Middleware/InitializeTenancyByDomainOrSubdomain.php +++ b/src/Middleware/InitializeTenancyByDomainOrSubdomain.php @@ -5,16 +5,13 @@ declare(strict_types=1); namespace Stancl\Tenancy\Middleware; use Closure; +use Illuminate\Http\Request; use Illuminate\Support\Str; class InitializeTenancyByDomainOrSubdomain { - /** - * Handle an incoming request. - * - * @param \Illuminate\Http\Request $request - */ - public function handle($request, Closure $next) + /** @return \Illuminate\Http\Response|mixed */ + public function handle(Request $request, Closure $next): mixed { if ($this->isSubdomain($request->getHost())) { return app(InitializeTenancyBySubdomain::class)->handle($request, $next); diff --git a/src/Middleware/InitializeTenancyByPath.php b/src/Middleware/InitializeTenancyByPath.php index ae15323c..c02fc23c 100644 --- a/src/Middleware/InitializeTenancyByPath.php +++ b/src/Middleware/InitializeTenancyByPath.php @@ -16,22 +16,15 @@ use Stancl\Tenancy\Tenancy; class InitializeTenancyByPath extends IdentificationMiddleware { - /** @var callable|null */ - public static $onFail; + public static ?Closure $onFail = null; - /** @var Tenancy */ - protected $tenancy; + public function __construct( + protected Tenancy $tenancy, + protected PathTenantResolver $resolver, + ) {} - /** @var PathTenantResolver */ - protected $resolver; - - public function __construct(Tenancy $tenancy, PathTenantResolver $resolver) - { - $this->tenancy = $tenancy; - $this->resolver = $resolver; - } - - public function handle(Request $request, Closure $next) + /** @return \Illuminate\Http\Response|mixed */ + public function handle(Request $request, Closure $next): mixed { /** @var Route $route */ $route = $request->route(); diff --git a/src/Middleware/InitializeTenancyByRequestData.php b/src/Middleware/InitializeTenancyByRequestData.php index 4e1d33ff..93ca42ab 100644 --- a/src/Middleware/InitializeTenancyByRequestData.php +++ b/src/Middleware/InitializeTenancyByRequestData.php @@ -11,33 +11,17 @@ use Stancl\Tenancy\Tenancy; class InitializeTenancyByRequestData extends IdentificationMiddleware { - /** @var string|null */ - public static $header = 'X-Tenant'; + public static string $header = 'X-Tenant'; + public static string $queryParameter = 'tenant'; + public static ?Closure $onFail = null; - /** @var string|null */ - public static $queryParameter = 'tenant'; + public function __construct( + protected Tenancy $tenancy, + protected RequestDataTenantResolver $resolver, + ) {} - /** @var callable|null */ - public static $onFail; - - /** @var Tenancy */ - protected $tenancy; - - /** @var TenantResolver */ - protected $resolver; - - public function __construct(Tenancy $tenancy, RequestDataTenantResolver $resolver) - { - $this->tenancy = $tenancy; - $this->resolver = $resolver; - } - - /** - * Handle an incoming request. - * - * @param \Illuminate\Http\Request $request - */ - public function handle($request, Closure $next) + /** @return \Illuminate\Http\Response|mixed */ + public function handle(Request $request, Closure $next): mixed { if ($request->method() !== 'OPTIONS') { return $this->initializeTenancy($request, $next, $this->getPayload($request)); diff --git a/src/Middleware/InitializeTenancyBySubdomain.php b/src/Middleware/InitializeTenancyBySubdomain.php index 76389df7..1bf083f3 100644 --- a/src/Middleware/InitializeTenancyBySubdomain.php +++ b/src/Middleware/InitializeTenancyBySubdomain.php @@ -6,6 +6,7 @@ namespace Stancl\Tenancy\Middleware; use Closure; use Exception; +use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Str; use Stancl\Tenancy\Exceptions\NotASubdomainException; @@ -21,15 +22,10 @@ class InitializeTenancyBySubdomain extends InitializeTenancyByDomain */ public static $subdomainIndex = 0; - /** @var callable|null */ - public static $onFail; + public static ?Closure $onFail = null; - /** - * Handle an incoming request. - * - * @param \Illuminate\Http\Request $request - */ - public function handle($request, Closure $next) + /** @return Response|mixed */ + public function handle(Request $request, Closure $next): mixed { $subdomain = $this->makeSubdomain($request->getHost()); diff --git a/src/Middleware/PreventAccessFromCentralDomains.php b/src/Middleware/PreventAccessFromCentralDomains.php index 14b2306d..40718730 100644 --- a/src/Middleware/PreventAccessFromCentralDomains.php +++ b/src/Middleware/PreventAccessFromCentralDomains.php @@ -11,12 +11,11 @@ class PreventAccessFromCentralDomains { /** * Set this property if you want to customize the on-fail behavior. - * - * @var callable|null */ - public static $abortRequest; + public static ?Closure $abortRequest; - public function handle(Request $request, Closure $next) + /** @return \Illuminate\Http\Response|mixed */ + public function handle(Request $request, Closure $next): mixed { if (in_array($request->getHost(), config('tenancy.central_domains'))) { $abortRequest = static::$abortRequest ?? function () { diff --git a/src/Middleware/ScopeSessions.php b/src/Middleware/ScopeSessions.php index 8abfcfe8..a72146d7 100644 --- a/src/Middleware/ScopeSessions.php +++ b/src/Middleware/ScopeSessions.php @@ -12,7 +12,8 @@ class ScopeSessions { public static $tenantIdKey = '_tenant_id'; - public function handle(Request $request, Closure $next) + /** @return \Illuminate\Http\Response|mixed */ + public function handle(Request $request, Closure $next): mixed { if (! tenancy()->initialized) { throw new TenancyNotInitializedException('Tenancy needs to be initialized before the session scoping middleware is executed'); diff --git a/src/Resolvers/Contracts/CachedTenantResolver.php b/src/Resolvers/Contracts/CachedTenantResolver.php index f93d7bb5..d4d5ba6e 100644 --- a/src/Resolvers/Contracts/CachedTenantResolver.php +++ b/src/Resolvers/Contracts/CachedTenantResolver.php @@ -65,7 +65,7 @@ abstract class CachedTenantResolver implements TenantResolver abstract public function resolveWithoutCache(mixed ...$args): Tenant; - public function resolved(Tenant $tenant, ...$args): void + public function resolved(Tenant $tenant, mixed ...$args): void { } diff --git a/src/Resolvers/DomainTenantResolver.php b/src/Resolvers/DomainTenantResolver.php index 926c02c0..d2970bb5 100644 --- a/src/Resolvers/DomainTenantResolver.php +++ b/src/Resolvers/DomainTenantResolver.php @@ -24,7 +24,6 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver { $domain = $args[0]; - /** @var Tenant|null $tenant */ $tenant = config('tenancy.tenant_model')::query() ->whereHas('domains', fn (Builder $query) => $query->where('domain', $domain)) ->with('domains') @@ -39,7 +38,7 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver throw new TenantCouldNotBeIdentifiedOnDomainException($args[0]); } - public function resolved(Tenant $tenant, ...$args): void + public function resolved(Tenant $tenant, mixed ...$args): void { $this->setCurrentDomain($tenant, $args[0]); } diff --git a/src/Resolvers/PathTenantResolver.php b/src/Resolvers/PathTenantResolver.php index 2ac2a59f..c98ac37e 100644 --- a/src/Resolvers/PathTenantResolver.php +++ b/src/Resolvers/PathTenantResolver.php @@ -23,7 +23,7 @@ class PathTenantResolver extends Contracts\CachedTenantResolver /** @var Route $route */ $route = $args[0]; - if ($id = $route->parameter(static::$tenantParameterName)) { + if ($id = (string) $route->parameter(static::$tenantParameterName)) { $route->forgetParameter(static::$tenantParameterName); if ($tenant = tenancy()->find($id)) { @@ -37,7 +37,7 @@ class PathTenantResolver extends Contracts\CachedTenantResolver public function getArgsForTenant(Tenant $tenant): array { return [ - [$tenant->id], + [$tenant->getTenantKey()], ]; } } diff --git a/src/Resolvers/RequestDataTenantResolver.php b/src/Resolvers/RequestDataTenantResolver.php index 5ed65495..8a5bbc53 100644 --- a/src/Resolvers/RequestDataTenantResolver.php +++ b/src/Resolvers/RequestDataTenantResolver.php @@ -17,7 +17,7 @@ class RequestDataTenantResolver extends Contracts\CachedTenantResolver public function resolveWithoutCache(mixed ...$args): Tenant { - $payload = $args[0]; + $payload = (string) $args[0]; if ($payload && $tenant = tenancy()->find($payload)) { return $tenant; @@ -29,7 +29,7 @@ class RequestDataTenantResolver extends Contracts\CachedTenantResolver public function getArgsForTenant(Tenant $tenant): array { return [ - [$tenant->id], + [$tenant->getTenantKey()], ]; } } diff --git a/src/UUIDGenerator.php b/src/UUIDGenerator.php index 736a6924..a0974862 100644 --- a/src/UUIDGenerator.php +++ b/src/UUIDGenerator.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Stancl\Tenancy; +use Illuminate\Database\Eloquent\Model; use Ramsey\Uuid\Uuid; use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator; @@ -11,7 +12,7 @@ use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator; class UUIDGenerator implements UniqueIdentifierGenerator { - public static function generate($resource): string + public static function generate(Model $model): string { return Uuid::uuid4()->toString(); } diff --git a/src/helpers.php b/src/helpers.php index ac805aa5..3f910a5b 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Stancl\Tenancy\CacheManager; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Tenancy; @@ -35,6 +36,7 @@ if (! function_exists('tenant')) { if (! function_exists('tenant_asset')) { // todo docblock + // todo add an option to generate paths respecting the ASSET_URL function tenant_asset(string|null $asset): string { return route('stancl.tenancy.asset', ['path' => $asset]); @@ -42,16 +44,42 @@ if (! function_exists('tenant_asset')) { } if (! function_exists('global_asset')) { - function global_asset(string $asset) // todo types, also inside the globalUrl implementation + function global_asset(string $asset): string { return app('globalUrl')->asset($asset); } } if (! function_exists('global_cache')) { - function global_cache() + /** + * Get / set the specified cache value in the global cache store. + * + * If an array is passed, we'll assume you want to put to the cache. + * + * @param dynamic key|key,default|data,expiration|null + * @return mixed|\Illuminate\Cache\CacheManager + * + * @throws \InvalidArgumentException + */ + function global_cache(): mixed { - return app('globalCache'); + $arguments = func_get_args(); + + if (empty($arguments)) { + return app('globalCache'); + } + + if (is_string($arguments[0])) { + return app('globalCache')->get(...$arguments); + } + + if (!is_array($arguments[0])) { + throw new InvalidArgumentException( + 'When setting a value in the cache, you must pass an array of key / value pairs.' + ); + } + + return app('globalCache')->put(key($arguments[0]), reset($arguments[0]), $arguments[1] ?? null); } } diff --git a/tests/DomainTest.php b/tests/DomainTest.php index 594270e1..6995da24 100644 --- a/tests/DomainTest.php +++ b/tests/DomainTest.php @@ -81,7 +81,7 @@ test('tenant can be identified by domain', function () { test('onfail logic can be customized', function () { InitializeTenancyByDomain::$onFail = function () { - return 'foo'; + return response('foo'); }; pest() diff --git a/tests/Etc/Tenant.php b/tests/Etc/Tenant.php index 20a96072..f20b0000 100644 --- a/tests/Etc/Tenant.php +++ b/tests/Etc/Tenant.php @@ -9,6 +9,9 @@ use Stancl\Tenancy\Database\Concerns\HasDatabase; use Stancl\Tenancy\Database\Concerns\HasDomains; use Stancl\Tenancy\Database\Models; +/** + * @method static static create(array $attributes = []) + */ class Tenant extends Models\Tenant implements TenantWithDatabase { use HasDatabase, HasDomains; diff --git a/tests/GlobalCacheTest.php b/tests/GlobalCacheTest.php index 8a13395c..ea38341b 100644 --- a/tests/GlobalCacheTest.php +++ b/tests/GlobalCacheTest.php @@ -50,3 +50,17 @@ test('global cache manager stores data in global cache', function () { expect(cache('def'))->toBe('ghi'); }); +test('the global_cache helper supports the same syntax as the cache helper', function () { + $tenant = Tenant::create(); + $tenant->enter(); + + expect(cache('foo'))->toBe(null); // tenant cache is empty + + global_cache(['foo' => 'bar']); + expect(global_cache('foo'))->toBe('bar'); + + global_cache()->set('foo', 'baz'); + expect(global_cache()->get('foo'))->toBe('baz'); + + expect(cache('foo'))->toBe(null); // tenant cache is not affected +}); diff --git a/tests/PathIdentificationTest.php b/tests/PathIdentificationTest.php index bfa8f8ad..517fa396 100644 --- a/tests/PathIdentificationTest.php +++ b/tests/PathIdentificationTest.php @@ -71,7 +71,7 @@ test('exception is thrown when tenant cannot be identified by path', function () test('onfail logic can be customized', function () { InitializeTenancyByPath::$onFail = function () { - return 'foo'; + return response('foo'); }; pest() diff --git a/tests/RequestDataIdentificationTest.php b/tests/RequestDataIdentificationTest.php index 81bdda53..e5a05f65 100644 --- a/tests/RequestDataIdentificationTest.php +++ b/tests/RequestDataIdentificationTest.php @@ -37,7 +37,6 @@ test('header identification works', function () { }); test('query parameter identification works', function () { - InitializeTenancyByRequestData::$header = null; InitializeTenancyByRequestData::$queryParameter = 'tenant'; $tenant = Tenant::create(); diff --git a/tests/SubdomainTest.php b/tests/SubdomainTest.php index 00096d8c..0ff52bc0 100644 --- a/tests/SubdomainTest.php +++ b/tests/SubdomainTest.php @@ -44,7 +44,7 @@ test('tenant can be identified by subdomain', function () { test('onfail logic can be customized', function () { InitializeTenancyBySubdomain::$onFail = function () { - return 'foo'; + return response('foo'); }; pest() From 77b5e508d6459724fa51b13c60a5a11620436ad4 Mon Sep 17 00:00:00 2001 From: PHP CS Fixer Date: Thu, 29 Sep 2022 00:47:38 +0000 Subject: [PATCH 17/48] Fix code style (php-cs-fixer) --- src/Database/Concerns/InitializationHelpers.php | 2 ++ src/Events/Contracts/TenancyEvent.php | 3 ++- src/Middleware/InitializeTenancyByDomain.php | 3 ++- src/Middleware/InitializeTenancyByPath.php | 3 ++- src/Middleware/InitializeTenancyByRequestData.php | 3 ++- src/helpers.php | 3 +-- 6 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Database/Concerns/InitializationHelpers.php b/src/Database/Concerns/InitializationHelpers.php index ff142c5a..46802df1 100644 --- a/src/Database/Concerns/InitializationHelpers.php +++ b/src/Database/Concerns/InitializationHelpers.php @@ -1,5 +1,7 @@ get(...$arguments); } - if (!is_array($arguments[0])) { + if (! is_array($arguments[0])) { throw new InvalidArgumentException( 'When setting a value in the cache, you must pass an array of key / value pairs.' ); From d463e2da6123bf586d3b7b0f43102c8fa889d9ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 29 Sep 2022 15:07:16 +0200 Subject: [PATCH 18/48] phpstan improvements --- src/Actions/CreateStorageSymlinksAction.php | 2 +- src/Actions/RemoveStorageSymlinksAction.php | 2 +- src/Concerns/DealsWithTenantSymlinks.php | 4 +++- .../DomainOccupiedByOtherTenantException.php | 2 +- .../TenancyNotInitializedException.php | 2 +- src/Jobs/CreateDatabase.php | 4 +++- src/Jobs/CreateStorageSymlinks.php | 21 ++++--------------- src/Jobs/DeleteDomains.php | 2 -- src/Middleware/IdentificationMiddleware.php | 4 +++- src/Middleware/ScopeSessions.php | 2 +- 10 files changed, 18 insertions(+), 27 deletions(-) diff --git a/src/Actions/CreateStorageSymlinksAction.php b/src/Actions/CreateStorageSymlinksAction.php index 779a42af..eac5d933 100644 --- a/src/Actions/CreateStorageSymlinksAction.php +++ b/src/Actions/CreateStorageSymlinksAction.php @@ -8,7 +8,7 @@ use Exception; use Illuminate\Support\Collection; use Illuminate\Support\LazyCollection; use Stancl\Tenancy\Concerns\DealsWithTenantSymlinks; -use Stancl\Tenancy\Database\Models\Tenant; +use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Events\CreatingStorageSymlink; use Stancl\Tenancy\Events\StorageSymlinkCreated; diff --git a/src/Actions/RemoveStorageSymlinksAction.php b/src/Actions/RemoveStorageSymlinksAction.php index bfbcfa0a..a3660e7a 100644 --- a/src/Actions/RemoveStorageSymlinksAction.php +++ b/src/Actions/RemoveStorageSymlinksAction.php @@ -7,7 +7,7 @@ namespace Stancl\Tenancy\Actions; use Illuminate\Support\Collection; use Illuminate\Support\LazyCollection; use Stancl\Tenancy\Concerns\DealsWithTenantSymlinks; -use Stancl\Tenancy\Database\Models\Tenant; +use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Events\RemovingStorageSymlink; use Stancl\Tenancy\Events\StorageSymlinkRemoved; diff --git a/src/Concerns/DealsWithTenantSymlinks.php b/src/Concerns/DealsWithTenantSymlinks.php index a4c972bb..d6d6f5f2 100644 --- a/src/Concerns/DealsWithTenantSymlinks.php +++ b/src/Concerns/DealsWithTenantSymlinks.php @@ -15,6 +15,8 @@ trait DealsWithTenantSymlinks * Tenants can have a symlink for each disk registered in the tenancy.filesystem.url_override config. * * This is used for creating all possible tenant symlinks and removing all existing tenant symlinks. + * + * @return Collection */ protected static function possibleTenantSymlinks(Tenant $tenant): Collection { @@ -33,7 +35,7 @@ trait DealsWithTenantSymlinks }); } - return $symlinks->mapWithKeys(fn ($item) => $item); + return $symlinks->mapWithKeys(fn ($item) => $item); // [[a => b], [c => d]] -> [a => b, c => d] } /** Determine if the provided path is an existing symlink. */ diff --git a/src/Exceptions/DomainOccupiedByOtherTenantException.php b/src/Exceptions/DomainOccupiedByOtherTenantException.php index 00d42f3e..c0860ca8 100644 --- a/src/Exceptions/DomainOccupiedByOtherTenantException.php +++ b/src/Exceptions/DomainOccupiedByOtherTenantException.php @@ -8,7 +8,7 @@ use Exception; class DomainOccupiedByOtherTenantException extends Exception { - public function __construct($domain) + public function __construct(string $domain) { parent::__construct("The $domain domain is occupied by another tenant."); } diff --git a/src/Exceptions/TenancyNotInitializedException.php b/src/Exceptions/TenancyNotInitializedException.php index d2744499..be936747 100644 --- a/src/Exceptions/TenancyNotInitializedException.php +++ b/src/Exceptions/TenancyNotInitializedException.php @@ -8,7 +8,7 @@ use Exception; class TenancyNotInitializedException extends Exception { - public function __construct($message = '') + public function __construct(string $message = '') { parent::__construct($message ?: 'Tenancy is not initialized.'); } diff --git a/src/Jobs/CreateDatabase.php b/src/Jobs/CreateDatabase.php index f143f399..dbc4b097 100644 --- a/src/Jobs/CreateDatabase.php +++ b/src/Jobs/CreateDatabase.php @@ -24,7 +24,7 @@ class CreateDatabase implements ShouldQueue ) { } - public function handle(DatabaseManager $databaseManager) + public function handle(DatabaseManager $databaseManager): bool { event(new CreatingDatabase($this->tenant)); @@ -38,5 +38,7 @@ class CreateDatabase implements ShouldQueue $this->tenant->database()->manager()->createDatabase($this->tenant); event(new DatabaseCreated($this->tenant)); + + return true; } } diff --git a/src/Jobs/CreateStorageSymlinks.php b/src/Jobs/CreateStorageSymlinks.php index 4f18bb03..2e1db88a 100644 --- a/src/Jobs/CreateStorageSymlinks.php +++ b/src/Jobs/CreateStorageSymlinks.php @@ -16,24 +16,11 @@ class CreateStorageSymlinks implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - public Tenant $tenant; + public function __construct( + public Tenant $tenant, + ) {} - /** - * Create a new job instance. - * - * @return void - */ - public function __construct(Tenant $tenant) - { - $this->tenant = $tenant; - } - - /** - * Execute the job. - * - * @return void - */ - public function handle() + public function handle(): void { CreateStorageSymlinksAction::handle($this->tenant); } diff --git a/src/Jobs/DeleteDomains.php b/src/Jobs/DeleteDomains.php index 8d89ce9e..15fff779 100644 --- a/src/Jobs/DeleteDomains.php +++ b/src/Jobs/DeleteDomains.php @@ -9,14 +9,12 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Stancl\Tenancy\Database\Concerns\HasDomains; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; class DeleteDomains { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - /** @var TenantWithDatabase&Model&HasDomains */ // todo unresolvable type for phpstan protected TenantWithDatabase&Model $tenant; public function __construct(TenantWithDatabase&Model $tenant) diff --git a/src/Middleware/IdentificationMiddleware.php b/src/Middleware/IdentificationMiddleware.php index ed582c93..12aa4a16 100644 --- a/src/Middleware/IdentificationMiddleware.php +++ b/src/Middleware/IdentificationMiddleware.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Middleware; use Closure; +use Illuminate\Http\Request; use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException; use Stancl\Tenancy\Contracts\TenantResolver; use Stancl\Tenancy\Tenancy; @@ -17,7 +18,8 @@ abstract class IdentificationMiddleware { public static ?Closure $onFail = null; - public function initializeTenancy($request, $next, ...$resolverArguments) + /** @return \Illuminate\Http\Response|mixed */ + public function initializeTenancy(Request $request, Closure $next, mixed ...$resolverArguments): mixed { try { $this->tenancy->initialize( diff --git a/src/Middleware/ScopeSessions.php b/src/Middleware/ScopeSessions.php index a72146d7..dc302ee5 100644 --- a/src/Middleware/ScopeSessions.php +++ b/src/Middleware/ScopeSessions.php @@ -10,7 +10,7 @@ use Stancl\Tenancy\Exceptions\TenancyNotInitializedException; class ScopeSessions { - public static $tenantIdKey = '_tenant_id'; + public static string $tenantIdKey = '_tenant_id'; /** @return \Illuminate\Http\Response|mixed */ public function handle(Request $request, Closure $next): mixed From e009f6700c5c39daffb711fbacca56eae812ca29 Mon Sep 17 00:00:00 2001 From: PHP CS Fixer Date: Thu, 29 Sep 2022 13:08:06 +0000 Subject: [PATCH 19/48] Fix code style (php-cs-fixer) --- src/Jobs/CreateStorageSymlinks.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Jobs/CreateStorageSymlinks.php b/src/Jobs/CreateStorageSymlinks.php index 2e1db88a..fb9a3b0d 100644 --- a/src/Jobs/CreateStorageSymlinks.php +++ b/src/Jobs/CreateStorageSymlinks.php @@ -18,7 +18,8 @@ class CreateStorageSymlinks implements ShouldQueue public function __construct( public Tenant $tenant, - ) {} + ) { + } public function handle(): void { From 121370ea017e8d0ee541e41417402dc6856445f5 Mon Sep 17 00:00:00 2001 From: Jori Stein <44996807+stein-j@users.noreply.github.com> Date: Thu, 29 Sep 2022 09:59:14 -0400 Subject: [PATCH 20/48] [4.x] Add command to bring the tenants up and down from maintenance and remove deprecated exception (#761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add bring up from maintenance function * Add up and down tenant maintenance commands * Rename commands signatures * Update TenancyServiceProvider.php * Complying to Laravel maintenance code and parameters * Update MaintenanceModeTest.php * Add maintenance mode via commands test * Update CheckTenantForMaintenanceMode.php * Update MaintenanceModeTest.php * Cookie bypass only for > Laravel 8 * minor formatting change, trigger CI * clean * Update MaintenanceModeTest.php * Add comments for using the 'tenants' option in runForMultiple * improve code * php-cs-fixer * fix php cs fixer config * improve test logic * remove version check since v4 will be L9+ Co-authored-by: Samuel Štancl Co-authored-by: lukinovec Co-authored-by: Samuel Štancl --- .php-cs-fixer.php | 1 + composer.json | 1 + src/Commands/Down.php | 52 +++++++++++++++++++ src/Commands/Up.php | 27 ++++++++++ src/Database/Concerns/MaintenanceMode.php | 28 ++++++---- .../CheckTenantForMaintenanceMode.php | 30 ++++++++--- src/TenancyServiceProvider.php | 2 + tests/Etc/Tenant.php | 3 +- tests/MaintenanceModeTest.php | 42 +++++++++++---- 9 files changed, 161 insertions(+), 25 deletions(-) create mode 100644 src/Commands/Down.php create mode 100644 src/Commands/Up.php diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 589838bc..7e649ea5 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -10,6 +10,7 @@ $rules = [ 'operators' => [ '=>' => null, '|' => 'no_space', + '&' => 'no_space', ] ], 'blank_line_after_namespace' => true, diff --git a/composer.json b/composer.json index cc213add..0dc9df09 100644 --- a/composer.json +++ b/composer.json @@ -63,6 +63,7 @@ "docker-m1": "ln -s docker-compose-m1.override.yml docker-compose.override.yml", "coverage": "open coverage/phpunit/html/index.html", "phpstan": "vendor/bin/phpstan", + "cs": "php-cs-fixer fix --config=.php-cs-fixer.php", "test": "PHP_VERSION=8.1 ./test --no-coverage", "test-full": "PHP_VERSION=8.1 ./test" }, diff --git a/src/Commands/Down.php b/src/Commands/Down.php new file mode 100644 index 00000000..96ed5335 --- /dev/null +++ b/src/Commands/Down.php @@ -0,0 +1,52 @@ +getDownDatabasePayload(); + + // This runs for all tenants if no --tenants are specified + tenancy()->runForMultiple($this->option('tenants'), function ($tenant) use ($payload) { + $this->line("Tenant: {$tenant['id']}"); + $tenant->putDownForMaintenance($payload); + }); + + $this->comment('Tenants are now in maintenance mode.'); + } + + /** Get the payload to be placed in the "down" file. */ + protected function getDownDatabasePayload() + { + return [ + 'except' => $this->excludedPaths(), + 'redirect' => $this->redirectPath(), + 'retry' => $this->getRetryTime(), + 'refresh' => $this->option('refresh'), + 'secret' => $this->option('secret'), + 'status' => (int) $this->option('status', 503), + ]; + } +} diff --git a/src/Commands/Up.php b/src/Commands/Up.php new file mode 100644 index 00000000..a3f690c2 --- /dev/null +++ b/src/Commands/Up.php @@ -0,0 +1,27 @@ +runForMultiple($this->getTenants(), function ($tenant) { + $this->line("Tenant: {$tenant['id']}"); + $tenant->bringUpFromMaintenance(); + }); + + $this->comment('Tenants are now out of maintenance mode.'); + } +} diff --git a/src/Database/Concerns/MaintenanceMode.php b/src/Database/Concerns/MaintenanceMode.php index 55e0e46d..cc4490f6 100644 --- a/src/Database/Concerns/MaintenanceMode.php +++ b/src/Database/Concerns/MaintenanceMode.php @@ -4,17 +4,27 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\Concerns; -use Carbon\Carbon; - +/** + * @mixin \Illuminate\Database\Eloquent\Model + */ trait MaintenanceMode { - public function putDownForMaintenance($data = []) + public function putDownForMaintenance($data = []): void { - $this->update(['maintenance_mode' => [ - 'time' => $data['time'] ?? Carbon::now()->getTimestamp(), - 'message' => $data['message'] ?? null, - 'retry' => $data['retry'] ?? null, - 'allowed' => $data['allowed'] ?? [], - ]]); + $this->update([ + 'maintenance_mode' => [ + 'except' => $data['except'] ?? null, + 'redirect' => $data['redirect'] ?? null, + 'retry' => $data['retry'] ?? null, + 'refresh' => $data['refresh'] ?? null, + 'secret' => $data['secret'] ?? null, + 'status' => $data['status'] ?? 503, + ], + ]); + } + + public function bringUpFromMaintenance(): void + { + $this->update(['maintenance_mode' => null]); } } diff --git a/src/Middleware/CheckTenantForMaintenanceMode.php b/src/Middleware/CheckTenantForMaintenanceMode.php index c1c734f5..58fcd184 100644 --- a/src/Middleware/CheckTenantForMaintenanceMode.php +++ b/src/Middleware/CheckTenantForMaintenanceMode.php @@ -7,7 +7,6 @@ namespace Stancl\Tenancy\Middleware; use Closure; use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode; use Stancl\Tenancy\Exceptions\TenancyNotInitializedException; -use Symfony\Component\HttpFoundation\IpUtils; use Symfony\Component\HttpKernel\Exception\HttpException; class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode @@ -21,19 +20,38 @@ class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode if (tenant('maintenance_mode')) { $data = tenant('maintenance_mode'); - if (isset($data['allowed']) && IpUtils::checkIp($request->ip(), (array) $data['allowed'])) { + if (isset($data['secret']) && $request->path() === $data['secret']) { + return $this->bypassResponse($data['secret']); + } + + if ($this->hasValidBypassCookie($request, $data) || + $this->inExceptArray($request)) { return $next($request); } - if ($this->inExceptArray($request)) { - return $next($request); + if (isset($data['redirect'])) { + $path = $data['redirect'] === '/' + ? $data['redirect'] + : trim($data['redirect'], '/'); + + if ($request->path() !== $path) { + return redirect($path); + } + } + + if (isset($data['template'])) { + return response( + $data['template'], + (int) ($data['status'] ?? 503), + $this->getHeaders($data) + ); } throw new HttpException( - 503, + (int) ($data['status'] ?? 503), 'Service Unavailable', null, - isset($data['retry']) ? ['Retry-After' => $data['retry']] : [] + $this->getHeaders($data) ); } diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index b8eee487..d9556283 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -86,6 +86,8 @@ class TenancyServiceProvider extends ServiceProvider Commands\TenantList::class, Commands\TenantDump::class, Commands\MigrateFresh::class, + Commands\Down::class, + Commands\Up::class, ]); $this->publishes([ diff --git a/tests/Etc/Tenant.php b/tests/Etc/Tenant.php index f20b0000..9b59dedb 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\MaintenanceMode; use Stancl\Tenancy\Database\Models; /** @@ -14,5 +15,5 @@ use Stancl\Tenancy\Database\Models; */ class Tenant extends Models\Tenant implements TenantWithDatabase { - use HasDatabase, HasDomains; + use HasDatabase, HasDomains, MaintenanceMode; } diff --git a/tests/MaintenanceModeTest.php b/tests/MaintenanceModeTest.php index 770dc5f2..6e28d1ab 100644 --- a/tests/MaintenanceModeTest.php +++ b/tests/MaintenanceModeTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); +use Illuminate\Support\Facades\Artisan; use Stancl\Tenancy\Database\Concerns\MaintenanceMode; -use Symfony\Component\HttpKernel\Exception\HttpException; use Illuminate\Support\Facades\Route; use Stancl\Tenancy\Middleware\CheckTenantForMaintenanceMode; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; use Stancl\Tenancy\Tests\Etc\Tenant; -test('tenant can be in maintenance mode', function () { +test('tenants can be in maintenance mode', function () { Route::get('/foo', function () { return 'bar'; })->middleware([InitializeTenancyByDomain::class, CheckTenantForMaintenanceMode::class]); @@ -19,16 +19,40 @@ test('tenant can be in maintenance mode', function () { 'domain' => 'acme.localhost', ]); - pest()->get('http://acme.localhost/foo') - ->assertSuccessful(); - - tenancy()->end(); // flush stored tenant instance + pest()->get('http://acme.localhost/foo')->assertStatus(200); $tenant->putDownForMaintenance(); - pest()->expectException(HttpException::class); - pest()->withoutExceptionHandling() - ->get('http://acme.localhost/foo'); + tenancy()->end(); // End tenancy before making a request + pest()->get('http://acme.localhost/foo')->assertStatus(503); + + $tenant->bringUpFromMaintenance(); + + tenancy()->end(); // End tenancy before making a request + pest()->get('http://acme.localhost/foo')->assertStatus(200); +}); + +test('tenants can be put into maintenance mode using artisan commands', function() { + Route::get('/foo', function () { + return 'bar'; + })->middleware([InitializeTenancyByDomain::class, CheckTenantForMaintenanceMode::class]); + + $tenant = MaintenanceTenant::create(); + $tenant->domains()->create([ + 'domain' => 'acme.localhost', + ]); + + pest()->get('http://acme.localhost/foo')->assertStatus(200); + + Artisan::call('tenants:down'); + + tenancy()->end(); // End tenancy before making a request + pest()->get('http://acme.localhost/foo')->assertStatus(503); + + Artisan::call('tenants:up'); + + tenancy()->end(); // End tenancy before making a request + pest()->get('http://acme.localhost/foo')->assertStatus(200); }); class MaintenanceTenant extends Tenant From fa09e3a0839e3d8a68d7eb85d69f514694e857ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 29 Sep 2022 16:02:08 +0200 Subject: [PATCH 21/48] remove version checks --- src/Bootstrappers/QueueTenancyBootstrapper.php | 11 ++++------- tests/BatchTest.php | 4 ++-- tests/TenantDatabaseManagerTest.php | 4 +--- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/Bootstrappers/QueueTenancyBootstrapper.php b/src/Bootstrappers/QueueTenancyBootstrapper.php index 2f859ecd..43b9dd20 100644 --- a/src/Bootstrappers/QueueTenancyBootstrapper.php +++ b/src/Bootstrappers/QueueTenancyBootstrapper.php @@ -62,14 +62,11 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper static::initializeTenancyForQueue($event->job->payload()['tenant_id'] ?? null); }); - if (version_compare(app()->version(), '8.64', '>=')) { - // JobRetryRequested only exists since Laravel 8.64 - $dispatcher->listen(JobRetryRequested::class, function ($event) use (&$previousTenant) { - $previousTenant = tenant(); + $dispatcher->listen(JobRetryRequested::class, function ($event) use (&$previousTenant) { + $previousTenant = tenant(); - static::initializeTenancyForQueue($event->payload()['tenant_id'] ?? null); - }); - } + static::initializeTenancyForQueue($event->payload()['tenant_id'] ?? null); + }); // If we're running tests, we make sure to clean up after any artisan('queue:work') calls $revertToPreviousState = function ($event) use (&$previousTenant, $runningTests) { diff --git a/tests/BatchTest.php b/tests/BatchTest.php index a168deb2..629a4e61 100644 --- a/tests/BatchTest.php +++ b/tests/BatchTest.php @@ -30,13 +30,13 @@ test('batch repository is set to tenant connection and reverted', function () { tenancy()->initialize($tenant); expect(getBatchRepositoryConnectionName())->toBe('tenant'); - + tenancy()->initialize($tenant2); expect(getBatchRepositoryConnectionName())->toBe('tenant'); tenancy()->end(); expect(getBatchRepositoryConnectionName())->toBe('central'); -})->skip(fn() => version_compare(app()->version(), '8.0', '<'), 'Job batches are only supported in Laravel 8+'); +}); function getBatchRepositoryConnectionName() { diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index d6a5b369..b16c06b6 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -154,9 +154,7 @@ test('schema manager uses schema to separate tenant dbs', function () { ]); tenancy()->initialize($tenant); - $schemaConfig = version_compare(app()->version(), '9.0', '>=') ? - config('database.connections.' . config('database.default') . '.search_path') : - config('database.connections.' . config('database.default') . '.schema'); + $schemaConfig = config('database.connections.' . config('database.default') . '.search_path'); expect($schemaConfig)->toBe($tenant->database()->getName()); expect(config(['database.connections.pgsql.database']))->toBe($originalDatabaseName); From 193e044777a0ad5f9f9f6af0e174a2c70f4184fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 29 Sep 2022 16:02:40 +0200 Subject: [PATCH 22/48] remove remaining version checks --- .../TenantDatabaseManagers/PostgreSQLSchemaManager.php | 6 +----- tests/BootstrapperTest.php | 4 ---- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php index fa5aa593..a7558e1b 100644 --- a/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php +++ b/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php @@ -25,11 +25,7 @@ class PostgreSQLSchemaManager extends TenantDatabaseManager public function makeConnectionConfig(array $baseConfig, string $databaseName): array { - if (version_compare(app()->version(), '9.0', '>=')) { - $baseConfig['search_path'] = $databaseName; - } else { - $baseConfig['schema'] = $databaseName; - } + $baseConfig['search_path'] = $databaseName; return $baseConfig; } diff --git a/tests/BootstrapperTest.php b/tests/BootstrapperTest.php index a610fbd2..ba4ea41a 100644 --- a/tests/BootstrapperTest.php +++ b/tests/BootstrapperTest.php @@ -332,10 +332,6 @@ function getDiskPrefix(string $disk): string $disk = Storage::disk($disk); $adapter = $disk->getAdapter(); - if (! Str::startsWith(app()->version(), '9.')) { - return $adapter->getPathPrefix(); - } - $prefixer = (new ReflectionObject($adapter))->getProperty('prefixer'); $prefixer->setAccessible(true); From a94227a19c7e06946a94e868b3b85f548129e696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 29 Sep 2022 22:20:55 +0200 Subject: [PATCH 23/48] get down to 59 phpstan errors --- phpstan.neon | 10 ++-- .../BatchTenancyBootstrapper.php | 4 +- .../CacheTenancyBootstrapper.php | 19 +++---- .../DatabaseTenancyBootstrapper.php | 6 +-- .../FilesystemTenancyBootstrapper.php | 6 ++- .../Integrations/ScoutTenancyBootstrapper.php | 19 +++---- .../QueueTenancyBootstrapper.php | 25 +++++---- .../RedisTenancyBootstrapper.php | 12 +++-- src/Commands/TenantList.php | 22 ++++---- src/Concerns/DealsWithTenantSymlinks.php | 8 +-- src/Contracts/TenancyBootstrapper.php | 4 +- src/Controllers/TenantAssetsController.php | 2 +- .../Concerns/InvalidatesResolverCache.php | 1 + .../InvalidatesTenantsResolverCache.php | 3 +- src/Database/DatabaseConfig.php | 10 ++-- src/Database/DatabaseManager.php | 53 +++++++------------ src/Database/ParentModelScope.php | 2 +- ...rmissionControlledMySQLDatabaseManager.php | 3 +- src/Database/TenantScope.php | 2 +- src/Features/TenantConfig.php | 13 ++--- src/Features/UniversalRoutes.php | 6 ++- src/Listeners/CreateTenantConnection.php | 16 +++--- src/Listeners/QueueableListener.php | 2 +- src/Middleware/InitializeTenancyByPath.php | 6 ++- src/Tenancy.php | 2 +- tests/AutomaticModeTest.php | 4 +- 26 files changed, 130 insertions(+), 130 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index f325f3ec..17f8f56e 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -13,14 +13,16 @@ parameters: - Illuminate\Database\Eloquent\Model ignoreErrors: - - - message: '#Cannot access offset (.*?) on Illuminate\\Contracts\\Foundation\\Application#' - paths: - - src/TenancyServiceProvider.php + - '#Cannot access offset (.*?) on Illuminate\\Contracts\\Foundation\\Application#' + - '#Cannot access offset (.*?) on Illuminate\\Contracts\\Config\\Repository#' - message: '#invalid type Laravel\\Telescope\\IncomingEntry#' paths: - src/Features/TelescopeTags.php + - + message: '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model::getRelationshipToPrimaryModel\(\)#' + paths: + - src/Database/ParentModelScope.php - message: '#Parameter \#1 \$key of method Illuminate\\Contracts\\Cache\\Repository::put\(\) expects string#' paths: diff --git a/src/Bootstrappers/BatchTenancyBootstrapper.php b/src/Bootstrappers/BatchTenancyBootstrapper.php index ccd1c00a..589bdac0 100644 --- a/src/Bootstrappers/BatchTenancyBootstrapper.php +++ b/src/Bootstrappers/BatchTenancyBootstrapper.php @@ -23,14 +23,14 @@ class BatchTenancyBootstrapper implements TenancyBootstrapper ) { } - public function bootstrap(Tenant $tenant) + public function bootstrap(Tenant $tenant): void { // Update batch repository connection to use the tenant connection $this->previousConnection = $this->batchRepository->getConnection(); $this->batchRepository->setConnection($this->databaseManager->connection('tenant')); } - public function revert() + public function revert(): void { if ($this->previousConnection) { // Replace batch repository connection with the previously replaced one diff --git a/src/Bootstrappers/CacheTenancyBootstrapper.php b/src/Bootstrappers/CacheTenancyBootstrapper.php index bef156d2..8dbbc27a 100644 --- a/src/Bootstrappers/CacheTenancyBootstrapper.php +++ b/src/Bootstrappers/CacheTenancyBootstrapper.php @@ -13,18 +13,13 @@ use Stancl\Tenancy\Contracts\Tenant; class CacheTenancyBootstrapper implements TenancyBootstrapper { - /** @var CacheManager */ - protected $originalCache; + protected ?CacheManager $originalCache = null; - /** @var Application */ - protected $app; + public function __construct( + protected Application $app + ) {} - public function __construct(Application $app) - { - $this->app = $app; - } - - public function bootstrap(Tenant $tenant) + public function bootstrap(Tenant $tenant): void { $this->resetFacadeCache(); @@ -34,7 +29,7 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper }); } - public function revert() + public function revert(): void { $this->resetFacadeCache(); @@ -50,7 +45,7 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper * facade has been made prior to bootstrapping tenancy. The * facade has its own cache, separate from the container. */ - public function resetFacadeCache() + public function resetFacadeCache(): void { Cache::clearResolvedInstances(); } diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index dd94bfd4..c6dba079 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -8,7 +8,7 @@ use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\DatabaseManager; -use Stancl\Tenancy\Exceptions\TenantDatabaseDoesNotExistException; +use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException; class DatabaseTenancyBootstrapper implements TenancyBootstrapper { @@ -20,7 +20,7 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper $this->database = $database; } - public function bootstrap(Tenant $tenant) + public function bootstrap(Tenant $tenant): void { /** @var TenantWithDatabase $tenant */ @@ -35,7 +35,7 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper $this->database->connectToTenant($tenant); } - public function revert() + public function revert(): void { $this->database->reconnectToCentral(); } diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index d90d36d0..cb77a752 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Bootstrappers; use Illuminate\Contracts\Foundation\Application; +use Illuminate\Routing\UrlGenerator; use Illuminate\Support\Facades\Storage; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; @@ -27,13 +28,14 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper ]; $this->app['url']->macro('setAssetRoot', function ($root) { + /** @var UrlGenerator $this */ $this->assetRoot = $root; return $this; }); } - public function bootstrap(Tenant $tenant) + public function bootstrap(Tenant $tenant): void { $suffix = $this->app['config']['tenancy.filesystem.suffix_base'] . $tenant->getTenantKey(); @@ -91,7 +93,7 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper } } - public function revert() + public function revert(): void { // storage_path() $this->app->useStoragePath($this->originalPaths['storage']); diff --git a/src/Bootstrappers/Integrations/ScoutTenancyBootstrapper.php b/src/Bootstrappers/Integrations/ScoutTenancyBootstrapper.php index 49869bb5..195c462d 100644 --- a/src/Bootstrappers/Integrations/ScoutTenancyBootstrapper.php +++ b/src/Bootstrappers/Integrations/ScoutTenancyBootstrapper.php @@ -10,27 +10,22 @@ use Stancl\Tenancy\Contracts\Tenant; class ScoutTenancyBootstrapper implements TenancyBootstrapper { - /** @var Repository */ - protected $config; + protected ?string $originalScoutPrefix = null; - /** @var string */ - protected $originalScoutPrefix; + public function __construct( + protected Repository $config, + ) {} - public function __construct(Repository $config) + public function bootstrap(Tenant $tenant): void { - $this->config = $config; - } - - public function bootstrap(Tenant $tenant) - { - if (! isset($this->originalScoutPrefix)) { + if ($this->originalScoutPrefix !== null) { $this->originalScoutPrefix = $this->config->get('scout.prefix'); } $this->config->set('scout.prefix', $this->getTenantPrefix($tenant)); } - public function revert() + public function revert(): void { $this->config->set('scout.prefix', $this->originalScoutPrefix); } diff --git a/src/Bootstrappers/QueueTenancyBootstrapper.php b/src/Bootstrappers/QueueTenancyBootstrapper.php index 43b9dd20..39bb84c3 100644 --- a/src/Bootstrappers/QueueTenancyBootstrapper.php +++ b/src/Bootstrappers/QueueTenancyBootstrapper.php @@ -39,7 +39,7 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper * However, we're registering a hook to initialize tenancy. Therefore, * we need to register the hook at service provider execution time. */ - public static function __constructStatic(Application $app) + public static function __constructStatic(Application $app): void { static::setUpJobListener($app->make(Dispatcher::class), $app->runningUnitTests()); } @@ -52,7 +52,7 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper $this->setUpPayloadGenerator(); } - protected static function setUpJobListener($dispatcher, $runningTests) + protected static function setUpJobListener(Dispatcher $dispatcher, bool $runningTests): void { $previousTenant = null; @@ -79,7 +79,7 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper $dispatcher->listen(JobFailed::class, $revertToPreviousState); // artisan('queue:work') which fails } - protected static function initializeTenancyForQueue($tenantId) + protected static function initializeTenancyForQueue(string|int $tenantId): void { if (! $tenantId) { // The job is not tenant-aware @@ -97,7 +97,9 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper tenancy()->end(); } - tenancy()->initialize(tenancy()->find($tenantId)); + /** @var Tenant $tenant */ + $tenant = tenancy()->find($tenantId); + tenancy()->initialize($tenant); return; } @@ -112,10 +114,13 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper // Tenancy was either not initialized, or initialized for a different tenant. // Therefore, we initialize it for the correct tenant. - tenancy()->initialize(tenancy()->find($tenantId)); + + /** @var Tenant $tenant */ + $tenant = tenancy()->find($tenantId); + tenancy()->initialize($tenant); } - protected static function revertToPreviousState($event, &$previousTenant) + protected static function revertToPreviousState($event, ?Tenant &$previousTenant): void { $tenantId = $event->job->payload()['tenant_id'] ?? null; @@ -135,7 +140,7 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper } } - protected function setUpPayloadGenerator() + protected function setUpPayloadGenerator(): void { $bootstrapper = &$this; @@ -146,17 +151,17 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper } } - public function bootstrap(Tenant $tenant) + public function bootstrap(Tenant $tenant): void { // } - public function revert() + public function revert(): void { // } - public function getPayload(string $connection) + public function getPayload(string $connection): array { if (! tenancy()->initialized) { return []; diff --git a/src/Bootstrappers/RedisTenancyBootstrapper.php b/src/Bootstrappers/RedisTenancyBootstrapper.php index 7536984e..975a37d5 100644 --- a/src/Bootstrappers/RedisTenancyBootstrapper.php +++ b/src/Bootstrappers/RedisTenancyBootstrapper.php @@ -22,18 +22,21 @@ class RedisTenancyBootstrapper implements TenancyBootstrapper $this->config = $config; } - public function bootstrap(Tenant $tenant) + public function bootstrap(Tenant $tenant): void { foreach ($this->prefixedConnections() as $connection) { $prefix = $this->config['tenancy.redis.prefix_base'] . $tenant->getTenantKey(); $client = Redis::connection($connection)->client(); - $this->originalPrefixes[$connection] = $client->getOption($client::OPT_PREFIX); + /** @var string $originalPrefix */ + $originalPrefix = $client->getOption($client::OPT_PREFIX); + + $this->originalPrefixes[$connection] = $originalPrefix; $client->setOption($client::OPT_PREFIX, $prefix); } } - public function revert() + public function revert(): void { foreach ($this->prefixedConnections() as $connection) { $client = Redis::connection($connection)->client(); @@ -44,7 +47,8 @@ class RedisTenancyBootstrapper implements TenancyBootstrapper $this->originalPrefixes = []; } - protected function prefixedConnections() + /** @return string[] */ + protected function prefixedConnections(): array { return $this->config['tenancy.redis.prefixed_connections']; } diff --git a/src/Commands/TenantList.php b/src/Commands/TenantList.php index ce8dfcec..9fd3f8bd 100644 --- a/src/Commands/TenantList.php +++ b/src/Commands/TenantList.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Commands; use Illuminate\Console\Command; +use Illuminate\Database\Eloquent\Model; use Stancl\Tenancy\Contracts\Tenant; class TenantList extends Command @@ -16,15 +17,16 @@ class TenantList extends Command public function handle(): void { $this->info('Listing all tenants.'); - tenancy() - ->query() - ->cursor() - ->each(function (Tenant $tenant) { - if ($tenant->domains) { - $this->line("[Tenant] {$tenant->getTenantKeyName()}: {$tenant->getTenantKey()} @ " . implode('; ', $tenant->domains->pluck('domain')->toArray() ?? [])); - } else { - $this->line("[Tenant] {$tenant->getTenantKeyName()}: {$tenant->getTenantKey()}"); - } - }); + + $tenants = tenancy()->query()->cursor(); + + foreach ($tenants as $tenant) { + /** @var Model&Tenant $tenant */ + if ($tenant->domains) { + $this->line("[Tenant] {$tenant->getTenantKeyName()}: {$tenant->getTenantKey()} @ " . implode('; ', $tenant->domains->pluck('domain')->toArray() ?? [])); + } else { + $this->line("[Tenant] {$tenant->getTenantKeyName()}: {$tenant->getTenantKey()}"); + } + } } } diff --git a/src/Concerns/DealsWithTenantSymlinks.php b/src/Concerns/DealsWithTenantSymlinks.php index d6d6f5f2..5f3baf5b 100644 --- a/src/Concerns/DealsWithTenantSymlinks.php +++ b/src/Concerns/DealsWithTenantSymlinks.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Concerns; use Illuminate\Support\Collection; -use Stancl\Tenancy\Database\Models\Tenant; +use Stancl\Tenancy\Contracts\Tenant; trait DealsWithTenantSymlinks { @@ -23,12 +23,14 @@ trait DealsWithTenantSymlinks $diskUrls = config('tenancy.filesystem.url_override'); $disks = config('tenancy.filesystem.root_override'); $suffixBase = config('tenancy.filesystem.suffix_base'); - $symlinks = collect(); $tenantKey = $tenant->getTenantKey(); + /** @var Collection> $symlinks */ + $symlinks = collect([]); + foreach ($diskUrls as $disk => $publicPath) { $storagePath = str_replace('%storage_path%', $suffixBase . $tenantKey, $disks[$disk]); - $publicPath = str_replace('%tenant_id%', $tenantKey, $publicPath); + $publicPath = str_replace('%tenant_id%', (string) $tenantKey, $publicPath); tenancy()->central(function () use ($symlinks, $publicPath, $storagePath) { $symlinks->push([public_path($publicPath) => storage_path($storagePath)]); diff --git a/src/Contracts/TenancyBootstrapper.php b/src/Contracts/TenancyBootstrapper.php index 8b43755f..6da5c537 100644 --- a/src/Contracts/TenancyBootstrapper.php +++ b/src/Contracts/TenancyBootstrapper.php @@ -9,7 +9,7 @@ namespace Stancl\Tenancy\Contracts; */ interface TenancyBootstrapper { - public function bootstrap(Tenant $tenant); + public function bootstrap(Tenant $tenant): void; - public function revert(); + public function revert(): void; } diff --git a/src/Controllers/TenantAssetsController.php b/src/Controllers/TenantAssetsController.php index 7e032c2c..278493c6 100644 --- a/src/Controllers/TenantAssetsController.php +++ b/src/Controllers/TenantAssetsController.php @@ -10,7 +10,7 @@ use Throwable; class TenantAssetsController extends Controller // todo rename this to TenantAssetController & update references in docs { - public static string|array|Closure $tenancyMiddleware = Stancl\Tenancy\Middleware\InitializeTenancyByDomain::class; + public static string|array|Closure $tenancyMiddleware = \Stancl\Tenancy\Middleware\InitializeTenancyByDomain::class; public function __construct() { diff --git a/src/Database/Concerns/InvalidatesResolverCache.php b/src/Database/Concerns/InvalidatesResolverCache.php index 3b864789..82a85997 100644 --- a/src/Database/Concerns/InvalidatesResolverCache.php +++ b/src/Database/Concerns/InvalidatesResolverCache.php @@ -10,6 +10,7 @@ use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver; trait InvalidatesResolverCache { + /** @var array> */ public static $resolvers = [ Resolvers\DomainTenantResolver::class, Resolvers\PathTenantResolver::class, diff --git a/src/Database/Concerns/InvalidatesTenantsResolverCache.php b/src/Database/Concerns/InvalidatesTenantsResolverCache.php index 8d7c2845..aa7fac4b 100644 --- a/src/Database/Concerns/InvalidatesTenantsResolverCache.php +++ b/src/Database/Concerns/InvalidatesTenantsResolverCache.php @@ -13,7 +13,8 @@ use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver; */ trait InvalidatesTenantsResolverCache { - public static $resolvers = [ + /** @var array> */ + public static array $resolvers = [ // todo single source of truth for this here and in InvalidatesResolverCache Resolvers\DomainTenantResolver::class, Resolvers\PathTenantResolver::class, Resolvers\RequestDataTenantResolver::class, diff --git a/src/Database/DatabaseConfig.php b/src/Database/DatabaseConfig.php index a4c79582..f024b442 100644 --- a/src/Database/DatabaseConfig.php +++ b/src/Database/DatabaseConfig.php @@ -26,20 +26,20 @@ class DatabaseConfig public static function __constructStatic(): void { - static::$usernameGenerator = static::$usernameGenerator ?? function (Tenant $tenant) { + static::$usernameGenerator = static::$usernameGenerator ?? function (Model&Tenant $tenant) { return Str::random(16); }; - static::$passwordGenerator = static::$passwordGenerator ?? function (Tenant $tenant) { + static::$passwordGenerator = static::$passwordGenerator ?? function (Model&Tenant $tenant) { return Hash::make(Str::random(32)); }; - static::$databaseNameGenerator = static::$databaseNameGenerator ?? function (Tenant $tenant) { + static::$databaseNameGenerator = static::$databaseNameGenerator ?? function (Model&Tenant $tenant) { return config('tenancy.database.prefix') . $tenant->getTenantKey() . config('tenancy.database.suffix'); }; } - public function __construct(Tenant $tenant) + public function __construct(Model&Tenant $tenant) { static::__constructStatic(); @@ -61,7 +61,7 @@ class DatabaseConfig static::$passwordGenerator = $passwordGenerator; } - public function getName(): ?string + public function getName(): string { return $this->tenant->getInternal('db_name') ?? (static::$databaseNameGenerator)($this->tenant); } diff --git a/src/Database/DatabaseManager.php b/src/Database/DatabaseManager.php index a92ccb7b..edde7515 100644 --- a/src/Database/DatabaseManager.php +++ b/src/Database/DatabaseManager.php @@ -15,25 +15,13 @@ use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; */ class DatabaseManager { - /** @var Application */ - protected $app; + public function __construct( + protected Application $app, + protected BaseDatabaseManager $database, + protected Repository $config, + ) {} - /** @var BaseDatabaseManager */ - protected $database; - - /** @var Repository */ - protected $config; - - public function __construct(Application $app, BaseDatabaseManager $database, Repository $config) - { - $this->app = $app; - $this->database = $database; - $this->config = $config; - } - - /** - * Connect to a tenant's database. - */ + /** Connect to a tenant's database. */ public function connectToTenant(TenantWithDatabase $tenant): void { $this->purgeTenantConnection(); @@ -41,35 +29,27 @@ class DatabaseManager $this->setDefaultConnection('tenant'); } - /** - * Reconnect to the default non-tenant connection. - */ + /** Reconnect to the default non-tenant connection. */ public function reconnectToCentral(): void { $this->purgeTenantConnection(); $this->setDefaultConnection($this->config->get('tenancy.database.central_connection')); } - /** - * Change the default database connection config. - */ + /** Change the default database connection config. */ public function setDefaultConnection(string $connection): void { $this->config['database.default'] = $connection; $this->database->setDefaultConnection($connection); } - /** - * Create the tenant database connection. - */ + /** Create the tenant database connection. */ public function createTenantConnection(TenantWithDatabase $tenant): void { $this->config['database.connections.tenant'] = $tenant->database()->connection(); } - /** - * Purge the tenant database connection. - */ + /** Purge the tenant database connection. */ public function purgeTenantConnection(): void { if (array_key_exists('tenant', $this->database->getConnections())) { @@ -83,8 +63,8 @@ class DatabaseManager * Check if a tenant can be created. * * @throws TenantCannotBeCreatedException - * @throws DatabaseManagerNotRegisteredException - * @throws TenantDatabaseAlreadyExistsException + * @throws Exceptions\DatabaseManagerNotRegisteredException + * @throws Exceptions\TenantDatabaseAlreadyExistsException */ public function ensureTenantCanBeCreated(TenantWithDatabase $tenant): void { @@ -94,8 +74,13 @@ class DatabaseManager throw new Exceptions\TenantDatabaseAlreadyExistsException($database); } - if ($manager instanceof Contracts\ManagesDatabaseUsers && $manager->userExists($username = $tenant->database()->getUsername())) { - throw new Exceptions\TenantDatabaseUserAlreadyExistsException($username); + if ($manager instanceof Contracts\ManagesDatabaseUsers) { + /** @var string $username */ + $username = $tenant->database()->getUsername(); + + if ($manager->userExists($username)) { + throw new Exceptions\TenantDatabaseUserAlreadyExistsException($username); + } } } } diff --git a/src/Database/ParentModelScope.php b/src/Database/ParentModelScope.php index 78f5de20..cfc003c1 100644 --- a/src/Database/ParentModelScope.php +++ b/src/Database/ParentModelScope.php @@ -19,7 +19,7 @@ class ParentModelScope implements Scope $builder->whereHas($builder->getModel()->getRelationshipToPrimaryModel()); } - public function extend(Builder $builder) + public function extend(Builder $builder): void { $builder->macro('withoutParentModel', function (Builder $builder) { return $builder->withoutGlobalScope($this); diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php index 337864dc..f7e7440e 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php @@ -12,7 +12,8 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl { use CreatesDatabaseUsers; - public static $grants = [ + /** @var string[] */ + public static array $grants = [ 'ALTER', 'ALTER ROUTINE', 'CREATE', 'CREATE ROUTINE', 'CREATE TEMPORARY TABLES', 'CREATE VIEW', 'DELETE', 'DROP', 'EVENT', 'EXECUTE', 'INDEX', 'INSERT', 'LOCK TABLES', 'REFERENCES', 'SELECT', 'SHOW VIEW', 'TRIGGER', 'UPDATE', diff --git a/src/Database/TenantScope.php b/src/Database/TenantScope.php index 8592f16c..8b887ac0 100644 --- a/src/Database/TenantScope.php +++ b/src/Database/TenantScope.php @@ -20,7 +20,7 @@ class TenantScope implements Scope $builder->where($model->qualifyColumn(BelongsToTenant::$tenantIdColumn), tenant()->getTenantKey()); } - public function extend(Builder $builder) + public function extend(Builder $builder): void { $builder->macro('withoutTenancy', function (Builder $builder) { return $builder->withoutGlobalScope($this); diff --git a/src/Features/TenantConfig.php b/src/Features/TenantConfig.php index 50756b2c..33f1c7dd 100644 --- a/src/Features/TenantConfig.php +++ b/src/Features/TenantConfig.php @@ -16,19 +16,16 @@ use Stancl\Tenancy\Tenancy; class TenantConfig implements Feature { - /** @var Repository */ - protected $config; - public array $originalConfig = []; - public static $storageToConfigMap = [ + /** @var array */ + public static array $storageToConfigMap = [ // 'paypal_api_key' => 'services.paypal.api_key', ]; - public function __construct(Repository $config) - { - $this->config = $config; - } + public function __construct( + protected Repository $config, + ) {} public function bootstrap(Tenancy $tenancy): void { diff --git a/src/Features/UniversalRoutes.php b/src/Features/UniversalRoutes.php index e327b5d3..ad0433fc 100644 --- a/src/Features/UniversalRoutes.php +++ b/src/Features/UniversalRoutes.php @@ -16,6 +16,7 @@ class UniversalRoutes implements Feature public static string $middlewareGroup = 'universal'; // todo docblock + /** @var array> */ public static array $identificationMiddlewares = [ Middleware\InitializeTenancyByDomain::class, Middleware\InitializeTenancyBySubdomain::class, @@ -42,7 +43,10 @@ class UniversalRoutes implements Feature public static function routeHasMiddleware(Route $route, string $middleware): bool { - if (in_array($middleware, $route->middleware(), true)) { + /** @var array $routeMiddleware */ + $routeMiddleware = $route->middleware(); + + if (in_array($middleware, $routeMiddleware, true)) { return true; } diff --git a/src/Listeners/CreateTenantConnection.php b/src/Listeners/CreateTenantConnection.php index 01351c08..73edc6bf 100644 --- a/src/Listeners/CreateTenantConnection.php +++ b/src/Listeners/CreateTenantConnection.php @@ -4,21 +4,21 @@ declare(strict_types=1); namespace Stancl\Tenancy\Listeners; +use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\DatabaseManager; use Stancl\Tenancy\Events\Contracts\TenantEvent; class CreateTenantConnection { - /** @var DatabaseManager */ - protected $database; - - public function __construct(DatabaseManager $database) - { - $this->database = $database; - } + public function __construct( + protected DatabaseManager $database, + ) {} public function handle(TenantEvent $event): void { - $this->database->createTenantConnection($event->tenant); + /** @var TenantWithDatabase */ + $tenant = $event->tenant; + + $this->database->createTenantConnection($tenant); } } diff --git a/src/Listeners/QueueableListener.php b/src/Listeners/QueueableListener.php index e10c1e7a..f486873d 100644 --- a/src/Listeners/QueueableListener.php +++ b/src/Listeners/QueueableListener.php @@ -13,7 +13,7 @@ abstract class QueueableListener implements ShouldQueue { public static bool $shouldQueue = false; - public function shouldQueue($event): bool + public function shouldQueue(object $event): bool { if (static::$shouldQueue) { return true; diff --git a/src/Middleware/InitializeTenancyByPath.php b/src/Middleware/InitializeTenancyByPath.php index e88a1950..d4733f62 100644 --- a/src/Middleware/InitializeTenancyByPath.php +++ b/src/Middleware/InitializeTenancyByPath.php @@ -9,6 +9,7 @@ use Illuminate\Http\Request; use Illuminate\Routing\Route; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\URL; +use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Events\InitializingTenancy; use Stancl\Tenancy\Exceptions\RouteIsMissingTenantParameterException; use Stancl\Tenancy\Resolvers\PathTenantResolver; @@ -36,7 +37,10 @@ class InitializeTenancyByPath extends IdentificationMiddleware if ($route->parameterNames()[0] === PathTenantResolver::$tenantParameterName) { // Set tenant as a default parameter for the URLs in the current request Event::listen(InitializingTenancy::class, function (InitializingTenancy $event) { - URL::defaults([PathTenantResolver::$tenantParameterName => $event->tenancy->tenant->getTenantKey()]); + /** @var Tenant $tenant */ + $tenant = $event->tenancy->tenant; + + URL::defaults([PathTenantResolver::$tenantParameterName => $tenant->getTenantKey()]); }); return $this->initializeTenancy( diff --git a/src/Tenancy.php b/src/Tenancy.php index 0a8d4542..c93df4fb 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -132,7 +132,7 @@ class Tenancy * Run a callback for multiple tenants. * More performant than running $tenant->run() one by one. * - * @param Tenant[]|\Traversable|string[]|null $tenants + * @param array|array|\Traversable|null $tenants */ public function runForMultiple($tenants, Closure $callback): void { diff --git a/tests/AutomaticModeTest.php b/tests/AutomaticModeTest.php index ab484ccf..fc740fc1 100644 --- a/tests/AutomaticModeTest.php +++ b/tests/AutomaticModeTest.php @@ -107,12 +107,12 @@ function contextIsSwitchedWhenTenancyInitialized() class MyBootstrapper implements TenancyBootstrapper { - public function bootstrap(\Stancl\Tenancy\Contracts\Tenant $tenant) + public function bootstrap(\Stancl\Tenancy\Contracts\Tenant $tenant): void { app()->instance('tenancy_initialized_for_tenant', $tenant->getTenantKey()); } - public function revert() + public function revert(): void { app()->instance('tenancy_ended', true); } From a08480f3103521b33112583e28991c932edccd60 Mon Sep 17 00:00:00 2001 From: PHP CS Fixer Date: Thu, 29 Sep 2022 20:21:29 +0000 Subject: [PATCH 24/48] Fix code style (php-cs-fixer) --- src/Bootstrappers/CacheTenancyBootstrapper.php | 3 ++- src/Bootstrappers/Integrations/ScoutTenancyBootstrapper.php | 3 ++- src/Database/DatabaseManager.php | 3 ++- src/Features/TenantConfig.php | 3 ++- src/Listeners/CreateTenantConnection.php | 3 ++- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/Bootstrappers/CacheTenancyBootstrapper.php b/src/Bootstrappers/CacheTenancyBootstrapper.php index 8dbbc27a..29547fae 100644 --- a/src/Bootstrappers/CacheTenancyBootstrapper.php +++ b/src/Bootstrappers/CacheTenancyBootstrapper.php @@ -17,7 +17,8 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper public function __construct( protected Application $app - ) {} + ) { + } public function bootstrap(Tenant $tenant): void { diff --git a/src/Bootstrappers/Integrations/ScoutTenancyBootstrapper.php b/src/Bootstrappers/Integrations/ScoutTenancyBootstrapper.php index 195c462d..da5a921a 100644 --- a/src/Bootstrappers/Integrations/ScoutTenancyBootstrapper.php +++ b/src/Bootstrappers/Integrations/ScoutTenancyBootstrapper.php @@ -14,7 +14,8 @@ class ScoutTenancyBootstrapper implements TenancyBootstrapper public function __construct( protected Repository $config, - ) {} + ) { + } public function bootstrap(Tenant $tenant): void { diff --git a/src/Database/DatabaseManager.php b/src/Database/DatabaseManager.php index edde7515..ce9219d5 100644 --- a/src/Database/DatabaseManager.php +++ b/src/Database/DatabaseManager.php @@ -19,7 +19,8 @@ class DatabaseManager protected Application $app, protected BaseDatabaseManager $database, protected Repository $config, - ) {} + ) { + } /** Connect to a tenant's database. */ public function connectToTenant(TenantWithDatabase $tenant): void diff --git a/src/Features/TenantConfig.php b/src/Features/TenantConfig.php index 33f1c7dd..e9c1c6e9 100644 --- a/src/Features/TenantConfig.php +++ b/src/Features/TenantConfig.php @@ -25,7 +25,8 @@ class TenantConfig implements Feature public function __construct( protected Repository $config, - ) {} + ) { + } public function bootstrap(Tenancy $tenancy): void { diff --git a/src/Listeners/CreateTenantConnection.php b/src/Listeners/CreateTenantConnection.php index 73edc6bf..b4983d32 100644 --- a/src/Listeners/CreateTenantConnection.php +++ b/src/Listeners/CreateTenantConnection.php @@ -12,7 +12,8 @@ class CreateTenantConnection { public function __construct( protected DatabaseManager $database, - ) {} + ) { + } public function handle(TenantEvent $event): void { From f98a901aeba814703a5cf8635ee44c0a39605046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 29 Sep 2022 23:39:35 +0200 Subject: [PATCH 25/48] get down to 21 phpstan errors --- phpstan.neon | 12 +++++++++++ .../QueueTenancyBootstrapper.php | 2 +- src/Commands/Down.php | 10 ++++++---- src/Commands/Link.php | 4 ++-- src/Commands/Rollback.php | 2 +- src/Commands/Run.php | 15 ++++++++++---- src/Commands/Seed.php | 2 +- src/Contracts/Domain.php | 4 +++- .../TenantCannotBeCreatedException.php | 1 + src/Controllers/TenantAssetsController.php | 6 +++++- src/Database/Contracts/TenantWithDatabase.php | 10 ++++++++++ src/Database/DatabaseConfig.php | 2 +- src/Features/TenantConfig.php | 9 ++++++--- src/Listeners/BootstrapTenancy.php | 6 +++++- src/Tenancy.php | 20 +++++++++++++++---- tests/CommandsTest.php | 6 ++++-- 16 files changed, 85 insertions(+), 26 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index 17f8f56e..3e9ba51d 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -31,6 +31,18 @@ parameters: message: '#PHPDoc tag \@param has invalid value \(dynamic#' paths: - src/helpers.php + - + message: '#Illuminate\\Routing\\UrlGenerator#' + paths: + - src/Bootstrappers/FilesystemTenancyBootstrapper.php + - + message: '#select\(\) expects string, Illuminate\\Database\\Query\\Expression given#' + paths: + - src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php + - + message: '#Trying to invoke Closure\|null but it might not be a callable#' + paths: + - src/Database/DatabaseConfig.php checkMissingIterableValueType: false treatPhpDocTypesAsCertain: false diff --git a/src/Bootstrappers/QueueTenancyBootstrapper.php b/src/Bootstrappers/QueueTenancyBootstrapper.php index 39bb84c3..5b6ef4d8 100644 --- a/src/Bootstrappers/QueueTenancyBootstrapper.php +++ b/src/Bootstrappers/QueueTenancyBootstrapper.php @@ -120,7 +120,7 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper tenancy()->initialize($tenant); } - protected static function revertToPreviousState($event, ?Tenant &$previousTenant): void + protected static function revertToPreviousState(JobProcessed|JobFailed $event, ?Tenant &$previousTenant): void { $tenantId = $event->job->payload()['tenant_id'] ?? null; diff --git a/src/Commands/Down.php b/src/Commands/Down.php index 96ed5335..6b390957 100644 --- a/src/Commands/Down.php +++ b/src/Commands/Down.php @@ -20,7 +20,7 @@ class Down extends DownCommand protected $description = 'Put tenants into maintenance mode.'; - public function handle(): void + public function handle(): int { // The base down command is heavily used. Instead of saving the data inside a file, // the data is stored the tenant database, which means some Laravel features @@ -29,16 +29,18 @@ class Down extends DownCommand $payload = $this->getDownDatabasePayload(); // This runs for all tenants if no --tenants are specified - tenancy()->runForMultiple($this->option('tenants'), function ($tenant) use ($payload) { + tenancy()->runForMultiple($this->getTenants(), function ($tenant) use ($payload) { $this->line("Tenant: {$tenant['id']}"); $tenant->putDownForMaintenance($payload); }); $this->comment('Tenants are now in maintenance mode.'); + + return 0; } /** Get the payload to be placed in the "down" file. */ - protected function getDownDatabasePayload() + protected function getDownDatabasePayload(): array { return [ 'except' => $this->excludedPaths(), @@ -46,7 +48,7 @@ class Down extends DownCommand 'retry' => $this->getRetryTime(), 'refresh' => $this->option('refresh'), 'secret' => $this->option('secret'), - 'status' => (int) $this->option('status', 503), + 'status' => (int) ($this->option('status') ?? 503), ]; } } diff --git a/src/Commands/Link.php b/src/Commands/Link.php index 2b9ee4cf..53f3cf6f 100644 --- a/src/Commands/Link.php +++ b/src/Commands/Link.php @@ -49,8 +49,8 @@ class Link extends Command { CreateStorageSymlinksAction::handle( $tenants, - $this->option('relative') ?? false, - $this->option('force') ?? false, + (bool) ($this->option('relative') ?? false), + (bool) ($this->option('force') ?? false), ); $this->info('The links have been created.'); diff --git a/src/Commands/Rollback.php b/src/Commands/Rollback.php index d61083d4..d3989cc0 100644 --- a/src/Commands/Rollback.php +++ b/src/Commands/Rollback.php @@ -36,7 +36,7 @@ class Rollback extends RollbackCommand return 1; } - tenancy()->runForMultiple($this->option('tenants'), function ($tenant) { + tenancy()->runForMultiple($this->getTenants(), function ($tenant) { $this->line("Tenant: {$tenant->getTenantKey()}"); event(new RollingBackDatabase($tenant)); diff --git a/src/Commands/Run.php b/src/Commands/Run.php index 403ffd1b..9bb04716 100644 --- a/src/Commands/Run.php +++ b/src/Commands/Run.php @@ -6,11 +6,14 @@ namespace Stancl\Tenancy\Commands; use Illuminate\Console\Command; use Illuminate\Contracts\Console\Kernel; +use Stancl\Tenancy\Concerns\HasATenantsOption; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Output\ConsoleOutput; class Run extends Command { + use HasATenantsOption; + protected $description = 'Run a command for tenant(s)'; protected $signature = 'tenants:run {commandname : The artisan command.} @@ -19,7 +22,8 @@ class Run extends Command public function handle(): void { $argvInput = $this->argvInput(); - tenancy()->runForMultiple($this->option('tenants'), function ($tenant) use ($argvInput) { + + tenancy()->runForMultiple($this->getTenants(), function ($tenant) use ($argvInput) { $this->line("Tenant: {$tenant->getTenantKey()}"); $this->getLaravel() @@ -30,12 +34,15 @@ class Run extends Command protected function argvInput(): ArgvInput { + /** @var string $commandname */ + $commandname = $this->argument('commandname'); + // Convert string command to array - $subCommand = explode(' ', $this->argument('commandname')); + $subcommand = explode(' ', $commandname); // Add "artisan" as first parameter because ArgvInput expects "artisan" as first parameter and later removes it - array_unshift($subCommand, 'artisan'); + array_unshift($subcommand, 'artisan'); - return new ArgvInput($subCommand); + return new ArgvInput($subcommand); } } diff --git a/src/Commands/Seed.php b/src/Commands/Seed.php index b59e0062..496c04e6 100644 --- a/src/Commands/Seed.php +++ b/src/Commands/Seed.php @@ -35,7 +35,7 @@ class Seed extends SeedCommand return 1; } - tenancy()->runForMultiple($this->option('tenants'), function ($tenant) { + tenancy()->runForMultiple($this->getTenants(), function ($tenant) { $this->line("Tenant: {$tenant->getTenantKey()}"); event(new SeedingDatabase($tenant)); diff --git a/src/Contracts/Domain.php b/src/Contracts/Domain.php index 2c02089e..a9a19a50 100644 --- a/src/Contracts/Domain.php +++ b/src/Contracts/Domain.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Stancl\Tenancy\Contracts; +use Illuminate\Database\Eloquent\Relations\BelongsTo; + /** * @property-read Tenant $tenant * @@ -15,5 +17,5 @@ namespace Stancl\Tenancy\Contracts; */ interface Domain { - public function tenant(); + public function tenant(): BelongsTo; } diff --git a/src/Contracts/TenantCannotBeCreatedException.php b/src/Contracts/TenantCannotBeCreatedException.php index 19eac15b..53d8589f 100644 --- a/src/Contracts/TenantCannotBeCreatedException.php +++ b/src/Contracts/TenantCannotBeCreatedException.php @@ -8,6 +8,7 @@ abstract class TenantCannotBeCreatedException extends \Exception { abstract public function reason(): string; + /** @var string */ protected $message; public function __construct() diff --git a/src/Controllers/TenantAssetsController.php b/src/Controllers/TenantAssetsController.php index 278493c6..615f8054 100644 --- a/src/Controllers/TenantAssetsController.php +++ b/src/Controllers/TenantAssetsController.php @@ -6,6 +6,7 @@ namespace Stancl\Tenancy\Controllers; use Closure; use Illuminate\Routing\Controller; +use Symfony\Component\HttpFoundation\BinaryFileResponse; use Throwable; class TenantAssetsController extends Controller // todo rename this to TenantAssetController & update references in docs @@ -17,7 +18,10 @@ class TenantAssetsController extends Controller // todo rename this to TenantAss $this->middleware(static::$tenancyMiddleware); } - public function asset(string $path = null) + /** + * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + */ + public function asset(string $path = null): BinaryFileResponse { abort_if($path === null, 404); diff --git a/src/Database/Contracts/TenantWithDatabase.php b/src/Database/Contracts/TenantWithDatabase.php index c9247d94..76a73340 100644 --- a/src/Database/Contracts/TenantWithDatabase.php +++ b/src/Database/Contracts/TenantWithDatabase.php @@ -9,5 +9,15 @@ use Stancl\Tenancy\Database\DatabaseConfig; interface TenantWithDatabase extends Tenant { + /** Get the tenant's database config. */ public function database(): DatabaseConfig; + + /** Get the internal prefix. */ + public static function internalPrefix(): string; + + /** Get an internal key. */ + public function getInternal(string $key): mixed; + + /** Set internal key. */ + public function setInternal(string $key, mixed $value): static; } diff --git a/src/Database/DatabaseConfig.php b/src/Database/DatabaseConfig.php index f024b442..6c68f379 100644 --- a/src/Database/DatabaseConfig.php +++ b/src/Database/DatabaseConfig.php @@ -81,7 +81,7 @@ class DatabaseConfig */ public function makeCredentials(): void { - $this->tenant->setInternal('db_name', $this->getName() ?? (static::$databaseNameGenerator)($this->tenant)); + $this->tenant->setInternal('db_name', $this->getName()); if ($this->manager() instanceof Contracts\ManagesDatabaseUsers) { $this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($this->tenant)); diff --git a/src/Features/TenantConfig.php b/src/Features/TenantConfig.php index 33f1c7dd..5bc84060 100644 --- a/src/Features/TenantConfig.php +++ b/src/Features/TenantConfig.php @@ -18,7 +18,7 @@ class TenantConfig implements Feature { public array $originalConfig = []; - /** @var array */ + /** @var array */ public static array $storageToConfigMap = [ // 'paypal_api_key' => 'services.paypal.api_key', ]; @@ -30,7 +30,10 @@ class TenantConfig implements Feature public function bootstrap(Tenancy $tenancy): void { Event::listen(TenancyBootstrapped::class, function (TenancyBootstrapped $event) { - $this->setTenantConfig($event->tenancy->tenant); + /** @var Tenant $tenant */ + $tenant = $event->tenancy->tenant; + + $this->setTenantConfig($tenant); }); Event::listen(RevertedToCentralContext::class, function () { @@ -40,8 +43,8 @@ class TenantConfig implements Feature public function setTenantConfig(Tenant $tenant): void { - /** @var Tenant|Model $tenant */ foreach (static::$storageToConfigMap as $storageKey => $configKey) { + /** @var Tenant&Model $tenant */ $override = Arr::get($tenant, $storageKey); if (! is_null($override)) { diff --git a/src/Listeners/BootstrapTenancy.php b/src/Listeners/BootstrapTenancy.php index 205efc5f..50f38208 100644 --- a/src/Listeners/BootstrapTenancy.php +++ b/src/Listeners/BootstrapTenancy.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Listeners; +use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Events\BootstrappingTenancy; use Stancl\Tenancy\Events\TenancyBootstrapped; use Stancl\Tenancy\Events\TenancyInitialized; @@ -15,7 +16,10 @@ class BootstrapTenancy event(new BootstrappingTenancy($event->tenancy)); foreach ($event->tenancy->getBootstrappers() as $bootstrapper) { - $bootstrapper->bootstrap($event->tenancy->tenant); + /** @var Tenant $tenant */ + $tenant = $event->tenancy->tenant; + + $bootstrapper->bootstrap($tenant); } event(new TenancyBootstrapped($event->tenancy)); diff --git a/src/Tenancy.php b/src/Tenancy.php index c93df4fb..5fe6bd52 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -99,19 +99,30 @@ class Tenancy { $class = config('tenancy.tenant_model'); - return new $class; + /** @var Tenant&Model $model */ + $model = new $class; + + return $model; } + /** + * Try to find a tenant using an ID. + * + * @return (Tenant&Model)|null + */ public static function find(int|string $id): Tenant|null { - return static::model()->where(static::model()->getTenantKeyName(), $id)->first(); + /** @var (Tenant&Model)|null */ + $tenant = static::model()->where(static::model()->getTenantKeyName(), $id)->first(); + + return $tenant; } /** * Run a callback in the central context. * Atomic, safely reverts to previous context. */ - public function central(Closure $callback) + public function central(Closure $callback): mixed { $previousTenant = $this->tenant; @@ -132,7 +143,7 @@ class Tenancy * Run a callback for multiple tenants. * More performant than running $tenant->run() one by one. * - * @param array|array|\Traversable|null $tenants + * @param array|array|\Traversable|string|int|null $tenants */ public function runForMultiple($tenants, Closure $callback): void { @@ -155,6 +166,7 @@ class Tenancy $tenant = $this->find($tenant); } + /** @var Tenant $tenant */ $this->initialize($tenant); $callback($tenant); } diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index f7785bf2..219d87b4 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Illuminate\Database\DatabaseManager; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; @@ -201,8 +202,9 @@ test('run command with array of tenants works', function () { Artisan::call('tenants:migrate-fresh'); pest()->artisan("tenants:run --tenants=$tenantId1 --tenants=$tenantId2 'foo foo --b=bar --c=xyz'") - ->expectsOutput('Tenant: ' . $tenantId1) - ->expectsOutput('Tenant: ' . $tenantId2); + ->expectsOutputToContain('Tenant: ' . $tenantId1) + ->expectsOutputToContain('Tenant: ' . $tenantId2) + ->assertExitCode(0); }); test('link command works', function() { From 065b029f485e5512786270b4088eb8f79a8d3efc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Fri, 30 Sep 2022 00:11:47 +0200 Subject: [PATCH 26/48] fix phpstan issues in FilesystemTenancyBootstrapper --- src/Bootstrappers/FilesystemTenancyBootstrapper.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index cb77a752..e9e0d93d 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -47,7 +47,7 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper // asset() if ($this->app['config']['tenancy.filesystem.asset_helper_tenancy'] ?? true) { if ($this->originalPaths['asset_url']) { - $this->app['config']['app.asset_url'] = ($this->originalPaths['asset_url'] ?? $this->app['config']['app.url']) . "/$suffix"; + $this->app['config']['app.asset_url'] = $this->originalPaths['asset_url'] . "/$suffix"; $this->app['url']->setAssetRoot($this->app['config']['app.asset_url']); } else { $this->app['url']->setAssetRoot($this->app['url']->route('stancl.tenancy.asset', ['path' => ''])); @@ -84,7 +84,7 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper if ($url = str_replace( '%tenant_id%', - $tenant->getTenantKey(), + (string) $tenant->getTenantKey(), $this->app['config']["tenancy.filesystem.url_override.{$disk}"] ?? '' )) { $this->app['config']["filesystems.disks.{$disk}.url"] = url($url); From 24146b26e2542cd03cb6b1c38b04e8551f1054e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Sat, 1 Oct 2022 17:59:33 +0200 Subject: [PATCH 27/48] fix #854 --- assets/config.php | 8 ++++++++ phpunit.xml | 2 +- src/Database/Concerns/BelongsToTenant.php | 8 +++----- src/Database/Concerns/HasScopedValidationRules.php | 4 ++-- src/Database/Concerns/InvalidatesResolverCache.php | 2 +- src/Database/TenantScope.php | 2 +- tests/SingleDatabaseTenancyTest.php | 4 +--- 7 files changed, 17 insertions(+), 13 deletions(-) diff --git a/assets/config.php b/assets/config.php index 7aff2b65..2827532a 100644 --- a/assets/config.php +++ b/assets/config.php @@ -215,4 +215,12 @@ return [ '--class' => 'DatabaseSeeder', // root seeder class // '--force' => true, ], + + /** + * Single-database tenancy config. + */ + 'single_db' => [ + /** The name of the column used by models with the BelongsToTenant trait. */ + 'tenant_id_column' => 'tenant_id', + ], ]; diff --git a/phpunit.xml b/phpunit.xml index 28fc8a08..9d2b9339 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -35,4 +35,4 @@ - \ No newline at end of file + diff --git a/src/Database/Concerns/BelongsToTenant.php b/src/Database/Concerns/BelongsToTenant.php index ade966a8..23c66332 100644 --- a/src/Database/Concerns/BelongsToTenant.php +++ b/src/Database/Concerns/BelongsToTenant.php @@ -12,11 +12,9 @@ use Stancl\Tenancy\Database\TenantScope; */ trait BelongsToTenant { - public static $tenantIdColumn = 'tenant_id'; - public function tenant() { - return $this->belongsTo(config('tenancy.tenant_model'), BelongsToTenant::$tenantIdColumn); + return $this->belongsTo(config('tenancy.tenant_model'), config('tenancy.single_db.tenant_id_column')); } public static function bootBelongsToTenant(): void @@ -24,9 +22,9 @@ trait BelongsToTenant static::addGlobalScope(new TenantScope); static::creating(function ($model) { - if (! $model->getAttribute(BelongsToTenant::$tenantIdColumn) && ! $model->relationLoaded('tenant')) { + if (! $model->getAttribute(config('tenancy.single_db.tenant_id_column')) && ! $model->relationLoaded('tenant')) { if (tenancy()->initialized) { - $model->setAttribute(BelongsToTenant::$tenantIdColumn, tenant()->getTenantKey()); + $model->setAttribute(config('tenancy.single_db.tenant_id_column'), tenant()->getTenantKey()); $model->setRelation('tenant', tenant()); } } diff --git a/src/Database/Concerns/HasScopedValidationRules.php b/src/Database/Concerns/HasScopedValidationRules.php index ae5c7fc7..8288f2e3 100644 --- a/src/Database/Concerns/HasScopedValidationRules.php +++ b/src/Database/Concerns/HasScopedValidationRules.php @@ -11,11 +11,11 @@ trait HasScopedValidationRules { public function unique($table, $column = 'NULL') { - return (new Unique($table, $column))->where(BelongsToTenant::$tenantIdColumn, $this->getTenantKey()); + return (new Unique($table, $column))->where(config('tenancy.single_db.tenant_id_column'), $this->getTenantKey()); } public function exists($table, $column = 'NULL') { - return (new Exists($table, $column))->where(BelongsToTenant::$tenantIdColumn, $this->getTenantKey()); + return (new Exists($table, $column))->where(config('tenancy.single_db.tenant_id_column'), $this->getTenantKey()); } } diff --git a/src/Database/Concerns/InvalidatesResolverCache.php b/src/Database/Concerns/InvalidatesResolverCache.php index 82a85997..fee3a076 100644 --- a/src/Database/Concerns/InvalidatesResolverCache.php +++ b/src/Database/Concerns/InvalidatesResolverCache.php @@ -11,7 +11,7 @@ use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver; trait InvalidatesResolverCache { /** @var array> */ - public static $resolvers = [ + public static $resolvers = [ // todo@deprecated, move this to a config key? related to a todo in InvalidatesTenantsResolverCache Resolvers\DomainTenantResolver::class, Resolvers\PathTenantResolver::class, Resolvers\RequestDataTenantResolver::class, diff --git a/src/Database/TenantScope.php b/src/Database/TenantScope.php index 8b887ac0..43214959 100644 --- a/src/Database/TenantScope.php +++ b/src/Database/TenantScope.php @@ -17,7 +17,7 @@ class TenantScope implements Scope return; } - $builder->where($model->qualifyColumn(BelongsToTenant::$tenantIdColumn), tenant()->getTenantKey()); + $builder->where($model->qualifyColumn(config('tenancy.single_db.tenant_id_column')), tenant()->getTenantKey()); } public function extend(Builder $builder): void diff --git a/tests/SingleDatabaseTenancyTest.php b/tests/SingleDatabaseTenancyTest.php index 8914a6d7..ec0a0edf 100644 --- a/tests/SingleDatabaseTenancyTest.php +++ b/tests/SingleDatabaseTenancyTest.php @@ -13,8 +13,6 @@ use Stancl\Tenancy\Database\Concerns\HasScopedValidationRules; use Stancl\Tenancy\Tests\Etc\Tenant as TestTenant; beforeEach(function () { - BelongsToTenant::$tenantIdColumn = 'tenant_id'; - Schema::create('posts', function (Blueprint $table) { $table->increments('id'); $table->string('text'); @@ -144,7 +142,7 @@ test('tenant id is not auto added when creating primary resources in central con }); test('tenant id column name can be customized', function () { - BelongsToTenant::$tenantIdColumn = 'team_id'; + config(['tenancy.single_db.tenant_id_column' => 'team_id']); Schema::drop('comments'); Schema::drop('posts'); From e5bc8ddb776f79f93ee72c0580fb2250e73f7f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Sat, 1 Oct 2022 18:01:39 +0200 Subject: [PATCH 28/48] add tenantIdColumn() method (refactor previous commit) --- src/Database/Concerns/BelongsToTenant.php | 11 ++++++++--- src/Database/Concerns/HasScopedValidationRules.php | 4 ++-- src/Database/TenantScope.php | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Database/Concerns/BelongsToTenant.php b/src/Database/Concerns/BelongsToTenant.php index 23c66332..07048a1f 100644 --- a/src/Database/Concerns/BelongsToTenant.php +++ b/src/Database/Concerns/BelongsToTenant.php @@ -14,7 +14,12 @@ trait BelongsToTenant { public function tenant() { - return $this->belongsTo(config('tenancy.tenant_model'), config('tenancy.single_db.tenant_id_column')); + return $this->belongsTo(config('tenancy.tenant_model'), static::tenantIdColumn()); + } + + public static function tenantIdColumn(): string + { + return config('tenancy.single_db.tenant_id_column'); } public static function bootBelongsToTenant(): void @@ -22,9 +27,9 @@ trait BelongsToTenant static::addGlobalScope(new TenantScope); static::creating(function ($model) { - if (! $model->getAttribute(config('tenancy.single_db.tenant_id_column')) && ! $model->relationLoaded('tenant')) { + if (! $model->getAttribute(static::tenantIdColumn()) && ! $model->relationLoaded('tenant')) { if (tenancy()->initialized) { - $model->setAttribute(config('tenancy.single_db.tenant_id_column'), tenant()->getTenantKey()); + $model->setAttribute(static::tenantIdColumn(), tenant()->getTenantKey()); $model->setRelation('tenant', tenant()); } } diff --git a/src/Database/Concerns/HasScopedValidationRules.php b/src/Database/Concerns/HasScopedValidationRules.php index 8288f2e3..7913a215 100644 --- a/src/Database/Concerns/HasScopedValidationRules.php +++ b/src/Database/Concerns/HasScopedValidationRules.php @@ -11,11 +11,11 @@ trait HasScopedValidationRules { public function unique($table, $column = 'NULL') { - return (new Unique($table, $column))->where(config('tenancy.single_db.tenant_id_column'), $this->getTenantKey()); + return (new Unique($table, $column))->where(BelongsToTenant::tenantIdColumn(), $this->getTenantKey()); } public function exists($table, $column = 'NULL') { - return (new Exists($table, $column))->where(config('tenancy.single_db.tenant_id_column'), $this->getTenantKey()); + return (new Exists($table, $column))->where(BelongsToTenant::tenantIdColumn(), $this->getTenantKey()); } } diff --git a/src/Database/TenantScope.php b/src/Database/TenantScope.php index 43214959..fdab9d70 100644 --- a/src/Database/TenantScope.php +++ b/src/Database/TenantScope.php @@ -17,7 +17,7 @@ class TenantScope implements Scope return; } - $builder->where($model->qualifyColumn(config('tenancy.single_db.tenant_id_column')), tenant()->getTenantKey()); + $builder->where($model->qualifyColumn(BelongsToTenant::tenantIdColumn()), tenant()->getTenantKey()); } public function extend(Builder $builder): void From ccaba0527221ef5bc30c9e6755bb4bd9b07a2199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Sat, 1 Oct 2022 20:01:18 +0200 Subject: [PATCH 29/48] Add identification section to config, refactor static properties --- assets/TenancyServiceProvider.stub.php | 12 +---- assets/config.php | 52 +++++++++++++++++++ assets/routes.php | 3 +- ...ntroller.php => TenantAssetController.php} | 8 ++- .../Concerns/InvalidatesResolverCache.php | 11 +--- .../InvalidatesTenantsResolverCache.php | 11 +--- ...RouteIsMissingTenantParameterException.php | 2 +- src/Middleware/InitializeTenancyByPath.php | 22 +++++--- .../Contracts/CachedTenantResolver.php | 29 +++++++---- src/Resolvers/DomainTenantResolver.php | 6 --- src/Resolvers/PathTenantResolver.php | 17 +++--- src/Tenancy.php | 41 ++++++++++++++- src/TenancyServiceProvider.php | 2 +- tests/CachedTenantResolverTest.php | 12 ++--- tests/PathIdentificationTest.php | 9 +--- tests/TenantAssetTest.php | 11 +--- tests/TestCase.php | 2 +- 17 files changed, 153 insertions(+), 97 deletions(-) rename src/Controllers/{TenantAssetsController.php => TenantAssetController.php} (65%) diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index a3626225..62ab8d0a 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -144,16 +144,8 @@ class TenancyServiceProvider extends ServiceProvider protected function makeTenancyMiddlewareHighestPriority() { - $tenancyMiddleware = [ - // Even higher priority than the initialization middleware - Middleware\PreventAccessFromCentralDomains::class, - - Middleware\InitializeTenancyByDomain::class, - Middleware\InitializeTenancyBySubdomain::class, - Middleware\InitializeTenancyByDomainOrSubdomain::class, - Middleware\InitializeTenancyByPath::class, - Middleware\InitializeTenancyByRequestData::class, - ]; + // Even higher priority than the initialization middleware + $tenancyMiddleware = array_merge([Middleware\PreventAccessFromCentralDomains::class], config('tenancy.identification.middleware')); foreach (array_reverse($tenancyMiddleware) as $middleware) { $this->app[\Illuminate\Contracts\Http\Kernel::class]->prependToMiddlewarePriority($middleware); diff --git a/assets/config.php b/assets/config.php index 2827532a..6130bade 100644 --- a/assets/config.php +++ b/assets/config.php @@ -4,6 +4,8 @@ declare(strict_types=1); use Stancl\Tenancy\Database\Models\Domain; use Stancl\Tenancy\Database\Models\Tenant; +use Stancl\Tenancy\Middleware; +use Stancl\Tenancy\Resolvers; return [ 'tenant_model' => Tenant::class, @@ -21,6 +23,56 @@ return [ 'localhost', ], + 'identification' => [ + /** + * The default middleware used for tenant identification. + * + * If you use multiple forms of identification, you can set this to the "main" approach you use. + */ + 'default_middleware' => Middleware\InitializeTenancyByDomain::class,// todo@identification add this to a 'tenancy' mw group + + /** + * All of the identification middleware used by the package. + * + * If you write your own, make sure to add them to this array. + */ + 'middleware' => [ + Middleware\InitializeTenancyByDomain::class, + Middleware\InitializeTenancyBySubdomain::class, + Middleware\InitializeTenancyByDomainOrSubdomain::class, + Middleware\InitializeTenancyByPath::class, + Middleware\InitializeTenancyByRequestData::class, + ], + + /** + * Tenant resolvers used by the package. + * + * Resolvers which implement the CachedTenantResolver contract have options for configuring the caching details. + * If you add your own resolvers, do not add the 'cache' key unless your resolver is based on CachedTenantResolver. + */ + 'resolvers' => [ + Resolvers\DomainTenantResolver::class => [ + 'cache' => false, + 'cache_ttl' => 3600, // seconds + 'cache_store' => null, // default + ], + Resolvers\PathTenantResolver::class => [ + 'tenant_parameter_name' => 'tenant', + + 'cache' => false, + 'cache_ttl' => 3600, // seconds + 'cache_store' => null, // default + ], + Resolvers\RequestDataTenantResolver::class => [ + 'cache' => false, + 'cache_ttl' => 3600, // seconds + 'cache_store' => null, // default + ], + ], + + // todo@docs update integration guides to use Stancl\Tenancy::defaultMiddleware() + ], + /** * Tenancy bootstrappers are executed when tenancy is initialized. * Their responsibility is making Laravel features tenant-aware. diff --git a/assets/routes.php b/assets/routes.php index 9223c099..a27f782d 100644 --- a/assets/routes.php +++ b/assets/routes.php @@ -3,7 +3,8 @@ declare(strict_types=1); use Illuminate\Support\Facades\Route; +use Stancl\Tenancy\Controllers\TenantAssetController; -Route::get('/tenancy/assets/{path?}', 'Stancl\Tenancy\Controllers\TenantAssetsController@asset') +Route::get('/tenancy/assets/{path?}', [TenantAssetController::class, 'asset']) ->where('path', '(.*)') ->name('stancl.tenancy.asset'); diff --git a/src/Controllers/TenantAssetsController.php b/src/Controllers/TenantAssetController.php similarity index 65% rename from src/Controllers/TenantAssetsController.php rename to src/Controllers/TenantAssetController.php index 615f8054..7a95dffe 100644 --- a/src/Controllers/TenantAssetsController.php +++ b/src/Controllers/TenantAssetController.php @@ -4,18 +4,16 @@ declare(strict_types=1); namespace Stancl\Tenancy\Controllers; -use Closure; use Illuminate\Routing\Controller; +use Stancl\Tenancy\Tenancy; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Throwable; -class TenantAssetsController extends Controller // todo rename this to TenantAssetController & update references in docs +class TenantAssetController extends Controller // todo@docs this was renamed from TenantAssetsController { - public static string|array|Closure $tenancyMiddleware = \Stancl\Tenancy\Middleware\InitializeTenancyByDomain::class; - public function __construct() { - $this->middleware(static::$tenancyMiddleware); + $this->middleware(Tenancy::defaultMiddleware()); } /** diff --git a/src/Database/Concerns/InvalidatesResolverCache.php b/src/Database/Concerns/InvalidatesResolverCache.php index fee3a076..21894f41 100644 --- a/src/Database/Concerns/InvalidatesResolverCache.php +++ b/src/Database/Concerns/InvalidatesResolverCache.php @@ -5,22 +5,15 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\Concerns; use Stancl\Tenancy\Contracts\Tenant; -use Stancl\Tenancy\Resolvers; use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver; +use Stancl\Tenancy\Tenancy; trait InvalidatesResolverCache { - /** @var array> */ - public static $resolvers = [ // todo@deprecated, move this to a config key? related to a todo in InvalidatesTenantsResolverCache - Resolvers\DomainTenantResolver::class, - Resolvers\PathTenantResolver::class, - Resolvers\RequestDataTenantResolver::class, - ]; - public static function bootInvalidatesResolverCache(): void { static::saved(function (Tenant $tenant) { - foreach (static::$resolvers as $resolver) { + foreach (Tenancy::cachedResolvers() as $resolver) { /** @var CachedTenantResolver $resolver */ $resolver = app($resolver); diff --git a/src/Database/Concerns/InvalidatesTenantsResolverCache.php b/src/Database/Concerns/InvalidatesTenantsResolverCache.php index aa7fac4b..d954567f 100644 --- a/src/Database/Concerns/InvalidatesTenantsResolverCache.php +++ b/src/Database/Concerns/InvalidatesTenantsResolverCache.php @@ -5,25 +5,18 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\Concerns; use Illuminate\Database\Eloquent\Model; -use Stancl\Tenancy\Resolvers; use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver; +use Stancl\Tenancy\Tenancy; /** * Meant to be used on models that belong to tenants. */ trait InvalidatesTenantsResolverCache { - /** @var array> */ - public static array $resolvers = [ // todo single source of truth for this here and in InvalidatesResolverCache - Resolvers\DomainTenantResolver::class, - Resolvers\PathTenantResolver::class, - Resolvers\RequestDataTenantResolver::class, - ]; - public static function bootInvalidatesTenantsResolverCache(): void { static::saved(function (Model $model) { - foreach (static::$resolvers as $resolver) { + foreach (Tenancy::cachedResolvers() as $resolver) { /** @var CachedTenantResolver $resolver */ $resolver = app($resolver); diff --git a/src/Exceptions/RouteIsMissingTenantParameterException.php b/src/Exceptions/RouteIsMissingTenantParameterException.php index b979c819..afe56ea7 100644 --- a/src/Exceptions/RouteIsMissingTenantParameterException.php +++ b/src/Exceptions/RouteIsMissingTenantParameterException.php @@ -11,7 +11,7 @@ class RouteIsMissingTenantParameterException extends Exception { public function __construct() { - $parameter = PathTenantResolver::$tenantParameterName; + $parameter = PathTenantResolver::tenantParameterName(); parent::__construct("The route's first argument is not the tenant id (configured paramter name: $parameter)."); } diff --git a/src/Middleware/InitializeTenancyByPath.php b/src/Middleware/InitializeTenancyByPath.php index d4733f62..3e484f87 100644 --- a/src/Middleware/InitializeTenancyByPath.php +++ b/src/Middleware/InitializeTenancyByPath.php @@ -34,14 +34,8 @@ class InitializeTenancyByPath extends IdentificationMiddleware // Only initialize tenancy if tenant is the first parameter // We don't want to initialize tenancy if the tenant is // simply injected into some route controller action. - if ($route->parameterNames()[0] === PathTenantResolver::$tenantParameterName) { - // Set tenant as a default parameter for the URLs in the current request - Event::listen(InitializingTenancy::class, function (InitializingTenancy $event) { - /** @var Tenant $tenant */ - $tenant = $event->tenancy->tenant; - - URL::defaults([PathTenantResolver::$tenantParameterName => $tenant->getTenantKey()]); - }); + if ($route->parameterNames()[0] === PathTenantResolver::tenantParameterName()) { + $this->setDefaultTenantForRouteParametersWhenTenancyIsInitialized(); return $this->initializeTenancy( $request, @@ -54,4 +48,16 @@ class InitializeTenancyByPath extends IdentificationMiddleware return $next($request); } + + protected function setDefaultTenantForRouteParametersWhenTenancyIsInitialized(): void + { + Event::listen(InitializingTenancy::class, function (InitializingTenancy $event) { + /** @var Tenant $tenant */ + $tenant = $event->tenancy->tenant; + + URL::defaults([ + PathTenantResolver::tenantParameterName() => $tenant->getTenantKey(), + ]); + }); + } } diff --git a/src/Resolvers/Contracts/CachedTenantResolver.php b/src/Resolvers/Contracts/CachedTenantResolver.php index d4d5ba6e..b6a4b15c 100644 --- a/src/Resolvers/Contracts/CachedTenantResolver.php +++ b/src/Resolvers/Contracts/CachedTenantResolver.php @@ -11,23 +11,17 @@ use Stancl\Tenancy\Contracts\TenantResolver; abstract class CachedTenantResolver implements TenantResolver { - public static bool $shouldCache = false; // todo docblocks for these - - public static int $cacheTTL = 3600; // seconds - - public static string|null $cacheStore = null; // default - /** @var Repository */ protected $cache; public function __construct(Factory $cache) { - $this->cache = $cache->store(static::$cacheStore); + $this->cache = $cache->store(static::cacheStore()); } public function resolve(mixed ...$args): Tenant { - if (! static::$shouldCache) { + if (! static::shouldCache()) { return $this->resolveWithoutCache(...$args); } @@ -42,14 +36,14 @@ abstract class CachedTenantResolver implements TenantResolver } $tenant = $this->resolveWithoutCache(...$args); - $this->cache->put($key, $tenant, static::$cacheTTL); + $this->cache->put($key, $tenant, static::cacheTTL()); return $tenant; } public function invalidateCache(Tenant $tenant): void { - if (! static::$shouldCache) { + if (! static::shouldCache()) { return; } @@ -75,4 +69,19 @@ abstract class CachedTenantResolver implements TenantResolver * @return array[] */ abstract public function getArgsForTenant(Tenant $tenant): array; + + public static function shouldCache(): bool + { + return config('tenancy.identification.resolvers.' . static::class . '.cache') ?? false; + } + + public static function cacheTTL(): int + { + return config('tenancy.identification.resolvers.' . static::class . '.cache_ttl') ?? 3600; + } + + public static function cacheStore(): string|null + { + return config('tenancy.identification.resolvers.' . static::class . '.cache_store'); + } } diff --git a/src/Resolvers/DomainTenantResolver.php b/src/Resolvers/DomainTenantResolver.php index d2970bb5..cf88f579 100644 --- a/src/Resolvers/DomainTenantResolver.php +++ b/src/Resolvers/DomainTenantResolver.php @@ -14,12 +14,6 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver /** The model representing the domain that the tenant was identified on. */ public static Domain $currentDomain; // todo |null? - public static bool $shouldCache = false; - - public static int $cacheTTL = 3600; // seconds - - public static string|null $cacheStore = null; // default - public function resolveWithoutCache(mixed ...$args): Tenant { $domain = $args[0]; diff --git a/src/Resolvers/PathTenantResolver.php b/src/Resolvers/PathTenantResolver.php index c98ac37e..1359e9c1 100644 --- a/src/Resolvers/PathTenantResolver.php +++ b/src/Resolvers/PathTenantResolver.php @@ -10,21 +10,13 @@ use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByPathException; class PathTenantResolver extends Contracts\CachedTenantResolver { - public static string $tenantParameterName = 'tenant'; - - public static bool $shouldCache = false; - - public static int $cacheTTL = 3600; // seconds - - public static string|null $cacheStore = null; // default - public function resolveWithoutCache(mixed ...$args): Tenant { /** @var Route $route */ $route = $args[0]; - if ($id = (string) $route->parameter(static::$tenantParameterName)) { - $route->forgetParameter(static::$tenantParameterName); + if ($id = (string) $route->parameter(static::tenantParameterName())) { + $route->forgetParameter(static::tenantParameterName()); if ($tenant = tenancy()->find($id)) { return $tenant; @@ -40,4 +32,9 @@ class PathTenantResolver extends Contracts\CachedTenantResolver [$tenant->getTenantKey()], ]; } + + public static function tenantParameterName(): string + { + return config('tenancy.identification.resolvers.' . static::class . '.tenant_parameter_name') ?? 'tenant'; + } } diff --git a/src/Tenancy.php b/src/Tenancy.php index 5fe6bd52..271d36b9 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -42,7 +42,7 @@ class Tenancy } } - // todo0 for phpstan this should be $this->tenant?, but first I want to clean up the $initialized logic and explore removing the property + // todo1 for phpstan this should be $this->tenant?, but first I want to clean up the $initialized logic and explore removing the property if ($this->initialized && $this->tenant->getTenantKey() === $tenant->getTenantKey()) { return; } @@ -157,7 +157,7 @@ class Tenancy $tenants = is_string($tenants) ? [$tenants] : $tenants; // Use all tenants if $tenants is falsey - $tenants = $tenants ?: $this->model()->cursor(); // todo0 phpstan thinks this isn't needed, but tests fail without it + $tenants = $tenants ?: $this->model()->cursor(); // todo1 phpstan thinks this isn't needed, but tests fail without it $originalTenant = $this->tenant; @@ -177,4 +177,41 @@ class Tenancy $this->end(); } } + + /** + * Cached tenant resolvers used by the package. + * + * @return array> + */ + public static function cachedResolvers(): array + { + $resolvers = config('tenancy.identification.resolvers', []); + + $cachedResolvers = array_filter($resolvers, function (array $options) { + // Resolvers based on CachedTenantResolver have the 'cache' option in the resolver config + return isset($options['cache']); + }); + + return array_keys($cachedResolvers); + } + + /** + * Tenant identification middleware used by the package. + * + * @return array> + */ + public static function middleware(): array + { + return config('tenancy.identification.middleware', []); + } + + /** + * Default tenant identification middleware used by the package. + * + * @return class-string + */ + public static function defaultMiddleware(): string + { + return config('tenancy.identification.default_middleware', Middleware\InitializeTenancyByDomain::class); + } } diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index d9556283..7e12a857 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -120,7 +120,7 @@ class TenancyServiceProvider extends ServiceProvider if ($event instanceof TenancyEvent) { match (tenancy()->logMode()) { LogMode::SILENT => tenancy()->logEvent($event), - LogMode::INSTANT => dump($event), // todo0 perhaps still log + LogMode::INSTANT => dump($event), // todo1 perhaps still log default => null, }; } diff --git a/tests/CachedTenantResolverTest.php b/tests/CachedTenantResolverTest.php index d71375be..fa624b04 100644 --- a/tests/CachedTenantResolverTest.php +++ b/tests/CachedTenantResolverTest.php @@ -6,9 +6,7 @@ use Illuminate\Support\Facades\DB; use Stancl\Tenancy\Resolvers\DomainTenantResolver; use Stancl\Tenancy\Tests\Etc\Tenant; -afterEach(function () { - DomainTenantResolver::$shouldCache = false; -}); +// todo@v4 test this with other resolvers as well? test('tenants can be resolved using the cached resolver', function () { $tenant = Tenant::create(); @@ -27,14 +25,14 @@ test('the underlying resolver is not touched when using the cached resolver', fu DB::enableQueryLog(); - DomainTenantResolver::$shouldCache = false; + config(['tenancy.identification.resolvers.' . DomainTenantResolver::class . '.cache' => false]); expect($tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toBeTrue(); DB::flushQueryLog(); expect($tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toBeTrue(); pest()->assertNotEmpty(DB::getQueryLog()); // not empty - DomainTenantResolver::$shouldCache = true; + config(['tenancy.identification.resolvers.' . DomainTenantResolver::class . '.cache' => true]); expect($tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toBeTrue(); DB::flushQueryLog(); @@ -50,7 +48,7 @@ test('cache is invalidated when the tenant is updated', function () { DB::enableQueryLog(); - DomainTenantResolver::$shouldCache = true; + config(['tenancy.identification.resolvers.' . DomainTenantResolver::class . '.cache' => true]); expect($tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toBeTrue(); DB::flushQueryLog(); @@ -74,7 +72,7 @@ test('cache is invalidated when a tenants domain is changed', function () { DB::enableQueryLog(); - DomainTenantResolver::$shouldCache = true; + config(['tenancy.identification.resolvers.' . DomainTenantResolver::class . '.cache' => true]); expect($tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toBeTrue(); DB::flushQueryLog(); diff --git a/tests/PathIdentificationTest.php b/tests/PathIdentificationTest.php index 517fa396..32880c4f 100644 --- a/tests/PathIdentificationTest.php +++ b/tests/PathIdentificationTest.php @@ -10,8 +10,6 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver; use Stancl\Tenancy\Tests\Etc\Tenant; beforeEach(function () { - PathTenantResolver::$tenantParameterName = 'tenant'; - Route::group([ 'prefix' => '/{tenant}', 'middleware' => InitializeTenancyByPath::class, @@ -26,11 +24,6 @@ beforeEach(function () { }); }); -afterEach(function () { - // Global state cleanup - PathTenantResolver::$tenantParameterName = 'tenant'; -}); - test('tenant can be identified by path', function () { Tenant::create([ 'id' => 'acme', @@ -101,7 +94,7 @@ test('an exception is thrown when the routes first parameter is not tenant', fun }); test('tenant parameter name can be customized', function () { - PathTenantResolver::$tenantParameterName = 'team'; + config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => 'team']); Route::group([ 'prefix' => '/{team}', diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index d43b7989..a1cd0f5b 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -6,10 +6,8 @@ use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Storage; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; -use Stancl\Tenancy\Controllers\TenantAssetsController; use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Listeners\BootstrapTenancy; -use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData; use Stancl\Tenancy\Tests\Etc\Tenant; @@ -21,13 +19,8 @@ beforeEach(function () { Event::listen(TenancyInitialized::class, BootstrapTenancy::class); }); -afterEach(function () { - // Cleanup - TenantAssetsController::$tenancyMiddleware = InitializeTenancyByDomain::class; -}); - test('asset can be accessed using the url returned by the tenant asset helper', function () { - TenantAssetsController::$tenancyMiddleware = InitializeTenancyByRequestData::class; + config(['tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class]); $tenant = Tenant::create(); tenancy()->initialize($tenant); @@ -95,7 +88,7 @@ test('asset helper tenancy can be disabled', function () { }); test('test asset controller returns a 404 when no path is provided', function () { - TenantAssetsController::$tenancyMiddleware = InitializeTenancyByRequestData::class; + config(['tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class]); $tenant = Tenant::create(); diff --git a/tests/TestCase.php b/tests/TestCase.php index f7f8b9ad..1c0ceb83 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -103,7 +103,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase '--realpath' => true, '--force' => true, ], - 'tenancy.bootstrappers.redis' => RedisTenancyBootstrapper::class, // todo0 change this to []? two tests in TenantDatabaseManagerTest are failing with that + 'tenancy.bootstrappers.redis' => RedisTenancyBootstrapper::class, // todo1 change this to []? two tests in TenantDatabaseManagerTest are failing with that 'queue.connections.central' => [ 'driver' => 'sync', 'central' => true, From 6ee93d044162209ee7e640421bf3e9851345fbb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Sat, 1 Oct 2022 20:02:13 +0200 Subject: [PATCH 30/48] update comment about middleware order --- assets/TenancyServiceProvider.stub.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 62ab8d0a..75784361 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -144,7 +144,7 @@ class TenancyServiceProvider extends ServiceProvider protected function makeTenancyMiddlewareHighestPriority() { - // Even higher priority than the initialization middleware + // PreventAccessFromCentralDomains has even higher priority than the identification middleware $tenancyMiddleware = array_merge([Middleware\PreventAccessFromCentralDomains::class], config('tenancy.identification.middleware')); foreach (array_reverse($tenancyMiddleware) as $middleware) { From 76a3e269c885736e6e9d00e6e89247be2f3fa042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Sun, 9 Oct 2022 19:53:14 +0200 Subject: [PATCH 31/48] final class -> class --- src/Commands/MigrateFresh.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Commands/MigrateFresh.php b/src/Commands/MigrateFresh.php index 56a6047f..32dc6ee5 100644 --- a/src/Commands/MigrateFresh.php +++ b/src/Commands/MigrateFresh.php @@ -8,7 +8,7 @@ use Illuminate\Console\Command; use Stancl\Tenancy\Concerns\HasATenantsOption; use Symfony\Component\Console\Input\InputOption; -final class MigrateFresh extends Command +class MigrateFresh extends Command { use HasATenantsOption; From 42dab2985ae81662b06a2211caafd8a7613eda2c Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 11 Oct 2022 10:33:32 +0200 Subject: [PATCH 32/48] Add `current()` and `currentOrFail()` tenant methods (#970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add and test `Tenant::current()` * Add and test `Tenant::currentOrFail()` * Fix code style (php-cs-fixer) * Update currentOrFail declaration Co-authored-by: Samuel Štancl * Change self return type to static Co-authored-by: PHP CS Fixer Co-authored-by: Samuel Štancl --- src/Database/Models/Tenant.php | 12 ++++++++++++ tests/TenantModelTest.php | 26 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/Database/Models/Tenant.php b/src/Database/Models/Tenant.php index 88c34146..83e75332 100644 --- a/src/Database/Models/Tenant.php +++ b/src/Database/Models/Tenant.php @@ -10,6 +10,7 @@ use Stancl\Tenancy\Contracts; use Stancl\Tenancy\Database\Concerns; use Stancl\Tenancy\Database\TenantCollection; use Stancl\Tenancy\Events; +use Stancl\Tenancy\Exceptions\TenancyNotInitializedException; /** * @property string|int $id @@ -45,6 +46,17 @@ class Tenant extends Model implements Contracts\Tenant return $this->getAttribute($this->getTenantKeyName()); } + public static function current(): static|null + { + return tenant(); + } + + /** @throws TenancyNotInitializedException */ + public static function currentOrFail(): static + { + return static::current() ?? throw new TenancyNotInitializedException; + } + public function newCollection(array $models = []): TenantCollection { return new TenantCollection($models); diff --git a/tests/TenantModelTest.php b/tests/TenantModelTest.php index b4fd38f6..fb62260c 100644 --- a/tests/TenantModelTest.php +++ b/tests/TenantModelTest.php @@ -18,6 +18,7 @@ use Stancl\Tenancy\Jobs\CreateDatabase; use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\UUIDGenerator; +use Stancl\Tenancy\Exceptions\TenancyNotInitializedException; test('created event is dispatched', function () { Event::fake([TenantCreated::class]); @@ -141,6 +142,31 @@ test('a command can be run on a collection of tenants', function () { expect(Tenant::find('t2')->foo)->toBe('xyz'); }); +test('the current method returns the currently initialized tenant', function() { + tenancy()->initialize($tenant = Tenant::create()); + + expect(Tenant::current())->toBe($tenant); +}); + +test('the current method returns null if there is no currently initialized tenant', function() { + tenancy()->end(); + + expect(Tenant::current())->toBeNull(); +}); + +test('currentOrFail method returns the currently initialized tenant', function() { + tenancy()->initialize($tenant = Tenant::create()); + + expect(Tenant::currentOrFail())->toBe($tenant); +}); + +test('currentOrFail method throws an exception if there is no currently initialized tenant', function() { + tenancy()->end(); + + expect(fn() => Tenant::currentOrFail())->toThrow(TenancyNotInitializedException::class); +}); + + class MyTenant extends Tenant { protected $table = 'tenants'; From 080b271bb372d9ea3ef30e0ef31988db92b954f0 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 17 Oct 2022 15:19:30 +0200 Subject: [PATCH 33/48] [4.x] Drop tenant databases on `migrate:fresh` (#971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Test that `migrate:fresh` deletes tenant databases * Delete tenants on `migrate:fresh` * Fix code style (php-cs-fixer) * Add config key for dropping tenant databases on `migrate:fresh` * Add MigrateFreshOverride * Try to override `migrate:fresh` in TenancyServiceProvider * Update `migrate:fresh` test * Fix code style (php-cs-fixer) * Drop tenant databases by default * Change `migrate:fresh` test to test if the tenant DBs are dropped by default * Override `migrate:fresh` by extending `FreshCommand` in TenancyServiceProvider * Update MigrateFreshOverride * Fix code style (php-cs-fixer) * Fix commands test * Simplify handle method * Fix code style (php-cs-fixer) * Don't drop tenant DBs on migrate:fresh by default * Change command overriding * Update migrate:fresh test * always register MigrateFreshOverride * misc Co-authored-by: PHP CS Fixer Co-authored-by: Samuel Štancl --- assets/config.php | 19 ++++---- composer.json | 3 +- phpstan.neon | 1 + src/Commands/MigrateFreshOverride.php | 19 ++++++++ src/Events/Contracts/TenantEvent.php | 2 +- src/TenancyServiceProvider.php | 5 ++ tests/CommandsTest.php | 67 ++++++++++++++++++++++----- 7 files changed, 95 insertions(+), 21 deletions(-) create mode 100644 src/Commands/MigrateFreshOverride.php diff --git a/assets/config.php b/assets/config.php index 6130bade..cd0a6c42 100644 --- a/assets/config.php +++ b/assets/config.php @@ -116,18 +116,21 @@ return [ 'pgsql' => Stancl\Tenancy\Database\TenantDatabaseManagers\PostgreSQLDatabaseManager::class, 'sqlsrv' => Stancl\Tenancy\Database\TenantDatabaseManagers\MicrosoftSQLDatabaseManager::class, - /** - * Use this database manager for MySQL to have a DB user created for each tenant database. - * You can customize the grants given to these users by changing the $grants property. - */ + /** + * Use this database manager for MySQL to have a DB user created for each tenant database. + * You can customize the grants given to these users by changing the $grants property. + */ // 'mysql' => Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMySQLDatabaseManager::class, - /** - * Disable the pgsql manager above, and enable the one below if you - * want to separate tenant DBs by schemas rather than databases. - */ + /** + * Disable the pgsql manager above, and enable the one below if you + * want to separate tenant DBs by schemas rather than databases. + */ // 'pgsql' => Stancl\Tenancy\Database\TenantDatabaseManagers\PostgreSQLSchemaManager::class, // Separate by schema instead of database ], + + // todo docblock + 'drop_tenant_databases_on_migrate_fresh' => false, ], /** diff --git a/composer.json b/composer.json index 0dc9df09..b30ea94c 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,8 @@ "doctrine/dbal": "^2.10", "spatie/valuestore": "^1.2.5", "pestphp/pest": "^1.21", - "nunomaduro/larastan": "^1.0" + "nunomaduro/larastan": "^1.0", + "spatie/invade": "^1.1" }, "autoload": { "psr-4": { diff --git a/phpstan.neon b/phpstan.neon index 3e9ba51d..0567d5ff 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,6 @@ includes: - ./vendor/nunomaduro/larastan/extension.neon + - ./vendor/spatie/invade/phpstan-extension.neon parameters: paths: diff --git a/src/Commands/MigrateFreshOverride.php b/src/Commands/MigrateFreshOverride.php new file mode 100644 index 00000000..88e9e21e --- /dev/null +++ b/src/Commands/MigrateFreshOverride.php @@ -0,0 +1,19 @@ +model()::cursor()->each->delete(); + } + + return parent::handle(); + } +} diff --git a/src/Events/Contracts/TenantEvent.php b/src/Events/Contracts/TenantEvent.php index 951fabfc..e07708b7 100644 --- a/src/Events/Contracts/TenantEvent.php +++ b/src/Events/Contracts/TenantEvent.php @@ -7,7 +7,7 @@ namespace Stancl\Tenancy\Events\Contracts; use Illuminate\Queue\SerializesModels; use Stancl\Tenancy\Contracts\Tenant; -abstract class TenantEvent +abstract class TenantEvent // todo we could add a feature to JobPipeline that automatically gets data for the send() from here { use SerializesModels; diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index 7e12a857..63a22a11 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Stancl\Tenancy; use Illuminate\Cache\CacheManager; +use Illuminate\Database\Console\Migrations\FreshCommand; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; @@ -90,6 +91,10 @@ class TenancyServiceProvider extends ServiceProvider Commands\Up::class, ]); + $this->app->extend(FreshCommand::class, function () { + return new Commands\MigrateFreshOverride; + }); + $this->publishes([ __DIR__ . '/../assets/config.php' => config_path('tenancy.php'), ], 'config'); diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index 219d87b4..bac5005d 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -2,24 +2,28 @@ declare(strict_types=1); -use Illuminate\Database\DatabaseManager; -use Illuminate\Support\Carbon; -use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; +use Stancl\Tenancy\Tests\Etc\User; +use Stancl\JobPipeline\JobPipeline; +use Stancl\Tenancy\Tests\Etc\Tenant; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Schema; -use Stancl\JobPipeline\JobPipeline; -use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; +use Stancl\Tenancy\Jobs\DeleteDomains; +use Illuminate\Support\Facades\Artisan; use Stancl\Tenancy\Events\TenancyEnded; -use Stancl\Tenancy\Events\TenancyInitialized; -use Stancl\Tenancy\Events\TenantCreated; use Stancl\Tenancy\Jobs\CreateDatabase; +use Stancl\Tenancy\Jobs\DeleteDatabase; +use Illuminate\Database\DatabaseManager; +use Stancl\Tenancy\Events\TenantCreated; +use Stancl\Tenancy\Events\TenantDeleted; +use Stancl\Tenancy\Tests\Etc\TestSeeder; +use Stancl\Tenancy\Events\DeletingTenant; +use Stancl\Tenancy\Tests\Etc\ExampleSeeder; +use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\RevertToCentralContext; -use Stancl\Tenancy\Tests\Etc\ExampleSeeder; -use Stancl\Tenancy\Tests\Etc\Tenant; -use Stancl\Tenancy\Tests\Etc\TestSeeder; -use Stancl\Tenancy\Tests\Etc\User; +use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; + beforeEach(function () { Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { @@ -267,6 +271,47 @@ test('run command works when sub command asks questions and accepts arguments', expect($user->email)->toBe('email@localhost'); }); +test('migrate fresh command only deletes tenant databases if drop_tenant_databases_on_migrate_fresh is true', function (bool $dropTenantDBsOnMigrateFresh) { + Event::listen(DeletingTenant::class, + JobPipeline::make([DeleteDomains::class])->send(function (DeletingTenant $event) { + return $event->tenant; + })->shouldBeQueued(false)->toListener() + ); + + Event::listen( + TenantDeleted::class, + JobPipeline::make([DeleteDatabase::class])->send(function (TenantDeleted $event) { + return $event->tenant; + })->shouldBeQueued(false)->toListener() + ); + + config(['tenancy.database.drop_tenant_databases_on_migrate_fresh' => $dropTenantDBsOnMigrateFresh]); + $shouldHaveDBAfterMigrateFresh = ! $dropTenantDBsOnMigrateFresh; + + /** @var Tenant[] $tenants */ + $tenants = [ + Tenant::create(), + Tenant::create(), + Tenant::create(), + ]; + + $tenantHasDatabase = fn (Tenant $tenant) => $tenant->database()->manager()->databaseExists($tenant->database()->getName()); + + foreach ($tenants as $tenant) { + expect($tenantHasDatabase($tenant))->toBeTrue(); + } + + pest()->artisan('migrate:fresh', [ + '--force' => true, + '--path' => __DIR__ . '/../assets/migrations', + '--realpath' => true, + ]); + + foreach ($tenants as $tenant) { + expect($tenantHasDatabase($tenant))->toBe($shouldHaveDBAfterMigrateFresh); + } +})->with([true, false]); + // todo@tests function runCommandWorks(): void { From 3f60c4a652b20ca8c988c2821374af1d38df6508 Mon Sep 17 00:00:00 2001 From: Jori Stein <44996807+stein-j@users.noreply.github.com> Date: Mon, 17 Oct 2022 13:48:24 -0400 Subject: [PATCH 34/48] Add maintenance mode events (#979) --- assets/TenancyServiceProvider.stub.php | 2 ++ src/Database/Concerns/MaintenanceMode.php | 6 ++++++ src/Events/TenantMaintenanceModeDisabled.php | 10 ++++++++++ src/Events/TenantMaintenanceModeEnabled.php | 10 ++++++++++ tests/MaintenanceModeTest.php | 15 +++++++++++++++ 5 files changed, 43 insertions(+) create mode 100644 src/Events/TenantMaintenanceModeDisabled.php create mode 100644 src/Events/TenantMaintenanceModeEnabled.php diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 75784361..7c52e295 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -58,6 +58,8 @@ class TenancyServiceProvider extends ServiceProvider return $event->tenant; })->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production. ], + Events\TenantMaintenanceModeEnabled::class => [], + Events\TenantMaintenanceModeDisabled::class => [], // Domain events Events\CreatingDomain::class => [], diff --git a/src/Database/Concerns/MaintenanceMode.php b/src/Database/Concerns/MaintenanceMode.php index cc4490f6..c147f748 100644 --- a/src/Database/Concerns/MaintenanceMode.php +++ b/src/Database/Concerns/MaintenanceMode.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\Concerns; +use Stancl\Tenancy\Events\TenantMaintenanceModeDisabled; +use Stancl\Tenancy\Events\TenantMaintenanceModeEnabled; /** * @mixin \Illuminate\Database\Eloquent\Model @@ -21,10 +23,14 @@ trait MaintenanceMode 'status' => $data['status'] ?? 503, ], ]); + + event(new TenantMaintenanceModeEnabled($this)); } public function bringUpFromMaintenance(): void { $this->update(['maintenance_mode' => null]); + + event(new TenantMaintenanceModeDisabled($this)); } } diff --git a/src/Events/TenantMaintenanceModeDisabled.php b/src/Events/TenantMaintenanceModeDisabled.php new file mode 100644 index 00000000..5b42cff6 --- /dev/null +++ b/src/Events/TenantMaintenanceModeDisabled.php @@ -0,0 +1,10 @@ +get('http://acme.localhost/foo')->assertStatus(200); }); +test('maintenance mode events are fired', function () { + $tenant = MaintenanceTenant::create(); + + Event::fake(); + + $tenant->putDownForMaintenance(); + + Event::assertDispatched(\Stancl\Tenancy\Events\TenantMaintenanceModeEnabled::class); + + $tenant->bringUpFromMaintenance(); + + Event::assertDispatched(\Stancl\Tenancy\Events\TenantMaintenanceModeDisabled::class); +}); + test('tenants can be put into maintenance mode using artisan commands', function() { Route::get('/foo', function () { return 'bar'; From 693e00b9be9daf7fb3ada777d5a510f7ec424950 Mon Sep 17 00:00:00 2001 From: PHP CS Fixer Date: Mon, 17 Oct 2022 17:48:49 +0000 Subject: [PATCH 35/48] Fix code style (php-cs-fixer) --- src/Database/Concerns/MaintenanceMode.php | 1 + src/Events/TenantMaintenanceModeDisabled.php | 2 +- src/Events/TenantMaintenanceModeEnabled.php | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Database/Concerns/MaintenanceMode.php b/src/Database/Concerns/MaintenanceMode.php index c147f748..1ad173cf 100644 --- a/src/Database/Concerns/MaintenanceMode.php +++ b/src/Database/Concerns/MaintenanceMode.php @@ -3,6 +3,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\Concerns; + use Stancl\Tenancy\Events\TenantMaintenanceModeDisabled; use Stancl\Tenancy\Events\TenantMaintenanceModeEnabled; diff --git a/src/Events/TenantMaintenanceModeDisabled.php b/src/Events/TenantMaintenanceModeDisabled.php index 5b42cff6..5b2d9778 100644 --- a/src/Events/TenantMaintenanceModeDisabled.php +++ b/src/Events/TenantMaintenanceModeDisabled.php @@ -7,4 +7,4 @@ namespace Stancl\Tenancy\Events; class TenantMaintenanceModeDisabled extends Contracts\TenantEvent { // -} \ No newline at end of file +} diff --git a/src/Events/TenantMaintenanceModeEnabled.php b/src/Events/TenantMaintenanceModeEnabled.php index 39d01032..752c83a5 100644 --- a/src/Events/TenantMaintenanceModeEnabled.php +++ b/src/Events/TenantMaintenanceModeEnabled.php @@ -7,4 +7,4 @@ namespace Stancl\Tenancy\Events; class TenantMaintenanceModeEnabled extends Contracts\TenantEvent { // -} \ No newline at end of file +} From 05f1b2d6f5fbda5762fd0a21aaa668ac6d14003a Mon Sep 17 00:00:00 2001 From: Abrar Ahmad Date: Tue, 18 Oct 2022 16:52:16 +0500 Subject: [PATCH 36/48] Add cookie option on Initialize Tenancy by Request identification (#980) * Add cookie option on Initialize Tenancy by Request identification * add cookie property --- .../InitializeTenancyByRequestData.php | 16 +++++++++++----- tests/RequestDataIdentificationTest.php | 19 ++++++++++++++----- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/Middleware/InitializeTenancyByRequestData.php b/src/Middleware/InitializeTenancyByRequestData.php index ba587d9a..ca29f3d7 100644 --- a/src/Middleware/InitializeTenancyByRequestData.php +++ b/src/Middleware/InitializeTenancyByRequestData.php @@ -12,6 +12,7 @@ use Stancl\Tenancy\Tenancy; class InitializeTenancyByRequestData extends IdentificationMiddleware { public static string $header = 'X-Tenant'; + public static string $cookie = 'X-Tenant'; public static string $queryParameter = 'tenant'; public static ?Closure $onFail = null; @@ -33,13 +34,18 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware protected function getPayload(Request $request): ?string { - $tenant = null; if (static::$header && $request->hasHeader(static::$header)) { - $tenant = $request->header(static::$header); - } elseif (static::$queryParameter && $request->has(static::$queryParameter)) { - $tenant = $request->get(static::$queryParameter); + return $request->header(static::$header); } - return $tenant; + if (static::$queryParameter && $request->has(static::$queryParameter)) { + return $request->get(static::$queryParameter); + } + + if (static::$cookie && $request->hasCookie(static::$cookie)) { + return $request->cookie(static::$cookie); + } + + return null; } } diff --git a/tests/RequestDataIdentificationTest.php b/tests/RequestDataIdentificationTest.php index e5a05f65..e10c00e1 100644 --- a/tests/RequestDataIdentificationTest.php +++ b/tests/RequestDataIdentificationTest.php @@ -20,19 +20,18 @@ beforeEach(function () { afterEach(function () { InitializeTenancyByRequestData::$header = 'X-Tenant'; + InitializeTenancyByRequestData::$cookie = 'X-Tenant'; InitializeTenancyByRequestData::$queryParameter = 'tenant'; }); test('header identification works', function () { InitializeTenancyByRequestData::$header = 'X-Tenant'; $tenant = Tenant::create(); - $tenant2 = Tenant::create(); $this ->withoutExceptionHandling() - ->get('test', [ - 'X-Tenant' => $tenant->id, - ]) + ->withHeader('X-Tenant', $tenant->id) + ->get('test') ->assertSee($tenant->id); }); @@ -40,10 +39,20 @@ test('query parameter identification works', function () { InitializeTenancyByRequestData::$queryParameter = 'tenant'; $tenant = Tenant::create(); - $tenant2 = Tenant::create(); $this ->withoutExceptionHandling() ->get('test?tenant=' . $tenant->id) ->assertSee($tenant->id); }); + +test('cookie identification works', function () { + InitializeTenancyByRequestData::$cookie = 'X-Tenant'; + $tenant = Tenant::create(); + + $this + ->withoutExceptionHandling() + ->withUnencryptedCookie('X-Tenant', $tenant->id) + ->get('test',) + ->assertSee($tenant->id); +}); From e4f5b9248540fe03b2c03989e38b9e5aa67b5a6e Mon Sep 17 00:00:00 2001 From: Jori Stein <44996807+stein-j@users.noreply.github.com> Date: Tue, 18 Oct 2022 13:11:57 -0400 Subject: [PATCH 37/48] [4.x] Update commands CLI outputs (#968) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Using laravel components * Ensure commands returns success * update tests * clean * bump EndBug version * Update ci.yml * Update ci.yml * Update ci.yml * revert CI changes * Update ci.yml * Update ci.yml * Update ci.yml * revert CI changes to it's original state * fix typos, improve code * improve Install & TenantList commands * php-cs-fixer * type GitHub properly Co-authored-by: Abrar Ahmad Co-authored-by: Samuel Štancl Co-authored-by: Samuel Štancl --- .github/workflows/ci.yml | 1 + assets/config.php | 8 +- src/Commands/Down.php | 15 ++-- src/Commands/Install.php | 146 +++++++++++++++++++++++++++------- src/Commands/Link.php | 10 ++- src/Commands/Migrate.php | 2 +- src/Commands/MigrateFresh.php | 30 ++++--- src/Commands/Rollback.php | 2 +- src/Commands/Run.php | 16 ++-- src/Commands/Seed.php | 2 +- src/Commands/TenantDump.php | 18 ++--- src/Commands/TenantList.php | 33 ++++++-- src/Commands/Up.php | 8 +- tests/CommandsTest.php | 14 ++-- tests/MaintenanceModeTest.php | 8 +- 15 files changed, 217 insertions(+), 96 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db2b0ffc..26de6a18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,3 +102,4 @@ jobs: author_name: "PHP CS Fixer" author_email: "phpcsfixer@example.com" message: Fix code style (php-cs-fixer) + diff --git a/assets/config.php b/assets/config.php index cd0a6c42..eb68d9b0 100644 --- a/assets/config.php +++ b/assets/config.php @@ -2,16 +2,14 @@ declare(strict_types=1); -use Stancl\Tenancy\Database\Models\Domain; -use Stancl\Tenancy\Database\Models\Tenant; use Stancl\Tenancy\Middleware; use Stancl\Tenancy\Resolvers; return [ - 'tenant_model' => Tenant::class, - 'id_generator' => Stancl\Tenancy\UUIDGenerator::class, + 'tenant_model' => Stancl\Tenancy\Database\Models\Tenant::class, + 'domain_model' => Stancl\Tenancy\Database\Models\Domain::class, - 'domain_model' => Domain::class, + 'id_generator' => Stancl\Tenancy\UUIDGenerator::class, /** * The list of domains hosting your central app. diff --git a/src/Commands/Down.php b/src/Commands/Down.php index 6b390957..e7341d7f 100644 --- a/src/Commands/Down.php +++ b/src/Commands/Down.php @@ -22,24 +22,23 @@ class Down extends DownCommand public function handle(): int { - // The base down command is heavily used. Instead of saving the data inside a file, - // the data is stored the tenant database, which means some Laravel features - // are not available with tenants. - $payload = $this->getDownDatabasePayload(); - // This runs for all tenants if no --tenants are specified tenancy()->runForMultiple($this->getTenants(), function ($tenant) use ($payload) { - $this->line("Tenant: {$tenant['id']}"); + $this->components->info("Tenant: {$tenant->getTenantKey()}"); $tenant->putDownForMaintenance($payload); }); - $this->comment('Tenants are now in maintenance mode.'); + $this->components->info('Tenants are now in maintenance mode.'); return 0; } - /** Get the payload to be placed in the "down" file. */ + /** + * Get the payload to be placed in the "down" file. This + * payload is the same as the original function + * but without the 'template' option. + */ protected function getDownDatabasePayload(): array { return [ diff --git a/src/Commands/Install.php b/src/Commands/Install.php index 12a2c2c9..424aa261 100644 --- a/src/Commands/Install.php +++ b/src/Commands/Install.php @@ -4,50 +4,136 @@ declare(strict_types=1); namespace Stancl\Tenancy\Commands; +use Closure; use Illuminate\Console\Command; class Install extends Command { protected $signature = 'tenancy:install'; - protected $description = 'Install stancl/tenancy.'; + protected $description = 'Install Tenancy for Laravel.'; - public function handle(): void + public function handle(): int { - $this->comment('Installing stancl/tenancy...'); - $this->callSilent('vendor:publish', [ - '--provider' => 'Stancl\Tenancy\TenancyServiceProvider', - '--tag' => 'config', - ]); - $this->info('✔️ Created config/tenancy.php'); + $this->step( + name: 'Publishing config file', + tag: 'config', + file: 'config/tenancy.php', + newLineBefore: true, + ); - if (! file_exists(base_path('routes/tenant.php'))) { - $this->callSilent('vendor:publish', [ + $this->step( + name: 'Publishing routes [routes/tenant.php]', + tag: 'routes', + file: 'routes/tenant.php', + ); + + $this->step( + name: 'Publishing service provider', + tag: 'providers', + file: 'app/Providers/TenancyServiceProvider.php', + ); + + $this->step( + name: 'Publishing migrations', + tag: 'migrations', + files: [ + 'database/migrations/2019_09_15_000010_create_tenants_table.php', + 'database/migrations/2019_09_15_000020_create_domains_table.php', + ], + warning: 'Migrations already exist', + ); + + $this->step( + name: 'Creating [database/migrations/tenant] folder', + task: fn () => mkdir(database_path('migrations/tenant')), + unless: is_dir(database_path('migrations/tenant')), + warning: 'Folder [database/migrations/tenant] already exists.', + newLineAfter: true, + ); + + $this->components->info('✨️ Tenancy for Laravel successfully installed.'); + + $this->askForSupport(); + + return 0; + } + + /** + * Run a step of the installation process. + * + * @param string $name The name of the step. + * @param Closure|null $task The task code. + * @param bool $unless Condition specifying when the task should NOT run. + * @param string|null $warning Warning shown when the $unless condition is true. + * @param string|null $file Name of the file being added. + * @param string|null $tag The tag being published. + * @param array|null $files Names of files being added. + * @param bool $newLineBefore Should a new line be printed after the step. + * @param bool $newLineAfter Should a new line be printed after the step. + */ + protected function step( + string $name, + Closure $task = null, + bool $unless = false, + string $warning = null, + string $file = null, + string $tag = null, + array $files = null, + bool $newLineBefore = false, + bool $newLineAfter = false, + ): void { + if ($file) { + $name .= " [$file]"; // Append clickable path to the task name + $unless = file_exists(base_path($file)); // Make the condition a check for the file's existence + $warning = "File [$file] already exists."; // Make the warning a message about the file already existing + } + + if ($tag) { + $task = fn () => $this->callSilent('vendor:publish', [ '--provider' => 'Stancl\Tenancy\TenancyServiceProvider', - '--tag' => 'routes', + '--tag' => $tag, ]); - $this->info('✔️ Created routes/tenant.php'); + } + + if ($files) { + // Show a warning if any of the files already exist + $unless = count(array_filter($files, fn ($file) => file_exists(base_path($file)))) !== 0; + } + + if (! $unless) { + if ($newLineBefore) { + $this->newLine(); + } + + $this->components->task($name, $task ?? fn () => null); + + if ($files) { + // Print out a clickable list of the added files + $this->components->bulletList(array_map(fn (string $file) => "[$file]", $files)); + } + + if ($newLineAfter) { + $this->newLine(); + } } else { - $this->info('Found routes/tenant.php.'); + $this->components->warn($warning); } + } - $this->callSilent('vendor:publish', [ - '--provider' => 'Stancl\Tenancy\TenancyServiceProvider', - '--tag' => 'providers', - ]); - $this->info('✔️ Created TenancyServiceProvider.php'); - - $this->callSilent('vendor:publish', [ - '--provider' => 'Stancl\Tenancy\TenancyServiceProvider', - '--tag' => 'migrations', - ]); - $this->info('✔️ Created migrations. Remember to run [php artisan migrate]!'); - - if (! is_dir(database_path('migrations/tenant'))) { - mkdir(database_path('migrations/tenant')); - $this->info('✔️ Created database/migrations/tenant folder.'); + /** If the user accepts, opens the GitHub project in the browser. */ + public function askForSupport(): void + { + if ($this->components->confirm('Would you like to show your support by starring the project on GitHub?', true)) { + if (PHP_OS_FAMILY === 'Darwin') { + exec('open https://github.com/archtechx/tenancy'); + } + if (PHP_OS_FAMILY === 'Windows') { + exec('start https://github.com/archtechx/tenancy'); + } + if (PHP_OS_FAMILY === 'Linux') { + exec('xdg-open https://github.com/archtechx/tenancy'); + } } - - $this->comment('✨️ stancl/tenancy installed successfully.'); } } diff --git a/src/Commands/Link.php b/src/Commands/Link.php index 53f3cf6f..0a587122 100644 --- a/src/Commands/Link.php +++ b/src/Commands/Link.php @@ -23,7 +23,7 @@ class Link extends Command protected $description = 'Create or remove tenant symbolic links.'; - public function handle(): void + public function handle(): int { $tenants = $this->getTenants(); @@ -35,14 +35,18 @@ class Link extends Command } } catch (Exception $exception) { $this->error($exception->getMessage()); + + return 1; } + + return 0; } protected function removeLinks(LazyCollection $tenants): void { RemoveStorageSymlinksAction::handle($tenants); - $this->info('The links have been removed.'); + $this->components->info('The links have been removed.'); } protected function createLinks(LazyCollection $tenants): void @@ -53,6 +57,6 @@ class Link extends Command (bool) ($this->option('force') ?? false), ); - $this->info('The links have been created.'); + $this->components->info('The links have been created.'); } } diff --git a/src/Commands/Migrate.php b/src/Commands/Migrate.php index 739b56de..82395fcc 100644 --- a/src/Commands/Migrate.php +++ b/src/Commands/Migrate.php @@ -43,7 +43,7 @@ class Migrate extends MigrateCommand } tenancy()->runForMultiple($this->getTenants(), function ($tenant) { - $this->line("Tenant: {$tenant->getTenantKey()}"); + $this->components->info("Tenant: {$tenant->getTenantKey()}"); event(new MigratingDatabase($tenant)); diff --git a/src/Commands/MigrateFresh.php b/src/Commands/MigrateFresh.php index 32dc6ee5..657c4990 100644 --- a/src/Commands/MigrateFresh.php +++ b/src/Commands/MigrateFresh.php @@ -23,23 +23,27 @@ class MigrateFresh extends Command $this->setName('tenants:migrate-fresh'); } - public function handle(): void + public function handle(): int { tenancy()->runForMultiple($this->getTenants(), function ($tenant) { - $this->info('Dropping tables.'); - $this->call('db:wipe', array_filter([ - '--database' => 'tenant', - '--drop-views' => $this->option('drop-views'), - '--force' => true, - ])); + $this->components->info("Tenant: {$tenant->getTenantKey()}"); - $this->info('Migrating.'); - $this->callSilent('tenants:migrate', [ - '--tenants' => [$tenant->getTenantKey()], - '--force' => true, - ]); + $this->components->task('Dropping tables', function () { + $this->callSilently('db:wipe', array_filter([ + '--database' => 'tenant', + '--drop-views' => $this->option('drop-views'), + '--force' => true, + ])); + }); + + $this->components->task('Migrating', function () use ($tenant) { + $this->callSilent('tenants:migrate', [ + '--tenants' => [$tenant->getTenantKey()], + '--force' => true, + ]); + }); }); - $this->info('Done.'); + return 0; } } diff --git a/src/Commands/Rollback.php b/src/Commands/Rollback.php index d3989cc0..1e84ab12 100644 --- a/src/Commands/Rollback.php +++ b/src/Commands/Rollback.php @@ -37,7 +37,7 @@ class Rollback extends RollbackCommand } tenancy()->runForMultiple($this->getTenants(), function ($tenant) { - $this->line("Tenant: {$tenant->getTenantKey()}"); + $this->components->info("Tenant: {$tenant->getTenantKey()}"); event(new RollingBackDatabase($tenant)); diff --git a/src/Commands/Run.php b/src/Commands/Run.php index 9bb04716..5ecc7c77 100644 --- a/src/Commands/Run.php +++ b/src/Commands/Run.php @@ -19,30 +19,32 @@ class Run extends Command protected $signature = 'tenants:run {commandname : The artisan command.} {--tenants=* : The tenant(s) to run the command for. Default: all}'; - public function handle(): void + public function handle(): int { $argvInput = $this->argvInput(); tenancy()->runForMultiple($this->getTenants(), function ($tenant) use ($argvInput) { - $this->line("Tenant: {$tenant->getTenantKey()}"); + $this->components->info("Tenant: {$tenant->getTenantKey()}"); $this->getLaravel() ->make(Kernel::class) ->handle($argvInput, new ConsoleOutput); }); + + return 0; } protected function argvInput(): ArgvInput { - /** @var string $commandname */ - $commandname = $this->argument('commandname'); + /** @var string $commandName */ + $commandName = $this->argument('commandname'); // Convert string command to array - $subcommand = explode(' ', $commandname); + $subCommand = explode(' ', $commandName); // Add "artisan" as first parameter because ArgvInput expects "artisan" as first parameter and later removes it - array_unshift($subcommand, 'artisan'); + array_unshift($subCommand, 'artisan'); - return new ArgvInput($subcommand); + return new ArgvInput($subCommand); } } diff --git a/src/Commands/Seed.php b/src/Commands/Seed.php index 496c04e6..8ed0b6d9 100644 --- a/src/Commands/Seed.php +++ b/src/Commands/Seed.php @@ -36,7 +36,7 @@ class Seed extends SeedCommand } tenancy()->runForMultiple($this->getTenants(), function ($tenant) { - $this->line("Tenant: {$tenant->getTenantKey()}"); + $this->components->info("Tenant: {$tenant->getTenantKey()}"); event(new SeedingDatabase($tenant)); diff --git a/src/Commands/TenantDump.php b/src/Commands/TenantDump.php index 9c8698c6..6edae6b0 100644 --- a/src/Commands/TenantDump.php +++ b/src/Commands/TenantDump.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Stancl\Tenancy\Commands; -use Illuminate\Console\Command; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\ConnectionResolverInterface; use Illuminate\Database\Console\DumpCommand; @@ -22,13 +21,6 @@ class TenantDump extends DumpCommand } public function handle(ConnectionResolverInterface $connections, Dispatcher $dispatcher): int - { - $this->tenant()->run(fn () => parent::handle($connections, $dispatcher)); - - return Command::SUCCESS; - } - - public function tenant(): Tenant { $tenant = $this->option('tenant') ?? tenant() @@ -39,9 +31,15 @@ class TenantDump extends DumpCommand $tenant = tenancy()->find($tenant); } - throw_if(! $tenant, 'Could not identify the tenant to use for dumping the schema.'); + if ($tenant === null) { + $this->components->error('Could not find tenant to use for dumping the schema.'); - return $tenant; + return 1; + } + + parent::handle($connections, $dispatcher); + + return 0; } protected function getOptions(): array diff --git a/src/Commands/TenantList.php b/src/Commands/TenantList.php index 9fd3f8bd..c008ba59 100644 --- a/src/Commands/TenantList.php +++ b/src/Commands/TenantList.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Commands; use Illuminate\Console\Command; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Stancl\Tenancy\Contracts\Tenant; @@ -14,19 +15,35 @@ class TenantList extends Command protected $description = 'List tenants.'; - public function handle(): void + public function handle(): int { - $this->info('Listing all tenants.'); - $tenants = tenancy()->query()->cursor(); + $this->components->info("Listing {$tenants->count()} tenants."); + foreach ($tenants as $tenant) { /** @var Model&Tenant $tenant */ - if ($tenant->domains) { - $this->line("[Tenant] {$tenant->getTenantKeyName()}: {$tenant->getTenantKey()} @ " . implode('; ', $tenant->domains->pluck('domain')->toArray() ?? [])); - } else { - $this->line("[Tenant] {$tenant->getTenantKeyName()}: {$tenant->getTenantKey()}"); - } + $this->components->twoColumnDetail($this->tenantCLI($tenant), $this->domainsCLI($tenant->domains)); } + + $this->newLine(); + + return 0; + } + + /** Generate the visual CLI output for the tenant name. */ + protected function tenantCLI(Model&Tenant $tenant): string + { + return "{$tenant->getTenantKeyName()}: {$tenant->getTenantKey()}"; + } + + /** Generate the visual CLI output for the domain names. */ + protected function domainsCLI(?Collection $domains): ?string + { + if (! $domains) { + return null; + } + + return "{$domains->pluck('domain')->implode(' / ')}"; } } diff --git a/src/Commands/Up.php b/src/Commands/Up.php index a3f690c2..08c935c3 100644 --- a/src/Commands/Up.php +++ b/src/Commands/Up.php @@ -15,13 +15,15 @@ class Up extends Command protected $description = 'Put tenants out of maintenance mode.'; - public function handle(): void + public function handle(): int { tenancy()->runForMultiple($this->getTenants(), function ($tenant) { - $this->line("Tenant: {$tenant['id']}"); + $this->components->info("Tenant: {$tenant->getTenantKey()}"); $tenant->bringUpFromMaintenance(); }); - $this->comment('Tenants are now out of maintenance mode.'); + $this->components->info('Tenants are now out of maintenance mode.'); + + return 0; } } diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index bac5005d..793dca30 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -171,7 +171,9 @@ test('install command works', function () { mkdir($dir, 0777, true); } - pest()->artisan('tenancy:install'); + pest()->artisan('tenancy:install') + ->expectsConfirmation('Would you like to show your support by starring the project on GitHub?', 'no') + ->assertExitCode(0); expect(base_path('routes/tenant.php'))->toBeFile(); expect(base_path('config/tenancy.php'))->toBeFile(); expect(app_path('Providers/TenancyServiceProvider.php'))->toBeFile(); @@ -214,7 +216,8 @@ test('run command with array of tenants works', function () { test('link command works', function() { $tenantId1 = Tenant::create()->getTenantKey(); $tenantId2 = Tenant::create()->getTenantKey(); - pest()->artisan('tenants:link'); + pest()->artisan('tenants:link') + ->assertExitCode(0); $this->assertDirectoryExists(storage_path("tenant-$tenantId1/app/public")); $this->assertEquals(storage_path("tenant-$tenantId1/app/public/"), readlink(public_path("public-$tenantId1"))); @@ -224,7 +227,7 @@ test('link command works', function() { pest()->artisan('tenants:link', [ '--remove' => true, - ]); + ])->assertExitCode(0); $this->assertDirectoryDoesNotExist(public_path("public-$tenantId1")); $this->assertDirectoryDoesNotExist(public_path("public-$tenantId2")); @@ -256,8 +259,9 @@ test('run command works when sub command asks questions and accepts arguments', pest()->artisan("tenants:run --tenants=$id 'user:addwithname Abrar' ") ->expectsQuestion('What is your email?', 'email@localhost') - ->expectsOutput("Tenant: $id") - ->expectsOutput("User created: Abrar(email@localhost)"); + ->expectsOutputToContain("Tenant: $id.") + ->expectsOutput("User created: Abrar(email@localhost)") + ->assertExitCode(0); // Assert we are in central context expect(tenancy()->initialized)->toBeFalse(); diff --git a/tests/MaintenanceModeTest.php b/tests/MaintenanceModeTest.php index e31fe026..86366e2f 100644 --- a/tests/MaintenanceModeTest.php +++ b/tests/MaintenanceModeTest.php @@ -59,12 +59,18 @@ test('tenants can be put into maintenance mode using artisan commands', function pest()->get('http://acme.localhost/foo')->assertStatus(200); + pest()->artisan('tenants:down') + ->expectsOutputToContain('Tenants are now in maintenance mode.') + ->assertExitCode(0); + Artisan::call('tenants:down'); tenancy()->end(); // End tenancy before making a request pest()->get('http://acme.localhost/foo')->assertStatus(503); - Artisan::call('tenants:up'); + pest()->artisan('tenants:up') + ->expectsOutputToContain('Tenants are now out of maintenance mode.') + ->assertExitCode(0); tenancy()->end(); // End tenancy before making a request pest()->get('http://acme.localhost/foo')->assertStatus(200); From 5d688e6e5df7db2dc39ae62c2ccafa3204a41554 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 18 Oct 2022 21:52:02 +0200 Subject: [PATCH 38/48] remove duplicate 'routes/tenant.php' from message --- src/Commands/Install.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Commands/Install.php b/src/Commands/Install.php index 424aa261..77c96588 100644 --- a/src/Commands/Install.php +++ b/src/Commands/Install.php @@ -23,7 +23,7 @@ class Install extends Command ); $this->step( - name: 'Publishing routes [routes/tenant.php]', + name: 'Publishing routes', tag: 'routes', file: 'routes/tenant.php', ); From fe0a322b87969f42031c411a48479046bd54fb8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 25 Oct 2022 12:53:31 +0200 Subject: [PATCH 39/48] add public connection() method to the Tenant DB manager interface --- .../StatefulTenantDatabaseManager.php | 22 +++++++++++++++++++ .../Contracts/TenantDatabaseManager.php | 9 -------- src/Database/DatabaseConfig.php | 4 +++- .../TenantDatabaseManager.php | 6 ++--- tests/DatabasePreparationTest.php | 4 +--- tests/TenantDatabaseManagerTest.php | 6 ++++- 6 files changed, 34 insertions(+), 17 deletions(-) create mode 100644 src/Database/Contracts/StatefulTenantDatabaseManager.php diff --git a/src/Database/Contracts/StatefulTenantDatabaseManager.php b/src/Database/Contracts/StatefulTenantDatabaseManager.php new file mode 100644 index 00000000..36a08db2 --- /dev/null +++ b/src/Database/Contracts/StatefulTenantDatabaseManager.php @@ -0,0 +1,22 @@ +setConnection($this->getTemplateConnectionName()); + if ($databaseManager instanceof Contracts\StatefulTenantDatabaseManager) { + $databaseManager->setConnection($this->getTemplateConnectionName()); + } return $databaseManager; } diff --git a/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php b/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php index b7dd15fa..87916088 100644 --- a/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php @@ -6,15 +6,15 @@ namespace Stancl\Tenancy\Database\TenantDatabaseManagers; use Illuminate\Database\Connection; use Illuminate\Support\Facades\DB; -use Stancl\Tenancy\Database\Contracts\TenantDatabaseManager as Contract; +use Stancl\Tenancy\Database\Contracts\StatefulTenantDatabaseManager; use Stancl\Tenancy\Database\Exceptions\NoConnectionSetException; -abstract class TenantDatabaseManager implements Contract // todo better naming? +abstract class TenantDatabaseManager implements StatefulTenantDatabaseManager { /** The database connection to the server. */ protected string $connection; - protected function database(): Connection + public function database(): Connection { if (! isset($this->connection)) { throw new NoConnectionSetException(static::class); diff --git a/tests/DatabasePreparationTest.php b/tests/DatabasePreparationTest.php index e31fac9b..d5641af4 100644 --- a/tests/DatabasePreparationTest.php +++ b/tests/DatabasePreparationTest.php @@ -22,9 +22,7 @@ test('database can be created after tenant creation', function () { })->toListener()); $tenant = Tenant::create(); - - $manager = app(MySQLDatabaseManager::class); - $manager->setConnection('mysql'); + $manager = $tenant->database()->manager(); expect($manager->databaseExists($tenant->database()->getName()))->toBeTrue(); }); diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index b16c06b6..33a3158f 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Stancl\JobPipeline\JobPipeline; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; +use Stancl\Tenancy\Database\Contracts\StatefulTenantDatabaseManager; use Stancl\Tenancy\Database\DatabaseManager; use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Events\TenancyInitialized; @@ -36,7 +37,10 @@ test('databases can be created and deleted', function ($driver, $databaseManager $name = 'db' . pest()->randomString(); $manager = app($databaseManager); - $manager->setConnection($driver); + + if ($manager instanceof StatefulTenantDatabaseManager) { + $manager->setConnection($driver); + } expect($manager->databaseExists($name))->toBeFalse(); From 8c346409483029368b9e711c8f0190b5bfdc98bb Mon Sep 17 00:00:00 2001 From: PHP CS Fixer Date: Tue, 25 Oct 2022 10:54:04 +0000 Subject: [PATCH 40/48] Fix code style (php-cs-fixer) --- src/Database/Contracts/StatefulTenantDatabaseManager.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Database/Contracts/StatefulTenantDatabaseManager.php b/src/Database/Contracts/StatefulTenantDatabaseManager.php index 36a08db2..1a2e928d 100644 --- a/src/Database/Contracts/StatefulTenantDatabaseManager.php +++ b/src/Database/Contracts/StatefulTenantDatabaseManager.php @@ -1,5 +1,7 @@ Date: Tue, 25 Oct 2022 18:03:04 +0200 Subject: [PATCH 41/48] [4.x] Make `tenants:migrate` default to configured schema path (#985) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add --schema-path to migration parameters config * Set TenantDump's path to configured schema-path if the path doesn't get passed * Test schema dump file creation and usage * Fix code style (php-cs-fixer) * hardcode default instead of reading from a config key that doesn't have to exist Co-authored-by: PHP CS Fixer Co-authored-by: Samuel Štancl --- assets/config.php | 1 + src/Commands/TenantDump.php | 4 ++++ tests/CommandsTest.php | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/assets/config.php b/assets/config.php index eb68d9b0..0e035953 100644 --- a/assets/config.php +++ b/assets/config.php @@ -258,6 +258,7 @@ return [ 'migration_parameters' => [ '--force' => true, // This needs to be true to run migrations in production. '--path' => [database_path('migrations/tenant')], + '--schema-path' => database_path('schema/tenant-schema.dump'), '--realpath' => true, ], diff --git a/src/Commands/TenantDump.php b/src/Commands/TenantDump.php index 6edae6b0..3f957bdd 100644 --- a/src/Commands/TenantDump.php +++ b/src/Commands/TenantDump.php @@ -22,6 +22,10 @@ class TenantDump extends DumpCommand public function handle(ConnectionResolverInterface $connections, Dispatcher $dispatcher): int { + if (is_null($this->option('path'))) { + $this->input->setOption('path', database_path('schema/tenant-schema.dump')); + } + $tenant = $this->option('tenant') ?? tenant() ?? $this->ask('What tenant do you want to dump the schema for?') diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index 793dca30..9a9f0bc5 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -120,6 +120,39 @@ test('dump command works', function () { expect('tests/Etc/tenant-schema-test.dump')->toBeFile(); }); +test('tenant dump file gets created as tenant-schema.dump in the database schema folder by default', function() { + config(['tenancy.migration_parameters.--schema-path' => $schemaPath = database_path('schema/tenant-schema.dump')]); + + $tenant = Tenant::create(); + Artisan::call('tenants:migrate'); + + tenancy()->initialize($tenant); + + Artisan::call('tenants:dump'); + + expect($schemaPath)->toBeFile(); + unlink($schemaPath); +}); + +test('migrate command uses the correct schema path by default', function () { + config(['tenancy.migration_parameters.--schema-path' => 'tests/Etc/tenant-schema.dump']); + $tenant = Tenant::create(); + + expect(Schema::hasTable('schema_users'))->toBeFalse(); + expect(Schema::hasTable('users'))->toBeFalse(); + + Artisan::call('tenants:migrate'); + + expect(Schema::hasTable('schema_users'))->toBeFalse(); + expect(Schema::hasTable('users'))->toBeFalse(); + + tenancy()->initialize($tenant); + + // Check for both tables to see if missing migrations also get executed + expect(Schema::hasTable('schema_users'))->toBeTrue(); + expect(Schema::hasTable('users'))->toBeTrue(); +}); + test('rollback command works', function () { $tenant = Tenant::create(); Artisan::call('tenants:migrate'); From 648acc48c72ec8793f30379cacf514decb9d14f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 25 Oct 2022 18:04:13 +0200 Subject: [PATCH 42/48] remove HasDataColumn trait --- src/Database/Concerns/HasDataColumn.php | 15 --------------- src/Database/Models/Tenant.php | 14 +++++++++----- 2 files changed, 9 insertions(+), 20 deletions(-) delete mode 100644 src/Database/Concerns/HasDataColumn.php diff --git a/src/Database/Concerns/HasDataColumn.php b/src/Database/Concerns/HasDataColumn.php deleted file mode 100644 index cf67b832..00000000 --- a/src/Database/Concerns/HasDataColumn.php +++ /dev/null @@ -1,15 +0,0 @@ -getAttribute($this->getTenantKeyName()); } + /** Get the current tenant. */ public static function current(): static|null { return tenant(); } - /** @throws TenancyNotInitializedException */ + /** + * Get the current tenant or throw an exception if tenancy is not initialized. + * + * @throws TenancyNotInitializedException + */ public static function currentOrFail(): static { return static::current() ?? throw new TenancyNotInitializedException; From 15dc40839b6d8dcfb54c32a4f998784fb687e710 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 26 Oct 2022 12:08:14 +0200 Subject: [PATCH 43/48] Unlink tenant schema path before each test in CommandsTest (#986) * Add tenantSchemaPath method * Unlink tenant schema path before each test in CommandsTest * Remove the tenantSchemaPath helper --- tests/CommandsTest.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index 9a9f0bc5..ea973070 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -26,6 +26,10 @@ 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()); @@ -131,7 +135,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 () { From ae8b01153ab6662b6a93ff05e813f5ea7d606931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Wed, 26 Oct 2022 12:20:21 +0200 Subject: [PATCH 44/48] phpstan pro --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index b30ea94c..bbca1e14 100644 --- a/composer.json +++ b/composer.json @@ -63,7 +63,7 @@ "docker-rebuild": "PHP_VERSION=8.1 docker-compose up -d --no-deps --build", "docker-m1": "ln -s docker-compose-m1.override.yml docker-compose.override.yml", "coverage": "open coverage/phpunit/html/index.html", - "phpstan": "vendor/bin/phpstan", + "phpstan": "vendor/bin/phpstan --pro", "cs": "php-cs-fixer fix --config=.php-cs-fixer.php", "test": "PHP_VERSION=8.1 ./test --no-coverage", "test-full": "PHP_VERSION=8.1 ./test" From bf504f4c795dfc5c59aee64f48d7395c0cf60acc Mon Sep 17 00:00:00 2001 From: Abrar Ahmad Date: Mon, 31 Oct 2022 16:13:54 +0500 Subject: [PATCH 45/48] [4.x] Use a dedicated DB connection for creating/deleting tenant databases (#946) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * create host connection for creating, deleting tenants * purge connection and add more tests * remove unused method * Improvements * test named * remove host connection name config key * Revert "remove host connection name config key" This reverts commit 42acb823e8f437bd0d6560b4cf567ef9769aa5b8. * Update DatabaseConfig.php * Update assets/config.php Co-authored-by: Samuel Štancl * Update DatabaseConfig.php * todo and comments * remove debug code * Update DatabaseConfig.php * strict assertions * Update TenantDatabaseManagerTest.php * Update src/Database/DatabaseConfig.php Co-authored-by: Samuel Štancl * purge connection improvements * Update DatabaseConfig.php * Update DatabaseConfig.php * Update DatabaseConfig.php * improve comments * remove "ensuring connection exists" check * remove test because it's duplicate * removing test because other two tests are using the same logic, so this test kinda already covered * Update TenantDatabaseManagerTest.php * Update DatabaseConfig.php * Revert "Update TenantDatabaseManagerTest.php" This reverts commit b8e0a1c982a4cf95bbc3bd646fa571eee510ba7b. * add default * Update src/Database/DatabaseConfig.php Co-authored-by: Samuel Štancl * update comment * remove unness mysql config and add a comment * tenancy_db_connection tenant config test * Update TenantDatabaseManagerTest.php * update test name and improve assertions * typo * change inline variable name * Update TenantDatabaseManagerTest.php * Update TenantDatabaseManagerTest.php * add DB::purge() calls * add new assertions [ci skip] * Fix code style (php-cs-fixer) * replace hostManager with manager * fix test * method rename Co-authored-by: Samuel Štancl Co-authored-by: Samuel Štancl Co-authored-by: PHP CS Fixer --- assets/config.php | 5 + src/Database/DatabaseConfig.php | 85 +++++++++++++-- tests/TenantDatabaseManagerTest.php | 158 ++++++++++++++++++++++++++-- 3 files changed, 229 insertions(+), 19 deletions(-) diff --git a/assets/config.php b/assets/config.php index 0e035953..82a4c722 100644 --- a/assets/config.php +++ b/assets/config.php @@ -98,6 +98,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/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/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], 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 46/48] [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); +}); From aa536529dfd11fbbca96ba51a937331bc098fea3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 1 Nov 2022 17:48:56 +0100 Subject: [PATCH 47/48] update DatabaseSeeder namespace --- assets/config.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/config.php b/assets/config.php index 20826d7d..cfde54ac 100644 --- a/assets/config.php +++ b/assets/config.php @@ -290,7 +290,7 @@ return [ * Parameters used by the tenants:seed command. */ 'seeder_parameters' => [ - '--class' => 'DatabaseSeeder', // root seeder class + '--class' => 'Database\Seeders\DatabaseSeeder', // root seeder class // '--force' => true, ], From 77c5ae1f32e8c54510a37206f8d96786fd766031 Mon Sep 17 00:00:00 2001 From: Abrar Ahmad Date: Thu, 3 Nov 2022 21:51:29 +0500 Subject: [PATCH 48/48] [4.x] Configure attributes for synced resources when creating models (#915) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * configure attributes for creating resource * Update ResourceSyncingTest.php * Update ci.yml * Update ResourceSyncingTest.php * Update ci.yml * cs * comments * Update tests/ResourceSyncingTest.php Co-authored-by: Samuel Štancl * improve comments, move method to `SyncMaster` interface * Revert "improve comments, move method to `SyncMaster` interface" This reverts commit 5ddd50deb9f5aa2ad0ebadec5bd5afcbf7e1257e. * Update ResourceSyncingTest.php * Update ResourceSyncingTest.php * update comment * Update ResourceSyncingTest.php * Update ResourceSyncingTest.php * wip * wip * wip * add a todo * assert that creation attributes returns null * classes at the end * rename method to `getAttributesForCreation` * Update ResourceSyncingTest.php * update comments * Fix little grammer * merge default values with sync attributes and tests * Update ResourceSyncingTest.php * method rename * method rename * Update ResourceSyncingTest.php * comments * Update ResourceSyncingTest.php * allow defining a mix of attribute names and default values * add test * code improvements * Fix code style (php-cs-fixer) * remove unused import * fix all phpstan issues in resource syncing code * Fix code style (php-cs-fixer) * wip * improve tests * Update ResourceSyncingTest.php * better names * Update UpdateSyncedResource.php * code style * Update UpdateSyncedResource.php * add comments above new tests * methods dockblocks and correct names * Update ResourceSyncingTest.php * update comments * remove different schema setup * delete custom migrations * self review * grammar, code style * refactor helpers for creating tenants Co-authored-by: Samuel Štancl Co-authored-by: Samuel Štancl Co-authored-by: PHP CS Fixer --- src/Contracts/Syncable.php | 3 + src/Database/Concerns/ResourceSyncing.php | 5 + src/Events/SyncedResourceSaved.php | 13 +- src/Listeners/UpdateSyncedResource.php | 86 +++- ...dd_extra_column_to_central_users_table.php | 26 ++ tests/ResourceSyncingTest.php | 373 +++++++++++++++++- 6 files changed, 469 insertions(+), 37 deletions(-) create mode 100644 tests/Etc/synced_resource_migrations/users_extra/2019_08_08_000009_add_extra_column_to_central_users_table.php diff --git a/src/Contracts/Syncable.php b/src/Contracts/Syncable.php index e09f4f7e..a481f318 100644 --- a/src/Contracts/Syncable.php +++ b/src/Contracts/Syncable.php @@ -15,4 +15,7 @@ interface Syncable public function getSyncedAttributeNames(): array; public function triggerSyncEvent(): void; + + /** Get the attributes used for creating the *other* model (i.e. tenant if this is the central one, and central if this is the tenant one). */ + public function getSyncedCreationAttributes(): array|null; // todo come up with a better name } diff --git a/src/Database/Concerns/ResourceSyncing.php b/src/Database/Concerns/ResourceSyncing.php index df5b0766..fd63738d 100644 --- a/src/Database/Concerns/ResourceSyncing.php +++ b/src/Database/Concerns/ResourceSyncing.php @@ -32,4 +32,9 @@ trait ResourceSyncing /** @var Syncable $this */ event(new SyncedResourceSaved($this, tenant())); } + + public function getSyncedCreationAttributes(): array|null + { + return null; + } } diff --git a/src/Events/SyncedResourceSaved.php b/src/Events/SyncedResourceSaved.php index 72d34d16..5c3b1334 100644 --- a/src/Events/SyncedResourceSaved.php +++ b/src/Events/SyncedResourceSaved.php @@ -10,14 +10,9 @@ use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; class SyncedResourceSaved { - public Syncable&Model $model; - - /** @var (TenantWithDatabase&Model)|null */ - public TenantWithDatabase|null $tenant; - - public function __construct(Syncable $model, TenantWithDatabase|null $tenant) - { - $this->model = $model; - $this->tenant = $tenant; + public function __construct( + public Syncable&Model $model, + public TenantWithDatabase|null $tenant, + ) { } } diff --git a/src/Listeners/UpdateSyncedResource.php b/src/Listeners/UpdateSyncedResource.php index 45f73516..39391eac 100644 --- a/src/Listeners/UpdateSyncedResource.php +++ b/src/Listeners/UpdateSyncedResource.php @@ -4,14 +4,19 @@ declare(strict_types=1); namespace Stancl\Tenancy\Listeners; -use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Pivot; +use Illuminate\Support\Arr; +use Stancl\Tenancy\Contracts\Syncable; use Stancl\Tenancy\Contracts\SyncMaster; +use Stancl\Tenancy\Contracts\Tenant; +use Stancl\Tenancy\Database\TenantCollection; use Stancl\Tenancy\Events\SyncedResourceChangedInForeignDatabase; use Stancl\Tenancy\Events\SyncedResourceSaved; use Stancl\Tenancy\Exceptions\ModelNotSyncMasterException; +// todo@v4 review all code related to resource syncing + class UpdateSyncedResource extends QueueableListener { public static bool $shouldQueue = false; @@ -30,25 +35,28 @@ class UpdateSyncedResource extends QueueableListener $this->updateResourceInTenantDatabases($tenants, $event, $syncedAttributes); } - protected function getTenantsForCentralModel($centralModel): EloquentCollection + protected function getTenantsForCentralModel(Syncable $centralModel): TenantCollection { if (! $centralModel instanceof SyncMaster) { // If we're trying to use a tenant User model instead of the central User model, for example. throw new ModelNotSyncMasterException(get_class($centralModel)); } - /** @var SyncMaster|Model $centralModel */ + /** @var Tenant&Model&SyncMaster $centralModel */ // Since this model is "dirty" (taken by reference from the event), it might have the tenants // relationship already loaded and cached. For this reason, we refresh the relationship. $centralModel->load('tenants'); - return $centralModel->tenants; + /** @var TenantCollection $tenants */ + $tenants = $centralModel->tenants; + + return $tenants; } - protected function updateResourceInCentralDatabaseAndGetTenants($event, $syncedAttributes): EloquentCollection + protected function updateResourceInCentralDatabaseAndGetTenants(SyncedResourceSaved $event, array $syncedAttributes): TenantCollection { - /** @var Model|SyncMaster $centralModel */ + /** @var (Model&SyncMaster)|null $centralModel */ $centralModel = $event->model->getCentralModelName()::where($event->model->getGlobalIdentifierKeyName(), $event->model->getGlobalIdentifierKey()) ->first(); @@ -59,15 +67,17 @@ class UpdateSyncedResource extends QueueableListener event(new SyncedResourceChangedInForeignDatabase($event->model, null)); } else { // If the resource doesn't exist at all in the central DB,we create - // the record with all attributes, not just the synced ones. - $centralModel = $event->model->getCentralModelName()::create($event->model->getAttributes()); + $centralModel = $event->model->getCentralModelName()::create($this->getAttributesForCreation($event->model)); event(new SyncedResourceChangedInForeignDatabase($event->model, null)); } }); // If the model was just created, the mapping of the tenant to the user likely doesn't exist, so we create it. $currentTenantMapping = function ($model) use ($event) { - return ((string) $model->pivot->tenant_id) === ((string) $event->tenant->getTenantKey()); + /** @var Tenant */ + $tenant = $event->tenant; + + return ((string) $model->pivot->tenant_id) === ((string) $tenant->getTenantKey()); }; $mappingExists = $centralModel->tenants->contains($currentTenantMapping); @@ -76,22 +86,29 @@ class UpdateSyncedResource extends QueueableListener // Here we should call TenantPivot, but we call general Pivot, so that this works // even if people use their own pivot model that is not based on our TenantPivot Pivot::withoutEvents(function () use ($centralModel, $event) { - $centralModel->tenants()->attach($event->tenant->getTenantKey()); + /** @var Tenant */ + $tenant = $event->tenant; + + $centralModel->tenants()->attach($tenant->getTenantKey()); }); } - return $centralModel->tenants->filter(function ($model) use ($currentTenantMapping) { + /** @var TenantCollection $tenants */ + $tenants = $centralModel->tenants->filter(function ($model) use ($currentTenantMapping) { // Remove the mapping for the current tenant. return ! $currentTenantMapping($model); }); + + return $tenants; } - protected function updateResourceInTenantDatabases($tenants, $event, $syncedAttributes): void + protected function updateResourceInTenantDatabases(TenantCollection $tenants, SyncedResourceSaved $event, array $syncedAttributes): void { tenancy()->runForMultiple($tenants, function ($tenant) use ($event, $syncedAttributes) { // Forget instance state and find the model, // again in the current tenant's context. + /** @var Model&Syncable $eventModel */ $eventModel = $event->model; if ($eventModel instanceof SyncMaster) { @@ -112,12 +129,53 @@ class UpdateSyncedResource extends QueueableListener if ($localModel) { $localModel->update($syncedAttributes); } else { - // When creating, we use all columns, not just the synced ones. - $localModel = $localModelClass::create($eventModel->getAttributes()); + $localModel = $localModelClass::create($this->getAttributesForCreation($eventModel)); } event(new SyncedResourceChangedInForeignDatabase($localModel, $tenant)); }); }); } + + protected function getAttributesForCreation(Model&Syncable $model): array + { + if (! $model->getSyncedCreationAttributes()) { + // Creation attributes are not specified so create the model as 1:1 copy + // exclude the "primary key" because we want primary key to handle by the target model to avoid duplication errors + $attributes = $model->getAttributes(); + unset($attributes[$model->getKeyName()]); + + return $attributes; + } + + if (Arr::isAssoc($model->getSyncedCreationAttributes())) { + // Developer provided the default values (key => value) or mix of default values and attribute names (values only) + // We will merge the default values with provided attributes and sync attributes + [$attributeNames, $defaultValues] = $this->getAttributeNamesAndDefaultValues($model); + $attributes = $model->only(array_merge($model->getSyncedAttributeNames(), $attributeNames)); + + return array_merge($attributes, $defaultValues); + } + + // Developer provided the attribute names, so we'll use them to pick model attributes + return $model->only($model->getSyncedCreationAttributes()); + } + + /** + * Split the attribute names (sequential index items) and default values (key => values). + */ + protected function getAttributeNamesAndDefaultValues(Model&Syncable $model): array + { + $syncedCreationAttributes = $model->getSyncedCreationAttributes() ?? []; + + $attributes = Arr::where($syncedCreationAttributes, function ($value, $key) { + return is_numeric($key); + }); + + $defaultValues = Arr::where($syncedCreationAttributes, function ($value, $key) { + return is_string($key); + }); + + return [$attributes, $defaultValues]; + } } diff --git a/tests/Etc/synced_resource_migrations/users_extra/2019_08_08_000009_add_extra_column_to_central_users_table.php b/tests/Etc/synced_resource_migrations/users_extra/2019_08_08_000009_add_extra_column_to_central_users_table.php new file mode 100644 index 00000000..bfa13cc1 --- /dev/null +++ b/tests/Etc/synced_resource_migrations/users_extra/2019_08_08_000009_add_extra_column_to_central_users_table.php @@ -0,0 +1,26 @@ +string('foo'); + }); + } + + public function down() + { + } +} diff --git a/tests/ResourceSyncingTest.php b/tests/ResourceSyncingTest.php index 214a9f47..430c52ef 100644 --- a/tests/ResourceSyncingTest.php +++ b/tests/ResourceSyncingTest.php @@ -44,9 +44,10 @@ beforeEach(function () { Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class); - UpdateSyncedResource::$shouldQueue = false; // global state cleanup + UpdateSyncedResource::$shouldQueue = false; // Global state cleanup Event::listen(SyncedResourceSaved::class, UpdateSyncedResource::class); + // Run migrations on central connection pest()->artisan('migrate', [ '--path' => [ __DIR__ . '/Etc/synced_resource_migrations', @@ -83,7 +84,7 @@ test('only the synced columns are updated in the central db', function () { ]); $tenant = ResourceTenant::create(); - migrateTenantsResource(); + migrateUsersTableForTenants(); tenancy()->initialize($tenant); @@ -126,6 +127,231 @@ test('only the synced columns are updated in the central db', function () { ], ResourceUser::first()->getAttributes()); }); +// This tests attribute list on the central side, and default values on the tenant side +// Those two don't depend on each other, we're just testing having each option on each side +// using tests that combine the two, to avoid having an excessively long and complex test suite +test('sync resource creation works when central model provides attributes and resource model provides default values', function () { + [$tenant1, $tenant2] = createTenantsAndRunMigrations(); + + addExtraColumnToCentralDB(); + + $centralUser = CentralUserProvidingAttributeNames::create([ + 'global_id' => 'acme', + 'name' => 'John Doe', + 'email' => 'john@localhost', + 'password' => 'secret', + 'role' => 'commenter', + 'foo' => 'bar', // foo does not exist in resource model + ]); + + $tenant1->run(function () { + expect(ResourceUserProvidingDefaultValues::all())->toHaveCount(0); + }); + + // When central model provides the list of attributes, resource model will be created from the provided list of attributes' values + $centralUser->tenants()->attach('t1'); + + $tenant1->run(function () { + $resourceUser = ResourceUserProvidingDefaultValues::all(); + expect($resourceUser)->toHaveCount(1); + expect($resourceUser->first()->global_id)->toBe('acme'); + expect($resourceUser->first()->email)->toBe('john@localhost'); + // 'foo' attribute is not provided by central model + expect($resourceUser->first()->foo)->toBeNull(); + }); + + tenancy()->initialize($tenant2); + + // When resource model provides the list of default values, central model will be created from the provided list of default values + ResourceUserProvidingDefaultValues::create([ + 'global_id' => 'asdf', + 'name' => 'John Doe', + 'email' => 'john@localhost', + 'password' => 'secret', + 'role' => 'commenter', + ]); + + tenancy()->end(); + + // Assert central user was created using the list of default values + $centralUser = CentralUserProvidingAttributeNames::whereGlobalId('asdf')->first(); + expect($centralUser)->not()->toBeNull(); + expect($centralUser->name)->toBe('Default Name'); + expect($centralUser->email)->toBe('default@localhost'); + expect($centralUser->password)->toBe('password'); + expect($centralUser->role)->toBe('admin'); + expect($centralUser->foo)->toBe('bar'); +}); + +// This tests default values on the central side, and attribute list on the tenant side +// Those two don't depend on each other, we're just testing having each option on each side +// using tests that combine the two, to avoid having an excessively long and complex test suite +test('sync resource creation works when central model provides default values and resource model provides attributes', function () { + [$tenant1, $tenant2] = createTenantsAndRunMigrations(); + + addExtraColumnToCentralDB(); + + $centralUser = CentralUserProvidingDefaultValues::create([ + 'global_id' => 'acme', + 'name' => 'John Doe', + 'email' => 'john@localhost', + 'password' => 'secret', + 'role' => 'commenter', + 'foo' => 'bar', // foo does not exist in resource model + ]); + + $tenant1->run(function () { + expect(ResourceUserProvidingDefaultValues::all())->toHaveCount(0); + }); + + // When central model provides the list of default values, resource model will be created from the provided list of default values + $centralUser->tenants()->attach('t1'); + + $tenant1->run(function () { + // Assert resource user was created using the list of default values + $resourceUser = ResourceUserProvidingDefaultValues::first(); + expect($resourceUser)->not()->toBeNull(); + expect($resourceUser->global_id)->toBe('acme'); + expect($resourceUser->email)->toBe('default@localhost'); + expect($resourceUser->password)->toBe('password'); + expect($resourceUser->role)->toBe('admin'); + }); + + tenancy()->initialize($tenant2); + + // When resource model provides the list of attributes, central model will be created from the provided list of attributes' values + ResourceUserProvidingAttributeNames::create([ + 'global_id' => 'asdf', + 'name' => 'John Doe', + 'email' => 'john@localhost', + 'password' => 'secret', + 'role' => 'commenter', + ]); + + tenancy()->end(); + + // Assert central user was created using the list of provided attributes + $centralUser = CentralUserProvidingAttributeNames::whereGlobalId('asdf')->first(); + expect($centralUser)->not()->toBeNull(); + expect($centralUser->email)->toBe('john@localhost'); + expect($centralUser->password)->toBe('secret'); + expect($centralUser->role)->toBe('commenter'); +}); + +// This tests mixed attribute list/defaults on the central side, and no specified attributes on the tenant side +// Those two don't depend on each other, we're just testing having each option on each side +// using tests that combine the two, to avoid having an excessively long and complex test suite +test('sync resource creation works when central model provides mixture and resource model provides nothing', function () { + [$tenant1, $tenant2] = createTenantsAndRunMigrations(); + + $centralUser = CentralUserProvidingMixture::create([ + 'global_id' => 'acme', + 'name' => 'John Doe', + 'email' => 'john@localhost', + 'password' => 'password', + 'role' => 'commentator' + ]); + + $tenant1->run(function () { + expect(ResourceUser::all())->toHaveCount(0); + }); + + // When central model provides the list of a mixture (attributes and default values), resource model will be created from the provided list of mixture (attributes and default values) + $centralUser->tenants()->attach('t1'); + + $tenant1->run(function () { + $resourceUser = ResourceUser::first(); + + // Assert resource user was created using the provided attributes and default values + expect($resourceUser->global_id)->toBe('acme'); + expect($resourceUser->name)->toBe('John Doe'); + expect($resourceUser->email)->toBe('john@localhost'); + // default values + expect($resourceUser->role)->toBe('admin'); + expect($resourceUser->password)->toBe('secret'); + }); + + tenancy()->initialize($tenant2); + + // When resource model provides nothing/null, the central model will be created as a 1:1 copy of resource model + $resourceUser = ResourceUser::create([ + 'global_id' => 'acmey', + 'name' => 'John Doe', + 'email' => 'john@localhost', + 'password' => 'password', + 'role' => 'commentator' + ]); + + tenancy()->end(); + + $centralUser = CentralUserProvidingMixture::whereGlobalId('acmey')->first(); + expect($resourceUser->getSyncedCreationAttributes())->toBeNull(); + + $centralUser = $centralUser->toArray(); + $resourceUser = $resourceUser->toArray(); + unset($centralUser['id']); + unset($resourceUser['id']); + + // Assert central user created as 1:1 copy of resource model except "id" + expect($centralUser)->toBe($resourceUser); +}); + +// This tests no specified attributes on the central side, and mixed attribute list/defaults on the tenant side +// Those two don't depend on each other, we're just testing having each option on each side +// using tests that combine the two, to avoid having an excessively long and complex test suite +test('sync resource creation works when central model provides nothing and resource model provides mixture', function () { + [$tenant1, $tenant2] = createTenantsAndRunMigrations(); + + $centralUser = CentralUser::create([ + 'global_id' => 'acme', + 'name' => 'John Doe', + 'email' => 'john@localhost', + 'password' => 'password', + 'role' => 'commenter', + ]); + + $tenant1->run(function () { + expect(ResourceUserProvidingMixture::all())->toHaveCount(0); + }); + + // When central model provides nothing/null, the resource model will be created as a 1:1 copy of central model + $centralUser->tenants()->attach('t1'); + + expect($centralUser->getSyncedCreationAttributes())->toBeNull(); + $tenant1->run(function () use ($centralUser) { + $resourceUser = ResourceUserProvidingMixture::first(); + expect($resourceUser)->not()->toBeNull(); + $resourceUser = $resourceUser->toArray(); + $centralUser = $centralUser->withoutRelations()->toArray(); + unset($resourceUser['id']); + unset($centralUser['id']); + + expect($resourceUser)->toBe($centralUser); + }); + + tenancy()->initialize($tenant2); + + // When resource model provides the list of a mixture (attributes and default values), central model will be created from the provided list of mixture (attributes and default values) + ResourceUserProvidingMixture::create([ + 'global_id' => 'absd', + 'name' => 'John Doe', + 'email' => 'john@localhost', + 'password' => 'password', + 'role' => 'commenter', + ]); + + tenancy()->end(); + + $centralUser = CentralUser::whereGlobalId('absd')->first(); + + // Assert central user was created using the provided list of attributes and default values + expect($centralUser->name)->toBe('John Doe'); + expect($centralUser->email)->toBe('john@localhost'); + // default values + expect($centralUser->role)->toBe('admin'); + expect($centralUser->password)->toBe('secret'); +}); + test('creating the resource in tenant database creates it in central database and creates the mapping', function () { creatingResourceInTenantDatabaseCreatesAndMapInCentralDatabase(); }); @@ -152,7 +378,7 @@ test('attaching a tenant to the central resource triggers a pull from the tenant $tenant = ResourceTenant::create([ 'id' => 't1', ]); - migrateTenantsResource(); + migrateUsersTableForTenants(); $tenant->run(function () { expect(ResourceUser::all())->toHaveCount(0); @@ -177,7 +403,7 @@ test('attaching users to tenants does not do anything', function () { $tenant = ResourceTenant::create([ 'id' => 't1', ]); - migrateTenantsResource(); + migrateUsersTableForTenants(); $tenant->run(function () { expect(ResourceUser::all())->toHaveCount(0); @@ -212,7 +438,7 @@ test('resources are synced only to workspaces that have the resource', function $t3 = ResourceTenant::create([ 'id' => 't3', ]); - migrateTenantsResource(); + migrateUsersTableForTenants(); $centralUser->tenants()->attach('t1'); $centralUser->tenants()->attach('t2'); @@ -250,7 +476,7 @@ test('when a resource exists in other tenant dbs but is created in a tenant db t $t2 = ResourceTenant::create([ 'id' => 't2', ]); - migrateTenantsResource(); + migrateUsersTableForTenants(); // Copy (cascade) user to t1 DB $centralUser->tenants()->attach('t1'); @@ -298,7 +524,7 @@ test('the synced columns are updated in other tenant dbs where the resource exis $t3 = ResourceTenant::create([ 'id' => 't3', ]); - migrateTenantsResource(); + migrateUsersTableForTenants(); // Copy (cascade) user to t1 DB $centralUser->tenants()->attach('t1'); @@ -353,7 +579,7 @@ test('when the resource doesnt exist in the tenant db non synced columns will ca 'id' => 't1', ]); - migrateTenantsResource(); + migrateUsersTableForTenants(); $centralUser->tenants()->attach('t1'); @@ -367,7 +593,7 @@ test('when the resource doesnt exist in the central db non synced columns will b 'id' => 't1', ]); - migrateTenantsResource(); + migrateUsersTableForTenants(); $t1->run(function () { ResourceUser::create([ @@ -389,7 +615,7 @@ test('the listener can be queued', function () { 'id' => 't1', ]); - migrateTenantsResource(); + migrateUsersTableForTenants(); Queue::assertNothingPushed(); @@ -428,7 +654,7 @@ test('an event is fired for all touched resources', function () { $t3 = ResourceTenant::create([ 'id' => 't3', ]); - migrateTenantsResource(); + migrateUsersTableForTenants(); // Copy (cascade) user to t1 DB $centralUser->tenants()->attach('t1'); @@ -509,7 +735,7 @@ function creatingResourceInTenantDatabaseCreatesAndMapInCentralDatabase() expect(ResourceUser::all())->toHaveCount(0); $tenant = ResourceTenant::create(); - migrateTenantsResource(); + migrateUsersTableForTenants(); tenancy()->initialize($tenant); @@ -524,7 +750,7 @@ function creatingResourceInTenantDatabaseCreatesAndMapInCentralDatabase() tenancy()->end(); - // Asset user was created + // Assert user was created expect(CentralUser::first()->global_id)->toBe('acme'); expect(CentralUser::first()->role)->toBe('commenter'); @@ -537,7 +763,28 @@ function creatingResourceInTenantDatabaseCreatesAndMapInCentralDatabase() expect(ResourceUser::first()->role)->toBe('commenter'); } -function migrateTenantsResource() +/** + * Create two tenants and run migrations for those tenants. + */ +function createTenantsAndRunMigrations(): array +{ + [$tenant1, $tenant2] = [ResourceTenant::create(['id' => 't1']), ResourceTenant::create(['id' => 't2'])]; + + migrateUsersTableForTenants(); + + return [$tenant1, $tenant2]; +} + +function addExtraColumnToCentralDB(): void +{ + // migrate extra column "foo" in central DB + pest()->artisan('migrate', [ + '--path' => __DIR__ . '/Etc/synced_resource_migrations/users_extra', + '--realpath' => true, + ])->assertExitCode(0); +} + +function migrateUsersTableForTenants(): void { pest()->artisan('tenants:migrate', [ '--path' => __DIR__ . '/Etc/synced_resource_migrations/users', @@ -593,6 +840,7 @@ class CentralUser extends Model implements SyncMaster public function getSyncedAttributeNames(): array { return [ + 'global_id', 'name', 'password', 'email', @@ -628,9 +876,106 @@ class ResourceUser extends Model implements Syncable public function getSyncedAttributeNames(): array { return [ + 'global_id', 'name', 'password', 'email', ]; } } + +// override method in ResourceUser class to return default attribute values +class ResourceUserProvidingDefaultValues extends ResourceUser +{ + public function getSyncedCreationAttributes(): array + { + // Default values when creating resources from tenant to central DB + return + [ + 'name' => 'Default Name', + 'email' => 'default@localhost', + 'password' => 'password', + 'role' => 'admin', + 'foo' => 'bar' + ]; + } +} + +// override method in ResourceUser class to return attribute names +class ResourceUserProvidingAttributeNames extends ResourceUser +{ + public function getSyncedCreationAttributes(): array + { + // Attributes used when creating resources from tenant to central DB + // Notice here we are not adding "code" filed because it doesn't + // exist in central model + return + [ + 'name', + 'password', + 'email', + 'role', + 'foo' => 'bar' + ]; + } + +} + +// override method in CentralUser class to return attribute default values +class CentralUserProvidingDefaultValues extends CentralUser +{ + public function getSyncedCreationAttributes(): array + { + // Attributes default values when creating resources from central to tenant model + return + [ + 'name' => 'Default User', + 'email' => 'default@localhost', + 'password' => 'password', + 'role' => 'admin', + ]; + } +} + +// override method in CentralUser class to return attribute names +class CentralUserProvidingAttributeNames extends CentralUser +{ + public function getSyncedCreationAttributes(): array + { + // Attributes used when creating resources from central to tenant DB + return + [ + 'global_id', + 'name', + 'password', + 'email', + 'role', + ]; + } +} + +class CentralUserProvidingMixture extends CentralUser +{ + public function getSyncedCreationAttributes(): array + { + return [ + 'name', + 'email', + 'role' => 'admin', + 'password' => 'secret', + ]; + } +} + +class ResourceUserProvidingMixture extends ResourceUser +{ + public function getSyncedCreationAttributes(): array + { + return [ + 'name', + 'email', + 'role' => 'admin', + 'password' => 'secret', + ]; + } +}