Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for ARRAY<JSON> type in Spring Data Spanner #1157

Merged
merged 16 commits into from
Aug 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/src/main/asciidoc/spanner.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ Natively supported types:

==== JSON fields

Spanner supports `JSON` type for columns. `JSON` columns are mapped to custom POJOs annotated with `@Column(spannerType = TypeCode.JSON)`. Read, write and query with custom SQL query are supported for JSON annotated fields.
Spanner supports `JSON` and `ARRAY<JSON>` type for columns. Such property needs to be annotated with `@Column(spannerType = TypeCode.JSON)`. `JSON` columns are mapped to custom POJOs and `ARRAY<JSON>` columns are mapped to List of custom POJOs. Read, write and query with custom SQL query are supported for JSON annotated fields.

NOTE: The default Gson instance used to convert to and from JSON representation can be customized by providing a bean of type `Gson`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@
import com.google.cloud.spring.data.spanner.core.mapping.SpannerPersistentEntity;
import com.google.cloud.spring.data.spanner.core.mapping.SpannerPersistentProperty;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.BiConsumer;
Expand Down Expand Up @@ -308,7 +310,7 @@ private boolean isValidSpannerKeyType(Class type) {

// @formatter:off

private static boolean attemptSetIterableValue(
private boolean attemptSetIterableValue(
Iterable<Object> value,
ValueBinder<WriteBuilder> valueBinder,
SpannerPersistentProperty spannerPersistentProperty,
Expand All @@ -321,8 +323,13 @@ private static boolean attemptSetIterableValue(

boolean valueSet = false;

// use the annotated column type if possible.
if (spannerPersistentProperty.getAnnotatedColumnItemType() != null) {

if (spannerPersistentProperty.getAnnotatedColumnItemType() == Type.Code.JSON) {
// if column annotated with JSON, convert directly
valueBinder.toJsonArray(this.convertIterableJsonToValue(value));
valueSet = true;
} else if (spannerPersistentProperty.getAnnotatedColumnItemType() != null) {
// use the annotated column type if possible.
valueSet =
attemptSetIterablePropertyWithTypeConversion(
value,
Expand Down Expand Up @@ -361,14 +368,23 @@ private static <T> boolean attemptSetSingleItemValue(
return true;
}

private Value covertJsonToValue(Object value) {
private Value convertJsonToValue(Object value) {
if (value == null) {
return Value.json(null);
}
String jsonString = this.spannerMappingContext.getGson().toJson(value);
return Value.json(jsonString);
}

private Iterable<String> convertIterableJsonToValue(Iterable<Object> value) {
List<String> result = new ArrayList<>();
if (value == null) {
return null;
}
value.forEach(item -> result.add(this.spannerMappingContext.getGson().toJson(item)));
return result;
}

/**
* For each property this method "set"s the column name and finds the corresponding "to" method on
* the {@link ValueBinder} interface.
Expand Down Expand Up @@ -429,7 +445,7 @@ private void writeProperty(
this.writeConverter);
} else if (property.getAnnotatedColumnItemType() == Type.Code.JSON) {
// annotated json column, bind directly
valueBinder.to(this.covertJsonToValue(propertyValue));
valueBinder.to(this.convertJsonToValue(propertyValue));
valueSet = true;
} else if (property.getAnnotatedColumnItemType() != null) {
// use the user's annotated column type if possible
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import com.google.cloud.spring.data.spanner.core.mapping.SpannerDataException;
import com.google.gson.Gson;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -99,6 +100,7 @@ public class StructAccessor {
.build();

// @formatter:on
private static final String EXCEPTION_COL_NOT_ARRAY = "Column is not an ARRAY type: ";

private Struct struct;

Expand Down Expand Up @@ -145,14 +147,43 @@ public Object getSingleValue(int colIndex) {

List getListValue(String colName) {
if (this.struct.getColumnType(colName).getCode() != Code.ARRAY) {
throw new SpannerDataException("Column is not an ARRAY type: " + colName);
throw new SpannerDataException(EXCEPTION_COL_NOT_ARRAY + colName);
}
Type.Code innerTypeCode = this.struct.getColumnType(colName).getArrayElementType().getCode();
Class clazz = SpannerTypeMapper.getSimpleJavaClassFor(innerTypeCode);
BiFunction<Struct, String, List> readMethod = readIterableMapping.get(clazz);
return readMethod.apply(this.struct, colName);
}

<T> List<T> getListJsonValue(String colName, Class<T> colType) {
if (this.struct.getColumnType(colName).getCode() != Code.ARRAY) {
throw new SpannerDataException(EXCEPTION_COL_NOT_ARRAY + colName);
}
List<String> jsonStringList = this.struct.getJsonList(colName);
List<T> result = new ArrayList<>();
jsonStringList.forEach(item ->
result.add(gson.fromJson(item, colType)));
return result;
}

public <T> Object getJsonValue(int colIndex, Class<T> colType) {
if (this.struct.getColumnType(colIndex).getCode() != Code.ARRAY) {
return getSingleJsonValue(colIndex, colType);
}
return getListJsonValue(colIndex, colType);
}

private <T> List<T> getListJsonValue(int colIndex, Class<T> colType) {
if (this.struct.getColumnType(colIndex).getCode() != Code.ARRAY) {
throw new SpannerDataException(EXCEPTION_COL_NOT_ARRAY + colIndex);
}
List<String> jsonStringList = this.struct.getJsonList(colIndex);
List<T> result = new ArrayList<>();
jsonStringList.forEach(item ->
result.add(gson.fromJson(item, colType)));
return result;
}

boolean hasColumn(String columnName) {
return this.columnNamesIndex.contains(columnName);
}
Expand Down Expand Up @@ -184,7 +215,8 @@ <T> T getSingleJsonValue(String colName, Class<T> colType) {
return gson.fromJson(jsonString, colType);
}

public <T> T getSingleJsonValue(int colIndex, Class<T> colType) {
//TODO: change this to private in next major release

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it because users will be using getJsonValue directly for single json value and array of json, instead of using separate methods getSingleJsonValue and getListJsonValue?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, getJsonValue is public and getSingleJsonValue and getListJsonValue does not need to be exposed. In fact, this method is mostly for internal usage. Users don’t need to interact with neither of these methods, as shown in the samples, user only need to add annotation of the dedicated field and use save()/find() as usual. But this method needs to be public because it is used outside of its package.

public <T> T getSingleJsonValue(int colIndex, Class<T> colType) {
if (this.struct.getColumnType(colIndex).getCode() != Code.JSON) {
throw new SpannerDataException("Column of index " + colIndex + " not an JSON type.");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ private <T> T readSingleWithConversion(SpannerPersistentProperty spannerPersiste
private <T> Iterable<T> readIterableWithConversion(
SpannerPersistentProperty spannerPersistentProperty) {
String colName = spannerPersistentProperty.getColumnName();
Type.Code spannerColumnType = spannerPersistentProperty.getAnnotatedColumnItemType();
if (spannerColumnType == Type.Code.JSON) {
return (List<T>) this.structAccessor.getListJsonValue(colName,
spannerPersistentProperty.getColumnInnerType());
}
List<?> listValue = this.structAccessor.getListValue(colName);
return listValue.stream()
.map(item -> convertOrRead((Class<T>) spannerPersistentProperty.getColumnInnerType(), item))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,9 @@ public void addPersistentProperty(SpannerPersistentProperty property) {
});
}

if (property.getAnnotatedColumnItemType() == Type.Code.JSON) {
if (property.getAnnotatedColumnItemType() == Type.Code.JSON && property.isCollectionLike()) {
this.jsonProperties.add(property.getColumnInnerType());
} else if (property.getAnnotatedColumnItemType() == Type.Code.JSON) {
this.jsonProperties.add(property.getType());
}
}
Expand Down Expand Up @@ -419,6 +421,7 @@ public Set<String> columns() {
}

// Lookup whether a particular class is a JSON entity property
// or is an inner type of a ARRAY<JSON> property
public boolean isJsonProperty(Class<?> type) {
return this.jsonProperties.contains(type);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,11 +240,11 @@ private List executeReadSql(Pageable pageable, Sort sort, QueryTagValue queryTag
return this.spannerTemplate.query(
struct -> new StructAccessor(struct).getSingleValue(0), statement, spannerQueryOptions);
}
// check if returnedType is a field annotated as json
boolean isJsonField = isJsonFieldType(returnedType);
if (isJsonField) {
// check if returnedType is a field annotated as json or is inner-type of a field annotated as json
if (isJsonFieldType(returnedType)) {
return this.spannerTemplate.query(
struct -> new StructAccessor(struct, this.spannerMappingContext.getGson()).getSingleJsonValue(0, returnedType),
struct -> new StructAccessor(struct,
this.spannerMappingContext.getGson()).getJsonValue(0, returnedType),
statement,
spannerQueryOptions);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ void ddlForListOfListOfDoubles() {
void createDdlForJson() {
assertColumnDdl(
JsonColumn.class, null, "jsonCol", Type.Code.JSON, OptionalLong.empty(), "jsonCol JSON");
assertColumnDdl(
List.class, JsonColumn.class, "arrayJsonCol", Type.Code.JSON, OptionalLong.empty(),
"arrayJsonCol ARRAY<JSON>");
}

private void assertColumnDdl(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -393,4 +393,35 @@ void readJsonFieldTest() {
assertThat(result.params.p1).isEqualTo("address line");
assertThat(result.params.p2).isEqualTo("5");
}

@Test
void readArrayJsonFieldTest() {
Struct row = mock(Struct.class);
when(row.getString("id")).thenReturn("1234");
when(row.getType())
.thenReturn(
Type.struct(
Arrays.asList(
Type.StructField.of("id", Type.string()),
Type.StructField.of("paramsList", Type.array(Type.json())))));
when(row.getColumnType("id")).thenReturn(Type.string());

when(row.getColumnType("paramsList")).thenReturn(Type.array(Type.json()));
when(row.getJsonList("paramsList")).thenReturn(
Arrays.asList("{\"p1\":\"address line\",\"p2\":\"5\"}",
"{\"p1\":\"address line 2\",\"p2\":\"6\"}", null));

TestEntities.TestEntityJsonArray result =
this.spannerEntityReader.read(TestEntities.TestEntityJsonArray.class, row);

assertThat(result.id).isEqualTo("1234");

assertThat(result.paramsList.get(0).p1).isEqualTo("address line");
assertThat(result.paramsList.get(0).p2).isEqualTo("5");

assertThat(result.paramsList.get(1).p1).isEqualTo("address line 2");
assertThat(result.paramsList.get(1).p2).isEqualTo("6");

assertThat(result.paramsList.get(2)).isNull();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,47 @@ void writeNullJsonTest() {
verify(valueBinder).to(Value.json(null));
}

@Test
void writeJsonArrayTest() {
TestEntities.Params parameters = new TestEntities.Params("some value", "some other value");
TestEntities.TestEntityJsonArray testEntity = new TestEntities.TestEntityJsonArray("id1", Arrays.asList(parameters, parameters));

WriteBuilder writeBuilder = mock(WriteBuilder.class);
ValueBinder<WriteBuilder> valueBinder = mock(ValueBinder.class);

when(writeBuilder.set("id")).thenReturn(valueBinder);
when(writeBuilder.set("paramsList")).thenReturn(valueBinder);

this.spannerEntityWriter.write(testEntity, writeBuilder::set);

List<String> stringList = new ArrayList<>();
stringList.add("{\"p1\":\"some value\",\"p2\":\"some other value\"}");
stringList.add("{\"p1\":\"some value\",\"p2\":\"some other value\"}");

verify(valueBinder).to(testEntity.id);
verify(valueBinder).toJsonArray(stringList);
}

@Test
void writeNullEmptyJsonArrayTest() {
TestEntities.TestEntityJsonArray testNull = new TestEntities.TestEntityJsonArray("id1", null);
TestEntities.TestEntityJsonArray testEmpty = new TestEntities.TestEntityJsonArray("id2", new ArrayList<>());

WriteBuilder writeBuilder = mock(WriteBuilder.class);
ValueBinder<WriteBuilder> valueBinder = mock(ValueBinder.class);

when(writeBuilder.set("id")).thenReturn(valueBinder);
when(writeBuilder.set("paramsList")).thenReturn(valueBinder);

this.spannerEntityWriter.write(testNull, writeBuilder::set);
this.spannerEntityWriter.write(testEmpty, writeBuilder::set);

verify(valueBinder).to(testNull.id);
verify(valueBinder).toJsonArray(isNull());
verify(valueBinder).to(testEmpty.id);
verify(valueBinder).toJsonArray(new ArrayList<>());
}

@Test
void writeUnsupportedTypeIterableTest() {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ static class PartialConstructor {
}

/** A test class with Json field. */
@Table(name = "custom_test_table")
@Table(name = "json_test_table")
static class TestEntityJson {
@PrimaryKey String id;

Expand All @@ -256,6 +256,20 @@ static class TestEntityJson {
}
}

/** A test class with Json Array field. */
@Table(name = "jsonarray_test_table2")
static class TestEntityJsonArray {
@PrimaryKey String id;

@Column(spannerType = TypeCode.JSON)
List<Params> paramsList;

TestEntityJsonArray(String id, List<Params> paramsList) {
this.id = id;
this.paramsList = paramsList;
}
}

static class Params {
String p1;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,38 @@ void insertAndDeleteWithJsonField() {
assertThat(this.spannerOperations.count(Trade.class)).isZero();
}

@Test
void insertAndDeleteWithArrayJsonField() {

this.spannerOperations.delete(Trade.class, KeySet.all());
assertThat(this.spannerOperations.count(Trade.class)).isZero();

Details details1 = new Details("abc", "def");
Details details2 = new Details("123", "234");
Trade trade1 = Trade.makeTrade();
trade1.setAdditionalDetails(Arrays.asList(details1, details2));
Trade trade2 = Trade.makeTrade();
trade2.setAdditionalDetails(null);

this.spannerOperations.insert(trade1);
this.spannerOperations.insert(trade2);
assertThat(this.spannerOperations.count(Trade.class)).isEqualTo(2L);

List<Trade> trades =
this.spannerOperations.queryAll(Trade.class, new SpannerPageableQueryOptions());

assertThat(trades).containsExactlyInAnyOrder(trade1, trade2);

Trade retrievedTrade =
this.spannerOperations.read(Trade.class, Key.of(trade1.getId(), trade1.getTraderId()));
assertThat(retrievedTrade).isEqualTo(trade1);
assertThat(retrievedTrade.getAdditionalDetails()).isInstanceOf(List.class);
assertThat(retrievedTrade.getAdditionalDetails()).containsExactly(details1, details2);

this.spannerOperations.deleteAll(Arrays.asList(trade1, trade2));
assertThat(this.spannerOperations.count(Trade.class)).isZero();
}

@Test
void readWriteTransactionTest() {
Trade trade = Trade.makeTrade();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,12 @@ void testGetJsonPropertyName() {

assertThat(entityWithNoJsonField.isJsonProperty(String.class)).isFalse();
assertThat(entityWithNoJsonField.isJsonProperty(long.class)).isFalse();

SpannerPersistentEntityImpl<EntityWithArrayJsonField> entityWithArrayJsonField =
(SpannerPersistentEntityImpl<EntityWithArrayJsonField>)
this.spannerMappingContext.getPersistentEntity(EntityWithArrayJsonField.class);
assertThat(entityWithArrayJsonField.isJsonProperty(JsonEntity.class)).isTrue();
assertThat(entityWithArrayJsonField.isJsonProperty(String.class)).isFalse();
}

private static class ParentInRelationship {
Expand Down Expand Up @@ -512,5 +518,12 @@ private static class EntityWithJsonField {
JsonEntity jsonField;
}

private static class EntityWithArrayJsonField {
@PrimaryKey String id;

@Column(spannerType = TypeCode.JSON)
List<JsonEntity> jsonListField;
}

private static class JsonEntity {}
}
Loading