ScrollableResults.java

/*
** Module   : ScrollableResults.java
** Abstract : Allows scrollable access to result set.
**
** Copyright (c) 2019-2024, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description----------------------------------------
** 001 ECF 20191001 Created initial version, with stubbed methods.
**     OM  20191111 Added method implementation.
** 002 SBI 20220811 Added new version of get method to retrieve selected columns from a row.
**         20221023 Added a usage of SQLQuery.hydrateRecord(ResultSet, RowStructure, int, Session).
** 003 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
** 004 AL2 20231211 Rewrite access to row structure count and DMO iface using getters. 
** 005 AD  20231110 Added get(boolean) that either returns get() or gets the primary keys from a ResultSet row.
**     AD  20231116 Refactored get() and get(boolean) so that get() calls get(boolean).
** 006 IAS 20230208 Dialect-specific error handler support.
** 007 CA  20240320 Avoid BDTs within internal FWD runtime.  Replaced iterators with Java 'for', where it
**                  applies.
** 008 OM  20240613 Added local awareness for validity of current row in delegated ResultSet.
** 009 SP  20240813 Extracted get(boolean) lambda code to getImpl(boolean) method.
** 010 LS  20241101 Changed getImpl(boolean) to get only the pk if the row contains only the recId.
** 011 OM  20241128 Multi-tenant runtime support: selected the proper persistence context, eventually
**                  based on [sharedDb] 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.util.logging.*;

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

/**
 * This object facilitates the random access to a {@code ResultSet} by providing scrolling primitives
 * ({@code first}, {@code next}, {@code prev}, {@code last}) and accessor to currently selected row.
 * <p>
 * The object is always aware whether the pointer of the delegate {@code ResultSet} is on a valid row by
 * intercepting the result of all invoked methods which moves the cursor in the delegate. This also have the
 * advantage of a bit of performance boost as it is able to detect and short-cut the call to the delegate in
 * cases the result is {@code false} / {@code null}.
 *
 * @param   <T>
 *          The expected type of the row elements. 
 * Note: 1. in the case of single-column result this is clearly OK, but when a table with a
 *          non homogeneous set of columns is queried, this type is not appropriate.
 *       2. The {@code ScrollingResults} is a very similar class that adds very little code
 *          compared to this object to which it delegates all {@code Result} API.
 *          TODO: decide whether we merge these classes
 */
public class ScrollableResults<T>
{
   /** Logger */
   private static final CentralLogger LOG = CentralLogger.get(ScrollableResults.class);
   
   /** The statement to close.  This assumes the {@link #rs} is the single result set for this statement. */
   private final Statement stmt;

   /** The delegate {@code ResultSet}. */
   private final ResultSet rs;
   
   /** The expected structure of a query, as generated by the {@link FqlToSqlConverter}. */
   private final ArrayList<RowStructure> rowStructure;
   
   /** The {@code Session} to be used.*/
   private final Session session;
   
   /** On close hook*/
   private SandboxRun<Void> onClose = () -> null;
   
   /** Tracks the 'available' flag of the inner {@code ResultSet}. */
   private boolean available = false;
   
   /**
    * Constructs a {@code ScrollableResults} object for further use.
    * 
    * @param   stmt
    *          The JDBC statement to close.
    * @param   rs
    *          The {@code ResultSet} to be used for extracting data.
    * @param   rowStructure
    *          The expected structure of a query, as generated by the {@link FqlToSqlConverter}.
    * @param   session
    *          The {@code Session} to be used.
    */
   public ScrollableResults(Statement stmt, ResultSet rs, ArrayList<RowStructure> rowStructure, Session session)
   {
      this.stmt = stmt;
      this.rs = rs;
      this.rowStructure = rowStructure;
      this.session = session;
      
      /*
      //TODO: check where and whether to put this:
      ScrollableResults.execute(() -> {
            if (rs.isBeforeFirst())
            {
               rs.next();
            }
            return null;
         },
         null);
      */
   }
   
   /**
    * Set on close hook.
    *
    * @param   hook
    *          The new hook.
    *
    * @return  {@code this}.
    */
   public ScrollableResults<T> onClose(SandboxRun<Void> hook)
   {
      this.onClose = hook;
      return this;
   }
   
   /**
    * Check whether the execution of the query which produces this result set is executed on the shared
    * section of the logical database. In case of non tenant databases, this flag is ignored.
    * 
    * @return  {@code true} if the buffers are not tenant private.
    */
   public boolean isSharedTenantDatabase()
   {
      return !session.getDatabase().isTenant();
   }
   
   /**
    * Go to result row.
    *
    * @return  {@code true} if there is a result under the cursor and {@code false} otherwise
    *          (there are no more records or some error occurred).
    */
   public boolean first()
   {
      available = execute(rs::first, false);
      return available;
   }
   
   /**
    * Advance to the last result row.
    *
    * @return  {@code true} if there is a result under the cursor and {@code false} otherwise
    *          (there are no more records or some error occurred).
    */
   public boolean last()
   {
      available = execute(rs::last, false);
      return available;
   }
   
   /**
    * Advance to the next result row.
    *
    * @return  {@code true} if there is a result under the cursor and {@code false} otherwise
    *          (there are no more records or some error occurred).
    */
   public boolean next()
   {
      try
      {
         available = rs.next();
      }
      catch (SQLException sql)
      {
         LOG.severe("", sql);
         available = false;
      }
      return available;
   }
   
   /**
    * Go back to previous result row.
    *
    * @return  {@code true} if there is a result under the cursor and {@code false} otherwise
    *          (there are no more records or some error occurred).
    */
   public boolean previous()
   {
      available = execute(rs::previous, false);
      return available;
   }
   
   /**
    * Test whether the current row is the first in this result set.
    *
    * @return  {@code true} if this is the first row of results.
    */
   public boolean isFirst()
   {
      return available && execute(rs::isFirst, false);
   }
   
   /**
    * Test whether the current row is the last in this result set.
    *
    * @return  {@code true} if this is the last row of results.
    */
   public boolean isLast()
   {
      return available && execute(rs::isLast, false);
   }
   
   /**
    * Obtain the full set of values on current row. An array of primary keys or full {@link Record} will be
    * returned.
    * 
    * @return  An array of {@code Object}s if the cursor is on a valid position. Otherwise {@code null} is
    *          returned. The type of returned elements may be both {@code Long} when an array of PKs (recids)
    *          is queried or an array of full {@link Record}s. The caller is responsible for handling both
    *          cases (including hydration, if needed).
    */
   public Object[] get()
   {
      return get(false);
   }
   
   /**
    * Obtain the full set of values on current row. An array of primary keys or full {@link Record}s will be
    * returned.
    * 
    * @param   forceOnlyId
    *          Indicates whether it's only the primary keys that must be returned, 
    *          or if the records must be hydrated and all data found is returned.
    * 
    * @return  When forceOnlyId is false, an array of {@code Object}s if the cursor is on a valid position. 
    *          If the cursor is not on a valid position then {@code null} is returned. 
    *          The type of returned elements may be both {@code Long} when an array of PKs (recids) is queried 
    *          or an array of full {@link Record}s. 
    *          The caller is responsible for handling both cases (including hydration, if needed).
    *          When forceOnlyId is true, an array of {@code Long} only with the PKs.
    */
   public Object[] get(boolean forceOnlyId)
   {
      if (!available)
      {
         return null;
      }

      try
      {
         return getImpl(forceOnlyId);
      }
      catch (SQLException sql)
      {
         LOG.severe("", sql);
         return null;
      }
   }
   
   /**
    * Obtain the selection of values on current row.
    * 
    * @param    dmoInfo
    *           The target data description
    * @param    idCol
    *           The name of the column which holds entity's id
    * @param    cols
    *           Array of names of other columns to retrieve.
    * 
    * @return   An array of {@code Object} s if the cursor is on a valid position. Otherwise {@code null} is
    *           returned.
    */
   public Object[] get(DmoMeta dmoInfo, String idCol, String[] cols)
   {
      if (!available)
      {
         return null;
      }
      
      return execute(() ->
      {
         List<String> colsAsList = Arrays.asList(cols);
         Set<String> uniqueCols = new HashSet<>(colsAsList);
         uniqueCols.add(idCol);
         Map<String, Integer> colsSelection = new HashMap<>(cols.length);
         for (String col : uniqueCols)
         {
            colsSelection.put(col, -1);
         }

         int cnt = 0;
         Iterator<Property> dmoCols = dmoInfo.getFields(true);
         while (dmoCols.hasNext())
         {
            Property dmoCol = dmoCols.next();
            String col = dmoCol.name;
            if (uniqueCols.contains(col))
            {
               colsSelection.put(col, cnt);
            }
            cnt++;
         }

         int rowSize = (rowStructure == null) ? 0 : rowStructure.size();
         
         if (rowSize == 0)
         {
            return null;
         }
         // the row is composed of columns from multiple DMOs. We split the row in components that match
         // the [rowStructure] map and hydrate all of the Records. This array is added as an entry in the
         // list of object returned by this method
         int rsOffset = 1;
         for (int i = 0; i < rowStructure.size(); i++)
         {
            RowStructure rowStruct = rowStructure.get(i);
            int expColumnCount = rowStruct.getCount();
            try
            {
               if (rowStruct.getDmoClass().equals(dmoInfo.iface))
               {
                  BaseRecord rec = SQLQuery.hydrateRecord(rs, rowStruct, rsOffset, session);
                  Object[] filteredRec = new Object[cols.length];
                  for (int k = 0; k < cols.length; k++)
                  {
                     Integer index = colsSelection.get(cols[k]);
                     if (index > 0 && index <= rec.data.length)
                     {
                        filteredRec[k] = rec.data[index - 1];
                     }
                     else
                     {
                        filteredRec[k] = cols[k];
                     }
                  }

                  return filteredRec;
               }
            }
            catch (PersistenceException e)
            {
               throw new SQLException(e);
            }
            
            // position on next record
            rsOffset += expColumnCount;
         }
         
         return null;
      }, null);
   }

   /**
    * Obtain the value of a {@code i}-th column of the current row.
    * @param i 
    *        column number
    * @param tClass 
    *        column 
    *
    * @return  The value on {@code i}-th column of the current row if {@code i} and current row
    *          are valid and {@code null} otherwise.
    */
   public T get(int i, Class<T> tClass)
   {
      return !available ? null : execute(() -> rs.getObject(i + 1, tClass), null);
   }
   
   /**
    * Obtain the index of current row in the result set. 0-based counter is used (ie, the first row
    * is number {@code 0}).
    * 
    * @return  the row index, staring at {@code 0}, or {@code -1} if there is no current row.
    */
   public int getRowNumber()
   {
      return !available ? -1 : execute(() -> rs.getRow() - 1, -1);
   }
   
   /**
    * Configures the index of current row in the result set. 0-based counter is used (ie, the first
    * row is number {@code 0}).
    * 
    * @param   row
    *          The 0-based index of the row to be selected under cursor. Use negative values to
    *          position the cursor form the end.
    *
    * @return  {@code true} if there is a row selected.
    */
   public boolean setRowNumber(int row)
   {
      // we need to transform to 1-base of [ResultSet]. The negative value remain unchanged 
      available = execute(() -> rs.absolute(row >= 0 ? row + 1 : row), false);
      return available;
   }
   
   /**
    * Test whether the cursor is located before the first row in this result set.
    *
    * @return  {@code true} if the cursor is located before the first row in this result set.
    */
   public boolean beforeFirst()
   {
      available = execute(() -> { rs.beforeFirst(); return true; }, false);
      return available;
   }
   
   /**
    * Scroll an given amount of rows, backward of forward.
    * 
    * @param   rows 
    *          The number of rows to scroll. If a positive value is provided the scroll is forward
    *          and if a negative value is provided the scroll is performed backward with number of
    *          rows equals with the absolute value of {@code rows}.
    *          
    * @return  {@code true} if there is a valid row at the new location.
    */
   public boolean scroll(int rows)
   {
      available = execute(() -> rs.relative(rows), false);
      return available;
   }
   
   /**
    * Closes the result set by releasing all resources.
    * 
    * @throws  PersistenceException
    *          if some error occurs in the process.
    */
   public void close()
   throws PersistenceException
   {
      execute(() -> { stmt.close(); onClose.run(); return null; }, null);
   }

   /**
    * Obtain the full set of values on current row. An array of primary keys or full {@link Record}s will be
    * returned.
    *
    * @param   forceOnlyId
    *          Indicates whether it's only the primary keys that must be returned,
    *          or if the records must be hydrated and all data found is returned.
    *
    * @return  When forceOnlyId is false, an array of {@code Object}s if the cursor is on a valid position.
    *          If the cursor is not on a valid position then {@code null} is returned.
    *          The type of returned elements may be both {@code Long} when an array of PKs (recids) is queried
    *          or an array of full {@link Record}s.
    *          The caller is responsible for handling both cases (including hydration, if needed).
    *          When forceOnlyId is true, an array of {@code Long} only with the PKs.
    */
   private Object[] getImpl(boolean forceOnlyId)
   throws SQLException
   {
      int rowSize = (rowStructure == null) ? 0 : rowStructure.size();
      int columnCount = rs.getMetaData().getColumnCount();
      Object[] ret;

      if (rowSize == 0 || rowSize == columnCount)
      {
         // if the [columnCount] matches the [rowSize] then we have only one column for each DMO to be
         // returned in a row. Since for each DMO all queries return at least the PK we deduce that the
         // result set of this query is compose only from PKs (recid/id).
         ret = new Object[columnCount];
         for (int k = 0; k < columnCount; k++)
         {
            ret[k] = rs.getObject(k + 1);
         }
      }
      else
      {
         // The row is composed of columns from multiple DMOs. We split the row in components that match
         // the [rowStructure] map and get the PKs of all the Records or hydrate all of the Records.
         // This array is added as an entry in the list of object returned by this method.
         ret = new Object[rowSize];
         int rsOffset = 1;
         int recCnt = 0;
         for (int i = 0; i < rowStructure.size(); i++)
         {
            RowStructure rowStruct = rowStructure.get(i);

            // if the current row contains only the recId we do not need to hydrate the record
            boolean needOnlyPK = !forceOnlyId && rowStruct instanceof AdaptiveRowStructure
                    && ((AdaptiveRowStructure) (rowStruct)).hasOnlyPK();

            int expColumnCount = rowStruct.getCount();
            try
            {
               if (forceOnlyId || needOnlyPK)
               {
                  ret[recCnt] =
                     SQLQuery.getRecordPrimaryKey(rs, rowStruct, rsOffset);
               }
               else
               {
                  ret[recCnt] =
                     SQLQuery.hydrateRecord(rs, rowStruct, rsOffset, session);
               }

            }
            catch (PersistenceException e)
            {
               throw new SQLException(e);
            }

            // position on next record
            rsOffset += expColumnCount;
            ++recCnt;
         }
      }
      return ret;
   }

   /**
    * Internal worker method. Execute a piece of code that potentially throws a
    * {@code SQLException} and returns the result. If the exception is throws, a default value is
    * used for return.
    * 
    * @param   code
    *          The code to be executed.
    * @param   defVal
    *          The default value to be returned when a {@code SQLException} is caught.
    * @param   <T>
    *          The class type of the returned object.
    * 
    * @return  The result of the evaluation od {@code code} or the {@code defVal} if evaluation
    *          failed.
    */
   private static <T> T execute(SandboxRun<T> code, T defVal)
   {
      try
      {
         return code.run();
      }
      catch (SQLException | PersistenceException sql)
      {
         LOG.severe("", sql);
         return defVal;
      }
   }
   
   /**
    * Functional interface for passing in a piece of code that potentially throws a 
    * {@code SQLException}.
    * 
    * @param   <T>
    *          The type of the returned value.
    */
   @FunctionalInterface
   static interface SandboxRun<T>
   {
      /**
       * The method to be executed.
       * 
       * @return  The evaluation of the code.
       *    
       * @throws  SQLException
       *          In case of errors while executing the method's body.
       */
      public T run()
      throws SQLException, PersistenceException;
   }
}