<NME> TaxonomyType.php
<BEF> <?php

namespace Bolt\Storage\Field\Type;

use Bolt\Exception\StorageException;
use Bolt\Storage\Collection;
use Bolt\Storage\Entity;
use Bolt\Storage\Mapping\ClassMetadata;
use Bolt\Storage\Query;
use Bolt\Storage\Query\QueryInterface;
use Bolt\Storage\QuerySet;
use Doctrine\DBAL\Query\QueryBuilder;

/**
 * This is one of a suite of basic Bolt field transformers that handles
 * the lifecycle of a field from pre-query to persist.
 *
 * @author Ross Riley <riley.ross@gmail.com>
 */
class TaxonomyType extends JoinTypeBase
{
    /**
     * Taxonomy fields allows queries on the parameters passed in.
     * For example the following queries:
     *     'pages', {'categories'=>'news'}
     *     'pages', {'categories'=>'news || events'}.
     *
     * Because the search is actually on the join table, we replace the
     * expression to filter the join side rather than on the main side.
     *
     * @param QueryInterface $query
     * @param ClassMetadata  $metadata
     *
     * @return QueryBuilder|null
     */
    public function query(QueryInterface $query, ClassMetadata $metadata)
    {
        $field = $this->mapping['fieldname'];
        /** @var Query\SelectQuery $query */
        foreach ($query->getFilters() as $filter) {
            foreach ((array) $filter->getKey() as $filterKey) {
                if ($filterKey == $field) {
                    $this->rewriteQueryFilterParameters($filter, $query, $field, 'slug');
                }
            }
        }

        return null;
    }

    /**
     * For the taxonomy field the load event modifies the query to fetch taxonomies related
     * to a content record from the join table.
     *
     * It does this via an additional ->addSelect() and ->leftJoin() call on the QueryBuilder
     * which includes then includes the taxonomies in the same query as the content fetch.
     *
     * @param QueryBuilder  $query
     * @param ClassMetadata $metadata
     *
     * @return QueryBuilder|null
     */
    public function load(QueryBuilder $query, ClassMetadata $metadata)
    {
        $field = $this->mapping['fieldname'];
        $target = $this->mapping['target'];
        $boltname = $metadata->getBoltName();

        if ($this->mapping['data']['has_sortorder']) {
            $order = "$field.sortorder";
            $query->addSelect($this->getPlatformGroupConcat("$field.sortorder", $order, '_' . $field . '_sortorder',
                $query));
        } else {
            $order = "$field.id";
        }

        $from = $query->getQueryPart('from');

        if (isset($from[0]['alias'])) {
            $alias = $from[0]['alias'];
        } else {
            $alias = $from[0]['table'];
        }

        $quotedField = $query->getConnection()->quoteIdentifier($field);

        $query
            ->addSelect($this->getPlatformGroupConcat("$field.id", $order, '_' . $field . '_id', $query))
            ->addSelect($this->getPlatformGroupConcat("$field.slug", $order, '_' . $field . '_slug', $query))
            ->addSelect($this->getPlatformGroupConcat("$field.name", $order, '_' . $field . '_name', $query))
            ->addSelect($this->getPlatformGroupConcat("$field.taxonomytype", $order, '_' . $field . '_taxonomytype',
                $query))
            ->leftJoin($alias, $target, $quotedField,
                "$alias.id = $quotedField.content_id AND $quotedField.contenttype='$boltname' AND $quotedField.taxonomytype='$field'")
            ->addGroupBy("$alias.id")
        ;

        return null;
    }

    /**
     * {@inheritdoc}
     */
    public function hydrate($data, $entity)
    {
        $taxName = $this->mapping['fieldname'];

        $data = $this->normalizeData($data, $taxName, '|');
        /** @var Entity\Content $entity */
        if (!count($entity->getTaxonomy())) {
            $entity->setTaxonomy($this->em->createCollection(Entity\Taxonomy::class));
        }

        /** @var Collection\Taxonomy $fieldTaxonomy */
        $fieldTaxonomy = $this->em->createCollection(Entity\Taxonomy::class);
        foreach ($data as $tax) {
            $tax['content_id'] = $entity->getId();
            $tax['contenttype'] = (string) $entity->getContenttype();
            $taxEntity = new Entity\Taxonomy($tax);
            $entity->getTaxonomy()
                ->add($taxEntity);
            $fieldTaxonomy->add($taxEntity);
        }
        $this->set($entity, $fieldTaxonomy);
        $grouping = $this->getGroup($fieldTaxonomy);
        if ($grouping) {
            $entity->setGroup($this->getGroup($fieldTaxonomy));
            $entity->setSortorder($this->getSortorder($fieldTaxonomy));
        }
    }

    /**
     * {@inheritdoc}
     */
    public function persist(QuerySet $queries, $entity)
    {
        $this->normalize($entity);
        $field = $this->mapping['fieldname'];
        $taxonomy = $entity->getTaxonomy()
            ->getField($field);

        // Fetch existing taxonomies
        $existingDB = $this->getExistingTaxonomies($entity) ?: [];
        /** @var Collection\Taxonomy $collection */
        $collection = $this->em->getCollectionManager()
            ->create(Entity\Taxonomy::class);
        $collection->setFromDatabaseValues($existingDB);
        $toDelete = $collection->update($taxonomy);
        $repo = $this->em->getRepository(Entity\Taxonomy::class);

        $queries->onResult(
            function ($query, $result, $id) use ($repo, $collection, $toDelete) {
                foreach ($collection as $entity) {
                    $entity->content_id = $id;
                    $repo->save($entity, true);
                }

                foreach ($toDelete as $entity) {
                    $repo->delete($entity);
                }
            }
        );
    }

    /**
     * {@inheritdoc}
     */
    public function getName()
    {
        return 'taxonomy';
    }

    /**
     * Get platform specific group_concat token for provided column.
     *
     * @param string       $column
     * @param string       $order
     * @param string       $alias
     * @param QueryBuilder $query
     *
     * @throws StorageException
     *
     * @return string
     */
    protected function getPlatformGroupConcat($column, $order, $alias, QueryBuilder $query)
    {
        $platform = $query->getConnection()
            ->getDatabasePlatform()
            ->getName();

        switch ($platform) {
            case 'mysql':
                return "GROUP_CONCAT($column ORDER BY $order ASC SEPARATOR '|') as $alias";
            case 'sqlite':
                return "GROUP_CONCAT($column, '|') as $alias";
            case 'postgresql':
                return "string_agg($column" . "::character varying, '|' ORDER BY $order) as $alias";
        }

        throw new StorageException(sprintf('Unsupported platform: %s', $platform));
    }

    /**
     * @param Collection\Taxonomy $taxonomy
     *
     * @return array|null
     */
    protected function getGroup(Collection\Taxonomy $taxonomy)
    {
        $group = null;
        $taxData = $this->mapping['data'];
        foreach ($taxonomy as $tax) {
            if ($taxData['behaves_like'] === 'grouping') {
                // Previously we only cared about the last one… so yeah
                $needle = $tax->getSlug();
                $index = array_search($needle, array_keys($taxData['options']));
                $group = [
                    'slug'  => $tax->getSlug(),
                    'name'  => $tax->getName(),
                    'order' => $tax->getSortorder(),
                    'index' => $index ?: 2147483647, // Maximum for a 32-bit integer
                ];
            }
        }

        return $group;
    }

    /**
     * @param Collection\Taxonomy $taxonomy
     *
     * @return int
     */
    protected function getSortorder(Collection\Taxonomy $taxonomy)
    {
        $taxData = $this->mapping['data'];
        $sortorder = 0;
        foreach ($taxonomy as $tax) {
            if ($taxData['has_sortorder']) {
                $sortorder = $tax->getSortorder();
            }
        }

        return $sortorder;
    }

    /**
     * Direct query to get existing taxonomy records.
     *
     * @param mixed $entity
     *
     * @return array
     */
    protected function getExistingTaxonomies($entity)
    {
        // Fetch existing taxonomies
        $query = $this->em->createQueryBuilder()
            ->select('*')
            ->from($this->mapping['target'])
            ->where('content_id = :content_id')
            ->andWhere('contenttype = :contenttype')
            ->andWhere('taxonomytype = :taxonomytype')
            ->setParameters([
                'content_id'   => $entity->id,
                'contenttype'  => $entity->getContenttype(),
                'taxonomytype' => $this->mapping['fieldname'],
            ]);
        $result = $query->execute()
            ->fetchAll();

        return $result ?: [];
    }

    /**
     * The normalize method takes care of any pre-persist cleaning up.
     *
     * For taxonomies that allows us to support non standard data formats such
     * as arrays that allow this style data setting to work...
     *
     *   `$entity->setCategories(['news', 'events']);`
     *
     *    or
     *
     *   `$entity->setCategories('news');`
     *
     * @param Entity\Content $entity
     */
    public function normalize($entity)
    {
        /** @var Collection\Taxonomy $collection */
        $collection = $this->normalizeFromPost($entity, Entity\Taxonomy::class);
        if ($collection) {
            $entity->setTaxonomy($collection);
        }
    }
}

<MSG> Another PHP 7.4 compat tweak
<DFF> @@ -66,7 +66,7 @@ class TaxonomyType extends JoinTypeBase
         $target = $this->mapping['target'];
         $boltname = $metadata->getBoltName();
 
-        if ($this->mapping['data']['has_sortorder']) {
+        if (is_array($this->mapping['data']) && $this->mapping['data']['has_sortorder']) {
             $order = "$field.sortorder";
             $query->addSelect($this->getPlatformGroupConcat("$field.sortorder", $order, '_' . $field . '_sortorder',
                 $query));

{'commit': 'diff --git a/src/Storage/Field/Type/TaxonomyType.php b/src/Storage/Field/Type/TaxonomyType.php\nindex 99b28f03f..abfbbb47c 100644\n--- a/src/Storage/Field/Type/TaxonomyType.php\n+++ b/src/Storage/Field/Type/TaxonomyType.php\n@@ -66,7 +66,7 @@ class TaxonomyType extends JoinTypeBase\n         $target = $this->mapping[\'target\'];\n         $boltname = $metadata->getBoltName();\n \n-        if ($this->mapping[\'data\'][\'has_sortorder\']) {\n+        if (is_array($this->mapping[\'data\']) && $this->mapping[\'data\'][\'has_sortorder\']) {\n             $order = "$field.sortorder";\n             $query->addSelect($this->getPlatformGroupConcat("$field.sortorder", $order, \'_\' . $field . \'_sortorder\',\n                 $query));\n', 'commit_subject': 'Another PHP 7.4 compat tweak', 'repo_name': 'bolt/bolt', 'committer': {'name': 'GitHub', 'email': '1505422b2465e9a84f6fdfaa161078890c593f06@github.com', 'time_sec': '1636716477', 'tz_offset': '60', 'date': {'seconds': '1636716477'}}, 'commit_tree': 'ffbd71625b125bd821c8934588b405af04b98592', 'commit_message': 'Another PHP 7.4 compat tweak', 'language_name': 'HTML', 'license': 'mit', 'hunks': '@@ -66,7 +66,7 @@ class TaxonomyType extends JoinTypeBase\n         $target = $this->mapping[\'target\'];\n         $boltname = $metadata->getBoltName();\n \n-        if ($this->mapping[\'data\'][\'has_sortorder\']) {\n+        if (is_array($this->mapping[\'data\']) && $this->mapping[\'data\'][\'has_sortorder\']) {\n             $order = "$field.sortorder";\n             $query->addSelect($this->getPlatformGroupConcat("$field.sortorder", $order, \'_\' . $field . \'_sortorder\',\n                 $query));\n', 'addition_count': 1, 'deletion_count': 1, 'src_file': 'a/src/Storage/Field/Type/TaxonomyType.php', 'tgt_file': 'b/src/Storage/Field/Type/TaxonomyType.php', 'file_extension': '.php', 'before_file': '<?php\n\nnamespace Bolt\\Storage\\Field\\Type;\n\nuse Bolt\\Exception\\StorageException;\nuse Bolt\\Storage\\Collection;\nuse Bolt\\Storage\\Entity;\nuse Bolt\\Storage\\Mapping\\ClassMetadata;\nuse Bolt\\Storage\\Query;\nuse Bolt\\Storage\\Query\\QueryInterface;\nuse Bolt\\Storage\\QuerySet;\nuse Doctrine\\DBAL\\Query\\QueryBuilder;\n\n/**\n * This is one of a suite of basic Bolt field transformers that handles\n * the lifecycle of a field from pre-query to persist.\n *\n * @author Ross Riley <riley.ross@gmail.com>\n */\nclass TaxonomyType extends JoinTypeBase\n{\n    /**\n     * Taxonomy fields allows queries on the parameters passed in.\n     * For example the following queries:\n     *     \'pages\', {\'categories\'=>\'news\'}\n     *     \'pages\', {\'categories\'=>\'news || events\'}.\n     *\n     * Because the search is actually on the join table, we replace the\n     * expression to filter the join side rather than on the main side.\n     *\n     * @param QueryInterface $query\n     * @param ClassMetadata  $metadata\n     *\n     * @return QueryBuilder|null\n     */\n    public function query(QueryInterface $query, ClassMetadata $metadata)\n    {\n        $field = $this->mapping[\'fieldname\'];\n        /** @var Query\\SelectQuery $query */\n        foreach ($query->getFilters() as $filter) {\n            foreach ((array) $filter->getKey() as $filterKey) {\n                if ($filterKey == $field) {\n                    $this->rewriteQueryFilterParameters($filter, $query, $field, \'slug\');\n                }\n            }\n        }\n\n        return null;\n    }\n\n    /**\n     * For the taxonomy field the load event modifies the query to fetch taxonomies related\n     * to a content record from the join table.\n     *\n     * It does this via an additional ->addSelect() and ->leftJoin() call on the QueryBuilder\n     * which includes then includes the taxonomies in the same query as the content fetch.\n     *\n     * @param QueryBuilder  $query\n     * @param ClassMetadata $metadata\n     *\n     * @return QueryBuilder|null\n     */\n    public function load(QueryBuilder $query, ClassMetadata $metadata)\n    {\n        $field = $this->mapping[\'fieldname\'];\n        $target = $this->mapping[\'target\'];\n        $boltname = $metadata->getBoltName();\n\n        if ($this->mapping[\'data\'][\'has_sortorder\']) {\n            $order = "$field.sortorder";\n            $query->addSelect($this->getPlatformGroupConcat("$field.sortorder", $order, \'_\' . $field . \'_sortorder\',\n                $query));\n        } else {\n            $order = "$field.id";\n        }\n\n        $from = $query->getQueryPart(\'from\');\n\n        if (isset($from[0][\'alias\'])) {\n            $alias = $from[0][\'alias\'];\n        } else {\n            $alias = $from[0][\'table\'];\n        }\n\n        $quotedField = $query->getConnection()->quoteIdentifier($field);\n\n        $query\n            ->addSelect($this->getPlatformGroupConcat("$field.id", $order, \'_\' . $field . \'_id\', $query))\n            ->addSelect($this->getPlatformGroupConcat("$field.slug", $order, \'_\' . $field . \'_slug\', $query))\n            ->addSelect($this->getPlatformGroupConcat("$field.name", $order, \'_\' . $field . \'_name\', $query))\n            ->addSelect($this->getPlatformGroupConcat("$field.taxonomytype", $order, \'_\' . $field . \'_taxonomytype\',\n                $query))\n            ->leftJoin($alias, $target, $quotedField,\n                "$alias.id = $quotedField.content_id AND $quotedField.contenttype=\'$boltname\' AND $quotedField.taxonomytype=\'$field\'")\n            ->addGroupBy("$alias.id")\n        ;\n\n        return null;\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function hydrate($data, $entity)\n    {\n        $taxName = $this->mapping[\'fieldname\'];\n\n        $data = $this->normalizeData($data, $taxName, \'|\');\n        /** @var Entity\\Content $entity */\n        if (!count($entity->getTaxonomy())) {\n            $entity->setTaxonomy($this->em->createCollection(Entity\\Taxonomy::class));\n        }\n\n        /** @var Collection\\Taxonomy $fieldTaxonomy */\n        $fieldTaxonomy = $this->em->createCollection(Entity\\Taxonomy::class);\n        foreach ($data as $tax) {\n            $tax[\'content_id\'] = $entity->getId();\n            $tax[\'contenttype\'] = (string) $entity->getContenttype();\n            $taxEntity = new Entity\\Taxonomy($tax);\n            $entity->getTaxonomy()\n                ->add($taxEntity);\n            $fieldTaxonomy->add($taxEntity);\n        }\n        $this->set($entity, $fieldTaxonomy);\n        $grouping = $this->getGroup($fieldTaxonomy);\n        if ($grouping) {\n            $entity->setGroup($this->getGroup($fieldTaxonomy));\n            $entity->setSortorder($this->getSortorder($fieldTaxonomy));\n        }\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function persist(QuerySet $queries, $entity)\n    {\n        $this->normalize($entity);\n        $field = $this->mapping[\'fieldname\'];\n        $taxonomy = $entity->getTaxonomy()\n            ->getField($field);\n\n        // Fetch existing taxonomies\n        $existingDB = $this->getExistingTaxonomies($entity) ?: [];\n        /** @var Collection\\Taxonomy $collection */\n        $collection = $this->em->getCollectionManager()\n            ->create(Entity\\Taxonomy::class);\n        $collection->setFromDatabaseValues($existingDB);\n        $toDelete = $collection->update($taxonomy);\n        $repo = $this->em->getRepository(Entity\\Taxonomy::class);\n\n        $queries->onResult(\n            function ($query, $result, $id) use ($repo, $collection, $toDelete) {\n                foreach ($collection as $entity) {\n                    $entity->content_id = $id;\n                    $repo->save($entity, true);\n                }\n\n                foreach ($toDelete as $entity) {\n                    $repo->delete($entity);\n                }\n            }\n        );\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function getName()\n    {\n        return \'taxonomy\';\n    }\n\n    /**\n     * Get platform specific group_concat token for provided column.\n     *\n     * @param string       $column\n     * @param string       $order\n     * @param string       $alias\n     * @param QueryBuilder $query\n     *\n     * @throws StorageException\n     *\n     * @return string\n     */\n    protected function getPlatformGroupConcat($column, $order, $alias, QueryBuilder $query)\n    {\n        $platform = $query->getConnection()\n            ->getDatabasePlatform()\n            ->getName();\n\n        switch ($platform) {\n            case \'mysql\':\n                return "GROUP_CONCAT($column ORDER BY $order ASC SEPARATOR \'|\') as $alias";\n            case \'sqlite\':\n                return "GROUP_CONCAT($column, \'|\') as $alias";\n            case \'postgresql\':\n                return "string_agg($column" . "::character varying, \'|\' ORDER BY $order) as $alias";\n        }\n\n        throw new StorageException(sprintf(\'Unsupported platform: %s\', $platform));\n    }\n\n    /**\n     * @param Collection\\Taxonomy $taxonomy\n     *\n     * @return array|null\n     */\n    protected function getGroup(Collection\\Taxonomy $taxonomy)\n    {\n        $group = null;\n        $taxData = $this->mapping[\'data\'];\n        foreach ($taxonomy as $tax) {\n            if ($taxData[\'behaves_like\'] === \'grouping\') {\n                // Previously we only cared about the last one… so yeah\n                $needle = $tax->getSlug();\n                $index = array_search($needle, array_keys($taxData[\'options\']));\n                $group = [\n                    \'slug\'  => $tax->getSlug(),\n                    \'name\'  => $tax->getName(),\n                    \'order\' => $tax->getSortorder(),\n                    \'index\' => $index ?: 2147483647, // Maximum for a 32-bit integer\n                ];\n            }\n        }\n\n        return $group;\n    }\n\n    /**\n     * @param Collection\\Taxonomy $taxonomy\n     *\n     * @return int\n     */\n    protected function getSortorder(Collection\\Taxonomy $taxonomy)\n    {\n        $taxData = $this->mapping[\'data\'];\n        $sortorder = 0;\n        foreach ($taxonomy as $tax) {\n            if ($taxData[\'has_sortorder\']) {\n                $sortorder = $tax->getSortorder();\n            }\n        }\n\n        return $sortorder;\n    }\n\n    /**\n     * Direct query to get existing taxonomy records.\n     *\n     * @param mixed $entity\n     *\n     * @return array\n     */\n    protected function getExistingTaxonomies($entity)\n    {\n        // Fetch existing taxonomies\n        $query = $this->em->createQueryBuilder()\n            ->select(\'*\')\n            ->from($this->mapping[\'target\'])\n            ->where(\'content_id = :content_id\')\n            ->andWhere(\'contenttype = :contenttype\')\n            ->andWhere(\'taxonomytype = :taxonomytype\')\n            ->setParameters([\n                \'content_id\'   => $entity->id,\n                \'contenttype\'  => $entity->getContenttype(),\n                \'taxonomytype\' => $this->mapping[\'fieldname\'],\n            ]);\n        $result = $query->execute()\n            ->fetchAll();\n\n        return $result ?: [];\n    }\n\n    /**\n     * The normalize method takes care of any pre-persist cleaning up.\n     *\n     * For taxonomies that allows us to support non standard data formats such\n     * as arrays that allow this style data setting to work...\n     *\n     *   `$entity->setCategories([\'news\', \'events\']);`\n     *\n     *    or\n     *\n     *   `$entity->setCategories(\'news\');`\n     *\n     * @param Entity\\Content $entity\n     */\n    public function normalize($entity)\n    {\n        /** @var Collection\\Taxonomy $collection */\n        $collection = $this->normalizeFromPost($entity, Entity\\Taxonomy::class);\n        if ($collection) {\n            $entity->setTaxonomy($collection);\n        }\n    }\n}\n', 'message': 'Another PHP 7.4 compat tweak', 'author_name': 'GitHub', 'lang': 'php'}