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            sourceResolver.release(source);
178        }
179        
180        return true;
181    }
182    
183    /**
184     * Checks whether the given table exists in the database.
185     * @param connection The database connection
186     * @param tableName the name of the table
187     * @return true is the table exists
188     * @throws SQLException In an SQL exception occurs
189     */
190    public static boolean tableExists(Connection connection, String tableName) throws SQLException
191    {
192        ResultSet rs = null;
193        boolean schemaExists = false;
194        
195        String name = tableName;
196        DatabaseMetaData metaData = connection.getMetaData();
197        
198        if (metaData.storesLowerCaseIdentifiers())
199        {
200            name = tableName.toLowerCase();
201        }
202        else if (metaData.storesUpperCaseIdentifiers())
203        {
204            name = tableName.toUpperCase();
205        }
206        
207        try
208        {
209            rs = metaData.getTables(null, null, name, null);
210            schemaExists = rs.next();
211        }
212        finally
213        {
214            ConnectionHelper.cleanup(rs);
215        }
216        
217        return schemaExists;
218    }
219
220    /**
221     * Run a SQL script using the connection passed in.
222     * @param connection the connection to use for the script
223     * @param script the script data.
224     * @throws IOException if an error occurs while reading the script.
225     * @throws SQLException if an error occurs while executing the script.
226     */
227    public static void runScript(Connection connection, String script) throws IOException, SQLException
228    {
229        ScriptContext scriptContext = new ScriptContext();
230        StringBuilder command = new StringBuilder();
231        
232        try
233        {
234            LineNumberReader lineReader = new LineNumberReader(new StringReader(script));
235            String line = null;
236            while ((line = lineReader.readLine()) != null)
237            {
238                if (__LOGGER.isDebugEnabled())
239                {
240                    __LOGGER.debug(String.format("Reading line: '%s'", line));
241                }
242                
243                boolean processCommand = false;
244                String trimmedLine = line.trim();
245                
246                if (trimmedLine.length() > 0)
247                {
248                    processCommand = processScriptLine(trimmedLine, command, scriptContext);
249                    
250                    if (processCommand)
251                    {
252                        _processCommand(connection, command, lineReader.getLineNumber(), scriptContext);
253                    }
254                }
255            }
256            
257            // If the entire file was processed and the command buffer is not empty, execute the current buffer.
258            if (command.length() > 0)
259            {
260                _processCommand(connection, command, lineReader.getLineNumber(), scriptContext);
261            }
262            
263            if (!connection.getAutoCommit())
264            {
265                connection.commit();
266            }
267        }
268        finally
269        {
270            if (!connection.getAutoCommit())
271            {
272                try
273                {
274                    // Fermer la connexion à la base
275                    connection.rollback();
276                }
277                catch (SQLException s)
278                {
279                    __LOGGER.error("Error while rollbacking connection", s);
280                }
281            }
282        }
283    }
284    
285    /**
286     * Run a SQL script using the connection passed in.
287     * @param connection the connection to use for the script
288     * @param is the input stream containing the script data.
289     * @throws IOException if an error occurs while reading the script.
290     * @throws SQLException if an error occurs while executing the script.
291     */
292    @SuppressWarnings("deprecation")
293    public static void runScript(Connection connection, InputStream is) throws IOException, SQLException
294    {
295        try
296        {
297            String script = IOUtils.toString(is, "UTF-8");
298            runScript(connection, script);
299        }
300        finally
301        {
302            IOUtils.closeQuietly(is);
303        }
304    }
305
306    /**
307     * Process a script line.
308     * @param line the line to process.
309     * @param commandBuffer the command buffer.
310     * @param scriptContext the script execution context.
311     * @return true to immediately process the command (a separator was found), false to process it later.
312     */
313    protected static boolean processScriptLine(String line, StringBuilder commandBuffer, ScriptContext scriptContext)
314    {
315        boolean processCommand = false;
316        
317        if (line.startsWith("//") || line.startsWith("--"))
318        {
319            String currentSeparator = scriptContext.getSeparator();
320            
321            // Search if the separator needs to be changed
322            if (line.contains(CHANGE_SEPARATOR_COMMAND))
323            {
324                // New separator
325                String newSeparator = line.substring(line.indexOf(CHANGE_SEPARATOR_COMMAND)
326                            + CHANGE_SEPARATOR_COMMAND.length()).trim();
327                
328                scriptContext.setSeparator(newSeparator);
329                
330                if (__LOGGER.isDebugEnabled())
331                {
332                    __LOGGER.debug(String.format("Changing separator to: '%s'", newSeparator));
333                }
334            }
335            else if (line.contains(IGNORE_EXCEPTIONS_COMMAND))
336            {
337                String ignoreStr = line.substring(line.indexOf(IGNORE_EXCEPTIONS_COMMAND)
338                            + IGNORE_EXCEPTIONS_COMMAND.length()).trim();
339                
340                boolean ignoreExceptions = "on".equals(ignoreStr);
341                
342                scriptContext.setIgnoreExceptions(ignoreExceptions);
343                
344                if (__LOGGER.isDebugEnabled())
345                {
346                    __LOGGER.debug(String.format("Ignore exceptions: '%s'", ignoreExceptions ? "on" : "off"));
347                }
348            }
349            
350            if (line.contains(currentSeparator))
351            {
352                if (commandBuffer.length() > 0)
353                {
354                    // End of command but do not use current line
355                    processCommand = true;
356                }
357            }
358        }
359        else if (line.endsWith(scriptContext.getSeparator()))
360        {
361            // End of command and use current line
362            processCommand = true;
363            commandBuffer.append(line.substring(0, line.lastIndexOf(scriptContext.getSeparator())));
364        }
365        else
366        {
367            // Append current command to the buffer
368            commandBuffer.append(line);
369            commandBuffer.append(" ");
370        }
371        
372        return processCommand;
373    }
374    
375    private static void _processCommand(Connection connection, StringBuilder command, int lineNumber, ScriptContext scriptContext) throws SQLException
376    {
377        if (__LOGGER.isInfoEnabled())
378        {
379            __LOGGER.info(String.format("Executing SQL command: '%s'", command));
380        }
381        
382        _execute(connection, command.toString(), lineNumber, scriptContext);
383
384        // Clear command
385        command.setLength(0);
386    }
387    
388    private static void _execute(Connection connection, String command, int lineNumber, ScriptContext scriptContext) throws SQLException
389    {
390        Statement statement = null;
391        try
392        {
393            statement = connection.createStatement();
394            statement.execute(command);
395        }
396        catch (SQLException e)
397        {
398            if (!scriptContext.ignoreExceptions())
399            {
400                String message = String.format("Unable to execute SQL: '%s' at line %d", command, lineNumber);
401                __LOGGER.error(message, e);
402                
403                throw new SQLException(message, e);
404            }
405        }
406        finally
407        {
408            ConnectionHelper.cleanup(statement);
409        }
410    }
411    
412    /**
413     * Script execution context.
414     */
415    protected static class ScriptContext
416    {
417        
418        /** The current script execution block separator. */
419        protected String _separator;
420        
421        /** True to ignore sql exceptions. */
422        protected boolean _ignoreExceptions;
423        
424        /**
425         * Default ScriptContext object.
426         */
427        public ScriptContext()
428        {
429            this(DEFAULT_SEPARATOR, false);
430        }
431        
432        /**
433         * Build a ScriptContext object.
434         * @param separator the separator
435         * @param ignoreExceptions true to ignore exceptions.
436         */
437        public ScriptContext(String separator, boolean ignoreExceptions)
438        {
439            this._separator = separator;
440            this._ignoreExceptions = ignoreExceptions;
441        }
442        
443        /**
444         * Get the separator.
445         * @return the separator
446         */
447        public String getSeparator()
448        {
449            return _separator;
450        }
451        
452        /**
453         * Set the separator.
454         * @param separator the separator to set
455         */
456        public void setSeparator(String separator)
457        {
458            this._separator = separator;
459        }
460        
461        /**
462         * Get the ignoreExceptions.
463         * @return the ignoreExceptions
464         */
465        public boolean ignoreExceptions()
466        {
467            return _ignoreExceptions;
468        }
469        
470        /**
471         * Set the ignoreExceptions.
472         * @param ignoreExceptions the ignoreExceptions to set
473         */
474        public void setIgnoreExceptions(boolean ignoreExceptions)
475        {
476            this._ignoreExceptions = ignoreExceptions;
477        }
478        
479    }
480    
481}