From 09ba388144958001406cea1034af306a4441c9ae Mon Sep 17 00:00:00 2001 From: zoran Date: Mon, 7 Sep 2026 09:00:34 +0200 Subject: [PATCH 1/2] New #1152: Add UuidValue expression for DBMS-independent UUID values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ColumnBuilder::uuidPrimaryKey()` and `uuid()` already produce the right DDL on every DBMS, but inserting a UUID required knowing which representation the current connection expects: MySQL, MariaDB, SQLite and Oracle store a UUID as 16 raw bytes, while PostgreSQL and MSSQL expect the canonical string. The official guide had to document the difference (yiisoft/docs#324) and `CommonCommandTest::testUuid()` works around it with a per-driver `match`. `UuidValue` carries the intent in the value instead of relying on the loaded table schema. That matters because a UUID column reads back as `binary(16)` on MySQL and `blob(16)` on SQLite, so a schema-driven typecast cannot tell it apart from an ordinary binary column. The value is normalized to the canonical lowercase form on construction, so the canonical string, 32 hexadecimal characters, 16 raw bytes and any `Stringable` returning one of those are all accepted — including `Ramsey\Uuid\UuidInterface` itself. `UuidValueBuilder` binds the canonical string, which is correct for PostgreSQL and MSSQL. It is left non-final with a single `prepareValue()` seam so drivers storing raw bytes override one method and reuse `DbUuidHelper::uuidToBlob()`, which until now was unused by `src/`. No behaviour changes for existing code: passing raw bytes or a raw string keeps working exactly as before. --- CHANGELOG.md | 1 + .../Value/Builder/UuidValueBuilder.php | 47 ++++++++++++ src/Expression/Value/UuidValue.php | 62 ++++++++++++++++ src/QueryBuilder/AbstractDQLQueryBuilder.php | 3 + tests/Db/Command/CommandTest.php | 21 ++++++ .../Value/Builder/UuidValueBuilderTest.php | 71 +++++++++++++++++++ tests/Db/Expression/Value/UuidValueTest.php | 70 ++++++++++++++++++ 7 files changed, 275 insertions(+) create mode 100644 src/Expression/Value/Builder/UuidValueBuilder.php create mode 100644 src/Expression/Value/UuidValue.php create mode 100644 tests/Db/Expression/Value/Builder/UuidValueBuilderTest.php create mode 100644 tests/Db/Expression/Value/UuidValueTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f2fbc913a..6e5e8efce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - 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) ## 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..0f8fd24de --- /dev/null +++ b/src/Expression/Value/Builder/UuidValueBuilder.php @@ -0,0 +1,47 @@ + + */ +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 representation expected by the DBMS. + * + * @param UuidValue $expression The expression to convert. + * + * @return mixed The value to bind, it's passed to {@see QueryBuilderInterface::buildValue()}. + */ + protected function prepareValue(UuidValue $expression): mixed + { + return $expression->value; + } +} diff --git a/src/Expression/Value/UuidValue.php b/src/Expression/Value/UuidValue.php new file mode 100644 index 000000000..d6420c200 --- /dev/null +++ b/src/Expression/Value/UuidValue.php @@ -0,0 +1,62 @@ +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) + { + try { + $this->value = strtolower(DbUuidHelper::toUuid((string) $value)); + } catch (InvalidArgumentException $e) { + throw new InvalidArgumentException( + 'Value is not a valid UUID. Expected the canonical form, 32 hexadecimal characters or 16 raw bytes.', + previous: $e, + ); + } + } +} 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..c005acde4 --- /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): mixed + { + 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..08c9f3b32 --- /dev/null +++ b/tests/Db/Expression/Value/UuidValueTest.php @@ -0,0 +1,70 @@ + [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); + } + + public function testPreviousExceptionIsKept(): void + { + try { + new UuidValue('not-a-uuid'); + } catch (InvalidArgumentException $e) { + $this->assertInstanceOf(InvalidArgumentException::class, $e->getPrevious()); + return; + } + + self::fail('The exception was not thrown.'); + } +} From 6795ad2ce81fd701a12dfb90c82a394c41b0376d Mon Sep 17 00:00:00 2001 From: KalimeroMK Date: Tue, 8 Sep 2026 06:56:23 +0200 Subject: [PATCH 2/2] Address review: bind UUID as Param, reuse helper exception Return a `Param` from `UuidValueBuilder::prepareValue()` so DBMS that store a UUID as raw bytes can bind it as `DataType::LOB` instead of letting `buildValue()` infer `DataType::STRING` from the PHP type. Reword the exception in `DbUuidHelper::toUuid()` and drop the extra try/catch that wrapped it in `UuidValue`. --- CHANGELOG.md | 2 ++ .../Value/Builder/UuidValueBuilder.php | 19 +++++++++++-------- src/Expression/Value/UuidValue.php | 9 +-------- src/Helper/DbUuidHelper.php | 4 +++- .../Value/Builder/UuidValueBuilderTest.php | 2 +- tests/Db/Expression/Value/UuidValueTest.php | 12 ------------ tests/Db/Helper/DbUuidHelperTest.php | 4 +++- 7 files changed, 21 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e5e8efce..bdbd6b373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ `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 index 0f8fd24de..a58ed06df 100644 --- a/src/Expression/Value/Builder/UuidValueBuilder.php +++ b/src/Expression/Value/Builder/UuidValueBuilder.php @@ -4,18 +4,21 @@ namespace Yiisoft\Db\Expression\Value\Builder; +use Yiisoft\Db\Constant\DataType; use Yiisoft\Db\Expression\ExpressionBuilderInterface; use Yiisoft\Db\Expression\ExpressionInterface; +use Yiisoft\Db\Expression\Value\Param; use Yiisoft\Db\Expression\Value\UuidValue; -use Yiisoft\Db\QueryBuilder\QueryBuilderInterface; use Yiisoft\Db\Helper\DbUuidHelper; +use Yiisoft\Db\QueryBuilder\QueryBuilderInterface; /** * Builder for {@see UuidValue} expressions. * - * Binds the UUID in the canonical string form, which is what PostgreSQL `uuid` and MSSQL `uniqueidentifier` columns - * expect. DBMS that store a UUID as raw bytes, such as MySQL, MariaDB, SQLite and Oracle, override - * {@see prepareValue()} to convert the value with {@see DbUuidHelper::uuidToBlob()}. + * Binds the UUID as a string parameter in the canonical form, which is what PostgreSQL `uuid` and MSSQL + * `uniqueidentifier` columns expect. DBMS that store a UUID as raw bytes, such as MySQL, MariaDB, SQLite and Oracle, + * override {@see prepareValue()} to convert the value with {@see DbUuidHelper::uuidToBlob()} and bind it as + * {@see DataType::LOB}, so the driver sends it as binary rather than as a character string. * * @implements ExpressionBuilderInterface */ @@ -34,14 +37,14 @@ public function build(ExpressionInterface $expression, array &$params = []): str } /** - * Converts the UUID to the representation expected by the DBMS. + * Converts the UUID to the parameter expected by the DBMS. * * @param UuidValue $expression The expression to convert. * - * @return mixed The value to bind, it's passed to {@see QueryBuilderInterface::buildValue()}. + * @return Param The parameter to bind, it's passed to {@see QueryBuilderInterface::buildValue()}. */ - protected function prepareValue(UuidValue $expression): mixed + protected function prepareValue(UuidValue $expression): Param { - return $expression->value; + return new Param($expression->value, DataType::STRING); } } diff --git a/src/Expression/Value/UuidValue.php b/src/Expression/Value/UuidValue.php index d6420c200..35969a1e5 100644 --- a/src/Expression/Value/UuidValue.php +++ b/src/Expression/Value/UuidValue.php @@ -50,13 +50,6 @@ final class UuidValue implements ExpressionInterface */ public function __construct(string|Stringable $value) { - try { - $this->value = strtolower(DbUuidHelper::toUuid((string) $value)); - } catch (InvalidArgumentException $e) { - throw new InvalidArgumentException( - 'Value is not a valid UUID. Expected the canonical form, 32 hexadecimal characters or 16 raw bytes.', - previous: $e, - ); - } + $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/tests/Db/Expression/Value/Builder/UuidValueBuilderTest.php b/tests/Db/Expression/Value/Builder/UuidValueBuilderTest.php index c005acde4..1386bec74 100644 --- a/tests/Db/Expression/Value/Builder/UuidValueBuilderTest.php +++ b/tests/Db/Expression/Value/Builder/UuidValueBuilderTest.php @@ -53,7 +53,7 @@ public function testPrepareValueIsOverridable(): void { $db = TestHelper::createSqliteMemoryConnection(); $builder = new class ($db->getQueryBuilder()) extends UuidValueBuilder { - protected function prepareValue(UuidValue $expression): mixed + protected function prepareValue(UuidValue $expression): Param { return new Param(DbUuidHelper::uuidToBlob($expression->value), DataType::LOB); } diff --git a/tests/Db/Expression/Value/UuidValueTest.php b/tests/Db/Expression/Value/UuidValueTest.php index 08c9f3b32..2f353a561 100644 --- a/tests/Db/Expression/Value/UuidValueTest.php +++ b/tests/Db/Expression/Value/UuidValueTest.php @@ -55,16 +55,4 @@ public function testConstructWithInvalidValue(string $value): void new UuidValue($value); } - - public function testPreviousExceptionIsKept(): void - { - try { - new UuidValue('not-a-uuid'); - } catch (InvalidArgumentException $e) { - $this->assertInstanceOf(InvalidArgumentException::class, $e->getPrevious()); - return; - } - - self::fail('The exception was not thrown.'); - } } 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);