Finesse/Wired

View on GitHub
src/Relations/BelongsToMany.php

Summary

Maintainability
D
2 days
Test Coverage
<?php

namespace Finesse\Wired\Relations;

use Finesse\MiniDB\Database;
use Finesse\Wired\Exceptions\DatabaseException;
use Finesse\Wired\Exceptions\IncorrectQueryException;
use Finesse\Wired\Exceptions\InvalidArgumentException;
use Finesse\Wired\Exceptions\InvalidReturnValueException;
use Finesse\Wired\Exceptions\NotModelException;
use Finesse\Wired\Helpers;
use Finesse\Wired\Mapper;
use Finesse\Wired\ModelInterface;
use Finesse\Wired\ModelQuery;
use Finesse\Wired\RelationInterface;

/**
 * Models relation: a parent model has many child models and the child model has many parent models.
 *
 * @author Surgie
 */
class BelongsToMany implements RelationInterface, AttachableRelationInterface
{
    /**
     * @var string|null The parent model identifier field name
     */
    protected $parentIdentifierField;

    /**
     * @var string The pivot table field containing parent model identifiers
     */
    protected $pivotParentField;

    /**
     * @var string Pivot table name
     */
    protected $pivotTable;

    /**
     * @var string The pivot table field containing child model identifiers
     */
    protected $pivotChildField;

    /**
     * @var string|null The child model identifier field name
     */
    protected $childIdentifierField;

    /**
     * @var string|ModelInterface The child model class name (checked)
     */
    protected $childModelClass;

    /**
     * @param string $modelClass The child model class name
     * @param string $pivotParentField The pivot table field containing parent model identifiers
     * @param string $pivotTable Pivot table name (a table containing the relation connections)
     * @param string $pivotChildField The pivot table field containing child model identifiers
     * @param string|null $parentIdentifierField The parent model identifier field name. Null means that the default
     *  parent model identifier field should be used.
     * @param string|null $childIdentifierField The child model identifier field name. Null means that the default
     *  child model identifier field should be used.
     */
    public function __construct(
        string $modelClass,
        string $pivotParentField,
        string $pivotTable,
        string $pivotChildField,
        string $parentIdentifierField = null,
        string $childIdentifierField = null
    ) {
        Helpers::checkModelClass('The child model class name', $modelClass);

        $this->parentIdentifierField = $parentIdentifierField;
        $this->pivotParentField = $pivotParentField;
        $this->pivotTable = $pivotTable;
        $this->pivotChildField = $pivotChildField;
        $this->childIdentifierField = $childIdentifierField;
        $this->childModelClass = $modelClass;
    }

    /**
     * {@inheritDoc}
     */
    public function applyToQueryWhere(ModelQuery $query, $constraint = null)
    {
        $pivotQuery = $query->makeSubQuery($this->pivotTable);
        $pivotQuery->whereColumn(
            $query->getTableIdentifier().'.'.$this->getParentModelIdentifierField($query),
            $pivotQuery->getTableIdentifier().'.'.$this->pivotParentField
        );
        RelationHelpers::addConstraintToQuery(
            $pivotQuery,
            $this->pivotChildField,
            $this->getChildModelIdentifierField(),
            $this->childModelClass,
            $constraint
        );
        $query->whereExists($pivotQuery->getBaseQuery());
    }

    /**
     * {@inheritDoc}
     */
    public function loadRelatives(Mapper $mapper, string $name, array $parents, \Closure $constraint = null)
    {
        if (!$parents) {
            return;
        }

        $sampleParent = reset($parents);
        $parentModelIdentifierField = $this->getParentModelIdentifierField($sampleParent);
        $pivotParentIdentifierAlias = '__wired_reserved_parent_model_id';
        $pivotChildIdentifierAlias = '__wired_reserved_child_model_id';

        // Collecting the list of the parent model identifiers
        $parentIds = Helpers::getObjectsPropertyValues($parents, $parentModelIdentifierField, true);

        // Getting child models
        if ($parentIds) {
            $query = $mapper->model($this->childModelClass);
            $pivotTableAlias = $query->makeSubQueryAliasIfRequired($this->pivotTable);
            $pivotTableIdentifier = $pivotTableAlias ?? $this->pivotTable;

            if ($constraint) {
                $query = $query->apply($constraint)->addTablesToColumnNames();
            }
            if (!$query->getBaseQuery()->select) {
                $query->addSelect($query->getTableIdentifier().'.*');
            }
            $children = $query
                ->addSelect($pivotTableIdentifier.'.'.$this->pivotParentField, $pivotParentIdentifierAlias)
                ->addSelect($pivotTableIdentifier.'.'.$this->pivotChildField, $pivotChildIdentifierAlias)
                ->innerJoin(
                    [$this->pivotTable, $pivotTableAlias],
                    $pivotTableIdentifier.'.'.$this->pivotChildField,
                    $query->getTableIdentifier().'.'.$this->getChildModelIdentifierField()
                )
                ->whereIn($pivotTableIdentifier.'.'.$this->pivotParentField, $parentIds)
                ->getBaseQuery()->get();
        } else {
            $children = [];
        }

        // Setting the relative models to the input models
        $childrenIndexedById = [];
        $groupedChildren = Helpers::groupArraysByKey($children, $pivotParentIdentifierAlias);
        foreach ($parents as $model) {
            $parentChildren = [];
            foreach ($groupedChildren[$model->$parentModelIdentifierField] ?? [] as $row) {
                $childId = $row[$pivotChildIdentifierAlias];

                if (!isset($childrenIndexedById[$childId])) {
                    unset($row[$pivotParentIdentifierAlias]);
                    unset($row[$pivotChildIdentifierAlias]);
                    $childrenIndexedById[$childId] = $this->childModelClass::createFromRow($row);
                }

                $parentChildren[] = $childrenIndexedById[$childId];
            }

            $model->setLoadedRelatives($name, $parentChildren);
        }
    }

    /**
     * {@inheritDoc}
     *
     * The attachments additional data are extra fields for the pivot table records (keys are field names)
     */
    public function attach(
        Mapper $mapper,
        array $parents,
        array $children,
        string $onMatch,
        bool $detachOther,
        callable $getAttachmentData = null
    ) {
        if (!$parents) {
            return;
        }

        $database = $mapper->getDatabase();
        $sampleParent = reset($parents);
        if ($children) {
            $sampleChild = reset($children);
            Helpers::checkModelObjectClass($sampleChild, $this->childModelClass);
        }
        $parentIdentifierField = $this->getParentModelIdentifierField($sampleParent);
        $childIdentifierField = $this->getChildModelIdentifierField();
        $parentIdentifiers = Helpers::getObjectsPropertyValues($parents, $parentIdentifierField, true);
        $childIdentifiers = Helpers::getObjectsPropertyValues($children, $childIdentifierField, true);

        try {
            // Detaching other children (if required)
            if ($detachOther) {
                $this->detachByIdentifiers($database, $parentIdentifiers, $childIdentifiers, true);
            }

            // Attaching the given children
            switch ($onMatch) {
                /** @noinspection PhpMissingBreakStatementInspection */
                case Mapper::REPLACE:
                    $this->detachByIdentifiers($database, $parentIdentifiers, $childIdentifiers);
                    // intentional break skip

                case Mapper::DUPLICATE:
                    $insertAttachments = [];

                    foreach ($parents as $parentKey => $parent) {
                        foreach ($children as $childKey => $child) {
                            $extraFields = $this->makeAttachmentExtraFields(
                                $parent,
                                $child,
                                $parentKey,
                                $childKey,
                                $getAttachmentData,
                                '$getAttachmentData'
                            );
                            $insertAttachments[] = $this->makeAttachmentRow(
                                $parent->$parentIdentifierField,
                                $child->$childIdentifierField,
                                $extraFields
                            );
                        }
                    }

                    if ($insertAttachments) {
                        $database->table($this->pivotTable)->insert($insertAttachments);
                    }
                    break;

                case Mapper::UPDATE:
                    $groupedParents = Helpers::groupObjectsByProperty($parents, $parentIdentifierField, true);
                    $groupedChildren = Helpers::groupObjectsByProperty($children, $childIdentifierField, true);

                    $oldAttachments = $database
                        ->table($this->pivotTable)
                        ->whereIn($this->pivotParentField, $parentIdentifiers)
                        ->whereIn($this->pivotChildField, $childIdentifiers)
                        ->get();

                    $groupedOldAttachments = [];
                    foreach ($oldAttachments as $row) {
                        $groupedOldAttachments[$row[$this->pivotParentField]][$row[$this->pivotChildField]][] = $row;
                    }

                    $insertAttachments = [];
                    $deleteQuery = null;

                    foreach ($groupedParents as $parentId => $parentsGroup) {
                        foreach ($groupedChildren as $childId => $childrenGroup) {
                            $result = $this->updateAttachmentsGroup(
                                $parentId,
                                $childId,
                                $parentsGroup,
                                $childrenGroup,
                                $groupedOldAttachments[$parentId][$childId] ?? [],
                                $detachOther,
                                $getAttachmentData
                            );

                            if ($result['delete']) {
                                $deleteQuery = ($deleteQuery ?? $database->table($this->pivotTable))->orWhere([
                                    [$this->pivotParentField, $parentId],
                                    [$this->pivotChildField, $childId],
                                ]);
                            }

                            foreach ($result['insert'] as $row) {
                                $insertAttachments[] = $row;
                            }
                        }
                    }

                    if ($deleteQuery) {
                        $deleteQuery->delete();
                    }
                    if ($insertAttachments) {
                        $database->table($this->pivotTable)->insert($insertAttachments);
                    }
                    break;

                default:
                    throw new InvalidArgumentException(sprintf(
                        'An unexpected $onMatch value given (%s)',
                        is_string($onMatch)
                            ? sprintf('"%s"', $onMatch)
                            : (is_object($onMatch) ? get_class($onMatch) : gettype($onMatch))
                    ));
            }
        } catch (\Throwable $exception) {
            throw Helpers::wrapException($exception);
        }
    }

    /**
     * @inheritDoc
     */
    public function detach(Mapper $mapper, array $parents, array $children)
    {
        if (!$parents || !$children) {
            return;
        }

        $sampleParent = reset($parents);
        $sampleChild = reset($children);
        Helpers::checkModelObjectClass($sampleChild, $this->childModelClass);
        $parentIdentifiers = Helpers::getObjectsPropertyValues($parents, $this->getParentModelIdentifierField($sampleParent), true);
        $childIdentifiers = Helpers::getObjectsPropertyValues($children, $this->getChildModelIdentifierField(), true);

        $this->detachByIdentifiers($mapper->getDatabase(), $parentIdentifiers, $childIdentifiers);
    }

    /**
     * Gets the parent model identifier field name from the relation property, a model instance, a model class name or a
     * query object.
     *
     * @param string|ModelInterface|ModelQuery $hint The model class name or the query object
     * @return string
     * @throws IncorrectQueryException
     * @throws InvalidArgumentException
     * @throws NotModelException
     */
    protected function getParentModelIdentifierField($hint): string
    {
        return $this->parentIdentifierField ?? Helpers::getModelIdentifierField($hint);
    }

    /**
     * Gets the object model identifier field name.
     */
    protected function getChildModelIdentifierField(): string
    {
        return $this->childIdentifierField ?? $this->childModelClass::getIdentifierField();
    }

    /**
     * Removes the attachments between the parent and the child models
     *
     * @param Database $database The database access
     * @param array $parentIdentifiers Identifiers of the parent models
     * @param array $childIdentifiers Identifires of the child models
     * @param bool $detachOther If true, the given child models will stay attached but other will be detached
     * @throws DatabaseException
     */
    protected function detachByIdentifiers(
        Database $database,
        array $parentIdentifiers,
        array $childIdentifiers,
        bool $detachOther = false
    ) {
        try {
            $query = $database->table($this->pivotTable);

            if ($detachOther) {
                $query->whereNotIn($this->pivotChildField, $childIdentifiers)->orWhereNull($this->pivotChildField);
            } else {
                $query->whereIn($this->pivotChildField, $childIdentifiers);
            }

            $query
                ->whereIn($this->pivotParentField, $parentIdentifiers)
                ->delete();
        } catch (\Throwable $exception) {
            throw Helpers::wrapException($exception);
        }
    }

    /**
     * Makes the given parents and children be attached. Updates the pivot table rows if an attachment already exists.
     * All the parents must have the same identifier as well as all the children.
     *
     * @param mixed $parentId The parent model identifier in the table
     * @param mixed $childId The child model identifier in the table
     * @param ModelInterface[] $parents The parent models
     * @param ModelInterface[] $children The child models
     * @param array[] $oldAttachments The existing attachments between the models in the database (table rows). Must be
     *  a not associative array.
     * @param bool $detachExcess If true, the excess existing attachments will be removed
     * @param callable|null $getAttachmentData A function generating the attachment additional fields
     * @return mixed[] Array with 2 keys:
     *  - 'delete' - `true` if the existing attachments must be removed from the database;
     *  - 'insert' - the pivot rows to insert to the database;
     * @throws DatabaseException
     * @throws InvalidReturnValueException
     */
    protected function updateAttachmentsGroup(
        $parentId,
        $childId,
        array $parents,
        array $children,
        array $oldAttachments,
        bool $detachExcess,
        callable $getAttachmentData = null
    ): array {
        $oldAttachmentsCount = count($oldAttachments);
        $newAttachmentsCount = count($parents) * count($children);
        $newAttachmentsExtraFields = [];
        $insertAttachments = [];
        $needsUpdate = false;

        foreach ($parents as $parentKey => $parent) {
            foreach ($children as $childKey => $child) {
                $newAttachmentsExtraFields[] = $this->makeAttachmentExtraFields(
                    $parent,
                    $child,
                    $parentKey,
                    $childKey,
                    $getAttachmentData,
                    '$getAttachmentData'
                );
            }
        }

        // Excess new attachments are inserted anyway
        if ($oldAttachmentsCount < $newAttachmentsCount) {
            for ($i = $oldAttachmentsCount; $i < $newAttachmentsCount; ++$i) {
                $insertAttachments[] = $this->makeAttachmentRow($parentId, $childId, $newAttachmentsExtraFields[$i]);
            }
            $newAttachmentsCount = $oldAttachmentsCount;
        }
        // After this line $oldAttachmentsCount >= $newAttachmentsCount

        // A single update query can be performed here when there are only 1 old and 1 new attachment

        if ($oldAttachmentsCount === $newAttachmentsCount) {
            // When number of the old attachments matches number of the new relations, there is a chance that the
            // attachments lists match and an update is not required
            for ($i = 0; $i < $oldAttachmentsCount; ++$i) {
                if (
                    $newAttachmentsExtraFields[$i] &&
                    Helpers::getFieldsToUpdate($oldAttachments[$i], $newAttachmentsExtraFields[$i])
                ) {
                    $needsUpdate = true;
                    break;
                }
            }
        } else {
            $needsUpdate = true;
        }

        // Making rows to insert (the existing are deleted)
        if ($needsUpdate) {
            for ($i = 0; $i < $oldAttachmentsCount; ++$i) {
                if ($i < $newAttachmentsCount) {
                    $insertAttachments[] = $newAttachmentsExtraFields[$i] + $oldAttachments[$i];
                } elseif ($detachExcess) {
                    break;
                } else {
                    $insertAttachments[] = $oldAttachments[$i];
                }
            }
        }

        return [
            'delete' => $needsUpdate,
            'insert' => $insertAttachments,
        ];
    }

    /**
     * Makes a database table row to represent an attachment
     *
     * @param mixed $parentId The parent model identifier in the table
     * @param mixed $childId The child model identifier in the table
     * @param array|null Additional fields to the row. The keys are the table fields names.
     * @return array The table row. The keys are the field names.
     */
    protected function makeAttachmentRow($parentId, $childId, array $extraFields = null): array
    {
        $row = [
            $this->pivotParentField => $parentId,
            $this->pivotChildField  => $childId
        ];

        return $extraFields ? $extraFields + $row : $row;
    }

    /**
     * Makes a list of additional fields to the attachment table row
     *
     * @param ModelInterface[] $parents Parent models
     * @param ModelInterface[] $children Child models
     * @param string|int $parentKey The index of the parent models array
     * @param string|int $childKey The index of the child models array
     * @param callable|null $extraFieldsMaker A function generating the attachment additional fields
     * @param string $callbackArgumentName The $extraFieldsMaker argument name for the exceptions
     * @return array|null The keys are the field names
     * @throws InvalidReturnValueException
     */
    protected function makeAttachmentExtraFields(
        ModelInterface $parent,
        ModelInterface $child,
        $parentKey,
        $childKey,
        callable $extraFieldsMaker = null,
        string $callbackArgumentName = '$extraFieldsMaker'
    ) {
        $extraFields = $extraFieldsMaker
            ? $extraFieldsMaker($parent, $child, $parentKey, $childKey)
            : null;

        if ($extraFields !== null && !is_array($extraFields)) {
            throw new InvalidReturnValueException(sprintf(
                'The %s return value expected to be an array or null, %s given',
                $callbackArgumentName,
                is_object($extraFields) ? get_class($extraFields) : gettype($extraFields)
            ));
        }

        return $extraFields;
    }
}