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}'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;
}
}
}