Skip to content

Commit 8d18b35

Browse files
feat(#401): improve JSON field extraction by adding index support (#402)
1 parent 1276f8d commit 8d18b35

File tree

9 files changed

+189
-13
lines changed

9 files changed

+189
-13
lines changed

src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetField.php

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,19 @@
44

55
namespace MartinGeorgiev\Doctrine\ORM\Query\AST\Functions;
66

7+
use Doctrine\ORM\Query\Lexer;
8+
use Doctrine\ORM\Query\Parser;
9+
use Doctrine\ORM\Query\QueryException;
10+
use Doctrine\ORM\Query\TokenType;
11+
use MartinGeorgiev\Utils\DoctrineOrm;
12+
713
/**
814
* Implementation of PostgreSQL json field retrieval, filtered by key (using ->).
915
*
16+
* Supports both string keys for object property access and integer indices for array element access:
17+
* - JSON_GET_FIELD(json_column, 'property_name') -> json_column->'property_name'
18+
* - JSON_GET_FIELD(json_column, 0) -> json_column->0
19+
*
1020
* @see https://www.postgresql.org/docs/9.4/static/functions-json.html
1121
* @since 0.1
1222
*
@@ -17,7 +27,24 @@ class JsonGetField extends BaseFunction
1727
protected function customizeFunction(): void
1828
{
1929
$this->setFunctionPrototype('(%s -> %s)');
20-
$this->addNodeMapping('StringPrimary');
21-
$this->addNodeMapping('StringPrimary');
30+
}
31+
32+
protected function feedParserWithNodes(Parser $parser): void
33+
{
34+
$shouldUseLexer = DoctrineOrm::isPre219();
35+
36+
$nodeForJsonDocumentName = $parser->StringPrimary();
37+
$parser->match($shouldUseLexer ? Lexer::T_COMMA : TokenType::T_COMMA);
38+
39+
// Second parameter can be either an index or a property name
40+
try {
41+
$nodeForJsonIndexOrPropertyName = $parser->ArithmeticPrimary();
42+
} catch (QueryException) {
43+
// If ArithmeticPrimary fails (e.g., when encountering a property name rather than an index), try StringPrimary
44+
$nodeForJsonIndexOrPropertyName = $parser->StringPrimary();
45+
}
46+
47+
/* @phpstan-ignore-next-line assign.propertyType */
48+
$this->nodes = [$nodeForJsonDocumentName, $nodeForJsonIndexOrPropertyName];
2249
}
2350
}

src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsInteger.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,19 @@
77
/**
88
* Implementation of PostgreSQL json field retrieval as integer, filtered by key (using ->> and type casting to BIGINT).
99
*
10+
* Supports both string keys for object property access and integer indices for array element access:
11+
* - JSON_GET_FIELD_AS_INTEGER(json_column, 'property_name') -> CAST(json_column->>'property_name' as BIGINT)
12+
* - JSON_GET_FIELD_AS_INTEGER(json_column, 0) -> CAST(json_column->>0 as BIGINT)
13+
*
1014
* @see https://www.postgresql.org/docs/9.4/static/functions-json.html
1115
* @since 0.3
1216
*
1317
* @author Martin Georgiev <martin.georgiev@gmail.com>
1418
*/
15-
class JsonGetFieldAsInteger extends BaseFunction
19+
class JsonGetFieldAsInteger extends JsonGetField
1620
{
1721
protected function customizeFunction(): void
1822
{
1923
$this->setFunctionPrototype('CAST(%s ->> %s as BIGINT)');
20-
$this->addNodeMapping('StringPrimary');
21-
$this->addNodeMapping('StringPrimary');
2224
}
2325
}

src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsText.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,19 @@
77
/**
88
* Implementation of PostgreSQL json field retrieval as text, filtered by key (using ->>).
99
*
10+
* Supports both string keys for object property access and integer indices for array element access:
11+
* - JSON_GET_FIELD_AS_TEXT(json_column, 'property_name') -> json_column->>'property_name'
12+
* - JSON_GET_FIELD_AS_TEXT(json_column, 0) -> json_column->>0
13+
*
1014
* @see https://www.postgresql.org/docs/9.4/static/functions-json.html
1115
* @since 0.1
1216
*
1317
* @author Martin Georgiev <martin.georgiev@gmail.com>
1418
*/
15-
class JsonGetFieldAsText extends BaseFunction
19+
class JsonGetFieldAsText extends JsonGetField
1620
{
1721
protected function customizeFunction(): void
1822
{
1923
$this->setFunctionPrototype('(%s ->> %s)');
20-
$this->addNodeMapping('StringPrimary');
21-
$this->addNodeMapping('StringPrimary');
2224
}
2325
}

tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsIntegerTest.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44

55
namespace Tests\Integration\MartinGeorgiev\Doctrine\ORM\Query\AST\Functions;
66

7+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetField;
78
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetFieldAsInteger;
89

910
class JsonGetFieldAsIntegerTest extends JsonTestCase
1011
{
1112
protected function getStringFunctions(): array
1213
{
1314
return [
15+
'JSON_GET_FIELD' => JsonGetField::class,
1416
'JSON_GET_FIELD_AS_INTEGER' => JsonGetFieldAsInteger::class,
1517
];
1618
}
@@ -22,6 +24,18 @@ public function test_json_get_field_as_integer(): void
2224
$this->assertSame(30, $result[0]['result']);
2325
}
2426

27+
public function test_json_get_field_as_integer_with_index(): void
28+
{
29+
// First, let's insert test data with numeric arrays
30+
$this->connection->executeStatement(
31+
\sprintf("UPDATE %s.containsjsons SET object1 = '{\"scores\": [85, 92, 78]}' WHERE id = 1", self::DATABASE_SCHEMA)
32+
);
33+
34+
$dql = "SELECT JSON_GET_FIELD_AS_INTEGER(JSON_GET_FIELD(t.object1, 'scores'), 1) as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsJsons t WHERE t.id = 1";
35+
$result = $this->executeDqlQuery($dql);
36+
$this->assertSame(92, $result[0]['result']);
37+
}
38+
2539
public function test_json_get_field_as_integer_empty_object(): void
2640
{
2741
$dql = "SELECT JSON_GET_FIELD_AS_INTEGER(t.object1, 'age') as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsJsons t WHERE t.id = 4";
@@ -36,7 +50,7 @@ public function test_json_get_field_as_integer_null_value(): void
3650
$this->assertNull($result[0]['result']);
3751
}
3852

39-
public function test_json_get_field_as_integer_nonexistent_field(): void
53+
public function test_json_get_field_as_integer_nonexistent_property_name(): void
4054
{
4155
$dql = "SELECT JSON_GET_FIELD_AS_INTEGER(t.object1, 'nonexistent') as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsJsons t WHERE t.id = 1";
4256
$result = $this->executeDqlQuery($dql);
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Integration\MartinGeorgiev\Doctrine\ORM\Query\AST\Functions;
6+
7+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetField;
8+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetFieldAsText;
9+
10+
class JsonGetFieldAsTextTest extends JsonTestCase
11+
{
12+
protected function getStringFunctions(): array
13+
{
14+
return [
15+
'JSON_GET_FIELD' => JsonGetField::class,
16+
'JSON_GET_FIELD_AS_TEXT' => JsonGetFieldAsText::class,
17+
];
18+
}
19+
20+
public function test_json_get_field_as_text_with_property_name(): void
21+
{
22+
$dql = "SELECT JSON_GET_FIELD_AS_TEXT(t.object1, 'name') as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsJsons t WHERE t.id = 1";
23+
$result = $this->executeDqlQuery($dql);
24+
$this->assertSame('John', $result[0]['result']);
25+
}
26+
27+
public function test_json_get_field_as_text_with_index(): void
28+
{
29+
$dql = "SELECT JSON_GET_FIELD_AS_TEXT(JSON_GET_FIELD(t.object1, 'tags'), 0) as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsJsons t WHERE t.id = 1";
30+
$result = $this->executeDqlQuery($dql);
31+
$this->assertSame('developer', $result[0]['result']);
32+
}
33+
34+
public function test_json_get_field_as_text_nested_access(): void
35+
{
36+
$dql = "SELECT JSON_GET_FIELD_AS_TEXT(JSON_GET_FIELD(t.object1, 'address'), 'city') as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsJsons t WHERE t.id = 1";
37+
$result = $this->executeDqlQuery($dql);
38+
$this->assertSame('New York', $result[0]['result']);
39+
}
40+
41+
public function test_json_get_field_as_text_with_null_value(): void
42+
{
43+
$dql = "SELECT JSON_GET_FIELD_AS_TEXT(t.object1, 'age') as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsJsons t WHERE t.id = 5";
44+
$result = $this->executeDqlQuery($dql);
45+
$this->assertNull($result[0]['result']);
46+
}
47+
48+
public function test_json_get_field_as_text_with_nonexistent_index(): void
49+
{
50+
$dql = "SELECT JSON_GET_FIELD_AS_TEXT(JSON_GET_FIELD(t.object1, 'tags'), 10) as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsJsons t WHERE t.id = 1";
51+
$result = $this->executeDqlQuery($dql);
52+
$this->assertNull($result[0]['result']);
53+
}
54+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Integration\MartinGeorgiev\Doctrine\ORM\Query\AST\Functions;
6+
7+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetField;
8+
9+
class JsonGetFieldTest extends JsonTestCase
10+
{
11+
protected function getStringFunctions(): array
12+
{
13+
return [
14+
'JSON_GET_FIELD' => JsonGetField::class,
15+
];
16+
}
17+
18+
public function test_json_get_field_with_property_name(): void
19+
{
20+
$dql = "SELECT JSON_GET_FIELD(t.object1, 'name') as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsJsons t WHERE t.id = 1";
21+
$result = $this->executeDqlQuery($dql);
22+
$this->assertSame('"John"', $result[0]['result']);
23+
}
24+
25+
public function test_json_get_field_with_index(): void
26+
{
27+
$dql = "SELECT JSON_GET_FIELD(JSON_GET_FIELD(t.object1, 'tags'), 0) as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsJsons t WHERE t.id = 1";
28+
$result = $this->executeDqlQuery($dql);
29+
$this->assertSame('"developer"', $result[0]['result']);
30+
}
31+
32+
public function test_json_get_field_nested_object_access(): void
33+
{
34+
$dql = "SELECT JSON_GET_FIELD(JSON_GET_FIELD(t.object1, 'address'), 'city') as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsJsons t WHERE t.id = 1";
35+
$result = $this->executeDqlQuery($dql);
36+
$this->assertSame('"New York"', $result[0]['result']);
37+
}
38+
39+
public function test_json_get_field_with_empty_array(): void
40+
{
41+
$dql = "SELECT JSON_GET_FIELD(JSON_GET_FIELD(t.object1, 'tags'), 0) as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsJsons t WHERE t.id = 3";
42+
$result = $this->executeDqlQuery($dql);
43+
$this->assertNull($result[0]['result']);
44+
}
45+
46+
public function test_json_get_field_with_nonexistent_index(): void
47+
{
48+
$dql = "SELECT JSON_GET_FIELD(JSON_GET_FIELD(t.object1, 'tags'), 10) as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsJsons t WHERE t.id = 1";
49+
$result = $this->executeDqlQuery($dql);
50+
$this->assertNull($result[0]['result']);
51+
}
52+
53+
public function test_json_get_field_with_nonexistent_property_name(): void
54+
{
55+
$dql = "SELECT JSON_GET_FIELD(t.object1, 'nonexistent') as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsJsons t WHERE t.id = 1";
56+
$result = $this->executeDqlQuery($dql);
57+
$this->assertNull($result[0]['result']);
58+
}
59+
}

tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsIntegerTest.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,34 @@
55
namespace Tests\Unit\MartinGeorgiev\Doctrine\ORM\Query\AST\Functions;
66

77
use Fixtures\MartinGeorgiev\Doctrine\Entity\ContainsJsons;
8+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetField;
89
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetFieldAsInteger;
910

1011
class JsonGetFieldAsIntegerTest extends TestCase
1112
{
1213
protected function getStringFunctions(): array
1314
{
1415
return [
16+
'JSON_GET_FIELD' => JsonGetField::class,
1517
'JSON_GET_FIELD_AS_INTEGER' => JsonGetFieldAsInteger::class,
1618
];
1719
}
1820

1921
protected function getExpectedSqlStatements(): array
2022
{
2123
return [
22-
"SELECT CAST(c0_.object1 ->> 'rank' as BIGINT) AS sclr_0 FROM ContainsJsons c0_",
24+
'extracts field as integer' => "SELECT CAST(c0_.object1 ->> 'rank' as BIGINT) AS sclr_0 FROM ContainsJsons c0_",
25+
'extracts array element as integer' => 'SELECT CAST(c0_.object1 ->> 0 as BIGINT) AS sclr_0 FROM ContainsJsons c0_',
26+
'extracts nested array element as integer' => "SELECT CAST((c0_.object1 -> 'scores') ->> 1 as BIGINT) AS sclr_0 FROM ContainsJsons c0_",
2327
];
2428
}
2529

2630
protected function getDqlStatements(): array
2731
{
2832
return [
29-
\sprintf("SELECT JSON_GET_FIELD_AS_INTEGER(e.object1, 'rank') FROM %s e", ContainsJsons::class),
33+
'extracts field as integer' => \sprintf("SELECT JSON_GET_FIELD_AS_INTEGER(e.object1, 'rank') FROM %s e", ContainsJsons::class),
34+
'extracts array element as integer' => \sprintf('SELECT JSON_GET_FIELD_AS_INTEGER(e.object1, 0) FROM %s e', ContainsJsons::class),
35+
'extracts nested array element as integer' => \sprintf("SELECT JSON_GET_FIELD_AS_INTEGER(JSON_GET_FIELD(e.object1, 'scores'), 1) FROM %s e", ContainsJsons::class),
3036
];
3137
}
3238
}

tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsTextTest.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,34 @@
55
namespace Tests\Unit\MartinGeorgiev\Doctrine\ORM\Query\AST\Functions;
66

77
use Fixtures\MartinGeorgiev\Doctrine\Entity\ContainsJsons;
8+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetField;
89
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetFieldAsText;
910

1011
class JsonGetFieldAsTextTest extends TestCase
1112
{
1213
protected function getStringFunctions(): array
1314
{
1415
return [
16+
'JSON_GET_FIELD' => JsonGetField::class,
1517
'JSON_GET_FIELD_AS_TEXT' => JsonGetFieldAsText::class,
1618
];
1719
}
1820

1921
protected function getExpectedSqlStatements(): array
2022
{
2123
return [
22-
"SELECT (c0_.object1 ->> 'country') AS sclr_0 FROM ContainsJsons c0_",
24+
'extracts field as text' => "SELECT (c0_.object1 ->> 'country') AS sclr_0 FROM ContainsJsons c0_",
25+
'extracts array element as text' => 'SELECT (c0_.object1 ->> 0) AS sclr_0 FROM ContainsJsons c0_',
26+
'extracts nested array element as text' => "SELECT ((c0_.object1 -> 'tags') ->> 1) AS sclr_0 FROM ContainsJsons c0_",
2327
];
2428
}
2529

2630
protected function getDqlStatements(): array
2731
{
2832
return [
29-
\sprintf("SELECT JSON_GET_FIELD_AS_TEXT(e.object1, 'country') FROM %s e", ContainsJsons::class),
33+
'extracts field as text' => \sprintf("SELECT JSON_GET_FIELD_AS_TEXT(e.object1, 'country') FROM %s e", ContainsJsons::class),
34+
'extracts array element as text' => \sprintf('SELECT JSON_GET_FIELD_AS_TEXT(e.object1, 0) FROM %s e', ContainsJsons::class),
35+
'extracts nested array element as text' => \sprintf("SELECT JSON_GET_FIELD_AS_TEXT(JSON_GET_FIELD(e.object1, 'tags'), 1) FROM %s e", ContainsJsons::class),
3036
];
3137
}
3238
}

tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldTest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ protected function getExpectedSqlStatements(): array
2121
return [
2222
'extracts top-level field from json' => "SELECT (c0_.object1 -> 'key') AS sclr_0 FROM ContainsJsons c0_",
2323
'extracts nested field from json' => "SELECT ((c0_.object1 -> 'nested') -> 'key') AS sclr_0 FROM ContainsJsons c0_",
24+
'extracts array element by index' => 'SELECT (c0_.object1 -> 0) AS sclr_0 FROM ContainsJsons c0_',
25+
'extracts nested array element by index' => "SELECT ((c0_.object1 -> 'tags') -> 1) AS sclr_0 FROM ContainsJsons c0_",
26+
'extracts field from array element by index' => "SELECT ((c0_.object1 -> 0) -> 'name') AS sclr_0 FROM ContainsJsons c0_",
2427
];
2528
}
2629

@@ -29,6 +32,9 @@ protected function getDqlStatements(): array
2932
return [
3033
'extracts top-level field from json' => \sprintf("SELECT JSON_GET_FIELD(e.object1, 'key') FROM %s e", ContainsJsons::class),
3134
'extracts nested field from json' => \sprintf("SELECT JSON_GET_FIELD(JSON_GET_FIELD(e.object1, 'nested'), 'key') FROM %s e", ContainsJsons::class),
35+
'extracts array element by index' => \sprintf('SELECT JSON_GET_FIELD(e.object1, 0) FROM %s e', ContainsJsons::class),
36+
'extracts nested array element by index' => \sprintf("SELECT JSON_GET_FIELD(JSON_GET_FIELD(e.object1, 'tags'), 1) FROM %s e", ContainsJsons::class),
37+
'extracts field from array element by index' => \sprintf("SELECT JSON_GET_FIELD(JSON_GET_FIELD(e.object1, 0), 'name') FROM %s e", ContainsJsons::class),
3238
];
3339
}
3440
}

0 commit comments

Comments
 (0)