SqlTokenizer.java
/*
* SPDX-FileCopyrightText: 2025 kaumei.io
* SPDX-License-Identifier: Apache-2.0
*/
package io.kaumei.jdbc.anno.utils;
import org.jspecify.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
import static java.util.Objects.requireNonNull;
/// Examples:
/// ```
/// :user.addresses[0].city
/// :user.addresses[0].*
/// :user.addresses[0].{values}
/// :user.addresses[0].{names}
/// ```
public class SqlTokenizer {
// @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 SqlTokenizer.name(root,path).toString();
}
@Override
public String toString() {
return name();
}
}
public record AllValues(String root, List<PathSegment> path) implements Parameter {
public String name() {
return SqlTokenizer.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 SqlTokenizer.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;
}
// ------------------------------------------------------------------------
public static List<Token> parse(SqlDV str) {
return new SqlTokenizer(str.value()).parse();
}
// ------------------------------------------------------------------------
private final String str;
private final int strLength;
private char lastChar = 0;
private int currentIndex = -1;
private char currentChar = 0;
private char lookahead;
SqlTokenizer(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.lastChar = this.currentChar;
this.currentChar = this.lookahead;
this.lookahead = this.currentIndex + 1 < this.strLength ? this.str.charAt(this.currentIndex + 1) : 0;
return this.currentIndex < this.strLength;
}
List<Token> parse() {
var list = new ArrayList<Token>();
int lastIndex = 0;
while (next()) {
if (currentChar == '?') {
if (lastIndex < currentIndex) {
list.add(new TextToken(str.substring(lastIndex, currentIndex)));
}
list.add(new UnnamedParameter());
lastIndex = currentIndex + 1;
} else if (currentChar == '\'' && lastChar != '\\') {
while (next()) {
if (currentChar == '\'' && lastChar != '\\') {
break;
}
}
} else if (currentChar == '\"' && lastChar != '\\') {
while (next()) {
if (currentChar == '\"' && lastChar != '\\') {
break;
}
}
} else if (currentChar == ':') {
int colonIndex = currentIndex;
if (next()) {
var param = afterColon();
if (param != null) {
if (lastIndex < colonIndex) {
list.add(new TextToken(str.substring(lastIndex, colonIndex)));
}
list.add(param);
lastIndex = currentIndex + 1;
}
}
}
}
if (lastIndex < currentIndex) {
list.add(new TextToken(str.substring(lastIndex)));
}
return list;
}
/// ```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(lookahead)) {
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();
}
}