Skip to content

Commit

Permalink
Add Doctrine colum types for mapping brick/date-time instances
Browse files Browse the repository at this point in the history
  • Loading branch information
corphi authored and juliusstoerrle committed Dec 6, 2024
1 parent e00c522 commit fc1604f
Show file tree
Hide file tree
Showing 7 changed files with 684 additions and 0 deletions.
4 changes: 4 additions & 0 deletions webservice/config/packages/doctrine.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ doctrine:
#server_version: '16'

profiling_collect_backtrace: '%kernel.debug%'
types:
instant: 'App\Infrastructure\Doctrine\DBAL\Type\InstantType'
local_datetime: 'App\Infrastructure\Doctrine\DBAL\Type\LocalDateTimeType'
local_time: 'App\Infrastructure\Doctrine\DBAL\Type\LocalTimeType'
use_savepoints: true
orm:
auto_generate_proxy_classes: true
Expand Down
78 changes: 78 additions & 0 deletions webservice/src/Infrastructure/Doctrine/DBAL/Type/InstantType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

declare(strict_types=1);

namespace App\Infrastructure\Doctrine\DBAL\Type;

use Brick\DateTime\Instant;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\Type;
use function preg_match;
use function str_pad;

final class InstantType extends Type
{
public const NAME = 'instant';

/**
* @template T
*
* @param T $value
*
* @return (T is null ? null : string)
*/
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
{
if (null === $value) {
return null;
}

if (!$value instanceof Instant) {
throw ConversionException::conversionFailedInvalidType($value, $this->getName(), ['null', Instant::class]);
}

if ($value->getEpochSecond() >= 10_000_000_000 || $value->getEpochSecond() <= -10_000_000_000) {
throw ConversionException::conversionFailedFormat($value->toDecimal(), self::NAME, '-10M to 10M seconds around Unix epoch.');
}

return $value->toDecimal();
}

/**
* @template T
*
* @param T $value
*
* @return (T is null ? null : Instant)
*/
public function convertToPHPValue($value, AbstractPlatform $platform): ?Instant
{
if (null === $value) {
return null;
}

$matches = [];
if (1 === preg_match('@\A(\d{1,10})(?:\.(\d{0,9}))?\z@', (string) $value, $matches)) {
return Instant::of(
(int) $matches[1],
isset($matches[2]) ? (int) str_pad($matches[2], 9, '0') : 0
);
}

throw ConversionException::conversionFailedFormat($value, $this->getName(), '/\d{1,10}(\.\d{0,9})?/');
}

public function getName(): string
{
return self::NAME;
}

public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
{
return $platform->getDecimalTypeDeclarationSQL([
'precision' => 19,
'scale' => 9,
]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

declare(strict_types=1);

namespace App\Infrastructure\Doctrine\DBAL\Type;

use Brick\DateTime\LocalDateTime;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\Type;
use Throwable;

final class LocalDateTimeType extends Type
{
public const NAME = 'local_datetime';

/**
* @template T
*
* @param T $value
*
* @return (T is null ? null : string)
*/
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
{
if (null === $value) {
return null;
}

if ($value instanceof LocalDateTime) {
return $value->toISOString();
}

throw ConversionException::conversionFailedInvalidType($value, $this->getName(), ['null', LocalDateTime::class]);
}

/**
* @template T
*
* @param T $value
*
* @return (T is null ? null : LocalDateTime)
*/
public function convertToPHPValue($value, AbstractPlatform $platform): ?LocalDateTime
{
if (null === $value) {
return null;
}

try {
return LocalDateTime::parse((string) $value);
} catch (Throwable $ex) {
throw ConversionException::conversionFailedFormat($value, $this->getName(), 'ISO 8601', $ex);
}
}

public function getName(): string
{
return self::NAME;
}

public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
{
return $platform->getStringTypeDeclarationSQL([
'length' => 1 + (6 + 1 + 2 + 1 + 2) + 1 + 8 + 1 + 9,
]);
}
}
68 changes: 68 additions & 0 deletions webservice/src/Infrastructure/Doctrine/DBAL/Type/LocalTimeType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

declare(strict_types=1);

namespace App\Infrastructure\Doctrine\DBAL\Type;

use Brick\DateTime\LocalTime;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\Type;
use Throwable;

final class LocalTimeType extends Type
{
public const NAME = 'local_time';

/**
* @template T
*
* @param T $value
*
* @return (T is null ? null : string)
*/
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
{
if (null === $value) {
return null;
}

if ($value instanceof LocalTime) {
return $value->toISOString();
}

throw ConversionException::conversionFailedInvalidType($value, $this->getName(), ['null', LocalTime::class]);
}

/**
* @template T
*
* @param T $value
*
* @return (T is null ? null : LocalTime)
*/
public function convertToPHPValue($value, AbstractPlatform $platform): ?LocalTime
{
if (null === $value) {
return null;
}

try {
return LocalTime::parse((string) $value);
} catch (Throwable $ex) {
throw ConversionException::conversionFailedFormat($value, $this->getName(), 'ISO 8601', $ex);
}
}

public function getName(): string
{
return self::NAME;
}

public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
{
return $platform->getStringTypeDeclarationSQL([
'length' => 8 + 1 + 9,
]);
}
}
160 changes: 160 additions & 0 deletions webservice/tests/Infrastructure/Doctrine/DBAL/Type/InstantTypeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
<?php

declare(strict_types=1);

namespace App\Tests\Infrastructure\Doctrine\DBAL\Type;

use App\Infrastructure\Doctrine\DBAL\Type\InstantType;
use Brick\DateTime\Instant;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Types\ConversionException;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Small;
use PHPUnit\Framework\TestCase;

#[CoversClass(InstantType::class)]
#[Small()]
final class InstantTypeTest extends TestCase
{
/**
* @return array{0: ?string, 1: ?Instant}[]
*/
public static function convertToDatabaseValueProvider(): array
{
return [
[null, null],
['0', Instant::epoch()],
['1234567890.123456789', Instant::of(1234567890, 123456789)],
['-123456789.123456789', Instant::of(-123456789, 123456789)],
['9999999999.999999999', Instant::of(9_999_999_999, 999_999_999)],
['-9999999999.999999999', Instant::of(-9_999_999_999, 999_999_999)],
];
}

#[DataProvider('convertToDatabaseValueProvider')]
public function testConvertToDatabaseValue(?string $expected, ?Instant $value): void
{
$platform = $this->createMock(AbstractPlatform::class);
$type = new InstantType();

$result = $type->convertToDatabaseValue($value, $platform);

self::assertSame($expected, $result);
}

/**
* @return array{0: mixed}[]
*/
public static function convertToDatabaseValueExceptionProvider(): array
{
return [
[true],
[Instant::min()],
[Instant::of(-10_000_000_000, 0)],
[Instant::of(10_000_000_000, 0)],
[Instant::max()],
];
}

#[DataProvider('convertToDatabaseValueExceptionProvider')]
public function testConvertToDatabaseValueException(mixed $value): void
{
$platform = $this->createMock(AbstractPlatform::class);
$type = new InstantType();

self::expectException(ConversionException::class);

$type->convertToDatabaseValue($value, $platform);
}

/**
* @return array{0: ?Instant, 1: mixed}[]
*/
public static function convertToPhpValueProvider(): array
{
return [
[null, null],
[Instant::epoch(), 0],
[Instant::epoch(), 0.0],
[Instant::epoch(), '0'],
[Instant::epoch(), '0.'],
[Instant::epoch(), '0.0'],
[Instant::of(42, 125000000), 42.125],
[Instant::of(4711, 250000000), 4711.25],
[Instant::of(0, 123456789), '0.123456789'],
[Instant::of(123456789, 0), '123456789.0'],
[Instant::of(123456, 789000000), '123456.789'],
];
}

#[DataProvider('convertToPhpValueProvider')]
public function testConvertToPhpValue(?Instant $expected, mixed $value): void
{
$platform = $this->createMock(AbstractPlatform::class);
$type = new InstantType();

$result = $type->convertToPHPValue($value, $platform);

if (null === $expected) {
self::assertNull($result);
} else {
self::assertNotNull($result);
self::assertTrue($expected->isEqualTo($result));
}
}

/**
* @return array{0: mixed}[]
*/
public static function convertToPhpValueExceptionProvider(): array
{
return [
[''],
['.0'],
['abcdef'],
];
}

#[DataProvider('convertToPhpValueExceptionProvider')]
public function testConvertToPhpValueException(mixed $value): void
{
$platform = $this->createMock(AbstractPlatform::class);
$type = new InstantType();

self::expectException(ConversionException::class);

$type->convertToPHPValue($value, $platform);
}

public function testGetName(): void
{
$type = new InstantType();

self::assertSame(InstantType::NAME, $type->getName());
}

/**
* @return array{0: string, 1: array<string, mixed>, 2: AbstractPlatform}[]
*/
public static function getSqlDeclarationProvider(): array
{
$mysql = new MySQLPlatform();

return [
['NUMERIC(19, 9)', [], $mysql],
];
}

/**
* @param array<string, mixed> $column
*/
#[DataProvider('getSqlDeclarationProvider')]
public function testGetSqlDeclaration(string $expected, array $column, AbstractPlatform $platform): void
{
$type = new InstantType();

self::assertSame($expected, $type->getSQLDeclaration($column, $platform));
}
}
Loading

0 comments on commit fc1604f

Please sign in to comment.