diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 27237520..59a74f52 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -35,14 +35,13 @@ 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! ])->send(function (Events\TenantCreated $event) { return $event->tenant; - })->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production. + })->shouldBeQueued(false), // `false` by default, but you likely want to make this `true` in production. // Listeners\CreateTenantStorage::class, ], diff --git a/assets/config.php b/assets/config.php index b4adcf4a..3c55fda8 100644 --- a/assets/config.php +++ b/assets/config.php @@ -52,7 +52,7 @@ return [ * * 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 + 'default_middleware' => Middleware\InitializeTenancyByDomain::class, /** * All of the identification middleware used by the package. @@ -100,19 +100,21 @@ return [ Resolvers\DomainTenantResolver::class => [ 'cache' => false, 'cache_ttl' => 3600, // seconds - 'cache_store' => null, // default + 'cache_store' => null, // null = default ], Resolvers\PathTenantResolver::class => [ 'tenant_parameter_name' => 'tenant', + 'tenant_model_column' => null, // null = tenant key + 'allowed_extra_model_columns' => [], // used with binding route fields 'cache' => false, 'cache_ttl' => 3600, // seconds - 'cache_store' => null, // default + 'cache_store' => null, // null = default ], Resolvers\RequestDataTenantResolver::class => [ 'cache' => false, 'cache_ttl' => 3600, // seconds - 'cache_store' => null, // default + 'cache_store' => null, // null = default ], ], @@ -150,25 +152,6 @@ return [ // Stancl\Tenancy\Bootstrappers\Integrations\ScoutTenancyBootstrapper::class, ], - - /** - * 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. */ @@ -348,6 +331,24 @@ return [ */ 'default_route_mode' => RouteMode::CENTRAL, + /** + * 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), + ], + /** * Parameters used by the tenants:migrate command. */ diff --git a/src/Database/Concerns/InvalidatesResolverCache.php b/src/Database/Concerns/InvalidatesResolverCache.php index 21894f41..0d34d9f8 100644 --- a/src/Database/Concerns/InvalidatesResolverCache.php +++ b/src/Database/Concerns/InvalidatesResolverCache.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\Concerns; +use Illuminate\Database\Eloquent\Model; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver; use Stancl\Tenancy\Tenancy; @@ -12,7 +13,7 @@ trait InvalidatesResolverCache { public static function bootInvalidatesResolverCache(): void { - static::saved(function (Tenant $tenant) { + static::saved(function (Tenant&Model $tenant) { foreach (Tenancy::cachedResolvers() as $resolver) { /** @var CachedTenantResolver $resolver */ $resolver = app($resolver); diff --git a/src/Exceptions/TenantColumnNotWhitelistedException.php b/src/Exceptions/TenantColumnNotWhitelistedException.php new file mode 100644 index 00000000..6b59d8f7 --- /dev/null +++ b/src/Exceptions/TenantColumnNotWhitelistedException.php @@ -0,0 +1,18 @@ +tenantCouldNotBeIdentified("on path with tenant key: $tenant_id (column not whitelisted)") + ->title('Tenant could not be identified on this route because the used column is not whitelisted.') + ->description('Please add the column to the list of allowed columns in the PathTenantResolver config.'); + } +} diff --git a/src/Exceptions/TenantCouldNotBeIdentifiedByIdException.php b/src/Exceptions/TenantCouldNotBeIdentifiedByIdException.php index ffd3dbdc..b344be53 100644 --- a/src/Exceptions/TenantCouldNotBeIdentifiedByIdException.php +++ b/src/Exceptions/TenantCouldNotBeIdentifiedByIdException.php @@ -13,8 +13,8 @@ class TenantCouldNotBeIdentifiedByIdException extends TenantCouldNotBeIdentified public function __construct(int|string $tenant_id) { $this - ->tenantCouldNotBeIdentified("by tenant id: $tenant_id") - ->title('Tenant could not be identified with that ID') - ->description('Are you sure the ID is correct and the tenant exists?'); + ->tenantCouldNotBeIdentified("by tenant key: $tenant_id") + ->title('Tenant could not be identified with that key') + ->description('Are you sure the key is correct and the tenant exists?'); } } diff --git a/src/Exceptions/TenantCouldNotBeIdentifiedByPathException.php b/src/Exceptions/TenantCouldNotBeIdentifiedByPathException.php index 5a494d90..ef51f8bf 100644 --- a/src/Exceptions/TenantCouldNotBeIdentifiedByPathException.php +++ b/src/Exceptions/TenantCouldNotBeIdentifiedByPathException.php @@ -11,7 +11,7 @@ class TenantCouldNotBeIdentifiedByPathException extends TenantCouldNotBeIdentifi public function __construct(int|string $tenant_id) { $this - ->tenantCouldNotBeIdentified("on path with tenant id: $tenant_id") + ->tenantCouldNotBeIdentified("on path with tenant key: $tenant_id") ->title('Tenant could not be identified on this path') ->description('Did you forget to create a tenant for this path?'); } diff --git a/src/Middleware/InitializeTenancyByPath.php b/src/Middleware/InitializeTenancyByPath.php index 2d44b3ed..dd280965 100644 --- a/src/Middleware/InitializeTenancyByPath.php +++ b/src/Middleware/InitializeTenancyByPath.php @@ -39,7 +39,7 @@ class InitializeTenancyByPath extends IdentificationMiddleware implements Usable // Only initialize tenancy if the route has the tenant parameter. // We don't want to initialize tenancy if the tenant is - // simply injected into some route controller action. + // simply injected into some central route action. if (in_array(PathTenantResolver::tenantParameterName(), $route->parameterNames())) { return $this->initializeTenancy( $request, diff --git a/src/PathIdentificationManager.php b/src/PathIdentificationManager.php index 48914964..37f7df69 100644 --- a/src/PathIdentificationManager.php +++ b/src/PathIdentificationManager.php @@ -8,6 +8,7 @@ use Closure; use Illuminate\Routing\Route; use Stancl\Tenancy\Resolvers\PathTenantResolver; +// todo a lot of duplicate logic with PathTenantResolver, ideally remove this class class PathIdentificationManager { public static Closure|null $tenantParameterName = null; diff --git a/src/Resolvers/Contracts/CachedTenantResolver.php b/src/Resolvers/Contracts/CachedTenantResolver.php index e6588718..3ea518b3 100644 --- a/src/Resolvers/Contracts/CachedTenantResolver.php +++ b/src/Resolvers/Contracts/CachedTenantResolver.php @@ -6,7 +6,9 @@ namespace Stancl\Tenancy\Resolvers\Contracts; use Illuminate\Contracts\Cache\Factory; use Illuminate\Contracts\Cache\Repository; +use Illuminate\Database\Eloquent\Model; use Stancl\Tenancy\Contracts\Tenant; +use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException; use Stancl\Tenancy\Contracts\TenantResolver; abstract class CachedTenantResolver implements TenantResolver @@ -19,13 +21,21 @@ abstract class CachedTenantResolver implements TenantResolver $this->cache = $cache->store(static::cacheStore()); } + /** + * Resolve a tenant using some value passed from the middleware. + * + * @throws TenantCouldNotBeIdentifiedException + */ public function resolve(mixed ...$args): Tenant { if (! static::shouldCache()) { - return $this->resolveWithoutCache(...$args); + $tenant = $this->resolveWithoutCache(...$args); + $this->resolved($tenant, ...$args); + + return $tenant; } - $key = $this->getCacheKey(...$args); + $key = $this->formatCacheKey(...$args); if ($tenant = $this->cache->get($key)) { $this->resolved($tenant, ...$args); @@ -35,44 +45,51 @@ abstract class CachedTenantResolver implements TenantResolver $tenant = $this->resolveWithoutCache(...$args); $this->cache->put($key, $tenant, static::cacheTTL()); + $this->resolved($tenant, ...$args); return $tenant; } - public function invalidateCache(Tenant $tenant): void + /** + * Invalidate this resolver's cache for a tenant. + */ + public function invalidateCache(Tenant&Model $tenant): void { if (! static::shouldCache()) { return; } - foreach ($this->getArgsForTenant($tenant) as $args) { - $this->cache->forget($this->getCacheKey(...$args)); + foreach ($this->getPossibleCacheKeys($tenant) as $key) { + $this->cache->forget($key); } } - public function getCacheKey(mixed ...$args): string + public function formatCacheKey(mixed ...$args): string { return '_tenancy_resolver:' . static::class . ':' . json_encode($args); } + /** + * Resolve a tenant using $args passed from middleware, without using cache. + * + * @throws TenantCouldNotBeIdentifiedException + */ abstract public function resolveWithoutCache(mixed ...$args): Tenant; + /** + * Called after a tenant has been resolved from cache or without cache. + * + * Used for side effects like removing the tenant parameter from the request route. + */ public function resolved(Tenant $tenant, mixed ...$args): void { } - /** - * Get all possible argument combinations for resolve() which can be used for caching the tenant. - * - * This is used during tenant cache invalidation. - * - * @return array[] - */ - abstract public function getArgsForTenant(Tenant $tenant): array; + abstract public function getPossibleCacheKeys(Tenant&Model $tenant): array; public static function shouldCache(): bool { - return config('tenancy.identification.resolvers.' . static::class . '.cache') ?? false; + return (config('tenancy.identification.resolvers.' . static::class . '.cache') ?? false) && static::cacheTTL() > 0; } public static function cacheTTL(): int diff --git a/src/Resolvers/DomainTenantResolver.php b/src/Resolvers/DomainTenantResolver.php index 809350fb..18f6d3a2 100644 --- a/src/Resolvers/DomainTenantResolver.php +++ b/src/Resolvers/DomainTenantResolver.php @@ -56,21 +56,22 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver } } - public function getArgsForTenant(Tenant $tenant): array + public function getPossibleCacheKeys(Tenant&Model $tenant): array { if ($tenant instanceof SingleDomainTenant) { - /** @var SingleDomainTenant&Model $tenant */ - return [ - [$tenant->getOriginal('domain')], // Previous domain - [$tenant->domain], // Current domain - ]; + $domains = array_filter([ + $tenant->getOriginal('domain'), // Previous domain + $tenant->domain, // Current domain + ]); + } else { + /** @var Tenant&Model $tenant */ + $tenant->unsetRelation('domains'); + + $domains = $tenant->domains->map(function (Domain&Model $domain) { + return $domain->domain; + })->toArray(); } - /** @var Tenant&Model $tenant */ - $tenant->unsetRelation('domains'); - - return $tenant->domains->map(function (Domain&Model $domain) { - return [$domain->domain]; - })->toArray(); + return array_map(fn (string $domain) => $this->formatCacheKey($domain), $domains); } } diff --git a/src/Resolvers/PathTenantResolver.php b/src/Resolvers/PathTenantResolver.php index 3abd8abf..cd828800 100644 --- a/src/Resolvers/PathTenantResolver.php +++ b/src/Resolvers/PathTenantResolver.php @@ -4,10 +4,11 @@ declare(strict_types=1); namespace Stancl\Tenancy\Resolvers; +use Illuminate\Database\Eloquent\Model; use Illuminate\Routing\Route; use Stancl\Tenancy\Contracts\Tenant; +use Stancl\Tenancy\Exceptions\TenantColumnNotWhitelistedException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByPathException; -use Stancl\Tenancy\PathIdentificationManager; class PathTenantResolver extends Contracts\CachedTenantResolver { @@ -16,26 +17,30 @@ class PathTenantResolver extends Contracts\CachedTenantResolver /** @var Route $route */ $route = $args[0]; - /** @var string $id */ - $id = $route->parameter(static::tenantParameterName()); + /** @var string $key */ + $key = $route->parameter(static::tenantParameterName()); + $column = $route->bindingFieldFor(static::tenantParameterName()) ?? static::tenantModelColumn(); - if ($id) { - // Forget the tenant parameter so that we don't have to accept it in route action methods - $route->forgetParameter(static::tenantParameterName()); + if ($column !== static::tenantModelColumn() && ! in_array($column, static::allowedExtraModelColumns())) { + throw new TenantColumnNotWhitelistedException($key); + } - if ($tenant = tenancy()->find($id)) { + if ($key) { + if ($tenant = tenancy()->find($key, $column)) { + /** @var Tenant $tenant */ return $tenant; } } - throw new TenantCouldNotBeIdentifiedByPathException($id); + throw new TenantCouldNotBeIdentifiedByPathException($key); } - public function getArgsForTenant(Tenant $tenant): array + public function getPossibleCacheKeys(Tenant&Model $tenant): array { - return [ - [$tenant->getTenantKey()], - ]; + $columns = array_unique(array_merge(static::allowedExtraModelColumns(), [static::tenantModelColumn()])); + $columnValuePairs = array_map(fn ($column) => [$column, $tenant->getAttribute($column)], $columns); + + return array_map(fn ($columnValuePair) => $this->formatCacheKey(...$columnValuePair), $columnValuePairs); } public function resolved(Tenant $tenant, mixed ...$args): void @@ -43,21 +48,22 @@ class PathTenantResolver extends Contracts\CachedTenantResolver /** @var Route $route */ $route = $args[0]; - $route->forgetParameter(PathIdentificationManager::getTenantParameterName()); + // Forget the tenant parameter so that we don't have to accept it in route action methods + $route->forgetParameter(static::tenantParameterName()); } - public function getCacheKey(mixed ...$args): string + public function formatCacheKey(mixed ...$args): string { - // todo@samuel fix the coupling here. when this is called from the cachedresolver, $args are the tenant key. when it's called from within this class, $args are a Route instance - // the logic shouldn't have to be coupled to where it's being called from + // When called in resolve(), $args contains the route + // When called in getPossibleCacheKeys(), $args contains the column-value pair + if ($args[0] instanceof Route) { + $column = $args[0]->bindingFieldFor(static::tenantParameterName()) ?? static::tenantModelColumn(); + $value = $args[0]->parameter(static::tenantParameterName()); + } else { + [$column, $value] = $args; + } - // todo@samuel also make the tenant column configurable - - // $args[0] can be either a Route instance with the tenant key as a parameter - // Or the tenant key - $args = [$args[0] instanceof Route ? $args[0]->parameter(static::tenantParameterName()) : $args[0]]; - - return '_tenancy_resolver:' . static::class . ':' . json_encode($args); + return parent::formatCacheKey($column, $value); } public static function tenantParameterName(): string @@ -69,4 +75,15 @@ class PathTenantResolver extends Contracts\CachedTenantResolver { return config('tenancy.identification.resolvers.' . static::class . '.tenant_route_name_prefix') ?? static::tenantParameterName() . '.'; } + + public static function tenantModelColumn(): string + { + return config('tenancy.identification.resolvers.' . static::class . '.tenant_model_column') ?? tenancy()->model()->getTenantKeyName(); + } + + /** @return string[] */ + public static function allowedExtraModelColumns(): array + { + return config('tenancy.identification.resolvers.' . static::class . '.allowed_extra_model_columns') ?? []; + } } diff --git a/src/Resolvers/RequestDataTenantResolver.php b/src/Resolvers/RequestDataTenantResolver.php index 8a5bbc53..49cdc908 100644 --- a/src/Resolvers/RequestDataTenantResolver.php +++ b/src/Resolvers/RequestDataTenantResolver.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Resolvers; +use Illuminate\Database\Eloquent\Model; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByRequestDataException; @@ -26,10 +27,10 @@ class RequestDataTenantResolver extends Contracts\CachedTenantResolver throw new TenantCouldNotBeIdentifiedByRequestDataException($payload); } - public function getArgsForTenant(Tenant $tenant): array + public function getPossibleCacheKeys(Tenant&Model $tenant): array { return [ - [$tenant->getTenantKey()], + $this->formatCacheKey($tenant->getTenantKey()), ]; } } diff --git a/src/Tenancy.php b/src/Tenancy.php index e9eae418..dea62068 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -137,10 +137,10 @@ class Tenancy /** * Try to find a tenant using an ID. */ - public static function find(int|string $id): Tenant|null + public static function find(int|string $id, string $column = null): (Tenant&Model)|null { - /** @var (Tenant&Model)|null */ - $tenant = static::model()->where(static::model()->getTenantKeyName(), $id)->first(); + /** @var (Tenant&Model)|null $tenant */ + $tenant = static::model()->firstWhere($column ?? static::model()->getTenantKeyName(), $id); return $tenant; } diff --git a/tests/CachedTenantResolverTest.php b/tests/CachedTenantResolverTest.php index 94a555aa..c6197655 100644 --- a/tests/CachedTenantResolverTest.php +++ b/tests/CachedTenantResolverTest.php @@ -2,12 +2,15 @@ declare(strict_types=1); +use Illuminate\Database\Schema\Blueprint; use Illuminate\Routing\Route; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Redis; use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Resolvers\PathTenantResolver; use Stancl\Tenancy\Resolvers\DomainTenantResolver; use Illuminate\Support\Facades\Route as RouteFacade; +use Illuminate\Support\Facades\Schema; use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\PathIdentificationManager; use Stancl\Tenancy\Resolvers\RequestDataTenantResolver; @@ -74,7 +77,7 @@ test('cache is invalidated when the tenant is updated', function (string $resolv expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenantKey))))->toBeTrue(); - expect(DB::getQueryLog())->not()->toBeEmpty(); // Cache was invalidated, so the tenant was retrievevd from the DB + expect(DB::getQueryLog())->not()->toBeEmpty(); // Cache was invalidated, so the tenant was retrieved from the DB })->with([ DomainTenantResolver::class, PathTenantResolver::class, @@ -109,6 +112,7 @@ test('cache is invalidated when a tenants domain is changed', function () { test('PathTenantResolver forgets the tenant route parameter when the tenant is resolved from cache', function() { config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.cache' => true]); + DB::enableQueryLog(); Tenant::create(['id' => 'foo']); @@ -127,6 +131,136 @@ test('PathTenantResolver forgets the tenant route parameter when the tenant is r pest()->assertEmpty(DB::getQueryLog()); // resolved from cache }); +test('PathTenantResolver properly separates cache for each tenant column', function () { + config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.cache' => true]); + config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.allowed_extra_model_columns' => ['slug']]); + Tenant::$extraCustomColumns = ['slug']; + DB::enableQueryLog(); + + Schema::table('tenants', function (Blueprint $table) { + $table->string('slug')->unique(); + }); + + $t1 = Tenant::create(['id' => 'foo', 'slug' => 'bar']); + $t2 = Tenant::create(['id' => 'bar', 'slug' => 'foo']); + + RouteFacade::get('x/{tenant}/a', function () { + return tenant()->getTenantKey(); + })->middleware(InitializeTenancyByPath::class); + + RouteFacade::get('x/{tenant:slug}/b', function () { + return tenant()->getTenantKey(); + })->middleware(InitializeTenancyByPath::class); + + DB::flushQueryLog(); + + $redisKeys = fn () => array_map( + fn (string $key) => str($key)->after('PathTenantResolver:')->toString(), + Redis::connection('cache')->keys('*') + ); + + pest()->get("/x/foo/a")->assertSee('foo'); + expect(count(DB::getRawQueryLog()))->toBe(1); + expect(DB::getRawQueryLog()[0]['raw_query'])->toBe("select * from `tenants` where `id` = 'foo' limit 1"); + expect($redisKeys())->toEqualCanonicalizing([ + '["id","foo"]', + ]); + + pest()->get("/x/bar/b")->assertSee('foo'); + expect(count(DB::getRawQueryLog()))->toBe(2); + expect(DB::getRawQueryLog()[1]['raw_query'])->toBe("select * from `tenants` where `slug` = 'bar' limit 1"); + expect($redisKeys())->toEqualCanonicalizing([ + '["id","foo"]', + '["slug","bar"]', + ]); + + // Test if cache hits + pest()->get("/x/foo/a")->assertSee('foo'); + expect(count(DB::getRawQueryLog()))->toBe(2); // unchanged + expect(count($redisKeys()))->toBe(2); // unchanged + + pest()->get("/x/bar/b")->assertSee('foo'); + expect(count(DB::getRawQueryLog()))->toBe(2); // unchanged + expect(count($redisKeys()))->toBe(2); // unchanged + + // Make requests for a tenant that has reversed values for the columns + pest()->get("/x/bar/a")->assertSee('bar'); + expect(count(DB::getRawQueryLog()))->toBe(3); // +1 + expect(DB::getRawQueryLog()[2]['raw_query'])->toBe("select * from `tenants` where `id` = 'bar' limit 1"); + expect($redisKeys())->toEqualCanonicalizing([ + '["id","foo"]', + '["slug","bar"]', + '["id","bar"]', // added + ]); + + pest()->get("/x/foo/b")->assertSee('bar'); + expect(count(DB::getRawQueryLog()))->toBe(4); + expect(DB::getRawQueryLog()[3]['raw_query'])->toBe("select * from `tenants` where `slug` = 'foo' limit 1"); + expect($redisKeys())->toEqualCanonicalizing([ + '["id","foo"]', + '["slug","bar"]', + '["id","bar"]', + '["slug","foo"]', // added + ]); + + // Test if cache hits for the tenant with reversed values + pest()->get("/x/bar/a")->assertSee('bar'); + expect(count(DB::getRawQueryLog()))->toBe(4); // unchanged + expect(count($redisKeys()))->toBe(4); // unchanged + + pest()->get("/x/foo/b")->assertSee('bar'); + expect(count(DB::getRawQueryLog()))->toBe(4); // unchanged + expect(count($redisKeys()))->toBe(4); // unchanged + + // Try to resolve the previous tenant again, confirming the cache values for the new tenant are properly separated from the previous tenant + pest()->get("/x/foo/a")->assertSee('foo'); + pest()->get("/x/foo/b")->assertSee('bar'); + pest()->get("/x/bar/a")->assertSee('bar'); + pest()->get("/x/bar/b")->assertSee('foo'); + expect(count(DB::getRawQueryLog()))->toBe(4); // unchanged + expect(count($redisKeys()))->toBe(4); // unchanged + + $t1->update(['random_value' => 'just to clear cache']); + expect($redisKeys())->toEqualCanonicalizing([ + // '["id","foo"]', // these two have been removed + // '["slug","bar"]', + '["id","bar"]', + '["slug","foo"]', + ]); + + $t2->update(['random_value' => 'just to clear cache']); + expect($redisKeys())->toBe([]); + + DB::flushQueryLog(); + + // Cache gets repopulated + pest()->get("/x/foo/a")->assertSee('foo'); + expect(count(DB::getRawQueryLog()))->toBe(1); + expect(count($redisKeys()))->toBe(1); + + pest()->get("/x/foo/b")->assertSee('bar'); + expect(count(DB::getRawQueryLog()))->toBe(2); + expect(count($redisKeys()))->toBe(2); + + pest()->get("/x/bar/a")->assertSee('bar'); + expect(count(DB::getRawQueryLog()))->toBe(3); + expect(count($redisKeys()))->toBe(3); + + pest()->get("/x/bar/b")->assertSee('foo'); + expect(count(DB::getRawQueryLog()))->toBe(4); + expect(count($redisKeys()))->toBe(4); + + // After which, the cache becomes active again + pest()->get("/x/foo/a")->assertSee('foo'); + pest()->get("/x/foo/b")->assertSee('bar'); + pest()->get("/x/bar/a")->assertSee('bar'); + pest()->get("/x/bar/b")->assertSee('foo'); + expect(count(DB::getRawQueryLog()))->toBe(4); // unchanged + expect(count($redisKeys()))->toBe(4); // unchanged + + Tenant::$extraCustomColumns = []; // reset +}); + /** * Return the argument for the resolver – tenant key, or a route instance with the tenant parameter. * diff --git a/tests/Etc/Tenant.php b/tests/Etc/Tenant.php index f9a11d95..d0eaa0ca 100644 --- a/tests/Etc/Tenant.php +++ b/tests/Etc/Tenant.php @@ -16,5 +16,12 @@ use Stancl\Tenancy\Database\Models; */ class Tenant extends Models\Tenant implements TenantWithDatabase { + public static array $extraCustomColumns = []; + use HasDatabase, HasDomains, HasPending, MaintenanceMode; + + public static function getCustomColumns(): array + { + return array_merge(parent::getCustomColumns(), static::$extraCustomColumns); + } } diff --git a/tests/PathIdentificationTest.php b/tests/PathIdentificationTest.php index c0819e0b..9fbaf68b 100644 --- a/tests/PathIdentificationTest.php +++ b/tests/PathIdentificationTest.php @@ -3,8 +3,11 @@ declare(strict_types=1); use Illuminate\Contracts\Http\Kernel; +use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Route; +use Illuminate\Support\Facades\Schema; use Stancl\Tenancy\Exceptions\RouteIsMissingTenantParameterException; +use Stancl\Tenancy\Exceptions\TenantColumnNotWhitelistedException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByPathException; use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Resolvers\PathTenantResolver; @@ -15,6 +18,7 @@ beforeEach(function () { config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => 'tenant']); InitializeTenancyByPath::$onFail = null; + Tenant::$extraCustomColumns = []; Route::group([ 'prefix' => '/{tenant}', @@ -160,3 +164,84 @@ test('central route can have a parameter with the same name as the tenant parame expect(tenancy()->initialized)->toBeFalse(); }); + +test('the tenant model column can be customized in the config', function () { + config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_model_column' => 'slug']); + Tenant::$extraCustomColumns = ['slug']; + + Schema::table('tenants', function (Blueprint $table) { + $table->string('slug')->unique(); + }); + + $tenant = Tenant::create([ + 'slug' => 'acme', + ]); + + Route::get('/{tenant}/foo', function () { + return tenant()->getTenantKey(); + })->middleware(InitializeTenancyByPath::class); + + $this->withoutExceptionHandling(); + pest()->get('/acme/foo')->assertSee($tenant->getTenantKey()); + expect(fn () => pest()->get($tenant->id . '/foo'))->toThrow(TenantCouldNotBeIdentifiedByPathException::class); + + Tenant::$extraCustomColumns = []; // static property reset +}); + +test('the tenant model column can be customized in the route definition', function () { + Tenant::$extraCustomColumns = ['slug']; + config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.allowed_extra_model_columns' => ['slug']]); + + Schema::table('tenants', function (Blueprint $table) { + $table->string('slug')->unique(); + }); + + $tenant = Tenant::create([ + 'slug' => 'acme', + ]); + + Route::get('/{tenant}/foo', function () { + return tenant()->getTenantKey(); + })->middleware(InitializeTenancyByPath::class); + + Route::get('/{tenant:slug}/bar', function () { + return tenant()->getTenantKey(); + })->middleware(InitializeTenancyByPath::class); + + $this->withoutExceptionHandling(); + + // No binding field defined + pest()->get($tenant->getTenantKey() . '/foo')->assertSee($tenant->getTenantKey()); + expect(fn () => pest()->get('/acme/foo'))->toThrow(TenantCouldNotBeIdentifiedByPathException::class); + + // Binding field defined + pest()->get('/acme/bar')->assertSee($tenant->getTenantKey()); + expect(fn () => pest()->get($tenant->id . '/bar'))->toThrow(TenantCouldNotBeIdentifiedByPathException::class); + + Tenant::$extraCustomColumns = []; // static property reset +}); + +test('any extra model column needs to be whitelisted', function () { + Tenant::$extraCustomColumns = ['slug']; + + Schema::table('tenants', function (Blueprint $table) { + $table->string('slug')->unique(); + }); + + $tenant = Tenant::create([ + 'slug' => 'acme', + ]); + + Route::get('/{tenant:slug}/foo', function () { + return tenant()->getTenantKey(); + })->middleware(InitializeTenancyByPath::class); + + $this->withoutExceptionHandling(); + expect(fn () => pest()->get('/acme/foo'))->toThrow(TenantColumnNotWhitelistedException::class); + + // After whitelisting the column it works + config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.allowed_extra_model_columns' => ['slug']]); + pest()->get('/acme/foo')->assertSee($tenant->getTenantKey()); + + Tenant::$extraCustomColumns = []; // static property reset +}); diff --git a/tests/SingleDomainTenantTest.php b/tests/SingleDomainTenantTest.php index 6e1b7726..3c976821 100644 --- a/tests/SingleDomainTenantTest.php +++ b/tests/SingleDomainTenantTest.php @@ -93,7 +93,7 @@ test('cache is invalidated when a single domain tenants domain is updated', func DB::flushQueryLog(); expect($tenant->is(app(DomainTenantResolver::class)->resolve('baz')))->toBeTrue(); pest()->assertNotEmpty(DB::getQueryLog()); // resolving using current subdomain for the first time - + DB::flushQueryLog(); expect($tenant->is(app(DomainTenantResolver::class)->resolve('baz')))->toBeTrue(); pest()->assertEmpty(DB::getQueryLog()); // resolving using current subdomain for the second time