1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2025-12-12 08:24:04 +00:00

[3.x] DB users (#382)

* Initial draft

* Apply fixes from StyleCI

* Use CI on master branch too

* Pass correct argument to queued DB creators/deleters

* Apply fixes from StyleCI

* Remove new interface from MySQLDBManager

* Make phpunit run

* Apply fixes from StyleCI

* Fix static property

* Default databaseName

* Use database transactions for creating users & granting permissions

* Apply fixes from StyleCI

* Get old tests to pass

* Apply fixes from StyleCI

* Add tests for PermissionControlledMySQLDatabaseManager

* Apply fixes from StyleCI

* Write test for extra config, fix bug with extra config

* Apply fixes from StyleCI
This commit is contained in:
Samuel Štancl 2020-05-03 18:12:27 +02:00 committed by GitHub
parent 60665517a0
commit 3bb2759fe2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 756 additions and 286 deletions

View file

@ -100,7 +100,7 @@ return [
* The connection that will be used as a template for the dynamically created tenant connection. * The connection that will be used as a template for the dynamically created tenant connection.
* Set to null to use the default connection. * Set to null to use the default connection.
*/ */
'based_on' => null, 'template_connection' => null,
/** /**
* Tenant database names are created like this: * Tenant database names are created like this:
@ -108,8 +108,6 @@ return [
*/ */
'prefix' => 'tenant', 'prefix' => 'tenant',
'suffix' => '', 'suffix' => '',
'separate_by' => 'database', // database or schema (only supported by pgsql)
], ],
/** /**
@ -197,9 +195,14 @@ return [
'pgsql' => Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLDatabaseManager::class, 'pgsql' => Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLDatabaseManager::class,
/** /**
* Disable the pgsql manager above, enable the one below, and set the * Use this database manager for MySQL to have a DB user created for each tenant database.
* tenancy.database.separate_by config key to 'schema' if you would * You can customize the grants given to these users by changing the $grants property.
* like to separate tenant DBs by schemas rather than databases. */
// 'mysql' => Stancl\Tenancy\TenantDatabaseManagers\PermissionControlledMySQLDatabaseManager::class,
/**
* Disable the pgsql manager above, and enable the one below if you
* want to separate tenant DBs by schemas rather than databases.
*/ */
// 'pgsql' => Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLSchemaManager::class, // Separate by schema instead of database // 'pgsql' => Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLSchemaManager::class, // Separate by schema instead of database
], ],
@ -252,8 +255,8 @@ return [
*/ */
'migrate_after_creation' => false, 'migrate_after_creation' => false,
'migration_parameters' => [ 'migration_parameters' => [
// '--force' => true, // Set this to true to be able to run migrations in production '--force' => true, // Set this to true to be able to run migrations in production
// '--path' => [], // If you need to customize paths to tenant migrations // '--path' => [database_path('migrations/tenant')], // If you need to customize paths to tenant migrations
], ],
/** /**

View file

@ -28,7 +28,7 @@
<env name="MAIL_DRIVER" value="array"/> <env name="MAIL_DRIVER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/> <env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/> <env name="SESSION_DRIVER" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/> <env name="DB_CONNECTION" value="central"/>
<env name="AWS_DEFAULT_REGION" value="us-west-2"/> <env name="AWS_DEFAULT_REGION" value="us-west-2"/>
<env name="STANCL_TENANCY_TEST_VARIANT" value="1"/> <env name="STANCL_TENANCY_TEST_VARIANT" value="1"/>
</php> </php>

View file

@ -45,6 +45,12 @@ class Migrate extends MigrateCommand
*/ */
public function handle() public function handle()
{ {
foreach (config('tenancy.migration_parameters') as $parameter => $value) {
if (! $this->input->hasParameterOption($parameter)) {
$this->input->setOption(ltrim($parameter, '-'), $value);
}
}
if (! $this->confirmToProceed()) { if (! $this->confirmToProceed()) {
return; return;
} }
@ -52,8 +58,6 @@ class Migrate extends MigrateCommand
tenancy()->all($this->option('tenants'))->each(function ($tenant) { tenancy()->all($this->option('tenants'))->each(function ($tenant) {
$this->line("Tenant: {$tenant['id']}"); $this->line("Tenant: {$tenant['id']}");
$this->input->setOption('database', $tenant->getConnectionName());
$tenant->run(function () { $tenant->run(function () {
// Migrate // Migrate
parent::handle(); parent::handle();

View file

@ -39,7 +39,7 @@ final class MigrateFresh extends Command
$tenant->run(function ($tenant) { $tenant->run(function ($tenant) {
$this->info('Dropping tables.'); $this->info('Dropping tables.');
$this->call('db:wipe', array_filter([ $this->call('db:wipe', array_filter([
'--database' => $tenant->getConnectionName(), '--database' => 'tenant',
'--force' => true, '--force' => true,
])); ]));

View file

@ -45,6 +45,12 @@ class Rollback extends RollbackCommand
*/ */
public function handle() public function handle()
{ {
foreach (config('tenancy.migration_parameters') as $parameter => $value) {
if (! $this->input->hasParameterOption($parameter)) {
$this->input->setOption(ltrim($parameter, '-'), $value);
}
}
if (! $this->confirmToProceed()) { if (! $this->confirmToProceed()) {
return; return;
} }
@ -52,8 +58,6 @@ class Rollback extends RollbackCommand
tenancy()->all($this->option('tenants'))->each(function ($tenant) { tenancy()->all($this->option('tenants'))->each(function ($tenant) {
$this->line("Tenant: {$tenant['id']}"); $this->line("Tenant: {$tenant['id']}");
$this->input->setOption('database', $tenant->getConnectionName());
$tenant->run(function () { $tenant->run(function () {
// Rollback // Rollback
parent::handle(); parent::handle();

View file

@ -56,8 +56,6 @@ class Seed extends SeedCommand
tenancy()->all($this->option('tenants'))->each(function ($tenant) { tenancy()->all($this->option('tenants'))->each(function ($tenant) {
$this->line("Tenant: {$tenant['id']}"); $this->line("Tenant: {$tenant['id']}");
$this->input->setOption('database', $tenant->getConnectionName());
$tenant->run(function () { $tenant->run(function () {
// Seed // Seed
parent::handle(); parent::handle();

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Contracts;
use Stancl\Tenancy\DatabaseConfig;
interface ManagesDatabaseUsers extends TenantDatabaseManager
{
public function createUser(DatabaseConfig $databaseConfig): bool;
public function deleteUser(DatabaseConfig $databaseConfig): bool;
public function userExists(string $username): bool;
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Contracts;
/**
* Used by sqlite to wrap database name in database_path().
*/
interface ModifiesDatabaseNameForConnection
{
public function getDatabaseNameForConnection(string $original): string;
}

View file

@ -4,23 +4,26 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Contracts; namespace Stancl\Tenancy\Contracts;
use Stancl\Tenancy\Tenant;
interface TenantDatabaseManager interface TenantDatabaseManager
{ {
/** /**
* Create a database. * Return the config key that separates databases (e.g. 'database' or 'schema').
* *
* @param string $name Name of the database. * @return string
* @return bool
*/ */
public function createDatabase(string $name): bool; public function getSeparator(): string;
/**
* Create a database.
*/
public function createDatabase(Tenant $tenant): bool;
/** /**
* Delete a database. * Delete a database.
*
* @param string $name Name of the database.
* @return bool
*/ */
public function deleteDatabase(string $name): bool; public function deleteDatabase(Tenant $tenant): bool;
/** /**
* Does a database exist. * Does a database exist.

166
src/DatabaseConfig.php Normal file
View file

@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Stancl\Tenancy\Contracts\Future\CanSetConnection;
use Stancl\Tenancy\Contracts\ManagesDatabaseUsers;
use Stancl\Tenancy\Contracts\ModifiesDatabaseNameForConnection;
use Stancl\Tenancy\Contracts\TenantDatabaseManager;
use Stancl\Tenancy\Exceptions\DatabaseManagerNotRegisteredException;
class DatabaseConfig
{
/** @var Tenant */
public $tenant;
/** @var callable */
public static $usernameGenerator;
/** @var callable */
public static $passwordGenerator;
/** @var callable */
public static $databaseNameGenerator;
public static function __constructStatic(): void
{
static::$usernameGenerator = static::$usernameGenerator ?? function (Tenant $tenant) {
return Str::random(16);
};
static::$passwordGenerator = static::$usernameGenerator ?? function (Tenant $tenant) {
return Hash::make(Str::random(32));
};
static::$databaseNameGenerator = static::$databaseNameGenerator ?? function (Tenant $tenant) {
return config('tenancy.database.prefix') . $tenant->id . config('tenancy.database.suffix');
};
}
public function __construct(Tenant $tenant)
{
static::__constructStatic();
$this->tenant = $tenant;
}
public static function generateDatabaseNamesUsing(callable $databaseNameGenerator): void
{
static::$databaseNameGenerator = $databaseNameGenerator;
}
public static function generateUsernamesUsing(callable $usernameGenerator): void
{
static::$usernameGenerator = $usernameGenerator;
}
public static function generatePasswordsUsing(callable $passwordGenerator): void
{
static::$passwordGenerator = $passwordGenerator;
}
public function getName(): ?string
{
return $this->tenant->data['_tenancy_db_name'] ?? (static::$databaseNameGenerator)($this->tenant);
}
public function getUsername(): ?string
{
return $this->tenant->data['_tenancy_db_username'] ?? null;
}
public function getPassword(): ?string
{
return $this->tenant->data['_tenancy_db_password'] ?? null;
}
public function makeCredentials(): void
{
$this->tenant->data['_tenancy_db_name'] = $this->getName() ?? (static::$databaseNameGenerator)($this->tenant);
if ($this->manager() instanceof ManagesDatabaseUsers) {
$this->tenant->data['_tenancy_db_username'] = $this->getUsername() ?? (static::$usernameGenerator)($this->tenant);
$this->tenant->data['_tenancy_db_password'] = $this->getPassword() ?? (static::$passwordGenerator)($this->tenant);
}
}
public function getTemplateConnectionName(): string
{
return $this->tenant->data['_tenancy_db_connection']
?? config('tenancy.database.template_connection')
?? DatabaseManager::$originalDefaultConnectionName;
}
/**
* Tenant's own database connection config.
*/
public function connection(): array
{
$template = $this->getTemplateConnectionName();
$templateConnection = config("database.connections.{$template}");
$databaseName = $this->getName();
if (($manager = $this->manager()) instanceof ModifiesDatabaseNameForConnection) {
/** @var ModifiesDatabaseNameForConnection $manager */
$databaseName = $manager->getDatabaseNameForConnection($databaseName);
}
return array_merge($templateConnection, $this->tenantConfig(), [
$this->manager()->getSeparator() => $databaseName,
]);
}
/**
* Additional config for the database connection, specific to this tenant.
*/
public function tenantConfig(): array
{
$dbConfig = array_filter(array_keys($this->tenant->data), function ($key) {
return Str::startsWith($key, '_tenancy_db_');
});
// Remove DB name because we set that separately
if (($pos = array_search('_tenancy_db_name', $dbConfig)) !== false) {
unset($dbConfig[$pos]);
}
// Remove DB connection because that's not used inside the array
if (($pos = array_search('_tenancy_db_connection', $dbConfig)) !== false) {
unset($dbConfig[$pos]);
}
return array_reduce($dbConfig, function ($config, $key) {
return array_merge($config, [
Str::substr($key, strlen('_tenancy_db_')) => $this->tenant[$key],
]);
}, []);
}
/**
* Get the TenantDatabaseManager for this tenant's connection.
*/
public function manager(): TenantDatabaseManager
{
$driver = config("database.connections.{$this->getTemplateConnectionName()}.driver");
$databaseManagers = config('tenancy.database_managers');
if (! array_key_exists($driver, $databaseManagers)) {
throw new DatabaseManagerNotRegisteredException($driver);
}
/** @var TenantDatabaseManager $databaseManager */
$databaseManager = app($databaseManagers[$driver]);
if ($databaseManager instanceof CanSetConnection) {
$databaseManager->setConnection($this->getTemplateConnectionName());
}
return $databaseManager;
}
}

View file

@ -8,18 +8,19 @@ use Closure;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\DatabaseManager as BaseDatabaseManager; use Illuminate\Database\DatabaseManager as BaseDatabaseManager;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Stancl\Tenancy\Contracts\Future\CanSetConnection;
use Stancl\Tenancy\Contracts\TenantCannotBeCreatedException; use Stancl\Tenancy\Contracts\TenantCannotBeCreatedException;
use Stancl\Tenancy\Contracts\TenantDatabaseManager;
use Stancl\Tenancy\Exceptions\DatabaseManagerNotRegisteredException; use Stancl\Tenancy\Exceptions\DatabaseManagerNotRegisteredException;
use Stancl\Tenancy\Exceptions\TenantDatabaseAlreadyExistsException; use Stancl\Tenancy\Exceptions\TenantDatabaseAlreadyExistsException;
use Stancl\Tenancy\Jobs\QueuedTenantDatabaseCreator; use Stancl\Tenancy\Jobs\QueuedTenantDatabaseCreator;
use Stancl\Tenancy\Jobs\QueuedTenantDatabaseDeleter; use Stancl\Tenancy\Jobs\QueuedTenantDatabaseDeleter;
/**
* @internal Class is subject to breaking changes in minor and patch versions.
*/
class DatabaseManager class DatabaseManager
{ {
/** @var string */ /** @var string */
public $originalDefaultConnectionName; public static $originalDefaultConnectionName;
/** @var Application */ /** @var Application */
protected $app; protected $app;
@ -34,14 +35,11 @@ class DatabaseManager
{ {
$this->app = $app; $this->app = $app;
$this->database = $database; $this->database = $database;
$this->originalDefaultConnectionName = $app['config']['database.default']; static::$originalDefaultConnectionName = $app['config']['database.default'];
} }
/** /**
* Set the TenantManager instance, used to dispatch tenancy events. * Set the TenantManager instance, used to dispatch tenancy events.
*
* @param TenantManager $tenantManager
* @return self
*/ */
public function withTenantManager(TenantManager $tenantManager): self public function withTenantManager(TenantManager $tenantManager): self
{ {
@ -52,35 +50,28 @@ class DatabaseManager
/** /**
* Connect to a tenant's database. * Connect to a tenant's database.
*
* @param Tenant $tenant
* @return void
*/ */
public function connect(Tenant $tenant) public function connect(Tenant $tenant)
{ {
$this->createTenantConnection($tenant->getDatabaseName(), $tenant->getConnectionName()); $this->createTenantConnection($tenant);
$this->setDefaultConnection($tenant->getConnectionName()); $this->setDefaultConnection('tenant');
$this->switchConnection($tenant->getConnectionName()); $this->switchConnection('tenant');
} }
/** /**
* Reconnect to the default non-tenant connection. * Reconnect to the default non-tenant connection.
*
* @return void
*/ */
public function reconnect() public function reconnect()
{ {
// Opposite order to connect() because we don't if ($this->tenancy->initialized) {
// want to ever purge the central connection $this->database->purge('tenant');
$this->switchConnection($this->originalDefaultConnectionName); }
$this->setDefaultConnection($this->originalDefaultConnectionName); $this->setDefaultConnection(static::$originalDefaultConnectionName);
$this->switchConnection(static::$originalDefaultConnectionName);
} }
/** /**
* Change the default database connection config. * Change the default database connection config.
*
* @param string $connection
* @return void
*/ */
public function setDefaultConnection(string $connection) public function setDefaultConnection(string $connection)
{ {
@ -89,57 +80,17 @@ class DatabaseManager
/** /**
* Create the tenant database connection. * Create the tenant database connection.
*
* @param string $databaseName
* @param string $connectionName
* @return void
*/ */
public function createTenantConnection($databaseName, $connectionName) public function createTenantConnection(Tenant $tenant)
{ {
// Create the database connection. $this->app['config']['database.connections.tenant'] = $tenant->database()->connection();
$based_on = $this->getBaseConnection($connectionName);
$this->app['config']["database.connections.$connectionName"] = $this->app['config']['database.connections.' . $based_on];
// Change database name.
$databaseName = $this->getDriver($connectionName) === 'sqlite' ? database_path($databaseName) : $databaseName;
$separateBy = $this->separateBy($connectionName);
$this->app['config']["database.connections.$connectionName.$separateBy"] = $databaseName;
}
/**
* Get the name of the connection that $connectionName should be based on.
*
* @param string $connectionName
* @return string
*/
public function getBaseConnection(string $connectionName): string
{
return ($connectionName !== 'tenant' ? $connectionName : null) // 'tenant' is not a specific connection, it's the default
?? $this->app['config']['tenancy.database.based_on']
?? $this->originalDefaultConnectionName; // tenancy.database.based_on === null => use the default connection
}
/**
* Get the driver of a database connection.
*
* @param string $connectionName
* @return string|null
*/
public function getDriver(string $connectionName): ?string
{
return $this->app['config']["database.connections.$connectionName.driver"];
} }
/** /**
* Switch the application's connection. * Switch the application's connection.
*
* @param string $connection
* @return void
*/ */
public function switchConnection(string $connection) public function switchConnection(string $connection)
{ {
$this->database->purge();
$this->database->reconnect($connection); $this->database->reconnect($connection);
$this->database->setDefaultConnection($connection); $this->database->setDefaultConnection($connection);
} }
@ -147,15 +98,13 @@ class DatabaseManager
/** /**
* Check if a tenant can be created. * Check if a tenant can be created.
* *
* @param Tenant $tenant
* @return void
* @throws TenantCannotBeCreatedException * @throws TenantCannotBeCreatedException
* @throws DatabaseManagerNotRegisteredException * @throws DatabaseManagerNotRegisteredException
* @throws TenantDatabaseAlreadyExistsException * @throws TenantDatabaseAlreadyExistsException
*/ */
public function ensureTenantCanBeCreated(Tenant $tenant): void public function ensureTenantCanBeCreated(Tenant $tenant): void
{ {
if ($this->getTenantDatabaseManager($tenant)->databaseExists($database = $tenant->getDatabaseName())) { if ($tenant->database()->manager()->databaseExists($database = $tenant->database()->getName())) {
throw new TenantDatabaseAlreadyExistsException($database); throw new TenantDatabaseAlreadyExistsException($database);
} }
} }
@ -170,101 +119,66 @@ class DatabaseManager
*/ */
public function createDatabase(Tenant $tenant, array $afterCreating = []) public function createDatabase(Tenant $tenant, array $afterCreating = [])
{ {
$database = $tenant->getDatabaseName();
$manager = $this->getTenantDatabaseManager($tenant);
$afterCreating = array_merge( $afterCreating = array_merge(
$afterCreating, $afterCreating,
$this->tenancy->event('database.creating', $database, $tenant) $this->tenancy->event('database.creating', $tenant->database()->getName(), $tenant)
); );
if ($this->app['config']['tenancy.queue_database_creation'] ?? false) { if ($this->app['config']['tenancy.queue_database_creation'] ?? false) {
$chain = []; $this->createDatabaseAsynchronously($tenant, $afterCreating);
foreach ($afterCreating as $item) {
if (is_string($item) && class_exists($item)) {
$chain[] = new $item($tenant); // Classes are instantiated and given $tenant
} elseif ($item instanceof ShouldQueue) {
$chain[] = $item;
}
}
QueuedTenantDatabaseCreator::withChain($chain)->dispatch($manager, $database);
} else { } else {
$manager->createDatabase($database); $this->createDatabaseSynchronously($tenant, $afterCreating);
foreach ($afterCreating as $item) { }
if (is_object($item) && ! $item instanceof Closure) {
$item->handle($tenant); $this->tenancy->event('database.created', $tenant->database()->getName(), $tenant);
} else { }
$item($tenant);
} protected function createDatabaseAsynchronously(Tenant $tenant, array $afterCreating)
{
$chain = [];
foreach ($afterCreating as $item) {
if (is_string($item) && class_exists($item)) {
$chain[] = new $item($tenant); // Classes are instantiated and given $tenant
} elseif ($item instanceof ShouldQueue) {
$chain[] = $item;
} }
} }
$this->tenancy->event('database.created', $database, $tenant); QueuedTenantDatabaseCreator::withChain($chain)->dispatch($tenant->database()->manager(), $tenant);
}
protected function createDatabaseSynchronously(Tenant $tenant, array $afterCreating)
{
$manager = $tenant->database()->manager();
$manager->createDatabase($tenant);
foreach ($afterCreating as $item) {
if (is_object($item) && ! $item instanceof Closure) {
$item->handle($tenant);
} else {
$item($tenant);
}
}
} }
/** /**
* Delete a tenant's database. * Delete a tenant's database.
* *
* @param Tenant $tenant
* @return void
* @throws DatabaseManagerNotRegisteredException * @throws DatabaseManagerNotRegisteredException
*/ */
public function deleteDatabase(Tenant $tenant) public function deleteDatabase(Tenant $tenant)
{ {
$database = $tenant->getDatabaseName(); $database = $tenant->database()->getName();
$manager = $this->getTenantDatabaseManager($tenant); $manager = $tenant->database()->manager();
$this->tenancy->event('database.deleting', $database, $tenant); $this->tenancy->event('database.deleting', $database, $tenant);
if ($this->app['config']['tenancy.queue_database_deletion'] ?? false) { if ($this->app['config']['tenancy.queue_database_deletion'] ?? false) {
QueuedTenantDatabaseDeleter::dispatch($manager, $database); QueuedTenantDatabaseDeleter::dispatch($manager, $tenant);
} else { } else {
$manager->deleteDatabase($database); $manager->deleteDatabase($tenant);
} }
$this->tenancy->event('database.deleted', $database, $tenant); $this->tenancy->event('database.deleted', $database, $tenant);
} }
/**
* Get the TenantDatabaseManager for a tenant's database connection.
*
* @param Tenant $tenant
* @return TenantDatabaseManager
* @throws DatabaseManagerNotRegisteredException
*/
public function getTenantDatabaseManager(Tenant $tenant): TenantDatabaseManager
{
$driver = $this->getDriver($this->getBaseConnection($connectionName = $tenant->getConnectionName()));
$databaseManagers = $this->app['config']['tenancy.database_managers'];
if (! array_key_exists($driver, $databaseManagers)) {
throw new DatabaseManagerNotRegisteredException($driver);
}
$databaseManager = $this->app[$databaseManagers[$driver]];
if ($connectionName !== 'tenant' && $databaseManager instanceof CanSetConnection) {
$databaseManager->setConnection($connectionName);
}
return $databaseManager;
}
/**
* What key on the connection config should be used to separate tenants.
*
* @param string $connectionName
* @return string
*/
public function separateBy(string $connectionName): string
{
if ($this->getDriver($this->getBaseConnection($connectionName)) === 'pgsql'
&& $this->app['config']['tenancy.database.separate_by'] === 'schema') {
return 'schema';
}
return 'database';
}
} }

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Exceptions;
use Stancl\Tenancy\Contracts\TenantCannotBeCreatedException;
class TenantDatabaseUserAlreadyExistsException extends TenantCannotBeCreatedException
{
/** @var string */
protected $user;
public function reason(): string
{
return "Database user {$this->user} already exists.";
}
public function __construct(string $user)
{
parent::__construct();
$this->user = $user;
}
}

View file

@ -10,6 +10,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Stancl\Tenancy\Contracts\TenantDatabaseManager; use Stancl\Tenancy\Contracts\TenantDatabaseManager;
use Stancl\Tenancy\Tenant;
class QueuedTenantDatabaseCreator implements ShouldQueue class QueuedTenantDatabaseCreator implements ShouldQueue
{ {
@ -18,20 +19,13 @@ class QueuedTenantDatabaseCreator implements ShouldQueue
/** @var TenantDatabaseManager */ /** @var TenantDatabaseManager */
protected $databaseManager; protected $databaseManager;
/** @var string */ /** @var Tenant */
protected $databaseName; protected $tenant;
/** public function __construct(TenantDatabaseManager $databaseManager, Tenant $tenant)
* Create a new job instance.
*
* @param TenantDatabaseManager $databaseManager
* @param string $databaseName
* @return void
*/
public function __construct(TenantDatabaseManager $databaseManager, string $databaseName)
{ {
$this->databaseManager = $databaseManager; $this->databaseManager = $databaseManager;
$this->databaseName = $databaseName; $this->tenant = $tenant;
} }
/** /**
@ -41,6 +35,6 @@ class QueuedTenantDatabaseCreator implements ShouldQueue
*/ */
public function handle() public function handle()
{ {
$this->databaseManager->createDatabase($this->databaseName); $this->databaseManager->createDatabase($this->tenant);
} }
} }

View file

@ -10,6 +10,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Stancl\Tenancy\Contracts\TenantDatabaseManager; use Stancl\Tenancy\Contracts\TenantDatabaseManager;
use Stancl\Tenancy\Tenant;
class QueuedTenantDatabaseDeleter implements ShouldQueue class QueuedTenantDatabaseDeleter implements ShouldQueue
{ {
@ -18,20 +19,13 @@ class QueuedTenantDatabaseDeleter implements ShouldQueue
/** @var TenantDatabaseManager */ /** @var TenantDatabaseManager */
protected $databaseManager; protected $databaseManager;
/** @var string */ /** @var Tenant */
protected $databaseName; protected $tenant;
/** public function __construct(TenantDatabaseManager $databaseManager, Tenant $tenant)
* Create a new job instance.
*
* @param TenantDatabaseManager $databaseManager
* @param string $databaseName
* @return void
*/
public function __construct(TenantDatabaseManager $databaseManager, string $databaseName)
{ {
$this->databaseManager = $databaseManager; $this->databaseManager = $databaseManager;
$this->databaseName = $databaseName; $this->tenant = $tenant;
} }
/** /**
@ -41,6 +35,6 @@ class QueuedTenantDatabaseDeleter implements ShouldQueue
*/ */
public function handle() public function handle()
{ {
$this->databaseManager->deleteDatabase($this->databaseName); $this->databaseManager->deleteDatabase($this->tenant);
} }
} }

View file

@ -21,22 +21,22 @@ use Stancl\Tenancy\Tenant;
class DatabaseStorageDriver implements StorageDriver, CanDeleteKeys, CanFindByAnyKey class DatabaseStorageDriver implements StorageDriver, CanDeleteKeys, CanFindByAnyKey
{ {
/** @var Application */ /** @var Application */
protected $app; public $app;
/** @var Connection */ /** @var Connection */
protected $centralDatabase; public $centralDatabase;
/** @var TenantRepository */ /** @var TenantRepository */
protected $tenants; public $tenants;
/** @var DomainRepository */ /** @var DomainRepository */
protected $domains; public $domains;
/** @var CachedTenantResolver */ /** @var CachedTenantResolver */
protected $cache; public $cache;
/** @var Tenant The default tenant. */ /** @var Tenant The default tenant. */
protected $tenant; public $tenant;
public function __construct(Application $app, ConfigRepository $config, CachedTenantResolver $cache) public function __construct(Application $app, ConfigRepository $config, CachedTenantResolver $cache)
{ {
@ -59,7 +59,7 @@ class DatabaseStorageDriver implements StorageDriver, CanDeleteKeys, CanFindByAn
public static function getCentralConnectionName(): string public static function getCentralConnectionName(): string
{ {
return config('tenancy.storage_drivers.db.connection') ?? app(DatabaseManager::class)->originalDefaultConnectionName; return config('tenancy.storage_drivers.db.connection') ?? DatabaseManager::$originalDefaultConnectionName;
} }
public function findByDomain(string $domain): Tenant public function findByDomain(string $domain): Tenant

View file

@ -50,8 +50,6 @@ class DomainRepository extends Repository
public function getTable(ConfigRepository $config) public function getTable(ConfigRepository $config)
{ {
return $config->get('tenancy.storage_drivers.db.table_names.DomainModel') // legacy return $config->get('tenancy.storage_drivers.db.table_names.domains') ?? 'domains';
?? $config->get('tenancy.storage_drivers.db.table_names.domains')
?? 'domains';
} }
} }

View file

@ -21,8 +21,8 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper
public function start(Tenant $tenant) public function start(Tenant $tenant)
{ {
$database = $tenant->getDatabaseName(); $database = $tenant->database()->getName();
if (! $this->database->getTenantDatabaseManager($tenant)->databaseExists($database)) { if (! $tenant->database()->manager()->databaseExists($database)) {
throw new TenantDatabaseDoesNotExistException($database); throw new TenantDatabaseDoesNotExistException($database);
} }

View file

@ -19,6 +19,7 @@ use Stancl\Tenancy\Exceptions\TenantStorageException;
/** /**
* @internal Class is subject to breaking changes in minor and patch versions. * @internal Class is subject to breaking changes in minor and patch versions.
*/ */
// todo make this class serializable
class Tenant implements ArrayAccess class Tenant implements ArrayAccess
{ {
use Traits\HasArrayAccess, use Traits\HasArrayAccess,
@ -91,8 +92,7 @@ class Tenant implements ArrayAccess
} }
/** /**
* DO NOT CALL THIS METHOD FROM USERLAND. Used by storage * Used by storage drivers to create persisted instances of Tenant.
* drivers to create persisted instances of Tenant.
* *
* @param array $data * @param array $data
* @return self * @return self
@ -230,6 +230,7 @@ class Tenant implements ArrayAccess
if ($this->persisted) { if ($this->persisted) {
$this->manager->updateTenant($this); $this->manager->updateTenant($this);
} else { } else {
$this->database()->makeCredentials();
$this->manager->createTenant($this); $this->manager->createTenant($this);
} }
@ -272,23 +273,13 @@ class Tenant implements ArrayAccess
} }
/** /**
* Get the tenant's database's name. * Get database config.
* *
* @return string * @return DatabaseConfig
*/ */
public function getDatabaseName(): string public function database(): DatabaseConfig
{ {
return $this->data['_tenancy_db_name'] ?? ($this->config->get('tenancy.database.prefix') . $this->id . $this->config->get('tenancy.database.suffix')); return new DatabaseConfig($this);
}
/**
* Get the tenant's database connection's name.
*
* @return string
*/
public function getConnectionName(): string
{
return $this->data['_tenancy_db_connection'] ?? 'tenant';
} }
/** /**

View file

@ -9,6 +9,7 @@ use Illuminate\Database\Connection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Contracts\Future\CanSetConnection; use Stancl\Tenancy\Contracts\Future\CanSetConnection;
use Stancl\Tenancy\Contracts\TenantDatabaseManager; use Stancl\Tenancy\Contracts\TenantDatabaseManager;
use Stancl\Tenancy\Tenant;
class MySQLDatabaseManager implements TenantDatabaseManager, CanSetConnection class MySQLDatabaseManager implements TenantDatabaseManager, CanSetConnection
{ {
@ -20,6 +21,11 @@ class MySQLDatabaseManager implements TenantDatabaseManager, CanSetConnection
$this->connection = $config->get('tenancy.database_manager_connections.mysql'); $this->connection = $config->get('tenancy.database_manager_connections.mysql');
} }
public function getSeparator(): string
{
return 'database';
}
protected function database(): Connection protected function database(): Connection
{ {
return DB::connection($this->connection); return DB::connection($this->connection);
@ -30,17 +36,18 @@ class MySQLDatabaseManager implements TenantDatabaseManager, CanSetConnection
$this->connection = $connection; $this->connection = $connection;
} }
public function createDatabase(string $name): bool public function createDatabase(Tenant $tenant): bool
{ {
$database = $tenant->database()->getName();
$charset = $this->database()->getConfig('charset'); $charset = $this->database()->getConfig('charset');
$collation = $this->database()->getConfig('collation'); $collation = $this->database()->getConfig('collation');
return $this->database()->statement("CREATE DATABASE `$name` CHARACTER SET `$charset` COLLATE `$collation`"); return $this->database()->statement("CREATE DATABASE `{$database}` CHARACTER SET `$charset` COLLATE `$collation`");
} }
public function deleteDatabase(string $name): bool public function deleteDatabase(Tenant $tenant): bool
{ {
return $this->database()->statement("DROP DATABASE `$name`"); return $this->database()->statement("DROP DATABASE `{$tenant->database()->getName()}`");
} }
public function databaseExists(string $name): bool public function databaseExists(string $name): bool

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\TenantDatabaseManagers;
use Stancl\Tenancy\Contracts\ManagesDatabaseUsers;
use Stancl\Tenancy\DatabaseConfig;
use Stancl\Tenancy\Exceptions\TenantDatabaseUserAlreadyExistsException;
use Stancl\Tenancy\Traits\CreatesDatabaseUsers;
class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager implements ManagesDatabaseUsers
{
use CreatesDatabaseUsers;
public static $grants = [
'ALTER', 'ALTER ROUTINE', 'CREATE', 'CREATE ROUTINE', 'CREATE TEMPORARY TABLES', 'CREATE VIEW',
'DELETE', 'DROP', 'EVENT', 'EXECUTE', 'INDEX', 'INSERT', 'LOCK TABLES', 'REFERENCES', 'SELECT',
'SHOW VIEW', 'TRIGGER', 'UPDATE',
];
public function createUser(DatabaseConfig $databaseConfig): bool
{
$database = $databaseConfig->getName();
$username = $databaseConfig->getUsername();
$hostname = $databaseConfig->connection()['host'];
$password = $databaseConfig->getPassword();
if ($this->userExists($username)) {
throw new TenantDatabaseUserAlreadyExistsException($username);
}
$this->database()->statement("CREATE USER `{$username}`@`{$hostname}` IDENTIFIED BY '{$password}'");
$grants = implode(', ', static::$grants);
if ($this->isVersion8()) { // MySQL 8+
$grantQuery = "GRANT $grants ON `$database`.* TO `$username`@`$hostname`";
} else { // MySQL 5.7
$grantQuery = "GRANT $grants ON `$database`.* TO `$username`@`$hostname` IDENTIFIED BY '$password'";
}
return $this->database()->statement($grantQuery);
}
protected function isVersion8(): bool
{
$version = $this->database()->select($this->database()->raw('select version()'))[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()}'");
}
public function userExists(string $username): bool
{
return (bool) $this->database()->select("SELECT count(*) FROM mysql.user WHERE user = '$username'")[0]->{'count(*)'};
}
}

View file

@ -9,6 +9,7 @@ use Illuminate\Database\Connection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Contracts\Future\CanSetConnection; use Stancl\Tenancy\Contracts\Future\CanSetConnection;
use Stancl\Tenancy\Contracts\TenantDatabaseManager; use Stancl\Tenancy\Contracts\TenantDatabaseManager;
use Stancl\Tenancy\Tenant;
class PostgreSQLDatabaseManager implements TenantDatabaseManager, CanSetConnection class PostgreSQLDatabaseManager implements TenantDatabaseManager, CanSetConnection
{ {
@ -20,6 +21,11 @@ class PostgreSQLDatabaseManager implements TenantDatabaseManager, CanSetConnecti
$this->connection = $config->get('tenancy.database_manager_connections.pgsql'); $this->connection = $config->get('tenancy.database_manager_connections.pgsql');
} }
public function getSeparator(): string
{
return 'database';
}
protected function database(): Connection protected function database(): Connection
{ {
return DB::connection($this->connection); return DB::connection($this->connection);
@ -30,14 +36,14 @@ class PostgreSQLDatabaseManager implements TenantDatabaseManager, CanSetConnecti
$this->connection = $connection; $this->connection = $connection;
} }
public function createDatabase(string $name): bool public function createDatabase(Tenant $tenant): bool
{ {
return $this->database()->statement("CREATE DATABASE \"$name\" WITH TEMPLATE=template0"); return $this->database()->statement("CREATE DATABASE \"{$tenant->database()->getName()}\" WITH TEMPLATE=template0");
} }
public function deleteDatabase(string $name): bool public function deleteDatabase(Tenant $tenant): bool
{ {
return $this->database()->statement("DROP DATABASE \"$name\""); return $this->database()->statement("DROP DATABASE \"{$tenant->database()->getName()}\"");
} }
public function databaseExists(string $name): bool public function databaseExists(string $name): bool

View file

@ -9,6 +9,7 @@ use Illuminate\Database\Connection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Contracts\Future\CanSetConnection; use Stancl\Tenancy\Contracts\Future\CanSetConnection;
use Stancl\Tenancy\Contracts\TenantDatabaseManager; use Stancl\Tenancy\Contracts\TenantDatabaseManager;
use Stancl\Tenancy\Tenant;
class PostgreSQLSchemaManager implements TenantDatabaseManager, CanSetConnection class PostgreSQLSchemaManager implements TenantDatabaseManager, CanSetConnection
{ {
@ -20,6 +21,11 @@ class PostgreSQLSchemaManager implements TenantDatabaseManager, CanSetConnection
$this->connection = $config->get('tenancy.database_manager_connections.pgsql'); $this->connection = $config->get('tenancy.database_manager_connections.pgsql');
} }
public function getSeparator(): string
{
return 'schema';
}
protected function database(): Connection protected function database(): Connection
{ {
return DB::connection($this->connection); return DB::connection($this->connection);
@ -30,14 +36,14 @@ class PostgreSQLSchemaManager implements TenantDatabaseManager, CanSetConnection
$this->connection = $connection; $this->connection = $connection;
} }
public function createDatabase(string $name): bool public function createDatabase(Tenant $tenant): bool
{ {
return $this->database()->statement("CREATE SCHEMA \"$name\""); return $this->database()->statement("CREATE SCHEMA \"{$tenant->database()->getName()}\"");
} }
public function deleteDatabase(string $name): bool public function deleteDatabase(Tenant $tenant): bool
{ {
return $this->database()->statement("DROP SCHEMA \"$name\""); return $this->database()->statement("DROP SCHEMA \"{$tenant->database()->getName()}\"");
} }
public function databaseExists(string $name): bool public function databaseExists(string $name): bool

View file

@ -4,23 +4,30 @@ declare(strict_types=1);
namespace Stancl\Tenancy\TenantDatabaseManagers; namespace Stancl\Tenancy\TenantDatabaseManagers;
use Stancl\Tenancy\Contracts\ModifiesDatabaseNameForConnection;
use Stancl\Tenancy\Contracts\TenantDatabaseManager; use Stancl\Tenancy\Contracts\TenantDatabaseManager;
use Stancl\Tenancy\Tenant;
class SQLiteDatabaseManager implements TenantDatabaseManager class SQLiteDatabaseManager implements TenantDatabaseManager, ModifiesDatabaseNameForConnection
{ {
public function createDatabase(string $name): bool public function getSeparator(): string
{
return 'database';
}
public function createDatabase(Tenant $tenant): bool
{ {
try { try {
return fclose(fopen(database_path($name), 'w')); return fclose(fopen(database_path($tenant->database()->getName()), 'w'));
} catch (\Throwable $th) { } catch (\Throwable $th) {
return false; return false;
} }
} }
public function deleteDatabase(string $name): bool public function deleteDatabase(Tenant $tenant): bool
{ {
try { try {
return unlink(database_path($name)); return unlink(database_path($tenant->database()->getName()));
} catch (\Throwable $th) { } catch (\Throwable $th) {
return false; return false;
} }
@ -30,4 +37,9 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
{ {
return file_exists(database_path($name)); return file_exists(database_path($name));
} }
public function getDatabaseNameForConnection(string $original): string
{
return database_path($original);
}
} }

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Traits;
use Stancl\Tenancy\Tenant;
trait CreatesDatabaseUsers
{
public function createDatabase(Tenant $tenant): bool
{
return $this->database()->transaction(function () use ($tenant) {
parent::createDatabase($tenant);
return $this->createUser($tenant->database());
});
}
public function deleteDatabase(Tenant $tenant): bool
{
return $this->database()->transaction(function () use ($tenant) {
parent::deleteDatabase($tenant);
return $this->deleteUser($tenant->database());
});
}
}

View file

@ -12,6 +12,6 @@ trait DealsWithMigrations
return parent::getMigrationPaths(); return parent::getMigrationPaths();
} }
return config('tenancy.migration_paths', [config('tenancy.migrations_directory') ?? database_path('migrations/tenant')]); return database_path('migrations/tenant');
} }
} }

View file

@ -8,11 +8,10 @@ use Stancl\Tenancy\Tenant;
class CacheManagerTest extends TestCase class CacheManagerTest extends TestCase
{ {
public $autoInitTenancy = false;
/** @test */ /** @test */
public function default_tag_is_automatically_applied() public function default_tag_is_automatically_applied()
{ {
$this->createTenant();
$this->initTenancy(); $this->initTenancy();
$this->assertArrayIsSubset([config('tenancy.cache.tag_base') . tenant('id')], cache()->tags('foo')->getTags()->getNames()); $this->assertArrayIsSubset([config('tenancy.cache.tag_base') . tenant('id')], cache()->tags('foo')->getTags()->getNames());
} }
@ -20,6 +19,7 @@ class CacheManagerTest extends TestCase
/** @test */ /** @test */
public function tags_are_merged_when_array_is_passed() public function tags_are_merged_when_array_is_passed()
{ {
$this->createTenant();
$this->initTenancy(); $this->initTenancy();
$expected = [config('tenancy.cache.tag_base') . tenant('id'), 'foo', 'bar']; $expected = [config('tenancy.cache.tag_base') . tenant('id'), 'foo', 'bar'];
$this->assertEquals($expected, cache()->tags(['foo', 'bar'])->getTags()->getNames()); $this->assertEquals($expected, cache()->tags(['foo', 'bar'])->getTags()->getNames());
@ -28,6 +28,7 @@ class CacheManagerTest extends TestCase
/** @test */ /** @test */
public function tags_are_merged_when_string_is_passed() public function tags_are_merged_when_string_is_passed()
{ {
$this->createTenant();
$this->initTenancy(); $this->initTenancy();
$expected = [config('tenancy.cache.tag_base') . tenant('id'), 'foo']; $expected = [config('tenancy.cache.tag_base') . tenant('id'), 'foo'];
$this->assertEquals($expected, cache()->tags('foo')->getTags()->getNames()); $this->assertEquals($expected, cache()->tags('foo')->getTags()->getNames());
@ -36,6 +37,7 @@ class CacheManagerTest extends TestCase
/** @test */ /** @test */
public function exception_is_thrown_when_zero_arguments_are_passed_to_tags_method() public function exception_is_thrown_when_zero_arguments_are_passed_to_tags_method()
{ {
$this->createTenant();
$this->initTenancy(); $this->initTenancy();
$this->expectException(\Exception::class); $this->expectException(\Exception::class);
cache()->tags(); cache()->tags();
@ -44,6 +46,7 @@ class CacheManagerTest extends TestCase
/** @test */ /** @test */
public function exception_is_thrown_when_more_than_one_argument_is_passed_to_tags_method() public function exception_is_thrown_when_more_than_one_argument_is_passed_to_tags_method()
{ {
$this->createTenant();
$this->initTenancy(); $this->initTenancy();
$this->expectException(\Exception::class); $this->expectException(\Exception::class);
cache()->tags(1, 2); cache()->tags(1, 2);

View file

@ -12,15 +12,9 @@ use Stancl\Tenancy\Tests\Etc\ExampleSeeder;
class CommandsTest extends TestCase class CommandsTest extends TestCase
{ {
public $autoCreateTenant = true;
public $autoInitTenancy = false; public $autoInitTenancy = false;
public function setUp(): void
{
parent::setUp();
config(['tenancy.migration_paths', [database_path('../migrations')]]);
}
/** @test */ /** @test */
public function migrate_command_doesnt_change_the_db_connection() public function migrate_command_doesnt_change_the_db_connection()
{ {
@ -173,6 +167,7 @@ class CommandsTest extends TestCase
$tenant = tenancy()->all()[1]; // a tenant is autocreated prior to this $tenant = tenancy()->all()[1]; // a tenant is autocreated prior to this
$data = $tenant->data; $data = $tenant->data;
unset($data['id']); unset($data['id']);
unset($data['_tenancy_db_name']);
$this->assertSame(['plan' => 'free', 'email' => 'foo@test.local'], $data); $this->assertSame(['plan' => 'free', 'email' => 'foo@test.local'], $data);
$this->assertSame(['aaa.localhost', 'bbb.localhost'], $tenant->domains); $this->assertSame(['aaa.localhost', 'bbb.localhost'], $tenant->domains);

View file

@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Stancl\Tenancy\Tenant; use Stancl\Tenancy\Tenant;
use Stancl\Tenancy\Tests\Etc\User;
class DataSeparationTest extends TestCase class DataSeparationTest extends TestCase
{ {
@ -151,8 +152,3 @@ class DataSeparationTest extends TestCase
$this->assertFalse(Storage::disk('public')->exists('abc')); $this->assertFalse(Storage::disk('public')->exists('abc'));
} }
} }
class User extends \Illuminate\Database\Eloquent\Model
{
protected $guarded = [];
}

View file

@ -5,16 +5,16 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Tests; namespace Stancl\Tenancy\Tests;
use Stancl\Tenancy\DatabaseManager; use Stancl\Tenancy\DatabaseManager;
use Stancl\Tenancy\Tenant;
class DatabaseManagerTest extends TestCase class DatabaseManagerTest extends TestCase
{ {
public $autoInitTenancy = false;
/** @test */ /** @test */
public function reconnect_method_works() public function reconnect_method_works()
{ {
$old_connection_name = app(\Illuminate\Database\DatabaseManager::class)->connection()->getName(); $old_connection_name = app(\Illuminate\Database\DatabaseManager::class)->connection()->getName();
tenancy()->init('test.localhost'); $this->createTenant();
$this->initTenancy();
app(\Stancl\Tenancy\DatabaseManager::class)->reconnect(); app(\Stancl\Tenancy\DatabaseManager::class)->reconnect();
$new_connection_name = app(\Illuminate\Database\DatabaseManager::class)->connection()->getName(); $new_connection_name = app(\Illuminate\Database\DatabaseManager::class)->connection()->getName();
@ -25,22 +25,30 @@ class DatabaseManagerTest extends TestCase
/** @test */ /** @test */
public function db_name_is_prefixed_with_db_path_when_sqlite_is_used() public function db_name_is_prefixed_with_db_path_when_sqlite_is_used()
{ {
if (file_exists(database_path('foodb'))) {
unlink(database_path('foodb')); // cleanup
}
config(['database.connections.fooconn.driver' => 'sqlite']); config(['database.connections.fooconn.driver' => 'sqlite']);
app(DatabaseManager::class)->createTenantConnection('foodb', 'fooconn'); $tenant = Tenant::new()->withData([
'_tenancy_db_name' => 'foodb',
'_tenancy_db_connection' => 'fooconn',
])->save();
app(DatabaseManager::class)->createTenantConnection($tenant);
$this->assertSame(config('database.connections.fooconn.database'), database_path('foodb')); $this->assertSame(config('database.connections.tenant.database'), database_path('foodb'));
} }
/** @test */ /** @test */
public function the_default_db_is_used_when_based_on_is_null() public function the_default_db_is_used_when_template_connection_is_null()
{ {
$this->assertSame('sqlite', config('database.default')); $this->assertSame('central', config('database.default'));
config([ config([
'database.connections.sqlite.foo' => 'bar', 'database.connections.central.foo' => 'bar',
'tenancy.database.based_on' => null, 'tenancy.database.template_connection' => null,
]); ]);
tenancy()->init('test.localhost'); $this->createTenant();
$this->initTenancy();
$this->assertSame('tenant', config('database.default')); $this->assertSame('tenant', config('database.default'));
$this->assertSame('bar', config('database.connections.' . config('database.default') . '.foo')); $this->assertSame('bar', config('database.connections.' . config('database.default') . '.foo'));

View file

@ -6,9 +6,11 @@ namespace Stancl\Tenancy\Tests;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Stancl\Tenancy\Tenant; use Stancl\Tenancy\Tenant;
use Stancl\Tenancy\Tests\Etc\User;
class DatabaseSchemaManagerTest extends TestCase class DatabaseSchemaManagerTest extends TestCase
{ {
public $autoCreateTenant = true;
public $autoInitTenancy = false; public $autoInitTenancy = false;
protected function getEnvironmentSetUp($app) protected function getEnvironmentSetUp($app)
@ -17,11 +19,11 @@ class DatabaseSchemaManagerTest extends TestCase
$app['config']->set([ $app['config']->set([
'database.default' => 'pgsql', 'database.default' => 'pgsql',
'tenancy.storage_drivers.db.connection' => 'pgsql',
'database.connections.pgsql.database' => 'main', 'database.connections.pgsql.database' => 'main',
'database.connections.pgsql.schema' => 'public', 'database.connections.pgsql.schema' => 'public',
'tenancy.database.based_on' => null, 'tenancy.database.template_connection' => null,
'tenancy.database.suffix' => '', 'tenancy.database.suffix' => '',
'tenancy.database.separate_by' => 'schema',
'tenancy.database_managers.pgsql' => \Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLSchemaManager::class, 'tenancy.database_managers.pgsql' => \Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLSchemaManager::class,
]); ]);
} }
@ -41,20 +43,18 @@ class DatabaseSchemaManagerTest extends TestCase
} }
/** @test */ /** @test */
public function the_default_db_is_used_when_based_on_is_null() public function the_default_db_is_used_when_template_connection_is_null()
{ {
config(['database.default' => 'pgsql']);
$this->assertSame('pgsql', config('database.default')); $this->assertSame('pgsql', config('database.default'));
config([ config([
'database.connections.pgsql.foo' => 'bar', 'database.connections.pgsql.foo' => 'bar',
'tenancy.database.based_on' => null, 'tenancy.database.template_connection' => null,
]); ]);
tenancy()->init('test.localhost'); tenancy()->init('test.localhost');
$this->assertSame('tenant', config('database.default')); $this->assertSame('tenant', config('database.default'));
$this->assertSame('bar', config('database.connections.' . config('database.default') . '.foo')); $this->assertSame('bar', config('database.connections.tenant.foo'));
} }
/** @test */ /** @test */
@ -63,7 +63,7 @@ class DatabaseSchemaManagerTest extends TestCase
$tenant = tenancy()->create(['schema.localhost']); $tenant = tenancy()->create(['schema.localhost']);
tenancy()->init('schema.localhost'); tenancy()->init('schema.localhost');
$this->assertSame($tenant->getDatabaseName(), config('database.connections.' . config('database.default') . '.schema')); $this->assertSame($tenant->database()->getName(), config('database.connections.' . config('database.default') . '.schema'));
} }
/** @test */ /** @test */
@ -96,6 +96,7 @@ class DatabaseSchemaManagerTest extends TestCase
$tenant1 = Tenant::create('tenant1.localhost'); $tenant1 = Tenant::create('tenant1.localhost');
$tenant2 = Tenant::create('tenant2.localhost'); $tenant2 = Tenant::create('tenant2.localhost');
\Artisan::call('tenants:migrate', [ \Artisan::call('tenants:migrate', [
'--tenants' => [$tenant1['id'], $tenant2['id']], '--tenants' => [$tenant1['id'], $tenant2['id']],
]); ]);

113
tests/DatabaseUsersTest.php Normal file
View file

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Tests;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Stancl\Tenancy\Contracts\ManagesDatabaseUsers;
use Stancl\Tenancy\Exceptions\TenantDatabaseUserAlreadyExistsException;
use Stancl\Tenancy\Tenant;
use Stancl\Tenancy\TenantDatabaseManagers\MySQLDatabaseManager;
use Stancl\Tenancy\TenantDatabaseManagers\PermissionControlledMySQLDatabaseManager;
class DatabaseUsersTest extends TestCase
{
public function setUp(): void
{
parent::setUp();
config([
'tenancy.database_managers.mysql' => PermissionControlledMySQLDatabaseManager::class,
'tenancy.database.suffix' => '',
'tenancy.database.template_connection' => 'mysql',
]);
}
/** @test */
public function users_are_created_when_permission_controlled_mysql_manager_is_used()
{
$tenant = Tenant::new()->withData([
'id' => 'foo' . Str::random(10),
]);
$tenant->database()->makeCredentials();
/** @var ManagesDatabaseUsers $manager */
$manager = $tenant->database()->manager();
$this->assertFalse($manager->userExists($tenant->database()->getUsername()));
$tenant->save();
$this->assertTrue($manager->userExists($tenant->database()->getUsername()));
}
/** @test */
public function a_tenants_database_cannot_be_created_when_the_user_already_exists()
{
$username = 'foo' . Str::random(8);
$tenant = Tenant::new()->withData([
'_tenancy_db_username' => $username,
])->save();
/** @var ManagesDatabaseUsers $manager */
$manager = $tenant->database()->manager();
$this->assertTrue($manager->userExists($tenant->database()->getUsername()));
$this->assertTrue($manager->databaseExists($tenant->database()->getName()));
$this->expectException(TenantDatabaseUserAlreadyExistsException::class);
$tenant2 = Tenant::new()->withData([
'_tenancy_db_username' => $username,
])->save();
/** @var ManagesDatabaseUsers $manager */
$manager = $tenant2->database()->manager();
// database was not created because of DB transaction
$this->assertFalse($manager->databaseExists($tenant2->database()->getName()));
}
/** @test */
public function correct_grants_are_given_to_users()
{
PermissionControlledMySQLDatabaseManager::$grants = [
'ALTER', 'ALTER ROUTINE', 'CREATE',
];
$tenant = Tenant::new()->withData([
'_tenancy_db_username' => $user = 'user' . Str::random(8),
])->save();
$query = DB::connection('mysql')->select("SHOW GRANTS FOR `{$tenant->database()->getUsername()}`@`{$tenant->database()->connection()['host']}`")[1];
$this->assertStringStartsWith('GRANT CREATE, ALTER, ALTER ROUTINE ON', $query->{"Grants for {$user}@mysql"}); // @mysql because that's the hostname within the docker network
}
/** @test */
public function having_existing_databases_without_users_and_switching_to_permission_controlled_mysql_manager_doesnt_break_existing_dbs()
{
config([
'tenancy.database_managers.mysql' => MySQLDatabaseManager::class,
'tenancy.database.suffix' => '',
'tenancy.database.template_connection' => 'mysql',
]);
$tenant = Tenant::new()->withData([
'id' => 'foo' . Str::random(10),
])->save();
$this->assertTrue($tenant->database()->manager() instanceof MySQLDatabaseManager);
$tenant = Tenant::new()->withData([
'id' => 'foo' . Str::random(10),
])->save();
tenancy()->initialize($tenant); // check if everything works
tenancy()->end();
config(['tenancy.database_managers.mysql' => PermissionControlledMySQLDatabaseManager::class]);
tenancy()->initialize($tenant); // check if everything works
$this->assertTrue($tenant->database()->manager() instanceof PermissionControlledMySQLDatabaseManager);
$this->assertSame('root', config('database.connections.tenant.username'));
}
}

10
tests/Etc/User.php Normal file
View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Tests\Etc;
class User extends \Illuminate\Database\Eloquent\Model
{
protected $guarded = [];
}

View file

@ -9,6 +9,9 @@ use Tenant;
class FacadeTest extends TestCase class FacadeTest extends TestCase
{ {
public $autoCreateTenant = true;
public $autoInitTenancy = true;
/** @test */ /** @test */
public function tenant_manager_can_be_accessed_using_the_Tenancy_facade() public function tenant_manager_can_be_accessed_using_the_Tenancy_facade()
{ {

View file

@ -14,6 +14,9 @@ use Illuminate\Support\Facades\Event;
class QueueTest extends TestCase class QueueTest extends TestCase
{ {
public $autoCreateTenant = true;
public $autoInitTenancy = true;
/** @test */ /** @test */
public function queues_use_non_tenant_db_connection() public function queues_use_non_tenant_db_connection()
{ {

View file

@ -8,7 +8,9 @@ use Stancl\Tenancy\Tenant;
class ReidentificationTest extends TestCase class ReidentificationTest extends TestCase
{ {
public $autoCreateTenant = true;
public $autoInitTenancy = false; public $autoInitTenancy = false;
/** /**
* These tests are run when a tenant is identified after another tenant has already been identified. * These tests are run when a tenant is identified after another tenant has already been identified.
*/ */

View file

@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Redis;
class TenancyBootstrappersTest extends TestCase class TenancyBootstrappersTest extends TestCase
{ {
public $autoCreateTenant = true;
public $autoInitTenancy = false; public $autoInitTenancy = false;
/** @test */ /** @test */

View file

@ -54,8 +54,8 @@ class TenantClassTest extends TestCase
$tenant = Tenant::create(['foo.localhost'], ['id' => $id]); $tenant = Tenant::create(['foo.localhost'], ['id' => $id]);
$tenant->foo = 'bar'; $tenant->foo = 'bar';
$tenant->save(); $tenant->save();
$this->assertEquals(['id' => $id, 'foo' => 'bar'], $tenant->data); $this->assertEquals(['id' => $id, 'foo' => 'bar', '_tenancy_db_name' => $tenant->database()->getName()], $tenant->data);
$this->assertEquals(['id' => $id, 'foo' => 'bar'], tenancy()->find($id)->data); $this->assertEquals(['id' => $id, 'foo' => 'bar', '_tenancy_db_name' => $tenant->database()->getName()], tenancy()->find($id)->data);
$tenant->addDomains('abc.localhost'); $tenant->addDomains('abc.localhost');
$tenant->save(); $tenant->save();
@ -102,6 +102,7 @@ class TenantClassTest extends TestCase
$data = tenancy()->all()->first()->data; $data = tenancy()->all()->first()->data;
unset($data['id']); unset($data['id']);
unset($data['_tenancy_db_name']);
$this->assertSame(['foo' => 'bar'], $data); $this->assertSame(['foo' => 'bar'], $data);
} }
@ -173,4 +174,54 @@ class TenantClassTest extends TestCase
return $tenant->id; return $tenant->id;
})); }));
} }
/** @test */
public function extra_config_is_merged_into_the_connection_config_array()
{
$tenant = Tenant::new()->withData([
'_tenancy_db_link' => 'foo',
'_tenancy_db_name' => 'dbname',
'_tenancy_db_username' => 'usernamefoo',
'_tenancy_db_password' => 'passwordfoo',
'_tenancy_db_connection' => 'mysql',
]);
config(['database.connections.mysql' => [
'driver' => 'mysql',
'url' => null,
'host' => 'mysql',
'port' => '3306',
'database' => 'main',
'username' => 'root',
'password' => 'password',
'unix_socket' => '',
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => [],
]]);
$this->assertEquals([
'database' => 'dbname',
'username' => 'usernamefoo',
'password' => 'passwordfoo',
'link' => 'foo',
'driver' => 'mysql',
'url' => null,
'host' => 'mysql',
'port' => '3306',
'unix_socket' => '',
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => [],
], $tenant->database()->connection());
}
} }

View file

@ -9,6 +9,7 @@ use Stancl\Tenancy\Jobs\QueuedTenantDatabaseCreator;
use Stancl\Tenancy\Jobs\QueuedTenantDatabaseDeleter; use Stancl\Tenancy\Jobs\QueuedTenantDatabaseDeleter;
use Stancl\Tenancy\Tenant; use Stancl\Tenancy\Tenant;
use Stancl\Tenancy\TenantDatabaseManagers\MySQLDatabaseManager; use Stancl\Tenancy\TenantDatabaseManagers\MySQLDatabaseManager;
use Stancl\Tenancy\TenantDatabaseManagers\PermissionControlledMySQLDatabaseManager;
use Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLDatabaseManager; use Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLDatabaseManager;
use Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLSchemaManager; use Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLSchemaManager;
use Stancl\Tenancy\TenantDatabaseManagers\SQLiteDatabaseManager; use Stancl\Tenancy\TenantDatabaseManagers\SQLiteDatabaseManager;
@ -27,25 +28,46 @@ class TenantDatabaseManagerTest extends TestCase
$this->markTestSkipped('As to not bloat your computer with test databases, this test is not run by default.'); $this->markTestSkipped('As to not bloat your computer with test databases, this test is not run by default.');
} }
config()->set([
"tenancy.database_managers.$driver" => $databaseManager,
]);
$name = 'db' . $this->randomString(); $name = 'db' . $this->randomString();
$tenant = Tenant::new()->withData([
'_tenancy_db_name' => $name,
'_tenancy_db_connection' => $driver,
]);
$this->assertFalse(app($databaseManager)->databaseExists($name)); $this->assertFalse(app($databaseManager)->databaseExists($name));
app($databaseManager)->createDatabase($name); $tenant->save(); // generate credentials & create DB
$this->assertTrue(app($databaseManager)->databaseExists($name)); $this->assertTrue(app($databaseManager)->databaseExists($name));
app($databaseManager)->deleteDatabase($name); app($databaseManager)->deleteDatabase($tenant);
$this->assertFalse(app($databaseManager)->databaseExists($name)); $this->assertFalse(app($databaseManager)->databaseExists($name));
} }
/** @test */ /** @test */
public function dbs_can_be_created_when_another_driver_is_used_for_the_central_db() public function dbs_can_be_created_when_another_driver_is_used_for_the_central_db()
{ {
$this->assertSame('sqlite', config('database.default')); $this->assertSame('central', config('database.default'));
$database = 'db' . $this->randomString(); $database = 'db' . $this->randomString();
app(MySQLDatabaseManager::class)->createDatabase($database); $tenant = Tenant::new()->withData([
'_tenancy_db_name' => $database,
'_tenancy_db_connection' => 'mysql',
]);
$this->assertFalse(app(MySQLDatabaseManager::class)->databaseExists($database));
$tenant->save(); // create DB
$this->assertTrue(app(MySQLDatabaseManager::class)->databaseExists($database)); $this->assertTrue(app(MySQLDatabaseManager::class)->databaseExists($database));
$database = 'db2' . $this->randomString(); $database = 'db' . $this->randomString();
app(PostgreSQLDatabaseManager::class)->createDatabase($database); $tenant = Tenant::new()->withData([
'_tenancy_db_name' => $database,
'_tenancy_db_connection' => 'pgsql',
]);
$this->assertFalse(app(PostgreSQLDatabaseManager::class)->databaseExists($database));
$tenant->save(); // create DB
$this->assertTrue(app(PostgreSQLDatabaseManager::class)->databaseExists($database)); $this->assertTrue(app(PostgreSQLDatabaseManager::class)->databaseExists($database));
} }
@ -59,16 +81,25 @@ class TenantDatabaseManagerTest extends TestCase
$this->markTestSkipped('As to not bloat your computer with test databases, this test is not run by default.'); $this->markTestSkipped('As to not bloat your computer with test databases, this test is not run by default.');
} }
config()->set('database.default', $driver); config()->set([
'database.default' => $driver,
"tenancy.database_managers.$driver" => $databaseManager,
]);
$name = 'db' . $this->randomString(); $name = 'db' . $this->randomString();
$tenant = Tenant::new()->withData([
'_tenancy_db_name' => $name,
'_tenancy_db_connection' => $driver,
]);
$tenant->database()->makeCredentials();
$this->assertFalse(app($databaseManager)->databaseExists($name)); $this->assertFalse(app($databaseManager)->databaseExists($name));
$job = new QueuedTenantDatabaseCreator(app($databaseManager), $name); $job = new QueuedTenantDatabaseCreator(app($databaseManager), $tenant);
$job->handle(); $job->handle();
$this->assertTrue(app($databaseManager)->databaseExists($name)); $this->assertTrue(app($databaseManager)->databaseExists($name));
$job = new QueuedTenantDatabaseDeleter(app($databaseManager), $name); $job = new QueuedTenantDatabaseDeleter(app($databaseManager), $tenant);
$job->handle(); $job->handle();
$this->assertFalse(app($databaseManager)->databaseExists($name)); $this->assertFalse(app($databaseManager)->databaseExists($name));
} }
@ -77,6 +108,7 @@ class TenantDatabaseManagerTest extends TestCase
{ {
return [ return [
['mysql', MySQLDatabaseManager::class], ['mysql', MySQLDatabaseManager::class],
['mysql', PermissionControlledMySQLDatabaseManager::class],
['sqlite', SQLiteDatabaseManager::class], ['sqlite', SQLiteDatabaseManager::class],
['pgsql', PostgreSQLDatabaseManager::class], ['pgsql', PostgreSQLDatabaseManager::class],
['pgsql', PostgreSQLSchemaManager::class], ['pgsql', PostgreSQLSchemaManager::class],

View file

@ -167,6 +167,7 @@ class TenantManagerTest extends TestCase
$tenant_data = $tenant->data; $tenant_data = $tenant->data;
unset($tenant_data['id']); unset($tenant_data['id']);
unset($tenant_data['_tenancy_db_name']);
$this->assertSame($data, $tenant_data); $this->assertSame($data, $tenant_data);
} }
@ -180,7 +181,7 @@ class TenantManagerTest extends TestCase
'_tenancy_db_name' => $database, '_tenancy_db_name' => $database,
]); ]);
$this->assertSame($database, $tenant->getDatabaseName()); $this->assertSame($database, $tenant->database()->getName());
} }
/** @test */ /** @test */

View file

@ -9,6 +9,9 @@ use Stancl\Tenancy\Tenant;
class TenantStorageTest extends TestCase class TenantStorageTest extends TestCase
{ {
public $autoCreateTenant = true;
public $autoInitTenancy = true;
/** @test */ /** @test */
public function deleting_a_tenant_works() public function deleting_a_tenant_works()
{ {

View file

@ -9,8 +9,8 @@ use Stancl\Tenancy\Tenant;
abstract class TestCase extends \Orchestra\Testbench\TestCase abstract class TestCase extends \Orchestra\Testbench\TestCase
{ {
public $autoCreateTenant = true; public $autoCreateTenant = false;
public $autoInitTenancy = true; public $autoInitTenancy = false;
/** /**
* Setup the test environment. * Setup the test environment.
@ -24,12 +24,12 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
Redis::connection('tenancy')->flushdb(); Redis::connection('tenancy')->flushdb();
Redis::connection('cache')->flushdb(); Redis::connection('cache')->flushdb();
$originalConnection = config('database.default'); file_put_contents(database_path('central.sqlite'), '');
$this->loadMigrationsFrom([ $this->artisan('migrate:fresh', [
'--path' => realpath(__DIR__ . '/../assets/migrations'), '--force' => true,
'--database' => 'central', '--path' => __DIR__ . '/../assets/migrations',
'--realpath' => true,
]); ]);
config(['database.default' => $originalConnection]); // fix issue caused by loadMigrationsFrom
if ($this->autoCreateTenant) { if ($this->autoCreateTenant) {
$this->createTenant(); $this->createTenant();
@ -62,9 +62,8 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
\Dotenv\Dotenv::create(__DIR__ . '/..')->load(); \Dotenv\Dotenv::create(__DIR__ . '/..')->load();
} }
fclose(fopen(database_path('central.sqlite'), 'w'));
$app['config']->set([ $app['config']->set([
'database.default' => 'central',
'database.redis.cache.host' => env('TENANCY_TEST_REDIS_HOST', '127.0.0.1'), 'database.redis.cache.host' => env('TENANCY_TEST_REDIS_HOST', '127.0.0.1'),
'database.redis.default.host' => env('TENANCY_TEST_REDIS_HOST', '127.0.0.1'), 'database.redis.default.host' => env('TENANCY_TEST_REDIS_HOST', '127.0.0.1'),
'database.redis.options.prefix' => 'foo', 'database.redis.options.prefix' => 'foo',
@ -80,9 +79,10 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
'database.connections.central' => [ 'database.connections.central' => [
'driver' => 'sqlite', 'driver' => 'sqlite',
'database' => database_path('central.sqlite'), 'database' => database_path('central.sqlite'),
// 'database' => ':memory:',
], ],
'tenancy.database' => [ 'tenancy.database' => [
'based_on' => 'sqlite', 'template_connection' => 'central',
'prefix' => 'tenant', 'prefix' => 'tenant',
'suffix' => '.sqlite', 'suffix' => '.sqlite',
], ],
@ -97,7 +97,11 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
'tenancy.redis.tenancy' => env('TENANCY_TEST_REDIS_TENANCY', true), 'tenancy.redis.tenancy' => env('TENANCY_TEST_REDIS_TENANCY', true),
'database.redis.client' => env('TENANCY_TEST_REDIS_CLIENT', 'phpredis'), 'database.redis.client' => env('TENANCY_TEST_REDIS_CLIENT', 'phpredis'),
'tenancy.redis.prefixed_connections' => ['default'], 'tenancy.redis.prefixed_connections' => ['default'],
'tenancy.migration_paths' => [database_path('../migrations')], 'tenancy.migration_parameters' => [
'--path' => [database_path('../migrations')],
'--realpath' => true,
'--force' => true,
],
'tenancy.storage_drivers.db.connection' => 'central', 'tenancy.storage_drivers.db.connection' => 'central',
'tenancy.bootstrappers.redis' => \Stancl\Tenancy\TenancyBootstrappers\RedisTenancyBootstrapper::class, 'tenancy.bootstrappers.redis' => \Stancl\Tenancy\TenancyBootstrappers\RedisTenancyBootstrapper::class,
'queue.connections.central' => [ 'queue.connections.central' => [