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..ebc5405 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -2,31 +2,39 @@ 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' }}
- name: Install dependencies with Composer
- run: composer install --no-progress --prefer-dist --optimize-autoloader
+ uses: ramsey/composer-install@v4
+ with:
+ composer-options: --optimize-autoloader
- name: Coding standards
if: matrix.analysis
@@ -37,12 +45,16 @@ 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
+ uses: coverallsapp/github-action@v2
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ file: clover.xml
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