mirror of
https://github.com/archtechx/tenancy.git
synced 2026-02-04 12:14:04 +00:00
Merge remote-tracking branch 'origin/august' into tenant-param-before-route-prefix
This commit is contained in:
commit
d9ee7cac44
37 changed files with 344 additions and 211 deletions
10
.github/workflows/validate.yml
vendored
10
.github/workflows/validate.yml
vendored
|
|
@ -8,14 +8,8 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: Check for todo0
|
- name: Check for priority todos
|
||||||
run: '! grep -r "todo0" --exclude-dir=workflows .'
|
run: '! grep -r "todo[0-9]" --exclude-dir=workflows .'
|
||||||
if: always()
|
|
||||||
- name: Check for todo1
|
|
||||||
run: '! grep -r "todo1" --exclude-dir=workflows .'
|
|
||||||
if: always()
|
|
||||||
- name: Check for todo2
|
|
||||||
run: '! grep -r "todo2" --exclude-dir=workflows .'
|
|
||||||
if: always()
|
if: always()
|
||||||
- name: Check for non-todo skip()s in tests
|
- name: Check for non-todo skip()s in tests
|
||||||
run: '! grep -r "skip(" --exclude-dir=workflows tests/ | grep -v "todo"'
|
run: '! grep -r "skip(" --exclude-dir=workflows tests/ | grep -v "todo"'
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,21 @@ use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
|
||||||
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
|
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
|
||||||
use Stancl\Tenancy\Bootstrappers\Integrations\FortifyRouteBootstrapper;
|
use Stancl\Tenancy\Bootstrappers\Integrations\FortifyRouteBootstrapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenancy for Laravel.
|
||||||
|
*
|
||||||
|
* Documentation: https://tenancyforlaravel.com
|
||||||
|
*
|
||||||
|
* We can sustainably develop Tenancy for Laravel thanks to our sponsors.
|
||||||
|
* Big thanks to everyone listed here: https://github.com/sponsors/stancl
|
||||||
|
*
|
||||||
|
* You can also support us, and save time, by purchasing these products:
|
||||||
|
* Exclusive content for sponsors: https://sponsors.tenancyforlaravel.com
|
||||||
|
* Multi-Tenant SaaS boilerplate: https://portal.archte.ch/boilerplate
|
||||||
|
* Multi-Tenant Laravel in Production e-book: https://portal.archte.ch/book
|
||||||
|
*
|
||||||
|
* All of these products can also be accessed at https://portal.archte.ch
|
||||||
|
*/
|
||||||
class TenancyServiceProvider extends ServiceProvider
|
class TenancyServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
// By default, no namespace is used to support the callable array syntax.
|
// By default, no namespace is used to support the callable array syntax.
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,21 @@ use Stancl\Tenancy\Bootstrappers;
|
||||||
use Stancl\Tenancy\Enums\RouteMode;
|
use Stancl\Tenancy\Enums\RouteMode;
|
||||||
use Stancl\Tenancy\UniqueIdentifierGenerators;
|
use Stancl\Tenancy\UniqueIdentifierGenerators;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenancy for Laravel.
|
||||||
|
*
|
||||||
|
* Documentation: https://tenancyforlaravel.com
|
||||||
|
*
|
||||||
|
* We can sustainably develop Tenancy for Laravel thanks to our sponsors.
|
||||||
|
* Big thanks to everyone listed here: https://github.com/sponsors/stancl
|
||||||
|
*
|
||||||
|
* You can also support us, and save time, by purchasing these products:
|
||||||
|
* Exclusive content for sponsors: https://sponsors.tenancyforlaravel.com
|
||||||
|
* Multi-Tenant SaaS boilerplate: https://portal.archte.ch/boilerplate
|
||||||
|
* Multi-Tenant Laravel in Production e-book: https://portal.archte.ch/book
|
||||||
|
*
|
||||||
|
* All of these products can also be accessed at https://portal.archte.ch
|
||||||
|
*/
|
||||||
return [
|
return [
|
||||||
/**
|
/**
|
||||||
* Configuration for the models used by Tenancy.
|
* Configuration for the models used by Tenancy.
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ services:
|
||||||
image: mcr.microsoft.com/mssql/server:2022-latest
|
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||||
environment:
|
environment:
|
||||||
- ACCEPT_EULA=Y
|
- ACCEPT_EULA=Y
|
||||||
- SA_PASSWORD=P@ssword # todo reuse env from above
|
- SA_PASSWORD=P@ssword # must be the same as TENANCY_TEST_SQLSRV_PASSWORD
|
||||||
healthcheck: # https://github.com/Microsoft/mssql-docker/issues/133#issuecomment-1995615432
|
healthcheck: # https://github.com/Microsoft/mssql-docker/issues/133#issuecomment-1995615432
|
||||||
test: timeout 2 bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/1433'
|
test: timeout 2 bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/1433'
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,10 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper
|
||||||
throw new Exception('The template connection must NOT have URL defined. Specify the connection using individual parts instead of a database URL.');
|
throw new Exception('The template connection must NOT have URL defined. Specify the connection using individual parts instead of a database URL.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Better debugging, but breaks cached lookup in prod
|
// Better debugging, but breaks cached lookup, so we disable this in prod
|
||||||
if (app()->environment('local') || app()->environment('testing')) { // todo@docs mention this change in v4 upgrade guide https://github.com/archtechx/tenancy/pull/945#issuecomment-1268206149
|
if (app()->environment('local') || app()->environment('testing')) {
|
||||||
$database = $tenant->database()->getName();
|
$database = $tenant->database()->getName();
|
||||||
if (! $tenant->database()->manager()->databaseExists($database)) { // todo@samuel does this call correctly use the host connection?
|
if (! $tenant->database()->manager()->databaseExists($database)) { // todo@dbRefactor does this call correctly use the host connection?
|
||||||
throw new TenantDatabaseDoesNotExistException($database);
|
throw new TenantDatabaseDoesNotExistException($database);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use Illuminate\Console\Command;
|
||||||
|
|
||||||
class CreatePendingTenants extends Command
|
class CreatePendingTenants extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'tenants:pending-create {--count= : The number of pending tenants to be created}';
|
protected $signature = 'tenants:pending-create {--count= : The number of pending tenants to maintain}';
|
||||||
|
|
||||||
protected $description = 'Create pending tenants.';
|
protected $description = 'Create pending tenants.';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,13 +51,24 @@ class Migrate extends MigrateCommand
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->getProcesses() > 1) {
|
$originalTemplateConnection = config('tenancy.database.template_tenant_connection');
|
||||||
return $this->runConcurrently($this->getTenantChunks()->map(function ($chunk) {
|
|
||||||
return $this->getTenants($chunk);
|
if ($database = $this->input->getOption('database')) {
|
||||||
}));
|
config(['tenancy.database.template_tenant_connection' => $database]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->migrateTenants($this->getTenants()) ? 0 : 1;
|
if ($this->getProcesses() > 1) {
|
||||||
|
$code = $this->runConcurrently($this->getTenantChunks()->map(function ($chunk) {
|
||||||
|
return $this->getTenants($chunk);
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
$code = $this->migrateTenants($this->getTenants()) ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the template tenant connection to the original one
|
||||||
|
config(['tenancy.database.template_tenant_connection' => $originalTemplateConnection]);
|
||||||
|
|
||||||
|
return $code;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function childHandle(mixed ...$args): bool
|
protected function childHandle(mixed ...$args): bool
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Stancl\Tenancy\Commands;
|
namespace Stancl\Tenancy\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\ConfirmableTrait;
|
||||||
use Illuminate\Database\Console\Migrations\BaseCommand;
|
use Illuminate\Database\Console\Migrations\BaseCommand;
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Support\LazyCollection;
|
use Illuminate\Support\LazyCollection;
|
||||||
|
|
@ -17,7 +18,7 @@ use Symfony\Component\Console\Output\OutputInterface as OI;
|
||||||
|
|
||||||
class MigrateFresh extends BaseCommand
|
class MigrateFresh extends BaseCommand
|
||||||
{
|
{
|
||||||
use HasTenantOptions, DealsWithMigrations, ParallelCommand;
|
use HasTenantOptions, DealsWithMigrations, ParallelCommand, ConfirmableTrait;
|
||||||
|
|
||||||
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)';
|
||||||
|
|
||||||
|
|
@ -27,6 +28,7 @@ class MigrateFresh extends BaseCommand
|
||||||
|
|
||||||
$this->addOption('drop-views', null, InputOption::VALUE_NONE, 'Drop views along with tenant tables.', null);
|
$this->addOption('drop-views', null, InputOption::VALUE_NONE, 'Drop views along with tenant tables.', null);
|
||||||
$this->addOption('step', null, InputOption::VALUE_NONE, 'Force the migrations to be run so they can be rolled back individually.');
|
$this->addOption('step', null, InputOption::VALUE_NONE, 'Force the migrations to be run so they can be rolled back individually.');
|
||||||
|
$this->addOption('force', null, InputOption::VALUE_NONE, 'Force the command to run when in production.', null);
|
||||||
$this->addProcessesOption();
|
$this->addProcessesOption();
|
||||||
|
|
||||||
$this->setName('tenants:migrate-fresh');
|
$this->setName('tenants:migrate-fresh');
|
||||||
|
|
@ -34,6 +36,10 @@ class MigrateFresh extends BaseCommand
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
|
if (! $this->confirmToProceed()) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
$success = true;
|
$success = true;
|
||||||
|
|
||||||
if ($this->getProcesses() > 1) {
|
if ($this->getProcesses() > 1) {
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,8 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Stancl\Tenancy\Contracts;
|
namespace Stancl\Tenancy\Contracts;
|
||||||
|
|
||||||
use Stancl\Tenancy\Tenancy;
|
|
||||||
|
|
||||||
/** Additional features, like Telescope tags and tenant redirects. */
|
/** Additional features, like Telescope tags and tenant redirects. */
|
||||||
interface Feature
|
interface Feature
|
||||||
{
|
{
|
||||||
public function bootstrap(Tenancy $tenancy): void;
|
public function bootstrap(): void;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,13 @@ namespace Stancl\Tenancy\Database\Concerns;
|
||||||
|
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Stancl\Tenancy\Contracts\Tenant;
|
use Stancl\Tenancy\Contracts\Tenant;
|
||||||
use Stancl\Tenancy\Events\CreatingPendingTenant;
|
use Stancl\Tenancy\Events\CreatingPendingTenant;
|
||||||
use Stancl\Tenancy\Events\PendingTenantCreated;
|
use Stancl\Tenancy\Events\PendingTenantCreated;
|
||||||
use Stancl\Tenancy\Events\PendingTenantPulled;
|
use Stancl\Tenancy\Events\PendingTenantPulled;
|
||||||
use Stancl\Tenancy\Events\PullingPendingTenant;
|
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
|
* @property ?Carbon $pending_since
|
||||||
*
|
*
|
||||||
|
|
@ -50,46 +49,62 @@ trait HasPending
|
||||||
*/
|
*/
|
||||||
public static function createPending(array $attributes = []): Model&Tenant
|
public static function createPending(array $attributes = []): Model&Tenant
|
||||||
{
|
{
|
||||||
$tenant = static::create($attributes);
|
try {
|
||||||
|
$tenant = static::create($attributes);
|
||||||
event(new CreatingPendingTenant($tenant));
|
event(new CreatingPendingTenant($tenant));
|
||||||
|
} finally {
|
||||||
// Update the pending_since value only after the tenant is created so it's
|
// 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.
|
// not marked as pending until after migrations, seeders, etc are run.
|
||||||
$tenant->update([
|
$tenant->update([
|
||||||
'pending_since' => now()->timestamp,
|
'pending_since' => now()->timestamp,
|
||||||
]);
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
event(new PendingTenantCreated($tenant));
|
event(new PendingTenantCreated($tenant));
|
||||||
|
|
||||||
return $tenant;
|
return $tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pull a pending tenant. */
|
/**
|
||||||
public static function pullPending(): Model&Tenant
|
* Pull a pending tenant from the pool or create a new one if the pool is empty.
|
||||||
|
*
|
||||||
|
* @param array $attributes The attributes to set on the tenant.
|
||||||
|
*/
|
||||||
|
public static function pullPending(array $attributes = []): Model&Tenant
|
||||||
{
|
{
|
||||||
/** @var Model&Tenant $pendingTenant */
|
/** @var Model&Tenant $pendingTenant */
|
||||||
$pendingTenant = static::pullPendingFromPool(true);
|
$pendingTenant = static::pullPendingFromPool(true, $attributes);
|
||||||
|
|
||||||
return $pendingTenant;
|
return $pendingTenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Try to pull a tenant from the pool of pending tenants. */
|
/**
|
||||||
public static function pullPendingFromPool(bool $firstOrCreate = true, array $attributes = []): ?Tenant
|
* Try to pull a tenant from the pool of pending tenants.
|
||||||
|
*
|
||||||
|
* @param bool $firstOrCreate If true, a tenant will be *created* if the pool is empty. Otherwise null is returned.
|
||||||
|
* @param array $attributes The attributes to set on the tenant.
|
||||||
|
*/
|
||||||
|
public static function pullPendingFromPool(bool $firstOrCreate = false, array $attributes = []): ?Tenant
|
||||||
{
|
{
|
||||||
/** @var (Model&Tenant)|null $tenant */
|
$tenant = DB::transaction(function () use ($attributes): ?Tenant {
|
||||||
$tenant = static::onlyPending()->first();
|
/** @var (Model&Tenant)|null $tenant */
|
||||||
|
$tenant = static::onlyPending()->first();
|
||||||
|
|
||||||
|
if ($tenant !== null) {
|
||||||
|
event(new PullingPendingTenant($tenant));
|
||||||
|
$tenant->update(array_merge($attributes, [
|
||||||
|
'pending_since' => null,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
});
|
||||||
|
|
||||||
if ($tenant === null) {
|
if ($tenant === null) {
|
||||||
return $firstOrCreate ? static::create($attributes) : null;
|
return $firstOrCreate ? static::create($attributes) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
event(new PullingPendingTenant($tenant));
|
// Only triggered if a tenant that was pulled from the pool is returned
|
||||||
|
|
||||||
$tenant->update(array_merge($attributes, [
|
|
||||||
'pending_since' => null,
|
|
||||||
]));
|
|
||||||
|
|
||||||
event(new PendingTenantPulled($tenant));
|
event(new PendingTenantPulled($tenant));
|
||||||
|
|
||||||
return $tenant;
|
return $tenant;
|
||||||
|
|
|
||||||
|
|
@ -222,7 +222,7 @@ class DatabaseConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* todo@name come up with a better name
|
* todo@dbRefactor come up with a better name
|
||||||
* Get database manager class from the given connection config's driver.
|
* Get database manager class from the given connection config's driver.
|
||||||
*
|
*
|
||||||
* @throws DatabaseManagerNotRegisteredException
|
* @throws DatabaseManagerNotRegisteredException
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage
|
||||||
$tables = $this->connection()->select("SELECT table_name FROM information_schema.tables WHERE table_schema = '{$schema}' AND table_type = 'BASE TABLE'");
|
$tables = $this->connection()->select("SELECT table_name FROM information_schema.tables WHERE table_schema = '{$schema}' AND table_type = 'BASE TABLE'");
|
||||||
|
|
||||||
// Grant permissions to any existing tables. This is used with RLS
|
// Grant permissions to any existing tables. This is used with RLS
|
||||||
// todo@samuel refactor this along with the todo in TenantDatabaseManager
|
// todo@dbRefactor refactor this along with the todo in TenantDatabaseManager
|
||||||
// and move the grantPermissions() call inside the condition in `ManagesPostgresUsers::createUser()`
|
// and move the grantPermissions() call inside the condition in `ManagesPostgresUsers::createUser()`
|
||||||
// but maybe moving it inside $createUser is wrong because some central user may migrate new tables
|
// but maybe moving it inside $createUser is wrong because some central user may migrate new tables
|
||||||
// while the RLS user should STILL get access to those tables
|
// while the RLS user should STILL get access to those tables
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Stancl\Tenancy\Database\TenantDatabaseManagers;
|
namespace Stancl\Tenancy\Database\TenantDatabaseManagers;
|
||||||
|
|
||||||
use AssertionError;
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use PDO;
|
use PDO;
|
||||||
|
|
@ -19,13 +18,6 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
|
||||||
*/
|
*/
|
||||||
public static string|null $path = null;
|
public static string|null $path = null;
|
||||||
|
|
||||||
/**
|
|
||||||
* Should the WAL journal mode be used for newly created databases.
|
|
||||||
*
|
|
||||||
* @see https://www.sqlite.org/pragma.html#pragma_journal_mode
|
|
||||||
*/
|
|
||||||
public static bool $WAL = true;
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* If this isn't null, a connection to the tenant DB will be created
|
* If this isn't null, a connection to the tenant DB will be created
|
||||||
* and passed to the provided closure, for the purpose of keeping the
|
* and passed to the provided closure, for the purpose of keeping the
|
||||||
|
|
@ -89,25 +81,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return file_put_contents($this->getPath($name), '') !== false;
|
||||||
if (file_put_contents($path = $this->getPath($name), '') === false) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (static::$WAL) {
|
|
||||||
$pdo = new PDO('sqlite:' . $path);
|
|
||||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
|
||||||
|
|
||||||
// @phpstan-ignore-next-line method.nonObject
|
|
||||||
assert($pdo->query('pragma journal_mode = wal')->fetch(PDO::FETCH_ASSOC)['journal_mode'] === 'wal', 'Unable to set journal mode to wal.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (AssertionError $e) {
|
|
||||||
throw $e;
|
|
||||||
} catch (Throwable) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteDatabase(TenantWithDatabase $tenant): bool
|
public function deleteDatabase(TenantWithDatabase $tenant): bool
|
||||||
|
|
@ -122,8 +96,16 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$path = $this->getPath($name);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return unlink($this->getPath($name));
|
unlink($path . '-journal');
|
||||||
|
unlink($path . '-wal');
|
||||||
|
unlink($path . '-shm');
|
||||||
|
} catch (Throwable) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return unlink($path);
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,9 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Stancl\Tenancy\Events;
|
namespace Stancl\Tenancy\Events;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Importantly, listeners for this event should not switch tenancy context.
|
||||||
|
*
|
||||||
|
* This event is fired from within a database transaction.
|
||||||
|
*/
|
||||||
class PullingPendingTenant extends Contracts\TenantEvent {}
|
class PullingPendingTenant extends Contracts\TenantEvent {}
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,10 @@ namespace Stancl\Tenancy\Features;
|
||||||
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Stancl\Tenancy\Contracts\Feature;
|
use Stancl\Tenancy\Contracts\Feature;
|
||||||
use Stancl\Tenancy\Tenancy;
|
|
||||||
|
|
||||||
class CrossDomainRedirect implements Feature
|
class CrossDomainRedirect implements Feature
|
||||||
{
|
{
|
||||||
public function bootstrap(Tenancy $tenancy): void
|
public function bootstrap(): void
|
||||||
{
|
{
|
||||||
RedirectResponse::macro('domain', function (string $domain) {
|
RedirectResponse::macro('domain', function (string $domain) {
|
||||||
/** @var RedirectResponse $this */
|
/** @var RedirectResponse $this */
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,12 @@ use Illuminate\Database\SQLiteConnection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use PDO;
|
use PDO;
|
||||||
use Stancl\Tenancy\Contracts\Feature;
|
use Stancl\Tenancy\Contracts\Feature;
|
||||||
use Stancl\Tenancy\Tenancy;
|
|
||||||
|
|
||||||
class DisallowSqliteAttach implements Feature
|
class DisallowSqliteAttach implements Feature
|
||||||
{
|
{
|
||||||
protected static bool|null $loadExtensionSupported = null;
|
|
||||||
public static string|false|null $extensionPath = null;
|
public static string|false|null $extensionPath = null;
|
||||||
|
|
||||||
public function bootstrap(Tenancy $tenancy): void
|
public function bootstrap(): void
|
||||||
{
|
{
|
||||||
// Handle any already resolved connections
|
// Handle any already resolved connections
|
||||||
foreach (DB::getConnections() as $connection) {
|
foreach (DB::getConnections() as $connection) {
|
||||||
|
|
@ -39,16 +37,12 @@ class DisallowSqliteAttach implements Feature
|
||||||
|
|
||||||
protected function loadExtension(PDO $pdo): bool
|
protected function loadExtension(PDO $pdo): bool
|
||||||
{
|
{
|
||||||
if (static::$loadExtensionSupported === null) {
|
static $loadExtensionSupported = method_exists($pdo, 'loadExtension');
|
||||||
static::$loadExtensionSupported = method_exists($pdo, 'loadExtension');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (static::$loadExtensionSupported === false) {
|
if ((! $loadExtensionSupported) ||
|
||||||
return false;
|
(static::$extensionPath === false) ||
|
||||||
}
|
(PHP_INT_SIZE !== 8)
|
||||||
if (static::$extensionPath === false) {
|
) return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$suffix = match (PHP_OS_FAMILY) {
|
$suffix = match (PHP_OS_FAMILY) {
|
||||||
'Linux' => 'so',
|
'Linux' => 'so',
|
||||||
|
|
@ -61,9 +55,7 @@ class DisallowSqliteAttach implements Feature
|
||||||
$arm = $arch === 'aarch64' || $arch === 'arm64';
|
$arm = $arch === 'aarch64' || $arch === 'arm64';
|
||||||
|
|
||||||
static::$extensionPath ??= realpath(base_path('vendor/stancl/tenancy/extensions/lib/' . ($arm ? 'arm/' : '') . 'noattach.' . $suffix));
|
static::$extensionPath ??= realpath(base_path('vendor/stancl/tenancy/extensions/lib/' . ($arm ? 'arm/' : '') . 'noattach.' . $suffix));
|
||||||
if (static::$extensionPath === false) {
|
if (static::$extensionPath === false) return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->loadExtension(static::$extensionPath); // @phpstan-ignore method.notFound
|
$pdo->loadExtension(static::$extensionPath); // @phpstan-ignore method.notFound
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,10 @@ namespace Stancl\Tenancy\Features;
|
||||||
use Laravel\Telescope\IncomingEntry;
|
use Laravel\Telescope\IncomingEntry;
|
||||||
use Laravel\Telescope\Telescope;
|
use Laravel\Telescope\Telescope;
|
||||||
use Stancl\Tenancy\Contracts\Feature;
|
use Stancl\Tenancy\Contracts\Feature;
|
||||||
use Stancl\Tenancy\Tenancy;
|
|
||||||
|
|
||||||
class TelescopeTags implements Feature
|
class TelescopeTags implements Feature
|
||||||
{
|
{
|
||||||
public function bootstrap(Tenancy $tenancy): void
|
public function bootstrap(): void
|
||||||
{
|
{
|
||||||
if (! class_exists(Telescope::class)) {
|
if (! class_exists(Telescope::class)) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ use Stancl\Tenancy\Contracts\Feature;
|
||||||
use Stancl\Tenancy\Contracts\Tenant;
|
use Stancl\Tenancy\Contracts\Tenant;
|
||||||
use Stancl\Tenancy\Events\RevertedToCentralContext;
|
use Stancl\Tenancy\Events\RevertedToCentralContext;
|
||||||
use Stancl\Tenancy\Events\TenancyBootstrapped;
|
use Stancl\Tenancy\Events\TenancyBootstrapped;
|
||||||
use Stancl\Tenancy\Tenancy;
|
|
||||||
|
|
||||||
class TenantConfig implements Feature
|
class TenantConfig implements Feature
|
||||||
{
|
{
|
||||||
|
|
@ -27,7 +26,7 @@ class TenantConfig implements Feature
|
||||||
protected Repository $config,
|
protected Repository $config,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function bootstrap(Tenancy $tenancy): void
|
public function bootstrap(): void
|
||||||
{
|
{
|
||||||
Event::listen(TenancyBootstrapped::class, function (TenancyBootstrapped $event) {
|
Event::listen(TenancyBootstrapped::class, function (TenancyBootstrapped $event) {
|
||||||
/** @var Tenant $tenant */
|
/** @var Tenant $tenant */
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@ class UserImpersonation implements Feature
|
||||||
/** The lifespan of impersonation tokens (in seconds). */
|
/** The lifespan of impersonation tokens (in seconds). */
|
||||||
public static int $ttl = 60;
|
public static int $ttl = 60;
|
||||||
|
|
||||||
public function bootstrap(Tenancy $tenancy): void
|
public function bootstrap(): void
|
||||||
{
|
{
|
||||||
$tenancy->macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string|null $authGuard = null, bool $remember = false): Model {
|
Tenancy::macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string|null $authGuard = null, bool $remember = false): Model {
|
||||||
return UserImpersonation::modelClass()::create([
|
return UserImpersonation::modelClass()::create([
|
||||||
Tenancy::tenantKeyColumn() => $tenant->getTenantKey(),
|
Tenancy::tenantKeyColumn() => $tenant->getTenantKey(),
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,19 @@ declare(strict_types=1);
|
||||||
namespace Stancl\Tenancy\Features;
|
namespace Stancl\Tenancy\Features;
|
||||||
|
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
|
use Illuminate\Support\Facades\Vite;
|
||||||
use Stancl\Tenancy\Contracts\Feature;
|
use Stancl\Tenancy\Contracts\Feature;
|
||||||
use Stancl\Tenancy\Overrides\Vite;
|
|
||||||
use Stancl\Tenancy\Tenancy;
|
|
||||||
|
|
||||||
class ViteBundler implements Feature
|
class ViteBundler implements Feature
|
||||||
{
|
{
|
||||||
/** @var Application */
|
public function __construct(
|
||||||
protected $app;
|
protected Application $app,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function __construct(Application $app)
|
public function bootstrap(): void
|
||||||
{
|
{
|
||||||
$this->app = $app;
|
Vite::createAssetPathsUsing(function ($path, $secure = null) {
|
||||||
}
|
return global_asset($path);
|
||||||
|
});
|
||||||
public function bootstrap(Tenancy $tenancy): void
|
|
||||||
{
|
|
||||||
$this->app->singleton(\Illuminate\Foundation\Vite::class, Vite::class);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ use Stancl\Tenancy\Concerns\UsableWithEarlyIdentification;
|
||||||
use Stancl\Tenancy\Enums\RouteMode;
|
use Stancl\Tenancy\Enums\RouteMode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* todo@name come up with a better name.
|
|
||||||
*
|
|
||||||
* Prevents accessing central domains in the tenant context/tenant domains in the central context.
|
* Prevents accessing central domains in the tenant context/tenant domains in the central context.
|
||||||
* The access isn't prevented if the request is trying to access a route flagged as 'universal',
|
* The access isn't prevented if the request is trying to access a route flagged as 'universal',
|
||||||
* or if this middleware should be skipped.
|
* or if this middleware should be skipped.
|
||||||
|
|
@ -68,9 +66,11 @@ class PreventAccessFromUnwantedDomains
|
||||||
return in_array($request->getHost(), config('tenancy.identification.central_domains'), true);
|
return in_array($request->getHost(), config('tenancy.identification.central_domains'), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// todo@samuel technically not an identification middleware but probably ok to keep this here
|
|
||||||
public function requestHasTenant(Request $request): bool
|
public function requestHasTenant(Request $request): bool
|
||||||
{
|
{
|
||||||
|
// This middleware is special in that it's not an identification middleware
|
||||||
|
// but still uses some logic from UsableWithEarlyIdentification, so we just
|
||||||
|
// need to implement this method here. It doesn't matter what it returns.
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Stancl\Tenancy\Overrides;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Vite as BaseVite;
|
|
||||||
|
|
||||||
class Vite extends BaseVite
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Generate an asset path for the application.
|
|
||||||
*
|
|
||||||
* @param string $path
|
|
||||||
* @param bool|null $secure
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
protected function assetPath($path, $secure = null)
|
|
||||||
{
|
|
||||||
return global_asset($path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -11,6 +11,7 @@ use Illuminate\Foundation\Bus\PendingDispatch;
|
||||||
use Illuminate\Support\Traits\Macroable;
|
use Illuminate\Support\Traits\Macroable;
|
||||||
use Stancl\Tenancy\Concerns\DealsWithRouteContexts;
|
use Stancl\Tenancy\Concerns\DealsWithRouteContexts;
|
||||||
use Stancl\Tenancy\Concerns\ManagesRLSPolicies;
|
use Stancl\Tenancy\Concerns\ManagesRLSPolicies;
|
||||||
|
use Stancl\Tenancy\Contracts\Feature;
|
||||||
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
|
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
|
||||||
use Stancl\Tenancy\Contracts\Tenant;
|
use Stancl\Tenancy\Contracts\Tenant;
|
||||||
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByIdException;
|
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByIdException;
|
||||||
|
|
@ -24,7 +25,11 @@ class Tenancy
|
||||||
*/
|
*/
|
||||||
public Tenant|null $tenant = null;
|
public Tenant|null $tenant = null;
|
||||||
|
|
||||||
// todo@docblock
|
/**
|
||||||
|
* Custom callback for providing a list of bootstrappers to use.
|
||||||
|
* When this is null, config('tenancy.bootstrappers') is used.
|
||||||
|
* @var ?Closure(): list<TenancyBootstrapper>
|
||||||
|
*/
|
||||||
public ?Closure $getBootstrappersUsing = null;
|
public ?Closure $getBootstrappersUsing = null;
|
||||||
|
|
||||||
/** Is tenancy fully initialized? */
|
/** Is tenancy fully initialized? */
|
||||||
|
|
@ -36,7 +41,7 @@ class Tenancy
|
||||||
public static array $findWith = [];
|
public static array $findWith = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A list of bootstrappers that have been initialized.
|
* List of bootstrappers that have been initialized.
|
||||||
*
|
*
|
||||||
* This is used when reverting tenancy, mainly if an exception
|
* This is used when reverting tenancy, mainly if an exception
|
||||||
* occurs during bootstrapping, to ensure we don't revert
|
* occurs during bootstrapping, to ensure we don't revert
|
||||||
|
|
@ -49,6 +54,23 @@ class Tenancy
|
||||||
*/
|
*/
|
||||||
public array $initializedBootstrappers = [];
|
public array $initializedBootstrappers = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of features that have been bootstrapped.
|
||||||
|
*
|
||||||
|
* Since features may be bootstrapped multiple times during
|
||||||
|
* the request cycle (in TSP::boot() and any other times the user calls
|
||||||
|
* bootstrapFeatures()), we keep track of which features have already
|
||||||
|
* been bootstrapped so we do not bootstrap them again. Features are
|
||||||
|
* bootstrapped once and irreversible.
|
||||||
|
*
|
||||||
|
* The main point of this is that some features *need* to be bootstrapped
|
||||||
|
* very early (see #949), so we bootstrap them directly in TSP, but we
|
||||||
|
* also need the ability to *change* which features are used at runtime
|
||||||
|
* (mainly tests of this package) and bootstrap features again after making
|
||||||
|
* changes to config('tenancy.features').
|
||||||
|
*/
|
||||||
|
protected array $bootstrappedFeatures = [];
|
||||||
|
|
||||||
/** Initialize tenancy for the passed tenant. */
|
/** Initialize tenancy for the passed tenant. */
|
||||||
public function initialize(Tenant|int|string $tenant): void
|
public function initialize(Tenant|int|string $tenant): void
|
||||||
{
|
{
|
||||||
|
|
@ -131,12 +153,12 @@ class Tenancy
|
||||||
/** @return TenancyBootstrapper[] */
|
/** @return TenancyBootstrapper[] */
|
||||||
public function getBootstrappers(): array
|
public function getBootstrappers(): array
|
||||||
{
|
{
|
||||||
// If no callback for getting bootstrappers is set, we just return all of them.
|
// If no callback for getting bootstrappers is set, we return the ones in config.
|
||||||
$resolve = $this->getBootstrappersUsing ?? function (Tenant $tenant) {
|
$resolve = $this->getBootstrappersUsing ?? function (?Tenant $tenant) {
|
||||||
return config('tenancy.bootstrappers');
|
return config('tenancy.bootstrappers');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Here We instantiate the bootstrappers and return them.
|
// Here we instantiate the bootstrappers and return them.
|
||||||
return array_map('app', $resolve($this->tenant));
|
return array_map('app', $resolve($this->tenant));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,6 +172,26 @@ class Tenancy
|
||||||
return in_array($bootstrapper, static::getBootstrappers(), true);
|
return in_array($bootstrapper, static::getBootstrappers(), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap configured Tenancy features.
|
||||||
|
*
|
||||||
|
* Normally, features are bootstrapped directly in TSP::boot(). However, if
|
||||||
|
* new features are enabled at runtime (e.g. during tests), this method may
|
||||||
|
* be called to bootstrap new features. It's idempotent and keeps track of
|
||||||
|
* which features have already been bootstrapped. Keep in mind that feature
|
||||||
|
* bootstrapping is irreversible.
|
||||||
|
*/
|
||||||
|
public function bootstrapFeatures(): void
|
||||||
|
{
|
||||||
|
foreach (config('tenancy.features') ?? [] as $feature) {
|
||||||
|
/** @var class-string<Feature> $feature */
|
||||||
|
if (! in_array($feature, $this->bootstrappedFeatures)) {
|
||||||
|
app($feature)->bootstrap();
|
||||||
|
$this->bootstrappedFeatures[] = $feature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Builder<Tenant&Model>
|
* @return Builder<Tenant&Model>
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -40,15 +40,6 @@ class TenancyServiceProvider extends ServiceProvider
|
||||||
// Make sure Tenancy is stateful.
|
// Make sure Tenancy is stateful.
|
||||||
$this->app->singleton(Tenancy::class);
|
$this->app->singleton(Tenancy::class);
|
||||||
|
|
||||||
// Make sure features are bootstrapped as soon as Tenancy is instantiated.
|
|
||||||
$this->app->extend(Tenancy::class, function (Tenancy $tenancy) {
|
|
||||||
foreach ($this->app['config']['tenancy.features'] ?? [] as $feature) {
|
|
||||||
$this->app[$feature]->bootstrap($tenancy);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $tenancy;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Make it possible to inject the current tenant by type hinting the Tenant contract.
|
// Make it possible to inject the current tenant by type hinting the Tenant contract.
|
||||||
$this->app->bind(Tenant::class, function ($app) {
|
$this->app->bind(Tenant::class, function ($app) {
|
||||||
return $app[Tenancy::class]->tenant;
|
return $app[Tenancy::class]->tenant;
|
||||||
|
|
@ -176,6 +167,11 @@ class TenancyServiceProvider extends ServiceProvider
|
||||||
return $instance;
|
return $instance;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bootstrap features that are already enabled in the config.
|
||||||
|
// If more features are enabled at runtime, this method may be called
|
||||||
|
// multiple times, it keeps track of which features have already been bootstrapped.
|
||||||
|
$this->app->make(Tenancy::class)->bootstrapFeatures();
|
||||||
|
|
||||||
Route::middlewareGroup('clone', []);
|
Route::middlewareGroup('clone', []);
|
||||||
Route::middlewareGroup('universal', []);
|
Route::middlewareGroup('universal', []);
|
||||||
Route::middlewareGroup('tenant', []);
|
Route::middlewareGroup('tenant', []);
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,12 @@ if (! function_exists('tenant')) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! function_exists('tenant_asset')) {
|
if (! function_exists('tenant_asset')) {
|
||||||
// todo@docblock
|
/**
|
||||||
|
* Generate a URL to an asset in tenant storage.
|
||||||
|
*
|
||||||
|
* If app.asset_url is set, this helper suffixes that URL before appending the asset path.
|
||||||
|
* If it is not set, the stancl.tenancy.asset route is used.
|
||||||
|
*/
|
||||||
function tenant_asset(string|null $asset): string
|
function tenant_asset(string|null $asset): string
|
||||||
{
|
{
|
||||||
if ($assetUrl = config('app.asset_url')) {
|
if ($assetUrl = config('app.asset_url')) {
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,6 @@ beforeEach(function () {
|
||||||
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
// todo@move move these to be in the same file as the other tests from this PR (#909) rather than generic "action tests"
|
|
||||||
|
|
||||||
test('create storage symlinks action works', function() {
|
test('create storage symlinks action works', function() {
|
||||||
config([
|
config([
|
||||||
'tenancy.bootstrappers' => [
|
'tenancy.bootstrappers' => [
|
||||||
|
|
|
||||||
|
|
@ -115,8 +115,6 @@ test('files can get fetched using the storage url', function() {
|
||||||
test('storage_path helper does not change if suffix_storage_path is off', function() {
|
test('storage_path helper does not change if suffix_storage_path is off', function() {
|
||||||
$originalStoragePath = storage_path();
|
$originalStoragePath = storage_path();
|
||||||
|
|
||||||
// todo@tests https://github.com/tenancy-for-laravel/v4/pull/44#issue-2228530362
|
|
||||||
|
|
||||||
config([
|
config([
|
||||||
'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class],
|
'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class],
|
||||||
'tenancy.filesystem.suffix_storage_path' => false,
|
'tenancy.filesystem.suffix_storage_path' => false,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
||||||
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
|
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
|
||||||
use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException;
|
use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException;
|
||||||
use function Stancl\Tenancy\Tests\pest;
|
use function Stancl\Tenancy\Tests\pest;
|
||||||
|
use Stancl\Tenancy\Events\MigratingDatabase;
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
if (file_exists($schemaPath = 'tests/Etc/tenant-schema-test.dump')) {
|
if (file_exists($schemaPath = 'tests/Etc/tenant-schema-test.dump')) {
|
||||||
|
|
@ -95,6 +96,60 @@ test('migrate command works with tenants option', function () {
|
||||||
expect(Schema::hasTable('users'))->toBeTrue();
|
expect(Schema::hasTable('users'))->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('migrate command uses the passed database option as the template tenant connection', function () {
|
||||||
|
$originalTemplateConnection = config('tenancy.database.template_tenant_connection');
|
||||||
|
|
||||||
|
// Add a custom connection that will be used as the template for the tenant connection
|
||||||
|
// Identical to the default (mysql), just with different charset and collation
|
||||||
|
config(['database.connections.custom_connection' => [
|
||||||
|
"driver" => "mysql",
|
||||||
|
"url" => "",
|
||||||
|
"host" => "mysql",
|
||||||
|
"port" => "3306",
|
||||||
|
"database" => "main",
|
||||||
|
"username" => "root",
|
||||||
|
"password" => "password",
|
||||||
|
"unix_socket" => "",
|
||||||
|
"charset" => "latin1", // Different from the default (utf8mb4)
|
||||||
|
"collation" => "latin1_swedish_ci", // Different from the default (utf8mb4_unicode_ci)
|
||||||
|
"prefix" => "",
|
||||||
|
"prefix_indexes" => true,
|
||||||
|
"strict" => true,
|
||||||
|
"engine" => null,
|
||||||
|
"options" => []
|
||||||
|
]]);
|
||||||
|
|
||||||
|
$templateConnectionDuringMigration = null;
|
||||||
|
$tenantConnectionDuringMigration = null;
|
||||||
|
|
||||||
|
Event::listen(MigratingDatabase::class, function() use (&$templateConnectionDuringMigration, &$tenantConnectionDuringMigration) {
|
||||||
|
$templateConnectionDuringMigration = config('tenancy.database.template_tenant_connection');
|
||||||
|
$tenantConnectionDuringMigration = DB::connection('tenant')->getConfig();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The original tenant template connection config remains default
|
||||||
|
expect(config('tenancy.database.template_tenant_connection'))->toBe($originalTemplateConnection);
|
||||||
|
|
||||||
|
Tenant::create();
|
||||||
|
|
||||||
|
// The original template connection is used when the --database option is not passed
|
||||||
|
pest()->artisan('tenants:migrate');
|
||||||
|
expect($templateConnectionDuringMigration)->toBe($originalTemplateConnection);
|
||||||
|
|
||||||
|
Tenant::create();
|
||||||
|
|
||||||
|
// The migrate command temporarily uses the connection passed in the --database option
|
||||||
|
pest()->artisan('tenants:migrate', ['--database' => 'custom_connection']);
|
||||||
|
expect($templateConnectionDuringMigration)->toBe('custom_connection');
|
||||||
|
|
||||||
|
// The tenant connection during migration actually used custom_connection's config
|
||||||
|
expect($tenantConnectionDuringMigration['charset'])->toBe('latin1');
|
||||||
|
expect($tenantConnectionDuringMigration['collation'])->toBe('latin1_swedish_ci');
|
||||||
|
|
||||||
|
// The tenant template connection config is restored to the original after migrating
|
||||||
|
expect(config('tenancy.database.template_tenant_connection'))->toBe($originalTemplateConnection);
|
||||||
|
});
|
||||||
|
|
||||||
test('migrate command only throws exceptions if skip-failing is not passed', function() {
|
test('migrate command only throws exceptions if skip-failing is not passed', function() {
|
||||||
Tenant::create();
|
Tenant::create();
|
||||||
|
|
||||||
|
|
@ -311,6 +366,21 @@ test('migrate fresh command works', function () {
|
||||||
expect(DB::table('users')->exists())->toBeFalse();
|
expect(DB::table('users')->exists())->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('migrate fresh command respects force option in production', function () {
|
||||||
|
// Set environment to production
|
||||||
|
app()->detectEnvironment(fn() => 'production');
|
||||||
|
|
||||||
|
Tenant::create();
|
||||||
|
|
||||||
|
// Without --force in production, command should prompt for confirmation
|
||||||
|
pest()->artisan('tenants:migrate-fresh')
|
||||||
|
->expectsConfirmation('Are you sure you want to run this command?');
|
||||||
|
|
||||||
|
// With --force, command should succeed without prompting
|
||||||
|
pest()->artisan('tenants:migrate-fresh', ['--force' => true])
|
||||||
|
->assertSuccessful();
|
||||||
|
});
|
||||||
|
|
||||||
test('run command with array of tenants works', function () {
|
test('run command with array of tenants works', function () {
|
||||||
$tenantId1 = Tenant::create()->getTenantKey();
|
$tenantId1 = Tenant::create()->getTenantKey();
|
||||||
$tenantId2 = Tenant::create()->getTenantKey();
|
$tenantId2 = Tenant::create()->getTenantKey();
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ use Illuminate\Contracts\Http\Kernel;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
use Stancl\Tenancy\Events\TenancyInitialized;
|
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||||
use Illuminate\Support\Facades\Route as RouteFacade;
|
use Illuminate\Support\Facades\Route as RouteFacade;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Stancl\Tenancy\Actions\CloneRoutesAsTenant;
|
use Stancl\Tenancy\Actions\CloneRoutesAsTenant;
|
||||||
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
|
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
|
||||||
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
|
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
|
||||||
|
|
@ -120,7 +121,7 @@ test('early identification works with path identification', function (bool $useK
|
||||||
RouteFacade::get('/{post}/comment/{comment}/edit', [$controller, 'computePost']);
|
RouteFacade::get('/{post}/comment/{comment}/edit', [$controller, 'computePost']);
|
||||||
});
|
});
|
||||||
|
|
||||||
$tenant = Tenant::create(['tenancy_db_name' => pest()->randomString()]);
|
$tenant = Tenant::create(['tenancy_db_name' => Str::random(10)]);
|
||||||
|
|
||||||
// Migrate users and comments tables on tenant connection
|
// Migrate users and comments tables on tenant connection
|
||||||
pest()->artisan('tenants:migrate', [
|
pest()->artisan('tenants:migrate', [
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ test('sqlite ATTACH statements can be blocked', function (bool $disallow) {
|
||||||
return json_encode(DB::select(request('q2')));
|
return json_encode(DB::select(request('q2')));
|
||||||
});
|
});
|
||||||
|
|
||||||
tenancy(); // trigger features: todo@samuel remove after feature refactor
|
tenancy()->bootstrapFeatures();
|
||||||
|
|
||||||
if ($disallow) {
|
if ($disallow) {
|
||||||
expect(fn () => pest()->post('/central-sqli', [
|
expect(fn () => pest()->post('/central-sqli', [
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ test('tenant redirect macro replaces only the hostname', function () {
|
||||||
'tenancy.features' => [CrossDomainRedirect::class],
|
'tenancy.features' => [CrossDomainRedirect::class],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
tenancy()->bootstrapFeatures();
|
||||||
|
|
||||||
Route::get('/foobar', function () {
|
Route::get('/foobar', function () {
|
||||||
return 'Foo';
|
return 'Foo';
|
||||||
})->name('home');
|
})->name('home');
|
||||||
|
|
|
||||||
|
|
@ -11,16 +11,21 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||||
use function Stancl\Tenancy\Tests\pest;
|
use function Stancl\Tenancy\Tests\pest;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
config([
|
||||||
|
'tenancy.features' => [TenantConfig::class],
|
||||||
|
'tenancy.bootstrappers' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
tenancy()->bootstrapFeatures();
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(function () {
|
afterEach(function () {
|
||||||
TenantConfig::$storageToConfigMap = [];
|
TenantConfig::$storageToConfigMap = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
test('nested tenant values are merged', function () {
|
test('nested tenant values are merged', function () {
|
||||||
expect(config('whitelabel.theme'))->toBeNull();
|
expect(config('whitelabel.theme'))->toBeNull();
|
||||||
config([
|
|
||||||
'tenancy.features' => [TenantConfig::class],
|
|
||||||
'tenancy.bootstrappers' => [],
|
|
||||||
]);
|
|
||||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||||
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||||
|
|
||||||
|
|
@ -39,10 +44,6 @@ test('nested tenant values are merged', function () {
|
||||||
|
|
||||||
test('config is merged and removed', function () {
|
test('config is merged and removed', function () {
|
||||||
expect(config('services.paypal'))->toBe(null);
|
expect(config('services.paypal'))->toBe(null);
|
||||||
config([
|
|
||||||
'tenancy.features' => [TenantConfig::class],
|
|
||||||
'tenancy.bootstrappers' => [],
|
|
||||||
]);
|
|
||||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||||
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||||
|
|
||||||
|
|
@ -68,10 +69,6 @@ test('config is merged and removed', function () {
|
||||||
|
|
||||||
test('the value can be set to multiple config keys', function () {
|
test('the value can be set to multiple config keys', function () {
|
||||||
expect(config('services.paypal'))->toBe(null);
|
expect(config('services.paypal'))->toBe(null);
|
||||||
config([
|
|
||||||
'tenancy.features' => [TenantConfig::class],
|
|
||||||
'tenancy.bootstrappers' => [],
|
|
||||||
]);
|
|
||||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||||
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,27 +3,42 @@
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Illuminate\Foundation\Vite;
|
use Illuminate\Foundation\Vite;
|
||||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
use Illuminate\Support\Facades\File;
|
||||||
use Stancl\Tenancy\Overrides\Vite as StanclVite;
|
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
|
||||||
use Stancl\Tenancy\Features\ViteBundler;
|
use Stancl\Tenancy\Features\ViteBundler;
|
||||||
|
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||||
|
|
||||||
test('vite helper uses our custom class', function() {
|
use function Stancl\Tenancy\Tests\withBootstrapping;
|
||||||
$vite = app(Vite::class);
|
|
||||||
|
|
||||||
expect($vite)->toBeInstanceOf(Vite::class);
|
|
||||||
expect($vite)->not()->toBeInstanceOf(StanclVite::class);
|
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
config([
|
config([
|
||||||
'tenancy.features' => [ViteBundler::class],
|
'tenancy.filesystem.asset_helper_override' => true,
|
||||||
|
'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tenant = Tenant::create();
|
File::ensureDirectoryExists(dirname($manifestPath = public_path('build/manifest.json')));
|
||||||
|
File::put($manifestPath, json_encode([
|
||||||
tenancy()->initialize($tenant);
|
'foo' => [
|
||||||
|
'file' => 'assets/foo-AbC123.js',
|
||||||
app()->forgetInstance(Vite::class);
|
'src' => 'js/foo.js',
|
||||||
|
],
|
||||||
$vite = app(Vite::class);
|
]));
|
||||||
|
});
|
||||||
expect($vite)->toBeInstanceOf(StanclVite::class);
|
|
||||||
|
test('vite bundler ensures vite assets use global_asset when asset_helper_override is enabled', function () {
|
||||||
|
config(['tenancy.features' => [ViteBundler::class]]);
|
||||||
|
tenancy()->bootstrapFeatures();
|
||||||
|
|
||||||
|
withBootstrapping();
|
||||||
|
|
||||||
|
tenancy()->initialize(Tenant::create());
|
||||||
|
|
||||||
|
// Not what we want
|
||||||
|
expect(asset('foo'))->toBe(route('stancl.tenancy.asset', ['path' => 'foo']));
|
||||||
|
|
||||||
|
$viteAssetUrl = app(Vite::class)->asset('foo');
|
||||||
|
$expectedGlobalUrl = global_asset('build/assets/foo-AbC123.js');
|
||||||
|
|
||||||
|
expect($viteAssetUrl)->toBe($expectedGlobalUrl);
|
||||||
|
expect($viteAssetUrl)->toBe('http://localhost/build/assets/foo-AbC123.js');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Stancl\Tenancy\Actions\CloneRoutesAsTenant;
|
use Stancl\Tenancy\Actions\CloneRoutesAsTenant;
|
||||||
use Stancl\Tenancy\Events\TenancyInitialized;
|
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||||
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||||
|
|
@ -44,7 +45,7 @@ test('asset can be accessed using the url returned by the tenant asset helper',
|
||||||
$tenant = Tenant::create();
|
$tenant = Tenant::create();
|
||||||
tenancy()->initialize($tenant);
|
tenancy()->initialize($tenant);
|
||||||
|
|
||||||
$filename = 'testfile' . pest()->randomString(10);
|
$filename = 'testfile' . Str::random(8);
|
||||||
Storage::disk('public')->put($filename, 'bar');
|
Storage::disk('public')->put($filename, 'bar');
|
||||||
$path = storage_path("app/public/$filename");
|
$path = storage_path("app/public/$filename");
|
||||||
|
|
||||||
|
|
@ -136,7 +137,7 @@ test('TenantAssetController headers are configurable', function () {
|
||||||
tenancy()->initialize($tenant);
|
tenancy()->initialize($tenant);
|
||||||
$tenant->createDomain('foo.localhost');
|
$tenant->createDomain('foo.localhost');
|
||||||
|
|
||||||
$filename = 'testfile' . pest()->randomString(10);
|
$filename = 'testfile' . Str::random(10);
|
||||||
Storage::disk('public')->put($filename, 'bar');
|
Storage::disk('public')->put($filename, 'bar');
|
||||||
|
|
||||||
$this->withoutExceptionHandling();
|
$this->withoutExceptionHandling();
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQ
|
||||||
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLDatabaseManager;
|
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLDatabaseManager;
|
||||||
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager;
|
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager;
|
||||||
use function Stancl\Tenancy\Tests\pest;
|
use function Stancl\Tenancy\Tests\pest;
|
||||||
|
use function Stancl\Tenancy\Tests\withBootstrapping;
|
||||||
|
use function Stancl\Tenancy\Tests\withTenantDatabases;
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
SQLiteDatabaseManager::$path = null;
|
SQLiteDatabaseManager::$path = null;
|
||||||
|
|
@ -43,7 +45,7 @@ test('databases can be created and deleted', function ($driver, $databaseManager
|
||||||
"tenancy.database.managers.$driver" => $databaseManager,
|
"tenancy.database.managers.$driver" => $databaseManager,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$name = 'db' . pest()->randomString();
|
$name = 'db' . Str::random(10);
|
||||||
|
|
||||||
$manager = app($databaseManager);
|
$manager = app($databaseManager);
|
||||||
|
|
||||||
|
|
@ -70,7 +72,7 @@ test('dbs can be created when another driver is used for the central db', functi
|
||||||
return $event->tenant;
|
return $event->tenant;
|
||||||
})->toListener());
|
})->toListener());
|
||||||
|
|
||||||
$database = 'db' . pest()->randomString();
|
$database = 'db' . Str::random(10);
|
||||||
|
|
||||||
$mysqlmanager = app(MySQLDatabaseManager::class);
|
$mysqlmanager = app(MySQLDatabaseManager::class);
|
||||||
$mysqlmanager->setConnection('mysql');
|
$mysqlmanager->setConnection('mysql');
|
||||||
|
|
@ -86,7 +88,7 @@ test('dbs can be created when another driver is used for the central db', functi
|
||||||
$postgresManager = app(PostgreSQLDatabaseManager::class);
|
$postgresManager = app(PostgreSQLDatabaseManager::class);
|
||||||
$postgresManager->setConnection('pgsql');
|
$postgresManager->setConnection('pgsql');
|
||||||
|
|
||||||
$database = 'db' . pest()->randomString();
|
$database = 'db' . Str::random(10);
|
||||||
expect($postgresManager->databaseExists($database))->toBeFalse();
|
expect($postgresManager->databaseExists($database))->toBeFalse();
|
||||||
|
|
||||||
Tenant::create([
|
Tenant::create([
|
||||||
|
|
@ -146,18 +148,15 @@ test('db name is prefixed with db path when sqlite is used', function () {
|
||||||
expect(database_path('foodb'))->toBe(config('database.connections.tenant.database'));
|
expect(database_path('foodb'))->toBe(config('database.connections.tenant.database'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sqlite databases use the WAL journal mode by default', function (bool|null $wal) {
|
test('sqlite databases respect the template journal_mode config', function (string $journal_mode) {
|
||||||
$expected = $wal ? 'wal' : 'delete';
|
withTenantDatabases();
|
||||||
if ($wal !== null) {
|
withBootstrapping();
|
||||||
SQLiteDatabaseManager::$WAL = $wal;
|
config([
|
||||||
} else {
|
'database.connections.sqlite.journal_mode' => $journal_mode,
|
||||||
// default behavior
|
'tenancy.bootstrappers' => [
|
||||||
$expected = 'wal';
|
DatabaseTenancyBootstrapper::class,
|
||||||
}
|
],
|
||||||
|
]);
|
||||||
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
|
|
||||||
return $event->tenant;
|
|
||||||
})->toListener());
|
|
||||||
|
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::create([
|
||||||
'tenancy_db_connection' => 'sqlite',
|
'tenancy_db_connection' => 'sqlite',
|
||||||
|
|
@ -170,11 +169,18 @@ test('sqlite databases use the WAL journal mode by default', function (bool|null
|
||||||
$db = new PDO('sqlite:' . $dbPath);
|
$db = new PDO('sqlite:' . $dbPath);
|
||||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
expect($db->query('pragma journal_mode')->fetch(PDO::FETCH_ASSOC)['journal_mode'])->toBe($expected);
|
// Before we connect to the DB using Laravel, it will be in default delete mode
|
||||||
|
expect($db->query('pragma journal_mode')->fetch(PDO::FETCH_ASSOC)['journal_mode'])->toBe('delete');
|
||||||
|
|
||||||
// cleanup
|
// This will trigger the logic in Laravel's SQLiteConnector
|
||||||
SQLiteDatabaseManager::$WAL = true;
|
$tenant->run(fn () => DB::select('select 1'));
|
||||||
})->with([true, false, null]);
|
|
||||||
|
$db = new PDO('sqlite:' . $dbPath);
|
||||||
|
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
|
// Once we connect to the DB, it will be in the configured journal mode
|
||||||
|
expect($db->query('pragma journal_mode')->fetch(PDO::FETCH_ASSOC)['journal_mode'])->toBe($journal_mode);
|
||||||
|
})->with(['delete', 'wal']);
|
||||||
|
|
||||||
test('schema manager uses schema to separate tenant dbs', function () {
|
test('schema manager uses schema to separate tenant dbs', function () {
|
||||||
config([
|
config([
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ beforeEach(function () {
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
tenancy()->bootstrapFeatures();
|
||||||
|
|
||||||
Event::listen(
|
Event::listen(
|
||||||
TenantCreated::class,
|
TenantCreated::class,
|
||||||
JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
|
JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
|
||||||
|
|
|
||||||
|
|
@ -236,11 +236,6 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
|
||||||
$app->singleton('Illuminate\Contracts\Console\Kernel', Etc\Console\ConsoleKernel::class);
|
$app->singleton('Illuminate\Contracts\Console\Kernel', Etc\Console\ConsoleKernel::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function randomString(int $length = 10)
|
|
||||||
{
|
|
||||||
return substr(str_shuffle(str_repeat($x = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', (int) (ceil($length / strlen($x))))), 1, $length);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function assertArrayIsSubset($subset, $array, string $message = ''): void
|
public function assertArrayIsSubset($subset, $array, string $message = ''): void
|
||||||
{
|
{
|
||||||
parent::assertTrue(array_intersect($subset, $array) == $subset, $message);
|
parent::assertTrue(array_intersect($subset, $array) == $subset, $message);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue