null if the oid is zero (a SQL
+ * NULL) or does not refer to a large object.
+ * @throws SQLException if a database access error occurs.
+ * @throws TypeCastException never thrown by this implementation.
+ */
@Override
public Object getSqlValue(final int column, final ResultSet resultSet)
throws SQLException,
@@ -58,28 +70,63 @@ public Object getSqlValue(final int column, final ResultSet resultSet)
logger.debug("'oid' is zero, the data is NULL.");
return null;
}
- LargeObject obj = lobj.open(oid, LargeObjectManager.READ);
- // If lobj.open() throws an exception, it means something wrong with the OID / table.
- // So to be accurate, we don't catch this exception but let it propagate.
- // Swallowing the exception silently indeed hides the problem, which is the wrong behavior
-// try {
-// obj = lobj.open(oid, LargeObjectManager.READ);
-// } catch (SQLException ex) {
-// logger.error("Failed to open oid {} for Large Object reading.", oid);
-// logger.error("Exception: {}", ex.getMessage());
-// logger.error("Returning null instead of bailing out");
-// return null;
-// }
-
- // Read the data
+
+ return readLargeObject(connection, lobj, oid);
+ } finally {
+ connection.setAutoCommit(autoCommit);
+ }
+ }
+
+ /**
+ * Reads the large object referenced by the given oid, or returns null if the
+ * oid does not refer to a large object at all - a PostgreSQL oid column is a
+ * generic object identifier, not necessarily a large object reference (e.g.
+ * 'table'::regclass), so {@link LargeObjectManager#open(long, int)} failing with
+ * SQLState 42704 (undefined_object) is an expected outcome, not a real error.
+ *
+ * PostgreSQL aborts the entire enclosing transaction on any failed command, so the open runs
+ * under a savepoint: on failure, rolling back to it clears the abort without discarding any
+ * other work already done in that transaction. Only a failure from the open itself is treated
+ * as "not a large object" - a failure reading or closing an object that did open is a real
+ * error and is rethrown rather than masked, since by then the oid is proven to be a large
+ * object.
+ *
+ * @param connection the connection to read from, already in a non-autocommit transaction.
+ * @param lobj the large object API to read the oid through.
+ * @param oid the oid value read from the result set.
+ * @return the large object's bytes, or null if oid is not a large
+ * object.
+ * @throws SQLException on any failure other than the oid not referring to a large object.
+ */
+ private byte[] readLargeObject(final Connection connection, final LargeObjectManager lobj, final long oid)
+ throws SQLException
+ {
+ Savepoint savepoint = connection.setSavepoint();
+ final LargeObject obj;
+ try {
+ obj = lobj.open(oid, LargeObjectManager.READ);
+ } catch (SQLException ex) {
+ connection.rollback(savepoint);
+ if (PSQLState.UNDEFINED_OBJECT.getState().equals(ex.getSQLState())) {
+ logger.debug("oid {} is not a large object (SQLState={}), returning null.", oid,
+ ex.getSQLState());
+ return null;
+ }
+ throw ex;
+ }
+
+ try {
byte buf[] = new byte[obj.size()];
obj.read(buf, 0, obj.size());
- // Close the object
obj.close();
-
return buf;
- } finally {
- connection.setAutoCommit(autoCommit);
+ } catch (SQLException ex) {
+ // The transaction is aborted again here, same as an open() failure, so recover the
+ // same way - but rethrow unconditionally: the oid is proven to be a large object by
+ // now, and attempting obj.close() in a finally block would itself throw (the
+ // transaction is aborted until the rollback below runs), masking this exception.
+ connection.rollback(savepoint);
+ throw ex;
}
}
diff --git a/src/site/asciidoc/databases/postgresql.adoc b/src/site/asciidoc/databases/postgresql.adoc
index c7c81338f..82e206b21 100644
--- a/src/site/asciidoc/databases/postgresql.adoc
+++ b/src/site/asciidoc/databases/postgresql.adoc
@@ -51,6 +51,29 @@ connection.getConfig().setProperty(DatabaseConfig.PROPERTY_DATATYPE_FACTORY,
|link:/dbunit/apidocs/org/dbunit/ext/postgresql/ArrayType.html[ArrayType] |Array columns (e.g. `integer[]`, `text[]`), read/written as their PostgreSQL literal text representation.
|===
+=== PostgreSQLOidDataType
+
+link:/dbunit/apidocs/org/dbunit/ext/postgresql/PostgreSQLOidDataType.html[PostgreSQLOidDataType]
+reads and writes `oid` columns through the PostgreSQL JDBC driver's
+large-object API (`LargeObjectManager`). Writing always creates a new large
+object and binds its oid; reading opens the large object the column's oid
+refers to and returns its bytes, or `null` when the oid is zero (dbUnit's
+convention for a SQL `NULL` in this column).
+
+A PostgreSQL `oid` column is a generic object identifier, not necessarily a
+large object reference — see the PostgreSQL
+https://www.postgresql.org/docs/current/datatype-oid.html[`oid` type
+documentation]. A column holding some other catalog object's oid (for
+example a table's own oid via `'sometable'::regclass`) now reads as `null`
+instead of failing the whole read: PostgreSQL reports this specific case as
+SQLState `42704` (`undefined_object`), which is distinguished from every
+other failure — a real access/permission error still propagates as an
+exception rather than being swallowed. Opening the large object runs under a
+savepoint, since PostgreSQL aborts the entire enclosing transaction on any
+failed command; rolling back to the savepoint on failure clears the abort
+without discarding any other work already done in that transaction, so the
+connection stays usable for the rest of the read.
+
=== GenericEnumType
link:/dbunit/apidocs/org/dbunit/ext/postgresql/GenericEnumType.html[GenericEnumType]
@@ -141,3 +164,8 @@ against PostgreSQL's own canonical output format (no extra whitespace,
elements quoted only where required) — see ArrayType above. Writing a
multi-dimensional array from a literal string is not supported; reading one
back for comparison or export works fine.
+
+An `oid` column reads as `null` both for a genuine SQL `NULL` and for an oid
+that doesn't reference a large object — see PostgreSQLOidDataType above.
+dbUnit cannot distinguish the two cases from the read value alone, since
+neither has any large-object content to return.
diff --git a/src/test/java/org/dbunit/ext/postgresql/PostgreSQLOidDataTypeTest.java b/src/test/java/org/dbunit/ext/postgresql/PostgreSQLOidDataTypeTest.java
index 32f4a18c8..db24cde23 100644
--- a/src/test/java/org/dbunit/ext/postgresql/PostgreSQLOidDataTypeTest.java
+++ b/src/test/java/org/dbunit/ext/postgresql/PostgreSQLOidDataTypeTest.java
@@ -21,22 +21,82 @@
package org.dbunit.ext.postgresql;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Savepoint;
+import java.sql.Statement;
import java.sql.Types;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.postgresql.PGConnection;
+import org.postgresql.largeobject.LargeObject;
+import org.postgresql.largeobject.LargeObjectManager;
+import org.postgresql.util.PSQLState;
/**
* Unit tests for {@link PostgreSQLOidDataType}.
*
* @author DbUnit.org
*/
+@ExtendWith(MockitoExtension.class)
class PostgreSQLOidDataTypeTest
{
+ private static final int COLUMN = 1;
+
+ @Mock
+ private ResultSet resultSet;
+
+ @Mock
+ private Statement statement;
+
+ @Mock
+ private Connection connection;
+
+ @Mock
+ private PGConnection pgConnection;
+
+ @Mock
+ private LargeObjectManager largeObjectManager;
+
+ @Mock
+ private LargeObject largeObject;
+
+ @Mock
+ private Savepoint savepoint;
+
+ private final PostgreSQLOidDataType type = new PostgreSQLOidDataType();
+
+ private void mockConnectionChain() throws SQLException
+ {
+ mockConnectionChain(true);
+ }
+
+ private void mockConnectionChain(final boolean ambientAutoCommit) throws SQLException
+ {
+ when(resultSet.getStatement()).thenReturn(statement);
+ when(statement.getConnection()).thenReturn(connection);
+ when(connection.getAutoCommit()).thenReturn(ambientAutoCommit);
+ when(connection.unwrap(PGConnection.class)).thenReturn(pgConnection);
+ when(pgConnection.getLargeObjectAPI()).thenReturn(largeObjectManager);
+ }
+
@Test
void testGetSqlType_onNewInstance_returnsTypesBigint()
{
- final PostgreSQLOidDataType type = new PostgreSQLOidDataType();
assertThat(type.getSqlType())
.as("getSqlType() should return Types.BIGINT for OID type.")
.isEqualTo(Types.BIGINT);
@@ -45,7 +105,6 @@ void testGetSqlType_onNewInstance_returnsTypesBigint()
@Test
void testIsNumber_onNewInstance_returnsFalse()
{
- final PostgreSQLOidDataType type = new PostgreSQLOidDataType();
assertThat(type.isNumber())
.as("isNumber() should return false for OID type.")
.isFalse();
@@ -54,7 +113,6 @@ void testIsNumber_onNewInstance_returnsFalse()
@Test
void testGetTypeClass_onNewInstance_returnsByteArrayClass()
{
- final PostgreSQLOidDataType type = new PostgreSQLOidDataType();
assertThat(type.getTypeClass())
.as("getTypeClass() should return byte[].class for OID type.")
.isEqualTo(byte[].class);
@@ -63,9 +121,175 @@ void testGetTypeClass_onNewInstance_returnsByteArrayClass()
@Test
void testInstantiation_withNoArgs_createsNonNullInstance()
{
- final PostgreSQLOidDataType type = new PostgreSQLOidDataType();
assertThat(type)
.as("PostgreSQLOidDataType should be instantiable with no arguments.")
.isNotNull();
}
+
+ @Test
+ void testGetSqlValue_withZeroOid_returnsNullWithoutOpeningLargeObject() throws Exception
+ {
+ mockConnectionChain();
+ when(resultSet.getLong(COLUMN)).thenReturn(0L);
+
+ final Object result = type.getSqlValue(COLUMN, resultSet);
+
+ assertThat(result)
+ .as("getSqlValue() should return null for a zero oid.")
+ .isNull();
+ verify(largeObjectManager, never()).open(anyLong(), anyInt());
+ verify(connection, never()).setSavepoint();
+ verify(connection).setAutoCommit(false);
+ verify(connection).setAutoCommit(true);
+ }
+
+ /**
+ * Issue 693: a live large object should still round-trip exactly as before.
+ */
+ @Test
+ void testGetSqlValue_whenOidIsALargeObject_returnsItsBytes() throws Exception
+ {
+ mockConnectionChain();
+ final byte[] data = {1, 2, 3, 4, 5};
+ when(resultSet.getLong(COLUMN)).thenReturn(42L);
+ when(connection.setSavepoint()).thenReturn(savepoint);
+ when(largeObjectManager.open(42L, LargeObjectManager.READ)).thenReturn(largeObject);
+ when(largeObject.size()).thenReturn(data.length);
+ when(largeObject.read(any(byte[].class), eq(0), eq(data.length))).thenAnswer(invocation -> {
+ final byte[] buf = invocation.getArgument(0);
+ System.arraycopy(data, 0, buf, 0, data.length);
+ return data.length;
+ });
+
+ final Object result = type.getSqlValue(COLUMN, resultSet);
+
+ assertThat(result)
+ .as("getSqlValue() should return the large object's bytes.")
+ .isEqualTo(data);
+ verify(connection, never()).rollback(any(Savepoint.class));
+ verify(largeObject).close();
+ }
+
+ /**
+ * Issue 693: an oid that is not a large object (e.g. a row oid from
+ * 'table'::regclass) must not fail the whole read - PostgreSQL reports this as
+ * SQLState 42704 (undefined_object).
+ */
+ @Test
+ void testGetSqlValue_whenOidIsNotALargeObject_returnsNullAndRecoversTransaction() throws Exception
+ {
+ mockConnectionChain();
+ when(resultSet.getLong(COLUMN)).thenReturn(16548L);
+ when(connection.setSavepoint()).thenReturn(savepoint);
+ final SQLException notALargeObject = new SQLException("large object 16548 does not exist",
+ PSQLState.UNDEFINED_OBJECT.getState());
+ when(largeObjectManager.open(16548L, LargeObjectManager.READ)).thenThrow(notALargeObject);
+
+ final Object result = type.getSqlValue(COLUMN, resultSet);
+
+ assertThat(result)
+ .as("getSqlValue() should return null when the oid is not a large object.")
+ .isNull();
+ verify(connection).rollback(savepoint);
+ verify(connection).setAutoCommit(true);
+ }
+
+ /**
+ * Issue 693: only the "not a large object" SQLState is swallowed - any other failure (e.g. a
+ * permission error) must still propagate, matching the original bug report's requirement that
+ * hiding real errors would be the wrong fix.
+ */
+ @Test
+ void testGetSqlValue_whenOpenFailsForAnotherReason_rethrowsAndRecoversTransaction() throws Exception
+ {
+ mockConnectionChain();
+ when(resultSet.getLong(COLUMN)).thenReturn(16548L);
+ when(connection.setSavepoint()).thenReturn(savepoint);
+ final SQLException permissionDenied =
+ new SQLException("permission denied for large object 16548", "42501");
+ when(largeObjectManager.open(16548L, LargeObjectManager.READ)).thenThrow(permissionDenied);
+
+ assertThatThrownBy(() -> type.getSqlValue(COLUMN, resultSet))
+ .as("getSqlValue() should propagate a SQLException unrelated to a missing large object.")
+ .isSameAs(permissionDenied);
+ verify(connection).rollback(savepoint);
+ verify(connection).setAutoCommit(true);
+ }
+
+ /**
+ * Issue 693: a failure reading a large object that did open (e.g. its catalog row is deleted
+ * mid-transaction) is not "not a large object" and must not be swallowed as null, even
+ * though PostgreSQL aborts the transaction the same way an open() failure does.
+ */
+ @Test
+ void testGetSqlValue_whenOpenSucceedsButSizeFails_rethrowsAndRecoversTransaction() throws Exception
+ {
+ mockConnectionChain();
+ when(resultSet.getLong(COLUMN)).thenReturn(42L);
+ when(connection.setSavepoint()).thenReturn(savepoint);
+ when(largeObjectManager.open(42L, LargeObjectManager.READ)).thenReturn(largeObject);
+ final SQLException undefinedObjectAfterOpen = new SQLException("large object 42 does not exist",
+ PSQLState.UNDEFINED_OBJECT.getState());
+ when(largeObject.size()).thenThrow(undefinedObjectAfterOpen);
+
+ assertThatThrownBy(() -> type.getSqlValue(COLUMN, resultSet))
+ .as("getSqlValue() must not treat a post-open failure as \"not a large object\", "
+ + "even with the same SQLState an open() failure would have.")
+ .isSameAs(undefinedObjectAfterOpen);
+ verify(connection).rollback(savepoint);
+ verify(largeObject, never()).close();
+ }
+
+ /**
+ * Issue 693: same as the size()-failure case, but failing on close() after a successful read
+ * - the bytes were already read successfully, so the failure must still propagate rather
+ * than silently discarding a value that was in fact read.
+ */
+ @Test
+ void testGetSqlValue_whenReadSucceedsButCloseFails_rethrowsAndRecoversTransaction() throws Exception
+ {
+ mockConnectionChain();
+ final byte[] data = {1, 2, 3};
+ when(resultSet.getLong(COLUMN)).thenReturn(42L);
+ when(connection.setSavepoint()).thenReturn(savepoint);
+ when(largeObjectManager.open(42L, LargeObjectManager.READ)).thenReturn(largeObject);
+ when(largeObject.size()).thenReturn(data.length);
+ when(largeObject.read(any(byte[].class), eq(0), eq(data.length))).thenAnswer(invocation -> {
+ final byte[] buf = invocation.getArgument(0);
+ System.arraycopy(data, 0, buf, 0, data.length);
+ return data.length;
+ });
+ final SQLException closeFailed = new SQLException("connection reset", "08006");
+ doThrow(closeFailed).when(largeObject).close();
+
+ assertThatThrownBy(() -> type.getSqlValue(COLUMN, resultSet))
+ .as("getSqlValue() must propagate a close() failure instead of masking it.")
+ .isSameAs(closeFailed);
+ verify(connection).rollback(savepoint);
+ }
+
+ /**
+ * Issue 693: when the caller already has auto-commit disabled (e.g. an externally managed
+ * transaction), getSqlValue() must not enable it, and the savepoint/rollback recovery must
+ * still work identically to the auto-commit-enabled case.
+ */
+ @Test
+ void testGetSqlValue_whenAmbientAutoCommitIsAlreadyFalse_leavesItFalseAndStillRecovers() throws Exception
+ {
+ mockConnectionChain(false);
+ when(resultSet.getLong(COLUMN)).thenReturn(16548L);
+ when(connection.setSavepoint()).thenReturn(savepoint);
+ final SQLException notALargeObject = new SQLException("large object 16548 does not exist",
+ PSQLState.UNDEFINED_OBJECT.getState());
+ when(largeObjectManager.open(16548L, LargeObjectManager.READ)).thenThrow(notALargeObject);
+
+ final Object result = type.getSqlValue(COLUMN, resultSet);
+
+ assertThat(result)
+ .as("getSqlValue() should still return null for a non-large-object oid when "
+ + "auto-commit was already disabled.")
+ .isNull();
+ verify(connection, never()).setAutoCommit(true);
+ verify(connection).rollback(savepoint);
+ }
}
diff --git a/src/test/java/org/dbunit/ext/postgresql/PostgresSQLOidIT.java b/src/test/java/org/dbunit/ext/postgresql/PostgresSQLOidIT.java
index 1017a59ba..d8eadfcae 100644
--- a/src/test/java/org/dbunit/ext/postgresql/PostgresSQLOidIT.java
+++ b/src/test/java/org/dbunit/ext/postgresql/PostgresSQLOidIT.java
@@ -102,4 +102,42 @@ void testOidDataType_withNullAndBinaryValues_roundTripsThroughDatabase() throws
assertThat("\\[text UTF-8](Anything)".getBytes())
.isEqualTo(it.getValue(1, "DATA"));
}
+
+ /**
+ * Issue 693: a PostgreSQL oid column is a generic object identifier, not necessarily a large
+ * object reference (see https://www.postgresql.org/docs/current/datatype-oid.html), e.g. a
+ * real catalog object's own oid such as a table's oid via 'table'::regclass,
+ * the original bug report's own example. Reading such a row must not fail the whole read,
+ * and the connection must remain usable for subsequent rows afterward.
+ */
+ @Test
+ void testOidDataType_withOidNotReferencingALargeObject_readsAsNullInsteadOfFailing()
+ throws Exception
+ {
+ assertThat(_connection).as("didn't get a connection").isNotNull();
+ final DatabaseConfig config = _connection.getConfig();
+ config.setProperty(DatabaseConfig.PROPERTY_DATATYPE_FACTORY,
+ new PostgresqlDataTypeFactory());
+
+ // dbUnit's own write path (setSqlValue()) always creates a genuine large object, so use
+ // raw SQL - two distinct real catalog oids - to put non-large-object values into the
+ // column.
+ try (Statement stat = _connection.getConnection().createStatement())
+ {
+ stat.execute("INSERT INTO " + testTable + "(DATA) VALUES ('pg_class'::regclass::oid)");
+ stat.execute("INSERT INTO " + testTable + "(DATA) VALUES ('pg_proc'::regclass::oid)");
+ }
+
+ final IDataSet ids = _connection.createDataSet();
+ final ITable it = ids.getTable(testTable);
+
+ assertThat(it.getRowCount()).isEqualTo(2);
+ assertThat(it.getValue(0, "DATA"))
+ .as("a non-large-object oid should read as null instead of throwing.")
+ .isNull();
+ assertThat(it.getValue(1, "DATA"))
+ .as("reading a second, distinct non-large-object oid afterward should still "
+ + "work, proving the connection recovered from the first failed read.")
+ .isNull();
+ }
}