From 8b41ea3184d772071ef4d89b0caf9181868175e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Fri, 30 Aug 2024 19:25:54 +0200 Subject: [PATCH 01/26] install command: support noninteractive use --- src/Commands/Install.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Commands/Install.php b/src/Commands/Install.php index 09e720bb..6a67fa3f 100644 --- a/src/Commands/Install.php +++ b/src/Commands/Install.php @@ -54,7 +54,9 @@ class Install extends Command $this->components->info('✨️ Tenancy for Laravel successfully installed.'); - $this->askForSupport(); + if (! $this->option('no-interaction')) { + $this->askForSupport(); + } return 0; } From f3e01c1581d6c0a1467e7ed3f50950e2321079a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 10 Sep 2024 12:02:14 +0200 Subject: [PATCH 02/26] fix docblock formatting --- src/Database/DatabaseConfig.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Database/DatabaseConfig.php b/src/Database/DatabaseConfig.php index 460da048..068e2182 100644 --- a/src/Database/DatabaseConfig.php +++ b/src/Database/DatabaseConfig.php @@ -192,7 +192,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 */ From 0fc105487bfcb6c1b49c269d495f6b7a7af8522e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 12 Sep 2024 18:34:45 +0200 Subject: [PATCH 03/26] Tenant DB manager database() -> connection() --- src/Commands/CreateUserWithRLSPolicies.php | 2 +- .../Concerns/ManagesPostgresUsers.php | 20 +++++++++---------- .../StatefulTenantDatabaseManager.php | 2 +- .../MicrosoftSQLDatabaseManager.php | 10 +++++----- .../MySQLDatabaseManager.php | 10 +++++----- ...olledMicrosoftSQLServerDatabaseManager.php | 10 +++++----- ...rmissionControlledMySQLDatabaseManager.php | 12 +++++------ ...ionControlledPostgreSQLDatabaseManager.php | 14 ++++++------- ...ssionControlledPostgreSQLSchemaManager.php | 14 ++++++------- .../PostgreSQLDatabaseManager.php | 6 +++--- .../PostgreSQLSchemaManager.php | 6 +++--- .../TenantDatabaseManager.php | 2 +- tests/TenantDatabaseManagerTest.php | 12 +++++------ 13 files changed, 60 insertions(+), 60 deletions(-) diff --git a/src/Commands/CreateUserWithRLSPolicies.php b/src/Commands/CreateUserWithRLSPolicies.php index 45b25278..3ad83a86 100644 --- a/src/Commands/CreateUserWithRLSPolicies.php +++ b/src/Commands/CreateUserWithRLSPolicies.php @@ -83,7 +83,7 @@ class CreateUserWithRLSPolicies extends Command $manager->setConnection($tenantModel->database()->getTenantHostConnectionName()); // Set the database name (= central schema name/search_path in this case), username, and password - $tenantModel->setInternal('db_name', $manager->database()->getConfig('search_path')); + $tenantModel->setInternal('db_name', $manager->connection()->getConfig('search_path')); $tenantModel->setInternal('db_username', $username); $tenantModel->setInternal('db_password', $password); diff --git a/src/Database/Concerns/ManagesPostgresUsers.php b/src/Database/Concerns/ManagesPostgresUsers.php index f73bac1c..c798fe13 100644 --- a/src/Database/Concerns/ManagesPostgresUsers.php +++ b/src/Database/Concerns/ManagesPostgresUsers.php @@ -30,7 +30,7 @@ trait ManagesPostgresUsers $createUser = ! $this->userExists($username); if ($createUser) { - $this->database()->statement("CREATE USER \"{$username}\" LOGIN PASSWORD '{$password}'"); + $this->connection()->statement("CREATE USER \"{$username}\" LOGIN PASSWORD '{$password}'"); } $this->grantPermissions($databaseConfig); @@ -46,38 +46,38 @@ trait ManagesPostgresUsers $username = $databaseConfig->getUsername(); // Tenant host connection config - $connectionName = $this->database()->getConfig('name'); - $centralDatabase = $this->database()->getConfig('database'); + $connectionName = $this->connection()->getConfig('name'); + $centralDatabase = $this->connection()->getConfig('database'); // Set the DB/schema name to the tenant DB/schema name config()->set( "database.connections.{$connectionName}", - $this->makeConnectionConfig($this->database()->getConfig(), $databaseConfig->getName()), + $this->makeConnectionConfig($this->connection()->getConfig(), $databaseConfig->getName()), ); // Connect to the tenant DB/schema - $this->database()->reconnect(); + $this->connection()->reconnect(); // Delete all database objects owned by the user (privileges, tables, views, etc.) // Postgres users cannot be deleted unless we delete all objects owned by it first - $this->database()->statement("DROP OWNED BY \"{$username}\""); + $this->connection()->statement("DROP OWNED BY \"{$username}\""); // Delete the user - $userDeleted = $this->database()->statement("DROP USER \"{$username}\""); + $userDeleted = $this->connection()->statement("DROP USER \"{$username}\""); config()->set( "database.connections.{$connectionName}", - $this->makeConnectionConfig($this->database()->getConfig(), $centralDatabase), + $this->makeConnectionConfig($this->connection()->getConfig(), $centralDatabase), ); // Reconnect to the central database - $this->database()->reconnect(); + $this->connection()->reconnect(); return $userDeleted; } public function userExists(string $username): bool { - return (bool) $this->database()->selectOne("SELECT usename FROM pg_user WHERE usename = '{$username}'"); + return (bool) $this->connection()->selectOne("SELECT usename FROM pg_user WHERE usename = '{$username}'"); } } diff --git a/src/Database/Contracts/StatefulTenantDatabaseManager.php b/src/Database/Contracts/StatefulTenantDatabaseManager.php index 6716e9eb..9bda3de2 100644 --- a/src/Database/Contracts/StatefulTenantDatabaseManager.php +++ b/src/Database/Contracts/StatefulTenantDatabaseManager.php @@ -13,7 +13,7 @@ use Stancl\Tenancy\Database\Exceptions\NoConnectionSetException; interface StatefulTenantDatabaseManager extends TenantDatabaseManager { /** Get the DB connection used by the tenant database manager. */ - public function database(): Connection; // todo@dbRefactor rename to connection() + public function connection(): Connection; /** * Set the DB connection that should be used by the tenant database manager. diff --git a/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php index 65950baa..1e5426ea 100644 --- a/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php @@ -11,19 +11,19 @@ class MicrosoftSQLDatabaseManager extends TenantDatabaseManager public function createDatabase(TenantWithDatabase $tenant): bool { $database = $tenant->database()->getName(); - $charset = $this->database()->getConfig('charset'); - $collation = $this->database()->getConfig('collation'); // todo check why these are not used + $charset = $this->connection()->getConfig('charset'); + $collation = $this->connection()->getConfig('collation'); // todo check why these are not used - return $this->database()->statement("CREATE DATABASE [{$database}]"); + return $this->connection()->statement("CREATE DATABASE [{$database}]"); } public function deleteDatabase(TenantWithDatabase $tenant): bool { - return $this->database()->statement("DROP DATABASE [{$tenant->database()->getName()}]"); + return $this->connection()->statement("DROP DATABASE [{$tenant->database()->getName()}]"); } public function databaseExists(string $name): bool { - return (bool) $this->database()->select("SELECT name FROM master.sys.databases WHERE name = '$name'"); + return (bool) $this->connection()->select("SELECT name FROM master.sys.databases WHERE name = '$name'"); } } diff --git a/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php index c96c162b..b86faef2 100644 --- a/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php @@ -11,19 +11,19 @@ class MySQLDatabaseManager extends TenantDatabaseManager public function createDatabase(TenantWithDatabase $tenant): bool { $database = $tenant->database()->getName(); - $charset = $this->database()->getConfig('charset'); - $collation = $this->database()->getConfig('collation'); + $charset = $this->connection()->getConfig('charset'); + $collation = $this->connection()->getConfig('collation'); - return $this->database()->statement("CREATE DATABASE `{$database}` CHARACTER SET `$charset` COLLATE `$collation`"); + return $this->connection()->statement("CREATE DATABASE `{$database}` CHARACTER SET `$charset` COLLATE `$collation`"); } public function deleteDatabase(TenantWithDatabase $tenant): bool { - return $this->database()->statement("DROP DATABASE `{$tenant->database()->getName()}`"); + return $this->connection()->statement("DROP DATABASE `{$tenant->database()->getName()}`"); } public function databaseExists(string $name): bool { - return (bool) $this->database()->select("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = '$name'"); + return (bool) $this->connection()->select("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = '$name'"); } } diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php index aec43d46..b373f41e 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php @@ -25,24 +25,24 @@ class PermissionControlledMicrosoftSQLServerDatabaseManager extends MicrosoftSQL $password = $databaseConfig->getPassword(); // Create login - $this->database()->statement("CREATE LOGIN [$username] WITH PASSWORD = '$password'"); + $this->connection()->statement("CREATE LOGIN [$username] WITH PASSWORD = '$password'"); // Create user in the database // Grant the user permissions specified in the $grants array // The 'CONNECT' permission is granted automatically $grants = implode(', ', static::$grants); - return $this->database()->statement("USE [$database]; CREATE USER [$username] FOR LOGIN [$username]; GRANT $grants TO [$username]"); + return $this->connection()->statement("USE [$database]; CREATE USER [$username] FOR LOGIN [$username]; GRANT $grants TO [$username]"); } public function deleteUser(DatabaseConfig $databaseConfig): bool { - return $this->database()->statement("DROP LOGIN [{$databaseConfig->getUsername()}]"); + return $this->connection()->statement("DROP LOGIN [{$databaseConfig->getUsername()}]"); } public function userExists(string $username): bool { - return (bool) $this->database()->select("SELECT sp.name as username FROM sys.server_principals sp WHERE sp.name = '{$username}'"); + return (bool) $this->connection()->select("SELECT sp.name as username FROM sys.server_principals sp WHERE sp.name = '{$username}'"); } public function makeConnectionConfig(array $baseConfig, string $databaseName): array @@ -58,7 +58,7 @@ class PermissionControlledMicrosoftSQLServerDatabaseManager extends MicrosoftSQL // Set the database to SINGLE_USER mode to ensure that // No other connections are using the database while we're trying to delete it // Rollback all active transactions - $this->database()->statement("ALTER DATABASE [{$tenant->database()->getName()}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;"); + $this->connection()->statement("ALTER DATABASE [{$tenant->database()->getName()}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;"); return parent::deleteDatabase($tenant); } diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php index 308d8786..8ea3e631 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php @@ -26,7 +26,7 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl $hostname = $databaseConfig->connection()['host']; $password = $databaseConfig->getPassword(); - $this->database()->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}'"); + $this->connection()->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}'"); $grants = implode(', ', static::$grants); @@ -36,24 +36,24 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl $grantQuery = "GRANT $grants ON `$database`.* TO `$username`@`%` IDENTIFIED BY '$password'"; } - return $this->database()->statement($grantQuery); + return $this->connection()->statement($grantQuery); } protected function isVersion8(): bool { - $versionSelect = (string) $this->database()->raw('select version()')->getValue($this->database()->getQueryGrammar()); - $version = $this->database()->select($versionSelect)[0]->{'version()'}; + $versionSelect = (string) $this->connection()->raw('select version()')->getValue($this->connection()->getQueryGrammar()); + $version = $this->connection()->select($versionSelect)[0]->{'version()'}; return version_compare($version, '8.0.0') >= 0; } public function deleteUser(DatabaseConfig $databaseConfig): bool { - return $this->database()->statement("DROP USER IF EXISTS '{$databaseConfig->getUsername()}'"); + return $this->connection()->statement("DROP USER IF EXISTS '{$databaseConfig->getUsername()}'"); } public function userExists(string $username): bool { - return (bool) $this->database()->select("SELECT count(*) FROM mysql.user WHERE user = '$username'")[0]->{'count(*)'}; + return (bool) $this->connection()->select("SELECT count(*) FROM mysql.user WHERE user = '$username'")[0]->{'count(*)'}; } } diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLDatabaseManager.php index 982ea4d6..1522234e 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLDatabaseManager.php @@ -21,26 +21,26 @@ class PermissionControlledPostgreSQLDatabaseManager extends PostgreSQLDatabaseMa $schema = $databaseConfig->connection()['search_path']; // Host config - $connectionName = $this->database()->getConfig('name'); - $centralDatabase = $this->database()->getConfig('database'); + $connectionName = $this->connection()->getConfig('name'); + $centralDatabase = $this->connection()->getConfig('database'); - $this->database()->statement("GRANT CONNECT ON DATABASE \"{$database}\" TO \"{$username}\""); + $this->connection()->statement("GRANT CONNECT ON DATABASE \"{$database}\" TO \"{$username}\""); // Connect to tenant database config(["database.connections.{$connectionName}.database" => $database]); - $this->database()->reconnect(); + $this->connection()->reconnect(); // Grant permissions to create and use tables in the configured schema ("public" by default) to the user - $this->database()->statement("GRANT USAGE, CREATE ON SCHEMA {$schema} TO \"{$username}\""); + $this->connection()->statement("GRANT USAGE, CREATE ON SCHEMA {$schema} TO \"{$username}\""); // Grant permissions to use sequences in the current schema to the user - $this->database()->statement("GRANT USAGE ON ALL SEQUENCES IN SCHEMA {$schema} TO \"{$username}\""); + $this->connection()->statement("GRANT USAGE ON ALL SEQUENCES IN SCHEMA {$schema} TO \"{$username}\""); // Reconnect to central database config(["database.connections.{$connectionName}.database" => $centralDatabase]); - $this->database()->reconnect(); + $this->connection()->reconnect(); return true; } diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php index 96693cae..fda4a836 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php @@ -23,11 +23,11 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage // Central database name $database = DB::connection(config('tenancy.database.central_connection'))->getDatabaseName(); - $this->database()->statement("GRANT CONNECT ON DATABASE {$database} TO \"{$username}\""); - $this->database()->statement("GRANT USAGE, CREATE ON SCHEMA \"{$schema}\" TO \"{$username}\""); - $this->database()->statement("GRANT USAGE ON ALL SEQUENCES IN SCHEMA \"{$schema}\" TO \"{$username}\""); + $this->connection()->statement("GRANT CONNECT ON DATABASE {$database} TO \"{$username}\""); + $this->connection()->statement("GRANT USAGE, CREATE ON SCHEMA \"{$schema}\" TO \"{$username}\""); + $this->connection()->statement("GRANT USAGE ON ALL SEQUENCES IN SCHEMA \"{$schema}\" TO \"{$username}\""); - $tables = $this->database()->select("SELECT table_name FROM information_schema.tables WHERE table_schema = '{$schema}'"); + $tables = $this->connection()->select("SELECT table_name FROM information_schema.tables WHERE table_schema = '{$schema}'"); // Grant permissions to any existing tables. This is used with RLS // todo@samuel refactor this along with the todo in TenantDatabaseManager @@ -36,7 +36,7 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage $tableName = $table->table_name; /** @var string $primaryKey */ - $primaryKey = $this->database()->selectOne(<<connection()->selectOne(<<column_name; // Grant all permissions for all existing tables - $this->database()->statement("GRANT ALL ON \"{$schema}\".\"{$tableName}\" TO \"{$username}\""); + $this->connection()->statement("GRANT ALL ON \"{$schema}\".\"{$tableName}\" TO \"{$username}\""); // Grant permission to reference the primary key for the table // The previous query doesn't grant the references privilege, so it has to be granted here - $this->database()->statement("GRANT REFERENCES (\"{$primaryKey}\") ON \"{$schema}\".\"{$tableName}\" TO \"{$username}\""); + $this->connection()->statement("GRANT REFERENCES (\"{$primaryKey}\") ON \"{$schema}\".\"{$tableName}\" TO \"{$username}\""); } return true; diff --git a/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php index 88f1c78c..4fff7202 100644 --- a/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php @@ -10,16 +10,16 @@ class PostgreSQLDatabaseManager extends TenantDatabaseManager { public function createDatabase(TenantWithDatabase $tenant): bool { - return $this->database()->statement("CREATE DATABASE \"{$tenant->database()->getName()}\" WITH TEMPLATE=template0"); + return $this->connection()->statement("CREATE DATABASE \"{$tenant->database()->getName()}\" WITH TEMPLATE=template0"); } public function deleteDatabase(TenantWithDatabase $tenant): bool { - return $this->database()->statement("DROP DATABASE \"{$tenant->database()->getName()}\""); + return $this->connection()->statement("DROP DATABASE \"{$tenant->database()->getName()}\""); } public function databaseExists(string $name): bool { - return (bool) $this->database()->selectOne("SELECT datname FROM pg_database WHERE datname = '$name'"); + return (bool) $this->connection()->selectOne("SELECT datname FROM pg_database WHERE datname = '$name'"); } } diff --git a/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php index a7558e1b..d0fb0337 100644 --- a/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php +++ b/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php @@ -10,17 +10,17 @@ class PostgreSQLSchemaManager extends TenantDatabaseManager { public function createDatabase(TenantWithDatabase $tenant): bool { - return $this->database()->statement("CREATE SCHEMA \"{$tenant->database()->getName()}\""); + return $this->connection()->statement("CREATE SCHEMA \"{$tenant->database()->getName()}\""); } public function deleteDatabase(TenantWithDatabase $tenant): bool { - return $this->database()->statement("DROP SCHEMA \"{$tenant->database()->getName()}\" CASCADE"); + return $this->connection()->statement("DROP SCHEMA \"{$tenant->database()->getName()}\" CASCADE"); } public function databaseExists(string $name): bool { - return (bool) $this->database()->select("SELECT schema_name FROM information_schema.schemata WHERE schema_name = '$name'"); + return (bool) $this->connection()->select("SELECT schema_name FROM information_schema.schemata WHERE schema_name = '$name'"); } public function makeConnectionConfig(array $baseConfig, string $databaseName): array diff --git a/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php b/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php index 87916088..3d8d7610 100644 --- a/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php @@ -14,7 +14,7 @@ abstract class TenantDatabaseManager implements StatefulTenantDatabaseManager /** The database connection to the server. */ protected string $connection; - public function database(): Connection + public function connection(): Connection { if (! isset($this->connection)) { throw new NoConnectionSetException(static::class); diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 43196ec2..559b40b5 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -332,7 +332,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 +371,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 +417,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 +446,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 +471,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 From 0c105c6d377ec2e1d0715086a8464c09a310cd7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 17 Sep 2024 18:29:07 +0200 Subject: [PATCH 04/26] backport ed02943 from v3 --- src/Overrides/CacheManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Overrides/CacheManager.php b/src/Overrides/CacheManager.php index f712025b..9c78288e 100644 --- a/src/Overrides/CacheManager.php +++ b/src/Overrides/CacheManager.php @@ -27,7 +27,7 @@ class CacheManager extends BaseCacheManager throw new \Exception("Method tags() takes exactly 1 argument. $count passed."); } - $names = $parameters[0]; + $names = array_values($parameters)[0]; $names = (array) $names; // cache()->tags('foo') https://laravel.com/docs/9.x/cache#removing-tagged-cache-items return $this->store()->tags(array_merge($tags, $names)); From 5929ff9da2d3480d751be920f24e82497219205c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Fri, 27 Sep 2024 21:03:39 +0200 Subject: [PATCH 05/26] rename mail bootstrapper test file --- .../{MailTenancyBootstrapper.php => MailConfigBootstrapper.php} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/Bootstrappers/{MailTenancyBootstrapper.php => MailConfigBootstrapper.php} (100%) diff --git a/tests/Bootstrappers/MailTenancyBootstrapper.php b/tests/Bootstrappers/MailConfigBootstrapper.php similarity index 100% rename from tests/Bootstrappers/MailTenancyBootstrapper.php rename to tests/Bootstrappers/MailConfigBootstrapper.php From 232f3ceb4e286021c96507f57c30fbd11cccc65d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Fri, 27 Sep 2024 21:13:20 +0200 Subject: [PATCH 06/26] update prompts dependency --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 3a26584f..f25d8c91 100644 --- a/composer.json +++ b/composer.json @@ -4,6 +4,7 @@ "keywords": [ "laravel", "multi-tenancy", + "multitenancy", "multi-database", "tenancy" ], @@ -25,7 +26,7 @@ "stancl/jobpipeline": "2.0.0-rc2", "stancl/virtualcolumn": "dev-master", "spatie/invade": "^1.1", - "laravel/prompts": "^0.1.9" + "laravel/prompts": "0.*" }, "require-dev": { "laravel/framework": "^10.1|^11.3", From b4a055315b5e9ae7fcfa09dcb69ecb5b9e658988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Fri, 27 Sep 2024 22:50:22 +0200 Subject: [PATCH 07/26] improve command output --- src/Commands/CreateUserWithRLSPolicies.php | 4 ++-- src/Commands/Install.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Commands/CreateUserWithRLSPolicies.php b/src/Commands/CreateUserWithRLSPolicies.php index 3ad83a86..fd0338b6 100644 --- a/src/Commands/CreateUserWithRLSPolicies.php +++ b/src/Commands/CreateUserWithRLSPolicies.php @@ -142,9 +142,9 @@ class CreateUserWithRLSPolicies extends Command $this->components->bulletList($createdPolicies); - $this->components->info('RLS policies updated successfully.'); + $this->components->success('RLS policies updated successfully.'); } else { - $this->components->info('All RLS policies are up to date.'); + $this->components->success('All RLS policies are up to date.'); } } diff --git a/src/Commands/Install.php b/src/Commands/Install.php index 6a67fa3f..9f6a9c31 100644 --- a/src/Commands/Install.php +++ b/src/Commands/Install.php @@ -52,7 +52,7 @@ class Install extends Command newLineAfter: true, ); - $this->components->info('✨️ Tenancy for Laravel successfully installed.'); + $this->components->success('✨️ Tenancy for Laravel successfully installed.'); if (! $this->option('no-interaction')) { $this->askForSupport(); From 39bcbda5d037f6bb7a0057e3790bf2952120f86c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Fri, 27 Sep 2024 23:02:03 +0200 Subject: [PATCH 08/26] parallel commands: core # autodetect, bugfixes, improved output --- .gitattributes | 1 + src/Commands/Migrate.php | 25 +++++++-- src/Commands/MigrateFresh.php | 8 ++- src/Commands/Rollback.php | 26 ++++++++-- src/Concerns/ParallelCommand.php | 89 ++++++++++++++++++++++++++++---- typedefs/FFI.php | 8 +++ 6 files changed, 138 insertions(+), 19 deletions(-) create mode 100644 typedefs/FFI.php diff --git a/.gitattributes b/.gitattributes index e0804500..3736c54d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -22,3 +22,4 @@ /t export-ignore /test export-ignore /tests export-ignore +/typedefs export-ignore diff --git a/src/Commands/Migrate.php b/src/Commands/Migrate.php index c3ba37e4..ab428546 100644 --- a/src/Commands/Migrate.php +++ b/src/Commands/Migrate.php @@ -16,6 +16,7 @@ use Stancl\Tenancy\Concerns\ParallelCommand; use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException; use Stancl\Tenancy\Events\DatabaseMigrated; use Stancl\Tenancy\Events\MigratingDatabase; +use Symfony\Component\Console\Output\OutputInterface as OI; class Migrate extends MigrateCommand { @@ -52,7 +53,7 @@ class Migrate extends MigrateCommand if ($this->getProcesses() > 1) { return $this->runConcurrently($this->getTenantChunks()->map(function ($chunk) { - return $this->getTenants($chunk->all()); + return $this->getTenants($chunk); })); } @@ -80,9 +81,25 @@ class Migrate extends MigrateCommand $tenant->run(function ($tenant) use (&$success) { event(new MigratingDatabase($tenant)); - // Migrate - if (parent::handle() !== 0) { - $success = false; + $verbosity = (int) $this->output->getVerbosity(); + + if ($this->runningConcurrently) { + // The output gets messy when multiple processes are writing to the same stdout + $this->output->setVerbosity(OI::VERBOSITY_QUIET); + } + + try { + // Migrate + if (parent::handle() !== 0) { + $success = false; + } + } finally { + $this->output->setVerbosity($verbosity); + } + + if ($this->runningConcurrently) { + // todo@cli the Migrating info above always has extra spaces, and the success below does WHEN there is work that got done by the block above. same in Rollback + $this->components->success("Migrated tenant {$tenant->getTenantKey()}"); } event(new DatabaseMigrated($tenant)); diff --git a/src/Commands/MigrateFresh.php b/src/Commands/MigrateFresh.php index 02e4c189..4e89cefd 100644 --- a/src/Commands/MigrateFresh.php +++ b/src/Commands/MigrateFresh.php @@ -38,7 +38,7 @@ class MigrateFresh extends BaseCommand if ($this->getProcesses() > 1) { return $this->runConcurrently($this->getTenantChunks()->map(function ($chunk) { - return $this->getTenants($chunk->all()); + return $this->getTenants($chunk); })); } @@ -81,6 +81,8 @@ class MigrateFresh extends BaseCommand } /** + * Only used when running concurrently. + * * @param LazyCollection $tenants */ protected function migrateFreshTenants(LazyCollection $tenants): bool @@ -89,6 +91,8 @@ class MigrateFresh extends BaseCommand foreach ($tenants as $tenant) { try { + $this->components->info("Migrating (fresh) tenant {$tenant->getTenantKey()}"); + $tenant->run(function ($tenant) use (&$success) { $this->components->info("Wiping database of tenant {$tenant->getTenantKey()}", OI::VERBOSITY_VERY_VERBOSE); if ($this->wipeDB()) { @@ -105,6 +109,8 @@ class MigrateFresh extends BaseCommand $success = false; $this->components->error("Migrating database of tenant {$tenant->getTenantKey()} failed!"); } + + $this->components->success("Migrated (fresh) tenant {$tenant->getTenantKey()}"); }); } catch (TenantDatabaseDoesNotExistException|QueryException $e) { $this->components->error("Migration failed for tenant {$tenant->getTenantKey()}: {$e->getMessage()}"); diff --git a/src/Commands/Rollback.php b/src/Commands/Rollback.php index 7ea01f08..e7018a5a 100644 --- a/src/Commands/Rollback.php +++ b/src/Commands/Rollback.php @@ -15,6 +15,7 @@ use Stancl\Tenancy\Concerns\ParallelCommand; use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException; use Stancl\Tenancy\Events\DatabaseRolledBack; use Stancl\Tenancy\Events\RollingBackDatabase; +use Symfony\Component\Console\Output\OutputInterface as OI; class Rollback extends RollbackCommand { @@ -42,7 +43,7 @@ class Rollback extends RollbackCommand if ($this->getProcesses() > 1) { return $this->runConcurrently($this->getTenantChunks()->map(function ($chunk) { - return $this->getTenants($chunk->all()); + return $this->getTenants($chunk); })); } @@ -70,14 +71,29 @@ class Rollback extends RollbackCommand foreach ($tenants as $tenant) { try { - $this->components->info("Tenant {$tenant->getTenantKey()}"); + $this->components->info("Rolling back tenant {$tenant->getTenantKey()}"); $tenant->run(function ($tenant) use (&$success) { event(new RollingBackDatabase($tenant)); - // Rollback - if (parent::handle() !== 0) { - $success = false; + $verbosity = (int) $this->output->getVerbosity(); + + if ($this->runningConcurrently) { + // The output gets messy when multiple processes are writing to the same stdout + $this->output->setVerbosity(OI::VERBOSITY_QUIET); + } + + try { + // Rollback + if (parent::handle() !== 0) { + $success = false; + } + } finally { + $this->output->setVerbosity($verbosity); + } + + if ($this->runningConcurrently) { + $this->components->success("Rolled back tenant {$tenant->getTenantKey()}"); } event(new DatabaseRolledBack($tenant)); diff --git a/src/Concerns/ParallelCommand.php b/src/Concerns/ParallelCommand.php index 55383788..1da3e7d9 100644 --- a/src/Concerns/ParallelCommand.php +++ b/src/Concerns/ParallelCommand.php @@ -5,23 +5,37 @@ 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; trait ParallelCommand { - public const MAX_PROCESSES = 24; + public const MAX_PROCESSES = 32; + 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, + ); } 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,16 +51,67 @@ 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.xperflevel0.logicalcpu', FFI::addr($cores), FFI::addr($size), null, 0) === 0) { + return $size->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) == -1) { + 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'), 'processor'), + 'Darwin', 'BSD' => $this->sysctlGetLogicalCoreCount(PHP_OS_FAMILY === 'Darwin'), + }; + } + protected function getProcesses(): int { - $processes = (int) $this->input->getOption('processes'); + $processes = $this->input->getOption('processes'); - if (($processes < 0) || ($processes > static::MAX_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 < 0) { // can come from sysctlGetLogicalCoreCount() + $this->components->error('Minimum value for processes is 1. Try specifying -p manually.'); + exit(1); + } + + if ($processes > static::MAX_PROCESSES) { $this->components->error('Maximum value for processes is ' . static::MAX_PROCESSES); exit(1); } if ($processes > 1 && ! function_exists('pcntl_fork')) { + exit(1); $this->components->error('The pcntl extension is required for parallel migrations to work.'); } @@ -54,7 +119,7 @@ trait ParallelCommand } /** - * @return Collection>> + * @return Collection>> */ protected function getTenantChunks(): Collection { @@ -64,20 +129,26 @@ trait ParallelCommand return $tenants->chunk((int) ceil($tenants->count() / $this->getProcesses()))->map(function ($chunk) { $chunk = array_values($chunk->all()); - /** @var Collection $chunk */ + /** @var array $chunk */ return $chunk; }); } /** - * @param array|ArrayAccess|null $args + * @param array|(ArrayAccess&Countable)|null $args */ - protected function runConcurrently(array|ArrayAccess|null $args = null): int + protected function runConcurrently(array|(ArrayAccess&Countable)|null $args = null): int { $processes = $this->getProcesses(); $success = true; $pids = []; + if (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 +172,7 @@ trait ParallelCommand $exitCode = pcntl_wexitstatus($status); if ($exitCode === 0) { - $this->components->info("Child process [$i] (PID $pid) finished successfully."); + $this->components->success("Child process [$i] (PID $pid) finished successfully."); } else { $success = false; $this->components->error("Child process [$i] (PID $pid) completed with failures."); diff --git a/typedefs/FFI.php b/typedefs/FFI.php new file mode 100644 index 00000000..16123957 --- /dev/null +++ b/typedefs/FFI.php @@ -0,0 +1,8 @@ + Date: Fri, 27 Sep 2024 23:16:39 +0200 Subject: [PATCH 09/26] phpstan fixes --- phpstan.neon | 1 + src/Concerns/ParallelCommand.php | 10 +++++++--- src/Overrides/TenancyUrlGenerator.php | 14 ++++++++++++-- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index fafcc146..c4c667b1 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -16,6 +16,7 @@ parameters: ignoreErrors: - identifier: missingType.iterableValue + - '#FFI#' - '#Return type(.*?) of method Stancl\\Tenancy\\Database\\Models\\Tenant\:\:newCollection\(\) should be compatible with return type#' - '#Method Stancl\\Tenancy\\Database\\Models\\Tenant\:\:newCollection\(\) should return#' - '#Cannot access offset (.*?) on Illuminate\\Contracts\\Foundation\\Application#' diff --git a/src/Concerns/ParallelCommand.php b/src/Concerns/ParallelCommand.php index 1da3e7d9..84bc0658 100644 --- a/src/Concerns/ParallelCommand.php +++ b/src/Concerns/ParallelCommand.php @@ -80,8 +80,12 @@ trait ParallelCommand // 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'), 'processor'), + '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.'), }; } @@ -111,8 +115,8 @@ trait ParallelCommand } if ($processes > 1 && ! function_exists('pcntl_fork')) { - exit(1); $this->components->error('The pcntl extension is required for parallel migrations to work.'); + exit(1); } return $processes; @@ -143,7 +147,7 @@ trait ParallelCommand $success = true; $pids = []; - if (count($args) < $processes) { + if ($args !== null && count($args) < $processes) { $processes = count($args); } diff --git a/src/Overrides/TenancyUrlGenerator.php b/src/Overrides/TenancyUrlGenerator.php index af307021..7c0a7879 100644 --- a/src/Overrides/TenancyUrlGenerator.php +++ b/src/Overrides/TenancyUrlGenerator.php @@ -4,8 +4,10 @@ declare(strict_types=1); namespace Stancl\Tenancy\Overrides; +use BackedEnum; use Illuminate\Routing\UrlGenerator; use Illuminate\Support\Arr; +use InvalidArgumentException; use Stancl\Tenancy\Resolvers\PathTenantResolver; /** @@ -51,7 +53,11 @@ class TenancyUrlGenerator extends UrlGenerator */ public function route($name, $parameters = [], $absolute = true) { - [$name, $parameters] = $this->prepareRouteInputs($name, Arr::wrap($parameters)); + if ($name instanceof BackedEnum && ! is_string($name = $name->value)) { // @phpstan-ignore function.impossibleType + throw new InvalidArgumentException('Attribute [name] expects a string backed enum.'); + } + + [$name, $parameters] = $this->prepareRouteInputs($name, Arr::wrap($parameters)); // @phpstan-ignore argument.type return parent::route($name, $parameters, $absolute); } @@ -62,7 +68,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); } From f017b6509bf63ba30db7f1c6dc2978c0c6214626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Fri, 27 Sep 2024 23:21:08 +0200 Subject: [PATCH 10/26] fix sysctl string --- src/Concerns/ParallelCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Concerns/ParallelCommand.php b/src/Concerns/ParallelCommand.php index 84bc0658..2a220b7e 100644 --- a/src/Concerns/ParallelCommand.php +++ b/src/Concerns/ParallelCommand.php @@ -60,7 +60,7 @@ trait ParallelCommand $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.xperflevel0.logicalcpu', FFI::addr($cores), FFI::addr($size), null, 0) === 0) { + if ($darwin && $ffi->sysctlbyname('hw.perflevel0.logicalcpu', FFI::addr($cores), FFI::addr($size), null, 0) === 0) { return $size->cdata; } else if ($darwin) { // Reset the size in case the pointer got written to (likely shouldn't happen) From 075789eb91cd173d8ddddccdaa2010229a6ef0db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Sun, 29 Sep 2024 12:25:46 +0200 Subject: [PATCH 11/26] fix typo in ParallelCommand --- src/Concerns/ParallelCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Concerns/ParallelCommand.php b/src/Concerns/ParallelCommand.php index 2a220b7e..df355929 100644 --- a/src/Concerns/ParallelCommand.php +++ b/src/Concerns/ParallelCommand.php @@ -61,7 +61,7 @@ trait ParallelCommand // 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 $size->cdata; + 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); From 303b52d25a7e50477e9c67c208737ccfc0de2cad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Sun, 29 Sep 2024 12:42:29 +0200 Subject: [PATCH 12/26] minor improvements to parallelization logic --- src/Concerns/ParallelCommand.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Concerns/ParallelCommand.php b/src/Concerns/ParallelCommand.php index df355929..0807fca3 100644 --- a/src/Concerns/ParallelCommand.php +++ b/src/Concerns/ParallelCommand.php @@ -68,7 +68,7 @@ trait ParallelCommand } // 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) == -1) { + if ($ffi->sysctlbyname('hw.ncpu', FFI::addr($cores), FFI::addr($size), null, 0) !== 0) { return -1; } @@ -104,7 +104,7 @@ trait ParallelCommand $processes = (int) $processes; } - if ($processes < 0) { // can come from sysctlGetLogicalCoreCount() + if ($processes < 1) { $this->components->error('Minimum value for processes is 1. Try specifying -p manually.'); exit(1); } From c6ba62bdd04c5ac63c2a94cf580c2ef0c5368f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 30 Sep 2024 16:36:37 +0200 Subject: [PATCH 13/26] ParallelCommand: add -P option for bypassing MAX_PROCESSES --- src/Concerns/ParallelCommand.php | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/Concerns/ParallelCommand.php b/src/Concerns/ParallelCommand.php index 0807fca3..aad7a1ec 100644 --- a/src/Concerns/ParallelCommand.php +++ b/src/Concerns/ParallelCommand.php @@ -14,7 +14,7 @@ use Symfony\Component\Console\Input\InputOption; trait ParallelCommand { - public const MAX_PROCESSES = 32; + public const MAX_PROCESSES = 24; protected bool $runningConcurrently = false; abstract protected function childHandle(mixed ...$args): bool; @@ -28,6 +28,14 @@ trait ParallelCommand '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 @@ -91,7 +99,12 @@ trait ParallelCommand protected function getProcesses(): int { - $processes = $this->input->getOption('processes'); + $processes = $this->input->getOption('forceProcesses'); + $forceProcesses = $processes !== -1; + + if ($processes === -1) { + $processes = $this->input->getOption('processes'); + } if ($processes === null) { // This is used when the option is set but *without* a value (-p). @@ -109,8 +122,8 @@ trait ParallelCommand exit(1); } - if ($processes > static::MAX_PROCESSES) { - $this->components->error('Maximum value for processes is ' . static::MAX_PROCESSES); + if ($processes > static::MAX_PROCESSES && ! $forceProcesses) { + $this->components->error('Maximum value for processes is ' . static::MAX_PROCESSES . ' provided value: ' . $processes); exit(1); } From 836a258a78a2da9e026c31ec77bcadbf116c067b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 30 Sep 2024 16:49:46 +0200 Subject: [PATCH 14/26] sqlite: use .sqlite suffix if db.suffix is empty --- src/Database/DatabaseConfig.php | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/Database/DatabaseConfig.php b/src/Database/DatabaseConfig.php index 068e2182..bd227864 100644 --- a/src/Database/DatabaseConfig.php +++ b/src/Database/DatabaseConfig.php @@ -25,21 +25,21 @@ class DatabaseConfig /** * Database username generator (can be set by the developer.). * - * @var Closure(Model&Tenant): string + * @var Closure(Model&Tenant, self): string */ public static Closure $usernameGenerator; /** * Database password generator (can be set by the developer.). * - * @var Closure(Model&Tenant): string + * @var Closure(Model&Tenant, self): string */ public static Closure $passwordGenerator; /** * Database name generator (can be set by the developer.). * - * @var Closure(Model&Tenant): string + * @var Closure(Model&Tenant, self): string */ public static Closure $databaseNameGenerator; @@ -58,8 +58,14 @@ class DatabaseConfig } if (! isset(static::$databaseNameGenerator)) { - static::$databaseNameGenerator = function (Model&Tenant $tenant) { - return config('tenancy.database.prefix') . $tenant->getTenantKey() . config('tenancy.database.suffix'); + static::$databaseNameGenerator = function (Model&Tenant $tenant, self $self) { + $suffix = config('tenancy.database.suffix'); + + if (! $suffix && $self->getTemplateConnection()['driver'] === 'sqlite') { + $suffix = '.sqlite'; + } + + return config('tenancy.database.prefix') . $tenant->getTenantKey() . $suffix; }; } } @@ -89,7 +95,7 @@ class DatabaseConfig public function getName(): string { - return $this->tenant->getInternal('db_name') ?? (static::$databaseNameGenerator)($this->tenant); + return $this->tenant->getInternal('db_name') ?? (static::$databaseNameGenerator)($this->tenant, $this); } public function getUsername(): ?string @@ -110,8 +116,8 @@ class DatabaseConfig $this->tenant->setInternal('db_name', $this->getName()); if ($this->connectionDriverManager($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) { - $this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($this->tenant)); - $this->tenant->setInternal('db_password', $this->getPassword() ?? (static::$passwordGenerator)($this->tenant)); + $this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($this->tenant, $this)); + $this->tenant->setInternal('db_password', $this->getPassword() ?? (static::$passwordGenerator)($this->tenant, $this)); } if ($this->tenant->exists) { From a37dc9b44963eaf4ed8008e4d86dfdb24b3f6bd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 3 Oct 2024 21:12:09 +0200 Subject: [PATCH 15/26] reimplement broadcasting channel helpers to support model binding --- .../ModelNotSyncMasterException.php | 2 +- src/helpers.php | 15 ++------------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/src/ResourceSyncing/ModelNotSyncMasterException.php b/src/ResourceSyncing/ModelNotSyncMasterException.php index 1022b054..4ddf7a55 100644 --- a/src/ResourceSyncing/ModelNotSyncMasterException.php +++ b/src/ResourceSyncing/ModelNotSyncMasterException.php @@ -12,6 +12,6 @@ class ModelNotSyncMasterException extends Exception { public function __construct(string $class) { - parent::__construct("Model of $class class is not a SyncMaster model. Make sure you're using the central model to make changes to synced resources when you're in the central context"); + parent::__construct("Model of $class class is not a SyncMaster model. Make sure you're using the central model to make changes to synced resources when you're in the central context."); } } diff --git a/src/helpers.php b/src/helpers.php index 35abeaf7..91d55910 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -112,7 +112,7 @@ if (! function_exists('tenant_channel')) { function tenant_channel(string $channelName, Closure $callback, array $options = []): void { // Register '{tenant}.channelName' - Broadcast::channel('{tenant}.' . $channelName, fn ($user, $tenantKey, ...$args) => $callback($user, ...$args), $options); + Broadcast::channel('{tenant}.' . $channelName, $callback, $options); } } @@ -121,17 +121,6 @@ if (! function_exists('global_channel')) { { // Register 'global__channelName' // Global channels are available in both the central and tenant contexts - Broadcast::channel('global__' . $channelName, fn ($user, ...$args) => $callback($user, ...$args), $options); - } -} - -if (! function_exists('universal_channel')) { - function universal_channel(string $channelName, Closure $callback, array $options = []): void - { - // Register 'channelName' - Broadcast::channel($channelName, $callback, $options); - - // Register '{tenant}.channelName' - tenant_channel($channelName, $callback, $options); + Broadcast::channel('global__' . $channelName, $callback, $options); } } From a52efe5cf0d9cb5ff97ad465b1b1b7ddfab248b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 3 Oct 2024 21:34:05 +0200 Subject: [PATCH 16/26] wip broadcasting test fixes --- composer.json | 2 ++ tests/BroadcastingTest.php | 8 +++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index f25d8c91..e510b395 100644 --- a/composer.json +++ b/composer.json @@ -69,6 +69,8 @@ "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", "coverage": "open coverage/phpunit/html/index.html", "phpstan": "vendor/bin/phpstan", "phpstan-pro": "vendor/bin/phpstan --pro", diff --git a/tests/BroadcastingTest.php b/tests/BroadcastingTest.php index 126a1843..bd15df18 100644 --- a/tests/BroadcastingTest.php +++ b/tests/BroadcastingTest.php @@ -136,9 +136,11 @@ 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); + + // todo: fix tests from below here as the closure is now NOT modified, the $tenant parameter is expected (when additional parameters are used) and kept + // and the universal_channel() helper was removed (get rid of tests related to testing the helper's behavior, but tests which use universal channels should + // be kept -- the two channels should just be registered by hand per docs instead of using the now removed helper) // The tenant channels are prefixed with '{tenant}.' // They accept the tenant key, but their closures only run in tenant context when tenancy is initialized From 43d821ca495329a7419a8fb922d16967862e57b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Fri, 11 Oct 2024 21:29:54 +0200 Subject: [PATCH 17/26] docker: base Dockerfile on php images, misc improvements --- Dockerfile | 69 ++++++++++++++++++---------------------------- composer.json | 8 +++--- docker-compose.yml | 4 +-- t | 2 +- test | 2 +- 5 files changed, 34 insertions(+), 51 deletions(-) diff --git a/Dockerfile b/Dockerfile index c7e129ad..988e3e02 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,45 +1,30 @@ -FROM shivammathur/node:latest -SHELL ["/bin/bash", "-c"] - ARG PHP_VERSION=8.3 +FROM php:${PHP_VERSION}-cli-bookworm +SHELL ["/bin/bash", "-c"] + +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 + +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 + +RUN apt autoremove && apt clean + +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 + +RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" +RUN echo "apc.enable_cli=1" >> "$PHP_INI_DIR/php.ini" + WORKDIR /var/www/html - -# our default timezone and language -ENV TZ=Europe/London -ENV LANG=en_GB.UTF-8 - -# 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 - -# 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 - -# 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 - -# install composer -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 diff --git a/composer.json b/composer.json index e510b395..7bafdce4 100644 --- a/composer.json +++ b/composer.json @@ -64,10 +64,10 @@ } }, "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", diff --git a/docker-compose.yml b/docker-compose.yml index b0dc0187..a4857cbf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,8 +2,6 @@ services: test: build: context: . - args: - PHP_VERSION: ${PHP_VERSION:-8.3} depends_on: mysql: condition: service_healthy @@ -20,7 +18,7 @@ services: dynamodb: condition: service_healthy volumes: - - .:/var/www/html:delegated + - .:/var/www/html:cached environment: DOCKER: 1 DB_PASSWORD: password diff --git a/t b/t index 1008e0f2..4fd5931c 100755 --- a/t +++ b/t @@ -1,3 +1,3 @@ #!/bin/bash -docker-compose exec -e COLUMNS=$(tput cols) -T test vendor/bin/pest --color=always --no-coverage --filter "$@" +docker compose exec -e COLUMNS=$(tput cols) -T test vendor/bin/pest --color=always --no-coverage --filter "$@" diff --git a/test b/test index ecdbcd9a..b8bd8fa0 100755 --- a/test +++ b/test @@ -1,4 +1,4 @@ #!/bin/bash # --columns doesn't seem to work at the moment, so we're setting it using an environment variable -docker-compose exec -e COLUMNS=$(tput cols) -T test vendor/bin/pest --colors=always "$@" +docker compose exec -e COLUMNS=$(tput cols) -T test vendor/bin/pest --colors=always "$@" From 9ee1d63dcef54b2b755bfc07ee04067800d00377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Fri, 11 Oct 2024 21:31:54 +0200 Subject: [PATCH 18/26] sqlite: use WAL journal mode by default --- .../SQLiteDatabaseManager.php | 24 ++++++++++++++- tests/TenantDatabaseManagerTest.php | 30 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index ada5d642..d69d19c4 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\TenantDatabaseManagers; +use AssertionError; +use PDO; use Stancl\Tenancy\Database\Contracts\TenantDatabaseManager; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Throwable; @@ -15,10 +17,30 @@ 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; + public function createDatabase(TenantWithDatabase $tenant): bool { try { - return (bool) file_put_contents($this->getPath($tenant->database()->getName()), ''); + if (file_put_contents($path = $this->getPath($tenant->database()->getName()), '') === false) { + return false; + } + + if (static::$WAL) { + $pdo = new PDO('sqlite:' . $path); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + 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; } diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 559b40b5..0b5376d9 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -145,6 +145,36 @@ test('db name is prefixed with db path when sqlite is used', function () { expect(database_path('foodb'))->toBe(config('database.connections.tenant.database')); }); +test('sqlite databases use the WAL journal mode by default', function (bool|null $wal) { + $expected = $wal ? 'wal' : 'delete'; + if ($wal !== null) { + SQLiteDatabaseManager::$WAL = $wal; + } else { + // default behavior + $expected = 'wal'; + } + + Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + + $tenant = Tenant::create([ + 'tenancy_db_connection' => 'sqlite', + ]); + + $dbPath = database_path($tenant->database()->getName()); + + expect(file_exists($dbPath))->toBeTrue(); + + $db = new PDO('sqlite:' . $dbPath); + $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + expect($db->query('pragma journal_mode')->fetch(PDO::FETCH_ASSOC)['journal_mode'])->toBe($expected); + + // cleanup + SQLiteDatabaseManager::$WAL = true; +})->with([true, false, null]); + test('schema manager uses schema to separate tenant dbs', function () { config([ 'tenancy.database.managers.pgsql' => PostgreSQLSchemaManager::class, From d4b997260076cb038ee81a1a808544ec13f2b5b5 Mon Sep 17 00:00:00 2001 From: PHP CS Fixer Date: Fri, 11 Oct 2024 19:32:21 +0000 Subject: [PATCH 19/26] Fix code style (php-cs-fixer) --- src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index d69d19c4..28e65cb4 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -35,7 +35,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager $pdo = new PDO('sqlite:' . $path); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - assert($pdo->query("pragma journal_mode = wal")->fetch(PDO::FETCH_ASSOC)['journal_mode'] === 'wal', "Unable to set journal mode to wal."); + assert($pdo->query('pragma journal_mode = wal')->fetch(PDO::FETCH_ASSOC)['journal_mode'] === 'wal', 'Unable to set journal mode to wal.'); } return true; From a07ba8c76c4b66fc17c08606e6648bbd52988525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Fri, 11 Oct 2024 21:54:10 +0200 Subject: [PATCH 20/26] docker: add composer back --- Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index 988e3e02..98353198 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,4 +27,7 @@ RUN docker-php-ext-install pdo_pgsql && docker-php-ext-enable pdo_pgsql 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 + WORKDIR /var/www/html From 5f8a3d2ffe76ae51a454749921c6c3c2922a3dd0 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 11 Oct 2024 22:50:38 +0200 Subject: [PATCH 21/26] Improve the tests where the removed universal_channel() was still used (#64) --- ...BroadcastChannelPrefixBootstrapperTest.php | 4 --- tests/BroadcastingTest.php | 29 ++++--------------- 2 files changed, 6 insertions(+), 27 deletions(-) diff --git a/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php b/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php index 29a80cbe..4c3ea30a 100644 --- a/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php @@ -48,10 +48,6 @@ test('BroadcastChannelPrefixBootstrapper prefixes the channels events are broadc $table->timestamps(); }); - universal_channel('users.{userId}', function ($user, $userId) { - return User::find($userId)->is($user); - }); - $broadcaster = app(BroadcastManager::class)->driver(); $tenant = Tenant::create(); diff --git a/tests/BroadcastingTest.php b/tests/BroadcastingTest.php index bd15df18..ba221307 100644 --- a/tests/BroadcastingTest.php +++ b/tests/BroadcastingTest.php @@ -138,14 +138,16 @@ test('broadcasting channel helpers register channels correctly', function() { $tenantChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === "{tenant}.$channelName"); expect($tenantChannelClosure)->toBe($centralChannelClosure); - // todo: fix tests from below here as the closure is now NOT modified, the $tenant parameter is expected (when additional parameters are used) and kept - // and the universal_channel() helper was removed (get rid of tests related to testing the helper's behavior, but tests which use universal channels should - // be kept -- the two channels should just be registered by hand per docs instead of using the now removed helper) - // 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(); @@ -162,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); From 10b214c452fafcc7944eea083f3f363a15d0c7e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Fri, 11 Oct 2024 23:01:12 +0200 Subject: [PATCH 22/26] add phpstan ignore --- src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index 28e65cb4..29aa9831 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -35,6 +35,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager $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.'); } From 56dd4117ab7f6dd44bd6cba35065a3f70f477a3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Sat, 9 Nov 2024 20:48:45 +0100 Subject: [PATCH 23/26] Fix origin id w/ empty header & using full-hostname subdomain records This makes it possible to have Domain records in both `foo` and `foo.{centralDomain}` format when using the combined domain/subdomain identification middleware, or the origin header id mw which extends it. This commit also refactors some related logic. --- composer.json | 1 + src/Middleware/InitializeTenancyByDomain.php | 7 ++- .../InitializeTenancyByDomainOrSubdomain.php | 53 ++++++++++------ .../InitializeTenancyBySubdomain.php | 12 ++-- src/Resolvers/DomainTenantResolver.php | 6 ++ ...edDomainAndSubdomainIdentificationTest.php | 62 +++++++++---------- tests/OriginHeaderIdentificationTest.php | 53 ++++++++++++++++ 7 files changed, 131 insertions(+), 63 deletions(-) diff --git a/composer.json b/composer.json index 7bafdce4..37f0b918 100644 --- a/composer.json +++ b/composer.json @@ -71,6 +71,7 @@ "docker-m1": "ln -s docker-compose-m1.override.yml docker-compose.override.yml", "testbench-unlink": "rm ./vendor/orchestra/testbench-core/laravel/vendor", "testbench-link": "ln -s vendor ./vendor/orchestra/testbench-core/laravel/vendor", + "testbench-repair": "mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/sessions && mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/views && mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/cache", "coverage": "open coverage/phpunit/html/index.html", "phpstan": "vendor/bin/phpstan", "phpstan-pro": "vendor/bin/phpstan --pro", diff --git a/src/Middleware/InitializeTenancyByDomain.php b/src/Middleware/InitializeTenancyByDomain.php index b9e049a4..60f1d316 100644 --- a/src/Middleware/InitializeTenancyByDomain.php +++ b/src/Middleware/InitializeTenancyByDomain.php @@ -44,7 +44,12 @@ class InitializeTenancyByDomain extends IdentificationMiddleware */ public function requestHasTenant(Request $request): bool { - return ! in_array($this->getDomain($request), config('tenancy.identification.central_domains')); + $domain = $this->getDomain($request); + + // Mainly used with origin identification if the header isn't specified and e.g. universal routes are used + if (! $domain) return false; + + return ! in_array($domain, config('tenancy.identification.central_domains')); } public function getDomain(Request $request): string diff --git a/src/Middleware/InitializeTenancyByDomainOrSubdomain.php b/src/Middleware/InitializeTenancyByDomainOrSubdomain.php index 845e42b3..77b64271 100644 --- a/src/Middleware/InitializeTenancyByDomainOrSubdomain.php +++ b/src/Middleware/InitializeTenancyByDomainOrSubdomain.php @@ -8,8 +8,9 @@ use Closure; use Exception; use Illuminate\Http\Request; use Illuminate\Http\Response; -use Illuminate\Support\Str; use Stancl\Tenancy\Concerns\UsableWithEarlyIdentification; +use Stancl\Tenancy\Resolvers\DomainTenantResolver; +use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException; class InitializeTenancyByDomainOrSubdomain extends InitializeTenancyBySubdomain { @@ -23,34 +24,46 @@ class InitializeTenancyByDomainOrSubdomain extends InitializeTenancyBySubdomain } $domain = $this->getDomain($request); + $subdomain = null; - if ($this->isSubdomain($domain)) { - $domain = $this->makeSubdomain($domain); + if (DomainTenantResolver::isSubdomain($domain)) { + $subdomain = $this->makeSubdomain($domain); - if ($domain instanceof Exception) { + if ($subdomain instanceof Exception) { $onFail = static::$onFail ?? function ($e) { throw $e; }; - return $onFail($domain, $request, $next); - } - - // If a Response instance was returned, we return it immediately. - // todo@samuel when does this execute? - if ($domain instanceof Response) { - return $domain; + return $onFail($subdomain, $request, $next); } } - return $this->initializeTenancy( - $request, - $next, - $domain - ); - } + try { + $this->tenancy->initialize( + $this->resolver->resolve($subdomain ?? $domain) + ); + } catch (TenantCouldNotBeIdentifiedException $e) { + if ($subdomain) { + try { + $this->tenancy->initialize( + $this->resolver->resolve($domain) + ); + } catch (TenantCouldNotBeIdentifiedException $e) { + $onFail = static::$onFail ?? function ($e) { + throw $e; + }; - protected function isSubdomain(string $hostname): bool - { - return Str::endsWith($hostname, config('tenancy.identification.central_domains')); + return $onFail($e, $request, $next); + } + } else { + $onFail = static::$onFail ?? function ($e) { + throw $e; + }; + + return $onFail($e, $request, $next); + } + } + + return $next($request); } } diff --git a/src/Middleware/InitializeTenancyBySubdomain.php b/src/Middleware/InitializeTenancyBySubdomain.php index 07a9c68d..ede50ab8 100644 --- a/src/Middleware/InitializeTenancyBySubdomain.php +++ b/src/Middleware/InitializeTenancyBySubdomain.php @@ -8,9 +8,9 @@ use Closure; use Exception; use Illuminate\Http\Request; use Illuminate\Http\Response; -use Illuminate\Support\Str; use Stancl\Tenancy\Concerns\UsableWithEarlyIdentification; use Stancl\Tenancy\Exceptions\NotASubdomainException; +use Stancl\Tenancy\Resolvers\DomainTenantResolver; class InitializeTenancyBySubdomain extends InitializeTenancyByDomain { @@ -57,20 +57,16 @@ class InitializeTenancyBySubdomain extends InitializeTenancyByDomain ); } - /** @return string|Response|Exception|mixed */ + /** @return string|Exception */ protected function makeSubdomain(string $hostname) { $parts = explode('.', $hostname); - $isLocalhost = count($parts) === 1; $isIpAddress = count(array_filter($parts, 'is_numeric')) === count($parts); - - // If we're on localhost or an IP address, then we're not visiting a subdomain. $isACentralDomain = in_array($hostname, config('tenancy.identification.central_domains'), true); - $notADomain = $isLocalhost || $isIpAddress; - $thirdPartyDomain = ! Str::endsWith($hostname, config('tenancy.identification.central_domains')); + $thirdPartyDomain = ! DomainTenantResolver::isSubdomain($hostname); - if ($isACentralDomain || $notADomain || $thirdPartyDomain) { + if ($isACentralDomain || $isIpAddress || $thirdPartyDomain) { return new NotASubdomainException($hostname); } diff --git a/src/Resolvers/DomainTenantResolver.php b/src/Resolvers/DomainTenantResolver.php index 34fd4c4f..4d34811e 100644 --- a/src/Resolvers/DomainTenantResolver.php +++ b/src/Resolvers/DomainTenantResolver.php @@ -12,6 +12,7 @@ use Stancl\Tenancy\Contracts\SingleDomainTenant; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException; use Stancl\Tenancy\Tenancy; +use Illuminate\Support\Str; class DomainTenantResolver extends Contracts\CachedTenantResolver { @@ -55,6 +56,11 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver return $tenant; } + public static function isSubdomain(string $domain): bool + { + return Str::endsWith($domain, config('tenancy.identification.central_domains')); + } + public function resolved(Tenant $tenant, mixed ...$args): void { $this->setCurrentDomain($tenant, $args[0]); diff --git a/tests/CombinedDomainAndSubdomainIdentificationTest.php b/tests/CombinedDomainAndSubdomainIdentificationTest.php index f7201b3a..85f11182 100644 --- a/tests/CombinedDomainAndSubdomainIdentificationTest.php +++ b/tests/CombinedDomainAndSubdomainIdentificationTest.php @@ -6,62 +6,56 @@ use Illuminate\Support\Facades\Route; use Stancl\Tenancy\Database\Concerns\HasDomains; use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain; use Stancl\Tenancy\Database\Models; +use Stancl\Tenancy\Tests\Etc\Tenant; beforeEach(function () { Route::group([ 'middleware' => InitializeTenancyByDomainOrSubdomain::class, ], function () { - Route::get('/foo/{a}/{b}', function ($a, $b) { - return "$a + $b"; + Route::get('/test', function () { + return tenant('id'); }); }); - - config(['tenancy.models.tenant' => CombinedTenant::class]); }); test('tenant can be identified by subdomain', function () { config(['tenancy.identification.central_domains' => ['localhost']]); - $tenant = CombinedTenant::create([ - 'id' => 'acme', - ]); - - $tenant->domains()->create([ - 'domain' => 'foo', - ]); + $tenant = Tenant::create(['id' => 'acme']); + $tenant->domains()->create(['domain' => 'foo']); expect(tenancy()->initialized)->toBeFalse(); - pest() - ->get('http://foo.localhost/foo/abc/xyz') - ->assertSee('abc + xyz'); - - expect(tenancy()->initialized)->toBeTrue(); - expect(tenant('id'))->toBe('acme'); + pest()->get('http://foo.localhost/test')->assertSee('acme'); }); test('tenant can be identified by domain', function () { config(['tenancy.identification.central_domains' => []]); - $tenant = CombinedTenant::create([ - 'id' => 'acme', - ]); - - $tenant->domains()->create([ - 'domain' => 'foobar.localhost', - ]); + $tenant = Tenant::create(['id' => 'acme']); + $tenant->domains()->create(['domain' => 'foobar.localhost']); expect(tenancy()->initialized)->toBeFalse(); - pest() - ->get('http://foobar.localhost/foo/abc/xyz') - ->assertSee('abc + xyz'); - - expect(tenancy()->initialized)->toBeTrue(); - expect(tenant('id'))->toBe('acme'); + pest()->get('http://foobar.localhost/test')->assertSee('acme'); }); -class CombinedTenant extends Models\Tenant -{ - use HasDomains; -} +test('domain records can be either in domain syntax or subdomain syntax', function () { + config(['tenancy.identification.central_domains' => ['localhost']]); + + $foo = Tenant::create(['id' => 'foo']); + $foo->domains()->create(['domain' => 'foo']); + + $bar = Tenant::create(['id' => 'bar']); + $bar->domains()->create(['domain' => 'bar.localhost']); + + expect(tenancy()->initialized)->toBeFalse(); + + // Subdomain format + pest()->get('http://foo.localhost/test')->assertSee('foo'); + + tenancy()->end(); + + // Domain format + pest()->get('http://bar.localhost/test')->assertSee('bar'); +}); diff --git a/tests/OriginHeaderIdentificationTest.php b/tests/OriginHeaderIdentificationTest.php index a32777da..83737f1f 100644 --- a/tests/OriginHeaderIdentificationTest.php +++ b/tests/OriginHeaderIdentificationTest.php @@ -38,6 +38,12 @@ test('origin identification works', function () { }); test('tenant routes are not accessible on central domains while using origin identification', function () { + $tenant = Tenant::create(); + + $tenant->domains()->create([ + 'domain' => 'foo', + ]); + pest() ->withHeader('Origin', 'localhost') ->post('home') @@ -54,3 +60,50 @@ test('onfail logic can be customized', function() { ->post('home') ->assertSee('onFail message'); }); + +test('origin identification can be used with universal routes', function () { + $tenant = Tenant::create(); + + $tenant->domains()->create([ + 'domain' => 'foo', + ]); + + Route::post('/universal', function () { + return response(tenant('id') ?? 'central'); + })->middleware([InitializeTenancyByOriginHeader::class, 'universal'])->name('universal'); + + pest() + ->withHeader('Origin', 'foo.localhost') + ->post('universal') + ->assertSee($tenant->id); + + tenancy()->end(); + + pest() + ->withHeader('Origin', 'localhost') + ->post('universal') + ->assertSee('central'); + + pest() + // no header + ->post('universal') + ->assertSee('central'); +}); + +test('origin identification can be used with both domains and subdomains', function () { + $foo = Tenant::create(); + $foo->domains()->create(['domain' => 'foo']); + + $bar = Tenant::create(); + $bar->domains()->create(['domain' => 'bar.localhost']); + + pest() + ->withHeader('Origin', 'foo.localhost') + ->post('home') + ->assertSee($foo->id); + + pest() + ->withHeader('Origin', 'bar.localhost') + ->post('home') + ->assertSee($bar->id); +}); From 19631f4e9a7390c41c47170946e6317f8391e120 Mon Sep 17 00:00:00 2001 From: PHP CS Fixer Date: Sat, 9 Nov 2024 19:54:56 +0000 Subject: [PATCH 24/26] Fix code style (php-cs-fixer) --- src/Middleware/InitializeTenancyByDomain.php | 4 +++- src/Middleware/InitializeTenancyByDomainOrSubdomain.php | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Middleware/InitializeTenancyByDomain.php b/src/Middleware/InitializeTenancyByDomain.php index 60f1d316..a0522c13 100644 --- a/src/Middleware/InitializeTenancyByDomain.php +++ b/src/Middleware/InitializeTenancyByDomain.php @@ -47,7 +47,9 @@ class InitializeTenancyByDomain extends IdentificationMiddleware $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; + if (! $domain) { + return false; + } return ! in_array($domain, config('tenancy.identification.central_domains')); } diff --git a/src/Middleware/InitializeTenancyByDomainOrSubdomain.php b/src/Middleware/InitializeTenancyByDomainOrSubdomain.php index 77b64271..aca17abd 100644 --- a/src/Middleware/InitializeTenancyByDomainOrSubdomain.php +++ b/src/Middleware/InitializeTenancyByDomainOrSubdomain.php @@ -9,8 +9,8 @@ use Exception; use Illuminate\Http\Request; use Illuminate\Http\Response; use Stancl\Tenancy\Concerns\UsableWithEarlyIdentification; -use Stancl\Tenancy\Resolvers\DomainTenantResolver; use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException; +use Stancl\Tenancy\Resolvers\DomainTenantResolver; class InitializeTenancyByDomainOrSubdomain extends InitializeTenancyBySubdomain { From 85bdbd57f7b215ce72138124579aa223a95469ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 25 Nov 2024 04:44:39 +0100 Subject: [PATCH 25/26] Fix pullFromPendingPool() behavior (#70) --- src/Bootstrappers/JobBatchBootstrapper.php | 1 + src/Database/Concerns/HasPending.php | 17 ++++++----------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/Bootstrappers/JobBatchBootstrapper.php b/src/Bootstrappers/JobBatchBootstrapper.php index 3ccf3b97..87db4869 100644 --- a/src/Bootstrappers/JobBatchBootstrapper.php +++ b/src/Bootstrappers/JobBatchBootstrapper.php @@ -27,6 +27,7 @@ class JobBatchBootstrapper implements TenancyBootstrapper public function bootstrap(Tenant $tenant): void { + // todo@revisit // Update batch repository connection to use the tenant connection $this->previousConnection = $this->batchRepository->getConnection(); $this->batchRepository->setConnection($this->databaseManager->connection('tenant')); diff --git a/src/Database/Concerns/HasPending.php b/src/Database/Concerns/HasPending.php index 83cf0cf2..ce0b1a37 100644 --- a/src/Database/Concerns/HasPending.php +++ b/src/Database/Concerns/HasPending.php @@ -73,20 +73,15 @@ trait HasPending } /** Try to pull a tenant from the pool of pending tenants. */ - public static function pullPendingFromPool(bool $firstOrCreate = false, array $attributes = []): ?Tenant + public static function pullPendingFromPool(bool $firstOrCreate = true, array $attributes = []): ?Tenant { - if (! static::onlyPending()->exists()) { - if (! $firstOrCreate) { - return null; - } - - static::createPending($attributes); - } - - // A pending tenant is surely available at this point - /** @var Model&Tenant $tenant */ + /** @var (Model&Tenant)|null $tenant */ $tenant = static::onlyPending()->first(); + if ($tenant === null) { + return $firstOrCreate ? static::create($attributes) : null; + } + event(new PullingPendingTenant($tenant)); $tenant->update(array_merge($attributes, [ From 48b916e1824ea17f69948868dcea3a1f9d1b8ef1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 25 Nov 2024 04:48:52 +0100 Subject: [PATCH 26/26] Supported named in-memory SQLite databases (#69) This PR adds support for named in-memory SQLite databases, making it feasible to use in-memory SQLite for tenant databases in tests. The usage is simply creating a tenant with 'tenancy_db_name' => ':memory:' and the bootstrapper will automatically update the tenant with a database name derived from its tenant key. There are static property hooks for keeping these DBs alive (at least one connection needs to be open, they don't have process lifetime and are essentially "refcounted") and closing them when the database is deleted. This gives the user control over the lifetimes of these databases. --- src/Database/DatabaseManager.php | 5 +- .../SQLiteDatabaseManager.php | 95 ++++++++++++++++++- 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/src/Database/DatabaseManager.php b/src/Database/DatabaseManager.php index edde7515..e250cd2f 100644 --- a/src/Database/DatabaseManager.php +++ b/src/Database/DatabaseManager.php @@ -9,6 +9,7 @@ use Illuminate\Contracts\Foundation\Application; use Illuminate\Database\DatabaseManager as BaseDatabaseManager; use Stancl\Tenancy\Contracts\TenantCannotBeCreatedException; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; +use Stancl\Tenancy\Database\TenantDatabaseManagers\SQLiteDatabaseManager; /** * @internal Class is subject to breaking changes in minor and patch versions. @@ -71,7 +72,9 @@ class DatabaseManager $manager = $tenant->database()->manager(); if ($manager->databaseExists($database = $tenant->database()->getName())) { - throw new Exceptions\TenantDatabaseAlreadyExistsException($database); + if (! $manager instanceof SQLiteDatabaseManager || ! SQLiteDatabaseManager::isInMemory($database)) { + throw new Exceptions\TenantDatabaseAlreadyExistsException($database); + } } if ($manager instanceof Contracts\ManagesDatabaseUsers) { diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index 29aa9831..d7fb8da2 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -5,6 +5,8 @@ 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; @@ -24,10 +26,71 @@ class SQLiteDatabaseManager implements TenantDatabaseManager */ 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 { - if (file_put_contents($path = $this->getPath($tenant->database()->getName()), '') === false) { + if (file_put_contents($path = $this->getPath($name), '') === false) { return false; } @@ -49,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; } @@ -58,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; } @@ -81,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_'); + } }