From 869ac32983c8b68e4010a00204923be2f5afbff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Sun, 27 Oct 2019 21:10:41 +0100 Subject: [PATCH] [2.2.0] [WIP] Add functionality (#206) * TenantDatabaseDoesNotExistException * Apply fixes from StyleCI * User post-creation callbacks * Rename method * postCreationActions * pass $tenant as parameter * pass $tenant to async actions * WIP findBy() * findBy\* ForwardsCalls * Apply fixes from StyleCI * findBy DB storage driver * Redis SD TODO message * Apply fixes from StyleCI * Fix chained jobs * WIP event system * import str * instanceof closure check * findBy instead of find * Tenant -> Tenants * dots * Use DB hooks instead of a SC key * Don't allow callables for queue chain * CanDeleteKeys interface * Apply fixes from StyleCI * CanFindByAnyKey interface * Apply fixes from StyleCI * Ditch models for custom repositories * Resolve circular dependency * Apply fixes from StyleCI * Fix tests * Apply fixes from StyleCI * FutureTest * Prefix tenant events with 'tenant.' * Event listener arguments test --- CONTRIBUTING.md | 7 +- assets/config.php | 6 +- src/Contracts/Future/CanDeleteKeys.php | 21 ++ src/Contracts/Future/CanFindByAnyKey.php | 24 +++ src/DatabaseManager.php | 52 ++++- src/Exceptions/NotImplementedException.php | 15 ++ .../TenantDatabaseDoesNotExistException.php | 15 ++ .../TenantDoesNotExistException.php | 4 +- .../Database/DatabaseStorageDriver.php | 118 ++++++----- src/StorageDrivers/Database/DomainModel.php | 25 --- .../Database/DomainRepository.php | 58 ++++++ src/StorageDrivers/Database/Repository.php | 41 ++++ src/StorageDrivers/Database/TenantModel.php | 142 ------------- .../Database/TenantRepository.php | 186 ++++++++++++++++++ src/StorageDrivers/RedisStorageDriver.php | 10 +- .../DatabaseTenancyBootstrapper.php | 6 + src/Tenant.php | 35 ++++ src/TenantManager.php | 71 ++++++- tests/CacheManagerTest.php | 7 + tests/FutureTest.php | 45 +++++ tests/TenantManagerTest.php | 10 + tests/TenantStorageTest.php | 10 +- 22 files changed, 654 insertions(+), 254 deletions(-) create mode 100644 src/Contracts/Future/CanDeleteKeys.php create mode 100644 src/Contracts/Future/CanFindByAnyKey.php create mode 100644 src/Exceptions/NotImplementedException.php create mode 100644 src/Exceptions/TenantDatabaseDoesNotExistException.php delete mode 100644 src/StorageDrivers/Database/DomainModel.php create mode 100644 src/StorageDrivers/Database/DomainRepository.php create mode 100644 src/StorageDrivers/Database/Repository.php delete mode 100644 src/StorageDrivers/Database/TenantModel.php create mode 100644 src/StorageDrivers/Database/TenantRepository.php create mode 100644 tests/FutureTest.php diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db8cc9d7..82f7aa5d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,15 +1,14 @@ # Contributing ## Code style - -StyleCI will automatically fix code style violations in your pull requests. +StyleCI will flag code style violations in your pull requests. ## Running tests ### With Docker -If you have Docker installed, simply run ./test. When you're done testing, run docker-compose down to shut down the containers. +If you have Docker installed, simply run ./fulltest. When you're done testing, run docker-compose down to shut down the containers. ### Without Docker If you run the tests of this package, please make sure you don't store anything in Redis @ 127.0.0.1:6379 db#14. The contents of this database are flushed everytime the tests are run. -Some tests are run only if the CI, TRAVIS and CONTINUOUS_INTEGRATION environment variables are set to true. This is to avoid things like bloating your MySQL instance with test databases. +Some tests are run only if the `CONTINUOUS_INTEGRATION` or `DOCKER` environment variables are set to true. This is to avoid things like bloating your MySQL instance with test databases. diff --git a/assets/config.php b/assets/config.php index e80edfb0..4b0aa810 100644 --- a/assets/config.php +++ b/assets/config.php @@ -13,8 +13,8 @@ return [ ], 'connection' => null, // Your central database connection. Set to null to use the default connection. 'table_names' => [ - 'TenantModel' => 'tenants', - 'DomainModel' => 'domains', + 'tenants' => 'tenants', + 'domains' => 'domains', ], ], 'redis' => [ @@ -93,7 +93,7 @@ return [ 'migrate_after_creation' => false, // run migrations after creating a tenant 'seed_after_migration' => false, // should the seeder run after automatic migration 'seeder_parameters' => [ - '--class' => 'DatabaseSeeder', // root seeder class to run after automatic migrations, eg: 'DatabaseSeeder' + '--class' => 'DatabaseSeeder', // root seeder class to run after automatic migrations, e.g.: 'DatabaseSeeder' ], 'queue_database_deletion' => false, 'delete_database_after_tenant_deletion' => false, // delete the tenant's database after deleting the tenant diff --git a/src/Contracts/Future/CanDeleteKeys.php b/src/Contracts/Future/CanDeleteKeys.php new file mode 100644 index 00000000..25f2f5f6 --- /dev/null +++ b/src/Contracts/Future/CanDeleteKeys.php @@ -0,0 +1,21 @@ +app = $app; @@ -30,6 +35,19 @@ class DatabaseManager $this->originalDefaultConnectionName = $app['config']['database.default']; } + /** + * Set the TenantManager instance, used to dispatch tenancy events. + * + * @param TenantManager $tenantManager + * @return self + */ + public function withTenantManager(TenantManager $tenantManager): self + { + $this->tenancy = $tenantManager; + + return $this; + } + /** * Connect to a tenant's database. * @@ -140,7 +158,7 @@ class DatabaseManager * Create a database for a tenant. * * @param Tenant $tenant - * @param \Illuminate\Contracts\Queue\ShouldQueue[]|callable[] $afterCreating + * @param ShouldQueue[]|callable[] $afterCreating * @return void */ public function createDatabase(Tenant $tenant, array $afterCreating = []) @@ -148,14 +166,34 @@ class DatabaseManager $database = $tenant->getDatabaseName(); $manager = $this->getTenantDatabaseManager($tenant); + $afterCreating = array_merge( + $afterCreating, + $this->tenancy->event('database.creating', $database, $tenant) + ); + if ($this->app['config']['tenancy.queue_database_creation'] ?? false) { - QueuedTenantDatabaseCreator::withChain($afterCreating)->dispatch($manager, $database); + $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; + } + } + + QueuedTenantDatabaseCreator::withChain($chain)->dispatch($manager, $database); } else { $manager->createDatabase($database); - foreach ($afterCreating as $callback) { - $callback(); + foreach ($afterCreating as $item) { + if (is_object($item) && ! $item instanceof Closure) { + $item->handle($tenant); + } else { + $item($tenant); + } } } + + $this->tenancy->event('database.created', $database, $tenant); } /** @@ -169,11 +207,15 @@ class DatabaseManager $database = $tenant->getDatabaseName(); $manager = $this->getTenantDatabaseManager($tenant); + $this->tenancy->event('database.deleting', $database, $tenant); + if ($this->app['config']['tenancy.queue_database_deletion'] ?? false) { QueuedTenantDatabaseDeleter::dispatch($manager, $database); } else { $manager->deleteDatabase($database); } + + $this->tenancy->event('database.deleted', $database, $tenant); } /** @@ -182,7 +224,7 @@ class DatabaseManager * @param Tenant $tenant * @return TenantDatabaseManager */ - protected function getTenantDatabaseManager(Tenant $tenant): TenantDatabaseManager + public function getTenantDatabaseManager(Tenant $tenant): TenantDatabaseManager { $driver = $this->getDriver($this->getBaseConnection($tenant->getConnectionName())); diff --git a/src/Exceptions/NotImplementedException.php b/src/Exceptions/NotImplementedException.php new file mode 100644 index 00000000..4ae156f7 --- /dev/null +++ b/src/Exceptions/NotImplementedException.php @@ -0,0 +1,15 @@ +message = "Tenant with this id does not exist: $id"; + $this->message = "Tenant with this $key does not exist: $id"; } } diff --git a/src/StorageDrivers/Database/DatabaseStorageDriver.php b/src/StorageDrivers/Database/DatabaseStorageDriver.php index 0b649eae..383cb890 100644 --- a/src/StorageDrivers/Database/DatabaseStorageDriver.php +++ b/src/StorageDrivers/Database/DatabaseStorageDriver.php @@ -4,41 +4,51 @@ declare(strict_types=1); namespace Stancl\Tenancy\StorageDrivers\Database; +use Illuminate\Config\Repository as ConfigRepository; +use Illuminate\Database\Connection; use Illuminate\Foundation\Application; use Illuminate\Support\Facades\DB; +use Stancl\Tenancy\Contracts\Future\CanDeleteKeys; +use Stancl\Tenancy\Contracts\Future\CanFindByAnyKey; use Stancl\Tenancy\Contracts\StorageDriver; use Stancl\Tenancy\DatabaseManager; use Stancl\Tenancy\Exceptions\DomainsOccupiedByOtherTenantException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedException; use Stancl\Tenancy\Exceptions\TenantDoesNotExistException; use Stancl\Tenancy\Exceptions\TenantWithThisIdAlreadyExistsException; -use Stancl\Tenancy\StorageDrivers\Database\DomainModel as Domains; -use Stancl\Tenancy\StorageDrivers\Database\TenantModel as Tenants; use Stancl\Tenancy\Tenant; -class DatabaseStorageDriver implements StorageDriver +class DatabaseStorageDriver implements StorageDriver, CanDeleteKeys, CanFindByAnyKey { /** @var Application */ protected $app; - /** @var \Illuminate\Database\Connection */ + /** @var Connection */ protected $centralDatabase; + /** @var TenantRepository */ + protected $tenants; + + /** @var DomainRepository */ + protected $domains; + /** @var Tenant The default tenant. */ protected $tenant; - public function __construct(Application $app) + public function __construct(Application $app, ConfigRepository $config) { $this->app = $app; $this->centralDatabase = $this->getCentralConnection(); + $this->tenants = new TenantRepository($config); + $this->domains = new DomainRepository($config); } /** * Get the central database connection. * - * @return \Illuminate\Database\Connection + * @return Connection */ - public static function getCentralConnection(): \Illuminate\Database\Connection + public static function getCentralConnection(): Connection { return DB::connection(static::getCentralConnectionName()); } @@ -50,7 +60,7 @@ class DatabaseStorageDriver implements StorageDriver public function findByDomain(string $domain): Tenant { - $id = $this->getTenantIdByDomain($domain); + $id = $this->domains->getTenantIdByDomain($domain); if (! $id) { throw new TenantCouldNotBeIdentifiedException($domain); } @@ -60,30 +70,43 @@ class DatabaseStorageDriver implements StorageDriver public function findById(string $id): Tenant { - $tenant = Tenants::find($id); + $tenant = $this->tenants->find($id); if (! $tenant) { throw new TenantDoesNotExistException($id); } - return Tenant::fromStorage($tenant->decoded()) - ->withDomains($this->getTenantDomains($id)); + return Tenant::fromStorage($this->tenants->decodeData($tenant)) + ->withDomains($this->domains->getTenantDomains($id)); } - protected function getTenantDomains($id) + /** + * Find a tenant using an arbitrary key. + * + * @param string $key + * @param mixed $value + * @return Tenant + * @throws TenantDoesNotExistException + */ + public function findBy(string $key, $value): Tenant { - return Domains::where('tenant_id', $id)->get()->map(function ($model) { - return $model->domain; - })->toArray(); + $tenant = $this->tenants->findBy($key, $value); + + if (! $tenant) { + throw new TenantDoesNotExistException($value, $key); + } + + return Tenant::fromStorage($this->tenants->decodeData($tenant)) + ->withDomains($this->domains->getTenantDomains($tenant['id'])); } public function ensureTenantCanBeCreated(Tenant $tenant): void { - if (Tenants::find($tenant->id)) { + if ($this->tenants->exists($tenant)) { throw new TenantWithThisIdAlreadyExistsException($tenant->id); } - if (Domains::whereIn('domain', $tenant->domains)->exists()) { + if ($this->domains->occupied($tenant->domains)) { throw new DomainsOccupiedByOtherTenantException; } } @@ -95,53 +118,28 @@ class DatabaseStorageDriver implements StorageDriver return $this; } - public function getTenantIdByDomain(string $domain): ?string - { - return Domains::where('domain', $domain)->first()->tenant_id ?? null; - } - public function createTenant(Tenant $tenant): void { $this->centralDatabase->transaction(function () use ($tenant) { - Tenants::create(array_merge(Tenants::encodeData($tenant->data), [ - 'id' => $tenant->id, - ]))->toArray(); - - $domainData = []; - foreach ($tenant->domains as $domain) { - $domainData[] = ['domain' => $domain, 'tenant_id' => $tenant->id]; - } - - Domains::insert($domainData); + $this->tenants->insert($tenant); + $this->domains->insertTenantDomains($tenant); }); } public function updateTenant(Tenant $tenant): void { $this->centralDatabase->transaction(function () use ($tenant) { - Tenants::find($tenant->id)->putMany($tenant->data); + $this->tenants->updateTenant($tenant); - $original_domains = Domains::where('tenant_id', $tenant->id)->get()->map(function ($model) { - return $model->domain; - })->toArray(); - $deleted_domains = array_diff($original_domains, $tenant->domains); - - Domains::whereIn('domain', $deleted_domains)->delete(); - - foreach ($tenant->domains as $domain) { - Domains::firstOrCreate([ - 'tenant_id' => $tenant->id, - 'domain' => $domain, - ]); - } + $this->domains->updateTenantDomains($tenant); }); } public function deleteTenant(Tenant $tenant): void { $this->centralDatabase->transaction(function () use ($tenant) { - Tenants::find($tenant->id)->delete(); - Domains::where('tenant_id', $tenant->id)->delete(); + $this->tenants->where('id', $tenant->id)->delete(); + $this->domains->where('tenant_id', $tenant->id)->delete(); }); } @@ -153,8 +151,9 @@ class DatabaseStorageDriver implements StorageDriver */ public function all(array $ids = []): array { - return Tenants::getAllTenants($ids)->map(function ($data) { - return Tenant::fromStorage($data)->withDomains($this->getTenantDomains($data['id'])); + return $this->tenants->all($ids)->map(function ($data) { + return Tenant::fromStorage($data) + ->withDomains($this->domains->getTenantDomains($data['id'])); })->toArray(); } @@ -170,27 +169,26 @@ class DatabaseStorageDriver implements StorageDriver public function get(string $key, Tenant $tenant = null) { - $tenant = $tenant ?? $this->currentTenant(); - - return Tenants::find($tenant->id)->get($key); + return $this->tenants->get($key, $tenant ?? $this->currentTenant()); } public function getMany(array $keys, Tenant $tenant = null): array { - $tenant = $tenant ?? $this->currentTenant(); - - return Tenants::find($tenant->id)->getMany($keys); + return $this->tenants->getMany($keys, $tenant ?? $this->currentTenant()); } public function put(string $key, $value, Tenant $tenant = null): void { - $tenant = $tenant ?? $this->currentTenant(); - Tenants::find($tenant->id)->put($key, $value); + $this->tenants->put($key, $value, $tenant ?? $this->currentTenant()); } public function putMany(array $kvPairs, Tenant $tenant = null): void { - $tenant = $tenant ?? $this->currentTenant(); - Tenants::find($tenant->id)->putMany($kvPairs); + $this->tenants->putMany($kvPairs, $tenant ?? $this->currentTenant()); + } + + public function deleteMany(array $keys, Tenant $tenant = null): void + { + $this->tenants->deleteMany($keys, $tenant ?? $this->currentTenant()); } } diff --git a/src/StorageDrivers/Database/DomainModel.php b/src/StorageDrivers/Database/DomainModel.php deleted file mode 100644 index abddff4b..00000000 --- a/src/StorageDrivers/Database/DomainModel.php +++ /dev/null @@ -1,25 +0,0 @@ -where('domain', $domain)->first()->tenant_id ?? null; + } + + public function occupied(array $domains): bool + { + return $this->whereIn('domain', $domains)->exists(); + } + + public function getTenantDomains($tenant) + { + $id = $tenant instanceof Tenant ? $tenant->id : $tenant; + + return $this->where('tenant_id', $id)->get('domain')->pluck('domain')->all(); + } + + public function insertTenantDomains(Tenant $tenant) + { + $this->insert(array_map(function ($domain) use ($tenant) { + return ['domain' => $domain, 'tenant_id' => $tenant->id]; + }, $tenant->domains)); + } + + public function updateTenantDomains(Tenant $tenant) + { + $originalDomains = $this->getTenantDomains($tenant); + $deletedDomains = array_diff($originalDomains, $tenant->domains); + $newDomains = array_diff($tenant->domains, $originalDomains); + + $this->whereIn('domain', $deletedDomains)->delete(); + + foreach ($newDomains as $domain) { + $this->insert([ + 'tenant_id' => $tenant->id, + 'domain' => $domain, + ]); + } + } + + public function getTable(ConfigRepository $config) + { + return $config->get('tenancy.storage_drivers.db.table_names.DomainModel') // legacy + ?? $config->get('tenancy.storage_drivers.db.table_names.domains') + ?? 'domains'; + } +} diff --git a/src/StorageDrivers/Database/Repository.php b/src/StorageDrivers/Database/Repository.php new file mode 100644 index 00000000..e3cce981 --- /dev/null +++ b/src/StorageDrivers/Database/Repository.php @@ -0,0 +1,41 @@ +database = DatabaseStorageDriver::getCentralConnection(); + $this->tableName = $this->getTable($config); + $this->table = $this->database->table($this->tableName); + } + + public function table() + { + return $this->table->newQuery()->from($this->tableName); + } + + abstract public function getTable(ConfigRepository $config); + + public function __call($method, $parameters) + { + return $this->table()->$method(...$parameters); + } +} diff --git a/src/StorageDrivers/Database/TenantModel.php b/src/StorageDrivers/Database/TenantModel.php deleted file mode 100644 index 16189723..00000000 --- a/src/StorageDrivers/Database/TenantModel.php +++ /dev/null @@ -1,142 +0,0 @@ - $value) { - if (in_array($key, static::customColumns(), true)) { - $result[$key] = $value; - } else { - $jsonData[$key] = $value; - } - } - - $result['data'] = $jsonData ? json_encode($jsonData) : '{}'; - - return $result; - } - - public static function getAllTenants(array $ids) - { - $tenants = $ids ? static::findMany($ids) : static::all(); - - return $tenants->map([__CLASS__, 'decodeData'])->toBase(); - } - - public function decoded() - { - return static::decodeData($this); - } - - /** - * Return a tenant array with data decoded into separate keys. - * - * @param self|array $tenant - * @return array - */ - public static function decodeData($tenant) - { - $tenant = $tenant instanceof self ? (array) $tenant->attributes : $tenant; - $decoded = json_decode($tenant[$dataColumn = static::dataColumn()], true); - - foreach ($decoded as $key => $value) { - $tenant[$key] = $value; - } - - // If $tenant[$dataColumn] has been overriden by a value, don't delete the key. - if (! array_key_exists($dataColumn, $decoded)) { - unset($tenant[$dataColumn]); - } - - return $tenant; - } - - public function getFromData(string $key) - { - $this->dataArray = $this->dataArray ?? json_decode($this->{$this->dataColumn()}, true); - - return $this->dataArray[$key] ?? null; - } - - public function get(string $key) - { - return $this->attributes[$key] ?? $this->getFromData($key) ?? null; - } - - public function getMany(array $keys): array - { - return array_reduce($keys, function ($result, $key) { - $result[$key] = $this->get($key); - - return $result; - }, []); - } - - public function put(string $key, $value) - { - if (in_array($key, $this->customColumns())) { - $this->update([$key => $value]); - } else { - $obj = json_decode($this->{$this->dataColumn()}); - $obj->$key = $value; - - $this->update([$this->dataColumn() => json_encode($obj)]); - } - - return $value; - } - - public function putMany(array $kvPairs) - { - $customColumns = []; - $jsonObj = json_decode($this->{$this->dataColumn()}); - - foreach ($kvPairs as $key => $value) { - if (in_array($key, $this->customColumns())) { - $customColumns[$key] = $value; - continue; - } - - $jsonObj->$key = $value; - } - - $this->update(array_merge($customColumns, [ - $this->dataColumn() => json_encode($jsonObj), - ])); - } -} diff --git a/src/StorageDrivers/Database/TenantRepository.php b/src/StorageDrivers/Database/TenantRepository.php new file mode 100644 index 00000000..478b4f49 --- /dev/null +++ b/src/StorageDrivers/Database/TenantRepository.php @@ -0,0 +1,186 @@ +whereIn('id', $ids)->get(); + } else { + $data = $this->table()->get(); + } + + return $data->map(function (stdClass $obj) { + return $this->decodeData((array) $obj); + }); + } + + public function find($tenant) + { + return (array) $this->table()->find( + $tenant instanceof Tenant ? $tenant->id : $tenant + ); + } + + public function findBy(string $key, $value) + { + if (in_array($key, static::customColumns())) { + return (array) $this->table()->where($key, $value)->first(); + } + + return (array) $this->table()->where( + static::dataColumn() . '->' . $key, + $value + )->first(); + } + + public function updateTenant(Tenant $tenant) + { + $this->putMany($tenant->data, $tenant); + } + + public function exists(Tenant $tenant) + { + return $this->where('id', $tenant->id)->exists(); + } + + public function get(string $key, Tenant $tenant) + { + return $this->decodeFreshDataForTenant($tenant)[$key] ?? null; + } + + public function getMany(array $keys, Tenant $tenant) + { + $decodedData = $this->decodeFreshDataForTenant($tenant); + + $result = []; + + foreach ($keys as $key) { + $result[$key] = $decodedData[$key] ?? null; + } + + return $result; + } + + public function put(string $key, $value, Tenant $tenant) + { + $record = $this->where('id', $tenant->id); + + if (in_array($key, static::customColumns())) { + $record->update([$key => $value]); + } else { + $data = json_decode($record->first(static::dataColumn())->data, true); + $data[$key] = $value; + + $record->update([static::dataColumn() => $data]); + } + } + + public function putMany(array $kvPairs, Tenant $tenant) + { + $record = $this->where('id', $tenant->id); + + $data = []; + $jsonData = json_decode($record->first(static::dataColumn())->data, true); + foreach ($kvPairs as $key => $value) { + if (in_array($key, static::customColumns())) { + $data[$key] = $value; + continue; + } else { + $jsonData[$key] = $value; + } + } + + $data[static::dataColumn()] = json_encode($jsonData); + + $record->update($data); + } + + public function deleteMany(array $keys, Tenant $tenant) + { + $record = $this->where('id', $tenant->id); + + $data = []; + $jsonData = json_decode($record->first(static::dataColumn())->data, true); + foreach ($keys as $key) { + if (in_array($key, static::customColumns())) { + $data[$key] = null; + + continue; + } else { + unset($jsonData[$key]); + } + } + + $data[static::dataColumn()] = json_encode($jsonData); + + $record->update($data); + } + + public function decodeFreshDataForTenant(Tenant $tenant): array + { + return $this->decodeData( + (array) $this->table()->where('id', $tenant->id)->first() + ); + } + + public static function decodeData(array $columns): array + { + $dataColumn = static::dataColumn(); + $decoded = json_decode($columns[$dataColumn], true); + $columns = array_merge($columns, $decoded); + + // If $columns[$dataColumn] has been overriden by a value, don't delete the key. + if (! array_key_exists($dataColumn, $decoded)) { + unset($columns[$dataColumn]); + } + + return $columns; + } + + public function insert(Tenant $tenant) + { + $this->table()->insert(array_merge( + $this->encodeData($tenant->data), + ['id' => $tenant->id] + )); + } + + public static function encodeData(array $data): array + { + $result = []; + foreach (array_intersect(static::customColumns(), array_keys($data)) as $customColumn) { + $result[$customColumn] = $data[$customColumn]; + unset($data[$customColumn]); + } + + $result = array_merge($result, [static::dataColumn() => json_encode($data)]); + + return $result; + } + + public static function customColumns(): array + { + return config('tenancy.storage_drivers.db.custom_columns', []); + } + + public static function dataColumn(): string + { + return config('tenancy.storage_drivers.db.data_column', 'data'); + } + + public function getTable(ConfigRepository $config) + { + return $config->get('tenancy.storage_drivers.db.table_names.TenantModel') // legacy + ?? $config->get('tenancy.storage_drivers.db.table_names.tenants') + ?? 'tenants'; + } +} diff --git a/src/StorageDrivers/RedisStorageDriver.php b/src/StorageDrivers/RedisStorageDriver.php index 862f5069..79852f8c 100644 --- a/src/StorageDrivers/RedisStorageDriver.php +++ b/src/StorageDrivers/RedisStorageDriver.php @@ -6,6 +6,7 @@ namespace Stancl\Tenancy\StorageDrivers; use Illuminate\Contracts\Redis\Factory as Redis; use Illuminate\Foundation\Application; +use Stancl\Tenancy\Contracts\Future\CanDeleteKeys; use Stancl\Tenancy\Contracts\StorageDriver; use Stancl\Tenancy\Exceptions\DomainsOccupiedByOtherTenantException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedException; @@ -13,7 +14,7 @@ use Stancl\Tenancy\Exceptions\TenantDoesNotExistException; use Stancl\Tenancy\Exceptions\TenantWithThisIdAlreadyExistsException; use Stancl\Tenancy\Tenant; -class RedisStorageDriver implements StorageDriver +class RedisStorageDriver implements StorageDriver, CanDeleteKeys { /** @var Application */ protected $app; @@ -230,4 +231,11 @@ class RedisStorageDriver implements StorageDriver $this->redis->hmset("tenants:{$tenant->id}", $kvPairs); } + + public function deleteMany(array $keys, Tenant $tenant = null): void + { + $tenant = $tenant ?? $this->tenant(); + + $this->redis->hdel("tenants:{$tenant->id}", ...$keys); + } } diff --git a/src/TenancyBootstrappers/DatabaseTenancyBootstrapper.php b/src/TenancyBootstrappers/DatabaseTenancyBootstrapper.php index e93e7301..8920f44a 100644 --- a/src/TenancyBootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/TenancyBootstrappers/DatabaseTenancyBootstrapper.php @@ -6,6 +6,7 @@ namespace Stancl\Tenancy\TenancyBootstrappers; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\DatabaseManager; +use Stancl\Tenancy\Exceptions\TenantDatabaseDoesNotExistException; use Stancl\Tenancy\Tenant; class DatabaseTenancyBootstrapper implements TenancyBootstrapper @@ -20,6 +21,11 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper public function start(Tenant $tenant) { + $database = $tenant->getDatabaseName(); + if (! $this->database->getTenantDatabaseManager($tenant)->databaseExists($database)) { + throw new TenantDatabaseDoesNotExistException($database); + } + $this->database->connect($tenant); } diff --git a/src/Tenant.php b/src/Tenant.php index eb44466f..30a4981a 100644 --- a/src/Tenant.php +++ b/src/Tenant.php @@ -9,8 +9,10 @@ use Closure; use Illuminate\Foundation\Application; use Illuminate\Support\Str; use Illuminate\Support\Traits\ForwardsCalls; +use Stancl\Tenancy\Contracts\Future\CanDeleteKeys; use Stancl\Tenancy\Contracts\StorageDriver; use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator; +use Stancl\Tenancy\Exceptions\NotImplementedException; use Stancl\Tenancy\Exceptions\TenantStorageException; /** @@ -349,6 +351,39 @@ class Tenant implements ArrayAccess return $this->put($key, $value); } + /** + * Delete a key from the tenant's storage. + * + * @param string $key + * @return self + */ + public function deleteKey(string $key): self + { + return $this->deleteKeys([$key]); + } + + /** + * Delete keys from the tenant's storage. + * + * @param string[] $keys + * @return self + */ + public function deleteKeys(array $keys): self + { + if (! $this->storage instanceof CanDeleteKeys) { + throw new NotImplementedException(get_class($this->storage), 'deleteMany', + 'This method was added to storage drivers provided by the package in 2.2.0 and will be part of the StorageDriver contract in 3.0.0.' + ); + } else { + $this->storage->deleteMany($keys); + foreach ($keys as $key) { + unset($this->data[$key]); + } + } + + return $this; + } + /** * Set a value. * diff --git a/src/TenantManager.php b/src/TenantManager.php index 5fe60e90..dae6ce59 100644 --- a/src/TenantManager.php +++ b/src/TenantManager.php @@ -4,10 +4,15 @@ declare(strict_types=1); namespace Stancl\Tenancy; +use Exception; use Illuminate\Contracts\Console\Kernel as ConsoleKernel; use Illuminate\Foundation\Application; use Illuminate\Support\Collection; +use Illuminate\Support\Str; +use Illuminate\Support\Traits\ForwardsCalls; +use Stancl\Tenancy\Contracts\Future\CanFindByAnyKey; use Stancl\Tenancy\Contracts\TenantCannotBeCreatedException; +use Stancl\Tenancy\Exceptions\NotImplementedException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedException; use Stancl\Tenancy\Jobs\QueuedTenantDatabaseMigrator; use Stancl\Tenancy\Jobs\QueuedTenantDatabaseSeeder; @@ -17,6 +22,8 @@ use Stancl\Tenancy\Jobs\QueuedTenantDatabaseSeeder; */ class TenantManager { + use ForwardsCalls; + /** * The current tenant. * @@ -47,7 +54,7 @@ class TenantManager $this->app = $app; $this->storage = $storage; $this->artisan = $artisan; - $this->database = $database; + $this->database = $database->withTenantManager($this); $this->bootstrapFeatures(); } @@ -60,6 +67,8 @@ class TenantManager */ public function createTenant(Tenant $tenant): self { + $this->event('tenant.creating', $tenant); + $this->ensureTenantCanBeCreated($tenant); $this->storage->createTenant($tenant); @@ -89,6 +98,8 @@ class TenantManager $this->database->createDatabase($tenant, $afterCreating); + $this->event('tenant.created', $tenant); + return $this; } @@ -100,12 +111,16 @@ class TenantManager */ public function deleteTenant(Tenant $tenant): self { + $this->event('tenant.deleting', $tenant); + $this->storage->deleteTenant($tenant); if ($this->shouldDeleteDatabase()) { $this->database->deleteDatabase($tenant); } + $this->event('tenant.deleted', $tenant); + return $this; } @@ -198,6 +213,34 @@ class TenantManager return $this->storage->findByDomain($domain); } + /** + * Find a tenant using an arbitrary key. + * + * @param string $key + * @param mixed $value + * @return Tenant + * @throws TenantCouldNotBeIdentifiedException + * @throws NotImplementedException + */ + public function findBy(string $key, $value): Tenant + { + if ($key === null) { + throw new Exception('No key supplied.'); + } + + if ($value === null) { + throw new Exception('No value supplied.'); + } + + if (! $this->storage instanceof CanFindByAnyKey) { + throw new NotImplementedException(get_class($this->storage), 'findBy', + 'This method was added to the DB storage driver provided by the package in 2.2.0 and might be part of the StorageDriver contract in 3.0.0.' + ); + } + + return $this->storage->findBy($key, $value); + } + /** * Get all tenants. * @@ -246,13 +289,13 @@ class TenantManager */ public function bootstrapTenancy(Tenant $tenant): self { - $prevented = $this->event('bootstrapping'); + $prevented = $this->event('bootstrapping', $tenant); foreach ($this->tenancyBootstrappers($prevented) as $bootstrapper) { $this->app[$bootstrapper]->start($tenant); } - $this->event('bootstrapped'); + $this->event('bootstrapped', $tenant); return $this; } @@ -263,7 +306,7 @@ class TenantManager return $this; } - $prevented = $this->event('ending'); + $prevented = $this->event('ending', $this->getTenant()); foreach ($this->tenancyBootstrappers($prevented) as $bootstrapper) { $this->app[$bootstrapper]->end(); @@ -383,17 +426,27 @@ class TenantManager } /** - * Execute event listeners. + * Trigger an event and execute event listeners. * * @param string $name + * @param mixed ...$args * @return string[] */ - protected function event(string $name): array + public function event(string $name, ...$args): array { - return array_reduce($this->eventListeners[$name] ?? [], function ($prevented, $listener) { - $prevented = array_merge($prevented, $listener($this) ?? []); + return array_reduce($this->eventListeners[$name] ?? [], function ($results, $listener) use ($args) { + $results = array_merge($results, $listener($this, ...$args) ?? []); - return $prevented; + return $results; }, []); } + + public function __call($method, $parameters) + { + if (Str::startsWith($method, 'findBy')) { + return $this->findBy(Str::snake(substr($method, 6)), $parameters[0]); + } + + static::throwBadMethodCallException($method); + } } diff --git a/tests/CacheManagerTest.php b/tests/CacheManagerTest.php index af2d8882..77e0c357 100644 --- a/tests/CacheManagerTest.php +++ b/tests/CacheManagerTest.php @@ -8,15 +8,19 @@ use Stancl\Tenancy\Tenant; class CacheManagerTest extends TestCase { + public $autoInitTenancy = false; + /** @test */ public function default_tag_is_automatically_applied() { + $this->initTenancy(); $this->assertArrayIsSubset([config('tenancy.cache.tag_base') . tenant('id')], cache()->tags('foo')->getTags()->getNames()); } /** @test */ public function tags_are_merged_when_array_is_passed() { + $this->initTenancy(); $expected = [config('tenancy.cache.tag_base') . tenant('id'), 'foo', 'bar']; $this->assertEquals($expected, cache()->tags(['foo', 'bar'])->getTags()->getNames()); } @@ -24,6 +28,7 @@ class CacheManagerTest extends TestCase /** @test */ public function tags_are_merged_when_string_is_passed() { + $this->initTenancy(); $expected = [config('tenancy.cache.tag_base') . tenant('id'), 'foo']; $this->assertEquals($expected, cache()->tags('foo')->getTags()->getNames()); } @@ -31,6 +36,7 @@ class CacheManagerTest extends TestCase /** @test */ public function exception_is_thrown_when_zero_arguments_are_passed_to_tags_method() { + $this->initTenancy(); $this->expectException(\Exception::class); cache()->tags(); } @@ -38,6 +44,7 @@ class CacheManagerTest extends TestCase /** @test */ public function exception_is_thrown_when_more_than_one_argument_is_passed_to_tags_method() { + $this->initTenancy(); $this->expectException(\Exception::class); cache()->tags(1, 2); } diff --git a/tests/FutureTest.php b/tests/FutureTest.php new file mode 100644 index 00000000..6a44a80e --- /dev/null +++ b/tests/FutureTest.php @@ -0,0 +1,45 @@ +withData(['email' => 'foo@example.com', 'role' => 'admin'])->save(); + + $this->assertArrayHasKey('email', $tenant->data); + $tenant->deleteKey('email'); + $this->assertArrayNotHasKey('email', $tenant->data); + $this->assertArrayNotHasKey('email', tenancy()->all()->first()->data); + + $tenant->put(['foo' => 'bar', 'abc' => 'xyz']); + $this->assertArrayHasKey('foo', $tenant->data); + $this->assertArrayHasKey('abc', $tenant->data); + + $tenant->deleteKeys(['foo', 'abc']); + $this->assertArrayNotHasKey('foo', $tenant->data); + $this->assertArrayNotHasKey('abc', $tenant->data); + } + + /** @test */ + public function tenant_can_be_identified_using_an_arbitrary_string() + { + if (! tenancy()->storage instanceof CanFindByAnyKey) { + $this->markTestSkipped(get_class(tenancy()->storage) . ' does not implement the CanFindByAnyKey interface.'); + } + + $tenant = Tenant::new()->withData(['email' => 'foo@example.com'])->save(); + + $this->assertSame($tenant->id, tenancy()->findByEmail('foo@example.com')->id); + } +} diff --git a/tests/TenantManagerTest.php b/tests/TenantManagerTest.php index f469972a..9506f9fd 100644 --- a/tests/TenantManagerTest.php +++ b/tests/TenantManagerTest.php @@ -320,4 +320,14 @@ class TenantManagerTest extends TestCase $this->expectException(TenantDoesNotExistException::class); tenancy()->find('gjnfdgf'); } + + /** @test */ + public function event_listeners_can_accept_arguments() + { + tenancy()->hook('tenant.creating', function ($tenantManager, $tenant) { + $this->assertSame('bar', $tenant->foo); + }); + + Tenant::new()->withData(['foo' => 'bar'])->save(); + } } diff --git a/tests/TenantStorageTest.php b/tests/TenantStorageTest.php index a33ec5cc..568746cb 100644 --- a/tests/TenantStorageTest.php +++ b/tests/TenantStorageTest.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Tests; -use Stancl\Tenancy\StorageDrivers\Database\TenantModel; +use Stancl\Tenancy\StorageDrivers\Database\TenantRepository; use Stancl\Tenancy\Tenant; class TenantStorageTest extends TestCase @@ -112,10 +112,11 @@ class TenantStorageTest extends TestCase } /** @test */ - public function tenant_model_uses_correct_connection() + public function tenant_repository_uses_correct_connection() { + config(['database.connections.foo' => config('database.connections.sqlite')]); config(['tenancy.storage_drivers.db.connection' => 'foo']); - $this->assertSame('foo', (new TenantModel)->getConnectionName()); + $this->assertSame('foo', app(TenantRepository::class)->database->getName()); } /** @test */ @@ -156,6 +157,9 @@ class TenantStorageTest extends TestCase tenancy()->create(['foo.localhost']); tenancy()->init('foo.localhost'); + tenant()->put('foo', '111'); + $this->assertSame('111', tenant()->get('foo')); + tenant()->put(['foo' => 'bar', 'abc' => 'xyz']); $this->assertSame(['foo' => 'bar', 'abc' => 'xyz'], tenant()->get(['foo', 'abc']));