1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2025-12-12 14:34:04 +00:00

Resolver refactor, path identification improvements (#41)

* resolver refactor

* Fix code style (php-cs-fixer)

* make tenant column used in PathTenantResolver configurable, fix phpstan errors, minor improvements

* support binding route fields, write tests for customizable tenant columns

* Invalidate cache for all possible columns in path resolver

* implement proper cache separation logic for different columns used by PathTenantResolver

* improve return type

---------

Co-authored-by: PHP CS Fixer <phpcsfixer@example.com>
This commit is contained in:
Samuel Štancl 2024-03-28 03:18:11 +01:00 committed by GitHub
parent dc430666ba
commit 0c11f29c19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 370 additions and 88 deletions

View file

@ -35,14 +35,13 @@ class TenancyServiceProvider extends ServiceProvider
Jobs\CreateDatabase::class, Jobs\CreateDatabase::class,
Jobs\MigrateDatabase::class, Jobs\MigrateDatabase::class,
// Jobs\SeedDatabase::class, // Jobs\SeedDatabase::class,
// Jobs\CreateStorageSymlinks::class, // Jobs\CreateStorageSymlinks::class,
// Your own jobs to prepare the tenant. // Your own jobs to prepare the tenant.
// Provision API keys, create S3 buckets, anything you want! // Provision API keys, create S3 buckets, anything you want!
])->send(function (Events\TenantCreated $event) { ])->send(function (Events\TenantCreated $event) {
return $event->tenant; 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, // Listeners\CreateTenantStorage::class,
], ],

View file

@ -52,7 +52,7 @@ return [
* *
* If you use multiple forms of identification, you can set this to the "main" approach you use. * 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. * All of the identification middleware used by the package.
@ -100,19 +100,21 @@ return [
Resolvers\DomainTenantResolver::class => [ Resolvers\DomainTenantResolver::class => [
'cache' => false, 'cache' => false,
'cache_ttl' => 3600, // seconds 'cache_ttl' => 3600, // seconds
'cache_store' => null, // default 'cache_store' => null, // null = default
], ],
Resolvers\PathTenantResolver::class => [ Resolvers\PathTenantResolver::class => [
'tenant_parameter_name' => 'tenant', 'tenant_parameter_name' => 'tenant',
'tenant_model_column' => null, // null = tenant key
'allowed_extra_model_columns' => [], // used with binding route fields
'cache' => false, 'cache' => false,
'cache_ttl' => 3600, // seconds 'cache_ttl' => 3600, // seconds
'cache_store' => null, // default 'cache_store' => null, // null = default
], ],
Resolvers\RequestDataTenantResolver::class => [ Resolvers\RequestDataTenantResolver::class => [
'cache' => false, 'cache' => false,
'cache_ttl' => 3600, // seconds 'cache_ttl' => 3600, // seconds
'cache_store' => null, // default 'cache_store' => null, // null = default
], ],
], ],
@ -150,25 +152,6 @@ return [
// Stancl\Tenancy\Bootstrappers\Integrations\ScoutTenancyBootstrapper::class, // 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. * Database tenancy config. Used by DatabaseTenancyBootstrapper.
*/ */
@ -348,6 +331,24 @@ return [
*/ */
'default_route_mode' => RouteMode::CENTRAL, '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. * Parameters used by the tenants:migrate command.
*/ */

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns; namespace Stancl\Tenancy\Database\Concerns;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver; use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver;
use Stancl\Tenancy\Tenancy; use Stancl\Tenancy\Tenancy;
@ -12,7 +13,7 @@ trait InvalidatesResolverCache
{ {
public static function bootInvalidatesResolverCache(): void public static function bootInvalidatesResolverCache(): void
{ {
static::saved(function (Tenant $tenant) { static::saved(function (Tenant&Model $tenant) {
foreach (Tenancy::cachedResolvers() as $resolver) { foreach (Tenancy::cachedResolvers() as $resolver) {
/** @var CachedTenantResolver $resolver */ /** @var CachedTenantResolver $resolver */
$resolver = app($resolver); $resolver = app($resolver);

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Exceptions;
use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException;
class TenantColumnNotWhitelistedException extends TenantCouldNotBeIdentifiedException
{
public function __construct(int|string $tenant_id)
{
$this
->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.');
}
}

View file

@ -13,8 +13,8 @@ class TenantCouldNotBeIdentifiedByIdException extends TenantCouldNotBeIdentified
public function __construct(int|string $tenant_id) public function __construct(int|string $tenant_id)
{ {
$this $this
->tenantCouldNotBeIdentified("by tenant id: $tenant_id") ->tenantCouldNotBeIdentified("by tenant key: $tenant_id")
->title('Tenant could not be identified with that ID') ->title('Tenant could not be identified with that key')
->description('Are you sure the ID is correct and the tenant exists?'); ->description('Are you sure the key is correct and the tenant exists?');
} }
} }

View file

@ -11,7 +11,7 @@ class TenantCouldNotBeIdentifiedByPathException extends TenantCouldNotBeIdentifi
public function __construct(int|string $tenant_id) public function __construct(int|string $tenant_id)
{ {
$this $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') ->title('Tenant could not be identified on this path')
->description('Did you forget to create a tenant for this path?'); ->description('Did you forget to create a tenant for this path?');
} }

View file

@ -39,7 +39,7 @@ class InitializeTenancyByPath extends IdentificationMiddleware implements Usable
// Only initialize tenancy if the route has the tenant parameter. // Only initialize tenancy if the route has the tenant parameter.
// We don't want to initialize tenancy if the tenant is // 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())) { if (in_array(PathTenantResolver::tenantParameterName(), $route->parameterNames())) {
return $this->initializeTenancy( return $this->initializeTenancy(
$request, $request,

View file

@ -8,6 +8,7 @@ use Closure;
use Illuminate\Routing\Route; use Illuminate\Routing\Route;
use Stancl\Tenancy\Resolvers\PathTenantResolver; use Stancl\Tenancy\Resolvers\PathTenantResolver;
// todo a lot of duplicate logic with PathTenantResolver, ideally remove this class
class PathIdentificationManager class PathIdentificationManager
{ {
public static Closure|null $tenantParameterName = null; public static Closure|null $tenantParameterName = null;

View file

@ -6,7 +6,9 @@ namespace Stancl\Tenancy\Resolvers\Contracts;
use Illuminate\Contracts\Cache\Factory; use Illuminate\Contracts\Cache\Factory;
use Illuminate\Contracts\Cache\Repository; use Illuminate\Contracts\Cache\Repository;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException;
use Stancl\Tenancy\Contracts\TenantResolver; use Stancl\Tenancy\Contracts\TenantResolver;
abstract class CachedTenantResolver implements TenantResolver abstract class CachedTenantResolver implements TenantResolver
@ -19,13 +21,21 @@ abstract class CachedTenantResolver implements TenantResolver
$this->cache = $cache->store(static::cacheStore()); $this->cache = $cache->store(static::cacheStore());
} }
/**
* Resolve a tenant using some value passed from the middleware.
*
* @throws TenantCouldNotBeIdentifiedException
*/
public function resolve(mixed ...$args): Tenant public function resolve(mixed ...$args): Tenant
{ {
if (! static::shouldCache()) { 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)) { if ($tenant = $this->cache->get($key)) {
$this->resolved($tenant, ...$args); $this->resolved($tenant, ...$args);
@ -35,44 +45,51 @@ abstract class CachedTenantResolver implements TenantResolver
$tenant = $this->resolveWithoutCache(...$args); $tenant = $this->resolveWithoutCache(...$args);
$this->cache->put($key, $tenant, static::cacheTTL()); $this->cache->put($key, $tenant, static::cacheTTL());
$this->resolved($tenant, ...$args);
return $tenant; 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()) { if (! static::shouldCache()) {
return; return;
} }
foreach ($this->getArgsForTenant($tenant) as $args) { foreach ($this->getPossibleCacheKeys($tenant) as $key) {
$this->cache->forget($this->getCacheKey(...$args)); $this->cache->forget($key);
} }
} }
public function getCacheKey(mixed ...$args): string public function formatCacheKey(mixed ...$args): string
{ {
return '_tenancy_resolver:' . static::class . ':' . json_encode($args); 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; 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 public function resolved(Tenant $tenant, mixed ...$args): void
{ {
} }
/** abstract public function getPossibleCacheKeys(Tenant&Model $tenant): array;
* 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;
public static function shouldCache(): bool 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 public static function cacheTTL(): int

View file

@ -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) { if ($tenant instanceof SingleDomainTenant) {
/** @var SingleDomainTenant&Model $tenant */ $domains = array_filter([
return [ $tenant->getOriginal('domain'), // Previous domain
[$tenant->getOriginal('domain')], // Previous domain $tenant->domain, // Current 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 */ return array_map(fn (string $domain) => $this->formatCacheKey($domain), $domains);
$tenant->unsetRelation('domains');
return $tenant->domains->map(function (Domain&Model $domain) {
return [$domain->domain];
})->toArray();
} }
} }

View file

@ -4,10 +4,11 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Resolvers; namespace Stancl\Tenancy\Resolvers;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Routing\Route; use Illuminate\Routing\Route;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Exceptions\TenantColumnNotWhitelistedException;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByPathException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByPathException;
use Stancl\Tenancy\PathIdentificationManager;
class PathTenantResolver extends Contracts\CachedTenantResolver class PathTenantResolver extends Contracts\CachedTenantResolver
{ {
@ -16,26 +17,30 @@ class PathTenantResolver extends Contracts\CachedTenantResolver
/** @var Route $route */ /** @var Route $route */
$route = $args[0]; $route = $args[0];
/** @var string $id */ /** @var string $key */
$id = $route->parameter(static::tenantParameterName()); $key = $route->parameter(static::tenantParameterName());
$column = $route->bindingFieldFor(static::tenantParameterName()) ?? static::tenantModelColumn();
if ($id) { if ($column !== static::tenantModelColumn() && ! in_array($column, static::allowedExtraModelColumns())) {
// Forget the tenant parameter so that we don't have to accept it in route action methods throw new TenantColumnNotWhitelistedException($key);
$route->forgetParameter(static::tenantParameterName()); }
if ($tenant = tenancy()->find($id)) { if ($key) {
if ($tenant = tenancy()->find($key, $column)) {
/** @var Tenant $tenant */
return $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 [ $columns = array_unique(array_merge(static::allowedExtraModelColumns(), [static::tenantModelColumn()]));
[$tenant->getTenantKey()], $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 public function resolved(Tenant $tenant, mixed ...$args): void
@ -43,21 +48,22 @@ class PathTenantResolver extends Contracts\CachedTenantResolver
/** @var Route $route */ /** @var Route $route */
$route = $args[0]; $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 // When called in resolve(), $args contains the route
// the logic shouldn't have to be coupled to where it's being called from // 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 return parent::formatCacheKey($column, $value);
// $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);
} }
public static function tenantParameterName(): string 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() . '.'; 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') ?? [];
}
} }

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Resolvers; namespace Stancl\Tenancy\Resolvers;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByRequestDataException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByRequestDataException;
@ -26,10 +27,10 @@ class RequestDataTenantResolver extends Contracts\CachedTenantResolver
throw new TenantCouldNotBeIdentifiedByRequestDataException($payload); throw new TenantCouldNotBeIdentifiedByRequestDataException($payload);
} }
public function getArgsForTenant(Tenant $tenant): array public function getPossibleCacheKeys(Tenant&Model $tenant): array
{ {
return [ return [
[$tenant->getTenantKey()], $this->formatCacheKey($tenant->getTenantKey()),
]; ];
} }
} }

View file

@ -137,10 +137,10 @@ class Tenancy
/** /**
* Try to find a tenant using an ID. * 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 */ /** @var (Tenant&Model)|null $tenant */
$tenant = static::model()->where(static::model()->getTenantKeyName(), $id)->first(); $tenant = static::model()->firstWhere($column ?? static::model()->getTenantKeyName(), $id);
return $tenant; return $tenant;
} }

View file

@ -2,12 +2,15 @@
declare(strict_types=1); declare(strict_types=1);
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Routing\Route; use Illuminate\Routing\Route;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use Stancl\Tenancy\Resolvers\PathTenantResolver; use Stancl\Tenancy\Resolvers\PathTenantResolver;
use Stancl\Tenancy\Resolvers\DomainTenantResolver; use Stancl\Tenancy\Resolvers\DomainTenantResolver;
use Illuminate\Support\Facades\Route as RouteFacade; use Illuminate\Support\Facades\Route as RouteFacade;
use Illuminate\Support\Facades\Schema;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\PathIdentificationManager; use Stancl\Tenancy\PathIdentificationManager;
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver; 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($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([ })->with([
DomainTenantResolver::class, DomainTenantResolver::class,
PathTenantResolver::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() { test('PathTenantResolver forgets the tenant route parameter when the tenant is resolved from cache', function() {
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.cache' => true]); config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.cache' => true]);
DB::enableQueryLog();
Tenant::create(['id' => 'foo']); 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 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. * Return the argument for the resolver tenant key, or a route instance with the tenant parameter.
* *

View file

@ -16,5 +16,12 @@ use Stancl\Tenancy\Database\Models;
*/ */
class Tenant extends Models\Tenant implements TenantWithDatabase class Tenant extends Models\Tenant implements TenantWithDatabase
{ {
public static array $extraCustomColumns = [];
use HasDatabase, HasDomains, HasPending, MaintenanceMode; use HasDatabase, HasDomains, HasPending, MaintenanceMode;
public static function getCustomColumns(): array
{
return array_merge(parent::getCustomColumns(), static::$extraCustomColumns);
}
} }

View file

@ -3,8 +3,11 @@
declare(strict_types=1); declare(strict_types=1);
use Illuminate\Contracts\Http\Kernel; use Illuminate\Contracts\Http\Kernel;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
use Stancl\Tenancy\Exceptions\RouteIsMissingTenantParameterException; use Stancl\Tenancy\Exceptions\RouteIsMissingTenantParameterException;
use Stancl\Tenancy\Exceptions\TenantColumnNotWhitelistedException;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByPathException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByPathException;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Resolvers\PathTenantResolver; use Stancl\Tenancy\Resolvers\PathTenantResolver;
@ -15,6 +18,7 @@ beforeEach(function () {
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => 'tenant']); config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => 'tenant']);
InitializeTenancyByPath::$onFail = null; InitializeTenancyByPath::$onFail = null;
Tenant::$extraCustomColumns = [];
Route::group([ Route::group([
'prefix' => '/{tenant}', '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(); 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
});