001/*
002 * Copyright 2005-2007 The Kuali Foundation
003 *
004 *
005 * Licensed under the Educational Community License, Version 2.0 (the "License");
006 * you may not use this file except in compliance with the License.
007 * You may obtain a copy of the License at
008 *
009 * http://www.opensource.org/licenses/ecl2.php
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.kualigan.maven.plugins.liquibase;
018
019import java.io.PrintStream;
020import java.io.Reader;
021
022import java.lang.reflect.Field;
023
024import liquibase.database.Database;
025import liquibase.database.jvm.JdbcConnection;
026
027import java.sql.Blob;
028import java.sql.Clob;
029import java.sql.Connection;
030import java.sql.DriverManager;
031import java.sql.DatabaseMetaData;
032import java.sql.PreparedStatement;
033import java.sql.SQLException;
034import java.sql.ResultSet;
035import java.sql.ResultSetMetaData;
036import java.sql.Statement;
037import java.sql.Types;
038
039import java.util.ArrayList;
040import java.util.Collection;
041import java.util.HashMap;
042import java.util.Map;
043import java.util.Observable;
044import java.util.Observer;
045
046import org.apache.maven.plugin.MojoExecutionException;
047import org.apache.maven.plugin.logging.Log;
048
049import org.codehaus.plexus.component.annotations.Component;
050import org.codehaus.plexus.component.annotations.Requirement;
051
052
053import static org.apache.tools.ant.Project.MSG_DEBUG;
054
055/**
056 * Helper class for migrating information from one database to another
057 *
058 * @author Leo Przybylski (przybyls@arizona.edu)
059 */
060@Component(role = org.kualigan.maven.plugins.liquibase.MigrateHelper.class, hint="default")
061public class DefaultMigrateHelper implements MigrateHelper {
062    public static final String ROLE_HINT = "default";
063    
064    private static final String[] carr = new String[] {"|", "\\", "-", "/"};
065    private static final String RECORD_COUNT_QUERY = "select count(*) as \"COUNT\" from %s";
066    private static final String SELECT_ALL_QUERY   = "select * from %s";
067    private static final String INSERT_STATEMENT   = "insert into %s (%s) values (%s)";
068    private static final String DATE_CONVERSION    = "TO_DATE('%s', 'YYYYMMDDHH24MISS')";
069    private static final String COUNT_FIELD        = "COUNT";
070    private static final String LIQUIBASE_TABLE    = "DATABASECHANGELOG";
071    private static final int[]  QUOTED_TYPES       = 
072        new int[] {Types.CHAR, Types.VARCHAR, Types.TIME, Types.LONGVARCHAR, Types.DATE, Types.TIMESTAMP};
073
074    private static final String HSQLDB_PUBLIC      = "PUBLIC";
075    private static final int    MAX_THREADS        = 3;
076
077    private Log log;
078    private Database source;
079    private Database target;
080    private int threadCount;
081    private Boolean interactiveMode;
082
083    public DefaultMigrateHelper() { 
084        int threadCount = 1;
085    }
086    
087    public void setSource(final Database source) {
088        this.source = source;
089    }
090    
091    public Database getSource() {
092        return this.source;
093    }
094
095    public void setTarget(final Database target) {
096        this.target = target;
097    }
098
099    public Database getTarget() {
100        return this.target;
101    }
102    
103    public Log getLog() {
104        return log;
105    }
106    
107    public void setLog(final Log log) {
108        this.log = log;
109    }
110    
111    public void migrate(final Database source, final Database target, final Log log, final Boolean interactiveMode) throws MojoExecutionException {
112        setTarget(target);
113        setSource(source);
114        setLog(log);
115        this.interactiveMode = interactiveMode;
116        migrate();
117    }
118    
119    public void migrate() throws MojoExecutionException {
120        getLog().debug("Migrating data from " + source.getConnection().getURL() + " to " + target.getConnection().getURL());
121
122        final Incrementor recordCountIncrementor = new Incrementor();
123        final Map<String, Integer> tableData = getTableData(recordCountIncrementor);
124
125        getLog().debug("Copying " + tableData.size() + " tables");
126
127        float recordVisitor = 0;
128        final ProgressObserver progressObserver = new ProgressObserver(recordCountIncrementor.getValue(),
129                                                                       48f, 48f/100,
130                                                                       "\r|%s[%s] %3d%% (%d/%d) records");
131        final ProgressObservable observable = new ProgressObservable();
132        observable.addObserver(progressObserver);
133
134        final ThreadGroup tgroup = new ThreadGroup("Migration Threads");
135
136        for (final String tableName : tableData.keySet()) {
137        //     debug("Migrating table " + tableName + " with " + tableData.get(tableName) + " records");
138            /*
139            if (tgroup.activeCount() < MAX_THREADS) {
140                new Thread(tgroup, new Runnable() {
141                        public void run() {
142                            migrate(source, target, tableName, observable);
143                        }
144                    }).start();
145            }
146            else {
147            */
148            final Map<String,Integer> columns = new HashMap<String, Integer>();
149            migrate(tableName, observable);
150                // }
151        }
152
153        // Wait for other threads to finish
154        try {
155            while(tgroup.activeCount() > 0) {
156                Thread.sleep(5000);
157            }
158        }
159        catch (InterruptedException e) {
160        }
161
162    /*
163        try {
164            final JdbcConnection targetDb = target.getConnection();
165            if (targetDb.getMetaData().getDriverName().toLowerCase().contains("hsqldb")) {
166                Statement st = targetDb.createStatement();
167                st.execute("CHECKPOINT"); 
168                st.close();
169            }
170            targetDb.close();
171        }
172        catch (Exception e) {
173            throw new MojoExecutionException(e.getMessage(), e);
174        }
175        */
176    }
177
178    protected void migrate(final String tableName, 
179                           final ProgressObservable observable) throws MojoExecutionException {
180        final JdbcConnection sourceDb = (JdbcConnection) getSource().getConnection();
181        final JdbcConnection targetDb = (JdbcConnection) getTarget().getConnection();
182
183        final Map<String, Integer> columns = getColumnMap(tableName);
184
185        if (columns.size() < 1) {
186            getLog().debug("Columns are empty for " + tableName);
187            return;
188        }
189
190        final PreparedStatement toStatement = prepareStatement(targetDb, tableName, columns);
191        Statement fromStatement = null;
192
193        final boolean hasClob = columns.values().contains(Types.CLOB);
194        int recordsLost = 0;
195        
196        try {
197            fromStatement = sourceDb.createStatement();
198            final ResultSet results = fromStatement.executeQuery(String.format(SELECT_ALL_QUERY, tableName));
199            
200            try {
201                while (results.next()) {
202                    try {
203                        toStatement.clearParameters();
204                        
205                        int i = 1;
206                        for (String columnName : columns.keySet()) {
207                            final Object value = results.getObject(columnName);
208                            
209                            if (value != null) {
210                                try {
211                                    handleLob(toStatement, value, i);
212                                }
213                                catch (Exception e) {
214                                    if (getLog().isDebugEnabled()) {
215                                        // getLog().warn(String.format("Error processing %s.%s %s", tableName, columnName, columns.get(columnName)));
216                                        if (Clob.class.isAssignableFrom(value.getClass())) {
217                                            // getLog().warn("Got exception trying to insert CLOB with length" + ((Clob) value).length());
218                                        }
219                                        // e.printStackTrace();
220                                    }
221                                }
222                            }
223                            else {
224                                toStatement.setObject(i,value);
225                            } 
226                            i++;
227                        }
228                        
229                        boolean retry = true;
230                        int retry_count = 0;
231                        while(retry) {
232                            try {
233                                toStatement.execute();
234                                retry = false;
235                            }
236                            catch (SQLException sqle) {
237                                retry = false;
238                                if (sqle.getMessage().contains("ORA-00942")) {
239                                    getLog().debug("Couldn't find " + tableName);
240                                    if (getLog().isDebugEnabled()) {
241                                        getLog().debug("Tried insert statement " + getStatementBuffer(tableName, columns), sqle);
242                                    }
243                                }
244                                else if (sqle.getMessage().contains("ORA-12519")) {
245                                    retry = true;
246                                    if (getLog().isDebugEnabled()) {
247                                        getLog().debug("Tried insert statement " + getStatementBuffer(tableName, columns), sqle);
248                                    }
249                                }
250                                else if (sqle.getMessage().contains("IN or OUT")) {
251                                    if (getLog().isDebugEnabled()) {
252                                        getLog().debug("Column count was " + columns.keySet().size(), sqle);
253                                    }
254                                }
255                                else if (sqle.getMessage().contains("Error reading")) {
256                                    if (retry_count > 5) {
257                                        if (getLog().isDebugEnabled()) {
258                                            getLog().debug("Tried insert statement " + getStatementBuffer(tableName, columns), sqle);
259                                        }
260                                        retry = false;
261                                    }
262                                    retry_count++;
263                                }
264                                else {
265                                    if (getLog().isDebugEnabled()) {
266                                        // getLog().warn("Error executing: " + getStatementBuffer(tableName, columns), sqle);
267                                    }
268                                }
269                            }
270                        }
271                    }
272                    catch (Exception e) {
273                        recordsLost++;
274                        throw e;
275                    }
276                    finally {
277                        observable.incrementRecord();
278                    }
279                }
280            }
281            finally {
282                if (results != null) {
283                    try {
284                        results.close();
285                    }
286                    catch(Exception e) {
287                    }
288                }
289            }
290        }
291        catch (Exception e) {
292            throw new MojoExecutionException(e.getMessage(), e);
293        }
294        finally {
295            if (sourceDb != null) {
296                try {
297                    if (sourceDb.getMetaData().getDriverName().toLowerCase().contains("hsqldb")) {
298                        Statement st = sourceDb.createStatement();
299                        st.execute("CHECKPOINT"); 
300                        st.close();
301                    }
302                    fromStatement.close();
303                    //sourceDb.close();
304                }
305                catch (Exception e) {
306                }
307            }
308
309            if (targetDb != null) {
310                try {
311                    targetDb.commit();
312                    if (targetDb.getMetaData().getDriverName().toLowerCase().contains("hsql")) {
313                        Statement st = targetDb.createStatement();
314                        st.execute("CHECKPOINT"); 
315                        st.close();
316                    }
317                    toStatement.close();
318                    // targetDb.close();
319                }
320                catch (Exception e) {
321                    getLog().debug("Error closing database connection");
322                    e.printStackTrace();
323                }
324            }
325            // debug("Lost " +recordsLost + " records");
326            columns.clear();
327        }
328    }
329
330    protected void handleLob(final PreparedStatement toStatement, final Object value, final int i) throws SQLException {
331        if (Clob.class.isAssignableFrom(value.getClass())) {
332            toStatement.setAsciiStream(i, ((Clob) value).getAsciiStream(), ((Clob) value).length());
333        }
334        else if (Blob.class.isAssignableFrom(value.getClass())) {
335            toStatement.setBinaryStream(i, ((Blob) value).getBinaryStream(), ((Blob) value).length());
336        }
337        else {
338            toStatement.setObject(i,value);
339        } 
340    }
341
342    protected PreparedStatement prepareStatement(final JdbcConnection conn, 
343                                                 final String tableName, 
344                                                 final Map<String, Integer> columns) throws MojoExecutionException {
345        final String statement = getStatementBuffer(tableName, columns);
346        
347        try {
348            return conn.prepareStatement(statement);
349        }
350        catch (Exception e) {
351            throw new MojoExecutionException(e.getMessage(), e);
352        }
353    }
354
355    protected String getStatementBuffer(final String tableName, final Map<String,Integer> columns) {
356        String retval = null;
357
358        final StringBuilder names  = new StringBuilder();
359        final StringBuilder values = new StringBuilder();
360        for (String columnName : columns.keySet()) {
361            names.append(columnName).append(",");
362            values.append("?,");
363        }
364
365        names.setLength(names.length() - 1);
366        values.setLength(values.length() - 1);
367        retval = String.format(INSERT_STATEMENT, tableName, names, values);
368        
369
370        return retval;
371    }
372
373    protected boolean isValidTable(final DatabaseMetaData metadata, final String tableName) {
374        return !(tableName.startsWith("BIN$") || tableName.toUpperCase().startsWith(LIQUIBASE_TABLE) || isSequence(metadata, tableName));
375    }
376
377    protected boolean isSequence(final DatabaseMetaData metadata, final String tableName) {
378        final JdbcConnection source = (JdbcConnection) getSource().getConnection();
379        try {
380            final ResultSet rs = source.getMetaData().getColumns(null, getSource().getDefaultSchemaName(), tableName, null);
381            int columnCount = 0;
382            boolean hasId = false;
383            try {
384                while (rs.next()) {
385                    columnCount++;
386                    if ("yes".equalsIgnoreCase(rs.getString("IS_AUTOINCREMENT"))) {
387                        hasId = true;
388                    }
389                }
390            }
391            finally {
392                if (rs != null) {
393                    try {
394                        rs.close();
395                    }
396                    catch (Exception e) {
397                    }
398                }
399                return (columnCount == 1 && hasId);
400            }
401        }
402        catch (Exception e) {
403            return false;
404        }
405    }
406
407    /**
408     * Get a list of table names available mapped to row counts
409     */
410    protected Map<String, Integer> getTableData(final Incrementor incrementor) throws MojoExecutionException {
411        JdbcConnection sourceConn = (JdbcConnection) getSource().getConnection();
412        JdbcConnection targetConn = (JdbcConnection) getTarget().getConnection();
413        final Map<String, Integer> retval = new HashMap<String, Integer>();
414        final Collection<String> toRemove = new ArrayList<String>();
415
416        getLog().debug("Looking up table names in schema " + getSource().getDefaultSchemaName());
417        try {
418            final DatabaseMetaData metadata = sourceConn.getMetaData();
419            final ResultSet tableResults = metadata.getTables(sourceConn.getCatalog(), getSource().getDefaultSchemaName(), null, new String[] { "TABLE" });
420            while (tableResults.next()) {
421                final String tableName = tableResults.getString("TABLE_NAME");
422                if (!isValidTable(metadata, tableName)) {
423                    continue;
424                }
425                if (tableName.toUpperCase().startsWith(LIQUIBASE_TABLE)) continue;
426                final int rowCount = getTableRecordCount(sourceConn, tableName);
427                if (rowCount < 1) { // no point in going through tables with no data
428                    
429                }
430                incrementor.increment(rowCount);
431                // debug("Adding table " + tableName);
432                retval.put(tableName, rowCount);
433            }
434            tableResults.close();
435        }
436        catch (Exception e) {
437            throw new MojoExecutionException(e.getMessage(), e);
438        }
439
440        try {
441            for (String tableName : retval.keySet()) {
442                final ResultSet tableResults = targetConn.getMetaData().getTables(targetConn.getCatalog(), getTarget().getDefaultSchemaName(), null, new String[] { "TABLE" });
443                if (!tableResults.next()) {
444                    getLog().debug("Removing " + tableName);
445                    toRemove.add(tableName);
446                }
447                tableResults.close();
448            }
449        }
450        catch (Exception e) {
451            throw new MojoExecutionException(e.getMessage(), e);
452        }
453
454        for (String tableName : toRemove) {
455            retval.remove(tableName);
456        }
457        
458        return retval;
459    }
460
461    protected Map<String, Integer> getColumnMap(final String tableName) throws MojoExecutionException {
462        final JdbcConnection targetDb = (JdbcConnection) target.getConnection();
463        final JdbcConnection sourceDb = (JdbcConnection) source.getConnection();
464        final Map<String,Integer> retval = new HashMap<String,Integer>();
465        final Collection<String> toRemove = new ArrayList<String>();
466        try {
467            final Statement state = targetDb.createStatement();                
468            final ResultSet altResults = state.executeQuery("select * from " + tableName + " where 1 = 0");
469            final ResultSetMetaData metadata = altResults.getMetaData();
470            
471            for (int i = 1; i <= metadata.getColumnCount(); i++) {
472                retval.put(metadata.getColumnName(i),
473                           metadata.getColumnType(i));
474            }
475            altResults.close();
476            state.close();
477        }
478        catch (Exception e) {
479            throw new MojoExecutionException(e.getMessage(), e);
480        }
481
482        for (final String column : retval.keySet()) {
483            try {
484                final Statement state = targetDb.createStatement();                
485                final ResultSet altResults = state.executeQuery("select * from " + tableName + " where 1 = 0");
486                final ResultSetMetaData metadata = altResults.getMetaData();
487
488                for (int i = 1; i <= metadata.getColumnCount(); i++) {
489                    retval.put(metadata.getColumnName(i),
490                               metadata.getColumnType(i));
491                }
492                altResults.close();
493                state.close();
494            }
495            catch (Exception e) {
496                throw new MojoExecutionException(e.getMessage(), e);
497            }
498        }
499
500        for (final String column : toRemove) {
501            retval.remove(column);
502        }
503        
504        return retval;
505    }
506
507    protected int getTableRecordCount(final JdbcConnection conn, final String tableName) throws MojoExecutionException {
508        final String query = String.format(RECORD_COUNT_QUERY, tableName);
509        Statement statement = null;
510        try {
511            statement = conn.createStatement();
512            final ResultSet results = statement.executeQuery(query);
513            results.next();
514            final int retval = results.getInt(COUNT_FIELD);
515            results.close();
516            return retval;
517        }
518        catch (Exception e) {
519            if (e.getMessage().contains("ORA-00942")) {
520                getLog().debug("Couldn't find " + tableName);
521                getLog().debug("Tried insert statement " + query);
522            }
523            getLog().debug("Exception executing " + query);
524            throw new MojoExecutionException(e.getMessage(), e);
525        }
526        finally {
527            try {
528                if (statement != null) {
529                    statement.close();
530                    statement = null;
531                }
532            }
533            catch (Exception e) {
534            }
535        }
536    }
537
538    /*
539    private void debug(String msg) {
540        getLog.debug(msg, MSG_DEBUG);
541    }
542
543    private Connection openConnection(String reference) {
544        final RdbmsConfig config = (RdbmsConfig) getProject().getReference(reference);
545        return openConnection(config);
546    }
547    
548    private Connection openConnection(RdbmsConfig config) {
549        Connection retval = null;
550        
551        while (retval == null) {
552            try {
553                debug("Loading schema " + config.getSchema() + " at url " + config.getUrl());
554                Class.forName(config.getDriver());
555
556                retval = DriverManager.getConnection(config.getUrl(), config.getUsername(), config.getPassword());
557                retval.setAutoCommit(false);
558
559
560                // If this is an HSQLDB database, then we probably want to turn off logging for permformance
561                if (config.getDriver().indexOf("hsqldb") > -1) {
562                    debug("Disabling hsqldb log");
563                    final Statement st = retval.createStatement();
564                    st.execute("SET FILES LOG FALSE");
565                    st.close();
566                }
567                
568            }
569            catch (Exception e) {
570                // throw new MojoExecutionException(e.getMessage(), e);
571            }
572        }
573        
574        return retval;
575    }
576*/
577
578    /**
579     * Helper class for incrementing values
580     */
581    private class Incrementor {
582        private int value;
583        
584        public Incrementor() {
585            value = 0;
586        }
587        
588        public int getValue() {
589            return value;
590        }
591
592        public void increment() {
593            value++;
594        }
595
596        public void increment(int by) {
597            value += by;
598        }
599    }
600
601    private class ProgressObservable extends Observable {
602        public void incrementRecord() {
603            setChanged();
604            notifyObservers();
605            clearChanged();
606        }
607    }
608
609    /**
610     * Observer for handling progress
611     * 
612     */
613    private class ProgressObserver implements Observer {
614
615        private float total;
616        private float progress;
617        private float length;
618        private float ratio;
619        private String template;
620        private float count;
621        private PrintStream out;
622        
623        public ProgressObserver(final float total,
624                                final float length,
625                                final float ratio,
626                                final String template) {
627            this.total    = total;
628            this.template = template;
629            this.ratio    = ratio;
630            this.length   = length;
631            this.count    = 0;
632            
633            out = System.out;
634            /*
635            try {
636                final Field field = Main.class.getDeclaredField("out");
637                field.setAccessible(true);
638                out = (PrintStream) field.get(null);
639            }
640            catch (Exception e) {
641                e.printStackTrace();
642            }
643            */
644        }
645
646        public synchronized void update(Observable o, Object arg) {
647            count++;
648
649            final int percent = (int) ((count / total) * 100f);
650            final int progress = (int) ((count / total) * (100f * ratio));
651            final StringBuilder progressBuffer = new StringBuilder();
652                
653            for (int x = 0; x < progress; x++) {
654                progressBuffer.append('=');
655            }
656            
657            for (int x = progress; x < length; x++) {
658                progressBuffer.append(' ');
659            }
660            int roll = (int) (count / (total / 1000));
661
662            if (interactiveMode) {
663                out.print(String.format(template, progressBuffer, carr[roll % carr.length], percent, (int) count, (int) total));
664            }
665            else if ((count % 5000) == 0 || count == total) {
666                out.println(String.format("(%s)%% %s of %s records", (int) ((count / total) * 100), (int) count, (int) total));
667            }            
668        }
669    }
670}