package liquibase.change; import liquibase.configuration.GlobalConfiguration; import liquibase.configuration.LiquibaseConfiguration; import liquibase.database.Database; import liquibase.database.core.MSSQLDatabase; import liquibase.exception.*; import liquibase.logging.LogFactory; import liquibase.statement.SqlStatement; import liquibase.statement.core.RawSqlStatement; import liquibase.util.StringUtils; import java.io.*; import java.util.*; /** * A common parent for all raw SQL related changes regardless of where the sql was sourced from. * * Implements the necessary logic to choose how the SQL string should be parsed to generate the statements. * */ public abstract class AbstractSQLChange extends AbstractChange implements DbmsTargetedChange { private boolean stripComments; private boolean splitStatements; private String endDelimiter; private String sql; private String dbms; protected String encoding = null; protected AbstractSQLChange() { setStripComments(null); setSplitStatements(null); } public InputStream openSqlStream() throws IOException { return null; } @Override @DatabaseChangeProperty(since = "3.0", exampleValue = "h2, oracle") public String getDbms() { return dbms; } @Override public void setDbms(final String dbms) { this.dbms = dbms; } /** * {@inheritDoc} * @param database * @return */ @Override public boolean supports(Database database) { return true; } @Override public Warnings warn(Database database) { return new Warnings(); } @Override public ValidationErrors validate(Database database) { ValidationErrors validationErrors = new ValidationErrors(); if (StringUtils.trimToNull(sql) == null) { validationErrors.addError("'sql' is required"); } return validationErrors; } /** * Return if comments should be stripped from the SQL before passing it to the database. * <p></p> * This will always return a non-null value and should be a boolean rather than a Boolean, but that breaks the Bean Standard. */ @DatabaseChangeProperty(description = "Set to true to remove any comments in the SQL before executing, otherwise false. Defaults to false if not set") public Boolean isStripComments() { return stripComments; } /** * Return true if comments should be stripped from the SQL before passing it to the database. * Passing null sets stripComments to the default value (false). */ public void setStripComments(Boolean stripComments) { if (stripComments == null) { this.stripComments = false; } else { this.stripComments = stripComments; } } /** * Return if the SQL should be split into multiple statements before passing it to the database. * By default, statements are split around ";" and "go" delimiters. * <p></p> * This will always return a non-null value and should be a boolean rather than a Boolean, but that breaks the Bean Standard. */ @DatabaseChangeProperty(description = "Set to false to not have liquibase split statements on ;'s and GO's. Defaults to true if not set") public Boolean isSplitStatements() { return splitStatements; } /** * Set whether SQL should be split into multiple statements. * Passing null sets stripComments to the default value (true). */ public void setSplitStatements(Boolean splitStatements) { if (splitStatements == null) { this.splitStatements = true; } else { this.splitStatements = splitStatements; } } /** * Return the raw SQL managed by this Change */ @DatabaseChangeProperty(serializationType = SerializationType.DIRECT_VALUE) public String getSql() { return sql; } /** * Set the raw SQL managed by this Change. The passed sql is trimmed and set to null if an empty string is passed. */ public void setSql(String sql) { this.sql = StringUtils.trimToNull(sql); } /** * Set the end delimiter used to split statements. Will return null if the default delimiter should be used. * * @see #splitStatements */ @DatabaseChangeProperty(description = "Delimiter to apply to the end of the statement. Defaults to ';', may be set to ''.", exampleValue = "\\nGO") public String getEndDelimiter() { return endDelimiter; } /** * Set the end delimiter for splitting SQL statements. Set to null to use the default delimiter. * @param endDelimiter */ public void setEndDelimiter(String endDelimiter) { this.endDelimiter = endDelimiter; } /** * Calculates the checksum based on the contained SQL. * * @see liquibase.change.AbstractChange#generateCheckSum() */ @Override public CheckSum generateCheckSum() { InputStream stream = null; try { stream = openSqlStream(); String sql = this.sql; if (stream == null && sql == null) { sql = ""; } if (sql != null) { stream = new ByteArrayInputStream(sql.getBytes(LiquibaseConfiguration.getInstance().getConfiguration(GlobalConfiguration.class).getOutputEncoding())); } return CheckSum.compute(new NormalizingStream(this.getEndDelimiter(), this.isSplitStatements(), this.isStripComments(), stream), false); } catch (IOException e) { throw new UnexpectedLiquibaseException(e); } finally { if (stream != null) { try { stream.close(); } catch (IOException e) { LogFactory.getLogger().debug("Error closing stream", e); } } } } /** * Generates one or more SqlStatements depending on how the SQL should be parsed. * If split statements is set to true then the SQL is split and each command is made into a separate SqlStatement. * <p></p> * If stripping comments is true then any comments are removed before the splitting is executed. * The set SQL is passed through the {@link java.sql.Connection#nativeSQL} method if a connection is available. */ @Override public SqlStatement[] generateStatements(Database database) { List<SqlStatement> returnStatements = new ArrayList<SqlStatement>(); String sql = StringUtils.trimToNull(getSql()); if (sql == null) { return new SqlStatement[0]; } String processedSQL = normalizeLineEndings(sql); for (String statement : StringUtils.processMutliLineSQL(processedSQL, isStripComments(), isSplitStatements(), getEndDelimiter())) { if (database instanceof MSSQLDatabase) { statement = statement.replaceAll("\\n", "\r\n"); } String escapedStatement = statement; try { if (database.getConnection() != null) { escapedStatement = database.getConnection().nativeSQL(statement); } } catch (DatabaseException e) { escapedStatement = statement; } returnStatements.add(new RawSqlStatement(escapedStatement, getEndDelimiter())); } return returnStatements.toArray(new SqlStatement[returnStatements.size()]); } @Override public boolean generateStatementsVolatile(Database database) { return false; } @Override public boolean generateRollbackStatementsVolatile(Database database) { return false; } @Override public ChangeStatus checkStatus(Database database) { return new ChangeStatus().unknown("Cannot check raw sql status"); } protected String normalizeLineEndings(String string) { return string.replace("\r", ""); } // @Override // public Set<String> getSerializableFields() { // Set<String> fieldsToSerialize = new HashSet<String>(super.getSerializableFields()); // fieldsToSerialize.add("splitStatements"); // fieldsToSerialize.add("stripComments"); // return Collections.unmodifiableSet(fieldsToSerialize); // } // // @Override // public Object getSerializableFieldValue(String field) { // if (field.equals("splitStatements")) { // return isSplitStatements(); // } // } public static class NormalizingStream extends InputStream { private ByteArrayInputStream headerStream; private PushbackInputStream stream; private byte[] quickBuffer = new byte[100]; private List<Byte> resizingBuffer = new ArrayList<Byte>(); private int lastChar = 'X'; private boolean seenNonSpace = false; public NormalizingStream(String endDelimiter, Boolean splitStatements, Boolean stripComments, InputStream stream) { this.stream = new PushbackInputStream(stream, 2048); try { this.headerStream = new ByteArrayInputStream((endDelimiter+":"+splitStatements+":"+stripComments+":").getBytes(LiquibaseConfiguration.getInstance().getConfiguration(GlobalConfiguration.class).getOutputEncoding())); } catch (UnsupportedEncodingException e) { throw new UnexpectedLiquibaseException(e); } } @Override public int read() throws IOException { if (headerStream != null) { int returnChar = headerStream.read(); if (returnChar != -1) { return returnChar; } headerStream = null; } int returnChar = stream.read(); if (isWhiteSpace(returnChar)) { returnChar = ' '; } while (returnChar == ' ' && (!seenNonSpace || lastChar == ' ')) { returnChar = stream.read(); if (isWhiteSpace(returnChar)) { returnChar = ' '; } } seenNonSpace = true; lastChar = returnChar; if (lastChar == ' ' && isOnlyWhitespaceRemaining()) { return -1; } return returnChar; } @Override public int available() throws IOException { return stream.available(); } @Override public boolean markSupported() { return stream.markSupported(); } @Override public void mark(int readlimit) { stream.mark(readlimit); } @Override public void reset() throws IOException { stream.reset(); } private boolean isOnlyWhitespaceRemaining() throws IOException { try { int quickBufferUsed = 0; while (true) { byte read = (byte) stream.read(); if (quickBufferUsed >= quickBuffer.length) { resizingBuffer.add(read); } else { quickBuffer[quickBufferUsed++] = read; } if (read == -1) { return true; } if (!isWhiteSpace(read)) { if (resizingBuffer.size() > 0) { byte[] buf = new byte[resizingBuffer.size()]; for (int i=0; i< resizingBuffer.size(); i++) { buf[i] = resizingBuffer.get(i); } stream.unread(buf); } stream.unread(quickBuffer, 0, quickBufferUsed); return false; } } } finally { resizingBuffer.clear(); } } private boolean isWhiteSpace(int read) { return read == ' ' || read == '\n' || read == '\r' || read == '\t'; } @Override public void close() throws IOException { stream.close(); } } }