Skip to content
Snippets Groups Projects
Commit 11e28966 authored by Jiří Pudil's avatar Jiří Pudil
Browse files

Merge branch '27-explicit-nullability' into 'master'

Make nullability explicit (not only) in ArrayType and CompositeType

Closes #27

See merge request !58
parents b8272276 6337aa9d
No related branches found
Tags 0.11.0
1 merge request!58Make nullability explicit (not only) in ArrayType and CompositeType
Pipeline #59080 passed
Showing
with 171 additions and 42 deletions
......@@ -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(),
);
}
// ...
}
```
......@@ -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;
......
......@@ -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),
);
}
}
......@@ -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),
);
}
}
......@@ -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),
);
}
}
......@@ -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),
),
);
}
......
......@@ -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),
);
}
}
......@@ -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),
);
}
}
......@@ -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),
);
}
}
......@@ -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),
),
);
}
......
......@@ -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(),
);
......
......@@ -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 === '"') {
......
......@@ -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);
},
);
}
......
<?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);
}
}
......@@ -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);
}
......@@ -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 {}
......@@ -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);
})();
......@@ -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);
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment