# 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;
       }
    }