diff --git a/composer.json b/composer.json index a3422c1..bb32313 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ }, "require-dev": { "phpunit/phpunit": "^8.0", - "mnapoli/hard-mode": "^0.1.1" + "mnapoli/hard-mode": "^0.1.1", + "ramsey/uuid": "^3.8" } } diff --git a/src/EntityManager/EntityManager.php b/src/EntityManager/EntityManager.php new file mode 100644 index 0000000..5a7e7de --- /dev/null +++ b/src/EntityManager/EntityManager.php @@ -0,0 +1,38 @@ +client = $client; + + $this->serializer = $serializer; + } + + public function persist($entity): void + { + } + + public function fetch($entity): object + { + } + + public function delete($entity): void + { + } + + // create + // read + // update + // delete +} diff --git a/src/Mapping/ClassMapping.php b/src/Mapping/ClassMapping.php new file mode 100644 index 0000000..23c373d --- /dev/null +++ b/src/Mapping/ClassMapping.php @@ -0,0 +1,103 @@ +className = $className; + $this->mapping = $config; + $this->tableName = $tableName; + } + + public static function fromArray(string $tableName, string $className, array $config): ClassMapping + { + if (\class_exists($className) === false) { + throw new ClassNameInvalidException('Could not map ' . $className . ' as the class was not found'); + } + + if (empty($config['fields']) === false) { + $fields = self::mapProperties($className, $config['fields']); + + $config['fields'] = $fields; + } + + return new static($tableName, $className, $config); + } + + public function getClassName(): string + { + return $this->className; + } + + public function getTableName(): string + { + return $this->tableName; + } + + public function getMappedProperty(string $propertyName): DynamoDBField + { + if (empty($this->mapping['fields']) === true) { // todo: add a test for this + throw new NoFieldsMappedForClassException('You have tried to access mapping for a class which has no mapped properties'); + } + + if ($this->hasMappedProperty($propertyName) === false) { + throw new MappingNotFoundException('Mapping for ' . $propertyName . ' could not be found'); + } + + return $this->mapping['fields'][$propertyName]; + } + + public function hasMappedProperty(string $propertyName): bool + { + // todo: add a test for this + if (\array_key_exists('fields', $this->mapping) === false) { + return false; + } + + return \array_key_exists($propertyName, $this->mapping['fields']); + } + + /** + * @param array $fields + * @return array + * @throws CannotMapNonExistentFieldException + * @throws \ReflectionException + */ + private static function mapProperties(string $className, array $fields): array + { + $reflection = new \ReflectionClass($className); + + $classProperties = array_reduce($reflection->getProperties(), static function ($carry, $item) { + $carry[] = $item->getName(); + return $carry; + }, []); + + $mappedFields = []; + $factory = new FieldMappingFactory; + + foreach ($fields as $classField => $type) { + if (\in_array($classField, $classProperties, false) === false) { + throw new CannotMapNonExistentFieldException('The field ' . $classField . ' does not exist in ' . $className); + } + + $mappedFields[$classField] = $factory->getDynamoDbType($type); + } + + return $mappedFields; + } +} diff --git a/src/Mapping/Exception/CannotMapNonExistentFieldException.php b/src/Mapping/Exception/CannotMapNonExistentFieldException.php new file mode 100644 index 0000000..89945bd --- /dev/null +++ b/src/Mapping/Exception/CannotMapNonExistentFieldException.php @@ -0,0 +1,7 @@ +originalFieldType = $originalFieldType; + } + + public function getDynamoDBFieldType(): string + { + return 'S'; + } + + public function getOriginalFieldType(): string + { + return $this->originalFieldType; + } + + public function castToDynamoDBType($value) + { + return $value->format(\DateTime::ATOM); + } + + public function restoreFromDynamoDBType($value): \DateTimeInterface + { + return \DateTime::createFromFormat(\DateTime::ATOM, $value); + } +} diff --git a/src/Mapping/Field/DynamoDBField.php b/src/Mapping/Field/DynamoDBField.php new file mode 100644 index 0000000..1d6d2d5 --- /dev/null +++ b/src/Mapping/Field/DynamoDBField.php @@ -0,0 +1,14 @@ +originalFieldType = $originalFieldType; + } + + public function getDynamoDBFieldType(): string + { + return 'N'; + } + + public function getOriginalFieldType(): string + { + return $this->originalFieldType; + } + + public function castToDynamoDBType($value) + { + return $value; + } + + public function restoreFromDynamoDBType($value) + { + return $value; + } +} diff --git a/src/Mapping/Field/StringField.php b/src/Mapping/Field/StringField.php new file mode 100644 index 0000000..d7f836a --- /dev/null +++ b/src/Mapping/Field/StringField.php @@ -0,0 +1,40 @@ +originalFieldType = $originalFieldType; + } + + public function getDynamoDBFieldType(): string + { + return 'S'; + } + + public function getOriginalFieldType(): string + { + return $this->originalFieldType; + } + + public function castToDynamoDBType($value): string + { + return (string)$value; + } + + public function restoreFromDynamoDBType($value) + { + if ('uuid' === $this->originalFieldType) { + return Uuid::fromString($value); + } + + return (string)$value; + } +} diff --git a/src/Mapping/FieldMappingFactory.php b/src/Mapping/FieldMappingFactory.php new file mode 100644 index 0000000..4baf40d --- /dev/null +++ b/src/Mapping/FieldMappingFactory.php @@ -0,0 +1,34 @@ +mapping = $mapping; + } + + public static function fromConfigArray(array $config): self + { + if (\array_key_exists('tables', $config) === false || empty($config['tables'])) { + throw new NoTableSpeficiedException('Dynamap needs at least one table to work with!'); + } + + $mapping = \array_reduce($config['tables'], static function ($carry, $item) { + foreach ($item['mappings'] as $classname => $properties) { + $carry[] = ClassMapping::fromArray($item['name'], $classname, $properties); + } + return $carry; + }, []); + + return new static($mapping); + } + + public function getTableFor(string $className): string + { + // todo: add a test for this + if (\class_exists($className) === false) { + throw new ClassNameInvalidException('Could not get table for ' . $className . ' as the class was not found'); + } + + foreach ($this->mapping as $mapping) { + if ($mapping->getClassName() === $className) { + return $mapping->getTableName(); + } + } + + // todo: add a test for this + throw new ClassNotMappedException('The class ' . $className . ' was not found in the mapping configuration'); + } + + public function isClassPropertyMapped(string $className, string $propertyName): bool + { + // todo: add a test for this + if (\class_exists($className) === false) { + return false; + } + + foreach ($this->mapping as $mapping) { + if ($mapping->getClassName() === $className) { + return $mapping->hasMappedProperty($propertyName); + } + } + + return false; + } + + // todo: add a test for this + public function getTypeFor(string $className, string $propertyName) + { + if ($this->isClassPropertyMapped($className, $propertyName) === false) { + throw new MappingNotFoundException('Mapping for ' . $propertyName . ' could not be found'); + } + + foreach ($this->mapping as $mapping) { + if ($mapping->getClassName() === $className) { + if ($mapping->hasMappedProperty($propertyName) === false) { + // todo: is this the right exception class? add a test for this + throw new MappingNotFoundException('Property ' . $propertyName . ' is not mapped'); + } + + return $mapping->getMappedProperty($propertyName); + } + } + } +} diff --git a/src/Serializer/EntitySerializer.php b/src/Serializer/EntitySerializer.php new file mode 100644 index 0000000..231f869 --- /dev/null +++ b/src/Serializer/EntitySerializer.php @@ -0,0 +1,81 @@ +mapping = $mapping; + } + + public function serialize(object $entity): array + { + $properties = []; + $className = \get_class($entity); + + $reflection = new \ReflectionObject($entity); + foreach ($reflection->getProperties() as $property) { + $property->setAccessible(true); + if (null === $property->getValue($entity)) { + # todo: add a test for this + continue; + } + if ($this->mapping->isClassPropertyMapped($className, $property->getName()) === true) { + $properties[$reflection->getName() . '_' . $property->getName()] = $this->transform($entity, $property); + } else { + $properties[$reflection->getName() . '_' . $property->getName()] = $property->getValue($entity); + } + } + + return $properties; + } + + public function unserialize(array $serialized): object + { + + $objects = []; + + foreach ($serialized as $key => $value) { + + $prefixLength = \strpos($key, '_'); + $className = \substr($key, 0, $prefixLength); + $propertyName = \substr($key, $prefixLength + 1); + + if (false === \in_array($className, $objects)) { + $objects[] = $className; + $concretion = (new \ReflectionClass($className))->newInstance(); + $reflection = new \ReflectionClass($className); + } + + $property = $reflection->getProperty($propertyName); + $property->setAccessible(true); + + if ($this->mapping->isClassPropertyMapped($className, $propertyName) === true) { + $value = $this->untransform($concretion, $propertyName, $value); + } + + $property->setValue($concretion, $value); + } + + return $concretion; + } + + private function transform($entity, $property) + { + $type = $this->mapping->getTypeFor(\get_class($entity), $property->getName()); + + return $type->castToDynamoDBType($property->getValue($entity)); + } + + private function untransform($entity, $property, $value) { + $type = $this->mapping->getTypeFor(\get_class($entity), $property); + + return $type->restoreFromDynamoDBType($value); + } +} diff --git a/tests/EntityManager/EntityManagerTest.php b/tests/EntityManager/EntityManagerTest.php new file mode 100644 index 0000000..f5b3313 --- /dev/null +++ b/tests/EntityManager/EntityManagerTest.php @@ -0,0 +1,50 @@ +client = $this->createMock(DynamoDbClient::class); + $this->serializer = new EntitySerializer( + Mapping::fromConfigArray([ + 'tables' => [ + [ + 'name' => 'my_table', + 'mappings' => [ + Article::class => [ + 'keys' => [ + 'id' => 'integer', + ], + ], + ], + ], + ], + ]) + ); + } + + public function test persisting new entity(): void + { + $em = new EntityManager($this->client, $this->serializer); + + $article = new Article; + + $em->persist($article); + $this->assertSame(1, 1); + } +} diff --git a/tests/Fixture/Article.php b/tests/Fixture/Article.php index a73122c..a5db98b 100644 --- a/tests/Fixture/Article.php +++ b/tests/Fixture/Article.php @@ -2,6 +2,8 @@ namespace Dynamap\Test\Fixture; +use Ramsey\Uuid\Uuid; + class Article { /** @var int */ @@ -16,19 +18,27 @@ class Article /** @var bool */ private $published = false; + /** @var int */ + private $numComments = 0; + /** @var \DateTimeImmutable */ private $createdAt; /** @var \DateTimeImmutable|null */ private $publishedAt; - public function __construct(int $id) + private $authorComment; + + public function __construct($id = null) { + if ($id === null) { + $id = Uuid::uuid4(); + } $this->id = $id; $this->createdAt = new \DateTimeImmutable; } - public function getId(): int + public function getId() { return $this->id; } @@ -75,8 +85,33 @@ public function setPublicationDate(?\DateTimeImmutable $publishedAt): void $this->publishedAt = $publishedAt; } - public function getPublicationDate(): ?\DateTimeImmutable + public function getPublicationDate(): ?\DateTimeInterface { return $this->publishedAt; } + + public function setNumComments(int $numComments): void + { + $this->numComments = $numComments; + } + + public function getNumComments(): ?int + { + return $this->numComments; + } + + public function setRating(float $rating): void + { + $this->rating = $rating; + } + + public function setAuthorComment(string $comment): void + { + $this->authorComment = $comment; + } + + public function getAuthorComment(): ?string + { + return $this->authorComment; + } } diff --git a/tests/Fixture/Author.php b/tests/Fixture/Author.php new file mode 100644 index 0000000..4980149 --- /dev/null +++ b/tests/Fixture/Author.php @@ -0,0 +1,36 @@ +name = $name; + } + + public function getName(): string + { + return $this->name; + } + +// public function addArticle(Article $article) +// { +// $article->setAuthor($this); +// $this->articles[] = $article; +// } +// +// public function getArticles(): array +// { +// return $this->articles; +// } +} diff --git a/tests/Fixture/Tag.php b/tests/Fixture/Tag.php new file mode 100644 index 0000000..bdd3507 --- /dev/null +++ b/tests/Fixture/Tag.php @@ -0,0 +1,7 @@ +expectException(ClassNameInvalidException::class); + ClassMapping::fromArray('my_table', 'UnknownClass', []); + } + + public function test fields are mapped(): void + { + $mapping = [ + 'fields' => [ + 'id' => 'uuid', + 'name' => 'string', + 'createdAt' => 'datetime', + 'rating' => 'float', + 'numComments' => 'integer', + 'published' => 'boolean', + ], + ]; + + $classMapping = ClassMapping::fromArray('my_table', Article::class, $mapping); + + $this->assertSame('S', $classMapping->getMappedProperty('id')->getDynamoDBFieldType()); + $this->assertSame('S', $classMapping->getMappedProperty('name')->getDynamoDBFieldType()); + $this->assertSame('S', $classMapping->getMappedProperty('createdAt')->getDynamoDBFieldType()); + $this->assertSame('N', $classMapping->getMappedProperty('rating')->getDynamoDBFieldType()); + $this->assertSame('N', $classMapping->getMappedProperty('numComments')->getDynamoDBFieldType()); + $this->assertSame('BOOL', $classMapping->getMappedProperty('published')->getDynamoDBFieldType()); + } + + public function test non existent fields cannot be mapped(): void + { + $mapping = [ + 'fields' => [ + 'non_existent_field' => 'string', + ], + ]; + + $this->expectException(CannotMapNonExistentFieldException::class); + ClassMapping::fromArray('my_table', Article::class, $mapping); + } +} diff --git a/tests/Mapping/MappingTest.php b/tests/Mapping/MappingTest.php new file mode 100644 index 0000000..5559728 --- /dev/null +++ b/tests/Mapping/MappingTest.php @@ -0,0 +1,75 @@ +expectException(NoTableSpeficiedException::class); + Mapping::fromConfigArray([]); + } + + public function test an exception is thrown when tables array is empty(): void + { + $this->expectException(NoTableSpeficiedException::class); + Mapping::fromConfigArray([ + 'tables' => [], + ]); + } + + public function test a table mapping is created(): void + { + $mapping = Mapping::fromConfigArray([ + 'tables' => [ + [ + 'name' => 'my_table', + 'mappings' => [ + Article::class => [], + Author::class => [], + ], + ], + [ + // if you're thinking about using mulitple tables, go back and read the AWS docs on why you shouldn't. + // then (and only then) come back and think about if you actually _really_ do want to do this. + 'name' => 'other_table', + 'mappings' => [ + Tag::class => [], + ], + ], + ], + ]); + + $this->assertSame('my_table', $mapping->getTableFor(Article::class)); + $this->assertSame('my_table', $mapping->getTableFor(Author::class)); + $this->assertSame('other_table', $mapping->getTableFor(Tag::class)); + } + + public function test a property mapping status can be queried() + { + $mapping = Mapping::fromConfigArray([ + 'tables' => [ + [ + 'name' => 'my_table', + 'mappings' => [ + Article::class => [ + 'fields' => [ + 'id' => 'integer', + ], + ], + ], + ], + ], + ]); + + $this->assertTrue($mapping->isClassPropertyMapped(Article::class, 'id')); + $this->assertFalse($mapping->isClassPropertyMapped(Article::class, 'other_field')); + } +} diff --git a/tests/Serializer/EntitySerializerTest.php b/tests/Serializer/EntitySerializerTest.php new file mode 100644 index 0000000..e72691d --- /dev/null +++ b/tests/Serializer/EntitySerializerTest.php @@ -0,0 +1,102 @@ +mapping = Mapping::fromConfigArray([ + 'tables' => [ + [ + 'name' => 'my_table', + 'mappings' => [ + Article::class => [ + 'fields' => [ + 'id' => 'uuid', + 'name' => 'string', + 'numComments' => 'integer', + 'rating' => 'float', + 'published' => 'boolean', + 'publishedAt' => 'datetime', + ], + ], + ], + ], + ], + ]); + } + + public function test an entity is seralized(): void + { + $serializer = new EntitySerializer($this->mapping); + + $uuid = Uuid::uuid4(); + + $article = new Article($uuid); + $article->setName('Test Article'); + $article->setNumComments(5); + $article->setRating(3.8); + $article->publish(); + + $result = $serializer->serialize($article); + + $this->assertSame($result['Dynamap\Test\Fixture\Article_id'], $uuid->toString()); + $this->assertSame($result['Dynamap\Test\Fixture\Article_name'], 'Test Article'); + $this->assertSame($result['Dynamap\Test\Fixture\Article_numComments'], 5); + $this->assertSame($result['Dynamap\Test\Fixture\Article_rating'], 3.8); + $this->assertTrue($result['Dynamap\Test\Fixture\Article_published']); + $this->assertSame($result['Dynamap\Test\Fixture\Article_publishedAt'], $article->getPublicationDate()->format(\DateTime::ATOM)); + } + + public function test an entity has non mapped properties serialized(): void + { + $serializer = new EntitySerializer($this->mapping); + + $uuid = Uuid::uuid4(); + $authorComment = 'This is a really great article about some tech thing'; + + $article = new Article($uuid); + $article->setName('Test article with unmapped data...'); + $article->setAuthorComment($authorComment); + + $result = $serializer->serialize($article); + + $this->assertSame($authorComment, $result['Dynamap\Test\Fixture\Article_authorComment']); + } + + public function test an entity is_unserialized(): void + { + $serializer = new EntitySerializer($this->mapping); + $uuid = Uuid::uuid4(); + + $article = new Article($uuid); + $article->setName('Test Article'); + $article->setNumComments(5); + $article->setRating(3.8); + $article->setAuthorComment('This is a really great article about some tech thing'); + $article->publish(); + + $serializedArticle = $serializer->serialize($article); + + $result = $serializer->unserialize($serializedArticle); + + $this->assertEquals($article->getId(), $result->getId()); + $this->assertSame($article->getName(), $result->getName()); + $this->assertSame($article->getNumComments(), $result->getNumComments()); + $this->assertSame($article->getRating(), $result->getRating()); + $this->assertSame($article->getAuthorComment(), $result->getAuthorComment()); + $this->assertEquals( + $article->getPublicationDate()->format(\DATE_ATOM), + $result->getPublicationDate()->format(\DATE_ATOM) + ); + } +} diff --git a/tests/TestCase/DynamapTestCase.php b/tests/TestCase/DynamapTestCase.php new file mode 100644 index 0000000..bdc0281 --- /dev/null +++ b/tests/TestCase/DynamapTestCase.php @@ -0,0 +1,79 @@ + 'latest', + 'endpoint' => 'http://localhost:8000/', + // DynamoDB local requires those parameters, even with random values + 'region' => 'us-east-1', + 'credentials' => [ + 'key' => 'FAKE_KEY', + 'secret' => 'FAKE_SECRET', + ], + ]); + + try { + $dynamoDb->deleteTable([ + 'TableName' => 'articles', + ]); + } catch (DynamoDbException $e) { + // The table doesn't exist the first time + } + + $dynamoDb->createTable([ + 'TableName' => 'articles', + 'KeySchema' => [ + [ + 'AttributeName' => 'id', + 'KeyType' => 'HASH', + ], + ], + 'AttributeDefinitions' => [ + [ + 'AttributeName' => 'id', + 'AttributeType' => 'N', + ], + ], + 'ProvisionedThroughput' => [ + 'WriteCapacityUnits' => 5, + 'ReadCapacityUnits' => 5, + ], + ]); + + $mapping = [ + Article::class => [ + 'table' => 'articles', + 'keys' => [ + 'id', + ], + ], + 'UnknownClass' => [ // This is a class that doesn't exist + 'table' => 'articles', + 'keys' => [ + 'id', + ], + ], + \stdClass::class => [ + 'table' => 'foo', // This is a table that doesn't exist + 'keys' => [ + 'id', + ], + ], + ]; + $this->dynamap = new Dynamap($dynamoDb, $mapping); + } +}