Skip to content

Commit 4e276e2

Browse files
committed
feat: support index serialization
1 parent 3f9efad commit 4e276e2

10 files changed

Lines changed: 311 additions & 77 deletions

File tree

.docker/localstack/init-aws.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,13 @@ else
99
echo "Error: $DYNAMODB_TABLE_CREATION_RETURN"
1010
echo "Skipping DynamoDB table creation."
1111
fi
12+
13+
DYNAMODB_TABLE_INDEX_CREATION_RETURN=$(awslocal dynamodb create-table --table-name $DYNAMODB_TABLE_INDEX_NAME --no-sign-request --endpoint-url=$DYNAMODB_LOCAL_ENDPOINT --attribute-definitions $DYNAMODB_TABLE_INDEX_ATTRIBUTE_DEFINITIONS --key-schema $DYNAMODB_TABLE_INDEX_KEY_SCHEMA --provisioned-throughput ReadCapacityUnits=10,WriteCapacityUnits=5 --local-secondary-indexes "$DYNAMODB_TABLE_INDEX_LSI_DEFINITIONS" --global-secondary-indexes "$DYNAMODB_TABLE_INDEX_GSI_DEFINITIONS" 2>&1 >/dev/null)
14+
DYNAMODB_TABLE_INDEX_CREATION_RETURN=$(echo $DYNAMODB_TABLE_INDEX_CREATION_RETURN | xargs)
15+
16+
if [ "$DYNAMODB_TABLE_INDEX_CREATION_RETURN" == "" ] ; then
17+
echo "DynamoDB table '$DYNAMODB_TABLE_INDEX_NAME' created successfully."
18+
else
19+
echo "Error: $DYNAMODB_TABLE_INDEX_CREATION_RETURN"
20+
echo "Skipping DynamoDB table creation."
21+
fi

compose.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,15 @@ services:
3232
LOCALSTACK_PERSISTENCE: 1
3333
EXTRA_CORS_ALLOWED_ORIGINS: "*"
3434
DEBUG: 1
35-
DYNAMODB_TABLE_NAME: test-table
3635
DYNAMODB_LOCAL_ENDPOINT: http://localstack:4566
36+
DYNAMODB_TABLE_NAME: test-table
3737
DYNAMODB_TABLE_KEY_SCHEMA: "AttributeName=PK,KeyType=HASH AttributeName=SK,KeyType=RANGE"
3838
DYNAMODB_TABLE_ATTRIBUTE_DEFINITIONS: "AttributeName=PK,AttributeType=S AttributeName=SK,AttributeType=S"
39+
DYNAMODB_TABLE_INDEX_NAME: test-table-index
40+
DYNAMODB_TABLE_INDEX_KEY_SCHEMA: "AttributeName=PK,KeyType=HASH AttributeName=SK,KeyType=RANGE"
41+
DYNAMODB_TABLE_INDEX_ATTRIBUTE_DEFINITIONS: 'AttributeName=PK,AttributeType=S AttributeName=SK,AttributeType=S AttributeName=GSI1_PK,AttributeType=S AttributeName=GSI1_SK,AttributeType=S AttributeName=LSI1_SK,AttributeType=S'
42+
DYNAMODB_TABLE_INDEX_LSI_DEFINITIONS: '[{"IndexName":"LSI1","KeySchema":[{"AttributeName":"PK","KeyType":"HASH"}, {"AttributeName":"LSI1_SK","KeyType":"RANGE"}],"Projection":{"ProjectionType":"ALL"}}]'
43+
DYNAMODB_TABLE_INDEX_GSI_DEFINITIONS: '[{"IndexName":"GSI1","KeySchema":[{"AttributeName":"GSI1_PK","KeyType":"HASH"}, {"AttributeName":"GSI1_SK","KeyType":"RANGE"}],"Projection":{"ProjectionType":"ALL"},"ProvisionedThroughput":{"ReadCapacityUnits":5,"WriteCapacityUnits":5}}]'
3944
DYNAMODB_SHARE_DB: 1
4045
volumes:
4146
- .docker/localstack/init-aws.sh:/etc/localstack/init/ready.d/init-aws.sh

src/Attribute/AbstractIndex.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace EduardoMarques\DynamoPHP\Attribute;
6+
7+
abstract class AbstractIndex
8+
{
9+
public function __construct(public string $name)
10+
{
11+
if ('' === $this->name) {
12+
throw new InvalidArgumentException(
13+
sprintf('Attribute argument %s::name must not be empty', $this::class)
14+
);
15+
}
16+
}
17+
}

src/Attribute/Entity.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,14 @@
99
#[PHPAttribute(PHPAttribute::TARGET_CLASS)]
1010
class Entity
1111
{
12+
/**
13+
* @param array<int, AbstractIndex> $indexes
14+
*/
1215
public function __construct(
1316
public string $table,
1417
public AbstractKey $partitionKey,
1518
public ?AbstractKey $sortKey = null,
19+
public array $indexes = [],
1620
) {
1721
if ('' === $this->table) {
1822
throw new InvalidArgumentException(

src/Attribute/GlobalIndex.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace EduardoMarques\DynamoPHP\Attribute;
6+
7+
class GlobalIndex extends AbstractIndex
8+
{
9+
public function __construct(
10+
string $name,
11+
public AbstractKey $partitionKey,
12+
public ?AbstractKey $sortKey = null,
13+
) {
14+
parent::__construct($name);
15+
}
16+
}

src/Attribute/LocalIndex.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace EduardoMarques\DynamoPHP\Attribute;
6+
7+
class LocalIndex extends AbstractIndex
8+
{
9+
public function __construct(
10+
string $name,
11+
public AbstractKey $sortKey,
12+
) {
13+
parent::__construct($name);
14+
}
15+
}

src/Metadata/EntityMetadata.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace EduardoMarques\DynamoPHP\Metadata;
66

7+
use EduardoMarques\DynamoPHP\Attribute\AbstractIndex;
78
use EduardoMarques\DynamoPHP\Attribute\AbstractKey;
89
use EduardoMarques\DynamoPHP\Attribute\Attribute;
910
use EduardoMarques\DynamoPHP\Attribute\Entity;
@@ -32,6 +33,14 @@ public function getSortKey(): ?AbstractKey
3233
return $this->entityAttribute->sortKey;
3334
}
3435

36+
/**
37+
* @return array<int, AbstractIndex>
38+
*/
39+
public function getIndexes(): array
40+
{
41+
return $this->entityAttribute->indexes;
42+
}
43+
3544
/**
3645
* @return array<string, Attribute>
3746
*/

src/Serializer/EntityNormalizer.php

Lines changed: 128 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
namespace EduardoMarques\DynamoPHP\Serializer;
66

7+
use EduardoMarques\DynamoPHP\Attribute\AbstractIndex;
8+
use EduardoMarques\DynamoPHP\Attribute\AbstractKey;
9+
use EduardoMarques\DynamoPHP\Attribute\GlobalIndex;
10+
use EduardoMarques\DynamoPHP\Attribute\LocalIndex;
711
use EduardoMarques\DynamoPHP\Metadata\MetadataException;
812
use EduardoMarques\DynamoPHP\Metadata\MetadataLoader;
913
use ReflectionException;
@@ -32,11 +36,23 @@ public function __construct(
3236
public function normalize(object $entity, bool $includePrimaryKey = true): array
3337
{
3438
$primaryKey = $includePrimaryKey ? $this->normalizePrimaryKey($entity) : [];
39+
$attributes = $this->normalizeAttributes($entity);
40+
$indexes = $this->normalizeIndexesFromEntity($entity);
3541

36-
return [
37-
...$primaryKey,
38-
...$this->normalizeAttributes($entity),
39-
];
42+
$item = [...$primaryKey, ...$attributes];
43+
44+
$overlappingIndexFields = array_intersect_key($indexes, $item);
45+
46+
if (false === empty($overlappingIndexFields)) {
47+
throw new InvalidEntityException(
48+
sprintf(
49+
'Index attributes cannot have overlap other item attributes: %s',
50+
json_encode(array_keys($overlappingIndexFields))
51+
)
52+
);
53+
}
54+
55+
return [...$item, ...$indexes];
4056
}
4157

4258
/**
@@ -159,34 +175,8 @@ protected function normalizePartitionKeyValueFromEntity(object $entity): string
159175
{
160176
$entityMetadata = $this->metadataLoader->getEntityMetadata($entity::class);
161177
$key = $entityMetadata->getPartitionKey();
162-
$definedFields = $key->getFields();
163-
$delimiter = $key->getDelimiter();
164-
$prefix = $key->getPrefix();
165-
166-
$classMetadata = $this->metadataLoader->getClassMetadata($entity::class);
167-
$finalValue = $prefix ?? '';
168-
169-
foreach ($definedFields as $field) {
170-
if (false === $classMetadata->has($field)) {
171-
throw new InvalidFieldException(
172-
sprintf(
173-
'Field "%s" defined in Partition Key is invalid. Are you sure it exists in the entity class?',
174-
$field
175-
)
176-
);
177-
}
178-
179-
/** @var ReflectionProperty $reflectionProperty */
180-
$reflectionProperty = $classMetadata->get($field);
181-
$propertyValue = $reflectionProperty->getValue($entity);
182-
183-
/** @var scalar $currentFieldValue */
184-
$currentFieldValue = $this->normalizer->normalize($propertyValue);
185178

186-
$finalValue .= empty($finalValue) ? $currentFieldValue : $delimiter . $currentFieldValue;
187-
}
188-
189-
return $finalValue;
179+
return $this->normalizeKeyValueFromEntity($entity, $key);
190180
}
191181

192182
/**
@@ -205,6 +195,17 @@ protected function normalizeSortKeyValueFromEntity(object $entity): ?string
205195
return null;
206196
}
207197

198+
return $this->normalizeKeyValueFromEntity($entity, $key);
199+
}
200+
201+
/**
202+
* @template T of object
203+
* @param T $entity
204+
* @throws ReflectionException
205+
* @throws ExceptionInterface
206+
*/
207+
protected function normalizeKeyValueFromEntity(object $entity, AbstractKey $key): string
208+
{
208209
$definedFields = $key->getFields();
209210
$delimiter = $key->getDelimiter();
210211
$prefix = $key->getPrefix();
@@ -216,8 +217,9 @@ protected function normalizeSortKeyValueFromEntity(object $entity): ?string
216217
if (false === $classMetadata->has($field)) {
217218
throw new InvalidFieldException(
218219
sprintf(
219-
'Field "%s" defined in Sort Key is invalid. Are you sure it exists in the entity class?',
220-
$field
220+
'Field "%s" defined in %s is invalid. Are you sure it exists in the entity class?',
221+
$field,
222+
$key::class
221223
)
222224
);
223225
}
@@ -247,49 +249,8 @@ protected function normalizePartitionKeyValueFromArray(string $class, array $val
247249
{
248250
$entityMetadata = $this->metadataLoader->getEntityMetadata($class);
249251
$key = $entityMetadata->getPartitionKey();
250-
$definedFields = $key->getFields();
251-
$delimiter = $key->getDelimiter();
252-
$prefix = $key->getPrefix();
253-
254-
$valuesByFieldSorted = [];
255-
256-
foreach ($definedFields as $field) {
257-
if (isset($valuesByField[$field])) {
258-
$valuesByFieldSorted[$field] = $valuesByField[$field];
259-
}
260-
}
261-
262-
$allFieldsProvided = empty(array_diff_key(array_flip($definedFields), $valuesByFieldSorted));
263252

264-
if (false === $allFieldsProvided) {
265-
throw new InvalidFieldException(
266-
'Provided Partition Key fields do not match the ones defined in the entity'
267-
);
268-
}
269-
270-
$classMetadata = $this->metadataLoader->getClassMetadata($class);
271-
$finalValue = $prefix ?? '';
272-
273-
foreach ($valuesByFieldSorted as $field => $value) {
274-
if (false === $classMetadata->has($field)) {
275-
throw new InvalidFieldException(
276-
sprintf('Field "%s" is invalid. Are you sure it exists in the entity class?', $field)
277-
);
278-
}
279-
280-
if (empty($value)) {
281-
throw new InvalidFieldException(
282-
sprintf('Field "%s" is invalid. Are you sure its value is provided?', $field)
283-
);
284-
}
285-
286-
/** @var scalar $currentFieldValue */
287-
$currentFieldValue = $this->normalizer->normalize($value);
288-
289-
$finalValue .= empty($finalValue) ? $currentFieldValue : $delimiter . $currentFieldValue;
290-
}
291-
292-
return $finalValue;
253+
return $this->normalizeKeyValueFromArray($class, $valuesByField, $key);
293254
}
294255

295256
/**
@@ -309,6 +270,21 @@ protected function normalizeSortKeyValueFromArray(string $class, array $valuesBy
309270
return null;
310271
}
311272

273+
return $this->normalizeKeyValueFromArray($class, $valuesByField, $key);
274+
}
275+
276+
/**
277+
* @template T of object
278+
* @param class-string<T> $class
279+
* @param array<string, mixed> $valuesByField
280+
* @throws ReflectionException
281+
* @throws ExceptionInterface
282+
*/
283+
protected function normalizeKeyValueFromArray(
284+
string $class,
285+
array $valuesByField,
286+
AbstractKey $key,
287+
): string {
312288
$definedFields = $key->getFields();
313289
$delimiter = $key->getDelimiter();
314290
$prefix = $key->getPrefix();
@@ -325,7 +301,7 @@ protected function normalizeSortKeyValueFromArray(string $class, array $valuesBy
325301

326302
if (false === $allFieldsProvided) {
327303
throw new InvalidFieldException(
328-
'Provided Sort Key fields do not match the ones defined in the entity'
304+
'Provided Partition Key fields do not match the ones defined in the entity'
329305
);
330306
}
331307

@@ -339,6 +315,12 @@ protected function normalizeSortKeyValueFromArray(string $class, array $valuesBy
339315
);
340316
}
341317

318+
if (empty($value)) {
319+
throw new InvalidFieldException(
320+
sprintf('Field "%s" is invalid. Are you sure its value is provided?', $field)
321+
);
322+
}
323+
342324
/** @var scalar $currentFieldValue */
343325
$currentFieldValue = $this->normalizer->normalize($value);
344326

@@ -372,4 +354,74 @@ protected function normalizeAttributes(object $entity): array
372354

373355
return $attributes;
374356
}
357+
358+
/**
359+
* @template T of object
360+
* @param T $entity
361+
* @return array<string, string>
362+
* @throws ReflectionException
363+
* @throws ExceptionInterface
364+
* @throws MetadataException
365+
*/
366+
protected function normalizeIndexesFromEntity(object $entity): array
367+
{
368+
$entityMetadata = $this->metadataLoader->getEntityMetadata($entity::class);
369+
$indexes = $entityMetadata->getIndexes();
370+
371+
$normalized = [];
372+
373+
foreach ($indexes as $index) {
374+
$currentNormalized = $this->normalizeIndexFromEntity($entity, $index);
375+
$overlappingKeys = array_intersect_key($currentNormalized, $normalized);
376+
377+
if (false === empty($overlappingKeys)) {
378+
throw new InvalidEntityException(
379+
sprintf(
380+
'Index attributes cannot overlap other index attributes: %s',
381+
json_encode(array_keys($overlappingKeys))
382+
)
383+
);
384+
}
385+
386+
$normalized = [...$normalized, ...$currentNormalized];
387+
}
388+
389+
return $normalized;
390+
}
391+
392+
/**
393+
* @template T of object
394+
* @param T $entity
395+
* @return array<string, string>
396+
* @throws ReflectionException
397+
* @throws ExceptionInterface
398+
*/
399+
protected function normalizeIndexFromEntity(object $entity, AbstractIndex $index): array
400+
{
401+
/** @var LocalIndex|GlobalIndex $index */
402+
$sortKey = $index->sortKey;
403+
404+
$sortKeyNormalized = null !== $sortKey
405+
? [$sortKey->getName() => $this->normalizeKeyValueFromEntity($entity, $sortKey)]
406+
: [];
407+
408+
if ($index instanceof LocalIndex) {
409+
return $sortKeyNormalized;
410+
}
411+
412+
/** @var GlobalIndex $index */
413+
$partitionKey = $index->partitionKey;
414+
$partitionKeyName = $partitionKey->getName();
415+
416+
if (isset($sortKeyNormalized[$partitionKeyName])) {
417+
throw new InvalidEntityException(
418+
sprintf('Keys within index "%s" cannot overlap each other: %s', $index->name, $partitionKeyName)
419+
);
420+
}
421+
422+
return [
423+
...$sortKeyNormalized,
424+
$partitionKeyName => $this->normalizeKeyValueFromEntity($entity, $partitionKey),
425+
];
426+
}
375427
}

0 commit comments

Comments
 (0)