Пользовательский поисковый модуль
Луна используется для поиска 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-компонентeditComponentFieldSearcherHistoriable- если ходите иметь исторический поиск по полю (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