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.util.Map;
028
029import org.apache.commons.io.IOUtils;
030import org.apache.excalibur.source.Source;
031import org.apache.excalibur.source.SourceResolver;
032import org.slf4j.Logger;
033import org.slf4j.LoggerFactory;
034
035import org.ametys.core.datasource.ConnectionHelper;
036
037/**
038 * Example of simple use: 
039 * SQLScriptHelper.createTableIfNotExists(dataSourceId, "QRTZ_JOB_DETAILS", "plugin:core://scripts/%s/quartz.sql", _sourceResolver);
040 * 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'...)
041 * 
042 * Tools to run SQL scripts.<p>
043 * Default separator for isolating statements is the semi colon
044 * character: <code>;</code>.<br>
045 * It can be changed by using a comment like the following snippet
046 * for using the string <code>---</code>:<br>
047 * <code>-- _separator_=---<br>
048 * begin<br>
049 * &nbsp;&nbsp;execute immediate 'DROP TABLE MYTABLE';<br>
050 * &nbsp;&nbsp;Exception when others then null;<br>
051 * end;<br>
052 * ---<br>
053 * -- _separator_=;<br>
054 * CREATE TABLE MYTABLE;<br>
055 * ...</code><br>
056 * Note that the command must be placed at the end of the comment.<br><br>
057 * The runner can be configured to ignore SQLExceptions. This can be useful
058 * to execute DROP statements when it's unknown if the tables exist:<br>
059 * <code>--_ignore_exceptions_=on<br>
060 * DROP TABLE MYTABLE;<br>
061 * --_ignore_exceptions_=off</code>
062 */
063public final class SQLScriptHelper
064{
065    /** Default separator used for isolating statements. */
066    public static final String DEFAULT_SEPARATOR = ";";
067    /** Command to ignore sql exceptions. */
068    public static final String IGNORE_EXCEPTIONS_COMMAND = "_ignore_exceptions_=";
069    /** Command to change the separator. */
070    public static final String CHANGE_SEPARATOR_COMMAND = "_separator_=";
071    /** Logger available to subclasses. */
072    protected static final Logger __LOGGER = LoggerFactory.getLogger(SQLScriptHelper.class);
073    
074    private SQLScriptHelper()
075    {
076        // Nothing to do
077    }
078
079    /**
080     * This method will test if a table exists, and if not will execute a script to create it
081     * @param datasourceId The data source id to open a connection to the database
082     * @param tableNameToCheck The name of the table that will be checked
083     * @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.
084     * @param sourceResolver The source resolver
085     * @return true if the table was created, false otherwise
086     * @throws SQLException If an error occurred while executing SQL script, or while testing table existence
087     * @throws IOException If an error occurred while getting the script file, or if the url is malformed
088     */
089    public static boolean createTableIfNotExists(String datasourceId, String tableNameToCheck, String location, SourceResolver sourceResolver) throws SQLException, IOException
090    {
091        return createTableIfNotExists(datasourceId, tableNameToCheck, location, sourceResolver, null);
092    }
093
094    /**
095     * This method will test if a table exists, and if not will execute a script to create it
096     * @param datasourceId The data source id to open a connection to the database
097     * @param tableNameToCheck The name of the table that will be checked
098     * @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.
099     * @param sourceResolver The source resolver
100     * @param replace The map of string to replace. Key is the regexp to seek, value is the replacing string.
101     * @return true if the table was created, false otherwise
102     * @throws SQLException If an error occurred while executing SQL script, or while testing table existence
103     * @throws IOException If an error occurred while getting the script file, or if the url is malformed
104     */
105    public static boolean createTableIfNotExists(String datasourceId, String tableNameToCheck, String location, SourceResolver sourceResolver, Map<String, String> replace) throws SQLException, IOException
106    {
107        Connection connection = null;
108        try
109        {
110            connection = ConnectionHelper.getConnection(datasourceId);
111            
112            return createTableIfNotExists(connection, tableNameToCheck, location, sourceResolver, replace);
113        }
114        finally
115        {
116            ConnectionHelper.cleanup(connection);
117        }
118    }
119    
120    /**
121     * This method will test if a table exists, and if not will execute a script to create it
122     * @param connection The database connection to use
123     * @param tableNameToCheck The name of the table that will be checked
124     * @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.
125     * @param sourceResolver The source resolver
126     * @return true if the table was created, false otherwise
127     * @throws SQLException If an error occurred while executing SQL script, or while testing table existence
128     * @throws IOException If an error occurred while getting the script file, or if the url is malformed
129     */
130    public static boolean createTableIfNotExists(Connection connection, String tableNameToCheck, String location, SourceResolver sourceResolver) throws SQLException, IOException
131    {
132        return createTableIfNotExists(connection, tableNameToCheck, location, sourceResolver, null);
133    }
134    
135    /**
136     * This method will test if a table exists, and if not will execute a script to create it
137     * @param connection The database connection to use
138     * @param tableNameToCheck The name of the table that will be checked
139     * @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.
140     * @param sourceResolver The source resolver
141     * @param replace The map of string to replace. Key is the regexp to seek, value is the replacing string.
142     * @return true if the table was created, false otherwise
143     * @throws SQLException If an error occurred while executing SQL script, or while testing table existence
144     * @throws IOException If an error occurred while getting the script file, or if the url is malformed
145     */
146    public static boolean createTableIfNotExists(Connection connection, String tableNameToCheck, String location, SourceResolver sourceResolver, Map<String, String> replace) throws SQLException, IOException
147    {
148        if (tableExists(connection, tableNameToCheck))
149        {
150            return false;
151        }
152        
153        String finalLocation = String.format(location, ConnectionHelper.getDatabaseType(connection));
154        
155        Source source = null;
156        try
157        {
158            source = sourceResolver.resolveURI(finalLocation);
159            
160            try (InputStream is = source.getInputStream())
161            {
162                String script = IOUtils.toString(is, "UTF-8");
163
164                if (replace != null)
165                {
166                    for (String replaceKey : replace.keySet())
167                    {
168                        script = script.replaceAll(replaceKey, replace.get(replaceKey));
169                    }
170                }
171                
172                SQLScriptHelper.runScript(connection, script);
173            }
174        }
175        finally
176        {
177            if (source != null)
178            {
179                sourceResolver.release(source);
180            }
181        }
182        
183        return true;
184    }
185    
186    /**
187     * Checks whether the given table exists in the database.
188     * @param connection The database connection
189     * @param tableName the name of the table
190     * @return true is the table exists
191     * @throws SQLException In an SQL exception occurs
192     */
193    public static boolean tableExists(Connection connection, String tableName) throws SQLException
194    {
195        ResultSet rs = null;
196        boolean schemaExists = false;
197        
198        String name = tableName;
199        DatabaseMetaData metaData = connection.getMetaData();
200        
201        if (metaData.storesLowerCaseIdentifiers())
202        {
203            name = tableName.toLowerCase();
204        }
205        else if (metaData.storesUpperCaseIdentifiers())
206        {
207            name = tableName.toUpperCase();
208        }
209        
210        try
211        {
212            rs = metaData.getTables(null, null, name, null);
213            schemaExists = rs.next();
214        }
215        finally
216        {
217            ConnectionHelper.cleanup(rs);
218        }
219        
220        return schemaExists;
221    }
222
223    /**
224     * Run a SQL script using the connection passed in.
225     * @param connection the connection to use for the script
226     * @param script the script data.
227     * @throws IOException if an error occurs while reading the script.
228     * @throws SQLException if an error occurs while executing the script.
229     */
230    public static void runScript(Connection connection, String script) throws IOException, SQLException
231    {
232        ScriptContext scriptContext = new ScriptContext();
233        StringBuilder command = new StringBuilder();
234        
235        try
236        {
237            LineNumberReader lineReader = new LineNumberReader(new StringReader(script));
238            String line = null;
239            while ((line = lineReader.readLine()) != null)
240            {
241                if (__LOGGER.isDebugEnabled())
242                {
243                    __LOGGER.debug(String.format("Reading line: '%s'", line));
244                }
245                
246                boolean processCommand = false;
247                String trimmedLine = line.trim();
248                
249                if (trimmedLine.length() > 0)
250                {
251                    processCommand = processScriptLine(trimmedLine, command, scriptContext);
252                    
253                    if (processCommand)
254                    {
255                        _processCommand(connection, command, lineReader.getLineNumber(), scriptContext);
256                    }
257                }
258            }
259            
260            // If the entire file was processed and the command buffer is not empty, execute the current buffer.
261            if (command.length() > 0)
262            {
263                _processCommand(connection, command, lineReader.getLineNumber(), scriptContext);
264            }
265            
266            if (!connection.getAutoCommit())
267            {
268                connection.commit();
269            }
270        }
271        finally
272        {
273            if (!connection.getAutoCommit())
274            {
275                try
276                {
277                    // Fermer la connexion à la base
278                    connection.rollback();
279                }
280                catch (SQLException s)
281                {
282                    __LOGGER.error("Error while rollbacking connection", s);
283                }
284            }
285        }
286    }
287    
288    /**
289     * Run a SQL script using the connection passed in.
290     * @param connection the connection to use for the script
291     * @param is the input stream containing the script data.
292     * @throws IOException if an error occurs while reading the script.
293     * @throws SQLException if an error occurs while executing the script.
294     */
295    public static void runScript(Connection connection, InputStream is) throws IOException, SQLException
296    {
297        try
298        {
299            String script = IOUtils.toString(is, "UTF-8");
300            runScript(connection, script);
301        }
302        finally
303        {
304            IOUtils.closeQuietly(is);
305        }
306    }
307
308    /**
309     * Process a script line.
310     * @param line the line to process.
311     * @param commandBuffer the command buffer.
312     * @param scriptContext the script execution context.
313     * @return true to immediately process the command (a separator was found), false to process it later.
314     */
315    protected static boolean processScriptLine(String line, StringBuilder commandBuffer, ScriptContext scriptContext)
316    {
317        boolean processCommand = false;
318        
319        if (line.startsWith("//") || line.startsWith("--"))
320        {
321            String currentSeparator = scriptContext.getSeparator();
322            
323            // Search if the separator needs to be changed
324            if (line.contains(CHANGE_SEPARATOR_COMMAND))
325            {
326                // New separator
327                String newSeparator = line.substring(line.indexOf(CHANGE_SEPARATOR_COMMAND)
328                            + CHANGE_SEPARATOR_COMMAND.length()).trim();
329                
330                scriptContext.setSeparator(newSeparator);
331                
332                if (__LOGGER.isDebugEnabled())
333                {
334                    __LOGGER.debug(String.format("Changing separator to: '%s'", newSeparator));
335                }
336            }
337            else if (line.contains(IGNORE_EXCEPTIONS_COMMAND))
338            {
339                String ignoreStr = line.substring(line.indexOf(IGNORE_EXCEPTIONS_COMMAND)
340                            + IGNORE_EXCEPTIONS_COMMAND.length()).trim();
341                
342                boolean ignoreExceptions = "on".equals(ignoreStr);
343                
344                scriptContext.setIgnoreExceptions(ignoreExceptions);
345                
346                if (__LOGGER.isDebugEnabled())
347                {
348                    __LOGGER.debug(String.format("Ignore exceptions: '%s'", ignoreExceptions ? "on" : "off"));
349                }
350            }
351            
352            if (line.contains(currentSeparator))
353            {
354                if (commandBuffer.length() > 0)
355                {
356                    // End of command but do not use current line
357                    processCommand = true;
358                }
359            }
360        }
361        else if (line.endsWith(scriptContext.getSeparator()))
362        {
363            // End of command and use current line
364            processCommand = true;
365            commandBuffer.append(line.substring(0, line.lastIndexOf(scriptContext.getSeparator())));
366        }
367        else
368        {
369            // Append current command to the buffer
370            commandBuffer.append(line);
371            commandBuffer.append(" ");
372        }
373        
374        return processCommand;
375    }
376    
377    private static void _processCommand(Connection connection, StringBuilder command, int lineNumber, ScriptContext scriptContext) throws SQLException
378    {
379        if (__LOGGER.isInfoEnabled())
380        {
381            __LOGGER.info(String.format("Executing SQL command: '%s'", command));
382        }
383        
384        _execute(connection, command.toString(), lineNumber, scriptContext);
385
386        // Clear command
387        command.setLength(0);
388    }
389    
390    private static void _execute(Connection connection, String command, int lineNumber, ScriptContext scriptContext) throws SQLException
391    {
392        Statement statement = null;
393        try
394        {
395            statement = connection.createStatement();
396            statement.execute(command);
397        }
398        catch (SQLException e)
399        {
400            if (!scriptContext.ignoreExceptions())
401            {
402                String message = String.format("Unable to execute SQL: '%s' at line %d", command, lineNumber);
403                __LOGGER.error(message, e);
404                
405                throw new SQLException(message, e);
406            }
407        }
408        finally
409        {
410            ConnectionHelper.cleanup(statement);
411        }
412    }
413    
414    /**
415     * Script execution context.
416     */
417    protected static class ScriptContext
418    {
419        
420        /** The current script execution block separator. */
421        protected String _separator;
422        
423        /** True to ignore sql exceptions. */
424        protected boolean _ignoreExceptions;
425        
426        /**
427         * Default ScriptContext object.
428         */
429        public ScriptContext()
430        {
431            this(DEFAULT_SEPARATOR, false);
432        }
433        
434        /**
435         * Build a ScriptContext object.
436         * @param separator the separator
437         * @param ignoreExceptions true to ignore exceptions.
438         */
439        public ScriptContext(String separator, boolean ignoreExceptions)
440        {
441            this._separator = separator;
442            this._ignoreExceptions = ignoreExceptions;
443        }
444        
445        /**
446         * Get the separator.
447         * @return the separator
448         */
449        public String getSeparator()
450        {
451            return _separator;
452        }
453        
454        /**
455         * Set the separator.
456         * @param separator the separator to set
457         */
458        public void setSeparator(String separator)
459        {
460            this._separator = separator;
461        }
462        
463        /**
464         * Get the ignoreExceptions.
465         * @return the ignoreExceptions
466         */
467        public boolean ignoreExceptions()
468        {
469            return _ignoreExceptions;
470        }
471        
472        /**
473         * Set the ignoreExceptions.
474         * @param ignoreExceptions the ignoreExceptions to set
475         */
476        public void setIgnoreExceptions(boolean ignoreExceptions)
477        {
478            this._ignoreExceptions = ignoreExceptions;
479        }
480        
481    }
482    
483}