diff --git a/src/core/etl/src/Flow/ETL/DSL/Entry.php b/src/core/etl/src/Flow/ETL/DSL/Entry.php index 6d06ab325..a8f7c1da5 100644 --- a/src/core/etl/src/Flow/ETL/DSL/Entry.php +++ b/src/core/etl/src/Flow/ETL/DSL/Entry.php @@ -7,6 +7,7 @@ use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Row\Entries; use Flow\ETL\Row\Entry as RowEntry; +use Flow\ETL\Row\Entry\Type\Uuid; use Flow\ETL\Row\Entry\TypedCollection\ScalarType; /** @@ -290,6 +291,14 @@ final public static function structure(string $name, RowEntry ...$entries) : Row return new RowEntry\StructureEntry($name, ...$entries); } + /** + * @return RowEntry\UuidEntry + */ + final public static function uuid(string $name, string $value) : RowEntry + { + return new RowEntry\UuidEntry($name, Uuid::fromString($value)); + } + /** * @return RowEntry\XMLEntry */ @@ -298,6 +307,9 @@ final public static function xml(string $name, \DOMDocument|string $data) : RowE return new RowEntry\XMLEntry($name, $data); } + /** + * @return RowEntry\XMLNodeEntry + */ final public static function xml_node(string $name, \DOMNode $data) : RowEntry { return new RowEntry\XMLNodeEntry($name, $data); diff --git a/src/core/etl/src/Flow/ETL/Row/Entry/Type/Uuid.php b/src/core/etl/src/Flow/ETL/Row/Entry/Type/Uuid.php new file mode 100644 index 000000000..9dd364d7a --- /dev/null +++ b/src/core/etl/src/Flow/ETL/Row/Entry/Type/Uuid.php @@ -0,0 +1,56 @@ +value = (string) \Ramsey\Uuid\Uuid::fromString($value); + } elseif (\class_exists(\Symfony\Component\Uid\Uuid::class)) { + $this->value = \Symfony\Component\Uid\Uuid::fromString($value)->toRfc4122(); + } else { + throw new RuntimeException("\Ramsey\Uuid\Uuid nor \Symfony\Component\Uid\Uuid class not found, please add 'ramsey/uuid' or 'symfony/uid' as a dependency to the project first."); + } + } catch (\InvalidArgumentException $e) { + throw new InvalidArgumentException("Invalid UUID: '{$value}'", 0, $e); + } + } elseif ($value instanceof \Ramsey\Uuid\UuidInterface) { + $this->value = $value->toString(); + } else { + $this->value = $value->toRfc4122(); + } + } + + public static function fromString(string $value) : self + { + return new self($value); + } + + public function isEqual(self $type) : bool + { + return $this->toString() === $type->toString(); + } + + public function toString() : string + { + return $this->value; + } +} diff --git a/src/core/etl/src/Flow/ETL/Row/Entry/UuidEntry.php b/src/core/etl/src/Flow/ETL/Row/Entry/UuidEntry.php new file mode 100644 index 000000000..f63f37c87 --- /dev/null +++ b/src/core/etl/src/Flow/ETL/Row/Entry/UuidEntry.php @@ -0,0 +1,96 @@ + + */ +final class UuidEntry implements \Stringable, Entry +{ + use EntryRef; + + /** + * @throws InvalidArgumentException + */ + public function __construct(private readonly string $name, private readonly Entry\Type\Uuid $value) + { + if ('' === $name) { + throw InvalidArgumentException::because('Entry name cannot be empty'); + } + } + + public static function from(string $name, string $value) : self + { + return new self($name, Entry\Type\Uuid::fromString($value)); + } + + public function __serialize() : array + { + return ['name' => $this->name, 'value' => $this->value->toString()]; + } + + public function __toString() : string + { + return $this->toString(); + } + + public function __unserialize(array $data) : void + { + $this->name = $data['name']; + $this->value = new Entry\Type\Uuid($data['value']); + } + + public function definition() : Definition + { + return Definition::uuid($this->name); + } + + public function is(string|Reference $name) : bool + { + if ($name instanceof Reference) { + return $this->name === $name->name(); + } + + return $this->name === $name; + } + + public function isEqual(Entry $entry) : bool + { + return $this->is($entry->name()) && $entry instanceof self && $this->value()->isEqual($entry->value()); + } + + public function map(callable $mapper) : Entry + { + return new self($this->name, $mapper($this->value)); + } + + public function name() : string + { + return $this->name; + } + + /** + * @throws InvalidArgumentException + */ + public function rename(string $name) : Entry + { + return new self($name, $this->value); + } + + public function toString() : string + { + return $this->value->toString(); + } + + public function value() : Entry\Type\Uuid + { + return $this->value; + } +} diff --git a/src/core/etl/src/Flow/ETL/Row/Factory/NativeEntryFactory.php b/src/core/etl/src/Flow/ETL/Row/Factory/NativeEntryFactory.php index ae86ed0fa..7d2e1c01c 100644 --- a/src/core/etl/src/Flow/ETL/Row/Factory/NativeEntryFactory.php +++ b/src/core/etl/src/Flow/ETL/Row/Factory/NativeEntryFactory.php @@ -57,6 +57,10 @@ public function create(string $entryName, mixed $value) : Entry return Row\Entry\JsonEntry::fromJsonString($entryName, $value); } + if ($this->isUuid($value)) { + return new Row\Entry\UuidEntry($entryName, Entry\Type\Uuid::fromString($value)); + } + if ($this->isXML($value)) { return new Entry\XMLEntry($entryName, $value); } @@ -89,6 +93,14 @@ public function create(string $entryName, mixed $value) : Entry return new Row\Entry\DateTimeEntry($entryName, $value); } + if ($value instanceof Entry\Type\Uuid || $value instanceof \Ramsey\Uuid\UuidInterface || $value instanceof \Symfony\Component\Uid\Uuid) { + if ($value instanceof \Ramsey\Uuid\UuidInterface || $value instanceof \Symfony\Component\Uid\Uuid) { + return new Row\Entry\UuidEntry($entryName, new Entry\Type\Uuid($value)); + } + + return new Row\Entry\UuidEntry($entryName, $value); + } + return new Row\Entry\ObjectEntry($entryName, $value); } @@ -195,6 +207,10 @@ private function fromDefinition(Schema\Definition $definition, mixed $value) : E return EntryDSL::xml($definition->entry()->name(), $value); } + if ($type === Entry\UuidEntry::class && (\is_string($value) || $value instanceof Entry\Type\Uuid)) { + return EntryDSL::uuid($definition->entry()->name(), \is_string($value) ? $value : $value->toString()); + } + if ($type === Entry\ObjectEntry::class && \is_object($value)) { return EntryDSL::object($definition->entry()->name(), $value); } @@ -289,6 +305,11 @@ private function isJson(string $string) : bool } } + private function isUuid(string $string) : bool + { + return 0 !== \preg_match(Entry\Type\Uuid::UUID_REGEXP, $string); + } + private function isXML(string $string) : bool { try { diff --git a/src/core/etl/src/Flow/ETL/Row/Reference/Expression/Uuid.php b/src/core/etl/src/Flow/ETL/Row/Reference/Expression/Uuid.php index 498b1c70b..e6ca026a3 100644 --- a/src/core/etl/src/Flow/ETL/Row/Reference/Expression/Uuid.php +++ b/src/core/etl/src/Flow/ETL/Row/Reference/Expression/Uuid.php @@ -8,9 +8,10 @@ use Flow\ETL\Row; use Flow\ETL\Row\Reference\Expression; -if (!\class_exists(\Ramsey\Uuid\Uuid::class)) { - throw new RuntimeException("\Ramsey\Uuid\Uuid class not found, please add ramsey/uuid dependency to the project first."); +if (!\class_exists(\Ramsey\Uuid\Uuid::class) && !\class_exists(\Symfony\Component\Uid\Uuid::class)) { + throw new RuntimeException("\Ramsey\Uuid\Uuid nor \Symfony\Component\Uid\Uuid class not found, please add 'ramsey/uuid' or 'symfony/uid' as a dependency to the project first."); } + final class Uuid implements Expression { private function __construct(private readonly string $uuidVersion, private readonly ?Expression $ref = null) @@ -33,9 +34,27 @@ public function eval(Row $row) : mixed $param = $this->ref?->eval($row); return match ($this->uuidVersion) { - 'uuid4' => \Ramsey\Uuid\Uuid::uuid4(), - 'uuid7' => $param instanceof \DateTimeInterface?\Ramsey\Uuid\Uuid::uuid7($param):null, - default=> null + 'uuid4' => $this->generateV4(), + 'uuid7' => $param instanceof \DateTimeInterface ? $this->generateV7($param) : null, + default => null }; } + + private function generateV4() : \Symfony\Component\Uid\UuidV4|\Ramsey\Uuid\UuidInterface + { + if (\class_exists(\Ramsey\Uuid\Uuid::class)) { + return \Ramsey\Uuid\Uuid::uuid4(); + } + + return \Symfony\Component\Uid\UuidV4::v4(); + } + + private function generateV7(\DateTimeInterface $dateTime) : \Symfony\Component\Uid\UuidV7|\Ramsey\Uuid\UuidInterface + { + if (\class_exists(\Ramsey\Uuid\Uuid::class)) { + return \Ramsey\Uuid\Uuid::uuid7($dateTime); + } + + return new \Symfony\Component\Uid\UuidV7(\Symfony\Component\Uid\UuidV7::generate($dateTime)); + } } diff --git a/src/core/etl/src/Flow/ETL/Row/Schema/Definition.php b/src/core/etl/src/Flow/ETL/Row/Schema/Definition.php index c132554cb..ef256726a 100644 --- a/src/core/etl/src/Flow/ETL/Row/Schema/Definition.php +++ b/src/core/etl/src/Flow/ETL/Row/Schema/Definition.php @@ -20,6 +20,7 @@ use Flow\ETL\Row\Entry\StringEntry; use Flow\ETL\Row\Entry\StructureEntry; use Flow\ETL\Row\Entry\TypedCollection\Type; +use Flow\ETL\Row\Entry\UuidEntry; use Flow\ETL\Row\Entry\XMLEntry; use Flow\ETL\Row\EntryReference; use Flow\ETL\Row\Schema\Constraint\Any; @@ -163,6 +164,11 @@ public static function union(string|EntryReference $entry, array $entryClasses, return new self($entry, $types, $constraint, $metadata); } + public static function uuid(string|EntryReference $entry, ?Constraint $constraint = null, ?Metadata $metadata = null) : self + { + return new self($entry, [UuidEntry::class], $constraint, $metadata); + } + public static function xml(string|EntryReference $entry, bool $nullable = false, ?Constraint $constraint = null, ?Metadata $metadata = null) : self { return new self($entry, ($nullable) ? [XMLEntry::class, NullEntry::class] : [XMLEntry::class], $constraint, $metadata); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/Entry/UuidEntryTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/Entry/UuidEntryTest.php new file mode 100644 index 000000000..86b3b9530 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/Entry/UuidEntryTest.php @@ -0,0 +1,103 @@ + [ + true, + new UuidEntry('name', Uuid::fromString('00000000-0000-0000-0000-000000000000')), + new UuidEntry('name', Uuid::fromString('00000000-0000-0000-0000-000000000000')), + ]; + yield 'different names and values' => [ + false, + new UuidEntry('name', Uuid::fromString('00000000-0000-0000-0000-000000000000')), + new UuidEntry('different_name', Uuid::fromString('11111111-1111-1111-1111-111111111111')), + ]; + yield 'equal names and different values' => [ + false, + new UuidEntry('name', Uuid::fromString('00000000-0000-0000-0000-000000000000')), + new UuidEntry('name', Uuid::fromString('11111111-1111-1111-1111-111111111111')), + ]; + yield 'different names characters and equal values' => [ + false, + new UuidEntry('NAME', Uuid::fromString('00000000-0000-0000-0000-000000000000')), + new UuidEntry('name', Uuid::fromString('00000000-0000-0000-0000-000000000000')), + ]; + } + + public static function valid_string_entries() : \Generator + { + yield ['00000000-0000-0000-0000-000000000000']; + yield ['11111111-1111-1111-1111-111111111111']; + yield ['fa2e03e9-707f-4ebc-a40d-4c3c846fef75']; + yield ['9a419c18-fc21-4481-9dea-5e9cf057d137']; + } + + protected function setUp() : void + { + if (!\class_exists(\Ramsey\Uuid\Uuid::class) && !\class_exists(\Symfony\Component\Uid\Uuid::class)) { + $this->markTestSkipped("Package 'ramsey/uuid' or 'symfony/uid' is required for this test."); + } + } + + /** + * @dataProvider valid_string_entries + */ + public function test_creates_uuid_entry_from_string(string $value) : void + { + $entry = UuidEntry::from('entry-name', $value); + + $this->assertEquals($value, $entry->value()->toString()); + } + + /** + * @dataProvider is_equal_data_provider + */ + public function test_is_equal(bool $equals, UuidEntry $entry, UuidEntry $nextEntry) : void + { + $this->assertSame($equals, $entry->isEqual($nextEntry)); + } + + public function test_map() : void + { + $entry = new UuidEntry('entry-name', Uuid::fromString('00000000-0000-0000-0000-000000000000')); + + $this->assertEquals( + $entry, + $entry->map(fn ($value) => $value) + ); + } + + public function test_prevents_from_creating_entry_from_random_value() : void + { + $this->expectExceptionMessage("Invalid UUID: 'random-value'"); + + UuidEntry::from('entry-name', 'random-value'); + } + + public function test_prevents_from_creating_entry_with_empty_entry_name() : void + { + $this->expectExceptionMessage('Entry name cannot be empty'); + + new UuidEntry('', Uuid::fromString('00000000-0000-0000-0000-000000000000')); + } + + public function test_renames_entry() : void + { + $entry = new UuidEntry('entry-name', $uuid = Uuid::fromString('00000000-0000-0000-0000-000000000000')); + /** @var UuidEntry $newEntry */ + $newEntry = $entry->rename('new-entry-name'); + + $this->assertEquals('new-entry-name', $newEntry->name()); + $this->assertEquals($uuid->toString(), $newEntry->value()->toString()); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/Factory/NativeEntryFactoryTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/Factory/NativeEntryFactoryTest.php index 29942f58c..584e22284 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/Factory/NativeEntryFactoryTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/Factory/NativeEntryFactoryTest.php @@ -269,6 +269,34 @@ public function test_string_with_schema() : void ); } + public function test_uuid_from_ramsey_uuid_library() : void + { + if (!\class_exists(\Ramsey\Uuid\Uuid::class)) { + $this->markTestSkipped("Package 'ramsey/uuid' is required for this test."); + } + + $this->assertEquals( + Entry::uuid('e', $uuid = \Ramsey\Uuid\Uuid::uuid4()->toString()), + (new NativeEntryFactory())->create('e', $uuid) + ); + } + + public function test_uuid_from_string() : void + { + $this->assertEquals( + Entry::uuid('e', $uuid = '00000000-0000-0000-0000-000000000000'), + (new NativeEntryFactory())->create('e', $uuid) + ); + } + + public function test_uuid_string_with_uuid_definition_provided() : void + { + $this->assertEquals( + Entry::uuid('e', $uuid = '00000000-0000-0000-0000-000000000000'), + (new NativeEntryFactory(new Schema(Schema\Definition::uuid('e'))))->create('e', $uuid) + ); + } + public function test_xml_from_dom_document() : void { $doc = new \DOMDocument(); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/Reference/Expression/UuidTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/Reference/Expression/UuidTest.php index aeb01286b..87901c3f0 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/Reference/Expression/UuidTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Row/Reference/Expression/UuidTest.php @@ -9,15 +9,25 @@ use function Flow\ETL\DSL\uuid_v7; use Flow\ETL\Row; use PHPUnit\Framework\TestCase; -use Ramsey\Uuid\Uuid; final class UuidTest extends TestCase { + protected function setUp() : void + { + if (!\class_exists(\Ramsey\Uuid\Uuid::class) && !\class_exists(\Symfony\Component\Uid\Uuid::class)) { + $this->markTestSkipped("Package 'ramsey/uuid' or 'symfony/uid' is required for this test."); + } + } + public function test_uuid4() : void { + if (!\class_exists(\Ramsey\Uuid\Uuid::class)) { + $this->markTestSkipped("Package 'ramsey/uuid' is required for this test."); + } + $expression = uuid_v4(); $this->assertTrue( - Uuid::isValid( + \Ramsey\Uuid\Uuid::isValid( $expression->eval(Row::create())->toString() ) ); @@ -39,8 +49,12 @@ public function test_uuid4_is_unique() : void public function test_uuid7() : void { + if (!\class_exists(\Ramsey\Uuid\Uuid::class)) { + $this->markTestSkipped("Package 'ramsey/uuid' is required for this test."); + } + $this->assertTrue( - Uuid::isValid( + \Ramsey\Uuid\Uuid::isValid( uuid_v7(lit(new \DateTimeImmutable('2020-01-01 00:00:00', new \DateTimeZone('UTC'))))->eval(Row::create())->toString() ) );