diff --git a/.gitattributes b/.gitattributes index 3736c54d..513bd7da 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,6 +10,7 @@ /.nvim.lua export-ignore /art export-ignore /coverage export-ignore +/CLAUDE.md export-ignore /CONTRIBUTING.md export-ignore /INTERNAL.md export-ignore /SUPPORT.md export-ignore @@ -19,6 +20,7 @@ /Dockerfile export-ignore /doctum export-ignore /phpunit.xml export-ignore +/static_properties.nu export-ignore /t export-ignore /test export-ignore /tests export-ignore diff --git a/.nvim.lua b/.nvim.lua index c9b5d9cb..5e7c5249 100644 --- a/.nvim.lua +++ b/.nvim.lua @@ -1,4 +1,3 @@ -- The tailwindcss LSP doesn't play nice with testbench due to the recursive --- `vendor` symlink in `testbench-core/laravel/vendor`, so we nuke its setup method here. --- This prevents the setup() call in neovim config from starting the client (or doing anything at all). -require('lspconfig').tailwindcss.setup = function () end +-- `vendor` symlink in `testbench-core/laravel/vendor`, so we disable it here. +vim.lsp.enable('tailwindcss', false) diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 1a01e9a8..e0b69e6e 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -57,7 +57,7 @@ class TenancyServiceProvider extends ServiceProvider // Provision API keys, create S3 buckets, anything you want! ])->send(function (Events\TenantCreated $event) { return $event->tenant; - })->shouldBeQueued(false), // `false` by default, but you likely want to make this `true` in production. + })->shouldBeQueued(false), // Listeners\CreateTenantStorage::class, ], @@ -80,7 +80,7 @@ class TenancyServiceProvider extends ServiceProvider Jobs\DeleteDatabase::class, ])->send(function (Events\TenantDeleted $event) { return $event->tenant; - })->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production. + })->shouldBeQueued(false), ], Events\TenantMaintenanceModeEnabled::class => [], diff --git a/assets/config.php b/assets/config.php index 06bceccb..d01cbff7 100644 --- a/assets/config.php +++ b/assets/config.php @@ -170,6 +170,7 @@ return [ Bootstrappers\DatabaseTenancyBootstrapper::class, Bootstrappers\CacheTenancyBootstrapper::class, // Bootstrappers\CacheTagsBootstrapper::class, // Alternative to CacheTenancyBootstrapper + // Bootstrappers\DatabaseCacheBootstrapper::class, // Separates cache by DB rather than by prefix, must run after DatabaseTenancyBootstrapper Bootstrappers\FilesystemTenancyBootstrapper::class, Bootstrappers\QueueTenancyBootstrapper::class, // Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed @@ -178,9 +179,10 @@ return [ Bootstrappers\DatabaseSessionBootstrapper::class, // Configurable bootstrappers + // Bootstrappers\TenantConfigBootstrapper::class, // Bootstrappers\RootUrlBootstrapper::class, // Bootstrappers\UrlGeneratorBootstrapper::class, - // Bootstrappers\MailConfigBootstrapper::class, // Note: Queueing mail requires using QueueTenancyBootstrapper with $forceRefresh set to true + // Bootstrappers\MailConfigBootstrapper::class, // Bootstrappers\BroadcastingConfigBootstrapper::class, // Bootstrappers\BroadcastChannelPrefixBootstrapper::class, @@ -419,7 +421,6 @@ return [ 'features' => [ // Stancl\Tenancy\Features\UserImpersonation::class, // Stancl\Tenancy\Features\TelescopeTags::class, - // Stancl\Tenancy\Features\TenantConfig::class, // Stancl\Tenancy\Features\CrossDomainRedirect::class, // Stancl\Tenancy\Features\ViteBundler::class, // Stancl\Tenancy\Features\DisallowSqliteAttach::class, diff --git a/docker-compose.yml b/docker-compose.yml index 34bd1cc1..70a68019 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,7 @@ services: build: context: . args: + PHP_VERSION: ${PHP_VERSION:-8.4} XDEBUG_ENABLED: ${XDEBUG_ENABLED:-false} depends_on: mysql: diff --git a/src/Actions/CloneRoutesAsTenant.php b/src/Actions/CloneRoutesAsTenant.php index ec60d880..f1cb1450 100644 --- a/src/Actions/CloneRoutesAsTenant.php +++ b/src/Actions/CloneRoutesAsTenant.php @@ -86,6 +86,7 @@ class CloneRoutesAsTenant { protected array $routesToClone = []; protected bool $addTenantParameter = true; + protected bool $tenantParameterBeforePrefix = true; protected string|null $domain = null; protected Closure|null $cloneUsing = null; // The callback should accept Route instance or the route name (string) protected Closure|null $shouldClone = null; @@ -177,6 +178,13 @@ class CloneRoutesAsTenant return $this; } + public function tenantParameterBeforePrefix(bool $tenantParameterBeforePrefix): static + { + $this->tenantParameterBeforePrefix = $tenantParameterBeforePrefix; + + return $this; + } + /** Clone an individual route. */ public function cloneRoute(Route|string $route): static { @@ -226,7 +234,13 @@ class CloneRoutesAsTenant $action->put('middleware', $middleware); if ($this->addTenantParameter) { - $action->put('prefix', $prefix . '/{' . PathTenantResolver::tenantParameterName() . '}'); + $tenantParameter = '{' . PathTenantResolver::tenantParameterName() . '}'; + + $newPrefix = $this->tenantParameterBeforePrefix + ? $tenantParameter . '/' . $prefix + : $prefix . '/' . $tenantParameter; + + $action->put('prefix', $newPrefix); } /** @var Route $newRoute */ diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index 8cc8127b..7f0bce0a 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -32,10 +32,10 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper throw new Exception('The template connection must NOT have URL defined. Specify the connection using individual parts instead of a database URL.'); } - // Better debugging, but breaks cached lookup in prod - if (app()->environment('local') || app()->environment('testing')) { // todo@docs mention this change in v4 upgrade guide https://github.com/archtechx/tenancy/pull/945#issuecomment-1268206149 + // Better debugging, but breaks cached lookup, so we disable this in prod + if (app()->environment('local') || app()->environment('testing')) { $database = $tenant->database()->getName(); - if (! $tenant->database()->manager()->databaseExists($database)) { // todo@samuel does this call correctly use the host connection? + if (! $tenant->database()->manager()->databaseExists($database)) { throw new TenantDatabaseDoesNotExistException($database); } } diff --git a/src/Bootstrappers/TenantConfigBootstrapper.php b/src/Bootstrappers/TenantConfigBootstrapper.php new file mode 100644 index 00000000..98ec2cb0 --- /dev/null +++ b/src/Bootstrappers/TenantConfigBootstrapper.php @@ -0,0 +1,54 @@ + */ + public static array $storageToConfigMap = [ + // 'paypal_api_key' => 'services.paypal.api_key', + ]; + + public function __construct( + protected Repository $config, + ) {} + + public function bootstrap(Tenant $tenant): void + { + foreach (static::$storageToConfigMap as $storageKey => $configKey) { + /** @var Tenant&Model $tenant */ + $override = Arr::get($tenant, $storageKey); + + if (! is_null($override)) { + if (is_array($configKey)) { + foreach ($configKey as $key) { + $this->originalConfig[$key] = $this->originalConfig[$key] ?? $this->config->get($key); + + $this->config->set($key, $override); + } + } else { + $this->originalConfig[$configKey] = $this->originalConfig[$configKey] ?? $this->config->get($configKey); + + $this->config->set($configKey, $override); + } + } + } + } + + public function revert(): void + { + foreach ($this->originalConfig as $key => $value) { + $this->config->set($key, $value); + } + } +} diff --git a/src/Commands/CreatePendingTenants.php b/src/Commands/CreatePendingTenants.php index c37b8bd7..11bdae63 100644 --- a/src/Commands/CreatePendingTenants.php +++ b/src/Commands/CreatePendingTenants.php @@ -8,7 +8,7 @@ use Illuminate\Console\Command; class CreatePendingTenants extends Command { - protected $signature = 'tenants:pending-create {--count= : The number of pending tenants to be created}'; + protected $signature = 'tenants:pending-create {--count= : The number of pending tenants to maintain}'; protected $description = 'Create pending tenants.'; diff --git a/src/Commands/CreateUserWithRLSPolicies.php b/src/Commands/CreateUserWithRLSPolicies.php index 420df935..3998dc48 100644 --- a/src/Commands/CreateUserWithRLSPolicies.php +++ b/src/Commands/CreateUserWithRLSPolicies.php @@ -81,12 +81,19 @@ class CreateUserWithRLSPolicies extends Command #[\SensitiveParameter] string $password, ): DatabaseConfig { + // This is a bit of a hack. We want to use our existing createUser() logic. + // That logic needs a DatabaseConfig instance. However, we aren't really working + // with any specific tenant here. We also *don't* want to use anything tenant-specific + // here. We are creating the SHARED "RLS user". Therefore, we need a custom DatabaseConfig + // instance for this purpose. The easiest way to do that is to grab an empty Tenant model + // (we use TenantWithDatabase in RLS) and manually create the host connection, just like + // DatabaseConfig::manager() would. We don't call that method since we want to use our existing + // PermissionControlledPostgreSQLSchemaManager $manager instance, rather than the "tenant's manager". + /** @var TenantWithDatabase $tenantModel */ $tenantModel = tenancy()->model(); - // Use a temporary DatabaseConfig instance to set the host connection $temporaryDbConfig = $tenantModel->database(); - $temporaryDbConfig->purgeHostConnection(); $tenantHostConnectionName = $temporaryDbConfig->getTenantHostConnectionName(); diff --git a/src/Commands/Install.php b/src/Commands/Install.php index 9f6a9c31..8521de5a 100644 --- a/src/Commands/Install.php +++ b/src/Commands/Install.php @@ -6,6 +6,7 @@ namespace Stancl\Tenancy\Commands; use Closure; use Illuminate\Console\Command; +use Illuminate\Support\Facades\Process; class Install extends Command { @@ -128,14 +129,27 @@ class Install extends Command public function askForSupport(): void { if ($this->components->confirm('Would you like to show your support by starring the project on GitHub?', true)) { - if (PHP_OS_FAMILY === 'Darwin') { - exec('open https://github.com/archtechx/tenancy'); + $ghVersion = Process::run('gh --version'); + $starred = false; + + // Make sure the `gh` binary is the actual GitHub CLI and not an unrelated tool + if ($ghVersion->successful() && str_contains($ghVersion->output(), 'https://github.com/cli/cli')) { + $starRequest = Process::run('gh api -X PUT user/starred/archtechx/tenancy'); + $starred = $starRequest->successful(); } - if (PHP_OS_FAMILY === 'Windows') { - exec('start https://github.com/archtechx/tenancy'); - } - if (PHP_OS_FAMILY === 'Linux') { - exec('xdg-open https://github.com/archtechx/tenancy'); + + if ($starred) { + $this->components->success('Repository starred via gh CLI, thank you!'); + } else { + if (PHP_OS_FAMILY === 'Darwin') { + exec('open https://github.com/archtechx/tenancy'); + } + if (PHP_OS_FAMILY === 'Windows') { + exec('start https://github.com/archtechx/tenancy'); + } + if (PHP_OS_FAMILY === 'Linux') { + exec('xdg-open https://github.com/archtechx/tenancy'); + } } } } diff --git a/src/Contracts/Feature.php b/src/Contracts/Feature.php index 74289981..25363cf5 100644 --- a/src/Contracts/Feature.php +++ b/src/Contracts/Feature.php @@ -4,10 +4,8 @@ declare(strict_types=1); namespace Stancl\Tenancy\Contracts; -use Stancl\Tenancy\Tenancy; - /** Additional features, like Telescope tags and tenant redirects. */ interface Feature { - public function bootstrap(Tenancy $tenancy): void; + public function bootstrap(): void; } diff --git a/src/Database/Concerns/HasDatabase.php b/src/Database/Concerns/HasDatabase.php index e1f4a55f..9388f168 100644 --- a/src/Database/Concerns/HasDatabase.php +++ b/src/Database/Concerns/HasDatabase.php @@ -28,7 +28,8 @@ trait HasDatabase } if ($key === $this->internalPrefix() . 'db_connection') { - // Remove DB connection because that's not used here + // Remove DB connection because that's not used for the connection *contents*. + // Instead the code uses getInternal('db_connection'). continue; } diff --git a/src/Database/Concerns/HasPending.php b/src/Database/Concerns/HasPending.php index ffb35f0c..34a66544 100644 --- a/src/Database/Concerns/HasPending.php +++ b/src/Database/Concerns/HasPending.php @@ -6,14 +6,13 @@ namespace Stancl\Tenancy\Database\Concerns; use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\DB; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Events\CreatingPendingTenant; use Stancl\Tenancy\Events\PendingTenantCreated; use Stancl\Tenancy\Events\PendingTenantPulled; use Stancl\Tenancy\Events\PullingPendingTenant; -// todo consider adding a method that sets pending_since to null — to flag tenants as not-pending - /** * @property ?Carbon $pending_since * @@ -50,46 +49,62 @@ trait HasPending */ public static function createPending(array $attributes = []): Model&Tenant { - $tenant = static::create($attributes); - - event(new CreatingPendingTenant($tenant)); - - // Update the pending_since value only after the tenant is created so it's - // Not marked as pending until finishing running the migrations, seeders, etc. - $tenant->update([ - 'pending_since' => now()->timestamp, - ]); + try { + $tenant = static::create($attributes); + event(new CreatingPendingTenant($tenant)); + } finally { + // Update the pending_since value only after the tenant is created so it's + // not marked as pending until after migrations, seeders, etc are run. + $tenant->update([ + 'pending_since' => now()->timestamp, + ]); + } event(new PendingTenantCreated($tenant)); return $tenant; } - /** Pull a pending tenant. */ - public static function pullPending(): Model&Tenant + /** + * Pull a pending tenant from the pool or create a new one if the pool is empty. + * + * @param array $attributes The attributes to set on the tenant. + */ + public static function pullPending(array $attributes = []): Model&Tenant { /** @var Model&Tenant $pendingTenant */ - $pendingTenant = static::pullPendingFromPool(true); + $pendingTenant = static::pullPendingFromPool(true, $attributes); return $pendingTenant; } - /** Try to pull a tenant from the pool of pending tenants. */ - public static function pullPendingFromPool(bool $firstOrCreate = true, array $attributes = []): ?Tenant + /** + * Try to pull a tenant from the pool of pending tenants. + * + * @param bool $firstOrCreate If true, a tenant will be *created* if the pool is empty. Otherwise null is returned. + * @param array $attributes The attributes to set on the tenant. + */ + public static function pullPendingFromPool(bool $firstOrCreate = false, array $attributes = []): ?Tenant { - /** @var (Model&Tenant)|null $tenant */ - $tenant = static::onlyPending()->first(); + $tenant = DB::transaction(function () use ($attributes): ?Tenant { + /** @var (Model&Tenant)|null $tenant */ + $tenant = static::onlyPending()->first(); + + if ($tenant !== null) { + event(new PullingPendingTenant($tenant)); + $tenant->update(array_merge($attributes, [ + 'pending_since' => null, + ])); + } + + return $tenant; + }); if ($tenant === null) { return $firstOrCreate ? static::create($attributes) : null; } - event(new PullingPendingTenant($tenant)); - - $tenant->update(array_merge($attributes, [ - 'pending_since' => null, - ])); - + // Only triggered if a tenant that was pulled from the pool is returned event(new PendingTenantPulled($tenant)); return $tenant; diff --git a/src/Database/DatabaseConfig.php b/src/Database/DatabaseConfig.php index 9a876d2d..bd167761 100644 --- a/src/Database/DatabaseConfig.php +++ b/src/Database/DatabaseConfig.php @@ -13,7 +13,6 @@ use Stancl\Tenancy\Database\Contracts\TenantWithDatabase as Tenant; use Stancl\Tenancy\Database\Exceptions\DatabaseManagerNotRegisteredException; use Stancl\Tenancy\Database\Exceptions\NoConnectionSetException; -// todo@dbRefactor refactor host connection logic to make customizing the host connection easier class DatabaseConfig { /** The tenant whose database we're dealing with. */ @@ -115,7 +114,7 @@ class DatabaseConfig { $this->tenant->setInternal('db_name', $this->getName()); - if ($this->connectionDriverManager($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) { + if ($this->managerForDriver($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) { $this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($this->tenant, $this)); $this->tenant->setInternal('db_password', $this->getPassword() ?? (static::$passwordGenerator)($this->tenant, $this)); } @@ -137,7 +136,9 @@ class DatabaseConfig } if ($template = config('tenancy.database.template_tenant_connection')) { - return is_array($template) ? array_merge($this->getCentralConnection(), $template) : config("database.connections.{$template}"); + return is_array($template) + ? array_merge($this->getCentralConnection(), $template) + : config("database.connections.{$template}"); } return $this->getCentralConnection(); @@ -176,10 +177,10 @@ class DatabaseConfig $config = $this->tenantConfig; $templateConnection = $this->getTemplateConnection(); - if ($this->connectionDriverManager($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) { - // We're removing the username and password because user with these credentials is not created yet - // If you need to provide username and password when using PermissionControlledMySQLDatabaseManager, - // consider creating a new connection and use it as `tenancy_db_connection` tenant config key + if ($this->managerForDriver($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) { + // We remove the username and password because the user with these credentials is not yet created. + // If you need to provide a username and a password when using a permission controlled database manager, + // consider creating a new connection and use it as `tenancy_db_connection`. unset($config['username'], $config['password']); } @@ -191,7 +192,7 @@ class DatabaseConfig } /** - * Purge the previous tenant connection before opening it for another tenant. + * Purge the previous host connection before opening it for another tenant. */ public function purgeHostConnection(): void { @@ -199,20 +200,20 @@ class DatabaseConfig } /** - * Get the TenantDatabaseManager for this tenant's connection. + * Get the TenantDatabaseManager for this tenant's host connection. * * @throws NoConnectionSetException|DatabaseManagerNotRegisteredException */ public function manager(): Contracts\TenantDatabaseManager { - // Laravel caches the previous PDO connection, so we purge it to be able to change the connection details + // Laravel persists the PDO connection, so we purge it to be able to change the connection details $this->purgeHostConnection(); // Create the tenant host connection config $tenantHostConnectionName = $this->getTenantHostConnectionName(); config(["database.connections.{$tenantHostConnectionName}" => $this->hostConnection()]); - $manager = $this->connectionDriverManager(config("database.connections.{$tenantHostConnectionName}.driver")); + $manager = $this->managerForDriver(config("database.connections.{$tenantHostConnectionName}.driver")); if ($manager instanceof Contracts\StatefulTenantDatabaseManager) { $manager->setConnection($tenantHostConnectionName); @@ -222,12 +223,11 @@ class DatabaseConfig } /** - * todo@name come up with a better name - * Get database manager class from the given connection config's driver. + * Get the TenantDatabaseManager for a given database driver. * * @throws DatabaseManagerNotRegisteredException */ - protected function connectionDriverManager(string $driver): Contracts\TenantDatabaseManager + protected function managerForDriver(string $driver): Contracts\TenantDatabaseManager { $databaseManagers = config('tenancy.database.managers'); diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php index 8ea3e631..47ec11a2 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php @@ -23,7 +23,6 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl { $database = $databaseConfig->getName(); $username = $databaseConfig->getUsername(); - $hostname = $databaseConfig->connection()['host']; $password = $databaseConfig->getPassword(); $this->connection()->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}'"); diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php index 933740ed..b528d4e3 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php @@ -30,10 +30,6 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage $tables = $this->connection()->select("SELECT table_name FROM information_schema.tables WHERE table_schema = '{$schema}' AND table_type = 'BASE TABLE'"); // Grant permissions to any existing tables. This is used with RLS - // todo@samuel refactor this along with the todo in TenantDatabaseManager - // and move the grantPermissions() call inside the condition in `ManagesPostgresUsers::createUser()` - // but maybe moving it inside $createUser is wrong because some central user may migrate new tables - // while the RLS user should STILL get access to those tables foreach ($tables as $table) { $tableName = $table->table_name; diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index 64b96fc1..34ad394d 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\TenantDatabaseManagers; -use AssertionError; use Closure; use Illuminate\Database\Eloquent\Model; use PDO; @@ -15,17 +14,12 @@ use Throwable; class SQLiteDatabaseManager implements TenantDatabaseManager { /** - * SQLite Database path without ending slash. + * SQLite database directory path. + * + * Defaults to database_path(). */ public static string|null $path = null; - /** - * Should the WAL journal mode be used for newly created databases. - * - * @see https://www.sqlite.org/pragma.html#pragma_journal_mode - */ - public static bool $WAL = true; - /* * If this isn't null, a connection to the tenant DB will be created * and passed to the provided closure, for the purpose of keeping the @@ -89,25 +83,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager return true; } - try { - if (file_put_contents($path = $this->getPath($name), '') === false) { - return false; - } - - if (static::$WAL) { - $pdo = new PDO('sqlite:' . $path); - $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - - // @phpstan-ignore-next-line method.nonObject - assert($pdo->query('pragma journal_mode = wal')->fetch(PDO::FETCH_ASSOC)['journal_mode'] === 'wal', 'Unable to set journal mode to wal.'); - } - - return true; - } catch (AssertionError $e) { - throw $e; - } catch (Throwable) { - return false; - } + return file_put_contents($this->getPath($name), '') !== false; } public function deleteDatabase(TenantWithDatabase $tenant): bool @@ -122,8 +98,16 @@ class SQLiteDatabaseManager implements TenantDatabaseManager return true; } + $path = $this->getPath($name); + try { - return unlink($this->getPath($name)); + unlink($path . '-journal'); + unlink($path . '-wal'); + unlink($path . '-shm'); + } catch (Throwable) {} + + try { + return unlink($path); } catch (Throwable) { return false; } @@ -150,15 +134,10 @@ class SQLiteDatabaseManager implements TenantDatabaseManager return $baseConfig; } - public function setConnection(string $connection): void - { - // - } - public function getPath(string $name): string { if (static::$path) { - return static::$path . DIRECTORY_SEPARATOR . $name; + return rtrim(static::$path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $name; } return database_path($name); diff --git a/src/Events/PullingPendingTenant.php b/src/Events/PullingPendingTenant.php index f823bb17..26d0433d 100644 --- a/src/Events/PullingPendingTenant.php +++ b/src/Events/PullingPendingTenant.php @@ -4,4 +4,9 @@ declare(strict_types=1); namespace Stancl\Tenancy\Events; +/** + * Importantly, listeners for this event should not switch tenancy context. + * + * This event is fired from within a database transaction. + */ class PullingPendingTenant extends Contracts\TenantEvent {} diff --git a/src/Features/CrossDomainRedirect.php b/src/Features/CrossDomainRedirect.php index a48be6ea..57786274 100644 --- a/src/Features/CrossDomainRedirect.php +++ b/src/Features/CrossDomainRedirect.php @@ -6,11 +6,10 @@ namespace Stancl\Tenancy\Features; use Illuminate\Http\RedirectResponse; use Stancl\Tenancy\Contracts\Feature; -use Stancl\Tenancy\Tenancy; class CrossDomainRedirect implements Feature { - public function bootstrap(Tenancy $tenancy): void + public function bootstrap(): void { RedirectResponse::macro('domain', function (string $domain) { /** @var RedirectResponse $this */ diff --git a/src/Features/DisallowSqliteAttach.php b/src/Features/DisallowSqliteAttach.php index f428a051..fbfa8e58 100644 --- a/src/Features/DisallowSqliteAttach.php +++ b/src/Features/DisallowSqliteAttach.php @@ -4,20 +4,17 @@ declare(strict_types=1); namespace Stancl\Tenancy\Features; -use Exception; use Illuminate\Database\Connectors\ConnectionFactory; use Illuminate\Database\SQLiteConnection; use Illuminate\Support\Facades\DB; use PDO; use Stancl\Tenancy\Contracts\Feature; -use Stancl\Tenancy\Tenancy; class DisallowSqliteAttach implements Feature { - protected static bool|null $loadExtensionSupported = null; public static string|false|null $extensionPath = null; - public function bootstrap(Tenancy $tenancy): void + public function bootstrap(): void { // Handle any already resolved connections foreach (DB::getConnections() as $connection) { @@ -39,31 +36,29 @@ class DisallowSqliteAttach implements Feature protected function loadExtension(PDO $pdo): bool { - if (static::$loadExtensionSupported === null) { - static::$loadExtensionSupported = method_exists($pdo, 'loadExtension'); - } + // todo@php85 In PHP 8.5, we can use setAuthorizer() instead of loading an extension. + // However, this is currently blocked on https://github.com/phpredis/phpredis/issues/2688 + static $loadExtensionSupported = method_exists($pdo, 'loadExtension'); - if (static::$loadExtensionSupported === false) { - return false; - } - if (static::$extensionPath === false) { - return false; - } + if ((! $loadExtensionSupported) || + (static::$extensionPath === false) || + (PHP_INT_SIZE !== 8) + ) return false; $suffix = match (PHP_OS_FAMILY) { 'Linux' => 'so', 'Windows' => 'dll', 'Darwin' => 'dylib', - default => throw new Exception("The DisallowSqliteAttach feature doesn't support your operating system: " . PHP_OS_FAMILY), + default => 'error', }; + if ($suffix === 'error') return false; + $arch = php_uname('m'); $arm = $arch === 'aarch64' || $arch === 'arm64'; static::$extensionPath ??= realpath(base_path('vendor/stancl/tenancy/extensions/lib/' . ($arm ? 'arm/' : '') . 'noattach.' . $suffix)); - if (static::$extensionPath === false) { - return false; - } + if (static::$extensionPath === false) return false; $pdo->loadExtension(static::$extensionPath); // @phpstan-ignore method.notFound diff --git a/src/Features/TelescopeTags.php b/src/Features/TelescopeTags.php index 0a580d23..225049df 100644 --- a/src/Features/TelescopeTags.php +++ b/src/Features/TelescopeTags.php @@ -7,11 +7,10 @@ namespace Stancl\Tenancy\Features; use Laravel\Telescope\IncomingEntry; use Laravel\Telescope\Telescope; use Stancl\Tenancy\Contracts\Feature; -use Stancl\Tenancy\Tenancy; class TelescopeTags implements Feature { - public function bootstrap(Tenancy $tenancy): void + public function bootstrap(): void { if (! class_exists(Telescope::class)) { return; diff --git a/src/Features/TenantConfig.php b/src/Features/TenantConfig.php index 5bc84060..3e248cb6 100644 --- a/src/Features/TenantConfig.php +++ b/src/Features/TenantConfig.php @@ -12,8 +12,10 @@ use Stancl\Tenancy\Contracts\Feature; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Events\RevertedToCentralContext; use Stancl\Tenancy\Events\TenancyBootstrapped; -use Stancl\Tenancy\Tenancy; +// todo@release remove this class + +/** @deprecated Use the TenantConfigBootstrapper instead. */ class TenantConfig implements Feature { public array $originalConfig = []; @@ -27,7 +29,7 @@ class TenantConfig implements Feature protected Repository $config, ) {} - public function bootstrap(Tenancy $tenancy): void + public function bootstrap(): void { Event::listen(TenancyBootstrapped::class, function (TenancyBootstrapped $event) { /** @var Tenant $tenant */ diff --git a/src/Features/UserImpersonation.php b/src/Features/UserImpersonation.php index 3db563a4..ac478d07 100644 --- a/src/Features/UserImpersonation.php +++ b/src/Features/UserImpersonation.php @@ -17,9 +17,9 @@ class UserImpersonation implements Feature /** The lifespan of impersonation tokens (in seconds). */ public static int $ttl = 60; - public function bootstrap(Tenancy $tenancy): void + public function bootstrap(): void { - $tenancy->macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string|null $authGuard = null, bool $remember = false): Model { + Tenancy::macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string|null $authGuard = null, bool $remember = false): Model { return UserImpersonation::modelClass()::create([ Tenancy::tenantKeyColumn() => $tenant->getTenantKey(), 'user_id' => $userId, diff --git a/src/Features/ViteBundler.php b/src/Features/ViteBundler.php index 987187c7..003984f7 100644 --- a/src/Features/ViteBundler.php +++ b/src/Features/ViteBundler.php @@ -7,19 +7,14 @@ namespace Stancl\Tenancy\Features; use Illuminate\Foundation\Application; use Illuminate\Support\Facades\Vite; use Stancl\Tenancy\Contracts\Feature; -use Stancl\Tenancy\Tenancy; class ViteBundler implements Feature { - /** @var Application */ - protected $app; + public function __construct( + protected Application $app, + ) {} - public function __construct(Application $app) - { - $this->app = $app; - } - - public function bootstrap(Tenancy $tenancy): void + public function bootstrap(): void { Vite::createAssetPathsUsing(function ($path, $secure = null) { return global_asset($path); diff --git a/src/Middleware/PreventAccessFromUnwantedDomains.php b/src/Middleware/PreventAccessFromUnwantedDomains.php index e3fea4ff..cdfa3b2c 100644 --- a/src/Middleware/PreventAccessFromUnwantedDomains.php +++ b/src/Middleware/PreventAccessFromUnwantedDomains.php @@ -66,9 +66,11 @@ class PreventAccessFromUnwantedDomains return in_array($request->getHost(), config('tenancy.identification.central_domains'), true); } - // todo@samuel technically not an identification middleware but probably ok to keep this here public function requestHasTenant(Request $request): bool { + // This middleware is special in that it's not an identification middleware + // but still uses some logic from UsableWithEarlyIdentification, so we just + // need to implement this method here. It doesn't matter what it returns. return false; } } diff --git a/src/Overrides/TenancyUrlGenerator.php b/src/Overrides/TenancyUrlGenerator.php index ed14d5b5..88ae54f3 100644 --- a/src/Overrides/TenancyUrlGenerator.php +++ b/src/Overrides/TenancyUrlGenerator.php @@ -110,7 +110,7 @@ class TenancyUrlGenerator extends UrlGenerator */ public function route($name, $parameters = [], $absolute = true) { - if ($name instanceof BackedEnum && ! is_string($name = $name->value)) { // @phpstan-ignore function.impossibleType + if ($name instanceof BackedEnum && ! is_string($name = $name->value)) { throw new InvalidArgumentException('Attribute [name] expects a string backed enum.'); } @@ -125,7 +125,7 @@ class TenancyUrlGenerator extends UrlGenerator */ public function temporarySignedRoute($name, $expiration, $parameters = [], $absolute = true) { - if ($name instanceof BackedEnum && ! is_string($name = $name->value)) { // @phpstan-ignore function.impossibleType + if ($name instanceof BackedEnum && ! is_string($name = $name->value)) { throw new InvalidArgumentException('Attribute [name] expects a string backed enum.'); } diff --git a/src/Tenancy.php b/src/Tenancy.php index 8e0ded99..95eeb950 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -11,6 +11,7 @@ use Illuminate\Foundation\Bus\PendingDispatch; use Illuminate\Support\Traits\Macroable; use Stancl\Tenancy\Concerns\DealsWithRouteContexts; use Stancl\Tenancy\Concerns\ManagesRLSPolicies; +use Stancl\Tenancy\Contracts\Feature; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByIdException; @@ -40,7 +41,7 @@ class Tenancy public static array $findWith = []; /** - * A list of bootstrappers that have been initialized. + * List of bootstrappers that have been initialized. * * This is used when reverting tenancy, mainly if an exception * occurs during bootstrapping, to ensure we don't revert @@ -53,6 +54,23 @@ class Tenancy */ public array $initializedBootstrappers = []; + /** + * List of features that have been bootstrapped. + * + * Since features may be bootstrapped multiple times during + * the request cycle (in TSP::boot() and any other times the user calls + * bootstrapFeatures()), we keep track of which features have already + * been bootstrapped so we do not bootstrap them again. Features are + * bootstrapped once and irreversible. + * + * The main point of this is that some features *need* to be bootstrapped + * very early (see #949), so we bootstrap them directly in TSP, but we + * also need the ability to *change* which features are used at runtime + * (mainly tests of this package) and bootstrap features again after making + * changes to config('tenancy.features'). + */ + protected array $bootstrappedFeatures = []; + /** Initialize tenancy for the passed tenant. */ public function initialize(Tenant|int|string $tenant): void { @@ -136,7 +154,7 @@ class Tenancy public function getBootstrappers(): array { // If no callback for getting bootstrappers is set, we return the ones in config. - $resolve = $this->getBootstrappersUsing ?? function (Tenant $tenant) { + $resolve = $this->getBootstrappersUsing ?? function (?Tenant $tenant) { return config('tenancy.bootstrappers'); }; @@ -154,6 +172,26 @@ class Tenancy return in_array($bootstrapper, static::getBootstrappers(), true); } + /** + * Bootstrap configured Tenancy features. + * + * Normally, features are bootstrapped directly in TSP::boot(). However, if + * new features are enabled at runtime (e.g. during tests), this method may + * be called to bootstrap new features. It's idempotent and keeps track of + * which features have already been bootstrapped. Keep in mind that feature + * bootstrapping is irreversible. + */ + public function bootstrapFeatures(): void + { + foreach (config('tenancy.features') ?? [] as $feature) { + /** @var class-string $feature */ + if (! in_array($feature, $this->bootstrappedFeatures)) { + app($feature)->bootstrap(); + $this->bootstrappedFeatures[] = $feature; + } + } + } + /** * @return Builder */ diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index 557306b2..a7f27e63 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -40,15 +40,6 @@ class TenancyServiceProvider extends ServiceProvider // Make sure Tenancy is stateful. $this->app->singleton(Tenancy::class); - // Make sure features are bootstrapped as soon as Tenancy is instantiated. - $this->app->extend(Tenancy::class, function (Tenancy $tenancy) { - foreach ($this->app['config']['tenancy.features'] ?? [] as $feature) { - $this->app[$feature]->bootstrap($tenancy); - } - - return $tenancy; - }); - // Make it possible to inject the current tenant by type hinting the Tenant contract. $this->app->bind(Tenant::class, function ($app) { return $app[Tenancy::class]->tenant; @@ -176,6 +167,11 @@ class TenancyServiceProvider extends ServiceProvider return $instance; }); + // Bootstrap features that are already enabled in the config. + // If more features are enabled at runtime, this method may be called + // multiple times, it keeps track of which features have already been bootstrapped. + $this->app->make(Tenancy::class)->bootstrapFeatures(); + Route::middlewareGroup('clone', []); Route::middlewareGroup('universal', []); Route::middlewareGroup('tenant', []); diff --git a/static_properties.nu b/static_properties.nu new file mode 100755 index 00000000..8b35e84e --- /dev/null +++ b/static_properties.nu @@ -0,0 +1,103 @@ +#!/usr/bin/env nu + +# Utility for exporting static properties used for configuration +def main []: nothing -> string { + "See --help for subcommands" +} + +# The current number of config static properties in the codebase +def "main count" [...paths: string]: nothing -> int { + props ...$paths | length +} + +# Available static properties, grouped by file, rendered as a table +def "main table" [...paths: string]: nothing -> string { + props ...$paths | table --theme rounded --expand +} + +# Plain text version of available static properties +def "main plain" [...paths: string]: nothing -> string { + props ...$paths + | each { $"// File: ($in.file)\n($in.props | str join "\n\n")"} + | str join "\n//------------------------------------------------------------\n\n" +} + +# Expressive Code formatting of available static properties, used in docs +def "main docs" [...paths: string]: nothing -> string { + (("{/* GENERATED_BEGIN */}\n" + (props ...$paths + | each { update props { each { if ($in | str ends-with "= [") { + $"($in)/* ... */];" + } else { $in }}}} + | each { $"```php /public static .*$/\n// File: ($in.file)\n($in.props | str join "\n\n")\n```"} + | str join "\n\n")) + + "\n{/* GENERATED_END */}") +} + +def props [...paths: string]: nothing -> table> { + ls ...(if ($paths | length) > 0 { + ($paths | each {|path| + if ($path | str contains "*") { + # already a glob expr + $path | into glob + } else if ($path | str ends-with ".php") { + # src/Foo/Bar.php + $path + } else { + # just 'src/Foo' passed + $"($path)/**/*.php" | into glob + } + }) + } else { + [("src/**/*.php" | into glob)] + }) + | each { { name: $in.name, content: (open $in.name) } } + | find -nr 'public static (?!.*function)' + | par-each {|file| + let lines = $file.content | lines + mut docblock_start = 0 + mut docblock_end = 0 + mut props = [] + for line in ($lines | enumerate) { + if ($line.item | str contains "/**") { + $docblock_start = $line.index + } + + if ($line.item | str contains "@internal") { + # Docblocks with @internal are ignored + $docblock_start = 0 + $docblock_end = 0 + } + + if ($line.item | str contains "*/") { + $docblock_end = $line.index + } + + if ( + ( + ( # Valid (non-internal) docblock + $docblock_start != 0 and + $docblock_end != 0 and + $docblock_end == ($line.index - 1) + ) or + ( # No docblock + $line.index != 0 and + (($lines | get ($line.index - 1)) | str index-of "*/") == -1 + ) + ) and + ($line.item | str trim | str index-of "public static") == 0 and + ($line.item | str trim | str index-of "public static function") == -1 + ) { + if ($docblock_start == 0) or ($docblock_end == 0) or ($docblock_end != ($line.index - 1)) { + $docblock_start = $line.index + $docblock_end = $line.index + } + $props = $props | append ($lines | slice $docblock_start..$line.index | each { str trim } | str join "\n") + $docblock_start = 0 + $docblock_end = 0 + } + } + + {file: $file.name, props: $props} + } + | where ($it.props | length) > 0 +} diff --git a/t b/t index 36d2d391..5b2c1f26 100755 --- a/t +++ b/t @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash if [[ "${CLAUDECODE}" != "1" ]]; then COLOR_FLAG="--colors=always" diff --git a/test b/test index 0df8f63e..b63dbdb9 100755 --- a/test +++ b/test @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash if [[ "${CLAUDECODE}" != "1" ]]; then COLOR_FLAG="--colors=always" diff --git a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php index d6b6a231..857e0eac 100644 --- a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php @@ -115,8 +115,6 @@ test('files can get fetched using the storage url', function() { test('storage_path helper does not change if suffix_storage_path is off', function() { $originalStoragePath = storage_path(); - // todo@tests https://github.com/tenancy-for-laravel/v4/pull/44#issue-2228530362 - config([ 'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class], 'tenancy.filesystem.suffix_storage_path' => false, diff --git a/tests/CloneActionTest.php b/tests/CloneActionTest.php index 656ad327..28a8ccd3 100644 --- a/tests/CloneActionTest.php +++ b/tests/CloneActionTest.php @@ -172,7 +172,7 @@ test('the clone action can clone specific routes either using name or route inst false, ]); -test('the clone action prefixes already prefixed routes correctly', function () { +test('the clone action prefixes already prefixed routes correctly', function (bool $tenantParameterBeforePrefix) { $routes = [ RouteFacade::get('/home', fn () => true) ->middleware(['clone']) @@ -195,7 +195,12 @@ test('the clone action prefixes already prefixed routes correctly', function () ->prefix('prefix/'), ]; - app(CloneRoutesAsTenant::class)->handle(); + $cloneAction = app(CloneRoutesAsTenant::class); + $cloneAction + ->tenantParameterBeforePrefix($tenantParameterBeforePrefix) + ->handle(); + + $expectedPrefix = $tenantParameterBeforePrefix ? '{tenant}/prefix' : 'prefix/{tenant}'; $clonedRoutes = [ RouteFacade::getRoutes()->getByName('tenant.home'), @@ -206,9 +211,10 @@ test('the clone action prefixes already prefixed routes correctly', function () // The cloned route is prefixed correctly foreach ($clonedRoutes as $key => $route) { - expect($route->getPrefix())->toBe("prefix/{tenant}"); + expect($route->getPrefix())->toBe($expectedPrefix); $clonedRouteUrl = route($route->getName(), ['tenant' => $tenant = Tenant::create()]); + $expectedPrefixInUrl = $tenantParameterBeforePrefix ? "{$tenant->id}/prefix" : "prefix/{$tenant->id}"; expect($clonedRouteUrl) // Original prefix does not occur in the cloned route's URL @@ -216,14 +222,14 @@ test('the clone action prefixes already prefixed routes correctly', function () ->not()->toContain("//prefix") ->not()->toContain("prefix//") // Instead, the route is prefixed correctly - ->toBe("http://localhost/prefix/{$tenant->id}/{$routes[$key]->getName()}"); + ->toBe("http://localhost/{$expectedPrefixInUrl}/{$routes[$key]->getName()}"); // The cloned route is accessible pest()->get($clonedRouteUrl)->assertOk(); } -}); +})->with([true, false]); -test('clone action trims trailing slashes from prefixes given to nested route groups', function () { +test('clone action trims trailing slashes from prefixes given to nested route groups', function (bool $tenantParameterBeforePrefix) { RouteFacade::prefix('prefix')->group(function () { RouteFacade::prefix('')->group(function () { // This issue seems to only happen when there's a group with a prefix, then a group with an empty prefix, and then a / route @@ -237,7 +243,10 @@ test('clone action trims trailing slashes from prefixes given to nested route gr }); }); - app(CloneRoutesAsTenant::class)->handle(); + $cloneAction = app(CloneRoutesAsTenant::class); + $cloneAction + ->tenantParameterBeforePrefix($tenantParameterBeforePrefix) + ->handle(); $clonedLandingUrl = route('tenant.landing', ['tenant' => $tenant = Tenant::create()]); $clonedHomeRouteUrl = route('tenant.home', ['tenant' => $tenant]); @@ -245,17 +254,20 @@ test('clone action trims trailing slashes from prefixes given to nested route gr $landingRoute = RouteFacade::getRoutes()->getByName('tenant.landing'); $homeRoute = RouteFacade::getRoutes()->getByName('tenant.home'); - expect($landingRoute->uri())->toBe('prefix/{tenant}'); - expect($homeRoute->uri())->toBe('prefix/{tenant}/home'); + $expectedPrefix = $tenantParameterBeforePrefix ? '{tenant}/prefix' : 'prefix/{tenant}'; + $expectedPrefixInUrl = $tenantParameterBeforePrefix ? "{$tenant->id}/prefix" : "prefix/{$tenant->id}"; + + expect($landingRoute->uri())->toBe($expectedPrefix); + expect($homeRoute->uri())->toBe("{$expectedPrefix}/home"); expect($clonedLandingUrl) ->not()->toContain("prefix//") - ->toBe("http://localhost/prefix/{$tenant->id}"); + ->toBe("http://localhost/{$expectedPrefixInUrl}"); expect($clonedHomeRouteUrl) ->not()->toContain("prefix//") - ->toBe("http://localhost/prefix/{$tenant->id}/home"); -}); + ->toBe("http://localhost/{$expectedPrefixInUrl}/home"); +})->with([true, false]); test('tenant routes are ignored from cloning and clone middleware in groups causes no issues', function () { // Should NOT be cloned, already has tenant parameter diff --git a/tests/EarlyIdentificationTest.php b/tests/EarlyIdentificationTest.php index a95bac0b..e6c08d26 100644 --- a/tests/EarlyIdentificationTest.php +++ b/tests/EarlyIdentificationTest.php @@ -10,6 +10,7 @@ use Illuminate\Contracts\Http\Kernel; use Illuminate\Support\Facades\Event; use Stancl\Tenancy\Events\TenancyInitialized; use Illuminate\Support\Facades\Route as RouteFacade; +use Illuminate\Support\Str; use Stancl\Tenancy\Actions\CloneRoutesAsTenant; use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; @@ -120,7 +121,7 @@ test('early identification works with path identification', function (bool $useK RouteFacade::get('/{post}/comment/{comment}/edit', [$controller, 'computePost']); }); - $tenant = Tenant::create(['tenancy_db_name' => pest()->randomString()]); + $tenant = Tenant::create(['tenancy_db_name' => Str::random(10)]); // Migrate users and comments tables on tenant connection pest()->artisan('tenants:migrate', [ diff --git a/tests/Features/NoAttachTest.php b/tests/Features/NoAttachTest.php index a1588a24..1ec62f2a 100644 --- a/tests/Features/NoAttachTest.php +++ b/tests/Features/NoAttachTest.php @@ -63,7 +63,7 @@ test('sqlite ATTACH statements can be blocked', function (bool $disallow) { return json_encode(DB::select(request('q2'))); }); - tenancy(); // trigger features: todo@samuel remove after feature refactor + tenancy()->bootstrapFeatures(); if ($disallow) { expect(fn () => pest()->post('/central-sqli', [ diff --git a/tests/Features/RedirectTest.php b/tests/Features/RedirectTest.php index a4102070..a871f529 100644 --- a/tests/Features/RedirectTest.php +++ b/tests/Features/RedirectTest.php @@ -12,6 +12,8 @@ test('tenant redirect macro replaces only the hostname', function () { 'tenancy.features' => [CrossDomainRedirect::class], ]); + tenancy()->bootstrapFeatures(); + Route::get('/foobar', function () { return 'Foo'; })->name('home'); diff --git a/tests/Features/TenantConfigTest.php b/tests/Features/TenantConfigTest.php index b06ddba9..483e44a6 100644 --- a/tests/Features/TenantConfigTest.php +++ b/tests/Features/TenantConfigTest.php @@ -2,29 +2,27 @@ declare(strict_types=1); -use Illuminate\Support\Facades\Event; -use Stancl\Tenancy\Events\TenancyEnded; -use Stancl\Tenancy\Events\TenancyInitialized; -use Stancl\Tenancy\Features\TenantConfig; -use Stancl\Tenancy\Listeners\BootstrapTenancy; -use Stancl\Tenancy\Listeners\RevertToCentralContext; +use Stancl\Tenancy\Bootstrappers\TenantConfigBootstrapper; use Stancl\Tenancy\Tests\Etc\Tenant; use function Stancl\Tenancy\Tests\pest; +use function Stancl\Tenancy\Tests\withBootstrapping; + +beforeEach(function () { + config([ + 'tenancy.bootstrappers' => [TenantConfigBootstrapper::class], + ]); + + withBootstrapping(); +}); afterEach(function () { - TenantConfig::$storageToConfigMap = []; + TenantConfigBootstrapper::$storageToConfigMap = []; }); test('nested tenant values are merged', function () { expect(config('whitelabel.theme'))->toBeNull(); - config([ - 'tenancy.features' => [TenantConfig::class], - 'tenancy.bootstrappers' => [], - ]); - Event::listen(TenancyInitialized::class, BootstrapTenancy::class); - Event::listen(TenancyEnded::class, RevertToCentralContext::class); - TenantConfig::$storageToConfigMap = [ + TenantConfigBootstrapper::$storageToConfigMap = [ 'whitelabel.config.theme' => 'whitelabel.theme', ]; @@ -39,14 +37,8 @@ test('nested tenant values are merged', function () { test('config is merged and removed', function () { expect(config('services.paypal'))->toBe(null); - config([ - 'tenancy.features' => [TenantConfig::class], - 'tenancy.bootstrappers' => [], - ]); - Event::listen(TenancyInitialized::class, BootstrapTenancy::class); - Event::listen(TenancyEnded::class, RevertToCentralContext::class); - TenantConfig::$storageToConfigMap = [ + TenantConfigBootstrapper::$storageToConfigMap = [ 'paypal_api_public' => 'services.paypal.public', 'paypal_api_private' => 'services.paypal.private', ]; @@ -68,14 +60,8 @@ test('config is merged and removed', function () { test('the value can be set to multiple config keys', function () { expect(config('services.paypal'))->toBe(null); - config([ - 'tenancy.features' => [TenantConfig::class], - 'tenancy.bootstrappers' => [], - ]); - Event::listen(TenancyInitialized::class, BootstrapTenancy::class); - Event::listen(TenancyEnded::class, RevertToCentralContext::class); - TenantConfig::$storageToConfigMap = [ + TenantConfigBootstrapper::$storageToConfigMap = [ 'paypal_api_public' => [ 'services.paypal.public1', 'services.paypal.public2', diff --git a/tests/Features/ViteBundlerTest.php b/tests/Features/ViteBundlerTest.php index 3934698f..17ee8e08 100644 --- a/tests/Features/ViteBundlerTest.php +++ b/tests/Features/ViteBundlerTest.php @@ -27,6 +27,7 @@ beforeEach(function () { test('vite bundler ensures vite assets use global_asset when asset_helper_override is enabled', function () { config(['tenancy.features' => [ViteBundler::class]]); + tenancy()->bootstrapFeatures(); withBootstrapping(); diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index 5c223fe2..ef1cb41f 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -8,6 +8,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; use Stancl\Tenancy\Actions\CloneRoutesAsTenant; use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Listeners\BootstrapTenancy; @@ -44,7 +45,7 @@ test('asset can be accessed using the url returned by the tenant asset helper', $tenant = Tenant::create(); tenancy()->initialize($tenant); - $filename = 'testfile' . pest()->randomString(10); + $filename = 'testfile' . Str::random(8); Storage::disk('public')->put($filename, 'bar'); $path = storage_path("app/public/$filename"); @@ -136,7 +137,7 @@ test('TenantAssetController headers are configurable', function () { tenancy()->initialize($tenant); $tenant->createDomain('foo.localhost'); - $filename = 'testfile' . pest()->randomString(10); + $filename = 'testfile' . Str::random(10); Storage::disk('public')->put($filename, 'bar'); $this->withoutExceptionHandling(); diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index c41ea35a..a9f99829 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -29,6 +29,8 @@ use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQ use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLDatabaseManager; use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager; use function Stancl\Tenancy\Tests\pest; +use function Stancl\Tenancy\Tests\withBootstrapping; +use function Stancl\Tenancy\Tests\withTenantDatabases; beforeEach(function () { SQLiteDatabaseManager::$path = null; @@ -43,7 +45,7 @@ test('databases can be created and deleted', function ($driver, $databaseManager "tenancy.database.managers.$driver" => $databaseManager, ]); - $name = 'db' . pest()->randomString(); + $name = 'db' . Str::random(10); $manager = app($databaseManager); @@ -70,7 +72,7 @@ test('dbs can be created when another driver is used for the central db', functi return $event->tenant; })->toListener()); - $database = 'db' . pest()->randomString(); + $database = 'db' . Str::random(10); $mysqlmanager = app(MySQLDatabaseManager::class); $mysqlmanager->setConnection('mysql'); @@ -86,7 +88,7 @@ test('dbs can be created when another driver is used for the central db', functi $postgresManager = app(PostgreSQLDatabaseManager::class); $postgresManager->setConnection('pgsql'); - $database = 'db' . pest()->randomString(); + $database = 'db' . Str::random(10); expect($postgresManager->databaseExists($database))->toBeFalse(); Tenant::create([ @@ -146,18 +148,15 @@ test('db name is prefixed with db path when sqlite is used', function () { expect(database_path('foodb'))->toBe(config('database.connections.tenant.database')); }); -test('sqlite databases use the WAL journal mode by default', function (bool|null $wal) { - $expected = $wal ? 'wal' : 'delete'; - if ($wal !== null) { - SQLiteDatabaseManager::$WAL = $wal; - } else { - // default behavior - $expected = 'wal'; - } - - Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { - return $event->tenant; - })->toListener()); +test('sqlite databases respect the template journal_mode config', function (string $journal_mode) { + withTenantDatabases(); + withBootstrapping(); + config([ + 'database.connections.sqlite.journal_mode' => $journal_mode, + 'tenancy.bootstrappers' => [ + DatabaseTenancyBootstrapper::class, + ], + ]); $tenant = Tenant::create([ 'tenancy_db_connection' => 'sqlite', @@ -170,11 +169,18 @@ test('sqlite databases use the WAL journal mode by default', function (bool|null $db = new PDO('sqlite:' . $dbPath); $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - expect($db->query('pragma journal_mode')->fetch(PDO::FETCH_ASSOC)['journal_mode'])->toBe($expected); + // Before we connect to the DB using Laravel, it will be in default delete mode + expect($db->query('pragma journal_mode')->fetch(PDO::FETCH_ASSOC)['journal_mode'])->toBe('delete'); - // cleanup - SQLiteDatabaseManager::$WAL = true; -})->with([true, false, null]); + // This will trigger the logic in Laravel's SQLiteConnector + $tenant->run(fn () => DB::select('select 1')); + + $db = new PDO('sqlite:' . $dbPath); + $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + // Once we connect to the DB, it will be in the configured journal mode + expect($db->query('pragma journal_mode')->fetch(PDO::FETCH_ASSOC)['journal_mode'])->toBe($journal_mode); +})->with(['delete', 'wal']); test('schema manager uses schema to separate tenant dbs', function () { config([ diff --git a/tests/TenantUserImpersonationTest.php b/tests/TenantUserImpersonationTest.php index 8c9c4124..48fbe691 100644 --- a/tests/TenantUserImpersonationTest.php +++ b/tests/TenantUserImpersonationTest.php @@ -42,6 +42,8 @@ beforeEach(function () { ], ]); + tenancy()->bootstrapFeatures(); + Event::listen( TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { diff --git a/tests/TestCase.php b/tests/TestCase.php index d4f2657b..ceee6522 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -25,6 +25,7 @@ use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use function Stancl\Tenancy\Tests\pest; use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper; +use Stancl\Tenancy\Bootstrappers\TenantConfigBootstrapper; abstract class TestCase extends \Orchestra\Testbench\TestCase { @@ -193,6 +194,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase $app->singleton(RootUrlBootstrapper::class); $app->singleton(UrlGeneratorBootstrapper::class); $app->singleton(FilesystemTenancyBootstrapper::class); + $app->singleton(TenantConfigBootstrapper::class); } protected function getPackageProviders($app) @@ -236,11 +238,6 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase $app->singleton('Illuminate\Contracts\Console\Kernel', Etc\Console\ConsoleKernel::class); } - public function randomString(int $length = 10) - { - return substr(str_shuffle(str_repeat($x = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', (int) (ceil($length / strlen($x))))), 1, $length); - } - public function assertArrayIsSubset($subset, $array, string $message = ''): void { parent::assertTrue(array_intersect($subset, $array) == $subset, $message);