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();
    }
}