← Back to list

How to Add Validations to the ANTLR Grammar

When building a custom query language or a flexible filter system, ANTLR is one of the most powerful tools you can use. It allows you to…

Renan Schmitt in Java Performance · 2025-09-22 10:02 · 100 claps · 4.1 min read paywalled
#java #antlr #programming #software-development #backend-development
Open on Medium ↗
Wiki topics: 💻 · Programming 🌐 · Web Development 🥊 · Combat Sports

How to Add Validations to the ANTLR Grammar

When building a custom query language or a flexible filter system, ANTLR is one of the most powerful tools you can use. It allows you to define a grammar and automatically generate parsers, making it much easier to handle complex expressions.

In a previous article, I showed how we can use ANTLR grammar to create a filter for a REST API. That worked well, but the first version was missing an important capability: validating field types. Without validation, users could try to compare incompatible types (e.g., comparing a string to a number) or even query fields that don’t exist.

In this article, we’ll walk through how to extend the grammar with type validation, ensuring that filters are not only syntactically correct but also semantically valid.

Generated by ChatGPT.

Generated by ChatGPT.

Recap

The grammar used for a simple filter looks like this:

  • An expression can be: expression AND/OR expression
  • An expression is composed of: field operator value
  • field is any string
  • value can be: string, boolean, number, or null
  • A number can be an integer or a double (integer + '.' + integer)
grammar Filter;

expression: expression AND expression
          | expression OR expression
          | field operator value;

operator: 'eq' | 'ne' | 'gt' | 'ge' | 'lt' | 'le';

value: STRING | number | BOOLEAN | NULL;

number: INTEGER | INTEGER '.' INTEGER;

field: FIELD_NAME;

AND: 'and';
OR: 'or';
NULL: 'null';
BOOLEAN: 'true' | 'false';
STRING: '\'' (~['\\] | '\\' .)* '\'';

INTEGER: [0-9]+;
FIELD_NAME: [a-z] [a-z0-9_-]*;

WS: [ \t\r\n]+ -> skip;

The code to interpret the grammar is as follows:

public class QueryFilterListener extends FilterBaseListener {
  private Integer fieldCounter = 1;
  private Stack<Object> stack;
  private final Stack<FilterResponse> filterResponseStack = new Stack<>();

  public FilterResponse getResult() {
    return filterResponseStack.pop();
  }

  @Override
  public void enterField(FilterParser.FieldContext ctx) {
    stack.push(ctx.getText());
  }

  @Override
  public void enterValue(FilterParser.ValueContext ctx) {
    stack.push(
        ctx.STRING() != null
            ? ctx.STRING().getText().substring(1, ctx.STRING().getText().length() - 1)
            : ctx.getText());
  }

  @Override
  public void enterOperator(FilterParser.OperatorContext ctx) {
    switch (ctx.getText()) {
      case "eq" -> stack.push("=");
      case "lt" -> stack.push("<");
      case "gt" -> stack.push(">");
      case "le" -> stack.push("<=");
      case "ge" -> stack.push(">=");
      case "ne" -> stack.push("<>");
      default -> throw new IllegalArgumentException("Unexpected value: " + ctx.getText());
    }
  }

  @Override
  public void enterExpression(FilterParser.ExpressionContext ctx) {
    stack = new Stack<>();
  }

  @Override
  public void exitExpression(FilterParser.ExpressionContext ctx) {
    if (ctx.AND() != null || ctx.OR() != null) {
      var right = filterResponseStack.pop();
      var left = filterResponseStack.pop();

      Map<Integer, Object> mergedMap = new HashMap<>(left.parameters());
      mergedMap.putAll(right.parameters());

      filterResponseStack.push(
          new FilterResponse(
              left.where() + (ctx.AND() != null ? " and " : " or ") + right.where(), mergedMap));
    } else {
      var value = (String) stack.pop();
      var operator = (String) stack.pop();
      var field = (String) stack.pop();

      String where = String.format("%s %s %s", field, operator, "?" + fieldCounter);
      Map<Integer, Object> parameters = Map.of(fieldCounter, value);

      filterResponseStack.push(new FilterResponse(where, parameters));

      fieldCounter++;
    }
  }
}

Testing the Grammar

Now, let’s add some tests to check our grammar.

When running them, you’ll notice the tests fail because:

  1. It does not check the type of the field being compared.
  2. It does not check if the field actually exists.
@Test
void errorWhenComparingIdWithString() {
  Assertions.assertThrows(
      IllegalArgumentException.class, 
      () -> productRepository.findByFilter("id eq '1'"));
}

@Test
void errorWhenComparingNameWithInteger() {
  Assertions.assertThrows(
      IllegalArgumentException.class, 
      () -> productRepository.findByFilter("name eq 1"));
}

@Test
void errorWhenUnknownField() {
  Assertions.assertThrows(
      IllegalArgumentException.class,
      () -> productRepository.findByFilter("unknownField eq 1"));
}

The Solution

To fix these issues, we extend the grammar interpreter with a new QueryFilterListener class:

public class QueryFilterListener extends FilterBaseListener {
  private static final Map<String, Class<?>> TYPE_MAP =
      Map.of(
          "id",
          Long.class,
          "price",
          Number.class,
          "name",
          String.class,
          "description",
          String.class,
          "category",
          String.class);

  private Integer fieldCounter = 1;
  private Stack<Object> stack;
  private final Stack<FilterResponse> filterResponseStack = new Stack<>();

  public FilterResponse getResult() {
    return filterResponseStack.pop();
  }

  @Override
  public void enterField(FilterParser.FieldContext ctx) {
    stack.push(new Field(ctx.getText(), TYPE_MAP.getOrDefault(ctx.getText(), String.class)));
  }

  @Override
  public void enterValue(FilterParser.ValueContext ctx) {
    Class<?> valueType = determineValueType(ctx);

    var field = (Field) stack.get(stack.size() - 2);
    if (valueType != null
        && field.type() != valueType
        && !field.type().isAssignableFrom(valueType)) {
      throw new IllegalArgumentException(
          "Field type mismatch: expected " + field.type() + ", found " + valueType);
    }

    stack.push(
        ctx.STRING() != null
            ? ctx.STRING().getText().substring(1, ctx.STRING().getText().length() - 1)
            : ctx.getText());
  }

  private Class<?> determineValueType(FilterParser.ValueContext ctx) {
    if (ctx.STRING() != null) {
      return String.class;
    } else if (ctx.BOOLEAN() != null) {
      return Boolean.class;
    } else if (ctx.number() != null) {
      return ctx.number().INTEGER().size() == 1 ? Integer.class : Double.class;
    } else {
      return null;
    }
  }

  @Override
  public void enterOperator(FilterParser.OperatorContext ctx) {
    switch (ctx.getText()) {
      case "eq" -> stack.push("=");
      case "lt" -> stack.push("<");
      case "gt" -> stack.push(">");
      case "le" -> stack.push("<=");
      case "ge" -> stack.push(">=");
      case "ne" -> stack.push("<>");
      default -> throw new IllegalArgumentException("Unexpected value: " + ctx.getText());
    }
  }

  @Override
  public void enterExpression(FilterParser.ExpressionContext ctx) {
    stack = new Stack<>();
  }

  @Override
  public void exitExpression(FilterParser.ExpressionContext ctx) {
    if (ctx.AND() != null || ctx.OR() != null) {
      var right = filterResponseStack.pop();
      var left = filterResponseStack.pop();

      Map<Integer, Object> mergedMap = new HashMap<>(left.parameters());
      mergedMap.putAll(right.parameters());

      filterResponseStack.push(
          new FilterResponse(
              left.where() + (ctx.AND() != null ? " and " : " or ") + right.where(), mergedMap));
    } else {
      var value = (String) stack.pop();
      var operator = (String) stack.pop();
      var field = (Field) stack.pop();

      String where = String.format("%s %s %s", field.name(), operator, "?" + fieldCounter);
      Map<Integer, Object> parameters = Map.of(fieldCounter, value);

      filterResponseStack.push(new FilterResponse(where, parameters));

      fieldCounter++;
    }
  }

  private record Field(String name, Class<?> type) {}
}

What Changed?

  1. A static Map defines the field names and their types.
  2. A new Field record was created to store both the field name and its type.
  3. On enterField, we push a Field record onto the stack.
  4. A new method determineValueType was added, which returns the type of the input value.
  5. On enterValue, a simple validation checks if the field type matches the provided value.

Conclusion

By adding type validation to our ANTLR grammar, we made our filter parser much more robust. Now, it doesn’t just parse expressions — it ensures they actually make sense according to the fields and types we support.

This approach can be extended further:

  • Enforcing stricter operators depending on types (e.g., < only for numbers).
  • Validating nested fields or object structures.
  • Providing meaningful error messages to the end user.

If you’re building a domain-specific language (DSL) or any query/filter system, investing time in validations will save you from subtle bugs and confusing user errors.

ANTLR gives us the power to go beyond syntax — and start enforcing semantics. 🚀


메타데이터
post_id
0f5e7ecf5452
slug
how-to-add-validations-to-the-antlr-grammar-0f5e7ecf5452
url
https://medium.com/@renanschmitt/how-to-add-validations-to-the-antlr-grammar-0f5e7ecf5452
canonical_url
https://medium.com/@renanschmitt/how-to-add-validations-to-the-antlr-grammar-0f5e7ecf5452
author_url
https://medium.com/@renanschmitt
status
ok
fetched_at
2026-06-24 11:06:28