Commit d969a6f6 authored by Bernhard Schussek's avatar Bernhard Schussek

Consolidated the different pagination implementations

parent 0a675fb2
.idea
.composer-cache
vendor
.php_cs.cache
......@@ -11,7 +11,7 @@
declare(strict_types=1);
namespace Cwd\DataDoctrineORMBundle\OwnedValue;
namespace Cwd\DataDoctrineORMBundle\CollectionEntry;
use Doctrine\Common\Collections\Collection;
use Webmozart\Assert\Assert;
......@@ -26,17 +26,17 @@ use Webmozart\Assert\Assert;
*
* These separate relations need an ID and a reference (foreign key) back to
* the object that owns them. None of that exists in a value object. To add
* this kind of information to a value object, it is wrapped into a OwnedValue.
* this kind of information to a value object, it is wrapped into a CollectionEntry.
*
* If you want to store a collection of value objects, create a new subclass
* of OwnedValue and override the OWNER_CLASS and VALUE_CLASS constants with:
* of CollectionEntry and override the OWNER_CLASS and VALUE_CLASS constants with:
*
* * The class name of the owning class (e.g. Contact)
* * The class name of the value object (e.g. PhoneNumber)
*
* Example:
*
* class ContactPhoneNumber extends OwnedValue
* class ContactPhoneNumber extends CollectionEntry
* {
* protected const OWNER_CLASS = Contact::class;
*
......@@ -44,7 +44,7 @@ use Webmozart\Assert\Assert;
* }
*
* In the owning class, use the methods synchronize() and synchronizeAll() to
* synchronize OwnedValue entities with actual value objects.
* synchronize CollectionEntry entities with actual value objects.
*
* Example:
*
......@@ -54,7 +54,7 @@ use Webmozart\Assert\Assert;
* ContactPhoneNumber::synchronizeAll($this->phoneNumbers, $phoneNumbers, $this);
* }
*/
abstract class OwnedValue
abstract class CollectionEntry
{
/**
* The name of the class owning the value object.
......@@ -147,7 +147,7 @@ abstract class OwnedValue
/**
* Extracts the value object of an owned value.
*
* @param OwnedValue|null $ownedValue The owned value. May be null
* @param CollectionEntry|null $ownedValue The owned value. May be null
*
* @return object|null The value object or null
*/
......
<?php
/*
* This file is part of the CWD Data Doctrine ORM Bundle
*
* (c) cwd.at GmbH <office@cwd.at>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Cwd\DataDoctrineORMBundle\Mapping;
use function class_exists;
use OutOfBoundsException;
use Webmozart\Assert\Assert;
class AdditionalMetadata
{
private $className;
private $joinMappings = [];
private $queryMappings = [];
private $ownedClasses = [];
private $suggestMappings = [];
public function __construct(string $className)
{
$this->className = $className;
}
public function getClassName(): string
{
return $this->className;
}
public function addJoinMapping(string $joinAlias, array $joinMapping): void
{
Assert::notEmpty($joinAlias);
Assert::keyExists($joinMapping, 'field');
Assert::keyExists($joinMapping, 'targetEntity');
Assert::keyExists($joinMapping, 'targetField');
Assert::stringNotEmpty($joinMapping['field']);
Assert::stringNotEmpty($joinMapping['targetEntity']);
Assert::stringNotEmpty($joinMapping['targetField']);
$this->joinMappings[$joinAlias] = $joinMapping;
}
public function getJoinMapping(string $joinAlias): array
{
if (!isset($this->joinMappings[$joinAlias])) {
throw new OutOfBoundsException(sprintf(
'The join mapping with the alias "%s" does not exist.',
$joinAlias
));
}
return $this->joinMappings[$joinAlias];
}
public function hasJoinMapping(string $joinAlias): bool
{
return isset($this->joinMappings[$joinAlias]);
}
public function getJoinMappings(): array
{
return $this->joinMappings;
}
public function addQueryMapping(string $fieldName, array $queryMapping): void
{
Assert::notEmpty($fieldName);
$queryMapping = array_replace([
'type' => 'string',
'fuzzy' => true,
], $queryMapping);
Assert::oneOf($queryMapping['type'], ['string', 'date']);
Assert::boolean($queryMapping['fuzzy']);
$this->queryMappings[$fieldName] = $queryMapping;
}
public function getQueryFields(): array
{
return array_keys($this->queryMappings);
}
public function getQueryMapping(string $fieldName): array
{
if (!isset($this->queryMappings[$fieldName])) {
throw new OutOfBoundsException(sprintf(
'The query mapping for the field "%s" does not exist.',
$fieldName
));
}
return $this->queryMappings[$fieldName];
}
public function getQueryMappings(): array
{
return $this->queryMappings;
}
public function addOwnedClass(string $className): void
{
Assert::notEmpty($className);
Assert::true(class_exists($className));
$this->ownedClasses[] = $className;
}
public function hasOwnedClass(string $className): bool
{
return in_array($className, $this->ownedClasses, true);
}
public function getOwnedClasses(): array
{
return $this->ownedClasses;
}
public function addSuggestMapping(string $fieldName, array $suggestMapping): void
{
Assert::notEmpty($fieldName);
$this->suggestMappings[$fieldName] = $suggestMapping;
}
public function getSuggestMapping(string $fieldName): array
{
if (!isset($this->suggestMappings[$fieldName])) {
throw new OutOfBoundsException(sprintf(
'The suggest mapping for the field "%s" does not exist.',
$fieldName
));
}
return $this->suggestMappings[$fieldName];
}
public function hasSuggestMapping(string $fieldName): bool
{
return isset($this->suggestMappings[$fieldName]);
}
public function getSuggestMappings(): array
{
return $this->suggestMappings;
}
}
<?php
/*
* This file is part of the CWD Data Doctrine ORM Bundle
*
* (c) cwd.at GmbH <office@cwd.at>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Cwd\DataDoctrineORMBundle\Mapping;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Mapping\ClassMetadata;
use function substr;
use Webmozart\Assert\Assert;
class AdditionalMetadataLoader
{
private $em;
public function __construct(EntityManager $em)
{
$this->em = $em;
}
public function loadMetadata(string $className): AdditionalMetadata
{
$classMetadata = $this->em->getClassMetadata($className);
$options = $classMetadata->table['options'] ?? [];
$joinMappings = $options['joins'] ?? [];
$queryMappings = $options['query'] ?? [];
$suggestMappings = $options['suggest'] ?? [];
$collectionMappings = $options['collections'] ?? [];
$additionalMetadata = new AdditionalMetadata($className);
$this->loadJoinMappings($additionalMetadata, $classMetadata, $joinMappings, $collectionMappings);
$this->loadQueryMappings($additionalMetadata, $classMetadata, $queryMappings);
$this->loadOwnedClasses($additionalMetadata, $collectionMappings);
$this->loadSuggestMappings($additionalMetadata, $classMetadata, $suggestMappings);
return $additionalMetadata;
}
private function loadJoinMappings(AdditionalMetadata $additionalMetadata, ClassMetadata $classMetadata, array $joinMappings, array $collectionMappings): void
{
foreach ($collectionMappings as $collectionName => $collectionMapping) {
if ('s' === substr($collectionName, -1)) {
$joinAlias = substr($collectionName, 0, -1);
if (isset($joinMappings[$joinAlias])) {
continue;
}
$joinMappings[$joinAlias] = ['field' => $collectionName];
}
}
foreach ($classMetadata->getFieldNames() as $fieldName) {
$fieldMapping = $classMetadata->getFieldMapping($fieldName);
$options = $fieldMapping['options'] ?? [];
if (isset($options['references']) && 'Id' === substr($fieldName, -2)) {
$joinAlias = substr($fieldName, 0, -2);
if (isset($joinMappings[$joinAlias])) {
continue;
}
$joinMappings[$joinAlias] = ['field' => $fieldName];
}
}
foreach ($joinMappings as $joinAlias => $joinMapping) {
$this->loadJoinMapping($additionalMetadata, $joinMappings, $joinAlias);
}
}
private function loadJoinMapping(AdditionalMetadata $additionalMetadata, array $joinMappings, string $joinAlias): void
{
if ($additionalMetadata->hasJoinMapping($joinAlias)) {
// Already loaded by recursion
return;
}
$joinMapping = $joinMappings[$joinAlias];
if (!isset($joinMapping['field'])) {
throw new MappingException(sprintf(
'Missing option "field" on join "%s" of entity "%s".',
$joinAlias,
$additionalMetadata->getClassName()
));
}
if (isset($joinMapping['via'])) {
$this->loadJoinMapping($additionalMetadata, $joinMappings, $joinMapping['via']);
$viaClassMetadata = $this->em->getClassMetadata(
$additionalMetadata->getJoinMapping($joinMapping['via'])['targetEntity']
);
} else {
$viaClassMetadata = $this->em->getClassMetadata($additionalMetadata->getClassName());
}
$fieldMapping = $viaClassMetadata->hasField($joinMapping['field'])
? $viaClassMetadata->getFieldMapping($joinMapping['field'])
: [];
if (!isset($joinMapping['targetEntity'])) {
if (isset($fieldMapping['options']['references']['entity'])) {
$joinMapping['targetEntity'] = $fieldMapping['options']['references']['entity'];
} elseif ($viaClassMetadata->hasAssociation($joinMapping['field'])) {
$joinMapping['targetEntity'] = $viaClassMetadata->getAssociationTargetClass($joinMapping['field']);
} else {
throw new MappingException(sprintf(
'Missing option "targetEntity" on join "%s" of entity "%s" and the '.
'field "%s" is neither an association nor is the "references" option set '.
'in the XML metadata of "%s".',
$joinAlias,
$additionalMetadata->getClassName(),
$joinMapping['field'],
$viaClassMetadata->getName()
));
}
}
if (!isset($joinMapping['targetField'])) {
if (isset($fieldMapping['options']['references']['field'])) {
$joinMapping['targetField'] = $fieldMapping['options']['references']['field'];
} elseif ($viaClassMetadata->hasAssociation($joinMapping['field'])) {
$associationMapping = $viaClassMetadata->getAssociationMapping($joinMapping['field']);
// TODO figure out how to support composite primary keys, once necessary
$joinMapping['field'] = current($viaClassMetadata->getIdentifierFieldNames());
$joinMapping['targetField'] = $associationMapping['mappedBy'];
} else {
throw new MappingException(sprintf(
'Missing option "targetField" on join "%s" of entity "%s" and the '.
'field "%s" is neither an association nor is the "references" option set '.
'in the XML metadata of "%s".',
$joinAlias,
$additionalMetadata->getClassName(),
$joinMapping['field'],
$viaClassMetadata->getName()
));
}
}
$additionalMetadata->addJoinMapping($joinAlias, $joinMapping);
}
private function loadQueryMappings(AdditionalMetadata $additionalMetadata, ClassMetadata $classMetadata, array $queryMappings): void
{
foreach ($queryMappings as $fieldName => $queryMapping) {
if ('' === $queryMapping) {
$queryMapping = [];
}
$fieldMapping = $this->getFieldMapping($additionalMetadata, $classMetadata, $fieldName);
if (!isset($queryMapping['type'])) {
switch ($fieldMapping['type']) {
case 'local_date':
$queryMapping['type'] = 'date';
break;
default:
$queryMapping['type'] = 'string';
break;
}
}
if (isset($queryMapping['fuzzy'])) {
Assert::oneOf($queryMapping['fuzzy'], ['true', 'false']);
$queryMapping['fuzzy'] = 'true' === $queryMapping['fuzzy'];
}
$additionalMetadata->addQueryMapping($fieldName, $queryMapping);
}
}
private function loadOwnedClasses(AdditionalMetadata $additionalMetadata, array $collectionMappings): void
{
foreach ($collectionMappings as $fieldName => $collectionMapping) {
$additionalMetadata->addOwnedClass($collectionMapping['entryClass']);
}
}
private function loadSuggestMappings(AdditionalMetadata $additionalMetadata, ClassMetadata $classMetadata, array $suggestMappings): void
{
// If there is no custom mapping and if the field "name" exists, use it
if (0 === count($suggestMappings) && $classMetadata->hasField('name')) {
$suggestMappings = ['name' => []];
}
foreach ($suggestMappings as $fieldName => $suggestMapping) {
if ('' === $suggestMapping) {
$suggestMapping = [];
}
// Validate
$this->getFieldMapping($additionalMetadata, $classMetadata, $fieldName);
$additionalMetadata->addSuggestMapping($fieldName, $suggestMapping);
}
}
private function getFieldMapping(AdditionalMetadata $additionalMetadata, ClassMetadata $classMetadata, string $fieldName): array
{
$pos = strpos($fieldName, '.');
if (false !== $pos) {
$portionBeforeDot = substr($fieldName, 0, $pos);
$portionAfterDot = substr($fieldName, $pos + 1);
if ($additionalMetadata->hasJoinMapping($portionBeforeDot)) {
$joinMapping = $additionalMetadata->getJoinMapping($portionBeforeDot);
$targetClassMetadata = $this->em->getClassMetadata($joinMapping['targetEntity']);
return $targetClassMetadata->getFieldMapping($portionAfterDot);
}
if (!$classMetadata->hasField($fieldName)) {
throw new MappingException(sprintf(
'The field "%s" is neither an embedded object nor '.
'a mapped join on entity "%s".',
$fieldName,
$additionalMetadata->getClassName()
));
}
}
return $classMetadata->getFieldMapping($fieldName);
}
}
<?php
/*
* This file is part of the CWD Data Doctrine ORM Bundle
*
* (c) cwd.at GmbH <office@cwd.at>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Cwd\DataDoctrineORMBundle\Mapping\Driver;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\ClassMetadataFactory;
use Doctrine\ORM\Mapping\Driver\SimplifiedXmlDriver;
class XmlDriverWithCollectionSupport extends SimplifiedXmlDriver
{
private $classMetadataFactory;
public function setClassMetadataFactory(ClassMetadataFactory $classMetadataFactory): void
{
$this->classMetadataFactory = $classMetadataFactory;
}
public function loadMetadataForClass($className, \Doctrine\Common\Persistence\Mapping\ClassMetadata $metadata)
{
/* @var $metadata \Doctrine\ORM\Mapping\ClassMetadataInfo */
parent::loadMetadataForClass($className, $metadata);
$options = $metadata->table['options'] ?? [];
if (isset($options['collections'])) {
$collectionMappings = $options['collections'];
foreach ($collectionMappings as $fieldName => $collectionMapping) {
if (isset($metadata->associationMappings[$fieldName])) {
continue;
}
$metadata->associationMappings[$fieldName] = array(
'fieldName' => $fieldName,
'targetEntity' => $collectionMapping['entryClass'],
'mappedBy' => 'owner',
'cascade' => ['remove', 'persist', 'refresh', 'merge', 'detach'],
'orphanRemoval' => true,
'orderBy' => ['id' => 'ASC'],
'type' => ClassMetadata::ONE_TO_MANY,
'inversedBy' => null,
'isOwningSide' => false,
'sourceEntity' => $metadata->getName(),
'fetch' => ClassMetadata::FETCH_LAZY,
'isCascadeRemove' => true,
'isCascadePersist' => true,
'isCascadeRefresh' => true,
'isCascadeMerge' => true,
'isCascadeDetach' => true,
);
}
}
if (isset($options['collectionEntry'])) {
if (!isset($metadata->fieldMappings['id'], $metadata->identifier)) {
$metadata->fieldMappings['id'] = [
'id' => true,
'fieldName' => 'id',
'type' => 'integer',
'columnName' => 'id',
];
$metadata->identifier = ['id'];
$metadata->fieldNames['id'] = 'id';
$metadata->columnNames['id'] = 'id';
$metadata->isIdentifierComposite = false;
$metadata->generatorType = ClassMetadata::GENERATOR_TYPE_AUTO;
}
if (!isset($metadata->associationMappings['owner'])) {
$metadata->associationMappings['owner'] = [
'fieldName' => 'owner',
'targetEntity' => $options['collectionEntry']['ownerClass'],
'inversedBy' => $options['collectionEntry']['ownerField'],
'joinColumns' => [
[
'name' => 'owner_id',
'referencedColumnName' => 'id',
'nullable' => false,
'onDelete' => 'CASCADE',
],
],
'type' => ClassMetadata::MANY_TO_ONE,
'mappedBy' => null,
'isOwningSide' => true,
'sourceEntity' => $className,
'fetch' => ClassMetadata::FETCH_LAZY,
'cascade' => [],
'isCascadeRemove' => false,
'isCascadePersist' => false,
'isCascadeRefresh' => false,
'isCascadeMerge' => false,
'isCascadeDetach' => false,
'sourceToTargetKeyColumns' => ['owner_id' => 'id'],
'joinColumnFieldNames' => ['owner_id' => 'owner_id'],
'targetToSourceKeyColumns' => ['id' => 'owner_id'],
'orphanRemoval' => false,
];
}
}
}
}
<?php
/*
* This file is part of the CWD Data Doctrine ORM Bundle
*
* (c) cwd.at GmbH <office@cwd.at>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Cwd\DataDoctrineORMBundle\Mapping;
use RuntimeException;
class MappingException extends RuntimeException
{
}
<?php
/*
* This file is part of the CWD Data Doctrine ORM Bundle
*
* (c) cwd.at GmbH <office@cwd.at>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Cwd\DataDoctrineORMBundle\Pagination;
use Cwd\DataBundle\Pagination\Offset\Base64Offset;
use Cwd\DataBundle\Pagination\Offset\NumericOffset;
use Cwd\DataBundle\Pagination\Page\Item;
use Cwd\DataBundle\Pagination\Page\Page;
use Cwd\DataBundle\Pagination\PageRequest\AfterOffset;
use Cwd\DataBundle\Pagination\PageRequest\NumberedPage;
use Cwd\DataBundle\Pagination\PageRequest\PageRequest;
use LogicException;
class PageFactory
{
public function getPageSlice(array $results, ?PageRequest $pageRequest): array
{
switch (true) {
case $pageRequest instanceof AfterOffset:
return array_slice($results, 1, $pageRequest->getNumberOfItems());
case $pageRequest instanceof NumberedPage:
return $results;
case null === $pageRequest:
return $results;
default:
throw new LogicException(sprintf(
'Unsupported page request: %s',
get_class($pageRequest)
));
}
}
public function createItems(array $results, ?PageRequest $pageRequest): array
{
switch (true) {
case $pageRequest instanceof AfterOffset:
return array_map(
function ($result) {
return new Item($result[0], Base64Offset::fromDecodedString($result['offset']));
},
$results
);
case $pageRequest instanceof NumberedPage:
$baseOffset = $pageRequest->getPageNumber() * $pageRequest->getNumberOfItems();
return count($results) > 0
? array_map(
function ($result, int $index) use ($baseOffset) {
return new Item($result, new NumericOffset($index, $baseOffset + $index));
},
$results,
range(0, count($results) - 1)
)
: [];
case null === $pageRequest:
return count($results) > 0
? array_map(
function ($result, int $index) {
return new Item($result, new NumericOffset($index, $index));
},
$results,
range(0, count($results) - 1)
)
: [];
default:
throw new LogicException(sprintf(
'Unsupported page request: %s',
get_class($pageRequest)