Persister.java

/*
** Module   : Persister.java
** Abstract : Manages database insert, update, delete operations for DMOs
**
** Copyright (c) 2019-2024, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description---------------------------------------
** 001 ECF 20190902 Created initial version. Inserts, updates, deletes database records using SQL mapped to
**                  the DMO's metadata.
**     OM  20191120 Some fixes in insert() method. Added delete() method.
** 002 OM  20201001 Improved DMO manipulation performance by caching slow Property annotation access.
**     OM  20201007 Replaced inlined column name with TemporaryBuffer.MULTIPLEX_FIELD_NAME.
**     OM  20201218 Fixed implementation for error and rejected attributes/hidden fields.
**     OM  20211020 Added _DATASOURCE_ROWID property.
**     ECF 20211229 Added WARNING-level logging of SQLExceptions caught during various operations.
**     ECF 20220531 Perform basic insert when bulk insert only contains one record.
**     OM  20220818 Handled Statement.SUCCESS_NO_INFO as normal execution.
**     RAA 20221221 Modified the execution of sql's. They now pass through the SQLStatementLogger
**                  in case logging is intended.
**     OM  20220909 Avoid ArrayIndexOutOfBoundsException when persisting records with no defined fields.
**     OM  20221117 Some properties (datetime-tz) may take a different number of positional parameters when
**                  used in insert/update or where predicate.
**     CA  20230116 Increased updateCache size to 4096.
** 003 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
** 004 RAA 20230518 Replaced lambdas with method referencing when using SQLStatementLogger.
** 005 RAA 20230607 SQLs are now executed through SQLExecutor instead of SQLStatementLogger.
** 006 RAA 20230615 Added SQL as a parameter when using SQLExecutor.
** 007 DDF 20230627 Made the size of the updateCache configurable.
**     DDF 20230627 Removed the static block that reads the configuration size of the cache.
**                  CacheManager will handle the finding of the configuration size and the
**                  cache creation.
**     DDF 20230627 Reverted change, removed class modifier.
**     DDF 20230706 Replaced createLRUCache call with another one that will use a null discriminator
**                  by default.
** 008 DDF 20231127 Modified logging level from WARNING to FINE for SQLException in the insert() method.
** 009 RAA 20230724 Replaced ConsumerWithException with DMLStatement.
** 010 RAA 20231220 Rewrote two loops to be index-type.
** 011 RAA 20230802 Added support for the VALIDATE keyword.
**     RAA 20230802 Split insert and update into two functions each, so dealing with VALIDATE is
**                  more straight-forward.
**     RAA 20230802 Instead of appending "validate" to the insert SQL, insertValidateSql is now used.
**     RAA 20230802 Changed name of validateMode to allowDBUniqueCheck.
**     RAA 20230802 In case of UNIQUE VIOLATION, the logging is now skipped in this class.
**     RAA 20230803 Replaced "validate" string concatenation with DirectAccess H2 notification.
**     RAA 20231114 Replaced hasDirectAccess call with a variable.
**     RAA 20231127 Removed functions with allowDBUniqueCheck parameter. Added some basic caching for
**                  allowDBUniqueCheck and directAccessDriver.
**     RAA 20231127 Fixed possibility of NPE appearing for allowDBUniqueCheck.
**     RAA 20240313 Fixed duplicated code after rebase.
**     RAA 20240316 Rebase fix: switched allowDBUniqueCheck from Boolean to boolean.
** 012 OM  20240319 Avoid logging on sqlstate 23000 (Duplicate entry, similar to unique_violation).
** 013 AL2 20240329 Allow setAllowDBUniqueCheck return the old value.
*/

/*
** 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.*;
import java.util.*;
import java.util.logging.*;

import org.h2.embedded.DirectAccessDriver;

import com.goldencode.cache.*;
import com.goldencode.p2j.persist.*;
import com.goldencode.p2j.persist.orm.SQLExecutor.*;
import com.goldencode.p2j.persist.orm.types.*;
import com.goldencode.p2j.util.logging.*;

/**
 * A class which processes database inserts, updates, and deletes for data model objects (DMOs),
 * using record metadata defined by the DMO's interface (in annotations).
 * <p>
 * The methods in this class are highly dependent upon the order of the elements in {@link
 * BaseRecord}&apos;s internal data array, which are laid out in a well-known order.
 * 
 * @see  Loader
 */
class Persister
{
   /** Logger */
   private static final CentralLogger LOG = CentralLogger.get(Persister.class);
   
   /** Cache of FQL strings, with or without max results and start offsets, to queries. */
   private static ExpiryCache<UpdateInfo, UpdateInfo> updateCache;

   /** Active database session */
   private final Session session;
   
   /** The {@code DirectAccessDriver} for H2. */
   private DirectAccessDriver directAccessDriver;
   
   /** 
    * Flag (only for temporary databases) that marks if the index server-validation was skipped and 
    * it should be done by H2. 
    */
   private boolean allowDBUniqueCheck;
   
   /**
    * Constructor.
    * 
    * @param   session
    *          Active database associated with this persister.
    */
   Persister(Session session)
   {
      this.session = session;
   }
   
   /**
    * Initializes the cache using CacheManager. Only one cache is used,
    * so there is no need for a discriminator. The default value of the cache
    * is used when there is no size available from the configuration.
    */
   public static void initializeCache()
   {
      updateCache = CacheManager.createLRUCache(Persister.class, 4096);
   }
   
   /**
    * Compose the SQL statements used to insert a single DMO into the database. This will include
    * a statement to insert the primary record into the primary table, followed by zero or more
    * statements to insert multiple records into secondary tables for the DMO's extent fields
    * (if any). Denormalized extent fields are treated like scalar fields.
    * 
    * @param   table
    *          SQL name of the primary table. The naming convention assumed for secondary tables
    *          is: {@code <primary_table>__<extent_size>}.
    * @param   allPropMeta
    *          Array of {@link PropertyMeta} objects in their natural order.
    * @param   temp
    *          Pass {@code true} for {@code _temp} DMOs. In this case the {@code insert} statement
    *          will be build with the mandatory {@code _multiplex} field for temp-tables.
    * 
    * @return  An array of one or more SQL statements as described above.
    * 
    * @see     PropertyMeta#compareTo(PropertyMeta)
    */
   static String[] composeInsertStatements(String table, PropertyMeta[] allPropMeta, boolean temp)
   {
      List<String> statements = new ArrayList<>();
      
      int i = 0;
      int len = allPropMeta.length;
      int seriesSize = 0;
      
      // primary record
      StringBuilder sql = new StringBuilder("insert into ").append(table).append(" (");
      
      int paramCount = 0; // counts the SQL parameter placeholders ("?")  for fields
      int propertyCount = 0;
      if (len > 0)
      {
         PropertyMeta propMeta = allPropMeta[0];
         if (propMeta.getExtent() == 0)
         {
            seriesSize = propMeta.seriesSize;
            propertyCount = propMeta.seriesSize;
            while (i < seriesSize)
            {
               if (i > 0)
               {
                  sql.append(", ");
               }
               
               propMeta = allPropMeta[i];
               sql.append(propMeta.getColumn());
               ++ paramCount;
               
               if (propMeta.isDateTimeTz())
               {
                  // adding support for timezone offset, in case of datetime-tz data
                  sql.append(", ");
                  sql.append(propMeta.getColumn()).append(DDLGeneratorWorker.DTZ_OFFSET);
                  ++ paramCount;
               }
               i++;
            }
         }
      }
      
      // inject the temp-table multiplexer, if the case
      if (temp)
      {
         if (i > 0)
         {
            sql.append(", ");
         }
         sql.append(TemporaryBuffer.MULTIPLEX_FIELD_NAME);
         i++;
      }
      
      // insert primary key last, so as not to complicate the column index use when setting
      // substitution parameters at execution time
      if (i > 0)
      {
         sql.append(", ");
      }
      sql.append(Session.PK).append(") values (");
      
      for (i = 0; i < paramCount; i++)
      {
         if (i > 0)
         {
            sql.append(", ");
         }
         sql.append("?");
      }
      
      // plus one placeholder for the _multiplex field, in case of temp-tables
      if (temp)
      {
         if (i > 0)
         {
            sql.append(", ");
         }
         sql.append("?");
         i++;
      }
      
      // ...plus one final placeholder for the primary key itself
      if (i > 0)
      {
         sql.append(", ");
      }
      sql.append("?)");
      
      statements.add(sql.toString().intern());
      
      // secondary records (if any)
      while (propertyCount < len)
      {
         PropertyMeta propMeta = allPropMeta[propertyCount];
         int extent = propMeta.getExtent();
         sql = new StringBuilder("insert into ");
         sql.append(table).append("__").append(extent).append(" (");
         
         seriesSize = propMeta.seriesSize;
         paramCount = 0;
         for (int j = 0; j < seriesSize; j++)
         {
            if (j > 0)
            {
               sql.append(", ");
            }
            
            propMeta = allPropMeta[propertyCount + j * extent];
            sql.append(propMeta.getColumn());
            ++ paramCount;
            
            if (propMeta.isDateTimeTz())
            {
               // adding support for timezone offset, in case of datetime-tz data
               sql.append(", ");
               sql.append(propMeta.getColumn()).append(DDLGeneratorWorker.DTZ_OFFSET);
               ++ paramCount;
            }
         }
         propertyCount += seriesSize * extent;
         
         sql.append(", parent__id, list__index) values (");
         
         for (int j = 0; j < paramCount; j++)
         {
            if (j > 0)
            {
               sql.append(", ");
            }
            sql.append("?");
         }
         
         // ...plus two final placeholders for the foreign key and list index
         sql.append(", ?, ?)");
         
         statements.add(sql.toString().intern());
      }
      
      return statements.toArray(new String[statements.size()]);
   }
   
   /**
    * Compose the statements needed to delete a DMO and all of its normalized extent field data.
    * The order of the statements is inverted from the order of the table names for the DMO, so
    * that foreign records are deleted before the primary record they reference.
    * 
    * @param   tables
    *          Table names for this DMO, primary first.
    * 
    * @return  An array of delete statements which can be executed in order to delete a single
    *          DMO record from the database.
    */
   static String[] composeDeleteStatements(String[] tables)
   {
      int len = tables.length;
      
      String[] sql = new String[len];
      
      for (int i = 0; i < len; i++)
      {
         String table = tables[i];
         int off = len - 1 - i;
         sql[off] = "delete from " + table + " where " +
                    (i > 0 ? "parent__id" : Session.PK) + " = ?";
      }
      
      return sql;
   }
   
   /**
    * Getter for the {@code allowDBUniqueCheck} field.
    * 
    * @return  Boolean
    *          The value of the {@code allowDBUniqueCheck} field.
    */
   boolean getAllowDBUniqueCheck()
   {
      return allowDBUniqueCheck;
   }
   
   /**
    * Setter for the {@code allowDBUniqueCheck} field.
    * 
    * @param   value
    *          The value which will be assigned to the {@code allowDBUniqueCheck} field.
    *          
    * @return  The old value set for allow DB unique check.
    */
   boolean setAllowDBUniqueCheck(boolean value)
   {
      boolean oldValue = allowDBUniqueCheck;
      allowDBUniqueCheck = value;
      return oldValue;
   }
   
   /**
    * Insert the data of the given list of DMOs into its corresponding primary table and any
    * secondary tables in the database. Use SQL batching if the JDBC driver supports it,
    * otherwise, submit each insert statement individually. The DMOs must all be of the same
    * type.
    * <p>
    * This method must be called within a transaction for the data to be preserved. Scalar data
    * is inserted first into the primary table, followed by normalized extent field data, if any,
    * into any secondary tables.
    * 
    * @param   records
    *          List of one or more DMO instances whose data is to be inserted into the database.
    * 
    * @return  The count of records (primary and secondary) successfully inserted into the
    *          database.
    * 
    * @throws  PersistenceException
    *          if an error occurs accessing the database.
    */
   <T extends BaseRecord> int bulkInsert(List<T> records)
   throws PersistenceException
   {
      switch (records.size())
      {
         case 0:
            return 0;
         case 1:
            return insert(records.get(0));
         default:
            break;
      }
      
      T dmo = records.get(0);
      PreparedStatement[] preparedStatements = prepareInsertStatements(dmo);
      boolean batch = session.supportsBatch();
      
      try
      {
         int count = 0;
         
         for (T next : records)
         {
            count += insert(next, preparedStatements, true);
         }
         
         // if driver does not support SQL batch, the inserts were executed within the insert
         // method; otherwise, they were added to their respective prepared statement batches
         // and must be executed now
         if (batch)
         {
            for (PreparedStatement ps : preparedStatements)
            {
               count += executeBatch(ps);
            }
         }
         
         return count;
      }
      catch (SQLException exc)
      {
         if (LOG.isLoggable(Level.WARNING))
         {
            LOG.log(Level.WARNING, "Bulk insert error", exc);
         }
         
         throw new PersistenceException(exc);
      }
      finally
      {
         closePreparedStatements(preparedStatements);
      }
   }
   
   /**
    * Insert the data of the given DMO into its corresponding primary table and any secondary
    * tables in the database. This method must be called within a transaction for the data to be
    * preserved. Scalar data is inserted first into the primary table, followed by normalized
    * extent field data, if any, into any secondary tables. Batch execution is used if supported
    * by the JDBC driver.
    * 
    * @param   dmo
    *          DMO instance whose data is to be inserted into the database.
    * 
    * @return  The count of records (primary and secondary) successfully inserted into the
    *          database.
    * 
    * @throws  PersistenceException
    *          if an error occurs accessing the database.
    */
   <T extends BaseRecord> int insert(T dmo)
   throws PersistenceException
   {
      PreparedStatement[] ps = prepareInsertStatements(dmo);
      
      try
      {
         return insert(dmo, ps, false);
      }
      finally
      {
         closePreparedStatements(ps);
      }
   }
   
   /**
    * Delete the record(s) associated with the given DMO from the database. Secondary (foreign)
    * records, if any, are deleted first, then the primary record.
    * 
    * @param   dmo
    *          DMO whose records are to be deleted.
    * 
    * @throws  PersistenceException
    *          if there is a database error.
    */
   boolean delete(BaseRecord dmo)
   throws PersistenceException
   {
      boolean success = true;
      
      try
      {
         RecordMeta recMeta = dmo._recordMeta();
         String[] deleteSql = recMeta.deleteSql;
         
         Long id = dmo.primaryKey();
         
         for (int i = 0; i < deleteSql.length; i++)
         {
            Connection conn = session.getConnection();
            try (PreparedStatement ps = conn.prepareStatement(deleteSql[i]))
            {
               ps.setLong(1, id);
               
               if (LOG.isLoggable(Level.FINE))
               {
                  LOG.log(Level.FINE, "FWD ORM: " + ps);
               }
               
               DMLStatement f = PreparedStatement::execute;
               SQLExecutor.getInstance().execute(session.getDatabase(), deleteSql[i], f, ps);
            }
         }
      }
      catch (SQLException exc)
      {
         if (LOG.isLoggable(Level.WARNING))
         {
            String msg = "Error deleting dmo " + dmo.getClass().getName() + "#" + dmo.primaryKey();
            LOG.log(Level.WARNING, msg, exc);
         }
         
         throw new PersistenceException(exc);
      }
      
      // TODO: implement success check
      return success;
   }
   
   /**
    * Delete the record(s) associated with the given DMO from the database. Secondary (foreign)
    * records, if any, are deleted first, then the primary record.
    * 
    * @param   dmoClass
    *          DMO implementation class.
    * @param   id
    *          Primary key of record to be deleted.
    * 
    * @throws  PersistenceException
    *          if there is a database error.
    */
   boolean delete(Class<? extends BaseRecord> dmoClass, Long id)
   throws PersistenceException
   {
      boolean success = true;
      try
      {
         RecordMeta recMeta = Session.getMetadata(dmoClass); 
         String[] deleteSql = recMeta.deleteSql;
         
         for (int i = 0; i < deleteSql.length; i++)
         {
            Connection conn = session.getConnection();
            try (PreparedStatement ps = conn.prepareStatement(deleteSql[i]))
            {
               ps.setLong(1, id);
               
               if (LOG.isLoggable(Level.FINE))
               {
                  LOG.log(Level.FINE, "FWD ORM: " + ps);
               }
               
               DMLStatement f = PreparedStatement::execute;
               SQLExecutor.getInstance().execute(session.getDatabase(), deleteSql[i], f, ps);
            }
         }
      }
      catch (SQLException exc)
      {
         if (LOG.isLoggable(Level.WARNING))
         {
            String msg = "Error deleting dmo " + dmoClass.getName() + "#" + id;
            LOG.log(Level.WARNING, msg, exc);
         }
         
         throw new PersistenceException(exc);
      }
      
      // TODO: implement success check
      return success;
   }
   
   /**
    * Saves changes of the {@code dmo} by executing one or more {@code UPDATE} statements.
    * 
    * @param   dmo
    *          The object whose changes will be flushed to database.
    * @param   <T>
    *          The type(class) of the Record.
    *
    * @throws  PersistenceException
    *          In case of any issue encountered in the process.
    */
   <T extends BaseRecord> void update(T dmo)
   throws PersistenceException
   {
      if (allowDBUniqueCheck)
      {
         // Check if we can bypass the DirectAccessHelper
         if (directAccessDriver != null)
         {
            directAccessDriver.startValidate();
         }
         else
         {
            directAccessDriver = DirectAccessHelper.startValidate(session);
         }
      }
      
      BitSet mods = dmo.getDirtyProps();
      if (mods.isEmpty())
      {
         // no bits set, that is, nothing to update
         return;
      }
      
      PropertyMeta[] properties = dmo._recordMeta().getPropertyMeta(false);
      UpdateInfo updateInfo = null;
      synchronized (updateCache)
      {
         UpdateInfo key = new UpdateInfo(dmo.getClass(), (BitSet) mods.clone());
         updateInfo = updateCache.get(key);
         
         if (updateInfo == null)
         {
            updateInfo = key;
            updateCache.put(updateInfo, updateInfo);
            
            DmoMeta dmoInfo = DmoMetadataManager.getDmoInfo(dmo.getClass());
            
            // using LinkedHashMap in order to have a fixed navigation order
            Map<Integer, StringBuilder> sqls = new LinkedHashMap<>();
            Map<Integer, List<Integer>> paramsIdx = new LinkedHashMap<>();
            
            // scan altered fields and fill the SQL statement and the parameter for it
            int extIdx = 0;
            PropertyMeta lastProp = null;
            int len = properties.length;
            for (int i = 0; i < len; i++)
            {
               PropertyMeta prop = properties[i];
               if (prop == lastProp)
               {
                  ++extIdx;
               }
               else
               {
                  extIdx = 0;
                  lastProp = prop;
               }
               
               // dmo.isPropertyDirty(prop.offset + extIdx)
               if (mods.get(prop.offset + extIdx))
               {
                  int extent = prop.getExtent();
                  int sqlKey = extent * 100_000 + extIdx;
                  List<Integer> sqlPar = paramsIdx.get(sqlKey);
                  StringBuilder sqlBuilder = sqls.get(sqlKey);
                  if (sqlBuilder == null)
                  {
                     sqlBuilder = new StringBuilder();
                     sqlBuilder.append("update ").append(dmoInfo.getSqlTableName());
                     if (extent != 0)
                     {
                        sqlBuilder.append("__").append(extent);
                     }
                     sqlBuilder.append(" set ");
                     sqls.put(sqlKey, sqlBuilder);
                     
                     sqlPar = new ArrayList<>();
                     paramsIdx.put(sqlKey, sqlPar);
                  }
                  
                  if (!sqlPar.isEmpty())
                  {
                     sqlBuilder.append(", ");
                  }
                  
                  sqlBuilder.append(prop.getColumn()).append("=?");
                  sqlPar.add(prop.offset + extIdx);
                  
                  Property property = dmoInfo.getFieldInfo(prop.getName());
                  if (property._isDatetimeTz)
                  {
                     // adding zone offset support for dtz:
                     sqlBuilder.append(", ");
                     sqlBuilder.append(prop.getColumn())
                               .append(DDLGeneratorWorker.DTZ_OFFSET)
                               .append("=?");
                  }
               }
            }
            
            // finish the statement 
            Set<Map.Entry<Integer, StringBuilder>> entries = sqls.entrySet();
            for (Map.Entry<Integer, StringBuilder> entry : entries)
            {
               int sqlKey = entry.getKey();
               int extent = sqlKey / 100_000;
               extIdx = sqlKey % 100_000;
               List<Integer> sqlPar = paramsIdx.get(sqlKey);
               StringBuilder sqlBuilder = entry.getValue();
               sqlBuilder.append(" where ");
               
               sqlBuilder.append(extent == 0 ? Session.PK : "parent__id");
               sqlBuilder.append("=?");
               sqlPar.add(ReservedProperty.ID_PRIMARY_KEY);  // dmo.primaryKey()
               
               if (extent == 0 && dmo instanceof TempRecord) // TempTableRecord
               {
                  sqlBuilder.append(" and ").append(TemporaryBuffer.MULTIPLEX_FIELD_NAME).append("=?");
                  sqlPar.add(ReservedProperty.ID_MULTIPLEX);  // ((TempRecord) dmo)._multiplex()
               }
               
               if (extent > 0)
               {
                  sqlBuilder.append(" and list__index=?");
                  sqlPar.add(ReservedProperty.ID_LIST__INDEX);  // extIdx
               }
            }
            
            Map<Integer, String> sqlStrs = new LinkedHashMap<>();
            sqls.forEach((ext, strBuilder) -> sqlStrs.put(ext, strBuilder.toString()));
            
            updateInfo.configure(sqlStrs, paramsIdx);
         }
      }
      
      // create statement for each sql/params and execute it (in a single batch ?)
      Connection conn = session.getConnection();
      Set<Map.Entry<Integer, String>> entries = updateInfo.sqlStrs.entrySet();
      int updateCount = 0;
      
      try
      {
         for (Map.Entry<Integer, String> entry : entries)
         {
            int sqlKey = entry.getKey();
            int extent = sqlKey / 100_000;
            int extIdx = sqlKey % 100_000;
            String sql = entry.getValue();
            List<Integer> sqlPar = updateInfo.paramsIdx.get(sqlKey);
            
            try (PreparedStatement ps = conn.prepareStatement(sql))
            {
               int paramNo = 1;
               for (int k = 0; k < sqlPar.size(); k++)
               {
                  Object par = null;
                  ParameterSetter typeHandler = null;
                  Integer fieldId = sqlPar.get(k);
                  switch (fieldId)
                  {
                     case ReservedProperty.ID_PARENT__ID:
                     case ReservedProperty.ID_PRIMARY_KEY:
                        par = dmo.primaryKey();
                        typeHandler = TypeManager.getTypeHandler(Long.class);
                        break;
                     case ReservedProperty.ID_LIST__INDEX:
                        par = extIdx;
                        typeHandler = TypeManager.getTypeHandler(Integer.class);
                        break;
                     case ReservedProperty.ID_ROW_STATE:
                        par = ((TempRecord) dmo)._rowState();
                        typeHandler = TypeManager.getTypeHandler(Integer.class);
                        break;
                     case ReservedProperty.ID_PEER_ROWID:
                        par = ((TempRecord) dmo)._peerRowid();
                        typeHandler = TypeManager.getTypeHandler(Long.class);
                        break;
                     case ReservedProperty.ID_ERROR_STRING:
                        par = ((TempRecord) dmo)._errorString();
                        typeHandler = TypeManager.getTypeHandler(String.class);
                        break;
                     case ReservedProperty.ID_ORIGIN_ROWID:
                        par = ((TempRecord) dmo)._originRowid();
                        typeHandler = TypeManager.getTypeHandler(Long.class);
                        break;
                     case ReservedProperty.ID_DATASOURCE_ROWID:
                        par = ((TempRecord) dmo)._datasourceRowid();
                        typeHandler = TypeManager.getTypeHandler(Long.class);
                        break;
                     case ReservedProperty.ID_ERROR_FLAG:
                        par = ((TempRecord) dmo)._errorFlags();
                        typeHandler = TypeManager.getTypeHandler(Integer.class);
                        break;
                     case ReservedProperty.ID_MULTIPLEX:
                        par = ((TempRecord) dmo)._multiplex();
                        typeHandler = TypeManager.getTypeHandler(Integer.class);
                        break;
                     default:
                        if (fieldId < 0 || fieldId > dmo.data.length)
                        {
                           LOG.severe("Something went awfully wrong, array bounds exception");
                        }
                        par = dmo.data[fieldId];
                        typeHandler = properties[fieldId].getDataHandler();
                  }
                  
                  paramNo += typeHandler.setParameter(ps, paramNo, par, true);
               }
               
               if (LOG.isLoggable(Level.FINE))
               {
                  LOG.log(Level.FINE, "FWD ORM: " + ps);
               }

               FunctionWithException<PreparedStatement, Integer> f = PreparedStatement::executeUpdate;
               updateCount += SQLExecutor.getInstance().execute(session.getDatabase(), sql, f, ps);
               
               if (updateCount == 0 && LOG.isLoggable(Level.WARNING))
               {
                  LOG.log(Level.WARNING,
                          "Failed to UPDATE #" + dmo.primaryKey() + " of " + dmo.getClass().getName() + ".\n" +
                          ps);
               }
            }
            catch (SQLException exc)
            {
               String msg = "Failed to update dmo " + dmo.getClass().getName();
               
               if (LOG.isLoggable(Level.WARNING))
               {
                  // Skip the logging of UNIQUE VIOLATION - unique_violation exception because this might be just a
                  // failed attempt to update a record with validateMode enabled, meaning that no server-side index
                  // validation took place. If this is not the case, logging will happen later anyway.
                  Throwable cause = exc;
                  while (cause != null                   &&
                        !(cause instanceof SQLException) &&
                        cause != cause.getCause())
                  {
                     cause = cause.getCause();
                  }
                  while (cause != null && cause instanceof SQLException)
                  {
                     SQLException sqle = (SQLException) cause;
                     String sqlState = sqle.getSQLState();
                     if (sqlState != null && !"23505".equals(sqlState)) // UNIQUE VIOLATION - unique_violation
                     {
                        // Log this error since it is not a UNIQUE VIOLATION
                        LOG.log(Level.WARNING, "Error during update", sqle);
                     }
                     
                     cause = sqle.getCause();
                  }
               }
               
               throw new PersistenceException(msg, exc);
            }
         }
      }
      finally
      {
         if (allowDBUniqueCheck)
         {
            if (directAccessDriver != null)
            {
               directAccessDriver.stopValidate();
            }
            else
            {
               DirectAccessHelper.stopValidate(session);
            }
         }
      }
   }
   
   /**
    * Insert the data of the given DMO into its corresponding primary table and any secondary
    * tables in the database. This method must be called within a transaction for the data to be
    * preserved. Scalar data is inserted first into the primary table, followed by normalized
    * extent field data, if any, into any secondary tables. Batch execution is used if supported
    * by the JDBC driver.
    * 
    * @param   dmo
    *          DMO instance whose data is to be inserted into the database.
    * @param   preparedStatements
    *          Array of prepared, insert statements corresponding with {@code dmo}'s primary
    *          table and any secondary tables for normalized extent fields, in ascending order of
    *          extent.
    * @param   bulk
    *          {@code true} if this insert is part of a bulk insert; {@code false} if it
    *          represents the insert of a single record. If the former, the caller is responsible
    *          for executing the batches of statements this method prepares; if the latter, this
    *          method will execute the batch of statements directly.
    * 
    * @return  The count of records (primary and secondary) successfully inserted into the
    *          database.
    * 
    * @throws  PersistenceException
    *          if an error occurs accessing the database.
    */
   private <T extends BaseRecord>
   int insert(T dmo, PreparedStatement[] preparedStatements, boolean bulk)
   throws PersistenceException
   {
      try
      {
         RecordMeta recMeta = dmo._recordMeta();
         PropertyMeta[] props = recMeta.getPropertyMeta(false);
         String[] insertSql = recMeta.insertSql;
         int tables = preparedStatements.length;
         boolean batch = false;
         
         int i = 0;
         int count = 0;
         Long id = dmo.primaryKey();
         Object[] data = dmo.data;
         boolean onlyExtentProps = (props.length == 0) || (props[0].getExtent() > 0);
         
         if (onlyExtentProps)
         {
            // if the table has only extent fields or no fields at all, then insert the 'empty record' into
            //    the primary table
            PreparedStatement ps = preparedStatements[0];
            int crtParam = 1;
            if (dmo instanceof TempRecord)
            {
               TempRecord asTemp = (TempRecord) dmo;
               ps.setInt(crtParam++, asTemp._multiplex());
            }
            ps.setLong(crtParam++, id);
            
            if (batch)
            {
               // batch insert is executed either inline below (non-bulk), or by the caller (bulk)
               ps.addBatch();
            }
            else
            {
               // non-batch insert is executed inline here
               ps.execute();
               count++;
            }
         }
         
         for (int j = (onlyExtentProps ? 1 : 0); j < tables; j++)
         {
            PreparedStatement ps = preparedStatements[j];
            
            // batch inserts, if the driver supports it; if caller requested batch, batch every
            // statement, otherwise only batch secondary table inserts
            batch = bulk || (session.supportsBatch() && i > 0);
            
            PropertyMeta propMeta = props[i];
            
            int extent = propMeta.getExtent();
            
            // how many statements for this table?
            int stmts = !onlyExtentProps && i == 0 ? 1 : extent;
            
            // how many parameters in this series?
            int seriesSize = propMeta.seriesSize;
            
            for (int k = 0; k < stmts; k++)
            {
               int crtParam = 1;
               for (int m = 0; m < seriesSize; m ++)
               {
                  int off = (extent == 0 ? i++ : i + m * extent);
                  propMeta = props[off];
                  Object datum = data[off];
                  
                  ParameterSetter setter = propMeta.getDataHandler();
                  
                  if (setter != null)
                  {
                     // setter.setParameter(ps, propMeta.columnIndex, datum);
                     int colCnt = setter.setParameter(ps, crtParam, datum, true);
                     
                     if (colCnt != propMeta.columnCount)
                     {
                        throw new PersistenceException(
                              "Failed to set the correct number of parameters for property #" +
                              crtParam + " (" + datum + ") for statement " + insertSql[j]);
                     }
                     
                     crtParam += colCnt;
                  }
                  else
                  {
                     throw new PersistenceException(
                           "Failed to set parameter #" + crtParam +
                           " (" + datum + ") for statement " + insertSql[j]);
                  }
               }
               
               // for temp-table records, add the multiplexer
               if (extent == 0 && dmo instanceof TempRecord)
               {
                  TempRecord asTemp = (TempRecord) dmo;
                  ps.setInt(crtParam++, asTemp._multiplex());
               }
               
               // primary key (primary table insert) or foreign key (secondary table insert)
               ps.setLong(crtParam++, id);
               
               // set list__index parameter, if insert is to secondary table
               if (extent > 0)
               {
                  ps.setInt(crtParam++, k);
                  
                  // have to increment base index for propMeta and data arrays here, as it
                  // wasn't incremented in the seriesSize loop
                  i++;
               }
               
               if (LOG.isLoggable(Level.FINE))
               {
                  LOG.log(Level.FINE, "FWD ORM: " + ps);
               }
               
               if (batch)
               {
                  // batch insert is executed either inline below (non-bulk), or by the caller (bulk)
                  ps.addBatch();
               }
               else
               {
                  // non-batch insert is executed inline here
                  DMLStatement f = PreparedStatement::execute;
                  SQLExecutor.getInstance().execute(session.getDatabase(), (String) null, f, ps);
                  count++;
               }
            }
            
            // non-bulk batch insert is executed inline here; for bulk batch insert, caller
            // executes the batches (since the scope of the batch is larger than a single DMO)
            if (batch && !bulk)
            {
               count += executeBatch(ps);
            }
            
            // [i] was only advanced through first element in the extent series; need to push
            // it ahead now to the beginning of the next series
            i += (extent * (seriesSize - 1));
         }
         
         return count;
      }
      catch (SQLException exc)
      {
         if (LOG.isLoggable(Level.WARNING))
         {
            // Skip the logging of UNIQUE VIOLATION - unique_violation exception because this might be just
            // a failed attempt to insert a record with validateMode enabled, meaning that no server-side index
            // validation took place. If this is not the case, logging will happen later anyway.
            Throwable cause = exc;
            while (cause != null                   &&
                  !(cause instanceof SQLException) &&
                  cause != cause.getCause())
            {
               cause = cause.getCause();
            }
            while (cause != null && cause instanceof SQLException)
            {
               SQLException sqle = (SQLException) cause;
               String sqlState = sqle.getSQLState();
               if (sqlState != null          && 
                   !"23505".equals(sqlState) && // UNIQUE VIOLATION - unique_violation 
                   !"23000".equals(sqlState))   // SQLIntegrityConstraintViolationException // Duplicate entry
               {
                  // Log this error since it is not a UNIQUE VIOLATION
                  LOG.log(Level.WARNING, "Error during insert", sqle);
               }
               
               cause = sqle.getCause();
            }
         }
         
         throw new PersistenceException(exc);
      }
      finally
      {
         if (allowDBUniqueCheck)
         {
            if (directAccessDriver != null)
            {
               directAccessDriver.stopValidate();
            }
            else
            {
               DirectAccessHelper.stopValidate(session);
            }
         }
      }
   }
   
   /**
    * Prepare the insert statements for the table(s) associated with the given DMO. There will
    * be one statement for the primary table, plus one per secondary table for normalized extent
    * fields.
    * 
    * @param   dmo
    *          Data model object whose type is used to determine the insert statements to be
    *          prepared.
    * 
    * @return  An array of prepared insert statements, one for the primary table and the rest
    *          (if any) for the secondary, normalized, extent field tables, in ascending order
    *          of extent size.
    * 
    * @throws  PersistenceException
    *          if there is an error preparing the statements.
    */
   private <T extends BaseRecord> PreparedStatement[] prepareInsertStatements(T dmo)
   throws PersistenceException
   {
      // this needs to be done here, as the prepared statement caches the 'validate' state
      if (allowDBUniqueCheck)
      {
         // Check if we can bypass the DirectAccessHelper
         if (directAccessDriver != null)
         {
            directAccessDriver.startValidate();
         }
         else
         {
            directAccessDriver = DirectAccessHelper.startValidate(session);
         }
      }
      
      RecordMeta recMeta = dmo._recordMeta();
      String[] insertSql = recMeta.insertSql;
      int tables = insertSql.length;
      PreparedStatement[] ps = new PreparedStatement[tables];
      
      try
      {
         Connection conn = session.getConnection();
         
         for (int i = 0; i < tables; i++)
         {
            ps[i] = conn.prepareStatement(insertSql[i]);
         }
      }
      catch (SQLException exc)
      {
         if (LOG.isLoggable(Level.WARNING))
         {
            String msg = "Error preparing insert statement(s) for " +
                         dmo.getClass().getName() + "#" + dmo.primaryKey();
            LOG.log(Level.WARNING, msg, exc);
         }
         
         throw new PersistenceException(exc);
      }
      
      return ps;
   }
   
   /**
    * Close the given prepared statements.
    * 
    * @param   ps
    *          Array of prepared statements to close.
    * 
    * @throws  PersistenceException
    *          if there is an error closing the prepared statements.
    */
   private void closePreparedStatements(PreparedStatement[] ps)
   throws PersistenceException
   {
      try
      {
         for (PreparedStatement next : ps)
         {
            next.close();
         }
      }
      catch (SQLException exc)
      {
         if (LOG.isLoggable(Level.WARNING))
         {
            LOG.log(Level.WARNING, "Error closing prepared statements", exc);
         }
         
         throw new PersistenceException(exc);
      }
   }
   
   /**
    * Execute a batch of commands using the given prepared statement.
    * 
    * @param   ps
    *          Prepared statement.
    * 
    * @return  The count of rows affected by the batch execution.
    *          TODO: handle possible status codes returned by executeBatch()
    * 
    * @throws  SQLException
    *          if a database error occurs.
    */
   private int executeBatch(PreparedStatement ps)
   throws SQLException
   {
      UpdateBatchStatement f = PreparedStatement::executeBatch;
      int[] counts = SQLExecutor.getInstance().execute(session.getDatabase(), (String) null, f, ps);
      
      int total = 0;
      for (int i = 0; i < counts.length; i++)
      {
         int count = counts[i];
         if (count >= 0)
         {
            total += count;
         }
         else if (count == Statement.SUCCESS_NO_INFO)
         {
            // the batch statement executed successfully but the number of affected rows is not available.
            total ++;
         }
         else
         {
            // element values < 0 indicate status codes
            if (LOG.isLoggable(Level.WARNING))
            {
               LOG.log(Level.WARNING, "Failed to batch execute (" + i + "): " + ps);
            }
         }
      }
      
      return total;
   }
   
   /**
    * This class acts both as key and value for storing the information on a cached {@code UPDATE}
    * set of statements. The key part is formed by the {@code type} and {@code state} members,
    * while the {@code sqlStrs} and {@code paramsIdx} are the 'payload': the list of statements as
    * strings, respectively the indexes of the fields that are updated by respective statement.
    * <p>
    * As result objects of this class have two usages:
    * <ul>
    *    <li>cache lookup: only the {@code type} and {@code state} are set ({@code sqlStrs} and
    *          {@code paramsIdx} remain unset). This is enough to locate a record in the cache;
    *          </li>
    *    <li>storage: all fields are set. The object is both the key and the value in the cache
    *          map.</li>
    */
   private static class UpdateInfo
   {
      /** The DMO that is affected by the cached statements stored here. Part of the key. */
      private final Class<? extends BaseRecord> type;
      
      /** The set of properties that are updated by the statements. Part of the key. */
      private final BitSet state;
      
      /** 
       * The hash value. It is computed a the in the constructor for the immutable part of the
       * object.
       */
      private final int hash;
      
      /** The list of UPDATE statements. Part of the value payload. */
      private Map<Integer, String> sqlStrs = null;
      
      /** 
       * The indexes of properties that are used in the corresponding statement.
       * Part of the value payload.
       */
      private Map<Integer, List<Integer>> paramsIdx = null;
      
      /**
       * The constructor only takes as parameters the data for key part.
       *
       * @param   type
       *          The {@code Class} of the record to be processed.
       * @param   state
       *          The set of bits representing the properties altered that need to be updated.
       */
      public UpdateInfo(Class<? extends BaseRecord> type, BitSet state)
      {
         this.type = type;
         this.state = state;
         
         // compute hash code.
         int result = type.hashCode();
         result = 31 * result + state.hashCode();
         hash = result;
      }
      
      /**
       * Test for equality with another object {@code o}.
       * 
       * @param   o
       *          The object to test for equality.
       * 
       * @return  {@code true} only when {@code this} and {@code o} represent the same logical
       *          object.
       */
      @Override
      public boolean equals(Object o)
      {
         if (this == o)
         {
            return true;
         }
         if (o == null || getClass() != o.getClass())
         {
            return false;
         }
         
         UpdateInfo that = (UpdateInfo) o;
         if (this.hash != that.hash)
         {
            return false;
         }
         
         if (!type.equals(that.type))
         {
            return false;
         }
         return state.equals(that.state);
      }
      
      /**
       * Returns the pre-computed hash code.
       * 
       * @return  the pre-computed hash code.
       */
      @Override
      public int hashCode()
      {
         return hash;
      }
      
      /**
       * Configures this object for usage.
       * 
       * @param   sqlStrs
       *          The SQL UPDATE statements, mapped by the extent.
       * @param   paramsIdx
       *          The indexes in the {@code data} for each SQL positional parameter.
       */
      public void configure(Map<Integer, String> sqlStrs, Map<Integer, List<Integer>> paramsIdx)
      {
         this.sqlStrs = sqlStrs;
         this.paramsIdx = paramsIdx;
      }
   }
}