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

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