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

vague first draft of v3. TenantModelTest is passing

This commit is contained in:
Samuel Štancl 2020-05-08 04:37:43 +02:00
parent c2c90ff755
commit bd9aad229b
56 changed files with 803 additions and 1366 deletions

View file

@ -8,6 +8,7 @@ use Illuminate\Console\Command;
use Illuminate\Database\Console\Migrations\MigrateCommand;
use Illuminate\Database\Migrations\Migrator;
use Stancl\Tenancy\DatabaseManager;
use Stancl\Tenancy\Events\DatabaseMigrated;
use Stancl\Tenancy\Traits\DealsWithMigrations;
use Stancl\Tenancy\Traits\HasATenantsOption;
@ -62,6 +63,8 @@ class Migrate extends MigrateCommand
// Migrate
parent::handle();
});
event(new DatabaseMigrated($tenant));
});
}
}

View file

@ -7,6 +7,7 @@ namespace Stancl\Tenancy\Commands;
use Illuminate\Database\ConnectionResolverInterface;
use Illuminate\Database\Console\Seeds\SeedCommand;
use Stancl\Tenancy\DatabaseManager;
use Stancl\Tenancy\Events\DatabaseSeeded;
use Stancl\Tenancy\Traits\HasATenantsOption;
class Seed extends SeedCommand
@ -60,6 +61,8 @@ class Seed extends SeedCommand
// Seed
parent::handle();
});
event(new DatabaseSeeded($tenant));
});
}
}

View file

@ -4,14 +4,12 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Contracts;
use Stancl\Tenancy\Database\Models\Tenant;
interface UniqueIdentifierGenerator
{
/**
* Generate a unique identifier.
*
* @param string[] $domains
* @param array $data
* @return string
*/
public static function generate(array $domains, array $data = []): string;
public static function generate(Tenant $tenant): string;
}

View file

@ -0,0 +1,11 @@
<?php
namespace Stancl\Tenancy\Database\Models\Concerns;
trait CentralConnection
{
public function getConnectionName()
{
return config('tenancy.central_connection');
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace Stancl\Tenancy\Database\Models\Concerns;
trait GeneratesIds
{
public static function bootGeneratesIds()
{
static::creating(function (self $model) {
if (! $model->id && config('tenancy.id_generator')) {
$model->id = app(config('tenancy.id_generator'))->generate($model);
}
});
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace Stancl\Tenancy\Database\Models\Concerns;
trait HasADataColumn
{
public static function bootHasADataColumn()
{
$encode = function (self $model) {
foreach ($model->getAttributes() as $key => $value) {
if (! in_array($key, static::getCustomColums())) {
$current = $model->getAttribute(static::getDataColumn()) ?? [];
$model->setAttribute(static::getDataColumn(), array_merge($current, [
$key => $value,
]));
unset($model->attributes[$key]);
}
}
};
$decode = function (self $model) {
foreach ($model->getAttribute(static::getDataColumn()) ?? [] as $key => $value) {
$model->setAttribute($key, $value);
}
$model->setAttribute(static::getDataColumn(), null);
};
static::saving($encode);
static::saved($decode);
static::retrieved($decode);
}
public function getCasts()
{
return array_merge(parent::getCasts(), [
static::getDataColumn() => 'array',
]);
}
/**
* Get the name of the column that stores additional data.
*/
public static function getDataColumn(): string
{
return 'data';
}
public static function getCustomColums(): array
{
return array_merge(['id'], config('tenancy.custom_columns'));
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace Stancl\Tenancy\Database\Models;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Events\DomainCreated;
use Stancl\Tenancy\Events\DomainDeleted;
use Stancl\Tenancy\Events\DomainSaved;
use Stancl\Tenancy\Events\DomainUpdated;
class Domain extends Model
{
public function tenant()
{
return $this->belongsTo(Tenant::class);
}
protected $dispatchEvents = [
'saved' => DomainSaved::class,
'created' => DomainCreated::class,
'updated' => DomainUpdated::class,
'deleted' => DomainDeleted::class,
];
}

View file

@ -0,0 +1,93 @@
<?php
namespace Stancl\Tenancy\Database\Models;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\DatabaseConfig;
use Stancl\Tenancy\Events;
// todo use a contract
class Tenant extends Model
{
use Concerns\CentralConnection, Concerns\HasADataColumn, Concerns\GeneratesIds, Concerns\HasADataColumn {
Concerns\HasADataColumn::getCasts as dataColumnCasts;
}
public $primaryKey = 'id';
public function getCasts()
{
return array_merge($this->dataColumnCasts(), [
'id' => $this->getIncrementing() ? 'integer' : 'string',
]);
}
public function getIncrementing()
{
return config('tenancy.id_generator') === null;
}
public $guarded = [];
public function domains() // todo not required
{
return $this->hasMany(Domain::class);
}
public static function internalPrefix(): string
{
return config('tenancy.database_prefix');
}
/**
* Get an internal key.
*
* @param string $key
* @return mixed
*/
public function getInternal(string $key)
{
return $this->getAttribute(static::internalPrefix() . $key);
}
/**
* Set internal key.
*
* @param string $key
* @param mixed $value
* @return $this
*/
public function setInternal(string $key, $value)
{
$this->setAttribute($key, $value);
return $this;
}
public function database(): DatabaseConfig
{
return new DatabaseConfig($this);
}
public function run(callable $callback)
{
$originalTenant = $this->manager->getTenant();
$this->manager->initializeTenancy($this);
$result = $callback($this);
$this->manager->endTenancy($this);
if ($originalTenant) {
$this->manager->initializeTenancy($originalTenant);
}
return $result;
}
protected $dispatchesEvents = [
'saved' => Events\TenantSaved::class,
'created' => Events\TenantCreated::class,
'updated' => Events\TenantUpdated::class,
'deleted' => Events\TenantDeleted::class,
];
}

View file

@ -10,6 +10,7 @@ use Stancl\Tenancy\Contracts\Future\CanSetConnection;
use Stancl\Tenancy\Contracts\ManagesDatabaseUsers;
use Stancl\Tenancy\Contracts\ModifiesDatabaseNameForConnection;
use Stancl\Tenancy\Contracts\TenantDatabaseManager;
use Stancl\Tenancy\Database\Models\Tenant;
use Stancl\Tenancy\Exceptions\DatabaseManagerNotRegisteredException;
class DatabaseConfig
@ -65,33 +66,33 @@ class DatabaseConfig
public function getName(): ?string
{
return $this->tenant->data['_tenancy_db_name'] ?? (static::$databaseNameGenerator)($this->tenant);
return $this->tenant->getInternal('db_name') ?? (static::$databaseNameGenerator)($this->tenant);
}
public function getUsername(): ?string
{
return $this->tenant->data['_tenancy_db_username'] ?? null;
return $this->tenant->getInternal('db_username') ?? null;
}
public function getPassword(): ?string
{
return $this->tenant->data['_tenancy_db_password'] ?? null;
return $this->tenant->getInternal('db_password') ?? null;
}
public function makeCredentials(): void
{
$this->tenant->data['_tenancy_db_name'] = $this->getName() ?? (static::$databaseNameGenerator)($this->tenant);
$this->tenant->setInternal('db_name', $this->getName() ?? (static::$databaseNameGenerator)($this->tenant));
if ($this->manager() instanceof ManagesDatabaseUsers) {
$this->tenant->data['_tenancy_db_username'] = $this->getUsername() ?? (static::$usernameGenerator)($this->tenant);
$this->tenant->data['_tenancy_db_password'] = $this->getPassword() ?? (static::$passwordGenerator)($this->tenant);
$this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($this->tenant));
$this->tenant->setInternal('db_password', $this->getPassword() ?? (static::$passwordGenerator)($this->tenant));
}
}
public function getTemplateConnectionName(): string
{
return $this->tenant->data['_tenancy_db_connection']
?? config('tenancy.database.template_connection')
return $this->tenant->getInternal('db_connection')
?? config('tenancy.template_tenant_connection')
?? DatabaseManager::$originalDefaultConnectionName;
}
@ -120,23 +121,23 @@ class DatabaseConfig
*/
public function tenantConfig(): array
{
$dbConfig = array_filter(array_keys($this->tenant->data), function ($key) {
return Str::startsWith($key, '_tenancy_db_');
$dbConfig = array_filter(array_keys($this->tenant->getAttributes()), function ($key) {
return Str::startsWith($key, $this->tenant->internalPrefix() . 'db_');
});
// Remove DB name because we set that separately
if (($pos = array_search('_tenancy_db_name', $dbConfig)) !== false) {
if (($pos = array_search($this->tenant->internalPrefix() . 'db_name', $dbConfig)) !== false) {
unset($dbConfig[$pos]);
}
// Remove DB connection because that's not used inside the array
if (($pos = array_search('_tenancy_db_connection', $dbConfig)) !== false) {
if (($pos = array_search($this->tenant->internalPrefix() . 'db_connection', $dbConfig)) !== false) {
unset($dbConfig[$pos]);
}
return array_reduce($dbConfig, function ($config, $key) {
return array_merge($config, [
Str::substr($key, strlen('_tenancy_db_')) => $this->tenant[$key],
Str::substr($key, strlen($this->tenant->internalPrefix() . 'db_')) => $this->tenant->getAttribute($key),
]);
}, []);
}

View file

@ -0,0 +1,19 @@
<?php
namespace Stancl\Tenancy\Events\Contracts;
use Illuminate\Queue\SerializesModels;
use Stancl\Tenancy\Database\Models\Domain;
abstract class DomainEvent
{
use SerializesModels;
/** @var Domain */
public $domain;
public function __construct(Domain $domain)
{
$this->domain = $domain;
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace Stancl\Tenancy\Events\Contracts;
use Illuminate\Queue\SerializesModels;
use Stancl\Tenancy\Database\Models\Tenant;
abstract class TenantEvent
{
use SerializesModels;
/** @var Tenant */
public $tenant;
public function __construct(Tenant $tenant)
{
$this->tenant = $tenant;
}
}

View file

@ -0,0 +1,7 @@
<?php
namespace Stancl\Tenancy\Events;
class DatabaseCreated extends Contracts\TenantEvent
{
}

View file

@ -0,0 +1,7 @@
<?php
namespace Stancl\Tenancy\Events;
class DatabaseDeleted extends Contracts\TenantEvent
{
}

View file

@ -0,0 +1,6 @@
<?php
namespace Stancl\Tenancy\Events;
class DatabaseMigrated extends Contracts\TenantEvent
{}

View file

@ -0,0 +1,7 @@
<?php
namespace Stancl\Tenancy\Events;
class DatabaseSeeded extends Contracts\TenantEvent
{
}

View file

@ -0,0 +1,7 @@
<?php
namespace Stancl\Tenancy\Events;
class DomainCreated extends Contracts\DomainEvent
{
}

View file

@ -0,0 +1,7 @@
<?php
namespace Stancl\Tenancy\Events;
class DomainDeleted extends Contracts\DomainEvent
{
}

View file

@ -0,0 +1,7 @@
<?php
namespace Stancl\Tenancy\Events;
class DomainSaved extends Contracts\DomainEvent
{
}

View file

@ -0,0 +1,7 @@
<?php
namespace Stancl\Tenancy\Events;
class DomainUpdated extends Contracts\DomainEvent
{
}

View file

@ -0,0 +1,74 @@
<?php
namespace Stancl\Tenancy\Events\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Pipeline\Pipeline;
class JobPipeline implements ShouldQueue
{
/** @var bool */
public static $shouldQueueByDefault = true;
/** @var callable[]|string[] */
public $jobs = [];
/** @var callable */
public $send;
/** @var bool */
public $shouldQueue = true;
public function __construct($jobs, callable $send = null, bool $shouldQueue = null)
{
$this->jobs = $jobs;
$this->send = $send ?? function ($event) {
// If no $send callback is set, we'll just pass the event through the jobs.
return $event;
};
$this->shouldQueue = $shouldQueue ?? static::$shouldQueueByDefault;
}
/** @param callable[]|string[] $jobs */
public static function make(array $jobs): self
{
return new static($jobs);
}
public function queue(bool $shouldQueue): self
{
$this->shouldQueue = $shouldQueue;
return $this;
}
public function send(callable $send): self
{
$this->send = $send;
return $this;
}
/** @return bool|$this */
public function shouldQueue(bool $shouldQueue = null)
{
if ($shouldQueue !== null) {
$this->shouldQueue = $shouldQueue;
return $this;
}
return $this->shouldQueue;
}
public function handle($event): void
{
/** @var Pipeline $pipeline */
$pipeline = app(Pipeline::class);
$pipeline
->send(($this->send)($event))
->through($this->jobs)
->thenReturn();
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace Stancl\Tenancy\Events\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
abstract class QueueableListener implements ShouldQueue
{
public static $shouldQueue = false;
public function shouldQueue()
{
return static::$shouldQueue;
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace Stancl\Tenancy\Events\Listeners;
use Stancl\Tenancy\Events\TenancyEnded;
class RevertToCentral
{
public function handle(TenancyEnded $event)
{
foreach (tenancy()->getBootstrappers() as $bootstrapper) {
$bootstrapper->end();
}
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace Stancl\Tenancy\Events;
use Stancl\Tenancy\Database\Models\Tenant;
class TenancyEnded
{
/** @var Tenant */
protected $tenant;
public function __construct(Tenant $tenant)
{
$this->tenant = $tenant;
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace Stancl\Tenancy\Events;
use Stancl\Tenancy\Database\Models\Tenant;
class TenancyInitialized
{
/** @var Tenant */
protected $tenant;
public function __construct(Tenant $tenant)
{
$this->tenant = $tenant;
}
}

View file

@ -0,0 +1,7 @@
<?php
namespace Stancl\Tenancy\Events;
class TenantCreated extends Contracts\TenantEvent
{
}

View file

@ -0,0 +1,7 @@
<?php
namespace Stancl\Tenancy\Events;
class TenantDeleted extends Contracts\TenantEvent
{
}

View file

@ -0,0 +1,7 @@
<?php
namespace Stancl\Tenancy\Events;
class TenantSaved extends Contracts\TenantEvent
{
}

View file

@ -0,0 +1,7 @@
<?php
namespace Stancl\Tenancy\Events;
class TenantUpdated extends Contracts\TenantEvent
{
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Stancl\Tenancy\Database\Models\Tenant;
class CreateDatabase implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/** @var Tenant */
protected $tenant;
public function __construct(Tenant $tenant)
{
$this->tenant = $tenant;
}
public function handle()
{
if ($this->tenant->getAttribute('_tenancy_create_database') !== false) {
$this->tenant->database()->manager()->createDatabase($this->tenant);
}
}
}

View file

@ -10,31 +10,22 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Stancl\Tenancy\Contracts\TenantDatabaseManager;
use Stancl\Tenancy\Tenant;
use Stancl\Tenancy\Database\Models\Tenant;
class QueuedTenantDatabaseDeleter implements ShouldQueue
class DeleteDatabase implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/** @var TenantDatabaseManager */
protected $databaseManager;
/** @var Tenant */
protected $tenant;
public function __construct(TenantDatabaseManager $databaseManager, Tenant $tenant)
public function __construct(Tenant $tenant)
{
$this->databaseManager = $databaseManager;
$this->tenant = $tenant;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$this->databaseManager->deleteDatabase($this->tenant);
$this->tenant->database()->manager()->deleteDatabase($this->tenant);
}
}

View file

@ -9,22 +9,18 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Stancl\Tenancy\Contracts\TenantDatabaseManager;
use Stancl\Tenancy\Tenant;
use Illuminate\Support\Facades\Artisan;
use Stancl\Tenancy\Database\Models\Tenant;
class QueuedTenantDatabaseCreator implements ShouldQueue
class MigrateDatabase implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/** @var TenantDatabaseManager */
protected $databaseManager;
/** @var Tenant */
protected $tenant;
public function __construct(TenantDatabaseManager $databaseManager, Tenant $tenant)
public function __construct(Tenant $tenant)
{
$this->databaseManager = $databaseManager;
$this->tenant = $tenant;
}
@ -35,6 +31,12 @@ class QueuedTenantDatabaseCreator implements ShouldQueue
*/
public function handle()
{
$this->databaseManager->createDatabase($this->tenant);
$migrationParameters = [
// todo ...
];
Artisan::call('tenants:migrate', [
'--tenants' => [$this->tenant->id],
] + $migrationParameters);
}
}

View file

@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Artisan;
use Stancl\Tenancy\Tenant;
class QueuedTenantDatabaseMigrator implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/** @var string */
protected $tenantId;
/** @var array */
protected $migrationParameters = [];
public function __construct(Tenant $tenant, $migrationParameters = [])
{
$this->tenantId = $tenant->id;
$this->migrationParameters = $migrationParameters;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
Artisan::call('tenants:migrate', [
'--tenants' => [$this->tenantId],
] + $this->migrationParameters);
}
}

View file

@ -10,18 +10,18 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Artisan;
use Stancl\Tenancy\Tenant;
use Stancl\Tenancy\Database\Models\Tenant;
class QueuedTenantDatabaseSeeder implements ShouldQueue
class SeedDatabase implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/** @var string */
protected $tenantId;
/** @var Tenant */
protected $tenant;
public function __construct(Tenant $tenant)
{
$this->tenantId = $tenant->id;
$this->tenant = $tenant;
}
/**
@ -32,7 +32,7 @@ class QueuedTenantDatabaseSeeder implements ShouldQueue
public function handle()
{
Artisan::call('tenants:seed', [
'--tenants' => [$this->tenantId],
'--tenants' => [$this->tenant->id],
]);
}
}

View file

@ -1,241 +0,0 @@
<?php
declare(strict_types=1);
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;
use Stancl\Tenancy\Exceptions\TenantDoesNotExistException;
use Stancl\Tenancy\Exceptions\TenantWithThisIdAlreadyExistsException;
use Stancl\Tenancy\Tenant;
class RedisStorageDriver implements StorageDriver, CanDeleteKeys
{
/** @var Application */
protected $app;
/** @var Redis */
protected $redis;
/** @var Tenant The default tenant. */
protected $tenant;
public function __construct(Application $app, Redis $redis)
{
$this->app = $app;
$this->redis = $redis->connection($app['config']['tenancy.storage_drivers.redis.connection'] ?? 'tenancy');
}
/**
* Get the current tenant.
*
* @return Tenant
*/
protected function tenant()
{
return $this->tenant ?? $this->app[Tenant::class];
}
public function withDefaultTenant(Tenant $tenant): self
{
$this->tenant = $tenant;
return $this;
}
public function ensureTenantCanBeCreated(Tenant $tenant): void
{
// Tenant ID
if ($this->redis->exists("tenants:{$tenant->id}")) {
throw new TenantWithThisIdAlreadyExistsException($tenant->id);
}
// Domains
if ($this->redis->exists(...array_map(function ($domain) {
return "domains:$domain";
}, $tenant->domains))) {
throw new DomainsOccupiedByOtherTenantException;
}
}
public function findByDomain(string $domain): Tenant
{
$id = $this->getTenantIdByDomain($domain);
if (! $id) {
throw new TenantCouldNotBeIdentifiedException($domain);
}
return $this->findById($id);
}
public function findById(string $id): Tenant
{
$data = $this->redis->hgetall("tenants:$id");
if (! $data) {
throw new TenantDoesNotExistException($id);
}
return $this->makeTenant($data);
}
public function getTenantIdByDomain(string $domain): ?string
{
return $this->redis->hget("domains:$domain", 'tenant_id') ?: null;
}
public function createTenant(Tenant $tenant): void
{
$this->redis->transaction(function ($pipe) use ($tenant) {
foreach ($tenant->domains as $domain) {
$pipe->hmset("domains:$domain", ['tenant_id' => $tenant->id]);
}
$data = [];
foreach ($tenant->data as $key => $value) {
$data[$key] = json_encode($value);
}
$pipe->hmset("tenants:{$tenant->id}", array_merge($data, ['_tenancy_domains' => json_encode($tenant->domains)]));
});
}
public function updateTenant(Tenant $tenant): void
{
$id = $tenant->id;
$old_domains = json_decode($this->redis->hget("tenants:$id", '_tenancy_domains'), true);
$deleted_domains = array_diff($old_domains, $tenant->domains);
$domains = $tenant->domains;
$data = [];
foreach ($tenant->data as $key => $value) {
$data[$key] = json_encode($value);
}
$this->redis->transaction(function ($pipe) use ($id, $data, $deleted_domains, $domains) {
foreach ($deleted_domains as $deleted_domain) {
$pipe->del("domains:$deleted_domain");
}
foreach ($domains as $domain) {
$pipe->hset("domains:$domain", 'tenant_id', $id);
}
$pipe->hmset("tenants:$id", array_merge($data, ['_tenancy_domains' => json_encode($domains)]));
});
}
public function deleteTenant(Tenant $tenant): void
{
$this->redis->transaction(function ($pipe) use ($tenant) {
foreach ($tenant->domains as $domain) {
$pipe->del("domains:$domain");
}
$pipe->del("tenants:{$tenant->id}");
});
}
/**
* Return a list of all tenants.
*
* @param string[] $ids
* @return Tenant[]
*/
public function all(array $ids = []): array
{
$hashes = array_map(function ($hash) {
return "tenants:{$hash}";
}, $ids);
if (! $hashes) {
// Prefix is applied to all functions except scan().
// This code applies the correct prefix manually.
$redis_prefix = $this->redis->getOption($this->redis->client()::OPT_PREFIX);
$all_keys = $this->redis->keys('tenants:*');
$hashes = array_map(function ($key) use ($redis_prefix) {
// Left strip $redis_prefix from $key
return substr($key, strlen($redis_prefix));
}, $all_keys);
}
return array_map(function ($tenant) {
return $this->makeTenant($this->redis->hgetall($tenant));
}, $hashes);
}
/**
* Make a Tenant instance from low-level array data.
*
* @param array $data
* @return Tenant
*/
protected function makeTenant(array $data): Tenant
{
foreach ($data as $key => $value) {
$data[$key] = json_decode($value, true);
}
$domains = $data['_tenancy_domains'];
unset($data['_tenancy_domains']);
return Tenant::fromStorage($data)->withDomains($domains);
}
public function get(string $key, Tenant $tenant = null)
{
$tenant = $tenant ?? $this->tenant();
$json_data = $this->redis->hget("tenants:{$tenant->id}", $key);
if ($json_data === false) {
return;
}
return json_decode($json_data, true);
}
public function getMany(array $keys, Tenant $tenant = null): array
{
$tenant = $tenant ?? $this->tenant();
$result = [];
$values = $this->redis->hmget("tenants:{$tenant->id}", $keys);
foreach ($keys as $i => $key) {
$result[$key] = json_decode($values[$i], true);
}
return $result;
}
public function put(string $key, $value, Tenant $tenant = null): void
{
$tenant = $tenant ?? $this->tenant();
$this->redis->hset("tenants:{$tenant->id}", $key, json_encode($value));
}
public function putMany(array $kvPairs, Tenant $tenant = null): void
{
$tenant = $tenant ?? $this->tenant();
foreach ($kvPairs as $key => $value) {
$kvPairs[$key] = json_encode($value);
}
$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);
}
}

39
src/Tenancy.php Normal file
View file

@ -0,0 +1,39 @@
<?php
namespace Stancl\Tenancy;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Database\Models\Tenant;
class Tenancy
{
/** @var Tenant|null */
public $tenant;
/** @var callable|null */
public static $getBootstrappers = null;
public function initialize(Tenant $tenant): void
{
$this->tenant = $tenant;
event(new Events\TenancyInitialized($tenant));
}
public function end(): void
{
event(new Events\TenancyEnded($this->tenant));
$this->tenant = null;
}
/** @return TenancyBootstrapper[] */
public function getBootstrappers(): array
{
$resolve = static::$getBootstrappers ?? function (Tenant $tenant) {
return config('tenancy.bootstrappers');
};
return $resolve($this->tenant);
}
}

View file

@ -8,6 +8,9 @@ use Illuminate\Cache\CacheManager;
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Stancl\Tenancy\Database\Models\Tenant;
use Stancl\Tenancy\Database\TenantObserver;
use Stancl\Tenancy\StorageDrivers\Database\DatabaseStorageDriver;
use Stancl\Tenancy\TenancyBootstrappers\FilesystemTenancyBootstrapper;
class TenancyServiceProvider extends ServiceProvider
@ -22,13 +25,13 @@ class TenancyServiceProvider extends ServiceProvider
$this->mergeConfigFrom(__DIR__ . '/../assets/config.php', 'tenancy');
$this->app->bind(Contracts\StorageDriver::class, function ($app) {
return $app->make($app['config']['tenancy.storage_drivers'][$app['config']['tenancy.storage_driver']]['driver']);
return $app->make(DatabaseStorageDriver::class);
});
$this->app->bind(Contracts\UniqueIdentifierGenerator::class, $this->app['config']['tenancy.unique_id_generator']);
$this->app->singleton(DatabaseManager::class);
$this->app->singleton(TenantManager::class);
$this->app->singleton(Tenancy::class);
$this->app->bind(Tenant::class, function ($app) {
return $app[TenantManager::class]->getTenant();
return $app[Tenancy::class]->tenant;
});
foreach ($this->app['config']['tenancy.bootstrappers'] as $bootstrapper) {

View file

@ -1,453 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy;
use ArrayAccess;
use Closure;
use Illuminate\Config\Repository;
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;
/**
* @internal Class is subject to breaking changes in minor and patch versions.
*/
// todo make this class serializable
class Tenant implements ArrayAccess
{
use Traits\HasArrayAccess,
ForwardsCalls;
/**
* Tenant data. A "cache" of tenant storage.
*
* @var array
*/
public $data = [];
/**
* List of domains that belong to the tenant.
*
* @var string[]
*/
public $domains = [];
/** @var Repository */
protected $config;
/** @var StorageDriver|CanDeleteKeys */
protected $storage;
/** @var TenantManager */
protected $manager;
/** @var UniqueIdentifierGenerator */
protected $idGenerator;
/**
* Does this tenant exist in the storage.
*
* @var bool
*/
public $persisted = false;
/**
* Use new() if you don't want to swap dependencies.
*
* @param StorageDriver $storage
* @param TenantManager $tenantManager
* @param UniqueIdentifierGenerator $idGenerator
*/
public function __construct(Repository $config, StorageDriver $storage, TenantManager $tenantManager, UniqueIdentifierGenerator $idGenerator)
{
$this->config = $config;
$this->storage = $storage->withDefaultTenant($this);
$this->manager = $tenantManager;
$this->idGenerator = $idGenerator;
}
/**
* Public constructor.
*
* @param Application $app
* @return self
*/
public static function new(Application $app = null): self
{
$app = $app ?? app();
return new static(
$app[Repository::class],
$app[StorageDriver::class],
$app[TenantManager::class],
$app[UniqueIdentifierGenerator::class]
);
}
/**
* Used by storage drivers to create persisted instances of Tenant.
*
* @param array $data
* @return self
*/
public static function fromStorage(array $data): self
{
return static::new()->withData($data)->persisted(true);
}
/**
* Create a tenant in a single call.
*
* @param string|string[] $domains
* @param array $data
* @return self
*/
public static function create($domains, array $data = []): self
{
return static::new()->withDomains((array) $domains)->withData($data)->save();
}
/**
* DO NOT CALL THIS METHOD FROM USERLAND UNLESS YOU KNOW WHAT YOU ARE DOING.
* Set $persisted.
*
* @param bool $persisted
* @return self
*/
public function persisted(bool $persisted): self
{
$this->persisted = $persisted;
return $this;
}
/**
* Does this model exist in the tenant storage.
*
* @return bool
*/
public function isPersisted(): bool
{
return $this->persisted;
}
/**
* Assign domains to the tenant.
*
* @param string|string[] $domains
* @return self
*/
public function addDomains($domains): self
{
$domains = (array) $domains;
$this->domains = array_merge($this->domains, $domains);
return $this;
}
/**
* Unassign domains from the tenant.
*
* @param string|string[] $domains
* @return self
*/
public function removeDomains($domains): self
{
$domains = (array) $domains;
$this->domains = array_diff($this->domains, $domains);
return $this;
}
/**
* Unassign all domains from the tenant.
*
* @return self
*/
public function clearDomains(): self
{
$this->domains = [];
return $this;
}
/**
* Set (overwrite) the tenant's domains.
*
* @param string|string[] $domains
* @return self
*/
public function withDomains($domains): self
{
$domains = (array) $domains;
$this->domains = $domains;
return $this;
}
/**
* Set (overwrite) tenant data.
*
* @param array $data
* @return self
*/
public function withData(array $data): self
{
$this->data = $data;
return $this;
}
/**
* Generate a random ID.
*
* @return void
*/
public function generateId()
{
$this->id = $this->idGenerator->generate($this->domains, $this->data);
}
/**
* Write the tenant's state to storage.
*
* @return self
*/
public function save(): self
{
if (! isset($this->data['id'])) {
$this->generateId();
}
if ($this->persisted) {
$this->manager->updateTenant($this);
} else {
$this->database()->makeCredentials();
$this->manager->createTenant($this);
}
return $this;
}
/**
* Delete a tenant from storage.
*
* @return self
*/
public function delete(): self
{
if ($this->persisted) {
$this->manager->deleteTenant($this);
$this->persisted = false;
}
return $this;
}
/**
* Unassign all domains from the tenant and write to storage.
*
* @return self
*/
public function softDelete(): self
{
$this->manager->event('tenant.softDeleting', $this);
$this->put([
'_tenancy_original_domains' => $this->domains,
]);
$this->clearDomains();
$this->save();
$this->manager->event('tenant.softDeleted', $this);
return $this;
}
/**
* Get database config.
*
* @return DatabaseConfig
*/
public function database(): DatabaseConfig
{
return new DatabaseConfig($this);
}
/**
* Get a value from tenant storage.
*
* @param string|string[] $keys
* @return void
*/
public function get($keys)
{
if (is_array($keys)) {
if ((array_intersect(array_keys($this->data), $keys) === $keys) ||
! $this->persisted) { // if all keys are present in cache
return array_reduce($keys, function ($pairs, $key) {
$pairs[$key] = $this->data[$key] ?? null;
return $pairs;
}, []);
}
return $this->storage->getMany($keys);
}
// single key
$key = $keys;
if (! isset($this->data[$key]) && $this->persisted) {
$this->data[$key] = $this->storage->get($key);
}
return $this->data[$key];
}
/**
* Set a value and write to storage.
*
* @param string|array<string, mixed> $key
* @param mixed $value
* @return self
*/
public function put($key, $value = null): self
{
$this->manager->event('tenant.updating', $this);
if ($key === 'id') {
throw new TenantStorageException("Tenant ids can't be changed.");
}
if (is_array($key)) {
if ($this->persisted) {
$this->storage->putMany($key);
}
foreach ($key as $k => $v) { // Add to cache
$this->data[$k] = $v;
}
} else {
if ($this->persisted) {
$this->storage->put($key, $value);
}
$this->data[$key] = $value;
}
$this->manager->event('tenant.updated', $this);
return $this;
}
/** @alias put */
public function set($key, $value = null): self
{
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
{
$this->manager->event('tenant.updating', $this);
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]);
}
}
$this->manager->event('tenant.updated', $this);
return $this;
}
/**
* Set a value in the data array without saving into storage.
*
* @param string $key
* @param mixed $value
* @return self
*/
public function with(string $key, $value): self
{
$this->data[$key] = $value;
return $this;
}
/**
* Run a closure inside the tenant's environment.
*
* @param Closure $closure
* @return mixed
*/
public function run(Closure $closure)
{
$originalTenant = $this->manager->getTenant();
$this->manager->initializeTenancy($this);
$result = $closure($this);
$this->manager->endTenancy($this);
if ($originalTenant) {
$this->manager->initializeTenancy($originalTenant);
}
return $result;
}
public function __get($key)
{
return $this->get($key);
}
public function __set($key, $value)
{
if ($key === 'id' && isset($this->data['id'])) {
throw new TenantStorageException("Tenant ids can't be changed.");
}
$this->data[$key] = $value;
}
public function __call($method, $parameters)
{
if (Str::startsWith($method, 'with')) {
return $this->with(Str::snake(substr($method, 4)), $parameters[0]);
}
static::throwBadMethodCallException($method);
}
}

View file

@ -1,471 +0,0 @@
<?php
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;
/**
* @internal Class is subject to breaking changes in minor and patch versions.
*/
class TenantManager
{
use ForwardsCalls;
/** @var Tenant The current tenant. */
protected $tenant;
/** @var Application */
protected $app;
/** @var ConsoleKernel */
protected $artisan;
/** @var Contracts\StorageDriver */
public $storage;
/** @var DatabaseManager */
public $database;
/** @var callable[][] */
protected $eventListeners = [];
/** @var bool Has tenancy been initialized. */
public $initialized = false;
public function __construct(Application $app, ConsoleKernel $artisan, Contracts\StorageDriver $storage, DatabaseManager $database)
{
$this->app = $app;
$this->storage = $storage;
$this->artisan = $artisan;
$this->database = $database->withTenantManager($this);
$this->bootstrapFeatures();
}
/**
* Write a new tenant to storage.
*
* @param Tenant $tenant
* @return self
*/
public function createTenant(Tenant $tenant): self
{
$this->event('tenant.creating', $tenant);
$this->ensureTenantCanBeCreated($tenant);
$this->storage->createTenant($tenant);
$tenant->persisted = true;
/** @var \Illuminate\Contracts\Queue\ShouldQueue[]|callable[] $afterCreating */
$afterCreating = [];
if ($this->shouldMigrateAfterCreation()) {
$afterCreating[] = $this->databaseCreationQueued()
? new QueuedTenantDatabaseMigrator($tenant, $this->getMigrationParameters())
: function () use ($tenant) {
$this->artisan->call('tenants:migrate', [
'--tenants' => [$tenant['id']],
] + $this->getMigrationParameters());
};
}
if ($this->shouldSeedAfterMigration()) {
$afterCreating[] = $this->databaseCreationQueued()
? new QueuedTenantDatabaseSeeder($tenant)
: function () use ($tenant) {
$this->artisan->call('tenants:seed', [
'--tenants' => [$tenant['id']],
]);
};
}
if ($this->shouldCreateDatabase($tenant)) {
$this->database->createDatabase($tenant, $afterCreating);
}
$this->event('tenant.created', $tenant);
return $this;
}
/**
* Delete a tenant from storage.
*
* @param Tenant $tenant
* @return self
*/
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;
}
/**
* Alias for Stancl\Tenancy\Tenant::create.
*
* @param string|string[] $domains
* @param array $data
* @return Tenant
*/
public static function create($domains, array $data = []): Tenant
{
return Tenant::create($domains, $data);
}
/**
* Ensure that a tenant can be created.
*
* @param Tenant $tenant
* @return void
* @throws TenantCannotBeCreatedException
*/
public function ensureTenantCanBeCreated(Tenant $tenant): void
{
if ($this->shouldCreateDatabase($tenant)) {
$this->database->ensureTenantCanBeCreated($tenant);
}
$this->storage->ensureTenantCanBeCreated($tenant);
}
/**
* Update an existing tenant in storage.
*
* @param Tenant $tenant
* @return self
*/
public function updateTenant(Tenant $tenant): self
{
$this->event('tenant.updating', $tenant);
$this->storage->updateTenant($tenant);
$this->event('tenant.updated', $tenant);
return $this;
}
/**
* Find tenant by domain & initialize tenancy.
*
* @param string|null $domain
* @return self
*/
public function init(string $domain = null): self
{
$domain = $domain ?? request()->getHost();
$this->initializeTenancy($this->findByDomain($domain));
return $this;
}
/**
* Find tenant by ID & initialize tenancy.
*
* @param string $id
* @return self
*/
public function initById(string $id): self
{
$this->initializeTenancy($this->find($id));
return $this;
}
/**
* Find a tenant using an id.
*
* @param string $id
* @return Tenant
* @throws TenantCouldNotBeIdentifiedException
*/
public function find(string $id): Tenant
{
return $this->storage->findById($id);
}
/**
* Find a tenant using a domain name.
*
* @param string $id
* @return Tenant
* @throws TenantCouldNotBeIdentifiedException
*/
public function findByDomain(string $domain): Tenant
{
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.
*
* @param Tenant[]|string[] $only
* @return Collection<Tenant>
*/
public function all($only = []): Collection
{
$only = array_map(function ($item) {
return $item instanceof Tenant ? $item->id : $item;
}, (array) $only);
return collect($this->storage->all($only));
}
/**
* Initialize tenancy.
*
* @param Tenant $tenant
* @return self
*/
public function initializeTenancy(Tenant $tenant): self
{
if ($this->initialized) {
$this->endTenancy();
}
$this->setTenant($tenant);
$this->bootstrapTenancy($tenant);
$this->initialized = true;
return $this;
}
/** @alias initializeTenancy */
public function initialize(Tenant $tenant): self
{
return $this->initializeTenancy($tenant);
}
/**
* Execute TenancyBootstrappers.
*
* @param Tenant $tenant
* @return self
*/
public function bootstrapTenancy(Tenant $tenant): self
{
$prevented = $this->event('bootstrapping', $tenant);
foreach ($this->tenancyBootstrappers($prevented) as $bootstrapper) {
$this->app[$bootstrapper]->start($tenant);
}
$this->event('bootstrapped', $tenant);
return $this;
}
public function endTenancy(): self
{
if (! $this->initialized) {
return $this;
}
$prevented = $this->event('ending', $this->tenant);
foreach ($this->tenancyBootstrappers($prevented) as $bootstrapper) {
$this->app[$bootstrapper]->end();
}
$this->initialized = false;
$this->tenant = null;
$this->event('ended');
return $this;
}
/** @alias endTenancy */
public function end(): self
{
return $this->endTenancy();
}
/**
* Get the current tenant.
*
* @param string $key
* @return Tenant|null|mixed
*/
public function getTenant(string $key = null)
{
if (! $this->tenant) {
return;
}
if (! is_null($key)) {
return $this->tenant[$key];
}
return $this->tenant;
}
protected function setTenant(Tenant $tenant): self
{
$this->tenant = $tenant;
return $this;
}
protected function bootstrapFeatures(): self
{
foreach ($this->app['config']['tenancy.features'] as $feature) {
$this->app[$feature]->bootstrap($this);
}
return $this;
}
/**
* Return a list of TenancyBootstrappers.
*
* @param string[] $except
* @return Contracts\TenancyBootstrapper[]
*/
public function tenancyBootstrappers($except = []): array
{
return array_diff_key($this->app['config']['tenancy.bootstrappers'], array_flip($except));
}
public function shouldCreateDatabase(Tenant $tenant): bool
{
if (array_key_exists('_tenancy_create_database', $tenant->data)) {
return $tenant->data['_tenancy_create_database'];
}
return $this->app['config']['tenancy.create_database'] ?? true;
}
public function shouldMigrateAfterCreation(): bool
{
return $this->app['config']['tenancy.migrate_after_creation'] ?? false;
}
public function shouldSeedAfterMigration(): bool
{
return $this->shouldMigrateAfterCreation() && $this->app['config']['tenancy.seed_after_migration'] ?? false;
}
public function databaseCreationQueued(): bool
{
return $this->app['config']['tenancy.queue_database_creation'] ?? false;
}
public function shouldDeleteDatabase(): bool
{
return $this->app['config']['tenancy.delete_database_after_tenant_deletion'] ?? false;
}
public function getSeederParameters()
{
return $this->app['config']['tenancy.seeder_parameters'] ?? [];
}
public function getMigrationParameters()
{
return $this->app['config']['tenancy.migration_parameters'] ?? [];
}
/**
* Add an event listener.
*
* @param string $name
* @param callable $listener
* @return self
*/
public function eventListener(string $name, callable $listener): self
{
$this->eventListeners[$name] = $this->eventListeners[$name] ?? [];
$this->eventListeners[$name][] = $listener;
return $this;
}
/**
* Add an event hook.
* @alias eventListener
*
* @param string $name
* @param callable $listener
* @return self
*/
public function hook(string $name, callable $listener): self
{
return $this->eventListener($name, $listener);
}
/**
* Trigger an event and execute its listeners.
*
* @param string $name
* @param mixed ...$args
* @return string[]
*/
public function event(string $name, ...$args): array
{
return array_reduce($this->eventListeners[$name] ?? [], function ($results, $listener) use ($args) {
return array_merge($results, $listener($this, ...$args) ?? []);
}, []);
}
public function __call($method, $parameters)
{
if (Str::startsWith($method, 'findBy')) {
return $this->findBy(Str::snake(substr($method, 6)), $parameters[0]);
}
static::throwBadMethodCallException($method);
}
}

View file

@ -6,10 +6,11 @@ namespace Stancl\Tenancy\UniqueIDGenerators;
use Ramsey\Uuid\Uuid;
use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
use Stancl\Tenancy\Database\Models\Tenant;
class UUIDGenerator implements UniqueIdentifierGenerator
{
public static function generate(array $domains, array $data = []): string
public static function generate(Tenant $tenant): string
{
return Uuid::uuid4()->toString();
}

View file

@ -2,18 +2,14 @@
declare(strict_types=1);
use Stancl\Tenancy\Tenant;
use Stancl\Tenancy\TenantManager;
use Stancl\Tenancy\Database\Models\Tenant;
use Stancl\Tenancy\Tenancy;
if (! function_exists('tenancy')) {
/** @return TenantManager|mixed */
function tenancy($key = null)
/** @return Tenancy */
function tenancy()
{
if ($key) {
return app(TenantManager::class)->getTenant($key) ?? null;
}
return app(TenantManager::class);
return app(Tenancy::class);
}
}