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    @SuppressWarnings("deprecation")
296    public static void runScript(Connection connection, InputStream is) throws IOException, SQLException
297    {
298        try
299        {
300            String script = IOUtils.toString(is, "UTF-8");
301            runScript(connection, script);
302        }
303        finally
304        {
305            IOUtils.closeQuietly(is);
306        }
307    }
308
309    /**
310     * Process a script line.
311     * @param line the line to process.
312     * @param commandBuffer the command buffer.
313     * @param scriptContext the script execution context.
314     * @return true to immediately process the command (a separator was found), false to process it later.
315     */
316    protected static boolean processScriptLine(String line, StringBuilder commandBuffer, ScriptContext scriptContext)
317    {
318        boolean processCommand = false;
319        
320        if (line.startsWith("//") || line.startsWith("--"))
321        {
322            String currentSeparator = scriptContext.getSeparator();
323            
324            // Search if the separator needs to be changed
325            if (line.contains(CHANGE_SEPARATOR_COMMAND))
326            {
327                // New separator
328                String newSeparator = line.substring(line.indexOf(CHANGE_SEPARATOR_COMMAND)
329                            + CHANGE_SEPARATOR_COMMAND.length()).trim();
330                
331                scriptContext.setSeparator(newSeparator);
332                
333                if (__LOGGER.isDebugEnabled())
334                {
335                    __LOGGER.debug(String.format("Changing separator to: '%s'", newSeparator));
336                }
337            }
338            else if (line.contains(IGNORE_EXCEPTIONS_COMMAND))
339            {
340                String ignoreStr = line.substring(line.indexOf(IGNORE_EXCEPTIONS_COMMAND)
341                            + IGNORE_EXCEPTIONS_COMMAND.length()).trim();
342                
343                boolean ignoreExceptions = "on".equals(ignoreStr);
344                
345                scriptContext.setIgnoreExceptions(ignoreExceptions);
346                
347                if (__LOGGER.isDebugEnabled())
348                {
349                    __LOGGER.debug(String.format("Ignore exceptions: '%s'", ignoreExceptions ? "on" : "off"));
350                }
351            }
352            
353            if (line.contains(currentSeparator))
354            {
355                if (commandBuffer.length() > 0)
356                {
357                    // End of command but do not use current line
358                    processCommand = true;
359                }
360            }
361        }
362        else if (line.endsWith(scriptContext.getSeparator()))
363        {
364            // End of command and use current line
365            processCommand = true;
366            commandBuffer.append(line.substring(0, line.lastIndexOf(scriptContext.getSeparator())));
367        }
368        else
369        {
370            // Append current command to the buffer
371            commandBuffer.append(line);
372            commandBuffer.append(" ");
373        }
374        
375        return processCommand;
376    }
377    
378    private static void _processCommand(Connection connection, StringBuilder command, int lineNumber, ScriptContext scriptContext) throws SQLException
379    {
380        if (__LOGGER.isInfoEnabled())
381        {
382            __LOGGER.info(String.format("Executing SQL command: '%s'", command));
383        }
384        
385        _execute(connection, command.toString(), lineNumber, scriptContext);
386
387        // Clear command
388        command.setLength(0);
389    }
390    
391    private static void _execute(Connection connection, String command, int lineNumber, ScriptContext scriptContext) throws SQLException
392    {
393        Statement statement = null;
394        try
395        {
396            statement = connection.createStatement();
397            statement.execute(command);
398        }
399        catch (SQLException e)
400        {
401            if (!scriptContext.ignoreExceptions())
402            {
403                String message = String.format("Unable to execute SQL: '%s' at line %d", command, lineNumber);
404                __LOGGER.error(message, e);
405                
406                throw new SQLException(message, e);
407            }
408        }
409        finally
410        {
411            ConnectionHelper.cleanup(statement);
412        }
413    }
414    
415    /**
416     * Script execution context.
417     */
418    protected static class ScriptContext
419    {
420        
421        /** The current script execution block separator. */
422        protected String _separator;
423        
424        /** True to ignore sql exceptions. */
425        protected boolean _ignoreExceptions;
426        
427        /**
428         * Default ScriptContext object.
429         */
430        public ScriptContext()
431        {
432            this(DEFAULT_SEPARATOR, false);
433        }
434        
435        /**
436         * Build a ScriptContext object.
437         * @param separator the separator
438         * @param ignoreExceptions true to ignore exceptions.
439         */
440        public ScriptContext(String separator, boolean ignoreExceptions)
441        {
442            this._separator = separator;
443            this._ignoreExceptions = ignoreExceptions;
444        }
445        
446        /**
447         * Get the separator.
448         * @return the separator
449         */
450        public String getSeparator()
451        {
452            return _separator;
453        }
454        
455        /**
456         * Set the separator.
457         * @param separator the separator to set
458         */
459        public void setSeparator(String separator)
460        {
461            this._separator = separator;
462        }
463        
464        /**
465         * Get the ignoreExceptions.
466         * @return the ignoreExceptions
467         */
468        public boolean ignoreExceptions()
469        {
470            return _ignoreExceptions;
471        }
472        
473        /**
474         * Set the ignoreExceptions.
475         * @param ignoreExceptions the ignoreExceptions to set
476         */
477        public void setIgnoreExceptions(boolean ignoreExceptions)
478        {
479            this._ignoreExceptions = ignoreExceptions;
480        }
481        
482    }
483    
484}