diff --git a/CHANGELOG.md b/CHANGELOG.md index f2fbc913a..bdbd6b373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ - Bug #1176: Index the result of `AbstractSchema::getSchemaMetadata()` by table name, so `getSchemaChecks()`, `getSchemaDefaultValues()`, `getSchemaForeignKeys()`, `getSchemaIndexes()`, `getSchemaPrimaryKeys()`, `getSchemaUniques()` and `getTableSchemas()` expose the table each item belongs to (@KalimeroMK) +- New #1152: Add `UuidValue` expression that represents a UUID value independently of DBMS (@KalimeroMK) +- Enh #1152: Improve the exception message of `DbUuidHelper::toUuid()` when the value isn't a valid UUID + (@KalimeroMK) ## 2.0.1 February 09, 2026 diff --git a/src/Expression/Value/Builder/UuidValueBuilder.php b/src/Expression/Value/Builder/UuidValueBuilder.php new file mode 100644 index 000000000..a58ed06df --- /dev/null +++ b/src/Expression/Value/Builder/UuidValueBuilder.php @@ -0,0 +1,50 @@ + + */ +class UuidValueBuilder implements ExpressionBuilderInterface +{ + /** + * @param QueryBuilderInterface $queryBuilder The query builder instance. + */ + public function __construct( + protected readonly QueryBuilderInterface $queryBuilder, + ) {} + + public function build(ExpressionInterface $expression, array &$params = []): string + { + return $this->queryBuilder->buildValue($this->prepareValue($expression), $params); + } + + /** + * Converts the UUID to the parameter expected by the DBMS. + * + * @param UuidValue $expression The expression to convert. + * + * @return Param The parameter to bind, it's passed to {@see QueryBuilderInterface::buildValue()}. + */ + protected function prepareValue(UuidValue $expression): Param + { + return new Param($expression->value, DataType::STRING); + } +} diff --git a/src/Expression/Value/UuidValue.php b/src/Expression/Value/UuidValue.php new file mode 100644 index 000000000..35969a1e5 --- /dev/null +++ b/src/Expression/Value/UuidValue.php @@ -0,0 +1,55 @@ +createCommand()->insert('{{%page}}', [ + * 'id' => new UuidValue(Uuid::uuid7()), + * ])->execute(); + * ``` + * + * The value is normalized to the canonical lowercase form on construction, so all of the following are equivalent: + * + * ```php + * new UuidValue('738146be-87b1-49f2-9913-36142fb6fcbe'); + * new UuidValue('738146be87b149f2991336142fb6fcbe'); + * new UuidValue(hex2bin('738146be87b149f2991336142fb6fcbe')); + * ``` + */ +final class UuidValue implements ExpressionInterface +{ + /** + * The UUID in the canonical lowercase form, for example `738146be-87b1-49f2-9913-36142fb6fcbe`. + */ + public readonly string $value; + + /** + * @param string|Stringable $value The UUID to represent. It can be: + * - a UUID in the canonical form, for example `738146be-87b1-49f2-9913-36142fb6fcbe`; + * - a UUID as 32 hexadecimal characters without dashes, for example `738146be87b149f2991336142fb6fcbe`; + * - a UUID as 16 raw bytes, for example the result of `Ramsey\Uuid\UuidInterface::getBytes()`; + * - any {@see Stringable} instance that returns one of the above, for example `Ramsey\Uuid\UuidInterface` itself. + * + * @throws InvalidArgumentException If the value isn't a valid UUID. + */ + public function __construct(string|Stringable $value) + { + $this->value = strtolower(DbUuidHelper::toUuid((string) $value)); + } +} diff --git a/src/Helper/DbUuidHelper.php b/src/Helper/DbUuidHelper.php index c157e9877..891c1ed66 100644 --- a/src/Helper/DbUuidHelper.php +++ b/src/Helper/DbUuidHelper.php @@ -25,7 +25,9 @@ public static function toUuid(string $blobString): string } elseif (strlen($blobString) === 32 && self::isValidHexUuid($blobString)) { $hex = $blobString; } else { - throw new InvalidArgumentException('Length of source data is should be 16 or 32 bytes.'); + throw new InvalidArgumentException( + 'Value is not a valid UUID. Expected the canonical form, 32 hexadecimal characters or 16 raw bytes.', + ); } return diff --git a/src/QueryBuilder/AbstractDQLQueryBuilder.php b/src/QueryBuilder/AbstractDQLQueryBuilder.php index d55c7a624..825f3c604 100644 --- a/src/QueryBuilder/AbstractDQLQueryBuilder.php +++ b/src/QueryBuilder/AbstractDQLQueryBuilder.php @@ -38,6 +38,8 @@ use Yiisoft\Db\Expression\Value\Builder\ValueBuilder; use Yiisoft\Db\Expression\Value\DateTimeValue; use Yiisoft\Db\Expression\Value\Builder\DateTimeValueBuilder; +use Yiisoft\Db\Expression\Value\UuidValue; +use Yiisoft\Db\Expression\Value\Builder\UuidValueBuilder; use Yiisoft\Db\QueryBuilder\Condition\ConditionInterface; use Yiisoft\Db\QueryBuilder\Condition\Simple; use Yiisoft\Db\Query\Query; @@ -591,6 +593,7 @@ protected function defaultExpressionBuilders(): array ColumnName::class => ColumnNameBuilder::class, Value::class => ValueBuilder::class, DateTimeValue::class => DateTimeValueBuilder::class, + UuidValue::class => UuidValueBuilder::class, Length::class => LengthBuilder::class, Greatest::class => GreatestBuilder::class, Least::class => LeastBuilder::class, diff --git a/tests/Db/Command/CommandTest.php b/tests/Db/Command/CommandTest.php index 87076d459..0323f57fd 100644 --- a/tests/Db/Command/CommandTest.php +++ b/tests/Db/Command/CommandTest.php @@ -9,6 +9,7 @@ use Yiisoft\Db\Constant\PseudoType; use Yiisoft\Db\Exception\NotSupportedException; use Yiisoft\Db\Expression\Expression; +use Yiisoft\Db\Expression\Value\UuidValue; use Yiisoft\Db\Schema\Column\ColumnBuilder; use Yiisoft\Db\Schema\Column\ColumnInterface; use Yiisoft\Db\Schema\Column\IntegerColumn; @@ -529,6 +530,26 @@ public function testInsert(): void ); } + public function testInsertUuid(): void + { + $db = TestHelper::createSqliteMemoryConnection(); + + $command = $db->createCommand(); + $command->insert('page', ['id' => new UuidValue('738146be-87b1-49f2-9913-36142fb6fcbe'), 'title' => 'test']); + + $this->assertSame( + 'INSERT INTO [page] ([id], [title]) VALUES (:qp0, :qp1)', + $command->getSql(), + ); + $this->assertSame( + [ + ':qp0' => '738146be-87b1-49f2-9913-36142fb6fcbe', + ':qp1' => 'test', + ], + $command->getParams(), + ); + } + public function testRenameColumn(): void { $db = TestHelper::createSqliteMemoryConnection(); diff --git a/tests/Db/Expression/Value/Builder/UuidValueBuilderTest.php b/tests/Db/Expression/Value/Builder/UuidValueBuilderTest.php new file mode 100644 index 000000000..1386bec74 --- /dev/null +++ b/tests/Db/Expression/Value/Builder/UuidValueBuilderTest.php @@ -0,0 +1,71 @@ +getQueryBuilder()); + + $params = []; + $result = $builder->build(new UuidValue(self::UUID), $params); + + $this->assertSame(':qp0', $result); + $this->assertEquals([':qp0' => new Param(self::UUID, DataType::STRING)], $params); + } + + public function testBuildAppendsToExistingParams(): void + { + $db = TestHelper::createSqliteMemoryConnection(); + $builder = new UuidValueBuilder($db->getQueryBuilder()); + + $params = [':qp0' => new Param('existing', DataType::STRING)]; + $result = $builder->build(new UuidValue(self::UUID), $params); + + $this->assertSame(':qp1', $result); + $this->assertEquals( + [':qp0' => new Param('existing', DataType::STRING), ':qp1' => new Param(self::UUID, DataType::STRING)], + $params, + ); + } + + /** + * DBMS that store a UUID as raw bytes override {@see UuidValueBuilder::prepareValue()}. + */ + public function testPrepareValueIsOverridable(): void + { + $db = TestHelper::createSqliteMemoryConnection(); + $builder = new class ($db->getQueryBuilder()) extends UuidValueBuilder { + protected function prepareValue(UuidValue $expression): Param + { + return new Param(DbUuidHelper::uuidToBlob($expression->value), DataType::LOB); + } + }; + + $params = []; + $result = $builder->build(new UuidValue(self::UUID), $params); + + $this->assertSame(':qp0', $result); + $this->assertEquals( + [':qp0' => new Param(DbUuidHelper::uuidToBlob(self::UUID), DataType::LOB)], + $params, + ); + } +} diff --git a/tests/Db/Expression/Value/UuidValueTest.php b/tests/Db/Expression/Value/UuidValueTest.php new file mode 100644 index 000000000..2f353a561 --- /dev/null +++ b/tests/Db/Expression/Value/UuidValueTest.php @@ -0,0 +1,58 @@ + [self::UUID]; + yield 'canonical in upper case' => ['738146BE-87B1-49F2-9913-36142FB6FCBE']; + yield 'hexadecimal' => ['738146be87b149f2991336142fb6fcbe']; + yield 'hexadecimal in upper case' => ['738146BE87B149F2991336142FB6FCBE']; + yield 'bytes' => [hex2bin('738146be87b149f2991336142fb6fcbe')]; + yield 'stringable' => [new StringableObject(self::UUID)]; + } + + #[DataProvider('values')] + public function testValueIsNormalized(string|Stringable $value): void + { + $expression = new UuidValue($value); + + $this->assertSame(self::UUID, $expression->value); + } + + public static function invalidValues(): iterable + { + yield 'empty' => ['']; + yield 'misplaced dash' => ['738146be-87b149f2-9913-36142fb6fcbe']; + yield 'not hexadecimal' => ['738146be-87b1-K9f2-9913-36142fb6fcbe']; + yield 'wrong separator' => ['738146be+87b1-49f2-9913-36142fb6fcbe']; + yield 'too short' => ['738146be87b149f2991336142fb6fcb']; + yield '32 characters but not hexadecimal' => ['zzz146be87b149f2991336142fb6fcbe']; + } + + #[DataProvider('invalidValues')] + public function testConstructWithInvalidValue(string $value): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Value is not a valid UUID. Expected the canonical form, 32 hexadecimal characters or 16 raw bytes.', + ); + + new UuidValue($value); + } +} diff --git a/tests/Db/Helper/DbUuidHelperTest.php b/tests/Db/Helper/DbUuidHelperTest.php index 501dd768a..d12e4ee93 100644 --- a/tests/Db/Helper/DbUuidHelperTest.php +++ b/tests/Db/Helper/DbUuidHelperTest.php @@ -49,7 +49,9 @@ public function testToUuid($blobUuid, $expected): void public function testToUuidFailed($blobUuid, $expected): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Length of source data is should be 16 or 32 bytes.'); + $this->expectExceptionMessage( + 'Value is not a valid UUID. Expected the canonical form, 32 hexadecimal characters or 16 raw bytes.', + ); $uuid = DbUuidHelper::toUuid($blobUuid); $this->assertEquals($expected, $uuid);