PostgreSQL requires a bound parameter's declared type to match its + * target {@code json}/{@code jsonb} column exactly - it has no implicit cast + * between the two - so one instance handles only the single sql type name it + * was constructed with. {@link PostgresqlDataTypeFactory} constructs the + * matching instance for each column automatically; there is normally no need + * to instantiate this class directly. + * + *
This class only shuttles the column's raw text between the driver and
+ * the dataset; it does not compare JSON values semantically. Use
+ * {@link org.dbunit.assertion.comparer.value.IsActualEqualToExpectedJsonValueComparer}
+ * to compare json/jsonb column values by their parsed document structure
+ * instead of raw text.
+ *
+ * @author Jeff Jensen
+ * @since 3.4.1
+ */
+public class JsonType extends AbstractDataType
+{
+ private static final Logger logger =
+ LoggerFactory.getLogger(JsonType.class);
+
+ private final String sqlTypeName;
+
+ /**
+ * Creates a data type adapter for the given PostgreSQL JSON sql type
+ * name.
+ *
+ * @param sqlTypeName
+ * The sql type name, {@code "json"} or {@code "jsonb"},
+ * needed to invoke the {@code setType()} method on the
+ * PGobject class.
+ */
+ public JsonType(final String sqlTypeName)
+ {
+ super(Objects.requireNonNull(sqlTypeName,
+ "The parameter 'sqlTypeName' must not be null"), Types.OTHER,
+ String.class, false);
+
+ this.sqlTypeName = sqlTypeName;
+ }
+
+ @Override
+ public Object getSqlValue(final int column, final ResultSet resultSet)
+ throws SQLException, TypeCastException
+ {
+ return resultSet.getString(column);
+ }
+
+ @Override
+ public void setSqlValue(final Object value, final int column,
+ final PreparedStatement statement)
+ throws SQLException, TypeCastException
+ {
+ if (value == null)
+ {
+ statement.setNull(column, Types.OTHER);
+ return;
+ }
+
+ statement.setObject(column,
+ getJson(value, statement.getConnection()));
+ }
+
+ @Override
+ public Object typeCast(final Object value) throws TypeCastException
+ {
+ return value == null ? null : value.toString();
+ }
+
+ /**
+ * Returns the sql type name this instance handles.
+ *
+ * @return {@code "json"} or {@code "jsonb"}, whichever sql type name this
+ * instance was constructed with.
+ */
+ public String getSqlTypeName()
+ {
+ return sqlTypeName;
+ }
+
+ private Object getJson(final Object value, final Connection connection)
+ throws TypeCastException
+ {
+ logger.debug("getJson(value={}, connection={}) - start", value,
+ connection);
+
+ Object tempJson = null;
+
+ try
+ {
+ final Class> aPGObjectClass = super.loadClass(
+ "org.postgresql.util.PGobject", connection);
+ final Constructor> ct = aPGObjectClass.getConstructor();
+ tempJson = ct.newInstance();
+
+ final Method setTypeMethod =
+ aPGObjectClass.getMethod("setType", String.class);
+ setTypeMethod.invoke(tempJson, sqlTypeName);
+
+ final Method setValueMethod =
+ aPGObjectClass.getMethod("setValue", String.class);
+ setValueMethod.invoke(tempJson, value.toString());
+
+ } catch (final ReflectiveOperationException e)
+ {
+ throw new TypeCastException(value, this, e);
+ }
+
+ return tempJson;
+ }
+}
diff --git a/src/main/java/org/dbunit/ext/postgresql/PostgresqlDataTypeFactory.java b/src/main/java/org/dbunit/ext/postgresql/PostgresqlDataTypeFactory.java
index 07f2d3b44..4831eb9fb 100644
--- a/src/main/java/org/dbunit/ext/postgresql/PostgresqlDataTypeFactory.java
+++ b/src/main/java/org/dbunit/ext/postgresql/PostgresqlDataTypeFactory.java
@@ -98,6 +98,9 @@ public DataType createDataType(final int sqlType, final String sqlTypeName)
} else if ("citext".equals(sqlTypeName))
{
return new CitextType();
+ } else if ("json".equals(sqlTypeName) || "jsonb".equals(sqlTypeName))
+ {
+ return new JsonType(sqlTypeName);
} else
{
// Finally check whether the user defined a custom datatype
diff --git a/src/site/asciidoc/databases/postgresql.adoc b/src/site/asciidoc/databases/postgresql.adoc
index 5e2bc9ecf..7b77a3a0a 100644
--- a/src/site/asciidoc/databases/postgresql.adoc
+++ b/src/site/asciidoc/databases/postgresql.adoc
@@ -9,9 +9,10 @@ equivalent.
== IDataTypeFactory
link:/dbunit/apidocs/org/dbunit/ext/postgresql/PostgresqlDataTypeFactory.html[PostgresqlDataTypeFactory]
-recognizes `uuid`, `interval`, `inet`, `geometry`, `citext`, `oid`, and
-(via an overridable hook) custom enum types, mapping each to the dedicated
-classes below; everything else delegates to `DefaultDataTypeFactory`.
+recognizes `uuid`, `interval`, `inet`, `geometry`, `citext`, `json`, `jsonb`,
+`oid` when reported as JDBC `BIGINT`, and (via an overridable hook) custom
+enum types, mapping each to the dedicated classes below; everything else
+delegates to `DefaultDataTypeFactory`.
Register it via `DatabaseConfig.PROPERTY_DATATYPE_FACTORY` — see
link:../properties.html#typefactory[Properties] and
link:../connections.html[Connections & Configuration].
@@ -45,6 +46,7 @@ connection.getConfig().setProperty(DatabaseConfig.PROPERTY_DATATYPE_FACTORY,
|link:/dbunit/apidocs/org/dbunit/ext/postgresql/InetType.html[InetType] |`inet` (IP address/network) values.
|link:/dbunit/apidocs/org/dbunit/ext/postgresql/UuidType.html[UuidType] |`uuid` values.
|link:/dbunit/apidocs/org/dbunit/ext/postgresql/GeometryType.html[GeometryType] |`geometry` (PostGIS) values, read/written as their string representation.
+|link:/dbunit/apidocs/org/dbunit/ext/postgresql/JsonType.html[JsonType] |`json` and `jsonb` values, read/written as their raw text representation.
|===
=== GenericEnumType
@@ -69,7 +71,33 @@ See the FAQ for more:
link:../faq.html#postgresqlEnumTypes[Are Postgresql enum types supported by
dbunit?]
+=== JsonType
+
+link:/dbunit/apidocs/org/dbunit/ext/postgresql/JsonType.html[JsonType]
+adapts between PostgreSQL's native `json`/`jsonb` types and Strings, using
+the same reflection-based `PGobject.setType()` approach as the other types
+above. PostgreSQL has no implicit cast between `json` and `jsonb`, so a
+bound parameter's PGobject type must match its target column exactly;
+`PostgresqlDataTypeFactory` handles this automatically by constructing a
+`JsonType` for the specific sql type name (`json` or `jsonb`) each column
+reports.
+
+This class only shuttles a column's raw text between the driver and the
+dataset; it does not compare JSON values semantically. PostgreSQL
+reformats `jsonb` text on storage (for example, inserting whitespace or
+reordering object keys), so a literal string comparison against an
+expected dataset value can spuriously fail even when the JSON is
+semantically identical. Use
+link:/dbunit/apidocs/org/dbunit/assertion/comparer/value/IsActualEqualToExpectedJsonValueComparer.html[IsActualEqualToExpectedJsonValueComparer]
+(see link:../datacomparisons/valuecomparer.html[Value Comparers]) to compare
+`json`/`jsonb` column values by their parsed document structure instead.
+
== Known Quirks
Enum type support requires the `isEnumType()` override above — see
GenericEnumType.
+
+`json`/`jsonb` column comparisons in assertions are literal string
+comparisons unless you use
+link:/dbunit/apidocs/org/dbunit/assertion/comparer/value/IsActualEqualToExpectedJsonValueComparer.html[IsActualEqualToExpectedJsonValueComparer]
+— see JsonType above.
diff --git a/src/test/java/org/dbunit/ext/postgresql/JsonTypeTest.java b/src/test/java/org/dbunit/ext/postgresql/JsonTypeTest.java
new file mode 100644
index 000000000..ad011a2e3
--- /dev/null
+++ b/src/test/java/org/dbunit/ext/postgresql/JsonTypeTest.java
@@ -0,0 +1,123 @@
+/*
+ *
+ * The DbUnit Database Testing Framework
+ * Copyright (C)2002-2004, DbUnit.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+ *
+ */
+package org.dbunit.ext.postgresql;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.verify;
+
+import java.sql.PreparedStatement;
+import java.sql.Types;
+
+import org.dbunit.dataset.datatype.AbstractDataType;
+import org.dbunit.dataset.datatype.TypeCastException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+/**
+ * Unit tests for {@link JsonType}.
+ *
+ * @author Jeff Jensen
+ * @since 3.4.1
+ */
+@ExtendWith(MockitoExtension.class)
+class JsonTypeTest extends AbstractPostgresqlStringDataTypeTest
+{
+ @Mock
+ private PreparedStatement statement;
+
+ @Override
+ protected AbstractDataType createType()
+ {
+ return new JsonType("json");
+ }
+
+ @Test
+ void testConstructor_withValidSqlTypeName_storesSqlTypeName()
+ {
+ final JsonType type = new JsonType("jsonb");
+ assertThat(type.getSqlTypeName())
+ .as("getSqlTypeName() should return the name passed to the constructor.")
+ .isEqualTo("jsonb");
+ }
+
+ @Test
+ void testConstructor_withNullSqlTypeName_throwsNullPointerException()
+ {
+ assertThatThrownBy(() -> new JsonType(null))
+ .as("Constructor should throw NullPointerException when sqlTypeName is null.")
+ .isInstanceOf(NullPointerException.class);
+ }
+
+ @Test
+ void testTypeCast_withJsonObjectString_returnsStringRepresentation()
+ throws TypeCastException
+ {
+ final JsonType type = new JsonType("json");
+ final String value = "{\"a\":1}";
+ final Object result = type.typeCast(value);
+ assertThat(result)
+ .as("typeCast() should return the string representation of the value.")
+ .isEqualTo("{\"a\":1}");
+ }
+
+ @Test
+ void testTypeCast_withJsonArrayString_returnsStringRepresentation()
+ throws TypeCastException
+ {
+ final JsonType type = new JsonType("jsonb");
+ final String value = "[1,2,3]";
+ final Object result = type.typeCast(value);
+ assertThat(result)
+ .as("typeCast() should return the string representation of the value.")
+ .isEqualTo("[1,2,3]");
+ }
+
+ @Test
+ void testTypeCast_withNullValue_returnsNull() throws TypeCastException
+ {
+ final JsonType type = new JsonType("json");
+ final Object result = type.typeCast(null);
+ assertThat(result)
+ .as("typeCast() should return null when given null.")
+ .isNull();
+ }
+
+ /**
+ * Issue 574: the reporter's original patch threw NPE from setSqlValue()
+ * when the column value was null.
+ */
+ @Test
+ void testSetSqlValue_withNullValue_doesNotThrowAndSetsSqlNull()
+ throws Exception
+ {
+ final JsonType type = new JsonType("jsonb");
+
+ assertThatCode(() -> type.setSqlValue(null, 1, statement))
+ .as("setSqlValue() should not throw NullPointerException when value is null.")
+ .doesNotThrowAnyException();
+
+ verify(statement).setNull(1, Types.OTHER);
+ }
+}
diff --git a/src/test/java/org/dbunit/ext/postgresql/PostgresqlDataTypeFactoryTest.java b/src/test/java/org/dbunit/ext/postgresql/PostgresqlDataTypeFactoryTest.java
index e855dbfbc..267ab5875 100644
--- a/src/test/java/org/dbunit/ext/postgresql/PostgresqlDataTypeFactoryTest.java
+++ b/src/test/java/org/dbunit/ext/postgresql/PostgresqlDataTypeFactoryTest.java
@@ -96,6 +96,40 @@ void testCreateCitextType_withCitextTypeName_returnsCitextTypeInstance() throws
assertThat(result).isInstanceOf(CitextType.class);
}
+ @Test
+ void testCreateJsonType_withJsonTypeName_returnsJsonTypeInstance() throws Exception
+ {
+ final PostgresqlDataTypeFactory instance =
+ new PostgresqlDataTypeFactory();
+
+ final int sqlType = Types.OTHER;
+ final String sqlTypeName = "json";
+
+ final DataType result = instance.createDataType(sqlType, sqlTypeName);
+ assertThat(result).as("createDataType() should return a JsonType for json.")
+ .isInstanceOf(JsonType.class);
+ assertThat(((JsonType) result).getSqlTypeName())
+ .as("The JsonType should keep the json sql type name.")
+ .isEqualTo("json");
+ }
+
+ @Test
+ void testCreateJsonType_withJsonbTypeName_returnsJsonTypeInstance() throws Exception
+ {
+ final PostgresqlDataTypeFactory instance =
+ new PostgresqlDataTypeFactory();
+
+ final int sqlType = Types.OTHER;
+ final String sqlTypeName = "jsonb";
+
+ final DataType result = instance.createDataType(sqlType, sqlTypeName);
+ assertThat(result).as("createDataType() should return a JsonType for jsonb.")
+ .isInstanceOf(JsonType.class);
+ assertThat(((JsonType) result).getSqlTypeName())
+ .as("The JsonType should keep the jsonb sql type name.")
+ .isEqualTo("jsonb");
+ }
+
@Test
void testCreateEnumType_withCustomEnumTypeName_returnsGenericEnumTypeInstance() throws Exception
{
diff --git a/src/test/java/org/dbunit/ext/postgresql/PostgresqlJsonIT.java b/src/test/java/org/dbunit/ext/postgresql/PostgresqlJsonIT.java
new file mode 100644
index 000000000..b832f0614
--- /dev/null
+++ b/src/test/java/org/dbunit/ext/postgresql/PostgresqlJsonIT.java
@@ -0,0 +1,149 @@
+package org.dbunit.ext.postgresql;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.StringReader;
+import java.sql.Statement;
+import java.sql.Types;
+import java.util.Objects;
+
+import org.dbunit.DatabaseEnvironment;
+import org.dbunit.database.DatabaseConfig;
+import org.dbunit.database.IDatabaseConnection;
+import org.dbunit.dataset.Column;
+import org.dbunit.dataset.IDataSet;
+import org.dbunit.dataset.ITable;
+import org.dbunit.dataset.ITableMetaData;
+import org.dbunit.dataset.ReplacementDataSet;
+import org.dbunit.dataset.xml.FlatXmlDataSetBuilder;
+import org.dbunit.operation.DatabaseOperation;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
+import org.xml.sax.InputSource;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * Integration test proving {@link JsonType} round-trips PostgreSQL
+ * {@code json}/{@code jsonb} column values, including a null value, through
+ * a real database (issue 574).
+ *
+ * @author Jeff Jensen
+ * @since 3.4.1
+ */
+@EnabledIfSystemProperty(named = "dbunit.profile", matches = "postgresql")
+class PostgresqlJsonIT
+{
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ private IDatabaseConnection _connection;
+ private final String testTable = "json_test";
+ // @formatter:off
+ private static final String xmlData = "" +
+ "