diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md deleted file mode 100644 index 9f3d2e65..00000000 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -name: "\U0001F41B Bug Report" -about: Report unexpected behavior with stancl/tenancy. -title: '' -labels: bug -assignees: stancl - ---- - -#### Describe the bug - - -#### Steps to reproduce - - -#### Expected behavior -A clear and concise description of what you expected to happen. - -#### Your setup - - Laravel version: [e.g. 8.2.0] - - stancl/tenancy version: [e.g. 3.1.0] diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 00000000..75e345b2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,48 @@ +name: 🐛 Bug Report +description: Report unexpected behavior with stancl/tenancy. +labels: ["bug"] +assignees: + - stancl +body: + - type: markdown + attributes: + value: | + Before opening a bug report, please search for the behaviour in the existing issues. + --- + Thank you for taking the time to file a bug report. To address this bug as fast as possible, we need some information. + - type: textarea + id: bug-description + attributes: + label: Bug description + description: A clear and concise description of what the bug is. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Step-by-step guide for reproducing the bug in a fresh Laravel application. + validations: + required: true + - type: textarea + id: logs + attributes: + label: Expected behavior + description: A clear and concise description of what you expected to happen. + validations: + required: true + + - type: input + id: laravel-version + attributes: + label: Laravel version + placeholder: "e.g. 8.2.0" + validations: + required: true + - type: input + id: tenancy-version + attributes: + label: stancl/tenancy version + placeholder: "e.g. 3.1.0" + validations: + required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efb8ad02..3cbda814 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,12 +2,14 @@ name: CI env: COMPOSE_INTERACTIVE_NO_CLI: 1 + PHP_CS_FIXER_IGNORE_ENV: 1 + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} on: push: - branches: [ 3.x, 2.x, master ] + branches: [ master ] pull_request: - branches: [ 3.x, 2.x, master ] + branches: [ master ] jobs: tests: @@ -15,8 +17,8 @@ jobs: strategy: matrix: - php: ["7.4", "8.0"] - laravel: ["^6.0", "^8.0"] + php: ["8.1"] + laravel: ["^9.0"] steps: - uses: actions/checkout@v2 @@ -26,7 +28,19 @@ jobs: run: docker-compose exec -T test composer require --no-interaction "laravel/framework:${{ matrix.laravel }}" - name: Run tests run: ./test - - name: Send code coverage to codecov - env: - CODECOV_TOKEN: 24382d15-84e7-4a55-bea4-c4df96a24a9b - run: bash <(curl -s https://codecov.io/bash) + + php-cs-fixer: + name: Code style (php-cs-fixer) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install php-cs-fixer + run: composer global require friendsofphp/php-cs-fixer + - name: Run php-cs-fixer + run: $HOME/.composer/vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php + - name: Commit changes from php-cs-fixer + uses: EndBug/add-and-commit@v5 + with: + author_name: "PHP CS Fixer" + author_email: "phpcsfixer@example.com" + message: Fix code style (php-cs-fixer) diff --git a/.gitignore b/.gitignore index 1d03dbec..64d9dc21 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ psysh phpunit_var_*.xml coverage/ clover.xml +tenant-schema-test.dump +tests/Etc/tmp/queuetest.json +docker-compose.override.yml +.php-cs-fixer.cache diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php new file mode 100644 index 00000000..589838bc --- /dev/null +++ b/.php-cs-fixer.php @@ -0,0 +1,141 @@ + ['syntax' => 'short'], + 'binary_operator_spaces' => [ + 'default' => 'single_space', + 'operators' => [ + '=>' => null, + '|' => 'no_space', + ] + ], + 'blank_line_after_namespace' => true, + 'blank_line_after_opening_tag' => true, + 'no_superfluous_phpdoc_tags' => true, + 'blank_line_before_statement' => [ + 'statements' => ['return'] + ], + 'braces' => true, + 'cast_spaces' => true, + 'class_definition' => true, + 'concat_space' => [ + 'spacing' => 'one' + ], + 'declare_equal_normalize' => true, + 'elseif' => true, + 'encoding' => true, + 'full_opening_tag' => true, + 'declare_strict_types' => true, + 'fully_qualified_strict_types' => true, // added by Shift + 'function_declaration' => true, + 'function_typehint_space' => true, + 'heredoc_to_nowdoc' => true, + 'include' => true, + 'increment_style' => ['style' => 'post'], + 'indentation_type' => true, + 'linebreak_after_opening_tag' => true, + 'line_ending' => true, + 'lowercase_cast' => true, + 'constant_case' => true, + 'lowercase_keywords' => true, + 'lowercase_static_reference' => true, // added from Symfony + 'magic_method_casing' => true, // added from Symfony + 'magic_constant_casing' => true, + 'method_argument_space' => true, + 'native_function_casing' => true, + 'no_alias_functions' => true, + 'no_extra_blank_lines' => [ + 'tokens' => [ + 'extra', + 'throw', + 'use', + 'use_trait', + ] + ], + 'no_blank_lines_after_class_opening' => true, + 'no_blank_lines_after_phpdoc' => true, + 'no_closing_tag' => true, + 'no_empty_phpdoc' => true, + 'no_empty_statement' => true, + 'no_leading_import_slash' => true, + 'no_leading_namespace_whitespace' => true, + 'no_mixed_echo_print' => [ + 'use' => 'echo' + ], + 'no_multiline_whitespace_around_double_arrow' => true, + 'multiline_whitespace_before_semicolons' => [ + 'strategy' => 'no_multi_line' + ], + 'no_short_bool_cast' => true, + 'no_singleline_whitespace_before_semicolons' => true, + 'no_spaces_after_function_name' => true, + 'no_spaces_around_offset' => true, + 'no_spaces_inside_parenthesis' => true, + 'no_trailing_comma_in_list_call' => true, + 'no_trailing_comma_in_singleline_array' => true, + 'no_trailing_whitespace' => true, + 'no_trailing_whitespace_in_comment' => true, + 'no_unneeded_control_parentheses' => true, + 'no_unreachable_default_argument_value' => true, + 'no_useless_return' => true, + 'no_whitespace_before_comma_in_array' => true, + 'no_whitespace_in_blank_line' => true, + 'normalize_index_brace' => true, + 'not_operator_with_successor_space' => true, + 'object_operator_without_whitespace' => true, + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'phpdoc_indent' => true, + 'general_phpdoc_tag_rename' => true, + 'phpdoc_no_access' => true, + 'phpdoc_no_package' => true, + 'phpdoc_no_useless_inheritdoc' => true, + 'phpdoc_scalar' => true, + 'phpdoc_single_line_var_spacing' => true, + 'phpdoc_summary' => true, + 'phpdoc_to_comment' => false, + 'phpdoc_trim' => true, + 'phpdoc_types' => true, + 'phpdoc_var_without_name' => true, + 'psr_autoloading' => true, + 'self_accessor' => true, + 'short_scalar_cast' => true, + 'simplified_null_return' => false, // disabled by Shift + 'single_blank_line_at_eof' => true, + 'single_blank_line_before_namespace' => true, + 'single_class_element_per_statement' => true, + 'single_import_per_statement' => true, + 'single_line_after_imports' => true, + 'no_unused_imports' => true, + 'single_line_comment_style' => [ + 'comment_types' => ['hash'] + ], + 'single_quote' => true, + 'space_after_semicolon' => true, + 'standardize_not_equals' => true, + 'switch_case_semicolon_to_colon' => true, + 'switch_case_space' => true, + 'ternary_operator_spaces' => true, + 'trailing_comma_in_multiline' => true, + 'trim_array_spaces' => true, + 'unary_operator_spaces' => true, + 'whitespace_after_comma_in_array' => true, +]; + +$project_path = getcwd(); +$finder = Finder::create() + ->in([ + $project_path . '/src', + ]) + ->name('*.php') + ->notName('*.blade.php') + ->ignoreDotFiles(true) + ->ignoreVCS(true); + +return (new Config()) + ->setFinder($finder) + ->setRules($rules) + ->setRiskyAllowed(true) + ->setUsingCache(true); diff --git a/.styleci.yml b/.styleci.yml deleted file mode 100644 index e6d2b2c1..00000000 --- a/.styleci.yml +++ /dev/null @@ -1,7 +0,0 @@ -risky: true -preset: laravel -enabled: -- declare_strict_types -disabled: -- concat_without_spaces -- ternary_operator_spaces diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7dce1b82..a5a6ec3f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,3 +9,16 @@ StyleCI will flag code style violations in your pull requests. Run `docker-compose up -d` to start the containers. Then run `./test` to run the tests. When you're done testing, run `docker-compose down` to shut down the containers. + +### Docker on M1 + +You can add: +```yaml +services: + mysql: + platform: linux/amd64 + mysql2: + platform: linux/amd64 +``` + +to `docker-compose.override.yml` to make `docker-compose up-d` work on M1. diff --git a/Dockerfile b/Dockerfile index 06d97aea..fb63afe3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ ARG PHP_VERSION=7.4 ARG PHP_TARGET=php:${PHP_VERSION}-cli -FROM ${PHP_TARGET} +FROM --platform=linux/amd64 ${PHP_TARGET} ARG COMPOSER_TARGET=2.0.3 @@ -22,20 +22,29 @@ ENV LANG=en_GB.UTF-8 # Dockerfile _and pin the versions_! Eg: # RUN pecl install memcached-2.2.0 && docker-php-ext-enable memcached -# install some OS packages we need -RUN apt-get update -RUN apt-get install -y --no-install-recommends libfreetype6-dev libjpeg62-turbo-dev libpng-dev libgmp-dev libldap2-dev netcat curl sqlite3 libsqlite3-dev libpq-dev libzip-dev unzip vim-tiny gosu git - # install php extensions + +RUN apt-get update \ + && apt-get install -y gnupg2 \ + && curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add - \ + && curl https://packages.microsoft.com/config/debian/11/prod.list > /etc/apt/sources.list.d/mssql-release.list \ + && apt-get update \ + && ACCEPT_EULA=Y apt-get install -y unixodbc-dev msodbcsql17 + +RUN apt-get install -y --no-install-recommends locales apt-transport-https libfreetype6-dev libjpeg62-turbo-dev libpng-dev libgmp-dev libldap2-dev netcat curl mariadb-client sqlite3 libsqlite3-dev libpq-dev libzip-dev unzip vim-tiny gosu git + RUN docker-php-ext-configure pgsql -with-pgsql=/usr/local/pgsql \ # && if [ "${PHP_VERSION}" = "7.4" ]; then docker-php-ext-configure gd --with-freetype --with-jpeg; else docker-php-ext-configure gd --with-freetype-dir=/usr/include/ --with-jpeg-dir=/usr/include/; fi \ && docker-php-ext-install -j$(nproc) gd pdo pdo_mysql pdo_pgsql pdo_sqlite pgsql zip gmp bcmath pcntl ldap sysvmsg exif \ # install the redis php extension - && pecl install redis-5.3.2 \ + && pecl install redis-5.3.7 \ && docker-php-ext-enable redis \ # install the pcov extention && pecl install pcov \ && docker-php-ext-enable pcov \ - && echo "pcov.enabled = 1" > /usr/local/etc/php/conf.d/pcov.ini + && echo "pcov.enabled = 1" > /usr/local/etc/php/conf.d/pcov.ini \ + # install sqlsrv + && pecl install sqlsrv pdo_sqlsrv \ + && docker-php-ext-enable sqlsrv pdo_sqlsrv # clear the apt cache RUN rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \ diff --git a/README.md b/README.md index 46f1b097..95fb7c60 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,9 @@

- Laravel 6.x/7.x/8.x + Laravel 9.x Latest Stable Version GitHub Actions CI status - codecov Donate

diff --git a/SUPPORT.md b/SUPPORT.md index b7caaa5c..24be468b 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -1,5 +1,5 @@ # Get Support -If you need help with implementing the package, you can join our community [Discord server](https://discord.gg/7cpgPxv) and ask in `#help`. +If you need help with implementing the package, you can join our community [Discord server](https://archte.ch/discord) and ask in `#help`. If you're interested in paid consulting from the maintainer, see the [contact page](https://tenancyforlaravel.com/contact/) on our website. diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 1d15f418..865bb93d 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -40,7 +40,13 @@ class TenancyServiceProvider extends ServiceProvider Events\TenantSaved::class => [], Events\UpdatingTenant::class => [], Events\TenantUpdated::class => [], - Events\DeletingTenant::class => [], + Events\DeletingTenant::class => [ + JobPipeline::make([ + Jobs\DeleteDomains::class, + ])->send(function (Events\DeletingTenant $event) { + return $event->tenant; + })->shouldBeQueued(false), + ], Events\TenantDeleted::class => [ JobPipeline::make([ Jobs\DeleteDatabase::class, diff --git a/assets/config.php b/assets/config.php index 029591ad..e1c82e6b 100644 --- a/assets/config.php +++ b/assets/config.php @@ -42,7 +42,8 @@ return [ 'central_connection' => env('DB_CONNECTION', 'central'), /** - * Connection used as a "template" for the tenant database connection. + * Connection used as a "template" for the dynamically created tenant database connection. + * Note: don't name your template connection tenant. That name is reserved by package. */ 'template_tenant_connection' => null, @@ -60,6 +61,7 @@ return [ 'sqlite' => Stancl\Tenancy\TenantDatabaseManagers\SQLiteDatabaseManager::class, 'mysql' => Stancl\Tenancy\TenantDatabaseManagers\MySQLDatabaseManager::class, 'pgsql' => Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLDatabaseManager::class, + 'sqlsrv' => Stancl\Tenancy\TenantDatabaseManagers\MicrosoftSQLDatabaseManager::class, /** * Use this database manager for MySQL to have a DB user created for each tenant database. diff --git a/assets/migrations/2019_09_15_000020_create_domains_table.php b/assets/migrations/2019_09_15_000020_create_domains_table.php index 77c1b88a..17f706c2 100644 --- a/assets/migrations/2019_09_15_000020_create_domains_table.php +++ b/assets/migrations/2019_09_15_000020_create_domains_table.php @@ -21,7 +21,7 @@ class CreateDomainsTable extends Migration $table->string('tenant_id'); $table->timestamps(); - $table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade'); + $table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade'); }); } diff --git a/composer.json b/composer.json index b10f2d16..8123944a 100644 --- a/composer.json +++ b/composer.json @@ -11,17 +11,17 @@ ], "require": { "ext-json": "*", - "illuminate/support": "^6.0|^7.0|^8.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0", "facade/ignition-contracts": "^1.0", "ramsey/uuid": "^3.7|^4.0", - "stancl/jobpipeline": "^1.0", - "stancl/virtualcolumn": "^1.0" + "stancl/jobpipeline": "dev-master", + "stancl/virtualcolumn": "dev-master" }, "require-dev": { - "vlucas/phpdotenv": "^3.3|^4.0|^5.0", - "laravel/framework": "^6.0|^7.0|^8.0", - "orchestra/testbench-browser-kit": "^4.0|^5.0|^6.0", - "league/flysystem-aws-s3-v3": "~1.0", + "laravel/framework": "^6.0|^7.0|^8.0|^9.0", + "orchestra/testbench": "^4.0|^5.0|^6.0|^7.0", + "phpunit/phpunit": "*", + "league/flysystem-aws-s3-v3": "^1.0|^3.0", "doctrine/dbal": "^2.10", "spatie/valuestore": "^1.2.5" }, @@ -49,6 +49,12 @@ } } }, + "scripts": { + "docker-up": "PHP_VERSION=8.0.11 docker-compose up -d", + "docker-down": "PHP_VERSION=8.0.11 docker-compose down", + "docker-rebuild": "PHP_VERSION=8.0.11 docker-compose up -d --no-deps --build", + "test": "PHP_VERSION=8.0.11 ./test" + }, "minimum-stability": "dev", "prefer-stable": true } diff --git a/docker-compose.yml b/docker-compose.yml index 30d87dfd..7b635637 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: build: context: . args: - PHP_VERSION: ${PHP_VERSION} + PHP_VERSION: ${PHP_VERSION:-8.1} depends_on: mysql: condition: service_healthy @@ -22,6 +22,9 @@ services: TENANCY_TEST_REDIS_HOST: redis TENANCY_TEST_MYSQL_HOST: mysql TENANCY_TEST_PGSQL_HOST: postgres + TENANCY_TEST_SQLSRV_HOST: mssql + TENANCY_TEST_SQLSRV_USERNAME: sa + TENANCY_TEST_SQLSRV_PASSWORD: P@ssword stdin_open: true tty: true mysql: @@ -64,3 +67,11 @@ services: interval: 1s timeout: 3s retries: 30 + mssql: + image: mcr.microsoft.com/mssql/server:2019-latest + ports: + - 1433:1433 + environment: + - ACCEPT_EULA=Y + - SA_PASSWORD=P@ssword # todo reuse values from env above + # todo missing health check diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index a107fc0d..59ee0aec 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -6,6 +6,7 @@ namespace Stancl\Tenancy\Bootstrappers; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; +use Stancl\Tenancy\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\DatabaseManager; use Stancl\Tenancy\Exceptions\TenantDatabaseDoesNotExistException; diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index d5ae2d50..6f720e7c 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Stancl\Tenancy\Bootstrappers; use Illuminate\Contracts\Foundation\Application; -use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Support\Facades\Storage; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; @@ -54,20 +53,24 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper } // Storage facade - foreach ($this->app['config']['tenancy.filesystem.disks'] as $disk) { - /** @var FilesystemAdapter $filesystemDisk */ - $filesystemDisk = Storage::disk($disk); - $this->originalPaths['disks'][$disk] = $filesystemDisk->getAdapter()->getPathPrefix(); + Storage::forgetDisk($this->app['config']['tenancy.filesystem.disks']); - if ($root = str_replace( - '%storage_path%', - storage_path(), - $this->app['config']["tenancy.filesystem.root_override.{$disk}"] ?? '' - )) { - $filesystemDisk->getAdapter()->setPathPrefix($finalPrefix = $root); - } else { - $root = $this->app['config']["filesystems.disks.{$disk}.root"]; - $filesystemDisk->getAdapter()->setPathPrefix($finalPrefix = $root . "/{$suffix}"); + foreach ($this->app['config']['tenancy.filesystem.disks'] as $disk) { + // todo@v4 \League\Flysystem\PathPrefixer is making this a lot more painful in flysystem v2 + + $originalRoot = $this->app['config']["filesystems.disks.{$disk}.root"]; + $this->originalPaths['disks'][$disk] = $originalRoot; + + $finalPrefix = str_replace( + ['%storage_path%', '%tenant%'], + [storage_path(), $tenant->getTenantKey()], + $this->app['config']["tenancy.filesystem.root_override.{$disk}"] ?? '', + ); + + if (! $finalPrefix) { + $finalPrefix = $originalRoot + ? rtrim($originalRoot, '/') . '/' . $suffix + : $suffix; } $this->app['config']["filesystems.disks.{$disk}.root"] = $finalPrefix; @@ -84,14 +87,9 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper $this->app['url']->setAssetRoot($this->app['config']['app.asset_url']); // Storage facade + Storage::forgetDisk($this->app['config']['tenancy.filesystem.disks']); foreach ($this->app['config']['tenancy.filesystem.disks'] as $disk) { - /** @var FilesystemAdapter $filesystemDisk */ - $filesystemDisk = Storage::disk($disk); - - $root = $this->originalPaths['disks'][$disk]; - - $filesystemDisk->getAdapter()->setPathPrefix($root); - $this->app['config']["filesystems.disks.{$disk}.root"] = $root; + $this->app['config']["filesystems.disks.{$disk}.root"] = $this->originalPaths['disks'][$disk]; } } } diff --git a/src/Bootstrappers/QueueTenancyBootstrapper.php b/src/Bootstrappers/QueueTenancyBootstrapper.php index 6fefaad2..2f859ecd 100644 --- a/src/Bootstrappers/QueueTenancyBootstrapper.php +++ b/src/Bootstrappers/QueueTenancyBootstrapper.php @@ -7,7 +7,10 @@ namespace Stancl\Tenancy\Bootstrappers; use Illuminate\Config\Repository; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Foundation\Application; +use Illuminate\Queue\Events\JobFailed; +use Illuminate\Queue\Events\JobProcessed; use Illuminate\Queue\Events\JobProcessing; +use Illuminate\Queue\Events\JobRetryRequested; use Illuminate\Queue\QueueManager; use Illuminate\Support\Testing\Fakes\QueueFake; use Stancl\Tenancy\Contracts\TenancyBootstrapper; @@ -21,6 +24,16 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper /** @var QueueManager */ protected $queue; + /** + * Don't persist the same tenant across multiple jobs even if they have the same tenant ID. + * + * This is useful when you're changing the tenant's state (e.g. properties in the `data` column) and want the next job to initialize tenancy again + * with the new data. Features like the Tenant Config are only executed when tenancy is initialized, so the re-initialization is needed in some cases. + * + * @var bool + */ + public static $forceRefresh = false; + /** * The normal constructor is only executed after tenancy is bootstrapped. * However, we're registering a hook to initialize tenancy. Therefore, @@ -28,7 +41,7 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper */ public static function __constructStatic(Application $app) { - static::setUpJobListener($app->make(Dispatcher::class)); + static::setUpJobListener($app->make(Dispatcher::class), $app->runningUnitTests()); } public function __construct(Repository $config, QueueManager $queue) @@ -39,25 +52,90 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper $this->setUpPayloadGenerator(); } - protected static function setUpJobListener($dispatcher) + protected static function setUpJobListener($dispatcher, $runningTests) { - $dispatcher->listen(JobProcessing::class, function ($event) { - $tenantId = $event->job->payload()['tenant_id'] ?? null; + $previousTenant = null; - // The job is not tenant-aware - if (! $tenantId) { - return; - } + $dispatcher->listen(JobProcessing::class, function ($event) use (&$previousTenant) { + $previousTenant = tenant(); - // Tenancy is already initialized for the tenant (e.g. dispatchNow was used) - if (tenancy()->initialized && tenant()->getTenantKey() === $tenantId) { - return; - } - - // Tenancy was either not initialized, or initialized for a different tenant. - // Therefore, we initialize it for the correct tenant. - tenancy()->initialize(tenancy()->find($tenantId)); + static::initializeTenancyForQueue($event->job->payload()['tenant_id'] ?? null); }); + + if (version_compare(app()->version(), '8.64', '>=')) { + // JobRetryRequested only exists since Laravel 8.64 + $dispatcher->listen(JobRetryRequested::class, function ($event) use (&$previousTenant) { + $previousTenant = tenant(); + + static::initializeTenancyForQueue($event->payload()['tenant_id'] ?? null); + }); + } + + // If we're running tests, we make sure to clean up after any artisan('queue:work') calls + $revertToPreviousState = function ($event) use (&$previousTenant, $runningTests) { + if ($runningTests) { + static::revertToPreviousState($event, $previousTenant); + } + }; + + $dispatcher->listen(JobProcessed::class, $revertToPreviousState); // artisan('queue:work') which succeeds + $dispatcher->listen(JobFailed::class, $revertToPreviousState); // artisan('queue:work') which fails + } + + protected static function initializeTenancyForQueue($tenantId) + { + if (! $tenantId) { + // The job is not tenant-aware + if (tenancy()->initialized) { + // Tenancy was initialized, so we revert back to the central context + tenancy()->end(); + } + + return; + } + + if (static::$forceRefresh) { + // Re-initialize tenancy between all jobs + if (tenancy()->initialized) { + tenancy()->end(); + } + + tenancy()->initialize(tenancy()->find($tenantId)); + + return; + } + + if (tenancy()->initialized) { + // Tenancy is already initialized + if (tenant()->getTenantKey() === $tenantId) { + // It's initialized for the same tenant (e.g. dispatchNow was used, or the previous job also ran for this tenant) + return; + } + } + + // Tenancy was either not initialized, or initialized for a different tenant. + // Therefore, we initialize it for the correct tenant. + tenancy()->initialize(tenancy()->find($tenantId)); + } + + protected static function revertToPreviousState($event, &$previousTenant) + { + $tenantId = $event->job->payload()['tenant_id'] ?? null; + + // The job was not tenant-aware + if (! $tenantId) { + return; + } + + // Revert back to the previous tenant + if (tenant() && $previousTenant && $previousTenant->isNot(tenant())) { + tenancy()->initialize($previousTenant); + } + + // End tenancy + if (tenant() && (! $previousTenant)) { + tenancy()->end(); + } } protected function setUpPayloadGenerator() diff --git a/src/CacheManager.php b/src/CacheManager.php index f7190842..09581201 100644 --- a/src/CacheManager.php +++ b/src/CacheManager.php @@ -13,15 +13,16 @@ class CacheManager extends BaseCacheManager * * @param string $method * @param array $parameters - * @return mixed */ public function __call($method, $parameters) { $tags = [config('tenancy.cache.tag_base') . tenant()->getTenantKey()]; if ($method === 'tags') { - if (count($parameters) !== 1) { - throw new \Exception("Method tags() takes exactly 1 argument. {count($parameters)} passed."); + $count = count($parameters); + + if ($count !== 1) { + throw new \Exception("Method tags() takes exactly 1 argument. $count passed."); } $names = $parameters[0]; diff --git a/src/Commands/Install.php b/src/Commands/Install.php index dd2dd280..41492b26 100644 --- a/src/Commands/Install.php +++ b/src/Commands/Install.php @@ -24,8 +24,6 @@ class Install extends Command /** * Execute the console command. - * - * @return mixed */ public function handle() { diff --git a/src/Commands/Migrate.php b/src/Commands/Migrate.php index 4bf8408c..52ecd47f 100644 --- a/src/Commands/Migrate.php +++ b/src/Commands/Migrate.php @@ -8,39 +8,31 @@ use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\Console\Migrations\MigrateCommand; use Illuminate\Database\Migrations\Migrator; use Stancl\Tenancy\Concerns\DealsWithMigrations; +use Stancl\Tenancy\Concerns\ExtendsLaravelCommand; use Stancl\Tenancy\Concerns\HasATenantsOption; use Stancl\Tenancy\Events\DatabaseMigrated; use Stancl\Tenancy\Events\MigratingDatabase; class Migrate extends MigrateCommand { - use HasATenantsOption, DealsWithMigrations; + use HasATenantsOption, DealsWithMigrations, ExtendsLaravelCommand; - /** - * The console command description. - * - * @var string - */ protected $description = 'Run migrations for tenant(s)'; - /** - * Create a new command instance. - * - * @param Migrator $migrator - * @param Dispatcher $dispatcher - */ + protected static function getTenantCommandName(): string + { + return 'tenants:migrate'; + } + public function __construct(Migrator $migrator, Dispatcher $dispatcher) { parent::__construct($migrator, $dispatcher); - $this->setName('tenants:migrate'); $this->specifyParameters(); } /** * Execute the console command. - * - * @return mixed */ public function handle() { @@ -55,7 +47,7 @@ class Migrate extends MigrateCommand } tenancy()->runForMultiple($this->option('tenants'), function ($tenant) { - $this->line("Tenant: {$tenant['id']}"); + $this->line("Tenant: {$tenant->getTenantKey()}"); event(new MigratingDatabase($tenant)); diff --git a/src/Commands/MigrateFresh.php b/src/Commands/MigrateFresh.php index f50e2f5f..63860153 100644 --- a/src/Commands/MigrateFresh.php +++ b/src/Commands/MigrateFresh.php @@ -7,6 +7,7 @@ namespace Stancl\Tenancy\Commands; use Illuminate\Console\Command; use Stancl\Tenancy\Concerns\DealsWithMigrations; use Stancl\Tenancy\Concerns\HasATenantsOption; +use Symfony\Component\Console\Input\InputOption; final class MigrateFresh extends Command { @@ -23,13 +24,13 @@ final class MigrateFresh extends Command { parent::__construct(); + $this->addOption('--drop-views', null, InputOption::VALUE_NONE, 'Drop views along with tenant tables.', null); + $this->setName('tenants:migrate-fresh'); } /** * Execute the console command. - * - * @return mixed */ public function handle() { @@ -37,6 +38,7 @@ final class MigrateFresh extends Command $this->info('Dropping tables.'); $this->call('db:wipe', array_filter([ '--database' => 'tenant', + '--drop-views' => $this->option('drop-views'), '--force' => true, ])); diff --git a/src/Commands/Rollback.php b/src/Commands/Rollback.php index ec9cc461..1c434189 100644 --- a/src/Commands/Rollback.php +++ b/src/Commands/Rollback.php @@ -7,13 +7,19 @@ namespace Stancl\Tenancy\Commands; use Illuminate\Database\Console\Migrations\RollbackCommand; use Illuminate\Database\Migrations\Migrator; use Stancl\Tenancy\Concerns\DealsWithMigrations; +use Stancl\Tenancy\Concerns\ExtendsLaravelCommand; use Stancl\Tenancy\Concerns\HasATenantsOption; use Stancl\Tenancy\Events\DatabaseRolledBack; use Stancl\Tenancy\Events\RollingBackDatabase; class Rollback extends RollbackCommand { - use HasATenantsOption, DealsWithMigrations; + use HasATenantsOption, DealsWithMigrations, ExtendsLaravelCommand; + + protected static function getTenantCommandName(): string + { + return 'tenants:rollback'; + } /** * The console command description. @@ -31,14 +37,11 @@ class Rollback extends RollbackCommand { parent::__construct($migrator); - $this->setName('tenants:rollback'); - $this->specifyParameters(); + $this->specifyTenantSignature(); } /** * Execute the console command. - * - * @return mixed */ public function handle() { @@ -53,7 +56,7 @@ class Rollback extends RollbackCommand } tenancy()->runForMultiple($this->option('tenants'), function ($tenant) { - $this->line("Tenant: {$tenant['id']}"); + $this->line("Tenant: {$tenant->getTenantKey()}"); event(new RollingBackDatabase($tenant)); diff --git a/src/Commands/Run.php b/src/Commands/Run.php index c2770825..2b20d9c3 100644 --- a/src/Commands/Run.php +++ b/src/Commands/Run.php @@ -27,14 +27,11 @@ class Run extends Command /** * Execute the console command. - * - * @return mixed */ public function handle() { tenancy()->runForMultiple($this->option('tenants'), function ($tenant) { - $this->line("Tenant: {$tenant['id']}"); - tenancy()->initialize($tenant); + $this->line("Tenant: {$tenant->getTenantKey()}"); $callback = function ($prefix = '') { return function ($arguments, $argument) use ($prefix) { diff --git a/src/Commands/Seed.php b/src/Commands/Seed.php index 43038107..8c525208 100644 --- a/src/Commands/Seed.php +++ b/src/Commands/Seed.php @@ -35,8 +35,6 @@ class Seed extends SeedCommand /** * Execute the console command. - * - * @return mixed */ public function handle() { @@ -51,7 +49,7 @@ class Seed extends SeedCommand } tenancy()->runForMultiple($this->option('tenants'), function ($tenant) { - $this->line("Tenant: {$tenant['id']}"); + $this->line("Tenant: {$tenant->getTenantKey()}"); event(new SeedingDatabase($tenant)); diff --git a/src/Commands/TenantDump.php b/src/Commands/TenantDump.php new file mode 100644 index 00000000..9c8698c6 --- /dev/null +++ b/src/Commands/TenantDump.php @@ -0,0 +1,53 @@ +setName('tenants:dump'); + $this->specifyParameters(); + } + + public function handle(ConnectionResolverInterface $connections, Dispatcher $dispatcher): int + { + $this->tenant()->run(fn () => parent::handle($connections, $dispatcher)); + + return Command::SUCCESS; + } + + public function tenant(): Tenant + { + $tenant = $this->option('tenant') + ?? tenant() + ?? $this->ask('What tenant do you want to dump the schema for?') + ?? tenancy()->query()->first(); + + if (! $tenant instanceof Tenant) { + $tenant = tenancy()->find($tenant); + } + + throw_if(! $tenant, 'Could not identify the tenant to use for dumping the schema.'); + + return $tenant; + } + + protected function getOptions(): array + { + return array_merge([ + ['tenant', null, InputOption::VALUE_OPTIONAL, '', null], + ], parent::getOptions()); + } +} diff --git a/src/Commands/TenantList.php b/src/Commands/TenantList.php index 493b5a93..13775676 100644 --- a/src/Commands/TenantList.php +++ b/src/Commands/TenantList.php @@ -25,8 +25,6 @@ class TenantList extends Command /** * Execute the console command. - * - * @return mixed */ public function handle() { @@ -36,9 +34,9 @@ class TenantList extends Command ->cursor() ->each(function (Tenant $tenant) { if ($tenant->domains) { - $this->line("[Tenant] id: {$tenant['id']} @ " . implode('; ', $tenant->domains->pluck('domain')->toArray() ?? [])); + $this->line("[Tenant] {$tenant->getTenantKeyName()}: {$tenant->getTenantKey()} @ " . implode('; ', $tenant->domains->pluck('domain')->toArray() ?? [])); } else { - $this->line("[Tenant] id: {$tenant['id']}"); + $this->line("[Tenant] {$tenant->getTenantKeyName()}: {$tenant->getTenantKey()}"); } }); } diff --git a/src/Concerns/ExtendsLaravelCommand.php b/src/Concerns/ExtendsLaravelCommand.php new file mode 100644 index 00000000..d08ad6b6 --- /dev/null +++ b/src/Concerns/ExtendsLaravelCommand.php @@ -0,0 +1,25 @@ +specifyParameters(); + } + + public function getName(): ?string + { + return static::getTenantCommandName(); + } + + public static function getDefaultName(): ?string + { + return static::getTenantCommandName(); + } + + abstract protected static function getTenantCommandName(): string; +} diff --git a/src/Concerns/HasATenantsOption.php b/src/Concerns/HasATenantsOption.php index a2b94ac5..32d508ec 100644 --- a/src/Concerns/HasATenantsOption.php +++ b/src/Concerns/HasATenantsOption.php @@ -12,7 +12,7 @@ trait HasATenantsOption protected function getOptions() { return array_merge([ - ['tenants', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL, '', null], + ['tenants', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_OPTIONAL, '', null], ], parent::getOptions()); } diff --git a/src/Contracts/TenantDatabaseManager.php b/src/Contracts/TenantDatabaseManager.php index aeed3dce..92801d75 100644 --- a/src/Contracts/TenantDatabaseManager.php +++ b/src/Contracts/TenantDatabaseManager.php @@ -20,18 +20,11 @@ interface TenantDatabaseManager /** * Does a database exist. - * - * @param string $name - * @return bool */ public function databaseExists(string $name): bool; /** * Make a DB connection config array. - * - * @param array $baseConfig - * @param string $databaseName - * @return array */ public function makeConnectionConfig(array $baseConfig, string $databaseName): array; @@ -39,9 +32,6 @@ interface TenantDatabaseManager * Set the DB connection that should be used by the tenant database manager. * * @throws NoConnectionSetException - * - * @param string $connection - * @return void */ public function setConnection(string $connection): void; } diff --git a/src/Database/Concerns/TenantRun.php b/src/Database/Concerns/TenantRun.php index d9a444de..29bbedac 100644 --- a/src/Database/Concerns/TenantRun.php +++ b/src/Database/Concerns/TenantRun.php @@ -11,9 +11,6 @@ trait TenantRun /** * Run a callback in this tenant's context. * Atomic, safely reverts to previous context. - * - * @param callable $callback - * @return mixed */ public function run(callable $callback) { diff --git a/src/Database/DatabaseManager.php b/src/Database/DatabaseManager.php index dd30f443..6242ffa9 100644 --- a/src/Database/DatabaseManager.php +++ b/src/Database/DatabaseManager.php @@ -7,10 +7,12 @@ namespace Stancl\Tenancy\Database; use Illuminate\Config\Repository; use Illuminate\Contracts\Foundation\Application; use Illuminate\Database\DatabaseManager as BaseDatabaseManager; +use Stancl\Tenancy\Contracts\ManagesDatabaseUsers; use Stancl\Tenancy\Contracts\TenantCannotBeCreatedException; use Stancl\Tenancy\Contracts\TenantWithDatabase; use Stancl\Tenancy\Exceptions\DatabaseManagerNotRegisteredException; use Stancl\Tenancy\Exceptions\TenantDatabaseAlreadyExistsException; +use Stancl\Tenancy\Exceptions\TenantDatabaseUserAlreadyExistsException; /** * @internal Class is subject to breaking changes in minor and patch versions. @@ -38,7 +40,7 @@ class DatabaseManager */ public function connectToTenant(TenantWithDatabase $tenant) { - $this->database->purge('tenant'); + $this->purgeTenantConnection(); $this->createTenantConnection($tenant); $this->setDefaultConnection('tenant'); } @@ -48,10 +50,7 @@ class DatabaseManager */ public function reconnectToCentral() { - if (tenancy()->initialized) { - $this->database->purge('tenant'); - } - + $this->purgeTenantConnection(); $this->setDefaultConnection($this->config->get('tenancy.database.central_connection')); } @@ -60,7 +59,7 @@ class DatabaseManager */ public function setDefaultConnection(string $connection) { - $this->app['config']['database.default'] = $connection; + $this->config['database.default'] = $connection; $this->database->setDefaultConnection($connection); } @@ -69,7 +68,19 @@ class DatabaseManager */ public function createTenantConnection(TenantWithDatabase $tenant) { - $this->app['config']['database.connections.tenant'] = $tenant->database()->connection(); + $this->config['database.connections.tenant'] = $tenant->database()->connection(); + } + + /** + * Purge the tenant database connection. + */ + public function purgeTenantConnection() + { + if (array_key_exists('tenant', $this->database->getConnections())) { + $this->database->purge('tenant'); + } + + unset($this->config['database.connections.tenant']); } /** @@ -81,8 +92,14 @@ class DatabaseManager */ public function ensureTenantCanBeCreated(TenantWithDatabase $tenant): void { - if ($tenant->database()->manager()->databaseExists($database = $tenant->database()->getName())) { + $manager = $tenant->database()->manager(); + + if ($manager->databaseExists($database = $tenant->database()->getName())) { throw new TenantDatabaseAlreadyExistsException($database); } + + if ($manager instanceof ManagesDatabaseUsers && $manager->userExists($username = $tenant->database()->getUsername())) { + throw new TenantDatabaseUserAlreadyExistsException($username); + } } } diff --git a/src/Database/Models/ImpersonationToken.php b/src/Database/Models/ImpersonationToken.php index 4aa63252..43c536fb 100644 --- a/src/Database/Models/ImpersonationToken.php +++ b/src/Database/Models/ImpersonationToken.php @@ -22,10 +22,15 @@ class ImpersonationToken extends Model use CentralConnection; protected $guarded = []; + public $timestamps = false; + protected $primaryKey = 'token'; + public $incrementing = false; + protected $table = 'tenant_user_impersonation_tokens'; + protected $dates = [ 'created_at', ]; diff --git a/src/Database/Models/Tenant.php b/src/Database/Models/Tenant.php index 4ec685b7..f88297be 100644 --- a/src/Database/Models/Tenant.php +++ b/src/Database/Models/Tenant.php @@ -29,7 +29,9 @@ class Tenant extends Model implements Contracts\Tenant Concerns\InvalidatesResolverCache; protected $table = 'tenants'; + protected $primaryKey = 'id'; + protected $guarded = []; public function getTenantKeyName(): string diff --git a/src/DatabaseConfig.php b/src/DatabaseConfig.php index c8280632..b3195960 100644 --- a/src/DatabaseConfig.php +++ b/src/DatabaseConfig.php @@ -80,8 +80,6 @@ class DatabaseConfig /** * Generate DB name, username & password and write them to the tenant model. - * - * @return void */ public function makeCredentials(): void { @@ -113,7 +111,8 @@ class DatabaseConfig $templateConnection = config("database.connections.{$template}"); return $this->manager()->makeConnectionConfig( - array_merge($templateConnection, $this->tenantConfig()), $this->getName() + array_merge($templateConnection, $this->tenantConfig()), + $this->getName() ); } diff --git a/src/Exceptions/ModelNotSyncMasterException.php b/src/Exceptions/ModelNotSyncMasterException.php index abe8904b..ee5feb9a 100644 --- a/src/Exceptions/ModelNotSyncMasterException.php +++ b/src/Exceptions/ModelNotSyncMasterException.php @@ -10,6 +10,6 @@ class ModelNotSyncMasterException extends Exception { public function __construct(string $class) { - parent::__construct("Model of $class class is not an SyncMaster model. Make sure you're using the central model to make changes to synced resouces when you're in the central context"); + parent::__construct("Model of $class class is not an SyncMaster model. Make sure you're using the central model to make changes to synced resources when you're in the central context"); } } diff --git a/src/Exceptions/TenantCouldNotBeIdentifiedById.php b/src/Exceptions/TenantCouldNotBeIdentifiedById.php index 8fa103ea..5c2e562c 100644 --- a/src/Exceptions/TenantCouldNotBeIdentifiedById.php +++ b/src/Exceptions/TenantCouldNotBeIdentifiedById.php @@ -9,7 +9,7 @@ use Facade\IgnitionContracts\ProvidesSolution; use Facade\IgnitionContracts\Solution; use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException; -// todo: in v3 this should be suffixed with Exception +// todo: in v4 this should be suffixed with Exception class TenantCouldNotBeIdentifiedById extends TenantCouldNotBeIdentifiedException implements ProvidesSolution { public function __construct($tenant_id) diff --git a/src/Features/UniversalRoutes.php b/src/Features/UniversalRoutes.php index 9c9ec5fb..c73a5304 100644 --- a/src/Features/UniversalRoutes.php +++ b/src/Features/UniversalRoutes.php @@ -46,7 +46,7 @@ class UniversalRoutes implements Feature } // Loop one level deep and check if the route's middleware - // groups have the searhced middleware group inside them + // groups have the searched 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)) { diff --git a/src/Features/UserImpersonation.php b/src/Features/UserImpersonation.php index 48d65bb9..f96465ff 100644 --- a/src/Features/UserImpersonation.php +++ b/src/Features/UserImpersonation.php @@ -33,7 +33,6 @@ class UserImpersonation implements Feature * * @param string|ImpersonationToken $token * @param int $ttl - * @return RedirectResponse */ public static function makeResponse($token, int $ttl = null): RedirectResponse { diff --git a/src/Jobs/CreateDatabase.php b/src/Jobs/CreateDatabase.php index 3a74534d..3cb2a6b4 100644 --- a/src/Jobs/CreateDatabase.php +++ b/src/Jobs/CreateDatabase.php @@ -36,8 +36,8 @@ class CreateDatabase implements ShouldQueue return false; } - $databaseManager->ensureTenantCanBeCreated($this->tenant); $this->tenant->database()->makeCredentials(); + $databaseManager->ensureTenantCanBeCreated($this->tenant); $this->tenant->database()->manager()->createDatabase($this->tenant); event(new DatabaseCreated($this->tenant)); diff --git a/src/Jobs/DeleteDomains.php b/src/Jobs/DeleteDomains.php new file mode 100644 index 00000000..4ea92b7f --- /dev/null +++ b/src/Jobs/DeleteDomains.php @@ -0,0 +1,29 @@ +tenant = $tenant; + } + + public function handle() + { + $this->tenant->domains->each->delete(); + } +} diff --git a/src/Listeners/UpdateSyncedResource.php b/src/Listeners/UpdateSyncedResource.php index 40d4d644..9be290f0 100644 --- a/src/Listeners/UpdateSyncedResource.php +++ b/src/Listeners/UpdateSyncedResource.php @@ -48,8 +48,7 @@ class UpdateSyncedResource extends QueueableListener protected function updateResourceInCentralDatabaseAndGetTenants($event, $syncedAttributes) { /** @var Model|SyncMaster $centralModel */ - $centralModel = $event->model->getCentralModelName() - ::where($event->model->getGlobalIdentifierKeyName(), $event->model->getGlobalIdentifierKey()) + $centralModel = $event->model->getCentralModelName()::where($event->model->getGlobalIdentifierKeyName(), $event->model->getGlobalIdentifierKey()) ->first(); // We disable events for this call, to avoid triggering this event & listener again. diff --git a/src/Middleware/CheckTenantForMaintenanceMode.php b/src/Middleware/CheckTenantForMaintenanceMode.php index 5554663f..c1c734f5 100644 --- a/src/Middleware/CheckTenantForMaintenanceMode.php +++ b/src/Middleware/CheckTenantForMaintenanceMode.php @@ -5,10 +5,10 @@ declare(strict_types=1); namespace Stancl\Tenancy\Middleware; use Closure; -use Illuminate\Foundation\Http\Exceptions\MaintenanceModeException; use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode; use Stancl\Tenancy\Exceptions\TenancyNotInitializedException; use Symfony\Component\HttpFoundation\IpUtils; +use Symfony\Component\HttpKernel\Exception\HttpException; class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode { @@ -29,7 +29,12 @@ class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode return $next($request); } - throw new MaintenanceModeException($data['time'], $data['retry'], $data['message']); + throw new HttpException( + 503, + 'Service Unavailable', + null, + isset($data['retry']) ? ['Retry-After' => $data['retry']] : [] + ); } return $next($request); diff --git a/src/Middleware/InitializeTenancyByDomain.php b/src/Middleware/InitializeTenancyByDomain.php index 24a1abb7..5a07112d 100644 --- a/src/Middleware/InitializeTenancyByDomain.php +++ b/src/Middleware/InitializeTenancyByDomain.php @@ -29,13 +29,13 @@ class InitializeTenancyByDomain extends IdentificationMiddleware * Handle an incoming request. * * @param \Illuminate\Http\Request $request - * @param \Closure $next - * @return mixed */ public function handle($request, Closure $next) { return $this->initializeTenancy( - $request, $next, $request->getHost() + $request, + $next, + $request->getHost() ); } } diff --git a/src/Middleware/InitializeTenancyByDomainOrSubdomain.php b/src/Middleware/InitializeTenancyByDomainOrSubdomain.php index 94217bba..9b153db3 100644 --- a/src/Middleware/InitializeTenancyByDomainOrSubdomain.php +++ b/src/Middleware/InitializeTenancyByDomainOrSubdomain.php @@ -13,8 +13,6 @@ class InitializeTenancyByDomainOrSubdomain * Handle an incoming request. * * @param \Illuminate\Http\Request $request - * @param \Closure $next - * @return mixed */ public function handle($request, Closure $next) { diff --git a/src/Middleware/InitializeTenancyByPath.php b/src/Middleware/InitializeTenancyByPath.php index 6289199b..e66400c5 100644 --- a/src/Middleware/InitializeTenancyByPath.php +++ b/src/Middleware/InitializeTenancyByPath.php @@ -38,7 +38,9 @@ class InitializeTenancyByPath extends IdentificationMiddleware // simply injected into some route controller action. if ($route->parameterNames()[0] === PathTenantResolver::$tenantParameterName) { return $this->initializeTenancy( - $request, $next, $route + $request, + $next, + $route ); } else { throw new RouteIsMissingTenantParameterException; diff --git a/src/Middleware/InitializeTenancyByRequestData.php b/src/Middleware/InitializeTenancyByRequestData.php index de75d8c5..4e1d33ff 100644 --- a/src/Middleware/InitializeTenancyByRequestData.php +++ b/src/Middleware/InitializeTenancyByRequestData.php @@ -36,8 +36,6 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware * Handle an incoming request. * * @param \Illuminate\Http\Request $request - * @param \Closure $next - * @return mixed */ public function handle($request, Closure $next) { diff --git a/src/Middleware/InitializeTenancyBySubdomain.php b/src/Middleware/InitializeTenancyBySubdomain.php index 55d76b05..76389df7 100644 --- a/src/Middleware/InitializeTenancyBySubdomain.php +++ b/src/Middleware/InitializeTenancyBySubdomain.php @@ -28,8 +28,6 @@ class InitializeTenancyBySubdomain extends InitializeTenancyByDomain * Handle an incoming request. * * @param \Illuminate\Http\Request $request - * @param \Closure $next - * @return mixed */ public function handle($request, Closure $next) { diff --git a/src/Resolvers/Contracts/CachedTenantResolver.php b/src/Resolvers/Contracts/CachedTenantResolver.php index 968ac794..e84f1fb1 100644 --- a/src/Resolvers/Contracts/CachedTenantResolver.php +++ b/src/Resolvers/Contracts/CachedTenantResolver.php @@ -75,7 +75,6 @@ abstract class CachedTenantResolver implements TenantResolver /** * Get all the arg combinations for resolve() that can be used to find this tenant. * - * @param Tenant $tenant * @return array[] */ abstract public function getArgsForTenant(Tenant $tenant): array; diff --git a/src/Tenancy.php b/src/Tenancy.php index 864c00f0..6873f93b 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -27,7 +27,6 @@ class Tenancy /** * Initializes the tenant. * @param Tenant|int|string $tenant - * @return void */ public function initialize($tenant): void { @@ -66,10 +65,10 @@ class Tenancy return; } - $this->initialized = false; - event(new Events\TenancyEnded($this)); + $this->initialized = false; + $this->tenant = null; } @@ -106,9 +105,6 @@ class Tenancy /** * Run a callback in the central context. * Atomic, safely reverts to previous context. - * - * @param callable $callback - * @return mixed */ public function central(callable $callback) { @@ -132,7 +128,6 @@ class Tenancy * More performant than running $tenant->run() one by one. * * @param Tenant[]|\Traversable|string[]|null $tenants - * @param callable $callback * @return void */ public function runForMultiple($tenants, callable $callback) diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index cf326792..e23200a6 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -7,6 +7,7 @@ namespace Stancl\Tenancy; use Illuminate\Cache\CacheManager; use Illuminate\Support\ServiceProvider; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; +use Stancl\Tenancy\Contracts\Domain; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Resolvers\DomainTenantResolver; @@ -14,8 +15,6 @@ class TenancyServiceProvider extends ServiceProvider { /** * Register services. - * - * @return void */ public function register(): void { @@ -75,8 +74,6 @@ class TenancyServiceProvider extends ServiceProvider /** * Bootstrap services. - * - * @return void */ public function boot(): void { @@ -87,6 +84,7 @@ class TenancyServiceProvider extends ServiceProvider Commands\Migrate::class, Commands\Rollback::class, Commands\TenantList::class, + Commands\TenantDump::class, Commands\MigrateFresh::class, ]); diff --git a/src/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php b/src/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php new file mode 100644 index 00000000..0bc34623 --- /dev/null +++ b/src/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php @@ -0,0 +1,57 @@ +connection === null) { + throw new NoConnectionSetException(static::class); + } + + return DB::connection($this->connection); + } + + public function setConnection(string $connection): void + { + $this->connection = $connection; + } + + public function createDatabase(TenantWithDatabase $tenant): bool + { + $database = $tenant->database()->getName(); + $charset = $this->database()->getConfig('charset'); + $collation = $this->database()->getConfig('collation'); + + return $this->database()->statement("CREATE DATABASE [{$database}]"); + } + + public function deleteDatabase(TenantWithDatabase $tenant): bool + { + return $this->database()->statement("DROP DATABASE [{$tenant->database()->getName()}]"); + } + + public function databaseExists(string $name): bool + { + return (bool) $this->database()->select("SELECT name FROM master.sys.databases WHERE name = '$name'"); + } + + public function makeConnectionConfig(array $baseConfig, string $databaseName): array + { + $baseConfig['database'] = $databaseName; + + return $baseConfig; + } +} diff --git a/src/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php b/src/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php index f8bedc97..918601a8 100644 --- a/src/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php +++ b/src/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php @@ -7,7 +7,6 @@ namespace Stancl\Tenancy\TenantDatabaseManagers; use Stancl\Tenancy\Concerns\CreatesDatabaseUsers; use Stancl\Tenancy\Contracts\ManagesDatabaseUsers; use Stancl\Tenancy\DatabaseConfig; -use Stancl\Tenancy\Exceptions\TenantDatabaseUserAlreadyExistsException; class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager implements ManagesDatabaseUsers { @@ -26,10 +25,6 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl $hostname = $databaseConfig->connection()['host']; $password = $databaseConfig->getPassword(); - if ($this->userExists($username)) { - throw new TenantDatabaseUserAlreadyExistsException($username); - } - $this->database()->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}'"); $grants = implode(', ', static::$grants); diff --git a/src/TenantDatabaseManagers/PostgreSQLSchemaManager.php b/src/TenantDatabaseManagers/PostgreSQLSchemaManager.php index 9d815b25..55f049d0 100644 --- a/src/TenantDatabaseManagers/PostgreSQLSchemaManager.php +++ b/src/TenantDatabaseManagers/PostgreSQLSchemaManager.php @@ -46,7 +46,11 @@ class PostgreSQLSchemaManager implements TenantDatabaseManager public function makeConnectionConfig(array $baseConfig, string $databaseName): array { - $baseConfig['schema'] = $databaseName; + if (version_compare(app()->version(), '9.0', '>=')) { + $baseConfig['search_path'] = $databaseName; + } else { + $baseConfig['schema'] = $databaseName; + } return $baseConfig; } diff --git a/tests/BootstrapperTest.php b/tests/BootstrapperTest.php index 1b0c880d..a0320282 100644 --- a/tests/BootstrapperTest.php +++ b/tests/BootstrapperTest.php @@ -4,11 +4,15 @@ declare(strict_types=1); namespace Stancl\Tenancy\Tests; +use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; +use ReflectionObject; +use ReflectionProperty; use Stancl\JobPipeline\JobPipeline; use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; @@ -165,6 +169,7 @@ class BootstrapperTest extends TestCase $tenant2 = Tenant::create(); tenancy()->initialize($tenant1); + Storage::disk('public')->put('foo', 'bar'); $this->assertSame('bar', Storage::disk('public')->get('foo')); @@ -184,30 +189,38 @@ class BootstrapperTest extends TestCase $this->assertFalse(Storage::disk('public')->exists('foo')); $this->assertFalse(Storage::disk('public')->exists('abc')); + $expected_storage_path = $old_storage_path . '/tenant' . tenant('id'); // /tenant = suffix base + + // Check that disk prefixes respect the root_override logic + $this->assertSame($expected_storage_path . '/app/', $this->getDiskPrefix('local')); + $this->assertSame($expected_storage_path . '/app/public/', $this->getDiskPrefix('public')); + $this->assertSame('tenant' . tenant('id') . '/', $this->getDiskPrefix('s3'), '/'); + // Check suffixing logic $new_storage_path = storage_path(); - $this->assertEquals($old_storage_path . '/' . config('tenancy.filesystem.suffix_base') . tenant('id'), $new_storage_path); + $this->assertEquals($expected_storage_path, $new_storage_path); + } - foreach (config('tenancy.filesystem.disks') as $disk) { - $suffix = config('tenancy.filesystem.suffix_base') . tenant('id'); + protected function getDiskPrefix(string $disk): string + { + /** @var FilesystemAdapter $disk */ + $disk = Storage::disk($disk); + $adapter = $disk->getAdapter(); - /** @var FilesystemAdapter $filesystemDisk */ - $filesystemDisk = Storage::disk($disk); - - $current_path_prefix = $filesystemDisk->getAdapter()->getPathPrefix(); - - if ($override = config("tenancy.filesystem.root_override.{$disk}")) { - $correct_path_prefix = str_replace('%storage_path%', storage_path(), $override); - } else { - if ($base = $old_storage_facade_roots[$disk]) { - $correct_path_prefix = $base . "/$suffix/"; - } else { - $correct_path_prefix = "$suffix/"; - } - } - - $this->assertSame($correct_path_prefix, $current_path_prefix); + if (! Str::startsWith(app()->version(), '9.')) { + return $adapter->getPathPrefix(); } + + $prefixer = (new ReflectionObject($adapter))->getProperty('prefixer'); + $prefixer->setAccessible(true); + + // reflection -> instance + $prefixer = $prefixer->getValue($adapter); + + $prefix = (new ReflectionProperty($prefixer, 'prefix')); + $prefix->setAccessible(true); + + return $prefix->getValue($prefixer); } // for queues see QueueTest diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index d7da0cab..145a93c5 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -91,6 +91,38 @@ class CommandsTest extends TestCase $this->assertTrue(Schema::hasTable('users')); } + /** @test */ + public function migrate_command_loads_schema_state() + { + $tenant = Tenant::create(); + + $this->assertFalse(Schema::hasTable('schema_users')); + $this->assertFalse(Schema::hasTable('users')); + + Artisan::call('tenants:migrate --schema-path="tests/Etc/tenant-schema.dump"'); + + $this->assertFalse(Schema::hasTable('schema_users')); + $this->assertFalse(Schema::hasTable('users')); + + tenancy()->initialize($tenant); + + // Check for both tables to see if missing migrations also get executed + $this->assertTrue(Schema::hasTable('schema_users')); + $this->assertTrue(Schema::hasTable('users')); + } + + /** @test */ + public function dump_command_works() + { + $tenant = Tenant::create(); + Artisan::call('tenants:migrate'); + + tenancy()->initialize($tenant); + + Artisan::call('tenants:dump --path="tests/Etc/tenant-schema-test.dump"'); + $this->assertFileExists('tests/Etc/tenant-schema-test.dump'); + } + /** @test */ public function rollback_command_works() { diff --git a/tests/DatabaseUsersTest.php b/tests/DatabaseUsersTest.php index 0b095024..344239d1 100644 --- a/tests/DatabaseUsersTest.php +++ b/tests/DatabaseUsersTest.php @@ -10,6 +10,7 @@ use Illuminate\Support\Str; use Stancl\JobPipeline\JobPipeline; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Contracts\ManagesDatabaseUsers; +use Stancl\Tenancy\Events\DatabaseCreated; use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Events\TenantCreated; use Stancl\Tenancy\Exceptions\TenantDatabaseUserAlreadyExistsException; @@ -67,14 +68,18 @@ class DatabaseUsersTest extends TestCase $this->assertTrue($manager->databaseExists($tenant->database()->getName())); $this->expectException(TenantDatabaseUserAlreadyExistsException::class); + Event::fake([DatabaseCreated::class]); + $tenant2 = Tenant::create([ 'tenancy_db_username' => $username, ]); /** @var ManagesDatabaseUsers $manager */ - $manager = $tenant2->database()->manager(); + $manager2 = $tenant2->database()->manager(); + // database was not created because of DB transaction - $this->assertFalse($manager->databaseExists($tenant2->database()->getName())); + $this->assertFalse($manager2->databaseExists($tenant2->database()->getName())); + Event::assertNotDispatched(DatabaseCreated::class); } /** @test */ diff --git a/tests/DeleteDomainsJobTest.php b/tests/DeleteDomainsJobTest.php new file mode 100644 index 00000000..7fce9cf3 --- /dev/null +++ b/tests/DeleteDomainsJobTest.php @@ -0,0 +1,42 @@ + DatabaseAndDomainTenant::class]); + } + + /** @test */ + public function job_delete_domains_successfully() + { + $tenant = DatabaseAndDomainTenant::create(); + + $tenant->domains()->create([ + 'domain' => 'foo.localhost', + ]); + $tenant->domains()->create([ + 'domain' => 'bar.localhost', + ]); + + $this->assertSame($tenant->domains()->count(), 2); + + (new DeleteDomains($tenant))->handle(); + + $this->assertSame($tenant->refresh()->domains()->count(), 0); + } +} + +class DatabaseAndDomainTenant extends Etc\Tenant +{ + use HasDomains; +} diff --git a/tests/Etc/ConsoleKernel.php b/tests/Etc/ConsoleKernel.php index 1bc66365..a548f113 100644 --- a/tests/Etc/ConsoleKernel.php +++ b/tests/Etc/ConsoleKernel.php @@ -4,15 +4,10 @@ declare(strict_types=1); namespace Stancl\Tenancy\Tests\Etc; -use Orchestra\Testbench\Console\Kernel; +use Orchestra\Testbench\Foundation\Console\Kernel; class ConsoleKernel extends Kernel { - /** - * The Artisan commands provided by your application. - * - * @var array - */ protected $commands = [ ExampleCommand::class, AddUserCommand::class, diff --git a/tests/Etc/ExampleSeeder.php b/tests/Etc/ExampleSeeder.php index a3e36123..2f97787e 100644 --- a/tests/Etc/ExampleSeeder.php +++ b/tests/Etc/ExampleSeeder.php @@ -19,7 +19,7 @@ class ExampleSeeder extends Seeder { DB::table('users')->insert([ 'name' => Str::random(10), - 'email' => Str::random(10).'@gmail.com', + 'email' => Str::random(10) . '@gmail.com', 'password' => bcrypt('password'), ]); } diff --git a/tests/Etc/tenant-schema.dump b/tests/Etc/tenant-schema.dump new file mode 100644 index 00000000..6af9f019 --- /dev/null +++ b/tests/Etc/tenant-schema.dump @@ -0,0 +1,66 @@ +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; +DROP TABLE IF EXISTS `failed_jobs`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `failed_jobs` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `uuid` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `connection` text COLLATE utf8mb4_unicode_ci NOT NULL, + `queue` text COLLATE utf8mb4_unicode_ci NOT NULL, + `payload` longtext COLLATE utf8mb4_unicode_ci NOT NULL, + `exception` longtext COLLATE utf8mb4_unicode_ci NOT NULL, + `failed_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `failed_jobs_uuid_unique` (`uuid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `migrations`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `migrations` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `migration` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `batch` int(11) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `password_resets`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `password_resets` ( + `email` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `token` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` timestamp NULL DEFAULT NULL, + KEY `password_resets_email_index` (`email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `users`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `schema_users` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `email` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `email_verified_at` timestamp NULL DEFAULT NULL, + `password` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `remember_token` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `users_email_unique` (`email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +INSERT INTO `migrations` VALUES (2,'2014_10_12_100000_testbench_create_password_resets_table',1); +INSERT INTO `migrations` VALUES (3,'2019_08_19_000000_testbench_create_failed_jobs_table',1); diff --git a/tests/Etc/tmp/queuetest.json b/tests/Etc/tmp/queuetest.json deleted file mode 100644 index 00cf7c37..00000000 --- a/tests/Etc/tmp/queuetest.json +++ /dev/null @@ -1 +0,0 @@ -{"tenant_id":"The current tenant id is: acme"} \ No newline at end of file diff --git a/tests/EventListenerTest.php b/tests/EventListenerTest.php index 4a45205c..02ed8b3b 100644 --- a/tests/EventListenerTest.php +++ b/tests/EventListenerTest.php @@ -20,7 +20,6 @@ use Stancl\Tenancy\Jobs\CreateDatabase; use Stancl\Tenancy\Jobs\MigrateDatabase; use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\QueueableListener; -use Stancl\Tenancy\Tenancy; use Stancl\Tenancy\Tests\Etc\Tenant; class EventListenerTest extends TestCase diff --git a/tests/MaintenanceModeTest.php b/tests/MaintenanceModeTest.php index a8ecb064..90232932 100644 --- a/tests/MaintenanceModeTest.php +++ b/tests/MaintenanceModeTest.php @@ -4,12 +4,12 @@ declare(strict_types=1); namespace Stancl\Tenancy\Tests; -use Illuminate\Foundation\Http\Exceptions\MaintenanceModeException; use Illuminate\Support\Facades\Route; use Stancl\Tenancy\Database\Concerns\MaintenanceMode; use Stancl\Tenancy\Middleware\CheckTenantForMaintenanceMode; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; use Stancl\Tenancy\Tests\Etc\Tenant; +use Symfony\Component\HttpKernel\Exception\HttpException; class MaintenanceModeTest extends TestCase { @@ -32,7 +32,7 @@ class MaintenanceModeTest extends TestCase $tenant->putDownForMaintenance(); - $this->expectException(MaintenanceModeException::class); + $this->expectException(HttpException::class); $this->withoutExceptionHandling() ->get('http://acme.localhost/foo'); } diff --git a/tests/QueueTest.php b/tests/QueueTest.php index 41d71320..fe34ba92 100644 --- a/tests/QueueTest.php +++ b/tests/QueueTest.php @@ -4,18 +4,31 @@ declare(strict_types=1); namespace Stancl\Tenancy\Tests; +use Exception; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Database\Schema\Blueprint; use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Queue\Events\JobProcessed; use Illuminate\Queue\Events\JobProcessing; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Str; use Spatie\Valuestore\Valuestore; +use Stancl\JobPipeline\JobPipeline; +use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper; +use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Events\TenancyInitialized; +use Stancl\Tenancy\Events\TenantCreated; +use Stancl\Tenancy\Jobs\CreateDatabase; use Stancl\Tenancy\Listeners\BootstrapTenancy; +use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Tests\Etc\Tenant; +use Stancl\Tenancy\Tests\Etc\User; class QueueTest extends TestCase { @@ -31,13 +44,68 @@ class QueueTest extends TestCase config([ 'tenancy.bootstrappers' => [ QueueTenancyBootstrapper::class, + DatabaseTenancyBootstrapper::class, ], 'queue.default' => 'redis', ]); Event::listen(TenancyInitialized::class, BootstrapTenancy::class); + Event::listen(TenancyEnded::class, RevertToCentralContext::class); - $this->valuestore = Valuestore::make(__DIR__ . '/Etc/tmp/queuetest.json')->flush(); + $this->createValueStore(); + } + + public function tearDown(): void + { + $this->valuestore->flush(); + } + + protected function createValueStore(): void + { + $valueStorePath = __DIR__ . '/Etc/tmp/queuetest.json'; + + if (! file_exists($valueStorePath)) { + // The directory sometimes goes missing as well when the file is deleted in git + if (! is_dir(__DIR__ . '/Etc/tmp')) { + mkdir(__DIR__ . '/Etc/tmp'); + } + + file_put_contents($valueStorePath, ''); + } + + $this->valuestore = Valuestore::make($valueStorePath)->flush(); + } + + protected function withFailedJobs() + { + Schema::connection('central')->create('failed_jobs', function (Blueprint $table) { + $table->increments('id'); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + protected function withUsers() + { + Schema::create('users', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->string('email')->unique(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + } + + protected function withTenantDatabases() + { + Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); } /** @test */ @@ -49,7 +117,7 @@ class QueueTest extends TestCase tenancy()->initialize($tenant); - Event::fake([JobProcessing::class]); + Event::fake([JobProcessing::class, JobProcessed::class]); dispatch(new TestJob($this->valuestore)); @@ -79,21 +147,95 @@ class QueueTest extends TestCase }); } - /** @test */ - public function tenancy_is_initialized_inside_queues() + /** + * @test + * + * @testWith [true] + * [false] + */ + public function tenancy_is_initialized_inside_queues(bool $shouldEndTenancy) { - $tenant = Tenant::create([ - 'id' => 'acme', - ]); + $this->withTenantDatabases(); + $this->withFailedJobs(); + + $tenant = Tenant::create(); tenancy()->initialize($tenant); - dispatch(new TestJob($this->valuestore)); + $this->withUsers(); + + $user = User::create(['name' => 'Foo', 'email' => 'foo@bar.com', 'password' => 'secret']); + + $this->valuestore->put('userName', 'Bar'); + + dispatch(new TestJob($this->valuestore, $user)); $this->assertFalse($this->valuestore->has('tenant_id')); + + if ($shouldEndTenancy) { + tenancy()->end(); + } + $this->artisan('queue:work --once'); - $this->assertSame('The current tenant id is: acme', $this->valuestore->get('tenant_id')); + $this->assertSame(0, DB::connection('central')->table('failed_jobs')->count()); + + $this->assertSame('The current tenant id is: ' . $tenant->id, $this->valuestore->get('tenant_id')); + + $tenant->run(function () use ($user) { + $this->assertSame('Bar', $user->fresh()->name); + }); + } + + /** + * @test + * + * @testWith [true] + * [false] + */ + public function tenancy_is_initialized_when_retrying_jobs(bool $shouldEndTenancy) + { + if (! Str::startsWith(app()->version(), '8')) { + $this->markTestSkipped('queue:retry tenancy is only supported in Laravel 8'); + } + + $this->withFailedJobs(); + $this->withTenantDatabases(); + + $tenant = Tenant::create(); + + tenancy()->initialize($tenant); + + $this->withUsers(); + + $user = User::create(['name' => 'Foo', 'email' => 'foo@bar.com', 'password' => 'secret']); + + $this->valuestore->put('userName', 'Bar'); + $this->valuestore->put('shouldFail', true); + + dispatch(new TestJob($this->valuestore, $user)); + + $this->assertFalse($this->valuestore->has('tenant_id')); + + if ($shouldEndTenancy) { + tenancy()->end(); + } + + $this->artisan('queue:work --once'); + + $this->assertSame(1, DB::connection('central')->table('failed_jobs')->count()); + $this->assertNull($this->valuestore->get('tenant_id')); // job failed + + $this->artisan('queue:retry all'); + $this->artisan('queue:work --once'); + + $this->assertSame(0, DB::connection('central')->table('failed_jobs')->count()); + + $this->assertSame('The current tenant id is: ' . $tenant->id, $this->valuestore->get('tenant_id')); // job succeeded + + $tenant->run(function () use ($user) { + $this->assertSame('Bar', $user->fresh()->name); + }); } /** @test */ @@ -127,13 +269,31 @@ class TestJob implements ShouldQueue /** @var Valuestore */ protected $valuestore; - public function __construct(Valuestore $valuestore) + /** @var User|null */ + protected $user; + + public function __construct(Valuestore $valuestore, User $user = null) { $this->valuestore = $valuestore; + $this->user = $user; } public function handle() { + if ($this->valuestore->get('shouldFail')) { + $this->valuestore->put('shouldFail', false); + + throw new Exception('failing'); + } + + if ($this->user) { + assert($this->user->getConnectionName() === 'tenant'); + } + $this->valuestore->put('tenant_id', 'The current tenant id is: ' . tenant('id')); + + if ($userName = $this->valuestore->get('userName')) { + $this->user->update(['name' => $userName]); + } } } diff --git a/tests/ResourceSyncingTest.php b/tests/ResourceSyncingTest.php index 570448d1..0ff95a52 100644 --- a/tests/ResourceSyncingTest.php +++ b/tests/ResourceSyncingTest.php @@ -589,7 +589,9 @@ class CentralUser extends Model implements SyncMaster use ResourceSyncing, CentralConnection; protected $guarded = []; + public $timestamps = false; + public $table = 'users'; public function tenants(): BelongsToMany @@ -633,7 +635,9 @@ class ResourceUser extends Model implements Syncable use ResourceSyncing; protected $table = 'users'; + protected $guarded = []; + public $timestamps = false; public function getGlobalIdentifierKey() diff --git a/tests/SingleDatabaseTenancyTest.php b/tests/SingleDatabaseTenancyTest.php index b64478cc..d0425dd9 100644 --- a/tests/SingleDatabaseTenancyTest.php +++ b/tests/SingleDatabaseTenancyTest.php @@ -329,6 +329,7 @@ class Post extends Model use BelongsToTenant; protected $guarded = []; + public $timestamps = false; public function comments() @@ -345,6 +346,7 @@ class Post extends Model class Comment extends Model { protected $guarded = []; + public $timestamps = false; public function post() @@ -368,5 +370,6 @@ class ScopedComment extends Comment class GlobalResource extends Model { protected $guarded = []; + public $timestamps = false; } diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index ead2bba8..12273c85 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -4,17 +4,22 @@ declare(strict_types=1); namespace Stancl\Tenancy\Tests; +use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use PDO; use Stancl\JobPipeline\JobPipeline; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Database\DatabaseManager; +use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Events\TenantCreated; use Stancl\Tenancy\Exceptions\TenantDatabaseAlreadyExistsException; use Stancl\Tenancy\Jobs\CreateDatabase; use Stancl\Tenancy\Listeners\BootstrapTenancy; +use Stancl\Tenancy\Listeners\RevertToCentralContext; +use Stancl\Tenancy\TenantDatabaseManagers\MicrosoftSQLDatabaseManager; use Stancl\Tenancy\TenantDatabaseManagers\MySQLDatabaseManager; use Stancl\Tenancy\TenantDatabaseManagers\PermissionControlledMySQLDatabaseManager; use Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLDatabaseManager; @@ -99,9 +104,56 @@ class TenantDatabaseManagerTest extends TestCase ['sqlite', SQLiteDatabaseManager::class], ['pgsql', PostgreSQLDatabaseManager::class], ['pgsql', PostgreSQLSchemaManager::class], + ['sqlsrv', MicrosoftSQLDatabaseManager::class], ]; } + /** @test */ + public function the_tenant_connection_is_fully_removed() + { + config([ + 'tenancy.boostrappers' => [ + DatabaseTenancyBootstrapper::class, + ], + ]); + + Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + + Event::listen(TenancyInitialized::class, BootstrapTenancy::class); + Event::listen(TenancyEnded::class, RevertToCentralContext::class); + + $tenant = Tenant::create(); + + $this->assertSame(['central'], array_keys(app('db')->getConnections())); + $this->assertArrayNotHasKey('tenant', config('database.connections')); + + tenancy()->initialize($tenant); + + $this->createUsersTable(); + + $this->assertSame(['central', 'tenant'], array_keys(app('db')->getConnections())); + $this->assertArrayHasKey('tenant', config('database.connections')); + + tenancy()->end(); + + $this->assertSame(['central'], array_keys(app('db')->getConnections())); + $this->assertNull(config('database.connections.tenant')); + } + + protected function createUsersTable() + { + Schema::create('users', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->string('email')->unique(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + } + /** @test */ public function db_name_is_prefixed_with_db_path_when_sqlite_is_used() { @@ -144,7 +196,11 @@ class TenantDatabaseManagerTest extends TestCase ]); tenancy()->initialize($tenant); - $this->assertSame($tenant->database()->getName(), config('database.connections.' . config('database.default') . '.schema')); + $schemaConfig = version_compare(app()->version(), '9.0', '>=') ? + config('database.connections.' . config('database.default') . '.search_path') : + config('database.connections.' . config('database.default') . '.schema'); + + $this->assertSame($tenant->database()->getName(), $schemaConfig); $this->assertSame($originalDatabaseName, config(['database.connections.pgsql.database'])); } @@ -217,5 +273,6 @@ class TenantDatabaseManagerTest extends TestCase /** @test */ public function path_used_by_sqlite_manager_can_be_customized() { + $this->markTestIncomplete(); } } diff --git a/tests/TenantModelTest.php b/tests/TenantModelTest.php index 46dc6a00..2d46c233 100644 --- a/tests/TenantModelTest.php +++ b/tests/TenantModelTest.php @@ -172,6 +172,7 @@ class MyTenant extends Tenant class AnotherTenant extends Model implements Contracts\Tenant { protected $guarded = []; + protected $table = 'tenants'; public function getTenantKeyName(): string diff --git a/tests/TenantUserImpersonationTest.php b/tests/TenantUserImpersonationTest.php index c5e83853..b50db84b 100644 --- a/tests/TenantUserImpersonationTest.php +++ b/tests/TenantUserImpersonationTest.php @@ -274,11 +274,13 @@ class TenantUserImpersonationTest extends TestCase class ImpersonationUser extends Authenticable { protected $guarded = []; + protected $table = 'users'; } class AnotherImpersonationUser extends Authenticable { protected $guarded = []; + protected $table = 'users'; } diff --git a/tests/TestCase.php b/tests/TestCase.php index bbc42489..75fe51fd 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -48,7 +48,11 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase protected function getEnvironmentSetUp($app) { if (file_exists(__DIR__ . '/../.env')) { - \Dotenv\Dotenv::create(__DIR__ . '/..')->load(); + if (method_exists(\Dotenv\Dotenv::class, 'createImmutable')) { + \Dotenv\Dotenv::createImmutable(__DIR__ . '/..')->load(); + } else { + \Dotenv\Dotenv::create(__DIR__ . '/..')->load(); + } } $app['config']->set([ @@ -77,12 +81,17 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase ], 'database.connections.sqlite.database' => ':memory:', 'database.connections.mysql.host' => env('TENANCY_TEST_MYSQL_HOST', '127.0.0.1'), + 'database.connections.sqlsrv.username' => env('TENANCY_TEST_SQLSRV_USERNAME', 'sa'), + 'database.connections.sqlsrv.password' => env('TENANCY_TEST_SQLSRV_PASSWORD', 'P@ssword'), + 'database.connections.sqlsrv.host' => env('TENANCY_TEST_SQLSRV_HOST', '127.0.0.1'), + 'database.connections.sqlsrv.database' => null, 'database.connections.pgsql.host' => env('TENANCY_TEST_PGSQL_HOST', '127.0.0.1'), 'tenancy.filesystem.disks' => [ 'local', 'public', 's3', ], + 'filesystems.disks.s3.bucket' => 'foo', 'tenancy.redis.tenancy' => env('TENANCY_TEST_REDIS_TENANCY', true), 'database.redis.client' => env('TENANCY_TEST_REDIS_CLIENT', 'phpredis'), 'tenancy.redis.prefixed_connections' => ['default'],