diff --git a/.env.example b/.env.example deleted file mode 100644 index 775d99e8..00000000 --- a/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -DB_DATABASE=travis_tenancy -DB_USERNAME=foo -DB_PASSWORD=bar \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index e59c8cdf..ff31db18 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,30 +1,28 @@ env: - - LARAVEL_VERSION="5.7.*" TESTBENCH_VERSION="~3.7" - - LARAVEL_VERSION="5.8.*" TESTBENCH_VERSION="~3.8" + - LARAVEL_VERSION="5.7.*" TESTBENCH_VERSION="~3.7" REDIS_DRIVER=phpredis + - LARAVEL_VERSION="5.8.*" TESTBENCH_VERSION="~3.8" REDIS_DRIVER=phpredis language: php php: - '7.2' services: - - mysql - - postgresql - - redis-server + - docker before_install: - - echo "extension = redis.so" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini + - docker-compose up -d install: - - composer require "laravel/framework:$LARAVEL_VERSION" "orchestra/testbench:$TESTBENCH_VERSION" - - travis_retry composer install --no-interaction + - travis_retry docker-compose exec test composer require --no-interaction "laravel/framework:$LARAVEL_VERSION" "orchestra/testbench:$TESTBENCH_VERSION" before_script: - - mysql -e 'CREATE DATABASE travis_tenancy;' - - psql -c 'create database travis_tenancy;' -U postgres - - export DB_USERNAME=root DB_PASSWORD="" DB_DATABASE=travis_tenancy CODECOV_TOKEN="24382d15-84e7-4a55-bea4-c4df96a24a9b" + - 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: vendor/bin/phpunit -v --coverage-clover=coverage.xml +script: docker-compose exec test vendor/bin/phpunit -v --coverage-clover=coverage.xml + +after_script: + - docker-compose down after_success: - bash <(curl -s https://codecov.io/bash) diff --git a/CHANGELOG-1.x.md b/CHANGELOG-1.x.md index 77963d92..d7bae2c0 100644 --- a/CHANGELOG-1.x.md +++ b/CHANGELOG-1.x.md @@ -1,5 +1,11 @@ # Release Notes for 1.x +## [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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..a6727e03 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM ubuntu:18.04 + +LABEL maintainer="Samuel Ć tancl" + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y curl zip unzip git sqlite3 \ + php7.2-fpm php7.2-cli \ + php7.2-pgsql php7.2-sqlite3 php7.2-gd \ + php7.2-curl php7.2-memcached \ + php7.2-imap php7.2-mysql php7.2-mbstring \ + php7.2-xml php7.2-zip php7.2-bcmath php7.2-soap \ + php7.2-intl php7.2-readline php7.2-xdebug \ + php-msgpack php-igbinary \ + && php -r "readfile('http://getcomposer.org/installer');" | php -- --install-dir=/usr/bin/ --filename=composer \ + && mkdir /run/php + +RUN apt-get install php7.2-redis + +RUN apt-get -y autoremove \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +WORKDIR /var/www/html \ No newline at end of file diff --git a/README.md b/README.md index 40f2cede..24cea388 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Travis CI build](https://travis-ci.com/stancl/tenancy.svg?branch=master)](https://travis-ci.com/stancl/tenancy) [![codecov](https://codecov.io/gh/stancl/tenancy/branch/master/graph/badge.svg)](https://codecov.io/gh/stancl/tenancy) -### *A Laravel multi-database tenancy implementation that respects your code.* +### *A Laravel multi-database tenancy package that respects your code.* You won't have to change a thing in your application's code.\* @@ -17,10 +17,11 @@ You won't have to change a thing in your application's code.\* ## Installation +> If you're installing this package for the first time, **there's also a [tutorial](https://stancl.github.io/blog/how-to-make-any-laravel-app-multi-tenant-in-5-minutes/).** + ### Requirements - Laravel 5.7 or 5.8 -- phpredis (predis is not supported) ### Installing the package @@ -97,6 +98,12 @@ config('tenancy.redis.prefix_base') . $uuid These changes will only apply for connections listed in `prefixed_connections`. +You can enable Redis tenancy by changing the `tenancy.redis.tenancy` config to `true`. + +**Note: If you want Redis to be multi-tenant, you *must* use phpredis. Predis does not support prefixes.** + +If you're using Laravel 5.7, predis is not supported even if Redis tenancy is disabled. + #### `cache` Cache keys will be tagged with a tag: @@ -326,7 +333,7 @@ The entire application will use a new database connection. The connection will b Connections listed in the `tenancy.redis.prefixed_connections` config array use a prefix based on the `tenancy.redis.prefix_base` and the tenant UUID. -**Note: You *must* use phpredis. Predis doesn't support prefixes.** +**Note: You *must* use phpredis if you want mutli-tenant Redis. Predis doesn't support prefixes.** ## Cache diff --git a/composer.json b/composer.json index 3dedc135..e6fc6818 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "stancl/tenancy", - "description": "A Laravel multi-database tenancy implementation that respects your code.", + "description": "A Laravel multi-database tenancy package that respects your code.", "keywords": ["laravel", "multi-tenancy", "multi-database", "tenancy"], "license": "MIT", "authors": [ @@ -10,13 +10,14 @@ } ], "require": { - "illuminate/support": "5.7.*||5.8.*", - "webpatser/laravel-uuid": "^3.0" + "illuminate/support": "5.8.*||5.7.*", + "webpatser/laravel-uuid": "^3.0", + "predis/predis": "^1.1" }, "require-dev": { "vlucas/phpdotenv": "^2.2||^3.3", "psy/psysh": "@stable", - "laravel/framework": "5.7.*||5.8.*", + "laravel/framework": "5.8.*||5.7.*", "orchestra/testbench": "~3.7||~3.8", "league/flysystem-aws-s3-v3": "~1.0" }, diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..d5315fb9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +version: '3' +services: + test: + build: + context: . + networks: + - testnet + depends_on: + - mysql + - postgres + - redis + volumes: + - .:/var/www/html + environment: + DOCKER: 1 + DB_PASSWORD: password + DB_USERNAME: root + DB_DATABASE: main + TENANCY_TEST_REDIS_HOST: redis + TENANCY_TEST_MYSQL_HOST: mysql + TENANCY_TEST_PGSQL_HOST: postgres + stdin_open: true + tty: true + mysql: + image: mysql:5.7 + environment: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: main + MYSQL_USER: user # redundant + MYSQL_PASSWORD: password + networks: + - testnet + postgres: + image: postgres:11 + environment: + POSTGRES_PASSWORD: password + POSTGRES_USER: root # superuser name + POSTGRES_DB: main + networks: + - testnet + redis: + image: redis:alpine + networks: + - testnet +networks: + testnet: + driver: bridge \ No newline at end of file diff --git a/src/DatabaseManager.php b/src/DatabaseManager.php index 1064aa5e..a5bae6bd 100644 --- a/src/DatabaseManager.php +++ b/src/DatabaseManager.php @@ -31,10 +31,19 @@ class DatabaseManager public function disconnect() { $default_connection = $this->originalDefaultConnection; + $this->database->purge(); $this->database->reconnect($default_connection); $this->database->setDefaultConnection($default_connection); } + /** + * Create a database. + * @todo Should this handle prefixes? + * + * @param string $name + * @param string $driver + * @return bool + */ public function create(string $name, string $driver = null) { $this->createTenantConnection($name); @@ -49,10 +58,18 @@ class DatabaseManager if (config('tenancy.queue_database_creation', false)) { QueuedTenantDatabaseCreator::dispatch(app($databaseManagers[$driver]), $name, 'create'); } else { - app($databaseManagers[$driver])->createDatabase($name); + return app($databaseManagers[$driver])->createDatabase($name); } } + /** + * Delete a database. + * @todo Should this handle prefixes? + * + * @param string $name + * @param string $driver + * @return bool + */ public function delete(string $name, string $driver = null) { $this->createTenantConnection($name); @@ -67,7 +84,7 @@ class DatabaseManager if (config('tenancy.queue_database_deletion', false)) { QueuedTenantDatabaseDeleter::dispatch(app($databaseManagers[$driver]), $name, 'delete'); } else { - app($databaseManagers[$driver])->deleteDatabase($name); + return app($databaseManagers[$driver])->deleteDatabase($name); } } diff --git a/src/Exceptions/PhpRedisNotInstalledException.php b/src/Exceptions/PhpRedisNotInstalledException.php new file mode 100644 index 00000000..6c9da3e7 --- /dev/null +++ b/src/Exceptions/PhpRedisNotInstalledException.php @@ -0,0 +1,8 @@ +redis->getOption($this->redis->client()::OPT_PREFIX); - $hashes = $hashes ?: $this->redis->scan(null, $redis_prefix.'tenants:*'); - - return array_map(function ($tenant) use ($redis_prefix) { - // Left strip $redis_prefix from $tenant - if (substr($tenant, 0, strlen($redis_prefix)) == $redis_prefix) { - $tenant = substr($tenant, strlen($redis_prefix)); + if (! $hashes) { + // Apparently, the PREFIX is applied to all functions except scan(). + // Therefore, if the `tenancy` Redis connection has a prefix set + // (and PhpRedis is used), prepend the prefix to the search. + $redis_prefix = ''; + if (config('database.redis.client') === 'phpredis') { + $redis_prefix = $this->redis->getOption($this->redis->client()::OPT_PREFIX); } - + $hashes = array_map(function ($hash) use ($redis_prefix) { + // Left strip $redis_prefix from $hash + return substr($hash, strlen($redis_prefix)); + }, $this->redis->scan(null, $redis_prefix.'tenants:*')); + } + + return array_map(function ($tenant) { return $this->redis->hgetall($tenant); }, $hashes); } diff --git a/src/Traits/BootstrapsTenancy.php b/src/Traits/BootstrapsTenancy.php index d09be0ff..1121cc73 100644 --- a/src/Traits/BootstrapsTenancy.php +++ b/src/Traits/BootstrapsTenancy.php @@ -5,6 +5,7 @@ namespace Stancl\Tenancy\Traits; use Stancl\Tenancy\CacheManager; use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Storage; +use Stancl\Tenancy\Exceptions\PhpRedisNotInstalledException; trait BootstrapsTenancy { @@ -13,7 +14,9 @@ trait BootstrapsTenancy public function bootstrap() { $this->switchDatabaseConnection(); - $this->setPhpRedisPrefix($this->app['config']['tenancy.redis.prefixed_connections']); + if ($this->app['config']['tenancy.redis.tenancy']) { + $this->setPhpRedisPrefix($this->app['config']['tenancy.redis.prefixed_connections']); + } $this->tagCache(); $this->suffixFilesystemRootPaths(); } @@ -28,7 +31,11 @@ trait BootstrapsTenancy foreach ($connections as $connection) { $prefix = $this->app['config']['tenancy.redis.prefix_base'] . $this->tenant['uuid']; $client = Redis::connection($connection)->client(); - $client->setOption($client::OPT_PREFIX, $prefix); + try { + $client->setOption($client::OPT_PREFIX, $prefix); + } catch (\Throwable $t) { + throw new PhpRedisNotInstalledException(); + } } } diff --git a/src/config/tenancy.php b/src/config/tenancy.php index de7fbb32..c33f8f63 100644 --- a/src/config/tenancy.php +++ b/src/config/tenancy.php @@ -12,6 +12,7 @@ return [ 'suffix' => '', ], 'redis' => [ + 'tenancy' => false, 'prefix_base' => 'tenant', 'prefixed_connections' => [ 'default', diff --git a/test b/test new file mode 100755 index 00000000..b22f03b1 --- /dev/null +++ b/test @@ -0,0 +1,5 @@ +#!/bin/bash + +# for development +docker-compose up -d +docker-compose exec test vendor/bin/phpunit "$@" \ No newline at end of file diff --git a/tests/BootstrapsTenancyTest.php b/tests/BootstrapsTenancyTest.php index 6fdab20c..a3751a1c 100644 --- a/tests/BootstrapsTenancyTest.php +++ b/tests/BootstrapsTenancyTest.php @@ -3,6 +3,8 @@ namespace Stancl\Tenancy\Tests; use Illuminate\Support\Facades\Redis; +use Illuminate\Support\Facades\Config; +use Stancl\Tenancy\Exceptions\PhpRedisNotInstalledException; class BootstrapsTenancyTest extends TestCase { @@ -30,6 +32,36 @@ class BootstrapsTenancyTest extends TestCase } } + /** @test */ + public function predis_is_supported() + { + if (app()->version() < 'v5.8.27') { + $this->markTestSkipped(); + } + + Config::set('database.redis.client', 'predis'); + Redis::setDriver('predis'); + Config::set('tenancy.redis.tenancy', false); + + // assert no exception is thrown from initializing tenancy + $this->assertNotNull($this->initTenancy()); + } + + /** @test */ + public function predis_is_not_supported_without_disabling_redis_multitenancy() + { + if (app()->version() < 'v5.8.27') { + $this->markTestSkipped(); + } + + Config::set('database.redis.client', 'predis'); + Redis::setDriver('predis'); + Config::set('tenancy.redis.tenancy', true); + + $this->expectException(PhpRedisNotInstalledException::class); + $this->initTenancy(); + } + /** @test */ public function filesystem_is_suffixed() { diff --git a/tests/DataSeparationTest.php b/tests/DataSeparationTest.php new file mode 100644 index 00000000..14deb808 --- /dev/null +++ b/tests/DataSeparationTest.php @@ -0,0 +1,151 @@ +create('tenant1.localhost'); + $tenant2 = tenancy()->create('tenant2.localhost'); + \Artisan::call('tenants:migrate', [ + '--tenants' => [$tenant1['uuid'], $tenant2['uuid']] + ]); + + tenancy()->init('tenant1.localhost'); + User::create([ + 'name' => 'foo', + 'email' => 'foo@bar.com', + 'email_verified_at' => now(), + 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password + 'remember_token' => Str::random(10), + ]); + $this->assertSame('foo', User::first()->name); + + tenancy()->init('tenant2.localhost'); + $this->assertSame(null, User::first()); + + User::create([ + 'name' => 'xyz', + 'email' => 'xyz@bar.com', + 'email_verified_at' => now(), + 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password + 'remember_token' => Str::random(10), + ]); + + $this->assertSame('xyz', User::first()->name); + $this->assertSame('xyz@bar.com', User::first()->email); + + tenancy()->init('tenant1.localhost'); + $this->assertSame('foo', User::first()->name); + $this->assertSame('foo@bar.com', User::first()->email); + + $tenant3 = tenancy()->create('tenant3.localhost'); + \Artisan::call('tenants:migrate', [ + '--tenants' => [$tenant1['uuid'], $tenant3['uuid']] + ]); + + tenancy()->init('tenant3.localhost'); + $this->assertSame(null, User::first()); + + tenancy()->init('tenant1.localhost'); + DB::table('users')->where('id', 1)->update(['name' => 'xxx']); + $this->assertSame('xxx', User::first()->name); + } + + /** @test */ + public function redis_is_separated() + { + tenancy()->create('tenant1.localhost'); + tenancy()->create('tenant2.localhost'); + + tenancy()->init('tenant1.localhost'); + Redis::set('foo', 'bar'); + $this->assertSame('bar', Redis::get('foo')); + + tenancy()->init('tenant2.localhost'); + $this->assertSame(null, Redis::get('foo')); + Redis::set('foo', 'xyz'); + Redis::set('abc', 'def'); + $this->assertSame('xyz', Redis::get('foo')); + $this->assertSame('def', Redis::get('abc')); + + tenancy()->init('tenant1.localhost'); + $this->assertSame('bar', Redis::get('foo')); + $this->assertSame(null, Redis::get('abc')); + + tenancy()->create('tenant3.localhost'); + tenancy()->init('tenant3.localhost'); + $this->assertSame(null, Redis::get('foo')); + $this->assertSame(null, Redis::get('abc')); + } + + /** @test */ + public function cache_is_separated() + { + tenancy()->create('tenant1.localhost'); + tenancy()->create('tenant2.localhost'); + + tenancy()->init('tenant1.localhost'); + Cache::put('foo', 'bar', 60); + $this->assertSame('bar', Cache::get('foo')); + + tenancy()->init('tenant2.localhost'); + $this->assertSame(null, Cache::get('foo')); + Cache::put('foo', 'xyz', 60); + Cache::put('abc', 'def', 60); + $this->assertSame('xyz', Cache::get('foo')); + $this->assertSame('def', Cache::get('abc')); + + tenancy()->init('tenant1.localhost'); + $this->assertSame('bar', Cache::get('foo')); + $this->assertSame(null, Cache::get('abc')); + + tenancy()->create('tenant3.localhost'); + tenancy()->init('tenant3.localhost'); + $this->assertSame(null, Cache::get('foo')); + $this->assertSame(null, Cache::get('abc')); + } + + /** @test */ + public function filesystem_is_separated() + { + tenancy()->create('tenant1.localhost'); + tenancy()->create('tenant2.localhost'); + + tenancy()->init('tenant1.localhost'); + Storage::disk('public')->put('foo', 'bar'); + $this->assertSame('bar', Storage::disk('public')->get('foo')); + + tenancy()->init('tenant2.localhost'); + $this->assertFalse(Storage::disk('public')->exists('foo')); + Storage::disk('public')->put('foo', 'xyz'); + Storage::disk('public')->put('abc', 'def'); + $this->assertSame('xyz', Storage::disk('public')->get('foo')); + $this->assertSame('def', Storage::disk('public')->get('abc')); + + tenancy()->init('tenant1.localhost'); + $this->assertSame('bar', Storage::disk('public')->get('foo')); + $this->assertFalse(Storage::disk('public')->exists('abc')); + + tenancy()->create('tenant3.localhost'); + tenancy()->init('tenant3.localhost'); + $this->assertFalse(Storage::disk('public')->exists('foo')); + $this->assertFalse(Storage::disk('public')->exists('abc')); + } +} + +class User extends \Illuminate\Database\Eloquent\Model +{ + protected $guarded = []; +} \ No newline at end of file diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index f4e84646..ac81e183 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -40,7 +40,7 @@ class TenantDatabaseManagerTest extends TestCase /** @test */ public function mysql_database_can_be_created_and_deleted() { - if (! $this->isTravis()) { + if (! $this->isContainerized()) { $this->markTestSkipped('As to not bloat your MySQL instance with test databases, this test is not run by default.'); } @@ -57,7 +57,7 @@ class TenantDatabaseManagerTest extends TestCase /** @test */ public function mysql_database_can_be_created_and_deleted_using_queued_commands() { - if (! $this->isTravis()) { + if (! $this->isContainerized()) { $this->markTestSkipped('As to not bloat your MySQL instance with test databases, this test is not run by default.'); } @@ -81,7 +81,7 @@ class TenantDatabaseManagerTest extends TestCase /** @test */ public function pgsql_database_can_be_created_and_deleted() { - if (! $this->isTravis()) { + if (! $this->isContainerized()) { $this->markTestSkipped('As to not bloat your PostgreSQL instance with test databases, this test is not run by default.'); } @@ -98,7 +98,7 @@ class TenantDatabaseManagerTest extends TestCase /** @test */ public function pgsql_database_can_be_created_and_deleted_using_queued_commands() { - if (! $this->isTravis()) { + if (! $this->isContainerized()) { $this->markTestSkipped('As to not bloat your PostgreSQL instance with test databases, this test is not run by default.'); } @@ -126,7 +126,7 @@ class TenantDatabaseManagerTest extends TestCase config()->set('tenancy.queue_database_creation', true); $db_name = 'testdatabase' . $this->randomString(10) . '.sqlite'; - $this->assertTrue(app(DatabaseManager::class)->create($db_name, 'sqlite')); + app(DatabaseManager::class)->create($db_name, 'sqlite'); Queue::assertPushed(QueuedTenantDatabaseCreator::class); } @@ -138,7 +138,7 @@ class TenantDatabaseManagerTest extends TestCase config()->set('tenancy.queue_database_deletion', true); $db_name = 'testdatabase' . $this->randomString(10) . '.sqlite'; - $this->assertTrue(app(DatabaseManager::class)->delete($db_name, 'sqlite')); + app(DatabaseManager::class)->delete($db_name, 'sqlite'); Queue::assertPushed(QueuedTenantDatabaseDeleter::class); } diff --git a/tests/TestCase.php b/tests/TestCase.php index 36321491..c0211310 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -36,7 +36,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase public function initTenancy($domain = 'localhost') { - tenancy()->init($domain); + return tenancy()->init($domain); } /** @@ -53,6 +53,8 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase $app['config']->set([ 'database.redis.client' => 'phpredis', + 'database.redis.cache.host' => env('TENANCY_TEST_REDIS_HOST', '127.0.0.1'), + 'database.redis.default.host' => env('TENANCY_TEST_REDIS_HOST', '127.0.0.1'), 'database.redis.tenancy' => [ 'host' => env('TENANCY_TEST_REDIS_HOST', '127.0.0.1'), 'password' => env('TENANCY_TEST_REDIS_PASSWORD', null), @@ -67,12 +69,16 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase 'suffix' => '.sqlite', ], 'database.connections.sqlite.database' => ':memory:', - 'database.connections.pgsql.username' => 'postgres', + 'database.connections.mysql.host' => env('TENANCY_TEST_MYSQL_HOST', '127.0.0.1'), + 'database.connections.pgsql.host' => env('TENANCY_TEST_PGSQL_HOST', '127.0.0.1'), + // 'database.connections.pgsql.username' => 'pgsqluser', 'tenancy.filesystem.disks' => [ 'local', 'public', 's3', ], + 'tenancy.redis.tenancy' => true, + 'tenancy.migrations_directory' => database_path('../migrations'), ]); } @@ -113,11 +119,9 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase return substr(str_shuffle(str_repeat($x = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', ceil($length / strlen($x)))), 1, $length); } - public function isTravis() + public function isContainerized() { - // Multiple, just to make sure. Someone might accidentally - // set one of these environment vars on their computer. - return env('CI') && env('TRAVIS') && env('CONTINUOUS_INTEGRATION'); + return env('CONTINUOUS_INTEGRATION') || env('DOCKER'); } public function assertArrayIsSubset($subset, $array, string $message = ''): void