SqlParser.java

/*
 * Copyright 2004-2010 the Seasar Foundation and the Others.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
 * either express or implied. See the License for the specific language
 * governing permissions and limitations under the License.
 */
package com.github.mygreen.splate.parser;

import java.util.Stack;

import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.ParseException;

import com.github.mygreen.splate.SqlUtils;
import com.github.mygreen.splate.node.BeginNode;
import com.github.mygreen.splate.node.BindVariableNode;
import com.github.mygreen.splate.node.ContainerNode;
import com.github.mygreen.splate.node.ElseNode;
import com.github.mygreen.splate.node.EmbeddedValueNode;
import com.github.mygreen.splate.node.IfNode;
import com.github.mygreen.splate.node.Node;
import com.github.mygreen.splate.node.ParenBindVariableNode;
import com.github.mygreen.splate.node.PrefixSqlNode;
import com.github.mygreen.splate.node.SqlNode;
import com.github.mygreen.splate.parser.SqlTokenizer.TokenType;

/**
 * SQLを解析して<code>Node</code>のツリーにするクラスです。
 *
 * @version 0.2
 * @author higa
 */
public class SqlParser {

    /**
     * SQLテンプレートの字句解析処理。
     */
    private final SqlTokenizer tokenizer;

    /**
     * EL式のパーサ
     */
    private final ExpressionParser expressionParser;

    private final Stack<Node> nodeStack = new Stack<>();

    /**
     * {@link SqlParser}を作成します。
     *
     * @param sql SQL
     * @param expressionParser {@literal IF}コメント名の式を処理するためのEL式のパーサです。
     */
    public SqlParser(final String sql,final ExpressionParser expressionParser) {
        this.tokenizer = new SqlTokenizer(normalizeSql(sql));
        this.expressionParser = expressionParser;
    }

    /**
     * パース対象のSQLをトリムなど行い正規化します。
     * @param sql パース対象のSQL
     * @return 正規化したSQL
     */
    protected String normalizeSql(final String sql) {
        String result = sql;
        result = result.strip();
        if (result.endsWith(";")) {
            result = result.substring(0, result.length() - 1);
        }

        return result;
    }

    /**
     * 解析対象のSQLを取得します。
     * @return 正規化(トリム、最後のセミコロン(;)削除)を返します。
     */
    public String getSql() {
        return tokenizer.getSql();
    }

    /**
     *
     * @return SQLを解析して<code>Node</code>のツリーを返します。
     */
    public Node parse() {
        push(new ContainerNode(0));
        while (TokenType.EOF != tokenizer.next()) {
            parseToken();
        }
        return pop();
    }

	/**
     * トークンを解析します。
     */
    protected void parseToken() {
        switch (tokenizer.getTokenType()) {
        case SQL:
            parseSql();
            break;
        case COMMENT:
            parseComment();
            break;
        case ELSE:
            parseElse();
            break;
        case BIND_VARIABLE:
            parseBindVariable();
            break;
        default:
            break;
        }
    }

    /**
     * SQLを解析します。
     */
    protected void parseSql() {
        String sql = tokenizer.getToken();
        if (isElseMode()) {
            sql = SqlUtils.replace(sql, "--", "");
        }
        Node node = peek();
        if ((node instanceof IfNode || node instanceof ElseNode)
                && node.getChildSize() == 0) {

            SqlTokenizer st = new SqlTokenizer(sql);
            st.skipWhitespace();
            String token = st.skipToken();
            st.skipWhitespace();
            if (sql.startsWith(",")) {
                if (sql.startsWith(", ")) {
                    node.addChild(new PrefixSqlNode(tokenizer.getPosition(), ", ", sql.substring(2)));
                } else {
                    node.addChild(new PrefixSqlNode(tokenizer.getPosition(), ",", sql.substring(1)));
                }
            } else if ("AND".equalsIgnoreCase(token)
                    || "OR".equalsIgnoreCase(token)) {
                node.addChild(new PrefixSqlNode(tokenizer.getPosition(), st.getBefore(), st.getAfter()));
            } else {
                node.addChild(new SqlNode(tokenizer.getPosition(), sql));
            }
        } else {
            node.addChild(new SqlNode(tokenizer.getPosition(), sql));
        }
    }

    /**
     * コメントを解析します。
     */
    protected void parseComment() {
        String comment = tokenizer.getToken();
        if (isTargetComment(comment)) {
            if (isIfComment(comment)) {
                parseIf();
            } else if (isBeginComment(comment)) {
                parseBegin();
            } else if (isEndComment(comment)) {
                return;
            } else {
                parseCommentBindVariable();
            }
        } else if(isHintComment(comment)){
            peek().addChild(new SqlNode(tokenizer.getPosition(), "/*" + comment + "*/"));
        }
    }

    /**
     * {@code IF} 句を解析します。
     */
    protected void parseIf() {
        final String condition = tokenizer.getToken().substring(2).trim();
        final int position = Math.max(tokenizer.getPosition() - 2 - condition.length(), 0);
        if (SqlUtils.isEmpty(condition)) {
            throw new SqlParseException(SqlUtils.resolveSqlPosition(tokenizer.getSql(), position),
                    "Not found IF condition.");
        }
        IfNode ifNode = new IfNode(position, condition, parseExpression(condition, position));
        peek().addChild(ifNode);
        push(ifNode);
        parseEnd();
    }

    /**
     * {@code BEGIN} 句を解析します。
     */
    protected void parseBegin() {
        BeginNode beginNode = new BeginNode(tokenizer.getPosition());
        peek().addChild(beginNode);
        push(beginNode);
        parseEnd();
    }

    /**
     * {@code END} 句を解析します。
     */
    protected void parseEnd() {
        while (TokenType.EOF != tokenizer.next()) {
            if (tokenizer.getTokenType() == TokenType.COMMENT
                    && isEndComment(tokenizer.getToken())) {

                pop();
                return;
            }
            parseToken();
        }
        throw new SqlParseException(SqlUtils.resolveSqlPosition(tokenizer.getSql(), tokenizer.getPosition()),
                "Not found END comment.");
    }

    /**
     * {@code ELSE} 句を解析します。
     */
    protected void parseElse() {
        Node parent = peek();
        if (!(parent instanceof IfNode)) {
            return;
        }
        IfNode ifNode = (IfNode) pop();
        ElseNode elseNode = new ElseNode(tokenizer.getPosition());
        ifNode.setElseNode(elseNode);
        push(elseNode);
        tokenizer.skipWhitespace();
    }

    /**
     * バインド変数コメントを解析します。
     */
    protected void parseCommentBindVariable() {
        String expr = tokenizer.getToken();
        String s = tokenizer.skipToken();
        int position = tokenizer.getPosition() - s.length() - expr.length() -2;
        if (s.startsWith("(") && s.endsWith(")")) {
            peek().addChild(new ParenBindVariableNode(position, expr, parseExpression(expr, position)));
        } else if (expr.startsWith("$")) {
            expr = expr.substring(1);
            position += 1;
            peek().addChild(new EmbeddedValueNode(position, expr, parseExpression(expr, position)));
        } else {
            peek().addChild(new BindVariableNode(position, expr, parseExpression(expr, position)));
        }
    }

    /**
     * バインド変数を解析します。
     */
    protected void parseBindVariable() {
        String expr = tokenizer.getToken();
        int position = tokenizer.getPosition();
        peek().addChild(new BindVariableNode(position, expr, parseExpression(expr, position)));
    }

    /**
     * EL式をパースします。
     * <p>例外処理を含めて共通化のために切り出したメソッドです。</p>
     *
     * @since 0.2
     * @param expression 式
     * @param position テンプレート位置
     * @return パースした式
     * @throws SqlParseException EL式のパースに失敗した場合にスローされます。
     */
    protected Expression parseExpression(final String expression, final int position) {
        try {
            return expressionParser.parseExpression(expression);
        } catch(ParseException e) {
            throw new SqlParseException(SqlUtils.resolveSqlPosition(tokenizer.getSql(), position),
                    String.format("Fail parsing expression '%s'.", expression),
                    e);
        }
    }

    /**
     * Pop (remove from the stack) the top node.
     *
     * @return the top node.
     */
    /**
     * 一番上のノードをポップ(スタックからも取り出す)します。
     *
     * @return 一番上のノード
     */
    protected Node pop() {
        return nodeStack.pop();
    }

    /**
     * 一番上のノードを返します。
     *
     * @return 一番上のノード
     */
    protected Node peek() {
        return nodeStack.peek();
    }

    /**
     * ノードを一番上に追加します。
     *
     * @param node ノード
     */
    protected void push(Node node) {
        nodeStack.push(node);
    }

    /**
     * {@code ELSE} モード(ELSE句の中の)かどうかを返します。
     *
     * @return {@code ELSE} モードのとき {@literal true} を返します。
     */
    protected boolean isElseMode() {
        for (Node node : nodeStack) {
            if (node instanceof ElseNode) {
                return true;
            }
        }
        return false;
    }

    /**
     * 対象とするコメントかどうかを返します。
     * バインド変数のコメントかどうかの判定に使用します。
     *
     * @param comment コメント
     * @return 対象とするコメントのとき {@literal true} を返します。
     */
    protected static boolean isTargetComment(String comment) {
        return comment != null && comment.length() > 0
                && Character.isJavaIdentifierStart(comment.charAt(0));
    }

    /**
     * Checks if this comment is a "Mirage-SQL" <code>IF</code> keyword.
     *
     * @param comment the comment to check
     * @return <code>true</code> if this comment is an <code>IF</code> keyword.
     */

    /**
     * {@literal IF} コメントかどうか判定します。
     *
     * @param comment コメント
     * @return {@literal IF} コメントのとき {@literal true} を返します。
     */
    protected static boolean isIfComment(String comment) {
        return comment.startsWith("IF");
    }

    /**
     * {@literal BEGIN} コメントかどうか判定します。
     *
     * @param comment コメント
     * @return {@literal BEGIN} コメントのとき {@literal true} を返します。
     */
    protected static boolean isBeginComment(String comment) {
        return comment != null && "BEGIN".equals(comment);
    }

    /**
     * {@literal END} コメントかどうか判定します。
     *
     * @param comment コメント
     * @return {@literal END} コメントのとき {@literal true} を返します。
     */
    protected static boolean isEndComment(String comment) {
        return comment != null && "END".equals(comment);
    }

    /**
     * Oracle のヒントコメントかどうか判定します。
     *
     * @param comment コメント
     * @return Oracle のヒントコメントのとき {@literal true} を返します。
     */
    protected static boolean isHintComment(String comment) {
        return comment != null && comment.startsWith("+");
    }
}