From deb3ad77f59ee7ced665d91c509ebbd7f7f5666d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 17 Jan 2019 22:24:12 +0100 Subject: [PATCH] Initial commit --- .gitignore | 2 + LICENSE | 21 ++ README.md | 343 ++++++++++++++++++++++ composer.json | 31 ++ src/CacheManager.php | 19 ++ src/Commands/Migrate.php | 69 +++++ src/Commands/Rollback.php | 69 +++++ src/Commands/Seed.php | 70 +++++ src/Commands/TenantList.php | 45 +++ src/DatabaseManager.php | 64 ++++ src/Interfaces/StorageDriver.php | 15 + src/Middleware/InitializeTenancy.php | 37 +++ src/StorageDrivers/RedisStorageDriver.php | 84 ++++++ src/TenancyServiceProvider.php | 63 ++++ src/Tenant.php | 30 ++ src/TenantManager.php | 226 ++++++++++++++ src/Traits/BootstrapsTenancy.php | 46 +++ src/config/tenancy.php | 28 ++ src/helpers.php | 21 ++ 19 files changed, 1283 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 src/CacheManager.php create mode 100644 src/Commands/Migrate.php create mode 100644 src/Commands/Rollback.php create mode 100644 src/Commands/Seed.php create mode 100644 src/Commands/TenantList.php create mode 100644 src/DatabaseManager.php create mode 100644 src/Interfaces/StorageDriver.php create mode 100644 src/Middleware/InitializeTenancy.php create mode 100644 src/StorageDrivers/RedisStorageDriver.php create mode 100644 src/TenancyServiceProvider.php create mode 100644 src/Tenant.php create mode 100644 src/TenantManager.php create mode 100644 src/Traits/BootstrapsTenancy.php create mode 100644 src/config/tenancy.php create mode 100644 src/helpers.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..d8a7996a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +composer.lock +vendor/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..8894cdf7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Samuel Štancl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..9beeb62b --- /dev/null +++ b/README.md @@ -0,0 +1,343 @@ +# Tenancy + +### *A Laravel multi-database tenancy implementation that respects your code.* + +You won't have to change a thing in your application's code. + +- :white_check_mark: No model traits to change database connection +- :white_check_mark: No replacing of Laravel classes (`Cache`, `Storage`, ...) with tenancy-aware classes +- :white_check_mark: Built-in tenant identification based on hostname + +## Installation + +### Installing the package + +``` +composer require stancl/tenancy +``` + +### Adding the `InitializeTenancy` middleware + +Open `app/Http/Kernel.php` and make the following changes: + +First, you want to create middleware groups so that we can apply this middleware on routes. +- Create a new middleware group in `$middlewareGroups`: + ```php + 'tenancy' => [ + \Stancl\Tenancy\Middleware\InitializeTenancy::class, + ], + ``` +- Create a new middleware group in `$routeMiddleware`: + ```php + 'tenancy' => \Stancl\Tenancy\Middleware\InitializeTenancy::class, + ``` +- Make the middleware top priority, so that it gets executed before anything else, thus making sure things like the database switch connections soon enough. + ```php + protected $middlewarePriority = [ + \Stancl\Tenancy\Middleware\InitializeTenancy::class, + ``` + +#### Configuring the middleware + +When a tenant route is visited, but the tenant can't be identified, an exception can be thrown. If you want to change this behavior, to a redirect for example, add this to your `app/Providers/AppServiceProvider.php`'s `boot()` method. + +```php +// use Stancl\Tenancy\Middleware\InitializeTenancy; + +$this->app->bind(InitializeTenancy::class, function ($app) { + return new InitializeTenancy(function ($exception) { + // redirect + }); +}); +``` + +### Creating tenant routes + +Add this method into `app/Providers/RouteServiceProvider.php`: + +```php +/** + * Define the "tenant" routes for the application. + * + * These routes all receive session state, CSRF protection, etc. + * + * @return void + */ +protected function mapTenantRoutes() +{ + Route::middleware(['web', 'tenancy']) + ->namespace($this->namespace) + ->group(base_path('routes/tenant.php')); +} +``` + +And add this line to `map()`: + +```php +$this->mapTenantRoutes(); +``` + +Now rename the `routes/web.php` file to `routes/tenant.php`. This file will contain routes accessible only with tenancy. + +Create an empty `routes/web.php` file. This file will contain routes accessible without tenancy (such as the landing page.) + +### Publishing the configuration file + +``` +php artisan vendor:publish --provider='Stancl\Tenancy\TenancyServiceProvider' --tag=config +``` + +You should see something along the lines of `Copied File [...] to [/config/tenancy.php]`. + +#### `database` + +Databases will be named like this: + +```php +config('tenancy.database.prefix') . $uuid . config('tenancy.database.suffix') +``` + +They will use a connection based on the connection specified using the `based_on` setting. Using `mysql` or `sqlite` is fine, but if you need to change more things than just the database name, you can create a new `tenant` connection and set `tenancy.database.based_on` to `tenant`. + +#### `redis` + +Keys will be prefixed with: + +```php +config('tenancy.redis.prefix_base') . $uuid +``` + +These changes will only apply for connections listed in `prefixed_connections`. + +#### `cache` + +Cache keys will be tagged with a tag: + +```php +config('tenancy.cache.prefix_base') . $uuid +``` + +### `filesystem` + +Filesystem paths will be suffixed with: + +```php +config('tenancy.filesystem.suffix_base') . $uuid +``` + +These changes will only apply for disks listen in `disks`. + +You can see an example in the [Filesystem](#Filesystem) section of the documentation. + +# Usage + +## Obtaining a `TenantManager` instance + +You can use the `tenancy()` and `tenant()` helpers to resolve `Stancl\Tenancy\TenantManager` out of the service container. These two helpers are exactly the same, the only reason there are two is nice syntax. `tenancy()->init()` sounds better than `tenant()->init()` and `tenant()->create()` sounds better than `tenancy()->create()`. + +### Creating a new tenant + +```php +>>> tenant()->create('dev.localhost') +=> [ + "uuid" => "49670df0-1a87-11e9-b7ba-cf5353777957", + "domain" => "dev.localhost", + ] +``` + +### Starting a session as a tenant + +This runs `TenantManager::bootstrap()` which switches the DB connection, prefixes Redis, changes filesystem root paths, etc. + +```php +tenancy()->init(); +// The domain will be autodetected unless specified as an argument +tenancy()->init('dev.localhost'); +``` + +### Getting tenant information based on his UUID + +You can use `find()`, which is an alias for `getTenantById()`. +You may use the second argument to specify the key(s) as a string/array. + +```php +>>> tenant()->getTenantById('dbe0b330-1a6e-11e9-b4c3-354da4b4f339'); +=> [ + "uuid" => "dbe0b330-1a6e-11e9-b4c3-354da4b4f339", + "domain" => "localhost", + "foo" => "bar", + ] +>>> tenant()->getTenantById('dbe0b330-1a6e-11e9-b4c3-354da4b4f339', 'foo'); +=> [ + "foo" => "bar", + ] +>>> tenant()->getTenantById('dbe0b330-1a6e-11e9-b4c3-354da4b4f339', ['foo', 'domain']); +=> [ + "foo" => "bar", + "domain" => "localhost", + ] +``` + +### Getting tenant UUID based on his domain + +```php +>>> tenant()->getTenantIdByDomain('localhost'); +=> "b3ce3f90-1a88-11e9-a6b0-038c6337ae50" +>>> tenant()->getIdByDomain('localhost'); +=> "b3ce3f90-1a88-11e9-a6b0-038c6337ae50" +``` + +### Getting tenant information based on his domain + +You may use the second argument to specify the key(s) as a string/array. + +```php +>>> tenant()->findByDomain('localhost'); +=> [ + "uuid" => "b3ce3f90-1a88-11e9-a6b0-038c6337ae50", + "domain" => "localhost", + ] +``` + +### Getting current tenant information + +You can access the public array `tenant` of `TenantManager` like this: + +```php +tenancy()->tenant +``` + +which returns an array. If you want to get the value of a specific key from the array, you can use one of the helpers with an argument --- the key on the `tenant` array. + +```php +tenant('uuid'); // Does the same thing as tenant()->tenant['uuid'] +``` + +### Listing all tenants + +```php +>>> tenant()->all(); +=> Illuminate\Support\Collection {#2980 + all: [ + [ + "uuid" => "32e20780-1a88-11e9-a051-4b6489a7edac", + "domain" => "localhost", + ], + [ + "uuid" => "49670df0-1a87-11e9-b7ba-cf5353777957", + "domain" => "dev.localhost", + ], + ], + } +>>> tenant()->all()->pluck('domain'); +=> Illuminate\Support\Collection {#2983 + all: [ + "localhost", + "dev.localhost", + ], + } +``` + +### Deleting a tenant + +```php +>>> tenant()->delete('dbe0b330-1a6e-11e9-b4c3-354da4b4f339'); +=> true +>>> tenant()->delete(tenant()->getTenantIdByDomain('dev.localhost')); +=> true +>>> tenant()->delete(tenant()->findByDomain('localhost')['uuid']); +=> true +``` + +## Storage driver + +Currently, only Redis is supported, but you're free to code your own storage driver which follows the `Stancl\Tenancy\Interfaces\StorageDriver` interface. Just point the `tenancy.storage_driver` setting at your driver. + +**Note that you need to configure persistence on your Redis instance** if you don't want to lose all information about tenants. + +Read the [Redis documentation page on persistence](https://redis.io/topics/persistence). You should definitely use AOF and if you want to be even more protected from data loss, you can use RDB **in conjunction with AOF**. + +If your cache driver is Redis and you don't want to use AOF with it, run two Redis instances. Otherwise, just make sure you use a different database (number) for tenancy and for anything else. + +### Storing custom data + +Along with the tenant and database info, you can store your own data in the storage. You can use: + +```php +tenancy()->get($key); +tenancy()->put($key, $value); +tenancy()->set($key, $value); // alias for put() +``` + +Note that `$key` has to be a string. + +## Database + +The entire application will use a new database connection. The connection will be based on the connection specified in `tenancy.database.based_on`. A database name of `tenancy.database.prefix` + tenant UUID + `tenancy.database.suffix` will be used. You can set the suffix to `.sqlite` if you're using sqlite and want the files to be in the sqlite format and you can leave the suffix empty if you're using MySQL (for example). + +## Redis + +Connections listed in the `tenancy.redis.prefixed_connections` config array use a prefix based on the `tenancy.redis.prefix_base` and the tenant UUID. + +**Note: You *must* use phpredis for prefixes to work. Predis doesn't support prefixes.** + +## Cache + +Both `cache()` and `Cache` will use `Stancl\Tenancy\CacheManager`, which adds a tag (`prefix_base` + tenant UUID) to all methods called on it. + + +## Filesystem + +Assuming the following tenancy config: + +```php +'filesystem' => [ + 'suffix_base' => 'tenant', + // Disks which should be suffixed with the prefix_base + tenant UUID. + 'disks' => [ + 'local', + // 's3', + ], +], +``` + +The `local` filesystem driver will be suffixed with a directory containing `tenant` and the tenant UUID. + +```php +>>> Storage::disk('local')->getAdapter()->getPathPrefix() +=> "/var/www/laravel/multitenancy/storage/app/" +>>> tenancy()->init() +=> [ + "uuid" => "dbe0b330-1a6e-11e9-b4c3-354da4b4f339", + "domain" => "localhost", + ] +>>> Storage::disk('local')->getAdapter()->getPathPrefix() +=> "/var/www/laravel/multitenancy/storage/app/tenantdbe0b330-1a6e-11e9-b4c3-354da4b4f339/" +``` + +## Artisan commands + +``` +Available commands for the "tenants" namespace: + tenants:list List tenants. + tenants:migrate Run migrations for tenant(s) + tenants:rollback Rollback migrations for tenant(s). + tenants:seed Seed tenant database(s). +``` + +#### `tenants:list` + +``` +$ artisan tenants:list +Listing all tenants. +[Tenant] uuid: dbe0b330-1a6e-11e9-b4c3-354da4b4f339 @ localhost +[Tenant] uuid: 49670df0-1a87-11e9-b7ba-cf5353777957 @ dev.localhost +``` + +#### `tenants:migrate`, `tenants:rollback`, `tenants:seed` + +- You may specify the tenant(s) UUIDs using the `--tenants` option. + +## Some tips + +- If you create a tenant using the interactive console (`artisan tinker`) and use sqlite, you might need to change the database's permissions and/or ownership (`chmod`/`chown`) so that the web application can access it. diff --git a/composer.json b/composer.json new file mode 100644 index 00000000..5513346c --- /dev/null +++ b/composer.json @@ -0,0 +1,31 @@ +{ + "name": "stancl/tenancy", + "description": "A Laravel multi-database tenancy implementation that respects your code.", + "keywords": ["laravel", "multi-tenancy", "multi-database", "tenancy"], + "license": "MIT", + "authors": [ + { + "name": "Samuel Štancl", + "email": "samuel.stancl@gmail.com" + } + ], + "require": { + "illuminate/support": "^5.7", + "webpatser/laravel-uuid": "^3.0" + }, + "autoload": { + "psr-4": { + "Stancl\\Tenancy\\": "src/" + }, + "files": [ + "src/helpers.php" + ] + }, + "extra": { + "laravel": { + "providers": [ + "Stancl\\Tenancy\\TenancyServiceProvider" + ] + } + } +} diff --git a/src/CacheManager.php b/src/CacheManager.php new file mode 100644 index 00000000..08e26f98 --- /dev/null +++ b/src/CacheManager.php @@ -0,0 +1,19 @@ +store()->tags(array_merge($tags, ...$parameters)); + } + + return $this->store()->tags($tags)->$method(...$parameters); + } +} diff --git a/src/Commands/Migrate.php b/src/Commands/Migrate.php new file mode 100644 index 00000000..e1e44c91 --- /dev/null +++ b/src/Commands/Migrate.php @@ -0,0 +1,69 @@ +database = $database; + + $this->setName('tenants:migrate'); + $this->specifyParameters(); + } + + /** + * Execute the console command. + * + * @return mixed + */ + public function handle() + { + if (! $this->confirmToProceed()) { + return; + } + + $this->input->setOption('database', 'tenant'); + + tenant()->all($this->option('tenants'))->each(function ($tenant) { + $this->line("Tenant: {$tenant['uuid']} ({$tenant['domain']})"); + $this->database->connectToTenant($tenant); + + // Migrate + parent::handle(); + }); + } + + protected function getOptions() + { + return array_merge([ + ['tenants', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL, '', null] + ], parent::getOptions()); + } + + protected function getMigrationPaths() + { + return [database_path('migrations/tenant')]; + } +} diff --git a/src/Commands/Rollback.php b/src/Commands/Rollback.php new file mode 100644 index 00000000..919d66f8 --- /dev/null +++ b/src/Commands/Rollback.php @@ -0,0 +1,69 @@ +database = $database; + + $this->setName('tenants:rollback'); + $this->specifyParameters(); + } + + /** + * Execute the console command. + * + * @return mixed + */ + public function handle() + { + if (! $this->confirmToProceed()) { + return; + } + + $this->input->setOption('database', 'tenant'); + + tenant()->all($this->option('tenants'))->each(function ($tenant) { + $this->line("Tenant: {$tenant['uuid']} ({$tenant['domain']})"); + $this->database->connectToTenant($tenant); + + // Migrate + parent::handle(); + }); + } + + protected function getOptions() + { + return array_merge([ + ['tenants', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL, '', null] + ], parent::getOptions()); + } + + protected function getMigrationPaths() + { + return [database_path('migrations/tenant')]; + } +} diff --git a/src/Commands/Seed.php b/src/Commands/Seed.php new file mode 100644 index 00000000..04f94f48 --- /dev/null +++ b/src/Commands/Seed.php @@ -0,0 +1,70 @@ +database = $database; + + $this->setName('tenants:seed'); + $this->specifyParameters(); + } + + /** + * Execute the console command. + * + * @return mixed + */ + public function handle() + { + if (! $this->confirmToProceed()) { + return; + } + + $this->input->setOption('database', 'tenant'); + + tenant()->all($this->option('tenants'))->each(function ($tenant) { + $this->line("Tenant: {$tenant['uuid']} ({$tenant['domain']})"); + $this->database->connectToTenant($tenant); + + // Migrate + parent::handle(); + }); + } + + protected function getOptions() + { + return array_merge([ + ['tenants', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL, '', null] + ], parent::getOptions()); + } + + protected function getMigrationPaths() + { + return [database_path('migrations/tenant')]; + } +} diff --git a/src/Commands/TenantList.php b/src/Commands/TenantList.php new file mode 100644 index 00000000..94eae4c6 --- /dev/null +++ b/src/Commands/TenantList.php @@ -0,0 +1,45 @@ +info("Listing all tenants."); + tenancy()->all()->each(function ($tenant) { + $this->line("[Tenant] uuid: {$tenant['uuid']} @ {$tenant['domain']}"); + }); + } +} diff --git a/src/DatabaseManager.php b/src/DatabaseManager.php new file mode 100644 index 00000000..bee904bc --- /dev/null +++ b/src/DatabaseManager.php @@ -0,0 +1,64 @@ +database = $database; + } + + public function connect(string $database) + { + $this->createTenantConnection($database); + $this->database->setDefaultConnection('tenant'); + $this->database->reconnect('tenant'); + } + + public function connectToTenant($tenant) + { + $this->connect(tenant()->getDatabaseName($tenant)); + } + + public function disconnect() + { + $this->database->reconnect('default'); + $this->database->setDefaultConnection('default'); + } + + public function create(string $name, string $driver = null) + { + $this->createTenantConnection($name); + $driver = $driver ?: $this->getDriver(); + if ($driver === "sqlite") { + $f = fopen(database_path($name), 'w'); + fclose($f); + + return; + } + + return DB::statement("CREATE DATABASE `$name`"); + } + + public function getDriver(): ?string + { + return config("database.connections.tenant.driver"); + } + + public function createTenantConnection(string $database_name) + { + // Create the `tenancy` database connection. + $based_on = config('tenancy.database.based_on') ?: config('database.default'); + config()->set([ + 'database.connections.tenant' => config('database.connections.' . $based_on) + ]); + + // Change DB name + $database_name = $this->getDriver() === "sqlite" ? database_path($database_name) : $database_name; + config()->set(['database.connections.tenant.database' => $database_name]); + } +} diff --git a/src/Interfaces/StorageDriver.php b/src/Interfaces/StorageDriver.php new file mode 100644 index 00000000..56c1af4d --- /dev/null +++ b/src/Interfaces/StorageDriver.php @@ -0,0 +1,15 @@ +onFail = $onFail ?: function ($e) { throw $e; }; + } + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @return mixed + */ + public function handle($request, Closure $next) + { + try { + tenancy()->init(); + } catch (\Exception $e) { + // Pass the exception to the onFail function if it takes any parameters. + $callback = $this->onFail; + if ((new \ReflectionFunction($callback))->getNumberOfParameters() > 0) { + $callback($e); + } else { + $callback(); + } + } + + return $next($request); + } +} diff --git a/src/StorageDrivers/RedisStorageDriver.php b/src/StorageDrivers/RedisStorageDriver.php new file mode 100644 index 00000000..979849e7 --- /dev/null +++ b/src/StorageDrivers/RedisStorageDriver.php @@ -0,0 +1,84 @@ +redis = Redis::connection('tenancy'); + } + + public function identifyTenant(string $domain): array + { + $id = $this->getTenantIdByDomain($domain); + if (! $id) { + throw new \Exception("Tenant could not be identified on domain {$domain}"); + } + return array_merge(['uuid' => $id], $this->getTenantById($id)); + } + + /** + * Get information about the tenant based on his uuid. + * + * @param string $uuid + * @param array|string $fields + * @return array + */ + public function getTenantById(string $uuid, $fields = []): array + { + if (! $fields) { + return $this->redis->hgetall("tenants:$uuid"); + } + + return array_combine($fields, $this->redis->hmget("tenants:$uuid", $fields)); + } + + public function getTenantIdByDomain(string $domain): ?string + { + return $this->redis->hget("domains:$domain", 'tenant_id') ?: null; + } + + public function createTenant(string $domain, string $uuid): array + { + $this->redis->hmset("domains:$domain", 'tenant_id', $uuid); + $this->redis->hmset("tenants:$uuid", 'uuid', $uuid, 'domain', $domain); + return $this->redis->hgetall("tenants:$uuid"); + } + + public function deleteTenant(string $id): bool + { + $domain = $this->getTenantById($id)['domain']; + $this->redis->del("domains:$domain"); + return (bool) $this->redis->del("tenants:$id"); + } + + public function getAllTenants(array $uuids = []): array + { + $hashes = array_map(function ($hash) { + return "tenants:{$hash}"; + }, $uuids); + + $hashes = $hashes ?: $this->redis->scan(null, 'tenants:*'); + + return array_map(function ($tenant) { + return $this->redis->hgetall($tenant); + }, $hashes); + } + + public function get(string $uuid, string $key) + { + return $this->redis->hget("tenants:$uuid", $key); + } + + public function put(string $uuid, string $key, $value) + { + $this->redis->hset("tenants:$uuid", $key, $value); + return $value; + } +} diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php new file mode 100644 index 00000000..fa49ab0c --- /dev/null +++ b/src/TenancyServiceProvider.php @@ -0,0 +1,63 @@ +app->runningInConsole()) { + $this->commands([ + Migrate::class, + Rollback::class, + Seed::class, + TenantList::class, + ]); + } + + $this->publishes([ + __DIR__ . '/config/tenancy.php' => config_path('tenancy.php'), + ], 'config'); + } + + /** + * Register services. + * + * @return void + */ + public function register() + { + $this->mergeConfigFrom(__DIR__ . '/config/tenancy.php', 'tenancy'); + + $this->app->bind(StorageDriver::class, $this->app['config']['tenancy.storage_driver']); + $this->app->singleton(DatabaseManager::class); + $this->app->singleton(TenantManager::class, function ($app) { + return new TenantManager($app, $app[StorageDriver::class], $app[DatabaseManager::class]); + }); + + $this->app->singleton(Migrate::class, function ($app) { + return new Migrate($app['migrator'], $app[DatabaseManager::class]); + }); + $this->app->singleton(Rollback::class, function ($app) { + return new Rollback($app['migrator'], $app[DatabaseManager::class]); + }); + $this->app->singleton(Seed::class, function ($app) { + return new Seed($app['db'], $app[DatabaseManager::class]); + }); + } +} diff --git a/src/Tenant.php b/src/Tenant.php new file mode 100644 index 00000000..a921197b --- /dev/null +++ b/src/Tenant.php @@ -0,0 +1,30 @@ +uuid = $data['uuid']; + $this->domain = $data['domain'] ?? tenancy()->getTenantById($data['uuid'], 'domain'); + $this->databaseName = $data['database_name'] ?? $this->getDatabaseName($data); + } + + public function getDatabaseName($uuid = null) + { + $uuid = $uuid ?: $this->uuid; + return config('tenancy.database._prefix_base') . $uuid . config('tenancy.database._suffix'); + } +} diff --git a/src/TenantManager.php b/src/TenantManager.php new file mode 100644 index 00000000..f4af0c77 --- /dev/null +++ b/src/TenantManager.php @@ -0,0 +1,226 @@ +app = $app; + $this->storage = $storage; + $this->database = $database; + } + + public function init(string $domain = null): array + { + $this->setTenant($this->identify($domain)); + $this->bootstrap(); + return $this->tenant; + } + + public function identify(string $domain = null): array + { + $domain = $domain ?: $this->currentDomain(); + + if (! $domain) { + throw new \Exception("No domain supplied nor detected."); + } + + $tenant = $this->storage->identifyTenant($domain); + + if (! $tenant || ! array_key_exists('uuid', $tenant) || ! $tenant['uuid']) { + throw new \Exception("Tenant could not be identified on domain {$domain}."); + } + + return $tenant; + } + + public function create(string $domain = null): array + { + $domain = $domain ?: $this->currentDomain(); + + if ($id = $this->storage->getTenantIdByDomain($domain)) { + throw new \Exception("Domain $domain is already occupied by tenant $id."); + } + + $tenant = $this->storage->createTenant($domain, \Uuid::generate(1, $domain)); + $this->database->create($this->getDatabaseName($tenant)); + \Artisan::call('tenants:migrate', [ + '--tenants' => [$tenant['uuid']] + ]); + + return $tenant; + } + + public function delete(string $uuid): bool + { + return $this->storage->deleteTenant($uuid); + } + + /** + * Return an array with information about a tenant based on his uuid. + * + * @param string $uuid + * @param array|string $fields + * @return array + */ + public function getTenantById(string $uuid, $fields = []) + { + $fields = (array) $fields; + return $this->storage->getTenantById($uuid, $fields); + } + + /** + * Alias for getTenantById(). + * + * @param string $uuid + * @param array|string $fields + * @return array + */ + public function find(string $uuid, $fields = []) + { + return $this->getTenantById($uuid, $fields); + } + + /** + * Get tenant uuid based on the domain that belongs to him. + * + * @param string $domain + * @return string|null + */ + public function getTenantIdByDomain(string $domain = null): ?string + { + $domain = $domain ?: $this->currentDomain(); + + return $this->storage->getTenantIdByDomain($domain); + } + + /** + * Alias for getTenantIdByDomain(). + * + * @param string $domain + * @return string|null + */ + public function getIdByDomain(string $domain = null) + { + return $this->getTenantIdByDomain($domain); + } + + /** + * Get tenant information based on his domain. + * + * @param string $domain + * @param mixed $fields + * @return array + */ + public function findByDomain(string $domain = null, $fields = []) + { + $domain = $domain ?: $this->currentDomain(); + + return $this->find($this->getIdByDomain($domain), $fields); + } + + public static function currentDomain(): ?string + { + return request()->getHost() ?? null; + } + + public function getDatabaseName($tenant = []): string + { + $tenant = $tenant ?: $this->tenant; + return config('tenancy.database.prefix') . $tenant['uuid'] . config('tenancy.database.suffix'); + } + + public function setTenant(array $tenant): array + { + $this->tenant = $tenant; + + return $tenant; + } + + /** + * Get all tenants. + * + * @param array|string $uuids + * @return array + */ + public function all($uuids = []) + { + $uuid = (array) $uuids; + return collect($this->storage->getAllTenants($uuids)); + } + + public function actAsId(string $uuid): array + { + return $this->setTenant($this->storage->getTenantById($uuid)); + } + + public function actAsDomain(string $domain): string + { + return $this->init($domain); + } + + public function get(string $key) + { + return $this->storage->get($this->tenant['uuid'], $key); + } + + /** + * Puts a value into the storage for the current tenant. + * + * @param string $key + * @param mixed $value + * @return mixed + */ + public function put(string $key, $value) + { + // Todo allow $value to be null and $key to be an array. + return $this->tenant[$key] = $this->storage->put($this->tenant['uuid'], $key, $value); + } + + /** + * Alias for put(). + * + * @param string $key + * @param mixed $value + * @return mixed + */ + public function set(string $key, $value) + { + return $this->put($this->put($key, $value)); + } +} diff --git a/src/Traits/BootstrapsTenancy.php b/src/Traits/BootstrapsTenancy.php new file mode 100644 index 00000000..8d2eb2fd --- /dev/null +++ b/src/Traits/BootstrapsTenancy.php @@ -0,0 +1,46 @@ +switchDatabaseConnection(); + $this->setPhpRedisPrefix($this->app['config']['tenancy.redis.prefixed_connections']); + $this->tagCache(); + $this->suffixFilesystemRootPaths(); + } + + public function switchDatabaseConnection() + { + $this->database->connect($this->getDatabaseName()); + } + + public function setPhpRedisPrefix($connections = ['default']) + { + return; + foreach ($connections as $connection) { + $prefix = config('tenancy.redis.prefix_base') . $this->tenant['uuid']; + $client = Redis::connection($connection)->client(); + $client->setOption($client::OPT_PREFIX, $prefix); + } + } + + public function tagCache() + { + $this->app->extend('cache', function () { + return new CacheManager($this->app); + }); + } + + public function suffixFilesystemRootPaths() + { + $suffix = $this->app['config']['tenancy.filesystem.suffix_base'] . tenant('uuid'); + foreach ($this->app['config']['tenancy.filesystem.disks'] as $disk) { + \Storage::disk($disk)->getAdapter()->setPathPrefix( + $this->app['config']["filesystems.disks.{$disk}.root"] . "/{$suffix}" + ); + } + } +} diff --git a/src/config/tenancy.php b/src/config/tenancy.php new file mode 100644 index 00000000..989fdecc --- /dev/null +++ b/src/config/tenancy.php @@ -0,0 +1,28 @@ + 'Stancl\Tenancy\StorageDrivers\RedisStorageDriver', + 'database' => [ + 'based_on' => 'sqlite', + 'prefix' => 'tenant', + 'suffix' => '.sqlite', + ], + 'redis' => [ + 'prefix_base' => 'tenant', + 'prefixed_connections' => [ + 'default', + ], + ], + 'cache' => [ + 'prefix_base' => 'tenant', + ], + + 'filesystem' => [ + 'suffix_base' => 'tenant', + // Disks which should be suffixed with the suffix_base + tenant UUID. + 'disks' => [ + // 'local', + // 's3', + ], + ], +]; diff --git a/src/helpers.php b/src/helpers.php new file mode 100644 index 00000000..a2769c2c --- /dev/null +++ b/src/helpers.php @@ -0,0 +1,21 @@ +tenant[$key]; + } + + return app(TenantManager::class); + } +} + +if (!function_exists('tenant')) { + function tenant($key = null) + { + return tenancy($key); + } +}