diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..4bea7147 --- /dev/null +++ b/.github/FUNDING.yml @@ -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'] diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index ce301e06..596c2292 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -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] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..be15dc62 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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) diff --git a/.styleci.yml b/.styleci.yml index 258feec5..50c26217 100644 --- a/.styleci.yml +++ b/.styleci.yml @@ -1,8 +1,6 @@ preset: laravel enabled: - declare_strict_types -- alpha_ordered_imports disabled: - concat_without_spaces - ternary_operator_spaces -- length_ordered_imports diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 87f9f300..00000000 --- a/.travis.yml +++ /dev/null @@ -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) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db8cc9d7..82f7aa5d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/DONATIONS.md b/DONATIONS.md new file mode 100644 index 00000000..bb48a79a --- /dev/null +++ b/DONATIONS.md @@ -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. diff --git a/Dockerfile b/Dockerfile index a6512668..75132249 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,30 +4,35 @@ 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 \ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* -WORKDIR /var/www/html \ No newline at end of file +WORKDIR /var/www/html diff --git a/README.md b/README.md index 7dc8e32c..a91ce3a4 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,16 @@ -# [stancl/tenancy](https://tenancy.samuelstancl.me) +

+ Tenancy for Laravel logo +

-[![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) +

+ Laravel 6.x + Latest Stable Version + GitHub Actions CI status + codecov + Donate +

+ +

Tenancy for Laravel — stancl/tenancy

### *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) diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 00000000..e35775e1 --- /dev/null +++ b/SUPPORT.md @@ -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) diff --git a/art/logo.png b/art/logo.png new file mode 100644 index 00000000..9080de1e Binary files /dev/null and b/art/logo.png differ diff --git a/art/old_logo.png b/art/old_logo.png new file mode 100644 index 00000000..365ee375 Binary files /dev/null and b/art/old_logo.png differ diff --git a/assets/config.php b/assets/config.php index 64aa211a..844b8d93 100644 --- a/assets/config.php +++ b/assets/config.php @@ -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, + ], ]; diff --git a/assets/tenant_routes.php.stub b/assets/tenant_routes.php.stub new file mode 100644 index 00000000..a51c3f1c --- /dev/null +++ b/assets/tenant_routes.php.stub @@ -0,0 +1,16 @@ +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'), - "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')) + ); + } + } } diff --git a/src/Commands/Migrate.php b/src/Commands/Migrate.php index d9ba4938..279a371b 100644 --- a/src/Commands/Migrate.php +++ b/src/Commands/Migrate.php @@ -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); - } } } diff --git a/src/Commands/MigrateFresh.php b/src/Commands/MigrateFresh.php index 6a31e888..bef3a09a 100644 --- a/src/Commands/MigrateFresh.php +++ b/src/Commands/MigrateFresh.php @@ -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); - } } } diff --git a/src/Commands/Rollback.php b/src/Commands/Rollback.php index b9168bf7..eee99ddd 100644 --- a/src/Commands/Rollback.php +++ b/src/Commands/Rollback.php @@ -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); - } } } diff --git a/src/Commands/Seed.php b/src/Commands/Seed.php index 61630408..36f59dd0 100644 --- a/src/Commands/Seed.php +++ b/src/Commands/Seed.php @@ -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); - } } } diff --git a/src/Contracts/Future/CanDeleteKeys.php b/src/Contracts/Future/CanDeleteKeys.php new file mode 100644 index 00000000..25f2f5f6 --- /dev/null +++ b/src/Contracts/Future/CanDeleteKeys.php @@ -0,0 +1,21 @@ +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'; } } diff --git a/src/Exceptions/NotImplementedException.php b/src/Exceptions/NotImplementedException.php new file mode 100644 index 00000000..4ae156f7 --- /dev/null +++ b/src/Exceptions/NotImplementedException.php @@ -0,0 +1,15 @@ +message = "Tenant with this $key does not exist: $id"; + } +} diff --git a/src/Features/TelescopeTags.php b/src/Features/TelescopeTags.php index c1d4450f..7978214b 100644 --- a/src/Features/TelescopeTags.php +++ b/src/Features/TelescopeTags.php @@ -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'), ]); diff --git a/src/Features/TenantConfig.php b/src/Features/TenantConfig.php index 25c8a57d..9c3d8206 100644 --- a/src/Features/TenantConfig.php +++ b/src/Features/TenantConfig.php @@ -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; diff --git a/src/Features/Timestamps.php b/src/Features/Timestamps.php new file mode 100644 index 00000000..309631ed --- /dev/null +++ b/src/Features/Timestamps.php @@ -0,0 +1,46 @@ +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 + ); + } +} diff --git a/src/Jobs/QueuedTenantDatabaseMigrator.php b/src/Jobs/QueuedTenantDatabaseMigrator.php index 84acca66..c71696cc 100644 --- a/src/Jobs/QueuedTenantDatabaseMigrator.php +++ b/src/Jobs/QueuedTenantDatabaseMigrator.php @@ -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); } } diff --git a/src/Jobs/QueuedTenantDatabaseSeeder.php b/src/Jobs/QueuedTenantDatabaseSeeder.php new file mode 100644 index 00000000..e1ecea41 --- /dev/null +++ b/src/Jobs/QueuedTenantDatabaseSeeder.php @@ -0,0 +1,38 @@ +tenantId = $tenant->id; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + Artisan::call('tenants:seed', [ + '--tenants' => [$this->tenantId], + ]); + } +} diff --git a/src/Middleware/InitializeTenancy.php b/src/Middleware/InitializeTenancy.php index 3bc383ba..c8b3bd2f 100644 --- a/src/Middleware/InitializeTenancy.php +++ b/src/Middleware/InitializeTenancy.php @@ -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); diff --git a/src/Middleware/InitializeTenancyByRequestData.php b/src/Middleware/InitializeTenancyByRequestData.php new file mode 100644 index 00000000..33e17a09 --- /dev/null +++ b/src/Middleware/InitializeTenancyByRequestData.php @@ -0,0 +1,70 @@ +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)); + } +} diff --git a/src/Middleware/PreventAccessFromTenantDomains.php b/src/Middleware/PreventAccessFromTenantDomains.php index c961fada..83afb31f 100644 --- a/src/Middleware/PreventAccessFromTenantDomains.php +++ b/src/Middleware/PreventAccessFromTenantDomains.php @@ -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; + } } diff --git a/src/StorageDrivers/Database/CachedTenantResolver.php b/src/StorageDrivers/Database/CachedTenantResolver.php new file mode 100644 index 00000000..db4d5a46 --- /dev/null +++ b/src/StorageDrivers/Database/CachedTenantResolver.php @@ -0,0 +1,68 @@ +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); + } + } +} diff --git a/src/StorageDrivers/Database/DatabaseStorageDriver.php b/src/StorageDrivers/Database/DatabaseStorageDriver.php index 4f5c64bb..1303ba38 100644 --- a/src/StorageDrivers/Database/DatabaseStorageDriver.php +++ b/src/StorageDrivers/Database/DatabaseStorageDriver.php @@ -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; } } diff --git a/src/StorageDrivers/Database/DomainModel.php b/src/StorageDrivers/Database/DomainModel.php deleted file mode 100644 index abddff4b..00000000 --- a/src/StorageDrivers/Database/DomainModel.php +++ /dev/null @@ -1,25 +0,0 @@ -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'; + } +} diff --git a/src/StorageDrivers/Database/Repository.php b/src/StorageDrivers/Database/Repository.php new file mode 100644 index 00000000..e3cce981 --- /dev/null +++ b/src/StorageDrivers/Database/Repository.php @@ -0,0 +1,41 @@ +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); + } +} diff --git a/src/StorageDrivers/Database/TenantModel.php b/src/StorageDrivers/Database/TenantModel.php deleted file mode 100644 index 782d0308..00000000 --- a/src/StorageDrivers/Database/TenantModel.php +++ /dev/null @@ -1,124 +0,0 @@ -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), - ])); - } -} diff --git a/src/StorageDrivers/Database/TenantRepository.php b/src/StorageDrivers/Database/TenantRepository.php new file mode 100644 index 00000000..adaf054a --- /dev/null +++ b/src/StorageDrivers/Database/TenantRepository.php @@ -0,0 +1,186 @@ +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'; + } +} diff --git a/src/StorageDrivers/RedisStorageDriver.php b/src/StorageDrivers/RedisStorageDriver.php index 4e35834f..79852f8c 100644 --- a/src/StorageDrivers/RedisStorageDriver.php +++ b/src/StorageDrivers/RedisStorageDriver.php @@ -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); + } } diff --git a/src/TenancyBootstrappers/DatabaseTenancyBootstrapper.php b/src/TenancyBootstrappers/DatabaseTenancyBootstrapper.php index e93e7301..8920f44a 100644 --- a/src/TenancyBootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/TenancyBootstrappers/DatabaseTenancyBootstrapper.php @@ -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); } diff --git a/src/TenancyBootstrappers/FilesystemTenancyBootstrapper.php b/src/TenancyBootstrappers/FilesystemTenancyBootstrapper.php index b08a79e2..35ea5b2b 100644 --- a/src/TenancyBootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/TenancyBootstrappers/FilesystemTenancyBootstrapper.php @@ -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]); } } } diff --git a/src/TenancyBootstrappers/QueueTenancyBootstrapper.php b/src/TenancyBootstrappers/QueueTenancyBootstrapper.php index d37f9426..b2a0fbf2 100644 --- a/src/TenancyBootstrappers/QueueTenancyBootstrapper.php +++ b/src/TenancyBootstrappers/QueueTenancyBootstrapper.php @@ -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 [ diff --git a/src/TenancyBootstrappers/RedisTenancyBootstrapper.php b/src/TenancyBootstrappers/RedisTenancyBootstrapper.php index 53dbd339..ae20e2c4 100644 --- a/src/TenancyBootstrappers/RedisTenancyBootstrapper.php +++ b/src/TenancyBootstrappers/RedisTenancyBootstrapper.php @@ -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; diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index 7f8e86ad..b5c41c73 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -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); }); } } diff --git a/src/Tenant.php b/src/Tenant.php index 836e27b8..38ac7261 100644 --- a/src/Tenant.php +++ b/src/Tenant.php @@ -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); diff --git a/src/TenantDatabaseManagers/MySQLDatabaseManager.php b/src/TenantDatabaseManagers/MySQLDatabaseManager.php index 11d8c123..f6c4ef96 100644 --- a/src/TenantDatabaseManagers/MySQLDatabaseManager.php +++ b/src/TenantDatabaseManagers/MySQLDatabaseManager.php @@ -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'"); } } diff --git a/src/TenantDatabaseManagers/PostgreSQLDatabaseManager.php b/src/TenantDatabaseManagers/PostgreSQLDatabaseManager.php index d6c974e7..fc21668e 100644 --- a/src/TenantDatabaseManagers/PostgreSQLDatabaseManager.php +++ b/src/TenantDatabaseManagers/PostgreSQLDatabaseManager.php @@ -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'"); } } diff --git a/src/TenantDatabaseManagers/PostgreSQLSchemaManager.php b/src/TenantDatabaseManagers/PostgreSQLSchemaManager.php new file mode 100644 index 00000000..a93ed901 --- /dev/null +++ b/src/TenantDatabaseManagers/PostgreSQLSchemaManager.php @@ -0,0 +1,47 @@ +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'"); + } +} diff --git a/src/TenantManager.php b/src/TenantManager.php index 2fb5f412..103ecc8c 100644 --- a/src/TenantManager.php +++ b/src/TenantManager.php @@ -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); + } } diff --git a/src/TenantRouteServiceProvider.php b/src/TenantRouteServiceProvider.php index e0ab3335..1f36b35b 100644 --- a/src/TenantRouteServiceProvider.php +++ b/src/TenantRouteServiceProvider.php @@ -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')); + } + }); } } diff --git a/src/Traits/DealsWithMigrations.php b/src/Traits/DealsWithMigrations.php index 48531587..f730cf07 100644 --- a/src/Traits/DealsWithMigrations.php +++ b/src/Traits/DealsWithMigrations.php @@ -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')]); } } diff --git a/src/Traits/HasATenantArgument.php b/src/Traits/HasATenantArgument.php new file mode 100644 index 00000000..7ad0c846 --- /dev/null +++ b/src/Traits/HasATenantArgument.php @@ -0,0 +1,29 @@ +find($this->argument('tenant'))]; + } + + public function __construct() + { + parent::__construct(); + + $this->specifyParameters(); + } +} diff --git a/src/Traits/HasATenantsOption.php b/src/Traits/HasATenantsOption.php index 4d6b247d..103a3b91 100644 --- a/src/Traits/HasATenantsOption.php +++ b/src/Traits/HasATenantsOption.php @@ -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(); + } } diff --git a/src/Traits/TenantAwareCommand.php b/src/Traits/TenantAwareCommand.php new file mode 100644 index 00000000..fb11df9f --- /dev/null +++ b/src/Traits/TenantAwareCommand.php @@ -0,0 +1,38 @@ +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; +} diff --git a/src/helpers.php b/src/helpers.php index 56d4cf08..58aa8ccb 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -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)); + } +} diff --git a/src/routes.php b/src/routes.php index 69f51e70..093f5de4 100644 --- a/src/routes.php +++ b/src/routes.php @@ -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'); +}); diff --git a/test b/test index 3f8244b3..0d5a2556 100755 --- a/test +++ b/test @@ -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 "$@" diff --git a/tests/CacheManagerTest.php b/tests/CacheManagerTest.php index af2d8882..77e0c357 100644 --- a/tests/CacheManagerTest.php +++ b/tests/CacheManagerTest.php @@ -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); } diff --git a/tests/CachedResolverTest.php b/tests/CachedResolverTest.php new file mode 100644 index 00000000..0d32915e --- /dev/null +++ b/tests/CachedResolverTest.php @@ -0,0 +1,164 @@ +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)); + } +} diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index 4cf8a92c..39bb46e2 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -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 */ diff --git a/tests/DatabaseManagerTest.php b/tests/DatabaseManagerTest.php index cbd976e8..9195ef69 100644 --- a/tests/DatabaseManagerTest.php +++ b/tests/DatabaseManagerTest.php @@ -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(); + } } diff --git a/tests/DatabaseSchemaManagerTest.php b/tests/DatabaseSchemaManagerTest.php new file mode 100644 index 00000000..5f9589e0 --- /dev/null +++ b/tests/DatabaseSchemaManagerTest.php @@ -0,0 +1,143 @@ +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); + } +} diff --git a/tests/Etc/AddUserCommand.php b/tests/Etc/AddUserCommand.php new file mode 100644 index 00000000..a270d158 --- /dev/null +++ b/tests/Etc/AddUserCommand.php @@ -0,0 +1,38 @@ + 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), + ]); + } +} diff --git a/tests/Etc/ConsoleKernel.php b/tests/Etc/ConsoleKernel.php index a801b9dc..1bc66365 100644 --- a/tests/Etc/ConsoleKernel.php +++ b/tests/Etc/ConsoleKernel.php @@ -15,5 +15,6 @@ class ConsoleKernel extends Kernel */ protected $commands = [ ExampleCommand::class, + AddUserCommand::class, ]; } diff --git a/tests/Etc/defaultHttpKernel.stub b/tests/Etc/defaultHttpKernelv6.stub similarity index 100% rename from tests/Etc/defaultHttpKernel.stub rename to tests/Etc/defaultHttpKernelv6.stub diff --git a/tests/Etc/defaultHttpKernelv7.stub b/tests/Etc/defaultHttpKernelv7.stub new file mode 100644 index 00000000..d98a24d5 --- /dev/null +++ b/tests/Etc/defaultHttpKernelv7.stub @@ -0,0 +1,66 @@ + [ + \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, + ]; +} \ No newline at end of file diff --git a/tests/Etc/modifiedHttpKernel.stub b/tests/Etc/modifiedHttpKernelv6.stub similarity index 97% rename from tests/Etc/modifiedHttpKernel.stub rename to tests/Etc/modifiedHttpKernelv6.stub index 86cf535c..0de76bbd 100644 --- a/tests/Etc/modifiedHttpKernel.stub +++ b/tests/Etc/modifiedHttpKernelv6.stub @@ -39,6 +39,7 @@ class Kernel extends HttpKernel ], 'api' => [ + \Stancl\Tenancy\Middleware\PreventAccessFromTenantDomains::class, 'throttle:60,1', 'bindings', ], diff --git a/tests/Etc/modifiedHttpKernelv7.stub b/tests/Etc/modifiedHttpKernelv7.stub new file mode 100644 index 00000000..037a57f4 --- /dev/null +++ b/tests/Etc/modifiedHttpKernelv7.stub @@ -0,0 +1,80 @@ + [ + \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, + ]; +} \ No newline at end of file diff --git a/tests/TenantConfigTest.php b/tests/Features/TenantConfigTest.php similarity index 93% rename from tests/TenantConfigTest.php rename to tests/Features/TenantConfigTest.php index 68c2cf4a..db8fcaf3 100644 --- a/tests/TenantConfigTest.php +++ b/tests/Features/TenantConfigTest.php @@ -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 { diff --git a/tests/Features/TimestampTest.php b/tests/Features/TimestampTest.php new file mode 100644 index 00000000..25cb1017 --- /dev/null +++ b/tests/Features/TimestampTest.php @@ -0,0 +1,56 @@ + [ + 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); + } +} diff --git a/tests/FutureTest.php b/tests/FutureTest.php new file mode 100644 index 00000000..6a44a80e --- /dev/null +++ b/tests/FutureTest.php @@ -0,0 +1,45 @@ +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); + } +} diff --git a/tests/QueueTest.php b/tests/QueueTest.php index 7d24fdc3..d657fc66 100644 --- a/tests/QueueTest.php +++ b/tests/QueueTest.php @@ -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 diff --git a/tests/TenantRedirectMacroTest.php b/tests/RedirectTest.php similarity index 56% rename from tests/TenantRedirectMacroTest.php rename to tests/RedirectTest.php index 970579ec..c0bd90a8 100644 --- a/tests/TenantRedirectMacroTest.php +++ b/tests/RedirectTest.php @@ -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'])); + } } diff --git a/tests/RequestDataIdentificationTest.php b/tests/RequestDataIdentificationTest.php new file mode 100644 index 00000000..fcbd0997 --- /dev/null +++ b/tests/RequestDataIdentificationTest.php @@ -0,0 +1,62 @@ + [ + '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); + } +} diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index 248e7703..b9bbabad 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -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')); + } } diff --git a/tests/TenantClassTest.php b/tests/TenantClassTest.php index 4a3775f2..38cc27cd 100644 --- a/tests/TenantClassTest.php +++ b/tests/TenantClassTest.php @@ -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; + })); + } } diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index fc3c34f4..89c0bbd7 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -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], ]; } diff --git a/tests/TenantManagerEventsTest.php b/tests/TenantManagerEventsTest.php index eca5077f..4dde735c 100644 --- a/tests/TenantManagerEventsTest.php +++ b/tests/TenantManagerEventsTest.php @@ -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); + } } diff --git a/tests/TenantManagerTest.php b/tests/TenantManagerTest.php index 7f8aae0d..8e78a607 100644 --- a/tests/TenantManagerTest.php +++ b/tests/TenantManagerTest.php @@ -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(); } } diff --git a/tests/TenantStorageTest.php b/tests/TenantStorageTest.php index 88bd5823..568746cb 100644 --- a/tests/TenantStorageTest.php +++ b/tests/TenantStorageTest.php @@ -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); } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 19b0a83f..5d865549 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -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); diff --git a/tests/Traits/TenantAwareCommandTest.php b/tests/Traits/TenantAwareCommandTest.php new file mode 100644 index 00000000..fcc3d0e2 --- /dev/null +++ b/tests/Traits/TenantAwareCommandTest.php @@ -0,0 +1,35 @@ +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(); + } +}