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

[1.7.0] Add DB storage driver (#82)

This commit is contained in:
Samuel Štancl 2019-08-16 14:36:49 +02:00 committed by GitHub
parent 674f4b3f9a
commit 9df78eb9c2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 375 additions and 25 deletions

View file

@ -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.

View file

@ -1,13 +1,25 @@
<?php
return [
'storage_driver' => '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' => '',
],

View file

@ -0,0 +1,35 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateTenantsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('tenants', function (Blueprint $table) {
$table->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');
}
}

View file

@ -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;

View file

@ -0,0 +1,84 @@
<?php
namespace Stancl\Tenancy\StorageDrivers;
use Stancl\Tenancy\Tenant;
use Stancl\Tenancy\Interfaces\StorageDriver;
class DatabaseStorageDriver implements StorageDriver
{
public $useJson = false;
// todo use an instance of tenant model?
// todo write tests verifying that data is decoded and added to the array
public function identifyTenant(string $domain): array
{
$id = $this->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;
}
}

View file

@ -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");
}

View file

@ -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']);

102
src/Tenant.php Normal file
View file

@ -0,0 +1,102 @@
<?php
namespace Stancl\Tenancy;
use Illuminate\Database\Eloquent\Model;
class Tenant extends Model
{
protected $guarded = [];
protected $primaryKey = 'uuid';
public $incrementing = false;
public $timestamps = false;
/**
* Decoded data from the data column.
*
* @var object
*/
private $dataObject;
public static function dataColumn()
{
return config('tenancy.storage.db.data_column', 'data');
}
public static function customColumns()
{
return config('tenancy.storage.db.custom_columns', []);
}
public function getConnectionName()
{
return config('tenancy.storage.db.connection', 'central');
}
public static function getAllTenants(array $uuids)
{
$tenants = $uuids ? static::findMany($uuids) : 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 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;
}
}

View file

@ -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).
*

6
test
View file

@ -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/

View file

@ -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("<?php
namespace App\Http;

View file

@ -2,6 +2,9 @@
namespace Stancl\Tenancy\Tests;
use Stancl\Tenancy\StorageDrivers\RedisStorageDriver;
use Stancl\Tenancy\StorageDrivers\DatabaseStorageDriver;
class TenantStorageTest extends TestCase
{
/** @test */
@ -111,4 +114,30 @@ class TenantStorageTest extends TestCase
$this->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')));
}
}

View file

@ -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)