Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
da06e37
Simplify operation with a match statement
lucasnetau Mar 20, 2025
5bf7b3a
Fix catching of malformed JSON when JSON_THROW_ON_ERROR flag is set
lucasnetau Mar 20, 2025
f8d9b98
Enable strict comparison by parsing the comparable with JSON decode t…
lucasnetau Mar 20, 2025
422dca0
Simplify the null comparable check
lucasnetau Mar 20, 2025
9d9c302
Fix consensus result, needs to be an array
lucasnetau Mar 20, 2025
5443c2c
LHS value of {"some":"value"} is Nothing, cannot compare to numerical…
lucasnetau Mar 20, 2025
79ff5c6
Add in additional test cases from the RFC for null handling
lucasnetau Mar 20, 2025
955cb78
Keep track if we have a nothing result or a value including null
lucasnetau Mar 20, 2025
5e53f98
Update failed queries baseline
lucasnetau Mar 20, 2025
627dedb
Implement == and < comparisons and then the derivative !=, <=, >, >= …
lucasnetau Mar 20, 2025
d206f9a
Update failed baseline to include tests that throw malformed or path …
lucasnetau Mar 20, 2025
77e8c50
Support negation of entire filter with or without wrapping parentheses
lucasnetau Mar 20, 2025
618b081
Simple support for boolean AND operator
lucasnetau Mar 20, 2025
de9a971
Handle standalone @ for selecting current node as value
lucasnetau Mar 20, 2025
01b94a8
Handle filter expression on current object
lucasnetau Mar 20, 2025
ab48e26
Update QueryTest to detect baselineFailed tests that now pass. Modify…
lucasnetau Mar 20, 2025
e78d936
Cleanup commented out code
lucasnetau Mar 20, 2025
78581c2
Add support for logical OR expressions, add support for Grouping
lucasnetau Mar 21, 2025
e2ad48b
Applied small optimizations, and fixes
SoftCreatR Mar 21, 2025
3d4fe01
Fixed tests
SoftCreatR Mar 21, 2025
9aab673
Update CHANGELOG.md
lucasnetau Mar 22, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .github/workflows/Test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,18 @@ jobs:
run: composer cs

- name: Execute tests
run: composer test -- --coverage-clover=coverage.xml
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@v4
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# Changelog

### 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:

Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "softcreatr/jsonpath",
"description": "JSONPath implementation for parsing, searching and flattening arrays",
"license": "MIT",
"version": "0.9.1",
"version": "0.10.0",
"authors": [
{
"name": "Stephen Frank",
Expand Down
232 changes: 161 additions & 71 deletions src/Filters/QueryMatchFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,123 +6,213 @@
* @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 MATCH_QUERY_NEGATION_WRAPPED = '^(?<negate>!)\((?<logicalexpr>.+)\)$';

protected const MATCH_QUERY_NEGATION_UNWRAPPED = '^(?<negate>!)(?<logicalexpr>.+)$';

protected const MATCH_QUERY_OPERATORS = '
@(\.(?<key>[^\s<>!=]+)|\[["\']?(?<keySquare>.*?)["\']?\])
(\s*(?<operator>==|=~|=|<>|!==|!=|>=|<=|>|<|in|!in|nin)\s*(?<comparisonValue>.+))?
(@\.(?<key>[^\s<>!=]+)|@\[["\']?(?<keySquare>.*?)["\']?\]|(?<node>@)|(%group(?<group>\d+)%))
(\s*(?<operator>==|=~|=|<>|!==|!=|>=|<=|>|<|in|!in|nin)\s*(?<comparisonValue>.+?(?=(&&|$|\|\||%))))?
(\s*(?<logicalandor>&&|\|\|)\s*)?
';

protected const MATCH_GROUPED_EXPRESSION = '#\([^)(]*+(?:(?R)[^)(]*)*+\)#';

/**
* @throws JSONPathException
*/
public function filter($collection): array
{
\preg_match('/^' . static::MATCH_QUERY_OPERATORS . '$/x', $this->token->value, $matches);

if (!isset($matches[1])) {
throw new RuntimeException('Malformed filter query');
}

$key = $matches['key'] ?: $matches['keySquare'];

if ($key === '') {
throw new RuntimeException('Malformed filter query: key was not set');
$filterExpression = $this->token->value;
$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'];
}

$operator = $matches['operator'] ?? null;
$comparisonValue = $matches['comparisonValue'] ?? null;

if (\is_string($comparisonValue)) {
if (\str_starts_with($comparisonValue, "[") && \str_ends_with($comparisonValue, "]")) {
$comparisonValue = \substr($comparisonValue, 1, -1);
$comparisonValue = \preg_replace('/^[\'"]/', '', $comparisonValue);
$comparisonValue = \preg_replace('/[\'"]$/', '', $comparisonValue);
$comparisonValue = \preg_replace('/[\'"], *[\'"]/', ',', $comparisonValue);
$comparisonValue = \array_map('trim', \explode(",", $comparisonValue));
} else {
$comparisonValue = \preg_replace('/^[\'"]/', '', $comparisonValue);
$comparisonValue = \preg_replace('/[\'"]$/', '', $comparisonValue);

if (\strtolower($comparisonValue) === 'false') {
$comparisonValue = false;
} elseif (\strtolower($comparisonValue) === 'true') {
$comparisonValue = true;
} elseif (\strtolower($comparisonValue) === 'null') {
$comparisonValue = null;
$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'])])
) {
throw new RuntimeException('Malformed filter query');
}

$return = [];
$matchCount = \count($matches[0]);

foreach ($collection as $value) {
$value1 = null;
for ($expressionPart = 0; $expressionPart < $matchCount; $expressionPart++) {
$filteredCollection = $collection;
$logicalJoin = $expressionPart > 0 ? $matches['logicalandor'][$expressionPart - 1] : null;

if (AccessHelper::keyExists($value, $key, $this->magicIsAllowed)) {
$value1 = AccessHelper::getValue($value, $key, $this->magicIsAllowed);
} elseif (\str_contains($key, '.')) {
$value1 = (new JSONPath($value))->find($key)->getData()[0] ?? '';
if ($logicalJoin === '&&') {
//Restrict the nodes we need to look at to those already meeting criteria
$filteredCollection = $return;
$return = [];
}

if ($value1) {
if ($operator === null) {
$return[] = $value;
}
//Processing a group
if ($matches['group'][$expressionPart] !== null) {
$filter = '$[?(' . $filterGroups[$matches['group'][$expressionPart]] . ')]';
$resolve = (new JSONPath($filteredCollection))->find($filter)->getData();
$return = $resolve;

/** @noinspection TypeUnsafeComparisonInspection */
// phpcs:ignore -- This is a loose comparison by design.
if (($operator === '=' || $operator === '==') && $value1 == $comparisonValue) {
$return[] = $value;
}
continue;
}

/** @noinspection TypeUnsafeComparisonInspection */
// phpcs:ignore -- This is a loose comparison by design.
if (($operator === '!=' || $operator === '!==' || $operator === '<>') && $value1 != $comparisonValue) {
$return[] = $value;
}
//Process a normal expression
$key = $matches['key'][$expressionPart] ?: $matches['keySquare'][$expressionPart];

if ($operator === '=~' && @\preg_match($comparisonValue, $value1)) {
$return[] = $value;
}
$operator = $matches['operator'][$expressionPart] ?? null;
$comparisonValue = $matches['comparisonValue'][$expressionPart] ?? null;

if ($operator === '>' && $value1 > $comparisonValue) {
$return[] = $value;
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)
}
}

if ($operator === '>=' && $value1 >= $comparisonValue) {
$return[] = $value;
foreach ($filteredCollection as $nodeIndex => $node) {
if ($logicalJoin === '||' && \array_key_exists($nodeIndex, $return)) {
//Short-circuit, node already exists in output due to previous test
continue;
}

if ($operator === '<' && $value1 < $comparisonValue) {
$return[] = $value;
$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;
}
}
} else {
//Node selection was plain @
$selectedNode = $node;
$notNothing = true;
}

if ($operator === '<=' && $value1 <= $comparisonValue) {
$return[] = $value;
$comparisonResult = null;

if ($notNothing) {
$comparisonResult = match ($operator) {
null => AccessHelper::keyExists($node, $key, $this->magicIsAllowed) || (!$key),
"=", "==" => $this->compareEquals($selectedNode, $comparisonValue),
"!=", "!==", "<>" => !$this->compareEquals($selectedNode, $comparisonValue),
'=~' => @\preg_match($comparisonValue, $selectedNode),
'<' => $this->compareLessThan($selectedNode, $comparisonValue),
Comment on lines +151 to +159
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Potential risk with error-suppressed regex.
Using @\preg_match($comparisonValue, $selectedNode) may hide warnings or errors from invalid user-supplied patterns. This can raise security and debugging concerns. Consider removing error suppression or validating patterns ahead of time.

'<=' => $this->compareLessThan($selectedNode, $comparisonValue)
|| $this->compareEquals($selectedNode, $comparisonValue),
'>' => $this->compareLessThan($comparisonValue, $selectedNode), //rfc semantics
'>=' => $this->compareLessThan($comparisonValue, $selectedNode) //rfc semantics
|| $this->compareEquals($selectedNode, $comparisonValue),
"in" => \is_array($comparisonValue) && \in_array($selectedNode, $comparisonValue, true),
'nin', "!in" => \is_array($comparisonValue) && !\in_array($selectedNode, $comparisonValue, true)
};
}

if ($operator === 'in' && \is_array($comparisonValue) && \in_array($value1, $comparisonValue, false)) {
$return[] = $value;
if ($negateFilter) {
$comparisonResult = !$comparisonResult;
}

if (
($operator === 'nin' || $operator === '!in')
&& \is_array($comparisonValue)
&& !\in_array($value1, $comparisonValue, false)
) {
$return[] = $value;
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($value): bool
{
return !\is_string($value) && \is_numeric($value);
}

protected function compareEquals($a, $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;
}
//Object/Array
//@TODO array and object comparison
}

return false;
}

protected function compareLessThan($a, $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;
}
}
3 changes: 1 addition & 2 deletions tests/JSONPathArrayAccessTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ public function testIterating(): void
* @testWith [false]
* [true]
*/
public function testDifferentStylesOfAccess(bool $asArray): void
public function testDifferentStylesOfAccess(bool $asArray = true): void
{
$container = new ArrayObject($this->getData('conferences', $asArray));
$data = new JSONPath($container);
Expand All @@ -97,7 +97,6 @@ public function testDifferentStylesOfAccess(bool $asArray): void
}

/**
* @throws JsonException
* @noinspection PhpUndefinedFieldInspection
*/
public function testUpdate(): void
Expand Down
Loading