ScriptUtils.java 13.8 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375
/*
 * Copyright 2002-2014 the original author or authors.
 *
 * 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 org.springframework.jdbc.datasource.init;

import java.io.IOException;
import java.io.LineNumberReader;
import java.sql.SQLException;
import java.util.LinkedList;
import java.util.List;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.core.io.support.EncodedResource;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.BadSqlGrammarException;
import org.springframework.util.StringUtils;


/**
 * Generic utility methods for working with SQL scripts. Mainly for 
 * internal use within the framework.
 * 
 * @author Chris Baldwin
 * @since 4.0.3
 */
public abstract class ScriptUtils {

	public static final String DEFAULT_COMMENT_PREFIX = "--";

	public static final char DEFAULT_STATEMENT_SEPARATOR_CHAR = ';';
	
	public static final String DEFAULT_STATEMENT_SEPARATOR = String.valueOf(DEFAULT_STATEMENT_SEPARATOR_CHAR);
	
	public static final String DEFAULT_BLOCK_COMMENT_START_DELIMITER = "/*";
	
	public static final String DEFAULT_BLOCK_COMMENT_END_DELIMITER = "*/";

	private static final Log logger = LogFactory.getLog(ScriptUtils.class);
	
	/**
	 * Split an SQL script into separate statements delimited by the provided
	 * delimiter character. Each individual statement will be added to the
	 * provided {@code List}.
	 * <p>Within a statement, "{@code --}" will be used as the comment prefix;
	 * any text beginning with the comment prefix and extending to the end of
	 * the line will be omitted from the statement. In addition, multiple adjacent
	 * whitespace characters will be collapsed into a single space.
	 * @param script the SQL script
	 * @param delim character delimiting each statement &mdash; typically a ';' character
	 * @param statements the list that will contain the individual statements
	 * @since 4.0.3
	 */
	public static void splitSqlScript(String script, char delim, List<String> statements) {
		splitSqlScript(script, String.valueOf(delim), DEFAULT_COMMENT_PREFIX, DEFAULT_BLOCK_COMMENT_START_DELIMITER,
				DEFAULT_BLOCK_COMMENT_END_DELIMITER, statements);
	}

	/**
	 * Split an SQL script into separate statements delimited by the provided
	 * delimiter string. Each individual statement will be added to the provided
	 * {@code List}.
	 * <p>Within a statement, the provided {@code commentPrefix} will be honored;
	 * any text beginning with the comment prefix and extending to the end of the
	 * line will be omitted from the statement. In addition, multiple adjacent
	 * whitespace characters will be collapsed into a single space.
	 * @param script the SQL script
	 * @param delim character delimiting each statement &mdash; typically a ';' character
	 * @param commentPrefix the prefix that identifies line comments in the SQL script &mdash; typically "--"
	 * @param statements the List that will contain the individual statements
	 * @since 4.0.3
	 */
	public static void splitSqlScript(String script, String delim, String commentPrefix, String blockCommentOpen,
			String blockCommentClose, List<String> statements) {
		StringBuilder sb = new StringBuilder();
		boolean inLiteral = false;
		boolean inEscape = false;
		char[] content = script.toCharArray();
		for (int i = 0; i < script.length(); i++) {
			char c = content[i];
			if (inEscape) {
				inEscape = false;
				sb.append(c);
				continue;
			}
			// MySQL style escapes
			if (c == '\\') {
				inEscape = true;
				sb.append(c);
				continue;
			}
			if (c == '\'') {
				inLiteral = !inLiteral;
			}
			if (!inLiteral) {
				if (script.startsWith(delim, i)) {
					// we've reached the end of the current statement
					if (sb.length() > 0) {
						statements.add(sb.toString());
						sb = new StringBuilder();
					}
					i += delim.length() - 1;
					continue;
				}
				else if (script.startsWith(commentPrefix, i)) {
					// skip over any content from the start of the comment to the EOL
					int indexOfNextNewline = script.indexOf("\n", i);
					if (indexOfNextNewline > i) {
						i = indexOfNextNewline;
						continue;
					}
					else {
						// if there's no EOL, we must be at the end
						// of the script, so stop here.
						break;
					}
				}
				else if (script.startsWith(blockCommentOpen, i)) {
					// skip over any block comments
					int indexOfCommentClose = script.indexOf(blockCommentClose, i);
					if (indexOfCommentClose > i) {
						i = indexOfCommentClose + blockCommentClose.length() - 1;
						continue;
					}
					else {
						throw new BadSqlGrammarException("", script.substring(i), 
								new SQLException("Missing block comment end delimiter")); 
					}
				}
				else if (c == ' ' || c == '\n' || c == '\t') {
					// avoid multiple adjacent whitespace characters
					if (sb.length() > 0 && sb.charAt(sb.length() - 1) != ' ') {
						c = ' ';
					}
					else {
						continue;
					}
				}
			}
			sb.append(c);
		}
		if (StringUtils.hasText(sb)) {
			statements.add(sb.toString());
		}
	}

	/**
	 * Read a script from the provided {@code LineNumberReader}, using
	 * "{@code --}" as the comment prefix, and build a {@code String} containing
	 * the lines.
	 * @param lineNumberReader the {@code LineNumberReader} containing the script
	 * to be processed
	 * @return a {@code String} containing the script lines
	 * @see #readScript(LineNumberReader, String, String)
	 * @since 4.0.3
	 */
	public static String readScript(LineNumberReader lineNumberReader) throws IOException {
		return readScript(lineNumberReader, DEFAULT_COMMENT_PREFIX, DEFAULT_STATEMENT_SEPARATOR);
	}

	/**
	 * Read a script from the given resource, using "{@code --}" as the comment prefix 
	 * and "{@code ;} as the statement separator, and build a String containing the lines.
	 * @param resource the {@code EncodedResource} to be read
	 * @return {@code String} containing the script lines
	 * @throws IOException in case of I/O errors
	 * @since 4.0.3
	 */
	public static String readScript(EncodedResource resource) throws IOException {
		LineNumberReader lnr = new LineNumberReader(resource.getReader());
		try {
			return readScript(lnr, DEFAULT_COMMENT_PREFIX, DEFAULT_STATEMENT_SEPARATOR);
		}
		finally {
			lnr.close();
		}
	}
	
	/**
	 * Read a script from the provided resource, using the supplied
	 * comment prefix and statement separator, and build a {@code String} containing the lines.
	 * <p>Lines <em>beginning</em> with the comment prefix are excluded from the
	 * results; however, line comments anywhere else &mdash; for example, within
	 * a statement &mdash; will be included in the results.
	 * @param resource the {@code EncodedResource} containing the script
	 * to be processed
	 * @param commentPrefix the prefix that identifies comments in the SQL script &mdash; typically "--"
	 * @param separator the statement separator in the SQL script &mdash; typically ";"
	 * @return a {@code String} containing the script lines
	 * @since 4.0.3
	 */
	public static String readScript(EncodedResource resource, String commentPrefix, 
			String separator) throws IOException {
		LineNumberReader lnr = new LineNumberReader(resource.getReader());
		try {
			return readScript(lnr, commentPrefix, separator);
		}
		finally {
			lnr.close();
		}
	}
	
	/**
	 * Read a script from the provided {@code LineNumberReader}, using the supplied
	 * comment prefix and statement separator, and build a {@code String} containing the lines.
	 * <p>Lines <em>beginning</em> with the comment prefix are excluded from the
	 * results; however, line comments anywhere else &mdash; for example, within
	 * a statement &mdash; will be included in the results.
	 * @param lineNumberReader the {@code LineNumberReader} containing the script
	 * to be processed
	 * @param commentPrefix the prefix that identifies comments in the SQL script &mdash; typically "--"
	 * @param separator the statement separator in the SQL script &mdash; typically ";"
	 * @return a {@code String} containing the script lines
	 * @since 4.0.3
	 */
	public static String readScript(LineNumberReader lineNumberReader, String commentPrefix, 
			String separator) throws IOException {
		String currentStatement = lineNumberReader.readLine();
		StringBuilder scriptBuilder = new StringBuilder();
		while (currentStatement != null) {
			if (StringUtils.hasText(currentStatement) &&
					(commentPrefix != null && !currentStatement.startsWith(commentPrefix))) {
				if (scriptBuilder.length() > 0) {
					scriptBuilder.append('\n');
				}
				scriptBuilder.append(currentStatement);
			}
			currentStatement = lineNumberReader.readLine();
		}
		maybeAddSeparatorToScript(scriptBuilder, separator);
		return scriptBuilder.toString();
	}

	private static void maybeAddSeparatorToScript(StringBuilder scriptBuilder, String separator) {
		if (separator == null) {
			return;
		}
		String trimmed = separator.trim();
		if (trimmed.length() == separator.length()) {
			return;
		}
		// separator ends in whitespace, so we might want to see if the script is trying
		// to end the same way
		if (scriptBuilder.lastIndexOf(trimmed) == scriptBuilder.length() - trimmed.length()) {
			scriptBuilder.append(separator.substring(trimmed.length()));
		}
	}

	/**
	 * Does the provided SQL script contain the specified delimiter?
	 * @param script the SQL script
	 * @param delim character delimiting each statement - typically a ';' character
	 * @since 4.0.3
	 */
	public static boolean containsSqlScriptDelimiters(String script, char delim) {
		return containsSqlScriptDelimiters(script, String.valueOf(delim));
	}
	
	/**
	 * Does the provided SQL script contain the specified delimiter?
	 * @param script the SQL script
	 * @param delim String delimiting each statement - typically a ';' character
	 * @since 4.0.3
	 */
	public static boolean containsSqlScriptDelimiters(String script, String delim) {
		boolean inLiteral = false;
		char[] content = script.toCharArray();
		for (int i = 0; i < script.length(); i++) {
			if (content[i] == '\'') {
				inLiteral = !inLiteral;
			}
			if (!inLiteral && script.startsWith(delim, i)) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Execute the given SQL script.
	 * <p>The script will normally be loaded by classpath. There should be one statement
	 * per line. Any statement separators will be removed.
	 * <p><b>Do not use this method to execute DDL if you expect rollback.</b>
	 * @param executor the {@code ScriptStatementExecutor} with which to perform JDBC operations
	 * @param resource the resource (potentially associated with a specific encoding) to load the SQL script from
	 * @param continueOnError whether or not to continue without throwing an exception in the event of an error
	 * @param ignoreFailedDrops whether of not to continue in the event of specifically an error on a {@code DROP}
	 * @param commentPrefix the script line comment prefix
	 * if not specified 
	 * @param separator the script statement separator, defaults to {@code DEFAUT_STATEMENT_SEPARATOR}
	 * if not specified 
	 * @param blockCommentStartDelim the script block comment starting delimiter
	 * @param blockCommentEndDelim the script block comment ending delimiter
	 * @since 4.0.3
	 */
	public static void executeSqlScript(ScriptStatementExecutor executor, EncodedResource resource, 
			boolean continueOnError, boolean ignoreFailedDrops, String commentPrefix, String separator,
			String blockCommentStartDelim, String blockCommentEndDelim) throws DataAccessException {

		if (logger.isInfoEnabled()) {
			logger.info("Executing SQL script from " + resource);
		}
		long startTime = System.currentTimeMillis();
		List<String> statements = new LinkedList<String>();
		String script;
		try {
			script = readScript(resource, commentPrefix, separator);
		}
		catch (IOException ex) {
			throw new CannotReadScriptException(resource, ex);
		}
		if (separator == null) {
			separator = DEFAULT_STATEMENT_SEPARATOR;
			if (!containsSqlScriptDelimiters(script, separator)) {
				separator = "\n";
			}
		}
		splitSqlScript(script, separator, commentPrefix, blockCommentStartDelim, blockCommentEndDelim, statements);
		int lineNumber = 0;
		for (String statement : statements) {
			lineNumber++;
			try {
				int rowsAffected = executor.executeScriptStatement(statement);
				if (logger.isDebugEnabled()) {
					logger.debug(rowsAffected + " returned as updateCount for SQL: " + statement);
				}
			}
			catch (DataAccessException ex) {
				boolean dropStatement = StringUtils.startsWithIgnoreCase(statement.trim(), "drop");
				if (continueOnError || (dropStatement && ignoreFailedDrops)) {
					if (logger.isDebugEnabled()) {
						logger.debug("Failed to execute SQL script statement at line " + lineNumber +
								" of resource " + resource + ": " + statement, ex);
					}
				}
				else {
					throw new ScriptStatementFailedException(statement, lineNumber, resource, ex);
				}
			}
		}
		long elapsedTime = System.currentTimeMillis() - startTime;
		if (logger.isInfoEnabled()) {
			logger.info("Done executing SQL script from " + resource + " in " + elapsedTime + " ms.");
		}
	}

	/**
	 * Interface to be implemented by an object so that {@code executeScript()} is able to use 
	 * it to execute script statements.
	 * @since 4.0.3
	 */
	public interface ScriptStatementExecutor
	{
		/**
		 * Execute the given SQL statement and return a count of the number of affected rows.
		 * @return the number of rows affected by the statement
		 * @throws DataAccessException if there is a problem during statement execution
		 */
		public int executeScriptStatement(String statement) throws DataAccessException;
	}

}