src/Form/Control/ScopeBuilder.php

Summary

Maintainability
A
0 mins
Test Coverage
<?php

declare(strict_types=1);

namespace Atk4\Ui\Form\Control;

use Atk4\Data\Field;
use Atk4\Data\Model;
use Atk4\Data\Model\Scope;
use Atk4\Data\Model\Scope\Condition;
use Atk4\Ui\Exception;
use Atk4\Ui\Form;
use Atk4\Ui\HtmlTemplate;
use Atk4\Ui\View;

class ScopeBuilder extends Form\Control
{
    public $renderLabel = false;

    /** @var array<string, array<string, mixed>|bool> */
    public array $options = [
        'enum' => [
            'limit' => 250,
        ],
        'debug' => false, // displays query output live on the page if set to true
    ];
    /**
     * Max depth of nested conditions allowed.
     * Corresponds to VueQueryBulder maxDepth.
     * Maximum support by JS component is 10.
     */
    public int $maxDepth = 5;

    /** @var list<string> Fields to use for creating the rules. */
    public array $fields = [];

    /** @var HtmlTemplate|null The template needed for the ScopeBuilder view. */
    public $scopeBuilderTemplate;

    /** @var list<non-empty-string> List of delimiters for auto-detection in order of priority. */
    public static array $listDelimiters = [';', ','];

    /** @var array<string, mixed> The date, time or datetime options. */
    public array $atkdDateOptions = [
        'flatpickr' => [],
    ];

    /** @var array<string, mixed> AtkLookup and Fomantic-UI dropdown options. */
    public array $atkLookupOptions = [
        'ui' => 'small basic button',
    ];

    /** @var View The scopebuilder View. Assigned in init(). */
    protected $scopeBuilderView;

    /** @var list<array<string, mixed>> Definition of VueQueryBuilder rules. */
    protected array $rules = [];

    /**
     * Set Labels for Vue-Query-Builder
     * see https://dabernathy89.github.io/vue-query-builder/configuration.html#labels.
     *
     * @var array<mixed, mixed>
     */
    public array $labels = [];

    /** @var array<string, mixed> Default VueQueryBuilder query. */
    protected array $query = [];

    protected const OPERATOR_TEXT_EQUALS = 'equals';
    protected const OPERATOR_TEXT_DOESNOT_EQUAL = 'does not equal';
    protected const OPERATOR_TEXT_GREATER = 'is alphabetically after';
    protected const OPERATOR_TEXT_GREATER_EQUAL = 'is alphabetically equal or after';
    protected const OPERATOR_TEXT_LESS = 'is alphabetically before';
    protected const OPERATOR_TEXT_LESS_EQUAL = 'is alphabetically equal or before';
    protected const OPERATOR_TEXT_CONTAINS = 'contains';
    protected const OPERATOR_TEXT_DOESNOT_CONTAIN = 'does not contain';
    protected const OPERATOR_TEXT_BEGINS_WITH = 'begins with';
    protected const OPERATOR_TEXT_DOESNOT_BEGIN_WITH = 'does not begin with';
    protected const OPERATOR_TEXT_ENDS_WITH = 'ends with';
    protected const OPERATOR_TEXT_DOESNOT_END_WITH = 'does not end with';
    protected const OPERATOR_TEXT_MATCHES_REGEX = 'matches regular expression';
    protected const OPERATOR_TEXT_DOESNOT_MATCH_REGEX = 'does not match regular expression';
    protected const OPERATOR_SIGN_EQUALS = '=';
    protected const OPERATOR_SIGN_DOESNOT_EQUAL = '<>';
    protected const OPERATOR_SIGN_GREATER = '>';
    protected const OPERATOR_SIGN_GREATER_EQUAL = '>=';
    protected const OPERATOR_SIGN_LESS = '<';
    protected const OPERATOR_SIGN_LESS_EQUAL = '<=';
    protected const OPERATOR_TIME_EQUALS = 'is on';
    protected const OPERATOR_TIME_DOESNOT_EQUAL = 'is not on';
    protected const OPERATOR_TIME_GREATER = 'is after';
    protected const OPERATOR_TIME_GREATER_EQUAL = 'is on or after';
    protected const OPERATOR_TIME_LESS = 'is before';
    protected const OPERATOR_TIME_LESS_EQUAL = 'is on or before';
    protected const OPERATOR_EQUALS = 'equals';
    protected const OPERATOR_DOESNOT_EQUAL = 'does not equal';
    protected const OPERATOR_IN = 'is in';
    protected const OPERATOR_NOT_IN = 'is not in';
    protected const OPERATOR_EMPTY = 'is empty';
    protected const OPERATOR_NOT_EMPTY = 'is not empty';

    protected const DATE_OPERATORS = [
        self::OPERATOR_TIME_EQUALS,
        self::OPERATOR_TIME_DOESNOT_EQUAL,
        self::OPERATOR_TIME_GREATER,
        self::OPERATOR_TIME_GREATER_EQUAL,
        self::OPERATOR_TIME_LESS,
        self::OPERATOR_TIME_LESS_EQUAL,
        self::OPERATOR_EMPTY,
        self::OPERATOR_NOT_EMPTY,
    ];

    protected const ENUM_OPERATORS = [
        self::OPERATOR_EQUALS,
        self::OPERATOR_DOESNOT_EQUAL,
        self::OPERATOR_EMPTY,
        self::OPERATOR_NOT_EMPTY,
    ];

    protected const DATE_OPERATORS_MAP = [
        self::OPERATOR_TIME_EQUALS => Condition::OPERATOR_EQUALS,
        self::OPERATOR_TIME_DOESNOT_EQUAL => Condition::OPERATOR_DOESNOT_EQUAL,
        self::OPERATOR_TIME_GREATER => Condition::OPERATOR_GREATER,
        self::OPERATOR_TIME_GREATER_EQUAL => Condition::OPERATOR_GREATER_EQUAL,
        self::OPERATOR_TIME_LESS => Condition::OPERATOR_LESS,
        self::OPERATOR_TIME_LESS_EQUAL => Condition::OPERATOR_LESS_EQUAL,
    ];

    /**
     * VueQueryBulder => Condition map of operators.
     *
     * Operator map supports also inputType specific operators in sub maps
     *
     * @var array<string, array<string, string>>
     */
    protected static array $operatorsMap = [
        'number' => [
            self::OPERATOR_SIGN_EQUALS => Condition::OPERATOR_EQUALS,
            self::OPERATOR_SIGN_DOESNOT_EQUAL => Condition::OPERATOR_DOESNOT_EQUAL,
            self::OPERATOR_SIGN_GREATER => Condition::OPERATOR_GREATER,
            self::OPERATOR_SIGN_GREATER_EQUAL => Condition::OPERATOR_GREATER_EQUAL,
            self::OPERATOR_SIGN_LESS => Condition::OPERATOR_LESS,
            self::OPERATOR_SIGN_LESS_EQUAL => Condition::OPERATOR_LESS_EQUAL,
        ],
        'date' => self::DATE_OPERATORS_MAP,
        'time' => self::DATE_OPERATORS_MAP,
        'datetime' => self::DATE_OPERATORS_MAP,
        'text' => [
            self::OPERATOR_TEXT_EQUALS => Condition::OPERATOR_EQUALS,
            self::OPERATOR_TEXT_DOESNOT_EQUAL => Condition::OPERATOR_DOESNOT_EQUAL,
            self::OPERATOR_TEXT_GREATER => Condition::OPERATOR_GREATER,
            self::OPERATOR_TEXT_GREATER_EQUAL => Condition::OPERATOR_GREATER_EQUAL,
            self::OPERATOR_TEXT_LESS => Condition::OPERATOR_LESS,
            self::OPERATOR_TEXT_LESS_EQUAL => Condition::OPERATOR_LESS_EQUAL,
            self::OPERATOR_TEXT_CONTAINS => Condition::OPERATOR_LIKE,
            self::OPERATOR_TEXT_DOESNOT_CONTAIN => Condition::OPERATOR_NOT_LIKE,
            self::OPERATOR_TEXT_BEGINS_WITH => Condition::OPERATOR_LIKE,
            self::OPERATOR_TEXT_DOESNOT_BEGIN_WITH => Condition::OPERATOR_NOT_LIKE,
            self::OPERATOR_TEXT_ENDS_WITH => Condition::OPERATOR_LIKE,
            self::OPERATOR_TEXT_DOESNOT_END_WITH => Condition::OPERATOR_NOT_LIKE,
            self::OPERATOR_IN => Condition::OPERATOR_IN,
            self::OPERATOR_NOT_IN => Condition::OPERATOR_NOT_IN,
            self::OPERATOR_TEXT_MATCHES_REGEX => Condition::OPERATOR_REGEXP,
            self::OPERATOR_TEXT_DOESNOT_MATCH_REGEX => Condition::OPERATOR_NOT_REGEXP,
            self::OPERATOR_EMPTY => Condition::OPERATOR_EQUALS,
            self::OPERATOR_NOT_EMPTY => Condition::OPERATOR_DOESNOT_EQUAL,
        ],
        'select' => [
            self::OPERATOR_EQUALS => Condition::OPERATOR_EQUALS,
            self::OPERATOR_DOESNOT_EQUAL => Condition::OPERATOR_DOESNOT_EQUAL,
        ],
        'lookup' => [
            self::OPERATOR_EQUALS => Condition::OPERATOR_EQUALS,
            self::OPERATOR_DOESNOT_EQUAL => Condition::OPERATOR_DOESNOT_EQUAL,
        ],
    ];

    /** @var array<string, string|array<string, mixed>> Definition of rule types. */
    protected static array $ruleTypes = [
        'default' => 'text',
        'text' => [
            'type' => 'text',
            'operators' => [
                self::OPERATOR_TEXT_EQUALS,
                self::OPERATOR_TEXT_DOESNOT_EQUAL,
                self::OPERATOR_TEXT_GREATER,
                self::OPERATOR_TEXT_GREATER_EQUAL,
                self::OPERATOR_TEXT_LESS,
                self::OPERATOR_TEXT_LESS_EQUAL,
                self::OPERATOR_TEXT_CONTAINS,
                self::OPERATOR_TEXT_DOESNOT_CONTAIN,
                self::OPERATOR_TEXT_BEGINS_WITH,
                self::OPERATOR_TEXT_DOESNOT_BEGIN_WITH,
                self::OPERATOR_TEXT_ENDS_WITH,
                self::OPERATOR_TEXT_DOESNOT_END_WITH,
                self::OPERATOR_TEXT_MATCHES_REGEX,
                self::OPERATOR_TEXT_DOESNOT_MATCH_REGEX,
                self::OPERATOR_IN,
                self::OPERATOR_NOT_IN,
                self::OPERATOR_EMPTY,
                self::OPERATOR_NOT_EMPTY,
            ],
        ],
        'lookup' => [
            'type' => 'custom-component',
            'inputType' => 'lookup',
            'component' => 'AtkLookup',
            'operators' => self::ENUM_OPERATORS,
            'componentProps' => [__CLASS__, 'getLookupProps'],
        ],
        'enum' => [
            'type' => 'select',
            'inputType' => 'select',
            'operators' => self::ENUM_OPERATORS,
            'choices' => [__CLASS__, 'getChoices'],
        ],
        'numeric' => [
            'type' => 'numeric',
            'inputType' => 'number',
            'operators' => [
                self::OPERATOR_SIGN_EQUALS,
                self::OPERATOR_SIGN_DOESNOT_EQUAL,
                self::OPERATOR_SIGN_GREATER,
                self::OPERATOR_SIGN_GREATER_EQUAL,
                self::OPERATOR_SIGN_LESS,
                self::OPERATOR_SIGN_LESS_EQUAL,
                self::OPERATOR_EMPTY,
                self::OPERATOR_NOT_EMPTY,
            ],
        ],
        'boolean' => [
            'type' => 'radio',
            'operators' => [],
            'choices' => [
                ['label' => 'Yes', 'value' => '1'],
                ['label' => 'No', 'value' => '0'],
            ],
        ],
        'date' => [
            'type' => 'custom-component',
            'component' => 'AtkDatePicker',
            'inputType' => 'date',
            'operators' => self::DATE_OPERATORS,
            'componentProps' => [__CLASS__, 'getDatePickerProps'],
        ],
        'datetime' => [
            'type' => 'custom-component',
            'component' => 'AtkDatePicker',
            'inputType' => 'datetime',
            'operators' => self::DATE_OPERATORS,
            'componentProps' => [__CLASS__, 'getDatePickerProps'],
        ],
        'time' => [
            'type' => 'custom-component',
            'component' => 'AtkDatePicker',
            'inputType' => 'time',
            'operators' => self::DATE_OPERATORS,
            'componentProps' => [__CLASS__, 'getDatePickerProps'],
        ],
        'smallint' => 'numeric',
        'integer' => 'numeric',
        'bigint' => 'numeric',
        'float' => 'numeric',
        'atk4_money' => 'numeric',
        'checkbox' => 'boolean',
    ];

    #[\Override]
    protected function init(): void
    {
        parent::init();

        if (!$this->scopeBuilderTemplate) {
            $this->scopeBuilderTemplate = new HtmlTemplate('<div {$attributes}><atk-query-builder v-bind="initData"></atk-query-builder></div>');
        }

        $this->scopeBuilderView = View::addTo($this, ['template' => $this->scopeBuilderTemplate]);

        if ($this->form !== null) {
            $this->form->onHook(Form::HOOK_LOAD_POST, function (Form $form, array &$postRawData) {
                $key = $this->entityField->getFieldName();
                $postRawData[$key] = $this->queryToScope($this->getApp()->decodeJson($postRawData[$key]));
            });
        }
    }

    /**
     * Set the model to build scope for.
     */
    #[\Override]
    public function setModel(Model $model): void
    {
        parent::setModel($model);

        $this->buildQuery($model);
    }

    /**
     * Build query from model scope.
     */
    protected function buildQuery(Model $model): void
    {
        if (!$this->fields) {
            $this->fields = array_keys($model->getFields());
        }

        foreach ($this->fields as $fieldName) {
            $field = $model->getField($fieldName);

            $this->addFieldRule($field);

            $this->addReferenceRules($field);
        }

        // build a ruleId => inputType map
        // this is used when selecting proper operator for the inputType (see self::$operatorsMap)
        $inputsMap = [];
        foreach ($this->rules as $rule) {
            $inputsMap[$rule['id']] = $rule['inputType'] ?? null;
        }

        if ($this->entityField !== null && $this->entityField->get() !== null) {
            $scope = $this->entityField->get();
        } else {
            $scope = $model->scope();
        }

        $this->query = $this->scopeToQuery($scope, $inputsMap)['query'];
    }

    /**
     * Add the field rules to use in VueQueryBuilder.
     */
    protected function addFieldRule(Field $field): void
    {
        if ($field->enum !== null || $field->values !== null) {
            $type = 'enum';
        } elseif ($field->hasReference()) {
            $type = 'lookup';
        } else {
            $type = $field->type;
        }

        $rule = $this->getRule($type, array_merge([
            'id' => $field->shortName,
            'label' => $field->getCaption(),
            'options' => $this->options[$type] ?? [],
        ], $field->ui['scopebuilder'] ?? []), $field);

        $this->rules[] = $rule;
    }

    /**
     * Set property for AtkLookup component.
     *
     * @return array<string, mixed>
     */
    protected function getLookupProps(Field $field): array
    {
        // set any of SuiDropdown props via this property
        // will be applied globally
        $props = $this->atkLookupOptions;
        $items = $this->getFieldItems($field, 10);
        foreach ($items as $value => $text) {
            $props['options'][] = ['key' => $value, 'text' => $text, 'value' => $value];
        }

        if ($field->hasReference()) {
            $props['reference'] = $field->shortName;
            $props['search'] = true;
        }

        $props['placeholder'] ??= 'Select ' . $field->getCaption();

        return $props;
    }

    /**
     * Set property for AtkDatePicker component.
     *
     * @return array<string, mixed>
     */
    protected function getDatePickerProps(Field $field): array
    {
        $props = $this->atkdDateOptions['flatpickr'] ?? [];
        $props['allowInput'] ??= true;

        $calendar = new Calendar();
        $phpFormat = $this->getApp()->uiPersistence->{$field->type . 'Format'};
        $props['dateFormat'] = $calendar->convertPhpDtFormatToFlatpickr($phpFormat, true);
        if ($field->type === 'datetime' || $field->type === 'time') {
            $props['noCalendar'] = $field->type === 'time';
            $props['enableTime'] = true;
            $props['time_24hr'] = $calendar->isDtFormatWith24hrTime($phpFormat);
            $props['enableSeconds'] ??= $calendar->isDtFormatWithSeconds($phpFormat);
            $props['formatSecondsPrecision'] ??= $calendar->isDtFormatWithMicroseconds($phpFormat) ? 6 : -1;
            $props['disableMobile'] = true;
        }

        return $props;
    }

    /**
     * Add rules on the referenced model fields.
     */
    protected function addReferenceRules(Field $field): void
    {
        if ($field->hasReference()) {
            $reference = $field->getReference();

            // add the number of records rule
            $this->rules[] = $this->getRule('numeric', [
                'id' => $reference->link . '/#',
                'label' => $field->getCaption() . ' number of records ',
            ]);

            $theirModel = $reference->createTheirModel();

            // add rules on all fields of the referenced model
            foreach ($theirModel->getFields() as $theirField) {
                $theirField->ui['scopebuilder'] = [
                    'id' => $reference->link . '/' . $theirField->shortName,
                    'label' => $field->getCaption() . ' is set to record where ' . $theirField->getCaption(),
                ];

                $this->addFieldRule($theirField);
            }
        }
    }

    /**
     * @param array<string, mixed> $defaults
     *
     * @return array<string, mixed>
     */
    protected function getRule(string $type, array $defaults = [], ?Field $field = null): array
    {
        $rule = static::$ruleTypes[$type] ?? static::$ruleTypes['default'];

        // when $rule is an alias
        if (is_string($rule)) {
            return $this->getRule($rule, $defaults, $field);
        }

        $options = $defaults['options'] ?? [];
        unset($defaults['options']);

        // map all callables
        foreach ($rule as $k => $v) {
            if (is_array($v) && is_callable($v)) {
                $rule[$k] = call_user_func($v, $field, $options);
            }
        }

        $rule = array_merge($rule, $defaults);

        return $rule;
    }

    /**
     * Return an array of items ID and name for a field.
     * Return field enum, values or reference values.
     *
     * @return array<mixed, mixed>
     */
    protected function getFieldItems(Field $field, ?int $limit = 250): array
    {
        $items = [];
        if ($field->enum !== null) {
            $items = array_slice($field->enum, 0, $limit);
            $items = array_combine($items, $items);
        }
        if ($field->values !== null) {
            $items = array_slice($field->values, 0, $limit, true);
        } elseif ($field->hasReference()) {
            $model = $field->getReference()->createTheirModel();
            $model->setLimit($limit);

            foreach ($model as $item) {
                $items[$item->get($field->getReference()->getTheirFieldName($model))] = $item->get($model->titleField);
            }
        }

        return $items;
    }

    /**
     * Returns the choices array for Select field rule.
     *
     * @param array<string, mixed> $options
     *
     * @return list<array{label: mixed, value: mixed}>
     */
    protected function getChoices(Field $field, array $options = []): array
    {
        $choices = $this->getFieldItems($field, $options['limit'] ?? 250);

        $ret = [
            ['label' => '[empty]', 'value' => null],
        ];
        foreach ($choices as $value => $label) {
            $ret[] = ['label' => $label, 'value' => $value];
        }

        return $ret;
    }

    #[\Override]
    protected function renderView(): void
    {
        parent::renderView();

        $this->scopeBuilderView->vue('atk-query-builder', [
            'data' => [
                'rules' => $this->rules,
                'maxDepth' => $this->maxDepth,
                'query' => $this->query,
                'name' => $this->shortName,
                'labels' => $this->labels !== [] ? $this->labels : null, // TODO do we need to really pass null for empty array?
                'form' => $this->form->formElement->name,
                'debug' => $this->options['debug'] ?? false,
            ],
        ]);
    }

    /**
     * Converts an VueQueryBuilder query array to Condition or Scope.
     *
     * @param array<string, mixed> $query
     */
    public function queryToScope(array $query): Scope\AbstractScope
    {
        if (!isset($query['type'])) {
            $query = ['type' => 'query-builder-group', 'query' => $query];
        }

        switch ($query['type']) {
            case 'query-builder-rule':
                $scope = $this->queryToCondition($query['query']);

                break;
            case 'query-builder-group':
                $components = array_map(fn ($v) => $this->queryToScope($v), $query['query']['children']);
                $scope = new Scope($components, $query['query']['logicalOperator']);

                break;
        }

        return $scope; // @phpstan-ignore variable.undefined
    }

    /**
     * Converts an VueQueryBuilder rule array to Condition or Scope.
     *
     * @param array<string, mixed> $query
     */
    public function queryToCondition(array $query): Condition
    {
        $key = $query['rule'];
        $operator = $query['operator'];
        $value = $query['value'];

        switch ($operator) {
            case self::OPERATOR_EMPTY:
            case self::OPERATOR_NOT_EMPTY:
                $value = null;

                break;
            case self::OPERATOR_TEXT_BEGINS_WITH:
            case self::OPERATOR_TEXT_DOESNOT_BEGIN_WITH:
                $value .= '%';

                break;
            case self::OPERATOR_TEXT_ENDS_WITH:
            case self::OPERATOR_TEXT_DOESNOT_END_WITH:
                $value = '%' . $value;

                break;
            case self::OPERATOR_TEXT_CONTAINS:
            case self::OPERATOR_TEXT_DOESNOT_CONTAIN:
                $value = '%' . $value . '%';

                break;
            case self::OPERATOR_IN:
            case self::OPERATOR_NOT_IN:
                $value = explode($this->detectDelimiter($value), $value);

                break;
            default:
                $value = $this->getApp()->uiPersistence->typecastLoadField($this->model->getField($key), $value);

                break;
        }

        $operatorsMap = array_merge(...array_values(static::$operatorsMap));

        $operator = $operator ? ($operatorsMap[strtolower($operator)] ?? '=') : null;

        return new Condition($key, $operator, $value);
    }

    /**
     * Converts Scope or Condition to VueQueryBuilder query array.
     *
     * @param array<string, string|null> $inputsMap
     *
     * @return array{type: string, query: array<string, mixed>}
     */
    public function scopeToQuery(Scope\AbstractScope $scope, array $inputsMap = []): array
    {
        $query = [];
        if ($scope instanceof Condition) {
            $query = [
                'type' => 'query-builder-rule',
                'query' => $this->conditionToQuery($scope, $inputsMap),
            ];
        }

        if ($scope instanceof Scope) {
            $children = [];
            foreach ($scope->getNestedConditions() as $nestedCondition) {
                $children[] = $this->scopeToQuery($nestedCondition, $inputsMap);
            }

            $query = [
                'type' => 'query-builder-group',
                'query' => [
                    'logicalOperator' => $scope->getJunction(),
                    'children' => $children,
                ],
            ];
        }

        return $query;
    }

    /**
     * Converts a Condition to VueQueryBuilder query array.
     *
     * @param array<string, string|null> $inputsMap
     *
     * @return array{rule: string, operator: string, value: string|null, option: array<string, mixed>|null}
     */
    public function conditionToQuery(Condition $condition, array $inputsMap = []): array
    {
        if (is_string($condition->field)) {
            $rule = $condition->field;
        } elseif ($condition->field instanceof Field) {
            $rule = $condition->field->shortName;
        } else {
            throw (new Exception('Unsupported scope field type'))
                ->addMoreInfo('field', $condition->field);
        }

        $operator = $condition->operator;
        $value = $condition->value;

        $inputType = $inputsMap[$rule] ?? 'text';

        if (in_array($operator, [Condition::OPERATOR_LIKE, Condition::OPERATOR_NOT_LIKE], true)) {
            // no %
            $match = 0;
            // % at the beginning
            $match += substr($value, 0, 1) === '%' ? 1 : 0;
            // % at the end
            $match += substr($value, -1) === '%' ? 2 : 0;

            $map = [
                Condition::OPERATOR_LIKE => [
                    self::OPERATOR_TEXT_EQUALS,
                    self::OPERATOR_TEXT_BEGINS_WITH,
                    self::OPERATOR_TEXT_ENDS_WITH,
                    self::OPERATOR_TEXT_CONTAINS,
                ],
                Condition::OPERATOR_NOT_LIKE => [
                    self::OPERATOR_TEXT_DOESNOT_EQUAL,
                    self::OPERATOR_TEXT_DOESNOT_BEGIN_WITH,
                    self::OPERATOR_TEXT_DOESNOT_END_WITH,
                    self::OPERATOR_TEXT_DOESNOT_CONTAIN,
                ],
            ];

            $operator = $map[strtoupper($operator)][$match];

            $value = trim($value, '%');
        } else {
            if (is_array($value)) {
                $map = [
                    Condition::OPERATOR_EQUALS => Condition::OPERATOR_IN,
                    Condition::OPERATOR_DOESNOT_EQUAL => Condition::OPERATOR_NOT_IN,
                ];
                $value = implode(', ', $value);
                $operator = $map[$operator];
            }

            $operatorsMap = array_merge(static::$operatorsMap[$inputType] ?? [], static::$operatorsMap['text']);
            $operatorKey = array_search(strtoupper($operator), $operatorsMap, true);
            $operator = $operatorKey;
        }

        return [
            'rule' => $rule,
            'operator' => $operator,
            'value' => $this->getApp()->uiPersistence->typecastSaveField($this->model->getField($rule), $value),
            'option' => $this->getConditionOption($inputType, $value, $condition),
        ];
    }

    /**
     * Return extra value option associate with certain inputType or null otherwise.
     *
     * @param mixed $value
     *
     * @return array<string, mixed>
     */
    protected function getConditionOption(string $type, $value, Condition $condition): ?array
    {
        $option = null;
        switch ($type) {
            case 'lookup':
                $condField = $condition->getModel()->getField($condition->field);
                $reference = $condField->getReference();
                $model = $reference->createTheirModel();
                $fieldName = $reference->getTheirFieldName($model);
                $entity = $model->tryLoadBy($fieldName, $value);
                if ($entity !== null) {
                    $option = [
                        'key' => $value,
                        'text' => $entity->get($model->titleField),
                        'value' => $value,
                    ];
                }

                break;
        }

        return $option;
    }

    /**
     * Auto-detects a string delimiter based on list of predefined values in ScopeBuilder::$listDelimiters in order of priority.
     *
     * @return non-empty-string
     */
    public function detectDelimiter(string $value): string
    {
        $matches = [];
        foreach (static::$listDelimiters as $delimiter) {
            $matches[$delimiter] = substr_count($value, $delimiter);
        }

        $max = array_keys($matches, max($matches), true);

        return $max !== [] ? reset($max) : reset(static::$listDelimiters);
    }
}