BaseRecord.java

/*
** Module   : BaseRecord.java
** Abstract : The abstract base class for all business data model object classes.
**
** Copyright (c) 2019-2023, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description---------------------------------------
** 001 ECF 20191001 Created initial version with basic data and accessors.
**     OM  20200110 Added support various data types and setter/getter signatures.
** 002 CA  20200922 Added getDatum(offset).
**     ECF 20201201 Cleaned up file and added some missing javadoc.
**     AIL 20201210 Added bulk set datum for specific buffer-copy operations.
**     ECF 20201210 Added direct access to data array for RecordBuffer.
**         20210126 Fixed getUnvalidatedIndices to account optionally for untouched, default property values.
**     AIL 20210129 Added support for bulkDataChanged. Improved bulk copy (doing lock and report only once).
**     OM  20201027 copy() method will update the status flags to allow the record to be correctly saved.
**     OM  20210404 CLOB fields are created and returned with their CP set.
**     ECF 20210423 Fixed mismatch between updates to dirtyProps and activePreviousValue, which resulted in
**                  incorrect before/after diffs being reported. Renamed setAllDatum to setAllData.
**     ECF 20210519 Added "in-use" tracking to report whether this record is referenced by any buffers or is
**                  being tracked for UNDO purposes.
**     ECF 20210805 Properly enable nested calls to set and restore the activeBuffer and related state.
**     ECF 20210924 Refactored debug string representation.
**     CA  20210929 Cache the PK getter method.
**     OM  20211020 Added _DATASOURCE_ROWID property.
**     CA  20220420 Extracted 'initialize' method which sets the default field values and sets the initial 
**                  state for indices, nulls and validation bitsets.  This is required for Record arguments
**                  used in a Java REST service, which must not be associated with a session.
**     ECF 20221005 Added support for dynamic initial values.
**     TJD 20220504 Upgrade do Java 11 minor changes
**     CA  20221109 Implemented runtime for EXCEPT/FIELDS options - only NO-LOCK records are loaded as 
**                  'partial/incomplete'.
**     TJD 20230103 The defaults MUST be initialized BEFORE the updateState call
**     CA  20220613 'getCodePage' must ignore an empty codepage set at the field.
**     CA  20230110 getDirtyIndices and getUnvalidatedIndices now return null instead of an empty BitSet, when
**                  no indices are found.
**     CA  20221109 Implemented runtime for EXCEPT/FIELDS options - only NO-LOCK records are loaded as 
**                  'partial/incomplete'.
** 003 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
** 004 EVL 20230605 Fix from OM for crash with updating state of the rolled back record. 
** 005 SR  20230731 Created getSize() method to return the size of a record in bytes.
** 006 AL2 20240409 Reworked references to store both the number of real references and transient references.
** 007 SP  20240805 Added new restoreActiveState method.
** 008 TJD 20230303 Allow performing a sub-transaction level changeset reset and roll-up 
**     TJD 20230306 Reset changeset changes on roll-up 
**     TJD 20231019 fix resetChangeset function name 
** 009 AS  20241010 Reworked activeOffset into activeOffsets(BitSet) to support
**                  validation of a subset of indices.
**     AS  20241010 Reworked activePropPreviousValues as Object[] to hold multiple values.
** 010 AL2 20241105 Added isReferenced API.
** 011 ES  20241203 Added new hasIndices method.
** 012 AS  20250220 Refactored getActiveUpdateDiff to return the diff for only one property.
** 013 AS  20250227 Store the result of applyInitialValues in nullProps, rather than pass it as parameter. 
*/

/*
** 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 com.goldencode.p2j.persist.*;
import com.goldencode.p2j.rest.serializers.*;
import com.goldencode.p2j.schema.*;
import com.goldencode.p2j.util.*;
import com.goldencode.p2j.util.logging.*;

import java.lang.reflect.*;
import java.sql.*;
import java.util.*;
import java.util.concurrent.atomic.*;

/**
 * Base record class which stores data and current state for a single record, and which provides services to
 * application-specific, DMO implementation subclasses.
 * <p>
 * Access to the internal state of this class is not thread-safe. Thus, instances are <strong>not</strong>
 * intended to be accessed from more than one thread.
 */
public abstract class BaseRecord
implements DmoState,
           Persistable
{
   /** Logger */
   private static final CentralLogger LOG = CentralLogger.get(BaseRecord.class);
   
   /** The cached getter method for the PK property. */
   public static final Method PK_GETTER;
   
   static
   {
      try
      {
         PK_GETTER = BaseRecord.class.getMethod("primaryKey");
      }
      catch (NoSuchMethodException | 
             SecurityException e)
      {
         throw new RuntimeException("Could not resolve PK getter!", e);
      }
   }
   
   /** Surrogate primary key of the record */
   protected Long id = null;
   
   /** The data represented by this record, in it's low-level (database friendly) form */
   protected final Object[] data;
   
   /** For an incomplete record, this holds the fields which were read or assigned in this record. */
   protected BitSet readFields = null;
   
   /** Current state of the record */
   protected final RecordState state = new RecordState();
   
   /** 
    * Count of references to this DMO; while positive, it must remain in the current session cache.
    * The 4 high bytes represent the number of buffers the record is transiently loaded in.
    * The 4 low bytes represent the number of active buffers the record is loaded in.
    */
   protected long references = 0;
   
   /** Map of properties touched, each bit corresponds with a property in the data array */
   private final BitSet dirtyProps;
   
   /** Map of properties currently requiring validation */
   private final BitSet unvalidated;
   
   /** Map of properties currently set to null, indicating unknown value */
   private final BitSet nullProps;
   
   /** Tracker to manage which indices have been updated by property updates */
   private final IndexState indexState;
   
   /** Record identifier (lock variant, which uses table name) for this record (created lazily) */
   private RecordIdentifier<String> recid = null;
   
   /** The currently active buffer when state is changing */
   private RecordBuffer activeBuffer = null;
   
   /** The offsets of the properties currently being modified. */
   protected BitSet activeOffsets = null;
   
   /** The most recent values of the properties currently being modified, for rollback purposes */
   private Object[] activePropPreviousValues = null;
   
   /** Object which tracks change to enable rollback of the record's data and state */
   private ChangeSet changeSet = null;
   
   /** Current version number, for stale checks */
   private int version = DmoVersioning.UNVERSIONED;
   
   /** Shared version data structure, for version updates and stale checks */
   private transient AtomicIntegerArray sharedVersion = null;
   
   /**
    * Default constructor which can only be invoked by subclasses. This constructor does not initialize
    * most state-related data structures. If these are needed for legacy record behavior, the {@link
    * #initialize(Session, boolean)} method should be invoked after construction.
    */
   protected BaseRecord()
   {
      RecordMeta recMeta = _recordMeta();
      
      int size = recMeta.getPropertyMeta(false).length;
      
      // initialize data array
      data = new Object[size];
      
      // initialize property-level state bit sets
      dirtyProps = new BitSet(size);
      unvalidated = new BitSet(size);
      nullProps = new BitSet(size);
      
      // track all indices which require full update (and validation) to enable record visibility
      int ulen = recMeta.uniqueIndices.length;
      int nlen = recMeta.nonuniqueIndices.length;
      indexState = new IndexState(ulen, nlen, recMeta.getPrimaryIndexId());
      activeOffsets = new BitSet();
      activePropPreviousValues = new Object[size];
   }
   
   /**
    * Copy the source record to the destination record.
    * <p>
    * The records are checked first to decide whether they are compatible (I.e. the number of properties
    * matches and also the type for each field, including the optional extent attribute). If not, the method
    * returns {@code false} and not copy operation is executed.
    * <p>
    * If the records pass validation, all properties are copied from source ({@code from}) to destination
    * {@code to}). At the same time, the status flags are updated so that the destination record can be
    * correctly saved.
    * 
    * @param   to
    *          The destination record.
    * @param   from
    *          The source record.
    * @param   b4reserved
    *          If {@code true} the before-table specific attributes are copied, too. Otherwise only the normal
    *          properties are processed.
    *
    * @return  {@code true} if the copy was performed (record's tables match).
    */
   public static boolean copy(BaseRecord to, BaseRecord from, boolean b4reserved)
   {
      PropertyMeta[] fromProps = from._recordMeta().getPropertyMeta(false);
      PropertyMeta[] toProps = to._recordMeta().getPropertyMeta(false);
      
      if (fromProps.length != toProps.length)
      {
         return false;
      }
      
      for (int i = 0; i < fromProps.length; i++)
      {
         PropertyMeta fromProp = fromProps[i];
         PropertyMeta toProp = toProps[i];
         
         if (fromProp.getType() != toProp.getType() || fromProp.getExtent() != toProp.getExtent())
         {
            return false;
         }
      }
      
      for (int i = 0; i < fromProps.length; i++)
      {
         if (!b4reserved)
         {
            switch (fromProps[i].getId())
            {
               case ReservedProperty.ID_ERROR_FLAG:
               case ReservedProperty.ID_ERROR_STRING:
               case ReservedProperty.ID_ROW_STATE:
               case ReservedProperty.ID_ORIGIN_ROWID:
               case ReservedProperty.ID_DATASOURCE_ROWID:
               case ReservedProperty.ID_PEER_ROWID:
                  // skip these attributes/properties
                  continue;
            }
         }
         
         // TODO: LOB data is mutable, needs to be duplicated explicitly
         boolean change = to.data[i] != from.data[i];
         if (change)
         {
            to.data[i] = from.data[i];
            
            // update the null property bitset and mark the property as changed to be able to save it
            to.nullProps.set(i, to.data[i] == null);
            to.dirtyProps.set(i);
         }
      }
      
      // mark it as changed
      to.updateState(CHANGED, true);
      
      return true;
   }
   
   /**
    * Populate the record's data with its default, initial values and update the record and
    * property states accordingly. Mark the record as newly created.
    * <p>
    * This method should be invoked only once, immediately after the object is constructed. It
    * is not invoked directly from the constructor, because the only use case which requires the
    * initialization performed here is a new data record being created on behalf of application
    * logic.
    * <p>
    * This is done separately from construction because there are use cases where an instance of
    * this class is needed, but we do not need the overhead of initializing the data with default
    * values, as they will be overwritten anyway. For instance, if the object is read from the
    * database or a copy/snapshot is being made, the data array will be populated with the values
    * retrieved from the database or copied from the other record, respectively.
    * <p>
    * It is not invoked optionally from the constructor, because a default constructor called
    * from subclasses is preferable, as the subclasses are often constructed by reflection.
    * <p>
    * This operation updates the not-null bitset, based on the values of non-nullable columns.
    * 
    * @param   session
    *          Database session, which is needed if this record must have its changes tracked for
    *          UNDO/rollback purposes; otherwise, may be {@code null}.
    * @param   setDefaults
    *          If {@code true}, set all default property values; if {@code false}, leave data array
    *          filled with null values.
    */
   public void initialize(Session session, boolean setDefaults)
   {
      // the defaults MUST be initialized BEFORE the updateState call
      initialize(setDefaults);
      
      // mark the record as newly created; this should be the only place this flag is set;
      // a newly created record is considered invalid until it passes validation, even though
      // its initial data may in fact be valid
      updateState(session, NEW, true);
   }
   
   /**
    * Initialize this record instance, by setting all fields to their default values and also initializing
    * the {@link #indexState}, {@link #unvalidated} and {@link #nullProps} states.
    * <p>
    * This method should be invoked only once, immediately after the object is constructed. It is not invoked 
    * directly from the constructor, because the only use case which requires the initialization performed 
    * here is a new data record being created on behalf of application logic.
    * <p>
    * This will be used by, {@link RecordSerializer JSON deserialization} when creating a new record for an
    * instance associated with the web service request (either request or response).
    * 
    * @param    setDefaults
    *           If {@code true}, set all default property values; if {@code false}, leave data array
    *           filled with null values.
    */
   public void initialize(boolean setDefaults)
   {
      RecordMeta recMeta = _recordMeta();
      
      // for a newly created record, all index state bits need to be set; they will be cleared one at a
      // time, as each index is updated
      indexState.setAll(recMeta.uniqueIndices.length, recMeta.nonuniqueIndices.length);
      
      if (setDefaults)
      {
         nullProps.clear();
         nullProps.or(recMeta.applyInitialValues(data));
         unvalidated.or(recMeta.getValidatableProperties());
      }
   }
   
   /**
    * Indicate whether this DMO is referenced by any buffer.
    * 
    * @return  {@code true} if in use by the above definition, else {@code false}.
    */
   public boolean isReferenced()
   {
      return references > 0;
   }
   
   /**
    * Indicate whether this DMO is "in use"; that is, whether buffers reference it or it is being tracked
    * for UNDO processing.
    * 
    * @return  {@code true} if in use by the above definition, else {@code false}.
    */
   public boolean isInUse()
   {
      return isReferenced() || checkState(TRACKED);
   }
   
   /**
    * Get the update state of this record's indices.
    * 
    * @return  Index state.
    */
   public IndexState getIndexState()
   {
      return indexState;
   }
   
   /**
    * Get the database identifier for this persistable object. This generally equates to the
    * backing table's primary key. However, it will be {@code null} for DMO instances which have
    * not yet been persisted to the database.
    *
    * @return  Unique, database identifier associated with this object.
    */
   @Override
   public final Long primaryKey()
   {
      return id;
   }
   
   /**
    * Set the database identifier for this persistable object (i.e., surrogate primary key).
    *
    * @param   id
    *          Unique, database identifier to be associated with this object.
    */
   @Override
   public final void primaryKey(Long id)
   {
      this.id = id;
   }
   
   /**
    * Copy this record's data and record state, including its primary key. The copy MUST NOT find its way
    * into the {@link Session} cache.
    * 
    * @return  Copy of this record.
    */
   public BaseRecord deepCopy()
   {
      BaseRecord copy = null;
      try
      {
         copy = getClass().getDeclaredConstructor().newInstance();
      }
      catch (ReflectiveOperationException exc)
      {
         throw new RuntimeException(exc);
      }
      
      // copies data and primary key
      copy.copy(this);
      
      // OR works here because copy's bitsets are initialized to all bits cleared
      copy.dirtyProps.or(this.dirtyProps);
      copy.nullProps.or(this.nullProps);
      
      // record state
      copy.state.state = this.state.state;
      
      // record identifier is immutable; ok to reference same object from copy
      copy.recid = this.recid;
      
      return copy;
   }
   
   /**
    * Makes this a copy of a source {@code BaseRecord} object. For permanent databases, the
    * internal {@code data} and the {@code id} of the {@code BaseRecord} are copied.
    * <p>
    * The only state copied is that of which properties are dirty (all of them after the copy).
    * Thus, this method is for specialized, internal use only, such as creating a new record in
    * order to copy data from one temp-table buffer to another.
    * 
    * @param   from
    *          The source to copy data from.
    */
   public void copy(BaseRecord from)
   {
      // TODO: BLOB data is mutable, needs to be duplicated explicitly
      int len = this.data.length;
      this.id = from.id;
      System.arraycopy(from.data, 0, this.data, 0, len);
      dirtyProps.set(0, len); // TODO: nullProps?
   }
   
   /**
    * Checks whether a set of record state flags are set for this record.
    * 
    * @param   flags
    *          One or more {@code RecordState} flags, bitwise OR'd together.
    * 
    * @return  {@code true} if ALL flags are set.
    * 
    * @see     RecordState
    */
   public boolean checkState(int flags)
   {
      return (state.state & flags) == flags;
   }
   
   /**
    * Indicate whether the record has passed validation. A newly created and initialized record
    * or a record which has had any of its properties changed is considered invalid, until it
    * passes validation.
    * 
    * NOTE: in fact, this is the state the record after the last validation, it might have been
    *       changes meanwhile so the state might be staled, check the NEED_VALIDATION bit to see
    *       if this is the case and call validation routines 
    * 
    * @return  {@code true} if this record has passed validation and has not since had any of its
    *          properties changed, else {@code false}.
    */
   public boolean isValid()
   {
      return !checkState(INVALID);
   }
   
   /**
    * Updates the set of state flags for this record. Depending on {@code set} parameter, the
    * flags can be set (when {@code true}) or removed (when {@code false}). If one flag is already
    * set and {@code set == true} that flag remains unchanged. The same applies for un-setting an
    * inactive flag.
    * 
    * @param   session
    *          Database session (used for UNDO purposes).
    * @param   flags
    *          A set of OR-ed {@code RecordState} flags to be updated.
    * @param   set
    *          If {@code true} the flags will be set, otherwise they will be unset.
    * 
    * @see     RecordState
    */
   public void updateState(Session session, int flags, boolean set)
   {
      // if this update will actually change the current state, track the change for possible rollback;
      // only track state changes which do not affect the fundamental nature of the record
      if (session != null                             && 
          (state.state & flags) != (set ? flags : 0)  && 
          ((flags & (NOUNDO | COPY)) == 0)            &&
          (set || flags != CACHED))
      {
         try
         {
            int txLevel = prepareChangeSet(session);
            if (txLevel >= 0)
            {
               changeSet.logStateChange(state.state, txLevel);
            }
         }
         catch (PersistenceException exc)
         {
            // TODO: handle
            LOG.severe("", exc);
         }
      }
      
      updateState(flags, set);
   }
   
   /**
    * Get the unique identifier for this record, comprised of its table name and primary key.
    * 
    * @return  Record identifier.
    */
   public RecordIdentifier<String> getRecordLockIdentifier()
   {
      if (recid == null)
      {
         recid = new RecordIdentifier<>(getTable(), id);
      }
      
      return recid;
   }
   
   /**
    * Report whether the table associated with this record has any (legacy) index.
    * 
    * @return  {@code true} if there is at least one legacy index defined in the table of this record.
    */
   public boolean isIndexed()
   {
      return !_recordMeta().allIndexedProps.isEmpty();
   }
   
   /**
    * Set the record buffer which is currently active for this record. This method is called to set the
    * active buffer temporarily associated with this record before some work is done to update this record's
    * data. This may be an update to a single datum or a bulk update.
    * <p>
    * A call to this method must be bracketed with a call to {@code run} the {@code Runnable} returned by
    * this method. This allows nested invocations and ensures that the active buffer and related state are
    * reset properly after the update work is done.
    * 
    * @param   buffer
    *          The buffer which is considered the &quot;active&quot; buffer for a subsequent unit of update
    *          work.
    * 
    * @return  A {@code Runnable} which MUST be executed in a {@code finally} block when the update work
    *          for which the active buffer was set is finished.
    */
   public Runnable setActiveBuffer(RecordBuffer buffer)
   {
      // remember the previously active buffer, offset, and datum value for restoration of state
      RecordBuffer previousBuffer = activeBuffer;
      BitSet previousOffsets = (BitSet) activeOffsets.clone();
      Object[] propPreviousValue = activePropPreviousValues.clone();
      boolean propPreviousValueEmpty = true;
      for (int i = 0; i < propPreviousValue.length; i++)
      {
         if (propPreviousValue[i] != null)
         {
            propPreviousValueEmpty = false;
         }
      }
      
      // set active buffer
      activeBuffer = buffer;

      if (previousBuffer == null && previousOffsets.cardinality() == 0 && propPreviousValueEmpty)
      {
         return this::restoreActiveState;
      }
      
      return () ->
      {
         // reset active buffer to previous state (null for outermost call; no-op for nested call)
         activeBuffer = previousBuffer;
         
         // reset remaining state to previous values
         activePropPreviousValues = propPreviousValue.clone();
         activeOffsets = previousOffsets;
      };
   }

   /**
    * Restore to default active buffer, offset, and datum value.
    */
   public void restoreActiveState()
   {
      activeBuffer = null;
      activeOffsets.clear();
      activePropPreviousValues = new Object[_recordMeta().getPropertyMeta(false).length];
   }

   /**
    * Indicate whether any property in the record currently is dirty (i.e., touched, not necessarily changed).
    * 
    * @return  {@code true} if any property was touched, else {@code false}.
    */
   public boolean isDirty()
   {
      return !dirtyProps.isEmpty();
   }
   
   /**
    * Indicate whether all index components for any index on this record are dirty (i.e., have been touched,
    * not necessarily changed..
    * 
    * @return  {@code true} if any index is fully dirty.
    */
   public boolean isAnyIndexFullyDirty()
   {
      RecordMeta rm = _recordMeta();
      
      return isAnyIndexFullyDirty(rm.uniqueIndices) || isAnyIndexFullyDirty(rm.nonuniqueIndices);
   }
   
   /**
    * Indicate whether tables have any indices.
    * 
    * @return  {@code true} if the table have any index.
    */
   public boolean hasIndices()
   {
      RecordMeta rm = _recordMeta();

      return rm.uniqueIndices.length > 0 || rm.nonuniqueIndices.length > 0;
   }
   
   // Undo the current/active update
   public void undoActive()
   {
      // TODO: complete this implementation: what about state? we probably need to preserve the
      //       previous state and roll this back as well
      for (int i = activeOffsets.nextSetBit(0); i != -1; i = activeOffsets.nextSetBit(i + 1))
      {
         data[i] = activePropPreviousValues[i];
      }
   }
   
   /**
    * Return a string representation of this object, primarily for debug purposes.
    * 
    * @return  String representation of this object.
    */
   @Override
   public String toString()
   {
      return toString(true);
   }
   
   /**
    * Return a string representation of this object, primarily for debug purposes, optionally including the
    * record's data in the output.
    * 
    * @param   includeData
    *          {@code true} to include the record's data. If {@code false}, all other state still will be
    *          included.
    * 
    * @return  String representation of this object.
    */
   public String toString(boolean includeData)
   {
      String sep = System.lineSeparator();
      
      StringBuilder buf = new StringBuilder(_recordMeta().tables[0]);
      buf.append(':').append(primaryKey()).append(sep)
         .append(state).append(sep)
         .append("references: ").append(references).append(sep)
         .append("dirty: ").append(dirtyProps).append(sep)
         .append("unvalidated: ").append(unvalidated);
      
      if (includeData)
      {
         int len = data.length;
         buf.append(sep).append("data: {");
         for (int i = 0; i < len; i++)
         {
            if (i > 0)
            {
               buf.append(", ");
            }
            
            buf.append(data[i]);
         }
         
         buf.append('}');
      }
      
      return buf.toString();
   }
   
   /**
    * This accessor allows direct access to private {@code data} array <strong>only to</strong>
    * instances of {@code PropertyMapper}. This is needed because the these instances need direct
    * and fast access the {@code data} and avoid normal status management of a record when the
    * properties are accessed using the {@code Record}'s setters and getters. Instances of
    * {@code PropertyMapper} are created only when the import is run so that management is not
    * required nor needed.
    * 
    * @param   pm
    *          The {@code PropertyMapper} which requests access.
    *
    * @return  The reference to internal {@code data} array if the passed parameter is a
    *          {@code PropertyMapper}
    *
    * @throws  NullPointerException
    *          If the passed in parameter is {@code null}.
    */
   public Object[] getData(PropertyMapper pm)
   {
      Objects.requireNonNull(pm);
      
      return data;
   }
   
   /**
    * This accessor allows direct access to private {@code data} array <strong>only to</strong>
    * instances of {@code RecordBuffer}.
    * 
    * @param   rb
    *          The {@code RecordBuffer} which requests access.
    * 
    * @return  The reference to internal {@code data} array if the passed parameter is a
    *          {@code RecordBuffer}
    * 
    * @throws  NullPointerException
    *          If the passed in parameter is {@code null}.
    */
   public Object[] getData(RecordBuffer rb)
   {
      Objects.requireNonNull(rb);
      
      return data;
   }
   
   /**
    * Indicate whether any of this record's validatable properties are dirty and have not been validated.
    * 
    * @return  {@code true} if record needs validation, else {@code false}.
    */
   public boolean needsValidation()
   {
      return !unvalidated.isEmpty();
   }
   
   /**
    * Get a bit set representing dirty unique or non-unique indices, subject to the conditions described by
    * the parameters. A set bit in the returned set indicates a dirty index. The position of each set bit
    * corresponds with the 0-based position of the associated index in the array of unique or non-unique
    * indices for the record.
    * 
    * @param   dirtyOffsets
    *          Optional offsets of dirty properties which must be a component of the indices returned.
    * @param   unique
    *          {@code true} to return dirty unique indices; {@code false} to return dirty non-unique indices.
    * @param   remaining
    *          If {@code true}, only <em>unvalidated</em> indices are returned. Otherwise, validation is not
    *          considered.
    * 
    * @return  A bit set, as described above.  <code>null</code> is returned when no indices are found.
    */
   public BitSet getDirtyIndices(BitSet dirtyOffsets, boolean unique, boolean remaining)
   {
      RecordMeta recMeta = _recordMeta();
      BitSet filter = new BitSet();
      BitSet[] indices = unique ? recMeta.uniqueIndices : recMeta.nonuniqueIndices;
      
      if (remaining)
      {
         filter.or(indexState.getBitSet(unique));
      }
      else
      {
         filter.set(0, indices.length);
      }
      
      return getDirtyIndices(indices, dirtyOffsets, filter, (state.state & NEW) == NEW);
   }
   
   /**
    * Get an Object array representing the old and new values of the property currently being changed
    * with the properties' offsets as keys and a two element arrays as values.
    * The first element is the previous value of the property; the second is the new value. If the property
    * has not been touched or the old and new values are the same, {@code null} is returned.
    * <p>
    * <strong>Note:</strong> this method will only return a non-null result while a property update is
    * active; that is, while a DMO property is being updated via the {@code RecordBuffer} invocation handler.
    * It is only at that moment that the previous value for the property being changed is guaranteed to
    * contain meaningful data.
    * 
    * @param   ormIndex
    *          The index of the property.
    * @param   extentIndex
    *          The offset index of the extent.
    *
    * @return  See above.
    */
   public Object[] getActiveUpdateDiff(int ormIndex, int extentIndex)
   {
      int actualIndex = ormIndex + extentIndex;
      if (dirtyProps.get(actualIndex) && !Objects.equals(activePropPreviousValues[actualIndex], 
                                                        data[actualIndex]))
      {
         Object[] res = new Object[2];
         res[0] = activePropPreviousValues[actualIndex];
         res[1] = data[actualIndex];
         return res;
      }
      return null;
   }
   
   /**
    * Return the set of bits which are marked dirty. For performance reasons, the object is not copied before
    * being returned. The caller MUST NOT alter its state!
    * 
    * @return  Bit set of dirty properties (those that have been touched, not necessarily changed).
    */
   public BitSet getDirtyProps()
   {
      return dirtyProps;
   }
   
   /**
    * Get the {@link #readFields}.
    * 
    * @return   The {@link #readFields}.
    */
   public BitSet _getReadFields()
   {
      return readFields;
   }

   /**
    * Set a single datum into the data array, and update the record and property state accordingly.
    * 
    * @param   offset
    *          Zero-based offset of the datum in the data array.
    * @param   datum
    *          Value to be set into the data array.
    */
   protected void setDatum(int offset, Object datum)
   {
      if (readFields != null)
      {
         readFields.set(offset);
      }

      if (activeBuffer != null)
      {
         activeOffsets.set(offset);
      }
      
      // update the bitset of dirty (touched) properties
      dirtyProps.set(offset);
      
      // temporarily save the old value (even if it is the same as the new value)
      activePropPreviousValues[offset] = data[offset];
      
      // for BigDecimal, equals checks the scale - we need compareTo, to check if the values are really the 
      // same, regardless of the scale value.
      if (!(Objects.equals(data[offset], datum) || 
            (datum instanceof Comparable && 
             data[offset] != null        && 
             ((Comparable) datum).compareTo(data[offset]) == 0)))
      {
         // ensure we have an exclusive lock on the record
         if (!lockForUpdate())
         {
            // do not pass go, do not collect $200
            return;
         }
         
         try
         {
            dataChanged(offset, datum);
         }
         catch (PersistenceException exc)
         {
            throw new RuntimeException(exc);
         }
         
         // update the value in the data array
         data[offset] = datum;
      }
   }
   
   /**
    * Set all data in the data array. This works only if the source has the same structure as the
    * destination (this). A prevalidation should be done - this will only check integrity, type and extents.
    * The order of the matching will be from left to right and it will avoid reserved properties.
    * 
    * @param   source
    *          A record from which the data will be copied to this data array.
    * 
    * @return  {@code true} if a valid copying could be done; so the properties match in type and extent and
    *          the number of properties match (except the reserved ones). 
    */
   protected boolean setAllData(BaseRecord source)
   {
      PropertyMeta[] fromProps = source._recordMeta().getPropertyMeta(false);
      PropertyMeta[] toProps = this._recordMeta().getPropertyMeta(false);
      Map<Integer, Integer> mapping = new HashMap<>();
      int i, j;
      
      // do the mapping and check if this copy makes sense (we have a correct mapping)
      for (i = 0, j = 0; i < fromProps.length && j < toProps.length;)
      {
         PropertyMeta fromProp = fromProps[i];
         PropertyMeta toProp = toProps[j];
         if (fromProp.isReserved())
         {
            i++;
            continue;
         }
         if (toProp.isReserved())
         {
            j++;
            continue;
         }
         
         if (fromProp.getType()   != toProp.getType() || 
             fromProp.getExtent() != toProp.getExtent())
         {
            return false;
         }
         
         mapping.put(j, i);
         j++;
         i++;
      }
      
      // the mapping is broken; the source can't be copied here so this method was used incorrectly
      if (!(i == fromProps.length && j == toProps.length))
      {
         return false;
      }
      
      // do an initial filtering of the offsets which should be updated
      List<Integer> updatedOffsets = null;
      List<Object> updatedDatums = null;
      for (Map.Entry<Integer, Integer> entry : mapping.entrySet())
      {
         Integer toOffset = entry.getKey();
         Integer fromOffset = entry.getValue();
         Object datum = source.getDatum(fromOffset);
         
         // update the bitset of dirty properties
         dirtyProps.set(toOffset);
         activeOffsets.set(toOffset);
         
         // for BigDecimal, equals checks the scale - we need compareTo, to check if the values are really the 
         // same, regardless of the scale value.
         if (!(Objects.equals(data[toOffset], datum) || 
              (datum instanceof Comparable && 
               data[toOffset] != null && 
               ((Comparable) datum).compareTo(data[toOffset]) == 0)))
         {
            if (updatedOffsets == null)
            {
               updatedOffsets = new ArrayList<>();
               updatedDatums = new ArrayList<>();
            }
            
            updatedOffsets.add(toOffset);
            updatedDatums.add(datum);
         }
      }
      
      // we shouldn't do any copy as no offset will be updated
      if (updatedOffsets == null || updatedOffsets.size() == 0)
      {
         return true;
      }
      
      // we will do a copy, so upgrade lock at first
      if (!lockForUpdate())
      {
         return false;
      }
      
      try
      {
         // notify data change in bulk mode
         // NOTE: this does not set activeOffset as we try to update all offsets at once!
         bulkDataChanged(updatedOffsets, updatedDatums);
      }
      catch (PersistenceException exc)
      {
         throw new RuntimeException(exc);
      }
      
      // finally, do the data set
      for (int idx = 0; idx < updatedOffsets.size(); idx++)
      {
         int offset = updatedOffsets.get(idx);
         Object datum = updatedDatums.get(idx);
         data[offset] = datum; 
      }
      
      return true;
   }
   
   /**
    * Get the field's value on the specified offset.
    * 
    * @param    offset
    *           Zero-based offset of the datum in the data array.
    * 
    * @return   The field's value.
    */
   protected Object getDatum(int offset)
   {
      return data[offset];
   }
   
   /**
    * Get the static record metadata for the concrete subclass.
    * 
    * @return  Static record metadata for the subclass.
    */
   protected abstract RecordMeta _recordMeta();
   
   /**
    * Obtain the codepage of the {@code clob} object at position {@code offset}.
    * <p>
    * Note: in order for this method to work, the {@code activeBuffer} must be set in advance. After the
    *       method was invoked, it is recommended to clear its value.
    * 
    * @param   offset
    *          The position for {@code clob} field.
    *
    * @return  The 4GL name of codepage of the {@code clob} field at position {@code offset}. If a codepage is
    *          not specified or if the field is not a {@code clob}, {@code null} is returned.
    */
   protected String getCodePage(int offset)
   {
      if (activeBuffer == null)
      {
         return null;
      }
      
      TempTable parentTable = activeBuffer.getParentTable();
      Property prop = activeBuffer.getDmoInfo().getProperty(offset);
      String cp = null;
      if (parentTable != null)
      {
         cp = parentTable.getCodePage(prop.name);
      }
      else
      {
         // must be a permanent table, use TableMapper
         TableMapper.LegacyFieldInfo lfi = TableMapper.getLegacyFieldInfo(activeBuffer, prop.name);
         if (lfi != null)
         {
            cp = lfi.getCodePage();
         }
      }
      
      return cp == null || cp.isEmpty() ? null : cp;

//      Property property = activeBuffer.getDmoInfo().getProperty(offset);
//      if (property == null || property.codePage.isEmpty())
//      {
//         return null;
//      }
//      
//      return property.codePage;
   }
   
   /**
    * Check if for an incomplete record, the specified field was read or assigned.
    * 
    * @param    index
    *           The field index.
    */
   protected void checkIncomplete(int index)
   {
      if (checkState(DmoState.INCOMPLETE) && !readFields.get(index))
      {
         RecordMeta meta = _recordMeta();
         String tname = meta.legacyName;
         String fname = meta.getPropertyMeta(false)[index].getLegacyName();
         String msg = "Field " + fname + " from " + tname + " record (recid " + primaryKey() + 
                       ") was missing from FIELDS phrase";
         
         ErrorManager.recordOrThrowError(8826, msg, false);
      }
   }

   /**
    * Mark this record as incomplete.
    * 
    * @param    session
    *           Database session (used for UNDO purposes).
    */
   void markIncomplete(Session session)
   {
      updateState(session, DmoState.INCOMPLETE, true);
      readFields = new BitSet(data.length);
   }

   /**
    * An index was updated.
    * 
    * @param   idxUid
    *          Index identifier.
    */
   void indexUpdated(int idxUid)
   {
      indexState.clear(idxUid);
   }
   
   /**
    * Compare the bitsets in the given array with the bitset of dirty properties for this record
    * and indicate whether all index components for any index in the array are dirty. The
    * positions of the bits correspond with the positions of the properties in the record's data
    * array.
    * 
    * @param   indices
    *          Array of bitsets, where each set bit represents a component of an index.
    * 
    * @return  {@code true} if all of the components of any index represented in the array are
    *          dirty, else {@code false}.
    */
   boolean isAnyIndexFullyDirty(BitSet[] indices)
   {
      int len = indices.length;
      for (int i = 0; i < len; i++)
      {
         BitSet orig = indices[i];
         BitSet copy = (BitSet) orig.clone();
         copy.and(dirtyProps);
         if (copy.equals(orig))
         {
            return true;
         }
      }
      
      return false;
   }
   
   /**
    * Mark all dirty properties as having been validated.
    */
   void markValidated()
   {
      unvalidated.andNot(dirtyProps);
   }
   
   /**
    * Mark properties as having been validated.
    * 
    * @param   bitset
    *          A bitset of offsets representing the properties which has been validated.
    */
   void markValidated(BitSet bitset)
   {
      unvalidated.andNot(bitset);
   }
   
   /**
    * Get a bit set representing the unique indices, if any, which are either fully or partially updated
    * and which have not already been validated. If the record is newly created and has not yet been
    * flushed to the database, a fully updated index is required; otherwise, a partially updated index is
    * sufficient.
    * 
    * @param   dirtyOffsets
    *          Optional offsets of  dirty properties which must be a component of the indices returned.
    *          An unvalidated indices bitset is computed for each dirty offset and is being added to the
    *          resulting bitset.
    * @param   allUnvalidated
    *          If {@code true}, then any unique index containing any property not previously validated is
    *          included in the returned bit set. If {@code false}, then only indices containing properties
    *          that have been explicitly made dirty are included. Setting this to {@code true} effectively
    *          considers all properties with untouched, default values in the check.
    * 
    * @return  A bit set as described above.  <code>null</code> is returned if no index is found.
    */
   BitSet getUnvalidatedIndices(BitSet dirtyOffsets, boolean allUnvalidated)
   {
      if (dirtyProps.cardinality() == 0 && unvalidated.cardinality() == 0 && !allUnvalidated)
      {
         return new BitSet();
      }
      BitSet unvalidatedIndices = new BitSet();
      int dirtyOffset = dirtyOffsets.nextSetBit(0);
      boolean dirtyPropsEmpty = dirtyProps.isEmpty();
      do {
         if (!allUnvalidated && (dirtyPropsEmpty || (dirtyOffset >= 0 && !unvalidated.get(dirtyOffset))))
         {
            dirtyOffset = dirtyOffsets.nextSetBit(dirtyOffset + 1);
            continue;
         }
         BitSet result = null;
         boolean isNew = (state.state & NEW) == NEW;
         BitSet[] unique = _recordMeta().uniqueIndices;
         int len = unique.length;

         for (int i = 0; i < len; i++) {
            BitSet nextUnique = unique[i];

            if (allUnvalidated) {
               // if the unique index contains any unvalidated property (including untouched default values),
               // include it in the set of indices which require validation
               if (nextUnique.intersects(unvalidated)) {
                  if (result == null) {
                     result = new BitSet();
                  }
                  result.set(i);
               }

               continue;
            }

            // skip this one if:
            // * an offset is provided, but it is not a component of the index; or
            // * no offset is provided, but none of the unvalidated properties are a component of the index
            if ((dirtyOffset >= 0 && !nextUnique.get(dirtyOffset)) ||
                    (dirtyOffset < 0 && !nextUnique.intersects(unvalidated))) {
               continue;
            }

            if (isNew) {
               // every component of the index must have been touched
               BitSet copy = (BitSet) nextUnique.clone();
               copy.and(dirtyProps);
               if (copy.equals(nextUnique)) {
                  if (result == null) {
                     result = new BitSet();
                  }
                  result.set(i);
               }
            } else {
               // any component of the index must have been touched; the tests above already have required
               // this, so just set the bit in the result
               if (result == null) {
                  result = new BitSet();
               }
               result.set(i);
            }
         }
         if (result != null) {
            unvalidatedIndices.or(result);
         }
         dirtyOffset = dirtyOffsets.nextSetBit(dirtyOffset + 1);
      }
      while (dirtyOffset != -1);
      return unvalidatedIndices;
   }
   
   /**
    * Get the offsets of the properties currently being updated, if any. When a DMO's setter method
    * is invoked, the DMO goes into an &apos;active&apos; state, during which the offsets of the
    * properties being updated are stored temporarily as the active offsets. When the update is
    * complete, no property is considered active.
    * <p>
    * The active state may be reset by this method. This should only be done from a {@link
    * Validation} object.
    * 
    * @return  A BitSet representing the offsets of properties being updated.
    */
   BitSet getActiveOffsets()
   {
      return (BitSet) activeOffsets.clone();
   }
   
   /**
    * Reset record and property state after database is synchronized with the record's data
    * (i.e., the most recent set of changes have been persisted).
    * <p>
    * This method does not turn off the {@code INVALID} flag.
    */
   void clearDirtyState(Session session)
   throws PersistenceException
   {
      dirtyProps.clear();
      unvalidated.clear();
      indexState.clearAll();
      updateState(session, CHANGED, false);
   }
   
   /**
    * Indicate whether the property at the given offset in the DMO's data array is dirty (i.e., has been
    * touched, but not necessarily changed).
    * 
    * @param   offset
    *          Zero-based offset of the target property.
    * 
    * @return  {@code true} if the property is dirty, else {@code false}.
    */
   boolean isPropertyDirty(int offset)
   {
      return dirtyProps.get(offset);
   }
   
   /**
    * Get a bit set indicating the positions of data elements which currently are set to {@code null}.
    * 
    * @return  Null properties bit set.
    */
   BitSet getNullProps()
   {
      return nullProps;
   }
   
   /**
    * Read a property directly from a {@code ResultSet}.
    *
    * @param   propOffset
    *          The index of the property to be read.
    * @param   rs
    *          The result set to read from.
    * @param   rsOffset
    *          The index in {@code ResultSet} to read from. If the property is a multiple column
    *          then more than one column will ve read from {@code ResultSet}.
    *
    * @return  The number of columns actually read from the {@code ResultSet}.
    */
   final int readProperty(int propOffset, ResultSet rs, int rsOffset)
   throws PersistenceException
   {
      return readProperty(propOffset, rs, rsOffset, false);
   }
   
   /**
    * Read a property directly from a {@code ResultSet}.
    *
    * @param   propOffset
    *          The index of the property to be read.
    * @param   rs
    *          The result set to read from.
    * @param   rsOffset
    *          The index in {@code ResultSet} to read from. If the property is a multiple column
    *          then more than one column will ve read from {@code ResultSet}.
    * @param   skip
    *          Flag indicating if this property will not be read from the result-set.
    *          
    * @return  The number of columns actually read from the {@code ResultSet}.
    */
   final int readProperty(int propOffset, ResultSet rs, int rsOffset, boolean skip)
   throws PersistenceException
   {
      PropertyMeta pm = _recordMeta().getPropertyMeta(false)[propOffset];
      PropertyReader propertyReader = pm.getDataHandler();
      if (skip)
      {
         return propertyReader.propertySize();
      }
      
      try
      {
         if (readFields != null)
         {
            readFields.set(propOffset);
         }
         
         return propertyReader.readProperty(rs, rsOffset, data, propOffset);
      }
      catch (java.sql.SQLException sqle)
      {
         throw new PersistenceException(sqle);
      }
   }
   
   /**
    * Set this record's version to the canonical, shared version number for this record.
    * 
    * @param   sharedVersion
    *          DMO version information.
    */
   void version(AtomicIntegerArray sharedVersion)
   {
      this.sharedVersion = sharedVersion;
      this.version = sharedVersion.get(DmoVersioning.VERSION);
   }
   
   /**
    * Indicate whether this record's data is stale, based on changes committed by other user contexts.
    * <p>
    * Always returns {@code false} for temp-table records.
    * 
    * @return  {@code true} if this record's version does not match the canonical, shared version of the
    *          record known to the FWD runtime; {@code false} if the versions match or if versioning is
    *          disabled for this record.
    */
   boolean isStale()
   {
      if (sharedVersion == null)
      {
         return false;
      }
      
      return version != sharedVersion.get(DmoVersioning.VERSION);
   }
   
   /**
    * The current application transaction has been committed. We do not need to maintain a change set any
    * longer. This is only invoked if this record was modified during the transaction, so increment this
    * DMO's version and the shared version.
    */
   void commit()
   {
      // discard tracked changes for UNDO
      changeSet = null;
      
      // update the canonical, shared version of this record
      if (sharedVersion != null)
      {
         // note: we rely on the fact that SavepointManager (which invokes this method from its
         // Block.commit() method) registers for master transaction commit AFTER the caller of
         // Session.commit(), so the transaction should have been committed before this method is invoked
         do
         {
            version = sharedVersion.incrementAndGet(DmoVersioning.VERSION);
         } while (version == DmoVersioning.UNVERSIONED);
      }
   }
   
   /**
    * Perform a full or sub-transaction level rollback of this record to its state as it was immediately
    * before the block of the specified transaction nesting level was entered.
    * 
    * @param   txLevel
    *          The nesting level of sub-transactions of the block being rolled back, where 0 represents
    *          the full transaction block, and a positive number represents that number of nested
    *          sub-transactions.
    */
   void rollback(int txLevel)
   throws PersistenceException
   {
      if (changeSet == null)
      {
         // this should not happen; we should only get this call if this record had at least one change
         // during an application transaction
         return;
      }
      
      changeSet.rollback(txLevel, this);
      
      // discard the change set after rollback of the full transaction
      if (txLevel == 0)
      {
         changeSet = null;
      }
   }
   
   /**
    * Perform a sub-transaction level changeset reset of this record
    * @param   txLevel
    *          The nesting level of sub-transactions of the block being rolled back, where 0 represents
    *          the full transaction block, and a positive number represents that number of nested
    *          sub-transactions.
    */
   void resetChangeSet(int txLevel)
   {
      if (changeSet == null)
      {
         // this should not happen; we should only get this call if this record had at least one change
         // during an application transaction
         return;
      }
      
      changeSet.resetTxLevel(txLevel);
      
   }

   /**
    * Perform a sub-transaction level changeset roll-up of this record
    * @param   txLevel
    *          The nesting level of sub-transactions of the block being rolled back, where 0 represents
    *          the full transaction block, and a positive number represents that number of nested
    *          sub-transactions.
    */
   public void rollUpChanges(int txLevel)
   {
      if (changeSet == null)
      {
         // this should not happen; we should only get this call if this record had at least one change
         // during an application transaction
         return;
      }
      changeSet.rollUpChanges(txLevel);
      resetChangeSet(txLevel+1);
   }

   /**
    * Get a bit set representing dirty unique or non-unique indices, subject to the conditions described by
    * the parameters. A set bit in the returned set indicates a dirty index. The position of each set bit
    * corresponds with the 0-based position of the associated index in the array of unique or non-unique
    * indices for the record.
    * 
    * @param   indices
    *          The array of bit sets representing the indices to be checked. This must be either the set of
    *          unique indices or non-unique indices associated with the record. Only one or the other can be
    *          checked by a call to this method.
    * @param   dirtyOffsets
    *          Optional offsets of  dirty properties which must be a component of the indices returned.
    * @param   filter
    *          An inclusive filter to apply to the results. If non-empty, each bit in the filter's bit set
    *          represents the position of an index to be checked in the {@code indices} parameter. If empty,
    *          the returned bit set will be empty.
    * @param   full
    *          If {@code true}, ALL index components must be dirty for an index to be considered dirty.
    *          If {@code false}, one or more index components must be dirty for an index to be considered
    *          dirty.
    * 
    * @return  A bit set, as described above.  <code>null</code> is returned when no indices are found.
    */
   private BitSet getDirtyIndices(BitSet[] indices, BitSet dirtyOffsets, BitSet filter, boolean full)
   {
      BitSet result = new BitSet();
      int dirtyOffset = dirtyOffsets.nextSetBit(0);
      do
      {
         if (filter.isEmpty() || (dirtyOffset >= 0 && !dirtyProps.get(dirtyOffset)))
         {
            // filter is set to filter ALL indices OR property at offset is not dirty, so there can be no
            // fully dirty indices containing the given property
            dirtyOffset = dirtyOffsets.nextSetBit(dirtyOffset + 1);
            continue;
         }

         BitSet dirty = null;

         for (int i = filter.nextSetBit(0); i >= 0; i = filter.nextSetBit(i + 1))
         {
            BitSet orig = indices[i];

            if (dirtyOffset >= 0 && !orig.get(dirtyOffset))
            {
               continue;
            }

            BitSet copy = (BitSet) orig.clone();
            copy.and(dirtyProps);
            if (full ? copy.equals(orig) : !copy.isEmpty())
            {
               if (dirty == null)
               {
                  dirty = new BitSet();
               }
               dirty.set(i);
            }
         }
         if (dirty != null)
         {
            result.or(dirty);
         }
         dirtyOffset = dirtyOffsets.nextSetBit(dirtyOffset + 1);
      }
      while(dirtyOffset != -1);
      return result;
   }
   
   /**
    * A property's value has changed. Update the record's internal state accordingly and set up rollback
    * information if the record is undoable.
    * 
    * @param   offset
    *          The datum's offset in the record's data array.
    * @param   datum
    *          The new value of the datum (may be {@code null}).
    */
   private void dataChanged(int offset, Object datum)
   throws PersistenceException
   {
      if (activeBuffer == null)
      {
         return;
      }
      
      // update the unvalidated properties bitset
      if (_recordMeta().validatable.get(offset))
      {
         unvalidated.set(offset);
      }
      
      // update the null property bitset
      nullProps.set(offset, datum == null);
      
      int flags = CHANGED;
      updateState(flags, true);
      
      int txLevel = prepareChangeSet(activeBuffer.getSession());
      if (txLevel >= 0)
      {
         changeSet.logStateChange(flags, txLevel);
         changeSet.logDataChange(txLevel, offset, datum);
      }
   }
   
   /**
    * Multiple property values have changed. Update the record's internal state accordingly and set up rollback
    * information if the record is undoable.
    * 
    * @param   offsets
    *          A list of offsets for the changed values in record's data array.
    * @param   datums
    *          A list of new values for the changed datums.
    */
   private void bulkDataChanged(List<Integer> offsets, List<Object> datums)
   throws PersistenceException
   {
      if (activeBuffer == null)
      {
         return;
      }
      
      for (int i = 0; i < offsets.size(); i++)
      {
         int offset = offsets.get(i);
         Object datum = datums.get(i);
         
         // update the unvalidated properties bitset
         if (_recordMeta().validatable.get(offset))
         {
            unvalidated.set(offset);
         }
         
         // update the null property bitset
         nullProps.set(offset, datum == null);
      }
      
      int flags = CHANGED;
      updateState(flags, true);
      
      int txLevel = prepareChangeSet(activeBuffer.getSession());
      if (txLevel >= 0)
      {
         changeSet.logStateChange(flags, txLevel);
         for (int i = 0; i < offsets.size(); i++)
         {
            int offset = offsets.get(i);
            Object datum = datums.get(i);
            changeSet.logDataChange(txLevel, offset, datum);
         }
      }
   }
   
   /**
    * Updates the set of state flags for this record. Depending on {@code add} parameter, the
    * flags can be set (when {@code true}) or removed (when {@code false}. If one flag is already
    * set and {@code add == true} that flag remains unchanged. The same stands for un-setting a
    * not active flag.
    * 
    * @param   flags
    *          A set of OR-ed {@code RecordState} flags to be updated.
    * @param   set
    *          If {@code true} the flags will be set, otherwise they will be unset.
    * 
    * @see     RecordState
    */
   private void updateState(int flags, boolean set)
   {
      if (set)
      {
         state.state |= flags;
      }
      else
      {
         state.state &= ~flags;
      }
   }
   
   /**
    * If this is the first update to this record since an application transaction began, prepare a
    * change set to track updates made to the record during the transaction, to be used for full or
    * sub-transaction rollback, if needed.
    * <p>
    * For a change set to be prepared, the following conditions must be met:
    * <ul>
    * <li>a record buffer must be associated with the record, indicating that this call is being made
    *     in the context of a business logic call into the record;</li>
    * <li>the record must be associated with an undoable table;</li>
    * <li>an application-level transaction must currently be active.</li>
    * </ul>
    * <p>
    * If a change set for the current transaction already exists, another is not created.
    * <p>
    * A non-negative return value indicates a change set exists and is ready to track changes.
    * 
    * @param   session
    *          Database session to be interrogated for transaction information.
    * 
    * @return  The current transaction nesting level, or -1 if any of the above conditions are not met.
    */
   private int prepareChangeSet(Session session)
   throws PersistenceException
   {
      Long txNestingId;
      
      if ((state.state & NOUNDO) == NOUNDO ||
          (state.state & COPY) == COPY ||
          session == null ||
          (txNestingId = session.getTxNestingId()) == null)
      {
         // record is not in a valid state for tracking undoable changes
         return -1;
      }
      
      // high order 2 words of transaction nesting ID are nesting level, low order 2 words are
      // transaction ID
      int txLevel = (int) ((txNestingId & (0xFFFFFFFFL << 32)) >>> 32);
      int txId = (int) (txNestingId & (0xFFFFFFFFL));
      
      if (txLevel >= 0)
      {
         // create a change set if one with the same transaction ID does not already exist
         if (changeSet == null || !changeSet.verify(session, txId))
         {
            changeSet = new ChangeSet(session, txId, data, state.state, txLevel);
         }
         
         // register this undoable record with the savepoint manager at the current transaction nesting
         // level
         session.trackUndoable(this);
      }
      
      return txLevel;
   }
   
   /**
    * Obtain an exclusive lock for the current record on behalf of the active buffer, if any.
    * 
    * @return  {@code true} if the lock is successfully obtained, or if it is unneeded due to system state,
    *          or if there is no active buffer. {@code false} if there was an error obtaining the lock (and
    *          we are in silent error mode).
    */
   private boolean lockForUpdate()
   {
      if (DatabaseManager.isInitializing())
      {
         // no locking needed at this time
         return true; 
      }
      
      // TODO: in certain, unstudied cases [activeBuffer] remains null, causing a NPE to be thrown
      if (activeBuffer == null)
      {
         //Exception exc = new RuntimeException("BaseRecord.lockForUpdate(activeBuffer == null)");
         //LOG.log(Level.FINER, "", exc);
         return true;
      }
      
      try
      {
         // try to get an exclusive lock
         activeBuffer.upgradeLock();
         
         return true;
      }
      catch (PersistenceException exc)
      {
         ErrorManager.recordOrThrowError(
            142,
            "Unable to update " + _recordMeta().tables[0] + " Field",
            exc);
         
         return false;
      }
   }
   
   /**
    * Get the name of the primary table associated with this record.
    * 
    * @return  Primary table name.
    */
   private String getTable()
   {
      String[] tables = _recordMeta().tables;
      
      return (tables != null && tables.length > 0) ? tables[0] : "";
   }
   
   /**
    * Get the size of the record.
    * 
    * @return Size of the record in bytes.
    */
   public long getSize()
   {
      PropertyMeta[] pm = _recordMeta().getPropertyMeta(false);
      long size = 0;
      for (int i = 0; i < data.length; i++)
      {
         size += pm[i].getDataHandler().getFieldSizeInIndex(data[i]);
      }
      return size;
   }
}