1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-02-05 16:34:04 +00:00

Merge branch 'master' into fix-url-bootstrappers

This commit is contained in:
Samuel Štancl 2024-11-25 04:51:57 +01:00
commit 58cdae908a
44 changed files with 584 additions and 260 deletions

1
.gitattributes vendored
View file

@ -22,3 +22,4 @@
/t export-ignore /t export-ignore
/test export-ignore /test export-ignore
/tests export-ignore /tests export-ignore
/typedefs export-ignore

View file

@ -1,45 +1,33 @@
FROM shivammathur/node:latest
SHELL ["/bin/bash", "-c"]
ARG PHP_VERSION=8.3 ARG PHP_VERSION=8.3
WORKDIR /var/www/html FROM php:${PHP_VERSION}-cli-bookworm
SHELL ["/bin/bash", "-c"]
# our default timezone and language RUN apt-get update && apt-get install -y --no-install-recommends \
ENV TZ=Europe/London git unzip libzip-dev libicu-dev libmemcached-dev zlib1g-dev libssl-dev sqlite3 libsqlite3-dev libpq-dev mariadb-client
ENV LANG=en_GB.UTF-8
# install MSSQL ODBC driver (1/2) RUN apt-get install -y gnupg2 \
RUN apt-get update \ && curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \
&& apt-get install -y gnupg2 \ && curl https://packages.microsoft.com/config/debian/12/prod.list > /etc/apt/sources.list.d/mssql-release.list \
&& curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add - \ && apt-get update \
&& curl https://packages.microsoft.com/config/ubuntu/20.04/prod.list > /etc/apt/sources.list.d/mssql-release.list \ && ACCEPT_EULA=Y apt-get install -y unixodbc-dev msodbcsql18
&& apt-get update
# install MSSQL ODBC driver (2/2) RUN apt autoremove && apt clean
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
# set PHP version RUN pecl install apcu && docker-php-ext-enable apcu
RUN update-alternatives --set php /usr/bin/php$PHP_VERSION \ RUN pecl install pcov && docker-php-ext-enable pcov
&& update-alternatives --set phar /usr/bin/phar$PHP_VERSION \ RUN pecl install redis && docker-php-ext-enable redis
&& update-alternatives --set phar.phar /usr/bin/phar.phar$PHP_VERSION \ RUN pecl install memcached && docker-php-ext-enable memcached
&& update-alternatives --set phpize /usr/bin/phpize$PHP_VERSION \ RUN pecl install pdo_sqlsrv && docker-php-ext-enable pdo_sqlsrv
&& update-alternatives --set php-config /usr/bin/php-config$PHP_VERSION 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 COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
# set the system timezone WORKDIR /var/www/html
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

View file

@ -4,6 +4,7 @@
"keywords": [ "keywords": [
"laravel", "laravel",
"multi-tenancy", "multi-tenancy",
"multitenancy",
"multi-database", "multi-database",
"tenancy" "tenancy"
], ],
@ -25,7 +26,7 @@
"stancl/jobpipeline": "2.0.0-rc2", "stancl/jobpipeline": "2.0.0-rc2",
"stancl/virtualcolumn": "dev-master", "stancl/virtualcolumn": "dev-master",
"spatie/invade": "^1.1", "spatie/invade": "^1.1",
"laravel/prompts": "^0.1.9" "laravel/prompts": "0.*"
}, },
"require-dev": { "require-dev": {
"laravel/framework": "^10.1|^11.3", "laravel/framework": "^10.1|^11.3",
@ -63,11 +64,14 @@
} }
}, },
"scripts": { "scripts": {
"docker-up": "docker-compose up -d", "docker-up": "docker compose up -d",
"docker-down": "docker-compose down", "docker-down": "docker compose down",
"docker-restart": "docker-compose down && docker-compose up -d", "docker-restart": "docker compose down && docker compose up -d",
"docker-rebuild": "PHP_VERSION=8.3 docker-compose up -d --no-deps --build", "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", "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", "coverage": "open coverage/phpunit/html/index.html",
"phpstan": "vendor/bin/phpstan", "phpstan": "vendor/bin/phpstan",
"phpstan-pro": "vendor/bin/phpstan --pro", "phpstan-pro": "vendor/bin/phpstan --pro",

View file

@ -2,8 +2,6 @@ services:
test: test:
build: build:
context: . context: .
args:
PHP_VERSION: ${PHP_VERSION:-8.3}
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy
@ -20,7 +18,7 @@ services:
dynamodb: dynamodb:
condition: service_healthy condition: service_healthy
volumes: volumes:
- .:/var/www/html:delegated - .:/var/www/html:cached
environment: environment:
DOCKER: 1 DOCKER: 1
DB_PASSWORD: password DB_PASSWORD: password

View file

@ -16,6 +16,7 @@ parameters:
ignoreErrors: ignoreErrors:
- -
identifier: missingType.iterableValue identifier: missingType.iterableValue
- '#FFI#'
- '#Return type(.*?) of method Stancl\\Tenancy\\Database\\Models\\Tenant\:\:newCollection\(\) should be compatible with return type#' - '#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#' - '#Method Stancl\\Tenancy\\Database\\Models\\Tenant\:\:newCollection\(\) should return#'
- '#Cannot access offset (.*?) on Illuminate\\Contracts\\Foundation\\Application#' - '#Cannot access offset (.*?) on Illuminate\\Contracts\\Foundation\\Application#'

View file

@ -27,6 +27,7 @@ class JobBatchBootstrapper implements TenancyBootstrapper
public function bootstrap(Tenant $tenant): void public function bootstrap(Tenant $tenant): void
{ {
// todo@revisit
// Update batch repository connection to use the tenant connection // Update batch repository connection to use the tenant connection
$this->previousConnection = $this->batchRepository->getConnection(); $this->previousConnection = $this->batchRepository->getConnection();
$this->batchRepository->setConnection($this->databaseManager->connection('tenant')); $this->batchRepository->setConnection($this->databaseManager->connection('tenant'));

View file

@ -83,7 +83,7 @@ class CreateUserWithRLSPolicies extends Command
$manager->setConnection($tenantModel->database()->getTenantHostConnectionName()); $manager->setConnection($tenantModel->database()->getTenantHostConnectionName());
// Set the database name (= central schema name/search_path in this case), username, and password // 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_username', $username);
$tenantModel->setInternal('db_password', $password); $tenantModel->setInternal('db_password', $password);
@ -142,9 +142,9 @@ class CreateUserWithRLSPolicies extends Command
$this->components->bulletList($createdPolicies); $this->components->bulletList($createdPolicies);
$this->components->info('RLS policies updated successfully.'); $this->components->success('RLS policies updated successfully.');
} else { } else {
$this->components->info('All RLS policies are up to date.'); $this->components->success('All RLS policies are up to date.');
} }
} }

View file

@ -52,9 +52,11 @@ class Install extends Command
newLineAfter: true, newLineAfter: true,
); );
$this->components->info('✨️ Tenancy for Laravel successfully installed.'); $this->components->success('✨️ Tenancy for Laravel successfully installed.');
if (! $this->option('no-interaction')) {
$this->askForSupport(); $this->askForSupport();
}
return 0; return 0;
} }

View file

@ -16,6 +16,7 @@ use Stancl\Tenancy\Concerns\ParallelCommand;
use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException; use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException;
use Stancl\Tenancy\Events\DatabaseMigrated; use Stancl\Tenancy\Events\DatabaseMigrated;
use Stancl\Tenancy\Events\MigratingDatabase; use Stancl\Tenancy\Events\MigratingDatabase;
use Symfony\Component\Console\Output\OutputInterface as OI;
class Migrate extends MigrateCommand class Migrate extends MigrateCommand
{ {
@ -52,7 +53,7 @@ class Migrate extends MigrateCommand
if ($this->getProcesses() > 1) { if ($this->getProcesses() > 1) {
return $this->runConcurrently($this->getTenantChunks()->map(function ($chunk) { return $this->runConcurrently($this->getTenantChunks()->map(function ($chunk) {
return $this->getTenants($chunk->all()); return $this->getTenants($chunk);
})); }));
} }
@ -80,10 +81,26 @@ class Migrate extends MigrateCommand
$tenant->run(function ($tenant) use (&$success) { $tenant->run(function ($tenant) use (&$success) {
event(new MigratingDatabase($tenant)); event(new MigratingDatabase($tenant));
$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 // Migrate
if (parent::handle() !== 0) { if (parent::handle() !== 0) {
$success = false; $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)); event(new DatabaseMigrated($tenant));
}); });

View file

@ -38,7 +38,7 @@ class MigrateFresh extends BaseCommand
if ($this->getProcesses() > 1) { if ($this->getProcesses() > 1) {
return $this->runConcurrently($this->getTenantChunks()->map(function ($chunk) { 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<covariant int|string, \Stancl\Tenancy\Contracts\Tenant&\Illuminate\Database\Eloquent\Model> $tenants * @param LazyCollection<covariant int|string, \Stancl\Tenancy\Contracts\Tenant&\Illuminate\Database\Eloquent\Model> $tenants
*/ */
protected function migrateFreshTenants(LazyCollection $tenants): bool protected function migrateFreshTenants(LazyCollection $tenants): bool
@ -89,6 +91,8 @@ class MigrateFresh extends BaseCommand
foreach ($tenants as $tenant) { foreach ($tenants as $tenant) {
try { try {
$this->components->info("Migrating (fresh) tenant {$tenant->getTenantKey()}");
$tenant->run(function ($tenant) use (&$success) { $tenant->run(function ($tenant) use (&$success) {
$this->components->info("Wiping database of tenant {$tenant->getTenantKey()}", OI::VERBOSITY_VERY_VERBOSE); $this->components->info("Wiping database of tenant {$tenant->getTenantKey()}", OI::VERBOSITY_VERY_VERBOSE);
if ($this->wipeDB()) { if ($this->wipeDB()) {
@ -105,6 +109,8 @@ class MigrateFresh extends BaseCommand
$success = false; $success = false;
$this->components->error("Migrating database of tenant {$tenant->getTenantKey()} failed!"); $this->components->error("Migrating database of tenant {$tenant->getTenantKey()} failed!");
} }
$this->components->success("Migrated (fresh) tenant {$tenant->getTenantKey()}");
}); });
} catch (TenantDatabaseDoesNotExistException|QueryException $e) { } catch (TenantDatabaseDoesNotExistException|QueryException $e) {
$this->components->error("Migration failed for tenant {$tenant->getTenantKey()}: {$e->getMessage()}"); $this->components->error("Migration failed for tenant {$tenant->getTenantKey()}: {$e->getMessage()}");

View file

@ -15,6 +15,7 @@ use Stancl\Tenancy\Concerns\ParallelCommand;
use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException; use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException;
use Stancl\Tenancy\Events\DatabaseRolledBack; use Stancl\Tenancy\Events\DatabaseRolledBack;
use Stancl\Tenancy\Events\RollingBackDatabase; use Stancl\Tenancy\Events\RollingBackDatabase;
use Symfony\Component\Console\Output\OutputInterface as OI;
class Rollback extends RollbackCommand class Rollback extends RollbackCommand
{ {
@ -42,7 +43,7 @@ class Rollback extends RollbackCommand
if ($this->getProcesses() > 1) { if ($this->getProcesses() > 1) {
return $this->runConcurrently($this->getTenantChunks()->map(function ($chunk) { return $this->runConcurrently($this->getTenantChunks()->map(function ($chunk) {
return $this->getTenants($chunk->all()); return $this->getTenants($chunk);
})); }));
} }
@ -70,15 +71,30 @@ class Rollback extends RollbackCommand
foreach ($tenants as $tenant) { foreach ($tenants as $tenant) {
try { try {
$this->components->info("Tenant {$tenant->getTenantKey()}"); $this->components->info("Rolling back tenant {$tenant->getTenantKey()}");
$tenant->run(function ($tenant) use (&$success) { $tenant->run(function ($tenant) use (&$success) {
event(new RollingBackDatabase($tenant)); event(new RollingBackDatabase($tenant));
$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 // Rollback
if (parent::handle() !== 0) { if (parent::handle() !== 0) {
$success = false; $success = false;
} }
} finally {
$this->output->setVerbosity($verbosity);
}
if ($this->runningConcurrently) {
$this->components->success("Rolled back tenant {$tenant->getTenantKey()}");
}
event(new DatabaseRolledBack($tenant)); event(new DatabaseRolledBack($tenant));
}); });

View file

@ -5,6 +5,9 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Concerns; namespace Stancl\Tenancy\Concerns;
use ArrayAccess; use ArrayAccess;
use Countable;
use Exception;
use FFI;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
@ -12,16 +15,35 @@ use Symfony\Component\Console\Input\InputOption;
trait ParallelCommand trait ParallelCommand
{ {
public const MAX_PROCESSES = 24; public const MAX_PROCESSES = 24;
protected bool $runningConcurrently = false;
abstract protected function childHandle(mixed ...$args): bool; abstract protected function childHandle(mixed ...$args): bool;
public function addProcessesOption(): void 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 protected function forkProcess(mixed ...$args): int
{ {
if (! app()->runningInConsole()) {
throw new Exception('Parallel commands are only available in CLI context.');
}
$pid = pcntl_fork(); $pid = pcntl_fork();
if ($pid === -1) { 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 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)) { if ($processes === -1) {
$this->components->error('Maximum value for processes is ' . static::MAX_PROCESSES); $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); exit(1);
} }
if ($processes > 1 && ! function_exists('pcntl_fork')) { if ($processes > 1 && ! function_exists('pcntl_fork')) {
$this->components->error('The pcntl extension is required for parallel migrations to work.'); $this->components->error('The pcntl extension is required for parallel migrations to work.');
exit(1);
} }
return $processes; return $processes;
} }
/** /**
* @return Collection<int, Collection<int, \Stancl\Tenancy\Contracts\Tenant&\Illuminate\Database\Eloquent\Model>>> * @return Collection<int, array<int, \Stancl\Tenancy\Contracts\Tenant&\Illuminate\Database\Eloquent\Model>>>
*/ */
protected function getTenantChunks(): Collection protected function getTenantChunks(): Collection
{ {
@ -64,20 +146,26 @@ trait ParallelCommand
return $tenants->chunk((int) ceil($tenants->count() / $this->getProcesses()))->map(function ($chunk) { return $tenants->chunk((int) ceil($tenants->count() / $this->getProcesses()))->map(function ($chunk) {
$chunk = array_values($chunk->all()); $chunk = array_values($chunk->all());
/** @var Collection<int, \Stancl\Tenancy\Contracts\Tenant&\Illuminate\Database\Eloquent\Model> $chunk */ /** @var array<int, \Stancl\Tenancy\Contracts\Tenant&\Illuminate\Database\Eloquent\Model> $chunk */
return $chunk; return $chunk;
}); });
} }
/** /**
* @param array|ArrayAccess<int, mixed>|null $args * @param array|(ArrayAccess<int, mixed>&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(); $processes = $this->getProcesses();
$success = true; $success = true;
$pids = []; $pids = [];
if ($args !== null && count($args) < $processes) {
$processes = count($args);
}
$this->runningConcurrently = true;
for ($i = 0; $i < $processes; $i++) { for ($i = 0; $i < $processes; $i++) {
$pid = $this->forkProcess($args !== null ? $args[$i] : null); $pid = $this->forkProcess($args !== null ? $args[$i] : null);
@ -101,7 +189,7 @@ trait ParallelCommand
$exitCode = pcntl_wexitstatus($status); $exitCode = pcntl_wexitstatus($status);
if ($exitCode === 0) { if ($exitCode === 0) {
$this->components->info("Child process [$i] (PID $pid) finished successfully."); $this->components->success("Child process [$i] (PID $pid) finished successfully.");
} else { } else {
$success = false; $success = false;
$this->components->error("Child process [$i] (PID $pid) completed with failures."); $this->components->error("Child process [$i] (PID $pid) completed with failures.");

View file

@ -73,20 +73,15 @@ trait HasPending
} }
/** Try to pull a tenant from the pool of pending tenants. */ /** 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()) { /** @var (Model&Tenant)|null $tenant */
if (! $firstOrCreate) {
return null;
}
static::createPending($attributes);
}
// A pending tenant is surely available at this point
/** @var Model&Tenant $tenant */
$tenant = static::onlyPending()->first(); $tenant = static::onlyPending()->first();
if ($tenant === null) {
return $firstOrCreate ? static::create($attributes) : null;
}
event(new PullingPendingTenant($tenant)); event(new PullingPendingTenant($tenant));
$tenant->update(array_merge($attributes, [ $tenant->update(array_merge($attributes, [

View file

@ -30,7 +30,7 @@ trait ManagesPostgresUsers
$createUser = ! $this->userExists($username); $createUser = ! $this->userExists($username);
if ($createUser) { if ($createUser) {
$this->database()->statement("CREATE USER \"{$username}\" LOGIN PASSWORD '{$password}'"); $this->connection()->statement("CREATE USER \"{$username}\" LOGIN PASSWORD '{$password}'");
} }
$this->grantPermissions($databaseConfig); $this->grantPermissions($databaseConfig);
@ -46,38 +46,38 @@ trait ManagesPostgresUsers
$username = $databaseConfig->getUsername(); $username = $databaseConfig->getUsername();
// Tenant host connection config // Tenant host connection config
$connectionName = $this->database()->getConfig('name'); $connectionName = $this->connection()->getConfig('name');
$centralDatabase = $this->database()->getConfig('database'); $centralDatabase = $this->connection()->getConfig('database');
// Set the DB/schema name to the tenant DB/schema name // Set the DB/schema name to the tenant DB/schema name
config()->set( config()->set(
"database.connections.{$connectionName}", "database.connections.{$connectionName}",
$this->makeConnectionConfig($this->database()->getConfig(), $databaseConfig->getName()), $this->makeConnectionConfig($this->connection()->getConfig(), $databaseConfig->getName()),
); );
// Connect to the tenant DB/schema // Connect to the tenant DB/schema
$this->database()->reconnect(); $this->connection()->reconnect();
// Delete all database objects owned by the user (privileges, tables, views, etc.) // 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 // 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 // Delete the user
$userDeleted = $this->database()->statement("DROP USER \"{$username}\""); $userDeleted = $this->connection()->statement("DROP USER \"{$username}\"");
config()->set( config()->set(
"database.connections.{$connectionName}", "database.connections.{$connectionName}",
$this->makeConnectionConfig($this->database()->getConfig(), $centralDatabase), $this->makeConnectionConfig($this->connection()->getConfig(), $centralDatabase),
); );
// Reconnect to the central database // Reconnect to the central database
$this->database()->reconnect(); $this->connection()->reconnect();
return $userDeleted; return $userDeleted;
} }
public function userExists(string $username): bool 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}'");
} }
} }

View file

@ -13,7 +13,7 @@ use Stancl\Tenancy\Database\Exceptions\NoConnectionSetException;
interface StatefulTenantDatabaseManager extends TenantDatabaseManager interface StatefulTenantDatabaseManager extends TenantDatabaseManager
{ {
/** Get the DB connection used by the tenant database manager. */ /** 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. * Set the DB connection that should be used by the tenant database manager.

View file

@ -25,21 +25,21 @@ class DatabaseConfig
/** /**
* Database username generator (can be set by the developer.). * Database username generator (can be set by the developer.).
* *
* @var Closure(Model&Tenant): string * @var Closure(Model&Tenant, self): string
*/ */
public static Closure $usernameGenerator; public static Closure $usernameGenerator;
/** /**
* Database password generator (can be set by the developer.). * Database password generator (can be set by the developer.).
* *
* @var Closure(Model&Tenant): string * @var Closure(Model&Tenant, self): string
*/ */
public static Closure $passwordGenerator; public static Closure $passwordGenerator;
/** /**
* Database name generator (can be set by the developer.). * Database name generator (can be set by the developer.).
* *
* @var Closure(Model&Tenant): string * @var Closure(Model&Tenant, self): string
*/ */
public static Closure $databaseNameGenerator; public static Closure $databaseNameGenerator;
@ -58,8 +58,14 @@ class DatabaseConfig
} }
if (! isset(static::$databaseNameGenerator)) { if (! isset(static::$databaseNameGenerator)) {
static::$databaseNameGenerator = function (Model&Tenant $tenant) { static::$databaseNameGenerator = function (Model&Tenant $tenant, self $self) {
return config('tenancy.database.prefix') . $tenant->getTenantKey() . config('tenancy.database.suffix'); $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 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 public function getUsername(): ?string
@ -110,8 +116,8 @@ class DatabaseConfig
$this->tenant->setInternal('db_name', $this->getName()); $this->tenant->setInternal('db_name', $this->getName());
if ($this->connectionDriverManager($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) { if ($this->connectionDriverManager($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) {
$this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($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->tenant->setInternal('db_password', $this->getPassword() ?? (static::$passwordGenerator)($this->tenant, $this));
} }
if ($this->tenant->exists) { if ($this->tenant->exists) {
@ -192,7 +198,8 @@ class DatabaseConfig
DB::purge($this->getTenantHostConnectionName()); DB::purge($this->getTenantHostConnectionName());
} }
/** Get the TenantDatabaseManager for this tenant's connection. /**
* Get the TenantDatabaseManager for this tenant's connection.
* *
* @throws NoConnectionSetException|DatabaseManagerNotRegisteredException * @throws NoConnectionSetException|DatabaseManagerNotRegisteredException
*/ */

View file

@ -9,6 +9,7 @@ use Illuminate\Contracts\Foundation\Application;
use Illuminate\Database\DatabaseManager as BaseDatabaseManager; use Illuminate\Database\DatabaseManager as BaseDatabaseManager;
use Stancl\Tenancy\Contracts\TenantCannotBeCreatedException; use Stancl\Tenancy\Contracts\TenantCannotBeCreatedException;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\TenantDatabaseManagers\SQLiteDatabaseManager;
/** /**
* @internal Class is subject to breaking changes in minor and patch versions. * @internal Class is subject to breaking changes in minor and patch versions.
@ -71,8 +72,10 @@ class DatabaseManager
$manager = $tenant->database()->manager(); $manager = $tenant->database()->manager();
if ($manager->databaseExists($database = $tenant->database()->getName())) { if ($manager->databaseExists($database = $tenant->database()->getName())) {
if (! $manager instanceof SQLiteDatabaseManager || ! SQLiteDatabaseManager::isInMemory($database)) {
throw new Exceptions\TenantDatabaseAlreadyExistsException($database); throw new Exceptions\TenantDatabaseAlreadyExistsException($database);
} }
}
if ($manager instanceof Contracts\ManagesDatabaseUsers) { if ($manager instanceof Contracts\ManagesDatabaseUsers) {
/** @var string $username */ /** @var string $username */

View file

@ -11,19 +11,19 @@ class MicrosoftSQLDatabaseManager extends TenantDatabaseManager
public function createDatabase(TenantWithDatabase $tenant): bool public function createDatabase(TenantWithDatabase $tenant): bool
{ {
$database = $tenant->database()->getName(); $database = $tenant->database()->getName();
$charset = $this->database()->getConfig('charset'); $charset = $this->connection()->getConfig('charset');
$collation = $this->database()->getConfig('collation'); // todo check why these are not used $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 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 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'");
} }
} }

View file

@ -11,19 +11,19 @@ class MySQLDatabaseManager extends TenantDatabaseManager
public function createDatabase(TenantWithDatabase $tenant): bool public function createDatabase(TenantWithDatabase $tenant): bool
{ {
$database = $tenant->database()->getName(); $database = $tenant->database()->getName();
$charset = $this->database()->getConfig('charset'); $charset = $this->connection()->getConfig('charset');
$collation = $this->database()->getConfig('collation'); $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 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 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'");
} }
} }

View file

@ -25,24 +25,24 @@ class PermissionControlledMicrosoftSQLServerDatabaseManager extends MicrosoftSQL
$password = $databaseConfig->getPassword(); $password = $databaseConfig->getPassword();
// Create login // 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 // Create user in the database
// Grant the user permissions specified in the $grants array // Grant the user permissions specified in the $grants array
// The 'CONNECT' permission is granted automatically // The 'CONNECT' permission is granted automatically
$grants = implode(', ', static::$grants); $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 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 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 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 // Set the database to SINGLE_USER mode to ensure that
// No other connections are using the database while we're trying to delete it // No other connections are using the database while we're trying to delete it
// Rollback all active transactions // 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); return parent::deleteDatabase($tenant);
} }

View file

@ -26,7 +26,7 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl
$hostname = $databaseConfig->connection()['host']; $hostname = $databaseConfig->connection()['host'];
$password = $databaseConfig->getPassword(); $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); $grants = implode(', ', static::$grants);
@ -36,24 +36,24 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl
$grantQuery = "GRANT $grants ON `$database`.* TO `$username`@`%` IDENTIFIED BY '$password'"; $grantQuery = "GRANT $grants ON `$database`.* TO `$username`@`%` IDENTIFIED BY '$password'";
} }
return $this->database()->statement($grantQuery); return $this->connection()->statement($grantQuery);
} }
protected function isVersion8(): bool protected function isVersion8(): bool
{ {
$versionSelect = (string) $this->database()->raw('select version()')->getValue($this->database()->getQueryGrammar()); $versionSelect = (string) $this->connection()->raw('select version()')->getValue($this->connection()->getQueryGrammar());
$version = $this->database()->select($versionSelect)[0]->{'version()'}; $version = $this->connection()->select($versionSelect)[0]->{'version()'};
return version_compare($version, '8.0.0') >= 0; return version_compare($version, '8.0.0') >= 0;
} }
public function deleteUser(DatabaseConfig $databaseConfig): bool 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 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(*)'};
} }
} }

View file

@ -21,26 +21,26 @@ class PermissionControlledPostgreSQLDatabaseManager extends PostgreSQLDatabaseMa
$schema = $databaseConfig->connection()['search_path']; $schema = $databaseConfig->connection()['search_path'];
// Host config // Host config
$connectionName = $this->database()->getConfig('name'); $connectionName = $this->connection()->getConfig('name');
$centralDatabase = $this->database()->getConfig('database'); $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 // Connect to tenant database
config(["database.connections.{$connectionName}.database" => $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 // 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 // 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 // Reconnect to central database
config(["database.connections.{$connectionName}.database" => $centralDatabase]); config(["database.connections.{$connectionName}.database" => $centralDatabase]);
$this->database()->reconnect(); $this->connection()->reconnect();
return true; return true;
} }

View file

@ -23,11 +23,11 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage
// Central database name // Central database name
$database = DB::connection(config('tenancy.database.central_connection'))->getDatabaseName(); $database = DB::connection(config('tenancy.database.central_connection'))->getDatabaseName();
$this->database()->statement("GRANT CONNECT ON DATABASE {$database} TO \"{$username}\""); $this->connection()->statement("GRANT CONNECT ON DATABASE {$database} TO \"{$username}\"");
$this->database()->statement("GRANT USAGE, CREATE ON SCHEMA \"{$schema}\" TO \"{$username}\""); $this->connection()->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 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 // Grant permissions to any existing tables. This is used with RLS
// todo@samuel refactor this along with the todo in TenantDatabaseManager // todo@samuel refactor this along with the todo in TenantDatabaseManager
@ -36,7 +36,7 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage
$tableName = $table->table_name; $tableName = $table->table_name;
/** @var string $primaryKey */ /** @var string $primaryKey */
$primaryKey = $this->database()->selectOne(<<<SQL $primaryKey = $this->connection()->selectOne(<<<SQL
SELECT column_name SELECT column_name
FROM information_schema.key_column_usage FROM information_schema.key_column_usage
WHERE table_name = '{$tableName}' WHERE table_name = '{$tableName}'
@ -44,11 +44,11 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage
SQL)->column_name; SQL)->column_name;
// Grant all permissions for all existing tables // 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 // 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 // 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; return true;

View file

@ -10,16 +10,16 @@ class PostgreSQLDatabaseManager extends TenantDatabaseManager
{ {
public function createDatabase(TenantWithDatabase $tenant): bool 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 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 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'");
} }
} }

View file

@ -10,17 +10,17 @@ class PostgreSQLSchemaManager extends TenantDatabaseManager
{ {
public function createDatabase(TenantWithDatabase $tenant): bool 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 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 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 public function makeConnectionConfig(array $baseConfig, string $databaseName): array

View file

@ -4,6 +4,10 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\TenantDatabaseManagers; 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\TenantDatabaseManager;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Throwable; use Throwable;
@ -15,10 +19,92 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
*/ */
public static string|null $path = null; 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 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 { 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) { } catch (Throwable) {
return false; return false;
} }
@ -26,8 +112,18 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
public function deleteDatabase(TenantWithDatabase $tenant): bool public function deleteDatabase(TenantWithDatabase $tenant): bool
{ {
$name = $tenant->database()->getName();
if ($this->isInMemory($name)) {
if (static::$closeInMemoryConnectionUsing) {
(static::$closeInMemoryConnectionUsing)($tenant);
}
return true;
}
try { try {
return unlink($this->getPath($tenant->database()->getName())); return unlink($this->getPath($name));
} catch (Throwable) { } catch (Throwable) {
return false; return false;
} }
@ -35,12 +131,21 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
public function databaseExists(string $name): bool 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 public function makeConnectionConfig(array $baseConfig, string $databaseName): array
{ {
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); $baseConfig['database'] = database_path($databaseName);
}
return $baseConfig; return $baseConfig;
} }
@ -58,4 +163,9 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
return database_path($name); return database_path($name);
} }
public static function isInMemory(string $name): bool
{
return $name === ':memory:' || str_contains($name, '_tenancy_inmemory_');
}
} }

View file

@ -14,7 +14,7 @@ abstract class TenantDatabaseManager implements StatefulTenantDatabaseManager
/** The database connection to the server. */ /** The database connection to the server. */
protected string $connection; protected string $connection;
public function database(): Connection public function connection(): Connection
{ {
if (! isset($this->connection)) { if (! isset($this->connection)) {
throw new NoConnectionSetException(static::class); throw new NoConnectionSetException(static::class);

View file

@ -44,7 +44,14 @@ class InitializeTenancyByDomain extends IdentificationMiddleware
*/ */
public function requestHasTenant(Request $request): bool 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 public function getDomain(Request $request): string

View file

@ -8,8 +8,9 @@ use Closure;
use Exception; use Exception;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Str;
use Stancl\Tenancy\Concerns\UsableWithEarlyIdentification; use Stancl\Tenancy\Concerns\UsableWithEarlyIdentification;
use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException;
use Stancl\Tenancy\Resolvers\DomainTenantResolver;
class InitializeTenancyByDomainOrSubdomain extends InitializeTenancyBySubdomain class InitializeTenancyByDomainOrSubdomain extends InitializeTenancyBySubdomain
{ {
@ -23,34 +24,46 @@ class InitializeTenancyByDomainOrSubdomain extends InitializeTenancyBySubdomain
} }
$domain = $this->getDomain($request); $domain = $this->getDomain($request);
$subdomain = null;
if ($this->isSubdomain($domain)) { if (DomainTenantResolver::isSubdomain($domain)) {
$domain = $this->makeSubdomain($domain); $subdomain = $this->makeSubdomain($domain);
if ($domain instanceof Exception) { if ($subdomain instanceof Exception) {
$onFail = static::$onFail ?? function ($e) { $onFail = static::$onFail ?? function ($e) {
throw $e; throw $e;
}; };
return $onFail($domain, $request, $next); return $onFail($subdomain, $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 $this->initializeTenancy( try {
$request, $this->tenancy->initialize(
$next, $this->resolver->resolve($subdomain ?? $domain)
$domain
); );
} catch (TenantCouldNotBeIdentifiedException $e) {
if ($subdomain) {
try {
$this->tenancy->initialize(
$this->resolver->resolve($domain)
);
} catch (TenantCouldNotBeIdentifiedException $e) {
$onFail = static::$onFail ?? function ($e) {
throw $e;
};
return $onFail($e, $request, $next);
}
} else {
$onFail = static::$onFail ?? function ($e) {
throw $e;
};
return $onFail($e, $request, $next);
}
} }
protected function isSubdomain(string $hostname): bool return $next($request);
{
return Str::endsWith($hostname, config('tenancy.identification.central_domains'));
} }
} }

View file

@ -8,9 +8,9 @@ use Closure;
use Exception; use Exception;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Str;
use Stancl\Tenancy\Concerns\UsableWithEarlyIdentification; use Stancl\Tenancy\Concerns\UsableWithEarlyIdentification;
use Stancl\Tenancy\Exceptions\NotASubdomainException; use Stancl\Tenancy\Exceptions\NotASubdomainException;
use Stancl\Tenancy\Resolvers\DomainTenantResolver;
class InitializeTenancyBySubdomain extends InitializeTenancyByDomain 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) protected function makeSubdomain(string $hostname)
{ {
$parts = explode('.', $hostname); $parts = explode('.', $hostname);
$isLocalhost = count($parts) === 1;
$isIpAddress = count(array_filter($parts, 'is_numeric')) === count($parts); $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); $isACentralDomain = in_array($hostname, config('tenancy.identification.central_domains'), true);
$notADomain = $isLocalhost || $isIpAddress; $thirdPartyDomain = ! DomainTenantResolver::isSubdomain($hostname);
$thirdPartyDomain = ! Str::endsWith($hostname, config('tenancy.identification.central_domains'));
if ($isACentralDomain || $notADomain || $thirdPartyDomain) { if ($isACentralDomain || $isIpAddress || $thirdPartyDomain) {
return new NotASubdomainException($hostname); return new NotASubdomainException($hostname);
} }

View file

@ -27,7 +27,7 @@ class CacheManager extends BaseCacheManager
throw new \Exception("Method tags() takes exactly 1 argument. $count passed."); 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 $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)); return $this->store()->tags(array_merge($tags, $names));

View file

@ -4,8 +4,10 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Overrides; namespace Stancl\Tenancy\Overrides;
use BackedEnum;
use Illuminate\Routing\UrlGenerator; use Illuminate\Routing\UrlGenerator;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use InvalidArgumentException;
use Stancl\Tenancy\Resolvers\PathTenantResolver; use Stancl\Tenancy\Resolvers\PathTenantResolver;
/** /**
@ -51,7 +53,11 @@ class TenancyUrlGenerator extends UrlGenerator
*/ */
public function route($name, $parameters = [], $absolute = true) 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); $url = parent::route($name, $parameters, $absolute);
@ -79,7 +85,11 @@ class TenancyUrlGenerator extends UrlGenerator
*/ */
public function temporarySignedRoute($name, $expiration, $parameters = [], $absolute = true) 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); return parent::temporarySignedRoute($name, $expiration, $parameters, $absolute);
} }

View file

@ -12,6 +12,7 @@ use Stancl\Tenancy\Contracts\SingleDomainTenant;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
use Stancl\Tenancy\Tenancy; use Stancl\Tenancy\Tenancy;
use Illuminate\Support\Str;
class DomainTenantResolver extends Contracts\CachedTenantResolver class DomainTenantResolver extends Contracts\CachedTenantResolver
{ {
@ -55,6 +56,11 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver
return $tenant; 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 public function resolved(Tenant $tenant, mixed ...$args): void
{ {
$this->setCurrentDomain($tenant, $args[0]); $this->setCurrentDomain($tenant, $args[0]);

View file

@ -12,6 +12,6 @@ class ModelNotSyncMasterException extends Exception
{ {
public function __construct(string $class) 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.");
} }
} }

View file

@ -112,7 +112,7 @@ if (! function_exists('tenant_channel')) {
function tenant_channel(string $channelName, Closure $callback, array $options = []): void function tenant_channel(string $channelName, Closure $callback, array $options = []): void
{ {
// Register '{tenant}.channelName' // 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' // Register 'global__channelName'
// Global channels are available in both the central and tenant contexts // Global channels are available in both the central and tenant contexts
Broadcast::channel('global__' . $channelName, fn ($user, ...$args) => $callback($user, ...$args), $options); Broadcast::channel('global__' . $channelName, $callback, $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);
} }
} }

2
t
View file

@ -1,3 +1,3 @@
#!/bin/bash #!/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 "$@"

2
test
View file

@ -1,4 +1,4 @@
#!/bin/bash #!/bin/bash
# --columns doesn't seem to work at the moment, so we're setting it using an environment variable # --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 "$@"

View file

@ -48,10 +48,6 @@ test('BroadcastChannelPrefixBootstrapper prefixes the channels events are broadc
$table->timestamps(); $table->timestamps();
}); });
universal_channel('users.{userId}', function ($user, $userId) {
return User::find($userId)->is($user);
});
$broadcaster = app(BroadcastManager::class)->driver(); $broadcaster = app(BroadcastManager::class)->driver();
$tenant = Tenant::create(); $tenant = Tenant::create();

View file

@ -136,14 +136,18 @@ test('broadcasting channel helpers register channels correctly', function() {
// Tenant channel registered its name is correctly prefixed ("{tenant}.user.{userId}") // Tenant channel registered its name is correctly prefixed ("{tenant}.user.{userId}")
$tenantChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === "{tenant}.$channelName"); $tenantChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === "{tenant}.$channelName");
expect($tenantChannelClosure) expect($tenantChannelClosure)->toBe($centralChannelClosure);
->not()->toBeNull() // Channel registered
->not()->toBe($centralChannelClosure); // The tenant channel closure is different after the auth user, it accepts the tenant ID
// The tenant channels are prefixed with '{tenant}.' // The tenant channels are prefixed with '{tenant}.'
// They accept the tenant key, but their closures only run in tenant context when tenancy is initialized // 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 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 // 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(), $centralUser->name))->toBeTrue();
expect($tenantChannelClosure($centralUser, $tenant->getTenantKey(), $tenantUser->name))->toBeFalse(); expect($tenantChannelClosure($centralUser, $tenant->getTenantKey(), $tenantUser->name))->toBeFalse();
@ -160,25 +164,6 @@ test('broadcasting channel helpers register channels correctly', function() {
expect($getChannels())->toBeEmpty(); 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 helper prefixes the channel name with 'global__'
global_channel($channelName, $channelClosure); global_channel($channelName, $channelClosure);

View file

@ -6,62 +6,56 @@ use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Database\Concerns\HasDomains; use Stancl\Tenancy\Database\Concerns\HasDomains;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain; use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain;
use Stancl\Tenancy\Database\Models; use Stancl\Tenancy\Database\Models;
use Stancl\Tenancy\Tests\Etc\Tenant;
beforeEach(function () { beforeEach(function () {
Route::group([ Route::group([
'middleware' => InitializeTenancyByDomainOrSubdomain::class, 'middleware' => InitializeTenancyByDomainOrSubdomain::class,
], function () { ], function () {
Route::get('/foo/{a}/{b}', function ($a, $b) { Route::get('/test', function () {
return "$a + $b"; return tenant('id');
}); });
}); });
config(['tenancy.models.tenant' => CombinedTenant::class]);
}); });
test('tenant can be identified by subdomain', function () { test('tenant can be identified by subdomain', function () {
config(['tenancy.identification.central_domains' => ['localhost']]); config(['tenancy.identification.central_domains' => ['localhost']]);
$tenant = CombinedTenant::create([ $tenant = Tenant::create(['id' => 'acme']);
'id' => 'acme', $tenant->domains()->create(['domain' => 'foo']);
]);
$tenant->domains()->create([
'domain' => 'foo',
]);
expect(tenancy()->initialized)->toBeFalse(); expect(tenancy()->initialized)->toBeFalse();
pest() pest()->get('http://foo.localhost/test')->assertSee('acme');
->get('http://foo.localhost/foo/abc/xyz')
->assertSee('abc + xyz');
expect(tenancy()->initialized)->toBeTrue();
expect(tenant('id'))->toBe('acme');
}); });
test('tenant can be identified by domain', function () { test('tenant can be identified by domain', function () {
config(['tenancy.identification.central_domains' => []]); config(['tenancy.identification.central_domains' => []]);
$tenant = CombinedTenant::create([ $tenant = Tenant::create(['id' => 'acme']);
'id' => 'acme', $tenant->domains()->create(['domain' => 'foobar.localhost']);
]);
$tenant->domains()->create([
'domain' => 'foobar.localhost',
]);
expect(tenancy()->initialized)->toBeFalse(); expect(tenancy()->initialized)->toBeFalse();
pest() pest()->get('http://foobar.localhost/test')->assertSee('acme');
->get('http://foobar.localhost/foo/abc/xyz')
->assertSee('abc + xyz');
expect(tenancy()->initialized)->toBeTrue();
expect(tenant('id'))->toBe('acme');
}); });
class CombinedTenant extends Models\Tenant test('domain records can be either in domain syntax or subdomain syntax', function () {
{ config(['tenancy.identification.central_domains' => ['localhost']]);
use HasDomains;
} $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');
});

View file

@ -38,6 +38,12 @@ test('origin identification works', function () {
}); });
test('tenant routes are not accessible on central domains while using origin identification', function () { test('tenant routes are not accessible on central domains while using origin identification', function () {
$tenant = Tenant::create();
$tenant->domains()->create([
'domain' => 'foo',
]);
pest() pest()
->withHeader('Origin', 'localhost') ->withHeader('Origin', 'localhost')
->post('home') ->post('home')
@ -54,3 +60,50 @@ test('onfail logic can be customized', function() {
->post('home') ->post('home')
->assertSee('onFail message'); ->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);
});

View file

@ -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')); 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 () { test('schema manager uses schema to separate tenant dbs', function () {
config([ config([
'tenancy.database.managers.pgsql' => PostgreSQLSchemaManager::class, 'tenancy.database.managers.pgsql' => PostgreSQLSchemaManager::class,
@ -332,7 +362,7 @@ test('database credentials can be provided to PermissionControlledMySQLDatabaseM
/** @var PermissionControlledMySQLDatabaseManager $manager */ /** @var PermissionControlledMySQLDatabaseManager $manager */
$manager = $tenant->database()->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->userExists($usernameForNewDB))->toBeTrue();
expect($manager->databaseExists($name))->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 */ /** @var MySQLDatabaseManager $manager */
$manager = $tenant->database()->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(); 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 */ /** @var MySQLDatabaseManager $manager */
$manager = $tenant->database()->manager(); $manager = $tenant->database()->manager();
expect($manager->databaseExists($name))->toBeTrue(); expect($manager->databaseExists($name))->toBeTrue();
expect($manager->database()->getConfig('host'))->toBe('mysql'); expect($manager->connection()->getConfig('host'))->toBe('mysql');
config([ config([
'tenancy.database.template_tenant_connection' => [ '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 */ /** @var MySQLDatabaseManager $manager */
$manager = $tenant->database()->manager(); $manager = $tenant->database()->manager();
expect($manager->databaseExists($name))->toBeTrue(); // tenant connection works 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 () { 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 */ /** @var MySQLDatabaseManager $manager */
$manager = $tenant->database()->manager(); $manager = $tenant->database()->manager();
expect($manager->databaseExists($name))->toBeTrue(); // tenant connection works expect($manager->databaseExists($name))->toBeTrue(); // tenant connection works
expect($manager->database()->getConfig('host'))->toBe('mysql2'); expect($manager->connection()->getConfig('host'))->toBe('mysql2');
expect($manager->database()->getConfig('url'))->toBeNull(); expect($manager->connection()->getConfig('url'))->toBeNull();
}); });
// Datasets // Datasets

8
typedefs/FFI.php Normal file
View file

@ -0,0 +1,8 @@
<?php
// Stub for Intelephense support
class FFI
{
public function __call($name, $arguments) {}
public function __callStatic($name, $arguments) {}
}