1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2025-12-14 07:54:04 +00:00

Merge branch 'master' into add-log-bootstrapper

This commit is contained in:
lukinovec 2025-10-28 10:43:53 +01:00 committed by GitHub
commit 0b3f6987c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 643 additions and 317 deletions

2
.gitattributes vendored
View file

@ -10,6 +10,7 @@
/.nvim.lua export-ignore /.nvim.lua export-ignore
/art export-ignore /art export-ignore
/coverage export-ignore /coverage export-ignore
/CLAUDE.md export-ignore
/CONTRIBUTING.md export-ignore /CONTRIBUTING.md export-ignore
/INTERNAL.md export-ignore /INTERNAL.md export-ignore
/SUPPORT.md export-ignore /SUPPORT.md export-ignore
@ -19,6 +20,7 @@
/Dockerfile export-ignore /Dockerfile export-ignore
/doctum export-ignore /doctum export-ignore
/phpunit.xml export-ignore /phpunit.xml export-ignore
/static_properties.nu export-ignore
/t export-ignore /t export-ignore
/test export-ignore /test export-ignore
/tests export-ignore /tests export-ignore

View file

@ -17,7 +17,6 @@ jobs:
matrix: matrix:
include: include:
- laravel: "^12.0" - laravel: "^12.0"
php: "8.4"
steps: steps:
- name: Checkout - name: Checkout

View file

@ -1,4 +1,3 @@
-- The tailwindcss LSP doesn't play nice with testbench due to the recursive -- The tailwindcss LSP doesn't play nice with testbench due to the recursive
-- `vendor` symlink in `testbench-core/laravel/vendor`, so we nuke its setup method here. -- `vendor` symlink in `testbench-core/laravel/vendor`, so we disable it here.
-- This prevents the setup() call in neovim config from starting the client (or doing anything at all). vim.lsp.enable('tailwindcss', false)
require('lspconfig').tailwindcss.setup = function () end

View file

@ -49,3 +49,9 @@ Use `composer phpstan` to run our phpstan suite.
Create `.env` with `PROJECT_PATH=/full/path/to/this/directory`. Configure a Docker-based interpreter for tests (with exec, not run). Create `.env` with `PROJECT_PATH=/full/path/to/this/directory`. Configure a Docker-based interpreter for tests (with exec, not run).
If you want to use XDebug, use `composer docker-rebuild-with-xdebug`. If you want to use XDebug, use `composer docker-rebuild-with-xdebug`.
## PHP 8.5
To use PHP 8.5 during development, run:
- `PHP_VERSION=8.5.0RC2 composer docker-rebuild` to build the `test` container with PHP 8.5
- `composer php85-patch` to get rid of some deprecation errors coming from `config/database.php` from within testbench-core

View file

@ -1,10 +1,8 @@
ARG PHP_VERSION=8.4 ARG PHP_VERSION=8.4
FROM php:${PHP_VERSION}-cli-bookworm FROM php:${PHP_VERSION}-cli-bookworm
SHELL ["/bin/bash", "-c"] SHELL ["/bin/bash", "-c"]
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update
git unzip libzip-dev libicu-dev libmemcached-dev zlib1g-dev libssl-dev sqlite3 libsqlite3-dev libpq-dev mariadb-client
RUN apt-get install -y gnupg2 \ RUN apt-get install -y gnupg2 \
&& curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \ && curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \
@ -12,18 +10,40 @@ RUN apt-get install -y gnupg2 \
&& apt-get update \ && apt-get update \
&& ACCEPT_EULA=Y apt-get install -y unixodbc-dev msodbcsql18 && ACCEPT_EULA=Y apt-get install -y unixodbc-dev msodbcsql18
RUN apt-get install -y --no-install-recommends \
git unzip libzip-dev libicu-dev libmemcached-dev zlib1g-dev libssl-dev sqlite3 libsqlite3-dev libpq-dev mariadb-client
RUN apt autoremove && apt clean RUN apt autoremove && apt clean
RUN pecl install apcu && docker-php-ext-enable apcu RUN pecl install apcu && docker-php-ext-enable apcu
RUN pecl install pcov && docker-php-ext-enable pcov RUN pecl install pcov && docker-php-ext-enable pcov
RUN pecl install redis && docker-php-ext-enable redis RUN pecl install redis-6.3.0RC1 && docker-php-ext-enable redis
RUN pecl install memcached && docker-php-ext-enable memcached RUN pecl install memcached && docker-php-ext-enable memcached
RUN pecl install pdo_sqlsrv && docker-php-ext-enable pdo_sqlsrv
RUN docker-php-ext-install zip && docker-php-ext-enable zip RUN docker-php-ext-install zip && docker-php-ext-enable zip
RUN docker-php-ext-install intl && docker-php-ext-enable intl RUN docker-php-ext-install intl && docker-php-ext-enable intl
RUN docker-php-ext-install pdo_mysql && docker-php-ext-enable pdo_mysql RUN docker-php-ext-install pdo_mysql && docker-php-ext-enable pdo_mysql
RUN docker-php-ext-install pdo_pgsql && docker-php-ext-enable pdo_pgsql RUN docker-php-ext-install pdo_pgsql && docker-php-ext-enable pdo_pgsql
RUN if [[ "${PHP_VERSION}" == *"8.5"* ]]; then \
mkdir sqlsrv \
&& cd sqlsrv \
&& pecl download pdo_sqlsrv-5.12.0 \
&& tar xzf pdo_sqlsrv-5.12.0.tgz \
&& cd pdo_sqlsrv-5.12.0 \
&& sed -i 's/= dbh->error_mode;/= static_cast<pdo_error_mode>(dbh->error_mode);/' pdo_dbh.cpp \
&& sed -i 's/zval_ptr_dtor( &dbh->query_stmt_zval );/OBJ_RELEASE(dbh->query_stmt_obj);dbh->query_stmt_obj=NULL;/' php_pdo_sqlsrv_int.h \
&& phpize \
&& ./configure --with-php-config=$(which php-config) \
&& make -j$(nproc) \
&& cp modules/pdo_sqlsrv.so $(php -r 'echo ini_get("extension_dir");') \
&& cd / \
&& rm -rf /sqlsrv; \
else \
pecl install pdo_sqlsrv; \
fi
RUN docker-php-ext-enable pdo_sqlsrv
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
RUN echo "apc.enable_cli=1" >> "$PHP_INI_DIR/php.ini" RUN echo "apc.enable_cli=1" >> "$PHP_INI_DIR/php.ini"

View file

@ -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.
@ -42,7 +57,7 @@ class TenancyServiceProvider extends ServiceProvider
// Provision API keys, create S3 buckets, anything you want! // Provision API keys, create S3 buckets, anything you want!
])->send(function (Events\TenantCreated $event) { ])->send(function (Events\TenantCreated $event) {
return $event->tenant; return $event->tenant;
})->shouldBeQueued(false), // `false` by default, but you likely want to make this `true` in production. })->shouldBeQueued(false),
// Listeners\CreateTenantStorage::class, // Listeners\CreateTenantStorage::class,
], ],
@ -65,7 +80,7 @@ class TenancyServiceProvider extends ServiceProvider
Jobs\DeleteDatabase::class, Jobs\DeleteDatabase::class,
])->send(function (Events\TenantDeleted $event) { ])->send(function (Events\TenantDeleted $event) {
return $event->tenant; return $event->tenant;
})->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production. })->shouldBeQueued(false),
], ],
Events\TenantMaintenanceModeEnabled::class => [], Events\TenantMaintenanceModeEnabled::class => [],

View file

@ -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.
@ -155,6 +170,7 @@ return [
Bootstrappers\DatabaseTenancyBootstrapper::class, Bootstrappers\DatabaseTenancyBootstrapper::class,
Bootstrappers\CacheTenancyBootstrapper::class, Bootstrappers\CacheTenancyBootstrapper::class,
// Bootstrappers\CacheTagsBootstrapper::class, // Alternative to CacheTenancyBootstrapper // Bootstrappers\CacheTagsBootstrapper::class, // Alternative to CacheTenancyBootstrapper
// Bootstrappers\DatabaseCacheBootstrapper::class, // Separates cache by DB rather than by prefix, must run after DatabaseTenancyBootstrapper
Bootstrappers\FilesystemTenancyBootstrapper::class, Bootstrappers\FilesystemTenancyBootstrapper::class,
Bootstrappers\QueueTenancyBootstrapper::class, Bootstrappers\QueueTenancyBootstrapper::class,
// Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed // Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed
@ -163,9 +179,10 @@ return [
Bootstrappers\DatabaseSessionBootstrapper::class, Bootstrappers\DatabaseSessionBootstrapper::class,
// Configurable bootstrappers // Configurable bootstrappers
// Bootstrappers\TenantConfigBootstrapper::class,
// Bootstrappers\RootUrlBootstrapper::class, // Bootstrappers\RootUrlBootstrapper::class,
// Bootstrappers\UrlGeneratorBootstrapper::class, // Bootstrappers\UrlGeneratorBootstrapper::class,
// Bootstrappers\MailConfigBootstrapper::class, // Note: Queueing mail requires using QueueTenancyBootstrapper with $forceRefresh set to true // Bootstrappers\MailConfigBootstrapper::class,
// Bootstrappers\BroadcastingConfigBootstrapper::class, // Bootstrappers\BroadcastingConfigBootstrapper::class,
// Bootstrappers\BroadcastChannelPrefixBootstrapper::class, // Bootstrappers\BroadcastChannelPrefixBootstrapper::class,
@ -404,7 +421,6 @@ return [
'features' => [ 'features' => [
// Stancl\Tenancy\Features\UserImpersonation::class, // Stancl\Tenancy\Features\UserImpersonation::class,
// Stancl\Tenancy\Features\TelescopeTags::class, // Stancl\Tenancy\Features\TelescopeTags::class,
// Stancl\Tenancy\Features\TenantConfig::class,
// Stancl\Tenancy\Features\CrossDomainRedirect::class, // Stancl\Tenancy\Features\CrossDomainRedirect::class,
// Stancl\Tenancy\Features\ViteBundler::class, // Stancl\Tenancy\Features\ViteBundler::class,
// Stancl\Tenancy\Features\DisallowSqliteAttach::class, // Stancl\Tenancy\Features\DisallowSqliteAttach::class,

View file

@ -65,13 +65,16 @@
"docker-restart": "docker compose down && docker compose up -d", "docker-restart": "docker compose down && docker compose up -d",
"docker-rebuild": [ "docker-rebuild": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"PHP_VERSION=8.4 docker compose up -d --no-deps --build" "docker compose up -d --no-deps --build"
], ],
"docker-rebuild-with-xdebug": [ "docker-rebuild-with-xdebug": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"PHP_VERSION=8.4 XDEBUG_ENABLED=true docker compose up -d --no-deps --build" "XDEBUG_ENABLED=true 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",
"php85-patch": [
"php -r '$file=\"vendor/orchestra/testbench-core/laravel/config/database.php\"; file_put_contents($file, str_replace(\"PDO::MYSQL_ATTR_SSL_CA\", \"Pdo\\\\Mysql::ATTR_SSL_CA\", file_get_contents($file)));'"
],
"testbench-unlink": "rm ./vendor/orchestra/testbench-core/laravel/vendor", "testbench-unlink": "rm ./vendor/orchestra/testbench-core/laravel/vendor",
"testbench-link": "ln -s /var/www/html/vendor ./vendor/orchestra/testbench-core/laravel/vendor", "testbench-link": "ln -s /var/www/html/vendor ./vendor/orchestra/testbench-core/laravel/vendor",
"testbench-repair": "mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/sessions && mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/views && mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/cache", "testbench-repair": "mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/sessions && mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/views && mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/cache",

View file

@ -3,6 +3,7 @@ services:
build: build:
context: . context: .
args: args:
PHP_VERSION: ${PHP_VERSION:-8.4}
XDEBUG_ENABLED: ${XDEBUG_ENABLED:-false} XDEBUG_ENABLED: ${XDEBUG_ENABLED:-false}
depends_on: depends_on:
mysql: mysql:
@ -80,7 +81,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

View file

@ -86,6 +86,7 @@ class CloneRoutesAsTenant
{ {
protected array $routesToClone = []; protected array $routesToClone = [];
protected bool $addTenantParameter = true; protected bool $addTenantParameter = true;
protected bool $tenantParameterBeforePrefix = true;
protected string|null $domain = null; protected string|null $domain = null;
protected Closure|null $cloneUsing = null; // The callback should accept Route instance or the route name (string) protected Closure|null $cloneUsing = null; // The callback should accept Route instance or the route name (string)
protected Closure|null $shouldClone = null; protected Closure|null $shouldClone = null;
@ -177,6 +178,13 @@ class CloneRoutesAsTenant
return $this; return $this;
} }
public function tenantParameterBeforePrefix(bool $tenantParameterBeforePrefix): static
{
$this->tenantParameterBeforePrefix = $tenantParameterBeforePrefix;
return $this;
}
/** Clone an individual route. */ /** Clone an individual route. */
public function cloneRoute(Route|string $route): static public function cloneRoute(Route|string $route): static
{ {
@ -226,7 +234,13 @@ class CloneRoutesAsTenant
$action->put('middleware', $middleware); $action->put('middleware', $middleware);
if ($this->addTenantParameter) { if ($this->addTenantParameter) {
$action->put('prefix', $prefix . '/{' . PathTenantResolver::tenantParameterName() . '}'); $tenantParameter = '{' . PathTenantResolver::tenantParameterName() . '}';
$newPrefix = $this->tenantParameterBeforePrefix
? $tenantParameter . '/' . $prefix
: $prefix . '/' . $tenantParameter;
$action->put('prefix', $newPrefix);
} }
/** @var Route $newRoute */ /** @var Route $newRoute */

View file

@ -99,12 +99,22 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper
{ {
$names = $this->config->get('tenancy.cache.stores'); $names = $this->config->get('tenancy.cache.stores');
if ( if ($this->config->get('tenancy.cache.scope_sessions', true)) {
$this->config->get('tenancy.cache.scope_sessions', true) && // These are the only cache driven session backends (see Laravel's config/session.php)
in_array($this->config->get('session.driver'), ['redis', 'memcached', 'dynamodb', 'apc'], true) if (! in_array($this->config->get('session.driver'), ['redis', 'memcached', 'dynamodb', 'apc'], true)) {
) { if (app()->environment('production')) {
// We only throw this exception in prod to make configuration a little easier. Developers
// may have scope_sessions set to true while using different session drivers e.g. in tests.
// Previously we just silently ignored this, however since session scoping is of high importance
// in production, we make sure to notify the developer, by throwing an exception, that session
// scoping isn't happening as expected/configured due to an incompatible session driver.
throw new Exception('Session driver [' . $this->config->get('session.driver') . '] cannot be scoped by tenancy.cache.scope_session');
}
} else {
// Scoping sessions using this bootstrapper implicitly adds the session store to $names
$names[] = $this->getSessionCacheStoreName(); $names[] = $this->getSessionCacheStoreName();
} }
}
$names = array_unique($names); $names = array_unique($names);
@ -112,6 +122,7 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper
$store = $this->config->get("cache.stores.{$name}"); $store = $this->config->get("cache.stores.{$name}");
if ($store === null || $store['driver'] === 'file') { if ($store === null || $store['driver'] === 'file') {
// 'file' stores are ignored here and instead handled by FilesystemTenancyBootstrapper
return false; return false;
} }

View file

@ -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)) {
throw new TenantDatabaseDoesNotExistException($database); throw new TenantDatabaseDoesNotExistException($database);
} }
} }

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers;
use Illuminate\Config\Repository;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
class TenantConfigBootstrapper implements TenancyBootstrapper
{
public array $originalConfig = [];
/** @var array<string, string|array> */
public static array $storageToConfigMap = [
// 'paypal_api_key' => 'services.paypal.api_key',
];
public function __construct(
protected Repository $config,
) {}
public function bootstrap(Tenant $tenant): void
{
foreach (static::$storageToConfigMap as $storageKey => $configKey) {
/** @var Tenant&Model $tenant */
$override = Arr::get($tenant, $storageKey);
if (! is_null($override)) {
if (is_array($configKey)) {
foreach ($configKey as $key) {
$this->originalConfig[$key] = $this->originalConfig[$key] ?? $this->config->get($key);
$this->config->set($key, $override);
}
} else {
$this->originalConfig[$configKey] = $this->originalConfig[$configKey] ?? $this->config->get($configKey);
$this->config->set($configKey, $override);
}
}
}
}
public function revert(): void
{
foreach ($this->originalConfig as $key => $value) {
$this->config->set($key, $value);
}
}
}

View file

@ -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.';

View file

@ -81,12 +81,19 @@ class CreateUserWithRLSPolicies extends Command
#[\SensitiveParameter] #[\SensitiveParameter]
string $password, string $password,
): DatabaseConfig { ): DatabaseConfig {
// This is a bit of a hack. We want to use our existing createUser() logic.
// That logic needs a DatabaseConfig instance. However, we aren't really working
// with any specific tenant here. We also *don't* want to use anything tenant-specific
// here. We are creating the SHARED "RLS user". Therefore, we need a custom DatabaseConfig
// instance for this purpose. The easiest way to do that is to grab an empty Tenant model
// (we use TenantWithDatabase in RLS) and manually create the host connection, just like
// DatabaseConfig::manager() would. We don't call that method since we want to use our existing
// PermissionControlledPostgreSQLSchemaManager $manager instance, rather than the "tenant's manager".
/** @var TenantWithDatabase $tenantModel */ /** @var TenantWithDatabase $tenantModel */
$tenantModel = tenancy()->model(); $tenantModel = tenancy()->model();
// Use a temporary DatabaseConfig instance to set the host connection
$temporaryDbConfig = $tenantModel->database(); $temporaryDbConfig = $tenantModel->database();
$temporaryDbConfig->purgeHostConnection(); $temporaryDbConfig->purgeHostConnection();
$tenantHostConnectionName = $temporaryDbConfig->getTenantHostConnectionName(); $tenantHostConnectionName = $temporaryDbConfig->getTenantHostConnectionName();

View file

@ -6,6 +6,7 @@ namespace Stancl\Tenancy\Commands;
use Closure; use Closure;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Process;
class Install extends Command class Install extends Command
{ {
@ -128,6 +129,18 @@ class Install extends Command
public function askForSupport(): void public function askForSupport(): void
{ {
if ($this->components->confirm('Would you like to show your support by starring the project on GitHub?', true)) { if ($this->components->confirm('Would you like to show your support by starring the project on GitHub?', true)) {
$ghVersion = Process::run('gh --version');
$starred = false;
// Make sure the `gh` binary is the actual GitHub CLI and not an unrelated tool
if ($ghVersion->successful() && str_contains($ghVersion->output(), 'https://github.com/cli/cli')) {
$starRequest = Process::run('gh api -X PUT user/starred/archtechx/tenancy');
$starred = $starRequest->successful();
}
if ($starred) {
$this->components->success('Repository starred via gh CLI, thank you!');
} else {
if (PHP_OS_FAMILY === 'Darwin') { if (PHP_OS_FAMILY === 'Darwin') {
exec('open https://github.com/archtechx/tenancy'); exec('open https://github.com/archtechx/tenancy');
} }
@ -139,4 +152,5 @@ class Install extends Command
} }
} }
} }
}
} }

View file

@ -14,10 +14,9 @@ use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Route as RouteFacade; use Illuminate\Support\Facades\Route as RouteFacade;
use Stancl\Tenancy\Enums\RouteMode; use Stancl\Tenancy\Enums\RouteMode;
// todo@refactor move this logic to some dedicated static class?
/** /**
* @mixin \Stancl\Tenancy\Tenancy * @mixin \Stancl\Tenancy\Tenancy
* @internal The public methods in this trait should not be understood to be a public stable API.
*/ */
trait DealsWithRouteContexts trait DealsWithRouteContexts
{ {

View file

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

View file

@ -28,7 +28,8 @@ trait HasDatabase
} }
if ($key === $this->internalPrefix() . 'db_connection') { if ($key === $this->internalPrefix() . 'db_connection') {
// Remove DB connection because that's not used here // Remove DB connection because that's not used for the connection *contents*.
// Instead the code uses getInternal('db_connection').
continue; continue;
} }

View file

@ -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
{ {
try {
$tenant = static::create($attributes); $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
{ {
$tenant = DB::transaction(function () use ($attributes): ?Tenant {
/** @var (Model&Tenant)|null $tenant */ /** @var (Model&Tenant)|null $tenant */
$tenant = static::onlyPending()->first(); $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;

View file

@ -13,7 +13,6 @@ use Stancl\Tenancy\Database\Contracts\TenantWithDatabase as Tenant;
use Stancl\Tenancy\Database\Exceptions\DatabaseManagerNotRegisteredException; use Stancl\Tenancy\Database\Exceptions\DatabaseManagerNotRegisteredException;
use Stancl\Tenancy\Database\Exceptions\NoConnectionSetException; use Stancl\Tenancy\Database\Exceptions\NoConnectionSetException;
// todo@dbRefactor refactor host connection logic to make customizing the host connection easier
class DatabaseConfig class DatabaseConfig
{ {
/** The tenant whose database we're dealing with. */ /** The tenant whose database we're dealing with. */
@ -115,7 +114,7 @@ class DatabaseConfig
{ {
$this->tenant->setInternal('db_name', $this->getName()); $this->tenant->setInternal('db_name', $this->getName());
if ($this->connectionDriverManager($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) { if ($this->managerForDriver($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) {
$this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($this->tenant, $this)); $this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($this->tenant, $this));
$this->tenant->setInternal('db_password', $this->getPassword() ?? (static::$passwordGenerator)($this->tenant, $this)); $this->tenant->setInternal('db_password', $this->getPassword() ?? (static::$passwordGenerator)($this->tenant, $this));
} }
@ -137,7 +136,9 @@ class DatabaseConfig
} }
if ($template = config('tenancy.database.template_tenant_connection')) { if ($template = config('tenancy.database.template_tenant_connection')) {
return is_array($template) ? array_merge($this->getCentralConnection(), $template) : config("database.connections.{$template}"); return is_array($template)
? array_merge($this->getCentralConnection(), $template)
: config("database.connections.{$template}");
} }
return $this->getCentralConnection(); return $this->getCentralConnection();
@ -176,10 +177,10 @@ class DatabaseConfig
$config = $this->tenantConfig; $config = $this->tenantConfig;
$templateConnection = $this->getTemplateConnection(); $templateConnection = $this->getTemplateConnection();
if ($this->connectionDriverManager($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) { if ($this->managerForDriver($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) {
// We're removing the username and password because user with these credentials is not created yet // We remove the username and password because the user with these credentials is not yet created.
// If you need to provide username and password when using PermissionControlledMySQLDatabaseManager, // If you need to provide a username and a password when using a permission controlled database manager,
// consider creating a new connection and use it as `tenancy_db_connection` tenant config key // consider creating a new connection and use it as `tenancy_db_connection`.
unset($config['username'], $config['password']); unset($config['username'], $config['password']);
} }
@ -191,7 +192,7 @@ class DatabaseConfig
} }
/** /**
* Purge the previous tenant connection before opening it for another tenant. * Purge the previous host connection before opening it for another tenant.
*/ */
public function purgeHostConnection(): void public function purgeHostConnection(): void
{ {
@ -199,20 +200,20 @@ class DatabaseConfig
} }
/** /**
* Get the TenantDatabaseManager for this tenant's connection. * Get the TenantDatabaseManager for this tenant's host connection.
* *
* @throws NoConnectionSetException|DatabaseManagerNotRegisteredException * @throws NoConnectionSetException|DatabaseManagerNotRegisteredException
*/ */
public function manager(): Contracts\TenantDatabaseManager public function manager(): Contracts\TenantDatabaseManager
{ {
// Laravel caches the previous PDO connection, so we purge it to be able to change the connection details // Laravel persists the PDO connection, so we purge it to be able to change the connection details
$this->purgeHostConnection(); $this->purgeHostConnection();
// Create the tenant host connection config // Create the tenant host connection config
$tenantHostConnectionName = $this->getTenantHostConnectionName(); $tenantHostConnectionName = $this->getTenantHostConnectionName();
config(["database.connections.{$tenantHostConnectionName}" => $this->hostConnection()]); config(["database.connections.{$tenantHostConnectionName}" => $this->hostConnection()]);
$manager = $this->connectionDriverManager(config("database.connections.{$tenantHostConnectionName}.driver")); $manager = $this->managerForDriver(config("database.connections.{$tenantHostConnectionName}.driver"));
if ($manager instanceof Contracts\StatefulTenantDatabaseManager) { if ($manager instanceof Contracts\StatefulTenantDatabaseManager) {
$manager->setConnection($tenantHostConnectionName); $manager->setConnection($tenantHostConnectionName);
@ -222,12 +223,11 @@ class DatabaseConfig
} }
/** /**
* todo@name come up with a better name * Get the TenantDatabaseManager for a given database driver.
* Get database manager class from the given connection config's driver.
* *
* @throws DatabaseManagerNotRegisteredException * @throws DatabaseManagerNotRegisteredException
*/ */
protected function connectionDriverManager(string $driver): Contracts\TenantDatabaseManager protected function managerForDriver(string $driver): Contracts\TenantDatabaseManager
{ {
$databaseManagers = config('tenancy.database.managers'); $databaseManagers = config('tenancy.database.managers');

View file

@ -23,7 +23,6 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl
{ {
$database = $databaseConfig->getName(); $database = $databaseConfig->getName();
$username = $databaseConfig->getUsername(); $username = $databaseConfig->getUsername();
$hostname = $databaseConfig->connection()['host'];
$password = $databaseConfig->getPassword(); $password = $databaseConfig->getPassword();
$this->connection()->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}'"); $this->connection()->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}'");

View file

@ -30,10 +30,6 @@ 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
// 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
// while the RLS user should STILL get access to those tables
foreach ($tables as $table) { foreach ($tables as $table) {
$tableName = $table->table_name; $tableName = $table->table_name;

View file

@ -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;
@ -15,17 +14,12 @@ use Throwable;
class SQLiteDatabaseManager implements TenantDatabaseManager class SQLiteDatabaseManager implements TenantDatabaseManager
{ {
/** /**
* SQLite Database path without ending slash. * SQLite database directory path.
*
* Defaults to database_path().
*/ */
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
@ -84,30 +78,13 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
// or creating a closure holding a reference to it and passing that to register_shutdown_function(). // or creating a closure holding a reference to it and passing that to register_shutdown_function().
$name = '_tenancy_inmemory_' . $tenant->getTenantKey(); $name = '_tenancy_inmemory_' . $tenant->getTenantKey();
$tenant->update(['tenancy_db_name' => "file:$name?mode=memory&cache=shared"]); $tenant->setInternal('db_name', "file:$name?mode=memory&cache=shared");
$tenant->save();
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 +99,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;
} }
@ -150,15 +135,10 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
return $baseConfig; return $baseConfig;
} }
public function setConnection(string $connection): void
{
//
}
public function getPath(string $name): string public function getPath(string $name): string
{ {
if (static::$path) { if (static::$path) {
return static::$path . DIRECTORY_SEPARATOR . $name; return rtrim(static::$path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $name;
} }
return database_path($name); return database_path($name);

View file

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

View file

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

View file

@ -4,25 +4,22 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Features; namespace Stancl\Tenancy\Features;
use Exception;
use Illuminate\Database\Connectors\ConnectionFactory; use Illuminate\Database\Connectors\ConnectionFactory;
use Illuminate\Database\SQLiteConnection; 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) {
if ($connection instanceof SQLiteConnection) { if ($connection instanceof SQLiteConnection) {
if (! $this->loadExtension($connection->getPdo())) { if (! $this->setAuthorizer($connection->getPdo())) {
return; return;
} }
} }
@ -31,42 +28,54 @@ class DisallowSqliteAttach implements Feature
// Apply the change to all sqlite connections resolved in the future // Apply the change to all sqlite connections resolved in the future
DB::extend('sqlite', function ($config, $name) { DB::extend('sqlite', function ($config, $name) {
$conn = app(ConnectionFactory::class)->make($config, $name); $conn = app(ConnectionFactory::class)->make($config, $name);
$this->loadExtension($conn->getPdo()); $this->setAuthorizer($conn->getPdo());
return $conn; return $conn;
}); });
} }
protected function loadExtension(PDO $pdo): bool protected function setAuthorizer(PDO $pdo): bool
{ {
if (static::$loadExtensionSupported === null) { if (PHP_VERSION_ID >= 80500) {
static::$loadExtensionSupported = method_exists($pdo, 'loadExtension'); $this->setNativeAuthorizer($pdo);
return true;
} }
if (static::$loadExtensionSupported === false) { static $loadExtensionSupported = method_exists($pdo, 'loadExtension');
return false;
} if ((! $loadExtensionSupported) ||
if (static::$extensionPath === false) { (static::$extensionPath === false) ||
return false; (PHP_INT_SIZE !== 8)
} ) return false;
$suffix = match (PHP_OS_FAMILY) { $suffix = match (PHP_OS_FAMILY) {
'Linux' => 'so', 'Linux' => 'so',
'Windows' => 'dll', 'Windows' => 'dll',
'Darwin' => 'dylib', 'Darwin' => 'dylib',
default => throw new Exception("The DisallowSqliteAttach feature doesn't support your operating system: " . PHP_OS_FAMILY), default => 'error',
}; };
if ($suffix === 'error') return false;
$arch = php_uname('m'); $arch = php_uname('m');
$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
return true; return true;
} }
protected function setNativeAuthorizer(PDO $pdo): void
{
// @phpstan-ignore method.notFound
$pdo->setAuthorizer(static function (int $action): int {
return $action === 24 // SQLITE_ATTACH
? PDO\Sqlite::DENY
: PDO\Sqlite::OK;
});
}
} }

View file

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

View file

@ -12,8 +12,10 @@ 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;
// todo@release remove this class
/** @deprecated Use the TenantConfigBootstrapper instead. */
class TenantConfig implements Feature class TenantConfig implements Feature
{ {
public array $originalConfig = []; public array $originalConfig = [];
@ -27,7 +29,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 */

View file

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

View file

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

View file

@ -40,7 +40,8 @@ class CreateDatabase implements ShouldQueue
try { try {
$databaseManager->ensureTenantCanBeCreated($this->tenant); $databaseManager->ensureTenantCanBeCreated($this->tenant);
$this->tenant->database()->manager()->createDatabase($this->tenant); $databaseCreated = $this->tenant->database()->manager()->createDatabase($this->tenant);
assert($databaseCreated);
event(new DatabaseCreated($this->tenant)); event(new DatabaseCreated($this->tenant));
} catch (TenantDatabaseAlreadyExistsException | TenantDatabaseUserAlreadyExistsException $e) { } catch (TenantDatabaseAlreadyExistsException | TenantDatabaseUserAlreadyExistsException $e) {

View file

@ -8,8 +8,6 @@ use Illuminate\Routing\Events\RouteMatched;
use Stancl\Tenancy\Enums\RouteMode; use Stancl\Tenancy\Enums\RouteMode;
use Stancl\Tenancy\Resolvers\PathTenantResolver; use Stancl\Tenancy\Resolvers\PathTenantResolver;
// todo@earlyIdReview
/** /**
* Conditionally removes the tenant parameter from matched routes when using kernel path identification. * Conditionally removes the tenant parameter from matched routes when using kernel path identification.
* *

View file

@ -6,7 +6,6 @@ namespace Stancl\Tenancy\Middleware;
use Closure; use Closure;
use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode; use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode;
use Stancl\Tenancy\Exceptions\TenancyNotInitializedException;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode
@ -14,7 +13,13 @@ class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode
public function handle($request, Closure $next) public function handle($request, Closure $next)
{ {
if (! tenant()) { if (! tenant()) {
throw new TenancyNotInitializedException; // If there's no tenant, there's no tenant to check for maintenance mode.
// Since tenant identification middleware has higher priority than this
// middleware, a missing tenant would have already lead to request termination.
// (And even if priority were misconfigured, the request would simply get
// terminated *after* this middleware.)
// Therefore, we are likely on a universal route, in central context.
return $next($request);
} }
if (tenant('maintenance_mode')) { if (tenant('maintenance_mode')) {

View file

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

View file

@ -110,7 +110,7 @@ class TenancyUrlGenerator extends UrlGenerator
*/ */
public function route($name, $parameters = [], $absolute = true) public function route($name, $parameters = [], $absolute = true)
{ {
if ($name instanceof BackedEnum && ! is_string($name = $name->value)) { // @phpstan-ignore function.impossibleType if ($name instanceof BackedEnum && ! is_string($name = $name->value)) {
throw new InvalidArgumentException('Attribute [name] expects a string backed enum.'); throw new InvalidArgumentException('Attribute [name] expects a string backed enum.');
} }
@ -125,7 +125,7 @@ class TenancyUrlGenerator extends UrlGenerator
*/ */
public function temporarySignedRoute($name, $expiration, $parameters = [], $absolute = true) public function temporarySignedRoute($name, $expiration, $parameters = [], $absolute = true)
{ {
if ($name instanceof BackedEnum && ! is_string($name = $name->value)) { // @phpstan-ignore function.impossibleType if ($name instanceof BackedEnum && ! is_string($name = $name->value)) {
throw new InvalidArgumentException('Attribute [name] expects a string backed enum.'); throw new InvalidArgumentException('Attribute [name] expects a string backed enum.');
} }

View file

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

View file

@ -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,11 +25,15 @@ 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? */
public bool $initialized = false; // todo@docs document the difference between $tenant being set and $initialized being true (e.g. end of initialize() method) public bool $initialized = false;
/** /**
* List of relations to eager load when fetching a tenant via tenancy()->find(). * List of relations to eager load when fetching a tenant via tenancy()->find().
@ -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
{ {
@ -117,10 +139,12 @@ class Tenancy
return; return;
} }
// We fire both of these events before unsetting tenant so that listeners
// to both events can access the current tenant. Having separate events
// still has value as it's consistent with our other events and provides
// more granularity for event listeners, e.g. for ensuring something runs
// before standard TenancyEnded listeners such as RevertToCentralContext.
event(new Events\EndingTenancy($this)); event(new Events\EndingTenancy($this));
// todo@samuel find a way to refactor these two methods
event(new Events\TenancyEnded($this)); event(new Events\TenancyEnded($this));
$this->tenant = null; $this->tenant = null;
@ -131,12 +155,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 +174,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>
*/ */

View file

@ -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', []);

View file

@ -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')) {

103
static_properties.nu Executable file
View file

@ -0,0 +1,103 @@
#!/usr/bin/env nu
# Utility for exporting static properties used for configuration
def main []: nothing -> string {
"See --help for subcommands"
}
# The current number of config static properties in the codebase
def "main count" [...paths: string]: nothing -> int {
props ...$paths | length
}
# Available static properties, grouped by file, rendered as a table
def "main table" [...paths: string]: nothing -> string {
props ...$paths | table --theme rounded --expand
}
# Plain text version of available static properties
def "main plain" [...paths: string]: nothing -> string {
props ...$paths
| each { $"// File: ($in.file)\n($in.props | str join "\n\n")"}
| str join "\n//------------------------------------------------------------\n\n"
}
# Expressive Code formatting of available static properties, used in docs
def "main docs" [...paths: string]: nothing -> string {
(("{/* GENERATED_BEGIN */}\n" + (props ...$paths
| each { update props { each { if ($in | str ends-with "= [") {
$"($in)/* ... */];"
} else { $in }}}}
| each { $"```php /public static .*$/\n// File: ($in.file)\n($in.props | str join "\n\n")\n```"}
| str join "\n\n"))
+ "\n{/* GENERATED_END */}")
}
def props [...paths: string]: nothing -> table<file: string, props: list<string>> {
ls ...(if ($paths | length) > 0 {
($paths | each {|path|
if ($path | str contains "*") {
# already a glob expr
$path | into glob
} else if ($path | str ends-with ".php") {
# src/Foo/Bar.php
$path
} else {
# just 'src/Foo' passed
$"($path)/**/*.php" | into glob
}
})
} else {
[("src/**/*.php" | into glob)]
})
| each { { name: $in.name, content: (open $in.name) } }
| find -nr 'public static (?!.*function)'
| par-each {|file|
let lines = $file.content | lines
mut docblock_start = 0
mut docblock_end = 0
mut props = []
for line in ($lines | enumerate) {
if ($line.item | str contains "/**") {
$docblock_start = $line.index
}
if ($line.item | str contains "@internal") {
# Docblocks with @internal are ignored
$docblock_start = 0
$docblock_end = 0
}
if ($line.item | str contains "*/") {
$docblock_end = $line.index
}
if (
(
( # Valid (non-internal) docblock
$docblock_start != 0 and
$docblock_end != 0 and
$docblock_end == ($line.index - 1)
) or
( # No docblock
$line.index != 0 and
(($lines | get ($line.index - 1)) | str index-of "*/") == -1
)
) and
($line.item | str trim | str index-of "public static") == 0 and
($line.item | str trim | str index-of "public static function") == -1
) {
if ($docblock_start == 0) or ($docblock_end == 0) or ($docblock_end != ($line.index - 1)) {
$docblock_start = $line.index
$docblock_end = $line.index
}
$props = $props | append ($lines | slice $docblock_start..$line.index | each { str trim } | str join "\n")
$docblock_start = 0
$docblock_end = 0
}
}
{file: $file.name, props: $props}
}
| where ($it.props | length) > 0
}

2
t
View file

@ -1,4 +1,4 @@
#!/bin/bash #!/usr/bin/env bash
if [[ "${CLAUDECODE}" != "1" ]]; then if [[ "${CLAUDECODE}" != "1" ]]; then
COLOR_FLAG="--colors=always" COLOR_FLAG="--colors=always"

2
test
View file

@ -1,4 +1,4 @@
#!/bin/bash #!/usr/bin/env bash
if [[ "${CLAUDECODE}" != "1" ]]; then if [[ "${CLAUDECODE}" != "1" ]]; then
COLOR_FLAG="--colors=always" COLOR_FLAG="--colors=always"

View file

@ -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' => [

View file

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

View file

@ -172,7 +172,7 @@ test('the clone action can clone specific routes either using name or route inst
false, false,
]); ]);
test('the clone action prefixes already prefixed routes correctly', function () { test('the clone action prefixes already prefixed routes correctly', function (bool $tenantParameterBeforePrefix) {
$routes = [ $routes = [
RouteFacade::get('/home', fn () => true) RouteFacade::get('/home', fn () => true)
->middleware(['clone']) ->middleware(['clone'])
@ -195,7 +195,12 @@ test('the clone action prefixes already prefixed routes correctly', function ()
->prefix('prefix/'), ->prefix('prefix/'),
]; ];
app(CloneRoutesAsTenant::class)->handle(); $cloneAction = app(CloneRoutesAsTenant::class);
$cloneAction
->tenantParameterBeforePrefix($tenantParameterBeforePrefix)
->handle();
$expectedPrefix = $tenantParameterBeforePrefix ? '{tenant}/prefix' : 'prefix/{tenant}';
$clonedRoutes = [ $clonedRoutes = [
RouteFacade::getRoutes()->getByName('tenant.home'), RouteFacade::getRoutes()->getByName('tenant.home'),
@ -206,9 +211,10 @@ test('the clone action prefixes already prefixed routes correctly', function ()
// The cloned route is prefixed correctly // The cloned route is prefixed correctly
foreach ($clonedRoutes as $key => $route) { foreach ($clonedRoutes as $key => $route) {
expect($route->getPrefix())->toBe("prefix/{tenant}"); expect($route->getPrefix())->toBe($expectedPrefix);
$clonedRouteUrl = route($route->getName(), ['tenant' => $tenant = Tenant::create()]); $clonedRouteUrl = route($route->getName(), ['tenant' => $tenant = Tenant::create()]);
$expectedPrefixInUrl = $tenantParameterBeforePrefix ? "{$tenant->id}/prefix" : "prefix/{$tenant->id}";
expect($clonedRouteUrl) expect($clonedRouteUrl)
// Original prefix does not occur in the cloned route's URL // Original prefix does not occur in the cloned route's URL
@ -216,14 +222,14 @@ test('the clone action prefixes already prefixed routes correctly', function ()
->not()->toContain("//prefix") ->not()->toContain("//prefix")
->not()->toContain("prefix//") ->not()->toContain("prefix//")
// Instead, the route is prefixed correctly // Instead, the route is prefixed correctly
->toBe("http://localhost/prefix/{$tenant->id}/{$routes[$key]->getName()}"); ->toBe("http://localhost/{$expectedPrefixInUrl}/{$routes[$key]->getName()}");
// The cloned route is accessible // The cloned route is accessible
pest()->get($clonedRouteUrl)->assertOk(); pest()->get($clonedRouteUrl)->assertOk();
} }
}); })->with([true, false]);
test('clone action trims trailing slashes from prefixes given to nested route groups', function () { test('clone action trims trailing slashes from prefixes given to nested route groups', function (bool $tenantParameterBeforePrefix) {
RouteFacade::prefix('prefix')->group(function () { RouteFacade::prefix('prefix')->group(function () {
RouteFacade::prefix('')->group(function () { RouteFacade::prefix('')->group(function () {
// This issue seems to only happen when there's a group with a prefix, then a group with an empty prefix, and then a / route // This issue seems to only happen when there's a group with a prefix, then a group with an empty prefix, and then a / route
@ -237,7 +243,10 @@ test('clone action trims trailing slashes from prefixes given to nested route gr
}); });
}); });
app(CloneRoutesAsTenant::class)->handle(); $cloneAction = app(CloneRoutesAsTenant::class);
$cloneAction
->tenantParameterBeforePrefix($tenantParameterBeforePrefix)
->handle();
$clonedLandingUrl = route('tenant.landing', ['tenant' => $tenant = Tenant::create()]); $clonedLandingUrl = route('tenant.landing', ['tenant' => $tenant = Tenant::create()]);
$clonedHomeRouteUrl = route('tenant.home', ['tenant' => $tenant]); $clonedHomeRouteUrl = route('tenant.home', ['tenant' => $tenant]);
@ -245,17 +254,20 @@ test('clone action trims trailing slashes from prefixes given to nested route gr
$landingRoute = RouteFacade::getRoutes()->getByName('tenant.landing'); $landingRoute = RouteFacade::getRoutes()->getByName('tenant.landing');
$homeRoute = RouteFacade::getRoutes()->getByName('tenant.home'); $homeRoute = RouteFacade::getRoutes()->getByName('tenant.home');
expect($landingRoute->uri())->toBe('prefix/{tenant}'); $expectedPrefix = $tenantParameterBeforePrefix ? '{tenant}/prefix' : 'prefix/{tenant}';
expect($homeRoute->uri())->toBe('prefix/{tenant}/home'); $expectedPrefixInUrl = $tenantParameterBeforePrefix ? "{$tenant->id}/prefix" : "prefix/{$tenant->id}";
expect($landingRoute->uri())->toBe($expectedPrefix);
expect($homeRoute->uri())->toBe("{$expectedPrefix}/home");
expect($clonedLandingUrl) expect($clonedLandingUrl)
->not()->toContain("prefix//") ->not()->toContain("prefix//")
->toBe("http://localhost/prefix/{$tenant->id}"); ->toBe("http://localhost/{$expectedPrefixInUrl}");
expect($clonedHomeRouteUrl) expect($clonedHomeRouteUrl)
->not()->toContain("prefix//") ->not()->toContain("prefix//")
->toBe("http://localhost/prefix/{$tenant->id}/home"); ->toBe("http://localhost/{$expectedPrefixInUrl}/home");
}); })->with([true, false]);
test('tenant routes are ignored from cloning and clone middleware in groups causes no issues', function () { test('tenant routes are ignored from cloning and clone middleware in groups causes no issues', function () {
// Should NOT be cloned, already has tenant parameter // Should NOT be cloned, already has tenant parameter

View file

@ -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', [

View file

@ -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', [

View file

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

View file

@ -2,29 +2,27 @@
declare(strict_types=1); declare(strict_types=1);
use Illuminate\Support\Facades\Event; use Stancl\Tenancy\Bootstrappers\TenantConfigBootstrapper;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Features\TenantConfig;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
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;
use function Stancl\Tenancy\Tests\withBootstrapping;
beforeEach(function () {
config([
'tenancy.bootstrappers' => [TenantConfigBootstrapper::class],
]);
withBootstrapping();
});
afterEach(function () { afterEach(function () {
TenantConfig::$storageToConfigMap = []; TenantConfigBootstrapper::$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(TenancyEnded::class, RevertToCentralContext::class);
TenantConfig::$storageToConfigMap = [ TenantConfigBootstrapper::$storageToConfigMap = [
'whitelabel.config.theme' => 'whitelabel.theme', 'whitelabel.config.theme' => 'whitelabel.theme',
]; ];
@ -39,14 +37,8 @@ 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(TenancyEnded::class, RevertToCentralContext::class);
TenantConfig::$storageToConfigMap = [ TenantConfigBootstrapper::$storageToConfigMap = [
'paypal_api_public' => 'services.paypal.public', 'paypal_api_public' => 'services.paypal.public',
'paypal_api_private' => 'services.paypal.private', 'paypal_api_private' => 'services.paypal.private',
]; ];
@ -68,14 +60,8 @@ 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(TenancyEnded::class, RevertToCentralContext::class);
TenantConfig::$storageToConfigMap = [ TenantConfigBootstrapper::$storageToConfigMap = [
'paypal_api_public' => [ 'paypal_api_public' => [
'services.paypal.public1', 'services.paypal.public1',
'services.paypal.public2', 'services.paypal.public2',

View file

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

View file

@ -6,6 +6,8 @@ use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Stancl\Tenancy\Database\Concerns\MaintenanceMode; use Stancl\Tenancy\Database\Concerns\MaintenanceMode;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Events\TenantMaintenanceModeDisabled;
use Stancl\Tenancy\Events\TenantMaintenanceModeEnabled;
use Stancl\Tenancy\Middleware\CheckTenantForMaintenanceMode; use Stancl\Tenancy\Middleware\CheckTenantForMaintenanceMode;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use function Stancl\Tenancy\Tests\pest; use function Stancl\Tenancy\Tests\pest;
@ -38,18 +40,46 @@ test('tenants can be in maintenance mode', function () {
pest()->get('http://acme.localhost/foo')->assertStatus(200); pest()->get('http://acme.localhost/foo')->assertStatus(200);
}); });
test('maintenance mode events are fired', function () { test('maintenance mode middleware can be used with universal routes', function () {
$tenant = MaintenanceTenant::create(); Route::get('/foo', function () {
return 'bar';
})->middleware(['universal', InitializeTenancyByDomain::class, CheckTenantForMaintenanceMode::class]);
Event::fake(); $tenant = MaintenanceTenant::create();
$tenant->domains()->create([
'domain' => 'acme.localhost',
]);
// Revert to central context after each request so that the tenant context
// from the request doesn't persist
$run = function (Closure $callback) { $callback(); tenancy()->end(); };
$run(fn () => pest()->get('http://acme.localhost/foo')->assertStatus(200));
$run(fn () => pest()->get('http://localhost/foo')->assertStatus(200));
$tenant->putDownForMaintenance(); $tenant->putDownForMaintenance();
Event::assertDispatched(\Stancl\Tenancy\Events\TenantMaintenanceModeEnabled::class); $run(fn () => pest()->get('http://acme.localhost/foo')->assertStatus(503));
$run(fn () => pest()->get('http://localhost/foo')->assertStatus(200)); // Not affected by a tenant's maintenance mode
$tenant->bringUpFromMaintenance(); $tenant->bringUpFromMaintenance();
Event::assertDispatched(\Stancl\Tenancy\Events\TenantMaintenanceModeDisabled::class); $run(fn () => pest()->get('http://acme.localhost/foo')->assertStatus(200));
$run(fn () => pest()->get('http://localhost/foo')->assertStatus(200));
});
test('maintenance mode events are fired', function () {
$tenant = MaintenanceTenant::create();
Event::fake([TenantMaintenanceModeEnabled::class, TenantMaintenanceModeDisabled::class]);
$tenant->putDownForMaintenance();
Event::assertDispatched(TenantMaintenanceModeEnabled::class);
$tenant->bringUpFromMaintenance();
Event::assertDispatched(TenantMaintenanceModeDisabled::class);
}); });
test('tenants can be put into maintenance mode using artisan commands', function() { test('tenants can be put into maintenance mode using artisan commands', function() {

View file

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

View file

@ -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([
@ -239,9 +245,6 @@ test('tenant database can be created and deleted on a foreign server', function
'prefix_indexes' => true, 'prefix_indexes' => true,
'strict' => true, 'strict' => true,
'engine' => null, 'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
], ],
]); ]);
@ -287,9 +290,6 @@ test('tenant database can be created on a foreign server by using the host from
'prefix_indexes' => true, 'prefix_indexes' => true,
'strict' => true, 'strict' => true,
'engine' => null, 'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
], ],
]); ]);
@ -327,9 +327,6 @@ test('database credentials can be provided to PermissionControlledMySQLDatabaseM
'prefix_indexes' => true, 'prefix_indexes' => true,
'strict' => true, 'strict' => true,
'engine' => null, 'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
], ],
]); ]);

View file

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

View file

@ -25,6 +25,7 @@ use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\LogTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\LogTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper;
use Stancl\Tenancy\Bootstrappers\TenantConfigBootstrapper;
use function Stancl\Tenancy\Tests\pest; use function Stancl\Tenancy\Tests\pest;
@ -145,9 +146,6 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
'prefix_indexes' => true, 'prefix_indexes' => true,
'strict' => true, 'strict' => true,
'engine' => null, 'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
], ],
'database.connections.sqlite.database' => ':memory:', 'database.connections.sqlite.database' => ':memory:',
'database.connections.mysql.charset' => 'utf8mb4', 'database.connections.mysql.charset' => 'utf8mb4',
@ -196,6 +194,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
$app->singleton(UrlGeneratorBootstrapper::class); $app->singleton(UrlGeneratorBootstrapper::class);
$app->singleton(FilesystemTenancyBootstrapper::class); $app->singleton(FilesystemTenancyBootstrapper::class);
$app->singleton(LogTenancyBootstrapper::class); $app->singleton(LogTenancyBootstrapper::class);
$app->singleton(TenantConfigBootstrapper::class);
} }
protected function getPackageProviders($app) protected function getPackageProviders($app)
@ -239,11 +238,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);