Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FRAM-156] Allow use of class name to resolve relation #91

Merged
merged 3 commits into from
Mar 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ v2.2.0
* `Whereable::orWhere()` on first parameter (column)
* `Whereable::whereNull()`
* `Whereable::whereNotNull()`
* Change: Allow usage of entity class name to resolve relations, instead of relation name
* Feat: Add `Bdf\Prime\Platform\PlatformSpecificOperationInterface` and `PlatformInterface::apply()` to allow changing the behavior on the platform
* Change: Rework visitors to remove the use of deprecated doctrine visitor API

Expand Down
17 changes: 11 additions & 6 deletions src/Collection/EntityCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,24 +96,29 @@ public function load($relations)
*
* <code>
* // Perform query on customer.customerPack.pack
* $customer->relation('packs')
* $customer->relation(CustomerPack::class)
* ->wrapAs('collection')
* ->all()
* ->link('pack')
* ->link(Pack::class)
* ->where(...)
* ->all()
* ;
* </code>
*
* @param string $relation The relation name
* @param class-string<R>|string $relationClass The relation class, or the relation name
* @param string|null $relationName The relation name, if ambiguous
*
* @return QueryInterface
* @return QueryInterface<ConnectionInterface, R>
*
* @template R as object
* @fixme Works with Polymorph
*
* @psalm-suppress TooManyArguments - @todo to remove in prime 3.0: RepositoryInterface::relation() has only 1 declared parameter for bc break
*/
public function link($relation)
public function link(string $relationClass, ?string $relationName = null)
{
return $this->repository
->relation($relation)
->relation($relationClass, $relationName)
->link($this->all())
;
}
Expand Down
27 changes: 20 additions & 7 deletions src/Entity/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ public function delete(): int
* If a relation is already loaded, the entity will be kept
* You can force loading using reload()
*
* @param string|array $relations
* @param string|class-string|array<string|class-string> $relations Relations to load. Can be the relation class, or the relation name
*
* @return $this
* @throws PrimeException
Expand All @@ -244,7 +244,7 @@ public function load($relations)
/**
* For loading entity relations
*
* @param string|array $relations
* @param string|class-string|array<string|class-string> $relations Relations to load. Can be the relation class, or the relation name
*
* @return $this
* @throws PrimeException
Expand All @@ -258,15 +258,28 @@ public function reload($relations)
}

/**
* Load entity relations
* Get the relation with the current entity
*
* <code>
* // Get the user relation by class name
* $customer->relation(User::class)->where('name', ':like', 'John%')->all();
*
* // When a relation is ambiguous, you can specify the relation name
* $customer->relation(User::class, 'activeUsers')->where('name', ':like', 'John%')->all();
*
* // Or directly by relation name (but it's not recommended, because it's not type safe)
* $customer->relation('activeUsers')->where('name', ':like', 'John%')->all();
* </code>
*
* @param string $relation
* @param class-string<R>|string $relationClass The relation class name, or the relation name
* @param string|null $relationName The relation name, to specify if there is multiple relations of the same class
*
* @return EntityRelation<static, object>
* @return EntityRelation<static, R>
* @template R as object
*/
public function relation(string $relation): EntityRelation
public function relation(string $relationClass, ?string $relationName = null): EntityRelation
{
return static::repository()->onRelation($relation, $this);
return static::repository()->onRelation($relationClass, $this, $relationName);
}

/**
Expand Down
87 changes: 81 additions & 6 deletions src/Mapper/Mapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,12 @@
use stdClass;

use function class_exists;
use function count;
use function current;
use function implode;
use function is_string;
use function method_exists;
use function sprintf;

/**
* Mapper
Expand Down Expand Up @@ -149,6 +153,12 @@ abstract class Mapper
*/
private ?RelationBuilder $relationBuilder = null;

/**
* @var array<class-string, array<string, string>>|null
* @see Mapper::resolveRelationNamesByClass()
*/
private ?array $relationClassesToNames = null;

/**
* The collection of behaviors
*
Expand Down Expand Up @@ -653,21 +663,52 @@ public function info(): MapperInfo
*
* Build object relation defined by user
*
* @param string $relationName
* @param class-string $relationClass
* @param string|null $relationName
*
* @return array Metadata for relation definition
* @return RelationDefinition Metadata for relation definition
*
* @throws \RuntimeException If relation or type does not exist
*/
public function relation(string $relationName): array
public function relation(string $relationClass, ?string $relationName = null): array
{
$relations = $this->relations();
$matchingName = $this->resolveRelationNamesByClass($relationClass);

if ($relationName && !isset($matchingName[$relationName])) {
throw new RelationNotFoundException(sprintf(
'Relation "%s" is not set in %s or does not match the given class "%s"',
$relationName,
$this->metadata->entityName,
$relationClass
));
}

if ($matchingName && !$relationName) {
if (count($matchingName) > 1) {
throw new RelationNotFoundException(sprintf(
'Multiple relations found for class "%s" in %s. Please specify the relation name (available relations: %s)',
$relationClass,
$this->metadata->entityName,
implode(', ', $matchingName)
));
}

// Get the relation name from the matching class
$relationName = current($matchingName);
}

$relationName ??= $relationClass;
$relation = $relations[$relationName] ?? null;

if (!isset($relations[$relationName])) {
throw new RelationNotFoundException('Relation "' . $relationName . '" is not set in ' . $this->metadata->entityName);
if (!$relation) {
throw new RelationNotFoundException(sprintf('Relation "%s" is not set in %s', $relationName, $this->metadata->entityName));
}

return $relations[$relationName];
// For compatibility with old relation definition (method relations() overridden for return an array)
$relation['name'] ??= $relationName;

return $relation;
}

//
Expand Down Expand Up @@ -1111,6 +1152,40 @@ public function build(): void
$this->hydrator->setPrimeInstantiator($this->serviceLocator->instantiator());
}

/**
* Try to resolve the relation names by the relation class
*
* @param string|class-string $relationClass The relation class name
*
* @return array<string, string>|null List of matching relation names, or null if none. The result array contains the relation names in both keys and values.
*/
private function resolveRelationNamesByClass(string $relationClass): ?array
{
if ($this->relationClassesToNames !== null) {
return $this->relationClassesToNames[$relationClass] ?? null;
}

// Build relations
$relations = $this->relations();

if ($this->relationBuilder) {
$mapping = $this->relationBuilder->relationClassesToNames();
} else {
// Legacy way: a raw array is used instead of a RelationBuilder
$mapping = [];

foreach ($relations as $name => $metadata) {
if ($entity = $metadata['entity'] ?? null) {
$mapping[$entity][$name] = $name;
}
}
}

$this->relationClassesToNames = $mapping;

return $mapping[$relationClass] ?? null;
}

/**
* Loads the mapper configurators from attributes
*
Expand Down
6 changes: 3 additions & 3 deletions src/Mapper/SingleTableInheritanceMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,12 @@ public function setMapperFactory(MapperFactoryInterface $mapperFactory): void
/**
* {@inheritdoc}
*/
public function relation(string $relationName): array
public function relation(string $relationClass, ?string $relationName = null): array
{
$relation = parent::relation($relationName);
$relation = parent::relation($relationClass, $relationName);

if ($this->isDiscriminatedMapper() && $relation['type'] == Relation::BY_INHERITANCE) {
throw new \RuntimeException('Relation type not allowed from relation "' . $relationName . '" in ' . $this->getEntityClass());
throw new \RuntimeException('Relation type not allowed from relation "' . ($relationName ?? $relationClass) . '" in ' . $this->getEntityClass());
}

return $relation;
Expand Down
36 changes: 32 additions & 4 deletions src/Relations/Builder/RelationBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
* RelationBuilder
*
* @psalm-type RelationDefinition = array{
* name: string,
* type: string,
* localKey: string,
* entity?: class-string,
Expand Down Expand Up @@ -53,6 +54,13 @@ class RelationBuilder implements ArrayAccess, IteratorAggregate
*/
private ?string $current = null;

/**
* Mapping of the relation classes to their names
*
* @var array<class-string, array<string, string>>
*/
private array $relationClassesToNames = [];


/**
* Get all defined relations
Expand All @@ -64,6 +72,16 @@ public function relations(): array
return $this->relations;
}

/**
* Mapping of the relation classes to their names
*
* @return array<class-string, array<string, string>>
*/
public function relationClassesToNames(): array
{
return $this->relationClassesToNames;
}

/**
* Specify the property to mark relation
*
Expand Down Expand Up @@ -296,7 +314,11 @@ public function inherit(string $key): self
*/
public function custom(string $relationClass, array $options = []): self
{
$this->relations[$this->current] = ['type' => RelationInterface::CUSTOM, 'relationClass' => $relationClass] + $options;
$this->relations[$this->current] = ['name' => $this->current, 'type' => RelationInterface::CUSTOM, 'relationClass' => $relationClass] + $options;

if ($entity = $options['entity'] ?? null) {
$this->relationClassesToNames[$entity][$this->current] = $this->current;
}

return $this;
}
Expand All @@ -309,7 +331,7 @@ public function custom(string $relationClass, array $options = []): self
*/
public function null(): self
{
$this->relations[$this->current] = ['type' => RelationInterface::NULL];
$this->relations[$this->current] = ['name' => $this->current, 'type' => RelationInterface::NULL];

return $this;
}
Expand Down Expand Up @@ -342,6 +364,8 @@ public function entity(string $entity): self
$this->relations[$this->current]['entity'] = $entity;
$this->relations[$this->current]['distantKey'] = $foreignKey;

$this->relationClassesToNames[$entity][$this->current] = $this->current;

return $this;
}

Expand Down Expand Up @@ -380,9 +404,13 @@ protected function add(string $type, string $key, array $options = []): self
{
if (($this->relations[$this->current]['type'] ?? null) === RelationInterface::BY_INHERITANCE) {
// Inherit from previous relation configuration
$this->relations[$this->current] = ['type' => $type, 'localKey' => $key] + $options + $this->relations[$this->current];
$this->relations[$this->current] = ['name' => $this->current, 'type' => $type, 'localKey' => $key] + $options + $this->relations[$this->current];
} else {
$this->relations[$this->current] = ['type' => $type, 'localKey' => $key] + $options;
$this->relations[$this->current] = ['name' => $this->current, 'type' => $type, 'localKey' => $key] + $options;
}

if ($entity = $options['entity'] ?? null) {
$this->relationClassesToNames[$entity][$this->current] = $this->current;
}

return $this;
Expand Down
2 changes: 0 additions & 2 deletions src/Relations/EntityRelation.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@
* @psalm-method int count()
*
* @mixin ReadCommandInterface<\Bdf\Prime\Connection\ConnectionInterface, R>
*
* @noinspection PhpHierarchyChecksInspection
*/
class EntityRelation
{
Expand Down
24 changes: 16 additions & 8 deletions src/Repository/EntityRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -394,26 +394,34 @@ public function reloadRelations($entity, $relations): void
/**
* Get a entity relation wrapper linked to the entity
*
* @param string $relationName
* @param class-string<R>|string $relationClass The relation class name, or the relation name
* @param string|null $relationName The relation name if the there is multiple relation on the same class
* @param E $entity
*
* @return EntityRelation<E, object>
* @return EntityRelation<E, R>
* @template R as object
*/
public function onRelation(string $relationName, $entity): EntityRelation
public function onRelation(string $relationClass, $entity, ?string $relationName = null): EntityRelation
{
return new EntityRelation($entity, $this->relation($relationName));
return new EntityRelation($entity, $this->relation($relationClass, $relationName));
}

/**
* {@inheritdoc}
*
* @psalm-suppress InvalidReturnType
* @psalm-suppress InvalidReturnStatement
*/
public function relation(string $relationName): RelationInterface
public function relation(string $relationClass, ?string $relationName = null): RelationInterface
{
if (!isset($this->relations[$relationName])) {
$this->relations[$relationName] = Relation::make($this, $relationName, $this->mapper->relation($relationName));
if ($relation = $this->relations[$relationName ?? $relationClass] ?? null) {
return $relation;
}

return $this->relations[$relationName];
$metadata = $this->mapper->relation($relationClass, $relationName);
$relationName = $metadata['name'];

return ($this->relations[$relationName] ??= Relation::make($this, $relationName, $metadata));
}

/**
Expand Down
Loading
Loading