Фильтрация данных через SQL-подобный запрос

опубликовано: ⏰ 16

Какое-то время назад была статья о том, как реализовать интерпретатор формул в Интерпретатор формул (DSL). При реализации tekBlueprint.frame появилась потребность сделать для REST API универсальный язык запросов для получения табличных данных для UI. Собственно, об этом и пойдёт речь.

Вводная

Как было выше сказано, появилась необходимость запрашивать данные от серверного слоя, используя некий универсальный формат описания запросов. Конечно же, можно сказать, что всё уже придумано до нас, и есть такие вещи, как GraphQL, OData, которые вполне решают эту задачу. В общем случае, их можно и нужно использовать, но были некоторые причины сделать свой вариант запросов.

Задача

Сначала поясню причины для реализации своего решения:

  1. Не хотелось добавлять в проект дополнительные библиотеки, от которых потребуется только небольшой процент функционала. Необходима только фильтрация данных.
  2. Не требуется определять список полей для выборки – задаются явно и запрещено всё, что не входит в список.
  3. Не требуются механизмы изменения данных (например, GraphQL Mutations) – всё делается по принципам RESTful API.
  4. Нужен полный контроль над синтаксисом с возможностью расширения.

Если отбросить готовые решения, то получим несколько вариантов решения задачи:

  1. Самый старый и известный со времён древних технологий – заранее предусматриваем всё в параметрах контроллёра и строим запрос по указанным с UI параметрам.
  2. Формируем JSON с фильтром на стороне UI и передаём в запросе – более гибкий вариант, но с определёнными ограничениями.
  3. Используем SQL-like запрос, парсим и приводим к SQL для СУБД – обычно сразу отпугивает, так как нужно писать парсер. Можно взять что-то готовое. Например, Apache Calcite, но выглядит как сильное переусложнение для решения подобной задачи.
Через параметры запросаФильтр в виде JSONФильтр в виде текстового запроса
ПлюсыПросто и понятно для реализацииБольшая гибкостьКомпактный и человекочитаемый вид
Самая высокая скорость разбора запросаПростота генерации в JavaScript -> JSONВозможность писать запросы с UI вручную
Высокая скорость разбора запроса
МинусыДовольно громоздкие запросыНе менее громоздкие запросыНужно реализовать свой парсер
Необходимо всё варианты предусматривать заранееНельзя передать в теле GET запроса, только в параметрах или использовать POSTБолее медленная скорость разбора запроса по сравнению с остальными
Можно, но сложно сделать обобщённое решениеТолько машинная генерация запроса
Только машинная генерация запроса

В итоге остановился на вариантах 2 и 3. Второй вариант по большому счёту используется как базовая модель для запроса ( далее класс Query), в который приводится третий вариант с запросом.

Фильтр в виде JSON

Для понимания приведу пример о чём речь. Ниже пример запроса пользователей, формируемого в коде (Java).

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();

Который сериализуется в следующий вид:

JSON
{
  "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 получается из текстового представления запроса.

Java
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.

Подключение плагина
XML
<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) ниже:

Antlrv4
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).

Java
/**
 * 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 и правил фильтрации.

Java
/**
 * 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;
}
Java
/**
 * 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
    }
}
Java
/**
 * 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));
    }
}
Java
/**
 * Represents a filter rule that can be applied to query data.
 *
 * @author Sergey Grachev
 */
public interface FilterRule {
}

Здесь:

  • FilterRule – интерфейс-маркер для всех операций сравнения в условии фильтра.
  • Query – класс описания параметров запроса и фильтрации.
  • FilterFieldRule – класс для простой фильтрации по полю.
  • FilterLogicalRule – класс для логических операций фильтрации.
  • FilterSerializer – дополнительный класс для сериализации фильтра в более компактный формат, чем просто через рефлексию. Не обязательные для использования.
  • FilterDeserializer – аналогично FilterSerializer.

Проверка разбора фильтра

Для проверки работоспособности можно просто сравнить два запроса: оригинальный и с фильтром из текстового запроса.

Java
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. Хотя, возможно, стоит и потреблению памяти ещё сравнить.

Text
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.

Java Antlr Dsl Parser Filter Devmix