diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/RawJsonValue.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/RawJsonValue.java new file mode 100644 index 000000000000..576afaf64e87 --- /dev/null +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/RawJsonValue.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2004-2022, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.common; + +/** Wraps a raw JSON string so that serializers can emit it as inline JSON rather than a string. */ +public record RawJsonValue(String value) {} diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/adapter/JacksonRowDataSerializer.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/adapter/JacksonRowDataSerializer.java index 4508c6d667d6..8ca843091df4 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/adapter/JacksonRowDataSerializer.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/adapter/JacksonRowDataSerializer.java @@ -36,6 +36,7 @@ import com.fasterxml.jackson.databind.SerializerProvider; import java.io.IOException; import java.util.List; +import org.hisp.dhis.common.RawJsonValue; /** * TODO switch to jgen.writeObject( field ) @@ -54,7 +55,11 @@ public void serialize(List> values, JsonGenerator jgen, SerializerP jgen.writeStartArray(); for (Object field : row) { - jgen.writeString(field != null ? String.valueOf(maybeFormat(field)) : EMPTY); + if (field instanceof RawJsonValue json) { + jgen.writeRawValue(json.value()); + } else { + jgen.writeString(field != null ? String.valueOf(maybeFormat(field)) : EMPTY); + } } jgen.writeEndArray(); diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/adapter/OutputFormatter.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/adapter/OutputFormatter.java index 360a896deea0..471e76ca6d4c 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/adapter/OutputFormatter.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/adapter/OutputFormatter.java @@ -30,6 +30,7 @@ package org.hisp.dhis.common.adapter; import java.math.BigDecimal; +import org.hisp.dhis.common.RawJsonValue; /** * Simple component responsible do enforce specific output/format to types where this is required. @@ -54,6 +55,10 @@ private OutputFormatter() {} * or is null it will return the given parameter object itself */ public static Object maybeFormat(final Object object) { + if (object instanceof RawJsonValue json) { + return json.value(); + } + if (object instanceof Double) { return formatDouble((Double) object); } diff --git a/dhis-2/dhis-api/src/test/java/org/hisp/dhis/common/adapter/JacksonRowDataSerializerTest.java b/dhis-2/dhis-api/src/test/java/org/hisp/dhis/common/adapter/JacksonRowDataSerializerTest.java new file mode 100644 index 000000000000..168be0a7769e --- /dev/null +++ b/dhis-2/dhis-api/src/test/java/org/hisp/dhis/common/adapter/JacksonRowDataSerializerTest.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2004-2022, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.common.adapter; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.StringWriter; +import java.util.List; +import org.hisp.dhis.common.RawJsonValue; +import org.junit.jupiter.api.Test; + +class JacksonRowDataSerializerTest { + + private final JacksonRowDataSerializer serializer = new JacksonRowDataSerializer(); + + @Test + void regularStringValuesAreWrappedInJsonStrings() throws Exception { + List> rows = List.of(List.of("hello", "world")); + + assertEquals("[[\"hello\",\"world\"]]", serialize(rows)); + } + + @Test + void rawJsonValueIsEmittedAsInlineJson() throws Exception { + List> rows = List.of(List.of("Alice", new RawJsonValue("{\"key\":\"value\"}"))); + + assertEquals("[[\"Alice\",{\"key\":\"value\"}]]", serialize(rows)); + } + + @Test + void rawJsonArrayIsEmittedAsInlineJsonArray() throws Exception { + List> rows = List.of(List.of(new RawJsonValue("[1,2,3]"))); + + assertEquals("[[[1,2,3]]]", serialize(rows)); + } + + @Test + void nullRawJsonValueIsEmittedAsEmptyString() throws Exception { + List row = new java.util.ArrayList<>(); + row.add(null); + List> rows = List.of(row); + + assertEquals("[[\"\"]]", serialize(rows)); + } + + private String serialize(List> rows) throws Exception { + StringWriter writer = new StringWriter(); + JsonGenerator generator = new JsonFactory().createGenerator(writer); + serializer.serialize(rows, generator, new ObjectMapper().getSerializerProvider()); + generator.flush(); + return writer.toString(); + } +} diff --git a/dhis-2/dhis-api/src/test/java/org/hisp/dhis/common/adapter/OutputFormatterTest.java b/dhis-2/dhis-api/src/test/java/org/hisp/dhis/common/adapter/OutputFormatterTest.java index 88db96621e56..fa56c35377a0 100644 --- a/dhis-2/dhis-api/src/test/java/org/hisp/dhis/common/adapter/OutputFormatterTest.java +++ b/dhis-2/dhis-api/src/test/java/org/hisp/dhis/common/adapter/OutputFormatterTest.java @@ -34,6 +34,7 @@ import static org.hisp.dhis.common.adapter.OutputFormatter.maybeFormat; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import org.hisp.dhis.common.RawJsonValue; import org.junit.jupiter.api.Test; /** @@ -107,4 +108,11 @@ void testMaybeFormatWhenObjectIsNotSupportedLong() { assertInstanceOf(Long.class, result); assertThat(result, is(notSupportedObject)); } + + @Test + void maybeFormatReturnsRawJsonStringForRawJsonValue() { + final Object result = maybeFormat(new RawJsonValue("{\"key\":\"value\"}")); + assertInstanceOf(String.class, result); + assertThat(result, is("{\"key\":\"value\"}")); + } } diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/sqlview/hibernate/HibernateSqlViewStore.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/sqlview/hibernate/HibernateSqlViewStore.java index 709e9f09e9ba..8022ac72cde2 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/sqlview/hibernate/HibernateSqlViewStore.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/sqlview/hibernate/HibernateSqlViewStore.java @@ -42,6 +42,7 @@ import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.math.NumberUtils; import org.hisp.dhis.common.Grid; +import org.hisp.dhis.common.RawJsonValue; import org.hisp.dhis.common.TransactionMode; import org.hisp.dhis.common.hibernate.HibernateIdentifiableObjectStore; import org.hisp.dhis.query.QueryParserException; @@ -56,6 +57,7 @@ import org.springframework.jdbc.BadSqlGrammarException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.support.rowset.SqlRowSet; +import org.springframework.jdbc.support.rowset.SqlRowSetMetaData; import org.springframework.stereotype.Repository; /** @@ -141,7 +143,28 @@ public void populateSqlViewGrid( log.debug("Get view SQL: " + sql + ", max limit: " + maxLimit); grid.addHeaders(rs); - grid.addRows(rs, maxLimit); + populateRows(grid, rs, maxLimit); + } + + private static void populateRows(Grid grid, SqlRowSet rs, int maxLimit) { + SqlRowSetMetaData rsmd = rs.getMetaData(); + int cols = rsmd.getColumnCount(); + boolean[] isJson = new boolean[cols + 1]; + for (int i = 1; i <= cols; i++) { + String typeName = rsmd.getColumnTypeName(i); + isJson[i] = "json".equalsIgnoreCase(typeName) || "jsonb".equalsIgnoreCase(typeName); + } + while (rs.next()) { + grid.addRow(); + for (int i = 1; i <= cols; i++) { + Object value = rs.getObject(i); + grid.addValue(isJson[i] && value != null ? new RawJsonValue(value.toString()) : value); + if (maxLimit > 0 && i > maxLimit) { + throw new IllegalStateException( + "Number of rows produced by query is larger than the max limit: " + maxLimit); + } + } + } } @Override diff --git a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/sqlview/hibernate/HibernateSqlViewStoreTest.java b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/sqlview/hibernate/HibernateSqlViewStoreTest.java index f74a3ee61498..ea055f2b4036 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/sqlview/hibernate/HibernateSqlViewStoreTest.java +++ b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/sqlview/hibernate/HibernateSqlViewStoreTest.java @@ -31,8 +31,13 @@ import static org.hisp.dhis.common.TransactionMode.READ; import static org.hisp.dhis.common.TransactionMode.WRITE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -40,9 +45,11 @@ import jakarta.persistence.EntityManager; import java.util.Map; import org.hisp.dhis.common.Grid; +import org.hisp.dhis.common.RawJsonValue; import org.hisp.dhis.security.acl.AclService; import org.hisp.dhis.setting.SystemSettings; import org.hisp.dhis.setting.SystemSettingsProvider; +import org.hisp.dhis.system.grid.ListGrid; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -51,6 +58,8 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.rowset.SqlRowSet; +import org.springframework.jdbc.support.rowset.SqlRowSetMetaData; @ExtendWith(MockitoExtension.class) class HibernateSqlViewStoreTest { @@ -75,6 +84,15 @@ void setUp() { aclService, readOnlyJdbcTemplate, settingsProvider); + + SqlRowSetMetaData emptyMeta = mock(SqlRowSetMetaData.class); + lenient().when(emptyMeta.getColumnCount()).thenReturn(0); + SqlRowSet emptyRowSet = mock(SqlRowSet.class); + lenient().when(emptyRowSet.getMetaData()).thenReturn(emptyMeta); + lenient().when(jdbcTemplate.queryForRowSet(anyString(), isNull())).thenReturn(emptyRowSet); + lenient() + .when(readOnlyJdbcTemplate.queryForRowSet(anyString(), isNull())) + .thenReturn(emptyRowSet); } @Test @@ -98,4 +116,51 @@ void sqlViewReadOnlyJdbcTemplateReadTest() { verify(readOnlyJdbcTemplate, times(1)).queryForRowSet(anyString(), isNull()); verify(jdbcTemplate, times(0)).queryForRowSet(anyString(), isNull()); } + + @Test + @DisplayName("jsonb column values are wrapped as RawJsonValue in the grid") + void populateSqlViewGridWrapsJsonbValuesAsRawJson() { + SqlRowSetMetaData metadata = mock(SqlRowSetMetaData.class); + when(metadata.getColumnCount()).thenReturn(2); + when(metadata.getColumnLabel(1)).thenReturn("name"); + when(metadata.getColumnTypeName(1)).thenReturn("text"); + when(metadata.getColumnLabel(2)).thenReturn("style"); + when(metadata.getColumnTypeName(2)).thenReturn("jsonb"); + + SqlRowSet rowSet = mock(SqlRowSet.class); + when(rowSet.getMetaData()).thenReturn(metadata); + when(rowSet.next()).thenReturn(true, false); + when(rowSet.getObject(1)).thenReturn("Malaria RDT"); + when(rowSet.getObject(2)).thenReturn("{\"icon\":\"star\"}"); + + when(jdbcTemplate.queryForRowSet(anyString(), isNull())).thenReturn(rowSet); + + Grid result = new ListGrid(); + store.populateSqlViewGrid(result, "sql", null, WRITE); + + assertEquals("Malaria RDT", result.getRow(0).get(0)); + assertInstanceOf(RawJsonValue.class, result.getRow(0).get(1)); + assertEquals("{\"icon\":\"star\"}", ((RawJsonValue) result.getRow(0).get(1)).value()); + } + + @Test + @DisplayName("null jsonb column values remain null in the grid") + void populateSqlViewGridKeepsNullJsonbAsNull() { + SqlRowSetMetaData metadata = mock(SqlRowSetMetaData.class); + when(metadata.getColumnCount()).thenReturn(1); + when(metadata.getColumnLabel(1)).thenReturn("style"); + when(metadata.getColumnTypeName(1)).thenReturn("jsonb"); + + SqlRowSet rowSet = mock(SqlRowSet.class); + when(rowSet.getMetaData()).thenReturn(metadata); + when(rowSet.next()).thenReturn(true, false); + // getObject(1) returns null by default - no stub needed + + when(jdbcTemplate.queryForRowSet(anyString(), isNull())).thenReturn(rowSet); + + Grid result = new ListGrid(); + store.populateSqlViewGrid(result, "sql", null, WRITE); + + assertNull(result.getRow(0).get(0)); + } }