diff --git a/.github/workflows/spring-data-mybatis-sample.yaml b/.github/workflows/spring-data-mybatis-sample.yaml index b19a17290..e7dfbc7b3 100644 --- a/.github/workflows/spring-data-mybatis-sample.yaml +++ b/.github/workflows/spring-data-mybatis-sample.yaml @@ -25,6 +25,9 @@ jobs: with: distribution: temurin java-version: 17 - - name: Run tests + - name: Run GoogleSQL sample tests run: mvn test - working-directory: samples/spring-data-mybatis + working-directory: samples/spring-data-mybatis/googlesql + - name: Run PostgreSQL sample tests + run: mvn test + working-directory: samples/spring-data-mybatis/postgresql diff --git a/samples/spring-data-mybatis/README.md b/samples/spring-data-mybatis/README.md index 61dc46f48..04e1b0950 100644 --- a/samples/spring-data-mybatis/README.md +++ b/samples/spring-data-mybatis/README.md @@ -1,100 +1,9 @@ -# Spring Data MyBatis Sample Application with Cloud Spanner PostgreSQL +# Spring Data MyBatis -This sample application shows how to develop portable applications using Spring Data MyBatis in -combination with Cloud Spanner PostgreSQL. This application can be configured to run on either a -[Cloud Spanner PostgreSQL](https://cloud.google.com/spanner/docs/postgresql-interface) database or -an open-source PostgreSQL database. The only change that is needed to switch between the two is -changing the active Spring profile that is used by the application. +This directory contains two sample applications for using Spring Data MyBatis +with the Spanner JDBC driver. -The application uses the Cloud Spanner JDBC driver to connect to Cloud Spanner PostgreSQL, and it -uses the PostgreSQL JDBC driver to connect to open-source PostgreSQL. Spring Data MyBatis works with -both drivers and offers a single consistent API to the application developer, regardless of the -actual database or JDBC driver being used. - -This sample shows: - -1. How to use Spring Data MyBatis with Cloud Spanner PostgreSQL. -2. How to develop a portable application that runs on both Google Cloud Spanner PostgreSQL and - open-source PostgreSQL with the same code base. -3. How to use bit-reversed sequences to automatically generate primary key values for entities. -4. How to use the Spanner Emulator for development in combination with Spring Data. - -__NOTE__: This application does __not require PGAdapter__. Instead, it connects to Cloud Spanner -PostgreSQL using the Cloud Spanner JDBC driver. - -## Cloud Spanner PostgreSQL - -Cloud Spanner PostgreSQL provides language support by expressing Spanner database functionality -through a subset of open-source PostgreSQL language constructs, with extensions added to support -Spanner functionality like interleaved tables and hinting. - -The PostgreSQL interface makes the capabilities of Spanner —__fully managed, unlimited scale, strong -consistency, high performance, and up to 99.999% global availability__— accessible using the -PostgreSQL dialect. Unlike other services that manage actual PostgreSQL database instances, Spanner -uses PostgreSQL-compatible syntax to expose its existing scale-out capabilities. This provides -familiarity for developers and portability for applications, but not 100% PostgreSQL compatibility. -The SQL syntax that Spanner supports is semantically equivalent PostgreSQL, meaning schemas -and queries written against the PostgreSQL interface can be easily ported to another PostgreSQL -environment. - -This sample showcases this portability with an application that works on both Cloud Spanner PostgreSQL -and open-source PostgreSQL with the same code base. - -## MyBatis Spring -[MyBatis Spring](http://mybatis.org/spring/) integrates MyBatis with the popular Java Spring -framework. This allows MyBatis to participate in Spring transactions and to automatically inject -MyBatis mappers into other beans. - -## Sample Application - -This sample shows how to create a portable application using Spring Data MyBatis and the Cloud Spanner -PostgreSQL dialect. The application works on both Cloud Spanner PostgreSQL and open-source -PostgreSQL. You can switch between the two by changing the active Spring profile: -* Profile `cs` runs the application on Cloud Spanner PostgreSQL. -* Profile `pg` runs the application on open-source PostgreSQL. - -The default profile is `cs`. You can change the default profile by modifying the -[application.properties](src/main/resources/application.properties) file. - -### Running the Application - -1. Choose the database system that you want to use by choosing a profile. The default profile is - `cs`, which runs the application on Cloud Spanner PostgreSQL. -2. The sample by default starts an instance of the Spanner Emulator together with the application and - runs the application against the emulator. -3. Modify the default profile in the [application.properties](src/main/resources/application.properties) - file to run the sample on an open-source PostgreSQL database. -4. Modify either [application-cs.properties](src/main/resources/application-cs.properties) or - [application-pg.properties](src/main/resources/application-pg.properties) to point to an existing - database. If you use Cloud Spanner, the database that the configuration file references must be a - database that uses the PostgreSQL dialect. -5. Run the application with `mvn spring-boot:run`. - -### Main Application Components - -The main application components are: -* [DatabaseSeeder.java](src/main/java/com/google/cloud/spanner/sample/DatabaseSeeder.java): This - class is responsible for creating the database schema and inserting some initial test data. The - schema is created from the [create_schema.sql](src/main/resources/create_schema.sql) file. The - `DatabaseSeeder` class loads this file into memory and executes it on the active database using - standard JDBC APIs. The class also removes Cloud Spanner-specific extensions to the PostgreSQL - dialect when the application runs on open-source PostgreSQL. -* [JdbcConfiguration.java](src/main/java/com/google/cloud/spanner/sample/JdbcConfiguration.java): - This utility class is used to determine whether the application is running on Cloud Spanner - PostgreSQL or open-source PostgreSQL. This can be used if you have specific features that should - only be executed on one of the two systems. -* [EmulatorInitializer.java](src/main/java/com/google/cloud/spanner/sample/EmulatorInitializer.java): - This ApplicationListener automatically starts the Spanner emulator as a Docker container if the - sample has been configured to run on the emulator. -* [AbstractEntity.java](src/main/java/com/google/cloud/spanner/sample/entities/AbstractEntity.java): - This is the shared base class for all entities in this sample application. It defines a number of - standard attributes, such as the identifier (primary key). The primary key is automatically - generated using a (bit-reversed) sequence. [Bit-reversed sequential values](https://cloud.google.com/spanner/docs/schema-design#bit_reverse_primary_key) - are considered a good choice for primary keys on Cloud Spanner. -* [Application.java](src/main/java/com/google/cloud/spanner/sample/Application.java): The starter - class of the application. It contains a command-line runner that executes a selection of queries - and updates on the database. -* [SingerService](src/main/java/com/google/cloud/spanner/sample/service/SingerService.java) and - [AlbumService](src/main/java/com/google/cloud/spanner/sample/service/SingerService.java) are - standard Spring service beans that contain business logic that can be executed as transactions. - This includes both read/write and read-only transactions. +- [GoogleSQL](googlesql): This sample uses the Spanner GoogleSQL dialect. +- [PostgreSQL](postgresql): This sample uses the Spanner PostgreSQL dialect and the Spanner JDBC + driver. It does not use PGAdapter. The sample application can also be configured to run on open + source PostgreSQL, and shows how a portable application be developed using this setup. diff --git a/samples/spring-data-mybatis/googlesql/README.md b/samples/spring-data-mybatis/googlesql/README.md new file mode 100644 index 000000000..7badfb698 --- /dev/null +++ b/samples/spring-data-mybatis/googlesql/README.md @@ -0,0 +1,52 @@ +# Spring Data MyBatis Sample Application with Spanner GoogleSQL + +This sample application shows how to develop applications using Spring Data MyBatis in +combination with Spanner GoogleSQL. + +This sample shows: + +1. How to use Spring Data MyBatis with a Spanner GoogleSQL database. +2. How to use bit-reversed identity columns to automatically generate primary key values for entities. +3. How to set the transaction isolation level that is used by the Spanner JDBC driver. +4. How to use the Spanner Emulator for development in combination with Spring Data. + +## MyBatis Spring +[MyBatis Spring](http://mybatis.org/spring/) integrates MyBatis with the popular Java Spring +framework. This allows MyBatis to participate in Spring transactions and to automatically inject +MyBatis mappers into other beans. + +### Running the Application + +1. The sample by default starts an instance of the Spanner Emulator together with the application and + runs the application against the emulator. +2. To run the sample on a real Spanner database, modify + [application.properties](src/main/resources/application.properties) and set the + `spanner.emulator` property to `false`. Modify the `spanner.project`, `spanner.instance`, and + `spanner.database` properties to point to an existing Spanner database. + The database must use the GoogleSQL dialect. +3. Run the application with `mvn spring-boot:run`. + +### Main Application Components + +The main application components are: +* [DatabaseSeeder.java](src/main/java/com/google/cloud/spanner/sample/DatabaseSeeder.java): This + class is responsible for creating the database schema and inserting some initial test data. The + schema is created from the [create_schema.sql](src/main/resources/create_schema.sql) file. The + `DatabaseSeeder` class loads this file into memory and executes it on the active database using + standard JDBC APIs. +* [EmulatorInitializer.java](src/main/java/com/google/cloud/spanner/sample/EmulatorInitializer.java): + This ApplicationListener automatically starts the Spanner emulator as a Docker container if the + sample has been configured to run on the emulator. You can disable this with the `spanner.emulator` + property in `application.properties`. +* [AbstractEntity.java](src/main/java/com/google/cloud/spanner/sample/entities/AbstractEntity.java): + This is the shared base class for all entities in this sample application. It defines a number of + standard attributes, such as the identifier (primary key). The primary key is automatically + generated using a (bit-reversed) identity column. [Bit-reversed sequential values](https://cloud.google.com/spanner/docs/schema-design#bit_reverse_primary_key) + are considered a good choice for primary keys in Spanner. +* [Application.java](src/main/java/com/google/cloud/spanner/sample/Application.java): The starter + class of the application. It contains a command-line runner that executes a selection of queries + and updates on the database. +* [SingerService](src/main/java/com/google/cloud/spanner/sample/service/SingerService.java) and + [AlbumService](src/main/java/com/google/cloud/spanner/sample/service/SingerService.java) are + standard Spring service beans that contain business logic that can be executed as transactions. + This includes both read/write and read-only transactions. diff --git a/samples/spring-data-mybatis/googlesql/pom.xml b/samples/spring-data-mybatis/googlesql/pom.xml new file mode 100644 index 000000000..47fb7b0a4 --- /dev/null +++ b/samples/spring-data-mybatis/googlesql/pom.xml @@ -0,0 +1,130 @@ + + + 4.0.0 + + org.example + cloud-spanner-spring-data-mybatis-googlesql-example + 1.0-SNAPSHOT + + Sample application showing how to use Spring Data MyBatis with Spanner GoogleSQL. + + + org.springframework.boot + spring-boot-starter-parent + 3.4.5 + + + + 17 + 17 + 17 + UTF-8 + + + + + + org.springframework.data + spring-data-bom + 2024.1.5 + import + pom + + + com.google.cloud + libraries-bom + 26.59.0 + import + pom + + + org.testcontainers + testcontainers-bom + 1.21.0 + import + pom + + + + + + + org.mybatis.spring.boot + mybatis-spring-boot-starter + 3.0.4 + + + org.mybatis.dynamic-sql + mybatis-dynamic-sql + 1.5.2 + + + + + + com.google.cloud + google-cloud-spanner + 6.91.1 + + + com.google.cloud + google-cloud-spanner-jdbc + 2.29.1 + + + com.google.api.grpc + proto-google-cloud-spanner-executor-v1 + + + + + org.testcontainers + testcontainers + + + + com.google.collections + google-collections + 1.0 + + + + + com.google.cloud + google-cloud-spanner + 6.91.1 + test-jar + test + + + com.google.api + gax-grpc + testlib + test + + + junit + junit + 4.13.2 + + + + + + + com.spotify.fmt + fmt-maven-plugin + 2.25 + + + + format + + + + + + + diff --git a/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/Application.java b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/Application.java new file mode 100644 index 000000000..cf9ab71d3 --- /dev/null +++ b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/Application.java @@ -0,0 +1,161 @@ +/* + * Copyright 2025 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 + * + * http://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.sample; + +import com.google.cloud.spanner.connection.SpannerPool; +import com.google.cloud.spanner.sample.entities.Album; +import com.google.cloud.spanner.sample.entities.Singer; +import com.google.cloud.spanner.sample.entities.Track; +import com.google.cloud.spanner.sample.mappers.AlbumMapper; +import com.google.cloud.spanner.sample.mappers.SingerMapper; +import com.google.cloud.spanner.sample.service.AlbumService; +import com.google.cloud.spanner.sample.service.SingerService; +import java.util.concurrent.ThreadLocalRandom; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application implements CommandLineRunner { + private static final Logger logger = LoggerFactory.getLogger(Application.class); + + public static void main(String[] args) { + // Start the Spanner emulator in a Docker container if the `spanner.auto_start_emulator` + // property has been set to true. If not, then this is a no-op. + EmulatorInitializer emulatorInitializer = new EmulatorInitializer(); + try { + SpringApplication application = new SpringApplication(Application.class); + application.addListeners(emulatorInitializer); + application.run(args).close(); + } finally { + SpannerPool.closeSpannerPool(); + emulatorInitializer.stopEmulator(); + } + } + + private final DatabaseSeeder databaseSeeder; + + private final SingerService singerService; + + private final AlbumService albumService; + + private final SingerMapper singerMapper; + + private final AlbumMapper albumMapper; + + public Application( + SingerService singerService, + AlbumService albumService, + DatabaseSeeder databaseSeeder, + SingerMapper singerMapper, + AlbumMapper albumMapper) { + this.databaseSeeder = databaseSeeder; + this.singerService = singerService; + this.albumService = albumService; + this.singerMapper = singerMapper; + this.albumMapper = albumMapper; + } + + @Override + public void run(String... args) { + // Set the system property 'drop_schema' to true to drop any existing database + // schema when the application is executed. + if (Boolean.parseBoolean(System.getProperty("drop_schema", "false"))) { + logger.info("Dropping existing schema if it exists"); + databaseSeeder.dropDatabaseSchemaIfExists(); + } + + logger.info("Creating database schema if it does not already exist"); + databaseSeeder.createDatabaseSchemaIfNotExists(); + logger.info("Deleting existing test data"); + databaseSeeder.deleteTestData(); + logger.info("Inserting fresh test data"); + databaseSeeder.insertTestData(); + + Iterable allSingers = singerMapper.findAll(); + for (Singer singer : allSingers) { + logger.info( + "Found singer: {} with {} albums", + singer, + albumMapper.countAlbumsBySingerId(singer.getId())); + for (Album album : albumMapper.findAlbumsBySingerId(singer.getId())) { + logger.info("\tAlbum: {}, released at {}", album, album.getReleaseDate()); + } + } + + // Create a new singer and three albums in a transaction. + Singer insertedSinger = + singerService.createSingerAndAlbums( + new Singer("Amethyst", "Jiang"), + new Album(DatabaseSeeder.randomTitle()), + new Album(DatabaseSeeder.randomTitle()), + new Album(DatabaseSeeder.randomTitle())); + logger.info( + "Inserted singer {} {} {}", + insertedSinger.getId(), + insertedSinger.getFirstName(), + insertedSinger.getLastName()); + + // Create a new Album and some Tracks in a read/write transaction. + // Track is an interleaved table. + Album album = new Album(DatabaseSeeder.randomTitle()); + album.setSingerId(insertedSinger.getId()); + albumService.createAlbumAndTracks( + album, + new Track(album, 1, DatabaseSeeder.randomTitle(), 3.14d), + new Track(album, 2, DatabaseSeeder.randomTitle(), 3.14d), + new Track(album, 3, DatabaseSeeder.randomTitle(), 3.14d), + new Track(album, 4, DatabaseSeeder.randomTitle(), 3.14d), + new Track(album, 5, DatabaseSeeder.randomTitle(), 3.14d), + new Track(album, 6, DatabaseSeeder.randomTitle(), 3.14d), + new Track(album, 7, DatabaseSeeder.randomTitle(), 3.14d)); + logger.info("Inserted album {}", album.getTitle()); + + // List all singers that have a last name starting with an 'J'. + logger.info("All singers with a last name starting with an 'J':"); + for (Singer singer : singerMapper.findSingersByLastNameStartingWith("J")) { + logger.info("\t{}", singer.getFullName()); + } + + // The singerService.listSingersWithLastNameStartingWith(..) method uses a read-only + // transaction. You should prefer read-only transactions to read/write transactions whenever + // possible, as read-only transactions do not take locks. + logger.info("All singers with a last name starting with an 'A', 'B', or 'C'."); + for (Singer singer : singerService.listSingersWithLastNameStartingWith("A", "B", "C")) { + logger.info("\t{}", singer.getFullName()); + } + + // Execute an insert-or-update for a Singer record. + // For this, we either get a random Singer from the database, or create a new Singer entity + // and assign it a random ID. + logger.info("Executing an insert-or-update statement for a Singer record"); + Singer singer; + if (ThreadLocalRandom.current().nextBoolean()) { + singer = singerMapper.getRandom(); + } else { + singer = new Singer(); + singer.setId(ThreadLocalRandom.current().nextLong()); + } + singer.setFirstName("Beatriz"); + singer.setLastName("Russel"); + singer.setActive(true); + // This executes an INSERT OR UPDATE statement. + singerMapper.insertOrUpdate(singer); + } +} diff --git a/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/DatabaseSeeder.java b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/DatabaseSeeder.java new file mode 100644 index 000000000..73898784e --- /dev/null +++ b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/DatabaseSeeder.java @@ -0,0 +1,316 @@ +/* + * Copyright 2025 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 + * + * http://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.sample; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.google.cloud.spanner.sample.entities.Singer; +import com.google.common.collect.ImmutableList; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.UncheckedIOException; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import javax.annotation.Nonnull; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.util.FileCopyUtils; + +/** This component creates the database schema and seeds it with some random test data. */ +@Component +public class DatabaseSeeder { + + /** Randomly generated names. */ + public static final ImmutableList INITIAL_SINGERS = + ImmutableList.of( + new Singer("Aaliyah", "Smith"), + new Singer("Benjamin", "Jones"), + new Singer("Chloe", "Brown"), + new Singer("David", "Williams"), + new Singer("Elijah", "Johnson"), + new Singer("Emily", "Miller"), + new Singer("Gabriel", "Garcia"), + new Singer("Hannah", "Rodriguez"), + new Singer("Isabella", "Hernandez"), + new Singer("Jacob", "Perez")); + + private static final Random RANDOM = new Random(); + + private final JdbcTemplate jdbcTemplate; + + @Value("classpath:create_schema.sql") + private Resource createSchemaFile; + + @Value("classpath:drop_schema.sql") + private Resource dropSchemaFile; + + public DatabaseSeeder(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + /** Reads a resource file into a string. */ + private static String resourceAsString(Resource resource) { + try (Reader reader = new InputStreamReader(resource.getInputStream(), UTF_8)) { + return FileCopyUtils.copyToString(reader); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** Removes all empty statements in the DDL script. */ + private String[] updateDdlStatements(String[] statements) { + // Remove any empty statements from the script. + return Arrays.stream(statements) + .filter(statement -> !statement.isBlank()) + .toArray(String[]::new); + } + + /** Creates the database schema if it does not yet exist. */ + public void createDatabaseSchemaIfNotExists() { + // We can safely just split the script based on ';', as we know that there are no literals or + // other strings that contain semicolons in the script. + String[] statements = updateDdlStatements(resourceAsString(createSchemaFile).split(";")); + // Execute all the DDL statements as a JDBC batch. That ensures that Spanner will apply all + // statements in a single DDL batch, which again is a lot more efficient than executing them + // one-by-one. + jdbcTemplate.batchUpdate(statements); + } + + /** Drops the database schema if it exists. */ + public void dropDatabaseSchemaIfExists() { + // We can safely just split the script based on ';', as we know that there are no literals or + // other strings that contain semicolons in the script. + String[] statements = updateDdlStatements(resourceAsString(dropSchemaFile).split(";")); + // Execute all the DDL statements as a JDBC batch. That ensures that Spanner will apply all + // statements in a single DDL batch, which again is a lot more efficient than executing them + // one-by-one. + jdbcTemplate.batchUpdate(statements); + } + + /** Deletes all data currently in the sample tables. */ + public void deleteTestData() { + // Delete all data in one batch. + jdbcTemplate.batchUpdate( + "delete from concerts where true", + "delete from venues where true", + "delete from tracks where true", + "delete from albums where true", + "delete from singers where true"); + } + + /** Inserts some initial test data into the database. */ + public void insertTestData() { + jdbcTemplate.batchUpdate( + "insert into singers (first_name, last_name) values (?, ?)", + new BatchPreparedStatementSetter() { + @Override + public void setValues(@Nonnull PreparedStatement preparedStatement, int i) + throws SQLException { + preparedStatement.setString(1, INITIAL_SINGERS.get(i).getFirstName()); + preparedStatement.setString(2, INITIAL_SINGERS.get(i).getLastName()); + } + + @Override + public int getBatchSize() { + return INITIAL_SINGERS.size(); + } + }); + + List singerIds = + jdbcTemplate.query( + "select id from singers", + resultSet -> { + ImmutableList.Builder builder = ImmutableList.builder(); + while (resultSet.next()) { + builder.add(resultSet.getLong(1)); + } + return builder.build(); + }); + jdbcTemplate.batchUpdate( + "insert into albums (title, marketing_budget, release_date, cover_picture, singer_id) values (?, ?, ?, ?, ?)", + new BatchPreparedStatementSetter() { + @Override + public void setValues(@Nonnull PreparedStatement preparedStatement, int i) + throws SQLException { + preparedStatement.setString(1, randomTitle()); + preparedStatement.setBigDecimal(2, randomBigDecimal()); + preparedStatement.setObject(3, randomDate()); + preparedStatement.setBytes(4, randomBytes()); + preparedStatement.setLong(5, randomElement(singerIds)); + } + + @Override + public int getBatchSize() { + return INITIAL_SINGERS.size() * 20; + } + }); + } + + /** Generates a random title for an album or a track. */ + static String randomTitle() { + return randomElement(ADJECTIVES) + " " + randomElement(NOUNS); + } + + /** Returns a random element from the given list. */ + static T randomElement(List list) { + return list.get(RANDOM.nextInt(list.size())); + } + + /** Generates a random {@link BigDecimal}. */ + BigDecimal randomBigDecimal() { + return BigDecimal.valueOf(RANDOM.nextDouble()).setScale(9, RoundingMode.HALF_UP); + } + + /** Generates a random {@link LocalDate}. */ + static LocalDate randomDate() { + return LocalDate.of(RANDOM.nextInt(200) + 1800, RANDOM.nextInt(12) + 1, RANDOM.nextInt(28) + 1); + } + + /** Generates a random byte array with a length between 4 and 1024 bytes. */ + static byte[] randomBytes() { + int size = RANDOM.nextInt(1020) + 4; + byte[] res = new byte[size]; + RANDOM.nextBytes(res); + return res; + } + + /** Some randomly generated nouns that are used to generate random titles. */ + private static final ImmutableList NOUNS = + ImmutableList.of( + "apple", + "banana", + "cherry", + "dog", + "elephant", + "fish", + "grass", + "house", + "key", + "lion", + "monkey", + "nail", + "orange", + "pen", + "queen", + "rain", + "shoe", + "tree", + "umbrella", + "van", + "whale", + "xylophone", + "zebra"); + + /** Some randomly generated adjectives that are used to generate random titles. */ + private static final ImmutableList ADJECTIVES = + ImmutableList.of( + "able", + "angelic", + "artistic", + "athletic", + "attractive", + "autumnal", + "calm", + "careful", + "cheerful", + "clever", + "colorful", + "confident", + "courageous", + "creative", + "curious", + "daring", + "determined", + "different", + "dreamy", + "efficient", + "elegant", + "energetic", + "enthusiastic", + "exciting", + "expressive", + "faithful", + "fantastic", + "funny", + "gentle", + "gifted", + "great", + "happy", + "helpful", + "honest", + "hopeful", + "imaginative", + "intelligent", + "interesting", + "inventive", + "joyful", + "kind", + "knowledgeable", + "loving", + "loyal", + "magnificent", + "mature", + "mysterious", + "natural", + "nice", + "optimistic", + "peaceful", + "perfect", + "pleasant", + "powerful", + "proud", + "quick", + "relaxed", + "reliable", + "responsible", + "romantic", + "safe", + "sensitive", + "sharp", + "simple", + "sincere", + "skillful", + "smart", + "sociable", + "strong", + "successful", + "sweet", + "talented", + "thankful", + "thoughtful", + "unique", + "upbeat", + "valuable", + "victorious", + "vivacious", + "warm", + "wealthy", + "wise", + "wonderful", + "worthy", + "youthful"); +} diff --git a/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/EmulatorInitializer.java b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/EmulatorInitializer.java new file mode 100644 index 000000000..6c6130be6 --- /dev/null +++ b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/EmulatorInitializer.java @@ -0,0 +1,57 @@ +/* + * Copyright 2025 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 + * + * http://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.sample; + +import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.core.env.ConfigurableEnvironment; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.PullPolicy; +import org.testcontainers.utility.DockerImageName; + +public class EmulatorInitializer + implements ApplicationListener { + private GenericContainer emulator; + + @Override + public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) { + ConfigurableEnvironment environment = event.getEnvironment(); + boolean useEmulator = + Boolean.TRUE.equals(environment.getProperty("spanner.emulator", Boolean.class)); + boolean autoStartEmulator = + Boolean.TRUE.equals(environment.getProperty("spanner.auto_start_emulator", Boolean.class)); + if (!(useEmulator && autoStartEmulator)) { + return; + } + + emulator = + new GenericContainer<>(DockerImageName.parse("gcr.io/cloud-spanner-emulator/emulator")); + emulator.withImagePullPolicy(PullPolicy.alwaysPull()); + emulator.addExposedPort(9010); + emulator.setWaitStrategy(Wait.forListeningPorts(9010)); + emulator.start(); + + System.setProperty("spanner.endpoint", "//localhost:" + emulator.getMappedPort(9010)); + } + + public void stopEmulator() { + if (this.emulator != null) { + this.emulator.stop(); + } + } +} diff --git a/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/AbstractEntity.java b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/AbstractEntity.java new file mode 100644 index 000000000..dcf64a22d --- /dev/null +++ b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/AbstractEntity.java @@ -0,0 +1,73 @@ +/* + * Copyright 2025 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 + * + * http://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.sample.entities; + +import java.time.OffsetDateTime; + +public abstract class AbstractEntity { + + /** This ID is generated using a (bit-reversed) identity column. */ + private Long id; + + private OffsetDateTime createdAt; + + private OffsetDateTime updatedAt; + + @Override + public boolean equals(Object o) { + if (!(o instanceof AbstractEntity)) { + return false; + } + AbstractEntity other = (AbstractEntity) o; + if (this == other) { + return true; + } + return this.getClass().equals(other.getClass()) + && this.id != null + && other.id != null + && this.id.equals(other.id); + } + + @Override + public int hashCode() { + return this.id == null ? 0 : this.id.hashCode(); + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + protected void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + protected void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Album.java b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Album.java new file mode 100644 index 000000000..9ea238506 --- /dev/null +++ b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Album.java @@ -0,0 +1,84 @@ +/* + * Copyright 2025 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 + * + * http://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.sample.entities; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public class Album extends AbstractEntity { + + private String title; + + private BigDecimal marketingBudget; + + private LocalDate releaseDate; + + private byte[] coverPicture; + + private Long singerId; + + public Album() {} + + public Album(String title) { + this.title = title; + } + + @Override + public String toString() { + return getTitle(); + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public BigDecimal getMarketingBudget() { + return marketingBudget; + } + + public void setMarketingBudget(BigDecimal marketingBudget) { + this.marketingBudget = marketingBudget; + } + + public LocalDate getReleaseDate() { + return releaseDate; + } + + public void setReleaseDate(LocalDate releaseDate) { + this.releaseDate = releaseDate; + } + + public byte[] getCoverPicture() { + return coverPicture; + } + + public void setCoverPicture(byte[] coverPicture) { + this.coverPicture = coverPicture; + } + + public Long getSingerId() { + return singerId; + } + + public void setSingerId(Long singerId) { + this.singerId = singerId; + } +} diff --git a/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Concert.java b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Concert.java new file mode 100644 index 000000000..ac13102af --- /dev/null +++ b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Concert.java @@ -0,0 +1,78 @@ +/* + * Copyright 2025 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 + * + * http://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.sample.entities; + +import java.time.OffsetDateTime; + +public class Concert extends AbstractEntity { + + private Long venueId; + + private Long singerId; + + private String name; + + private OffsetDateTime startTime; + + private OffsetDateTime endTime; + + public Concert(Venue venue, Singer singer, String name) { + this.venueId = venue.getId(); + this.singerId = singer.getId(); + this.name = name; + } + + public Long getVenueId() { + return venueId; + } + + public void setVenueId(Long venueId) { + this.venueId = venueId; + } + + public Long getSingerId() { + return singerId; + } + + public void setSingerId(Long singerId) { + this.singerId = singerId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public OffsetDateTime getStartTime() { + return startTime; + } + + public void setStartTime(OffsetDateTime startTime) { + this.startTime = startTime; + } + + public OffsetDateTime getEndTime() { + return endTime; + } + + public void setEndTime(OffsetDateTime endTime) { + this.endTime = endTime; + } +} diff --git a/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Singer.java b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Singer.java new file mode 100644 index 000000000..b3f6d1c4f --- /dev/null +++ b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Singer.java @@ -0,0 +1,73 @@ +/* + * Copyright 2025 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 + * + * http://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.sample.entities; + +public class Singer extends AbstractEntity { + + private String firstName; + + private String lastName; + + /** The full name is generated by the database using a generated column. */ + private String fullName; + + private Boolean active; + + public Singer() {} + + public Singer(String firstName, String lastName) { + this.firstName = firstName; + this.lastName = lastName; + } + + @Override + public String toString() { + return getFullName(); + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getFullName() { + return fullName; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + public Boolean getActive() { + return active; + } + + public void setActive(Boolean active) { + this.active = active; + } +} diff --git a/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Track.java b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Track.java new file mode 100644 index 000000000..8191c696c --- /dev/null +++ b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Track.java @@ -0,0 +1,66 @@ +/* + * Copyright 2025 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 + * + * http://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.sample.entities; + +/** + * The "tracks" table is interleaved in "albums". That means that the first part of the primary key + * (the "id" column) references the Album that this Track belongs to. That again means that we do + * not auto-generate the id for this entity. + */ +public class Track extends AbstractEntity { + + /** + * This is the second part of the primary key of a Track. The first part, the 'id' column is + * defined in the {@link AbstractEntity} super class. + */ + private int trackNumber; + + private String title; + + private Double sampleRate; + + public Track(Album album, int trackNumber, String title, Double sampleRate) { + setId(album.getId()); + this.trackNumber = trackNumber; + this.title = title; + this.sampleRate = sampleRate; + } + + public int getTrackNumber() { + return trackNumber; + } + + public void setTrackNumber(int trackNumber) { + this.trackNumber = trackNumber; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public Double getSampleRate() { + return sampleRate; + } + + public void setSampleRate(Double sampleRate) { + this.sampleRate = sampleRate; + } +} diff --git a/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Venue.java b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Venue.java new file mode 100644 index 000000000..f5eb4443d --- /dev/null +++ b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Venue.java @@ -0,0 +1,43 @@ +/* + * Copyright 2025 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 + * + * http://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.sample.entities; + +public class Venue extends AbstractEntity { + private String name; + + private String description; + + public Venue(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/mappers/AlbumMapper.java b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/mappers/AlbumMapper.java new file mode 100644 index 000000000..f39b08a33 --- /dev/null +++ b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/mappers/AlbumMapper.java @@ -0,0 +1,48 @@ +/* + * Copyright 2025 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 + * + * http://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.sample.mappers; + +import com.google.cloud.spanner.sample.entities.Album; +import java.util.List; +import java.util.Optional; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface AlbumMapper { + + @Select("SELECT * FROM albums WHERE id = #{albumId}") + Album get(@Param("albumId") long albumId); + + @Select("SELECT * FROM albums LIMIT 1") + Optional getFirst(); + + @Select("SELECT COUNT(1) FROM albums WHERE singer_id = #{singerId}") + long countAlbumsBySingerId(@Param("singerId") long singerId); + + @Select("SELECT * FROM albums WHERE singer_id = #{singerId}") + List findAlbumsBySingerId(@Param("singerId") long singerId); + + @Insert( + "INSERT INTO albums (title, marketing_budget, release_date, cover_picture, singer_id) " + + "VALUES (#{title}, #{marketingBudget}, #{releaseDate}, #{coverPicture}, #{singerId})") + @Options(useGeneratedKeys = true, keyProperty = "id") + int insert(Album album); +} diff --git a/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/mappers/ConcertMapper.java b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/mappers/ConcertMapper.java new file mode 100644 index 000000000..1b52c603f --- /dev/null +++ b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/mappers/ConcertMapper.java @@ -0,0 +1,29 @@ +/* + * Copyright 2025 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 + * + * http://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.sample.mappers; + +import com.google.cloud.spanner.sample.entities.Venue; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ConcertMapper { + + @Select("SELECT * FROM concerts WHERE id = #{concertId}") + Venue get(@Param("concertId") long concertId); +} diff --git a/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/mappers/SingerMapper.java b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/mappers/SingerMapper.java new file mode 100644 index 000000000..65ddb72ed --- /dev/null +++ b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/mappers/SingerMapper.java @@ -0,0 +1,73 @@ +/* + * Copyright 2025 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 + * + * http://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.sample.mappers; + +import com.google.cloud.spanner.sample.entities.Singer; +import java.util.List; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface SingerMapper { + + @Select("SELECT * FROM singers WHERE id = #{singerId}") + Singer get(@Param("singerId") long singerId); + + @Select("SELECT * FROM singers TABLESAMPLE RESERVOIR (1 ROWS)") + Singer getRandom(); + + @Select("SELECT * FROM singers ORDER BY last_name, first_name, id") + List findAll(); + + @Select("SELECT * FROM singers WHERE starts_with(last_name, #{lastName})") + List findSingersByLastNameStartingWith(@Param("lastName") String lastName); + + /** + * Inserts a new singer record and returns both the generated primary key value and the generated + * full name. + */ + @Insert( + "INSERT INTO singers (first_name, last_name, active) " + + "VALUES (#{firstName}, #{lastName}, #{active})") + @Options(useGeneratedKeys = true, keyProperty = "id,fullName") + int insert(Singer singer); + + /** + * Executes an insert-or-update statement for a Singer record. Note that the id must have been set + * manually on the Singer entity before calling this method. The statement only returns the + * 'fullName' property, because the 'id' is already known. + */ + @Insert( + "INSERT OR UPDATE singers (id, first_name, last_name, active) " + + "VALUES (#{id}, #{firstName}, #{lastName}, #{active})") + @Options(useGeneratedKeys = true, keyProperty = "fullName") + int insertOrUpdate(Singer singer); + + /** Updates an existing singer and returns the generated full name. */ + @Update( + "UPDATE singers SET " + + "first_name=#{first_name}, " + + "last_name=#{last_name}, " + + "active=#{active} " + + "WHERE id=#{id}") + @Options(useGeneratedKeys = true, keyProperty = "fullName") + int update(Singer singer); +} diff --git a/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/mappers/TrackMapper.java b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/mappers/TrackMapper.java new file mode 100644 index 000000000..729c56fa6 --- /dev/null +++ b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/mappers/TrackMapper.java @@ -0,0 +1,37 @@ +/* + * Copyright 2025 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 + * + * http://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.sample.mappers; + +import com.google.cloud.spanner.sample.entities.Track; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface TrackMapper { + + @Select("SELECT * FROM tracks WHERE id = #{albumId} AND track_number = #{trackNumber}") + Track get(@Param("albumId") long albumId, @Param("trackNumber") long trackNumber); + + @Insert( + "INSERT INTO tracks (id, track_number, title, sample_rate) " + + "VALUES (#{id}, #{trackNumber}, #{title}, #{sampleRate})") + @Options(useGeneratedKeys = true, keyProperty = "id") + int insert(Track track); +} diff --git a/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/mappers/VenueMapper.java b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/mappers/VenueMapper.java new file mode 100644 index 000000000..be220f867 --- /dev/null +++ b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/mappers/VenueMapper.java @@ -0,0 +1,29 @@ +/* + * Copyright 2025 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 + * + * http://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.sample.mappers; + +import com.google.cloud.spanner.sample.entities.Venue; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface VenueMapper { + + @Select("SELECT * FROM venues WHERE id = #{venueId}") + Venue get(@Param("venueId") long venueId); +} diff --git a/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/service/AlbumService.java b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/service/AlbumService.java new file mode 100644 index 000000000..1a7e125f0 --- /dev/null +++ b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/service/AlbumService.java @@ -0,0 +1,49 @@ +/* + * Copyright 2025 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 + * + * http://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.sample.service; + +import com.google.cloud.spanner.sample.entities.Album; +import com.google.cloud.spanner.sample.entities.Track; +import com.google.cloud.spanner.sample.mappers.AlbumMapper; +import com.google.cloud.spanner.sample.mappers.TrackMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AlbumService { + private final AlbumMapper albumMapper; + + private final TrackMapper trackMapper; + + public AlbumService(AlbumMapper albumMapper, TrackMapper trackMapper) { + this.albumMapper = albumMapper; + this.trackMapper = trackMapper; + } + + /** Creates an album and a set of tracks in a read/write transaction. */ + @Transactional + public Album createAlbumAndTracks(Album album, Track... tracks) { + // Saving an album will update the album entity with the generated primary key. + albumMapper.insert(album); + for (Track track : tracks) { + // Set the id that was generated on the Album before saving it. + track.setId(album.getId()); + trackMapper.insert(track); + } + return album; + } +} diff --git a/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/service/SingerService.java b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/service/SingerService.java new file mode 100644 index 000000000..c56893a1b --- /dev/null +++ b/samples/spring-data-mybatis/googlesql/src/main/java/com/google/cloud/spanner/sample/service/SingerService.java @@ -0,0 +1,67 @@ +/* + * Copyright 2025 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 + * + * http://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.sample.service; + +import com.google.cloud.spanner.sample.entities.Album; +import com.google.cloud.spanner.sample.entities.Singer; +import com.google.cloud.spanner.sample.mappers.AlbumMapper; +import com.google.cloud.spanner.sample.mappers.SingerMapper; +import com.google.common.collect.ImmutableList; +import java.util.List; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class SingerService { + private final SingerMapper singerRepository; + + private final AlbumMapper albumRepository; + + public SingerService(SingerMapper singerRepository, AlbumMapper albumRepository) { + this.singerRepository = singerRepository; + this.albumRepository = albumRepository; + } + + /** Creates a singer and a list of albums in a read/write transaction. */ + @Transactional + public Singer createSingerAndAlbums(Singer singer, Album... albums) { + // Saving a singer will update the singer entity with the generated primary key. + singerRepository.insert(singer); + for (Album album : albums) { + // Set the singerId that was generated on the Album before saving it. + album.setSingerId(singer.getId()); + albumRepository.insert(album); + } + return singer; + } + + /** + * Searches for all singers that have a last name starting with any of the given prefixes. This + * method uses a read-only transaction. Read-only transactions should be preferred to read/write + * transactions whenever possible, as read-only transactions do not take locks. + */ + @Transactional(readOnly = true) + public List listSingersWithLastNameStartingWith(String... prefixes) { + ImmutableList.Builder result = ImmutableList.builder(); + // This is not the most efficient way to search for this, but the main purpose of this method is + // to show how to use read-only transactions. + for (String prefix : prefixes) { + result.addAll(singerRepository.findSingersByLastNameStartingWith(prefix)); + } + return result.build(); + } +} diff --git a/samples/spring-data-mybatis/googlesql/src/main/resources/application.properties b/samples/spring-data-mybatis/googlesql/src/main/resources/application.properties new file mode 100644 index 000000000..bbe258dbf --- /dev/null +++ b/samples/spring-data-mybatis/googlesql/src/main/resources/application.properties @@ -0,0 +1,36 @@ + +# Map column names with an underscore to property names in camel case. +# E.g. column 'full_name' maps to Java property 'fullName'. +mybatis.configuration.map-underscore-to-camel-case=true + +# The sample by default uses the Spanner emulator. +# Disable this flag to run the sample on a real Spanner instance. +spanner.emulator=true + +# The sample by default starts an emulator instance in Docker. +# Disable this flag to run the sample on an Emulator instance that +# you start manually, for example if you don't have Docker installed +# on your local machine. Keep the 'spanner.emulator=true' line above +# to connect to the emulator that you have started. +spanner.auto_start_emulator=true + +# Update these properties to match your project, instance, and database. +spanner.project=my-project +spanner.instance=my-instance +spanner.database=mybatis-sample + +# Sets the isolation level that will be used by default for read/write transactions. +# Spanner supports the isolation levels SERIALIZABLE and REPEATABLE READ. +spanner.default_isolation_level=REPEATABLE_READ + +spring.datasource.url=jdbc:cloudspanner:${spanner.endpoint}/projects/${spanner.project}/instances/${spanner.instance}/databases/${spanner.database};default_isolation_level=${spanner.default_isolation_level};autoConfigEmulator=${spanner.emulator};${spanner.additional_properties} +spring.datasource.driver-class-name=com.google.cloud.spanner.jdbc.JdbcDriver + + +# These properties are only used for testing. + +# This property is automatically set to point to the Spanner emulator that is automatically +# started together with the application. It remains empty if the application is executed +# against a real Spanner instance. +spanner.endpoint= +spanner.additional_properties= diff --git a/samples/spring-data-mybatis/googlesql/src/main/resources/create_schema.sql b/samples/spring-data-mybatis/googlesql/src/main/resources/create_schema.sql new file mode 100644 index 000000000..f54ef6492 --- /dev/null +++ b/samples/spring-data-mybatis/googlesql/src/main/resources/create_schema.sql @@ -0,0 +1,59 @@ + +-- This script creates the database schema for this sample application. +-- The script is executed by the DatabaseSeeder class. + +CREATE TABLE IF NOT EXISTS singers ( + id INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE) PRIMARY KEY, + first_name STRING(MAX), + last_name STRING(MAX), + full_name STRING(MAX) AS (CASE WHEN first_name IS NULL THEN last_name + WHEN last_name IS NULL THEN first_name + ELSE first_name || ' ' || last_name END) STORED, + active BOOL DEFAULT (TRUE), + created_at TIMESTAMP DEFAULT (CURRENT_TIMESTAMP), + updated_at TIMESTAMP DEFAULT (CURRENT_TIMESTAMP), +); + +CREATE TABLE IF NOT EXISTS albums ( + id INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE) PRIMARY KEY, + title STRING(MAX) NOT NULL, + marketing_budget NUMERIC, + release_date DATE, + cover_picture BYTES(MAX), + singer_id INT64 NOT NULL, + created_at TIMESTAMP DEFAULT (CURRENT_TIMESTAMP), + updated_at TIMESTAMP DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT fk_albums_singers FOREIGN KEY (singer_id) REFERENCES singers (id) +); + +CREATE TABLE IF NOT EXISTS tracks ( + id INT64 NOT NULL, + track_number INT64 NOT NULL, + title STRING(MAX) NOT NULL, + sample_rate FLOAT64 NOT NULL, + created_at TIMESTAMP DEFAULT (CURRENT_TIMESTAMP), + updated_at TIMESTAMP DEFAULT (CURRENT_TIMESTAMP), +) PRIMARY KEY (id, track_number), INTERLEAVE IN PARENT albums ON DELETE CASCADE +; + +CREATE TABLE IF NOT EXISTS venues ( + id INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE) PRIMARY KEY, + name STRING(MAX) NOT NULL, + description JSON NOT NULL, + created_at TIMESTAMP DEFAULT (CURRENT_TIMESTAMP), + updated_at TIMESTAMP DEFAULT (CURRENT_TIMESTAMP), +); + +CREATE TABLE IF NOT EXISTS concerts ( + id INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE) PRIMARY KEY, + venue_id INT64 NOT NULL, + singer_id INT64 NOT NULL, + name STRING(MAX) NOT NULL, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT (CURRENT_TIMESTAMP), + updated_at TIMESTAMP DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT fk_concerts_venues FOREIGN KEY (venue_id) REFERENCES venues (id), + CONSTRAINT fk_concerts_singers FOREIGN KEY (singer_id) REFERENCES singers (id), + CONSTRAINT chk_end_time_after_start_time CHECK (end_time > start_time) +); diff --git a/samples/spring-data-mybatis/src/main/resources/drop_schema.sql b/samples/spring-data-mybatis/googlesql/src/main/resources/drop_schema.sql similarity index 100% rename from samples/spring-data-mybatis/src/main/resources/drop_schema.sql rename to samples/spring-data-mybatis/googlesql/src/main/resources/drop_schema.sql diff --git a/samples/spring-data-mybatis/googlesql/src/test/java/com/google/cloud/spanner/sample/ApplicationEmulatorTest.java b/samples/spring-data-mybatis/googlesql/src/test/java/com/google/cloud/spanner/sample/ApplicationEmulatorTest.java new file mode 100644 index 000000000..abdfdcdef --- /dev/null +++ b/samples/spring-data-mybatis/googlesql/src/test/java/com/google/cloud/spanner/sample/ApplicationEmulatorTest.java @@ -0,0 +1,42 @@ +/* + * Copyright 2025 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 + * + * http://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.sample; + +import static org.junit.Assume.assumeTrue; + +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.testcontainers.DockerClientFactory; + +@RunWith(JUnit4.class) +public class ApplicationEmulatorTest { + + @BeforeClass + public static void checkDocker() { + assumeTrue( + "Docker is required for this test", DockerClientFactory.instance().isDockerAvailable()); + } + + @Test + public void testRunApplicationOnEmulator() { + System.setProperty("spanner.emulator", "true"); + System.setProperty("spanner.auto_start_emulator", "true"); + Application.main(new String[] {}); + } +} diff --git a/samples/spring-data-mybatis/googlesql/src/test/java/com/google/cloud/spanner/sample/ApplicationTest.java b/samples/spring-data-mybatis/googlesql/src/test/java/com/google/cloud/spanner/sample/ApplicationTest.java new file mode 100644 index 000000000..ade5d85c6 --- /dev/null +++ b/samples/spring-data-mybatis/googlesql/src/test/java/com/google/cloud/spanner/sample/ApplicationTest.java @@ -0,0 +1,1001 @@ +/* + * Copyright 2025 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 + * + * http://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.sample; + +import static com.google.cloud.spanner.sample.DatabaseSeeder.INITIAL_SINGERS; +import static com.google.cloud.spanner.sample.DatabaseSeeder.randomDate; +import static com.google.cloud.spanner.sample.DatabaseSeeder.randomTitle; +import static junit.framework.TestCase.assertEquals; +import static junit.framework.TestCase.assertTrue; +import static org.junit.Assert.assertNotEquals; + +import com.google.cloud.spanner.MockSpannerServiceImpl.StatementResult; +import com.google.cloud.spanner.Statement; +import com.google.cloud.spanner.connection.AbstractMockServerTest; +import com.google.cloud.spanner.sample.entities.Singer; +import com.google.common.collect.Streams; +import com.google.longrunning.Operation; +import com.google.protobuf.Any; +import com.google.protobuf.Empty; +import com.google.protobuf.ListValue; +import com.google.protobuf.NullValue; +import com.google.protobuf.Value; +import com.google.spanner.admin.database.v1.UpdateDatabaseDdlMetadata; +import com.google.spanner.v1.BeginTransactionRequest; +import com.google.spanner.v1.CommitRequest; +import com.google.spanner.v1.ExecuteBatchDmlRequest; +import com.google.spanner.v1.ExecuteSqlRequest; +import com.google.spanner.v1.ResultSet; +import com.google.spanner.v1.ResultSetMetadata; +import com.google.spanner.v1.ResultSetStats; +import com.google.spanner.v1.StructType; +import com.google.spanner.v1.StructType.Field; +import com.google.spanner.v1.Type; +import com.google.spanner.v1.TypeAnnotationCode; +import com.google.spanner.v1.TypeCode; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.LongStream; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class ApplicationTest extends AbstractMockServerTest { + + @BeforeClass + public static void setupQueryResults() { + // Add a DDL response to the server. + addDdlResponseToSpannerAdmin(); + + // Set up results for the 'delete all test data' operations. + mockSpanner.putStatementResult( + StatementResult.update(Statement.of("delete from concerts where true"), 0L)); + mockSpanner.putStatementResult( + StatementResult.update(Statement.of("delete from venues where true"), 0L)); + mockSpanner.putStatementResult( + StatementResult.update(Statement.of("delete from tracks where true"), 0L)); + mockSpanner.putStatementResult( + StatementResult.update(Statement.of("delete from albums where true"), 0L)); + mockSpanner.putStatementResult( + StatementResult.update(Statement.of("delete from singers where true"), 0L)); + + // Set up results for inserting test data. + for (Singer singer : INITIAL_SINGERS) { + mockSpanner.putStatementResult( + StatementResult.update( + Statement.newBuilder("insert into singers (first_name, last_name) values (@p1, @p2)") + .bind("p1") + .to(singer.getFirstName()) + .bind("p2") + .to(singer.getLastName()) + .build(), + 1L)); + } + mockSpanner.putStatementResult( + StatementResult.query( + Statement.of("select id from singers"), + ResultSet.newBuilder() + .setMetadata( + ResultSetMetadata.newBuilder() + .setRowType( + StructType.newBuilder() + .addFields( + Field.newBuilder() + .setName("id") + .setType(Type.newBuilder().setCode(TypeCode.INT64).build()) + .build()) + .build()) + .build()) + .addAllRows( + LongStream.rangeClosed(1L, INITIAL_SINGERS.size()) + .mapToObj( + id -> + ListValue.newBuilder() + .addValues( + Value.newBuilder() + .setStringValue(String.valueOf(Long.reverse(id))) + .build()) + .build()) + .collect(Collectors.toList())) + .build())); + mockSpanner.putPartialStatementResult( + StatementResult.update( + Statement.of( + "insert into albums (title, marketing_budget, release_date, cover_picture, singer_id) values (@p1, @p2, @p3, @p4, @p5)"), + 1L)); + + // Set up results for the queries that the application runs. + mockSpanner.putStatementResult( + StatementResult.query( + Statement.of("SELECT * FROM singers ORDER BY last_name, first_name, id"), + ResultSet.newBuilder() + .setMetadata( + ResultSetMetadata.newBuilder() + .setRowType( + StructType.newBuilder() + .addFields( + Field.newBuilder() + .setName("id") + .setType(Type.newBuilder().setCode(TypeCode.INT64).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("active") + .setType(Type.newBuilder().setCode(TypeCode.BOOL).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("last_name") + .setType(Type.newBuilder().setCode(TypeCode.STRING).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("full_name") + .setType(Type.newBuilder().setCode(TypeCode.STRING).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("updated_at") + .setType( + Type.newBuilder().setCode(TypeCode.TIMESTAMP).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("created_at") + .setType( + Type.newBuilder().setCode(TypeCode.TIMESTAMP).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("first_name") + .setType(Type.newBuilder().setCode(TypeCode.STRING).build()) + .build()) + .build()) + .build()) + .addAllRows( + Streams.mapWithIndex( + INITIAL_SINGERS.stream(), + (singer, index) -> + ListValue.newBuilder() + .addValues( + Value.newBuilder() + .setStringValue(String.valueOf(Long.reverse(index + 1))) + .build()) + .addValues(Value.newBuilder().setBoolValue(true).build()) + .addValues( + Value.newBuilder() + .setStringValue(singer.getLastName()) + .build()) + .addValues( + Value.newBuilder() + .setStringValue( + singer.getFirstName() + " " + singer.getLastName()) + .build()) + .addValues( + Value.newBuilder() + .setNullValue(NullValue.NULL_VALUE) + .build()) + .addValues( + Value.newBuilder() + .setNullValue(NullValue.NULL_VALUE) + .build()) + .addValues( + Value.newBuilder() + .setStringValue(singer.getFirstName()) + .build()) + .build()) + .collect(Collectors.toList())) + .build())); + mockSpanner.putPartialStatementResult( + StatementResult.query( + Statement.of("SELECT COUNT(1) FROM albums WHERE singer_id = @p1"), + ResultSet.newBuilder() + .setMetadata( + ResultSetMetadata.newBuilder() + .setRowType( + StructType.newBuilder() + .addFields( + Field.newBuilder() + .setName("c") + .setType(Type.newBuilder().setCode(TypeCode.INT64).build()) + .build()) + .build()) + .build()) + .addRows( + ListValue.newBuilder() + .addValues(Value.newBuilder().setStringValue("10").build()) + .build()) + .build())); + for (long singerId : LongStream.rangeClosed(1L, INITIAL_SINGERS.size()).toArray()) { + mockSpanner.putStatementResult( + StatementResult.query( + Statement.newBuilder("SELECT * FROM albums WHERE singer_id = @p1") + .bind("p1") + .to(Long.reverse(singerId)) + .build(), + ResultSet.newBuilder() + .setMetadata( + ResultSetMetadata.newBuilder() + .setRowType( + StructType.newBuilder() + .addFields( + Field.newBuilder() + .setName("id") + .setType( + Type.newBuilder().setCode(TypeCode.INT64).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("title") + .setType( + Type.newBuilder().setCode(TypeCode.STRING).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("singer_id") + .setType( + Type.newBuilder().setCode(TypeCode.INT64).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("updated_at") + .setType( + Type.newBuilder().setCode(TypeCode.TIMESTAMP).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("created_at") + .setType( + Type.newBuilder().setCode(TypeCode.TIMESTAMP).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("release_date") + .setType(Type.newBuilder().setCode(TypeCode.DATE).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("cover_picture") + .setType( + Type.newBuilder().setCode(TypeCode.BYTES).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("marketing_budget") + .setType( + Type.newBuilder() + .setCode(TypeCode.NUMERIC) + .setTypeAnnotation(TypeAnnotationCode.PG_NUMERIC) + .build()) + .build()) + .build()) + .build()) + .addAllRows( + IntStream.rangeClosed(1, 10) + .mapToObj( + albumId -> + ListValue.newBuilder() + .addValues( + Value.newBuilder() + .setStringValue( + String.valueOf(Long.reverse(albumId * singerId))) + .build()) + .addValues(Value.newBuilder().setStringValue(randomTitle())) + .addValues( + Value.newBuilder() + .setStringValue( + String.valueOf(Long.reverse(singerId)))) + .addValues( + Value.newBuilder() + .setNullValue(NullValue.NULL_VALUE) + .build()) + .addValues( + Value.newBuilder() + .setNullValue(NullValue.NULL_VALUE) + .build()) + .addValues( + Value.newBuilder() + .setStringValue(randomDate().toString()) + .build()) + .addValues( + Value.newBuilder() + .setNullValue(NullValue.NULL_VALUE) + .build()) + .addValues( + Value.newBuilder() + .setNullValue(NullValue.NULL_VALUE) + .build()) + .build()) + .collect(Collectors.toList())) + .build())); + } + int singerIndex = ThreadLocalRandom.current().nextInt(INITIAL_SINGERS.size()); + Singer randomSinger = INITIAL_SINGERS.get(singerIndex); + mockSpanner.putStatementResult( + StatementResult.query( + Statement.of("SELECT * FROM singers TABLESAMPLE RESERVOIR (1 ROWS)"), + ResultSet.newBuilder() + .setMetadata( + ResultSetMetadata.newBuilder() + .setRowType( + StructType.newBuilder() + .addFields( + Field.newBuilder() + .setName("id") + .setType(Type.newBuilder().setCode(TypeCode.INT64).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("active") + .setType(Type.newBuilder().setCode(TypeCode.BOOL).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("last_name") + .setType(Type.newBuilder().setCode(TypeCode.STRING).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("full_name") + .setType(Type.newBuilder().setCode(TypeCode.STRING).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("updated_at") + .setType( + Type.newBuilder().setCode(TypeCode.TIMESTAMP).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("created_at") + .setType( + Type.newBuilder().setCode(TypeCode.TIMESTAMP).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("first_name") + .setType(Type.newBuilder().setCode(TypeCode.STRING).build()) + .build()) + .build()) + .build()) + .addRows( + ListValue.newBuilder() + .addValues( + Value.newBuilder() + .setStringValue(String.valueOf(Long.reverse(singerIndex + 1))) + .build()) + .addValues(Value.newBuilder().setBoolValue(true).build()) + .addValues( + Value.newBuilder().setStringValue(randomSinger.getLastName()).build()) + .addValues( + Value.newBuilder() + .setStringValue( + randomSinger.getFirstName() + " " + randomSinger.getLastName()) + .build()) + .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) + .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) + .addValues( + Value.newBuilder().setStringValue(randomSinger.getFirstName()).build()) + .build()) + .build())); + + mockSpanner.putStatementResult( + StatementResult.query( + Statement.newBuilder( + "INSERT INTO singers (first_name, last_name, active) VALUES (@p1, @p2, @p3)\n" + + "THEN RETURN *") + .bind("p1") + .to("Amethyst") + .bind("p2") + .to("Jiang") + .bind("p3") + .to((com.google.cloud.spanner.Value) null) + .build(), + ResultSet.newBuilder() + .setMetadata( + ResultSetMetadata.newBuilder() + .setRowType( + StructType.newBuilder() + .addFields( + Field.newBuilder() + .setName("id") + .setType(Type.newBuilder().setCode(TypeCode.INT64).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("active") + .setType(Type.newBuilder().setCode(TypeCode.BOOL).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("last_name") + .setType(Type.newBuilder().setCode(TypeCode.STRING).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("full_name") + .setType(Type.newBuilder().setCode(TypeCode.STRING).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("updated_at") + .setType( + Type.newBuilder().setCode(TypeCode.TIMESTAMP).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("created_at") + .setType( + Type.newBuilder().setCode(TypeCode.TIMESTAMP).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("first_name") + .setType(Type.newBuilder().setCode(TypeCode.STRING).build()) + .build()) + .build()) + .build()) + .addRows( + ListValue.newBuilder() + .addValues( + Value.newBuilder() + .setStringValue( + String.valueOf(Long.reverse(INITIAL_SINGERS.size() + 2))) + .build()) + .addValues(Value.newBuilder().setBoolValue(true).build()) + .addValues(Value.newBuilder().setStringValue("Amethyst").build()) + .addValues(Value.newBuilder().setStringValue("Amethyst Jiang").build()) + .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) + .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) + .addValues(Value.newBuilder().setStringValue("Jiang").build()) + .build()) + .setStats(ResultSetStats.newBuilder().setRowCountExact(1L).build()) + .build())); + mockSpanner.putPartialStatementResult( + StatementResult.query( + Statement.of( + "INSERT INTO albums (title, marketing_budget, release_date, cover_picture, singer_id) VALUES (@p1, @p2, @p3, @p4, @p5)\n" + + "THEN RETURN *"), + ResultSet.newBuilder() + .setMetadata( + ResultSetMetadata.newBuilder() + .setRowType( + StructType.newBuilder() + .addFields( + Field.newBuilder() + .setName("id") + .setType(Type.newBuilder().setCode(TypeCode.INT64).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("title") + .setType(Type.newBuilder().setCode(TypeCode.STRING).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("singer_id") + .setType(Type.newBuilder().setCode(TypeCode.INT64).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("updated_at") + .setType( + Type.newBuilder().setCode(TypeCode.TIMESTAMP).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("created_at") + .setType( + Type.newBuilder().setCode(TypeCode.TIMESTAMP).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("release_date") + .setType(Type.newBuilder().setCode(TypeCode.DATE).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("cover_picture") + .setType(Type.newBuilder().setCode(TypeCode.BYTES).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("marketing_budget") + .setType( + Type.newBuilder() + .setCode(TypeCode.NUMERIC) + .setTypeAnnotation(TypeAnnotationCode.PG_NUMERIC) + .build()) + .build()) + .build()) + .build()) + .addRows( + ListValue.newBuilder() + .addValues( + Value.newBuilder() + .setStringValue(String.valueOf(Long.reverse(1L))) + .build()) + .addValues(Value.newBuilder().setStringValue(randomTitle())) + .addValues(Value.newBuilder().setStringValue(String.valueOf(1L))) + .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) + .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) + .addValues( + Value.newBuilder().setStringValue(randomDate().toString()).build()) + .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) + .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) + .build()) + .setStats(ResultSetStats.newBuilder().setRowCountExact(1L).build()) + .build())); + mockSpanner.putPartialStatementResult( + StatementResult.query( + Statement.of( + "INSERT INTO tracks (id, track_number, title, sample_rate) VALUES (@p1, @p2, @p3, @p4)\n" + + "THEN RETURN *"), + ResultSet.newBuilder() + .setMetadata( + ResultSetMetadata.newBuilder() + .setRowType( + StructType.newBuilder() + .addFields( + Field.newBuilder() + .setName("id") + .setType(Type.newBuilder().setCode(TypeCode.INT64).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("track_number") + .setType(Type.newBuilder().setCode(TypeCode.INT64).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("title") + .setType(Type.newBuilder().setCode(TypeCode.STRING).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("sample_rate") + .setType( + Type.newBuilder().setCode(TypeCode.FLOAT64).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("updated_at") + .setType( + Type.newBuilder().setCode(TypeCode.TIMESTAMP).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("created_at") + .setType( + Type.newBuilder().setCode(TypeCode.TIMESTAMP).build()) + .build()) + .build()) + .build()) + .addRows( + ListValue.newBuilder() + .addValues( + Value.newBuilder() + .setStringValue(String.valueOf(Long.reverse(1L))) + .build()) + .addValues(Value.newBuilder().setStringValue("1").build()) + .addValues(Value.newBuilder().setStringValue(randomTitle())) + .addValues(Value.newBuilder().setNumberValue(3.14d)) + .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) + .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) + .build()) + .setStats(ResultSetStats.newBuilder().setRowCountExact(1L).build()) + .build())); + mockSpanner.putStatementResult( + StatementResult.query( + Statement.of("select * from albums limit 1"), + ResultSet.newBuilder() + .setMetadata( + ResultSetMetadata.newBuilder() + .setRowType( + StructType.newBuilder() + .addFields( + Field.newBuilder() + .setName("id") + .setType(Type.newBuilder().setCode(TypeCode.INT64).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("title") + .setType(Type.newBuilder().setCode(TypeCode.STRING).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("singer_id") + .setType(Type.newBuilder().setCode(TypeCode.INT64).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("updated_at") + .setType( + Type.newBuilder().setCode(TypeCode.TIMESTAMP).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("created_at") + .setType( + Type.newBuilder().setCode(TypeCode.TIMESTAMP).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("release_date") + .setType(Type.newBuilder().setCode(TypeCode.DATE).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("cover_picture") + .setType(Type.newBuilder().setCode(TypeCode.BYTES).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("marketing_budget") + .setType( + Type.newBuilder() + .setCode(TypeCode.NUMERIC) + .setTypeAnnotation(TypeAnnotationCode.PG_NUMERIC) + .build()) + .build()) + .build()) + .build()) + .addRows( + ListValue.newBuilder() + .addValues( + Value.newBuilder() + .setStringValue(String.valueOf(Long.reverse(1L))) + .build()) + .addValues(Value.newBuilder().setStringValue(randomTitle())) + .addValues(Value.newBuilder().setStringValue(String.valueOf(1L))) + .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) + .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) + .addValues( + Value.newBuilder().setStringValue(randomDate().toString()).build()) + .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) + .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) + .build()) + .setStats(ResultSetStats.newBuilder().setRowCountExact(1L).build()) + .build())); + for (String prefix : new String[] {"J", "A", "B", "C"}) { + mockSpanner.putStatementResult( + StatementResult.query( + Statement.newBuilder("SELECT * FROM singers WHERE starts_with(last_name, @p1)") + .bind("p1") + .to(prefix) + .build(), + ResultSet.newBuilder() + .setMetadata( + ResultSetMetadata.newBuilder() + .setRowType( + StructType.newBuilder() + .addFields( + Field.newBuilder() + .setName("id") + .setType( + Type.newBuilder().setCode(TypeCode.INT64).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("active") + .setType(Type.newBuilder().setCode(TypeCode.BOOL).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("last_name") + .setType( + Type.newBuilder().setCode(TypeCode.STRING).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("full_name") + .setType( + Type.newBuilder().setCode(TypeCode.STRING).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("updated_at") + .setType( + Type.newBuilder().setCode(TypeCode.TIMESTAMP).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("created_at") + .setType( + Type.newBuilder().setCode(TypeCode.TIMESTAMP).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("first_name") + .setType( + Type.newBuilder().setCode(TypeCode.STRING).build()) + .build()) + .build()) + .build()) + .addAllRows( + Streams.mapWithIndex( + INITIAL_SINGERS.stream() + .filter( + singer -> + singer.getLastName().startsWith(prefix.substring(0, 1))), + (singer, index) -> + ListValue.newBuilder() + .addValues( + Value.newBuilder() + .setStringValue( + String.valueOf(Long.reverse(index + 1))) + .build()) + .addValues(Value.newBuilder().setBoolValue(true).build()) + .addValues( + Value.newBuilder() + .setStringValue(singer.getLastName()) + .build()) + .addValues( + Value.newBuilder() + .setStringValue( + singer.getFirstName() + + " " + + singer.getLastName()) + .build()) + .addValues( + Value.newBuilder() + .setNullValue(NullValue.NULL_VALUE) + .build()) + .addValues( + Value.newBuilder() + .setNullValue(NullValue.NULL_VALUE) + .build()) + .addValues( + Value.newBuilder() + .setStringValue(singer.getFirstName()) + .build()) + .build()) + .collect(Collectors.toList())) + .build())); + mockSpanner.putPartialStatementResult( + StatementResult.query( + Statement.of( + "INSERT OR UPDATE singers (id, first_name, last_name, active) VALUES (@p1, @p2, @p3, @p4)\n" + + "THEN RETURN *"), + ResultSet.newBuilder() + .setMetadata( + ResultSetMetadata.newBuilder() + .setRowType( + StructType.newBuilder() + .addFields( + Field.newBuilder() + .setName("id") + .setType( + Type.newBuilder().setCode(TypeCode.INT64).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("active") + .setType(Type.newBuilder().setCode(TypeCode.BOOL).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("last_name") + .setType( + Type.newBuilder().setCode(TypeCode.STRING).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("full_name") + .setType( + Type.newBuilder().setCode(TypeCode.STRING).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("updated_at") + .setType( + Type.newBuilder().setCode(TypeCode.TIMESTAMP).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("created_at") + .setType( + Type.newBuilder().setCode(TypeCode.TIMESTAMP).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("first_name") + .setType( + Type.newBuilder().setCode(TypeCode.STRING).build()) + .build()) + .build()) + .build()) + .addRows( + ListValue.newBuilder() + .addValues( + Value.newBuilder() + .setStringValue( + String.valueOf(ThreadLocalRandom.current().nextLong())) + .build()) + .addValues(Value.newBuilder().setBoolValue(true).build()) + .addValues(Value.newBuilder().setStringValue("Russel").build()) + .addValues(Value.newBuilder().setStringValue("Beatriz Russel").build()) + .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) + .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) + .addValues(Value.newBuilder().setStringValue("Beatriz").build()) + .build()) + .setStats(ResultSetStats.newBuilder().setRowCountExact(1L).build()) + .build())); + } + } + + @Test + public void testRunApplication() { + System.setProperty("spanner.emulator", "false"); + System.setProperty("spanner.endpoint", "//localhost:" + getPort()); + System.setProperty("spanner.additional_properties", "usePlainText=true"); + Application.main(new String[] {}); + + assertEquals( + 39, + mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).stream() + .filter( + request -> + !request.getSql().equals("SELECT 1") + && !request + .getSql() + .equals( + "SELECT * FROM singers ORDER BY sha256(last_name::bytea) LIMIT 1")) + .count()); + assertEquals(3, mockSpanner.countRequestsOfType(ExecuteBatchDmlRequest.class)); + assertEquals(6, mockSpanner.countRequestsOfType(CommitRequest.class)); + + // Verify that the service methods use transactions. + String insertSingerSql = + "INSERT INTO singers (first_name, last_name, active) VALUES (@p1, @p2, @p3)\nTHEN RETURN *"; + assertEquals( + 1, + mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).stream() + .filter(request -> request.getSql().equals(insertSingerSql)) + .count()); + ExecuteSqlRequest insertSingerRequest = + mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).stream() + .filter(request -> request.getSql().equals(insertSingerSql)) + .findFirst() + .orElseThrow(); + assertTrue(insertSingerRequest.hasTransaction()); + assertTrue(insertSingerRequest.getTransaction().hasBegin()); + assertTrue(insertSingerRequest.getTransaction().getBegin().hasReadWrite()); + String insertAlbumSql = + "INSERT INTO albums (title, marketing_budget, release_date, cover_picture, singer_id) " + + "VALUES (@p1, @p2, @p3, @p4, @p5)\nTHEN RETURN *"; + assertEquals( + 4, + mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).stream() + .filter(request -> request.getSql().equals(insertAlbumSql)) + .count()); + // The first 3 requests belong to the transaction that is executed together with the 'INSERT + // INTO singers' statement. + List insertAlbumRequests = + mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).stream() + .filter(request -> request.getSql().equals(insertAlbumSql)) + .toList() + .subList(0, 3); + ExecuteSqlRequest firstInsertAlbumRequest = insertAlbumRequests.get(0); + for (ExecuteSqlRequest request : insertAlbumRequests) { + assertTrue(request.hasTransaction()); + assertTrue(request.getTransaction().hasId()); + assertEquals( + firstInsertAlbumRequest.getTransaction().getId(), request.getTransaction().getId()); + } + // Verify that the transaction is committed. + assertEquals( + 1, + mockSpanner.getRequestsOfType(CommitRequest.class).stream() + .filter( + request -> + request + .getTransactionId() + .equals(firstInsertAlbumRequest.getTransaction().getId())) + .count()); + + // The last 'INSERT INTO albums' request belong in a transaction with 8 'INSERT INTO tracks' + // requests. + ExecuteSqlRequest lastInsertAlbumRequest = + mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).stream() + .filter(request -> request.getSql().equals(insertAlbumSql)) + .toList() + .get(3); + assertNotEquals( + lastInsertAlbumRequest.getTransaction().getId(), + firstInsertAlbumRequest.getTransaction().getId()); + assertTrue(lastInsertAlbumRequest.hasTransaction()); + assertTrue(lastInsertAlbumRequest.getTransaction().hasBegin()); + assertTrue(lastInsertAlbumRequest.getTransaction().getBegin().hasReadWrite()); + String insertTrackSql = + "INSERT INTO tracks (id, track_number, title, sample_rate) " + + "VALUES (@p1, @p2, @p3, @p4)\nTHEN RETURN *"; + assertEquals( + 7, + mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).stream() + .filter(request -> request.getSql().equals(insertTrackSql)) + .count()); + List insertTrackRequests = + mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).stream() + .filter(request -> request.getSql().equals(insertTrackSql)) + .toList(); + for (ExecuteSqlRequest request : insertTrackRequests) { + assertTrue(request.hasTransaction()); + assertTrue(request.getTransaction().hasId()); + assertEquals( + insertTrackRequests.get(0).getTransaction().getId(), request.getTransaction().getId()); + } + // Verify that the transaction is committed. + assertEquals( + 1, + mockSpanner.getRequestsOfType(CommitRequest.class).stream() + .filter( + request -> + request + .getTransactionId() + .equals(insertTrackRequests.get(0).getTransaction().getId())) + .count()); + + // Verify that the SingerService#listSingersWithLastNameStartingWith(..) method uses a read-only + // transaction. + assertEquals( + 1, + mockSpanner.getRequestsOfType(BeginTransactionRequest.class).stream() + .filter(request -> request.getOptions().hasReadOnly()) + .count()); + String selectSingersSql = "SELECT * FROM singers WHERE starts_with(last_name, @p1)"; + assertEquals( + 4, + mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).stream() + .filter(request -> request.getSql().equals(selectSingersSql)) + .count()); + List selectSingersRequests = + mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).stream() + .filter(request -> request.getSql().equals(selectSingersSql)) + .toList() + .subList(1, 4); + ExecuteSqlRequest firstSelectSingersRequest = selectSingersRequests.get(0); + for (ExecuteSqlRequest request : selectSingersRequests) { + assertTrue(request.hasTransaction()); + assertTrue(request.getTransaction().hasId()); + } + // Verify that the read-only transaction is not committed. + assertEquals( + 0, + mockSpanner.getRequestsOfType(CommitRequest.class).stream() + .filter( + request -> + request + .getTransactionId() + .equals(firstSelectSingersRequest.getTransaction().getId())) + .count()); + } + + private static void addDdlResponseToSpannerAdmin() { + mockDatabaseAdmin.addResponse( + Operation.newBuilder() + .setDone(true) + .setResponse(Any.pack(Empty.getDefaultInstance())) + .setMetadata(Any.pack(UpdateDatabaseDdlMetadata.getDefaultInstance())) + .build()); + } +} diff --git a/samples/spring-data-mybatis/postgresql/README.md b/samples/spring-data-mybatis/postgresql/README.md new file mode 100644 index 000000000..61dc46f48 --- /dev/null +++ b/samples/spring-data-mybatis/postgresql/README.md @@ -0,0 +1,100 @@ +# Spring Data MyBatis Sample Application with Cloud Spanner PostgreSQL + +This sample application shows how to develop portable applications using Spring Data MyBatis in +combination with Cloud Spanner PostgreSQL. This application can be configured to run on either a +[Cloud Spanner PostgreSQL](https://cloud.google.com/spanner/docs/postgresql-interface) database or +an open-source PostgreSQL database. The only change that is needed to switch between the two is +changing the active Spring profile that is used by the application. + +The application uses the Cloud Spanner JDBC driver to connect to Cloud Spanner PostgreSQL, and it +uses the PostgreSQL JDBC driver to connect to open-source PostgreSQL. Spring Data MyBatis works with +both drivers and offers a single consistent API to the application developer, regardless of the +actual database or JDBC driver being used. + +This sample shows: + +1. How to use Spring Data MyBatis with Cloud Spanner PostgreSQL. +2. How to develop a portable application that runs on both Google Cloud Spanner PostgreSQL and + open-source PostgreSQL with the same code base. +3. How to use bit-reversed sequences to automatically generate primary key values for entities. +4. How to use the Spanner Emulator for development in combination with Spring Data. + +__NOTE__: This application does __not require PGAdapter__. Instead, it connects to Cloud Spanner +PostgreSQL using the Cloud Spanner JDBC driver. + +## Cloud Spanner PostgreSQL + +Cloud Spanner PostgreSQL provides language support by expressing Spanner database functionality +through a subset of open-source PostgreSQL language constructs, with extensions added to support +Spanner functionality like interleaved tables and hinting. + +The PostgreSQL interface makes the capabilities of Spanner —__fully managed, unlimited scale, strong +consistency, high performance, and up to 99.999% global availability__— accessible using the +PostgreSQL dialect. Unlike other services that manage actual PostgreSQL database instances, Spanner +uses PostgreSQL-compatible syntax to expose its existing scale-out capabilities. This provides +familiarity for developers and portability for applications, but not 100% PostgreSQL compatibility. +The SQL syntax that Spanner supports is semantically equivalent PostgreSQL, meaning schemas +and queries written against the PostgreSQL interface can be easily ported to another PostgreSQL +environment. + +This sample showcases this portability with an application that works on both Cloud Spanner PostgreSQL +and open-source PostgreSQL with the same code base. + +## MyBatis Spring +[MyBatis Spring](http://mybatis.org/spring/) integrates MyBatis with the popular Java Spring +framework. This allows MyBatis to participate in Spring transactions and to automatically inject +MyBatis mappers into other beans. + +## Sample Application + +This sample shows how to create a portable application using Spring Data MyBatis and the Cloud Spanner +PostgreSQL dialect. The application works on both Cloud Spanner PostgreSQL and open-source +PostgreSQL. You can switch between the two by changing the active Spring profile: +* Profile `cs` runs the application on Cloud Spanner PostgreSQL. +* Profile `pg` runs the application on open-source PostgreSQL. + +The default profile is `cs`. You can change the default profile by modifying the +[application.properties](src/main/resources/application.properties) file. + +### Running the Application + +1. Choose the database system that you want to use by choosing a profile. The default profile is + `cs`, which runs the application on Cloud Spanner PostgreSQL. +2. The sample by default starts an instance of the Spanner Emulator together with the application and + runs the application against the emulator. +3. Modify the default profile in the [application.properties](src/main/resources/application.properties) + file to run the sample on an open-source PostgreSQL database. +4. Modify either [application-cs.properties](src/main/resources/application-cs.properties) or + [application-pg.properties](src/main/resources/application-pg.properties) to point to an existing + database. If you use Cloud Spanner, the database that the configuration file references must be a + database that uses the PostgreSQL dialect. +5. Run the application with `mvn spring-boot:run`. + +### Main Application Components + +The main application components are: +* [DatabaseSeeder.java](src/main/java/com/google/cloud/spanner/sample/DatabaseSeeder.java): This + class is responsible for creating the database schema and inserting some initial test data. The + schema is created from the [create_schema.sql](src/main/resources/create_schema.sql) file. The + `DatabaseSeeder` class loads this file into memory and executes it on the active database using + standard JDBC APIs. The class also removes Cloud Spanner-specific extensions to the PostgreSQL + dialect when the application runs on open-source PostgreSQL. +* [JdbcConfiguration.java](src/main/java/com/google/cloud/spanner/sample/JdbcConfiguration.java): + This utility class is used to determine whether the application is running on Cloud Spanner + PostgreSQL or open-source PostgreSQL. This can be used if you have specific features that should + only be executed on one of the two systems. +* [EmulatorInitializer.java](src/main/java/com/google/cloud/spanner/sample/EmulatorInitializer.java): + This ApplicationListener automatically starts the Spanner emulator as a Docker container if the + sample has been configured to run on the emulator. +* [AbstractEntity.java](src/main/java/com/google/cloud/spanner/sample/entities/AbstractEntity.java): + This is the shared base class for all entities in this sample application. It defines a number of + standard attributes, such as the identifier (primary key). The primary key is automatically + generated using a (bit-reversed) sequence. [Bit-reversed sequential values](https://cloud.google.com/spanner/docs/schema-design#bit_reverse_primary_key) + are considered a good choice for primary keys on Cloud Spanner. +* [Application.java](src/main/java/com/google/cloud/spanner/sample/Application.java): The starter + class of the application. It contains a command-line runner that executes a selection of queries + and updates on the database. +* [SingerService](src/main/java/com/google/cloud/spanner/sample/service/SingerService.java) and + [AlbumService](src/main/java/com/google/cloud/spanner/sample/service/SingerService.java) are + standard Spring service beans that contain business logic that can be executed as transactions. + This includes both read/write and read-only transactions. diff --git a/samples/spring-data-mybatis/pom.xml b/samples/spring-data-mybatis/postgresql/pom.xml similarity index 97% rename from samples/spring-data-mybatis/pom.xml rename to samples/spring-data-mybatis/postgresql/pom.xml index 80ac5eb9a..ccc3499b7 100644 --- a/samples/spring-data-mybatis/pom.xml +++ b/samples/spring-data-mybatis/postgresql/pom.xml @@ -5,7 +5,7 @@ 4.0.0 org.example - cloud-spanner-spring-data-mybatis-example + cloud-spanner-spring-data-mybatis-postgresql-example 1.0-SNAPSHOT Sample application showing how to use Spring Data MyBatis with Cloud Spanner PostgreSQL. diff --git a/samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/Application.java b/samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/Application.java similarity index 100% rename from samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/Application.java rename to samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/Application.java diff --git a/samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/DatabaseSeeder.java b/samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/DatabaseSeeder.java similarity index 100% rename from samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/DatabaseSeeder.java rename to samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/DatabaseSeeder.java diff --git a/samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/EmulatorInitializer.java b/samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/EmulatorInitializer.java similarity index 100% rename from samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/EmulatorInitializer.java rename to samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/EmulatorInitializer.java diff --git a/samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/JdbcConfiguration.java b/samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/JdbcConfiguration.java similarity index 100% rename from samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/JdbcConfiguration.java rename to samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/JdbcConfiguration.java diff --git a/samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/entities/AbstractEntity.java b/samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/entities/AbstractEntity.java similarity index 100% rename from samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/entities/AbstractEntity.java rename to samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/entities/AbstractEntity.java diff --git a/samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/entities/Album.java b/samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/entities/Album.java similarity index 100% rename from samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/entities/Album.java rename to samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/entities/Album.java diff --git a/samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/entities/Concert.java b/samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/entities/Concert.java similarity index 100% rename from samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/entities/Concert.java rename to samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/entities/Concert.java diff --git a/samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/entities/Singer.java b/samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/entities/Singer.java similarity index 100% rename from samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/entities/Singer.java rename to samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/entities/Singer.java diff --git a/samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/entities/Track.java b/samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/entities/Track.java similarity index 100% rename from samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/entities/Track.java rename to samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/entities/Track.java diff --git a/samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/entities/Venue.java b/samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/entities/Venue.java similarity index 100% rename from samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/entities/Venue.java rename to samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/entities/Venue.java diff --git a/samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/mappers/AlbumMapper.java b/samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/mappers/AlbumMapper.java similarity index 100% rename from samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/mappers/AlbumMapper.java rename to samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/mappers/AlbumMapper.java diff --git a/samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/mappers/ConcertMapper.java b/samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/mappers/ConcertMapper.java similarity index 100% rename from samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/mappers/ConcertMapper.java rename to samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/mappers/ConcertMapper.java diff --git a/samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/mappers/SingerMapper.java b/samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/mappers/SingerMapper.java similarity index 100% rename from samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/mappers/SingerMapper.java rename to samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/mappers/SingerMapper.java diff --git a/samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/mappers/TrackMapper.java b/samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/mappers/TrackMapper.java similarity index 100% rename from samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/mappers/TrackMapper.java rename to samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/mappers/TrackMapper.java diff --git a/samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/mappers/VenueMapper.java b/samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/mappers/VenueMapper.java similarity index 100% rename from samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/mappers/VenueMapper.java rename to samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/mappers/VenueMapper.java diff --git a/samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/service/AlbumService.java b/samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/service/AlbumService.java similarity index 100% rename from samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/service/AlbumService.java rename to samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/service/AlbumService.java diff --git a/samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/service/SingerService.java b/samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/service/SingerService.java similarity index 100% rename from samples/spring-data-mybatis/src/main/java/com/google/cloud/spanner/sample/service/SingerService.java rename to samples/spring-data-mybatis/postgresql/src/main/java/com/google/cloud/spanner/sample/service/SingerService.java diff --git a/samples/spring-data-mybatis/src/main/resources/application-cs.properties b/samples/spring-data-mybatis/postgresql/src/main/resources/application-cs.properties similarity index 100% rename from samples/spring-data-mybatis/src/main/resources/application-cs.properties rename to samples/spring-data-mybatis/postgresql/src/main/resources/application-cs.properties diff --git a/samples/spring-data-mybatis/src/main/resources/application-pg.properties b/samples/spring-data-mybatis/postgresql/src/main/resources/application-pg.properties similarity index 100% rename from samples/spring-data-mybatis/src/main/resources/application-pg.properties rename to samples/spring-data-mybatis/postgresql/src/main/resources/application-pg.properties diff --git a/samples/spring-data-mybatis/src/main/resources/application.properties b/samples/spring-data-mybatis/postgresql/src/main/resources/application.properties similarity index 100% rename from samples/spring-data-mybatis/src/main/resources/application.properties rename to samples/spring-data-mybatis/postgresql/src/main/resources/application.properties diff --git a/samples/spring-data-mybatis/src/main/resources/create_schema.sql b/samples/spring-data-mybatis/postgresql/src/main/resources/create_schema.sql similarity index 100% rename from samples/spring-data-mybatis/src/main/resources/create_schema.sql rename to samples/spring-data-mybatis/postgresql/src/main/resources/create_schema.sql diff --git a/samples/spring-data-mybatis/postgresql/src/main/resources/drop_schema.sql b/samples/spring-data-mybatis/postgresql/src/main/resources/drop_schema.sql new file mode 100644 index 000000000..23e7b65d3 --- /dev/null +++ b/samples/spring-data-mybatis/postgresql/src/main/resources/drop_schema.sql @@ -0,0 +1,5 @@ +drop table if exists concerts; +drop table if exists venues; +drop table if exists tracks; +drop table if exists albums; +drop table if exists singers; diff --git a/samples/spring-data-mybatis/src/test/java/com/google/cloud/spanner/sample/ApplicationEmulatorTest.java b/samples/spring-data-mybatis/postgresql/src/test/java/com/google/cloud/spanner/sample/ApplicationEmulatorTest.java similarity index 100% rename from samples/spring-data-mybatis/src/test/java/com/google/cloud/spanner/sample/ApplicationEmulatorTest.java rename to samples/spring-data-mybatis/postgresql/src/test/java/com/google/cloud/spanner/sample/ApplicationEmulatorTest.java diff --git a/samples/spring-data-mybatis/src/test/java/com/google/cloud/spanner/sample/ApplicationTest.java b/samples/spring-data-mybatis/postgresql/src/test/java/com/google/cloud/spanner/sample/ApplicationTest.java similarity index 100% rename from samples/spring-data-mybatis/src/test/java/com/google/cloud/spanner/sample/ApplicationTest.java rename to samples/spring-data-mybatis/postgresql/src/test/java/com/google/cloud/spanner/sample/ApplicationTest.java