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

Merge branch 'master' into resource-syncing

This commit is contained in:
Samuel Štancl 2022-09-29 23:42:58 +02:00
commit e8071944ad
106 changed files with 1609 additions and 451 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:

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,10 +47,13 @@ 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.
@ -93,6 +97,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 => [],
]; ];

View file

@ -32,6 +32,7 @@ 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
], ],
@ -118,6 +119,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.
* *

View file

@ -18,7 +18,7 @@
"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.0"
@ -63,6 +63,7 @@
"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",
"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

@ -10,16 +10,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();
@ -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%',
$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', '>=')) { $dispatcher->listen(JobRetryRequested::class, function ($event) use (&$previousTenant) {
// JobRetryRequested only exists since Laravel 8.64 $previousTenant = tenant();
$dispatcher->listen(JobRetryRequested::class, function ($event) use (&$previousTenant) {
$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'];
} }

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

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Commands;
use Illuminate\Foundation\Console\DownCommand;
use Stancl\Tenancy\Concerns\HasATenantsOption;
class Down extends DownCommand
{
use HasATenantsOption;
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
{
// The base down command is heavily used. Instead of saving the data inside a file,
// the data is stored the tenant database, which means some Laravel features
// are not available with tenants.
$payload = $this->getDownDatabasePayload();
// This runs for all tenants if no --tenants are specified
tenancy()->runForMultiple($this->getTenants(), function ($tenant) use ($payload) {
$this->line("Tenant: {$tenant['id']}");
$tenant->putDownForMaintenance($payload);
});
$this->comment('Tenants are now in maintenance mode.');
return 0;
}
/** Get the payload to be placed in the "down" file. */
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

@ -8,24 +8,11 @@ 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';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Install stancl/tenancy.'; protected $description = 'Install stancl/tenancy.';
/** public function handle(): void
* Execute the console command.
*/
public function handle()
{ {
$this->comment('Installing stancl/tenancy...'); $this->comment('Installing stancl/tenancy...');
$this->callSilent('vendor:publish', [ $this->callSilent('vendor:publish', [

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

@ -0,0 +1,58 @@
<?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\HasATenantsOption;
class Link extends Command
{
use HasATenantsOption;
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(): void
{
$tenants = $this->getTenants();
try {
if ($this->option('remove')) {
$this->removeLinks($tenants);
} else {
$this->createLinks($tenants);
}
} catch (Exception $exception) {
$this->error($exception->getMessage());
}
}
protected function removeLinks(LazyCollection $tenants): void
{
RemoveStorageSymlinksAction::handle($tenants);
$this->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->info('The links have been created.');
}
}

View file

@ -7,7 +7,6 @@ namespace Stancl\Tenancy\Commands;
use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\Console\Migrations\MigrateCommand; 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\ExtendsLaravelCommand; use Stancl\Tenancy\Concerns\ExtendsLaravelCommand;
use Stancl\Tenancy\Concerns\HasATenantsOption; use Stancl\Tenancy\Concerns\HasATenantsOption;
use Stancl\Tenancy\Events\DatabaseMigrated; use Stancl\Tenancy\Events\DatabaseMigrated;
@ -15,7 +14,7 @@ use Stancl\Tenancy\Events\MigratingDatabase;
class Migrate extends MigrateCommand class Migrate extends MigrateCommand
{ {
use HasATenantsOption, DealsWithMigrations, ExtendsLaravelCommand; use HasATenantsOption, ExtendsLaravelCommand;
protected $description = 'Run migrations for tenant(s)'; protected $description = 'Run migrations for tenant(s)';
@ -31,10 +30,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,10 +39,10 @@ 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->line("Tenant: {$tenant->getTenantKey()}");
event(new MigratingDatabase($tenant)); event(new MigratingDatabase($tenant));
@ -56,5 +52,7 @@ class Migrate extends MigrateCommand
event(new DatabaseMigrated($tenant)); event(new DatabaseMigrated($tenant));
}); });
return 0;
} }
} }

View file

@ -5,19 +5,13 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Commands; namespace Stancl\Tenancy\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Stancl\Tenancy\Concerns\DealsWithMigrations;
use Stancl\Tenancy\Concerns\HasATenantsOption; use Stancl\Tenancy\Concerns\HasATenantsOption;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
final class MigrateFresh extends Command final class MigrateFresh extends Command
{ {
use HasATenantsOption, DealsWithMigrations; use HasATenantsOption;
/**
* 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,12 +23,9 @@ final class MigrateFresh extends Command
$this->setName('tenants:migrate-fresh'); $this->setName('tenants:migrate-fresh');
} }
/** public function handle(): void
* 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->info('Dropping tables.');
$this->call('db:wipe', array_filter([ $this->call('db:wipe', array_filter([
'--database' => 'tenant', '--database' => 'tenant',

View file

@ -6,7 +6,6 @@ namespace Stancl\Tenancy\Commands;
use Illuminate\Database\Console\Migrations\RollbackCommand; 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\ExtendsLaravelCommand; use Stancl\Tenancy\Concerns\ExtendsLaravelCommand;
use Stancl\Tenancy\Concerns\HasATenantsOption; use Stancl\Tenancy\Concerns\HasATenantsOption;
use Stancl\Tenancy\Events\DatabaseRolledBack; use Stancl\Tenancy\Events\DatabaseRolledBack;
@ -14,25 +13,10 @@ use Stancl\Tenancy\Events\RollingBackDatabase;
class Rollback extends RollbackCommand class Rollback extends RollbackCommand
{ {
use HasATenantsOption, DealsWithMigrations, ExtendsLaravelCommand; use HasATenantsOption, 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 +24,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,10 +33,10 @@ 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->line("Tenant: {$tenant->getTenantKey()}");
event(new RollingBackDatabase($tenant)); event(new RollingBackDatabase($tenant));
@ -65,5 +46,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,44 @@ 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\HasATenantsOption;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\ConsoleOutput;
class Run extends Command class Run extends Command
{ {
/** use HasATenantsOption;
* 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(): void
* Execute the console command.
*/
public function handle()
{ {
tenancy()->runForMultiple($this->option('tenants'), function ($tenant) { $argvInput = $this->argvInput();
tenancy()->runForMultiple($this->getTenants(), function ($tenant) use ($argvInput) {
$this->line("Tenant: {$tenant->getTenantKey()}"); $this->line("Tenant: {$tenant->getTenantKey()}");
Artisan::call($this->argument('commandname')); $this->getLaravel()
$this->comment('Command output:'); ->make(Kernel::class)
$this->info(Artisan::output()); ->handle($argvInput, new ConsoleOutput);
}); });
} }
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

@ -14,29 +14,16 @@ class Seed extends SeedCommand
{ {
use HasATenantsOption; use HasATenantsOption;
/**
* 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,10 +32,10 @@ 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->line("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

@ -5,39 +5,28 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Commands; namespace Stancl\Tenancy\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;
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(): void
* Execute the console command.
*/
public function handle()
{ {
$this->info('Listing all tenants.'); $this->info('Listing all tenants.');
tenancy()
->query() $tenants = tenancy()->query()->cursor();
->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() ?? [])); if ($tenant->domains) {
} else { $this->line("[Tenant] {$tenant->getTenantKeyName()}: {$tenant->getTenantKey()} @ " . implode('; ', $tenant->domains->pluck('domain')->toArray() ?? []));
$this->line("[Tenant] {$tenant->getTenantKeyName()}: {$tenant->getTenantKey()}"); } else {
} $this->line("[Tenant] {$tenant->getTenantKeyName()}: {$tenant->getTenantKey()}");
}); }
}
} }
} }

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

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Commands;
use Illuminate\Console\Command;
use Stancl\Tenancy\Concerns\HasATenantsOption;
class Up extends Command
{
use HasATenantsOption;
protected $signature = 'tenants:up';
protected $description = 'Put tenants out of maintenance mode.';
public function handle(): void
{
tenancy()->runForMultiple($this->getTenants(), function ($tenant) {
$this->line("Tenant: {$tenant['id']}");
$tenant->bringUpFromMaintenance();
});
$this->comment('Tenants are now out of maintenance mode.');
}
}

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

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

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

@ -6,18 +6,22 @@ namespace Stancl\Tenancy\Controllers;
use Closure; use Closure;
use Illuminate\Routing\Controller; use Illuminate\Routing\Controller;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Throwable; use Throwable;
class TenantAssetsController extends Controller class TenantAssetsController extends Controller // todo rename this to TenantAssetController & update references in docs
{ {
public static string|array|Closure $tenancyMiddleware = Stancl\Tenancy\Middleware\InitializeTenancyByDomain::class; public static string|array|Closure $tenancyMiddleware = \Stancl\Tenancy\Middleware\InitializeTenancyByDomain::class;
public function __construct() public function __construct()
{ {
$this->middleware(static::$tenancyMiddleware); $this->middleware(static::$tenancyMiddleware);
} }
public function asset(string $path = null) /**
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
*/
public function asset(string $path = null): BinaryFileResponse
{ {
abort_if($path === null, 404); abort_if($path === null, 404);

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,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

@ -10,6 +10,7 @@ use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver;
trait InvalidatesResolverCache trait InvalidatesResolverCache
{ {
/** @var array<class-string<CachedTenantResolver>> */
public static $resolvers = [ public static $resolvers = [
Resolvers\DomainTenantResolver::class, Resolvers\DomainTenantResolver::class,
Resolvers\PathTenantResolver::class, Resolvers\PathTenantResolver::class,

View file

@ -13,7 +13,8 @@ use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver;
*/ */
trait InvalidatesTenantsResolverCache trait InvalidatesTenantsResolverCache
{ {
public static $resolvers = [ /** @var array<class-string<CachedTenantResolver>> */
public static array $resolvers = [ // todo single source of truth for this here and in InvalidatesResolverCache
Resolvers\DomainTenantResolver::class, Resolvers\DomainTenantResolver::class,
Resolvers\PathTenantResolver::class, Resolvers\PathTenantResolver::class,
Resolvers\RequestDataTenantResolver::class, Resolvers\RequestDataTenantResolver::class,

View file

@ -4,17 +4,27 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns; namespace Stancl\Tenancy\Database\Concerns;
use Carbon\Carbon; /**
* @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,
'retry' => $data['retry'] ?? null, 'redirect' => $data['redirect'] ?? null,
'allowed' => $data['allowed'] ?? [], 'retry' => $data['retry'] ?? null,
]]); 'refresh' => $data['refresh'] ?? null,
'secret' => $data['secret'] ?? null,
'status' => $data['status'] ?? 503,
],
]);
}
public function bringUpFromMaintenance(): void
{
$this->update(['maintenance_mode' => null]);
} }
} }

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

@ -26,20 +26,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 +61,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,7 +81,7 @@ 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->manager() 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));

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) {
throw new Exceptions\TenantDatabaseUserAlreadyExistsException($username); /** @var string $username */
$username = $tenant->database()->getUsername();
if ($manager->userExists($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

@ -26,6 +26,7 @@ class Tenant extends Model implements Contracts\Tenant
Concerns\HasDataColumn, Concerns\HasDataColumn,
Concerns\HasInternalKeys, Concerns\HasInternalKeys,
Concerns\TenantRun, Concerns\TenantRun,
Concerns\InitializationHelpers,
Concerns\InvalidatesResolverCache; Concerns\InvalidatesResolverCache;
protected $table = 'tenants'; protected $table = 'tenants';

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

@ -20,7 +20,7 @@ class TenantScope implements Scope
$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

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

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

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

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

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

View file

@ -4,22 +4,22 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Middleware; namespace Stancl\Tenancy\Middleware;
use Closure;
use Illuminate\Http\Request;
use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException; use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException;
use Stancl\Tenancy\Contracts\TenantResolver; use Stancl\Tenancy\Contracts\TenantResolver;
use Stancl\Tenancy\Tenancy; use Stancl\Tenancy\Tenancy;
/**
* @property Tenancy $tenancy
* @property TenantResolver $resolver
*/
abstract class IdentificationMiddleware abstract class IdentificationMiddleware
{ {
/** @var callable */ public static ?Closure $onFail = null;
public static $onFail;
/** @var Tenancy */ /** @return \Illuminate\Http\Response|mixed */
protected $tenancy; public function initializeTenancy(Request $request, Closure $next, mixed ...$resolverArguments): mixed
/** @var TenantResolver */
protected $resolver;
public function initializeTenancy($request, $next, ...$resolverArguments)
{ {
try { try {
$this->tenancy->initialize( $this->tenancy->initialize(

View file

@ -5,32 +5,22 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Middleware; namespace Stancl\Tenancy\Middleware;
use Closure; use Closure;
use Illuminate\Http\Request;
use Stancl\Tenancy\Resolvers\DomainTenantResolver; use Stancl\Tenancy\Resolvers\DomainTenantResolver;
use Stancl\Tenancy\Tenancy; use Stancl\Tenancy\Tenancy;
class InitializeTenancyByDomain extends IdentificationMiddleware class InitializeTenancyByDomain extends IdentificationMiddleware
{ {
/** @var callable|null */ public static ?Closure $onFail = null;
public static $onFail;
/** @var Tenancy */ public function __construct(
protected $tenancy; protected Tenancy $tenancy,
protected DomainTenantResolver $resolver,
/** @var DomainTenantResolver */ ) {
protected $resolver;
public function __construct(Tenancy $tenancy, DomainTenantResolver $resolver)
{
$this->tenancy = $tenancy;
$this->resolver = $resolver;
} }
/** /** @return \Illuminate\Http\Response|mixed */
* Handle an incoming request. public function handle(Request $request, Closure $next): mixed
*
* @param \Illuminate\Http\Request $request
*/
public function handle($request, Closure $next)
{ {
return $this->initializeTenancy( return $this->initializeTenancy(
$request, $request,

View file

@ -5,16 +5,13 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Middleware; namespace Stancl\Tenancy\Middleware;
use Closure; use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class InitializeTenancyByDomainOrSubdomain class InitializeTenancyByDomainOrSubdomain
{ {
/** /** @return \Illuminate\Http\Response|mixed */
* Handle an incoming request. public function handle(Request $request, Closure $next): mixed
*
* @param \Illuminate\Http\Request $request
*/
public function handle($request, Closure $next)
{ {
if ($this->isSubdomain($request->getHost())) { if ($this->isSubdomain($request->getHost())) {
return app(InitializeTenancyBySubdomain::class)->handle($request, $next); return app(InitializeTenancyBySubdomain::class)->handle($request, $next);

View file

@ -7,28 +7,26 @@ namespace Stancl\Tenancy\Middleware;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Route; use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Events\InitializingTenancy;
use Stancl\Tenancy\Exceptions\RouteIsMissingTenantParameterException; use Stancl\Tenancy\Exceptions\RouteIsMissingTenantParameterException;
use Stancl\Tenancy\Resolvers\PathTenantResolver; use Stancl\Tenancy\Resolvers\PathTenantResolver;
use Stancl\Tenancy\Tenancy; use Stancl\Tenancy\Tenancy;
class InitializeTenancyByPath extends IdentificationMiddleware class InitializeTenancyByPath extends IdentificationMiddleware
{ {
/** @var callable|null */ public static ?Closure $onFail = null;
public static $onFail;
/** @var Tenancy */ public function __construct(
protected $tenancy; protected Tenancy $tenancy,
protected PathTenantResolver $resolver,
/** @var PathTenantResolver */ ) {
protected $resolver;
public function __construct(Tenancy $tenancy, PathTenantResolver $resolver)
{
$this->tenancy = $tenancy;
$this->resolver = $resolver;
} }
public function handle(Request $request, Closure $next) /** @return \Illuminate\Http\Response|mixed */
public function handle(Request $request, Closure $next): mixed
{ {
/** @var Route $route */ /** @var Route $route */
$route = $request->route(); $route = $request->route();
@ -37,6 +35,14 @@ class InitializeTenancyByPath extends IdentificationMiddleware
// We don't want to initialize tenancy if the tenant is // We don't want to initialize tenancy if the tenant is
// simply injected into some route controller action. // simply injected into some route controller action.
if ($route->parameterNames()[0] === PathTenantResolver::$tenantParameterName) { if ($route->parameterNames()[0] === PathTenantResolver::$tenantParameterName) {
// Set tenant as a default parameter for the URLs in the current request
Event::listen(InitializingTenancy::class, function (InitializingTenancy $event) {
/** @var Tenant $tenant */
$tenant = $event->tenancy->tenant;
URL::defaults([PathTenantResolver::$tenantParameterName => $tenant->getTenantKey()]);
});
return $this->initializeTenancy( return $this->initializeTenancy(
$request, $request,
$next, $next,

View file

@ -11,33 +11,18 @@ use Stancl\Tenancy\Tenancy;
class InitializeTenancyByRequestData extends IdentificationMiddleware class InitializeTenancyByRequestData extends IdentificationMiddleware
{ {
/** @var string|null */ public static string $header = 'X-Tenant';
public static $header = 'X-Tenant'; public static string $queryParameter = 'tenant';
public static ?Closure $onFail = null;
/** @var string|null */ public function __construct(
public static $queryParameter = 'tenant'; protected Tenancy $tenancy,
protected RequestDataTenantResolver $resolver,
/** @var callable|null */ ) {
public static $onFail;
/** @var Tenancy */
protected $tenancy;
/** @var TenantResolver */
protected $resolver;
public function __construct(Tenancy $tenancy, RequestDataTenantResolver $resolver)
{
$this->tenancy = $tenancy;
$this->resolver = $resolver;
} }
/** /** @return \Illuminate\Http\Response|mixed */
* Handle an incoming request. public function handle(Request $request, Closure $next): mixed
*
* @param \Illuminate\Http\Request $request
*/
public function handle($request, Closure $next)
{ {
if ($request->method() !== 'OPTIONS') { if ($request->method() !== 'OPTIONS') {
return $this->initializeTenancy($request, $next, $this->getPayload($request)); return $this->initializeTenancy($request, $next, $this->getPayload($request));

View file

@ -6,6 +6,7 @@ namespace Stancl\Tenancy\Middleware;
use Closure; use Closure;
use Exception; use Exception;
use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Stancl\Tenancy\Exceptions\NotASubdomainException; use Stancl\Tenancy\Exceptions\NotASubdomainException;
@ -21,15 +22,10 @@ class InitializeTenancyBySubdomain extends InitializeTenancyByDomain
*/ */
public static $subdomainIndex = 0; public static $subdomainIndex = 0;
/** @var callable|null */ public static ?Closure $onFail = null;
public static $onFail;
/** /** @return Response|mixed */
* Handle an incoming request. public function handle(Request $request, Closure $next): mixed
*
* @param \Illuminate\Http\Request $request
*/
public function handle($request, Closure $next)
{ {
$subdomain = $this->makeSubdomain($request->getHost()); $subdomain = $this->makeSubdomain($request->getHost());

View file

@ -11,12 +11,11 @@ class PreventAccessFromCentralDomains
{ {
/** /**
* Set this property if you want to customize the on-fail behavior. * Set this property if you want to customize the on-fail behavior.
*
* @var callable|null
*/ */
public static $abortRequest; public static ?Closure $abortRequest;
public function handle(Request $request, Closure $next) /** @return \Illuminate\Http\Response|mixed */
public function handle(Request $request, Closure $next): mixed
{ {
if (in_array($request->getHost(), config('tenancy.central_domains'))) { if (in_array($request->getHost(), config('tenancy.central_domains'))) {
$abortRequest = static::$abortRequest ?? function () { $abortRequest = static::$abortRequest ?? function () {

View file

@ -10,9 +10,10 @@ use Stancl\Tenancy\Exceptions\TenancyNotInitializedException;
class ScopeSessions class ScopeSessions
{ {
public static $tenantIdKey = '_tenant_id'; public static string $tenantIdKey = '_tenant_id';
public function handle(Request $request, Closure $next) /** @return \Illuminate\Http\Response|mixed */
public function handle(Request $request, Closure $next): mixed
{ {
if (! tenancy()->initialized) { if (! tenancy()->initialized) {
throw new TenancyNotInitializedException('Tenancy needs to be initialized before the session scoping middleware is executed'); throw new TenancyNotInitializedException('Tenancy needs to be initialized before the session scoping middleware is executed');

View file

@ -65,7 +65,7 @@ abstract class CachedTenantResolver implements TenantResolver
abstract public function resolveWithoutCache(mixed ...$args): Tenant; abstract public function resolveWithoutCache(mixed ...$args): Tenant;
public function resolved(Tenant $tenant, ...$args): void public function resolved(Tenant $tenant, mixed ...$args): void
{ {
} }

View file

@ -24,7 +24,6 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver
{ {
$domain = $args[0]; $domain = $args[0];
/** @var Tenant|null $tenant */
$tenant = config('tenancy.tenant_model')::query() $tenant = config('tenancy.tenant_model')::query()
->whereHas('domains', fn (Builder $query) => $query->where('domain', $domain)) ->whereHas('domains', fn (Builder $query) => $query->where('domain', $domain))
->with('domains') ->with('domains')
@ -39,7 +38,7 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver
throw new TenantCouldNotBeIdentifiedOnDomainException($args[0]); throw new TenantCouldNotBeIdentifiedOnDomainException($args[0]);
} }
public function resolved(Tenant $tenant, ...$args): void public function resolved(Tenant $tenant, mixed ...$args): void
{ {
$this->setCurrentDomain($tenant, $args[0]); $this->setCurrentDomain($tenant, $args[0]);
} }

View file

@ -23,7 +23,7 @@ class PathTenantResolver extends Contracts\CachedTenantResolver
/** @var Route $route */ /** @var Route $route */
$route = $args[0]; $route = $args[0];
if ($id = $route->parameter(static::$tenantParameterName)) { if ($id = (string) $route->parameter(static::$tenantParameterName)) {
$route->forgetParameter(static::$tenantParameterName); $route->forgetParameter(static::$tenantParameterName);
if ($tenant = tenancy()->find($id)) { if ($tenant = tenancy()->find($id)) {
@ -37,7 +37,7 @@ class PathTenantResolver extends Contracts\CachedTenantResolver
public function getArgsForTenant(Tenant $tenant): array public function getArgsForTenant(Tenant $tenant): array
{ {
return [ return [
[$tenant->id], [$tenant->getTenantKey()],
]; ];
} }
} }

View file

@ -17,7 +17,7 @@ class RequestDataTenantResolver extends Contracts\CachedTenantResolver
public function resolveWithoutCache(mixed ...$args): Tenant public function resolveWithoutCache(mixed ...$args): Tenant
{ {
$payload = $args[0]; $payload = (string) $args[0];
if ($payload && $tenant = tenancy()->find($payload)) { if ($payload && $tenant = tenancy()->find($payload)) {
return $tenant; return $tenant;
@ -29,7 +29,7 @@ class RequestDataTenantResolver extends Contracts\CachedTenantResolver
public function getArgsForTenant(Tenant $tenant): array public function getArgsForTenant(Tenant $tenant): array
{ {
return [ return [
[$tenant->id], [$tenant->getTenantKey()],
]; ];
} }
} }

View file

@ -99,19 +99,30 @@ class Tenancy
{ {
$class = config('tenancy.tenant_model'); $class = config('tenancy.tenant_model');
return new $class; /** @var Tenant&Model $model */
$model = new $class;
return $model;
} }
/**
* Try to find a tenant using an ID.
*
* @return (Tenant&Model)|null
*/
public static function find(int|string $id): Tenant|null public static function find(int|string $id): Tenant|null
{ {
return static::model()->where(static::model()->getTenantKeyName(), $id)->first(); /** @var (Tenant&Model)|null */
$tenant = static::model()->where(static::model()->getTenantKeyName(), $id)->first();
return $tenant;
} }
/** /**
* Run a callback in the central context. * Run a callback in the central context.
* Atomic, safely reverts to previous context. * Atomic, safely reverts to previous context.
*/ */
public function central(Closure $callback) public function central(Closure $callback): mixed
{ {
$previousTenant = $this->tenant; $previousTenant = $this->tenant;
@ -132,7 +143,7 @@ class Tenancy
* Run a callback for multiple tenants. * Run a callback for multiple tenants.
* More performant than running $tenant->run() one by one. * More performant than running $tenant->run() one by one.
* *
* @param Tenant[]|\Traversable|string[]|null $tenants * @param array<Tenant>|array<string|int>|\Traversable|string|int|null $tenants
*/ */
public function runForMultiple($tenants, Closure $callback): void public function runForMultiple($tenants, Closure $callback): void
{ {
@ -146,7 +157,7 @@ class Tenancy
$tenants = is_string($tenants) ? [$tenants] : $tenants; $tenants = is_string($tenants) ? [$tenants] : $tenants;
// Use all tenants if $tenants is falsey // Use all tenants if $tenants is falsey
$tenants = $tenants ?: $this->model()->cursor(); $tenants = $tenants ?: $this->model()->cursor(); // todo0 phpstan thinks this isn't needed, but tests fail without it
$originalTenant = $this->tenant; $originalTenant = $this->tenant;
@ -155,6 +166,7 @@ class Tenancy
$tenant = $this->find($tenant); $tenant = $this->find($tenant);
} }
/** @var Tenant $tenant */
$this->initialize($tenant); $this->initialize($tenant);
$callback($tenant); $callback($tenant);
} }

View file

@ -78,6 +78,7 @@ class TenancyServiceProvider extends ServiceProvider
{ {
$this->commands([ $this->commands([
Commands\Run::class, Commands\Run::class,
Commands\Link::class,
Commands\Seed::class, Commands\Seed::class,
Commands\Install::class, Commands\Install::class,
Commands\Migrate::class, Commands\Migrate::class,
@ -85,6 +86,8 @@ class TenancyServiceProvider extends ServiceProvider
Commands\TenantList::class, Commands\TenantList::class,
Commands\TenantDump::class, Commands\TenantDump::class,
Commands\MigrateFresh::class, Commands\MigrateFresh::class,
Commands\Down::class,
Commands\Up::class,
]); ]);
$this->publishes([ $this->publishes([

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy; namespace Stancl\Tenancy;
use Illuminate\Database\Eloquent\Model;
use Ramsey\Uuid\Uuid; use Ramsey\Uuid\Uuid;
use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator; use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
@ -11,7 +12,7 @@ use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
class UUIDGenerator implements UniqueIdentifierGenerator class UUIDGenerator implements UniqueIdentifierGenerator
{ {
public static function generate($resource): string public static function generate(Model $model): string
{ {
return Uuid::uuid4()->toString(); return Uuid::uuid4()->toString();
} }

View file

@ -35,6 +35,7 @@ if (! function_exists('tenant')) {
if (! function_exists('tenant_asset')) { if (! function_exists('tenant_asset')) {
// todo docblock // todo docblock
// todo add an option to generate paths respecting the ASSET_URL
function tenant_asset(string|null $asset): string function tenant_asset(string|null $asset): string
{ {
return route('stancl.tenancy.asset', ['path' => $asset]); return route('stancl.tenancy.asset', ['path' => $asset]);
@ -42,27 +43,57 @@ if (! function_exists('tenant_asset')) {
} }
if (! function_exists('global_asset')) { if (! function_exists('global_asset')) {
function global_asset(string $asset) // todo types, also inside the globalUrl implementation function global_asset(string $asset): string
{ {
return app('globalUrl')->asset($asset); return app('globalUrl')->asset($asset);
} }
} }
if (! function_exists('global_cache')) { if (! function_exists('global_cache')) {
function global_cache() /**
* Get / set the specified cache value in the global cache store.
*
* If an array is passed, we'll assume you want to put to the cache.
*
* @param dynamic key|key,default|data,expiration|null
* @return mixed|\Illuminate\Cache\CacheManager
*
* @throws \InvalidArgumentException
*/
function global_cache(): mixed
{ {
return app('globalCache'); $arguments = func_get_args();
if (empty($arguments)) {
return app('globalCache');
}
if (is_string($arguments[0])) {
return app('globalCache')->get(...$arguments);
}
if (! is_array($arguments[0])) {
throw new InvalidArgumentException(
'When setting a value in the cache, you must pass an array of key / value pairs.'
);
}
return app('globalCache')->put(key($arguments[0]), reset($arguments[0]), $arguments[1] ?? null);
} }
} }
if (! function_exists('tenant_route')) { if (! function_exists('tenant_route')) {
function tenant_route(string $domain, string $route, array $parameters = [], bool $absolute = true): string function tenant_route(string $domain, string $route, array $parameters = [], bool $absolute = true): string
{ {
// replace the first occurrence of the hostname fragment with $domain
$url = route($route, $parameters, $absolute); $url = route($route, $parameters, $absolute);
$hostname = parse_url($url, PHP_URL_HOST);
$position = strpos($url, $hostname);
return substr_replace($url, $domain, $position, strlen($hostname)); /**
* The original hostname in the generated route.
*
* @var string $hostname
*/
$hostname = parse_url($url, PHP_URL_HOST);
return (string) str($url)->replace($hostname, $domain);
} }
} }

69
tests/ActionTest.php Normal file
View file

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Event;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Database\Models\Tenant;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Actions\CreateStorageSymlinksAction;
use Stancl\Tenancy\Actions\RemoveStorageSymlinksAction;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
});
// todo move these to be in the same file as the other tests from this PR (#909) rather than generic "action tests"
test('create storage symlinks action works', function() {
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
],
'tenancy.filesystem.suffix_base' => 'tenant-',
'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/',
'tenancy.filesystem.url_override.public' => 'public-%tenant_id%'
]);
/** @var Tenant $tenant */
$tenant = Tenant::create();
$tenantKey = $tenant->getTenantKey();
tenancy()->initialize($tenant);
$this->assertDirectoryDoesNotExist($publicPath = public_path("public-$tenantKey"));
CreateStorageSymlinksAction::handle($tenant);
$this->assertDirectoryExists($publicPath);
$this->assertEquals(storage_path("app/public/"), readlink($publicPath));
});
test('remove storage symlinks action works', function() {
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
],
'tenancy.filesystem.suffix_base' => 'tenant-',
'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/',
'tenancy.filesystem.url_override.public' => 'public-%tenant_id%'
]);
/** @var Tenant $tenant */
$tenant = Tenant::create();
$tenantKey = $tenant->getTenantKey();
tenancy()->initialize($tenant);
CreateStorageSymlinksAction::handle($tenant);
$this->assertDirectoryExists($publicPath = public_path("public-$tenantKey"));
RemoveStorageSymlinksAction::handle($tenant);
$this->assertDirectoryDoesNotExist($publicPath);
});

View file

@ -107,12 +107,12 @@ function contextIsSwitchedWhenTenancyInitialized()
class MyBootstrapper implements TenancyBootstrapper class MyBootstrapper implements TenancyBootstrapper
{ {
public function bootstrap(\Stancl\Tenancy\Contracts\Tenant $tenant) public function bootstrap(\Stancl\Tenancy\Contracts\Tenant $tenant): void
{ {
app()->instance('tenancy_initialized_for_tenant', $tenant->getTenantKey()); app()->instance('tenancy_initialized_for_tenant', $tenant->getTenantKey());
} }
public function revert() public function revert(): void
{ {
app()->instance('tenancy_ended', true); app()->instance('tenancy_ended', true);
} }

44
tests/BatchTest.php Normal file
View file

@ -0,0 +1,44 @@
<?php
use Illuminate\Bus\BatchRepository;
use Illuminate\Support\Facades\Event;
use Stancl\Tenancy\Bootstrappers\BatchTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Tests\Etc\Tenant;
beforeEach(function () {
config([
'tenancy.bootstrappers' => [
DatabaseTenancyBootstrapper::class,
BatchTenancyBootstrapper::class,
],
]);
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
});
test('batch repository is set to tenant connection and reverted', function () {
$tenant = Tenant::create();
$tenant2 = Tenant::create();
expect(getBatchRepositoryConnectionName())->toBe('central');
tenancy()->initialize($tenant);
expect(getBatchRepositoryConnectionName())->toBe('tenant');
tenancy()->initialize($tenant2);
expect(getBatchRepositoryConnectionName())->toBe('tenant');
tenancy()->end();
expect(getBatchRepositoryConnectionName())->toBe('central');
});
function getBatchRepositoryConnectionName()
{
return app(BatchRepository::class)->getConnection()->getName();
}

View file

@ -2,10 +2,10 @@
declare(strict_types=1); declare(strict_types=1);
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Stancl\JobPipeline\JobPipeline; use Stancl\JobPipeline\JobPipeline;
use Illuminate\Support\Facades\File;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
@ -14,8 +14,14 @@ use Illuminate\Support\Facades\Storage;
use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Jobs\CreateDatabase; use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Events\TenantCreated; use Stancl\Tenancy\Events\TenantCreated;
use Stancl\Tenancy\Events\TenantDeleted;
use Stancl\Tenancy\Events\DeletingTenant;
use Illuminate\Filesystem\FilesystemAdapter;
use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Jobs\CreateStorageSymlinks;
use Stancl\Tenancy\Jobs\RemoveStorageSymlinks;
use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\DeleteTenantStorage;
use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper;
@ -184,24 +190,156 @@ test('filesystem data is separated', function () {
expect($new_storage_path)->toEqual($expected_storage_path); expect($new_storage_path)->toEqual($expected_storage_path);
}); });
test('tenant storage can get deleted after the tenant when DeletingTenant listens to DeleteTenantStorage', function () {
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
],
'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/',
'tenancy.filesystem.url_override.public' => 'public-%tenant_id%'
]);
$tenant1 = Tenant::create();
$tenant2 = Tenant::create();
$tenant1StorageUrl = 'http://localhost/public-' . $tenant1->getKey().'/';
$tenant2StorageUrl = 'http://localhost/public-' . $tenant2->getKey().'/';
tenancy()->initialize($tenant1);
$this->assertEquals(
$tenant1StorageUrl,
Storage::disk('public')->url('')
);
Storage::disk('public')->put($tenant1FileName = 'tenant1.txt', 'text');
$this->assertEquals(
$tenant1StorageUrl . $tenant1FileName,
Storage::disk('public')->url($tenant1FileName)
);
tenancy()->initialize($tenant2);
$this->assertEquals(
$tenant2StorageUrl,
Storage::disk('public')->url('')
);
Storage::disk('public')->put($tenant2FileName = 'tenant2.txt', 'text');
$this->assertEquals(
$tenant2StorageUrl . $tenant2FileName,
Storage::disk('public')->url($tenant2FileName)
);
});
test('files can get fetched using the storage url', function() {
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
],
'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/',
'tenancy.filesystem.url_override.public' => 'public-%tenant_id%'
]);
$tenant1 = Tenant::create();
$tenant2 = Tenant::create();
pest()->artisan('tenants:link');
// First tenant
tenancy()->initialize($tenant1);
Storage::disk('public')->put($tenantFileName = 'tenant1.txt', $tenantKey = $tenant1->getTenantKey());
$url = Storage::disk('public')->url($tenantFileName);
$tenantDiskName = Str::of(config('tenancy.filesystem.url_override.public'))->replace('%tenant_id%', $tenantKey);
$hostname = Str::of($url)->before($tenantDiskName);
$parsedUrl = Str::of($url)->after($hostname);
expect(file_get_contents(public_path($parsedUrl)))->toBe($tenantKey);
// Second tenant
tenancy()->initialize($tenant2);
Storage::disk('public')->put($tenantFileName = 'tenant2.txt', $tenantKey = $tenant2->getTenantKey());
$url = Storage::disk('public')->url($tenantFileName);
$tenantDiskName = Str::of(config('tenancy.filesystem.url_override.public'))->replace('%tenant_id%', $tenantKey);
$hostname = Str::of($url)->before($tenantDiskName);
$parsedUrl = Str::of($url)->after($hostname);
expect(file_get_contents(public_path($parsedUrl)))->toBe($tenantKey);
});
test('create and delete storage symlinks jobs work', function() {
Event::listen(
TenantCreated::class,
JobPipeline::make([CreateStorageSymlinks::class])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener()
);
Event::listen(
TenantDeleted::class,
JobPipeline::make([RemoveStorageSymlinks::class])->send(function (TenantDeleted $event) {
return $event->tenant;
})->toListener()
);
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
],
'tenancy.filesystem.suffix_base' => 'tenant-',
'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/',
'tenancy.filesystem.url_override.public' => 'public-%tenant_id%'
]);
/** @var Tenant $tenant */
$tenant = Tenant::create();
tenancy()->initialize($tenant);
$tenantKey = $tenant->getTenantKey();
$this->assertDirectoryExists(storage_path("app/public"));
$this->assertEquals(storage_path("app/public/"), readlink(public_path("public-$tenantKey")));
$tenant->delete();
$this->assertDirectoryDoesNotExist(public_path("public-$tenantKey"));
});
test('local storage public urls are generated correctly', function() {
Event::listen(DeletingTenant::class, DeleteTenantStorage::class);
tenancy()->initialize(Tenant::create());
$tenantStoragePath = storage_path();
Storage::fake('test');
expect(File::isDirectory($tenantStoragePath))->toBeTrue();
Storage::put('test.txt', 'testing file');
tenant()->delete();
expect(File::isDirectory($tenantStoragePath))->toBeFalse();
});
function getDiskPrefix(string $disk): string function getDiskPrefix(string $disk): string
{ {
/** @var FilesystemAdapter $disk */ /** @var FilesystemAdapter $disk */
$disk = Storage::disk($disk); $disk = Storage::disk($disk);
$adapter = $disk->getAdapter(); $adapter = $disk->getAdapter();
if (! Str::startsWith(app()->version(), '9.')) { $prefixer = (new ReflectionObject($adapter))->getProperty('prefixer');
return $adapter->getPathPrefix(); $prefixer->setAccessible(true);
}
$prefixer = (new ReflectionObject($adapter))->getProperty('prefixer'); // reflection -> instance
$prefixer->setAccessible(true); $prefixer = $prefixer->getValue($adapter);
// reflection -> instance $prefix = (new ReflectionProperty($prefixer, 'prefix'));
$prefixer = $prefixer->getValue($adapter); $prefix->setAccessible(true);
$prefix = (new ReflectionProperty($prefixer, 'prefix')); return $prefix->getValue($prefixer);
$prefix->setAccessible(true);
return $prefix->getValue($prefixer);
} }

View file

@ -2,6 +2,8 @@
declare(strict_types=1); declare(strict_types=1);
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
@ -16,6 +18,8 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Tests\Etc\ExampleSeeder; use Stancl\Tenancy\Tests\Etc\ExampleSeeder;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use Stancl\Tenancy\Tests\Etc\TestSeeder;
use Stancl\Tenancy\Tests\Etc\User;
beforeEach(function () { beforeEach(function () {
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
@ -26,6 +30,15 @@ beforeEach(function () {
DatabaseTenancyBootstrapper::class, DatabaseTenancyBootstrapper::class,
]]); ]]);
config([
'tenancy.bootstrappers' => [
DatabaseTenancyBootstrapper::class,
],
'tenancy.filesystem.suffix_base' => 'tenant-',
'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/',
'tenancy.filesystem.url_override.public' => 'public-%tenant_id%'
]);
Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class);
}); });
@ -40,9 +53,9 @@ afterEach(function () {
test('migrate command doesnt change the db connection', function () { test('migrate command doesnt change the db connection', function () {
expect(Schema::hasTable('users'))->toBeFalse(); expect(Schema::hasTable('users'))->toBeFalse();
$old_connection_name = app(\Illuminate\Database\DatabaseManager::class)->connection()->getName(); $old_connection_name = app(DatabaseManager::class)->connection()->getName();
Artisan::call('tenants:migrate'); Artisan::call('tenants:migrate');
$new_connection_name = app(\Illuminate\Database\DatabaseManager::class)->connection()->getName(); $new_connection_name = app(DatabaseManager::class)->connection()->getName();
expect(Schema::hasTable('users'))->toBeFalse(); expect(Schema::hasTable('users'))->toBeFalse();
expect($new_connection_name)->toEqual($old_connection_name); expect($new_connection_name)->toEqual($old_connection_name);
@ -115,8 +128,22 @@ test('rollback command works', function () {
expect(Schema::hasTable('users'))->toBeFalse(); expect(Schema::hasTable('users'))->toBeFalse();
}); });
// Incomplete test test('seed command works', function (){
test('seed command works'); $tenant = Tenant::create();
Artisan::call('tenants:migrate');
$tenant->run(function (){
expect(DB::table('users')->count())->toBe(0);
});
Artisan::call('tenants:seed', ['--class' => TestSeeder::class]);
$tenant->run(function (){
$user = DB::table('users');
expect($user->count())->toBe(1)
->and($user->first()->email)->toBe('seeded@user');
});
});
test('database connection is switched to default', function () { test('database connection is switched to default', function () {
databaseConnectionSwitchedToDefault(); databaseConnectionSwitchedToDefault();
@ -175,8 +202,69 @@ test('run command with array of tenants works', function () {
Artisan::call('tenants:migrate-fresh'); Artisan::call('tenants:migrate-fresh');
pest()->artisan("tenants:run --tenants=$tenantId1 --tenants=$tenantId2 'foo foo --b=bar --c=xyz'") pest()->artisan("tenants:run --tenants=$tenantId1 --tenants=$tenantId2 'foo foo --b=bar --c=xyz'")
->expectsOutput('Tenant: ' . $tenantId1) ->expectsOutputToContain('Tenant: ' . $tenantId1)
->expectsOutput('Tenant: ' . $tenantId2); ->expectsOutputToContain('Tenant: ' . $tenantId2)
->assertExitCode(0);
});
test('link command works', function() {
$tenantId1 = Tenant::create()->getTenantKey();
$tenantId2 = Tenant::create()->getTenantKey();
pest()->artisan('tenants:link');
$this->assertDirectoryExists(storage_path("tenant-$tenantId1/app/public"));
$this->assertEquals(storage_path("tenant-$tenantId1/app/public/"), readlink(public_path("public-$tenantId1")));
$this->assertDirectoryExists(storage_path("tenant-$tenantId2/app/public"));
$this->assertEquals(storage_path("tenant-$tenantId2/app/public/"), readlink(public_path("public-$tenantId2")));
pest()->artisan('tenants:link', [
'--remove' => true,
]);
$this->assertDirectoryDoesNotExist(public_path("public-$tenantId1"));
$this->assertDirectoryDoesNotExist(public_path("public-$tenantId2"));
});
test('link command works with a specified tenant', function() {
$tenantKey = Tenant::create()->getTenantKey();
pest()->artisan('tenants:link', [
'--tenants' => [$tenantKey],
]);
$this->assertDirectoryExists(storage_path("tenant-$tenantKey/app/public"));
$this->assertEquals(storage_path("tenant-$tenantKey/app/public/"), readlink(public_path("public-$tenantKey")));
pest()->artisan('tenants:link', [
'--remove' => true,
'--tenants' => [$tenantKey],
]);
$this->assertDirectoryDoesNotExist(public_path("public-$tenantKey"));
});
test('run command works when sub command asks questions and accepts arguments', function () {
$tenant = Tenant::create();
$id = $tenant->getTenantKey();
Artisan::call('tenants:migrate');
pest()->artisan("tenants:run --tenants=$id 'user:addwithname Abrar' ")
->expectsQuestion('What is your email?', 'email@localhost')
->expectsOutput("Tenant: $id")
->expectsOutput("User created: Abrar(email@localhost)");
// Assert we are in central context
expect(tenancy()->initialized)->toBeFalse();
// Assert user was created in tenant context
tenancy()->initialize($tenant);
$user = User::first();
// Assert user is same as provided using the command
expect($user->name)->toBe('Abrar');
expect($user->email)->toBe('email@localhost');
}); });
// todo@tests // todo@tests

View file

@ -81,7 +81,7 @@ test('tenant can be identified by domain', function () {
test('onfail logic can be customized', function () { test('onfail logic can be customized', function () {
InitializeTenancyByDomain::$onFail = function () { InitializeTenancyByDomain::$onFail = function () {
return 'foo'; return response('foo');
}; };
pest() pest()

View file

@ -2,12 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
namespace Stancl\Tenancy\Tests\Etc; namespace Stancl\Tenancy\Tests\Etc\Console;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Stancl\Tenancy\Concerns\HasATenantsOption; use Stancl\Tenancy\Concerns\HasATenantsOption;
use Stancl\Tenancy\Concerns\TenantAwareCommand; use Stancl\Tenancy\Concerns\TenantAwareCommand;
use Stancl\Tenancy\Tests\Etc\User;
class AddUserCommand extends Command class AddUserCommand extends Command
{ {

View file

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace Stancl\Tenancy\Tests\Etc; namespace Stancl\Tenancy\Tests\Etc\Console;
use Orchestra\Testbench\Foundation\Console\Kernel; use Orchestra\Testbench\Foundation\Console\Kernel;
@ -10,6 +10,7 @@ class ConsoleKernel extends Kernel
{ {
protected $commands = [ protected $commands = [
ExampleCommand::class, ExampleCommand::class,
ExampleQuestionCommand::class,
AddUserCommand::class, AddUserCommand::class,
]; ];
} }

View file

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace Stancl\Tenancy\Tests\Etc; namespace Stancl\Tenancy\Tests\Etc\Console;
use Illuminate\Console\Command; use Illuminate\Console\Command;

View file

@ -0,0 +1,46 @@
<?php
namespace Stancl\Tenancy\Tests\Etc\Console;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
use Stancl\Tenancy\Tests\Etc\User;
class ExampleQuestionCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'user:addwithname {name}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Command description';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$email = $this->ask('What is your email?');
User::create([
'name' => $this->argument('name'),
'email' => $email,
'email_verified_at' => now(),
'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
'remember_token' => Str::random(10),
]);
$this->line("User created: ". $this->argument('name') . "($email)");
return 0;
}
}

View file

@ -7,9 +7,13 @@ namespace Stancl\Tenancy\Tests\Etc;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\Concerns\HasDatabase; use Stancl\Tenancy\Database\Concerns\HasDatabase;
use Stancl\Tenancy\Database\Concerns\HasDomains; use Stancl\Tenancy\Database\Concerns\HasDomains;
use Stancl\Tenancy\Database\Concerns\MaintenanceMode;
use Stancl\Tenancy\Database\Models; use Stancl\Tenancy\Database\Models;
/**
* @method static static create(array $attributes = [])
*/
class Tenant extends Models\Tenant implements TenantWithDatabase class Tenant extends Models\Tenant implements TenantWithDatabase
{ {
use HasDatabase, HasDomains; use HasDatabase, HasDomains, MaintenanceMode;
} }

View file

@ -50,3 +50,17 @@ test('global cache manager stores data in global cache', function () {
expect(cache('def'))->toBe('ghi'); expect(cache('def'))->toBe('ghi');
}); });
test('the global_cache helper supports the same syntax as the cache helper', function () {
$tenant = Tenant::create();
$tenant->enter();
expect(cache('foo'))->toBe(null); // tenant cache is empty
global_cache(['foo' => 'bar']);
expect(global_cache('foo'))->toBe('bar');
global_cache()->set('foo', 'baz');
expect(global_cache()->get('foo'))->toBe('baz');
expect(cache('foo'))->toBe(null); // tenant cache is not affected
});

View file

@ -2,14 +2,14 @@
declare(strict_types=1); declare(strict_types=1);
use Illuminate\Support\Facades\Artisan;
use Stancl\Tenancy\Database\Concerns\MaintenanceMode; use Stancl\Tenancy\Database\Concerns\MaintenanceMode;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Middleware\CheckTenantForMaintenanceMode; use Stancl\Tenancy\Middleware\CheckTenantForMaintenanceMode;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
test('tenant can be in maintenance mode', function () { test('tenants can be in maintenance mode', function () {
Route::get('/foo', function () { Route::get('/foo', function () {
return 'bar'; return 'bar';
})->middleware([InitializeTenancyByDomain::class, CheckTenantForMaintenanceMode::class]); })->middleware([InitializeTenancyByDomain::class, CheckTenantForMaintenanceMode::class]);
@ -19,16 +19,40 @@ test('tenant can be in maintenance mode', function () {
'domain' => 'acme.localhost', 'domain' => 'acme.localhost',
]); ]);
pest()->get('http://acme.localhost/foo') pest()->get('http://acme.localhost/foo')->assertStatus(200);
->assertSuccessful();
tenancy()->end(); // flush stored tenant instance
$tenant->putDownForMaintenance(); $tenant->putDownForMaintenance();
pest()->expectException(HttpException::class); tenancy()->end(); // End tenancy before making a request
pest()->withoutExceptionHandling() pest()->get('http://acme.localhost/foo')->assertStatus(503);
->get('http://acme.localhost/foo');
$tenant->bringUpFromMaintenance();
tenancy()->end(); // End tenancy before making a request
pest()->get('http://acme.localhost/foo')->assertStatus(200);
});
test('tenants can be put into maintenance mode using artisan commands', function() {
Route::get('/foo', function () {
return 'bar';
})->middleware([InitializeTenancyByDomain::class, CheckTenantForMaintenanceMode::class]);
$tenant = MaintenanceTenant::create();
$tenant->domains()->create([
'domain' => 'acme.localhost',
]);
pest()->get('http://acme.localhost/foo')->assertStatus(200);
Artisan::call('tenants:down');
tenancy()->end(); // End tenancy before making a request
pest()->get('http://acme.localhost/foo')->assertStatus(503);
Artisan::call('tenants:up');
tenancy()->end(); // End tenancy before making a request
pest()->get('http://acme.localhost/foo')->assertStatus(200);
}); });
class MaintenanceTenant extends Tenant class MaintenanceTenant extends Tenant

View file

@ -18,7 +18,11 @@ beforeEach(function () {
], function () { ], function () {
Route::get('/foo/{a}/{b}', function ($a, $b) { Route::get('/foo/{a}/{b}', function ($a, $b) {
return "$a + $b"; return "$a + $b";
}); })->name('foo');
Route::get('/baz/{a}/{b}', function ($a, $b) {
return "$a - $b";
})->name('baz');
}); });
}); });
@ -67,7 +71,7 @@ test('exception is thrown when tenant cannot be identified by path', function ()
test('onfail logic can be customized', function () { test('onfail logic can be customized', function () {
InitializeTenancyByPath::$onFail = function () { InitializeTenancyByPath::$onFail = function () {
return 'foo'; return response('foo');
}; };
pest() pest()
@ -123,3 +127,23 @@ test('tenant parameter name can be customized', function () {
->withoutExceptionHandling() ->withoutExceptionHandling()
->get('/acme/foo/abc/xyz'); ->get('/acme/foo/abc/xyz');
}); });
test('tenant parameter is set for all routes as the default parameter once the tenancy initialized', function () {
Tenant::create([
'id' => 'acme',
]);
expect(tenancy()->initialized)->toBeFalse();
// make a request that will initialize tenancy
pest()->get(route('foo', ['tenant' => 'acme', 'a' => 1, 'b' => 2]));
expect(tenancy()->initialized)->toBeTrue();
expect(tenant('id'))->toBe('acme');
// assert that the route WITHOUT the tenant parameter matches the route WITH the tenant parameter
expect(route('baz', ['a' => 1, 'b' => 2]))->toBe(route('baz', ['tenant' => 'acme', 'a' => 1, 'b' => 2]));
expect(route('baz', ['a' => 1, 'b' => 2]))->toBe('http://localhost/acme/baz/1/2'); // assert the full route string
pest()->get(route('baz', ['a' => 1, 'b' => 2]))->assertOk(); // Assert route don't need tenant parameter
});

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