diff --git a/README.md b/README.md index 7b8c3b68b18748b4d783f0ffbb3f3557a9288800..2f6858551ce4b1de3bbee254e49b50ae20e4378d 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,6 @@ Take a look at how a `LIKE` condition could be implemented. It maps to a `LIKE` ```php use Grifart\Tables\Expression; use Grifart\Tables\Types\TextType; -use function Grifart\Tables\Types\mapToDatabase; final class IsLike implements Condition { @@ -166,7 +165,7 @@ final class IsLike implements Condition return new \Dibi\Expression( '? LIKE ?', $this->expression->toSql(), - mapToDatabase($this->pattern, TextType::varchar()), + TextType::varchar()->toDatabase($this->pattern), ); } } @@ -434,6 +433,12 @@ You can map values to an array via the `ArrayType`. This formats the items using $dateArrayType = ArrayType::of(new DateType()); ``` +Note that while arrays in PostgreSQL can contain `NULL`, `ArrayType` rejects null values unless they are explicitly allowed: + +```php +$nullableDateArrayType = ArrayType::of(NullableType::of(new DateType())); +``` + ##### Enum types You can map native PHP enumerations to PostgreSQL's enums using the `EnumType`. This requires that the provided enum is a `\BackedEnum`, and serializes it to its backing value: @@ -482,3 +487,24 @@ $moneyType = new class extends CompositeType { } }; ``` + +Similarly to arrays, in PostgreSQL, composite type fields are always nullable. However, `CompositeType` rejects null values except in positions where they are explicitly allowed: + +```php +$nullableDateArrayType = ArrayType::of(NullableType::of(new DateType())); +``` + +```php +$moneyType = new class extends CompositeType { + public function __construct() + { + parent::__construct( + new Database\NamedType(new Database\Identifier('public', 'money')), + NullableType::of(DecimalType::decimal()), + new CurrencyType(), + ); + } + + // ... +} +``` diff --git a/src/Column.php b/src/Column.php index 59dd95cf9a348b67480d1b5b1d3d2d998b62de52..2ebd5a55f9b82a1351d8e976d6facbef10e377db 100644 --- a/src/Column.php +++ b/src/Column.php @@ -6,6 +6,7 @@ namespace Grifart\Tables; use Dibi\Expression as DibiExpression; use Grifart\Tables\Database\Identifier; +use Grifart\Tables\Types\NullableType; /** * @template TableType of Table @@ -59,6 +60,10 @@ final class Column extends ExpressionWithShorthands $location = new Identifier($table::getSchema(), $table::getTableName(), $column->getName()); $resolvedType = $typeResolver->resolveType($column->getType(), $location); + if ($column->isNullable()) { + $resolvedType = NullableType::of($resolvedType); + } + /** @var Column<FromTableType, mixed> $column */ $column = new self($column, $resolvedType); return $column; diff --git a/src/Conditions/IsEqualTo.php b/src/Conditions/IsEqualTo.php index 2612c43e96e62ec26f5d29bd7a206aed8fdea3ae..7a35c39d4c82ac47121a53af40bace640f7956cf 100644 --- a/src/Conditions/IsEqualTo.php +++ b/src/Conditions/IsEqualTo.php @@ -6,7 +6,6 @@ namespace Grifart\Tables\Conditions; use Dibi\Expression as DibiExpression; use Grifart\Tables\Expression; -use function Grifart\Tables\Types\mapToDatabase; /** * @template ValueType @@ -27,7 +26,7 @@ final class IsEqualTo implements Condition return new DibiExpression( '? = ?', $this->expression->toSql(), - mapToDatabase($this->value, $this->expression->getType()), + $this->expression->getType()->toDatabase($this->value), ); } } diff --git a/src/Conditions/IsGreaterThan.php b/src/Conditions/IsGreaterThan.php index 38b80e18cda0a596f7d0517ce18e78b1da4ab9b9..90f6a3400b169eb57ea34330a8495efe1b2324b9 100644 --- a/src/Conditions/IsGreaterThan.php +++ b/src/Conditions/IsGreaterThan.php @@ -6,7 +6,6 @@ namespace Grifart\Tables\Conditions; use Dibi\Expression as DibiExpression; use Grifart\Tables\Expression; -use function Grifart\Tables\Types\mapToDatabase; /** * @template ValueType @@ -27,7 +26,7 @@ final class IsGreaterThan implements Condition return new DibiExpression( '? > ?', $this->expression->toSql(), - mapToDatabase($this->value, $this->expression->getType()), + $this->expression->getType()->toDatabase($this->value), ); } } diff --git a/src/Conditions/IsGreaterThanOrEqualTo.php b/src/Conditions/IsGreaterThanOrEqualTo.php index 447dd6a20c30ee71cbcbc3fd81741bd3b90b49e8..8aedc9ff3e6533645ed70ad8df42e4249bf74317 100644 --- a/src/Conditions/IsGreaterThanOrEqualTo.php +++ b/src/Conditions/IsGreaterThanOrEqualTo.php @@ -6,7 +6,6 @@ namespace Grifart\Tables\Conditions; use Dibi\Expression as DibiExpression; use Grifart\Tables\Expression; -use function Grifart\Tables\Types\mapToDatabase; /** * @template ValueType @@ -27,7 +26,7 @@ final class IsGreaterThanOrEqualTo implements Condition return new DibiExpression( '? >= ?', $this->expression->toSql(), - mapToDatabase($this->value, $this->expression->getType()), + $this->expression->getType()->toDatabase($this->value), ); } } diff --git a/src/Conditions/IsIn.php b/src/Conditions/IsIn.php index b9bb5caf6e9f0332221a254fe84dd3ce58bbce8b..760401a09a58cdea873e420a28950aec8be2cfcd 100644 --- a/src/Conditions/IsIn.php +++ b/src/Conditions/IsIn.php @@ -6,7 +6,6 @@ namespace Grifart\Tables\Conditions; use Dibi\Expression as DibiExpression; use Grifart\Tables\Expression; -use function Grifart\Tables\Types\mapToDatabase; use function Phun\map; /** @@ -30,7 +29,7 @@ final class IsIn implements Condition $this->expression->toSql(), map( $this->values, - fn(mixed $value) => mapToDatabase($value, $this->expression->getType()), + fn(mixed $value) => $this->expression->getType()->toDatabase($value), ), ); } diff --git a/src/Conditions/IsLesserThan.php b/src/Conditions/IsLesserThan.php index 515caec4e2f36d39f18cc55e441c5e5591fd6cff..4f220daadd76546397619bdb309211ca46d519e4 100644 --- a/src/Conditions/IsLesserThan.php +++ b/src/Conditions/IsLesserThan.php @@ -6,7 +6,6 @@ namespace Grifart\Tables\Conditions; use Dibi\Expression as DibiExpression; use Grifart\Tables\Expression; -use function Grifart\Tables\Types\mapToDatabase; /** * @template ValueType @@ -27,7 +26,7 @@ final class IsLesserThan implements Condition return new DibiExpression( '? < ?', $this->expression->toSql(), - mapToDatabase($this->value, $this->expression->getType()), + $this->expression->getType()->toDatabase($this->value), ); } } diff --git a/src/Conditions/IsLesserThanOrEqualTo.php b/src/Conditions/IsLesserThanOrEqualTo.php index 13e9a9327358233bd8454915508d89855521951e..59abbcae70b68c520bfd3edf2f97593ab2011825 100644 --- a/src/Conditions/IsLesserThanOrEqualTo.php +++ b/src/Conditions/IsLesserThanOrEqualTo.php @@ -6,7 +6,6 @@ namespace Grifart\Tables\Conditions; use Dibi\Expression as DibiExpression; use Grifart\Tables\Expression; -use function Grifart\Tables\Types\mapToDatabase; /** * @template ValueType @@ -27,7 +26,7 @@ final class IsLesserThanOrEqualTo implements Condition return new DibiExpression( '? <= ?', $this->expression->toSql(), - mapToDatabase($this->value, $this->expression->getType()), + $this->expression->getType()->toDatabase($this->value), ); } } diff --git a/src/Conditions/IsNotEqualTo.php b/src/Conditions/IsNotEqualTo.php index c071604a93c396e1af2adf59a4ff0b5d5cdcfc08..6f2645e85ba7057257ccfd39ccc403cda840cea2 100644 --- a/src/Conditions/IsNotEqualTo.php +++ b/src/Conditions/IsNotEqualTo.php @@ -6,7 +6,6 @@ namespace Grifart\Tables\Conditions; use Dibi\Expression as DibiExpression; use Grifart\Tables\Expression; -use function Grifart\Tables\Types\mapToDatabase; /** * @template ValueType @@ -27,7 +26,7 @@ final class IsNotEqualTo implements Condition return new DibiExpression( '? != ?', $this->expression->toSql(), - mapToDatabase($this->value, $this->expression->getType()), + $this->expression->getType()->toDatabase($this->value), ); } } diff --git a/src/Conditions/IsNotIn.php b/src/Conditions/IsNotIn.php index f2bdd34886894baade269acb4e7f5b31cdd60309..11f0986f098247d8fc338854bb758921fecdf459 100644 --- a/src/Conditions/IsNotIn.php +++ b/src/Conditions/IsNotIn.php @@ -6,7 +6,6 @@ namespace Grifart\Tables\Conditions; use Dibi\Expression as DibiExpression; use Grifart\Tables\Expression; -use function Grifart\Tables\Types\mapToDatabase; use function Phun\map; /** @@ -30,7 +29,7 @@ final class IsNotIn implements Condition $this->expression->toSql(), map( $this->values, - fn(mixed $value) => mapToDatabase($value, $this->expression->getType()), + fn(mixed $value) => $this->expression->getType()->toDatabase($value), ), ); } diff --git a/src/TableManager.php b/src/TableManager.php index 3938260d30546c4f5e61664f995c9ea8a6775ab7..e17e9ad2af5f6a2f74bd418dc2806989f430e9ec 100644 --- a/src/TableManager.php +++ b/src/TableManager.php @@ -40,7 +40,7 @@ final class TableManager 'INTO %n.%n', $table::getSchema(), $table::getTableName(), mapWithKeys( $changes->getModifications(), - static fn(string $columnName, mixed $value) => $value !== null ? $table->getTypeOf($columnName)->toDatabase($value) : null, + static fn(string $columnName, mixed $value) => $table->getTypeOf($columnName)->toDatabase($value), ), ); } catch (UniqueConstraintViolationException $e) { @@ -113,7 +113,7 @@ final class TableManager $modelRows[] = $rowClass::reconstitute( mapWithKeys( $dibiRow->toArray(), - static fn(string $columnName, mixed $value) => $value !== null ? $table->getTypeOf($columnName)->fromDatabase($value) : null, + static fn(string $columnName, mixed $value) => $table->getTypeOf($columnName)->fromDatabase($value), ), ); } @@ -176,7 +176,7 @@ final class TableManager return $rowClass::reconstitute( mapWithKeys( $dibiRow->toArray(), - static fn(string $columnName, mixed $value) => $value !== null ? $table->getTypeOf($columnName)->fromDatabase($value) : null, + static fn(string $columnName, mixed $value) => $table->getTypeOf($columnName)->fromDatabase($value), ), ); } @@ -196,7 +196,7 @@ final class TableManager 'SET %a', mapWithKeys( $changes->getModifications(), - static fn(string $columnName, mixed $value) => $value !== null ? $table->getTypeOf($columnName)->toDatabase($value) : null, + static fn(string $columnName, mixed $value) => $table->getTypeOf($columnName)->toDatabase($value), ), 'WHERE %ex', $primaryKey->getCondition($table)->toSql()->getValues(), ); diff --git a/src/Types/ArrayType.php b/src/Types/ArrayType.php index 76aed5c3a7775f2d9b228967b1e12ec9b2e2544d..e0f1675638e768096539b6b6e2a871a8c63eba8a 100644 --- a/src/Types/ArrayType.php +++ b/src/Types/ArrayType.php @@ -9,6 +9,7 @@ use Dibi\Literal; use Grifart\ClassScaffolder\Definition\Types\Type as PhpType; use Grifart\Tables\Database\ArrayType as DatabaseArrayType; use Grifart\Tables\Type; +use Grifart\Tables\UnexpectedNullValue; use function Grifart\ClassScaffolder\Definition\Types\listOf; use function Phun\map; @@ -16,7 +17,7 @@ use function Phun\map; * @template ItemType * @implements Type<ItemType[]> */ -final class ArrayType implements Type // @todo: There is implicit support for nullable types, shouldn't it be explicit instead? +final class ArrayType implements Type { /** * @param Type<ItemType> $itemType @@ -59,7 +60,13 @@ final class ArrayType implements Type // @todo: There is implicit support for nu new Literal('ARRAY['), ...map( $value, - fn(mixed $item) => $item !== null ? $this->itemType->toDatabase($item) : new Literal('NULL'), + function (mixed $item) { + if ($item === null && ! $this->itemType instanceof NullableType) { + throw new UnexpectedNullValue(); + } + + return $this->itemType->toDatabase($item); + }, ), new Literal(']::'), $this->getDatabaseType()->toSql(), @@ -74,9 +81,15 @@ final class ArrayType implements Type // @todo: There is implicit support for nu public function fromDatabase(mixed $value): array { $result = $this->parseArray($value); - return map( // @phpstan-ignore return.type (will be fixed in later commits) + return map( $result, - fn($item) => $item !== null ? $this->itemType->fromDatabase($item) : null, + function ($item) { + if ($item === null && ! $this->itemType instanceof NullableType) { + throw new UnexpectedNullValue(); + } + + return $this->itemType->fromDatabase($item); + }, ); } @@ -97,7 +110,7 @@ final class ArrayType implements Type // @todo: There is implicit support for nu if ( ! $string && $char === '}') { if ($item !== '') { - $result[] = $item; + $result[] = $item !== 'NULL' ? $item : null; } $end = $i; break; @@ -108,7 +121,7 @@ final class ArrayType implements Type // @todo: There is implicit support for nu $subArrayStart = $i; $this->parseArray($value, $i, $i); $item = \substr($value, $subArrayStart, $i - $subArrayStart + 1); - } elseif ( ! $string && $char ===',') { + } elseif ( ! $string && $char === ',') { $result[] = $item !== 'NULL' ? $item : null; $item = ''; } elseif ( ! $string && $char === '"') { diff --git a/src/Types/CompositeType.php b/src/Types/CompositeType.php index 64c42f6c34bf87e7762dad4eb3eb40d576d1fe37..6a58484bf8c569dd08e4dd21c17d3fb470adc5bc 100644 --- a/src/Types/CompositeType.php +++ b/src/Types/CompositeType.php @@ -8,6 +8,7 @@ use Dibi\Expression; use Dibi\Literal; use Grifart\Tables\Database\DatabaseType; use Grifart\Tables\Type; +use Grifart\Tables\UnexpectedNullValue; use function Phun\map; use function Phun\mapWithKeys; @@ -47,7 +48,15 @@ abstract class CompositeType implements Type new Literal('ROW('), ...mapWithKeys( $value, - fn(int $index, mixed $item) => $item !== null ? $this->types[$index]->toDatabase($item) : new Literal('NULL'), + function (int $index, mixed $item) { + $itemType = $this->types[$index]; + + if ($item === null && ! $itemType instanceof NullableType) { + throw new UnexpectedNullValue(); + } + + return $itemType->toDatabase($item); + }, ), new Literal(')::'), $this->getDatabaseType()->toSql(), @@ -69,7 +78,15 @@ abstract class CompositeType implements Type \assert(\count($result) === \count($this->types)); return mapWithKeys( $result, - fn($index, $item) => $item !== null ? $this->types[$index]->fromDatabase($item) : null, + function (int $index, mixed $item) { + $itemType = $this->types[$index]; + + if ($item === null && ! $itemType instanceof NullableType) { + throw new UnexpectedNullValue(); + } + + return $itemType->fromDatabase($item); + }, ); } diff --git a/src/Types/NullableType.php b/src/Types/NullableType.php new file mode 100644 index 0000000000000000000000000000000000000000..d351c0b64742cf438fbbd86260b40cae49b2ebd9 --- /dev/null +++ b/src/Types/NullableType.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +namespace Grifart\Tables\Types; + +use Dibi\Expression; +use Dibi\Expression as DibiExpression; +use Grifart\ClassScaffolder\Definition\Types\Type as PhpType; +use Grifart\Tables\Database\DatabaseType; +use Grifart\Tables\Type; +use function Grifart\ClassScaffolder\Definition\Types\nullable; + +/** + * @template ValueType + * @implements Type<ValueType|null> + */ +final readonly class NullableType implements Type +{ + /** + * @param Type<ValueType> $type + */ + private function __construct(private Type $type) {} + + /** + * @template OfValueType + * @param Type<OfValueType> $type + * @return self<OfValueType> + */ + public static function of(Type $type): self + { + return new self($type); + } + + public function getPhpType(): PhpType + { + return nullable($this->type->getPhpType()); + } + + public function getDatabaseType(): DatabaseType + { + return $this->type->getDatabaseType(); + } + + public function toDatabase(mixed $value): DibiExpression + { + if ($value === null) { + return new Expression('%sql', 'NULL'); + } + + return $this->type->toDatabase($value); + } + + public function fromDatabase(mixed $value): mixed + { + if ($value === null) { + return null; + } + + return $this->type->fromDatabase($value); + } +} diff --git a/src/Types/functions.php b/src/Types/functions.php index b1fb257cac637b0623a21516a4cecf908bc04ee0..9b7376352c92899d8292460ea96a6b5f51a68bd7 100644 --- a/src/Types/functions.php +++ b/src/Types/functions.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Grifart\Tables\Types; +use Deprecated; use Grifart\Tables\Type; /** @@ -11,11 +12,10 @@ use Grifart\Tables\Type; * @param ValueType $value * @param Type<ValueType> $type */ +#[Deprecated('Use $type->toDatabase($value) instead')] function mapToDatabase(mixed $value, Type $type): mixed { - return $value !== null - ? $type->toDatabase($value) - : null; + return $type->toDatabase($value); } /** @@ -23,9 +23,8 @@ function mapToDatabase(mixed $value, Type $type): mixed * @param Type<ValueType> $type * @return ValueType|null */ +#[Deprecated('Use $type->fromDatabase($value) instead')] function mapFromDatabase(mixed $value, Type $type): mixed { - return $value !== null - ? $type->fromDatabase($value) - : null; + return $type->fromDatabase($value); } diff --git a/src/exceptions.php b/src/exceptions.php index 696803a932472af5b25cc2f92faaaf0e3ed6a31b..c6cb98dbe40e277b48d970e7265b109332b1bcf7 100644 --- a/src/exceptions.php +++ b/src/exceptions.php @@ -69,3 +69,5 @@ final class GivenSearchCriteriaHaveNotMatchedAnyRows extends RuntimeException {} final class RowWithGivenPrimaryKeyAlreadyExists extends RuntimeException {} final class RowNotFound extends RuntimeException {} final class TooManyRowsFound extends UsageException {} + +final class UnexpectedNullValue extends UsageException {} diff --git a/tests/Types/ArrayTypeTest.phpt b/tests/Types/ArrayTypeTest.phpt index 5800c9b9824aeb644060b06cd57cd69ecb74aaf3..cfb3c07144cc2d5ee003e58758b015affc863403 100644 --- a/tests/Types/ArrayTypeTest.phpt +++ b/tests/Types/ArrayTypeTest.phpt @@ -6,7 +6,9 @@ namespace Grifart\Tables\Tests\Types; use Grifart\Tables\Types\ArrayType; use Grifart\Tables\Types\IntType; +use Grifart\Tables\Types\NullableType; use Grifart\Tables\Types\TextType; +use Grifart\Tables\UnexpectedNullValue; use Tester\Assert; use function Grifart\Tables\Tests\connect; use function Grifart\Tables\Tests\executeExpressionInDatabase; @@ -18,7 +20,7 @@ $connection = connect(); (function() use ($connection) { $theInput = [42, null, -5]; - $intArrayType = ArrayType::of(IntType::integer()); + $intArrayType = ArrayType::of(NullableType::of(IntType::integer())); Assert::same('ARRAY[42,NULL,-5]::integer[]', $connection->translate($dbExpr = $intArrayType->toDatabase($theInput))); $dbResult = executeExpressionInDatabase($connection, $dbExpr); Assert::same('{42,NULL,-5}', $dbResult); @@ -35,10 +37,16 @@ $connection = connect(); })(); (function() use ($connection) { - $theInput = ['simple', null, '', 'co,m\\ple"\'x']; + $theInput = ['simple', '', 'co,m\\ple"\'x']; $textArrayType = ArrayType::of(TextType::text()); - Assert::same("ARRAY['simple',NULL,'','co,m\\ple\"''x']::text[]", $connection->translate($dbExpr = $textArrayType->toDatabase($theInput))); + Assert::same("ARRAY['simple','','co,m\\ple\"''x']::text[]", $connection->translate($dbExpr = $textArrayType->toDatabase($theInput))); $dbResult = executeExpressionInDatabase($connection, $dbExpr); - Assert::same('{simple,NULL,"","co,m\\\\ple\\"\'x"}', $dbResult); + Assert::same('{simple,"","co,m\\\\ple\\"\'x"}', $dbResult); Assert::same($theInput, $textArrayType->fromDatabase($dbResult)); })(); + +(function() { + $textArrayType = ArrayType::of(TextType::text()); + Assert::throws(fn() => $textArrayType->toDatabase([null]), UnexpectedNullValue::class); + Assert::throws(fn() => $textArrayType->fromDatabase('{NULL}'), UnexpectedNullValue::class); +})(); diff --git a/tests/Types/CompositeTypeTest.phpt b/tests/Types/CompositeTypeTest.phpt index e6ce65947e640b76d44ca9f8bb54d5d896117ce5..7e931bda0e06828ecbbaa2fa5f18874241bca26a 100644 --- a/tests/Types/CompositeTypeTest.phpt +++ b/tests/Types/CompositeTypeTest.phpt @@ -10,7 +10,9 @@ use Grifart\Tables\Database\Identifier; use Grifart\Tables\Database\NamedType; use Grifart\Tables\Types\CompositeType; use Grifart\Tables\Types\IntType; +use Grifart\Tables\Types\NullableType; use Grifart\Tables\Types\TextType; +use Grifart\Tables\UnexpectedNullValue; use Tester\Assert; use function Grifart\ClassScaffolder\Definition\Types\nullable; use function Grifart\ClassScaffolder\Definition\Types\tuple; @@ -26,11 +28,11 @@ $composite = new class extends CompositeType { parent::__construct( new NamedType(new Identifier('databaseType')), IntType::integer(), - IntType::integer(), - TextType::text(), + NullableType::of(IntType::integer()), TextType::text(), TextType::text(), TextType::text(), + NullableType::of(TextType::text()), ); } @@ -56,3 +58,6 @@ Assert::same( ); Assert::same([42, null, 'com\\ple"\'x', '(', '', null], $composite->fromDatabase('(42,,"com\\\\ple""\'x","(","",)')); + +Assert::throws(fn() => $composite->toDatabase([null, null, 'foo', '', '', null]), UnexpectedNullValue::class); +Assert::throws(fn() => $composite->fromDatabase('(,,"foo","","",)'), UnexpectedNullValue::class);