From 054ec8a355e15644220bdce396a47455cbbfc288 Mon Sep 17 00:00:00 2001 From: Riku Sarkinen Date: Thu, 27 Aug 2026 15:01:35 +0100 Subject: [PATCH 1/2] ci: improve test workflow and add PHP compatibility checks - Add root declaration to .editorconfig - Add concurrency control and read permissions to tests workflow - Enable fail-fast: false for matrix builds - Use ramsey/composer-install action for dependency caching - Separate coverage runs from regular test runs to speed up CI - Add php-coveralls as a pre-installed tool for analysis job - Bump actions/checkout to v7 - Add phpcompatibility/php-compatibility for PHP version checks - Export-ignore composer.lock in .gitattributes --- .editorconfig | 2 + .gitattributes | 1 + .github/workflows/tests.yml | 32 ++++-- .gitignore | 1 + Makefile | 24 ++-- README.md | 32 +++--- composer.json | 10 +- composer.lock | 216 +++++++++++++++++++++++++++++++++++- phpcs.xml.dist | 4 + phpstan.neon.dist | 10 +- phpunit.xml.dist | 6 +- src/Dot.php | 48 +++++--- src/helpers.php | 2 +- tests/DotTest.php | 49 +++++++- 14 files changed, 386 insertions(+), 51 deletions(-) diff --git a/.editorconfig b/.editorconfig index 01f9f73..697be35 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,3 +1,5 @@ +root = true + [*] indent_style = space indent_size = 4 diff --git a/.gitattributes b/.gitattributes index 3ea4df1..37cfbca 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,7 @@ /.github export-ignore /.gitattributes export-ignore /.gitignore export-ignore +/composer.lock export-ignore /Makefile export-ignore /phpcs.xml.dist export-ignore /phpstan.neon.dist export-ignore diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index efbcedf..74a0755 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,31 +2,40 @@ name: tests on: [push, pull_request] +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: tests: name: Test PHP ${{ matrix.php }} runs-on: ubuntu-latest - continue-on-error: ${{ matrix.experimental }} strategy: + fail-fast: false matrix: - php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] - experimental: [false] + php: ["7.4", "8.0", "8.1", "8.2", "8.3", "8.4", "8.5"] include: - - php: '8.5' + - php: "8.5" analysis: true steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up PHP ${{ matrix.php }} uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - coverage: xdebug + coverage: ${{ matrix.analysis && 'xdebug' || 'none' }} + tools: ${{ matrix.analysis && 'php-coveralls' || '' }} - name: Install dependencies with Composer - run: composer install --no-progress --prefer-dist --optimize-autoloader + uses: ramsey/composer-install@v3 + with: + composer-options: --optimize-autoloader - name: Coding standards if: matrix.analysis @@ -37,12 +46,15 @@ jobs: run: vendor/bin/phpstan - name: Tests + if: ${{ !matrix.analysis }} + run: vendor/bin/phpunit + + - name: Tests with coverage + if: matrix.analysis run: vendor/bin/phpunit --coverage-clover clover.xml - name: Upload coverage results to Coveralls if: matrix.analysis env: COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - composer require php-coveralls/php-coveralls -n -W - vendor/bin/php-coveralls --coverage_clover=clover.xml -v + run: php-coveralls --coverage_clover=clover.xml -v diff --git a/.gitignore b/.gitignore index a113d5a..53a114e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store .idea +.phpunit.cache .phpunit.result.cache .vscode clover.xml diff --git a/Makefile b/Makefile index 5f697ba..1476dc9 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,26 @@ +.PHONY: install test phpunit phpcs phpcbf phpstan coverage help + +PHP_BIN := vendor/bin + install: composer install +test: phpunit phpcs phpstan + phpunit: - ./vendor/bin/phpunit + $(PHP_BIN)/phpunit phpcs: - ./vendor/bin/phpcs + $(PHP_BIN)/phpcs + +phpcbf: + $(PHP_BIN)/phpcbf phpstan: - ./vendor/bin/phpstan + $(PHP_BIN)/phpstan + +coverage: + $(PHP_BIN)/phpunit --coverage-html coverage -test: - make phpunit - make phpcs - make phpstan +help: + @echo "Targets: install, test, phpunit, phpcs, phpcbf, phpstan, coverage" diff --git a/README.md b/README.md index b014772..925d3a5 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,8 @@ $array = []; ### count() -Returns the number of items in a given key: +Returns the number of items in a given key. A non-countable value counts as +one item and a missing key counts as zero items: ```php $dot->count('user.siblings'); ``` @@ -223,10 +224,7 @@ echo $dot->get('user.name'); // ArrayAccess echo $dot['user.name']; -// Equivalent vanilla PHP < 7.0 -echo isset($array['user']['name']) ? $array['user']['name'] : null; - -// Equivalent vanilla PHP >= 7.0 +// Equivalent vanilla PHP echo $array['user']['name'] ?? null; ``` @@ -342,11 +340,7 @@ Returns the value of a given key and deletes the key: ```php echo $dot->pull('user.name'); -// Equivalent vanilla PHP < 7.0 -echo isset($array['user']['name']) ? $array['user']['name'] : null; -unset($array['user']['name']); - -// Equivalent vanilla PHP >= 7.0 +// Equivalent vanilla PHP echo $array['user']['name'] ?? null; unset($array['user']['name']); ``` @@ -380,6 +374,9 @@ $dot->push('John'); $array[] = 'John'; ``` +If the given key already holds a non-array, non-null value, the value is not +pushed and the Dot object is left unchanged. + ### replace() @@ -393,7 +390,7 @@ array_replace($originalArray, $array); Replaces the values with values having the same keys in the given array or Dot object with the given key: ```php -$dot->merge('user', $array); +$dot->replace('user', $array); // Equivalent vanilla PHP array_replace($originalArray['user'], $array); @@ -418,7 +415,7 @@ Multiple key / value pairs: ```php $dot->set([ 'user.name' => 'John', - 'page.title' => 'Home' + 'page.title' => 'Home' ]); ``` @@ -451,6 +448,15 @@ Returns all the stored items as JSON: echo $dot->toJson(); ``` +You can also pass [JSON encoding options](https://www.php.net/manual/en/json.constants.php): +```php +// For a given key +echo $dot->toJson('user', JSON_PRETTY_PRINT); + +// For all the stored items +echo $dot->toJson(JSON_PRETTY_PRINT); +``` + ## Contributing ### Pull Requests @@ -470,7 +476,7 @@ All pull requests must be accompanied by passing unit tests and complete code co ### Static Analysis -All pull requests must pass static analysis using [PHPStan](https://github.com/sebastianbergmann/phpunit/). +All pull requests must pass static analysis using [PHPStan](https://github.com/phpstan/phpstan). ## License diff --git a/composer.json b/composer.json index cc0333e..7b03a48 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,10 @@ "require-dev": { "phpunit/phpunit": "^9.5", "squizlabs/php_codesniffer": "^3.7", - "phpstan/phpstan": "^2.1" + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2", + "phpcompatibility/php-compatibility": "^9.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0" }, "autoload": { "files": [ @@ -26,5 +29,10 @@ "psr-4": { "Adbar\\": "src" } + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } } } diff --git a/composer.lock b/composer.lock index f974214..579ac45 100644 --- a/composer.lock +++ b/composer.lock @@ -4,9 +4,105 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "16b4066439046f87b06b149dd7ce4992", + "content-hash": "13ed08772475766b1d05be7d4137f786", "packages": [], "packages-dev": [ + { + "name": "dealerdirect/phpcodesniffer-composer-installer", + "version": "v1.2.1", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/composer-installer.git", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.2", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.1.0 || ^4.0" + }, + "require-dev": { + "composer/composer": "^2.2", + "ext-json": "*", + "ext-zip": "*", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^9.0 || ^10.0.0@dev", + "yoast/phpunit-polyfills": "^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Franck Nijhof", + "email": "opensource@frenck.dev", + "homepage": "https://frenck.dev", + "role": "Open source developer" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer Standards Composer Installer Plugin", + "keywords": [ + "PHPCodeSniffer", + "PHP_CodeSniffer", + "code quality", + "codesniffer", + "composer", + "installer", + "phpcbf", + "phpcs", + "plugin", + "qa", + "quality", + "standard", + "standards", + "style guide", + "stylecheck", + "tests" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/composer-installer/issues", + "security": "https://github.com/PHPCSStandards/composer-installer/security/policy", + "source": "https://github.com/PHPCSStandards/composer-installer" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-05-06T08:26:05+00:00" + }, { "name": "doctrine/instantiator", "version": "1.5.0", @@ -313,6 +409,68 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpcompatibility/php-compatibility", + "version": "9.3.5", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibility.git", + "reference": "9fb324479acf6f39452e0655d2429cc0d3914243" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/9fb324479acf6f39452e0655d2429cc0d3914243", + "reference": "9fb324479acf6f39452e0655d2429cc0d3914243", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "squizlabs/php_codesniffer": "^2.3 || ^3.0.2" + }, + "conflict": { + "squizlabs/php_codesniffer": "2.6.2" + }, + "require-dev": { + "phpunit/phpunit": "~4.5 || ^5.0 || ^6.0 || ^7.0" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.5 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "homepage": "https://github.com/wimg", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCompatibility/PHPCompatibility/graphs/contributors" + } + ], + "description": "A set of sniffs for PHP_CodeSniffer that checks for PHP cross-version compatibility.", + "homepage": "http://techblog.wimgodden.be/tag/codesniffer/", + "keywords": [ + "compatibility", + "phpcs", + "standards" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibility/issues", + "source": "https://github.com/PHPCompatibility/PHPCompatibility" + }, + "time": "2019-12-27T09:44:58+00:00" + }, { "name": "phpstan/phpstan", "version": "2.1.46", @@ -366,6 +524,62 @@ ], "time": "2026-04-01T09:25:14+00:00" }, + { + "name": "phpstan/phpstan-phpunit", + "version": "2.0.16", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-phpunit.git", + "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/6ab598e1bc106e6827fd346ae4a12b4a5d634c32", + "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.32" + }, + "conflict": { + "phpunit/phpunit": "<7.0" + }, + "require-dev": { + "nikic/php-parser": "^5", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPUnit extensions and rules for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-phpunit/issues", + "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.16" + }, + "time": "2026-02-14T09:05:21+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "9.2.32", diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 3009f2f..70d5e21 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -9,6 +9,10 @@ + + + + diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 63aadba..72bcdfe 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,5 +1,13 @@ +includes: + - vendor/phpstan/phpstan-phpunit/extension.neon + - vendor/phpstan/phpstan-phpunit/rules.neon + parameters: - level: 6 + level: 8 paths: - src - tests + # The test suite intentionally asserts the runtime types of values that + # are already covered by the library's own generic PHPDoc annotations, so + # do not treat those PHPDoc types as certain during analysis. + treatPhpDocTypesAsCertain: false diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 987da04..ee0a4ca 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,8 +1,10 @@ @@ -12,7 +14,7 @@ - + src diff --git a/src/Dot.php b/src/Dot.php index 9350fb6..8aefec4 100644 --- a/src/Dot.php +++ b/src/Dot.php @@ -123,7 +123,7 @@ public function clear($keys = null) /** * Delete the given key or keys * - * @param array|array|int|string $keys + * @param array|int|string $keys * @return $this */ public function delete($keys) @@ -138,7 +138,7 @@ public function delete($keys) } $items = &$this->items; - $segments = explode($this->delimiter, $key); + $segments = explode($this->delimiter, (string) $key); $lastSegment = array_pop($segments); foreach ($segments as $segment) { @@ -385,9 +385,9 @@ public function mergeRecursiveDistinct($key, $value = []) * duplicate keys are not converted to arrays but rather overwrite the * value in the first array with the duplicate value in the second array. * - * @param array|array> $array1 Initial array to merge - * @param array|array> $array2 Array to recursively merge - * @return array|array> + * @param array $array1 Initial array to merge + * @param array $array2 Array to recursively merge + * @return array */ protected function arrayMergeRecursiveDistinct(array $array1, array $array2) { @@ -401,6 +401,10 @@ protected function arrayMergeRecursiveDistinct(array $array1, array $array2) } } + // The recursive merge widens the inferred value type through the + // $merged reference; the values remain valid TValue at runtime, but + // PHPStan cannot prove this for an unconstrained template type. + // @phpstan-ignore return.type return $merged; } @@ -431,6 +435,9 @@ public function pull($key = null, $default = null) * Push a given value to the end of the array * in a given key * + * If the given key already holds a non-array, non-null value, + * the value is not pushed and the Dot object is left unchanged. + * * @param mixed $key * @param mixed $value * @return $this @@ -494,16 +501,22 @@ public function set($keys, $value = null) return $this; } - $items = &$this->items; + // Non-string keys (e.g. integers) can not contain the delimiter, + // so they are set directly to avoid overwriting the whole store. + if (!is_string($keys)) { + $this->items[$keys] = $value; - if (is_string($keys)) { - foreach (explode($this->delimiter, $keys) as $key) { - if (!isset($items[$key]) || !is_array($items[$key])) { - $items[$key] = []; - } + return $this; + } - $items = &$items[$key]; + $items = &$this->items; + + foreach (explode($this->delimiter, $keys) as $key) { + if (!isset($items[$key]) || !is_array($items[$key])) { + $items[$key] = []; } + + $items = &$items[$key]; } $items = $value; @@ -633,12 +646,21 @@ public function offsetUnset($key): void /** * Return the number of items in a given key * + * A non-countable value counts as one item and a + * missing key counts as zero items. + * * @param int|string|null $key * @return int */ public function count($key = null): int { - return count($this->get($key)); + $value = $this->get($key); + + if (is_array($value) || $value instanceof Countable) { + return count($value); + } + + return $value === null ? 0 : 1; } /* diff --git a/src/helpers.php b/src/helpers.php index fd45517..d072d15 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -19,7 +19,7 @@ * @param non-empty-string $delimiter * @return \Adbar\Dot */ - function dot($items, $parse = false, $delimiter = ".") + function dot($items = [], $parse = false, $delimiter = ".") { return new Dot($items, $parse, $delimiter); } diff --git a/tests/DotTest.php b/tests/DotTest.php index 9e47ba4..6c67617 100644 --- a/tests/DotTest.php +++ b/tests/DotTest.php @@ -76,6 +76,14 @@ public function testConstructWithCustomDelimiter(): void $this->assertSame(['foo' => ['bar' => 'baz']], $dot->get()); } + public function testConstructHelperWithoutValues(): void + { + $dot = dot(); + + $this->assertInstanceOf(Dot::class, $dot); + $this->assertSame([], $dot->all()); + } + public function testConstructHelper(): void { $dot = dot(['foo' => 'bar']); @@ -572,6 +580,14 @@ public function testPushValueToKey(): void $this->assertSame(['bar', 'baz'], $dot->get('foo')); } + public function testPushValueToScalarKeyLeavesDotUnchanged(): void + { + $dot = new Dot(['foo' => ['bar' => 'baz']]); + $dot->push('foo.bar', 'qux'); + + $this->assertSame(['foo' => ['bar' => 'baz']], $dot->all()); + } + public function testPushReturnsDot(): void { $dot = $dot = new Dot(); @@ -614,7 +630,7 @@ public function testReplaceKeyWithDot(): void { $dot1 = new Dot(['foo' => ['bar' => 'baz', 'qux' => 'quux']]); $dot2 = new Dot(['qux' => 'corge']); - $dot1->merge('foo', $dot2); + $dot1->replace('foo', $dot2); $this->assertSame(['bar' => 'baz', 'qux' => 'corge'], $dot1->get('foo')); } @@ -648,6 +664,14 @@ public function testSetArrayOfKeyValuePairs(): void $this->assertSame(['foo' => 'bar', 'baz' => 'qux'], $dot->all()); } + public function testSetIntegerKeyDoesNotOverwriteStore(): void + { + $dot = new Dot(['foo' => 'bar']); + $dot->set(5, 'baz'); + + $this->assertSame(['foo' => 'bar', 5 => 'baz'], $dot->all()); + } + public function testSetReturnsDot(): void { $dot = new Dot(); @@ -853,7 +877,28 @@ public function testCount(): void { $dot = new Dot([1, 2, 3]); - $this->assertSame(3, $dot->count()); + $this->assertCount(3, $dot); + } + + public function testCountKey(): void + { + $dot = new Dot(['foo' => [1, 2, 3]]); + + $this->assertSame(3, $dot->count('foo')); + } + + public function testCountScalarKey(): void + { + $dot = new Dot(['foo' => 'bar']); + + $this->assertSame(1, $dot->count('foo')); + } + + public function testCountNonExistingKey(): void + { + $dot = new Dot(['foo' => 'bar']); + + $this->assertSame(0, $dot->count('baz')); } public function testCountable(): void From 6465b46a9ce461c6a7557933bbdcbc5fe267a3fe Mon Sep 17 00:00:00 2001 From: Riku Sarkinen Date: Thu, 27 Aug 2026 15:25:19 +0100 Subject: [PATCH 2/2] ci: update coverage upload to use coverallsapp action Replace php-coveralls tool with the official coverallsapp/github-action for uploading coverage results. Also bump ramsey/composer-install to v4. This simplifies the workflow by removing the need to install the php-coveralls tool separately. --- .github/workflows/tests.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 74a0755..ebc5405 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,10 +30,9 @@ jobs: with: php-version: ${{ matrix.php }} coverage: ${{ matrix.analysis && 'xdebug' || 'none' }} - tools: ${{ matrix.analysis && 'php-coveralls' || '' }} - name: Install dependencies with Composer - uses: ramsey/composer-install@v3 + uses: ramsey/composer-install@v4 with: composer-options: --optimize-autoloader @@ -55,6 +54,7 @@ jobs: - name: Upload coverage results to Coveralls if: matrix.analysis - env: - COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: php-coveralls --coverage_clover=clover.xml -v + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + file: clover.xml