Skip to content

Commit 9eea3aa

Browse files
authored
Verify PG in SysDB and Spring Starter (#366)
1 parent feee7ad commit 9eea3aa

10 files changed

Lines changed: 336 additions & 5 deletions

File tree

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ postgresql = "42.7.10"
1919
rest-assured = "6.0.0"
2020
shadow = "9.4.1"
2121
slf4j = "2.0.17"
22+
sqlite-jdbc = "3.49.1.0"
2223
spotless = "8.4.0"
2324
spring-boot = "3.4.4"
2425
spring-framework = "6.2.5"
@@ -48,6 +49,7 @@ postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql"
4849
rest-assured = { module = "io.rest-assured:rest-assured", version.ref = "rest-assured" }
4950
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
5051
slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" }
52+
sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite-jdbc" }
5153
spring-aop = { module = "org.springframework:spring-aop", version.ref = "spring-framework" }
5254
spring-boot-autoconfigure = { module = "org.springframework.boot:spring-boot-autoconfigure", version.ref = "spring-boot" }
5355
spring-boot-configuration-processor = { module = "org.springframework.boot:spring-boot-configuration-processor", version.ref = "spring-boot" }

transact-spring-boot-starter/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ dependencies {
3838
testImplementation(libs.spring.aop)
3939
testImplementation(libs.aspectjweaver)
4040
testImplementation(libs.mockito.core)
41+
testImplementation(libs.testcontainers.postgresql)
42+
testImplementation(libs.postgresql)
43+
testImplementation(libs.hikaricp)
44+
testImplementation(libs.sqlite.jdbc)
4145
testRuntimeOnly(libs.logback.classic)
4246
}
4347

transact-spring-boot-starter/src/main/java/dev/dbos/transact/spring/DBOSAutoConfiguration.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import dev.dbos.transact.DBOS;
44
import dev.dbos.transact.config.DBOSConfig;
55

6+
import java.sql.Connection;
7+
import java.sql.SQLException;
68
import java.util.List;
79
import java.util.Objects;
810

@@ -60,12 +62,28 @@ public DBOS dbos(DBOSConfig config, ObjectProvider<DataSource> dataSourceProvide
6062
if (config.databaseUrl() == null && config.dataSource() == null) {
6163
DataSource dataSource = dataSourceProvider.getIfAvailable();
6264
if (dataSource != null) {
65+
validatePostgresDataSource(dataSource);
6366
config = config.withDataSource(dataSource);
6467
}
68+
} else if (config.dataSource() != null) {
69+
validatePostgresDataSource(config.dataSource());
6570
}
6671
return new DBOS(config);
6772
}
6873

74+
private static void validatePostgresDataSource(DataSource dataSource) {
75+
try (Connection conn = dataSource.getConnection()) {
76+
String productName = conn.getMetaData().getDatabaseProductName();
77+
if (!productName.toLowerCase().contains("postgresql")) {
78+
throw new IllegalStateException(
79+
"DBOS requires a PostgreSQL datasource, but the provided datasource reports: "
80+
+ productName);
81+
}
82+
} catch (SQLException e) {
83+
throw new IllegalStateException("Failed to validate DBOS datasource", e);
84+
}
85+
}
86+
6987
@Bean
7088
@ConditionalOnMissingBean
7189
public DBOSLifecycle dbosLifecycle(DBOS dbos) {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package dev.dbos.transact.spring;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import dev.dbos.transact.DBOS;
6+
7+
import javax.sql.DataSource;
8+
9+
import com.zaxxer.hikari.HikariConfig;
10+
import com.zaxxer.hikari.HikariDataSource;
11+
import org.junit.jupiter.api.Test;
12+
import org.springframework.boot.autoconfigure.AutoConfigurations;
13+
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
14+
import org.sqlite.SQLiteDataSource;
15+
16+
class DBOSAutoConfigurationIntegrationTest {
17+
18+
@Test
19+
void realPostgresDataSourceIsAccepted() {
20+
try (var pg = new PgContainer();
21+
var ds = hikariDataSource(pg)) {
22+
new ApplicationContextRunner()
23+
.withConfiguration(AutoConfigurations.of(DBOSAutoConfiguration.class))
24+
.withPropertyValues("dbos.application.name=test-app")
25+
.withBean(DataSource.class, () -> ds)
26+
.run(
27+
context -> {
28+
assertThat(context).hasNotFailed();
29+
assertThat(context).hasSingleBean(DBOS.class);
30+
});
31+
}
32+
}
33+
34+
@Test
35+
void realPostgresDataSourceRunsMigrations() {
36+
try (var pg = new PgContainer();
37+
var ds = hikariDataSource(pg)) {
38+
new ApplicationContextRunner()
39+
.withConfiguration(AutoConfigurations.of(DBOSAutoConfiguration.class))
40+
.withPropertyValues("dbos.application.name=test-app")
41+
.withBean(DataSource.class, () -> ds)
42+
.run(
43+
context -> {
44+
assertThat(context).hasNotFailed();
45+
// Verify DBOS ran migrations against the provided datasource.
46+
try (var conn = ds.getConnection();
47+
var rs =
48+
conn.getMetaData()
49+
.getTables(null, "dbos", "workflow_status", new String[] {"TABLE"})) {
50+
assertThat(rs.next())
51+
.as("dbos.workflow_status table should exist after migration")
52+
.isTrue();
53+
}
54+
});
55+
}
56+
}
57+
58+
@Test
59+
void sqliteSpringDataSourceFails() {
60+
var ds = sqliteDataSource();
61+
new ApplicationContextRunner()
62+
.withConfiguration(AutoConfigurations.of(DBOSAutoConfiguration.class))
63+
.withPropertyValues("dbos.application.name=test-app")
64+
.withBean(DataSource.class, () -> ds)
65+
.run(context -> assertThat(context).hasFailed());
66+
}
67+
68+
@Test
69+
void sqliteSpringDataSourceFailsWithHelpfulMessage() {
70+
var ds = sqliteDataSource();
71+
new ApplicationContextRunner()
72+
.withConfiguration(AutoConfigurations.of(DBOSAutoConfiguration.class))
73+
.withPropertyValues("dbos.application.name=test-app")
74+
.withBean(DataSource.class, () -> ds)
75+
.run(
76+
context ->
77+
assertThat(context.getStartupFailure())
78+
.hasMessageContaining("PostgreSQL")
79+
.hasMessageContaining("SQLite"));
80+
}
81+
82+
private static HikariDataSource hikariDataSource(PgContainer pg) {
83+
var config = new HikariConfig();
84+
config.setJdbcUrl(pg.jdbcUrl());
85+
config.setUsername(pg.username());
86+
config.setPassword(pg.password());
87+
return new HikariDataSource(config);
88+
}
89+
90+
private static SQLiteDataSource sqliteDataSource() {
91+
var ds = new SQLiteDataSource();
92+
ds.setUrl("jdbc:sqlite::memory:");
93+
return ds;
94+
}
95+
}

transact-spring-boot-starter/src/test/java/dev/dbos/transact/spring/DBOSAutoConfigurationTest.java

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@
22

33
import static org.assertj.core.api.Assertions.assertThat;
44
import static org.mockito.Mockito.mock;
5+
import static org.mockito.Mockito.when;
56

67
import dev.dbos.transact.DBOS;
78
import dev.dbos.transact.config.DBOSConfig;
89

10+
import java.sql.Connection;
11+
import java.sql.DatabaseMetaData;
12+
import java.sql.SQLException;
13+
914
import javax.sql.DataSource;
1015

1116
import org.junit.jupiter.api.Test;
@@ -208,14 +213,10 @@ void customizerThatThrowsFails() {
208213

209214
@Test
210215
void dataSourceBeanIsUsedWhenNoDatasourceUrlConfigured() {
211-
// When a DataSource bean is present and no dbos.datasource.url is set,
212-
// the DBOS instance should be created using that DataSource (no databaseUrl required).
213-
// We provide a mock DBOSLifecycle to prevent dbos.launch() from running.
214-
var mockDs = mock(DataSource.class);
215216
new ApplicationContextRunner()
216217
.withConfiguration(AutoConfigurations.of(DBOSAutoConfiguration.class))
217218
.withPropertyValues("dbos.application.name=test-app")
218-
.withBean(DataSource.class, () -> mockDs)
219+
.withBean(DataSource.class, () -> mockPostgresDataSource())
219220
.withBean(
220221
DBOSAutoConfiguration.DBOSLifecycle.class,
221222
() -> mock(DBOSAutoConfiguration.DBOSLifecycle.class))
@@ -225,4 +226,53 @@ void dataSourceBeanIsUsedWhenNoDatasourceUrlConfigured() {
225226
assertThat(context).hasSingleBean(DBOS.class);
226227
});
227228
}
229+
230+
@Test
231+
void nonPostgresSpringDataSourceFails() {
232+
var mockDs = mockDataSource("MySQL");
233+
new ApplicationContextRunner()
234+
.withConfiguration(AutoConfigurations.of(DBOSAutoConfiguration.class))
235+
.withPropertyValues("dbos.application.name=test-app")
236+
.withBean(DataSource.class, () -> mockDs)
237+
.withBean(
238+
DBOSAutoConfiguration.DBOSLifecycle.class,
239+
() -> mock(DBOSAutoConfiguration.DBOSLifecycle.class))
240+
.run(
241+
context -> {
242+
assertThat(context).hasFailed();
243+
assertThat(context.getStartupFailure())
244+
.hasMessageContaining("PostgreSQL")
245+
.hasMessageContaining("MySQL");
246+
});
247+
}
248+
249+
@Test
250+
void postgresSpringDataSourceSucceeds() {
251+
new ApplicationContextRunner()
252+
.withConfiguration(AutoConfigurations.of(DBOSAutoConfiguration.class))
253+
.withPropertyValues("dbos.application.name=test-app")
254+
.withBean(DataSource.class, () -> mockPostgresDataSource())
255+
.withBean(
256+
DBOSAutoConfiguration.DBOSLifecycle.class,
257+
() -> mock(DBOSAutoConfiguration.DBOSLifecycle.class))
258+
.run(context -> assertThat(context).hasNotFailed());
259+
}
260+
261+
private static DataSource mockPostgresDataSource() {
262+
return mockDataSource("PostgreSQL");
263+
}
264+
265+
private static DataSource mockDataSource(String productName) {
266+
try {
267+
var meta = mock(DatabaseMetaData.class);
268+
when(meta.getDatabaseProductName()).thenReturn(productName);
269+
var conn = mock(Connection.class);
270+
when(conn.getMetaData()).thenReturn(meta);
271+
var ds = mock(DataSource.class);
272+
when(ds.getConnection()).thenReturn(conn);
273+
return ds;
274+
} catch (SQLException e) {
275+
throw new RuntimeException(e);
276+
}
277+
}
228278
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package dev.dbos.transact.spring;
2+
3+
import java.sql.DriverManager;
4+
import java.sql.SQLException;
5+
import java.util.ArrayList;
6+
import java.util.UUID;
7+
import java.util.concurrent.ArrayBlockingQueue;
8+
import java.util.concurrent.BlockingQueue;
9+
import java.util.concurrent.Semaphore;
10+
11+
import org.testcontainers.postgresql.PostgreSQLContainer;
12+
13+
class PgContainer implements AutoCloseable {
14+
15+
private static final int SIZE = Runtime.getRuntime().availableProcessors();
16+
private static final BlockingQueue<PostgreSQLContainer> POOL = new ArrayBlockingQueue<>(SIZE);
17+
private static final Semaphore PERMITS = new Semaphore(SIZE);
18+
19+
static {
20+
Runtime.getRuntime()
21+
.addShutdownHook(
22+
new Thread(
23+
() -> {
24+
var containers = new ArrayList<PostgreSQLContainer>();
25+
POOL.drainTo(containers);
26+
containers.forEach(PostgreSQLContainer::stop);
27+
}));
28+
}
29+
30+
private static PostgreSQLContainer acquire() {
31+
try {
32+
PERMITS.acquire();
33+
var container = POOL.poll();
34+
if (container == null) {
35+
container = new PostgreSQLContainer("postgres:18");
36+
container.start();
37+
}
38+
return container;
39+
} catch (InterruptedException e) {
40+
throw new RuntimeException(e);
41+
}
42+
}
43+
44+
private static void release(PostgreSQLContainer c) {
45+
POOL.offer(c);
46+
PERMITS.release();
47+
}
48+
49+
private final PostgreSQLContainer pgContainer;
50+
private final String jdbcUrl;
51+
private final String dbName;
52+
53+
PgContainer() {
54+
pgContainer = acquire();
55+
dbName = "test_" + UUID.randomUUID().toString().replace("-", "");
56+
jdbcUrl = pgContainer.getJdbcUrl().replaceFirst("/[^/]+$", "/" + dbName);
57+
try (var conn =
58+
DriverManager.getConnection(
59+
pgContainer.getJdbcUrl(), pgContainer.getUsername(), pgContainer.getPassword());
60+
var stmt = conn.createStatement()) {
61+
stmt.execute("CREATE DATABASE " + dbName);
62+
} catch (SQLException e) {
63+
throw new RuntimeException(e);
64+
}
65+
}
66+
67+
@Override
68+
public void close() {
69+
try (var conn = DriverManager.getConnection(pgContainer.getJdbcUrl(), username(), password());
70+
var stmt = conn.createStatement()) {
71+
stmt.execute("DROP DATABASE IF EXISTS %s WITH (FORCE)".formatted(dbName));
72+
} catch (SQLException e) {
73+
throw new RuntimeException(e);
74+
}
75+
release(pgContainer);
76+
}
77+
78+
String jdbcUrl() {
79+
return jdbcUrl;
80+
}
81+
82+
String username() {
83+
return pgContainer.getUsername();
84+
}
85+
86+
String password() {
87+
return pgContainer.getPassword();
88+
}
89+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
junit.jupiter.execution.parallel.enabled = true
2+
junit.jupiter.execution.parallel.mode.default = concurrent
3+
junit.jupiter.execution.parallel.mode.classes.default = concurrent
4+
junit.jupiter.execution.parallel.config.strategy = dynamic
5+
junit.jupiter.execution.parallel.config.dynamic.factor = 1.0
6+
junit.jupiter.execution.timeout.default = 60 s

transact/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ dependencies {
4444
testImplementation(libs.java.websocket)
4545
testImplementation(libs.logback.classic)
4646
testImplementation(libs.mockito.core)
47+
testImplementation(libs.sqlite.jdbc)
4748
testImplementation(libs.rest.assured)
4849
testImplementation(libs.maven.artifact)
4950
testImplementation(libs.testcontainers.postgresql)

transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,22 @@ public static String sanitizeSchema(String schema) {
6161
private final SchedulesDAO schedulesDAO;
6262
private final StreamsDAO streamsDAO;
6363

64+
private static void validatePostgresDataSource(DataSource dataSource) {
65+
try (Connection conn = dataSource.getConnection()) {
66+
String productName = conn.getMetaData().getDatabaseProductName();
67+
if (!productName.toLowerCase().contains("postgresql")) {
68+
throw new IllegalStateException(
69+
"DBOS requires a PostgreSQL datasource, but the provided datasource reports: "
70+
+ productName);
71+
}
72+
} catch (SQLException e) {
73+
throw new IllegalStateException("Failed to validate DBOS datasource", e);
74+
}
75+
}
76+
6477
private SystemDatabase(
6578
DataSource dataSource, String schema, boolean created, DBOSSerializer serializer) {
79+
validatePostgresDataSource(dataSource);
6680
schema = sanitizeSchema(schema);
6781
if (schema.contains("\"")) {
6882
throw new IllegalArgumentException("Schema name must not contain double quotes");

0 commit comments

Comments
 (0)