diff --git a/src/core/etl/src/Flow/ETL/PHP/Type/ArrayContentDetector.php b/src/core/etl/src/Flow/ETL/PHP/Type/ArrayContentDetector.php new file mode 100644 index 000000000..b073edc91 --- /dev/null +++ b/src/core/etl/src/Flow/ETL/PHP/Type/ArrayContentDetector.php @@ -0,0 +1,70 @@ +uniqueKeysType->first(); + + if (null !== $type && !$type instanceof ScalarType) { + throw InvalidArgumentException::because('First unique key type must be of ScalarType, given: ' . $type::class); + } + + return $type; + } + + public function firstValueType() : ?Type + { + return $this->uniqueValuesType->first(); + } + + public function isList() : bool + { + if (!$this->firstKeyType()?->isInteger()) { + return false; + } + + return 1 === $this->uniqueValuesType->without(ArrayType::empty(), new NullType())->count(); + } + + public function isMap() : bool + { + if (1 === $this->uniqueValuesType->without(ArrayType::empty(), new NullType())->count()) { + if ($this->isList()) { + return false; + } + + if (!$this->firstKeyType()?->isValidArrayKey()) { + return false; + } + + return 1 === $this->uniqueKeysType->count(); + } + + return false; + } + + public function isStructure() : bool + { + if ($this->isList() || $this->isMap()) { + return false; + } + + return $this->firstKeyType()?->isString() + && 1 === $this->uniqueKeysType->count() + && 0 !== $this->uniqueValuesType->without(ArrayType::empty(), new NullType())->count(); + } +} diff --git a/src/core/etl/src/Flow/ETL/PHP/Type/Logical/List/ListElement.php b/src/core/etl/src/Flow/ETL/PHP/Type/Logical/List/ListElement.php index af5c62b32..8034bf3cb 100644 --- a/src/core/etl/src/Flow/ETL/PHP/Type/Logical/List/ListElement.php +++ b/src/core/etl/src/Flow/ETL/PHP/Type/Logical/List/ListElement.php @@ -24,6 +24,11 @@ public static function float() : self return new self(ScalarType::float()); } + public static function fromType(Type $type) : self + { + return new self($type); + } + public static function integer() : self { return new self(ScalarType::integer()); diff --git a/src/core/etl/src/Flow/ETL/PHP/Type/Logical/Map/MapKey.php b/src/core/etl/src/Flow/ETL/PHP/Type/Logical/Map/MapKey.php index cffff19a1..6e51a0b98 100644 --- a/src/core/etl/src/Flow/ETL/PHP/Type/Logical/Map/MapKey.php +++ b/src/core/etl/src/Flow/ETL/PHP/Type/Logical/Map/MapKey.php @@ -10,6 +10,11 @@ private function __construct(private readonly ScalarType $value) { } + public static function fromType(ScalarType $type) : self + { + return new self($type); + } + public static function integer() : self { return new self(ScalarType::integer()); diff --git a/src/core/etl/src/Flow/ETL/PHP/Type/Logical/Map/MapValue.php b/src/core/etl/src/Flow/ETL/PHP/Type/Logical/Map/MapValue.php index a8dbd723f..1302750a0 100644 --- a/src/core/etl/src/Flow/ETL/PHP/Type/Logical/Map/MapValue.php +++ b/src/core/etl/src/Flow/ETL/PHP/Type/Logical/Map/MapValue.php @@ -24,6 +24,11 @@ public static function float() : self return new self(ScalarType::float()); } + public static function fromType(Type $type) : self + { + return new self($type); + } + public static function integer() : self { return new self(ScalarType::integer()); diff --git a/src/core/etl/src/Flow/ETL/PHP/Type/Native/ArrayType.php b/src/core/etl/src/Flow/ETL/PHP/Type/Native/ArrayType.php new file mode 100644 index 000000000..d9b15c037 --- /dev/null +++ b/src/core/etl/src/Flow/ETL/PHP/Type/Native/ArrayType.php @@ -0,0 +1,37 @@ +empty === $type->empty; + } + + public function isValid(mixed $value) : bool + { + return \is_array($value); + } + + public function toString() : string + { + if ($this->empty) { + return 'array'; + } + + return 'array'; + } +} diff --git a/src/core/etl/src/Flow/ETL/PHP/Type/Native/CallableType.php b/src/core/etl/src/Flow/ETL/PHP/Type/Native/CallableType.php new file mode 100644 index 000000000..09752dc81 --- /dev/null +++ b/src/core/etl/src/Flow/ETL/PHP/Type/Native/CallableType.php @@ -0,0 +1,24 @@ +value = match (\strtolower($value)) { 'integer' => self::INTEGER, 'float', 'double' => self::FLOAT, 'string' => self::STRING, @@ -53,11 +55,31 @@ public static function string(bool $optional = false) : self return new self(self::STRING, $optional); } + public function isBoolean() : bool + { + return $this->value === self::BOOLEAN; + } + public function isEqual(Type $type) : bool { return $type instanceof self && $type->value === $this->value; } + public function isFloat() : bool + { + return $this->value === self::FLOAT; + } + + public function isInteger() : bool + { + return $this->value === self::INTEGER; + } + + public function isString() : bool + { + return $this->value === self::STRING; + } + public function isValid(mixed $value) : bool { if (null === $value && $this->optional) { @@ -82,6 +104,11 @@ public function isValid(mixed $value) : bool return true; } + public function isValidArrayKey() : bool + { + return $this->isString() || $this->isInteger(); + } + public function optional() : bool { return $this->optional; diff --git a/src/core/etl/src/Flow/ETL/PHP/Type/TypeFactory.php b/src/core/etl/src/Flow/ETL/PHP/Type/TypeFactory.php new file mode 100644 index 000000000..73b51ad5b --- /dev/null +++ b/src/core/etl/src/Flow/ETL/PHP/Type/TypeFactory.php @@ -0,0 +1,72 @@ +firstKeyType(); + $firstValue = $detector->firstValueType(); + + if ($detector->isList() && $firstValue) { + return new ListType(ListElement::fromType($firstValue)); + } + + if ($detector->isMap() && $firstKey && $firstValue) { + return new MapType(MapKey::fromType($firstKey), MapValue::fromType($firstValue)); + } + + if ($detector->isStructure()) { + $elements = []; + + foreach ($value as $key => $item) { + $elements[] = new StructureElement($key, $this->getType($item)); + } + + return new StructureType(...$elements); + } + + return new ArrayType([] === \array_filter($value, fn ($value) : bool => null !== $value)); + } + + throw InvalidArgumentException::because('Unsupported type given: ' . \gettype($value)); + } +} diff --git a/src/core/etl/src/Flow/ETL/PHP/Type/Types.php b/src/core/etl/src/Flow/ETL/PHP/Type/Types.php new file mode 100644 index 000000000..0e378cd3f --- /dev/null +++ b/src/core/etl/src/Flow/ETL/PHP/Type/Types.php @@ -0,0 +1,46 @@ +types = \array_map( + fn (string $type) : Type => \unserialize($type), + \array_unique( + \array_map(fn (Type $type) : string => \serialize($type), $types) + ) + ); + $this->first = $this->types[0] ?? null; + } + + public function count() : int + { + return \count($this->types); + } + + public function first() : ?Type + { + return $this->first; + } + + public function without(Type ...$types) : self + { + return new self(...\array_filter($this->types, function (Type $type) use ($types) : bool { + foreach ($types as $withoutType) { + if ($type->isEqual($withoutType)) { + return false; + } + } + + return true; + })); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/PHP/Type/ArrayContentDetectorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/PHP/Type/ArrayContentDetectorTest.php new file mode 100644 index 000000000..603a26416 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/PHP/Type/ArrayContentDetectorTest.php @@ -0,0 +1,285 @@ + [ + [ + ScalarType::integer(), + ], + [ + ScalarType::string(), + ], + true, + ]; + + yield 'simple map' => [ + [ + ScalarType::string(), + ], + [ + ScalarType::string(), + ], + false, + ]; + + yield 'simple structure' => [ + [ + ScalarType::string(), + ], + [ + ScalarType::string(), + new MapType(MapKey::string(), MapValue::string()), + new ListType(ListElement::integer()), + ], + false, + ]; + + yield 'list of unique same structures' => [ + [ + ScalarType::integer(), + ], + [ + new StructureType( + new StructureElement('id', ScalarType::integer()), + new StructureElement('name', ScalarType::string()) + ), + ], + true, + ]; + + yield 'map with string key, of maps string with string' => [ + [ + ScalarType::string(), + ], + [ + new MapType( + MapKey::string(), + MapValue::map( + new MapType(MapKey::string(), MapValue::string()), + ) + ), + ], + false, + ]; + + yield 'array of nulls' => [ + [ + ScalarType::string(), + ], + [ + new NullType(), + new NullType(), + new NullType(), + ], + false, + ]; + } + + public static function provide_map_data() : \Generator + { + yield 'simple list' => [ + [ + ScalarType::integer(), + ], + [ + ScalarType::string(), + ], + false, + ]; + + yield 'simple map' => [ + [ + ScalarType::string(), + ], + [ + ScalarType::string(), + ], + true, + ]; + + yield 'simple structure' => [ + [ + ScalarType::string(), + ], + [ + ScalarType::string(), + new MapType(MapKey::string(), MapValue::string()), + new ListType(ListElement::integer()), + ], + false, + ]; + + yield 'list of unique same structures' => [ + [ + ScalarType::integer(), + ], + [ + new StructureType( + new StructureElement('id', ScalarType::integer()), + new StructureElement('name', ScalarType::string()) + ), + ], + false, + ]; + + yield 'map with string key, of maps string with string' => [ + [ + ScalarType::string(), + ], + [ + new MapType( + MapKey::string(), + MapValue::map( + new MapType(MapKey::string(), MapValue::string()), + ) + ), + ], + true, + ]; + + yield 'array of nulls' => [ + [ + ScalarType::string(), + ], + [ + new NullType(), + new NullType(), + new NullType(), + ], + false, + ]; + } + + public static function provide_structure_data() : \Generator + { + yield 'simple list' => [ + [ + ScalarType::integer(), + ], + [ + ScalarType::string(), + ], + false, + ]; + + yield 'simple map' => [ + [ + ScalarType::string(), + ], + [ + ScalarType::string(), + ], + false, + ]; + + yield 'simple structure' => [ + [ + ScalarType::string(), + ], + [ + ScalarType::string(), + new MapType(MapKey::string(), MapValue::string()), + new ListType(ListElement::integer()), + ], + true, + ]; + + yield 'list of unique same structures' => [ + [ + ScalarType::integer(), + ], + [ + new StructureType( + new StructureElement('id', ScalarType::integer()), + new StructureElement('name', ScalarType::string()) + ), + ], + false, + ]; + + yield 'map with string key, of maps string with string' => [ + [ + ScalarType::string(), + ], + [ + new MapType( + MapKey::string(), + MapValue::map( + new MapType(MapKey::string(), MapValue::string()), + ) + ), + ], + false, + ]; + + yield 'array of nulls' => [ + [ + ScalarType::string(), + ], + [ + new NullType(), + new NullType(), + new NullType(), + ], + false, + ]; + + yield 'array of empty arrays' => [ + [ + ScalarType::string(), + ], + [ + ArrayType::empty(), + ArrayType::empty(), + ArrayType::empty(), + ], + false, + ]; + } + + #[DataProvider('provide_list_data')] + public function test_list_data(array $keys, array $values, bool $expected) : void + { + $this->assertSame( + $expected, + (new ArrayContentDetector(new Types(...$keys), new Types(...$values)))->isList() + ); + } + + #[DataProvider('provide_map_data')] + public function test_map_data(array $keys, array $values, bool $expected) : void + { + $this->assertSame( + $expected, + (new ArrayContentDetector(new Types(...$keys), new Types(...$values)))->isMap() + ); + } + + #[DataProvider('provide_structure_data')] + public function test_structure_data(array $keys, array $values, bool $expected) : void + { + $this->assertSame( + $expected, + (new ArrayContentDetector(new Types(...$keys), new Types(...$values)))->isStructure() + ); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/PHP/Type/Native/ArrayTypeTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/PHP/Type/Native/ArrayTypeTest.php new file mode 100644 index 000000000..14764d73c --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/PHP/Type/Native/ArrayTypeTest.php @@ -0,0 +1,69 @@ +assertTrue( + (new ArrayType())->isEqual(new ArrayType) + ); + $this->assertTrue( + ArrayType::empty()->isEqual(ArrayType::empty()) + ); + $this->assertFalse( + (new ArrayType())->isEqual(new MapType(MapKey::string(), MapValue::float())) + ); + $this->assertFalse( + (new ArrayType())->isEqual(ScalarType::float()) + ); + $this->assertFalse( + ArrayType::empty()->isEqual(new ArrayType) + ); + } + + public function test_to_string() : void + { + $this->assertSame( + 'array', + (new ArrayType())->toString() + ); + $this->assertSame( + 'array', + ArrayType::empty()->toString() + ); + } + + public function test_valid() : void + { + $this->assertTrue( + (new ArrayType())->isValid([]) + ); + $this->assertTrue( + (new ArrayType())->isValid(['one']) + ); + $this->assertTrue( + (new ArrayType())->isValid([1]) + ); + $this->assertFalse( + (new ArrayType())->isValid(null) + ); + $this->assertFalse( + (new ArrayType())->isValid('one') + ); + $this->assertFalse( + (new ArrayType())->isValid(true) + ); + $this->assertFalse( + (new ArrayType())->isValid(123) + ); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/PHP/Type/Native/CallableTypeTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/PHP/Type/Native/CallableTypeTest.php new file mode 100644 index 000000000..cd9d3fe03 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/PHP/Type/Native/CallableTypeTest.php @@ -0,0 +1,50 @@ +assertTrue( + (new CallableType)->isEqual(new CallableType) + ); + $this->assertFalse( + (new CallableType)->isEqual(new MapType(MapKey::string(), MapValue::float())) + ); + $this->assertFalse( + (new CallableType)->isEqual(ScalarType::float()) + ); + } + + public function test_to_string() : void + { + $this->assertSame( + 'callable', + (new CallableType)->toString() + ); + } + + public function test_valid() : void + { + $this->assertTrue( + (new CallableType)->isValid('printf') + ); + $this->assertFalse( + (new CallableType)->isValid('one') + ); + $this->assertFalse( + (new CallableType)->isValid([1, 2]) + ); + $this->assertFalse( + (new CallableType)->isValid(123) + ); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/PHP/Type/Native/NullTypeTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/PHP/Type/Native/NullTypeTest.php new file mode 100644 index 000000000..5c62ec921 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/PHP/Type/Native/NullTypeTest.php @@ -0,0 +1,50 @@ +assertTrue( + (new NullType)->isEqual(new NullType) + ); + $this->assertFalse( + (new NullType)->isEqual(new MapType(MapKey::string(), MapValue::float())) + ); + $this->assertFalse( + (new NullType)->isEqual(ScalarType::float()) + ); + } + + public function test_to_string() : void + { + $this->assertSame( + 'null', + (new NullType)->toString() + ); + } + + public function test_valid() : void + { + $this->assertTrue( + (new NullType)->isValid(null) + ); + $this->assertFalse( + (new NullType)->isValid('one') + ); + $this->assertFalse( + (new NullType)->isValid([1, 2]) + ); + $this->assertFalse( + (new NullType)->isValid(123) + ); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/PHP/Type/Native/ResourceTypeTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/PHP/Type/Native/ResourceTypeTest.php new file mode 100644 index 000000000..f7f2e2d86 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/PHP/Type/Native/ResourceTypeTest.php @@ -0,0 +1,52 @@ +assertTrue( + (new ResourceType)->isEqual(new ResourceType) + ); + $this->assertFalse( + (new ResourceType)->isEqual(new MapType(MapKey::string(), MapValue::float())) + ); + $this->assertFalse( + (new ResourceType)->isEqual(ScalarType::float()) + ); + } + + public function test_to_string() : void + { + $this->assertSame( + 'resource', + (new ResourceType)->toString() + ); + } + + public function test_valid() : void + { + $handle = \fopen('php://temp/max', 'r+b'); + $this->assertTrue( + (new ResourceType)->isValid($handle) + ); + \fclose($handle); + $this->assertFalse( + (new ResourceType)->isValid('one') + ); + $this->assertFalse( + (new ResourceType)->isValid([1, 2]) + ); + $this->assertFalse( + (new ResourceType)->isValid(123) + ); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/PHP/Type/TypeFactoryTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/PHP/Type/TypeFactoryTest.php new file mode 100644 index 000000000..98e381117 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/PHP/Type/TypeFactoryTest.php @@ -0,0 +1,307 @@ + [ + null, + NullType::class, + 'null', + ]; + + yield 'simple list' => [ + [ + 'one', + 'two', + 'three', + ], + ListType::class, + 'list', + ]; + + yield 'simple map' => [ + [ + 'one' => 'one', + 'two' => 'two', + 'three' => 'three', + ], + MapType::class, + 'map', + ]; + + yield 'simple structure' => [ + [ + 'one' => 'one', + 'two' => 'two', + 'three' => 'three', + 'list' => [ + 1, 2, 3, + ], + 'map' => [ + 'one' => 'one', + 'two' => 'two', + 'three' => 'three', + ], + ], + StructureType::class, + 'structure{one: string, two: string, three: string, list: list, map: map}', + ]; + + yield 'list of unique same structures' => [ + [ + [ + 'id' => 1, + 'name' => 'Test 1', + ], + [ + 'id' => 2, + 'name' => 'Test 2', + ], + ], + ListType::class, + 'list', + ]; + + yield 'map with string key, of maps string with string' => [ + [ + 'one' => [ + 'map' => [ + 'one' => 'one', + 'two' => 'two', + 'three' => 'three', + ], + ], + 'two' => [ + 'map' => [ + 'one' => 'one', + 'two' => 'two', + 'three' => 'three', + ], + ], + ], + MapType::class, + 'map>>', + ]; + + yield 'empty array' => [ + [], + ArrayType::class, + 'array', + ]; + + yield 'list with null' => [ + [ + 1, + 2, + 3, + null, + 5, + ], + ListType::class, + 'list', + ]; + + yield 'one level list' => [ + [ + 'one', + 'two', + 'three', + 'map' => [ + 'one' => 'one', + 'two' => 'two', + 'three' => 'three', + ], + 'list' => [ + 1, 2, 3, + ], + ], + ArrayType::class, + 'array', + ]; + + yield 'two level list' => [ + [ + 'one', + 'two', + 'three', + 'map' => [ + 'one' => 'one', + 'two' => 'two', + 'three' => 'three', + 'list' => [ + 1, 2, 3, + ], + 'map' => [ + 'one' => 'one', + 'two' => 'two', + 'three' => 'three', + ], + ], + 'list' => [ + 1, 2, 3, + ], + ], + ArrayType::class, + 'array', + ]; + + yield 'complex structure' => [ + [ + [ + 'id' => 1, + 'name' => 'Test 1', + 'active' => true, + ], + [ + 'id' => 2, + 'name' => 'Test 2', + ], + ], + ArrayType::class, + 'array', + ]; + + yield 'list of lists' => [ + [ + [ + 1, 2, 3, + ], + [ + 4, 5, 6, + ], + ], + ListType::class, + 'list>', + ]; + + yield 'list of lists with null' => [ + [ + [ + 1, 2, 3, + ], + null, + [ + 4, 5, 6, + ], + ], + ListType::class, + 'list>', + ]; + + yield 'list of lists with empty' => [ + [ + [ + 1, 2, 3, + ], + [ + ], + [ + 4, 5, 6, + ], + ], + ListType::class, + 'list>', + ]; + + yield 'list of lists with array of nulls' => [ + [ + [ + 1, 2, 3, + ], + [ + null, + ], + [ + 4, 5, 6, + ], + ], + ListType::class, + 'list>', + ]; + + yield 'map with null' => [ + [ + 'one' => 'one', + 'two' => null, + 'three' => 'three', + ], + MapType::class, + 'map', + ]; + } + + public static function provide_object_data() : \Generator + { + yield 'stdclass' => [ + new \stdClass(), + ]; + + yield 'datetime' => [ + new \DateTime(), + ]; + + yield 'datetime immutable' => [ + new \DateTimeImmutable(), + ]; + } + + public static function provide_scalar_data() : \Generator + { + yield 'bool' => [ + true, + 'boolean', + ]; + + yield 'string' => [ + 'test', + 'string', + ]; + + yield 'float' => [ + 1.666, + 'float', + ]; + + yield 'integer' => [ + 123456789, + 'integer', + ]; + } + + #[DataProvider('provide_data')] + public function test_data($data, string $class, string $description) : void + { + $type = (new TypeFactory())->getType($data); + + $this->assertInstanceOf($class, $type); + $this->assertSame($description, $type->toString()); + } + + #[DataProvider('provide_object_data')] + public function test_object_types(mixed $data) : void + { + $this->assertInstanceOf(ObjectType::class, (new TypeFactory())->getType($data)); + } + + #[DataProvider('provide_scalar_data')] + public function test_scalar_types(mixed $data, string $description) : void + { + $type = (new TypeFactory())->getType($data); + $this->assertInstanceOf(ScalarType::class, $type); + $this->assertSame($description, $type->toString()); + } +}