Skip to content

Пользовательский поисковый модуль

Луна используется для поиска Lucene. Поэтому при реализации модуля поиска нам надо будет реализовать метод добавления индекса, а так же метод формирования запроса

Для начала нам надо реализовать базовый интерфейс ru.slie.luna.issue.field.searcher.FieldSearcher

Бекэнд

java
package ru.slie.luna.issue.field.searcher.impl;

import org.apache.commons.lang3.StringUtils;
import org.apache.lucene.document.*;
import org.apache.lucene.queryparser.classic.ParseException;
import org.apache.lucene.search.Query;
import org.apache.lucene.util.BytesRef;
import org.jspecify.annotations.NonNull;
import ru.slie.luna.exception.ValidateException;
import ru.slie.luna.issue.Issue;
import ru.slie.luna.issue.field.IssueField;
import ru.slie.luna.issue.field.annotations.IssueFieldSearcherComponent;
import ru.slie.luna.issue.field.options.Option;
import ru.slie.luna.issue.field.options.OptionsManager;
import ru.slie.luna.issue.field.searcher.*;
import ru.slie.luna.issue.field.searcher.basic.OptionSearchParams;
import ru.slie.luna.issue.field.searcher.sorter.OptionFieldSorter;
import ru.slie.luna.issue.field.searcher.statistics.OptionStatisticsMapper;
import ru.slie.luna.issue.field.type.MultiSelectFieldType;
import ru.slie.luna.issue.field.type.SingleSelectFieldType;
import ru.slie.luna.issue.index.IndexQueryFactory;
import ru.slie.luna.issue.index.IndexingFactory;
import ru.slie.luna.issue.index.QueryNoSupportedOperatorException;
import ru.slie.luna.issue.query.QueryContext;
import ru.slie.luna.issue.query.clause.ConditionClause;
import ru.slie.luna.issue.query.operand.Operand;
import ru.slie.luna.issue.query.operand.OperandResolver;
import ru.slie.luna.issue.query.operand.OperandValueError;
import ru.slie.luna.issue.query.operand.QueryValue;
import ru.slie.luna.issue.query.operator.NotSupportedOperator;
import ru.slie.luna.issue.query.operator.Operator;
import ru.slie.luna.issue.query.value.ClauseValueType;
import ru.slie.luna.locale.I18nResolver;
import ru.slie.luna.utils.FormatUtils;
import ru.slie.luna.utils.WithName;

import java.util.*;

@Component
public class OptionsSearcher extends AbstractFieldSearcherBasic<OptionSearchParams> implements
                                     FieldSearcher, FieldSearcherSortable,
                                     FieldSearcherStattable, FieldSearcherBasic {
    private final OptionsManager optionsManager;
    private final I18nResolver i18n;
    private final OperandResolver operandResolver;
    private final IndexQueryFactory queryFactory;

    public OptionsSearcher(OptionsManager optionsManager,
                           I18nResolver i18n,
                           OperandResolver operandResolver,
                           IndexQueryFactory queryFactory) {
        super(OptionSearchParams.class);
        this.optionsManager = optionsManager;
        this.i18n = i18n;
        this.operandResolver = operandResolver;
        this.queryFactory = queryFactory;
    }

    @Override
    public String getName() {
        return i18n.getText("app.field_searcher.option_searcher.label");
    }

    @Override
    public String getDescription() {
        return null;
    }

    @Override
    public List<String> getSupportedFieldTypeKey() {
        return Arrays.asList(
                SingleSelectFieldType.class.getCanonicalName(),
                MultiSelectFieldType.class.getCanonicalName()
        );
    }

    /**
     * Определяем, какие оператор поддерживает наш поисковый модуль
    */
    @Override
    public List<Operator> getSupportedOperator() {
        return Arrays.asList(Operator.IN, Operator.NOT_IN, Operator.EQUALS, Operator.NOT_EQUALS, Operator.IS, Operator.IS_NOT);
    }

    /**
     * Валидируем параметры запроса
    */
    @Override
    public void validateOperand(QueryContext context, IssueField field, Operand operand) throws OperandValueError {
        List<String> values = operandResolver.getValues(context, field, operand).stream().map(QueryValue::getStringValue).toList();

        for (String name: values) {
            if (StringUtils.isNumeric(name)) {
                if (optionsManager.getOptionById(field, Long.parseLong(name)) == null) {
                    throw new OperandValueError(i18n.getText("app.field.option_id.not_found", name), field);
                }
            } else {
                List<Option> options = optionsManager.findOptionByName(field, name);
                if (options.isEmpty()) {
                    throw new OperandValueError(i18n.getText("app.field.option_name.not_exists", name), field);
                }
            }
        }
    }

    /**
     * Указать тип для автокомплита. Можете указать свой собственный тип и реализовать для него `ClauseValueGenerator`
    */
    @Override
    public ClauseValueType getClauseValueType() {
        return ClauseValueType.of(Option.class);
    }

    /**
     * Создаем индекс для поля. Так как у нас поисковый модуль может применяться как к полям с единственным выбором так и с множественным выбором, проверяем тип
    */
    @Override
    public void addDocumentIndex(Document doc, Issue issue, IssueField field) {
        Object value = issue.getFieldValue(field);
        if (value == null) {
            return;
        }
        switch (value) {
            case Collection<?> collection -> {
                for (Object item : collection) {
                    if (item instanceof Option option) {
                        addOptionValue(doc, option, field, true);
                    }
                }
            }
            case Option option -> addOptionValue(doc, option, field, false);
            default -> {}
        }
    }

    private List<Option> getValueFromClause(QueryContext context, ConditionClause clause) {
        List<String> values = operandResolver.getValues(context, clause.getField(), clause.getOperand()).stream().map(QueryValue::getStringValue).toList();
        List<Option> options = new ArrayList<>();
        for (String name: values) {
            if (StringUtils.isNumeric(name)) {
                Option opt = optionsManager.getOptionById(clause.getField(), Long.parseLong(name));
                if (opt != null) {
                    options.add(opt);
                }
            }

            options.addAll(optionsManager.findOptionByName(clause.getField(), name));
        }

        return options;
    }

    /**
     * Формируем запрос для lucene. Используйте IndexQueryFactory что бы упростить код
    */
    @Override
    public Query getIndexQuery(QueryContext context, ConditionClause clause) throws OperandValueError, NotSupportedOperator {
        List<Option> options = getValueFromClause(context, clause);
        List<String> optionIds = options.stream().map(Option::getId).map(String::valueOf).toList();

        try {
            return queryFactory.createQueryForArrayString(clause.getField().getId(), clause.getOperator(), optionIds);
        } catch (ParseException e) {
            throw new OperandValueError(e.getLocalizedMessage(), clause.getField());
        } catch (QueryNoSupportedOperatorException e) {
            throw new NotSupportedOperator(clause.getField(), e.getOperator());
        }
    }

    private void addOptionValue(Document doc, Option option, IssueField field, boolean multiple) {
        doc.add(new StringField(field.getId(), option.getId().toString(), Field.Store.YES));
        if (multiple) {
            doc.add(new SortedSetDocValuesField(field.getId(), new BytesRef(option.getId().toString())));
            doc.add(new SortedSetDocValuesField(IndexingFactory.getNameForSort(field.getId()), new BytesRef(option.getName())));
        } else {
            doc.add(new SortedDocValuesField(field.getId(), new BytesRef(option.getId().toString())));
            doc.add(new SortedDocValuesField(IndexingFactory.getNameForSort(field.getId()), new BytesRef(option.getName())));
        }
    }

    /**
     * FieldSearcherSortable: Если по вашему полю можно делать сортировку, добавьте класс реализующий LuceneFieldSorter.
    */
    @NonNull
    @Override
    public LuceneFieldSorter getSorter(@NonNull IssueField field) {
        return new OptionFieldSorter(field);
    }

    /**
     * FieldSearcherStattable: Для формирования отчетов и агрегации по полю создайте класс, реализующий StatisticsMapper
    */
    @NonNull
    @Override
    public StatisticsMapper<Option> getStatisticsMapper(@NonNull IssueField field) {
        return new OptionStatisticsMapper(optionsManager, field);
    }

    @Override
    protected FieldSearchRepresentation getRepresentation(QueryContext context, IssueField field, OptionSearchParams formParams) throws ValidateException {
        List<Option> labels = new ArrayList<>();

        if (formParams != null && formParams.getValues() != null) {
            labels.addAll(optionsManager.findOptionByNames(field, formParams.getValues()));
        }

        return getRepresentationFromValues(field, labels);
    }

    /**
     * FieldSearcherBasic: добавьте базовый поиск.
     * Можете использовать абстрактный класс AbstractFieldSearcherBasic как в этом примере,
     * что бы вручную не конвертировать данные с фронтенда
    */
    @Override
    public Optional<FieldSearchRepresentation> getRepresentationFromClause(QueryContext context, ConditionClause clause) {
        if (!clause.getOperator().is(Operator.IN, Operator.EQUALS)) {
            return Optional.empty();
        }

        List<Option> options = getValueFromClause(context, clause);
        return Optional.of(getRepresentationFromValues(clause.getField(), options));
    }

    private FieldSearchRepresentation getRepresentationFromValues(IssueField field, List<Option> values) {
        OptionSearchParams params = new OptionSearchParams();
        String fieldName = IssueField.getFieldNameForQueryString(field);

        if (values != null) {
            params.setValues(values.stream().map(Option::getName).toList());
        }

        if (values == null || values.isEmpty()) {
            return new FieldSearchRepresentation("", i18n.getText("app.field_searcher.all_values"), params);
        } else if (values.size() == 1) {
            return new FieldSearchRepresentation(String.format("%s = %s",
                    fieldName,
                    FormatUtils.quotedString(values.getFirst().getName())),
                    values.getFirst().getName(),
                    params);
        } else {
            return new FieldSearchRepresentation(String.format("%s in (%s)",
                    fieldName,
                    StringUtils.join(values.stream().map(s -> FormatUtils.quotedString(s.getName())).toList(), ", ")),
                    StringUtils.join(values.stream().map(WithName::getName).toList(), ", "),
                    params);
        }
    }
}

Дополнительные интерфесы

  • FieldSearcherBasic - возможность базового поиска. Необходимо будет еще добавить vue-компонент editComponent
  • FieldSearcherHistoriable - если ходите иметь исторический поиск по полю (was, changed)
  • FieldSearcherSortable - для поддержки сортировки (order by)
  • FieldSearcherStattable - для отчетов, графиков, статистико по полю
  • AbstractFieldSearcherBasic - если хотите реализовать FieldSearcherBasic с минимумом усилий
  • AbstractIdFieldSearcherBasic - еще меньше усилий, если ваше поле хранит id значений, а сами значения реализуют интерфейсы WithId и WithName

Фронтенд

Если вы реализовали интерфейс FieldSearcherBasic, так же необходимо создать компонент для базового поиска

vue
<script lang="ts" setup>
import { PropType } from "vue";
import MultiSelect from "luna";
import { FieldSearcherDescriptor } from "luna";

const props = defineProps({
  searcher: Object as PropType<FieldSearcherDescriptor>
});

const value = defineModel<{values: Array<string>}>({default: {values: []}});

const labelOptions = async (term: string, excludes: Array<string>) => {
  const { data } = await queryService.getSuggestions(props.searcher.name, term, excludes);
  const out = [];
  for (const item of data) {
    out.push({
      id: item.value,
      name: item.displayValue,
    });
  }

  return out;
}

</script>

<template>
  <div class="options-field-searcher">
    <MultiSelect :options="labelOptions" v-model="value.values"></MultiSelect>
  </div>
</template>

<style lang="scss">
  .options-field-searcher {
    padding: 10px;
  }
</style>

Информация

Не забудьте прописать этот компонент в vite.config.ts

Настроим дескриптор

yaml
fieldSearchers:
  - key: OptionsSearcher
    name: Options searcher
    className: ru.slie.luna.issue.field.searcher.impl.OptionsSearcher
    editComponent:
      path: frontend/OptionFieldSearcherEditComponent.js