1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-02-05 19:14:03 +00:00

Merge branch 'master' into phpstan-ci

This commit is contained in:
Samuel Štancl 2022-11-04 14:17:14 +01:00 committed by GitHub
commit c0c7855135
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
144 changed files with 3627 additions and 758 deletions

View file

@ -11,7 +11,7 @@ on:
jobs: jobs:
tests: tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: abrardev/tenancy:latest container: archtechx/tenancy:latest
strategy: strategy:
matrix: matrix:

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
.env .env
.DS_Store
composer.lock composer.lock
vendor/ vendor/
.vscode/ .vscode/

View file

@ -10,6 +10,7 @@ $rules = [
'operators' => [ 'operators' => [
'=>' => null, '=>' => null,
'|' => 'no_space', '|' => 'no_space',
'&' => 'no_space',
] ]
], ],
'blank_line_after_namespace' => true, 'blank_line_after_namespace' => true,

View file

@ -28,6 +28,7 @@ class TenancyServiceProvider extends ServiceProvider
Jobs\CreateDatabase::class, Jobs\CreateDatabase::class,
Jobs\MigrateDatabase::class, Jobs\MigrateDatabase::class,
// Jobs\SeedDatabase::class, // Jobs\SeedDatabase::class,
Jobs\CreateStorageSymlinks::class,
// Your own jobs to prepare the tenant. // Your own jobs to prepare the tenant.
// Provision API keys, create S3 buckets, anything you want! // Provision API keys, create S3 buckets, anything you want!
@ -46,14 +47,25 @@ class TenancyServiceProvider extends ServiceProvider
])->send(function (Events\DeletingTenant $event) { ])->send(function (Events\DeletingTenant $event) {
return $event->tenant; return $event->tenant;
})->shouldBeQueued(false), })->shouldBeQueued(false),
// Listeners\DeleteTenantStorage::class,
], ],
Events\TenantDeleted::class => [ Events\TenantDeleted::class => [
JobPipeline::make([ JobPipeline::make([
Jobs\DeleteDatabase::class, Jobs\DeleteDatabase::class,
Jobs\RemoveStorageSymlinks::class,
])->send(function (Events\TenantDeleted $event) { ])->send(function (Events\TenantDeleted $event) {
return $event->tenant; return $event->tenant;
})->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production. })->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production.
], ],
Events\TenantMaintenanceModeEnabled::class => [],
Events\TenantMaintenanceModeDisabled::class => [],
// Pending tenant events
Events\CreatingPendingTenant::class => [],
Events\PendingTenantCreated::class => [],
Events\PullingPendingTenant::class => [],
Events\PendingTenantPulled::class => [],
// Domain events // Domain events
Events\CreatingDomain::class => [], Events\CreatingDomain::class => [],
@ -93,6 +105,12 @@ class TenancyServiceProvider extends ServiceProvider
Listeners\UpdateSyncedResource::class, Listeners\UpdateSyncedResource::class,
], ],
// Storage symlinks
Events\CreatingStorageSymlink::class => [],
Events\StorageSymlinkCreated::class => [],
Events\RemovingStorageSymlink::class => [],
Events\StorageSymlinkRemoved::class => [],
// Fired only when a synced resource is changed in a different DB than the origin DB (to avoid infinite loops) // Fired only when a synced resource is changed in a different DB than the origin DB (to avoid infinite loops)
Events\SyncedResourceChangedInForeignDatabase::class => [], Events\SyncedResourceChangedInForeignDatabase::class => [],
]; ];
@ -134,16 +152,8 @@ class TenancyServiceProvider extends ServiceProvider
protected function makeTenancyMiddlewareHighestPriority() protected function makeTenancyMiddlewareHighestPriority()
{ {
$tenancyMiddleware = [ // PreventAccessFromCentralDomains has even higher priority than the identification middleware
// Even higher priority than the initialization middleware $tenancyMiddleware = array_merge([Middleware\PreventAccessFromCentralDomains::class], config('tenancy.identification.middleware'));
Middleware\PreventAccessFromCentralDomains::class,
Middleware\InitializeTenancyByDomain::class,
Middleware\InitializeTenancyBySubdomain::class,
Middleware\InitializeTenancyByDomainOrSubdomain::class,
Middleware\InitializeTenancyByPath::class,
Middleware\InitializeTenancyByRequestData::class,
];
foreach (array_reverse($tenancyMiddleware) as $middleware) { foreach (array_reverse($tenancyMiddleware) as $middleware) {
$this->app[\Illuminate\Contracts\Http\Kernel::class]->prependToMiddlewarePriority($middleware); $this->app[\Illuminate\Contracts\Http\Kernel::class]->prependToMiddlewarePriority($middleware);

View file

@ -2,14 +2,14 @@
declare(strict_types=1); declare(strict_types=1);
use Stancl\Tenancy\Database\Models\Domain; use Stancl\Tenancy\Middleware;
use Stancl\Tenancy\Database\Models\Tenant; use Stancl\Tenancy\Resolvers;
return [ return [
'tenant_model' => Tenant::class, 'tenant_model' => Stancl\Tenancy\Database\Models\Tenant::class,
'id_generator' => Stancl\Tenancy\UUIDGenerator::class, 'domain_model' => Stancl\Tenancy\Database\Models\Domain::class,
'domain_model' => Domain::class, 'id_generator' => Stancl\Tenancy\UUIDGenerator::class,
/** /**
* The list of domains hosting your central app. * The list of domains hosting your central app.
@ -21,6 +21,56 @@ return [
'localhost', 'localhost',
], ],
'identification' => [
/**
* The default middleware used for tenant identification.
*
* If you use multiple forms of identification, you can set this to the "main" approach you use.
*/
'default_middleware' => Middleware\InitializeTenancyByDomain::class,// todo@identification add this to a 'tenancy' mw group
/**
* All of the identification middleware used by the package.
*
* If you write your own, make sure to add them to this array.
*/
'middleware' => [
Middleware\InitializeTenancyByDomain::class,
Middleware\InitializeTenancyBySubdomain::class,
Middleware\InitializeTenancyByDomainOrSubdomain::class,
Middleware\InitializeTenancyByPath::class,
Middleware\InitializeTenancyByRequestData::class,
],
/**
* Tenant resolvers used by the package.
*
* Resolvers which implement the CachedTenantResolver contract have options for configuring the caching details.
* If you add your own resolvers, do not add the 'cache' key unless your resolver is based on CachedTenantResolver.
*/
'resolvers' => [
Resolvers\DomainTenantResolver::class => [
'cache' => false,
'cache_ttl' => 3600, // seconds
'cache_store' => null, // default
],
Resolvers\PathTenantResolver::class => [
'tenant_parameter_name' => 'tenant',
'cache' => false,
'cache_ttl' => 3600, // seconds
'cache_store' => null, // default
],
Resolvers\RequestDataTenantResolver::class => [
'cache' => false,
'cache_ttl' => 3600, // seconds
'cache_store' => null, // default
],
],
// todo@docs update integration guides to use Stancl\Tenancy::defaultMiddleware()
],
/** /**
* Tenancy bootstrappers are executed when tenancy is initialized. * Tenancy bootstrappers are executed when tenancy is initialized.
* Their responsibility is making Laravel features tenant-aware. * Their responsibility is making Laravel features tenant-aware.
@ -32,9 +82,29 @@ return [
Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper::class, Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper::class,
Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class, Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class,
Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class, Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class,
Stancl\Tenancy\Bootstrappers\BatchTenancyBootstrapper::class,
// Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed // Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed
], ],
/**
* Pending tenants config.
* This is useful if you're looking for a way to always have a tenant ready to be used.
*/
'pending' => [
/**
* If disabled, pending tenants will be excluded from all tenant queries.
* You can still use ::withPending(), ::withoutPending() and ::onlyPending() to include or exclude the pending tenants regardless of this setting.
* Note: when disabled, this will also ignore pending tenants when running the tenant commands (migration, seed, etc.)
*/
'include_in_queries' => true,
/**
* Defines how many pending tenants you want to have ready in the pending tenant pool.
* This depends on the volume of tenants you're creating.
*/
'count' => env('TENANCY_PENDING_COUNT', 5),
],
/** /**
* Database tenancy config. Used by DatabaseTenancyBootstrapper. * Database tenancy config. Used by DatabaseTenancyBootstrapper.
*/ */
@ -47,6 +117,11 @@ return [
*/ */
'template_tenant_connection' => null, 'template_tenant_connection' => null,
/**
* The name of the temporary connection used for creating and deleting tenant databases.
*/
'tenant_host_connection_name' => 'tenant_host_connection',
/** /**
* Tenant database names are created like this: * Tenant database names are created like this:
* prefix + tenant_id + suffix. * prefix + tenant_id + suffix.
@ -75,6 +150,9 @@ return [
*/ */
// 'pgsql' => Stancl\Tenancy\Database\TenantDatabaseManagers\PostgreSQLSchemaManager::class, // Separate by schema instead of database // 'pgsql' => Stancl\Tenancy\Database\TenantDatabaseManagers\PostgreSQLSchemaManager::class, // Separate by schema instead of database
], ],
// todo docblock
'drop_tenant_databases_on_migrate_fresh' => false,
], ],
/** /**
@ -118,6 +196,24 @@ return [
'public' => '%storage_path%/app/public/', 'public' => '%storage_path%/app/public/',
], ],
/*
* Tenant-aware Storage::disk()->url() can be enabled for specific local disks here
* by mapping the disk's name to a name with '%tenant_id%' (this will be used as the public name of the disk).
* Doing that will override the disk's default URL with a URL containing the current tenant's key.
*
* For example, Storage::disk('public')->url('') will return https://your-app.test/storage/ by default.
* After adding 'public' => 'public-%tenant_id%' to 'url_override',
* the returned URL will be https://your-app.test/public-1/ (%tenant_id% gets substitued by the current tenant's ID).
*
* Use `php artisan tenants:link` to create a symbolic link from the tenant's storage to its public directory.
*/
'url_override' => [
// Note that the local disk you add must exist in the tenancy.filesystem.root_override config
// todo@v4 Rename %tenant_id% to %tenant_key%
// todo@v4 Rename url_override to something that describes the config key better
'public' => 'public-%tenant_id%',
],
/** /**
* Should storage_path() be suffixed. * Should storage_path() be suffixed.
* *
@ -186,6 +282,7 @@ return [
'migration_parameters' => [ 'migration_parameters' => [
'--force' => true, // This needs to be true to run migrations in production. '--force' => true, // This needs to be true to run migrations in production.
'--path' => [database_path('migrations/tenant')], '--path' => [database_path('migrations/tenant')],
'--schema-path' => database_path('schema/tenant-schema.dump'),
'--realpath' => true, '--realpath' => true,
], ],
@ -193,7 +290,15 @@ return [
* Parameters used by the tenants:seed command. * Parameters used by the tenants:seed command.
*/ */
'seeder_parameters' => [ 'seeder_parameters' => [
'--class' => 'DatabaseSeeder', // root seeder class '--class' => 'Database\Seeders\DatabaseSeeder', // root seeder class
// '--force' => true, // '--force' => true,
], ],
/**
* Single-database tenancy config.
*/
'single_db' => [
/** The name of the column used by models with the BelongsToTenant trait. */
'tenant_id_column' => 'tenant_id',
],
]; ];

View file

@ -3,7 +3,8 @@
declare(strict_types=1); declare(strict_types=1);
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Controllers\TenantAssetController;
Route::get('/tenancy/assets/{path?}', 'Stancl\Tenancy\Controllers\TenantAssetsController@asset') Route::get('/tenancy/assets/{path?}', [TenantAssetController::class, 'asset'])
->where('path', '(.*)') ->where('path', '(.*)')
->name('stancl.tenancy.asset'); ->name('stancl.tenancy.asset');

View file

@ -18,10 +18,10 @@
"php": "^8.1", "php": "^8.1",
"ext-json": "*", "ext-json": "*",
"illuminate/support": "^9.0", "illuminate/support": "^9.0",
"facade/ignition-contracts": "^1.0", "spatie/ignition": "^1.4",
"ramsey/uuid": "^4.0", "ramsey/uuid": "^4.0",
"stancl/jobpipeline": "^1.0", "stancl/jobpipeline": "^1.0",
"stancl/virtualcolumn": "^1.0" "stancl/virtualcolumn": "^1.3"
}, },
"require-dev": { "require-dev": {
"laravel/framework": "^9.0", "laravel/framework": "^9.0",
@ -30,7 +30,8 @@
"doctrine/dbal": "^2.10", "doctrine/dbal": "^2.10",
"spatie/valuestore": "^1.2.5", "spatie/valuestore": "^1.2.5",
"pestphp/pest": "^1.21", "pestphp/pest": "^1.21",
"nunomaduro/larastan": "^1.0" "nunomaduro/larastan": "^1.0",
"spatie/invade": "^1.1"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@ -62,7 +63,8 @@
"docker-rebuild": "PHP_VERSION=8.1 docker-compose up -d --no-deps --build", "docker-rebuild": "PHP_VERSION=8.1 docker-compose up -d --no-deps --build",
"docker-m1": "ln -s docker-compose-m1.override.yml docker-compose.override.yml", "docker-m1": "ln -s docker-compose-m1.override.yml docker-compose.override.yml",
"coverage": "open coverage/phpunit/html/index.html", "coverage": "open coverage/phpunit/html/index.html",
"phpstan": "vendor/bin/phpstan", "phpstan": "vendor/bin/phpstan --pro",
"cs": "php-cs-fixer fix --config=.php-cs-fixer.php",
"test": "PHP_VERSION=8.1 ./test --no-coverage", "test": "PHP_VERSION=8.1 ./test --no-coverage",
"test-full": "PHP_VERSION=8.1 ./test" "test-full": "PHP_VERSION=8.1 ./test"
}, },

View file

@ -12,6 +12,8 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
# mssql:
# condition: service_healthy
volumes: volumes:
- .:/var/www/html:delegated - .:/var/www/html:delegated
environment: environment:
@ -74,4 +76,8 @@ services:
environment: environment:
- ACCEPT_EULA=Y - ACCEPT_EULA=Y
- SA_PASSWORD=P@ssword # todo reuse values from env above - SA_PASSWORD=P@ssword # todo reuse values from env above
# todo missing health check healthcheck:
test: /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P P@ssword -Q "SELECT 1" -b -o /dev/null
interval: 10s
timeout: 10s
retries: 10

View file

@ -1,5 +1,6 @@
includes: includes:
- ./vendor/nunomaduro/larastan/extension.neon - ./vendor/nunomaduro/larastan/extension.neon
- ./vendor/spatie/invade/phpstan-extension.neon
parameters: parameters:
paths: paths:
@ -10,16 +11,39 @@ parameters:
universalObjectCratesClasses: universalObjectCratesClasses:
- Illuminate\Routing\Route - Illuminate\Routing\Route
- Illuminate\Database\Eloquent\Model
ignoreErrors: ignoreErrors:
- - '#Cannot access offset (.*?) on Illuminate\\Contracts\\Foundation\\Application#'
message: '#Cannot access offset (.*?) on Illuminate\\Contracts\\Foundation\\Application#' - '#Cannot access offset (.*?) on Illuminate\\Contracts\\Config\\Repository#'
paths:
- src/TenancyServiceProvider.php
- -
message: '#invalid type Laravel\\Telescope\\IncomingEntry#' message: '#invalid type Laravel\\Telescope\\IncomingEntry#'
paths: paths:
- src/Features/TelescopeTags.php - src/Features/TelescopeTags.php
-
message: '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model::getRelationshipToPrimaryModel\(\)#'
paths:
- src/Database/ParentModelScope.php
-
message: '#Parameter \#1 \$key of method Illuminate\\Contracts\\Cache\\Repository::put\(\) expects string#'
paths:
- src/helpers.php
-
message: '#PHPDoc tag \@param has invalid value \(dynamic#'
paths:
- src/helpers.php
-
message: '#Illuminate\\Routing\\UrlGenerator#'
paths:
- src/Bootstrappers/FilesystemTenancyBootstrapper.php
-
message: '#select\(\) expects string, Illuminate\\Database\\Query\\Expression given#'
paths:
- src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php
-
message: '#Trying to invoke Closure\|null but it might not be a callable#'
paths:
- src/Database/DatabaseConfig.php
checkMissingIterableValueType: false checkMissingIterableValueType: false
treatPhpDocTypesAsCertain: false treatPhpDocTypesAsCertain: false

View file

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Actions;
use Exception;
use Illuminate\Support\Collection;
use Illuminate\Support\LazyCollection;
use Stancl\Tenancy\Concerns\DealsWithTenantSymlinks;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Events\CreatingStorageSymlink;
use Stancl\Tenancy\Events\StorageSymlinkCreated;
class CreateStorageSymlinksAction
{
use DealsWithTenantSymlinks;
public static function handle(Tenant|Collection|LazyCollection $tenants, bool $relativeLink = false, bool $force = false): void
{
$tenants = $tenants instanceof Tenant ? collect([$tenants]) : $tenants;
/** @var Tenant $tenant */
foreach ($tenants as $tenant) {
foreach (static::possibleTenantSymlinks($tenant) as $publicPath => $storagePath) {
static::createLink($publicPath, $storagePath, $tenant, $relativeLink, $force);
}
}
}
protected static function createLink(string $publicPath, string $storagePath, Tenant $tenant, bool $relativeLink, bool $force): void
{
event(new CreatingStorageSymlink($tenant));
if (static::symlinkExists($publicPath)) {
// If $force isn't passed, don't overwrite the existing symlink
throw_if(! $force, new Exception("The [$publicPath] link already exists."));
app()->make('files')->delete($publicPath);
}
// Make sure the storage path exists before we create a symlink
if (! is_dir($storagePath)) {
mkdir($storagePath, 0777, true);
}
if ($relativeLink) {
app()->make('files')->relativeLink($storagePath, $publicPath);
} else {
app()->make('files')->link($storagePath, $publicPath);
}
event((new StorageSymlinkCreated($tenant)));
}
}

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Actions;
use Illuminate\Support\Collection;
use Illuminate\Support\LazyCollection;
use Stancl\Tenancy\Concerns\DealsWithTenantSymlinks;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Events\RemovingStorageSymlink;
use Stancl\Tenancy\Events\StorageSymlinkRemoved;
class RemoveStorageSymlinksAction
{
use DealsWithTenantSymlinks;
public static function handle(Tenant|Collection|LazyCollection $tenants): void
{
$tenants = $tenants instanceof Tenant ? collect([$tenants]) : $tenants;
/** @var Tenant $tenant */
foreach ($tenants as $tenant) {
foreach (static::possibleTenantSymlinks($tenant) as $publicPath => $storagePath) {
static::removeLink($publicPath, $tenant);
}
}
}
protected static function removeLink(string $publicPath, Tenant $tenant): void
{
if (static::symlinkExists($publicPath)) {
event(new RemovingStorageSymlink($tenant));
app()->make('files')->delete($publicPath);
event(new StorageSymlinkRemoved($tenant));
}
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers;
use Illuminate\Bus\DatabaseBatchRepository;
use Illuminate\Database\Connection;
use Illuminate\Database\DatabaseManager;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
class BatchTenancyBootstrapper implements TenancyBootstrapper
{
/**
* The previous database connection instance.
*/
protected ?Connection $previousConnection = null;
public function __construct(
protected DatabaseBatchRepository $batchRepository,
protected DatabaseManager $databaseManager
) {
}
public function bootstrap(Tenant $tenant): void
{
// Update batch repository connection to use the tenant connection
$this->previousConnection = $this->batchRepository->getConnection();
$this->batchRepository->setConnection($this->databaseManager->connection('tenant'));
}
public function revert(): void
{
if ($this->previousConnection) {
// Replace batch repository connection with the previously replaced one
$this->batchRepository->setConnection($this->previousConnection);
$this->previousConnection = null;
}
}
}

View file

@ -13,18 +13,14 @@ use Stancl\Tenancy\Contracts\Tenant;
class CacheTenancyBootstrapper implements TenancyBootstrapper class CacheTenancyBootstrapper implements TenancyBootstrapper
{ {
/** @var CacheManager */ protected ?CacheManager $originalCache = null;
protected $originalCache;
/** @var Application */ public function __construct(
protected $app; protected Application $app
) {
public function __construct(Application $app)
{
$this->app = $app;
} }
public function bootstrap(Tenant $tenant) public function bootstrap(Tenant $tenant): void
{ {
$this->resetFacadeCache(); $this->resetFacadeCache();
@ -34,7 +30,7 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper
}); });
} }
public function revert() public function revert(): void
{ {
$this->resetFacadeCache(); $this->resetFacadeCache();
@ -50,7 +46,7 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper
* facade has been made prior to bootstrapping tenancy. The * facade has been made prior to bootstrapping tenancy. The
* facade has its own cache, separate from the container. * facade has its own cache, separate from the container.
*/ */
public function resetFacadeCache() public function resetFacadeCache(): void
{ {
Cache::clearResolvedInstances(); Cache::clearResolvedInstances();
} }

View file

@ -8,7 +8,7 @@ use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\DatabaseManager; use Stancl\Tenancy\Database\DatabaseManager;
use Stancl\Tenancy\Exceptions\TenantDatabaseDoesNotExistException; use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException;
class DatabaseTenancyBootstrapper implements TenancyBootstrapper class DatabaseTenancyBootstrapper implements TenancyBootstrapper
{ {
@ -20,7 +20,7 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper
$this->database = $database; $this->database = $database;
} }
public function bootstrap(Tenant $tenant) public function bootstrap(Tenant $tenant): void
{ {
/** @var TenantWithDatabase $tenant */ /** @var TenantWithDatabase $tenant */
@ -35,7 +35,7 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper
$this->database->connectToTenant($tenant); $this->database->connectToTenant($tenant);
} }
public function revert() public function revert(): void
{ {
$this->database->reconnectToCentral(); $this->database->reconnectToCentral();
} }

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers; namespace Stancl\Tenancy\Bootstrappers;
use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\Foundation\Application;
use Illuminate\Routing\UrlGenerator;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
@ -27,13 +28,14 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
]; ];
$this->app['url']->macro('setAssetRoot', function ($root) { $this->app['url']->macro('setAssetRoot', function ($root) {
/** @var UrlGenerator $this */
$this->assetRoot = $root; $this->assetRoot = $root;
return $this; return $this;
}); });
} }
public function bootstrap(Tenant $tenant) public function bootstrap(Tenant $tenant): void
{ {
$suffix = $this->app['config']['tenancy.filesystem.suffix_base'] . $tenant->getTenantKey(); $suffix = $this->app['config']['tenancy.filesystem.suffix_base'] . $tenant->getTenantKey();
@ -45,7 +47,7 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
// asset() // asset()
if ($this->app['config']['tenancy.filesystem.asset_helper_tenancy'] ?? true) { if ($this->app['config']['tenancy.filesystem.asset_helper_tenancy'] ?? true) {
if ($this->originalPaths['asset_url']) { if ($this->originalPaths['asset_url']) {
$this->app['config']['app.asset_url'] = ($this->originalPaths['asset_url'] ?? $this->app['config']['app.url']) . "/$suffix"; $this->app['config']['app.asset_url'] = $this->originalPaths['asset_url'] . "/$suffix";
$this->app['url']->setAssetRoot($this->app['config']['app.asset_url']); $this->app['url']->setAssetRoot($this->app['config']['app.asset_url']);
} else { } else {
$this->app['url']->setAssetRoot($this->app['url']->route('stancl.tenancy.asset', ['path' => ''])); $this->app['url']->setAssetRoot($this->app['url']->route('stancl.tenancy.asset', ['path' => '']));
@ -57,9 +59,10 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
foreach ($this->app['config']['tenancy.filesystem.disks'] as $disk) { foreach ($this->app['config']['tenancy.filesystem.disks'] as $disk) {
// todo@v4 \League\Flysystem\PathPrefixer is making this a lot more painful in flysystem v2 // todo@v4 \League\Flysystem\PathPrefixer is making this a lot more painful in flysystem v2
$diskConfig = $this->app['config']["filesystems.disks.{$disk}"];
$originalRoot = $diskConfig['root'] ?? null;
$originalRoot = $this->app['config']["filesystems.disks.{$disk}.root"]; $this->originalPaths['disks']['path'][$disk] = $originalRoot;
$this->originalPaths['disks'][$disk] = $originalRoot;
$finalPrefix = str_replace( $finalPrefix = str_replace(
['%storage_path%', '%tenant%'], ['%storage_path%', '%tenant%'],
@ -74,10 +77,23 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
} }
$this->app['config']["filesystems.disks.{$disk}.root"] = $finalPrefix; $this->app['config']["filesystems.disks.{$disk}.root"] = $finalPrefix;
// Storage Url
if ($diskConfig['driver'] === 'local') {
$this->originalPaths['disks']['url'][$disk] = $diskConfig['url'] ?? null;
if ($url = str_replace(
'%tenant_id%',
(string) $tenant->getTenantKey(),
$this->app['config']["tenancy.filesystem.url_override.{$disk}"] ?? ''
)) {
$this->app['config']["filesystems.disks.{$disk}.url"] = url($url);
}
}
} }
} }
public function revert() public function revert(): void
{ {
// storage_path() // storage_path()
$this->app->useStoragePath($this->originalPaths['storage']); $this->app->useStoragePath($this->originalPaths['storage']);
@ -88,8 +104,16 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
// Storage facade // Storage facade
Storage::forgetDisk($this->app['config']['tenancy.filesystem.disks']); Storage::forgetDisk($this->app['config']['tenancy.filesystem.disks']);
foreach ($this->app['config']['tenancy.filesystem.disks'] as $disk) { foreach ($this->app['config']['tenancy.filesystem.disks'] as $diskName) {
$this->app['config']["filesystems.disks.{$disk}.root"] = $this->originalPaths['disks'][$disk]; $this->app['config']["filesystems.disks.$diskName.root"] = $this->originalPaths['disks']['path'][$diskName];
$diskConfig = $this->app['config']['filesystems.disks.' . $diskName];
// Storage Url
$url = $this->originalPaths['disks.url.' . $diskName] ?? null;
if ($diskConfig['driver'] === 'local' && ! is_null($url)) {
$$this->app['config']["filesystems.disks.$diskName.url"] = $url;
}
} }
} }
} }

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers\Integrations;
use Illuminate\Contracts\Config\Repository;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
class ScoutTenancyBootstrapper implements TenancyBootstrapper
{
protected ?string $originalScoutPrefix = null;
public function __construct(
protected Repository $config,
) {
}
public function bootstrap(Tenant $tenant): void
{
if ($this->originalScoutPrefix !== null) {
$this->originalScoutPrefix = $this->config->get('scout.prefix');
}
$this->config->set('scout.prefix', $this->getTenantPrefix($tenant));
}
public function revert(): void
{
$this->config->set('scout.prefix', $this->originalScoutPrefix);
}
protected function getTenantPrefix(Tenant $tenant): string
{
return (string) $tenant->getTenantKey();
}
}

View file

@ -39,7 +39,7 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
* However, we're registering a hook to initialize tenancy. Therefore, * However, we're registering a hook to initialize tenancy. Therefore,
* we need to register the hook at service provider execution time. * we need to register the hook at service provider execution time.
*/ */
public static function __constructStatic(Application $app) public static function __constructStatic(Application $app): void
{ {
static::setUpJobListener($app->make(Dispatcher::class), $app->runningUnitTests()); static::setUpJobListener($app->make(Dispatcher::class), $app->runningUnitTests());
} }
@ -52,7 +52,7 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
$this->setUpPayloadGenerator(); $this->setUpPayloadGenerator();
} }
protected static function setUpJobListener($dispatcher, $runningTests) protected static function setUpJobListener(Dispatcher $dispatcher, bool $runningTests): void
{ {
$previousTenant = null; $previousTenant = null;
@ -62,14 +62,11 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
static::initializeTenancyForQueue($event->job->payload()['tenant_id'] ?? null); static::initializeTenancyForQueue($event->job->payload()['tenant_id'] ?? null);
}); });
if (version_compare(app()->version(), '8.64', '>=')) {
// JobRetryRequested only exists since Laravel 8.64
$dispatcher->listen(JobRetryRequested::class, function ($event) use (&$previousTenant) { $dispatcher->listen(JobRetryRequested::class, function ($event) use (&$previousTenant) {
$previousTenant = tenant(); $previousTenant = tenant();
static::initializeTenancyForQueue($event->payload()['tenant_id'] ?? null); static::initializeTenancyForQueue($event->payload()['tenant_id'] ?? null);
}); });
}
// If we're running tests, we make sure to clean up after any artisan('queue:work') calls // If we're running tests, we make sure to clean up after any artisan('queue:work') calls
$revertToPreviousState = function ($event) use (&$previousTenant, $runningTests) { $revertToPreviousState = function ($event) use (&$previousTenant, $runningTests) {
@ -82,7 +79,7 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
$dispatcher->listen(JobFailed::class, $revertToPreviousState); // artisan('queue:work') which fails $dispatcher->listen(JobFailed::class, $revertToPreviousState); // artisan('queue:work') which fails
} }
protected static function initializeTenancyForQueue($tenantId) protected static function initializeTenancyForQueue(string|int $tenantId): void
{ {
if (! $tenantId) { if (! $tenantId) {
// The job is not tenant-aware // The job is not tenant-aware
@ -100,7 +97,9 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
tenancy()->end(); tenancy()->end();
} }
tenancy()->initialize(tenancy()->find($tenantId)); /** @var Tenant $tenant */
$tenant = tenancy()->find($tenantId);
tenancy()->initialize($tenant);
return; return;
} }
@ -115,10 +114,13 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
// Tenancy was either not initialized, or initialized for a different tenant. // Tenancy was either not initialized, or initialized for a different tenant.
// Therefore, we initialize it for the correct tenant. // Therefore, we initialize it for the correct tenant.
tenancy()->initialize(tenancy()->find($tenantId));
/** @var Tenant $tenant */
$tenant = tenancy()->find($tenantId);
tenancy()->initialize($tenant);
} }
protected static function revertToPreviousState($event, &$previousTenant) protected static function revertToPreviousState(JobProcessed|JobFailed $event, ?Tenant &$previousTenant): void
{ {
$tenantId = $event->job->payload()['tenant_id'] ?? null; $tenantId = $event->job->payload()['tenant_id'] ?? null;
@ -138,7 +140,7 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
} }
} }
protected function setUpPayloadGenerator() protected function setUpPayloadGenerator(): void
{ {
$bootstrapper = &$this; $bootstrapper = &$this;
@ -149,17 +151,17 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
} }
} }
public function bootstrap(Tenant $tenant) public function bootstrap(Tenant $tenant): void
{ {
// //
} }
public function revert() public function revert(): void
{ {
// //
} }
public function getPayload(string $connection) public function getPayload(string $connection): array
{ {
if (! tenancy()->initialized) { if (! tenancy()->initialized) {
return []; return [];

View file

@ -22,18 +22,21 @@ class RedisTenancyBootstrapper implements TenancyBootstrapper
$this->config = $config; $this->config = $config;
} }
public function bootstrap(Tenant $tenant) public function bootstrap(Tenant $tenant): void
{ {
foreach ($this->prefixedConnections() as $connection) { foreach ($this->prefixedConnections() as $connection) {
$prefix = $this->config['tenancy.redis.prefix_base'] . $tenant->getTenantKey(); $prefix = $this->config['tenancy.redis.prefix_base'] . $tenant->getTenantKey();
$client = Redis::connection($connection)->client(); $client = Redis::connection($connection)->client();
$this->originalPrefixes[$connection] = $client->getOption($client::OPT_PREFIX); /** @var string $originalPrefix */
$originalPrefix = $client->getOption($client::OPT_PREFIX);
$this->originalPrefixes[$connection] = $originalPrefix;
$client->setOption($client::OPT_PREFIX, $prefix); $client->setOption($client::OPT_PREFIX, $prefix);
} }
} }
public function revert() public function revert(): void
{ {
foreach ($this->prefixedConnections() as $connection) { foreach ($this->prefixedConnections() as $connection) {
$client = Redis::connection($connection)->client(); $client = Redis::connection($connection)->client();
@ -44,7 +47,8 @@ class RedisTenancyBootstrapper implements TenancyBootstrapper
$this->originalPrefixes = []; $this->originalPrefixes = [];
} }
protected function prefixedConnections() /** @return string[] */
protected function prefixedConnections(): array
{ {
return $this->config['tenancy.redis.prefixed_connections']; return $this->config['tenancy.redis.prefixed_connections'];
} }

View file

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Commands;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
class ClearPendingTenants extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'tenants:pending-clear
{--all : Override the default settings and deletes all pending tenants}
{--older-than-days= : Deletes all pending tenants older than the amount of days}
{--older-than-hours= : Deletes all pending tenants older than the amount of hours}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Remove pending tenants.';
/**
* Execute the console command.
*/
public function handle()
{
$this->info('Removing pending tenants.');
$expirationDate = now();
// We compare the original expiration date to the new one to check if the new one is different later
$originalExpirationDate = $expirationDate->copy()->toImmutable();
// Skip the time constraints if the 'all' option is given
if (! $this->option('all')) {
$olderThanDays = $this->option('older-than-days');
$olderThanHours = $this->option('older-than-hours');
if ($olderThanDays && $olderThanHours) {
$this->line("<options=bold,reverse;fg=red> Cannot use '--older-than-days' and '--older-than-hours' together \n"); // todo@cli refactor all of these styled command outputs to use $this->components
$this->line('Please, choose only one of these options.');
return 1; // Exit code for failure
}
if ($olderThanDays) {
$expirationDate->subDays($olderThanDays);
}
if ($olderThanHours) {
$expirationDate->subHours($olderThanHours);
}
}
$deletedTenantCount = tenancy()
->query()
->onlyPending()
->when($originalExpirationDate->notEqualTo($expirationDate), function (Builder $query) use ($expirationDate) {
$query->where($query->getModel()->getColumnForQuery('pending_since'), '<', $expirationDate->timestamp);
})
->get()
->each // Trigger the model events by deleting the tenants one by one
->delete()
->count();
$this->info($deletedTenantCount . ' pending ' . str('tenant')->plural($deletedTenantCount) . ' deleted.');
}
}

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Commands;
use Illuminate\Console\Command;
class CreatePendingTenants extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'tenants:pending-create {--count= : The number of pending tenants to be created}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create pending tenants.';
/**
* Execute the console command.
*/
public function handle()
{
$this->info('Creating pending tenants.');
$maxPendingTenantCount = (int) ($this->option('count') ?? config('tenancy.pending.count'));
$pendingTenantCount = $this->getPendingTenantCount();
$createdCount = 0;
while ($pendingTenantCount < $maxPendingTenantCount) {
tenancy()->model()::createPending();
// Fetching the pending tenant count in each iteration prevents creating too many tenants
// If pending tenants are being created somewhere else while running this command
$pendingTenantCount = $this->getPendingTenantCount();
$createdCount++;
}
$this->info($createdCount . ' ' . str('tenant')->plural($createdCount) . ' created.');
$this->info($maxPendingTenantCount . ' ' . str('tenant')->plural($maxPendingTenantCount) . ' ready to be used.');
return 1;
}
/**
* Calculate the number of currently available pending tenants.
*/
private function getPendingTenantCount(): int
{
return tenancy()
->query()
->onlyPending()
->count();
}
}

53
src/Commands/Down.php Normal file
View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Commands;
use Illuminate\Foundation\Console\DownCommand;
use Stancl\Tenancy\Concerns\HasTenantOptions;
class Down extends DownCommand
{
use HasTenantOptions;
protected $signature = 'tenants:down
{--redirect= : The path that users should be redirected to}
{--retry= : The number of seconds after which the request may be retried}
{--refresh= : The number of seconds after which the browser may refresh}
{--secret= : The secret phrase that may be used to bypass maintenance mode}
{--status=503 : The status code that should be used when returning the maintenance mode response}';
protected $description = 'Put tenants into maintenance mode.';
public function handle(): int
{
$payload = $this->getDownDatabasePayload();
tenancy()->runForMultiple($this->getTenants(), function ($tenant) use ($payload) {
$this->components->info("Tenant: {$tenant->getTenantKey()}");
$tenant->putDownForMaintenance($payload);
});
$this->components->info('Tenants are now in maintenance mode.');
return 0;
}
/**
* Get the payload to be placed in the "down" file. This
* payload is the same as the original function
* but without the 'template' option.
*/
protected function getDownDatabasePayload(): array
{
return [
'except' => $this->excludedPaths(),
'redirect' => $this->redirectPath(),
'retry' => $this->getRetryTime(),
'refresh' => $this->option('refresh'),
'secret' => $this->option('secret'),
'status' => (int) ($this->option('status') ?? 503),
];
}
}

View file

@ -4,63 +4,136 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Commands; namespace Stancl\Tenancy\Commands;
use Closure;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class Install extends Command class Install extends Command
{ {
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'tenancy:install'; protected $signature = 'tenancy:install';
/** protected $description = 'Install Tenancy for Laravel.';
* The console command description.
*
* @var string
*/
protected $description = 'Install stancl/tenancy.';
/** public function handle(): int
* Execute the console command.
*/
public function handle()
{ {
$this->comment('Installing stancl/tenancy...'); $this->step(
$this->callSilent('vendor:publish', [ name: 'Publishing config file',
'--provider' => 'Stancl\Tenancy\TenancyServiceProvider', tag: 'config',
'--tag' => 'config', file: 'config/tenancy.php',
]); newLineBefore: true,
$this->info('✔️ Created config/tenancy.php'); );
if (! file_exists(base_path('routes/tenant.php'))) { $this->step(
$this->callSilent('vendor:publish', [ name: 'Publishing routes',
tag: 'routes',
file: 'routes/tenant.php',
);
$this->step(
name: 'Publishing service provider',
tag: 'providers',
file: 'app/Providers/TenancyServiceProvider.php',
);
$this->step(
name: 'Publishing migrations',
tag: 'migrations',
files: [
'database/migrations/2019_09_15_000010_create_tenants_table.php',
'database/migrations/2019_09_15_000020_create_domains_table.php',
],
warning: 'Migrations already exist',
);
$this->step(
name: 'Creating [database/migrations/tenant] folder',
task: fn () => mkdir(database_path('migrations/tenant')),
unless: is_dir(database_path('migrations/tenant')),
warning: 'Folder [database/migrations/tenant] already exists.',
newLineAfter: true,
);
$this->components->info('✨️ Tenancy for Laravel successfully installed.');
$this->askForSupport();
return 0;
}
/**
* Run a step of the installation process.
*
* @param string $name The name of the step.
* @param Closure|null $task The task code.
* @param bool $unless Condition specifying when the task should NOT run.
* @param string|null $warning Warning shown when the $unless condition is true.
* @param string|null $file Name of the file being added.
* @param string|null $tag The tag being published.
* @param array|null $files Names of files being added.
* @param bool $newLineBefore Should a new line be printed after the step.
* @param bool $newLineAfter Should a new line be printed after the step.
*/
protected function step(
string $name,
Closure $task = null,
bool $unless = false,
string $warning = null,
string $file = null,
string $tag = null,
array $files = null,
bool $newLineBefore = false,
bool $newLineAfter = false,
): void {
if ($file) {
$name .= " [$file]"; // Append clickable path to the task name
$unless = file_exists(base_path($file)); // Make the condition a check for the file's existence
$warning = "File [$file] already exists."; // Make the warning a message about the file already existing
}
if ($tag) {
$task = fn () => $this->callSilent('vendor:publish', [
'--provider' => 'Stancl\Tenancy\TenancyServiceProvider', '--provider' => 'Stancl\Tenancy\TenancyServiceProvider',
'--tag' => 'routes', '--tag' => $tag,
]); ]);
$this->info('✔️ Created routes/tenant.php'); }
if ($files) {
// Show a warning if any of the files already exist
$unless = count(array_filter($files, fn ($file) => file_exists(base_path($file)))) !== 0;
}
if (! $unless) {
if ($newLineBefore) {
$this->newLine();
}
$this->components->task($name, $task ?? fn () => null);
if ($files) {
// Print out a clickable list of the added files
$this->components->bulletList(array_map(fn (string $file) => "[$file]", $files));
}
if ($newLineAfter) {
$this->newLine();
}
} else { } else {
$this->info('Found routes/tenant.php.'); $this->components->warn($warning);
} }
}
$this->callSilent('vendor:publish', [
'--provider' => 'Stancl\Tenancy\TenancyServiceProvider', /** If the user accepts, opens the GitHub project in the browser. */
'--tag' => 'providers', public function askForSupport(): void
]); {
$this->info('✔️ Created TenancyServiceProvider.php'); if ($this->components->confirm('Would you like to show your support by starring the project on GitHub?', true)) {
if (PHP_OS_FAMILY === 'Darwin') {
$this->callSilent('vendor:publish', [ exec('open https://github.com/archtechx/tenancy');
'--provider' => 'Stancl\Tenancy\TenancyServiceProvider', }
'--tag' => 'migrations', if (PHP_OS_FAMILY === 'Windows') {
]); exec('start https://github.com/archtechx/tenancy');
$this->info('✔️ Created migrations. Remember to run [php artisan migrate]!'); }
if (PHP_OS_FAMILY === 'Linux') {
if (! is_dir(database_path('migrations/tenant'))) { exec('xdg-open https://github.com/archtechx/tenancy');
mkdir(database_path('migrations/tenant')); }
$this->info('✔️ Created database/migrations/tenant folder.'); }
}
$this->comment('✨️ stancl/tenancy installed successfully.');
} }
} }

62
src/Commands/Link.php Normal file
View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Commands;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\LazyCollection;
use Stancl\Tenancy\Actions\CreateStorageSymlinksAction;
use Stancl\Tenancy\Actions\RemoveStorageSymlinksAction;
use Stancl\Tenancy\Concerns\HasTenantOptions;
class Link extends Command
{
use HasTenantOptions;
protected $signature = 'tenants:link
{--tenants=* : The tenant(s) to run the command for. Default: all}
{--relative : Create the symbolic link using relative paths}
{--force : Recreate existing symbolic links}
{--remove : Remove symbolic links}';
protected $description = 'Create or remove tenant symbolic links.';
public function handle(): int
{
$tenants = $this->getTenants();
try {
if ($this->option('remove')) {
$this->removeLinks($tenants);
} else {
$this->createLinks($tenants);
}
} catch (Exception $exception) {
$this->error($exception->getMessage());
return 1;
}
return 0;
}
protected function removeLinks(LazyCollection $tenants): void
{
RemoveStorageSymlinksAction::handle($tenants);
$this->components->info('The links have been removed.');
}
protected function createLinks(LazyCollection $tenants): void
{
CreateStorageSymlinksAction::handle(
$tenants,
(bool) ($this->option('relative') ?? false),
(bool) ($this->option('force') ?? false),
);
$this->components->info('The links have been created.');
}
}

View file

@ -9,13 +9,13 @@ use Illuminate\Database\Console\Migrations\MigrateCommand;
use Illuminate\Database\Migrations\Migrator; use Illuminate\Database\Migrations\Migrator;
use Stancl\Tenancy\Concerns\DealsWithMigrations; use Stancl\Tenancy\Concerns\DealsWithMigrations;
use Stancl\Tenancy\Concerns\ExtendsLaravelCommand; use Stancl\Tenancy\Concerns\ExtendsLaravelCommand;
use Stancl\Tenancy\Concerns\HasATenantsOption; use Stancl\Tenancy\Concerns\HasTenantOptions;
use Stancl\Tenancy\Events\DatabaseMigrated; use Stancl\Tenancy\Events\DatabaseMigrated;
use Stancl\Tenancy\Events\MigratingDatabase; use Stancl\Tenancy\Events\MigratingDatabase;
class Migrate extends MigrateCommand class Migrate extends MigrateCommand
{ {
use HasATenantsOption, DealsWithMigrations, ExtendsLaravelCommand; use HasTenantOptions, DealsWithMigrations, ExtendsLaravelCommand;
protected $description = 'Run migrations for tenant(s)'; protected $description = 'Run migrations for tenant(s)';
@ -31,10 +31,7 @@ class Migrate extends MigrateCommand
$this->specifyParameters(); $this->specifyParameters();
} }
/** public function handle(): int
* Execute the console command.
*/
public function handle()
{ {
foreach (config('tenancy.migration_parameters') as $parameter => $value) { foreach (config('tenancy.migration_parameters') as $parameter => $value) {
if (! $this->input->hasParameterOption($parameter)) { if (! $this->input->hasParameterOption($parameter)) {
@ -43,11 +40,11 @@ class Migrate extends MigrateCommand
} }
if (! $this->confirmToProceed()) { if (! $this->confirmToProceed()) {
return; return 1;
} }
tenancy()->runForMultiple($this->option('tenants'), function ($tenant) { tenancy()->runForMultiple($this->getTenants(), function ($tenant) {
$this->line("Tenant: {$tenant->getTenantKey()}"); $this->components->info("Tenant: {$tenant->getTenantKey()}");
event(new MigratingDatabase($tenant)); event(new MigratingDatabase($tenant));
@ -56,5 +53,7 @@ class Migrate extends MigrateCommand
event(new DatabaseMigrated($tenant)); event(new DatabaseMigrated($tenant));
}); });
return 0;
} }
} }

View file

@ -6,18 +6,13 @@ namespace Stancl\Tenancy\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Stancl\Tenancy\Concerns\DealsWithMigrations; use Stancl\Tenancy\Concerns\DealsWithMigrations;
use Stancl\Tenancy\Concerns\HasATenantsOption; use Stancl\Tenancy\Concerns\HasTenantOptions;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
final class MigrateFresh extends Command class MigrateFresh extends Command
{ {
use HasATenantsOption, DealsWithMigrations; use HasTenantOptions, DealsWithMigrations;
/**
* The console command description.
*
* @var string
*/
protected $description = 'Drop all tables and re-run all migrations for tenant(s)'; protected $description = 'Drop all tables and re-run all migrations for tenant(s)';
public function __construct() public function __construct()
@ -29,26 +24,27 @@ final class MigrateFresh extends Command
$this->setName('tenants:migrate-fresh'); $this->setName('tenants:migrate-fresh');
} }
/** public function handle(): int
* Execute the console command.
*/
public function handle()
{ {
tenancy()->runForMultiple($this->option('tenants'), function ($tenant) { tenancy()->runForMultiple($this->getTenants(), function ($tenant) {
$this->info('Dropping tables.'); $this->components->info("Tenant: {$tenant->getTenantKey()}");
$this->call('db:wipe', array_filter([
$this->components->task('Dropping tables', function () {
$this->callSilently('db:wipe', array_filter([
'--database' => 'tenant', '--database' => 'tenant',
'--drop-views' => $this->option('drop-views'), '--drop-views' => $this->option('drop-views'),
'--force' => true, '--force' => true,
])); ]));
});
$this->info('Migrating.'); $this->components->task('Migrating', function () use ($tenant) {
$this->callSilent('tenants:migrate', [ $this->callSilent('tenants:migrate', [
'--tenants' => [$tenant->getTenantKey()], '--tenants' => [$tenant->getTenantKey()],
'--force' => true, '--force' => true,
]); ]);
}); });
});
$this->info('Done.'); return 0;
} }
} }

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Commands;
use Illuminate\Database\Console\Migrations\FreshCommand;
class MigrateFreshOverride extends FreshCommand
{
public function handle()
{
if (config('tenancy.database.drop_tenant_databases_on_migrate_fresh')) {
tenancy()->model()::cursor()->each->delete();
}
return parent::handle();
}
}

View file

@ -8,31 +8,16 @@ use Illuminate\Database\Console\Migrations\RollbackCommand;
use Illuminate\Database\Migrations\Migrator; use Illuminate\Database\Migrations\Migrator;
use Stancl\Tenancy\Concerns\DealsWithMigrations; use Stancl\Tenancy\Concerns\DealsWithMigrations;
use Stancl\Tenancy\Concerns\ExtendsLaravelCommand; use Stancl\Tenancy\Concerns\ExtendsLaravelCommand;
use Stancl\Tenancy\Concerns\HasATenantsOption; use Stancl\Tenancy\Concerns\HasTenantOptions;
use Stancl\Tenancy\Events\DatabaseRolledBack; use Stancl\Tenancy\Events\DatabaseRolledBack;
use Stancl\Tenancy\Events\RollingBackDatabase; use Stancl\Tenancy\Events\RollingBackDatabase;
class Rollback extends RollbackCommand class Rollback extends RollbackCommand
{ {
use HasATenantsOption, DealsWithMigrations, ExtendsLaravelCommand; use HasTenantOptions, DealsWithMigrations, ExtendsLaravelCommand;
protected static function getTenantCommandName(): string
{
return 'tenants:rollback';
}
/**
* The console command description.
*
* @var string
*/
protected $description = 'Rollback migrations for tenant(s).'; protected $description = 'Rollback migrations for tenant(s).';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct(Migrator $migrator) public function __construct(Migrator $migrator)
{ {
parent::__construct($migrator); parent::__construct($migrator);
@ -40,10 +25,7 @@ class Rollback extends RollbackCommand
$this->specifyTenantSignature(); $this->specifyTenantSignature();
} }
/** public function handle(): int
* Execute the console command.
*/
public function handle()
{ {
foreach (config('tenancy.migration_parameters') as $parameter => $value) { foreach (config('tenancy.migration_parameters') as $parameter => $value) {
if (! $this->input->hasParameterOption($parameter)) { if (! $this->input->hasParameterOption($parameter)) {
@ -52,11 +34,11 @@ class Rollback extends RollbackCommand
} }
if (! $this->confirmToProceed()) { if (! $this->confirmToProceed()) {
return; return 1;
} }
tenancy()->runForMultiple($this->option('tenants'), function ($tenant) { tenancy()->runForMultiple($this->getTenants(), function ($tenant) {
$this->line("Tenant: {$tenant->getTenantKey()}"); $this->components->info("Tenant: {$tenant->getTenantKey()}");
event(new RollingBackDatabase($tenant)); event(new RollingBackDatabase($tenant));
@ -65,5 +47,12 @@ class Rollback extends RollbackCommand
event(new DatabaseRolledBack($tenant)); event(new DatabaseRolledBack($tenant));
}); });
return 0;
}
protected static function getTenantCommandName(): string
{
return 'tenants:rollback';
} }
} }

View file

@ -5,36 +5,46 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Commands; namespace Stancl\Tenancy\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan; use Illuminate\Contracts\Console\Kernel;
use Stancl\Tenancy\Concerns\HasTenantOptions;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\ConsoleOutput;
class Run extends Command class Run extends Command
{ {
/** use HasTenantOptions;
* The console command description.
*
* @var string
*/
protected $description = 'Run a command for tenant(s)'; protected $description = 'Run a command for tenant(s)';
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'tenants:run {commandname : The artisan command.} protected $signature = 'tenants:run {commandname : The artisan command.}
{--tenants=* : The tenant(s) to run the command for. Default: all}'; {--tenants=* : The tenant(s) to run the command for. Default: all}';
/** public function handle(): int
* Execute the console command.
*/
public function handle()
{ {
tenancy()->runForMultiple($this->option('tenants'), function ($tenant) { $argvInput = $this->argvInput();
$this->line("Tenant: {$tenant->getTenantKey()}");
Artisan::call($this->argument('commandname')); tenancy()->runForMultiple($this->getTenants(), function ($tenant) use ($argvInput) {
$this->comment('Command output:'); $this->components->info("Tenant: {$tenant->getTenantKey()}");
$this->info(Artisan::output());
$this->getLaravel()
->make(Kernel::class)
->handle($argvInput, new ConsoleOutput);
}); });
return 0;
}
protected function argvInput(): ArgvInput
{
/** @var string $commandName */
$commandName = $this->argument('commandname');
// Convert string command to array
$subCommand = explode(' ', $commandName);
// Add "artisan" as first parameter because ArgvInput expects "artisan" as first parameter and later removes it
array_unshift($subCommand, 'artisan');
return new ArgvInput($subCommand);
} }
} }

View file

@ -6,37 +6,24 @@ namespace Stancl\Tenancy\Commands;
use Illuminate\Database\ConnectionResolverInterface; use Illuminate\Database\ConnectionResolverInterface;
use Illuminate\Database\Console\Seeds\SeedCommand; use Illuminate\Database\Console\Seeds\SeedCommand;
use Stancl\Tenancy\Concerns\HasATenantsOption; use Stancl\Tenancy\Concerns\HasTenantOptions;
use Stancl\Tenancy\Events\DatabaseSeeded; use Stancl\Tenancy\Events\DatabaseSeeded;
use Stancl\Tenancy\Events\SeedingDatabase; use Stancl\Tenancy\Events\SeedingDatabase;
class Seed extends SeedCommand class Seed extends SeedCommand
{ {
use HasATenantsOption; use HasTenantOptions;
/**
* The console command description.
*
* @var string
*/
protected $description = 'Seed tenant database(s).'; protected $description = 'Seed tenant database(s).';
protected $name = 'tenants:seed'; protected $name = 'tenants:seed';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct(ConnectionResolverInterface $resolver) public function __construct(ConnectionResolverInterface $resolver)
{ {
parent::__construct($resolver); parent::__construct($resolver);
} }
/** public function handle(): int
* Execute the console command.
*/
public function handle()
{ {
foreach (config('tenancy.seeder_parameters') as $parameter => $value) { foreach (config('tenancy.seeder_parameters') as $parameter => $value) {
if (! $this->input->hasParameterOption($parameter)) { if (! $this->input->hasParameterOption($parameter)) {
@ -45,11 +32,11 @@ class Seed extends SeedCommand
} }
if (! $this->confirmToProceed()) { if (! $this->confirmToProceed()) {
return; return 1;
} }
tenancy()->runForMultiple($this->option('tenants'), function ($tenant) { tenancy()->runForMultiple($this->getTenants(), function ($tenant) {
$this->line("Tenant: {$tenant->getTenantKey()}"); $this->components->info("Tenant: {$tenant->getTenantKey()}");
event(new SeedingDatabase($tenant)); event(new SeedingDatabase($tenant));
@ -58,5 +45,7 @@ class Seed extends SeedCommand
event(new DatabaseSeeded($tenant)); event(new DatabaseSeeded($tenant));
}); });
return 0;
} }
} }

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Commands; namespace Stancl\Tenancy\Commands;
use Illuminate\Console\Command;
use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\ConnectionResolverInterface; use Illuminate\Database\ConnectionResolverInterface;
use Illuminate\Database\Console\DumpCommand; use Illuminate\Database\Console\DumpCommand;
@ -23,13 +22,10 @@ class TenantDump extends DumpCommand
public function handle(ConnectionResolverInterface $connections, Dispatcher $dispatcher): int public function handle(ConnectionResolverInterface $connections, Dispatcher $dispatcher): int
{ {
$this->tenant()->run(fn () => parent::handle($connections, $dispatcher)); if (is_null($this->option('path'))) {
$this->input->setOption('path', database_path('schema/tenant-schema.dump'));
return Command::SUCCESS;
} }
public function tenant(): Tenant
{
$tenant = $this->option('tenant') $tenant = $this->option('tenant')
?? tenant() ?? tenant()
?? $this->ask('What tenant do you want to dump the schema for?') ?? $this->ask('What tenant do you want to dump the schema for?')
@ -39,9 +35,15 @@ class TenantDump extends DumpCommand
$tenant = tenancy()->find($tenant); $tenant = tenancy()->find($tenant);
} }
throw_if(! $tenant, 'Could not identify the tenant to use for dumping the schema.'); if ($tenant === null) {
$this->components->error('Could not find tenant to use for dumping the schema.');
return $tenant; return 1;
}
parent::handle($connections, $dispatcher);
return 0;
} }
protected function getOptions(): array protected function getOptions(): array

View file

@ -5,39 +5,45 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Commands; namespace Stancl\Tenancy\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
class TenantList extends Command class TenantList extends Command
{ {
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'tenants:list'; protected $signature = 'tenants:list';
/**
* The console command description.
*
* @var string
*/
protected $description = 'List tenants.'; protected $description = 'List tenants.';
/** public function handle(): int
* Execute the console command.
*/
public function handle()
{ {
$this->info('Listing all tenants.'); $tenants = tenancy()->query()->cursor();
tenancy()
->query() $this->components->info("Listing {$tenants->count()} tenants.");
->cursor()
->each(function (Tenant $tenant) { foreach ($tenants as $tenant) {
if ($tenant->domains) { /** @var Model&Tenant $tenant */
$this->line("[Tenant] {$tenant->getTenantKeyName()}: {$tenant->getTenantKey()} @ " . implode('; ', $tenant->domains->pluck('domain')->toArray() ?? [])); $this->components->twoColumnDetail($this->tenantCLI($tenant), $this->domainsCLI($tenant->domains));
} else {
$this->line("[Tenant] {$tenant->getTenantKeyName()}: {$tenant->getTenantKey()}");
} }
});
$this->newLine();
return 0;
}
/** Generate the visual CLI output for the tenant name. */
protected function tenantCLI(Model&Tenant $tenant): string
{
return "<fg=yellow>{$tenant->getTenantKeyName()}: {$tenant->getTenantKey()}</>";
}
/** Generate the visual CLI output for the domain names. */
protected function domainsCLI(?Collection $domains): ?string
{
if (! $domains) {
return null;
}
return "<fg=blue;options=bold>{$domains->pluck('domain')->implode(' / ')}</>";
} }
} }

29
src/Commands/Up.php Normal file
View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Commands;
use Illuminate\Console\Command;
use Stancl\Tenancy\Concerns\HasTenantOptions;
class Up extends Command
{
use HasTenantOptions;
protected $signature = 'tenants:up';
protected $description = 'Put tenants out of maintenance mode.';
public function handle(): int
{
tenancy()->runForMultiple($this->getTenants(), function ($tenant) {
$this->components->info("Tenant: {$tenant->getTenantKey()}");
$tenant->bringUpFromMaintenance();
});
$this->components->info('Tenants are now out of maintenance mode.');
return 0;
}
}

View file

@ -6,12 +6,12 @@ namespace Stancl\Tenancy\Concerns;
trait DealsWithMigrations trait DealsWithMigrations
{ {
protected function getMigrationPaths() protected function getMigrationPaths(): array
{ {
if ($this->input->hasOption('path') && $this->input->getOption('path')) { if ($this->input->hasOption('path') && $this->input->getOption('path')) {
return parent::getMigrationPaths(); return parent::getMigrationPaths();
} }
return database_path('migrations/tenant'); return [database_path('migrations/tenant')];
} }
} }

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Concerns;
use Illuminate\Support\Collection;
use Stancl\Tenancy\Contracts\Tenant;
trait DealsWithTenantSymlinks
{
/**
* Get all possible tenant symlinks, existing or not (array of ['public path' => 'storage path']).
*
* Tenants can have a symlink for each disk registered in the tenancy.filesystem.url_override config.
*
* This is used for creating all possible tenant symlinks and removing all existing tenant symlinks.
*
* @return Collection<string, string>
*/
protected static function possibleTenantSymlinks(Tenant $tenant): Collection
{
$diskUrls = config('tenancy.filesystem.url_override');
$disks = config('tenancy.filesystem.root_override');
$suffixBase = config('tenancy.filesystem.suffix_base');
$tenantKey = $tenant->getTenantKey();
/** @var Collection<array<string, string>> $symlinks */
$symlinks = collect([]);
foreach ($diskUrls as $disk => $publicPath) {
$storagePath = str_replace('%storage_path%', $suffixBase . $tenantKey, $disks[$disk]);
$publicPath = str_replace('%tenant_id%', (string) $tenantKey, $publicPath);
tenancy()->central(function () use ($symlinks, $publicPath, $storagePath) {
$symlinks->push([public_path($publicPath) => storage_path($storagePath)]);
});
}
return $symlinks->mapWithKeys(fn ($item) => $item); // [[a => b], [c => d]] -> [a => b, c => d]
}
/** Determine if the provided path is an existing symlink. */
protected static function symlinkExists(string $link): bool
{
return file_exists($link) && is_link($link);
}
}

View file

@ -5,14 +5,19 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Concerns; namespace Stancl\Tenancy\Concerns;
use Illuminate\Support\LazyCollection; use Illuminate\Support\LazyCollection;
use Stancl\Tenancy\Database\Concerns\PendingScope;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
trait HasATenantsOption /**
* Adds 'tenants' and 'with-pending' options.
*/
trait HasTenantOptions
{ {
protected function getOptions() protected function getOptions()
{ {
return array_merge([ return array_merge([
['tenants', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_OPTIONAL, '', null], ['tenants', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_OPTIONAL, '', null],
['with-pending', null, InputOption::VALUE_NONE, 'include pending tenants in query'],
], parent::getOptions()); ], parent::getOptions());
} }
@ -23,6 +28,9 @@ trait HasATenantsOption
->when($this->option('tenants'), function ($query) { ->when($this->option('tenants'), function ($query) {
$query->whereIn(tenancy()->model()->getTenantKeyName(), $this->option('tenants')); $query->whereIn(tenancy()->model()->getTenantKeyName(), $this->option('tenants'));
}) })
->when(tenancy()->model()::hasGlobalScope(PendingScope::class), function ($query) {
$query->withPending(config('tenancy.pending.include_in_queries') ?: $this->option('with-pending'));
})
->cursor(); ->cursor();
} }

View file

@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Contracts; namespace Stancl\Tenancy\Contracts;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** /**
* @property-read Tenant $tenant * @property-read Tenant $tenant
* *
@ -15,5 +17,5 @@ namespace Stancl\Tenancy\Contracts;
*/ */
interface Domain interface Domain
{ {
public function tenant(); public function tenant(): BelongsTo;
} }

View file

@ -15,4 +15,7 @@ interface Syncable
public function getSyncedAttributeNames(): array; public function getSyncedAttributeNames(): array;
public function triggerSyncEvent(): void; public function triggerSyncEvent(): void;
/** Get the attributes used for creating the *other* model (i.e. tenant if this is the central one, and central if this is the tenant one). */
public function getSyncedCreationAttributes(): array|null; // todo come up with a better name
} }

View file

@ -9,7 +9,7 @@ namespace Stancl\Tenancy\Contracts;
*/ */
interface TenancyBootstrapper interface TenancyBootstrapper
{ {
public function bootstrap(Tenant $tenant); public function bootstrap(Tenant $tenant): void;
public function revert(); public function revert(): void;
} }

View file

@ -8,6 +8,7 @@ abstract class TenantCannotBeCreatedException extends \Exception
{ {
abstract public function reason(): string; abstract public function reason(): string;
/** @var string */
protected $message; protected $message;
public function __construct() public function __construct()

View file

@ -5,9 +5,8 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Contracts; namespace Stancl\Tenancy\Contracts;
use Exception; use Exception;
use Facade\IgnitionContracts\BaseSolution; use Spatie\Ignition\Contracts\BaseSolution;
use Facade\IgnitionContracts\ProvidesSolution; use Spatie\Ignition\Contracts\ProvidesSolution;
use Facade\IgnitionContracts\Solution;
abstract class TenantCouldNotBeIdentifiedException extends Exception implements ProvidesSolution abstract class TenantCouldNotBeIdentifiedException extends Exception implements ProvidesSolution
{ {
@ -42,7 +41,7 @@ abstract class TenantCouldNotBeIdentifiedException extends Exception implements
} }
/** Get the Ignition description. */ /** Get the Ignition description. */
public function getSolution(): Solution public function getSolution(): BaseSolution
{ {
return BaseSolution::create($this->solutionTitle) return BaseSolution::create($this->solutionTitle)
->setSolutionDescription($this->solutionDescription) ->setSolutionDescription($this->solutionDescription)

View file

@ -4,10 +4,12 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Contracts; namespace Stancl\Tenancy\Contracts;
use Illuminate\Database\Eloquent\Model;
interface UniqueIdentifierGenerator interface UniqueIdentifierGenerator
{ {
/** /**
* Generate a unique identifier. * Generate a unique identifier for a model.
*/ */
public static function generate($resource): string; public static function generate(Model $model): string;
} }

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Controllers;
use Illuminate\Routing\Controller;
use Stancl\Tenancy\Tenancy;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Throwable;
class TenantAssetController extends Controller // todo@docs this was renamed from TenantAssetsController
{
public function __construct()
{
$this->middleware(Tenancy::defaultMiddleware());
}
/**
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
*/
public function asset(string $path = null): BinaryFileResponse
{
abort_if($path === null, 404);
try {
return response()->file(storage_path("app/public/$path"));
} catch (Throwable) {
abort(404);
}
}
}

View file

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Controllers;
use Closure;
use Illuminate\Routing\Controller;
use Throwable;
class TenantAssetsController extends Controller
{
public static string|array|Closure $tenancyMiddleware = Stancl\Tenancy\Middleware\InitializeTenancyByDomain::class;
public function __construct()
{
$this->middleware(static::$tenancyMiddleware);
}
public function asset(string $path = null)
{
abort_if($path === null, 404);
try {
return response()->file(storage_path("app/public/$path"));
} catch (Throwable) {
abort(404);
}
}
}

View file

@ -12,11 +12,14 @@ use Stancl\Tenancy\Database\TenantScope;
*/ */
trait BelongsToTenant trait BelongsToTenant
{ {
public static $tenantIdColumn = 'tenant_id';
public function tenant() public function tenant()
{ {
return $this->belongsTo(config('tenancy.tenant_model'), BelongsToTenant::$tenantIdColumn); return $this->belongsTo(config('tenancy.tenant_model'), static::tenantIdColumn());
}
public static function tenantIdColumn(): string
{
return config('tenancy.single_db.tenant_id_column');
} }
public static function bootBelongsToTenant(): void public static function bootBelongsToTenant(): void
@ -24,9 +27,9 @@ trait BelongsToTenant
static::addGlobalScope(new TenantScope); static::addGlobalScope(new TenantScope);
static::creating(function ($model) { static::creating(function ($model) {
if (! $model->getAttribute(BelongsToTenant::$tenantIdColumn) && ! $model->relationLoaded('tenant')) { if (! $model->getAttribute(static::tenantIdColumn()) && ! $model->relationLoaded('tenant')) {
if (tenancy()->initialized) { if (tenancy()->initialized) {
$model->setAttribute(BelongsToTenant::$tenantIdColumn, tenant()->getTenantKey()); $model->setAttribute(static::tenantIdColumn(), tenant()->getTenantKey());
$model->setRelation('tenant', tenant()); $model->setRelation('tenant', tenant());
} }
} }

View file

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns;
use Stancl\VirtualColumn\VirtualColumn;
/**
* Extends VirtualColumn for backwards compatibility. This trait will be removed in v4.
*/
trait HasDataColumn
{
use VirtualColumn;
}

View file

@ -10,6 +10,8 @@ use Stancl\Tenancy\Contracts\Domain;
/** /**
* @property-read Domain[]|\Illuminate\Database\Eloquent\Collection $domains * @property-read Domain[]|\Illuminate\Database\Eloquent\Collection $domains
* @mixin \Illuminate\Database\Eloquent\Model
* @mixin \Stancl\Tenancy\Contracts\Tenant
*/ */
trait HasDomains trait HasDomains
{ {

View file

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns;
use Carbon\Carbon;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Events\CreatingPendingTenant;
use Stancl\Tenancy\Events\PendingTenantCreated;
use Stancl\Tenancy\Events\PendingTenantPulled;
use Stancl\Tenancy\Events\PullingPendingTenant;
// todo consider adding a method that sets pending_since to null — to flag tenants as not-pending
/**
* @property Carbon $pending_since
*
* @method static static|\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder withPending(bool $withPending = true)
* @method static static|\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder onlyPending()
* @method static static|\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder withoutPending()
*/
trait HasPending
{
/**
* Boot the has pending trait for a model.
*
* @return void
*/
public static function bootHasPending()
{
static::addGlobalScope(new PendingScope());
}
/**
* Initialize the has pending trait for an instance.
*
* @return void
*/
public function initializeHasPending()
{
$this->casts['pending_since'] = 'timestamp';
}
/**
* Determine if the model instance is in a pending state.
*
* @return bool
*/
public function pending()
{
return ! is_null($this->pending_since);
}
/** Create a pending tenant. */
public static function createPending($attributes = []): Tenant
{
$tenant = static::create($attributes);
event(new CreatingPendingTenant($tenant));
// Update the pending_since value only after the tenant is created so it's
// Not marked as pending until finishing running the migrations, seeders, etc.
$tenant->update([
'pending_since' => now()->timestamp,
]);
event(new PendingTenantCreated($tenant));
return $tenant;
}
/** Pull a pending tenant. */
public static function pullPending(): Tenant
{
return static::pullPendingFromPool(true);
}
/** Try to pull a tenant from the pool of pending tenants. */
public static function pullPendingFromPool(bool $firstOrCreate = false): ?Tenant
{
if (! static::onlyPending()->exists()) {
if (! $firstOrCreate) {
return null;
}
static::createPending();
}
// A pending tenant is surely available at this point
$tenant = static::onlyPending()->first();
event(new PullingPendingTenant($tenant));
$tenant->update([
'pending_since' => null,
]);
event(new PendingTenantPulled($tenant));
return $tenant;
}
}

View file

@ -11,11 +11,11 @@ trait HasScopedValidationRules
{ {
public function unique($table, $column = 'NULL') public function unique($table, $column = 'NULL')
{ {
return (new Unique($table, $column))->where(BelongsToTenant::$tenantIdColumn, $this->getTenantKey()); return (new Unique($table, $column))->where(BelongsToTenant::tenantIdColumn(), $this->getTenantKey());
} }
public function exists($table, $column = 'NULL') public function exists($table, $column = 'NULL')
{ {
return (new Exists($table, $column))->where(BelongsToTenant::$tenantIdColumn, $this->getTenantKey()); return (new Exists($table, $column))->where(BelongsToTenant::tenantIdColumn(), $this->getTenantKey());
} }
} }

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns;
/**
* @mixin \Stancl\Tenancy\Contracts\Tenant
*/
trait InitializationHelpers
{
public function enter(): void
{
tenancy()->initialize($this);
}
public function leave(): void
{
tenancy()->end();
}
}

View file

@ -5,21 +5,15 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns; namespace Stancl\Tenancy\Database\Concerns;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Resolvers;
use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver; use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver;
use Stancl\Tenancy\Tenancy;
trait InvalidatesResolverCache trait InvalidatesResolverCache
{ {
public static $resolvers = [
Resolvers\DomainTenantResolver::class,
Resolvers\PathTenantResolver::class,
Resolvers\RequestDataTenantResolver::class,
];
public static function bootInvalidatesResolverCache(): void public static function bootInvalidatesResolverCache(): void
{ {
static::saved(function (Tenant $tenant) { static::saved(function (Tenant $tenant) {
foreach (static::$resolvers as $resolver) { foreach (Tenancy::cachedResolvers() as $resolver) {
/** @var CachedTenantResolver $resolver */ /** @var CachedTenantResolver $resolver */
$resolver = app($resolver); $resolver = app($resolver);

View file

@ -5,24 +5,18 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns; namespace Stancl\Tenancy\Database\Concerns;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Resolvers;
use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver; use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver;
use Stancl\Tenancy\Tenancy;
/** /**
* Meant to be used on models that belong to tenants. * Meant to be used on models that belong to tenants.
*/ */
trait InvalidatesTenantsResolverCache trait InvalidatesTenantsResolverCache
{ {
public static $resolvers = [
Resolvers\DomainTenantResolver::class,
Resolvers\PathTenantResolver::class,
Resolvers\RequestDataTenantResolver::class,
];
public static function bootInvalidatesTenantsResolverCache(): void public static function bootInvalidatesTenantsResolverCache(): void
{ {
static::saved(function (Model $model) { static::saved(function (Model $model) {
foreach (static::$resolvers as $resolver) { foreach (Tenancy::cachedResolvers() as $resolver) {
/** @var CachedTenantResolver $resolver */ /** @var CachedTenantResolver $resolver */
$resolver = app($resolver); $resolver = app($resolver);

View file

@ -4,17 +4,34 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns; namespace Stancl\Tenancy\Database\Concerns;
use Carbon\Carbon; use Stancl\Tenancy\Events\TenantMaintenanceModeDisabled;
use Stancl\Tenancy\Events\TenantMaintenanceModeEnabled;
/**
* @mixin \Illuminate\Database\Eloquent\Model
*/
trait MaintenanceMode trait MaintenanceMode
{ {
public function putDownForMaintenance($data = []) public function putDownForMaintenance($data = []): void
{ {
$this->update(['maintenance_mode' => [ $this->update([
'time' => $data['time'] ?? Carbon::now()->getTimestamp(), 'maintenance_mode' => [
'message' => $data['message'] ?? null, 'except' => $data['except'] ?? null,
'redirect' => $data['redirect'] ?? null,
'retry' => $data['retry'] ?? null, 'retry' => $data['retry'] ?? null,
'allowed' => $data['allowed'] ?? [], 'refresh' => $data['refresh'] ?? null,
]]); 'secret' => $data['secret'] ?? null,
'status' => $data['status'] ?? 503,
],
]);
event(new TenantMaintenanceModeEnabled($this));
}
public function bringUpFromMaintenance(): void
{
$this->update(['maintenance_mode' => null]);
event(new TenantMaintenanceModeDisabled($this));
} }
} }

View file

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class PendingScope implements Scope
{
/**
* All of the extensions to be added to the builder.
*
* @var string[]
*/
protected $extensions = ['WithPending', 'WithoutPending', 'OnlyPending'];
/**
* Apply the scope to a given Eloquent query builder.
*
* @return void
*/
public function apply(Builder $builder, Model $model)
{
$builder->when(! config('tenancy.pending.include_in_queries'), function (Builder $builder) {
$builder->whereNull($builder->getModel()->getColumnForQuery('pending_since'));
});
}
/**
* Extend the query builder with the needed functions.
*
* @return void
*/
public function extend(Builder $builder)
{
foreach ($this->extensions as $extension) {
$this->{"add{$extension}"}($builder);
}
}
/**
* Add the with-pending extension to the builder.
*
* @return void
*/
protected function addWithPending(Builder $builder)
{
$builder->macro('withPending', function (Builder $builder, $withPending = true) {
if (! $withPending) {
return $builder->withoutPending();
}
return $builder->withoutGlobalScope($this);
});
}
/**
* Add the without-pending extension to the builder.
*
* @return void
*/
protected function addWithoutPending(Builder $builder)
{
$builder->macro('withoutPending', function (Builder $builder) {
$builder->withoutGlobalScope($this)
->whereNull($builder->getModel()->getColumnForQuery('pending_since'))
->orWhereNull($builder->getModel()->getDataColumn());
return $builder;
});
}
/**
* Add the only-pending extension to the builder.
*
* @return void
*/
protected function addOnlyPending(Builder $builder)
{
$builder->macro('onlyPending', function (Builder $builder) {
$builder->withoutGlobalScope($this)->whereNotNull($builder->getModel()->getColumnForQuery('pending_since'));
return $builder;
});
}
}

View file

@ -32,4 +32,9 @@ trait ResourceSyncing
/** @var Syncable $this */ /** @var Syncable $this */
event(new SyncedResourceSaved($this, tenant())); event(new SyncedResourceSaved($this, tenant()));
} }
public function getSyncedCreationAttributes(): array|null
{
return null;
}
} }

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Database\Contracts;
use Illuminate\Database\Connection;
use Stancl\Tenancy\Database\Exceptions\NoConnectionSetException;
/**
* Tenant database manager with a persistent connection.
*/
interface StatefulTenantDatabaseManager extends TenantDatabaseManager
{
/** Get the DB connection used by the tenant database manager. */
public function database(): Connection; // todo rename to connection()
/**
* Set the DB connection that should be used by the tenant database manager.
*
* @throws NoConnectionSetException
*/
public function setConnection(string $connection): void;
}

View file

@ -4,8 +4,6 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\Contracts; namespace Stancl\Tenancy\Database\Contracts;
use Stancl\Tenancy\Database\Exceptions\NoConnectionSetException;
interface TenantDatabaseManager interface TenantDatabaseManager
{ {
/** Create a database. */ /** Create a database. */
@ -19,11 +17,4 @@ interface TenantDatabaseManager
/** Construct a DB connection config array. */ /** Construct a DB connection config array. */
public function makeConnectionConfig(array $baseConfig, string $databaseName): array; public function makeConnectionConfig(array $baseConfig, string $databaseName): array;
/**
* Set the DB connection that should be used by the tenant database manager.
*
* @throws NoConnectionSetException
*/
public function setConnection(string $connection): void;
} }

View file

@ -9,5 +9,15 @@ use Stancl\Tenancy\Database\DatabaseConfig;
interface TenantWithDatabase extends Tenant interface TenantWithDatabase extends Tenant
{ {
/** Get the tenant's database config. */
public function database(): DatabaseConfig; public function database(): DatabaseConfig;
/** Get the internal prefix. */
public static function internalPrefix(): string;
/** Get an internal key. */
public function getInternal(string $key): mixed;
/** Set internal key. */
public function setInternal(string $key, mixed $value): static;
} }

View file

@ -5,10 +5,14 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database; namespace Stancl\Tenancy\Database;
use Closure; use Closure;
use Illuminate\Database;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase as Tenant; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase as Tenant;
use Stancl\Tenancy\Database\Exceptions\DatabaseManagerNotRegisteredException;
use Stancl\Tenancy\Database\Exceptions\NoConnectionSetException;
class DatabaseConfig class DatabaseConfig
{ {
@ -26,20 +30,20 @@ class DatabaseConfig
public static function __constructStatic(): void public static function __constructStatic(): void
{ {
static::$usernameGenerator = static::$usernameGenerator ?? function (Tenant $tenant) { static::$usernameGenerator = static::$usernameGenerator ?? function (Model&Tenant $tenant) {
return Str::random(16); return Str::random(16);
}; };
static::$passwordGenerator = static::$passwordGenerator ?? function (Tenant $tenant) { static::$passwordGenerator = static::$passwordGenerator ?? function (Model&Tenant $tenant) {
return Hash::make(Str::random(32)); return Hash::make(Str::random(32));
}; };
static::$databaseNameGenerator = static::$databaseNameGenerator ?? function (Tenant $tenant) { static::$databaseNameGenerator = static::$databaseNameGenerator ?? function (Model&Tenant $tenant) {
return config('tenancy.database.prefix') . $tenant->getTenantKey() . config('tenancy.database.suffix'); return config('tenancy.database.prefix') . $tenant->getTenantKey() . config('tenancy.database.suffix');
}; };
} }
public function __construct(Tenant $tenant) public function __construct(Model&Tenant $tenant)
{ {
static::__constructStatic(); static::__constructStatic();
@ -61,7 +65,7 @@ class DatabaseConfig
static::$passwordGenerator = $passwordGenerator; static::$passwordGenerator = $passwordGenerator;
} }
public function getName(): ?string public function getName(): string
{ {
return $this->tenant->getInternal('db_name') ?? (static::$databaseNameGenerator)($this->tenant); return $this->tenant->getInternal('db_name') ?? (static::$databaseNameGenerator)($this->tenant);
} }
@ -81,9 +85,9 @@ class DatabaseConfig
*/ */
public function makeCredentials(): void public function makeCredentials(): void
{ {
$this->tenant->setInternal('db_name', $this->getName() ?? (static::$databaseNameGenerator)($this->tenant)); $this->tenant->setInternal('db_name', $this->getName());
if ($this->manager() instanceof Contracts\ManagesDatabaseUsers) { if ($this->connectionDriverManager($this->getTemplateConnectionName()) instanceof Contracts\ManagesDatabaseUsers) {
$this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($this->tenant)); $this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($this->tenant));
$this->tenant->setInternal('db_password', $this->getPassword() ?? (static::$passwordGenerator)($this->tenant)); $this->tenant->setInternal('db_password', $this->getPassword() ?? (static::$passwordGenerator)($this->tenant));
} }
@ -100,6 +104,11 @@ class DatabaseConfig
?? config('tenancy.database.central_connection'); ?? config('tenancy.database.central_connection');
} }
public function getTenantHostConnectionName(): string
{
return config('tenancy.database.tenant_host_connection_name', 'tenant_host_connection');
}
/** /**
* Tenant's own database connection config. * Tenant's own database connection config.
*/ */
@ -114,6 +123,40 @@ class DatabaseConfig
); );
} }
/**
* Tenant's host database connection config.
*/
public function hostConnection(): array
{
$config = $this->tenantConfig();
$template = $this->getTemplateConnectionName();
$templateConnection = config("database.connections.{$template}");
if ($this->connectionDriverManager($template) instanceof Contracts\ManagesDatabaseUsers) {
// We're removing the username and password because user with these credentials is not created yet
// If you need to provide username and password when using PermissionControlledMySQLDatabaseManager,
// consider creating a new connection and use it as `tenancy_db_connection` tenant config key
unset($config['username'], $config['password']);
}
if (! $config) {
return $templateConnection;
}
return array_replace($templateConnection, $config);
}
/**
* Purge host database connection.
*
* It's possible database has previous tenant connection.
* This will clean up the previous connection before creating it for the current tenant.
*/
public function purgeHostConnection(): void
{
DB::purge($this->getTenantHostConnectionName());
}
/** /**
* Additional config for the database connection, specific to this tenant. * Additional config for the database connection, specific to this tenant.
*/ */
@ -140,10 +183,37 @@ class DatabaseConfig
}, []); }, []);
} }
/** Get the TenantDatabaseManager for this tenant's connection. */ /** Get the TenantDatabaseManager for this tenant's connection.
*
* @throws NoConnectionSetException|DatabaseManagerNotRegisteredException
*/
public function manager(): Contracts\TenantDatabaseManager public function manager(): Contracts\TenantDatabaseManager
{ {
$driver = config("database.connections.{$this->getTemplateConnectionName()}.driver"); // Laravel caches the previous PDO connection, so we purge it to be able to change the connection details
$this->purgeHostConnection(); // todo come up with a better name
// Create the tenant host connection config
$tenantHostConnectionName = $this->getTenantHostConnectionName();
config(["database.connections.{$tenantHostConnectionName}" => $this->hostConnection()]);
$manager = $this->connectionDriverManager($tenantHostConnectionName);
if ($manager instanceof Contracts\StatefulTenantDatabaseManager) {
$manager->setConnection($tenantHostConnectionName);
}
return $manager;
}
/**
* todo come up with a better name
* Get database manager class from the given connection config's driver.
*
* @throws DatabaseManagerNotRegisteredException
*/
protected function connectionDriverManager(string $connectionName): Contracts\TenantDatabaseManager
{
$driver = config("database.connections.{$connectionName}.driver");
$databaseManagers = config('tenancy.database.managers'); $databaseManagers = config('tenancy.database.managers');
@ -151,11 +221,6 @@ class DatabaseConfig
throw new Exceptions\DatabaseManagerNotRegisteredException($driver); throw new Exceptions\DatabaseManagerNotRegisteredException($driver);
} }
/** @var Contracts\TenantDatabaseManager $databaseManager */ return app($databaseManagers[$driver]);
$databaseManager = app($databaseManagers[$driver]);
$databaseManager->setConnection($this->getTemplateConnectionName());
return $databaseManager;
} }
} }

View file

@ -15,25 +15,14 @@ use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
*/ */
class DatabaseManager class DatabaseManager
{ {
/** @var Application */ public function __construct(
protected $app; protected Application $app,
protected BaseDatabaseManager $database,
/** @var BaseDatabaseManager */ protected Repository $config,
protected $database; ) {
/** @var Repository */
protected $config;
public function __construct(Application $app, BaseDatabaseManager $database, Repository $config)
{
$this->app = $app;
$this->database = $database;
$this->config = $config;
} }
/** /** Connect to a tenant's database. */
* Connect to a tenant's database.
*/
public function connectToTenant(TenantWithDatabase $tenant): void public function connectToTenant(TenantWithDatabase $tenant): void
{ {
$this->purgeTenantConnection(); $this->purgeTenantConnection();
@ -41,35 +30,27 @@ class DatabaseManager
$this->setDefaultConnection('tenant'); $this->setDefaultConnection('tenant');
} }
/** /** Reconnect to the default non-tenant connection. */
* Reconnect to the default non-tenant connection.
*/
public function reconnectToCentral(): void public function reconnectToCentral(): void
{ {
$this->purgeTenantConnection(); $this->purgeTenantConnection();
$this->setDefaultConnection($this->config->get('tenancy.database.central_connection')); $this->setDefaultConnection($this->config->get('tenancy.database.central_connection'));
} }
/** /** Change the default database connection config. */
* Change the default database connection config.
*/
public function setDefaultConnection(string $connection): void public function setDefaultConnection(string $connection): void
{ {
$this->config['database.default'] = $connection; $this->config['database.default'] = $connection;
$this->database->setDefaultConnection($connection); $this->database->setDefaultConnection($connection);
} }
/** /** Create the tenant database connection. */
* Create the tenant database connection.
*/
public function createTenantConnection(TenantWithDatabase $tenant): void public function createTenantConnection(TenantWithDatabase $tenant): void
{ {
$this->config['database.connections.tenant'] = $tenant->database()->connection(); $this->config['database.connections.tenant'] = $tenant->database()->connection();
} }
/** /** Purge the tenant database connection. */
* Purge the tenant database connection.
*/
public function purgeTenantConnection(): void public function purgeTenantConnection(): void
{ {
if (array_key_exists('tenant', $this->database->getConnections())) { if (array_key_exists('tenant', $this->database->getConnections())) {
@ -83,8 +64,8 @@ class DatabaseManager
* Check if a tenant can be created. * Check if a tenant can be created.
* *
* @throws TenantCannotBeCreatedException * @throws TenantCannotBeCreatedException
* @throws DatabaseManagerNotRegisteredException * @throws Exceptions\DatabaseManagerNotRegisteredException
* @throws TenantDatabaseAlreadyExistsException * @throws Exceptions\TenantDatabaseAlreadyExistsException
*/ */
public function ensureTenantCanBeCreated(TenantWithDatabase $tenant): void public function ensureTenantCanBeCreated(TenantWithDatabase $tenant): void
{ {
@ -94,8 +75,13 @@ class DatabaseManager
throw new Exceptions\TenantDatabaseAlreadyExistsException($database); throw new Exceptions\TenantDatabaseAlreadyExistsException($database);
} }
if ($manager instanceof Contracts\ManagesDatabaseUsers && $manager->userExists($username = $tenant->database()->getUsername())) { if ($manager instanceof Contracts\ManagesDatabaseUsers) {
/** @var string $username */
$username = $tenant->database()->getUsername();
if ($manager->userExists($username)) {
throw new Exceptions\TenantDatabaseUserAlreadyExistsException($username); throw new Exceptions\TenantDatabaseUserAlreadyExistsException($username);
} }
} }
} }
}

View file

@ -5,9 +5,12 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\Models; namespace Stancl\Tenancy\Database\Models;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Stancl\Tenancy\Database\Concerns\CentralConnection; use Stancl\Tenancy\Database\Concerns\CentralConnection;
use Stancl\Tenancy\Exceptions\StatefulGuardRequiredException;
/** /**
* @property string $token * @property string $token
@ -38,9 +41,15 @@ class ImpersonationToken extends Model
public static function booted(): void public static function booted(): void
{ {
static::creating(function ($model) { static::creating(function ($model) {
$authGuard = $model->auth_guard ?? config('auth.defaults.guard');
if (! Auth::guard($authGuard) instanceof StatefulGuard) {
throw new StatefulGuardRequiredException($authGuard);
}
$model->created_at = $model->created_at ?? $model->freshTimestamp(); $model->created_at = $model->created_at ?? $model->freshTimestamp();
$model->token = $model->token ?? Str::random(128); $model->token = $model->token ?? Str::random(128);
$model->auth_guard = $model->auth_guard ?? config('auth.defaults.guard'); $model->auth_guard = $authGuard;
}); });
} }
} }

View file

@ -10,6 +10,8 @@ use Stancl\Tenancy\Contracts;
use Stancl\Tenancy\Database\Concerns; use Stancl\Tenancy\Database\Concerns;
use Stancl\Tenancy\Database\TenantCollection; use Stancl\Tenancy\Database\TenantCollection;
use Stancl\Tenancy\Events; use Stancl\Tenancy\Events;
use Stancl\Tenancy\Exceptions\TenancyNotInitializedException;
use Stancl\VirtualColumn\VirtualColumn;
/** /**
* @property string|int $id * @property string|int $id
@ -21,17 +23,17 @@ use Stancl\Tenancy\Events;
*/ */
class Tenant extends Model implements Contracts\Tenant class Tenant extends Model implements Contracts\Tenant
{ {
use Concerns\CentralConnection, use VirtualColumn,
Concerns\CentralConnection,
Concerns\GeneratesIds, Concerns\GeneratesIds,
Concerns\HasDataColumn,
Concerns\HasInternalKeys, Concerns\HasInternalKeys,
Concerns\TenantRun, Concerns\TenantRun,
Concerns\HasPending,
Concerns\InitializationHelpers,
Concerns\InvalidatesResolverCache; Concerns\InvalidatesResolverCache;
protected $table = 'tenants'; protected $table = 'tenants';
protected $primaryKey = 'id'; protected $primaryKey = 'id';
protected $guarded = []; protected $guarded = [];
public function getTenantKeyName(): string public function getTenantKeyName(): string
@ -44,6 +46,22 @@ class Tenant extends Model implements Contracts\Tenant
return $this->getAttribute($this->getTenantKeyName()); return $this->getAttribute($this->getTenantKeyName());
} }
/** Get the current tenant. */
public static function current(): static|null
{
return tenant();
}
/**
* Get the current tenant or throw an exception if tenancy is not initialized.
*
* @throws TenancyNotInitializedException
*/
public static function currentOrFail(): static
{
return static::current() ?? throw new TenancyNotInitializedException;
}
public function newCollection(array $models = []): TenantCollection public function newCollection(array $models = []): TenantCollection
{ {
return new TenantCollection($models); return new TenantCollection($models);

View file

@ -19,7 +19,7 @@ class ParentModelScope implements Scope
$builder->whereHas($builder->getModel()->getRelationshipToPrimaryModel()); $builder->whereHas($builder->getModel()->getRelationshipToPrimaryModel());
} }
public function extend(Builder $builder) public function extend(Builder $builder): void
{ {
$builder->macro('withoutParentModel', function (Builder $builder) { $builder->macro('withoutParentModel', function (Builder $builder) {
return $builder->withoutGlobalScope($this); return $builder->withoutGlobalScope($this);

View file

@ -12,7 +12,8 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl
{ {
use CreatesDatabaseUsers; use CreatesDatabaseUsers;
public static $grants = [ /** @var string[] */
public static array $grants = [
'ALTER', 'ALTER ROUTINE', 'CREATE', 'CREATE ROUTINE', 'CREATE TEMPORARY TABLES', 'CREATE VIEW', 'ALTER', 'ALTER ROUTINE', 'CREATE', 'CREATE ROUTINE', 'CREATE TEMPORARY TABLES', 'CREATE VIEW',
'DELETE', 'DROP', 'EVENT', 'EXECUTE', 'INDEX', 'INSERT', 'LOCK TABLES', 'REFERENCES', 'SELECT', 'DELETE', 'DROP', 'EVENT', 'EXECUTE', 'INDEX', 'INSERT', 'LOCK TABLES', 'REFERENCES', 'SELECT',
'SHOW VIEW', 'TRIGGER', 'UPDATE', 'SHOW VIEW', 'TRIGGER', 'UPDATE',

View file

@ -25,11 +25,7 @@ class PostgreSQLSchemaManager extends TenantDatabaseManager
public function makeConnectionConfig(array $baseConfig, string $databaseName): array public function makeConnectionConfig(array $baseConfig, string $databaseName): array
{ {
if (version_compare(app()->version(), '9.0', '>=')) {
$baseConfig['search_path'] = $databaseName; $baseConfig['search_path'] = $databaseName;
} else {
$baseConfig['schema'] = $databaseName;
}
return $baseConfig; return $baseConfig;
} }

View file

@ -10,10 +10,15 @@ use Throwable;
class SQLiteDatabaseManager implements TenantDatabaseManager class SQLiteDatabaseManager implements TenantDatabaseManager
{ {
/**
* SQLite Database path without ending slash.
*/
public static string|null $path = null;
public function createDatabase(TenantWithDatabase $tenant): bool public function createDatabase(TenantWithDatabase $tenant): bool
{ {
try { try {
return file_put_contents(database_path($tenant->database()->getName()), ''); return (bool) file_put_contents($this->getPath($tenant->database()->getName()), '');
} catch (Throwable) { } catch (Throwable) {
return false; return false;
} }
@ -22,7 +27,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
public function deleteDatabase(TenantWithDatabase $tenant): bool public function deleteDatabase(TenantWithDatabase $tenant): bool
{ {
try { try {
return unlink(database_path($tenant->database()->getName())); return unlink($this->getPath($tenant->database()->getName()));
} catch (Throwable) { } catch (Throwable) {
return false; return false;
} }
@ -30,7 +35,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
public function databaseExists(string $name): bool public function databaseExists(string $name): bool
{ {
return file_exists(database_path($name)); return file_exists($this->getPath($name));
} }
public function makeConnectionConfig(array $baseConfig, string $databaseName): array public function makeConnectionConfig(array $baseConfig, string $databaseName): array
@ -44,4 +49,13 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
{ {
// //
} }
public function getPath(string $name): string
{
if (static::$path) {
return static::$path . DIRECTORY_SEPARATOR . $name;
}
return database_path($name);
}
} }

View file

@ -6,15 +6,15 @@ namespace Stancl\Tenancy\Database\TenantDatabaseManagers;
use Illuminate\Database\Connection; use Illuminate\Database\Connection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Database\Contracts\TenantDatabaseManager as Contract; use Stancl\Tenancy\Database\Contracts\StatefulTenantDatabaseManager;
use Stancl\Tenancy\Database\Exceptions\NoConnectionSetException; use Stancl\Tenancy\Database\Exceptions\NoConnectionSetException;
abstract class TenantDatabaseManager implements Contract // todo better naming? abstract class TenantDatabaseManager implements StatefulTenantDatabaseManager
{ {
/** The database connection to the server. */ /** The database connection to the server. */
protected string $connection; protected string $connection;
protected function database(): Connection public function database(): Connection
{ {
if (! isset($this->connection)) { if (! isset($this->connection)) {
throw new NoConnectionSetException(static::class); throw new NoConnectionSetException(static::class);

View file

@ -17,10 +17,10 @@ class TenantScope implements Scope
return; return;
} }
$builder->where($model->qualifyColumn(BelongsToTenant::$tenantIdColumn), tenant()->getTenantKey()); $builder->where($model->qualifyColumn(BelongsToTenant::tenantIdColumn()), tenant()->getTenantKey());
} }
public function extend(Builder $builder) public function extend(Builder $builder): void
{ {
$builder->macro('withoutTenancy', function (Builder $builder) { $builder->macro('withoutTenancy', function (Builder $builder) {
return $builder->withoutGlobalScope($this); return $builder->withoutGlobalScope($this);

View file

@ -8,11 +8,8 @@ use Stancl\Tenancy\Tenancy;
abstract class TenancyEvent abstract class TenancyEvent
{ {
/** @var Tenancy */ public function __construct(
public $tenancy; public Tenancy $tenancy,
) {
public function __construct(Tenancy $tenancy)
{
$this->tenancy = $tenancy;
} }
} }

View file

@ -7,7 +7,7 @@ namespace Stancl\Tenancy\Events\Contracts;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
abstract class TenantEvent abstract class TenantEvent // todo we could add a feature to JobPipeline that automatically gets data for the send() from here
{ {
use SerializesModels; use SerializesModels;

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Events;
class CreatingPendingTenant extends Contracts\TenantEvent
{
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Events;
class CreatingStorageSymlink extends Contracts\TenantEvent
{
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Events;
class PendingTenantCreated extends Contracts\TenantEvent
{
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Events;
class PendingTenantPulled extends Contracts\TenantEvent
{
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Events;
class PullingPendingTenant extends Contracts\TenantEvent
{
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Events;
class RemovingStorageSymlink extends Contracts\TenantEvent
{
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Events;
class StorageSymlinkCreated extends Contracts\TenantEvent
{
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Events;
class StorageSymlinkRemoved extends Contracts\TenantEvent
{
}

View file

@ -10,14 +10,9 @@ use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
class SyncedResourceSaved class SyncedResourceSaved
{ {
public Syncable&Model $model; public function __construct(
public Syncable&Model $model,
/** @var (TenantWithDatabase&Model)|null */ public TenantWithDatabase|null $tenant,
public TenantWithDatabase|null $tenant; ) {
public function __construct(Syncable $model, TenantWithDatabase|null $tenant)
{
$this->model = $model;
$this->tenant = $tenant;
} }
} }

View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Events;
class TenantMaintenanceModeDisabled extends Contracts\TenantEvent
{
//
}

View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Events;
class TenantMaintenanceModeEnabled extends Contracts\TenantEvent
{
//
}

View file

@ -8,7 +8,7 @@ use Exception;
class DomainOccupiedByOtherTenantException extends Exception class DomainOccupiedByOtherTenantException extends Exception
{ {
public function __construct($domain) public function __construct(string $domain)
{ {
parent::__construct("The $domain domain is occupied by another tenant."); parent::__construct("The $domain domain is occupied by another tenant.");
} }

View file

@ -11,7 +11,7 @@ class RouteIsMissingTenantParameterException extends Exception
{ {
public function __construct() public function __construct()
{ {
$parameter = PathTenantResolver::$tenantParameterName; $parameter = PathTenantResolver::tenantParameterName();
parent::__construct("The route's first argument is not the tenant id (configured paramter name: $parameter)."); parent::__construct("The route's first argument is not the tenant id (configured paramter name: $parameter).");
} }

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Exceptions;
use Exception;
class StatefulGuardRequiredException extends Exception
{
public function __construct(string $guardName)
{
parent::__construct("Cannot use a non-stateful guard ('$guardName'). A guard implementing the Illuminate\\Contracts\\Auth\\StatefulGuard interface is required.");
}
}

View file

@ -8,7 +8,7 @@ use Exception;
class TenancyNotInitializedException extends Exception class TenancyNotInitializedException extends Exception
{ {
public function __construct($message = '') public function __construct(string $message = '')
{ {
parent::__construct($message ?: 'Tenancy is not initialized.'); parent::__construct($message ?: 'Tenancy is not initialized.');
} }

View file

@ -14,12 +14,16 @@ class CrossDomainRedirect implements Feature
{ {
RedirectResponse::macro('domain', function (string $domain) { RedirectResponse::macro('domain', function (string $domain) {
/** @var RedirectResponse $this */ /** @var RedirectResponse $this */
// Replace first occurrence of the hostname fragment with $domain
$url = $this->getTargetUrl(); $url = $this->getTargetUrl();
/**
* The original hostname in the redirect response.
*
* @var string $hostname
*/
$hostname = parse_url($url, PHP_URL_HOST); $hostname = parse_url($url, PHP_URL_HOST);
$position = strpos($url, $hostname);
$this->setTargetUrl(substr_replace($url, $domain, $position, strlen($hostname))); $this->setTargetUrl((string) str($url)->replace($hostname, $domain));
return $this; return $this;
}); });

View file

@ -16,24 +16,25 @@ use Stancl\Tenancy\Tenancy;
class TenantConfig implements Feature class TenantConfig implements Feature
{ {
/** @var Repository */
protected $config;
public array $originalConfig = []; public array $originalConfig = [];
public static $storageToConfigMap = [ /** @var array<string, string|array> */
public static array $storageToConfigMap = [
// 'paypal_api_key' => 'services.paypal.api_key', // 'paypal_api_key' => 'services.paypal.api_key',
]; ];
public function __construct(Repository $config) public function __construct(
{ protected Repository $config,
$this->config = $config; ) {
} }
public function bootstrap(Tenancy $tenancy): void public function bootstrap(Tenancy $tenancy): void
{ {
Event::listen(TenancyBootstrapped::class, function (TenancyBootstrapped $event) { Event::listen(TenancyBootstrapped::class, function (TenancyBootstrapped $event) {
$this->setTenantConfig($event->tenancy->tenant); /** @var Tenant $tenant */
$tenant = $event->tenancy->tenant;
$this->setTenantConfig($tenant);
}); });
Event::listen(RevertedToCentralContext::class, function () { Event::listen(RevertedToCentralContext::class, function () {
@ -43,8 +44,8 @@ class TenantConfig implements Feature
public function setTenantConfig(Tenant $tenant): void public function setTenantConfig(Tenant $tenant): void
{ {
/** @var Tenant|Model $tenant */
foreach (static::$storageToConfigMap as $storageKey => $configKey) { foreach (static::$storageToConfigMap as $storageKey => $configKey) {
/** @var Tenant&Model $tenant */
$override = Arr::get($tenant, $storageKey); $override = Arr::get($tenant, $storageKey);
if (! is_null($override)) { if (! is_null($override)) {

View file

@ -16,6 +16,7 @@ class UniversalRoutes implements Feature
public static string $middlewareGroup = 'universal'; public static string $middlewareGroup = 'universal';
// todo docblock // todo docblock
/** @var array<class-string<\Stancl\Tenancy\Middleware\IdentificationMiddleware>> */
public static array $identificationMiddlewares = [ public static array $identificationMiddlewares = [
Middleware\InitializeTenancyByDomain::class, Middleware\InitializeTenancyByDomain::class,
Middleware\InitializeTenancyBySubdomain::class, Middleware\InitializeTenancyBySubdomain::class,
@ -42,7 +43,10 @@ class UniversalRoutes implements Feature
public static function routeHasMiddleware(Route $route, string $middleware): bool public static function routeHasMiddleware(Route $route, string $middleware): bool
{ {
if (in_array($middleware, $route->middleware(), true)) { /** @var array $routeMiddleware */
$routeMiddleware = $route->middleware();
if (in_array($middleware, $routeMiddleware, true)) {
return true; return true;
} }

View file

@ -0,0 +1,28 @@
<?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\Commands\ClearPendingTenants as ClearPendingTenantsCommand;
class ClearPendingTenants implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
Artisan::call(ClearPendingTenantsCommand::class);
}
}

View file

@ -24,7 +24,7 @@ class CreateDatabase implements ShouldQueue
) { ) {
} }
public function handle(DatabaseManager $databaseManager) public function handle(DatabaseManager $databaseManager): bool
{ {
event(new CreatingDatabase($this->tenant)); event(new CreatingDatabase($this->tenant));
@ -38,5 +38,7 @@ class CreateDatabase implements ShouldQueue
$this->tenant->database()->manager()->createDatabase($this->tenant); $this->tenant->database()->manager()->createDatabase($this->tenant);
event(new DatabaseCreated($this->tenant)); event(new DatabaseCreated($this->tenant));
return true;
} }
} }

View file

@ -0,0 +1,28 @@
<?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\Commands\CreatePendingTenants as CreatePendingTenantsCommand;
class CreatePendingTenants implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
Artisan::call(CreatePendingTenantsCommand::class);
}
}

View file

@ -0,0 +1,28 @@
<?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\Actions\CreateStorageSymlinksAction;
use Stancl\Tenancy\Contracts\Tenant;
class CreateStorageSymlinks implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public Tenant $tenant,
) {
}
public function handle(): void
{
CreateStorageSymlinksAction::handle($this->tenant);
}
}

View file

@ -9,14 +9,12 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Stancl\Tenancy\Database\Concerns\HasDomains;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
class DeleteDomains class DeleteDomains
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/** @var TenantWithDatabase&Model&HasDomains */ // todo unresolvable type for phpstan
protected TenantWithDatabase&Model $tenant; protected TenantWithDatabase&Model $tenant;
public function __construct(TenantWithDatabase&Model $tenant) public function __construct(TenantWithDatabase&Model $tenant)

View file

@ -0,0 +1,40 @@
<?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\Actions\RemoveStorageSymlinksAction;
use Stancl\Tenancy\Contracts\Tenant;
class RemoveStorageSymlinks implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public Tenant $tenant;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Tenant $tenant)
{
$this->tenant = $tenant;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
RemoveStorageSymlinksAction::handle($this->tenant);
}
}

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Listeners; namespace Stancl\Tenancy\Listeners;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Events\BootstrappingTenancy; use Stancl\Tenancy\Events\BootstrappingTenancy;
use Stancl\Tenancy\Events\TenancyBootstrapped; use Stancl\Tenancy\Events\TenancyBootstrapped;
use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Events\TenancyInitialized;
@ -15,7 +16,10 @@ class BootstrapTenancy
event(new BootstrappingTenancy($event->tenancy)); event(new BootstrappingTenancy($event->tenancy));
foreach ($event->tenancy->getBootstrappers() as $bootstrapper) { foreach ($event->tenancy->getBootstrappers() as $bootstrapper) {
$bootstrapper->bootstrap($event->tenancy->tenant); /** @var Tenant $tenant */
$tenant = $event->tenancy->tenant;
$bootstrapper->bootstrap($tenant);
} }
event(new TenancyBootstrapped($event->tenancy)); event(new TenancyBootstrapped($event->tenancy));

View file

@ -4,21 +4,22 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Listeners; namespace Stancl\Tenancy\Listeners;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\DatabaseManager; use Stancl\Tenancy\Database\DatabaseManager;
use Stancl\Tenancy\Events\Contracts\TenantEvent; use Stancl\Tenancy\Events\Contracts\TenantEvent;
class CreateTenantConnection class CreateTenantConnection
{ {
/** @var DatabaseManager */ public function __construct(
protected $database; protected DatabaseManager $database,
) {
public function __construct(DatabaseManager $database)
{
$this->database = $database;
} }
public function handle(TenantEvent $event): void public function handle(TenantEvent $event): void
{ {
$this->database->createTenantConnection($event->tenant); /** @var TenantWithDatabase */
$tenant = $event->tenant;
$this->database->createTenantConnection($tenant);
} }
} }

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Listeners;
use Illuminate\Support\Facades\File;
use Stancl\Tenancy\Events\DeletingTenant;
class DeleteTenantStorage
{
public function handle(DeletingTenant $event): void
{
File::deleteDirectory($event->tenant->run(fn () => storage_path()));
}
}

View file

@ -13,7 +13,7 @@ abstract class QueueableListener implements ShouldQueue
{ {
public static bool $shouldQueue = false; public static bool $shouldQueue = false;
public function shouldQueue($event): bool public function shouldQueue(object $event): bool
{ {
if (static::$shouldQueue) { if (static::$shouldQueue) {
return true; return true;

View file

@ -14,7 +14,7 @@ class RevertToCentralContext
{ {
event(new RevertingToCentralContext($event->tenancy)); event(new RevertingToCentralContext($event->tenancy));
foreach ($event->tenancy->getBootstrappers() as $bootstrapper) { foreach (array_reverse($event->tenancy->getBootstrappers()) as $bootstrapper) {
$bootstrapper->revert(); $bootstrapper->revert();
} }

View file

@ -4,14 +4,19 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Listeners; namespace Stancl\Tenancy\Listeners;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Support\Arr;
use Stancl\Tenancy\Contracts\Syncable;
use Stancl\Tenancy\Contracts\SyncMaster; use Stancl\Tenancy\Contracts\SyncMaster;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Database\TenantCollection;
use Stancl\Tenancy\Events\SyncedResourceChangedInForeignDatabase; use Stancl\Tenancy\Events\SyncedResourceChangedInForeignDatabase;
use Stancl\Tenancy\Events\SyncedResourceSaved; use Stancl\Tenancy\Events\SyncedResourceSaved;
use Stancl\Tenancy\Exceptions\ModelNotSyncMasterException; use Stancl\Tenancy\Exceptions\ModelNotSyncMasterException;
// todo@v4 review all code related to resource syncing
class UpdateSyncedResource extends QueueableListener class UpdateSyncedResource extends QueueableListener
{ {
public static bool $shouldQueue = false; public static bool $shouldQueue = false;
@ -30,25 +35,28 @@ class UpdateSyncedResource extends QueueableListener
$this->updateResourceInTenantDatabases($tenants, $event, $syncedAttributes); $this->updateResourceInTenantDatabases($tenants, $event, $syncedAttributes);
} }
protected function getTenantsForCentralModel($centralModel): EloquentCollection protected function getTenantsForCentralModel(Syncable $centralModel): TenantCollection
{ {
if (! $centralModel instanceof SyncMaster) { if (! $centralModel instanceof SyncMaster) {
// If we're trying to use a tenant User model instead of the central User model, for example. // If we're trying to use a tenant User model instead of the central User model, for example.
throw new ModelNotSyncMasterException(get_class($centralModel)); throw new ModelNotSyncMasterException(get_class($centralModel));
} }
/** @var SyncMaster|Model $centralModel */ /** @var Tenant&Model&SyncMaster $centralModel */
// Since this model is "dirty" (taken by reference from the event), it might have the tenants // Since this model is "dirty" (taken by reference from the event), it might have the tenants
// relationship already loaded and cached. For this reason, we refresh the relationship. // relationship already loaded and cached. For this reason, we refresh the relationship.
$centralModel->load('tenants'); $centralModel->load('tenants');
return $centralModel->tenants; /** @var TenantCollection $tenants */
$tenants = $centralModel->tenants;
return $tenants;
} }
protected function updateResourceInCentralDatabaseAndGetTenants($event, $syncedAttributes): EloquentCollection protected function updateResourceInCentralDatabaseAndGetTenants(SyncedResourceSaved $event, array $syncedAttributes): TenantCollection
{ {
/** @var Model|SyncMaster $centralModel */ /** @var (Model&SyncMaster)|null $centralModel */
$centralModel = $event->model->getCentralModelName()::where($event->model->getGlobalIdentifierKeyName(), $event->model->getGlobalIdentifierKey()) $centralModel = $event->model->getCentralModelName()::where($event->model->getGlobalIdentifierKeyName(), $event->model->getGlobalIdentifierKey())
->first(); ->first();
@ -59,15 +67,17 @@ class UpdateSyncedResource extends QueueableListener
event(new SyncedResourceChangedInForeignDatabase($event->model, null)); event(new SyncedResourceChangedInForeignDatabase($event->model, null));
} else { } else {
// If the resource doesn't exist at all in the central DB,we create // If the resource doesn't exist at all in the central DB,we create
// the record with all attributes, not just the synced ones. $centralModel = $event->model->getCentralModelName()::create($this->getAttributesForCreation($event->model));
$centralModel = $event->model->getCentralModelName()::create($event->model->getAttributes());
event(new SyncedResourceChangedInForeignDatabase($event->model, null)); event(new SyncedResourceChangedInForeignDatabase($event->model, null));
} }
}); });
// If the model was just created, the mapping of the tenant to the user likely doesn't exist, so we create it. // If the model was just created, the mapping of the tenant to the user likely doesn't exist, so we create it.
$currentTenantMapping = function ($model) use ($event) { $currentTenantMapping = function ($model) use ($event) {
return ((string) $model->pivot->tenant_id) === ((string) $event->tenant->getTenantKey()); /** @var Tenant */
$tenant = $event->tenant;
return ((string) $model->pivot->tenant_id) === ((string) $tenant->getTenantKey());
}; };
$mappingExists = $centralModel->tenants->contains($currentTenantMapping); $mappingExists = $centralModel->tenants->contains($currentTenantMapping);
@ -76,22 +86,29 @@ class UpdateSyncedResource extends QueueableListener
// Here we should call TenantPivot, but we call general Pivot, so that this works // Here we should call TenantPivot, but we call general Pivot, so that this works
// even if people use their own pivot model that is not based on our TenantPivot // even if people use their own pivot model that is not based on our TenantPivot
Pivot::withoutEvents(function () use ($centralModel, $event) { Pivot::withoutEvents(function () use ($centralModel, $event) {
$centralModel->tenants()->attach($event->tenant->getTenantKey()); /** @var Tenant */
$tenant = $event->tenant;
$centralModel->tenants()->attach($tenant->getTenantKey());
}); });
} }
return $centralModel->tenants->filter(function ($model) use ($currentTenantMapping) { /** @var TenantCollection $tenants */
$tenants = $centralModel->tenants->filter(function ($model) use ($currentTenantMapping) {
// Remove the mapping for the current tenant. // Remove the mapping for the current tenant.
return ! $currentTenantMapping($model); return ! $currentTenantMapping($model);
}); });
return $tenants;
} }
protected function updateResourceInTenantDatabases($tenants, $event, $syncedAttributes): void protected function updateResourceInTenantDatabases(TenantCollection $tenants, SyncedResourceSaved $event, array $syncedAttributes): void
{ {
tenancy()->runForMultiple($tenants, function ($tenant) use ($event, $syncedAttributes) { tenancy()->runForMultiple($tenants, function ($tenant) use ($event, $syncedAttributes) {
// Forget instance state and find the model, // Forget instance state and find the model,
// again in the current tenant's context. // again in the current tenant's context.
/** @var Model&Syncable $eventModel */
$eventModel = $event->model; $eventModel = $event->model;
if ($eventModel instanceof SyncMaster) { if ($eventModel instanceof SyncMaster) {
@ -112,12 +129,53 @@ class UpdateSyncedResource extends QueueableListener
if ($localModel) { if ($localModel) {
$localModel->update($syncedAttributes); $localModel->update($syncedAttributes);
} else { } else {
// When creating, we use all columns, not just the synced ones. $localModel = $localModelClass::create($this->getAttributesForCreation($eventModel));
$localModel = $localModelClass::create($eventModel->getAttributes());
} }
event(new SyncedResourceChangedInForeignDatabase($localModel, $tenant)); event(new SyncedResourceChangedInForeignDatabase($localModel, $tenant));
}); });
}); });
} }
protected function getAttributesForCreation(Model&Syncable $model): array
{
if (! $model->getSyncedCreationAttributes()) {
// Creation attributes are not specified so create the model as 1:1 copy
// exclude the "primary key" because we want primary key to handle by the target model to avoid duplication errors
$attributes = $model->getAttributes();
unset($attributes[$model->getKeyName()]);
return $attributes;
}
if (Arr::isAssoc($model->getSyncedCreationAttributes())) {
// Developer provided the default values (key => value) or mix of default values and attribute names (values only)
// We will merge the default values with provided attributes and sync attributes
[$attributeNames, $defaultValues] = $this->getAttributeNamesAndDefaultValues($model);
$attributes = $model->only(array_merge($model->getSyncedAttributeNames(), $attributeNames));
return array_merge($attributes, $defaultValues);
}
// Developer provided the attribute names, so we'll use them to pick model attributes
return $model->only($model->getSyncedCreationAttributes());
}
/**
* Split the attribute names (sequential index items) and default values (key => values).
*/
protected function getAttributeNamesAndDefaultValues(Model&Syncable $model): array
{
$syncedCreationAttributes = $model->getSyncedCreationAttributes() ?? [];
$attributes = Arr::where($syncedCreationAttributes, function ($value, $key) {
return is_numeric($key);
});
$defaultValues = Arr::where($syncedCreationAttributes, function ($value, $key) {
return is_string($key);
});
return [$attributes, $defaultValues];
}
} }

View file

@ -7,7 +7,6 @@ namespace Stancl\Tenancy\Middleware;
use Closure; use Closure;
use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode; use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode;
use Stancl\Tenancy\Exceptions\TenancyNotInitializedException; use Stancl\Tenancy\Exceptions\TenancyNotInitializedException;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode
@ -21,19 +20,38 @@ class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode
if (tenant('maintenance_mode')) { if (tenant('maintenance_mode')) {
$data = tenant('maintenance_mode'); $data = tenant('maintenance_mode');
if (isset($data['allowed']) && IpUtils::checkIp($request->ip(), (array) $data['allowed'])) { if (isset($data['secret']) && $request->path() === $data['secret']) {
return $this->bypassResponse($data['secret']);
}
if ($this->hasValidBypassCookie($request, $data) ||
$this->inExceptArray($request)) {
return $next($request); return $next($request);
} }
if ($this->inExceptArray($request)) { if (isset($data['redirect'])) {
return $next($request); $path = $data['redirect'] === '/'
? $data['redirect']
: trim($data['redirect'], '/');
if ($request->path() !== $path) {
return redirect($path);
}
}
if (isset($data['template'])) {
return response(
$data['template'],
(int) ($data['status'] ?? 503),
$this->getHeaders($data)
);
} }
throw new HttpException( throw new HttpException(
503, (int) ($data['status'] ?? 503),
'Service Unavailable', 'Service Unavailable',
null, null,
isset($data['retry']) ? ['Retry-After' => $data['retry']] : [] $this->getHeaders($data)
); );
} }

Some files were not shown because too many files have changed in this diff Show more