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

}