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 * &nbsp;&nbsp;execute immediate 'DROP TABLE MYTABLE';<br>
057 * &nbsp;&nbsp;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}