diff --git a/.travis.yml b/.travis.yml index b55100c8..87f9f300 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,7 +20,7 @@ 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: ./test +script: ./fulltest after_success: - bash <(curl -s https://codecov.io/bash) diff --git a/CHANGELOG-1.x.md b/CHANGELOG-1.x.md deleted file mode 100644 index 10a04384..00000000 --- a/CHANGELOG-1.x.md +++ /dev/null @@ -1,119 +0,0 @@ -# Release Notes for 1.x - -## [v1.8.0 (2019-08-17)](https://github.com/stancl/tenancy/compare/v1.7.0...v1.8.0) - -### Added - -- **Multi-tenant Jobs:** Jobs are now automatically multi-tenant. The [documentation page](https://stancl-tenancy.netlify.com/docs/jobs-queues/) covers the small tweaks you will have to make to your config to get multi-tenant jobs to work. -- **Telescope Integration**: You can read more about this on the [documentation page](https://stancl-tenancy.netlify.com/docs/telescope/). -- **Horizon Integration**: You can read more about this on the [documentation page](https://stancl-tenancy.netlify.com/docs/horizon/). -- **Tenant Redirect** and **Custom ID schemes**: You can now easily redirect to tenant domains. You can also use a custom tenant ID scheme if you don't like UUIDs. You can read about these features [here](https://stancl-tenancy.netlify.com/docs/misc-tips/). - -### Fixed - -- #112 *PostgreSQL Database creation error.* - -### Code - -- Strict types declaration is now used in every file. - -## [v1.7.0 (2019-08-17)](https://github.com/stancl/tenancy/compare/v1.6.1...v1.7.0) - -### Added: - -- DB storage driver - you don't have to use Redis to store tenants anymore. Relational databases are now supported as well. [more info](https://stancl-tenancy.netlify.com/docs/storage-drivers/#database) -- `tenancy:install` will do everything except DB/Redis connection creation for you. It will make changes to Http/Kernel.php, create `routes/tenant.php`, publish config, and (optionally) publish the migration. [more info](https://stancl-tenancy.netlify.com/docs/installation/) -- `tenants:run` [more info](https://stancl-tenancy.netlify.com/docs/console-commands/#run) -- New documentation: https://stancl-tenancy.netlify.com -- Custom tenant DB names [more info](https://stancl-tenancy.netlify.com/docs/custom-database-names/) -- stancl/tenancy events [more info](https://stancl-tenancy.netlify.com/docs/event-system/) - -### Fixed: - -- #89 *Command "tenants:migrate" cannot be found when used in app code* -- #87 *Unable to migrate multiple tenants at once when using MySQL* -- #96 *Issue w/ redis->scan() in getAllTenants logic.* - -## [v1.6.1 (2019-08-04)](https://github.com/stancl/tenancy/compare/v1.6.0...v1.6.1) - -Multiple phpunit.xml configs are now generated to run the tests with different configurations, such as different Redis drivers. - -### Fixed - -- `tenancy()->all()` with predis [`0dc8c80`](https://github.com/stancl/tenancy/commit/0dc8c80a02efbee5676cc72e648e108037ca5268) - -### Dropped - -- Laravel 5.7 support [`65b3882`](https://github.com/stancl/tenancy/commit/65b38827d5a2fa183838a9dce9fb6a157fd7e859) - -## [v1.6.0 (2019-07-30)](https://github.com/stancl/tenancy/compare/v1.5.1...v1.6.0) - -### Added - -- `GlobalCache` facade [#78](https://github.com/stancl/tenancy/pull/78) - -## [v1.5.1 (2019-07-25)](https://github.com/stancl/tenancy/compare/v1.5.0...v1.5.1) - -### Fixed - -- Database is reconnected after migrating/rolling back/seeding is done [#71](https://github.com/stancl/tenancy/pull/71) -- Fixed tenant()->delete() (it used to delete the record from the `tenants` namespace but not the `domains` namespace) [#73](https://github.com/stancl/tenancy/pull/73) - -## [v1.5.0 (2019-07-13)](https://github.com/stancl/tenancy/compare/v1.4.0...v1.5.0) - -### Added - -- PostgreSQL DB manager [#52](https://github.com/stancl/tenancy/pull/52) -- `tenancy()->end()` [#68](https://github.com/stancl/tenancy/pull/68) - -### Fixed - -- Return type docblock for `TenantManager::all()` [#63](https://github.com/stancl/tenancy/issue/63) - -## [v1.4.0 (2019-07-03)](https://github.com/stancl/tenancy/compare/v1.3.1...v1.4.0) - -### Added - -- Predis support [#59](https://github.com/stancl/tenancy/pull/59) - -## [v1.3.1 (2019-05-06)](https://github.com/stancl/tenancy/compare/v1.3.0...v1.3.1) - -### Fixed -- Fix jobs [#38](https://github.com/stancl/tenancy/pull/38) -- Fix tests for 5.8 [#41](https://github.com/stancl/tenancy/issues/41) - - -## [v1.3.0 (2019-02-27)](https://github.com/stancl/tenancy/compare/v1.2.0...v1.3.0) - -### Added -- Add 5.8 support [#33](https://github.com/stancl/tenancy/pull/33) - - -## [v1.2.0 (2019-02-15)](https://github.com/stancl/tenancy/compare/v1.1.3...v1.2.0) - -### Added -- Add `Tenancy` facade [#29](https://github.com/stancl/tenancy/issues/29) [`987c54f`](https://github.com/stancl/tenancy/commit/987c54f04e6ff3bdef068d92da6a9ace847f6c37) - - -## [v1.1.3 (2019-02-13)](https://github.com/stancl/tenancy/compare/v1.1.2...v1.1.3) - -### Fixed -- Fix CacheManager (it merged tags incorrectly), write tests for CacheManager [#31](https://github.com/stancl/tenancy/issues/31) [`a2d68b1`](https://github.com/stancl/tenancy/commit/a2d68b12611350f70befa3eb97fb56c99d006b54) - - -## [v1.1.2 (2019-02-13)](https://github.com/stancl/tenancy/compare/v1.1.1...v1.1.2) - -### Fixed -- Fix small bug in CacheManager [`d4d4119`](https://github.com/stancl/tenancy/commit/d4d411975496272158d7823597427fad8966fff8) - - -## [v1.1.1 (2019-02-11)](https://github.com/stancl/tenancy/compare/v1.1.0...v1.1.1) - -### Fixed -- Fix "Associative arrays are stored as objects" [#28](https://github.com/stancl/tenancy/issues/28) - - -## [v1.1.0 (2019-02-10)](https://github.com/stancl/tenancy/compare/v1.0.0...v1.1.0) - -### Added -- Add array support to the storage [#27](https://github.com/stancl/tenancy/pull/27) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..db8cc9d7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,15 @@ +# Contributing + +## Code style + +StyleCI will automatically fix 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. + +### 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. diff --git a/README.md b/README.md index 94f158cc..7dc8e32c 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,21 @@ # [stancl/tenancy](https://tenancy.samuelstancl.me) -[![Laravel 5.8](https://img.shields.io/badge/laravel-5.8-red.svg)](https://laravel.com) +[![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=1.x)](https://travis-ci.com/stancl/tenancy) -[![codecov](https://codecov.io/gh/stancl/tenancy/branch/1.x/graph/badge.svg)](https://codecov.io/gh/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) -### *A Laravel multi-database tenancy package that respects your code.* +### *Automatic multi-tenancy for your Laravel app.* -You won't have to change a thing in your application's code.\* +You won't have to change a thing in your application's code. - :heavy_check_mark: No model traits to change database connection - :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) -\* depending on how you use the filesystem. Everything else will work out of the box. +### [Documentation](https://tenancy.samuelstancl.me/docs/v2/) -### [Documentation](https://tenancy.samuelstancl.me/docs) - -Documentation can be found here: https://tenancy.samuelstancl.me/docs +Documentation can be found here: https://tenancy.samuelstancl.me/docs/v2/ The repository with the documentation source code can be found here: [stancl/tenancy-docs](https://github.com/stancl/tenancy-docs). diff --git a/assets/config.php b/assets/config.php index ff2fad22..64aa211a 100644 --- a/assets/config.php +++ b/assets/config.php @@ -3,16 +3,22 @@ declare(strict_types=1); return [ - 'storage_driver' => 'Stancl\Tenancy\StorageDrivers\Database\DatabaseStorageDriver', - 'storage' => [ - 'db' => [ // Stancl\Tenancy\StorageDrivers\Database\DatabaseStorageDriver + 'storage_driver' => 'db', + 'storage_drivers' => [ + 'db' => [ + 'driver' => Stancl\Tenancy\StorageDrivers\Database\DatabaseStorageDriver::class, 'data_column' => 'data', 'custom_columns' => [ // 'plan', ], - 'connection' => 'central', + 'connection' => null, + 'table_names' => [ + 'TenantModel' => 'tenants', + 'DomainModel' => 'domains', + ], ], - 'redis' => [ // Stancl\Tenancy\StorageDrivers\RedisStorageDriver + 'redis' => [ + 'driver' => Stancl\Tenancy\StorageDrivers\RedisStorageDriver::class, 'connection' => 'tenancy', ], ], @@ -21,7 +27,7 @@ return [ // 'localhost', ], 'database' => [ - 'based_on' => 'mysql', // The connection that will be used as a base for the dynamically created tenant connection. + 'based_on' => null, // The connection that will be used as a base for the dynamically created tenant connection. 'prefix' => 'tenant', 'suffix' => '', ], @@ -35,7 +41,7 @@ return [ 'cache' => [ 'tag_base' => 'tenant', ], - 'filesystem' => [ // https://stancl-tenancy.netlify.com/docs/filesystem-tenancy/ + 'filesystem' => [ // https://tenancy.samuelstancl.me/docs/v2/filesystem-tenancy/ 'suffix_base' => 'tenant', // Disks which should be suffixed with the suffix_base + tenant id. 'disks' => [ @@ -51,29 +57,43 @@ return [ ], 'database_managers' => [ // Tenant database managers handle the creation & deletion of tenant databases. - 'sqlite' => 'Stancl\Tenancy\TenantDatabaseManagers\SQLiteDatabaseManager', - 'mysql' => 'Stancl\Tenancy\TenantDatabaseManagers\MySQLDatabaseManager', - 'pgsql' => 'Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLDatabaseManager', + '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', ], 'bootstrappers' => [ // Tenancy bootstrappers are executed when tenancy is initialized. // Their responsibility is making Laravel features tenant-aware. - 'database' => 'Stancl\Tenancy\TenancyBootstrappers\DatabaseTenancyBootstrapper', - 'cache' => 'Stancl\Tenancy\TenancyBootstrappers\CacheTenancyBootstrapper', - 'filesystem' => 'Stancl\Tenancy\TenancyBootstrappers\FilesystemTenancyBootstrapper', - 'redis' => 'Stancl\Tenancy\TenancyBootstrappers\RedisTenancyBootstrapper', - 'queue' => 'Stancl\Tenancy\TenancyBootstrappers\QueueTenancyBootstrapper', + '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\TelescopeTags', - 'Stancl\Tenancy\Features\TenantRedirect', + + // Stancl\Tenancy\Features\TenantConfig::class, + // Stancl\Tenancy\Features\TelescopeTags::class, + // Stancl\Tenancy\Features\TenantRedirect::class, ], + 'storage_to_config_map' => [ // Used by the TenantConfig feature + // 'paypal_api_key' => 'services.paypal.api_key', + ], + 'home_url' => '/app', 'migrate_after_creation' => false, // run migrations after creating a tenant - 'delete_database_after_tenant_deletion' => false, // delete tenant's database after deleting him + '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 'queue_database_creation' => false, 'queue_database_deletion' => false, - 'unique_id_generator' => 'Stancl\Tenancy\UUIDGenerator', + 'unique_id_generator' => Stancl\Tenancy\UniqueIDGenerators\UUIDGenerator::class, ]; diff --git a/assets/migrations/2019_08_08_000000_create_tenants_table.php b/assets/migrations/2019_09_15_000010_create_tenants_table.php similarity index 77% rename from assets/migrations/2019_08_08_000000_create_tenants_table.php rename to assets/migrations/2019_09_15_000010_create_tenants_table.php index 8d4b7b9e..f779856f 100644 --- a/assets/migrations/2019_08_08_000000_create_tenants_table.php +++ b/assets/migrations/2019_09_15_000010_create_tenants_table.php @@ -13,11 +13,12 @@ class CreateTenantsTable extends Migration * * @return void */ - public function up() + public function up(): void { Schema::create('tenants', function (Blueprint $table) { $table->string('id', 36)->primary(); // 36 characters is the default uuid length - // your custom, indexed columns go here + + // (optional) your custom, indexed columns may go here $table->json('data'); }); @@ -28,8 +29,8 @@ class CreateTenantsTable extends Migration * * @return void */ - public function down() + public function down(): void { - Schema::drop('tenants'); + Schema::dropIfExists('tenants'); } } diff --git a/assets/migrations/2019_09_15_000000_create_domains_table.php b/assets/migrations/2019_09_15_000020_create_domains_table.php similarity index 58% rename from assets/migrations/2019_09_15_000000_create_domains_table.php rename to assets/migrations/2019_09_15_000020_create_domains_table.php index 815acd76..1ee0d5f3 100644 --- a/assets/migrations/2019_09_15_000000_create_domains_table.php +++ b/assets/migrations/2019_09_15_000020_create_domains_table.php @@ -13,11 +13,13 @@ class CreateDomainsTable extends Migration * * @return void */ - public function up() + public function up(): void { Schema::create('domains', function (Blueprint $table) { - $table->string('tenant_id', 36)->primary(); // 36 characters is the default uuid length - $table->string('domain', 255)->index(); // don't change this + $table->string('domain', 255)->primary(); + $table->string('tenant_id', 36); + + $table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade'); }); } @@ -26,8 +28,8 @@ class CreateDomainsTable extends Migration * * @return void */ - public function down() + public function down(): void { - Schema::drop('domains'); + Schema::dropIfExists('domains'); } } diff --git a/composer.json b/composer.json index cd31d282..2480fbdb 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,8 @@ ], "require": { "illuminate/support": "^6.0", - "webpatser/laravel-uuid": "^3.0" + "facade/ignition-contracts": "^1.0", + "ramsey/uuid": "^3.7" }, "require-dev": { "vlucas/phpdotenv": "^3.3", diff --git a/fulltest b/fulltest new file mode 100755 index 00000000..0c526f4e --- /dev/null +++ b/fulltest @@ -0,0 +1,7 @@ +#!/bin/bash +set -e + +# for development +docker-compose up -d +./test "$@" +docker-compose exec test vendor/bin/phpcov merge --clover clover.xml coverage/ diff --git a/src/CacheManager.php b/src/CacheManager.php index e361bc5c..29610371 100644 --- a/src/CacheManager.php +++ b/src/CacheManager.php @@ -8,19 +8,26 @@ use Illuminate\Cache\CacheManager as BaseCacheManager; class CacheManager extends BaseCacheManager { + /** + * Add tags and forward the call to the inner cache store. + * + * @param string $method + * @param array $parameters + * @return mixed + */ public function __call($method, $parameters) { $tags = [config('tenancy.cache.tag_base') . tenant('id')]; if ($method === 'tags') { - if (\count($parameters) !== 1) { + if (count($parameters) !== 1) { throw new \Exception("Method tags() takes exactly 1 argument. {count($parameters)} passed."); } $names = $parameters[0]; $names = (array) $names; // cache()->tags('foo') https://laravel.com/docs/5.7/cache#removing-tagged-cache-items - return $this->store()->tags(\array_merge($tags, $names)); + return $this->store()->tags(array_merge($tags, $names)); } return $this->store()->tags($tags)->$method(...$parameters); diff --git a/src/Commands/CreateTenant.php b/src/Commands/CreateTenant.php new file mode 100644 index 00000000..1c97da07 --- /dev/null +++ b/src/Commands/CreateTenant.php @@ -0,0 +1,47 @@ +withDomains($this->getDomains()) + ->withData($this->getData()) + ->save(); + + $this->info($tenant->id); + } + + public function getDomains(): array + { + return $this->option('domain'); + } + + public function getData(): array + { + return array_reduce($this->argument('data'), function ($data, $pair) { + [$key, $value] = explode('=', $pair, 2); + $data[$key] = $value; + + return $data; + }, []); + } +} diff --git a/src/Commands/Install.php b/src/Commands/Install.php index b25f2603..136a190e 100644 --- a/src/Commands/Install.php +++ b/src/Commands/Install.php @@ -36,21 +36,21 @@ class Install extends Command ]); $this->info('✔️ Created config/tenancy.php'); - $newKernel = \str_replace( + $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')) + file_get_contents(app_path('Http/Kernel.php')) ); - $newKernel = \str_replace("'web' => [", "'web' => [ + $newKernel = str_replace("'web' => [", "'web' => [ \Stancl\Tenancy\Middleware\PreventAccessFromTenantDomains::class,", $newKernel); - \file_put_contents(app_path('Http/Kernel.php'), $newKernel); + file_put_contents(app_path('Http/Kernel.php'), $newKernel); $this->info('✔️ Set middleware priority'); - \file_put_contents( + file_put_contents( base_path('routes/tenant.php'), "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 migration?', true)) { + if ($this->confirm('Do you want to publish the default database migrations?', true)) { $this->callSilent('vendor:publish', [ '--provider' => 'Stancl\Tenancy\TenancyServiceProvider', '--tag' => 'migrations', ]); - $this->info('✔️ Created migration.'); + $this->info('✔️ Created migrations.'); } - if (! \is_dir(database_path('migrations/tenant'))) { - \mkdir(database_path('migrations/tenant')); + if (! is_dir(database_path('migrations/tenant'))) { + mkdir(database_path('migrations/tenant')); $this->info('✔️ Created database/migrations/tenant folder.'); } diff --git a/src/Commands/Migrate.php b/src/Commands/Migrate.php index 7f68e37b..d9ba4938 100644 --- a/src/Commands/Migrate.php +++ b/src/Commands/Migrate.php @@ -53,19 +53,17 @@ class Migrate extends MigrateCommand tenancy()->all($this->option('tenants'))->each(function ($tenant) { $this->line("Tenant: {$tenant['id']}"); - // See Illuminate\Database\Migrations\DatabaseMigrationRepository::getConnection. - // Database connections are cached by Illuminate\Database\ConnectionResolver. - $this->input->setOption('database', 'tenant'); - tenancy()->initialize($tenant); // todo2 test that this works with multiple tenants with MySQL + $this->input->setOption('database', $tenant->getConnectionName()); + tenancy()->initialize($tenant); // Migrate parent::handle(); + + tenancy()->endTenancy(); }); if ($originalTenant) { tenancy()->initialize($originalTenant); - } else { - tenancy()->endTenancy(); } } } diff --git a/src/Commands/MigrateFresh.php b/src/Commands/MigrateFresh.php new file mode 100644 index 00000000..6a31e888 --- /dev/null +++ b/src/Commands/MigrateFresh.php @@ -0,0 +1,62 @@ +setName('tenants:migrate-fresh'); + } + + /** + * Execute the console command. + * + * @return mixed + */ + 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); + + $this->call('db:wipe', array_filter([ + '--database' => $tenant->getConnectionName(), + '--force' => true, + ])); + + $this->call('tenants:migrate', [ + '--tenants' => [$tenant->id], + ]); + + tenancy()->end(); + }); + + $this->info('Done.'); + + if ($originalTenant) { + tenancy()->initialize($originalTenant); + } + } +} diff --git a/src/Commands/Rollback.php b/src/Commands/Rollback.php index 7883ee86..b9168bf7 100644 --- a/src/Commands/Rollback.php +++ b/src/Commands/Rollback.php @@ -49,21 +49,21 @@ class Rollback extends RollbackCommand return; } - $this->input->setOption('database', 'tenant'); - $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(); }); if ($originalTenant) { tenancy()->initialize($originalTenant); - } else { - tenancy()->endTenancy(); } } } diff --git a/src/Commands/Run.php b/src/Commands/Run.php index 87616f48..874b1f86 100644 --- a/src/Commands/Run.php +++ b/src/Commands/Run.php @@ -39,7 +39,7 @@ class Run extends Command $callback = function ($prefix = '') { return function ($arguments, $argument) use ($prefix) { - [$key, $value] = \explode('=', $argument, 2); + [$key, $value] = explode('=', $argument, 2); $arguments[$prefix . $key] = $value; return $arguments; @@ -47,21 +47,19 @@ class Run extends Command }; // Turns ['foo=bar', 'abc=xyz=zzz'] into ['foo' => 'bar', 'abc' => 'xyz=zzz'] - $arguments = \array_reduce($this->option('argument'), $callback(), []); + $arguments = array_reduce($this->option('argument'), $callback(), []); // Turns ['foo=bar', 'abc=xyz=zzz'] into ['--foo' => 'bar', '--abc' => 'xyz=zzz'] - $options = \array_reduce($this->option('option'), $callback('--'), []); + $options = array_reduce($this->option('option'), $callback('--'), []); // Run command - $this->call($this->argument('commandname'), \array_merge($arguments, $options)); + $this->call($this->argument('commandname'), array_merge($arguments, $options)); tenancy()->endTenancy(); }); if ($originalTenant) { tenancy()->initialize($originalTenant); - } else { - tenancy()->endTenancy(); } } } diff --git a/src/Commands/Seed.php b/src/Commands/Seed.php index 7b0b5177..61630408 100644 --- a/src/Commands/Seed.php +++ b/src/Commands/Seed.php @@ -47,21 +47,21 @@ class Seed extends SeedCommand return; } - $this->input->setOption('database', 'tenant'); - $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(); }); if ($originalTenant) { tenancy()->initialize($originalTenant); - } else { - tenancy()->endTenancy(); } } } diff --git a/src/Commands/TenantList.php b/src/Commands/TenantList.php index a7dd9b9e..3d57df22 100644 --- a/src/Commands/TenantList.php +++ b/src/Commands/TenantList.php @@ -31,7 +31,7 @@ class TenantList extends Command { $this->info('Listing all tenants.'); tenancy()->all()->each(function ($tenant) { - $this->line("[Tenant] id: {$tenant['id']} @ ", implode('; ', $tenant->domains)); + $this->line("[Tenant] id: {$tenant['id']} @ " . implode('; ', $tenant->domains)); }); } } diff --git a/src/Contracts/TenancyBootstrapper.php b/src/Contracts/TenancyBootstrapper.php index 4dfb9536..2e1e6559 100644 --- a/src/Contracts/TenancyBootstrapper.php +++ b/src/Contracts/TenancyBootstrapper.php @@ -6,9 +6,12 @@ namespace Stancl\Tenancy\Contracts; use Stancl\Tenancy\Tenant; +/** + * TenancyBootstrappers are classes that make existing code tenant-aware. + */ interface TenancyBootstrapper { - public function start(Tenant $tenant); // todo2 TenantManager instead of Tenant + public function start(Tenant $tenant); public function end(); } diff --git a/src/DatabaseManager.php b/src/DatabaseManager.php index ce8cd99a..c36b51fa 100644 --- a/src/DatabaseManager.php +++ b/src/DatabaseManager.php @@ -62,7 +62,7 @@ class DatabaseManager public function createTenantConnection($databaseName, $connectionName) { // Create the database connection. - $based_on = $this->app['config']['tenancy.database.based_on'] ?? $this->originalDefaultConnectionName; + $based_on = $this->getBaseConnection($connectionName); $this->app['config']["database.connections.$connectionName"] = $this->app['config']['database.connections.' . $based_on]; // Change database name. @@ -71,17 +71,36 @@ class DatabaseManager } /** - * Get the driver of a database connection. + * Get the name of the connection that $connectionName should be based on. * * @param string $connectionName * @return string */ - protected function getDriver(string $connectionName): string + public function getBaseConnection(string $connectionName): string + { + return ($connectionName !== 'tenant' ? $connectionName : null) // 'tenant' is not a specific connection, it's the default + ?? $this->app['config']['tenancy.database.based_on'] + ?? $this->originalDefaultConnectionName; // tenancy.database.based_on === null => use the default connection + } + + /** + * Get the driver of a database connection. + * + * @param string $connectionName + * @return string|null + */ + public function getDriver(string $connectionName): ?string { return $this->app['config']["database.connections.$connectionName.driver"]; } - public function switchConnection($connection) + /** + * Switch the application's connection. + * + * @param string $connection + * @return void + */ + public function switchConnection(string $connection) { $this->app['config']['database.default'] = $connection; $this->database->purge(); @@ -103,6 +122,12 @@ class DatabaseManager } } + /** + * Create a database for a tenant. + * + * @param Tenant $tenant + * @return void + */ public function createDatabase(Tenant $tenant) { $database = $tenant->getDatabaseName(); @@ -111,10 +136,16 @@ class DatabaseManager if ($this->app['config']['tenancy.queue_database_creation'] ?? false) { QueuedTenantDatabaseCreator::dispatch($manager, $database); } else { - return $manager->createDatabase($database); + $manager->createDatabase($database); } } + /** + * Delete a tenant's database. + * + * @param Tenant $tenant + * @return void + */ public function deleteDatabase(Tenant $tenant) { $database = $tenant->getDatabaseName(); @@ -123,15 +154,19 @@ class DatabaseManager if ($this->app['config']['tenancy.queue_database_deletion'] ?? false) { QueuedTenantDatabaseDeleter::dispatch($manager, $database); } else { - return $manager->deleteDatabase($database); + $manager->deleteDatabase($database); } } + /** + * Get the TenantDatabaseManager for a tenant's database connection. + * + * @param Tenant $tenant + * @return TenantDatabaseManager + */ protected function getTenantDatabaseManager(Tenant $tenant): TenantDatabaseManager { - // todo2 this shouldn't have to create a connection - $this->createTenantConnection($tenant->getDatabaseName(), $tenant->getConnectionName()); - $driver = $this->getDriver($tenant->getConnectionName()); + $driver = $this->getDriver($this->getBaseConnection($tenant->getConnectionName())); $databaseManagers = $this->app['config']['tenancy.database_managers']; diff --git a/src/Exceptions/DatabaseManagerNotRegisteredException.php b/src/Exceptions/DatabaseManagerNotRegisteredException.php index 8af2f5e1..e93c31a1 100644 --- a/src/Exceptions/DatabaseManagerNotRegisteredException.php +++ b/src/Exceptions/DatabaseManagerNotRegisteredException.php @@ -8,6 +8,6 @@ class DatabaseManagerNotRegisteredException extends \Exception { public function __construct($driver) { - $this->message = "Database manager for driver $driver is not registered."; + parent::__construct("Database manager for driver $driver is not registered."); } } diff --git a/src/Exceptions/TenantCouldNotBeIdentifiedException.php b/src/Exceptions/TenantCouldNotBeIdentifiedException.php index 1d1b9633..fbebcd10 100644 --- a/src/Exceptions/TenantCouldNotBeIdentifiedException.php +++ b/src/Exceptions/TenantCouldNotBeIdentifiedException.php @@ -4,10 +4,23 @@ declare(strict_types=1); namespace Stancl\Tenancy\Exceptions; -class TenantCouldNotBeIdentifiedException extends \Exception +use Facade\IgnitionContracts\BaseSolution; +use Facade\IgnitionContracts\ProvidesSolution; +use Facade\IgnitionContracts\Solution; + +class TenantCouldNotBeIdentifiedException extends \Exception implements ProvidesSolution { public function __construct($domain) { - $this->message = "Tenant could not be identified on domain $domain"; + parent::__construct("Tenant could not be identified on domain $domain"); + } + + public function getSolution(): Solution + { + return BaseSolution::create('Tenant could not be identified on this domain') + ->setSolutionDescription('Did you forget to create a tenant for this domain?') + ->setDocumentationLinks([ + 'Creating Tenants' => 'https://tenancy.samuelstancl.me/docs/v2/creating-tenants/', + ]); } } diff --git a/src/Facades/TenantFacade.php b/src/Facades/TenantFacade.php index 8e553a8e..e319cb8c 100644 --- a/src/Facades/TenantFacade.php +++ b/src/Facades/TenantFacade.php @@ -5,13 +5,17 @@ declare(strict_types=1); namespace Stancl\Tenancy\Facades; use Illuminate\Support\Facades\Facade; -use Stancl\Tenancy\Tenant as Tenant; +use Stancl\Tenancy\Tenant; -// todo2 rename to CurrentTenant? class TenantFacade extends Facade { protected static function getFacadeAccessor() { return Tenant::class; } + + public static function create($domains, array $data = []): Tenant + { + return Tenant::create($domains, $data); + } } diff --git a/src/Features/TelescopeTags.php b/src/Features/TelescopeTags.php index 19a1131a..c1d4450f 100644 --- a/src/Features/TelescopeTags.php +++ b/src/Features/TelescopeTags.php @@ -14,6 +14,13 @@ class TelescopeTags implements Feature /** @var callable User-specific callback that returns tags. */ protected $callback; + public function __construct() + { + $this->callback = function ($entry) { + return []; + }; + } + public function bootstrap(TenantManager $tenantManager): void { if (! class_exists(Telescope::class)) { @@ -26,7 +33,6 @@ class TelescopeTags implements Feature if (in_array('tenancy', optional(request()->route())->middleware() ?? [])) { $tags = array_merge($tags, [ 'tenant:' . tenant('id'), - // todo2 domain? ]); } diff --git a/src/Features/TenantConfig.php b/src/Features/TenantConfig.php new file mode 100644 index 00000000..25c8a57d --- /dev/null +++ b/src/Features/TenantConfig.php @@ -0,0 +1,61 @@ +config = $config; + + foreach ($this->getStorageToConfigMap() as $configKey) { + $this->originalConfig[$configKey] = $this->config[$configKey]; + } + } + + public function bootstrap(TenantManager $tenantManager): void + { + $tenantManager->eventListener('bootstrapped', function (TenantManager $manager) { + $this->setTenantConfig($manager->getTenant()); + }); + + $tenantManager->eventListener('ended', function () { + $this->unsetTenantConfig(); + }); + } + + public function setTenantConfig(Tenant $tenant): void + { + foreach ($this->getStorageToConfigMap() as $storageKey => $configKey) { + $override = $tenant->data[$storageKey] ?? null; + if (! is_null($override)) { + $this->config[$configKey] = $override; + } + } + } + + public function unsetTenantConfig(): void + { + foreach ($this->getStorageToConfigMap() as $configKey) { + $this->config[$configKey] = $this->originalConfig[$configKey]; + } + } + + public function getStorageToConfigMap(): array + { + return $this->config['tenancy.storage_to_config_map'] ?? []; + } +} diff --git a/src/Jobs/QueuedTenantDatabaseCreator.php b/src/Jobs/QueuedTenantDatabaseCreator.php index 5d026d56..bd03fc55 100644 --- a/src/Jobs/QueuedTenantDatabaseCreator.php +++ b/src/Jobs/QueuedTenantDatabaseCreator.php @@ -15,7 +15,10 @@ class QueuedTenantDatabaseCreator implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + /** @var TenantDatabaseManager */ protected $databaseManager; + + /** @var string */ protected $databaseName; /** diff --git a/src/Jobs/QueuedTenantDatabaseDeleter.php b/src/Jobs/QueuedTenantDatabaseDeleter.php index 170686cc..7d395579 100644 --- a/src/Jobs/QueuedTenantDatabaseDeleter.php +++ b/src/Jobs/QueuedTenantDatabaseDeleter.php @@ -15,7 +15,10 @@ class QueuedTenantDatabaseDeleter implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + /** @var TenantDatabaseManager */ protected $databaseManager; + + /** @var string */ protected $databaseName; /** diff --git a/src/Jobs/QueuedTenantDatabaseMigrator.php b/src/Jobs/QueuedTenantDatabaseMigrator.php new file mode 100644 index 00000000..84acca66 --- /dev/null +++ b/src/Jobs/QueuedTenantDatabaseMigrator.php @@ -0,0 +1,38 @@ +tenant = $tenant; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + Artisan::call('tenants:migrate', [ + '--tenants' => [$this->tenant->id], + ]); + } +} diff --git a/src/Middleware/InitializeTenancy.php b/src/Middleware/InitializeTenancy.php index 3b488f90..3bc383ba 100644 --- a/src/Middleware/InitializeTenancy.php +++ b/src/Middleware/InitializeTenancy.php @@ -5,10 +5,14 @@ declare(strict_types=1); namespace Stancl\Tenancy\Middleware; use Closure; +use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedException; class InitializeTenancy { - public function __construct(Closure $onFail = null) + /** @var callable */ + protected $onFail; + + public function __construct(callable $onFail = null) { $this->onFail = $onFail ?? function ($e) { throw $e; @@ -25,8 +29,8 @@ class InitializeTenancy public function handle($request, Closure $next) { try { - tenancy()->init(); - } catch (\Exception $e) { + tenancy()->init($request->getHost()); + } catch (TenantCouldNotBeIdentifiedException $e) { ($this->onFail)($e); } diff --git a/src/Middleware/PreventAccessFromTenantDomains.php b/src/Middleware/PreventAccessFromTenantDomains.php index 082a613f..c961fada 100644 --- a/src/Middleware/PreventAccessFromTenantDomains.php +++ b/src/Middleware/PreventAccessFromTenantDomains.php @@ -6,6 +6,10 @@ namespace Stancl\Tenancy\Middleware; use Closure; +/** + * 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. + */ class PreventAccessFromTenantDomains { /** @@ -19,8 +23,16 @@ class PreventAccessFromTenantDomains { // If the domain is not in exempt domains, it's a tenant domain. // Tenant domains can't have routes without tenancy middleware. - if (! \in_array(request()->getHost(), config('tenancy.exempt_domains')) && - ! \in_array('tenancy', request()->route()->middleware())) { + $isExemptDomain = in_array($request->getHost(), config('tenancy.exempt_domains')); + $isTenantDomain = ! $isExemptDomain; + + $isTenantRoute = in_array('tenancy', $request->route()->middleware()); + + 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); } diff --git a/src/StorageDrivers/Database/CentralConnection.php b/src/StorageDrivers/Database/CentralConnection.php new file mode 100644 index 00000000..3c697783 --- /dev/null +++ b/src/StorageDrivers/Database/CentralConnection.php @@ -0,0 +1,13 @@ +app = $app; + $this->centralDatabase = $this->getCentralConnection(); + } + + /** + * Get the central database connection. + * + * @return \Illuminate\Database\Connection + */ + public static function getCentralConnection(): \Illuminate\Database\Connection + { + return DB::connection(static::getCentralConnectionName()); + } + + public static function getCentralConnectionName(): string + { + return config('tenancy.storage_drivers.db.connection') ?? app(DatabaseManager::class)->originalDefaultConnectionName; } public function findByDomain(string $domain): Tenant @@ -54,13 +72,12 @@ class DatabaseStorageDriver implements StorageDriver public function ensureTenantCanBeCreated(Tenant $tenant): void { - // todo2 test this if (Tenants::find($tenant->id)) { throw new TenantWithThisIdAlreadyExistsException($tenant->id); } if (Domains::whereIn('domain', $tenant->domains)->exists()) { - throw new DomainOccupiedByOtherTenantException(); + throw new DomainsOccupiedByOtherTenantException; } } @@ -78,32 +95,45 @@ class DatabaseStorageDriver implements StorageDriver public function createTenant(Tenant $tenant): void { - DB::transaction(function () use ($tenant) { - Tenants::create(['id' => $tenant->id, 'data' => '{}'])->toArray(); + $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::create($domainData); + + Domains::insert($domainData); }); } public function updateTenant(Tenant $tenant): void { - Tenants::find($tenant->id)->putMany($tenant->data); - Domains::firstOrCreate(array_map(function ($domain) use ($tenant) { - return [ - 'tenant_id' => $tenant->id, - 'domain' => $domain, - ]; - }, $tenant->domains)); + $this->centralDatabase->transaction(function () use ($tenant) { + Tenants::find($tenant->id)->putMany($tenant->data); + + $original_domains = Domains::where('tenant_id', $tenant->id)->get()->map(function ($model) { + return $model->domain; + })->toArray(); + $deleted_domains = array_diff($original_domains, $tenant->domains); + + Domains::whereIn('domain', $deleted_domains)->delete(); + + foreach ($tenant->domains as $domain) { + Domains::firstOrCreate([ + 'tenant_id' => $tenant->id, + 'domain' => $domain, + ]); + } + }); } public function deleteTenant(Tenant $tenant): void { - Tenants::find($tenant->id)->delete(); - Domains::where('tenant_id', $tenant->id)->delete(); + $this->centralDatabase->transaction(function () use ($tenant) { + Tenants::find($tenant->id)->delete(); + Domains::where('tenant_id', $tenant->id)->delete(); + }); } /** diff --git a/src/StorageDrivers/Database/DomainModel.php b/src/StorageDrivers/Database/DomainModel.php index 1282ae05..abddff4b 100644 --- a/src/StorageDrivers/Database/DomainModel.php +++ b/src/StorageDrivers/Database/DomainModel.php @@ -11,14 +11,15 @@ use Illuminate\Database\Eloquent\Model; */ class DomainModel extends Model { + use CentralConnection; + protected $guarded = []; - protected $primaryKey = 'id'; + protected $primaryKey = 'domain'; public $incrementing = false; public $timestamps = false; - public $table = 'domains'; - public function getConnectionName() + public function getTable() { - return config('tenancy.storage.db.connection') ?? app(DatabaseManager::class)->originalDefaultConnectionName; + return config('tenancy.storage_drivers.db.table_names.DomainModel', 'domains'); } } diff --git a/src/StorageDrivers/Database/TenantModel.php b/src/StorageDrivers/Database/TenantModel.php index 1f962e3a..782d0308 100644 --- a/src/StorageDrivers/Database/TenantModel.php +++ b/src/StorageDrivers/Database/TenantModel.php @@ -11,25 +11,26 @@ use Illuminate\Database\Eloquent\Model; */ class TenantModel extends Model { + use CentralConnection; + protected $guarded = []; protected $primaryKey = 'id'; public $incrementing = false; public $timestamps = false; - public $table = 'tenants'; + + public function getTable() + { + return config('tenancy.storage_drivers.db.table_names.TenantModel', 'tenants'); + } public static function dataColumn() { - return config('tenancy.storage.db.data_column', 'data'); + return config('tenancy.storage_drivers.db.data_column', 'data'); } public static function customColumns() { - return config('tenancy.storage.db.custom_columns', []); - } - - public function getConnectionName() - { - return config('tenancy.storage.db.connection') ?? app(DatabaseManager::class)->originalDefaultConnectionName; + return config('tenancy.storage_drivers.db.custom_columns', []); } public static function getAllTenants(array $ids) @@ -81,7 +82,7 @@ class TenantModel extends Model public function getMany(array $keys): array { - return array_reduce($keys, function ($result, $key) { // todo2 performance + return array_reduce($keys, function ($result, $key) { $result[$key] = $this->get($key); return $result; diff --git a/src/StorageDrivers/RedisStorageDriver.php b/src/StorageDrivers/RedisStorageDriver.php index cbfb8e1a..4e35834f 100644 --- a/src/StorageDrivers/RedisStorageDriver.php +++ b/src/StorageDrivers/RedisStorageDriver.php @@ -7,14 +7,13 @@ namespace Stancl\Tenancy\StorageDrivers; use Illuminate\Contracts\Redis\Factory as Redis; use Illuminate\Foundation\Application; use Stancl\Tenancy\Contracts\StorageDriver; +use Stancl\Tenancy\Exceptions\DomainsOccupiedByOtherTenantException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedException; +use Stancl\Tenancy\Exceptions\TenantWithThisIdAlreadyExistsException; use Stancl\Tenancy\Tenant; -// todo2 transactions instead of pipelines? class RedisStorageDriver implements StorageDriver { - // todo2 json encoding? - /** @var Application */ protected $app; @@ -27,7 +26,7 @@ class RedisStorageDriver implements StorageDriver public function __construct(Application $app, Redis $redis) { $this->app = $app; - $this->redis = $redis->connection($app['config']['tenancy.redis.connection'] ?? 'tenancy'); + $this->redis = $redis->connection($app['config']['tenancy.storage_drivers.redis.connection'] ?? 'tenancy'); } /** @@ -49,7 +48,17 @@ class RedisStorageDriver implements StorageDriver public function ensureTenantCanBeCreated(Tenant $tenant): void { - // todo2 + // Tenant ID + if ($this->redis->exists("tenants:{$tenant->id}")) { + throw new TenantWithThisIdAlreadyExistsException($tenant->id); + } + + // Domains + if ($this->redis->exists(...array_map(function ($domain) { + return "domains:$domain"; + }, $tenant->domains))) { + throw new DomainsOccupiedByOtherTenantException; + } } public function findByDomain(string $domain): Tenant @@ -59,26 +68,12 @@ class RedisStorageDriver implements StorageDriver throw new TenantCouldNotBeIdentifiedException($domain); } - return $this->find($id); + return $this->findById($id); } public function findById(string $id): Tenant { - $data = $this->redis->hgetall("tenants:$id"); - $keys = []; - $values = []; - foreach ($data as $i => $value) { - if ($i & 1) { // is odd - $values[] = $value; - } else { - $keys[] = $value; - } - } - - $data = array_combine($keys, $values); - $domains = []; // todo2 - - return Tenant::fromStorage($data)->withDomains($domains); + return $this->makeTenant($this->redis->hgetall("tenants:$id")); } public function getTenantIdByDomain(string $domain): ?string @@ -88,32 +83,49 @@ class RedisStorageDriver implements StorageDriver public function createTenant(Tenant $tenant): void { - $this->redis->pipeline(function ($pipe) use ($tenant) { - $id = $tenant->id; - + $this->redis->transaction(function ($pipe) use ($tenant) { foreach ($tenant->domains as $domain) { - $pipe->hmset("domains:$domain", 'tenant_id', $id); + $pipe->hmset("domains:$domain", ['tenant_id' => $tenant->id]); } - $pipe->hmset("tenants:$id", 'id', json_encode($id), 'domain', json_encode($domain)); + + $data = []; + foreach ($tenant->data as $key => $value) { + $data[$key] = json_encode($value); + } + + $pipe->hmset("tenants:{$tenant->id}", array_merge($data, ['_tenancy_domains' => json_encode($tenant->domains)])); }); } public function updateTenant(Tenant $tenant): void { - $this->redis->pipeline(function ($pipe) use ($tenant) { - $pipe->hmset("tenants:{$tenant->id}", $tenant->data); + $id = $tenant->id; - foreach ($tenant->domains as $domain) { - $pipe->hmset("domains:$domain", 'tenant_id', $tenant->id); + $old_domains = json_decode($this->redis->hget("tenants:$id", '_tenancy_domains'), true); + $deleted_domains = array_diff($old_domains, $tenant->domains); + $domains = $tenant->domains; + + $data = []; + foreach ($tenant->data as $key => $value) { + $data[$key] = json_encode($value); + } + + $this->redis->transaction(function ($pipe) use ($id, $data, $deleted_domains, $domains) { + foreach ($deleted_domains as $deleted_domain) { + $pipe->del("domains:$deleted_domain"); } - // todo2 deleted domains + foreach ($domains as $domain) { + $pipe->hset("domains:$domain", 'tenant_id', $id); + } + + $pipe->hmset("tenants:$id", array_merge($data, ['_tenancy_domains' => json_encode($domains)])); }); } public function deleteTenant(Tenant $tenant): void { - $this->redis->pipeline(function ($pipe) use ($tenant) { + $this->redis->transaction(function ($pipe) use ($tenant) { foreach ($tenant->domains as $domain) { $pipe->del("domains:$domain"); } @@ -122,9 +134,14 @@ class RedisStorageDriver implements StorageDriver }); } + /** + * Return a list of all tenants. + * + * @param string[] $ids + * @return Tenant[] + */ public function all(array $ids = []): array { - // todo2 $this->redis->pipeline() $hashes = array_map(function ($hash) { return "tenants:{$hash}"; }, $ids); @@ -143,15 +160,38 @@ class RedisStorageDriver implements StorageDriver } return array_map(function ($tenant) { - return $this->redis->hgetall($tenant); + return $this->makeTenant($this->redis->hgetall($tenant)); }, $hashes); } + /** + * Make a Tenant instance from low-level array data. + * + * @param array $data + * @return Tenant + */ + protected function makeTenant(array $data): Tenant + { + foreach ($data as $key => $value) { + $data[$key] = json_decode($value, true); + } + + $domains = $data['_tenancy_domains']; + unset($data['_tenancy_domains']); + + return Tenant::fromStorage($data)->withDomains($domains); + } + public function get(string $key, Tenant $tenant = null) { $tenant = $tenant ?? $this->tenant(); - return json_decode($this->redis->hget("tenants:{$tenant->id}", $key), true); + $json_data = $this->redis->hget("tenants:{$tenant->id}", $key); + if ($json_data === false) { + return; + } + + return json_decode($json_data, true); } public function getMany(array $keys, Tenant $tenant = null): array @@ -161,7 +201,7 @@ class RedisStorageDriver implements StorageDriver $result = []; $values = $this->redis->hmget("tenants:{$tenant->id}", $keys); foreach ($keys as $i => $key) { - $result[$key] = $values[$i]; + $result[$key] = json_decode($values[$i], true); } return $result; diff --git a/src/TenancyBootstrappers/CacheTenancyBootstrapper.php b/src/TenancyBootstrappers/CacheTenancyBootstrapper.php index dc4bb91e..ffd1ef6d 100644 --- a/src/TenancyBootstrappers/CacheTenancyBootstrapper.php +++ b/src/TenancyBootstrappers/CacheTenancyBootstrapper.php @@ -36,5 +36,7 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper $this->app->extend('cache', function () { return $this->originalCache; }); + + $this->originalCache = null; } } diff --git a/src/TenancyBootstrappers/DatabaseTenancyBootstrapper.php b/src/TenancyBootstrappers/DatabaseTenancyBootstrapper.php index 227dbdf9..e93e7301 100644 --- a/src/TenancyBootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/TenancyBootstrappers/DatabaseTenancyBootstrapper.php @@ -4,22 +4,17 @@ declare(strict_types=1); namespace Stancl\Tenancy\TenancyBootstrappers; -use Illuminate\Foundation\Application; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\DatabaseManager; use Stancl\Tenancy\Tenant; class DatabaseTenancyBootstrapper implements TenancyBootstrapper { - /** @var Application */ - protected $app; - /** @var DatabaseManager */ protected $database; - public function __construct(Application $app, DatabaseManager $database) + public function __construct(DatabaseManager $database) { - $this->app = $app; $this->database = $database; } diff --git a/src/TenancyBootstrappers/FilesystemTenancyBootstrapper.php b/src/TenancyBootstrappers/FilesystemTenancyBootstrapper.php index ec9c17e2..b08a79e2 100644 --- a/src/TenancyBootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/TenancyBootstrappers/FilesystemTenancyBootstrapper.php @@ -9,37 +9,50 @@ use Illuminate\Support\Facades\Storage; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Tenant; -// todo better solution than tenant_asset? - class FilesystemTenancyBootstrapper implements TenancyBootstrapper { - protected $originalPaths = []; - /** @var Application */ protected $app; + /** @var array */ + public $originalPaths = []; + public function __construct(Application $app) { $this->app = $app; $this->originalPaths = [ 'disks' => [], - 'path' => $this->app->storagePath(), + 'storage' => $this->app->storagePath(), + 'asset_url' => $this->app['config']['app.asset_url'], ]; + + $this->app['url']->macro('setAssetRoot', function ($root) { + $this->assetRoot = $root; + + return $this; + }); } public function start(Tenant $tenant) { - // todo2 revisit this $suffix = $this->app['config']['tenancy.filesystem.suffix_base'] . $tenant->id; // storage_path() - $this->app->useStoragePath($this->originalPaths['path'] . "/{$suffix}"); + $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' => ''])); + } // Storage facade foreach ($this->app['config']['tenancy.filesystem.disks'] as $disk) { $this->originalPaths['disks'][$disk] = Storage::disk($disk)->getAdapter()->getPathPrefix(); - if ($root = \str_replace('%storage_path%', storage_path(), $this->app['config']["tenancy.filesystem.root_override.{$disk}"])) { + if ($root = str_replace('%storage_path%', storage_path(), $this->app['config']["tenancy.filesystem.root_override.{$disk}"])) { Storage::disk($disk)->getAdapter()->setPathPrefix($root); } else { $root = $this->app['config']["filesystems.disks.{$disk}.root"]; @@ -52,7 +65,11 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper public function end() { // storage_path() - $this->app->useStoragePath($this->originalPaths['path']); + $this->app->useStoragePath($this->originalPaths['storage']); + + // asset() + $this->app['config']['app.asset_url'] = $this->originalPaths['asset_url']; + $this->app['url']->setAssetRoot($this->app['config']['app.asset_url']); // Storage facade foreach ($this->app['config']['tenancy.filesystem.disks'] as $disk) { diff --git a/src/TenancyBootstrappers/QueueTenancyBootstrapper.php b/src/TenancyBootstrappers/QueueTenancyBootstrapper.php index 418ed7e5..d37f9426 100644 --- a/src/TenancyBootstrappers/QueueTenancyBootstrapper.php +++ b/src/TenancyBootstrappers/QueueTenancyBootstrapper.php @@ -5,13 +5,14 @@ declare(strict_types=1); namespace Stancl\Tenancy\TenancyBootstrappers; use Illuminate\Contracts\Foundation\Application; +use Illuminate\Support\Testing\Fakes\QueueFake; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Tenant; class QueueTenancyBootstrapper implements TenancyBootstrapper { /** @var bool Has tenancy been started. */ - protected $started = false; + public $started = false; /** @var Application */ protected $app; @@ -20,12 +21,14 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper { $this->app = $app; - $this->app['queue']->createPayloadUsing([$this, 'createPayload']); - $this->app['events']->listen(\Illuminate\Queue\Events\JobProcessing::class, function ($event) { - if (\array_key_exists('tenant_id', $event->job->payload())) { - tenancy()->initById($event->job->payload()['tenant_id']); - } - }); + $bootstrapper = &$this; + + $queue = $this->app['queue']; + if (! $queue instanceof QueueFake) { + $queue->createPayloadUsing(function () use (&$bootstrapper) { + return $bootstrapper->getPayload(); + }); + } } public function start(Tenant $tenant) @@ -38,19 +41,18 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper $this->started = false; } - public function createPayload() + public function getPayload() { if (! $this->started) { return []; } - $id = tenant()->get('id'); + $id = tenant('id'); return [ 'tenant_id' => $id, 'tags' => [ "tenant:$id", - // todo2 domain ], ]; } diff --git a/src/TenancyBootstrappers/RedisTenancyBootstrapper.php b/src/TenancyBootstrappers/RedisTenancyBootstrapper.php index be758e2c..53dbd339 100644 --- a/src/TenancyBootstrappers/RedisTenancyBootstrapper.php +++ b/src/TenancyBootstrappers/RedisTenancyBootstrapper.php @@ -4,28 +4,28 @@ declare(strict_types=1); namespace Stancl\Tenancy\TenancyBootstrappers; -use Illuminate\Contracts\Foundation\Application; +use Illuminate\Config\Repository; use Illuminate\Support\Facades\Redis; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Tenant; class RedisTenancyBootstrapper implements TenancyBootstrapper { - /** @var string[string] Original prefixes of connections */ - protected $originalPrefixes = []; + /** @var array Original prefixes of connections */ + public $originalPrefixes = []; - /** @var Application */ - protected $app; + /** @var Repository */ + protected $config; - public function __construct(Application $app) + public function __construct(Repository $config) { - $this->app = $app; + $this->config = $config; } public function start(Tenant $tenant) { foreach ($this->prefixedConnections() as $connection) { - $prefix = $this->app['config']['tenancy.redis.prefix_base'] . $tenant['id']; + $prefix = $this->config['tenancy.redis.prefix_base'] . $tenant['id']; $client = Redis::connection($connection)->client(); $this->originalPrefixes[$connection] = $client->getOption($client::OPT_PREFIX); @@ -40,10 +40,12 @@ class RedisTenancyBootstrapper implements TenancyBootstrapper $client->setOption($client::OPT_PREFIX, $this->originalPrefixes[$connection]); } + + $this->originalPrefixes = []; } protected function prefixedConnections() { - return config('tenancy.redis.prefixed_connections'); + return $this->config['tenancy.redis.prefixed_connections']; } } diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index 9ecf22b1..7f8e86ad 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -7,53 +7,21 @@ namespace Stancl\Tenancy; use Illuminate\Cache\CacheManager; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; +use Stancl\Tenancy\TenancyBootstrappers\FilesystemTenancyBootstrapper; class TenancyServiceProvider extends ServiceProvider { - /** - * Bootstrap services. - * - * @return void - */ - public function boot() - { - $this->commands([ - Commands\Run::class, - Commands\Seed::class, - Commands\Install::class, - Commands\Migrate::class, - Commands\Rollback::class, - Commands\TenantList::class, - ]); - - $this->publishes([ - __DIR__ . '/../assets/config.php' => config_path('tenancy.php'), - ], 'config'); - - $this->publishes([ - __DIR__ . '/../assets/migrations/' => database_path('migrations'), - ], 'migrations'); - - $this->loadRoutesFrom(__DIR__ . '/routes.php'); - - Route::middlewareGroup('tenancy', [ - \Stancl\Tenancy\Middleware\InitializeTenancy::class, - ]); - - $this->app->register(TenantRouteServiceProvider::class); - } - /** * Register services. * * @return void */ - public function register() + public function register(): void { $this->mergeConfigFrom(__DIR__ . '/../assets/config.php', 'tenancy'); $this->app->bind(Contracts\StorageDriver::class, function ($app) { - return $app->make($app['config']['tenancy.storage_driver']); + return $app->make($app['config']['tenancy.storage_drivers'][$app['config']['tenancy.storage_driver']]['driver']); }); $this->app->bind(Contracts\UniqueIdentifierGenerator::class, $this->app['config']['tenancy.unique_id_generator']); $this->app->singleton(DatabaseManager::class); @@ -79,5 +47,54 @@ class TenancyServiceProvider extends ServiceProvider $this->app->bind('globalCache', function ($app) { return new CacheManager($app); }); + + $this->app->register(TenantRouteServiceProvider::class); + } + + /** + * Bootstrap services. + * + * @return void + */ + public function boot(): void + { + $this->commands([ + Commands\Run::class, + Commands\Seed::class, + Commands\Install::class, + Commands\Migrate::class, + Commands\Rollback::class, + Commands\TenantList::class, + Commands\CreateTenant::class, + Commands\MigrateFresh::class, + ]); + + $this->publishes([ + __DIR__ . '/../assets/config.php' => config_path('tenancy.php'), + ], 'config'); + + $this->publishes([ + __DIR__ . '/../assets/migrations/' => database_path('migrations'), + ], 'migrations'); + + $this->loadRoutesFrom(__DIR__ . '/routes.php'); + + Route::middlewareGroup('tenancy', [ + \Stancl\Tenancy\Middleware\InitializeTenancy::class, + ]); + + $this->app->singleton('globalUrl', function ($app) { + $instance = clone $app['url']; + $instance->setAssetRoot($app[FilesystemTenancyBootstrapper::class]->originalPaths['asset_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'])); + } + }); } } diff --git a/src/Tenant.php b/src/Tenant.php index f5595dce..836e27b8 100644 --- a/src/Tenant.php +++ b/src/Tenant.php @@ -6,19 +6,19 @@ namespace Stancl\Tenancy; use ArrayAccess; use Illuminate\Foundation\Application; +use Illuminate\Support\Str; +use Illuminate\Support\Traits\ForwardsCalls; use Stancl\Tenancy\Contracts\StorageDriver; use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator; use Stancl\Tenancy\Exceptions\TenantStorageException; -// todo2 write tests for updating the tenant -// todo2 addDomain(), removeDomain() - /** * @internal Class is subject to breaking changes in minor and patch versions. */ class Tenant implements ArrayAccess { - use Traits\HasArrayAccess; + use Traits\HasArrayAccess, + ForwardsCalls; /** * Tenant data. A "cache" of tenant storage. @@ -51,8 +51,16 @@ class Tenant implements ArrayAccess * * @var bool */ - protected $persisted = false; + public $persisted = false; + /** + * 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) { $this->app = $app; @@ -61,6 +69,12 @@ class Tenant implements ArrayAccess $this->idGenerator = $idGenerator; } + /** + * Public constructor. + * + * @param Application $app + * @return self + */ public static function new(Application $app = null): self { $app = $app ?? app(); @@ -73,27 +87,49 @@ class Tenant implements ArrayAccess ); } + /** + * DO NOT CALL THIS METHOD FROM USERLAND. Used by storage + * drivers to create persisted instances of Tenant. + * + * @param array $data + * @return self + */ public static function fromStorage(array $data): self { return static::new()->withData($data)->persisted(true); } + /** + * Create a tenant in a single call. + * + * @param string|string[] $domains + * @param array $data + * @return self + */ public static function create($domains, array $data = []): self { return static::new()->withDomains((array) $domains)->withData($data)->save(); } - protected function persisted($persisted = null) + /** + * DO NOT CALL THIS METHOD FROM USERLAND UNLESS YOU KNOW WHAT YOU ARE DOING. + * Set $persisted. + * + * @param bool $persisted + * @return self + */ + public function persisted(bool $persisted): self { - if (gettype($persisted) === 'boolean') { - $this->persisted = $persisted; - - return $this; - } + $this->persisted = $persisted; return $this; } + /** + * Does this model exist in the tenant storage. + * + * @return bool + */ public function isPersisted(): bool { return $this->persisted; @@ -127,6 +163,11 @@ class Tenant implements ArrayAccess return $this; } + /** + * Unassign all domains from the tenant. + * + * @return self + */ public function clearDomains(): self { $this->domains = []; @@ -134,6 +175,12 @@ class Tenant implements ArrayAccess return $this; } + /** + * Set (overwrite) the tenant's domains. + * + * @param string|string[] $domains + * @return self + */ public function withDomains($domains): self { $domains = (array) $domains; @@ -143,6 +190,12 @@ class Tenant implements ArrayAccess return $this; } + /** + * Set (overwrite) tenant data. + * + * @param array $data + * @return self + */ public function withData(array $data): self { $this->data = $data; @@ -150,11 +203,21 @@ class Tenant implements ArrayAccess return $this; } + /** + * Generate a random ID. + * + * @return void + */ public function generateId() { $this->id = $this->idGenerator->generate($this->domains, $this->data); } + /** + * Write the tenant's state to storage. + * + * @return self + */ public function save(): self { if (! isset($this->data['id'])) { @@ -188,7 +251,7 @@ class Tenant implements ArrayAccess } /** - * Unassign all domains from the tenant. + * Unassign all domains from the tenant and write to storage. * * @return self */ @@ -201,12 +264,22 @@ class Tenant implements ArrayAccess return $this; } - public function getDatabaseName() + /** + * Get the tenant's database's name. + * + * @return string + */ + public function getDatabaseName(): string { return $this->data['_tenancy_db_name'] ?? ($this->app['config']['tenancy.database.prefix'] . $this->id . $this->app['config']['tenancy.database.suffix']); } - public function getConnectionName() + /** + * Get the tenant's database connection's name. + * + * @return string + */ + public function getConnectionName(): string { return $this->data['_tenancy_db_connection'] ?? 'tenant'; } @@ -243,6 +316,13 @@ class Tenant implements ArrayAccess return $this->data[$key]; } + /** + * Set a value and write to storage. + * + * @param string|array $key + * @param mixed $value + * @return self + */ public function put($key, $value = null): self { if ($key === 'id') { @@ -268,6 +348,20 @@ class Tenant implements ArrayAccess return $this->put($key, $value); } + /** + * Set a value. + * + * @param string $key + * @param mixed $value + * @return self + */ + public function with(string $key, $value): self + { + $this->data[$key] = $value; + + return $this; + } + public function __get($key) { return $this->get($key); @@ -278,6 +372,16 @@ class Tenant implements ArrayAccess if ($key === 'id' && isset($this->data['id'])) { throw new TenantStorageException("Tenant ids can't be changed."); } + $this->data[$key] = $value; } + + public function __call($method, $parameters) + { + if (Str::startsWith($method, 'with')) { + return $this->with(Str::snake(substr($method, 4)), $parameters[0]); + } + + static::throwBadMethodCallException($method); + } } diff --git a/src/TenantDatabaseManagers/MySQLDatabaseManager.php b/src/TenantDatabaseManagers/MySQLDatabaseManager.php index f68a7771..11d8c123 100644 --- a/src/TenantDatabaseManagers/MySQLDatabaseManager.php +++ b/src/TenantDatabaseManagers/MySQLDatabaseManager.php @@ -4,23 +4,32 @@ declare(strict_types=1); namespace Stancl\Tenancy\TenantDatabaseManagers; -use Illuminate\Support\Facades\DB; +use Illuminate\Config\Repository; +use Illuminate\Database\DatabaseManager as IlluminateDatabaseManager; use Stancl\Tenancy\Contracts\TenantDatabaseManager; class MySQLDatabaseManager implements TenantDatabaseManager { + /** @var \Illuminate\Database\Connection */ + protected $database; + + public function __construct(Repository $config, IlluminateDatabaseManager $databaseManager) + { + $this->database = $databaseManager->connection($config['tenancy.database_manager_connections.mysql']); + } + public function createDatabase(string $name): bool { - return DB::statement("CREATE DATABASE `$name` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + return $this->database->statement("CREATE DATABASE `$name` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); } public function deleteDatabase(string $name): bool { - return DB::statement("DROP DATABASE `$name`"); + return $this->database->statement("DROP DATABASE `$name`"); } public function databaseExists(string $name): bool { - return (bool) DB::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 6cb48124..d6c974e7 100644 --- a/src/TenantDatabaseManagers/PostgreSQLDatabaseManager.php +++ b/src/TenantDatabaseManagers/PostgreSQLDatabaseManager.php @@ -4,23 +4,32 @@ declare(strict_types=1); namespace Stancl\Tenancy\TenantDatabaseManagers; -use Illuminate\Support\Facades\DB; +use Illuminate\Config\Repository; +use Illuminate\Database\DatabaseManager as IlluminateDatabaseManager; use Stancl\Tenancy\Contracts\TenantDatabaseManager; class PostgreSQLDatabaseManager implements TenantDatabaseManager { + /** @var \Illuminate\Database\Connection */ + protected $database; + + public function __construct(Repository $config, IlluminateDatabaseManager $databaseManager) + { + $this->database = $databaseManager->connection($config['tenancy.database_manager_connections.pgsql']); + } + public function createDatabase(string $name): bool { - return DB::statement("CREATE DATABASE \"$name\" WITH TEMPLATE=template0"); + return $this->database->statement("CREATE DATABASE \"$name\" WITH TEMPLATE=template0"); } public function deleteDatabase(string $name): bool { - return DB::statement("DROP DATABASE \"$name\""); + return $this->database->statement("DROP DATABASE \"$name\""); } public function databaseExists(string $name): bool { - return (bool) DB::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/TenantManager.php b/src/TenantManager.php index 5522ceb3..2fb5f412 100644 --- a/src/TenantManager.php +++ b/src/TenantManager.php @@ -9,6 +9,7 @@ use Illuminate\Foundation\Application; use Illuminate\Support\Collection; use Stancl\Tenancy\Contracts\TenantCannotBeCreatedException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedException; +use Stancl\Tenancy\Jobs\QueuedTenantDatabaseMigrator; /** * @internal Class is subject to breaking changes in minor and patch versions. @@ -50,6 +51,12 @@ class TenantManager $this->bootstrapFeatures(); } + /** + * Write a new tenant to storage. + * + * @param Tenant $tenant + * @return self + */ public function createTenant(Tenant $tenant): self { $this->ensureTenantCanBeCreated($tenant); @@ -58,14 +65,24 @@ class TenantManager $this->database->createDatabase($tenant); if ($this->shouldMigrateAfterCreation()) { - $this->artisan->call('tenants:migrate', [ - '--tenants' => [$tenant['id']], - ]); + if ($this->shouldQueueMigration()) { + QueuedTenantDatabaseMigrator::dispatch($tenant); + } else { + $this->artisan->call('tenants:migrate', [ + '--tenants' => [$tenant['id']], + ]); + } } return $this; } + /** + * Delete a tenant from storage. + * + * @param Tenant $tenant + * @return self + */ public function deleteTenant(Tenant $tenant): self { $this->storage->deleteTenant($tenant); @@ -77,6 +94,13 @@ class TenantManager return $this; } + /** + * Alias for Stancl\Tenancy\Tenant::create. + * + * @param string|string[] $domains + * @param array $data + * @return Tenant + */ public static function create($domains, array $data = []): Tenant { return Tenant::create($domains, $data); @@ -95,6 +119,12 @@ class TenantManager $this->database->ensureTenantCanBeCreated($tenant); } + /** + * Update an existing tenant in storage. + * + * @param Tenant $tenant + * @return self + */ public function updateTenant(Tenant $tenant): self { $this->storage->updateTenant($tenant); @@ -102,6 +132,12 @@ class TenantManager return $this; } + /** + * Find tenant by domain & initialize tenancy. + * + * @param string|null $domain + * @return self + */ public function init(string $domain = null): self { $domain = $domain ?? request()->getHost(); @@ -110,6 +146,12 @@ class TenantManager return $this; } + /** + * Find tenant by ID & initialize tenancy. + * + * @param string $id + * @return self + */ public function initById(string $id): self { $this->initializeTenancy($this->find($id)); @@ -156,6 +198,12 @@ class TenantManager return collect($this->storage->all($only)); } + /** + * Initialize tenancy. + * + * @param Tenant $tenant + * @return self + */ public function initializeTenancy(Tenant $tenant): self { $this->setTenant($tenant); @@ -171,6 +219,12 @@ class TenantManager return $this->initializeTenancy($tenant); } + /** + * Execute TenancyBootstrappers. + * + * @param Tenant $tenant + * @return self + */ public function bootstrapTenancy(Tenant $tenant): self { $prevented = $this->event('bootstrapping'); @@ -257,6 +311,11 @@ class TenantManager return $this->app['config']['tenancy.migrate_after_creation'] ?? false; } + public function shouldQueueMigration(): bool + { + return $this->app['config']['tenancy.queue_automatic_migration'] ?? false; + } + public function shouldDeleteDatabase(): bool { return $this->app['config']['tenancy.delete_database_after_tenant_deletion'] ?? false; @@ -277,6 +336,19 @@ class TenantManager return $this; } + /** + * Add an event hook. + * @alias eventListener + * + * @param string $name + * @param callable $listener + * @return self + */ + public function hook(string $name, callable $listener): self + { + return $this->eventListener($name, $listener); + } + /** * Execute event listeners. * diff --git a/src/TenantRouteServiceProvider.php b/src/TenantRouteServiceProvider.php index 84cfdefd..e0ab3335 100644 --- a/src/TenantRouteServiceProvider.php +++ b/src/TenantRouteServiceProvider.php @@ -11,8 +11,8 @@ 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'))) { + 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')); diff --git a/src/Traits/HasATenantsOption.php b/src/Traits/HasATenantsOption.php index 26680d03..4d6b247d 100644 --- a/src/Traits/HasATenantsOption.php +++ b/src/Traits/HasATenantsOption.php @@ -10,7 +10,7 @@ trait HasATenantsOption { protected function getOptions() { - return \array_merge([ + return array_merge([ ['tenants', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL, '', null], ], parent::getOptions()); } diff --git a/src/UUIDGenerator.php b/src/UniqueIDGenerators/UUIDGenerator.php similarity index 68% rename from src/UUIDGenerator.php rename to src/UniqueIDGenerators/UUIDGenerator.php index 7497c78a..7a90f06c 100644 --- a/src/UUIDGenerator.php +++ b/src/UniqueIDGenerators/UUIDGenerator.php @@ -2,14 +2,15 @@ declare(strict_types=1); -namespace Stancl\Tenancy; +namespace Stancl\Tenancy\UniqueIDGenerators; +use Ramsey\Uuid\Uuid; use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator; class UUIDGenerator implements UniqueIdentifierGenerator { public static function generate(array $domains, array $data = []): string { - return (string) \Webpatser\Uuid\Uuid::generate(1, $domains[0] ?? ''); + return Uuid::uuid4()->toString(); } } diff --git a/src/helpers.php b/src/helpers.php index dd492855..56d4cf08 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -20,7 +20,7 @@ if (! \function_exists('tenant')) { function tenant($key = null) { if (! is_null($key)) { - return app(Tenant::class)->get($key); + return optional(app(Tenant::class))->get($key) ?? null; } return app(Tenant::class); @@ -30,6 +30,20 @@ if (! \function_exists('tenant')) { if (! \function_exists('tenant_asset')) { function tenant_asset($asset) { - return route('stancl.tenancy.asset', ['asset' => $asset]); + return route('stancl.tenancy.asset', ['path' => $asset]); + } +} + +if (! \function_exists('global_asset')) { + function global_asset($asset) + { + return app('globalUrl')->asset($asset); + } +} + +if (! \function_exists('global_cache')) { + function global_cache() + { + return app('globalCache'); } } diff --git a/test b/test index 780da956..3f8244b3 100755 --- a/test +++ b/test @@ -1,10 +1,7 @@ #!/bin/bash set -e -# for development -docker-compose up -d 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 "$@" -docker-compose exec test vendor/bin/phpcov merge --clover clover.xml coverage/ diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index 49055b16..4cf8a92c 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -117,190 +117,55 @@ class CommandsTest extends TestCase ->expectsOutput('xyz'); } - // todo2 check that multiple tenants can be migrated at once using all database engines - /** @test */ public function install_command_works() { - if (! \is_dir($dir = app_path('Http'))) { - \mkdir($dir, 0777, true); + if (! is_dir($dir = app_path('Http'))) { + mkdir($dir, 0777, true); } - if (! \is_dir($dir = base_path('routes'))) { - \mkdir($dir, 0777, true); + if (! is_dir($dir = base_path('routes'))) { + mkdir($dir, 0777, true); } - // todo2 move this to a file - \file_put_contents(app_path('Http/Kernel.php'), " [ - \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', - 'bindings', - ], - ]; - - /** - * 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, - 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, - 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, - 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, - ]; - - /** - * The priority-sorted list of middleware. - * - * This forces non-global middleware to always be in the given order. - * - * @var array - */ - protected \$middlewarePriority = [ - \Illuminate\Session\Middleware\StartSession::class, - \Illuminate\View\Middleware\ShareErrorsFromSession::class, - \App\Http\Middleware\Authenticate::class, - \Illuminate\Session\Middleware\AuthenticateSession::class, - \Illuminate\Routing\Middleware\SubstituteBindings::class, - \Illuminate\Auth\Middleware\Authorize::class, - ]; -} -"); + file_put_contents(app_path('Http/Kernel.php'), file_get_contents(__DIR__ . '/Etc/defaultHttpKernel.stub')); $this->artisan('tenancy:install') - ->expectsQuestion('Do you want to publish the default database migration?', 'yes'); + ->expectsQuestion('Do you want to publish the default database migrations?', 'yes'); $this->assertFileExists(base_path('routes/tenant.php')); $this->assertFileExists(base_path('config/tenancy.php')); - $this->assertFileExists(database_path('migrations/2019_08_08_000000_create_tenants_table.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("assertSame(file_get_contents(__DIR__ . '/Etc/modifiedHttpKernel.stub'), file_get_contents(app_path('Http/Kernel.php'))); + } -namespace App\Http; + /** @test */ + public function migrate_fresh_command_works() + { + $this->assertFalse(Schema::hasTable('users')); + Artisan::call('tenants:migrate-fresh'); + $this->assertFalse(Schema::hasTable('users')); + tenancy()->init('test.localhost'); + $this->assertTrue(Schema::hasTable('users')); -use Illuminate\Foundation\Http\Kernel as HttpKernel; + $this->assertFalse(DB::table('users')->exists()); + DB::table('users')->insert(['name' => 'xxx', 'password' => bcrypt('password'), 'email' => 'foo@bar.xxx']); + $this->assertTrue(DB::table('users')->exists()); -class Kernel extends HttpKernel -{ - /** - * The application's global HTTP middleware stack. - * - * These middleware are run during every request to your application. - * - * @var array - */ - protected \$middleware = [ - \App\Http\Middleware\TrustProxies::class, - \App\Http\Middleware\CheckForMaintenanceMode::class, - \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, - \App\Http\Middleware\TrimStrings::class, - \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, - ]; + // test that db is wiped + Artisan::call('tenants:migrate-fresh'); + $this->assertFalse(DB::table('users')->exists()); + } - /** - * The application's route middleware groups. - * - * @var array - */ - protected \$middlewareGroups = [ - 'web' => [ - \Stancl\Tenancy\Middleware\PreventAccessFromTenantDomains::class, - \App\Http\Middleware\EncryptCookies::class, - \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, - \Illuminate\Session\Middleware\StartSession::class, - // \Illuminate\Session\Middleware\AuthenticateSession::class, - \Illuminate\View\Middleware\ShareErrorsFromSession::class, - \App\Http\Middleware\VerifyCsrfToken::class, - \Illuminate\Routing\Middleware\SubstituteBindings::class, - ], + /** @test */ + public function create_command_works() + { + Artisan::call('tenants:create -d aaa.localhost -d bbb.localhost plan=free email=foo@test.local'); + $tenant = tenancy()->all()[1]; // a tenant is autocreated prior to this + $data = $tenant->data; + unset($data['id']); - 'api' => [ - 'throttle:60,1', - 'bindings', - ], - ]; - - /** - * 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, - 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, - 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, - 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, - ]; - - /** - * The priority-sorted list of middleware. - * - * This forces non-global middleware to always be in the given order. - * - * @var array - */ - protected \$middlewarePriority = [ - \Stancl\Tenancy\Middleware\PreventAccessFromTenantDomains::class, - \Stancl\Tenancy\Middleware\InitializeTenancy::class, - \Illuminate\Session\Middleware\StartSession::class, - \Illuminate\View\Middleware\ShareErrorsFromSession::class, - \App\Http\Middleware\Authenticate::class, - \Illuminate\Session\Middleware\AuthenticateSession::class, - \Illuminate\Routing\Middleware\SubstituteBindings::class, - \Illuminate\Auth\Middleware\Authorize::class, - ]; -} -", \file_get_contents(app_path('Http/Kernel.php'))); + $this->assertSame(['plan' => 'free', 'email' => 'foo@test.local'], $data); + $this->assertSame(['aaa.localhost', 'bbb.localhost'], $tenant->domains); } } diff --git a/tests/DatabaseManagerTest.php b/tests/DatabaseManagerTest.php index 1cf33ec4..cbd976e8 100644 --- a/tests/DatabaseManagerTest.php +++ b/tests/DatabaseManagerTest.php @@ -25,10 +25,24 @@ class DatabaseManagerTest extends TestCase /** @test */ public function db_name_is_prefixed_with_db_path_when_sqlite_is_used() { - // make `tenant` not sqlite so that it has to detect sqlite from fooconn - config(['database.connections.tenant.driver' => 'mysql']); + config(['database.connections.fooconn.driver' => 'sqlite']); app(DatabaseManager::class)->createTenantConnection('foodb', 'fooconn'); $this->assertSame(config('database.connections.fooconn.database'), database_path('foodb')); } + + /** @test */ + public function the_default_db_is_used_when_based_on_is_null() + { + $this->assertSame('sqlite', config('database.default')); + config([ + 'database.connections.sqlite.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')); + } } diff --git a/tests/Etc/defaultHttpKernel.stub b/tests/Etc/defaultHttpKernel.stub new file mode 100644 index 00000000..0c83951c --- /dev/null +++ b/tests/Etc/defaultHttpKernel.stub @@ -0,0 +1,80 @@ + [ + \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', + 'bindings', + ], + ]; + + /** + * 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, + 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, + 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, + 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, + ]; + + /** + * The priority-sorted list of middleware. + * + * This forces non-global middleware to always be in the given order. + * + * @var array + */ + protected $middlewarePriority = [ + \Illuminate\Session\Middleware\StartSession::class, + \Illuminate\View\Middleware\ShareErrorsFromSession::class, + \App\Http\Middleware\Authenticate::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/Etc/modifiedHttpKernel.stub b/tests/Etc/modifiedHttpKernel.stub new file mode 100644 index 00000000..86cf535c --- /dev/null +++ b/tests/Etc/modifiedHttpKernel.stub @@ -0,0 +1,83 @@ + [ + \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' => [ + 'throttle:60,1', + 'bindings', + ], + ]; + + /** + * 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, + 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, + 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, + 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, + ]; + + /** + * The priority-sorted list of middleware. + * + * This forces non-global middleware to always be in the given order. + * + * @var array + */ + protected $middlewarePriority = [ + \Stancl\Tenancy\Middleware\PreventAccessFromTenantDomains::class, + \Stancl\Tenancy\Middleware\InitializeTenancy::class, + \Illuminate\Session\Middleware\StartSession::class, + \Illuminate\View\Middleware\ShareErrorsFromSession::class, + \App\Http\Middleware\Authenticate::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/FacadeTest.php b/tests/FacadeTest.php index fb7f1200..8422a9bb 100644 --- a/tests/FacadeTest.php +++ b/tests/FacadeTest.php @@ -4,12 +4,19 @@ declare(strict_types=1); namespace Stancl\Tenancy\Tests; +use Tenancy; use Tenant; class FacadeTest extends TestCase { /** @test */ - public function tenant_manager_can_be_accessed_using_the_Tenant_facade() + public function tenant_manager_can_be_accessed_using_the_Tenancy_facade() + { + $this->assertSame(tenancy()->getTenant(), Tenancy::getTenant()); + } + + /** @test */ + public function tenant_storage_can_be_accessed_using_the_Tenant_facade() { tenant()->put('foo', 'bar'); Tenant::put('abc', 'xyz'); @@ -17,4 +24,10 @@ class FacadeTest extends TestCase $this->assertSame('bar', Tenant::get('foo')); $this->assertSame('xyz', Tenant::get('abc')); } + + /** @test */ + public function tenant_can_be_created_using_the_Tenant_facade() + { + $this->assertSame('bar', Tenant::create(['foo.localhost'], ['foo' => 'bar'])->foo); + } } diff --git a/tests/QueueTest.php b/tests/QueueTest.php index 1af5abc9..7d24fdc3 100644 --- a/tests/QueueTest.php +++ b/tests/QueueTest.php @@ -17,7 +17,7 @@ class QueueTest extends TestCase /** @test */ public function queues_use_non_tenant_db_connection() { - // todo2 finish this test. requires using the db driver + // requires using the db driver $this->markTestIncomplete(); } @@ -56,6 +56,6 @@ class TestJob implements ShouldQueue */ public function handle() { - logger(\json_encode(\DB::table('users')->get())); + logger(json_encode(\DB::table('users')->get())); } } diff --git a/tests/ReidentificationTest.php b/tests/ReidentificationTest.php index 74c21492..6d54edae 100644 --- a/tests/ReidentificationTest.php +++ b/tests/ReidentificationTest.php @@ -31,7 +31,7 @@ class ReidentificationTest extends TestCase $current_path_prefix = \Storage::disk($disk)->getAdapter()->getPathPrefix(); if ($override = config("tenancy.filesystem.root_override.{$disk}")) { - $correct_path_prefix = \str_replace('%storage_path%', storage_path(), $override); + $correct_path_prefix = str_replace('%storage_path%', storage_path(), $override); } else { if ($base = $originals[$disk]) { $correct_path_prefix = $base . "/$suffix/"; diff --git a/tests/BootstrapsTenancyTest.php b/tests/TenancyBootstrappersTest.php similarity index 79% rename from tests/BootstrapsTenancyTest.php rename to tests/TenancyBootstrappersTest.php index 310784cf..fe463803 100644 --- a/tests/BootstrapsTenancyTest.php +++ b/tests/TenancyBootstrappersTest.php @@ -6,8 +6,7 @@ namespace Stancl\Tenancy\Tests; use Illuminate\Support\Facades\Redis; -// todo2 rename -class BootstrapsTenancyTest extends TestCase +class TenancyBootstrappersTest extends TestCase { public $autoInitTenancy = false; @@ -56,7 +55,7 @@ class BootstrapsTenancyTest extends TestCase $current_path_prefix = \Storage::disk($disk)->getAdapter()->getPathPrefix(); if ($override = config("tenancy.filesystem.root_override.{$disk}")) { - $correct_path_prefix = \str_replace('%storage_path%', storage_path(), $override); + $correct_path_prefix = str_replace('%storage_path%', storage_path(), $override); } else { if ($base = $old_storage_facade_roots[$disk]) { $correct_path_prefix = $base . "/$suffix/"; @@ -78,4 +77,20 @@ class BootstrapsTenancyTest extends TestCase $expected = [config('tenancy.cache.tag_base') . tenant('id'), 'foo', 'bar']; $this->assertEquals($expected, cache()->tags(['foo', 'bar'])->getTags()->getNames()); } + + /** @test */ + public function the_default_db_connection_is_used_when_the_config_value_is_null() + { + $original = config('database.default'); + tenancy()->create(['foo.localhost']); + tenancy()->init('foo.localhost'); + + $this->assertSame(null, config("database.connections.$original.foo")); + + config(["database.connections.$original.foo" => 'bar']); + tenancy()->create(['bar.localhost']); + tenancy()->init('bar.localhost'); + + $this->assertSame('bar', config("database.connections.$original.foo")); + } } diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index 967877e4..248e7703 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -4,25 +4,68 @@ declare(strict_types=1); namespace Stancl\Tenancy\Tests; +use Stancl\Tenancy\Tenant; + class TenantAssetTest extends TestCase { + public $autoCreateTenant = false; + public $autoInitTenancy = false; + /** @test */ public function asset_can_be_accessed_using_the_url_returned_by_the_tenant_asset_helper() { + Tenant::create('localhost'); + tenancy()->init('localhost'); + $filename = 'testfile' . $this->randomString(10); \Storage::disk('public')->put($filename, 'bar'); $path = storage_path("app/public/$filename"); // response()->file() returns BinaryFileResponse whose content is // inaccessible via getContent, so ->assertSee() can't be used - // $this->get(tenant_asset($filename))->assertSuccessful(); // TODO2 COMMENTED ASSERTIONS - // $this->assertFileExists($path); // TODO2 COMMENTED ASSERTIONS + $this->assertFileExists($path); + $response = $this->get(tenant_asset($filename)); - $f = \fopen($path, 'r'); - $content = \fread($f, \filesize($path)); - \fclose($f); + $response->assertSuccessful(); - // $this->assertSame('bar', $content); // TODO2 COMMENTED ASSERTIONS - $this->assertTrue(true); // TODO2 COMMENTED ASSERTIONS + $f = fopen($path, 'r'); + $content = fread($f, filesize($path)); + fclose($f); + + $this->assertSame('bar', $content); + } + + /** @test */ + public function asset_helper_returns_a_link_to_TenantAssetController_when_asset_url_is_null() + { + config(['app.asset_url' => null]); + + Tenant::create('foo.localhost'); + tenancy()->init('foo.localhost'); + + $this->assertSame(route('stancl.tenancy.asset', ['path' => 'foo']), asset('foo')); + } + + /** @test */ + public function asset_helper_returns_a_link_to_an_external_url_when_asset_url_is_not_null() + { + config(['app.asset_url' => 'https://an-s3-bucket']); + + $tenant = Tenant::create(['foo.localhost']); + tenancy()->init('foo.localhost'); + + $this->assertSame("https://an-s3-bucket/tenant{$tenant->id}/foo", asset('foo')); + } + + /** @test */ + public function global_asset_helper_returns_the_same_url_regardless_of_tenancy_initialization() + { + $original = global_asset('foobar'); + $this->assertSame(asset('foobar'), global_asset('foobar')); + + Tenant::create(['foo.localhost']); + tenancy()->init('foo.localhost'); + + $this->assertSame($original, global_asset('foobar')); } } diff --git a/tests/TenantClassTest.php b/tests/TenantClassTest.php new file mode 100644 index 00000000..4a3775f2 --- /dev/null +++ b/tests/TenantClassTest.php @@ -0,0 +1,108 @@ +makePartial(); + // $this->instance(StorageDriver::class, $spy); + + $tenant = Tenant::create(['foo.localhost'], ['foo' => 'bar']); + $this->assertSame('bar', $tenant->data['foo']); + + $tenant->put('abc', 'xyz'); + $this->assertSame('xyz', $tenant->data['abc']); + + $tenant->put(['aaa' => 'bbb', 'ccc' => 'ddd']); + $this->assertSame('bbb', $tenant->data['aaa']); + $this->assertSame('ddd', $tenant->data['ccc']); + + // $spy->shouldNotHaveReceived('get'); + + $this->assertSame(null, $tenant->dfuighdfuigfhdui); + // $spy->shouldHaveReceived('get')->once(); + + Mockery::close(); + } + + /** @test */ + public function tenant_can_have_multiple_domains() + { + $tenant = Tenant::create(['foo.localhost', 'bar.localhost']); + $this->assertSame(['foo.localhost', 'bar.localhost'], $tenant->domains); + $this->assertSame($tenant->id, Tenancy::findByDomain('foo.localhost')->id); + $this->assertSame($tenant->id, Tenancy::findByDomain('bar.localhost')->id); + } + + /** @test */ + public function updating_a_tenant_works() + { + $id = 'abc' . $this->randomString(); + $tenant = Tenant::create(['foo.localhost'], ['id' => $id]); + $tenant->foo = 'bar'; + $tenant->save(); + $this->assertEquals(['id' => $id, 'foo' => 'bar'], $tenant->data); + $this->assertEquals(['id' => $id, 'foo' => 'bar'], tenancy()->find($id)->data); + + $tenant->addDomains('abc.localhost'); + $tenant->save(); + $this->assertEqualsCanonicalizing(['foo.localhost', 'abc.localhost'], $tenant->domains); + $this->assertEqualsCanonicalizing(['foo.localhost', 'abc.localhost'], tenancy()->find($id)->domains); + + $tenant->removeDomains(['foo.localhost']); + $tenant->save(); + $this->assertEqualsCanonicalizing(['abc.localhost'], $tenant->domains); + $this->assertEqualsCanonicalizing(['abc.localhost'], tenancy()->find($id)->domains); + + $tenant->withDomains(['completely.localhost', 'different.localhost', 'domains.localhost']); + $tenant->save(); + $this->assertEqualsCanonicalizing(['completely.localhost', 'different.localhost', 'domains.localhost'], $tenant->domains); + $this->assertEqualsCanonicalizing(['completely.localhost', 'different.localhost', 'domains.localhost'], tenancy()->find($id)->domains); + } + + /** @test */ + public function with_methods_work() + { + $id = 'foo' . $this->randomString(); + $tenant = Tenant::new()->withDomains(['foo.localhost'])->with('id', $id); + $this->assertSame($id, $tenant->id); + + $id2 = 'bar' . $this->randomString(); + $tenant2 = Tenant::new()->withDomains(['bar.localhost'])->withId($id2)->withFooBar('xyz'); + $this->assertSame($id2, $tenant2->data['id']); + $this->assertSame('xyz', $tenant2->foo_bar); + $this->assertArrayHasKey('foo_bar', $tenant2->data); + } + + /** @test */ + public function an_exception_is_thrown_when_an_unknown_method_is_called() + { + $tenant = Tenant::new(); + $this->expectException(\BadMethodCallException::class); + $tenant->sdjigndfgnjdfgj(); + } + + /** @test */ + public function tenant_data_can_be_set_during_creation() + { + Tenant::new()->withData(['foo' => 'bar'])->save(); + + $data = tenancy()->all()->first()->data; + unset($data['id']); + + $this->assertSame(['foo' => 'bar'], $data); + } +} diff --git a/tests/TenantConfigTest.php b/tests/TenantConfigTest.php new file mode 100644 index 00000000..68c2cf4a --- /dev/null +++ b/tests/TenantConfigTest.php @@ -0,0 +1,38 @@ +assertSame(null, config('services.paypal')); + config([ + 'tenancy.storage_to_config_map' => [ + 'paypal_api_public' => 'services.paypal.public', + 'paypal_api_private' => 'services.paypal.private', + ], + 'tenancy.features' => ['Stancl\Tenancy\Features\TenantConfig'], + ]); + + tenancy()->create('foo.localhost', [ + 'paypal_api_public' => 'foo', + 'paypal_api_private' => 'bar', + ]); + + tenancy()->init('foo.localhost'); + $this->assertSame(['public' => 'foo', 'private' => 'bar'], config('services.paypal')); + + tenancy()->end(); + $this->assertSame([ + 'public' => null, + 'private' => null, + ], config('services.paypal')); + } +} diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index a40305a5..fc3c34f4 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -14,6 +14,8 @@ use Stancl\Tenancy\TenantDatabaseManagers\SQLiteDatabaseManager; class TenantDatabaseManagerTest extends TestCase { + public $autoInitTenancy = false; + /** * @test * @dataProvider database_manager_provider @@ -24,8 +26,6 @@ class TenantDatabaseManagerTest extends TestCase $this->markTestSkipped('As to not bloat your computer with test databases, this test is not run by default.'); } - config()->set('database.default', $driver); // todo the DB creator would not work for MySQL when sqlite is used for the central DB - $name = 'db' . $this->randomString(); $this->assertFalse(app($databaseManager)->databaseExists($name)); app($databaseManager)->createDatabase($name); @@ -34,6 +34,20 @@ class TenantDatabaseManagerTest extends TestCase $this->assertFalse(app($databaseManager)->databaseExists($name)); } + /** @test */ + public function dbs_can_be_created_when_another_driver_is_used_for_the_central_db() + { + $this->assertSame('sqlite', config('database.default')); + + $database = 'db' . $this->randomString(); + app(MySQLDatabaseManager::class)->createDatabase($database); + $this->assertTrue(app(MySQLDatabaseManager::class)->databaseExists($database)); + + $database = 'db2' . $this->randomString(); + app(PostgreSQLDatabaseManager::class)->createDatabase($database); + $this->assertTrue(app(PostgreSQLDatabaseManager::class)->databaseExists($database)); + } + /** * @test * @dataProvider database_manager_provider diff --git a/tests/TenantManagerTest.php b/tests/TenantManagerTest.php index a27d037c..7f8aae0d 100644 --- a/tests/TenantManagerTest.php +++ b/tests/TenantManagerTest.php @@ -5,8 +5,13 @@ declare(strict_types=1); namespace Stancl\Tenancy\Tests; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; +use Stancl\Tenancy\Exceptions\DomainsOccupiedByOtherTenantException; +use Stancl\Tenancy\Exceptions\TenantWithThisIdAlreadyExistsException; +use Stancl\Tenancy\Jobs\QueuedTenantDatabaseMigrator; use Stancl\Tenancy\Tenant; +use Stancl\Tenancy\TenantManager; class TenantManagerTest extends TestCase { @@ -147,7 +152,7 @@ class TenantManagerTest extends TestCase { $tenant1 = Tenant::new()->withDomains(['foo.localhost'])->save(); $tenant2 = Tenant::new()->withDomains(['bar.localhost'])->save(); - $this->assertEquals([$tenant1, $tenant2], tenancy()->all()->toArray()); + $this->assertEqualsCanonicalizing([$tenant1, $tenant2], tenancy()->all()->toArray()); } /** @test */ @@ -187,4 +192,78 @@ class TenantManagerTest extends TestCase $this->expectException(\Stancl\Tenancy\Exceptions\TenantStorageException::class); $tenant2->put('id', 'foo'); } + + /** @test */ + public function all_returns_a_collection_of_tenant_objects() + { + Tenant::create('foo.localhost'); + $this->assertSame('Tenant', class_basename(tenancy()->all()[0])); + } + + /** @test */ + public function Tenant_is_bound_correctly_to_the_service_container() + { + $this->assertSame(null, app(Tenant::class)); + $tenant = Tenant::create(['foo.localhost']); + app(TenantManager::class)->initializeTenancy($tenant); + $this->assertSame($tenant->id, app(Tenant::class)->id); + $this->assertSame(app(Tenant::class), app(TenantManager::class)->getTenant()); + app(TenantManager::class)->endTenancy(); + $this->assertSame(app(Tenant::class), app(TenantManager::class)->getTenant()); + } + + /** @test */ + public function id_can_be_supplied_during_creation() + { + $id = 'abc' . $this->randomString(); + $this->assertSame($id, Tenant::create(['foo.localhost'], ['id' => $id])->id); + $this->assertTrue(tenancy()->all()->contains(function ($tenant) use ($id) { + return $tenant->id === $id; + })); + } + + /** @test */ + public function automatic_migrations_work() + { + $tenant = Tenant::create(['foo.localhost']); + tenancy()->initialize($tenant); + $this->assertFalse(\Schema::hasTable('users')); + + config(['tenancy.migrate_after_creation' => true]); + $tenant2 = Tenant::create(['bar.localhost']); + tenancy()->initialize($tenant2); + $this->assertTrue(\Schema::hasTable('users')); + } + + /** @test */ + public function ensureTenantCanBeCreated_works() + { + $id = 'foo' . $this->randomString(); + Tenant::create(['foo.localhost'], ['id' => $id]); + $this->expectException(DomainsOccupiedByOtherTenantException::class); + Tenant::create(['foo.localhost']); + + $this->expectException(TenantWithThisIdAlreadyExistsException::class); + Tenant::create(['bar.localhost'], ['id' => $id]); + } + + /** @test */ + public function automigration_can_be_queued() + { + Queue::fake(); + + config([ + 'tenancy.migrate_after_creation' => true, + 'tenancy.queue_automatic_migration' => true, + ]); + + $tenant = Tenant::new()->save(); + tenancy()->initialize($tenant); + + Queue::assertPushed(QueuedTenantDatabaseMigrator::class); + + $this->assertFalse(\Schema::hasTable('users')); + (new QueuedTenantDatabaseMigrator($tenant))->handle(); + $this->assertTrue(\Schema::hasTable('users')); + } } diff --git a/tests/TenantRedirectMacroTest.php b/tests/TenantRedirectMacroTest.php index 34183093..970579ec 100644 --- a/tests/TenantRedirectMacroTest.php +++ b/tests/TenantRedirectMacroTest.php @@ -5,12 +5,20 @@ declare(strict_types=1); namespace Stancl\Tenancy\Tests; use Route; +use Stancl\Tenancy\Tenant; class TenantRedirectMacroTest extends TestCase { + public $autoCreateTenant = false; + public $autoInitTenancy = false; + /** @test */ public function tenant_redirect_macro_replaces_only_the_hostname() { + config([ + 'tenancy.features' => ['Stancl\Tenancy\Features\TenantRedirect'], + ]); + Route::get('/foobar', function () { return 'Foo'; })->name('home'); @@ -19,6 +27,9 @@ class TenantRedirectMacroTest extends TestCase return redirect()->route('home')->tenant('abcd'); }); + Tenant::create('foo.localhost'); + tenancy()->init('foo.localhost'); + $this->get('/redirect') ->assertRedirect('http://abcd/foobar'); } diff --git a/tests/TenantStorageTest.php b/tests/TenantStorageTest.php index 91c97dd3..88bd5823 100644 --- a/tests/TenantStorageTest.php +++ b/tests/TenantStorageTest.php @@ -16,9 +16,9 @@ class TenantStorageTest extends TestCase { $abc = Tenant::new()->withDomains(['abc.localhost'])->save(); $exists = function () use ($abc) { - return tenancy()->all()->reduce(function ($result, $tenant) use ($abc) { - return $result ?: $tenant->id === $abc->id; - }, false); + return tenancy()->all()->contains(function ($tenant) use ($abc) { + return $tenant->id === $abc->id; + }); }; $this->assertTrue($exists()); @@ -95,26 +95,26 @@ class TenantStorageTest extends TestCase public function data_is_stored_with_correct_data_types() { tenant()->put('someBool', false); - $this->assertSame('boolean', \gettype(tenant()->get('someBool'))); - $this->assertSame('boolean', \gettype(tenant()->get(['someBool'])['someBool'])); + $this->assertSame('boolean', gettype(tenant()->get('someBool'))); + $this->assertSame('boolean', gettype(tenant()->get(['someBool'])['someBool'])); tenant()->put('someInt', 5); - $this->assertSame('integer', \gettype(tenant()->get('someInt'))); - $this->assertSame('integer', \gettype(tenant()->get(['someInt'])['someInt'])); + $this->assertSame('integer', gettype(tenant()->get('someInt'))); + $this->assertSame('integer', gettype(tenant()->get(['someInt'])['someInt'])); tenant()->put('someDouble', 11.40); - $this->assertSame('double', \gettype(tenant()->get('someDouble'))); - $this->assertSame('double', \gettype(tenant()->get(['someDouble'])['someDouble'])); + $this->assertSame('double', gettype(tenant()->get('someDouble'))); + $this->assertSame('double', gettype(tenant()->get(['someDouble'])['someDouble'])); tenant()->put('string', 'foo'); - $this->assertSame('string', \gettype(tenant()->get('string'))); - $this->assertSame('string', \gettype(tenant()->get(['string'])['string'])); + $this->assertSame('string', gettype(tenant()->get('string'))); + $this->assertSame('string', gettype(tenant()->get(['string'])['string'])); } /** @test */ public function tenant_model_uses_correct_connection() { - config(['tenancy.storage.db.connection' => 'foo']); + config(['tenancy.storage_drivers.db.connection' => 'foo']); $this->assertSame('foo', (new TenantModel)->getConnectionName()); } @@ -149,7 +149,7 @@ class TenantStorageTest extends TestCase ]); config(['database.default' => 'sqlite']); // fix issue caused by loadMigrationsFrom - config(['tenancy.storage.db.custom_columns' => [ + config(['tenancy.storage_drivers.db.custom_columns' => [ 'foo', ]]); @@ -159,6 +159,6 @@ class TenantStorageTest extends TestCase tenant()->put(['foo' => 'bar', 'abc' => 'xyz']); $this->assertSame(['bar', 'xyz'], tenant()->get(['foo', 'abc'])); - $this->assertSame('bar', \DB::connection('central')->table('tenants')->where('id', tenant('id'))->first()->foo); + $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 500d0461..19b0a83f 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -5,8 +5,6 @@ declare(strict_types=1); namespace Stancl\Tenancy\Tests; use Illuminate\Support\Facades\Redis; -use Stancl\Tenancy\StorageDrivers\Database\DatabaseStorageDriver; -use Stancl\Tenancy\StorageDrivers\RedisStorageDriver; use Stancl\Tenancy\Tenant; abstract class TestCase extends \Orchestra\Testbench\TestCase @@ -27,7 +25,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase Redis::connection('cache')->flushdb(); $this->loadMigrationsFrom([ - '--path' => \realpath(__DIR__ . '/../assets/migrations'), + '--path' => realpath(__DIR__ . '/../assets/migrations'), '--database' => 'central', ]); config(['database.default' => 'sqlite']); // fix issue caused by loadMigrationsFrom @@ -59,11 +57,11 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase */ protected function getEnvironmentSetUp($app) { - if (\file_exists(__DIR__ . '/../.env')) { + if (file_exists(__DIR__ . '/../.env')) { \Dotenv\Dotenv::create(__DIR__ . '/..')->load(); } - \fclose(\fopen(database_path('central.sqlite'), 'w')); + fclose(fopen(database_path('central.sqlite'), 'w')); $app['config']->set([ 'database.redis.cache.host' => env('TENANCY_TEST_REDIS_HOST', '127.0.0.1'), @@ -99,17 +97,13 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase 'database.redis.client' => env('TENANCY_TEST_REDIS_CLIENT', 'phpredis'), 'tenancy.redis.prefixed_connections' => ['default'], 'tenancy.migrations_directory' => database_path('../migrations'), + 'tenancy.storage_drivers.db.connection' => 'central', + 'tenancy.bootstrappers.redis' => \Stancl\Tenancy\TenancyBootstrappers\RedisTenancyBootstrapper::class, ]); - if (env('TENANCY_TEST_STORAGE_DRIVER', 'redis') === 'redis') { - $app['config']->set([ - 'tenancy.storage_driver' => RedisStorageDriver::class, - ]); - } elseif (env('TENANCY_TEST_STORAGE_DRIVER', 'redis') === 'db') { - $app['config']->set([ - 'tenancy.storage_driver' => DatabaseStorageDriver::class, - ]); - } + $app->singleton(\Stancl\Tenancy\TenancyBootstrappers\RedisTenancyBootstrapper::class); + + $app['config']->set(['tenancy.storage_driver' => env('TENANCY_TEST_STORAGE_DRIVER', 'redis')]); } protected function getPackageProviders($app) @@ -152,7 +146,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase public function randomString(int $length = 10) { - return \substr(\str_shuffle(\str_repeat($x = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', (int) (\ceil($length / \strlen($x))))), 1, $length); + return substr(str_shuffle(str_repeat($x = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', (int) (ceil($length / strlen($x))))), 1, $length); } public function isContainerized() @@ -162,6 +156,6 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase public function assertArrayIsSubset($subset, $array, string $message = ''): void { - parent::assertTrue(\array_intersect($subset, $array) == $subset, $message); + parent::assertTrue(array_intersect($subset, $array) == $subset, $message); } }