diff --git a/.gitattributes b/.gitattributes index 71cac0c..43de6b0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,6 +8,8 @@ /.gitattributes export-ignore /.gitignore export-ignore /.travis.yml export-ignore +/.scrutinizer.yml export-ignore /.php-cs-fixer.dist.php export-ignore /kahlan-config.php export-ignore /phpstan.neon.dist export-ignore +/phpstan-baseline.php export-ignore diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index d3a09f3..8e66683 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -10,23 +10,23 @@ jobs: runs-on: ubuntu-latest if: ${{ github.actor == 'dependabot[bot]' }} steps: - + - name: Dependabot metadata id: metadata - uses: dependabot/fetch-metadata@v1.6.0 + uses: dependabot/fetch-metadata@v2.0.0 with: github-token: "${{ secrets.GITHUB_TOKEN }}" - + - name: Auto-merge Dependabot PRs for semver-minor updates if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}} run: gh pr merge --auto --merge "$PR_URL" env: PR_URL: ${{github.event.pull_request.html_url}} GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - + - name: Auto-merge Dependabot PRs for semver-patch updates if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}} run: gh pr merge --auto --merge "$PR_URL" env: PR_URL: ${{github.event.pull_request.html_url}} - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} \ No newline at end of file + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/fix-php-code-style-issues.yml b/.github/workflows/fix-php-code-style-issues.yml index 584be23..f38f8aa 100644 --- a/.github/workflows/fix-php-code-style-issues.yml +++ b/.github/workflows/fix-php-code-style-issues.yml @@ -1,6 +1,12 @@ name: Check & fix styling -on: [push] +on: + push: + branches: + - 'main' + pull_request: + branches: + - 'main' permissions: contents: write @@ -15,12 +21,13 @@ jobs: with: ref: ${{ github.head_ref }} - - name: Run PHP CS Fixer - uses: docker://oskarstark/php-cs-fixer-ga - with: - args: --config=.php-cs-fixer.dist.php --allow-risky=yes + - name: Install Composer packages + run: composer update --ansi --no-interaction + + - name: Run PHP-CS-Fixer Lint + run: vendor/bin/php-cs-fixer fix - name: Commit changes uses: stefanzweifel/git-auto-commit-action@v5 with: - commit_message: Fix styling \ No newline at end of file + commit_message: Fix styling diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 3b0d547..7778cdf 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -1,6 +1,25 @@ name: Tests -on: [push, pull_request] +on: + push: + branches: + - 'devs' + paths: + - 'src/**.php' + - 'spec/**.php' + - composer.json + - kahlan-config.php + - .github/workflows/run-tests.yml + + pull_request: + branches: + - 'devs' + paths: + - 'src/**.php' + - 'spec/**.php' + - composer.json + - kahlan-config.php + - .github/workflows/run-tests.yml concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} diff --git a/.github/workflows/test-coding-standards.yml b/.github/workflows/test-coding-standards.yml index ac193d2..c84d406 100644 --- a/.github/workflows/test-coding-standards.yml +++ b/.github/workflows/test-coding-standards.yml @@ -1,8 +1,6 @@ name: Coding Standards on: - schedule: - - cron: '0 0 * * *' pull_request: paths: - '**.php' @@ -15,10 +13,10 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true - + permissions: contents: read - + jobs: lint: name: PHP ${{ matrix.php-version }} Lint with PHP CS Fixer @@ -54,11 +52,8 @@ jobs: ${{ runner.os }}-${{ matrix.php-version }}- ${{ runner.os }}- - - name: Setup Composer's GitHub OAuth access - run: composer config --global github-oauth.github.com ${{ secrets.GITHUB_TOKEN }} - - name: Install dependencies run: composer update --ansi --no-interaction - - name: Run PHP CS Fixer - run: vendor/bin/php-cs-fixer fix --verbose --ansi --dry-run --using-cache=no --diff + - name: Run lint + run: composer cs diff --git a/.github/workflows/test-phpstan.yml b/.github/workflows/test-phpstan.yml index 571bf4f..a04d542 100644 --- a/.github/workflows/test-phpstan.yml +++ b/.github/workflows/test-phpstan.yml @@ -3,9 +3,9 @@ name: PHPStan on: - schedule: - - cron: '0 0 * * *' pull_request: + branches: + - 'devs' paths: - 'src/**.php' - composer.json @@ -13,6 +13,8 @@ on: - phpstan-baseline.php - '.github/workflows/test-phpstan.yml' push: + branches: + - 'devs' paths: - 'src/**.php' - composer.json @@ -29,7 +31,7 @@ permissions: jobs: build: - name: PHP ${{ matrix.php-version }} Static Analysis + name: PHP ${{ matrix.php-versions }} Static Analysis runs-on: ubuntu-22.04 strategy: fail-fast: false @@ -49,7 +51,7 @@ jobs: - name: Validate composer.json run: composer validate --strict - + - name: Get composer cache directory run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV @@ -58,7 +60,7 @@ jobs: with: path: ${{ env.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: composer-${{ runner.os }}- + restore-keys: ${{ runner.os }}-composer- - name: Create PHPStan result cache directory run: mkdir -p build/phpstan @@ -70,11 +72,8 @@ jobs: key: ${{ runner.os }}-phpstan-${{ github.sha }} restore-keys: ${{ runner.os }}-phpstan- - - name: Setup Composer's GitHub OAuth access - run: composer config --global github-oauth.github.com ${{ secrets.GITHUB_TOKEN }} - - name: Install dependencies run: composer update --ansi --no-interaction - - name: Run PHPStan - run: vendor/bin/phpstan analyse + - name: Run static analysis + run: composer phpstan:check diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml index 47d2917..ec40921 100644 --- a/.github/workflows/update-changelog.yml +++ b/.github/workflows/update-changelog.yml @@ -28,4 +28,4 @@ jobs: with: branch: main commit_message: Update CHANGELOG - file_pattern: CHANGELOG.md \ No newline at end of file + file_pattern: CHANGELOG.md diff --git a/composer.json b/composer.json index d3852c7..46397f2 100644 --- a/composer.json +++ b/composer.json @@ -43,6 +43,10 @@ "Composer\\Config::disableProcessTimeout", "vendor/bin/kahlan --coverage=4 --reporter=verbose --clover=clover.xml" ], + "cs": [ + "Composer\\Config::disableProcessTimeout", + "php-cs-fixer check --ansi --verbose --diff" + ], "cs:fix": [ "Composer\\Config::disableProcessTimeout", "vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes" @@ -58,7 +62,8 @@ }, "scripts-descriptions": { "test": "Execute les tests unitaires", - "cs:fix": "Corriger le style de codage", + "cs": "Verifie le style de codage", + "cs:fix": "Corrige le style de codage", "phpstan:baseline": "Exécute PHPStan puis transférer toutes les erreurs vers le fichier de baseline", "phpstan:check": "Exécute PHPStan avec la prise en charge des identifiants" }, diff --git a/spec/FileHandler.spec.php b/spec/FileHandler.spec.php new file mode 100644 index 0000000..f99c7bb --- /dev/null +++ b/spec/FileHandler.spec.php @@ -0,0 +1,341 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use BlitzPHP\Parametres\Exceptions\ParametresException; +use BlitzPHP\Parametres\Parametres; +use BlitzPHP\Utilities\Date; +use BlitzPHP\Utilities\Iterable\Arr; + +use function Kahlan\expect; + +describe('Parametres / FileHandler', function () { + beforeAll(function () { + config()->set('parametres.file.path', $path = storage_path('.parametres.json')); + $this->path = $path; + + $this->seeInFile = function (array $where) { + $data = json_decode(file_get_contents($this->path), true) ?: []; + $data = collect($data); + + foreach ($where as $k => $v) { + if ($v === null) { + $data = $data->whereNull($k); + } else { + $data = $data->where($k, '=', $v); + } + } + + return $data->isNotEmpty(); + }; + + $this->insertFakeData = function (array $data) { + $base = [ + 'type' => 'string', + 'context' => null, + 'created_at' => Date::now()->format('Y-m-d H:i:s'), + 'updated_at' => Date::now()->format('Y-m-d H:i:s'), + ]; + if (Arr::dimensions($data) === 1) { + $data = [$data]; + } + + $data = array_map(fn ($item) => array_merge($base, $item + [ + 'id' => uniqid(more_entropy: true), + ]), $data); + + file_put_contents($this->path, json_encode($data, JSON_PRETTY_PRINT)); + }; + }); + + beforeEach(function () { + $config = config('parametres'); + $config['handlers'] = ['file']; + + $this->parametres = new Parametres($config); + }); + + afterEach(function () { + @unlink($this->path); + }); + + it('Lève une exception si le chemin d\'accès du fichier de stockage n\'est pas specifié', function () { + $config = config('parametres'); + $config['handlers'] = ['file']; + $config['file']['path'] = ''; + + expect(fn () => new Parametres($config))->toThrow(ParametresException::fileForStorageNotDefined()); + }); + + it('Lève une exception si le dossier du fichier de stockage n\'existe pas', function () { + $config = config('parametres'); + $config['handlers'] = ['file']; + $config['file']['path'] = $path = __DIR__ . '/app/parametres.json'; + + expect(fn () => new Parametres($config))->toThrow(ParametresException::directoryOfFileNotFound($path)); + }); + + it('Insert bien les donnees dans le fichier de stockage', function () { + $this->parametres->set('test.site_name', 'Foo'); + + expect($this->seeInFile([ + 'file' => 'test', + 'key' => 'site_name', + 'value' => 'Foo', + 'type' => 'string', + ]))->toBeTruthy(); + }); + + it('Peut definir une valeur booleenne `true`', function () { + $this->parametres->set('test.site_name', true); + + expect($this->seeInFile([ + 'file' => 'test', + 'key' => 'site_name', + 'value' => 1, + 'type' => 'boolean', + ]))->toBeTruthy(); + + expect($this->parametres->get('test.site_name'))->toBeTruthy(); + }); + + it('Peut definir une valeur booleenne `false`', function () { + $this->parametres->set('test.site_name', false); + + expect($this->seeInFile([ + 'file' => 'test', + 'key' => 'site_name', + 'value' => 0, + 'type' => 'boolean', + ]))->toBeTruthy(); + + expect($this->parametres->get('test.site_name'))->toBeFalsy(); + }); + + it('Peut definir une valeur à `null`', function () { + $this->parametres->set('test.site_name', null); + + expect($this->seeInFile([ + 'file' => 'test', + 'key' => 'site_name', + 'value' => null, + 'type' => 'NULL', + ]))->toBeTruthy(); + + expect($this->parametres->get('test.site_name'))->toBeNull(); + }); + + it('Peut inserer un tableau de donnees', function () { + $data = ['foo' => 'bar']; + $this->parametres->set('test.site_name', $data); + + expect($this->seeInFile([ + 'file' => 'test', + 'key' => 'site_name', + 'value' => serialize($data), + 'type' => 'array', + ]))->toBeTruthy(); + + expect($this->parametres->get('test.site_name'))->toBe($data); + }); + + it('Peut inserer un object', function () { + $data = (object) ['foo' => 'bar']; + $this->parametres->set('test.site_name', $data); + + expect($this->seeInFile([ + 'file' => 'test', + 'key' => 'site_name', + 'value' => serialize($data), + 'type' => 'object', + ]))->toBeTruthy(); + + expect((array) $this->parametres->get('test.site_name'))->toBe((array) $data); + }); + + it('Peut modifier une entree existante dans le fichier de stockage', function () { + $this->insertFakeData([ + 'file' => 'test', + 'key' => 'site_name', + 'value' => 'foo', + ]); + + $this->parametres->set('test.site_name', 'Bar'); + + expect($this->seeInFile([ + 'file' => 'test', + 'key' => 'site_name', + 'value' => 'Bar', + ]))->toBeTruthy(); + }); + + it('Peut modifier une entree existante dans le fichier de stockage et laisser les autres intacte', function () { + $this->insertFakeData([ + $t1 = [ + 'file' => 'test', + 'key' => 'site_name', + 'value' => 'foo', + ], + $t2 = [ + 'file' => 'test', + 'key' => 'site_lang', + 'value' => 'fr', + ], + $t3 = [ + 'file' => 'fake', + 'key' => 'site_name', + 'value' => 'foo', + ], + ]); + + $this->parametres->set('test.site_name', 'Bar'); + + expect($this->seeInFile([ + 'file' => 'test', + 'key' => 'site_name', + 'value' => 'Bar', + ]))->toBeTruthy(); + + expect($this->seeInFile($t1))->toBeFalsy(); + + expect($this->seeInFile($t2))->toBeTruthy(); + + expect($this->seeInFile($t3))->toBeTruthy(); + }); + + it('Peut fonctionner sans le fichier de configuration', function () { + $this->parametres->set('nada.site_name', 'Bar'); + + expect($this->seeInFile([ + 'file' => 'nada', + 'key' => 'site_name', + 'value' => 'Bar', + ]))->toBeTruthy(); + + expect($this->parametres->get('nada.site_name'))->toBe('Bar'); + }); + + it('Peut supprimer les donnees dans le fichier de stockage', function () { + $this->insertFakeData([ + 'file' => 'test', + 'key' => 'site_name', + 'value' => 'foo', + ]); + + $this->parametres->forget('test.site_name'); + + expect($this->seeInFile([ + 'file' => 'test', + 'key' => 'site_name', + ]))->toBeFalsy(); + }); + + it("Peut supprimer une donnee meme si elle n'est pas deja presente dans le fichier de stockage", function () { + $this->parametres->forget('test.site_name'); + + expect($this->seeInFile([ + 'file' => 'test', + 'key' => 'site_name', + ]))->toBeFalsy(); + }); + + it('Peut vider toutes les donnees en db, et continuer a utiliser les donnees du fichier de configuration', function () { + // Valeur par defaut issue du fichier de config + expect('Parametres Test')->toBe($this->parametres->get('test.site_name')); + + $this->parametres->set('test.site_name', 'Foo'); + + // Doit etre la derniere valeur definie + expect('Foo')->toBe($this->parametres->get('test.site_name')); + + $this->parametres->flush(); + + expect($this->seeInFile([ + 'file' => 'test', + 'key' => 'site_name', + ]))->toBeFalsy(); + + // Doit rentrer à la valeur par défaut + expect('Parametres Test')->toBe($this->parametres->get('test.site_name')); + }); + + it('Peut definir une donnee avec le contexte', function () { + $this->parametres->set('test.site_name', 'Banana', 'environment:test'); + + expect($this->seeInFile([ + 'file' => 'test', + 'key' => 'site_name', + 'value' => 'Banana', + 'type' => 'string', + 'context' => 'environment:test', + ]))->toBeTruthy(); + }); + + it("Peut modifier les donnees d'un context uniquement", function () { + $this->parametres->set('test.site_name', 'Humpty'); + $this->parametres->set('test.site_name', 'Jack', 'context:male'); + $this->parametres->set('test.site_name', 'Jill', 'context:female'); + $this->parametres->set('test.site_name', 'Jane', 'context:female'); + + expect($this->seeInFile([ + 'file' => 'test', + 'key' => 'site_name', + 'value' => 'Jane', + 'type' => 'string', + 'context' => 'context:female', + ]))->toBeTruthy(); + + expect($this->seeInFile([ + 'file' => 'test', + 'key' => 'site_name', + 'value' => 'Humpty', + 'type' => 'string', + 'context' => null, + ]))->toBeTruthy(); + + expect($this->seeInFile([ + 'file' => 'test', + 'key' => 'site_name', + 'value' => 'Jack', + 'type' => 'string', + 'context' => 'context:male', + ]))->toBeTruthy(); + }); + + it("Peut supprimer les donnees d'un context uniquement", function () { + $this->parametres->set('test.site_name', 'Humpty'); + $this->parametres->set('test.site_name', 'Jack', 'context:male'); + $this->parametres->set('test.site_name', 'Jane', 'context:female'); + + $this->parametres->forget('test.site_name', 'context:female'); + + expect($this->seeInFile([ + 'file' => 'test', + 'key' => 'site_name', + 'context' => 'context:female', + ]))->toBeFalsy(); + + expect($this->seeInFile([ + 'file' => 'test', + 'key' => 'site_name', + 'value' => 'Humpty', + 'type' => 'string', + 'context' => null, + ]))->toBeTruthy(); + + expect($this->seeInFile([ + 'file' => 'test', + 'key' => 'site_name', + 'value' => 'Jack', + 'type' => 'string', + 'context' => 'context:male', + ]))->toBeTruthy(); + }); +}); diff --git a/spec/bootstrap.php b/spec/bootstrap.php index 7bffb19..fd3218c 100644 --- a/spec/bootstrap.php +++ b/spec/bootstrap.php @@ -22,6 +22,7 @@ require_once SYST_PATH . 'Constants/constants.php'; require_once SYST_PATH . 'Helpers/common.php'; +require_once SYST_PATH . 'Helpers/path.php'; Services::autoloader()->initialize()->register(); Services::container()->initialize(); diff --git a/src/Config/parametres.php b/src/Config/parametres.php index 353b1b3..5e6520b 100644 --- a/src/Config/parametres.php +++ b/src/Config/parametres.php @@ -11,6 +11,7 @@ use BlitzPHP\Parametres\Handlers\ArrayHandler; use BlitzPHP\Parametres\Handlers\DatabaseHandler; +use BlitzPHP\Parametres\Handlers\FileHandler; return [ /** @@ -38,4 +39,13 @@ 'group' => null, 'writeable' => true, ], + + /** + * Paramètres du gestionnaire "File". + */ + 'file' => [ + 'class' => FileHandler::class, + 'path' => storage_path('app/.parameters.json'), + 'writeable' => true, + ], ]; diff --git a/src/Exceptions/ParametresException.php b/src/Exceptions/ParametresException.php new file mode 100644 index 0000000..7038e4b --- /dev/null +++ b/src/Exceptions/ParametresException.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Parametres\Exceptions; + +use RuntimeException; + +class ParametresException extends RuntimeException +{ + public static function fileForStorageNotDefined(): self + { + return new self("Le fichier de stockage des données n'a pas été définit"); + } + + public static function directoryOfFileNotFound(string $path): self + { + return new self("Le répertoire du fichier '{$path}' n'a pas été trouvé"); + } +} diff --git a/src/Handlers/FileHandler.php b/src/Handlers/FileHandler.php new file mode 100644 index 0000000..c456166 --- /dev/null +++ b/src/Handlers/FileHandler.php @@ -0,0 +1,182 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Parametres\Handlers; + +use BlitzPHP\Parametres\Exceptions\ParametresException; +use BlitzPHP\Utilities\Date; +use BlitzPHP\Utilities\Iterable\Collection; + +class FileHandler extends ArrayHandler +{ + /** + * Chemin d'accès du fichier de stockage des paramètres + */ + private string $path; + + /** + * Tableau des contextes qui ont été stockés. + * + * @var list|list + */ + private array $hydrated = []; + + /** + * @param array $config + */ + public function __construct(array $config = []) + { + if ($config === []) { + $config = config('parametres.file', []); + } + + if ('' === $this->path = ($config['path'] ?? '')) { + throw ParametresException::fileForStorageNotDefined(); + } + if (! is_dir(pathinfo($this->path, PATHINFO_DIRNAME))) { + throw ParametresException::directoryOfFileNotFound($this->path); + } + if (! file_exists($this->path)) { + file_put_contents($this->path, '[]'); + } + } + + /** + * {@inheritDoc} + */ + public function has(string $file, string $property, ?string $context = null): bool + { + $this->hydrate($context); + + return $this->hasStored($file, $property, $context); + } + + /** + * {@inheritDoc} + */ + public function set(string $file, string $property, mixed $value = null, ?string $context = null): void + { + $time = Date::now()->format('Y-m-d H:i:s'); + $type = gettype($value); + $prepared = $this->prepareValue($value); + + $data = $this->getData(); + + // S'il a été stocké, nous devons le mettre à jour + if ($this->has($file, $property, $context)) { + $updated = $data->where('file', $file)->where('key', $property)->whereStrict('context', $context)->first(); + + $data = $data->map(fn ($item) => $item['id'] !== $updated['id'] ? $item : array_merge($item, [ + 'value' => $prepared, + 'type' => $type, + 'context' => $context, + 'updated_at' => $time, + ])); + // ...sinon l'insérer + } else { + $data = $data->add([ + 'id' => uniqid(more_entropy: true), + 'file' => $file, + 'key' => $property, + 'value' => $prepared, + 'type' => $type, + 'context' => $context, + 'created_at' => $time, + 'updated_at' => $time, + ]); + } + + $this->saveDate($data); + + // Modifier dans la memoire locale + $this->setStored($file, $property, $value, $context); + } + + /** + * {@inheritDoc} + */ + public function forget(string $file, string $property, ?string $context = null): void + { + $this->hydrate($context); + + $data = $this->getData(); + + $deleted = $data->where('file', $file)->where('key', $property)->whereStrict('context', $context)->first(); + $data = $data->filter(fn ($item) => $item['id'] !== $deleted['id']); + + $this->saveDate($data); + + // Supprimer dans la mémoire locale + $this->forgetStored($file, $property, $context); + } + + /** + * {@inheritDoc} + */ + public function flush(): void + { + $this->saveDate(collect([])); + + parent::flush(); + } + + /** + * Récupère les valeurs de la base de données en vrac pour minimiser les appels. + * Le général (null) est toujours récupéré une fois, les contextes sont récupérés dans leur intégralité pour chaque nouvelle requête. + */ + private function hydrate(?string $context = null): void + { + // Vérification de l'achèvement des travaux + if (in_array($context, $this->hydrated, true)) { + return; + } + + $data = $this->getData(); + + if ($context === null) { + $this->hydrated[] = null; + $data = $data->whereNull('context'); + } else { + // Si le général n'a pas été hydraté, on l'hydrate donc. + if (! in_array(null, $this->hydrated, true)) { + $this->hydrated[] = null; + } else { + $data = $data->where('context', $context); + } + + $this->hydrated[] = $context; + } + + foreach ($data->all() as $row) { + $this->setStored($row['file'], $row['key'], $this->parseValue($row['value'], $row['type']), $row['context']); + } + } + + /** + * Recupère les données à partir du fichier servant de source de données + */ + private function getData(): Collection + { + $data = json_decode(file_get_contents($this->path), true) ?: []; + + return collect($data); + } + + /** + * Persiste les données dans le fichier servant de source de données + */ + private function saveDate(Collection $data): void + { + $data = $data->toArray(); + + file_put_contents($this->path, json_encode($data, JSON_PRETTY_PRINT)); + } +}