001package io.ebean.migration.ddl;
002
003import java.io.BufferedReader;
004import java.io.IOException;
005import java.io.Reader;
006import java.util.ArrayList;
007import java.util.List;
008
009/**
010 * Parses string content into separate SQL/DDL statements.
011 */
012public class DdlParser {
013
014  private final DdlAutoCommit ddlAutoCommit;
015
016  private final StatementsSeparator parse = new StatementsSeparator();
017
018  private final List<String> statements = new ArrayList<>();
019  private final List<String> statementsNonTrans = new ArrayList<>();
020
021  public DdlParser(DdlAutoCommit ddlAutoCommit) {
022    this.ddlAutoCommit = ddlAutoCommit;
023  }
024
025  void push(String sql) {
026    if (ddlAutoCommit.transactional(sql)) {
027      statements.add(sql);
028    } else {
029      statementsNonTrans.add(sql);
030    }
031  }
032
033  /**
034   * Break up the sql in reader into a list of statements using the semi-colon and $$ delimiters;
035   */
036  public List<String> parse(Reader reader) {
037    try {
038      BufferedReader br = new BufferedReader(reader);
039      String s;
040      while ((s = br.readLine()) != null) {
041        parse.nextLine(s);
042      }
043      parse.endOfContent();
044      return statements;
045    } catch (IOException e) {
046      throw new DdlRunnerException(e);
047    }
048  }
049
050  /**
051   * Return the non-transactional statements which run later with auto commit true.
052   */
053  public List<String> getNonTransactional() {
054    return statementsNonTrans;
055  }
056
057  /**
058   * Local utility used to detect the end of statements / separate statements.
059   * This is often just the semicolon character but for trigger/procedures this
060   * detects the $$ demarcation used in the history DDL generation for MySql and
061   * Postgres.
062   */
063  class StatementsSeparator {
064
065    private static final String EOL = "\n";
066
067    private static final String GO = "GO";
068    private static final String PROCEDURE = " PROCEDURE ";
069
070    private boolean trimDelimiter;
071    private boolean inDbProcedure;
072
073    private StringBuilder sb = new StringBuilder();
074
075    private int lineCount;
076    private int quoteCount;
077
078    void lineContainsDollars(String line) {
079      if (inDbProcedure) {
080        if (trimDelimiter) {
081          line = line.replace("$$", "");
082        }
083        endOfStatement(line);
084      } else {
085        // MySql style delimiter needs to be trimmed/removed
086        trimDelimiter = line.equals("delimiter $$");
087        if (!trimDelimiter) {
088          sb.append(line).append(EOL);
089        }
090        inDbProcedure = true;
091      }
092    }
093
094    void endOfStatement(String line) {
095      // end of Db procedure
096      sb.append(line);
097      push(sb.toString().trim());
098      newBuffer();
099    }
100
101    private void newBuffer() {
102      quoteCount = 0;
103      lineCount = 0;
104      inDbProcedure = false;
105      sb = new StringBuilder();
106    }
107
108    /**
109     * Process the next line of the script.
110     */
111    void nextLine(String line) {
112
113      if (line.trim().equals(GO)) {
114        endOfStatement("");
115        return;
116      }
117
118      if (line.contains("$$")) {
119        lineContainsDollars(line);
120        return;
121      }
122
123      if (inDbProcedure) {
124        sb.append(line).append(EOL);
125        return;
126      }
127
128      if (sb.length() == 0 && (line.isEmpty() || line.startsWith("--"))) {
129        // ignore leading empty lines and sql comments
130        return;
131      }
132
133      if (lineCount == 0 && isStartDbProcedure(line)) {
134        inDbProcedure = true;
135      }
136
137      lineCount++;
138      quoteCount += countQuotes(line);
139      if (hasOddQuotes()) {
140        // must continue
141        sb.append(line).append(EOL);
142        return;
143      }
144
145      int semiPos = line.lastIndexOf(';');
146      if (semiPos == -1) {
147        sb.append(line).append(EOL);
148
149      } else if (semiPos == line.length() - 1) {
150        // semicolon at end of line
151        endOfStatement(line);
152
153      } else {
154        // semicolon in middle of line
155        String remaining = line.substring(semiPos + 1).trim();
156        if (!remaining.startsWith("--")) {
157          // remaining not an inline sql comment so keep going ...
158          sb.append(line).append(EOL);
159          return;
160        }
161
162        String preSemi = line.substring(0, semiPos + 1);
163        endOfStatement(preSemi);
164      }
165    }
166
167    /**
168     * Return true if the start of DB procedure is detected.
169     */
170    private boolean isStartDbProcedure(String line) {
171      return line.length() > 26 && line.substring(0, 26).toUpperCase().contains(PROCEDURE);
172    }
173
174    /**
175     * Return true if the count of quotes is odd.
176     */
177    private boolean hasOddQuotes() {
178      return quoteCount % 2 == 1;
179    }
180
181    /**
182     * Return the count of single quotes in the content.
183     */
184    private int countQuotes(String content) {
185      int count = 0;
186      for (int i = 0; i < content.length(); i++) {
187        if (content.charAt(i) == '\'') {
188          count++;
189        }
190      }
191      return count;
192    }
193
194    /**
195     * Append trailing non-terminated content as an extra statement.
196     */
197    void endOfContent() {
198      String remaining = sb.toString().trim();
199      if (remaining.length() > 0) {
200        push(remaining);
201        newBuffer();
202      }
203    }
204  }
205}