RecordBufferSerializer.java

/*
** Module   : RecordBufferSerializer.java
** Abstract : Handles serialization of RecordBuffer objects.
**
** Copyright (c) 2025, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description---------------------------------------
** 001 AP  20250129 Created initial version.
 */

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

import com.goldencode.p2j.persist.orm.*;

import java.sql.*;
import java.time.*;
import java.util.*;

/**
 * {@link RecordBufferSerializer} objects are used for retrieving the data from the SQL table(s) associated to
 * a {@link RecordBuffer}. Method {@link RecordBufferSerializer#sqlTableContent} returns the data as a Map,
 * that can be further serialized.
 */
public class RecordBufferSerializer
{
   /** {@link Session} associated with the serialized {@link RecordBuffer} */
   private final Session session;
   
   /** {@link DmoMeta} associated with the serialized {@link RecordBuffer} */
   private final DmoMeta dmoInfo;
   
   /** List of normalized extent fields */
   private final List<Property> normalizedFields;
   
   /** Mapping from lowercased SQL column names from {@link Property#column} to the actual {@link Property} */
   private final Map<String, Property> sqlNameNormalizedFields;
   
   /** Flag to indicate whether the legacy column names should be used or the SQL ones */
   private final boolean useLegacyFieldsName;
   
   /** Map that represents the current row that is being populated. The keys are the column's names and the
    * values represent either Strings when the column holds scalars or List<String> when it holds extents */
   private Map<String, Object> currentRowObject;
   
   /**
    * Constructor for {@link RecordBufferSerializer}. 
    * 
    * @param recordBuffer
    *        The {@link RecordBuffer} for which the associated SQL table needs to be serialized
    * @param useLegacyFieldsName
    *        Flag used to set whether the SQL field names should be used or the legacy ones
    */
   public RecordBufferSerializer(RecordBuffer recordBuffer, boolean useLegacyFieldsName)
   {
      this.dmoInfo = recordBuffer.getDmoInfo();
      this.session = recordBuffer.getPersistence().getSession(!dmoInfo.multiTenant);
      this.normalizedFields = new ArrayList<>();
      this.useLegacyFieldsName = useLegacyFieldsName;
      this.sqlNameNormalizedFields = new HashMap<>();
   }
   
   /** Constructor for {@link RecordBufferSerializer} with the 
    * {@link RecordBufferSerializer#useLegacyFieldsName} set to true by default.
    *
    * @param recordBuffer
    *        The {@link RecordBuffer} for which the associated SQL table needs to be serialized
    */
   public RecordBufferSerializer(RecordBuffer recordBuffer)
   {
      this(recordBuffer, true);
   }
   
   /**
    * Get the content of the SQL table associated with this buffer as a List of Maps. A Map represents a
    * record. The key is the field name. The value is either a scalar or a List of strings representing an
    * extent.
    *
    * @return List of Maps, where the key is the field name and the value is a String representing a scalar
    *         value or a List of Strings representing the extent values
    *
    * @throws PersistenceException
    *         If the JDBC connection has been closed or there was an error checking its status.
    * @throws SQLException
    *         If a database access error occurs, or if any of the SQL operations (preparation, execution, or 
    *         result handling) fail
    */
   public List<Map<String, Object>> sqlTableContent()
   throws PersistenceException, SQLException
   {
      Connection conn = this.session.getConnection();
      List<Map<String, Object>> results = new ArrayList<>();
      try (PreparedStatement ps = conn.prepareStatement("select * from " + dmoInfo.sqlTable);
           ResultSet res = ps.executeQuery())
      {
         while (res.next())
         {
            currentRowObject = new HashMap<>();
            results.add(currentRowObject);
            handleSqlRow(res);
            addNormalizedExtentFields(conn, res.getInt(1));
         }
      }
      return results;
   }
   
   /**
    * Iterates over all the fields from the associated DMO and populates the
    * {@link RecordBufferSerializer#currentRowObject} with data ONLY from the main table. It doesn't handle
    * the tables where the extent values are saved. In this method ONLY the scalar values and the denormalized
    * fields are handled.
    *
    * @param resultSet
    *        The current {@code ResultSet}
    *
    * @throws SQLException
    *         If a database access error occurs, or if any of the SQL operations (preparation, execution, or 
    *         result handling) fail
    */
   @SuppressWarnings("unchecked")
   private void handleSqlRow(ResultSet resultSet)
   throws SQLException
   {
      Iterator<Property> fieldsIterator = dmoInfo.getFields(false);
      
      while (fieldsIterator.hasNext())
      {
         Property field = fieldsIterator.next();
         String label = getFieldName(field);
         
         // this is a scalar value
         if (field.extent == 0)
         {
            String value = getFullValueForField(resultSet, field);
            currentRowObject.put(label, value);
         }
         // this is an extent, but denormalized, so the data is in the same table
         else if (field.denormalized)
         {
            String value = getFullValueForField(resultSet, field);
            List<String> currentValuesForExtent = (List<String>) currentRowObject.get(label);
            
            if (currentValuesForExtent == null)
            {
               currentValuesForExtent = new ArrayList<>();
               currentRowObject.put(label, currentValuesForExtent);
            }
            currentValuesForExtent.add(value);
         }
         // this is normalized extent, and on the first row in the resultSet
         else if (resultSet.isFirst())
         {
            // 'cache' the normalized fields for further usage
            sqlNameNormalizedFields.put(field.column.toLowerCase(), field);
            normalizedFields.add(field);
         }
      }
   }
   
   /**
    * Gets the full value for a field in the {@link ResultSet}, handling the case where the field is of type
    * datetime-tz.
    *
    * @param resultSet
    *        The current {@code ResultSet}
    * @param field
    *        The {@link Property} for which to retrieve the value
    *
    * @return Full value for a field in the {@link ResultSet} or null if SQL value is NULL
    *
    * @throws SQLException
    *         If the columnLabel is not valid; if a database access error occurs or this method is called on a
    *         closed result set
    */
   private String getFullValueForField(ResultSet resultSet, Property field)
   throws SQLException
   {
      if (resultSet.getString(field.column) == null)
      {
         return null;
      }
      
      if (!field._isDatetimeTz)
      {
         return resultSet.getString(field.column);
      }
      
      Timestamp tt = resultSet.getTimestamp(field.column);
      if (tt == null)
      {
         return null;
      }
      else
      {
         int offset = resultSet.getInt(field.column + DDLGeneratorWorker.DTZ_OFFSET); // DTZ_OFFSET 
         ZoneOffset zoneOffset = ZoneOffset.ofHoursMinutes(offset / 60, offset % 60);
         OffsetDateTime offsetDateTime = OffsetDateTime.ofInstant(tt.toInstant(), zoneOffset);
         return offsetDateTime.toString();
      }
   }
   
   /**
    * Adds the normalized extent fields to {@link RecordBufferSerializer#currentRowObject}. It first adds the
    * field names as key in the {@link RecordBufferSerializer#currentRowObject} map. Then, iterates through
    * all the sqls used to load the table data and populates the lists in the map using
    * {@link RecordBufferSerializer#handleSqlNormalizedRow(ResultSet)}.
    *
    * @param conn
    *        The {@link Connection} object used to interact with the database
    * @param rowId
    *        The ID of the row for which to fetch and process data
    *
    * @throws SQLException
    *         If a database access error occurs, or if any of the SQL operations (preparation, execution, or
    *         result handling) fail
    */
   private void addNormalizedExtentFields(Connection conn, int rowId)
   throws SQLException
   {
      for (Property normalizedField : normalizedFields)
      {
         currentRowObject.put(getFieldName(normalizedField), new ArrayList<>());
      }
      
      String[] sqls = dmoInfo.getRecordMeta().getLoadSql();
      for (int i = 1; i < sqls.length; i++)
      {
         try (PreparedStatement ps = conn.prepareStatement(sqls[i]))
         {
            ps.setInt(1, rowId);
            
            try (ResultSet res = ps.executeQuery())
            {
               while (res.next())
               {
                  handleSqlNormalizedRow(res);
               }
            }
         }
      }
   }
   
   /**
    * Iterates through the columns in the associated with the {@link ResultSet} and if the column exists in
    * {@link RecordBufferSerializer#sqlNameNormalizedFields}, adds the value in the List from
    * {@link RecordBufferSerializer#currentRowObject} for this extent. 
    * NOTE: this method assumes the {@link RecordBufferSerializer#currentRowObject} already contains the 
    * field name and the associated value is a List. This method does not handle scenarios when this is not
    * true.
    * 
    * @param resultSet
    *        The current {@code ResultSet}
    *
    * @throws SQLException
    *         If a database access error occurs
    */
   @SuppressWarnings("unchecked")
   private void handleSqlNormalizedRow(ResultSet resultSet)
   throws SQLException
   {
      ResultSetMetaData metaData = resultSet.getMetaData();
      int columnCount = metaData.getColumnCount();
      
      for (int i = 1; i <= columnCount; i++)
      {
         String columnName = metaData.getColumnLabel(i).toLowerCase();
         Property field = sqlNameNormalizedFields.get(columnName);
         if (field == null)
         {
            continue;
         }
         
         List<String> currentValuesForExtent = (List<String>) currentRowObject.get(getFieldName(field));
         
         String value = getFullValueForField(resultSet, field);
         currentValuesForExtent.add(value);
      }
   }
   
   /**
    * Gets the field name based on the flag {@link RecordBufferSerializer#useLegacyFieldsName}.
    *
    * @param field
    *        The field for which to the get name
    *
    * @return See above
    */
   private String getFieldName(Property field)
   {
      if (useLegacyFieldsName)
      {
         return field.legacy;
      }
      return field.column;
   }
}