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:
commit
29d13ae5b4
86 changed files with 2001 additions and 236 deletions
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
|
|
@ -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
1
.gitignore
vendored
|
|
@ -1,4 +1,5 @@
|
||||||
.env
|
.env
|
||||||
|
.DS_Store
|
||||||
composer.lock
|
composer.lock
|
||||||
vendor/
|
vendor/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
8
INTERNAL.md
Normal 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`
|
||||||
|
|
@ -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 => [],
|
||||||
|
|
|
||||||
|
|
@ -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',
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
16
phpstan.neon
16
phpstan.neon
|
|
@ -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
|
||||||
|
|
|
||||||
79
src/Bootstrappers/MailTenancyBootstrapper.php
Normal file
79
src/Bootstrappers/MailTenancyBootstrapper.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/Commands/ClearPendingTenants.php
Normal file
57
src/Commands/ClearPendingTenants.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/Commands/CreatePendingTenants.php
Normal file
46
src/Commands/CreatePendingTenants.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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).';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
100
src/Database/Concerns/HasPending.php
Normal file
100
src/Database/Concerns/HasPending.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
88
src/Database/Concerns/PendingScope.php
Normal file
88
src/Database/Concerns/PendingScope.php
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
src/Database/Contracts/StatefulTenantDatabaseManager.php
Normal file
24
src/Database/Contracts/StatefulTenantDatabaseManager.php
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
9
src/Events/CreatingPendingTenant.php
Normal file
9
src/Events/CreatingPendingTenant.php
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Stancl\Tenancy\Events;
|
||||||
|
|
||||||
|
class CreatingPendingTenant extends Contracts\TenantEvent
|
||||||
|
{
|
||||||
|
}
|
||||||
9
src/Events/PendingTenantCreated.php
Normal file
9
src/Events/PendingTenantCreated.php
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Stancl\Tenancy\Events;
|
||||||
|
|
||||||
|
class PendingTenantCreated extends Contracts\TenantEvent
|
||||||
|
{
|
||||||
|
}
|
||||||
9
src/Events/PendingTenantPulled.php
Normal file
9
src/Events/PendingTenantPulled.php
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Stancl\Tenancy\Events;
|
||||||
|
|
||||||
|
class PendingTenantPulled extends Contracts\TenantEvent
|
||||||
|
{
|
||||||
|
}
|
||||||
9
src/Events/PullingPendingTenant.php
Normal file
9
src/Events/PullingPendingTenant.php
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Stancl\Tenancy\Events;
|
||||||
|
|
||||||
|
class PullingPendingTenant extends Contracts\TenantEvent
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
23
src/Jobs/ClearPendingTenants.php
Normal file
23
src/Jobs/ClearPendingTenants.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/Jobs/CreatePendingTenants.php
Normal file
23
src/Jobs/CreatePendingTenants.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
src/Listeners/UseCentralConnection.php
Normal file
21
src/Listeners/UseCentralConnection.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/Listeners/UseTenantConnection.php
Normal file
21
src/Listeners/UseTenantConnection.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
3
t
Executable file
|
|
@ -0,0 +1,3 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
docker-compose exec -T test vendor/bin/pest --no-coverage --filter "$@"
|
||||||
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 () {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 () {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
72
tests/MailTest.php
Normal 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
45
tests/ManualModeTest.php
Normal 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'));
|
||||||
|
});
|
||||||
192
tests/PendingTenantsTest.php
Normal file
192
tests/PendingTenantsTest.php
Normal 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);
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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 () {
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 () {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue