Skip to content

feat: add support for formatting functions to_char, to_date, to_number, to_timestamp #386

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ This package provides comprehensive Doctrine support for PostgreSQL features:
- Special aggregates (`any_value`, `xmlagg`)
- **Mathematical/Arithmetic Functions**
- **Range Functions**
- **Data Type Formatting Functions**

Full documentation:
- [Available Types](docs/AVAILABLE-TYPES.md)
Expand Down
4 changes: 4 additions & 0 deletions docs/AVAILABLE-FUNCTIONS-AND-OPERATORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,12 @@
| starts_with | STARTS_WITH | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\StartsWith` |
| string_agg | STRING_AGG | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\StringAgg` |
| string_to_array | STRING_TO_ARRAY | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\StringToArray` |
| to_char | TO_CHAR | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToChar` |
| to_date | TO_DATE | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToDate` |
| to_json | TO_JSON | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToJson` |
| to_jsonb | TO_JSONB | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToJsonb` |
| to_number | TO_NUMBER | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToNumber` |
| to_timestamp | TO_TIMESTAMP | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToTimestamp` |
| to_tsquery | TO_TSQUERY | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToTsquery` |
| to_tsvector | TO_TSVECTOR | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToTsvector` |
| trunc | TRUNC | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Trunc` |
Expand Down
6 changes: 6 additions & 0 deletions docs/INTEGRATING-WITH-DOCTRINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,12 @@ $configuration->addCustomStringFunction('JSONB_OBJECT_AGG', MartinGeorgiev\Doctr
$configuration->addCustomStringFunction('STRING_AGG', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\StringAgg::class);
$configuration->addCustomStringFunction('XML_AGG', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\XmlAgg::class);

# data type formatting functions
$configuration->addCustomStringFunction('TO_CHAR', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToChar::class);
$configuration->addCustomStringFunction('TO_DATE', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToDate::class);
$configuration->addCustomStringFunction('TO_NUMBER', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToNumber::class);
$configuration->addCustomStringFunction('TO_TIMESTAMP', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToTimestamp::class);

$em = EntityManager::create($dbParams, $configuration);
```

Expand Down
6 changes: 6 additions & 0 deletions docs/INTEGRATING-WITH-LARAVEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,12 @@ return [
'JSONB_OBJECT_AGG' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbObjectAgg::class,
'STRING_AGG' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\StringAgg::class,
'XML_AGG' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\XmlAgg::class,

# data type formatting functions
'TO_CHAR' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToChar::class,
'TO_DATE' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToDate::class,
'TO_NUMBER' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToNumber::class,
'TO_TIMESTAMP' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToTimestamp::class,
],

...
Expand Down
6 changes: 6 additions & 0 deletions docs/INTEGRATING-WITH-SYMFONY.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,4 +241,10 @@ doctrine:
JSONB_OBJECT_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbObjectAgg
STRING_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\StringAgg
XML_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\XmlAgg

# data type formatting functions
TO_CHAR: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToChar
TO_DATE: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToDate
TO_NUMBER: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToNumber
TO_TIMESTAMP: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToTimestamp
```
3 changes: 3 additions & 0 deletions fixtures/MartinGeorgiev/Doctrine/Entity/ContainsDates.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,7 @@ class ContainsDates extends Entity

#[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE)]
public \DateTimeImmutable $datetimetz2;

#[ORM\Column(type: Types::DATEINTERVAL)]
public \DateTimeImmutable $dateinterval1;
}
21 changes: 21 additions & 0 deletions src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/ToChar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

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

/**
* Implementation of PostgreSQL to_char().
*
* @see https://www.postgresql.org/docs/17/functions-formatting.html
* @since 3.3.0
*/
class ToChar extends BaseFunction
{
protected function customizeFunction(): void
{
$this->setFunctionPrototype('to_char(%s, %s)');
$this->addNodeMapping('ArithmeticFactor');
$this->addNodeMapping('StringPrimary');
}
}
21 changes: 21 additions & 0 deletions src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/ToDate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

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

/**
* Implementation of PostgreSQL to_date().
*
* @see https://www.postgresql.org/docs/17/functions-formatting.html
* @since 3.3.0
*/
class ToDate extends BaseFunction
{
protected function customizeFunction(): void
{
$this->setFunctionPrototype('to_date(%s, %s)');
$this->addNodeMapping('StringPrimary');
$this->addNodeMapping('StringPrimary');
}
}
21 changes: 21 additions & 0 deletions src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/ToNumber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

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

/**
* Implementation of PostgreSQL to_number().
*
* @see https://www.postgresql.org/docs/17/functions-formatting.html
* @since 3.3.0
*/
class ToNumber extends BaseFunction
{
protected function customizeFunction(): void
{
$this->setFunctionPrototype('to_number(%s, %s)');
$this->addNodeMapping('StringPrimary');
$this->addNodeMapping('StringPrimary');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

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

/**
* Implementation of PostgreSQL to_timestamp().
*
* @see https://www.postgresql.org/docs/17/functions-formatting.html
* @since 3.3.0
*/
class ToTimestamp extends BaseFunction
{
protected function customizeFunction(): void
{
$this->setFunctionPrototype('to_timestamp(%s, %s)');
$this->addNodeMapping('StringPrimary');
$this->addNodeMapping('StringPrimary');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ protected function createTestTableForDateFixture(): void
time1 TIME,
time2 TIME,
datetimetz1 TIMESTAMPTZ,
datetimetz2 TIMESTAMPTZ
datetimetz2 TIMESTAMPTZ,
dateinterval1 INTERVAL
)
', $fullTableName);

Expand All @@ -43,8 +44,8 @@ protected function createTestTableForDateFixture(): void
protected function insertTestDataForDateFixture(): void
{
$sql = \sprintf('
INSERT INTO %s.containsdates (date1, date2, datetime1, datetime2, time1, time2, datetimetz1, datetimetz2) VALUES
(\'2023-06-15\', \'2023-06-16\', \'2023-06-15 10:30:00\', \'2023-06-16 11:45:00\', \'10:30:00\', \'11:45:00\', \'2023-06-15 10:30:00+00\', \'2023-06-16 11:45:00+00\')
INSERT INTO %s.containsdates (date1, date2, datetime1, datetime2, time1, time2, datetimetz1, datetimetz2, dateinterval1) VALUES
(\'2023-06-15\', \'2023-06-16\', \'2023-06-15 10:30:00\', \'2023-06-16 11:45:00\', \'10:30:00\', \'11:45:00\', \'2023-06-15 10:30:00+00\', \'2023-06-16 11:45:00+00\', \'15h 2m 12s\')
', self::DATABASE_SCHEMA);
$this->connection->executeStatement($sql);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php

declare(strict_types=1);

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

use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Exception\DriverException;
use Doctrine\ORM\Query\QueryException;
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToChar;
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToTimestamp;
use Tests\Integration\MartinGeorgiev\TestCase;

class ToCharTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$this->createTestSchema();
$this->createTestTableForDateFixture();
$this->createTestTableForNumericFixture();
}

protected function getStringFunctions(): array
{
return [
'to_char' => ToChar::class,
'to_timestamp' => ToTimestamp::class,
];
}

public function test_tochar_for_timestamp(): void
{
$dql = "SELECT to_char(t.datetimetz1, 'HH12:MI:SS') AS result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsDates t WHERE t.id = 1";
$result = $this->executeDqlQuery($dql);
static::assertSame('10:30:00', $result[0]['result']);
}

public function test_tochar_for_interval(): void
{
$dql = "SELECT to_char(t.dateinterval1, 'HH24:MI:SS') AS result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsDates t WHERE t.id = 1";
$result = $this->executeDqlQuery($dql);
static::assertSame('15:02:12', $result[0]['result']);
}

public function test_tochar_for_numeric(): void
{
$dql = "SELECT to_char(t.decimal1, '999D99S') AS result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsNumerics t WHERE t.id = 1";
$result = $this->executeDqlQuery($dql);
static::assertSame('125.80-', $result[0]['result']);
}

public function test_tochar_for_numeric_literal(): void
{
$dql = "SELECT to_char(125.80, '999D99S') AS result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsNumerics t WHERE t.id = 1";
$result = $this->executeDqlQuery($dql);
static::assertSame('125.80+', $result[0]['result']);
}

public function test_tochar_for_numeric_literal_negative(): void
{
$dql = "SELECT to_char(-125.80, '999D99S') AS result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsNumerics t WHERE t.id = 1";
$result = $this->executeDqlQuery($dql);
static::assertSame('125.80-', $result[0]['result']);
}

public function test_tochar_with_subfunction(): void
{
$dql = "SELECT to_char(to_timestamp('05 Dec 2000 at 11:55 and 32 seconds', 'DD Mon YYYY tt HH24:MI ttt SS ttttttt'), 'HH24:MI:SS') AS result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsDates t WHERE t.id = 1";
$result = $this->executeDqlQuery($dql);
static::assertSame('11:55:32', $result[0]['result']);
}

public function test_tochar_throws_with_invalid_input_type(): void
{
$this->expectException(DriverException::class);
$dql = "SELECT to_char('can only be timestamp, interval or numeric, never a string', 'DD Mon YYYY') AS result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsDates t WHERE t.id = 1";
$this->executeDqlQuery($dql);
}

public function test_tochar_throws_with_invalid_format(): void
{
$this->expectException(Exception::class);
$dql = "SELECT to_char(t.decimal1, 'invalid_format') FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsNumerics t WHERE t.id = 1";
$this->executeDqlQuery($dql);
}

public function test_tochar_throws_with_unsupported_null_input(): void
{
$this->expectException(QueryException::class);
$dql = "SELECT to_char(NULL, '999D99S') AS result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsNumerics t WHERE t.id = 1";
$this->executeDqlQuery($dql);
}

private function createTestTableForDateFixture(): void
{
$tableName = 'containsdates';

$this->dropTestTableIfItExists($tableName);

$fullTableName = \sprintf('%s.%s', self::DATABASE_SCHEMA, $tableName);
$sql = \sprintf('
CREATE TABLE %s (
id SERIAL PRIMARY KEY,
date1 DATE,
date2 DATE,
datetime1 TIMESTAMP,
datetime2 TIMESTAMP,
time1 TIME,
time2 TIME,
datetimetz1 TIMESTAMPTZ,
datetimetz2 TIMESTAMPTZ,
dateinterval1 INTERVAL
)
', $fullTableName);

$this->connection->executeStatement($sql);

$sql = \sprintf('
INSERT INTO %s.containsdates (date1, date2, datetime1, datetime2, time1, time2, datetimetz1, datetimetz2, dateinterval1) VALUES
(\'2023-06-15\', \'2023-06-16\', \'2023-06-15 10:30:00\', \'2023-06-16 11:45:00\', \'10:30:00\', \'11:45:00\', \'2023-06-15 10:30:00+00\', \'2023-06-16 11:45:00+00\', \'15h 2m 12s\')
', 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, -125.8, 20.5)
', self::DATABASE_SCHEMA);
$this->connection->executeStatement($sql);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

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

use Doctrine\DBAL\Exception\DriverException;
use Doctrine\ORM\Query\QueryException;
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToDate;

class ToDateTest extends TextTestCase
{
protected function getStringFunctions(): array
{
return [
'to_date' => ToDate::class,
];
}

public function test_todate(): void
{
$dql = "SELECT to_date('05 Dec 2000', 'DD Mon YYYY') as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsTexts t WHERE t.id = 1";
$result = $this->executeDqlQuery($dql);
static::assertSame('2000-12-05', $result[0]['result']);
}

public function test_todate_throws_with_invalid_input(): void
{
$this->expectException(DriverException::class);
$dql = "SELECT to_date('invalid_date', 'DD Mon YYYY') as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsTexts t WHERE t.id = 1";
$this->executeDqlQuery($dql);
}

public function test_todate_with_invalid_format(): void
{
$dql = "SELECT to_date('05 Dec 2000', 'invalid_format') as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsTexts t WHERE t.id = 1";
$result = $this->executeDqlQuery($dql);
static::assertSame('2005-01-01', $result[0]['result']);
}

public function test_todate_throws_with_unsupported_format_type(): void
{
$this->expectException(QueryException::class);
$dql = "SELECT to_date('05 Dec 2000', 1) as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsTexts t WHERE t.id = 1";
$this->executeDqlQuery($dql);
}

public function test_todate_throws_with_unsupported_null_input(): void
{
$this->expectException(QueryException::class);
$dql = "SELECT to_date(null, 'DD Mon YYYY') as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsTexts t WHERE t.id = 1";
$this->executeDqlQuery($dql);
}
}
Loading
Loading