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

Merge branch 'master' into add-skip-failing-options-to-migrate

This commit is contained in:
lukinovec 2023-01-06 06:51:23 +01:00 committed by GitHub
commit 29d13ae5b4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
86 changed files with 2001 additions and 236 deletions

View file

@ -103,3 +103,17 @@ jobs:
author_email: "phpcsfixer@example.com" author_email: "phpcsfixer@example.com"
message: Fix code style (php-cs-fixer) message: Fix code style (php-cs-fixer)
phpstan:
name: Static analysis (PHPStan)
runs-on: ubuntu-latest
steps:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: imagick, swoole
- uses: actions/checkout@v2
- name: Install composer dependencies
run: composer install
- name: Run phpstan
run: vendor/bin/phpstan analyse

1
.gitignore vendored
View file

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

View file

@ -10,8 +10,16 @@ Run `composer docker-up` to start the containers. Then run `composer test` to ru
If you need to pass additional flags to phpunit, use `./test --foo` instead of `composer test --foo`. Composer scripts unfortunately don't pass CLI arguments. If you need to pass additional flags to phpunit, use `./test --foo` instead of `composer test --foo`. Composer scripts unfortunately don't pass CLI arguments.
If you want to run a specific test (or test file), you can also use `./t 'name of the test'`. This is equivalent to `./test --no-coverage --filter 'name of the test'` (`--no-coverage` speeds up the execution time).
When you're done testing, run `composer docker-down` to shut down the containers. When you're done testing, run `composer docker-down` to shut down the containers.
### Debugging tests
If you're developing some feature and you encounter `SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry` errors, it's likely that some PHP errors were thrown in past test runs and prevented the test cleanup from running properly.
To fix this, simply delete the database memory by shutting down containers and starting them again: `composer docker-down && composer docker-up`.
### Docker on M1 ### Docker on M1
Run `composer docker-m1` to symlink `docker-compose-m1.override.yml` to `docker-compose.override.yml`. This will reconfigure a few services in the docker compose config to be compatible with M1. Run `composer docker-m1` to symlink `docker-compose-m1.override.yml` to `docker-compose.override.yml`. This will reconfigure a few services in the docker compose config to be compatible with M1.

View file

@ -1,7 +1,7 @@
# add amd64 platform to support Mac M1 # add amd64 platform to support Mac M1
FROM --platform=linux/amd64 shivammathur/node:latest-amd64 FROM --platform=linux/amd64 shivammathur/node:latest-amd64
ARG PHP_VERSION=8.1 ARG PHP_VERSION=8.2
WORKDIR /var/www/html WORKDIR /var/www/html

8
INTERNAL.md Normal file
View file

@ -0,0 +1,8 @@
# Internal development notes
## Updating the docker image used by the GH action
1. Login in to Docker Hub: `docker login -u archtechx -p`
2. Build the image (probably shut down docker-compose containers first): `docker-compose build --no-cache`
3. Tag a new image: `docker tag tenancy_test archtechx/tenancy:latest`
4. Push the image: `docker push archtechx/tenancy:latest`

View file

@ -61,6 +61,12 @@ class TenancyServiceProvider extends ServiceProvider
Events\TenantMaintenanceModeEnabled::class => [], Events\TenantMaintenanceModeEnabled::class => [],
Events\TenantMaintenanceModeDisabled::class => [], Events\TenantMaintenanceModeDisabled::class => [],
// Pending tenant events
Events\CreatingPendingTenant::class => [],
Events\PendingTenantCreated::class => [],
Events\PullingPendingTenant::class => [],
Events\PendingTenantPulled::class => [],
// Domain events // Domain events
Events\CreatingDomain::class => [], Events\CreatingDomain::class => [],
Events\DomainCreated::class => [], Events\DomainCreated::class => [],

View file

@ -6,10 +6,29 @@ use Stancl\Tenancy\Middleware;
use Stancl\Tenancy\Resolvers; use Stancl\Tenancy\Resolvers;
return [ return [
'tenant_model' => Stancl\Tenancy\Database\Models\Tenant::class, /**
'domain_model' => Stancl\Tenancy\Database\Models\Domain::class, * Configuration for the models used by Tenancy.
*/
'models' => [
'tenant' => Stancl\Tenancy\Database\Models\Tenant::class,
'domain' => Stancl\Tenancy\Database\Models\Domain::class,
'id_generator' => Stancl\Tenancy\UUIDGenerator::class, /**
* Name of the column used to relate models to tenants.
*
* This is used by the HasDomains trait, and models that use the BelongsToTenant trait (used in single-database tenancy).
*/
'tenant_key_column' => 'tenant_id',
/**
* Used for generating tenant IDs.
*
* - Feel free to override this with a custom class that implements the UniqueIdentifierGenerator interface.
* - To use autoincrement IDs, set this to null and update the `tenants` table migration to use an autoincrement column.
* SECURITY NOTE: Keep in mind that autoincrement IDs come with *potential* enumeration issues (such as tenant storage URLs).
*/
'id_generator' => Stancl\Tenancy\UUIDGenerator::class,
],
/** /**
* The list of domains hosting your central app. * The list of domains hosting your central app.
@ -83,9 +102,29 @@ return [
Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class, Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class,
Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class, Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class,
Stancl\Tenancy\Bootstrappers\BatchTenancyBootstrapper::class, Stancl\Tenancy\Bootstrappers\BatchTenancyBootstrapper::class,
// Stancl\Tenancy\Bootstrappers\MailTenancyBootstrapper::class, // Queueing mail requires using QueueTenancyBootstrapper with $forceRefresh set to true
// Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed // Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed
], ],
/**
* Pending tenants config.
* This is useful if you're looking for a way to always have a tenant ready to be used.
*/
'pending' => [
/**
* If disabled, pending tenants will be excluded from all tenant queries.
* You can still use ::withPending(), ::withoutPending() and ::onlyPending() to include or exclude the pending tenants regardless of this setting.
* Note: when disabled, this will also ignore pending tenants when running the tenant commands (migration, seed, etc.)
*/
'include_in_queries' => true,
/**
* Defines how many pending tenants you want to have ready in the pending tenant pool.
* This depends on the volume of tenants you're creating.
*/
'count' => env('TENANCY_PENDING_COUNT', 5),
],
/** /**
* Database tenancy config. Used by DatabaseTenancyBootstrapper. * Database tenancy config. Used by DatabaseTenancyBootstrapper.
*/ */
@ -98,6 +137,11 @@ return [
*/ */
'template_tenant_connection' => null, 'template_tenant_connection' => null,
/**
* The name of the temporary connection used for creating and deleting tenant databases.
*/
'tenant_host_connection_name' => 'tenant_host_connection',
/** /**
* Tenant database names are created like this: * Tenant database names are created like this:
* prefix + tenant_id + suffix. * prefix + tenant_id + suffix.
@ -258,6 +302,7 @@ return [
'migration_parameters' => [ 'migration_parameters' => [
'--force' => true, // This needs to be true to run migrations in production. '--force' => true, // This needs to be true to run migrations in production.
'--path' => [database_path('migrations/tenant')], '--path' => [database_path('migrations/tenant')],
'--schema-path' => database_path('schema/tenant-schema.dump'),
'--realpath' => true, '--realpath' => true,
], ],
@ -265,15 +310,7 @@ return [
* Parameters used by the tenants:seed command. * Parameters used by the tenants:seed command.
*/ */
'seeder_parameters' => [ 'seeder_parameters' => [
'--class' => 'DatabaseSeeder', // root seeder class '--class' => 'Database\Seeders\DatabaseSeeder', // root seeder class
// '--force' => true, // '--force' => true,
], ],
/**
* Single-database tenancy config.
*/
'single_db' => [
/** The name of the column used by models with the BelongsToTenant trait. */
'tenant_id_column' => 'tenant_id',
],
]; ];

View file

@ -5,8 +5,9 @@ declare(strict_types=1);
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Stancl\Tenancy\Tenancy;
class CreateTenantUserImpersonationTokensTable extends Migration return new class extends Migration
{ {
/** /**
* Run the migrations. * Run the migrations.
@ -17,13 +18,13 @@ class CreateTenantUserImpersonationTokensTable extends Migration
{ {
Schema::create('tenant_user_impersonation_tokens', function (Blueprint $table) { Schema::create('tenant_user_impersonation_tokens', function (Blueprint $table) {
$table->string('token', 128)->primary(); $table->string('token', 128)->primary();
$table->string('tenant_id'); $table->string(Tenancy::tenantKeyColumn());
$table->string('user_id'); $table->string('user_id');
$table->string('auth_guard'); $table->string('auth_guard');
$table->string('redirect_url'); $table->string('redirect_url');
$table->timestamp('created_at'); $table->timestamp('created_at');
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade'); $table->foreign(Tenancy::tenantKeyColumn())->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
}); });
} }
@ -36,4 +37,4 @@ class CreateTenantUserImpersonationTokensTable extends Migration
{ {
Schema::dropIfExists('tenant_user_impersonation_tokens'); Schema::dropIfExists('tenant_user_impersonation_tokens');
} }
} };

View file

@ -6,7 +6,7 @@ use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
class CreateTenantsTable extends Migration return new class extends Migration
{ {
/** /**
* Run the migrations. * Run the migrations.
@ -34,4 +34,4 @@ class CreateTenantsTable extends Migration
{ {
Schema::dropIfExists('tenants'); Schema::dropIfExists('tenants');
} }
} };

View file

@ -5,8 +5,9 @@ declare(strict_types=1);
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Stancl\Tenancy\Tenancy;
class CreateDomainsTable extends Migration return new class extends Migration
{ {
/** /**
* Run the migrations. * Run the migrations.
@ -18,10 +19,10 @@ class CreateDomainsTable extends Migration
Schema::create('domains', function (Blueprint $table) { Schema::create('domains', function (Blueprint $table) {
$table->increments('id'); $table->increments('id');
$table->string('domain', 255)->unique(); $table->string('domain', 255)->unique();
$table->string('tenant_id'); $table->string(Tenancy::tenantKeyColumn());
$table->timestamps(); $table->timestamps();
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade'); $table->foreign(Tenancy::tenantKeyColumn())->references('id')->on('tenants')->onUpdate('cascade');
}); });
} }
@ -34,4 +35,4 @@ class CreateDomainsTable extends Migration
{ {
Schema::dropIfExists('domains'); Schema::dropIfExists('domains');
} }
} };

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Controllers\TenantAssetController; use Stancl\Tenancy\Controllers\TenantAssetController;
// todo make this work with path identification
Route::get('/tenancy/assets/{path?}', [TenantAssetController::class, 'asset']) Route::get('/tenancy/assets/{path?}', [TenantAssetController::class, 'asset'])
->where('path', '(.*)') ->where('path', '(.*)')
->name('stancl.tenancy.asset'); ->name('stancl.tenancy.asset');

View file

@ -15,13 +15,13 @@
} }
], ],
"require": { "require": {
"php": "^8.1", "php": "^8.2",
"ext-json": "*", "ext-json": "*",
"illuminate/support": "^9.0", "illuminate/support": "^9.0",
"spatie/ignition": "^1.4", "spatie/ignition": "^1.4",
"ramsey/uuid": "^4.0", "ramsey/uuid": "^4.0",
"stancl/jobpipeline": "^1.0", "stancl/jobpipeline": "^1.0",
"stancl/virtualcolumn": "^1.0" "stancl/virtualcolumn": "^1.3"
}, },
"require-dev": { "require-dev": {
"laravel/framework": "^9.0", "laravel/framework": "^9.0",
@ -58,15 +58,16 @@
} }
}, },
"scripts": { "scripts": {
"docker-up": "PHP_VERSION=8.1 docker-compose up -d", "docker-up": "PHP_VERSION=8.2 docker-compose up -d",
"docker-down": "PHP_VERSION=8.1 docker-compose down", "docker-down": "PHP_VERSION=8.2 docker-compose down",
"docker-rebuild": "PHP_VERSION=8.1 docker-compose up -d --no-deps --build", "docker-rebuild": "PHP_VERSION=8.2 docker-compose up -d --no-deps --build",
"docker-m1": "ln -s docker-compose-m1.override.yml docker-compose.override.yml", "docker-m1": "ln -s docker-compose-m1.override.yml docker-compose.override.yml",
"coverage": "open coverage/phpunit/html/index.html", "coverage": "open coverage/phpunit/html/index.html",
"phpstan": "vendor/bin/phpstan", "phpstan": "vendor/bin/phpstan",
"phpstan-pro": "vendor/bin/phpstan --pro",
"cs": "php-cs-fixer fix --config=.php-cs-fixer.php", "cs": "php-cs-fixer fix --config=.php-cs-fixer.php",
"test": "PHP_VERSION=8.1 ./test --no-coverage", "test": "PHP_VERSION=8.2 ./test --no-coverage",
"test-full": "PHP_VERSION=8.1 ./test" "test-full": "PHP_VERSION=8.2 ./test"
}, },
"minimum-stability": "dev", "minimum-stability": "dev",
"prefer-stable": true, "prefer-stable": true,

View file

@ -4,7 +4,7 @@ services:
build: build:
context: . context: .
args: args:
PHP_VERSION: ${PHP_VERSION:-8.1} PHP_VERSION: ${PHP_VERSION:-8.2}
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy

View file

@ -16,16 +16,19 @@ parameters:
ignoreErrors: ignoreErrors:
- '#Cannot access offset (.*?) on Illuminate\\Contracts\\Foundation\\Application#' - '#Cannot access offset (.*?) on Illuminate\\Contracts\\Foundation\\Application#'
- '#Cannot access offset (.*?) on Illuminate\\Contracts\\Config\\Repository#' - '#Cannot access offset (.*?) on Illuminate\\Contracts\\Config\\Repository#'
-
message: '#Call to an undefined (method|static method) Illuminate\\Database\\Eloquent\\(Model|Builder)#'
paths:
- src/Commands/CreatePendingTenants.php
- src/Commands/ClearPendingTenants.php
- src/Database/Concerns/PendingScope.php
- src/Database/ParentModelScope.php
- -
message: '#invalid type Laravel\\Telescope\\IncomingEntry#' message: '#invalid type Laravel\\Telescope\\IncomingEntry#'
paths: paths:
- src/Features/TelescopeTags.php - src/Features/TelescopeTags.php
- -
message: '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model::getRelationshipToPrimaryModel\(\)#' message: '#Parameter \#1 \$key of method Illuminate\\Cache\\Repository::put\(\) expects#'
paths:
- src/Database/ParentModelScope.php
-
message: '#Parameter \#1 \$key of method Illuminate\\Contracts\\Cache\\Repository::put\(\) expects string#'
paths: paths:
- src/helpers.php - src/helpers.php
- -
@ -44,6 +47,9 @@ parameters:
message: '#Trying to invoke Closure\|null but it might not be a callable#' message: '#Trying to invoke Closure\|null but it might not be a callable#'
paths: paths:
- src/Database/DatabaseConfig.php - src/Database/DatabaseConfig.php
- '#Method Stancl\\Tenancy\\Tenancy::cachedResolvers\(\) should return array#'
- '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$tenancy#'
- '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$resolver#'
checkMissingIterableValueType: false checkMissingIterableValueType: false
treatPhpDocTypesAsCertain: false treatPhpDocTypesAsCertain: false

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers;
use Illuminate\Config\Repository;
use Illuminate\Foundation\Application;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
class MailTenancyBootstrapper implements TenancyBootstrapper
{
/**
* Tenant properties to be mapped to config (similarly to the TenantConfig feature).
*
* For example:
* [
* 'config.key.name' => 'tenant_property',
* ]
*/
public static array $credentialsMap = [];
public static string|null $mailer = null;
protected array $originalConfig = [];
public static array $mapPresets = [
'smtp' => [
'mail.mailers.smtp.host' => 'smtp_host',
'mail.mailers.smtp.port' => 'smtp_port',
'mail.mailers.smtp.username' => 'smtp_username',
'mail.mailers.smtp.password' => 'smtp_password',
],
];
public function __construct(
protected Repository $config,
protected Application $app
) {
static::$mailer ??= $config->get('mail.default');
static::$credentialsMap = array_merge(static::$credentialsMap, static::$mapPresets[static::$mailer] ?? []);
}
public function bootstrap(Tenant $tenant): void
{
// Forget the mail manager instance to clear the cached mailers
$this->app->forgetInstance('mail.manager');
$this->setConfig($tenant);
}
public function revert(): void
{
$this->unsetConfig();
$this->app->forgetInstance('mail.manager');
}
protected function setConfig(Tenant $tenant): void
{
foreach (static::$credentialsMap as $configKey => $storageKey) {
$override = $tenant->$storageKey;
if (array_key_exists($storageKey, $tenant->getAttributes())) {
$this->originalConfig[$configKey] ??= $this->config->get($configKey);
$this->config->set($configKey, $override);
}
}
}
protected function unsetConfig(): void
{
foreach ($this->originalConfig as $key => $value) {
$this->config->set($key, $value);
}
}
}

View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Commands;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
class ClearPendingTenants extends Command
{
protected $signature = 'tenants:pending-clear
{--older-than-days= : Deletes all pending tenants older than the amount of days}
{--older-than-hours= : Deletes all pending tenants older than the amount of hours}';
protected $description = 'Remove pending tenants.';
public function handle(): int
{
$this->components->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();
$olderThanDays = (int) $this->option('older-than-days');
$olderThanHours = (int) $this->option('older-than-hours');
if ($olderThanDays && $olderThanHours) {
$this->components->error("Cannot use '--older-than-days' and '--older-than-hours' together. 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->components->info($deletedTenantCount . ' pending ' . str('tenant')->plural($deletedTenantCount) . ' deleted.');
return 0;
}
}

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Commands;
use Illuminate\Console\Command;
class CreatePendingTenants extends Command
{
protected $signature = 'tenants:pending-create {--count= : The number of pending tenants to be created}';
protected $description = 'Create pending tenants.';
public function handle(): int
{
$this->components->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->components->info($createdCount . ' pending ' . str('tenant')->plural($createdCount) . ' created.');
$this->components->info($maxPendingTenantCount . ' pending ' . str('tenant')->plural($maxPendingTenantCount) . ' ready to be used.');
return 0;
}
/** Calculate the number of currently available pending tenants. */
protected function getPendingTenantCount(): int
{
return tenancy()->query()
->onlyPending()
->count();
}
}

View file

@ -5,11 +5,11 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Commands; namespace Stancl\Tenancy\Commands;
use Illuminate\Foundation\Console\DownCommand; use Illuminate\Foundation\Console\DownCommand;
use Stancl\Tenancy\Concerns\HasATenantsOption; use Stancl\Tenancy\Concerns\HasTenantOptions;
class Down extends DownCommand class Down extends DownCommand
{ {
use HasATenantsOption; use HasTenantOptions;
protected $signature = 'tenants:down protected $signature = 'tenants:down
{--redirect= : The path that users should be redirected to} {--redirect= : The path that users should be redirected to}

View file

@ -117,6 +117,7 @@ class Install extends Command
$this->newLine(); $this->newLine();
} }
} else { } else {
/** @var string $warning */
$this->components->warn($warning); $this->components->warn($warning);
} }
} }

View file

@ -9,11 +9,11 @@ use Illuminate\Console\Command;
use Illuminate\Support\LazyCollection; use Illuminate\Support\LazyCollection;
use Stancl\Tenancy\Actions\CreateStorageSymlinksAction; use Stancl\Tenancy\Actions\CreateStorageSymlinksAction;
use Stancl\Tenancy\Actions\RemoveStorageSymlinksAction; use Stancl\Tenancy\Actions\RemoveStorageSymlinksAction;
use Stancl\Tenancy\Concerns\HasATenantsOption; use Stancl\Tenancy\Concerns\HasTenantOptions;
class Link extends Command class Link extends Command
{ {
use HasATenantsOption; use HasTenantOptions;
protected $signature = 'tenants:link protected $signature = 'tenants:link
{--tenants=* : The tenant(s) to run the command for. Default: all} {--tenants=* : The tenant(s) to run the command for. Default: all}
@ -34,7 +34,7 @@ class Link extends Command
$this->createLinks($tenants); $this->createLinks($tenants);
} }
} catch (Exception $exception) { } catch (Exception $exception) {
$this->error($exception->getMessage()); $this->components->error($exception->getMessage());
return 1; return 1;
} }

View file

@ -9,14 +9,15 @@ use Illuminate\Database\Console\Migrations\MigrateCommand;
use Illuminate\Database\Migrations\Migrator; use Illuminate\Database\Migrations\Migrator;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use Stancl\Tenancy\Concerns\ExtendsLaravelCommand; use Stancl\Tenancy\Concerns\ExtendsLaravelCommand;
use Stancl\Tenancy\Concerns\HasATenantsOption; use Stancl\Tenancy\Concerns\DealsWithMigrations;
use Stancl\Tenancy\Concerns\HasTenantOptions;
use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException; use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException;
use Stancl\Tenancy\Events\DatabaseMigrated; use Stancl\Tenancy\Events\DatabaseMigrated;
use Stancl\Tenancy\Events\MigratingDatabase; use Stancl\Tenancy\Events\MigratingDatabase;
class Migrate extends MigrateCommand class Migrate extends MigrateCommand
{ {
use HasATenantsOption, ExtendsLaravelCommand; use HasTenantOptions, DealsWithMigrations, ExtendsLaravelCommand;
protected $description = 'Run migrations for tenant(s)'; protected $description = 'Run migrations for tenant(s)';

View file

@ -4,13 +4,14 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Commands; namespace Stancl\Tenancy\Commands;
use Illuminate\Console\Command; use Illuminate\Database\Console\Migrations\BaseCommand;
use Stancl\Tenancy\Concerns\HasATenantsOption; use Stancl\Tenancy\Concerns\DealsWithMigrations;
use Stancl\Tenancy\Concerns\HasTenantOptions;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
class MigrateFresh extends Command class MigrateFresh extends BaseCommand
{ {
use HasATenantsOption; use HasTenantOptions, DealsWithMigrations;
protected $description = 'Drop all tables and re-run all migrations for tenant(s)'; protected $description = 'Drop all tables and re-run all migrations for tenant(s)';

View file

@ -5,13 +5,18 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Commands; namespace Stancl\Tenancy\Commands;
use Illuminate\Database\Console\Migrations\FreshCommand; use Illuminate\Database\Console\Migrations\FreshCommand;
use Illuminate\Support\Facades\Schema;
class MigrateFreshOverride extends FreshCommand class MigrateFreshOverride extends FreshCommand
{ {
public function handle() public function handle()
{ {
if (config('tenancy.database.drop_tenant_databases_on_migrate_fresh')) { if (config('tenancy.database.drop_tenant_databases_on_migrate_fresh')) {
tenancy()->model()::cursor()->each->delete(); $tenantModel = tenancy()->model();
if (Schema::hasTable($tenantModel->getTable())) {
$tenantModel::cursor()->each->delete();
}
} }
return parent::handle(); return parent::handle();

View file

@ -6,14 +6,15 @@ namespace Stancl\Tenancy\Commands;
use Illuminate\Database\Console\Migrations\RollbackCommand; use Illuminate\Database\Console\Migrations\RollbackCommand;
use Illuminate\Database\Migrations\Migrator; use Illuminate\Database\Migrations\Migrator;
use Stancl\Tenancy\Concerns\DealsWithMigrations;
use Stancl\Tenancy\Concerns\ExtendsLaravelCommand; use Stancl\Tenancy\Concerns\ExtendsLaravelCommand;
use Stancl\Tenancy\Concerns\HasATenantsOption; use Stancl\Tenancy\Concerns\HasTenantOptions;
use Stancl\Tenancy\Events\DatabaseRolledBack; use Stancl\Tenancy\Events\DatabaseRolledBack;
use Stancl\Tenancy\Events\RollingBackDatabase; use Stancl\Tenancy\Events\RollingBackDatabase;
class Rollback extends RollbackCommand class Rollback extends RollbackCommand
{ {
use HasATenantsOption, ExtendsLaravelCommand; use HasTenantOptions, DealsWithMigrations, ExtendsLaravelCommand;
protected $description = 'Rollback migrations for tenant(s).'; protected $description = 'Rollback migrations for tenant(s).';

View file

@ -6,13 +6,13 @@ namespace Stancl\Tenancy\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Kernel; use Illuminate\Contracts\Console\Kernel;
use Stancl\Tenancy\Concerns\HasATenantsOption; use Stancl\Tenancy\Concerns\HasTenantOptions;
use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\ConsoleOutput;
class Run extends Command class Run extends Command
{ {
use HasATenantsOption; use HasTenantOptions;
protected $description = 'Run a command for tenant(s)'; protected $description = 'Run a command for tenant(s)';

View file

@ -6,13 +6,13 @@ namespace Stancl\Tenancy\Commands;
use Illuminate\Database\ConnectionResolverInterface; use Illuminate\Database\ConnectionResolverInterface;
use Illuminate\Database\Console\Seeds\SeedCommand; use Illuminate\Database\Console\Seeds\SeedCommand;
use Stancl\Tenancy\Concerns\HasATenantsOption; use Stancl\Tenancy\Concerns\HasTenantOptions;
use Stancl\Tenancy\Events\DatabaseSeeded; use Stancl\Tenancy\Events\DatabaseSeeded;
use Stancl\Tenancy\Events\SeedingDatabase; use Stancl\Tenancy\Events\SeedingDatabase;
class Seed extends SeedCommand class Seed extends SeedCommand
{ {
use HasATenantsOption; use HasTenantOptions;
protected $description = 'Seed tenant database(s).'; protected $description = 'Seed tenant database(s).';

View file

@ -22,6 +22,10 @@ class TenantDump extends DumpCommand
public function handle(ConnectionResolverInterface $connections, Dispatcher $dispatcher): int public function handle(ConnectionResolverInterface $connections, Dispatcher $dispatcher): int
{ {
if (is_null($this->option('path'))) {
$this->input->setOption('path', config('tenancy.migration_parameters.--schema-path') ?? database_path('schema/tenant-schema.dump'));
}
$tenant = $this->option('tenant') $tenant = $this->option('tenant')
?? tenant() ?? tenant()
?? $this->ask('What tenant do you want to dump the schema for?') ?? $this->ask('What tenant do you want to dump the schema for?')
@ -37,7 +41,7 @@ class TenantDump extends DumpCommand
return 1; return 1;
} }
parent::handle($connections, $dispatcher); $tenant->run(fn () => parent::handle($connections, $dispatcher));
return 0; return 0;
} }

View file

@ -5,11 +5,11 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Commands; namespace Stancl\Tenancy\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Stancl\Tenancy\Concerns\HasATenantsOption; use Stancl\Tenancy\Concerns\HasTenantOptions;
class Up extends Command class Up extends Command
{ {
use HasATenantsOption; use HasTenantOptions;
protected $signature = 'tenants:up'; protected $signature = 'tenants:up';

View file

@ -4,6 +4,9 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Concerns; namespace Stancl\Tenancy\Concerns;
/**
* @mixin \Illuminate\Database\Console\Migrations\BaseCommand
*/
trait DealsWithMigrations trait DealsWithMigrations
{ {
protected function getMigrationPaths(): array protected function getMigrationPaths(): array

View file

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

View file

@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* *
* @see \Stancl\Tenancy\Database\Models\Domain * @see \Stancl\Tenancy\Database\Models\Domain
* *
* @method __call(string $method, array $parameters) IDE support. This will be a model. * @method __call(string $method, array $parameters) IDE support. This will be a model. // todo check if we can remove these now
* @method static __callStatic(string $method, array $parameters) IDE support. This will be a model. * @method static __callStatic(string $method, array $parameters) IDE support. This will be a model.
* @mixin \Illuminate\Database\Eloquent\Model * @mixin \Illuminate\Database\Eloquent\Model
*/ */

View file

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

View file

@ -6,6 +6,7 @@ namespace Stancl\Tenancy\Database\Concerns;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Database\TenantScope; use Stancl\Tenancy\Database\TenantScope;
use Stancl\Tenancy\Tenancy;
/** /**
* @property-read Tenant $tenant * @property-read Tenant $tenant
@ -14,12 +15,7 @@ trait BelongsToTenant
{ {
public function tenant() public function tenant()
{ {
return $this->belongsTo(config('tenancy.tenant_model'), static::tenantIdColumn()); return $this->belongsTo(config('tenancy.models.tenant'), Tenancy::tenantKeyColumn());
}
public static function tenantIdColumn(): string
{
return config('tenancy.single_db.tenant_id_column');
} }
public static function bootBelongsToTenant(): void public static function bootBelongsToTenant(): void
@ -27,9 +23,9 @@ trait BelongsToTenant
static::addGlobalScope(new TenantScope); static::addGlobalScope(new TenantScope);
static::creating(function ($model) { static::creating(function ($model) {
if (! $model->getAttribute(static::tenantIdColumn()) && ! $model->relationLoaded('tenant')) { if (! $model->getAttribute(Tenancy::tenantKeyColumn()) && ! $model->relationLoaded('tenant')) {
if (tenancy()->initialized) { if (tenancy()->initialized) {
$model->setAttribute(static::tenantIdColumn(), tenant()->getTenantKey()); $model->setAttribute(Tenancy::tenantKeyColumn(), tenant()->getTenantKey());
$model->setRelation('tenant', tenant()); $model->setRelation('tenant', tenant());
} }
} }

View file

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

View file

@ -2,11 +2,10 @@
declare(strict_types=1); declare(strict_types=1);
// todo not sure if this should be in Database\
namespace Stancl\Tenancy\Database\Concerns; namespace Stancl\Tenancy\Database\Concerns;
use Stancl\Tenancy\Contracts\Domain; use Stancl\Tenancy\Contracts\Domain;
use Stancl\Tenancy\Tenancy;
/** /**
* @property-read Domain[]|\Illuminate\Database\Eloquent\Collection $domains * @property-read Domain[]|\Illuminate\Database\Eloquent\Collection $domains
@ -17,12 +16,12 @@ trait HasDomains
{ {
public function domains() public function domains()
{ {
return $this->hasMany(config('tenancy.domain_model'), 'tenant_id'); return $this->hasMany(config('tenancy.models.domain'), Tenancy::tenantKeyColumn());
} }
public function createDomain($data): Domain public function createDomain($data): Domain
{ {
$class = config('tenancy.domain_model'); $class = config('tenancy.models.domain');
if (! is_array($data)) { if (! is_array($data)) {
$data = ['domain' => $data]; $data = ['domain' => $data];

View file

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Events\CreatingPendingTenant;
use Stancl\Tenancy\Events\PendingTenantCreated;
use Stancl\Tenancy\Events\PendingTenantPulled;
use Stancl\Tenancy\Events\PullingPendingTenant;
// todo consider adding a method that sets pending_since to null — to flag tenants as not-pending
/**
* @property ?Carbon $pending_since
*
* @method static static|\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder withPending(bool $withPending = true)
* @method static static|\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder onlyPending()
* @method static static|\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder withoutPending()
*/
trait HasPending
{
/** Boot the trait. */
public static function bootHasPending(): void
{
static::addGlobalScope(new PendingScope());
}
/** Initialize the trait. */
public function initializeHasPending(): void
{
$this->casts['pending_since'] = 'timestamp';
}
/** Determine if the model instance is in a pending state. */
public function pending(): bool
{
return ! is_null($this->pending_since);
}
/**
* Create a pending tenant.
*
* @param array<string, mixed> $attributes
*/
public static function createPending(array $attributes = []): Model&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(): Model&Tenant
{
/** @var Model&Tenant $pendingTenant */
$pendingTenant = static::pullPendingFromPool(true);
return $pendingTenant;
}
/** 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
/** @var Model&Tenant $tenant */
$tenant = static::onlyPending()->first();
event(new PullingPendingTenant($tenant));
$tenant->update([
'pending_since' => null,
]);
event(new PendingTenantPulled($tenant));
return $tenant;
}
}

View file

@ -6,16 +6,17 @@ namespace Stancl\Tenancy\Database\Concerns;
use Illuminate\Validation\Rules\Exists; use Illuminate\Validation\Rules\Exists;
use Illuminate\Validation\Rules\Unique; use Illuminate\Validation\Rules\Unique;
use Stancl\Tenancy\Tenancy;
trait HasScopedValidationRules trait HasScopedValidationRules
{ {
public function unique($table, $column = 'NULL') public function unique($table, $column = 'NULL')
{ {
return (new Unique($table, $column))->where(BelongsToTenant::tenantIdColumn(), $this->getTenantKey()); return (new Unique($table, $column))->where(Tenancy::tenantKeyColumn(), $this->getTenantKey());
} }
public function exists($table, $column = 'NULL') public function exists($table, $column = 'NULL')
{ {
return (new Exists($table, $column))->where(BelongsToTenant::tenantIdColumn(), $this->getTenantKey()); return (new Exists($table, $column))->where(Tenancy::tenantKeyColumn(), $this->getTenantKey());
} }
} }

View file

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

View file

@ -13,8 +13,9 @@ trait ResourceSyncing
public static function bootResourceSyncing(): void public static function bootResourceSyncing(): void
{ {
static::saved(function (Syncable $model) { static::saved(function (Syncable $model) {
/** @var ResourceSyncing $model */ if ($model->shouldSync()) {
$model->triggerSyncEvent(); $model->triggerSyncEvent();
}
}); });
static::creating(function (self $model) { static::creating(function (self $model) {
@ -32,4 +33,14 @@ trait ResourceSyncing
/** @var Syncable $this */ /** @var Syncable $this */
event(new SyncedResourceSaved($this, tenant())); event(new SyncedResourceSaved($this, tenant()));
} }
public function getSyncedCreationAttributes(): array|null
{
return null;
}
public function shouldSync(): bool
{
return true;
}
} }

View file

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

View file

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

View file

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

View file

@ -28,7 +28,7 @@ class Domain extends Model implements Contracts\Domain
public function tenant(): BelongsTo public function tenant(): BelongsTo
{ {
return $this->belongsTo(config('tenancy.tenant_model')); return $this->belongsTo(config('tenancy.models.tenant'));
} }
protected $dispatchesEvents = [ protected $dispatchesEvents = [

View file

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

View file

@ -14,7 +14,7 @@ class TenantPivot extends Pivot
static::saved(function (self $pivot) { static::saved(function (self $pivot) {
$parent = $pivot->pivotParent; $parent = $pivot->pivotParent;
if ($parent instanceof Syncable) { if ($parent instanceof Syncable && $parent->shouldSync()) {
$parent->triggerSyncEvent(); $parent->triggerSyncEvent();
} }
}); });

View file

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

View file

@ -7,7 +7,7 @@ namespace Stancl\Tenancy\Database;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope; use Illuminate\Database\Eloquent\Scope;
use Stancl\Tenancy\Database\Concerns\BelongsToTenant; use Stancl\Tenancy\Tenancy;
class TenantScope implements Scope class TenantScope implements Scope
{ {
@ -17,7 +17,7 @@ class TenantScope implements Scope
return; return;
} }
$builder->where($model->qualifyColumn(BelongsToTenant::tenantIdColumn()), tenant()->getTenantKey()); $builder->where($model->qualifyColumn(Tenancy::tenantKeyColumn()), tenant()->getTenantKey());
} }
public function extend(Builder $builder): void public function extend(Builder $builder): void

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -20,7 +20,7 @@ class UserImpersonation implements Feature
{ {
$tenancy->macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string $authGuard = null): ImpersonationToken { $tenancy->macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string $authGuard = null): ImpersonationToken {
return ImpersonationToken::create([ return ImpersonationToken::create([
'tenant_id' => $tenant->getTenantKey(), Tenancy::tenantKeyColumn() => $tenant->getTenantKey(),
'user_id' => $userId, 'user_id' => $userId,
'redirect_url' => $redirectUrl, 'redirect_url' => $redirectUrl,
'auth_guard' => $authGuard, 'auth_guard' => $authGuard,
@ -39,7 +39,7 @@ class UserImpersonation implements Feature
abort_if($tokenExpired, 403); abort_if($tokenExpired, 403);
$tokenTenantId = (string) $token->tenant_id; $tokenTenantId = (string) $token->getAttribute(Tenancy::tenantKeyColumn());
$currentTenantId = (string) tenant()->getTenantKey(); $currentTenantId = (string) tenant()->getTenantKey();
abort_unless($tokenTenantId === $currentTenantId, 403); abort_unless($tokenTenantId === $currentTenantId, 403);
@ -48,6 +48,23 @@ class UserImpersonation implements Feature
$token->delete(); $token->delete();
session()->put('tenancy_impersonating', true);
return redirect($token->redirect_url); return redirect($token->redirect_url);
} }
public static function isImpersonating(): bool
{
return session()->has('tenancy_impersonating');
}
/**
* Logout from the current domain and forget impersonation session.
*/
public static function leave(): void // todo possibly rename
{
auth()->logout();
session()->forget('tenancy_impersonating');
}
} }

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Artisan;
use Stancl\Tenancy\Commands\ClearPendingTenants as ClearPendingTenantsCommand;
class ClearPendingTenants implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(): void
{
Artisan::call(ClearPendingTenantsCommand::class);
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Artisan;
use Stancl\Tenancy\Commands\CreatePendingTenants as CreatePendingTenantsCommand;
class CreatePendingTenants implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(): void
{
Artisan::call(CreatePendingTenantsCommand::class);
}
}

View file

@ -6,7 +6,7 @@ namespace Stancl\Tenancy\Listeners;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\DatabaseManager; use Stancl\Tenancy\Database\DatabaseManager;
use Stancl\Tenancy\Events\Contracts\TenantEvent; use Stancl\Tenancy\Events\Contracts\TenancyEvent;
class CreateTenantConnection class CreateTenantConnection
{ {
@ -15,11 +15,12 @@ class CreateTenantConnection
) { ) {
} }
public function handle(TenantEvent $event): void public function handle(TenancyEvent $event): void
{ {
/** @var TenantWithDatabase */ /** @var TenantWithDatabase $tenant */
$tenant = $event->tenant; $tenant = $event->tenancy->tenant;
$this->database->purgeTenantConnection();
$this->database->createTenantConnection($tenant); $this->database->createTenantConnection($tenant);
} }
} }

View file

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

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Listeners;
use Stancl\Tenancy\Database\DatabaseManager;
use Stancl\Tenancy\Events\Contracts\TenancyEvent;
class UseCentralConnection
{
public function __construct(
protected DatabaseManager $database,
) {
}
public function handle(TenancyEvent $event): void
{
$this->database->reconnectToCentral();
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Listeners;
use Stancl\Tenancy\Database\DatabaseManager;
use Stancl\Tenancy\Events\Contracts\TenancyEvent;
class UseTenantConnection
{
public function __construct(
protected DatabaseManager $database,
) {
}
public function handle(TenancyEvent $event): void
{
$this->database->setDefaultConnection('tenant');
}
}

View file

@ -45,8 +45,6 @@ class InitializeTenancyByPath extends IdentificationMiddleware
} else { } else {
throw new RouteIsMissingTenantParameterException; throw new RouteIsMissingTenantParameterException;
} }
return $next($request);
} }
protected function setDefaultTenantForRouteParametersWhenTenancyIsInitialized(): void protected function setDefaultTenantForRouteParametersWhenTenancyIsInitialized(): void

View file

@ -34,18 +34,17 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware
protected function getPayload(Request $request): ?string protected function getPayload(Request $request): ?string
{ {
$payload = null;
if (static::$header && $request->hasHeader(static::$header)) { if (static::$header && $request->hasHeader(static::$header)) {
return $request->header(static::$header); $payload = $request->header(static::$header);
} elseif (static::$queryParameter && $request->has(static::$queryParameter)) {
$payload = $request->get(static::$queryParameter);
} elseif (static::$cookie && $request->hasCookie(static::$cookie)) {
$payload = $request->cookie(static::$cookie);
} }
if (static::$queryParameter && $request->has(static::$queryParameter)) { /** @var ?string $payload */
return $request->get(static::$queryParameter); return $payload;
}
if (static::$cookie && $request->hasCookie(static::$cookie)) {
return $request->cookie(static::$cookie);
}
return null;
} }
} }

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Resolvers; namespace Stancl\Tenancy\Resolvers;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Contracts\Domain; use Stancl\Tenancy\Contracts\Domain;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
@ -18,7 +19,7 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver
{ {
$domain = $args[0]; $domain = $args[0];
$tenant = config('tenancy.tenant_model')::query() $tenant = config('tenancy.models.tenant')::query()
->whereHas('domains', fn (Builder $query) => $query->where('domain', $domain)) ->whereHas('domains', fn (Builder $query) => $query->where('domain', $domain))
->with('domains') ->with('domains')
->first(); ->first();
@ -39,14 +40,16 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver
protected function setCurrentDomain(Tenant $tenant, string $domain): void protected function setCurrentDomain(Tenant $tenant, string $domain): void
{ {
/** @var Tenant&Model $tenant */
static::$currentDomain = $tenant->domains->where('domain', $domain)->first(); static::$currentDomain = $tenant->domains->where('domain', $domain)->first();
} }
public function getArgsForTenant(Tenant $tenant): array public function getArgsForTenant(Tenant $tenant): array
{ {
/** @var Tenant&Model $tenant */
$tenant->unsetRelation('domains'); $tenant->unsetRelation('domains');
return $tenant->domains->map(function (Domain $domain) { return $tenant->domains->map(function (Domain&Model $domain) {
return [$domain->domain]; return [$domain->domain];
})->toArray(); })->toArray();
} }

View file

@ -15,7 +15,10 @@ class PathTenantResolver extends Contracts\CachedTenantResolver
/** @var Route $route */ /** @var Route $route */
$route = $args[0]; $route = $args[0];
if ($id = (string) $route->parameter(static::tenantParameterName())) { /** @var string $id */
$id = $route->parameter(static::tenantParameterName());
if ($id) {
$route->forgetParameter(static::tenantParameterName()); $route->forgetParameter(static::tenantParameterName());
if ($tenant = tenancy()->find($id)) { if ($tenant = tenancy()->find($id)) {

View file

@ -42,8 +42,7 @@ class Tenancy
} }
} }
// 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()) {
if ($this->initialized && $this->tenant->getTenantKey() === $tenant->getTenantKey()) {
return; return;
} }
@ -52,6 +51,7 @@ class Tenancy
$this->end(); $this->end();
} }
/** @var Tenant&Model $tenant */
$this->tenant = $tenant; $this->tenant = $tenant;
event(new Events\InitializingTenancy($this)); event(new Events\InitializingTenancy($this));
@ -97,7 +97,7 @@ class Tenancy
public static function model(): Tenant&Model public static function model(): Tenant&Model
{ {
$class = config('tenancy.tenant_model'); $class = config('tenancy.models.tenant');
/** @var Tenant&Model $model */ /** @var Tenant&Model $model */
$model = new $class; $model = new $class;
@ -105,6 +105,12 @@ class Tenancy
return $model; return $model;
} }
/** Name of the column used to relate models to tenants. */
public static function tenantKeyColumn(): string
{
return config('tenancy.models.tenant_key_column') ?? 'tenant_id';
}
/** /**
* Try to find a tenant using an ID. * Try to find a tenant using an ID.
* *
@ -112,6 +118,7 @@ class Tenancy
*/ */
public static function find(int|string $id): Tenant|null public static function find(int|string $id): Tenant|null
{ {
// todo update all syntax like this once we're fully on PHP 8.2
/** @var (Tenant&Model)|null */ /** @var (Tenant&Model)|null */
$tenant = static::model()->where(static::model()->getTenantKeyName(), $id)->first(); $tenant = static::model()->where(static::model()->getTenantKeyName(), $id)->first();
@ -156,7 +163,7 @@ class Tenancy
// Wrap string in array // Wrap string in array
$tenants = is_string($tenants) ? [$tenants] : $tenants; $tenants = is_string($tenants) ? [$tenants] : $tenants;
// Use all tenants if $tenants is falsey // Use all tenants if $tenants is falsy
$tenants = $tenants ?: $this->model()->cursor(); // todo1 phpstan thinks this isn't needed, but tests fail without it $tenants = $tenants ?: $this->model()->cursor(); // todo1 phpstan thinks this isn't needed, but tests fail without it
$originalTenant = $this->tenant; $originalTenant = $this->tenant;

View file

@ -54,9 +54,9 @@ class TenancyServiceProvider extends ServiceProvider
$this->app->singleton($bootstrapper); $this->app->singleton($bootstrapper);
} }
// Bind the class in the tenancy.id_generator config to the UniqueIdentifierGenerator abstract. // Bind the class in the tenancy.models.id_generator config to the UniqueIdentifierGenerator abstract.
if (! is_null($this->app['config']['tenancy.id_generator'])) { if (! is_null($this->app['config']['tenancy.models.id_generator'])) {
$this->app->bind(Contracts\UniqueIdentifierGenerator::class, $this->app['config']['tenancy.id_generator']); $this->app->bind(Contracts\UniqueIdentifierGenerator::class, $this->app['config']['tenancy.models.id_generator']);
} }
$this->app->singleton(Commands\Migrate::class, function ($app) { $this->app->singleton(Commands\Migrate::class, function ($app) {
@ -78,7 +78,9 @@ class TenancyServiceProvider extends ServiceProvider
public function boot(): void public function boot(): void
{ {
$this->commands([ $this->commands([
Commands\Up::class,
Commands\Run::class, Commands\Run::class,
Commands\Down::class,
Commands\Link::class, Commands\Link::class,
Commands\Seed::class, Commands\Seed::class,
Commands\Install::class, Commands\Install::class,
@ -87,8 +89,8 @@ class TenancyServiceProvider extends ServiceProvider
Commands\TenantList::class, Commands\TenantList::class,
Commands\TenantDump::class, Commands\TenantDump::class,
Commands\MigrateFresh::class, Commands\MigrateFresh::class,
Commands\Down::class, Commands\ClearPendingTenants::class,
Commands\Up::class, Commands\CreatePendingTenants::class,
]); ]);
$this->app->extend(FreshCommand::class, function () { $this->app->extend(FreshCommand::class, function () {

3
t Executable file
View file

@ -0,0 +1,3 @@
#!/bin/bash
docker-compose exec -T test vendor/bin/pest --no-coverage --filter "$@"

View file

@ -3,6 +3,7 @@
declare(strict_types=1); declare(strict_types=1);
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Mail\MailManager;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Stancl\JobPipeline\JobPipeline; use Stancl\JobPipeline\JobPipeline;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
@ -23,6 +24,7 @@ use Stancl\Tenancy\Jobs\RemoveStorageSymlinks;
use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\DeleteTenantStorage; use Stancl\Tenancy\Listeners\DeleteTenantStorage;
use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\MailTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
@ -326,20 +328,55 @@ test('local storage public urls are generated correctly', function() {
expect(File::isDirectory($tenantStoragePath))->toBeFalse(); expect(File::isDirectory($tenantStoragePath))->toBeFalse();
}); });
test('MailTenancyBootstrapper maps tenant mail credentials to config as specified in the $credentialsMap property and makes the mailer use tenant credentials', function() {
MailTenancyBootstrapper::$credentialsMap = [
'mail.mailers.smtp.username' => 'smtp_username',
'mail.mailers.smtp.password' => 'smtp_password'
];
config([
'mail.default' => 'smtp',
'mail.mailers.smtp.username' => $defaultUsername = 'default username',
'mail.mailers.smtp.password' => 'no password'
]);
$tenant = Tenant::create(['smtp_password' => $password = 'testing password']);
tenancy()->initialize($tenant);
expect(array_key_exists('smtp_password', tenant()->getAttributes()))->toBeTrue();
expect(array_key_exists('smtp_host', tenant()->getAttributes()))->toBeFalse();
expect(config('mail.mailers.smtp.username'))->toBe($defaultUsername);
expect(config('mail.mailers.smtp.password'))->toBe(tenant()->smtp_password);
// Assert that the current mailer uses tenant's smtp_password
assertMailerTransportUsesPassword($password);
});
test('MailTenancyBootstrapper reverts the config and mailer credentials to default when tenancy ends', function() {
MailTenancyBootstrapper::$credentialsMap = ['mail.mailers.smtp.password' => 'smtp_password'];
config(['mail.default' => 'smtp', 'mail.mailers.smtp.password' => $defaultPassword = 'no password']);
tenancy()->initialize(Tenant::create(['smtp_password' => $tenantPassword = 'testing password']));
expect(config('mail.mailers.smtp.password'))->toBe($tenantPassword);
assertMailerTransportUsesPassword($tenantPassword);
tenancy()->end();
expect(config('mail.mailers.smtp.password'))->toBe($defaultPassword);
// Assert that the current mailer uses the default SMTP password
assertMailerTransportUsesPassword($defaultPassword);
});
function getDiskPrefix(string $disk): string function getDiskPrefix(string $disk): string
{ {
/** @var FilesystemAdapter $disk */ /** @var FilesystemAdapter $disk */
$disk = Storage::disk($disk); $disk = Storage::disk($disk);
$adapter = $disk->getAdapter(); $adapter = $disk->getAdapter();
$prefix = invade(invade($adapter)->prefixer)->prefix;
$prefixer = (new ReflectionObject($adapter))->getProperty('prefixer'); return $prefix;
$prefixer->setAccessible(true);
// reflection -> instance
$prefixer = $prefixer->getValue($adapter);
$prefix = (new ReflectionProperty($prefixer, 'prefix'));
$prefix->setAccessible(true);
return $prefix->getValue($prefixer);
} }

View file

@ -16,7 +16,7 @@ beforeEach(function () {
}); });
}); });
config(['tenancy.tenant_model' => CombinedTenant::class]); config(['tenancy.models.tenant' => CombinedTenant::class]);
}); });
test('tenant can be identified by subdomain', function () { test('tenant can be identified by subdomain', function () {

View file

@ -27,14 +27,14 @@ use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException; use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException;
beforeEach(function () { beforeEach(function () {
if (file_exists($schemaPath = 'tests/Etc/tenant-schema-test.dump')) {
unlink($schemaPath);
}
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant; return $event->tenant;
})->toListener()); })->toListener());
config(['tenancy.bootstrappers' => [
DatabaseTenancyBootstrapper::class,
]]);
config([ config([
'tenancy.bootstrappers' => [ 'tenancy.bootstrappers' => [
DatabaseTenancyBootstrapper::class, DatabaseTenancyBootstrapper::class,
@ -153,12 +153,61 @@ test('migrate command does not stop after the first failure if skip-failing is p
test('dump command works', function () { test('dump command works', function () {
$tenant = Tenant::create(); $tenant = Tenant::create();
$schemaPath = 'tests/Etc/tenant-schema-test.dump';
Artisan::call('tenants:migrate'); Artisan::call('tenants:migrate');
expect($schemaPath)->not()->toBeFile();
Artisan::call('tenants:dump ' . "--tenant='$tenant->id' --path='$schemaPath'");
expect($schemaPath)->toBeFile();
});
test('dump command generates dump at the passed path', function() {
$tenant = Tenant::create();
Artisan::call('tenants:migrate');
expect($schemaPath = 'tests/Etc/tenant-schema-test.dump')->not()->toBeFile();
Artisan::call("tenants:dump --tenant='$tenant->id' --path='$schemaPath'");
expect($schemaPath)->toBeFile();
});
test('dump command generates dump at the path specified in the tenancy migration parameters config', function() {
config(['tenancy.migration_parameters.--schema-path' => $schemaPath = 'tests/Etc/tenant-schema-test.dump']);
$tenant = Tenant::create();
Artisan::call('tenants:migrate');
expect($schemaPath)->not()->toBeFile();
Artisan::call("tenants:dump --tenant='$tenant->id'");
expect($schemaPath)->toBeFile();
});
test('migrate command correctly uses the schema dump located at the configured 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); tenancy()->initialize($tenant);
Artisan::call('tenants:dump --path="tests/Etc/tenant-schema-test.dump"'); // schema_users is a table included in the tests/Etc/tenant-schema dump
expect('tests/Etc/tenant-schema-test.dump')->toBeFile(); // 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 () { test('rollback command works', function () {
@ -365,7 +414,7 @@ function runCommandWorks(): void
Artisan::call('tenants:migrate', ['--tenants' => [$id]]); Artisan::call('tenants:migrate', ['--tenants' => [$id]]);
pest()->artisan("tenants:run --tenants=$id 'foo foo --b=bar --c=xyz' ") pest()->artisan("tenants:run --tenants=$id 'foo foo --b=bar --c=xyz' ")
->expectsOutput("User's name is Test command") ->expectsOutput("User's name is Test user")
->expectsOutput('foo') ->expectsOutput('foo')
->expectsOutput('xyz'); ->expectsOutput('xyz');
} }

View file

@ -22,9 +22,7 @@ test('database can be created after tenant creation', function () {
})->toListener()); })->toListener());
$tenant = Tenant::create(); $tenant = Tenant::create();
$manager = $tenant->database()->manager();
$manager = app(MySQLDatabaseManager::class);
$manager->setConnection('mysql');
expect($manager->databaseExists($tenant->database()->getName()))->toBeTrue(); expect($manager->databaseExists($tenant->database()->getName()))->toBeTrue();
}); });

View file

@ -6,7 +6,7 @@ use Stancl\Tenancy\Database\Concerns\HasDomains;
use Stancl\Tenancy\Jobs\DeleteDomains; use Stancl\Tenancy\Jobs\DeleteDomains;
beforeEach(function () { beforeEach(function () {
config(['tenancy.tenant_model' => DatabaseAndDomainTenant::class]); config(['tenancy.models.tenant' => DatabaseAndDomainTenant::class]);
}); });
test('job delete domains successfully', function (){ test('job delete domains successfully', function (){
@ -29,4 +29,4 @@ test('job delete domains successfully', function (){
class DatabaseAndDomainTenant extends \Stancl\Tenancy\Tests\Etc\Tenant class DatabaseAndDomainTenant extends \Stancl\Tenancy\Tests\Etc\Tenant
{ {
use HasDomains; use HasDomains;
} }

View file

@ -21,7 +21,7 @@ beforeEach(function () {
}); });
}); });
config(['tenancy.tenant_model' => DomainTenant::class]); config(['tenancy.models.tenant' => DomainTenant::class]);
}); });
test('tenant can be identified using hostname', function () { test('tenant can be identified using hostname', function () {

View file

@ -6,13 +6,13 @@ namespace Stancl\Tenancy\Tests\Etc\Console;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Stancl\Tenancy\Concerns\HasATenantsOption; use Stancl\Tenancy\Concerns\HasTenantOptions;
use Stancl\Tenancy\Concerns\TenantAwareCommand; use Stancl\Tenancy\Concerns\TenantAwareCommand;
use Stancl\Tenancy\Tests\Etc\User; use Stancl\Tenancy\Tests\Etc\User;
class AddUserCommand extends Command class AddUserCommand extends Command
{ {
use TenantAwareCommand, HasATenantsOption; use TenantAwareCommand, HasTenantOptions;
/** /**
* The name and signature of the console command. * The name and signature of the console command.

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Tests\Etc\Console; namespace Stancl\Tenancy\Tests\Etc\Console;
use Illuminate\Support\Str;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class ExampleCommand extends Command class ExampleCommand extends Command
@ -22,14 +23,13 @@ class ExampleCommand extends Command
*/ */
public function handle() public function handle()
{ {
User::create([ $id = User::create([
'id' => 999, 'name' => 'Test user',
'name' => 'Test command', 'email' => Str::random(8) . '@example.com',
'email' => 'test@command.com',
'password' => bcrypt('password'), 'password' => bcrypt('password'),
]); ])->id;
$this->line("User's name is " . User::find(999)->name); $this->line("User's name is " . User::find($id)->name);
$this->line($this->argument('a')); $this->line($this->argument('a'));
$this->line($this->option('c')); $this->line($this->option('c'));
} }

View file

@ -7,6 +7,7 @@ namespace Stancl\Tenancy\Tests\Etc;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\Concerns\HasDatabase; use Stancl\Tenancy\Database\Concerns\HasDatabase;
use Stancl\Tenancy\Database\Concerns\HasDomains; use Stancl\Tenancy\Database\Concerns\HasDomains;
use Stancl\Tenancy\Database\Concerns\HasPending;
use Stancl\Tenancy\Database\Concerns\MaintenanceMode; use Stancl\Tenancy\Database\Concerns\MaintenanceMode;
use Stancl\Tenancy\Database\Models; use Stancl\Tenancy\Database\Models;
@ -15,5 +16,5 @@ use Stancl\Tenancy\Database\Models;
*/ */
class Tenant extends Models\Tenant implements TenantWithDatabase class Tenant extends Models\Tenant implements TenantWithDatabase
{ {
use HasDatabase, HasDomains, MaintenanceMode; use HasDatabase, HasDomains, HasPending, MaintenanceMode;
} }

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddExtraColumnToCentralUsersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->string('foo');
});
}
public function down()
{
}
}

72
tests/MailTest.php Normal file
View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
use Illuminate\Mail\MailManager;
use Illuminate\Support\Facades\Event;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\MailTenancyBootstrapper;
beforeEach(function() {
config(['mail.default' => 'smtp']);
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
});
// Initialize tenancy as $tenant and assert that the smtp mailer's transport has the correct password
function assertMailerTransportUsesPassword(string|null $password) {
$manager = app(MailManager::class);
$mailer = invade($manager)->get('smtp');
$mailerPassword = invade($mailer->getSymfonyTransport())->password;
expect($mailerPassword)->toBe((string) $password);
};
test('mailer transport uses the correct credentials', function() {
config(['mail.default' => 'smtp', 'mail.mailers.smtp.password' => $defaultPassword = 'DEFAULT']);
MailTenancyBootstrapper::$credentialsMap = ['mail.mailers.smtp.password' => 'smtp_password'];
tenancy()->initialize($tenant = Tenant::create());
assertMailerTransportUsesPassword($defaultPassword); // $tenant->smtp_password is not set, so the default password should be used
tenancy()->end();
// Assert mailer uses the updated password
$tenant->update(['smtp_password' => $newPassword = 'changed']);
tenancy()->initialize($tenant);
assertMailerTransportUsesPassword($newPassword);
tenancy()->end();
// Assert mailer uses the correct password after switching to a different tenant
tenancy()->initialize(Tenant::create(['smtp_password' => $newTenantPassword = 'updated']));
assertMailerTransportUsesPassword($newTenantPassword);
tenancy()->end();
// Assert mailer uses the default password after tenancy ends
assertMailerTransportUsesPassword($defaultPassword);
});
test('initializing and ending tenancy binds a fresh MailManager instance without cached mailers', function() {
$mailers = fn() => invade(app(MailManager::class))->mailers;
app(MailManager::class)->mailer('smtp');
expect($mailers())->toHaveCount(1);
tenancy()->initialize(Tenant::create());
expect($mailers())->toHaveCount(0);
app(MailManager::class)->mailer('smtp');
expect($mailers())->toHaveCount(1);
tenancy()->end();
expect($mailers())->toHaveCount(0);
});

45
tests/ManualModeTest.php Normal file
View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Event;
use Stancl\JobPipeline\JobPipeline;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Events\TenantCreated;
use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Listeners\CreateTenantConnection;
use Stancl\Tenancy\Listeners\UseCentralConnection;
use Stancl\Tenancy\Listeners\UseTenantConnection;
use \Stancl\Tenancy\Tests\Etc\Tenant;
test('manual tenancy initialization works', function () {
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
Event::listen(TenancyInitialized::class, CreateTenantConnection::class);
Event::listen(TenancyInitialized::class, UseTenantConnection::class);
Event::listen(TenancyEnded::class, UseCentralConnection::class);
$tenant = Tenant::create();
expect(app('db')->getDefaultConnection())->toBe('central');
expect(array_keys(app('db')->getConnections()))->toBe(['central', 'tenant_host_connection']);
pest()->assertArrayNotHasKey('tenant', config('database.connections'));
tenancy()->initialize($tenant);
// Trigger creation of the tenant connection
createUsersTable();
expect(app('db')->getDefaultConnection())->toBe('tenant');
expect(array_keys(app('db')->getConnections()))->toBe(['central', 'tenant']);
pest()->assertArrayHasKey('tenant', config('database.connections'));
tenancy()->end();
expect(array_keys(app('db')->getConnections()))->toBe(['central']);
expect(config('database.connections.tenant'))->toBeNull();
expect(app('db')->getDefaultConnection())->toBe(config('tenancy.database.central_connection'));
});

View file

@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Event;
use Stancl\Tenancy\Commands\ClearPendingTenants;
use Stancl\Tenancy\Commands\CreatePendingTenants;
use Stancl\Tenancy\Events\CreatingPendingTenant;
use Stancl\Tenancy\Events\PendingTenantCreated;
use Stancl\Tenancy\Events\PendingTenantPulled;
use Stancl\Tenancy\Events\PullingPendingTenant;
use Stancl\Tenancy\Tests\Etc\Tenant;
test('tenants are correctly identified as pending', function (){
Tenant::createPending();
expect(Tenant::onlyPending()->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('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);
});

View file

@ -44,9 +44,10 @@ beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::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); Event::listen(SyncedResourceSaved::class, UpdateSyncedResource::class);
// Run migrations on central connection
pest()->artisan('migrate', [ pest()->artisan('migrate', [
'--path' => [ '--path' => [
__DIR__ . '/Etc/synced_resource_migrations', __DIR__ . '/Etc/synced_resource_migrations',
@ -83,7 +84,7 @@ test('only the synced columns are updated in the central db', function () {
]); ]);
$tenant = ResourceTenant::create(); $tenant = ResourceTenant::create();
migrateTenantsResource(); migrateUsersTableForTenants();
tenancy()->initialize($tenant); tenancy()->initialize($tenant);
@ -126,6 +127,231 @@ test('only the synced columns are updated in the central db', function () {
], ResourceUser::first()->getAttributes()); ], 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 tenant 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(TenantUserProvidingDefaultValues::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 = TenantUserProvidingDefaultValues::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
TenantUserProvidingDefaultValues::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 tenant 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(TenantUserProvidingDefaultValues::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 = TenantUserProvidingDefaultValues::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
TenantUserProvidingAttributeNames::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 tenant 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 tenant 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(TenantUserProvidingMixture::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 = TenantUserProvidingMixture::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)
TenantUserProvidingMixture::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 () { test('creating the resource in tenant database creates it in central database and creates the mapping', function () {
creatingResourceInTenantDatabaseCreatesAndMapInCentralDatabase(); creatingResourceInTenantDatabaseCreatesAndMapInCentralDatabase();
}); });
@ -152,7 +378,7 @@ test('attaching a tenant to the central resource triggers a pull from the tenant
$tenant = ResourceTenant::create([ $tenant = ResourceTenant::create([
'id' => 't1', 'id' => 't1',
]); ]);
migrateTenantsResource(); migrateUsersTableForTenants();
$tenant->run(function () { $tenant->run(function () {
expect(ResourceUser::all())->toHaveCount(0); expect(ResourceUser::all())->toHaveCount(0);
@ -177,7 +403,7 @@ test('attaching users to tenants does not do anything', function () {
$tenant = ResourceTenant::create([ $tenant = ResourceTenant::create([
'id' => 't1', 'id' => 't1',
]); ]);
migrateTenantsResource(); migrateUsersTableForTenants();
$tenant->run(function () { $tenant->run(function () {
expect(ResourceUser::all())->toHaveCount(0); expect(ResourceUser::all())->toHaveCount(0);
@ -212,7 +438,7 @@ test('resources are synced only to workspaces that have the resource', function
$t3 = ResourceTenant::create([ $t3 = ResourceTenant::create([
'id' => 't3', 'id' => 't3',
]); ]);
migrateTenantsResource(); migrateUsersTableForTenants();
$centralUser->tenants()->attach('t1'); $centralUser->tenants()->attach('t1');
$centralUser->tenants()->attach('t2'); $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([ $t2 = ResourceTenant::create([
'id' => 't2', 'id' => 't2',
]); ]);
migrateTenantsResource(); migrateUsersTableForTenants();
// Copy (cascade) user to t1 DB // Copy (cascade) user to t1 DB
$centralUser->tenants()->attach('t1'); $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([ $t3 = ResourceTenant::create([
'id' => 't3', 'id' => 't3',
]); ]);
migrateTenantsResource(); migrateUsersTableForTenants();
// Copy (cascade) user to t1 DB // Copy (cascade) user to t1 DB
$centralUser->tenants()->attach('t1'); $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', 'id' => 't1',
]); ]);
migrateTenantsResource(); migrateUsersTableForTenants();
$centralUser->tenants()->attach('t1'); $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', 'id' => 't1',
]); ]);
migrateTenantsResource(); migrateUsersTableForTenants();
$t1->run(function () { $t1->run(function () {
ResourceUser::create([ ResourceUser::create([
@ -389,7 +615,7 @@ test('the listener can be queued', function () {
'id' => 't1', 'id' => 't1',
]); ]);
migrateTenantsResource(); migrateUsersTableForTenants();
Queue::assertNothingPushed(); Queue::assertNothingPushed();
@ -428,7 +654,7 @@ test('an event is fired for all touched resources', function () {
$t3 = ResourceTenant::create([ $t3 = ResourceTenant::create([
'id' => 't3', 'id' => 't3',
]); ]);
migrateTenantsResource(); migrateUsersTableForTenants();
// Copy (cascade) user to t1 DB // Copy (cascade) user to t1 DB
$centralUser->tenants()->attach('t1'); $centralUser->tenants()->attach('t1');
@ -509,7 +735,7 @@ function creatingResourceInTenantDatabaseCreatesAndMapInCentralDatabase()
expect(ResourceUser::all())->toHaveCount(0); expect(ResourceUser::all())->toHaveCount(0);
$tenant = ResourceTenant::create(); $tenant = ResourceTenant::create();
migrateTenantsResource(); migrateUsersTableForTenants();
tenancy()->initialize($tenant); tenancy()->initialize($tenant);
@ -524,7 +750,7 @@ function creatingResourceInTenantDatabaseCreatesAndMapInCentralDatabase()
tenancy()->end(); tenancy()->end();
// Asset user was created // Assert user was created
expect(CentralUser::first()->global_id)->toBe('acme'); expect(CentralUser::first()->global_id)->toBe('acme');
expect(CentralUser::first()->role)->toBe('commenter'); expect(CentralUser::first()->role)->toBe('commenter');
@ -537,7 +763,65 @@ function creatingResourceInTenantDatabaseCreatesAndMapInCentralDatabase()
expect(ResourceUser::first()->role)->toBe('commenter'); expect(ResourceUser::first()->role)->toBe('commenter');
} }
function migrateTenantsResource() test('resources are synced only when sync is enabled', function (bool $enabled) {
app()->instance('_tenancy_test_shouldSync', $enabled);
[$tenant1, $tenant2] = createTenantsAndRunMigrations();
migrateUsersTableForTenants();
tenancy()->initialize($tenant1);
TenantUserWithConditionalSync::create([
'global_id' => 'absd',
'name' => 'John Doe',
'email' => 'john@localhost',
'password' => 'password',
'role' => 'commenter',
]);
tenancy()->end();
expect(CentralUserWithConditionalSync::all())->toHaveCount($enabled ? 1 : 0);
expect(CentralUserWithConditionalSync::whereGlobalId('absd')->exists())->toBe($enabled);
$centralUser = CentralUserWithConditionalSync::create([
'global_id' => 'acme',
'name' => 'John Doe',
'email' => 'john@localhost',
'password' => 'password',
'role' => 'commenter',
]);
$centralUser->tenants()->attach('t2');
$tenant2->run(function () use ($enabled) {
expect(TenantUserWithConditionalSync::all())->toHaveCount($enabled ? 1 : 0);
expect(TenantUserWithConditionalSync::whereGlobalId('acme')->exists())->toBe($enabled);
});
})->with([[true], [false]]);
/**
* 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', [ pest()->artisan('tenants:migrate', [
'--path' => __DIR__ . '/Etc/synced_resource_migrations/users', '--path' => __DIR__ . '/Etc/synced_resource_migrations/users',
@ -545,6 +829,7 @@ function migrateTenantsResource()
])->assertExitCode(0); ])->assertExitCode(0);
} }
// Tenant model used for resource syncing setup
class ResourceTenant extends Tenant class ResourceTenant extends Tenant
{ {
public function users() public function users()
@ -593,6 +878,7 @@ class CentralUser extends Model implements SyncMaster
public function getSyncedAttributeNames(): array public function getSyncedAttributeNames(): array
{ {
return [ return [
'global_id',
'name', 'name',
'password', 'password',
'email', 'email',
@ -600,6 +886,7 @@ class CentralUser extends Model implements SyncMaster
} }
} }
// Tenant users
class ResourceUser extends Model implements Syncable class ResourceUser extends Model implements Syncable
{ {
use ResourceSyncing; use ResourceSyncing;
@ -628,9 +915,122 @@ class ResourceUser extends Model implements Syncable
public function getSyncedAttributeNames(): array public function getSyncedAttributeNames(): array
{ {
return [ return [
'global_id',
'name', 'name',
'password', 'password',
'email', 'email',
]; ];
} }
} }
// override method in ResourceUser class to return default attribute values
class TenantUserProvidingDefaultValues 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 TenantUserProvidingAttributeNames 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 TenantUserProvidingMixture extends ResourceUser
{
public function getSyncedCreationAttributes(): array
{
return [
'name',
'email',
'role' => 'admin',
'password' => 'secret',
];
}
}
class CentralUserWithConditionalSync extends CentralUser
{
public function shouldSync(): bool
{
return app('_tenancy_test_shouldSync');
}
}
class TenantUserWithConditionalSync extends ResourceUser
{
public function shouldSync(): bool
{
return app('_tenancy_test_shouldSync');
}
}

View file

@ -31,7 +31,7 @@ beforeEach(function () {
$table->foreign('post_id')->references('id')->on('posts')->onUpdate('cascade')->onDelete('cascade'); $table->foreign('post_id')->references('id')->on('posts')->onUpdate('cascade')->onDelete('cascade');
}); });
config(['tenancy.tenant_model' => Tenant::class]); config(['tenancy.models.tenant' => Tenant::class]);
}); });
test('primary models are scoped to the current tenant', function () { test('primary models are scoped to the current tenant', function () {
@ -142,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 () { test('tenant id column name can be customized', function () {
config(['tenancy.single_db.tenant_id_column' => 'team_id']); config(['tenancy.models.tenant_key_column' => 'team_id']);
Schema::drop('comments'); Schema::drop('comments');
Schema::drop('posts'); Schema::drop('posts');

View file

@ -20,7 +20,7 @@ beforeEach(function () {
}); });
}); });
config(['tenancy.tenant_model' => SubdomainTenant::class]); config(['tenancy.models.tenant' => SubdomainTenant::class]);
}); });
test('tenant can be identified by subdomain', function () { test('tenant can be identified by subdomain', function () {

View file

@ -3,11 +3,13 @@
declare(strict_types=1); declare(strict_types=1);
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Stancl\JobPipeline\JobPipeline; use Stancl\JobPipeline\JobPipeline;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Database\Contracts\StatefulTenantDatabaseManager;
use Stancl\Tenancy\Database\DatabaseManager; use Stancl\Tenancy\Database\DatabaseManager;
use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Events\TenancyInitialized;
@ -36,7 +38,10 @@ test('databases can be created and deleted', function ($driver, $databaseManager
$name = 'db' . pest()->randomString(); $name = 'db' . pest()->randomString();
$manager = app($databaseManager); $manager = app($databaseManager);
$manager->setConnection($driver);
if ($manager instanceof StatefulTenantDatabaseManager) {
$manager->setConnection($driver);
}
expect($manager->databaseExists($name))->toBeFalse(); expect($manager->databaseExists($name))->toBeFalse();
@ -48,7 +53,7 @@ test('databases can be created and deleted', function ($driver, $databaseManager
expect($manager->databaseExists($name))->toBeTrue(); expect($manager->databaseExists($name))->toBeTrue();
$manager->deleteDatabase($tenant); $manager->deleteDatabase($tenant);
expect($manager->databaseExists($name))->toBeFalse(); 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 () { test('dbs can be created when another driver is used for the central db', function () {
expect(config('database.default'))->toBe('central'); expect(config('database.default'))->toBe('central');
@ -100,7 +105,7 @@ test('the tenant connection is fully removed', function () {
$tenant = Tenant::create(); $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')); pest()->assertArrayNotHasKey('tenant', config('database.connections'));
tenancy()->initialize($tenant); tenancy()->initialize($tenant);
@ -179,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([ config([
'tenancy.database.managers.mysql' => PermissionControlledMySQLDatabaseManager::class, 'tenancy.database.managers.mysql' => PermissionControlledMySQLDatabaseManager::class,
'database.connections.mysql2' => [ 'database.connections.mysql2' => [
@ -215,10 +220,151 @@ test('tenant database can be created on a foreign server', function () {
/** @var PermissionControlledMySQLDatabaseManager $manager */ /** @var PermissionControlledMySQLDatabaseManager $manager */
$manager = $tenant->database()->manager(); $manager = $tenant->database()->manager();
$manager->setConnection('mysql'); expect($manager->databaseExists($name))->toBeTrue(); // mysql2
expect($manager->databaseExists($name))->toBeFalse();
$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(); expect($manager->databaseExists($name))->toBeTrue();
}); });
@ -241,11 +387,11 @@ test('path used by sqlite manager can be customized', function () {
'tenancy_db_connection' => 'sqlite', 'tenancy_db_connection' => 'sqlite',
]); ]);
expect(file_exists( $customPath . '/' . $name))->toBeTrue(); expect(file_exists($customPath . '/' . $name))->toBeTrue();
}); });
// Datasets // Datasets
dataset('database_manager_provider', [ dataset('database_managers', [
['mysql', MySQLDatabaseManager::class], ['mysql', MySQLDatabaseManager::class],
['mysql', PermissionControlledMySQLDatabaseManager::class], ['mysql', PermissionControlledMySQLDatabaseManager::class],
['sqlite', SQLiteDatabaseManager::class], ['sqlite', SQLiteDatabaseManager::class],

View file

@ -43,7 +43,7 @@ test('current tenant can be resolved from service container using typehint', fun
}); });
test('id is generated when no id is supplied', function () { test('id is generated when no id is supplied', function () {
config(['tenancy.id_generator' => UUIDGenerator::class]); config(['tenancy.models.id_generator' => UUIDGenerator::class]);
$this->mock(UUIDGenerator::class, function ($mock) { $this->mock(UUIDGenerator::class, function ($mock) {
return $mock->shouldReceive('generate')->once(); return $mock->shouldReceive('generate')->once();

View file

@ -83,6 +83,19 @@ test('tenant user can be impersonated on a tenant domain', function () {
pest()->get('http://foo.localhost/dashboard') pest()->get('http://foo.localhost/dashboard')
->assertSuccessful() ->assertSuccessful()
->assertSee('You are logged in as Joe'); ->assertSee('You are logged in as Joe');
expect(UserImpersonation::isImpersonating())->toBeTrue();
expect(session('tenancy_impersonating'))->toBeTrue();
// Leave impersonation
UserImpersonation::leave();
expect(UserImpersonation::isImpersonating())->toBeFalse();
expect(session('tenancy_impersonating'))->toBeNull();
// Assert can't access the tenant dashboard
pest()->get('http://foo.localhost/dashboard')
->assertRedirect('http://foo.localhost/login');
}); });
test('tenant user can be impersonated on a tenant path', function () { test('tenant user can be impersonated on a tenant path', function () {
@ -116,6 +129,19 @@ test('tenant user can be impersonated on a tenant path', function () {
pest()->get('/acme/dashboard') pest()->get('/acme/dashboard')
->assertSuccessful() ->assertSuccessful()
->assertSee('You are logged in as Joe'); ->assertSee('You are logged in as Joe');
expect(UserImpersonation::isImpersonating())->toBeTrue();
expect(session('tenancy_impersonating'))->toBeTrue();
// Leave impersonation
UserImpersonation::leave();
expect(UserImpersonation::isImpersonating())->toBeFalse();
expect(session('tenancy_impersonating'))->toBeNull();
// Assert can't access the tenant dashboard
pest()->get('/acme/dashboard')
->assertRedirect('/login');
}); });
test('tokens have a limited ttl', function () { test('tokens have a limited ttl', function () {

View file

@ -14,6 +14,7 @@ use Stancl\Tenancy\Facades\GlobalCache;
use Stancl\Tenancy\Facades\Tenancy; use Stancl\Tenancy\Facades\Tenancy;
use Stancl\Tenancy\TenancyServiceProvider; use Stancl\Tenancy\TenancyServiceProvider;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use Stancl\Tenancy\Bootstrappers\MailTenancyBootstrapper;
abstract class TestCase extends \Orchestra\Testbench\TestCase abstract class TestCase extends \Orchestra\Testbench\TestCase
{ {
@ -104,15 +105,17 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
'--force' => true, '--force' => true,
], ],
'tenancy.bootstrappers.redis' => RedisTenancyBootstrapper::class, // todo1 change this to []? two tests in TenantDatabaseManagerTest are failing with that 'tenancy.bootstrappers.redis' => RedisTenancyBootstrapper::class, // todo1 change this to []? two tests in TenantDatabaseManagerTest are failing with that
'tenancy.bootstrappers.mail' => MailTenancyBootstrapper::class,
'queue.connections.central' => [ 'queue.connections.central' => [
'driver' => 'sync', 'driver' => 'sync',
'central' => true, 'central' => true,
], ],
'tenancy.seeder_parameters' => [], 'tenancy.seeder_parameters' => [],
'tenancy.tenant_model' => Tenant::class, // Use test tenant w/ DBs & domains 'tenancy.models.tenant' => Tenant::class, // Use test tenant w/ DBs & domains
]); ]);
$app->singleton(RedisTenancyBootstrapper::class); // todo (Samuel) use proper approach eg config for singleton registration $app->singleton(RedisTenancyBootstrapper::class); // todo (Samuel) use proper approach eg config for singleton registration
$app->singleton(MailTenancyBootstrapper::class);
} }
protected function getPackageProviders($app) protected function getPackageProviders($app)