diff --git a/.github/workflows/spring-data-jdbc-sample.yaml b/.github/workflows/spring-data-jdbc-sample.yaml index 0185a2905..7d5b4ea41 100644 --- a/.github/workflows/spring-data-jdbc-sample.yaml +++ b/.github/workflows/spring-data-jdbc-sample.yaml @@ -25,6 +25,9 @@ jobs: with: distribution: temurin java-version: 17 - - name: Run tests + - name: Run tests on GoogleSQL run: mvn test - working-directory: samples/spring-data-jdbc + working-directory: samples/spring-data-jdbc/googlesql + - name: Run tests on PostgreSQL + run: mvn test + working-directory: samples/spring-data-jdbc/postgresql diff --git a/samples/spring-data-jdbc/README.md b/samples/spring-data-jdbc/README.md index da1d69532..4b6dbcc57 100644 --- a/samples/spring-data-jdbc/README.md +++ b/samples/spring-data-jdbc/README.md @@ -1,95 +1,17 @@ -# Spring Data JDBC Sample Application with Cloud Spanner PostgreSQL +# Spring Data JDBC -This sample application shows how to develop portable applications using Spring Data JDBC 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 JDBC 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 JDBC 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. - -__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. - -## Spring Data JDBC +This directory contains two sample applications for using Spring Data JDBC +with the Spanner JDBC driver. [Spring Data JDBC](https://spring.io/projects/spring-data-jdbc) is part of the larger Spring Data -family. It makes it easy to implement JDBC based repositories. This module deals with enhanced -support for JDBC based data access layers. +family. It makes it easy to implement JDBC based repositories. +This module deals with enhanced support for JDBC based data access layers. Spring Data JDBC aims at being conceptually easy. In order to achieve this it does NOT offer caching, lazy loading, write behind or many other features of JPA. This makes Spring Data JDBC a simple, limited, opinionated ORM. -## Sample Application - -This sample shows how to create a portable application using Spring Data JDBC 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. Modify the default profile in the - [application.properties](src/main/resources/application.properties) file. -2. 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. -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. 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): - Spring Data JDBC by default detects the database dialect based on the JDBC driver that is used. - This class overrides this default and instructs Spring Data JDBC to also use the PostgreSQL - dialect for Cloud Spanner PostgreSQL. -* [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. - +- [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-jdbc/googlesql/README.md b/samples/spring-data-jdbc/googlesql/README.md new file mode 100644 index 000000000..7abe334ef --- /dev/null +++ b/samples/spring-data-jdbc/googlesql/README.md @@ -0,0 +1,57 @@ +# Spring Data JDBC Sample Application with Spanner GoogleSQL + +This sample application shows how to use Spring Data JDBC with Spanner GoogleSQL. + +This sample shows: + +1. How to use Spring Data JDBC with Spanner GoogleSQL. +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. + +## Spring Data JDBC + +[Spring Data JDBC](https://spring.io/projects/spring-data-jdbc) is part of the larger Spring Data +family. It makes it easy to implement JDBC based repositories. This module deals with enhanced +support for JDBC based data access layers. + +Spring Data JDBC aims at being conceptually easy. In order to achieve this it does NOT offer caching, +lazy loading, write behind or many other features of JPA. This makes Spring Data JDBC a simple, +limited, opinionated ORM. + +### Running the Application + +The application by default runs on the Spanner Emulator. + +1. Modify the [application.properties](src/main/resources/application.properties) to point to an existing + database. The database must use the GoogleSQL dialect. +2. 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. +* [SpannerDialectProvider](src/main/java/com/google/cloud/spanner/sample/SpannerDialectProvider.java): + Spring Data JDBC by default detects the database dialect based on the JDBC driver that is used. + Spanner GoogleSQL is not automatically recognized by Spring Data, so we add a dialect provider + for Spanner. +* [SpannerDialect](src/main/java/com/google/cloud/spanner/sample/SpannerDialect.java): + Spring Data JDBC requires a dialect for the database, so it knows which features are supported, + and how to build clauses like `LIMIT` and `FOR UPDATE`. This class provides this information. It + is based on the built-in `AnsiDialect` in Spring Data JDBC. +* [JdbcConfiguration.java](src/main/java/com/google/cloud/spanner/sample/JdbcConfiguration.java): + This configuration file serves two purposes: + 1. Make sure `OpenTelemetry` is initialized before any data sources. + 2. Add a converter for `LocalDate` properties. Spring Data JDBC by default map these to `TIMESTAMP` + columns, but a better fit in Spanner is `DATE`. +* [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. diff --git a/samples/spring-data-jdbc/googlesql/pom.xml b/samples/spring-data-jdbc/googlesql/pom.xml new file mode 100644 index 000000000..57b0dea29 --- /dev/null +++ b/samples/spring-data-jdbc/googlesql/pom.xml @@ -0,0 +1,138 @@ + + + 4.0.0 + + org.example + cloud-spanner-spring-data-jdbc-googlesql-example + 1.0-SNAPSHOT + + Sample application showing how to use Spring Data JDBC with Cloud Spanner GoogleSQL. + + + + 17 + 17 + 17 + UTF-8 + + + + + + org.springframework.data + spring-data-bom + 2024.1.5 + import + pom + + + com.google.cloud + google-cloud-spanner-bom + 6.91.1 + import + pom + + + com.google.cloud + libraries-bom + 26.59.0 + import + pom + + + io.opentelemetry + opentelemetry-bom + 1.49.0 + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-data-jdbc + 3.4.5 + + + + + com.google.cloud + google-cloud-spanner-jdbc + + + com.google.api.grpc + proto-google-cloud-spanner-executor-v1 + + + + + + + io.opentelemetry + opentelemetry-sdk + + + com.google.cloud.opentelemetry + exporter-trace + 0.34.0 + + + com.google.cloud.opentelemetry + exporter-metrics + 0.34.0 + + + + + org.testcontainers + testcontainers + 1.21.0 + + + + com.google.collections + google-collections + 1.0 + + + + + com.google.cloud + google-cloud-spanner + 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-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/Application.java b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/Application.java new file mode 100644 index 000000000..a75ea2fec --- /dev/null +++ b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/Application.java @@ -0,0 +1,269 @@ +/* + * 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.SavepointSupport; +import com.google.cloud.spanner.connection.SpannerPool; +import com.google.cloud.spanner.jdbc.CloudSpannerJdbcConnection; +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.repositories.AlbumRepository; +import com.google.cloud.spanner.sample.repositories.SingerRepository; +import com.google.cloud.spanner.sample.repositories.TrackRepository; +import com.google.cloud.spanner.sample.service.SingerService; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Savepoint; +import java.sql.Statement; +import javax.sql.DataSource; +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) { + // This automatically starts the Spanner emulator in a Docker container, unless the + // spanner.auto_start_emulator property has been set to false. In that case, it 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 SingerRepository singerRepository; + + private final AlbumRepository albumRepository; + + private final TrackRepository trackRepository; + + private final Tracer tracer; + + private final DataSource dataSource; + + public Application( + SingerService singerService, + DatabaseSeeder databaseSeeder, + SingerRepository singerRepository, + AlbumRepository albumRepository, + TrackRepository trackRepository, + Tracer tracer, + DataSource dataSource) { + this.databaseSeeder = databaseSeeder; + this.singerService = singerService; + this.singerRepository = singerRepository; + this.albumRepository = albumRepository; + this.trackRepository = trackRepository; + this.tracer = tracer; + this.dataSource = dataSource; + } + + @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 = singerRepository.findAll(); + for (Singer singer : allSingers) { + logger.info( + "Found singer: {} with {} albums", + singer, + albumRepository.countAlbumsBySingerId(singer.getId())); + for (Album album : albumRepository.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 track record and insert it into the database. + Album album = albumRepository.getFirst().orElseThrow(); + Track track = new Track(album, 1, DatabaseSeeder.randomTitle()); + track.setSampleRate(3.14d); + // Spring Data JDBC supports the same base CRUD operations on entities as for example + // Spring Data JPA. + trackRepository.save(track); + + // 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 : singerRepository.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()); + } + + // Run two concurrent transactions that conflict with each other to show the automatic retry + // behavior built into the JDBC driver. + concurrentTransactions(); + + // Use a savepoint to roll back to a previous point in a transaction. + savepoints(); + } + + void concurrentTransactions() { + // Create two transactions that conflict with each other to trigger a transaction retry. + // This sample is intended to show a couple of things: + // 1. Spanner will abort transactions that conflict. The Spanner JDBC driver will automatically + // retry aborted transactions internally, which ensures that both these transactions + // succeed without any errors. See + // https://cloud.google.com/spanner/docs/jdbc-session-mgmt-commands#retry_aborts_internally + // for more information on how the JDBC driver retries aborted transactions. + // 2. The JDBC driver adds information to the OpenTelemetry tracing that makes it easier to find + // transactions that were aborted and retried. + logger.info("Executing two concurrent transactions"); + Span span = tracer.spanBuilder("update-singers").startSpan(); + try (Scope ignore = span.makeCurrent(); + Connection connection1 = dataSource.getConnection(); + Connection connection2 = dataSource.getConnection(); + Statement statement1 = connection1.createStatement(); + Statement statement2 = connection2.createStatement()) { + statement1.execute("begin"); + statement1.execute("set transaction_tag='update-singer-1'"); + statement2.execute("begin"); + statement2.execute("set transaction_tag='update-singer-2'"); + long id = 0L; + statement1.execute("set statement_tag='fetch-singer-id'"); + try (ResultSet resultSet = statement1.executeQuery("select id from singers limit 1")) { + while (resultSet.next()) { + id = resultSet.getLong(1); + } + } + String sql = "update singers set active=not active where id=?"; + statement1.execute("set statement_tag='update-singer-1'"); + try (PreparedStatement preparedStatement = connection1.prepareStatement(sql)) { + preparedStatement.setLong(1, id); + preparedStatement.executeUpdate(); + } + statement2.execute("set statement_tag='update-singer-2'"); + try (PreparedStatement preparedStatement = connection2.prepareStatement(sql)) { + preparedStatement.setLong(1, id); + preparedStatement.executeUpdate(); + } + statement1.execute("commit"); + statement2.execute("commit"); + } catch (SQLException exception) { + span.recordException(exception); + throw new RuntimeException(exception); + } finally { + span.end(); + } + } + + void savepoints() { + // Run a transaction with a savepoint, and rollback to that savepoint. + logger.info("Executing a transaction with a savepoint"); + Span span = tracer.spanBuilder("savepoint-sample").startSpan(); + try (Scope ignore = span.makeCurrent(); + Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement()) { + // Enable savepoints for this connection. + connection + .unwrap(CloudSpannerJdbcConnection.class) + .setSavepointSupport(SavepointSupport.ENABLED); + + statement.execute("begin"); + statement.execute("set transaction_tag='transaction-with-savepoint'"); + + // Fetch a random album. + long id = 0L; + try (ResultSet resultSet = + statement.executeQuery( + "/*@statement_tag='fetch-album-id'*/ select id from albums limit 1")) { + while (resultSet.next()) { + id = resultSet.getLong(1); + } + } + // Set a savepoint that we can roll back to at a later moment in the transaction. + // Note that the savepoint name must be a valid identifier. + Savepoint savepoint = connection.setSavepoint("fetched_album_id"); + + String sql = + "/*@statement_tag='update-album-marketing-budget-by-10-percent'*/ update albums set marketing_budget=marketing_budget * 1.1 where id=?"; + try (PreparedStatement preparedStatement = connection.prepareStatement(sql)) { + preparedStatement.setLong(1, id); + preparedStatement.executeUpdate(); + } + + // Rollback to the savepoint that we set at an earlier stage, and then update the marketing + // budget by 20 percent instead. + connection.rollback(savepoint); + + sql = + "/*@statement_tag='update-album-marketing-budget-by-20-percent'*/ update albums set marketing_budget=marketing_budget * 1.2 where id=?"; + try (PreparedStatement preparedStatement = connection.prepareStatement(sql)) { + preparedStatement.setLong(1, id); + preparedStatement.executeUpdate(); + } + statement.execute("commit"); + + // Reset the state of the connection before returning it to the connection pool. + statement.execute("reset all"); + } catch (SQLException exception) { + span.recordException(exception); + throw new RuntimeException(exception); + } finally { + span.end(); + } + } +} diff --git a/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/DatabaseSeeder.java b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/DatabaseSeeder.java new file mode 100644 index 000000000..3e370a097 --- /dev/null +++ b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/DatabaseSeeder.java @@ -0,0 +1,353 @@ +/* + * 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 io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; +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; + + private final Tracer tracer; + + @Value("classpath:create_schema.sql") + private Resource createSchemaFile; + + @Value("classpath:drop_schema.sql") + private Resource dropSchemaFile; + + public DatabaseSeeder(JdbcTemplate jdbcTemplate, Tracer tracer) { + this.jdbcTemplate = jdbcTemplate; + this.tracer = tracer; + } + + /** 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 statements that start with a 'skip_on_open_source_pg' comment if the application is + * running on open-source PostgreSQL. This ensures that we can use the same DDL script both on + * Cloud Spanner and on open-source PostgreSQL. It also removes any empty statements in the given + * array. + */ + 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 Cloud 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 Cloud 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() { + Span span = tracer.spanBuilder("deleteTestData").startSpan(); + try (Scope ignore = span.makeCurrent()) { + // Delete all data in one batch. + jdbcTemplate.execute("set statement_tag='batch_delete_test_data'"); + 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"); + } catch (Throwable t) { + span.recordException(t); + throw t; + } finally { + span.end(); + } + } + + /** Inserts some initial test data into the database. */ + public void insertTestData() { + Span span = tracer.spanBuilder("insertTestData").startSpan(); + try (Scope ignore = span.makeCurrent()) { + jdbcTemplate.execute("begin"); + jdbcTemplate.execute("set transaction_tag='insert_test_data'"); + jdbcTemplate.execute("set statement_tag='insert_singers'"); + 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.execute("set statement_tag='insert_albums'"); + 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; + } + }); + jdbcTemplate.execute("commit"); + } catch (Throwable t) { + try { + jdbcTemplate.execute("rollback"); + } catch (Exception ignore) { + } + span.recordException(t); + throw t; + } finally { + span.end(); + } + } + + /** 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-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/EmulatorInitializer.java b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/EmulatorInitializer.java new file mode 100644 index 000000000..afc55890e --- /dev/null +++ b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/EmulatorInitializer.java @@ -0,0 +1,57 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/JdbcConfiguration.java b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/JdbcConfiguration.java new file mode 100644 index 000000000..2537ed7d7 --- /dev/null +++ b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/JdbcConfiguration.java @@ -0,0 +1,74 @@ +/* + * 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 java.sql.JDBCType; +import java.sql.SQLType; +import java.time.LocalDate; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; +import org.springframework.context.annotation.Lazy; +import org.springframework.data.jdbc.core.convert.DefaultJdbcTypeFactory; +import org.springframework.data.jdbc.core.convert.JdbcArrayColumns; +import org.springframework.data.jdbc.core.convert.JdbcConverter; +import org.springframework.data.jdbc.core.convert.JdbcCustomConversions; +import org.springframework.data.jdbc.core.convert.MappingJdbcConverter; +import org.springframework.data.jdbc.core.convert.RelationResolver; +import org.springframework.data.jdbc.core.dialect.JdbcDialect; +import org.springframework.data.jdbc.core.mapping.JdbcMappingContext; +import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration; +import org.springframework.data.relational.core.dialect.Dialect; +import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations; + +/** + * This configuration class is registered as depending on OpenTelemetry, as the JDBC driver uses the + * globally registered OpenTelemetry instance. It also overrides the default jdbcConverter + * implementation to map LocalDate to the JDBC type DATE (the default implementation maps LocalDate + * to TIMESTAMP). + */ +@DependsOn("openTelemetry") +@Configuration +public class JdbcConfiguration extends AbstractJdbcConfiguration { + + @Bean + @Override + public JdbcConverter jdbcConverter( + JdbcMappingContext mappingContext, + NamedParameterJdbcOperations operations, + @Lazy RelationResolver relationResolver, + JdbcCustomConversions conversions, + Dialect dialect) { + JdbcArrayColumns arrayColumns = + dialect instanceof JdbcDialect + ? ((JdbcDialect) dialect).getArraySupport() + : JdbcArrayColumns.DefaultSupport.INSTANCE; + DefaultJdbcTypeFactory jdbcTypeFactory = + new DefaultJdbcTypeFactory(operations.getJdbcOperations(), arrayColumns); + return new MappingJdbcConverter( + mappingContext, relationResolver, conversions, jdbcTypeFactory) { + @Override + public SQLType getTargetSqlType(RelationalPersistentProperty property) { + if (property.getActualType().equals(LocalDate.class)) { + return JDBCType.DATE; + } + return super.getTargetSqlType(property); + } + }; + } +} diff --git a/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/OpenTelemetryConfiguration.java b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/OpenTelemetryConfiguration.java new file mode 100644 index 000000000..076554473 --- /dev/null +++ b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/OpenTelemetryConfiguration.java @@ -0,0 +1,121 @@ +/* + * 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.auth.oauth2.GoogleCredentials; +import com.google.cloud.opentelemetry.metric.GoogleCloudMetricExporter; +import com.google.cloud.opentelemetry.metric.MetricConfiguration; +import com.google.cloud.opentelemetry.trace.TraceConfiguration; +import com.google.cloud.opentelemetry.trace.TraceExporter; +import com.google.cloud.spanner.SpannerOptions; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.export.MetricExporter; +import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import io.opentelemetry.sdk.trace.samplers.Sampler; +import java.io.IOException; +import java.util.concurrent.ThreadLocalRandom; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +// @AutoConfiguration(before = DataSourceAutoConfiguration.class) +@Configuration +public class OpenTelemetryConfiguration { + + @Value("${open_telemetry.enabled}") + private boolean enabled; + + @Value("${spanner.emulator}") + private boolean emulator; + + @Value("${open_telemetry.project}") + private String project; + + @Bean + public OpenTelemetry openTelemetry() { + if (!enabled || emulator) { + return OpenTelemetry.noop(); + } + + // Enable OpenTelemetry tracing in Spanner. + SpannerOptions.enableOpenTelemetryTraces(); + SpannerOptions.enableOpenTelemetryMetrics(); + + if (!hasDefaultCredentials()) { + // Create a no-op OpenTelemetry object if this environment does not have any default + // credentials configured. This could for example be on local test environments that use + // the Spanner emulator. + return OpenTelemetry.noop(); + } + + TraceConfiguration.Builder traceConfigurationBuilder = TraceConfiguration.builder(); + TraceConfiguration traceConfiguration = traceConfigurationBuilder.setProjectId(project).build(); + SpanExporter traceExporter = TraceExporter.createWithConfiguration(traceConfiguration); + + MetricConfiguration.Builder metricConfigurationBuilder = MetricConfiguration.builder(); + MetricConfiguration metricConfiguration = + metricConfigurationBuilder.setProjectId(project).build(); + MetricExporter metricExporter = + GoogleCloudMetricExporter.createWithConfiguration(metricConfiguration); + + SdkMeterProvider sdkMeterProvider = + SdkMeterProvider.builder() + .registerMetricReader(PeriodicMetricReader.builder(metricExporter).build()) + .build(); + + // Create an OpenTelemetry object and register it as the global OpenTelemetry object. This + // will automatically be picked up by the Spanner libraries and used for tracing. + return OpenTelemetrySdk.builder() + .setTracerProvider( + SdkTracerProvider.builder() + // Set sampling to 'AlwaysOn' in this example. In production, you want to reduce + // this to a smaller fraction to limit the number of traces that are being + // collected. + .setSampler(Sampler.alwaysOn()) + .setResource( + Resource.builder() + .put( + "service.name", + "spanner-jdbc-spring-data-sample-" + + ThreadLocalRandom.current().nextInt()) + .build()) + .addSpanProcessor(BatchSpanProcessor.builder(traceExporter).build()) + .build()) + .setMeterProvider(sdkMeterProvider) + .buildAndRegisterGlobal(); + } + + private boolean hasDefaultCredentials() { + try { + return GoogleCredentials.getApplicationDefault() != null; + } catch (IOException exception) { + return false; + } + } + + @Bean + public Tracer tracer(OpenTelemetry openTelemetry) { + return openTelemetry.getTracer("com.google.cloud.spanner.jdbc.sample.spring-data-jdbc"); + } +} diff --git a/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/SpannerDialect.java b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/SpannerDialect.java new file mode 100644 index 000000000..b89500584 --- /dev/null +++ b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/SpannerDialect.java @@ -0,0 +1,139 @@ +/* + * 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.time.ZoneId.systemDefault; + +import com.google.common.collect.ImmutableList; +import java.sql.JDBCType; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.Collection; +import java.util.Date; +import javax.annotation.Nonnull; +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.convert.ReadingConverter; +import org.springframework.data.convert.WritingConverter; +import org.springframework.data.jdbc.core.mapping.JdbcValue; +import org.springframework.data.relational.core.dialect.AnsiDialect; +import org.springframework.data.relational.core.dialect.LimitClause; +import org.springframework.data.relational.core.sql.IdentifierProcessing; +import org.springframework.data.relational.core.sql.IdentifierProcessing.LetterCasing; +import org.springframework.data.relational.core.sql.IdentifierProcessing.Quoting; + +/** + * The Spanner GoogleSQL dialect is relatively close to the standard ANSI dialect. We therefore + * create a custom dialect based on ANSI, but with a few overrides. + */ +public class SpannerDialect extends AnsiDialect { + public static final SpannerDialect INSTANCE = new SpannerDialect(); + + /** Spanner uses backticks for identifier quoting. */ + private static final Quoting QUOTING = new Quoting("`"); + + /** Spanner supports mixed-case identifiers. */ + private static final IdentifierProcessing IDENTIFIER_PROCESSING = + IdentifierProcessing.create(QUOTING, LetterCasing.AS_IS); + + private static final LimitClause LIMIT_CLAUSE = + new LimitClause() { + private static final long DEFAULT_LIMIT_FOR_OFFSET = Long.MAX_VALUE / 2; + + @Nonnull + @Override + public String getLimit(long limit) { + return String.format("LIMIT %d", limit); + } + + @Nonnull + @Override + public String getOffset(long offset) { + // Spanner does not support an OFFSET clause without a LIMIT clause. + return String.format("LIMIT %d OFFSET %d", DEFAULT_LIMIT_FOR_OFFSET, offset); + } + + @Nonnull + @Override + public String getLimitOffset(long limit, long offset) { + return String.format("LIMIT %d OFFSET %d", limit, offset); + } + + @Nonnull + @Override + public Position getClausePosition() { + return Position.AFTER_ORDER_BY; + } + }; + + private SpannerDialect() {} + + @Nonnull + @Override + public IdentifierProcessing getIdentifierProcessing() { + return IDENTIFIER_PROCESSING; + } + + @Nonnull + @Override + public LimitClause limit() { + return LIMIT_CLAUSE; + } + + @Nonnull + @Override + public Collection getConverters() { + return ImmutableList.of( + TimestampAtUtcToOffsetDateTimeConverter.INSTANCE, + OffsetDateTimeToTimestampJdbcValueConverter.INSTANCE, + LocalDateTimeToDateConverter.INSTANCE); + } + + @ReadingConverter + enum TimestampAtUtcToOffsetDateTimeConverter implements Converter { + INSTANCE; + + private static final ZoneId UTC = ZoneId.of("UTC"); + + @Override + public OffsetDateTime convert(Timestamp timestamp) { + return OffsetDateTime.ofInstant(timestamp.toInstant(), UTC); + } + } + + @WritingConverter + enum OffsetDateTimeToTimestampJdbcValueConverter implements Converter { + INSTANCE; + + @Override + public JdbcValue convert(@Nonnull OffsetDateTime source) { + return JdbcValue.of(source, JDBCType.TIMESTAMP); + } + } + + @ReadingConverter + enum LocalDateTimeToDateConverter implements Converter { + INSTANCE; + + @Nonnull + @Override + public Date convert(LocalDateTime source) { + return Date.from(source.atZone(systemDefault()).toInstant()); + } + } +} diff --git a/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/SpannerDialectProvider.java b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/SpannerDialectProvider.java new file mode 100644 index 000000000..8f9f5ce46 --- /dev/null +++ b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/SpannerDialectProvider.java @@ -0,0 +1,46 @@ +/* + * 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 java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.SQLException; +import java.util.Locale; +import java.util.Optional; +import org.springframework.data.jdbc.repository.config.DialectResolver; +import org.springframework.data.relational.core.dialect.Dialect; +import org.springframework.jdbc.core.ConnectionCallback; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.lang.Nullable; + +public class SpannerDialectProvider implements DialectResolver.JdbcDialectProvider { + @Override + public Optional getDialect(JdbcOperations operations) { + return Optional.ofNullable( + operations.execute((ConnectionCallback) SpannerDialectProvider::getDialect)); + } + + @Nullable + private static Dialect getDialect(Connection connection) throws SQLException { + DatabaseMetaData metaData = connection.getMetaData(); + String name = metaData.getDatabaseProductName().toLowerCase(Locale.ENGLISH); + if (name.contains("spanner")) { + return SpannerDialect.INSTANCE; + } + return null; + } +} diff --git a/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/AbstractEntity.java b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/AbstractEntity.java new file mode 100644 index 000000000..251acd1a8 --- /dev/null +++ b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/AbstractEntity.java @@ -0,0 +1,80 @@ +/* + * 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; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.annotation.PersistenceCreator; + +public abstract class AbstractEntity { + + /** This ID is generated using a (bit-reversed) sequence. */ + @Id private Long id; + + @CreatedDate private OffsetDateTime createdAt; + + @LastModifiedDate private OffsetDateTime updatedAt; + + @PersistenceCreator + public AbstractEntity() {} + + @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; + } + + protected 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-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Album.java b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Album.java new file mode 100644 index 000000000..36674c609 --- /dev/null +++ b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Album.java @@ -0,0 +1,88 @@ +/* + * 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; +import org.springframework.data.annotation.PersistenceCreator; +import org.springframework.data.relational.core.mapping.Table; + +@Table("albums") +public class Album extends AbstractEntity { + + private String title; + + private BigDecimal marketingBudget; + + private LocalDate releaseDate; + + private byte[] coverPicture; + + private Long singerId; + + @PersistenceCreator + 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-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Concert.java b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Concert.java new file mode 100644 index 000000000..5c1fb0a4f --- /dev/null +++ b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Concert.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.time.OffsetDateTime; +import org.springframework.data.annotation.PersistenceCreator; +import org.springframework.data.relational.core.mapping.Table; + +@Table("concerts") +public class Concert extends AbstractEntity { + + private Long venueId; + + private Long singerId; + + private String name; + + private OffsetDateTime startTime; + + private OffsetDateTime endTime; + + @PersistenceCreator + public Concert() {} + + 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-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Singer.java b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Singer.java new file mode 100644 index 000000000..a6f8fdfc4 --- /dev/null +++ b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Singer.java @@ -0,0 +1,75 @@ +/* + * 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 org.springframework.data.annotation.PersistenceCreator; +import org.springframework.data.annotation.ReadOnlyProperty; +import org.springframework.data.relational.core.mapping.Table; + +@Table("singers") +public class Singer extends AbstractEntity { + + private String firstName; + + private String lastName; + + /** Mark fullName as a {@link ReadOnlyProperty}, as it is generated by the database. */ + @ReadOnlyProperty private String fullName; + + private Boolean active; + + @PersistenceCreator + 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 Boolean getActive() { + return active; + } + + public void setActive(Boolean active) { + this.active = active; + } +} diff --git a/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Track.java b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Track.java new file mode 100644 index 000000000..1a8e031b2 --- /dev/null +++ b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Track.java @@ -0,0 +1,88 @@ +/* + * 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 org.springframework.data.annotation.PersistenceCreator; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +/** + * 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. We can achieve this by adding an extra property, + * albumId, that is mapped to the "id" column. We can then manually set an albumId value before + * inserting the record in the database. + */ +@Table("tracks") +public class Track extends AbstractEntity { + + /** + * We need to map this to the "id" column to be able to explicitly set it, instead of letting + * Spring Data generate it. This is necessary, because Track is interleaved in Album. That again + * means that we must use the ID value of the Album for a Track. + */ + @Column("id") + private Long albumId; + + /** This is the second part of the primary key of a Track. */ + private int trackNumber; + + private String title; + + private Double sampleRate; + + @PersistenceCreator + public Track() {} + + public Track(Album album, int trackNumber, String title) { + setAlbumId(album.getId()); + this.trackNumber = trackNumber; + this.title = title; + } + + public Long getAlbumId() { + return albumId; + } + + private void setAlbumId(Long albumId) { + this.albumId = albumId; + } + + 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-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Venue.java b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Venue.java new file mode 100644 index 000000000..78137ebc0 --- /dev/null +++ b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/entities/Venue.java @@ -0,0 +1,50 @@ +/* + * 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 org.springframework.data.annotation.PersistenceCreator; +import org.springframework.data.relational.core.mapping.Table; + +@Table("venues") +public class Venue extends AbstractEntity { + private String name; + + private String description; + + @PersistenceCreator + public Venue() {} + + 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-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/repositories/AlbumRepository.java b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/repositories/AlbumRepository.java new file mode 100644 index 000000000..ae6bf52d3 --- /dev/null +++ b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/repositories/AlbumRepository.java @@ -0,0 +1,40 @@ +/* + * 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.repositories; + +import com.google.cloud.spanner.sample.entities.Album; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jdbc.repository.query.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface AlbumRepository extends CrudRepository { + + /** + * The implementation for this method is automatically generated and will fetch all albums of the + * given singer. + */ + List findAlbumsBySingerId(Long singerId); + + long countAlbumsBySingerId(Long singerId); + + /** Returns the first album in the database. */ + @Query("select * from albums limit 1") + Optional getFirst(); +} diff --git a/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/repositories/ConcertRepository.java b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/repositories/ConcertRepository.java new file mode 100644 index 000000000..dbfb82ccc --- /dev/null +++ b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/repositories/ConcertRepository.java @@ -0,0 +1,24 @@ +/* + * 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.repositories; + +import com.google.cloud.spanner.sample.entities.Concert; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ConcertRepository extends CrudRepository {} diff --git a/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/repositories/SingerRepository.java b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/repositories/SingerRepository.java new file mode 100644 index 000000000..c542d69fb --- /dev/null +++ b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/repositories/SingerRepository.java @@ -0,0 +1,34 @@ +/* + * 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.repositories; + +import com.google.cloud.spanner.sample.entities.Singer; +import java.util.List; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface SingerRepository extends CrudRepository { + + /** + * The implementation for this method is automatically generated and will fetch all singers with + * the given last name. + */ + List findSingersByLastName(String lastName); + + List findSingersByLastNameStartingWith(String prefix); +} diff --git a/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/repositories/TrackRepository.java b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/repositories/TrackRepository.java new file mode 100644 index 000000000..7b1147024 --- /dev/null +++ b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/repositories/TrackRepository.java @@ -0,0 +1,24 @@ +/* + * 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.repositories; + +import com.google.cloud.spanner.sample.entities.Track; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TrackRepository extends CrudRepository {} diff --git a/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/repositories/VenueRepository.java b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/repositories/VenueRepository.java new file mode 100644 index 000000000..58ce3bc17 --- /dev/null +++ b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/repositories/VenueRepository.java @@ -0,0 +1,24 @@ +/* + * 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.repositories; + +import com.google.cloud.spanner.sample.entities.Venue; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface VenueRepository extends CrudRepository {} diff --git a/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/service/SingerService.java b/samples/spring-data-jdbc/googlesql/src/main/java/com/google/cloud/spanner/sample/service/SingerService.java new file mode 100644 index 000000000..55f4ee5b9 --- /dev/null +++ b/samples/spring-data-jdbc/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.repositories.AlbumRepository; +import com.google.cloud.spanner.sample.repositories.SingerRepository; +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 SingerRepository singerRepository; + + private final AlbumRepository albumRepository; + + public SingerService(SingerRepository singerRepository, AlbumRepository albumRepository) { + this.singerRepository = singerRepository; + this.albumRepository = albumRepository; + } + + /** Creates a singer and a list of albums in a transaction. */ + @Transactional + public Singer createSingerAndAlbums(Singer singer, Album... albums) { + // Saving a singer will return an updated singer entity that has the primary key value set. + singer = singerRepository.save(singer); + for (Album album : albums) { + // Set the singerId that was generated on the Album before saving it. + album.setSingerId(singer.getId()); + albumRepository.save(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-jdbc/googlesql/src/main/resources/META-INF/spring.factories b/samples/spring-data-jdbc/googlesql/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000..3dc08db94 --- /dev/null +++ b/samples/spring-data-jdbc/googlesql/src/main/resources/META-INF/spring.factories @@ -0,0 +1 @@ +org.springframework.data.jdbc.repository.config.DialectResolver$JdbcDialectProvider=org.springframework.data.jdbc.repository.config.DialectResolver.DefaultDialectProvider,com.google.cloud.spanner.sample.SpannerDialectProvider diff --git a/samples/spring-data-jdbc/googlesql/src/main/resources/application.properties b/samples/spring-data-jdbc/googlesql/src/main/resources/application.properties new file mode 100644 index 000000000..a38d9ab98 --- /dev/null +++ b/samples/spring-data-jdbc/googlesql/src/main/resources/application.properties @@ -0,0 +1,36 @@ + +# This application uses a Spanner GoogleSQL database. + +spanner.project=my-project +spanner.instance=my-instance +spanner.database=spring-data-jdbc + +# 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 + +# 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 + +# Setting this property to true instructs the Spanner JDBC driver to include the SQL statement that +# is executed in the trace. This makes it easier to identify slow queries in your application. +spanner.enable_extended_tracing=true + +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};enableExtendedTracing=${spanner.enable_extended_tracing};${spanner.additional_properties} +spring.datasource.driver-class-name=com.google.cloud.spanner.jdbc.JdbcDriver + +# Enable/disable OpenTelemetry tracing and export these to Google Cloud Trace. +open_telemetry.enabled=true +open_telemetry.project=${spanner.project} + +# Used for testing +spanner.endpoint= +spanner.additional_properties= diff --git a/samples/spring-data-jdbc/googlesql/src/main/resources/create_schema.sql b/samples/spring-data-jdbc/googlesql/src/main/resources/create_schema.sql new file mode 100644 index 000000000..f54ef6492 --- /dev/null +++ b/samples/spring-data-jdbc/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-jdbc/src/main/resources/drop_schema.sql b/samples/spring-data-jdbc/googlesql/src/main/resources/drop_schema.sql similarity index 100% rename from samples/spring-data-jdbc/src/main/resources/drop_schema.sql rename to samples/spring-data-jdbc/googlesql/src/main/resources/drop_schema.sql diff --git a/samples/spring-data-jdbc/googlesql/src/test/java/com/google/cloud/spanner/sample/ApplicationEmulatorTest.java b/samples/spring-data-jdbc/googlesql/src/test/java/com/google/cloud/spanner/sample/ApplicationEmulatorTest.java new file mode 100644 index 000000000..7681e2a68 --- /dev/null +++ b/samples/spring-data-jdbc/googlesql/src/test/java/com/google/cloud/spanner/sample/ApplicationEmulatorTest.java @@ -0,0 +1,69 @@ +/* + * 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 com.google.cloud.spanner.connection.SpannerPool; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.springframework.boot.SpringApplication; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.PullPolicy; +import org.testcontainers.utility.DockerImageName; + +@RunWith(JUnit4.class) +public class ApplicationEmulatorTest { + private static GenericContainer emulator; + + @BeforeClass + public static void startEmulator() { + assumeTrue(DockerClientFactory.instance().isDockerAvailable()); + + emulator = + new GenericContainer<>( + DockerImageName.parse("gcr.io/cloud-spanner-emulator/emulator:latest")) + .withImagePullPolicy(PullPolicy.alwaysPull()) + .withExposedPorts(9010) + .waitingFor(Wait.forListeningPorts(9010)); + emulator.start(); + } + + @AfterClass + public static void cleanup() { + SpannerPool.closeSpannerPool(); + if (emulator != null) { + emulator.stop(); + } + } + + @Test + public void testRunApplication() { + System.setProperty("open_telemetry.enabled", "false"); + System.setProperty("open_telemetry.project", "test-project"); + System.setProperty("spanner.emulator", "true"); + System.setProperty("spanner.auto_start_emulator", "false"); + System.setProperty( + "spanner.endpoint", String.format("//localhost:%d", emulator.getMappedPort(9010))); + SpringApplication.run(Application.class).close(); + } +} diff --git a/samples/spring-data-jdbc/postgresql/README.md b/samples/spring-data-jdbc/postgresql/README.md new file mode 100644 index 000000000..da1d69532 --- /dev/null +++ b/samples/spring-data-jdbc/postgresql/README.md @@ -0,0 +1,95 @@ +# Spring Data JDBC Sample Application with Cloud Spanner PostgreSQL + +This sample application shows how to develop portable applications using Spring Data JDBC 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 JDBC 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 JDBC 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. + +__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. + +## Spring Data JDBC + +[Spring Data JDBC](https://spring.io/projects/spring-data-jdbc) is part of the larger Spring Data +family. It makes it easy to implement JDBC based repositories. This module deals with enhanced +support for JDBC based data access layers. + +Spring Data JDBC aims at being conceptually easy. In order to achieve this it does NOT offer caching, +lazy loading, write behind or many other features of JPA. This makes Spring Data JDBC a simple, +limited, opinionated ORM. + +## Sample Application + +This sample shows how to create a portable application using Spring Data JDBC 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. Modify the default profile in the + [application.properties](src/main/resources/application.properties) file. +2. 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. +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. 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): + Spring Data JDBC by default detects the database dialect based on the JDBC driver that is used. + This class overrides this default and instructs Spring Data JDBC to also use the PostgreSQL + dialect for Cloud Spanner PostgreSQL. +* [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. + diff --git a/samples/spring-data-jdbc/pom.xml b/samples/spring-data-jdbc/postgresql/pom.xml similarity index 98% rename from samples/spring-data-jdbc/pom.xml rename to samples/spring-data-jdbc/postgresql/pom.xml index 032ad9fe0..5e04a256d 100644 --- a/samples/spring-data-jdbc/pom.xml +++ b/samples/spring-data-jdbc/postgresql/pom.xml @@ -5,7 +5,7 @@ 4.0.0 org.example - cloud-spanner-spring-data-jdbc-example + cloud-spanner-spring-data-jdbc-postgresql-example 1.0-SNAPSHOT Sample application showing how to use Spring Data JDBC with Cloud Spanner PostgreSQL. diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/Application.java b/samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/Application.java similarity index 100% rename from samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/Application.java rename to samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/Application.java diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/DatabaseSeeder.java b/samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/DatabaseSeeder.java similarity index 100% rename from samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/DatabaseSeeder.java rename to samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/DatabaseSeeder.java diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/JdbcConfiguration.java b/samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/JdbcConfiguration.java similarity index 100% rename from samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/JdbcConfiguration.java rename to samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/JdbcConfiguration.java diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/OpenTelemetryConfiguration.java b/samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/OpenTelemetryConfiguration.java similarity index 97% rename from samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/OpenTelemetryConfiguration.java rename to samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/OpenTelemetryConfiguration.java index b833e48b7..f1e615290 100644 --- a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/OpenTelemetryConfiguration.java +++ b/samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/OpenTelemetryConfiguration.java @@ -36,6 +36,8 @@ import java.io.IOException; import java.util.concurrent.ThreadLocalRandom; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/AbstractEntity.java b/samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/entities/AbstractEntity.java similarity index 100% rename from samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/AbstractEntity.java rename to samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/entities/AbstractEntity.java diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Album.java b/samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/entities/Album.java similarity index 100% rename from samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Album.java rename to samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/entities/Album.java diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Concert.java b/samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/entities/Concert.java similarity index 100% rename from samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Concert.java rename to samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/entities/Concert.java diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Singer.java b/samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/entities/Singer.java similarity index 100% rename from samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Singer.java rename to samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/entities/Singer.java diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Track.java b/samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/entities/Track.java similarity index 100% rename from samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Track.java rename to samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/entities/Track.java diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Venue.java b/samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/entities/Venue.java similarity index 100% rename from samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Venue.java rename to samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/entities/Venue.java diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/AlbumRepository.java b/samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/repositories/AlbumRepository.java similarity index 100% rename from samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/AlbumRepository.java rename to samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/repositories/AlbumRepository.java diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/ConcertRepository.java b/samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/repositories/ConcertRepository.java similarity index 100% rename from samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/ConcertRepository.java rename to samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/repositories/ConcertRepository.java diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/SingerRepository.java b/samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/repositories/SingerRepository.java similarity index 100% rename from samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/SingerRepository.java rename to samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/repositories/SingerRepository.java diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/TrackRepository.java b/samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/repositories/TrackRepository.java similarity index 100% rename from samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/TrackRepository.java rename to samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/repositories/TrackRepository.java diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/VenueRepository.java b/samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/repositories/VenueRepository.java similarity index 100% rename from samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/VenueRepository.java rename to samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/repositories/VenueRepository.java diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/service/SingerService.java b/samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/service/SingerService.java similarity index 100% rename from samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/service/SingerService.java rename to samples/spring-data-jdbc/postgresql/src/main/java/com/google/cloud/spanner/sample/service/SingerService.java diff --git a/samples/spring-data-jdbc/src/main/resources/application-cs.properties b/samples/spring-data-jdbc/postgresql/src/main/resources/application-cs.properties similarity index 100% rename from samples/spring-data-jdbc/src/main/resources/application-cs.properties rename to samples/spring-data-jdbc/postgresql/src/main/resources/application-cs.properties diff --git a/samples/spring-data-jdbc/src/main/resources/application-pg.properties b/samples/spring-data-jdbc/postgresql/src/main/resources/application-pg.properties similarity index 100% rename from samples/spring-data-jdbc/src/main/resources/application-pg.properties rename to samples/spring-data-jdbc/postgresql/src/main/resources/application-pg.properties diff --git a/samples/spring-data-jdbc/src/main/resources/application.properties b/samples/spring-data-jdbc/postgresql/src/main/resources/application.properties similarity index 100% rename from samples/spring-data-jdbc/src/main/resources/application.properties rename to samples/spring-data-jdbc/postgresql/src/main/resources/application.properties diff --git a/samples/spring-data-jdbc/src/main/resources/create_schema.sql b/samples/spring-data-jdbc/postgresql/src/main/resources/create_schema.sql similarity index 100% rename from samples/spring-data-jdbc/src/main/resources/create_schema.sql rename to samples/spring-data-jdbc/postgresql/src/main/resources/create_schema.sql diff --git a/samples/spring-data-jdbc/postgresql/src/main/resources/drop_schema.sql b/samples/spring-data-jdbc/postgresql/src/main/resources/drop_schema.sql new file mode 100644 index 000000000..23e7b65d3 --- /dev/null +++ b/samples/spring-data-jdbc/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-jdbc/src/test/java/com/google/cloud/spanner/sample/ApplicationEmulatorTest.java b/samples/spring-data-jdbc/postgresql/src/test/java/com/google/cloud/spanner/sample/ApplicationEmulatorTest.java similarity index 100% rename from samples/spring-data-jdbc/src/test/java/com/google/cloud/spanner/sample/ApplicationEmulatorTest.java rename to samples/spring-data-jdbc/postgresql/src/test/java/com/google/cloud/spanner/sample/ApplicationEmulatorTest.java diff --git a/samples/spring-data-jdbc/src/test/java/com/google/cloud/spanner/sample/ApplicationTest.java b/samples/spring-data-jdbc/postgresql/src/test/java/com/google/cloud/spanner/sample/ApplicationTest.java similarity index 100% rename from samples/spring-data-jdbc/src/test/java/com/google/cloud/spanner/sample/ApplicationTest.java rename to samples/spring-data-jdbc/postgresql/src/test/java/com/google/cloud/spanner/sample/ApplicationTest.java diff --git a/samples/spring-data-jdbc/src/test/resources/application-cs.properties b/samples/spring-data-jdbc/postgresql/src/test/resources/application-cs.properties similarity index 100% rename from samples/spring-data-jdbc/src/test/resources/application-cs.properties rename to samples/spring-data-jdbc/postgresql/src/test/resources/application-cs.properties diff --git a/samples/spring-data-jdbc/src/test/resources/application-pg.properties b/samples/spring-data-jdbc/postgresql/src/test/resources/application-pg.properties similarity index 100% rename from samples/spring-data-jdbc/src/test/resources/application-pg.properties rename to samples/spring-data-jdbc/postgresql/src/test/resources/application-pg.properties diff --git a/samples/spring-data-jdbc/src/test/resources/application.properties b/samples/spring-data-jdbc/postgresql/src/test/resources/application.properties similarity index 100% rename from samples/spring-data-jdbc/src/test/resources/application.properties rename to samples/spring-data-jdbc/postgresql/src/test/resources/application.properties