diff --git a/README.md b/README.md index f893f82c..092d3210 100644 --- a/README.md +++ b/README.md @@ -366,7 +366,35 @@ Note that deleting a tenant doesn't delete his database. You can do this manuall ## 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. +### Database + +The database storage driver lets you store information about tenants in a relational database like MySQL, PostgreSQL and SQLite. + +To use this storage driver, publish the `create_tenants_table` migration: + +``` +php artisan vendor:publish --provider='Stancl\Tenancy\TenancyServiceProvider' --tag=migrations +``` + +By default, the table contains only `uuid`, `domain` and `data` columns. + +The `data` column is used to store information about a tenant, such as their selected plan, in JSON form. This package does not store anything in the column by default. + +You can store specific keys in your own columns. This is useful if you want to use RDBMS features like indexes. + +If you don't need any custom columns, you can skip the next section and run: + +``` +php artisan migrate +``` + +#### Adding your own columns + +To add your own columns, TODO. + +### Redis + +Using Redis as your storage driver is recommended due to its low overhead compared to a relational database like MySQL. **Note that you need to configure persistence on your Redis instance** if you don't want to lose all information about tenants. diff --git a/src/config/tenancy.php b/assets/config.php similarity index 72% rename from src/config/tenancy.php rename to assets/config.php index 6ebd7251..b8f27925 100644 --- a/src/config/tenancy.php +++ b/assets/config.php @@ -1,13 +1,25 @@ 'Stancl\Tenancy\StorageDrivers\RedisStorageDriver', + 'storage_driver' => 'Stancl\Tenancy\StorageDrivers\DatabaseStorageDriver', + 'storage' => [ + 'db' => [ // Stancl\Tenancy\StorageDrivers\DatabaseStorageDriver + 'data_column' => 'data', + 'custom_columns' => [ + // 'plan', + ], + 'connection' => 'central', + ], + 'redis' => [ // Stancl\Tenancy\StorageDrivers\RedisStorageDriver + 'connection' => 'tenancy', + ], + ], 'tenant_route_namespace' => 'App\Http\Controllers', 'exempt_domains' => [ // 'localhost', ], 'database' => [ - 'based_on' => 'mysql', + 'based_on' => 'sqlite', 'prefix' => 'tenant', 'suffix' => '', ], diff --git a/assets/migrations/2019_08_08_000000_create_tenants_table.php b/assets/migrations/2019_08_08_000000_create_tenants_table.php new file mode 100644 index 00000000..fc07702e --- /dev/null +++ b/assets/migrations/2019_08_08_000000_create_tenants_table.php @@ -0,0 +1,35 @@ +string('uuid', 36)->primary(); // don't change this + $table->string('domain', 255)->index(); // don't change this + + // your indexed columns go here + + $table->json('data')->default('{}'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('tenants'); + } +} diff --git a/src/Interfaces/StorageDriver.php b/src/Interfaces/StorageDriver.php index 83dcae4a..1bda4976 100644 --- a/src/Interfaces/StorageDriver.php +++ b/src/Interfaces/StorageDriver.php @@ -6,6 +6,7 @@ interface StorageDriver { public function identifyTenant(string $domain): array; + /** @return array[] */ public function getAllTenants(array $uuids = []): array; public function getTenantById(string $uuid, array $fields = []): array; diff --git a/src/StorageDrivers/DatabaseStorageDriver.php b/src/StorageDrivers/DatabaseStorageDriver.php new file mode 100644 index 00000000..faaa9f16 --- /dev/null +++ b/src/StorageDrivers/DatabaseStorageDriver.php @@ -0,0 +1,84 @@ +getTenantIdByDomain($domain); + if (! $id) { + throw new \Exception("Tenant could not be identified on domain {$domain}"); + } + + return $this->getTenantById($id); + } + + /** + * Get information about the tenant based on his uuid. + * + * @param string $uuid + * @param array $fields + * @return array + */ + public function getTenantById(string $uuid, array $fields = []): array + { + if ($fields) { + return Tenant::decodeData(Tenant::find($uuid)->only($fields)); + } else { + return Tenant::find($uuid)->decoded(); + } + } + + public function getTenantIdByDomain(string $domain): ?string + { + return Tenant::where('domain', $domain)->first()->uuid ?? null; + } + + public function createTenant(string $domain, string $uuid): array + { + return Tenant::create(['uuid' => $uuid, 'domain' => $domain])->toArray(); + } + + public function deleteTenant(string $id): bool + { + return Tenant::find($id)->delete(); + } + + public function getAllTenants(array $uuids = []): array + { + return Tenant::getAllTenants($uuids)->toArray(); + } + + public function get(string $uuid, string $key) + { + return Tenant::find($uuid)->get($key); + } + + public function getMany(string $uuid, array $keys): array + { + return Tenant::find($uuid)->getMany($keys); + } + + public function put(string $uuid, string $key, $value) + { + return Tenant::find($uuid)->put($key, $value); + } + + public function putMany(string $uuid, array $values): array + { + foreach ($values as $key => $value) { + Tenant::find($uuid)->put($key, $value); + } + + return $values; + } +} diff --git a/src/StorageDrivers/RedisStorageDriver.php b/src/StorageDrivers/RedisStorageDriver.php index 82faebdb..63c087af 100644 --- a/src/StorageDrivers/RedisStorageDriver.php +++ b/src/StorageDrivers/RedisStorageDriver.php @@ -11,7 +11,7 @@ class RedisStorageDriver implements StorageDriver public function __construct() { - $this->redis = Redis::connection('tenancy'); + $this->redis = Redis::connection(config('tenancy.redis.connection', 'tenancy')); } public function identifyTenant(string $domain): array @@ -33,8 +33,6 @@ class RedisStorageDriver implements StorageDriver */ public function getTenantById(string $uuid, array $fields = []): array { - $fields = (array) $fields; - if (! $fields) { return $this->redis->hgetall("tenants:$uuid"); } diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index 76218665..09ffeaaf 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -33,9 +33,13 @@ class TenancyServiceProvider extends ServiceProvider ]); $this->publishes([ - __DIR__ . '/config/tenancy.php' => config_path('tenancy.php'), + __DIR__ . '/../assets/config.php' => config_path('tenancy.php'), ], 'config'); + $this->publishes([ + __DIR__ . '/../assets/migrations/' => database_path('migrations'), + ], 'migrations'); + $this->loadRoutesFrom(__DIR__ . '/routes.php'); Route::middlewareGroup('tenancy', [ @@ -52,7 +56,7 @@ class TenancyServiceProvider extends ServiceProvider */ public function register() { - $this->mergeConfigFrom(__DIR__ . '/config/tenancy.php', 'tenancy'); + $this->mergeConfigFrom(__DIR__ . '/../assets/config.php', 'tenancy'); $this->app->bind(StorageDriver::class, $this->app['config']['tenancy.storage_driver']); $this->app->bind(ServerConfigManager::class, $this->app['config']['tenancy.server.manager']); diff --git a/src/Tenant.php b/src/Tenant.php new file mode 100644 index 00000000..2eb8039f --- /dev/null +++ b/src/Tenant.php @@ -0,0 +1,102 @@ +map([__CLASS__, 'decodeData'])->toBase(); + } + + public function decoded() + { + return static::decodeData($this); + } + + /** + * Return a tenant array with data decoded into separate keys. + * + * @param Tenant|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->$key ?? $this->getFromData($key) ?? null; + } + + /** @todo In v2, this should return an associative array. */ + public function getMany(array $keys): array + { + return array_map([$this, 'get'], $keys); + } + + public function put(string $key, $value) + { + if (array_key_exists($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; + } +} diff --git a/src/TenantManager.php b/src/TenantManager.php index 9d5531bb..37c9e716 100644 --- a/src/TenantManager.php +++ b/src/TenantManager.php @@ -23,7 +23,7 @@ final class TenantManager * * @var StorageDriver */ - protected $storage; + public $storage; /** * Database manager. @@ -86,7 +86,10 @@ final class TenantManager throw new \Exception("Domain $domain is already occupied by tenant $id."); } - $tenant = $this->jsonDecodeArrayValues($this->storage->createTenant($domain, (string) \Webpatser\Uuid\Uuid::generate(1, $domain))); + $tenant = $this->storage->createTenant($domain, (string) \Webpatser\Uuid\Uuid::generate(1, $domain)); + if ($this->useJson()) { + $tenant = $this->jsonDecodeArrayValues($tenant); + } if ($data) { $this->put($data, null, $tenant['uuid']); @@ -115,7 +118,12 @@ final class TenantManager { $fields = (array) $fields; - return $this->jsonDecodeArrayValues($this->storage->getTenantById($uuid, $fields)); + $tenant = $this->storage->getTenantById($uuid, $fields); + if ($this->useJson()) { + $tenant = $this->jsonDecodeArrayValues($tenant); + } + + return $tenant; } /** @@ -200,7 +208,9 @@ final class TenantManager */ public function setTenant(array $tenant): array { - $tenant = $this->jsonDecodeArrayValues($tenant); + if ($this->useJson()) { + $tenant = $this->jsonDecodeArrayValues($tenant); + } $this->tenant = $tenant; @@ -227,10 +237,15 @@ final class TenantManager public function all($uuids = []) { $uuids = (array) $uuids; + $tenants = $this->storage->getAllTenants($uuids); - return collect(array_map(function ($tenant_array) { - return $this->jsonDecodeArrayValues($tenant_array); - }, $this->storage->getAllTenants($uuids))); + if ($this->useJson()) { + $tenants = array_map(function ($tenant_array) { + return $this->jsonDecodeArrayValues($tenant_array); + }, $tenants); + } + + return collect($tenants); } /** @@ -336,6 +351,15 @@ final class TenantManager return $array; } + public function useJson() + { + if (property_exists($this->storage, 'useJson') && $this->storage->useJson === false) { + return false; + } + + return true; + } + /** * Return the identified tenant's attribute(s). * diff --git a/test b/test index ff55e60b..822b6d4b 100755 --- a/test +++ b/test @@ -4,7 +4,9 @@ set -e # for development docker-compose up -d printf "Variant 1\n\n" -TENANCY_TEST_REDIS_TENANCY=1 TENANCY_TEST_REDIS_CLIENT=phpredis docker-compose exec test vendor/bin/phpunit --coverage-php coverage/1.cov "$@" +docker-compose exec test env TENANCY_TEST_REDIS_TENANCY=1 TENANCY_TEST_REDIS_CLIENT=phpredis TENANCY_TEST_STORAGE_DRIVER=redis vendor/bin/phpunit --coverage-php coverage/1.cov "$@" printf "Variant 2\n\n" -TENANCY_TEST_REDIS_TENANCY=0 TENANCY_TEST_REDIS_CLIENT=predis docker-compose exec test vendor/bin/phpunit --coverage-php coverage/2.cov "$@" +docker-compose exec test env TENANCY_TEST_REDIS_TENANCY=0 TENANCY_TEST_REDIS_CLIENT=predis TENANCY_TEST_STORAGE_DRIVER=redis vendor/bin/phpunit --coverage-php coverage/2.cov "$@" +printf "Variant 3\n\n" +docker-compose exec test env TENANCY_TEST_REDIS_TENANCY=1 TENANCY_TEST_REDIS_CLIENT=phpredis TENANCY_TEST_STORAGE_DRIVER=db vendor/bin/phpunit --coverage-php coverage/3.cov "$@" docker-compose exec test vendor/bin/phpcov merge --clover clover.xml coverage/ diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index df90a21b..6d2a8e41 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -210,6 +210,7 @@ class Kernel extends HttpKernel ->expectsQuestion('Do you want to publish the default database migration?', 'yes'); $this->assertFileExists(base_path('routes/tenant.php')); $this->assertFileExists(base_path('config/tenancy.php')); + $this->assertFileExists(database_path('migrations/2019_08_08_000000_create_tenants_table.php')); $this->assertSame("assertSame($value, tenancy()->put($value)); } + + /** @test */ + public function correct_storage_driver_is_used() + { + if (config('tenancy.storage_driver') == DatabaseStorageDriver::class) { + $this->assertSame('DatabaseStorageDriver', class_basename(tenancy()->storage)); + } elseif (config('tenancy.storage_driver') == RedisStorageDriver::class) { + $this->assertSame('RedisStorageDriver', class_basename(tenancy()->storage)); + } + } + + /** @test */ + public function data_is_stored_with_correct_data_types() + { + tenancy()->put('someBool', false); + $this->assertSame('boolean', gettype(tenancy()->get('someBool'))); + + tenancy()->put('someInt', 5); + $this->assertSame('integer', gettype(tenancy()->get('someInt'))); + + tenancy()->put('someDouble', 11.40); + $this->assertSame('double', gettype(tenancy()->get('someDouble'))); + + tenancy()->put('string', 'foo'); + $this->assertSame('string', gettype(tenancy()->get('string'))); + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 0183cc5a..9cafb202 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,19 +3,14 @@ namespace Stancl\Tenancy\Tests; use Illuminate\Support\Facades\Redis; +use Stancl\Tenancy\StorageDrivers\RedisStorageDriver; +use Stancl\Tenancy\StorageDrivers\DatabaseStorageDriver; abstract class TestCase extends \Orchestra\Testbench\TestCase { public $autoCreateTenant = true; public $autoInitTenancy = true; - private function checkRequirements(): void - { - parent::checkRequirements(); - - dd($this->getAnnotations()); - } - /** * Setup the test environment. * @@ -28,6 +23,12 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase Redis::connection('tenancy')->flushdb(); Redis::connection('cache')->flushdb(); + $this->loadMigrationsFrom([ + '--path' => realpath(__DIR__ . '/../assets/migrations'), + '--database' => 'central', + ]); + config(['database.default' => 'sqlite']); // fix issue caused by loadMigrationsFrom + if ($this->autoCreateTenant) { $this->createTenant(); } @@ -37,6 +38,13 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase } } + protected function tearDown(): void + { + // config(['database.default' => 'central']); + + parent::tearDown(); + } + public function createTenant($domain = 'localhost') { tenant()->create($domain); @@ -59,6 +67,8 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase \Dotenv\Dotenv::create(__DIR__ . '/..')->load(); } + fclose(fopen(database_path('central.sqlite'), 'w')); + $app['config']->set([ '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'), @@ -72,6 +82,10 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase 'database' => env('TENANCY_TEST_REDIS_DB', 14), 'prefix' => 'abc', // todo unrelated to tenancy, but this doesn't seem to have an effect? try to replicate in a fresh laravel installation ], + 'database.connections.central' => [ + 'driver' => 'sqlite', + 'database' => database_path('central.sqlite'), + ], 'tenancy.database' => [ 'based_on' => 'sqlite', 'prefix' => 'tenant', @@ -90,11 +104,27 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase 'tenancy.redis.prefixed_connections' => ['default'], 'tenancy.migrations_directory' => database_path('../migrations'), ]); + + if (env('TENANCY_TEST_STORAGE_DRIVER', 'redis') === 'redis') { + $app['config']->set([ + 'tenancy.storage_driver' => RedisStorageDriver::class, + ]); + + tenancy()->storage = $app->make(RedisStorageDriver::class); + } elseif (env('TENANCY_TEST_STORAGE_DRIVER', 'redis') === 'db') { + $app['config']->set([ + 'tenancy.storage_driver' => DatabaseStorageDriver::class, + ]); + + tenancy()->storage = $app->make(DatabaseStorageDriver::class); + } } protected function getPackageProviders($app) { - return [\Stancl\Tenancy\TenancyServiceProvider::class]; + return [ + \Stancl\Tenancy\TenancyServiceProvider::class, + ]; } protected function getPackageAliases($app)