mirror of
https://github.com/archtechx/tenancy.git
synced 2026-02-04 09:04:03 +00:00
Merge branch 'master' into impersonation-token-cleanup
This commit is contained in:
commit
69a1326029
68 changed files with 1336 additions and 367 deletions
2
.gitattributes
vendored
2
.gitattributes
vendored
|
|
@ -10,6 +10,7 @@
|
|||
/.nvim.lua export-ignore
|
||||
/art export-ignore
|
||||
/coverage export-ignore
|
||||
/CLAUDE.md export-ignore
|
||||
/CONTRIBUTING.md export-ignore
|
||||
/INTERNAL.md export-ignore
|
||||
/SUPPORT.md export-ignore
|
||||
|
|
@ -19,6 +20,7 @@
|
|||
/Dockerfile export-ignore
|
||||
/doctum export-ignore
|
||||
/phpunit.xml export-ignore
|
||||
/static_properties.nu export-ignore
|
||||
/t export-ignore
|
||||
/test export-ignore
|
||||
/tests export-ignore
|
||||
|
|
|
|||
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
|
|
@ -17,7 +17,6 @@ jobs:
|
|||
matrix:
|
||||
include:
|
||||
- laravel: "^12.0"
|
||||
php: "8.4"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
|
|
|||
10
.github/workflows/validate.yml
vendored
10
.github/workflows/validate.yml
vendored
|
|
@ -8,14 +8,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Check for todo0
|
||||
run: '! grep -r "todo0" --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 .'
|
||||
- name: Check for priority todos
|
||||
run: '! grep -r "todo[0-9]" --exclude-dir=workflows .'
|
||||
if: always()
|
||||
- name: Check for non-todo skip()s in tests
|
||||
run: '! grep -r "skip(" --exclude-dir=workflows tests/ | grep -v "todo"'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
-- 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.
|
||||
-- This prevents the setup() call in neovim config from starting the client (or doing anything at all).
|
||||
require('lspconfig').tailwindcss.setup = function () end
|
||||
-- `vendor` symlink in `testbench-core/laravel/vendor`, so we disable it here.
|
||||
vim.lsp.enable('tailwindcss', false)
|
||||
|
|
|
|||
|
|
@ -43,3 +43,15 @@ If you need to rebuild the container for any reason (e.g. a change in `Dockerfil
|
|||
## PHPStan
|
||||
|
||||
Use `composer phpstan` to run our phpstan suite.
|
||||
|
||||
## PhpStorm
|
||||
|
||||
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`.
|
||||
|
||||
## 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
|
||||
|
|
|
|||
42
Dockerfile
42
Dockerfile
|
|
@ -1,10 +1,8 @@
|
|||
ARG PHP_VERSION=8.4
|
||||
|
||||
FROM php:${PHP_VERSION}-cli-bookworm
|
||||
SHELL ["/bin/bash", "-c"]
|
||||
|
||||
RUN apt-get update && 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-get update
|
||||
|
||||
RUN apt-get install -y gnupg2 \
|
||||
&& curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \
|
||||
|
|
@ -12,22 +10,56 @@ RUN apt-get install -y gnupg2 \
|
|||
&& apt-get update \
|
||||
&& 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 pecl install apcu && docker-php-ext-enable apcu
|
||||
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 pdo_sqlsrv && docker-php-ext-enable pdo_sqlsrv
|
||||
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 pdo_mysql && docker-php-ext-enable pdo_mysql
|
||||
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 echo "apc.enable_cli=1" >> "$PHP_INI_DIR/php.ini"
|
||||
|
||||
# Only used on GHA
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
|
||||
|
||||
# Conditionally install and configure Xdebug (last step for faster rebuilds)
|
||||
ARG XDEBUG_ENABLED=false
|
||||
RUN if [ "$XDEBUG_ENABLED" = "true" ]; then \
|
||||
pecl install xdebug && docker-php-ext-enable xdebug && \
|
||||
echo "xdebug.mode=debug" >> "$PHP_INI_DIR/conf.d/docker-php-ext-xdebug.ini" && \
|
||||
echo "xdebug.start_with_request=yes" >> "$PHP_INI_DIR/conf.d/docker-php-ext-xdebug.ini" && \
|
||||
echo "xdebug.client_host=host.docker.internal" >> "$PHP_INI_DIR/conf.d/docker-php-ext-xdebug.ini" && \
|
||||
echo "xdebug.client_port=9003" >> "$PHP_INI_DIR/conf.d/docker-php-ext-xdebug.ini" && \
|
||||
echo "xdebug.discover_client_host=true" >> "$PHP_INI_DIR/conf.d/docker-php-ext-xdebug.ini" && \
|
||||
echo "xdebug.log=/var/log/xdebug.log" >> "$PHP_INI_DIR/conf.d/docker-php-ext-xdebug.ini"; \
|
||||
fi
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
|
|
|||
|
|
@ -21,6 +21,21 @@ use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
|
|||
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
|
||||
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
|
||||
{
|
||||
// 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!
|
||||
])->send(function (Events\TenantCreated $event) {
|
||||
return $event->tenant;
|
||||
})->shouldBeQueued(false), // `false` by default, but you likely want to make this `true` in production.
|
||||
})->shouldBeQueued(false),
|
||||
|
||||
// Listeners\CreateTenantStorage::class,
|
||||
],
|
||||
|
|
@ -65,7 +80,7 @@ class TenancyServiceProvider extends ServiceProvider
|
|||
Jobs\DeleteDatabase::class,
|
||||
])->send(function (Events\TenantDeleted $event) {
|
||||
return $event->tenant;
|
||||
})->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production.
|
||||
})->shouldBeQueued(false),
|
||||
],
|
||||
|
||||
Events\TenantMaintenanceModeEnabled::class => [],
|
||||
|
|
|
|||
|
|
@ -8,6 +8,21 @@ use Stancl\Tenancy\Bootstrappers;
|
|||
use Stancl\Tenancy\Enums\RouteMode;
|
||||
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 [
|
||||
/**
|
||||
* Configuration for the models used by Tenancy.
|
||||
|
|
@ -155,6 +170,7 @@ return [
|
|||
Bootstrappers\DatabaseTenancyBootstrapper::class,
|
||||
Bootstrappers\CacheTenancyBootstrapper::class,
|
||||
// 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\QueueTenancyBootstrapper::class,
|
||||
// Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed
|
||||
|
|
@ -163,9 +179,10 @@ return [
|
|||
Bootstrappers\DatabaseSessionBootstrapper::class,
|
||||
|
||||
// Configurable bootstrappers
|
||||
// Bootstrappers\TenantConfigBootstrapper::class,
|
||||
// Bootstrappers\RootUrlBootstrapper::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\BroadcastChannelPrefixBootstrapper::class,
|
||||
|
||||
|
|
@ -404,7 +421,6 @@ return [
|
|||
'features' => [
|
||||
// Stancl\Tenancy\Features\UserImpersonation::class,
|
||||
// Stancl\Tenancy\Features\TelescopeTags::class,
|
||||
// Stancl\Tenancy\Features\TenantConfig::class,
|
||||
// Stancl\Tenancy\Features\CrossDomainRedirect::class,
|
||||
// Stancl\Tenancy\Features\ViteBundler::class,
|
||||
// Stancl\Tenancy\Features\DisallowSqliteAttach::class,
|
||||
|
|
|
|||
|
|
@ -63,8 +63,18 @@
|
|||
"docker-up": "docker compose up -d",
|
||||
"docker-down": "docker compose down",
|
||||
"docker-restart": "docker compose down && docker compose up -d",
|
||||
"docker-rebuild": "PHP_VERSION=8.4 docker compose up -d --no-deps --build",
|
||||
"docker-rebuild": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"docker compose up -d --no-deps --build"
|
||||
],
|
||||
"docker-rebuild-with-xdebug": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"XDEBUG_ENABLED=true docker compose up -d --no-deps --build"
|
||||
],
|
||||
"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-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",
|
||||
|
|
@ -72,10 +82,22 @@
|
|||
"phpstan": "vendor/bin/phpstan --memory-limit=256M",
|
||||
"phpstan-pro": "vendor/bin/phpstan --memory-limit=256M --pro",
|
||||
"cs": "PHP_CS_FIXER_IGNORE_ENV=1 php-cs-fixer fix --config=.php-cs-fixer.php",
|
||||
"test": "./test --no-coverage",
|
||||
"test-full": "./test",
|
||||
"act": "act -j tests --matrix 'laravel:^11.0'",
|
||||
"act-input": "act -j tests --matrix 'laravel:^11.0' --input"
|
||||
"test": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"./test --no-coverage"
|
||||
],
|
||||
"test-full": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"./test"
|
||||
],
|
||||
"act": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"act -j tests --matrix 'laravel:^11.0'"
|
||||
],
|
||||
"act-input": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"act -j tests --matrix 'laravel:^11.0' --input"
|
||||
]
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ services:
|
|||
test:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
PHP_VERSION: ${PHP_VERSION:-8.4}
|
||||
XDEBUG_ENABLED: ${XDEBUG_ENABLED:-false}
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
|
|
@ -18,7 +21,8 @@ services:
|
|||
dynamodb:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- .:/var/www/html:cached
|
||||
- .:${PROJECT_PATH:-$PWD}:cached
|
||||
working_dir: ${PROJECT_PATH:-$PWD}
|
||||
environment:
|
||||
DOCKER: 1
|
||||
DB_PASSWORD: password
|
||||
|
|
@ -30,6 +34,8 @@ services:
|
|||
TENANCY_TEST_SQLSRV_HOST: mssql
|
||||
TENANCY_TEST_SQLSRV_USERNAME: sa
|
||||
TENANCY_TEST_SQLSRV_PASSWORD: P@ssword
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
stdin_open: true
|
||||
tty: true
|
||||
mysql:
|
||||
|
|
@ -75,7 +81,7 @@ services:
|
|||
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||
environment:
|
||||
- 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
|
||||
test: timeout 2 bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/1433'
|
||||
interval: 10s
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ class CloneRoutesAsTenant
|
|||
{
|
||||
protected array $routesToClone = [];
|
||||
protected bool $addTenantParameter = true;
|
||||
protected bool $tenantParameterBeforePrefix = true;
|
||||
protected string|null $domain = null;
|
||||
protected Closure|null $cloneUsing = null; // The callback should accept Route instance or the route name (string)
|
||||
protected Closure|null $shouldClone = null;
|
||||
|
|
@ -177,6 +178,13 @@ class CloneRoutesAsTenant
|
|||
return $this;
|
||||
}
|
||||
|
||||
public function tenantParameterBeforePrefix(bool $tenantParameterBeforePrefix): static
|
||||
{
|
||||
$this->tenantParameterBeforePrefix = $tenantParameterBeforePrefix;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** Clone an individual route. */
|
||||
public function cloneRoute(Route|string $route): static
|
||||
{
|
||||
|
|
@ -226,7 +234,13 @@ class CloneRoutesAsTenant
|
|||
$action->put('middleware', $middleware);
|
||||
|
||||
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 */
|
||||
|
|
|
|||
|
|
@ -99,12 +99,22 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper
|
|||
{
|
||||
$names = $this->config->get('tenancy.cache.stores');
|
||||
|
||||
if (
|
||||
$this->config->get('tenancy.cache.scope_sessions', true) &&
|
||||
in_array($this->config->get('session.driver'), ['redis', 'memcached', 'dynamodb', 'apc'], true)
|
||||
) {
|
||||
if ($this->config->get('tenancy.cache.scope_sessions', true)) {
|
||||
// These are the only cache driven session backends (see Laravel's config/session.php)
|
||||
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 = array_unique($names);
|
||||
|
||||
|
|
@ -112,6 +122,7 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper
|
|||
$store = $this->config->get("cache.stores.{$name}");
|
||||
|
||||
if ($store === null || $store['driver'] === 'file') {
|
||||
// 'file' stores are ignored here and instead handled by FilesystemTenancyBootstrapper
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
123
src/Bootstrappers/DatabaseCacheBootstrapper.php
Normal file
123
src/Bootstrappers/DatabaseCacheBootstrapper.php
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Bootstrappers;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Cache\CacheManager;
|
||||
use Illuminate\Cache\DatabaseStore;
|
||||
use Illuminate\Config\Repository;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
|
||||
use Stancl\Tenancy\Contracts\Tenant;
|
||||
use Stancl\Tenancy\TenancyServiceProvider;
|
||||
|
||||
/**
|
||||
* This bootstrapper allows cache to be stored in tenant databases by switching the database
|
||||
* connection used by cache stores that use the database driver.
|
||||
*
|
||||
* Can be used instead of CacheTenancyBootstrapper.
|
||||
*
|
||||
* By default, this bootstrapper scopes ALL cache stores that use the database driver. If you only
|
||||
* want to scope SOME stores, set the static $stores property to an array of names of the stores
|
||||
* you want to scope. These stores must use 'database' as their driver.
|
||||
*
|
||||
* Notably, this bootstrapper sets TenancyServiceProvider::$adjustCacheManagerUsing to a callback
|
||||
* that ensures all affected stores still use the central connection when accessed via global cache
|
||||
* (typicaly the GlobalCache facade or global_cache() helper).
|
||||
*/
|
||||
class DatabaseCacheBootstrapper implements TenancyBootstrapper
|
||||
{
|
||||
/**
|
||||
* Cache stores to scope.
|
||||
*
|
||||
* If null, all cache stores that use the database driver will be scoped.
|
||||
* If an array, only the specified stores will be scoped. These all must use the database driver.
|
||||
*/
|
||||
public static array|null $stores = null;
|
||||
|
||||
/**
|
||||
* Should scoped stores be adjusted on the global cache manager to use the central connection.
|
||||
*
|
||||
* You may want to set this to false if you don't use the built-in global cache and instead provide
|
||||
* a list of stores to scope (static::$stores), with your own global store excluded that you then
|
||||
* use manually. But in such a scenario you likely wouldn't be using global cache at all which means
|
||||
* the callbacks for adjusting it wouldn't be executed in the first place.
|
||||
*/
|
||||
public static bool $adjustGlobalCacheManager = true;
|
||||
|
||||
public function __construct(
|
||||
protected Repository $config,
|
||||
protected CacheManager $cache,
|
||||
protected array $originalConnections = [],
|
||||
protected array $originalLockConnections = []
|
||||
) {}
|
||||
|
||||
public function bootstrap(Tenant $tenant): void
|
||||
{
|
||||
if (! config('database.connections.tenant')) {
|
||||
throw new Exception('DatabaseCacheBootstrapper must run after DatabaseTenancyBootstrapper.');
|
||||
}
|
||||
|
||||
$stores = $this->scopedStoreNames();
|
||||
|
||||
foreach ($stores as $storeName) {
|
||||
$this->originalConnections[$storeName] = $this->config->get("cache.stores.{$storeName}.connection");
|
||||
$this->originalLockConnections[$storeName] = $this->config->get("cache.stores.{$storeName}.lock_connection");
|
||||
|
||||
$this->config->set("cache.stores.{$storeName}.connection", 'tenant');
|
||||
$this->config->set("cache.stores.{$storeName}.lock_connection", 'tenant');
|
||||
|
||||
$this->cache->purge($storeName);
|
||||
}
|
||||
|
||||
if (static::$adjustGlobalCacheManager) {
|
||||
// Preferably we'd try to respect the original value of this static property -- store it in a variable,
|
||||
// pull it into the closure, and execute it there. But such a naive approach would lead to existing callbacks
|
||||
// *from here* being executed repeatedly in a loop on reinitialization. For that reason we do not do that
|
||||
// (this is our only use of $adjustCacheManagerUsing anyway) but ideally at some point we'd have a better solution.
|
||||
$originalConnections = array_combine($stores, array_map(fn (string $storeName) => [
|
||||
'connection' => $this->originalConnections[$storeName] ?? config('tenancy.database.central_connection'),
|
||||
'lockConnection' => $this->originalLockConnections[$storeName] ?? config('tenancy.database.central_connection'),
|
||||
], $stores));
|
||||
|
||||
TenancyServiceProvider::$adjustCacheManagerUsing = static function (CacheManager $manager) use ($originalConnections) {
|
||||
foreach ($originalConnections as $storeName => $connections) {
|
||||
/** @var DatabaseStore $store */
|
||||
$store = $manager->store($storeName)->getStore();
|
||||
|
||||
$store->setConnection(DB::connection($connections['connection']));
|
||||
$store->setLockConnection(DB::connection($connections['lockConnection']));
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public function revert(): void
|
||||
{
|
||||
foreach ($this->originalConnections as $storeName => $originalConnection) {
|
||||
$this->config->set("cache.stores.{$storeName}.connection", $originalConnection);
|
||||
$this->config->set("cache.stores.{$storeName}.lock_connection", $this->originalLockConnections[$storeName]);
|
||||
|
||||
$this->cache->purge($storeName);
|
||||
}
|
||||
|
||||
TenancyServiceProvider::$adjustCacheManagerUsing = null;
|
||||
}
|
||||
|
||||
protected function scopedStoreNames(): array
|
||||
{
|
||||
return array_filter(
|
||||
static::$stores ?? array_keys($this->config->get('cache.stores', [])),
|
||||
function ($storeName) {
|
||||
$store = $this->config->get("cache.stores.{$storeName}");
|
||||
|
||||
if (! $store) return false;
|
||||
if (! isset($store['driver'])) return false;
|
||||
|
||||
return $store['driver'] === 'database';
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
|
||||
// Better debugging, but breaks cached lookup 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
|
||||
// Better debugging, but breaks cached lookup, so we disable this in prod
|
||||
if (app()->environment('local') || app()->environment('testing')) {
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
54
src/Bootstrappers/TenantConfigBootstrapper.php
Normal file
54
src/Bootstrappers/TenantConfigBootstrapper.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ use Illuminate\Console\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.';
|
||||
|
||||
|
|
|
|||
|
|
@ -81,12 +81,19 @@ class CreateUserWithRLSPolicies extends Command
|
|||
#[\SensitiveParameter]
|
||||
string $password,
|
||||
): 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 */
|
||||
$tenantModel = tenancy()->model();
|
||||
|
||||
// Use a temporary DatabaseConfig instance to set the host connection
|
||||
$temporaryDbConfig = $tenantModel->database();
|
||||
|
||||
$temporaryDbConfig->purgeHostConnection();
|
||||
|
||||
$tenantHostConnectionName = $temporaryDbConfig->getTenantHostConnectionName();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ namespace Stancl\Tenancy\Commands;
|
|||
|
||||
use Closure;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
class Install extends Command
|
||||
{
|
||||
|
|
@ -128,6 +129,18 @@ class Install extends Command
|
|||
public function askForSupport(): void
|
||||
{
|
||||
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') {
|
||||
exec('open https://github.com/archtechx/tenancy');
|
||||
}
|
||||
|
|
@ -139,4 +152,5 @@ class Install extends Command
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,13 +51,24 @@ class Migrate extends MigrateCommand
|
|||
return 1;
|
||||
}
|
||||
|
||||
if ($this->getProcesses() > 1) {
|
||||
return $this->runConcurrently($this->getTenantChunks()->map(function ($chunk) {
|
||||
return $this->getTenants($chunk);
|
||||
}));
|
||||
$originalTemplateConnection = config('tenancy.database.template_tenant_connection');
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Stancl\Tenancy\Commands;
|
||||
|
||||
use Illuminate\Console\ConfirmableTrait;
|
||||
use Illuminate\Database\Console\Migrations\BaseCommand;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\LazyCollection;
|
||||
|
|
@ -17,7 +18,7 @@ use Symfony\Component\Console\Output\OutputInterface as OI;
|
|||
|
||||
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)';
|
||||
|
||||
|
|
@ -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('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->setName('tenants:migrate-fresh');
|
||||
|
|
@ -34,6 +36,10 @@ class MigrateFresh extends BaseCommand
|
|||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! $this->confirmToProceed()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$success = true;
|
||||
|
||||
if ($this->getProcesses() > 1) {
|
||||
|
|
|
|||
|
|
@ -14,10 +14,9 @@ use Illuminate\Support\Arr;
|
|||
use Illuminate\Support\Facades\Route as RouteFacade;
|
||||
use Stancl\Tenancy\Enums\RouteMode;
|
||||
|
||||
// todo@refactor move this logic to some dedicated static class?
|
||||
|
||||
/**
|
||||
* @mixin \Stancl\Tenancy\Tenancy
|
||||
* @internal The public methods in this trait should not be understood to be a public stable API.
|
||||
*/
|
||||
trait DealsWithRouteContexts
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,10 +4,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace Stancl\Tenancy\Contracts;
|
||||
|
||||
use Stancl\Tenancy\Tenancy;
|
||||
|
||||
/** Additional features, like Telescope tags and tenant redirects. */
|
||||
interface Feature
|
||||
{
|
||||
public function bootstrap(Tenancy $tenancy): void;
|
||||
public function bootstrap(): void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ trait HasDatabase
|
|||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,14 +6,13 @@ namespace Stancl\Tenancy\Database\Concerns;
|
|||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Stancl\Tenancy\Contracts\Tenant;
|
||||
use Stancl\Tenancy\Events\CreatingPendingTenant;
|
||||
use Stancl\Tenancy\Events\PendingTenantCreated;
|
||||
use Stancl\Tenancy\Events\PendingTenantPulled;
|
||||
use Stancl\Tenancy\Events\PullingPendingTenant;
|
||||
|
||||
// todo consider adding a method that sets pending_since to null — to flag tenants as not-pending
|
||||
|
||||
/**
|
||||
* @property ?Carbon $pending_since
|
||||
*
|
||||
|
|
@ -50,46 +49,62 @@ trait HasPending
|
|||
*/
|
||||
public static function createPending(array $attributes = []): Model&Tenant
|
||||
{
|
||||
try {
|
||||
$tenant = static::create($attributes);
|
||||
|
||||
event(new CreatingPendingTenant($tenant));
|
||||
|
||||
} finally {
|
||||
// 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([
|
||||
'pending_since' => now()->timestamp,
|
||||
]);
|
||||
}
|
||||
|
||||
event(new PendingTenantCreated($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 */
|
||||
$pendingTenant = static::pullPendingFromPool(true);
|
||||
$pendingTenant = static::pullPendingFromPool(true, $attributes);
|
||||
|
||||
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 */
|
||||
$tenant = static::onlyPending()->first();
|
||||
|
||||
if ($tenant !== null) {
|
||||
event(new PullingPendingTenant($tenant));
|
||||
$tenant->update(array_merge($attributes, [
|
||||
'pending_since' => null,
|
||||
]));
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
});
|
||||
|
||||
if ($tenant === null) {
|
||||
return $firstOrCreate ? static::create($attributes) : null;
|
||||
}
|
||||
|
||||
event(new PullingPendingTenant($tenant));
|
||||
|
||||
$tenant->update(array_merge($attributes, [
|
||||
'pending_since' => null,
|
||||
]));
|
||||
|
||||
// Only triggered if a tenant that was pulled from the pool is returned
|
||||
event(new PendingTenantPulled($tenant));
|
||||
|
||||
return $tenant;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ use Stancl\Tenancy\Database\Contracts\TenantWithDatabase as Tenant;
|
|||
use Stancl\Tenancy\Database\Exceptions\DatabaseManagerNotRegisteredException;
|
||||
use Stancl\Tenancy\Database\Exceptions\NoConnectionSetException;
|
||||
|
||||
// todo@dbRefactor refactor host connection logic to make customizing the host connection easier
|
||||
class DatabaseConfig
|
||||
{
|
||||
/** The tenant whose database we're dealing with. */
|
||||
|
|
@ -115,7 +114,7 @@ class DatabaseConfig
|
|||
{
|
||||
$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_password', $this->getPassword() ?? (static::$passwordGenerator)($this->tenant, $this));
|
||||
}
|
||||
|
|
@ -137,7 +136,9 @@ class DatabaseConfig
|
|||
}
|
||||
|
||||
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();
|
||||
|
|
@ -176,10 +177,10 @@ class DatabaseConfig
|
|||
$config = $this->tenantConfig;
|
||||
$templateConnection = $this->getTemplateConnection();
|
||||
|
||||
if ($this->connectionDriverManager($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) {
|
||||
// We're removing the username and password because user with these credentials is not created yet
|
||||
// If you need to provide username and password when using PermissionControlledMySQLDatabaseManager,
|
||||
// consider creating a new connection and use it as `tenancy_db_connection` tenant config key
|
||||
if ($this->managerForDriver($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) {
|
||||
// We remove the username and password because the user with these credentials is not yet created.
|
||||
// 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`.
|
||||
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
|
||||
{
|
||||
|
|
@ -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
|
||||
*/
|
||||
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();
|
||||
|
||||
// Create the tenant host connection config
|
||||
$tenantHostConnectionName = $this->getTenantHostConnectionName();
|
||||
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) {
|
||||
$manager->setConnection($tenantHostConnectionName);
|
||||
|
|
@ -222,12 +223,11 @@ class DatabaseConfig
|
|||
}
|
||||
|
||||
/**
|
||||
* todo@name come up with a better name
|
||||
* Get database manager class from the given connection config's driver.
|
||||
* Get the TenantDatabaseManager for a given database driver.
|
||||
*
|
||||
* @throws DatabaseManagerNotRegisteredException
|
||||
*/
|
||||
protected function connectionDriverManager(string $driver): Contracts\TenantDatabaseManager
|
||||
protected function managerForDriver(string $driver): Contracts\TenantDatabaseManager
|
||||
{
|
||||
$databaseManagers = config('tenancy.database.managers');
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl
|
|||
{
|
||||
$database = $databaseConfig->getName();
|
||||
$username = $databaseConfig->getUsername();
|
||||
$hostname = $databaseConfig->connection()['host'];
|
||||
$password = $databaseConfig->getPassword();
|
||||
|
||||
$this->connection()->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}'");
|
||||
|
|
|
|||
|
|
@ -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'");
|
||||
|
||||
// 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) {
|
||||
$tableName = $table->table_name;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||
|
||||
namespace Stancl\Tenancy\Database\TenantDatabaseManagers;
|
||||
|
||||
use AssertionError;
|
||||
use Closure;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use PDO;
|
||||
|
|
@ -15,17 +14,12 @@ use Throwable;
|
|||
class SQLiteDatabaseManager implements TenantDatabaseManager
|
||||
{
|
||||
/**
|
||||
* SQLite Database path without ending slash.
|
||||
* SQLite database directory path.
|
||||
*
|
||||
* Defaults to database_path().
|
||||
*/
|
||||
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
|
||||
* 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().
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
try {
|
||||
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;
|
||||
}
|
||||
return file_put_contents($this->getPath($name), '') !== false;
|
||||
}
|
||||
|
||||
public function deleteDatabase(TenantWithDatabase $tenant): bool
|
||||
|
|
@ -122,8 +99,16 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
|
|||
return true;
|
||||
}
|
||||
|
||||
$path = $this->getPath($name);
|
||||
|
||||
try {
|
||||
return unlink($this->getPath($name));
|
||||
unlink($path . '-journal');
|
||||
unlink($path . '-wal');
|
||||
unlink($path . '-shm');
|
||||
} catch (Throwable) {}
|
||||
|
||||
try {
|
||||
return unlink($path);
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -150,15 +135,10 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
|
|||
return $baseConfig;
|
||||
}
|
||||
|
||||
public function setConnection(string $connection): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function getPath(string $name): string
|
||||
{
|
||||
if (static::$path) {
|
||||
return static::$path . DIRECTORY_SEPARATOR . $name;
|
||||
return rtrim(static::$path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $name;
|
||||
}
|
||||
|
||||
return database_path($name);
|
||||
|
|
|
|||
|
|
@ -4,4 +4,9 @@ declare(strict_types=1);
|
|||
|
||||
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 {}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ use Illuminate\Support\Facades\Cache;
|
|||
|
||||
class GlobalCache extends Cache
|
||||
{
|
||||
/** Make sure this works identically to global_cache() */
|
||||
protected static $cached = false;
|
||||
|
||||
protected static function getFacadeAccessor()
|
||||
{
|
||||
return 'globalCache';
|
||||
|
|
|
|||
|
|
@ -6,11 +6,10 @@ namespace Stancl\Tenancy\Features;
|
|||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Stancl\Tenancy\Contracts\Feature;
|
||||
use Stancl\Tenancy\Tenancy;
|
||||
|
||||
class CrossDomainRedirect implements Feature
|
||||
{
|
||||
public function bootstrap(Tenancy $tenancy): void
|
||||
public function bootstrap(): void
|
||||
{
|
||||
RedirectResponse::macro('domain', function (string $domain) {
|
||||
/** @var RedirectResponse $this */
|
||||
|
|
|
|||
|
|
@ -4,25 +4,22 @@ declare(strict_types=1);
|
|||
|
||||
namespace Stancl\Tenancy\Features;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Database\Connectors\ConnectionFactory;
|
||||
use Illuminate\Database\SQLiteConnection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PDO;
|
||||
use Stancl\Tenancy\Contracts\Feature;
|
||||
use Stancl\Tenancy\Tenancy;
|
||||
|
||||
class DisallowSqliteAttach implements Feature
|
||||
{
|
||||
protected static bool|null $loadExtensionSupported = null;
|
||||
public static string|false|null $extensionPath = null;
|
||||
|
||||
public function bootstrap(Tenancy $tenancy): void
|
||||
public function bootstrap(): void
|
||||
{
|
||||
// Handle any already resolved connections
|
||||
foreach (DB::getConnections() as $connection) {
|
||||
if ($connection instanceof SQLiteConnection) {
|
||||
if (! $this->loadExtension($connection->getPdo())) {
|
||||
if (! $this->setAuthorizer($connection->getPdo())) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -31,42 +28,54 @@ class DisallowSqliteAttach implements Feature
|
|||
// Apply the change to all sqlite connections resolved in the future
|
||||
DB::extend('sqlite', function ($config, $name) {
|
||||
$conn = app(ConnectionFactory::class)->make($config, $name);
|
||||
$this->loadExtension($conn->getPdo());
|
||||
$this->setAuthorizer($conn->getPdo());
|
||||
|
||||
return $conn;
|
||||
});
|
||||
}
|
||||
|
||||
protected function loadExtension(PDO $pdo): bool
|
||||
protected function setAuthorizer(PDO $pdo): bool
|
||||
{
|
||||
if (static::$loadExtensionSupported === null) {
|
||||
static::$loadExtensionSupported = method_exists($pdo, 'loadExtension');
|
||||
if (PHP_VERSION_ID >= 80500) {
|
||||
$this->setNativeAuthorizer($pdo);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (static::$loadExtensionSupported === false) {
|
||||
return false;
|
||||
}
|
||||
if (static::$extensionPath === false) {
|
||||
return false;
|
||||
}
|
||||
static $loadExtensionSupported = method_exists($pdo, 'loadExtension');
|
||||
|
||||
if ((! $loadExtensionSupported) ||
|
||||
(static::$extensionPath === false) ||
|
||||
(PHP_INT_SIZE !== 8)
|
||||
) return false;
|
||||
|
||||
$suffix = match (PHP_OS_FAMILY) {
|
||||
'Linux' => 'so',
|
||||
'Windows' => 'dll',
|
||||
'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');
|
||||
$arm = $arch === 'aarch64' || $arch === 'arm64';
|
||||
|
||||
static::$extensionPath ??= realpath(base_path('vendor/stancl/tenancy/extensions/lib/' . ($arm ? 'arm/' : '') . 'noattach.' . $suffix));
|
||||
if (static::$extensionPath === false) {
|
||||
return false;
|
||||
}
|
||||
if (static::$extensionPath === false) return false;
|
||||
|
||||
$pdo->loadExtension(static::$extensionPath); // @phpstan-ignore method.notFound
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,10 @@ namespace Stancl\Tenancy\Features;
|
|||
use Laravel\Telescope\IncomingEntry;
|
||||
use Laravel\Telescope\Telescope;
|
||||
use Stancl\Tenancy\Contracts\Feature;
|
||||
use Stancl\Tenancy\Tenancy;
|
||||
|
||||
class TelescopeTags implements Feature
|
||||
{
|
||||
public function bootstrap(Tenancy $tenancy): void
|
||||
public function bootstrap(): void
|
||||
{
|
||||
if (! class_exists(Telescope::class)) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -12,8 +12,10 @@ use Stancl\Tenancy\Contracts\Feature;
|
|||
use Stancl\Tenancy\Contracts\Tenant;
|
||||
use Stancl\Tenancy\Events\RevertedToCentralContext;
|
||||
use Stancl\Tenancy\Events\TenancyBootstrapped;
|
||||
use Stancl\Tenancy\Tenancy;
|
||||
|
||||
// todo@release remove this class
|
||||
|
||||
/** @deprecated Use the TenantConfigBootstrapper instead. */
|
||||
class TenantConfig implements Feature
|
||||
{
|
||||
public array $originalConfig = [];
|
||||
|
|
@ -27,7 +29,7 @@ class TenantConfig implements Feature
|
|||
protected Repository $config,
|
||||
) {}
|
||||
|
||||
public function bootstrap(Tenancy $tenancy): void
|
||||
public function bootstrap(): void
|
||||
{
|
||||
Event::listen(TenancyBootstrapped::class, function (TenancyBootstrapped $event) {
|
||||
/** @var Tenant $tenant */
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ class UserImpersonation implements Feature
|
|||
/** The lifespan of impersonation tokens (in seconds). */
|
||||
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([
|
||||
Tenancy::tenantKeyColumn() => $tenant->getTenantKey(),
|
||||
'user_id' => $userId,
|
||||
|
|
|
|||
|
|
@ -5,22 +5,19 @@ declare(strict_types=1);
|
|||
namespace Stancl\Tenancy\Features;
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Support\Facades\Vite;
|
||||
use Stancl\Tenancy\Contracts\Feature;
|
||||
use Stancl\Tenancy\Overrides\Vite;
|
||||
use Stancl\Tenancy\Tenancy;
|
||||
|
||||
class ViteBundler implements Feature
|
||||
{
|
||||
/** @var Application */
|
||||
protected $app;
|
||||
public function __construct(
|
||||
protected Application $app,
|
||||
) {}
|
||||
|
||||
public function __construct(Application $app)
|
||||
public function bootstrap(): void
|
||||
{
|
||||
$this->app = $app;
|
||||
}
|
||||
|
||||
public function bootstrap(Tenancy $tenancy): void
|
||||
{
|
||||
$this->app->singleton(\Illuminate\Foundation\Vite::class, Vite::class);
|
||||
Vite::createAssetPathsUsing(function ($path, $secure = null) {
|
||||
return global_asset($path);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@ class CreateDatabase implements ShouldQueue
|
|||
|
||||
try {
|
||||
$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));
|
||||
} catch (TenantDatabaseAlreadyExistsException | TenantDatabaseUserAlreadyExistsException $e) {
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ use Illuminate\Routing\Events\RouteMatched;
|
|||
use Stancl\Tenancy\Enums\RouteMode;
|
||||
use Stancl\Tenancy\Resolvers\PathTenantResolver;
|
||||
|
||||
// todo@earlyIdReview
|
||||
|
||||
/**
|
||||
* Conditionally removes the tenant parameter from matched routes when using kernel path identification.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ namespace Stancl\Tenancy\Middleware;
|
|||
|
||||
use Closure;
|
||||
use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode;
|
||||
use Stancl\Tenancy\Exceptions\TenancyNotInitializedException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode
|
||||
|
|
@ -14,7 +13,13 @@ class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode
|
|||
public function handle($request, Closure $next)
|
||||
{
|
||||
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')) {
|
||||
|
|
|
|||
|
|
@ -11,8 +11,6 @@ use Stancl\Tenancy\Concerns\UsableWithEarlyIdentification;
|
|||
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.
|
||||
* The access isn't prevented if the request is trying to access a route flagged as 'universal',
|
||||
* or if this middleware should be skipped.
|
||||
|
|
@ -68,9 +66,11 @@ class PreventAccessFromUnwantedDomains
|
|||
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
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ class TenancyUrlGenerator extends UrlGenerator
|
|||
*/
|
||||
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.');
|
||||
}
|
||||
|
||||
|
|
@ -125,7 +125,7 @@ class TenancyUrlGenerator extends UrlGenerator
|
|||
*/
|
||||
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.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,9 @@ abstract class CachedTenantResolver implements TenantResolver
|
|||
|
||||
public function __construct(Application $app)
|
||||
{
|
||||
// globalCache should generally not be injected, however in this case
|
||||
// the class is always created from scratch when calling invalidateCache()
|
||||
// meaning the global cache stores are also resolved from scratch.
|
||||
$this->cache = $app->make('globalCache')->store(static::cacheStore());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use Illuminate\Foundation\Bus\PendingDispatch;
|
|||
use Illuminate\Support\Traits\Macroable;
|
||||
use Stancl\Tenancy\Concerns\DealsWithRouteContexts;
|
||||
use Stancl\Tenancy\Concerns\ManagesRLSPolicies;
|
||||
use Stancl\Tenancy\Contracts\Feature;
|
||||
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
|
||||
use Stancl\Tenancy\Contracts\Tenant;
|
||||
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByIdException;
|
||||
|
|
@ -24,11 +25,15 @@ class Tenancy
|
|||
*/
|
||||
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;
|
||||
|
||||
/** 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().
|
||||
|
|
@ -36,7 +41,7 @@ class Tenancy
|
|||
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
|
||||
* occurs during bootstrapping, to ensure we don't revert
|
||||
|
|
@ -49,6 +54,23 @@ class Tenancy
|
|||
*/
|
||||
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. */
|
||||
public function initialize(Tenant|int|string $tenant): void
|
||||
{
|
||||
|
|
@ -117,10 +139,12 @@ class Tenancy
|
|||
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));
|
||||
|
||||
// todo@samuel find a way to refactor these two methods
|
||||
|
||||
event(new Events\TenancyEnded($this));
|
||||
|
||||
$this->tenant = null;
|
||||
|
|
@ -131,12 +155,12 @@ class Tenancy
|
|||
/** @return TenancyBootstrapper[] */
|
||||
public function getBootstrappers(): array
|
||||
{
|
||||
// If no callback for getting bootstrappers is set, we just return all of them.
|
||||
$resolve = $this->getBootstrappersUsing ?? function (Tenant $tenant) {
|
||||
// If no callback for getting bootstrappers is set, we return the ones in config.
|
||||
$resolve = $this->getBootstrappersUsing ?? function (?Tenant $tenant) {
|
||||
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));
|
||||
}
|
||||
|
||||
|
|
@ -150,6 +174,26 @@ class Tenancy
|
|||
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>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ class TenancyServiceProvider extends ServiceProvider
|
|||
public static bool $registerForgetTenantParameterListener = true;
|
||||
public static bool $migrateFreshOverride = true;
|
||||
|
||||
/** @internal */
|
||||
public static Closure|null $adjustCacheManagerUsing = null;
|
||||
|
||||
/* Register services. */
|
||||
public function register(): void
|
||||
{
|
||||
|
|
@ -37,15 +40,6 @@ class TenancyServiceProvider extends ServiceProvider
|
|||
// Make sure Tenancy is stateful.
|
||||
$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.
|
||||
$this->app->bind(Tenant::class, function ($app) {
|
||||
return $app[Tenancy::class]->tenant;
|
||||
|
|
@ -81,7 +75,29 @@ class TenancyServiceProvider extends ServiceProvider
|
|||
});
|
||||
|
||||
$this->app->bind('globalCache', function ($app) {
|
||||
return new CacheManager($app);
|
||||
// We create a separate CacheManager to be used for "global" cache -- cache that
|
||||
// is always central, regardless of the current context.
|
||||
//
|
||||
// Importantly, we use a regular binding here, not a singleton. Thanks to that,
|
||||
// any time we resolve this cache manager, we get a *fresh* instance -- an instance
|
||||
// that was not affected by any scoping logic.
|
||||
//
|
||||
// This works great for cache stores that are *directly* scoped, like Redis or
|
||||
// any other tagged or prefixed stores, but it doesn't work for the database driver.
|
||||
//
|
||||
// When we use the DatabaseTenancyBootstrapper, it changes the default connection,
|
||||
// and therefore the connection of the database store that will be created when
|
||||
// this new CacheManager is instantiated again.
|
||||
//
|
||||
// For that reason, we also adjust the relevant stores on this new CacheManager
|
||||
// using the callback below. It is set by DatabaseCacheBootstrapper.
|
||||
$manager = new CacheManager($app);
|
||||
|
||||
if (static::$adjustCacheManagerUsing !== null) {
|
||||
(static::$adjustCacheManagerUsing)($manager);
|
||||
}
|
||||
|
||||
return $manager;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -152,6 +168,11 @@ class TenancyServiceProvider extends ServiceProvider
|
|||
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('universal', []);
|
||||
Route::middlewareGroup('tenant', []);
|
||||
|
|
|
|||
|
|
@ -36,7 +36,12 @@ if (! function_exists('tenant')) {
|
|||
}
|
||||
|
||||
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
|
||||
{
|
||||
if ($assetUrl = config('app.asset_url')) {
|
||||
|
|
|
|||
103
static_properties.nu
Executable file
103
static_properties.nu
Executable 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
2
t
|
|
@ -1,4 +1,4 @@
|
|||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ "${CLAUDECODE}" != "1" ]]; then
|
||||
COLOR_FLAG="--colors=always"
|
||||
|
|
|
|||
2
test
2
test
|
|
@ -1,4 +1,4 @@
|
|||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ "${CLAUDECODE}" != "1" ]]; then
|
||||
COLOR_FLAG="--colors=always"
|
||||
|
|
|
|||
|
|
@ -18,8 +18,6 @@ beforeEach(function () {
|
|||
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() {
|
||||
config([
|
||||
'tenancy.bootstrappers' => [
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ test('tags separate cache properly', function () {
|
|||
$tenant1 = Tenant::create();
|
||||
tenancy()->initialize($tenant1);
|
||||
|
||||
cache()->put('foo', 'bar', 1);
|
||||
cache()->put('foo', 'bar');
|
||||
expect(cache()->get('foo'))->toBe('bar');
|
||||
|
||||
$tenant2 = Tenant::create();
|
||||
|
|
@ -64,7 +64,7 @@ test('tags separate cache properly', function () {
|
|||
|
||||
expect(cache('foo'))->not()->toBe('bar');
|
||||
|
||||
cache()->put('foo', 'xyz', 1);
|
||||
cache()->put('foo', 'xyz');
|
||||
expect(cache()->get('foo'))->toBe('xyz');
|
||||
});
|
||||
|
||||
|
|
@ -72,7 +72,7 @@ test('invoking the cache helper works', function () {
|
|||
$tenant1 = Tenant::create();
|
||||
tenancy()->initialize($tenant1);
|
||||
|
||||
cache(['foo' => 'bar'], 1);
|
||||
cache(['foo' => 'bar']);
|
||||
expect(cache('foo'))->toBe('bar');
|
||||
|
||||
$tenant2 = Tenant::create();
|
||||
|
|
@ -80,7 +80,7 @@ test('invoking the cache helper works', function () {
|
|||
|
||||
expect(cache('foo'))->not()->toBe('bar');
|
||||
|
||||
cache(['foo' => 'xyz'], 1);
|
||||
cache(['foo' => 'xyz']);
|
||||
expect(cache('foo'))->toBe('xyz');
|
||||
});
|
||||
|
||||
|
|
@ -88,7 +88,7 @@ test('cache is persisted', function () {
|
|||
$tenant1 = Tenant::create();
|
||||
tenancy()->initialize($tenant1);
|
||||
|
||||
cache(['foo' => 'bar'], 10);
|
||||
cache(['foo' => 'bar']);
|
||||
expect(cache('foo'))->toBe('bar');
|
||||
|
||||
tenancy()->end();
|
||||
|
|
@ -102,7 +102,7 @@ test('cache is persisted when reidentification is used', function () {
|
|||
$tenant2 = Tenant::create();
|
||||
tenancy()->initialize($tenant1);
|
||||
|
||||
cache(['foo' => 'bar'], 10);
|
||||
cache(['foo' => 'bar']);
|
||||
expect(cache('foo'))->toBe('bar');
|
||||
|
||||
tenancy()->initialize($tenant2);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
$originalStoragePath = storage_path();
|
||||
|
||||
// todo@tests https://github.com/tenancy-for-laravel/v4/pull/44#issue-2228530362
|
||||
|
||||
config([
|
||||
'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class],
|
||||
'tenancy.filesystem.suffix_storage_path' => false,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ use Illuminate\Support\Facades\Route as RouteFacade;
|
|||
use Illuminate\Support\Facades\Schema;
|
||||
use Stancl\Tenancy\Bootstrappers\CacheTagsBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException;
|
||||
use Stancl\Tenancy\Events\TenancyEnded;
|
||||
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||
|
|
@ -23,6 +25,8 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
|||
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
|
||||
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
use function Stancl\Tenancy\Tests\withCacheTables;
|
||||
use function Stancl\Tenancy\Tests\withTenantDatabases;
|
||||
|
||||
beforeEach($cleanup = function () {
|
||||
Tenant::$extraCustomColumns = [];
|
||||
|
|
@ -112,11 +116,19 @@ test('cache is invalidated when the tenant is updated', function (string $resolv
|
|||
// Only testing update here - presumably if this works, deletes (and other things we test here)
|
||||
// will work as well. The main unique thing about this test is that it makes the change from
|
||||
// *within* the tenant context.
|
||||
test('cache is invalidated when tenant is updated from within the tenant context', function (string $cacheBootstrapper) {
|
||||
config(['tenancy.bootstrappers' => [$cacheBootstrapper]]);
|
||||
test('cache is invalidated when tenant is updated from within the tenant context', function (string $cacheStore, array $bootstrappers) {
|
||||
config([
|
||||
'cache.default' => $cacheStore,
|
||||
'tenancy.bootstrappers' => $bootstrappers,
|
||||
]);
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||
|
||||
if ($cacheStore === 'database') {
|
||||
withCacheTables();
|
||||
withTenantDatabases();
|
||||
}
|
||||
|
||||
$resolver = PathTenantResolver::class;
|
||||
|
||||
$tenant = Tenant::create([$tenantModelColumn = tenantModelColumn(true) => 'acme']);
|
||||
|
|
@ -150,9 +162,9 @@ test('cache is invalidated when tenant is updated from within the tenant context
|
|||
|
||||
expect(DB::getQueryLog())->not()->toBeEmpty(); // Cache was invalidated, so the tenant was retrieved from the DB
|
||||
})->with([
|
||||
// todo@samuel test this with the database cache bootstrapper too?
|
||||
CacheTenancyBootstrapper::class,
|
||||
CacheTagsBootstrapper::class,
|
||||
['redis', [CacheTenancyBootstrapper::class]],
|
||||
['redis', [CacheTagsBootstrapper::class]],
|
||||
['database', [DatabaseTenancyBootstrapper::class, DatabaseCacheBootstrapper::class]],
|
||||
]);
|
||||
|
||||
test('cache is invalidated when the tenant is deleted', function (string $resolver, bool $configureTenantModelColumn) {
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ test('the clone action can clone specific routes either using name or route inst
|
|||
false,
|
||||
]);
|
||||
|
||||
test('the clone action prefixes already prefixed routes correctly', function () {
|
||||
test('the clone action prefixes already prefixed routes correctly', function (bool $tenantParameterBeforePrefix) {
|
||||
$routes = [
|
||||
RouteFacade::get('/home', fn () => true)
|
||||
->middleware(['clone'])
|
||||
|
|
@ -195,7 +195,12 @@ test('the clone action prefixes already prefixed routes correctly', function ()
|
|||
->prefix('prefix/'),
|
||||
];
|
||||
|
||||
app(CloneRoutesAsTenant::class)->handle();
|
||||
$cloneAction = app(CloneRoutesAsTenant::class);
|
||||
$cloneAction
|
||||
->tenantParameterBeforePrefix($tenantParameterBeforePrefix)
|
||||
->handle();
|
||||
|
||||
$expectedPrefix = $tenantParameterBeforePrefix ? '{tenant}/prefix' : 'prefix/{tenant}';
|
||||
|
||||
$clonedRoutes = [
|
||||
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
|
||||
foreach ($clonedRoutes as $key => $route) {
|
||||
expect($route->getPrefix())->toBe("prefix/{tenant}");
|
||||
expect($route->getPrefix())->toBe($expectedPrefix);
|
||||
|
||||
$clonedRouteUrl = route($route->getName(), ['tenant' => $tenant = Tenant::create()]);
|
||||
$expectedPrefixInUrl = $tenantParameterBeforePrefix ? "{$tenant->id}/prefix" : "prefix/{$tenant->id}";
|
||||
|
||||
expect($clonedRouteUrl)
|
||||
// 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//")
|
||||
// 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
|
||||
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('')->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
|
||||
|
|
@ -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()]);
|
||||
$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');
|
||||
$homeRoute = RouteFacade::getRoutes()->getByName('tenant.home');
|
||||
|
||||
expect($landingRoute->uri())->toBe('prefix/{tenant}');
|
||||
expect($homeRoute->uri())->toBe('prefix/{tenant}/home');
|
||||
$expectedPrefix = $tenantParameterBeforePrefix ? '{tenant}/prefix' : 'prefix/{tenant}';
|
||||
$expectedPrefixInUrl = $tenantParameterBeforePrefix ? "{$tenant->id}/prefix" : "prefix/{$tenant->id}";
|
||||
|
||||
expect($landingRoute->uri())->toBe($expectedPrefix);
|
||||
expect($homeRoute->uri())->toBe("{$expectedPrefix}/home");
|
||||
|
||||
expect($clonedLandingUrl)
|
||||
->not()->toContain("prefix//")
|
||||
->toBe("http://localhost/prefix/{$tenant->id}");
|
||||
->toBe("http://localhost/{$expectedPrefixInUrl}");
|
||||
|
||||
expect($clonedHomeRouteUrl)
|
||||
->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 () {
|
||||
// Should NOT be cloned, already has tenant parameter
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
|||
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
use Stancl\Tenancy\Events\MigratingDatabase;
|
||||
|
||||
beforeEach(function () {
|
||||
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();
|
||||
});
|
||||
|
||||
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() {
|
||||
Tenant::create();
|
||||
|
||||
|
|
@ -311,6 +366,21 @@ test('migrate fresh command works', function () {
|
|||
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 () {
|
||||
$tenantId1 = Tenant::create()->getTenantKey();
|
||||
$tenantId2 = Tenant::create()->getTenantKey();
|
||||
|
|
|
|||
193
tests/DatabaseCacheBootstrapperTest.php
Normal file
193
tests/DatabaseCacheBootstrapperTest.php
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
use function Stancl\Tenancy\Tests\withBootstrapping;
|
||||
use function Stancl\Tenancy\Tests\withCacheTables;
|
||||
use function Stancl\Tenancy\Tests\withTenantDatabases;
|
||||
|
||||
beforeEach(function () {
|
||||
withBootstrapping();
|
||||
withCacheTables();
|
||||
withTenantDatabases(true);
|
||||
|
||||
DatabaseCacheBootstrapper::$stores = null;
|
||||
|
||||
config([
|
||||
'cache.stores.database.connection' => 'central', // Explicitly set cache DB connection name in config
|
||||
'cache.stores.database.lock_connection' => 'central', // Also set lock connection name
|
||||
'cache.default' => 'database',
|
||||
'tenancy.bootstrappers' => [
|
||||
DatabaseTenancyBootstrapper::class,
|
||||
DatabaseCacheBootstrapper::class, // Used instead of CacheTenancyBootstrapper
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
DatabaseCacheBootstrapper::$stores = null;
|
||||
});
|
||||
|
||||
test('DatabaseCacheBootstrapper switches the database cache store connections correctly', function () {
|
||||
expect(config('cache.stores.database.connection'))->toBe('central');
|
||||
expect(config('cache.stores.database.lock_connection'))->toBe('central');
|
||||
expect(Cache::store()->getConnection()->getName())->toBe('central');
|
||||
expect(Cache::lock('foo')->getConnectionName())->toBe('central');
|
||||
|
||||
tenancy()->initialize(Tenant::create());
|
||||
|
||||
expect(config('cache.stores.database.connection'))->toBe('tenant');
|
||||
expect(config('cache.stores.database.lock_connection'))->toBe('tenant');
|
||||
expect(Cache::store()->getConnection()->getName())->toBe('tenant');
|
||||
expect(Cache::lock('foo')->getConnectionName())->toBe('tenant');
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
expect(config('cache.stores.database.connection'))->toBe('central');
|
||||
expect(config('cache.stores.database.lock_connection'))->toBe('central');
|
||||
expect(Cache::store()->getConnection()->getName())->toBe('central');
|
||||
expect(Cache::lock('foo')->getConnectionName())->toBe('central');
|
||||
});
|
||||
|
||||
test('cache is separated correctly when using DatabaseCacheBootstrapper', function() {
|
||||
// We need the prefix later for lower-level assertions. Let's store it
|
||||
// once now and reuse this variable rather than re-fetching it to make
|
||||
// it clear that the scoping does NOT come from a prefix change.
|
||||
|
||||
$cachePrefix = config('cache.prefix');
|
||||
$getCacheUsingDbQuery = fn (string $cacheKey) =>
|
||||
DB::selectOne("SELECT * FROM `cache` WHERE `key` = '{$cachePrefix}{$cacheKey}'")?->value;
|
||||
|
||||
$tenant = Tenant::create();
|
||||
$tenant2 = Tenant::create();
|
||||
|
||||
// Write to cache in central context
|
||||
cache()->set('foo', 'central');
|
||||
expect(Cache::get('foo'))->toBe('central');
|
||||
// The value retrieved by the DB query is formatted like "s:7:"central";".
|
||||
// We use toContain() because of this formatting instead of just toBe().
|
||||
expect($getCacheUsingDbQuery('foo'))->toContain('central');
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
// Central cache doesn't leak to tenant context
|
||||
expect(Cache::has('foo'))->toBeFalse();
|
||||
expect($getCacheUsingDbQuery('foo'))->toBeNull();
|
||||
|
||||
cache()->set('foo', 'bar');
|
||||
expect(Cache::get('foo'))->toBe('bar');
|
||||
expect($getCacheUsingDbQuery('foo'))->toContain('bar');
|
||||
|
||||
tenancy()->initialize($tenant2);
|
||||
|
||||
// Assert one tenant's cache doesn't leak to another tenant
|
||||
expect(Cache::has('foo'))->toBeFalse();
|
||||
expect($getCacheUsingDbQuery('foo'))->toBeNull();
|
||||
|
||||
cache()->set('foo', 'xyz');
|
||||
expect(Cache::get('foo'))->toBe('xyz');
|
||||
expect($getCacheUsingDbQuery('foo'))->toContain('xyz');
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
// Assert cache didn't leak to the original tenant
|
||||
expect(Cache::get('foo'))->toBe('bar');
|
||||
expect($getCacheUsingDbQuery('foo'))->toContain('bar');
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
// Assert central 'foo' cache is still the same ('central')
|
||||
expect(Cache::get('foo'))->toBe('central');
|
||||
expect($getCacheUsingDbQuery('foo'))->toContain('central');
|
||||
});
|
||||
|
||||
test('DatabaseCacheBootstrapper auto-detects all database driver stores by default', function() {
|
||||
config([
|
||||
'cache.stores.database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => 'central',
|
||||
'table' => 'cache',
|
||||
],
|
||||
'cache.stores.sessions' => [
|
||||
'driver' => 'database',
|
||||
'connection' => 'central',
|
||||
'table' => 'sessions_cache',
|
||||
],
|
||||
'cache.stores.redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => 'default',
|
||||
],
|
||||
'cache.stores.file' => [
|
||||
'driver' => 'file',
|
||||
'path' => '/foo/bar',
|
||||
],
|
||||
]);
|
||||
|
||||
// Here, we're using auto-detection (default behavior)
|
||||
expect(config('cache.stores.database.connection'))->toBe('central');
|
||||
expect(config('cache.stores.sessions.connection'))->toBe('central');
|
||||
expect(config('cache.stores.redis.connection'))->toBe('default');
|
||||
expect(config('cache.stores.file.path'))->toBe('/foo/bar');
|
||||
|
||||
tenancy()->initialize(Tenant::create());
|
||||
|
||||
// Using auto-detection (default behavior),
|
||||
// all database driver stores should be configured,
|
||||
// and stores with non-database drivers are ignored.
|
||||
expect(config('cache.stores.database.connection'))->toBe('tenant');
|
||||
expect(config('cache.stores.sessions.connection'))->toBe('tenant');
|
||||
expect(config('cache.stores.redis.connection'))->toBe('default'); // unchanged
|
||||
expect(config('cache.stores.file.path'))->toBe('/foo/bar'); // unchanged
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
// All database stores should be reverted, others unchanged
|
||||
expect(config('cache.stores.database.connection'))->toBe('central');
|
||||
expect(config('cache.stores.sessions.connection'))->toBe('central');
|
||||
expect(config('cache.stores.redis.connection'))->toBe('default');
|
||||
expect(config('cache.stores.file.path'))->toBe('/foo/bar');
|
||||
});
|
||||
|
||||
test('manual $stores configuration takes precedence over auto-detection', function() {
|
||||
// Configure multiple database stores
|
||||
config([
|
||||
'cache.stores.sessions' => [
|
||||
'driver' => 'database',
|
||||
'connection' => 'central',
|
||||
'table' => 'sessions_cache',
|
||||
],
|
||||
'cache.stores.redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => 'default',
|
||||
],
|
||||
]);
|
||||
|
||||
// Specific store overrides (including non-database stores)
|
||||
DatabaseCacheBootstrapper::$stores = ['sessions', 'redis']; // Note: excludes 'database'
|
||||
|
||||
expect(config('cache.stores.database.connection'))->toBe('central');
|
||||
expect(config('cache.stores.sessions.connection'))->toBe('central');
|
||||
expect(config('cache.stores.redis.connection'))->toBe('default');
|
||||
|
||||
tenancy()->initialize(Tenant::create());
|
||||
|
||||
// Manual config takes precedence: only 'sessions' is configured
|
||||
// - redis filtered out by driver check
|
||||
// - database store not included in $stores
|
||||
expect(config('cache.stores.database.connection'))->toBe('central'); // Excluded in manual config
|
||||
expect(config('cache.stores.sessions.connection'))->toBe('tenant'); // Included and is database driver
|
||||
expect(config('cache.stores.redis.connection'))->toBe('default'); // Included but filtered out (not database driver)
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
// Only the manually configured stores' config will be reverted
|
||||
expect(config('cache.stores.database.connection'))->toBe('central');
|
||||
expect(config('cache.stores.sessions.connection'))->toBe('central');
|
||||
expect(config('cache.stores.redis.connection'))->toBe('default');
|
||||
});
|
||||
|
|
@ -10,6 +10,7 @@ use Illuminate\Contracts\Http\Kernel;
|
|||
use Illuminate\Support\Facades\Event;
|
||||
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||
use Illuminate\Support\Facades\Route as RouteFacade;
|
||||
use Illuminate\Support\Str;
|
||||
use Stancl\Tenancy\Actions\CloneRoutesAsTenant;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
|
||||
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']);
|
||||
});
|
||||
|
||||
$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
|
||||
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')));
|
||||
});
|
||||
|
||||
tenancy(); // trigger features: todo@samuel remove after feature refactor
|
||||
tenancy()->bootstrapFeatures();
|
||||
|
||||
if ($disallow) {
|
||||
expect(fn () => pest()->post('/central-sqli', [
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ test('tenant redirect macro replaces only the hostname', function () {
|
|||
'tenancy.features' => [CrossDomainRedirect::class],
|
||||
]);
|
||||
|
||||
tenancy()->bootstrapFeatures();
|
||||
|
||||
Route::get('/foobar', function () {
|
||||
return 'Foo';
|
||||
})->name('home');
|
||||
|
|
|
|||
|
|
@ -2,29 +2,27 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
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\Bootstrappers\TenantConfigBootstrapper;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
use function Stancl\Tenancy\Tests\withBootstrapping;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'tenancy.bootstrappers' => [TenantConfigBootstrapper::class],
|
||||
]);
|
||||
|
||||
withBootstrapping();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
TenantConfig::$storageToConfigMap = [];
|
||||
TenantConfigBootstrapper::$storageToConfigMap = [];
|
||||
});
|
||||
|
||||
test('nested tenant values are merged', function () {
|
||||
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',
|
||||
];
|
||||
|
||||
|
|
@ -39,14 +37,8 @@ test('nested tenant values are merged', function () {
|
|||
|
||||
test('config is merged and removed', function () {
|
||||
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_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 () {
|
||||
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.public1',
|
||||
'services.paypal.public2',
|
||||
|
|
|
|||
|
|
@ -3,27 +3,42 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Vite;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use Stancl\Tenancy\Overrides\Vite as StanclVite;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Features\ViteBundler;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
|
||||
test('vite helper uses our custom class', function() {
|
||||
$vite = app(Vite::class);
|
||||
|
||||
expect($vite)->toBeInstanceOf(Vite::class);
|
||||
expect($vite)->not()->toBeInstanceOf(StanclVite::class);
|
||||
use function Stancl\Tenancy\Tests\withBootstrapping;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'tenancy.features' => [ViteBundler::class],
|
||||
'tenancy.filesystem.asset_helper_override' => true,
|
||||
'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class],
|
||||
]);
|
||||
|
||||
$tenant = Tenant::create();
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
app()->forgetInstance(Vite::class);
|
||||
|
||||
$vite = app(Vite::class);
|
||||
|
||||
expect($vite)->toBeInstanceOf(StanclVite::class);
|
||||
File::ensureDirectoryExists(dirname($manifestPath = public_path('build/manifest.json')));
|
||||
File::put($manifestPath, json_encode([
|
||||
'foo' => [
|
||||
'file' => 'assets/foo-AbC123.js',
|
||||
'src' => 'js/foo.js',
|
||||
],
|
||||
]));
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
|||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
use Stancl\Tenancy\Bootstrappers\CacheTagsBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
||||
|
||||
use function Stancl\Tenancy\Tests\withCacheTables;
|
||||
use function Stancl\Tenancy\Tests\withTenantDatabases;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
|
|
@ -20,26 +25,40 @@ beforeEach(function () {
|
|||
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||
|
||||
withCacheTables();
|
||||
});
|
||||
|
||||
test('global cache manager stores data in global cache', function (string $bootstrapper) {
|
||||
config(['tenancy.bootstrappers' => [$bootstrapper]]);
|
||||
test('global cache manager stores data in global cache', function (string $store, array $bootstrappers) {
|
||||
config([
|
||||
'cache.default' => $store,
|
||||
'tenancy.bootstrappers' => $bootstrappers,
|
||||
]);
|
||||
|
||||
if ($store === 'database') withTenantDatabases(true);
|
||||
|
||||
expect(cache('foo'))->toBe(null);
|
||||
GlobalCache::put(['foo' => 'bar'], 1);
|
||||
GlobalCache::put('foo', 'bar');
|
||||
expect(GlobalCache::get('foo'))->toBe('bar');
|
||||
|
||||
$tenant1 = Tenant::create();
|
||||
tenancy()->initialize($tenant1);
|
||||
expect(GlobalCache::get('foo'))->toBe('bar');
|
||||
|
||||
GlobalCache::put(['abc' => 'xyz'], 1);
|
||||
cache(['def' => 'ghi'], 10);
|
||||
GlobalCache::put('abc', 'xyz');
|
||||
cache(['def' => 'ghi']);
|
||||
expect(cache('def'))->toBe('ghi');
|
||||
|
||||
// different stores, same underlying connection. the prefix is set ON THE STORE
|
||||
expect(cache()->store()->getStore() === GlobalCache::store()->getStore())->toBeFalse();
|
||||
// different stores
|
||||
expect(cache()->store()->getStore() !== GlobalCache::store()->getStore())->toBeTrue();
|
||||
if ($store === 'redis') {
|
||||
// same underlying connection. the prefix is set ON THE STORE
|
||||
expect(cache()->store()->getStore()->connection() === GlobalCache::store()->getStore()->connection())->toBeTrue();
|
||||
} else {
|
||||
// different connections
|
||||
expect(cache()->store()->getStore()->getConnection()->getName())->toBe('tenant');
|
||||
expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
}
|
||||
|
||||
tenancy()->end();
|
||||
expect(GlobalCache::get('abc'))->toBe('xyz');
|
||||
|
|
@ -51,25 +70,129 @@ test('global cache manager stores data in global cache', function (string $boots
|
|||
expect(GlobalCache::get('abc'))->toBe('xyz');
|
||||
expect(GlobalCache::get('foo'))->toBe('bar');
|
||||
expect(cache('def'))->toBe(null);
|
||||
cache(['def' => 'xxx'], 1);
|
||||
cache(['def' => 'xxx']);
|
||||
expect(cache('def'))->toBe('xxx');
|
||||
|
||||
tenancy()->initialize($tenant1);
|
||||
expect(cache('def'))->toBe('ghi');
|
||||
})->with([
|
||||
CacheTagsBootstrapper::class,
|
||||
CacheTenancyBootstrapper::class,
|
||||
['redis', [CacheTagsBootstrapper::class]],
|
||||
['redis', [CacheTenancyBootstrapper::class]],
|
||||
['database', [DatabaseTenancyBootstrapper::class, DatabaseCacheBootstrapper::class]],
|
||||
]);
|
||||
|
||||
test('the global_cache helper supports the same syntax as the cache helper', function (string $bootstrapper) {
|
||||
config(['tenancy.bootstrappers' => [$bootstrapper]]);
|
||||
test('global cache facade is not persistent', function () {
|
||||
$oldId = spl_object_id(GlobalCache::getFacadeRoot());
|
||||
|
||||
$_ = new class {};
|
||||
|
||||
expect(spl_object_id(GlobalCache::getFacadeRoot()))->not()->toBe($oldId);
|
||||
});
|
||||
|
||||
test('global cache is always central', function (string $store, array $bootstrappers, string $initialCentralCall) {
|
||||
config([
|
||||
'cache.default' => $store,
|
||||
'tenancy.bootstrappers' => $bootstrappers,
|
||||
]);
|
||||
|
||||
if ($store === 'database') {
|
||||
withTenantDatabases(true);
|
||||
}
|
||||
|
||||
// This tells us which "accessor" for the global cache should be instantiated first, before we go
|
||||
// into the tenant context. We make sure to not touch the other one here. This tests that whether
|
||||
// a particular accessor is used "early" makes no difference in the later behavior.
|
||||
if ($initialCentralCall === 'helper') {
|
||||
if ($store === 'database') expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
global_cache()->put('central-helper', true);
|
||||
} else if ($initialCentralCall === 'facade') {
|
||||
if ($store === 'database') expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
GlobalCache::put('central-facade', true);
|
||||
} else if ($initialCentralCall === 'both') {
|
||||
if ($store === 'database') expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
global_cache()->put('central-helper', true);
|
||||
if ($store === 'database') expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
GlobalCache::put('central-facade', true);
|
||||
}
|
||||
|
||||
$tenant = Tenant::create();
|
||||
$tenant->enter();
|
||||
|
||||
// different stores, same underlying connection. the prefix is set ON THE STORE
|
||||
expect(cache()->store()->getStore() === global_cache()->store()->getStore())->toBeFalse();
|
||||
// Here we use both the helper and the facade to ensure the value is accessible via either one
|
||||
if ($initialCentralCall === 'helper') {
|
||||
if ($store === 'database') expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
if ($store === 'database') expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
expect(global_cache('central-helper'))->toBe(true);
|
||||
expect(GlobalCache::get('central-helper'))->toBe(true);
|
||||
} else if ($initialCentralCall === 'facade') {
|
||||
if ($store === 'database') expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
if ($store === 'database') expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
expect(global_cache('central-facade'))->toBe(true);
|
||||
expect(GlobalCache::get('central-facade'))->toBe(true);
|
||||
} else if ($initialCentralCall === 'both') {
|
||||
if ($store === 'database') expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
if ($store === 'database') expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
expect(global_cache('central-helper'))->toBe(true);
|
||||
expect(GlobalCache::get('central-helper'))->toBe(true);
|
||||
expect(global_cache('central-facade'))->toBe(true);
|
||||
expect(GlobalCache::get('central-facade'))->toBe(true);
|
||||
}
|
||||
|
||||
global_cache()->put('tenant-helper', true);
|
||||
GlobalCache::put('tenant-facade', true);
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
if ($store === 'database') expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
if ($store === 'database') expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
|
||||
expect(global_cache('tenant-helper'))->toBe(true);
|
||||
expect(GlobalCache::get('tenant-helper'))->toBe(true);
|
||||
expect(global_cache('tenant-facade'))->toBe(true);
|
||||
expect(GlobalCache::get('tenant-facade'))->toBe(true);
|
||||
|
||||
if ($initialCentralCall === 'helper') {
|
||||
expect(GlobalCache::get('central-helper'))->toBe(true);
|
||||
} else if ($initialCentralCall === 'facade') {
|
||||
expect(global_cache('central-facade'))->toBe(true);
|
||||
} else if ($initialCentralCall === 'both') {
|
||||
expect(global_cache('central-helper'))->toBe(true);
|
||||
expect(GlobalCache::get('central-helper'))->toBe(true);
|
||||
expect(global_cache('central-facade'))->toBe(true);
|
||||
expect(GlobalCache::get('central-facade'))->toBe(true);
|
||||
}
|
||||
})->with([
|
||||
['redis', [CacheTagsBootstrapper::class]],
|
||||
['redis', [CacheTenancyBootstrapper::class]],
|
||||
['database', [DatabaseTenancyBootstrapper::class, DatabaseCacheBootstrapper::class]],
|
||||
])->with([
|
||||
'helper',
|
||||
'facade',
|
||||
'both',
|
||||
'none',
|
||||
]);
|
||||
|
||||
test('the global_cache helper supports the same syntax as the cache helper', function (string $store, array $bootstrappers) {
|
||||
config([
|
||||
'cache.default' => $store,
|
||||
'tenancy.bootstrappers' => $bootstrappers,
|
||||
]);
|
||||
|
||||
if ($store === 'database') withTenantDatabases(true);
|
||||
|
||||
$tenant = Tenant::create();
|
||||
$tenant->enter();
|
||||
|
||||
// different stores
|
||||
expect(cache()->store()->getStore() !== GlobalCache::store()->getStore())->toBeTrue();
|
||||
if ($store === 'redis') {
|
||||
// same underlying connection. the prefix is set ON THE STORE
|
||||
expect(cache()->store()->getStore()->connection() === global_cache()->store()->getStore()->connection())->toBeTrue();
|
||||
} else {
|
||||
// different connections
|
||||
expect(cache()->store()->getStore()->getConnection()->getName())->toBe('tenant');
|
||||
expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
}
|
||||
|
||||
expect(cache('foo'))->toBe(null); // tenant cache is empty
|
||||
|
||||
|
|
@ -81,6 +204,7 @@ test('the global_cache helper supports the same syntax as the cache helper', fun
|
|||
|
||||
expect(cache('foo'))->toBe(null); // tenant cache is not affected
|
||||
})->with([
|
||||
CacheTagsBootstrapper::class,
|
||||
CacheTenancyBootstrapper::class,
|
||||
['redis', [CacheTagsBootstrapper::class]],
|
||||
['redis', [CacheTenancyBootstrapper::class]],
|
||||
['database', [DatabaseTenancyBootstrapper::class, DatabaseCacheBootstrapper::class]],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ use Illuminate\Support\Facades\Artisan;
|
|||
use Illuminate\Support\Facades\Event;
|
||||
use Stancl\Tenancy\Database\Concerns\MaintenanceMode;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Stancl\Tenancy\Events\TenantMaintenanceModeDisabled;
|
||||
use Stancl\Tenancy\Events\TenantMaintenanceModeEnabled;
|
||||
use Stancl\Tenancy\Middleware\CheckTenantForMaintenanceMode;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
|
||||
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);
|
||||
});
|
||||
|
||||
test('maintenance mode events are fired', function () {
|
||||
$tenant = MaintenanceTenant::create();
|
||||
test('maintenance mode middleware can be used with universal routes', function () {
|
||||
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();
|
||||
|
||||
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();
|
||||
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -2,21 +2,52 @@
|
|||
|
||||
namespace Stancl\Tenancy\Tests;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Stancl\Tenancy\Tests\TestCase;
|
||||
use Stancl\JobPipeline\JobPipeline;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Stancl\Tenancy\Events\TenancyEnded;
|
||||
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||
use Stancl\Tenancy\Jobs\CreateDatabase;
|
||||
use Stancl\Tenancy\Events\TenantCreated;
|
||||
use Stancl\Tenancy\Jobs\MigrateDatabase;
|
||||
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
|
||||
uses(TestCase::class)->in(__DIR__);
|
||||
|
||||
function withTenantDatabases()
|
||||
function withBootstrapping()
|
||||
{
|
||||
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||
}
|
||||
|
||||
function withTenantDatabases(bool $migrate = false)
|
||||
{
|
||||
Event::listen(TenantCreated::class, JobPipeline::make($migrate
|
||||
? [CreateDatabase::class, MigrateDatabase::class]
|
||||
: [CreateDatabase::class]
|
||||
)->send(function (TenantCreated $event) {
|
||||
return $event->tenant;
|
||||
})->toListener());
|
||||
}
|
||||
|
||||
function withCacheTables()
|
||||
{
|
||||
Schema::create('cache', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->mediumText('value');
|
||||
$table->integer('expiration');
|
||||
});
|
||||
|
||||
Schema::create('cache_locks', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->string('owner');
|
||||
$table->integer('expiration');
|
||||
});
|
||||
}
|
||||
|
||||
function pest(): TestCase
|
||||
{
|
||||
return \Pest\TestSuite::getInstance()->test;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use Illuminate\Http\Request;
|
|||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Stancl\Tenancy\Actions\CloneRoutesAsTenant;
|
||||
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||
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();
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
$filename = 'testfile' . pest()->randomString(10);
|
||||
$filename = 'testfile' . Str::random(8);
|
||||
Storage::disk('public')->put($filename, 'bar');
|
||||
$path = storage_path("app/public/$filename");
|
||||
|
||||
|
|
@ -136,7 +137,7 @@ test('TenantAssetController headers are configurable', function () {
|
|||
tenancy()->initialize($tenant);
|
||||
$tenant->createDomain('foo.localhost');
|
||||
|
||||
$filename = 'testfile' . pest()->randomString(10);
|
||||
$filename = 'testfile' . Str::random(10);
|
||||
Storage::disk('public')->put($filename, 'bar');
|
||||
|
||||
$this->withoutExceptionHandling();
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQ
|
|||
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLDatabaseManager;
|
||||
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
use function Stancl\Tenancy\Tests\withBootstrapping;
|
||||
use function Stancl\Tenancy\Tests\withTenantDatabases;
|
||||
|
||||
beforeEach(function () {
|
||||
SQLiteDatabaseManager::$path = null;
|
||||
|
|
@ -43,7 +45,7 @@ test('databases can be created and deleted', function ($driver, $databaseManager
|
|||
"tenancy.database.managers.$driver" => $databaseManager,
|
||||
]);
|
||||
|
||||
$name = 'db' . pest()->randomString();
|
||||
$name = 'db' . Str::random(10);
|
||||
|
||||
$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;
|
||||
})->toListener());
|
||||
|
||||
$database = 'db' . pest()->randomString();
|
||||
$database = 'db' . Str::random(10);
|
||||
|
||||
$mysqlmanager = app(MySQLDatabaseManager::class);
|
||||
$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->setConnection('pgsql');
|
||||
|
||||
$database = 'db' . pest()->randomString();
|
||||
$database = 'db' . Str::random(10);
|
||||
expect($postgresManager->databaseExists($database))->toBeFalse();
|
||||
|
||||
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'));
|
||||
});
|
||||
|
||||
test('sqlite databases use the WAL journal mode by default', function (bool|null $wal) {
|
||||
$expected = $wal ? 'wal' : 'delete';
|
||||
if ($wal !== null) {
|
||||
SQLiteDatabaseManager::$WAL = $wal;
|
||||
} else {
|
||||
// default behavior
|
||||
$expected = 'wal';
|
||||
}
|
||||
|
||||
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
|
||||
return $event->tenant;
|
||||
})->toListener());
|
||||
test('sqlite databases respect the template journal_mode config', function (string $journal_mode) {
|
||||
withTenantDatabases();
|
||||
withBootstrapping();
|
||||
config([
|
||||
'database.connections.sqlite.journal_mode' => $journal_mode,
|
||||
'tenancy.bootstrappers' => [
|
||||
DatabaseTenancyBootstrapper::class,
|
||||
],
|
||||
]);
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'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->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
|
||||
SQLiteDatabaseManager::$WAL = true;
|
||||
})->with([true, false, null]);
|
||||
// This will trigger the logic in Laravel's SQLiteConnector
|
||||
$tenant->run(fn () => DB::select('select 1'));
|
||||
|
||||
$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 () {
|
||||
config([
|
||||
|
|
@ -239,9 +245,6 @@ test('tenant database can be created and deleted on a foreign server', function
|
|||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'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,
|
||||
'strict' => true,
|
||||
'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,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ beforeEach(function () {
|
|||
],
|
||||
]);
|
||||
|
||||
tenancy()->bootstrapFeatures();
|
||||
|
||||
Event::listen(
|
||||
TenantCreated::class,
|
||||
JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper;
|
|||
use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\TenantConfigBootstrapper;
|
||||
|
||||
abstract class TestCase extends \Orchestra\Testbench\TestCase
|
||||
{
|
||||
|
|
@ -38,6 +40,10 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
|
|||
|
||||
ini_set('memory_limit', '1G');
|
||||
|
||||
TenancyServiceProvider::$registerForgetTenantParameterListener = true;
|
||||
TenancyServiceProvider::$migrateFreshOverride = true;
|
||||
TenancyServiceProvider::$adjustCacheManagerUsing = null;
|
||||
|
||||
Redis::connection('default')->flushdb();
|
||||
Redis::connection('cache')->flushdb();
|
||||
Artisan::call('cache:clear memcached'); // flush memcached
|
||||
|
|
@ -138,9 +144,6 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
|
|||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'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.mysql.charset' => 'utf8mb4',
|
||||
|
|
@ -180,6 +183,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
|
|||
// to manually register bootstrappers as singletons here.
|
||||
$app->singleton(RedisTenancyBootstrapper::class);
|
||||
$app->singleton(CacheTenancyBootstrapper::class);
|
||||
$app->singleton(DatabaseCacheBootstrapper::class);
|
||||
$app->singleton(BroadcastingConfigBootstrapper::class);
|
||||
$app->singleton(BroadcastChannelPrefixBootstrapper::class);
|
||||
$app->singleton(PostgresRLSBootstrapper::class);
|
||||
|
|
@ -187,6 +191,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
|
|||
$app->singleton(RootUrlBootstrapper::class);
|
||||
$app->singleton(UrlGeneratorBootstrapper::class);
|
||||
$app->singleton(FilesystemTenancyBootstrapper::class);
|
||||
$app->singleton(TenantConfigBootstrapper::class);
|
||||
}
|
||||
|
||||
protected function getPackageProviders($app)
|
||||
|
|
@ -230,11 +235,6 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
|
|||
$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
|
||||
{
|
||||
parent::assertTrue(array_intersect($subset, $array) == $subset, $message);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue