diff --git a/.gitattributes b/.gitattributes index 338700a..8d6cfb7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,12 +6,9 @@ /.gitattributes export-ignore /.github export-ignore /.gitignore export-ignore -/.php-cs-fixer.dist.php export-ignore -/.php_cs.dist export-ignore /bin export-ignore /docs export-ignore -/phpunit.xml.dist export-ignore +/phpstan.neon.dist export-ignore /phpunit.xml.dist export-ignore /pint.json export-ignore -/psalm.xml.dist export-ignore /tests export-ignore diff --git a/.github/workflows/larastan.yml b/.github/workflows/larastan.yml new file mode 100644 index 0000000..f860a80 --- /dev/null +++ b/.github/workflows/larastan.yml @@ -0,0 +1,26 @@ +name: Larastan + +on: + push: + paths: + - '**.php' + - 'phpstan.neon.dist' + +jobs: + larastan: + name: larastan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + + - name: Install dependencies + uses: ramsey/composer-install@v2 + + - name: Run Larastan + run: ./vendor/bin/phpstan --error-format=github diff --git a/.github/workflows/markdown-normalize.yml b/.github/workflows/markdown-normalize.yml index c69a3d1..bca4793 100644 --- a/.github/workflows/markdown-normalize.yml +++ b/.github/workflows/markdown-normalize.yml @@ -5,6 +5,9 @@ on: paths: - "*.md" +permissions: + contents: write + jobs: normalize: timeout-minutes: 1 diff --git a/.github/workflows/pint.yml b/.github/workflows/pint.yml index e8f2d06..4b12f4a 100644 --- a/.github/workflows/pint.yml +++ b/.github/workflows/pint.yml @@ -1,34 +1,38 @@ name: PHP Linting (Pint) on: - workflow_dispatch: - push: - branches-ignore: - - 'dependabot/npm_and_yarn/*' + workflow_dispatch: + push: + branches-ignore: + - 'dependabot/npm_and_yarn/*' + +permissions: + contents: write jobs: - phplint: - runs-on: ubuntu-latest + phplint: + runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 2 + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 2 + ref: ${{ github.head_ref }} - - name: Laravel pint - uses: aglipanci/laravel-pint-action@2.3.0 - with: - preset: laravel + - name: Laravel pint + uses: aglipanci/laravel-pint-action@2.3.0 + with: + preset: laravel - - name: Extract branch name - shell: bash - run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" - id: extract_branch + - name: Extract branch name + shell: bash + run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" + id: extract_branch - - name: Commit Changes - uses: stefanzweifel/git-auto-commit-action@v4 - with: - commit_message: PHP Linting (Pint) - branch: ${{ steps.extract_branch.outputs.branch }} - skip_fetch: true + - name: Commit Changes + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: PHP Linting (Pint) + branch: ${{ steps.extract_branch.outputs.branch }} + skip_fetch: true diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 8ffe6d7..6400213 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -2,11 +2,11 @@ name: Tests on: push: - paths-ignore: - - '**.md' - pull_request: - paths-ignore: - - '**.md' + paths: + - '**.php' + - 'phpunit.xml.dist' + - '.github/workflows/run-tests.yml' + - 'composer.json' jobs: test: @@ -14,23 +14,12 @@ jobs: strategy: fail-fast: true matrix: - php: [8.2, 8.1] - laravel: [10.*, 9.*, 8.*,] + php: [8.3, 8.2, 8.1] + laravel: [10.*] dependency-version: [prefer-lowest, prefer-stable] include: - laravel: 10.* testbench: 8.* - - laravel: 9.* - testbench: 7.* - - laravel: 8.* - testbench: ^6.23 - exclude: - - laravel: 8.* - php: 8.2 - dependency-version: prefer-lowest - - laravel: 9.* - php: 8.2 - dependency-version: prefer-lowest name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} @@ -59,4 +48,4 @@ jobs: run: composer show -D - name: Execute tests - run: vendor/bin/pest -p + run: vendor/bin/pest --stop-on-failure --parallel --display-skipped diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml deleted file mode 100644 index d5e6270..0000000 --- a/.github/workflows/stale.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: "Close stale issues" -on: - schedule: - - cron: "23 12 * * *" - -jobs: - stale: - runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write - - steps: - - uses: actions/stale@v5 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - stale-issue-message: "This issue is stale because it has been open 21 days with no activity. Remove stale label or comment or this will be closed in 7 days." - stale-issue-label: "stale" - stale-pr-message: "This PR is stale because it has been open for 21 days with no activity. Remove stale label or comment or this will be closed in 7 days." - stale-pr-label: "stale" - exempt-issue-labels: "enhancement,help wanted" - days-before-stale: 21 - days-before-close: 7 diff --git a/.gitignore b/.gitignore index 3b01ce5..6275139 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ .idea +.php-cs-fixer.cache .php_cs .php_cs.cache -.php-cs-fixer.cache .phpunit.result.cache build composer.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index a769087..9849b14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,10 @@ All notable changes to `laravel-settings` will be documented in this file ### What's Changed -- Bump creyD/prettier_action from 4.2 to 4.3 by @dependabot in https://github.com/rawilk/laravel-settings/pull/15 -- Bump aglipanci/laravel-pint-action from 2.1.0 to 2.2.0 by @dependabot in https://github.com/rawilk/laravel-settings/pull/17 -- Add Laravel 10.x Support by @rawilk in https://github.com/rawilk/laravel-settings/pull/18 -- Add Php 8.2 compatibility by @rawilk in https://github.com/rawilk/laravel-settings/pull/19 +- Bump creyD/prettier_action from 4.2 to 4.3 by @dependabot in https://github.com/rawilk/laravel-settings/pull/15 +- Bump aglipanci/laravel-pint-action from 2.1.0 to 2.2.0 by @dependabot in https://github.com/rawilk/laravel-settings/pull/17 +- Add Laravel 10.x Support by @rawilk in https://github.com/rawilk/laravel-settings/pull/18 +- Add Php 8.2 compatibility by @rawilk in https://github.com/rawilk/laravel-settings/pull/19 **Full Changelog**: https://github.com/rawilk/laravel-settings/compare/v2.2.1...v2.2.2 @@ -17,10 +17,10 @@ All notable changes to `laravel-settings` will be documented in this file ### What's Changed -- Bump dependabot/fetch-metadata from 1.3.5 to 1.3.6 by @dependabot in https://github.com/rawilk/laravel-settings/pull/13 -- Bump aglipanci/laravel-pint-action from 1.0.0 to 2.1.0 by @dependabot in https://github.com/rawilk/laravel-settings/pull/10 -- Improve internal handling of the Context object on Settings service class -- Prevent decryption errors when checking if a value should be persisted or not on `set()` +- Bump dependabot/fetch-metadata from 1.3.5 to 1.3.6 by @dependabot in https://github.com/rawilk/laravel-settings/pull/13 +- Bump aglipanci/laravel-pint-action from 1.0.0 to 2.1.0 by @dependabot in https://github.com/rawilk/laravel-settings/pull/10 +- Improve internal handling of the Context object on Settings service class +- Prevent decryption errors when checking if a value should be persisted or not on `set()` **Full Changelog**: https://github.com/rawilk/laravel-settings/compare/v2.2.0...v2.2.1 @@ -28,7 +28,7 @@ All notable changes to `laravel-settings` will be documented in this file ### What's Changed -- Allow cache to be temporarily disabled (via `temporarilyDisableCache()`) +- Allow cache to be temporarily disabled (via `temporarilyDisableCache()`) **Full Changelog**: https://github.com/rawilk/laravel-settings/compare/v2.1.1...v2.2.0 @@ -36,9 +36,9 @@ All notable changes to `laravel-settings` will be documented in this file ### What's Changed -- Bump dependabot/fetch-metadata from 1.3.4 to 1.3.5 by @dependabot in https://github.com/rawilk/laravel-settings/pull/8 -- Bump actions/checkout from 2 to 3 by @dependabot in https://github.com/rawilk/laravel-settings/pull/9 -- Prevent non-strings from being unserialized or decrypted +- Bump dependabot/fetch-metadata from 1.3.4 to 1.3.5 by @dependabot in https://github.com/rawilk/laravel-settings/pull/8 +- Bump actions/checkout from 2 to 3 by @dependabot in https://github.com/rawilk/laravel-settings/pull/9 +- Prevent non-strings from being unserialized or decrypted **Full Changelog**: https://github.com/rawilk/laravel-settings/compare/v2.1.0...v2.1.1 @@ -46,13 +46,13 @@ All notable changes to `laravel-settings` will be documented in this file ### Added -- Feature: model settings by @rawilk in https://github.com/rawilk/laravel-settings/pull/7 +- Feature: model settings by @rawilk in https://github.com/rawilk/laravel-settings/pull/7 ### Changed -- Composer: Update doctrine/dbal requirement from ^2.12 to ^3.5 by @dependabot in https://github.com/rawilk/laravel-settings/pull/5 -- Bump creyD/prettier_action from 3.0 to 4.2 by @dependabot in https://github.com/rawilk/laravel-settings/pull/6 -- Drop official PHP 8.0 support +- Composer: Update doctrine/dbal requirement from ^2.12 to ^3.5 by @dependabot in https://github.com/rawilk/laravel-settings/pull/5 +- Bump creyD/prettier_action from 3.0 to 4.2 by @dependabot in https://github.com/rawilk/laravel-settings/pull/6 +- Drop official PHP 8.0 support **Full Changelog**: https://github.com/rawilk/laravel-settings/compare/v2.0.1...v2.1.0 @@ -60,39 +60,39 @@ All notable changes to `laravel-settings` will be documented in this file ### Updated -- Add support for Laravel 9.* -- Add support for PHP 8.1 +- Add support for Laravel 9.\* +- Add support for PHP 8.1 ## 2.0.0 - 2020-12-01 ### Breaking Changes -- Drop support for Laravel v6 and v7 -- Drop support for php 7 +- Drop support for Laravel v6 and v7 +- Drop support for php 7 ### Updated -- Add support for php 8 -- Update some of code base to use php 8 features +- Add support for php 8 +- Update some of code base to use php 8 features ## 1.0.3 - 2020-10-26 ### Fixed -- Fix bug with context being reset when saving ([#3](https://github.com/rawilk/laravel-settings/issues/3)) +- Fix bug with context being reset when saving ([#3](https://github.com/rawilk/laravel-settings/issues/3)) ## 1.0.2 - 2020-10-09 ### Fixed -- Wrap decrypting values in a try/catch to help prevent decryption errors when caching is used - [#2](https://github.com/rawilk/laravel-settings/issues/2) +- Wrap decrypting values in a try/catch to help prevent decryption errors when caching is used - [#2](https://github.com/rawilk/laravel-settings/issues/2) ## 1.0.1 - 2020-09-09 ### Added -- Add support for Laravel 8 +- Add support for Laravel 8 ## 1.0.0 - 2020-08-02 -- initial release +- initial release diff --git a/README.md b/README.md index c5830bc..2b20096 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,10 @@ If you discover any security related issues, please email randall@randallwilk.de - [Randall Wilk](https://github.com/rawilk) - [All Contributors](../../contributors) +## Alternatives + +- [spatie/laravel-settings](https://github.com/spatie/laravel-settings) + ## Disclaimer This package is not affiliated with, maintained, authorized, endorsed or sponsored by Laravel or any of its affiliates. diff --git a/composer.json b/composer.json index 19d4af7..be4c176 100644 --- a/composer.json +++ b/composer.json @@ -19,21 +19,18 @@ } ], "require": { - "php": "^8.0|^8.1|^8.2", - "illuminate/database": "^8.0|^9.0|^10.0", - "illuminate/support": "^8.0|^9.0|^10.0", - "spatie/laravel-package-tools": "^1.2|^1.13" + "php": "^8.1|^8.2|^8.3", + "illuminate/database": "^10.0", + "illuminate/support": "^10.0", + "spatie/laravel-package-tools": "^1.13" }, "require-dev": { - "doctrine/dbal": "^3.5", "laravel/pint": "^1.2", - "mockery/mockery": "^1.4.2", - "orchestra/testbench": "^6.5|^7.0|^8.0", - "pestphp/pest": "^1.22", - "pestphp/pest-plugin-laravel": "^1.3", - "pestphp/pest-plugin-parallel": "^1.0|^1.2", - "phpunit/phpunit": "^9.4", - "spatie/laravel-ray": "^1.0|^1.31" + "nunomaduro/larastan": "^2.6", + "orchestra/testbench": "^8.0", + "pestphp/pest": "^2.10", + "pestphp/pest-plugin-laravel": "^2.2", + "spatie/laravel-ray": "^1.31" }, "autoload": { "psr-4": { @@ -52,8 +49,9 @@ "post-autoload-dump": [ "@php ./vendor/bin/testbench package:discover --ansi" ], + "analyse": "vendor/bin/phpstan analyse", "test": "vendor/bin/pest -p", - "format": "vendor/bin/pint" + "format": "vendor/bin/pint --dirty" }, "config": { "sort-packages": true, diff --git a/config/settings.php b/config/settings.php index 6f8abab..00fd427 100644 --- a/config/settings.php +++ b/config/settings.php @@ -1,5 +1,7 @@ \Rawilk\Settings\Models\Setting::class, ], ], + + /* + |-------------------------------------------------------------------------- + | Teams + |-------------------------------------------------------------------------- + | + | When set to true the package implements teams using the `team_foreign_key`. + | + | If you want the migrations to register the `team_foreign_key`, you must + | set this to true before running the migration. + | + | If you already ran the migrations, then you must make a new migration to + | add the `team_foreign_key` column to the settings table, and update the + | unique constraint on the table. See the `add_settings_team_field` migration + | for how to do this. + | + */ + 'teams' => false, + + /* + |-------------------------------------------------------------------------- + | Team Foreign Key + |-------------------------------------------------------------------------- + | + | When teams is set to true, our database/eloquent drivers will use this + | column as a team foreign key to scope queries to. + | + | The team id will also be included in a cache key when caching is enabled. + | + */ + 'team_foreign_key' => 'team_id', + + /* + |-------------------------------------------------------------------------- + | Context Serializer + |-------------------------------------------------------------------------- + | + | The context serializer is responsible for converting a Context object + | into a string, which gets appended to a setting key in the database. + | + | Any custom serializer you use must implement the + | \Rawilk\Settings\Contracts\ContextSerializer interface. + | + | Supported: + | - \Rawilk\Settings\Support\ContextSerializers\ContextSerializer (default) + | - \Rawilk\Settings\Support\ContextSerializers\DotNotationContextSerializer + | + */ + 'context_serializer' => \Rawilk\Settings\Support\ContextSerializers\ContextSerializer::class, + + /* + |-------------------------------------------------------------------------- + | Key Generator + |-------------------------------------------------------------------------- + | + | The key generator is responsible for generating a suitable key for a + | setting. + | + | Any custom key generator you use must implement the + | \Rawilk\Settings\Contracts\KeyGenerator interface. + | + | Supported: + | - \Rawilk\Settings\Support\KeyGenerators\ReadableKeyGenerator + | - \Rawilk\Settings\Support\KeyGenerators\Md5KeyGenerator (default) + | + */ + 'key_generator' => \Rawilk\Settings\Support\KeyGenerators\Md5KeyGenerator::class, + + /* + |-------------------------------------------------------------------------- + | Value Serializer + |-------------------------------------------------------------------------- + | + | By default, we use php's serialize() and unserialize() functions to + | prepare the setting values for storage. You may use the `JsonValueSerializer` + | instead if you want to store the values as json instead. + | + | Any custom value serializer you use must implement the + | \Rawilk\Settings\Contracts\ValueSerializer interface. + | + */ + 'value_serializer' => \Rawilk\Settings\Support\ValueSerializers\ValueSerializer::class, ]; diff --git a/database/migrations/add_settings_team_field.php.stub b/database/migrations/add_settings_team_field.php.stub new file mode 100644 index 0000000..a4ed83c --- /dev/null +++ b/database/migrations/add_settings_team_field.php.stub @@ -0,0 +1,29 @@ +unsignedBigInteger(config('settings.team_foreign_key'))->nullable()->after('id'); + $table->index(config('settings.team_foreign_key'), 'settings_team_id_index'); + + $table->dropUnique('settings_key_unique'); + + $table->unique([ + 'key', + config('settings.team_foreign_key'), + ]); + }); + } +}; diff --git a/docs/_index.md b/docs/_index.md index b0dd595..fc121f4 100644 --- a/docs/_index.md +++ b/docs/_index.md @@ -1,5 +1,5 @@ --- -title: v2 +title: v3 slogan: Store Laravel application settings in the database. githubUrl: https://github.com/rawilk/laravel-settings branch: main diff --git a/docs/advanced-usage/custom-drivers.md b/docs/advanced-usage/custom-drivers.md index f7c2513..752944e 100644 --- a/docs/advanced-usage/custom-drivers.md +++ b/docs/advanced-usage/custom-drivers.md @@ -32,17 +32,27 @@ Any custom drivers you make must implement the `Rawilk\Settings\Contracts\Driver the interface looks like: ```php + {note} The `ReadableKeyGenerator` key generator (or a custom one) is required for using the `all` and `flush` methods on the `Settings` facade, as well as flushing +> a model's settings when it is deleted. + +If you'd like to use your own KeyGenerator, you may do so by implementing the `Rawilk\Settings\Contracts\KeyGenerator` interface. Here is what the interface looks like: + +Here's what a custom key generator might look like: + +```php +use Rawilk\Settings\Contracts\KeyGenerator; +use Rawilk\Settings\Contracts\ContextSerializer; +use Rawilk\Settings\Support\Context; +use Illuminate\Support\Str; + +class CustomKeyGenerator implements KeyGenerator +{ + protected ContextSerializer $contextSerializer; + + public function generate(string $key, Context $context = null): string + { + $key = strtoupper($key); + + if ($context) { + $key .= $this->contextPrefix() . $this->serializer->serialize($context); + } + + return $key; + } + + public function removeContextFromKey(string $key): string + { + return Str::before($key, $this->contextPrefix()); + } + + public function setContextSerializer(ContextSerializer $serializer): self + { + $this->serializer = $serializer; + + return $this; + } + + /** + * This prefix is how we will determine that a database record has a context when + * flushing/retrieving all settings from the setting drivers. + */ + public function contextPrefix(): string + { + return '|context|'; + } +} +``` + +Notice that the class requires a `ContextSerializer` object to be passed into a setter. This kind of generator is responsible for converting the context object into a string +suitable for storage. See [ContextSerializer](#user-content-contextserializer) for more information. + +After defining your class, you need to add it the settings config file: + +```php +// config/settings.php +'key_generator' => CustomKeyGenerator::class, +``` + +## ContextSerializer + +The context serializer is responsible for taking a `Rawilk\Settings\Support\Context` object and converting it into a string suitable for storage. By default, the package will use +the `ContextSerializer` class, which will use php's `serialize` method to convert the context into a string. If you're using the `ReadableKeyGenerator`, or a custom one of your own, +we recommend using the `DotNotationContextSerializer` class instead, which doesn't rely on php's `serialize` method. You may also make your own context serializer by implementing the +`Rawilk\Settings\Contracts\ContextSerializer` interface. Here is what a custom context serializer might look like: + +```php +use Rawilk\Settings\Contracts\ContextSerializer; +use Rawilk\Settings\Support\Context; + +class CustomContextSerializer implements ContextSerializer +{ + public function serialize(Context $context = null): string + { + if (is_null($context)) { + return ''; + } + + return json_encode($context->toArray()); + } +} +``` + +After defining your class, you need to add it the settings config file: + +```php +// config/settings.php +'context_serializer' => CustomContextSerializer::class, +``` + +## ValueSerializer + +The value serializer is responsible for preparing a value for storage. By default, the package uses the `ValueSerializer` class, which will use php's `serialize` method to convert +the value into a string, and then `unserialize` to return the original value. You can alternatively use the `JsonValueSerializer` class, which will use php's `json_encode` and `json_decode` +instead. + +You are also free to create your own value serializer by implementing the `Rawilk\Settings\Contracts\ValueSerializer` interface. Here is what a custom value serializer might look like: + +```php +use Rawilk\Settings\Contracts\ValueSerializer; + +class CustomValueSerializer implements ValueSerializer +{ + public function serialize($value): string + { + return json_encode($value); + } + + public function unserialize(string $serialized): mixed + { + return json_decode($serialized, true); + } +} +``` + +After defining your class, you need to add it the settings config file: + +```php +// config/settings.php +'value_serializer' => CustomValueSerializer::class, +``` diff --git a/docs/advanced-usage/custom-model.md b/docs/advanced-usage/custom-model.md index 728328e..9b1e4e3 100644 --- a/docs/advanced-usage/custom-model.md +++ b/docs/advanced-usage/custom-model.md @@ -9,17 +9,27 @@ the package's model, or create your own. The only requirement is that it impleme Here is what the interface looks like: ```php + + */ +public function all($keys = null): \Illuminate\Support\Collection +``` + ### has ```php @@ -94,3 +106,27 @@ public function isFalse(string $key, $default = false): bool */ public function isTrue(string $key, $default = true): bool ``` + +### flush + +```php +/** + * Flush all settings from storage. + * + * @param array|string|null $keys Only flush a subset of settings. + * @return void + */ +public function flush($keys = null): void +``` + +### cacheKeyForSetting + +```php +/** + * Get the correct cache key for a given setting. + * + * @param string $key + * @return string + */ +public function cacheKeyForSetting(string $key): string +``` diff --git a/docs/basic-usage/basic-usage.md b/docs/basic-usage/basic-usage.md index 9605fc8..440c868 100644 --- a/docs/basic-usage/basic-usage.md +++ b/docs/basic-usage/basic-usage.md @@ -49,3 +49,42 @@ Settings::isFalse('app.debug'); // false Settings::set('app.debug', false); Settings::isFalse('app.debug'); // true ``` + +## Retrieve all settings + +Using `all`, you can retrieve all the stored settings, which will be returned as a collection. You may also retrieve a subset of settings +by passing in an array of keys to retrieve. + +```php +Settings::all(); + +// Subset of settings +Settings::all(['foo', 'bar']); +``` + +The collection of settings returned from this method will contain objects structured like this: + +```json +{ + "id": 1, + "key": "foo", + "value": "bar", + "original_key": "foo" +} +``` + +> {tip} The `original_key` property is set by settings to reflect the key that is used in the database. + +> {tip} If you'd like to retrieve all settings that do not have a context, you may provide a `false` value for a context: `settings()->context(false)->all()` + +## Flushing settings + +Multiple settings can be deleted at one time using the `flush` method on settings. It works similar to `forget`, however +it is not able to flush the cache for each setting, unless you pass in a subset of keys to flush. + +```php +Settings::flush(); + +// Flush a subset of settings +Settings::flush(['foo', 'bar']); +``` diff --git a/docs/basic-usage/contextual-settings.md b/docs/basic-usage/contextual-settings.md index fe85282..13938a6 100644 --- a/docs/basic-usage/contextual-settings.md +++ b/docs/basic-usage/contextual-settings.md @@ -19,4 +19,7 @@ Settings::context($userContext)->isTrue('notifications'); // true Settings::context($user2Context)->isTrue('notifications'); // false ``` -> {tip} You can put anything you want in context, as long as it's in array form. +> {tip} You can put anything you want in context, as long as it's in array form, however the values must be numeric, strings, or booleans. + +If you're looking to scope settings globally to a team or tenant, check out the [teams](/docs/laravel-settings/{version}/basic-usage/teams) documentation for more information. This can be much easier +than using the `Context` all the time for multi-tenant or team based applications. diff --git a/docs/basic-usage/events.md b/docs/basic-usage/events.md new file mode 100644 index 0000000..28e5547 --- /dev/null +++ b/docs/basic-usage/events.md @@ -0,0 +1,99 @@ +--- +title: Events +sort: 5 +--- + +## Introduction + +New in v3, the settings service now fires events after certain operations. You may listen for these events in your application +to execute any additional code. Below are all the events this package dispatches. + +## SettingsFlushed + +The `SettingsFlushed` event is fired when `Settings::flush()` is called, and receives the keys that were flushed, if any, along +with the current team id and context object. + +Here is the signature of the event: + +```php +namespace Rawilk\Settings\Events; + +use Illuminate\Foundation\Events\Dispatchable; +use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Collection; +use Rawilk\Settings\Support\Context; + +final class SettingsFlushed +{ + use Dispatchable; + use SerializesModels; + + public function __construct( + public bool|Collection|string $keys, + public mixed $teamId, + public bool|Context|null $context, + ) { + } +} +``` + +## SettingWasDeleted + +The `SettingWasDeleted` event is fired anytime a single setting is deleted, using `Settings::forget()`. It receives the key that was deleted +along with the current team id and context object. The event will also receive the key that is used for storage, and the cache key for that setting. + +Here is the signature of the event: + +```php +namespace Rawilk\Settings\Events; + +use Illuminate\Foundation\Events\Dispatchable; +use Illuminate\Queue\SerializesModels; +use Rawilk\Settings\Support\Context; + +final class SettingWasDeleted +{ + use Dispatchable; + use SerializesModels; + + public function __construct( + public string $key, + public string $storageKey, + public string $cacheKey, + public mixed $teamId, + public bool|Context|null $context, + ) { + } +} +``` + +## SettingWasStored + +The `SettingWasStored` event is fired when a setting is persisted to the database using `Settings::set()`. It will receive the key that was stored, the value, and the current team +id and context object. The storage key and cache key for that setting will also be provided to the event. + +Here is the signature of the event: + +```php +use Illuminate\Foundation\Events\Dispatchable; +use Illuminate\Queue\SerializesModels; +use Rawilk\Settings\Support\Context; + +final class SettingWasStored +{ + use Dispatchable; + use SerializesModels; + + public function __construct( + public string $key, + public string $storageKey, + public string $cacheKey, + public mixed $value, + public mixed $teamId, + public bool|Context|null $context, + ) { + } +} +``` + +> {note} This event **will not be fired** if the setting exists and the value is not changed if you have caching enabled. diff --git a/docs/basic-usage/model-settings.md b/docs/basic-usage/model-settings.md index 1a8d54f..00db84f 100644 --- a/docs/basic-usage/model-settings.md +++ b/docs/basic-usage/model-settings.md @@ -49,3 +49,29 @@ protected function contextArguments(): array ]; } ``` + +## Deleting Models + +When a model is deleted, the trait registers an event listener for the model's `deleted` event, which will flush all settings for that model. +If you wish to disable this behavior, you can simply set a static boolean `$flushSettingsOnDelete` property on your model to `false`. + +```php +namespace App\Models; + +use Rawilk\Settings\Models\HasSettings; +use Illuminate\Database\Eloquent\Model; + +class User extends Model +{ + use HasSettings; + + protected static bool $flushSettingsOnDelete = false; +} +``` + +If you are using soft-deletes on your model, you may need to disable this behavior as well, and manually flush the model's settings +when you force delete it. + +> {note} This will only work when the `ReadableKeyGenerator` is used. + +For more information on the key generators, see [Custom Generators](/docs/laravel-settings/{version}/advanced-usage/custom-generators). diff --git a/docs/basic-usage/teams.md b/docs/basic-usage/teams.md new file mode 100644 index 0000000..7c74fdd --- /dev/null +++ b/docs/basic-usage/teams.md @@ -0,0 +1,129 @@ +--- +title: Teams +sort: 2 +--- + +## Introduction + +As of v3, `laravel-settings` supports using teams or multi-tenancy. This means you can have settings scoped to a team, and retrieve them by team. +By default, teams are disabled, however you can easily enable them by setting the `teams` config option to `true`. + +## Enabling the Teams Feature + +> {info} These configuration changes must be made **before** running the migrations when first installing the package or when upgrading from v2. +> +> If you have already run the migrations and want to upgrade your implementation, you can add a migration and copy the contents of the `add_settings_team_field` migration from the package +> after you make the configuration changes below. + +To enable teams, you must enable it in the settings config file: + +```php +// config/settings.php +'teams' = true, +``` + +If you want to use a custom foreign key for teams, you can also set it in the config file: + +```php +// config/settings.php +'team_foreign_key' => 'custom_team_id', +``` + +## Working with Teams Settings + +After implementing a solution for selecting a team on the authentication process (for example, setting the `team_id` +of the currently selected team on the **session:** `session(['team_id' => $team->id]);`), we can set the global `team_id` +from anywhere, but we recommend setting it in a middleware. + +Example Team Middleware: + +```php +namespace App\Http\Middleware; + +use Rawilk\Settings\Facades\Settings; + +class TeamMiddleware +{ + public function handle($request, Closure $next) + { + if (auth()->check()) { + Settings::setTeamId(session('team_id')); + } + + // Other custom ways to get the team id + /* if (! empty(auth('api')->user())) { + // `getTeamIdFromToken()` example of custom method for getting the set team_id + Settings::setTeamId( + auth('api')->user()->getTeamIdFromToken() + ); + } */ + + return $next($request); + } +} +``` + +> {note} You must add your custom middleware to the `web` middleware group in `app/Http/Kernel.php`, or any other middleware groups +> that you want to use it in. + +## Storing/Retrieving Team Settings + +With the team id set on `Settings`, the service will automatically set the team id on any settings that are persisted or retrieved from the database. + +## Changing the Active Team ID + +While your middleware may set the current team id for settings, you may need to change it later to another team for various reasons. The two most common +reasons are: + +### Switching Teams After Login + +If your application allows the user to switch between various teams which they belong to, you can activate the team id for settings by calling the `setTeamId` method: + +```php +Settings::setTeamId($teamId)->get(...); +``` + +### Administering Team Details + +You may have created a user management page where you can view the settings of users on certain teams. For managing that user +in each team they belong to, you must use the `setTeamId` method on `Settings` to cause settings to be scoped +for that specific team. + +If you need to switch back to the current team id, you can use the `getTeamId` method on `Settings`. + +```php +$currentTeamId = Settings::getTeamId(); + +Settings::setTeamId($teamId)->set(...); + +// Revert back to original team id of request. +Settings::setTeamId($currentTeamId); +``` + +> {tip} You can pass in an eloquent model to `setTeamId` instead of an id if you prefer. + +## Contextual Settings + +The `Context` object can be used in conjunction with teams for further scoping of settings. The most common scenario for this would be if you have a +multi-tenant application, and you want to have user-specific settings for each tenant, you can use both teams and context. + +Let's say the user has a timezone configured differently for each tenant. In tenant 1, the timezone is set to 'UTC' for the user, but in tenant 2 +the timezone is set to 'America/Chicago' for the user. Here's how you can combine context and teams to get those different setting values. + +```php +use Rawilk\Settings\Support\Context; +use Rawilk\Settings\Facades\Settings; + +$userContext = new Context(['user_id' => 1]); + +Settings::setTeamId(1); + +Settings::context($userContext)->get('timezone'); // UTC + +Settings::setTeamId(2); + +Settings::contest($userContext)->get('timezone'); // America/Chicago +``` + +This will also work with [model settings](/docs/laravel-settings/{version}/basic-usage/model-settings) as well. For more information on the +`Context` object, check out the [docs](/docs/laravel-settings/{version}/basic-usage/contextual-settings) here. diff --git a/docs/best-practices/performance-tips.md b/docs/best-practices/performance-tips.md index da57c0c..240dbbd 100644 --- a/docs/best-practices/performance-tips.md +++ b/docs/best-practices/performance-tips.md @@ -8,11 +8,14 @@ You are free to turn caching off, but you might notice a performance hit on larg on apps that are retrieving many settings on each page load. As always, if you choose to bypass the provided methods for setting and removing settings, you will need to flush the cache manually for -each setting you manipulate manually. To determine the cache key for a setting key, you should use the `Rawilk\Settings\Support\KeyGenerator` to -generate the md5 version of the setting's key: +each setting you manipulate manually. To determine the cache key for a setting key, you should use the `cacheKeyForSetting` method +on the Settings facade to generate the correct cache key for the setting: ```php -(new KeyGenerator(new ContextSerializer))->generate($key, $context); +$cacheKey = Settings::cacheKeyForSetting('foo'); + +// With context +$cacheKey = Settings::context(new Context(['id' => 1]))->cacheKeyForSetting('foo'); ``` -You will also need to prefix it with the `cache_key_prefix` found in `config/settings.php`. +> {tip} The `cacheKeyForSetting` method will take into account the current team id and context as well that is set on the settings service. diff --git a/docs/changelog.md b/docs/changelog.md index 3731ce9..b41d2bc 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,6 +1,6 @@ --- title: Changelog -sort: 5 +sort: 6 --- All notable changes for laravel-settings are documented [on GitHub](https://github.com/rawilk/laravel-settings/blob/main/CHANGELOG.md). diff --git a/docs/installation.md b/docs/installation.md index 5f2ca0e..f95b079 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -20,6 +20,9 @@ php artisan vendor:publish --tag="settings-migrations" php artisan migrate ``` +> {note} If you plan on using the [teams](/docs/laravel-settings/{version}/basic-usage/teams) feature, you need to publish the config first +> and enable the `teams` option before running the migrations. + ## Configuration You can publish the configuration file with: @@ -29,3 +32,19 @@ php artisan vendor:publish --tag="settings-config" ``` You can view the default configuration here: https://github.com/rawilk/laravel-settings/blob/{branch}/config/settings.php + +### Generators + +For backwards compatibility and to reduce the amount of breaking changes from v2, the package uses the `Md5KeyGenerator` class by default. This key generator generates +a md5 hash of a serialized setting key and context object combination. Using this generator, however, prevents you from using some new features, such as `all` and `flush` +on the settings facade, as well as flushing a model's settings when it is deleted. + +To use these features, you must use the new `ReadableKeyGenerator` class or a custom key generator class of your own. This key generator will not hash the setting key in any way, +allowing the package to search for settings easier by key, and partial searches by context. To use this key generator, you just need to update the settings config file: + +```php +// config/settings.php +'key_generator' => \Rawilk\Settings\Support\KeyGenerators\ReadableKeyGenerator::class, +``` + +For more information on the key generators, see the [Custom Generators](/docs/laravel-settings/{version}/advanced-usage/custom-generators) documentation. diff --git a/docs/introduction.md b/docs/introduction.md index c60adbd..c2c1ae4 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -28,6 +28,10 @@ settings()->get('foo'); settings('foo'); ``` +## Alternatives + +- [spatie/laravel-settings](https://github.com/spatie/laravel-settings) + ## Disclaimer This package is not affiliated with, maintained, authorized, endorsed or sponsored by Laravel or any of its affiliates. diff --git a/docs/questions-and-issues.md b/docs/questions-and-issues.md index 8a757c8..059d095 100644 --- a/docs/questions-and-issues.md +++ b/docs/questions-and-issues.md @@ -1,6 +1,6 @@ --- title: Questions & Issues -sort: 4 +sort: 5 --- Find yourself stuck using the package? Found a bug? Do you have general questions or suggestions for improving the package? diff --git a/docs/requirements.md b/docs/requirements.md index b0a8a9f..6f9e4fa 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -5,10 +5,8 @@ sort: 2 ## General Requirements -- PHP **8.1**1 or greater -- Laravel **8.0** or greater - -1 PHP 8.0 is not officially supported, however the package should still be able to run on that version. +- PHP **8.1** or greater +- Laravel **10.0** or greater ## Version Matrix @@ -16,6 +14,6 @@ sort: 2 | ------- | --------------- | --------------- | | 6.0 | 1.0.0 | 1.0.3 | | 7.0 | 1.0.0 | 1.0.3 | -| 8.0 | 1.0.1 | | -| 9.0 | 2.0.1 | | +| 8.0 | 1.0.1 | 2.2.2 | +| 9.0 | 2.0.1 | 2.2.2 | | 10.0 | 2.2.2 | | diff --git a/docs/upgrade.md b/docs/upgrade.md new file mode 100644 index 0000000..9af125e --- /dev/null +++ b/docs/upgrade.md @@ -0,0 +1,214 @@ +--- +title: Upgrade Guide +sort: 4 +--- + +# Upgrading from v2 to v3 + +> {info} I've attempted to document every possible breaking change, however there may be some issues I've missed. +> If you run into a breaking change not documented here, please submit a PR to the docs to update it. + +## Updating Dependencies + +### Laravel 10.0 required + +Laravel 10.0 is now required. If you are still using Laravel 8.0 or 9.0, you will need to upgrade to Laravel 10.0 before upgrading to v3. + +### PHP 8.1 required + +`laravel-settings` now requires PHP 8.1.0 or greater. + +## Updating Configuration + +### Config file changes + +The following configuration options should be added to your `config/settings.php` file if you have it published: + +```php + /* + |-------------------------------------------------------------------------- + | Teams + |-------------------------------------------------------------------------- + | + | When set to true the package implements teams using the `team_foreign_key`. + | + | If you want the migrations to register the `team_foreign_key`, you must + | set this to true before running the migration. + | + | If you already ran the migrations, then you must make a new migration to + | add the `team_foreign_key` column to the settings table, and update the + | unique constraint on the table. See the `add_settings_team_field` migration + | for how to do this. + | + */ + 'teams' => false, + + /* + |-------------------------------------------------------------------------- + | Team Foreign Key + |-------------------------------------------------------------------------- + | + | When teams is set to true, our database/eloquent drivers will use this + | column as a team foreign key to scope queries to. + | + | The team id will also be included in a cache key when caching is enabled. + | + */ + 'team_foreign_key' => 'team_id', + + /* + |-------------------------------------------------------------------------- + | Context Serializer + |-------------------------------------------------------------------------- + | + | The context serializer is responsible for converting a Context object + | into a string, which gets appended to a setting key in the database. + | + | Any custom serializer you use must implement the + | \Rawilk\Settings\Contracts\ContextSerializer interface. + | + | Supported: + | - \Rawilk\Settings\Support\ContextSerializers\ContextSerializer (default) + | - \Rawilk\Settings\Support\ContextSerializers\DotNotationContextSerializer + | + */ + 'context_serializer' => \Rawilk\Settings\Support\ContextSerializers\ContextSerializer::class, + + /* + |-------------------------------------------------------------------------- + | Key Generator + |-------------------------------------------------------------------------- + | + | The key generator is responsible for generating a suitable key for a + | setting. + | + | Any custom key generator you use must implement the + | \Rawilk\Settings\Contracts\KeyGenerator interface. + | + | Supported: + | - \Rawilk\Settings\Support\KeyGenerators\ReadableKeyGenerator + | - \Rawilk\Settings\Support\KeyGenerators\Md5KeyGenerator (default) + | + */ + 'key_generator' => \Rawilk\Settings\Support\KeyGenerators\Md5KeyGenerator::class, + + /* + |-------------------------------------------------------------------------- + | Value Serializer + |-------------------------------------------------------------------------- + | + | By default, we use php's serialize() and unserialize() functions to + | prepare the setting values for storage. You may use the `JsonValueSerializer` + | instead if you want to store the values as json instead. + | + | Any custom value serializer you use must implement the + | \Rawilk\Settings\Contracts\ValueSerializer interface. + | + */ + 'value_serializer' => \Rawilk\Settings\Support\ValueSerializers\ValueSerializer::class, +``` + +### Migrations + +With teams being supported in v3, a new migration has been added to add a `team_id` column to the settings table. If you are using +the database or eloquent drivers and plan on using teams, be sure to set the `teams` configuration option to `true`, and then publish +and run the new migration from the package: + +```bash +php artisan vendor:publish --tag="settings-migrations" +php artisan migrate +``` + +## Contracts + +Some of the interfaces have changed, so if you are using custom drivers or extending any of ours, be sure to update your code to be compatible with the +updated interfaces. + +### Driver Contract + +All the method signatures in the `Driver` interface have changed to accept a `$teamId = null` argument. There are also new methods added for `all()` and `flush()`. Here's +what the interface looks like now: + +```php + 1]))->cacheKeyForSetting('foo'); +``` diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..5e7ef9c --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,13 @@ +includes: + - ./vendor/nunomaduro/larastan/extension.neon + +parameters: + level: 4 + paths: + - src + - config + - database + tmpDir: build/phpstan + checkOctaneCompatibility: true + checkModelProperties: true + checkMissingIterableValueType: false diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 31628f5..7789bc4 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,14 +1,9 @@ - diff --git a/pint.json b/pint.json index 5b7dcca..2e326cc 100644 --- a/pint.json +++ b/pint.json @@ -7,6 +7,36 @@ "types_spaces": { "space": "none" }, - "single_trait_insert_per_statement": true + "combine_consecutive_issets": true, + "combine_consecutive_unsets": true, + "declare_parentheses": true, + "declare_strict_types": true, + "explicit_string_variable": true, + "single_trait_insert_per_statement": true, + "ordered_class_elements": { + "order": [ + "use_trait", + "case", + "constant", + "constant_public", + "constant_protected", + "constant_private", + "property_public", + "property_protected", + "property_private", + "construct", + "destruct", + "magic", + "phpunit", + "method_abstract", + "method_public_static", + "method_public", + "method_protected_static", + "method_protected", + "method_private_static", + "method_private" + ], + "sort_algorithm": "none" + } } } diff --git a/src/Contracts/ContextSerializer.php b/src/Contracts/ContextSerializer.php new file mode 100644 index 0000000..bb632c8 --- /dev/null +++ b/src/Contracts/ContextSerializer.php @@ -0,0 +1,12 @@ +table()->where('key', $key)->delete(); + $this->db() + ->where('key', $key) + ->when( + $teamId !== false, + fn (Builder $query) => $query->where("{$this->table}.{$this->teamForeignKey}", $teamId) + ) + ->delete(); } - public function get(string $key, $default = null) + public function get(string $key, $default = null, $teamId = null) { - $value = $this->table()->where('key', $key)->value('value'); + $value = $this->db() + ->where('key', $key) + ->when( + $teamId !== false, + fn (Builder $query) => $query->where("{$this->table}.{$this->teamForeignKey}", $teamId) + ) + ->value('value'); return $value ?? $default; } - public function has($key): bool + public function all($teamId = null, $keys = null): array|Arrayable { - return $this->table()->where('key', $key)->exists(); + return $this->baseBulkQuery($teamId, $keys)->get(); } - public function set(string $key, $value = null): void + public function has($key, $teamId = null): bool { - try { - $this->table()->insert(compact('key', 'value')); - } catch (Throwable) { - $this->table()->where('key', $key)->update(compact('value')); + return $this->db() + ->where('key', $key) + ->when( + $teamId !== false, + fn (Builder $query) => $query->where("{$this->table}.{$this->teamForeignKey}", $teamId) + ) + ->exists(); + } + + public function set(string $key, $value = null, $teamId = null): void + { + $data = [ + 'key' => $key, + ]; + + if ($teamId !== false) { + $data[$this->teamForeignKey] = $teamId; } + + $this->db()->updateOrInsert($data, compact('value')); } - protected function table(): Builder + public function flush($teamId = null, $keys = null): void + { + $this->baseBulkQuery($teamId, $keys)->delete(); + } + + protected function db(): Builder { return $this->connection->table($this->table); } + + protected function normalizeKeys($keys): string|Collection|bool + { + if (is_bool($keys)) { + return $keys; + } + + if (is_string($keys)) { + return $keys; + } + + return collect($keys)->flatten()->filter(); + } + + private function baseBulkQuery($teamId, $keys): Builder + { + $keys = $this->normalizeKeys($keys); + + return $this->db() + ->when( + // False means we want settings without a context set. + $keys === false, + fn (Builder $query) => $query->where('key', 'NOT LIKE', '%' . Settings::getKeyGenerator()->contextPrefix() . '%'), + ) + ->when( + // When keys is a string, we're trying to do a partial lookup for context + is_string($keys), + fn (Builder $query) => $query->where('key', 'LIKE', "%{$keys}"), + ) + ->when( + $keys instanceof Collection && $keys->isNotEmpty(), + fn (Builder $query) => $query->whereIn('key', $keys), + ) + ->when( + $teamId !== false, + fn (Builder $query) => $query->where("{$this->table}.{$this->teamForeignKey}", $teamId) + ); + } } diff --git a/src/Drivers/EloquentDriver.php b/src/Drivers/EloquentDriver.php index 864750e..b0143bf 100644 --- a/src/Drivers/EloquentDriver.php +++ b/src/Drivers/EloquentDriver.php @@ -4,6 +4,7 @@ namespace Rawilk\Settings\Drivers; +use Illuminate\Contracts\Support\Arrayable; use Rawilk\Settings\Contracts\Driver; use Rawilk\Settings\Contracts\Setting; @@ -13,23 +14,33 @@ public function __construct(protected Setting $model) { } - public function forget($key): void + public function forget($key, $teamId = null): void { - $this->model::removeSetting($key); + $this->model::removeSetting($key, $teamId); } - public function get(string $key, $default = null) + public function get(string $key, $default = null, $teamId = null) { - return $this->model::getValue($key, $default); + return $this->model::getValue($key, $default, $teamId); } - public function has($key): bool + public function all($teamId = null, $keys = null): array|Arrayable { - return $this->model::has($key); + return $this->model::getAll($teamId, $keys); } - public function set(string $key, $value = null): void + public function has($key, $teamId = null): bool { - $this->model::set($key, $value); + return $this->model::has($key, $teamId); + } + + public function set(string $key, $value = null, $teamId = null): void + { + $this->model::set($key, $value, $teamId); + } + + public function flush($teamId = null, $keys = null): void + { + $this->model::flush($teamId, $keys); } } diff --git a/src/Drivers/Factory.php b/src/Drivers/Factory.php index 7249d69..36e9d4a 100644 --- a/src/Drivers/Factory.php +++ b/src/Drivers/Factory.php @@ -33,17 +33,25 @@ public function extend(string $driver, Closure $callback): self return $this; } + public function setDefaultDriver(string $driver): void + { + $this->app['config']['settings.driver'] = $driver; + } + protected function createDatabaseDriver(array $config): DatabaseDriver { return new DatabaseDriver( - $this->app['db']->connection(Arr::get($config, 'connection')), - $this->app['config']['settings.table'] + connection: $this->app['db']->connection(Arr::get($config, 'connection')), + table: $this->app['config']['settings.table'], + teamForeignKey: $this->app['config']['settings.team_foreign_key'] ?? null, ); } protected function createEloquentDriver(): EloquentDriver { - return new EloquentDriver(app(SettingContract::class)); + return new EloquentDriver( + model: app(SettingContract::class), + ); } protected function getDefaultDriver(): string @@ -51,11 +59,6 @@ protected function getDefaultDriver(): string return $this->app['config']['settings.driver']; } - public function setDefaultDriver(string $driver): void - { - $this->app['config']['settings.driver'] = $driver; - } - protected function getDriverConfig(string $driver): ?array { return $this->app['config']["settings.drivers.{$driver}"]; diff --git a/src/Events/SettingWasDeleted.php b/src/Events/SettingWasDeleted.php new file mode 100644 index 0000000..d622771 --- /dev/null +++ b/src/Events/SettingWasDeleted.php @@ -0,0 +1,24 @@ +context()); } + protected static function bootHasSettings(): void + { + static::deleted(function (self $model) { + if ($model->shouldFlushSettingsOnDelete()) { + $model->settings()->flush(); + } + }); + } + /** * Additional arguments that uniquely identify this model. */ @@ -34,4 +44,13 @@ protected function contextArguments(): array { return []; } + + protected function shouldFlushSettingsOnDelete(): bool + { + if (SettingsFacade::getKeyGenerator() instanceof Md5KeyGenerator) { + return false; + } + + return static::$flushSettingsOnDelete ?? true; + } } diff --git a/src/Models/Setting.php b/src/Models/Setting.php index 799e9f9..6c587cb 100644 --- a/src/Models/Setting.php +++ b/src/Models/Setting.php @@ -4,41 +4,138 @@ namespace Rawilk\Settings\Models; +use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Collection; use Rawilk\Settings\Contracts\Setting as SettingContract; +use Rawilk\Settings\Facades\Settings; +/** + * @property int $id + * @property string $key + * @property mixed $value + * @property int|string|null $team_id + */ class Setting extends Model implements SettingContract { + public $timestamps = false; + + protected ?string $teamForeignKey = null; + + protected $guarded = ['id']; + public function __construct(array $attributes = []) { parent::__construct($attributes); $this->setTable(config('settings.table')); + $this->teamForeignKey = config('settings.team_foreign_key'); } - protected $guarded = ['id']; + public static function getValue(string $key, $default = null, $teamId = null) + { + $value = static::query() + ->where('key', $key) + ->when( + $teamId !== false, + fn (Builder $query) => $query->where( + static::make()->getTable() . '.' . config('settings.team_foreign_key'), + $teamId, + ), + ) + ->value('value'); - public $timestamps = false; + return $value ?? $default; + } - public static function getValue(string $key, $default = null) + public static function getAll($teamId = null, $keys = null): array|Arrayable { - $value = self::where('key', $key)->value('value'); + return static::baseBulkQuery($teamId, $keys)->get(); + } - return $value ?? $default; + public static function has($key, $teamId = null): bool + { + return static::query() + ->where('key', $key) + ->when( + $teamId !== false, + fn (Builder $query) => $query->where( + static::make()->getTable() . '.' . config('settings.team_foreign_key'), + $teamId, + ), + ) + ->exists(); + } + + public static function removeSetting($key, $teamId = null): void + { + static::query() + ->where('key', $key) + ->when( + $teamId !== false, + fn (Builder $query) => $query->where( + static::make()->getTable() . '.' . config('settings.team_foreign_key'), + $teamId, + ), + ) + ->delete(); + } + + public static function set(string $key, $value = null, $teamId = null) + { + $data = ['key' => $key]; + + if ($teamId !== false) { + $data[config('settings.team_foreign_key')] = $teamId; + } + + return static::updateOrCreate($data, compact('value')); } - public static function has($key): bool + public static function flush($teamId = null, $keys = null): void { - return self::where('key', $key)->exists(); + static::baseBulkQuery($teamId, $keys)->delete(); } - public static function removeSetting($key): void + protected static function baseBulkQuery($teamId, $keys): Builder { - self::where('key', $key)->delete(); + $keys = static::normalizeKeys($keys); + + return static::query() + ->when( + // False means we want settings without a context set. + $keys === false, + fn (Builder $query) => $query->where('key', 'NOT LIKE', '%' . Settings::getKeyGenerator()->contextPrefix() . '%'), + ) + ->when( + // When keys is a string, we're trying to do a partial lookup for context + is_string($keys), + fn (Builder $query) => $query->where('key', 'LIKE', "%{$keys}"), + ) + ->when( + $keys instanceof Collection && $keys->isNotEmpty(), + fn (Builder $query) => $query->whereIn('key', $keys), + ) + ->when( + $teamId !== false, + fn (Builder $query) => $query->where( + static::make()->getTable() . '.' . config('settings.team_foreign_key'), + $teamId, + ), + ); } - public static function set(string $key, $value = null) + protected static function normalizeKeys($keys): string|Collection|bool { - return self::updateOrCreate(compact('key'), compact('value')); + if (is_bool($keys)) { + return $keys; + } + + if (is_string($keys)) { + return $keys; + } + + return collect($keys)->flatten()->filter(); } } diff --git a/src/Settings.php b/src/Settings.php index 40df29f..d63be53 100755 --- a/src/Settings.php +++ b/src/Settings.php @@ -6,28 +6,31 @@ use Illuminate\Contracts\Cache\Repository as Cache; use Illuminate\Contracts\Encryption\Encrypter; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Collection; use Illuminate\Support\Str; use Illuminate\Support\Traits\Macroable; use Rawilk\Settings\Contracts\Driver; +use Rawilk\Settings\Contracts\KeyGenerator; +use Rawilk\Settings\Contracts\ValueSerializer; +use Rawilk\Settings\Events\SettingsFlushed; +use Rawilk\Settings\Events\SettingWasDeleted; +use Rawilk\Settings\Events\SettingWasStored; +use Rawilk\Settings\Exceptions\InvalidBulkValueResult; +use Rawilk\Settings\Exceptions\InvalidKeyGenerator; use Rawilk\Settings\Support\Context; -use Rawilk\Settings\Support\ContextSerializer; -use Rawilk\Settings\Support\KeyGenerator; -use Rawilk\Settings\Support\ValueSerializer; +use Rawilk\Settings\Support\KeyGenerators\Md5KeyGenerator; -class Settings implements Driver +class Settings { use Macroable; protected ?Cache $cache = null; - protected ?Context $context = null; + protected null|Context|bool $context = null; protected ?Encrypter $encrypter = null; - protected KeyGenerator $keyGenerator; - - protected ValueSerializer $valueSerializer; - protected bool $cacheEnabled = false; protected bool $encryptionEnabled = false; @@ -39,10 +42,20 @@ class Settings implements Driver // Meant for internal use only. protected bool $resetContext = true; - public function __construct(protected Driver $driver) - { - $this->keyGenerator = new KeyGenerator(new ContextSerializer); - $this->valueSerializer = new ValueSerializer; + protected bool $teams = false; + + /** @var null|string|int */ + protected mixed $teamId = null; + + protected ?string $teamForeignKey = null; + + protected string $cacheKeyPrefix = ''; + + public function __construct( + protected Driver $driver, + protected KeyGenerator $keyGenerator, + protected ValueSerializer $valueSerializer, + ) { } // mainly for testing purposes @@ -51,20 +64,68 @@ public function getDriver(): Driver return $this->driver; } - public function context(Context $context = null): self + /** + * Pass in `false` for context when calling `all()` to only return results + * that do not have context. + */ + public function context(Context|bool $context = null): self { $this->context = $context; return $this; } + public function getTeamId(): mixed + { + return $this->teamId; + } + + /** + * Set the team id for teams/groups support. This id is used when querying settings. + * + * @param int|string|null|\Illuminate\Database\Eloquent\Model $id + */ + public function setTeamId(mixed $id): self + { + if ($id instanceof Model) { + $id = $id->getKey(); + } + + $this->teamId = $id; + + return $this; + } + + public function getTeamForeignKey(): ?string + { + return $this->teamForeignKey; + } + + public function setTeamForeignKey(?string $foreignKey): self + { + $this->teamForeignKey = $foreignKey; + + return $this; + } + public function forget($key) { $key = $this->normalizeKey($key); $generatedKey = $this->getKeyForStorage($key); - $driverResult = $this->driver->forget($generatedKey); + $driverResult = $this->driver->forget( + key: $generatedKey, + teamId: $this->teams ? $this->teamId : false, + ); + + SettingWasDeleted::dispatch( + $key, + $generatedKey, + $this->getCacheKey($generatedKey), + $this->teams ? $this->teamId : false, + $this->context, + ); if ($this->temporarilyDisableCache || $this->cacheIsEnabled()) { $this->cache->forget($this->getCacheKey($generatedKey)); @@ -89,10 +150,18 @@ public function get(string $key, $default = null) if ($this->cacheIsEnabled()) { $value = $this->cache->rememberForever( $this->getCacheKey($generatedKey), - fn () => $this->driver->get(key: $generatedKey, default: $default) + fn () => $this->driver->get( + key: $generatedKey, + default: $default, + teamId: $this->teams ? $this->teamId : false, + ) ); } else { - $value = $this->driver->get(key: $generatedKey, default: $default); + $value = $this->driver->get( + key: $generatedKey, + default: $default, + teamId: $this->teams ? $this->teamId : false, + ); } if ($value !== null && $value !== $default) { @@ -109,11 +178,46 @@ public function get(string $key, $default = null) return $value ?? $default; } + public function all($keys = null): Collection + { + $keys = $this->normalizeBulkLookupKey($keys); + + $values = collect($this->driver->all( + teamId: $this->teams ? $this->teamId : false, + keys: $keys, + ))->map(function (mixed $record): mixed { + $record = $this->normalizeBulkRetrievedValue($record); + $value = $record->value; + + if ($value !== null) { + $value = $this->unserializeValue($this->decryptValue($value)); + } + + $record->value = $value; + $record->original_key = $record->key; + $record->key = $this->keyGenerator->removeContextFromKey($record->key); + + return $record; + }); + + if ($this->resetContext) { + $this->context(); + } + + $this->temporarilyDisableCache = false; + $this->resetContext = true; + + return $values; + } + public function has($key): bool { $key = $this->normalizeKey($key); - $has = $this->driver->has($this->getKeyForStorage($key)); + $has = $this->driver->has( + key: $this->getKeyForStorage($key), + teamId: $this->teams ? $this->teamId : false, + ); if ($this->resetContext) { $this->context(); @@ -125,11 +229,11 @@ public function has($key): bool return $has; } - public function set(string $key, $value = null) + public function set(string $key, $value = null): mixed { $key = $this->normalizeKey($key); - // We really only need to update the value if is has changed + // We really only need to update the value if it has changed // to prevent the cache being reset on the key. if (! $this->shouldSetNewValue(key: $key, newValue: $value)) { $this->context(); @@ -141,8 +245,18 @@ public function set(string $key, $value = null) $serializedValue = $this->serializeValue($value); $driverResult = $this->driver->set( + key: $generatedKey, + value: $this->encryptionIsEnabled() ? $this->encrypter->encrypt($serializedValue) : $serializedValue, + teamId: $this->teams ? $this->teamId : false, + ); + + SettingWasStored::dispatch( + $key, $generatedKey, - $this->encryptionIsEnabled() ? $this->encrypter->encrypt($serializedValue) : $serializedValue + $this->getCacheKey($generatedKey), + $value, + $this->teams ? $this->teamId : false, + $this->context, ); if ($this->temporarilyDisableCache || $this->cacheIsEnabled()) { @@ -159,7 +273,7 @@ public function isFalse(string $key, $default = false): bool { $value = $this->get(key: $key, default: $default); - return $value === false || $value === '0' || $value === 1; + return $value === false || $value === '0' || $value === 0; } public function isTrue(string $key, $default = true): bool @@ -169,52 +283,37 @@ public function isTrue(string $key, $default = true): bool return $value === true || $value === '1' || $value === 1; } - protected function normalizeKey(string $key): string + public function flush($keys = null): mixed { - if (Str::startsWith(haystack: $key, needles: 'file_')) { - return str_replace(search: 'file_', replace: 'file.', subject: $key); - } + $keys = $this->normalizeBulkLookupKey($keys); - return $key; - } - - protected function getCacheKey(string $key): string - { - return config('settings.cache_key_prefix') . $key; - } - - protected function getKeyForStorage(string $key): string - { - return $this->keyGenerator->generate(key: $key, context: $this->context); - } + $driverResult = $this->driver->flush( + teamId: $this->teams ? $this->teamId : false, + keys: $keys, + ); - protected function serializeValue($value): string - { - return $this->valueSerializer->serialize($value); - } + SettingsFlushed::dispatch( + $keys, + $this->teams ? $this->teamId : false, + $this->context, + ); - protected function unserializeValue($serialized) - { - if (! is_string($serialized)) { - return $serialized; + // Flush the cache for all deleted keys. + // Note: Only works when a subset of keys is specified. + if ($keys instanceof Collection && ($this->temporarilyDisableCache || $this->cacheIsEnabled())) { + $keys->each(function (string $key) { + $this->cache->forget($this->getCacheKey($key)); + }); } - // Attempt to unserialize the value, but return the original value if that fails. - return rescue(fn () => $this->valueSerializer->unserialize($serialized), fn () => $serialized); - } - - protected function shouldSetNewValue(string $key, $newValue): bool - { - if (! $this->cacheIsEnabled()) { - return true; + if ($this->resetContext) { + $this->context(); } - // To prevent decryption errors, we will check if we have a setting set for the current context and key. - if (! $this->doNotResetContext()->has($key)) { - return true; - } + $this->temporarilyDisableCache = false; + $this->resetContext = true; - return $newValue !== $this->doNotResetContext()->get($key); + return $driverResult; } public function disableCache(): self @@ -245,15 +344,6 @@ public function setCache(Cache $cache): self return $this; } - protected function cacheIsEnabled(): bool - { - if ($this->temporarilyDisableCache) { - return false; - } - - return $this->cacheEnabled && $this->cache !== null; - } - public function disableEncryption(): self { $this->encryptionEnabled = false; @@ -275,6 +365,123 @@ public function setEncrypter(Encrypter $encrypter): self return $this; } + public function enableTeams(): self + { + $this->teams = true; + + return $this; + } + + public function disableTeams(): self + { + $this->teams = false; + + return $this; + } + + public function teamsAreEnabled(): bool + { + return $this->teams; + } + + public function useCacheKeyPrefix(string $prefix): self + { + $this->cacheKeyPrefix = $prefix; + + return $this; + } + + public function getKeyGenerator(): KeyGenerator + { + return $this->keyGenerator; + } + + /** + * Generate the key to use for caching a specific setting. + * This is meant for external usage. + */ + public function cacheKeyForSetting(string $key): string + { + $storageKey = $this->getKeyForStorage( + $this->normalizeKey($key), + ); + + $cacheKey = $this->getCacheKey($storageKey); + + if ($this->resetContext) { + $this->context(); + } + + $this->resetContext = true; + + return $cacheKey; + } + + protected function normalizeKey(string $key): string + { + if (Str::startsWith(haystack: $key, needles: 'file_')) { + return str_replace(search: 'file_', replace: 'file.', subject: $key); + } + + return $key; + } + + protected function getCacheKey(string $key): string + { + $cacheKey = $this->cacheKeyPrefix . $key; + + if ($this->teams) { + $teamId = $this->teamId ?? 'null'; + + $cacheKey .= "::team:{$teamId}"; + } + + return $cacheKey; + } + + protected function getKeyForStorage(string $key): string + { + return $this->keyGenerator->generate(key: $key, context: $this->context); + } + + protected function serializeValue($value): string + { + return $this->valueSerializer->serialize($value); + } + + protected function unserializeValue($serialized) + { + if (! is_string($serialized)) { + return $serialized; + } + + // Attempt to unserialize the value, but return the original value if that fails. + return rescue(fn () => $this->valueSerializer->unserialize($serialized), fn () => $serialized); + } + + protected function shouldSetNewValue(string $key, $newValue): bool + { + if (! $this->cacheIsEnabled()) { + return true; + } + + // To prevent decryption errors, we will check if we have a setting set for the current context and key. + if (! $this->doNotResetContext()->has($key)) { + return true; + } + + return $newValue !== $this->doNotResetContext()->get($key); + } + + protected function cacheIsEnabled(): bool + { + if ($this->temporarilyDisableCache) { + return false; + } + + return $this->cacheEnabled && $this->cache !== null; + } + protected function encryptionIsEnabled(): bool { return $this->encryptionEnabled && $this->encrypter !== null; @@ -299,4 +506,42 @@ protected function doNotResetContext(): self return $this; } + + protected function normalizeBulkRetrievedValue(mixed $record): object + { + if (is_array($record)) { + $record = (object) $record; + } + + throw_unless( + is_object($record), + InvalidBulkValueResult::notObject(), + ); + + throw_unless( + isset($record->key, $record->value), + InvalidBulkValueResult::missingValueOrKey(), + ); + + return $record; + } + + protected function normalizeBulkLookupKey($key): string|Collection|bool + { + if (is_null($key) && $this->context !== null) { + throw_if( + $this->keyGenerator instanceof Md5KeyGenerator, + InvalidKeyGenerator::forPartialLookup($this->keyGenerator::class), + ); + + return is_bool($this->context) + ? $this->context + : $this->keyGenerator->generate('', $this->context); + } + + return collect($key) + ->flatten() + ->filter() + ->map(fn (string $key): string => $this->getKeyForStorage($this->normalizeKey($key))); + } } diff --git a/src/SettingsServiceProvider.php b/src/SettingsServiceProvider.php index 8bc186b..751fefb 100644 --- a/src/SettingsServiceProvider.php +++ b/src/SettingsServiceProvider.php @@ -6,6 +6,9 @@ use Rawilk\Settings\Contracts\Setting as SettingContract; use Rawilk\Settings\Drivers\Factory; +use Rawilk\Settings\Support\ContextSerializers\ContextSerializer; +use Rawilk\Settings\Support\KeyGenerators\Md5KeyGenerator; +use Rawilk\Settings\Support\ValueSerializers\ValueSerializer; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -16,7 +19,10 @@ public function configurePackage(Package $package): void $package ->name('laravel-settings') ->hasConfigFile() - ->hasMigration('create_settings_table'); + ->hasMigrations([ + 'create_settings_table', + 'add_settings_team_field', + ]); } public function packageBooted(): void @@ -29,6 +35,14 @@ public function packageRegistered(): void $this->registerSettings(); } + public function provides(): array + { + return [ + Settings::class, + 'SettingsFactory', + ]; + } + protected function bootModelBindings(): void { $config = $this->app['config']['settings.drivers.eloquent']; @@ -47,11 +61,20 @@ protected function registerSettings(): void fn ($app) => new Factory($app) ); - $this->app->singleton(Settings::class, static function ($app) { + $this->app->singleton(Settings::class, function ($app) { + $keyGenerator = $app->make($app['config']['settings.key_generator'] ?? Md5KeyGenerator::class); + $keyGenerator->setContextSerializer( + $app->make($app['config']['settings.context_serializer'] ?? ContextSerializer::class) + ); + $settings = new Settings( - $app['SettingsFactory']->driver() + driver: $app['SettingsFactory']->driver(), + keyGenerator: $keyGenerator, + valueSerializer: $app->make($app['config']['settings.value_serializer'] ?? ValueSerializer::class), ); + $settings->useCacheKeyPrefix($app['config']['settings.cache_key_prefix'] ?? ''); + $settings->setCache($app['cache.store']); if (config('app.key')) { @@ -60,16 +83,11 @@ protected function registerSettings(): void $app['config']['settings.cache'] ? $settings->enableCache() : $settings->disableCache(); $app['config']['settings.encryption'] ? $settings->enableEncryption() : $settings->disableEncryption(); + $app['config']['settings.teams'] ? $settings->enableTeams() : $settings->disableTeams(); + + $settings->setTeamForeignKey($app['config']['settings.team_foreign_key'] ?? 'team_id'); return $settings; }); } - - public function provides(): array - { - return [ - Settings::class, - 'SettingsFactory', - ]; - } } diff --git a/src/Support/Context.php b/src/Support/Context.php index cabc9b6..68e4ea8 100644 --- a/src/Support/Context.php +++ b/src/Support/Context.php @@ -5,9 +5,11 @@ namespace Rawilk\Settings\Support; use Countable; +use Illuminate\Contracts\Support\Arrayable; use OutOfBoundsException; +use Rawilk\Settings\Exceptions\InvalidContextValue; -class Context implements Countable +class Context implements Arrayable, Countable { protected array $arguments = []; @@ -43,6 +45,8 @@ public function remove(string $name): self public function set(string $name, $value): self { + $this->ensureValidValue($name, $value); + $this->arguments[$name] = $value; return $this; @@ -52,4 +56,17 @@ public function count(): int { return count($this->arguments); } + + public function toArray(): array + { + return $this->arguments; + } + + protected function ensureValidValue(string $key, mixed $value): void + { + throw_unless( + is_string($value) || is_numeric($value) || is_bool($value) || is_null($value), + InvalidContextValue::forKey($key), + ); + } } diff --git a/src/Support/ContextSerializer.php b/src/Support/ContextSerializer.php deleted file mode 100644 index dd423ca..0000000 --- a/src/Support/ContextSerializer.php +++ /dev/null @@ -1,13 +0,0 @@ -toArray()) + ->map(function ($value, string $key) { + // Use the model's morph class when possible. + $value = match ($key) { + 'model' => rescue(fn () => app($value)->getMorphClass(), $value), + default => $value, + }; + + if ($value === false) { + $value = 0; + } + + return "{$key}:{$value}"; + }) + ->implode('::'); + } +} diff --git a/src/Support/KeyGenerator.php b/src/Support/KeyGenerator.php deleted file mode 100644 index a030e23..0000000 --- a/src/Support/KeyGenerator.php +++ /dev/null @@ -1,17 +0,0 @@ -serializer->serialize($context)); - } -} diff --git a/src/Support/KeyGenerators/Md5KeyGenerator.php b/src/Support/KeyGenerators/Md5KeyGenerator.php new file mode 100644 index 0000000..d827090 --- /dev/null +++ b/src/Support/KeyGenerators/Md5KeyGenerator.php @@ -0,0 +1,37 @@ +serializer->serialize($context)); + } + + public function removeContextFromKey(string $key): string + { + throw new RuntimeException('Md5KeyGenerator does not support removing the context from the key.'); + } + + public function setContextSerializer(ContextSerializer $serializer): self + { + $this->serializer = $serializer; + + return $this; + } + + public function contextPrefix(): string + { + throw new RuntimeException('Md5KeyGenerator does not support a context prefix.'); + } +} diff --git a/src/Support/KeyGenerators/ReadableKeyGenerator.php b/src/Support/KeyGenerators/ReadableKeyGenerator.php new file mode 100644 index 0000000..9c73f73 --- /dev/null +++ b/src/Support/KeyGenerators/ReadableKeyGenerator.php @@ -0,0 +1,54 @@ +normalizeKey($key); + + if ($context) { + $key .= $this->contextPrefix() . $this->serializer->serialize($context); + } + + return $key; + } + + public function removeContextFromKey(string $key): string + { + return Str::before($key, $this->contextPrefix()); + } + + public function setContextSerializer(ContextSerializer $serializer): self + { + $this->serializer = $serializer; + + return $this; + } + + public function contextPrefix(): string + { + return ':c:::'; + } + + protected function normalizeKey(string $key): string + { + // We want to preserve period characters in the key, however everything else is fair game + // to convert to a slug. + return Str::of($key) + ->replace('.', '-dot-') + ->slug() + ->replace('-dot-', '.') + ->__toString(); + } +} diff --git a/src/Support/ValueSerializer.php b/src/Support/ValueSerializer.php deleted file mode 100644 index ab9fbc5..0000000 --- a/src/Support/ValueSerializer.php +++ /dev/null @@ -1,18 +0,0 @@ - false]); + } +} diff --git a/src/helpers.php b/src/helpers.php index 20aa94f..0fbf514 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -29,7 +29,7 @@ function settings($key = null, $default = null, $context = null) return null; } - if ($context instanceof Context) { + if ($context instanceof Context || is_bool($context)) { $settings->context($context); } diff --git a/tests/ArchTest.php b/tests/ArchTest.php new file mode 100644 index 0000000..e90c56c --- /dev/null +++ b/tests/ArchTest.php @@ -0,0 +1,78 @@ +expect(['dd', 'dump', 'ray', 'var_dump', 'ddd']) + ->each->not->toBeUsed(); + +test('strict types are used') + ->expect('Rawilk\Settings') + ->toUseStrictTypes(); + +test('only interfaces are placed in the contracts directory') + ->expect('Rawilk\Settings\Contracts') + ->toBeInterfaces(); + +test('each driver implements the Driver contract') + ->expect('Rawilk\Settings\Drivers') + ->classes() + ->toImplement(Driver::class)->ignoring(Factory::class) + ->toHaveSuffix('Driver')->ignoring(Factory::class); + +test('only facades are used in the Facades directory') + ->expect('Rawilk\Settings\Facades') + ->toExtend(Facade::class) + ->classes() + ->not->toHaveSuffix('Facade'); + +test('models are configured correctly and are extendable') + ->expect('Rawilk\Settings\Models') + ->classes() + ->toExtend(Model::class) + ->not->toBeFinal(); + +test('context serializers are configured correctly') + ->expect('Rawilk\Settings\Support\ContextSerializers') + ->toBeClasses() + ->classes() + ->toImplement(ContextSerializer::class) + ->toExtendNothing() + ->toHaveSuffix('Serializer'); + +test('key generators are configured correctly') + ->expect('Rawilk\Settings\Support\KeyGenerators') + ->toBeClasses() + ->classes() + ->toImplement(KeyGenerator::class) + ->toExtendNothing() + ->toHaveSuffix('Generator'); + +test('value serializers are configured correctly') + ->expect('Rawilk\Settings\Support\ValueSerializers') + ->toBeClasses() + ->classes() + ->toImplement(ValueSerializer::class) + ->toExtendNothing() + ->toHaveSuffix('Serializer'); + +test('events are configured correctly') + ->expect('Rawilk\Settings\Events') + ->toBeClasses() + ->classes() + ->toExtendNothing() + ->toBeFinal() + ->toUse([ + Dispatchable::class, + SerializesModels::class, + ]); diff --git a/tests/Feature/Drivers/DatabaseDriverTest.php b/tests/Feature/Drivers/DatabaseDriverTest.php index fb4ce27..2c21ea3 100644 --- a/tests/Feature/Drivers/DatabaseDriverTest.php +++ b/tests/Feature/Drivers/DatabaseDriverTest.php @@ -2,56 +2,246 @@ declare(strict_types=1); -use Illuminate\Support\Facades\DB; use Rawilk\Settings\Drivers\DatabaseDriver; +/** + * Note: Setting `false` as the team id in some calls is essentially like setting it to `false` in the config file. + */ beforeEach(function () { - $this->driver = new DatabaseDriver(app('db')->connection(), 'settings'); - $this->db = DB::table('settings'); + config([ + 'settings.driver' => 'database', + 'settings.teams' => true, + 'settings.team_foreign_key' => 'team_id', + ]); + + $this->driver = new DatabaseDriver( + connection: app('db')->connection(), + table: 'settings', + teamForeignKey: 'team_id', + ); + + migrateTeams(); + setDatabaseDriverConnection(); }); it('creates new entries', function () { - $this->driver->set('foo', 'bar'); + $this->driver->set('foo', 'bar', false); - expect($this->db->count())->toBe(1) - ->and($this->db->where('key', 'foo')->value('value'))->toBe('bar'); + $this->assertDatabaseCount('settings', 1); + + $this->assertDatabaseHas('settings', [ + 'key' => 'foo', + 'value' => 'bar', + 'team_id' => null, + ]); +}); + +it('creates new entries for teams', function () { + $this->driver->set('foo', 'bar', 1); + + $this->assertDatabaseCount('settings', 1); + + $this->assertDatabaseHas('settings', [ + 'key' => 'foo', + 'value' => 'bar', + 'team_id' => 1, + ]); }); it('updates existing values', function () { - $this->driver->set('foo', 'bar'); + $this->driver->set('foo', 'bar', false); + + $this->assertDatabaseHas('settings', [ + 'key' => 'foo', + 'value' => 'bar', + 'team_id' => null, + ]); + + $this->driver->set('foo', 'updated value', false); + + $this->assertDatabaseCount('settings', 1); - expect($this->db->where('key', 'foo')->value('value'))->toBe('bar'); + $this->assertDatabaseHas('settings', [ + 'key' => 'foo', + 'value' => 'updated value', + 'team_id' => null, + ]); +}); + +it('updates team values', function () { + $this->driver->set('foo', 'no team value', null); + $this->driver->set('foo', 'team value', 1); + + $this->assertDatabaseCount('settings', 2); + + $this->driver->set('foo', 'updated team value', 1); - $this->driver->set('foo', 'updated value'); + $this->assertDatabaseCount('settings', 2); - expect($this->db->count())->toBe(1) - ->and($this->db->where('key', 'foo')->value('value'))->toBe('updated value'); + $this->assertDatabaseHas('settings', [ + 'key' => 'foo', + 'value' => 'no team value', + 'team_id' => null, + ]); + + $this->assertDatabaseHas('settings', [ + 'key' => 'foo', + 'value' => 'updated team value', + 'team_id' => 1, + ]); }); it('checks if a setting is persisted', function () { - expect($this->driver->has('foo'))->toBeFalse(); + expect($this->driver->has('foo', false))->toBeFalse(); + + $this->driver->set('foo', 'bar', false); + + expect($this->driver->has('foo', false))->toBeTrue(); +}); - $this->driver->set('foo', 'bar'); +it('checks if a team setting is persisted', function () { + $this->driver->set('foo', 'no team value', null); + expect($this->driver->has('foo', 1))->toBeFalse(); - expect($this->driver->has('foo'))->toBeTrue(); + $this->driver->set('foo', 'team value', 1); + expect($this->driver->has('foo', 1))->toBeTrue(); }); it('gets a persisted setting value', function () { - $this->driver->set('foo', 'some value'); + $this->driver->set('foo', 'some value', false); - expect($this->driver->get('foo'))->toBe('some value'); + expect($this->driver->get(key: 'foo', teamId: false))->toBe('some value'); }); it('returns a default value for settings that are not persisted', function () { - expect($this->driver->get('foo', 'my default value'))->toBe('my default value'); + expect($this->driver->get(key: 'foo', default: 'my default value', teamId: false))->toBe('my default value'); +}); + +it('gets a persisted team value', function () { + $this->driver->set('foo', 'no team value', null); + $this->driver->set('foo', 'team value', 1); + + expect($this->driver->get(key: 'foo', teamId: 1))->toBe('team value'); +}); + +it('gets a default value for a team', function () { + $this->driver->set('foo', 'no team value', null); + + expect($this->driver->get(key: 'foo', default: 'my default', teamId: 1))->toBe('my default'); }); it('removes persisted settings', function () { - $this->driver->set('foo', 'bar'); + $this->driver->set('foo', 'bar', false); + + expect($this->driver->has('foo', false))->toBeTrue(); + + $this->driver->forget('foo', false); + + expect($this->driver->has('foo', false))->toBeFalse(); +}); + +it('removes persisted team values', function () { + $this->driver->set('foo', 'team 1 value', 1); + $this->driver->set('foo', 'team 2 value', 2); + + $this->assertDatabaseCount('settings', 2); + + $this->driver->forget('foo', 1); + + $this->assertDatabaseCount('settings', 1); + + $this->assertDatabaseMissing('settings', [ + 'key' => 'foo', + 'team_id' => 1, + ]); +}); + +it('can get all persisted settings', function () { + $this->driver->set('one', 'value 1', false); + $this->driver->set('two', 'value 2', false); + + $settings = $this->driver->all(); + + expect($settings)->toHaveCount(2) + ->first()->value->toBe('value 1') + ->and($settings[1]->value)->toBe('value 2'); +}); + +it('can get a subset of persisted settings', function () { + $this->driver->set('one', 'value 1', false); + $this->driver->set('two', 'value 2', false); + $this->driver->set('three', 'value 3', false); + + $settings = $this->driver->all(keys: ['one', 'three']); + + expect($settings)->toHaveCount(2) + ->first()->value->toBe('value 1') + ->and($settings[1]->value)->toBe('value 3'); +}); + +it('can get all of a teams persisted settings', function () { + $this->driver->set('one', 'value 1', 1); + $this->driver->set('two', 'value 2', 1); + $this->driver->set('one', 'team 2 value 1', 2); + + $settings = $this->driver->all(teamId: 1); + + expect($settings)->toHaveCount(2) + ->first()->value->toBe('value 1') + ->and($settings[1]->value)->toBe('value 2'); +}); + +it('can do partial lookups on all', function () { + $this->driver->set('one:1', 'value 1'); + $this->driver->set('one:2', 'value 2_1'); + $this->driver->set('two:1', 'value 2'); + + $settings = $this->driver->all(keys: ':1'); + + expect($settings)->toHaveCount(2) + ->first()->value->toBe('value 1') + ->and($settings[1]->value)->toBe('value 2'); +}); + +it('can delete all settings', function () { + $this->driver->set('one', 'one', false); + $this->driver->set('two', 'two', false); + + $this->assertDatabaseCount('settings', 2); + + $this->driver->flush(); + + $this->assertDatabaseCount('settings', 0); +}); + +it('can delete all team settings', function () { + $this->driver->set('one', 'one', 1); + $this->driver->set('two', 'two', 1); + $this->driver->set('two', 'team two', 2); + + $this->assertDatabaseCount('settings', 3); + + $this->driver->flush(teamId: 1); + + $this->assertDatabaseCount('settings', 1); +}); + +it('can flush a subset of settings', function () { + $this->driver->set('one', 'one', false); + $this->driver->set('two', 'two', false); + $this->driver->set('three', 'three', false); + + $this->assertDatabaseCount('settings', 3); + + $this->driver->flush(keys: ['one', 'three']); - expect($this->driver->has('foo'))->toBeTrue(); + $this->assertDatabaseCount('settings', 1); - $this->driver->forget('foo'); + $this->assertDatabaseMissing('settings', [ + 'key' => 'one', + ]); - expect($this->driver->has('foo'))->toBeFalse(); + $this->assertDatabaseMissing('settings', [ + 'key' => 'three', + ]); }); diff --git a/tests/Feature/Drivers/EloquentDriverTest.php b/tests/Feature/Drivers/EloquentDriverTest.php index 6e96516..a3d5d75 100644 --- a/tests/Feature/Drivers/EloquentDriverTest.php +++ b/tests/Feature/Drivers/EloquentDriverTest.php @@ -5,53 +5,237 @@ use Rawilk\Settings\Drivers\EloquentDriver; use Rawilk\Settings\Models\Setting; +/** + * Note: Setting `false` as the team id in some calls is essentially like setting it to `false` in the config file. + */ beforeEach(function () { + config([ + 'settings.driver' => 'eloquent', + 'settings.teams' => true, + 'settings.team_foreign_key' => 'team_id', + ]); + $this->driver = new EloquentDriver(app(Setting::class)); $this->model = app(Setting::class); + + migrateTeams(); }); it('creates new entries', function () { - $this->driver->set('foo', 'bar'); + $this->driver->set('foo', 'bar', false); - expect($this->model::all())->count()->toBe(1) - ->and($this->model::first()->value)->toBe('bar'); + $this->assertDatabaseCount($this->model, 1); + + $this->assertDatabaseHas($this->model, [ + 'key' => 'foo', + 'value' => 'bar', + 'team_id' => null, + ]); +}); + +it('creates new entries for teams', function () { + $this->driver->set('foo', 'bar', 1); + + $this->assertDatabaseCount($this->model, 1); + + $this->assertDatabaseHas($this->model, [ + 'key' => 'foo', + 'value' => 'bar', + 'team_id' => 1, + ]); }); it('updates existing entries', function () { - $this->driver->set('foo', 'bar'); + $this->driver->set('foo', 'bar', false); + + $setting = $this->model::first(); + + expect($setting->value)->toBe('bar') + ->and($setting->team_id)->toBeNull(); + + $this->driver->set('foo', 'updated value', false); + + $this->assertDatabaseCount($this->model, 1); + + $updatedSetting = $this->model::first(); + + expect($updatedSetting->value)->toBe('updated value') + ->and($updatedSetting->team_id)->toBeNull(); +}); + +it('updates team values', function () { + $this->driver->set('foo', 'no team value', null); + $this->driver->set('foo', 'team value', 1); - expect($this->model::first()->value)->toBe('bar'); + $this->assertDatabaseCount($this->model, 2); - $this->driver->set('foo', 'updated value'); + $this->driver->set('foo', 'updated team value', 1); - expect($this->model::count())->toBe(1) - ->and($this->model::first()->value)->toBe('updated value'); + $this->assertDatabaseCount($this->model, 2); + + $this->assertDatabaseHas($this->model, [ + 'key' => 'foo', + 'value' => 'no team value', + 'team_id' => null, + ]); + + $this->assertDatabaseHas($this->model, [ + 'key' => 'foo', + 'value' => 'updated team value', + 'team_id' => 1, + ]); }); it('checks if a setting is persisted', function () { - expect($this->driver->has('foo'))->toBeFalse(); + expect($this->driver->has('foo', false))->toBeFalse(); - $this->driver->set('foo', 'bar'); + $this->driver->set('foo', 'bar', false); - expect($this->driver->has('foo'))->toBeTrue(); + expect($this->driver->has('foo', false))->toBeTrue(); +}); + +it('checks if a team setting is persisted', function () { + $this->driver->set('foo', 'no team value', null); + expect($this->driver->has('foo', 1))->toBeFalse(); + + $this->driver->set('foo', 'team value', 1); + expect($this->driver->has('foo', 1))->toBeTrue(); }); it('gets a persisted setting value', function () { - $this->driver->set('foo', 'bar'); + $this->driver->set('foo', 'bar', false); - expect($this->driver->get('foo'))->toBe('bar'); + expect($this->driver->get(key: 'foo', teamId: false))->toBe('bar'); }); it('returns a default value for settings that are not persisted', function () { - expect($this->driver->get('foo', 'my default value'))->toBe('my default value'); + expect($this->driver->get(key: 'foo', default: 'my default value', teamId: false))->toBe('my default value'); +}); + +it('gets a persisted team value', function () { + $this->driver->set('foo', 'no team value', null); + $this->driver->set('foo', 'team value', 1); + + expect($this->driver->get(key: 'foo', teamId: 1))->toBe('team value'); +}); + +it('gets a default value for a team', function () { + $this->driver->set('foo', 'no team value', null); + + expect($this->driver->get(key: 'foo', default: 'my default', teamId: 1))->toBe('my default'); }); it('removes persisted settings', function () { $this->driver->set('foo', 'bar'); + expect($this->driver->has('foo', false))->toBeTrue(); + + $this->driver->forget('foo', false); + + expect($this->driver->has('foo', false))->toBeFalse(); +}); + +it('removes persisted team values', function () { + $this->driver->set('foo', 'team 1 value', 1); + $this->driver->set('foo', 'team 2 value', 2); + + $this->assertDatabaseCount('settings', 2); + + $this->driver->forget('foo', 1); + + $this->assertDatabaseCount('settings', 1); + + $this->assertDatabaseMissing('settings', [ + 'key' => 'foo', + 'team_id' => 1, + ]); +}); + +it('can get all persisted settings', function () { + $this->driver->set('one', 'value 1', false); + $this->driver->set('two', 'value 2', false); + + $settings = $this->driver->all(); + + expect($settings)->toHaveCount(2) + ->first()->value->toBe('value 1') + ->and($settings[1]->value)->toBe('value 2'); +}); + +it('can get a subset of persisted settings', function () { + $this->driver->set('one', 'value 1', false); + $this->driver->set('two', 'value 2', false); + $this->driver->set('three', 'value 3', false); + + $settings = $this->driver->all(keys: ['one', 'three']); + + expect($settings)->toHaveCount(2) + ->first()->value->toBe('value 1') + ->and($settings[1]->value)->toBe('value 3'); +}); + +it('can get all of a teams persisted settings', function () { + $this->driver->set('one', 'value 1', 1); + $this->driver->set('two', 'value 2', 1); + $this->driver->set('one', 'team 2 value 1', 2); + + $settings = $this->driver->all(teamId: 1); + + expect($settings)->toHaveCount(2) + ->first()->value->toBe('value 1') + ->and($settings[1]->value)->toBe('value 2'); +}); + +it('can do partial lookups on all', function () { + $this->driver->set('one:1', 'value 1'); + $this->driver->set('one:2', 'value 2_1'); + $this->driver->set('two:1', 'value 2'); + + $settings = $this->driver->all(keys: ':1'); + + expect($settings)->toHaveCount(2) + ->first()->value->toBe('value 1') + ->and($settings[1]->value)->toBe('value 2'); +}); + +it('can delete all settings', function () { + $this->driver->set('one', 'one', false); + $this->driver->set('two', 'two', false); + + $this->assertDatabaseCount('settings', 2); + + $this->driver->flush(); + + $this->assertDatabaseCount('settings', 0); +}); + +it('can delete all team settings', function () { + $this->driver->set('one', 'one', 1); + $this->driver->set('two', 'two', 1); + $this->driver->set('two', 'team two', 2); + + $this->assertDatabaseCount('settings', 3); + + $this->driver->flush(teamId: 1); + + $this->assertDatabaseCount('settings', 1); +}); + +it('can flush a subset of settings', function () { + $this->driver->set('one', 'one', false); + $this->driver->set('two', 'two', false); + $this->driver->set('three', 'three', false); + + $this->assertDatabaseCount('settings', 3); + + $this->driver->flush(keys: ['one', 'three']); - expect($this->driver->has('foo'))->toBeTrue(); + $this->assertDatabaseCount('settings', 1); - $this->driver->forget('foo'); + $this->assertDatabaseMissing('settings', [ + 'key' => 'one', + ]); - expect($this->driver->has('foo'))->toBeFalse(); + $this->assertDatabaseMissing('settings', [ + 'key' => 'three', + ]); }); diff --git a/tests/Feature/HasSettingsTest.php b/tests/Feature/HasSettingsTest.php index 764ed71..5fc0ad0 100644 --- a/tests/Feature/HasSettingsTest.php +++ b/tests/Feature/HasSettingsTest.php @@ -4,13 +4,14 @@ use Rawilk\Settings\Facades\Settings; use Rawilk\Settings\Support\Context; +use Rawilk\Settings\Support\ContextSerializers\DotNotationContextSerializer; +use Rawilk\Settings\Support\KeyGenerators\ReadableKeyGenerator; use Rawilk\Settings\Tests\Support\Models\Company; use Rawilk\Settings\Tests\Support\Models\CustomUser; use Rawilk\Settings\Tests\Support\Models\User; beforeEach(function () { - $migration = include __DIR__ . '/../Support/database/migrations/create_test_tables.php'; - $migration->up(); + migrateTestTables(); User::factory(2)->create(); }); @@ -77,3 +78,52 @@ expect(Settings::context($user1Context)->get('program.name'))->toBe($user1->email) ->and(Settings::context($user2Context)->get('program.name'))->toBe($user2->email); }); + +test("a model's settings are flushed when the model is deleted", function () { + $settings = settings(); + (fn () => $this->keyGenerator = (new ReadableKeyGenerator)->setContextSerializer(new DotNotationContextSerializer))->call($settings); + + $user1 = User::first(); + $user2 = User::where('id', '>', $user1->getKey())->first(); + + Settings::set('user.email', 'foo'); + + $user1->settings()->set('user.email', $user1->email); + $user2->settings()->set('user.email', $user2->email); + + $this->assertDatabaseCount('settings', 3); + + $user1->delete(); + + $this->assertDatabaseCount('settings', 2); + + $this->assertDatabaseMissing('settings', [ + 'key' => 'user.email:c:::model:user::id:' . $user1->getKey(), + ]); +}); + +test('a model can be configured to not flush settings on delete', function () { + $settings = settings(); + (fn () => $this->keyGenerator = (new ReadableKeyGenerator)->setContextSerializer(new DotNotationContextSerializer))->call($settings); + + $company = Company::factory()->create(); + $company->settings()->set('foo', 'bar'); + + $this->assertDatabaseCount('settings', 1); + + $company->delete(); + + $this->assertDatabaseCount('settings', 1); +}); + +test("a model's settings will not be flushed if the md5 key generator is being used", function () { + $user = User::first(); + + $user->settings()->set('foo', 'bar'); + + $this->assertDatabaseCount('settings', 1); + + $user->delete(); + + $this->assertDatabaseCount('settings', 1); +}); diff --git a/tests/Feature/SettingsTest.php b/tests/Feature/SettingsTest.php index 3d3cb1f..66c1ab1 100644 --- a/tests/Feature/SettingsTest.php +++ b/tests/Feature/SettingsTest.php @@ -3,8 +3,20 @@ declare(strict_types=1); use Illuminate\Support\Facades\DB; -use Rawilk\Settings\Facades\Settings; +use Illuminate\Support\Facades\Event; +use Rawilk\Settings\Contracts\Setting; +use Rawilk\Settings\Drivers\EloquentDriver; +use Rawilk\Settings\Events\SettingsFlushed; +use Rawilk\Settings\Events\SettingWasDeleted; +use Rawilk\Settings\Events\SettingWasStored; +use Rawilk\Settings\Exceptions\InvalidKeyGenerator; +use Rawilk\Settings\Facades\Settings as SettingsFacade; use Rawilk\Settings\Support\Context; +use Rawilk\Settings\Support\ContextSerializers\ContextSerializer; +use Rawilk\Settings\Support\ContextSerializers\DotNotationContextSerializer; +use Rawilk\Settings\Support\KeyGenerators\Md5KeyGenerator; +use Rawilk\Settings\Support\KeyGenerators\ReadableKeyGenerator; +use Rawilk\Settings\Support\ValueSerializers\JsonValueSerializer; beforeEach(function () { config([ @@ -14,221 +26,229 @@ 'settings.encryption' => false, ]); - // The Database driver doesn't seem to be using the same Sqlite connection the tests are using, so - // we'll force it to here. This should fix issues with the settings table not existing when the - // driver queries it. - $driver = Settings::getDriver(); - $reflection = new ReflectionClass($driver); - - $property = $reflection->getProperty('connection'); - $property->setAccessible(true); - $property->setValue($driver, DB::connection()); + setDatabaseDriverConnection(); }); it('can determine if a setting has been persisted', function () { - expect(Settings::has('foo'))->toBeFalse(); + expect(SettingsFacade::has('foo'))->toBeFalse(); - Settings::set('foo', 'bar'); + SettingsFacade::set('foo', 'bar'); - expect(Settings::has('foo'))->toBeTrue(); + expect(SettingsFacade::has('foo'))->toBeTrue(); DB::table('settings')->truncate(); - expect(Settings::has('foo'))->toBeFalse(); + expect(SettingsFacade::has('foo'))->toBeFalse(); }); it('gets persisted setting values', function () { - Settings::set('foo', 'bar'); + SettingsFacade::set('foo', 'bar'); - expect(Settings::get('foo'))->toBe('bar'); + expect(SettingsFacade::get('foo'))->toBe('bar'); }); it('returns a default value if a setting is not persisted', function () { - expect(Settings::get('foo', 'default value'))->toBe('default value'); + expect(SettingsFacade::get('foo', 'default value'))->toBe('default value'); }); it('can retrieve values based on context', function () { - Settings::set('foo', 'bar'); + SettingsFacade::set('foo', 'bar'); $userContext = new Context(['user_id' => 1]); - Settings::context($userContext)->set('foo', 'user_1_value'); + SettingsFacade::context($userContext)->set('foo', 'user_1_value'); expect(DB::table('settings')->count())->toBe(2) - ->and(Settings::get('foo'))->toBe('bar') - ->and(Settings::context($userContext)->get('foo'))->toBe('user_1_value'); + ->and(SettingsFacade::get('foo'))->toBe('bar') + ->and(SettingsFacade::context($userContext)->get('foo'))->toBe('user_1_value'); }); it('can determine if a setting is persisted based on context', function () { - Settings::set('foo', 'bar'); + SettingsFacade::set('foo', 'bar'); $userContext = new Context(['user_id' => 1]); $user2Context = new Context(['user_id' => 2]); - expect(Settings::has('foo'))->toBeTrue() - ->and(Settings::context($userContext)->has('foo'))->toBeFalse(); + expect(SettingsFacade::has('foo'))->toBeTrue() + ->and(SettingsFacade::context($userContext)->has('foo'))->toBeFalse(); - Settings::context($userContext)->set('foo', 'user 1 value'); + SettingsFacade::context($userContext)->set('foo', 'user 1 value'); - expect(Settings::context($userContext)->has('foo'))->toBeTrue() - ->and(Settings::context($user2Context)->has('foo'))->toBeFalse(); + expect(SettingsFacade::context($userContext)->has('foo'))->toBeTrue() + ->and(SettingsFacade::context($user2Context)->has('foo'))->toBeFalse(); - Settings::context($user2Context)->set('foo', 'user 2 value'); + SettingsFacade::context($user2Context)->set('foo', 'user 2 value'); - expect(Settings::context($userContext)->has('foo'))->toBeTrue() - ->and(Settings::context($user2Context)->has('foo'))->toBeTrue() - ->and(Settings::has('foo'))->toBeTrue(); + expect(SettingsFacade::context($userContext)->has('foo'))->toBeTrue() + ->and(SettingsFacade::context($user2Context)->has('foo'))->toBeTrue() + ->and(SettingsFacade::has('foo'))->toBeTrue(); }); it('can remove persisted values based on context', function () { $userContext = new Context(['user_id' => 1]); $user2Context = new Context(['user_id' => 2]); - Settings::set('foo', 'bar'); - Settings::context($userContext)->set('foo', 'user 1 value'); - Settings::context($user2Context)->set('foo', 'user 2 value'); + SettingsFacade::set('foo', 'bar'); + SettingsFacade::context($userContext)->set('foo', 'user 1 value'); + SettingsFacade::context($user2Context)->set('foo', 'user 2 value'); - expect(Settings::has('foo'))->toBeTrue() - ->and(Settings::context($userContext)->has('foo'))->toBeTrue() - ->and(Settings::context($user2Context)->has('foo'))->toBeTrue(); + expect(SettingsFacade::has('foo'))->toBeTrue() + ->and(SettingsFacade::context($userContext)->has('foo'))->toBeTrue() + ->and(SettingsFacade::context($user2Context)->has('foo'))->toBeTrue(); - Settings::context($user2Context)->forget('foo'); + SettingsFacade::context($user2Context)->forget('foo'); - expect(Settings::has('foo'))->toBeTrue() - ->and(Settings::context($userContext)->has('foo'))->toBeTrue() - ->and(Settings::context($user2Context)->has('foo'))->toBeFalse(); + expect(SettingsFacade::has('foo'))->toBeTrue() + ->and(SettingsFacade::context($userContext)->has('foo'))->toBeTrue() + ->and(SettingsFacade::context($user2Context)->has('foo'))->toBeFalse(); }); it('persists values', function () { - Settings::set('foo', 'bar'); + SettingsFacade::set('foo', 'bar'); expect(DB::table('settings')->count())->toBe(1) - ->and(Settings::get('foo'))->toBe('bar'); + ->and(SettingsFacade::get('foo'))->toBe('bar'); - Settings::set('foo', 'updated value'); + SettingsFacade::set('foo', 'updated value'); expect(DB::table('settings')->count())->toBe(1) - ->and(Settings::get('foo'))->toBe('updated value'); + ->and(SettingsFacade::get('foo'))->toBe('updated value'); }); it('removes persisted values from storage', function () { - Settings::set('foo', 'bar'); - Settings::set('bar', 'foo'); + SettingsFacade::set('foo', 'bar'); + SettingsFacade::set('bar', 'foo'); expect(DB::table('settings')->count())->toBe(2) - ->and(Settings::has('foo'))->toBeTrue() - ->and(Settings::has('bar'))->toBeTrue(); + ->and(SettingsFacade::has('foo'))->toBeTrue() + ->and(SettingsFacade::has('bar'))->toBeTrue(); - Settings::forget('foo'); + SettingsFacade::forget('foo'); expect(DB::table('settings')->count())->toBe(1) - ->and(Settings::has('foo'))->toBeFalse() - ->and(Settings::has('bar'))->toBeTrue(); + ->and(SettingsFacade::has('foo'))->toBeFalse() + ->and(SettingsFacade::has('bar'))->toBeTrue(); }); it('can evaluate stored boolean settings', function () { - Settings::set('app.debug', '1'); - expect(Settings::isTrue('app.debug'))->toBeTrue(); + SettingsFacade::set('app.debug', '1'); + expect(SettingsFacade::isTrue('app.debug'))->toBeTrue(); + + SettingsFacade::set('app.debug', '0'); + expect(SettingsFacade::isTrue('app.debug'))->toBeFalse() + ->and(SettingsFacade::isFalse('app.debug'))->toBeTrue(); + + SettingsFacade::set('app.debug', true); + expect(SettingsFacade::isTrue('app.debug'))->toBeTrue() + ->and(SettingsFacade::isFalse('app.debug'))->toBeFalse(); +}); + +it('can evaluate boolean stored settings using the json value serializer', function () { + $settings = settings(); + (fn () => $this->valueSerializer = new JsonValueSerializer)->call($settings); + + $settings->set('app.debug', '1'); + expect($settings->isTrue('app.debug'))->toBeTrue(); - Settings::set('app.debug', '0'); - expect(Settings::isTrue('app.debug'))->toBeFalse() - ->and(Settings::isFalse('app.debug'))->toBeTrue(); + $settings->set('app.debug', '0'); + expect($settings->isTrue('app.debug'))->toBeFalse() + ->and($settings->isFalse('app.debug'))->toBeTrue(); - Settings::set('app.debug', true); - expect(Settings::isTrue('app.debug'))->toBeTrue() - ->and(Settings::isFalse('app.debug'))->toBeFalse(); + $settings->set('app.debug', true); + expect($settings->isTrue('app.debug'))->toBeTrue() + ->and($settings->isFalse('app.debug'))->toBeFalse(); }); it('can cache values on retrieval', function () { enableSettingsCache(); - Settings::set('foo', 'bar'); + SettingsFacade::set('foo', 'bar'); resetQueryCount(); - expect(Settings::get('foo'))->toBe('bar'); + expect(SettingsFacade::get('foo'))->toBe('bar'); assertQueryCount(1); resetQueryCount(); - expect(Settings::get('foo'))->toBe('bar'); + expect(SettingsFacade::get('foo'))->toBe('bar'); assertQueryCount(0); }); it('flushes the cache when updating a value', function () { enableSettingsCache(); - Settings::set('foo', 'bar'); + SettingsFacade::set('foo', 'bar'); resetQueryCount(); - expect(Settings::get('foo'))->toBe('bar'); + expect(SettingsFacade::get('foo'))->toBe('bar'); assertQueryCount(1); resetQueryCount(); - expect(Settings::get('foo'))->toBe('bar'); + expect(SettingsFacade::get('foo'))->toBe('bar'); assertQueryCount(0); - Settings::set('foo', 'updated value'); + SettingsFacade::set('foo', 'updated value'); resetQueryCount(); - expect(Settings::get('foo'))->toBe('updated value'); + expect(SettingsFacade::get('foo'))->toBe('updated value'); assertQueryCount(1); }); it('does not invalidate other cached settings when updating a value', function () { enableSettingsCache(); - Settings::set('foo', 'bar'); - Settings::set('bar', 'foo'); + SettingsFacade::set('foo', 'bar'); + SettingsFacade::set('bar', 'foo'); resetQueryCount(); - expect(Settings::get('foo'))->toBe('bar') - ->and(Settings::get('bar'))->toBe('foo'); + expect(SettingsFacade::get('foo'))->toBe('bar') + ->and(SettingsFacade::get('bar'))->toBe('foo'); assertQueryCount(2); resetQueryCount(); - expect(Settings::get('foo'))->toBe('bar') - ->and(Settings::get('bar'))->toBe('foo'); + expect(SettingsFacade::get('foo'))->toBe('bar') + ->and(SettingsFacade::get('bar'))->toBe('foo'); assertQueryCount(0); - Settings::set('foo', 'updated value'); + SettingsFacade::set('foo', 'updated value'); resetQueryCount(); - expect(Settings::get('foo'))->toBe('updated value') - ->and(Settings::get('bar'))->toBe('foo'); + expect(SettingsFacade::get('foo'))->toBe('updated value') + ->and(SettingsFacade::get('bar'))->toBe('foo'); assertQueryCount(1); }); test('the boolean checks use cached values if cache is enabled', function () { enableSettingsCache(); - Settings::set('true.value', true); - Settings::set('false.value', false); + SettingsFacade::set('true.value', true); + SettingsFacade::set('false.value', false); resetQueryCount(); - expect(Settings::isTrue('true.value'))->toBeTrue() - ->and(Settings::isFalse('false.value'))->toBeTrue(); + expect(SettingsFacade::isTrue('true.value'))->toBeTrue() + ->and(SettingsFacade::isFalse('false.value'))->toBeTrue(); assertQueryCount(2); resetQueryCount(); - expect(Settings::isTrue('true.value'))->toBeTrue() - ->and(Settings::isFalse('false.value'))->toBeTrue(); + expect(SettingsFacade::isTrue('true.value'))->toBeTrue() + ->and(SettingsFacade::isFalse('false.value'))->toBeTrue(); assertQueryCount(0); }); it('does not use the cache if the cache is disabled', function () { - Settings::disableCache(); + SettingsFacade::disableCache(); DB::enableQueryLog(); - Settings::set('foo', 'bar'); + SettingsFacade::set('foo', 'bar'); resetQueryCount(); - expect(Settings::get('foo'))->toBe('bar'); + expect(SettingsFacade::get('foo'))->toBe('bar'); assertQueryCount(1); resetQueryCount(); - expect(Settings::get('foo'))->toBe('bar'); + expect(SettingsFacade::get('foo'))->toBe('bar'); assertQueryCount(1); }); it('can encrypt values', function () { - Settings::enableEncryption(); + SettingsFacade::enableEncryption(); - Settings::set('foo', 'bar'); + SettingsFacade::set('foo', 'bar'); $storedSetting = DB::table('settings')->first(); $unEncrypted = unserialize(decrypt($storedSetting->value)); @@ -237,21 +257,21 @@ }); it('can decrypt values', function () { - Settings::enableEncryption(); + SettingsFacade::enableEncryption(); - Settings::set('foo', 'bar'); + SettingsFacade::set('foo', 'bar'); // The stored value will be encrypted and not retrieve serialized yet if encryption // is enabled. $storedSetting = DB::table('settings')->first(); expect(isSerialized($storedSetting->value))->toBeFalse() - ->and(Settings::get('foo'))->toBe('bar'); + ->and(SettingsFacade::get('foo'))->toBe('bar'); }); it('does not encrypt if encryption is disabled', function () { - Settings::disableEncryption(); + SettingsFacade::disableEncryption(); - Settings::set('foo', 'bar'); + SettingsFacade::set('foo', 'bar'); $storedSetting = DB::table('settings')->first(); @@ -260,13 +280,223 @@ }); it('does not try to decrypt if encryption is disabled', function () { - Settings::enableEncryption(); - Settings::set('foo', 'bar'); + SettingsFacade::enableEncryption(); + SettingsFacade::set('foo', 'bar'); + + SettingsFacade::disableEncryption(); + + $value = SettingsFacade::get('foo'); + + expect($value) + ->not->toBe('bar') + ->not->toBe(serialize('bar')); +}); + +test('custom value serializers can be used', function () { + $settings = settings(); + (fn () => $this->valueSerializer = new JsonValueSerializer)->call($settings); + + $settings->disableEncryption(); + + $settings->set('foo', 'my value'); + $settings->set('array-value', ['foo' => 'bar', 'bool' => true]); + + $this->assertDatabaseHas('settings', [ + 'value' => '"my value"', + ]); + + $this->assertDatabaseHas('settings', [ + 'value' => '{"foo":"bar","bool":true}', + ]); + + expect($settings->get('foo'))->toBe('my value') + ->and($settings->get('array-value'))->toBeArray() + ->and($settings->get('array-value'))->toMatchArray(['foo' => 'bar', 'bool' => true]); +}); + +it('can get all persisted values', function () { + $settings = settings(); + (fn () => $this->keyGenerator = (new ReadableKeyGenerator)->setContextSerializer(new DotNotationContextSerializer))->call($settings); + + $settings->set('one', 'value 1'); + $settings->set('two', 'value 2'); + + $storedSettings = $settings->all(); + + expect($storedSettings)->toHaveCount(2) + ->and($storedSettings[0]->key)->toBe('one') + ->and($storedSettings[0]->original_key)->toBe('one') + ->and($storedSettings[0]->value)->toBe('value 1') + ->and($storedSettings[1]->key)->toBe('two') + ->and($storedSettings[1]->original_key)->toBe('two') + ->and($storedSettings[1]->value)->toBe('value 2'); +}); + +test('retrieving all settings works with the Eloquent driver', function () { + $settings = settings(); + (fn () => $this->driver = new EloquentDriver(app(Setting::class)))->call($settings); + (fn () => $this->keyGenerator = (new ReadableKeyGenerator)->setContextSerializer(new DotNotationContextSerializer))->call($settings); + + $settings->set('one', 'value 1'); + $settings->set('two', 'value 2'); + + $storedSettings = $settings->all(); + + expect($storedSettings)->toHaveCount(2) + ->and($storedSettings[0]->key)->toBe('one') + ->and($storedSettings[0]->original_key)->toBe('one') + ->and($storedSettings[0]->value)->toBe('value 1') + ->and($storedSettings[1]->key)->toBe('two') + ->and($storedSettings[1]->original_key)->toBe('two') + ->and($storedSettings[1]->value)->toBe('value 2'); +}); + +it('can retrieve all settings for a given context', function () { + $settings = settings(); + (fn () => $this->keyGenerator = (new ReadableKeyGenerator)->setContextSerializer(new DotNotationContextSerializer))->call($settings); + + $context = new Context(['id' => 'foo']); + $contextTwo = new Context(['id' => 'foobar']); + + $settings->set('one', 'no context value'); + $settings->set('two', 'no context value 2'); + $settings->context($context)->set('one', 'context one value 1'); + $settings->context($context)->set('two', 'context one value 2'); + $settings->context($contextTwo)->set('one', 'context two value 1'); + + $storedSettings = $settings->context($context)->all(); + + expect($storedSettings)->toHaveCount(2) + ->and($storedSettings[0]->key)->toBe('one') + ->and($storedSettings[0]->original_key)->toBe('one:c:::id:foo') + ->and($storedSettings[0]->value)->toBe('context one value 1') + ->and($storedSettings[1]->key)->toBe('two') + ->and($storedSettings[1]->original_key)->toBe('two:c:::id:foo') + ->and($storedSettings[1]->value)->toBe('context one value 2'); +}); + +it('throws an exception when doing a partial context lookup using the md5 key generator', function () { + $settings = settings(); + (fn () => $this->keyGenerator = (new Md5KeyGenerator)->setContextSerializer(new ContextSerializer))->call($settings); + + SettingsFacade::context(new Context(['id' => 1]))->all(); +})->throws(InvalidKeyGenerator::class); + +it('can flush all settings', function () { + $settings = settings(); + (fn () => $this->keyGenerator = (new ReadableKeyGenerator)->setContextSerializer(new DotNotationContextSerializer))->call($settings); + + $settings->set('one', 'value 1'); + $settings->set('two', 'value 2'); + + $this->assertDatabaseCount('settings', 2); + + $settings->flush(); + + $this->assertDatabaseCount('settings', 0); +}); + +it('can flush a subset of settings', function () { + $settings = settings(); + (fn () => $this->keyGenerator = (new ReadableKeyGenerator)->setContextSerializer(new DotNotationContextSerializer))->call($settings); + + $settings->set('one', 'value 1'); + $settings->set('two', 'value 2'); + $settings->set('three', 'value 3'); + + $this->assertDatabaseCount('settings', 3); + + $settings->flush(['one', 'three']); + + $this->assertDatabaseCount('settings', 1); + + $this->assertDatabaseMissing('settings', [ + 'key' => 'one', + ]); + + $this->assertDatabaseMissing('settings', [ + 'key' => 'three', + ]); +}); + +it('can flush settings base on context', function () { + $settings = settings(); + (fn () => $this->keyGenerator = (new ReadableKeyGenerator)->setContextSerializer(new DotNotationContextSerializer))->call($settings); + + $context = new Context(['id' => 'foo']); + + $settings->set('one', 'value 1'); + $settings->context($context)->set('one', 'context 1'); + + $this->assertDatabaseCount('settings', 2); + + $settings->context($context)->flush(); + + $this->assertDatabaseCount('settings', 1); +}); + +it('dispatches an event when settings are flushed', function () { + $settings = settings(); + (fn () => $this->keyGenerator = (new ReadableKeyGenerator)->setContextSerializer(new DotNotationContextSerializer))->call($settings); + + Event::fake(); + + $settings->set('one', 'value 1'); + $settings->set('two', 'value 2'); + + $settings->flush(); + + Event::assertDispatched(SettingsFlushed::class); +}); + +it('dispatches an event when a setting is deleted', function () { + Event::fake(); + + SettingsFacade::set('foo', 'bar'); + SettingsFacade::forget('foo'); + + Event::assertDispatched(function (SettingWasDeleted $event) { + return $event->key === 'foo' + && $event->teamId === false + && is_null($event->context); + }); +}); + +it('dispatches an event when a setting is saved', function () { + Event::fake(); + + SettingsFacade::set('foo', 'bar'); + + Event::assertDispatched(function (SettingWasStored $event) { + return $event->key === 'foo' + && $event->value === 'bar'; + }); +}); + +it('does not dispatch the stored event if the setting value has not changed', function () { + Event::fake(); + + // This only works when caching is enabled. + SettingsFacade::enableCache(); + + SettingsFacade::set('foo', 'bar'); + SettingsFacade::set('foo', 'bar'); + + Event::assertDispatchedTimes(SettingWasStored::class, 1); +}); + +it('can generate the cache key for a given setting', function () { + $settings = settings(); + $settings->useCacheKeyPrefix('settings.'); + (fn () => $this->keyGenerator = (new ReadableKeyGenerator)->setContextSerializer(new DotNotationContextSerializer))->call($settings); + + expect(SettingsFacade::cacheKeyForSetting('foo'))->toBe('settings.foo') + ->and(SettingsFacade::context(new Context(['foo' => 'bar']))->cacheKeyForSetting('foo'))->toBe('settings.foo:c:::foo:bar'); - Settings::disableEncryption(); + SettingsFacade::enableTeams(); + SettingsFacade::setTeamId(1); - expect(Settings::get('foo'))->not()->toBe('bar') - ->and(Settings::get('foo'))->not()->toBe(serialize('bar')); + expect(SettingsFacade::cacheKeyForSetting('foo'))->toBe('settings.foo::team:1'); }); // Helpers... @@ -278,7 +508,7 @@ function assertQueryCount(int $expected): void function enableSettingsCache(): void { - Settings::enableCache(); + SettingsFacade::enableCache(); DB::connection()->enableQueryLog(); } @@ -348,7 +578,7 @@ function isSerialized(mixed $data): bool case 'd': $end = ''; - return (bool) preg_match("/^{$token}:[0-9.E+-]+;$end/", $data); + return (bool) preg_match("/^{$token}:[0-9.E+-]+;{$end}/", $data); } return false; diff --git a/tests/Feature/TeamsTest.php b/tests/Feature/TeamsTest.php new file mode 100644 index 0000000..add5bbd --- /dev/null +++ b/tests/Feature/TeamsTest.php @@ -0,0 +1,226 @@ + 'database', + 'settings.table' => 'settings', + 'settings.cache' => false, + 'settings.cache_key_prefix' => 'settings.', + 'settings.encryption' => false, + 'settings.teams' => true, + 'settings.team_foreign_key' => 'team_id', + ]); + + migrateTestTables(); + migrateTeams(); + + setDatabaseDriverConnection(); + + Team::factory()->create(); +}); + +test('teams can be enabled and disabled', function () { + // Should be enabled with the config value set to true + expect(SettingsFacade::teamsAreEnabled())->toBeTrue(); + + SettingsFacade::disableTeams(); + expect(SettingsFacade::teamsAreEnabled())->toBeFalse(); + + SettingsFacade::enableTeams(); + expect(SettingsFacade::teamsAreEnabled())->toBeTrue(); +}); + +test('team id can be set', function () { + expect(SettingsFacade::getTeamId())->toBeNull(); + + SettingsFacade::setTeamId(1); + + expect(SettingsFacade::getTeamId())->toBe(1); +}); + +it('sets a team id when saving', function () { + $team = Team::first(); + SettingsFacade::setTeamId($team); + + SettingsFacade::set('foo', 'bar'); + + $setting = DB::table('settings')->first(); + + expect($setting->team_id)->toBe($team->id); +}); + +it('updates team settings', function () { + $team = Team::first(); + SettingsFacade::setTeamId($team); + + SettingsFacade::set('foo', 'bar'); + SettingsFacade::set('foo', 'updated'); + + $this->assertDatabaseCount('settings', 1); + + $setting = DB::table('settings')->first(); + $value = unserialize($setting->value); + + expect($setting)->team_id->toBe($team->id) + ->and($value)->toBe('updated'); +}); + +test('two teams can have the same setting', function () { + $team1 = Team::first(); + $team2 = Team::factory()->create(); + + SettingsFacade::setTeamId($team1); + SettingsFacade::set('foo', 'team 1 value'); + + SettingsFacade::setTeamId($team2); + SettingsFacade::set('foo', 'team 2 value'); + + $this->assertDatabaseCount('settings', 2); + + $setting1 = DB::table('settings')->where('team_id', $team1->id)->first(); + $setting2 = DB::table('settings')->where('team_id', $team2->id)->first(); + + expect($setting1->team_id)->toBe($team1->id) + ->and(unserialize($setting1->value))->toBe('team 1 value') + ->and($setting2->team_id)->toBe($team2->id) + ->and(unserialize($setting2->value))->toBe('team 2 value') + ->and($setting1->key)->toBe($setting2->key); +}); + +it('checks if a team has a setting', function () { + SettingsFacade::set('foo', 'null team'); + expect(SettingsFacade::has('foo'))->toBeTrue(); + + $team = Team::first(); + + SettingsFacade::setTeamId($team); + expect(SettingsFacade::has('foo'))->toBeFalse(); + + SettingsFacade::set('foo', 'team value'); + expect(SettingsFacade::has('foo'))->toBeTrue(); +}); + +it('gets a team setting value', function () { + $team = Team::first(); + $team2 = Team::factory()->create(); + + // Also verify that no team id can be used + SettingsFacade::setTeamId(null); + SettingsFacade::set('foo', 'no team value'); + expect(SettingsFacade::get('foo'))->toBe('no team value'); + + SettingsFacade::setTeamId($team); + SettingsFacade::set('foo', 'team value'); + expect(SettingsFacade::get('foo'))->toBe('team value'); + + SettingsFacade::setTeamId($team2); + SettingsFacade::set('foo', 'team 2 value'); + expect(SettingsFacade::get('foo'))->toBe('team 2 value'); +}); + +it('forgets the settings for a team', function () { + $team = Team::first(); + + SettingsFacade::set('foo', 'no team value'); + + SettingsFacade::setTeamId($team); + SettingsFacade::set('foo', 'team value'); + + $this->assertDatabaseCount('settings', 2); + + SettingsFacade::forget('foo'); + + $this->assertDatabaseCount('settings', 1); + $this->assertDatabaseMissing('settings', [ + 'team_id' => $team->id, + ]); + $this->assertDatabaseHas('settings', [ + 'team_id' => null, + ]); +}); + +test('the cache is scoped for teams', function () { + $team = Team::first(); + $team2 = Team::factory()->create(); + + SettingsFacade::setTeamId($team); + SettingsFacade::set('foo', 'team 1 value'); + + SettingsFacade::setTeamId($team2); + SettingsFacade::set('foo', 'team 2 value'); + + SettingsFacade::enableCache(); + + expect(SettingsFacade::get('foo'))->toBe('team 2 value'); + + SettingsFacade::setTeamId($team); + + expect(SettingsFacade::get('foo'))->toBe('team 1 value'); +}); + +test("all of a team's settings can be retrieved at once", function () { + $team = Team::first(); + + $settings = settings(); + (fn () => $this->keyGenerator = (new ReadableKeyGenerator)->setContextSerializer(new DotNotationContextSerializer))->call($settings); + + $settings->set('one', 'non-team value'); + $settings->context(new Context(['id' => 'foo']))->set('one', 'non-team value context 1'); + + $settings->setTeamId($team); + + $settings->set('one', 'team value'); + $settings->set('two', 'team value 2'); + $settings->context(new Context(['id' => 'foo']))->set('one', 'team value context 1'); + + $storedSettings = $settings->all(); + + expect($storedSettings)->toHaveCount(3) + ->and($storedSettings[0]->key)->toBe('one') + ->and($storedSettings[0]->team_id)->toBe($team->id) + ->and($storedSettings[0]->value)->toBe('team value') + ->and($storedSettings[1]->key)->toBe('two') + ->and($storedSettings[1]->team_id)->toBe($team->id) + ->and($storedSettings[1]->value)->toBe('team value 2') + ->and($storedSettings[2]->key)->toBe('one') + ->and($storedSettings[2]->team_id)->toBe($team->id) + ->and($storedSettings[2]->value)->toBe('team value context 1') + ->and($storedSettings[2]->original_key)->toBe('one:c:::id:foo'); + + // Can get all team settings without context attached to them. + $nonContextSettings = $settings->context(false)->all(); + + expect($nonContextSettings)->toHaveCount(2) + ->and($nonContextSettings->pluck('value'))->not->toContain('team value context 1'); +}); + +test("a team's settings can be flushed", function () { + $team = Team::first(); + + $settings = settings(); + (fn () => $this->keyGenerator = (new ReadableKeyGenerator)->setContextSerializer(new DotNotationContextSerializer))->call($settings); + + $settings->set('one', 'non-team value'); + + $settings->setTeamId($team); + $settings->set('one', 'team value'); + + $this->assertDatabaseCount('settings', 2); + + $settings->flush(); + + $this->assertDatabaseCount('settings', 1); + + $this->assertDatabaseMissing('settings', [ + 'team_id' => $team->id, + ]); +}); diff --git a/tests/Pest.php b/tests/Pest.php index fbddc41..8a2054b 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,24 +1,38 @@ in(__DIR__); // Helpers... -if (! function_exists('fake') && class_exists(\Faker\Factory::class)) { - /** - * Ensure the fake method exists. If we ever drop laravel 8 support, we can remove this helper. - */ - function fake($locale = null) - { - $locale ??= app('config')->get('app.faker_locale') ?? 'en_US'; - - $abstract = \Faker\Generator::class . ':' . $locale; - - if (! app()->bound($abstract)) { - app()->singleton($abstract, fn () => \Faker\Factory::create($locale)); - } - - return app()->make($abstract); - } + +/** + * The Database driver doesn't seem to be using the same Sqlite connection + * the tests are using, so we'll force it to here. This should fix issues + * with the settings table not existing when the driver queries it. + */ +function setDatabaseDriverConnection(): void +{ + $driver = Settings::getDriver(); + $reflection = new ReflectionClass($driver); + + $property = $reflection->getProperty('connection'); + $property->setAccessible(true); + $property->setValue($driver, DB::connection()); +} + +function migrateTestTables(): void +{ + $migration = include __DIR__ . '/Support/database/migrations/create_test_tables.php'; + $migration->up(); +} + +function migrateTeams(): void +{ + $migration = include __DIR__ . '/../database/migrations/add_settings_team_field.php.stub'; + $migration->up(); } diff --git a/tests/Support/Drivers/CustomDriver.php b/tests/Support/Drivers/CustomDriver.php index 9740b29..944b99b 100644 --- a/tests/Support/Drivers/CustomDriver.php +++ b/tests/Support/Drivers/CustomDriver.php @@ -4,27 +4,37 @@ namespace Rawilk\Settings\Tests\Support\Drivers; +use Illuminate\Contracts\Support\Arrayable; use Rawilk\Settings\Contracts\Driver; -class CustomDriver implements Driver +final class CustomDriver implements Driver { - public function forget($key): void + public function forget($key, $teamId = null): void { // } - public function get(string $key, $default = null) + public function get(string $key, $default = null, $teamId = null) { return $default; } - public function has($key): bool + public function has($key, $teamId = null): bool { return true; } - public function set(string $key, $value = null): void + public function set(string $key, $value = null, $teamId = null): void { // } + + public function all($teamId = null, $keys = null): array|Arrayable + { + return []; + } + + public function flush($teamId = null, $keys = null): void + { + } } diff --git a/tests/Support/Models/Company.php b/tests/Support/Models/Company.php index 777fef3..658a5db 100644 --- a/tests/Support/Models/Company.php +++ b/tests/Support/Models/Company.php @@ -9,11 +9,13 @@ use Rawilk\Settings\Models\HasSettings; use Rawilk\Settings\Tests\Support\database\factories\CompanyFactory; -class Company extends Model +final class Company extends Model { use HasFactory; use HasSettings; + protected static bool $flushSettingsOnDelete = false; + protected static function newFactory(): CompanyFactory { return new CompanyFactory; diff --git a/tests/Support/Models/CustomUser.php b/tests/Support/Models/CustomUser.php index 36e521e..a9637f4 100644 --- a/tests/Support/Models/CustomUser.php +++ b/tests/Support/Models/CustomUser.php @@ -4,7 +4,7 @@ namespace Rawilk\Settings\Tests\Support\Models; -class CustomUser extends User +final class CustomUser extends User { protected $table = 'users'; diff --git a/tests/Support/Models/Team.php b/tests/Support/Models/Team.php new file mode 100644 index 0000000..1b0fe99 --- /dev/null +++ b/tests/Support/Models/Team.php @@ -0,0 +1,19 @@ + + */ final class CompanyFactory extends Factory { protected $model = Company::class; - /** - * @return array - */ public function definition(): array { return [ diff --git a/tests/Support/database/factories/TeamFactory.php b/tests/Support/database/factories/TeamFactory.php new file mode 100644 index 0000000..32ecd9f --- /dev/null +++ b/tests/Support/database/factories/TeamFactory.php @@ -0,0 +1,23 @@ + + */ +final class TeamFactory extends Factory +{ + protected $model = Team::class; + + public function definition(): array + { + return [ + 'name' => fake()->name(), + ]; + } +} diff --git a/tests/Support/database/migrations/create_test_tables.php b/tests/Support/database/migrations/create_test_tables.php index a27f396..e5b7ead 100644 --- a/tests/Support/database/migrations/create_test_tables.php +++ b/tests/Support/database/migrations/create_test_tables.php @@ -21,5 +21,11 @@ public function up(): void $table->string('name'); $table->timestamps(); }); + + Schema::create('teams', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->timestamps(); + }); } }; diff --git a/tests/TestCase.php b/tests/TestCase.php index 6eac549..f431e2d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -9,16 +9,22 @@ class TestCase extends Orchestra { - protected function getPackageProviders($app): array + public function getEnvironmentSetUp($app): void { - return [ - SettingsServiceProvider::class, + $migrations = [ + 'create_settings_table.php.stub', ]; + + foreach ($migrations as $migrationName) { + $migration = include __DIR__ . '/../database/migrations/' . $migrationName; + $migration->up(); + } } - public function getEnvironmentSetUp($app): void + protected function getPackageProviders($app): array { - $migration = include __DIR__ . '/../database/migrations/create_settings_table.php.stub'; - $migration->up(); + return [ + SettingsServiceProvider::class, + ]; } } diff --git a/tests/Unit/ContextSerializerTest.php b/tests/Unit/ContextSerializers/ContextSerializerTest.php similarity index 86% rename from tests/Unit/ContextSerializerTest.php rename to tests/Unit/ContextSerializers/ContextSerializerTest.php index 1859660..a6d9255 100644 --- a/tests/Unit/ContextSerializerTest.php +++ b/tests/Unit/ContextSerializers/ContextSerializerTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); use Rawilk\Settings\Support\Context; -use Rawilk\Settings\Support\ContextSerializer; +use Rawilk\Settings\Support\ContextSerializers\ContextSerializer; it('accepts a context argument', function () { $context = (new Context)->set('a', 'a'); diff --git a/tests/Unit/ContextSerializers/DotNotationContextSerializerTest.php b/tests/Unit/ContextSerializers/DotNotationContextSerializerTest.php new file mode 100644 index 0000000..27c1cbc --- /dev/null +++ b/tests/Unit/ContextSerializers/DotNotationContextSerializerTest.php @@ -0,0 +1,33 @@ +serializer = new DotNotationContextSerializer; +}); + +it('serializes a context object to dot notation', function () { + $context = new Context([ + 'model' => User::class, + 'id' => 1, + 'bool_value' => true, + ]); + + expect($this->serializer->serialize($context))->toBe('model:user::id:1::bool_value:1'); +}); + +it('serializes null values to an empty string', function () { + expect($this->serializer->serialize(null))->toBe(''); +}); + +it('handles false boolean values', function () { + $context = new Context([ + 'bool-value' => false, + ]); + + expect($this->serializer->serialize($context))->toBe('bool-value:0'); +}); diff --git a/tests/Unit/ContextTest.php b/tests/Unit/ContextTest.php index 92ea4b4..e61991c 100644 --- a/tests/Unit/ContextTest.php +++ b/tests/Unit/ContextTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Rawilk\Settings\Exceptions\InvalidContextValue; use Rawilk\Settings\Support\Context; it('serializes values when created', function () { @@ -34,3 +35,21 @@ $context = new Context; $context->get('test'); })->throws(OutOfBoundsException::class); + +it('can be converted to an array', function () { + $context = new Context(['id' => 1, 'model' => 'User']); + + expect($context->toArray()) + ->toBeArray() + ->toMatchArray([ + 'id' => 1, + 'model' => 'User', + ]); +}); + +it('only accepts numeric, string, or boolean values', function () { + new Context([ + 'id' => 1, + 'invalid-key' => ['array'], + ]); +})->throws(InvalidContextValue::class, 'invalid-key'); diff --git a/tests/Unit/KeyGeneratorTest.php b/tests/Unit/KeyGeneratorTest.php deleted file mode 100644 index f758b88..0000000 --- a/tests/Unit/KeyGeneratorTest.php +++ /dev/null @@ -1,24 +0,0 @@ -shouldReceive('serialize') - ->with($context) - ->andReturn('serialized'); - - $generator = new KeyGenerator($serializer); - - expect($generator->generate('key', $context))->toBe(md5('keyserialized')); -}); diff --git a/tests/Unit/KeyGenerators/Md5KeyGeneratorTest.php b/tests/Unit/KeyGenerators/Md5KeyGeneratorTest.php new file mode 100644 index 0000000..65a5fce --- /dev/null +++ b/tests/Unit/KeyGenerators/Md5KeyGeneratorTest.php @@ -0,0 +1,39 @@ +keyGenerator = (new Md5KeyGenerator) + ->setContextSerializer(new ContextSerializer); +}); + +it('generates an md5 hash of a key', function () { + // N; is for a serialized null context object + expect($this->keyGenerator->generate('my-key'))->toBe(md5('my-keyN;')); +}); + +it('generates an md5 hash of a key and context object', function () { + $context = new Context([ + 'id' => 123, + ]); + + expect($this->keyGenerator->generate('my-key', $context)) + ->toBe(md5('my-key' . serialize($context))); +}); + +it('works with other context serializers', function () { + $this->keyGenerator->setContextSerializer(new DotNotationContextSerializer); + + $context = new Context([ + 'id' => 123, + 'bool-value' => false, + ]); + + expect($this->keyGenerator->generate('my-key', $context)) + ->toBe(md5('my-keyid:123::bool-value:0')); +}); diff --git a/tests/Unit/KeyGenerators/ReadableKeyGeneratorTest.php b/tests/Unit/KeyGenerators/ReadableKeyGeneratorTest.php new file mode 100644 index 0000000..87527b5 --- /dev/null +++ b/tests/Unit/KeyGenerators/ReadableKeyGeneratorTest.php @@ -0,0 +1,42 @@ +keyGenerator = (new ReadableKeyGenerator) + ->setContextSerializer(new DotNotationContextSerializer); +}); + +it('generates a key without context', function (string $key, string $expectedKey) { + expect($this->keyGenerator->generate($key))->toBe($expectedKey); +})->with([ + ['my-key', 'my-key'], + ['my key', 'my-key'], + ['MY key', 'my-key'], + ['my.key', 'my.key'], +]); + +it('generates a key with context', function () { + $context = new Context([ + 'id' => 123, + 'model' => User::class, + ]); + + expect($this->keyGenerator->generate('app.timezone', $context))->toBe('app.timezone:c:::id:123::model:user'); +}); + +it('works with other context serializers', function () { + $this->keyGenerator->setContextSerializer(new ContextSerializer); + + $context = new Context([ + 'id' => 123, + ]); + + expect($this->keyGenerator->generate('my-key', $context))->toBe('my-key:c:::' . serialize($context)); +}); diff --git a/tests/Unit/ValueSerializers/JsonValueSerializerTest.php b/tests/Unit/ValueSerializers/JsonValueSerializerTest.php new file mode 100644 index 0000000..3967d21 --- /dev/null +++ b/tests/Unit/ValueSerializers/JsonValueSerializerTest.php @@ -0,0 +1,38 @@ +serializer = new JsonValueSerializer; +}); + +it('serializes values as json', function (mixed $value, string $expected) { + expect($this->serializer->serialize($value))->toBe($expected); +})->with([ + [1, '1'], + ['1', '"1"'], + [true, 'true'], + [false, 'false'], + [0, '0'], + [null, 'null'], + [1.1, '1.1'], + [['array' => 'array'], '{"array":"array"}'], + [[1, '2', 3], '[1,"2",3]'], + [(object) ['a' => 'b'], '{"a":"b"}'], +]); + +it('unserializes json values', function (string $value, mixed $expected) { + expect($this->serializer->unserialize($value))->toBe($expected); +})->with([ + ['1', 1], + ['"1"', '1'], + ['true', true], + ['false', false], + ['0', 0], + ['null', null], + ['1.1', 1.1], + ['{"array":"array"}', ['array' => 'array']], + ['{"a":"b"}', ['a' => 'b']], +]); diff --git a/tests/Unit/ValueSerializerTest.php b/tests/Unit/ValueSerializers/ValueSerializerTest.php similarity index 63% rename from tests/Unit/ValueSerializerTest.php rename to tests/Unit/ValueSerializers/ValueSerializerTest.php index f55370b..dfaad53 100644 --- a/tests/Unit/ValueSerializerTest.php +++ b/tests/Unit/ValueSerializers/ValueSerializerTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use Rawilk\Settings\Support\ValueSerializer; +use Rawilk\Settings\Support\ValueSerializers\ValueSerializer; it('serializes values', function (mixed $value) { $serializer = new ValueSerializer; @@ -15,13 +15,19 @@ $serialized = serialize($value); - expect($serializer->unserialize($serialized))->toEqual($value); + if (is_object($value)) { + expect($serializer->unserialize($serialized))->toBeObject(); + } else { + expect($serializer->unserialize($serialized))->toEqual($value); + } })->with('values'); dataset('values', [ null, 1, 1.1, + true, + false, 'string', ['array' => 'array'], (object) ['a' => 'b'],