| description | GQL Parser design: ANTLR4 integration and GQL AST |
|---|---|
| sidebar_label | GQL Parser |
| sidebar_position | 82 |
| slug | /development/graph/parser |
| title | GQL Parser Design |
| doc_type | reference |
This document describes how the GQL (Graph Query Language, ISO/IEC 39075) parser is integrated into ClickHouse using ANTLR4.
This document focuses on the parser. The parser builds normalized ClickHouse
IAST nodes for supported GQL syntax; the interpreter layer now has an
initial direct planner path for supported query roots, while graph storage and catalog
execution remain separate follow-up work.
ClickHouse already uses ANTLR4 for parsing PromQL (Prometheus Query Language):
contrib/antlr4-cpp-runtime/ -- ANTLR4 C++ runtime library
contrib/antlr4-cpp-runtime-cmake/ -- CMake build for the runtime
contrib/antlr4-grammars/ -- PromQL grammar (PromQL.g4)
contrib/antlr4-grammars-cmake/ -- CMake that generates C++ from .g4
src/Parsers/Prometheus/
PrometheusQueryParsingUtil-antlr.cpp -- ANTLR ParseTree -> ClickHouse AST
The GQL parser reuses the same ANTLR4 runtime pattern, but keeps the local GQL
grammar and generated sources under src/Parsers/graph.
The GQL grammar is sourced from the opengql/grammar repository, which provides a language-independent ANTLR grammar conforming to ISO GQL.
Key characteristics of the grammar:
- Case-insensitive keywords (
options { caseInsensitive = true; }) - Combined lexer and parser rules in a single
GQL.g4file - The upstream grammar covers the broad GQL standard surface, including
MATCH,RETURN,WHERE, DML, DDL, session management, and transactions. The current production LeoGraph entry intentionally parses one executablestatement, not the fullgqlProgram.
src/Parsers/graph/grammar/GQL.g4
-> src/Parsers/graph/grammar/generate.sh
-> src/Parsers/graph/generated/GQLLexer.*
-> src/Parsers/graph/generated/GQLParser.*
-> src/Parsers/graph/generated/GQLVisitor.*
-> src/Parsers/graph/visitor/GQLParseTreeVisitor*
-> src/Parsers/graph/AST/GQL*
After changing GQL.g4, regenerate the parser sources with:
./src/Parsers/graph/grammar/generate.shSee src/Parsers/graph/grammar/README.md for platform-specific generator
notes.
The current implementation supports the parser-facing slice that is already stable enough for downstream planner work to consume without re-parsing source text:
| Statement | Example | Notes |
|---|---|---|
MATCH ... RETURN |
MATCH (a:Person) RETURN a.name |
Core linear query path |
OPTIONAL MATCH |
OPTIONAL MATCH (a)-[e]->(b) RETURN b |
Includes block-wrapper support |
graph SELECT |
SELECT a FROM { MATCH (a) RETURN a } |
Normalizes to GQLSelectClause plus optional GQLPageClause |
focused USE query |
USE foo MATCH (a) RETURN a |
Preserves USE as a structured clause |
named / inline CALL |
CALL foo(a) YIELD x RETURN x |
Distinct AST nodes for named vs inline calls |
INSERT |
INSERT (n:Person {name: 'Alice'}) |
GQLInsertClause with GQLInsertPathPattern through ParserGQLQuery / parseStatement |
SET |
MATCH (n) SET n.age = 30 |
GQLSetClause with property / all-properties / label items through ParserGQLQuery / parseStatement |
REMOVE |
MATCH (n) REMOVE n.age |
GQLRemoveClause with property / label items through ParserGQLQuery / parseStatement |
DELETE |
MATCH (n) DELETE n |
GQLDeleteClause with DETACH / NODETACH modes through ParserGQLQuery / parseStatement |
| catalog DDL | CREATE GRAPH g ANY AS COPY OF h |
GQLCatalogStatement with structured object names and graph sources through ParserGQLQuery / parseStatement |
| nested query | { MATCH (a) RETURN a } NEXT YIELD a { RETURN a } |
Preserves GQLSubquery wrapper |
| Pattern | Syntax | Description |
|---|---|---|
| Node / edge | (a:Label) / -[e:Label]-> |
Core classic pattern nodes |
| Parenthesized path | ((a)-[e]->(b))? |
Keeps its own wrapper plus outer quantifier |
| Classic alternation | `(a)-[e]->(b) | (c)-[f]->(d)` |
| Simplified path | `()-/ :A | :B /->()` |
| Counted search prefix | MATCH ANY 3 PATHS ... |
Count preserved as GQLCountSpec |
| Quantified non-edge primary | (a){2} |
Preserved via GQLQuantifiedPathPrimary |
SESSION/START TRANSACTION(session management)- binder, storage, catalog execution, and complete query planning
- semantic validation for type compatibility and catalog references; the parser only builds syntactic
GQLTypeExpression/GQLGraphTypeSpecificationnodes - GQL text in ordinary ClickHouse sessions; production GQL parsing requires explicit
dialect = gql/query_language = gql
The current refactor keeps the antlr4 side and the IAST side intentionally separate:
- The
visit*boundaries inGQLParseTreeVisitorfollowGQL.g4. - The
IASTlayer is normalized in a graph-native shape, instead of mirroring every parse-tree wrapper rule. - Pure grammar pass-through nodes such as
compositeQueryStatementare not preserved as dedicated AST wrappers.
This keeps the visitor easy to debug against the grammar while still producing a stable AST contract for later planner work.
The current query-level contract is:
| Grammar rule | Returned AST node | Notes |
|---|---|---|
compositeQueryStatement |
forwards child root | no dedicated wrapper |
compositeQueryExpression with a set operator |
GQLCombinedQuery |
stores queries + operators, preserving ALL / DISTINCT explicitly |
compositeQueryExpression without a set operator |
forwards child root | keeps the root stable |
| linear clause query | GQLSingleQuery |
ordered list of clause nodes |
top-level selectStatement |
GQLSingleQuery |
starts with GQLSelectClause, followed by GQLPageClause when paging is present |
nestedQuerySpecification |
GQLSubquery |
preserves the wrapper; the inner query child is itself a normalized query root |
This is the main design rule that aligns the ClickHouse visitor with the graph-native AST shape:
- grammar decides which
visit*methods exist; - AST normalization decides which nodes are allowed to become public query roots.
At the current phase, the most important GQL* nodes are:
IAST
|
+-- GQLSingleQuery -- ordered clause list for linear queries
+-- GQLCombinedQuery -- `UNION` / `EXCEPT` / `INTERSECT` / `OTHERWISE`, with explicit operator variants
+-- GQLSubquery -- explicit `{ ... }` wrapper with `NEXT` chain support
|
+-- GQLAtSchemaClause -- `AT schemaReference`
+-- GQLSchemaReference -- typed `schemaReference` wrapper
+-- GQLGraphExpression -- typed wrapper for `graphExpression`
+-- GQLBindingTableExpression -- typed wrapper for `bindingTableExpression`
+-- GQLBindingInitializer -- typed wrapper for `= ...` in binding definitions
+-- GQLBindingVariableDefinitionBlock
+-- GQLBindingVariableDefinition
+-- GQLYieldClause
|
+-- GQLMatchClause -- `MATCH` / `OPTIONAL MATCH`
+-- GQLGraphPatternBlock -- delimited graph-pattern body for predicates
+-- GQLMatchStatementBlock -- delimited `MATCH`-statement block wrapper
+-- GQLReturnClause -- `RETURN`
+-- GQLSelectClause -- `SELECT`
+-- GQLWhereClause -- `WHERE`, `FILTER`, `HAVING`
+-- GQLUseClause -- `USE`
+-- GQLCallNamedClause -- named `CALL foo(...) YIELD ...`
+-- GQLCallVariableScopeClause -- optional `(x, y)` scope for inline `CALL`
+-- GQLCallInlineClause -- inline `CALL { ... }` or `CALL (x, y) { ... }`
+-- GQLGroupByClause -- structured `GROUP BY`
+-- GQLLetClause -- `LET`
+-- GQLForClause -- `FOR`
+-- GQLPageClause -- standalone `ORDER BY` / `OFFSET` / `LIMIT`
|
+-- GQLInsertClause -- `INSERT` with insert path patterns
+-- GQLInsertPathPattern -- node-edge chain in an insert clause
+-- GQLSetClause -- `SET` with property / all-properties / label items
+-- GQLRemoveClause -- `REMOVE` with property / label items
+-- GQLDeleteClause -- `DELETE` with `DETACH` / `NODETACH` modes
|
+-- GQLPathPattern
+-- GQLPathTerm
+-- GQLPathPatternAlternation
+-- GQLPathModePrefix
+-- GQLPathSearchPrefix
+-- GQLCountSpec
+-- GQLParenthesizedPathPattern
+-- GQLSimplifiedPathPattern
+-- GQLSimplifiedPathExpr
+-- GQLQuantifiedPathPrimary
+-- GQLNodePattern
+-- GQLEdgePattern
+-- GQLLabelExpression
+-- GQLQuantifier
+-- GQLListConstructor
+-- GQLRecordConstructor
+-- GQLExpr -- current expression skeleton
Pattern nodes stay graph-native, while expressions use a GQLExpr layer. Most value-function branches are now structurally represented: numeric functions (all 13 branches), character/string functions (including TRIM via GQLExpr::TrimString with explicit TrimSpec), datetime functions, duration functions (including DURATION_BETWEEN via GQLExpr::DurationBetween with TemporalQualifier), and list functions. Bare keyword datetime forms (CURRENT_DATE, CURRENT_TIME, CURRENT_TIMESTAMP, LOCAL_TIME, LOCAL_TIMESTAMP) are structured as GQLExpr::Kind::FunctionCall with bare_keyword = true, so round-trip output omits parentheses. Datetime and duration value functions with string parameters (e.g. DATE('2024-01-15'), ZONED_TIME('10:30:00+02:00'), DURATION('P1Y')) store their argument as a GQLExpr::Kind::Literal child node instead of raw text. The expression-primary layer now also covers valueQueryExpression (VALUE { subquery }) via GQLExpr::Kind::ValueQuery, letValueExpression (LET ... IN ... END) via GQLExpr::Kind::LetExpr, and pathValueConstructor (PATH [ ... ]) via GQLExpr::Kind::PathConstructor. The normalizedPredicatePart2 (IS [NOT] [normalForm] NORMALIZED) is now fully structured as a GQLExpr::BinaryOp in both the top-level normalizedPredicateExprAlt visitor and the whenOperand CASE branch. GQLExpr::Kind::FunctionCall carries aggregate DISTINCT / ALL through a SetQuantifier field instead of stringifying the modifier back into raw text. Inside GQLSubquery, wrapper-level metadata such as AT schema, binding definitions, and NEXT YIELD now also have dedicated AST nodes instead of being stored as top-level raw-text leaves. schemaReference, graphExpression, bindingTableExpression, and binding initializers are also wrapped in dedicated nodes so the nested procedure-body contract can grow without reshaping its parents. Dynamic parameters ($param) are now GQLExpr::Kind::DynamicParameter instead of Literal, and keyword-based special values (NULL, TRUE, FALSE, UNKNOWN, SESSION_USER) are GQLExpr::Kind::SpecialValue, keeping the Literal kind reserved for actual data literals and string constants. Temporal literals (DATE '...', TIME '...', DATETIME '...', TIMESTAMP '...') are GQLExpr::Kind::TemporalLiteral, and duration literals (DURATION '...') are GQLExpr::Kind::DurationLiteral; both keep the keyword in text and the string value as a Literal child node, distinguishing them from both plain string literals and parenthesized datetime/duration value functions (FunctionCall).
The visitor is organized in the same high-level layers as the grammar:
- query-level visitors such as
visitCompositeQueryExpression,visitLinearQueryStatement, andvisitSelectStatementdecide the normalized query root; - clause-level visitors such as
visitMatchStatement,visitCallQueryStatement,visitFilterStatement, andvisitReturnStatementbuild individual clause nodes; - pattern-level visitors such as
visitGraphPattern,visitPathPattern,visitNodePattern, andvisitEdgePatternbuild graph-specific subtrees; - expression-level visitors build
GQLExprand related leaf nodes.
Key design constraints for the visitor:
visitSelectStatementmust finish reading the needed parse-tree branches before it constructsGQLSelectClause, then emit aGQLSingleQueryand appendGQLPageClauseonly when paging exists.visitNestedQuerySpecificationmust preserve aGQLSubquerywrapper instead of unwrapping its inner query.- Classic-pattern alternation and simplified-path alternation deliberately use parallel node families (
GQLPathPatternAlternationvsGQLSimplifiedPathExpr); they should not be merged into one shared node type. - If a nested procedure body contains a non-query statement that the current query AST cannot represent, the visitor should fail explicitly instead of hiding it inside a top-level raw-text query node.
These constraints keep the query contract stable while the lower layers continue to expand.
The primary dialect-mode entry is GQLParserUtils::parseStatement, used by ParserGQLQuery. It invokes the grammar wrapper gqlStatement, which is defined as statement SEMICOLON* EOF, and then returns the inner statement parse-tree node to the visitor. This keeps complete-input validation in the grammar while preserving the existing visitor contract.
The inner statement rule is a superset that includes query, DML, and catalog DDL alternatives, so Dialect::gql validates the main parser-only path with one normalized pipeline.
ASTPtr parseDialectGQL(std::string_view query)
{
OPENGQL::GQLParseTreeVisitor visitor;
auto * parse_tree = OPENGQL::GQLParserUtils::parseStatement(query);
return std::any_cast<ASTPtr>(visitor.visit(parse_tree));
}Ordinary ClickHouse ParserQuery does not auto-detect GQL. GQL text is not routed through a prefix-sniffing fallback in ClickHouse mode, so inputs such as MATCH ... RETURN ..., graph-shaped SELECT, focused USE, and GQL CALL must fail as ClickHouse queries unless the session has selected Dialect::gql.
Query, DML, and catalog DDL are covered through ParserGQLQuery in explicit Dialect::gql mode, where the whole input span is routed to parseStatement without competing with normal ClickHouse SQL prefixes. The GQL driver does not use ClickHouse's SQL lexer / tryParseQuery path, because valid GQL tokens and grammar shapes can be rejected before ANTLR sees them.
Dialect::gql is now wired into the existing dialect mechanism alongside kusto, prql, and promql. The integration consists of:
- Setting:
allow_experimental_gql_dialect(defaultfalse,EXPERIMENTALtier). When the dialect isgqlbut this flag is off, all dispatch points throwSUPPORT_IS_DISABLED. - Alias:
query_languageis a settings-level alias fordialect(declared viaDECLARE_WITH_ALIASinSettings.cpp). BothSET dialect = 'gql'andSET query_language = 'gql'route to the sameDialectenum and the same parser dispatch.query_languageis not an independent setting; it resolves todialectthrough the standardBaseSettingsalias mechanism. - Parser wrapper:
ParserGQLQuery(src/Parsers/graph/ParserGQLQuery.h/cpp) is a small dialect-specific parser class, not anIParserBaseimplementation. It accepts the caller-provided query text span, trims surrounding ASCII whitespace, and unconditionally routes the complete GQL statement throughGQLParserUtils::parseStatement. It does not passthrough ClickHouseSETstatements; session changes such asSET dialect = 'gql'are parsed by the normal ClickHouse parser before dispatch entersDialect::gql. - Server dispatch (
executeQueryImpl): aDialect::gqlbranch before the fallbackParserQuery. - Client dispatch (
ClientBase::parseQuery,LocalConnection): matchingDialect::gqlbranches using the sameParserGQLQuery.
Under dialect = gql (equivalently query_language = gql), the top-level entry uses gqlStatement -> statement EOF through GQLParserUtils::parseStatement, so query, DML, and catalog DDL statements share the same normalized GQL AST pipeline. Under dialect = clickhouse, ClickHouse ParserQuery remains responsible for SQL compatibility and does not produce GQL* AST nodes. This keeps language selection explicit and avoids growing prefix sniffing into a permanent mixed-parser architecture. The spelling query_language is now a settings-level alias for dialect, so parser dispatch has a single source of truth.
Known limitations of the current skeleton:
- Distributed / remote-node queries (
HedgedConnections,MultiplexedConnections) reset foreign dialects toclickhousebefore forwarding, but do not propagategqlto remote shards. Distributed GQL execution is not a usable path until GQL-to-SQL rewrite exists. - Interpreter / planner is only partially wired:
GQLSingleQueryandGQLCombinedQueryenterInterpreterGQLQuery, but unsupported clause, expression, catalog, storage, and distributed shapes still fail closed with explicit unsupported exceptions. - Client multi-statement splitting is not implemented for
Dialect::gql: the current GQL driver expects the caller-provided span to contain one completegqlStatement. Inputs with multiple statements are rejected by the grammar-level EOF wrapper rather than split by the ClickHouse SQL token driver.
The current parser work therefore focuses on making:
GQL text -> antlr4 parse tree -> normalized GQL IAST
as complete and stable as possible before widening the top-level routing rules in ParserQuery.
The AST contract should keep obvious graph, catalog, expression, and type structure in typed AST nodes instead of storing source slices. The parser layer now uses two complementary representations:
- Semantically identical ClickHouse SQL expression pieces should be built through ClickHouse-native AST nodes where practical, such as
ASTIdentifier,ASTLiteral,ASTFunction, andASTExpressionList. The graph visitor exposes helper constructors for this boundary so future migration is incremental instead of ad hoc. - GQL-specific syntax stays in
GQL*nodes: property access, graph / binding-table expressions, path / value queries, pattern trees, type predicates,GQLTypeExpression, andGQLGraphTypeSpecification. - Typed declarations in
LET VALUE, nested procedure binding definitions,CAST, andIS TYPEDuseGQLTypeExpressioninstead of raw type strings. - Nested graph-type specifications in catalog DDL use
GQLGraphTypeSpecification/GQLElementTypeSpecificationinstead ofGQLCatalogStatement::source_text. - Plain literal tokens can still stay as parser leaves when no catalog, graph-reference, expression, or type structure is lost.
New or changed AST nodes should preserve a dense non-null children list, deep-copy all owned children in clone, and produce normalized formatAST output that can be reparsed through ParserGQLQuery / parseStatement.
antlr4 syntax and lexer errors are translated into ClickHouse exceptions with existing error codes such as SYNTAX_ERROR.
The current rule of thumb is:
- use
GQLParserUtilsforSLL -> LLfallback and listener wiring; - use existing ClickHouse error codes;
- keep feature gaps explicit with clear
Unsupported GQL ...exceptions while the AST layer is still expanding.
The useful tests at this phase are shape tests for the normalized AST contract:
- query-root tests: verify whether a query returns
GQLSingleQuery,GQLCombinedQuery, orGQLSubquery; - clause-order tests: verify that linear queries preserve clause order inside
GQLSingleQuery; - wrapper tests: verify that top-level graph
SELECTnormalizes toGQLSelectClauseplus optionalGQLPageClause,{ ... }keeps aGQLSubquerywrapper, and weak top-level prefixes still fall back to plain SQL when they are not graph-shaped; - path tests: verify that parenthesized and simplified path primaries keep their own AST families, that classic
|/|+|alternation preservesGQLPathPatternAlternation, and that outer?/{m,n}quantifiers attach to either the native primary orGQLQuantifiedPathPrimaryinstead of being folded into an edge node; - pattern and expression tests: verify the current structured coverage, including aggregate
DISTINCT/ALL, counted path prefixes, dynamic/special value primaries,TRIMshape/spec assertions, andDURATION_BETWEENqualifier assertions, and make raw-text fallbacks explicit where they still exist.