Repository: SoftCreatR/JSONPath
Branch: main
Commit: a07f7e4290cb
Files: 45
Total size: 131.3 KB
Directory structure:
gitextract_xceowo_u/
├── .gitattributes
├── .github/
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug.md
│ │ ├── documentation.md
│ │ └── feature.md
│ ├── PULL_REQUEST_TEMPLATE.md
│ ├── diff.json
│ ├── php-syntax.json
│ └── workflows/
│ ├── Test.yml
│ ├── UpdateContributors.yml
│ └── codestyle.yml
├── .gitignore
├── .php-cs-fixer.dist.php
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── LICENSE.md
├── README.md
├── composer.json
├── phpcs.xml
├── phpstan.neon.dist
├── phpunit.xml.dist
├── src/
│ ├── AccessHelper.php
│ ├── Filters/
│ │ ├── AbstractFilter.php
│ │ ├── IndexFilter.php
│ │ ├── IndexesFilter.php
│ │ ├── QueryMatchFilter.php
│ │ ├── QueryResultFilter.php
│ │ ├── RecursiveFilter.php
│ │ └── SliceFilter.php
│ ├── JSONPath.php
│ ├── JSONPathException.php
│ ├── JSONPathLexer.php
│ ├── JSONPathToken.php
│ └── TokenType.php
└── tests/
├── AccessHelperTest.php
├── IndexFilterTest.php
├── IndexesFilterTest.php
├── JSONPathCoreTest.php
├── JSONPathIntegrationTest.php
├── JSONPathLexerTest.php
├── JSONPathTokenTest.php
├── QueryMatchFilterTest.php
├── QueryResultFilterTest.php
├── RecursiveFilterTest.php
└── SliceFilterTest.php
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitattributes
================================================
*.php diff=php
================================================
FILE: .github/FUNDING.yml
================================================
github: softcreatr
custom: ['https://ecologi.com/softcreatr?r=61212ab3fc69b8eb8a2014f4']
================================================
FILE: .github/ISSUE_TEMPLATE/bug.md
================================================
---
name: 🐛 Bug Report
about: Submit a bug report, to help us improve.
labels: "bug"
---
## 🐛 Bug Report
(A clear and concise description of what the bug is)
## Have you spent some time to check if this issue has been raised before?
[ ] I have read googled for a similar issue or checked our older issues for a similar bug
### Have you read the [Code of Conduct](https://github.com/SoftCreatR/JSONPath/blob/main/CODE_OF_CONDUCT.md)?
[ ] I have read the Code of Conduct
## To Reproduce
(Write your steps here:)
## Expected behavior
<!--
How did you expect your project to behave?
It’s fine if you’re not sure your understanding is correct.
Write down what you thought would happen.
-->
(Write what you thought would happen.)
## Actual Behavior
<!--
Did something go wrong?
Is something broken, or not behaving as you expected?
Describe this section in detail, and attach screenshots if possible.
Don't only say "it doesn't work"!
-->
(Write what happened. Add screenshots, if applicable.)
## Your Environment
<!-- Include as many relevant details about the environment you experienced the bug in -->
(Write Environment, Operating system and version etc.)
================================================
FILE: .github/ISSUE_TEMPLATE/documentation.md
================================================
---
name: 📚 Documentation
about: Report an issue related to documentation.
labels: "documentation"
---
## 📚 Documentation
(A clear and concise description of what the issue is.)
### Have you read the [Code of Conduct](https://github.com/SoftCreatR/JSONPath/blob/main/CODE_OF_CONDUCT.md)?
[ ] I have read the Code of Conduct
================================================
FILE: .github/ISSUE_TEMPLATE/feature.md
================================================
---
name: 💡 Feature / Idea
about: Submit a proposal for a new feature.
labels: "feature"
---
## 💡 Feature / Idea
(A clear and concise description of what the feature is.)
## Have you spent some time to check if this issue has been raised before?
[ ] I have read googled for a similar issue or checked our older issues for a similar idea
### Have you read the [Code of Conduct](https://github.com/SoftCreatR/JSONPath/blob/main/CODE_OF_CONDUCT.md)?
[ ] I have read the Code of Conduct
## Pitch
(Please explain why this feature should be implemented and how it would be used. Add examples, if applicable.)
================================================
FILE: .github/PULL_REQUEST_TEMPLATE.md
================================================
<!--
Thank you for sending the PR! We appreciate you spending the time to work on these changes.
Help us understand your motivation by explaining why you decided to make this change.
Happy contributing!
-->
# 🔀 Pull Request
## What does this PR do?
(Provide a description of what this PR does.)
## Test Plan
(Write your test plan here. If you changed any code, please provide us with clear instructions on how you verified your changes work.)
## Related PRs and Issues
(If this PR is related to any other PR or resolves any issue or related to any issue link all related PR and issues here.)
================================================
FILE: .github/diff.json
================================================
{
"problemMatcher": [
{
"owner": "diff",
"pattern": [
{
"regexp": "--- a/(.*)",
"file": 1,
"message": 1
}
]
}
]
}
================================================
FILE: .github/php-syntax.json
================================================
{
"problemMatcher": [
{
"owner": "php -l",
"pattern": [
{
"regexp": "^\\s*(PHP\\s+)?([a-zA-Z\\s]+):\\s+(.*)\\s+in\\s+(\\S+)\\s+on\\s+line\\s+(\\d+)$",
"file": 4,
"line": 5,
"message": 3
}
]
}
]
}
================================================
FILE: .github/workflows/Test.yml
================================================
name: Test
on:
push:
paths:
- '**.php'
- 'composer.json'
branches:
- 'main'
pull_request:
paths:
- '**.php'
- 'composer.json'
workflow_dispatch:
jobs:
run:
runs-on: ubuntu-latest
strategy:
matrix:
php:
- '8.5'
name: PHP ${{ matrix.php }} Test
steps:
- name: Git checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: dom, json, mbstring, xml, xmlwriter
tools: phpcs
coverage: pcov
env:
COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup problem matchers for PHP syntax check
run: echo "::add-matcher::.github/php-syntax.json"
- run: |
! find . -type f -name '*.php' -exec php -l '{}' \; 2>&1 |grep -v '^No syntax errors detected'
- name: Setup problem matchers for PHPUnit
run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
- name: Install dependencies
run: composer install --prefer-dist --no-interaction --no-progress
- name: Run phpcs
run: composer cs
- name: Run PHPStan
run: composer phpstan
- name: Execute tests
run: |
set +e
output=$(composer test -- --coverage-clover=coverage.xml 2>&1)
exit_code=$?
echo "$output"
# If the only issue is the cache directory warning, ignore the exit code.
if echo "$output" | grep -q "No cache directory configured, result of static analysis for code coverage will not be cached"; then
echo "Ignoring known PHPUnit warning about missing cache directory."
exit 0
else
exit $exit_code
fi
- name: Run codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
================================================
FILE: .github/workflows/UpdateContributors.yml
================================================
name: Update Contributors
on: [ push, workflow_dispatch]
jobs:
Update:
runs-on: ubuntu-latest
steps:
- name: Git checkout
uses: actions/checkout@v4
- name: Update Contributors
uses: BobAnkh/add-contributors@master
with:
REPO_NAME: 'SoftCreatR/JSONPath'
CONTRIBUTOR: '## Contributors ✨'
ACCESS_TOKEN: ${{secrets.GITHUB_TOKEN}}
================================================
FILE: .github/workflows/codestyle.yml
================================================
name: Code Style
on:
push:
paths:
- '**.php'
pull_request:
paths:
- '**.php'
jobs:
php:
name: PHP
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP with tools
uses: shivammathur/setup-php@v2
with:
php-version: '8.5'
extensions: dom, json, mbstring, xml, xmlwriter
tools: cs2pr, phpcs, php-cs-fixer
- name: phpcs
run: phpcs -n -q --report=checkstyle | cs2pr
- name: php-cs-fixer
run: php-cs-fixer fix --dry-run --diff
================================================
FILE: .gitignore
================================================
.idea
.phpunit.result.cache
vendor
.php_cs.cache
composer.lock
composer.phar
.phpcs-cache
================================================
FILE: .php-cs-fixer.dist.php
================================================
<?php
use PhpCsFixer\Config;
use PhpCsFixer\Finder;
$finder = Finder::create()
->in(__DIR__)
->notPath('vendor');
return new Config()
->setRiskyAllowed(true)
->setRules([
'@PSR1' => true,
'@PSR2' => true,
'array_push' => true,
'backtick_to_shell_exec' => true,
'no_alias_language_construct_call' => true,
'no_mixed_echo_print' => true,
'pow_to_exponentiation' => true,
'random_api_migration' => true,
'array_syntax' => ['syntax' => 'short'],
'no_multiline_whitespace_around_double_arrow' => true,
'no_trailing_comma_in_singleline_array' => true,
'no_whitespace_before_comma_in_array' => true,
'normalize_index_brace' => true,
'whitespace_after_comma_in_array' => true,
'non_printable_character' => ['use_escape_sequences_in_strings' => true],
'lowercase_static_reference' => true,
'magic_constant_casing' => true,
'magic_method_casing' => true,
'native_function_casing' => true,
'native_function_type_declaration_casing' => true,
'cast_spaces' => ['space' => 'none'],
'lowercase_cast' => true,
'no_unset_cast' => true,
'short_scalar_cast' => true,
'class_attributes_separation' => true,
'no_blank_lines_after_class_opening' => true,
'no_null_property_initialization' => true,
'self_accessor' => true,
'single_class_element_per_statement' => true,
'single_trait_insert_per_statement' => true,
'no_empty_comment' => true,
'single_line_comment_style' => ['comment_types' => ['hash']],
'native_constant_invocation' => ['strict' => false],
'no_alternative_syntax' => true,
'no_trailing_comma_in_list_call' => true,
'no_unneeded_control_parentheses' => [
'statements' => [
'break',
'clone',
'continue',
'echo_print',
'return',
'switch_case',
'yield',
'yield_from',
],
],
'no_unneeded_curly_braces' => ['namespaces' => true],
'switch_continue_to_break' => true,
'trailing_comma_in_multiline' => ['elements' => ['arrays']],
'function_typehint_space' => true,
'lambda_not_used_import' => true,
'native_function_invocation' => ['include' => ['@internal']],
'no_unreachable_default_argument_value' => true,
'nullable_type_declaration_for_default_null_value' => true,
'return_type_declaration' => true,
'static_lambda' => true,
'fully_qualified_strict_types' => ['leading_backslash_in_global_namespace' => true],
'no_leading_import_slash' => true,
'no_unused_imports' => true,
'ordered_imports' => [
'imports_order' => ['class', 'function', 'const'],
'sort_algorithm' => 'alpha',
],
'blank_line_between_import_groups' => true,
'declare_equal_normalize' => true,
'dir_constant' => true,
'explicit_indirect_variable' => true,
'function_to_constant' => true,
'is_null' => true,
'no_unset_on_property' => true,
'list_syntax' => ['syntax' => 'short'],
'clean_namespace' => true,
'no_leading_namespace_whitespace' => true,
'single_blank_line_before_namespace' => true,
'no_homoglyph_names' => true,
'binary_operator_spaces' => true,
'concat_space' => ['spacing' => 'one'],
'increment_style' => ['style' => 'post'],
'logical_operators' => true,
'object_operator_without_whitespace' => true,
'operator_linebreak' => true,
'standardize_increment' => true,
'standardize_not_equals' => true,
'ternary_operator_spaces' => true,
'ternary_to_elvis_operator' => true,
'ternary_to_null_coalescing' => true,
'unary_operator_spaces' => true,
'no_useless_return' => true,
'return_assignment' => true,
'multiline_whitespace_before_semicolons' => true,
'no_empty_statement' => true,
'no_singleline_whitespace_before_semicolons' => true,
'space_after_semicolon' => ['remove_in_empty_for_expressions' => true],
'escape_implicit_backslashes' => true,
'explicit_string_variable' => true,
'heredoc_to_nowdoc' => true,
'no_binary_string' => true,
'simple_to_complex_string_variable' => true,
'array_indentation' => true,
'blank_line_before_statement' => ['statements' => ['return', 'exit']],
'compact_nullable_typehint' => true,
'method_chaining_indentation' => true,
'no_extra_blank_lines' => [
'tokens' => [
'case',
'continue',
'curly_brace_block',
'default',
'extra',
'parenthesis_brace_block',
'square_brace_block',
'switch',
'throw',
'use',
],
],
'no_spaces_around_offset' => true,
])
->setFinder($finder);
================================================
FILE: CHANGELOG.md
================================================
# Changelog
### 1.0.2
- Fixed tokenizer handling for quoted bracket keys containing `$` so literals like `['[$the.size$]']` remain atomic and do not split into root tokens.
### 1.0.1
- Aligned the query runner and lexer with the JSONPath comparison suite: JSON documents are now decoded as objects to preserve `{}` vs `[]`, unsupported selectors no longer abort the runner, and dot-notation now accepts quoted keys with dots/spaces/leading `@`.
- Hardened filter parsing: boolean-only filters (`?(true|false|null)`), literal short-circuiting (`&& false`, `|| true`), and empty filters now return the expected collections instead of throwing.
- Slice filters gracefully skip non-countable objects.
### 1.0.0
- Rebuilt the test suite from scratch: removed bulky baseline fixtures and added compact unit/integration coverage for every filter (index, union, query, recursive, slice), lexer edge cases, and JSONPath core helpers. Runs reflection-free and deprecation-free.
- Achieved and enforced 100% code coverage across AccessHelper, all filters, lexer, tokens, and JSONPath core while keeping phpstan and coding standards clean.
- Added a lightweight manual query runner with curated examples to exercise selectors quickly without external datasets.
- Major compatibility push toward the unofficial JSONPath standard: unions support slices/queries/wildcards, trailing commas parse correctly, negative indexes and bracket-escaped keys (quotes, brackets, wildcards, special chars) are honored, filters compare path-to-path and root references, equality/deep-equality/regex/in/nin semantics align with expectations, and null existence/value handling follows RFC behavior.
- New feature highlights from this cycle:
- Multi-key unions with and without quotes: `$[name,year]` and `$["name","year"]`.
- Robust bracket notation for special/escaped keys, including `']'`, `'*'`, `$`, backslashes, and mixed punctuation.
- Trailing comma support in unions/slices (e.g. `$..books[0,1,2,]`).
- Negative index handling aligned with spec (short arrays return empty; -1 works where valid).
- Filter improvements: path-to-path/root comparisons, deep equality across scalars/objects/arrays/null/empties, regex matching, `in`/`nin`/`!in`, tautological expressions, and `?@` existence behavior per RFC.
- Unions combining slices/queries/wildcards now return complete results (e.g. `$[1:3,4]`, `$[*,1]`).
### 0.11.0
🔻 Breaking changes ahead:
- Dropped support for PHP < 8.5
- `JSONPathToken` now uses a `TokenType` enum and the constructor signature changed accordingly.
- `JSONPath` options flag is now an `int` bitmask (was `bool`), requiring callers to pass integer flags.
- `SliceFilter` returns an empty result for non-positive step values (previously iterated indefinitely).
- `QueryResultFilter` now throws a `JSONPathException` for unsupported operators instead of silently proceeding.
- Access helper behavior is stricter: `arrayValues` throws on invalid types; ArrayAccess lookups check `offsetExists` before reading; traversables and objects are handled distinctly.
- Adopted PHP 8.5 features: `TokenType` enum, readonly value object for tokens, typed flags/options, and `#[\Override]` usage.
- CI now runs on PHP 8.5 with required extensions; code style workflow updated accordingly.
- Added coverage for AccessHelper edge cases (magic getters, ArrayAccess, traversables, negative indexes), QueryResultFilter arithmetic branches, and SliceFilter negative/null bounds.
- Fixed empty-expression handling in lexer and improved safety in AccessHelper traversable lookups.
- Added PHPStan static analysis to the toolchain and addressed its findings.
### 0.10.1
- Fixed ignore whitespace after comparison value in filter expression
### 0.10.0
- Fixed query/selector Filter Expression With Current Object
- Fixed query/selector Filter Expression With Different Grouped Operators
- Fixed query/selector Filter Expression With equals_on_array_of_numbers
- Fixed query/selector Filter Expression With Negation and Equals
- Fixed query/selector Filter Expression With Negation and Less Than
- Fixed query/selector Filter Expression Without Value
- Fixed query/selector Filter Expression With Boolean AND Operator (#42)
- Fixed query/selector Filter Expression With Boolean OR Operator (#43)
- Fixed query/selector Filter Expression With Equals (#45)
- Fixed query/selector Filter Expression With Equals false (#46)
- Fixed query/selector Filter Expression With Equals null (#47)
- Fixed query/selector Filter Expression With Equals Number With Fraction (#48)
- Fixed query/selector Filter Expression With Equals true (#50)
- Fixed query/selector Filter Expression With Greater Than (#52)
- Fixed query/selector Filter Expression With Greater Than or Equal (#53)
- Fixed query/selector Filter Expression With Less Than (#54)
- Fixed query/selector Filter Expression With Less Than or Equal (#55)
- Fixed query/selector Filter Expression With Not Equals (#56)
- Fixed query/selector Filter Expression With Value (#57)
- Fixed query/selector script_expression (Expected test result corrected)
- Added additional NULL related query tests from JSONPath RFC
### 0.9.0
🔻 Breaking changes ahead:
- Dropped support for PHP < 8.1
### 0.8.3
- Change `getData()` so that it can be mixed instead of array
### 0.8.2
- AccessHelper & RecursiveFilter now return a plain `object`, rather than an `ArrayAccess` object
### 0.8.1
- Removed strict_types
- Applied some PSR-12 related changes
- Small code optimizations
### 0.8.0
🔻 Breaking changes ahead:
- Dropped support for PHP < 8.0
- Removed deprecated method `JSONPath->data()`
### 0.7.5
- Added support for $.length
- Added trim to explode to support both 1,2,3 and 1, 2, 3 inputs
- Dropped in_array strict equality check to be in line with the other standard equality checks such as (== and !=)
### 0.7.4
- Removed PHPUnit from conflicting packages
### 0.7.3
- Fixed PHP 7.4+ compatibility issues
### 0.7.2
- Fixed query/selector "Array Slice With Start Large Negative Number And Open End On Short Array" (#7)
- Fixed query/selector "Union With Keys" (#22)
- Fixed query/selector "Dot Notation After Union With Keys" (#15)
- Fixed query/selector "Union With Keys After Array Slice" (#23)
- Fixed query/selector "Union With Keys After Bracket Notation" (#24)
- Fixed query/selector "Union With Keys On Object Without Key" (#25)
### 0.7.1
- Fixed issues with empty tokens (`['']` and `[""]`)
- Fixed TypeError in AccessHelper::keyExists
- Improved QueryTest
### 0.7.0
🔻 Breaking changes ahead:
- Made JSONPath::__construct final
- Added missing type hints
- Partially reduced complexity
- Performed some code optimizations
- Updated composer.json for proper PHPUnit/PHP usage
- Added support for regular expression operator (`=~`)
- Added QueryTest to perform tests against all queries from https://cburgmer.github.io/json-path-comparison/
- Switched Code Style from PSR-2 to PSR-12
### 0.6.4
- Removed unnecessary type casting, that caused problems under certain circumstances
- Added support for `nin` operator
- Added support for greater than or equal operator (`>=`)
- Added support for less or equal operator (`<=`)
### 0.6.3
- Added support for `in` operator
- Fixed evaluation on indexed object
### 0.6.x
- Dropped support for PHP < 7.1
- Switched from (broken) PSR-0 to PSR-4
- Updated PHPUnit to 8.5 / 9.4
- Updated tests
- Added missing PHPDoc blocks
- Added return type hints
- Moved from Travis to GitHub actions
- Set `strict_types=1`
### 0.5.0
- Fixed the slice notation (e.g. [0:2:5] etc.). **Breaks code relying on the broken implementation**
### 0.3.0
- Added JSONPathToken class as value object
- Lexer clean up and refactor
- Updated the lexing and filtering of the recursive token ("..") to allow for a combination of recursion
and filters, e.g. $..[?(@.type == 'suburb')].name
### 0.2.1 - 0.2.5
- Various bug fixes and clean up
### 0.2.0
- Added a heap of array access features for more creative iterating and chaining possibilities
### 0.1.x
- Init
================================================
FILE: CODE_OF_CONDUCT.md
================================================
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to make participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity, expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at hello@1-2.dev. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq
================================================
FILE: LICENSE.md
================================================
MIT License
Original work - Copyright (c) 2018 Flow Communications
Modified work - Copyright (c) 2020 Sascha Greuel <hello@1-2.dev>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# JSONPath for PHP 8.5+
[](https://github.com/SoftCreatR/JSONPath/actions/workflows/Test.yml) [](https://packagist.org/packages/softcreatr/jsonpath)
[](./LICENSE) [](https://ecologi.com/softcreatr?r=61212ab3fc69b8eb8a2014f4)
[](https://codecov.io/gh/SoftCreatR/JSONPath)
This is a [JSONPath](http://goessner.net/articles/JsonPath/) implementation for PHP that targets the de facto comparison suite/RFC semantics while keeping the API small, cached, and `eval`-free.
## Highlights
- PHP 8.5+ only, with enums/readonly tokens and no `eval`.
- Works with arrays, objects, and `ArrayAccess`/traversables in any combination.
- Unions cover slices/queries/wildcards/multi-key strings (quoted or unquoted); negative indexes and escaped bracket notation are supported.
- Filters support path-to-path/root comparisons, regex, `in`/`nin`/`!in`, deep equality, RFC-style null existence/value handling, and literal-only short-circuiting (e.g., `?(true)`, `?(false)`, `&& false`, `|| true`).
- Tokenized parsing with internal caching; lightweight manual runner to try bundled examples quickly.
## Installation
Requires PHP 8.5 or newer.
```bash
composer require softcreatr/jsonpath:"^1.0"
```
## Development
Useful commands:
```bash
composer exec phpunit
composer phpstan
composer cs
```
## JSONPath Examples
JSONPath | Result
--------------------------|-------------------------------------
`$.store.books[*].author` | the authors of all books in the store
`$..author` | all authors
`$.store..price` | the price of everything in the store.
`$..books[2]` | the third book
`$..books[(@.length-1)]` | the last book in order.
`$..books[-1:]` | the last book in order.
`$..books[0,1]` | the first two books
`$..books[title,year]` | multiple keys in a union
`$..books[:2]` | the first two books
`$..books[::2]` | every second book starting from first one
`$..books[1:6:3]` | every third book starting from 1 till 6
`$..books[?(@.isbn)]` | filter all books with isbn number
`$..books[?(@.price<10)]` | filter all books cheaper than 10
`$..books.length` | the amount of books
`$..*` | all elements in the data (recursively extracted)
Expression syntax
---
Symbol | Description
----------------------|-------------------------
`$` | The root object/element (not strictly necessary)
`@` | The current object/element
`.` or `[]` | Child operator
`..` | Recursive descent
`*` | Wildcard. All child elements regardless their index.
`[,]` | Array indices as a set
`[start:end:step]` | Array slice operator borrowed from ES4/Python.
`?()` | Filters a result set by a comparison expression (constant expressions like `?(true)`/`?(false)` are allowed; unsupported/empty filters evaluate to an empty result)
`()` | Uses the result of a comparison expression as the index
## PHP Usage
#### Using arrays
```php
<?php
require_once __DIR__ . '/vendor/autoload.php';
$data = ['people' => [
['name' => 'Sascha'],
['name' => 'Bianca'],
['name' => 'Alexander'],
['name' => 'Maximilian'],
]];
print_r((new \Flow\JSONPath\JSONPath($data))->find('$.people.*.name')->getData());
/*
Array
(
[0] => Sascha
[1] => Bianca
[2] => Alexander
[3] => Maximilian
)
*/
```
#### Using objects
```php
<?php
require_once __DIR__ . '/vendor/autoload.php';
$data = json_decode('{"name":"Sascha Greuel","birthdate":"1987-12-16","city":"Gladbeck","country":"Germany"}', false);
print_r((new \Flow\JSONPath\JSONPath($data))->find('$')->getData()[0]);
/*
stdClass Object
(
[name] => Sascha Greuel
[birthdate] => 1987-12-16
[city] => Gladbeck
[country] => Germany
)
*/
```
### Magic method access
The options flag `JSONPath::ALLOW_MAGIC` will instruct JSONPath when retrieving a value to first check if an object
has a magic `__get()` method and will call this method if available. This feature is *iffy* and
not very predictable as:
- wildcard and recursive features will only look at public properties and can't smell which properties are magically accessible
- there is no `property_exists` check for magic methods so an object with a magic `__get()` will always return `true` when checking
if the property exists
- any errors thrown or unpredictable behavior caused by fetching via `__get()` is your own problem to deal with
```php
<?php
use Flow\JSONPath\JSONPath;
$myObject = (new Foo())->get('bar');
$jsonPath = new JSONPath($myObject, JSONPath::ALLOW_MAGIC);
```
## Script expressions
Script execution is intentionally **not** supported:
- It would require `eval`, which we avoid.
- Behavior would diverge across languages and defeat having a portable expression syntax.
Supported filter/query patterns (200+ cases covered in the comparison suite):
```
[?(@._KEY_ _OPERATOR_ _VALUE_)]
Operators: ==, =, !=, <>, !==, <, >, <=, >=, =~, in, nin, !in
Examples:
[?(@.title == "A string")] // equality
[?(@.title = "A string")] // SQL-style equals
[?(@.price < 10)] // numeric comparisons
[?(@.title =~ /^a(nother)?/i)] // regex
[?(@.title in ["A","B"])] // membership
[?(@.title nin ["A"])] // not in
[?(@.title !in ["A"])] // alternate not in
[?(@.key == @.other)] // path-to-path comparison
[?(@.key == $.rootValue)] // root reference
[?(@)] or [?(@==@)] // truthy/tautology
[?(@.length)] // existence checks
[?(@['weird-key']=="ok")] // bracket-escaped keys and negative indexes
```
A full list of (un)supported filter/query patterns can be found in the [JSONPath Comparison Cheatsheet](https://cburgmer.github.io/json-path-comparison/).
## Similar projects
[FlowCommunications/JSONPath](https://github.com/FlowCommunications/JSONPath) is the predecessor of this library by Stephen Frank
Other / Similar implementations can be found in the [Wiki](https://github.com/SoftCreatR/JSONPath/wiki/Other-Implementations).
## Changelog
A list of changes can be found in the [CHANGELOG.md](CHANGELOG.md) file.
## License 🌳
[MIT](LICENSE.md) © [1-2.dev](https://1-2.dev)
This package is Treeware. If you use it in production, then we ask that you [**buy the world a tree**](https://ecologi.com/softcreatr?r=61212ab3fc69b8eb8a2014f4) to thank us for our work. By contributing to the ecologi project, you’ll be creating employment for local families and restoring wildlife habitats.
## Contributors ✨
<table>
<tr>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/SoftCreatR>
<img src=https://avatars.githubusercontent.com/u/81188?v=4 width="100;" alt=Sascha Greuel/>
<br />
<sub style="font-size:14px"><b>Sascha Greuel</b></sub>
</a>
</td>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/lucasnetau>
<img src=https://avatars.githubusercontent.com/u/9331242?v=4 width="100;" alt=James Lucas/>
<br />
<sub style="font-size:14px"><b>James Lucas</b></sub>
</a>
</td>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/Schrank>
<img src=https://avatars.githubusercontent.com/u/379680?v=4 width="100;" alt=Fabian Blechschmidt/>
<br />
<sub style="font-size:14px"><b>Fabian Blechschmidt</b></sub>
</a>
</td>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/mpesari>
<img src=https://avatars.githubusercontent.com/u/11061725?v=4 width="100;" alt=Mikko Pesari/>
<br />
<sub style="font-size:14px"><b>Mikko Pesari</b></sub>
</a>
</td>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/warlof>
<img src=https://avatars.githubusercontent.com/u/648753?v=4 width="100;" alt=warlof/>
<br />
<sub style="font-size:14px"><b>warlof</b></sub>
</a>
</td>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/SG5>
<img src=https://avatars.githubusercontent.com/u/3931761?v=4 width="100;" alt=Sergey G/>
<br />
<sub style="font-size:14px"><b>Sergey G</b></sub>
</a>
</td>
</tr>
<tr>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/drealecs>
<img src=https://avatars.githubusercontent.com/u/209984?v=4 width="100;" alt=Alexandru Pătrănescu/>
<br />
<sub style="font-size:14px"><b>Alexandru Pătrănescu</b></sub>
</a>
</td>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/oleg-andreyev>
<img src=https://avatars.githubusercontent.com/u/1244112?v=4 width="100;" alt=Oleg Andreyev/>
<br />
<sub style="font-size:14px"><b>Oleg Andreyev</b></sub>
</a>
</td>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/rcjsuen>
<img src=https://avatars.githubusercontent.com/u/15629116?v=4 width="100;" alt=Remy Suen/>
<br />
<sub style="font-size:14px"><b>Remy Suen</b></sub>
</a>
</td>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/esomething>
<img src=https://avatars.githubusercontent.com/u/64032?v=4 width="100;" alt=esomething/>
<br />
<sub style="font-size:14px"><b>esomething</b></sub>
</a>
</td>
</tr>
</table>
================================================
FILE: composer.json
================================================
{
"name": "softcreatr/jsonpath",
"description": "JSONPath implementation for parsing, searching and flattening arrays",
"license": "MIT",
"version": "1.0.2",
"authors": [
{
"name": "Stephen Frank",
"email": "stephen@flowsa.com",
"homepage": "https://prismaticbytes.com",
"role": "Developer"
},
{
"name": "Sascha Greuel",
"email": "hello@1-2.dev",
"homepage": "https://1-2.dev",
"role": "Developer"
}
],
"support": {
"email": "hello@1-2.dev",
"issues": "https://github.com/SoftCreatR/JSONPath/issues",
"forum": "https://github.com/SoftCreatR/JSONPath/discussions",
"source": "https://github.com/SoftCreatR/JSONPath"
},
"require": {
"php": "^8.5",
"ext-json": "*"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.92",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^12",
"squizlabs/php_codesniffer": "^4.0"
},
"replace": {
"flow/jsonpath": "*"
},
"minimum-stability": "stable",
"autoload": {
"psr-4": {
"Flow\\JSONPath\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Flow\\JSONPath\\Test\\": "tests/"
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist"
},
"scripts": {
"cs": "phpcs",
"cs-fix": "php-cs-fixer fix --config=.php-cs-fixer.dist.php",
"phpstan": "phpstan analyse --no-progress -c phpstan.neon.dist",
"test": "phpunit"
}
}
================================================
FILE: phpcs.xml
================================================
<?xml version="1.0"?>
<ruleset
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/squizlabs/PHP_CodeSniffer/master/phpcs.xsd"
>
<file>src/</file>
<file>tests/</file>
<arg name="extensions" value="php" />
<arg value="p"/>
<arg name="basepath" value="."/>
<rule ref="PSR12">
<!-- https://github.com/squizlabs/PHP_CodeSniffer/issues/3200 -->
<exclude name="PSR12.Classes.AnonClassDeclaration.SpaceAfterKeyword"/>
<!-- We have a large number of comments between the closing brace of an `if` and the `else`. -->
<exclude name="Squiz.ControlStructures.ControlSignature.SpaceAfterCloseBrace"/>
</rule>
</ruleset>
================================================
FILE: phpstan.neon.dist
================================================
parameters:
treatPhpDocTypesAsCertain: false
level: 6
paths:
- src
- tests
tmpDir: .phpstan-cache
================================================
FILE: phpunit.xml.dist
================================================
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>./tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>./src/</directory>
</include>
</source>
</phpunit>
================================================
FILE: src/AccessHelper.php
================================================
<?php
/**
* JSONPath implementation for PHP.
*
* @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License
*/
declare(strict_types=1);
namespace Flow\JSONPath;
use ArrayAccess;
use Traversable;
class AccessHelper
{
/**
* @return array<int, int|string>
*/
public static function collectionKeys(mixed $collection): array
{
if (\is_object($collection)) {
return \array_keys(\get_object_vars($collection));
}
return \array_keys($collection);
}
public static function isCollectionType(mixed $collection): bool
{
return \is_array($collection) || \is_object($collection);
}
public static function keyExists(mixed $collection, int|string|null $key, bool $magicIsAllowed = false): bool
{
if ($magicIsAllowed && \is_object($collection) && \method_exists($collection, '__get')) {
return true;
}
if (\is_array($collection)) {
if (\is_int($key) && $key < 0) {
$keys = \array_keys($collection);
$index = \count($keys) + $key;
return $index >= 0 && \array_key_exists($index, $keys);
}
return \array_key_exists($key ?? '', $collection);
}
if ($collection instanceof ArrayAccess) {
return $collection->offsetExists($key);
}
if (\is_object($collection)) {
return \property_exists($collection, (string)$key);
}
return false;
}
/**
* @todo Optimize conditions
*/
public static function getValue(mixed $collection, int|string|null $key, bool $magicIsAllowed = false): mixed
{
if (
$magicIsAllowed
&& \is_object($collection)
&& !$collection instanceof ArrayAccess && \method_exists($collection, '__get')
) {
$return = $collection->__get($key);
} elseif (\is_int($key) && $collection instanceof Traversable && !$collection instanceof ArrayAccess) {
$return = self::getValueByIndex($collection, $key);
} elseif (\is_object($collection) && !$collection instanceof ArrayAccess) {
$return = $collection->{$key};
} elseif ($collection instanceof ArrayAccess) {
$return = $collection->offsetExists($key) ? $collection->offsetGet($key) : null;
} elseif (\is_array($collection)) {
if (\is_int($key) && $key < 0) {
$index = \count($collection) + $key;
$return = $index >= 0 && \array_key_exists($index, $collection) ? $collection[$index] : null;
} else {
$return = $collection[$key] ?? null;
}
} else {
$return = null;
}
return $return;
}
/**
* Find item in php collection by index
* Written this way to handle instances ArrayAccess or Traversable objects
*/
private static function getValueByIndex(mixed $collection, int $key): mixed
{
$i = 0;
foreach ($collection as $val) {
if ($i === $key) {
return $val;
}
$i++;
}
if ($key < 0) {
$total = $i;
$i = 0;
foreach ($collection as $val) {
if ($i - $total === $key) {
return $val;
}
$i++;
}
}
return null;
}
public static function setValue(mixed &$collection, int|string|null $key, mixed $value): mixed
{
if (\is_object($collection) && !$collection instanceof ArrayAccess) {
$collection->{$key} = $value;
return $value;
}
if ($collection instanceof ArrayAccess) {
$collection->offsetSet($key, $value);
return $value;
}
$collection[$key] = $value;
return $value;
}
public static function unsetValue(mixed &$collection, int|string|null $key): void
{
if (\is_object($collection) && !$collection instanceof ArrayAccess) {
unset($collection->{$key});
}
if ($collection instanceof ArrayAccess) {
$collection->offsetUnset($key);
}
if (\is_array($collection)) {
unset($collection[$key]);
}
}
/**
* @throws JSONPathException
*/
/**
* @return array<int, mixed>
* @throws JSONPathException
*/
public static function arrayValues(mixed $collection): array
{
if (\is_array($collection)) {
return \array_values($collection);
}
if (\is_object($collection)) {
return \array_values((array)$collection);
}
throw new JSONPathException('Invalid variable type for arrayValues');
}
}
================================================
FILE: src/Filters/AbstractFilter.php
================================================
<?php
/**
* JSONPath implementation for PHP.
*
* @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License
*/
declare(strict_types=1);
namespace Flow\JSONPath\Filters;
use Flow\JSONPath\JSONPath;
use Flow\JSONPath\JSONPathToken;
abstract class AbstractFilter
{
protected bool $magicIsAllowed;
protected mixed $rootData = null;
public function __construct(protected JSONPathToken $token, int $options = 0)
{
$this->magicIsAllowed = ($options & JSONPath::ALLOW_MAGIC) === JSONPath::ALLOW_MAGIC;
}
public function setRootData(mixed $root): void
{
$this->rootData = $root;
}
/**
* @param array<array-key, mixed>|object $collection
* @return array<array-key, mixed>
*/
abstract public function filter(array|object $collection): array;
}
================================================
FILE: src/Filters/IndexFilter.php
================================================
<?php
/**
* JSONPath implementation for PHP.
*
* @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License
*/
declare(strict_types=1);
namespace Flow\JSONPath\Filters;
use Flow\JSONPath\AccessHelper;
use Flow\JSONPath\JSONPathException;
class IndexFilter extends AbstractFilter
{
/**
* @throws JSONPathException
* @inheritDoc
*/
public function filter(array|object $collection): array
{
if (\is_array($this->token->value)) {
$result = [];
foreach ($this->token->value as $value) {
if (AccessHelper::keyExists($collection, $value, $this->magicIsAllowed)) {
$result[] = AccessHelper::getValue($collection, $value, $this->magicIsAllowed);
}
}
return $result;
}
if (AccessHelper::keyExists($collection, $this->token->value, $this->magicIsAllowed)) {
return [
AccessHelper::getValue($collection, $this->token->value, $this->magicIsAllowed),
];
}
if ($this->token->value === '*' && !$this->token->quoted) {
return AccessHelper::arrayValues($collection);
}
if ($this->token->value === 'length') {
return [
\count($collection),
];
}
return [];
}
}
================================================
FILE: src/Filters/IndexesFilter.php
================================================
<?php
/**
* JSONPath implementation for PHP.
*
* @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License
*/
declare(strict_types=1);
namespace Flow\JSONPath\Filters;
use Flow\JSONPath\AccessHelper;
use Flow\JSONPath\JSONPath;
use Flow\JSONPath\JSONPathException;
use Flow\JSONPath\JSONPathToken;
use Flow\JSONPath\TokenType;
class IndexesFilter extends AbstractFilter
{
/**
* @inheritDoc
*
* @throws JSONPathException
*/
public function filter(array|object $collection): array
{
$return = [];
foreach ($this->token->value as $index) {
if (\is_array($index) && ($index['type'] ?? null) === 'slice') {
$sliceToken = new JSONPathToken(TokenType::Slice, $index['value']);
$sliceFilter = new SliceFilter(
$sliceToken,
$this->magicIsAllowed ? JSONPath::ALLOW_MAGIC : 0
);
$sliceFilter->setRootData($this->rootData ?? $collection);
$return = \array_merge($return, $sliceFilter->filter($collection));
continue;
}
if (\is_array($index) && ($index['type'] ?? null) === 'query') {
$queryToken = new JSONPathToken(TokenType::QueryMatch, $index['value']);
$queryFilter = new QueryMatchFilter(
$queryToken,
$this->magicIsAllowed ? JSONPath::ALLOW_MAGIC : 0
);
$queryFilter->setRootData($this->rootData ?? $collection);
$return = \array_merge($return, $queryFilter->filter($collection));
continue;
}
if ($index === '*' && !$this->token->quoted) {
$return = \array_merge($return, AccessHelper::arrayValues($collection));
continue;
}
if (AccessHelper::keyExists($collection, $index, $this->magicIsAllowed)) {
$return[] = AccessHelper::getValue($collection, $index, $this->magicIsAllowed);
}
}
return $return;
}
}
================================================
FILE: src/Filters/QueryMatchFilter.php
================================================
<?php
/**
* JSONPath implementation for PHP.
*
* @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License
*/
declare(strict_types=1);
namespace Flow\JSONPath\Filters;
use Flow\JSONPath\AccessHelper;
use Flow\JSONPath\JSONPath;
use Flow\JSONPath\JSONPathException;
use JsonException;
use RuntimeException;
use const JSON_THROW_ON_ERROR;
use const PREG_OFFSET_CAPTURE;
use const PREG_UNMATCHED_AS_NULL;
class QueryMatchFilter extends AbstractFilter
{
protected const string MATCH_QUERY_NEGATION_WRAPPED = '^(?<negate>!)\((?<logicalexpr>.+)\)$';
protected const string MATCH_QUERY_NEGATION_UNWRAPPED = '^(?<negate>!)(?<logicalexpr>.+)$';
protected const string MATCH_QUERY_OPERATORS = '
(@\.(?<key>[^\s<>!=]+)|@\[["\']?(?<keySquare>.*?)["\']?\]|(?<node>@)|(%group(?<group>\d+)%))
(\s*(?<operator>==|=~|=|<>|!==|!=|>=|<=|>|<|in|!in|nin)\s*(?<comparisonValue>.+?(?=\s*(?:&&|$|\|\||%))))?
(\s*(?<logicalandor>&&|\|\|)\s*)?
';
protected const string MATCH_GROUPED_EXPRESSION = '#\([^)(]*+(?:(?R)[^)(]*)*+\)#';
/**
* @throws JSONPathException
* @inheritDoc
*/
public function filter(array|object $collection): array
{
$filterExpression = $this->token->value;
$isShorthand = $this->token->shorthand ?? false;
if (\is_array($filterExpression)) {
$isShorthand = $filterExpression['shorthand'] ?? $isShorthand;
$filterExpression = $filterExpression['expression'] ?? '';
}
$negateFilter = false;
if (
\preg_match('/' . static::MATCH_QUERY_NEGATION_WRAPPED . '/x', $filterExpression, $negationMatches)
|| \preg_match('/' . static::MATCH_QUERY_NEGATION_UNWRAPPED . '/x', $filterExpression, $negationMatches)
) {
$negateFilter = true;
$filterExpression = $negationMatches['logicalexpr'];
}
$literalResult = $this->evaluateLiteralExpression($filterExpression, $collection);
if ($literalResult !== null) {
return $literalResult;
}
$shortCircuitResult = $this->evaluateExpressionWithTrailingLiteral($filterExpression, $collection);
if ($shortCircuitResult !== null) {
return $shortCircuitResult;
}
$filterGroups = [];
if (
\preg_match_all(
static::MATCH_GROUPED_EXPRESSION,
$filterExpression,
$matches,
PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL
)
) {
foreach ($matches[0] as $i => $matchesGroup) {
$test = \substr($matchesGroup[0], 1, -1);
//sanity check that our group is a group and not something within a string or regular expression
if (\preg_match('/' . static::MATCH_QUERY_OPERATORS . '/x', $test)) {
$filterGroups[$i] = $test;
$filterExpression = \str_replace($matchesGroup[0], "%group{$i}%", $filterExpression);
}
}
}
$match = \preg_match_all(
'/' . static::MATCH_QUERY_OPERATORS . '/x',
$filterExpression,
$matches,
PREG_UNMATCHED_AS_NULL
);
if (
$match === false
|| !isset($matches[1][0])
|| isset($matches['logicalandor'][\array_key_last($matches['logicalandor'])])
) {
$constantResult = $this->evaluateConstantExpression($filterExpression);
if ($constantResult !== null) {
return $constantResult ? AccessHelper::arrayValues($collection) : [];
}
throw new RuntimeException('Malformed filter query');
}
$return = [];
$matchCount = \count($matches[0]);
for ($expressionPart = 0; $expressionPart < $matchCount; $expressionPart++) {
$filteredCollection = $collection;
$logicalJoin = $expressionPart > 0 ? $matches['logicalandor'][$expressionPart - 1] : null;
if ($logicalJoin === '&&') {
//Restrict the nodes we need to look at to those already meeting criteria
$filteredCollection = $return;
$return = [];
}
//Processing a group
if ($matches['group'][$expressionPart] !== null) {
$filter = '$[?(' . $filterGroups[$matches['group'][$expressionPart]] . ')]';
$resolve = new JSONPath($filteredCollection)->find($filter)->getData();
$return = $resolve;
continue;
}
//Process a normal expression
$key = $this->normalizeKey($matches['key'][$expressionPart] ?: $matches['keySquare'][$expressionPart]);
$operator = $matches['operator'][$expressionPart] ?? null;
$comparisonValue = $matches['comparisonValue'][$expressionPart] ?? null;
$comparisonIsPath = $this->isPathComparison($comparisonValue);
$canCompareMissing = \in_array($operator, ['=', '==', '!=', '!==', '<>'], true) && $comparisonIsPath;
if (\is_string($comparisonValue)) {
$comparisonValue = \preg_replace('/^\'/', '"', $comparisonValue);
$comparisonValue = \preg_replace('/\'$/', '"', $comparisonValue);
try {
$comparisonValue = \json_decode($comparisonValue, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
//Leave $comparisonValue as raw (e.g. regular express or non quote wrapped string)
}
}
foreach ($filteredCollection as $nodeIndex => $node) {
if ($logicalJoin === '||' && \array_key_exists($nodeIndex, $return)) {
//Short-circuit, node already exists in output due to previous test
continue;
}
$selectedNode = null;
$notNothing = AccessHelper::keyExists($node, $key, $this->magicIsAllowed);
if ($key) {
if ($notNothing) {
$selectedNode = AccessHelper::getValue($node, $key, $this->magicIsAllowed);
} elseif (\str_contains($key, '.')) {
$foundValue = new JSONPath($node)->find($key)->getData();
if ($foundValue) {
$selectedNode = $foundValue[0];
$notNothing = true;
}
} elseif ($canCompareMissing) {
$notNothing = true;
}
} else {
//Node selection was plain @
$selectedNode = $node;
$notNothing = true;
}
$comparisonResult = null;
if ($notNothing) {
$resolvedComparisonValue = $this->resolveComparisonValue($comparisonValue, $node);
$comparisonResult = false;
switch ($operator) {
case null:
if ($key === '' || $key === null) {
$comparisonResult = !$isShorthand || $this->isTruthy($selectedNode);
} else {
$comparisonResult = AccessHelper::keyExists($node, $key, $this->magicIsAllowed)
|| (!$key);
}
break;
case "=":
case "==":
$comparisonResult = $this->compareEquals($selectedNode, $resolvedComparisonValue);
break;
case "!=":
case "!==":
case "<>":
$comparisonResult = !$this->compareEquals($selectedNode, $resolvedComparisonValue);
break;
case '=~':
$comparisonResult = @\preg_match(
(string)$resolvedComparisonValue,
(string)$selectedNode
);
break;
case '<':
$comparisonResult = $this->compareLessThan($selectedNode, $resolvedComparisonValue);
break;
case '<=':
$comparisonResult = $this->compareLessThan($selectedNode, $resolvedComparisonValue)
|| $this->compareEquals($selectedNode, $resolvedComparisonValue);
break;
case '>':
//rfc semantics
$comparisonResult = $this->compareLessThan($resolvedComparisonValue, $selectedNode);
break;
case '>=':
//rfc semantics
$comparisonResult = $this->compareLessThan($resolvedComparisonValue, $selectedNode)
|| $this->compareEquals($selectedNode, $resolvedComparisonValue);
break;
case "in":
$comparisonResult = \is_array($resolvedComparisonValue)
&& \in_array($selectedNode, $resolvedComparisonValue, true);
break;
case 'nin':
case "!in":
$comparisonResult = \is_array($resolvedComparisonValue)
&& !\in_array($selectedNode, $resolvedComparisonValue, true);
break;
}
}
if ($negateFilter) {
$comparisonResult = !$comparisonResult;
}
if ($comparisonResult) {
$return[$nodeIndex] = $node;
}
}
}
//Keep out returned nodes in the same order they were defined in the original collection
\ksort($return);
return $return;
}
protected function isNumber(mixed $value): bool
{
return !\is_string($value) && \is_numeric($value);
}
/**
* @throws JSONPathException
*/
private function resolveComparisonValue(mixed $comparisonValue, mixed $node): mixed
{
if (!\is_string($comparisonValue)) {
return $comparisonValue;
}
if (\str_starts_with($comparisonValue, '@')) {
$path = \substr($comparisonValue, 1);
if ($path === '' || $path === '.') {
return $node;
}
$resolved = new JSONPath($node)->find($path)->getData();
return \is_array($resolved) && \array_key_exists(0, $resolved) ? $resolved[0] : null;
}
if (\str_starts_with($comparisonValue, '$')) {
$root = $this->rootData ?? $node;
$resolved = new JSONPath($root)->find($comparisonValue)->getData();
return \is_array($resolved) && \array_key_exists(0, $resolved) ? $resolved[0] : null;
}
return $comparisonValue;
}
private function normalizeKey(mixed $key): int|string|null
{
if (\is_string($key) && \preg_match('/^-?\d+$/', $key)) {
return (int)$key;
}
return $key;
}
private function isPathComparison(mixed $comparisonValue): bool
{
return \is_string($comparisonValue) && \str_starts_with($comparisonValue, '@');
}
private function evaluateConstantExpression(string $expression): ?bool
{
$pattern = '/^\s*(?<left>[^&|]+?)\s*(?<operator>==|=|!=|!==|<>|<=|>=|<|>)\s*(?<right>[^&|]+?)\s*$/';
if (!\preg_match($pattern, $expression, $matches)) {
return null;
}
$left = $this->decodeLiteral($matches['left']);
$right = $this->decodeLiteral($matches['right']);
$operator = $matches['operator'];
return match ($operator) {
'==', '=' => $this->compareEquals($left, $right),
'!=', '!==', '<>' => !$this->compareEquals($left, $right),
'<' => $this->compareLessThan($left, $right),
'<=' => $this->compareLessThan($left, $right) || $this->compareEquals($left, $right),
'>' => $this->compareLessThan($right, $left),
'>=' => $this->compareLessThan($right, $left) || $this->compareEquals($left, $right),
};
}
/**
* @param array<int, mixed>|object $collection
* @return array<int, mixed>|null
* @throws JSONPathException
*/
private function evaluateLiteralExpression(string $expression, array|object $collection): ?array
{
$trimmed = \trim($expression);
if ($trimmed === '') {
return [];
}
$literalValue = $this->decodeLiteral($trimmed);
$literalIsBool = \is_bool($literalValue);
if (!$literalIsBool && $literalValue !== null) {
return null;
}
return $this->isTruthy($literalValue) ? AccessHelper::arrayValues($collection) : [];
}
/**
* @param array<int, mixed>|object $collection
* @return array<int, mixed>|null
* @throws JSONPathException
*/
private function evaluateExpressionWithTrailingLiteral(
string $expression,
array|object $collection
): ?array {
if (
!\preg_match(
'/^(?<left>.+?)\s*(?<op>&&|\|\|)\s*(?<literal>true|false|null)\s*$/i',
$expression,
$matches
)
) {
return null;
}
$leftFilter = '$[?(' . $matches['left'] . ')]';
$leftResult = new JSONPath($collection)->find($leftFilter)->getData();
$literalValue = $this->decodeLiteral($matches['literal']);
$literalIsTrue = $this->isTruthy($literalValue);
return match ($matches['op']) {
'&&' => $literalIsTrue ? $leftResult : [],
'||' => $literalIsTrue ? AccessHelper::arrayValues($collection) : $leftResult,
default => [],
};
}
private function decodeLiteral(string $literal): mixed
{
$literal = \trim($literal);
try {
return \json_decode($literal, true, 512, \JSON_THROW_ON_ERROR);
} catch (JsonException) {
if (\is_numeric($literal)) {
return $literal + 0;
}
return $literal;
}
}
private function isTruthy(mixed $value): bool
{
return (bool)$value;
}
protected function compareEquals(mixed $a, mixed $b): bool
{
$type_a = \gettype($a);
$type_b = \gettype($b);
if ($type_a === $type_b || ($this->isNumber($a) && $this->isNumber($b))) {
//Primitives or Numbers
if ($a === null || \is_scalar($a)) {
/** @noinspection TypeUnsafeComparisonInspection */
return $a == $b;
}
if (\is_array($a) && \is_array($b)) {
return $this->deepEqual($a, $b);
}
if (\is_object($a) && \is_object($b)) {
return $this->deepEqual((array)$a, (array)$b);
}
}
return false;
}
/**
* @param array<array-key, mixed> $a
* @param array<array-key, mixed> $b
*/
private function deepEqual(array $a, array $b): bool
{
$aIsList = \array_is_list($a);
$bIsList = \array_is_list($b);
if ($aIsList !== $bIsList) {
return false;
}
if (\count($a) !== \count($b)) {
return false;
}
if ($aIsList) {
return \array_all($a, fn ($value, $index) => \array_key_exists($index, $b)
&& $this->compareEquals($value, $b[$index]));
}
return \array_all($a, fn ($value, $key) => \array_key_exists($key, $b)
&& $this->compareEquals($value, $b[$key]));
}
protected function compareLessThan(mixed $a, mixed $b): bool
{
if ((\is_string($a) && \is_string($b)) || ($this->isNumber($a) && $this->isNumber($b))) {
//numerical and string comparison supported only
return $a < $b;
}
return false;
}
}
================================================
FILE: src/Filters/QueryResultFilter.php
================================================
<?php
/**
* JSONPath implementation for PHP.
*
* @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License
*/
declare(strict_types=1);
namespace Flow\JSONPath\Filters;
use Flow\JSONPath\AccessHelper;
use Flow\JSONPath\JSONPathException;
class QueryResultFilter extends AbstractFilter
{
/**
* @throws JSONPathException
* @inheritDoc
*/
public function filter(array|object $collection): array
{
if (!\preg_match('/@\.(?<key>\w+)\s*(?<operator>[-+*\/])\s*(?<numeric>\d+)/', $this->token->value, $matches)) {
throw new JSONPathException('Unsupported operator in expression');
}
$matchKey = $matches['key'];
if (AccessHelper::keyExists($collection, $matchKey, $this->magicIsAllowed)) {
$value = AccessHelper::getValue($collection, $matchKey, $this->magicIsAllowed);
} elseif ($matches['key'] === 'length') {
$value = \count($collection);
} else {
return [];
}
$resultKey = match ($matches['operator']) {
'+' => $value + $matches['numeric'],
'*' => $value * $matches['numeric'],
'-' => $value - $matches['numeric'],
'/' => $value / $matches['numeric'],
};
$result = [];
if (AccessHelper::keyExists($collection, $resultKey, $this->magicIsAllowed)) {
$result[] = AccessHelper::getValue($collection, $resultKey, $this->magicIsAllowed);
}
return $result;
}
}
================================================
FILE: src/Filters/RecursiveFilter.php
================================================
<?php
/**
* JSONPath implementation for PHP.
*
* @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License
*/
declare(strict_types=1);
namespace Flow\JSONPath\Filters;
use Flow\JSONPath\AccessHelper;
use Flow\JSONPath\JSONPathException;
class RecursiveFilter extends AbstractFilter
{
/**
* @inheritDoc
*
* @throws JSONPathException
*/
public function filter(array|object $collection): array
{
$result = [];
$this->recurse($result, $collection);
return $result;
}
/**
* @param array<int, array<array-key, mixed>> $result
* @param array<array-key, mixed>|object $data
*
* @throws JSONPathException
*/
private function recurse(array &$result, array|object $data): void
{
$result[] = (array)$data;
if (AccessHelper::isCollectionType($data)) {
foreach (AccessHelper::arrayValues($data) as $value) {
if (AccessHelper::isCollectionType($value)) {
$this->recurse($result, $value);
}
}
}
}
}
================================================
FILE: src/Filters/SliceFilter.php
================================================
<?php
/**
* JSONPath implementation for PHP.
*
* @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License
*/
declare(strict_types=1);
namespace Flow\JSONPath\Filters;
use Flow\JSONPath\AccessHelper;
class SliceFilter extends AbstractFilter
{
/**
* @inheritDoc
*/
public function filter(array|object $collection): array
{
if (
!\is_array($collection)
&& !$collection instanceof \Countable
&& !$collection instanceof \ArrayAccess
) {
return [];
}
$length = \count($collection);
$start = $this->token->value['start'];
$end = $this->token->value['end'];
$step = $this->token->value['step'] ?? 1;
$result = [];
if ($step === 0) {
return $result;
}
if ($step > 0) {
[$start, $end] = $this->normalizeForPositiveStep($length, $start, $end);
for ($i = $start; $i < $end; $i += $step) {
if (AccessHelper::keyExists($collection, $i, $this->magicIsAllowed)) {
$result[] = $collection[$i];
}
}
return $result;
}
[$start, $end] = $this->normalizeForNegativeStep($length, $start, $end);
for ($i = $start; $i > $end; $i += $step) {
if (AccessHelper::keyExists($collection, $i, $this->magicIsAllowed)) {
$result[] = $collection[$i];
}
}
return $result;
}
/**
* @return array{0: int, 1: int}
*/
private function normalizeForPositiveStep(int $length, ?int $start, ?int $end): array
{
if ($start === null) {
$start = 0;
} elseif ($start < 0) {
$start += $length;
}
if ($start < 0) {
$start = 0;
} elseif ($start > $length) {
$start = $length;
}
if ($end === null) {
$end = $length;
} elseif ($end < 0) {
$end += $length;
}
if ($end < 0) {
$end = 0;
} elseif ($end > $length) {
$end = $length;
}
return [$start, $end];
}
/**
* @return array{0: int, 1: int}
*/
private function normalizeForNegativeStep(int $length, ?int $start, ?int $end): array
{
if ($start === null) {
$start = $length - 1;
} else {
if ($start < 0) {
$start += $length;
}
if ($start < 0) {
$start = -1;
} elseif ($start >= $length) {
$start = $length - 1;
}
}
if ($end === null) {
$end = -1;
} else {
if ($end < 0) {
$end += $length;
}
if ($end < 0) {
$end = -1;
} elseif ($end >= $length) {
$end = $length - 1;
}
}
return [$start, $end];
}
}
================================================
FILE: src/JSONPath.php
================================================
<?php
/**
* JSONPath implementation for PHP.
*
* @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License
*/
declare(strict_types=1);
namespace Flow\JSONPath;
use ArrayAccess;
use Countable;
use Iterator;
use JsonSerializable;
use Override;
/**
* @implements ArrayAccess<int|string, mixed>
* @implements Iterator<int|string, mixed>
*/
class JSONPath implements ArrayAccess, Iterator, JsonSerializable, Countable
{
public const int ALLOW_MAGIC = 1;
/** @var array<int, list<JSONPathToken>> */
protected static array $tokenCache = [];
protected mixed $data = [];
protected int $options = 0;
final public function __construct(mixed $data = [], int $options = 0)
{
$this->data = $data;
$this->options = $options;
}
/**
* Evaluate an expression
*
* @throws JSONPathException
*
* @return static
*/
public function find(string $expression): self
{
$tokens = $this->parseTokens($expression);
$collectionData = [$this->data];
foreach ($tokens as $token) {
$filter = $token->buildFilter($this->options);
$filter->setRootData($this->data);
$filteredDataList = [];
foreach ($collectionData as $value) {
if (AccessHelper::isCollectionType($value)) {
$filteredDataList[] = $filter->filter($value);
}
}
if (!empty($filteredDataList)) {
$collectionData = \array_merge(...$filteredDataList);
} else {
$collectionData = [];
}
}
return new static($collectionData, $this->options);
}
public function first(): mixed
{
$keys = AccessHelper::collectionKeys($this->data);
if (empty($keys)) {
return null;
}
$value = $this->data[$keys[0]] ?? null;
return AccessHelper::isCollectionType($value) ? new static($value, $this->options) : $value;
}
/**
* Evaluate an expression and return the last result
*/
public function last(): mixed
{
$keys = AccessHelper::collectionKeys($this->data);
if (empty($keys)) {
return null;
}
$value = $this->data[\end($keys)] ?? null;
return AccessHelper::isCollectionType($value) ? new static($value, $this->options) : $value;
}
/**
* Evaluate an expression and return the first key
*/
public function firstKey(): string|int|null
{
$keys = AccessHelper::collectionKeys($this->data);
if (empty($keys)) {
return null;
}
return $keys[0];
}
/**
* Evaluate an expression and return the last key
*/
public function lastKey(): string|int|null
{
$keys = AccessHelper::collectionKeys($this->data);
if (empty($keys)) {
return null;
}
return \end($keys);
}
/**
* @return list<JSONPathToken>
* @throws JSONPathException
*/
public function parseTokens(string $expression): array
{
$cacheKey = \crc32($expression);
if (isset(static::$tokenCache[$cacheKey])) {
return static::$tokenCache[$cacheKey];
}
$lexer = new JSONPathLexer($expression);
$tokens = $lexer->parseExpression();
static::$tokenCache[$cacheKey] = $tokens;
return $tokens;
}
public function getData(): mixed
{
return $this->data;
}
/**
* @noinspection MagicMethodsValidityInspection
*/
public function __get(string|int $key): mixed
{
return $this->offsetExists($key) ? $this->offsetGet($key) : null;
}
#[Override]
public function offsetExists(mixed $offset): bool
{
return AccessHelper::keyExists($this->data, $offset);
}
#[Override]
public function offsetGet(mixed $offset): mixed
{
$value = AccessHelper::getValue($this->data, $offset);
return AccessHelper::isCollectionType($value)
? new static($value, $this->options)
: $value;
}
#[Override]
public function offsetSet(mixed $offset, mixed $value): void
{
if ($offset === null) {
$this->data[] = $value;
} else {
AccessHelper::setValue($this->data, $offset, $value);
}
}
#[Override]
public function offsetUnset(mixed $offset): void
{
AccessHelper::unsetValue($this->data, $offset);
}
#[Override]
public function jsonSerialize(): mixed
{
return $this->getData();
}
#[Override]
public function current(): mixed
{
$value = \current($this->data);
return AccessHelper::isCollectionType($value) ? new static($value, $this->options) : $value;
}
#[Override]
public function next(): void
{
\next($this->data);
}
#[Override]
public function key(): string|int|null
{
return \key($this->data);
}
#[Override]
public function valid(): bool
{
return \key($this->data) !== null;
}
#[Override]
public function rewind(): void
{
\reset($this->data);
}
#[Override]
public function count(): int
{
return \count($this->data);
}
}
================================================
FILE: src/JSONPathException.php
================================================
<?php
/**
* JSONPath implementation for PHP.
*
* @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License
*/
declare(strict_types=1);
namespace Flow\JSONPath;
use Exception;
class JSONPathException extends Exception
{
// does nothing
}
================================================
FILE: src/JSONPathLexer.php
================================================
<?php
/**
* JSONPath implementation for PHP.
*
* @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License
*/
declare(strict_types=1);
namespace Flow\JSONPath;
class JSONPathLexer
{
/*
* Match within bracket groups
* Matches are whitespace insensitive
*/
// e.g.: foo or 40f35757-2563-4790-b0b1-caa904be455f or $
public const string MATCH_INDEX = '(?!-)[\-\w]+ | \\$ | \\*';
// Eg. 0,1,2 or *,1 or 0,1,2,
public const string MATCH_INDEXES = '\s* (?:-?\d+|\*) (?: \s* , \s* (?:-?\d+|\*) )+ \s* ,? \s*';
// Eg. [0:2:1] or [-1]
public const string MATCH_SLICE = '(?:-?\d*:-?\d*(?::-?\d*)?|-\\d+)';
// Eg. ?(@.length - 1)
public const string MATCH_QUERY_RESULT = '\s* \( .+? \) \s*';
// Eg. ?(@.foo = "bar")
public const string MATCH_QUERY_MATCH = '\s* \?\(.+?\) \s*';
// Eg. 'bar'
public const string MATCH_INDEX_IN_SINGLE_QUOTES = '\s* \' (.+?)? \' \s*';
// Eg. "bar"
public const string MATCH_INDEX_IN_DOUBLE_QUOTES = '\s* " (.+?)? " \s*';
private readonly string $expression;
private readonly int $expressionLength;
public function __construct(string $expression)
{
$expression = \trim($expression);
$len = \strlen($expression);
if ($len === 0) {
$this->expression = '';
$this->expressionLength = 0;
return;
}
if ($expression[0] === '$' || $expression[0] === '@') {
$expression = \substr($expression, 1);
}
if ($expression === '') {
$this->expression = '';
$this->expressionLength = 0;
return;
}
if ($expression[0] !== '.' && $expression[0] !== '[') {
$expression = '.' . $expression;
}
$this->expression = $expression;
$this->expressionLength = \strlen($expression);
}
/**
* @return list<JSONPathToken>
* @throws JSONPathException
*/
public function parseExpressionTokens(): array
{
$squareBracketDepth = 0;
$tokenValue = '';
$tokens = [];
$inBracketQuote = null;
$inQuote = null;
for ($i = 0; $i < $this->expressionLength; $i++) {
$char = $this->expression[$i];
if ($squareBracketDepth === 0 && ($char === "'" || $char === '"')) {
$escaped = $this->isEscaped($tokenValue);
$inQuote = $inQuote === $char && !$escaped ? null : ($inQuote ?? $char);
}
if (($squareBracketDepth === 0) && $inQuote === null && $char === '.') {
if ($this->lookAhead($i) === '.') {
$tokens[] = new JSONPathToken(TokenType::Recursive, null);
}
continue;
}
if ($char === '[' && $inBracketQuote === null) {
$squareBracketDepth++;
if ($squareBracketDepth === 1) {
$inBracketQuote = null;
continue;
}
}
if ($char === ']' && $squareBracketDepth > 0 && $inBracketQuote === null) {
$squareBracketDepth--;
if ($squareBracketDepth === 0) {
$tokens[] = $this->createToken($tokenValue);
$tokenValue = '';
continue;
}
}
/*
* Within square brackets
*/
if ($squareBracketDepth > 0) {
if (($char === "'" || $char === '"')) {
$escaped = $this->isEscaped($tokenValue);
if ($inBracketQuote === null && !$escaped) {
$inBracketQuote = $char;
} elseif ($inBracketQuote === $char && !$escaped) {
$inBracketQuote = null;
}
}
$tokenValue .= $char;
continue;
}
/*
* Outside square brackets
*/
$tokenValue .= $char;
if (
$inQuote === null
&& ($this->atEnd($i) || \in_array($this->lookAhead($i), ['.', '['], true))
) {
$tokens[] = $this->createToken($tokenValue);
$tokenValue = '';
}
}
if ($tokenValue !== '') {
$tokens[] = $this->createToken($tokenValue);
}
return $tokens;
}
protected function lookAhead(int $pos, int $forward = 1): ?string
{
return $this->expression[$pos + $forward] ?? null;
}
protected function atEnd(int $pos): bool
{
return $pos === ($this->expressionLength - 1);
}
/**
* @return list<JSONPathToken>
* @throws JSONPathException
*/
public function parseExpression(): array
{
return $this->parseExpressionTokens();
}
/**
* @throws JSONPathException
*/
protected function createToken(string $value): JSONPathToken
{
// The IDE doesn't like, what we do with $value, so let's
// move it to a separate variable, to get rid of any IDE warnings
$tokenValue = \trim($value);
/** @var JSONPathToken|null $ret */
$ret = null;
if (\str_contains($tokenValue, ',')) {
$parts = \array_values(\array_filter(
\array_map('trim', \explode(',', $tokenValue)),
static fn (string $part): bool => $part !== ''
));
if ($parts !== []) {
$union = [];
$hasSlice = false;
$hasQuery = false;
foreach ($parts as $part) {
if (
\preg_match('/^' . static::MATCH_INDEX_IN_SINGLE_QUOTES . '$/xu', $part, $matches)
|| \preg_match('/^' . static::MATCH_INDEX_IN_DOUBLE_QUOTES . '$/xu', $part, $matches)
) {
$union[] = $this->decodeQuotedIndex($matches[1] ?? '', $matches[0][0]);
continue;
}
if (\preg_match('/^-\\d+$/', $part)) {
$union[] = (int)$part;
continue;
}
if (\preg_match('/^' . static::MATCH_SLICE . '$/u', $part)) {
$union[] = [
'type' => 'slice',
'value' => $this->parseSlice($part),
];
$hasSlice = true;
continue;
}
if (\preg_match('/^(' . static::MATCH_INDEX . ')$/xu', $part)) {
$union[] = \preg_match('/^-?\d+$/', $part) ? (int)$part : $part;
continue;
}
if (\preg_match('/^' . static::MATCH_QUERY_MATCH . '$/xu', $part)) {
$union[] = [
'type' => 'query',
'value' => \substr($part, 2, -1),
];
$hasQuery = true;
}
}
if (\count($union) === \count($parts)) {
$quotedPattern = '/^(' . static::MATCH_INDEX_IN_SINGLE_QUOTES . '|'
. static::MATCH_INDEX_IN_DOUBLE_QUOTES . ')$/xu';
$quotedCallback = static function (string $part) use ($quotedPattern): bool {
return \preg_match($quotedPattern, $part) === 1;
};
$quotedParts = \array_filter($parts, $quotedCallback);
$allQuoted = \count($quotedParts) === \count($parts);
$tokenType = ($hasSlice || $hasQuery || !$allQuoted) ? TokenType::Indexes : TokenType::Index;
return new JSONPathToken($tokenType, $union, $allQuoted);
}
}
}
if (\preg_match('/^-\\d+$/', $tokenValue)) {
return new JSONPathToken(TokenType::Index, (int)$tokenValue);
}
if ($tokenValue === '') {
return new JSONPathToken(TokenType::Indexes, []);
}
if (
($tokenValue[0] === "'" || $tokenValue[0] === '"')
&& $tokenValue[\strlen($tokenValue) - 1] === $tokenValue[0]
) {
$tokenValue = $this->decodeQuotedIndex(\substr($tokenValue, 1, -1), $tokenValue[0]);
return new JSONPathToken(TokenType::Index, $tokenValue, true);
}
if (\preg_match('/^(' . static::MATCH_INDEX . ')$/xu', $tokenValue, $matches)) {
if (\preg_match('/^-?\d+$/', $tokenValue)) {
$tokenValue = (int)$tokenValue;
}
$ret = new JSONPathToken(TokenType::Index, $tokenValue);
} elseif (\preg_match('/^' . static::MATCH_SLICE . '$/xu', $tokenValue, $matches)) {
$tokenValue = $this->parseSlice($tokenValue);
$ret = new JSONPathToken(TokenType::Slice, $tokenValue);
} elseif (\preg_match('/^' . static::MATCH_QUERY_RESULT . '$/xu', $tokenValue)) {
$tokenValue = \substr($tokenValue, 1, -1);
$ret = new JSONPathToken(TokenType::QueryResult, $tokenValue);
} elseif ($tokenValue === '?()') {
$ret = new JSONPathToken(TokenType::QueryMatch, '', shorthand: false);
} elseif ($tokenValue === '?') {
$ret = new JSONPathToken(TokenType::QueryMatch, '@', shorthand: true);
} elseif (\preg_match('/^\\?@/', $tokenValue)) {
$expr = \substr($tokenValue, 1);
$expr = $expr === '' ? '@' : $expr;
$ret = new JSONPathToken(TokenType::QueryMatch, $expr, shorthand: true);
} elseif (\preg_match('/^' . static::MATCH_QUERY_MATCH . '$/xu', $tokenValue)) {
$tokenValue = \substr($tokenValue, 2, -1);
$ret = new JSONPathToken(TokenType::QueryMatch, $tokenValue);
}
if ($ret !== null) {
return $ret;
}
throw new JSONPathException("Unable to parse token {$tokenValue} in expression: {$this->expression}");
}
/**
* @return array{start: int|null, end: int|null, step: int|null}
*/
private function parseSlice(string $tokenValue): array
{
$parts = \explode(':', $tokenValue);
return [
'start' => $parts[0] !== '' ? (int)$parts[0] : null,
'end' => isset($parts[1]) && $parts[1] !== '' ? (int)$parts[1] : null,
'step' => isset($parts[2]) && $parts[2] !== '' ? (int)$parts[2] : null,
];
}
private function isEscaped(string $tokenValue): bool
{
$len = \strlen($tokenValue);
if ($len === 0) {
return false;
}
$backslashCount = 0;
for ($i = $len - 1; $i >= 0; $i--) {
if ($tokenValue[$i] === '\\') {
$backslashCount++;
continue;
}
break;
}
return ($backslashCount % 2) === 1;
}
private function decodeQuotedIndex(string $tokenValue, string $quote): string
{
// Unescape backslashes first, then the quote type used
$tokenValue = \str_replace('\\\\', '\\', $tokenValue);
if ($quote === "'") {
$tokenValue = \str_replace("\\'", "'", $tokenValue);
} elseif ($quote === '"') {
$tokenValue = \str_replace('\\"', '"', $tokenValue);
}
return $tokenValue;
}
}
================================================
FILE: src/JSONPathToken.php
================================================
<?php
/**
* JSONPath implementation for PHP.
*
* @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License
*/
declare(strict_types=1);
namespace Flow\JSONPath;
use Flow\JSONPath\Filters\AbstractFilter;
use Flow\JSONPath\Filters\IndexesFilter;
use Flow\JSONPath\Filters\IndexFilter;
use Flow\JSONPath\Filters\QueryMatchFilter;
use Flow\JSONPath\Filters\QueryResultFilter;
use Flow\JSONPath\Filters\RecursiveFilter;
use Flow\JSONPath\Filters\SliceFilter;
readonly class JSONPathToken
{
public function __construct(
public TokenType $type,
public mixed $value,
public bool $quoted = false,
public bool $shorthand = false,
) {
// ...
}
public function buildFilter(int $options): AbstractFilter
{
$filterClass = match ($this->type) {
TokenType::Index => IndexFilter::class,
TokenType::Indexes => IndexesFilter::class,
TokenType::QueryMatch => QueryMatchFilter::class,
TokenType::QueryResult => QueryResultFilter::class,
TokenType::Recursive => RecursiveFilter::class,
TokenType::Slice => SliceFilter::class,
};
return new $filterClass($this, $options);
}
}
================================================
FILE: src/TokenType.php
================================================
<?php
/**
* JSONPath implementation for PHP.
*
* @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License
*/
declare(strict_types=1);
namespace Flow\JSONPath;
enum TokenType: string
{
case Index = 'index';
case Recursive = 'recursive';
case QueryResult = 'queryResult';
case QueryMatch = 'queryMatch';
case Slice = 'slice';
case Indexes = 'indexes';
}
================================================
FILE: tests/AccessHelperTest.php
================================================
<?php
/**
* JSONPath implementation for PHP.
*
* @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License
*/
declare(strict_types=1);
namespace Flow\JSONPath\Test;
use ArrayAccess;
use ArrayIterator;
use Flow\JSONPath\AccessHelper;
use Flow\JSONPath\JSONPathException;
use IteratorAggregate;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Traversable;
#[CoversClass(AccessHelper::class)]
class AccessHelperTest extends TestCase
{
public function testKeyExistsRespectsMagicGet(): void
{
$magic = new class {
public function __get(string $name): string
{
return "magic-{$name}";
}
public function __set(string $name, mixed $value): void
{
$this->{$name} = $value;
}
public function __isset(string $name): bool
{
return isset($this->{$name});
}
};
self::assertTrue(AccessHelper::keyExists($magic, 'foo', true));
self::assertFalse(AccessHelper::keyExists($magic, 'foo'));
}
public function testKeyExistsSupportsArrayAccessAndNegativeIndex(): void
{
$arrayAccess = new class implements ArrayAccess {
/** @var array<string, string> */
private array $store = ['bar' => 'baz'];
public function offsetExists($offset): bool
{
return \array_key_exists($offset, $this->store);
}
public function offsetGet($offset): mixed
{
return $this->store[$offset];
}
public function offsetSet($offset, $value): void
{
$this->store[$offset] = $value;
}
public function offsetUnset($offset): void
{
unset($this->store[$offset]);
}
};
self::assertTrue(AccessHelper::keyExists($arrayAccess, 'bar'));
self::assertTrue(AccessHelper::keyExists([1 => 'foo'], -1));
}
public function testGetValueCoversMagicArrayAndArrayAccess(): void
{
$magic = new class {
public function __get(string $name): string
{
return "magic-{$name}";
}
public function __set(string $name, mixed $value): void
{
$this->{$name} = $value;
}
public function __isset(string $name): bool
{
return isset($this->{$name});
}
};
$arrayAccess = new class implements ArrayAccess {
/** @var array<string, string> */
private array $store = ['bar' => 'baz'];
public function offsetExists($offset): bool
{
return \array_key_exists($offset, $this->store);
}
public function offsetGet($offset): mixed
{
return $this->store[$offset];
}
public function offsetSet($offset, $value): void
{
$this->store[$offset] = $value;
}
public function offsetUnset($offset): void
{
unset($this->store[$offset]);
}
};
self::assertSame('magic-foo', AccessHelper::getValue($magic, 'foo', true));
self::assertSame('baz', AccessHelper::getValue($arrayAccess, 'bar'));
self::assertSame('b', AccessHelper::getValue(['a', 'b'], -1));
self::assertNull(AccessHelper::getValue(['a'], 'missing'));
$plainObject = (object)['prop' => 'value'];
self::assertSame('value', AccessHelper::getValue($plainObject, 'prop'));
}
public function testGetValueByIndexSupportsTraversableAndNegativeOffset(): void
{
$iterable = new class implements IteratorAggregate {
public function getIterator(): Traversable
{
return new ArrayIterator(['first', 'second', 'third']);
}
};
self::assertSame('third', AccessHelper::getValue($iterable, -1));
self::assertSame('second', AccessHelper::getValue($iterable, 1));
}
public function testGetValueNullCases(): void
{
self::assertNull(AccessHelper::getValue('scalar', 'foo'));
self::assertNull(AccessHelper::getValue('scalar', 5));
$iterable = new ArrayIterator(['only']);
self::assertNull(AccessHelper::getValue($iterable, 5));
}
public function testArrayValuesThrowsOnInvalidType(): void
{
$this->expectException(JSONPathException::class);
AccessHelper::arrayValues('not-an-array');
}
/**
* @throws JSONPathException
*/
public function testArrayValuesCastsObject(): void
{
$obj = (object)['a' => 1, 'b' => 2];
self::assertSame([1, 2], AccessHelper::arrayValues($obj));
self::assertSame([1, 2], AccessHelper::arrayValues(['a' => 1, 'b' => 2]));
}
public function testGetValueByIndexReturnsNullWhenOutOfRange(): void
{
$iterable = (static function () {
yield 'first';
})();
self::assertNull(AccessHelper::getValue($iterable, 10));
}
public function testKeyExistsAndCollectionHelpers(): void
{
$object = (object)['a' => 1];
self::assertTrue(AccessHelper::keyExists($object, 'a'));
self::assertFalse(AccessHelper::keyExists('scalar', 'a'));
self::assertSame(['a'], AccessHelper::collectionKeys($object));
self::assertSame(['b'], AccessHelper::collectionKeys(['b' => 2]));
self::assertFalse(AccessHelper::isCollectionType('scalar'));
}
public function testSetAndUnsetValueAcrossTypes(): void
{
$object = (object)['a' => 1];
AccessHelper::setValue($object, 'b', 2);
self::assertSame(2, $object->b);
$array = ['x' => 1];
AccessHelper::setValue($array, 'y', 3);
self::assertSame(3, $array['y']);
$arrayAccess = new class implements ArrayAccess {
/** @var array<string, string> */
public array $store = [];
public function offsetExists($offset): bool
{
return \array_key_exists($offset, $this->store);
}
public function offsetGet($offset): mixed
{
return $this->store[$offset];
}
public function offsetSet($offset, $value): void
{
$this->store[$offset] = $value;
}
public function offsetUnset($offset): void
{
unset($this->store[$offset]);
}
};
AccessHelper::setValue($arrayAccess, 'k', 'v');
self::assertSame('v', $arrayAccess->store['k']);
AccessHelper::unsetValue($arrayAccess, 'k');
self::assertArrayNotHasKey('k', $arrayAccess->store);
AccessHelper::unsetValue($array, 'x');
self::assertArrayNotHasKey('x', $array);
$obj = (object)['x' => 1];
AccessHelper::unsetValue($obj, 'x');
self::assertFalse(\property_exists($obj, 'x'));
$arrayAccess->offsetUnset('missing');
}
}
================================================
FILE: tests/IndexFilterTest.php
================================================
<?php
/**
* JSONPath implementation for PHP.
*
* @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License
*/
declare(strict_types=1);
namespace Flow\JSONPath\Test;
use ArrayObject;
use Flow\JSONPath\Filters\IndexFilter;
use Flow\JSONPath\JSONPath;
use Flow\JSONPath\JSONPathException;
use Flow\JSONPath\JSONPathToken;
use Flow\JSONPath\TokenType;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(IndexFilter::class)]
class IndexFilterTest extends TestCase
{
/**
* @throws JSONPathException
*/
public function testArrayValueTokenReturnsOnlyExistingKeys(): void
{
$token = new JSONPathToken(TokenType::Index, [0, 2, 99]);
$filter = new IndexFilter($token);
self::assertSame(
['first', 'third'],
$filter->filter(['first', 'second', 'third'])
);
}
/**
* @throws JSONPathException
*/
public function testSingleIndexWorksForObjectsAndArrayAccess(): void
{
$token = new JSONPathToken(TokenType::Index, 'prop');
$filter = new IndexFilter($token);
$object = (object)['prop' => 5];
self::assertSame([5], $filter->filter($object));
$arrayObject = new ArrayObject(['prop' => 'value']);
self::assertSame(['value'], $filter->filter($arrayObject));
}
/**
* @throws JSONPathException
*/
public function testWildcardReturnsValuesAndLengthReturnsCount(): void
{
$wildcard = new IndexFilter(new JSONPathToken(TokenType::Index, '*'));
$length = new IndexFilter(new JSONPathToken(TokenType::Index, 'length'));
$input = ['a' => 1, 'b' => 2];
self::assertSame([1, 2], $wildcard->filter($input));
self::assertSame([2], $length->filter($input));
}
/**
* @throws JSONPathException
*/
public function testReturnsEmptyWhenKeyMissing(): void
{
$filter = new IndexFilter(new JSONPathToken(TokenType::Index, 'missing'));
self::assertSame([], $filter->filter(['present' => 1]));
}
/**
* @throws JSONPathException
*/
public function testJSONPathFindOnScalarProducesEmptyCollection(): void
{
$result = new JSONPath(123)->find('$.missing');
self::assertSame([], $result->getData());
}
}
================================================
FILE: tests/IndexesFilterTest.php
================================================
<?php
/**
* JSONPath implementation for PHP.
*
* @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License
*/
declare(strict_types=1);
namespace Flow\JSONPath\Test;
use Flow\JSONPath\Filters\IndexesFilter;
use Flow\JSONPath\JSONPathException;
use Flow\JSONPath\JSONPathToken;
use Flow\JSONPath\TokenType;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(IndexesFilter::class)]
class IndexesFilterTest extends TestCase
{
/**
* @throws JSONPathException
*/
public function testReturnsSliceAndExplicitIndexes(): void
{
$token = new JSONPathToken(TokenType::Indexes, [
['type' => 'slice', 'value' => ['start' => 1, 'end' => 3, 'step' => null]],
0,
]);
$filter = new IndexesFilter($token);
self::assertSame([2, 3, 1], $filter->filter([1, 2, 3, 4]));
}
/**
* @throws JSONPathException
*/
public function testSupportsQueryAndWildcard(): void
{
$token = new JSONPathToken(TokenType::Indexes, [
['type' => 'query', 'value' => '@.v>1'],
'*',
]);
$filter = new IndexesFilter($token);
$filter->setRootData([]);
$data = [
['v' => 1],
['v' => 2],
];
$result = $filter->filter($data);
self::assertSame([['v' => 2], ['v' => 1], ['v' => 2]], $result);
}
}
================================================
FILE: tests/JSONPathCoreTest.php
================================================
<?php
/**
* JSONPath implementation for PHP.
*
* @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License
*/
declare(strict_types=1);
namespace Flow\JSONPath\Test;
use Flow\JSONPath\JSONPath;
use Flow\JSONPath\JSONPathException;
use Flow\JSONPath\TokenType;
use PHPUnit\Framework\TestCase;
class JSONPathCoreTest extends TestCase
{
/**
* @throws JSONPathException
*/
public function testFindAndHelpers(): void
{
$data = [
'list' => [
['v' => 1],
['v' => 2],
['v' => 3],
],
'nested' => ['inner' => ['x' => 9]],
];
$path = new JSONPath($data);
$slice = $path->find('$.list[1:3]');
self::assertSame([['v' => 2], ['v' => 3]], $slice->getData());
$first = $path->first();
$last = $path->last();
self::assertSame($data['list'], $first instanceof JSONPath ? $first->getData() : $first);
self::assertSame(['inner' => ['x' => 9]], $last instanceof JSONPath ? $last->getData() : $last);
self::assertSame('list', $path->firstKey());
self::assertSame('nested', $path->lastKey());
}
public function testOffsetAccessAndIteration(): void
{
$path = new JSONPath(['child' => ['a' => 1]]);
self::assertTrue($path->offsetExists('child'));
/** @var JSONPath $child */
$child = $path['child'];
self::assertInstanceOf(JSONPath::class, $child);
self::assertSame(['a' => 1], $child->getData());
$path[] = 'appended';
$path['new'] = 'value';
unset($path['child']);
$collected = [];
foreach ($path as $key => $value) {
$collected[$key] = $value instanceof JSONPath ? $value->getData() : $value;
}
self::assertArrayHasKey(0, $collected);
self::assertSame('appended', $collected[0]);
self::assertSame('value', $collected['new']);
self::assertEquals(new JSONPathException('oops'), new JSONPathException('oops'));
self::assertSame('index', TokenType::Index->value);
}
/**
* @throws JSONPathException
*/
public function testParseTokensCachesResults(): void
{
$path = new JSONPath(['a' => ['b' => 1]]);
$first = $path->parseTokens('$.a.b');
$second = $path->parseTokens('$.a.b');
self::assertNotEmpty($first);
self::assertSame($first, $second);
}
public function testJsonSerializeAndMagicGet(): void
{
$path = new JSONPath(['a' => 1]);
self::assertSame(['a' => 1], $path->jsonSerialize());
self::assertSame(1, $path->__get('a'));
self::assertNull($path->__get('missing'));
$empty = new JSONPath([]);
self::assertNull($empty->first());
self::assertNull($empty->last());
self::assertNull($empty->firstKey());
self::assertNull($empty->lastKey());
self::assertSame(0, $empty->count());
}
/**
* @throws JSONPathException
*/
public function testFindOnScalarReturnsEmptyResult(): void
{
$result = new JSONPath(123)->find('$.missing')->getData();
self::assertSame([], $result);
}
}
================================================
FILE: tests/JSONPathIntegrationTest.php
================================================
<?php
/**
* JSONPath implementation for PHP.
*
* @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License
*/
declare(strict_types=1);
namespace Flow\JSONPath\Test;
use ArrayObject;
use Flow\JSONPath\JSONPath;
use Flow\JSONPath\JSONPathException;
use PHPUnit\Framework\TestCase;
class JSONPathIntegrationTest extends TestCase
{
/**
* @throws JSONPathException
*/
public function testArrayObjectTraversal(): void
{
$data = new ArrayObject([
'items' => new ArrayObject([
['name' => 'keep', 'active' => true],
['name' => 'skip', 'active' => false],
]),
]);
$result = new JSONPath($data)->find('$.items[?(@.active==true)]')->getData();
self::assertSame([['name' => 'keep', 'active' => true]], $result);
}
/**
* @throws JSONPathException
*/
public function testDashedIndexIsParsedWithoutQuotes(): void
{
$data = ['data' => ['dash-key' => 42, 'other' => 1]];
$result = new JSONPath($data)->find('$.data[dash-key]')->getData();
self::assertSame([42], $result);
}
/**
* @throws JSONPathException
*/
public function testSlicesResolveViaPublicApi(): void
{
$path = new JSONPath(['values' => [0, 1, 2, 3, 4]]);
self::assertSame([1, 2, 3], $path->find('$.values[1:-1]')->getData());
self::assertSame([4, 3], $path->find('$.values[-1:-3:-1]')->getData());
}
}
================================================
FILE: tests/JSONPathLexerTest.php
================================================
<?php
/**
* JSONPath implementation for PHP.
*
* @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License
*/
declare(strict_types=1);
namespace Flow\JSONPath\Test;
use Flow\JSONPath\JSONPathException;
use Flow\JSONPath\JSONPathLexer;
use Flow\JSONPath\TokenType;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
#[CoversClass(JSONPathLexer::class)]
class JSONPathLexerTest extends TestCase
{
/**
* @param list<array{type: TokenType, value: mixed, shorthand?: bool}> $expectedTokens
* @throws JSONPathException
*/
#[DataProvider('expressionProvider')]
public function testParsesExpressions(string $expression, array $expectedTokens): void
{
$tokens = new JSONPathLexer($expression)->parseExpression();
self::assertCount(\count($expectedTokens), $tokens);
foreach ($expectedTokens as $i => $expected) {
self::assertEquals($expected['type'], $tokens[$i]->type);
self::assertEquals($expected['value'], $tokens[$i]->value);
if (\array_key_exists('shorthand', $expected)) {
self::assertSame($expected['shorthand'], $tokens[$i]->shorthand);
}
}
}
/**
* @return iterable<string, array{string, list<array{type: TokenType, value: mixed, shorthand?: bool}>}>
*/
public static function expressionProvider(): iterable
{
yield 'wildcard index' => [
'.*',
[
['type' => TokenType::Index, 'value' => '*'],
],
];
yield 'simple index' => [
'.foo',
[
['type' => TokenType::Index, 'value' => 'foo'],
],
];
yield 'bare index normalizes dot prefix' => [
'foo',
[
['type' => TokenType::Index, 'value' => 'foo'],
],
];
yield 'complex quoted index' => [
'["\'b.^*_"]',
[
['type' => TokenType::Index, 'value' => "'b.^*_"],
],
];
yield 'integer index' => [
'[0]',
[
['type' => TokenType::Index, 'value' => 0],
],
];
yield 'index after dot notation' => [
'.books[0]',
[
['type' => TokenType::Index, 'value' => 'books'],
['type' => TokenType::Index, 'value' => 0],
],
];
yield 'quoted index with whitespace' => [
'[ "foo$-/\'" ]',
[
['type' => TokenType::Index, 'value' => "foo$-/'"],
],
];
yield 'slice with explicit bounds' => [
'[0:1:2]',
[
['type' => TokenType::Slice, 'value' => ['start' => 0, 'end' => 1, 'step' => 2]],
],
];
yield 'negative index' => [
'[-1]',
[
['type' => TokenType::Index, 'value' => -1],
],
];
yield 'slice all nulls' => [
'[:]',
[
['type' => TokenType::Slice, 'value' => ['start' => null, 'end' => null, 'step' => null]],
],
];
yield 'shorthand query current' => [
'[?@]',
[
['type' => TokenType::QueryMatch, 'value' => '@', 'shorthand' => true],
],
];
yield 'shorthand query comparison' => [
'[?@==null]',
[
['type' => TokenType::QueryMatch, 'value' => '@==null', 'shorthand' => true],
],
];
yield 'shorthand query empty expression' => [
'[?]',
[
['type' => TokenType::QueryMatch, 'value' => '@', 'shorthand' => true],
],
];
yield 'double quoted index with escape' => [
'$["a\\"b"]',
[
['type' => TokenType::Index, 'value' => 'a"b'],
],
];
yield 'union with slice and negative index' => [
'[-2,1:3]',
[
[
'type' => TokenType::Indexes,
'value' => [
-2,
[
'type' => 'slice',
'value' => ['start' => 1, 'end' => 3, 'step' => null],
],
],
],
],
];
yield 'union with query' => [
'[1,?(@.foo>1)]',
[
[
'type' => TokenType::Indexes,
'value' => [
1,
[
'type' => 'query',
'value' => '@.foo>1',
],
],
],
],
];
yield 'single quoted index with escapes' => [
"$['back\\\\slash\\'quote']",
[
['type' => TokenType::Index, 'value' => "back\\slash'quote"],
],
];
yield 'multiple quoted indexes collapse to array' => [
'["first","second"]',
[
['type' => TokenType::Index, 'value' => ['first', 'second'], 'quoted' => true],
],
];
yield 'empty quoted index resolves to empty string' => [
'[""]',
[
['type' => TokenType::Index, 'value' => '', 'quoted' => true],
],
];
yield 'quoted index in dot notation preserves dots' => [
"$.'some.key'",
[
['type' => TokenType::Index, 'value' => 'some.key', 'quoted' => true],
],
];
yield 'empty bracket notation yields empty index list' => [
'$[]',
[
['type' => TokenType::Indexes, 'value' => []],
],
];
yield 'empty filter expression tokenizes to empty query match' => [
'$[?()]',
[
['type' => TokenType::QueryMatch, 'value' => '', 'shorthand' => false],
],
];
yield 'quoted index preserves brackets and dollar signs' => [
'$[\'[$the.size$]\']',
[
['type' => TokenType::Index, 'value' => '[$the.size$]', 'quoted' => true],
],
];
yield 'query result expression' => [
'[(@.foo + 2)]',
[
['type' => TokenType::QueryResult, 'value' => '@.foo + 2'],
],
];
yield 'query match' => [
"[?(@['@language']='en')]",
[
['type' => TokenType::QueryMatch, 'value' => "@['@language']='en'"],
],
];
yield 'recursive simple' => [
'..foo',
[
['type' => TokenType::Recursive, 'value' => null],
['type' => TokenType::Index, 'value' => 'foo'],
],
];
yield 'recursive wildcard' => [
'..*',
[
['type' => TokenType::Recursive, 'value' => null],
['type' => TokenType::Index, 'value' => '*'],
],
];
yield 'indexes with whitespace' => [
'[ 1,2 , 3]',
[
['type' => TokenType::Indexes, 'value' => [1, 2, 3]],
],
];
}
/**
* @throws JSONPathException
*/
public function testIndexBadlyFormed(): void
{
$this->expectException(JSONPathException::class);
$this->expectExceptionMessage('Unable to parse token hello* in expression: .hello*');
new JSONPathLexer('.hello*')->parseExpression();
}
/**
* @throws JSONPathException
*/
public function testRecursiveBadlyFormed(): void
{
$this->expectException(JSONPathException::class);
$this->expectExceptionMessage('Unable to parse token ba^r in expression: ..ba^r');
new JSONPathLexer('..ba^r')->parseExpression();
}
/**
* @throws JSONPathException
*/
public function testEmptyExpressionsReturnNoTokens(): void
{
self::assertSame([], new JSONPathLexer('')->parseExpression());
self::assertSame([], new JSONPathLexer('$')->parseExpression());
}
/**
* @throws JSONPathException
*/
public function testSingleCharacterExpressionNormalized(): void
{
self::assertSame([], new JSONPathLexer('.')->parseExpression());
}
/**
* @throws JSONPathException
*/
public function testUnclosedBracketThrowsAfterFinalFlush(): void
{
$this->expectException(JSONPathException::class);
new JSONPathLexer("['unterminated")->parseExpression();
}
}
================================================
FILE: tests/JSONPathTokenTest.php
================================================
<?php
/**
* JSONPath implementation for PHP.
*
* @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License
*/
declare(strict_types=1);
namespace Flow\JSONPath\Test;
use Flow\JSONPath\Filters\IndexesFilter;
use Flow\JSONPath\Filters\IndexFilter;
use Flow\JSONPath\Filters\QueryMatchFilter;
use Flow\JSONPath\Filters\QueryResultFilter;
use Flow\JSONPath\Filters\RecursiveFilter;
use Flow\JSONPath\Filters\SliceFilter;
use Flow\JSONPath\JSONPathToken;
use Flow\JSONPath\TokenType;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(JSONPathToken::class)]
class JSONPathTokenTest extends TestCase
{
public function testBuildFilterReturnsExpectedTypes(): void
{
self::assertInstanceOf(
IndexFilter::class,
new JSONPathToken(TokenType::Index, null)->buildFilter(0)
);
self::assertInstanceOf(
IndexesFilter::class,
new JSONPathToken(TokenType::Indexes, [])->buildFilter(0)
);
self::assertInstanceOf(
QueryMatchFilter::class,
new JSONPathToken(TokenType::QueryMatch, '')->buildFilter(0)
);
self::assertInstanceOf(
QueryResultFilter::class,
new JSONPathToken(TokenType::QueryResult, '')->buildFilter(0)
);
self::assertInstanceOf(
RecursiveFilter::class,
new JSONPathToken(TokenType::Recursive, null)->buildFilter(0)
);
self::assertInstanceOf(
SliceFilter::class,
new JSONPathToken(TokenType::Slice, ['start' => 0, 'end' => 0, 'step' => 1])->buildFilter(0)
);
}
public function testConstructorSetsProperties(): void
{
$token = new JSONPathToken(TokenType::Index, 'value');
self::assertSame(TokenType::Index, $token->type);
self::assertSame('value', $token->value);
}
}
================================================
FILE: tests/QueryMatchFilterTest.php
================================================
<?php
/**
* JSONPath implementation for PHP.
*
* @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License
*/
declare(strict_types=1);
namespace Flow\JSONPath\Test;
use Flow\JSONPath\Filters\QueryMatchFilter;
use Flow\JSONPath\JSONPath;
use Flow\JSONPath\JSONPathException;
use Flow\JSONPath\JSONPathToken;
use Flow\JSONPath\TokenType;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use RuntimeException;
#[CoversClass(QueryMatchFilter::class)]
class QueryMatchFilterTest extends TestCase
{
/**
* @return iterable<string, array{data: mixed, expression: string, expected: array<int, mixed>}>
*/
public static function filterProvider(): iterable
{
yield 'shorthand truthy filters values' => [
'data' => [0, 1, '', 'value', false],
'expression' => '$[?@]',
'expected' => [1 => 1, 3 => 'value'],
];
yield 'negation wrapped' => [
'data' => [['flag' => true], ['flag' => false]],
'expression' => '$[?(!(@.flag==true))]',
'expected' => [['flag' => false]],
];
yield 'negation unwrapped' => [
'data' => [['flag' => true], ['flag' => false]],
'expression' => '$[?(!@.flag==true)]',
'expected' => [['flag' => false]],
];
yield 'grouped logical expressions' => [
'data' => [
['active' => true, 'score' => 1],
['active' => true, 'score' => 2],
['active' => false, 'score' => 3],
],
'expression' => '$[?(@.active==true && (@.score>1))]',
'expected' => [['active' => true, 'score' => 2]],
];
yield 'path comparison current and root' => [
'data' => [
'threshold' => 5,
'items' => [
['v' => 5, 'w' => 5],
['v' => 4, 'w' => 5],
],
],
'expression' => '$.items[?(@.v==@.w && @.v==$.threshold)]',
'expected' => [['v' => 5, 'w' => 5]],
];
yield 'missing key compared to path still evaluates' => [
'data' => [['foo' => 1], ['foo' => 1, 'bar' => 1]],
'expression' => '$[?(@.bar==@.foo)]',
'expected' => [['foo' => 1, 'bar' => 1]],
];
yield 'dot separated key resolves through jsonpath' => [
'data' => [
['nested' => ['value' => 3]],
['nested' => ['value' => 4]],
],
'expression' => '$[?(@.nested.value==3)]',
'expected' => [['nested' => ['value' => 3]]],
];
yield 'deep equal lists and objects' => [
'data' => [
['left' => [1, 2], 'right' => [1, 2]],
['left' => [1, 2], 'right' => [2, 1]],
['left' => (object)['a' => 1, 'b' => 2], 'right' => (object)['b' => 2, 'a' => 1]],
['left' => (object)['a' => 1], 'right' => (object)['a' => 2]],
],
'expression' => '$[?(@.left==@.right)]',
'expected' => [
['left' => [1, 2], 'right' => [1, 2]],
['left' => (object)['a' => 1, 'b' => 2], 'right' => (object)['b' => 2, 'a' => 1]],
],
];
yield 'plain node selection compares current node' => [
'data' => [0, 1, 2],
'expression' => '$[?(@==@)]',
'expected' => [0, 1, 2],
];
yield 'deep equal failure branches' => [
'data' => [
['left' => [1, 2], 'right' => ['a' => 1, 'b' => 2]],
['left' => [1], 'right' => [1, 2]],
],
'expression' => '$[?(@.left==@.right)]',
'expected' => [],
];
yield 'regex comparison' => [
'data' => ['foo', 'bar'],
'expression' => '$[?(@ =~ /fo.*/)]',
'expected' => ['foo'],
];
yield 'in operator' => [
'data' => [1, 2, 3],
'expression' => '$[?(@ in [1,3])]',
'expected' => [1, 3],
];
yield 'nin operator with short circuit or' => [
'data' => [
['a' => 1],
['b' => 1],
['a' => 3],
],
'expression' => '$[?(@.a nin [2,3] || @.b==1)]',
'expected' => [
['a' => 1],
['b' => 1],
],
];
yield 'existence check without operator' => [
'data' => [
['value' => 1],
['other' => 2],
],
'expression' => '$[?(@.value)]',
'expected' => [
['value' => 1],
],
];
yield '!in operator' => [
'data' => [1, 2, 3],
'expression' => '$[?(@ !in [2])]',
'expected' => [1, 3],
];
yield 'less than comparison' => [
'data' => [['n' => 1], ['n' => 3]],
'expression' => '$[?(@.n<2)]',
'expected' => [['n' => 1]],
];
yield 'less or equal comparison' => [
'data' => [['n' => 1], ['n' => 2], ['n' => 3]],
'expression' => '$[?(@.n<=2)]',
'expected' => [['n' => 1], ['n' => 2]],
];
yield 'greater or equal comparison' => [
'data' => [['n' => 1], ['n' => 2], ['n' => 3]],
'expression' => '$[?(@.n>=2)]',
'expected' => [['n' => 2], ['n' => 3]],
];
yield 'not equals comparison' => [
'data' => [['value' => 1], ['value' => 2]],
'expression' => '$[?(@.value!=2)]',
'expected' => [['value' => 1]],
];
}
/**
* @param array<int, mixed> $expected
* @throws JSONPathException
*/
#[DataProvider('filterProvider')]
public function testFilterScenarios(mixed $data, string $expression, array $expected): void
{
$result = new JSONPath($data)->find($expression)->getData();
self::assertEquals(\array_values($expected), \array_values($result));
}
/**
* @return iterable<string, array{expression: string, expectMatch: bool}>
*/
public static function constantExpressionProvider(): iterable
{
yield 'num comparison true' => ['expression' => '[?(1<2)]', 'expectMatch' => true];
yield 'num comparison false' => ['expression' => '[?(2>3)]', 'expectMatch' => false];
yield 'num with leading zeros decoded as number' => ['expression' => '[?(0123==123)]', 'expectMatch' => true];
yield 'string literal decoding' => ['expression' => '[?(foo==foo)]', 'expectMatch' => true];
yield 'invalid less than comparison for non-scalars' => ['expression' => '[?([]<1)]', 'expectMatch' => false];
yield 'not equals' => ['expression' => '[?(2!=3)]', 'expectMatch' => true];
yield 'less or equal' => ['expression' => '[?(2<=2)]', 'expectMatch' => true];
yield 'greater or equal' => ['expression' => '[?(1>=2)]', 'expectMatch' => false];
yield 'json literal deep equal' => ['expression' => '[?({"a":1}=={"a":1})]', 'expectMatch' => true];
}
/**
* @throws JSONPathException
*/
#[DataProvider('constantExpressionProvider')]
public function testConstantExpressions(string $expression, bool $expectMatch): void
{
$data = ['keep'];
$result = new JSONPath($data)->find('$' . $expression)->getData();
self::assertSame($expectMatch ? ['keep'] : [], $result);
}
/**
* @throws JSONPathException
*/
public function testShorthandTokenValueArrayFiltersTruthyNodes(): void
{
$token = new JSONPathToken(TokenType::QueryMatch, ['expression' => '@', 'shorthand' => true]);
$filter = new QueryMatchFilter($token);
$collection = [0, 1, '', 'value', false];
self::assertSame([1 => 1, 3 => 'value'], $filter->filter($collection));
}
/**
* @throws JSONPathException
*/
public function testMalformedFilterThrowsRuntimeException(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Malformed filter query');
new JSONPath([1])->find('$[?(foo)]');
}
/**
* @throws JSONPathException
*/
public function testLiteralOnlyFilterExpressionsReturnWholeCollectionOrEmpty(): void
{
$data = [1, 2, 3];
self::assertSame($data, new JSONPath($data)->find('$[?(true)]')->getData());
self::assertSame([], new JSONPath($data)->find('$[?(false)]')->getData());
}
/**
* @throws JSONPathException
*/
public function testLogicalExpressionsWithLiteralRightOperand(): void
{
$data = [
['key' => 1],
['key' => -1],
];
self::assertSame(
[],
new JSONPath($data)->find('$[?(@.key>0 && false)]')->getData()
);
self::assertSame(
$data,
new JSONPath($data)->find('$[?(@.key>0 || true)]')->getData()
);
}
/**
* @throws JSONPathException
*/
public function testEmptyFilterExpressionReturnsEmpty(): void
{
self::assertSame([], new JSONPath([1, 2])->find('$[?()]')->getData());
}
/**
* @throws JSONPathException
*/
public function testNormalizeKeyCastsNumericStrings(): void
{
$token = new JSONPathToken(TokenType::QueryMatch, '@["2"]=="two"');
$filter = new QueryMatchFilter($token);
$result = $filter->filter([
['2' => 'two', '1' => 'one'],
['2' => 'nope', '1' => 'one'],
]);
self::assertSame([['2' => 'two', '1' => 'one']], \array_values($result));
}
}
================================================
FILE: tests/QueryResultFilterTest.php
================================================
<?php
/**
* JSONPath implementation for PHP.
*
* @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License
*/
declare(strict_types=1);
namespace Flow\JSONPath\Test;
use Flow\JSONPath\Filters\QueryResultFilter;
use Flow\JSONPath\JSONPathException;
use Flow\JSONPath\JSONPathToken;
use Flow\JSONPath\TokenType;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
#[CoversClass(QueryResultFilter::class)]
class QueryResultFilterTest extends TestCase
{
/**
* @throws JSONPathException
*/
public function testFilterResolvesComputedIndex(): void
{
$token = new JSONPathToken(TokenType::QueryResult, '@.foo + 2');
$filter = new QueryResultFilter($token);
$collection = [
'foo' => 3,
5 => 'bar',
];
self::assertSame(['bar'], $filter->filter($collection));
}
/**
* @throws JSONPathException
*/
public function testFilterReturnsEmptyWhenLengthExceeded(): void
{
$token = new JSONPathToken(TokenType::QueryResult, '@.length + 1');
$filter = new QueryResultFilter($token);
$collection = ['a', 'b'];
self::assertSame([], $filter->filter($collection));
}
/**
* @throws JSONPathException
*/
#[DataProvider('arithmeticProvider')]
public function testFilterSupportsAllArithmeticOperators(string $expression, int|float $expectedIndex): void
{
$token = new JSONPathToken(TokenType::QueryResult, $expression);
$filter = new QueryResultFilter($token);
$collection = [
'value' => 4,
$expectedIndex => 'found',
];
self::assertSame(['found'], $filter->filter($collection));
}
/**
* @return list<array{string, int|float}>
*/
public static function arithmeticProvider(): array
{
return [
['@.value - 1', 3],
['@.value * 2', 8],
['@.value / 2', 2],
];
}
/**
* @throws JSONPathException
*/
public function testFilterFallsBackToLengthWhenKeyMissing(): void
{
$token = new JSONPathToken(TokenType::QueryResult, '@.length - 1');
$filter = new QueryResultFilter($token);
$collection = ['zero', 'one'];
self::assertSame(['one'], $filter->filter($collection));
}
/**
* @throws JSONPathException
*/
public function testFilterReturnsEmptyWhenKeyNotFound(): void
{
$token = new JSONPathToken(TokenType::QueryResult, '@.missing + 1');
$filter = new QueryResultFilter($token);
self::assertSame([], $filter->filter(['foo' => 'bar']));
}
/**
* @throws JSONPathException
*/
public function testFilterReturnsEmptyWhenComputedIndexMissing(): void
{
$token = new JSONPathToken(TokenType::QueryResult, '@.foo + 10');
$filter = new QueryResultFilter($token);
self::assertSame([], $filter->filter(['foo' => 1]));
}
/**
* @throws JSONPathException
*/
public function testFilterReturnsEmptyWhenResultKeyMissing(): void
{
$token = new JSONPathToken(TokenType::QueryResult, '@.foo + 100');
$filter = new QueryResultFilter($token);
self::assertSame([], $filter->filter(['foo' => 1]));
}
public function testUnsupportedOperatorThrows(): void
{
$this->expectException(JSONPathException::class);
$token = new JSONPathToken(TokenType::QueryResult, '@.foo ^ 2');
new QueryResultFilter($token)->filter(['foo' => 1]);
}
}
================================================
FILE: tests/RecursiveFilterTest.php
================================================
<?php
/**
* JSONPath implementation for PHP.
*
* @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License
*/
declare(strict_types=1);
namespace Flow\JSONPath\Test;
use Flow\JSONPath\Filters\RecursiveFilter;
use Flow\JSONPath\JSONPathException;
use Flow\JSONPath\JSONPathToken;
use Flow\JSONPath\TokenType;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(RecursiveFilter::class)]
class RecursiveFilterTest extends TestCase
{
/**
* @throws JSONPathException
*/
public function testRecursesThroughNestedArraysAndObjects(): void
{
$token = new JSONPathToken(TokenType::Recursive, null);
$filter = new RecursiveFilter($token);
$nestedObject = (object)['inner' => ['value' => 3]];
$data = ['obj' => $nestedObject, 'scalar' => 1];
$result = $filter->filter($data);
self::assertSame($nestedObject, $result[0]['obj']);
self::assertSame(
[
['inner' => ['value' => 3]],
['value' => 3],
],
\array_slice($result, 1)
);
}
}
================================================
FILE: tests/SliceFilterTest.php
================================================
<?php
/**
* JSONPath implementation for PHP.
*
* @license https://github.com/SoftCreatR/JSONPath/blob/main/LICENSE MIT License
*/
declare(strict_types=1);
namespace Flow\JSONPath\Test;
use ArrayObject;
use Flow\JSONPath\Filters\SliceFilter;
use Flow\JSONPath\JSONPathToken;
use Flow\JSONPath\TokenType;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
#[CoversClass(SliceFilter::class)]
class SliceFilterTest extends TestCase
{
/**
* @param array<string, int|null> $slice
* @param array<array-key, mixed>|object $input
* @param array<int, mixed> $expected
*/
#[DataProvider('sliceProvider')]
public function testFilterHandlesNegativeAndNullBounds(array $slice, array|object $input, array $expected): void
{
$token = new JSONPathToken(TokenType::Slice, $slice);
$filter = new SliceFilter($token);
self::assertSame($expected, $filter->filter($input));
}
/**
* @return iterable<string, array{array<string, int|null>, array<array-key, mixed>|object, array<int, mixed>}>
*/
public static function edgeCaseProvider(): iterable
{
yield 'step zero returns empty' => [
['start' => 0, 'end' => null, 'step' => 0],
['a', 'b'],
[],
];
yield 'start beyond length yields empty' => [
['start' => 5, 'end' => null, 'step' => 1],
['a', 'b'],
[],
];
yield 'end beyond length clamps to length' => [
['start' => 0, 'end' => 10, 'step' => 1],
['a', 'b'],
['a', 'b'],
];
yield 'negative step with null bounds reverses' => [
['start' => null, 'end' => null, 'step' => -1],
['a', 'b', 'c'],
['c', 'b', 'a'],
];
yield 'positive step with end below zero yields empty' => [
['start' => 0, 'end' => -10, 'step' => 1],
['a', 'b', 'c'],
[],
];
yield 'negative step with start far below length clamps to -1' => [
['start' => -5, 'end' => null, 'step' => -1],
['a', 'b', 'c'],
[],
];
yield 'negative step with start beyond length clamps to last index' => [
['start' => 10, 'end' => null, 'step' => -1],
['a', 'b', 'c'],
['c', 'b', 'a'],
];
yield 'negative step with end beyond length clamps end' => [
['start' => 1, 'end' => 10, 'step' => -1],
['a', 'b', 'c'],
[],
];
yield 'negative step with very negative start clamps to -1 and high end clamps to length' => [
['start' => -5, 'end' => 10, 'step' => -1],
['a', 'b', 'c'],
[],
];
yield 'negative step with end far below zero still collects prefix' => [
['start' => 1, 'end' => -10, 'step' => -1],
['a', 'b', 'c'],
['b', 'a'],
];
yield 'non countable object yields empty' => [
['start' => 0, 'end' => null, 'step' => 1],
(object)['a' => 1],
[],
];
}
/**
* @param array<string, int|null> $slice
* @param array<array-key, mixed> $input
* @param array<int, mixed> $expected
*/
#[DataProvider('edgeCaseProvider')]
public function testEdgeCases(array $slice, array|object $input, array $expected): void
{
$token = new JSONPathToken(TokenType::Slice, $slice);
$filter = new SliceFilter($token);
self::assertSame($expected, $filter->filter($input));
}
/**
* @return array<string, array{array<string, int|null>, array<array-key, mixed>|object, array<int, mixed>}>
*/
public static function sliceProvider(): array
{
return [
'negative start clamps at zero' => [
['start' => -10, 'end' => 2, 'step' => 1],
['a', 'b', 'c'],
['a', 'b'],
],
'negative end wraps from length' => [
['start' => 0, 'end' => -1, 'step' => 1],
['a', 'b', 'c'],
['a', 'b'],
],
'nulls default to full length' => [
['start' => null, 'end' => null, 'step' => 2],
['a', 'b', 'c', 'd'],
['a', 'c'],
],
'negative step slices in reverse order' => [
['start' => 2, 'end' => 0, 'step' => -1],
['a', 'b', 'c'],
['c', 'b'],
],
'works with array object' => [
['start' => 0, 'end' => 2, 'step' => 1],
new ArrayObject(['a', 'b', 'c']),
['a', 'b'],
],
];
}
}
gitextract_xceowo_u/
├── .gitattributes
├── .github/
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug.md
│ │ ├── documentation.md
│ │ └── feature.md
│ ├── PULL_REQUEST_TEMPLATE.md
│ ├── diff.json
│ ├── php-syntax.json
│ └── workflows/
│ ├── Test.yml
│ ├── UpdateContributors.yml
│ └── codestyle.yml
├── .gitignore
├── .php-cs-fixer.dist.php
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── LICENSE.md
├── README.md
├── composer.json
├── phpcs.xml
├── phpstan.neon.dist
├── phpunit.xml.dist
├── src/
│ ├── AccessHelper.php
│ ├── Filters/
│ │ ├── AbstractFilter.php
│ │ ├── IndexFilter.php
│ │ ├── IndexesFilter.php
│ │ ├── QueryMatchFilter.php
│ │ ├── QueryResultFilter.php
│ │ ├── RecursiveFilter.php
│ │ └── SliceFilter.php
│ ├── JSONPath.php
│ ├── JSONPathException.php
│ ├── JSONPathLexer.php
│ ├── JSONPathToken.php
│ └── TokenType.php
└── tests/
├── AccessHelperTest.php
├── IndexFilterTest.php
├── IndexesFilterTest.php
├── JSONPathCoreTest.php
├── JSONPathIntegrationTest.php
├── JSONPathLexerTest.php
├── JSONPathTokenTest.php
├── QueryMatchFilterTest.php
├── QueryResultFilterTest.php
├── RecursiveFilterTest.php
└── SliceFilterTest.php
SYMBOL INDEX (144 symbols across 23 files)
FILE: src/AccessHelper.php
class AccessHelper (line 16) | class AccessHelper
method collectionKeys (line 21) | public static function collectionKeys(mixed $collection): array
method isCollectionType (line 30) | public static function isCollectionType(mixed $collection): bool
method keyExists (line 35) | public static function keyExists(mixed $collection, int|string|null $k...
method getValue (line 66) | public static function getValue(mixed $collection, int|string|null $ke...
method getValueByIndex (line 98) | private static function getValueByIndex(mixed $collection, int $key): ...
method setValue (line 126) | public static function setValue(mixed &$collection, int|string|null $k...
method unsetValue (line 145) | public static function unsetValue(mixed &$collection, int|string|null ...
method arrayValues (line 167) | public static function arrayValues(mixed $collection): array
FILE: src/Filters/AbstractFilter.php
class AbstractFilter (line 16) | abstract class AbstractFilter
method __construct (line 22) | public function __construct(protected JSONPathToken $token, int $optio...
method setRootData (line 27) | public function setRootData(mixed $root): void
method filter (line 36) | abstract public function filter(array|object $collection): array;
FILE: src/Filters/IndexFilter.php
class IndexFilter (line 16) | class IndexFilter extends AbstractFilter
method filter (line 22) | public function filter(array|object $collection): array
FILE: src/Filters/IndexesFilter.php
class IndexesFilter (line 19) | class IndexesFilter extends AbstractFilter
method filter (line 26) | public function filter(array|object $collection): array
FILE: src/Filters/QueryMatchFilter.php
class QueryMatchFilter (line 23) | class QueryMatchFilter extends AbstractFilter
method filter (line 41) | public function filter(array|object $collection): array
method isNumber (line 258) | protected function isNumber(mixed $value): bool
method resolveComparisonValue (line 266) | private function resolveComparisonValue(mixed $comparisonValue, mixed ...
method normalizeKey (line 294) | private function normalizeKey(mixed $key): int|string|null
method isPathComparison (line 303) | private function isPathComparison(mixed $comparisonValue): bool
method evaluateConstantExpression (line 308) | private function evaluateConstantExpression(string $expression): ?bool
method evaluateLiteralExpression (line 335) | private function evaluateLiteralExpression(string $expression, array|o...
method evaluateExpressionWithTrailingLiteral (line 358) | private function evaluateExpressionWithTrailingLiteral(
method decodeLiteral (line 384) | private function decodeLiteral(string $literal): mixed
method isTruthy (line 399) | private function isTruthy(mixed $value): bool
method compareEquals (line 404) | protected function compareEquals(mixed $a, mixed $b): bool
method deepEqual (line 432) | private function deepEqual(array $a, array $b): bool
method compareLessThan (line 454) | protected function compareLessThan(mixed $a, mixed $b): bool
FILE: src/Filters/QueryResultFilter.php
class QueryResultFilter (line 16) | class QueryResultFilter extends AbstractFilter
method filter (line 22) | public function filter(array|object $collection): array
FILE: src/Filters/RecursiveFilter.php
class RecursiveFilter (line 16) | class RecursiveFilter extends AbstractFilter
method filter (line 23) | public function filter(array|object $collection): array
method recurse (line 38) | private function recurse(array &$result, array|object $data): void
FILE: src/Filters/SliceFilter.php
class SliceFilter (line 15) | class SliceFilter extends AbstractFilter
method filter (line 20) | public function filter(array|object $collection): array
method normalizeForPositiveStep (line 66) | private function normalizeForPositiveStep(int $length, ?int $start, ?i...
method normalizeForNegativeStep (line 98) | private function normalizeForNegativeStep(int $length, ?int $start, ?i...
FILE: src/JSONPath.php
class JSONPath (line 23) | class JSONPath implements ArrayAccess, Iterator, JsonSerializable, Count...
method __construct (line 34) | final public function __construct(mixed $data = [], int $options = 0)
method find (line 47) | public function find(string $expression): self
method first (line 73) | public function first(): mixed
method last (line 89) | public function last(): mixed
method firstKey (line 105) | public function firstKey(): string|int|null
method lastKey (line 119) | public function lastKey(): string|int|null
method parseTokens (line 134) | public function parseTokens(string $expression): array
method getData (line 150) | public function getData(): mixed
method __get (line 158) | public function __get(string|int $key): mixed
method offsetExists (line 163) | #[Override]
method offsetGet (line 169) | #[Override]
method offsetSet (line 179) | #[Override]
method offsetUnset (line 189) | #[Override]
method jsonSerialize (line 195) | #[Override]
method current (line 201) | #[Override]
method next (line 209) | #[Override]
method key (line 215) | #[Override]
method valid (line 221) | #[Override]
method rewind (line 227) | #[Override]
method count (line 233) | #[Override]
FILE: src/JSONPathException.php
class JSONPathException (line 15) | class JSONPathException extends Exception
FILE: src/JSONPathLexer.php
class JSONPathLexer (line 13) | class JSONPathLexer
method __construct (line 45) | public function __construct(string $expression)
method parseExpressionTokens (line 80) | public function parseExpressionTokens(): array
method lookAhead (line 165) | protected function lookAhead(int $pos, int $forward = 1): ?string
method atEnd (line 170) | protected function atEnd(int $pos): bool
method parseExpression (line 179) | public function parseExpression(): array
method createToken (line 187) | protected function createToken(string $value): JSONPathToken
method parseSlice (line 324) | private function parseSlice(string $tokenValue): array
method isEscaped (line 335) | private function isEscaped(string $tokenValue): bool
method decodeQuotedIndex (line 356) | private function decodeQuotedIndex(string $tokenValue, string $quote):...
FILE: src/JSONPathToken.php
class JSONPathToken (line 21) | readonly class JSONPathToken
method __construct (line 23) | public function __construct(
method buildFilter (line 32) | public function buildFilter(int $options): AbstractFilter
FILE: tests/AccessHelperTest.php
class AccessHelperTest (line 22) | #[CoversClass(AccessHelper::class)]
method testKeyExistsRespectsMagicGet (line 25) | public function testKeyExistsRespectsMagicGet(): void
method testKeyExistsSupportsArrayAccessAndNegativeIndex (line 48) | public function testKeyExistsSupportsArrayAccessAndNegativeIndex(): void
method testGetValueCoversMagicArrayAndArrayAccess (line 79) | public function testGetValueCoversMagicArrayAndArrayAccess(): void
method testGetValueByIndexSupportsTraversableAndNegativeOffset (line 131) | public function testGetValueByIndexSupportsTraversableAndNegativeOffse...
method testGetValueNullCases (line 144) | public function testGetValueNullCases(): void
method testArrayValuesThrowsOnInvalidType (line 153) | public function testArrayValuesThrowsOnInvalidType(): void
method testArrayValuesCastsObject (line 162) | public function testArrayValuesCastsObject(): void
method testGetValueByIndexReturnsNullWhenOutOfRange (line 169) | public function testGetValueByIndexReturnsNullWhenOutOfRange(): void
method testKeyExistsAndCollectionHelpers (line 178) | public function testKeyExistsAndCollectionHelpers(): void
method testSetAndUnsetValueAcrossTypes (line 188) | public function testSetAndUnsetValueAcrossTypes(): void
FILE: tests/IndexFilterTest.php
class IndexFilterTest (line 22) | #[CoversClass(IndexFilter::class)]
method testArrayValueTokenReturnsOnlyExistingKeys (line 28) | public function testArrayValueTokenReturnsOnlyExistingKeys(): void
method testSingleIndexWorksForObjectsAndArrayAccess (line 42) | public function testSingleIndexWorksForObjectsAndArrayAccess(): void
method testWildcardReturnsValuesAndLengthReturnsCount (line 57) | public function testWildcardReturnsValuesAndLengthReturnsCount(): void
method testReturnsEmptyWhenKeyMissing (line 71) | public function testReturnsEmptyWhenKeyMissing(): void
method testJSONPathFindOnScalarProducesEmptyCollection (line 81) | public function testJSONPathFindOnScalarProducesEmptyCollection(): void
FILE: tests/IndexesFilterTest.php
class IndexesFilterTest (line 20) | #[CoversClass(IndexesFilter::class)]
method testReturnsSliceAndExplicitIndexes (line 26) | public function testReturnsSliceAndExplicitIndexes(): void
method testSupportsQueryAndWildcard (line 41) | public function testSupportsQueryAndWildcard(): void
FILE: tests/JSONPathCoreTest.php
class JSONPathCoreTest (line 18) | class JSONPathCoreTest extends TestCase
method testFindAndHelpers (line 23) | public function testFindAndHelpers(): void
method testOffsetAccessAndIteration (line 48) | public function testOffsetAccessAndIteration(): void
method testParseTokensCachesResults (line 80) | public function testParseTokensCachesResults(): void
method testJsonSerializeAndMagicGet (line 90) | public function testJsonSerializeAndMagicGet(): void
method testFindOnScalarReturnsEmptyResult (line 110) | public function testFindOnScalarReturnsEmptyResult(): void
FILE: tests/JSONPathIntegrationTest.php
class JSONPathIntegrationTest (line 18) | class JSONPathIntegrationTest extends TestCase
method testArrayObjectTraversal (line 23) | public function testArrayObjectTraversal(): void
method testDashedIndexIsParsedWithoutQuotes (line 40) | public function testDashedIndexIsParsedWithoutQuotes(): void
method testSlicesResolveViaPublicApi (line 52) | public function testSlicesResolveViaPublicApi(): void
FILE: tests/JSONPathLexerTest.php
class JSONPathLexerTest (line 20) | #[CoversClass(JSONPathLexer::class)]
method testParsesExpressions (line 27) | #[DataProvider('expressionProvider')]
method expressionProvider (line 47) | public static function expressionProvider(): iterable
method testIndexBadlyFormed (line 270) | public function testIndexBadlyFormed(): void
method testRecursiveBadlyFormed (line 281) | public function testRecursiveBadlyFormed(): void
method testEmptyExpressionsReturnNoTokens (line 292) | public function testEmptyExpressionsReturnNoTokens(): void
method testSingleCharacterExpressionNormalized (line 301) | public function testSingleCharacterExpressionNormalized(): void
method testUnclosedBracketThrowsAfterFinalFlush (line 309) | public function testUnclosedBracketThrowsAfterFinalFlush(): void
FILE: tests/JSONPathTokenTest.php
class JSONPathTokenTest (line 24) | #[CoversClass(JSONPathToken::class)]
method testBuildFilterReturnsExpectedTypes (line 27) | public function testBuildFilterReturnsExpectedTypes(): void
method testConstructorSetsProperties (line 60) | public function testConstructorSetsProperties(): void
FILE: tests/QueryMatchFilterTest.php
class QueryMatchFilterTest (line 23) | #[CoversClass(QueryMatchFilter::class)]
method filterProvider (line 29) | public static function filterProvider(): iterable
method testFilterScenarios (line 186) | #[DataProvider('filterProvider')]
method constantExpressionProvider (line 197) | public static function constantExpressionProvider(): iterable
method testConstantExpressions (line 213) | #[DataProvider('constantExpressionProvider')]
method testShorthandTokenValueArrayFiltersTruthyNodes (line 225) | public function testShorthandTokenValueArrayFiltersTruthyNodes(): void
method testMalformedFilterThrowsRuntimeException (line 238) | public function testMalformedFilterThrowsRuntimeException(): void
method testLiteralOnlyFilterExpressionsReturnWholeCollectionOrEmpty (line 249) | public function testLiteralOnlyFilterExpressionsReturnWholeCollectionO...
method testLogicalExpressionsWithLiteralRightOperand (line 260) | public function testLogicalExpressionsWithLiteralRightOperand(): void
method testEmptyFilterExpressionReturnsEmpty (line 280) | public function testEmptyFilterExpressionReturnsEmpty(): void
method testNormalizeKeyCastsNumericStrings (line 288) | public function testNormalizeKeyCastsNumericStrings(): void
FILE: tests/QueryResultFilterTest.php
class QueryResultFilterTest (line 21) | #[CoversClass(QueryResultFilter::class)]
method testFilterResolvesComputedIndex (line 27) | public function testFilterResolvesComputedIndex(): void
method testFilterReturnsEmptyWhenLengthExceeded (line 43) | public function testFilterReturnsEmptyWhenLengthExceeded(): void
method testFilterSupportsAllArithmeticOperators (line 56) | #[DataProvider('arithmeticProvider')]
method arithmeticProvider (line 73) | public static function arithmeticProvider(): array
method testFilterFallsBackToLengthWhenKeyMissing (line 85) | public function testFilterFallsBackToLengthWhenKeyMissing(): void
method testFilterReturnsEmptyWhenKeyNotFound (line 98) | public function testFilterReturnsEmptyWhenKeyNotFound(): void
method testFilterReturnsEmptyWhenComputedIndexMissing (line 109) | public function testFilterReturnsEmptyWhenComputedIndexMissing(): void
method testFilterReturnsEmptyWhenResultKeyMissing (line 120) | public function testFilterReturnsEmptyWhenResultKeyMissing(): void
method testUnsupportedOperatorThrows (line 128) | public function testUnsupportedOperatorThrows(): void
FILE: tests/RecursiveFilterTest.php
class RecursiveFilterTest (line 20) | #[CoversClass(RecursiveFilter::class)]
method testRecursesThroughNestedArraysAndObjects (line 26) | public function testRecursesThroughNestedArraysAndObjects(): void
FILE: tests/SliceFilterTest.php
class SliceFilterTest (line 21) | #[CoversClass(SliceFilter::class)]
method testFilterHandlesNegativeAndNullBounds (line 29) | #[DataProvider('sliceProvider')]
method edgeCaseProvider (line 41) | public static function edgeCaseProvider(): iterable
method testEdgeCases (line 113) | #[DataProvider('edgeCaseProvider')]
method sliceProvider (line 125) | public static function sliceProvider(): array
Condensed preview — 45 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (143K chars).
[
{
"path": ".gitattributes",
"chars": 15,
"preview": "*.php diff=php\n"
},
{
"path": ".github/FUNDING.yml",
"chars": 91,
"preview": "github: softcreatr\r\ncustom: ['https://ecologi.com/softcreatr?r=61212ab3fc69b8eb8a2014f4']\r\n"
},
{
"path": ".github/ISSUE_TEMPLATE/bug.md",
"chars": 1233,
"preview": "---\r\nname: 🐛 Bug Report\r\nabout: Submit a bug report, to help us improve.\r\nlabels: \"bug\"\r\n---\r\n\r\n## 🐛 Bug Report\r\n\r\n(A cl"
},
{
"path": ".github/ISSUE_TEMPLATE/documentation.md",
"chars": 341,
"preview": "---\r\nname: 📚 Documentation\r\nabout: Report an issue related to documentation.\r\nlabels: \"documentation\"\r\n---\r\n\r\n## 📚 Docum"
},
{
"path": ".github/ISSUE_TEMPLATE/feature.md",
"chars": 632,
"preview": "---\r\nname: 💡 Feature / Idea\r\nabout: Submit a proposal for a new feature.\r\nlabels: \"feature\"\r\n---\r\n\r\n## 💡 Feature / Idea\r"
},
{
"path": ".github/PULL_REQUEST_TEMPLATE.md",
"chars": 624,
"preview": "<!--\r\nThank you for sending the PR! We appreciate you spending the time to work on these changes.\r\n\r\nHelp us understand "
},
{
"path": ".github/diff.json",
"chars": 190,
"preview": "{\n \"problemMatcher\": [\n {\n \"owner\": \"diff\",\n \"pattern\": [\n {\n \"regexp\": \"--- a/(.*)\",\n "
},
{
"path": ".github/php-syntax.json",
"chars": 282,
"preview": "{\n \"problemMatcher\": [\n {\n \"owner\": \"php -l\",\n \"pattern\": [\n {\n \"regexp\": \"^\\\\s*(PHP\\\\s+)?"
},
{
"path": ".github/workflows/Test.yml",
"chars": 1964,
"preview": "name: Test\n\non:\n push:\n paths:\n - '**.php'\n - 'composer.json'\n branches:\n - 'main'\n pull_request:"
},
{
"path": ".github/workflows/UpdateContributors.yml",
"chars": 406,
"preview": "name: Update Contributors\n\non: [ push, workflow_dispatch]\n\njobs:\n Update:\n runs-on: ubuntu-latest\n\n steps:\n "
},
{
"path": ".github/workflows/codestyle.yml",
"chars": 547,
"preview": "name: Code Style\n\non:\n push:\n paths:\n - '**.php'\n pull_request:\n paths:\n - '**.php'\n\njobs:\n php:\n name"
},
{
"path": ".gitignore",
"chars": 97,
"preview": ".idea\r\n.phpunit.result.cache\r\nvendor\r\n.php_cs.cache\r\ncomposer.lock\r\ncomposer.phar\r\n.phpcs-cache\r\n"
},
{
"path": ".php-cs-fixer.dist.php",
"chars": 5232,
"preview": "<?php\n\nuse PhpCsFixer\\Config;\nuse PhpCsFixer\\Finder;\n\n$finder = Finder::create()\n ->in(__DIR__)\n ->notPath('vendor"
},
{
"path": "CHANGELOG.md",
"chars": 8228,
"preview": "# Changelog\r\n\r\n### 1.0.2\n- Fixed tokenizer handling for quoted bracket keys containing `$` so literals like `['[$the.siz"
},
{
"path": "CODE_OF_CONDUCT.md",
"chars": 3410,
"preview": "# Contributor Covenant Code of Conduct\r\n\r\n## Our Pledge\r\n\r\nIn the interest of fostering an open and welcoming environmen"
},
{
"path": "LICENSE.md",
"chars": 1179,
"preview": "MIT License\r\n\r\nOriginal work - Copyright (c) 2018 Flow Communications\r\nModified work - Copyright (c) 2020 Sascha Greuel "
},
{
"path": "README.md",
"chars": 10631,
"preview": "# JSONPath for PHP 8.5+\n\n[. The extraction includes 45 files (131.3 KB), approximately 33.9k tokens, and a symbol index with 144 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.