Skip to content

Commit

Permalink
feat(orm): Deprecate get methods, and add findById methods on QueryRe…
Browse files Browse the repository at this point in the history
…positoryExtension (#FRAM-136) (#79)
  • Loading branch information
vincent4vx committed Oct 19, 2023
1 parent 3730de8 commit 435d04c
Show file tree
Hide file tree
Showing 7 changed files with 448 additions and 0 deletions.
2 changes: 2 additions & 0 deletions src/Entity/Model.php
Expand Up @@ -29,6 +29,8 @@
* @method static static getOrNew(mixed $key)
* @method static static getOrFail(mixed $key)
* @method static static|null findById(mixed $key)
* @method static static findByIdOrNew(mixed $key)
* @method static static findByIdOrFail(mixed $key)
* @method static static|null findOne(array $criteria, ?array $attributes = null)
*
* @method static QueryInterface where(string|array|callable $column, mixed|null $operator = null, mixed $value = null)
Expand Down
227 changes: 227 additions & 0 deletions src/Query/QueryRepositoryExtension.php
Expand Up @@ -9,14 +9,22 @@
use Bdf\Prime\Events;
use Bdf\Prime\Exception\EntityNotFoundException;
use Bdf\Prime\Exception\PrimeException;
use Bdf\Prime\Exception\QueryBuildingException;
use Bdf\Prime\Mapper\Mapper;
use Bdf\Prime\Mapper\Metadata;
use Bdf\Prime\Query\Closure\ClosureCompiler;
use Bdf\Prime\Query\Contract\Query\KeyValueQueryInterface;
use Bdf\Prime\Query\Contract\Whereable;
use Bdf\Prime\Relations\Relation;
use Bdf\Prime\Repository\EntityRepository;
use Bdf\Prime\Repository\RepositoryInterface;
use Closure;
use Doctrine\DBAL\Query\Expression\CompositeExpression;

use function array_diff;
use function array_keys;
use function count;
use function is_array;

/**
* QueryRepositoryExtension
Expand Down Expand Up @@ -108,9 +116,12 @@ public function repository(ReadCommandInterface $query, $name = null)
* @param null|string|array $attributes
*
* @return E|null
* @deprecated Since 2.1. Use {@see findById()} instead.
*/
public function get(ReadCommandInterface $query, $id, $attributes = null)
{
@trigger_error('Query::get()/getOrFail()/getOrNew() is deprecated since 2.1. Use findById() instead.', E_USER_DEPRECATED);

if (empty($id)) {
return null;
}
Expand Down Expand Up @@ -139,6 +150,7 @@ public function get(ReadCommandInterface $query, $id, $attributes = null)
* @return E
*
* @throws EntityNotFoundException If entity is not found
* @deprecated Since 2.1. Use {@see findByIdOrFail()} instead.
*/
public function getOrFail(ReadCommandInterface $query, $id, $attributes = null)
{
Expand All @@ -159,6 +171,7 @@ public function getOrFail(ReadCommandInterface $query, $id, $attributes = null)
* @param null|string|array $attributes
*
* @return E
* @deprecated Since 2.1. Use {@see findByIdOrNew()} instead.
*/
public function getOrNew(ReadCommandInterface $query, $id, $attributes = null)
{
Expand All @@ -171,6 +184,168 @@ public function getOrNew(ReadCommandInterface $query, $id, $attributes = null)
return $this->repository->entity();
}

/**
* Find entity by its primary key
* In case of composite primary key, the primary key can be resolved by previous where() call
*
* Note: If criterion which is not part of the primary key is passed, or if the primary key is not complete, the query will throw an {@see QueryBuildingException}.
*
* <code>
* $queries->findById(2);
* $queries->findById(['key1' => 1, 'key2' => 5]);
* $queries->where('key1', 1)->findById(5); // Same as above: the composite key is completed by previous where() call
* </code>
*
* @param ReadCommandInterface<ConnectionInterface, E>&Whereable $query
* @param mixed|array<string, mixed> $id The entity PK. Use an array for composite PK
*
* @return E|null The entity or null if not found
* @throws PrimeException When query fail
*/
public function findById(ReadCommandInterface $query, $id)
{
$pkAttributes = $this->metadata->primary['attributes'];
$criteria = null;

// Scalar id is used, so resolve the primary key attribute name
if (!is_array($id)) {
if (count($pkAttributes) === 1) {
// Single primary key
$id = [$pkAttributes[0] => $id];
} else {
// Composite primary key : resolve missing primary key attributes
$criteria = $this->toCriteria($query) ?? [];

foreach ($pkAttributes as $key) {
if (!isset($criteria[$key])) {
$id = [$key => $id];
break;
}
}

if (!is_array($id)) {
throw new QueryBuildingException('Ambiguous findById() call : All primary key attributes are already defined on query, so missing part of the primary key cannot be resolved. Use an array as parameter instead to explicitly define the primary key attribute name.');
}
}
}

$keys = array_keys($id);

// Some criteria are not part of the primary key
if ($extraKeys = array_diff($keys, $pkAttributes)) {
throw new QueryBuildingException('Only primary keys must be passed to findById(). Unexpected keys : ' . implode(', ', $extraKeys));
}

$missingPk = array_diff($pkAttributes, $keys);

if ($missingPk) {
// Some primary key attributes are missing
// so check if they are defined in the query on previous where() call
$criteria ??= $this->toCriteria($query) ?? [];

foreach ($missingPk as $i => $key) {
if (isset($criteria[$key])) {
unset($missingPk[$i]);
}
}

if ($missingPk) {
throw new QueryBuildingException('Only primary keys must be passed to findById(). Missing keys : ' . implode(', ', $missingPk));
}
}

return $query->where($id)->first();
}

/**
* Find entity by its primary key, or throws exception if not found in repository
* In case of composite primary key, the primary key can be resolved by previous where() call
*
* Note: If criterion which is not part of the primary key is passed, or if the primary key is not complete, the query will throw an {@see QueryBuildingException}.
*
* @param ReadCommandInterface<ConnectionInterface, E>&Whereable $query
* @param mixed|array<string, mixed> $id The entity PK. Use an array for composite PK
*
* @return E
*
* @throws EntityNotFoundException If entity is not found
* @throws PrimeException When query fail
*/
public function findByIdOrFail(ReadCommandInterface $query, $id)
{
$entity = $this->findById($query, $id);

if ($entity !== null) {
return $entity;
}

throw new EntityNotFoundException('Cannot resolve entity identifier "'.implode('", "', (array)$id).'"');
}

/**
* Find entity by its primary key, or throws exception if not found in repository
* In case of composite primary key, the primary key can be resolved by previous where() call
*
* Note: If criterion which is not part of the primary key is passed, or if the primary key is not complete, the query will throw an {@see QueryBuildingException}.
*
* @param ReadCommandInterface<ConnectionInterface, E>&Whereable $query
* @param mixed|array<string, mixed> $id The entity PK. Use an array for composite PK
*
* @return E
*
* @throws EntityNotFoundException If entity is not found
* @throws PrimeException When query fail
*/
public function findByIdOrNew(ReadCommandInterface $query, $id)
{
$entity = $this->findById($query, $id);

if ($entity !== null) {
return $entity;
}

return $this->repository->entity($this->toCriteria($query) ?? []);
}

/**
* Execute the query and return the first result, or throw {@see EntityNotFoundException} if no result
*
* @param ReadCommandInterface<ConnectionInterface, E> $query
*
* @return E
*/
public function firstOrFail(ReadCommandInterface $query)
{
$entity = $query->first();

if ($entity !== null) {
return $entity;
}

throw new EntityNotFoundException('Cannot resolve entity');
}

/**
* Execute the query and return the first result, or instantiate a new entity if no result
*
* @param ReadCommandInterface<ConnectionInterface, E> $query
* @param bool $useCriteriaAsDefault If true, the criteria of the query will be used as default attributes
*
* @return E
*/
public function firstOrNew(ReadCommandInterface $query, bool $useCriteriaAsDefault = true)
{
$entity = $query->first();

if ($entity !== null) {
return $entity;
}

$attributes = $useCriteriaAsDefault ? $this->toCriteria($query) : null;

return $this->repository->entity($attributes ?? []);
}

/**
* Filter entities by a predicate
*
Expand Down Expand Up @@ -262,6 +437,58 @@ public function by(ReadCommandInterface $query, $attribute, $combine = false)
return $query;
}

/**
* Convert query where clause to key-value criteria
*
* @param ReadCommandInterface $query
*
* @return array<string, mixed>|null
*/
public function toCriteria(ReadCommandInterface $query): ?array
{
if ($query instanceof KeyValueQueryInterface) {
return $query->statement('where');
}

$criteria = [];

$statements = [];

// Flatten query with a single level of nesting
foreach ($query->statement('where') as $statement) {
if (CompositeExpression::TYPE_AND !== ($statement['glue'] ?? null)) {
// Only AND composite expression are supported
return null;
}

if (!isset($statement['nested'])) {
$statements[] = $statement;
} else {
$statements = [...$statements, ...$statement['nested']];
}
}

foreach ($statements as $statement) {
if (
!isset($statement['column'], $statement['glue'], $statement['operator'])
|| $statement['glue'] !== CompositeExpression::TYPE_AND
|| $statement['operator'] !== '=' // @todo support :eq etc...
) {
return null; // Cannot extract complex criteria
}

$value = $statement['value'] ?? null;

if (is_array($value)) {
return null;
}

$criteria[$statement['column']] = $value;
}

return $criteria;
}

/**
* Post processor for hydrating entities
*
Expand Down
6 changes: 6 additions & 0 deletions src/Query/ReadCommandInterface.php
Expand Up @@ -18,6 +18,12 @@
* @method R|null get($pk) Get one entity by its identifier
* @method R getOrFail($pk) Get one entity or throws when entity is not found
* @method R getOrNew($pk) Get one entity or return a new one if not found in repository
* @method R|null findById(mixed|array $pk) Get one entity by its primary key or null if not found in repository
* @method R findByIdOrFail(mixed|array $pk) Get one entity by its primary key or throws if not found in repository
* @method R findByIdOrNew(mixed|array $pk) Get one entity by its primary key or return a new one if not found in repository, using the where criteria as default values
* @method R firstOrFail() Get the first result of the query, or throws an exception if no result
* @method R firstOrNew(bool $useCriteriaAsDefault = true) Get the first result of the query, or create a new instance if no result. If $useCriteriaAsDefault is true, the where criteria will be used as default values for the new instance.
* @method array<string, mixed>|null toCriteria() Transform the query where clause to simple key/value criteria. Return null if the query is not a simple criteria.
*
* @template C as \Bdf\Prime\Connection\ConnectionInterface
* @template R as object|array
Expand Down
5 changes: 5 additions & 0 deletions src/Relations/EntityRelation.php
Expand Up @@ -24,6 +24,11 @@
* @psalm-method R|null get($pk) Get one entity by its identifier
* @psalm-method R getOrFail($pk) Get one entity or throws when entity is not found
* @psalm-method R getOrNew($pk) Get one entity or return a new one if not found in repository
* @psalm-method R|null findById(mixed|array $pk) Get one entity by its primary key or null if not found in repository
* @psalm-method R findByIdOrFail(mixed|array $pk) Get one entity by its primary key or throws if not found in repository
* @psalm-method R findByIdOrNew(mixed|array $pk) Get one entity by its primary key or instantiate a new one, using where clause criteria if not found in repository
* @psalm-method R firstOrFail() Get the first result of the query, or throws an exception if no result
* @psalm-method R firstOrNew(bool $useCriteriaAsDefault = true) Get the first result of the query, or create a new instance if no result. If $useCriteriaAsDefault is true, the where criteria will be used as default values for the new instance.
* @psalm-method int count()
*
* @mixin ReadCommandInterface<\Bdf\Prime\Connection\ConnectionInterface, R>
Expand Down
49 changes: 49 additions & 0 deletions tests/Query/QueryOrmTest.php
Expand Up @@ -18,6 +18,7 @@
use Bdf\Prime\Query\Expression\Value;
use Bdf\Prime\Repository\RepositoryInterface;
use Bdf\Prime\Right;
use Bdf\Prime\TestEntity;
use Bdf\Prime\TestFiltersEntity;
use Bdf\Prime\TestFiltersEntityMapper;
use Bdf\Prime\User;
Expand Down Expand Up @@ -1208,4 +1209,52 @@ public function test_with_criteria_expression()

$this->assertEquals('SELECT t0.* FROM customer_ t0 WHERE t0.name_ LIKE \'foo%\' AND t0.id_ > \'5\'', $query->toRawSql());
}

public function test_toCriteria_empty()
{
$this->assertSame([], $this->query->toCriteria());
}

public function test_toCriteria_simple()
{
$this->assertSame([
'id' => 42,
'name' => 'Robert',
], $this->query->where('id', 42)->where('name', 'Robert')->toCriteria());
}

public function test_toCriteria_simple_nested()
{
$this->assertSame([
'id' => 42,
'name' => 'Robert',
], $this->query->where(['id' => 42, 'name' => 'Robert'])->toCriteria());
}

public function test_toCriteria_not_supported()
{
$this->assertNull($this->query->where('id', '>', 42)->toCriteria());
$this->assertNull($this->query->where('id', 42)->orWhere('name', 'foo')->toCriteria());
$this->assertNull($this->query->where('id', [42, 45])->toCriteria());
$this->assertNull(TestEntity::builder()
->where(['id' => 42, 'name' => 'Robert'])
->where(function (Query $query) {
$query->where([
'value' => 'xxx',
'other' => 'yyy',
]);
})
->toCriteria()
);
}

public function test_toCriteria_with_filter()
{
$this->assertSame([
'id' => 42,
'name' => 'Robert',
], $this->query
->filter(fn (TestEntity $entity) => $entity->id === 42 && $entity->name === 'Robert')
->toCriteria());
}
}

0 comments on commit 435d04c

Please sign in to comment.