DBHelper.java

/*
** Module   : DBHelper.java
** Abstract : A helper to access the conversion database.
**
** Copyright (c) 2020-2023, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description----------------------------------------
** 001 CA  20200412 Created initial version, for incremental conversion support.
** 002 CA  20200423 After the collection is persisted, clear it, to help garbage collection.  Also,
**                  records in a collections are inserted 2000 at a time.
** 003 ECF 20200906 New ORM implementation.
** 004 CA  20220402 Moved the 'cvtdb' to the 'cvtpath' folder (defaults to 'cvt' relative to project dir).
** 005 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
*/
/*
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU Affero General Public License as
** published by the Free Software Foundation, either version 3 of the
** License, or (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
** GNU Affero General Public License for more details.
**
** You may find a copy of the GNU Affero GPL version 3 at the following
** location: https://www.gnu.org/licenses/agpl-3.0.en.html
** 
** Additional terms under GNU Affero GPL version 3 section 7:
** 
**   Under Section 7 of the GNU Affero GPL version 3, the following additional
**   terms apply to the works covered under the License.  These additional terms
**   are non-permissive additional terms allowed under Section 7 of the GNU
**   Affero GPL version 3 and may not be removed by you.
** 
**   0. Attribution Requirement.
** 
**     You must preserve all legal notices or author attributions in the covered
**     work or Appropriate Legal Notices displayed by works containing the covered
**     work.  You may not remove from the covered work any author or developer
**     credit already included within the covered work.
** 
**   1. No License To Use Trademarks.
** 
**     This license does not grant any license or rights to use the trademarks
**     Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
**     of Golden Code Development Corporation. You are not authorized to use the
**     name Golden Code, FWD, or the names of any author or contributor, for
**     publicity purposes without written authorization.
** 
**   2. No Misrepresentation of Affiliation.
** 
**     You may not represent yourself as Golden Code Development Corporation or FWD.
** 
**     You may not represent yourself for publicity purposes as associated with
**     Golden Code Development Corporation, FWD, or any author or contributor to
**     the covered work, without written authorization.
** 
**   3. No Misrepresentation of Source or Origin.
** 
**     You may not represent the covered work as solely your work.  All modified
**     versions of the covered work must be marked in a reasonable way to make it
**     clear that the modified work is not originating from Golden Code Development
**     Corporation or FWD.  All modified versions must contain the notices of
**     attribution required in this license.
*/

package com.goldencode.p2j.convert.db;

import java.io.*;
import java.sql.*;
import java.util.*;
import java.util.function.*;
import java.util.logging.*;

import com.goldencode.ast.*;
import com.goldencode.p2j.cfg.*;
import com.goldencode.p2j.convert.*;
import com.goldencode.p2j.uast.*;

/**
 * Provides APIs to access the conversion database.
 */
class DBHelper
{
   /** Logger */
   private static final ConversionStatus LOG = ConversionStatus.get(DBHelper.class);
   
   /** Default database user. */
   private static String dbUser = "admin";
   
   /** Default database password. */
   private static String dbPass = "admin";

   /** Flag indicating the database is connected. */
   private boolean connected = false;

   /** Database connection */
   private Connection connection = null;
   
   /** Database URL */
   private String dbURL = null;

   /** A cache of prepared statements. */
   private Map<String, PreparedStatement> statements = new HashMap<>();

   /** Cache of db-backed collections. */
   private Map<String, StoredCollection> stores = new HashMap<>();

   /** Flag indicating if we are persisting the collections. */
   private boolean inPersist = false;
   
   /**
    * Get the state of the {@link #inPersist} flag.
    * 
    * @return   See above.
    */
   public boolean inPersist()
   {
      return inPersist;
   }
   
   /**
    * Check if the database is connected.
    * 
    * @return   The {@link #connected} state.
    */
   public boolean connected()
   {
      return connected;
   }

   /**
    * Disconnect the database.
    * <p>
    * This will persist all db-backed collection.
    */
   public void disconnect()
   {
      if (connection == null)
      {
         return;
      }
      
      try
      {
         this.inPersist = true;
         try
         {
            connection.setAutoCommit(false);
            for (StoredCollection map : stores.values())
            {
               map.persist();
               map.clear();
            }
         }
         finally
         {
            connection.commit();
            connection.setAutoCommit(true);
         }
      }
      catch (SQLException e)
      {
         throw new RuntimeException(e);
      }
      finally
      {
         this.inPersist = false;
      }
      
      stores.clear();
      
      for (Statement stmt : statements.values())
      {
         try
         {
            stmt.close();
         }
         catch (SQLException e)
         {
            throw new RuntimeException(e);
         }
      }
      statements.clear();

      try
      {
         connection.close();
      }
      catch (Exception exc)
      {
         throw new RuntimeException("Error closing database connection", exc);
      }
      
      connected = false;
      connection = null;
   }
   
   /**
    * Connect to the database.
    */
   public void connect()
   {
      if (connection != null)
      {
         disconnect();
      }
      
      try
      {
         if (dbURL == null)
         {
            String cvtFolder = Configuration.getConversionFolder();
            String dbFolder = cvtFolder + File.separator + "cvtdb";
            String db = dbFolder + File.separator + "cvtdb"; 

            long maxMemory = Runtime.getRuntime().maxMemory();
            long cacheSize = -1;
            if (maxMemory < Long.MAX_VALUE)
            {
               cacheSize = maxMemory / 1024 / 2;
            }
            
            dbURL = "jdbc:h2:"
                  + db + ";"
                  + "DB_CLOSE_DELAY=-1;"
                  + "AUTOCOMMIT=OFF;"
                  + "LOCK_MODE=3;"
                  + "MV_STORE=FALSE;"
                  + "DB_CLOSE_ON_EXIT=TRUE;";
            
            if (cacheSize > -1)
            {
               dbURL += (";CACHE_SIZE=" + cacheSize);
            }
            
            // load driver
            Class.forName("org.h2.Driver");
         }
         
         connection = DriverManager.getConnection(dbURL, dbUser, dbPass);
         connection.setAutoCommit(true);
         connected = true;
      }
      catch (Exception exc)
      {
         LOG.log(Level.SEVERE, "", exc);
         
         throw new RuntimeException("Error opening database connection", exc);
      }
   }

   /**
    * Convert the specified type to SQL.
    * 
    * @param    clazz
    *           The Java type.
    *           
    * @return   The SQL type.
    */
   public String convertToSql(Class<?> clazz)
   {
      if (clazz == String.class)
      {
         return "VARCHAR";
      }
      else if (clazz == Long.class)
      {
         return "BIGINT";
      }
      else if (clazz == Integer.class)
      {
         return "INT";
      }
      else if (clazz == Double.class)
      {
         return "DOUBLE";
      }
      else if (clazz == AstKey.class)
      {
         return "OTHER";
      }
      else if (clazz == Aast.class)
      {
         return "OTHER";
      }
      else if (clazz == FrameAstKey.class)
      {
         return "OTHER";
      }
      
      throw new IllegalArgumentException("Could not map class " + clazz + " to a Java type!");
   }

   /**
    * Execute an update SQL.
    * 
    * @param    sql
    *           The SQL update statement.
    * @param    args
    *           The SQL arguments.
    *           
    * @return   The number of affected records.
    */
   public int executeUpdate(String sql, Object... args)
   {
      int res = 0;
      try
      {
         PreparedStatement stmt = createStatement(sql);
         for (int i = 0; i < args.length; i++)
         {
            stmt.setObject(i + 1, args[i]);
         }
         res = stmt.executeUpdate();
         stmt.clearParameters();
         
         return res;
      }
      catch (SQLException e)
      {
         throw new RuntimeException(e);
      }
   }

   /**
    * Execute a generic SQL.
    * 
    * @param    sql
    *           The SQL statement.
    * @param    args
    *           The SQL arguments.
    */
   public void executeSQL(String sql, Object... args)
   {
      try
      {
         PreparedStatement stmt = createStatement(sql);
         for (int i = 0; i < args.length; i++)
         {
            stmt.setObject(i + 1, args[i]);
         }
         stmt.execute();
         stmt.clearParameters();
      }
      catch (SQLException e)
      {
         throw new RuntimeException(e);
      }
   }

   /**
    * Execute an SQL which selects a single integer result.
    * 
    * @param    sql
    *           The SQL statement.
    * @param    args
    *           The SQL arguments.
    *           
    * @return   The count result.
    */
   public int executeCount(String sql, Object... args)
   {
      try
      {
         PreparedStatement stmt = createStatement(sql);
         for (int i = 0; i < args.length; i++)
         {
            stmt.setObject(i + 1, args[i]);
         }
         ResultSet rs = stmt.executeQuery();
         int count = rs.next() ? rs.getInt(1) : 0;
         stmt.clearParameters();
         
         return count;
      }
      catch (SQLException e)
      {
         throw new RuntimeException(e);
      }
   }

   /**
    * Execute a SQL query statement.
    * 
    * @param    sql
    *           The SQL statement.
    * @param    args
    *           The SQL arguments.
    *           
    * @return   The SQL result.
    */
   public List<Object[]> executeQuery(String sql, Object... args)
   {
      try
      {
         PreparedStatement stmt = createStatement(sql);
         for (int i = 0; i < args.length; i++)
         {
            stmt.setObject(i + 1, args[i]);
         }
         ResultSet rs = stmt.executeQuery();

         List<Object[]> res = new ArrayList<>();
         int colCount = rs.getMetaData().getColumnCount();
         while (rs.next())
         {
            Object[] row = new Object[colCount];
            
            for (int i = 1; i <= colCount; i++)
            {
               row[i - 1] = rs.getObject(i);
            }
            
            res.add(row);
         }
         
         stmt.clearParameters();
         
         return res;
      }
      catch (SQLException e)
      {
         throw new RuntimeException(e);
      }
   }
   
   /**
    * Execute a SQL query statement, which will use the specified consumer to process the results.
    * 
    * @param    sql
    *           The SQL statement.
    * @param    c
    *           The consumer to process the result set.
    * @param    args
    *           The SQL arguments.
    */
   public void executeQuery(String sql, Consumer<ResultSet> c, Object... args)
   {
      try
      {
         PreparedStatement stmt = createStatement(sql);
         for (int i = 0; i < args.length; i++)
         {
            stmt.setObject(i + 1, args[i]);
         }
         ResultSet rs = stmt.executeQuery();

         while (rs.next())
         {
            c.accept(rs);
         }
         
         stmt.clearParameters();
      }
      catch (SQLException e)
      {
         throw new RuntimeException(e);
      }
   }

   /**
    * Get the stored collection with the specified name.
    * 
    * @param    name
    *           The stored collection name.
    *           
    * @return   The found collection, or <code>null</code> if it was not yet added to 
    *           {@link #stores}.
    */
   public StoredCollection getCollection(String name)
   {
      return this.stores.get(name);
   }
   
   /**
    * Register a db-backed collection with the given name.
    * 
    * @param    name
    *           The collection name.
    * @param    c
    *           The collection.
    *           
    * @throws   IllegalStateException
    *           If there is already a collection with this name in {@link #stores}.
    */
   public void register(String name, StoredCollection c)
   {
      if (this.stores.containsKey(name))
      {
         throw new IllegalStateException("Only one collection must be active for " + name + "!");
      }
      
      this.stores.put(name, c);
   }

   /**
    * Persist this collection, using the specified SQL and consumer to set the query batch 
    * arguments.
    * 
    * @param    c
    *           The collection to persist.
    * @param    sql
    *           The insert SQL.
    * @param    ps
    *           The consumer to add the records as a batch insert.
    */
   public void persistCollection(Collection c, 
                                 String sql, 
                                 BiConsumer<PreparedStatement, Object> ps)
   {
      try
      {
         PreparedStatement stmt = createStatement(sql);
         
         int batchRecords = 2000;
         int idx = 0;
         for (Object o : c)
         {
            idx = idx + 1;
            
            ps.accept(stmt, o);
            
            if (idx % batchRecords == 0)
            {
               stmt.executeBatch();
            }
         }
         stmt.executeBatch();
         stmt.clearParameters();
      }
      catch (SQLException e)
      {
         throw new RuntimeException(e);
      }
   }
   
   /**
    * Prepare the SQL statement and cache it.
    * 
    * @param    sql
    *           The SQL statement to prepare.
    *           
    * @return   The prepared statement (a new instance or an instance from the {@link #statements} 
    *           cache).
    *           
    * @throws   SQLException
    *           If the SQL statement can't be created.
    */
   private PreparedStatement createStatement(String sql)
   throws SQLException
   {
      PreparedStatement stmt = statements.get(sql);
      if (stmt == null)
      {
         stmt = connection.prepareStatement(sql);
         statements.put(sql, stmt);
      }
      
      return stmt;
   }
}