From 6d3712a622692c9c7691890b0cc29b19a582f90b Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Mon, 4 Aug 2025 01:46:44 +0300 Subject: [PATCH 1/3] feat(#399): support array types for `CAST` --- .../Doctrine/ORM/Query/AST/Functions/Cast.php | 16 ++ src/MartinGeorgiev/Utils/DoctrineLexer.php | 14 ++ .../ORM/Query/AST/Functions/CastTest.php | 220 ++++++++++++++++++ .../ORM/Query/AST/Functions/CastTest.php | 8 + 4 files changed, 258 insertions(+) create mode 100644 tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/CastTest.php diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Cast.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Cast.php index e43bc8f8..df25d94c 100644 --- a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Cast.php +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Cast.php @@ -45,6 +45,7 @@ public function parse(Parser $parser): void return; } + // Handle parameterized types (e.g., DECIMAL(10, 2)) if ($lexer->isNextToken($shouldUseLexer ? Lexer::T_OPEN_PARENTHESIS : TokenType::T_OPEN_PARENTHESIS)) { $parser->match($shouldUseLexer ? Lexer::T_OPEN_PARENTHESIS : TokenType::T_OPEN_PARENTHESIS); $parameter = $parser->Literal(); @@ -61,6 +62,21 @@ public function parse(Parser $parser): void $type .= '('.\implode(', ', $parameters).')'; } + // Handle array types by checking if the next token is '[' + // Since brackets are not recognized as specific tokens, we need to check the token value + $nextTokenValue = DoctrineLexer::getLookaheadValue($lexer); + if ($nextTokenValue === '[') { + // Consume the '[' token + $parser->match($shouldUseLexer ? Lexer::T_NONE : TokenType::T_NONE); + + // Check for the closing ']' token + $nextTokenValue = DoctrineLexer::getLookaheadValue($lexer); + if ($nextTokenValue === ']') { + $parser->match($shouldUseLexer ? Lexer::T_NONE : TokenType::T_NONE); + $type .= '[]'; + } + } + $this->targetType = $type; $parser->match($shouldUseLexer ? Lexer::T_CLOSE_PARENTHESIS : TokenType::T_CLOSE_PARENTHESIS); diff --git a/src/MartinGeorgiev/Utils/DoctrineLexer.php b/src/MartinGeorgiev/Utils/DoctrineLexer.php index 1abc5499..db2747bb 100644 --- a/src/MartinGeorgiev/Utils/DoctrineLexer.php +++ b/src/MartinGeorgiev/Utils/DoctrineLexer.php @@ -37,6 +37,20 @@ public static function getLookaheadType(Lexer $lexer) return $lexer->lookahead?->type; } + /** + * @return mixed|null + */ + public static function getLookaheadValue(Lexer $lexer) + { + if (self::isPre200($lexer)) { + // @phpstan-ignore-next-line + return $lexer->lookahead['value'] ?? null; + } + + // @phpstan-ignore-next-line + return $lexer->lookahead?->value; + } + /** * @return mixed|null */ diff --git a/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/CastTest.php b/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/CastTest.php new file mode 100644 index 00000000..59da40bb --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/CastTest.php @@ -0,0 +1,220 @@ +createTestTableForTextFixture(); + $this->createTestTableForArrayFixture(); + $this->createTestTableForNumericFixture(); + } + + protected function getStringFunctions(): array + { + return [ + 'CAST' => Cast::class, + ]; + } + + public function test_cast_text_to_integer(): void + { + $dql = 'SELECT CAST(t.text1 AS INTEGER) AS result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsTexts t WHERE t.id = 1'; + $result = $this->executeDqlQuery($dql); + static::assertSame(123, $result[0]['result']); + } + + public function test_cast_text_to_text(): void + { + $dql = 'SELECT CAST(t.text1 AS TEXT) AS result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsTexts t WHERE t.id = 1'; + $result = $this->executeDqlQuery($dql); + static::assertSame('123', $result[0]['result']); + } + + public function test_cast_text_to_boolean(): void + { + $dql = 'SELECT CAST(t.text2 AS BOOLEAN) AS result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsTexts t WHERE t.id = 1'; + $result = $this->executeDqlQuery($dql); + static::assertTrue($result[0]['result']); + } + + public function test_cast_text_to_decimal(): void + { + $dql = 'SELECT CAST(t.text1 AS DECIMAL(10, 2)) AS result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsTexts t WHERE t.id = 1'; + $result = $this->executeDqlQuery($dql); + static::assertEquals('123.00', $result[0]['result']); + } + + public function test_cast_array_to_text_array(): void + { + $dql = 'SELECT CAST(a.integerArray AS TEXT[]) AS result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsArrays a WHERE a.id = 1'; + $result = $this->executeDqlQuery($dql); + static::assertIsString($result[0]['result']); + static::assertStringContainsString('{', $result[0]['result']); + } + + public function test_cast_boolean_array_to_integer_array(): void + { + $dql = 'SELECT CAST(a.boolArray AS INTEGER[]) AS result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsArrays a WHERE a.id = 1'; + $result = $this->executeDqlQuery($dql); + static::assertIsString($result[0]['result']); + static::assertStringContainsString('{', $result[0]['result']); + } + + public function test_cast_with_where_condition(): void + { + $dql = 'SELECT t.id FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsTexts t WHERE CAST(t.text1 AS INTEGER) > 100'; + $result = $this->executeDqlQuery($dql); + static::assertNotEmpty($result); + } + + public function test_cast_in_complex_query(): void + { + $dql = 'SELECT t.id, CAST(t.text1 AS INTEGER) as casted_text FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsTexts t WHERE t.id IN (1, 2, 3)'; + $result = $this->executeDqlQuery($dql); + static::assertNotEmpty($result); + } + + public function test_cast_numeric_to_integer(): void + { + $dql = 'SELECT CAST(n.decimal1 AS INTEGER) AS result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsNumerics n WHERE n.id = 1'; + $result = $this->executeDqlQuery($dql); + static::assertSame(11, $result[0]['result']); // PostgreSQL rounds 10.5 to 11 + } + + public function test_cast_numeric_to_decimal(): void + { + $dql = 'SELECT CAST(n.integer1 AS DECIMAL(10, 2)) AS result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsNumerics n WHERE n.id = 1'; + $result = $this->executeDqlQuery($dql); + static::assertEquals('10.00', $result[0]['result']); + } + + public function test_cast_throws_with_invalid_type(): void + { + $this->expectException(DriverException::class); + $dql = "SELECT CAST('invalid' AS INVALID_TYPE) AS result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsTexts t WHERE t.id = 1"; + $this->executeDqlQuery($dql); + } + + public function test_cast_throws_with_null_input(): void + { + $this->expectException(QueryException::class); + $dql = 'SELECT CAST(NULL AS INTEGER) AS result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsTexts t WHERE t.id = 1'; + $this->executeDqlQuery($dql); + } + + public function test_cast_lowercase_array_types(): void + { + $dql = 'SELECT CAST(a.integerArray AS int[]) AS result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsArrays a WHERE a.id = 1'; + $result = $this->executeDqlQuery($dql); + static::assertIsString($result[0]['result']); + static::assertStringContainsString('{', $result[0]['result']); + } + + public function test_cast_mixed_case_array_types(): void + { + $dql = 'SELECT CAST(a.integerArray AS Text[]) AS result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsArrays a WHERE a.id = 1'; + $result = $this->executeDqlQuery($dql); + static::assertIsString($result[0]['result']); + static::assertStringContainsString('{', $result[0]['result']); + } + + public function test_cast_parameterized_decimal_array(): void + { + $dql = 'SELECT CAST(a.integerArray AS DECIMAL(10, 2)[]) AS result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsArrays a WHERE a.id = 1'; + $result = $this->executeDqlQuery($dql); + static::assertIsString($result[0]['result']); + static::assertStringContainsString('{', $result[0]['result']); + } + + private function createTestTableForTextFixture(): void + { + $tableName = 'containstexts'; + + $this->dropTestTableIfItExists($tableName); + + $fullTableName = \sprintf('%s.%s', self::DATABASE_SCHEMA, $tableName); + $sql = \sprintf(' + CREATE TABLE %s ( + id SERIAL PRIMARY KEY, + text1 TEXT, + text2 TEXT + ) + ', $fullTableName); + + $this->connection->executeStatement($sql); + + $sql = \sprintf(' + INSERT INTO %s.containstexts (text1, text2) VALUES + (\'123\', \'true\'), + (\'456\', \'false\'), + (\'789\', \'1\') + ', self::DATABASE_SCHEMA); + $this->connection->executeStatement($sql); + } + + private function createTestTableForArrayFixture(): void + { + $tableName = 'containsarrays'; + + $this->dropTestTableIfItExists($tableName); + + $fullTableName = \sprintf('%s.%s', self::DATABASE_SCHEMA, $tableName); + $sql = \sprintf(' + CREATE TABLE %s ( + id SERIAL PRIMARY KEY, + textarray TEXT[], + smallintarray SMALLINT[], + integerarray INTEGER[], + bigintarray BIGINT[], + boolarray BOOLEAN[] + ) + ', $fullTableName); + + $this->connection->executeStatement($sql); + + $sql = \sprintf(' + INSERT INTO %s.containsarrays (textarray, smallintarray, integerarray, bigintarray, boolarray) VALUES + (\'{"apple", "banana", "cherry"}\', \'{1, 2, 3}\', \'{10, 20, 30}\', \'{100, 200, 300}\', \'{true, false, true}\'), + (\'{"dog", "cat", "bird"}\', \'{4, 5, 6}\', \'{40, 50, 60}\', \'{400, 500, 600}\', \'{false, true, false}\') + ', self::DATABASE_SCHEMA); + $this->connection->executeStatement($sql); + } + + private function createTestTableForNumericFixture(): void + { + $tableName = 'containsnumerics'; + + $this->dropTestTableIfItExists($tableName); + + $fullTableName = \sprintf('%s.%s', self::DATABASE_SCHEMA, $tableName); + $sql = \sprintf(' + CREATE TABLE %s ( + id SERIAL PRIMARY KEY, + integer1 INTEGER, + integer2 INTEGER, + bigint1 BIGINT, + bigint2 BIGINT, + decimal1 DECIMAL, + decimal2 DECIMAL + ) + ', $fullTableName); + + $this->connection->executeStatement($sql); + + $sql = \sprintf(' + INSERT INTO %s.containsnumerics (integer1, integer2, bigint1, bigint2, decimal1, decimal2) VALUES + (10, 20, 1000, 2000, 10.5, 20.5) + ', self::DATABASE_SCHEMA); + $this->connection->executeStatement($sql); + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/CastTest.php b/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/CastTest.php index e00f7660..cb8e2201 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/CastTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/CastTest.php @@ -25,6 +25,10 @@ protected function getExpectedSqlStatements(): array 'cast as jsonb' => 'SELECT cast(c0_.text1 as JSONB) AS sclr_0 FROM ContainsTexts c0_', 'cast as boolean' => 'SELECT cast(c0_.text1 as BOOLEAN) AS sclr_0 FROM ContainsTexts c0_', 'cast with precision' => 'SELECT cast(c0_.text1 as DECIMAL(10, 2)) AS sclr_0 FROM ContainsTexts c0_', + 'cast as integer array' => 'SELECT cast(c0_.text1 as INTEGER[]) AS sclr_0 FROM ContainsTexts c0_', + 'cast as text array' => 'SELECT cast(c0_.text1 as TEXT[]) AS sclr_0 FROM ContainsTexts c0_', + 'cast as boolean array' => 'SELECT cast(c0_.text1 as BOOLEAN[]) AS sclr_0 FROM ContainsTexts c0_', + 'cast as decimal array' => 'SELECT cast(c0_.text1 as DECIMAL(10, 2)[]) AS sclr_0 FROM ContainsTexts c0_', ]; } @@ -37,6 +41,10 @@ protected function getDqlStatements(): array 'cast as jsonb' => \sprintf('SELECT CAST(e.text1 AS JSONB) FROM %s e', ContainsTexts::class), 'cast as boolean' => \sprintf('SELECT CAST(e.text1 AS BOOLEAN) FROM %s e', ContainsTexts::class), 'cast with precision' => \sprintf('SELECT CAST(e.text1 AS DECIMAL(10, 2)) FROM %s e', ContainsTexts::class), + 'cast as integer array' => \sprintf('SELECT CAST(e.text1 AS INTEGER[]) FROM %s e', ContainsTexts::class), + 'cast as text array' => \sprintf('SELECT CAST(e.text1 AS TEXT[]) FROM %s e', ContainsTexts::class), + 'cast as boolean array' => \sprintf('SELECT CAST(e.text1 AS BOOLEAN[]) FROM %s e', ContainsTexts::class), + 'cast as decimal array' => \sprintf('SELECT CAST(e.text1 AS DECIMAL(10, 2)[]) FROM %s e', ContainsTexts::class), ]; } } From 8273d1925281b62f674113e1f0f7a5aadc39be1c Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Mon, 4 Aug 2025 01:52:11 +0300 Subject: [PATCH 2/3] no message --- src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Cast.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Cast.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Cast.php index df25d94c..dfd6b4da 100644 --- a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Cast.php +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Cast.php @@ -67,11 +67,13 @@ public function parse(Parser $parser): void $nextTokenValue = DoctrineLexer::getLookaheadValue($lexer); if ($nextTokenValue === '[') { // Consume the '[' token + /** @phpstan-ignore-next-line */ $parser->match($shouldUseLexer ? Lexer::T_NONE : TokenType::T_NONE); // Check for the closing ']' token $nextTokenValue = DoctrineLexer::getLookaheadValue($lexer); if ($nextTokenValue === ']') { + /** @phpstan-ignore-next-line */ $parser->match($shouldUseLexer ? Lexer::T_NONE : TokenType::T_NONE); $type .= '[]'; } From b017002168fcdbd0914a0b5badac166306d84fa6 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Mon, 4 Aug 2025 01:53:08 +0300 Subject: [PATCH 3/3] no message --- ci/phpstan/baselines/lexer-variations.neon | 1 + src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Cast.php | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/ci/phpstan/baselines/lexer-variations.neon b/ci/phpstan/baselines/lexer-variations.neon index de961a18..968e0d2d 100644 --- a/ci/phpstan/baselines/lexer-variations.neon +++ b/ci/phpstan/baselines/lexer-variations.neon @@ -8,6 +8,7 @@ parameters: - '#Fetching deprecated class constant T_COMMA of class Doctrine\\ORM\\Query\\Lexer#' - '#Fetching deprecated class constant T_ORDER of class Doctrine\\ORM\\Query\\Lexer#' - '#Fetching deprecated class constant T_AS of class Doctrine\\ORM\\Query\\Lexer#' + - '#Fetching deprecated class constant T_NONE of class Doctrine\\ORM\\Query\\Lexer#' - '#Fetching deprecated class constant T_DISTINCT of class Doctrine\\ORM\\Query\\Lexer#' - '#Parameter \#1 \$token of method Doctrine\\ORM\\Query\\Parser::match\(\) expects Doctrine\\ORM\\Query\\TokenType, mixed given.#' - '#Parameter \#1 \$type of method Doctrine\\Common\\Lexer\\AbstractLexer::isNextToken\(\) expects Doctrine\\ORM\\Query\\TokenType, mixed given.#' diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Cast.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Cast.php index dfd6b4da..df25d94c 100644 --- a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Cast.php +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Cast.php @@ -67,13 +67,11 @@ public function parse(Parser $parser): void $nextTokenValue = DoctrineLexer::getLookaheadValue($lexer); if ($nextTokenValue === '[') { // Consume the '[' token - /** @phpstan-ignore-next-line */ $parser->match($shouldUseLexer ? Lexer::T_NONE : TokenType::T_NONE); // Check for the closing ']' token $nextTokenValue = DoctrineLexer::getLookaheadValue($lexer); if ($nextTokenValue === ']') { - /** @phpstan-ignore-next-line */ $parser->match($shouldUseLexer ? Lexer::T_NONE : TokenType::T_NONE); $type .= '[]'; }