001/* 002 * Copyright 2016 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.ametys.core.script; 017 018import java.io.IOException; 019import java.io.InputStream; 020import java.io.LineNumberReader; 021import java.io.StringReader; 022import java.sql.Connection; 023import java.sql.DatabaseMetaData; 024import java.sql.ResultSet; 025import java.sql.SQLException; 026import java.sql.Statement; 027import java.time.Instant; 028import java.util.Map; 029 030import org.apache.commons.io.IOUtils; 031import org.apache.excalibur.source.Source; 032import org.apache.excalibur.source.SourceResolver; 033import org.slf4j.Logger; 034import org.slf4j.LoggerFactory; 035 036import org.ametys.core.datasource.ConnectionHelper; 037import org.ametys.core.migration.MigrationException; 038import org.ametys.core.migration.MigrationExtensionPoint; 039import org.ametys.core.migration.configuration.impl.SqlVersionConfiguration; 040import org.ametys.core.migration.storage.impl.SqlVersionStorage; 041import org.ametys.core.migration.version.Version; 042import org.ametys.core.migration.version.impl.SqlVersion; 043 044/** 045 * Example of simple use: 046 * SQLScriptHelper.createTableIfNotExists(dataSourceId, "QRTZ_JOB_DETAILS", "plugin:core://scripts/%s/quartz.sql", _sourceResolver); 047 * Will test if table QRTZ_JOB_DETAILS exits in database from datasource dataSourceId. If not, the script plugin:core://scripts/%s/quartz.sql will be resolved and executed (where %s is replaced by the database type 'mysql', 'derby'...) 048 * 049 * Tools to run SQL scripts.<p> 050 * Default separator for isolating statements is the semi colon 051 * character: <code>;</code>.<br> 052 * It can be changed by using a comment like the following snippet 053 * for using the string <code>---</code>:<br> 054 * <code>-- _separator_=---<br> 055 * begin<br> 056 * execute immediate 'DROP TABLE MYTABLE';<br> 057 * Exception when others then null;<br> 058 * end;<br> 059 * ---<br> 060 * -- _separator_=;<br> 061 * CREATE TABLE MYTABLE;<br> 062 * ...</code><br> 063 * Note that the command must be placed at the end of the comment.<br><br> 064 * The runner can be configured to ignore SQLExceptions. This can be useful 065 * to execute DROP statements when it's unknown if the tables exist:<br> 066 * <code>--_ignore_exceptions_=on<br> 067 * DROP TABLE MYTABLE;<br> 068 * --_ignore_exceptions_=off</code> 069 */ 070public final class SQLScriptHelper 071{ 072 /** Default separator used for isolating statements. */ 073 public static final String DEFAULT_SEPARATOR = ";"; 074 /** Command to ignore sql exceptions. */ 075 public static final String IGNORE_EXCEPTIONS_COMMAND = "_ignore_exceptions_="; 076 /** Command to change the separator. */ 077 public static final String CHANGE_SEPARATOR_COMMAND = "_separator_="; 078 /** Logger available to subclasses. */ 079 protected static final Logger __LOGGER = LoggerFactory.getLogger(SQLScriptHelper.class); 080 081 private SQLScriptHelper() 082 { 083 // Nothing to do 084 } 085 086 /** 087 * This method will test if a table exists, and if not will execute a script to create it 088 * @param datasourceId The data source id to open a connection to the database 089 * @param tableNameToCheck The name of the table that will be checked 090 * @param location The source location where to find the script to execute to create the table. This string will be format with String.format with the dbType as argument. 091 * @param sourceResolver The source resolver 092 * @return true if the table was created, false otherwise 093 * @throws SQLException If an error occurred while executing SQL script, or while testing table existence 094 * @throws IOException If an error occurred while getting the script file, or if the url is malformed 095 */ 096 public static boolean createTableIfNotExists(String datasourceId, String tableNameToCheck, String location, SourceResolver sourceResolver) throws SQLException, IOException 097 { 098 return createTableIfNotExists(datasourceId, tableNameToCheck, location, sourceResolver, null); 099 } 100 101 /** 102 * This method will test if a table exists, and if not will execute a script to create it 103 * @param datasourceId The data source id to open a connection to the database 104 * @param tableNameToCheck The name of the table that will be checked 105 * @param location The source location where to find the script to execute to create the table. This string will be format with String.format with the dbType as argument. 106 * @param sourceResolver The source resolver 107 * @param replace The map of string to replace. Key is the regexp to seek, value is the replacing string. 108 * @return true if the table was created, false otherwise 109 * @throws SQLException If an error occurred while executing SQL script, or while testing table existence 110 * @throws IOException If an error occurred while getting the script file, or if the url is malformed 111 */ 112 public static boolean createTableIfNotExists(String datasourceId, String tableNameToCheck, String location, SourceResolver sourceResolver, Map<String, String> replace) throws SQLException, IOException 113 { 114 Connection connection = null; 115 try 116 { 117 connection = ConnectionHelper.getConnection(datasourceId); 118 119 return createTableIfNotExists(connection, tableNameToCheck, location, sourceResolver, replace); 120 } 121 finally 122 { 123 ConnectionHelper.cleanup(connection); 124 } 125 } 126 127 /** 128 * This method will test if a table exists, and if not will execute a script to create it 129 * @param connection The database connection to use 130 * @param tableNameToCheck The name of the table that will be checked 131 * @param location The source location where to find the script to execute to create the table. This string will be format with String.format with the dbType as argument. 132 * @param sourceResolver The source resolver 133 * @return true if the table was created, false otherwise 134 * @throws SQLException If an error occurred while executing SQL script, or while testing table existence 135 * @throws IOException If an error occurred while getting the script file, or if the url is malformed 136 */ 137 public static boolean createTableIfNotExists(Connection connection, String tableNameToCheck, String location, SourceResolver sourceResolver) throws SQLException, IOException 138 { 139 return createTableIfNotExists(connection, tableNameToCheck, location, sourceResolver, null); 140 } 141 142 /** 143 * This method will test if a table exists, and if not will execute a script to create it 144 * @param connection The database connection to use 145 * @param tableNameToCheck The name of the table that will be checked 146 * @param location The source location where to find the script to execute to create the table. This string will be format with String.format with the dbType as argument. 147 * @param sourceResolver The source resolver 148 * @param replace The map of string to replace. Key is the regexp to seek, value is the replacing string. 149 * @return true if the table was created, false otherwise 150 * @throws SQLException If an error occurred while executing SQL script, or while testing table existence 151 * @throws IOException If an error occurred while getting the script file, or if the url is malformed 152 */ 153 public static boolean createTableIfNotExists(Connection connection, String tableNameToCheck, String location, SourceResolver sourceResolver, Map<String, String> replace) throws SQLException, IOException 154 { 155 if (tableExists(connection, tableNameToCheck)) 156 { 157 return false; 158 } 159 160 String finalLocation = String.format(location, ConnectionHelper.getDatabaseType(connection)); 161 162 Source source = null; 163 try 164 { 165 source = sourceResolver.resolveURI(finalLocation); 166 167 try (InputStream is = source.getInputStream()) 168 { 169 String script = IOUtils.toString(is, "UTF-8"); 170 171 if (replace != null) 172 { 173 for (String replaceKey : replace.keySet()) 174 { 175 script = script.replaceAll(replaceKey, replace.get(replaceKey)); 176 } 177 } 178 179 SQLScriptHelper.runScript(connection, script); 180 } 181 } 182 finally 183 { 184 sourceResolver.release(source); 185 } 186 187 return true; 188 } 189 190 /** 191 * This method will test if a table exists, and if not will execute a script to create it 192 * @param datasourceId The data source id to open a connection to the database 193 * @param tableNameToCheck The name of the table that will be checked 194 * @param location The source location where to find the script to execute to create the table. This string will be format with String.format with the dbType as argument. 195 * @param sourceResolver The source resolver 196 * @param replace The map of string to replace. Key is the regexp to seek, value is the replacing string. 197 * @param componentId component used to get the most recent version number 198 * @param versionId version id that will be stored in database (can be the same as componentId) 199 * @param migrationEP Migration Extension Point used to read the version 200 * @param sqlVersionStorage SQJ Version Storage used to store the version 201 * @return true if the table was created, false otherwise 202 * @throws SQLException If an error occurred while executing SQL script, or while testing table existence 203 * @throws IOException If an error occurred while getting the script file, or if the url is malformed 204 */ 205 public static boolean createTableIfNotExists(String datasourceId, String tableNameToCheck, String location, SourceResolver sourceResolver, Map<String, String> replace, 206 String componentId, String versionId, MigrationExtensionPoint migrationEP, SqlVersionStorage sqlVersionStorage) throws SQLException, IOException 207 { 208 Connection connection = null; 209 try 210 { 211 connection = ConnectionHelper.getConnection(datasourceId); 212 213 if (createTableIfNotExists(connection, tableNameToCheck, location, sourceResolver, replace)) 214 { 215 SqlVersionConfiguration config = new SqlVersionConfiguration(datasourceId); 216 Version currentVersion = sqlVersionStorage.getCurrentVersion(versionId, config, "sql"); 217 String versionNumber = migrationEP.getExpectedVersionForComponent(componentId); 218 if (currentVersion == null || currentVersion.getVersionNumber() == null || !currentVersion.getVersionNumber().equals(versionNumber)) 219 { 220 String databaseType = ConnectionHelper.getDatabaseType(connection); 221 SqlVersion version = new SqlVersion("script", versionId, versionNumber, Instant.now(), "Initialization", datasourceId, databaseType); 222 sqlVersionStorage.addVersion(version); 223 } 224 225 return true; 226 } 227 } 228 catch (MigrationException e) 229 { 230 __LOGGER.error("The tables " + tableNameToCheck + " was created but the version table was not, you might restart the application to solve this.", e); 231 } 232 finally 233 { 234 ConnectionHelper.cleanup(connection); 235 } 236 237 return false; 238 } 239 240 /** 241 * Checks whether the given table exists in the database. 242 * @param connection The database connection 243 * @param tableName the name of the table 244 * @return true is the table exists 245 * @throws SQLException In an SQL exception occurs 246 */ 247 public static boolean tableExists(Connection connection, String tableName) throws SQLException 248 { 249 ResultSet rs = null; 250 251 DatabaseMetaData metaData = connection.getMetaData(); 252 253 // Test for non escaped table names 254 String name = tableName; 255 if (metaData.storesLowerCaseIdentifiers()) 256 { 257 name = tableName.toLowerCase(); 258 } 259 else if (metaData.storesUpperCaseIdentifiers()) 260 { 261 name = tableName.toUpperCase(); 262 } 263 264 try 265 { 266 rs = metaData.getTables(connection.getCatalog(), connection.getSchema(), name, null); 267 if (rs.next()) 268 { 269 return true; 270 } 271 } 272 finally 273 { 274 ConnectionHelper.cleanup(rs); 275 } 276 277 // Test for escaped table names 278 String quotedName = tableName; 279 if (metaData.storesLowerCaseQuotedIdentifiers()) 280 { 281 quotedName = tableName.toLowerCase(); 282 } 283 else if (metaData.storesUpperCaseQuotedIdentifiers()) 284 { 285 quotedName = tableName.toUpperCase(); 286 } 287 288 if (!quotedName.equals(name)) 289 { 290 try 291 { 292 rs = metaData.getTables(connection.getCatalog(), connection.getSchema(), quotedName, null); 293 if (rs.next()) 294 { 295 return true; 296 } 297 } 298 finally 299 { 300 ConnectionHelper.cleanup(rs); 301 } 302 } 303 304 return false; 305 } 306 307 /** 308 * Run a SQL script using the connection passed in. 309 * @param connection the connection to use for the script 310 * @param script the script data. 311 * @throws IOException if an error occurs while reading the script. 312 * @throws SQLException if an error occurs while executing the script. 313 */ 314 public static void runScript(Connection connection, String script) throws IOException, SQLException 315 { 316 ScriptContext scriptContext = new ScriptContext(); 317 StringBuilder command = new StringBuilder(); 318 319 try 320 { 321 LineNumberReader lineReader = new LineNumberReader(new StringReader(script)); 322 String line = null; 323 while ((line = lineReader.readLine()) != null) 324 { 325 if (__LOGGER.isDebugEnabled()) 326 { 327 __LOGGER.debug(String.format("Reading line: '%s'", line)); 328 } 329 330 boolean processCommand = false; 331 String trimmedLine = line.trim(); 332 333 if (trimmedLine.length() > 0) 334 { 335 processCommand = processScriptLine(trimmedLine, command, scriptContext); 336 337 if (processCommand) 338 { 339 _processCommand(connection, command, lineReader.getLineNumber(), scriptContext); 340 } 341 } 342 } 343 344 // If the entire file was processed and the command buffer is not empty, execute the current buffer. 345 if (command.length() > 0) 346 { 347 _processCommand(connection, command, lineReader.getLineNumber(), scriptContext); 348 } 349 350 if (!connection.getAutoCommit()) 351 { 352 connection.commit(); 353 } 354 } 355 finally 356 { 357 if (!connection.getAutoCommit()) 358 { 359 try 360 { 361 // Fermer la connexion à la base 362 connection.rollback(); 363 } 364 catch (SQLException s) 365 { 366 __LOGGER.error("Error while rollbacking connection", s); 367 } 368 } 369 } 370 } 371 372 /** 373 * Run a SQL script using the connection passed in. 374 * @param connection the connection to use for the script 375 * @param is the input stream containing the script data. 376 * @throws IOException if an error occurs while reading the script. 377 * @throws SQLException if an error occurs while executing the script. 378 */ 379 public static void runScript(Connection connection, InputStream is) throws IOException, SQLException 380 { 381 try 382 { 383 String script = IOUtils.toString(is, "UTF-8"); 384 runScript(connection, script); 385 } 386 finally 387 { 388 IOUtils.closeQuietly(is); 389 } 390 } 391 392 /** 393 * Process a script line. 394 * @param line the line to process. 395 * @param commandBuffer the command buffer. 396 * @param scriptContext the script execution context. 397 * @return true to immediately process the command (a separator was found), false to process it later. 398 */ 399 protected static boolean processScriptLine(String line, StringBuilder commandBuffer, ScriptContext scriptContext) 400 { 401 boolean processCommand = false; 402 403 if (line.startsWith("//") || line.startsWith("--")) 404 { 405 String currentSeparator = scriptContext.getSeparator(); 406 407 // Search if the separator needs to be changed 408 if (line.contains(CHANGE_SEPARATOR_COMMAND)) 409 { 410 // New separator 411 String newSeparator = line.substring(line.indexOf(CHANGE_SEPARATOR_COMMAND) 412 + CHANGE_SEPARATOR_COMMAND.length()).trim(); 413 414 scriptContext.setSeparator(newSeparator); 415 416 if (__LOGGER.isDebugEnabled()) 417 { 418 __LOGGER.debug(String.format("Changing separator to: '%s'", newSeparator)); 419 } 420 } 421 else if (line.contains(IGNORE_EXCEPTIONS_COMMAND)) 422 { 423 String ignoreStr = line.substring(line.indexOf(IGNORE_EXCEPTIONS_COMMAND) 424 + IGNORE_EXCEPTIONS_COMMAND.length()).trim(); 425 426 boolean ignoreExceptions = "on".equals(ignoreStr); 427 428 scriptContext.setIgnoreExceptions(ignoreExceptions); 429 430 if (__LOGGER.isDebugEnabled()) 431 { 432 __LOGGER.debug(String.format("Ignore exceptions: '%s'", ignoreExceptions ? "on" : "off")); 433 } 434 } 435 436 if (line.contains(currentSeparator)) 437 { 438 if (commandBuffer.length() > 0) 439 { 440 // End of command but do not use current line 441 processCommand = true; 442 } 443 } 444 } 445 else if (line.endsWith(scriptContext.getSeparator())) 446 { 447 // End of command and use current line 448 processCommand = true; 449 commandBuffer.append(line.substring(0, line.lastIndexOf(scriptContext.getSeparator()))); 450 } 451 else 452 { 453 // Append current command to the buffer 454 commandBuffer.append(line); 455 commandBuffer.append(" "); 456 } 457 458 return processCommand; 459 } 460 461 private static void _processCommand(Connection connection, StringBuilder command, int lineNumber, ScriptContext scriptContext) throws SQLException 462 { 463 if (__LOGGER.isInfoEnabled()) 464 { 465 __LOGGER.info(String.format("Executing SQL command: '%s'", command)); 466 } 467 468 _execute(connection, command.toString(), lineNumber, scriptContext); 469 470 // Clear command 471 command.setLength(0); 472 } 473 474 private static void _execute(Connection connection, String command, int lineNumber, ScriptContext scriptContext) throws SQLException 475 { 476 Statement statement = null; 477 try 478 { 479 statement = connection.createStatement(); 480 statement.execute(command); 481 } 482 catch (SQLException e) 483 { 484 if (!scriptContext.ignoreExceptions()) 485 { 486 String message = String.format("Unable to execute SQL: '%s' at line %d", command, lineNumber); 487 __LOGGER.error(message, e); 488 489 throw new SQLException(message, e); 490 } 491 } 492 finally 493 { 494 ConnectionHelper.cleanup(statement); 495 } 496 } 497 498 /** 499 * Script execution context. 500 */ 501 protected static class ScriptContext 502 { 503 504 /** The current script execution block separator. */ 505 protected String _separator; 506 507 /** True to ignore sql exceptions. */ 508 protected boolean _ignoreExceptions; 509 510 /** 511 * Default ScriptContext object. 512 */ 513 public ScriptContext() 514 { 515 this(DEFAULT_SEPARATOR, false); 516 } 517 518 /** 519 * Build a ScriptContext object. 520 * @param separator the separator 521 * @param ignoreExceptions true to ignore exceptions. 522 */ 523 public ScriptContext(String separator, boolean ignoreExceptions) 524 { 525 this._separator = separator; 526 this._ignoreExceptions = ignoreExceptions; 527 } 528 529 /** 530 * Get the separator. 531 * @return the separator 532 */ 533 public String getSeparator() 534 { 535 return _separator; 536 } 537 538 /** 539 * Set the separator. 540 * @param separator the separator to set 541 */ 542 public void setSeparator(String separator) 543 { 544 this._separator = separator; 545 } 546 547 /** 548 * Get the ignoreExceptions. 549 * @return the ignoreExceptions 550 */ 551 public boolean ignoreExceptions() 552 { 553 return _ignoreExceptions; 554 } 555 556 /** 557 * Set the ignoreExceptions. 558 * @param ignoreExceptions the ignoreExceptions to set 559 */ 560 public void setIgnoreExceptions(boolean ignoreExceptions) 561 { 562 this._ignoreExceptions = ignoreExceptions; 563 } 564 565 } 566 567}