Skip to content

Commit

Permalink
feat: #2576 Add spring-cloud-gcp-data-spanner support for Instant val…
Browse files Browse the repository at this point in the history
…ues (@ablx) (#2881)

* feat: #2576 Added typeadapter to read and write Instant values. (#2841)

* chore: additional test cases for InstantTypeAdapterTest

---------

Co-authored-by: Mirco Franzek <ablx@users.noreply.github.com>
  • Loading branch information
burkedavison and ablx committed May 15, 2024
1 parent 035f2c3 commit 7d4bb44
Show file tree
Hide file tree
Showing 7 changed files with 264 additions and 53 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
package com.google.cloud.spring.autoconfigure.spanner;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import com.google.api.gax.core.CredentialsProvider;
import com.google.api.gax.retrying.RetrySettings;
Expand All @@ -31,6 +33,8 @@
import com.google.cloud.spring.data.spanner.core.admin.SpannerSchemaUtils;
import com.google.cloud.spring.data.spanner.core.mapping.SpannerMappingContext;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurationPackage;
import org.springframework.boot.autoconfigure.AutoConfigurations;
Expand All @@ -46,6 +50,14 @@ class GcpSpannerAutoConfigurationTests {
/** Mock Gson object for use in configuration. */
public static Gson MOCK_GSON = mock(Gson.class);

@BeforeAll
static void beforeAll() {
GsonBuilder builderMock = mock(GsonBuilder.class);
when(builderMock.registerTypeAdapter(any(), any())).thenReturn(builderMock);
when(builderMock.create()).thenReturn(MOCK_GSON);
when(MOCK_GSON.newBuilder()).thenReturn(builderMock);
}

private ApplicationContextRunner contextRunner =
new ApplicationContextRunner()
.withConfiguration(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@

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.typeadapter.InstantTypeAdapter;
import com.google.gson.Gson;
import com.google.gson.TypeAdapter;
import java.time.Instant;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
Expand Down Expand Up @@ -50,12 +53,11 @@ public class SpannerMappingContext

private Gson gson;

public SpannerMappingContext() {
}
public SpannerMappingContext() {}

public SpannerMappingContext(Gson gson) {
Assert.notNull(gson, "A non-null gson is required.");
this.gson = gson;
this.gson = addTypeAdapter(gson, new InstantTypeAdapter(), Instant.class);
}

@NonNull
Expand Down Expand Up @@ -96,7 +98,8 @@ protected <T> SpannerPersistentEntity<T> createPersistentEntity(
protected <T> SpannerPersistentEntityImpl<T> constructPersistentEntity(
TypeInformation<T> typeInformation) {
SpannerEntityProcessor processor;
if (this.applicationContext == null || !this.applicationContext.containsBean("spannerConverter")) {
if (this.applicationContext == null
|| !this.applicationContext.containsBean("spannerConverter")) {
processor = new ConverterAwareMappingSpannerEntityProcessor(this);
} else {
processor = this.applicationContext.getBean(SpannerEntityProcessor.class);
Expand Down Expand Up @@ -124,4 +127,9 @@ public SpannerPersistentEntity<?> getPersistentEntityOrFail(Class<?> entityClass
}
return entity;
}

private <T> Gson addTypeAdapter(Gson gson, TypeAdapter<T> typeAdapter, Class<T> type) {

return gson.newBuilder().registerTypeAdapter(type, typeAdapter).create();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.cloud.spring.data.spanner.core.mapping.typeadapter;

import com.google.gson.TypeAdapter;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonToken;
import com.google.gson.stream.JsonWriter;
import java.io.IOException;
import java.time.Instant;

public class InstantTypeAdapter extends TypeAdapter<Instant> {
@Override
public void write(JsonWriter jsonWriter, Instant instant) throws IOException {
if (instant == null) {
jsonWriter.nullValue();
} else {
jsonWriter.value(instant.toString());
}
}

@Override
public Instant read(JsonReader jsonReader) throws IOException {
if (jsonReader.peek() == JsonToken.NULL) {
return null;
}
return Instant.parse(jsonReader.nextString());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
import com.google.cloud.spring.data.spanner.core.mapping.SpannerDataException;
import com.google.cloud.spring.data.spanner.core.mapping.SpannerMappingContext;
import com.google.gson.Gson;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
Expand All @@ -61,8 +62,7 @@ void setup() {
this.spannerReadConverter = new SpannerReadConverter();
SpannerMappingContext mappingContext = new SpannerMappingContext(new Gson());
this.spannerEntityReader =
new ConverterAwareMappingSpannerEntityReader(
mappingContext, this.spannerReadConverter);
new ConverterAwareMappingSpannerEntityReader(mappingContext, this.spannerReadConverter);
}

@Test
Expand Down Expand Up @@ -132,8 +132,8 @@ void readArraySingularMismatchTest() {
.build();

assertThatThrownBy(() -> this.spannerEntityReader.read(OuterTestEntity.class, rowStruct))
.isInstanceOf(SpannerDataException.class)
.hasMessage("Column is not an ARRAY type: innerTestEntities");
.isInstanceOf(SpannerDataException.class)
.hasMessage("Column is not an ARRAY type: innerTestEntities");
}

@Test
Expand All @@ -148,19 +148,23 @@ void readSingularArrayMismatchTest() {
Type.struct(StructField.of("string_col", Type.string())), List.of(colStruct))
.build();

ConverterAwareMappingSpannerEntityReader testReader = new ConverterAwareMappingSpannerEntityReader(new SpannerMappingContext(), new SpannerReadConverter(
List.of(
new Converter<Struct, Integer>() {
@Nullable
@Override
public Integer convert(Struct source) {
return source.getString("string_col").length();
}
})));
ConverterAwareMappingSpannerEntityReader testReader =
new ConverterAwareMappingSpannerEntityReader(
new SpannerMappingContext(),
new SpannerReadConverter(
List.of(
new Converter<Struct, Integer>() {
@Nullable
@Override
public Integer convert(Struct source) {
return source.getString("string_col").length();
}
})));
assertThatThrownBy(() -> testReader.read(OuterTestEntityFlatFaulty.class, rowStruct))
.isInstanceOf(SpannerDataException.class)
.hasMessage("The value in column with name innerLengths could not be converted to the corresponding"
+ " property in the entity. The property's type is class java.lang.Integer.");
.isInstanceOf(SpannerDataException.class)
.hasMessage(
"The value in column with name innerLengths could not be converted to the corresponding"
+ " property in the entity. The property's type is class java.lang.Integer.");
}

@Test
Expand Down Expand Up @@ -218,8 +222,8 @@ void readNotFoundColumnTest() {
.build();

assertThatThrownBy(() -> this.spannerEntityReader.read(TestEntity.class, struct))
.isInstanceOf(SpannerDataException.class)
.hasMessage("Unable to read column from Cloud Spanner results: id4");
.isInstanceOf(SpannerDataException.class)
.hasMessage("Unable to read column from Cloud Spanner results: id4");
}

@Test
Expand Down Expand Up @@ -255,11 +259,11 @@ void readUnconvertableValueTest() {
.to(Value.bytes(ByteArray.copyFrom("string1")))
.build();


assertThatThrownBy(() -> this.spannerEntityReader.read(TestEntity.class, struct))
.isInstanceOf(ConversionFailedException.class)
.hasMessage("Failed to convert from type [java.lang.String] to type "
+ "[java.lang.Double] for value [UNCONVERTABLE VALUE]")
.hasMessage(
"Failed to convert from type [java.lang.String] to type "
+ "[java.lang.Double] for value [UNCONVERTABLE VALUE]")
.hasStackTraceContaining(
"java.lang.NumberFormatException: For input string: \"UNCONVERTABLEVALUE\"");
}
Expand All @@ -270,8 +274,8 @@ void readUnmatachableTypesTest() {
Struct.newBuilder().set("fieldWithUnsupportedType").to(Value.string("key1")).build();

assertThatThrownBy(() -> this.spannerEntityReader.read(FaultyTestEntity.class, struct))
.isInstanceOf(SpannerDataException.class)
.hasMessage("Unable to read column from Cloud Spanner results: id");
.isInstanceOf(SpannerDataException.class)
.hasMessage("Unable to read column from Cloud Spanner results: id");
}

@Test
Expand Down Expand Up @@ -329,8 +333,7 @@ void testPartialConstructor() {
void ensureConstructorArgsAreReadOnce() {
Struct row = mock(Struct.class);
when(row.getString("id")).thenReturn("1234");
when(row.getType())
.thenReturn(Type.struct(List.of(StructField.of("id", Type.string()))));
when(row.getType()).thenReturn(Type.struct(List.of(StructField.of("id", Type.string()))));
when(row.getColumnType("id")).thenReturn(Type.string());

TestEntities.SimpleConstructorTester result =
Expand All @@ -354,9 +357,10 @@ void testPartialConstructorWithNotEnoughArgs() {
.to(Value.float64(3.14))
.build();

assertThatThrownBy(() -> this.spannerEntityReader.read(TestEntities.PartialConstructor.class, struct))
.isInstanceOf(SpannerDataException.class)
.hasMessage("Column not found: custom_col");
assertThatThrownBy(
() -> this.spannerEntityReader.read(TestEntities.PartialConstructor.class, struct))
.isInstanceOf(SpannerDataException.class)
.hasMessage("Column not found: custom_col");
}

@Test
Expand All @@ -367,11 +371,16 @@ void zeroArgsListShouldNotThrowError() {
.set("zeroArgsListOfObjects")
.to(Value.stringArray(List.of("hello", "world")))
.build();
// Starting from Spring 3.0, Collection types without generics can be resolved to type with wildcard
// generics (i.e., "?"). For example, "zeroArgsListOfObjects" will be resolved to List<?>, rather
// Starting from Spring 3.0, Collection types without generics can be resolved to type with
// wildcard
// generics (i.e., "?"). For example, "zeroArgsListOfObjects" will be resolved to List<?>,
// rather
// than List.
assertThatNoException()
.isThrownBy(() -> this.spannerEntityReader.read(TestEntities.TestEntityWithListWithZeroTypeArgs.class, struct));
.isThrownBy(
() ->
this.spannerEntityReader.read(
TestEntities.TestEntityWithListWithZeroTypeArgs.class, struct));
}

@Test
Expand All @@ -397,6 +406,28 @@ void readJsonFieldTest() {
assertThat(result.params.p2).isEqualTo("5");
}

@Test
void readJsonInstantFieldTest() {
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("params", Type.json()))));
when(row.getColumnType("id")).thenReturn(Type.string());

when(row.getJson("params")).thenReturn("{\"instant\":\"1970-01-01T00:00:00Z\"}");

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

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

assertThat(result.params.instant).isEqualTo(Instant.ofEpochSecond(0));
}

@Test
void readArrayJsonFieldTest() {
Struct row = mock(Struct.class);
Expand All @@ -410,9 +441,12 @@ void readArrayJsonFieldTest() {
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));
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);
Expand Down

0 comments on commit 7d4bb44

Please sign in to comment.