返回博客

源码精读:OQL Parser — 从文本到执行计划

OQL(Ontology Query Language)是 coomia-dip 的自研查询语言,其解析器完全手写——没有使用 ANTLR 或 JavaCC 等解析器生成器。解析流水线分为三层:OQLParserService(入口门面)→ OQLLexer(词法分析)→ OQLParser(递归下降语法分析),最终输出 OQLQuery AST。词法分析器支持 30+ 关键字、6 种 Token 类型(标识符、字符串、整数、浮点数、运算符、参数占位符),语法分析器实现完整的 SELECT-FROM-WHERE-GROUP BY-ORDER BY-LIMIT 语法,并扩展了 SIMILARTO(向量搜索)、CONNECTEDTO(图遍历)、METRIC(指标引用)三种领域特定函数。本文将逐行剖析词法分析器的状态机、递归下降解析器的产生式实现、AST 的 Record 模型设计,以及错误恢复策略。

Coomia发布于 2025年12月5日12 分钟阅读
分享本文Twitter / X

源码精读:OQL Parser — 从文本到执行计划

系列:S9 源码精读 · 第 6 篇 | 难度:高级 | 阅读时间:25 分钟

#TL;DR

OQL(Ontology Query Language)是 coomia-dip 的自研查询语言,其解析器完全手写——没有使用 ANTLR 或 JavaCC 等解析器生成器。解析流水线分为三层:OQLParserService(入口门面)→ OQLLexer(词法分析)→ OQLParser(递归下降语法分析),最终输出 OQLQuery AST。词法分析器支持 30+ 关键字、6 种 Token 类型(标识符、字符串、整数、浮点数、运算符、参数占位符),语法分析器实现完整的 SELECT-FROM-WHERE-GROUP BY-ORDER BY-LIMIT 语法,并扩展了 SIMILAR_TO(向量搜索)、CONNECTED_TO(图遍历)、METRIC(指标引用)三种领域特定函数。本文将逐行剖析词法分析器的状态机、递归下降解析器的产生式实现、AST 的 Record 模型设计,以及错误恢复策略。

#目录

  1. 三层解析架构
  2. 入口门面:OQLParserService
  3. 词法分析器:OQLLexer
  4. Token 类型与关键字表
  5. 字符串字面量与转义处理
  6. 数值识别:整数与浮点的区分
  7. 运算符的多字符前瞻
  8. 注释跳过:行注释与块注释
  9. 递归下降解析器:OQLParser
  10. AST 模型:OQLQuery Record
  11. Key Takeaways

#1. 三层解析架构

Code
OQL 文本 → OQLParserService → OQLLexer → List<TokenInfo>
                                           ↓
                            OQLParser → OQLQuery (AST)
                                           ↓
                            QueryOptimizer → PhysicalPlan
Code
data-Layer/src/main/java/com/onto/data/query/
├── parser/
│   ├── OQLParserService.java    # 入口门面:输入验证 + 组装
│   ├── OQLLexer.java            # 手写词法分析器
│   ├── OQLParser.java           # 递归下降语法分析器
│   ├── OQLToken.java            # Token 枚举
│   └── OQLErrorListener.java    # 错误监听器
├── ast/
│   ├── OQLQuery.java            # AST 根节点(Record)
│   ├── SelectClause.java        # SELECT 子句
│   ├── FromClause.java          # FROM 子句
│   ├── WhereClause.java         # WHERE 子句
│   ├── Projection.java          # 投影(字段/聚合/相似度/指标)
│   ├── GroupByClause.java       # GROUP BY 子句
│   ├── OrderByClause.java       # ORDER BY 子句
│   └── LimitClause.java         # LIMIT/OFFSET 子句
├── ast/condition/
│   ├── Condition.java           # 条件接口
│   ├── ComparisonCondition.java # 比较条件(=, !=, >, <, >=, <=)
│   ├── LogicalCondition.java    # 逻辑条件(AND, OR, NOT)
│   ├── InCondition.java         # IN / NOT IN
│   ├── LikeCondition.java       # LIKE 模式匹配
│   ├── NullCondition.java       # IS NULL / IS NOT NULL
│   ├── RelationCondition.java   # HAS 关系条件
│   ├── GraphCondition.java      # CONNECTED_TO 图条件
│   └── VectorCondition.java     # SIMILAR_TO 向量条件
└── exception/
    ├── OQLSyntaxException.java  # 语法错误(含行列号)
    └── OQLSemanticException.java # 语义错误

#2. 入口门面:OQLParserService

Java
@ApplicationScoped
public class OQLParserService {

    private static final int MAX_QUERY_LENGTH = 10000;

    public OQLQuery parse(String oql) {
        // 1. 输入验证
        validateInput(oql);

        // 2. 词法分析
        var lexer = new OQLLexer(oql);
        var tokens = lexer.tokenize();

        // 3. 语法分析
        var parser = new OQLParser(tokens);
        var ast = parser.parse();

        return ast;
    }

    private void validateInput(String oql) {
        if (oql == null || oql.isBlank()) {
            throw new OQLSyntaxException("OQL query cannot be null or empty");
        }
        if (oql.length() > MAX_QUERY_LENGTH) {
            throw new OQLSyntaxException(
                    "OQL query exceeds maximum length of 10000 characters");
        }
    }
}

门面模式的价值OQLParserService 隐藏了"先 Lexer 后 Parser"的两步细节。调用者只需 parser.parse(oql) 一行代码。MAX_QUERY_LENGTH = 10000 防止超长查询导致的 OOM 和 ReDoS 攻击。

#3. 词法分析器:OQLLexer

Java
public class OQLLexer {

    private final String input;
    private int pos;
    private int line;
    private int column;

    public OQLLexer(String input) {
        this.input = input;
        this.pos = 0;
        this.line = 1;
        this.column = 1;
    }

    public List<TokenInfo> tokenize() {
        var tokens = new ArrayList<TokenInfo>();

        while (pos < input.length()) {
            skipWhitespaceAndComments();
            if (pos >= input.length()) break;

            var token = nextToken();
            if (token != null) {
                tokens.add(token);
            }
        }

        tokens.add(new TokenInfo(OQLToken.EOF, "", line, column));
        return tokens;
    }
}

状态机设计pos(当前位置)、line(行号)、column(列号)三个状态变量跟踪扫描进度。每个 Token 都记录了精确的行列位置,为语法错误的精确定位提供基础。

#3.1 Token 分发器

Java
private TokenInfo nextToken() {
    char c = current();

    if (c == '\'')                              return readString(...);
    if (Character.isDigit(c) || (c == '-' && ...)) return readNumber(...);
    if (Character.isLetter(c) || c == '_')      return readIdentifier(...);
    if (c == '$')                               return readParameter(...);
    return readOperatorOrSymbol(...);
}

五路分发基于首字符类型:单引号 → 字符串,数字/负号 → 数值,字母/下划线 → 标识符/关键字,$ → 参数占位符,其他 → 运算符/符号。

#4. Token 类型与关键字表

Java
private static final Map<String, OQLToken> KEYWORDS = Map.ofEntries(
    Map.entry("SELECT", OQLToken.SELECT),
    Map.entry("DISTINCT", OQLToken.DISTINCT),
    Map.entry("FROM", OQLToken.FROM),
    Map.entry("WHERE", OQLToken.WHERE),
    Map.entry("AND", OQLToken.AND),
    Map.entry("OR", OQLToken.OR),
    Map.entry("NOT", OQLToken.NOT),
    Map.entry("IN", OQLToken.IN),
    Map.entry("LIKE", OQLToken.LIKE),
    Map.entry("IS", OQLToken.IS),
    Map.entry("NULL", OQLToken.NULL),
    // ... 共 30+ 关键字
    Map.entry("CONNECTED_TO", OQLToken.CONNECTED_TO),
    Map.entry("SIMILARITY", OQLToken.SIMILARITY),
    Map.entry("METRIC", OQLToken.METRIC),
    Map.entry("MATCH", OQLToken.MATCH),
    Map.entry("SHORTEST_PATH", OQLToken.SHORTEST_PATH),
    Map.entry("GRAPH_TABLE", OQLToken.GRAPH_TABLE)
);

关键字识别策略:先读取完整标识符,再用大写形式在 KEYWORDS Map 中查找。这意味着 OQL 是大小写不敏感的——selectSELECTSelect 都合法。

三类领域扩展关键字

  • 向量搜索SIMILARITY
  • 图遍历CONNECTED_TO, MATCH, SHORTEST_PATH, GRAPH_TABLE
  • 指标引用METRIC

#5. 字符串字面量与转义处理

Java
private TokenInfo readString(int startLine, int startColumn) {
    advance(); // skip opening quote
    var sb = new StringBuilder();

    while (pos < input.length()) {
        char c = current();
        if (c == '\'') {
            if (pos + 1 < input.length() && input.charAt(pos + 1) == '\'') {
                // 转义引号:'' → '
                sb.append('\'');
                advance();
                advance();
            } else {
                // 字符串结束
                advance();
                break;
            }
        } else {
            sb.append(c);
            advance();
        }
    }

    return new TokenInfo(OQLToken.STRING, sb.toString(), startLine, startColumn);
}

SQL 风格转义:OQL 使用 ''(两个单引号)表示字面量单引号,与 SQL 标准一致。例如 'O''Brien' 解析为字符串 O'Brien

#6. 数值识别:整数与浮点的区分

Java
private TokenInfo readNumber(int startLine, int startColumn) {
    var sb = new StringBuilder();

    if (current() == '-') { sb.append('-'); advance(); }

    while (pos < input.length() && Character.isDigit(current())) {
        sb.append(current()); advance();
    }

    // 检查小数点
    if (pos < input.length() && current() == '.'
            && pos + 1 < input.length()
            && Character.isDigit(input.charAt(pos + 1))) {
        sb.append('.'); advance();
        while (pos < input.length() && Character.isDigit(current())) {
            sb.append(current()); advance();
        }
        return new TokenInfo(OQLToken.NUMBER, sb.toString(), ...);
    }

    return new TokenInfo(OQLToken.INTEGER, sb.toString(), ...);
}

INTEGER vs NUMBER:词法分析器在 Token 级别就区分了整数和浮点数——42INTEGER3.14NUMBER。这为后续语法分析中的类型推断提供了基础。

前瞻检查current() == '.' && ... && Character.isDigit(input.charAt(pos + 1)) 确保 . 后面必须跟数字才算小数点——否则 42.field 中的 . 会被误判为小数点。

#7. 运算符的多字符前瞻

Java
private TokenInfo readOperatorOrSymbol(int startLine, int startColumn) {
    char c = current();
    switch (c) {
        case '<':
            if (peek() == '=') { advance(); advance(); return tokenInfo(OQLToken.LTE); }
            if (peek() == '>') { advance(); advance(); return tokenInfo(OQLToken.NEQ); }
            advance(); return tokenInfo(OQLToken.LT);
        case '>':
            if (peek() == '=') { advance(); advance(); return tokenInfo(OQLToken.GTE); }
            advance(); return tokenInfo(OQLToken.GT);
        case '!':
            if (peek() == '=') { advance(); advance(); return tokenInfo(OQLToken.NEQ); }
            advance(); return tokenInfo(OQLToken.UNKNOWN);
        case '.':
            if (peek() == '.') { advance(); advance(); return tokenInfo(OQLToken.DOTDOT); }
            advance(); return tokenInfo(OQLToken.DOT);
    }
}

一字符前瞻< 可能是 <(LT)、<=(LTE)或 <>(NEQ),需要前瞻一个字符才能确定。同样 . 可能是属性访问(DOT)或范围运算符(DOTDOT ..)。

#8. 注释跳过:行注释与块注释

Java
private void skipWhitespaceAndComments() {
    while (pos < input.length()) {
        char c = current();

        if (Character.isWhitespace(c)) { /* skip */ continue; }

        // 行注释:-- 到行尾
        if (c == '-' && pos + 1 < input.length() && input.charAt(pos + 1) == '-') {
            while (pos < input.length() && current() != '\n') pos++;
            continue;
        }

        // 块注释:/* ... */
        if (c == '/' && pos + 1 < input.length() && input.charAt(pos + 1) == '*') {
            pos += 2;
            while (pos + 1 < input.length()) {
                if (current() == '*' && input.charAt(pos + 1) == '/') {
                    pos += 2; break;
                }
                pos++;
            }
            continue;
        }
        break;
    }
}

OQL 支持 SQL 风格的两种注释:--(行注释)和 /* */(块注释)。

#9. 递归下降解析器:OQLParser

#9.1 顶层语法

Java
public OQLQuery parse() {
    var builder = OQLQuery.builder();
    builder.select(parseSelectClause());    // SELECT(必需)
    builder.from(parseFromClause());        // FROM(必需)
    if (check(OQLToken.WHERE))   builder.where(parseWhereClause());
    if (check(OQLToken.GROUP))   builder.groupBy(parseGroupByClause());
    if (check(OQLToken.ORDER))   builder.orderBy(parseOrderByClause());
    if (check(OQLToken.LIMIT))   builder.limit(parseLimitClause());
    expect(OQLToken.EOF, "end of query");
    return builder.build();
}

语法结构SELECTFROM 是必需的,WHEREGROUP BYORDER BYLIMIT 是可选的——通过 check() 前瞻判断是否存在。

#9.2 条件解析:优先级层级

Java
condition       → parseOrCondition()
orCondition     → andCondition (OR andCondition)*
andCondition    → notCondition (AND notCondition)*
notCondition    → NOT? primaryCondition
primaryCondition → LPAREN condition RPAREN
                 | HAS relationCondition
                 | CONNECTED_TO graphCondition
                 | SIMILARITY vectorCondition
                 | field IS [NOT] NULL
                 | field [NOT] IN (values)
                 | field LIKE pattern
                 | field op value

优先级通过调用层级实现OR < AND < NOT < 基本条件。例如 a = 1 AND b = 2 OR c = 3 被解析为 (a = 1 AND b = 2) OR c = 3

#9.3 投影解析:四种投影类型

Java
private Projection parseProjection() {
    if (check(OQLToken.STAR))           return Projection.wildcard();
    if (isAggregateFunction(current())) return parseAggregateProjection();
    if (check(OQLToken.SIMILARITY))     return parseSimilarityProjection();
    if (check(OQLToken.METRIC))         return parseMetricProjection();
    // 字段投影
    String fieldPath = parseFieldPath();
    String alias = check(OQLToken.AS) ? expect(IDENTIFIER).value() : null;
    return Projection.field(fieldPath, alias);
}
投影类型示例解析方法
通配符*Projection.wildcard()
聚合函数COUNT(*), SUM(amount)parseAggregateProjection()
相似度SIMILARITY(embedding, [0.1, 0.2])parseSimilarityProjection()
指标引用METRIC('revenue') AS revparseMetricProjection()
字段name, attributes.roleProjection.field()

#9.4 参数化查询

Java
private Object parseValue() {
    return switch (token.type()) {
        case STRING    -> token.value();
        case NUMBER    -> Double.parseDouble(token.value());
        case INTEGER   -> Long.parseLong(token.value());
        case TRUE      -> true;
        case FALSE     -> false;
        case NULL      -> null;
        case PARAMETER -> new ParameterPlaceholder(token.value());
        default -> throw error("value");
    };
}

public record ParameterPlaceholder(String name) {}

$paramName 语法支持参数化查询,防止 OQL 注入。ParameterPlaceholder 在执行阶段被实际值替换。

#10. AST 模型:OQLQuery Record

Java
public record OQLQuery(
    SelectClause select,
    FromClause from,
    Optional<WhereClause> where,
    Optional<GroupByClause> groupBy,
    Optional<OrderByClause> orderBy,
    Optional<LimitClause> limit
) {
    public boolean isAggregateQuery() {
        return select.hasAggregation() || groupBy.isPresent();
    }

    public OQLQuery withSelect(SelectClause newSelect) {
        return new OQLQuery(newSelect, from, where, groupBy, orderBy, limit);
    }
}

Record 的不可变性:Java Record 确保 AST 一旦构建就不可修改。withSelect() 方法返回新的 AST 实例——这是函数式不可变更新模式,用于 MetricProjectionRewriter 等查询重写场景。

聚合查询判定isAggregateQuery() 检查 SELECT 子句中是否有聚合函数,或是否存在 GROUP BY——这影响后续的优化策略和缓存策略。

#11. Key Takeaways

  1. 手写词法分析器:不依赖 ANTLR/JavaCC,OQLLexer 使用状态机模式逐字符扫描,支持 30+ 关键字和精确行列位置追踪
  2. 递归下降解析器OQLParser 通过调用层级实现运算符优先级(OR < AND < NOT < 基本条件),无需优先级表
  3. 大小写不敏感:标识符转大写后在关键字表中查找,selectSELECT 等价
  4. INTEGER vs NUMBER:词法级别区分整数和浮点数,为类型推断提供基础
  5. SQL 风格注释:支持 --(行注释)和 /* */(块注释)
  6. 参数化查询$paramName 语法生成 ParameterPlaceholder,防止 OQL 注入
  7. 不可变 AST:使用 Java Record + Optional 组合,withSelect() 实现函数式更新
  8. 领域扩展SIMILAR_TOCONNECTED_TOMETRIC 三个领域特定函数扩展了标准 SQL 语法

#下一篇

S9-07:AnalyticsQueryService — 14 种聚合的实现。我们将深入 Data Layer 的分析查询服务,看它如何通过 SQL Builder 模式实现分组聚合、时间桶聚合、TopN 和分布查询。

Tags: #coomia-dip #source-code-reading #data-Layer #oql #lexer #parser #ast #recursive-descent #query-language