Фильтрация данных через SQL-подобный запрос
опубликовано: ⏰ 16Какое-то время назад была статья о том, как реализовать интерпретатор формул в Интерпретатор формул (DSL). При реализации tekBlueprint.frame появилась потребность сделать для REST API универсальный язык запросов для получения табличных данных для UI. Собственно, об этом и пойдёт речь.
Вводная
Как было выше сказано, появилась необходимость запрашивать данные от серверного слоя, используя некий универсальный формат описания запросов. Конечно же, можно сказать, что всё уже придумано до нас, и есть такие вещи, как GraphQL, OData, которые вполне решают эту задачу. В общем случае, их можно и нужно использовать, но были некоторые причины сделать свой вариант запросов.
Задача
Сначала поясню причины для реализации своего решения:
- Не хотелось добавлять в проект дополнительные библиотеки, от которых потребуется только небольшой процент функционала. Необходима только фильтрация данных.
- Не требуется определять список полей для выборки – задаются явно и запрещено всё, что не входит в список.
- Не требуются механизмы изменения данных (например, GraphQL Mutations) – всё делается по принципам RESTful API.
- Нужен полный контроль над синтаксисом с возможностью расширения.
Если отбросить готовые решения, то получим несколько вариантов решения задачи:
- Самый старый и известный со времён древних технологий – заранее предусматриваем всё в параметрах контроллёра и строим запрос по указанным с UI параметрам.
- Формируем JSON с фильтром на стороне UI и передаём в запросе – более гибкий вариант, но с определёнными ограничениями.
- Используем SQL-like запрос, парсим и приводим к SQL для СУБД – обычно сразу отпугивает, так как нужно писать парсер. Можно взять что-то готовое. Например, Apache Calcite, но выглядит как сильное переусложнение для решения подобной задачи.
| Через параметры запроса | Фильтр в виде JSON | Фильтр в виде текстового запроса | |
|---|---|---|---|
| Плюсы | Просто и понятно для реализации | Большая гибкость | Компактный и человекочитаемый вид |
| Самая высокая скорость разбора запроса | Простота генерации в JavaScript -> JSON | Возможность писать запросы с UI вручную | |
| Высокая скорость разбора запроса | |||
| Минусы | Довольно громоздкие запросы | Не менее громоздкие запросы | Нужно реализовать свой парсер |
| Необходимо всё варианты предусматривать заранее | Нельзя передать в теле GET запроса, только в параметрах или использовать POST | Более медленная скорость разбора запроса по сравнению с остальными | |
| Можно, но сложно сделать обобщённое решение | Только машинная генерация запроса | ||
| Только машинная генерация запроса |
В итоге остановился на вариантах 2 и 3. Второй вариант по большому счёту используется как базовая модель для запроса (
далее класс Query), в который приводится третий вариант с запросом.
Фильтр в виде JSON
Для понимания приведу пример о чём речь. Ниже пример запроса пользователей, формируемого в коде (Java).
var query = Query.builder().page(10).size(20)
.filter(or(
isNotNull("_name"),
and(eq("_name", "maria"), ilike("_name", "ari"), eq("type", "local")),
and(eq("_name", "smith"), eq("type", "oidc"))))
.search("any text")
.build();Который сериализуется в следующий вид:
{
"filter": {
"or": [
{ "field": "_name", "op": "ISNN" },
{
"and": [
{ "field": "_name", "op": "EQ", "value": "maria" },
{ "field": "_name", "op": "ILIKE", "value": "ari" },
{ "field": "type", "op": "EQ", "value": "local" }
]
},
{
"and": [
{ "field": "_name", "op": "EQ", "value": "smith" },
{ "field": "type", "op": "EQ", "value": "oidc" }
]
}
]
},
"search": "any text",
"page": 10,
"size": 20
}Передавать такой JSON в запросе данных не очень удобно:
- Необходимо как-то сериализовать в параметры запроса. Длина URL имеет ограничение, хотя и довольно большое, в некоторых браузерах.
- Альтернатива – это использовать POST вместо GET. Но это ломает правила использования RESTful API – данные не меняются, а только читаются.
SQL-like запрос
Здесь используется тот же класс Query, но параметр filter получается из текстового представления запроса.
var query = Query.builder().page(10).size(20)
.filter(QueryFilterCompiler.compile("""
_name is not null
or (_name = "maria" or _name ilike "%ari%" and type="local")
or (_name = "smith" and type="type")
"""))
.search("any text")
.build();Запрос в таком виде уже можно передавать в параметрах URL, маскируя спецсимволы.
ANTLR4
Делаем по аналогии с Интерпретатор формул (DSL). Используем Maven.
Подключение плагина
<build>
<plugins>
<plugin>
<groupId>org.antlr</groupId>
<artifactId>antlr4-maven-plugin</artifactId>
<version>4.13.2</version>
<executions>
<execution>
<id>antlr</id>
<goals>
<goal>antlr4</goal>
</goals>
</execution>
</executions>
<configuration>
<visitor>true</visitor>
</configuration>
</plugin>
</plugins>
</build>Сам файл с грамматиками должен находиться в директории
src/main/antlr4/com/github/devmix/sample/filter/antlr4/QueryFilter.g4, а финальный пакет для классов будет
com.github.devmix.sample.filter.antlr4.
Грамматика и генерация парсера
В отличие от формулы, грамматика для фильтра будет немного сложнее. Пример грамматики (QueryFilter.g4) ниже:
grammar QueryFilter;
filter: expr EOF;
expr : left=expr op=(AND | OR) right=expr # logicalExpr
| LPAREN expr RPAREN # parenExpr
| operand # operandExpr
;
operand : field op=(EQ | NE | LT | LTE | GT | GTE) value # compareExpr
| field op=(IS_NULL | IS_NOT_NULL) # nullCheckExpr
| field op=(LIKE | ILIKE) STRING # stringPatternExpr
| field op=(IN | NOT_IN) LPAREN valueList RPAREN # inExpr
;
valueList : value (',' value)*;
value : type=(STRING | INTEGER | FLOAT | BOOLEAN | NULL)
;
field : IDENTIFIER ('.' IDENTIFIER)* // Support nested fields (e.g., user.name)
;
// LOGICAL OPERATORS
AND : 'and' | 'AND';
OR : 'or' | 'OR';
// COMPARE OPERATORS
EQ : 'eq' | '=' | '==';
NE : 'ne' | '!=' | '<>';
LT : 'lt' | '<';
LTE : 'lte' | '<=';
GT : 'gt' | '>';
GTE : 'gte' | '>=';
IS_NULL : 'is' (' '? 'null');
IS_NOT_NULL : 'is' (' '? 'not') (' '? 'null');
LIKE : 'like'; // field ilike "%asd%"
ILIKE : 'ilike'; // field like "%asd%"
IN : 'in';
NOT_IN : 'not' (' '? 'in');
// ATOMS
STRING : DQUOTE (~["\\\r\n] | '\\' ["\\])* DQUOTE
| SQUOTE (~['\\\r\n] | '\\' ['\\])* SQUOTE
;
INTEGER : [+-]? [0-9]+
;
FLOAT : [+-]? [0-9]+ '.' [0-9]+
;
BOOLEAN : 'true'
| 'false'
;
NULL : 'null'
;
IDENTIFIER : [a-zA-Z_] [a-zA-Z_0-9]*;
// GROUPING CHARS
LPAREN : '('
;
RPAREN : ')'
;
SQUOTE : '\''
;
DQUOTE : '"'
;
// WHITE SPACES & COMMENTS
WS : [ \r\n\t]+ -> skip
;
COMMENT : '/*' .*? '*/' -> skip
;
LINE_COMMENT: '//' ~[\r\n]* -> skip
;Здесь видно, что для операций сравнения может использоваться несколько представлений. Например, для проверки
равнозначности можно использовать eq, =, ==. Кому как удобнее и привычнее.
Также есть логические операции or, and.
После описания грамматики можно запустить Maven-команду для генерации классов: mvn antlr4:antlr4. В результате получим
следующие классы, которые потребуются далее:
QueryFilterLexer.java– лексический для разбора формулы на токены.QueryFilterParser.java– синтаксический анализатор для построения дерева разбора формулы (AST).QueryFilterBaseVisitor.java– базовый класс визитора, который можно использовать для обхода дерева разбора. Для создания этого класса необходимо включить в плагине<visitor>true</visitor>.
Abstract Syntax Tree
Теперь можно создать класс-визитор, который будет строить FilterRule на основе запроса. Снова для упрощения
совместил его с классом компилятора и добавил метод compile, который принимает строку с фильтром и возвращает корневой
узел AST (FilterRule).
/**
* This class is responsible for compiling a filter string into an AST (Abstract Syntax Tree)
* using the ANTLR4-generated parser and lexer.
*
* @author Sergey Grachev
*/
public final class QueryFilterCompiler extends QueryFilterBaseVisitor<FilterRule> {
/**
* Compiles a filter string into a FilterRule object.
*
* @param filter The filter string to compile.
* @return A FilterRule object representing the parsed filter, or null if syntax errors are present.
*/
@Nullable
public static FilterRule compile(final String filter) {
final var tokens = new CommonTokenStream(new QueryFilterLexer(CharStreams.fromString(filter)));
final var parser = new QueryFilterParser(tokens);
final var tree = parser.filter();
if (parser.getNumberOfSyntaxErrors() > 0) {
System.err.println("Syntax errors detected!");
return null;
}
return new QueryFilterCompiler().visit(tree);
}
/**
* Visits a filter context and returns the corresponding FilterRule.
*
* @param ctx The filter context to visit.
* @return A FilterRule representing the parsed filter.
*/
@Override
public FilterRule visitFilter(final QueryFilterParser.FilterContext ctx) {
return visit(ctx.expr());
}
/**
* Visits a logical expression context and returns the corresponding FilterRule.
*
* @param ctx The logical expression context to visit.
* @return A FilterRule representing the parsed logical expression.
*/
@Override
public FilterRule visitLogicalExpr(final QueryFilterParser.LogicalExprContext ctx) {
final var left = visit(ctx.left);
final var right = visit(ctx.right);
final var operator = logicalOperatorOf(ctx.op);
// flatten same-operator groups for cleaner AST
final var rules = new ArrayList<FilterRule>();
if (left instanceof final FilterLogicalRule l && l.getOperator().equals(operator)) {
rules.addAll(l.getFilters());
} else {
rules.add(left);
}
if (right instanceof final FilterLogicalRule r && r.getOperator().equals(operator)) {
rules.addAll(r.getFilters());
} else {
rules.add(right);
}
return new FilterLogicalRule(operator, rules);
}
/**
* Visits a parenthesized expression context and returns the corresponding FilterRule.
*
* @param ctx The parenthesized expression context to visit.
* @return A FilterRule representing the parsed parenthesized expression.
*/
@Override
public FilterRule visitParenExpr(final QueryFilterParser.ParenExprContext ctx) {
return visit(ctx.expr());
}
/**
* Visits a comparison expression context and returns the corresponding FilterRule.
*
* @param ctx The comparison expression context to visit.
* @return A FilterRule representing the parsed comparison expression.
*/
@Override
public FilterRule visitCompareExpr(final QueryFilterParser.CompareExprContext ctx) {
final var field = ctx.field().getText();
final var operator = compareOperatorOf(ctx.op);
final var value = parseValue(ctx.value().getText(), ctx.value().type);
return new FilterFieldRule(field, operator, value);
}
/**
* Visits a null check expression context and returns the corresponding FilterRule.
*
* @param ctx The null check expression context to visit.
* @return A FilterRule representing the parsed null check expression.
*/
@Override
public FilterRule visitNullCheckExpr(final QueryFilterParser.NullCheckExprContext ctx) {
final var field = ctx.field().getText();
final var operator = compareOperatorOf(ctx.op);
return new FilterFieldRule(field, operator);
}
/**
* Visits a string pattern expression context and returns the corresponding FilterRule.
*
* @param ctx The string pattern expression context to visit.
* @return A FilterRule representing the parsed string pattern expression.
*/
@Override
public FilterRule visitStringPatternExpr(final QueryFilterParser.StringPatternExprContext ctx) {
final var field = ctx.field().getText();
final var operator = compareOperatorOf(ctx.op);
final var string = ctx.STRING();
final var value = parseValue(string.getText(), string.getSymbol());
return new FilterFieldRule(field, operator, value);
}
/**
* Visits an "in" expression context and returns the corresponding FilterRule.
*
* @param ctx The "in" expression context to visit.
* @return A FilterRule representing the parsed "in" expression.
*/
@Override
public FilterRule visitInExpr(final QueryFilterParser.InExprContext ctx) {
final var field = ctx.field().getText();
final var operator = compareOperatorOf(ctx.op);
final var values = new ArrayList<Object>();
final var valuesParser = ctx.valueList().value();
for (final var valueParser : valuesParser) {
values.add(parseValue(valueParser.getText(), valueParser.type));
}
return new FilterFieldRule(field, operator, values);
}
/**
* Parses a value from its textual representation and token type.
*
* @param text The text of the value to parse.
* @param token The token type of the value.
* @return The parsed value object.
*/
@Nullable
private Object parseValue(final @Nullable String text, final Token token) {
final var type = token.getType();
final Object value;
if (text == null || type == QueryFilterParser.NULL) {
value = null;
} else if (type == QueryFilterParser.STRING) {
value = text.substring(1, text.length() - 1);
} else if (type == QueryFilterParser.BOOLEAN) {
value = Boolean.valueOf(text);
} else if (type == QueryFilterParser.FLOAT) {
value = Double.valueOf(text);
} else if (type == QueryFilterParser.INTEGER) {
value = Long.valueOf(text);
} else {
value = null;
}
return value;
}
/**
* Converts a token representing a logical operator into its corresponding FilterLogicalRule.Operator enum.
*
* @param op The token representing the logical operator.
* @return The corresponding FilterLogicalRule.Operator enum.
*/
private FilterLogicalRule.Operator logicalOperatorOf(final Token op) {
final var type = op.getType();
if (type == QueryFilterParser.AND) {
return FilterLogicalRule.Operator.AND;
}
if (type == QueryFilterParser.OR) {
return FilterLogicalRule.Operator.OR;
}
throw new UnsupportedOperationException("Logical operator '" + op.getText() + "' not implemented");
}
/**
* Converts a token representing a comparison operator into its corresponding FilterFieldRule.Operator enum.
*
* @param op The token representing the comparison operator.
* @return The corresponding FilterFieldRule.Operator enum.
*/
private FilterFieldRule.Operator compareOperatorOf(final Token op) {
return switch (op.getType()) {
case QueryFilterParser.EQ -> FilterFieldRule.Operator.EQ;
case QueryFilterParser.NE -> FilterFieldRule.Operator.NE;
case QueryFilterParser.LIKE -> FilterFieldRule.Operator.LIKE;
case QueryFilterParser.ILIKE -> FilterFieldRule.Operator.ILIKE;
case QueryFilterParser.GT -> FilterFieldRule.Operator.GT;
case QueryFilterParser.GTE -> FilterFieldRule.Operator.GTE;
case QueryFilterParser.LT -> FilterFieldRule.Operator.LT;
case QueryFilterParser.LTE -> FilterFieldRule.Operator.LTE;
case QueryFilterParser.IN -> FilterFieldRule.Operator.IN;
case QueryFilterParser.NOT_IN -> FilterFieldRule.Operator.NIN;
case QueryFilterParser.IS_NULL -> FilterFieldRule.Operator.ISN;
case QueryFilterParser.IS_NOT_NULL -> FilterFieldRule.Operator.ISNN;
default ->
throw new UnsupportedOperationException("Compare operator '" + op.getText() + "' not implemented");
};
}
}Каждый из методов создаёт новое правило для запроса. Это либо простая операция на полем, или логическая группа операций над полями.
Ниже классы для формирования Query и правил фильтрации.
/**
* Represents a query for filtering and searching data.
*
* @author Sergey Grachev
*/
@Getter
@Setter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
@NoArgsConstructor
@AllArgsConstructor
public final class Query implements Serializable {
@Serial
private static final long serialVersionUID = -7649292546603153860L;
/**
* The filter rule used to define the filtering criteria for the query.
*/
@JsonDeserialize(using = FilterDeserializer.class)
@JsonSerialize(using = FilterSerializer.class)
@JsonProperty("filter")
private @Nullable FilterRule filter;
/**
* The search term used to define the global search criteria for the query.
*/
@JsonProperty("search")
private @Nullable String search;
/**
* The page number for pagination of results.
*/
@JsonProperty("page")
private int page = 0;
/**
* The size of the result set to return.
*/
@JsonProperty("size")
private int size = 20;
}/**
* Represents a filter rule for a specific field.
*
* @author Sergey Grachev
*/
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public final class FilterFieldRule implements FilterRule {
/**
* The field name to which this rule applies.
*/
@JsonProperty("field")
private String field;
/**
* The operator used for comparison in this filter rule.
*/
@JsonProperty("op")
private Operator operator;
/**
* The value against which the field is compared.
*/
@JsonProperty("value")
private @Nullable Object value;
public FilterFieldRule(final String field, final Operator operator) {
this.field = field;
this.operator = operator;
}
public static FilterFieldRule eq(final String field, final Object value) {
return new FilterFieldRule(field, Operator.EQ, value);
}
public static FilterFieldRule ne(final String field, final Object value) {
return new FilterFieldRule(field, Operator.NE, value);
}
public static FilterFieldRule ilike(final String field, final Object value) {
return new FilterFieldRule(field, Operator.ILIKE, value);
}
public static FilterFieldRule gt(final String field, final Object value) {
return new FilterFieldRule(field, Operator.GT, value);
}
public static FilterFieldRule lt(final String field, final Object value) {
return new FilterFieldRule(field, Operator.LT, value);
}
public static FilterFieldRule gte(final String field, final Object value) {
return new FilterFieldRule(field, Operator.GTE, value);
}
public static FilterFieldRule lte(final String field, final Object value) {
return new FilterFieldRule(field, Operator.LTE, value);
}
public static FilterFieldRule in(final String field, final Object value) {
return new FilterFieldRule(field, Operator.IN, value);
}
public static FilterFieldRule nin(final String field, final Object value) {
return new FilterFieldRule(field, Operator.NIN, value);
}
public static FilterFieldRule isNull(final String field) {
return new FilterFieldRule(field, Operator.ISN);
}
public static FilterFieldRule isNotNull(final String field) {
return new FilterFieldRule(field, Operator.ISNN);
}
/**
* Enum representing the various operators used in filter rules.
* Each operator defines a specific type of comparison or condition
* that can be applied to a field.
*/
public enum Operator {
EQ, NE, LIKE, ILIKE, GT, LT, GTE, LTE, IN, NIN, ISN, ISNN
}
}/**
* Represents a logical rule for filtering, which can be either AND or OR.
*
* @author Sergey Grachev
*/
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public final class FilterLogicalRule implements FilterRule {
/**
* The operator for this logical rule, indicating whether it's an AND or OR operation.
*/
@JsonProperty("op")
private Operator operator;
/**
* A list of filter rules that are combined under this logical rule.
*/
@Singular
@JsonProperty("f")
private List<FilterRule> filters;
public FilterLogicalRule(final List<FilterRule> filters) {
this.filters = filters;
}
/**
* Enum representing the logical operators for filter rules.
*/
public enum Operator {
AND, OR
}
public static FilterLogicalRule or(final FilterRule... rules) {
return new FilterLogicalRule(Operator.OR, List.of(rules));
}
public static FilterLogicalRule and(final FilterRule... rules) {
return new FilterLogicalRule(Operator.AND, List.of(rules));
}
}/**
* Represents a filter rule that can be applied to query data.
*
* @author Sergey Grachev
*/
public interface FilterRule {
}Здесь:
FilterRule– интерфейс-маркер для всех операций сравнения в условии фильтра.Query– класс описания параметров запроса и фильтрации.FilterFieldRule– класс для простой фильтрации по полю.FilterLogicalRule– класс для логических операций фильтрации.FilterSerializer– дополнительный класс для сериализации фильтра в более компактный формат, чем просто через рефлексию. Не обязательные для использования.FilterDeserializer– аналогичноFilterSerializer.
Проверка разбора фильтра
Для проверки работоспособности можно просто сравнить два запроса: оригинальный и с фильтром из текстового запроса.
public class CompilerTest {
private static final ObjectMapper MAPPER = new ObjectMapper()
.enable(SerializationFeature.INDENT_OUTPUT);
public static void main(final String[] args) throws JsonProcessingException {
final var query1 = Query.builder().page(10).size(20)
.filter(or(
isNotNull("_name"),
and(eq("_name", "maria"), ilike("_name", "%ari%"), eq("type", "local")),
and(eq("_name", "smith"), eq("type", "oidc"))))
.search("any text")
.build();
final var query2 = Query.builder().page(10).size(20)
.filter(QueryFilterCompiler.compile("""
_name is not null
or (_name = "maria" and _name ilike "%ari%" and type = "local")
or (_name = "smith" and type = "oidc")
"""))
.search("any text")
.build();
System.out.println(query1.equals(query2));
System.out.println(MAPPER.writeValueAsString(query1));
}
}Сравнение скорости выполнения
Если сравнивать по скорости выполнения, то десериализация JSON выполняется ~18.5% быстрее. С одной стороны разница большая, а с другой – это всё-равно 70к RPS. Хотя, возможно, стоит и потреблению памяти ещё сравнить.
Benchmark Mode Cnt Score Error Units
FormulaBenchmark.benchJson thrpt 86892.340 ops/s
FormulaBenchmark.benchQuery thrpt 70803.605 ops/sЗаключение
В целом, можно было бы добавить в запрос не только условия выборки, но и параметры сортировки, чтобы не передавать отдельно. Возможно, так и стоит сделать в будущем.
Если в проекте используется Spring Data, то класс Query легко превращается
в Specification, который можно использовать
в запросах. Пример кода приводить не буду, так как его можно сгенерировать любой нейронкой по указанным тут классам и
исправить до рабочего состояния.
Если рассматривать применение в будущем, то пока в таком виде будет использоваться в модуле frame-search
из tekBlueprint.frame для обращения к REST API и расширенных фильтров с UI.
ℹ️ Примечание
Пример проекта доступен в GitHub.