Skip to content

Commit

Permalink
Add support for JSON Spanner data type in Spring Data Spanner (#635)
Browse files Browse the repository at this point in the history
To add support for JSON field in spring data spanner module.
With these changes, a custom POJO field can be mapped as JSON spanner data type with annotation @column(spannerType = TypeCode.JSON). Supporting:
create DDL; read; write; sql query edge case: where query return is Json-field or list of Json-field.

Corresponding issue: #593.

There are follow-up issues for this PR:
- Enhancement to spring data spanner JSON support - allow setting changes on Gson object #650
- support for ARRAY<JSON> in spring data spanner #651
  • Loading branch information
zhumin8 committed Oct 12, 2021
1 parent 34a4582 commit 5281422
Show file tree
Hide file tree
Showing 23 changed files with 624 additions and 24 deletions.
27 changes: 26 additions & 1 deletion docs/src/main/asciidoc/spanner.adoc
Expand Up @@ -478,10 +478,35 @@ Natively supported types:
* `java.time.LocalDateTime`


==== 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.

[source,java]
----
@Table(name = "traders")
public class Trader {
@PrimaryKey
@Column(name = "trader_id")
String traderId;
@Column(spannerType = TypeCode.JSON)
Details details;
}
public class Details {
String name;
String affiliation;
Boolean isActive;
}
----


==== Lists

Spanner supports `ARRAY` types for columns.
`ARRAY` columns are mapped to `List` fields in POJOS.
`ARRAY` columns are mapped to `List` fields in POJOs.

Example:

Expand Down
Expand Up @@ -186,8 +186,13 @@ String getColumnDdlString(SpannerPersistentProperty spannerPersistentProperty,
spannerPersistentProperty.isGenerateSchemaNotNull(),
spannerPersistentProperty.isCommitTimestamp());
}
spannerJavaType = spannerEntityProcessor
if (spannerColumnType == Type.Code.JSON) {
spannerJavaType = columnType;
}
else {
spannerJavaType = spannerEntityProcessor
.getCorrespondingSpannerJavaType(columnType, false);
}

if (spannerJavaType == null) {
throw new SpannerDataException(
Expand Down
Expand Up @@ -33,12 +33,14 @@
import com.google.cloud.spanner.Key;
import com.google.cloud.spanner.Mutation.WriteBuilder;
import com.google.cloud.spanner.Struct;
import com.google.cloud.spanner.Type;
import com.google.cloud.spanner.Value;
import com.google.cloud.spanner.ValueBinder;
import com.google.cloud.spring.data.spanner.core.mapping.SpannerDataException;
import com.google.cloud.spring.data.spanner.core.mapping.SpannerMappingContext;
import com.google.cloud.spring.data.spanner.core.mapping.SpannerPersistentEntity;
import com.google.cloud.spring.data.spanner.core.mapping.SpannerPersistentProperty;
import com.google.gson.Gson;

import org.springframework.data.mapping.PersistentPropertyAccessor;
import org.springframework.util.Assert;
Expand Down Expand Up @@ -68,6 +70,8 @@ public class ConverterAwareMappingSpannerEntityWriter implements SpannerEntityWr
static final Map<Class<?>, BiConsumer<ValueBinder<?>, Iterable>>
iterablePropertyTypeToMethodMap = createIterableTypeMapping();

private static final Gson gson = new Gson();

@SuppressWarnings("unchecked")
private static Map<Class<?>, BiConsumer<ValueBinder<?>, Iterable>> createIterableTypeMapping() {
Map<Class<?>, BiConsumer<ValueBinder<?>, Iterable>> map = new LinkedHashMap<>();
Expand Down Expand Up @@ -339,6 +343,15 @@ private static <T> boolean attemptSetSingleItemValue(Object value, Class<?> sour
return true;
}


private static Value covertJsonToValue(Object value) {
if (value == null) {
return Value.json(null);
}
String jsonString = gson.toJson(value);
return Value.json(jsonString);
}

/**
* <p>
* For each property this method "set"s the column name and finds the corresponding "to"
Expand Down Expand Up @@ -393,6 +406,11 @@ private void writeProperty(MultipleValueBinder sink,
valueSet = attemptSetSingleItemValue(Value.COMMIT_TIMESTAMP, Timestamp.class, valueBinder,
Timestamp.class, this.writeConverter);
}
// annotated json column, bind directly
else if (property.getAnnotatedColumnItemType() == Type.Code.JSON) {
valueBinder.to(covertJsonToValue(propertyValue));
valueSet = true;
}
// use the user's annotated column type if possible
else if (property.getAnnotatedColumnItemType() != null) {
valueSet = attemptSetSingleItemValue(propertyValue, propertyType,
Expand Down
Expand Up @@ -32,6 +32,7 @@
import com.google.cloud.spanner.Type.Code;
import com.google.cloud.spring.core.util.MapBuilder;
import com.google.cloud.spring.data.spanner.core.mapping.SpannerDataException;
import com.google.gson.Gson;

/**
* A convenience wrapper class around Struct to make reading columns easier without
Expand Down Expand Up @@ -107,6 +108,8 @@ public class StructAccessor {

private Set<String> columnNamesIndex;

private static final Gson gson = new Gson();

public StructAccessor(Struct struct) {
this.struct = struct;
this.columnNamesIndex = indexColumnNames();
Expand Down Expand Up @@ -170,4 +173,23 @@ private Class getSingleItemTypeCode(Type colType) {
? SpannerTypeMapper.getArrayJavaClassFor(colType.getArrayElementType().getCode())
: SpannerTypeMapper.getSimpleJavaClassFor(code);
}

<T> T getSingleJsonValue(String colName, Class<T> colType) {
if (this.struct.isNull(colName)) {
return null;
}
String jsonString = this.struct.getJson(colName);
return gson.fromJson(jsonString, colType);
}

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.");
}
if (this.struct.isNull(colIndex)) {
return null;
}
String jsonString = this.struct.getJson(colIndex);
return gson.fromJson(jsonString, colType);
}
}
Expand Up @@ -20,6 +20,7 @@
import java.util.stream.Collectors;

import com.google.cloud.spanner.Struct;
import com.google.cloud.spanner.Type;
import com.google.cloud.spring.data.spanner.core.mapping.SpannerDataException;
import com.google.cloud.spring.data.spanner.core.mapping.SpannerPersistentProperty;

Expand Down Expand Up @@ -104,6 +105,11 @@ public <T> T getPropertyValue(SpannerPersistentProperty spannerPersistentPropert
private <T> T readSingleWithConversion(
SpannerPersistentProperty spannerPersistentProperty) {
String colName = spannerPersistentProperty.getColumnName();
Type.Code spannerColumnType = spannerPersistentProperty.getAnnotatedColumnItemType();
if (spannerColumnType == Type.Code.JSON) {
Object value = this.structAccessor.getSingleJsonValue(colName, spannerPersistentProperty.getType());
return (T) value;
}
Object value = this.structAccessor.getSingleValue(colName);
return (value != null) ? convertOrRead((Class<T>) spannerPersistentProperty.getType(), value) : null;
}
Expand Down
Expand Up @@ -27,6 +27,7 @@
import java.util.regex.Pattern;

import com.google.cloud.spanner.Key;
import com.google.cloud.spanner.Type;
import com.google.cloud.spring.data.spanner.core.convert.ConversionUtils;
import com.google.cloud.spring.data.spanner.core.convert.ConverterAwareMappingSpannerEntityProcessor;
import com.google.cloud.spring.data.spanner.core.convert.SpannerEntityProcessor;
Expand Down Expand Up @@ -96,6 +97,8 @@ public class SpannerPersistentEntityImpl<T>

private final String where;

private final Set<Class<?>> jsonProperties = new HashSet<>();

/**
* Creates a {@link SpannerPersistentEntityImpl}.
* @param information type information about the underlying entity type.
Expand Down Expand Up @@ -180,6 +183,10 @@ else if (property.isEagerInterleaved()) {
+ " in " + getType().getSimpleName() + ".");
});
}

if (property.getAnnotatedColumnItemType() == Type.Code.JSON) {
this.jsonProperties.add(property.getType());
}
}

private void addPersistentPropertyToPersistentEntity(
Expand Down Expand Up @@ -398,6 +405,11 @@ public Set<String> columns() {
return Collections.unmodifiableSet(this.columnNames);
}

// Lookup whether a particular class is a JSON entity property
public boolean isJsonProperty(Class<?> type) {
return this.jsonProperties.contains(type);
}

public void setApplicationContext(ApplicationContext applicationContext)
throws BeansException {
this.context.addPropertyAccessor(new BeanFactoryAccessor());
Expand Down
Expand Up @@ -39,6 +39,7 @@
import com.google.cloud.spring.data.spanner.core.mapping.SpannerDataException;
import com.google.cloud.spring.data.spanner.core.mapping.SpannerMappingContext;
import com.google.cloud.spring.data.spanner.core.mapping.SpannerPersistentEntity;
import com.google.cloud.spring.data.spanner.core.mapping.SpannerPersistentEntityImpl;

import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
Expand Down Expand Up @@ -232,15 +233,33 @@ private List executeReadSql(Pageable pageable, Sort sort, QueryTagValue queryTag

Statement statement = buildStatementFromQueryAndTags(queryTagValue);

return (getReturnedSimpleConvertableItemType() != null)
? this.spannerTemplate.query(
struct -> new StructAccessor(struct).getSingleValue(0), statement,
spannerQueryOptions)
: this.spannerTemplate.query(this.entityType,
statement,
if (getReturnedSimpleConvertableItemType() != null) {
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) {
return this.spannerTemplate.query(
struct -> new StructAccessor(struct).getSingleJsonValue(0, returnedType), statement,
spannerQueryOptions);
}

return this.spannerTemplate.query(this.entityType,
statement,
spannerQueryOptions);
}

private boolean isJsonFieldType(Class<?> returnedType) {
SpannerPersistentEntityImpl<?> persistentEntity = (SpannerPersistentEntityImpl<?>) this.spannerMappingContext
.getPersistentEntity(this.entityType);
if (persistentEntity == null) {
return false;
}
return persistentEntity.isJsonProperty(returnedType);
}

private Statement buildStatementFromQueryAndTags(QueryTagValue queryTagValue) {
Map<String, java.lang.reflect.Parameter> paramMetadataMap = new HashMap<>();
for (java.lang.reflect.Parameter param : getQueryMethod().getMethod().getParameters()) {
Expand Down
Expand Up @@ -22,6 +22,7 @@

import com.google.cloud.ByteArray;
import com.google.cloud.spanner.Key;
import com.google.cloud.spanner.Type;
import com.google.cloud.spring.data.spanner.core.convert.ConverterAwareMappingSpannerEntityProcessor;
import com.google.cloud.spring.data.spanner.core.convert.SpannerEntityProcessor;
import com.google.cloud.spring.data.spanner.core.mapping.Column;
Expand Down Expand Up @@ -77,7 +78,7 @@ public void getCreateDdlTest() {
+ "primitiveIntField INT64 , bigIntField INT64 , bytes BYTES(MAX) , "
+ "bytesList ARRAY<BYTES(111)> , integerList ARRAY<INT64> , "
+ "doubles ARRAY<FLOAT64> , commitTimestamp TIMESTAMP OPTIONS (allow_commit_timestamp=true) , "
+ "bigDecimalField NUMERIC , bigDecimals ARRAY<NUMERIC> ) " +
+ "bigDecimalField NUMERIC , bigDecimals ARRAY<NUMERIC> , jsonCol JSON ) " +
"PRIMARY KEY ( id , id_2 , id3 )";

assertThat(this.spannerSchemaUtils.getCreateTableDdlString(TestEntity.class))
Expand All @@ -87,74 +88,81 @@ public void getCreateDdlTest() {
@Test
public void createDdlString() {
assertColumnDdl(String.class, null,
"id", OptionalLong.empty(),
"id", null, OptionalLong.empty(),
"id STRING(MAX)");
}

@Test
public void createDdlStringCustomLength() {
assertColumnDdl(String.class, null,
"id", OptionalLong.of(333L),
"id", null, OptionalLong.of(333L),
"id STRING(333)");
}

@Test
public void createDdlBytesMax() {
assertColumnDdl(ByteArray.class, null,
"bytes", OptionalLong.empty(),
"bytes", null, OptionalLong.empty(),
"bytes BYTES(MAX)");
}

@Test
public void createDdlBytesCustomLength() {
assertColumnDdl(ByteArray.class, null,
"bytes", OptionalLong.of(333L),
"bytes", null, OptionalLong.of(333L),
"bytes BYTES(333)");
}

@Test
public void ddlForListOfByteArray() {
assertColumnDdl(List.class, ByteArray.class,
"bytesList", OptionalLong.of(111L),
"bytesList", null, OptionalLong.of(111L),
"bytesList ARRAY<BYTES(111)>");
}

@Test
public void ddlForDoubleArray() {
assertColumnDdl(double[].class, null,
"doubles", OptionalLong.of(111L),
"doubles", null, OptionalLong.of(111L),
"doubles ARRAY<FLOAT64>");
}

@Test
public void ddlForNumericList() {
assertColumnDdl(List.class, BigDecimal.class,
"bigDecimals", OptionalLong.empty(),
"bigDecimals", null, OptionalLong.empty(),
"bigDecimals ARRAY<NUMERIC>");
}

@Test
public void createDdlNumeric() {
assertColumnDdl(BigDecimal.class, null,
"bigDecimal", OptionalLong.empty(),
"bigDecimal", null, OptionalLong.empty(),
"bigDecimal NUMERIC");
}

@Test
public void ddlForListOfListOfIntegers() {
assertColumnDdl(List.class, Integer.class,
"integerList", OptionalLong.empty(),
"integerList", null, OptionalLong.empty(),
"integerList ARRAY<INT64>");
}

@Test
public void ddlForListOfListOfDoubles() {
assertColumnDdl(List.class, Double.class,
"doubleList", OptionalLong.empty(),
"doubleList", null, OptionalLong.empty(),
"doubleList ARRAY<FLOAT64>");
}

private void assertColumnDdl(Class clazz, Class innerClazz, String name,
@Test
public void createDdlForJson() {
assertColumnDdl(JsonColumn.class, null,
"jsonCol", Type.Code.JSON, OptionalLong.empty(),
"jsonCol JSON");
}

private void assertColumnDdl(Class clazz, Class innerClazz, String name, Type.Code code,
OptionalLong length, String expectedDDL) {
SpannerPersistentProperty spannerPersistentProperty = mock(SpannerPersistentProperty.class);

Expand All @@ -165,6 +173,9 @@ private void assertColumnDdl(Class clazz, Class innerClazz, String name,

when(spannerPersistentProperty.getColumnName()).thenReturn(name);
when(spannerPersistentProperty.getMaxColumnLength()).thenReturn(length);

when(spannerPersistentProperty
.getAnnotatedColumnItemType()).thenReturn(code);
assertThat(
this.spannerSchemaUtils.getColumnDdlString(
spannerPersistentProperty, this.spannerEntityProcessor))
Expand Down Expand Up @@ -261,6 +272,13 @@ private static class TestEntity {
BigDecimal bigDecimalField;

List<BigDecimal> bigDecimals;

@Column(spannerType = TypeCode.JSON)
JsonColumn jsonCol;
}
private static class JsonColumn {
String param1;
String param2;
}

private static class EmbeddedColumns {
Expand Down

0 comments on commit 5281422

Please sign in to comment.