FWDDataObject.java

/*
** Module   : FWDDataObject.java
** Abstract : A proxy to be able to track changes in a DataObject instance.
**
** Copyright (c) 2019-2025, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description---------------------------------------
** 001 CA  20191119 Created initial version.
** 002 OM  20201231 Added support for REJECTED attribute.
** 003 CA  20210910 Changes to allow support for before-table returned from the remote request via an
**                  OUTPUT/INPUT-OUTPUT table or dataset.
**     CA  20210917 Javadoc fixes.
**     OM  20211020 Added _DATASOURCE_ROWID property.
**     CA  20220104 Added support for denormalized extent fields, for .NET client compatibility.
**     CA  20220215 Fixed denormalized extent when the field is referenced by its integer index.
**     CA  20220216 Fixed issues with denormalized extent field names: they can collide with other legacy 
**                  field names so the changes ensure they are unique.
**     CA  20220428 Log only the first set state, when tracking changes.
**     CA  20220604 Replaced the FWD proxy with the Java proxy, to allow easy usage from Tomcat.
**     CA  20221116 Added 'unknownValuesForFields', a flag which when set, will force the DataObject.get to
**                  return null even for Java native types - without this flag, DataObject.get will return 
**                  i.e. 0 or false for int/boolean native types.
** 004 CA  20230221 Encapsulated the field and relation metadata in their own classes, to avoid the DataObject
**                  overhead and increase performance.  Other performance optimizations.
** 005 CA  20240918 Removed default methods from interfaces and moved them as concrete implementations, as
**                  default methods cause the Java metaspace to spike one order of magnitude.
** 006 AS  20250217 Resolve a normalized extent field case insensitively. 
** 007 AS  20250227 Use the same property resolving logic for both normalized and denormalized extents.
** 008 AP  20250411 Refactored invoke method to better handle both normalized and denormalized extents. 
**                  For denormalized extents added indexed access to each element
**     CA  20250424 'forcedNormalized' is used by FWD runtime, so normalized access needs to be allowed even
**                  when using denormalized extents. 
**     CA  20250424 .NET compatibiltiy still uses normalized extent fields when transferring them.
*/

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

import java.lang.reflect.*;
import java.util.*;

import org.apache.tuscany.sdo.util.*;
import com.goldencode.p2j.persist.*;
import com.goldencode.p2j.persist.orm.BaseRecord;

import commonj.sdo.*;

/**
 * Proxy wrapper for a {@link DataObject} instance.  This is required to be able to track 
 * before-table changes for a SDO row.
 */
public class FWDDataObject
implements FWDDataObjectType,
           InvocationHandler
{
   /** The wrapped row. */
   private DataObject row;
   
   /** The id, as used by {@link BaseRecord#primaryKey()}. */
   private Long id;
   
   /** The origin-rowid, as used by {@link TempRecord#_originRowid()}. */
   private Long originRowid;
   
   /** The data-source-rowid, as used by {@link TempRecord#_datasourceRowid()}. */
   private Long dataSourceRowid;
   
   /** The peer-rowid, as used by {@link TempRecord#_peerRowid()}. */
   private Long peerRowid;
   
   /** The row-state, as used by {@link TempRecord#_rowState()}. */
   private Integer rowState;
   
   /** The error-string, as used by {@link TempRecord#_errorString()}. */
   private String errorString;
   
   /** The error-flag, as used by {@link TempRecord#_errorFlags()}. */
   private Integer errorFlag;
   
   /** Flag indicating if changes are being tracked. */
   private boolean logging = false;

   /** The source table metadata used to create this {@link #row}. */
   private DataObject tableMetaData;
   
   /** Flag indicating if extents are normalized. */
   private boolean normalizedExtent;
   
   /** 
    * When <code>true</code>, this will force the resolution of normalized field names, as we are within
    * the FWD runtime, to re-create a row incoming from the remote side (and this always uses normalized
    * fields).
    * <p>
    * This flag is used only when {@link #normalizedExtent} flag is <code>false</code> (we are in denormalized
    * extent fields mode), so the FWD runtime can access the extent field directly by their legacy name,
    * and not in the denormalized mode.
    */
   private boolean forceNormalizedFields = false;
   
   /** A bit-set with the properties on which a 'set' method has been called. */
   private BitSet setProps = null;
   
   /** Mapping of the properties to their index. */
   private Map<String, Integer> propToIndex = null;
   
   /**
    * Proxy handle which dispatches method calls to the wrapped {@link #row}.
    * <p>
    * Certain APIs are intercepted to allow tracking changes.
    */
   @Override
   public Object invoke(Object proxy, Method method, Object[] args)
   throws Throwable
   {
      if (method.getDeclaringClass().isAssignableFrom(FWDDataObjectType.class))
      {
         return method.invoke(this, args);
      }
      
      String mname = method.getName();
      switch (mname)
      {
         case "delete":
            if (isLogging())
            {
               DataSetSDOHelper.logDeleted((FWDDataObjectType) proxy);
            }
            DataObjectUtil.delete((DataObject) proxy);
            return null;
         case "equals":
            return this.equals(args[0]);
         case "hashCode":
            return this.hashCode();
      }
      
      if (normalizedExtent && DataSetSDOHelper.isUnknownValuesForFields())
      {
         // this is currently done only for normalized extent
         if (setProps == null)
         {
            List props = row.getInstanceProperties();
            setProps = new BitSet(props.size());
            propToIndex = new HashMap<>();
            
            for (int i = 0; i < props.size(); i++)
            {
               Property prop = (Property) props.get(i);
               propToIndex.put(prop.getName(), i);
            }
         }
         
         Integer idx = null;
         if (mname.equals("get") || mname.equals("isSet") || mname.equals("unset") || mname.startsWith("set"))
         {
            idx = getIndex(args[0]);
         }
         
         if (idx != null)
         {
            switch (mname)
            {
               case "get":
                  if (!row.isSet(idx))
                  {
                     return null;
                  }

                  break;
                  
               case "isSet":
                  return setProps.get(idx);
                  
               case "unset":
                  setProps.clear(idx);
                  break;
                  
               default:
                  // a setter method
                  setProps.set(idx);
                  break;
            }
         }
      }
      
      
      if (isLogging() && mname.startsWith("set") && rowState == null)
      {
         DataSetSDOHelper.logChanged((FWDDataObjectType) proxy);
      }
      
      // denormalized extent case:
      // mechanism to get the exact index in row if the input is String, then we fallback to the default
      // implementation
      
      // normalized extent case:
      // mechanism to get the field name ignoring case sensitivity, then we fallback to the default
      // implementation

      // the FWD runtime will require to access the extent fields by their legacy name, even when we are in
      // denormalized mode.  the 'forceNormalizedFields' flag is used to indicate this 'access from
      // FWD runtime mode', so the extent fields can be accessed directly by their name.
      
      
      if (!(args != null     &&
            args.length >= 1 && 
            (mname.startsWith("set") || 
             mname.equals("unset")   || 
             mname.startsWith("get") || 
             mname.equals("isSet"))))
      {
         return method.invoke(row, args);
      }
      
      if (!normalizedExtent)
      {
         LegacyFieldMetaData fieldMeta;
         
         if (forceNormalizedFields   || 
             mname.equals("setList") || 
             mname.equals("getList") ||
             (mname.equals("set") && args[1] instanceof List))
         {
            if (args[0] instanceof Property)
            {
               // direct access, 'they know what they are doing'
               return method.invoke(row, args);
            }
            
            // forbid access to entire extent if denormalized access
            if (!forceNormalizedFields)
            {
               throw new IllegalArgumentException("Access to the entire denormalized extent is not allowed");
            }
            
            if (args[0] instanceof String && 
                ((String) args[0]).startsWith(DataSetSDOHelper.DENORMALIZED_PREFIX))
            {
               throw new IllegalArgumentException(
                  "Access is configured for normalized, but denormalized field provided: " + args[0]);
            }
            
            // find the start position of this field
            Object arg0 = args[0];
            List<LegacyFieldMetaData> fields = DataSetSDOHelper.getFieldMetaData(tableMetaData, false);
            LegacyFieldMetaData field = null;
            for (int i = 0; i < fields.size(); i++)
            {
               LegacyFieldMetaData f = fields.get(i);
               String fname = f.extentField == null ? f.name : f.extentField;
               if (arg0 instanceof String && ((String) arg0).equalsIgnoreCase(fname))
               {
                  field = f;
                  break;
               }
               // f.idx is 1-based, but DataObject receives 0-based
               else if (arg0 instanceof Integer && ((Integer) arg0).equals(f.idx - 1))
               {
                  field = f;
                  break;
               }
            }
            
            if (field == null)
            {
               throw new IllegalArgumentException("Could not find field for " + args[0] + " in " + 
                                                  DataSetSDOHelper.getTableName(tableMetaData));
            }
            int fieldPos = fields.indexOf(field);
            
            if (mname.startsWith("set"))
            {
               if (field.extentField != null)
               {
                  // second argument is a list
                  List l = (List) args[1];
                  for (int i = 0; i < l.size(); i++)
                  {
                     arg0 = args[0] instanceof String 
                               ? DataSetSDOHelper.buildDenormalizedFieldName(field.extentField, i + 1)
                               : fields.indexOf(field) + i;
                     if (arg0 instanceof String)
                     {
                        row.set((String) arg0, l.get(i));
                     }
                     else
                     {
                        row.set((Integer) arg0, l.get(i));
                     }
                  }
               }
               else
               {
                  // normal field access
                  arg0 = args[0] instanceof String ? field.name : fieldPos;
                  return method.invoke(row, arg0, args[1]);
               }
               return null;
            }
            else if (mname.startsWith("get"))
            {
               if (field.extentField != null)
               {
                  // return a list with the extent field content
                  List res = new ArrayList<>();
                  for (int i = fieldPos, idx = 1; i < fields.size(); i++, idx++)
                  {
                     LegacyFieldMetaData fnew = fields.get(i);
                     if (!field.extentField.equalsIgnoreCase(fnew.extentField))
                     {
                        break;
                     }
                     arg0 = args[0] instanceof String 
                               ? DataSetSDOHelper.buildDenormalizedFieldName(field.extentField, idx)
                               : i;
                     Object val = arg0 instanceof String ? row.get((String) arg0) : row.get((Integer) arg0);
                     res.add(val);
                  }
                  return res;
               }
               else
               {
                  // normal field access
                  arg0 = args[0] instanceof String ? field.name : fieldPos;
                  return method.invoke(row, arg0);
               }
            }
            else if (mname.startsWith("isSet"))
            {
               if (field.extentField != null)
               {
                  for (int i = fieldPos, idx = 1; i < fields.size(); i++, idx++)
                  {
                     LegacyFieldMetaData fnew = fields.get(i);
                     if (!field.extentField.equalsIgnoreCase(fnew.extentField))
                     {
                        break;
                     }
                     arg0 = args[0] instanceof String 
                               ? DataSetSDOHelper.buildDenormalizedFieldName(field.extentField, idx)
                               : i;
                     if (arg0 instanceof String)
                     {
                        return row.isSet((String) arg0);
                     }
                     else
                     {
                        return row.isSet((Integer) arg0);
                     }
                  }
                  return false;
               }
               else
               {
                  // normal field access
                  arg0 = args[0] instanceof String ? field.name : fieldPos;
                  return method.invoke(row, arg0);
               }
            }
            else if (mname.startsWith("unset"))
            {
               if (field.extentField != null)
               {
                  for (int i = fieldPos, idx = 1; i < fields.size(); i++, idx++)
                  {
                     LegacyFieldMetaData fnew = fields.get(i);
                     if (!field.extentField.equalsIgnoreCase(fnew.extentField))
                     {
                        break;
                     }
                     arg0 = args[0] instanceof String 
                               ? DataSetSDOHelper.buildDenormalizedFieldName(field.extentField, idx)
                               : i;
                     if (arg0 instanceof String)
                     {
                        row.unset((String) arg0);
                     }
                     else
                     {
                        row.unset((Integer) arg0);
                     }
                  }
                  return null;
               }
               else
               {
                  // normal field access
                  arg0 = args[0] instanceof String ? field.name : fieldPos;
                  return method.invoke(row, arg0);
               }
            }
            
            throw new IllegalArgumentException("Could not invoke " + mname + " with normalized mode.");
         }
         
         if (args[0] instanceof String)
         {
            // access single element from extent using name
            String fieldName = (String) args[0];
            // allow failure only for setters
            boolean failNotFound = (mname.startsWith("set") || mname.equals("unset"));
            
            fieldMeta = DataSetSDOHelper.getFieldMetaData(tableMetaData,  
                                                          fieldName,
                                                          forceNormalizedFields,
                                                          failNotFound);
            if (fieldMeta != null)
            {
               String extentField = fieldMeta.getExtentField();
               int extentIndex = fieldMeta.getExtentIndex() != null ? fieldMeta.getExtentIndex() : -1;
               
               if (extentIndex > 0)
               {
                  // calculate the real extent from the legacy field.
                  args[0] = DataSetSDOHelper.buildDenormalizedFieldName(extentField, extentIndex);
               }
               else
               {
                  // we are on the denormalized field, ensure the call uses the proper name
                  args[0] = fieldMeta.getName();
               }
            }
         }
         
         return method.invoke(row, args);
      }
      
      // we are in the normalized case
      if (args[0] instanceof String)
      {
         String fieldName = (String) args[0];

         // allow failure only for setters
         boolean failNotFound = (mname.startsWith("set") || mname.equals("unset"));

         LegacyFieldMetaData fieldMeta = DataSetSDOHelper.getFieldMetaData(tableMetaData, 
                                                                           fieldName, 
                                                                           forceNormalizedFields,
                                                                           failNotFound);
         if (fieldMeta != null)
         {
            // we are on the normalized field, ensure the call uses the proper name
            args[0] = fieldMeta.getName();
         }
      }
      
      return method.invoke(row, args);
   }
   
   /**
    * Set the state of the {@link #forceNormalizedFields} flag.
    * 
    * @param    forceNormalizedFields
    *           When <code>true</code>, normalized field names are assumed always.
    */
   @Override
   public void setForceNormalizedFields(boolean forceNormalizedFields)
   {
      this.forceNormalizedFields = forceNormalizedFields;
   }
   
   /**
    * Start tracking changes in this record.
    */
   @Override
   public void beginLogging()
   {
      this.originRowid = null;
      this.peerRowid = null;
      this.rowState = null;
      this.errorString = null;
      this.errorFlag = null;

      this.logging = true;
   }
   
   /**
    * Stop tracking changes in this record.
    */
   @Override
   public void endLogging()
   {
      this.logging = false;
   }

   /**
    * Set the source table metadata used to create this {@link #row}.
    * 
    * @param    tableMetaData
    *           The source table metadata.
    */
   @Override
   public void setTableMetaData(DataObject tableMetaData)
   {
      this.tableMetaData = tableMetaData;
      this.normalizedExtent = tableMetaData.getBoolean("normalizedExtent");
   }
   
   /**
    * Set the {@link #row}.
    * 
    * @param    row
    *           The wrapped {@link DataObject} instance.
    */
   @Override
   public void setRow(DataObject row)
   {
      this.row = row;
   }

   /**
    * Get the {@link #row}.
    * 
    * @return   See above.
    */
   @Override
   public DataObject getRow()
   {
      return row;
   }
   
   /**
    * Get the identifier (primary key) for this data-object.
    *
    * @return   The {@link #id}.
    */
   @Override
   public Long primaryKey()
   {
      return id;
   }

   /**
    * Set the identifier (primary key) for this data-object.
    *
    * @param   id
    *          Unique identifier to be associated with this object.
    */
   @Override
   public void primaryKey(Long id)
   {
      this.id = id;
   }

   /**
    * Gets the rowid (as {@code Long} value) of the peer record. Exclusively, for a BEFORE-TABLE
    * record this is the {@code after-rowid} and {@code before-rowid} for AFTER-TABLE record.
    * 
    * @return  The rowid value of the peer record or {@code null} if there is none.
    *
    * @see     Buffer#__AFTER_ROWID__
    */
   @Override
   public Long _peerRowid()
   {
      return peerRowid;
   }

   /**
    * Sets the rowid (as {@code Long} value) of the peer record. Exclusively, for a BEFORE-TABLE
    * record this is the {@code after-rowid} and {@code before-rowid} for AFTER-TABLE record.
    *
    * @param   rowid
    *          The new long rowid value of the peer record or {@code null}.
    *
    * @see     Buffer#__AFTER_ROWID__
    */
   @Override
   public void _peerRowid(Long rowid)
   {
      this.peerRowid = rowid;
   }

   /**
    * Gets the current value of the {@code ROW-STATE} property for this record. Valid values
    * are defined as constants in {@link Buffer} interface. Additionally, {@code null} value is
    * returned if property is not available. 
    * 
    * @return  The current value of the {@code ROW-STATUS} property for this record as
    *          explained above.
    *
    * @see     Buffer#__ROW_STATE__
    */
   @Override
   public Integer _rowState()
   {
      return rowState;
   }

   /**
    * Sets the value of the {@code ROW-STATUS} property for this record. Valid values are defined
    * as constants in {@link Buffer} interface. Additionally {@code null} value is allowed if
    * property is not available.
    *
    * @param  state
    *         The new value of the {@code ROW-STATE} property for this record as explained above.
    *
    * @see     Buffer#__ROW_STATE__
    */
   @Override
   public void _rowState(Integer state)
   {
      this.rowState = state;
   }
   
   /**
    * Gets the current value of the {@code ORIGIN-ROWID} property for this record. This property
    * of a record in a buffer of a CHANGE DATASET points to original record of the change.
    * Additionally, {@code null} value is returned if property is not available.
    * 
    * @return  The current value of the {@code ORIGIN-ROWID} property for this record as
    *          explained above.
    *
    * @see     Buffer#__ORIGIN_ROWID__
    */
   @Override
   public Long _originRowid()
   {
      return originRowid;
   }
   
   /**
    * Sets the current value of the {@code ORIGIN-ROWID} property for this record. This property
    * of a record in a buffer of a CHANGE DATASET points to original record of the change.
    * Additionally, {@code null} value is returned if property is not available.
    *
    * @param  rowid
    *         The new value of the {@code ORIGIN-ROWID} property for this record as explained
    *         above.
    *
    * @see     Buffer#__ORIGIN_ROWID__
    */
   @Override
   public void _originRowid(Long rowid)
   {
      this.originRowid = rowid;
   }
   
   /**
    * Gets the current value of the {@code DATA_SOURCE_ROWID} property for this record. This property of a
    * record in a buffer of a CHANGE DATASET points to original record of the change. Additionally,
    * {@code null} value is returned if property is not available.
    *
    * @return  The current value of the {@code DATA_SOURCE_ROWID} property for this record as explained above.
    *
    * @see     Buffer#__DATA_SOURCE_ROWID__
    */
   @Override
   public Long _datasourceRowid()
   {
      return dataSourceRowid;
   }
   
   /**
    * Sets the current value of the {@code DATA_SOURCE_ROWID} property for this record. This property of a
    * record in a buffer of a CHANGE DATASET points to original record of the change. Additionally,
    * {@code null} value is returned if property is not available.
    *
    * @param   rowid
    *          The new value of the {@code DATA_SOURCE_ROWID} property for this record as explained above.
    *
    * @see     Buffer#__DATA_SOURCE_ROWID__
    */
   @Override
   public void _datasourceRowid(Long rowid)
   {
      this.dataSourceRowid = rowid;
   }
   
   /**
    * Gets the current value of the {@code __error-flag__} for this record.
    *
    * @return  the current value of the {@code __error-flag__} for this record.
    *
    * @see     Buffer#__ERROR_FLAG__
    */
   @Override
   public Integer _errorFlags()
   {
      return errorFlag;
   }

   /**
    * Sets the current value of the {@code __error-flag__} for this record.
    *
    * @param   flag
    *          the current value of the {@code __error-flag__} for this record.
    *
    * @see     Buffer#__ERROR_FLAG__
    */
   @Override
   public void _errorFlags(Integer flag)
   {
      this.errorFlag = flag;
   }

   /**
    * Gets the current value of the {@code __error-string__} for this record.
    *
    * @return  the current value of the {@code __error-string__} for this record.
    *
    * @see     Buffer#__ERROR_STRING__
    */
   @Override
   public String _errorString()
   {
      return errorString;
   }

   /**
    * Sets the current value of the {@code __error-string__} for this record.
    *
    * @param   error
    *          the current value of the {@code __error-string__} for this record.
    *
    * @see     Buffer#__ERROR_STRING__
    */
   @Override
   public void _errorString(String error)
   {
      this.errorString = error;
   }
   
   /**
    * Check if this instance is the same as the specified {@link FWDDataObject} or 
    * {@link DataObject} instance.
    * 
    * @param    obj
    *           The other instance.
    * 
    * @return    See above.
    */
   @Override
   public boolean equals(Object obj)
   {
      if (obj instanceof FWDDataObjectType)
      {
         return row.equals(((FWDDataObjectType) obj).getRow());
      }
      
      if (obj instanceof DataObject)
      {
         return row.equals(obj);
      }
      
      return super.equals(obj);
   }
   
   /**
    * Compute this object's hashcode, the same as the {@link #row}'s hashcode.
    * 
    * @return   See above.
    */
   @Override
   public int hashCode()
   {
      return row.hashCode();
   }
   
   /**
    * Log an event that this row is newly created.
    */
   @Override
   public void rowCreated()
   {
      if (isLogging())
      {
         DataSetSDOHelper.logCreated(this);
      }
   }
   
   /**
    * Check if logging is enabled.
    * 
    * @return   <code>true</code> if {@link #logging} is set and the row is already added to the table.
    */
   @Override
   public boolean isLogging()
   {
      return logging && row.getDataGraph() != null;
   }
   
   /**
    * Get an array with this record's meta information.
    * 
    * @return   See above.
    */
   @Override
   public Object[] toArray()
   {
      return TempRecord.asArray(this);
   }
   
   /**
    * Resolve the index argument to its numeric value.
    * 
    * @param    idx
    *           The index, as a numeric value, {@link String} or {@link Property}.
    *           
    * @return   The property index as resolved from {@link #propToIndex}.
    */
   private Integer getIndex(Object idx)
   {
      if (idx instanceof Integer) 
      {
         return (Integer) idx;
      }
      else if (idx instanceof Property)
      {
         return propToIndex.get(((Property) idx).getName());
      }
      else
      {
         return propToIndex.get((String) idx);
      }
   }
}