diff --git a/docs/src/main/asciidoc/index.adoc b/docs/src/main/asciidoc/index.adoc index f9e92dc800..d136674b20 100644 --- a/docs/src/main/asciidoc/index.adoc +++ b/docs/src/main/asciidoc/index.adoc @@ -47,6 +47,8 @@ include::metrics.adoc[] include::spanner.adoc[] +include::spanner-spring-data-r2dbc.adoc[] + include::datastore.adoc[] include::firestore.adoc[] diff --git a/docs/src/main/asciidoc/spanner-spring-data-r2dbc.adoc b/docs/src/main/asciidoc/spanner-spring-data-r2dbc.adoc new file mode 100644 index 0000000000..d916f2f102 --- /dev/null +++ b/docs/src/main/asciidoc/spanner-spring-data-r2dbc.adoc @@ -0,0 +1,45 @@ +:spring-data-commons-ref: https://docs.spring.io/spring-data/data-commons/docs/current/reference/html + +[#spring-data-cloud-spanner-r2dbc] +== Cloud Spanner Spring Data R2DBC + +The Spring Data R2DBC Dialect for Cloud Spanner enables the usage of https://github.com/spring-projects/spring-data-r2dbc[Spring Data R2DBC] with Cloud Spanner. + +The goal of the Spring Data project is to create easy and consistent ways of using data access technologies from Spring Framework applications. + +=== Setup + +Maven coordinates, using <>: + +[source,xml] +---- + + com.google.cloud + spring-cloud-spanner-spring-data-r2dbc + +---- + +Gradle coordinates: + +[source,subs="normal"] +---- +dependencies { + implementation("com.google.cloud:spring-cloud-spanner-spring-data-r2dbc") +} +---- +=== Overview + +Spring Data R2DBC allows you to use the convenient features of Spring Data in a reactive application. +These features include: + +* Spring configuration support using Java based `@Configuration` classes. +* Annotation based mapping metadata. +* Automatic implementation of Repository interfaces. +* Support for Reactive Transactions. +* Schema and data initialization utilities. + +See the https://docs.spring.io/spring-data/r2dbc/docs/current/reference/html/[Spring Data R2DBC documentation] for more information on how to use Spring Data R2DBC. + +=== Sample Application + +We provide a https://github.com/GoogleCloudPlatform/spring-cloud-gcp/tree/main/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples[sample application] which demonstrates using the Spring Data R2DBC framework with Cloud Spanner in https://docs.spring.io/spring-framework/reference/web/webflux.html[Spring WebFlux]. \ No newline at end of file diff --git a/pom.xml b/pom.xml index 7c014e986d..ccb69ce3f2 100644 --- a/pom.xml +++ b/pom.xml @@ -456,6 +456,7 @@ spring-cloud-previews spring-cloud-generator + spring-cloud-spanner-spring-data-r2dbc docs spring-cloud-gcp-samples @@ -675,8 +676,8 @@ spring-cloud-gcp-security-firebase spring-cloud-gcp-vision spring-cloud-gcp-kms - spring-cloud-previews + spring-cloud-spanner-spring-data-r2dbc diff --git a/spring-cloud-gcp-dependencies/pom.xml b/spring-cloud-gcp-dependencies/pom.xml index 459977f42f..ea1f8ee4d3 100644 --- a/spring-cloud-gcp-dependencies/pom.xml +++ b/spring-cloud-gcp-dependencies/pom.xml @@ -37,6 +37,7 @@ 26.19.0 1.13.1 1.0.2.RELEASE + 1.2.2 @@ -233,6 +234,11 @@ spring-cloud-gcp-starter-kms ${project.version} + + com.google.cloud + spring-cloud-spanner-spring-data-r2dbc + ${project.version} + @@ -265,6 +271,12 @@ ${r2dbc-postgres-driver.version} + + com.google.cloud + cloud-spanner-r2dbc + ${cloud-spanner-r2dbc.version} + + com.google.cloud diff --git a/spring-cloud-gcp-samples/pom.xml b/spring-cloud-gcp-samples/pom.xml index 38745e0f52..982f4f7ec8 100644 --- a/spring-cloud-gcp-samples/pom.xml +++ b/spring-cloud-gcp-samples/pom.xml @@ -84,6 +84,7 @@ spring-cloud-gcp-kotlin-samples spring-cloud-gcp-metrics-sample spring-cloud-gcp-kms-sample + spring-cloud-spanner-r2dbc-samples diff --git a/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/README.adoc b/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/README.adoc new file mode 100644 index 0000000000..588688f824 --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/README.adoc @@ -0,0 +1,24 @@ +# Cloud Spanner Spring Data R2DBC sample + +This sample creates a table called `BOOK` on application startup, and deletes it prior to application shutdown. + +## Running the Sample + +image:http://gstatic.com/cloudssh/images/open-btn.svg[link=https://ssh.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https%3A%2F%2Fgithub.com%2FGoogleCloudPlatform%2Fcloud-spanner-r2dbc&cloudshell_open_in_editor=cloud-spanner-r2dbc-samples/cloud-spanner-spring-data-r2dbc-sample/README.adoc] + + +1. Run the sample from the command line, providing `spanner.instance`, `spanner.database` and `gcp.project` properties: + +``` +mvn spring-boot:run -Dspring-boot.run.jvmArguments="-Dspanner.instance=[SPANNER-INSTANCE] -Dspanner.database=[SPANNER-DATABASE] -Dgcp.project=GCP-PROJECT" +``` + +2. Visit http://localhost:8080/index.html + +3. Try the different actions available + + - Listing all books. + - Adding a new book with only title. + - Adding a new book with extra details (a `Map` field in `Book` entity) stored as a spanner JSON column. + - Adding a new book with a review (a custom class field in `Book` entity) stored as a spanner JSON column + - Searching for a book by its ID. diff --git a/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/pom.xml b/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/pom.xml new file mode 100644 index 0000000000..e66f03ede4 --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/pom.xml @@ -0,0 +1,65 @@ + + + + + + spring-cloud-gcp-samples + com.google.cloud + 4.6.1-SNAPSHOT + + 4.0.0 + + spring-cloud-spanner-r2dbc-samples + Spring Framework on Google Cloud Code Sample - Spanner-r2dbc + + + + + + com.google.cloud + spring-cloud-gcp-dependencies + ${project.version} + pom + import + + + + + + + com.google.cloud + spring-cloud-spanner-spring-data-r2dbc + + + + org.springframework.boot + spring-boot-starter-webflux + + + + + org.springframework.boot + spring-boot-starter-test + test + + + io.projectreactor + reactor-test + test + + + + + + + maven-deploy-plugin + + true + + + + + + diff --git a/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/main/java/com/example/Book.java b/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/main/java/com/example/Book.java new file mode 100644 index 0000000000..2711b85169 --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/main/java/com/example/Book.java @@ -0,0 +1,98 @@ +/* + * Copyright 2020 Google LLC + * + * 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.example; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.springframework.data.annotation.Id; +import org.springframework.data.domain.Persistable; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +/** Book entity. */ +@Table +public class Book implements Persistable { + + @Id + @Column("ID") + private String id; + + @Column("TITLE") + private String title; + + @Column("EXTRADETAILS") + private Map extraDetails; + + @Column("REVIEWS") + private Review review; + + @Column("CATEGORIES") + private List categories; + + public Book(String title, Map extraDetails, Review review) { + this.id = UUID.randomUUID().toString(); + this.title = title; + this.extraDetails = extraDetails; + this.review = review; + } + + public String getId() { + return id; + } + + @Override + public boolean isNew() { + return true; + } + + public String getTitle() { + return this.title; + } + + public Map getExtraDetails() { + return extraDetails; + } + + public Review getReview() { + return review; + } + + public List getCategories() { + return categories; + } + + public void setCategories(List categories) { + this.categories = categories; + } + + @Override + public String toString() { + return "Book{" + + "id='" + + id + + '\'' + + ", title='" + + title + + '\'' + + ", extraDetails=" + + (extraDetails == null ? "" : extraDetails.toString()) + + ", categories=" + + (categories == null ? "" : categories) + + '}'; + } +} diff --git a/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/main/java/com/example/BookRepository.java b/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/main/java/com/example/BookRepository.java new file mode 100644 index 0000000000..4afb1c2193 --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/main/java/com/example/BookRepository.java @@ -0,0 +1,26 @@ +/* + * Copyright 2020 Google LLC + * + * 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.example; + +import org.springframework.data.repository.reactive.ReactiveCrudRepository; + +/** + * Spring Data repository for books. + * + *

Query derivation is not supported yet. + */ +interface BookRepository extends ReactiveCrudRepository {} diff --git a/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/main/java/com/example/CustomConfiguration.java b/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/main/java/com/example/CustomConfiguration.java new file mode 100644 index 0000000000..daccf60213 --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/main/java/com/example/CustomConfiguration.java @@ -0,0 +1,92 @@ +/* + * Copyright 2021 Google LLC + * + * 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.example; + +import com.google.cloud.spanner.r2dbc.v2.JsonWrapper; +import com.google.gson.Gson; +import com.google.gson.JsonParseException; +import io.r2dbc.spi.ConnectionFactory; +import java.util.ArrayList; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.convert.ReadingConverter; +import org.springframework.data.convert.WritingConverter; +import org.springframework.data.r2dbc.config.AbstractR2dbcConfiguration; +import org.springframework.data.r2dbc.convert.R2dbcCustomConversions; +import org.springframework.stereotype.Component; + +@Configuration +public class CustomConfiguration extends AbstractR2dbcConfiguration { + + @Autowired ApplicationContext applicationContext; + + @Override + public ConnectionFactory connectionFactory() { + return null; + } + + @Bean + @Override + public R2dbcCustomConversions r2dbcCustomConversions() { + List> converters = new ArrayList<>(); + converters.add(this.applicationContext.getBean(JsonToReviewsConverter.class)); + converters.add(this.applicationContext.getBean(ReviewsToJsonConverter.class)); + return new R2dbcCustomConversions(getStoreConversions(), converters); + } + + @Component + @ReadingConverter + public class JsonToReviewsConverter implements Converter { + + private final Gson gson; + + @Autowired + public JsonToReviewsConverter(Gson gson) { + this.gson = gson; + } + + @Override + public Review convert(JsonWrapper json) { + try { + return this.gson.fromJson(json.toString(), Review.class); + } catch (JsonParseException e) { + return new Review(); + } + } + } + + @Component + @WritingConverter + public class ReviewsToJsonConverter implements Converter { + + private final Gson gson; + + @Autowired + public ReviewsToJsonConverter(Gson gson) { + this.gson = gson; + } + + @Override + public JsonWrapper convert(Review source) { + return JsonWrapper.of(this.gson.toJson(source)); + } + } +} diff --git a/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/main/java/com/example/Review.java b/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/main/java/com/example/Review.java new file mode 100644 index 0000000000..f5de2416a6 --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/main/java/com/example/Review.java @@ -0,0 +1,65 @@ +/* + * Copyright 2021 Google LLC + * + * 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.example; + +import com.google.common.base.Objects; + +public class Review { + String reviewerId; + String reviewerContent; + + public String getReviewerId() { + return reviewerId; + } + + public void setReviewerId(String reviewerId) { + this.reviewerId = reviewerId; + } + + public String getReviewerContent() { + return reviewerContent; + } + + public void setReviewerContent(String reviewerContent) { + this.reviewerContent = reviewerContent; + } + + public Review() {} + + public Review(String reviewerId, String reviewerContent) { + this.reviewerId = reviewerId; + this.reviewerContent = reviewerContent; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Review review = (Review) o; + return Objects.equal(reviewerId, review.reviewerId) + && Objects.equal(reviewerContent, review.reviewerContent); + } + + @Override + public int hashCode() { + return Objects.hashCode(reviewerId, reviewerContent); + } +} diff --git a/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/main/java/com/example/SpringDataR2dbcApp.java b/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/main/java/com/example/SpringDataR2dbcApp.java new file mode 100644 index 0000000000..15a577cfe9 --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/main/java/com/example/SpringDataR2dbcApp.java @@ -0,0 +1,109 @@ +/* + * Copyright 2020 Google LLC + * + * 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.example; + +import static com.google.cloud.spanner.r2dbc.SpannerConnectionFactoryProvider.INSTANCE; +import static io.r2dbc.spi.ConnectionFactoryOptions.DATABASE; +import static org.springframework.web.reactive.function.server.RequestPredicates.GET; +import static org.springframework.web.reactive.function.server.RouterFunctions.route; + +import java.net.URI; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.annotation.Bean; +import org.springframework.context.event.ContextClosedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories; +import org.springframework.r2dbc.core.DatabaseClient; +import org.springframework.util.Assert; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Hooks; + +/** Driver application showing Cloud Spanner R2DBC use with Spring Data. */ +@SpringBootApplication +@EnableR2dbcRepositories +public class SpringDataR2dbcApp { + + private static final Logger LOGGER = LoggerFactory.getLogger(SpringDataR2dbcApp.class); + + private static final String SPANNER_INSTANCE = System.getProperty("spanner.instance"); + + private static final String SPANNER_DATABASE = System.getProperty("spanner.database"); + + private static final String GCP_PROJECT = System.getProperty("gcp.project"); + + @Autowired private DatabaseClient r2dbcClient; + + public static void main(String[] args) { + Hooks.onOperatorDebug(); + Assert.notNull(INSTANCE, "Please provide spanner.instance property"); + Assert.notNull(DATABASE, "Please provide spanner.database property"); + Assert.notNull(GCP_PROJECT, "Please provide gcp.project property"); + + SpringApplication.run(SpringDataR2dbcApp.class, args); + } + + @EventListener(ApplicationReadyEvent.class) + public void setUpData() { + LOGGER.info("Setting up test table BOOK..."); + try { + r2dbcClient + .sql( + "CREATE TABLE BOOK (" + + " ID STRING(36) NOT NULL," + + " TITLE STRING(MAX) NOT NULL," + + " EXTRADETAILS JSON," + + " REVIEWS JSON," + + " CATEGORIES ARRAY" + + ") PRIMARY KEY (ID)") + .fetch() + .rowsUpdated() + .block(); + + } catch (Exception e) { + LOGGER.info("Failed to set up test table BOOK", e); + return; + } + LOGGER.info("Finished setting up test table BOOK"); + LOGGER.info("App Started..visit http://localhost:8080/index.html"); + } + + @EventListener({ContextClosedEvent.class}) + public void tearDownData() { + LOGGER.info("Deleting test table BOOK..."); + try { + r2dbcClient.sql("DROP TABLE BOOK").fetch().rowsUpdated().block(); + } catch (Exception e) { + LOGGER.info("Failed to delete test table BOOK", e); + return; + } + + LOGGER.info("Finished deleting test table BOOK."); + } + + @Bean + public RouterFunction indexRouter() { + // Serve static index.html at root. + return route( + GET("/"), req -> ServerResponse.permanentRedirect(URI.create("/index.html")).build()); + } +} diff --git a/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/main/java/com/example/WebController.java b/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/main/java/com/example/WebController.java new file mode 100644 index 0000000000..1692d26c95 --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/main/java/com/example/WebController.java @@ -0,0 +1,65 @@ +/* + * Copyright 2019-2020 Google LLC + * + * 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.example; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Provides HTTP endpoints for manipulating the BOOK table. + * + *

    + *
  • {@code /list} Returns all books in the table (GET). + *
  • {@code /add} Adds a new book with a given title and a generated UUID as {@code id} (POST). + *
  • {@code /search/\{id\}} Finds a single book by its ID. + *
+ */ +@RestController +public class WebController { + + @Autowired private R2dbcEntityTemplate r2dbcEntityTemplate; + + @Autowired private BookRepository r2dbcRepository; + + @GetMapping("/list") + public Flux listBooks() { + return r2dbcEntityTemplate.select(Book.class).all(); + } + + @PostMapping("/add") + public Mono addBook(@RequestBody Book book) { + return r2dbcEntityTemplate.insert(Book.class).using(book).log().then(); + } + + /** For test cleanup. */ + @GetMapping("/delete-all") + public Mono deleteAll() { + return r2dbcRepository.findAll().flatMap(x -> r2dbcRepository.delete(x)).log().then(); + } + + @GetMapping("/search/{id}") + public Mono searchBooks(@PathVariable String id) { + return r2dbcRepository.findById(id); + } +} diff --git a/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/main/resources/application.properties b/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/main/resources/application.properties new file mode 100644 index 0000000000..e952e083ff --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/main/resources/application.properties @@ -0,0 +1,4 @@ +spring.r2dbc.url=\ +r2dbc:cloudspanner://spanner.googleapis.com:443/projects/${gcp.project}/instances/${spanner.instance}/databases/${spanner.database} + +logging.level.org.springframework.r2dbc=DEBUG \ No newline at end of file diff --git a/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/main/resources/static/index.html b/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/main/resources/static/index.html new file mode 100644 index 0000000000..3059c3076e --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/main/resources/static/index.html @@ -0,0 +1,155 @@ + + + Spring Data R2DBC for Cloud Spanner demo + + + + + + + + +
+ + + +
+ +

Required Field

+
+ New Book Title: +
+ +

Extra details (Map entity field turning into JSON Spanner column)

+
+ Rating: Is series: +
+ +

Review (custom Review object entity field turning into JSON Spanner column)

+
+ Reviewer: Review: +
+ +

Comma-separated categories (List entity field turning into ARRAY Spanner column)

+
+ Categories: +
+ + Add Book + +
+ + +
+ + + + + + + diff --git a/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/test/java/com/example/SpringDataR2dbcAppIntegrationTest.java b/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/test/java/com/example/SpringDataR2dbcAppIntegrationTest.java new file mode 100644 index 0000000000..2f0422e17b --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-spanner-r2dbc-samples/src/test/java/com/example/SpringDataR2dbcAppIntegrationTest.java @@ -0,0 +1,157 @@ +package com.example; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.cloud.ServiceOptions; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +@EnabledIfSystemProperty(named = "it.spanner", matches = "true") +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class SpringDataR2dbcAppIntegrationTest { + + @DynamicPropertySource + static void registerProperties(DynamicPropertyRegistry registry) { + registry.add( + "gcp.project", + () -> System.getProperty("gcp.project", ServiceOptions.getDefaultProjectId())); + registry.add("spanner.database", () -> System.getProperty("spanner.database", "testdb")); + registry.add("spanner.instance", () -> System.getProperty("spanner.instance", "reactivetest")); + } + + @Autowired private WebTestClient webTestClient; + + @Autowired private BookRepository bookRepository; + + @Autowired private ObjectMapper objectMapper; + + @AfterEach + void deleteRecords() { + this.webTestClient.get().uri("delete-all").exchange().expectStatus().is2xxSuccessful(); + } + + @Test + void tryBasicRepoMethods() { + Book newBook = new Book("Call of the wild", null, null); + bookRepository.save(newBook).block(); + Book newBook2 = new Book("War and Peace", null, null); + bookRepository.save(newBook2).block(); + + StepVerifier.create(bookRepository.findById(newBook.getId())) + .expectNextCount(1L) + .verifyComplete(); + + StepVerifier.create(bookRepository.findAll()).expectNextCount(2L).verifyComplete(); + + StepVerifier.create(bookRepository.count()).expectNext(2L).verifyComplete(); + } + + @Test + void testBasicWebEndpoints() throws JsonProcessingException { + + // initially empty table + this.webTestClient + .get() + .uri("/list") + .exchange() + .expectBody(Book[].class) + .isEqualTo(new Book[0]); + + Book newBook = new Book("Call of the wild", null, null); + this.webTestClient + .post() + .uri("/add") + .contentType(MediaType.APPLICATION_JSON) + .body(Mono.just(objectMapper.writeValueAsString(newBook)), String.class) + .exchange() + .expectStatus() + .is2xxSuccessful(); + + AtomicReference id = new AtomicReference<>(); + this.webTestClient + .get() + .uri("/list") + .exchange() + .expectBody(Book[].class) + .value( + books -> { + assertThat(books).hasSize(1); + assertThat(books[0].getTitle()).isEqualTo("Call of the wild"); + id.set(books[0].getId()); + }); + + assertThat(id).doesNotHaveValue(""); + + this.webTestClient + .get() + .uri("/search/" + id.get()) + .exchange() + .expectBody(Book.class) + .value( + book -> { + assertThat(book.getTitle()).isEqualTo("Call of the wild"); + }); + } + + @Test + void testJsonWebEndpoints() { + this.webTestClient + .post() + .uri("/add") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .body( + Mono.just( + "{\"title\":\"Call of the wild II\",\"extraDetails\":" + + "{\"rating\":\"8\",\"series\":\"yes\"},\"review\":null}"), + String.class) + .exchange() + .expectStatus() + .is2xxSuccessful(); + + this.webTestClient + .post() + .uri("/add") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .body( + Mono.just( + "{\"title\":\"Call of the wild III\",\"extraDetails\":null," + + "\"review\":{\"reviewerId\":\"John\",\"reviewerContent\":\"Good read.\"}}"), + String.class) + .exchange() + .expectStatus() + .is2xxSuccessful(); + + this.webTestClient + .get() + .uri("/list") + .exchange() + .expectBody(Book[].class) + .value( + books -> { + assertThat(books).hasSize(2); + for (Book book : books) { + if (book.getTitle().equals("Call of the wild II")) { + assertThat(book.getExtraDetails()).containsEntry("rating", "8"); + assertThat(book.getExtraDetails()).containsEntry("series", "yes"); + } + if (book.getTitle().equals("Call of the wild III")) { + assertThat(book.getReview()).isEqualTo(new Review("John", "Good read.")); + } + } + }); + } +} diff --git a/spring-cloud-spanner-spring-data-r2dbc/pom.xml b/spring-cloud-spanner-spring-data-r2dbc/pom.xml new file mode 100644 index 0000000000..0276f9dacd --- /dev/null +++ b/spring-cloud-spanner-spring-data-r2dbc/pom.xml @@ -0,0 +1,34 @@ + + + + spring-cloud-gcp + com.google.cloud + 4.6.1-SNAPSHOT + + 4.0.0 + + Spring Framework on Google Cloud Module - Cloud Spanner R2DBC Spring Data Dialect + spring-cloud-spanner-spring-data-r2dbc + + + + com.google.cloud + cloud-spanner-r2dbc + + + + org.springframework.data + spring-data-r2dbc + + + + io.projectreactor + reactor-test + test + + + + + \ No newline at end of file diff --git a/spring-cloud-spanner-spring-data-r2dbc/src/main/java/com/google/cloud/spanner/r2dbc/springdata/JsonToMapConverter.java b/spring-cloud-spanner-spring-data-r2dbc/src/main/java/com/google/cloud/spanner/r2dbc/springdata/JsonToMapConverter.java new file mode 100644 index 0000000000..0e384b33ff --- /dev/null +++ b/spring-cloud-spanner-spring-data-r2dbc/src/main/java/com/google/cloud/spanner/r2dbc/springdata/JsonToMapConverter.java @@ -0,0 +1,42 @@ +/* + * Copyright 2021-2021 Google LLC + * + * 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.spanner.r2dbc.springdata; + +import com.google.cloud.spanner.r2dbc.v2.JsonWrapper; +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.convert.ReadingConverter; + +/** JsonWrapper to Map converter. */ +@ReadingConverter +public class JsonToMapConverter implements Converter> { + + private final Gson gson; + + @Autowired + public JsonToMapConverter(Gson gson) { + this.gson = gson; + } + + @Override + public Map convert(JsonWrapper json) throws JsonSyntaxException { + return this.gson.fromJson(json.toString(), Map.class); + } +} diff --git a/spring-cloud-spanner-spring-data-r2dbc/src/main/java/com/google/cloud/spanner/r2dbc/springdata/MapToJsonConverter.java b/spring-cloud-spanner-spring-data-r2dbc/src/main/java/com/google/cloud/spanner/r2dbc/springdata/MapToJsonConverter.java new file mode 100644 index 0000000000..d580410d44 --- /dev/null +++ b/spring-cloud-spanner-spring-data-r2dbc/src/main/java/com/google/cloud/spanner/r2dbc/springdata/MapToJsonConverter.java @@ -0,0 +1,43 @@ +/* + * Copyright 2021-2021 Google LLC + * + * 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.spanner.r2dbc.springdata; + +import com.google.cloud.spanner.r2dbc.v2.JsonWrapper; +import com.google.gson.Gson; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.convert.WritingConverter; + +/** + * Map to JsonWrapper Converter. + */ +@WritingConverter +public class MapToJsonConverter implements Converter, JsonWrapper> { + + private final Gson gson; + + @Autowired + public MapToJsonConverter(Gson gson) { + this.gson = gson; + } + + @Override + public JsonWrapper convert(Map source) { + return JsonWrapper.of(this.gson.toJson(source)); + } +} diff --git a/spring-cloud-spanner-spring-data-r2dbc/src/main/java/com/google/cloud/spanner/r2dbc/springdata/SpannerArrayColumns.java b/spring-cloud-spanner-spring-data-r2dbc/src/main/java/com/google/cloud/spanner/r2dbc/springdata/SpannerArrayColumns.java new file mode 100644 index 0000000000..c0bcf5071f --- /dev/null +++ b/spring-cloud-spanner-spring-data-r2dbc/src/main/java/com/google/cloud/spanner/r2dbc/springdata/SpannerArrayColumns.java @@ -0,0 +1,36 @@ +/* + * Copyright 2022-2022 Google LLC + * + * 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.spanner.r2dbc.springdata; + +import org.springframework.data.relational.core.dialect.ArrayColumns; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +class SpannerArrayColumns implements ArrayColumns { + + @Override + public boolean isSupported() { + return true; + } + + @Override + public Class getArrayType(Class userType) { + Assert.notNull(userType, "Array component type must not be null"); + + return ClassUtils.resolvePrimitiveIfNecessary(userType); + } +} diff --git a/spring-cloud-spanner-spring-data-r2dbc/src/main/java/com/google/cloud/spanner/r2dbc/springdata/SpannerBindMarkerFactoryProvider.java b/spring-cloud-spanner-spring-data-r2dbc/src/main/java/com/google/cloud/spanner/r2dbc/springdata/SpannerBindMarkerFactoryProvider.java new file mode 100644 index 0000000000..cd979f25bc --- /dev/null +++ b/spring-cloud-spanner-spring-data-r2dbc/src/main/java/com/google/cloud/spanner/r2dbc/springdata/SpannerBindMarkerFactoryProvider.java @@ -0,0 +1,36 @@ +/* + * Copyright 2021-2021 Google LLC + * + * 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.spanner.r2dbc.springdata; + +import com.google.cloud.spanner.r2dbc.SpannerConnectionFactoryMetadata; +import io.r2dbc.spi.ConnectionFactory; +import org.springframework.r2dbc.core.binding.BindMarkersFactory; +import org.springframework.r2dbc.core.binding.BindMarkersFactoryResolver.BindMarkerFactoryProvider; + +/** + * Provides the named bind marker strategy for Cloud Spanner. + */ +public class SpannerBindMarkerFactoryProvider implements BindMarkerFactoryProvider { + + @Override + public BindMarkersFactory getBindMarkers(ConnectionFactory connectionFactory) { + if (SpannerConnectionFactoryMetadata.INSTANCE.equals(connectionFactory.getMetadata())) { + return SpannerR2dbcDialect.NAMED; + } + return null; + } +} diff --git a/spring-cloud-spanner-spring-data-r2dbc/src/main/java/com/google/cloud/spanner/r2dbc/springdata/SpannerR2dbcDialect.java b/spring-cloud-spanner-spring-data-r2dbc/src/main/java/com/google/cloud/spanner/r2dbc/springdata/SpannerR2dbcDialect.java new file mode 100644 index 0000000000..0d546213b9 --- /dev/null +++ b/spring-cloud-spanner-spring-data-r2dbc/src/main/java/com/google/cloud/spanner/r2dbc/springdata/SpannerR2dbcDialect.java @@ -0,0 +1,122 @@ +/* + * Copyright 2019-2020 Google LLC + * + * 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.spanner.r2dbc.springdata; + +import com.google.cloud.ByteArray; +import com.google.cloud.Date; +import com.google.cloud.Timestamp; +import com.google.cloud.spanner.r2dbc.v2.JsonWrapper; +import com.google.gson.Gson; +import java.util.Arrays; +import java.util.Collection; +import org.springframework.data.r2dbc.dialect.R2dbcDialect; +import org.springframework.data.relational.core.dialect.AbstractDialect; +import org.springframework.data.relational.core.dialect.ArrayColumns; +import org.springframework.data.relational.core.dialect.LimitClause; +import org.springframework.data.relational.core.dialect.LockClause; +import org.springframework.data.relational.core.sql.LockOptions; +import org.springframework.r2dbc.core.binding.BindMarkersFactory; + +/** + * The {@link R2dbcDialect} implementation which enables usage of Spring Data R2DBC with Cloud + * Spanner. + */ +public class SpannerR2dbcDialect extends AbstractDialect implements R2dbcDialect { + static final BindMarkersFactory NAMED = + BindMarkersFactory.named("@", "val", 32); + + public static final String SQL_LIMIT = "LIMIT "; + + private Gson gson = new Gson(); + + private static final LimitClause LIMIT_CLAUSE = new LimitClause() { + @Override + public String getLimit(long limit) { + return SQL_LIMIT + limit; + } + + @Override + public String getOffset(long offset) { + return SQL_LIMIT + Long.MAX_VALUE + " OFFSET " + offset; + } + + @Override + public String getLimitOffset(long limit, long offset) { + return SQL_LIMIT + limit + " OFFSET " + offset; + } + + @Override + public Position getClausePosition() { + return Position.AFTER_ORDER_BY; + } + }; + + /** + * Pessimistic locking is not supported. + * Spanner has a LOCK_SCANNED_RANGES hint, but it appears before SELECT, a position not currently + * supported in LockClause.Position + */ + private static final LockClause LOCK_CLAUSE = new LockClause() { + @Override + public String getLock(LockOptions lockOptions) { + return ""; + } + + @Override + public Position getClausePosition() { + // It does not matter where to append an empty string. + return Position.AFTER_FROM_TABLE; + } + }; + + @Override + public BindMarkersFactory getBindMarkersFactory() { + return NAMED; + } + + @Override + public LimitClause limit() { + return LIMIT_CLAUSE; + } + + @Override + public LockClause lock() { + return LOCK_CLAUSE; + } + + @Override + public Collection> getSimpleTypes() { + return Arrays.asList( + JsonWrapper.class, + Timestamp.class, + ByteArray.class, + Date.class); + } + + @Override + public Collection getConverters() { + return Arrays.asList( + new JsonToMapConverter<>(this.gson), + new MapToJsonConverter<>(this.gson)); + } + + @Override + public ArrayColumns getArraySupport() { + return new SpannerArrayColumns(); + } + +} \ No newline at end of file diff --git a/spring-cloud-spanner-spring-data-r2dbc/src/main/java/com/google/cloud/spanner/r2dbc/springdata/SpannerR2dbcDialectProvider.java b/spring-cloud-spanner-spring-data-r2dbc/src/main/java/com/google/cloud/spanner/r2dbc/springdata/SpannerR2dbcDialectProvider.java new file mode 100644 index 0000000000..aac2f81114 --- /dev/null +++ b/spring-cloud-spanner-spring-data-r2dbc/src/main/java/com/google/cloud/spanner/r2dbc/springdata/SpannerR2dbcDialectProvider.java @@ -0,0 +1,37 @@ +/* + * Copyright 2019-2020 Google LLC + * + * 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.spanner.r2dbc.springdata; + +import com.google.cloud.spanner.r2dbc.SpannerConnectionFactoryMetadata; +import io.r2dbc.spi.ConnectionFactory; +import java.util.Optional; +import org.springframework.data.r2dbc.dialect.DialectResolver.R2dbcDialectProvider; +import org.springframework.data.r2dbc.dialect.R2dbcDialect; + +/** + * Provides the {@link SpannerR2dbcDialect} for Spring Data. + */ +public class SpannerR2dbcDialectProvider implements R2dbcDialectProvider { + + @Override + public Optional getDialect(ConnectionFactory connectionFactory) { + if (connectionFactory.getMetadata().getName().equals(SpannerConnectionFactoryMetadata.NAME)) { + return Optional.of(new SpannerR2dbcDialect()); + } + return Optional.empty(); + } +} diff --git a/spring-cloud-spanner-spring-data-r2dbc/src/main/resources/META-INF/spring.factories b/spring-cloud-spanner-spring-data-r2dbc/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000000..295ae067cb --- /dev/null +++ b/spring-cloud-spanner-spring-data-r2dbc/src/main/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +org.springframework.data.r2dbc.dialect.DialectResolver$R2dbcDialectProvider=\ +com.google.cloud.spanner.r2dbc.springdata.SpannerR2dbcDialectProvider +org.springframework.r2dbc.core.binding.BindMarkersFactoryResolver$BindMarkerFactoryProvider=com.google.cloud.spanner.r2dbc.springdata.SpannerBindMarkerFactoryProvider diff --git a/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/MapJsonConverterTest.java b/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/MapJsonConverterTest.java new file mode 100644 index 0000000000..44b72e4b7e --- /dev/null +++ b/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/MapJsonConverterTest.java @@ -0,0 +1,63 @@ +/* + * Copyright 2020-2021 Google LLC + * + * 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.spanner.r2dbc.springdata; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.google.cloud.spanner.r2dbc.v2.JsonWrapper; +import com.google.common.collect.ImmutableMap; +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Test for converters. */ +class MapJsonConverterTest { + private Gson gson = new Gson(); + + @Test + void jsonToMapConverterTest() { + JsonToMapConverter jsonToMapConverter = new JsonToMapConverter(this.gson); + Map resultMap = + jsonToMapConverter.convert( + JsonWrapper.of("{\"a\":\"a string\",\"b\":9, \"c\" : 12.537, \"d\" : true}")); + assertThat(resultMap) + .isInstanceOf(Map.class) + .hasSize(4) + .containsEntry("a", "a string") + .containsEntry("b", 9.0) + .containsEntry("c", 12.537) + .containsEntry("d", true); + + // Convert should fail: duplicate keys not allowed + JsonWrapper invalidJsonToConvert = JsonWrapper.of("{\"a\":\"a string\"," + + " \"a\":\"another string\"}"); + assertThatThrownBy( + () -> jsonToMapConverter.convert(invalidJsonToConvert)) + .isInstanceOf(JsonSyntaxException.class); + } + + @Test + void mapToJsonConverterTest() { + MapToJsonConverter mapToJsonConverter = new MapToJsonConverter(this.gson); + Map mapToConvert = + ImmutableMap.of("a", "a string", "b", 9, "c", 12.537, "d", true); + assertThat(mapToJsonConverter.convert(mapToConvert)) + .isEqualTo(JsonWrapper.of("{\"a\":\"a string\",\"b\":9,\"c\":12.537,\"d\":true}")); + } +} diff --git a/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/SpannerArrayColumnsTest.java b/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/SpannerArrayColumnsTest.java new file mode 100644 index 0000000000..2fc732e423 --- /dev/null +++ b/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/SpannerArrayColumnsTest.java @@ -0,0 +1,35 @@ +/* + * Copyright 2023-2024 Google LLC + * + * 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.spanner.r2dbc.springdata; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class SpannerArrayColumnsTest { + @Test + void isSupportedTest() { + SpannerArrayColumns spannerArrayColumns = new SpannerArrayColumns(); + assertThat(spannerArrayColumns.isSupported()).isTrue(); + } + + @Test + void arrayTypeTest() { + SpannerArrayColumns spannerArrayColumns = new SpannerArrayColumns(); + assertThat(spannerArrayColumns.getArrayType(Object.class)).isEqualTo(Object.class); + } +} diff --git a/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/SpannerBindMarkerFactoryProviderTest.java b/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/SpannerBindMarkerFactoryProviderTest.java new file mode 100644 index 0000000000..5dfa05b770 --- /dev/null +++ b/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/SpannerBindMarkerFactoryProviderTest.java @@ -0,0 +1,63 @@ +/* + * Copyright 2021-2021 Google LLC + * + * 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.spanner.r2dbc.springdata; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.cloud.spanner.Spanner; +import com.google.cloud.spanner.SpannerOptions; +import com.google.cloud.spanner.r2dbc.SpannerConnectionConfiguration; +import com.google.cloud.spanner.r2dbc.v2.SpannerClientLibraryConnectionFactory; +import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.ConnectionFactoryMetadata; +import org.junit.jupiter.api.Test; +import org.springframework.r2dbc.core.binding.BindMarkersFactory; + +class SpannerBindMarkerFactoryProviderTest { + + @Test + void spannerBindMarkersFoundForV2ConnectionFactory() { + SpannerBindMarkerFactoryProvider provider = new SpannerBindMarkerFactoryProvider(); + SpannerConnectionConfiguration mockConfig = mock(SpannerConnectionConfiguration.class); + SpannerOptions mockSpannerOptions = mock(SpannerOptions.class); + Spanner mockService = mock(Spanner.class); + + when(mockConfig.buildSpannerOptions()).thenReturn(mockSpannerOptions); + when(mockSpannerOptions.getService()).thenReturn(mockService); + + SpannerClientLibraryConnectionFactory cf = + new SpannerClientLibraryConnectionFactory(mockConfig); + + BindMarkersFactory factory = provider.getBindMarkers(cf); + assertThat(factory).isSameAs(SpannerR2dbcDialect.NAMED); + } + + @Test + void spannerBindMarkersNotFoundForUnknownFactory() { + SpannerBindMarkerFactoryProvider provider = new SpannerBindMarkerFactoryProvider(); + ConnectionFactory cf = mock(ConnectionFactory.class); + ConnectionFactoryMetadata mockMetadata = mock(ConnectionFactoryMetadata.class); + when(cf.getMetadata()).thenReturn(mockMetadata); + when(mockMetadata.getName()).thenReturn("SOME_DATABASE"); + + BindMarkersFactory factory = provider.getBindMarkers(cf); + assertThat(factory).isNull(); + } + +} diff --git a/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/SpannerR2dbcDialectTest.java b/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/SpannerR2dbcDialectTest.java new file mode 100644 index 0000000000..e4a3de44f9 --- /dev/null +++ b/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/SpannerR2dbcDialectTest.java @@ -0,0 +1,95 @@ +/* + * Copyright 2020-2021 Google LLC + * + * 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.spanner.r2dbc.springdata; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.cloud.ByteArray; +import com.google.cloud.Date; +import com.google.cloud.Timestamp; +import com.google.cloud.spanner.r2dbc.v2.JsonWrapper; +import java.util.Collection; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.data.relational.core.dialect.LimitClause; +import org.springframework.data.relational.core.dialect.LockClause; +import org.springframework.data.relational.core.sql.LockMode; +import org.springframework.data.relational.core.sql.LockOptions; +import org.springframework.data.relational.core.sql.SQL; +import org.springframework.data.relational.core.sql.Select; +import org.springframework.data.relational.core.sql.Table; +import org.springframework.r2dbc.core.binding.BindMarkers; + +class SpannerR2dbcDialectTest { + + @Test + void testLimitClause() { + LimitClause clause = new SpannerR2dbcDialect().limit(); + assertThat(clause.getOffset(100)).isEqualTo("LIMIT 9223372036854775807 OFFSET 100"); + assertThat(clause.getLimit(42)).isEqualTo("LIMIT 42"); + assertThat(clause.getLimitOffset(42, 100)).isEqualTo("LIMIT 42 OFFSET 100"); + assertThat(clause.getClausePosition()).isSameAs(LimitClause.Position.AFTER_ORDER_BY); + } + + @Test + void testBindMarkersFactory() { + SpannerR2dbcDialect dialect = new SpannerR2dbcDialect(); + BindMarkers bindMarkers = dialect.getBindMarkersFactory().create(); + assertThat(bindMarkers).isNotNull(); + assertThat(bindMarkers.next().getPlaceholder()).isEqualTo("@val0"); + assertThat(bindMarkers.next().getPlaceholder()).isEqualTo("@val1"); + } + + @Test + void lockStringAlwaysEmpty() { + SpannerR2dbcDialect dialect = new SpannerR2dbcDialect(); + Table table = SQL.table("aTable"); + Select sql = Select.builder().select(table.column("aColumn")) + .from(table) + .build(); + LockOptions lockOptions = new LockOptions(LockMode.PESSIMISTIC_READ, sql.getFrom()); + + LockClause lock = dialect.lock(); + + assertNotNull(lock); + assertThat(lock.getLock(lockOptions)).isEmpty(); + assertThat(lock.getClausePosition()).isSameAs(LockClause.Position.AFTER_FROM_TABLE); + } + + @Test + void testSimpleType() { + SpannerR2dbcDialect dialect = new SpannerR2dbcDialect(); + SimpleTypeHolder simpleTypeHolder = dialect.getSimpleTypeHolder(); + assertThat(Stream.of(JsonWrapper.class, Timestamp.class, Date.class, ByteArray.class) + .allMatch(simpleTypeHolder::isSimpleType)).isTrue(); + } + + @Test + void testConverter() { + SpannerR2dbcDialect dialect = new SpannerR2dbcDialect(); + Collection converters = dialect.getConverters(); + assertTrue( + converters.stream() + .anyMatch(converter -> converter.getClass().equals(JsonToMapConverter.class))); + assertTrue( + converters.stream() + .anyMatch(converter -> converter.getClass().equals(MapToJsonConverter.class))); + } +} diff --git a/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/it/SpannerR2dbcDialectDateTimeBindingIntegrationTest.java b/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/it/SpannerR2dbcDialectDateTimeBindingIntegrationTest.java new file mode 100644 index 0000000000..a0c24a344b --- /dev/null +++ b/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/it/SpannerR2dbcDialectDateTimeBindingIntegrationTest.java @@ -0,0 +1,232 @@ +/* + * Copyright 2022-2022 Google LLC + * + * 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.spanner.r2dbc.springdata.it; + +import static com.google.cloud.spanner.r2dbc.SpannerConnectionFactoryProvider.INSTANCE; +import static com.google.cloud.spanner.r2dbc.SpannerConnectionFactoryProvider.PROJECT; +import static io.r2dbc.spi.ConnectionFactoryOptions.DATABASE; +import static io.r2dbc.spi.ConnectionFactoryOptions.DRIVER; + +import com.google.cloud.Date; +import com.google.cloud.ServiceOptions; +import com.google.cloud.Timestamp; +import com.google.cloud.spanner.r2dbc.springdata.SpannerR2dbcDialect; +import com.google.cloud.spanner.r2dbc.springdata.it.entities.Card; +import io.r2dbc.spi.ConnectionFactories; +import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.ConnectionFactoryOptions; +import io.r2dbc.spi.Option; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.Arrays; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.extension.ExtendWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.convert.ReadingConverter; +import org.springframework.data.convert.WritingConverter; +import org.springframework.data.r2dbc.config.AbstractR2dbcConfiguration; +import org.springframework.data.r2dbc.convert.R2dbcCustomConversions; +import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Integration tests verifying the {@link Timestamp},{@link Date} support of {@link + * SpannerR2dbcDialect}. + */ +@EnabledIfSystemProperty(named = "it.spanner", matches = "true") +@ExtendWith(SpringExtension.class) +@ContextConfiguration( + classes = SpannerR2dbcDialectDateTimeBindingIntegrationTest.TestConfiguration.class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SpannerR2dbcDialectDateTimeBindingIntegrationTest { + + private static final Logger log = + LoggerFactory.getLogger(SpannerR2dbcDialectDateTimeBindingIntegrationTest.class); + + private static final String PROJECT_NAME = + System.getProperty("gcp.project", ServiceOptions.getDefaultProjectId()); + private static final String DRIVER_NAME = "spanner"; + + private static final String TEST_INSTANCE = + System.getProperty("spanner.instance", "reactivetest"); + + private static final String TEST_DATABASE = System.getProperty("spanner.database", "testdb"); + + private static final ConnectionFactory connectionFactory = + ConnectionFactories.get( + ConnectionFactoryOptions.builder() + .option(Option.valueOf("project"), ServiceOptions.getDefaultProjectId()) + .option(PROJECT, PROJECT_NAME) + .option(DRIVER, DRIVER_NAME) + .option(INSTANCE, TEST_INSTANCE) + .option(DATABASE, TEST_DATABASE) + .build()); + + @Autowired private R2dbcEntityTemplate r2dbcEntityTemplate; + + /** Initializes the integration test environment for the Spanner R2DBC dialect. */ + @BeforeAll + static void initializeTestEnvironment() { + Mono.from(connectionFactory.create()) + .flatMap( + c -> + Mono.from(c.createStatement("drop table card").execute()) + .doOnSuccess(x -> log.info("Table drop completed.")) + .doOnError( + x -> { + if (!x.getMessage().contains("Table not found")) { + log.info("Table drop failed. {}", x.getMessage()); + } + }) + .onErrorResume(x -> Mono.empty()) + .thenReturn(c)) + .flatMap( + c -> + Mono.from( + c.createStatement( + "create table card (" + + " id int64 not null," + + " expiry_year int64 not null," + + " expiry_month int64 not null," + + " issue_date date not null," + + " requested_at timestamp not null" + + ") primary key (id)") + .execute()) + .doOnSuccess(x -> log.info("Table creation completed."))) + .block(); + } + + @AfterAll + static void cleanupTableAfterTest() { + Mono.from(connectionFactory.create()) + .flatMap( + c -> + Mono.from(c.createStatement("drop table card").execute()) + .doOnSuccess(x -> log.info("Table drop completed.")) + .doOnError(x -> log.info("Table drop failed."))) + .block(); + } + + @Test + void shouldReadWriteDateAndTimestampTypes() { + Card card = + new Card( + 1L, + 2022, + 12, + LocalDate.parse("2021-12-31"), + LocalDateTime.parse("2021-12-15T21:30:10")); + + this.r2dbcEntityTemplate + .insert(Card.class) + .using(card) + .then() + .as(StepVerifier::create) + .verifyComplete(); + + this.r2dbcEntityTemplate + .select(Card.class) + .first() + .as(StepVerifier::create) + .expectNextMatches( + c -> + c.getId() == 1L + && c.getExpiryYear() == 2022 + && c.getExpiryMonth() == 12 + && c.getIssueDate().equals(LocalDate.parse("2021-12-31")) + && c.getRequestedAt().equals(LocalDateTime.parse("2021-12-15T21:30:10"))) + .verifyComplete(); + } + + /** Register custom converters. */ + @Configuration + static class TestConfiguration extends AbstractR2dbcConfiguration { + + @Autowired ApplicationContext applicationContext; + + @Override + public ConnectionFactory connectionFactory() { + return connectionFactory; + } + + @Bean + @Override + public R2dbcCustomConversions r2dbcCustomConversions() { + return new R2dbcCustomConversions( + getStoreConversions(), + Arrays.asList( + new DateToLocalDateConverter(), + new LocalDateToDateConverter(), + new LocalDateTimeToTimestampConverter(), + new TimestampToLocalDateTimeConverter())); + } + } + + /** {@link Date} to {@link LocalDate} reading converter. */ + @ReadingConverter + static class DateToLocalDateConverter implements Converter { + + @Override + public LocalDate convert(Date row) { + return LocalDate.parse(row.toString()); + } + } + + /** {@link LocalDate} to {@link Date} writing converter. */ + @WritingConverter + static class LocalDateToDateConverter implements Converter { + + @Override + public Date convert(LocalDate source) { + return Date.parseDate(source.toString()); + } + } + + /** {@link Timestamp} to {@link LocalDateTime} reading converter. */ + @ReadingConverter + static class TimestampToLocalDateTimeConverter implements Converter { + + @Override + public LocalDateTime convert(Timestamp row) { + return OffsetDateTime.parse(row.toString()).toLocalDateTime(); + } + } + + /** {@link LocalDateTime} to {@link Timestamp} writing converter. */ + @WritingConverter + static class LocalDateTimeToTimestampConverter implements Converter { + + @Override + public Timestamp convert(LocalDateTime source) { + return Timestamp.parseTimestamp(source.toString()); + } + } +} diff --git a/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/it/SpannerR2dbcDialectIntegrationTest.java b/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/it/SpannerR2dbcDialectIntegrationTest.java new file mode 100644 index 0000000000..afa013bdc9 --- /dev/null +++ b/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/it/SpannerR2dbcDialectIntegrationTest.java @@ -0,0 +1,180 @@ +/* + * Copyright 2019-2020 Google LLC + * + * 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.spanner.r2dbc.springdata.it; + +import static com.google.cloud.spanner.r2dbc.SpannerConnectionFactoryProvider.INSTANCE; +import static io.r2dbc.spi.ConnectionFactoryOptions.DATABASE; +import static io.r2dbc.spi.ConnectionFactoryOptions.DRIVER; + +import com.google.cloud.ServiceOptions; +import com.google.cloud.spanner.r2dbc.springdata.it.entities.President; +import io.r2dbc.spi.Connection; +import io.r2dbc.spi.ConnectionFactories; +import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.ConnectionFactoryOptions; +import io.r2dbc.spi.Option; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; +import org.springframework.data.relational.core.query.Query; +import org.springframework.r2dbc.core.DatabaseClient; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Integration tests for the Spring Data R2DBC dialect. + * + *

By default, the test is configured to run tests in the `reactivetest` instance on the + * `testdb` database. This can be configured by overriding the `spanner.instance` and + * `spanner.database` system properties. + */ +@EnabledIfSystemProperty(named = "it.spanner", matches = "true") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SpannerR2dbcDialectIntegrationTest { + + private static final Logger logger = + LoggerFactory.getLogger(SpannerR2dbcDialectIntegrationTest.class); + + private static final String DRIVER_NAME = "spanner"; + + private static final String TEST_INSTANCE = + System.getProperty("spanner.instance", "reactivetest"); + + private static final String TEST_DATABASE = + System.getProperty("spanner.database", "testdb"); + + private static final ConnectionFactory connectionFactory = + ConnectionFactories.get(ConnectionFactoryOptions.builder() + .option(Option.valueOf("project"), ServiceOptions.getDefaultProjectId()) + .option(DRIVER, DRIVER_NAME) + .option(INSTANCE, TEST_INSTANCE) + .option(DATABASE, TEST_DATABASE) + .build()); + + private DatabaseClient databaseClient; + + private R2dbcEntityTemplate r2dbcEntityTemplate; + + /** + * Initializes the integration test environment for the Spanner R2DBC dialect. + */ + @BeforeAll + public void initializeTestEnvironment() { + Connection connection = Mono.from(connectionFactory.create()).block(); + + this.r2dbcEntityTemplate = new R2dbcEntityTemplate(connectionFactory); + this.databaseClient = this.r2dbcEntityTemplate.getDatabaseClient(); + + if (SpannerTestUtils.tableExists(connection, "PRESIDENT")) { + this.databaseClient.sql("DROP TABLE PRESIDENT") + .fetch() + .rowsUpdated() + .block(); + } + + this.databaseClient.sql( + "CREATE TABLE PRESIDENT (" + + " NAME STRING(256) NOT NULL," + + " START_YEAR INT64 NOT NULL" + + ") PRIMARY KEY (NAME)") + .fetch() + .rowsUpdated() + .block(); + } + + @AfterEach + public void cleanupTableAfterTest() { + this.databaseClient + .sql("DELETE FROM PRESIDENT where NAME is not null") + .fetch() + .rowsUpdated() + .block(); + } + + @Test + void testReadWrite() { + insertPresident(new President("Bill Clinton", 1992)); + + this.r2dbcEntityTemplate.select(President.class) + .first() + .as(StepVerifier::create) + .expectNextMatches( + president -> president.getName().equals("Bill Clinton") + && president.getStartYear() == 1992) + .verifyComplete(); + } + + @Test + void testLimitOffsetSupport() { + insertPresident(new President("Bill Clinton", 1992)); + insertPresident(new President("Joe Smith", 1996)); + insertPresident(new President("Bob", 2000)); + insertPresident(new President("Hello", 2004)); + insertPresident(new President("George Washington", 2008)); + + this.r2dbcEntityTemplate.select(President.class) + .matching( + Query.empty() + .sort(Sort.by(Direction.ASC, "name")) + .with(PageRequest.of(0, 2)) + ) + // Get the page at index 1; 2 elements per page. + //.page() + .all() + .as(StepVerifier::create) + .expectNextMatches(president -> president.getName().equals("Bill Clinton")) + .expectNextMatches(president -> president.getName().equals("Bob")) + .verifyComplete(); + } + + @Test + void testRowMap() { + insertPresident(new President("Bill Clinton", 1992)); + insertPresident(new President("Joe Smith", 1996)); + insertPresident(new President("Bob", 2000)); + insertPresident(new President("Hello", 2004)); + insertPresident(new President("George Washington", 2008)); + + this.r2dbcEntityTemplate.select(President.class) + .matching( + Query.empty() + .sort(Sort.by(Direction.ASC, "name"))) + .all() + .map(president -> president.getName()) + .as(StepVerifier::create) + .expectNext( + "Bill Clinton", "Bob", "George Washington", "Hello", "Joe Smith") + .verifyComplete(); + } + + private void insertPresident(President president) { + this.r2dbcEntityTemplate + .insert(President.class) + .using(president) + .then() + .as(StepVerifier::create) + .verifyComplete(); + } +} diff --git a/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/it/SpannerR2dbcDialectJsonIntegrationTest.java b/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/it/SpannerR2dbcDialectJsonIntegrationTest.java new file mode 100644 index 0000000000..c28de14894 --- /dev/null +++ b/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/it/SpannerR2dbcDialectJsonIntegrationTest.java @@ -0,0 +1,294 @@ +/* + * Copyright 2021-2021 Google LLC + * + * 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.spanner.r2dbc.springdata.it; + +import static com.google.cloud.spanner.r2dbc.SpannerConnectionFactoryProvider.INSTANCE; +import static com.google.cloud.spanner.r2dbc.SpannerConnectionFactoryProvider.PROJECT; +import static io.r2dbc.spi.ConnectionFactoryOptions.DATABASE; +import static io.r2dbc.spi.ConnectionFactoryOptions.DRIVER; + +import com.google.cloud.ServiceOptions; +import com.google.cloud.spanner.r2dbc.springdata.it.entities.Address; +import com.google.cloud.spanner.r2dbc.springdata.it.entities.Person; +import com.google.cloud.spanner.r2dbc.v2.JsonWrapper; +import com.google.common.math.DoubleMath; +import com.google.gson.Gson; +import com.google.gson.JsonParseException; +import io.r2dbc.spi.Connection; +import io.r2dbc.spi.ConnectionFactories; +import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.ConnectionFactoryOptions; +import io.r2dbc.spi.Option; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.convert.ReadingConverter; +import org.springframework.data.convert.WritingConverter; +import org.springframework.data.r2dbc.config.AbstractR2dbcConfiguration; +import org.springframework.data.r2dbc.convert.R2dbcCustomConversions; +import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; +import org.springframework.r2dbc.core.DatabaseClient; +import org.springframework.stereotype.Component; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Integration tests for the Spring Data R2DBC dialect Json support. + * + *

By default, the test is configured to run tests in the `reactivetest` instance on the `testdb` + * database. This can be configured by overriding the `spanner.instance` and `spanner.database` + * system properties. + */ +@EnabledIfSystemProperty(named = "it.spanner", matches = "true") +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = SpannerR2dbcDialectJsonIntegrationTest.TestConfiguration.class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SpannerR2dbcDialectJsonIntegrationTest { + + private static final String PROJECT_NAME = + System.getProperty("gcp.project", ServiceOptions.getDefaultProjectId()); + private static final String DRIVER_NAME = "spanner"; + + private static final String TEST_INSTANCE = + System.getProperty("spanner.instance", "reactivetest"); + + private static final String TEST_DATABASE = System.getProperty("spanner.database", "testdb"); + + private static final ConnectionFactory connectionFactory = + ConnectionFactories.get( + ConnectionFactoryOptions.builder() + .option(Option.valueOf("project"), ServiceOptions.getDefaultProjectId()) + .option(PROJECT, PROJECT_NAME) + .option(DRIVER, DRIVER_NAME) + .option(INSTANCE, TEST_INSTANCE) + .option(DATABASE, TEST_DATABASE) + .build()); + + private DatabaseClient databaseClient; + + @Autowired private R2dbcEntityTemplate r2dbcEntityTemplate; + + /** Initializes the integration test environment for the Spanner R2DBC dialect. */ + @BeforeAll + public void initializeTestEnvironment() { + Connection connection = Mono.from(connectionFactory.create()).block(); + + this.databaseClient = this.r2dbcEntityTemplate.getDatabaseClient(); + + if (SpannerTestUtils.tableExists(connection, "PERSON")) { + this.databaseClient.sql("DROP TABLE PERSON").fetch().rowsUpdated().block(); + } + + this.databaseClient + .sql( + "CREATE TABLE PERSON (" + + " NAME STRING(256) NOT NULL," + + " BIRTH_YEAR INT64 NOT NULL," + + " EXTRAS JSON," + + " ADDRESS JSON" + + ") PRIMARY KEY (NAME)") + .fetch() + .rowsUpdated() + .block(); + } + + @AfterEach + public void cleanupTableAfterTest() { + this.databaseClient + .sql("DELETE FROM PERSON where NAME is not null") + .fetch() + .rowsUpdated() + .block(); + } + + private void insertPerson(Person person) { + this.r2dbcEntityTemplate + .insert(Person.class) + .using(person) + .then() + .as(StepVerifier::create) + .verifyComplete(); + } + + @Test + void testReadWriteWithJsonFieldString() { + Map extras = new HashMap<>(); + extras.put("bio", "former U.S. president"); + extras.put("spouse", "Hillary Clinton"); + Person billClinton = new Person("Bill Clinton", 1946, extras, null); + insertPerson(billClinton); + + this.r2dbcEntityTemplate + .select(Person.class) + .first() + .as(StepVerifier::create) + .expectNextMatches( + person -> + person.getName().equals("Bill Clinton") + && person.getBirthYear() == 1946 + && person.getExtras().getOrDefault("spouse", "none").equals("Hillary Clinton") + && person + .getExtras() + .getOrDefault("bio", "none") + .equals("former U.S. president")) + .verifyComplete(); + } + + @Test + void testReadWriteWithJsonFieldBoolean() { + Map extras = new HashMap<>(); + extras.put("male", true); + extras.put("US citizen", true); + Person billClinton = new Person("Bill Clinton", 1946, extras, null); + insertPerson(billClinton); + + this.r2dbcEntityTemplate + .select(Person.class) + .first() + .as(StepVerifier::create) + .expectNextMatches( + person -> + person.getName().equals("Bill Clinton") + && person.getBirthYear() == 1946 + && person.getExtras().getOrDefault("male", false).equals(true) + && person.getExtras().getOrDefault("US citizen", false).equals(true)) + .verifyComplete(); + } + + @Test + void testReadWriteWithJsonFieldDouble() { + Map extras = new HashMap<>(); + extras.put("weight", 144.5); + extras.put("height", 5.916); + Person billClinton = new Person("John Doe", 1946, extras, null); + insertPerson(billClinton); + + this.r2dbcEntityTemplate + .select(Person.class) + .first() + .as(StepVerifier::create) + .expectNextMatches( + person -> + person.getName().equals("John Doe") + && person.getBirthYear() == 1946 + && DoubleMath.fuzzyEquals( + (double) person.getExtras().get("weight"), 144.5, 1e-5) + && DoubleMath.fuzzyEquals( + (double) person.getExtras().get("height"), 5.916, 1e-5)) + .verifyComplete(); + } + + @Test + void testReadWriteWithJsonFieldCustomClass() { + Address address = new Address("home address", "work address", 10012, 10011); + Person billClinton = new Person("Bill Clinton", 1946, null, address); + insertPerson(billClinton); + + this.r2dbcEntityTemplate + .select(Person.class) + .first() + .as(StepVerifier::create) + .expectNextMatches( + person -> + person.getName().equals("Bill Clinton") + && person.getBirthYear() == 1946 + && person.getAddress().equals(address)) + .verifyComplete(); + } + + /** Register custom converters between Map and JsonWrapper. */ + @Configuration + static class TestConfiguration extends AbstractR2dbcConfiguration { + + @Autowired ApplicationContext applicationContext; + + @Override + public ConnectionFactory connectionFactory() { + return connectionFactory; + } + + @Bean + public Gson gson() { + return new Gson(); + } + + @Bean + @Override + public R2dbcCustomConversions r2dbcCustomConversions() { + List> converters = new ArrayList<>(); + converters.add(this.applicationContext.getBean(JsonToReviewsConverter.class)); + converters.add(this.applicationContext.getBean(ReviewsToJsonConverter.class)); + return new R2dbcCustomConversions(getStoreConversions(), converters); + } + + @Component + @ReadingConverter + public class JsonToReviewsConverter implements Converter { + + private final Gson gson; + + @Autowired + public JsonToReviewsConverter(Gson gson) { + this.gson = gson; + } + + @Override + public Address convert(JsonWrapper json) { + try { + return this.gson.fromJson(json.toString(), Address.class); + } catch (JsonParseException e) { + return new Address(); + } + } + } + + @Component + @WritingConverter + public class ReviewsToJsonConverter implements Converter { + + private final Gson gson; + + @Autowired + public ReviewsToJsonConverter(Gson gson) { + this.gson = gson; + } + + @Override + public JsonWrapper convert(Address source) { + try { + return JsonWrapper.of(this.gson.toJson(source)); + } catch (JsonParseException e) { + return JsonWrapper.of(""); + } + } + } + } +} diff --git a/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/it/SpannerTestUtils.java b/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/it/SpannerTestUtils.java new file mode 100644 index 0000000000..be82b9078c --- /dev/null +++ b/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/it/SpannerTestUtils.java @@ -0,0 +1,39 @@ +/* + * Copyright 2019-2020 Google LLC + * + * 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.spanner.r2dbc.springdata.it; + +import io.r2dbc.spi.Connection; +import reactor.core.publisher.Mono; + +/** + * Helper functions for Spanner integration testing. + */ +public class SpannerTestUtils { + + /** + * Returns true if the Spanner table exists; false if not. + */ + public static boolean tableExists(Connection connection, String tableName) { + return Mono.from(connection.createStatement( + "SELECT table_name FROM information_schema.tables WHERE table_name = @name") + .bind("name", tableName) + .execute()) + .flatMapMany(result -> result.map((r, m) -> r)) + .hasElements() + .block(); + } +} diff --git a/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/it/entities/Address.java b/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/it/entities/Address.java new file mode 100644 index 0000000000..90b5636236 --- /dev/null +++ b/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/it/entities/Address.java @@ -0,0 +1,64 @@ +/* + * Copyright 2021-2021 Google LLC + * + * 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.spanner.r2dbc.springdata.it.entities; + +import com.google.common.base.Objects; + +/** Example custom class entity field. */ +public class Address { + String homeAddress; + String workAddress; + Integer homeZipCode; + Integer workZipCode; + + public Address() {} + + /** + * Constructor. + * + * @param homeAddress home address + * @param workAddress work address + * @param homeZipCode home zip code + * @param workZipCode work zip code + */ + public Address(String homeAddress, String workAddress, Integer homeZipCode, Integer workZipCode) { + this.homeAddress = homeAddress; + this.workAddress = workAddress; + this.homeZipCode = homeZipCode; + this.workZipCode = workZipCode; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Address address = (Address) o; + return Objects.equal(this.homeAddress, address.homeAddress) + && Objects.equal(this.workAddress, address.workAddress) + && Objects.equal(this.homeZipCode, address.homeZipCode) + && Objects.equal(this.workZipCode, address.workZipCode); + } + + @Override + public int hashCode() { + return Objects.hashCode(this.homeZipCode, this.workZipCode); + } +} diff --git a/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/it/entities/Card.java b/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/it/entities/Card.java new file mode 100644 index 0000000000..baa3cbdaf2 --- /dev/null +++ b/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/it/entities/Card.java @@ -0,0 +1,90 @@ +/* + * Copyright 2022-2022 Google LLC + * + * 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.spanner.r2dbc.springdata.it.entities; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import org.springframework.data.relational.core.mapping.Column; + +/** Example entity. */ +public class Card { + + @Column("id") + private long id; + + @Column("expiry_year") + private int expiryYear; + + @Column("expiry_month") + private int expiryMonth; + + @Column("issue_date") + private LocalDate issueDate; + + @Column("requested_at") + private LocalDateTime requestedAt; + + /** Constructor. */ + public Card(long id, int expiryYear, int expiryMonth, LocalDate issueDate, + LocalDateTime requestedAt) { + this.id = id; + this.expiryYear = expiryYear; + this.expiryMonth = expiryMonth; + this.issueDate = issueDate; + this.requestedAt = requestedAt; + } + + public long getId() { + return this.id; + } + + public void setId(long id) { + this.id = id; + } + + public int getExpiryYear() { + return this.expiryYear; + } + + public void setExpiryYear(int expiryYear) { + this.expiryYear = expiryYear; + } + + public int getExpiryMonth() { + return this.expiryMonth; + } + + public void setExpiryMonth(int expiryMonth) { + this.expiryMonth = expiryMonth; + } + + public LocalDate getIssueDate() { + return this.issueDate; + } + + public void setIssueDate(LocalDate issueDate) { + this.issueDate = issueDate; + } + + public LocalDateTime getRequestedAt() { + return this.requestedAt; + } + + public void setRequestedAt(LocalDateTime requestedAt) { + this.requestedAt = requestedAt; + } +} diff --git a/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/it/entities/Person.java b/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/it/entities/Person.java new file mode 100644 index 0000000000..1d11c02f83 --- /dev/null +++ b/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/it/entities/Person.java @@ -0,0 +1,97 @@ +/* + * Copyright 2021-2021 Google LLC + * + * 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.spanner.r2dbc.springdata.it.entities; + +import java.util.Map; +import org.springframework.data.relational.core.mapping.Column; + +/** + * Example entity. + */ +public class Person { + + @Column("NAME") + private String name; + + @Column("BIRTH_YEAR") + private long birthYear; + + @Column("EXTRAS") + private Map extras; + + @Column("ADDRESS") + private Address address; + + /** + * Constructor. + * + * @param name name + * @param birthYear birth year. + * @param extras extra info stored in Map. + */ + public Person(String name, long birthYear, Map extras, Address address) { + this.name = name; + this.birthYear = birthYear; + this.extras = extras; + this.address = address; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public long getBirthYear() { + return this.birthYear; + } + + public void setBirthYear(long birthYear) { + this.birthYear = birthYear; + } + + public Map getExtras() { + return this.extras; + } + + public void setExtras(Map extras) { + this.extras = extras; + } + + public Address getAddress() { + return this.address; + } + + public void setAddress(Address address) { + this.address = address; + } + + @Override + public String toString() { + return "President{" + + "name='" + + this.name + + '\'' + + ", birthYear=" + + this.birthYear + + ", extras=" + + (this.getExtras() == null ? " " : this.getExtras().toString()) + + '}'; + } +} diff --git a/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/it/entities/President.java b/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/it/entities/President.java new file mode 100644 index 0000000000..2affe37ffa --- /dev/null +++ b/spring-cloud-spanner-spring-data-r2dbc/src/test/java/com/google/cloud/spanner/r2dbc/springdata/it/entities/President.java @@ -0,0 +1,61 @@ +/* + * Copyright 2019-2020 Google LLC + * + * 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.spanner.r2dbc.springdata.it.entities; + +import org.springframework.data.relational.core.mapping.Column; + +/** + * Example entity. + */ +public class President { + + @Column("NAME") + private String name; + + @Column("START_YEAR") + private long startYear; + + public President(String name, long startYear) { + this.name = name; + this.startYear = startYear; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public long getStartYear() { + return this.startYear; + } + + public void setStartYear(long startYear) { + this.startYear = startYear; + } + + @Override + public String toString() { + return "President{" + + "name='" + + this.name + '\'' + + ", startYear=" + + this.startYear + '}'; + } +}