Support databases that return SUCCESS_NO_INFO upon batch execution

The PreparedStatement.executeBatch method may return a general success
indicator SUCCESS_NO_INFO. For example, this is the case for Oracle or
SAP MaxDB. If SUCCESS_NO_INFO is returned, the number of database rows
updated or deleted by a particular row of the prepared statement batch
cannot be determined. In particular it is not known whether 0 or 1 rows
have been updated as updating 0 rows is a successful statement execution
as well, from JDBC's point of view. Gwtorm, however, will interpret
SUCCESS_NO_INFO as a failure. Consequently, on a database returning
SUCCESS_NO_INFO, any INSERT, UPDATE or DELETE operation will fail.

Furthermore, to implement the UPSERT functionality, firstly updates are
attempted. For rows that could not be updated successfully, an insert
will be performed. Here, it is necessary to determine the exact update
count for individual items. If this is not possible for a particular SQL
dialect (as it returns SUCCESS_NO_INFO), updates need to be attempted
individually.

To determine, if update batching can safely be used, the SQLDialect is
equipped with a method to indicate whether batch execution allows to
determine the exact batch update counts for individual rows.

Change-Id: I779f5a70acbb1886ea7be003a322970f7d7efb0e
Signed-off-by: Adrian Goerler <adrian.goerler@sap.com>
diff --git a/src/main/java/com/google/gwtorm/jdbc/JdbcAccess.java b/src/main/java/com/google/gwtorm/jdbc/JdbcAccess.java
index 563553f..fa4dace 100644
--- a/src/main/java/com/google/gwtorm/jdbc/JdbcAccess.java
+++ b/src/main/java/com/google/gwtorm/jdbc/JdbcAccess.java
@@ -151,43 +151,157 @@
   @Override
   public void insert(final Iterable<T> instances) throws OrmException {
     try {
-      PreparedStatement ps = null;
-      try {
-        int cnt = 0;
-        for (final T o : instances) {
-          if (ps == null) {
-            ps = schema.getConnection().prepareStatement(getInsertOneSql());
-          }
-          bindOneInsert(ps, o);
-          ps.addBatch();
-          cnt++;
-        }
-        execute(ps, cnt);
-      } finally {
-        if (ps != null) {
-          ps.close();
-        }
+      if (schema.getDialect().canDetermineIndividualBatchUpdateCounts()) {
+        insertAsBatch(instances);
+      } else {
+        insertIndividually(instances);
       }
     } catch (SQLException e) {
       throw convertError("insert", e);
     }
   }
 
+  private void insertIndividually(Iterable<T> instances) throws SQLException,
+      OrmConcurrencyException {
+    PreparedStatement ps = null;
+    try {
+      boolean concurrencyViolationDetected = false;
+      for (final T o : instances) {
+        if (ps == null) {
+          ps = schema.getConnection().prepareStatement(getInsertOneSql());
+        }
+        bindOneInsert(ps, o);
+        int updateCount = ps.executeUpdate();
+        if (updateCount != 1) {
+          concurrencyViolationDetected = true;
+        }
+      }
+      if (concurrencyViolationDetected) {
+        throw new OrmConcurrencyException();
+      }
+    } finally {
+      if (ps != null) {
+        ps.close();
+      }
+    }
+  }
+
+  private void insertAsBatch(final Iterable<T> instances) throws SQLException,
+      OrmConcurrencyException {
+    PreparedStatement ps = null;
+    try {
+      int cnt = 0;
+      for (final T o : instances) {
+        if (ps == null) {
+          ps = schema.getConnection().prepareStatement(getInsertOneSql());
+        }
+        bindOneInsert(ps, o);
+        ps.addBatch();
+        cnt++;
+      }
+      execute(ps, cnt);
+    } finally {
+      if (ps != null) {
+        ps.close();
+      }
+    }
+  }
+
   @Override
   public void update(final Iterable<T> instances) throws OrmException {
     try {
+      if (schema.getDialect().canDetermineIndividualBatchUpdateCounts()) {
+        updateAsBatch(instances);
+      } else {
+        updateIndividually(instances);
+      }
+    } catch (SQLException e) {
+      throw convertError("update", e);
+    }
+  }
+
+  private void updateIndividually(Iterable<T> instances) throws SQLException,
+      OrmConcurrencyException {
+    PreparedStatement ps = null;
+    try {
+      boolean concurrencyViolationDetected = false;
+      for (final T o : instances) {
+        if (ps == null) {
+          ps = schema.getConnection().prepareStatement(getUpdateOneSql());
+        }
+        bindOneUpdate(ps, o);
+        int updateCount = ps.executeUpdate();
+        if (updateCount != 1) {
+          concurrencyViolationDetected = true;
+        }
+      }
+      if (concurrencyViolationDetected) {
+        throw new OrmConcurrencyException();
+      }
+    } finally {
+      if (ps != null) {
+        ps.close();
+      }
+    }
+  }
+
+  private void updateAsBatch(final Iterable<T> instances) throws SQLException,
+      OrmConcurrencyException {
+    PreparedStatement ps = null;
+    try {
+      int cnt = 0;
+      for (final T o : instances) {
+        if (ps == null) {
+          ps = schema.getConnection().prepareStatement(getUpdateOneSql());
+        }
+        bindOneUpdate(ps, o);
+        ps.addBatch();
+        cnt++;
+      }
+      execute(ps, cnt);
+    } finally {
+      if (ps != null) {
+        ps.close();
+      }
+    }
+  }
+
+  /**
+   * Attempt to update instances.
+   *
+   * @param instances the instances to attempt to update
+   * @return collection of instances that cannot be updated as they are not yet
+   *         existing
+   */
+  private Collection<T> attemptUpdate(final Iterable<T> instances)
+      throws OrmException {
+    if (schema.getDialect().canDetermineIndividualBatchUpdateCounts()) {
+      return attemptUpdateAsBatch(instances);
+    } else {
+      return attemptUpdatesIndividually(instances);
+    }
+  }
+
+  private Collection<T> attemptUpdatesIndividually(Iterable<T> instances)
+      throws OrmException {
+    Collection<T> inserts = null;
+    try {
       PreparedStatement ps = null;
       try {
-        int cnt = 0;
+        List<T> allInstances = new ArrayList<T>();
         for (final T o : instances) {
           if (ps == null) {
             ps = schema.getConnection().prepareStatement(getUpdateOneSql());
           }
           bindOneUpdate(ps, o);
-          ps.addBatch();
-          cnt++;
+          int updateCount = ps.executeUpdate();
+          if (updateCount != 1) {
+            if (inserts == null) {
+              inserts = new ArrayList<T>();
+            }
+            inserts.add(o);          }
+          allInstances.add(o);
         }
-        execute(ps, cnt);
       } finally {
         if (ps != null) {
           ps.close();
@@ -196,12 +310,21 @@
     } catch (SQLException e) {
       throw convertError("update", e);
     }
+    return inserts;
   }
 
   @Override
   public void upsert(final Iterable<T> instances) throws OrmException {
-    // Assume update first, it will cheaply tell us if the row is missing.
-    //
+  // Assume update first, it will cheaply tell us if the row is missing.
+  Collection<T> inserts = attemptUpdate(instances);
+
+    if (inserts != null) {
+      insert(inserts);
+    }
+  }
+
+  private Collection<T> attemptUpdateAsBatch(final Iterable<T> instances)
+      throws OrmException {
     Collection<T> inserts = null;
     try {
       PreparedStatement ps = null;
@@ -244,36 +367,68 @@
       throw convertError("update", e);
     }
 
-    if (inserts != null) {
-      insert(inserts);
-    }
+    return inserts;
   }
 
   @Override
   public void delete(final Iterable<T> instances) throws OrmException {
     try {
-      PreparedStatement ps = null;
-      try {
-        int cnt = 0;
-        for (final T o : instances) {
-          if (ps == null) {
-            ps = schema.getConnection().prepareStatement(getDeleteOneSql());
-          }
-          bindOneDelete(ps, o);
-          ps.addBatch();
-          cnt++;
-        }
-        execute(ps, cnt);
-      } finally {
-        if (ps != null) {
-          ps.close();
-        }
+      if (schema.getDialect().canDetermineIndividualBatchUpdateCounts()) {
+        deleteAsBatch(instances);
+      } else {
+        deleteIndividually(instances);
       }
     } catch (SQLException e) {
       throw convertError("delete", e);
     }
   }
 
+  private void deleteIndividually(Iterable<T> instances) throws SQLException,
+      OrmConcurrencyException {
+    PreparedStatement ps = null;
+    try {
+      boolean concurrencyViolationDetected = false;
+      for (final T o : instances) {
+        if (ps == null) {
+          ps = schema.getConnection().prepareStatement(getDeleteOneSql());
+        }
+        bindOneDelete(ps, o);
+        int updateCount = ps.executeUpdate();
+        if (updateCount != 1) {
+          concurrencyViolationDetected = true;
+        }
+      }
+      if (concurrencyViolationDetected) {
+        throw new OrmConcurrencyException();
+      }
+    } finally {
+      if (ps != null) {
+        ps.close();
+      }
+    }
+  }
+
+  private void deleteAsBatch(final Iterable<T> instances) throws SQLException,
+      OrmConcurrencyException {
+    PreparedStatement ps = null;
+    try {
+      int cnt = 0;
+      for (final T o : instances) {
+        if (ps == null) {
+          ps = schema.getConnection().prepareStatement(getDeleteOneSql());
+        }
+        bindOneDelete(ps, o);
+        ps.addBatch();
+        cnt++;
+      }
+      execute(ps, cnt);
+    } finally {
+      if (ps != null) {
+        ps.close();
+      }
+    }
+  }
+
   private static void execute(final PreparedStatement ps, final int cnt)
       throws SQLException, OrmConcurrencyException {
     if (cnt == 0) {
diff --git a/src/main/java/com/google/gwtorm/schema/sql/SqlDialect.java b/src/main/java/com/google/gwtorm/schema/sql/SqlDialect.java
index 4fca155..afc1d25 100644
--- a/src/main/java/com/google/gwtorm/schema/sql/SqlDialect.java
+++ b/src/main/java/com/google/gwtorm/schema/sql/SqlDialect.java
@@ -1,4 +1,4 @@
-// Copyright 2008 Google Inc.
+// Copyright (C) 2011 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -327,4 +327,17 @@
       String fromColumn, ColumnModel col) throws OrmException;
 
   protected abstract String getNextSequenceValueSql(String seqname);
+
+  /**
+   * Does the array returned by the PreparedStatement.executeBatch method return
+   * the exact number of rows updated for every row in the batch?
+   *
+   * @return <code>true</code> if the executeBatch method returns the number of
+   *         rows affected for every row in the batch; <code>false</code> if it
+   *         may return Statement.SUCESS_NO_INFO
+   */
+  public boolean canDetermineIndividualBatchUpdateCounts() {
+    return true;
+
+  }
 }
diff --git a/src/test/java/com/google/gwtorm/jdbc/TestJdbcAccess.java b/src/test/java/com/google/gwtorm/jdbc/AbstractTestJdbcAccess.java
similarity index 67%
rename from src/test/java/com/google/gwtorm/jdbc/TestJdbcAccess.java
rename to src/test/java/com/google/gwtorm/jdbc/AbstractTestJdbcAccess.java
index 3f1ab65..76319d6 100644
--- a/src/test/java/com/google/gwtorm/jdbc/TestJdbcAccess.java
+++ b/src/test/java/com/google/gwtorm/jdbc/AbstractTestJdbcAccess.java
@@ -17,11 +17,10 @@
 
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.fail;
-import static org.mockito.Mockito.CALLS_REAL_METHODS;
-import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyZeroInteractions;
 import static org.mockito.Mockito.when;
@@ -34,7 +33,6 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
 
-import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.Parameterized;
@@ -48,26 +46,28 @@
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.HashSet;
 import java.util.List;
-import java.util.Set;
 
 @RunWith(Parameterized.class)
-public class TestJdbcAccess {
+public abstract class AbstractTestJdbcAccess {
 
   private static final String INSERT = "insert";
   private static final String UPDATE = "update";
   private static final String DELETE = "delete";
 
-  private static final SqlDialect DIALECT = mock(SqlDialect.class,
-      CALLS_REAL_METHODS);
+  private final JdbcAccess<Data, Data.DataKey> classUnderTest;
 
   private final Iterable<Data> noData;
   private final Iterable<Data> oneRow;
   private final Iterable<Data> twoRows;
-  private Connection conn;
+  private final Connection conn;
+  protected final SqlDialect dialect;
 
-  private static abstract class IterableProvider<T> {
+  protected Integer totalUpdateCount = null;
+
+  protected SQLException sqlException = null;
+
+  static abstract class IterableProvider<T> {
     abstract Iterable<T> createIterable(T... ts);
   }
 
@@ -86,7 +86,7 @@
         @Override
         Iterable<Data> createIterable(Data... data) {
           List<Data> list = Arrays.asList(data);
-          return new ListResultSet<TestJdbcAccess.Data>(list);
+          return new ListResultSet<Data>(list);
         }
       };
 
@@ -100,18 +100,24 @@
         }
       };
 
-  public TestJdbcAccess(IterableProvider<Data> dataProvider) {
-    noData = dataProvider.createIterable();
-    oneRow = dataProvider.createIterable(new Data(1));
-    twoRows = dataProvider.createIterable(new Data(1), new Data(2));
-  }
-
   @Parameters
   public static Collection<Object[]> data() {
     return Arrays.asList(new Object[][] { {LIST_PROVIDER},
-        {UNMODIFIABLE_LIST_PROVIDER}, {LIST_RESULT_SET_PROVIDER}});
+        {UNMODIFIABLE_LIST_PROVIDER}, {LIST_RESULT_SET_PROVIDER},});
   }
 
+  public AbstractTestJdbcAccess(IterableProvider<Data> dataProvider)
+      throws SQLException {
+    noData = dataProvider.createIterable();
+    oneRow = dataProvider.createIterable(new Data(1));
+    twoRows = dataProvider.createIterable(new Data(1), new Data(2));
+    conn = mock(Connection.class);
+    dialect = createDialect();
+    classUnderTest = createJdbcAccess(dialect, conn);
+  }
+
+  protected abstract SqlDialect createDialect() throws SQLException;
+
   private PreparedStatement stubStatementWithUpdateCounts(String command,
       final int... updateCounts) throws SQLException {
     PreparedStatement ps = mock(PreparedStatement.class);
@@ -120,14 +126,19 @@
     doNothing().when(ps).addBatch();
     when(ps.executeBatch()).thenReturn(updateCounts);
 
+    int total = 0;
+
     // non-batching
     if (updateCounts != null && updateCounts.length > 0) {
       OngoingStubbing<Integer> stubber = when(ps.executeUpdate());
       for (int updateCount : updateCounts) {
         stubber = stubber.thenReturn(updateCount);
+        total += updateCount;
       }
     }
 
+    totalUpdateCount = Integer.valueOf(total);
+
     when(conn.prepareStatement(command)).thenReturn(ps);
     return ps;
   }
@@ -142,19 +153,16 @@
     return ps;
   }
 
-  private JdbcAccess<Data, Data.DataKey> createJdbcAccess(
-      final SqlDialect dialect) {
-    JdbcSchema schema = setupSchema(dialect);
+  private static JdbcAccess<Data, Data.DataKey> createJdbcAccess(
+      final SqlDialect dialect, Connection conn) {
+    JdbcSchema schema = setupSchema(dialect, conn);
 
     JdbcAccess<Data, Data.DataKey> classUnderTest = new DataJdbcAccess(schema);
     return classUnderTest;
   }
 
-  private JdbcAccess<Data, Data.DataKey> createClassUnderTest() {
-    return createJdbcAccess(DIALECT);
-  }
-
-  private JdbcSchema setupSchema(final SqlDialect dialect) {
+  private static JdbcSchema setupSchema(final SqlDialect dialect,
+      final Connection conn) {
     @SuppressWarnings("rawtypes")
     Database db = mock(Database.class);
     try {
@@ -169,138 +177,130 @@
     }
   }
 
-  private static void assertNotUsed(PreparedStatement insert) {
+  protected static void assertUsedBatchingOnly(PreparedStatement ps,
+      int ...ids) throws SQLException {
+    verify(ps, times(ids.length)).addBatch();
+    verify(ps).executeBatch();
+    verify(ps, never()).executeUpdate();
+    assertExpectedIdsUsed(ps, ids);
+  }
+
+  protected static void assertUsedNonBatchingOnly(PreparedStatement ps,
+      int ... ids) throws SQLException {
+    verify(ps, never()).addBatch();
+    verify(ps, never()).executeBatch();
+    verify(ps, times(ids.length)).executeUpdate();
+    assertExpectedIdsUsed(ps, ids);
+  }
+
+  protected static void assertNotUsed(PreparedStatement insert) {
     verifyZeroInteractions(insert);
   }
 
-  private static void assertUsedBatchingOnly(PreparedStatement statement)
-      throws SQLException {
-    verify(statement, atLeastOnce()).addBatch();
-    verify(statement).executeBatch();
-    verify(statement, never()).executeUpdate();
-  }
+  protected abstract void assertCorrectUpdating(PreparedStatement ps, int ... ids)
+      throws SQLException;
 
   private static void assertExpectedIdsUsed(PreparedStatement statement,
       int... ids) throws SQLException {
-
-    Set<Integer> notSet = new HashSet<Integer>(2);
-    notSet.add(1);
-    notSet.add(2);
-
     for (int id : ids) {
       verify(statement).setInt(1, id);
-      notSet.remove(Integer.valueOf(id));
     }
-
-    for (Integer id : notSet) {
-      verify(statement, never()).setInt(1, id);
-    }
-  }
-
-  @Before
-  public void setup() {
-    conn = mock(Connection.class);
   }
 
   @Test
   public void testInsertNothing() throws OrmException {
-    setup();
-    createClassUnderTest().insert(noData);
+    classUnderTest.insert(noData);
   }
 
   @Test
-  public void testInsertOne() throws OrmException, SQLException {
+  public void testInsertOne() throws SQLException, OrmException {
     PreparedStatement insert = stubStatementWithUpdateCounts(INSERT, 1);
 
-    createClassUnderTest().insert(oneRow);
+    classUnderTest.insert(oneRow);
 
-    assertUsedBatchingOnly(insert);
+    assertCorrectUpdating(insert, 1);
   }
 
   @Test
-  public void testInsertOneDBException() throws OrmException, SQLException {
-    SQLException exception = new BatchUpdateException();
+  public void testInsertOneException() throws OrmException, SQLException {
+    sqlException = new BatchUpdateException();
     PreparedStatement insert =
-        stubStatementThrowExceptionOnExecute(INSERT, exception);
-    JdbcAccess<Data, Data.DataKey> classUnderTest = createClassUnderTest();
+        stubStatementThrowExceptionOnExecute(INSERT, sqlException);
     try {
       classUnderTest.insert(oneRow);
       fail("missingException");
     } catch (OrmException e) {
       // expected
-      assertSame(e.getCause(), exception);
+      assertSame(e.getCause(), sqlException);
     }
 
-    assertUsedBatchingOnly(insert);
+    assertCorrectUpdating(insert, 1);
   }
 
   @Test
   public void testUpdateNothing() throws OrmException {
-    createClassUnderTest().update(noData);
+    classUnderTest.update(noData);
   }
 
   @Test
-  public void testUpdateOne() throws OrmException, SQLException {
+  public void testUpdateOne() throws SQLException, OrmException {
     PreparedStatement update = stubStatementWithUpdateCounts(UPDATE, 1);
 
-    createClassUnderTest().update(oneRow);
+    classUnderTest.update(oneRow);
 
-    assertUsedBatchingOnly(update);
+    assertCorrectUpdating(update, 1);
   }
 
   @Test
-  public void testUpdateOneDBException() throws OrmException, SQLException {
-    SQLException exception = new BatchUpdateException();
+  public void testUpdateOneException() throws SQLException {
+    sqlException = new BatchUpdateException();
     PreparedStatement update =
-        stubStatementThrowExceptionOnExecute(UPDATE, exception);
-    JdbcAccess<Data, Data.DataKey> classUnderTest = createClassUnderTest();
+        stubStatementThrowExceptionOnExecute(UPDATE, sqlException);
     try {
       classUnderTest.update(oneRow);
       fail("missingException");
     } catch (OrmException e) {
       // expected
-      assertSame(e.getCause(), exception);
+      assertSame(e.getCause(), sqlException);
     }
 
-    assertUsedBatchingOnly(update);
+    assertCorrectUpdating(update, 1);
   }
 
   @Test
-  public void testUpdateOneConcurrentlyModifiedException() throws SQLException,
+  public void testUpdateTwoConcurrentlyModifiedException() throws SQLException,
       OrmException {
-    PreparedStatement update = stubStatementWithUpdateCounts(UPDATE, 0);
-    JdbcAccess<Data, Data.DataKey> classUnderTest = createClassUnderTest();
+    PreparedStatement update = stubStatementWithUpdateCounts(UPDATE, 0, 0);
     try {
-      classUnderTest.update(oneRow);
+      classUnderTest.update(twoRows);
       fail("missing OrmConcurrencyException");
     } catch (OrmConcurrencyException e) {
       // expected
     }
-    assertUsedBatchingOnly(update);
+    assertCorrectUpdating(update, 1, 2);
   }
 
   @Test
   public void testUpsertNothing() throws OrmException, SQLException {
-    createClassUnderTest().upsert(noData);
+    classUnderTest.upsert(noData);
   }
 
   @Test
-  public void testUpsertOneExisting() throws OrmException, SQLException {
+  public void upsertOneExisting() throws OrmException, SQLException {
     PreparedStatement update = stubStatementWithUpdateCounts(UPDATE, 1);
     PreparedStatement insert = stubStatementWithUpdateCounts(INSERT);
 
-    createClassUnderTest().upsert(oneRow);
+    classUnderTest.upsert(oneRow);
 
-    assertUsedBatchingOnly(update);
+    assertCorrectUpdating(update, 1);
     assertNotUsed(insert);
   }
 
   @Test
-  public void testUpsertOneException() throws OrmException, SQLException {
+  public void upsertOneException() throws OrmException, SQLException {
     SQLException exception = new BatchUpdateException();
     PreparedStatement update =
         stubStatementThrowExceptionOnExecute(UPDATE, exception);
-    JdbcAccess<Data, Data.DataKey> classUnderTest = createClassUnderTest();
     try {
       classUnderTest.upsert(oneRow);
       fail("missingException");
@@ -309,19 +309,18 @@
       assertSame(e.getCause(), exception);
     }
 
-    assertUsedBatchingOnly(update);
+    assertCorrectUpdating(update, 1);
   }
 
   @Test
-  public void testUpsertOneNotExisting() throws OrmException, SQLException {
+  public void upsertOneNotExisting() throws OrmException, SQLException {
     PreparedStatement update = stubStatementWithUpdateCounts(UPDATE);
     PreparedStatement insert = stubStatementWithUpdateCounts(INSERT, 1);
 
-    createClassUnderTest().upsert(oneRow);
+    classUnderTest.upsert(oneRow);
 
-    assertUsedBatchingOnly(update);
-    assertUsedBatchingOnly(insert);
-    assertExpectedIdsUsed(insert, 1);
+    assertCorrectUpdating(update, 1);
+    assertCorrectUpdating(insert, 1);
   }
 
   @Test
@@ -330,58 +329,43 @@
     PreparedStatement update = stubStatementWithUpdateCounts(UPDATE);
     PreparedStatement insert = stubStatementWithUpdateCounts(INSERT, 1, 1);
 
-    createClassUnderTest().upsert(twoRows);
+    classUnderTest.upsert(twoRows);
 
-    assertUsedBatchingOnly(update);
-    assertUsedBatchingOnly(insert);
-    assertExpectedIdsUsed(insert, 1, 2);
+    assertCorrectUpdating(update, 1, 2);
+    assertCorrectUpdating(insert, 1, 2);
   }
 
   @Test
-  public void testUpsertTwoNotExisting() throws SQLException, OrmException {
+  public void upsertTwoNotExistingNoInfo() throws SQLException, OrmException {
     PreparedStatement update = stubStatementWithUpdateCounts(UPDATE, 0, 0);
     PreparedStatement insert = stubStatementWithUpdateCounts(INSERT, 1, 1);
 
-    createClassUnderTest().upsert(twoRows);
+    classUnderTest.upsert(twoRows);
 
-    assertUsedBatchingOnly(update);
-    assertUsedBatchingOnly(insert);
-    assertExpectedIdsUsed(insert, 1, 2);
+    assertCorrectUpdating(update, 1, 2);
+    assertCorrectUpdating(insert, 1, 2);
   }
 
   @Test
-  public void testUpsertTwoBothExisting() throws SQLException, OrmException {
+  public void upsertTwoBothExisting() throws SQLException, OrmException {
     PreparedStatement update = stubStatementWithUpdateCounts(UPDATE, 1, 1);
     PreparedStatement insert = stubStatementWithUpdateCounts(INSERT);
 
-    createClassUnderTest().upsert(twoRows);
+    classUnderTest.upsert(twoRows);
 
-    assertUsedBatchingOnly(update);
+    assertCorrectUpdating(update, 1, 2);
     assertNotUsed(insert);
   }
 
   @Test
-  public void testUpsertTwoFirstExisting() throws SQLException, OrmException {
+  public void upsertTwoFirstExistsingNoInfo() throws SQLException, OrmException {
     PreparedStatement update = stubStatementWithUpdateCounts(UPDATE, 1, 0);
     PreparedStatement insert = stubStatementWithUpdateCounts(INSERT, 1);
 
-    createClassUnderTest().upsert(twoRows);
+    classUnderTest.upsert(twoRows);
 
-    assertUsedBatchingOnly(update);
-    assertUsedBatchingOnly(insert);
-    assertExpectedIdsUsed(insert, 2);
-  }
-
-  @Test
-  public void testUpsertTwoSecondExisting() throws SQLException, OrmException {
-    PreparedStatement update = stubStatementWithUpdateCounts(UPDATE, 0, 1);
-    PreparedStatement insert = stubStatementWithUpdateCounts(INSERT, 1);
-
-    createClassUnderTest().upsert(twoRows);
-
-    assertUsedBatchingOnly(update);
-    assertUsedBatchingOnly(insert);
-    assertExpectedIdsUsed(insert, 1);
+    assertCorrectUpdating(update, 1, 2);
+    assertCorrectUpdating(insert, 2);
   }
 
   @Test
@@ -390,37 +374,46 @@
     PreparedStatement update = stubStatementWithUpdateCounts(UPDATE, null);
     PreparedStatement insert = stubStatementWithUpdateCounts(INSERT, 1, 1);
 
-    createClassUnderTest().upsert(twoRows);
+    classUnderTest.upsert(twoRows);
 
-    assertUsedBatchingOnly(update);
-    assertUsedBatchingOnly(insert);
-    assertExpectedIdsUsed(insert, 1, 2);
+    assertCorrectUpdating(update, 1, 2);
+    assertCorrectUpdating(insert, 1, 2);
   }
 
   @Test
-  public void testDeleteOneExisting() throws SQLException, OrmException {
+  public void upsertTwoSecondExisting() throws SQLException, OrmException {
+    PreparedStatement update = stubStatementWithUpdateCounts(UPDATE, 0, 1);
+    PreparedStatement insert = stubStatementWithUpdateCounts(INSERT, 1);
+
+    classUnderTest.upsert(twoRows);
+
+    assertCorrectUpdating(update, 1, 2);
+    assertCorrectUpdating(insert, 1);
+  }
+
+  @Test
+  public void deleteOneExisting() throws SQLException, OrmException {
     PreparedStatement delete = stubStatementWithUpdateCounts(DELETE, 1);
 
-    createClassUnderTest().delete(oneRow);
+    classUnderTest.delete(oneRow);
 
-    assertUsedBatchingOnly(delete);
+    assertCorrectUpdating(delete, 1);
   }
 
   @Test
-  public void testDeleteOneNotExisting() throws SQLException, OrmException {
-    PreparedStatement delete = stubStatementWithUpdateCounts(DELETE, 0);
-    JdbcAccess<Data, Data.DataKey> classUnderTest = createClassUnderTest();
+  public void deleteTwoNotExisting() throws SQLException, OrmException {
+    PreparedStatement delete = stubStatementWithUpdateCounts(DELETE, 0, 1);
     try {
-      classUnderTest.delete(oneRow);
+      classUnderTest.delete(twoRows);
       fail("missing OrmConcurrencyException");
     } catch (OrmConcurrencyException e) {
       // expected
     }
 
-    assertUsedBatchingOnly(delete);
+    assertCorrectUpdating(delete, 1, 2);
   }
 
-  private class Schema extends JdbcSchema {
+  private static class Schema extends JdbcSchema {
 
     protected Schema(Database<?> d) throws OrmException {
       super(d);
@@ -433,7 +426,7 @@
 
   }
 
-  private static class Data {
+  static class Data {
 
     private final int id;
 
@@ -468,14 +461,12 @@
     }
 
     @Override
-    public com.google.gwtorm.jdbc.TestJdbcAccess.Data.DataKey primaryKey(
-        Data entity) {
+    public Data.DataKey primaryKey(Data entity) {
       throw new UnsupportedOperationException();
     }
 
     @Override
-    public Data get(com.google.gwtorm.jdbc.TestJdbcAccess.Data.DataKey key)
-        throws OrmException {
+    public Data get(Data.DataKey key) throws OrmException {
       throw new UnsupportedOperationException();
     }
 
@@ -534,4 +525,5 @@
     }
 
   }
+
 }
diff --git a/src/test/java/com/google/gwtorm/jdbc/TestJdbcAccessBatching.java b/src/test/java/com/google/gwtorm/jdbc/TestJdbcAccessBatching.java
new file mode 100644
index 0000000..eebd3a7
--- /dev/null
+++ b/src/test/java/com/google/gwtorm/jdbc/TestJdbcAccessBatching.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2011 The Android Open Source Project
+//
+// 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
+//
+// http://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 com.google.gwtorm.jdbc;
+
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.mock;
+
+import com.google.gwtorm.schema.sql.SqlDialect;
+
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+
+@RunWith(Parameterized.class)
+public class TestJdbcAccessBatching extends AbstractTestJdbcAccess {
+
+  public TestJdbcAccessBatching(IterableProvider<Data> dataProvider)
+      throws SQLException {
+    super(dataProvider);
+  }
+
+  @Override
+  protected void assertCorrectUpdating(PreparedStatement ps,
+      int ... ids) throws SQLException {
+    assertUsedBatchingOnly(ps, ids);
+  }
+
+  @Override
+  protected SqlDialect createDialect() {
+    return mock(SqlDialect.class, CALLS_REAL_METHODS);
+  }
+
+}
diff --git a/src/test/java/com/google/gwtorm/jdbc/TestJdbcAccessNonBatching.java b/src/test/java/com/google/gwtorm/jdbc/TestJdbcAccessNonBatching.java
new file mode 100644
index 0000000..165d4c3
--- /dev/null
+++ b/src/test/java/com/google/gwtorm/jdbc/TestJdbcAccessNonBatching.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2011 The Android Open Source Project
+//
+// 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
+//
+// http://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 com.google.gwtorm.jdbc;
+
+import static java.lang.Boolean.FALSE;
+import static org.mockito.Matchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.gwtorm.schema.sql.SqlDialect;
+
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+
+@RunWith(Parameterized.class)
+public class TestJdbcAccessNonBatching extends AbstractTestJdbcAccess {
+
+  public TestJdbcAccessNonBatching(IterableProvider<Data> dataProvider)
+      throws SQLException {
+    super(dataProvider);
+  }
+
+  @Override
+  protected void assertCorrectUpdating(PreparedStatement ps,
+      int ... ids) throws SQLException {
+    assertUsedNonBatchingOnly(ps, ids);
+  }
+
+  @Override
+  protected SqlDialect createDialect() {
+    final SqlDialect dialect = mock(SqlDialect.class);
+    when(dialect.canDetermineIndividualBatchUpdateCounts()).thenReturn(FALSE);
+    when(
+        dialect.convertError(any(String.class), any(String.class),
+            any(SQLException.class))).thenCallRealMethod();
+    return dialect;
+  }
+
+}