Eigene DSL mit CongoCC erstellen

“Every day, work to refine the skills you have and to add new tools to your repertoire.”

Andrew Hunt

Für viele Aufgabenbereiche werden keine vollständigen Programmiersprachen benötigen, stattdessen können sogenannte Domain-Specific Languages (DSL) verwendet werden. Eine DSL ist auf eine spezielle Domäne zugeschnitten und Probleme können in ihr sehr spezifisch formuliert werden.

Obwohl eine vollständige Programmiersprachen wie Java, Python oder Lua weit mächtiger ist als eine DSL haben diese dennoch einige Vorteile. Zum einen ist der Lernaufwand nicht so groß, so dass auch Nicht-Entwickler diese Sprachen nutzen können Der technische und syntaktische Ballast ist nicht so groß, da der spezielle, enge Domänenzuschnitt Redundanzen eliminiert.

Entwickler arbeiten jeden Tag mit den unterschiedlichsten DSL Varianten. Die bekanntesten sind SQL und andere Abfragesprachen, Gradle, Maven, Regex, AWK und natürlich die Grammatiken der Parser- und Lexer-Generatoren wie Lex, YaCC, Bison, CoCo, JavaCC, CongoCC und Template-Engines wie FreeMarker und FreshMarker. Statt eine Anwendung für ein spezielles Problem zu entwickeln, kann also auch eine DSL verwendet werden oder aber auch eine neue DSL für die Domäne entwickelt werden.

Die folgenden CSV Daten enthalten die Passagierliste der Titanic, die am 15. April 1912 gesunken ist.

1;0;3;"Braund, Mr. Owen Harris";male;22;1;0;A/5 21171;7.25;;S
2;1;1;"Cumings, Mrs. John Bradley (Florence Briggs Thayer)";female;38;1;0;PC 17599;71.2833;C85;C
3;1;3;"Heikkinen, Miss. Laina";female;26;0;0;STON/O2. 3101282;7.925;;S
4;1;1;"Futrelle, Mrs. Jacques Heath (Lily May Peel)";female;35;1;0;113803;53.1;C123;S
5;0;3;"Allen, Mr. William Henry";male;35;0;0;373450;8.05;;S
6;0;3;"Moran, Mr. James";male;;0;0;330877;8.4583;;Q
7;0;1;"McCarthy, Mr. Timothy J";male;54;0;0;17463;51.8625;E46;S
8;0;3;"Palsson, Master. Gosta Leonard";male;2;3;1;349909;21.075;;S
9;1;3;"Johnson, Mrs. Oscar W (Elisabeth Vilhelmina Berg)";female;27;0;2;347742;11.1333;;S
10;1;2;"Nasser, Mrs. Nicholas (Adele Achem)";female;14;1;0;237736;30.0708;;C
…

Jede Zeile enthält die Daten zu einem einzelnen Passagier. In der zweiten Spalte steht eine 1 für einen überlebenden und eine 0 für einen verstorbenen Passagier. In der dritten Spalte steht die gebuchte Klasse und in der vierten Spalte der Name des Passagiers.

Um Daten der Passagiere aus dieser CSV Datei zu extrahieren kann ein eigenes Programm geschrieben werden, ein Tool wie AWK verwendet oder eine eigene DSL zum Einsatz kommen.

Die eigene DSL nennt sich CQL und beherrscht eine Mixtur aus SQL und AWK Syntax.

SELECT $3, $4, $5 FROM input WHERE $2 == 0 && $3 = 1 USING ';' LIMIT 25;

Die Beispiel Abfrage liefert für eine Zeile in der Passagierliste die Spalten 3, 4 und 5 wenn der Passagier nicht überlebt hat und in der ersten Klasse gereist ist. Durch die USING Anweisung wird das Semikolon als Separator verwendet. Ohne die USING Anweisung wird, wie bei AWK üblich, Whitespace als Separator verwendet. Die LIMIT Anweisung reduziert das Ergebnis auf 25 Einträge.

@ParameterizedTest
@CsvSource(value = {
    "SELECT $3, $4, $5 FROM input WHERE $2 == 0 && $3 == 1 USING ';,' LIMIT 25;"
}, delimiter = '#')
void titanic(String input) throws URISyntaxException, IOException {
  CQLParser parser = new CQLParser("cql", input);
  parser.Root();
  Node rootNode = parser.rootNode();
  if (rootNode.get(0) instanceof SelectStatement selectStatement) {
    SelectHandler selectHandler = new SelectHandler();
    StatementVisitor statementVisitor = new StatementVisitor();
    SelectHandler selectHandler = selectStatement.accept(statementVisitor, selectHandler);
    Path path = Path.of(getClass().getClassLoader().getResource("titanic.txt").toURI());
    assertEquals(25, selectHandler.handle(List.of(path)).size());
  }
}

Da es noch keinen vollständigen CQL Interpreter gibt, werden die ersten Gehversuche mit der eigenen DSL mit Hilfe von JUnit Tests durchgeführt. Der obige Test führt das CQL Beispiel auf der Titanic Passagierliste aus. Das Ergebnis der Ausführung ist die nachfolgende Liste.

1 "McCarthy, Mr. Timothy J" male
1 "Fortune, Mr. Charles Alexander" male
1 "Uruchurtu, Don. Manuel E" male
1 "Meyer, Mr. Edgar Joseph" male
1 "Holverson, Mr. Alexander Oskar" male
1 "Ostby, Mr. Engelhart Cornelius" male
1 "Harris, Mr. Henry Birkhardt" male
1 "Stewart, Mr. Albert A" male
1 "Carrau, Mr. Francisco M" male
1 "Chaffee, Mr. Herbert Fuller" male
1 "Goldschmidt, Mr. George B" male
1 "White, Mr. Richard Frasar" male
1 "Porter, Mr. Walter Chamberlain" male
1 "Baxter, Mr. Quigg Edmond" male
1 "White, Mr. Percival Wayland" male
1 "Futrelle, Mr. Jacques Heath" male
1 "Giglio, Mr. Victor" male
1 "Williams, Mr. Charles Duane" male
1 "Baumann, Mr. John D" male
1 "Van der hoef, Mr. Wyckoff" male
1 "Smith, Mr. James Clinch" male
1 "Isham, Miss. Ann Elizabeth" female
1 "Rood, Mr. Hugh Roscoe" male
1 "Minahan, Dr. William Edward" male
1 "Stead, Mr. William Thomas" male

Implementiert wird der CQL Interpreter mit Hilfe des CongoCC Parser-Generator. Die folgende Grammatik umfasst den gesamten Sprachumfang, mit dem Zeilen aus Dateien extrahiert werden können und deren manipulierten Spalten auf StdOut ausgegeben werden.

PARSER_PACKAGE="de.schegge.cql";
NODE_PACKAGE="de.schegge.cql.ast";

SKIP : <WHITESPACE : (" "| "\t"| "\n"| "\r")+>;

TOKEN :
      <OPEN_PAREN : "(">
    | <CLOSE_PAREN : ")">
    | <EQUALS : "=">
    | <DOT : ".">
    | <PLUS : "+">
    | <MINUS : "-">
    | <TIMES : "*">
    | <DIVIDE : "/">
    | <PERCENT : "%">
    | <XOR : "^">
    | <OR : "|">
    | <AND : "&">
    | <LT : "<">
    | <GT : ">">
    | <COMMA : ",">
    | <COLON : ":">
    | <SEMICOLON : ";">
    | <EXCLAM : "!">
    | <DOT_DOT : "..">
    | <DOUBLE_EQUALS : "==">
    | <NOT_EQUALS : "!=">
    | <LTE : "<=">
    | <GTE : ">=">
    | <OR2 : "||">
    | <AND2 : "&&">
    | <NULL : "null">
    | <TRUE : "true">
    | <FALSE : "false">
    | <VARIABLE : ("$" (["0"-"9"])+)>
    | <INTEGER : (["0"-"9"])+>
    | <DECIMAL : <INTEGER> "." <INTEGER>>
    | <STRING_LITERAL :
      ("\"" ((~["\\", "\""]) | ("\\" ~[]))* "\"")
      |
      ("'" ((~["\\", "'"]) | ("\\" ~[]))* "'")
      >
;

TOKEN [IGNORE_CASE] #Keyword :
    <SELECT : "select">
  | <FROM : "from">
  | <WHERE : "where">
  | <LIMIT : "limit">
  | <USING : "using"> 
;

TOKEN : <IDENTIFIER : ((["A"-"Z","a"-"z"]) (["A"-"Z","a"-"z","0"-"9","_"])*)> #Identifier ;

Expression : OrExpression ;

OrExpression : AndExpression ( (<OR>|<OR2>|<XOR>) AndExpression )* ;

AndExpression : EqualityExpression ( (<AND>|<AND2>) EqualityExpression )* ;

EqualityExpression : 
    RelationalExpression [ (<EQUALS>|<DOUBLE_EQUALS>|<NOT_EQUALS>) RelationalExpression ] ;

RelationalExpression : AdditiveExpression [ (<GT>|<GTE>|<LT>|<LTE>) AdditiveExpression ] ;

AdditiveExpression : MultiplicativeExpression ( (<PLUS>|<MINUS>|<DOT_DOT>) MultiplicativeExpression )* ;

MultiplicativeExpression : UnaryExpression ( (<TIMES>|<DIVIDE>|<PERCENT>) UnaryExpression )* ;

UnaryExpression #void : UnaryPlusMinusExpression | NotExpression | BaseExpression ;

NotExpression : <EXCLAM> PrimaryExpression ;

UnaryPlusMinusExpression : (<PLUS>|<MINUS>) PrimaryExpression ;

PrimaryExpression : BaseExpression ;

BaseExpression : NumberLiteral | StringLiteral | BooleanLiteral | <VARIABLE> | <IDENTIFIER>| 
    Parenthesis | NullLiteral ;

StringLiteral : <STRING_LITERAL> ;

NumberLiteral : <INTEGER>|<DECIMAL> ;

BooleanLiteral : <TRUE>|<FALSE> ;

NullLiteral : <NULL> ;

Parenthesis : <OPEN_PAREN> Expression <CLOSE_PAREN> ;

Select #SelectStatement :
    <SELECT>
    Expression (<COMMA> Expression)*
    <FROM>
    <IDENTIFIER>
    [ <WHERE> Expression ]
    [ <LIMIT> <INTEGER> ]
    [ <USING> StringLiteral ]
    <SEMICOLON>
;

Root : (Select)+ <EOF> ;

Da CongoCC ein Nachfolger des bekannten JavaCC Parser-Generators ist, zeigt die Grammatik eine gewisse Ähnlichkeit. Jedoch ist sie mit CongoCC erfreulich schlanker geraten.

Die Verknüpfung zwischen Grammatik und dem Parser Code kann bei CongoCC vollständig über die INJECT Anweisungen erfolgen. Für dieses einfache Beispiel erhalten alle Knoten über das Node Interface eine accept Methode für einen CqlVisitor.

INJECT Node :
  import de.schegge.cql.CqlVisitor;
{
   default <I, O> O accept(CqlVisitor visitor, I input) {
        throw new UnsupportedOperationException();
   }
}

INJECT SelectStatement :
  import de.schegge.cql.CqlVisitor;
{
   public <I, O> O accept(CqlVisitor visitor, I input) {
      return (O)visitor.visit(this, input);
   }
}
…

Damit ein spezieller Type von Knoten von dem CqlVisitor korrekt bearbeitet werden kann, wird eine eigene Implementierung über eine INJECT Anweisung eingefügt. Als Beispiel ist hier die INJECT Anweisung für das SelectStatement dargestellt.

Der CQL Interpreter nutzt eine Instanz der Klasse SelectHandler um die Eingaben zu verarbeiten. Der Selecthandler wird von dem StatementVisitor erzeugt, der eine Implementierung von CqlVisitor ist.

class StatementVisitor implements CqlVisitor<SelectHandler, SelectHandler> {
    private static final ExpressionEvaluator EXPRESSION_EVALUATOR = new ExpressionEvaluator();

    @Override
    public SelectHandler visit(SelectStatement selectStatement, SelectHandler input) {
        Node where = selectStatement.firstDescendantOfType(TokenType.WHERE);
        Node limit = selectStatement.firstDescendantOfType(TokenType.LIMIT);
        Node using = selectStatement.firstDescendantOfType(TokenType.USING);
        int max = getLastColumnIndex(selectStatement, where, limit, using);

        for (int i = 1, j = 1; i < max - 2; i += 2, j++) {
            Node node = selectStatement.get(i);
            input.addColumnHandler(context -> node.accept(EXPRESSION_EVALUATOR, context).toString());
        }

        if (where != null) {
            Node whereClause = where.nextSibling();
            input.setWhereHandler(context -> {
                BooleanValue result = whereClause.accept(EXPRESSION_EVALUATOR, context);
                return result.get();
            });
        }
        if (limit != null) {
            Node limitSize = limit.nextSibling();
            input.setLimit(Integer.parseInt(limitSize.toString()));
        }
        if (using != null) {
            String usingDelimiter = using.nextSibling().toString();
            input.setDelimiter(usingDelimiter.substring(1, usingDelimiter.length() - 1));
        }
        return input;
    }

    private static int getLastColumnIndex(SelectStatement select, Node... nodes) {
        return Stream.of(nodes).filter(Objects::nonNull).map(select::indexOf)
          .findFirst().orElse(select.getChildCount());
    }
}
…

Die auszugebenden Spalten sind durch Knoten im SelectStatement repräsentiert. Der erste Knoten mit Index 0 ist das SELECT Token. Daher beginnt die For-Schleife mit dem Index 1 und endet vor dem ersten Token vom Type WHERE, USING oder LIMIT. In der Schleife wird mit dem ExpressionEvaluator, der auch CqlVisitor implementiert, ein ColumnHandler erzeugt. Der Zähler wird dabei immer um 2 erhöht, weil zwischen den Ausdrücken für die Spalten ein KOMMA Token platziert ist.

Ein ColumnHandler erzeugt einen Wert für die Ausgabe auf StdOut, indem Ausdrücke aus Variable, Identifier und Literale Knoten ausgewertet werden. Im Titanic Beispiel besteht jeder Ausdruck nur aus einer einzelnen Variable. Es können aber auch kompliziertere Ausdrücke verwendet werden.

SELECT $3 .. '. Klasse', $4 FROM input WHERE $2 == 0 && $3 > 1 LIMIT 25 USING ';';

In diesem Beispiel wird die Nummer der Klasse in der Variablen $3 mit dem String . Klasse verknüpft. Außerdem werden nun alle Passagiere ausgewertet, die nicht in der ersten Klasse reisten und die Ausgabe ändert sich auf die folgende Liste.

3. Klasse "Braund, Mr. Owen Harris"
3. Klasse "Allen, Mr. William Henry"
3. Klasse "Moran, Mr. James"
3. Klasse "Palsson, Master. Gosta Leonard"
3. Klasse "Saundercock, Mr. William Henry"
3. Klasse "Andersson, Mr. Anders Johan"
3. Klasse "Vestrom, Miss. Hulda Amanda Adolfina"
3. Klasse "Rice, Master. Eugene"
3. Klasse "Vander Planke, Mrs. Julius (Emelia Maria Vandemoortele)"
2. Klasse "Fynney, Mr. Joseph J"
3. Klasse "Palsson, Miss. Torborg Danira"
3. Klasse "Emir, Mr. Farred Chehab"
3. Klasse "Todoroff, Mr. Lalio"
2. Klasse "Wheadon, Mr. Edward H"
3. Klasse "Cann, Mr. Ernest Charles"
3. Klasse "Vander Planke, Miss. Augusta Maria"
3. Klasse "Ahlin, Mrs. Johan (Johanna Persdotter Larsson)"
2. Klasse "Turpin, Mrs. William John Robert (Dorothy Ann Wonnacott)"
3. Klasse "Kraeff, Mr. Theodor"
3. Klasse "Rogers, Mr. William John"
3. Klasse "Lennon, Mr. Denis"
3. Klasse "Samaan, Mr. Youssef"
3. Klasse "Arnold-Franchi, Mrs. Josef (Josefine Franchi)"
3. Klasse "Panula, Master. Juha Niilo"
3. Klasse "Nosworthy, Mr. Richard Cater"

Im ExpressionEvaluator werden die Bestandteile der Ausdrücke ausgewertet und ein Ergebnis vom Type Value<T> zurückgegeben. Für die Verwendung als Spalten-Eintrag wird das Ergebnis als String ausgegeben und für die Verwendung im Where Ausdruck in ein Boolean konvertiert.

@Override
public Value<?> visit(VARIABLE variableName, RecordContext input) {
  int index = variableName.getIndex() - 1;
  if (index == -1) {
    return new StringValue(input.record());
  }
  String[] columns = input.columns();
  if (index >= 0 && index < columns.length) {
    return new StringValue(columns[index]);
  }
  return Value.NULL;
}

@Override
public Value<?> visit(Identifier identifier, RecordContext input) {
  return switch (identifier.toString()) {
    case "NR" -> new LongValue(input.nr().longValue());
    case "FNR" -> new LongValue(input.fnr().longValue());
    case "FILENAME" -> new StringValue(input.filename());
    default -> throw new IllegalArgumentException();
  };
}

@Override
public Value<?> visit(INTEGER integer, RecordContext input) {
  return new LongValue(Long.parseLong(integer.toString()));
}

…

Die visit Methoden der ExpressionEvaluator Klasse besitzen alle einen RecordContext als Parameter. In diesem sind die die Eingabezeile record, die einzelnen Spalten der Eingabezeile columns, der Dateiname filename und die AWK-ähnlichen Zählvariablen nr und fnr hinterlegt.

Die visit Methode für das VARIABLE Token liefert für $0 die gesamte Zeile und für $1, die erste Spalte, für $2 die zweite Spalte, usw. bis keine weiteren Spalten in der Eingabezeile existieren. Für alle weiteren Variablen wird Value.NULL zurück geliefert.

Die visit Methode für den Identifier Node liefert für die bekannten Namen NR, FNR und FILENAME die Werte aus dem RecordContext und wirft bei unbekannten Namen eine IllegalArgumentException.

Die visit Methoden für Literale sind besonders einfach, wie hier für das INTEGER Token gezeigt ist. Die String Darstellung des Token wird in Long umgewandelt und in den passenden Value<T> Type eingefügt. Der einzige Fehler der hier auftreten kann ist der Long Überlauf. Der CQL Interpreter wirft also eine Exeption, falls die Zahl größer ist als 9223372036854775807.

Interessanter sind aber die visit Methoden für die nicht terminalen Knoten in den Ausdrücken. Hier einmal beispielhaft die Auswertung einer AdditiveExpression.

AdditiveExpression : 
  MultiplicativeExpression 
  ( 
    (<PLUS>|<MINUS>|<DOT_DOT>) 
    MultiplicativeExpression 
  )* 
;

Eine AdditiveExpression besteht aus einer nicht optionalen MultiplicativeExpression und danach einer beliebig langen Kette von weiteren MultiplicativeExpression mit einem vorhergehenden Token als Operator.

@Override
public Value<?> visit(AdditiveExpression additiveExpression, RecordContext input) {
  Value<?> result = additiveExpression.get(0).accept(this, input);
  for (int i = 1; i < additiveExpression.getChildCount(); i += 2) {
    Value<?> second = additiveExpression.get(i + 1).accept(this, input);
    result = switch (((Token) additiveExpression.get(i)).getType()) {
      case DOT_DOT -> new StringValue(result.toString() + second.toString());
      case PLUS -> result.toNumber().plus(second.toNumber());
      case MINUS -> result.toNumber().minus(second.toNumber());
      default -> throw new IllegalArgumentException();
    };
  }
  return result;
}

Die entsprechende visit Methode ist strukturell identisch aufgebaut. Zu beachten ist nur, dass der Visitor nicht direkt auf MultiplicativeExpression arbeitet, sondern die accept Methode entsprechenden Nodes in der AdditiveExpression aufruft. Das hat nicht nur ästhetische oder pädagogische Gründe, sondern ist dem Parse-Tree geschuldet, der von CongoCC generiert wird. Wird eine Zahl als erste MultiplicativeExpression gefunden, dann wird kein MultiplicativeExpression Knoten eingefügt, sondern nur ein INTEGER oder ein DECIMAL Token. Hier schützt das Visitor Pattern vor häufigen Gebrauch des instanceof Operators im Parse-Tree.

Zuletzt fehlt nur die handle Methode des SelectHandlers als Herzstück des CQL Interpreters. In ihr werden die Zeilen aus de Eingabe eingelesen, der RecordContext erzeugt, der Where-Ausdruck überprüft und bei erfolgreicher Überprüfung die Ausgabe erzeugt.

private void handlePath(AtomicInteger nr, AtomicInteger count, Path path) throws IOException {
    AtomicInteger fnr = new AtomicInteger();
    try (BufferedReader reader = Files.newBufferedReader(path)) {
        String line;
        while ((line = reader.readLine()) != null) {
            nr.incrementAndGet();
            fnr.incrementAndGet();
            String[] columns = line.split(delimiter);
            RecordContext context = new RecordContext(line, columns, nr, fnr, path.toString());
            if (!whereHandler.handle(context)) {
                continue;
            }
            if (limit < count.incrementAndGet()) {
                break;
            }
            String result = columnHandlers.stream().map(ch -> ch.handle(context)).collect(joining(" "));
            System.out.println(result);
        }
    }
}

Neben der Auswertung von Titanic Passagierlisten existieren natürlich auch andere Anwendungsfälle für CQL. Es können Spalten aus CSV-Dateien gelöscht werden, Spalten aufaddiert werden, die Mehrwertsteuer zu Nettopreis-Spalten hinzufügen, etc.

Damit ist die eigene DSL im Grunde schon implementiert und es fehlt lediglich eine passende Fassade um die DSL auf der Kommandozeile zu nutzen.

Schreibe einen Kommentar