Skip to content

Commit 0cda902

Browse files
feat: add support for JSONB_PATH_EXISTS(), JSONB_PATH_MATCH(), JSONB_PATH_QUERY(), JSONB_PATH_QUERY_ARRAY() and JSONB_PATH_QUERY_FIRST() (martin-georgiev#346)
1 parent c3cb08d commit 0cda902

File tree

12 files changed

+635
-0
lines changed

12 files changed

+635
-0
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception;
6+
7+
use Doctrine\DBAL\Types\ConversionException;
8+
9+
/**
10+
* @since 3.1
11+
*
12+
* @author Martin Georgiev <martin.georgiev@gmail.com>
13+
*/
14+
class InvalidBooleanException extends ConversionException
15+
{
16+
public static function forNonLiteralNode(string $nodeClass, string $functionName): self
17+
{
18+
return new self(\sprintf(
19+
'The boolean parameter for %s must be a string literal, got %s',
20+
$functionName,
21+
$nodeClass
22+
));
23+
}
24+
25+
public static function forInvalidBoolean(string $value, string $functionName): self
26+
{
27+
return new self(\sprintf(
28+
'Invalid boolean value "%s" provided for %s. Must be "true" or "false".',
29+
$value,
30+
$functionName
31+
));
32+
}
33+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MartinGeorgiev\Doctrine\ORM\Query\AST\Functions;
6+
7+
use Doctrine\ORM\Query\AST\Node;
8+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception\InvalidArgumentForVariadicFunctionException;
9+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Traits\BooleanValidationTrait;
10+
11+
/**
12+
* Implementation of PostgreSQL JSONB_PATH_EXISTS().
13+
*
14+
* Checks whether the JSON path returns any item for the specified JSON value.
15+
*
16+
* @see https://www.postgresql.org/docs/14/functions-json.html
17+
* @since 3.1
18+
*
19+
* @author Martin Georgiev <martin.georgiev@gmail.com>
20+
*
21+
* @example Using it in DQL: "SELECT JSONB_PATH_EXISTS(e.jsonbData, '$.a[*] ? (@ > 2)')"
22+
*/
23+
class JsonbPathExists extends BaseVariadicFunction
24+
{
25+
use BooleanValidationTrait;
26+
27+
protected function customizeFunction(): void
28+
{
29+
$this->setFunctionPrototype('jsonb_path_exists(%s)');
30+
}
31+
32+
protected function validateArguments(Node ...$arguments): void
33+
{
34+
$argumentCount = \count($arguments);
35+
if ($argumentCount < 2 || $argumentCount > 4) {
36+
throw InvalidArgumentForVariadicFunctionException::between('jsonb_path_exists', 2, 4);
37+
}
38+
39+
// Validate that the fourth parameter is a valid boolean if provided
40+
if ($argumentCount === 4) {
41+
$this->validateBoolean($arguments[3], 'JSONB_PATH_EXISTS');
42+
}
43+
}
44+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MartinGeorgiev\Doctrine\ORM\Query\AST\Functions;
6+
7+
use Doctrine\ORM\Query\AST\Node;
8+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception\InvalidArgumentForVariadicFunctionException;
9+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Traits\BooleanValidationTrait;
10+
11+
/**
12+
* Implementation of PostgreSQL JSONB_PATH_MATCH().
13+
*
14+
* Returns the SQL boolean result of a JSON path predicate check for the specified JSON value.
15+
* This is useful only with predicate check expressions, not SQL-standard JSON path expressions,
16+
* since it will either fail or return NULL if the path result is not a single boolean value.
17+
*
18+
* @see https://www.postgresql.org/docs/14/functions-json.html
19+
* @since 3.1
20+
*
21+
* @author Martin Georgiev <martin.georgiev@gmail.com>
22+
*
23+
* @example Using it in DQL: "SELECT JSONB_PATH_MATCH(e.jsonbData, 'exists($.a[*] ? (@ >= 2 && @ <= 4))')"
24+
*/
25+
class JsonbPathMatch extends BaseVariadicFunction
26+
{
27+
use BooleanValidationTrait;
28+
29+
protected function customizeFunction(): void
30+
{
31+
$this->setFunctionPrototype('jsonb_path_match(%s)');
32+
}
33+
34+
protected function validateArguments(Node ...$arguments): void
35+
{
36+
$argumentCount = \count($arguments);
37+
if ($argumentCount < 2 || $argumentCount > 4) {
38+
throw InvalidArgumentForVariadicFunctionException::between('jsonb_path_match', 2, 4);
39+
}
40+
41+
// Validate that the fourth parameter is a valid boolean if provided
42+
if ($argumentCount === 4) {
43+
$this->validateBoolean($arguments[3], 'JSONB_PATH_MATCH');
44+
}
45+
}
46+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MartinGeorgiev\Doctrine\ORM\Query\AST\Functions;
6+
7+
use Doctrine\ORM\Query\AST\Node;
8+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception\InvalidArgumentForVariadicFunctionException;
9+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Traits\BooleanValidationTrait;
10+
11+
/**
12+
* Implementation of PostgreSQL JSONB_PATH_QUERY().
13+
*
14+
* Returns all JSON items returned by the JSON path for the specified JSON value.
15+
* Returns multiple rows, so it's useful when used in a subquery.
16+
*
17+
* @see https://www.postgresql.org/docs/14/functions-json.html
18+
* @since 3.1
19+
*
20+
* @author Martin Georgiev <martin.georgiev@gmail.com>
21+
*
22+
* @example Using it in DQL: "SELECT e.id FROM Entity e WHERE e.id IN (SELECT JSONB_PATH_QUERY(e2.jsonbData, '$.items[*].id') FROM Entity2 e2)"
23+
*/
24+
class JsonbPathQuery extends BaseVariadicFunction
25+
{
26+
use BooleanValidationTrait;
27+
28+
protected function customizeFunction(): void
29+
{
30+
$this->setFunctionPrototype('jsonb_path_query(%s)');
31+
}
32+
33+
protected function validateArguments(Node ...$arguments): void
34+
{
35+
$argumentCount = \count($arguments);
36+
if ($argumentCount < 2 || $argumentCount > 4) {
37+
throw InvalidArgumentForVariadicFunctionException::between('jsonb_path_query', 2, 4);
38+
}
39+
40+
// Validate that the fourth parameter is a valid boolean if provided
41+
if ($argumentCount === 4) {
42+
$this->validateBoolean($arguments[3], 'JSONB_PATH_QUERY');
43+
}
44+
}
45+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MartinGeorgiev\Doctrine\ORM\Query\AST\Functions;
6+
7+
use Doctrine\ORM\Query\AST\Node;
8+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception\InvalidArgumentForVariadicFunctionException;
9+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Traits\BooleanValidationTrait;
10+
11+
/**
12+
* Implementation of PostgreSQL JSONB_PATH_QUERY_ARRAY().
13+
*
14+
* Returns all JSON items returned by the JSON path for the specified JSON value as a JSON array.
15+
*
16+
* @see https://www.postgresql.org/docs/14/functions-json.html
17+
* @since 3.1
18+
*
19+
* @author Martin Georgiev <martin.georgiev@gmail.com>
20+
*
21+
* @example Using it in DQL: "SELECT JSONB_PATH_QUERY_ARRAY(e.jsonbData, '$.items[*].id') FROM Entity e"
22+
*/
23+
class JsonbPathQueryArray extends BaseVariadicFunction
24+
{
25+
use BooleanValidationTrait;
26+
27+
protected function customizeFunction(): void
28+
{
29+
$this->setFunctionPrototype('jsonb_path_query_array(%s)');
30+
}
31+
32+
protected function validateArguments(Node ...$arguments): void
33+
{
34+
$argumentCount = \count($arguments);
35+
if ($argumentCount < 2 || $argumentCount > 4) {
36+
throw InvalidArgumentForVariadicFunctionException::between('jsonb_path_query_array', 2, 4);
37+
}
38+
39+
// Validate that the fourth parameter is a valid boolean if provided
40+
if ($argumentCount === 4) {
41+
$this->validateBoolean($arguments[3], 'JSONB_PATH_QUERY_ARRAY');
42+
}
43+
}
44+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MartinGeorgiev\Doctrine\ORM\Query\AST\Functions;
6+
7+
use Doctrine\ORM\Query\AST\Node;
8+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception\InvalidArgumentForVariadicFunctionException;
9+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Traits\BooleanValidationTrait;
10+
11+
/**
12+
* Implementation of PostgreSQL JSONB_PATH_QUERY_FIRST().
13+
*
14+
* Returns the first JSON item returned by the JSON path for the specified JSON value.
15+
* Returns NULL if there are no results.
16+
*
17+
* @see https://www.postgresql.org/docs/14/functions-json.html
18+
* @since 3.1
19+
*
20+
* @author Martin Georgiev <martin.georgiev@gmail.com>
21+
*
22+
* @example Using it in DQL: "SELECT JSONB_PATH_QUERY_FIRST(e.jsonbData, '$.items[*] ? (@.price > 100)') FROM Entity e"
23+
*/
24+
class JsonbPathQueryFirst extends BaseVariadicFunction
25+
{
26+
use BooleanValidationTrait;
27+
28+
protected function customizeFunction(): void
29+
{
30+
$this->setFunctionPrototype('jsonb_path_query_first(%s)');
31+
}
32+
33+
protected function validateArguments(Node ...$arguments): void
34+
{
35+
$argumentCount = \count($arguments);
36+
if ($argumentCount < 2 || $argumentCount > 4) {
37+
throw InvalidArgumentForVariadicFunctionException::between('jsonb_path_query_first', 2, 4);
38+
}
39+
40+
// Validate that the fourth parameter is a valid boolean if provided
41+
if ($argumentCount === 4) {
42+
$this->validateBoolean($arguments[3], 'JSONB_PATH_QUERY_FIRST');
43+
}
44+
}
45+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Traits;
6+
7+
use Doctrine\ORM\Query\AST\Literal;
8+
use Doctrine\ORM\Query\AST\Node;
9+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception\InvalidBooleanException;
10+
11+
/**
12+
* Provides boolean validation functionality for functions that accept boolean parameters.
13+
*
14+
* @since 3.1
15+
*
16+
* @author Martin Georgiev <martin.georgiev@gmail.com>
17+
*/
18+
trait BooleanValidationTrait
19+
{
20+
/**
21+
* Validates that the given node represents a valid boolean value.
22+
*
23+
* @throws InvalidBooleanException If the value is not a valid boolean
24+
*/
25+
protected function validateBoolean(Node $node, string $functionName): void
26+
{
27+
if (!$node instanceof Literal || !\is_string($node->value)) {
28+
throw InvalidBooleanException::forNonLiteralNode($node::class, $functionName);
29+
}
30+
31+
$value = \strtolower(\trim((string) $node->value, "'\""));
32+
$lowercaseValue = \strtolower($value);
33+
34+
if (!$this->isValidBoolean($lowercaseValue)) {
35+
throw InvalidBooleanException::forInvalidBoolean($value, $functionName);
36+
}
37+
}
38+
39+
private function isValidBoolean(string $boolean): bool
40+
{
41+
return \in_array($boolean, ['true', 'false'], true);
42+
}
43+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\MartinGeorgiev\Doctrine\ORM\Query\AST\Functions;
6+
7+
use Fixtures\MartinGeorgiev\Doctrine\Entity\ContainsJsons;
8+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception\InvalidArgumentForVariadicFunctionException;
9+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception\InvalidBooleanException;
10+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbPathExists;
11+
12+
class JsonbPathExistsTest extends TestCase
13+
{
14+
protected function getStringFunctions(): array
15+
{
16+
return [
17+
'JSONB_PATH_EXISTS' => JsonbPathExists::class,
18+
];
19+
}
20+
21+
protected function getExpectedSqlStatements(): array
22+
{
23+
return [
24+
'checks if path exists with condition' => "SELECT jsonb_path_exists(c0_.object1, '$.a[*] ? (@ > 2)') AS sclr_0 FROM ContainsJsons c0_",
25+
'checks if nested path exists' => "SELECT jsonb_path_exists(c0_.object1, '$.address.city') AS sclr_0 FROM ContainsJsons c0_",
26+
'checks if nested path exists with vars argument' => "SELECT jsonb_path_exists(c0_.object1, '$.address.city', '{\"strict\": false}') AS sclr_0 FROM ContainsJsons c0_",
27+
'checks if nested path exists with vars and silent arguments' => "SELECT jsonb_path_exists(c0_.object1, '$.address.city', '{\"strict\": false}', 'true') AS sclr_0 FROM ContainsJsons c0_",
28+
];
29+
}
30+
31+
protected function getDqlStatements(): array
32+
{
33+
return [
34+
'checks if path exists with condition' => \sprintf("SELECT JSONB_PATH_EXISTS(e.object1, '$.a[*] ? (@ > 2)') FROM %s e", ContainsJsons::class),
35+
'checks if nested path exists' => \sprintf("SELECT JSONB_PATH_EXISTS(e.object1, '$.address.city') FROM %s e", ContainsJsons::class),
36+
'checks if nested path exists with vars argument' => \sprintf("SELECT JSONB_PATH_EXISTS(e.object1, '$.address.city', '{\"strict\": false}') FROM %s e", ContainsJsons::class),
37+
'checks if nested path exists with vars and silent arguments' => \sprintf("SELECT JSONB_PATH_EXISTS(e.object1, '$.address.city', '{\"strict\": false}', 'true') FROM %s e", ContainsJsons::class),
38+
];
39+
}
40+
41+
public function test_invalid_boolean_throws_exception(): void
42+
{
43+
$this->expectException(InvalidBooleanException::class);
44+
$this->expectExceptionMessage('Invalid boolean value "invalid" provided for JSONB_PATH_EXISTS. Must be "true" or "false".');
45+
46+
$dql = \sprintf("SELECT JSONB_PATH_EXISTS(e.object1, '$.items[*].id', '{\"strict\": false}', 'invalid') FROM %s e", ContainsJsons::class);
47+
$this->buildEntityManager()->createQuery($dql)->getSQL();
48+
}
49+
50+
public function test_too_few_arguments_throws_exception(): void
51+
{
52+
$this->expectException(InvalidArgumentForVariadicFunctionException::class);
53+
$this->expectExceptionMessage('jsonb_path_exists() requires between 2 and 4 arguments');
54+
55+
$dql = \sprintf('SELECT JSONB_PATH_EXISTS(e.object1) FROM %s e', ContainsJsons::class);
56+
$this->buildEntityManager()->createQuery($dql)->getSQL();
57+
}
58+
59+
public function test_too_many_arguments_throws_exception(): void
60+
{
61+
$this->expectException(InvalidArgumentForVariadicFunctionException::class);
62+
$this->expectExceptionMessage('jsonb_path_exists() requires between 2 and 4 arguments');
63+
64+
$dql = \sprintf("SELECT JSONB_PATH_EXISTS(e.object1, '$.items[*].id', '{\"strict\": false}', 'true', 'extra_arg') FROM %s e", ContainsJsons::class);
65+
$this->buildEntityManager()->createQuery($dql)->getSQL();
66+
}
67+
}

0 commit comments

Comments
 (0)