Skip to content

Commit

Permalink
Add an Unique attribute on Tables
Browse files Browse the repository at this point in the history
  • Loading branch information
micoli committed Jul 14, 2023
1 parent 21b2b9b commit b1d763f
Show file tree
Hide file tree
Showing 12 changed files with 217 additions and 4 deletions.
7 changes: 4 additions & 3 deletions src/Elql.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@

namespace Micoli\Elql;

use Micoli\Elql\ExpressionLanguage\ExpressionLanguageEvaluator;
use Micoli\Elql\ExpressionLanguage\ExpressionLanguageEvaluatorInterface;
use Micoli\Elql\Persister\PersisterInterface;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;

class Elql
{
public function __construct(
public readonly PersisterInterface $persister,
private readonly ExpressionLanguage $expressionLanguage = new ExpressionLanguage(),
private readonly ExpressionLanguageEvaluatorInterface $expressionLanguageEvaluator = new ExpressionLanguageEvaluator(),
) {
}

Expand Down Expand Up @@ -87,6 +88,6 @@ private function match(mixed $record, ?string $where): bool
return true;
}

return (bool) $this->expressionLanguage->evaluate($where, ['record' => $record]);
return (bool) $this->expressionLanguageEvaluator->evaluate($where, $record);
}
}
11 changes: 11 additions & 0 deletions src/Exception/ElqlException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace Micoli\Elql\Exception;

use LogicException;

class ElqlException extends LogicException
{
}
9 changes: 9 additions & 0 deletions src/Exception/NonUniqueException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Micoli\Elql\Exception;

class NonUniqueException extends ElqlException
{
}
31 changes: 31 additions & 0 deletions src/ExpressionLanguage/ExpressionLanguageEvaluator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Micoli\Elql\ExpressionLanguage;

use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\ExpressionLanguage\ExpressionFunction;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;

class ExpressionLanguageEvaluator implements ExpressionLanguageEvaluatorInterface
{
public function __construct(
private readonly ExpressionLanguage $expressionLanguage = new ExpressionLanguage(new ArrayAdapter()),
) {
foreach ([
'strtoupper', 'strtolower',
'str_starts_with', 'str_ends_with', 'str_contains',
'substr', 'strlen',
'trim', 'ltrim', 'rtrim',
'abs', 'min', 'max', 'floor', 'ceil',
] as $nativeFunction) {
$this->expressionLanguage->addFunction(ExpressionFunction::fromPhp($nativeFunction));
}
}

public function evaluate(string $expression, mixed $record): mixed
{
return $this->expressionLanguage->evaluate($expression, ['record' => $record]);
}
}
10 changes: 10 additions & 0 deletions src/ExpressionLanguage/ExpressionLanguageEvaluatorInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace Micoli\Elql\ExpressionLanguage;

interface ExpressionLanguageEvaluatorInterface
{
public function evaluate(string $expression, mixed $record): mixed;
}
34 changes: 34 additions & 0 deletions src/Metadata/MetadataManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@

class MetadataManager implements MetadataManagerInterface
{
/**
* @var array<string, array<string, string>>
*/
private array $uniques = [];

public function __construct(
/**
* @var array<class-string, string>
Expand Down Expand Up @@ -51,4 +56,33 @@ public function tableNameExtractor(string $model): string

return $this->tableNames[$model];
}

/**
* @param class-string $model
*
* @return array<string, string>
*/
public function uniques(string $model): array
{
if (isset($this->uniques[$model])) {
return $this->uniques[$model];
}
$reflectedClass = new ReflectionClass($model);
$attributes = $reflectedClass->getAttributes();
$uniques = [];
foreach ($attributes as $reflectedAttribute) {
$attribute = $reflectedAttribute->newInstance();
// @codeCoverageIgnoreStart
if (!$attribute instanceof Unique) {
continue;
}
// @codeCoverageIgnoreStart

$uniques[$attribute->name ?? $attribute->expression] = $attribute->expression;
}

$this->uniques[$model] = $uniques;

return $uniques;
}
}
7 changes: 7 additions & 0 deletions src/Metadata/MetadataManagerInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,11 @@ interface MetadataManagerInterface
* @param class-string $model
*/
public function tableNameExtractor(string $model): string;

/**
* @param class-string $model
*
* @return array<string, string>
*/
public function uniques(string $model): array;
}
17 changes: 17 additions & 0 deletions src/Metadata/Unique.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Micoli\Elql\Metadata;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
class Unique
{
public function __construct(
public readonly string $expression,
public readonly ?string $name = null,
) {
}
}
14 changes: 13 additions & 1 deletion src/Persister/FilePersister.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use Doctrine\Common\Annotations\AnnotationReader;
use Micoli\Elql\Encoder\YamlEncoder;
use Micoli\Elql\ExpressionLanguage\ExpressionLanguageEvaluator;
use Micoli\Elql\ExpressionLanguage\ExpressionLanguageEvaluatorInterface;
use Micoli\Elql\Metadata\MetadataManagerInterface;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor;
Expand All @@ -29,6 +31,7 @@ public function __construct(
private readonly string $dir,
private readonly MetadataManagerInterface $metadataManager,
private readonly string $format = JsonEncoder::FORMAT,
private readonly IndexChecker $indexChecker = new IndexChecker(),
private readonly Serializer $serializer = new Serializer([
new ArrayDenormalizer(),
new DateTimeNormalizer(),
Expand All @@ -46,6 +49,7 @@ public function __construct(
new JsonEncoder(),
new YamlEncoder(),
]),
private readonly ExpressionLanguageEvaluatorInterface $expressionLanguageEvaluator = new ExpressionLanguageEvaluator(),
) {
$this->filesystem = new Filesystem();
}
Expand Down Expand Up @@ -89,7 +93,15 @@ public function getDatabase(): array

public function addRecord(object $record): void
{
$this->getRecords($record::class)->data[] = $record;
$inMemoryTable = $this->getRecords($record::class);
$this->indexChecker->checkUniqueIndexes(
$this->metadataManager,
$this->expressionLanguageEvaluator,
$record,
$inMemoryTable,
);

$inMemoryTable->data[] = $record;
}

/**
Expand Down
46 changes: 46 additions & 0 deletions src/Persister/IndexChecker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace Micoli\Elql\Persister;

use Micoli\Elql\Exception\NonUniqueException;
use Micoli\Elql\ExpressionLanguage\ExpressionLanguageEvaluatorInterface;
use Micoli\Elql\Metadata\MetadataManagerInterface;

class IndexChecker
{
public function __construct()
{
}

public function checkUniqueIndexes(
MetadataManagerInterface $metadataManager,
ExpressionLanguageEvaluatorInterface $expressionLanguageEvaluator,
object $record,
InMemoryTable $inMemoryTable,
): void {
$errors = [];
foreach ($metadataManager->uniques($record::class) as $indexName => $indexExpression) {
/**
* @var string $newRecordIndexValue
*/
$newRecordIndexValue = $expressionLanguageEvaluator->evaluate($indexExpression, $record);
/**
* @var mixed $data
*/
foreach ($inMemoryTable->data as $data) {
/**
* @var string $existingRecordIndexValue
*/
$existingRecordIndexValue = $expressionLanguageEvaluator->evaluate($indexExpression, $data);
if ($existingRecordIndexValue === $newRecordIndexValue) {
$errors[] = sprintf('[%s:%s]', $indexName, json_encode($existingRecordIndexValue));
}
}
}
if (count($errors) > 0) {
throw new NonUniqueException(sprintf('Duplicate on %s', implode(', ', $errors)));
}
}
}
32 changes: 32 additions & 0 deletions tests/ElqlTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use DateTimeImmutable;
use Micoli\Elql\Elql;
use Micoli\Elql\Encoder\YamlEncoder;
use Micoli\Elql\Exception\NonUniqueException;
use Micoli\Elql\Metadata\MetadataManager;
use Micoli\Elql\Persister\FilePersister;
use Micoli\Elql\Tests\Fixtures\Baz;
Expand Down Expand Up @@ -99,4 +100,35 @@ public function testItShouldReadFromFiles(): void
self::assertSame('c', $record->firstName);
self::assertSame('c', $record->lastName);
}

public function testItShouldNotAddIfUniqueIndexIsDuplicated(): void
{
$this->database->add(new Baz(1, 'a', 'a'));

self::expectException(NonUniqueException::class);
self::expectExceptionMessage('Duplicate on [record.id:1]');
$this->database->add(new Baz(1, 'b', 'b'));
}

public function testItShouldNotAddIfMultipleUniqueIndexIsDuplicated(): void
{
$this->database->add(
new Baz(1, 'a', 'a'),
new Baz(2, 'b', 'b'),
);

self::expectException(NonUniqueException::class);
self::expectExceptionMessage('Duplicate on [record.id:2], [fullname:["a","a"]]');
$this->database->add(new Baz(2, 'a', 'a'));
}

public function testItUseExtendedExpressionLanguage(): void
{
$this->database->add(
new Baz(1, 'a', 'a'),
new Baz(2, 'b', 'b'),
);

self::assertCount(1, $this->database->find(Baz::class, 'strtoupper(record.firstName) === "A"'));
}
}
3 changes: 3 additions & 0 deletions tests/Fixtures/Baz.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
namespace Micoli\Elql\Tests\Fixtures;

use Micoli\Elql\Metadata\Table;
use Micoli\Elql\Metadata\Unique;

#[Table('b_a_z')]
#[Unique('record.id')]
#[Unique('[record.firstName,record.lastName]', 'fullname')]
class Baz
{
public function __construct(
Expand Down

0 comments on commit b1d763f

Please sign in to comment.