From ecc3374293ae7081f1ef67d4a8bf06aaf841456b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Fri, 8 Aug 2025 00:54:01 +0200 Subject: [PATCH] [4.x] Support `database` cache store tenancy (#1290) (resolve #852) * Initial implementation (lukinovec) * Make sure DatabaseCacheBootstrapper runs after DatabaseTenancyBootstrapper, misc wip changes * Fix withTenantDatabases() * Add failing test (GlobalCacheTest) * Configure globalCache's DB stores to use central connection instead of default connection every time it's reinstantiated * Make GlobalCache facade not cached. Even though it wasn't causing issues in our existing tests, it likely was flaky, and making it not $cached makes it now consistent with global_cache() - always getting a new CacheManager from the globalCache container binding * Add database connection assertions in GlobalCacheTest * Run all cached resolver/global cache tests with DatabaseCacheBootstrapper * Reset adjustCacheManagerUsing in revert() and TestCase * Reset static $stores property * Finalize GlobalCache-related changes * tests: remove pointless cache TTLs * Refactor DatabaseCacheBootstrapper * Refactor tests Co-authored-by: lukinovec --- CONTRIBUTING.md | 6 + Dockerfile | 12 ++ composer.json | 29 ++- docker-compose.yml | 7 +- .../DatabaseCacheBootstrapper.php | 123 +++++++++++ src/Facades/GlobalCache.php | 3 + .../Contracts/CachedTenantResolver.php | 3 + src/TenancyServiceProvider.php | 27 ++- .../CacheTagsBootstrapperTest.php | 12 +- tests/CachedTenantResolverTest.php | 22 +- tests/DatabaseCacheBootstrapperTest.php | 193 ++++++++++++++++++ tests/GlobalCacheTest.php | 160 +++++++++++++-- tests/Pest.php | 35 +++- tests/TestCase.php | 6 + 14 files changed, 600 insertions(+), 38 deletions(-) create mode 100644 src/Bootstrappers/DatabaseCacheBootstrapper.php create mode 100644 tests/DatabaseCacheBootstrapperTest.php diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d256f42..ee451d20 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,3 +43,9 @@ If you need to rebuild the container for any reason (e.g. a change in `Dockerfil ## PHPStan Use `composer phpstan` to run our phpstan suite. + +## PhpStorm + +Create `.env` with `PROJECT_PATH=/full/path/to/this/directory`. Configure a Docker-based interpreter for tests (with exec, not run). + +If you want to use XDebug, use `composer docker-rebuild-with-xdebug`. diff --git a/Dockerfile b/Dockerfile index 0d51244e..fb1620cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,4 +30,16 @@ RUN echo "apc.enable_cli=1" >> "$PHP_INI_DIR/php.ini" # Only used on GHA COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer +# Conditionally install and configure Xdebug (last step for faster rebuilds) +ARG XDEBUG_ENABLED=false +RUN if [ "$XDEBUG_ENABLED" = "true" ]; then \ + pecl install xdebug && docker-php-ext-enable xdebug && \ + echo "xdebug.mode=debug" >> "$PHP_INI_DIR/conf.d/docker-php-ext-xdebug.ini" && \ + echo "xdebug.start_with_request=yes" >> "$PHP_INI_DIR/conf.d/docker-php-ext-xdebug.ini" && \ + echo "xdebug.client_host=host.docker.internal" >> "$PHP_INI_DIR/conf.d/docker-php-ext-xdebug.ini" && \ + echo "xdebug.client_port=9003" >> "$PHP_INI_DIR/conf.d/docker-php-ext-xdebug.ini" && \ + echo "xdebug.discover_client_host=true" >> "$PHP_INI_DIR/conf.d/docker-php-ext-xdebug.ini" && \ + echo "xdebug.log=/var/log/xdebug.log" >> "$PHP_INI_DIR/conf.d/docker-php-ext-xdebug.ini"; \ +fi + WORKDIR /var/www/html diff --git a/composer.json b/composer.json index 2ee61fb0..2eab8837 100644 --- a/composer.json +++ b/composer.json @@ -63,7 +63,14 @@ "docker-up": "docker compose up -d", "docker-down": "docker compose down", "docker-restart": "docker compose down && docker compose up -d", - "docker-rebuild": "PHP_VERSION=8.4 docker compose up -d --no-deps --build", + "docker-rebuild": [ + "Composer\\Config::disableProcessTimeout", + "PHP_VERSION=8.4 docker compose up -d --no-deps --build" + ], + "docker-rebuild-with-xdebug": [ + "Composer\\Config::disableProcessTimeout", + "PHP_VERSION=8.4 XDEBUG_ENABLED=true docker compose up -d --no-deps --build" + ], "docker-m1": "ln -s docker-compose-m1.override.yml docker-compose.override.yml", "testbench-unlink": "rm ./vendor/orchestra/testbench-core/laravel/vendor", "testbench-link": "ln -s /var/www/html/vendor ./vendor/orchestra/testbench-core/laravel/vendor", @@ -72,10 +79,22 @@ "phpstan": "vendor/bin/phpstan --memory-limit=256M", "phpstan-pro": "vendor/bin/phpstan --memory-limit=256M --pro", "cs": "PHP_CS_FIXER_IGNORE_ENV=1 php-cs-fixer fix --config=.php-cs-fixer.php", - "test": "./test --no-coverage", - "test-full": "./test", - "act": "act -j tests --matrix 'laravel:^11.0'", - "act-input": "act -j tests --matrix 'laravel:^11.0' --input" + "test": [ + "Composer\\Config::disableProcessTimeout", + "./test --no-coverage" + ], + "test-full": [ + "Composer\\Config::disableProcessTimeout", + "./test" + ], + "act": [ + "Composer\\Config::disableProcessTimeout", + "act -j tests --matrix 'laravel:^11.0'" + ], + "act-input": [ + "Composer\\Config::disableProcessTimeout", + "act -j tests --matrix 'laravel:^11.0' --input" + ] }, "minimum-stability": "dev", "prefer-stable": true, diff --git a/docker-compose.yml b/docker-compose.yml index 9d5eb6c8..2d7a6e9f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,8 @@ services: test: build: context: . + args: + XDEBUG_ENABLED: ${XDEBUG_ENABLED:-false} depends_on: mysql: condition: service_healthy @@ -18,7 +20,8 @@ services: dynamodb: condition: service_healthy volumes: - - .:/var/www/html:cached + - .:${PROJECT_PATH:-$PWD}:cached + working_dir: ${PROJECT_PATH:-$PWD} environment: DOCKER: 1 DB_PASSWORD: password @@ -30,6 +33,8 @@ services: TENANCY_TEST_SQLSRV_HOST: mssql TENANCY_TEST_SQLSRV_USERNAME: sa TENANCY_TEST_SQLSRV_PASSWORD: P@ssword + extra_hosts: + - "host.docker.internal:host-gateway" stdin_open: true tty: true mysql: diff --git a/src/Bootstrappers/DatabaseCacheBootstrapper.php b/src/Bootstrappers/DatabaseCacheBootstrapper.php new file mode 100644 index 00000000..ae547471 --- /dev/null +++ b/src/Bootstrappers/DatabaseCacheBootstrapper.php @@ -0,0 +1,123 @@ +scopedStoreNames(); + + foreach ($stores as $storeName) { + $this->originalConnections[$storeName] = $this->config->get("cache.stores.{$storeName}.connection"); + $this->originalLockConnections[$storeName] = $this->config->get("cache.stores.{$storeName}.lock_connection"); + + $this->config->set("cache.stores.{$storeName}.connection", 'tenant'); + $this->config->set("cache.stores.{$storeName}.lock_connection", 'tenant'); + + $this->cache->purge($storeName); + } + + if (static::$adjustGlobalCacheManager) { + // Preferably we'd try to respect the original value of this static property -- store it in a variable, + // pull it into the closure, and execute it there. But such a naive approach would lead to existing callbacks + // *from here* being executed repeatedly in a loop on reinitialization. For that reason we do not do that + // (this is our only use of $adjustCacheManagerUsing anyway) but ideally at some point we'd have a better solution. + $originalConnections = array_combine($stores, array_map(fn (string $storeName) => [ + 'connection' => $this->originalConnections[$storeName] ?? config('tenancy.database.central_connection'), + 'lockConnection' => $this->originalLockConnections[$storeName] ?? config('tenancy.database.central_connection'), + ], $stores)); + + TenancyServiceProvider::$adjustCacheManagerUsing = static function (CacheManager $manager) use ($originalConnections) { + foreach ($originalConnections as $storeName => $connections) { + /** @var DatabaseStore $store */ + $store = $manager->store($storeName)->getStore(); + + $store->setConnection(DB::connection($connections['connection'])); + $store->setLockConnection(DB::connection($connections['lockConnection'])); + } + }; + } + } + + public function revert(): void + { + foreach ($this->originalConnections as $storeName => $originalConnection) { + $this->config->set("cache.stores.{$storeName}.connection", $originalConnection); + $this->config->set("cache.stores.{$storeName}.lock_connection", $this->originalLockConnections[$storeName]); + + $this->cache->purge($storeName); + } + + TenancyServiceProvider::$adjustCacheManagerUsing = null; + } + + protected function scopedStoreNames(): array + { + return array_filter( + static::$stores ?? array_keys($this->config->get('cache.stores', [])), + function ($storeName) { + $store = $this->config->get("cache.stores.{$storeName}"); + + if (! $store) return false; + if (! isset($store['driver'])) return false; + + return $store['driver'] === 'database'; + } + ); + } +} diff --git a/src/Facades/GlobalCache.php b/src/Facades/GlobalCache.php index b8b5ce99..d1a182aa 100644 --- a/src/Facades/GlobalCache.php +++ b/src/Facades/GlobalCache.php @@ -8,6 +8,9 @@ use Illuminate\Support\Facades\Cache; class GlobalCache extends Cache { + /** Make sure this works identically to global_cache() */ + protected static $cached = false; + protected static function getFacadeAccessor() { return 'globalCache'; diff --git a/src/Resolvers/Contracts/CachedTenantResolver.php b/src/Resolvers/Contracts/CachedTenantResolver.php index 16966149..017f4b04 100644 --- a/src/Resolvers/Contracts/CachedTenantResolver.php +++ b/src/Resolvers/Contracts/CachedTenantResolver.php @@ -17,6 +17,9 @@ abstract class CachedTenantResolver implements TenantResolver public function __construct(Application $app) { + // globalCache should generally not be injected, however in this case + // the class is always created from scratch when calling invalidateCache() + // meaning the global cache stores are also resolved from scratch. $this->cache = $app->make('globalCache')->store(static::cacheStore()); } diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index 22a81624..557306b2 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -23,6 +23,9 @@ class TenancyServiceProvider extends ServiceProvider public static bool $registerForgetTenantParameterListener = true; public static bool $migrateFreshOverride = true; + /** @internal */ + public static Closure|null $adjustCacheManagerUsing = null; + /* Register services. */ public function register(): void { @@ -81,7 +84,29 @@ class TenancyServiceProvider extends ServiceProvider }); $this->app->bind('globalCache', function ($app) { - return new CacheManager($app); + // We create a separate CacheManager to be used for "global" cache -- cache that + // is always central, regardless of the current context. + // + // Importantly, we use a regular binding here, not a singleton. Thanks to that, + // any time we resolve this cache manager, we get a *fresh* instance -- an instance + // that was not affected by any scoping logic. + // + // This works great for cache stores that are *directly* scoped, like Redis or + // any other tagged or prefixed stores, but it doesn't work for the database driver. + // + // When we use the DatabaseTenancyBootstrapper, it changes the default connection, + // and therefore the connection of the database store that will be created when + // this new CacheManager is instantiated again. + // + // For that reason, we also adjust the relevant stores on this new CacheManager + // using the callback below. It is set by DatabaseCacheBootstrapper. + $manager = new CacheManager($app); + + if (static::$adjustCacheManagerUsing !== null) { + (static::$adjustCacheManagerUsing)($manager); + } + + return $manager; }); } diff --git a/tests/Bootstrappers/CacheTagsBootstrapperTest.php b/tests/Bootstrappers/CacheTagsBootstrapperTest.php index 660be1a7..f07a0f3f 100644 --- a/tests/Bootstrappers/CacheTagsBootstrapperTest.php +++ b/tests/Bootstrappers/CacheTagsBootstrapperTest.php @@ -56,7 +56,7 @@ test('tags separate cache properly', function () { $tenant1 = Tenant::create(); tenancy()->initialize($tenant1); - cache()->put('foo', 'bar', 1); + cache()->put('foo', 'bar'); expect(cache()->get('foo'))->toBe('bar'); $tenant2 = Tenant::create(); @@ -64,7 +64,7 @@ test('tags separate cache properly', function () { expect(cache('foo'))->not()->toBe('bar'); - cache()->put('foo', 'xyz', 1); + cache()->put('foo', 'xyz'); expect(cache()->get('foo'))->toBe('xyz'); }); @@ -72,7 +72,7 @@ test('invoking the cache helper works', function () { $tenant1 = Tenant::create(); tenancy()->initialize($tenant1); - cache(['foo' => 'bar'], 1); + cache(['foo' => 'bar']); expect(cache('foo'))->toBe('bar'); $tenant2 = Tenant::create(); @@ -80,7 +80,7 @@ test('invoking the cache helper works', function () { expect(cache('foo'))->not()->toBe('bar'); - cache(['foo' => 'xyz'], 1); + cache(['foo' => 'xyz']); expect(cache('foo'))->toBe('xyz'); }); @@ -88,7 +88,7 @@ test('cache is persisted', function () { $tenant1 = Tenant::create(); tenancy()->initialize($tenant1); - cache(['foo' => 'bar'], 10); + cache(['foo' => 'bar']); expect(cache('foo'))->toBe('bar'); tenancy()->end(); @@ -102,7 +102,7 @@ test('cache is persisted when reidentification is used', function () { $tenant2 = Tenant::create(); tenancy()->initialize($tenant1); - cache(['foo' => 'bar'], 10); + cache(['foo' => 'bar']); expect(cache('foo'))->toBe('bar'); tenancy()->initialize($tenant2); diff --git a/tests/CachedTenantResolverTest.php b/tests/CachedTenantResolverTest.php index d33a85e5..920c95a1 100644 --- a/tests/CachedTenantResolverTest.php +++ b/tests/CachedTenantResolverTest.php @@ -14,6 +14,8 @@ use Illuminate\Support\Facades\Route as RouteFacade; use Illuminate\Support\Facades\Schema; use Stancl\Tenancy\Bootstrappers\CacheTagsBootstrapper; use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper; +use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper; +use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException; use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Events\TenancyInitialized; @@ -23,6 +25,8 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Resolvers\RequestDataTenantResolver; use function Stancl\Tenancy\Tests\pest; +use function Stancl\Tenancy\Tests\withCacheTables; +use function Stancl\Tenancy\Tests\withTenantDatabases; beforeEach($cleanup = function () { Tenant::$extraCustomColumns = []; @@ -112,11 +116,19 @@ test('cache is invalidated when the tenant is updated', function (string $resolv // Only testing update here - presumably if this works, deletes (and other things we test here) // will work as well. The main unique thing about this test is that it makes the change from // *within* the tenant context. -test('cache is invalidated when tenant is updated from within the tenant context', function (string $cacheBootstrapper) { - config(['tenancy.bootstrappers' => [$cacheBootstrapper]]); +test('cache is invalidated when tenant is updated from within the tenant context', function (string $cacheStore, array $bootstrappers) { + config([ + 'cache.default' => $cacheStore, + 'tenancy.bootstrappers' => $bootstrappers, + ]); Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class); + if ($cacheStore === 'database') { + withCacheTables(); + withTenantDatabases(); + } + $resolver = PathTenantResolver::class; $tenant = Tenant::create([$tenantModelColumn = tenantModelColumn(true) => 'acme']); @@ -150,9 +162,9 @@ test('cache is invalidated when tenant is updated from within the tenant context expect(DB::getQueryLog())->not()->toBeEmpty(); // Cache was invalidated, so the tenant was retrieved from the DB })->with([ - // todo@samuel test this with the database cache bootstrapper too? - CacheTenancyBootstrapper::class, - CacheTagsBootstrapper::class, + ['redis', [CacheTenancyBootstrapper::class]], + ['redis', [CacheTagsBootstrapper::class]], + ['database', [DatabaseTenancyBootstrapper::class, DatabaseCacheBootstrapper::class]], ]); test('cache is invalidated when the tenant is deleted', function (string $resolver, bool $configureTenantModelColumn) { diff --git a/tests/DatabaseCacheBootstrapperTest.php b/tests/DatabaseCacheBootstrapperTest.php new file mode 100644 index 00000000..87ca91ca --- /dev/null +++ b/tests/DatabaseCacheBootstrapperTest.php @@ -0,0 +1,193 @@ + 'central', // Explicitly set cache DB connection name in config + 'cache.stores.database.lock_connection' => 'central', // Also set lock connection name + 'cache.default' => 'database', + 'tenancy.bootstrappers' => [ + DatabaseTenancyBootstrapper::class, + DatabaseCacheBootstrapper::class, // Used instead of CacheTenancyBootstrapper + ], + ]); +}); + +afterEach(function () { + DatabaseCacheBootstrapper::$stores = null; +}); + +test('DatabaseCacheBootstrapper switches the database cache store connections correctly', function () { + expect(config('cache.stores.database.connection'))->toBe('central'); + expect(config('cache.stores.database.lock_connection'))->toBe('central'); + expect(Cache::store()->getConnection()->getName())->toBe('central'); + expect(Cache::lock('foo')->getConnectionName())->toBe('central'); + + tenancy()->initialize(Tenant::create()); + + expect(config('cache.stores.database.connection'))->toBe('tenant'); + expect(config('cache.stores.database.lock_connection'))->toBe('tenant'); + expect(Cache::store()->getConnection()->getName())->toBe('tenant'); + expect(Cache::lock('foo')->getConnectionName())->toBe('tenant'); + + tenancy()->end(); + + expect(config('cache.stores.database.connection'))->toBe('central'); + expect(config('cache.stores.database.lock_connection'))->toBe('central'); + expect(Cache::store()->getConnection()->getName())->toBe('central'); + expect(Cache::lock('foo')->getConnectionName())->toBe('central'); +}); + +test('cache is separated correctly when using DatabaseCacheBootstrapper', function() { + // We need the prefix later for lower-level assertions. Let's store it + // once now and reuse this variable rather than re-fetching it to make + // it clear that the scoping does NOT come from a prefix change. + + $cachePrefix = config('cache.prefix'); + $getCacheUsingDbQuery = fn (string $cacheKey) => + DB::selectOne("SELECT * FROM `cache` WHERE `key` = '{$cachePrefix}{$cacheKey}'")?->value; + + $tenant = Tenant::create(); + $tenant2 = Tenant::create(); + + // Write to cache in central context + cache()->set('foo', 'central'); + expect(Cache::get('foo'))->toBe('central'); + // The value retrieved by the DB query is formatted like "s:7:"central";". + // We use toContain() because of this formatting instead of just toBe(). + expect($getCacheUsingDbQuery('foo'))->toContain('central'); + + tenancy()->initialize($tenant); + + // Central cache doesn't leak to tenant context + expect(Cache::has('foo'))->toBeFalse(); + expect($getCacheUsingDbQuery('foo'))->toBeNull(); + + cache()->set('foo', 'bar'); + expect(Cache::get('foo'))->toBe('bar'); + expect($getCacheUsingDbQuery('foo'))->toContain('bar'); + + tenancy()->initialize($tenant2); + + // Assert one tenant's cache doesn't leak to another tenant + expect(Cache::has('foo'))->toBeFalse(); + expect($getCacheUsingDbQuery('foo'))->toBeNull(); + + cache()->set('foo', 'xyz'); + expect(Cache::get('foo'))->toBe('xyz'); + expect($getCacheUsingDbQuery('foo'))->toContain('xyz'); + + tenancy()->initialize($tenant); + + // Assert cache didn't leak to the original tenant + expect(Cache::get('foo'))->toBe('bar'); + expect($getCacheUsingDbQuery('foo'))->toContain('bar'); + + tenancy()->end(); + + // Assert central 'foo' cache is still the same ('central') + expect(Cache::get('foo'))->toBe('central'); + expect($getCacheUsingDbQuery('foo'))->toContain('central'); +}); + +test('DatabaseCacheBootstrapper auto-detects all database driver stores by default', function() { + config([ + 'cache.stores.database' => [ + 'driver' => 'database', + 'connection' => 'central', + 'table' => 'cache', + ], + 'cache.stores.sessions' => [ + 'driver' => 'database', + 'connection' => 'central', + 'table' => 'sessions_cache', + ], + 'cache.stores.redis' => [ + 'driver' => 'redis', + 'connection' => 'default', + ], + 'cache.stores.file' => [ + 'driver' => 'file', + 'path' => '/foo/bar', + ], + ]); + + // Here, we're using auto-detection (default behavior) + expect(config('cache.stores.database.connection'))->toBe('central'); + expect(config('cache.stores.sessions.connection'))->toBe('central'); + expect(config('cache.stores.redis.connection'))->toBe('default'); + expect(config('cache.stores.file.path'))->toBe('/foo/bar'); + + tenancy()->initialize(Tenant::create()); + + // Using auto-detection (default behavior), + // all database driver stores should be configured, + // and stores with non-database drivers are ignored. + expect(config('cache.stores.database.connection'))->toBe('tenant'); + expect(config('cache.stores.sessions.connection'))->toBe('tenant'); + expect(config('cache.stores.redis.connection'))->toBe('default'); // unchanged + expect(config('cache.stores.file.path'))->toBe('/foo/bar'); // unchanged + + tenancy()->end(); + + // All database stores should be reverted, others unchanged + expect(config('cache.stores.database.connection'))->toBe('central'); + expect(config('cache.stores.sessions.connection'))->toBe('central'); + expect(config('cache.stores.redis.connection'))->toBe('default'); + expect(config('cache.stores.file.path'))->toBe('/foo/bar'); +}); + +test('manual $stores configuration takes precedence over auto-detection', function() { + // Configure multiple database stores + config([ + 'cache.stores.sessions' => [ + 'driver' => 'database', + 'connection' => 'central', + 'table' => 'sessions_cache', + ], + 'cache.stores.redis' => [ + 'driver' => 'redis', + 'connection' => 'default', + ], + ]); + + // Specific store overrides (including non-database stores) + DatabaseCacheBootstrapper::$stores = ['sessions', 'redis']; // Note: excludes 'database' + + expect(config('cache.stores.database.connection'))->toBe('central'); + expect(config('cache.stores.sessions.connection'))->toBe('central'); + expect(config('cache.stores.redis.connection'))->toBe('default'); + + tenancy()->initialize(Tenant::create()); + + // Manual config takes precedence: only 'sessions' is configured + // - redis filtered out by driver check + // - database store not included in $stores + expect(config('cache.stores.database.connection'))->toBe('central'); // Excluded in manual config + expect(config('cache.stores.sessions.connection'))->toBe('tenant'); // Included and is database driver + expect(config('cache.stores.redis.connection'))->toBe('default'); // Included but filtered out (not database driver) + + tenancy()->end(); + + // Only the manually configured stores' config will be reverted + expect(config('cache.stores.database.connection'))->toBe('central'); + expect(config('cache.stores.sessions.connection'))->toBe('central'); + expect(config('cache.stores.redis.connection'))->toBe('default'); +}); diff --git a/tests/GlobalCacheTest.php b/tests/GlobalCacheTest.php index 72ba5ebd..016ad2a4 100644 --- a/tests/GlobalCacheTest.php +++ b/tests/GlobalCacheTest.php @@ -11,6 +11,11 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Bootstrappers\CacheTagsBootstrapper; use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper; +use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper; +use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; + +use function Stancl\Tenancy\Tests\withCacheTables; +use function Stancl\Tenancy\Tests\withTenantDatabases; beforeEach(function () { config([ @@ -20,26 +25,40 @@ beforeEach(function () { Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class); + + withCacheTables(); }); -test('global cache manager stores data in global cache', function (string $bootstrapper) { - config(['tenancy.bootstrappers' => [$bootstrapper]]); +test('global cache manager stores data in global cache', function (string $store, array $bootstrappers) { + config([ + 'cache.default' => $store, + 'tenancy.bootstrappers' => $bootstrappers, + ]); + + if ($store === 'database') withTenantDatabases(true); expect(cache('foo'))->toBe(null); - GlobalCache::put(['foo' => 'bar'], 1); + GlobalCache::put('foo', 'bar'); expect(GlobalCache::get('foo'))->toBe('bar'); $tenant1 = Tenant::create(); tenancy()->initialize($tenant1); expect(GlobalCache::get('foo'))->toBe('bar'); - GlobalCache::put(['abc' => 'xyz'], 1); - cache(['def' => 'ghi'], 10); + GlobalCache::put('abc', 'xyz'); + cache(['def' => 'ghi']); expect(cache('def'))->toBe('ghi'); - // different stores, same underlying connection. the prefix is set ON THE STORE - expect(cache()->store()->getStore() === GlobalCache::store()->getStore())->toBeFalse(); - expect(cache()->store()->getStore()->connection() === GlobalCache::store()->getStore()->connection())->toBeTrue(); + // different stores + expect(cache()->store()->getStore() !== GlobalCache::store()->getStore())->toBeTrue(); + if ($store === 'redis') { + // same underlying connection. the prefix is set ON THE STORE + expect(cache()->store()->getStore()->connection() === GlobalCache::store()->getStore()->connection())->toBeTrue(); + } else { + // different connections + expect(cache()->store()->getStore()->getConnection()->getName())->toBe('tenant'); + expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central'); + } tenancy()->end(); expect(GlobalCache::get('abc'))->toBe('xyz'); @@ -51,25 +70,129 @@ test('global cache manager stores data in global cache', function (string $boots expect(GlobalCache::get('abc'))->toBe('xyz'); expect(GlobalCache::get('foo'))->toBe('bar'); expect(cache('def'))->toBe(null); - cache(['def' => 'xxx'], 1); + cache(['def' => 'xxx']); expect(cache('def'))->toBe('xxx'); tenancy()->initialize($tenant1); expect(cache('def'))->toBe('ghi'); })->with([ - CacheTagsBootstrapper::class, - CacheTenancyBootstrapper::class, + ['redis', [CacheTagsBootstrapper::class]], + ['redis', [CacheTenancyBootstrapper::class]], + ['database', [DatabaseTenancyBootstrapper::class, DatabaseCacheBootstrapper::class]], ]); -test('the global_cache helper supports the same syntax as the cache helper', function (string $bootstrapper) { - config(['tenancy.bootstrappers' => [$bootstrapper]]); +test('global cache facade is not persistent', function () { + $oldId = spl_object_id(GlobalCache::getFacadeRoot()); + + $_ = new class {}; + + expect(spl_object_id(GlobalCache::getFacadeRoot()))->not()->toBe($oldId); +}); + +test('global cache is always central', function (string $store, array $bootstrappers, string $initialCentralCall) { + config([ + 'cache.default' => $store, + 'tenancy.bootstrappers' => $bootstrappers, + ]); + + if ($store === 'database') { + withTenantDatabases(true); + } + + // This tells us which "accessor" for the global cache should be instantiated first, before we go + // into the tenant context. We make sure to not touch the other one here. This tests that whether + // a particular accessor is used "early" makes no difference in the later behavior. + if ($initialCentralCall === 'helper') { + if ($store === 'database') expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central'); + global_cache()->put('central-helper', true); + } else if ($initialCentralCall === 'facade') { + if ($store === 'database') expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central'); + GlobalCache::put('central-facade', true); + } else if ($initialCentralCall === 'both') { + if ($store === 'database') expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central'); + global_cache()->put('central-helper', true); + if ($store === 'database') expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central'); + GlobalCache::put('central-facade', true); + } $tenant = Tenant::create(); $tenant->enter(); - // different stores, same underlying connection. the prefix is set ON THE STORE - expect(cache()->store()->getStore() === global_cache()->store()->getStore())->toBeFalse(); - expect(cache()->store()->getStore()->connection() === global_cache()->store()->getStore()->connection())->toBeTrue(); + // Here we use both the helper and the facade to ensure the value is accessible via either one + if ($initialCentralCall === 'helper') { + if ($store === 'database') expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central'); + if ($store === 'database') expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central'); + expect(global_cache('central-helper'))->toBe(true); + expect(GlobalCache::get('central-helper'))->toBe(true); + } else if ($initialCentralCall === 'facade') { + if ($store === 'database') expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central'); + if ($store === 'database') expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central'); + expect(global_cache('central-facade'))->toBe(true); + expect(GlobalCache::get('central-facade'))->toBe(true); + } else if ($initialCentralCall === 'both') { + if ($store === 'database') expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central'); + if ($store === 'database') expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central'); + expect(global_cache('central-helper'))->toBe(true); + expect(GlobalCache::get('central-helper'))->toBe(true); + expect(global_cache('central-facade'))->toBe(true); + expect(GlobalCache::get('central-facade'))->toBe(true); + } + + global_cache()->put('tenant-helper', true); + GlobalCache::put('tenant-facade', true); + + tenancy()->end(); + + if ($store === 'database') expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central'); + if ($store === 'database') expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central'); + + expect(global_cache('tenant-helper'))->toBe(true); + expect(GlobalCache::get('tenant-helper'))->toBe(true); + expect(global_cache('tenant-facade'))->toBe(true); + expect(GlobalCache::get('tenant-facade'))->toBe(true); + + if ($initialCentralCall === 'helper') { + expect(GlobalCache::get('central-helper'))->toBe(true); + } else if ($initialCentralCall === 'facade') { + expect(global_cache('central-facade'))->toBe(true); + } else if ($initialCentralCall === 'both') { + expect(global_cache('central-helper'))->toBe(true); + expect(GlobalCache::get('central-helper'))->toBe(true); + expect(global_cache('central-facade'))->toBe(true); + expect(GlobalCache::get('central-facade'))->toBe(true); + } +})->with([ + ['redis', [CacheTagsBootstrapper::class]], + ['redis', [CacheTenancyBootstrapper::class]], + ['database', [DatabaseTenancyBootstrapper::class, DatabaseCacheBootstrapper::class]], +])->with([ + 'helper', + 'facade', + 'both', + 'none', +]); + +test('the global_cache helper supports the same syntax as the cache helper', function (string $store, array $bootstrappers) { + config([ + 'cache.default' => $store, + 'tenancy.bootstrappers' => $bootstrappers, + ]); + + if ($store === 'database') withTenantDatabases(true); + + $tenant = Tenant::create(); + $tenant->enter(); + + // different stores + expect(cache()->store()->getStore() !== GlobalCache::store()->getStore())->toBeTrue(); + if ($store === 'redis') { + // same underlying connection. the prefix is set ON THE STORE + expect(cache()->store()->getStore()->connection() === global_cache()->store()->getStore()->connection())->toBeTrue(); + } else { + // different connections + expect(cache()->store()->getStore()->getConnection()->getName())->toBe('tenant'); + expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central'); + } expect(cache('foo'))->toBe(null); // tenant cache is empty @@ -81,6 +204,7 @@ test('the global_cache helper supports the same syntax as the cache helper', fun expect(cache('foo'))->toBe(null); // tenant cache is not affected })->with([ - CacheTagsBootstrapper::class, - CacheTenancyBootstrapper::class, + ['redis', [CacheTagsBootstrapper::class]], + ['redis', [CacheTenancyBootstrapper::class]], + ['database', [DatabaseTenancyBootstrapper::class, DatabaseCacheBootstrapper::class]], ]); diff --git a/tests/Pest.php b/tests/Pest.php index cd18d174..a4517f09 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,21 +2,52 @@ namespace Stancl\Tenancy\Tests; +use Illuminate\Database\Schema\Blueprint; use Stancl\Tenancy\Tests\TestCase; use Stancl\JobPipeline\JobPipeline; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Schema; +use Stancl\Tenancy\Events\TenancyEnded; +use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Jobs\CreateDatabase; use Stancl\Tenancy\Events\TenantCreated; +use Stancl\Tenancy\Jobs\MigrateDatabase; +use Stancl\Tenancy\Listeners\BootstrapTenancy; +use Stancl\Tenancy\Listeners\RevertToCentralContext; uses(TestCase::class)->in(__DIR__); -function withTenantDatabases() +function withBootstrapping() { - Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { + Event::listen(TenancyInitialized::class, BootstrapTenancy::class); + Event::listen(TenancyEnded::class, RevertToCentralContext::class); +} + +function withTenantDatabases(bool $migrate = false) +{ + Event::listen(TenantCreated::class, JobPipeline::make($migrate + ? [CreateDatabase::class, MigrateDatabase::class] + : [CreateDatabase::class] + )->send(function (TenantCreated $event) { return $event->tenant; })->toListener()); } +function withCacheTables() +{ + Schema::create('cache', function (Blueprint $table) { + $table->string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration'); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration'); + }); +} + function pest(): TestCase { return \Pest\TestSuite::getInstance()->test; diff --git a/tests/TestCase.php b/tests/TestCase.php index 47af9e7d..d4f2657b 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -24,6 +24,7 @@ use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper; use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use function Stancl\Tenancy\Tests\pest; +use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper; abstract class TestCase extends \Orchestra\Testbench\TestCase { @@ -38,6 +39,10 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase ini_set('memory_limit', '1G'); + TenancyServiceProvider::$registerForgetTenantParameterListener = true; + TenancyServiceProvider::$migrateFreshOverride = true; + TenancyServiceProvider::$adjustCacheManagerUsing = null; + Redis::connection('default')->flushdb(); Redis::connection('cache')->flushdb(); Artisan::call('cache:clear memcached'); // flush memcached @@ -180,6 +185,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase // to manually register bootstrappers as singletons here. $app->singleton(RedisTenancyBootstrapper::class); $app->singleton(CacheTenancyBootstrapper::class); + $app->singleton(DatabaseCacheBootstrapper::class); $app->singleton(BroadcastingConfigBootstrapper::class); $app->singleton(BroadcastChannelPrefixBootstrapper::class); $app->singleton(PostgresRLSBootstrapper::class);