diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetField.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetField.php index c9de4ca0..af68998f 100644 --- a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetField.php +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetField.php @@ -4,9 +4,19 @@ namespace MartinGeorgiev\Doctrine\ORM\Query\AST\Functions; +use Doctrine\ORM\Query\Lexer; +use Doctrine\ORM\Query\Parser; +use Doctrine\ORM\Query\QueryException; +use Doctrine\ORM\Query\TokenType; +use MartinGeorgiev\Utils\DoctrineOrm; + /** * Implementation of PostgreSQL json field retrieval, filtered by key (using ->). * + * Supports both string keys for object property access and integer indices for array element access: + * - JSON_GET_FIELD(json_column, 'property_name') -> json_column->'property_name' + * - JSON_GET_FIELD(json_column, 0) -> json_column->0 + * * @see https://www.postgresql.org/docs/9.4/static/functions-json.html * @since 0.1 * @@ -17,7 +27,24 @@ class JsonGetField extends BaseFunction protected function customizeFunction(): void { $this->setFunctionPrototype('(%s -> %s)'); - $this->addNodeMapping('StringPrimary'); - $this->addNodeMapping('StringPrimary'); + } + + protected function feedParserWithNodes(Parser $parser): void + { + $shouldUseLexer = DoctrineOrm::isPre219(); + + $nodeForJsonDocumentName = $parser->StringPrimary(); + $parser->match($shouldUseLexer ? Lexer::T_COMMA : TokenType::T_COMMA); + + // Second parameter can be either an index or a property name + try { + $nodeForJsonIndexOrPropertyName = $parser->ArithmeticPrimary(); + } catch (QueryException) { + // If ArithmeticPrimary fails (e.g., when encountering a property name rather than an index), try StringPrimary + $nodeForJsonIndexOrPropertyName = $parser->StringPrimary(); + } + + /* @phpstan-ignore-next-line assign.propertyType */ + $this->nodes = [$nodeForJsonDocumentName, $nodeForJsonIndexOrPropertyName]; } } diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsInteger.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsInteger.php index c5421df9..c6bff679 100644 --- a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsInteger.php +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsInteger.php @@ -7,17 +7,19 @@ /** * Implementation of PostgreSQL json field retrieval as integer, filtered by key (using ->> and type casting to BIGINT). * + * Supports both string keys for object property access and integer indices for array element access: + * - JSON_GET_FIELD_AS_INTEGER(json_column, 'property_name') -> CAST(json_column->>'property_name' as BIGINT) + * - JSON_GET_FIELD_AS_INTEGER(json_column, 0) -> CAST(json_column->>0 as BIGINT) + * * @see https://www.postgresql.org/docs/9.4/static/functions-json.html * @since 0.3 * * @author Martin Georgiev */ -class JsonGetFieldAsInteger extends BaseFunction +class JsonGetFieldAsInteger extends JsonGetField { protected function customizeFunction(): void { $this->setFunctionPrototype('CAST(%s ->> %s as BIGINT)'); - $this->addNodeMapping('StringPrimary'); - $this->addNodeMapping('StringPrimary'); } } diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsText.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsText.php index 87d9e27c..e3b7424e 100644 --- a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsText.php +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsText.php @@ -7,17 +7,19 @@ /** * Implementation of PostgreSQL json field retrieval as text, filtered by key (using ->>). * + * Supports both string keys for object property access and integer indices for array element access: + * - JSON_GET_FIELD_AS_TEXT(json_column, 'property_name') -> json_column->>'property_name' + * - JSON_GET_FIELD_AS_TEXT(json_column, 0) -> json_column->>0 + * * @see https://www.postgresql.org/docs/9.4/static/functions-json.html * @since 0.1 * * @author Martin Georgiev */ -class JsonGetFieldAsText extends BaseFunction +class JsonGetFieldAsText extends JsonGetField { protected function customizeFunction(): void { $this->setFunctionPrototype('(%s ->> %s)'); - $this->addNodeMapping('StringPrimary'); - $this->addNodeMapping('StringPrimary'); } } diff --git a/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsIntegerTest.php b/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsIntegerTest.php index 22e523f7..29ffa044 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsIntegerTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsIntegerTest.php @@ -4,6 +4,7 @@ namespace Tests\Integration\MartinGeorgiev\Doctrine\ORM\Query\AST\Functions; +use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetField; use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetFieldAsInteger; class JsonGetFieldAsIntegerTest extends JsonTestCase @@ -11,6 +12,7 @@ class JsonGetFieldAsIntegerTest extends JsonTestCase protected function getStringFunctions(): array { return [ + 'JSON_GET_FIELD' => JsonGetField::class, 'JSON_GET_FIELD_AS_INTEGER' => JsonGetFieldAsInteger::class, ]; } @@ -22,6 +24,18 @@ public function test_json_get_field_as_integer(): void $this->assertSame(30, $result[0]['result']); } + public function test_json_get_field_as_integer_with_index(): void + { + // First, let's insert test data with numeric arrays + $this->connection->executeStatement( + \sprintf("UPDATE %s.containsjsons SET object1 = '{\"scores\": [85, 92, 78]}' WHERE id = 1", self::DATABASE_SCHEMA) + ); + + $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"; + $result = $this->executeDqlQuery($dql); + $this->assertSame(92, $result[0]['result']); + } + public function test_json_get_field_as_integer_empty_object(): void { $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 $this->assertNull($result[0]['result']); } - public function test_json_get_field_as_integer_nonexistent_field(): void + public function test_json_get_field_as_integer_nonexistent_property_name(): void { $dql = "SELECT JSON_GET_FIELD_AS_INTEGER(t.object1, 'nonexistent') as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsJsons t WHERE t.id = 1"; $result = $this->executeDqlQuery($dql); diff --git a/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsTextTest.php b/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsTextTest.php new file mode 100644 index 00000000..b9c0fbbb --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsTextTest.php @@ -0,0 +1,54 @@ + JsonGetField::class, + 'JSON_GET_FIELD_AS_TEXT' => JsonGetFieldAsText::class, + ]; + } + + public function test_json_get_field_as_text_with_property_name(): void + { + $dql = "SELECT JSON_GET_FIELD_AS_TEXT(t.object1, 'name') as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsJsons t WHERE t.id = 1"; + $result = $this->executeDqlQuery($dql); + $this->assertSame('John', $result[0]['result']); + } + + public function test_json_get_field_as_text_with_index(): void + { + $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"; + $result = $this->executeDqlQuery($dql); + $this->assertSame('developer', $result[0]['result']); + } + + public function test_json_get_field_as_text_nested_access(): void + { + $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"; + $result = $this->executeDqlQuery($dql); + $this->assertSame('New York', $result[0]['result']); + } + + public function test_json_get_field_as_text_with_null_value(): void + { + $dql = "SELECT JSON_GET_FIELD_AS_TEXT(t.object1, 'age') as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsJsons t WHERE t.id = 5"; + $result = $this->executeDqlQuery($dql); + $this->assertNull($result[0]['result']); + } + + public function test_json_get_field_as_text_with_nonexistent_index(): void + { + $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"; + $result = $this->executeDqlQuery($dql); + $this->assertNull($result[0]['result']); + } +} diff --git a/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldTest.php b/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldTest.php new file mode 100644 index 00000000..1133f1f8 --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldTest.php @@ -0,0 +1,59 @@ + JsonGetField::class, + ]; + } + + public function test_json_get_field_with_property_name(): void + { + $dql = "SELECT JSON_GET_FIELD(t.object1, 'name') as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsJsons t WHERE t.id = 1"; + $result = $this->executeDqlQuery($dql); + $this->assertSame('"John"', $result[0]['result']); + } + + public function test_json_get_field_with_index(): void + { + $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"; + $result = $this->executeDqlQuery($dql); + $this->assertSame('"developer"', $result[0]['result']); + } + + public function test_json_get_field_nested_object_access(): void + { + $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"; + $result = $this->executeDqlQuery($dql); + $this->assertSame('"New York"', $result[0]['result']); + } + + public function test_json_get_field_with_empty_array(): void + { + $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"; + $result = $this->executeDqlQuery($dql); + $this->assertNull($result[0]['result']); + } + + public function test_json_get_field_with_nonexistent_index(): void + { + $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"; + $result = $this->executeDqlQuery($dql); + $this->assertNull($result[0]['result']); + } + + public function test_json_get_field_with_nonexistent_property_name(): void + { + $dql = "SELECT JSON_GET_FIELD(t.object1, 'nonexistent') as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsJsons t WHERE t.id = 1"; + $result = $this->executeDqlQuery($dql); + $this->assertNull($result[0]['result']); + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsIntegerTest.php b/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsIntegerTest.php index 77a0079d..df8a9791 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsIntegerTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsIntegerTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\MartinGeorgiev\Doctrine\ORM\Query\AST\Functions; use Fixtures\MartinGeorgiev\Doctrine\Entity\ContainsJsons; +use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetField; use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetFieldAsInteger; class JsonGetFieldAsIntegerTest extends TestCase @@ -12,6 +13,7 @@ class JsonGetFieldAsIntegerTest extends TestCase protected function getStringFunctions(): array { return [ + 'JSON_GET_FIELD' => JsonGetField::class, 'JSON_GET_FIELD_AS_INTEGER' => JsonGetFieldAsInteger::class, ]; } @@ -19,14 +21,18 @@ protected function getStringFunctions(): array protected function getExpectedSqlStatements(): array { return [ - "SELECT CAST(c0_.object1 ->> 'rank' as BIGINT) AS sclr_0 FROM ContainsJsons c0_", + 'extracts field as integer' => "SELECT CAST(c0_.object1 ->> 'rank' as BIGINT) AS sclr_0 FROM ContainsJsons c0_", + 'extracts array element as integer' => 'SELECT CAST(c0_.object1 ->> 0 as BIGINT) AS sclr_0 FROM ContainsJsons c0_', + 'extracts nested array element as integer' => "SELECT CAST((c0_.object1 -> 'scores') ->> 1 as BIGINT) AS sclr_0 FROM ContainsJsons c0_", ]; } protected function getDqlStatements(): array { return [ - \sprintf("SELECT JSON_GET_FIELD_AS_INTEGER(e.object1, 'rank') FROM %s e", ContainsJsons::class), + 'extracts field as integer' => \sprintf("SELECT JSON_GET_FIELD_AS_INTEGER(e.object1, 'rank') FROM %s e", ContainsJsons::class), + 'extracts array element as integer' => \sprintf('SELECT JSON_GET_FIELD_AS_INTEGER(e.object1, 0) FROM %s e', ContainsJsons::class), + '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), ]; } } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsTextTest.php b/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsTextTest.php index f0a651a0..d76866ea 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsTextTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldAsTextTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\MartinGeorgiev\Doctrine\ORM\Query\AST\Functions; use Fixtures\MartinGeorgiev\Doctrine\Entity\ContainsJsons; +use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetField; use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetFieldAsText; class JsonGetFieldAsTextTest extends TestCase @@ -12,6 +13,7 @@ class JsonGetFieldAsTextTest extends TestCase protected function getStringFunctions(): array { return [ + 'JSON_GET_FIELD' => JsonGetField::class, 'JSON_GET_FIELD_AS_TEXT' => JsonGetFieldAsText::class, ]; } @@ -19,14 +21,18 @@ protected function getStringFunctions(): array protected function getExpectedSqlStatements(): array { return [ - "SELECT (c0_.object1 ->> 'country') AS sclr_0 FROM ContainsJsons c0_", + 'extracts field as text' => "SELECT (c0_.object1 ->> 'country') AS sclr_0 FROM ContainsJsons c0_", + 'extracts array element as text' => 'SELECT (c0_.object1 ->> 0) AS sclr_0 FROM ContainsJsons c0_', + 'extracts nested array element as text' => "SELECT ((c0_.object1 -> 'tags') ->> 1) AS sclr_0 FROM ContainsJsons c0_", ]; } protected function getDqlStatements(): array { return [ - \sprintf("SELECT JSON_GET_FIELD_AS_TEXT(e.object1, 'country') FROM %s e", ContainsJsons::class), + 'extracts field as text' => \sprintf("SELECT JSON_GET_FIELD_AS_TEXT(e.object1, 'country') FROM %s e", ContainsJsons::class), + 'extracts array element as text' => \sprintf('SELECT JSON_GET_FIELD_AS_TEXT(e.object1, 0) FROM %s e', ContainsJsons::class), + '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), ]; } } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldTest.php b/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldTest.php index 24e3471a..c629dc28 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/JsonGetFieldTest.php @@ -21,6 +21,9 @@ protected function getExpectedSqlStatements(): array return [ 'extracts top-level field from json' => "SELECT (c0_.object1 -> 'key') AS sclr_0 FROM ContainsJsons c0_", 'extracts nested field from json' => "SELECT ((c0_.object1 -> 'nested') -> 'key') AS sclr_0 FROM ContainsJsons c0_", + 'extracts array element by index' => 'SELECT (c0_.object1 -> 0) AS sclr_0 FROM ContainsJsons c0_', + 'extracts nested array element by index' => "SELECT ((c0_.object1 -> 'tags') -> 1) AS sclr_0 FROM ContainsJsons c0_", + 'extracts field from array element by index' => "SELECT ((c0_.object1 -> 0) -> 'name') AS sclr_0 FROM ContainsJsons c0_", ]; } @@ -29,6 +32,9 @@ protected function getDqlStatements(): array return [ 'extracts top-level field from json' => \sprintf("SELECT JSON_GET_FIELD(e.object1, 'key') FROM %s e", ContainsJsons::class), 'extracts nested field from json' => \sprintf("SELECT JSON_GET_FIELD(JSON_GET_FIELD(e.object1, 'nested'), 'key') FROM %s e", ContainsJsons::class), + 'extracts array element by index' => \sprintf('SELECT JSON_GET_FIELD(e.object1, 0) FROM %s e', ContainsJsons::class), + 'extracts nested array element by index' => \sprintf("SELECT JSON_GET_FIELD(JSON_GET_FIELD(e.object1, 'tags'), 1) FROM %s e", ContainsJsons::class), + 'extracts field from array element by index' => \sprintf("SELECT JSON_GET_FIELD(JSON_GET_FIELD(e.object1, 0), 'name') FROM %s e", ContainsJsons::class), ]; } }