1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-02-05 15:54:03 +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
/test 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
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

View file

@ -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",

View file

@ -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

View file

@ -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#'

View file

@ -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'));

View file

@ -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.');
}
}

View file

@ -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;
}

View file

@ -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));

View file

@ -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<covariant int|string, \Stancl\Tenancy\Contracts\Tenant&\Illuminate\Database\Eloquent\Model> $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()}");

View file

@ -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));

View file

@ -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<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
{
@ -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<int, \Stancl\Tenancy\Contracts\Tenant&\Illuminate\Database\Eloquent\Model> $chunk */
/** @var array<int, \Stancl\Tenancy\Contracts\Tenant&\Illuminate\Database\Eloquent\Model> $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();
$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.");

View file

@ -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, [

View file

@ -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}'");
}
}

View file

@ -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.

View file

@ -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
*/

View file

@ -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) {

View file

@ -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'");
}
}

View file

@ -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'");
}
}

View file

@ -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);
}

View file

@ -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(*)'};
}
}

View file

@ -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;
}

View file

@ -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(<<<SQL
$primaryKey = $this->connection()->selectOne(<<<SQL
SELECT column_name
FROM information_schema.key_column_usage
WHERE table_name = '{$tableName}'
@ -44,11 +44,11 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage
SQL)->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;

View file

@ -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'");
}
}

View file

@ -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

View file

@ -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_');
}
}

View file

@ -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);

View file

@ -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

View file

@ -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);
}
}

View file

@ -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);
}

View file

@ -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));

View file

@ -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);
}

View file

@ -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]);

View file

@ -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.");
}
}

View file

@ -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);
}
}

2
t
View file

@ -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 "$@"

2
test
View file

@ -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 "$@"

View file

@ -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();

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}")
$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);

View file

@ -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');
});

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 () {
$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);
});

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'));
});
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

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) {}
}