diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 07ecbc6a..724aed35 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,7 +11,7 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
- container: abrardev/tenancy:latest
+ container: archtechx/tenancy:latest
strategy:
matrix:
diff --git a/.gitignore b/.gitignore
index 64d9dc21..5a5960b0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
.env
+.DS_Store
composer.lock
vendor/
.vscode/
diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php
index 589838bc..7e649ea5 100644
--- a/.php-cs-fixer.php
+++ b/.php-cs-fixer.php
@@ -10,6 +10,7 @@ $rules = [
'operators' => [
'=>' => null,
'|' => 'no_space',
+ '&' => 'no_space',
]
],
'blank_line_after_namespace' => true,
diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php
index 865bb93d..a38aee42 100644
--- a/assets/TenancyServiceProvider.stub.php
+++ b/assets/TenancyServiceProvider.stub.php
@@ -28,6 +28,7 @@ class TenancyServiceProvider extends ServiceProvider
Jobs\CreateDatabase::class,
Jobs\MigrateDatabase::class,
// Jobs\SeedDatabase::class,
+ Jobs\CreateStorageSymlinks::class,
// Your own jobs to prepare the tenant.
// Provision API keys, create S3 buckets, anything you want!
@@ -46,14 +47,25 @@ class TenancyServiceProvider extends ServiceProvider
])->send(function (Events\DeletingTenant $event) {
return $event->tenant;
})->shouldBeQueued(false),
+
+ // Listeners\DeleteTenantStorage::class,
],
Events\TenantDeleted::class => [
JobPipeline::make([
Jobs\DeleteDatabase::class,
+ Jobs\RemoveStorageSymlinks::class,
])->send(function (Events\TenantDeleted $event) {
return $event->tenant;
})->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production.
],
+ Events\TenantMaintenanceModeEnabled::class => [],
+ Events\TenantMaintenanceModeDisabled::class => [],
+
+ // Pending tenant events
+ Events\CreatingPendingTenant::class => [],
+ Events\PendingTenantCreated::class => [],
+ Events\PullingPendingTenant::class => [],
+ Events\PendingTenantPulled::class => [],
// Domain events
Events\CreatingDomain::class => [],
@@ -93,6 +105,12 @@ class TenancyServiceProvider extends ServiceProvider
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)
Events\SyncedResourceChangedInForeignDatabase::class => [],
];
@@ -134,16 +152,8 @@ class TenancyServiceProvider extends ServiceProvider
protected function makeTenancyMiddlewareHighestPriority()
{
- $tenancyMiddleware = [
- // Even higher priority than the initialization middleware
- Middleware\PreventAccessFromCentralDomains::class,
-
- Middleware\InitializeTenancyByDomain::class,
- Middleware\InitializeTenancyBySubdomain::class,
- Middleware\InitializeTenancyByDomainOrSubdomain::class,
- Middleware\InitializeTenancyByPath::class,
- Middleware\InitializeTenancyByRequestData::class,
- ];
+ // PreventAccessFromCentralDomains has even higher priority than the identification middleware
+ $tenancyMiddleware = array_merge([Middleware\PreventAccessFromCentralDomains::class], config('tenancy.identification.middleware'));
foreach (array_reverse($tenancyMiddleware) as $middleware) {
$this->app[\Illuminate\Contracts\Http\Kernel::class]->prependToMiddlewarePriority($middleware);
diff --git a/assets/config.php b/assets/config.php
index 2a54e0b9..cfde54ac 100644
--- a/assets/config.php
+++ b/assets/config.php
@@ -2,14 +2,14 @@
declare(strict_types=1);
-use Stancl\Tenancy\Database\Models\Domain;
-use Stancl\Tenancy\Database\Models\Tenant;
+use Stancl\Tenancy\Middleware;
+use Stancl\Tenancy\Resolvers;
return [
- 'tenant_model' => Tenant::class,
- 'id_generator' => Stancl\Tenancy\UUIDGenerator::class,
+ 'tenant_model' => Stancl\Tenancy\Database\Models\Tenant::class,
+ 'domain_model' => Stancl\Tenancy\Database\Models\Domain::class,
- 'domain_model' => Domain::class,
+ 'id_generator' => Stancl\Tenancy\UUIDGenerator::class,
/**
* The list of domains hosting your central app.
@@ -21,6 +21,56 @@ return [
'localhost',
],
+ 'identification' => [
+ /**
+ * The default middleware used for tenant identification.
+ *
+ * If you use multiple forms of identification, you can set this to the "main" approach you use.
+ */
+ 'default_middleware' => Middleware\InitializeTenancyByDomain::class,// todo@identification add this to a 'tenancy' mw group
+
+ /**
+ * All of the identification middleware used by the package.
+ *
+ * If you write your own, make sure to add them to this array.
+ */
+ 'middleware' => [
+ Middleware\InitializeTenancyByDomain::class,
+ Middleware\InitializeTenancyBySubdomain::class,
+ Middleware\InitializeTenancyByDomainOrSubdomain::class,
+ Middleware\InitializeTenancyByPath::class,
+ Middleware\InitializeTenancyByRequestData::class,
+ ],
+
+ /**
+ * Tenant resolvers used by the package.
+ *
+ * Resolvers which implement the CachedTenantResolver contract have options for configuring the caching details.
+ * If you add your own resolvers, do not add the 'cache' key unless your resolver is based on CachedTenantResolver.
+ */
+ 'resolvers' => [
+ Resolvers\DomainTenantResolver::class => [
+ 'cache' => false,
+ 'cache_ttl' => 3600, // seconds
+ 'cache_store' => null, // default
+ ],
+ Resolvers\PathTenantResolver::class => [
+ 'tenant_parameter_name' => 'tenant',
+
+ 'cache' => false,
+ 'cache_ttl' => 3600, // seconds
+ 'cache_store' => null, // default
+ ],
+ Resolvers\RequestDataTenantResolver::class => [
+ 'cache' => false,
+ 'cache_ttl' => 3600, // seconds
+ 'cache_store' => null, // default
+ ],
+ ],
+
+ // todo@docs update integration guides to use Stancl\Tenancy::defaultMiddleware()
+ ],
+
/**
* Tenancy bootstrappers are executed when tenancy is initialized.
* Their responsibility is making Laravel features tenant-aware.
@@ -32,9 +82,29 @@ return [
Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper::class,
Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class,
Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class,
+ Stancl\Tenancy\Bootstrappers\BatchTenancyBootstrapper::class,
// Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed
],
+
+ /**
+ * Pending tenants config.
+ * This is useful if you're looking for a way to always have a tenant ready to be used.
+ */
+ 'pending' => [
+ /**
+ * If disabled, pending tenants will be excluded from all tenant queries.
+ * You can still use ::withPending(), ::withoutPending() and ::onlyPending() to include or exclude the pending tenants regardless of this setting.
+ * Note: when disabled, this will also ignore pending tenants when running the tenant commands (migration, seed, etc.)
+ */
+ 'include_in_queries' => true,
+ /**
+ * Defines how many pending tenants you want to have ready in the pending tenant pool.
+ * This depends on the volume of tenants you're creating.
+ */
+ 'count' => env('TENANCY_PENDING_COUNT', 5),
+ ],
+
/**
* Database tenancy config. Used by DatabaseTenancyBootstrapper.
*/
@@ -47,6 +117,11 @@ return [
*/
'template_tenant_connection' => null,
+ /**
+ * The name of the temporary connection used for creating and deleting tenant databases.
+ */
+ 'tenant_host_connection_name' => 'tenant_host_connection',
+
/**
* Tenant database names are created like this:
* prefix + tenant_id + suffix.
@@ -63,18 +138,21 @@ return [
'pgsql' => Stancl\Tenancy\Database\TenantDatabaseManagers\PostgreSQLDatabaseManager::class,
'sqlsrv' => Stancl\Tenancy\Database\TenantDatabaseManagers\MicrosoftSQLDatabaseManager::class,
- /**
- * Use this database manager for MySQL to have a DB user created for each tenant database.
- * You can customize the grants given to these users by changing the $grants property.
- */
+ /**
+ * Use this database manager for MySQL to have a DB user created for each tenant database.
+ * You can customize the grants given to these users by changing the $grants property.
+ */
// 'mysql' => Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMySQLDatabaseManager::class,
- /**
- * Disable the pgsql manager above, and enable the one below if you
- * want to separate tenant DBs by schemas rather than databases.
- */
+ /**
+ * Disable the pgsql manager above, and enable the one below if you
+ * want to separate tenant DBs by schemas rather than databases.
+ */
// 'pgsql' => Stancl\Tenancy\Database\TenantDatabaseManagers\PostgreSQLSchemaManager::class, // Separate by schema instead of database
],
+
+ // todo docblock
+ 'drop_tenant_databases_on_migrate_fresh' => false,
],
/**
@@ -118,6 +196,24 @@ return [
'public' => '%storage_path%/app/public/',
],
+ /*
+ * 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.
*
@@ -186,6 +282,7 @@ return [
'migration_parameters' => [
'--force' => true, // This needs to be true to run migrations in production.
'--path' => [database_path('migrations/tenant')],
+ '--schema-path' => database_path('schema/tenant-schema.dump'),
'--realpath' => true,
],
@@ -193,7 +290,15 @@ return [
* Parameters used by the tenants:seed command.
*/
'seeder_parameters' => [
- '--class' => 'DatabaseSeeder', // root seeder class
+ '--class' => 'Database\Seeders\DatabaseSeeder', // root seeder class
// '--force' => true,
],
+
+ /**
+ * Single-database tenancy config.
+ */
+ 'single_db' => [
+ /** The name of the column used by models with the BelongsToTenant trait. */
+ 'tenant_id_column' => 'tenant_id',
+ ],
];
diff --git a/assets/routes.php b/assets/routes.php
index 9223c099..a27f782d 100644
--- a/assets/routes.php
+++ b/assets/routes.php
@@ -3,7 +3,8 @@
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
+use Stancl\Tenancy\Controllers\TenantAssetController;
-Route::get('/tenancy/assets/{path?}', 'Stancl\Tenancy\Controllers\TenantAssetsController@asset')
+Route::get('/tenancy/assets/{path?}', [TenantAssetController::class, 'asset'])
->where('path', '(.*)')
->name('stancl.tenancy.asset');
diff --git a/composer.json b/composer.json
index ff5befd9..587bbb06 100644
--- a/composer.json
+++ b/composer.json
@@ -18,10 +18,10 @@
"php": "^8.1",
"ext-json": "*",
"illuminate/support": "^9.0",
- "facade/ignition-contracts": "^1.0",
+ "spatie/ignition": "^1.4",
"ramsey/uuid": "^4.0",
"stancl/jobpipeline": "^1.0",
- "stancl/virtualcolumn": "^1.0"
+ "stancl/virtualcolumn": "^1.3"
},
"require-dev": {
"laravel/framework": "^9.0",
@@ -30,7 +30,8 @@
"doctrine/dbal": "^2.10",
"spatie/valuestore": "^1.2.5",
"pestphp/pest": "^1.21",
- "nunomaduro/larastan": "^1.0"
+ "nunomaduro/larastan": "^1.0",
+ "spatie/invade": "^1.1"
},
"autoload": {
"psr-4": {
@@ -62,7 +63,8 @@
"docker-rebuild": "PHP_VERSION=8.1 docker-compose up -d --no-deps --build",
"docker-m1": "ln -s docker-compose-m1.override.yml docker-compose.override.yml",
"coverage": "open coverage/phpunit/html/index.html",
- "phpstan": "vendor/bin/phpstan",
+ "phpstan": "vendor/bin/phpstan --pro",
+ "cs": "php-cs-fixer fix --config=.php-cs-fixer.php",
"test": "PHP_VERSION=8.1 ./test --no-coverage",
"test-full": "PHP_VERSION=8.1 ./test"
},
diff --git a/docker-compose.yml b/docker-compose.yml
index 7b635637..116b48f1 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -12,6 +12,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
+ # mssql:
+ # condition: service_healthy
volumes:
- .:/var/www/html:delegated
environment:
@@ -74,4 +76,8 @@ services:
environment:
- ACCEPT_EULA=Y
- 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
diff --git a/phpstan.neon b/phpstan.neon
index 9ff082dd..0567d5ff 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -1,5 +1,6 @@
includes:
- ./vendor/nunomaduro/larastan/extension.neon
+ - ./vendor/spatie/invade/phpstan-extension.neon
parameters:
paths:
@@ -10,16 +11,39 @@ parameters:
universalObjectCratesClasses:
- Illuminate\Routing\Route
+ - Illuminate\Database\Eloquent\Model
ignoreErrors:
- -
- message: '#Cannot access offset (.*?) on Illuminate\\Contracts\\Foundation\\Application#'
- paths:
- - src/TenancyServiceProvider.php
+ - '#Cannot access offset (.*?) on Illuminate\\Contracts\\Foundation\\Application#'
+ - '#Cannot access offset (.*?) on Illuminate\\Contracts\\Config\\Repository#'
-
message: '#invalid type Laravel\\Telescope\\IncomingEntry#'
paths:
- 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
treatPhpDocTypesAsCertain: false
diff --git a/phpunit.xml b/phpunit.xml
index 28fc8a08..9d2b9339 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -35,4 +35,4 @@
-
\ No newline at end of file
+
diff --git a/src/Actions/CreateStorageSymlinksAction.php b/src/Actions/CreateStorageSymlinksAction.php
new file mode 100644
index 00000000..eac5d933
--- /dev/null
+++ b/src/Actions/CreateStorageSymlinksAction.php
@@ -0,0 +1,55 @@
+ $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)));
+ }
+}
diff --git a/src/Actions/RemoveStorageSymlinksAction.php b/src/Actions/RemoveStorageSymlinksAction.php
new file mode 100644
index 00000000..a3660e7a
--- /dev/null
+++ b/src/Actions/RemoveStorageSymlinksAction.php
@@ -0,0 +1,40 @@
+ $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));
+ }
+ }
+}
diff --git a/src/Bootstrappers/BatchTenancyBootstrapper.php b/src/Bootstrappers/BatchTenancyBootstrapper.php
new file mode 100644
index 00000000..589bdac0
--- /dev/null
+++ b/src/Bootstrappers/BatchTenancyBootstrapper.php
@@ -0,0 +1,41 @@
+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;
+ }
+ }
+}
diff --git a/src/Bootstrappers/CacheTenancyBootstrapper.php b/src/Bootstrappers/CacheTenancyBootstrapper.php
index bef156d2..29547fae 100644
--- a/src/Bootstrappers/CacheTenancyBootstrapper.php
+++ b/src/Bootstrappers/CacheTenancyBootstrapper.php
@@ -13,18 +13,14 @@ use Stancl\Tenancy\Contracts\Tenant;
class CacheTenancyBootstrapper implements TenancyBootstrapper
{
- /** @var CacheManager */
- protected $originalCache;
+ protected ?CacheManager $originalCache = null;
- /** @var Application */
- protected $app;
-
- public function __construct(Application $app)
- {
- $this->app = $app;
+ public function __construct(
+ protected Application $app
+ ) {
}
- public function bootstrap(Tenant $tenant)
+ public function bootstrap(Tenant $tenant): void
{
$this->resetFacadeCache();
@@ -34,7 +30,7 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper
});
}
- public function revert()
+ public function revert(): void
{
$this->resetFacadeCache();
@@ -50,7 +46,7 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper
* facade has been made prior to bootstrapping tenancy. The
* facade has its own cache, separate from the container.
*/
- public function resetFacadeCache()
+ public function resetFacadeCache(): void
{
Cache::clearResolvedInstances();
}
diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php
index dd94bfd4..c6dba079 100644
--- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php
+++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php
@@ -8,7 +8,7 @@ use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\DatabaseManager;
-use Stancl\Tenancy\Exceptions\TenantDatabaseDoesNotExistException;
+use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException;
class DatabaseTenancyBootstrapper implements TenancyBootstrapper
{
@@ -20,7 +20,7 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper
$this->database = $database;
}
- public function bootstrap(Tenant $tenant)
+ public function bootstrap(Tenant $tenant): void
{
/** @var TenantWithDatabase $tenant */
@@ -35,7 +35,7 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper
$this->database->connectToTenant($tenant);
}
- public function revert()
+ public function revert(): void
{
$this->database->reconnectToCentral();
}
diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php
index 6f720e7c..e9e0d93d 100644
--- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php
+++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers;
use Illuminate\Contracts\Foundation\Application;
+use Illuminate\Routing\UrlGenerator;
use Illuminate\Support\Facades\Storage;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
@@ -27,13 +28,14 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
];
$this->app['url']->macro('setAssetRoot', function ($root) {
+ /** @var UrlGenerator $this */
$this->assetRoot = $root;
return $this;
});
}
- public function bootstrap(Tenant $tenant)
+ public function bootstrap(Tenant $tenant): void
{
$suffix = $this->app['config']['tenancy.filesystem.suffix_base'] . $tenant->getTenantKey();
@@ -45,7 +47,7 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
// asset()
if ($this->app['config']['tenancy.filesystem.asset_helper_tenancy'] ?? true) {
if ($this->originalPaths['asset_url']) {
- $this->app['config']['app.asset_url'] = ($this->originalPaths['asset_url'] ?? $this->app['config']['app.url']) . "/$suffix";
+ $this->app['config']['app.asset_url'] = $this->originalPaths['asset_url'] . "/$suffix";
$this->app['url']->setAssetRoot($this->app['config']['app.asset_url']);
} else {
$this->app['url']->setAssetRoot($this->app['url']->route('stancl.tenancy.asset', ['path' => '']));
@@ -57,9 +59,10 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
foreach ($this->app['config']['tenancy.filesystem.disks'] as $disk) {
// 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'][$disk] = $originalRoot;
+ $this->originalPaths['disks']['path'][$disk] = $originalRoot;
$finalPrefix = str_replace(
['%storage_path%', '%tenant%'],
@@ -74,10 +77,23 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
}
$this->app['config']["filesystems.disks.{$disk}.root"] = $finalPrefix;
+
+ // Storage Url
+ if ($diskConfig['driver'] === 'local') {
+ $this->originalPaths['disks']['url'][$disk] = $diskConfig['url'] ?? null;
+
+ if ($url = str_replace(
+ '%tenant_id%',
+ (string) $tenant->getTenantKey(),
+ $this->app['config']["tenancy.filesystem.url_override.{$disk}"] ?? ''
+ )) {
+ $this->app['config']["filesystems.disks.{$disk}.url"] = url($url);
+ }
+ }
}
}
- public function revert()
+ public function revert(): void
{
// storage_path()
$this->app->useStoragePath($this->originalPaths['storage']);
@@ -88,8 +104,16 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
// Storage facade
Storage::forgetDisk($this->app['config']['tenancy.filesystem.disks']);
- foreach ($this->app['config']['tenancy.filesystem.disks'] as $disk) {
- $this->app['config']["filesystems.disks.{$disk}.root"] = $this->originalPaths['disks'][$disk];
+ foreach ($this->app['config']['tenancy.filesystem.disks'] as $diskName) {
+ $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;
+ }
}
}
}
diff --git a/src/Bootstrappers/Integrations/ScoutTenancyBootstrapper.php b/src/Bootstrappers/Integrations/ScoutTenancyBootstrapper.php
new file mode 100644
index 00000000..da5a921a
--- /dev/null
+++ b/src/Bootstrappers/Integrations/ScoutTenancyBootstrapper.php
@@ -0,0 +1,38 @@
+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();
+ }
+}
diff --git a/src/Bootstrappers/QueueTenancyBootstrapper.php b/src/Bootstrappers/QueueTenancyBootstrapper.php
index 2f859ecd..5b6ef4d8 100644
--- a/src/Bootstrappers/QueueTenancyBootstrapper.php
+++ b/src/Bootstrappers/QueueTenancyBootstrapper.php
@@ -39,7 +39,7 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
* However, we're registering a hook to initialize tenancy. Therefore,
* 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());
}
@@ -52,7 +52,7 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
$this->setUpPayloadGenerator();
}
- protected static function setUpJobListener($dispatcher, $runningTests)
+ protected static function setUpJobListener(Dispatcher $dispatcher, bool $runningTests): void
{
$previousTenant = null;
@@ -62,14 +62,11 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
static::initializeTenancyForQueue($event->job->payload()['tenant_id'] ?? null);
});
- if (version_compare(app()->version(), '8.64', '>=')) {
- // JobRetryRequested only exists since Laravel 8.64
- $dispatcher->listen(JobRetryRequested::class, function ($event) use (&$previousTenant) {
- $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
$revertToPreviousState = function ($event) use (&$previousTenant, $runningTests) {
@@ -82,7 +79,7 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
$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) {
// The job is not tenant-aware
@@ -100,7 +97,9 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
tenancy()->end();
}
- tenancy()->initialize(tenancy()->find($tenantId));
+ /** @var Tenant $tenant */
+ $tenant = tenancy()->find($tenantId);
+ tenancy()->initialize($tenant);
return;
}
@@ -115,10 +114,13 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
// Tenancy was either not initialized, or initialized for a different 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;
@@ -138,7 +140,7 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
}
}
- protected function setUpPayloadGenerator()
+ protected function setUpPayloadGenerator(): void
{
$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) {
return [];
diff --git a/src/Bootstrappers/RedisTenancyBootstrapper.php b/src/Bootstrappers/RedisTenancyBootstrapper.php
index 7536984e..975a37d5 100644
--- a/src/Bootstrappers/RedisTenancyBootstrapper.php
+++ b/src/Bootstrappers/RedisTenancyBootstrapper.php
@@ -22,18 +22,21 @@ class RedisTenancyBootstrapper implements TenancyBootstrapper
$this->config = $config;
}
- public function bootstrap(Tenant $tenant)
+ public function bootstrap(Tenant $tenant): void
{
foreach ($this->prefixedConnections() as $connection) {
$prefix = $this->config['tenancy.redis.prefix_base'] . $tenant->getTenantKey();
$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);
}
}
- public function revert()
+ public function revert(): void
{
foreach ($this->prefixedConnections() as $connection) {
$client = Redis::connection($connection)->client();
@@ -44,7 +47,8 @@ class RedisTenancyBootstrapper implements TenancyBootstrapper
$this->originalPrefixes = [];
}
- protected function prefixedConnections()
+ /** @return string[] */
+ protected function prefixedConnections(): array
{
return $this->config['tenancy.redis.prefixed_connections'];
}
diff --git a/src/Commands/ClearPendingTenants.php b/src/Commands/ClearPendingTenants.php
new file mode 100644
index 00000000..18d9fa42
--- /dev/null
+++ b/src/Commands/ClearPendingTenants.php
@@ -0,0 +1,74 @@
+info('Removing pending tenants.');
+
+ $expirationDate = now();
+ // We compare the original expiration date to the new one to check if the new one is different later
+ $originalExpirationDate = $expirationDate->copy()->toImmutable();
+
+ // Skip the time constraints if the 'all' option is given
+ if (! $this->option('all')) {
+ $olderThanDays = $this->option('older-than-days');
+ $olderThanHours = $this->option('older-than-hours');
+
+ if ($olderThanDays && $olderThanHours) {
+ $this->line(" Cannot use '--older-than-days' and '--older-than-hours' together \n"); // todo@cli refactor all of these styled command outputs to use $this->components
+ $this->line('Please, choose only one of these options.');
+
+ return 1; // Exit code for failure
+ }
+
+ if ($olderThanDays) {
+ $expirationDate->subDays($olderThanDays);
+ }
+
+ if ($olderThanHours) {
+ $expirationDate->subHours($olderThanHours);
+ }
+ }
+
+ $deletedTenantCount = tenancy()
+ ->query()
+ ->onlyPending()
+ ->when($originalExpirationDate->notEqualTo($expirationDate), function (Builder $query) use ($expirationDate) {
+ $query->where($query->getModel()->getColumnForQuery('pending_since'), '<', $expirationDate->timestamp);
+ })
+ ->get()
+ ->each // Trigger the model events by deleting the tenants one by one
+ ->delete()
+ ->count();
+
+ $this->info($deletedTenantCount . ' pending ' . str('tenant')->plural($deletedTenantCount) . ' deleted.');
+ }
+}
diff --git a/src/Commands/CreatePendingTenants.php b/src/Commands/CreatePendingTenants.php
new file mode 100644
index 00000000..88202093
--- /dev/null
+++ b/src/Commands/CreatePendingTenants.php
@@ -0,0 +1,62 @@
+info('Creating pending tenants.');
+
+ $maxPendingTenantCount = (int) ($this->option('count') ?? config('tenancy.pending.count'));
+ $pendingTenantCount = $this->getPendingTenantCount();
+ $createdCount = 0;
+
+ while ($pendingTenantCount < $maxPendingTenantCount) {
+ tenancy()->model()::createPending();
+
+ // Fetching the pending tenant count in each iteration prevents creating too many tenants
+ // If pending tenants are being created somewhere else while running this command
+ $pendingTenantCount = $this->getPendingTenantCount();
+
+ $createdCount++;
+ }
+
+ $this->info($createdCount . ' ' . str('tenant')->plural($createdCount) . ' created.');
+ $this->info($maxPendingTenantCount . ' ' . str('tenant')->plural($maxPendingTenantCount) . ' ready to be used.');
+
+ return 1;
+ }
+
+ /**
+ * Calculate the number of currently available pending tenants.
+ */
+ private function getPendingTenantCount(): int
+ {
+ return tenancy()
+ ->query()
+ ->onlyPending()
+ ->count();
+ }
+}
diff --git a/src/Commands/Down.php b/src/Commands/Down.php
new file mode 100644
index 00000000..3b68bcb2
--- /dev/null
+++ b/src/Commands/Down.php
@@ -0,0 +1,53 @@
+getDownDatabasePayload();
+
+ tenancy()->runForMultiple($this->getTenants(), function ($tenant) use ($payload) {
+ $this->components->info("Tenant: {$tenant->getTenantKey()}");
+ $tenant->putDownForMaintenance($payload);
+ });
+
+ $this->components->info('Tenants are now in maintenance mode.');
+
+ return 0;
+ }
+
+ /**
+ * Get the payload to be placed in the "down" file. This
+ * payload is the same as the original function
+ * but without the 'template' option.
+ */
+ protected function getDownDatabasePayload(): array
+ {
+ return [
+ 'except' => $this->excludedPaths(),
+ 'redirect' => $this->redirectPath(),
+ 'retry' => $this->getRetryTime(),
+ 'refresh' => $this->option('refresh'),
+ 'secret' => $this->option('secret'),
+ 'status' => (int) ($this->option('status') ?? 503),
+ ];
+ }
+}
diff --git a/src/Commands/Install.php b/src/Commands/Install.php
index 41492b26..77c96588 100644
--- a/src/Commands/Install.php
+++ b/src/Commands/Install.php
@@ -4,63 +4,136 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Commands;
+use Closure;
use Illuminate\Console\Command;
class Install extends Command
{
- /**
- * The name and signature of the console command.
- *
- * @var string
- */
protected $signature = 'tenancy:install';
- /**
- * The console command description.
- *
- * @var string
- */
- protected $description = 'Install stancl/tenancy.';
+ protected $description = 'Install Tenancy for Laravel.';
- /**
- * Execute the console command.
- */
- public function handle()
+ public function handle(): int
{
- $this->comment('Installing stancl/tenancy...');
- $this->callSilent('vendor:publish', [
- '--provider' => 'Stancl\Tenancy\TenancyServiceProvider',
- '--tag' => 'config',
- ]);
- $this->info('✔️ Created config/tenancy.php');
+ $this->step(
+ name: 'Publishing config file',
+ tag: 'config',
+ file: 'config/tenancy.php',
+ newLineBefore: true,
+ );
- if (! file_exists(base_path('routes/tenant.php'))) {
- $this->callSilent('vendor:publish', [
+ $this->step(
+ name: 'Publishing routes',
+ tag: 'routes',
+ file: 'routes/tenant.php',
+ );
+
+ $this->step(
+ name: 'Publishing service provider',
+ tag: 'providers',
+ file: 'app/Providers/TenancyServiceProvider.php',
+ );
+
+ $this->step(
+ name: 'Publishing migrations',
+ tag: 'migrations',
+ files: [
+ 'database/migrations/2019_09_15_000010_create_tenants_table.php',
+ 'database/migrations/2019_09_15_000020_create_domains_table.php',
+ ],
+ warning: 'Migrations already exist',
+ );
+
+ $this->step(
+ name: 'Creating [database/migrations/tenant] folder',
+ task: fn () => mkdir(database_path('migrations/tenant')),
+ unless: is_dir(database_path('migrations/tenant')),
+ warning: 'Folder [database/migrations/tenant] already exists.',
+ newLineAfter: true,
+ );
+
+ $this->components->info('✨️ Tenancy for Laravel successfully installed.');
+
+ $this->askForSupport();
+
+ return 0;
+ }
+
+ /**
+ * Run a step of the installation process.
+ *
+ * @param string $name The name of the step.
+ * @param Closure|null $task The task code.
+ * @param bool $unless Condition specifying when the task should NOT run.
+ * @param string|null $warning Warning shown when the $unless condition is true.
+ * @param string|null $file Name of the file being added.
+ * @param string|null $tag The tag being published.
+ * @param array|null $files Names of files being added.
+ * @param bool $newLineBefore Should a new line be printed after the step.
+ * @param bool $newLineAfter Should a new line be printed after the step.
+ */
+ protected function step(
+ string $name,
+ Closure $task = null,
+ bool $unless = false,
+ string $warning = null,
+ string $file = null,
+ string $tag = null,
+ array $files = null,
+ bool $newLineBefore = false,
+ bool $newLineAfter = false,
+ ): void {
+ if ($file) {
+ $name .= " [$file]"; // Append clickable path to the task name
+ $unless = file_exists(base_path($file)); // Make the condition a check for the file's existence
+ $warning = "File [$file] already exists."; // Make the warning a message about the file already existing
+ }
+
+ if ($tag) {
+ $task = fn () => $this->callSilent('vendor:publish', [
'--provider' => 'Stancl\Tenancy\TenancyServiceProvider',
- '--tag' => 'routes',
+ '--tag' => $tag,
]);
- $this->info('✔️ Created routes/tenant.php');
+ }
+
+ if ($files) {
+ // Show a warning if any of the files already exist
+ $unless = count(array_filter($files, fn ($file) => file_exists(base_path($file)))) !== 0;
+ }
+
+ if (! $unless) {
+ if ($newLineBefore) {
+ $this->newLine();
+ }
+
+ $this->components->task($name, $task ?? fn () => null);
+
+ if ($files) {
+ // Print out a clickable list of the added files
+ $this->components->bulletList(array_map(fn (string $file) => "[$file]", $files));
+ }
+
+ if ($newLineAfter) {
+ $this->newLine();
+ }
} else {
- $this->info('Found routes/tenant.php.');
+ $this->components->warn($warning);
}
+ }
- $this->callSilent('vendor:publish', [
- '--provider' => 'Stancl\Tenancy\TenancyServiceProvider',
- '--tag' => 'providers',
- ]);
- $this->info('✔️ Created TenancyServiceProvider.php');
-
- $this->callSilent('vendor:publish', [
- '--provider' => 'Stancl\Tenancy\TenancyServiceProvider',
- '--tag' => 'migrations',
- ]);
- $this->info('✔️ Created migrations. Remember to run [php artisan migrate]!');
-
- if (! is_dir(database_path('migrations/tenant'))) {
- mkdir(database_path('migrations/tenant'));
- $this->info('✔️ Created database/migrations/tenant folder.');
+ /** If the user accepts, opens the GitHub project in the browser. */
+ public function askForSupport(): void
+ {
+ if ($this->components->confirm('Would you like to show your support by starring the project on GitHub?', true)) {
+ if (PHP_OS_FAMILY === 'Darwin') {
+ exec('open https://github.com/archtechx/tenancy');
+ }
+ if (PHP_OS_FAMILY === 'Windows') {
+ exec('start https://github.com/archtechx/tenancy');
+ }
+ if (PHP_OS_FAMILY === 'Linux') {
+ exec('xdg-open https://github.com/archtechx/tenancy');
+ }
}
-
- $this->comment('✨️ stancl/tenancy installed successfully.');
}
}
diff --git a/src/Commands/Link.php b/src/Commands/Link.php
new file mode 100644
index 00000000..a6dd6c5f
--- /dev/null
+++ b/src/Commands/Link.php
@@ -0,0 +1,62 @@
+getTenants();
+
+ try {
+ if ($this->option('remove')) {
+ $this->removeLinks($tenants);
+ } else {
+ $this->createLinks($tenants);
+ }
+ } catch (Exception $exception) {
+ $this->error($exception->getMessage());
+
+ return 1;
+ }
+
+ return 0;
+ }
+
+ protected function removeLinks(LazyCollection $tenants): void
+ {
+ RemoveStorageSymlinksAction::handle($tenants);
+
+ $this->components->info('The links have been removed.');
+ }
+
+ protected function createLinks(LazyCollection $tenants): void
+ {
+ CreateStorageSymlinksAction::handle(
+ $tenants,
+ (bool) ($this->option('relative') ?? false),
+ (bool) ($this->option('force') ?? false),
+ );
+
+ $this->components->info('The links have been created.');
+ }
+}
diff --git a/src/Commands/Migrate.php b/src/Commands/Migrate.php
index 52ecd47f..0d2fceaa 100644
--- a/src/Commands/Migrate.php
+++ b/src/Commands/Migrate.php
@@ -9,13 +9,13 @@ use Illuminate\Database\Console\Migrations\MigrateCommand;
use Illuminate\Database\Migrations\Migrator;
use Stancl\Tenancy\Concerns\DealsWithMigrations;
use Stancl\Tenancy\Concerns\ExtendsLaravelCommand;
-use Stancl\Tenancy\Concerns\HasATenantsOption;
+use Stancl\Tenancy\Concerns\HasTenantOptions;
use Stancl\Tenancy\Events\DatabaseMigrated;
use Stancl\Tenancy\Events\MigratingDatabase;
class Migrate extends MigrateCommand
{
- use HasATenantsOption, DealsWithMigrations, ExtendsLaravelCommand;
+ use HasTenantOptions, DealsWithMigrations, ExtendsLaravelCommand;
protected $description = 'Run migrations for tenant(s)';
@@ -31,10 +31,7 @@ class Migrate extends MigrateCommand
$this->specifyParameters();
}
- /**
- * Execute the console command.
- */
- public function handle()
+ public function handle(): int
{
foreach (config('tenancy.migration_parameters') as $parameter => $value) {
if (! $this->input->hasParameterOption($parameter)) {
@@ -43,11 +40,11 @@ class Migrate extends MigrateCommand
}
if (! $this->confirmToProceed()) {
- return;
+ return 1;
}
- tenancy()->runForMultiple($this->option('tenants'), function ($tenant) {
- $this->line("Tenant: {$tenant->getTenantKey()}");
+ tenancy()->runForMultiple($this->getTenants(), function ($tenant) {
+ $this->components->info("Tenant: {$tenant->getTenantKey()}");
event(new MigratingDatabase($tenant));
@@ -56,5 +53,7 @@ class Migrate extends MigrateCommand
event(new DatabaseMigrated($tenant));
});
+
+ return 0;
}
}
diff --git a/src/Commands/MigrateFresh.php b/src/Commands/MigrateFresh.php
index 63860153..45a93115 100644
--- a/src/Commands/MigrateFresh.php
+++ b/src/Commands/MigrateFresh.php
@@ -6,18 +6,13 @@ namespace Stancl\Tenancy\Commands;
use Illuminate\Console\Command;
use Stancl\Tenancy\Concerns\DealsWithMigrations;
-use Stancl\Tenancy\Concerns\HasATenantsOption;
+use Stancl\Tenancy\Concerns\HasTenantOptions;
use Symfony\Component\Console\Input\InputOption;
-final class MigrateFresh extends Command
+class MigrateFresh extends Command
{
- use HasATenantsOption, DealsWithMigrations;
+ use HasTenantOptions, DealsWithMigrations;
- /**
- * The console command description.
- *
- * @var string
- */
protected $description = 'Drop all tables and re-run all migrations for tenant(s)';
public function __construct()
@@ -29,26 +24,27 @@ final class MigrateFresh extends Command
$this->setName('tenants:migrate-fresh');
}
- /**
- * Execute the console command.
- */
- public function handle()
+ public function handle(): int
{
- tenancy()->runForMultiple($this->option('tenants'), function ($tenant) {
- $this->info('Dropping tables.');
- $this->call('db:wipe', array_filter([
- '--database' => 'tenant',
- '--drop-views' => $this->option('drop-views'),
- '--force' => true,
- ]));
+ tenancy()->runForMultiple($this->getTenants(), function ($tenant) {
+ $this->components->info("Tenant: {$tenant->getTenantKey()}");
- $this->info('Migrating.');
- $this->callSilent('tenants:migrate', [
- '--tenants' => [$tenant->getTenantKey()],
- '--force' => true,
- ]);
+ $this->components->task('Dropping tables', function () {
+ $this->callSilently('db:wipe', array_filter([
+ '--database' => 'tenant',
+ '--drop-views' => $this->option('drop-views'),
+ '--force' => true,
+ ]));
+ });
+
+ $this->components->task('Migrating', function () use ($tenant) {
+ $this->callSilent('tenants:migrate', [
+ '--tenants' => [$tenant->getTenantKey()],
+ '--force' => true,
+ ]);
+ });
});
- $this->info('Done.');
+ return 0;
}
}
diff --git a/src/Commands/MigrateFreshOverride.php b/src/Commands/MigrateFreshOverride.php
new file mode 100644
index 00000000..88e9e21e
--- /dev/null
+++ b/src/Commands/MigrateFreshOverride.php
@@ -0,0 +1,19 @@
+model()::cursor()->each->delete();
+ }
+
+ return parent::handle();
+ }
+}
diff --git a/src/Commands/Rollback.php b/src/Commands/Rollback.php
index 1c434189..f9d9dac0 100644
--- a/src/Commands/Rollback.php
+++ b/src/Commands/Rollback.php
@@ -8,31 +8,16 @@ use Illuminate\Database\Console\Migrations\RollbackCommand;
use Illuminate\Database\Migrations\Migrator;
use Stancl\Tenancy\Concerns\DealsWithMigrations;
use Stancl\Tenancy\Concerns\ExtendsLaravelCommand;
-use Stancl\Tenancy\Concerns\HasATenantsOption;
+use Stancl\Tenancy\Concerns\HasTenantOptions;
use Stancl\Tenancy\Events\DatabaseRolledBack;
use Stancl\Tenancy\Events\RollingBackDatabase;
class Rollback extends RollbackCommand
{
- use HasATenantsOption, DealsWithMigrations, ExtendsLaravelCommand;
+ use HasTenantOptions, DealsWithMigrations, ExtendsLaravelCommand;
- protected static function getTenantCommandName(): string
- {
- return 'tenants:rollback';
- }
-
- /**
- * The console command description.
- *
- * @var string
- */
protected $description = 'Rollback migrations for tenant(s).';
- /**
- * Create a new command instance.
- *
- * @return void
- */
public function __construct(Migrator $migrator)
{
parent::__construct($migrator);
@@ -40,10 +25,7 @@ class Rollback extends RollbackCommand
$this->specifyTenantSignature();
}
- /**
- * Execute the console command.
- */
- public function handle()
+ public function handle(): int
{
foreach (config('tenancy.migration_parameters') as $parameter => $value) {
if (! $this->input->hasParameterOption($parameter)) {
@@ -52,11 +34,11 @@ class Rollback extends RollbackCommand
}
if (! $this->confirmToProceed()) {
- return;
+ return 1;
}
- tenancy()->runForMultiple($this->option('tenants'), function ($tenant) {
- $this->line("Tenant: {$tenant->getTenantKey()}");
+ tenancy()->runForMultiple($this->getTenants(), function ($tenant) {
+ $this->components->info("Tenant: {$tenant->getTenantKey()}");
event(new RollingBackDatabase($tenant));
@@ -65,5 +47,12 @@ class Rollback extends RollbackCommand
event(new DatabaseRolledBack($tenant));
});
+
+ return 0;
+ }
+
+ protected static function getTenantCommandName(): string
+ {
+ return 'tenants:rollback';
}
}
diff --git a/src/Commands/Run.php b/src/Commands/Run.php
index 075f9116..afc9871a 100644
--- a/src/Commands/Run.php
+++ b/src/Commands/Run.php
@@ -5,36 +5,46 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Commands;
use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Artisan;
+use Illuminate\Contracts\Console\Kernel;
+use Stancl\Tenancy\Concerns\HasTenantOptions;
+use Symfony\Component\Console\Input\ArgvInput;
+use Symfony\Component\Console\Output\ConsoleOutput;
class Run extends Command
{
- /**
- * The console command description.
- *
- * @var string
- */
+ use HasTenantOptions;
+
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.}
{--tenants=* : The tenant(s) to run the command for. Default: all}';
- /**
- * Execute the console command.
- */
- public function handle()
+ public function handle(): int
{
- tenancy()->runForMultiple($this->option('tenants'), function ($tenant) {
- $this->line("Tenant: {$tenant->getTenantKey()}");
+ $argvInput = $this->argvInput();
- Artisan::call($this->argument('commandname'));
- $this->comment('Command output:');
- $this->info(Artisan::output());
+ tenancy()->runForMultiple($this->getTenants(), function ($tenant) use ($argvInput) {
+ $this->components->info("Tenant: {$tenant->getTenantKey()}");
+
+ $this->getLaravel()
+ ->make(Kernel::class)
+ ->handle($argvInput, new ConsoleOutput);
});
+
+ return 0;
+ }
+
+ protected function argvInput(): ArgvInput
+ {
+ /** @var string $commandName */
+ $commandName = $this->argument('commandname');
+
+ // Convert string command to array
+ $subCommand = explode(' ', $commandName);
+
+ // Add "artisan" as first parameter because ArgvInput expects "artisan" as first parameter and later removes it
+ array_unshift($subCommand, 'artisan');
+
+ return new ArgvInput($subCommand);
}
}
diff --git a/src/Commands/Seed.php b/src/Commands/Seed.php
index 8c525208..5cf468e9 100644
--- a/src/Commands/Seed.php
+++ b/src/Commands/Seed.php
@@ -6,37 +6,24 @@ namespace Stancl\Tenancy\Commands;
use Illuminate\Database\ConnectionResolverInterface;
use Illuminate\Database\Console\Seeds\SeedCommand;
-use Stancl\Tenancy\Concerns\HasATenantsOption;
+use Stancl\Tenancy\Concerns\HasTenantOptions;
use Stancl\Tenancy\Events\DatabaseSeeded;
use Stancl\Tenancy\Events\SeedingDatabase;
class Seed extends SeedCommand
{
- use HasATenantsOption;
+ use HasTenantOptions;
- /**
- * The console command description.
- *
- * @var string
- */
protected $description = 'Seed tenant database(s).';
protected $name = 'tenants:seed';
- /**
- * Create a new command instance.
- *
- * @return void
- */
public function __construct(ConnectionResolverInterface $resolver)
{
parent::__construct($resolver);
}
- /**
- * Execute the console command.
- */
- public function handle()
+ public function handle(): int
{
foreach (config('tenancy.seeder_parameters') as $parameter => $value) {
if (! $this->input->hasParameterOption($parameter)) {
@@ -45,11 +32,11 @@ class Seed extends SeedCommand
}
if (! $this->confirmToProceed()) {
- return;
+ return 1;
}
- tenancy()->runForMultiple($this->option('tenants'), function ($tenant) {
- $this->line("Tenant: {$tenant->getTenantKey()}");
+ tenancy()->runForMultiple($this->getTenants(), function ($tenant) {
+ $this->components->info("Tenant: {$tenant->getTenantKey()}");
event(new SeedingDatabase($tenant));
@@ -58,5 +45,7 @@ class Seed extends SeedCommand
event(new DatabaseSeeded($tenant));
});
+
+ return 0;
}
}
diff --git a/src/Commands/TenantDump.php b/src/Commands/TenantDump.php
index 9c8698c6..3f957bdd 100644
--- a/src/Commands/TenantDump.php
+++ b/src/Commands/TenantDump.php
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Commands;
-use Illuminate\Console\Command;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\ConnectionResolverInterface;
use Illuminate\Database\Console\DumpCommand;
@@ -23,13 +22,10 @@ class TenantDump extends DumpCommand
public function handle(ConnectionResolverInterface $connections, Dispatcher $dispatcher): int
{
- $this->tenant()->run(fn () => parent::handle($connections, $dispatcher));
+ if (is_null($this->option('path'))) {
+ $this->input->setOption('path', database_path('schema/tenant-schema.dump'));
+ }
- return Command::SUCCESS;
- }
-
- public function tenant(): Tenant
- {
$tenant = $this->option('tenant')
?? tenant()
?? $this->ask('What tenant do you want to dump the schema for?')
@@ -39,9 +35,15 @@ class TenantDump extends DumpCommand
$tenant = tenancy()->find($tenant);
}
- throw_if(! $tenant, 'Could not identify the tenant to use for dumping the schema.');
+ if ($tenant === null) {
+ $this->components->error('Could not find tenant to use for dumping the schema.');
- return $tenant;
+ return 1;
+ }
+
+ parent::handle($connections, $dispatcher);
+
+ return 0;
}
protected function getOptions(): array
diff --git a/src/Commands/TenantList.php b/src/Commands/TenantList.php
index 13775676..c008ba59 100644
--- a/src/Commands/TenantList.php
+++ b/src/Commands/TenantList.php
@@ -5,39 +5,45 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Commands;
use Illuminate\Console\Command;
+use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Contracts\Tenant;
class TenantList extends Command
{
- /**
- * The name and signature of the console command.
- *
- * @var string
- */
protected $signature = 'tenants:list';
- /**
- * The console command description.
- *
- * @var string
- */
protected $description = 'List tenants.';
- /**
- * Execute the console command.
- */
- public function handle()
+ public function handle(): int
{
- $this->info('Listing all tenants.');
- tenancy()
- ->query()
- ->cursor()
- ->each(function (Tenant $tenant) {
- if ($tenant->domains) {
- $this->line("[Tenant] {$tenant->getTenantKeyName()}: {$tenant->getTenantKey()} @ " . implode('; ', $tenant->domains->pluck('domain')->toArray() ?? []));
- } else {
- $this->line("[Tenant] {$tenant->getTenantKeyName()}: {$tenant->getTenantKey()}");
- }
- });
+ $tenants = tenancy()->query()->cursor();
+
+ $this->components->info("Listing {$tenants->count()} tenants.");
+
+ foreach ($tenants as $tenant) {
+ /** @var Model&Tenant $tenant */
+ $this->components->twoColumnDetail($this->tenantCLI($tenant), $this->domainsCLI($tenant->domains));
+ }
+
+ $this->newLine();
+
+ return 0;
+ }
+
+ /** Generate the visual CLI output for the tenant name. */
+ protected function tenantCLI(Model&Tenant $tenant): string
+ {
+ return "{$tenant->getTenantKeyName()}: {$tenant->getTenantKey()}>";
+ }
+
+ /** Generate the visual CLI output for the domain names. */
+ protected function domainsCLI(?Collection $domains): ?string
+ {
+ if (! $domains) {
+ return null;
+ }
+
+ return "{$domains->pluck('domain')->implode(' / ')}>";
}
}
diff --git a/src/Commands/Up.php b/src/Commands/Up.php
new file mode 100644
index 00000000..cf005251
--- /dev/null
+++ b/src/Commands/Up.php
@@ -0,0 +1,29 @@
+runForMultiple($this->getTenants(), function ($tenant) {
+ $this->components->info("Tenant: {$tenant->getTenantKey()}");
+ $tenant->bringUpFromMaintenance();
+ });
+
+ $this->components->info('Tenants are now out of maintenance mode.');
+
+ return 0;
+ }
+}
diff --git a/src/Concerns/DealsWithMigrations.php b/src/Concerns/DealsWithMigrations.php
index 4bb6b44c..3129c68d 100644
--- a/src/Concerns/DealsWithMigrations.php
+++ b/src/Concerns/DealsWithMigrations.php
@@ -6,12 +6,12 @@ namespace Stancl\Tenancy\Concerns;
trait DealsWithMigrations
{
- protected function getMigrationPaths()
+ protected function getMigrationPaths(): array
{
if ($this->input->hasOption('path') && $this->input->getOption('path')) {
return parent::getMigrationPaths();
}
- return database_path('migrations/tenant');
+ return [database_path('migrations/tenant')];
}
}
diff --git a/src/Concerns/DealsWithTenantSymlinks.php b/src/Concerns/DealsWithTenantSymlinks.php
new file mode 100644
index 00000000..5f3baf5b
--- /dev/null
+++ b/src/Concerns/DealsWithTenantSymlinks.php
@@ -0,0 +1,48 @@
+ '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
+ */
+ 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> $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);
+ }
+}
diff --git a/src/Concerns/HasATenantsOption.php b/src/Concerns/HasTenantOptions.php
similarity index 63%
rename from src/Concerns/HasATenantsOption.php
rename to src/Concerns/HasTenantOptions.php
index 32d508ec..f8a763a7 100644
--- a/src/Concerns/HasATenantsOption.php
+++ b/src/Concerns/HasTenantOptions.php
@@ -5,14 +5,19 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Concerns;
use Illuminate\Support\LazyCollection;
+use Stancl\Tenancy\Database\Concerns\PendingScope;
use Symfony\Component\Console\Input\InputOption;
-trait HasATenantsOption
+/**
+ * Adds 'tenants' and 'with-pending' options.
+ */
+trait HasTenantOptions
{
protected function getOptions()
{
return array_merge([
['tenants', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_OPTIONAL, '', null],
+ ['with-pending', null, InputOption::VALUE_NONE, 'include pending tenants in query'],
], parent::getOptions());
}
@@ -23,6 +28,9 @@ trait HasATenantsOption
->when($this->option('tenants'), function ($query) {
$query->whereIn(tenancy()->model()->getTenantKeyName(), $this->option('tenants'));
})
+ ->when(tenancy()->model()::hasGlobalScope(PendingScope::class), function ($query) {
+ $query->withPending(config('tenancy.pending.include_in_queries') ?: $this->option('with-pending'));
+ })
->cursor();
}
diff --git a/src/Contracts/Domain.php b/src/Contracts/Domain.php
index 2c02089e..a9a19a50 100644
--- a/src/Contracts/Domain.php
+++ b/src/Contracts/Domain.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Contracts;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
/**
* @property-read Tenant $tenant
*
@@ -15,5 +17,5 @@ namespace Stancl\Tenancy\Contracts;
*/
interface Domain
{
- public function tenant();
+ public function tenant(): BelongsTo;
}
diff --git a/src/Contracts/Syncable.php b/src/Contracts/Syncable.php
index e09f4f7e..a481f318 100644
--- a/src/Contracts/Syncable.php
+++ b/src/Contracts/Syncable.php
@@ -15,4 +15,7 @@ interface Syncable
public function getSyncedAttributeNames(): array;
public function triggerSyncEvent(): void;
+
+ /** Get the attributes used for creating the *other* model (i.e. tenant if this is the central one, and central if this is the tenant one). */
+ public function getSyncedCreationAttributes(): array|null; // todo come up with a better name
}
diff --git a/src/Contracts/TenancyBootstrapper.php b/src/Contracts/TenancyBootstrapper.php
index 8b43755f..6da5c537 100644
--- a/src/Contracts/TenancyBootstrapper.php
+++ b/src/Contracts/TenancyBootstrapper.php
@@ -9,7 +9,7 @@ namespace Stancl\Tenancy\Contracts;
*/
interface TenancyBootstrapper
{
- public function bootstrap(Tenant $tenant);
+ public function bootstrap(Tenant $tenant): void;
- public function revert();
+ public function revert(): void;
}
diff --git a/src/Contracts/TenantCannotBeCreatedException.php b/src/Contracts/TenantCannotBeCreatedException.php
index 19eac15b..53d8589f 100644
--- a/src/Contracts/TenantCannotBeCreatedException.php
+++ b/src/Contracts/TenantCannotBeCreatedException.php
@@ -8,6 +8,7 @@ abstract class TenantCannotBeCreatedException extends \Exception
{
abstract public function reason(): string;
+ /** @var string */
protected $message;
public function __construct()
diff --git a/src/Contracts/TenantCouldNotBeIdentifiedException.php b/src/Contracts/TenantCouldNotBeIdentifiedException.php
index 0066291f..011d974b 100644
--- a/src/Contracts/TenantCouldNotBeIdentifiedException.php
+++ b/src/Contracts/TenantCouldNotBeIdentifiedException.php
@@ -5,9 +5,8 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Contracts;
use Exception;
-use Facade\IgnitionContracts\BaseSolution;
-use Facade\IgnitionContracts\ProvidesSolution;
-use Facade\IgnitionContracts\Solution;
+use Spatie\Ignition\Contracts\BaseSolution;
+use Spatie\Ignition\Contracts\ProvidesSolution;
abstract class TenantCouldNotBeIdentifiedException extends Exception implements ProvidesSolution
{
@@ -42,7 +41,7 @@ abstract class TenantCouldNotBeIdentifiedException extends Exception implements
}
/** Get the Ignition description. */
- public function getSolution(): Solution
+ public function getSolution(): BaseSolution
{
return BaseSolution::create($this->solutionTitle)
->setSolutionDescription($this->solutionDescription)
diff --git a/src/Contracts/UniqueIdentifierGenerator.php b/src/Contracts/UniqueIdentifierGenerator.php
index b21d6028..14d91ae0 100644
--- a/src/Contracts/UniqueIdentifierGenerator.php
+++ b/src/Contracts/UniqueIdentifierGenerator.php
@@ -4,10 +4,12 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Contracts;
+use Illuminate\Database\Eloquent\Model;
+
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;
}
diff --git a/src/Controllers/TenantAssetController.php b/src/Controllers/TenantAssetController.php
new file mode 100644
index 00000000..7a95dffe
--- /dev/null
+++ b/src/Controllers/TenantAssetController.php
@@ -0,0 +1,32 @@
+middleware(Tenancy::defaultMiddleware());
+ }
+
+ /**
+ * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
+ */
+ public function asset(string $path = null): BinaryFileResponse
+ {
+ abort_if($path === null, 404);
+
+ try {
+ return response()->file(storage_path("app/public/$path"));
+ } catch (Throwable) {
+ abort(404);
+ }
+ }
+}
diff --git a/src/Controllers/TenantAssetsController.php b/src/Controllers/TenantAssetsController.php
deleted file mode 100644
index 03d600d0..00000000
--- a/src/Controllers/TenantAssetsController.php
+++ /dev/null
@@ -1,30 +0,0 @@
-middleware(static::$tenancyMiddleware);
- }
-
- public function asset(string $path = null)
- {
- abort_if($path === null, 404);
-
- try {
- return response()->file(storage_path("app/public/$path"));
- } catch (Throwable) {
- abort(404);
- }
- }
-}
diff --git a/src/Database/Concerns/BelongsToTenant.php b/src/Database/Concerns/BelongsToTenant.php
index ade966a8..07048a1f 100644
--- a/src/Database/Concerns/BelongsToTenant.php
+++ b/src/Database/Concerns/BelongsToTenant.php
@@ -12,11 +12,14 @@ use Stancl\Tenancy\Database\TenantScope;
*/
trait BelongsToTenant
{
- public static $tenantIdColumn = 'tenant_id';
-
public function tenant()
{
- return $this->belongsTo(config('tenancy.tenant_model'), BelongsToTenant::$tenantIdColumn);
+ return $this->belongsTo(config('tenancy.tenant_model'), static::tenantIdColumn());
+ }
+
+ public static function tenantIdColumn(): string
+ {
+ return config('tenancy.single_db.tenant_id_column');
}
public static function bootBelongsToTenant(): void
@@ -24,9 +27,9 @@ trait BelongsToTenant
static::addGlobalScope(new TenantScope);
static::creating(function ($model) {
- if (! $model->getAttribute(BelongsToTenant::$tenantIdColumn) && ! $model->relationLoaded('tenant')) {
+ if (! $model->getAttribute(static::tenantIdColumn()) && ! $model->relationLoaded('tenant')) {
if (tenancy()->initialized) {
- $model->setAttribute(BelongsToTenant::$tenantIdColumn, tenant()->getTenantKey());
+ $model->setAttribute(static::tenantIdColumn(), tenant()->getTenantKey());
$model->setRelation('tenant', tenant());
}
}
diff --git a/src/Database/Concerns/HasDataColumn.php b/src/Database/Concerns/HasDataColumn.php
deleted file mode 100644
index cf67b832..00000000
--- a/src/Database/Concerns/HasDataColumn.php
+++ /dev/null
@@ -1,15 +0,0 @@
-casts['pending_since'] = 'timestamp';
+ }
+
+ /**
+ * Determine if the model instance is in a pending state.
+ *
+ * @return bool
+ */
+ public function pending()
+ {
+ return ! is_null($this->pending_since);
+ }
+
+ /** Create a pending tenant. */
+ public static function createPending($attributes = []): Tenant
+ {
+ $tenant = static::create($attributes);
+
+ event(new CreatingPendingTenant($tenant));
+
+ // Update the pending_since value only after the tenant is created so it's
+ // Not marked as pending until finishing running the migrations, seeders, etc.
+ $tenant->update([
+ 'pending_since' => now()->timestamp,
+ ]);
+
+ event(new PendingTenantCreated($tenant));
+
+ return $tenant;
+ }
+
+ /** Pull a pending tenant. */
+ public static function pullPending(): Tenant
+ {
+ return static::pullPendingFromPool(true);
+ }
+
+ /** Try to pull a tenant from the pool of pending tenants. */
+ public static function pullPendingFromPool(bool $firstOrCreate = false): ?Tenant
+ {
+ if (! static::onlyPending()->exists()) {
+ if (! $firstOrCreate) {
+ return null;
+ }
+
+ static::createPending();
+ }
+
+ // A pending tenant is surely available at this point
+ $tenant = static::onlyPending()->first();
+
+ event(new PullingPendingTenant($tenant));
+
+ $tenant->update([
+ 'pending_since' => null,
+ ]);
+
+ event(new PendingTenantPulled($tenant));
+
+ return $tenant;
+ }
+}
diff --git a/src/Database/Concerns/HasScopedValidationRules.php b/src/Database/Concerns/HasScopedValidationRules.php
index ae5c7fc7..7913a215 100644
--- a/src/Database/Concerns/HasScopedValidationRules.php
+++ b/src/Database/Concerns/HasScopedValidationRules.php
@@ -11,11 +11,11 @@ trait HasScopedValidationRules
{
public function unique($table, $column = 'NULL')
{
- return (new Unique($table, $column))->where(BelongsToTenant::$tenantIdColumn, $this->getTenantKey());
+ return (new Unique($table, $column))->where(BelongsToTenant::tenantIdColumn(), $this->getTenantKey());
}
public function exists($table, $column = 'NULL')
{
- return (new Exists($table, $column))->where(BelongsToTenant::$tenantIdColumn, $this->getTenantKey());
+ return (new Exists($table, $column))->where(BelongsToTenant::tenantIdColumn(), $this->getTenantKey());
}
}
diff --git a/src/Database/Concerns/InitializationHelpers.php b/src/Database/Concerns/InitializationHelpers.php
new file mode 100644
index 00000000..46802df1
--- /dev/null
+++ b/src/Database/Concerns/InitializationHelpers.php
@@ -0,0 +1,21 @@
+initialize($this);
+ }
+
+ public function leave(): void
+ {
+ tenancy()->end();
+ }
+}
diff --git a/src/Database/Concerns/InvalidatesResolverCache.php b/src/Database/Concerns/InvalidatesResolverCache.php
index 3b864789..21894f41 100644
--- a/src/Database/Concerns/InvalidatesResolverCache.php
+++ b/src/Database/Concerns/InvalidatesResolverCache.php
@@ -5,21 +5,15 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns;
use Stancl\Tenancy\Contracts\Tenant;
-use Stancl\Tenancy\Resolvers;
use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver;
+use Stancl\Tenancy\Tenancy;
trait InvalidatesResolverCache
{
- public static $resolvers = [
- Resolvers\DomainTenantResolver::class,
- Resolvers\PathTenantResolver::class,
- Resolvers\RequestDataTenantResolver::class,
- ];
-
public static function bootInvalidatesResolverCache(): void
{
static::saved(function (Tenant $tenant) {
- foreach (static::$resolvers as $resolver) {
+ foreach (Tenancy::cachedResolvers() as $resolver) {
/** @var CachedTenantResolver $resolver */
$resolver = app($resolver);
diff --git a/src/Database/Concerns/InvalidatesTenantsResolverCache.php b/src/Database/Concerns/InvalidatesTenantsResolverCache.php
index 8d7c2845..d954567f 100644
--- a/src/Database/Concerns/InvalidatesTenantsResolverCache.php
+++ b/src/Database/Concerns/InvalidatesTenantsResolverCache.php
@@ -5,24 +5,18 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns;
use Illuminate\Database\Eloquent\Model;
-use Stancl\Tenancy\Resolvers;
use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver;
+use Stancl\Tenancy\Tenancy;
/**
* Meant to be used on models that belong to tenants.
*/
trait InvalidatesTenantsResolverCache
{
- public static $resolvers = [
- Resolvers\DomainTenantResolver::class,
- Resolvers\PathTenantResolver::class,
- Resolvers\RequestDataTenantResolver::class,
- ];
-
public static function bootInvalidatesTenantsResolverCache(): void
{
static::saved(function (Model $model) {
- foreach (static::$resolvers as $resolver) {
+ foreach (Tenancy::cachedResolvers() as $resolver) {
/** @var CachedTenantResolver $resolver */
$resolver = app($resolver);
diff --git a/src/Database/Concerns/MaintenanceMode.php b/src/Database/Concerns/MaintenanceMode.php
index 55e0e46d..1ad173cf 100644
--- a/src/Database/Concerns/MaintenanceMode.php
+++ b/src/Database/Concerns/MaintenanceMode.php
@@ -4,17 +4,34 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns;
-use Carbon\Carbon;
+use Stancl\Tenancy\Events\TenantMaintenanceModeDisabled;
+use Stancl\Tenancy\Events\TenantMaintenanceModeEnabled;
+/**
+ * @mixin \Illuminate\Database\Eloquent\Model
+ */
trait MaintenanceMode
{
- public function putDownForMaintenance($data = [])
+ public function putDownForMaintenance($data = []): void
{
- $this->update(['maintenance_mode' => [
- 'time' => $data['time'] ?? Carbon::now()->getTimestamp(),
- 'message' => $data['message'] ?? null,
- 'retry' => $data['retry'] ?? null,
- 'allowed' => $data['allowed'] ?? [],
- ]]);
+ $this->update([
+ 'maintenance_mode' => [
+ 'except' => $data['except'] ?? null,
+ 'redirect' => $data['redirect'] ?? null,
+ 'retry' => $data['retry'] ?? null,
+ 'refresh' => $data['refresh'] ?? null,
+ 'secret' => $data['secret'] ?? null,
+ 'status' => $data['status'] ?? 503,
+ ],
+ ]);
+
+ event(new TenantMaintenanceModeEnabled($this));
+ }
+
+ public function bringUpFromMaintenance(): void
+ {
+ $this->update(['maintenance_mode' => null]);
+
+ event(new TenantMaintenanceModeDisabled($this));
}
}
diff --git a/src/Database/Concerns/PendingScope.php b/src/Database/Concerns/PendingScope.php
new file mode 100644
index 00000000..8a6ad913
--- /dev/null
+++ b/src/Database/Concerns/PendingScope.php
@@ -0,0 +1,88 @@
+when(! config('tenancy.pending.include_in_queries'), function (Builder $builder) {
+ $builder->whereNull($builder->getModel()->getColumnForQuery('pending_since'));
+ });
+ }
+
+ /**
+ * Extend the query builder with the needed functions.
+ *
+ * @return void
+ */
+ public function extend(Builder $builder)
+ {
+ foreach ($this->extensions as $extension) {
+ $this->{"add{$extension}"}($builder);
+ }
+ }
+ /**
+ * Add the with-pending extension to the builder.
+ *
+ * @return void
+ */
+ protected function addWithPending(Builder $builder)
+ {
+ $builder->macro('withPending', function (Builder $builder, $withPending = true) {
+ if (! $withPending) {
+ return $builder->withoutPending();
+ }
+
+ return $builder->withoutGlobalScope($this);
+ });
+ }
+
+ /**
+ * Add the without-pending extension to the builder.
+ *
+ * @return void
+ */
+ protected function addWithoutPending(Builder $builder)
+ {
+ $builder->macro('withoutPending', function (Builder $builder) {
+ $builder->withoutGlobalScope($this)
+ ->whereNull($builder->getModel()->getColumnForQuery('pending_since'))
+ ->orWhereNull($builder->getModel()->getDataColumn());
+
+ return $builder;
+ });
+ }
+
+ /**
+ * Add the only-pending extension to the builder.
+ *
+ * @return void
+ */
+ protected function addOnlyPending(Builder $builder)
+ {
+ $builder->macro('onlyPending', function (Builder $builder) {
+ $builder->withoutGlobalScope($this)->whereNotNull($builder->getModel()->getColumnForQuery('pending_since'));
+
+ return $builder;
+ });
+ }
+}
diff --git a/src/Database/Concerns/ResourceSyncing.php b/src/Database/Concerns/ResourceSyncing.php
index df5b0766..fd63738d 100644
--- a/src/Database/Concerns/ResourceSyncing.php
+++ b/src/Database/Concerns/ResourceSyncing.php
@@ -32,4 +32,9 @@ trait ResourceSyncing
/** @var Syncable $this */
event(new SyncedResourceSaved($this, tenant()));
}
+
+ public function getSyncedCreationAttributes(): array|null
+ {
+ return null;
+ }
}
diff --git a/src/Database/Contracts/StatefulTenantDatabaseManager.php b/src/Database/Contracts/StatefulTenantDatabaseManager.php
new file mode 100644
index 00000000..1a2e928d
--- /dev/null
+++ b/src/Database/Contracts/StatefulTenantDatabaseManager.php
@@ -0,0 +1,24 @@
+getTenantKey() . config('tenancy.database.suffix');
};
}
- public function __construct(Tenant $tenant)
+ public function __construct(Model&Tenant $tenant)
{
static::__constructStatic();
@@ -61,7 +65,7 @@ class DatabaseConfig
static::$passwordGenerator = $passwordGenerator;
}
- public function getName(): ?string
+ public function getName(): string
{
return $this->tenant->getInternal('db_name') ?? (static::$databaseNameGenerator)($this->tenant);
}
@@ -81,9 +85,9 @@ class DatabaseConfig
*/
public function makeCredentials(): void
{
- $this->tenant->setInternal('db_name', $this->getName() ?? (static::$databaseNameGenerator)($this->tenant));
+ $this->tenant->setInternal('db_name', $this->getName());
- if ($this->manager() instanceof Contracts\ManagesDatabaseUsers) {
+ if ($this->connectionDriverManager($this->getTemplateConnectionName()) instanceof Contracts\ManagesDatabaseUsers) {
$this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($this->tenant));
$this->tenant->setInternal('db_password', $this->getPassword() ?? (static::$passwordGenerator)($this->tenant));
}
@@ -100,6 +104,11 @@ class DatabaseConfig
?? config('tenancy.database.central_connection');
}
+ public function getTenantHostConnectionName(): string
+ {
+ return config('tenancy.database.tenant_host_connection_name', 'tenant_host_connection');
+ }
+
/**
* Tenant's own database connection config.
*/
@@ -114,6 +123,40 @@ class DatabaseConfig
);
}
+ /**
+ * Tenant's host database connection config.
+ */
+ public function hostConnection(): array
+ {
+ $config = $this->tenantConfig();
+ $template = $this->getTemplateConnectionName();
+ $templateConnection = config("database.connections.{$template}");
+
+ if ($this->connectionDriverManager($template) instanceof Contracts\ManagesDatabaseUsers) {
+ // We're removing the username and password because user with these credentials is not created yet
+ // If you need to provide username and password when using PermissionControlledMySQLDatabaseManager,
+ // consider creating a new connection and use it as `tenancy_db_connection` tenant config key
+ unset($config['username'], $config['password']);
+ }
+
+ if (! $config) {
+ return $templateConnection;
+ }
+
+ return array_replace($templateConnection, $config);
+ }
+
+ /**
+ * Purge host database connection.
+ *
+ * It's possible database has previous tenant connection.
+ * This will clean up the previous connection before creating it for the current tenant.
+ */
+ public function purgeHostConnection(): void
+ {
+ DB::purge($this->getTenantHostConnectionName());
+ }
+
/**
* Additional config for the database connection, specific to this tenant.
*/
@@ -140,10 +183,37 @@ class DatabaseConfig
}, []);
}
- /** Get the TenantDatabaseManager for this tenant's connection. */
+ /** Get the TenantDatabaseManager for this tenant's connection.
+ *
+ * @throws NoConnectionSetException|DatabaseManagerNotRegisteredException
+ */
public function manager(): Contracts\TenantDatabaseManager
{
- $driver = config("database.connections.{$this->getTemplateConnectionName()}.driver");
+ // Laravel caches the previous PDO connection, so we purge it to be able to change the connection details
+ $this->purgeHostConnection(); // todo come up with a better name
+
+ // Create the tenant host connection config
+ $tenantHostConnectionName = $this->getTenantHostConnectionName();
+ config(["database.connections.{$tenantHostConnectionName}" => $this->hostConnection()]);
+
+ $manager = $this->connectionDriverManager($tenantHostConnectionName);
+
+ if ($manager instanceof Contracts\StatefulTenantDatabaseManager) {
+ $manager->setConnection($tenantHostConnectionName);
+ }
+
+ return $manager;
+ }
+
+ /**
+ * todo come up with a better name
+ * Get database manager class from the given connection config's driver.
+ *
+ * @throws DatabaseManagerNotRegisteredException
+ */
+ protected function connectionDriverManager(string $connectionName): Contracts\TenantDatabaseManager
+ {
+ $driver = config("database.connections.{$connectionName}.driver");
$databaseManagers = config('tenancy.database.managers');
@@ -151,11 +221,6 @@ class DatabaseConfig
throw new Exceptions\DatabaseManagerNotRegisteredException($driver);
}
- /** @var Contracts\TenantDatabaseManager $databaseManager */
- $databaseManager = app($databaseManagers[$driver]);
-
- $databaseManager->setConnection($this->getTemplateConnectionName());
-
- return $databaseManager;
+ return app($databaseManagers[$driver]);
}
}
diff --git a/src/Database/DatabaseManager.php b/src/Database/DatabaseManager.php
index a92ccb7b..ce9219d5 100644
--- a/src/Database/DatabaseManager.php
+++ b/src/Database/DatabaseManager.php
@@ -15,25 +15,14 @@ use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
*/
class DatabaseManager
{
- /** @var Application */
- protected $app;
-
- /** @var BaseDatabaseManager */
- protected $database;
-
- /** @var Repository */
- protected $config;
-
- public function __construct(Application $app, BaseDatabaseManager $database, Repository $config)
- {
- $this->app = $app;
- $this->database = $database;
- $this->config = $config;
+ public function __construct(
+ protected Application $app,
+ protected BaseDatabaseManager $database,
+ protected Repository $config,
+ ) {
}
- /**
- * Connect to a tenant's database.
- */
+ /** Connect to a tenant's database. */
public function connectToTenant(TenantWithDatabase $tenant): void
{
$this->purgeTenantConnection();
@@ -41,35 +30,27 @@ class DatabaseManager
$this->setDefaultConnection('tenant');
}
- /**
- * Reconnect to the default non-tenant connection.
- */
+ /** Reconnect to the default non-tenant connection. */
public function reconnectToCentral(): void
{
$this->purgeTenantConnection();
$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
{
$this->config['database.default'] = $connection;
$this->database->setDefaultConnection($connection);
}
- /**
- * Create the tenant database connection.
- */
+ /** Create the tenant database connection. */
public function createTenantConnection(TenantWithDatabase $tenant): void
{
$this->config['database.connections.tenant'] = $tenant->database()->connection();
}
- /**
- * Purge the tenant database connection.
- */
+ /** Purge the tenant database connection. */
public function purgeTenantConnection(): void
{
if (array_key_exists('tenant', $this->database->getConnections())) {
@@ -83,8 +64,8 @@ class DatabaseManager
* Check if a tenant can be created.
*
* @throws TenantCannotBeCreatedException
- * @throws DatabaseManagerNotRegisteredException
- * @throws TenantDatabaseAlreadyExistsException
+ * @throws Exceptions\DatabaseManagerNotRegisteredException
+ * @throws Exceptions\TenantDatabaseAlreadyExistsException
*/
public function ensureTenantCanBeCreated(TenantWithDatabase $tenant): void
{
@@ -94,8 +75,13 @@ class DatabaseManager
throw new Exceptions\TenantDatabaseAlreadyExistsException($database);
}
- if ($manager instanceof Contracts\ManagesDatabaseUsers && $manager->userExists($username = $tenant->database()->getUsername())) {
- throw new Exceptions\TenantDatabaseUserAlreadyExistsException($username);
+ if ($manager instanceof Contracts\ManagesDatabaseUsers) {
+ /** @var string $username */
+ $username = $tenant->database()->getUsername();
+
+ if ($manager->userExists($username)) {
+ throw new Exceptions\TenantDatabaseUserAlreadyExistsException($username);
+ }
}
}
}
diff --git a/src/Database/Models/ImpersonationToken.php b/src/Database/Models/ImpersonationToken.php
index 8161aca7..05d17ad4 100644
--- a/src/Database/Models/ImpersonationToken.php
+++ b/src/Database/Models/ImpersonationToken.php
@@ -5,9 +5,12 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\Models;
use Carbon\Carbon;
+use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Stancl\Tenancy\Database\Concerns\CentralConnection;
+use Stancl\Tenancy\Exceptions\StatefulGuardRequiredException;
/**
* @property string $token
@@ -38,9 +41,15 @@ class ImpersonationToken extends Model
public static function booted(): void
{
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->token = $model->token ?? Str::random(128);
- $model->auth_guard = $model->auth_guard ?? config('auth.defaults.guard');
+ $model->auth_guard = $authGuard;
});
}
}
diff --git a/src/Database/Models/Tenant.php b/src/Database/Models/Tenant.php
index 4518e7b7..37c2af2d 100644
--- a/src/Database/Models/Tenant.php
+++ b/src/Database/Models/Tenant.php
@@ -10,6 +10,8 @@ use Stancl\Tenancy\Contracts;
use Stancl\Tenancy\Database\Concerns;
use Stancl\Tenancy\Database\TenantCollection;
use Stancl\Tenancy\Events;
+use Stancl\Tenancy\Exceptions\TenancyNotInitializedException;
+use Stancl\VirtualColumn\VirtualColumn;
/**
* @property string|int $id
@@ -21,17 +23,17 @@ use Stancl\Tenancy\Events;
*/
class Tenant extends Model implements Contracts\Tenant
{
- use Concerns\CentralConnection,
+ use VirtualColumn,
+ Concerns\CentralConnection,
Concerns\GeneratesIds,
- Concerns\HasDataColumn,
Concerns\HasInternalKeys,
Concerns\TenantRun,
+ Concerns\HasPending,
+ Concerns\InitializationHelpers,
Concerns\InvalidatesResolverCache;
protected $table = 'tenants';
-
protected $primaryKey = 'id';
-
protected $guarded = [];
public function getTenantKeyName(): string
@@ -44,6 +46,22 @@ class Tenant extends Model implements Contracts\Tenant
return $this->getAttribute($this->getTenantKeyName());
}
+ /** Get the current tenant. */
+ public static function current(): static|null
+ {
+ return tenant();
+ }
+
+ /**
+ * Get the current tenant or throw an exception if tenancy is not initialized.
+ *
+ * @throws TenancyNotInitializedException
+ */
+ public static function currentOrFail(): static
+ {
+ return static::current() ?? throw new TenancyNotInitializedException;
+ }
+
public function newCollection(array $models = []): TenantCollection
{
return new TenantCollection($models);
diff --git a/src/Database/ParentModelScope.php b/src/Database/ParentModelScope.php
index 78f5de20..cfc003c1 100644
--- a/src/Database/ParentModelScope.php
+++ b/src/Database/ParentModelScope.php
@@ -19,7 +19,7 @@ class ParentModelScope implements Scope
$builder->whereHas($builder->getModel()->getRelationshipToPrimaryModel());
}
- public function extend(Builder $builder)
+ public function extend(Builder $builder): void
{
$builder->macro('withoutParentModel', function (Builder $builder) {
return $builder->withoutGlobalScope($this);
diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php
index 337864dc..f7e7440e 100644
--- a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php
+++ b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php
@@ -12,7 +12,8 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl
{
use CreatesDatabaseUsers;
- public static $grants = [
+ /** @var string[] */
+ public static array $grants = [
'ALTER', 'ALTER ROUTINE', 'CREATE', 'CREATE ROUTINE', 'CREATE TEMPORARY TABLES', 'CREATE VIEW',
'DELETE', 'DROP', 'EVENT', 'EXECUTE', 'INDEX', 'INSERT', 'LOCK TABLES', 'REFERENCES', 'SELECT',
'SHOW VIEW', 'TRIGGER', 'UPDATE',
diff --git a/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php
index fa5aa593..a7558e1b 100644
--- a/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php
+++ b/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php
@@ -25,11 +25,7 @@ class PostgreSQLSchemaManager extends TenantDatabaseManager
public function makeConnectionConfig(array $baseConfig, string $databaseName): array
{
- if (version_compare(app()->version(), '9.0', '>=')) {
- $baseConfig['search_path'] = $databaseName;
- } else {
- $baseConfig['schema'] = $databaseName;
- }
+ $baseConfig['search_path'] = $databaseName;
return $baseConfig;
}
diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php
index 59c373a9..ada5d642 100644
--- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php
+++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php
@@ -10,10 +10,15 @@ use Throwable;
class SQLiteDatabaseManager implements TenantDatabaseManager
{
+ /**
+ * SQLite Database path without ending slash.
+ */
+ public static string|null $path = null;
+
public function createDatabase(TenantWithDatabase $tenant): bool
{
try {
- return file_put_contents(database_path($tenant->database()->getName()), '');
+ return (bool) file_put_contents($this->getPath($tenant->database()->getName()), '');
} catch (Throwable) {
return false;
}
@@ -22,7 +27,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
public function deleteDatabase(TenantWithDatabase $tenant): bool
{
try {
- return unlink(database_path($tenant->database()->getName()));
+ return unlink($this->getPath($tenant->database()->getName()));
} catch (Throwable) {
return false;
}
@@ -30,7 +35,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
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
@@ -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);
+ }
}
diff --git a/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php b/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php
index b7dd15fa..87916088 100644
--- a/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php
+++ b/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php
@@ -6,15 +6,15 @@ namespace Stancl\Tenancy\Database\TenantDatabaseManagers;
use Illuminate\Database\Connection;
use Illuminate\Support\Facades\DB;
-use Stancl\Tenancy\Database\Contracts\TenantDatabaseManager as Contract;
+use Stancl\Tenancy\Database\Contracts\StatefulTenantDatabaseManager;
use Stancl\Tenancy\Database\Exceptions\NoConnectionSetException;
-abstract class TenantDatabaseManager implements Contract // todo better naming?
+abstract class TenantDatabaseManager implements StatefulTenantDatabaseManager
{
/** The database connection to the server. */
protected string $connection;
- protected function database(): Connection
+ public function database(): Connection
{
if (! isset($this->connection)) {
throw new NoConnectionSetException(static::class);
diff --git a/src/Database/TenantScope.php b/src/Database/TenantScope.php
index 8592f16c..fdab9d70 100644
--- a/src/Database/TenantScope.php
+++ b/src/Database/TenantScope.php
@@ -17,10 +17,10 @@ class TenantScope implements Scope
return;
}
- $builder->where($model->qualifyColumn(BelongsToTenant::$tenantIdColumn), tenant()->getTenantKey());
+ $builder->where($model->qualifyColumn(BelongsToTenant::tenantIdColumn()), tenant()->getTenantKey());
}
- public function extend(Builder $builder)
+ public function extend(Builder $builder): void
{
$builder->macro('withoutTenancy', function (Builder $builder) {
return $builder->withoutGlobalScope($this);
diff --git a/src/Events/Contracts/TenancyEvent.php b/src/Events/Contracts/TenancyEvent.php
index f292049d..85b793f5 100644
--- a/src/Events/Contracts/TenancyEvent.php
+++ b/src/Events/Contracts/TenancyEvent.php
@@ -8,11 +8,8 @@ use Stancl\Tenancy\Tenancy;
abstract class TenancyEvent
{
- /** @var Tenancy */
- public $tenancy;
-
- public function __construct(Tenancy $tenancy)
- {
- $this->tenancy = $tenancy;
+ public function __construct(
+ public Tenancy $tenancy,
+ ) {
}
}
diff --git a/src/Events/Contracts/TenantEvent.php b/src/Events/Contracts/TenantEvent.php
index 951fabfc..e07708b7 100644
--- a/src/Events/Contracts/TenantEvent.php
+++ b/src/Events/Contracts/TenantEvent.php
@@ -7,7 +7,7 @@ namespace Stancl\Tenancy\Events\Contracts;
use Illuminate\Queue\SerializesModels;
use Stancl\Tenancy\Contracts\Tenant;
-abstract class TenantEvent
+abstract class TenantEvent // todo we could add a feature to JobPipeline that automatically gets data for the send() from here
{
use SerializesModels;
diff --git a/src/Events/CreatingPendingTenant.php b/src/Events/CreatingPendingTenant.php
new file mode 100644
index 00000000..dfbe6c70
--- /dev/null
+++ b/src/Events/CreatingPendingTenant.php
@@ -0,0 +1,9 @@
+model = $model;
- $this->tenant = $tenant;
+ public function __construct(
+ public Syncable&Model $model,
+ public TenantWithDatabase|null $tenant,
+ ) {
}
}
diff --git a/src/Events/TenantMaintenanceModeDisabled.php b/src/Events/TenantMaintenanceModeDisabled.php
new file mode 100644
index 00000000..5b2d9778
--- /dev/null
+++ b/src/Events/TenantMaintenanceModeDisabled.php
@@ -0,0 +1,10 @@
+getTargetUrl();
+
+ /**
+ * The original hostname in the redirect response.
+ *
+ * @var string $hostname
+ */
$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;
});
diff --git a/src/Features/TenantConfig.php b/src/Features/TenantConfig.php
index 50756b2c..7b82a7cd 100644
--- a/src/Features/TenantConfig.php
+++ b/src/Features/TenantConfig.php
@@ -16,24 +16,25 @@ use Stancl\Tenancy\Tenancy;
class TenantConfig implements Feature
{
- /** @var Repository */
- protected $config;
-
public array $originalConfig = [];
- public static $storageToConfigMap = [
+ /** @var array */
+ public static array $storageToConfigMap = [
// 'paypal_api_key' => 'services.paypal.api_key',
];
- public function __construct(Repository $config)
- {
- $this->config = $config;
+ public function __construct(
+ protected Repository $config,
+ ) {
}
public function bootstrap(Tenancy $tenancy): void
{
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 () {
@@ -43,8 +44,8 @@ class TenantConfig implements Feature
public function setTenantConfig(Tenant $tenant): void
{
- /** @var Tenant|Model $tenant */
foreach (static::$storageToConfigMap as $storageKey => $configKey) {
+ /** @var Tenant&Model $tenant */
$override = Arr::get($tenant, $storageKey);
if (! is_null($override)) {
diff --git a/src/Features/UniversalRoutes.php b/src/Features/UniversalRoutes.php
index e327b5d3..ad0433fc 100644
--- a/src/Features/UniversalRoutes.php
+++ b/src/Features/UniversalRoutes.php
@@ -16,6 +16,7 @@ class UniversalRoutes implements Feature
public static string $middlewareGroup = 'universal';
// todo docblock
+ /** @var array> */
public static array $identificationMiddlewares = [
Middleware\InitializeTenancyByDomain::class,
Middleware\InitializeTenancyBySubdomain::class,
@@ -42,7 +43,10 @@ class UniversalRoutes implements Feature
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;
}
diff --git a/src/Jobs/ClearPendingTenants.php b/src/Jobs/ClearPendingTenants.php
new file mode 100644
index 00000000..7cd78495
--- /dev/null
+++ b/src/Jobs/ClearPendingTenants.php
@@ -0,0 +1,28 @@
+tenant));
@@ -38,5 +38,7 @@ class CreateDatabase implements ShouldQueue
$this->tenant->database()->manager()->createDatabase($this->tenant);
event(new DatabaseCreated($this->tenant));
+
+ return true;
}
}
diff --git a/src/Jobs/CreatePendingTenants.php b/src/Jobs/CreatePendingTenants.php
new file mode 100644
index 00000000..8f3da218
--- /dev/null
+++ b/src/Jobs/CreatePendingTenants.php
@@ -0,0 +1,28 @@
+tenant);
+ }
+}
diff --git a/src/Jobs/DeleteDomains.php b/src/Jobs/DeleteDomains.php
index 8d89ce9e..15fff779 100644
--- a/src/Jobs/DeleteDomains.php
+++ b/src/Jobs/DeleteDomains.php
@@ -9,14 +9,12 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
-use Stancl\Tenancy\Database\Concerns\HasDomains;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
class DeleteDomains
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
- /** @var TenantWithDatabase&Model&HasDomains */ // todo unresolvable type for phpstan
protected TenantWithDatabase&Model $tenant;
public function __construct(TenantWithDatabase&Model $tenant)
diff --git a/src/Jobs/RemoveStorageSymlinks.php b/src/Jobs/RemoveStorageSymlinks.php
new file mode 100644
index 00000000..3022da79
--- /dev/null
+++ b/src/Jobs/RemoveStorageSymlinks.php
@@ -0,0 +1,40 @@
+tenant = $tenant;
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ RemoveStorageSymlinksAction::handle($this->tenant);
+ }
+}
diff --git a/src/Listeners/BootstrapTenancy.php b/src/Listeners/BootstrapTenancy.php
index 205efc5f..50f38208 100644
--- a/src/Listeners/BootstrapTenancy.php
+++ b/src/Listeners/BootstrapTenancy.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Listeners;
+use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Events\BootstrappingTenancy;
use Stancl\Tenancy\Events\TenancyBootstrapped;
use Stancl\Tenancy\Events\TenancyInitialized;
@@ -15,7 +16,10 @@ class BootstrapTenancy
event(new BootstrappingTenancy($event->tenancy));
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));
diff --git a/src/Listeners/CreateTenantConnection.php b/src/Listeners/CreateTenantConnection.php
index 01351c08..b4983d32 100644
--- a/src/Listeners/CreateTenantConnection.php
+++ b/src/Listeners/CreateTenantConnection.php
@@ -4,21 +4,22 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Listeners;
+use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\DatabaseManager;
use Stancl\Tenancy\Events\Contracts\TenantEvent;
class CreateTenantConnection
{
- /** @var DatabaseManager */
- protected $database;
-
- public function __construct(DatabaseManager $database)
- {
- $this->database = $database;
+ public function __construct(
+ protected DatabaseManager $database,
+ ) {
}
public function handle(TenantEvent $event): void
{
- $this->database->createTenantConnection($event->tenant);
+ /** @var TenantWithDatabase */
+ $tenant = $event->tenant;
+
+ $this->database->createTenantConnection($tenant);
}
}
diff --git a/src/Listeners/DeleteTenantStorage.php b/src/Listeners/DeleteTenantStorage.php
new file mode 100644
index 00000000..ce1a4203
--- /dev/null
+++ b/src/Listeners/DeleteTenantStorage.php
@@ -0,0 +1,16 @@
+tenant->run(fn () => storage_path()));
+ }
+}
diff --git a/src/Listeners/QueueableListener.php b/src/Listeners/QueueableListener.php
index e10c1e7a..f486873d 100644
--- a/src/Listeners/QueueableListener.php
+++ b/src/Listeners/QueueableListener.php
@@ -13,7 +13,7 @@ abstract class QueueableListener implements ShouldQueue
{
public static bool $shouldQueue = false;
- public function shouldQueue($event): bool
+ public function shouldQueue(object $event): bool
{
if (static::$shouldQueue) {
return true;
diff --git a/src/Listeners/RevertToCentralContext.php b/src/Listeners/RevertToCentralContext.php
index ac746ed4..0a680532 100644
--- a/src/Listeners/RevertToCentralContext.php
+++ b/src/Listeners/RevertToCentralContext.php
@@ -14,7 +14,7 @@ class RevertToCentralContext
{
event(new RevertingToCentralContext($event->tenancy));
- foreach ($event->tenancy->getBootstrappers() as $bootstrapper) {
+ foreach (array_reverse($event->tenancy->getBootstrappers()) as $bootstrapper) {
$bootstrapper->revert();
}
diff --git a/src/Listeners/UpdateSyncedResource.php b/src/Listeners/UpdateSyncedResource.php
index 45f73516..39391eac 100644
--- a/src/Listeners/UpdateSyncedResource.php
+++ b/src/Listeners/UpdateSyncedResource.php
@@ -4,14 +4,19 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Listeners;
-use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Pivot;
+use Illuminate\Support\Arr;
+use Stancl\Tenancy\Contracts\Syncable;
use Stancl\Tenancy\Contracts\SyncMaster;
+use Stancl\Tenancy\Contracts\Tenant;
+use Stancl\Tenancy\Database\TenantCollection;
use Stancl\Tenancy\Events\SyncedResourceChangedInForeignDatabase;
use Stancl\Tenancy\Events\SyncedResourceSaved;
use Stancl\Tenancy\Exceptions\ModelNotSyncMasterException;
+// todo@v4 review all code related to resource syncing
+
class UpdateSyncedResource extends QueueableListener
{
public static bool $shouldQueue = false;
@@ -30,25 +35,28 @@ class UpdateSyncedResource extends QueueableListener
$this->updateResourceInTenantDatabases($tenants, $event, $syncedAttributes);
}
- protected function getTenantsForCentralModel($centralModel): EloquentCollection
+ protected function getTenantsForCentralModel(Syncable $centralModel): TenantCollection
{
if (! $centralModel instanceof SyncMaster) {
// If we're trying to use a tenant User model instead of the central User model, for example.
throw new ModelNotSyncMasterException(get_class($centralModel));
}
- /** @var SyncMaster|Model $centralModel */
+ /** @var Tenant&Model&SyncMaster $centralModel */
// Since this model is "dirty" (taken by reference from the event), it might have the tenants
// relationship already loaded and cached. For this reason, we refresh the relationship.
$centralModel->load('tenants');
- return $centralModel->tenants;
+ /** @var TenantCollection $tenants */
+ $tenants = $centralModel->tenants;
+
+ return $tenants;
}
- protected function updateResourceInCentralDatabaseAndGetTenants($event, $syncedAttributes): EloquentCollection
+ protected function updateResourceInCentralDatabaseAndGetTenants(SyncedResourceSaved $event, array $syncedAttributes): TenantCollection
{
- /** @var Model|SyncMaster $centralModel */
+ /** @var (Model&SyncMaster)|null $centralModel */
$centralModel = $event->model->getCentralModelName()::where($event->model->getGlobalIdentifierKeyName(), $event->model->getGlobalIdentifierKey())
->first();
@@ -59,15 +67,17 @@ class UpdateSyncedResource extends QueueableListener
event(new SyncedResourceChangedInForeignDatabase($event->model, null));
} else {
// If the resource doesn't exist at all in the central DB,we create
- // the record with all attributes, not just the synced ones.
- $centralModel = $event->model->getCentralModelName()::create($event->model->getAttributes());
+ $centralModel = $event->model->getCentralModelName()::create($this->getAttributesForCreation($event->model));
event(new SyncedResourceChangedInForeignDatabase($event->model, null));
}
});
// If the model was just created, the mapping of the tenant to the user likely doesn't exist, so we create it.
$currentTenantMapping = function ($model) use ($event) {
- return ((string) $model->pivot->tenant_id) === ((string) $event->tenant->getTenantKey());
+ /** @var Tenant */
+ $tenant = $event->tenant;
+
+ return ((string) $model->pivot->tenant_id) === ((string) $tenant->getTenantKey());
};
$mappingExists = $centralModel->tenants->contains($currentTenantMapping);
@@ -76,22 +86,29 @@ class UpdateSyncedResource extends QueueableListener
// Here we should call TenantPivot, but we call general Pivot, so that this works
// even if people use their own pivot model that is not based on our TenantPivot
Pivot::withoutEvents(function () use ($centralModel, $event) {
- $centralModel->tenants()->attach($event->tenant->getTenantKey());
+ /** @var Tenant */
+ $tenant = $event->tenant;
+
+ $centralModel->tenants()->attach($tenant->getTenantKey());
});
}
- return $centralModel->tenants->filter(function ($model) use ($currentTenantMapping) {
+ /** @var TenantCollection $tenants */
+ $tenants = $centralModel->tenants->filter(function ($model) use ($currentTenantMapping) {
// Remove the mapping for the current tenant.
return ! $currentTenantMapping($model);
});
+
+ return $tenants;
}
- protected function updateResourceInTenantDatabases($tenants, $event, $syncedAttributes): void
+ protected function updateResourceInTenantDatabases(TenantCollection $tenants, SyncedResourceSaved $event, array $syncedAttributes): void
{
tenancy()->runForMultiple($tenants, function ($tenant) use ($event, $syncedAttributes) {
// Forget instance state and find the model,
// again in the current tenant's context.
+ /** @var Model&Syncable $eventModel */
$eventModel = $event->model;
if ($eventModel instanceof SyncMaster) {
@@ -112,12 +129,53 @@ class UpdateSyncedResource extends QueueableListener
if ($localModel) {
$localModel->update($syncedAttributes);
} else {
- // When creating, we use all columns, not just the synced ones.
- $localModel = $localModelClass::create($eventModel->getAttributes());
+ $localModel = $localModelClass::create($this->getAttributesForCreation($eventModel));
}
event(new SyncedResourceChangedInForeignDatabase($localModel, $tenant));
});
});
}
+
+ protected function getAttributesForCreation(Model&Syncable $model): array
+ {
+ if (! $model->getSyncedCreationAttributes()) {
+ // Creation attributes are not specified so create the model as 1:1 copy
+ // exclude the "primary key" because we want primary key to handle by the target model to avoid duplication errors
+ $attributes = $model->getAttributes();
+ unset($attributes[$model->getKeyName()]);
+
+ return $attributes;
+ }
+
+ if (Arr::isAssoc($model->getSyncedCreationAttributes())) {
+ // Developer provided the default values (key => value) or mix of default values and attribute names (values only)
+ // We will merge the default values with provided attributes and sync attributes
+ [$attributeNames, $defaultValues] = $this->getAttributeNamesAndDefaultValues($model);
+ $attributes = $model->only(array_merge($model->getSyncedAttributeNames(), $attributeNames));
+
+ return array_merge($attributes, $defaultValues);
+ }
+
+ // Developer provided the attribute names, so we'll use them to pick model attributes
+ return $model->only($model->getSyncedCreationAttributes());
+ }
+
+ /**
+ * Split the attribute names (sequential index items) and default values (key => values).
+ */
+ protected function getAttributeNamesAndDefaultValues(Model&Syncable $model): array
+ {
+ $syncedCreationAttributes = $model->getSyncedCreationAttributes() ?? [];
+
+ $attributes = Arr::where($syncedCreationAttributes, function ($value, $key) {
+ return is_numeric($key);
+ });
+
+ $defaultValues = Arr::where($syncedCreationAttributes, function ($value, $key) {
+ return is_string($key);
+ });
+
+ return [$attributes, $defaultValues];
+ }
}
diff --git a/src/Middleware/CheckTenantForMaintenanceMode.php b/src/Middleware/CheckTenantForMaintenanceMode.php
index c1c734f5..58fcd184 100644
--- a/src/Middleware/CheckTenantForMaintenanceMode.php
+++ b/src/Middleware/CheckTenantForMaintenanceMode.php
@@ -7,7 +7,6 @@ namespace Stancl\Tenancy\Middleware;
use Closure;
use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode;
use Stancl\Tenancy\Exceptions\TenancyNotInitializedException;
-use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpKernel\Exception\HttpException;
class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode
@@ -21,19 +20,38 @@ class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode
if (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);
}
- if ($this->inExceptArray($request)) {
- return $next($request);
+ if (isset($data['redirect'])) {
+ $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(
- 503,
+ (int) ($data['status'] ?? 503),
'Service Unavailable',
null,
- isset($data['retry']) ? ['Retry-After' => $data['retry']] : []
+ $this->getHeaders($data)
);
}
diff --git a/src/Middleware/IdentificationMiddleware.php b/src/Middleware/IdentificationMiddleware.php
index 38f4684d..12aa4a16 100644
--- a/src/Middleware/IdentificationMiddleware.php
+++ b/src/Middleware/IdentificationMiddleware.php
@@ -4,22 +4,22 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Middleware;
+use Closure;
+use Illuminate\Http\Request;
use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException;
use Stancl\Tenancy\Contracts\TenantResolver;
use Stancl\Tenancy\Tenancy;
+/**
+ * @property Tenancy $tenancy
+ * @property TenantResolver $resolver
+ */
abstract class IdentificationMiddleware
{
- /** @var callable */
- public static $onFail;
+ public static ?Closure $onFail = null;
- /** @var Tenancy */
- protected $tenancy;
-
- /** @var TenantResolver */
- protected $resolver;
-
- public function initializeTenancy($request, $next, ...$resolverArguments)
+ /** @return \Illuminate\Http\Response|mixed */
+ public function initializeTenancy(Request $request, Closure $next, mixed ...$resolverArguments): mixed
{
try {
$this->tenancy->initialize(
diff --git a/src/Middleware/InitializeTenancyByDomain.php b/src/Middleware/InitializeTenancyByDomain.php
index 5a07112d..add5597d 100644
--- a/src/Middleware/InitializeTenancyByDomain.php
+++ b/src/Middleware/InitializeTenancyByDomain.php
@@ -5,32 +5,22 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Middleware;
use Closure;
+use Illuminate\Http\Request;
use Stancl\Tenancy\Resolvers\DomainTenantResolver;
use Stancl\Tenancy\Tenancy;
class InitializeTenancyByDomain extends IdentificationMiddleware
{
- /** @var callable|null */
- public static $onFail;
+ public static ?Closure $onFail = null;
- /** @var Tenancy */
- protected $tenancy;
-
- /** @var DomainTenantResolver */
- protected $resolver;
-
- public function __construct(Tenancy $tenancy, DomainTenantResolver $resolver)
- {
- $this->tenancy = $tenancy;
- $this->resolver = $resolver;
+ public function __construct(
+ protected Tenancy $tenancy,
+ protected DomainTenantResolver $resolver,
+ ) {
}
- /**
- * Handle an incoming request.
- *
- * @param \Illuminate\Http\Request $request
- */
- public function handle($request, Closure $next)
+ /** @return \Illuminate\Http\Response|mixed */
+ public function handle(Request $request, Closure $next): mixed
{
return $this->initializeTenancy(
$request,
diff --git a/src/Middleware/InitializeTenancyByDomainOrSubdomain.php b/src/Middleware/InitializeTenancyByDomainOrSubdomain.php
index 9b153db3..1a30001a 100644
--- a/src/Middleware/InitializeTenancyByDomainOrSubdomain.php
+++ b/src/Middleware/InitializeTenancyByDomainOrSubdomain.php
@@ -5,16 +5,13 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Middleware;
use Closure;
+use Illuminate\Http\Request;
use Illuminate\Support\Str;
class InitializeTenancyByDomainOrSubdomain
{
- /**
- * Handle an incoming request.
- *
- * @param \Illuminate\Http\Request $request
- */
- public function handle($request, Closure $next)
+ /** @return \Illuminate\Http\Response|mixed */
+ public function handle(Request $request, Closure $next): mixed
{
if ($this->isSubdomain($request->getHost())) {
return app(InitializeTenancyBySubdomain::class)->handle($request, $next);
diff --git a/src/Middleware/InitializeTenancyByPath.php b/src/Middleware/InitializeTenancyByPath.php
index e66400c5..3e484f87 100644
--- a/src/Middleware/InitializeTenancyByPath.php
+++ b/src/Middleware/InitializeTenancyByPath.php
@@ -7,28 +7,26 @@ namespace Stancl\Tenancy\Middleware;
use Closure;
use Illuminate\Http\Request;
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\Resolvers\PathTenantResolver;
use Stancl\Tenancy\Tenancy;
class InitializeTenancyByPath extends IdentificationMiddleware
{
- /** @var callable|null */
- public static $onFail;
+ public static ?Closure $onFail = null;
- /** @var Tenancy */
- protected $tenancy;
-
- /** @var PathTenantResolver */
- protected $resolver;
-
- public function __construct(Tenancy $tenancy, PathTenantResolver $resolver)
- {
- $this->tenancy = $tenancy;
- $this->resolver = $resolver;
+ public function __construct(
+ protected Tenancy $tenancy,
+ protected PathTenantResolver $resolver,
+ ) {
}
- public function handle(Request $request, Closure $next)
+ /** @return \Illuminate\Http\Response|mixed */
+ public function handle(Request $request, Closure $next): mixed
{
/** @var Route $route */
$route = $request->route();
@@ -36,7 +34,9 @@ class InitializeTenancyByPath extends IdentificationMiddleware
// Only initialize tenancy if tenant is the first parameter
// We don't want to initialize tenancy if the tenant is
// simply injected into some route controller action.
- if ($route->parameterNames()[0] === PathTenantResolver::$tenantParameterName) {
+ if ($route->parameterNames()[0] === PathTenantResolver::tenantParameterName()) {
+ $this->setDefaultTenantForRouteParametersWhenTenancyIsInitialized();
+
return $this->initializeTenancy(
$request,
$next,
@@ -48,4 +48,16 @@ class InitializeTenancyByPath extends IdentificationMiddleware
return $next($request);
}
+
+ protected function setDefaultTenantForRouteParametersWhenTenancyIsInitialized(): void
+ {
+ Event::listen(InitializingTenancy::class, function (InitializingTenancy $event) {
+ /** @var Tenant $tenant */
+ $tenant = $event->tenancy->tenant;
+
+ URL::defaults([
+ PathTenantResolver::tenantParameterName() => $tenant->getTenantKey(),
+ ]);
+ });
+ }
}
diff --git a/src/Middleware/InitializeTenancyByRequestData.php b/src/Middleware/InitializeTenancyByRequestData.php
index 4e1d33ff..ca29f3d7 100644
--- a/src/Middleware/InitializeTenancyByRequestData.php
+++ b/src/Middleware/InitializeTenancyByRequestData.php
@@ -11,33 +11,19 @@ use Stancl\Tenancy\Tenancy;
class InitializeTenancyByRequestData extends IdentificationMiddleware
{
- /** @var string|null */
- public static $header = 'X-Tenant';
+ public static string $header = 'X-Tenant';
+ public static string $cookie = 'X-Tenant';
+ public static string $queryParameter = 'tenant';
+ public static ?Closure $onFail = null;
- /** @var string|null */
- public static $queryParameter = 'tenant';
-
- /** @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;
+ public function __construct(
+ protected Tenancy $tenancy,
+ protected RequestDataTenantResolver $resolver,
+ ) {
}
- /**
- * Handle an incoming request.
- *
- * @param \Illuminate\Http\Request $request
- */
- public function handle($request, Closure $next)
+ /** @return \Illuminate\Http\Response|mixed */
+ public function handle(Request $request, Closure $next): mixed
{
if ($request->method() !== 'OPTIONS') {
return $this->initializeTenancy($request, $next, $this->getPayload($request));
@@ -48,13 +34,18 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware
protected function getPayload(Request $request): ?string
{
- $tenant = null;
if (static::$header && $request->hasHeader(static::$header)) {
- $tenant = $request->header(static::$header);
- } elseif (static::$queryParameter && $request->has(static::$queryParameter)) {
- $tenant = $request->get(static::$queryParameter);
+ return $request->header(static::$header);
}
- return $tenant;
+ if (static::$queryParameter && $request->has(static::$queryParameter)) {
+ return $request->get(static::$queryParameter);
+ }
+
+ if (static::$cookie && $request->hasCookie(static::$cookie)) {
+ return $request->cookie(static::$cookie);
+ }
+
+ return null;
}
}
diff --git a/src/Middleware/InitializeTenancyBySubdomain.php b/src/Middleware/InitializeTenancyBySubdomain.php
index 76389df7..1bf083f3 100644
--- a/src/Middleware/InitializeTenancyBySubdomain.php
+++ b/src/Middleware/InitializeTenancyBySubdomain.php
@@ -6,6 +6,7 @@ namespace Stancl\Tenancy\Middleware;
use Closure;
use Exception;
+use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Str;
use Stancl\Tenancy\Exceptions\NotASubdomainException;
@@ -21,15 +22,10 @@ class InitializeTenancyBySubdomain extends InitializeTenancyByDomain
*/
public static $subdomainIndex = 0;
- /** @var callable|null */
- public static $onFail;
+ public static ?Closure $onFail = null;
- /**
- * Handle an incoming request.
- *
- * @param \Illuminate\Http\Request $request
- */
- public function handle($request, Closure $next)
+ /** @return Response|mixed */
+ public function handle(Request $request, Closure $next): mixed
{
$subdomain = $this->makeSubdomain($request->getHost());
diff --git a/src/Middleware/PreventAccessFromCentralDomains.php b/src/Middleware/PreventAccessFromCentralDomains.php
index 14b2306d..40718730 100644
--- a/src/Middleware/PreventAccessFromCentralDomains.php
+++ b/src/Middleware/PreventAccessFromCentralDomains.php
@@ -11,12 +11,11 @@ class PreventAccessFromCentralDomains
{
/**
* 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'))) {
$abortRequest = static::$abortRequest ?? function () {
diff --git a/src/Middleware/ScopeSessions.php b/src/Middleware/ScopeSessions.php
index 8abfcfe8..dc302ee5 100644
--- a/src/Middleware/ScopeSessions.php
+++ b/src/Middleware/ScopeSessions.php
@@ -10,9 +10,10 @@ use Stancl\Tenancy\Exceptions\TenancyNotInitializedException;
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) {
throw new TenancyNotInitializedException('Tenancy needs to be initialized before the session scoping middleware is executed');
diff --git a/src/Resolvers/Contracts/CachedTenantResolver.php b/src/Resolvers/Contracts/CachedTenantResolver.php
index f93d7bb5..b6a4b15c 100644
--- a/src/Resolvers/Contracts/CachedTenantResolver.php
+++ b/src/Resolvers/Contracts/CachedTenantResolver.php
@@ -11,23 +11,17 @@ use Stancl\Tenancy\Contracts\TenantResolver;
abstract class CachedTenantResolver implements TenantResolver
{
- public static bool $shouldCache = false; // todo docblocks for these
-
- public static int $cacheTTL = 3600; // seconds
-
- public static string|null $cacheStore = null; // default
-
/** @var Repository */
protected $cache;
public function __construct(Factory $cache)
{
- $this->cache = $cache->store(static::$cacheStore);
+ $this->cache = $cache->store(static::cacheStore());
}
public function resolve(mixed ...$args): Tenant
{
- if (! static::$shouldCache) {
+ if (! static::shouldCache()) {
return $this->resolveWithoutCache(...$args);
}
@@ -42,14 +36,14 @@ abstract class CachedTenantResolver implements TenantResolver
}
$tenant = $this->resolveWithoutCache(...$args);
- $this->cache->put($key, $tenant, static::$cacheTTL);
+ $this->cache->put($key, $tenant, static::cacheTTL());
return $tenant;
}
public function invalidateCache(Tenant $tenant): void
{
- if (! static::$shouldCache) {
+ if (! static::shouldCache()) {
return;
}
@@ -65,7 +59,7 @@ abstract class CachedTenantResolver implements TenantResolver
abstract public function resolveWithoutCache(mixed ...$args): Tenant;
- public function resolved(Tenant $tenant, ...$args): void
+ public function resolved(Tenant $tenant, mixed ...$args): void
{
}
@@ -75,4 +69,19 @@ abstract class CachedTenantResolver implements TenantResolver
* @return array[]
*/
abstract public function getArgsForTenant(Tenant $tenant): array;
+
+ public static function shouldCache(): bool
+ {
+ return config('tenancy.identification.resolvers.' . static::class . '.cache') ?? false;
+ }
+
+ public static function cacheTTL(): int
+ {
+ return config('tenancy.identification.resolvers.' . static::class . '.cache_ttl') ?? 3600;
+ }
+
+ public static function cacheStore(): string|null
+ {
+ return config('tenancy.identification.resolvers.' . static::class . '.cache_store');
+ }
}
diff --git a/src/Resolvers/DomainTenantResolver.php b/src/Resolvers/DomainTenantResolver.php
index 926c02c0..cf88f579 100644
--- a/src/Resolvers/DomainTenantResolver.php
+++ b/src/Resolvers/DomainTenantResolver.php
@@ -14,17 +14,10 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver
/** The model representing the domain that the tenant was identified on. */
public static Domain $currentDomain; // todo |null?
- public static bool $shouldCache = false;
-
- public static int $cacheTTL = 3600; // seconds
-
- public static string|null $cacheStore = null; // default
-
public function resolveWithoutCache(mixed ...$args): Tenant
{
$domain = $args[0];
- /** @var Tenant|null $tenant */
$tenant = config('tenancy.tenant_model')::query()
->whereHas('domains', fn (Builder $query) => $query->where('domain', $domain))
->with('domains')
@@ -39,7 +32,7 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver
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]);
}
diff --git a/src/Resolvers/PathTenantResolver.php b/src/Resolvers/PathTenantResolver.php
index 2ac2a59f..1359e9c1 100644
--- a/src/Resolvers/PathTenantResolver.php
+++ b/src/Resolvers/PathTenantResolver.php
@@ -10,21 +10,13 @@ use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByPathException;
class PathTenantResolver extends Contracts\CachedTenantResolver
{
- public static string $tenantParameterName = 'tenant';
-
- public static bool $shouldCache = false;
-
- public static int $cacheTTL = 3600; // seconds
-
- public static string|null $cacheStore = null; // default
-
public function resolveWithoutCache(mixed ...$args): Tenant
{
/** @var Route $route */
$route = $args[0];
- if ($id = $route->parameter(static::$tenantParameterName)) {
- $route->forgetParameter(static::$tenantParameterName);
+ if ($id = (string) $route->parameter(static::tenantParameterName())) {
+ $route->forgetParameter(static::tenantParameterName());
if ($tenant = tenancy()->find($id)) {
return $tenant;
@@ -37,7 +29,12 @@ class PathTenantResolver extends Contracts\CachedTenantResolver
public function getArgsForTenant(Tenant $tenant): array
{
return [
- [$tenant->id],
+ [$tenant->getTenantKey()],
];
}
+
+ public static function tenantParameterName(): string
+ {
+ return config('tenancy.identification.resolvers.' . static::class . '.tenant_parameter_name') ?? 'tenant';
+ }
}
diff --git a/src/Resolvers/RequestDataTenantResolver.php b/src/Resolvers/RequestDataTenantResolver.php
index 5ed65495..8a5bbc53 100644
--- a/src/Resolvers/RequestDataTenantResolver.php
+++ b/src/Resolvers/RequestDataTenantResolver.php
@@ -17,7 +17,7 @@ class RequestDataTenantResolver extends Contracts\CachedTenantResolver
public function resolveWithoutCache(mixed ...$args): Tenant
{
- $payload = $args[0];
+ $payload = (string) $args[0];
if ($payload && $tenant = tenancy()->find($payload)) {
return $tenant;
@@ -29,7 +29,7 @@ class RequestDataTenantResolver extends Contracts\CachedTenantResolver
public function getArgsForTenant(Tenant $tenant): array
{
return [
- [$tenant->id],
+ [$tenant->getTenantKey()],
];
}
}
diff --git a/src/Tenancy.php b/src/Tenancy.php
index 012881ae..e95e0059 100644
--- a/src/Tenancy.php
+++ b/src/Tenancy.php
@@ -42,7 +42,7 @@ class Tenancy
}
}
- // todo0 for phpstan this should be $this->tenant?, but first I want to clean up the $initialized logic and explore removing the property
+ // todo1 for phpstan this should be $this->tenant?, but first I want to clean up the $initialized logic and explore removing the property
if ($this->initialized && $this->tenant->getTenantKey() === $tenant->getTenantKey()) {
return;
}
@@ -99,19 +99,30 @@ class Tenancy
{
$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
{
- 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.
* Atomic, safely reverts to previous context.
*/
- public function central(Closure $callback)
+ public function central(Closure $callback): mixed
{
$previousTenant = $this->tenant;
@@ -132,7 +143,7 @@ class Tenancy
* Run a callback for multiple tenants.
* More performant than running $tenant->run() one by one.
*
- * @param Tenant[]|\Traversable|string[]|null $tenants
+ * @param array|array|\Traversable|string|int|null $tenants
*/
public function runForMultiple($tenants, Closure $callback): void
{
@@ -145,8 +156,8 @@ class Tenancy
// Wrap string in array
$tenants = is_string($tenants) ? [$tenants] : $tenants;
- // Use all tenants if $tenants is falsey
- $tenants = $tenants ?: $this->model()->cursor();
+ // Use all tenants if $tenants is falsy
+ $tenants = $tenants ?: $this->model()->cursor(); // todo1 phpstan thinks this isn't needed, but tests fail without it
$originalTenant = $this->tenant;
@@ -155,6 +166,7 @@ class Tenancy
$tenant = $this->find($tenant);
}
+ /** @var Tenant $tenant */
$this->initialize($tenant);
$callback($tenant);
}
@@ -165,4 +177,41 @@ class Tenancy
$this->end();
}
}
+
+ /**
+ * Cached tenant resolvers used by the package.
+ *
+ * @return array>
+ */
+ public static function cachedResolvers(): array
+ {
+ $resolvers = config('tenancy.identification.resolvers', []);
+
+ $cachedResolvers = array_filter($resolvers, function (array $options) {
+ // Resolvers based on CachedTenantResolver have the 'cache' option in the resolver config
+ return isset($options['cache']);
+ });
+
+ return array_keys($cachedResolvers);
+ }
+
+ /**
+ * Tenant identification middleware used by the package.
+ *
+ * @return array>
+ */
+ public static function middleware(): array
+ {
+ return config('tenancy.identification.middleware', []);
+ }
+
+ /**
+ * Default tenant identification middleware used by the package.
+ *
+ * @return class-string
+ */
+ public static function defaultMiddleware(): string
+ {
+ return config('tenancy.identification.default_middleware', Middleware\InitializeTenancyByDomain::class);
+ }
}
diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php
index 3850720c..01770cda 100644
--- a/src/TenancyServiceProvider.php
+++ b/src/TenancyServiceProvider.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy;
use Illuminate\Cache\CacheManager;
+use Illuminate\Database\Console\Migrations\FreshCommand;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
@@ -77,7 +78,10 @@ class TenancyServiceProvider extends ServiceProvider
public function boot(): void
{
$this->commands([
+ Commands\Up::class,
Commands\Run::class,
+ Commands\Down::class,
+ Commands\Link::class,
Commands\Seed::class,
Commands\Install::class,
Commands\Migrate::class,
@@ -85,8 +89,14 @@ class TenancyServiceProvider extends ServiceProvider
Commands\TenantList::class,
Commands\TenantDump::class,
Commands\MigrateFresh::class,
+ Commands\ClearPendingTenants::class,
+ Commands\CreatePendingTenants::class,
]);
+ $this->app->extend(FreshCommand::class, function () {
+ return new Commands\MigrateFreshOverride;
+ });
+
$this->publishes([
__DIR__ . '/../assets/config.php' => config_path('tenancy.php'),
], 'config');
@@ -117,7 +127,7 @@ class TenancyServiceProvider extends ServiceProvider
if ($event instanceof TenancyEvent) {
match (tenancy()->logMode()) {
LogMode::SILENT => tenancy()->logEvent($event),
- LogMode::INSTANT => dump($event), // todo0 perhaps still log
+ LogMode::INSTANT => dump($event), // todo1 perhaps still log
default => null,
};
}
diff --git a/src/UUIDGenerator.php b/src/UUIDGenerator.php
index 736a6924..a0974862 100644
--- a/src/UUIDGenerator.php
+++ b/src/UUIDGenerator.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy;
+use Illuminate\Database\Eloquent\Model;
use Ramsey\Uuid\Uuid;
use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
@@ -11,7 +12,7 @@ use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
class UUIDGenerator implements UniqueIdentifierGenerator
{
- public static function generate($resource): string
+ public static function generate(Model $model): string
{
return Uuid::uuid4()->toString();
}
diff --git a/src/helpers.php b/src/helpers.php
index 23b5a627..c50da940 100644
--- a/src/helpers.php
+++ b/src/helpers.php
@@ -35,6 +35,7 @@ if (! function_exists('tenant')) {
if (! function_exists('tenant_asset')) {
// todo docblock
+ // todo add an option to generate paths respecting the ASSET_URL
function tenant_asset(string|null $asset): string
{
return route('stancl.tenancy.asset', ['path' => $asset]);
@@ -42,27 +43,57 @@ if (! function_exists('tenant_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);
}
}
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')) {
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);
- $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);
}
}
diff --git a/tests/ActionTest.php b/tests/ActionTest.php
new file mode 100644
index 00000000..cc0950ea
--- /dev/null
+++ b/tests/ActionTest.php
@@ -0,0 +1,69 @@
+ [
+ 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);
+});
diff --git a/tests/AutomaticModeTest.php b/tests/AutomaticModeTest.php
index ab484ccf..fc740fc1 100644
--- a/tests/AutomaticModeTest.php
+++ b/tests/AutomaticModeTest.php
@@ -107,12 +107,12 @@ function contextIsSwitchedWhenTenancyInitialized()
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());
}
- public function revert()
+ public function revert(): void
{
app()->instance('tenancy_ended', true);
}
diff --git a/tests/BatchTest.php b/tests/BatchTest.php
new file mode 100644
index 00000000..629a4e61
--- /dev/null
+++ b/tests/BatchTest.php
@@ -0,0 +1,44 @@
+ [
+ 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();
+}
diff --git a/tests/BootstrapperTest.php b/tests/BootstrapperTest.php
index 96afbc83..ba4ea41a 100644
--- a/tests/BootstrapperTest.php
+++ b/tests/BootstrapperTest.php
@@ -2,10 +2,10 @@
declare(strict_types=1);
-use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
use Stancl\JobPipeline\JobPipeline;
+use Illuminate\Support\Facades\File;
use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
@@ -14,8 +14,14 @@ use Illuminate\Support\Facades\Storage;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Jobs\CreateDatabase;
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\Jobs\CreateStorageSymlinks;
+use Stancl\Tenancy\Jobs\RemoveStorageSymlinks;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
+use Stancl\Tenancy\Listeners\DeleteTenantStorage;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper;
@@ -184,24 +190,156 @@ test('filesystem data is separated', function () {
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
{
/** @var FilesystemAdapter $disk */
$disk = Storage::disk($disk);
$adapter = $disk->getAdapter();
- if (! Str::startsWith(app()->version(), '9.')) {
- return $adapter->getPathPrefix();
- }
+ $prefixer = (new ReflectionObject($adapter))->getProperty('prefixer');
+ $prefixer->setAccessible(true);
- $prefixer = (new ReflectionObject($adapter))->getProperty('prefixer');
- $prefixer->setAccessible(true);
+ // reflection -> instance
+ $prefixer = $prefixer->getValue($adapter);
- // reflection -> instance
- $prefixer = $prefixer->getValue($adapter);
+ $prefix = (new ReflectionProperty($prefixer, 'prefix'));
+ $prefix->setAccessible(true);
- $prefix = (new ReflectionProperty($prefixer, 'prefix'));
- $prefix->setAccessible(true);
-
- return $prefix->getValue($prefixer);
+ return $prefix->getValue($prefixer);
}
diff --git a/tests/CachedTenantResolverTest.php b/tests/CachedTenantResolverTest.php
index d71375be..fa624b04 100644
--- a/tests/CachedTenantResolverTest.php
+++ b/tests/CachedTenantResolverTest.php
@@ -6,9 +6,7 @@ use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Resolvers\DomainTenantResolver;
use Stancl\Tenancy\Tests\Etc\Tenant;
-afterEach(function () {
- DomainTenantResolver::$shouldCache = false;
-});
+// todo@v4 test this with other resolvers as well?
test('tenants can be resolved using the cached resolver', function () {
$tenant = Tenant::create();
@@ -27,14 +25,14 @@ test('the underlying resolver is not touched when using the cached resolver', fu
DB::enableQueryLog();
- DomainTenantResolver::$shouldCache = false;
+ config(['tenancy.identification.resolvers.' . DomainTenantResolver::class . '.cache' => false]);
expect($tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toBeTrue();
DB::flushQueryLog();
expect($tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toBeTrue();
pest()->assertNotEmpty(DB::getQueryLog()); // not empty
- DomainTenantResolver::$shouldCache = true;
+ config(['tenancy.identification.resolvers.' . DomainTenantResolver::class . '.cache' => true]);
expect($tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toBeTrue();
DB::flushQueryLog();
@@ -50,7 +48,7 @@ test('cache is invalidated when the tenant is updated', function () {
DB::enableQueryLog();
- DomainTenantResolver::$shouldCache = true;
+ config(['tenancy.identification.resolvers.' . DomainTenantResolver::class . '.cache' => true]);
expect($tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toBeTrue();
DB::flushQueryLog();
@@ -74,7 +72,7 @@ test('cache is invalidated when a tenants domain is changed', function () {
DB::enableQueryLog();
- DomainTenantResolver::$shouldCache = true;
+ config(['tenancy.identification.resolvers.' . DomainTenantResolver::class . '.cache' => true]);
expect($tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toBeTrue();
DB::flushQueryLog();
diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php
index 7415b74f..95672753 100644
--- a/tests/CommandsTest.php
+++ b/tests/CommandsTest.php
@@ -2,29 +2,45 @@
declare(strict_types=1);
-use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
+use Stancl\Tenancy\Tests\Etc\User;
+use Stancl\JobPipeline\JobPipeline;
+use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
-use Stancl\JobPipeline\JobPipeline;
-use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
+use Stancl\Tenancy\Jobs\DeleteDomains;
+use Illuminate\Support\Facades\Artisan;
use Stancl\Tenancy\Events\TenancyEnded;
-use Stancl\Tenancy\Events\TenancyInitialized;
-use Stancl\Tenancy\Events\TenantCreated;
use Stancl\Tenancy\Jobs\CreateDatabase;
+use Stancl\Tenancy\Jobs\DeleteDatabase;
+use Illuminate\Database\DatabaseManager;
+use Stancl\Tenancy\Events\TenantCreated;
+use Stancl\Tenancy\Events\TenantDeleted;
+use Stancl\Tenancy\Tests\Etc\TestSeeder;
+use Stancl\Tenancy\Events\DeletingTenant;
+use Stancl\Tenancy\Tests\Etc\ExampleSeeder;
+use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
-use Stancl\Tenancy\Tests\Etc\ExampleSeeder;
-use Stancl\Tenancy\Tests\Etc\Tenant;
+use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
beforeEach(function () {
+ if (file_exists($schemaPath = database_path('schema/tenant-schema.dump'))) {
+ unlink($schemaPath);
+ }
+
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
- config(['tenancy.bootstrappers' => [
- 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(TenancyEnded::class, RevertToCentralContext::class);
@@ -40,9 +56,9 @@ afterEach(function () {
test('migrate command doesnt change the db connection', function () {
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');
- $new_connection_name = app(\Illuminate\Database\DatabaseManager::class)->connection()->getName();
+ $new_connection_name = app(DatabaseManager::class)->connection()->getName();
expect(Schema::hasTable('users'))->toBeFalse();
expect($new_connection_name)->toEqual($old_connection_name);
@@ -103,6 +119,38 @@ test('dump command works', function () {
expect('tests/Etc/tenant-schema-test.dump')->toBeFile();
});
+test('tenant dump file gets created as tenant-schema.dump in the database schema folder by default', function() {
+ config(['tenancy.migration_parameters.--schema-path' => $schemaPath = database_path('schema/tenant-schema.dump')]);
+
+ $tenant = Tenant::create();
+ Artisan::call('tenants:migrate');
+
+ tenancy()->initialize($tenant);
+
+ Artisan::call('tenants:dump');
+
+ expect($schemaPath)->toBeFile();
+});
+
+test('migrate command uses the correct schema path by default', function () {
+ config(['tenancy.migration_parameters.--schema-path' => 'tests/Etc/tenant-schema.dump']);
+ $tenant = Tenant::create();
+
+ expect(Schema::hasTable('schema_users'))->toBeFalse();
+ expect(Schema::hasTable('users'))->toBeFalse();
+
+ Artisan::call('tenants:migrate');
+
+ expect(Schema::hasTable('schema_users'))->toBeFalse();
+ expect(Schema::hasTable('users'))->toBeFalse();
+
+ tenancy()->initialize($tenant);
+
+ // Check for both tables to see if missing migrations also get executed
+ expect(Schema::hasTable('schema_users'))->toBeTrue();
+ expect(Schema::hasTable('users'))->toBeTrue();
+});
+
test('rollback command works', function () {
$tenant = Tenant::create();
Artisan::call('tenants:migrate');
@@ -115,8 +163,22 @@ test('rollback command works', function () {
expect(Schema::hasTable('users'))->toBeFalse();
});
-// Incomplete test
-test('seed command works');
+test('seed command works', function (){
+ $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 () {
databaseConnectionSwitchedToDefault();
@@ -140,7 +202,9 @@ test('install command works', function () {
mkdir($dir, 0777, true);
}
- pest()->artisan('tenancy:install');
+ pest()->artisan('tenancy:install')
+ ->expectsConfirmation('Would you like to show your support by starring the project on GitHub?', 'no')
+ ->assertExitCode(0);
expect(base_path('routes/tenant.php'))->toBeFile();
expect(base_path('config/tenancy.php'))->toBeFile();
expect(app_path('Providers/TenancyServiceProvider.php'))->toBeFile();
@@ -175,10 +239,114 @@ test('run command with array of tenants works', function () {
Artisan::call('tenants:migrate-fresh');
pest()->artisan("tenants:run --tenants=$tenantId1 --tenants=$tenantId2 'foo foo --b=bar --c=xyz'")
- ->expectsOutput('Tenant: ' . $tenantId1)
- ->expectsOutput('Tenant: ' . $tenantId2);
+ ->expectsOutputToContain('Tenant: ' . $tenantId1)
+ ->expectsOutputToContain('Tenant: ' . $tenantId2)
+ ->assertExitCode(0);
});
+test('link command works', function() {
+ $tenantId1 = Tenant::create()->getTenantKey();
+ $tenantId2 = Tenant::create()->getTenantKey();
+ pest()->artisan('tenants:link')
+ ->assertExitCode(0);
+
+ $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,
+ ])->assertExitCode(0);
+
+ $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')
+ ->expectsOutputToContain("Tenant: $id.")
+ ->expectsOutput("User created: Abrar(email@localhost)")
+ ->assertExitCode(0);
+
+ // 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');
+});
+
+test('migrate fresh command only deletes tenant databases if drop_tenant_databases_on_migrate_fresh is true', function (bool $dropTenantDBsOnMigrateFresh) {
+ Event::listen(DeletingTenant::class,
+ JobPipeline::make([DeleteDomains::class])->send(function (DeletingTenant $event) {
+ return $event->tenant;
+ })->shouldBeQueued(false)->toListener()
+ );
+
+ Event::listen(
+ TenantDeleted::class,
+ JobPipeline::make([DeleteDatabase::class])->send(function (TenantDeleted $event) {
+ return $event->tenant;
+ })->shouldBeQueued(false)->toListener()
+ );
+
+ config(['tenancy.database.drop_tenant_databases_on_migrate_fresh' => $dropTenantDBsOnMigrateFresh]);
+ $shouldHaveDBAfterMigrateFresh = ! $dropTenantDBsOnMigrateFresh;
+
+ /** @var Tenant[] $tenants */
+ $tenants = [
+ Tenant::create(),
+ Tenant::create(),
+ Tenant::create(),
+ ];
+
+ $tenantHasDatabase = fn (Tenant $tenant) => $tenant->database()->manager()->databaseExists($tenant->database()->getName());
+
+ foreach ($tenants as $tenant) {
+ expect($tenantHasDatabase($tenant))->toBeTrue();
+ }
+
+ pest()->artisan('migrate:fresh', [
+ '--force' => true,
+ '--path' => __DIR__ . '/../assets/migrations',
+ '--realpath' => true,
+ ]);
+
+ foreach ($tenants as $tenant) {
+ expect($tenantHasDatabase($tenant))->toBe($shouldHaveDBAfterMigrateFresh);
+ }
+})->with([true, false]);
+
// todo@tests
function runCommandWorks(): void
{
diff --git a/tests/DatabasePreparationTest.php b/tests/DatabasePreparationTest.php
index e31fac9b..d5641af4 100644
--- a/tests/DatabasePreparationTest.php
+++ b/tests/DatabasePreparationTest.php
@@ -22,9 +22,7 @@ test('database can be created after tenant creation', function () {
})->toListener());
$tenant = Tenant::create();
-
- $manager = app(MySQLDatabaseManager::class);
- $manager->setConnection('mysql');
+ $manager = $tenant->database()->manager();
expect($manager->databaseExists($tenant->database()->getName()))->toBeTrue();
});
diff --git a/tests/DomainTest.php b/tests/DomainTest.php
index 594270e1..6995da24 100644
--- a/tests/DomainTest.php
+++ b/tests/DomainTest.php
@@ -81,7 +81,7 @@ test('tenant can be identified by domain', function () {
test('onfail logic can be customized', function () {
InitializeTenancyByDomain::$onFail = function () {
- return 'foo';
+ return response('foo');
};
pest()
diff --git a/tests/Etc/AddUserCommand.php b/tests/Etc/Console/AddUserCommand.php
similarity index 81%
rename from tests/Etc/AddUserCommand.php
rename to tests/Etc/Console/AddUserCommand.php
index 46e1fcbb..9b421f95 100644
--- a/tests/Etc/AddUserCommand.php
+++ b/tests/Etc/Console/AddUserCommand.php
@@ -2,16 +2,17 @@
declare(strict_types=1);
-namespace Stancl\Tenancy\Tests\Etc;
+namespace Stancl\Tenancy\Tests\Etc\Console;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
-use Stancl\Tenancy\Concerns\HasATenantsOption;
+use Stancl\Tenancy\Concerns\HasTenantOptions;
use Stancl\Tenancy\Concerns\TenantAwareCommand;
+use Stancl\Tenancy\Tests\Etc\User;
class AddUserCommand extends Command
{
- use TenantAwareCommand, HasATenantsOption;
+ use TenantAwareCommand, HasTenantOptions;
/**
* The name and signature of the console command.
diff --git a/tests/Etc/ConsoleKernel.php b/tests/Etc/Console/ConsoleKernel.php
similarity index 72%
rename from tests/Etc/ConsoleKernel.php
rename to tests/Etc/Console/ConsoleKernel.php
index a548f113..c5e5ee85 100644
--- a/tests/Etc/ConsoleKernel.php
+++ b/tests/Etc/Console/ConsoleKernel.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Stancl\Tenancy\Tests\Etc;
+namespace Stancl\Tenancy\Tests\Etc\Console;
use Orchestra\Testbench\Foundation\Console\Kernel;
@@ -10,6 +10,7 @@ class ConsoleKernel extends Kernel
{
protected $commands = [
ExampleCommand::class,
+ ExampleQuestionCommand::class,
AddUserCommand::class,
];
}
diff --git a/tests/Etc/ExampleCommand.php b/tests/Etc/Console/ExampleCommand.php
similarity index 94%
rename from tests/Etc/ExampleCommand.php
rename to tests/Etc/Console/ExampleCommand.php
index 49e7189b..72263b37 100644
--- a/tests/Etc/ExampleCommand.php
+++ b/tests/Etc/Console/ExampleCommand.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Stancl\Tenancy\Tests\Etc;
+namespace Stancl\Tenancy\Tests\Etc\Console;
use Illuminate\Console\Command;
diff --git a/tests/Etc/Console/ExampleQuestionCommand.php b/tests/Etc/Console/ExampleQuestionCommand.php
new file mode 100644
index 00000000..9a967054
--- /dev/null
+++ b/tests/Etc/Console/ExampleQuestionCommand.php
@@ -0,0 +1,46 @@
+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;
+ }
+}
diff --git a/tests/Etc/Tenant.php b/tests/Etc/Tenant.php
index 20a96072..f9a11d95 100644
--- a/tests/Etc/Tenant.php
+++ b/tests/Etc/Tenant.php
@@ -7,9 +7,14 @@ namespace Stancl\Tenancy\Tests\Etc;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\Concerns\HasDatabase;
use Stancl\Tenancy\Database\Concerns\HasDomains;
+use Stancl\Tenancy\Database\Concerns\HasPending;
+use Stancl\Tenancy\Database\Concerns\MaintenanceMode;
use Stancl\Tenancy\Database\Models;
+/**
+ * @method static static create(array $attributes = [])
+ */
class Tenant extends Models\Tenant implements TenantWithDatabase
{
- use HasDatabase, HasDomains;
+ use HasDatabase, HasDomains, HasPending, MaintenanceMode;
}
diff --git a/tests/Etc/synced_resource_migrations/users_extra/2019_08_08_000009_add_extra_column_to_central_users_table.php b/tests/Etc/synced_resource_migrations/users_extra/2019_08_08_000009_add_extra_column_to_central_users_table.php
new file mode 100644
index 00000000..bfa13cc1
--- /dev/null
+++ b/tests/Etc/synced_resource_migrations/users_extra/2019_08_08_000009_add_extra_column_to_central_users_table.php
@@ -0,0 +1,26 @@
+string('foo');
+ });
+ }
+
+ public function down()
+ {
+ }
+}
diff --git a/tests/GlobalCacheTest.php b/tests/GlobalCacheTest.php
index 8a13395c..ea38341b 100644
--- a/tests/GlobalCacheTest.php
+++ b/tests/GlobalCacheTest.php
@@ -50,3 +50,17 @@ test('global cache manager stores data in global cache', function () {
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
+});
diff --git a/tests/MaintenanceModeTest.php b/tests/MaintenanceModeTest.php
index 770dc5f2..86366e2f 100644
--- a/tests/MaintenanceModeTest.php
+++ b/tests/MaintenanceModeTest.php
@@ -2,14 +2,15 @@
declare(strict_types=1);
+use Illuminate\Support\Facades\Artisan;
+use Illuminate\Support\Facades\Event;
use Stancl\Tenancy\Database\Concerns\MaintenanceMode;
-use Symfony\Component\HttpKernel\Exception\HttpException;
use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Middleware\CheckTenantForMaintenanceMode;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
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 () {
return 'bar';
})->middleware([InitializeTenancyByDomain::class, CheckTenantForMaintenanceMode::class]);
@@ -19,16 +20,60 @@ test('tenant can be in maintenance mode', function () {
'domain' => 'acme.localhost',
]);
- pest()->get('http://acme.localhost/foo')
- ->assertSuccessful();
-
- tenancy()->end(); // flush stored tenant instance
+ pest()->get('http://acme.localhost/foo')->assertStatus(200);
$tenant->putDownForMaintenance();
- pest()->expectException(HttpException::class);
- pest()->withoutExceptionHandling()
- ->get('http://acme.localhost/foo');
+ tenancy()->end(); // End tenancy before making a request
+ pest()->get('http://acme.localhost/foo')->assertStatus(503);
+
+ $tenant->bringUpFromMaintenance();
+
+ tenancy()->end(); // End tenancy before making a request
+ pest()->get('http://acme.localhost/foo')->assertStatus(200);
+});
+
+test('maintenance mode events are fired', function () {
+ $tenant = MaintenanceTenant::create();
+
+ Event::fake();
+
+ $tenant->putDownForMaintenance();
+
+ Event::assertDispatched(\Stancl\Tenancy\Events\TenantMaintenanceModeEnabled::class);
+
+ $tenant->bringUpFromMaintenance();
+
+ Event::assertDispatched(\Stancl\Tenancy\Events\TenantMaintenanceModeDisabled::class);
+});
+
+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);
+
+ pest()->artisan('tenants:down')
+ ->expectsOutputToContain('Tenants are now in maintenance mode.')
+ ->assertExitCode(0);
+
+ Artisan::call('tenants:down');
+
+ tenancy()->end(); // End tenancy before making a request
+ pest()->get('http://acme.localhost/foo')->assertStatus(503);
+
+ pest()->artisan('tenants:up')
+ ->expectsOutputToContain('Tenants are now out of maintenance mode.')
+ ->assertExitCode(0);
+
+ tenancy()->end(); // End tenancy before making a request
+ pest()->get('http://acme.localhost/foo')->assertStatus(200);
});
class MaintenanceTenant extends Tenant
diff --git a/tests/PathIdentificationTest.php b/tests/PathIdentificationTest.php
index bda0cfcb..32880c4f 100644
--- a/tests/PathIdentificationTest.php
+++ b/tests/PathIdentificationTest.php
@@ -10,21 +10,18 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver;
use Stancl\Tenancy\Tests\Etc\Tenant;
beforeEach(function () {
- PathTenantResolver::$tenantParameterName = 'tenant';
-
Route::group([
'prefix' => '/{tenant}',
'middleware' => InitializeTenancyByPath::class,
], function () {
Route::get('/foo/{a}/{b}', function ($a, $b) {
return "$a + $b";
- });
- });
-});
+ })->name('foo');
-afterEach(function () {
- // Global state cleanup
- PathTenantResolver::$tenantParameterName = 'tenant';
+ Route::get('/baz/{a}/{b}', function ($a, $b) {
+ return "$a - $b";
+ })->name('baz');
+ });
});
test('tenant can be identified by path', function () {
@@ -67,7 +64,7 @@ test('exception is thrown when tenant cannot be identified by path', function ()
test('onfail logic can be customized', function () {
InitializeTenancyByPath::$onFail = function () {
- return 'foo';
+ return response('foo');
};
pest()
@@ -97,7 +94,7 @@ test('an exception is thrown when the routes first parameter is not tenant', fun
});
test('tenant parameter name can be customized', function () {
- PathTenantResolver::$tenantParameterName = 'team';
+ config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => 'team']);
Route::group([
'prefix' => '/{team}',
@@ -123,3 +120,23 @@ test('tenant parameter name can be customized', function () {
->withoutExceptionHandling()
->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
+});
diff --git a/tests/PendingTenantsTest.php b/tests/PendingTenantsTest.php
new file mode 100644
index 00000000..8dbda9ee
--- /dev/null
+++ b/tests/PendingTenantsTest.php
@@ -0,0 +1,209 @@
+count())->toBe(1);
+
+ Tenant::onlyPending()->first()->update([
+ 'pending_since' => null
+ ]);
+
+ expect(Tenant::onlyPending()->count())->toBe(0);
+});
+
+test('pending trait adds query scopes', function () {
+ Tenant::createPending();
+ Tenant::create();
+ Tenant::create();
+
+ expect(Tenant::onlyPending()->count())->toBe(1)
+ ->and(Tenant::withPending(true)->count())->toBe(3)
+ ->and(Tenant::withPending(false)->count())->toBe(2)
+ ->and(Tenant::withoutPending()->count())->toBe(2);
+
+});
+
+test('pending tenants can be created and deleted using commands', function () {
+ config(['tenancy.pending.count' => 4]);
+
+ Artisan::call(CreatePendingTenants::class);
+
+ expect(Tenant::onlyPending()->count())->toBe(4);
+
+ Artisan::call(ClearPendingTenants::class);
+
+ expect(Tenant::onlyPending()->count())->toBe(0);
+});
+
+test('CreatePendingTenants command can have an older than constraint', function () {
+ config(['tenancy.pending.count' => 2]);
+
+ Artisan::call(CreatePendingTenants::class);
+
+ tenancy()->model()->query()->onlyPending()->first()->update([
+ 'pending_since' => now()->subDays(5)->timestamp
+ ]);
+
+ Artisan::call('tenants:pending-clear --older-than-days=2');
+
+ expect(Tenant::onlyPending()->count())->toBe(1);
+});
+
+test('CreatePendingTenants command cannot run with both time constraints', function () {
+ pest()->artisan('tenants:pending-clear --older-than-days=2 --older-than-hours=2')
+ ->assertFailed();
+});
+
+test('CreatePendingTenants commands all option overrides any config constraints', function () {
+ Tenant::createPending();
+ Tenant::createPending();
+
+ tenancy()->model()->query()->onlyPending()->first()->update([
+ 'pending_since' => now()->subDays(10)
+ ]);
+
+ config(['tenancy.pending.older_than_days' => 4]);
+
+ Artisan::call(ClearPendingTenants::class, [
+ '--all' => true
+ ]);
+
+ expect(Tenant::onlyPending()->count())->toBe(0);
+});
+
+test('tenancy can check if there are any pending tenants', function () {
+ expect(Tenant::onlyPending()->exists())->toBeFalse();
+
+ Tenant::createPending();
+
+ expect(Tenant::onlyPending()->exists())->toBeTrue();
+});
+
+test('tenancy can pull a pending tenant', function () {
+ Tenant::createPending();
+
+ expect(Tenant::pullPendingFromPool())->toBeInstanceOf(Tenant::class);
+});
+
+test('pulling a tenant from the pending tenant pool removes it from the pool', function () {
+ Tenant::createPending();
+
+ expect(Tenant::onlyPending()->count())->toEqual(1);
+
+ Tenant::pullPendingFromPool();
+
+ expect(Tenant::onlyPending()->count())->toEqual(0);
+});
+
+test('a new tenant gets created while pulling a pending tenant if the pending pool is empty', function () {
+ expect(Tenant::withPending()->get()->count())->toBe(0); // All tenants
+
+ Tenant::pullPending();
+
+ expect(Tenant::withPending()->get()->count())->toBe(1); // All tenants
+});
+
+test('pending tenants are included in all queries based on the include_in_queries config', function () {
+ Tenant::createPending();
+
+ config(['tenancy.pending.include_in_queries' => false]);
+
+ expect(Tenant::all()->count())->toBe(0);
+
+ config(['tenancy.pending.include_in_queries' => true]);
+
+ expect(Tenant::all()->count())->toBe(1);
+});
+
+test('pending events are dispatched', function () {
+ Event::fake([
+ CreatingPendingTenant::class,
+ PendingTenantCreated::class,
+ PullingPendingTenant::class,
+ PendingTenantPulled::class,
+ ]);
+
+ Tenant::createPending();
+
+ Event::assertDispatched(CreatingPendingTenant::class);
+ Event::assertDispatched(PendingTenantCreated::class);
+
+ Tenant::pullPending();
+
+ Event::assertDispatched(PullingPendingTenant::class);
+ Event::assertDispatched(PendingTenantPulled::class);
+});
+
+test('commands do not run for pending tenants if tenancy.pending.include_in_queries is false and the with pending option does not get passed', function() {
+ config(['tenancy.pending.include_in_queries' => false]);
+
+ $tenants = collect([
+ Tenant::create(),
+ Tenant::create(),
+ Tenant::createPending(),
+ Tenant::createPending(),
+ ]);
+
+ pest()->artisan('tenants:migrate --with-pending');
+
+ $artisan = pest()->artisan("tenants:run 'foo foo --b=bar --c=xyz'");
+
+ $pendingTenants = $tenants->filter->pending();
+ $readyTenants = $tenants->reject->pending();
+
+ $pendingTenants->each(fn ($tenant) => $artisan->doesntExpectOutputToContain("Tenant: {$tenant->getTenantKey()}"));
+ $readyTenants->each(fn ($tenant) => $artisan->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}"));
+
+ $artisan->assertExitCode(0);
+});
+
+test('commands run for pending tenants too if tenancy.pending.include_in_queries is true', function() {
+ config(['tenancy.pending.include_in_queries' => true]);
+
+ $tenants = collect([
+ Tenant::create(),
+ Tenant::create(),
+ Tenant::createPending(),
+ Tenant::createPending(),
+ ]);
+
+ pest()->artisan('tenants:migrate --with-pending');
+
+ $artisan = pest()->artisan("tenants:run 'foo foo --b=bar --c=xyz'");
+
+ $tenants->each(fn ($tenant) => $artisan->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}"));
+
+ $artisan->assertExitCode(0);
+});
+
+test('commands run for pending tenants too if the with pending option is passed', function() {
+ config(['tenancy.pending.include_in_queries' => false]);
+
+ $tenants = collect([
+ Tenant::create(),
+ Tenant::create(),
+ Tenant::createPending(),
+ Tenant::createPending(),
+ ]);
+
+ pest()->artisan('tenants:migrate --with-pending');
+
+ $artisan = pest()->artisan("tenants:run 'foo foo --b=bar --c=xyz' --with-pending");
+
+ $tenants->each(fn ($tenant) => $artisan->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}"));
+
+ $artisan->assertExitCode(0);
+});
diff --git a/tests/RequestDataIdentificationTest.php b/tests/RequestDataIdentificationTest.php
index 81bdda53..e10c00e1 100644
--- a/tests/RequestDataIdentificationTest.php
+++ b/tests/RequestDataIdentificationTest.php
@@ -20,31 +20,39 @@ beforeEach(function () {
afterEach(function () {
InitializeTenancyByRequestData::$header = 'X-Tenant';
+ InitializeTenancyByRequestData::$cookie = 'X-Tenant';
InitializeTenancyByRequestData::$queryParameter = 'tenant';
});
test('header identification works', function () {
InitializeTenancyByRequestData::$header = 'X-Tenant';
$tenant = Tenant::create();
- $tenant2 = Tenant::create();
$this
->withoutExceptionHandling()
- ->get('test', [
- 'X-Tenant' => $tenant->id,
- ])
+ ->withHeader('X-Tenant', $tenant->id)
+ ->get('test')
->assertSee($tenant->id);
});
test('query parameter identification works', function () {
- InitializeTenancyByRequestData::$header = null;
InitializeTenancyByRequestData::$queryParameter = 'tenant';
$tenant = Tenant::create();
- $tenant2 = Tenant::create();
$this
->withoutExceptionHandling()
->get('test?tenant=' . $tenant->id)
->assertSee($tenant->id);
});
+
+test('cookie identification works', function () {
+ InitializeTenancyByRequestData::$cookie = 'X-Tenant';
+ $tenant = Tenant::create();
+
+ $this
+ ->withoutExceptionHandling()
+ ->withUnencryptedCookie('X-Tenant', $tenant->id)
+ ->get('test',)
+ ->assertSee($tenant->id);
+});
diff --git a/tests/ResourceSyncingTest.php b/tests/ResourceSyncingTest.php
index 214a9f47..430c52ef 100644
--- a/tests/ResourceSyncingTest.php
+++ b/tests/ResourceSyncingTest.php
@@ -44,9 +44,10 @@ beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
- UpdateSyncedResource::$shouldQueue = false; // global state cleanup
+ UpdateSyncedResource::$shouldQueue = false; // Global state cleanup
Event::listen(SyncedResourceSaved::class, UpdateSyncedResource::class);
+ // Run migrations on central connection
pest()->artisan('migrate', [
'--path' => [
__DIR__ . '/Etc/synced_resource_migrations',
@@ -83,7 +84,7 @@ test('only the synced columns are updated in the central db', function () {
]);
$tenant = ResourceTenant::create();
- migrateTenantsResource();
+ migrateUsersTableForTenants();
tenancy()->initialize($tenant);
@@ -126,6 +127,231 @@ test('only the synced columns are updated in the central db', function () {
], ResourceUser::first()->getAttributes());
});
+// This tests attribute list on the central side, and default values on the tenant side
+// Those two don't depend on each other, we're just testing having each option on each side
+// using tests that combine the two, to avoid having an excessively long and complex test suite
+test('sync resource creation works when central model provides attributes and resource model provides default values', function () {
+ [$tenant1, $tenant2] = createTenantsAndRunMigrations();
+
+ addExtraColumnToCentralDB();
+
+ $centralUser = CentralUserProvidingAttributeNames::create([
+ 'global_id' => 'acme',
+ 'name' => 'John Doe',
+ 'email' => 'john@localhost',
+ 'password' => 'secret',
+ 'role' => 'commenter',
+ 'foo' => 'bar', // foo does not exist in resource model
+ ]);
+
+ $tenant1->run(function () {
+ expect(ResourceUserProvidingDefaultValues::all())->toHaveCount(0);
+ });
+
+ // When central model provides the list of attributes, resource model will be created from the provided list of attributes' values
+ $centralUser->tenants()->attach('t1');
+
+ $tenant1->run(function () {
+ $resourceUser = ResourceUserProvidingDefaultValues::all();
+ expect($resourceUser)->toHaveCount(1);
+ expect($resourceUser->first()->global_id)->toBe('acme');
+ expect($resourceUser->first()->email)->toBe('john@localhost');
+ // 'foo' attribute is not provided by central model
+ expect($resourceUser->first()->foo)->toBeNull();
+ });
+
+ tenancy()->initialize($tenant2);
+
+ // When resource model provides the list of default values, central model will be created from the provided list of default values
+ ResourceUserProvidingDefaultValues::create([
+ 'global_id' => 'asdf',
+ 'name' => 'John Doe',
+ 'email' => 'john@localhost',
+ 'password' => 'secret',
+ 'role' => 'commenter',
+ ]);
+
+ tenancy()->end();
+
+ // Assert central user was created using the list of default values
+ $centralUser = CentralUserProvidingAttributeNames::whereGlobalId('asdf')->first();
+ expect($centralUser)->not()->toBeNull();
+ expect($centralUser->name)->toBe('Default Name');
+ expect($centralUser->email)->toBe('default@localhost');
+ expect($centralUser->password)->toBe('password');
+ expect($centralUser->role)->toBe('admin');
+ expect($centralUser->foo)->toBe('bar');
+});
+
+// This tests default values on the central side, and attribute list on the tenant side
+// Those two don't depend on each other, we're just testing having each option on each side
+// using tests that combine the two, to avoid having an excessively long and complex test suite
+test('sync resource creation works when central model provides default values and resource model provides attributes', function () {
+ [$tenant1, $tenant2] = createTenantsAndRunMigrations();
+
+ addExtraColumnToCentralDB();
+
+ $centralUser = CentralUserProvidingDefaultValues::create([
+ 'global_id' => 'acme',
+ 'name' => 'John Doe',
+ 'email' => 'john@localhost',
+ 'password' => 'secret',
+ 'role' => 'commenter',
+ 'foo' => 'bar', // foo does not exist in resource model
+ ]);
+
+ $tenant1->run(function () {
+ expect(ResourceUserProvidingDefaultValues::all())->toHaveCount(0);
+ });
+
+ // When central model provides the list of default values, resource model will be created from the provided list of default values
+ $centralUser->tenants()->attach('t1');
+
+ $tenant1->run(function () {
+ // Assert resource user was created using the list of default values
+ $resourceUser = ResourceUserProvidingDefaultValues::first();
+ expect($resourceUser)->not()->toBeNull();
+ expect($resourceUser->global_id)->toBe('acme');
+ expect($resourceUser->email)->toBe('default@localhost');
+ expect($resourceUser->password)->toBe('password');
+ expect($resourceUser->role)->toBe('admin');
+ });
+
+ tenancy()->initialize($tenant2);
+
+ // When resource model provides the list of attributes, central model will be created from the provided list of attributes' values
+ ResourceUserProvidingAttributeNames::create([
+ 'global_id' => 'asdf',
+ 'name' => 'John Doe',
+ 'email' => 'john@localhost',
+ 'password' => 'secret',
+ 'role' => 'commenter',
+ ]);
+
+ tenancy()->end();
+
+ // Assert central user was created using the list of provided attributes
+ $centralUser = CentralUserProvidingAttributeNames::whereGlobalId('asdf')->first();
+ expect($centralUser)->not()->toBeNull();
+ expect($centralUser->email)->toBe('john@localhost');
+ expect($centralUser->password)->toBe('secret');
+ expect($centralUser->role)->toBe('commenter');
+});
+
+// This tests mixed attribute list/defaults on the central side, and no specified attributes on the tenant side
+// Those two don't depend on each other, we're just testing having each option on each side
+// using tests that combine the two, to avoid having an excessively long and complex test suite
+test('sync resource creation works when central model provides mixture and resource model provides nothing', function () {
+ [$tenant1, $tenant2] = createTenantsAndRunMigrations();
+
+ $centralUser = CentralUserProvidingMixture::create([
+ 'global_id' => 'acme',
+ 'name' => 'John Doe',
+ 'email' => 'john@localhost',
+ 'password' => 'password',
+ 'role' => 'commentator'
+ ]);
+
+ $tenant1->run(function () {
+ expect(ResourceUser::all())->toHaveCount(0);
+ });
+
+ // When central model provides the list of a mixture (attributes and default values), resource model will be created from the provided list of mixture (attributes and default values)
+ $centralUser->tenants()->attach('t1');
+
+ $tenant1->run(function () {
+ $resourceUser = ResourceUser::first();
+
+ // Assert resource user was created using the provided attributes and default values
+ expect($resourceUser->global_id)->toBe('acme');
+ expect($resourceUser->name)->toBe('John Doe');
+ expect($resourceUser->email)->toBe('john@localhost');
+ // default values
+ expect($resourceUser->role)->toBe('admin');
+ expect($resourceUser->password)->toBe('secret');
+ });
+
+ tenancy()->initialize($tenant2);
+
+ // When resource model provides nothing/null, the central model will be created as a 1:1 copy of resource model
+ $resourceUser = ResourceUser::create([
+ 'global_id' => 'acmey',
+ 'name' => 'John Doe',
+ 'email' => 'john@localhost',
+ 'password' => 'password',
+ 'role' => 'commentator'
+ ]);
+
+ tenancy()->end();
+
+ $centralUser = CentralUserProvidingMixture::whereGlobalId('acmey')->first();
+ expect($resourceUser->getSyncedCreationAttributes())->toBeNull();
+
+ $centralUser = $centralUser->toArray();
+ $resourceUser = $resourceUser->toArray();
+ unset($centralUser['id']);
+ unset($resourceUser['id']);
+
+ // Assert central user created as 1:1 copy of resource model except "id"
+ expect($centralUser)->toBe($resourceUser);
+});
+
+// This tests no specified attributes on the central side, and mixed attribute list/defaults on the tenant side
+// Those two don't depend on each other, we're just testing having each option on each side
+// using tests that combine the two, to avoid having an excessively long and complex test suite
+test('sync resource creation works when central model provides nothing and resource model provides mixture', function () {
+ [$tenant1, $tenant2] = createTenantsAndRunMigrations();
+
+ $centralUser = CentralUser::create([
+ 'global_id' => 'acme',
+ 'name' => 'John Doe',
+ 'email' => 'john@localhost',
+ 'password' => 'password',
+ 'role' => 'commenter',
+ ]);
+
+ $tenant1->run(function () {
+ expect(ResourceUserProvidingMixture::all())->toHaveCount(0);
+ });
+
+ // When central model provides nothing/null, the resource model will be created as a 1:1 copy of central model
+ $centralUser->tenants()->attach('t1');
+
+ expect($centralUser->getSyncedCreationAttributes())->toBeNull();
+ $tenant1->run(function () use ($centralUser) {
+ $resourceUser = ResourceUserProvidingMixture::first();
+ expect($resourceUser)->not()->toBeNull();
+ $resourceUser = $resourceUser->toArray();
+ $centralUser = $centralUser->withoutRelations()->toArray();
+ unset($resourceUser['id']);
+ unset($centralUser['id']);
+
+ expect($resourceUser)->toBe($centralUser);
+ });
+
+ tenancy()->initialize($tenant2);
+
+ // When resource model provides the list of a mixture (attributes and default values), central model will be created from the provided list of mixture (attributes and default values)
+ ResourceUserProvidingMixture::create([
+ 'global_id' => 'absd',
+ 'name' => 'John Doe',
+ 'email' => 'john@localhost',
+ 'password' => 'password',
+ 'role' => 'commenter',
+ ]);
+
+ tenancy()->end();
+
+ $centralUser = CentralUser::whereGlobalId('absd')->first();
+
+ // Assert central user was created using the provided list of attributes and default values
+ expect($centralUser->name)->toBe('John Doe');
+ expect($centralUser->email)->toBe('john@localhost');
+ // default values
+ expect($centralUser->role)->toBe('admin');
+ expect($centralUser->password)->toBe('secret');
+});
+
test('creating the resource in tenant database creates it in central database and creates the mapping', function () {
creatingResourceInTenantDatabaseCreatesAndMapInCentralDatabase();
});
@@ -152,7 +378,7 @@ test('attaching a tenant to the central resource triggers a pull from the tenant
$tenant = ResourceTenant::create([
'id' => 't1',
]);
- migrateTenantsResource();
+ migrateUsersTableForTenants();
$tenant->run(function () {
expect(ResourceUser::all())->toHaveCount(0);
@@ -177,7 +403,7 @@ test('attaching users to tenants does not do anything', function () {
$tenant = ResourceTenant::create([
'id' => 't1',
]);
- migrateTenantsResource();
+ migrateUsersTableForTenants();
$tenant->run(function () {
expect(ResourceUser::all())->toHaveCount(0);
@@ -212,7 +438,7 @@ test('resources are synced only to workspaces that have the resource', function
$t3 = ResourceTenant::create([
'id' => 't3',
]);
- migrateTenantsResource();
+ migrateUsersTableForTenants();
$centralUser->tenants()->attach('t1');
$centralUser->tenants()->attach('t2');
@@ -250,7 +476,7 @@ test('when a resource exists in other tenant dbs but is created in a tenant db t
$t2 = ResourceTenant::create([
'id' => 't2',
]);
- migrateTenantsResource();
+ migrateUsersTableForTenants();
// Copy (cascade) user to t1 DB
$centralUser->tenants()->attach('t1');
@@ -298,7 +524,7 @@ test('the synced columns are updated in other tenant dbs where the resource exis
$t3 = ResourceTenant::create([
'id' => 't3',
]);
- migrateTenantsResource();
+ migrateUsersTableForTenants();
// Copy (cascade) user to t1 DB
$centralUser->tenants()->attach('t1');
@@ -353,7 +579,7 @@ test('when the resource doesnt exist in the tenant db non synced columns will ca
'id' => 't1',
]);
- migrateTenantsResource();
+ migrateUsersTableForTenants();
$centralUser->tenants()->attach('t1');
@@ -367,7 +593,7 @@ test('when the resource doesnt exist in the central db non synced columns will b
'id' => 't1',
]);
- migrateTenantsResource();
+ migrateUsersTableForTenants();
$t1->run(function () {
ResourceUser::create([
@@ -389,7 +615,7 @@ test('the listener can be queued', function () {
'id' => 't1',
]);
- migrateTenantsResource();
+ migrateUsersTableForTenants();
Queue::assertNothingPushed();
@@ -428,7 +654,7 @@ test('an event is fired for all touched resources', function () {
$t3 = ResourceTenant::create([
'id' => 't3',
]);
- migrateTenantsResource();
+ migrateUsersTableForTenants();
// Copy (cascade) user to t1 DB
$centralUser->tenants()->attach('t1');
@@ -509,7 +735,7 @@ function creatingResourceInTenantDatabaseCreatesAndMapInCentralDatabase()
expect(ResourceUser::all())->toHaveCount(0);
$tenant = ResourceTenant::create();
- migrateTenantsResource();
+ migrateUsersTableForTenants();
tenancy()->initialize($tenant);
@@ -524,7 +750,7 @@ function creatingResourceInTenantDatabaseCreatesAndMapInCentralDatabase()
tenancy()->end();
- // Asset user was created
+ // Assert user was created
expect(CentralUser::first()->global_id)->toBe('acme');
expect(CentralUser::first()->role)->toBe('commenter');
@@ -537,7 +763,28 @@ function creatingResourceInTenantDatabaseCreatesAndMapInCentralDatabase()
expect(ResourceUser::first()->role)->toBe('commenter');
}
-function migrateTenantsResource()
+/**
+ * Create two tenants and run migrations for those tenants.
+ */
+function createTenantsAndRunMigrations(): array
+{
+ [$tenant1, $tenant2] = [ResourceTenant::create(['id' => 't1']), ResourceTenant::create(['id' => 't2'])];
+
+ migrateUsersTableForTenants();
+
+ return [$tenant1, $tenant2];
+}
+
+function addExtraColumnToCentralDB(): void
+{
+ // migrate extra column "foo" in central DB
+ pest()->artisan('migrate', [
+ '--path' => __DIR__ . '/Etc/synced_resource_migrations/users_extra',
+ '--realpath' => true,
+ ])->assertExitCode(0);
+}
+
+function migrateUsersTableForTenants(): void
{
pest()->artisan('tenants:migrate', [
'--path' => __DIR__ . '/Etc/synced_resource_migrations/users',
@@ -593,6 +840,7 @@ class CentralUser extends Model implements SyncMaster
public function getSyncedAttributeNames(): array
{
return [
+ 'global_id',
'name',
'password',
'email',
@@ -628,9 +876,106 @@ class ResourceUser extends Model implements Syncable
public function getSyncedAttributeNames(): array
{
return [
+ 'global_id',
'name',
'password',
'email',
];
}
}
+
+// override method in ResourceUser class to return default attribute values
+class ResourceUserProvidingDefaultValues extends ResourceUser
+{
+ public function getSyncedCreationAttributes(): array
+ {
+ // Default values when creating resources from tenant to central DB
+ return
+ [
+ 'name' => 'Default Name',
+ 'email' => 'default@localhost',
+ 'password' => 'password',
+ 'role' => 'admin',
+ 'foo' => 'bar'
+ ];
+ }
+}
+
+// override method in ResourceUser class to return attribute names
+class ResourceUserProvidingAttributeNames extends ResourceUser
+{
+ public function getSyncedCreationAttributes(): array
+ {
+ // Attributes used when creating resources from tenant to central DB
+ // Notice here we are not adding "code" filed because it doesn't
+ // exist in central model
+ return
+ [
+ 'name',
+ 'password',
+ 'email',
+ 'role',
+ 'foo' => 'bar'
+ ];
+ }
+
+}
+
+// override method in CentralUser class to return attribute default values
+class CentralUserProvidingDefaultValues extends CentralUser
+{
+ public function getSyncedCreationAttributes(): array
+ {
+ // Attributes default values when creating resources from central to tenant model
+ return
+ [
+ 'name' => 'Default User',
+ 'email' => 'default@localhost',
+ 'password' => 'password',
+ 'role' => 'admin',
+ ];
+ }
+}
+
+// override method in CentralUser class to return attribute names
+class CentralUserProvidingAttributeNames extends CentralUser
+{
+ public function getSyncedCreationAttributes(): array
+ {
+ // Attributes used when creating resources from central to tenant DB
+ return
+ [
+ 'global_id',
+ 'name',
+ 'password',
+ 'email',
+ 'role',
+ ];
+ }
+}
+
+class CentralUserProvidingMixture extends CentralUser
+{
+ public function getSyncedCreationAttributes(): array
+ {
+ return [
+ 'name',
+ 'email',
+ 'role' => 'admin',
+ 'password' => 'secret',
+ ];
+ }
+}
+
+class ResourceUserProvidingMixture extends ResourceUser
+{
+ public function getSyncedCreationAttributes(): array
+ {
+ return [
+ 'name',
+ 'email',
+ 'role' => 'admin',
+ 'password' => 'secret',
+ ];
+ }
+}
diff --git a/tests/SingleDatabaseTenancyTest.php b/tests/SingleDatabaseTenancyTest.php
index 34b12383..ec0a0edf 100644
--- a/tests/SingleDatabaseTenancyTest.php
+++ b/tests/SingleDatabaseTenancyTest.php
@@ -13,8 +13,6 @@ use Stancl\Tenancy\Database\Concerns\HasScopedValidationRules;
use Stancl\Tenancy\Tests\Etc\Tenant as TestTenant;
beforeEach(function () {
- BelongsToTenant::$tenantIdColumn = 'tenant_id';
-
Schema::create('posts', function (Blueprint $table) {
$table->increments('id');
$table->string('text');
@@ -61,7 +59,7 @@ test('secondary models are not scoped to the current tenant when accessed direct
expect(Comment::count())->toBe(2);
});
-test('secondary models a r e scoped to the current tenant when accessed directly and parent relationship traitis used', function () {
+test('secondary models ARE scoped to the current tenant when accessed directly and parent relationship trait is used', function () {
$acme = Tenant::create([
'id' => 'acme',
]);
@@ -144,7 +142,7 @@ test('tenant id is not auto added when creating primary resources in central con
});
test('tenant id column name can be customized', function () {
- BelongsToTenant::$tenantIdColumn = 'team_id';
+ config(['tenancy.single_db.tenant_id_column' => 'team_id']);
Schema::drop('comments');
Schema::drop('posts');
@@ -207,13 +205,13 @@ test('the model returned by the tenant helper has unique and exists validation r
$uniqueFails = Validator::make($data, [
'slug' => 'unique:posts',
])->fails();
- $existsFails = Validator::make($data, [
+ $existsPass = Validator::make($data, [
'slug' => 'exists:posts',
- ])->fails();
+ ])->passes();
// Assert that 'unique' and 'exists' aren't scoped by default
- // pest()->assertFalse($uniqueFails); // todo get these two assertions to pass. for some reason, the validator is passing for both 'unique' and 'exists'
- // pest()->assertTrue($existsFails); // todo get these two assertions to pass. for some reason, the validator is passing for both 'unique' and 'exists'
+ expect($uniqueFails)->toBeTrue(); // Expect unique rule failed to pass because slug 'foo' already exists
+ expect($existsPass)->toBeTrue(); // Expect exists rule pass because slug 'foo' exists
$uniqueFails = Validator::make($data, [
'slug' => tenant()->unique('posts'),
diff --git a/tests/SubdomainTest.php b/tests/SubdomainTest.php
index 00096d8c..0ff52bc0 100644
--- a/tests/SubdomainTest.php
+++ b/tests/SubdomainTest.php
@@ -44,7 +44,7 @@ test('tenant can be identified by subdomain', function () {
test('onfail logic can be customized', function () {
InitializeTenancyBySubdomain::$onFail = function () {
- return 'foo';
+ return response('foo');
};
pest()
diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php
index d43b7989..a1cd0f5b 100644
--- a/tests/TenantAssetTest.php
+++ b/tests/TenantAssetTest.php
@@ -6,10 +6,8 @@ use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
-use Stancl\Tenancy\Controllers\TenantAssetsController;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
-use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
use Stancl\Tenancy\Tests\Etc\Tenant;
@@ -21,13 +19,8 @@ beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
});
-afterEach(function () {
- // Cleanup
- TenantAssetsController::$tenancyMiddleware = InitializeTenancyByDomain::class;
-});
-
test('asset can be accessed using the url returned by the tenant asset helper', function () {
- TenantAssetsController::$tenancyMiddleware = InitializeTenancyByRequestData::class;
+ config(['tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class]);
$tenant = Tenant::create();
tenancy()->initialize($tenant);
@@ -95,7 +88,7 @@ test('asset helper tenancy can be disabled', function () {
});
test('test asset controller returns a 404 when no path is provided', function () {
- TenantAssetsController::$tenancyMiddleware = InitializeTenancyByRequestData::class;
+ config(['tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class]);
$tenant = Tenant::create();
diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php
index ab25310c..19b74e21 100644
--- a/tests/TenantDatabaseManagerTest.php
+++ b/tests/TenantDatabaseManagerTest.php
@@ -3,11 +3,13 @@
declare(strict_types=1);
use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Stancl\JobPipeline\JobPipeline;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
+use Stancl\Tenancy\Database\Contracts\StatefulTenantDatabaseManager;
use Stancl\Tenancy\Database\DatabaseManager;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
@@ -36,7 +38,10 @@ test('databases can be created and deleted', function ($driver, $databaseManager
$name = 'db' . pest()->randomString();
$manager = app($databaseManager);
- $manager->setConnection($driver);
+
+ if ($manager instanceof StatefulTenantDatabaseManager) {
+ $manager->setConnection($driver);
+ }
expect($manager->databaseExists($name))->toBeFalse();
@@ -48,7 +53,7 @@ test('databases can be created and deleted', function ($driver, $databaseManager
expect($manager->databaseExists($name))->toBeTrue();
$manager->deleteDatabase($tenant);
expect($manager->databaseExists($name))->toBeFalse();
-})->with('database_manager_provider');
+})->with('database_managers');
test('dbs can be created when another driver is used for the central db', function () {
expect(config('database.default'))->toBe('central');
@@ -100,7 +105,7 @@ test('the tenant connection is fully removed', function () {
$tenant = Tenant::create();
- expect(array_keys(app('db')->getConnections()))->toBe(['central']);
+ expect(array_keys(app('db')->getConnections()))->toBe(['central', 'tenant_host_connection']);
pest()->assertArrayNotHasKey('tenant', config('database.connections'));
tenancy()->initialize($tenant);
@@ -154,9 +159,7 @@ test('schema manager uses schema to separate tenant dbs', function () {
]);
tenancy()->initialize($tenant);
- $schemaConfig = version_compare(app()->version(), '9.0', '>=') ?
- config('database.connections.' . config('database.default') . '.search_path') :
- config('database.connections.' . config('database.default') . '.schema');
+ $schemaConfig = config('database.connections.' . config('database.default') . '.search_path');
expect($schemaConfig)->toBe($tenant->database()->getName());
expect(config(['database.connections.pgsql.database']))->toBe($originalDatabaseName);
@@ -181,7 +184,7 @@ test('a tenants database cannot be created when the database already exists', fu
]);
});
-test('tenant database can be created on a foreign server', function () {
+test('tenant database can be created and deleted on a foreign server', function () {
config([
'tenancy.database.managers.mysql' => PermissionControlledMySQLDatabaseManager::class,
'database.connections.mysql2' => [
@@ -217,19 +220,178 @@ test('tenant database can be created on a foreign server', function () {
/** @var PermissionControlledMySQLDatabaseManager $manager */
$manager = $tenant->database()->manager();
- $manager->setConnection('mysql');
- expect($manager->databaseExists($name))->toBeFalse();
+ expect($manager->databaseExists($name))->toBeTrue(); // mysql2
- $manager->setConnection('mysql2');
+ $manager->setConnection('mysql');
+ expect($manager->databaseExists($name))->toBeFalse(); // check that the DB doesn't exist in 'mysql'
+
+ $manager->setConnection('mysql2'); // set the connection back
+ $manager->deleteDatabase($tenant);
+
+ expect($manager->databaseExists($name))->toBeFalse();
+});
+
+test('tenant database can be created on a foreign server by using the host from tenant config', function () {
+ config([
+ 'tenancy.database.managers.mysql' => MySQLDatabaseManager::class,
+ 'tenancy.database.template_tenant_connection' => 'mysql', // This will be overridden by tenancy_db_host
+ 'database.connections.mysql2' => [
+ 'driver' => 'mysql',
+ 'host' => 'mysql2',
+ 'port' => 3306,
+ 'database' => 'main',
+ 'username' => 'root',
+ 'password' => 'password',
+ 'unix_socket' => env('DB_SOCKET', ''),
+ 'charset' => 'utf8mb4',
+ 'collation' => 'utf8mb4_unicode_ci',
+ 'prefix' => '',
+ 'prefix_indexes' => true,
+ 'strict' => true,
+ 'engine' => null,
+ 'options' => extension_loaded('pdo_mysql') ? array_filter([
+ PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
+ ]) : [],
+ ],
+ ]);
+
+ Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
+ return $event->tenant;
+ })->toListener());
+
+ $name = 'foo' . Str::random(8);
+ $tenant = Tenant::create([
+ 'tenancy_db_name' => $name,
+ 'tenancy_db_host' => 'mysql2',
+ ]);
+
+ /** @var MySQLDatabaseManager $manager */
+ $manager = $tenant->database()->manager();
+
+ expect($manager->databaseExists($name))->toBeTrue();
+});
+
+test('database credentials can be provided to PermissionControlledMySQLDatabaseManager by specifying a connection', function () {
+ config([
+ 'tenancy.database.managers.mysql' => PermissionControlledMySQLDatabaseManager::class,
+ 'tenancy.database.template_tenant_connection' => 'mysql',
+ 'database.connections.mysql2' => [
+ 'driver' => 'mysql',
+ 'host' => 'mysql2',
+ 'port' => 3306,
+ 'database' => 'main',
+ 'username' => 'root',
+ 'password' => 'password',
+ 'unix_socket' => env('DB_SOCKET', ''),
+ 'charset' => 'utf8mb4',
+ 'collation' => 'utf8mb4_unicode_ci',
+ 'prefix' => '',
+ 'prefix_indexes' => true,
+ 'strict' => true,
+ 'engine' => null,
+ 'options' => extension_loaded('pdo_mysql') ? array_filter([
+ PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
+ ]) : [],
+ ],
+ ]);
+
+ // Create a new random database user with privileges to use with mysql2 connection
+ $username = 'dbuser' . Str::random(4);
+ $password = Str::random('8');
+ $mysql2DB = DB::connection('mysql2');
+ $mysql2DB->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}';");
+ $mysql2DB->statement("GRANT ALL PRIVILEGES ON *.* TO `{$username}`@`%` identified by '{$password}' WITH GRANT OPTION;");
+ $mysql2DB->statement("FLUSH PRIVILEGES;");
+
+ DB::purge('mysql2'); // forget the mysql2 connection so that it uses the new credentials the next time
+
+ config(['database.connections.mysql2.username' => $username]);
+ config(['database.connections.mysql2.password' => $password]);
+
+ Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
+ return $event->tenant;
+ })->toListener());
+
+ $name = 'foo' . Str::random(8);
+ $usernameForNewDB = 'user_for_new_db' . Str::random(4);
+ $passwordForNewDB = Str::random(8);
+ $tenant = Tenant::create([
+ 'tenancy_db_name' => $name,
+ 'tenancy_db_connection' => 'mysql2',
+ 'tenancy_db_username' => $usernameForNewDB,
+ 'tenancy_db_password' => $passwordForNewDB,
+ ]);
+
+ /** @var PermissionControlledMySQLDatabaseManager $manager */
+ $manager = $tenant->database()->manager();
+
+ expect($manager->database()->getConfig('username'))->toBe($username); // user created for the HOST connection
+ expect($manager->userExists($usernameForNewDB))->toBeTrue();
+ expect($manager->databaseExists($name))->toBeTrue();
+});
+
+test('tenant database can be created by using the username and password from tenant config', function () {
+ Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
+ return $event->tenant;
+ })->toListener());
+
+ config([
+ 'tenancy.database.managers.mysql' => MySQLDatabaseManager::class,
+ 'tenancy.database.template_tenant_connection' => 'mysql',
+ ]);
+
+ // Create a new random database user with privileges to use with `mysql` connection
+ $username = 'dbuser' . Str::random(4);
+ $password = Str::random('8');
+ $mysqlDB = DB::connection('mysql');
+ $mysqlDB->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}';");
+ $mysqlDB->statement("GRANT ALL PRIVILEGES ON *.* TO `{$username}`@`%` identified by '{$password}' WITH GRANT OPTION;");
+ $mysqlDB->statement("FLUSH PRIVILEGES;");
+
+ DB::purge('mysql2'); // forget the mysql2 connection so that it uses the new credentials the next time
+
+ // Remove `mysql` credentials to make sure we will be using the credentials from the tenant config
+ config(['database.connections.mysql.username' => null]);
+ config(['database.connections.mysql.password' => null]);
+
+ $name = 'foo' . Str::random(8);
+ $tenant = Tenant::create([
+ 'tenancy_db_name' => $name,
+ 'tenancy_db_username' => $username,
+ 'tenancy_db_password' => $password,
+ ]);
+
+ /** @var MySQLDatabaseManager $manager */
+ $manager = $tenant->database()->manager();
+
+ expect($manager->database()->getConfig('username'))->toBe($username); // user created for the HOST connection
expect($manager->databaseExists($name))->toBeTrue();
});
test('path used by sqlite manager can be customized', function () {
- pest()->markTestIncomplete();
+ Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
+ return $event->tenant;
+ })->toListener());
+
+ // Set custom path for SQLite file
+ SQLiteDatabaseManager::$path = $customPath = database_path('custom_' . Str::random(8));
+
+ if (! is_dir($customPath)) {
+ // Create custom directory
+ mkdir($customPath);
+ }
+
+ $name = Str::random(8). '.sqlite';
+ Tenant::create([
+ 'tenancy_db_name' => $name,
+ 'tenancy_db_connection' => 'sqlite',
+ ]);
+
+ expect(file_exists($customPath . '/' . $name))->toBeTrue();
});
// Datasets
-dataset('database_manager_provider', [
+dataset('database_managers', [
['mysql', MySQLDatabaseManager::class],
['mysql', PermissionControlledMySQLDatabaseManager::class],
['sqlite', SQLiteDatabaseManager::class],
diff --git a/tests/TenantModelTest.php b/tests/TenantModelTest.php
index b4fd38f6..fb62260c 100644
--- a/tests/TenantModelTest.php
+++ b/tests/TenantModelTest.php
@@ -18,6 +18,7 @@ use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Tests\Etc\Tenant;
use Stancl\Tenancy\UUIDGenerator;
+use Stancl\Tenancy\Exceptions\TenancyNotInitializedException;
test('created event is dispatched', function () {
Event::fake([TenantCreated::class]);
@@ -141,6 +142,31 @@ test('a command can be run on a collection of tenants', function () {
expect(Tenant::find('t2')->foo)->toBe('xyz');
});
+test('the current method returns the currently initialized tenant', function() {
+ tenancy()->initialize($tenant = Tenant::create());
+
+ expect(Tenant::current())->toBe($tenant);
+});
+
+test('the current method returns null if there is no currently initialized tenant', function() {
+ tenancy()->end();
+
+ expect(Tenant::current())->toBeNull();
+});
+
+test('currentOrFail method returns the currently initialized tenant', function() {
+ tenancy()->initialize($tenant = Tenant::create());
+
+ expect(Tenant::currentOrFail())->toBe($tenant);
+});
+
+test('currentOrFail method throws an exception if there is no currently initialized tenant', function() {
+ tenancy()->end();
+
+ expect(fn() => Tenant::currentOrFail())->toThrow(TenancyNotInitializedException::class);
+});
+
+
class MyTenant extends Tenant
{
protected $table = 'tenants';
diff --git a/tests/TenantUserImpersonationTest.php b/tests/TenantUserImpersonationTest.php
index 65aa380d..0fcb9022 100644
--- a/tests/TenantUserImpersonationTest.php
+++ b/tests/TenantUserImpersonationTest.php
@@ -4,25 +4,27 @@ declare(strict_types=1);
use Carbon\Carbon;
use Carbon\CarbonInterval;
+use Illuminate\Support\Str;
+use Illuminate\Auth\TokenGuard;
use Illuminate\Auth\SessionGuard;
+use Stancl\JobPipeline\JobPipeline;
use Illuminate\Support\Facades\Auth;
+use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
-use Illuminate\Support\Str;
-use Stancl\JobPipeline\JobPipeline;
-use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
-use Stancl\Tenancy\Database\Models\ImpersonationToken;
use Stancl\Tenancy\Events\TenancyEnded;
-use Stancl\Tenancy\Events\TenancyInitialized;
-use Stancl\Tenancy\Events\TenantCreated;
-use Stancl\Tenancy\Features\UserImpersonation;
use Stancl\Tenancy\Jobs\CreateDatabase;
+use Stancl\Tenancy\Events\TenantCreated;
+use Stancl\Tenancy\Events\TenancyInitialized;
+use Stancl\Tenancy\Features\UserImpersonation;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
-use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
-use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
-use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Foundation\Auth\User as Authenticable;
+use Stancl\Tenancy\Database\Models\ImpersonationToken;
+use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
+use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
+use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
+use Stancl\Tenancy\Exceptions\StatefulGuardRequiredException;
beforeEach(function () {
pest()->artisan('migrate', [
@@ -223,6 +225,46 @@ test('impersonation works with multiple models and guards', function () {
});
});
+test('impersonation tokens can be created only with stateful guards', function () {
+ config([
+ 'auth.guards' => [
+ 'nonstateful' => [
+ 'driver' => 'nonstateful',
+ 'provider' => 'provider',
+ ],
+ 'stateful' => [
+ 'driver' => 'session',
+ 'provider' => 'provider',
+ ],
+ ],
+ 'auth.providers.provider' => [
+ 'driver' => 'eloquent',
+ 'model' => ImpersonationUser::class,
+ ],
+ ]);
+
+ $tenant = Tenant::create();
+ migrateTenants();
+
+ $user = $tenant->run(function () {
+ return ImpersonationUser::create([
+ 'name' => 'Joe',
+ 'email' => 'joe@local',
+ 'password' => bcrypt('secret'),
+ ]);
+ });
+
+ Auth::extend('nonstateful', fn($app, $name, array $config) => new TokenGuard(Auth::createUserProvider($config['provider']), request()));
+
+ expect(fn() => tenancy()->impersonate($tenant, $user->id, '/dashboard', 'nonstateful'))
+ ->toThrow(StatefulGuardRequiredException::class);
+
+ Auth::extend('stateful', fn ($app, $name, array $config) => new SessionGuard($name, Auth::createUserProvider($config['provider']), session()));
+
+ expect(tenancy()->impersonate($tenant, $user->id, '/dashboard', 'stateful'))
+ ->toBeInstanceOf(ImpersonationToken::class);
+});
+
function migrateTenants()
{
pest()->artisan('tenants:migrate')->assertExitCode(0);
diff --git a/tests/TestCase.php b/tests/TestCase.php
index 1c6c6d8a..1c0ceb83 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -4,8 +4,15 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Tests;
+use Dotenv\Dotenv;
+use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Redis;
use PDO;
+use Stancl\Tenancy\Bootstrappers\BatchTenancyBootstrapper;
+use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper;
+use Stancl\Tenancy\Facades\GlobalCache;
+use Stancl\Tenancy\Facades\Tenancy;
+use Stancl\Tenancy\TenancyServiceProvider;
use Stancl\Tenancy\Tests\Etc\Tenant;
abstract class TestCase extends \Orchestra\Testbench\TestCase
@@ -42,13 +49,13 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
/**
* Define environment setup.
*
- * @param \Illuminate\Foundation\Application $app
+ * @param Application $app
* @return void
*/
protected function getEnvironmentSetUp($app)
{
if (file_exists(__DIR__ . '/../.env')) {
- \Dotenv\Dotenv::createImmutable(__DIR__ . '/..')->load();
+ Dotenv::createImmutable(__DIR__ . '/..')->load();
}
$app['config']->set([
@@ -96,7 +103,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
'--realpath' => true,
'--force' => true,
],
- 'tenancy.bootstrappers.redis' => \Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class,
+ 'tenancy.bootstrappers.redis' => RedisTenancyBootstrapper::class, // todo1 change this to []? two tests in TenantDatabaseManagerTest are failing with that
'queue.connections.central' => [
'driver' => 'sync',
'central' => true,
@@ -105,28 +112,28 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
'tenancy.tenant_model' => Tenant::class, // Use test tenant w/ DBs & domains
]);
- $app->singleton(\Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class);
+ $app->singleton(RedisTenancyBootstrapper::class); // todo (Samuel) use proper approach eg config for singleton registration
}
protected function getPackageProviders($app)
{
return [
- \Stancl\Tenancy\TenancyServiceProvider::class,
+ TenancyServiceProvider::class,
];
}
protected function getPackageAliases($app)
{
return [
- 'Tenancy' => \Stancl\Tenancy\Facades\Tenancy::class,
- 'GlobalCache' => \Stancl\Tenancy\Facades\GlobalCache::class,
+ 'Tenancy' => Tenancy::class,
+ 'GlobalCache' => GlobalCache::class,
];
}
/**
* Resolve application HTTP Kernel implementation.
*
- * @param \Illuminate\Foundation\Application $app
+ * @param Application $app
* @return void
*/
protected function resolveApplicationHttpKernel($app)
@@ -137,12 +144,12 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
/**
* Resolve application Console Kernel implementation.
*
- * @param \Illuminate\Foundation\Application $app
+ * @param Application $app
* @return void
*/
protected function resolveApplicationConsoleKernel($app)
{
- $app->singleton('Illuminate\Contracts\Console\Kernel', Etc\ConsoleKernel::class);
+ $app->singleton('Illuminate\Contracts\Console\Kernel', Etc\Console\ConsoleKernel::class);
}
public function randomString(int $length = 10)