Smile-SA/elasticsuite

View on GitHub
src/module-elasticsuite-catalog/Helper/AbstractAttribute.php

Summary

Maintainability
C
1 day
Test Coverage
<?php
/**
 * DISCLAIMER
 *
 * Do not edit or add to this file if you wish to upgrade Smile ElasticSuite to newer
 * versions in the future.
 *
 * @category  Smile
 * @package   Smile\ElasticsuiteCatalog
 * @author    Aurelien FOUCRET <aurelien.foucret@smile.fr>
 * @copyright 2020 Smile
 * @license   Open Software License ("OSL") v. 3.0
 */

namespace Smile\ElasticsuiteCatalog\Helper;

use Smile\ElasticsuiteCore\Helper\Mapping;
use Magento\Framework\App\Helper\Context;
use Magento\Catalog\Model\ResourceModel\Eav\AttributeFactory;
use Magento\Eav\Model\Entity\Attribute\AttributeInterface;
use Smile\ElasticsuiteCore\Api\Index\Mapping\FieldInterface;

/**
 * Abstract ElasticSuite catalog attributes helper.
 *
 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
 *
 * @category Smile
 * @package  Smile\ElasticsuiteCatalog
 * @author   Aurelien FOUCRET <aurelien.foucret@smile.fr>
 */
abstract class AbstractAttribute extends Mapping
{
    /**
     * @var \Magento\Catalog\Model\ResourceModel\Eav\AttributeFactory
     */
    private $attributeFactory;

    /**
     * @var \Magento\Catalog\Model\ResourceModel\Product\Attribute\CollectionFactory
     */
    private $attributeCollectionFactory;

    /**
     * @var array
     */
    private $storeAttributes = [];

    /**
     * @var array
     */
    private $attributes = [];

    /**
     * @var array
     */
    private $attributesCode = [];

    /**
     * @var array
     */
    private $attributeOptionTextCache = [];

    /**
     * @var array
     */
    private $attributeFieldTypeCache = [];

    /**
     * @var array
     */
    private $attributeUsesSourceCache = [];

    /**
     * @var array
     */
    private $attributeBackendCache = [];

    /**
     * @var array
     */
    private $attributeFrontendCache = [];

    /**
     * @var array
     */
    private $attributeMappers = [];

    /**
     * @var array
     */
    private $attributeCleaners = [];

    /**
     * @param Context          $context           Helper context.
     * @param AttributeFactory $attributeFactory  Factory used to create attributes.
     * @param mixed            $collectionFactory Attribute collection factory.
     */
    public function __construct(Context $context, AttributeFactory $attributeFactory, $collectionFactory)
    {
        parent::__construct($context);
        $this->attributeFactory = $attributeFactory;
        $this->attributeCollectionFactory = $collectionFactory;
    }

    /**
     * Retrieve a new product attribute collection.
     *
     * @return \Magento\Catalog\Model\ResourceModel\Product\Attribute\Collection
     */
    public function getAttributeCollection()
    {
        return $this->attributeCollectionFactory->create();
    }

    /**
     * Parse attribute to get mapping field creation parameters.
     *
     * @param AttributeInterface $attribute Product attribute.
     *
     * @return array
     */
    public function getMappingFieldOptions(AttributeInterface $attribute)
    {
        $options = [
            'is_searchable'       => $attribute->getIsSearchable(),
            'is_filterable'       => $attribute->getIsFilterable() || $attribute->getIsFilterableInSearch(),
            'search_weight'       => $attribute->getSearchWeight(),
            'is_used_for_sort_by' => $attribute->getUsedForSortBy(),
            'filter_logical_operator'   => $attribute->getFacetBooleanLogic(),
        ];

        if ($attribute->getIsUsedInSpellcheck()) {
            $options['is_used_in_spellcheck'] = true;
        }

        if ($attribute->getIsDisplayedInAutocomplete()) {
            $options['is_filterable'] = true;
        }

        if ($attribute->getUsedForSortBy()) {
            $options['sort_order_asc_missing']  = $attribute->getSortOrderAscMissing();
            $options['sort_order_desc_missing'] = $attribute->getSortOrderDescMissing();
        }

        if ($attribute->getIsSpannable()) {
            $options['is_spannable'] = $attribute->getIsSpannable();
        }

        if ($attribute->getNormsDisabled()) {
            $options['norms_disabled'] = $attribute->getNormsDisabled();
        }

        if ($attribute->getDefaultAnalyzer()) {
            $options['default_search_analyzer'] = $attribute->getDefaultAnalyzer();
        }

        return $options;
    }

    /**
     * Get mapping field type for an attribute.
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
     *
     * @param int $attributeId Product attribute id.
     *
     * @return string
     */
    public function getFieldType($attributeId)
    {
        // Backward compatibility.
        if (!is_numeric($attributeId)) {
            $attributeId = $this->getAttributeId($attributeId);
        }

        if (!isset($this->attributeFieldTypeCache[$attributeId])) {
            $attribute = $this->getAttributeById($attributeId);
            $type = FieldInterface::FIELD_TYPE_TEXT;

            if ($attribute->getSourceModel() == 'Magento\Eav\Model\Entity\Attribute\Source\Boolean') {
                $type = FieldInterface::FIELD_TYPE_BOOLEAN;
            } elseif ($this->getBackendType($attributeId) == 'int') {
                $type = FieldInterface::FIELD_TYPE_INTEGER;
            } elseif ($attribute->getFrontendClass() == 'validate-digits') {
                $type = FieldInterface::FIELD_TYPE_LONG;
            } elseif ($this->getBackendType($attributeId) == 'decimal' || $attribute->getFrontendClass() == 'validate-number') {
                $type = FieldInterface::FIELD_TYPE_DOUBLE;
            } elseif ($this->getBackendType($attributeId) == 'datetime') {
                $type = FieldInterface::FIELD_TYPE_DATE;
            } elseif ($this->usesSource($attributeId)) {
                $type = $attribute->getSourceModel() ? FieldInterface::FIELD_TYPE_KEYWORD : FieldInterface::FIELD_TYPE_INTEGER;
            }

            $this->attributeFieldTypeCache[$attributeId] = $type;
        }

        return $this->attributeFieldTypeCache[$attributeId];
    }

    /**
     * Parse attribute raw value (as saved in the database) to prepare the indexed value.
     * For attribute using options the option value is also added to the result which contains two keys :
     *   - one is "attribute_code" and contained the option id(s)
     *   - the other one is "option_text_attribute_code" and contained option value(s)
     * All value are transformed into arays to have a more simple management of
     * multivalued attributes merging on composite products).
     * ES doesn't care of having array of int when it an int is required.
     *
     * @SuppressWarnings(PHPMD.ElseExpression)
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
     *
     * @param int     $attributeId Product attribute Id.
     * @param integer $storeId     Store id.
     * @param mixed   $value       Raw value to be parsed.
     *
     * @return array
     */
    public function prepareIndexValue($attributeId, $storeId, $value)
    {
        // Backward compatibility.
        if (!is_numeric($attributeId)) {
            $attributeId = $this->getAttributeId($attributeId);
        }

        $attributeCode = $this->getAttributeCodeById($attributeId);
        $values = [];

        $mapperKey = 'simple_' . $attributeId;
        if (!isset($this->attributeMappers[$mapperKey])) {
            $this->attributeMappers[$mapperKey] = function ($value) use ($attributeId) {
                return $this->prepareSimpleIndexAttributeValue($attributeId, $value);
            };
        }

        if (!isset($this->attributeCleaners[$mapperKey])) {
            if ($this->includesZeroFalseValues($attributeId)) {
                // Filter empty values while keeping "0" (int or float) and "false" value.
                $this->attributeCleaners[$mapperKey] = function ($value) {
                    return (($value === false) || strlen($value));
                };
            } else {
                // Filter out empty values. Also removes "0" and "false" values.
                $this->attributeCleaners[$mapperKey] = function ($value) {
                    return !empty($value);
                };
            }
        }

        if ($this->usesSource($attributeId) && !is_array($value)) {
            $value = explode(',', $value);
        }

        if (!is_array($value)) {
            $value = [$value];
        }

        $value = array_map($this->attributeMappers[$mapperKey], $value);
        $value = array_filter($value, $this->attributeCleaners[$mapperKey]);
        $value = array_values($value);
        $values[$attributeCode] = $value;

        if ($this->usesSource($attributeId)) {
            $optionTextFieldName = $this->getOptionTextFieldName($attributeCode);
            $optionTextValues    = $this->getIndexOptionsText($attributeId, $storeId, $value);
            // Filter empty values. Not using array_filter here because it could remove "0" string from values.
            $optionTextValues    = array_diff(array_map('trim', array_map('strval', $optionTextValues)), ['', null, false]);
            $optionTextValues    = array_values($optionTextValues);
            $values[$optionTextFieldName] = $optionTextValues;
        }

        return array_filter($values);
    }

    /**
     * Transform an array of options ids into an arrays of option values for attribute that uses a source.
     * Values are localized for a store id.
     *
     * @param int     $attributeId Product attribute Id.
     * @param integer $storeId     Store id
     * @param array   $optionIds   Array of options ids.
     *
     * @return array
     */
    public function getIndexOptionsText($attributeId, $storeId, array $optionIds)
    {
        $mapperKey = sprintf("options_%s_%s", $attributeId, $storeId);

        if (!isset($this->attributeMappers[$mapperKey])) {
            $this->attributeMappers[$mapperKey] = function ($optionId) use ($attributeId, $storeId) {
                return $this->getIndexOptionText($attributeId, $storeId, $optionId);
            };
        }

        $optionValues = array_map($this->attributeMappers[$mapperKey], $optionIds);

        return $optionValues;
    }

    /**
     * Transform an options id into an array of option value for attribute that uses a source.
     * Value islocalized for a store id.
     *
     * @param int            $attributeId Product attribute.
     * @param integer        $storeId     Store id.
     * @param string|integer $optionId    Option id.
     *
     * @return string|boolean
     */
    public function getIndexOptionText($attributeId, $storeId, $optionId)
    {
        $attribute = $this->getAttributeByStore($attributeId, $storeId);

        if (!isset($this->attributeOptionTextCache[$storeId]) || !isset($this->attributeOptionTextCache[$storeId][$attributeId])) {
            $this->attributeOptionTextCache[$storeId][$attributeId] = [];
        }

        if (!isset($this->attributeOptionTextCache[$storeId][$attributeId][$optionId])) {
            $optionValue = $attribute->getSource()->getIndexOptionText($optionId);
            if ($this->getFieldType($attributeId) == FieldInterface::FIELD_TYPE_BOOLEAN) {
                $optionValue = null;
                if ($optionId == \Magento\Eav\Model\Entity\Attribute\Source\Boolean::VALUE_YES) {
                    $optionValue = $attribute->getStoreLabel($storeId);
                }
            }
            $this->attributeOptionTextCache[$storeId][$attributeId][$optionId] = $optionValue;
        }

        return $this->attributeOptionTextCache[$storeId][$attributeId][$optionId];
    }

    /**
     * Returns field use for filtering for an attribute.
     *
     * @param AttributeInterface $attribute Attribute.
     *
     * @return string
     */
    public function getFilterField(AttributeInterface $attribute)
    {
        $field = $attribute->getAttributeCode();

        // Do not use self::usesSource($attributeId) here.
        // This method is called in layered navigation with an already loaded attribute.
        // Going through $this->usesSource() would cause a reload of this unique attribute.
        // In layered navigation context, all filterable attributes goes through this, it would cause a huge overload.
        // Let's stick with the legacy usesSource() of attribute object.
        if ($attribute->usesSource()) {
            $field = $this->getOptionTextFieldName($field);
        }

        return $field;
    }

    /**
     * Ensure types of numerical values is correct before indexing.
     *
     * @param int   $attributeId Product attribute Id.
     * @param mixed $value       Raw value.
     *
     * @return mixed
     */
    private function prepareSimpleIndexAttributeValue($attributeId, $value)
    {
        if ($this->getFieldType($attributeId) == FieldInterface::FIELD_TYPE_BOOLEAN) {
            $value = boolval($value);
        } elseif ($this->getBackendType($attributeId) == 'decimal'
            || $this->getFrontendClass($attributeId) == 'validate-number') {
            $value = floatval($value);
        } elseif ($this->getBackendType($attributeId) == 'int') {
            $value = intval($value);
        }

        return $value;
    }

    /**
     * Load the localized version of an attribute.
     * This code uses a local cache to ensure correct performance during indexing.
     *
     * @param AttributeInterface|int $attribute Product attribute.
     * @param integer                $storeId   Store id.
     *
     * @return \Magento\Catalog\Api\Data\EavAttributeInterface
     */
    private function getAttributeByStore($attribute, $storeId)
    {
        $attributeId = $this->getAttributeId($attribute);

        if (!isset($this->storeAttributes[$storeId]) || !isset($this->storeAttributes[$storeId][$attributeId])) {
            /**
             * @var EavAttributeInterface
             */
            $storeAttribute = $this->attributeFactory->create();
            $storeAttribute->load($attributeId)->setStoreId($storeId);
            $this->storeAttributes[$storeId][$attributeId] = $storeAttribute;
        }

        return $this->storeAttributes[$storeId][$attributeId];
    }

    /**
     * Load an attribute by id.
     * This code uses a local cache to ensure correct performance during indexing.
     *
     * @param int $attributeId Product attribute id.
     *
     * @return \Magento\Catalog\Api\Data\EavAttributeInterface
     */
    private function getAttributeById($attributeId)
    {
        if (!isset($this->attributes[$attributeId])) {
            /**
             * @var EavAttributeInterface
             */
            $attribute = $this->attributeFactory->create();
            $attribute->load($attributeId);
            $this->attributes[$attributeId] = $attribute;
        }

        return $this->attributes[$attributeId];
    }

    /**
     * Load an attribute by id.
     * This code uses a local cache to ensure correct performance during indexing.
     *
     * @param int $attributeId Product attribute id.
     *
     * @return string
     */
    private function getAttributeCodeById($attributeId)
    {
        if (!isset($this->attributesCode[$attributeId])) {
            /**
             * @var EavAttributeInterface
             */
            $attribute = $this->getAttributeById($attributeId);
            $this->attributesCode[$attributeId] = $attribute->getAttributeCode();
        }

        return $this->attributesCode[$attributeId];
    }

    /**
     * This util method is used to ensure the attribute is an integer and uses it's id if it is an object.
     *
     * @param AttributeInterface|integer $attribute Product attribute.
     *
     * @return integer
     */
    private function getAttributeId($attribute)
    {
        $attributeId = $attribute;

        if (is_object($attribute)) {
            $attributeId = $attribute->getAttributeId();
        }

        return $attributeId;
    }

    /**
     * Compute result of $attribute->usesSource() into a local cache.
     * Mandatory because a lot of costly plugins (like in Swatches module) are plugged on this method.
     *
     * @param int $attributeId Attribute ID
     *
     * @return bool
     */
    private function usesSource($attributeId)
    {
        if (!isset($this->attributeUsesSourceCache[$attributeId])) {
            $attribute = $this->getAttributeById($attributeId);
            $this->attributeUsesSourceCache[$attributeId] = $attribute->usesSource();
        }

        return $this->attributeUsesSourceCache[$attributeId];
    }

    /**
     * Compute result of $attribute->getBackendType() into a local cache.
     * Mandatory because a lot of costly plugins (like in Swatches module) are plugged on this method.
     *
     * @param int $attributeId Attribute ID
     *
     * @return string|null
     */
    private function getBackendType($attributeId)
    {
        if (!isset($this->attributeBackendCache[$attributeId])) {
            $attribute = $this->getAttributeById($attributeId);
            $this->attributeBackendCache[$attributeId] = $attribute->getBackendType();
        }

        return $this->attributeBackendCache[$attributeId];
    }

    /**
     * Compute result of $attribute->getFrontendClass() into a local cache.
     *
     * @param int $attributeId Attribute ID
     *
     * @return string|null
     */
    private function getFrontendClass($attributeId)
    {
        if (!isset($this->attributeFrontendCache[$attributeId])) {
            $attribute = $this->getAttributeById($attributeId);
            $this->attributeFrontendCache[$attributeId] = $attribute->getFrontendClass();
        }

        return $this->attributeFrontendCache[$attributeId];
    }

    /**
     * Returns true if attribute allows indexing zero/false values.
     * Not using a local cache since it is only called once per attribute.
     *
     * @param int $attributeId Attribute ID
     *
     * @return bool
     */
    private function includesZeroFalseValues($attributeId)
    {
        return (bool) $this->getAttributeById($attributeId)->getIncludeZeroFalseValues();
    }
}