Cursor.java

/*
** Module   : Cursor.java
** Abstract : Cursor support for scrolling, dynamic queries
**
** Copyright (c) 2004-2025, Golden Code Development Corporation.
**
** -#- -I- --Date-- --JPRM-- ----------------------------------Description----------------------------------
** 001 ECF 20051215   @23737 Created initial version. Used by dynamic
**                           queries to manage scrolling and repositioning
**                           in a progressively updated results list.
** 002 ECF 20060216   @24717 Changed beforeFirst() method access. Made package
**                           private so CompoundQuery can access it.
** 003 ECF 20061019   @30526 Added reset() method. Resets cursor to a
**                           known, default state.
** 004 ECF 20061204   @31564 Integrated RepositionCache. This replaced a
**                           simple hash map approach which failed in
**                           cases where a lookup used an ID array which
**                           had fewer IDs than joined tables in a query.
**                           Also fixed reposition(int row) method variant
**                           to ensure cursor is always positioned before
**                           the target record.
** 005 ECF 20061206   @31585 Added boolean return value to reposition()
**                           variants which accept IDs. Indicates whether
**                           desired row was matched.
** 006 ECF 20070507   @33454 Fixed forward(). Set position to end of
**                           results after attempting to advance the
**                           cache.
** 007 ECF 20070518   @33679 Added contains() method. Tests whether the
**                           cursor's cache contains a particular primary
**                           key ID.
** 008 ECF 20071011   @35444 Fixed forward() and backward() methods. It
**                           was possible for the cursor's position to get
**                           out of sync with the associated query and for
**                           duplicate, ghost records to be cached by the
**                           cursor.
** 009 ECF 20080112   @36998 Removed restriction of dealing only with IDs.
**                           This class can now handle both DMO instances
**                           and primary key IDs when retrieving data.
** 010 ECF 20080731   @39317 Added rowid support. Refactored reposition*()
**                           methods.
** 011 ECF 20081106   @40321 Fixed regression introduced by H009 (@36998).
**                           Fixed contains() method to work whether IDs
**                           or DMOs are stored by the Cursor.
** 012 ECF 20090306   @42978 Added information to be tracked. An instance now
**                           knows if it has visited the first result in a set
**                           and which direction it has run off end of query
**                           results.
** 013 SVL 20140909          Modifications that allow cursor to be used as
**                           cache for dynamic scrolling queries.
** 014 SVL 20150216          Fixed positioning in scan mode during reposition.
** 015 SVL 20150331          deleteResultListEntry is now able to delete rows even if the cursor
**                           is positioned between them.
** 016 SVL 20150625          Fixed deletion of the first row.
** 017 SVL 20150722          Cursor position is set up properly before reposition scan.
** 018 OM  20150727          Granted package access to previous(), first() & last().
** 019 ECF 20160202          Replaced Apache commons logging with J2SE logging.
** 020 SVL 20160113          Fixed edge case of iterations past cached rows. Added referenceRow.
** 021 SVL 20160526          Added ability to set results.
** 022 SVL 20160608          Validation of reposition IDs is moved away from this class. Fixed
**                           reference row error for bulk delete. Added matching for null IDs.
** 023 SVL 20161007          Fix for the case when REPOSITION-TO-ROWID takes more ids than the
**                           actual query table count.
** 024 OM  20170209          Added return value for repositioning methods.
** 025 OM  20180926          Fixed repositionByID() to scan all available records.
** 026 SVL 20191002          Fixes for first/last. Added addResultAtCurrentPosition.
** 027 SVL 20191218          Fixed invalid result set generated by REPOSITION TO ROWID.
**     SVL 20200409          Fixed reposition from row N to row N+1 using REPOSITION TO ROW N+1.
** 028 ECF 20200906          New ORM implementation.
**     OM  20220202          Lowered access for reset(). Dropped unused import.
**     SVL 20220320          Fill repo cache when a list of results is set.
**     TW  20220831          Implemented toString for IDE debugging (refs: #6689)
**     SVL 20220901          Fixed wrong entry being deleted from repoCache in deleteResultListEntry().
** 029 GBB 20230512          Logging methods replaced by CentralLogger/ConversionStatus.
** 030 OM  20241128          Multi-tenant runtime support: selected the proper persistence context, eventually
**                           based on [sharedDb] parameter.
** 031 SB  20250325          Retrieve the primary key of the cached record instead of the record itself to
**                           account for the case when its state can become invalid. Refs #9402.
** 032 AL2 20250415          Allow consumers of repositionByID to decide if it should be a scan or not.
** 033 AL2 20250521          Repositioning to a row is done with NO_LOCK.
*/

/*
** 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 java.util.*;
import java.util.logging.*;
import com.goldencode.p2j.persist.event.*;
import com.goldencode.p2j.persist.lock.LockType;
import com.goldencode.p2j.persist.orm.*;
import com.goldencode.p2j.util.*;
import com.goldencode.p2j.util.ErrorManager;
import com.goldencode.p2j.util.logging.*;

/**
 * Implementation of a query results list scrollable cursor, for queries which
 * fetch their results dynamically.  This object maintains a transient list
 * of results for a dynamic query, such that the user of the query may scroll
 * forward, backward, to an absolute results list row, or to a result which
 * corresponds with an array of primary key IDs (which represent a particular
 * set of joined records).  The list is considered transient because it may
 * be discarded and rebuilt in certain cases where the user changes scrolling
 * or query navigation direction.
 * <p>
 * This class is used by the {@link DynamicQuery} class and requires that the
 * query it manages implement the {@link Scrollable} interface.  It is not
 * manipulated directly by business logic.
 */
final class Cursor
{
   /** Logger */
   private static final CentralLogger LOG = CentralLogger.get(Cursor.class.getName());
   
   /** Cached list of query results (as arrays of primary keys or DMOs) */
   private List<Object[]> results = new ArrayList<>();
   
   /**
    * Last fetched row used as the reference row for future iterations. It is an array of record
    * snapshots.
    */
   private Record[] referenceRow = null;
   
   /** Map of primary key lists of query results to their result list rows */
   private final RepositionCache repoCache = new RepositionCache();
   
   /** Scrollable query whose results are cursored */
   private final Scrollable query;
   
   /** Current position within the results list */
   private double position = -0.5;
   
   /** Flag indicating forward scroll direction */
   private boolean forwardScroll = true;
   
   /** Have we visited the first result in the result set? */
   private boolean foundFirst = false;
   
   /** Have we visited the full result set? */
   private boolean fullSet = false;
   
   /**
    * Was the first row (or the last row if the scroll direction is backward) deleted by
    * {@link #deleteResultListEntry}?
    */
   private boolean firstRowDeleted = false;
   
   /**
    * Constructor which associates the cursor with the query it is managing.
    *
    * @param   query
    *          Instance of a scrollable query.
    */
   Cursor(Scrollable query)
   {
      this.query = query;
   }
   
   /**
    * Reset cursor to a known default state.
    */
   void reset()
   {
      results.clear();
      repoCache.clear();
      position = -0.5;
      foundFirst = false;
      forwardScroll = true;
      fullSet = false;
      firstRowDeleted = false;
      referenceRow = null;
   }
   
   /**
    * Set underlying cursor results.
    *
    * @param results
    *        Results to set: cached list of query results (as arrays of primary keys or DMOs).
    */
   void setResults(List<Object[]> results)
   {
      reset();
      this.results = results;

      for (int i = 0; i < results.size(); i++)
      {
         addToRepoCache(results.get(i), i);
      }
      fullSet = true;
   }
   
   /**
    * Reposition the cursor such that a request to retrieve the next result
    * will retrieve the result which matches the specified primary key ID.  A
    * request to retrieve the previous result will retrieve the result
    * immediately previous to this, if any, in the results list.
    *
    * @param   scan
    *          <code>true</code> to scan all results in the event a match is
    *          not found in the cache;  <code>false</code> to check the cache
    *          only.
    * @param   id
    *          A primary key ID, representing the target record (in the case
    *          of a join, this ID is associated with the left-most record in
    *          the join).
    *
    * @return  <code>true</code> if a matching record was found and the
    *          cursor was repositioned to it;  <code>false</code> if any ID
    *          was unknown or if a matching record could not be found (and we
    *          are in silent error mode).
    * 
    * @throws  ErrorConditionException
    *          if the specified ID represents the unknown value.
    */
   boolean repositionByID(boolean scan, integer id)
   {
      Long[] serIDs = new Long[] { id.longValue() };
      
      if (!validateReposition(id))
      {
         return false;
      }
      
      return reposition(serIDs, scan);
   }
   
   /**
    * Reposition the cursor such that a request to retrieve the next result
    * will retrieve the result which matches the specified array of primary
    * key IDs.  A request to retrieve the previous result will retrieve the
    * result immediately previous to this, if any, in the results list.
    * <p>
    * If the specified result does not already exist in the results list, the
    * query is instructed to retrieve results sequentially, starting with the
    * first result, caching each result in its list.  This continues until
    * either the desired result is found (and the cursor is then repositioned
    * accordingly), or until no more results are available (in which case the
    * cursor remains at its current position).
    *
    * @param   scan
    *          <code>true</code> to scan all results in the event a match is
    *          not found in the cache;  <code>false</code> to check the cache
    *          only.
    * @param   id1
    *          A primary key ID, representing the target record (in the case
    *          of a join, this ID is associated with the left-most record in
    *          the join).
    * @param   joinIDs
    *          All remaining primary key IDs, if any, arranged from left to
    *          right to coincide with the records being joined by the
    *          underlying query.
    *
    * @return  <code>true</code> if a matching record was found and the
    *          cursor was repositioned to it;  <code>false</code> if any ID
    *          was unknown or if a matching record could not be found (and we
    *          are in silent error mode).
    */
   boolean repositionByID(boolean scan, rowid id1, rowid... joinIDs)
   {
      int len = joinIDs.length;
      Long[] serIDs = new Long[len + 1];
      
      serIDs[0] = id1.getValue();
      
      for (int i = 0; i < len; i++)
      {
         rowid next = joinIDs[i];
         
         serIDs[i + 1] = next.getValue();
      }
      
      return reposition(serIDs, scan);
   }
   
   /**
    * Reposition the cursor such that a request to retrieve the next result
    * will retrieve the result which matches the specified array of primary
    * key IDs.  A request to retrieve the previous result will retrieve the
    * result immediately previous to this, if any, in the results list.
    * <p>
    * If the specified result already exists in the results list, the cursor
    * is immediately repositioned to just before that result.  Otherwise,
    * the behavior depends upon the value of the <code>scan</code> parameter.
    * If it is <code>true</code>, the query is instructed to retrieve results
    * sequentially, starting with the first available, caching each result in
    * its list.  This continues until either the desired result is found (in
    * which case the cursor is repositioned accordingly), or until no more
    * results are available (in which case the cursor remains at its current
    * position).
    *
    * @param   ids
    *          Array of primary key IDs, arranged from left to right to
    *          coincide with the records being joined by the underlying
    *          query.
    * @param   scan
    *          <code>true</code> to scan all results in the event a match is
    *          not found in the cache;  <code>false</code> to check the cache
    *          only.
    *
    * @return  <code>true</code> if a matching record was found and the
    *          cursor was repositioned to it;  <code>false</code> if any ID
    *          was unknown or if a matching record could not be found (and we
    *          are in silent error mode).
    */
   boolean reposition(Long[] ids, boolean scan)
   {
      // Check cache first.
      ids = DBUtils.trimToTableCount(ids, query);
      int row = repoCache.getRow(ids);
      if (row >= 0)
      {
         position = forwardScroll ? row - 0.5 : row + 0.5;
         
         return true;
      }
      
      if (!scan || isFullSet())
      {
         return false;
      }

      int size = results.size();
      if (size > 0)
      {
         // Advance to just before last cached result.
         position = size - 1.5;

         // Synchronize query's results with cursor's position.
         // This shouldn't lock the records because a reposition
         // will end up in-between records.
         if (forwardScroll)
         {
            query.next(LockType.NONE);
         }
         else
         {
            query.previous(LockType.NONE);
         }
      }

      Object[] data;
      while (true)
      {
         data = forwardScroll ? query.peekNext() : query.peekPrevious();
         
         if (data == null)
            break;
         
         if (match(ids, data))
         {
            position = forwardScroll ? position - 0.5 : position + 0.5;
            return true;
         }
      }
      
      // Here we need to emulate a quirk/defect of Progress' reposition
      // implementation:  if no match was found, we leave the "current"
      // position at the last checked position.  Furthermore, if we are
      // fetching on reposition, we have to load this last record into the
      // associated buffer(s), to truly emulate the GET NEXT semantic.
      if (query.isFetchOnReposition() && !results.isEmpty())
      {
         try
         {
            query.next(LockType.NONE);
         }
         catch (ErrorConditionException exc)
         {
            if (LOG.isLoggable(Level.FINE))
            {
               LOG.log(Level.FINE, "Error fetching record(s) during reposition", exc);
            }
         }
      }
      
      return false;
   }
   
   /**
    * Get off-end state of query results.
    *
    * @return off-end state of query results.
    */
   OffEnd getOffEnd()
   {
      if (!foundFirst)
      {
         return OffEnd.NONE;
      }
      
      if (fullSet && position > results.size() - 1)
      {
         return OffEnd.BACK;
      }
      
      if (position < 0)
      {
         return forwardScroll ? OffEnd.FRONT : OffEnd.BACK;
      }
      
      return OffEnd.NONE;
   }
   
   /**
    * Reposition the cursor to the specified row in the result set.  The row
    * indicates the position between two results, such that a call to
    * retrieve the next record will get the result at the specified row,
    * and a call to retrieve the previous result will get the result before
    * the new position.
    * <p>
    * If the specified row does not already exist in the results list, the
    * query is instructed to retrieve results sequentially, starting with the
    * first result, caching each result in its list.  This continues until
    * either the desired row is reached (and the cursor is then repositioned
    * accordingly), or until no more results are available (in which case the
    * cursor remains at its current position).
    * <p>
    * Note that this method will clear the cache if the cursor previously had
    * been scrolling backward.
    *
    * @param   row
    *          1-based index of the target position.
    *
    * @throws  ErrorConditionException
    *          if <code>row</code> represents unknown value.
    */
   void reposition(NumberType row)
   {
      if (validateReposition(row))
      {
         reposition(row.intValue());
      }
   }
   
   /**
    * Reposition the cursor to the specified row in the result set.  The row
    * indicates the position between two results, such that a call to
    * retrieve the next record will get the result at the specified row,
    * and a call to retrieve the previous result will get the result before
    * the new position.
    * <p>
    * If the specified row does not already exist in the results list, the
    * query is instructed to retrieve results sequentially, starting with the
    * first result, caching each result in its list.  This continues until
    * either the desired row is reached (and the cursor is then repositioned
    * accordingly), or until no more results are available (in which case the
    * cursor remains at its current position).
    * <p>
    * Note that this will clear the cache if we previously had been scrolling
    * backward.
    *
    * @param   row
    *          1-based index of the target position.
    */
   void reposition(int row)
   {
      // Adjust for 1-based index;  disallow negative numbers.
      row = Math.max(0, --row);
      
      // Change to forward scroll mode if necessary.
      if (!forwardScroll)
      {
         forwardScroll = true;
         results.clear();
         repoCache.clear();
         referenceRow = null;
         
         first();
         query.first(LockType.NONE);
      }
      
      // Convert to relative move.
      int jump = row - ((int) Math.ceil(position + 0.5));
      if (jump < 0)
      {
         backward(-jump);
      }
      else if (jump > 0)
      {
         forward(jump);
      }
      else if (position < row)
      {
         // Using REPOSITION FORWARD we cannot jump from an on-record position to the next
         // position, the minimal jump is 1.5 in this case. E.g. using REPOSITION FORWARD 1 from
         // the position 5 we'll get to the position 6.5. This section handles this case.
         position = Math.floor(position) + 0.5;
      }
   }
   
   /**
    * Advance the current cursor position forward by the specified number of
    * rows.  This will always leave the cursor between two results.  For
    * example, given a request to move 1 row forward:
    * <ul>
    *   <li>if the cursor currently is positioned <i>directly on a result</i>,
    *       this would move it ahead <i>one and a half positions</i>, to just
    *       beyond the following result;
    *   <li>if the cursor currently is positioned <i>between two results</i>,
    *       this would move it ahead <i>one position</i>, to just beyond the
    *       following result.
    * </ul>
    * <p>
    * If the request carries the cursor beyond the end of the currently
    * cached results list, the query is instructed to retrieve additional
    * results sequentially.  These are added to the results list.  This
    * continues until the request is satisfied, or until there are no more
    * results available.  In the latter case, the cursor is left positioned
    * just beyond the last available result.
    *
    * @param   rows
    *          Number of rows to scroll the cursor forward.
    *
    * @return  {@code true} if operation was successful and the cursor was moved the exact amount
    *          of rows as specified by the parameter. If {@code false} is returned then it is
    *          possible that cursor's position was moved but not with {@code rows} rows.
    * 
    * @throws  ErrorConditionException
    *          if {@code rows} represents the unknown value.
    */
   boolean forward(NumberType rows)
   {
      if (validateReposition(rows))
      {
         return forward(rows.intValue());
      }
      return false;
   }
   
   /**
    * Advance the current cursor position forward by the specified number of
    * rows.  This will always leave the cursor between two results.  For
    * example, given a request to move 1 row forward:
    * <ul>
    *   <li>if the cursor currently is positioned <i>directly on a result</i>,
    *       this would move it ahead <i>one and a half positions</i>, to just
    *       beyond the following result;
    *   <li>if the cursor currently is positioned <i>between two results</i>,
    *       this would move it ahead <i>one position</i>, to just beyond the
    *       following result.
    * </ul>
    * <p>
    * If the request carries the cursor beyond the end of the currently
    * cached results list, the query is instructed to retrieve additional
    * results sequentially.  These are added to the results list.  This
    * continues until the request is satisfied, or until there are no more
    * results available.  In the latter case, the cursor is left positioned
    * just beyond the last available result.
    *
    * @param   rows
    *          Number of rows to scroll the cursor forward.
    *
    * @return  {@code true} if operation was successful and the cursor was moved the exact amount
    *          of rows as specified by the parameter. If {@code false} is returned then it is
    *          possible that cursor's position was moved but not with {@code rows} rows.
    */
   boolean forward(int rows)
   {
      double expectedPosition = position + rows;
      if (forwardScroll)
      {
         int cacheSize = results.size();
         double gap = Math.floor(position) + 1 + rows - cacheSize;
         if (gap >= 0)
         {
            if (!fullSet)
            {
               if (cacheSize > 0)
               {
                  // Advance to just before last cached result.
                  position = cacheSize - 1.5;
                  
                  // Synchronize query's results with cursor's position.
                  // Don't enforce locking on the record; otherwise the reposition
                  // will pend for other locks.
                  query.next(LockType.NONE);
                  
                  // If gap == 0 then we are on the last cached row and we should sync query
                  // because next() can be called from this position.
               }
               
               for (int i = 0; i < gap; i++)
               {
                  Object[] row = query.peekNext();
                  
                  if (row == null)
                  {
                     break;
                  }
               }
            }
            
            position = results.size() - 1;
         }
         else
         {
            position += rows;
         }
         
         // Ensure cursor is between two entries.
         position = Math.floor(position) + 0.5;
      }
      else
      {
         position -= rows;
         
         // Ensure cursor is between two entries.
         position = Math.ceil(position) - 0.5;
         
         if (position < -0.5)
         {
            position = -0.5;
         }
      }
      
      return Math.abs(expectedPosition - position) < 1;
   }
   
   /**
    * Move the current cursor position backward by the specified number of
    * rows.  This will always leave the cursor between two results.  For
    * example, given a request to move 1 row backward:
    * <ul>
    *   <li>if the cursor currently is positioned <i>directly on a result</i>,
    *       this would move it back <i>one half position</i>, to just
    *       before the current result;
    *   <li>if the cursor currently is positioned <i>between two results</i>,
    *       this would move it back <i>one position</i>, to just before the
    *       current result.
    * </ul>
    * <p>
    * If the request carries the cursor before the start of the currently
    * cached results list, the query is instructed to retrieve additional,
    * previous results sequentially.  These are prepended to the results list.
    * This continues until the request is satisfied, or until there are no
    * more results available.  In the latter case, the cursor is left
    * positioned just before the first available result.
    *
    * @param   rows
    *          Number of rows to scroll the cursor backward.
    *
    * @return  {@code true} if operation was successful and the cursor was moved the exact amount
    *          of rows as specified by the parameter. If {@code false} is returned then it is
    *          possible that cursor's position was moved but not with {@code rows} rows.
    *
    * @throws  ErrorConditionException
    *          if {@code rows} represents the unknown value.
    */
   boolean backward(NumberType rows)
   {
      if (validateReposition(rows))
      {
         return backward(rows.intValue());
      }
      return false;
   }
   
   /**
    * Move the current cursor position backward by the specified number of
    * rows.  This will always leave the cursor between two results.  For
    * example, given a request to move 1 row backward:
    * <ul>
    *   <li>if the cursor currently is positioned <i>directly on a result</i>,
    *       this would move it back <i>one half position</i>, to just
    *       before the current result;
    *   <li>if the cursor currently is positioned <i>between two results</i>,
    *       this would move it back <i>one position</i>, to just before the
    *       current result.
    * </ul>
    * <p>
    * If the request carries the cursor before the start of the currently
    * cached results list, the query is instructed to retrieve additional,
    * previous results sequentially.  These are prepended to the results list.
    * This continues until the request is satisfied, or until there are no
    * more results available.  In the latter case, the cursor is left
    * positioned just before the first available result.
    *
    * @param   rows
    *          Number of rows to scroll the cursor backward.
    *
    * @return  {@code true} if operation was successful and the cursor was moved the exact amount
    *          of rows as specified by the parameter. If {@code false} is returned then it is
    *          possible that cursor's position was moved but not with {@code rows} rows.
    */
   boolean backward(int rows)
   {
      double expectedPosition = position - rows;
      if (forwardScroll)
      {
         position -= rows;
         
         // Ensure cursor is between two entries.
         position = Math.floor(position) + 0.5;
         
         if (position < -0.5)
         {
            position = -0.5;
         }
      }
      else
      {
         int cacheSize = results.size();
         double gap = Math.floor(position) + rows - cacheSize;
         if (gap > 0)
         {
            if (!fullSet)
            {
               if (cacheSize > 0)
               {
                  // Advance to just before first cached result.
                  position = cacheSize - 1.5;
                  
                  // Synchronize query's results with cursor's position.
                  query.previous(LockType.NONE);
               }
               
               for (int i = 0; i < gap; i++)
               {
                  Object[] row = query.peekPrevious();
                  
                  if (row == null)
                  {
                     break;
                  }
               }
            }
            
            position = forwardScroll ? -0.5 : results.size() - 0.5;
         }
         else
         {
            position += rows;
         }
         
         // Ensure cursor is between two entries.
         position = forwardScroll ? Math.floor(position) + 0.5 : Math.ceil(position) - 0.5;
      }
      
      return Math.abs(expectedPosition - position) < 1;
   }
   
   /**
    * Get the <b><i>current</i></b> size of the cached results list.  However,
    * since this cache is built progressively as the query is scrolled,
    * repositioned, and naturally navigated, this <b>does not</b> necessarily
    * represent the full count of results which may satisfy the associated
    * query.
    *
    * @return  Current results list size.
    */
   integer size()
   {
      return new integer(results.size());
   }
   
   /**
    * Get the 1-based index of the current row in the result set.  This is
    * the index of the entry at or before which the cursor currently is
    * positioned.
    *
    * @return  Current row or the unknown value if the current row cannot be
    *          determined.  The current row cannot be determined if the
    *          results list is empty.
    */
   integer currentRow()
   {
      if (!foundFirst || !forwardScroll || results.size() == 0)
      {
         return (new integer());
      }
      
      // Determine row at which or before which we are positioned, adjusting
      // for 0-based index.
      int row = (int) Math.ceil(position) + 1;
      
      return (new integer(row));
   }
   
   /**
    * Indicate whether the cursor has crossed the specified boundary (front or
    * back) of the associated query's list of available results.  If
    * <code>true</code>, this indicates that the query cannot produce any more
    * results in that scroll direction.  If <code>boundary</code> is
    * <code>null</code> or <code>OffEnd.NONE</code>, assume the caller just
    * wants to know if the cursor has gone off-end at all (either direction).
    * 
    * @param   boundary
    *          One of the <code>OffEnd</code> enum types, or <code>null</code>
    *          to indicate we want to know if the cursor has gone off either
    *          end of the result list (equivalent to specifying the enum type
    *          <code>NONE</code>).
    *
    * @return  <code>true</code> if the cursor is off the specified end of its
    *          query's results list, else <code>false</code>.
    */
   boolean isOffEnd(OffEnd boundary)
   {
      OffEnd offEnd = getOffEnd();
      
      if (boundary == null || OffEnd.NONE.equals(boundary))
      {
         return !OffEnd.NONE.equals(offEnd);
      }
      
      return offEnd.equals(boundary);
   }
   
   /**
    * Store a result in the first position of the cached results list.  If
    * <code>ids</code> is <code>null</code>, this indicates the query has run
    * off the beginning of its available results.
    *
    * @param   data
    *          Result to add:  the primary keys of the records backing the
    *          result to be added or the records themselves (or a mix).
    */
   void addResultFirst(Object[] data)
   {
      referenceRow = null;
      foundFirst = true;

      // Change to forward scroll mode if necessary.
      if (!forwardScroll)
      {
         forwardScroll = true;
         results.clear();
         repoCache.clear();
      }

      if (data == null)
      {
         // empty data set
         fullSet = true;
         position = -0.5;
         return;
      }

      firstRowDeleted = false;
      results.add(0, data);
      first();
      addToRepoCache(data);
   }
   
   /**
    * Store a result in the last position of the cached results list.  If
    * <code>ids</code> is <code>null</code>, this indicates the query has run
    * off the end of its available results.
    *
    * @param   data
    *          Result to add:  the primary keys of the records backing the
    *          result to be added or the records themselves (or a mix).
    */
   void addResultLast(Object[] data)
   {
      referenceRow = null;
      foundFirst = true;

      // Change to backward scroll mode if necessary.
      if (forwardScroll)
      {
         forwardScroll = false;
         results.clear();
         repoCache.clear();
      }

      if (data == null)
      {
         // empty data set
         fullSet = true;
         position = -0.5;
         return;
      }

      firstRowDeleted = false;
      results.add(data);
      last();
      addToRepoCache(data);
   }

   /**
    * Add a result to the current position of the cached results list.
    *
    * @param   data
    *          Result to add:  the primary keys of the records backing the
    *          result to be added or the records themselves (or a mix).
    */
   void addResultAtCurrentPosition(Object[] data)
   {
      if (position < 0)
      {
         position = 0;
      }
      else if (position > results.size() - 1)
      {
         position = results.size();
      }
      else
      {
         position = (int) Math.floor(position) + 1;
      }

      if (results.isEmpty())
      {
         foundFirst = true;
      }

      results.add((int) position, data);
      addToRepoCache(data);
   }
   
   /**
    * Add a result to the end of the cached results list.  If <code>ids</code>
    * is <code>null</code>, this indicates the query has run off the end of
    * its available results.
    *
    * @param   data
    *          Result to add:  the primary keys of the records backing the
    *          result to be added or the records themselves (or a mix).
    */
   void addResultNext(Object[] data)
   {
      if (query.getTableCount() == 1)
      {
         referenceRow = null;
      }
      
      if (data == null)
      {
         if (!foundFirst)
         {
            foundFirst = true;
            fullSet = true;
            position = -0.5;
            return;
         }
         
         if (forwardScroll)
         {
            fullSet = true;
            position = results.size() - 0.5;
         }
         
         return;
      }
      
      if (results.isEmpty())
      {
         addResultFirst(data);
         
         return;
      }
      
      // Add result to end of list;  set current position to end of results list.
      results.add(data);
      position = results.size() - 1;
      addToRepoCache(data);
   }
   
   /**
    * Prepend a result to the beginning of the cached results list.  If
    * <code>ids</code> is <code>null</code>, this indicates the query has run
    * off the beginning of its available results.
    *
    * @param   data
    *          Result to add:  the primary keys of the records backing the
    *          result to be added or the records themselves (or a mix).
    */
   void addResultPrevious(Object[] data)
   {
      if (query.getTableCount() == 1)
      {
         referenceRow = null;
      }
      
      if (data == null)
      {
         if (!foundFirst)
         {
            foundFirst = true;
            fullSet = true;
            position = -0.5;
            return;
         }
         
         if (!forwardScroll)
         {
            fullSet = true;
            position = -0.5;
            forwardScroll = true;
            Collections.reverse(results);
         }
         
         return;
      }
      
      if (results.isEmpty())
      {
         addResultLast(data);
         
         return;
      }
      
      results.add(data);
      position = results.size() - 1;
      addToRepoCache(data);
   }
   
   /**
    * Store the last fetched row as the reference row for future iterations.
    *
    * @param event
    *        Event caused by update of a record of specific type.
    */
   void storeReferenceRow(RecordChangeEvent event)
   {
      if (fullSet || results.size() == 0 ||
          (query.getTableCount() == 1 && referenceRow != null) ||
          (event != null && event.isFullBulkDelete()))
      {
         return;
      }
      
      RecordBuffer[] buffers;
      
      // get all record buffers
      if (query instanceof CompoundQuery)
      {
         CompoundQuery q = (CompoundQuery) query;
         buffers = new RecordBuffer[query.getTableCount()];
         int i = 0;
         Iterator<RecordBuffer> iter = q.recordBuffers();
         while (iter.hasNext())
         {
            RecordBuffer buffer = iter.next();
            buffers[i++] = buffer;
         }
      }
      else
      {
         buffers = query.getRecordBuffers();
      }
      
      // get last fetched row
      Object[] rowData = results.get(results.size() - 1);
      
      if (referenceRow == null)
      {
         referenceRow = new Record[buffers.length];
      }
      
      for (int i = 0; i < buffers.length; i++)
      {
         if (referenceRow[i] != null)
         {
            continue;
         }
         
         RecordBuffer buffer = buffers[i];
         Object rowElement = rowData[i];
         Long rowElementId = rowElement instanceof Record
                             ? ((Record) rowElement).primaryKey()
                             : (Long) rowElement;
         
         Record dmo = null;
         
         // check event if it contains target DMO
         if (event != null &&
             event.getSource().getDMOInterface().equals(buffer.getDMOInterface()))
         {
            Record snapshot = event.getSnapshot();
            if (snapshot != null && snapshot.primaryKey().equals(rowElementId))
            {
               referenceRow[i] = snapshot.snapshot();
               dmo = snapshot;
            }
         }
         
         // directly load DMO
         if (dmo == null && rowElementId != null)
         {
            Persistence persistence = buffer.getPersistence();
            Session session = persistence.getSession(!buffer.getDmoInfo().multiTenant);
            
            try
            {
               dmo = session.get(buffer.getDMOImplementationClass(), rowElementId);
            }
            catch (PersistenceException exc)
            {
               throw new IllegalArgumentException(exc);
            }
            
            if (dmo != null)
            {
               referenceRow[i] = dmo.snapshot();
               try
               {
                  buffer.evictDMOIfUnused(dmo);
               }
               catch (PersistenceException exc)
               {
                  DBUtils.handleException(buffer.getDatabase(), exc);
                  ErrorManager.recordOrThrowError(exc);
               }
            }
         }
         
         if (dmo == null)
         {
            // fallback
            referenceRow = null;
            break;
         }
      }
   }

   /**
    * Reset reference row snapshots starting from the given buffer index.
    *
    * @param startIndex
    *        Starting buffer index in the set of all buffers participating in the query.
    */
   void resetReferenceRow(int startIndex)
   {
      if (startIndex == 0)
      {
         referenceRow = null;
      }
      else if (referenceRow != null)
      {
         Arrays.fill(referenceRow, startIndex, referenceRow.length, null);
      }
   }
   
   /**
    * Produce a string representation of the cursor.
    * 
    * @return  String representation of the cursor.
    */
   public String toString()
   {
      StringBuilder buf = new StringBuilder();
      buf.append(super.toString())
         .append("\n").append(" position:").append(position).append(" (0-based)")
         .append("\n").append("  fullSet:").append(fullSet)
         .append("\n").append("repoCache:").append(repoCache.toString().replace("\n", "\n   "));
      return buf.toString();
   }
   
   /**
    * Get the reference row for future iterations.
    *
    * @return array of record snapshots which form the reference row for future iterations.
    */
   public Record[] getReferenceRow()
   {
      return referenceRow;
   }
   
   /**
    * Move the cursor exactly onto the index of the first result <i>in the
    * cache</i>, and return the primary key array representing that result.
    * If there is no such result, the original cursor position is restored.
    * Note that such a condition does not necessarily imply that no further
    * result is available, only that none was cached by the cursor.
    *
    * @return  Primary key array representing the first cached result row, or
    *          <code>null</code> if no such result was cached.
    */
   Object[] getFirst()
   {
      if (results.isEmpty() || (!forwardScroll && !fullSet))
      {
         return null;
      }
      
      first();
      
      return getResult(-0.5);
   }
   
   /**
    * Move the cursor exactly onto the index of the last result <i>in the
    * cache</i>, and return the primary key array representing that result.
    * If there is no such result, the original cursor position is restored.
    * Note that such a condition does not necessarily imply that no further
    * result is available, only that none was cached by the cursor.
    *
    * @return  Primary key array representing the last cached result row, or
    *          <code>null</code> if no such result was cached.
    */
   Object[] getLast()
   {
      if (results.isEmpty() || (!fullSet && forwardScroll))
      {
         return null;
      }
      
      last();
      
      return getResult(position + 0.5);
   }
   
   /**
    * Move the cursor exactly onto the index of the next result <i>in the
    * cache</i>, and return the primary key array representing that result.
    * If there is no such result, the original cursor position is restored.
    * Note that such a condition does not necessarily imply that no further
    * result is available, only that none was cached by the cursor.
    *
    * @return  Primary key array representing the next cached result row, or
    *          <code>null</code> if no such result was cached.
    */
   Object[] getNext()
   {
      if (results.isEmpty())
      {
         return null;
      }
      
      next();
      
      return getResult(forwardScroll ? position - 0.5 : position + 0.5);
   }
   
   /**
    * Move the cursor exactly onto the index of the previous result <i>in the
    * cache</i>, and return the primary key array representing that result.
    * If there is no such result, the original cursor position is restored.
    * Note that such a condition does not necessarily imply that no further
    * result is available, only that none was cached by the cursor.
    *
    * @return  Primary key array representing the previous cached result row,
    *          or <code>null</code> if no such result was cached.
    */
   Object[] getPrevious()
   {
      if (results.isEmpty())
      {
         return null;
      }
      
      previous();
      
      return getResult(forwardScroll ? position + 0.5 : position - 0.5);
   }
   
   /**
    * Retrieve the virtual, composite row of results at the cursor's current
    * position.  If there are no results cached, return null.
    * 
    * @return  Array of results for the current row.
    */
   Object[] getRow()
   {
      if (results.isEmpty())
      {
         return null;
      }
      
      return getResult(position);
   }

   /**
    * Get last loaded result row.
    *
    * @return last loaded result row
    */
   Object[] peekLastLoadedResult()
   {
      int size = results.size();
      return size > 0 ? results.get(size - 1) : null;
   }
   
   /**
    * Indicate whether the results cache contains the given ID within any of
    * its arrays.  Each array within the cache is checked at the given index
    * for a match.
    *
    * @param   id
    *          ID to match.
    * @param   index
    *          Index position at which a match is attempted within each array
    *          stored in the results cache.
    *
    * @return  <code>true</code> if the cache contains the given ID, else
    *          <code>false</code>.
    */
   boolean contains(Long id, int index)
   {
      for (Object[] next : results)
      {
         Object o = next[index];
         Long nextID = null;
         if (o instanceof Record)
         {
            nextID = ((Record) o).primaryKey();
         }
         else
         {
            nextID = (Long) o;
         }
         
         if (nextID.equals(id))
         {
            return true;
         }
      }
      
      return false;
   }
   
   /**
    * Move the cursor to just before the index of the first entry, whether or
    * not it actually exists in the results list.
    */
   void beforeFirst()
   {
      position = -0.5;
   }
   
   /**
    * Determines if the query has fetched all of its results. I.e. if it has reached the last
    * result starting from the first one, of the first stating from the last one.
    *
    * @return <code>true</code> if the query has fetched all of its results.
    */
   boolean isFullSet()
   {
      return fullSet;
   }
   
   /**
    * Determines if the cache is build in the forward direction (starting from the first result),
    * or backward direction (starting from the last result).
    *
    * @return <code>true</code> if the cache is build in the forward direction, <code>false</code>
    *         if it is build in the backward direction
    */
   boolean isForwardScroll()
   {
      return forwardScroll;
   }
   
   /**
    * Determines if we have visited the first result in the result set.
    *
    * @return <code>true</code> if we have visited the first result in the result set.
    */
   boolean isFoundFirst()
   {
      return foundFirst;
   }
   
   /**
    * Deletes the current row in the cache.
    *
    * @param allowBetweenRows
    *        If <code>true</code> then the cursor can be positioned between rows (the next
    *        row will be deleted). <code>false</code> for conventional DELETE-RESULT-LIST-ENTRY()
    *        mode where the cursor should be positioned on a row (otherwise <code>false</code> is
    *        returned).
    *
    * @return <code>true</code> on success.
    */
   boolean deleteResultListEntry(boolean allowBetweenRows)
   {
      boolean betweenRows = Math.floor(position) != position;
      OffEnd offEnd = getOffEnd();
      if ((OffEnd.NONE.equals(offEnd) ||
           (allowBetweenRows && OffEnd.FRONT.equals(offEnd))) &&
          results.size() > 0 &&
          (allowBetweenRows || !betweenRows))
      {
         storeReferenceRow(null);

         if (position == 0)
         {
            firstRowDeleted = true;
         }
         
         if (allowBetweenRows && betweenRows)
         {
            int targetPosition = (int) (position + 0.5);
            removeFromRepoCache(results.remove(targetPosition));
         }
         else
         {
            removeFromRepoCache(getRow());
            results.remove((int) position);
            position -= 0.5;
         }

         return true;
      }
      
      return false;
   }
   
   /**
    * Move the cursor exactly onto the index of the next entry, whether or not it actually exists
    * in the results list.
    */
   void next()
   {
      if (forwardScroll)
      {
         position = Math.floor(position) + 1;
      }
      else
      {
         position = Math.ceil(position) - 1;
      }
   }
   
   /**
    * Move the cursor exactly onto the index of the first entry, whether or not it actually exists
    * in the results list.
    */
   void first()
   {
      position = 0;
   }
   
   /**
    * Move the cursor exactly onto the index of the last entry, whether or not it actually exists
    * in the results list.
    */
   void last()
   {
      if (forwardScroll)
      {
         position = Math.max(0, results.size() - 1);
      }
      else
      {
         position = 0;
      }
   }
   
   /**
    * Move the cursor exactly onto the index of the previous entry, whether or not it actually
    * exists in the results list.
    */
   void previous()
   {
      if (forwardScroll)
      {
         position = Math.ceil(position) - 1;
      }
      else
      {
         position = Math.floor(position) + 1;
      }
   }
   
   /**
    * Determines if the first row (or the last row if the scroll direction is backward) deleted by
    * {@link #deleteResultListEntry}.
    *
    * @return See above.
    */
   boolean isFirstRowDeleted()
   {
      return firstRowDeleted;
   }
   
   /**
    * Retrieve the result at the cursor's current position, which should
    * always be at an index boundary (not between result indices).  If there
    * is no such result, the cursor's current position is reset to the
    * position provided and <code>null</code> is returned.  The latter
    * condition indicates that the cursor has been moved beyond a cached
    * result, such that the caller needs to attempt to query a result and
    * cache it.
    *
    * @param   resetPosition
    *          Position to which the cursor must be reset in the event no
    *          result is available in the cached results list at the cursor's
    *          current position.
    * 
    * @return  The data at the cursor's current position, or <code>null</code>
    *          if no such data exists. For deleted DMOs their record ID will
    *          be retrieved instead.
    */
   private Object[] getResult(double resetPosition)
   {
      // It is safe to truncate here, since position is expected to be set to
      // an integer boundary when this method is called.
      int index = (int) position;
      
      if (index < 0 || index >= results.size())
      {
         position = resetPosition;
         return null;
      }
      
      Object[] data = results.get(index);
      for (int i = 0; i < data.length; i++)
      {
         Object obj = data[i];
         if (obj instanceof Record && ((Record) obj).checkState(DmoState.DELETED))
         {
            // If the cached DMO instance has been deleted put its record ID instead to force
            // a refetch of the record, as its state could be updated. See #9402 for an example.
            data[i] = ((Record) obj).primaryKey();
         }
      }
      
      return data;
   }
   
   /**
    * Associate an array of primary key IDs representing a query result with
    * the cursor's current position.  This is used to enable a fast look up
    * when performing a "reverse" reposition;  that is, a reposition where
    * the cursor is given an array of primary keys and must move to the
    * associated results list row.
    * <p>
    * Note that this method should only be invoked when the current cursor
    * position corresponds with the specified result, otherwise the row will
    * be mismatched for the result.
    *
    * @param   data
    *          Array of primary keys or DMOs representing a query result.
    */
   private void addToRepoCache(Object[] data)
   {
      int row = (int) Math.ceil(position);
      addToRepoCache(data, row);
   }

   /**
    * Associate an array of primary key IDs representing a query result with
    * the specified cursor position.  This is used to enable a fast look up
    * when performing a "reverse" reposition;  that is, a reposition where
    * the cursor is given an array of primary keys and must move to the
    * associated results list row.
    *
    * @param   data
    *          Array of primary keys or DMOs representing a query result.
    * @param   row
    *          0-based row number associated with the specified query result.
    */
   private void addToRepoCache(Object[] data, int row)
   {
      int len = data.length;
      Long[] ids = new Long[len];
      for (int i = 0; i < len; i++)
      {
         Object datum = data[i];
         ids[i] = (datum instanceof Record ? ((Record) datum).primaryKey() : (Long) datum);
      }
      repoCache.add(ids, row);
   }
   
   /**
    * Delete the cache entry associated with the specified array of primary keys or DMOs .
    *
    * @param   data
    *          Array of primary keys or DMOs associated with the entry to be deleted.
    */
   private void removeFromRepoCache(Object[] data)
   {
      int len = data.length;
      Long[] ids = new Long[len];
      for (int i = 0; i < len; i++)
      {
         Object datum = data[i];
         ids[i] = (datum instanceof Record ? ((Record) datum).primaryKey() : (Long) datum);
      }
      repoCache.remove(ids);
   }
   
   /**
    * Detect whether all elements in one object array match those at the same
    * positions within another array.  The length of the first array
    * determines the number of elements checked.  That is, if the second
    * array contains more elements than the first, excess elements in the
    * second array are ignored.
    *
    * @param   a1
    *          First array.  May have fewer or the same number of elements as
    *          <code>a2</code>.
    * @param   a2
    *          Second array.  May have the same number or more elements than
    *          <code>a1</code>.
    *
    * @return  <code>true</code> if all elements in <code>a1</code> equal
    *          those elements at corresponding positions in <code>a2</code>.
    */
   private boolean match(Long[] a1, Object[] a2)
   {
      int len = a1.length;
      for (int i = 0; i < len; i++)
      {
         Long id = a1[i];
         Object datum = a2[i];
         
         if (datum instanceof Record)
         {
            datum = ((Record) datum).primaryKey();
         }
         
         if (id == null)
         {
            if (datum != null)
            {
               return false;
            }
         }
         else if (!id.equals(datum))
         {
            return false;
         }
      }
      
      return true;
   }
   
   /**
    * Validate a numeric value provided to a reposition request to ensure
    * that it does not represent the unknown value.
    *
    * @param   value
    *          ID or row number/offset to be validated.
    *
    * @return  <code>true</code> if the number is valid;  <code>false</code>
    *          if it is invalid, but we are in silent error mode.
    *
    * @throws  ErrorConditionException
    *          if the number is invalid and we are not in silent error mode.
    */
   private boolean validateReposition(BaseDataType value)
   {
      if (value.isUnknown())
      {
         ErrorManager.recordOrThrowError(
            3165,
            "Could not evaluate reposition amount for query");
         
         return false;
      }
      
      return true;
   }
}