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

Merge branch '2.x'

This commit is contained in:
Samuel Štancl 2020-04-30 20:49:22 +02:00
commit d900929264
87 changed files with 2952 additions and 570 deletions

12
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,12 @@
# These are supported funding model platforms
github: stancl
patreon: samuelstancl
open_collective: # Replace with a single Open Collective username
ko_fi: samuelstancl
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: ['https://www.paypal.me/samuelstancl', 'https://gumroad.com/l/tenancy']

View file

@ -17,6 +17,6 @@ assignees: stancl
A clear and concise description of what you expected to happen.
#### Your setup
- Laravel version [e.g. 5.8]
- stancl/tenancy version [e.g. 22]
- Storage driver [e.g. Redis]
- Laravel version: [e.g. 6.2.0]
- stancl/tenancy version: [e.g. 2.1.0]
- Storage driver: [e.g. DB]

31
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,31 @@
name: CI
env:
COMPOSE_INTERACTIVE_NO_CLI: 1
on:
push:
branches: [ 2.x, master ]
pull_request:
branches: [ 2.x, master ]
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
laravel: ["^6.0", "^7.0"]
steps:
- uses: actions/checkout@v2
- name: Start docker containers
run: docker-compose up -d
- name: Install dependencies
run: docker-compose exec -T test composer require --no-interaction "laravel/framework:${{ matrix.laravel }}"
- name: Run tests
run: ./fulltest
- name: Send code coverage to codecov
env:
CODECOV_TOKEN: 24382d15-84e7-4a55-bea4-c4df96a24a9b
run: bash <(curl -s https://codecov.io/bash)

View file

@ -1,8 +1,6 @@
preset: laravel
enabled:
- declare_strict_types
- alpha_ordered_imports
disabled:
- concat_without_spaces
- ternary_operator_spaces
- length_ordered_imports

View file

@ -1,26 +0,0 @@
# env:
# - LARAVEL_VERSION="^6.0" TESTBENCH_VERSION="~4.0" REDIS_DRIVER=phpredis
language: php
php:
- '7.2'
services:
- docker
before_install:
- docker-compose up -d
# install:
# - travis_retry docker-compose exec test composer require --no-interaction "laravel/framework:$LARAVEL_VERSION" "orchestra/testbench:$TESTBENCH_VERSION"
install:
- travis_retry docker-compose exec test composer install --no-interaction
before_script:
- export DB_USERNAME=root DB_PASSWORD="" DB_DATABASE=tenancy CODECOV_TOKEN="24382d15-84e7-4a55-bea4-c4df96a24a9b"
- cat vendor/laravel/framework/src/Illuminate/Foundation/Application.php| grep 'const VERSION'
script: ./fulltest
after_success:
- bash <(curl -s https://codecov.io/bash)

View file

@ -1,15 +1,14 @@
# Contributing
## Code style
StyleCI will automatically fix code style violations in your pull requests.
StyleCI will flag code style violations in your pull requests.
## Running tests
### With Docker
If you have Docker installed, simply run ./test. When you're done testing, run docker-compose down to shut down the containers.
If you have Docker installed, simply run ./fulltest. When you're done testing, run docker-compose down to shut down the containers.
### Without Docker
If you run the tests of this package, please make sure you don't store anything in Redis @ 127.0.0.1:6379 db#14. The contents of this database are flushed everytime the tests are run.
Some tests are run only if the CI, TRAVIS and CONTINUOUS_INTEGRATION environment variables are set to true. This is to avoid things like bloating your MySQL instance with test databases.
Some tests are run only if the `CONTINUOUS_INTEGRATION` or `DOCKER` environment variables are set to true. This is to avoid things like bloating your MySQL instance with test databases.

39
DONATIONS.md Normal file
View file

@ -0,0 +1,39 @@
# Donations
Any donations will be greatly appreciated and help ensure that the package is developed and maintained in the future.
If you're a company and this package is helping you make money, please consider donating.
**If you'd like to donate in your local currency, see the Bank transfer section.**
### Patreon
If you would like to support me on a monthly basis, you can use Patreon: https://patreon.com/samuelstancl
### PayPal
PayPal is the preferable donation method for one-time donations as it comes with the lowest fees.
You can donate here: [https://paypal.me/samuelstancl](https://paypal.me/samuelstancl)
### Other methods
If you can't use PayPal, you may use my Gumroad link. This comes with higher fees but any donations will be greatly appreciated nonetheless.
You can donate here: [https://gumroad.com/l/tenancy](https://gumroad.com/l/tenancy)
### Bank transfer
If you'd like to donate money from your bank account, you can can do that too. I use [TransferWise](https://transferwise.com/invite/u/samuels1719) (affiliate link 🙂), so I can accept bank transfers in virtually any currency.
Contact me on [samuel.stancl@gmail.com](mailto:samuel.stancl@gmail.com?subject=Donation) and I'll give you bank details for your local currency.
### Legal
If you're a business making a donation, you may want an invoice.
Contact me on [samuel.stancl@gmail.com](mailto:samuel.stancl@gmail.com?subject=Donation%20with%20invoice) and let me know what you need to have on the invoice and I will make it happen.
### Thank you!
Again, any donations are greatly appreciated. Thanks to everyone who has donated, you're helping keep this package maintained.

View file

@ -4,27 +4,32 @@ LABEL maintainer="Samuel Štancl"
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y curl zip unzip git sqlite3 \
php7.2-fpm php7.2-cli \
php7.2-pgsql php7.2-sqlite3 php7.2-gd \
php7.2-curl php7.2-memcached \
php7.2-imap php7.2-mysql php7.2-mbstring \
php7.2-xml php7.2-zip php7.2-bcmath php7.2-soap \
php7.2-intl php7.2-readline php7.2-xdebug \
RUN apt-get update
RUN apt-get install -y software-properties-common
RUN add-apt-repository -y ppa:ondrej/php
RUN apt-get install -y curl zip unzip git sqlite3 \
php7.4-fpm php7.4-cli \
php7.4-pgsql php7.4-sqlite3 php7.4-gd \
php7.4-curl php7.4-memcached \
php7.4-imap php7.4-mysql php7.4-mbstring \
php7.4-xml php7.4-zip php7.4-bcmath php7.4-soap \
php7.4-intl php7.4-readline php7.4-xdebug \
php-msgpack php-igbinary \
&& php -r "readfile('http://getcomposer.org/installer');" | php -- --install-dir=/usr/bin/ --filename=composer \
&& mkdir /run/php
RUN apt-get install -y php7.2-redis
RUN apt-get install -y python3
RUN apt-get install -y php7.2-dev php-pear
RUN apt-get install -y php7.4-dev php-pear
RUN pecl install redis-4.3.0
RUN echo "extension=redis.so" > /etc/php/7.4/mods-available/redis.ini
RUN ln -sf /etc/php/7.4/mods-available/redis.ini /etc/php/7.4/fpm/conf.d/20-redis.ini
RUN ln -sf /etc/php/7.4/mods-available/redis.ini /etc/php/7.4/cli/conf.d/20-redis.ini
RUN pecl install xdebug
# RUN echo '' > /etc/php/7.2/cli/conf.d/20-xdebug.ini
# RUN echo 'zend_extension=/usr/lib/php/20170718/xdebug.so' >> /etc/php/7.2/cli/php.ini
RUN echo 'zend_extension=/usr/lib/php/20170718/xdebug.so' > /etc/php/7.2/cli/conf.d/20-xdebug.ini
RUN echo 'zend_extension=/usr/lib/php/20190902/xdebug.so' > /etc/php/7.4/cli/conf.d/20-xdebug.ini
RUN apt-get -y autoremove \
&& apt-get clean \

View file

@ -1,10 +1,16 @@
# [stancl/tenancy](https://tenancy.samuelstancl.me)
<p align="center">
<a href="https://tenancyforlaravel.com"><img width="800" src="/art/logo.png" alt="Tenancy for Laravel logo" /></a>
</p>
[![Laravel 6.x](https://img.shields.io/badge/laravel-6.x-red.svg)](https://laravel.com)
[![Latest Stable Version](https://poser.pugx.org/stancl/tenancy/version)](https://packagist.org/packages/stancl/tenancy)
[![Travis CI build](https://travis-ci.com/stancl/tenancy.svg?branch=2.x)](https://travis-ci.com/stancl/tenancy)
[![codecov](https://codecov.io/gh/stancl/tenancy/branch/2.x/graph/badge.svg)](https://codecov.io/gh/stancl/tenancy)
[![Donate](https://img.shields.io/badge/Donate-%3C3-red)](https://gumroad.com/l/tenancy)
<p align="center">
<a href="https://laravel.com"><img alt="Laravel 6.x" src="https://img.shields.io/badge/laravel-6.x-red.svg"></a>
<a href="https://packagist.org/packages/stancl/tenancy"><img alt="Latest Stable Version" src="https://poser.pugx.org/stancl/tenancy/version"></a>
<a href="https://github.com/stancl/tenancy/actions"><img alt="GitHub Actions CI status" src="https://github.com/stancl/tenancy/workflows/CI/badge.svg"></a>
<a href="https://codecov.io/gh/stancl/tenancy"><img alt="codecov" src="https://codecov.io/gh/stancl/tenancy/branch/2.x/graph/badge.svg"></a>
<a href="https://github.com/stancl/tenancy/blob/2.x/DONATIONS.md"><img alt="Donate" src="https://img.shields.io/badge/Donate-%3C3-red"></a>
</p>
<h1><a href="https://tenancyforlaravel.com">Tenancy for Laravel &mdash; stancl/tenancy</a></h1>
### *Automatic multi-tenancy for your Laravel app.*
@ -14,8 +20,15 @@ You won't have to change a thing in your application's code.
- :heavy_check_mark: No replacing of Laravel classes (`Cache`, `Storage`, ...) with tenancy-aware classes
- :heavy_check_mark: Built-in tenant identification based on hostname (including second level domains)
### [Documentation](https://tenancy.samuelstancl.me/docs/v2/)
### [Documentation](https://tenancyforlaravel.com/docs/v2/)
Documentation can be found here: https://tenancy.samuelstancl.me/docs/v2/
Documentation can be found here: https://tenancyforlaravel.com/docs/v2/
The repository with the documentation source code can be found here: [stancl/tenancy-docs](https://github.com/stancl/tenancy-docs).
### [Need help?](https://github.com/stancl/tenancy/blob/2.x/SUPPORT.md)
### Credits
- Created by [Samuel Štancl](https://twitter.com/samuelstancl)
- Logo by [Brian Dillingham](https://twitter.com/dillinghammm)

9
SUPPORT.md Normal file
View file

@ -0,0 +1,9 @@
# Get Support
If you need help with implementing the package, you can:
- Open an [issue here on GitHub](https://github.com/stancl/tenancy/issues/new?assignees=stancl&labels=support&template=support-question.md&title=)
- Join our new [Discord server](https://discord.gg/7cpgPxv) and ask in `#help`
The methods above are preferred, but you may also
- Contact me on Telegram: [@samuelstancl](https://t.me/samuelstancl)
- Send me an email: [samuel.stancl@gmail.com](mailto:samuel.stancl@gmail.com)

BIN
art/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

BIN
art/old_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View file

@ -3,97 +3,289 @@
declare(strict_types=1);
return [
/**
* Storage drivers are used to store information about your tenants.
* They hold the Tenant Storage data and keeps track of domains.
*/
'storage_driver' => 'db',
'storage_drivers' => [
/**
* The majority of applications will want to use this storage driver.
* The information about tenants is persisted in a relational DB
* like MySQL or PostgreSQL. The only downside is performance.
*
* A database connection to the central database has to be established on each
* request, to identify the tenant based on the domain. This takes three DB
* queries. Then, the connection to the tenant database is established.
*
* Note: From v2.3.0, the performance of the DB storage driver can be improved
* by a lot by using Cached Tenant Lookup. Be sure to enable that if you're
* using this storage driver. Enabling that feature can completely avoid
* querying the central database to identify build the Tenant object.
*/
'db' => [
'driver' => Stancl\Tenancy\StorageDrivers\Database\DatabaseStorageDriver::class,
'data_column' => 'data',
'custom_columns' => [
// 'plan',
],
/**
* Your central database connection. Set to null to use the default one.
*
* Note: It's recommended to create a designated central connection,
* to let you easily use it in your app, e.g. via the DB facade.
*/
'connection' => null,
'table_names' => [
'TenantModel' => 'tenants',
'DomainModel' => 'domains',
'tenants' => 'tenants',
'domains' => 'domains',
],
/**
* Here you can enable the Cached Tenant Lookup.
*
* You can specify what cache store should be used to cache the tenant resolution.
* Set to string with a specific cache store name, or to null to disable cache.
*/
'cache_store' => null,
'cache_ttl' => 3600, // seconds
],
/**
* The Redis storage driver is much more performant than the database driver.
* However, by default, Redis is a not a durable data storage. It works well for ephemeral data
* like cache, but to hold critical data, it needs to be configured in a way that guarantees
* that data will be persisted permanently. Specifically, you want to enable both AOF and
* RDB. Read this here: https://tenancy.samuelstancl.me/docs/v2/storage-drivers/#redis.
*/
'redis' => [
'driver' => Stancl\Tenancy\StorageDrivers\RedisStorageDriver::class,
'connection' => 'tenancy',
],
],
/**
* Controller namespace used by routes in routes/tenant.php.
*/
'tenant_route_namespace' => 'App\Http\Controllers',
'exempt_domains' => [ // e.g. domains which host landing pages, sign up pages, etc
/**
* Central domains (hostnames), e.g. domains which host landing pages, sign up pages, etc.
*/
'exempt_domains' => [
// 'localhost',
],
'database' => [
'based_on' => null, // The connection that will be used as a base for the dynamically created tenant connection.
'prefix' => 'tenant',
'suffix' => '',
],
'redis' => [
'prefix_base' => 'tenant',
'prefixed_connections' => [
// 'default',
// 'cache',
],
],
'cache' => [
'tag_base' => 'tenant',
],
'filesystem' => [ // https://tenancy.samuelstancl.me/docs/v2/filesystem-tenancy/
'suffix_base' => 'tenant',
// Disks which should be suffixed with the suffix_base + tenant id.
'disks' => [
'local',
'public',
// 's3',
],
'root_override' => [
// Disks whose roots should be overriden after storage_path() is suffixed.
'local' => '%storage_path%/app/',
'public' => '%storage_path%/app/public/',
],
],
'database_managers' => [
// Tenant database managers handle the creation & deletion of tenant databases.
'sqlite' => Stancl\Tenancy\TenantDatabaseManagers\SQLiteDatabaseManager::class,
'mysql' => Stancl\Tenancy\TenantDatabaseManagers\MySQLDatabaseManager::class,
'pgsql' => Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLDatabaseManager::class,
],
'database_manager_connections' => [
// Connections used by TenantDatabaseManagers. This tells, for example, the
// MySQLDatabaseManager to use the mysql connection to create databases.
'sqlite' => 'sqlite',
'mysql' => 'mysql',
'pgsql' => 'pgsql',
],
/**
* Tenancy bootstrappers are executed when tenancy is initialized.
* Their responsibility is making Laravel features tenant-aware.
*
* To configure their behavior, see the config keys below.
*/
'bootstrappers' => [
// Tenancy bootstrappers are executed when tenancy is initialized.
// Their responsibility is making Laravel features tenant-aware.
'database' => Stancl\Tenancy\TenancyBootstrappers\DatabaseTenancyBootstrapper::class,
'cache' => Stancl\Tenancy\TenancyBootstrappers\CacheTenancyBootstrapper::class,
'filesystem' => Stancl\Tenancy\TenancyBootstrappers\FilesystemTenancyBootstrapper::class,
'queue' => Stancl\Tenancy\TenancyBootstrappers\QueueTenancyBootstrapper::class,
// 'redis' => Stancl\Tenancy\TenancyBootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed
],
'features' => [
// Features are classes that provide additional functionality
// not needed for tenancy to be bootstrapped. They are run
// regardless of whether tenancy has been initialized.
// Stancl\Tenancy\Features\TenantConfig::class,
// Stancl\Tenancy\Features\TelescopeTags::class,
// Stancl\Tenancy\Features\TenantRedirect::class,
/**
* Database tenancy config. Used by DatabaseTenancyBootstrapper.
*/
'database' => [
/**
* The connection that will be used as a template for the dynamically created tenant connection.
* Set to null to use the default connection.
*/
'based_on' => null,
/**
* Tenant database names are created like this:
* prefix + tenant_id + suffix.
*/
'prefix' => 'tenant',
'suffix' => '',
'separate_by' => 'database', // database or schema (only supported by pgsql)
],
/**
* Redis tenancy config. Used by RedisTenancyBoostrapper.
*
* Note: You need phpredis to use Redis tenancy.
*
* Note: You don't need to use this if you're using Redis only for cache.
* Redis tenancy is only relevant if you're making direct Redis calls,
* either using the Redis facade or by injecting it as a dependency.
*/
'redis' => [
'prefix_base' => 'tenant', // Each key in Redis will be prepended by this prefix_base, followed by the tenant id.
'prefixed_connections' => [ // Redis connections whose keys are prefixed, to separate one tenant's keys from another.
// 'default',
],
],
/**
* Cache tenancy config. Used by CacheTenancyBootstrapper.
*
* This works for all Cache facade calls, cache() helper
* calls and direct calls to injected cache stores.
*
* Each key in cache will have a tag applied on it. This tag is used to
* scope the cache both when writing to it and when reading from it.
*/
'cache' => [
'tag_base' => 'tenant', // This tag_base, followed by the tenant_id, will form a tag that will be applied on each cache call.
],
/**
* Filesystem tenancy config. Used by FilesystemTenancyBootstrapper.
* https://tenancy.samuelstancl.me/docs/v2/filesystem-tenancy/.
*/
'filesystem' => [
/**
* Each disk listed in the 'disks' array will be suffixed by the suffix_base, followed by the tenant_id.
*/
'suffix_base' => 'tenant',
'disks' => [
'local',
'public',
// 's3',
],
/**
* Use this for local disks.
*
* See https://tenancy.samuelstancl.me/docs/v2/filesystem-tenancy/
*/
'root_override' => [
// Disks whose roots should be overriden after storage_path() is suffixed.
'local' => '%storage_path%/app/',
'public' => '%storage_path%/app/public/',
],
/**
* Should storage_path() be suffixed.
*
* Note: Disabling this will likely break local disk tenancy. Only disable this if you're using an external file storage service like S3.
*
* For the vast majority of applications, this feature should be enabled. But in some
* edge cases, it can cause issues (like using Passport with Vapor - see #196), so
* you may want to disable this if you are experiencing these edge case issues.
*/
'suffix_storage_path' => true,
/**
* By default, asset() calls are made multi-tenant too. You can use global_asset() and mix()
* for global, non-tenant-specific assets. However, you might have some issues when using
* packages that use asset() calls inside the tenant app. To avoid such issues, you can
* disable asset() helper tenancy and explicitly use tenant_asset() calls in places
* where you want to use tenant-specific assets (product images, avatars, etc).
*/
'asset_helper_tenancy' => true,
],
/**
* TenantDatabaseManagers are classes that handle the creation & deletion of tenant databases.
*/
'database_managers' => [
'sqlite' => Stancl\Tenancy\TenantDatabaseManagers\SQLiteDatabaseManager::class,
'mysql' => Stancl\Tenancy\TenantDatabaseManagers\MySQLDatabaseManager::class,
'pgsql' => Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLDatabaseManager::class,
/**
* Disable the pgsql manager above, enable the one below, and set the
* tenancy.database.separate_by config key to 'schema' if you would
* like to separate tenant DBs by schemas rather than databases.
*/
// 'pgsql' => Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLSchemaManager::class, // Separate by schema instead of database
],
/**
* Connections used by TenantDatabaseManagers. This tells, for example, the
* MySQLDatabaseManager to use the mysql connection to create databases.
*/
'database_manager_connections' => [
'sqlite' => 'sqlite',
'mysql' => 'mysql',
'pgsql' => 'pgsql',
],
/**
* Features are classes that provide additional functionality
* not needed for tenancy to be bootstrapped. They are run
* regardless of whether tenancy has been initialized.
*
* See the documentation page for each class to
* understand which ones you want to enable.
*/
'features' => [
// Stancl\Tenancy\Features\Timestamps::class, // https://tenancy.samuelstancl.me/docs/v2/features/timestamps/
// Stancl\Tenancy\Features\TenantConfig::class, // https://tenancy.samuelstancl.me/docs/v2/features/tenant-config/
// Stancl\Tenancy\Features\TelescopeTags::class, // https://tenancy.samuelstancl.me/docs/v2/telescope/
// Stancl\Tenancy\Features\TenantRedirect::class, // https://tenancy.samuelstancl.me/docs/v2/features/tenant-redirect/
],
'storage_to_config_map' => [ // Used by the TenantConfig feature
// 'paypal_api_key' => 'services.paypal.api_key',
],
/**
* The URL to which users will be redirected when they try to acceess a central route on a tenant domain.
*/
'home_url' => '/app',
'migrate_after_creation' => false, // run migrations after creating a tenant
'queue_automatic_migration' => false, // queue the automatic post-tenant-creation migrations
'delete_database_after_tenant_deletion' => false, // delete the tenant's database after deleting the tenant
/**
* Automatically create a database when creating a tenant.
*/
'create_database' => true,
/**
* Should tenant databases be created asynchronously in a queued job.
*/
'queue_database_creation' => false,
/**
* Should tenant migrations be ran after the tenant's database is created.
*/
'migrate_after_creation' => false,
'migration_parameters' => [
// '--force' => true, // Set this to true to be able to run migrations in production
// '--path' => [], // If you need to customize paths to tenant migrations
],
/**
* Should tenant databases be automatically seeded after they're created & migrated.
*/
'seed_after_migration' => false, // should the seeder run after automatic migration
'seeder_parameters' => [
'--class' => 'DatabaseSeeder', // root seeder class, e.g.: 'DatabaseSeeder'
// '--force' => true,
],
/**
* Automatically delete the tenant's database after the tenant is deleted.
*
* This will save space but permanently delete data which you might want to keep.
*/
'delete_database_after_tenant_deletion' => false,
/**
* Should tenant databases be deleted asynchronously in a queued job.
*/
'queue_database_deletion' => false,
/**
* If you don't supply an id when creating a tenant, this class will be used to generate a random ID.
*/
'unique_id_generator' => Stancl\Tenancy\UniqueIDGenerators\UUIDGenerator::class,
/**
* Middleware pushed to the global middleware stack.
*/
'global_middleware' => [
Stancl\Tenancy\Middleware\InitializeTenancy::class,
],
];

View file

@ -0,0 +1,16 @@
<?php
/*
|--------------------------------------------------------------------------
| Tenant Routes
|--------------------------------------------------------------------------
|
| Here you can register the tenant routes for your application.
| These routes are loaded by the TenantRouteServiceProvider
| with the tenancy and web middleware groups. Good luck!
|
*/
Route::get('/app', function () {
return 'This is your multi-tenant application. The id of the current tenant is ' . tenant('id');
});

View file

@ -10,17 +10,17 @@
}
],
"require": {
"illuminate/support": "^6.0",
"ext-json": "*",
"illuminate/support": "^6.0|^7.0",
"facade/ignition-contracts": "^1.0",
"ramsey/uuid": "^3.7"
"ramsey/uuid": "^3.7|^4.0"
},
"require-dev": {
"vlucas/phpdotenv": "^3.3",
"laravel/telescope": "^2.0",
"laravel/framework": "^6.0",
"orchestra/testbench-browser-kit": "^4.0",
"vlucas/phpdotenv": "^3.3|^4.0",
"laravel/framework": "^6.0|^7.0",
"orchestra/testbench-browser-kit": "^4.0|^5.0",
"league/flysystem-aws-s3-v3": "~1.0",
"phpunit/phpcov": "^6.0"
"phpunit/phpcov": "^6.0|^7.0"
},
"autoload": {
"psr-4": {

View file

@ -4,4 +4,4 @@ set -e
# for development
docker-compose up -d
./test "$@"
docker-compose exec test vendor/bin/phpcov merge --clover clover.xml coverage/
docker-compose exec -T test vendor/bin/phpcov merge --clover clover.xml coverage/

View file

@ -33,53 +33,35 @@ class Install extends Command
$this->callSilent('vendor:publish', [
'--provider' => 'Stancl\Tenancy\TenancyServiceProvider',
'--tag' => 'config',
]);
]);
$this->info('✔️ Created config/tenancy.php');
$newKernel = str_replace(
'protected $middlewarePriority = [',
"protected \$middlewarePriority = [
\Stancl\Tenancy\Middleware\PreventAccessFromTenantDomains::class,
\Stancl\Tenancy\Middleware\InitializeTenancy::class,",
file_get_contents(app_path('Http/Kernel.php'))
);
$newKernel = $this->setMiddlewarePriority();
$newKernel = str_replace("'web' => [", "'web' => [
\Stancl\Tenancy\Middleware\PreventAccessFromTenantDomains::class,", $newKernel);
$newKernel = str_replace("'api' => [", "'api' => [
\Stancl\Tenancy\Middleware\PreventAccessFromTenantDomains::class,", $newKernel);
file_put_contents(app_path('Http/Kernel.php'), $newKernel);
$this->info('✔️ Set middleware priority');
file_put_contents(
base_path('routes/tenant.php'),
"<?php
/*
|--------------------------------------------------------------------------
| Tenant Routes
|--------------------------------------------------------------------------
|
| Here is where you can register tenant routes for your application. These
| routes are loaded by the TenantRouteServiceProvider within a group
| which contains the \"InitializeTenancy\" middleware. Good luck!
|
*/
Route::get('/app', function () {
return 'This is your multi-tenant application. The id of the current tenant is ' . tenant('id');
});
"
);
$this->info('✔️ Created routes/tenant.php');
if (! file_exists(base_path('routes/tenant.php'))) {
file_put_contents(base_path('routes/tenant.php'), file_get_contents(__DIR__ . '/../../assets/tenant_routes.php.stub'));
$this->info('✔️ Created routes/tenant.php');
} else {
$this->info('Found routes/tenant.php.');
}
$this->line('');
$this->line("This package lets you store data about tenants either in Redis or in a relational database like MySQL. If you're going to use the database storage, you need to create a tenants table.");
if ($this->confirm('Do you want to publish the default database migrations?', true)) {
$this->line('This package lets you store data about tenants either in Redis or in a relational database like MySQL. To store data about tenants in a relational database, you need a few database tables.');
if ($this->confirm('Do you wish to publish the migrations that create these tables?', true)) {
$this->callSilent('vendor:publish', [
'--provider' => 'Stancl\Tenancy\TenancyServiceProvider',
'--tag' => 'migrations',
'--provider' => 'Stancl\Tenancy\TenancyServiceProvider',
'--tag' => 'migrations',
]);
$this->info('✔️ Created migrations.');
$this->info('✔️ Created migrations. Remember to run [php artisan migrate]!');
}
if (! is_dir(database_path('migrations/tenant'))) {
@ -89,4 +71,34 @@ Route::get('/app', function () {
$this->comment('✨️ stancl/tenancy installed successfully.');
}
protected function setMiddlewarePriority(): string
{
if (app()->version()[0] === '6') {
return str_replace(
'protected $middlewarePriority = [',
"protected \$middlewarePriority = [
\Stancl\Tenancy\Middleware\PreventAccessFromTenantDomains::class,
\Stancl\Tenancy\Middleware\InitializeTenancy::class,",
file_get_contents(app_path('Http/Kernel.php'))
);
} else {
return str_replace(
"];\n}",
"];\n\n protected \$middlewarePriority = [
\Stancl\Tenancy\Middleware\PreventAccessFromTenantDomains::class,
\Stancl\Tenancy\Middleware\InitializeTenancy::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\Illuminate\Auth\Middleware\Authorize::class,
];
}",
file_get_contents(app_path('Http/Kernel.php'))
);
}
}
}

View file

@ -49,21 +49,15 @@ class Migrate extends MigrateCommand
return;
}
$originalTenant = tenancy()->getTenant();
tenancy()->all($this->option('tenants'))->each(function ($tenant) {
$this->line("Tenant: {$tenant['id']}");
$this->input->setOption('database', $tenant->getConnectionName());
tenancy()->initialize($tenant);
// Migrate
parent::handle();
tenancy()->endTenancy();
$tenant->run(function () {
// Migrate
parent::handle();
});
});
if ($originalTenant) {
tenancy()->initialize($originalTenant);
}
}
}

View file

@ -33,30 +33,24 @@ final class MigrateFresh extends Command
*/
public function handle()
{
$originalTenant = tenancy()->getTenant();
$this->info('Dropping tables.');
tenancy()->all($this->option('tenants'))->each(function ($tenant) {
$this->line("Tenant: {$tenant->id}");
tenancy()->initialize($tenant);
$tenant->run(function ($tenant) {
$this->info('Dropping tables.');
$this->call('db:wipe', array_filter([
'--database' => $tenant->getConnectionName(),
'--force' => true,
]));
$this->call('db:wipe', array_filter([
'--database' => $tenant->getConnectionName(),
'--force' => true,
]));
$this->call('tenants:migrate', [
'--tenants' => [$tenant->id],
]);
tenancy()->end();
$this->info('Migrating.');
$this->callSilent('tenants:migrate', [
'--tenants' => [$tenant->id],
'--force' => true,
]);
});
});
$this->info('Done.');
if ($originalTenant) {
tenancy()->initialize($originalTenant);
}
}
}

View file

@ -49,21 +49,15 @@ class Rollback extends RollbackCommand
return;
}
$originalTenant = tenancy()->getTenant();
tenancy()->all($this->option('tenants'))->each(function ($tenant) {
$this->line("Tenant: {$tenant['id']}");
$this->input->setOption('database', $tenant->getConnectionName());
tenancy()->initialize($tenant);
// Migrate
parent::handle();
tenancy()->endTenancy();
$tenant->run(function () {
// Rollback
parent::handle();
});
});
if ($originalTenant) {
tenancy()->initialize($originalTenant);
}
}
}

View file

@ -43,25 +43,25 @@ class Seed extends SeedCommand
*/
public function handle()
{
foreach (config('tenancy.seeder_parameters') as $parameter => $value) {
if (! $this->input->hasParameterOption($parameter)) {
$this->input->setOption(ltrim($parameter, '-'), $value);
}
}
if (! $this->confirmToProceed()) {
return;
}
$originalTenant = tenancy()->getTenant();
tenancy()->all($this->option('tenants'))->each(function ($tenant) {
$this->line("Tenant: {$tenant['id']}");
$this->input->setOption('database', $tenant->getConnectionName());
tenancy()->initialize($tenant);
// Seed
parent::handle();
tenancy()->endTenancy();
$tenant->run(function () {
// Seed
parent::handle();
});
});
if ($originalTenant) {
tenancy()->initialize($originalTenant);
}
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Contracts\Future;
use Stancl\Tenancy\Tenant;
/**
* This interface will be part of the StorageDriver interface in 3.x.
*/
interface CanDeleteKeys
{
/**
* Delete keys from the storage.
*
* @param string[] $keys
* @return void
*/
public function deleteMany(array $keys, Tenant $tenant = null): void;
}

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Contracts\Future;
use Stancl\Tenancy\Exceptions\TenantDoesNotExistException;
use Stancl\Tenancy\Tenant;
/**
* This interface *might* be part of the StorageDriver interface in 3.x.
*/
interface CanFindByAnyKey
{
/**
* Find a tenant using an arbitrary key.
*
* @param string $key
* @param mixed $value
* @return Tenant
* @throws TenantDoesNotExistException
*/
public function findBy(string $key, $value): Tenant;
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Contracts\Future;
/**
* This interface *might* be part of the TenantDatabaseManager interface in 3.x.
*/
interface CanSetConnection
{
public function setConnection(string $connection): void;
}

View file

@ -4,8 +4,12 @@ declare(strict_types=1);
namespace Stancl\Tenancy;
use Closure;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\DatabaseManager as BaseDatabaseManager;
use Illuminate\Foundation\Application;
use Stancl\Tenancy\Contracts\Future\CanSetConnection;
use Stancl\Tenancy\Contracts\TenantCannotBeCreatedException;
use Stancl\Tenancy\Contracts\TenantDatabaseManager;
use Stancl\Tenancy\Exceptions\DatabaseManagerNotRegisteredException;
use Stancl\Tenancy\Exceptions\TenantDatabaseAlreadyExistsException;
@ -23,6 +27,9 @@ class DatabaseManager
/** @var BaseDatabaseManager */
protected $database;
/** @var TenantManager */
protected $tenancy;
public function __construct(Application $app, BaseDatabaseManager $database)
{
$this->app = $app;
@ -30,6 +37,19 @@ class DatabaseManager
$this->originalDefaultConnectionName = $app['config']['database.default'];
}
/**
* Set the TenantManager instance, used to dispatch tenancy events.
*
* @param TenantManager $tenantManager
* @return self
*/
public function withTenantManager(TenantManager $tenantManager): self
{
$this->tenancy = $tenantManager;
return $this;
}
/**
* Connect to a tenant's database.
*
@ -39,6 +59,7 @@ class DatabaseManager
public function connect(Tenant $tenant)
{
$this->createTenantConnection($tenant->getDatabaseName(), $tenant->getConnectionName());
$this->setDefaultConnection($tenant->getConnectionName());
$this->switchConnection($tenant->getConnectionName());
}
@ -49,7 +70,21 @@ class DatabaseManager
*/
public function reconnect()
{
// Opposite order to connect() because we don't
// want to ever purge the central connection
$this->switchConnection($this->originalDefaultConnectionName);
$this->setDefaultConnection($this->originalDefaultConnectionName);
}
/**
* Change the default database connection config.
*
* @param string $connection
* @return void
*/
public function setDefaultConnection(string $connection)
{
$this->app['config']['database.default'] = $connection;
}
/**
@ -67,7 +102,9 @@ class DatabaseManager
// Change database name.
$databaseName = $this->getDriver($connectionName) === 'sqlite' ? database_path($databaseName) : $databaseName;
$this->app['config']["database.connections.$connectionName.database"] = $databaseName;
$separateBy = $this->separateBy($connectionName);
$this->app['config']["database.connections.$connectionName.$separateBy"] = $databaseName;
}
/**
@ -102,7 +139,6 @@ class DatabaseManager
*/
public function switchConnection(string $connection)
{
$this->app['config']['database.default'] = $connection;
$this->database->purge();
$this->database->reconnect($connection);
$this->database->setDefaultConnection($connection);
@ -114,6 +150,8 @@ class DatabaseManager
* @param Tenant $tenant
* @return void
* @throws TenantCannotBeCreatedException
* @throws DatabaseManagerNotRegisteredException
* @throws TenantDatabaseAlreadyExistsException
*/
public function ensureTenantCanBeCreated(Tenant $tenant): void
{
@ -126,18 +164,43 @@ class DatabaseManager
* Create a database for a tenant.
*
* @param Tenant $tenant
* @param ShouldQueue[]|callable[] $afterCreating
* @return void
* @throws DatabaseManagerNotRegisteredException
*/
public function createDatabase(Tenant $tenant)
public function createDatabase(Tenant $tenant, array $afterCreating = [])
{
$database = $tenant->getDatabaseName();
$manager = $this->getTenantDatabaseManager($tenant);
$afterCreating = array_merge(
$afterCreating,
$this->tenancy->event('database.creating', $database, $tenant)
);
if ($this->app['config']['tenancy.queue_database_creation'] ?? false) {
QueuedTenantDatabaseCreator::dispatch($manager, $database);
$chain = [];
foreach ($afterCreating as $item) {
if (is_string($item) && class_exists($item)) {
$chain[] = new $item($tenant); // Classes are instantiated and given $tenant
} elseif ($item instanceof ShouldQueue) {
$chain[] = $item;
}
}
QueuedTenantDatabaseCreator::withChain($chain)->dispatch($manager, $database);
} else {
$manager->createDatabase($database);
foreach ($afterCreating as $item) {
if (is_object($item) && ! $item instanceof Closure) {
$item->handle($tenant);
} else {
$item($tenant);
}
}
}
$this->tenancy->event('database.created', $database, $tenant);
}
/**
@ -145,17 +208,22 @@ class DatabaseManager
*
* @param Tenant $tenant
* @return void
* @throws DatabaseManagerNotRegisteredException
*/
public function deleteDatabase(Tenant $tenant)
{
$database = $tenant->getDatabaseName();
$manager = $this->getTenantDatabaseManager($tenant);
$this->tenancy->event('database.deleting', $database, $tenant);
if ($this->app['config']['tenancy.queue_database_deletion'] ?? false) {
QueuedTenantDatabaseDeleter::dispatch($manager, $database);
} else {
$manager->deleteDatabase($database);
}
$this->tenancy->event('database.deleted', $database, $tenant);
}
/**
@ -163,10 +231,11 @@ class DatabaseManager
*
* @param Tenant $tenant
* @return TenantDatabaseManager
* @throws DatabaseManagerNotRegisteredException
*/
protected function getTenantDatabaseManager(Tenant $tenant): TenantDatabaseManager
public function getTenantDatabaseManager(Tenant $tenant): TenantDatabaseManager
{
$driver = $this->getDriver($this->getBaseConnection($tenant->getConnectionName()));
$driver = $this->getDriver($this->getBaseConnection($connectionName = $tenant->getConnectionName()));
$databaseManagers = $this->app['config']['tenancy.database_managers'];
@ -174,6 +243,28 @@ class DatabaseManager
throw new DatabaseManagerNotRegisteredException($driver);
}
return $this->app[$databaseManagers[$driver]];
$databaseManager = $this->app[$databaseManagers[$driver]];
if ($connectionName !== 'tenant' && $databaseManager instanceof CanSetConnection) {
$databaseManager->setConnection($connectionName);
}
return $databaseManager;
}
/**
* What key on the connection config should be used to separate tenants.
*
* @param string $connectionName
* @return string
*/
public function separateBy(string $connectionName): string
{
if ($this->getDriver($this->getBaseConnection($connectionName)) === 'pgsql'
&& $this->app['config']['tenancy.database.separate_by'] === 'schema') {
return 'schema';
}
return 'database';
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Exceptions;
use Exception;
class NotImplementedException extends Exception
{
public function __construct($class, $method, $extra)
{
parent::__construct("The $class class does not implement the $method method. $extra");
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Exceptions;
use Exception;
class TenantDatabaseDoesNotExistException extends Exception
{
public function __construct($database)
{
parent::__construct("Database $database does not exist.");
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Exceptions;
use Exception;
class TenantDoesNotExistException extends Exception
{
public function __construct(string $id, string $key = 'id')
{
$this->message = "Tenant with this $key does not exist: $id";
}
}

View file

@ -7,6 +7,7 @@ namespace Stancl\Tenancy\Features;
use Laravel\Telescope\IncomingEntry;
use Laravel\Telescope\Telescope;
use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Middleware\PreventAccessFromTenantDomains;
use Stancl\Tenancy\TenantManager;
class TelescopeTags implements Feature
@ -30,7 +31,15 @@ class TelescopeTags implements Feature
Telescope::tag(function (IncomingEntry $entry) {
$tags = $this->getTags($entry);
if (in_array('tenancy', optional(request()->route())->middleware() ?? [])) {
if (! request()->route()) {
return $tags;
}
$tenantRoute = PreventAccessFromTenantDomains::routeHasMiddleware(request()->route(), 'tenancy')
|| PreventAccessFromTenantDomains::routeHasMiddleware(request()->route(), 'universal');
// Don't do anything if we're visiting a universal route on a central domain
if ($tenantRoute && tenancy()->initialized) {
$tags = array_merge($tags, [
'tenant:' . tenant('id'),
]);

View file

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Features;
use Illuminate\Config\Repository;
use Illuminate\Contracts\Config\Repository;
use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Tenant;
use Stancl\Tenancy\TenantManager;

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Features;
use Illuminate\Config\Repository;
use Illuminate\Support\Facades\Date;
use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Tenant;
use Stancl\Tenancy\TenantManager;
class Timestamps implements Feature
{
/** @var Repository */
protected $config;
public function __construct(Repository $config)
{
$this->config = $config;
}
public function bootstrap(TenantManager $tenantManager): void
{
$tenantManager->hook('tenant.creating', function ($tm, Tenant $tenant) {
$tenant->with('created_at', $this->now());
$tenant->with('updated_at', $this->now());
});
$tenantManager->hook('tenant.updating', function ($tm, Tenant $tenant) {
$tenant->with('updated_at', $this->now());
});
$tenantManager->hook('tenant.softDeleting', function ($tm, Tenant $tenant) {
$tenant->with('deleted_at', $this->now());
});
}
public function now(): string
{
// Add this key to your tenancy.php config if you need to change the format.
return Date::now()->format(
$this->config->get('tenancy.timestamp_format') ?? 'c' // ISO 8601
);
}
}

View file

@ -16,12 +16,16 @@ class QueuedTenantDatabaseMigrator implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/** @var Tenant */
protected $tenant;
/** @var string */
protected $tenantId;
public function __construct(Tenant $tenant)
/** @var array */
protected $migrationParameters = [];
public function __construct(Tenant $tenant, $migrationParameters = [])
{
$this->tenant = $tenant;
$this->tenantId = $tenant->id;
$this->migrationParameters = $migrationParameters;
}
/**
@ -32,7 +36,7 @@ class QueuedTenantDatabaseMigrator implements ShouldQueue
public function handle()
{
Artisan::call('tenants:migrate', [
'--tenants' => [$this->tenant->id],
]);
'--tenants' => [$this->tenantId],
] + $this->migrationParameters);
}
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Artisan;
use Stancl\Tenancy\Tenant;
class QueuedTenantDatabaseSeeder implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/** @var string */
protected $tenantId;
public function __construct(Tenant $tenant)
{
$this->tenantId = $tenant->id;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
Artisan::call('tenants:seed', [
'--tenants' => [$this->tenantId],
]);
}
}

View file

@ -28,10 +28,16 @@ class InitializeTenancy
*/
public function handle($request, Closure $next)
{
try {
tenancy()->init($request->getHost());
} catch (TenantCouldNotBeIdentifiedException $e) {
($this->onFail)($e);
if (tenancy()->initialized) {
return $next($request);
}
if (! in_array($request->getHost(), config('tenancy.exempt_domains', []), true)) {
try {
tenancy()->init($request->getHost());
} catch (TenantCouldNotBeIdentifiedException $e) {
return ($this->onFail)($e, $request, $next);
}
}
return $next($request);

View file

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Middleware;
use Closure;
use Illuminate\Http\Request;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedException;
class InitializeTenancyByRequestData
{
/** @var string|null */
protected $header;
/** @var string|null */
protected $queryParameter;
/** @var callable */
protected $onFail;
public function __construct(?string $header = 'X-Tenant', ?string $queryParameter = 'tenant', callable $onFail = null)
{
$this->header = $header;
$this->queryParameter = $queryParameter;
$this->onFail = $onFail ?? function ($e) {
throw $e;
};
}
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
if ($request->method() !== 'OPTIONS') {
try {
$this->initializeTenancy($request);
} catch (TenantCouldNotBeIdentifiedException $e) {
return ($this->onFail)($e, $request, $next);
}
}
return $next($request);
}
protected function initializeTenancy(Request $request)
{
if (tenancy()->initialized) {
return;
}
$tenant = null;
if ($this->header && $request->hasHeader($this->header)) {
$tenant = $request->header($this->header);
} elseif ($this->queryParameter && $request->has($this->queryParameter)) {
$tenant = $request->get($this->queryParameter);
}
if (! $tenant) {
throw new TenantCouldNotBeIdentifiedException($request->getHost());
}
tenancy()->initialize(tenancy()->find($tenant));
}
}

View file

@ -5,13 +5,24 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Middleware;
use Closure;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Route as Router;
/**
* Prevent access to non-tenant routes from domains that are not exempt from tenancy.
* = allow access to central routes only from routes listed in tenancy.exempt_routes.
* Prevent access from tenant domains to central routes and vice versa.
*/
class PreventAccessFromTenantDomains
{
/** @var callable */
protected $central404;
public function __construct(callable $central404 = null)
{
$this->central404 = $central404 ?? function () {
return 404;
};
}
/**
* Handle an incoming request.
*
@ -21,21 +32,44 @@ class PreventAccessFromTenantDomains
*/
public function handle($request, Closure $next)
{
// If the route is universal, always let the request pass.
if ($this->routeHasMiddleware($request->route(), 'universal')) {
return $next($request);
}
// If the domain is not in exempt domains, it's a tenant domain.
// Tenant domains can't have routes without tenancy middleware.
$isExemptDomain = in_array($request->getHost(), config('tenancy.exempt_domains'));
$isTenantDomain = ! $isExemptDomain;
$isTenantRoute = in_array('tenancy', $request->route()->middleware());
$isTenantRoute = $this->routeHasMiddleware($request->route(), 'tenancy');
if ($isTenantDomain && ! $isTenantRoute) { // accessing web routes from tenant domains
return redirect(config('tenancy.home_url'));
}
if ($isExemptDomain && $isTenantRoute) { // accessing tenant routes on web domains
abort(404);
return ($this->central404)($request, $next);
}
return $next($request);
}
public static function routeHasMiddleware(Route $route, $middleware): bool
{
if (in_array($middleware, $route->middleware(), true)) {
return true;
}
// Loop one level deep and check if the route's middleware
// groups have a `tenancy` middleware group inside them
$middlewareGroups = Router::getMiddlewareGroups();
foreach ($route->gatherMiddleware() as $inner) {
if (! $inner instanceof Closure && isset($middlewareGroups[$inner]) && in_array($middleware, $middlewareGroups[$inner], true)) {
return true;
}
}
return false;
}
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\StorageDrivers\Database;
use Closure;
use Illuminate\Cache\CacheManager;
use Illuminate\Cache\Repository as CacheRepository;
use Illuminate\Config\Repository as ConfigRepository;
class CachedTenantResolver
{
/** @var CacheRepository */
protected $cache;
/** @var ConfigRepository */
protected $config;
public function __construct(CacheManager $cacheManager, ConfigRepository $config)
{
$this->cache = $cacheManager->store($config->get('tenancy.storage_drivers.db.cache_store'));
$this->config = $config;
}
protected function ttl(): int
{
return $this->config->get('tenancy.storage_drivers.db.cache_ttl');
}
public function getTenantIdByDomain(string $domain, Closure $query): ?string
{
return $this->cache->remember('_tenancy_domain_to_id:' . $domain, $this->ttl(), $query);
}
public function getDataById(string $id, Closure $dataQuery): ?array
{
return $this->cache->remember('_tenancy_id_to_data:' . $id, $this->ttl(), $dataQuery);
}
public function getDomainsById(string $id, Closure $domainsQuery): ?array
{
return $this->cache->remember('_tenancy_id_to_domains:' . $id, $this->ttl(), $domainsQuery);
}
public function invalidateTenant(string $id): void
{
$this->invalidateTenantData($id);
$this->invalidateTenantDomains($id);
}
public function invalidateTenantData(string $id): void
{
$this->cache->forget('_tenancy_id_to_data:' . $id);
}
public function invalidateTenantDomains(string $id): void
{
$this->cache->forget('_tenancy_id_to_domains:' . $id);
}
public function invalidateDomainToIdMapping(array $domains): void
{
foreach ($domains as $domain) {
$this->cache->forget('_tenancy_domain_to_id:' . $domain);
}
}
}

View file

@ -4,40 +4,55 @@ declare(strict_types=1);
namespace Stancl\Tenancy\StorageDrivers\Database;
use Illuminate\Config\Repository as ConfigRepository;
use Illuminate\Database\Connection;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Contracts\Future\CanDeleteKeys;
use Stancl\Tenancy\Contracts\Future\CanFindByAnyKey;
use Stancl\Tenancy\Contracts\StorageDriver;
use Stancl\Tenancy\DatabaseManager;
use Stancl\Tenancy\Exceptions\DomainsOccupiedByOtherTenantException;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedException;
use Stancl\Tenancy\Exceptions\TenantDoesNotExistException;
use Stancl\Tenancy\Exceptions\TenantWithThisIdAlreadyExistsException;
use Stancl\Tenancy\StorageDrivers\Database\DomainModel as Domains;
use Stancl\Tenancy\StorageDrivers\Database\TenantModel as Tenants;
use Stancl\Tenancy\Tenant;
class DatabaseStorageDriver implements StorageDriver
class DatabaseStorageDriver implements StorageDriver, CanDeleteKeys, CanFindByAnyKey
{
/** @var Application */
protected $app;
/** @var \Illuminate\Database\Connection */
/** @var Connection */
protected $centralDatabase;
/** @var TenantRepository */
protected $tenants;
/** @var DomainRepository */
protected $domains;
/** @var CachedTenantResolver */
protected $cache;
/** @var Tenant The default tenant. */
protected $tenant;
public function __construct(Application $app)
public function __construct(Application $app, ConfigRepository $config, CachedTenantResolver $cache)
{
$this->app = $app;
$this->cache = $cache;
$this->centralDatabase = $this->getCentralConnection();
$this->tenants = new TenantRepository($config);
$this->domains = new DomainRepository($config);
}
/**
* Get the central database connection.
*
* @return \Illuminate\Database\Connection
* @return Connection
*/
public static function getCentralConnection(): \Illuminate\Database\Connection
public static function getCentralConnection(): Connection
{
return DB::connection(static::getCentralConnectionName());
}
@ -49,7 +64,16 @@ class DatabaseStorageDriver implements StorageDriver
public function findByDomain(string $domain): Tenant
{
$id = $this->getTenantIdByDomain($domain);
$query = function () use ($domain) {
return $this->domains->getTenantIdByDomain($domain);
};
if ($this->usesCache()) {
$id = $this->cache->getTenantIdByDomain($domain, $query);
} else {
$id = $query();
}
if (! $id) {
throw new TenantCouldNotBeIdentifiedException($domain);
}
@ -59,24 +83,58 @@ class DatabaseStorageDriver implements StorageDriver
public function findById(string $id): Tenant
{
return Tenant::fromStorage(Tenants::find($id)->decoded())
->withDomains($this->getTenantDomains($id));
$dataQuery = function () use ($id) {
$data = $this->tenants->find($id);
return $data ? $this->tenants->decodeData($data) : null;
};
$domainsQuery = function () use ($id) {
return $this->domains->getTenantDomains($id);
};
if ($this->usesCache()) {
$data = $this->cache->getDataById($id, $dataQuery);
$domains = $this->cache->getDomainsById($id, $domainsQuery);
} else {
$data = $dataQuery();
$domains = $domainsQuery();
}
if (! $data) {
throw new TenantDoesNotExistException($id);
}
return Tenant::fromStorage($data)
->withDomains($domains);
}
protected function getTenantDomains($id)
/**
* Find a tenant using an arbitrary key.
*
* @param string $key
* @param mixed $value
* @return Tenant
* @throws TenantDoesNotExistException
*/
public function findBy(string $key, $value): Tenant
{
return Domains::where('tenant_id', $id)->get()->map(function ($model) {
return $model->domain;
})->toArray();
$tenant = $this->tenants->findBy($key, $value);
if (! $tenant) {
throw new TenantDoesNotExistException($value, $key);
}
return Tenant::fromStorage($this->tenants->decodeData($tenant))
->withDomains($this->domains->getTenantDomains($tenant['id']));
}
public function ensureTenantCanBeCreated(Tenant $tenant): void
{
if (Tenants::find($tenant->id)) {
if ($this->tenants->exists($tenant)) {
throw new TenantWithThisIdAlreadyExistsException($tenant->id);
}
if (Domains::whereIn('domain', $tenant->domains)->exists()) {
if ($this->domains->occupied($tenant->domains)) {
throw new DomainsOccupiedByOtherTenantException;
}
}
@ -88,52 +146,43 @@ class DatabaseStorageDriver implements StorageDriver
return $this;
}
public function getTenantIdByDomain(string $domain): ?string
{
return Domains::where('domain', $domain)->first()->tenant_id ?? null;
}
public function createTenant(Tenant $tenant): void
{
$this->centralDatabase->transaction(function () use ($tenant) {
Tenants::create(['id' => $tenant->id, 'data' => json_encode($tenant->data)])->toArray();
$domainData = [];
foreach ($tenant->domains as $domain) {
$domainData[] = ['domain' => $domain, 'tenant_id' => $tenant->id];
}
Domains::insert($domainData);
$this->tenants->insert($tenant);
$this->domains->insertTenantDomains($tenant);
});
}
public function updateTenant(Tenant $tenant): void
{
$this->centralDatabase->transaction(function () use ($tenant) {
Tenants::find($tenant->id)->putMany($tenant->data);
$originalDomains = $this->domains->getTenantDomains($tenant);
$original_domains = Domains::where('tenant_id', $tenant->id)->get()->map(function ($model) {
return $model->domain;
})->toArray();
$deleted_domains = array_diff($original_domains, $tenant->domains);
$this->centralDatabase->transaction(function () use ($tenant, $originalDomains) {
$this->tenants->updateTenant($tenant);
Domains::whereIn('domain', $deleted_domains)->delete();
foreach ($tenant->domains as $domain) {
Domains::firstOrCreate([
'tenant_id' => $tenant->id,
'domain' => $domain,
]);
}
$this->domains->updateTenantDomains($tenant, $originalDomains);
});
if ($this->usesCache()) {
$this->cache->invalidateTenant($tenant->id);
$this->cache->invalidateDomainToIdMapping($originalDomains);
}
}
public function deleteTenant(Tenant $tenant): void
{
$originalDomains = $this->domains->getTenantDomains($tenant);
$this->centralDatabase->transaction(function () use ($tenant) {
Tenants::find($tenant->id)->delete();
Domains::where('tenant_id', $tenant->id)->delete();
$this->tenants->where('id', $tenant->id)->delete();
$this->domains->where('tenant_id', $tenant->id)->delete();
});
if ($this->usesCache()) {
$this->cache->invalidateTenant($tenant->id);
$this->cache->invalidateDomainToIdMapping($originalDomains);
}
}
/**
@ -144,8 +193,9 @@ class DatabaseStorageDriver implements StorageDriver
*/
public function all(array $ids = []): array
{
return Tenants::getAllTenants($ids)->map(function ($data) {
return Tenant::fromStorage($data)->withDomains($this->getTenantDomains($data['id']));
return $this->tenants->all($ids)->map(function ($data) {
return Tenant::fromStorage($data)
->withDomains($this->domains->getTenantDomains($data['id']));
})->toArray();
}
@ -161,27 +211,46 @@ class DatabaseStorageDriver implements StorageDriver
public function get(string $key, Tenant $tenant = null)
{
$tenant = $tenant ?? $this->currentTenant();
return Tenants::find($tenant->id)->get($key);
return $this->tenants->get($key, $tenant ?? $this->currentTenant());
}
public function getMany(array $keys, Tenant $tenant = null): array
{
$tenant = $tenant ?? $this->currentTenant();
return Tenants::find($tenant->id)->getMany($keys);
return $this->tenants->getMany($keys, $tenant ?? $this->currentTenant());
}
public function put(string $key, $value, Tenant $tenant = null): void
{
$tenant = $tenant ?? $this->currentTenant();
Tenants::find($tenant->id)->put($key, $value);
$this->tenants->put($key, $value, $tenant);
if ($this->usesCache()) {
$this->cache->invalidateTenantData($tenant->id);
}
}
public function putMany(array $kvPairs, Tenant $tenant = null): void
{
$tenant = $tenant ?? $this->currentTenant();
Tenants::find($tenant->id)->putMany($kvPairs);
$this->tenants->putMany($kvPairs, $tenant);
if ($this->usesCache()) {
$this->cache->invalidateTenantData($tenant->id);
}
}
public function deleteMany(array $keys, Tenant $tenant = null): void
{
$tenant = $tenant ?? $this->currentTenant();
$this->tenants->deleteMany($keys, $tenant);
if ($this->usesCache()) {
$this->cache->invalidateTenantData($tenant->id);
}
}
public function usesCache(): bool
{
return $this->app['config']['tenancy.storage_drivers.db.cache_store'] !== null;
}
}

View file

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\StorageDrivers\Database;
use Illuminate\Database\Eloquent\Model;
/**
* @internal Class is subject to breaking changes in minor and patch versions.
*/
class DomainModel extends Model
{
use CentralConnection;
protected $guarded = [];
protected $primaryKey = 'domain';
public $incrementing = false;
public $timestamps = false;
public function getTable()
{
return config('tenancy.storage_drivers.db.table_names.DomainModel', 'domains');
}
}

View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\StorageDrivers\Database;
use Illuminate\Config\Repository as ConfigRepository;
use Stancl\Tenancy\Tenant;
class DomainRepository extends Repository
{
public function getTenantIdByDomain(string $domain): ?string
{
return $this->where('domain', $domain)->first()->tenant_id ?? null;
}
public function occupied(array $domains): bool
{
return $this->whereIn('domain', $domains)->exists();
}
public function getTenantDomains($tenant)
{
$id = $tenant instanceof Tenant ? $tenant->id : $tenant;
return $this->where('tenant_id', $id)->get('domain')->pluck('domain')->all();
}
public function insertTenantDomains(Tenant $tenant)
{
$this->insert(array_map(function ($domain) use ($tenant) {
return ['domain' => $domain, 'tenant_id' => $tenant->id];
}, $tenant->domains));
}
public function updateTenantDomains(Tenant $tenant, array $originalDomains)
{
$deletedDomains = array_diff($originalDomains, $tenant->domains);
$newDomains = array_diff($tenant->domains, $originalDomains);
$this->whereIn('domain', $deletedDomains)->delete();
foreach ($newDomains as $domain) {
$this->insert([
'tenant_id' => $tenant->id,
'domain' => $domain,
]);
}
}
public function getTable(ConfigRepository $config)
{
return $config->get('tenancy.storage_drivers.db.table_names.DomainModel') // legacy
?? $config->get('tenancy.storage_drivers.db.table_names.domains')
?? 'domains';
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\StorageDrivers\Database;
use Illuminate\Config\Repository as ConfigRepository;
use Illuminate\Database\Connection;
use Illuminate\Database\Query\Builder;
/** @mixin Builder */
abstract class Repository
{
/** @var Connection */
public $database;
/** @var string */
protected $tableName;
/** @var Builder */
private $table;
public function __construct(ConfigRepository $config)
{
$this->database = DatabaseStorageDriver::getCentralConnection();
$this->tableName = $this->getTable($config);
$this->table = $this->database->table($this->tableName);
}
public function table()
{
return $this->table->newQuery()->from($this->tableName);
}
abstract public function getTable(ConfigRepository $config);
public function __call($method, $parameters)
{
return $this->table()->$method(...$parameters);
}
}

View file

@ -1,124 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\StorageDrivers\Database;
use Illuminate\Database\Eloquent\Model;
/**
* @internal Class is subject to breaking changes in minor and patch versions.
*/
class TenantModel extends Model
{
use CentralConnection;
protected $guarded = [];
protected $primaryKey = 'id';
public $incrementing = false;
public $timestamps = false;
public function getTable()
{
return config('tenancy.storage_drivers.db.table_names.TenantModel', 'tenants');
}
public static function dataColumn()
{
return config('tenancy.storage_drivers.db.data_column', 'data');
}
public static function customColumns()
{
return config('tenancy.storage_drivers.db.custom_columns', []);
}
public static function getAllTenants(array $ids)
{
$tenants = $ids ? static::findMany($ids) : static::all();
return $tenants->map([__CLASS__, 'decodeData'])->toBase();
}
public function decoded()
{
return static::decodeData($this);
}
/**
* Return a tenant array with data decoded into separate keys.
*
* @param self|array $tenant
* @return array
*/
public static function decodeData($tenant)
{
$tenant = $tenant instanceof self ? (array) $tenant->attributes : $tenant;
$decoded = json_decode($tenant[$dataColumn = static::dataColumn()], true);
foreach ($decoded as $key => $value) {
$tenant[$key] = $value;
}
// If $tenant[$dataColumn] has been overriden by a value, don't delete the key.
if (! array_key_exists($dataColumn, $decoded)) {
unset($tenant[$dataColumn]);
}
return $tenant;
}
public function getFromData(string $key)
{
$this->dataArray = $this->dataArray ?? json_decode($this->{$this->dataColumn()}, true);
return $this->dataArray[$key] ?? null;
}
public function get(string $key)
{
return $this->attributes[$key] ?? $this->getFromData($key) ?? null;
}
public function getMany(array $keys): array
{
return array_reduce($keys, function ($result, $key) {
$result[$key] = $this->get($key);
return $result;
}, []);
}
public function put(string $key, $value)
{
if (in_array($key, $this->customColumns())) {
$this->update([$key => $value]);
} else {
$obj = json_decode($this->{$this->dataColumn()});
$obj->$key = $value;
$this->update([$this->dataColumn() => json_encode($obj)]);
}
return $value;
}
public function putMany(array $kvPairs)
{
$customColumns = [];
$jsonObj = json_decode($this->{$this->dataColumn()});
foreach ($kvPairs as $key => $value) {
if (in_array($key, $this->customColumns())) {
$customColumns[$key] = $value;
continue;
}
$jsonObj->$key = $value;
}
$this->update(array_merge($customColumns, [
$this->dataColumn() => json_encode($jsonObj),
]));
}
}

View file

@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\StorageDrivers\Database;
use Illuminate\Config\Repository as ConfigRepository;
use Stancl\Tenancy\Tenant;
use stdClass;
class TenantRepository extends Repository
{
public function all($ids = [])
{
if ($ids) {
$data = $this->whereIn('id', $ids)->get();
} else {
$data = $this->table()->get();
}
return $data->map(function (stdClass $obj) {
return $this->decodeData((array) $obj);
});
}
public function find($tenant)
{
return (array) $this->table()->find(
$tenant instanceof Tenant ? $tenant->id : $tenant
);
}
public function findBy(string $key, $value)
{
if (in_array($key, static::customColumns())) {
return (array) $this->table()->where($key, $value)->first();
}
return (array) $this->table()->where(
static::dataColumn() . '->' . $key,
$value
)->first();
}
public function updateTenant(Tenant $tenant)
{
$this->putMany($tenant->data, $tenant);
}
public function exists(Tenant $tenant)
{
return $this->where('id', $tenant->id)->exists();
}
public function get(string $key, Tenant $tenant)
{
return $this->decodeFreshDataForTenant($tenant)[$key] ?? null;
}
public function getMany(array $keys, Tenant $tenant)
{
$decodedData = $this->decodeFreshDataForTenant($tenant);
$result = [];
foreach ($keys as $key) {
$result[$key] = $decodedData[$key] ?? null;
}
return $result;
}
public function put(string $key, $value, Tenant $tenant)
{
$record = $this->where('id', $tenant->id);
if (in_array($key, static::customColumns())) {
$record->update([$key => $value]);
} else {
$data = json_decode($record->first()->{static::dataColumn()}, true);
$data[$key] = $value;
$record->update([static::dataColumn() => $data]);
}
}
public function putMany(array $kvPairs, Tenant $tenant)
{
$record = $this->where('id', $tenant->id);
$data = [];
$jsonData = json_decode($record->first()->{static::dataColumn()}, true);
foreach ($kvPairs as $key => $value) {
if (in_array($key, static::customColumns())) {
$data[$key] = $value;
continue;
} else {
$jsonData[$key] = $value;
}
}
$data[static::dataColumn()] = json_encode($jsonData);
$record->update($data);
}
public function deleteMany(array $keys, Tenant $tenant)
{
$record = $this->where('id', $tenant->id);
$data = [];
$jsonData = json_decode($record->first(static::dataColumn())->data, true);
foreach ($keys as $key) {
if (in_array($key, static::customColumns())) {
$data[$key] = null;
continue;
} else {
unset($jsonData[$key]);
}
}
$data[static::dataColumn()] = json_encode($jsonData);
$record->update($data);
}
public function decodeFreshDataForTenant(Tenant $tenant): array
{
return $this->decodeData(
(array) $this->table()->where('id', $tenant->id)->first()
);
}
public static function decodeData(array $columns): array
{
$dataColumn = static::dataColumn();
$decoded = json_decode($columns[$dataColumn], true);
$columns = array_merge($columns, $decoded);
// If $columns[$dataColumn] has been overriden by a value, don't delete the key.
if (! array_key_exists($dataColumn, $decoded)) {
unset($columns[$dataColumn]);
}
return $columns;
}
public function insert(Tenant $tenant)
{
$this->table()->insert(array_merge(
$this->encodeData($tenant->data),
['id' => $tenant->id]
));
}
public static function encodeData(array $data): array
{
$result = [];
foreach (array_intersect(static::customColumns(), array_keys($data)) as $customColumn) {
$result[$customColumn] = $data[$customColumn];
unset($data[$customColumn]);
}
$result = array_merge($result, [static::dataColumn() => json_encode($data)]);
return $result;
}
public static function customColumns(): array
{
return config('tenancy.storage_drivers.db.custom_columns', []);
}
public static function dataColumn(): string
{
return config('tenancy.storage_drivers.db.data_column', 'data');
}
public function getTable(ConfigRepository $config)
{
return $config->get('tenancy.storage_drivers.db.table_names.TenantModel') // legacy
?? $config->get('tenancy.storage_drivers.db.table_names.tenants')
?? 'tenants';
}
}

View file

@ -6,13 +6,15 @@ namespace Stancl\Tenancy\StorageDrivers;
use Illuminate\Contracts\Redis\Factory as Redis;
use Illuminate\Foundation\Application;
use Stancl\Tenancy\Contracts\Future\CanDeleteKeys;
use Stancl\Tenancy\Contracts\StorageDriver;
use Stancl\Tenancy\Exceptions\DomainsOccupiedByOtherTenantException;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedException;
use Stancl\Tenancy\Exceptions\TenantDoesNotExistException;
use Stancl\Tenancy\Exceptions\TenantWithThisIdAlreadyExistsException;
use Stancl\Tenancy\Tenant;
class RedisStorageDriver implements StorageDriver
class RedisStorageDriver implements StorageDriver, CanDeleteKeys
{
/** @var Application */
protected $app;
@ -73,7 +75,13 @@ class RedisStorageDriver implements StorageDriver
public function findById(string $id): Tenant
{
return $this->makeTenant($this->redis->hgetall("tenants:$id"));
$data = $this->redis->hgetall("tenants:$id");
if (! $data) {
throw new TenantDoesNotExistException($id);
}
return $this->makeTenant($data);
}
public function getTenantIdByDomain(string $domain): ?string
@ -223,4 +231,11 @@ class RedisStorageDriver implements StorageDriver
$this->redis->hmset("tenants:{$tenant->id}", $kvPairs);
}
public function deleteMany(array $keys, Tenant $tenant = null): void
{
$tenant = $tenant ?? $this->tenant();
$this->redis->hdel("tenants:{$tenant->id}", ...$keys);
}
}

View file

@ -6,6 +6,7 @@ namespace Stancl\Tenancy\TenancyBootstrappers;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\DatabaseManager;
use Stancl\Tenancy\Exceptions\TenantDatabaseDoesNotExistException;
use Stancl\Tenancy\Tenant;
class DatabaseTenancyBootstrapper implements TenancyBootstrapper
@ -20,6 +21,11 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper
public function start(Tenant $tenant)
{
$database = $tenant->getDatabaseName();
if (! $this->database->getTenantDatabaseManager($tenant)->databaseExists($database)) {
throw new TenantDatabaseDoesNotExistException($database);
}
$this->database->connect($tenant);
}

View file

@ -4,7 +4,8 @@ declare(strict_types=1);
namespace Stancl\Tenancy\TenancyBootstrappers;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Storage;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Tenant;
@ -38,26 +39,32 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
$suffix = $this->app['config']['tenancy.filesystem.suffix_base'] . $tenant->id;
// storage_path()
$this->app->useStoragePath($this->originalPaths['storage'] . "/{$suffix}");
if ($this->app['config']['tenancy.filesystem.suffix_storage_path'] ?? true) {
$this->app->useStoragePath($this->originalPaths['storage'] . "/{$suffix}");
}
// asset()
if ($this->originalPaths['asset_url']) {
$this->app['config']['app.asset_url'] = ($this->originalPaths['asset_url'] ?? $this->app['config']['app.url']) . "/$suffix";
$this->app['url']->setAssetRoot($this->app['config']['app.asset_url']);
} else {
$this->app['url']->setAssetRoot($this->app['url']->route('stancl.tenancy.asset', ['path' => '']));
if ($this->app['config']['tenancy.filesystem.asset_helper_tenancy'] ?? true) {
if ($this->originalPaths['asset_url']) {
$this->app['config']['app.asset_url'] = ($this->originalPaths['asset_url'] ?? $this->app['config']['app.url']) . "/$suffix";
$this->app['url']->setAssetRoot($this->app['config']['app.asset_url']);
} else {
$this->app['url']->setAssetRoot($this->app['url']->route('stancl.tenancy.asset', ['path' => '']));
}
}
// Storage facade
foreach ($this->app['config']['tenancy.filesystem.disks'] as $disk) {
$this->originalPaths['disks'][$disk] = Storage::disk($disk)->getAdapter()->getPathPrefix();
/** @var FilesystemAdapter $filesystemDisk */
$filesystemDisk = Storage::disk($disk);
$this->originalPaths['disks'][$disk] = $filesystemDisk->getAdapter()->getPathPrefix();
if ($root = str_replace('%storage_path%', storage_path(), $this->app['config']["tenancy.filesystem.root_override.{$disk}"])) {
Storage::disk($disk)->getAdapter()->setPathPrefix($root);
$filesystemDisk->getAdapter()->setPathPrefix($root);
} else {
$root = $this->app['config']["filesystems.disks.{$disk}.root"];
Storage::disk($disk)->getAdapter()->setPathPrefix($root . "/{$suffix}");
$filesystemDisk->getAdapter()->setPathPrefix($root . "/{$suffix}");
}
}
}
@ -73,7 +80,10 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
// Storage facade
foreach ($this->app['config']['tenancy.filesystem.disks'] as $disk) {
Storage::disk($disk)->getAdapter()->setPathPrefix($this->originalPaths['disks'][$disk]);
/** @var FilesystemAdapter $filesystemDisk */
$filesystemDisk = Storage::disk($disk);
$filesystemDisk->getAdapter()->setPathPrefix($this->originalPaths['disks'][$disk]);
}
}
}

View file

@ -4,7 +4,8 @@ declare(strict_types=1);
namespace Stancl\Tenancy\TenancyBootstrappers;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Config\Repository;
use Illuminate\Queue\QueueManager;
use Illuminate\Support\Testing\Fakes\QueueFake;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Tenant;
@ -14,19 +15,18 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
/** @var bool Has tenancy been started. */
public $started = false;
/** @var Application */
protected $app;
/** @var Repository */
protected $config;
public function __construct(Application $app)
public function __construct(Repository $config, QueueManager $queue)
{
$this->app = $app;
$this->config = $config;
$bootstrapper = &$this;
$queue = $this->app['queue'];
if (! $queue instanceof QueueFake) {
$queue->createPayloadUsing(function () use (&$bootstrapper) {
return $bootstrapper->getPayload();
$queue->createPayloadUsing(function ($connection) use (&$bootstrapper) {
return $bootstrapper->getPayload($connection);
});
}
}
@ -41,12 +41,16 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
$this->started = false;
}
public function getPayload()
public function getPayload(string $connection)
{
if (! $this->started) {
return [];
}
if ($this->config["queue.connections.$connection.central"]) {
return [];
}
$id = tenant('id');
return [

View file

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy\TenancyBootstrappers;
use Illuminate\Config\Repository;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Support\Facades\Redis;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Tenant;

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy;
use Illuminate\Cache\CacheManager;
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Stancl\Tenancy\TenancyBootstrappers\FilesystemTenancyBootstrapper;
@ -77,24 +78,52 @@ class TenancyServiceProvider extends ServiceProvider
__DIR__ . '/../assets/migrations/' => database_path('migrations'),
], 'migrations');
$this->loadRoutesFrom(__DIR__ . '/routes.php');
foreach ($this->app['config']['tenancy.global_middleware'] as $middleware) {
$this->app->make(Kernel::class)->prependMiddleware($middleware);
}
/*
* Since tenancy is initialized in the global middleware stack, this
* middleware group acts mostly as a 'flag' for the PreventAccess
* middleware to decide whether the request should be aborted.
*/
Route::middlewareGroup('tenancy', [
\Stancl\Tenancy\Middleware\InitializeTenancy::class,
/* Prevent access from tenant domains to central routes and vice versa. */
Middleware\PreventAccessFromTenantDomains::class,
]);
Route::middlewareGroup('universal', []);
$this->loadRoutesFrom(__DIR__ . '/routes.php');
$this->app->singleton('globalUrl', function ($app) {
$instance = clone $app['url'];
$instance->setAssetRoot($app[FilesystemTenancyBootstrapper::class]->originalPaths['asset_url']);
if ($app->bound(FilesystemTenancyBootstrapper::class)) {
$instance = clone $app['url'];
$instance->setAssetRoot($app[FilesystemTenancyBootstrapper::class]->originalPaths['asset_url']);
} else {
$instance = $app['url'];
}
return $instance;
});
// Queue tenancy
$this->app['events']->listen(\Illuminate\Queue\Events\JobProcessing::class, function ($event) {
if (array_key_exists('tenant_id', $event->job->payload())) {
tenancy()->initialize(tenancy()->find($event->job->payload()['tenant_id']));
$tenantId = $event->job->payload()['tenant_id'] ?? null;
// The job is not tenant-aware
if (! $tenantId) {
return;
}
// Tenancy is already initialized for the tenant (e.g. dispatchNow was used)
if (tenancy()->initialized && tenant('id') === $tenantId) {
return;
}
// Tenancy was either not initialized, or initialized for a different tenant.
// Therefore, we initialize it for the correct tenant.
tenancy()->initById($tenantId);
});
}
}

View file

@ -5,11 +5,15 @@ declare(strict_types=1);
namespace Stancl\Tenancy;
use ArrayAccess;
use Closure;
use Illuminate\Config\Repository;
use Illuminate\Foundation\Application;
use Illuminate\Support\Str;
use Illuminate\Support\Traits\ForwardsCalls;
use Stancl\Tenancy\Contracts\Future\CanDeleteKeys;
use Stancl\Tenancy\Contracts\StorageDriver;
use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
use Stancl\Tenancy\Exceptions\NotImplementedException;
use Stancl\Tenancy\Exceptions\TenantStorageException;
/**
@ -34,10 +38,10 @@ class Tenant implements ArrayAccess
*/
public $domains = [];
/** @var Application */
protected $app;
/** @var Repository */
protected $config;
/** @var StorageDriver */
/** @var StorageDriver|CanDeleteKeys */
protected $storage;
/** @var TenantManager */
@ -56,14 +60,13 @@ class Tenant implements ArrayAccess
/**
* Use new() if you don't want to swap dependencies.
*
* @param Application $app
* @param StorageDriver $storage
* @param TenantManager $tenantManager
* @param UniqueIdentifierGenerator $idGenerator
*/
public function __construct(Application $app, StorageDriver $storage, TenantManager $tenantManager, UniqueIdentifierGenerator $idGenerator)
public function __construct(Repository $config, StorageDriver $storage, TenantManager $tenantManager, UniqueIdentifierGenerator $idGenerator)
{
$this->app = $app;
$this->config = $config;
$this->storage = $storage->withDefaultTenant($this);
$this->manager = $tenantManager;
$this->idGenerator = $idGenerator;
@ -80,7 +83,7 @@ class Tenant implements ArrayAccess
$app = $app ?? app();
return new static(
$app,
$app[Repository::class],
$app[StorageDriver::class],
$app[TenantManager::class],
$app[UniqueIdentifierGenerator::class]
@ -230,8 +233,6 @@ class Tenant implements ArrayAccess
$this->manager->createTenant($this);
}
$this->persisted = true;
return $this;
}
@ -257,10 +258,16 @@ class Tenant implements ArrayAccess
*/
public function softDelete(): self
{
$this->put('_tenancy_original_domains', $this->domains);
$this->manager->event('tenant.softDeleting', $this);
$this->put([
'_tenancy_original_domains' => $this->domains,
]);
$this->clearDomains();
$this->save();
$this->manager->event('tenant.softDeleted', $this);
return $this;
}
@ -271,7 +278,7 @@ class Tenant implements ArrayAccess
*/
public function getDatabaseName(): string
{
return $this->data['_tenancy_db_name'] ?? ($this->app['config']['tenancy.database.prefix'] . $this->id . $this->app['config']['tenancy.database.suffix']);
return $this->data['_tenancy_db_name'] ?? ($this->config->get('tenancy.database.prefix') . $this->id . $this->config->get('tenancy.database.suffix'));
}
/**
@ -325,20 +332,30 @@ class Tenant implements ArrayAccess
*/
public function put($key, $value = null): self
{
$this->manager->event('tenant.updating', $this);
if ($key === 'id') {
throw new TenantStorageException("Tenant ids can't be changed.");
}
if (is_array($key)) {
$this->storage->putMany($key);
if ($this->persisted) {
$this->storage->putMany($key);
}
foreach ($key as $k => $v) { // Add to cache
$this->data[$k] = $v;
}
} else {
$this->storage->put($key, $value);
if ($this->persisted) {
$this->storage->put($key, $value);
}
$this->data[$key] = $value;
}
$this->manager->event('tenant.updated', $this);
return $this;
}
@ -349,7 +366,44 @@ class Tenant implements ArrayAccess
}
/**
* Set a value.
* Delete a key from the tenant's storage.
*
* @param string $key
* @return self
*/
public function deleteKey(string $key): self
{
return $this->deleteKeys([$key]);
}
/**
* Delete keys from the tenant's storage.
*
* @param string[] $keys
* @return self
*/
public function deleteKeys(array $keys): self
{
$this->manager->event('tenant.updating', $this);
if (! $this->storage instanceof CanDeleteKeys) {
throw new NotImplementedException(get_class($this->storage), 'deleteMany',
'This method was added to storage drivers provided by the package in 2.2.0 and will be part of the StorageDriver contract in 3.0.0.'
);
} else {
$this->storage->deleteMany($keys);
foreach ($keys as $key) {
unset($this->data[$key]);
}
}
$this->manager->event('tenant.updated', $this);
return $this;
}
/**
* Set a value in the data array without saving into storage.
*
* @param string $key
* @param mixed $value
@ -362,6 +416,27 @@ class Tenant implements ArrayAccess
return $this;
}
/**
* Run a closure inside the tenant's environment.
*
* @param Closure $closure
* @return mixed
*/
public function run(Closure $closure)
{
$originalTenant = $this->manager->getTenant();
$this->manager->initializeTenancy($this);
$result = $closure($this);
$this->manager->endTenancy($this);
if ($originalTenant) {
$this->manager->initializeTenancy($originalTenant);
}
return $result;
}
public function __get($key)
{
return $this->get($key);

View file

@ -4,32 +4,47 @@ declare(strict_types=1);
namespace Stancl\Tenancy\TenantDatabaseManagers;
use Illuminate\Config\Repository;
use Illuminate\Database\DatabaseManager as IlluminateDatabaseManager;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Database\Connection;
use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Contracts\Future\CanSetConnection;
use Stancl\Tenancy\Contracts\TenantDatabaseManager;
class MySQLDatabaseManager implements TenantDatabaseManager
class MySQLDatabaseManager implements TenantDatabaseManager, CanSetConnection
{
/** @var \Illuminate\Database\Connection */
protected $database;
/** @var string */
protected $connection;
public function __construct(Repository $config, IlluminateDatabaseManager $databaseManager)
public function __construct(Repository $config)
{
$this->database = $databaseManager->connection($config['tenancy.database_manager_connections.mysql']);
$this->connection = $config->get('tenancy.database_manager_connections.mysql');
}
protected function database(): Connection
{
return DB::connection($this->connection);
}
public function setConnection(string $connection): void
{
$this->connection = $connection;
}
public function createDatabase(string $name): bool
{
return $this->database->statement("CREATE DATABASE `$name` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
$charset = $this->database()->getConfig('charset');
$collation = $this->database()->getConfig('collation');
return $this->database()->statement("CREATE DATABASE `$name` CHARACTER SET `$charset` COLLATE `$collation`");
}
public function deleteDatabase(string $name): bool
{
return $this->database->statement("DROP DATABASE `$name`");
return $this->database()->statement("DROP DATABASE `$name`");
}
public function databaseExists(string $name): bool
{
return (bool) $this->database->select("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = '$name'");
return (bool) $this->database()->select("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = '$name'");
}
}

View file

@ -4,32 +4,44 @@ declare(strict_types=1);
namespace Stancl\Tenancy\TenantDatabaseManagers;
use Illuminate\Config\Repository;
use Illuminate\Database\DatabaseManager as IlluminateDatabaseManager;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Database\Connection;
use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Contracts\Future\CanSetConnection;
use Stancl\Tenancy\Contracts\TenantDatabaseManager;
class PostgreSQLDatabaseManager implements TenantDatabaseManager
class PostgreSQLDatabaseManager implements TenantDatabaseManager, CanSetConnection
{
/** @var \Illuminate\Database\Connection */
protected $database;
/** @var string */
protected $connection;
public function __construct(Repository $config, IlluminateDatabaseManager $databaseManager)
public function __construct(Repository $config)
{
$this->database = $databaseManager->connection($config['tenancy.database_manager_connections.pgsql']);
$this->connection = $config->get('tenancy.database_manager_connections.pgsql');
}
protected function database(): Connection
{
return DB::connection($this->connection);
}
public function setConnection(string $connection): void
{
$this->connection = $connection;
}
public function createDatabase(string $name): bool
{
return $this->database->statement("CREATE DATABASE \"$name\" WITH TEMPLATE=template0");
return $this->database()->statement("CREATE DATABASE \"$name\" WITH TEMPLATE=template0");
}
public function deleteDatabase(string $name): bool
{
return $this->database->statement("DROP DATABASE \"$name\"");
return $this->database()->statement("DROP DATABASE \"$name\"");
}
public function databaseExists(string $name): bool
{
return (bool) $this->database->select("SELECT datname FROM pg_database WHERE datname = '$name'");
return (bool) $this->database()->select("SELECT datname FROM pg_database WHERE datname = '$name'");
}
}

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\TenantDatabaseManagers;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Database\Connection;
use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Contracts\Future\CanSetConnection;
use Stancl\Tenancy\Contracts\TenantDatabaseManager;
class PostgreSQLSchemaManager implements TenantDatabaseManager, CanSetConnection
{
/** @var string */
protected $connection;
public function __construct(Repository $config)
{
$this->connection = $config->get('tenancy.database_manager_connections.pgsql');
}
protected function database(): Connection
{
return DB::connection($this->connection);
}
public function setConnection(string $connection): void
{
$this->connection = $connection;
}
public function createDatabase(string $name): bool
{
return $this->database()->statement("CREATE SCHEMA \"$name\"");
}
public function deleteDatabase(string $name): bool
{
return $this->database()->statement("DROP SCHEMA \"$name\"");
}
public function databaseExists(string $name): bool
{
return (bool) $this->database()->select("SELECT schema_name FROM information_schema.schemata WHERE schema_name = '$name'");
}
}

View file

@ -4,23 +4,27 @@ declare(strict_types=1);
namespace Stancl\Tenancy;
use Exception;
use Illuminate\Contracts\Console\Kernel as ConsoleKernel;
use Illuminate\Foundation\Application;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Illuminate\Support\Traits\ForwardsCalls;
use Stancl\Tenancy\Contracts\Future\CanFindByAnyKey;
use Stancl\Tenancy\Contracts\TenantCannotBeCreatedException;
use Stancl\Tenancy\Exceptions\NotImplementedException;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedException;
use Stancl\Tenancy\Jobs\QueuedTenantDatabaseMigrator;
use Stancl\Tenancy\Jobs\QueuedTenantDatabaseSeeder;
/**
* @internal Class is subject to breaking changes in minor and patch versions.
*/
class TenantManager
{
/**
* The current tenant.
*
* @var Tenant
*/
use ForwardsCalls;
/** @var Tenant The current tenant. */
protected $tenant;
/** @var Application */
@ -46,7 +50,7 @@ class TenantManager
$this->app = $app;
$this->storage = $storage;
$this->artisan = $artisan;
$this->database = $database;
$this->database = $database->withTenantManager($this);
$this->bootstrapFeatures();
}
@ -59,21 +63,43 @@ class TenantManager
*/
public function createTenant(Tenant $tenant): self
{
$this->event('tenant.creating', $tenant);
$this->ensureTenantCanBeCreated($tenant);
$this->storage->createTenant($tenant);
$this->database->createDatabase($tenant);
$tenant->persisted = true;
/** @var \Illuminate\Contracts\Queue\ShouldQueue[]|callable[] $afterCreating */
$afterCreating = [];
if ($this->shouldMigrateAfterCreation()) {
if ($this->shouldQueueMigration()) {
QueuedTenantDatabaseMigrator::dispatch($tenant);
} else {
$this->artisan->call('tenants:migrate', [
'--tenants' => [$tenant['id']],
]);
}
$afterCreating[] = $this->databaseCreationQueued()
? new QueuedTenantDatabaseMigrator($tenant, $this->getMigrationParameters())
: function () use ($tenant) {
$this->artisan->call('tenants:migrate', [
'--tenants' => [$tenant['id']],
] + $this->getMigrationParameters());
};
}
if ($this->shouldSeedAfterMigration()) {
$afterCreating[] = $this->databaseCreationQueued()
? new QueuedTenantDatabaseSeeder($tenant)
: function () use ($tenant) {
$this->artisan->call('tenants:seed', [
'--tenants' => [$tenant['id']],
]);
};
}
if ($this->shouldCreateDatabase($tenant)) {
$this->database->createDatabase($tenant, $afterCreating);
}
$this->event('tenant.created', $tenant);
return $this;
}
@ -85,12 +111,16 @@ class TenantManager
*/
public function deleteTenant(Tenant $tenant): self
{
$this->event('tenant.deleting', $tenant);
$this->storage->deleteTenant($tenant);
if ($this->shouldDeleteDatabase()) {
$this->database->deleteDatabase($tenant);
}
$this->event('tenant.deleted', $tenant);
return $this;
}
@ -115,8 +145,11 @@ class TenantManager
*/
public function ensureTenantCanBeCreated(Tenant $tenant): void
{
if ($this->shouldCreateDatabase($tenant)) {
$this->database->ensureTenantCanBeCreated($tenant);
}
$this->storage->ensureTenantCanBeCreated($tenant);
$this->database->ensureTenantCanBeCreated($tenant);
}
/**
@ -127,8 +160,12 @@ class TenantManager
*/
public function updateTenant(Tenant $tenant): self
{
$this->event('tenant.updating', $tenant);
$this->storage->updateTenant($tenant);
$this->event('tenant.updated', $tenant);
return $this;
}
@ -183,6 +220,34 @@ class TenantManager
return $this->storage->findByDomain($domain);
}
/**
* Find a tenant using an arbitrary key.
*
* @param string $key
* @param mixed $value
* @return Tenant
* @throws TenantCouldNotBeIdentifiedException
* @throws NotImplementedException
*/
public function findBy(string $key, $value): Tenant
{
if ($key === null) {
throw new Exception('No key supplied.');
}
if ($value === null) {
throw new Exception('No value supplied.');
}
if (! $this->storage instanceof CanFindByAnyKey) {
throw new NotImplementedException(get_class($this->storage), 'findBy',
'This method was added to the DB storage driver provided by the package in 2.2.0 and might be part of the StorageDriver contract in 3.0.0.'
);
}
return $this->storage->findBy($key, $value);
}
/**
* Get all tenants.
*
@ -206,6 +271,10 @@ class TenantManager
*/
public function initializeTenancy(Tenant $tenant): self
{
if ($this->initialized) {
$this->endTenancy();
}
$this->setTenant($tenant);
$this->bootstrapTenancy($tenant);
$this->initialized = true;
@ -227,20 +296,24 @@ class TenantManager
*/
public function bootstrapTenancy(Tenant $tenant): self
{
$prevented = $this->event('bootstrapping');
$prevented = $this->event('bootstrapping', $tenant);
foreach ($this->tenancyBootstrappers($prevented) as $bootstrapper) {
$this->app[$bootstrapper]->start($tenant);
}
$this->event('bootstrapped');
$this->event('bootstrapped', $tenant);
return $this;
}
public function endTenancy(): self
{
$prevented = $this->event('ending');
if (! $this->initialized) {
return $this;
}
$prevented = $this->event('ending', $this->tenant);
foreach ($this->tenancyBootstrappers($prevented) as $bootstrapper) {
$this->app[$bootstrapper]->end();
@ -306,14 +379,28 @@ class TenantManager
return array_diff_key($this->app['config']['tenancy.bootstrappers'], array_flip($except));
}
public function shouldCreateDatabase(Tenant $tenant): bool
{
if (array_key_exists('_tenancy_create_database', $tenant->data)) {
return $tenant->data['_tenancy_create_database'];
}
return $this->app['config']['tenancy.create_database'] ?? true;
}
public function shouldMigrateAfterCreation(): bool
{
return $this->app['config']['tenancy.migrate_after_creation'] ?? false;
}
public function shouldQueueMigration(): bool
public function shouldSeedAfterMigration(): bool
{
return $this->app['config']['tenancy.queue_automatic_migration'] ?? false;
return $this->shouldMigrateAfterCreation() && $this->app['config']['tenancy.seed_after_migration'] ?? false;
}
public function databaseCreationQueued(): bool
{
return $this->app['config']['tenancy.queue_database_creation'] ?? false;
}
public function shouldDeleteDatabase(): bool
@ -321,6 +408,16 @@ class TenantManager
return $this->app['config']['tenancy.delete_database_after_tenant_deletion'] ?? false;
}
public function getSeederParameters()
{
return $this->app['config']['tenancy.seeder_parameters'] ?? [];
}
public function getMigrationParameters()
{
return $this->app['config']['tenancy.migration_parameters'] ?? [];
}
/**
* Add an event listener.
*
@ -350,17 +447,25 @@ class TenantManager
}
/**
* Execute event listeners.
* Trigger an event and execute its listeners.
*
* @param string $name
* @param mixed ...$args
* @return string[]
*/
protected function event(string $name): array
public function event(string $name, ...$args): array
{
return array_reduce($this->eventListeners[$name] ?? [], function ($prevented, $listener) {
$prevented = array_merge($prevented, $listener($this) ?? []);
return $prevented;
return array_reduce($this->eventListeners[$name] ?? [], function ($results, $listener) use ($args) {
return array_merge($results, $listener($this, ...$args) ?? []);
}, []);
}
public function __call($method, $parameters)
{
if (Str::startsWith($method, 'findBy')) {
return $this->findBy(Str::snake(substr($method, 6)), $parameters[0]);
}
static::throwBadMethodCallException($method);
}
}

View file

@ -11,11 +11,12 @@ class TenantRouteServiceProvider extends RouteServiceProvider
{
public function map()
{
if (! in_array(request()->getHost(), $this->app['config']['tenancy.exempt_domains'] ?? [])
&& file_exists(base_path('routes/tenant.php'))) {
Route::middleware(['web', 'tenancy'])
->namespace($this->app['config']['tenant_route_namespace'] ?? 'App\Http\Controllers')
->group(base_path('routes/tenant.php'));
}
$this->app->booted(function () {
if (file_exists(base_path('routes/tenant.php'))) {
Route::middleware(['web', 'tenancy'])
->namespace($this->app['config']['tenancy.tenant_route_namespace'] ?? 'App\Http\Controllers')
->group(base_path('routes/tenant.php'));
}
});
}
}

View file

@ -8,6 +8,10 @@ trait DealsWithMigrations
{
protected function getMigrationPaths()
{
return [config('tenancy.migrations_directory', database_path('migrations/tenant'))];
if ($this->input->hasOption('path') && $this->input->getOption('path')) {
return parent::getMigrationPaths();
}
return config('tenancy.migration_paths', [config('tenancy.migrations_directory') ?? database_path('migrations/tenant')]);
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Traits;
use Symfony\Component\Console\Input\InputArgument;
trait HasATenantArgument
{
protected function getArguments()
{
return array_merge([
['tenant', InputArgument::REQUIRED, 'Tenant id', null],
], parent::getArguments());
}
protected function getTenants(): array
{
return [tenancy()->find($this->argument('tenant'))];
}
public function __construct()
{
parent::__construct();
$this->specifyParameters();
}
}

View file

@ -14,4 +14,16 @@ trait HasATenantsOption
['tenants', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL, '', null],
], parent::getOptions());
}
protected function getTenants(): array
{
return tenancy()->all($this->option('tenants'))->all();
}
public function __construct()
{
parent::__construct();
$this->specifyParameters();
}
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Traits;
use Stancl\Tenancy\Tenant;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
trait TenantAwareCommand
{
/** @return int */
protected function execute(InputInterface $input, OutputInterface $output)
{
$tenants = $this->getTenants();
$exitCode = 0;
foreach ($tenants as $tenant) {
$result = (int) $tenant->run(function () {
return $this->laravel->call([$this, 'handle']);
});
if ($result !== 0) {
$exitCode = $result;
}
}
return $exitCode;
}
/**
* Get an array of tenants for which the command should be executed.
*
* @return Tenant[]
*/
abstract protected function getTenants(): array;
}

View file

@ -5,7 +5,8 @@ declare(strict_types=1);
use Stancl\Tenancy\Tenant;
use Stancl\Tenancy\TenantManager;
if (! \function_exists('tenancy')) {
if (! function_exists('tenancy')) {
/** @return TenantManager|mixed */
function tenancy($key = null)
{
if ($key) {
@ -16,34 +17,55 @@ if (! \function_exists('tenancy')) {
}
}
if (! \function_exists('tenant')) {
if (! function_exists('tenant')) {
/**
* Get a key from the current tenant's storage.
*
* @param string|null $key
* @return Tenant|mixed
*/
function tenant($key = null)
{
if (! is_null($key)) {
return optional(app(Tenant::class))->get($key) ?? null;
if (is_null($key)) {
return app(Tenant::class);
}
return app(Tenant::class);
return optional(app(Tenant::class))->get($key) ?? null;
}
}
if (! \function_exists('tenant_asset')) {
if (! function_exists('tenant_asset')) {
/** @return string */
function tenant_asset($asset)
{
return route('stancl.tenancy.asset', ['path' => $asset]);
}
}
if (! \function_exists('global_asset')) {
if (! function_exists('global_asset')) {
function global_asset($asset)
{
return app('globalUrl')->asset($asset);
}
}
if (! \function_exists('global_cache')) {
if (! function_exists('global_cache')) {
function global_cache()
{
return app('globalCache');
}
}
if (! function_exists('tenant_route')) {
function tenant_route($route, $parameters = [], string $domain = null)
{
$domain = $domain ?? request()->getHost();
// replace first occurance of hostname fragment with $domain
$url = route($route, $parameters);
$hostname = parse_url($url, PHP_URL_HOST);
$position = strpos($url, $hostname);
return substr_replace($url, $domain, $position, strlen($hostname));
}
}

View file

@ -2,6 +2,8 @@
declare(strict_types=1);
Route::get('/tenancy/assets/{path}', 'Stancl\Tenancy\Controllers\TenantAssetsController@asset')
->where('path', '(.*)')
->name('stancl.tenancy.asset');
Route::middleware(['tenancy'])->group(function () {
Route::get('/tenancy/assets/{path?}', 'Stancl\Tenancy\Controllers\TenantAssetsController@asset')
->where('path', '(.*)')
->name('stancl.tenancy.asset');
});

8
test
View file

@ -1,7 +1,7 @@
#!/bin/bash
set -e
printf "Variant 1\n\n"
docker-compose exec test env TENANCY_TEST_STORAGE_DRIVER=db vendor/bin/phpunit --coverage-php coverage/2.cov "$@"
printf "Variant 2\n\n"
docker-compose exec test env TENANCY_TEST_STORAGE_DRIVER=redis vendor/bin/phpunit --coverage-php coverage/1.cov "$@"
printf "Variant 1 (DB)\n\n"
docker-compose exec -T test env TENANCY_TEST_STORAGE_DRIVER=db vendor/bin/phpunit --coverage-php coverage/1.cov "$@"
printf "Variant 2 (Redis)\n\n"
docker-compose exec -T test env TENANCY_TEST_STORAGE_DRIVER=redis vendor/bin/phpunit --coverage-php coverage/2.cov "$@"

View file

@ -8,15 +8,19 @@ use Stancl\Tenancy\Tenant;
class CacheManagerTest extends TestCase
{
public $autoInitTenancy = false;
/** @test */
public function default_tag_is_automatically_applied()
{
$this->initTenancy();
$this->assertArrayIsSubset([config('tenancy.cache.tag_base') . tenant('id')], cache()->tags('foo')->getTags()->getNames());
}
/** @test */
public function tags_are_merged_when_array_is_passed()
{
$this->initTenancy();
$expected = [config('tenancy.cache.tag_base') . tenant('id'), 'foo', 'bar'];
$this->assertEquals($expected, cache()->tags(['foo', 'bar'])->getTags()->getNames());
}
@ -24,6 +28,7 @@ class CacheManagerTest extends TestCase
/** @test */
public function tags_are_merged_when_string_is_passed()
{
$this->initTenancy();
$expected = [config('tenancy.cache.tag_base') . tenant('id'), 'foo'];
$this->assertEquals($expected, cache()->tags('foo')->getTags()->getNames());
}
@ -31,6 +36,7 @@ class CacheManagerTest extends TestCase
/** @test */
public function exception_is_thrown_when_zero_arguments_are_passed_to_tags_method()
{
$this->initTenancy();
$this->expectException(\Exception::class);
cache()->tags();
}
@ -38,6 +44,7 @@ class CacheManagerTest extends TestCase
/** @test */
public function exception_is_thrown_when_more_than_one_argument_is_passed_to_tags_method()
{
$this->initTenancy();
$this->expectException(\Exception::class);
cache()->tags(1, 2);
}

View file

@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Tests;
use Illuminate\Support\Facades\Cache;
use Stancl\Tenancy\StorageDrivers\Database\DatabaseStorageDriver;
use Stancl\Tenancy\Tenant;
class CachedResolverTest extends TestCase
{
public $autoCreateTenant = false;
public $autoInitTenancy = false;
public function setUp(): void
{
parent::setUp();
if (config('tenancy.storage_driver') !== 'db') {
$this->markTestSkipped('This test is only relevant for the DB storage driver.');
}
config(['tenancy.storage_drivers.db.cache_store' => config('cache.default')]);
}
/** @test */
public function a_query_is_not_made_for_tenant_id_once_domain_is_cached()
{
$tenant = Tenant::new()
->withData(['foo' => 'bar'])
->withDomains(['foo.localhost'])
->save();
// query is made
$queried = tenancy()->findByDomain('foo.localhost');
$this->assertEquals($tenant->data, $queried->data);
$this->assertSame($tenant->domains, $queried->domains);
// cache is set
$this->assertEquals($tenant->id, Cache::get('_tenancy_domain_to_id:foo.localhost'));
$this->assertEquals($tenant->data, Cache::get('_tenancy_id_to_data:' . $tenant->id));
$this->assertSame($tenant->domains, Cache::get('_tenancy_id_to_domains:' . $tenant->id));
// query is not made
DatabaseStorageDriver::getCentralConnection()->enableQueryLog();
$cached = tenancy()->findByDomain('foo.localhost');
$this->assertEquals($tenant->data, $cached->data);
$this->assertSame($tenant->domains, $cached->domains);
$this->assertSame([], DatabaseStorageDriver::getCentralConnection()->getQueryLog());
}
/** @test */
public function a_query_is_not_made_for_tenant_once_id_is_cached()
{
$tenant = Tenant::new()
->withData(['foo' => 'bar'])
->withDomains(['foo.localhost'])
->save();
// query is made
$queried = tenancy()->find($tenant->id);
$this->assertEquals($tenant->data, $queried->data);
$this->assertSame($tenant->domains, $queried->domains);
// cache is set
$this->assertEquals($tenant->data, Cache::get('_tenancy_id_to_data:' . $tenant->id));
$this->assertSame($tenant->domains, Cache::get('_tenancy_id_to_domains:' . $tenant->id));
// query is not made
DatabaseStorageDriver::getCentralConnection()->enableQueryLog();
$cached = tenancy()->find($tenant->id);
$this->assertEquals($tenant->data, $cached->data);
$this->assertSame($tenant->domains, $cached->domains);
$this->assertSame([], DatabaseStorageDriver::getCentralConnection()->getQueryLog());
}
/** @test */
public function modifying_tenant_domains_invalidates_the_cached_domain_to_id_mapping()
{
$tenant = Tenant::new()
->withDomains(['foo.localhost', 'bar.localhost'])
->save();
// queried
$this->assertSame($tenant->id, tenancy()->findByDomain('foo.localhost')->id);
$this->assertSame($tenant->id, tenancy()->findByDomain('bar.localhost')->id);
// assert cache set
$this->assertSame($tenant->id, Cache::get('_tenancy_domain_to_id:foo.localhost'));
$this->assertSame($tenant->id, Cache::get('_tenancy_domain_to_id:bar.localhost'));
$tenant
->removeDomains(['foo.localhost', 'bar.localhost'])
->addDomains(['xyz.localhost'])
->save();
// assert neither domain is cached
$this->assertSame(null, Cache::get('_tenancy_domain_to_id:foo.localhost'));
$this->assertSame(null, Cache::get('_tenancy_domain_to_id:bar.localhost'));
$this->assertSame(null, Cache::get('_tenancy_domain_to_id:xyz.localhost'));
}
/** @test */
public function modifying_tenants_data_invalidates_tenant_data_cache()
{
$tenant = Tenant::new()->withData(['foo' => 'bar'])->save();
// cache record is set
$this->assertSame('bar', tenancy()->find($tenant->id)->get('foo'));
$this->assertSame('bar', Cache::get('_tenancy_id_to_data:' . $tenant->id)['foo']);
// cache record is invalidated
$tenant->set('foo', 'xyz');
$this->assertSame(null, Cache::get('_tenancy_id_to_data:' . $tenant->id));
// cache record is set
$this->assertSame('xyz', tenancy()->find($tenant->id)->get('foo'));
$this->assertSame('xyz', Cache::get('_tenancy_id_to_data:' . $tenant->id)['foo']);
// cache record is invalidated
$tenant->foo = 'abc';
$tenant->save();
$this->assertSame(null, Cache::get('_tenancy_id_to_data:' . $tenant->id));
}
/** @test */
public function modifying_tenants_domains_invalidates_tenant_domain_cache()
{
$tenant = Tenant::new()
->withData(['foo' => 'bar'])
->withDomains(['foo.localhost'])
->save();
// cache record is set
$this->assertSame(['foo.localhost'], tenancy()->find($tenant->id)->domains);
$this->assertSame(['foo.localhost'], Cache::get('_tenancy_id_to_domains:' . $tenant->id));
// cache record is invalidated
$tenant->addDomains(['bar.localhost'])->save();
$this->assertEquals(null, Cache::get('_tenancy_id_to_domains:' . $tenant->id));
$this->assertEquals(['foo.localhost', 'bar.localhost'], tenancy()->find($tenant->id)->domains);
}
/** @test */
public function deleting_a_tenant_invalidates_all_caches()
{
$tenant = Tenant::new()
->withData(['foo' => 'bar'])
->withDomains(['foo.localhost'])
->save();
tenancy()->findByDomain('foo.localhost');
$this->assertEquals($tenant->id, Cache::get('_tenancy_domain_to_id:foo.localhost'));
$this->assertEquals($tenant->data, Cache::get('_tenancy_id_to_data:' . $tenant->id));
$this->assertEquals(['foo.localhost'], Cache::get('_tenancy_id_to_domains:' . $tenant->id));
$tenant->delete();
$this->assertEquals(null, Cache::get('_tenancy_domain_to_id:foo.localhost'));
$this->assertEquals(null, Cache::get('_tenancy_id_to_data:' . $tenant->id));
$this->assertEquals(null, Cache::get('_tenancy_id_to_domains:' . $tenant->id));
}
}

View file

@ -18,7 +18,7 @@ class CommandsTest extends TestCase
{
parent::setUp();
config(['tenancy.migrations_directory' => database_path('../migrations')]);
config(['tenancy.migration_paths', [database_path('../migrations')]]);
}
/** @test */
@ -127,16 +127,25 @@ class CommandsTest extends TestCase
mkdir($dir, 0777, true);
}
file_put_contents(app_path('Http/Kernel.php'), file_get_contents(__DIR__ . '/Etc/defaultHttpKernel.stub'));
if (app()->version()[0] === '6') {
file_put_contents(app_path('Http/Kernel.php'), file_get_contents(__DIR__ . '/Etc/defaultHttpKernelv6.stub'));
} else {
file_put_contents(app_path('Http/Kernel.php'), file_get_contents(__DIR__ . '/Etc/defaultHttpKernelv7.stub'));
}
$this->artisan('tenancy:install')
->expectsQuestion('Do you want to publish the default database migrations?', 'yes');
->expectsQuestion('Do you wish to publish the migrations that create these tables?', 'yes');
$this->assertFileExists(base_path('routes/tenant.php'));
$this->assertFileExists(base_path('config/tenancy.php'));
$this->assertFileExists(database_path('migrations/2019_09_15_000010_create_tenants_table.php'));
$this->assertFileExists(database_path('migrations/2019_09_15_000020_create_domains_table.php'));
$this->assertDirectoryExists(database_path('migrations/tenant'));
$this->assertSame(file_get_contents(__DIR__ . '/Etc/modifiedHttpKernel.stub'), file_get_contents(app_path('Http/Kernel.php')));
if (app()->version()[0] === '6') {
$this->assertSame(file_get_contents(__DIR__ . '/Etc/modifiedHttpKernelv6.stub'), file_get_contents(app_path('Http/Kernel.php')));
} else {
$this->assertSame(file_get_contents(__DIR__ . '/Etc/modifiedHttpKernelv7.stub'), file_get_contents(app_path('Http/Kernel.php')));
}
}
/** @test */

View file

@ -45,4 +45,21 @@ class DatabaseManagerTest extends TestCase
$this->assertSame('tenant', config('database.default'));
$this->assertSame('bar', config('database.connections.' . config('database.default') . '.foo'));
}
/** @test */
public function ending_tenancy_doesnt_purge_the_central_connection()
{
$this->markTestIncomplete('Seems like this only happens on MySQL?');
// regression test for https://github.com/stancl/tenancy/pull/189
// config(['tenancy.migrate_after_creation' => true]);
tenancy()->create(['foo.localhost']);
tenancy()->init('foo.localhost');
tenancy()->end();
$this->assertNotEmpty(tenancy()->all());
tenancy()->all()->each->delete();
}
}

View file

@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Tests;
use Illuminate\Support\Str;
use Stancl\Tenancy\Tenant;
class DatabaseSchemaManagerTest extends TestCase
{
public $autoInitTenancy = false;
protected function getEnvironmentSetUp($app)
{
parent::getEnvironmentSetUp($app);
$app['config']->set([
'database.default' => 'pgsql',
'database.connections.pgsql.database' => 'main',
'database.connections.pgsql.schema' => 'public',
'tenancy.database.based_on' => null,
'tenancy.database.suffix' => '',
'tenancy.database.separate_by' => 'schema',
'tenancy.database_managers.pgsql' => \Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLSchemaManager::class,
]);
}
/** @test */
public function reconnect_method_works()
{
$old_connection_name = app(\Illuminate\Database\DatabaseManager::class)->connection()->getName();
tenancy()->init('test.localhost');
app(\Stancl\Tenancy\DatabaseManager::class)->reconnect();
$new_connection_name = app(\Illuminate\Database\DatabaseManager::class)->connection()->getName();
$this->assertSame($old_connection_name, $new_connection_name);
}
/** @test */
public function the_default_db_is_used_when_based_on_is_null()
{
config(['database.default' => 'pgsql']);
$this->assertSame('pgsql', config('database.default'));
config([
'database.connections.pgsql.foo' => 'bar',
'tenancy.database.based_on' => null,
]);
tenancy()->init('test.localhost');
$this->assertSame('tenant', config('database.default'));
$this->assertSame('bar', config('database.connections.' . config('database.default') . '.foo'));
}
/** @test */
public function make_sure_using_schema_connection()
{
$tenant = tenancy()->create(['schema.localhost']);
tenancy()->init('schema.localhost');
$this->assertSame($tenant->getDatabaseName(), config('database.connections.' . config('database.default') . '.schema'));
}
/** @test */
public function databases_are_separated_using_schema_and_not_database()
{
tenancy()->create('foo.localhost');
tenancy()->init('foo.localhost');
$this->assertSame('tenant', config('database.default'));
$this->assertSame('main', config('database.connections.tenant.database'));
$schema1 = config('database.connections.' . config('database.default') . '.schema');
$database1 = config('database.connections.' . config('database.default') . '.database');
tenancy()->create('bar.localhost');
tenancy()->init('bar.localhost');
$this->assertSame('tenant', config('database.default'));
$this->assertSame('main', config('database.connections.tenant.database'));
$schema2 = config('database.connections.' . config('database.default') . '.schema');
$database2 = config('database.connections.' . config('database.default') . '.database');
$this->assertSame($database1, $database2);
$this->assertNotSame($schema1, $schema2);
}
/** @test */
public function schemas_are_separated()
{
// copied from DataSeparationTest
$tenant1 = Tenant::create('tenant1.localhost');
$tenant2 = Tenant::create('tenant2.localhost');
\Artisan::call('tenants:migrate', [
'--tenants' => [$tenant1['id'], $tenant2['id']],
]);
tenancy()->init('tenant1.localhost');
User::create([
'name' => 'foo',
'email' => 'foo@bar.com',
'email_verified_at' => now(),
'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
'remember_token' => Str::random(10),
]);
$this->assertSame('foo', User::first()->name);
tenancy()->init('tenant2.localhost');
$this->assertSame(null, User::first());
User::create([
'name' => 'xyz',
'email' => 'xyz@bar.com',
'email_verified_at' => now(),
'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
'remember_token' => Str::random(10),
]);
$this->assertSame('xyz', User::first()->name);
$this->assertSame('xyz@bar.com', User::first()->email);
tenancy()->init('tenant1.localhost');
$this->assertSame('foo', User::first()->name);
$this->assertSame('foo@bar.com', User::first()->email);
$tenant3 = Tenant::create('tenant3.localhost');
\Artisan::call('tenants:migrate', [
'--tenants' => [$tenant1['id'], $tenant3['id']],
]);
tenancy()->init('tenant3.localhost');
$this->assertSame(null, User::first());
tenancy()->init('tenant1.localhost');
\DB::table('users')->where('id', 1)->update(['name' => 'xxx']);
$this->assertSame('xxx', User::first()->name);
}
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Tests\Etc;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
use Stancl\Tenancy\Traits\HasATenantsOption;
use Stancl\Tenancy\Traits\TenantAwareCommand;
class AddUserCommand extends Command
{
use TenantAwareCommand, HasATenantsOption;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'user:add';
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
User::create([
'name' => Str::random(10),
'email' => Str::random(10) . '@gmail.com',
'email_verified_at' => now(),
'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
'remember_token' => Str::random(10),
]);
}
}

View file

@ -15,5 +15,6 @@ class ConsoleKernel extends Kernel
*/
protected $commands = [
ExampleCommand::class,
AddUserCommand::class,
];
}

View file

@ -0,0 +1,66 @@
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array
*/
protected $middleware = [
\App\Http\Middleware\TrustProxies::class,
\Fruitcake\Cors\HandleCors::class,
\App\Http\Middleware\CheckForMaintenanceMode::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
];
/**
* The application's route middleware groups.
*
* @var array
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'api' => [
'throttle:60,1',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
/**
* The application's route middleware.
*
* These middleware may be assigned to groups or used individually.
*
* @var array
*/
protected $routeMiddleware = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
];
}

View file

@ -39,6 +39,7 @@ class Kernel extends HttpKernel
],
'api' => [
\Stancl\Tenancy\Middleware\PreventAccessFromTenantDomains::class,
'throttle:60,1',
'bindings',
],

View file

@ -0,0 +1,80 @@
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array
*/
protected $middleware = [
\App\Http\Middleware\TrustProxies::class,
\Fruitcake\Cors\HandleCors::class,
\App\Http\Middleware\CheckForMaintenanceMode::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
];
/**
* The application's route middleware groups.
*
* @var array
*/
protected $middlewareGroups = [
'web' => [
\Stancl\Tenancy\Middleware\PreventAccessFromTenantDomains::class,
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'api' => [
\Stancl\Tenancy\Middleware\PreventAccessFromTenantDomains::class,
'throttle:60,1',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
/**
* The application's route middleware.
*
* These middleware may be assigned to groups or used individually.
*
* @var array
*/
protected $routeMiddleware = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
];
protected $middlewarePriority = [
\Stancl\Tenancy\Middleware\PreventAccessFromTenantDomains::class,
\Stancl\Tenancy\Middleware\InitializeTenancy::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\Illuminate\Auth\Middleware\Authorize::class,
];
}

View file

@ -2,7 +2,9 @@
declare(strict_types=1);
namespace Stancl\Tenancy\Tests;
namespace Stancl\Tenancy\Tests\Features;
use Stancl\Tenancy\Tests\TestCase;
class TenantConfigTest extends TestCase
{

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Tests\Features;
use Stancl\Tenancy\Features\Timestamps;
use Stancl\Tenancy\Tenant;
use Stancl\Tenancy\Tests\TestCase;
class TimestampTest extends TestCase
{
public $autoCreateTenant = false;
public $autoInitTenancy = false;
public function setUp(): void
{
parent::setUp();
config(['tenancy.features' => [
Timestamps::class,
]]);
}
/** @test */
public function create_and_update_timestamps_are_added_on_create()
{
$tenant = Tenant::new()->save();
$this->assertArrayHasKey('created_at', $tenant->data);
$this->assertArrayHasKey('updated_at', $tenant->data);
}
/** @test */
public function update_timestamps_are_added()
{
$tenant = Tenant::new()->save();
$this->assertSame($tenant->created_at, $tenant->updated_at);
$this->assertSame('string', gettype($tenant->created_at));
sleep(1);
$tenant->put('abc', 'def');
$this->assertTrue($tenant->updated_at > $tenant->created_at);
}
/** @test */
public function softdelete_timestamps_are_added()
{
$tenant = Tenant::new()->save();
$this->assertNull($tenant->deleted_at);
$tenant->softDelete();
$this->assertNotNull($tenant->deleted_at);
}
}

45
tests/FutureTest.php Normal file
View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Tests;
use Stancl\Tenancy\Contracts\Future\CanFindByAnyKey;
use Stancl\Tenancy\Tenant;
class FutureTest extends TestCase
{
public $autoCreateTenant = false;
public $autoInitTenancy = false;
/** @test */
public function keys_can_be_deleted_from_tenant_storage()
{
$tenant = Tenant::new()->withData(['email' => 'foo@example.com', 'role' => 'admin'])->save();
$this->assertArrayHasKey('email', $tenant->data);
$tenant->deleteKey('email');
$this->assertArrayNotHasKey('email', $tenant->data);
$this->assertArrayNotHasKey('email', tenancy()->all()->first()->data);
$tenant->put(['foo' => 'bar', 'abc' => 'xyz']);
$this->assertArrayHasKey('foo', $tenant->data);
$this->assertArrayHasKey('abc', $tenant->data);
$tenant->deleteKeys(['foo', 'abc']);
$this->assertArrayNotHasKey('foo', $tenant->data);
$this->assertArrayNotHasKey('abc', $tenant->data);
}
/** @test */
public function tenant_can_be_identified_using_an_arbitrary_string()
{
if (! tenancy()->storage instanceof CanFindByAnyKey) {
$this->markTestSkipped(get_class(tenancy()->storage) . ' does not implement the CanFindByAnyKey interface.');
}
$tenant = Tenant::new()->withData(['email' => 'foo@example.com'])->save();
$this->assertSame($tenant->id, tenancy()->findByEmail('foo@example.com')->id);
}
}

View file

@ -33,6 +33,19 @@ class QueueTest extends TestCase
return $event->job->payload()['tenant_id'] === tenant('id');
});
}
/** @test */
public function tenancy_is_not_initialized_in_non_tenant_queues()
{
$this->loadLaravelMigrations(['--database' => 'tenant']);
Event::fake();
dispatch(new TestJob())->onConnection('central');
Event::assertDispatched(JobProcessing::class, function ($event) {
return ! isset($event->job->payload()['tenant_id']);
});
}
}
class TestJob implements ShouldQueue

View file

@ -7,7 +7,7 @@ namespace Stancl\Tenancy\Tests;
use Route;
use Stancl\Tenancy\Tenant;
class TenantRedirectMacroTest extends TestCase
class RedirectTest extends TestCase
{
public $autoCreateTenant = false;
public $autoInitTenancy = false;
@ -33,4 +33,17 @@ class TenantRedirectMacroTest extends TestCase
$this->get('/redirect')
->assertRedirect('http://abcd/foobar');
}
/** @test */
public function tenant_route_helper_generates_correct_url()
{
Route::get('/abcdef/{a?}/{b?}', function () {
return 'Foo';
})->name('foo');
$this->assertSame('http://foo.localhost/abcdef/as/df', tenant_route('foo', ['a' => 'as', 'b' => 'df'], 'foo.localhost'));
$this->assertSame('http://foo.localhost/abcdef', tenant_route('foo', [], 'foo.localhost'));
$this->assertSame('http://' . request()->getHost() . '/abcdef/x/y', tenant_route('foo', ['a' => 'x', 'b' => 'y']));
}
}

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Tests;
use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
use Stancl\Tenancy\Tenant;
class RequestDataIdentificationTest extends TestCase
{
public $autoCreateTenant = false;
public $autoInitTenancy = false;
public function setUp(): void
{
parent::setUp();
config([
'tenancy.exempt_domains' => [
'localhost',
],
]);
Route::middleware(InitializeTenancyByRequestData::class)->get('/test', function () {
return 'Tenant id: ' . tenant('id');
});
}
/** @test */
public function header_identification_works()
{
$this->app->bind(InitializeTenancyByRequestData::class, function () {
return new InitializeTenancyByRequestData('X-Tenant');
});
$tenant = Tenant::new()->save();
$tenant2 = Tenant::new()->save();
$this
->withoutExceptionHandling()
->get('test', [
'X-Tenant' => $tenant->id,
])
->assertSee($tenant->id);
}
/** @test */
public function query_parameter_identification_works()
{
$this->app->bind(InitializeTenancyByRequestData::class, function () {
return new InitializeTenancyByRequestData(null, 'tenant');
});
$tenant = Tenant::new()->save();
$tenant2 = Tenant::new()->save();
$this
->withoutExceptionHandling()
->get('test?tenant=' . $tenant->id)
->assertSee($tenant->id);
}
}

View file

@ -68,4 +68,20 @@ class TenantAssetTest extends TestCase
$this->assertSame($original, global_asset('foobar'));
}
/** @test */
public function asset_helper_tenancy_can_be_disabled()
{
$original = asset('foo');
config([
'app.asset_url' => null,
'tenancy.filesystem.asset_helper_tenancy' => false,
]);
Tenant::create('foo.localhost');
tenancy()->init('foo.localhost');
$this->assertSame($original, asset('foo'));
}
}

View file

@ -105,4 +105,72 @@ class TenantClassTest extends TestCase
$this->assertSame(['foo' => 'bar'], $data);
}
/** @test */
public function run_method_works()
{
$this->assertSame(null, tenancy()->getTenant());
$users_table_empty = function () {
return count(\DB::table('users')->get()) === 0;
};
$tenant = Tenant::new()->save();
\Artisan::call('tenants:migrate', [
'--tenants' => [$tenant->id],
]);
tenancy()->initialize($tenant);
$this->assertTrue($users_table_empty());
tenancy()->end();
$foo = $tenant->run(function () {
\DB::table('users')->insert([
'name' => 'foo',
'email' => 'foo@bar.xy',
'password' => bcrypt('secret'),
]);
return 'foo';
});
// test return value
$this->assertSame('foo', $foo);
// test that tenancy was ended
$this->assertSame(false, tenancy()->initialized);
$this->assertSame(null, tenancy()->getTenant());
// test closure
tenancy()->initialize($tenant);
$this->assertFalse($users_table_empty());
// test returning to original tenant
$tenant2 = Tenant::new()->save();
\Artisan::call('tenants:migrate', [
'--tenants' => [$tenant2->id],
]);
tenancy()->initialize($tenant2);
$this->assertSame($tenant2, tenancy()->getTenant());
$this->assertTrue($users_table_empty());
$tenant->run(function () {
\DB::table('users')->insert([
'name' => 'bar',
'email' => 'bar@bar.xy',
'password' => bcrypt('secret'),
]);
});
$this->assertSame($tenant2, tenancy()->getTenant());
$this->assertSame(2, $tenant->run(function () {
return \DB::table('users')->count();
}));
// test that the tenant variable can be accessed
$this->assertSame($tenant->id, $tenant->run(function ($tenant) {
return $tenant->id;
}));
}
}

View file

@ -10,6 +10,7 @@ use Stancl\Tenancy\Jobs\QueuedTenantDatabaseDeleter;
use Stancl\Tenancy\Tenant;
use Stancl\Tenancy\TenantDatabaseManagers\MySQLDatabaseManager;
use Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLDatabaseManager;
use Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLSchemaManager;
use Stancl\Tenancy\TenantDatabaseManagers\SQLiteDatabaseManager;
class TenantDatabaseManagerTest extends TestCase
@ -78,6 +79,7 @@ class TenantDatabaseManagerTest extends TestCase
['mysql', MySQLDatabaseManager::class],
['sqlite', SQLiteDatabaseManager::class],
['pgsql', PostgreSQLDatabaseManager::class],
['pgsql', PostgreSQLSchemaManager::class],
];
}

View file

@ -121,4 +121,18 @@ class TenantManagerEventsTest extends TestCase
tenancy()->init('abc.localhost');
$this->assertSame('tenant', \DB::connection()->getConfig()['name']);
}
/** @test */
public function tenant_is_persisted_before_the_created_hook_is_called()
{
$was_persisted = false;
Tenancy::eventListener('tenant.created', function ($tenancy, $tenant) use (&$was_persisted) {
$was_persisted = $tenant->persisted;
});
Tenant::new()->save();
$this->assertTrue($was_persisted);
}
}

View file

@ -8,10 +8,14 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Stancl\Tenancy\Exceptions\DomainsOccupiedByOtherTenantException;
use Stancl\Tenancy\Exceptions\TenantDoesNotExistException;
use Stancl\Tenancy\Exceptions\TenantWithThisIdAlreadyExistsException;
use Stancl\Tenancy\Jobs\QueuedTenantDatabaseCreator;
use Stancl\Tenancy\Jobs\QueuedTenantDatabaseMigrator;
use Stancl\Tenancy\Jobs\QueuedTenantDatabaseSeeder;
use Stancl\Tenancy\Tenant;
use Stancl\Tenancy\TenantManager;
use Stancl\Tenancy\Tests\Etc\ExampleSeeder;
class TenantManagerTest extends TestCase
{
@ -235,6 +239,27 @@ class TenantManagerTest extends TestCase
$this->assertTrue(\Schema::hasTable('users'));
}
/** @test */
public function automatic_seeding_works()
{
config(['tenancy.migrate_after_creation' => true]);
$tenant = Tenant::create(['foo.localhost']);
tenancy()->initialize($tenant);
$this->assertSame(0, \DB::table('users')->count());
config([
'tenancy.seed_after_migration' => true,
'tenancy.seeder_parameters' => [
'--class' => ExampleSeeder::class,
],
]);
$tenant2 = Tenant::create(['bar.localhost']);
tenancy()->initialize($tenant2);
$this->assertSame(1, \DB::table('users')->count());
}
/** @test */
public function ensureTenantCanBeCreated_works()
{
@ -248,22 +273,110 @@ class TenantManagerTest extends TestCase
}
/** @test */
public function automigration_can_be_queued()
public function automigration_is_queued_when_db_creation_is_queued()
{
Queue::fake();
config([
'tenancy.queue_database_creation' => true,
'tenancy.migrate_after_creation' => true,
'tenancy.queue_automatic_migration' => true,
]);
$tenant = Tenant::new()->save();
tenancy()->initialize($tenant);
Queue::assertPushed(QueuedTenantDatabaseMigrator::class);
Queue::assertPushedWithChain(QueuedTenantDatabaseCreator::class, [
QueuedTenantDatabaseMigrator::class,
]);
$this->assertFalse(\Schema::hasTable('users'));
(new QueuedTenantDatabaseMigrator($tenant))->handle();
$this->assertTrue(\Schema::hasTable('users'));
// foreach (Queue::pushedJobs() as $job) {
// $job[0]['job']->handle(); // this doesn't execute the chained job
// }
// tenancy()->initialize($tenant);
// $this->assertTrue(\Schema::hasTable('users'));
}
/** @test */
public function autoseeding_is_queued_when_db_creation_is_queued()
{
Queue::fake();
config([
'tenancy.queue_database_creation' => true,
'tenancy.migrate_after_creation' => true,
'tenancy.seed_after_migration' => true,
]);
Tenant::new()->save();
Queue::assertPushedWithChain(QueuedTenantDatabaseCreator::class, [
QueuedTenantDatabaseMigrator::class,
QueuedTenantDatabaseSeeder::class,
]);
}
/** @test */
public function TenantDoesNotExistException_is_thrown_when_find_is_called_on_an_id_that_does_not_belong_to_any_tenant()
{
$this->expectException(TenantDoesNotExistException::class);
tenancy()->find('gjnfdgf');
}
/** @test */
public function event_listeners_can_accept_arguments()
{
tenancy()->hook('tenant.creating', function ($tenantManager, $tenant) {
$this->assertSame('bar', $tenant->foo);
});
Tenant::new()->withData(['foo' => 'bar'])->save();
}
/** @test */
public function tenant_creating_hook_can_be_used_to_modify_tenants_data()
{
tenancy()->hook('tenant.creating', function ($tm, Tenant $tenant) {
$tenant->put([
'foo' => 'bar',
'abc123' => 'def456',
]);
});
$tenant = Tenant::new()->save();
$this->assertArrayHasKey('foo', $tenant->data);
$this->assertArrayHasKey('abc123', $tenant->data);
}
/** @test */
public function database_creation_can_be_disabled()
{
config(['tenancy.create_database' => false]);
tenancy()->hook('database.creating', function () {
$this->fail();
});
$tenant = Tenant::new()->save();
$this->assertTrue(true);
}
/** @test */
public function database_creation_can_be_disabled_for_specific_tenants()
{
config(['tenancy.create_database' => true]);
tenancy()->hook('database.creating', function () {
$this->assertTrue(true);
});
$tenant = Tenant::new()->save();
tenancy()->hook('database.creating', function () {
$this->fail();
});
$tenant2 = Tenant::new()->withData([
'_tenancy_create_database' => false,
])->save();
}
}

View file

@ -4,9 +4,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Tests;
use Stancl\Tenancy\StorageDrivers\Database\DatabaseStorageDriver;
use Stancl\Tenancy\StorageDrivers\Database\TenantModel;
use Stancl\Tenancy\StorageDrivers\RedisStorageDriver;
use Stancl\Tenancy\StorageDrivers\Database\TenantRepository;
use Stancl\Tenancy\Tenant;
class TenantStorageTest extends TestCase
@ -84,10 +82,12 @@ class TenantStorageTest extends TestCase
/** @test */
public function correct_storage_driver_is_used()
{
if (config('tenancy.storage_driver') == DatabaseStorageDriver::class) {
if (config('tenancy.storage_driver') == 'db') {
$this->assertSame('DatabaseStorageDriver', class_basename(tenancy()->storage));
} elseif (config('tenancy.storage_driver') == RedisStorageDriver::class) {
} elseif (config('tenancy.storage_driver') == 'redis') {
$this->assertSame('RedisStorageDriver', class_basename(tenancy()->storage));
} else {
dd(class_basename(config('tenancy.storage_driver')));
}
}
@ -112,10 +112,11 @@ class TenantStorageTest extends TestCase
}
/** @test */
public function tenant_model_uses_correct_connection()
public function tenant_repository_uses_correct_connection()
{
config(['database.connections.foo' => config('database.connections.sqlite')]);
config(['tenancy.storage_drivers.db.connection' => 'foo']);
$this->assertSame('foo', (new TenantModel)->getConnectionName());
$this->assertSame('foo', app(TenantRepository::class)->database->getName());
}
/** @test */
@ -137,7 +138,7 @@ class TenantStorageTest extends TestCase
/** @test */
public function custom_columns_work_with_db_storage_driver()
{
if (config('tenancy.storage_driver') != 'Stancl\Tenancy\StorageDrivers\DatabaseStorageDriver') {
if (config('tenancy.storage_driver') != 'db') {
$this->markTestSkipped();
}
@ -153,12 +154,41 @@ class TenantStorageTest extends TestCase
'foo',
]]);
tenant()->create(['foo.localhost']);
tenancy()->create(['foo.localhost']);
tenancy()->init('foo.localhost');
tenant()->put(['foo' => 'bar', 'abc' => 'xyz']);
$this->assertSame(['bar', 'xyz'], tenant()->get(['foo', 'abc']));
tenant()->put('foo', '111');
$this->assertSame('111', tenant()->get('foo'));
$this->assertSame('bar', DB::connection('central')->table('tenants')->where('id', tenant('id'))->first()->foo);
tenant()->put(['foo' => 'bar', 'abc' => 'xyz']);
$this->assertSame(['foo' => 'bar', 'abc' => 'xyz'], tenant()->get(['foo', 'abc']));
$this->assertSame('bar', \DB::connection('central')->table('tenants')->where('id', tenant('id'))->first()->foo);
}
/** @test */
public function custom_columns_can_be_used_on_tenant_create()
{
if (config('tenancy.storage_driver') != 'db') {
$this->markTestSkipped();
}
tenancy()->endTenancy();
$this->loadMigrationsFrom([
'--path' => __DIR__ . '/Etc',
'--database' => 'central',
]);
config(['database.default' => 'sqlite']); // fix issue caused by loadMigrationsFrom
config(['tenancy.storage_drivers.db.custom_columns' => [
'foo',
]]);
tenancy()->create(['foo.localhost'], ['foo' => 'bar', 'abc' => 'xyz']);
tenancy()->init('foo.localhost');
$this->assertSame(['foo' => 'bar', 'abc' => 'xyz'], tenant()->get(['foo', 'abc']));
$this->assertSame('bar', \DB::connection('central')->table('tenants')->where('id', tenant('id'))->first()->foo);
}
}

View file

@ -24,11 +24,12 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
Redis::connection('tenancy')->flushdb();
Redis::connection('cache')->flushdb();
$originalConnection = config('database.default');
$this->loadMigrationsFrom([
'--path' => realpath(__DIR__ . '/../assets/migrations'),
'--database' => 'central',
]);
config(['database.default' => 'sqlite']); // fix issue caused by loadMigrationsFrom
config(['database.default' => $originalConnection]); // fix issue caused by loadMigrationsFrom
if ($this->autoCreateTenant) {
$this->createTenant();
@ -96,9 +97,14 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
'tenancy.redis.tenancy' => env('TENANCY_TEST_REDIS_TENANCY', true),
'database.redis.client' => env('TENANCY_TEST_REDIS_CLIENT', 'phpredis'),
'tenancy.redis.prefixed_connections' => ['default'],
'tenancy.migrations_directory' => database_path('../migrations'),
'tenancy.migration_paths' => [database_path('../migrations')],
'tenancy.storage_drivers.db.connection' => 'central',
'tenancy.bootstrappers.redis' => \Stancl\Tenancy\TenancyBootstrappers\RedisTenancyBootstrapper::class,
'queue.connections.central' => [
'driver' => 'sync',
'central' => true,
],
'tenancy.seeder_parameters' => [],
]);
$app->singleton(\Stancl\Tenancy\TenancyBootstrappers\RedisTenancyBootstrapper::class);

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Tests\Traits;
use Stancl\Tenancy\Tenant;
use Stancl\Tenancy\Tests\TestCase;
class TenantAwareCommandTest extends TestCase
{
public $autoCreateTenant = false;
public $autoInitTenancy = false;
/** @test */
public function commands_run_globally_are_tenant_aware_and_return_valid_exit_code()
{
$tenant1 = Tenant::new()->save();
$tenant2 = Tenant::new()->save();
\Artisan::call('tenants:migrate', [
'--tenants' => [$tenant1['id'], $tenant2['id']],
]);
$this->artisan('user:add')
->assertExitCode(0);
tenancy()->initializeTenancy($tenant1);
$this->assertNotEmpty(\DB::table('users')->get());
tenancy()->end();
tenancy()->initializeTenancy($tenant2);
$this->assertNotEmpty(\DB::table('users')->get());
tenancy()->end();
}
}