CursorSimpleTest.java

/*
 *    Copyright 2009-2025 the original author or authors.
 *
 *    Licensed under the Apache License, Version 2.0 (the "License");
 *    you may not use this file except in compliance with the License.
 *    You may obtain a copy of the License at
 *
 *       https://www.apache.org/licenses/LICENSE-2.0
 *
 *    Unless required by applicable law or agreed to in writing, software
 *    distributed under the License is distributed on an "AS IS" BASIS,
 *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *    See the License for the specific language governing permissions and
 *    limitations under the License.
 */
package org.apache.ibatis.submitted.cursor_simple;

import java.io.Reader;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.NoSuchElementException;

import org.apache.ibatis.BaseDataTest;
import org.apache.ibatis.cursor.Cursor;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.RowBounds;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;

@TestMethodOrder(MethodOrderer.MethodName.class)
class CursorSimpleTest {

  private static SqlSessionFactory sqlSessionFactory;

  @BeforeAll
  static void setUp() throws Exception {
    // create a SqlSessionFactory
    try (
        Reader reader = Resources.getResourceAsReader("org/apache/ibatis/submitted/cursor_simple/mybatis-config.xml")) {
      sqlSessionFactory = new SqlSessionFactoryBuilder().build(reader);
    }

    // populate in-memory database
    BaseDataTest.runScript(sqlSessionFactory.getConfiguration().getEnvironment().getDataSource(),
        "org/apache/ibatis/submitted/cursor_simple/CreateDB.sql");
  }

  @Test
  void shouldGetAllUser() {
    try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
      Mapper mapper = sqlSession.getMapper(Mapper.class);
      Cursor<User> usersCursor = mapper.getAllUsers();

      Assertions.assertFalse(usersCursor.isOpen());

      // Cursor is just created, current index is -1
      Assertions.assertEquals(-1, usersCursor.getCurrentIndex());

      Iterator<User> iterator = usersCursor.iterator();

      // Check if hasNext, fetching is started
      Assertions.assertTrue(iterator.hasNext());
      Assertions.assertTrue(usersCursor.isOpen());
      Assertions.assertFalse(usersCursor.isConsumed());

      // next() has not been called, index is still -1
      Assertions.assertEquals(-1, usersCursor.getCurrentIndex());

      User user = iterator.next();
      Assertions.assertEquals("User1", user.getName());
      Assertions.assertEquals(0, usersCursor.getCurrentIndex());

      user = iterator.next();
      Assertions.assertEquals("User2", user.getName());
      Assertions.assertEquals(1, usersCursor.getCurrentIndex());

      user = iterator.next();
      Assertions.assertEquals("User3", user.getName());
      Assertions.assertEquals(2, usersCursor.getCurrentIndex());

      user = iterator.next();
      Assertions.assertEquals("User4", user.getName());
      Assertions.assertEquals(3, usersCursor.getCurrentIndex());

      user = iterator.next();
      Assertions.assertEquals("User5", user.getName());
      Assertions.assertEquals(4, usersCursor.getCurrentIndex());

      // Check no more elements
      Assertions.assertFalse(iterator.hasNext());
      Assertions.assertFalse(usersCursor.isOpen());
      Assertions.assertTrue(usersCursor.isConsumed());
    }
  }

  @Test
  void cursorClosedOnSessionClose() {
    Cursor<User> usersCursor;
    try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
      Mapper mapper = sqlSession.getMapper(Mapper.class);
      usersCursor = mapper.getAllUsers();

      Assertions.assertFalse(usersCursor.isOpen());

      Iterator<User> iterator = usersCursor.iterator();

      // Check if hasNext, fetching is started
      Assertions.assertTrue(iterator.hasNext());
      Assertions.assertTrue(usersCursor.isOpen());
      Assertions.assertFalse(usersCursor.isConsumed());

      // Consume only the first result
      User user = iterator.next();
      Assertions.assertEquals("User1", user.getName());

      // Check there is still remaining elements
      Assertions.assertTrue(iterator.hasNext());
      Assertions.assertTrue(usersCursor.isOpen());
      Assertions.assertFalse(usersCursor.isConsumed());
    }

    // The cursor was not fully consumed, but it should be close since we closed the session
    Assertions.assertFalse(usersCursor.isOpen());
    Assertions.assertFalse(usersCursor.isConsumed());
  }

  @Test
  void cursorWithRowBound() {
    try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
      // RowBound starting at offset 1 and limiting to 2 items
      Cursor<User> usersCursor = sqlSession.selectCursor("getAllUsers", null, new RowBounds(1, 3));

      Iterator<User> iterator = usersCursor.iterator();

      User user = iterator.next();
      Assertions.assertEquals("User2", user.getName());
      Assertions.assertEquals(1, usersCursor.getCurrentIndex());

      // Calling hasNext() before next()
      Assertions.assertTrue(iterator.hasNext());
      user = iterator.next();
      Assertions.assertEquals("User3", user.getName());
      Assertions.assertEquals(2, usersCursor.getCurrentIndex());

      // Calling next() without a previous hasNext() call
      user = iterator.next();
      Assertions.assertEquals("User4", user.getName());
      Assertions.assertEquals(3, usersCursor.getCurrentIndex());

      Assertions.assertFalse(iterator.hasNext());
      Assertions.assertFalse(usersCursor.isOpen());
      Assertions.assertTrue(usersCursor.isConsumed());
    }
  }

  @Test
  void cursorIteratorNoSuchElementExceptionWithHasNext() {
    try (SqlSession sqlSession = sqlSessionFactory.openSession();
        Cursor<User> usersCursor = sqlSession.selectCursor("getAllUsers", null, new RowBounds(1, 1))) {
      try {
        Iterator<User> iterator = usersCursor.iterator();

        User user = iterator.next();
        Assertions.assertEquals("User2", user.getName());
        Assertions.assertEquals(1, usersCursor.getCurrentIndex());

        Assertions.assertFalse(iterator.hasNext());
        iterator.next();
        Assertions.fail("We should have failed since we call next() when hasNext() returned false");
      } catch (NoSuchElementException e) {
        Assertions.assertFalse(usersCursor.isOpen());
        Assertions.assertTrue(usersCursor.isConsumed());
      }
    }
  }

  @Test
  void cursorIteratorNoSuchElementExceptionNoHasNext() {
    try (SqlSession sqlSession = sqlSessionFactory.openSession();
        Cursor<User> usersCursor = sqlSession.selectCursor("getAllUsers", null, new RowBounds(1, 1))) {
      try {
        Iterator<User> iterator = usersCursor.iterator();
        User user = iterator.next();
        Assertions.assertEquals("User2", user.getName());
        Assertions.assertEquals(1, usersCursor.getCurrentIndex());

        // Trying next() without hasNext()
        iterator.next();
        Assertions.fail("We should have failed since we call next() when is no more items");
      } catch (NoSuchElementException e) {
        Assertions.assertFalse(usersCursor.isOpen());
        Assertions.assertTrue(usersCursor.isConsumed());
      }
    }
  }

  @Test
  void cursorWithBadRowBound() {
    try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
      // Trying to start at offset 10 (which does not exist, since there is only 4 items)
      Cursor<User> usersCursor = sqlSession.selectCursor("getAllUsers", null, new RowBounds(10, 2));
      Iterator<User> iterator = usersCursor.iterator();

      Assertions.assertFalse(iterator.hasNext());
      Assertions.assertFalse(usersCursor.isOpen());
      Assertions.assertTrue(usersCursor.isConsumed());
    }
  }

  @Test
  void cursorMultipleHasNextCall() {
    try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
      Mapper mapper = sqlSession.getMapper(Mapper.class);
      Cursor<User> usersCursor = mapper.getAllUsers();

      Iterator<User> iterator = usersCursor.iterator();

      Assertions.assertEquals(-1, usersCursor.getCurrentIndex());

      User user = iterator.next();
      Assertions.assertEquals("User1", user.getName());
      Assertions.assertEquals(0, usersCursor.getCurrentIndex());

      Assertions.assertTrue(iterator.hasNext());
      Assertions.assertTrue(iterator.hasNext());
      Assertions.assertTrue(iterator.hasNext());
      // assert that index has not changed after hasNext() call
      Assertions.assertEquals(0, usersCursor.getCurrentIndex());
    }
  }

  @Test
  void cursorMultipleIteratorCall() {
    Iterator<User> iterator2 = null;
    try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
      Mapper mapper = sqlSession.getMapper(Mapper.class);
      Cursor<User> usersCursor = mapper.getAllUsers();

      Iterator<User> iterator = usersCursor.iterator();
      User user = iterator.next();
      Assertions.assertEquals("User1", user.getName());
      Assertions.assertEquals(0, usersCursor.getCurrentIndex());

      iterator2 = usersCursor.iterator();
      iterator2.hasNext();
      Assertions.fail("We should have failed since calling iterator several times is not allowed");
    } catch (IllegalStateException e) {
      Assertions.assertNull(iterator2, "iterator2 should be null");
      return;
    }
    Assertions.fail("Should have returned earlier");
  }

  @Test
  void cursorMultipleCloseCall() {
    try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
      Mapper mapper = sqlSession.getMapper(Mapper.class);
      Cursor<User> usersCursor = mapper.getAllUsers();

      Assertions.assertFalse(usersCursor.isOpen());

      Iterator<User> iterator = usersCursor.iterator();

      // Check if hasNext, fetching is started
      Assertions.assertTrue(iterator.hasNext());
      Assertions.assertTrue(usersCursor.isOpen());
      Assertions.assertFalse(usersCursor.isConsumed());

      // Consume only the first result
      User user = iterator.next();
      Assertions.assertEquals("User1", user.getName());

      usersCursor.close();
      // Check multiple close are no-op
      usersCursor.close();

      // hasNext now return false, since the cursor is closed
      Assertions.assertFalse(iterator.hasNext());
      Assertions.assertFalse(usersCursor.isOpen());
      Assertions.assertFalse(usersCursor.isConsumed());
    }
  }

  @Test
  void cursorUsageAfterClose() {

    try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
      Mapper mapper = sqlSession.getMapper(Mapper.class);

      Cursor<User> usersCursor = mapper.getAllUsers();
      try {
        Iterator<User> iterator = usersCursor.iterator();
        User user = iterator.next();
        Assertions.assertEquals("User1", user.getName());
        Assertions.assertEquals(0, usersCursor.getCurrentIndex());

        user = iterator.next();
        Assertions.assertEquals("User2", user.getName());
        Assertions.assertEquals(1, usersCursor.getCurrentIndex());

        usersCursor.close();

        // hasNext now return false, since the cursor is closed
        Assertions.assertFalse(iterator.hasNext());
        Assertions.assertFalse(usersCursor.isOpen());
        Assertions.assertFalse(usersCursor.isConsumed());

        // trying next() will fail
        iterator.next();

        Assertions.fail("We should have failed with NoSuchElementException since Cursor is closed");
      } catch (NoSuchElementException e) {
        // We had an exception and current index has not changed
        Assertions.assertEquals(1, usersCursor.getCurrentIndex());
        usersCursor.close();
        return;
      }
    }

    Assertions.fail("Should have returned earlier");
  }

  @Test
  void shouldGetAllUserUsingAnnotationBasedMapper() {
    try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
      sqlSession.getConfiguration().getMapperRegistry().addMapper(AnnotationMapper.class);
      AnnotationMapper mapper = sqlSession.getMapper(AnnotationMapper.class);
      Cursor<User> usersCursor = mapper.getAllUsers();

      Assertions.assertFalse(usersCursor.isOpen());
      Assertions.assertFalse(usersCursor.isConsumed());
      Assertions.assertEquals(-1, usersCursor.getCurrentIndex());

      List<User> userList = new ArrayList<>();
      for (User user : usersCursor) {
        userList.add(user);
        Assertions.assertEquals(userList.size() - 1, usersCursor.getCurrentIndex());
      }

      Assertions.assertFalse(usersCursor.isOpen());
      Assertions.assertTrue(usersCursor.isConsumed());
      Assertions.assertEquals(4, usersCursor.getCurrentIndex());

      Assertions.assertEquals(5, userList.size());
      User user = userList.get(0);
      Assertions.assertEquals("User1", user.getName());
      user = userList.get(1);
      Assertions.assertEquals("User2", user.getName());
      user = userList.get(2);
      Assertions.assertEquals("User3", user.getName());
      user = userList.get(3);
      Assertions.assertEquals("User4", user.getName());
      user = userList.get(4);
      Assertions.assertEquals("User5", user.getName());
    }
  }

  @Test
  void shouldThrowIllegalStateExceptionUsingIteratorOnSessionClosed() {
    Cursor<User> usersCursor;
    try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
      usersCursor = sqlSession.getMapper(Mapper.class).getAllUsers();
    }
    try {
      usersCursor.iterator();
      Assertions.fail("Should throws the IllegalStateException when call the iterator method after session is closed.");
    } catch (IllegalStateException e) {
      Assertions.assertEquals("A Cursor is already closed.", e.getMessage());
    }

    // verify for checking order
    try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
      usersCursor = sqlSession.getMapper(Mapper.class).getAllUsers();
      usersCursor.iterator();
    }
    try {
      usersCursor.iterator();
      Assertions.fail("Should throws the IllegalStateException when call the iterator already.");
    } catch (IllegalStateException e) {
      Assertions.assertEquals("Cannot open more than one iterator on a Cursor", e.getMessage());
    }

  }

  @Test
  void shouldNullItemNotStopIteration() {
    try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
      Mapper mapper = sqlSession.getMapper(Mapper.class);
      Cursor<User> cursor = mapper.getNullUsers(new RowBounds());
      Iterator<User> iterator = cursor.iterator();

      Assertions.assertFalse(cursor.isOpen());

      // Cursor is just created, current index is -1
      Assertions.assertEquals(-1, cursor.getCurrentIndex());

      // Check if hasNext, fetching is started
      Assertions.assertTrue(iterator.hasNext());
      // Re-invoking hasNext() should not fetch the next row
      Assertions.assertTrue(iterator.hasNext());
      Assertions.assertTrue(cursor.isOpen());
      Assertions.assertFalse(cursor.isConsumed());

      // next() has not been called, index is still -1
      Assertions.assertEquals(-1, cursor.getCurrentIndex());

      User user = iterator.next();
      Assertions.assertNull(user);
      Assertions.assertEquals(0, cursor.getCurrentIndex());

      Assertions.assertTrue(iterator.hasNext());
      user = iterator.next();
      Assertions.assertEquals("Kate", user.getName());
      Assertions.assertEquals(1, cursor.getCurrentIndex());

      Assertions.assertTrue(iterator.hasNext());
      user = iterator.next();
      Assertions.assertNull(user);
      Assertions.assertEquals(2, cursor.getCurrentIndex());

      Assertions.assertTrue(iterator.hasNext());
      user = iterator.next();
      Assertions.assertNull(user);
      Assertions.assertEquals(3, cursor.getCurrentIndex());

      // Check no more elements
      Assertions.assertFalse(iterator.hasNext());
      Assertions.assertFalse(cursor.isOpen());
      Assertions.assertTrue(cursor.isConsumed());
    }
  }

  @Test
  void shouldRowBoundsCountNullItem() {
    try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
      Mapper mapper = sqlSession.getMapper(Mapper.class);
      Cursor<User> cursor = mapper.getNullUsers(new RowBounds(1, 2));
      Iterator<User> iterator = cursor.iterator();

      Assertions.assertFalse(cursor.isOpen());

      // Check if hasNext, fetching is started
      Assertions.assertTrue(iterator.hasNext());
      // Re-invoking hasNext() should not fetch the next row
      Assertions.assertTrue(iterator.hasNext());
      Assertions.assertTrue(cursor.isOpen());
      Assertions.assertFalse(cursor.isConsumed());

      User user = iterator.next();
      Assertions.assertEquals("Kate", user.getName());
      Assertions.assertEquals(1, cursor.getCurrentIndex());

      Assertions.assertTrue(iterator.hasNext());
      user = iterator.next();
      Assertions.assertNull(user);
      Assertions.assertEquals(2, cursor.getCurrentIndex());

      // Check no more elements
      Assertions.assertFalse(iterator.hasNext());
      Assertions.assertFalse(cursor.isOpen());
      Assertions.assertTrue(cursor.isConsumed());
    }
  }
}