diff --git a/bundle/src/Api/SudoObject/SudoObjectFactory.php b/bundle/src/Api/SudoObject/SudoObjectFactory.php new file mode 100644 index 0000000..a37bdb1 --- /dev/null +++ b/bundle/src/Api/SudoObject/SudoObjectFactory.php @@ -0,0 +1,122 @@ + ['order_forms'], + * ] + */ +class SudoObjectFactory +{ + + /** + * @param array $relationships + * @return array + */ + public function create( + object $entity, + array $relationships = [] + ): array { + assert(str_starts_with(get_class($entity), 'App\\Entity\\')); + + $return = []; + + $relationshipsToInclude = $relationships[get_class($entity)] ?? []; + $reflectionClass = new \ReflectionClass($entity); + $properties = $reflectionClass->getProperties(); + + foreach ($properties as $property) { + $column = $this->getAttribute($property, ORM\Column::class); + + if ($column) { + $return[$property->getName()] = $this->getPropertyValue($entity, $property); + continue; + } + + // to continue, it must be a relationship that is explicitly included + if (!in_array($property->getName(), $relationshipsToInclude)) { + continue; + } + + $oneToMany = $this->getAttribute($property, ORM\OneToMany::class); + $manyToOne = $this->getAttribute($property, ORM\ManyToOne::class); + + if ($oneToMany) { + /** @var Collection $manyEntities */ + $manyEntities = $property->getValue($entity); + $manyEntitiesArray = []; + + foreach ($manyEntities as $manyEntity) { + $manyEntitiesArray[] = $this->create($manyEntity, $relationships); + } + + $return[$property->getName()] = $manyEntitiesArray; + continue; + } + + if ($manyToOne) { + $parentEntity = $property->getValue($entity); + + if ($parentEntity === null) { + $return[$property->getName()] = null; + continue; + } + + assert(is_object($parentEntity)); + $return[$property->getName()] = $this->create($parentEntity, $relationships); + continue; + } + + throw new \LogicException('should not reach here'); + } + + return $return; + } + + private function getPropertyValue(object $entity, \ReflectionProperty $property): mixed + { + $rawValue = $property->getValue($entity); + + if ($rawValue instanceof \DateTimeImmutable) { + return $rawValue->getTimestamp(); + } + + if ($rawValue instanceof \BackedEnum) { + return $rawValue->value; + } + + return $rawValue; + } + + /** + * @template T of object + * @param class-string $attributeClass + * @return ?\ReflectionAttribute + */ + private function getAttribute(\ReflectionProperty $property, string $attributeClass): ?object + { + $attrs = $property->getAttributes($attributeClass); + + if (count($attrs) === 0) { + return null; + } + + assert(count($attrs) === 1, 'more than one expected attributes of ' . $attributeClass); + + /** @var \ReflectionAttribute $attr */ + $attr = $attrs[0]; + + return $attr; + } + +} diff --git a/tests/Bundle/Api/SudoObject/SudoObjectFactoryTest.php b/tests/Bundle/Api/SudoObject/SudoObjectFactoryTest.php new file mode 100644 index 0000000..7ea1ccd --- /dev/null +++ b/tests/Bundle/Api/SudoObject/SudoObjectFactoryTest.php @@ -0,0 +1,227 @@ +factory = new SudoObjectFactory(); + } + + public function test_basic_column_properties(): void + { + $entity = new \App\Entity\SimpleEntity('hello', 42, null); + $result = $this->factory->create($entity); + + $this->assertSame([ + 'name' => 'hello', + 'count' => 42, + 'nullable' => null, + ], $result); + } + + public function test_non_column_properties_are_excluded(): void + { + $entity = new \App\Entity\SimpleEntity('hello', 42, null); + $result = $this->factory->create($entity); + + $this->assertArrayNotHasKey('excluded', $result); + } + + public function test_datetime_immutable_is_converted_to_timestamp(): void + { + $dt = new \DateTimeImmutable('@1700000000'); + $entity = new \App\Entity\EntityWithDatetime($dt); + $result = $this->factory->create($entity); + + $this->assertSame(1700000000, $result['created_at']); + } + + public function test_backed_enum_is_converted_to_value(): void + { + $entity = new \App\Entity\EntityWithEnum(\App\Enum\SudoFactoryStatus::Active); + $result = $this->factory->create($entity); + + $this->assertSame('active', $result['status']); + } + + public function test_one_to_many_relationship_included_when_specified(): void + { + $child1 = new \App\Entity\ChildEntity('first'); + $child2 = new \App\Entity\ChildEntity('second'); + $parent = new \App\Entity\ParentEntity('parent', [$child1, $child2]); + + $result = $this->factory->create($parent, [ + \App\Entity\ParentEntity::class => ['children'], + ]); + + $this->assertSame('parent', $result['name']); + $this->assertCount(2, $result['children']); + $this->assertSame(['name' => 'first'], $result['children'][0]); + $this->assertSame(['name' => 'second'], $result['children'][1]); + } + + public function test_many_to_one_relationship_included_when_specified(): void + { + $parent = new \App\Entity\ParentEntity('parent', []); + $child = new \App\Entity\ChildEntity('child', $parent); + + $result = $this->factory->create($child, [ + \App\Entity\ChildEntity::class => ['parent'], + ]); + + $this->assertSame('child', $result['name']); + $this->assertSame(['name' => 'parent'], $result['parent']); + } + + public function test_many_to_one_null_relationship(): void + { + $child = new \App\Entity\ChildEntity('child', null); + + $result = $this->factory->create($child, [ + \App\Entity\ChildEntity::class => ['parent'], + ]); + + $this->assertNull($result['parent']); + } + + public function test_relationship_not_included_when_not_specified(): void + { + $parent = new \App\Entity\ParentEntity('parent', []); + + $result = $this->factory->create($parent); + + $this->assertArrayNotHasKey('children', $result); + } + + public function test_logic_exception_for_unmapped_relationship_property(): void + { + $entity = new \App\Entity\EntityWithUnmappedRelationship(); + + $this->expectException(\LogicException::class); + + $this->factory->create($entity, [ + \App\Entity\EntityWithUnmappedRelationship::class => ['other'], + ]); + } +} + + +// Helpers + +namespace App\Entity; + +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +class SimpleEntity +{ + #[ORM\Column] + public string $name; + + #[ORM\Column] + public int $count; + + #[ORM\Column] + public ?string $nullable; + + public string $excluded = 'excluded'; + + public function __construct(string $name, int $count, ?string $nullable) + { + $this->name = $name; + $this->count = $count; + $this->nullable = $nullable; + } +} + +#[ORM\Entity] +class EntityWithDatetime +{ + #[ORM\Column] + public \DateTimeImmutable $created_at; + + public function __construct(\DateTimeImmutable $created_at) + { + $this->created_at = $created_at; + } +} + +#[ORM\Entity] +class EntityWithEnum +{ + #[ORM\Column] + public \App\Enum\SudoFactoryStatus $status; + + public function __construct(\App\Enum\SudoFactoryStatus $status) + { + $this->status = $status; + } +} + +#[ORM\Entity] +class ParentEntity +{ + #[ORM\Column] + public string $name; + + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: ChildEntity::class, mappedBy: 'parent')] + public Collection $children; + + /** + * @param string $name + * @param ChildEntity[] $children + */ + public function __construct(string $name, array $children) + { + $this->name = $name; + $this->children = new ArrayCollection($children); + } +} + +#[ORM\Entity] +class ChildEntity +{ + #[ORM\Column] + public string $name; + + #[ORM\ManyToOne(targetEntity: ParentEntity::class)] + public ?ParentEntity $parent; + + public function __construct(string $name, ?ParentEntity $parent = null) + { + $this->name = $name; + $this->parent = $parent; + } +} + +#[ORM\Entity] +class EntityWithUnmappedRelationship +{ + #[ORM\Column] + public string $name = 'test'; + + public string $other = 'other'; +} + + +namespace App\Enum; + +enum SudoFactoryStatus: string +{ + case Active = 'active'; + case Inactive = 'inactive'; +}