diff --git a/.gitattributes b/.gitattributes index e0804500..3736c54d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -22,3 +22,4 @@ /t export-ignore /test export-ignore /tests export-ignore +/typedefs export-ignore diff --git a/Dockerfile b/Dockerfile index c7e129ad..98353198 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,45 +1,33 @@ -FROM shivammathur/node:latest -SHELL ["/bin/bash", "-c"] - ARG PHP_VERSION=8.3 -WORKDIR /var/www/html +FROM php:${PHP_VERSION}-cli-bookworm +SHELL ["/bin/bash", "-c"] -# our default timezone and language -ENV TZ=Europe/London -ENV LANG=en_GB.UTF-8 +RUN apt-get update && apt-get install -y --no-install-recommends \ + git unzip libzip-dev libicu-dev libmemcached-dev zlib1g-dev libssl-dev sqlite3 libsqlite3-dev libpq-dev mariadb-client -# install MSSQL ODBC driver (1/2) -RUN apt-get update \ - && apt-get install -y gnupg2 \ - && curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add - \ - && curl https://packages.microsoft.com/config/ubuntu/20.04/prod.list > /etc/apt/sources.list.d/mssql-release.list \ - && apt-get update +RUN apt-get install -y gnupg2 \ + && curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \ + && curl https://packages.microsoft.com/config/debian/12/prod.list > /etc/apt/sources.list.d/mssql-release.list \ + && apt-get update \ + && ACCEPT_EULA=Y apt-get install -y unixodbc-dev msodbcsql18 -# install MSSQL ODBC driver (2/2) -RUN if [[ $(uname -m) == "arm64" || $(uname -m) == "aarch64" ]]; \ - then ACCEPT_EULA=Y apt-get install -y unixodbc-dev msodbcsql18; \ - else ACCEPT_EULA=Y apt-get install -y unixodbc-dev=2.3.7 unixodbc=2.3.7 odbcinst1debian2=2.3.7 odbcinst=2.3.7 msodbcsql17; \ - fi +RUN apt autoremove && apt clean -# set PHP version -RUN update-alternatives --set php /usr/bin/php$PHP_VERSION \ - && update-alternatives --set phar /usr/bin/phar$PHP_VERSION \ - && update-alternatives --set phar.phar /usr/bin/phar.phar$PHP_VERSION \ - && update-alternatives --set phpize /usr/bin/phpize$PHP_VERSION \ - && update-alternatives --set php-config /usr/bin/php-config$PHP_VERSION +RUN pecl install apcu && docker-php-ext-enable apcu +RUN pecl install pcov && docker-php-ext-enable pcov +RUN pecl install redis && docker-php-ext-enable redis +RUN pecl install memcached && docker-php-ext-enable memcached +RUN pecl install pdo_sqlsrv && docker-php-ext-enable pdo_sqlsrv +RUN docker-php-ext-install zip && docker-php-ext-enable zip +RUN docker-php-ext-install intl && docker-php-ext-enable intl +RUN docker-php-ext-install pdo_mysql && docker-php-ext-enable pdo_mysql +RUN docker-php-ext-install pdo_pgsql && docker-php-ext-enable pdo_pgsql -# install composer +RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" +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 -# set the system timezone -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone - -# install PHP extensions -RUN pecl install redis && printf "; priority=20\nextension=redis.so\n" > /etc/php/$PHP_VERSION/mods-available/redis.ini && phpenmod -v $PHP_VERSION redis -RUN pecl install pdo_sqlsrv && printf "; priority=30\nextension=pdo_sqlsrv.so\n" > /etc/php/$PHP_VERSION/mods-available/pdo_sqlsrv.ini && phpenmod -v $PHP_VERSION pdo_sqlsrv -RUN pecl install pcov && printf "; priority=40\nextension=pcov.so\n" > /etc/php/$PHP_VERSION/mods-available/pcov.ini && phpenmod -v $PHP_VERSION pcov - -RUN apt-get install -y --no-install-recommends libmemcached-dev zlib1g-dev -RUN pecl install memcached && printf "; priority=50\nextension=memcached.so\n" > /etc/php/$PHP_VERSION/mods-available/memcached.ini && phpenmod -v $PHP_VERSION memcached -RUN pecl install apcu && printf "; priority=60\nextension=apcu.so\napc.enable_cli=1\n" > /etc/php/$PHP_VERSION/mods-available/apcu.ini && phpenmod -v $PHP_VERSION apcu +WORKDIR /var/www/html diff --git a/composer.json b/composer.json index 3a26584f..37f0b918 100644 --- a/composer.json +++ b/composer.json @@ -4,6 +4,7 @@ "keywords": [ "laravel", "multi-tenancy", + "multitenancy", "multi-database", "tenancy" ], @@ -25,7 +26,7 @@ "stancl/jobpipeline": "2.0.0-rc2", "stancl/virtualcolumn": "dev-master", "spatie/invade": "^1.1", - "laravel/prompts": "^0.1.9" + "laravel/prompts": "0.*" }, "require-dev": { "laravel/framework": "^10.1|^11.3", @@ -63,11 +64,14 @@ } }, "scripts": { - "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.3 docker-compose up -d --no-deps --build", + "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.3 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 vendor ./vendor/orchestra/testbench-core/laravel/vendor", + "testbench-repair": "mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/sessions && mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/views && mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/cache", "coverage": "open coverage/phpunit/html/index.html", "phpstan": "vendor/bin/phpstan", "phpstan-pro": "vendor/bin/phpstan --pro", diff --git a/docker-compose.yml b/docker-compose.yml index b0dc0187..a4857cbf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,8 +2,6 @@ services: test: build: context: . - args: - PHP_VERSION: ${PHP_VERSION:-8.3} depends_on: mysql: condition: service_healthy @@ -20,7 +18,7 @@ services: dynamodb: condition: service_healthy volumes: - - .:/var/www/html:delegated + - .:/var/www/html:cached environment: DOCKER: 1 DB_PASSWORD: password diff --git a/phpstan.neon b/phpstan.neon index fafcc146..c4c667b1 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -16,6 +16,7 @@ parameters: ignoreErrors: - identifier: missingType.iterableValue + - '#FFI#' - '#Return type(.*?) of method Stancl\\Tenancy\\Database\\Models\\Tenant\:\:newCollection\(\) should be compatible with return type#' - '#Method Stancl\\Tenancy\\Database\\Models\\Tenant\:\:newCollection\(\) should return#' - '#Cannot access offset (.*?) on Illuminate\\Contracts\\Foundation\\Application#' diff --git a/src/Bootstrappers/JobBatchBootstrapper.php b/src/Bootstrappers/JobBatchBootstrapper.php index 3ccf3b97..87db4869 100644 --- a/src/Bootstrappers/JobBatchBootstrapper.php +++ b/src/Bootstrappers/JobBatchBootstrapper.php @@ -27,6 +27,7 @@ class JobBatchBootstrapper implements TenancyBootstrapper public function bootstrap(Tenant $tenant): void { + // todo@revisit // Update batch repository connection to use the tenant connection $this->previousConnection = $this->batchRepository->getConnection(); $this->batchRepository->setConnection($this->databaseManager->connection('tenant')); diff --git a/src/Commands/CreateUserWithRLSPolicies.php b/src/Commands/CreateUserWithRLSPolicies.php index 45b25278..fd0338b6 100644 --- a/src/Commands/CreateUserWithRLSPolicies.php +++ b/src/Commands/CreateUserWithRLSPolicies.php @@ -83,7 +83,7 @@ class CreateUserWithRLSPolicies extends Command $manager->setConnection($tenantModel->database()->getTenantHostConnectionName()); // Set the database name (= central schema name/search_path in this case), username, and password - $tenantModel->setInternal('db_name', $manager->database()->getConfig('search_path')); + $tenantModel->setInternal('db_name', $manager->connection()->getConfig('search_path')); $tenantModel->setInternal('db_username', $username); $tenantModel->setInternal('db_password', $password); @@ -142,9 +142,9 @@ class CreateUserWithRLSPolicies extends Command $this->components->bulletList($createdPolicies); - $this->components->info('RLS policies updated successfully.'); + $this->components->success('RLS policies updated successfully.'); } else { - $this->components->info('All RLS policies are up to date.'); + $this->components->success('All RLS policies are up to date.'); } } diff --git a/src/Commands/Install.php b/src/Commands/Install.php index 09e720bb..9f6a9c31 100644 --- a/src/Commands/Install.php +++ b/src/Commands/Install.php @@ -52,9 +52,11 @@ class Install extends Command newLineAfter: true, ); - $this->components->info('✨️ Tenancy for Laravel successfully installed.'); + $this->components->success('✨️ Tenancy for Laravel successfully installed.'); - $this->askForSupport(); + if (! $this->option('no-interaction')) { + $this->askForSupport(); + } return 0; } diff --git a/src/Commands/Migrate.php b/src/Commands/Migrate.php index c3ba37e4..ab428546 100644 --- a/src/Commands/Migrate.php +++ b/src/Commands/Migrate.php @@ -16,6 +16,7 @@ use Stancl\Tenancy\Concerns\ParallelCommand; use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException; use Stancl\Tenancy\Events\DatabaseMigrated; use Stancl\Tenancy\Events\MigratingDatabase; +use Symfony\Component\Console\Output\OutputInterface as OI; class Migrate extends MigrateCommand { @@ -52,7 +53,7 @@ class Migrate extends MigrateCommand if ($this->getProcesses() > 1) { return $this->runConcurrently($this->getTenantChunks()->map(function ($chunk) { - return $this->getTenants($chunk->all()); + return $this->getTenants($chunk); })); } @@ -80,9 +81,25 @@ class Migrate extends MigrateCommand $tenant->run(function ($tenant) use (&$success) { event(new MigratingDatabase($tenant)); - // Migrate - if (parent::handle() !== 0) { - $success = false; + $verbosity = (int) $this->output->getVerbosity(); + + if ($this->runningConcurrently) { + // The output gets messy when multiple processes are writing to the same stdout + $this->output->setVerbosity(OI::VERBOSITY_QUIET); + } + + try { + // Migrate + if (parent::handle() !== 0) { + $success = false; + } + } finally { + $this->output->setVerbosity($verbosity); + } + + if ($this->runningConcurrently) { + // todo@cli the Migrating info above always has extra spaces, and the success below does WHEN there is work that got done by the block above. same in Rollback + $this->components->success("Migrated tenant {$tenant->getTenantKey()}"); } event(new DatabaseMigrated($tenant)); diff --git a/src/Commands/MigrateFresh.php b/src/Commands/MigrateFresh.php index 02e4c189..4e89cefd 100644 --- a/src/Commands/MigrateFresh.php +++ b/src/Commands/MigrateFresh.php @@ -38,7 +38,7 @@ class MigrateFresh extends BaseCommand if ($this->getProcesses() > 1) { return $this->runConcurrently($this->getTenantChunks()->map(function ($chunk) { - return $this->getTenants($chunk->all()); + return $this->getTenants($chunk); })); } @@ -81,6 +81,8 @@ class MigrateFresh extends BaseCommand } /** + * Only used when running concurrently. + * * @param LazyCollection $tenants */ protected function migrateFreshTenants(LazyCollection $tenants): bool @@ -89,6 +91,8 @@ class MigrateFresh extends BaseCommand foreach ($tenants as $tenant) { try { + $this->components->info("Migrating (fresh) tenant {$tenant->getTenantKey()}"); + $tenant->run(function ($tenant) use (&$success) { $this->components->info("Wiping database of tenant {$tenant->getTenantKey()}", OI::VERBOSITY_VERY_VERBOSE); if ($this->wipeDB()) { @@ -105,6 +109,8 @@ class MigrateFresh extends BaseCommand $success = false; $this->components->error("Migrating database of tenant {$tenant->getTenantKey()} failed!"); } + + $this->components->success("Migrated (fresh) tenant {$tenant->getTenantKey()}"); }); } catch (TenantDatabaseDoesNotExistException|QueryException $e) { $this->components->error("Migration failed for tenant {$tenant->getTenantKey()}: {$e->getMessage()}"); diff --git a/src/Commands/Rollback.php b/src/Commands/Rollback.php index 7ea01f08..e7018a5a 100644 --- a/src/Commands/Rollback.php +++ b/src/Commands/Rollback.php @@ -15,6 +15,7 @@ use Stancl\Tenancy\Concerns\ParallelCommand; use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException; use Stancl\Tenancy\Events\DatabaseRolledBack; use Stancl\Tenancy\Events\RollingBackDatabase; +use Symfony\Component\Console\Output\OutputInterface as OI; class Rollback extends RollbackCommand { @@ -42,7 +43,7 @@ class Rollback extends RollbackCommand if ($this->getProcesses() > 1) { return $this->runConcurrently($this->getTenantChunks()->map(function ($chunk) { - return $this->getTenants($chunk->all()); + return $this->getTenants($chunk); })); } @@ -70,14 +71,29 @@ class Rollback extends RollbackCommand foreach ($tenants as $tenant) { try { - $this->components->info("Tenant {$tenant->getTenantKey()}"); + $this->components->info("Rolling back tenant {$tenant->getTenantKey()}"); $tenant->run(function ($tenant) use (&$success) { event(new RollingBackDatabase($tenant)); - // Rollback - if (parent::handle() !== 0) { - $success = false; + $verbosity = (int) $this->output->getVerbosity(); + + if ($this->runningConcurrently) { + // The output gets messy when multiple processes are writing to the same stdout + $this->output->setVerbosity(OI::VERBOSITY_QUIET); + } + + try { + // Rollback + if (parent::handle() !== 0) { + $success = false; + } + } finally { + $this->output->setVerbosity($verbosity); + } + + if ($this->runningConcurrently) { + $this->components->success("Rolled back tenant {$tenant->getTenantKey()}"); } event(new DatabaseRolledBack($tenant)); diff --git a/src/Concerns/ParallelCommand.php b/src/Concerns/ParallelCommand.php index 55383788..aad7a1ec 100644 --- a/src/Concerns/ParallelCommand.php +++ b/src/Concerns/ParallelCommand.php @@ -5,6 +5,9 @@ declare(strict_types=1); namespace Stancl\Tenancy\Concerns; use ArrayAccess; +use Countable; +use Exception; +use FFI; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Symfony\Component\Console\Input\InputOption; @@ -12,16 +15,35 @@ use Symfony\Component\Console\Input\InputOption; trait ParallelCommand { public const MAX_PROCESSES = 24; + protected bool $runningConcurrently = false; abstract protected function childHandle(mixed ...$args): bool; public function addProcessesOption(): void { - $this->addOption('processes', 'p', InputOption::VALUE_OPTIONAL, 'How many processes to spawn. Maximum value: ' . static::MAX_PROCESSES . ', recommended value: core count', 1); + $this->addOption( + 'processes', + 'p', + InputOption::VALUE_OPTIONAL, + 'How many processes to spawn. Maximum value: ' . static::MAX_PROCESSES . ', recommended value: core count (use just -p)', + -1, + ); + + $this->addOption( + 'forceProcesses', + 'P', + InputOption::VALUE_OPTIONAL, + 'Same as --processes but without a maximum value. Use at your own risk', + -1, + ); } protected function forkProcess(mixed ...$args): int { + if (! app()->runningInConsole()) { + throw new Exception('Parallel commands are only available in CLI context.'); + } + $pid = pcntl_fork(); if ($pid === -1) { @@ -37,24 +59,84 @@ trait ParallelCommand } } + protected function sysctlGetLogicalCoreCount(bool $darwin): int + { + $ffi = FFI::cdef('int sysctlbyname(const char *name, void *oldp, size_t *oldlenp, void *newp, size_t newlen);'); + + $cores = $ffi->new('int'); + $size = $ffi->new('size_t'); + $size->cdata = FFI::sizeof($cores); + + // perflevel0 refers to P-cores on M-series, and the entire CPU on Intel Macs + if ($darwin && $ffi->sysctlbyname('hw.perflevel0.logicalcpu', FFI::addr($cores), FFI::addr($size), null, 0) === 0) { + return $cores->cdata; + } else if ($darwin) { + // Reset the size in case the pointer got written to (likely shouldn't happen) + $size->cdata = FFI::sizeof($cores); + } + + // This should return the total number of logical cores on any BSD-based system + if ($ffi->sysctlbyname('hw.ncpu', FFI::addr($cores), FFI::addr($size), null, 0) !== 0) { + return -1; + } + + return $cores->cdata; + } + + protected function getLogicalCoreCount(): int + { + // We use the logical core count as it should work best for I/O bound code + return match (PHP_OS_FAMILY) { + 'Windows' => (int) getenv('NUMBER_OF_PROCESSORS'), + 'Linux' => substr_count( + file_get_contents('/proc/cpuinfo') ?: throw new Exception('Could not open /proc/cpuinfo for core count detection, please specify -p manually.'), + 'processor', + ), + 'Darwin', 'BSD' => $this->sysctlGetLogicalCoreCount(PHP_OS_FAMILY === 'Darwin'), + default => throw new Exception('Core count detection not implemented for ' . PHP_OS_FAMILY . ', please specify -p manually.'), + }; + } + protected function getProcesses(): int { - $processes = (int) $this->input->getOption('processes'); + $processes = $this->input->getOption('forceProcesses'); + $forceProcesses = $processes !== -1; - if (($processes < 0) || ($processes > static::MAX_PROCESSES)) { - $this->components->error('Maximum value for processes is ' . static::MAX_PROCESSES); + if ($processes === -1) { + $processes = $this->input->getOption('processes'); + } + + if ($processes === null) { + // This is used when the option is set but *without* a value (-p). + $processes = $this->getLogicalCoreCount(); + } else if ((int) $processes === -1) { + // Default value we set for the option -- this is used when the option is *not set*. + $processes = 1; + } else { + // Option value set by the user. + $processes = (int) $processes; + } + + if ($processes < 1) { + $this->components->error('Minimum value for processes is 1. Try specifying -p manually.'); + exit(1); + } + + if ($processes > static::MAX_PROCESSES && ! $forceProcesses) { + $this->components->error('Maximum value for processes is ' . static::MAX_PROCESSES . ' provided value: ' . $processes); exit(1); } if ($processes > 1 && ! function_exists('pcntl_fork')) { $this->components->error('The pcntl extension is required for parallel migrations to work.'); + exit(1); } return $processes; } /** - * @return Collection>> + * @return Collection>> */ protected function getTenantChunks(): Collection { @@ -64,20 +146,26 @@ trait ParallelCommand return $tenants->chunk((int) ceil($tenants->count() / $this->getProcesses()))->map(function ($chunk) { $chunk = array_values($chunk->all()); - /** @var Collection $chunk */ + /** @var array $chunk */ return $chunk; }); } /** - * @param array|ArrayAccess|null $args + * @param array|(ArrayAccess&Countable)|null $args */ - protected function runConcurrently(array|ArrayAccess|null $args = null): int + protected function runConcurrently(array|(ArrayAccess&Countable)|null $args = null): int { $processes = $this->getProcesses(); $success = true; $pids = []; + if ($args !== null && count($args) < $processes) { + $processes = count($args); + } + + $this->runningConcurrently = true; + for ($i = 0; $i < $processes; $i++) { $pid = $this->forkProcess($args !== null ? $args[$i] : null); @@ -101,7 +189,7 @@ trait ParallelCommand $exitCode = pcntl_wexitstatus($status); if ($exitCode === 0) { - $this->components->info("Child process [$i] (PID $pid) finished successfully."); + $this->components->success("Child process [$i] (PID $pid) finished successfully."); } else { $success = false; $this->components->error("Child process [$i] (PID $pid) completed with failures."); diff --git a/src/Database/Concerns/HasPending.php b/src/Database/Concerns/HasPending.php index 83cf0cf2..ce0b1a37 100644 --- a/src/Database/Concerns/HasPending.php +++ b/src/Database/Concerns/HasPending.php @@ -73,20 +73,15 @@ trait HasPending } /** Try to pull a tenant from the pool of pending tenants. */ - public static function pullPendingFromPool(bool $firstOrCreate = false, array $attributes = []): ?Tenant + public static function pullPendingFromPool(bool $firstOrCreate = true, array $attributes = []): ?Tenant { - if (! static::onlyPending()->exists()) { - if (! $firstOrCreate) { - return null; - } - - static::createPending($attributes); - } - - // A pending tenant is surely available at this point - /** @var Model&Tenant $tenant */ + /** @var (Model&Tenant)|null $tenant */ $tenant = static::onlyPending()->first(); + if ($tenant === null) { + return $firstOrCreate ? static::create($attributes) : null; + } + event(new PullingPendingTenant($tenant)); $tenant->update(array_merge($attributes, [ diff --git a/src/Database/Concerns/ManagesPostgresUsers.php b/src/Database/Concerns/ManagesPostgresUsers.php index f73bac1c..c798fe13 100644 --- a/src/Database/Concerns/ManagesPostgresUsers.php +++ b/src/Database/Concerns/ManagesPostgresUsers.php @@ -30,7 +30,7 @@ trait ManagesPostgresUsers $createUser = ! $this->userExists($username); if ($createUser) { - $this->database()->statement("CREATE USER \"{$username}\" LOGIN PASSWORD '{$password}'"); + $this->connection()->statement("CREATE USER \"{$username}\" LOGIN PASSWORD '{$password}'"); } $this->grantPermissions($databaseConfig); @@ -46,38 +46,38 @@ trait ManagesPostgresUsers $username = $databaseConfig->getUsername(); // Tenant host connection config - $connectionName = $this->database()->getConfig('name'); - $centralDatabase = $this->database()->getConfig('database'); + $connectionName = $this->connection()->getConfig('name'); + $centralDatabase = $this->connection()->getConfig('database'); // Set the DB/schema name to the tenant DB/schema name config()->set( "database.connections.{$connectionName}", - $this->makeConnectionConfig($this->database()->getConfig(), $databaseConfig->getName()), + $this->makeConnectionConfig($this->connection()->getConfig(), $databaseConfig->getName()), ); // Connect to the tenant DB/schema - $this->database()->reconnect(); + $this->connection()->reconnect(); // Delete all database objects owned by the user (privileges, tables, views, etc.) // Postgres users cannot be deleted unless we delete all objects owned by it first - $this->database()->statement("DROP OWNED BY \"{$username}\""); + $this->connection()->statement("DROP OWNED BY \"{$username}\""); // Delete the user - $userDeleted = $this->database()->statement("DROP USER \"{$username}\""); + $userDeleted = $this->connection()->statement("DROP USER \"{$username}\""); config()->set( "database.connections.{$connectionName}", - $this->makeConnectionConfig($this->database()->getConfig(), $centralDatabase), + $this->makeConnectionConfig($this->connection()->getConfig(), $centralDatabase), ); // Reconnect to the central database - $this->database()->reconnect(); + $this->connection()->reconnect(); return $userDeleted; } public function userExists(string $username): bool { - return (bool) $this->database()->selectOne("SELECT usename FROM pg_user WHERE usename = '{$username}'"); + return (bool) $this->connection()->selectOne("SELECT usename FROM pg_user WHERE usename = '{$username}'"); } } diff --git a/src/Database/Contracts/StatefulTenantDatabaseManager.php b/src/Database/Contracts/StatefulTenantDatabaseManager.php index 6716e9eb..9bda3de2 100644 --- a/src/Database/Contracts/StatefulTenantDatabaseManager.php +++ b/src/Database/Contracts/StatefulTenantDatabaseManager.php @@ -13,7 +13,7 @@ use Stancl\Tenancy\Database\Exceptions\NoConnectionSetException; interface StatefulTenantDatabaseManager extends TenantDatabaseManager { /** Get the DB connection used by the tenant database manager. */ - public function database(): Connection; // todo@dbRefactor rename to connection() + public function connection(): Connection; /** * Set the DB connection that should be used by the tenant database manager. diff --git a/src/Database/DatabaseConfig.php b/src/Database/DatabaseConfig.php index 460da048..bd227864 100644 --- a/src/Database/DatabaseConfig.php +++ b/src/Database/DatabaseConfig.php @@ -25,21 +25,21 @@ class DatabaseConfig /** * Database username generator (can be set by the developer.). * - * @var Closure(Model&Tenant): string + * @var Closure(Model&Tenant, self): string */ public static Closure $usernameGenerator; /** * Database password generator (can be set by the developer.). * - * @var Closure(Model&Tenant): string + * @var Closure(Model&Tenant, self): string */ public static Closure $passwordGenerator; /** * Database name generator (can be set by the developer.). * - * @var Closure(Model&Tenant): string + * @var Closure(Model&Tenant, self): string */ public static Closure $databaseNameGenerator; @@ -58,8 +58,14 @@ class DatabaseConfig } if (! isset(static::$databaseNameGenerator)) { - static::$databaseNameGenerator = function (Model&Tenant $tenant) { - return config('tenancy.database.prefix') . $tenant->getTenantKey() . config('tenancy.database.suffix'); + static::$databaseNameGenerator = function (Model&Tenant $tenant, self $self) { + $suffix = config('tenancy.database.suffix'); + + if (! $suffix && $self->getTemplateConnection()['driver'] === 'sqlite') { + $suffix = '.sqlite'; + } + + return config('tenancy.database.prefix') . $tenant->getTenantKey() . $suffix; }; } } @@ -89,7 +95,7 @@ class DatabaseConfig public function getName(): string { - return $this->tenant->getInternal('db_name') ?? (static::$databaseNameGenerator)($this->tenant); + return $this->tenant->getInternal('db_name') ?? (static::$databaseNameGenerator)($this->tenant, $this); } public function getUsername(): ?string @@ -110,8 +116,8 @@ class DatabaseConfig $this->tenant->setInternal('db_name', $this->getName()); if ($this->connectionDriverManager($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) { - $this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($this->tenant)); - $this->tenant->setInternal('db_password', $this->getPassword() ?? (static::$passwordGenerator)($this->tenant)); + $this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($this->tenant, $this)); + $this->tenant->setInternal('db_password', $this->getPassword() ?? (static::$passwordGenerator)($this->tenant, $this)); } if ($this->tenant->exists) { @@ -192,7 +198,8 @@ class DatabaseConfig DB::purge($this->getTenantHostConnectionName()); } - /** Get the TenantDatabaseManager for this tenant's connection. + /** + * Get the TenantDatabaseManager for this tenant's connection. * * @throws NoConnectionSetException|DatabaseManagerNotRegisteredException */ diff --git a/src/Database/DatabaseManager.php b/src/Database/DatabaseManager.php index edde7515..e250cd2f 100644 --- a/src/Database/DatabaseManager.php +++ b/src/Database/DatabaseManager.php @@ -9,6 +9,7 @@ use Illuminate\Contracts\Foundation\Application; use Illuminate\Database\DatabaseManager as BaseDatabaseManager; use Stancl\Tenancy\Contracts\TenantCannotBeCreatedException; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; +use Stancl\Tenancy\Database\TenantDatabaseManagers\SQLiteDatabaseManager; /** * @internal Class is subject to breaking changes in minor and patch versions. @@ -71,7 +72,9 @@ class DatabaseManager $manager = $tenant->database()->manager(); if ($manager->databaseExists($database = $tenant->database()->getName())) { - throw new Exceptions\TenantDatabaseAlreadyExistsException($database); + if (! $manager instanceof SQLiteDatabaseManager || ! SQLiteDatabaseManager::isInMemory($database)) { + throw new Exceptions\TenantDatabaseAlreadyExistsException($database); + } } if ($manager instanceof Contracts\ManagesDatabaseUsers) { diff --git a/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php index 65950baa..1e5426ea 100644 --- a/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php @@ -11,19 +11,19 @@ class MicrosoftSQLDatabaseManager extends TenantDatabaseManager public function createDatabase(TenantWithDatabase $tenant): bool { $database = $tenant->database()->getName(); - $charset = $this->database()->getConfig('charset'); - $collation = $this->database()->getConfig('collation'); // todo check why these are not used + $charset = $this->connection()->getConfig('charset'); + $collation = $this->connection()->getConfig('collation'); // todo check why these are not used - return $this->database()->statement("CREATE DATABASE [{$database}]"); + return $this->connection()->statement("CREATE DATABASE [{$database}]"); } public function deleteDatabase(TenantWithDatabase $tenant): bool { - return $this->database()->statement("DROP DATABASE [{$tenant->database()->getName()}]"); + return $this->connection()->statement("DROP DATABASE [{$tenant->database()->getName()}]"); } public function databaseExists(string $name): bool { - return (bool) $this->database()->select("SELECT name FROM master.sys.databases WHERE name = '$name'"); + return (bool) $this->connection()->select("SELECT name FROM master.sys.databases WHERE name = '$name'"); } } diff --git a/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php index c96c162b..b86faef2 100644 --- a/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php @@ -11,19 +11,19 @@ class MySQLDatabaseManager extends TenantDatabaseManager public function createDatabase(TenantWithDatabase $tenant): bool { $database = $tenant->database()->getName(); - $charset = $this->database()->getConfig('charset'); - $collation = $this->database()->getConfig('collation'); + $charset = $this->connection()->getConfig('charset'); + $collation = $this->connection()->getConfig('collation'); - return $this->database()->statement("CREATE DATABASE `{$database}` CHARACTER SET `$charset` COLLATE `$collation`"); + return $this->connection()->statement("CREATE DATABASE `{$database}` CHARACTER SET `$charset` COLLATE `$collation`"); } public function deleteDatabase(TenantWithDatabase $tenant): bool { - return $this->database()->statement("DROP DATABASE `{$tenant->database()->getName()}`"); + return $this->connection()->statement("DROP DATABASE `{$tenant->database()->getName()}`"); } public function databaseExists(string $name): bool { - return (bool) $this->database()->select("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = '$name'"); + return (bool) $this->connection()->select("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = '$name'"); } } diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php index aec43d46..b373f41e 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php @@ -25,24 +25,24 @@ class PermissionControlledMicrosoftSQLServerDatabaseManager extends MicrosoftSQL $password = $databaseConfig->getPassword(); // Create login - $this->database()->statement("CREATE LOGIN [$username] WITH PASSWORD = '$password'"); + $this->connection()->statement("CREATE LOGIN [$username] WITH PASSWORD = '$password'"); // Create user in the database // Grant the user permissions specified in the $grants array // The 'CONNECT' permission is granted automatically $grants = implode(', ', static::$grants); - return $this->database()->statement("USE [$database]; CREATE USER [$username] FOR LOGIN [$username]; GRANT $grants TO [$username]"); + return $this->connection()->statement("USE [$database]; CREATE USER [$username] FOR LOGIN [$username]; GRANT $grants TO [$username]"); } public function deleteUser(DatabaseConfig $databaseConfig): bool { - return $this->database()->statement("DROP LOGIN [{$databaseConfig->getUsername()}]"); + return $this->connection()->statement("DROP LOGIN [{$databaseConfig->getUsername()}]"); } public function userExists(string $username): bool { - return (bool) $this->database()->select("SELECT sp.name as username FROM sys.server_principals sp WHERE sp.name = '{$username}'"); + return (bool) $this->connection()->select("SELECT sp.name as username FROM sys.server_principals sp WHERE sp.name = '{$username}'"); } public function makeConnectionConfig(array $baseConfig, string $databaseName): array @@ -58,7 +58,7 @@ class PermissionControlledMicrosoftSQLServerDatabaseManager extends MicrosoftSQL // Set the database to SINGLE_USER mode to ensure that // No other connections are using the database while we're trying to delete it // Rollback all active transactions - $this->database()->statement("ALTER DATABASE [{$tenant->database()->getName()}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;"); + $this->connection()->statement("ALTER DATABASE [{$tenant->database()->getName()}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;"); return parent::deleteDatabase($tenant); } diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php index 308d8786..8ea3e631 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php @@ -26,7 +26,7 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl $hostname = $databaseConfig->connection()['host']; $password = $databaseConfig->getPassword(); - $this->database()->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}'"); + $this->connection()->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}'"); $grants = implode(', ', static::$grants); @@ -36,24 +36,24 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl $grantQuery = "GRANT $grants ON `$database`.* TO `$username`@`%` IDENTIFIED BY '$password'"; } - return $this->database()->statement($grantQuery); + return $this->connection()->statement($grantQuery); } protected function isVersion8(): bool { - $versionSelect = (string) $this->database()->raw('select version()')->getValue($this->database()->getQueryGrammar()); - $version = $this->database()->select($versionSelect)[0]->{'version()'}; + $versionSelect = (string) $this->connection()->raw('select version()')->getValue($this->connection()->getQueryGrammar()); + $version = $this->connection()->select($versionSelect)[0]->{'version()'}; return version_compare($version, '8.0.0') >= 0; } public function deleteUser(DatabaseConfig $databaseConfig): bool { - return $this->database()->statement("DROP USER IF EXISTS '{$databaseConfig->getUsername()}'"); + return $this->connection()->statement("DROP USER IF EXISTS '{$databaseConfig->getUsername()}'"); } public function userExists(string $username): bool { - return (bool) $this->database()->select("SELECT count(*) FROM mysql.user WHERE user = '$username'")[0]->{'count(*)'}; + return (bool) $this->connection()->select("SELECT count(*) FROM mysql.user WHERE user = '$username'")[0]->{'count(*)'}; } } diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLDatabaseManager.php index 982ea4d6..1522234e 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLDatabaseManager.php @@ -21,26 +21,26 @@ class PermissionControlledPostgreSQLDatabaseManager extends PostgreSQLDatabaseMa $schema = $databaseConfig->connection()['search_path']; // Host config - $connectionName = $this->database()->getConfig('name'); - $centralDatabase = $this->database()->getConfig('database'); + $connectionName = $this->connection()->getConfig('name'); + $centralDatabase = $this->connection()->getConfig('database'); - $this->database()->statement("GRANT CONNECT ON DATABASE \"{$database}\" TO \"{$username}\""); + $this->connection()->statement("GRANT CONNECT ON DATABASE \"{$database}\" TO \"{$username}\""); // Connect to tenant database config(["database.connections.{$connectionName}.database" => $database]); - $this->database()->reconnect(); + $this->connection()->reconnect(); // Grant permissions to create and use tables in the configured schema ("public" by default) to the user - $this->database()->statement("GRANT USAGE, CREATE ON SCHEMA {$schema} TO \"{$username}\""); + $this->connection()->statement("GRANT USAGE, CREATE ON SCHEMA {$schema} TO \"{$username}\""); // Grant permissions to use sequences in the current schema to the user - $this->database()->statement("GRANT USAGE ON ALL SEQUENCES IN SCHEMA {$schema} TO \"{$username}\""); + $this->connection()->statement("GRANT USAGE ON ALL SEQUENCES IN SCHEMA {$schema} TO \"{$username}\""); // Reconnect to central database config(["database.connections.{$connectionName}.database" => $centralDatabase]); - $this->database()->reconnect(); + $this->connection()->reconnect(); return true; } diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php index 96693cae..fda4a836 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php @@ -23,11 +23,11 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage // Central database name $database = DB::connection(config('tenancy.database.central_connection'))->getDatabaseName(); - $this->database()->statement("GRANT CONNECT ON DATABASE {$database} TO \"{$username}\""); - $this->database()->statement("GRANT USAGE, CREATE ON SCHEMA \"{$schema}\" TO \"{$username}\""); - $this->database()->statement("GRANT USAGE ON ALL SEQUENCES IN SCHEMA \"{$schema}\" TO \"{$username}\""); + $this->connection()->statement("GRANT CONNECT ON DATABASE {$database} TO \"{$username}\""); + $this->connection()->statement("GRANT USAGE, CREATE ON SCHEMA \"{$schema}\" TO \"{$username}\""); + $this->connection()->statement("GRANT USAGE ON ALL SEQUENCES IN SCHEMA \"{$schema}\" TO \"{$username}\""); - $tables = $this->database()->select("SELECT table_name FROM information_schema.tables WHERE table_schema = '{$schema}'"); + $tables = $this->connection()->select("SELECT table_name FROM information_schema.tables WHERE table_schema = '{$schema}'"); // Grant permissions to any existing tables. This is used with RLS // todo@samuel refactor this along with the todo in TenantDatabaseManager @@ -36,7 +36,7 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage $tableName = $table->table_name; /** @var string $primaryKey */ - $primaryKey = $this->database()->selectOne(<<connection()->selectOne(<<column_name; // Grant all permissions for all existing tables - $this->database()->statement("GRANT ALL ON \"{$schema}\".\"{$tableName}\" TO \"{$username}\""); + $this->connection()->statement("GRANT ALL ON \"{$schema}\".\"{$tableName}\" TO \"{$username}\""); // Grant permission to reference the primary key for the table // The previous query doesn't grant the references privilege, so it has to be granted here - $this->database()->statement("GRANT REFERENCES (\"{$primaryKey}\") ON \"{$schema}\".\"{$tableName}\" TO \"{$username}\""); + $this->connection()->statement("GRANT REFERENCES (\"{$primaryKey}\") ON \"{$schema}\".\"{$tableName}\" TO \"{$username}\""); } return true; diff --git a/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php index 88f1c78c..4fff7202 100644 --- a/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php @@ -10,16 +10,16 @@ class PostgreSQLDatabaseManager extends TenantDatabaseManager { public function createDatabase(TenantWithDatabase $tenant): bool { - return $this->database()->statement("CREATE DATABASE \"{$tenant->database()->getName()}\" WITH TEMPLATE=template0"); + return $this->connection()->statement("CREATE DATABASE \"{$tenant->database()->getName()}\" WITH TEMPLATE=template0"); } public function deleteDatabase(TenantWithDatabase $tenant): bool { - return $this->database()->statement("DROP DATABASE \"{$tenant->database()->getName()}\""); + return $this->connection()->statement("DROP DATABASE \"{$tenant->database()->getName()}\""); } public function databaseExists(string $name): bool { - return (bool) $this->database()->selectOne("SELECT datname FROM pg_database WHERE datname = '$name'"); + return (bool) $this->connection()->selectOne("SELECT datname FROM pg_database WHERE datname = '$name'"); } } diff --git a/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php index a7558e1b..d0fb0337 100644 --- a/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php +++ b/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php @@ -10,17 +10,17 @@ class PostgreSQLSchemaManager extends TenantDatabaseManager { public function createDatabase(TenantWithDatabase $tenant): bool { - return $this->database()->statement("CREATE SCHEMA \"{$tenant->database()->getName()}\""); + return $this->connection()->statement("CREATE SCHEMA \"{$tenant->database()->getName()}\""); } public function deleteDatabase(TenantWithDatabase $tenant): bool { - return $this->database()->statement("DROP SCHEMA \"{$tenant->database()->getName()}\" CASCADE"); + return $this->connection()->statement("DROP SCHEMA \"{$tenant->database()->getName()}\" CASCADE"); } public function databaseExists(string $name): bool { - return (bool) $this->database()->select("SELECT schema_name FROM information_schema.schemata WHERE schema_name = '$name'"); + return (bool) $this->connection()->select("SELECT schema_name FROM information_schema.schemata WHERE schema_name = '$name'"); } public function makeConnectionConfig(array $baseConfig, string $databaseName): array diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index ada5d642..d7fb8da2 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -4,6 +4,10 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\TenantDatabaseManagers; +use AssertionError; +use Closure; +use Illuminate\Database\Eloquent\Model; +use PDO; use Stancl\Tenancy\Database\Contracts\TenantDatabaseManager; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Throwable; @@ -15,10 +19,92 @@ class SQLiteDatabaseManager implements TenantDatabaseManager */ 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 + * connection alive for the desired lifetime. This means it's the + * closure's job to store the connection in a place that lives as + * long as the connection should live. + * + * The closure is called in makeConnectionConfig() -- a method normally + * called shortly before a connection is established. + * + * NOTE: The closure is called EVERY time makeConnectionConfig() + * is called, therefore it's up to the closure to discard + * the connection if a connection to the same database is already persisted. + * + * The closure also receives the DSN used to create the PDO connection, + * since the PDO connection driver makes it a bit hard to recover DB names + * from PDO instances. That should make it easier to match these with + * tenant instances passed to $closeInMemoryConnectionUsing closures, + * if you're setting that property as well. + * + * @property Closure(PDO, string)|null + */ + public static Closure|null $persistInMemoryConnectionUsing = null; + + /* + * The opposite of $persistInMemoryConnectionUsing. This closure + * is called when the tenant is deleted, to clear the database + * in case a tenant with the same ID should be created within + * the lifetime of the $persistInMemoryConnectionUsing logic. + * + * NOTE: The parameter provided to the closure is the Tenant + * instance, not a PDO connection. + * + * @property Closure(Tenant)|null + */ + public static Closure|null $closeInMemoryConnectionUsing = null; + public function createDatabase(TenantWithDatabase $tenant): bool { + /** @var TenantWithDatabase&Model $tenant */ + $name = $tenant->database()->getName(); + + if ($this->isInMemory($name)) { + // If :memory: is used, we update the tenant with a *named* in-memory SQLite connection. + // + // This makes use of the package feasible with in-memory SQLite. Pure :memory: isn't + // sufficient since the tenant creation process involves constant creation and destruction + // of the tenant connection, always clearing the memory (like migrations). Additionally, + // tenancy()->central() calls would close the database since at the moment we close the + // tenant connection (to prevent accidental references to it in the central context) when + // tenancy is ended. + // + // Note that named in-memory databases DO NOT have process lifetime. You need an open + // PDO connection to keep the memory from being cleaned up. It's up to the user how they + // handle this, common solutions may involve storing the connection in the service container + // or creating a closure holding a reference to it and passing that to register_shutdown_function(). + + $name = '_tenancy_inmemory_' . $tenant->getTenantKey(); + $tenant->update(['tenancy_db_name' => "file:$name?mode=memory&cache=shared"]); + + return true; + } + try { - return (bool) file_put_contents($this->getPath($tenant->database()->getName()), ''); + 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; } @@ -26,8 +112,18 @@ class SQLiteDatabaseManager implements TenantDatabaseManager public function deleteDatabase(TenantWithDatabase $tenant): bool { + $name = $tenant->database()->getName(); + + if ($this->isInMemory($name)) { + if (static::$closeInMemoryConnectionUsing) { + (static::$closeInMemoryConnectionUsing)($tenant); + } + + return true; + } + try { - return unlink($this->getPath($tenant->database()->getName())); + return unlink($this->getPath($name)); } catch (Throwable) { return false; } @@ -35,12 +131,21 @@ class SQLiteDatabaseManager implements TenantDatabaseManager public function databaseExists(string $name): bool { - return file_exists($this->getPath($name)); + return $this->isInMemory($name) || file_exists($this->getPath($name)); } public function makeConnectionConfig(array $baseConfig, string $databaseName): array { - $baseConfig['database'] = database_path($databaseName); + if ($this->isInMemory($databaseName)) { + $baseConfig['database'] = $databaseName; + + if (static::$persistInMemoryConnectionUsing !== null) { + $dsn = "sqlite:$databaseName"; + (static::$persistInMemoryConnectionUsing)(new PDO($dsn), $dsn); + } + } else { + $baseConfig['database'] = database_path($databaseName); + } return $baseConfig; } @@ -58,4 +163,9 @@ class SQLiteDatabaseManager implements TenantDatabaseManager return database_path($name); } + + public static function isInMemory(string $name): bool + { + return $name === ':memory:' || str_contains($name, '_tenancy_inmemory_'); + } } diff --git a/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php b/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php index 87916088..3d8d7610 100644 --- a/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php @@ -14,7 +14,7 @@ abstract class TenantDatabaseManager implements StatefulTenantDatabaseManager /** The database connection to the server. */ protected string $connection; - public function database(): Connection + public function connection(): Connection { if (! isset($this->connection)) { throw new NoConnectionSetException(static::class); diff --git a/src/Middleware/InitializeTenancyByDomain.php b/src/Middleware/InitializeTenancyByDomain.php index b9e049a4..a0522c13 100644 --- a/src/Middleware/InitializeTenancyByDomain.php +++ b/src/Middleware/InitializeTenancyByDomain.php @@ -44,7 +44,14 @@ class InitializeTenancyByDomain extends IdentificationMiddleware */ public function requestHasTenant(Request $request): bool { - return ! in_array($this->getDomain($request), config('tenancy.identification.central_domains')); + $domain = $this->getDomain($request); + + // Mainly used with origin identification if the header isn't specified and e.g. universal routes are used + if (! $domain) { + return false; + } + + return ! in_array($domain, config('tenancy.identification.central_domains')); } public function getDomain(Request $request): string diff --git a/src/Middleware/InitializeTenancyByDomainOrSubdomain.php b/src/Middleware/InitializeTenancyByDomainOrSubdomain.php index 845e42b3..aca17abd 100644 --- a/src/Middleware/InitializeTenancyByDomainOrSubdomain.php +++ b/src/Middleware/InitializeTenancyByDomainOrSubdomain.php @@ -8,8 +8,9 @@ use Closure; use Exception; use Illuminate\Http\Request; use Illuminate\Http\Response; -use Illuminate\Support\Str; use Stancl\Tenancy\Concerns\UsableWithEarlyIdentification; +use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException; +use Stancl\Tenancy\Resolvers\DomainTenantResolver; class InitializeTenancyByDomainOrSubdomain extends InitializeTenancyBySubdomain { @@ -23,34 +24,46 @@ class InitializeTenancyByDomainOrSubdomain extends InitializeTenancyBySubdomain } $domain = $this->getDomain($request); + $subdomain = null; - if ($this->isSubdomain($domain)) { - $domain = $this->makeSubdomain($domain); + if (DomainTenantResolver::isSubdomain($domain)) { + $subdomain = $this->makeSubdomain($domain); - if ($domain instanceof Exception) { + if ($subdomain instanceof Exception) { $onFail = static::$onFail ?? function ($e) { throw $e; }; - return $onFail($domain, $request, $next); - } - - // If a Response instance was returned, we return it immediately. - // todo@samuel when does this execute? - if ($domain instanceof Response) { - return $domain; + return $onFail($subdomain, $request, $next); } } - return $this->initializeTenancy( - $request, - $next, - $domain - ); - } + try { + $this->tenancy->initialize( + $this->resolver->resolve($subdomain ?? $domain) + ); + } catch (TenantCouldNotBeIdentifiedException $e) { + if ($subdomain) { + try { + $this->tenancy->initialize( + $this->resolver->resolve($domain) + ); + } catch (TenantCouldNotBeIdentifiedException $e) { + $onFail = static::$onFail ?? function ($e) { + throw $e; + }; - protected function isSubdomain(string $hostname): bool - { - return Str::endsWith($hostname, config('tenancy.identification.central_domains')); + return $onFail($e, $request, $next); + } + } else { + $onFail = static::$onFail ?? function ($e) { + throw $e; + }; + + return $onFail($e, $request, $next); + } + } + + return $next($request); } } diff --git a/src/Middleware/InitializeTenancyBySubdomain.php b/src/Middleware/InitializeTenancyBySubdomain.php index 07a9c68d..ede50ab8 100644 --- a/src/Middleware/InitializeTenancyBySubdomain.php +++ b/src/Middleware/InitializeTenancyBySubdomain.php @@ -8,9 +8,9 @@ use Closure; use Exception; use Illuminate\Http\Request; use Illuminate\Http\Response; -use Illuminate\Support\Str; use Stancl\Tenancy\Concerns\UsableWithEarlyIdentification; use Stancl\Tenancy\Exceptions\NotASubdomainException; +use Stancl\Tenancy\Resolvers\DomainTenantResolver; class InitializeTenancyBySubdomain extends InitializeTenancyByDomain { @@ -57,20 +57,16 @@ class InitializeTenancyBySubdomain extends InitializeTenancyByDomain ); } - /** @return string|Response|Exception|mixed */ + /** @return string|Exception */ protected function makeSubdomain(string $hostname) { $parts = explode('.', $hostname); - $isLocalhost = count($parts) === 1; $isIpAddress = count(array_filter($parts, 'is_numeric')) === count($parts); - - // If we're on localhost or an IP address, then we're not visiting a subdomain. $isACentralDomain = in_array($hostname, config('tenancy.identification.central_domains'), true); - $notADomain = $isLocalhost || $isIpAddress; - $thirdPartyDomain = ! Str::endsWith($hostname, config('tenancy.identification.central_domains')); + $thirdPartyDomain = ! DomainTenantResolver::isSubdomain($hostname); - if ($isACentralDomain || $notADomain || $thirdPartyDomain) { + if ($isACentralDomain || $isIpAddress || $thirdPartyDomain) { return new NotASubdomainException($hostname); } diff --git a/src/Overrides/CacheManager.php b/src/Overrides/CacheManager.php index f712025b..9c78288e 100644 --- a/src/Overrides/CacheManager.php +++ b/src/Overrides/CacheManager.php @@ -27,7 +27,7 @@ class CacheManager extends BaseCacheManager throw new \Exception("Method tags() takes exactly 1 argument. $count passed."); } - $names = $parameters[0]; + $names = array_values($parameters)[0]; $names = (array) $names; // cache()->tags('foo') https://laravel.com/docs/9.x/cache#removing-tagged-cache-items return $this->store()->tags(array_merge($tags, $names)); diff --git a/src/Overrides/TenancyUrlGenerator.php b/src/Overrides/TenancyUrlGenerator.php index 274930b8..7c21ce3f 100644 --- a/src/Overrides/TenancyUrlGenerator.php +++ b/src/Overrides/TenancyUrlGenerator.php @@ -4,8 +4,10 @@ declare(strict_types=1); namespace Stancl\Tenancy\Overrides; +use BackedEnum; use Illuminate\Routing\UrlGenerator; use Illuminate\Support\Arr; +use InvalidArgumentException; use Stancl\Tenancy\Resolvers\PathTenantResolver; /** @@ -51,7 +53,11 @@ class TenancyUrlGenerator extends UrlGenerator */ public function route($name, $parameters = [], $absolute = true) { - [$name, $parameters] = $this->prepareRouteInputs($name, Arr::wrap($parameters)); + if ($name instanceof BackedEnum && ! is_string($name = $name->value)) { // @phpstan-ignore function.impossibleType + throw new InvalidArgumentException('Attribute [name] expects a string backed enum.'); + } + + [$name, $parameters] = $this->prepareRouteInputs($name, Arr::wrap($parameters)); // @phpstan-ignore argument.type $url = parent::route($name, $parameters, $absolute); @@ -79,7 +85,11 @@ class TenancyUrlGenerator extends UrlGenerator */ public function temporarySignedRoute($name, $expiration, $parameters = [], $absolute = true) { - [$name, $parameters] = $this->prepareRouteInputs($name, Arr::wrap($parameters)); + if ($name instanceof BackedEnum && ! is_string($name = $name->value)) { // @phpstan-ignore function.impossibleType + throw new InvalidArgumentException('Attribute [name] expects a string backed enum.'); + } + + [$name, $parameters] = $this->prepareRouteInputs($name, Arr::wrap($parameters)); // @phpstan-ignore argument.type return parent::temporarySignedRoute($name, $expiration, $parameters, $absolute); } diff --git a/src/Resolvers/DomainTenantResolver.php b/src/Resolvers/DomainTenantResolver.php index 34fd4c4f..4d34811e 100644 --- a/src/Resolvers/DomainTenantResolver.php +++ b/src/Resolvers/DomainTenantResolver.php @@ -12,6 +12,7 @@ use Stancl\Tenancy\Contracts\SingleDomainTenant; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException; use Stancl\Tenancy\Tenancy; +use Illuminate\Support\Str; class DomainTenantResolver extends Contracts\CachedTenantResolver { @@ -55,6 +56,11 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver return $tenant; } + public static function isSubdomain(string $domain): bool + { + return Str::endsWith($domain, config('tenancy.identification.central_domains')); + } + public function resolved(Tenant $tenant, mixed ...$args): void { $this->setCurrentDomain($tenant, $args[0]); diff --git a/src/ResourceSyncing/ModelNotSyncMasterException.php b/src/ResourceSyncing/ModelNotSyncMasterException.php index 1022b054..4ddf7a55 100644 --- a/src/ResourceSyncing/ModelNotSyncMasterException.php +++ b/src/ResourceSyncing/ModelNotSyncMasterException.php @@ -12,6 +12,6 @@ class ModelNotSyncMasterException extends Exception { public function __construct(string $class) { - parent::__construct("Model of $class class is not a SyncMaster model. Make sure you're using the central model to make changes to synced resources when you're in the central context"); + parent::__construct("Model of $class class is not a SyncMaster model. Make sure you're using the central model to make changes to synced resources when you're in the central context."); } } diff --git a/src/helpers.php b/src/helpers.php index 35abeaf7..91d55910 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -112,7 +112,7 @@ if (! function_exists('tenant_channel')) { function tenant_channel(string $channelName, Closure $callback, array $options = []): void { // Register '{tenant}.channelName' - Broadcast::channel('{tenant}.' . $channelName, fn ($user, $tenantKey, ...$args) => $callback($user, ...$args), $options); + Broadcast::channel('{tenant}.' . $channelName, $callback, $options); } } @@ -121,17 +121,6 @@ if (! function_exists('global_channel')) { { // Register 'global__channelName' // Global channels are available in both the central and tenant contexts - Broadcast::channel('global__' . $channelName, fn ($user, ...$args) => $callback($user, ...$args), $options); - } -} - -if (! function_exists('universal_channel')) { - function universal_channel(string $channelName, Closure $callback, array $options = []): void - { - // Register 'channelName' - Broadcast::channel($channelName, $callback, $options); - - // Register '{tenant}.channelName' - tenant_channel($channelName, $callback, $options); + Broadcast::channel('global__' . $channelName, $callback, $options); } } diff --git a/t b/t index 1008e0f2..4fd5931c 100755 --- a/t +++ b/t @@ -1,3 +1,3 @@ #!/bin/bash -docker-compose exec -e COLUMNS=$(tput cols) -T test vendor/bin/pest --color=always --no-coverage --filter "$@" +docker compose exec -e COLUMNS=$(tput cols) -T test vendor/bin/pest --color=always --no-coverage --filter "$@" diff --git a/test b/test index ecdbcd9a..b8bd8fa0 100755 --- a/test +++ b/test @@ -1,4 +1,4 @@ #!/bin/bash # --columns doesn't seem to work at the moment, so we're setting it using an environment variable -docker-compose exec -e COLUMNS=$(tput cols) -T test vendor/bin/pest --colors=always "$@" +docker compose exec -e COLUMNS=$(tput cols) -T test vendor/bin/pest --colors=always "$@" diff --git a/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php b/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php index 29a80cbe..4c3ea30a 100644 --- a/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php @@ -48,10 +48,6 @@ test('BroadcastChannelPrefixBootstrapper prefixes the channels events are broadc $table->timestamps(); }); - universal_channel('users.{userId}', function ($user, $userId) { - return User::find($userId)->is($user); - }); - $broadcaster = app(BroadcastManager::class)->driver(); $tenant = Tenant::create(); diff --git a/tests/Bootstrappers/MailTenancyBootstrapper.php b/tests/Bootstrappers/MailConfigBootstrapper.php similarity index 100% rename from tests/Bootstrappers/MailTenancyBootstrapper.php rename to tests/Bootstrappers/MailConfigBootstrapper.php diff --git a/tests/BroadcastingTest.php b/tests/BroadcastingTest.php index 126a1843..ba221307 100644 --- a/tests/BroadcastingTest.php +++ b/tests/BroadcastingTest.php @@ -136,14 +136,18 @@ test('broadcasting channel helpers register channels correctly', function() { // Tenant channel registered – its name is correctly prefixed ("{tenant}.user.{userId}") $tenantChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === "{tenant}.$channelName"); - expect($tenantChannelClosure) - ->not()->toBeNull() // Channel registered - ->not()->toBe($centralChannelClosure); // The tenant channel closure is different – after the auth user, it accepts the tenant ID + expect($tenantChannelClosure)->toBe($centralChannelClosure); // The tenant channels are prefixed with '{tenant}.' // They accept the tenant key, but their closures only run in tenant context when tenancy is initialized // The regular channels don't accept the tenant key, but they also respect the current context // The tenant key is used solely for the name prefixing – the closures can still run in the central context + tenant_channel($channelName, $tenantChannelClosure = function ($user, $tenant, $userName) { + return User::firstWhere('name', $userName)?->is($user) ?? false; + }); + + expect($tenantChannelClosure)->not()->toBe($centralChannelClosure); + expect($tenantChannelClosure($centralUser, $tenant->getTenantKey(), $centralUser->name))->toBeTrue(); expect($tenantChannelClosure($centralUser, $tenant->getTenantKey(), $tenantUser->name))->toBeFalse(); @@ -160,25 +164,6 @@ test('broadcasting channel helpers register channels correctly', function() { expect($getChannels())->toBeEmpty(); - // universal_channel helper registers both the unprefixed and the prefixed broadcasting channel correctly - // Using the tenant_channel helper + basic channel registration (Broadcast::channel()) - universal_channel($channelName, $channelClosure); - - // Regular channel registered correctly - $centralChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === $channelName); - expect($centralChannelClosure)->not()->toBeNull(); - - // Tenant channel registered correctly - $tenantChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === "{tenant}.$channelName"); - expect($tenantChannelClosure) - ->not()->toBeNull() // Channel registered - ->not()->toBe($centralChannelClosure); // The tenant channel callback is different – after the auth user, it accepts the tenant ID - - $broadcastManager->purge($driver); - $broadcastManager->extend($driver, fn () => new NullBroadcaster); - - expect($getChannels())->toBeEmpty(); - // Global channel helper prefixes the channel name with 'global__' global_channel($channelName, $channelClosure); diff --git a/tests/CombinedDomainAndSubdomainIdentificationTest.php b/tests/CombinedDomainAndSubdomainIdentificationTest.php index f7201b3a..85f11182 100644 --- a/tests/CombinedDomainAndSubdomainIdentificationTest.php +++ b/tests/CombinedDomainAndSubdomainIdentificationTest.php @@ -6,62 +6,56 @@ use Illuminate\Support\Facades\Route; use Stancl\Tenancy\Database\Concerns\HasDomains; use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain; use Stancl\Tenancy\Database\Models; +use Stancl\Tenancy\Tests\Etc\Tenant; beforeEach(function () { Route::group([ 'middleware' => InitializeTenancyByDomainOrSubdomain::class, ], function () { - Route::get('/foo/{a}/{b}', function ($a, $b) { - return "$a + $b"; + Route::get('/test', function () { + return tenant('id'); }); }); - - config(['tenancy.models.tenant' => CombinedTenant::class]); }); test('tenant can be identified by subdomain', function () { config(['tenancy.identification.central_domains' => ['localhost']]); - $tenant = CombinedTenant::create([ - 'id' => 'acme', - ]); - - $tenant->domains()->create([ - 'domain' => 'foo', - ]); + $tenant = Tenant::create(['id' => 'acme']); + $tenant->domains()->create(['domain' => 'foo']); expect(tenancy()->initialized)->toBeFalse(); - pest() - ->get('http://foo.localhost/foo/abc/xyz') - ->assertSee('abc + xyz'); - - expect(tenancy()->initialized)->toBeTrue(); - expect(tenant('id'))->toBe('acme'); + pest()->get('http://foo.localhost/test')->assertSee('acme'); }); test('tenant can be identified by domain', function () { config(['tenancy.identification.central_domains' => []]); - $tenant = CombinedTenant::create([ - 'id' => 'acme', - ]); - - $tenant->domains()->create([ - 'domain' => 'foobar.localhost', - ]); + $tenant = Tenant::create(['id' => 'acme']); + $tenant->domains()->create(['domain' => 'foobar.localhost']); expect(tenancy()->initialized)->toBeFalse(); - pest() - ->get('http://foobar.localhost/foo/abc/xyz') - ->assertSee('abc + xyz'); - - expect(tenancy()->initialized)->toBeTrue(); - expect(tenant('id'))->toBe('acme'); + pest()->get('http://foobar.localhost/test')->assertSee('acme'); }); -class CombinedTenant extends Models\Tenant -{ - use HasDomains; -} +test('domain records can be either in domain syntax or subdomain syntax', function () { + config(['tenancy.identification.central_domains' => ['localhost']]); + + $foo = Tenant::create(['id' => 'foo']); + $foo->domains()->create(['domain' => 'foo']); + + $bar = Tenant::create(['id' => 'bar']); + $bar->domains()->create(['domain' => 'bar.localhost']); + + expect(tenancy()->initialized)->toBeFalse(); + + // Subdomain format + pest()->get('http://foo.localhost/test')->assertSee('foo'); + + tenancy()->end(); + + // Domain format + pest()->get('http://bar.localhost/test')->assertSee('bar'); +}); diff --git a/tests/OriginHeaderIdentificationTest.php b/tests/OriginHeaderIdentificationTest.php index a32777da..83737f1f 100644 --- a/tests/OriginHeaderIdentificationTest.php +++ b/tests/OriginHeaderIdentificationTest.php @@ -38,6 +38,12 @@ test('origin identification works', function () { }); test('tenant routes are not accessible on central domains while using origin identification', function () { + $tenant = Tenant::create(); + + $tenant->domains()->create([ + 'domain' => 'foo', + ]); + pest() ->withHeader('Origin', 'localhost') ->post('home') @@ -54,3 +60,50 @@ test('onfail logic can be customized', function() { ->post('home') ->assertSee('onFail message'); }); + +test('origin identification can be used with universal routes', function () { + $tenant = Tenant::create(); + + $tenant->domains()->create([ + 'domain' => 'foo', + ]); + + Route::post('/universal', function () { + return response(tenant('id') ?? 'central'); + })->middleware([InitializeTenancyByOriginHeader::class, 'universal'])->name('universal'); + + pest() + ->withHeader('Origin', 'foo.localhost') + ->post('universal') + ->assertSee($tenant->id); + + tenancy()->end(); + + pest() + ->withHeader('Origin', 'localhost') + ->post('universal') + ->assertSee('central'); + + pest() + // no header + ->post('universal') + ->assertSee('central'); +}); + +test('origin identification can be used with both domains and subdomains', function () { + $foo = Tenant::create(); + $foo->domains()->create(['domain' => 'foo']); + + $bar = Tenant::create(); + $bar->domains()->create(['domain' => 'bar.localhost']); + + pest() + ->withHeader('Origin', 'foo.localhost') + ->post('home') + ->assertSee($foo->id); + + pest() + ->withHeader('Origin', 'bar.localhost') + ->post('home') + ->assertSee($bar->id); +}); diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 43196ec2..0b5376d9 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -145,6 +145,36 @@ 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()); + + $tenant = Tenant::create([ + 'tenancy_db_connection' => 'sqlite', + ]); + + $dbPath = database_path($tenant->database()->getName()); + + expect(file_exists($dbPath))->toBeTrue(); + + $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); + + // cleanup + SQLiteDatabaseManager::$WAL = true; +})->with([true, false, null]); + test('schema manager uses schema to separate tenant dbs', function () { config([ 'tenancy.database.managers.pgsql' => PostgreSQLSchemaManager::class, @@ -332,7 +362,7 @@ test('database credentials can be provided to PermissionControlledMySQLDatabaseM /** @var PermissionControlledMySQLDatabaseManager $manager */ $manager = $tenant->database()->manager(); - expect($manager->database()->getConfig('username'))->toBe($username); // user created for the HOST connection + expect($manager->connection()->getConfig('username'))->toBe($username); // user created for the HOST connection expect($manager->userExists($usernameForNewDB))->toBeTrue(); expect($manager->databaseExists($name))->toBeTrue(); }); @@ -371,7 +401,7 @@ test('tenant database can be created by using the username and password from ten /** @var MySQLDatabaseManager $manager */ $manager = $tenant->database()->manager(); - expect($manager->database()->getConfig('username'))->toBe($username); // user created for the HOST connection + expect($manager->connection()->getConfig('username'))->toBe($username); // user created for the HOST connection expect($manager->databaseExists($name))->toBeTrue(); }); @@ -417,7 +447,7 @@ test('the tenant connection template can be specified either by name or as a con /** @var MySQLDatabaseManager $manager */ $manager = $tenant->database()->manager(); expect($manager->databaseExists($name))->toBeTrue(); - expect($manager->database()->getConfig('host'))->toBe('mysql'); + expect($manager->connection()->getConfig('host'))->toBe('mysql'); config([ 'tenancy.database.template_tenant_connection' => [ @@ -446,7 +476,7 @@ test('the tenant connection template can be specified either by name or as a con /** @var MySQLDatabaseManager $manager */ $manager = $tenant->database()->manager(); expect($manager->databaseExists($name))->toBeTrue(); // tenant connection works - expect($manager->database()->getConfig('host'))->toBe('mysql2'); + expect($manager->connection()->getConfig('host'))->toBe('mysql2'); }); test('partial tenant connection templates get merged into the central connection template', function () { @@ -471,8 +501,8 @@ test('partial tenant connection templates get merged into the central connection /** @var MySQLDatabaseManager $manager */ $manager = $tenant->database()->manager(); expect($manager->databaseExists($name))->toBeTrue(); // tenant connection works - expect($manager->database()->getConfig('host'))->toBe('mysql2'); - expect($manager->database()->getConfig('url'))->toBeNull(); + expect($manager->connection()->getConfig('host'))->toBe('mysql2'); + expect($manager->connection()->getConfig('url'))->toBeNull(); }); // Datasets diff --git a/typedefs/FFI.php b/typedefs/FFI.php new file mode 100644 index 00000000..16123957 --- /dev/null +++ b/typedefs/FFI.php @@ -0,0 +1,8 @@ +