ParsedSql.java
/*
* SPDX-FileCopyrightText: 2026 kaumei.io
* SPDX-License-Identifier: Apache-2.0
*/
package io.kaumei.jdbc.anno.utils;
import org.jspecify.annotations.Nullable;
import java.util.*;
import static java.util.Objects.requireNonNull;
public class ParsedSql implements Iterable<ParsedSql.Token> {
public static ParsedSql of(SqlDV str) {
return new Parser(str.value()).parse();
}
// @formatter:off
public sealed interface Token permits TextToken, UnnamedParameter, Parameter { }
// A simple text part of the SQL.
public record TextToken(String sql) implements Token { }
// A simple ? of the SQL
public record UnnamedParameter() implements Token { }
// path to the object to use
public sealed interface PathSegment permits Property, Index { }
public record Property(String name) implements PathSegment { }
public record Index(int index) implements PathSegment { }
public sealed interface Parameter extends Token permits SingleParameter, AllValues, AllNames {
String root();
List<PathSegment> path();
String name();
}
public record SingleParameter(String root, List<PathSegment> path) implements Parameter {
public String name() {
return ParsedSql.name(root,path).toString();
}
@Override
public String toString() {
return name();
}
}
public record AllValues(String root, List<PathSegment> path) implements Parameter {
public String name() {
return ParsedSql.name(root,path).append(".{values}").toString();
}
@Override
public String toString() {
return name();
}
}
public record AllNames(String root, List<PathSegment> path) implements Parameter {
public String name() {
return ParsedSql.name(root,path).append(".{names}").toString();
}
@Override
public String toString() {
return name();
}
}
// @formatter:on
private static StringBuilder name(String root, List<PathSegment> path) {
var sb = new StringBuilder(root);
for (var p : path) {
if (p instanceof Index index) {
sb.append("[").append(index.index).append("]");
} else if (p instanceof Property property) {
sb.append(".").append(property.name);
}
}
return sb;
}
// ------------------------------------------------------------------------
private final List<Token> list;
private final Map<Token, Integer> token2index;
private final Map<String, List<Token>> name2tokens;
ParsedSql(List<Token> list, Map<Token, Integer> token2index, Map<String, List<Token>> name2tokens) {
this.list = list;
this.token2index = token2index;
this.name2tokens = name2tokens;
}
public boolean containsName(String name) {
return name2tokens.containsKey(name);
}
public List<Token> parametersByName(String name) {
return requireNonNull(name2tokens.get(name));
}
public int parameterIndexOf(Token parameter) {
return requireNonNull(token2index.get(parameter));
}
@Override
public Iterator<Token> iterator() {
return this.list.iterator();
}
// ------------------------------------------------------------------------
/// Examples:
/// ```
/// :user.addresses[0].city
/// :user.addresses[0].*
/// :user.addresses[0].{values}
/// :user.addresses[0].{names}
/// ```
static class Parser {
private final String str;
private final int strLength;
private int currentIndex = -1;
private char currentChar = 0;
private char lookahead;
Parser(String str) {
this.str = requireNonNull(str);
this.strLength = str.length();
this.lookahead = this.strLength > 0 ? this.str.charAt(0) : 0;
}
private boolean isLast() {
return this.lookahead == 0;
}
/// package visibility for testing only
int currentIndex() {
return currentIndex;
}
/// switch to the next char. return false if the string is at the end
/// package visibility for testing only
boolean next() {
this.currentIndex++;
this.currentChar = this.lookahead;
this.lookahead = this.currentIndex + 1 < this.strLength ? this.str.charAt(this.currentIndex + 1) : 0;
return this.currentIndex < this.strLength;
}
ParsedSql parse() {
var list = new ArrayList<Token>();
var token2index = new IdentityHashMap<Token, Integer>();
var name2token = new HashMap<String, List<Token>>();
int lastIndex = 0;
while (next()) {
if (currentChar == '?') {
if (lastIndex < currentIndex) {
list.add(new TextToken(str.substring(lastIndex, currentIndex)));
}
var parameter = new UnnamedParameter();
list.add(parameter);
token2index.put(parameter, token2index.size() + 1);
lastIndex = currentIndex + 1;
} else if (currentChar == '\'' || currentChar == '\"' || currentChar == '`') {
skipQuoted(currentChar);
} else if (currentChar == ':') {
int colonIndex = currentIndex;
if (next()) {
var param = afterColon();
if (param instanceof Parameter p) {
if (lastIndex < colonIndex) {
list.add(new TextToken(str.substring(lastIndex, colonIndex)));
}
list.add(param);
token2index.put(param, token2index.size() + 1);
name2token.computeIfAbsent(p.name(), n -> new ArrayList<>()).add(p);
lastIndex = currentIndex + 1;
}
}
}
}
if (lastIndex < currentIndex) {
list.add(new TextToken(str.substring(lastIndex)));
}
return new ParsedSql(list, token2index, name2token);
}
private void skipQuoted(char quote) {
while (next()) {
if (currentChar == quote) {
if (lookahead == quote) {
next();
} else {
break;
}
}
}
}
/// ```java
/// parameterRef ::= ':' identifier path? terminal?
/// path ::= (index | segment)+
/// index :: = '[' digit+ ']'
/// segment ::= '.' identifier
/// terminal ::= '.*' | '.{' meta '}'
/// meta ::= 'names' | 'values'
/// identifier ::= JavaIdentifierStart JavaIdentifierPart*
/// integer ::= digit+
/// ```
/// package visibility for testing only
@Nullable Token afterColon() {
if (!Character.isJavaIdentifierStart(currentChar)) {
return null;
}
var root = parseIdentifier();
List<PathSegment> path = new ArrayList<>();
while ((lookahead == '[' || lookahead == '.') && next()) {
if (currentChar == '[') {
if (Character.isDigit(lookahead) && next()) {
var digit = parseDigit();
if (!next() || currentChar != ']') {
return null;
}
path.add(new Index(digit));
} else {
return null;
}
} else if (currentChar == '.') {
if (Character.isJavaIdentifierStart(lookahead) && next()) {
var segment = parseIdentifier();
path.add(new Property(segment));
} else if (lookahead == '*' && next()) {
return new AllValues(root, path);
} else if (lookahead == '{' && next()) {
if (lookahead == 'n' && tokens('n', 'a', 'm', 'e', 's') && next() && currentChar == '}') {
return new AllNames(root, path);
} else if (lookahead == 'v' && tokens('v', 'a', 'l', 'u', 'e', 's') && next() && currentChar == '}') {
return new AllValues(root, path);
} else {
return null;
}
} else {
return null;
}
} else {
break;
}
} // for
return new SingleParameter(root, path);
}
private boolean tokens(char... c) {
for (char value : c) {
if (lookahead != value || !next()) {
return false;
}
}
return true;
}
/// package visibility for testing only
String parseIdentifier() {
if (Character.isJavaIdentifierStart(currentChar)) {
var startIndex = currentIndex;
while (Character.isJavaIdentifierPart(lookahead) && !isLast()) {
if (!next()) { // sanity-check
throw new IllegalStateException(); // sanity-check
}
}
return this.str.substring(startIndex, currentIndex + 1);
}
throw new IllegalStateException();
}
/// package visibility for testing only
Integer parseDigit() {
if (Character.isDigit(currentChar)) {
var startIndex = currentIndex;
while (Character.isDigit(lookahead)) {
if (!next()) { // sanity-check
throw new IllegalStateException(); // sanity-check
}
}
return Integer.parseInt(this.str, startIndex, currentIndex + 1, 10);
}
throw new IllegalStateException();
}
}
}