1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-02-05 19:14:03 +00:00

Merge branch '1.x' into telescope-tags

This commit is contained in:
Samuel Štancl 2019-08-17 22:22:52 +02:00
commit a00113b3d1
36 changed files with 558 additions and 707 deletions

22
.github/ISSUE_TEMPLATE/bug-report.md vendored Normal file
View file

@ -0,0 +1,22 @@
---
name: "\U0001F41B Bug Report"
about: Report unexpected behavior with stancl/tenancy.
title: ''
labels: bug
assignees: stancl
---
#### Describe the bug
<!-- A clear and concise description of what the bug is. -->
#### Steps to reproduce
#### Expected behavior
A clear and concise description of what you expected to happen.
#### Your setup
- Laravel version [e.g. 5.8]
- stancl/tenancy version [e.g. 22]
- Storage driver [e.g. Redis]

View file

@ -0,0 +1,10 @@
---
name: "\U0001F4DA Documentation Issue"
about: Suggest how the documentation could be improved.
title: ''
labels: documentation
assignees: stancl
---

View file

@ -0,0 +1,14 @@
---
name: "\U0001F4A1 Feature Suggestion"
about: Suggest an idea for stancl/tenancy.
title: ''
labels: feature
assignees: stancl
---
#### Description
<!-- Description of the feature -->
#### Why this should be added
<!-- Give a use case for this feature -->

View file

@ -0,0 +1,10 @@
---
name: "❤️ Support Question"
about: Ask for help with implementing stancl/tenancy.
title: ''
labels: support
assignees: stancl
---

View file

@ -1,4 +1,6 @@
preset: laravel
enabled:
- native_function_invocation
disabled:
- concat_without_spaces
- ternary_operator_spaces

View file

@ -1,5 +1,22 @@
# Release Notes for 1.x
## [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.

608
README.md
View file

@ -13,610 +13,8 @@ You won't have to change a thing in your application's code.\*
- :heavy_check_mark: No replacing of Laravel classes (`Cache`, `Storage`, ...) with tenancy-aware classes
- :heavy_check_mark: Built-in tenant identification based on hostname (including second level domains)
\* depending on how you use the filesystem. Be sure to read [that section](#filesystemstorage). Everything else will work out of the box.
\* depending on how you use the filesystem. Everything else will work out of the box.
## Table Of Contents
### [Documentation](https://stancl-tenancy.netlify.com/docs/)
<details>
<summary><strong>Click to expand/collapse</strong></summary>
- [stancl/tenancy](#stancltenancy)
+ [*A Laravel multi-database tenancy package that respects your code.*](#-a-laravel-multi-database-tenancy-package-that-respects-your-code-)
- [Installation](#installation)
+ [Requirements](#requirements)
+ [Installing the package](#installing-the-package)
+ [Configuring the `InitializeTenancy` middleware](#configuring-the--initializetenancy--middleware)
+ [Creating tenant routes](#creating-tenant-routes)
+ [Publishing the configuration file](#publishing-the-configuration-file)
- [`exempt_domains`](#-exempt-domains-)
- [`database`](#-database-)
- [`redis`](#-redis-)
- [`cache`](#-cache-)
- [`filesystem`](#-filesystem-)
- [Usage](#usage)
* [Creating a Redis connection for storing tenancy-related data](#creating-a-redis-connection-for-storing-tenancy-related-data)
* [Obtaining a `TenantManager` instance](#obtaining-a--tenantmanager--instance)
+ [Creating a new tenant](#creating-a-new-tenant)
+ [Starting a session as a tenant](#starting-a-session-as-a-tenant)
+ [Getting tenant information based on his UUID](#getting-tenant-information-based-on-his-uuid)
+ [Getting tenant UUID based on his domain](#getting-tenant-uuid-based-on-his-domain)
+ [Getting tenant information based on his domain](#getting-tenant-information-based-on-his-domain)
+ [Getting current tenant information](#getting-current-tenant-information)
+ [Listing all tenants](#listing-all-tenants)
+ [Deleting a tenant](#deleting-a-tenant)
* [Storage driver](#storage-driver)
+ [Storing custom data](#storing-custom-data)
* [Database](#database)
* [Redis](#redis)
* [Cache](#cache)
* [Filesystem/Storage](#filesystem-storage)
* [Artisan commands](#artisan-commands)
- [`tenants:list`](#-tenants-list-)
- [`tenants:migrate`, `tenants:rollback`, `tenants:seed`](#-tenants-migrate----tenants-rollback----tenants-seed-)
- [Running your commands for tenants](#running-your-commands-for-tenants)
+ [Tenant migrations](#tenant-migrations)
* [Testing](#testing)
- [Tips](#tips)
* [HTTPS certificates](#https-certificates)
+ [1. Use nginx with the lua module](#1-use-nginx-with-the-lua-module)
+ [2. Add a simple server block for each tenant](#2-add-a-simple-server-block-for-each-tenant)
+ [Generating certificates](#generating-certificates)
- [Development](#development)
* [Running tests](#running-tests)
+ [With Docker](#with-docker)
+ [Without Docker](#without-docker)
</details>
## Stay updated
If you'd like to be notified about new versions and related stuff, [sign up for e-mail notifications](http://eepurl.com/gyCnbf) or join our [Telegram channel](https://t.me/joinchat/AAAAAFjdrbSJg0ZCHTzxLA).
# 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.8
### Installing the package
```
composer require stancl/tenancy
```
This package follows [semantic versioning 2.0.0](https://semver.org). Each major release will have its own branch, so that bug fixes can be provided for older versions as well.
### Configuring the `InitializeTenancy` middleware
The `TenancyServiceProvider` automatically adds the `tenancy` middleware group which can be assigned to routes. You only need to make sure the middleware is top priority.
Open `app/Http/Kernel.php` and make the middleware top priority, so that it gets executed before anything else, making sure things like the database switch connections soon enough.
```php
protected $middlewarePriority = [
\Stancl\Tenancy\Middleware\InitializeTenancy::class,
// ...
];
```
When a tenant route is visited, but the tenant can't be identified, an exception is thrown. If you want to change this behavior, to a redirect for example, add this to your `app/Providers/AppServiceProvider.php`'s `boot()` method.
```php
// use Stancl\Tenancy\Middleware\InitializeTenancy;
$this->app->bind(InitializeTenancy::class, function ($app) {
return new InitializeTenancy(function ($exception) {
// redirect
});
});
```
### Creating tenant routes
`Stancl\Tenancy\TenantRouteServiceProvider` maps tenant routes only if the current domain is not [exempt from tenancy](#exempt_domains). Tenant routes are loaded from `routes/tenant.php`.
Rename the `routes/web.php` file to `routes/tenant.php`. This file will contain routes accessible only with tenancy.
Create an empty `routes/web.php` file. This file will contain routes accessible without tenancy (such as the routes specific to the part of your app which creates tenants - landing page, sign up page, etc).
### Publishing the configuration file
```php
php artisan vendor:publish --provider='Stancl\Tenancy\TenancyServiceProvider' --tag=config
```
You should see something along the lines of `Copied File [...] to [/config/tenancy.php]`.
#### `exempt_domains`
Domains listed in this array won't have tenant routes.
For example, you can put the domain on which you have your landing page here.
#### `database`
Databases will be named like this:
```php
config('tenancy.database.prefix') . $uuid . config('tenancy.database.suffix')
```
They will use a connection based on the connection specified using the `based_on` setting. Using `mysql` or `sqlite` is fine, but if you need to change more things than just the database name, you can create a new `tenant` connection and set `tenancy.database.based_on` to `tenant`.
#### `redis`
Keys will be prefixed with:
```php
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:
```php
config('tenancy.cache.tag_base') . $uuid
```
#### `filesystem`
Filesystem paths will be suffixed with:
```php
config('tenancy.filesystem.suffix_base') . $uuid
```
These changes will only apply for disks listed in `disks`.
You can see an example in the [Filesystem](#filesystemstorage) section of the documentation The `filesystem.root_override` section is explained there as well.
# Usage
## Creating a Redis connection for storing tenancy-related data
Add an array like this to `database.redis` config:
```php
'tenancy' => [
'host' => env('TENANCY_REDIS_HOST', '127.0.0.1'),
'password' => env('TENANCY_REDIS_PASSWORD', null),
'port' => env('TENANCY_REDIS_PORT', 6380),
'database' => env('TENANCY_REDIS_DB', 3),
],
```
Note the different `database` number and the different port.
A different port is used in this example, because if you use Redis for caching, you may want to run one instance with no persistence and another instance with persistence for tenancy-related data. If you want to run only one Redis instance, just make sure you use a different database number to avoid collisions.
Read the [Storage driver](#storage-driver) section for more information.
## Obtaining a `TenantManager` instance
You can use the `tenancy()` and `tenant()` helpers to resolve `Stancl\Tenancy\TenantManager` out of the service container. These two helpers are exactly the same, the only reason there are two is nice syntax. `tenancy()->init()` sounds better than `tenant()->init()` and `tenant()->create()` sounds better than `tenancy()->create()`. **You may also use the `Tenancy` facade.**
### Creating a new tenant
```php
>>> tenant()->create('dev.localhost')
=> [
"uuid" => "49670df0-1a87-11e9-b7ba-cf5353777957",
"domain" => "dev.localhost",
]
```
You can also put data into the storage during the tenant creation process:
```php
>>> tenant()->create('dev.localhost', [
'plan' => 'basic'
])
=> [
"uuid" => "49670df0-1a87-11e9-b7ba-cf5353777957",
"domain" => "dev.localhost",
"plan" => "basic",
]
```
If you want to specify the tenant's database name, set the `tenancy.database_name_key` configuration key to the name of the key that is used to specify the database name in the tenant storage. You must use a name that you won't use for storing other data, so it's recommended to avoid names like `database` and use names like `_stancl_tenancy_database_name` instead. Then just give the key a value during the tenant creation process:
```php
>>> tenant()->create('example.com', [
'_stancl_tenancy_database_name' => 'example_com'
])
=> [
"uuid" => "49670df0-1a87-11e9-b7ba-cf5353777957",
"domain" => "example.com",
"_stancl_tenancy_database_name" => "example_com",
]
```
When you create a new tenant, you can [migrate](#tenant-migrations) their database like this:
```php
\Artisan::call('tenants:migrate', [
'--tenants' => [$tenant['uuid']]
]);
```
You can also seed the database in the same way. The only difference is the command name (`tenants:seed`).
### Starting a session as a tenant
This switches the DB connection, prefixes Redis, changes filesystem root paths and tags cache.
```php
tenancy()->init();
// The domain will be autodetected unless specified as an argument
tenancy()->init('dev.localhost');
```
### Getting tenant information based on his UUID
You can use `find()`, which is an alias for `getTenantById()`.
You may use the second argument to specify the key(s) as a string/array.
```php
>>> tenant()->getTenantById('dbe0b330-1a6e-11e9-b4c3-354da4b4f339');
=> [
"uuid" => "dbe0b330-1a6e-11e9-b4c3-354da4b4f339",
"domain" => "localhost",
"foo" => "bar",
]
>>> tenant()->getTenantById('dbe0b330-1a6e-11e9-b4c3-354da4b4f339', 'foo');
=> [
"foo" => "bar",
]
>>> tenant()->getTenantById('dbe0b330-1a6e-11e9-b4c3-354da4b4f339', ['foo', 'domain']);
=> [
"foo" => "bar",
"domain" => "localhost",
]
```
### Getting tenant UUID based on his domain
```php
>>> tenant()->getTenantIdByDomain('localhost');
=> "b3ce3f90-1a88-11e9-a6b0-038c6337ae50"
>>> tenant()->getIdByDomain('localhost');
=> "b3ce3f90-1a88-11e9-a6b0-038c6337ae50"
```
### Getting tenant information based on his domain
You may use the second argument to specify the key(s) as a string/array.
```php
>>> tenant()->findByDomain('localhost');
=> [
"uuid" => "b3ce3f90-1a88-11e9-a6b0-038c6337ae50",
"domain" => "localhost",
]
```
### Getting current tenant information
You can access the public array `tenant` of `TenantManager` like this:
```php
tenancy()->tenant
```
which returns an array. If you want to get the value of a specific key from the array, you can use one of the helpers with an argument --- the key on the `tenant` array.
```php
tenant('uuid'); // Does the same thing as tenant()->tenant['uuid']
```
### Listing all tenants
```php
>>> tenant()->all();
=> Illuminate\Support\Collection {#2980
all: [
[
"uuid" => "32e20780-1a88-11e9-a051-4b6489a7edac",
"domain" => "localhost",
],
[
"uuid" => "49670df0-1a87-11e9-b7ba-cf5353777957",
"domain" => "dev.localhost",
],
],
}
>>> tenant()->all()->pluck('domain');
=> Illuminate\Support\Collection {#2983
all: [
"localhost",
"dev.localhost",
],
}
```
### Deleting a tenant
```php
>>> tenant()->delete('dbe0b330-1a6e-11e9-b4c3-354da4b4f339');
=> true
>>> tenant()->delete(tenant()->getTenantIdByDomain('dev.localhost'));
=> true
>>> tenant()->delete(tenant()->findByDomain('localhost')['uuid']);
=> true
```
Note that deleting a tenant doesn't delete his database. You can do this manually, though. To get the database name of a tenant, you can do use the `TenantManager::getDatabaseName()` method.
```php
>>> tenant()->getDatabaseName(tenant()->findByDomain('laravel.localhost'))
=> "tenant67412a60-1c01-11e9-a9e9-f799baa56fd9"
```
## Storage driver
Currently, only Redis is supported, but you're free to code your own storage driver which follows the `Stancl\Tenancy\Interfaces\StorageDriver` interface. Just point the `tenancy.storage_driver` setting at your driver.
**Note that you need to configure persistence on your Redis instance** if you don't want to lose all information about tenants.
Read the [Redis documentation page on persistence](https://redis.io/topics/persistence). You should definitely use AOF and if you want to be even more protected from data loss, you can use RDB **in conjunction with AOF**.
If your cache driver is Redis and you don't want to use AOF with it, run two Redis instances. Otherwise, just make sure you use a different database (number) for tenancy and for anything else.
### Storing custom data
Along with the tenant and database info, you can store your own data in the storage. This is useful, for example, when you want to store tenant-specific config. You can use:
```php
get (string|array $key, string $uuid = null) // $uuid defaults to the current tenant's UUID
put (string|array $key, mixed $value = null, string $uuid = null) // if $key is array, make sure $value is null
```
```php
tenancy()->get($key);
tenancy()->get($key, $uuid);
tenancy()->get(['key1', 'key2']);
tenancy()->put($key, $value);
tenancy()->set($key, $value); // alias for put()
tenancy()->put($key, $value, $uuid);
tenancy()->put(['key1' => 'value1', 'key2' => 'value2']);
tenancy()->put(['key1' => 'value1', 'key2' => 'value2'], null, $uuid);
```
Note that `$key` has to be a string or an array with string keys. The value(s) can be of any data type. Example with arrays:
```php
>>> tenant()->put('foo', ['a' => 'b', 'c' => 'd']);
=> [ // put() returns the supplied value(s)
"a" => "b",
"c" => "d",
]
>>> tenant()->get('foo');
=> [
"a" => "b",
"c" => "d",
]
```
## Database
The entire application will use a new database connection. The connection will be based on the connection specified in `tenancy.database.based_on`. A database name of `tenancy.database.prefix` + tenant UUID + `tenancy.database.suffix` will be used. You can set the suffix to `.sqlite` if you're using sqlite and want the files to be in the sqlite format and you can leave the suffix empty if you're using MySQL (for example).
## Redis
Connections listed in the `tenancy.redis.prefixed_connections` config array use a prefix based on the `tenancy.redis.prefix_base` and the tenant UUID. This separates tenant data. **However note that `Redis::scan()` does not respect the prefix.**
**Note: You *must* use phpredis if you want mutli-tenant Redis. Predis doesn't support prefixes.**
## Cache
Both the `cache()` helper and the `Cache` facade will use `Stancl\Tenancy\CacheManager`, which adds a tag (`prefix_base` + tenant UUID) to all methods called on it.
If you need to store something in global, non-tenant cache, you can use the `GlobalCache` facade the same way you'd use the `Cache` facade.
## Filesystem/Storage
Assuming the following tenancy config:
```php
'filesystem' => [
'suffix_base' => 'tenant',
// Disks which should be suffixed with the suffix_base + tenant UUID.
'disks' => [
'local',
// 'public',
// 's3',
],
'root_override' => [
// Disks whose roots should be overriden after storage_path() is suffixed.
'local' => '%storage_path%/app/',
'public' => '%storage_path%/app/public/',
],
],
```
1. The `storage_path()` will be suffixed with a directory named `tenant` + the tenant UUID.
2. The `local` disk's root will be `storage_path('app')` (which is equivalen to `storage_path() . '/app/'`).
By default, all disks' roots are suffixed with `tenant` + the tenant UUID. This works for s3 and similar disks. But for local disks, this results in unwanted behavior. The default root for this disk is `storage_path('app')`:
```php
'local' => [
'driver' => 'local',
'root' => storage_path('app'),
],
```
However, this configration file was loaded *before* tenancy was initialized. This means that if we simply suffix this disk's root, we get `/path_to_your_application/storage/app/tenant1e22e620-1cb8-11e9-93b6-8d1b78ac0bcd/`. That's not what we want. We want `/path_to_your_application/storage/tenant1e22e620-1cb8-11e9-93b6-8d1b78ac0bcd/app/`.
This is what the override section of the config is for. `%storage_path%` gets replaced by `storage_path()` *after* tenancy is initialized. **The roots of disks listed in the `root_override` section of the config will be replaced according it. All other disks will be simply suffixed with `tenant` + the tenant UUID.**
```php
>>> Storage::disk('local')->getAdapter()->getPathPrefix()
=> "/var/www/laravel/multitenancy/storage/app/"
>>> tenancy()->init()
=> [
"uuid" => "dbe0b330-1a6e-11e9-b4c3-354da4b4f339",
"domain" => "localhost",
]
>>> Storage::disk('local')->getAdapter()->getPathPrefix()
=> "/var/www/laravel/multitenancy/storage/tenantdbe0b330-1a6e-11e9-b4c3-354da4b4f339/app/"
```
`storage_path()` will also be suffixed in the same way. Note that this means that each tenant will have their own storage directory.
![The folder structure](https://i.imgur.com/GAXQOnN.png)
If you write to these directories, you will need to create them after you create the tenant. See the docs for [PHP's mkdir](http://php.net/function.mkdir).
Logs will be saved to `storage/logs` regardless of any changes to `storage_path()`.
One thing that you **will** have to change if you use storage similarly to the example on the image is your use of the helper function `asset()` (that is, if you use it).
You need to make this change to your code:
```diff
- asset("storage/images/products/$product_id.png");
+ tenant_asset("images/products/$product_id.png");
```
Note that all (public) tenant assets have to be in the `app/public/` subdirectory of the tenant's storage directory, as shown in the image above.
This is what the backend of `tenant_asset()` returns:
```php
// TenantAssetsController
return response()->file(storage_path('app/public/' . $path));
```
With default filesystem configuration, these two commands are equivalent:
```php
Storage::disk('public')->put($filename, $data);
Storage::disk('local')->put("public/$filename", $data);
```
If you want to store something globally, simply create a new disk and *don't* add it to the `tenancy.filesystem.disks` config.
## Artisan commands
```
Available commands for the "tenants" namespace:
tenants:list List tenants.
tenants:migrate Run migrations for tenant(s)
tenants:rollback Rollback migrations for tenant(s).
tenants:run Run a command for tenant(s).
tenants:seed Seed tenant database(s).
```
#### `tenants:list`
```
$ artisan tenants:list
Listing all tenants.
[Tenant] uuid: dbe0b330-1a6e-11e9-b4c3-354da4b4f339 @ localhost
[Tenant] uuid: 49670df0-1a87-11e9-b7ba-cf5353777957 @ dev.localhost
```
#### `tenants:migrate`, `tenants:rollback`, `tenants:seed`
- You may specify the tenant UUID(s) using the `--tenants` option.
```
$ artisan tenants:seed --tenants=8075a580-1cb8-11e9-8822-49c5d8f8ff23
Tenant: 8075a580-1cb8-11e9-8822-49c5d8f8ff23 (laravel.localhost)
Database seeding completed successfully.
```
### Running your commands for tenants
You can use the `tenants:run` command to run your own commands for tenants.
If your command's signature were `email:send {user} {--queue} {--subject} {body}`, you would run this command like this:
```
$ artisan tenants:run email:send --tenants=8075a580-1cb8-11e9-8822-49c5d8f8ff23 --option="queue=1" --option="subject=New Feature" --argument="body=We have launched a new feature. ..."
```
The `=` separates the argument/option name from its value, but you can still use `=` in the argument's value.
### Tenant migrations
Tenant migrations are located in `database/migrations/tenant`, so you should move your tenant migrations there.
## Testing
To test your multi-tenant application, simply run the following at the beginning of every test:
```php
tenant()->create('test.localhost')
tenancy()->init('test.localhost')
```
To do this automatically, you can make this part of your `TestCase::setUp()` method. [Here](https://github.com/stancl/tenancy/blob/13048002ef687c3c85207df1fbf8b09ce89fb430/tests/TestCase.php#L31-L37)'s how this package handles it.
# Tips
- If you create a tenant using the interactive console (`artisan tinker`) and use sqlite, you might need to change the database's permissions and/or ownership (`chmod`/`chown`) so that the web application can access it.
## HTTPS certificates
<details>
<summary><strong>Click to expand/collapse</strong></summary>
HTTPS certificates are very easy to deal with if you use the `yourclient1.yourapp.com`, `yourclient2.yourapp.com` model. You can use a wildcard HTTPS certificate.
If you use the model where second level domains are used, there are multiple ways you can solve this.
This guide focuses on nginx.
### 1. Use nginx with the lua module
Specifically, you're interested in the [`ssl_certificate_by_lua_block`](https://github.com/openresty/lua-nginx-module#ssl_certificate_by_lua_block) directive. Nginx doesn't support using variables such as the hostname in the `ssl_certificate` directive, which is why the lua module is needed.
This approach lets you use one server block for all tenants.
### 2. Add a simple server block for each tenant
You can store most of your config in a file, such as `/etc/nginx/includes/tenant`, and include this file into tenant server blocks.
```nginx
server {
include includes/tenant;
server_name foo.bar;
# ssl_certificate /etc/foo/...;
}
```
### Generating certificates
You can generate a certificate using certbot. If you use the `--nginx` flag, you will need to run certbot as root. If you use the `--webroot` flag, you only need the user that runs it to have write access to the webroot directory (or perhaps webroot/.well-known is enough) and some certbot files (you can specify these using --work-dir, --config-dir and --logs-dir).
Creating this config dynamically from PHP is not easy, but is probably feasible. Giving `www-data` write access to `/etc/nginx/sites-available/tenants.conf` should work.
However, you still need to reload nginx configuration to apply the changes to configuration. This is problematic and I'm not sure if there is a simple and secure way to do this from PHP.
</details>
# Development
## 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.
Documentation has been moved to a custom website: https://stancl-tenancy.netlify.com/docs/

View file

@ -1,7 +1,19 @@
<?php
return [
'storage_driver' => 'Stancl\Tenancy\StorageDrivers\RedisStorageDriver',
'storage_driver' => 'Stancl\Tenancy\StorageDrivers\DatabaseStorageDriver',
'storage' => [
'db' => [ // Stancl\Tenancy\StorageDrivers\DatabaseStorageDriver
'data_column' => 'data',
'custom_columns' => [
// 'plan',
],
'connection' => 'central',
],
'redis' => [ // Stancl\Tenancy\StorageDrivers\RedisStorageDriver
'connection' => 'tenancy',
],
],
'tenant_route_namespace' => 'App\Http\Controllers',
'exempt_domains' => [
// 'localhost',

View file

@ -0,0 +1,35 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateTenantsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('tenants', function (Blueprint $table) {
$table->string('uuid', 36)->primary(); // don't change this
$table->string('domain', 255)->index(); // don't change this
// your indexed columns go here
$table->json('data');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('tenants');
}
}

View file

@ -18,7 +18,7 @@ class CacheManager extends BaseCacheManager
$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);

View file

@ -34,15 +34,16 @@ class Install extends Command
]);
$this->info('✔️ Created config/tenancy.php');
file_put_contents(app_path('Http/Kernel.php'), str_replace(
\file_put_contents(app_path('Http/Kernel.php'), \str_replace(
'protected $middlewarePriority = [',
"protected \$middlewarePriority = [\n \Stancl\Tenancy\Middleware\InitializeTenancy::class,",
file_get_contents(app_path('Http/Kernel.php'))
\file_get_contents(app_path('Http/Kernel.php'))
));
$this->info('✔️ Set middleware priority');
file_put_contents(base_path('routes/tenant.php'),
"<?php
\file_put_contents(
base_path('routes/tenant.php'),
"<?php
/*
|--------------------------------------------------------------------------
@ -58,7 +59,8 @@ class Install extends Command
Route::get('/your/application/homepage', function () {
return 'This is your multi-tenant application. The uuid of the current tenant is ' . tenant('uuid');
});
");
"
);
$this->info('✔️ Created routes/tenant.php');
$this->line('');
@ -71,6 +73,11 @@ Route::get('/your/application/homepage', function () {
$this->info('✔️ Created migration.');
}
if (! \is_dir(database_path('migrations/tenant'))) {
\mkdir(database_path('migrations/tenant'));
$this->info('✔️ Created database/migrations/tenant folder.');
}
$this->comment('✨️ stancl/tenancy installed successfully.');
}
}

View file

@ -47,11 +47,14 @@ class Migrate extends MigrateCommand
return;
}
$this->input->setOption('database', 'tenant');
tenant()->all($this->option('tenants'))->each(function ($tenant) {
$this->line("Tenant: {$tenant['uuid']} ({$tenant['domain']})");
$this->database->connectToTenant($tenant);
// See Illuminate\Database\Migrations\DatabaseMigrationRepository::getConnection.
// Database connections are cached by Illuminate\Database\ConnectionResolver.
$connectionName = "tenant{$tenant['uuid']}";
$this->input->setOption('database', $connectionName);
$this->database->connectToTenant($tenant, $connectionName);
// Migrate
parent::handle();

View file

@ -40,7 +40,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;
@ -48,13 +48,13 @@ 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()->end();
});

View file

@ -10,21 +10,23 @@ final class DatabaseManager
{
public $originalDefaultConnection;
protected $defaultTenantConnectionName = 'tenant';
public function __construct(BaseDatabaseManager $database)
{
$this->originalDefaultConnection = config('database.default');
$this->database = $database;
}
public function connect(string $database)
public function connect(string $database, string $connectionName = null)
{
$this->createTenantConnection($database);
$this->useConnection('tenant');
$this->createTenantConnection($database, $connectionName);
$this->useConnection($connectionName);
}
public function connectToTenant($tenant)
public function connectToTenant($tenant, string $connectionName = null)
{
$this->connect(tenant()->getDatabaseName($tenant));
$this->connect(tenant()->getDatabaseName($tenant), $connectionName);
}
public function disconnect()
@ -50,7 +52,7 @@ final class DatabaseManager
$databaseManagers = config('tenancy.database_managers');
if (! array_key_exists($driver, $databaseManagers)) {
if (! \array_key_exists($driver, $databaseManagers)) {
throw new \Exception("Database could not be created: no database manager for driver $driver is registered.");
}
@ -76,7 +78,7 @@ final class DatabaseManager
$databaseManagers = config('tenancy.database_managers');
if (! array_key_exists($driver, $databaseManagers)) {
if (! \array_key_exists($driver, $databaseManagers)) {
throw new \Exception("Database could not be deleted: no database manager for driver $driver is registered.");
}
@ -87,26 +89,32 @@ final class DatabaseManager
}
}
public function getDriver(): ?string
public function getDriver($connectionName = null): ?string
{
return config('database.connections.tenant.driver');
$connectionName = $connectionName ?: $this->defaultTenantConnectionName;
return config("database.connections.$connectionName.driver");
}
public function createTenantConnection(string $database_name)
public function createTenantConnection(string $databaseName, string $connectionName = null)
{
// Create the `tenancy` database connection.
$connectionName = $connectionName ?: $this->defaultTenantConnectionName;
// Create the database connection.
$based_on = config('tenancy.database.based_on') ?: config('database.default');
config()->set([
'database.connections.tenant' => config('database.connections.' . $based_on),
"database.connections.$connectionName" => config('database.connections.' . $based_on),
]);
// Change DB name
$database_name = $this->getDriver() === 'sqlite' ? database_path($database_name) : $database_name;
config()->set(['database.connections.tenant.database' => $database_name]);
$databaseName = $this->getDriver($connectionName) === 'sqlite' ? database_path($databaseName) : $databaseName;
config()->set(["database.connections.$connectionName.database" => $databaseName]);
}
public function useConnection(string $connection)
public function useConnection(string $connection = null)
{
$connection = $connection ?: $this->defaultTenantConnectionName;
$this->database->setDefaultConnection($connection);
$this->database->reconnect($connection);
}

View file

@ -6,6 +6,7 @@ interface StorageDriver
{
public function identifyTenant(string $domain): array;
/** @return array[] */
public function getAllTenants(array $uuids = []): array;
public function getTenantById(string $uuid, array $fields = []): array;

View file

@ -0,0 +1,87 @@
<?php
namespace Stancl\Tenancy\StorageDrivers;
use Stancl\Tenancy\Tenant;
use Stancl\Tenancy\Interfaces\StorageDriver;
class DatabaseStorageDriver implements StorageDriver
{
public $useJson = false;
// todo use an instance of tenant model?
// todo write tests verifying that data is decoded and added to the array
public function identifyTenant(string $domain): array
{
$id = $this->getTenantIdByDomain($domain);
if (! $id) {
throw new \Exception("Tenant could not be identified on domain {$domain}");
}
return $this->getTenantById($id);
}
/**
* Get information about the tenant based on his uuid.
*
* @param string $uuid
* @param array $fields
* @return array
*/
public function getTenantById(string $uuid, array $fields = []): array
{
if ($fields) {
return Tenant::decodeData(Tenant::find($uuid)->only($fields));
} else {
return Tenant::find($uuid)->decoded();
}
}
public function getTenantIdByDomain(string $domain): ?string
{
return Tenant::where('domain', $domain)->first()->uuid ?? null;
}
public function createTenant(string $domain, string $uuid): array
{
$tenant = Tenant::create(['uuid' => $uuid, 'domain' => $domain, 'data' => '{}'])->toArray();
unset($tenant['data']);
return $tenant;
}
public function deleteTenant(string $id): bool
{
return Tenant::find($id)->delete();
}
public function getAllTenants(array $uuids = []): array
{
return Tenant::getAllTenants($uuids)->toArray();
}
public function get(string $uuid, string $key)
{
return Tenant::find($uuid)->get($key);
}
public function getMany(string $uuid, array $keys): array
{
return Tenant::find($uuid)->getMany($keys);
}
public function put(string $uuid, string $key, $value)
{
return Tenant::find($uuid)->put($key, $value);
}
public function putMany(string $uuid, array $values): array
{
foreach ($values as $key => $value) {
Tenant::find($uuid)->put($key, $value);
}
return $values;
}
}

View file

@ -11,7 +11,7 @@ class RedisStorageDriver implements StorageDriver
public function __construct()
{
$this->redis = Redis::connection('tenancy');
$this->redis = Redis::connection(config('tenancy.redis.connection', 'tenancy'));
}
public function identifyTenant(string $domain): array
@ -33,13 +33,11 @@ class RedisStorageDriver implements StorageDriver
*/
public function getTenantById(string $uuid, array $fields = []): array
{
$fields = (array) $fields;
if (! $fields) {
return $this->redis->hgetall("tenants:$uuid");
}
return array_combine($fields, $this->redis->hmget("tenants:$uuid", $fields));
return \array_combine($fields, $this->redis->hmget("tenants:$uuid", $fields));
}
public function getTenantIdByDomain(string $domain): ?string
@ -50,7 +48,7 @@ class RedisStorageDriver implements StorageDriver
public function createTenant(string $domain, string $uuid): array
{
$this->redis->hmset("domains:$domain", 'tenant_id', $uuid);
$this->redis->hmset("tenants:$uuid", 'uuid', json_encode($uuid), 'domain', json_encode($domain));
$this->redis->hmset("tenants:$uuid", 'uuid', \json_encode($uuid), 'domain', \json_encode($domain));
return $this->redis->hgetall("tenants:$uuid");
}
@ -65,7 +63,7 @@ class RedisStorageDriver implements StorageDriver
public function deleteTenant(string $id): bool
{
try {
$domain = json_decode($this->getTenantById($id)['domain']);
$domain = \json_decode($this->getTenantById($id)['domain']);
} catch (\Throwable $th) {
throw new \Exception("No tenant with UUID $id exists.");
}
@ -77,7 +75,7 @@ class RedisStorageDriver implements StorageDriver
public function getAllTenants(array $uuids = []): array
{
$hashes = array_map(function ($hash) {
$hashes = \array_map(function ($hash) {
return "tenants:{$hash}";
}, $uuids);
@ -88,18 +86,17 @@ class RedisStorageDriver implements StorageDriver
if (config('database.redis.client') === 'phpredis') {
$redis_prefix = $this->redis->getOption($this->redis->client()::OPT_PREFIX) ?? $redis_prefix;
$all_keys = $this->redis->scan(null, $redis_prefix . 'tenants:*');
} else {
$all_keys = $this->redis->scan(null, 'MATCH', $redis_prefix . 'tenants:*')[1];
}
$hashes = array_map(function ($key) use ($redis_prefix) {
$all_keys = $this->redis->keys('tenants:*');
$hashes = \array_map(function ($key) use ($redis_prefix) {
// Left strip $redis_prefix from $key
return substr($key, strlen($redis_prefix));
return \substr($key, \strlen($redis_prefix));
}, $all_keys);
}
return array_map(function ($tenant) {
return \array_map(function ($tenant) {
return $this->redis->hgetall($tenant);
}, $hashes);
}

View file

@ -34,9 +34,13 @@ class TenancyServiceProvider extends ServiceProvider
]);
$this->publishes([
__DIR__ . '/config/tenancy.php' => config_path('tenancy.php'),
__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', [
@ -75,7 +79,7 @@ class TenancyServiceProvider extends ServiceProvider
*/
public function register()
{
$this->mergeConfigFrom(__DIR__ . '/config/tenancy.php', 'tenancy');
$this->mergeConfigFrom(__DIR__ . '/../assets/config.php', 'tenancy');
$this->app->bind(StorageDriver::class, $this->app['config']['tenancy.storage_driver']);
$this->app->bind(ServerConfigManager::class, $this->app['config']['tenancy.server.manager']);

102
src/Tenant.php Normal file
View file

@ -0,0 +1,102 @@
<?php
namespace Stancl\Tenancy;
use Illuminate\Database\Eloquent\Model;
class Tenant extends Model
{
protected $guarded = [];
protected $primaryKey = 'uuid';
public $incrementing = false;
public $timestamps = false;
/**
* Decoded data from the data column.
*
* @var object
*/
private $dataObject;
public static function dataColumn()
{
return config('tenancy.storage.db.data_column', 'data');
}
public static function customColumns()
{
return config('tenancy.storage.db.custom_columns', []);
}
public function getConnectionName()
{
return config('tenancy.storage.db.connection') ?: config('database.default');
}
public static function getAllTenants(array $uuids)
{
$tenants = $uuids ? static::findMany($uuids) : static::all();
return $tenants->map([__CLASS__, 'decodeData'])->toBase();
}
public function decoded()
{
return static::decodeData($this);
}
/**
* Return a tenant array with data decoded into separate keys.
*
* @param Tenant|array $tenant
* @return array
*/
public static function decodeData($tenant)
{
$tenant = $tenant instanceof self ? (array) $tenant->attributes : $tenant;
$decoded = \json_decode($tenant[$dataColumn = static::dataColumn()], true);
foreach ($decoded as $key => $value) {
$tenant[$key] = $value;
}
// If $tenant[$dataColumn] has been overriden by a value, don't delete the key.
if (! \array_key_exists($dataColumn, $decoded)) {
unset($tenant[$dataColumn]);
}
return $tenant;
}
public function getFromData(string $key)
{
$this->dataArray = $this->dataArray ?? \json_decode($this->{$this->dataColumn()}, true);
return $this->dataArray[$key] ?? null;
}
public function get(string $key)
{
return $this->attributes[$key] ?? $this->getFromData($key) ?? null;
}
/** @todo In v2, this should return an associative array. */
public function getMany(array $keys): array
{
return \array_map([$this, 'get'], $keys);
}
public function put(string $key, $value)
{
if (\array_key_exists($key, $this->customColumns())) {
$this->update([$key => $value]);
} else {
$obj = \json_decode($this->{$this->dataColumn()});
$obj->$key = $value;
$this->update([$this->dataColumn() => \json_encode($obj)]);
}
return $value;
}
}

View file

@ -9,7 +9,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
public function createDatabase(string $name): bool
{
try {
return fclose(fopen(database_path($name), 'w'));
return \fclose(\fopen(database_path($name), 'w'));
} catch (\Throwable $th) {
return false;
}
@ -18,7 +18,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
public function deleteDatabase(string $name): bool
{
try {
return unlink(database_path($name));
return \unlink(database_path($name));
} catch (\Throwable $th) {
return false;
}

View file

@ -23,7 +23,7 @@ final class TenantManager
*
* @var StorageDriver
*/
protected $storage;
public $storage;
/**
* Database manager.
@ -37,7 +37,7 @@ final class TenantManager
*
* @var array
*/
public $tenant;
public $tenant = [];
public function __construct(Application $app, StorageDriver $storage, DatabaseManager $database)
{
@ -64,7 +64,7 @@ final class TenantManager
$tenant = $this->storage->identifyTenant($domain);
if (! $tenant || ! array_key_exists('uuid', $tenant) || ! $tenant['uuid']) {
if (! $tenant || ! \array_key_exists('uuid', $tenant) || ! $tenant['uuid']) {
throw new \Exception("Tenant could not be identified on domain {$domain}.");
}
@ -86,12 +86,15 @@ final class TenantManager
throw new \Exception("Domain $domain is already occupied by tenant $id.");
}
$tenant = $this->jsonDecodeArrayValues($this->storage->createTenant($domain, (string) \Webpatser\Uuid\Uuid::generate(1, $domain)));
$tenant = $this->storage->createTenant($domain, (string) \Webpatser\Uuid\Uuid::generate(1, $domain));
if ($this->useJson()) {
$tenant = $this->jsonDecodeArrayValues($tenant);
}
if ($data) {
$this->put($data, null, $tenant['uuid']);
$tenant = array_merge($tenant, $data);
$tenant = \array_merge($tenant, $data);
}
$this->database->create($this->getDatabaseName($tenant));
@ -115,7 +118,12 @@ final class TenantManager
{
$fields = (array) $fields;
return $this->jsonDecodeArrayValues($this->storage->getTenantById($uuid, $fields));
$tenant = $this->storage->getTenantById($uuid, $fields);
if ($this->useJson()) {
$tenant = $this->jsonDecodeArrayValues($tenant);
}
return $tenant;
}
/**
@ -167,7 +175,7 @@ final class TenantManager
$uuid = $this->getIdByDomain($domain);
if (is_null($uuid)) {
if (\is_null($uuid)) {
throw new \Exception("Tenant with domain $domain could not be identified.");
}
@ -200,7 +208,9 @@ final class TenantManager
*/
public function setTenant(array $tenant): array
{
if ($this->useJson()) {
$tenant = $this->jsonDecodeArrayValues($tenant);
}
$this->tenant = $tenant;
@ -227,10 +237,15 @@ final class TenantManager
public function all($uuids = [])
{
$uuids = (array) $uuids;
$tenants = $this->storage->getAllTenants($uuids);
return collect(array_map(function ($tenant_array) {
if ($this->useJson()) {
$tenants = \array_map(function ($tenant_array) {
return $this->jsonDecodeArrayValues($tenant_array);
}, $this->storage->getAllTenants($uuids)));
}, $tenants);
}
return collect($tenants);
}
/**
@ -258,11 +273,16 @@ final class TenantManager
{
$uuid = $uuid ?: $this->tenant['uuid'];
if (\array_key_exists('uuid', $this->tenant) && $uuid === $this->tenant['uuid'] &&
! \is_array($key) && \array_key_exists($key, $this->tenant)) {
return $this->tenant[$key];
}
if (\is_array($key)) {
return $this->jsonDecodeArrayValues($this->storage->getMany($uuid, $key));
}
return json_decode($this->storage->get($uuid, $key), true);
return \json_decode($this->storage->get($uuid, $key), true);
}
/**
@ -275,10 +295,10 @@ final class TenantManager
*/
public function put($key, $value = null, string $uuid = null)
{
if (in_array($key, ['uuid', 'domain'], true) || (
is_array($key) && (
in_array('uuid', array_keys($key), true) ||
in_array('domain', array_keys($key), true)
if (\in_array($key, ['uuid', 'domain'], true) || (
\is_array($key) && (
\in_array('uuid', \array_keys($key), true) ||
\in_array('domain', \array_keys($key), true)
)
)) {
throw new CannotChangeUuidOrDomainException;
@ -299,7 +319,7 @@ final class TenantManager
}
if (! \is_null($value)) {
return $target[$key] = json_decode($this->storage->put($uuid, $key, json_encode($value)), true);
return $target[$key] = \json_decode($this->storage->put($uuid, $key, \json_encode($value)), true);
}
if (! \is_array($key)) {
@ -308,7 +328,7 @@ final class TenantManager
foreach ($key as $k => $v) {
$target[$k] = $v;
$key[$k] = json_encode($v);
$key[$k] = \json_encode($v);
}
return $this->jsonDecodeArrayValues($this->storage->putMany($uuid, $key));
@ -329,18 +349,28 @@ final class TenantManager
protected function jsonDecodeArrayValues(array $array)
{
array_walk($array, function (&$value, $key) {
$value = json_decode($value, true);
\array_walk($array, function (&$value, $key) {
$value = \json_decode($value, true);
});
return $array;
}
public function useJson()
{
if (\property_exists($this->storage, 'useJson') && $this->storage->useJson === false) {
return false;
}
return true;
}
/**
* Return the identified tenant's attribute(s).
*
* @param string $attribute
* @return mixed
* @todo Deprecate this in v2.
*/
public function __invoke($attribute)
{
@ -348,6 +378,6 @@ final class TenantManager
return $this->tenant;
}
return $this->tenant[(string) $attribute];
return $this->get((string) $attribute);
}
}

View file

@ -10,7 +10,7 @@ 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'))) {
&& \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'));

View file

@ -138,7 +138,7 @@ trait BootstrapsTenancy
foreach ($this->app['config']['tenancy.filesystem.disks'] as $disk) {
$old['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"];

View file

@ -8,7 +8,7 @@ trait HasATenantsOption
{
protected function getOptions()
{
return array_merge([
return \array_merge([
['tenants', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL, '', null],
], parent::getOptions());
}

View file

@ -78,7 +78,7 @@ trait TenantManagerEvents
*/
public function event(string $name): Collection
{
return array_reduce($this->listeners[$name], function ($prevents, $listener) {
return \array_reduce($this->listeners[$name], function ($prevents, $listener) {
return $prevents->merge($listener($this) ?? []);
}, collect([]));
}

View file

@ -2,7 +2,7 @@
use Stancl\Tenancy\TenantManager;
if (! function_exists('tenancy')) {
if (! \function_exists('tenancy')) {
function tenancy($key = null)
{
if ($key) {
@ -13,14 +13,14 @@ if (! function_exists('tenancy')) {
}
}
if (! function_exists('tenant')) {
if (! \function_exists('tenant')) {
function tenant($key = null)
{
return tenancy($key);
}
}
if (! function_exists('tenant_asset')) {
if (! \function_exists('tenant_asset')) {
function tenant_asset($asset)
{
return route('stancl.tenancy.asset', ['asset' => $asset]);

6
test
View file

@ -4,7 +4,9 @@ set -e
# for development
docker-compose up -d
printf "Variant 1\n\n"
TENANCY_TEST_REDIS_TENANCY=1 TENANCY_TEST_REDIS_CLIENT=phpredis docker-compose exec test vendor/bin/phpunit --coverage-php coverage/1.cov "$@"
docker-compose exec test env TENANCY_TEST_REDIS_TENANCY=1 TENANCY_TEST_REDIS_CLIENT=phpredis TENANCY_TEST_STORAGE_DRIVER=redis vendor/bin/phpunit --coverage-php coverage/1.cov "$@"
printf "Variant 2\n\n"
TENANCY_TEST_REDIS_TENANCY=0 TENANCY_TEST_REDIS_CLIENT=predis docker-compose exec test vendor/bin/phpunit --coverage-php coverage/2.cov "$@"
docker-compose exec test env TENANCY_TEST_REDIS_TENANCY=0 TENANCY_TEST_REDIS_CLIENT=predis TENANCY_TEST_STORAGE_DRIVER=redis vendor/bin/phpunit --coverage-php coverage/2.cov "$@"
printf "Variant 3\n\n"
docker-compose exec test env TENANCY_TEST_REDIS_TENANCY=1 TENANCY_TEST_REDIS_CLIENT=phpredis TENANCY_TEST_STORAGE_DRIVER=db vendor/bin/phpunit --coverage-php coverage/3.cov "$@"
docker-compose exec test vendor/bin/phpcov merge --clover clover.xml coverage/

View file

@ -86,7 +86,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/";

View file

@ -114,17 +114,19 @@ class CommandsTest extends TestCase
->expectsOutput('xyz');
}
// todo 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);
}
file_put_contents(app_path('Http/Kernel.php'), "<?php
\file_put_contents(app_path('Http/Kernel.php'), "<?php
namespace App\Http;
@ -210,6 +212,8 @@ class Kernel extends HttpKernel
->expectsQuestion('Do you want to publish the default database migration?', '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->assertDirectoryExists(database_path('migrations/tenant'));
$this->assertSame("<?php
namespace App\Http;

View file

@ -2,6 +2,8 @@
namespace Stancl\Tenancy\Tests;
use Stancl\Tenancy\DatabaseManager;
class DatabaseManagerTest extends TestCase
{
public $autoInitTenancy = false;
@ -17,4 +19,14 @@ class DatabaseManagerTest extends TestCase
$this->assertSame($old_connection_name, $new_connection_name);
$this->assertNotEquals('tenant', $new_connection_name);
}
/** @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']);
app(DatabaseManager::class)->createTenantConnection('foodb', 'fooconn');
$this->assertSame(config('database.connections.fooconn.database'), database_path('foodb'));
}
}

View file

@ -27,7 +27,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/";

View file

@ -16,9 +16,9 @@ class TenantAssetTest extends TestCase
$this->get(tenant_asset($filename))->assertSuccessful();
$this->assertFileExists($path);
$f = fopen($path, 'r');
$content = fread($f, filesize($path));
fclose($f);
$f = \fopen($path, 'r');
$content = \fread($f, \filesize($path));
\fclose($f);
$this->assertSame('bar', $content);
}

View file

@ -87,7 +87,7 @@ class TenantDatabaseManagerTest extends TestCase
config()->set('database.default', 'pgsql');
$db_name = strtolower('testdatabase' . $this->randomString(10));
$db_name = \strtolower('testdatabase' . $this->randomString(10));
$this->assertTrue(app(DatabaseManager::class)->create($db_name, 'pgsql'));
$this->assertNotEmpty(DB::select("SELECT datname FROM pg_database WHERE datname = '$db_name'"));
@ -104,7 +104,7 @@ class TenantDatabaseManagerTest extends TestCase
config()->set('database.default', 'pgsql');
$db_name = strtolower('testdatabase' . $this->randomString(10));
$db_name = \strtolower('testdatabase' . $this->randomString(10));
$databaseManagers = config('tenancy.database_managers');
$job = new QueuedTenantDatabaseCreator(app($databaseManagers['pgsql']), $db_name);

View file

@ -24,6 +24,9 @@ class TenantManagerTest extends TestCase
/** @test */
public function invoke_works()
{
tenant()->create('foo.localhost');
tenancy()->init('foo.localhost');
$this->assertSame(tenant('uuid'), tenant()('uuid'));
}

View file

@ -2,6 +2,10 @@
namespace Stancl\Tenancy\Tests;
use Stancl\Tenancy\Tenant;
use Stancl\Tenancy\StorageDrivers\RedisStorageDriver;
use Stancl\Tenancy\StorageDrivers\DatabaseStorageDriver;
class TenantStorageTest extends TestCase
{
/** @test */
@ -37,7 +41,7 @@ class TenantStorageTest extends TestCase
{
$keys = ['foo', 'abc'];
$vals = ['bar', 'xyz'];
$data = array_combine($keys, $vals);
$data = \array_combine($keys, $vals);
tenancy()->put($data);
@ -72,13 +76,13 @@ class TenantStorageTest extends TestCase
$keys = ['foo', 'abc'];
$vals = ['bar', 'xyz'];
$data = array_combine($keys, $vals);
$data = \array_combine($keys, $vals);
tenancy()->put($data, null, $uuid);
$this->assertSame($vals, tenancy()->get($keys, $uuid));
$this->assertNotSame($vals, tenancy()->get($keys));
$this->assertFalse(array_intersect($data, tenant()->tenant) == $data); // assert array not subset
$this->assertFalse(\array_intersect($data, tenant()->tenant) == $data); // assert array not subset
}
/** @test */
@ -111,4 +115,41 @@ class TenantStorageTest extends TestCase
$this->assertSame($value, tenancy()->put($value));
}
/** @test */
public function correct_storage_driver_is_used()
{
if (config('tenancy.storage_driver') == DatabaseStorageDriver::class) {
$this->assertSame('DatabaseStorageDriver', class_basename(tenancy()->storage));
} elseif (config('tenancy.storage_driver') == RedisStorageDriver::class) {
$this->assertSame('RedisStorageDriver', class_basename(tenancy()->storage));
}
}
/** @test */
public function data_is_stored_with_correct_data_types()
{
tenancy()->put('someBool', false);
$this->assertSame('boolean', \gettype(tenancy()->get('someBool')));
tenancy()->put('someInt', 5);
$this->assertSame('integer', \gettype(tenancy()->get('someInt')));
tenancy()->put('someDouble', 11.40);
$this->assertSame('double', \gettype(tenancy()->get('someDouble')));
tenancy()->put('string', 'foo');
$this->assertSame('string', \gettype(tenancy()->get('string')));
}
/** @test */
public function tenant_model_uses_correct_connection()
{
config(['tenancy.storage.db.connection' => 'foo']);
$this->assertSame('foo', (new Tenant)->getConnectionName());
config(['tenancy.storage.db.connection' => null]);
config(['database.default' => 'foobar']);
$this->assertSame('foobar', (new Tenant)->getConnectionName());
}
}

View file

@ -3,19 +3,14 @@
namespace Stancl\Tenancy\Tests;
use Illuminate\Support\Facades\Redis;
use Stancl\Tenancy\StorageDrivers\RedisStorageDriver;
use Stancl\Tenancy\StorageDrivers\DatabaseStorageDriver;
abstract class TestCase extends \Orchestra\Testbench\TestCase
{
public $autoCreateTenant = true;
public $autoInitTenancy = true;
private function checkRequirements(): void
{
parent::checkRequirements();
dd($this->getAnnotations());
}
/**
* Setup the test environment.
*
@ -28,6 +23,12 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
Redis::connection('tenancy')->flushdb();
Redis::connection('cache')->flushdb();
$this->loadMigrationsFrom([
'--path' => \realpath(__DIR__ . '/../assets/migrations'),
'--database' => 'central',
]);
config(['database.default' => 'sqlite']); // fix issue caused by loadMigrationsFrom
if ($this->autoCreateTenant) {
$this->createTenant();
}
@ -37,6 +38,13 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
}
}
protected function tearDown(): void
{
// config(['database.default' => 'central']);
parent::tearDown();
}
public function createTenant($domain = 'localhost')
{
tenant()->create($domain);
@ -55,10 +63,12 @@ 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'));
$app['config']->set([
'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'),
@ -72,6 +82,10 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
'database' => env('TENANCY_TEST_REDIS_DB', 14),
'prefix' => 'abc', // todo unrelated to tenancy, but this doesn't seem to have an effect? try to replicate in a fresh laravel installation
],
'database.connections.central' => [
'driver' => 'sqlite',
'database' => database_path('central.sqlite'),
],
'tenancy.database' => [
'based_on' => 'sqlite',
'prefix' => 'tenant',
@ -90,11 +104,27 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
'tenancy.redis.prefixed_connections' => ['default'],
'tenancy.migrations_directory' => database_path('../migrations'),
]);
if (env('TENANCY_TEST_STORAGE_DRIVER', 'redis') === 'redis') {
$app['config']->set([
'tenancy.storage_driver' => RedisStorageDriver::class,
]);
tenancy()->storage = $app->make(RedisStorageDriver::class);
} elseif (env('TENANCY_TEST_STORAGE_DRIVER', 'redis') === 'db') {
$app['config']->set([
'tenancy.storage_driver' => DatabaseStorageDriver::class,
]);
tenancy()->storage = $app->make(DatabaseStorageDriver::class);
}
}
protected function getPackageProviders($app)
{
return [\Stancl\Tenancy\TenancyServiceProvider::class];
return [
\Stancl\Tenancy\TenancyServiceProvider::class,
];
}
protected function getPackageAliases($app)
@ -130,7 +160,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
public function randomString(int $length = 10)
{
return substr(str_shuffle(str_repeat($x = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', ceil($length / strlen($x)))), 1, $length);
return \substr(\str_shuffle(\str_repeat($x = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', \ceil($length / \strlen($x)))), 1, $length);
}
public function isContainerized()
@ -140,6 +170,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);
}
}