DirectAccessHelper.java

/*
** Module   : DirectAccessHelper.java
** Abstract : Middleware between FWD persistence layer and underlying database with direct access interface.
**
** Copyright (c) 2023-2024, Golden Code Development Corporation.
**
** -#- -I- --Date-- -----------------------------------Description-----------------------------------
** 001 AL2 20230210 Created initial version.
** 002 AL2 20230320 Avoid hydration if record is already found in the session cache by recid.
** 003 AL2 20230517 Adapt to new row structure constructor signature.
** 004 AL2 20231013 Added nextPrimaryKey.
** 005 AL2 20230926 Added hasRecords.
** 006 AL2 20231116 Added support for clearing multiplex in FWD-H2.
** 007 DDF 20231116 Normalize rowid and longchar arguments.
**     DDF 20231117 Normalize date, datetime and unknown value arguments.
** 008 AL2 20231211 Use FullRowStructure to signal the fact that the full record was extracted.
** 009 RAA 20230803 Added support for notifying H2 to handle unique index validation.
**     RAA 20231127 Made startValidate return DirectAccessDriver.
** 010 CA  20240422 For a NEW temp record which was not flushed and is being deleted or rolled back in the
**                  transaction, the allocated primary key row (which is an empty template) to reserve this
**                  primary key, needs to be explicitly removed.
** 011 AL2 20240512 Remove bulk option from nextPrimaryKey.
** 012 ES  20240626 Refactor if conditionals in normalizeValue method.
** 013 CA  20240627 'clearPrimaryKey' will return false if the PK is not found, instead of throwing NPE.
** 014 AS  20240808 Throw DirectAccessException in findByUniqueIndex if more than one record can be accessed.
** 015 AS  20241121 Added normalization of FieldReference objects in normalizeValue.
*/

/*
** 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.persist.orm;

import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;

import org.h2.embedded.DirectAccessDriver;
import org.h2.embedded.DirectAccessResponse;
import org.h2.jdbc.JdbcConnection;

import com.goldencode.p2j.oo.lang._BaseObject_;
import com.goldencode.p2j.persist.PersistenceException;
import com.goldencode.p2j.persist.Record;
import com.goldencode.p2j.persist.TempRecord;
import com.goldencode.p2j.persist.UniqueIndexLookup;
import com.goldencode.p2j.persist.FieldReference;
import com.goldencode.p2j.util.*;

/**
 * Common point where all the direct access interface is used inside FWD. Any attempt
 * to use a database driver internal structures should be done through this gateway.
 */
public class DirectAccessHelper
{
   /**
    * Direct access attempt to retrieve the next primary key available for the mentioned
    * table and multiplex. The target table should be defined with {@code MULTIPLEXED}
    * to allow the generation of a proper index to be used by this routine.
    * 
    * In essence, this retrieves the next primary key and reserves its spot. Unless a further
    * insert is done with that key, the reserved slot will leak. Use this carefully.
    * 
    * If you want to do a bulk insert, use the bulk option. This will no reserve any spot as
    * the number of intended keys is unknown. If bulk is set, this will return the first key
    * that is available and no higher keys are already reserved / used. Such key should be used
    * immediately, otherwise the previous mentioned invariant may not hold (an insert happens in
    * the mean-time).
    * 
    * @param   session 
    *          The session with a database with direct access interface.
    * @param   tableName
    *          The name of the table from which the next key is needed.
    * @param   multiplex
    *          The multiplex for which the next key is needed. This is mandatory, as 4GL does
    *          the reclaiming of keys per-multiplex (e.g. one multiplex won't reclaim the keys 
    *          from another multiplex, even if they are in the same physical table).
    *          
    * @return  The next key available according to the provided table and multiplex. This would be
    *          reclaimed or not / reserved or not depending on the {@code bulk} value.
    * 
    * @throws  DirectAccessException
    *          An exception is thrown if the table doesn't exist.
    * 
    */
   public static long nextPrimaryKey(Session session, String tableName, int multiplex)
   throws DirectAccessException
   {
      try
      {
         JdbcConnection conn = getH2Connection(session);
         DirectAccessDriver driver = conn.getDirectAccessDriver();
         return driver.nextPrimaryKey(tableName, multiplex);
      }
      catch (SQLException e)
      {
         throw new DirectAccessException("Error direct accessing for finding next primary key", e);
      }
   }
   
   /**
    * When a {@link DmoState#NEW} record is being deleted or undone in the same transaction, without having
    * a chance to occupy the reserved slot (by primary key) via an explicit or implicit flush, that slot 
    * needs to be cleared. 
    * 
    * @param    session
    *           The session with a database with direct access interface.
    * @param    dmo
    *           The temporary DMO instance in {@link DmoState#NEW} state.
    *           
    * @return   {@code true} if the primary key was found and removed from this multiplex.
    *
    * @throws   DirectAccessException
    *           An exception is thrown if the table doesn't exist.
    */
   public static boolean clearPrimaryKey(Session session, TempRecord dmo)
   throws DirectAccessException
   {
      try
      {
         DmoMeta meta = DmoMetadataManager.getDmoInfo(dmo.getClass());
         String tableName = meta.getSqlTableName();
         int multiplex = dmo._multiplex();
         long primaryKey = dmo.primaryKey();
         
         JdbcConnection conn = getH2Connection(session);
         DirectAccessDriver driver = conn.getDirectAccessDriver();
         return driver.clearPrimaryKey(tableName, multiplex, primaryKey);
      }
      catch (SQLException e)
      {
         throw new DirectAccessException("Error direct accessing for clearing primary key for undone record", e);
      }
   }

   /**
    * Retrieve the number of rows that are currently in a specified table.
    * 
    * @param   session 
    *          The session with a database with direct access interface.
    * @param   tableName
    *          The name of the table for which we want the number of rows.
    * @param   multiplex
    *          The multiplex for which the query is done.
    *          
    * @return  The number of rows currently in the specified table.
    */
   public static boolean hasRecords(Session session, String tableName, int multiplex)
   throws DirectAccessException
   {
      try
      {
         JdbcConnection conn = getH2Connection(session);
         DirectAccessDriver driver = conn.getDirectAccessDriver();
         return driver.hasRecords(tableName, multiplex);
      }
      catch (SQLException e)
      {
         throw new DirectAccessException("Error direct accessing for finding the row count", e);
      }
   }
   
   /**
    * Direct access attempt on database structures to retrieve a single record based on recid.
    * 
    * @param   session 
    *          The session with a database with direct access interface.
    * @param   meta
    *          The meta of the table to be queried.
    * @param   recid
    *          The value of the recid for the record to be retrieved.
    *          
    * @return  A hydrated record directly from the underlying direct access interface.
    * 
    * @throws  DirectAccessException
    *          An exception is thrown if the table doesn't exist. An exception is also 
    *          thrown if the prerequisites are not met (no recid primary key).
    * 
    */
   public static Record findByRowid(Session session, DmoMeta meta, long recid)
   throws DirectAccessException
   {
      try
      {
         JdbcConnection conn = getH2Connection(session);
         DirectAccessDriver driver = conn.getDirectAccessDriver();
         String tableName = meta.getSqlTableName();
         DirectAccessResponse response = driver.getRow(tableName, recid);
         return interpretResponse(session, meta, response);
      }
      catch (SQLException | PersistenceException e)
      {
         throw new DirectAccessException("Error direct accessing for finding record by rowid", e);
      }
   }

   /**
    * Direct access attempt on database structures to retrieve a single record based on a 
    * unique index.
    * 
    * @param   session 
    *          The session with a database with direct access interface.
    * @param   meta
    *          The meta of the table to be queried.
    * @param   lookup
    *          Specific object which contains low-level information about the properties 
    *          and values needed for unique index lookup. 
    *          
    * @return  A hydrated record directly from the underlying direct access interface.
    * 
    * @throws  DirectAccessException
    *          An exception is thrown if the table doesn't exist. Also, if the properties don't 
    *          match any unique index, then an exception is thrown.
    */
   public static Record findByUniqueIndex(Session session, DmoMeta meta, UniqueIndexLookup lookup)
   throws DirectAccessException
   {
      try
      {
         JdbcConnection conn = getH2Connection(session);
         DirectAccessDriver driver = conn.getDirectAccessDriver();
         String tableName = meta.getSqlTableName();
         Object[] values = lookup.getValues();
         for (int i = 0; i < values.length; i++)
         {
            values[i] = normalizeValue(values[i]);
         }
         DirectAccessResponse response = driver.getUniqueRow(tableName, lookup.getProperties(), values);
         if (response.getAmbiguous())
         {
            throw new PersistenceException("Unique result constraint violation.");
         }

         return interpretResponse(session, meta, response);
      }
      catch (SQLException | PersistenceException e)
      {
         throw new DirectAccessException("Error direct accessing for finding record by unique index", e);
      }
   }

   /**
    * Forcefully clear the multiplex in the FWD-H2. This is required, as the reclaiming process stores
    * that last batch active for each multiplex. This way, we properly reclaim the ids per-multiplex.
    * To avoid leaking, we need to notify FWD-H2 that storing such information is no longer needed.
    * 
    * @param   session
    *          The session with a database with direct access interface.
    * @param   tableName
    *          The name of the table for which we wan't to kill the multiplex
    * @param   multiplex
    *          The multiplex whose scope was closed.
    */
   public static void killMultiplex(Session session, String tableName, int multiplex)
   {     
      JdbcConnection conn = getH2Connection(session);
      DirectAccessDriver driver = conn.getDirectAccessDriver();
      try 
      {   
         driver.killMultiplex(tableName, multiplex);
      }
      catch (SQLException e)
      {
         throw new IllegalStateException("Error direct accessing for killing multiplex", e);
      }
   }

   /**
    * Check if the provided session allows a direct access interface.
    * 
    * @param   session 
    *          The session with a database with direct access interface.
    *          
    * @return  {@code true} if this session allows direct access.
    */
   public static boolean hasDirectAccess(Session session)
   {
      return getH2Connection(session) != null;
   }
   
   /**
    * Mark that server-side unique index validation has stopped and notify H2 that it should handle
    * the process.
    * 
    * @param   session
    *          The session with a database with direct access interface.
    *          
    * @return  DirectAccessDriver
    *          The H2 driver.
    */
   public static DirectAccessDriver startValidate(Session session)
   {
      JdbcConnection conn = getH2Connection(session);
      if (conn == null)
      {
         return null;
      }
      
      DirectAccessDriver driver = conn.getDirectAccessDriver();
      driver.startValidate();
      return driver;
   }
   
   /**
    * Mark that H2 no longer needs to handle unique index validation, as it will be done server-side.
    * 
    * @param   session
    *          The session with a database with direct access interface.
    */
   public static void stopValidate(Session session)
   {
      JdbcConnection conn = getH2Connection(session);
      if (conn == null)
      {
         return;
      }
      
      conn.getDirectAccessDriver().stopValidate();
   }

   /**
    * Normalize the FWD specific data types into Java native types. This is needed
    * in order to interface with the underlying database through direct access.
    * 
    * @param   arg
    *          The object to be "normalized" (converted to Java native type)
    *          
    * @return  A Java native type that can be used by the direct access interface.
    * 
    * @throws  DirectAccessException
    *          An exception is thrown when a provided argument couldn't be converted to a
    *          Java representation which can be passed to the H2 engine.
    */
   private static Object normalizeValue(Object arg)
   throws DirectAccessException
   {
      boolean expected = false;
      if (arg == null)
      {
         return null;
      }
      if (arg instanceof Number  ||
          arg instanceof Boolean ||
          arg instanceof String)
      {
         // already normalized
         return arg;
      }

      if (arg instanceof FieldReference)
      {
         arg = ((FieldReference) arg).get();
      }

      if (arg instanceof BaseDataType)
      {
         if (((BaseDataType) arg).isUnknown())
         {
            return null;
         }
         if (arg.getClass() == integer.class)
         {
            return ((integer) arg).toJavaIntegerType();
         }
         if (arg.getClass() == int64.class)
         {
            return ((int64) arg).toJavaLongType();
         }
         if (arg.getClass() == logical.class)
         {
            return ((logical) arg).toJavaType();
         }
         if (arg.getClass() == handle.class)
         {
            return ((handle) arg).getResourceId();
         }
         if (arg.getClass() == character.class)
         {
            return ((character) arg).toJavaType();
         }
         if (arg.getClass() == longchar.class)
         {
            return ((longchar) arg).toJavaType();
         }
         if (arg.getClass() == decimal.class)
         {
            return ((decimal) arg).toJavaType();
         }
         if (arg.getClass() == date.class)
         {
            return ((date) arg).dateValue();
         }
         if (arg.getClass() == datetime.class)
         {
            return ((datetime) arg).dateValue();
         }
         if (arg.getClass() == rowid.class)
         {
            return((rowid) arg).getValue();
         }
         if (arg.getClass() == recid.class)
         {
            return((recid) arg).getValue();
         }
         if (arg.getClass() == object.class)
         {
            return (object.resourceId((object<? extends _BaseObject_>) arg));
         }
         if (arg instanceof datetimetz)
         {
            expected = true;
         }
      }
      
      throw new DirectAccessException("Can't normalize FWD value to use it with direct access.", expected);
   }
   
   /**
    * Core method of hydrating a record from the retrieved result set. Usually, the result-set
    * is a dummy container of row structures from the underlying database. Make sure these
    * are converted to FWD structures by hydrating.
    * 
    * @param   session 
    *          The session with a database with direct access interface.
    * @param   meta
    *          The meta of the table to be queried.
    * @param   response
    *          The response returned by the direct-access interface.
    *          
    * @return  A hydrated record directly from the underlying direct access interface.
    * 
    * @throws  SQLException
    *          An exception is thrown when anything goes wrong with the dummy JDBC API (result-set).
    * @throws  PersistenceException
    *          This method does a session cache look-up. If this goes wrong, an exception is thrown.
    *          
    */
   private static Record interpretResponse(Session session, DmoMeta meta, DirectAccessResponse response)
   throws PersistenceException, 
          SQLException
   {
      if (response.recid == null)
      {
         // not found
         return null;
      }
      
      Record dmo = session.getCached(meta.getImplementationClass(), response.recid);
      if (dmo != null && !dmo.checkState(DmoState.STALE) && !dmo.checkState(DmoState.INCOMPLETE))
      {
         return dmo;
      }
      
      ResultSet rs = response.getResultSet();
      if (rs.next())
      {
         RowStructure rowStructure = new FullRowStructure(meta);
         return (Record) SQLQuery.hydrateRecord(rs, rowStructure, 1, session);
      }
      else
      {
         return null;
      }
   }
   
   /**
    * Retrieve the H2 connection straight from the provided session. This returns {@code null}
    * if the H2 connection doesn't allows direct access interface of if this is not a H2 connection.
    * This should be refactored maybe to be more generic (not necessarily that close related to H2)
    * 
    * @param   session 
    *          The session with a database with direct access interface.
    *          
    * @return  A H2 specific connection.
    */
   private static JdbcConnection getH2Connection(Session session)
   {
      if (session == null)
      {
         return null;
      }
      
      Connection conn;
      try
      {
         conn = session.getConnection();
      }
      catch (PersistenceException e)
      {
         return null;
      }
      
      if (TempTableDataSourceProvider.isProxy(conn))
      {
         conn = TempTableDataSourceProvider.unwrapProxy(conn);
      }
      
      if (conn != null && 
          conn instanceof JdbcConnection && 
          ((JdbcConnection) conn).getDirectAccessDriver() != null)
      {
         return (JdbcConnection) conn;
      }
      
      return null;
   }

}