Skip to content
Open
11 changes: 11 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ jobs:
tests:
name: Tests
runs-on: ubuntu-latest
env:
RIKUDOU_TEST_DYNAMO_TABLE: TestTableDynamoPsr6
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
strategy:
max-parallel: 1
matrix:
version: ['7.3', '7.4', '8.0', '8.1']
steps:
Expand All @@ -62,6 +67,12 @@ jobs:
coverage:
name: Report Coverage
runs-on: ubuntu-latest
needs:
- tests
env:
RIKUDOU_TEST_DYNAMO_TABLE: TestTableDynamoPsr6
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- name: Setup PHP
uses: shivammathur/setup-php@v2
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"friendsofphp/php-cs-fixer": "^2.16",
"phpstan/phpstan": "^0.12.43",
"phpunit/phpunit": "^9.3",
"ext-json": "*"
"ext-json": "*",
"jetbrains/phpstorm-attributes": "^1.0"
},
"provide": {
"psr/cache-implementation": "1.0",
Expand Down
143 changes: 143 additions & 0 deletions src/Dynamo/DynamoDbTableCreator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

namespace Rikudou\DynamoDbCache\Dynamo;

use AsyncAws\Core\Exception\Http\ClientException;
use AsyncAws\DynamoDb\DynamoDbClient;
use AsyncAws\DynamoDb\Enum\TableStatus;
use AsyncAws\DynamoDb\Exception\ResourceNotFoundException;
use ReflectionObject;
use Rikudou\DynamoDbCache\DynamoDbCache;

final class DynamoDbTableCreator implements DynamoDbTableCreatorInterface
{
/**
* @var DynamoDbCache
*/
private $cache;

/**
* @var string
*/
private $tableName;

/**
* @var DynamoDbClient
*/
private $awsClient;

/**
* @var string
*/
private $primaryField;

/**
* @var string
*/
private $ttlField;

public function __construct(DynamoDbCache $cache)
{
$this->cache = $cache;
$this->initialize();
}

public function exists(): bool
{
try {
$this->awsClient->describeTable([
'TableName' => $this->tableName,
]);

return true;
} catch (ResourceNotFoundException $e) {
return false;
}
}

public function create(string $mode = self::MODE_PAY_PER_REQUEST, bool $throw = true): bool
{
try {
$this->awsClient->createTable([
'AttributeDefinitions' => [
[
'AttributeName' => $this->primaryField,
'AttributeType' => 'S',
],
],
'BillingMode' => $mode,
'KeySchema' => [
[
'AttributeName' => $this->primaryField,
'KeyType' => 'HASH',
],
],
'TableName' => $this->tableName,
]);
while (!$this->isActive()) {
usleep(2000);
}
$this->awsClient->updateTimeToLive([
'TableName' => $this->tableName,
'TimeToLiveSpecification' => [
'AttributeName' => $this->ttlField,
'Enabled' => true,
],
]);

return true;
} catch (ClientException $e) {
if ($throw) {
throw $e;
}

return false;
}
}

public function createIfNotExists(string $mode = self::MODE_PAY_PER_REQUEST, bool $throw = true): bool
{
if (!$this->exists()) {
return $this->create($mode, $throw);
}

return true;
}

private function initialize(): void
{
$reflection = new ReflectionObject($this->cache);

$reflectionTableName = $reflection->getProperty('tableName');
$reflectionClient = $reflection->getProperty('client');
$reflectionPrimaryField = $reflection->getProperty('primaryField');
$reflectionTtlField = $reflection->getProperty('ttlField');
$reflectionValueField = $reflection->getProperty('valueField');

$reflectionTableName->setAccessible(true);
$reflectionClient->setAccessible(true);
$reflectionPrimaryField->setAccessible(true);
$reflectionTtlField->setAccessible(true);
$reflectionValueField->setAccessible(true);

$this->tableName = $reflectionTableName->getValue($this->cache);
$this->awsClient = $reflectionClient->getValue($this->cache);
$this->primaryField = $reflectionPrimaryField->getValue($this->cache);
$this->ttlField = $reflectionTtlField->getValue($this->cache);
}

private function isActive(): bool
{
if (!$this->exists()) {
return false; // @codeCoverageIgnore
}
$result = $this->awsClient->describeTable([
'TableName' => $this->tableName,
])->getTable();
if ($result === null) {
return false; // @codeCoverageIgnore
}

return $result->getTableStatus() === TableStatus::ACTIVE;
}
}
25 changes: 25 additions & 0 deletions src/Dynamo/DynamoDbTableCreatorInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace Rikudou\DynamoDbCache\Dynamo;

use JetBrains\PhpStorm\ExpectedValues;

interface DynamoDbTableCreatorInterface
{
public const MODE_PROVISIONED = 'PROVISIONED';
public const MODE_PAY_PER_REQUEST = 'PAY_PER_REQUEST';

public function exists(): bool;

public function create(
#[ExpectedValues(valuesFromClass: self::class)]
string $mode = self::MODE_PAY_PER_REQUEST,
bool $throw = true
): bool;

public function createIfNotExists(
#[ExpectedValues(valuesFromClass: self::class)]
string $mode = self::MODE_PAY_PER_REQUEST,
bool $throw = true
): bool;
}
91 changes: 91 additions & 0 deletions tests/Dynamo/DynamoDbTableCreatorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

namespace Rikudou\Tests\DynamoDbCache\Dynamo;

use AsyncAws\DynamoDb\DynamoDbClient;
use AsyncAws\DynamoDb\Exception\ResourceInUseException;
use AsyncAws\DynamoDb\Exception\ResourceNotFoundException;
use AsyncAws\DynamoDb\Input\DeleteTableInput;
use Rikudou\DynamoDbCache\Dynamo\DynamoDbTableCreator;
use PHPUnit\Framework\TestCase;
use Rikudou\DynamoDbCache\Dynamo\DynamoDbTableCreatorInterface;
use Rikudou\DynamoDbCache\DynamoDbCacheBuilder;
use RuntimeException;

class DynamoDbTableCreatorTest extends TestCase
{
/**
* @var DynamoDbTableCreator
*/
private $instance;

/**
* @var DynamoDbClient
*/
private $dynamo;

protected function setUp(): void
{
if (
!getenv('AWS_ACCESS_KEY_ID')
|| !getenv('AWS_SECRET_ACCESS_KEY')
|| !getenv('RIKUDOU_TEST_DYNAMO_TABLE')
) {
$this->markTestSkipped('This test needs access to real AWS servers');
}

$this->dynamo = new DynamoDbClient();
$cache = DynamoDbCacheBuilder::create(
getenv('RIKUDOU_TEST_DYNAMO_TABLE'),
$this->dynamo,
)->build();
$this->instance = new DynamoDbTableCreator($cache);
}

protected function tearDown(): void
{
$table = getenv('RIKUDOU_TEST_DYNAMO_TABLE');
try {
$this->dynamo->deleteTable(new DeleteTableInput([
'TableName' => $table,
]))->resolve();
} catch (ResourceNotFoundException $ignore) {
}
$count = 0;
while ($this->instance->exists()) {
usleep(2000);
if ($count === 1000) {
throw new RuntimeException("Table wasn't cleaned up in {$count} iterations");
}
++$count;
}
}

public function testExists()
{
self::assertFalse($this->instance->exists());
self::assertTrue($this->instance->create());
self::assertTrue($this->instance->exists());
}

public function testCreate()
{
self::assertFalse($this->instance->exists());

self::assertTrue($this->instance->create());
self::assertTrue($this->instance->exists());
self::assertFalse($this->instance->create(DynamoDbTableCreatorInterface::MODE_PAY_PER_REQUEST, false));

$this->expectException(ResourceInUseException::class);
$this->instance->create();
}

public function testCreateIfNotExists()
{
self::assertFalse($this->instance->exists());

self::assertTrue($this->instance->createIfNotExists());
self::assertTrue($this->instance->exists());
self::assertTrue($this->instance->createIfNotExists());
}
}