Skip to content

Commit 6218dbe

Browse files
authored
Feature/storm 82 (#83)
* Clean up API naming, schema validation timing, and test infrastructure. * Detect unsaved entities used as foreign keys during insert/update. (#82)
1 parent 401d18f commit 6218dbe

48 files changed

Lines changed: 1128 additions & 1722 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/error-handling.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -250,23 +250,23 @@ public interface UserRepository extends EntityRepository<User, Integer> {}
250250

251251
For more targeted logging, annotate individual methods instead of the entire repository. See the [SQL Logging](sql-logging.md) page for details.
252252

253-
### Use StatementCapture in Tests
253+
### Use SqlCapture in Tests
254254

255-
The `StatementCapture` class from `storm-test` records all SQL statements generated during a block of code. This is useful for verifying that the correct queries are being generated:
255+
The `SqlCapture` class from `storm-test` records all SQL statements generated during a block of code. This is useful for verifying that the correct queries are being generated:
256256

257257
```java
258-
var capture = new StatementCapture();
258+
var capture = new SqlCapture();
259259
capture.run(() -> {
260260
userRepository.findAll();
261261
});
262262

263263
// Inspect the captured SQL.
264-
List<CapturedStatement> statements = capture.statements();
264+
List<CapturedSql> statements = capture.statements();
265265
assertEquals(1, statements.size());
266266
assertTrue(statements.get(0).statement().contains("SELECT"));
267267
```
268268

269-
See the [Testing](testing.md) page for full details on `StatementCapture` and the `@StormTest` annotation.
269+
See the [Testing](testing.md) page for full details on `SqlCapture` and the `@StormTest` annotation.
270270

271271
### Read the Suppressed SQL
272272

docs/testing.md

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Writing tests for database code can involve repetitive setup: creating a `DataSo
88
The module provides two categories of functionality:
99

1010
1. **JUnit 5 integration** (`@StormTest`) for automatic database setup, script execution, and parameter injection.
11-
2. **Statement capture** (`StatementCapture`) for recording and inspecting SQL statements generated during test execution. This component is framework-agnostic and works independently of JUnit.
11+
2. **Statement capture** (`SqlCapture`) for recording and inspecting SQL statements generated during test execution. This component is framework-agnostic and works independently of JUnit.
1212

1313
---
1414

@@ -46,7 +46,7 @@ The module uses H2 as its default in-memory database. To use H2, add it as a tes
4646

4747
JUnit 5 (`junit-jupiter-api`) is an optional dependency of `storm-test`. It is not pulled in transitively, so it does not appear on your classpath unless you add it yourself. Most projects already have JUnit Jupiter as a test dependency, in which case the `@StormTest` annotation and `StormExtension` are available automatically with no extra configuration.
4848

49-
If you only need `StatementCapture` and `CapturedStatement` (for example, in a project that uses TestNG, or for development-time debugging outside of any test framework), `storm-test` works without JUnit on the classpath. The JUnit-specific classes simply remain unused.
49+
If you only need `SqlCapture` and `CapturedSql` (for example, in a project that uses TestNG, or for development-time debugging outside of any test framework), `storm-test` works without JUnit on the classpath. The JUnit-specific classes simply remain unused.
5050

5151
---
5252

@@ -107,7 +107,7 @@ Test methods can declare parameters of the following types, and Storm will resol
107107
| Parameter type | What is injected |
108108
|--------------------|---------------------------------------------------------------------------------|
109109
| `DataSource` | The test database connection. |
110-
| `StatementCapture` | A fresh capture instance for recording SQL statements (see below). |
110+
| `SqlCapture` | A fresh capture instance for recording SQL statements (see below). |
111111
| Any type with a static `of(DataSource)` factory method | An instance created via that factory method. This covers `ORMTemplate` and custom types that follow the same pattern. |
112112

113113
The factory method resolution also supports Kotlin companion objects. If a class has a `Companion` field with an `of(DataSource)` method, Storm will use it. This means `ORMTemplate` works seamlessly in both Kotlin and Java tests without any additional configuration.
@@ -195,11 +195,11 @@ class PostgresTest {
195195

196196
## Statement Capture
197197

198-
When testing database code, knowing _what_ SQL is executed is often as important as knowing _whether_ the operation succeeded. A test might pass because the correct rows were returned, but the underlying query could be inefficient, missing a filter, or using unexpected parameters. `StatementCapture` gives you visibility into the SQL that Storm generates, so you can write assertions not just on results, but on the queries themselves.
198+
When testing database code, knowing _what_ SQL is executed is often as important as knowing _whether_ the operation succeeded. A test might pass because the correct rows were returned, but the underlying query could be inefficient, missing a filter, or using unexpected parameters. `SqlCapture` gives you visibility into the SQL that Storm generates, so you can write assertions not just on results, but on the queries themselves.
199199

200-
`StatementCapture` records every SQL statement generated during a block of code, along with its operation type (`SELECT`, `INSERT`, `UPDATE`, `DELETE`) and bound parameter values. It provides a high-level API designed for test assertions: count statements, filter by operation type, and inspect individual queries.
200+
`SqlCapture` records every SQL statement generated during a block of code, along with its operation type (`SELECT`, `INSERT`, `UPDATE`, `DELETE`) and bound parameter values. It provides a high-level API designed for test assertions: count statements, filter by operation type, and inspect individual queries.
201201

202-
`StatementCapture` is framework-agnostic. It does not depend on JUnit and can be used with any test framework, or even outside of tests entirely (for example, in development-time debugging or diagnostics).
202+
`SqlCapture` is framework-agnostic. It does not depend on JUnit and can be used with any test framework, or even outside of tests entirely (for example, in development-time debugging or diagnostics).
203203

204204
### Use Cases
205205

@@ -209,7 +209,7 @@ When testing database code, knowing _what_ SQL is executed is often as important
209209

210210
**Inspecting SQL structure.** For custom queries or complex filter logic, you may want to verify that the generated SQL contains specific clauses (such as a `WHERE` condition or a `JOIN`) or that the correct parameters were bound. This is especially useful when testing query builder logic that constructs dynamic predicates.
211211

212-
**Debugging during development.** When a query does not return the expected results, wrapping the operation in a `StatementCapture` block lets you print the exact SQL and parameters without configuring logging or attaching a debugger.
212+
**Debugging during development.** When a query does not return the expected results, wrapping the operation in a `SqlCapture` block lets you print the exact SQL and parameters without configuring logging or attaching a debugger.
213213

214214
### Basic Usage
215215

@@ -219,7 +219,7 @@ Wrap any Storm operation in a `run`, `execute`, or `executeThrowing` call to cap
219219
<TabItem value="kotlin" label="Kotlin" default>
220220

221221
```kotlin
222-
val capture = StatementCapture()
222+
val capture = SqlCapture()
223223

224224
capture.run { orm.entity(User::class).findAll() }
225225

@@ -230,7 +230,7 @@ capture.count(Operation.SELECT) shouldBe 1
230230
<TabItem value="java" label="Java">
231231

232232
```java
233-
var capture = new StatementCapture();
233+
var capture = new SqlCapture();
234234

235235
capture.run(() -> orm.entity(User.class).findAll());
236236

@@ -246,7 +246,7 @@ The `execute` variant returns the result of the captured operation, so you can c
246246
<TabItem value="kotlin" label="Kotlin" default>
247247

248248
```kotlin
249-
val capture = StatementCapture()
249+
val capture = SqlCapture()
250250

251251
val users = capture.execute { orm.entity(User::class).findAll() }
252252

@@ -258,7 +258,7 @@ capture.count(Operation.SELECT) shouldBe 1
258258
<TabItem value="java" label="Java">
259259

260260
```java
261-
var capture = new StatementCapture();
261+
var capture = new SqlCapture();
262262

263263
List<User> users = capture.execute(() -> orm.entity(User.class).findAll());
264264

@@ -281,7 +281,7 @@ All three methods are scoped: only SQL statements generated within the block are
281281

282282
### Inspecting Captured Statements
283283

284-
Each captured statement is represented as a `CapturedStatement` record with three fields:
284+
Each captured statement is represented as a `CapturedSql` record with three fields:
285285

286286
| Field | Type | Description |
287287
|--------------|-------------------|---------------------------------------------------------------------------------|
@@ -300,21 +300,21 @@ int selects = capture.count(Operation.SELECT);
300300
int inserts = capture.count(Operation.INSERT);
301301

302302
// Get all captured statements
303-
List<CapturedStatement> all = capture.statements();
303+
List<CapturedSql> all = capture.statements();
304304

305305
// Filter by operation type
306-
List<CapturedStatement> selectStmts = capture.statements(Operation.SELECT);
306+
List<CapturedSql> selectStmts = capture.statements(Operation.SELECT);
307307

308308
// Inspect a specific statement
309-
CapturedStatement stmt = selectStmts.getFirst();
309+
CapturedSql stmt = selectStmts.getFirst();
310310
String sql = stmt.statement(); // SQL with ? placeholders
311311
List<Object> params = stmt.parameters(); // Bound parameter values
312312
Operation op = stmt.operation(); // SELECT, INSERT, UPDATE, DELETE, or UNDEFINED
313313
```
314314

315315
### Accumulation and Clearing
316316

317-
Statements accumulate across multiple `run`/`execute` calls on the same `StatementCapture` instance. This is useful when you want to measure the total SQL activity of a sequence of operations. Use `clear()` to reset between captures when you need to measure operations independently:
317+
Statements accumulate across multiple `run`/`execute` calls on the same `SqlCapture` instance. This is useful when you want to measure the total SQL activity of a sequence of operations. Use `clear()` to reset between captures when you need to measure operations independently:
318318

319319
```java
320320
capture.run(() -> orm.entity(User.class).findAll());
@@ -327,14 +327,14 @@ assertEquals(0, capture.count());
327327

328328
### Verifying Query Counts
329329

330-
A count assertion is the simplest and most common use of `StatementCapture`. It protects against regressions where a code change inadvertently introduces extra queries:
330+
A count assertion is the simplest and most common use of `SqlCapture`. It protects against regressions where a code change inadvertently introduces extra queries:
331331

332332
<Tabs groupId="language">
333333
<TabItem value="kotlin" label="Kotlin" default>
334334

335335
```kotlin
336336
@Test
337-
fun `bulk insert should use single statement`(orm: ORMTemplate, capture: StatementCapture) {
337+
fun `bulk insert should use single statement`(orm: ORMTemplate, capture: SqlCapture) {
338338
val items = listOf(Item(name = "A"), Item(name = "B"), Item(name = "C"))
339339
capture.run { orm.entity(Item::class).insertAll(items) }
340340

@@ -347,7 +347,7 @@ fun `bulk insert should use single statement`(orm: ORMTemplate, capture: Stateme
347347

348348
```java
349349
@Test
350-
void bulkInsertShouldUseSingleStatement(ORMTemplate orm, StatementCapture capture) {
350+
void bulkInsertShouldUseSingleStatement(ORMTemplate orm, SqlCapture capture) {
351351
var items = List.of(new Item(0, "A"), new Item(0, "B"), new Item(0, "C"));
352352
capture.run(() -> orm.entity(Item.class).insertAll(items));
353353

@@ -364,7 +364,7 @@ For finer-grained assertions, inspect the SQL text and bound parameters of indiv
364364

365365
```java
366366
@Test
367-
void findByIdShouldUseWhereClause(ORMTemplate orm, StatementCapture capture) {
367+
void findByIdShouldUseWhereClause(ORMTemplate orm, SqlCapture capture) {
368368
capture.run(() -> orm.entity(User.class).findById(42));
369369

370370
var stmts = capture.statements(Operation.SELECT);
@@ -380,7 +380,7 @@ When a service method should only read data, you can verify that no write operat
380380

381381
```java
382382
@Test
383-
void reportGenerationShouldBeReadOnly(ORMTemplate orm, StatementCapture capture) {
383+
void reportGenerationShouldBeReadOnly(ORMTemplate orm, SqlCapture capture) {
384384
capture.run(() -> generateReport(orm));
385385

386386
assertEquals(0, capture.count(Operation.INSERT));
@@ -393,20 +393,20 @@ void reportGenerationShouldBeReadOnly(ORMTemplate orm, StatementCapture capture)
393393

394394
## With JUnit 5 Parameter Injection
395395

396-
When using `@StormTest`, a fresh `StatementCapture` instance is automatically injected into each test method that declares it as a parameter. This means you do not need to create one manually, and each test starts with a clean slate:
396+
When using `@StormTest`, a fresh `SqlCapture` instance is automatically injected into each test method that declares it as a parameter. This means you do not need to create one manually, and each test starts with a clean slate:
397397

398398
```java
399399
@StormTest(scripts = {"/schema.sql", "/data.sql"})
400400
class QueryCountTest {
401401

402402
@Test
403-
void insertShouldGenerateOneStatement(ORMTemplate orm, StatementCapture capture) {
403+
void insertShouldGenerateOneStatement(ORMTemplate orm, SqlCapture capture) {
404404
capture.run(() -> orm.entity(Item.class).insert(new Item(0, "Test")));
405405
assertEquals(1, capture.count(Operation.INSERT));
406406
}
407407

408408
@Test
409-
void eachTestGetsAFreshCapture(StatementCapture capture) {
409+
void eachTestGetsAFreshCapture(SqlCapture capture) {
410410
// No statements from previous tests
411411
assertEquals(0, capture.count());
412412
}
@@ -418,7 +418,7 @@ class QueryCountTest {
418418
## Tips
419419

420420
1. **Keep SQL scripts small and focused.** Each test class should set up only the tables and data it needs. This keeps tests fast and independent.
421-
2. **Use `StatementCapture` to verify query counts.** Asserting the number of statements an operation produces is an effective way to catch unintended query changes during refactoring.
421+
2. **Use `SqlCapture` to verify query counts.** Asserting the number of statements an operation produces is an effective way to catch unintended query changes during refactoring.
422422
3. **Clear between captures** when a single test method needs to measure multiple operations independently.
423423
4. **Prefer `@StormTest` over manual setup.** It eliminates boilerplate and ensures consistent database lifecycle management across test classes.
424-
5. **`StatementCapture` is thread-local.** Captures are bound to the calling thread, so multi-threaded tests will only record statements from the thread that called `run`/`execute`.
424+
5. **`SqlCapture` is thread-local.** Captures are bound to the calling thread, so multi-threaded tests will only record statements from the thread that called `run`/`execute`.

storm-core/src/main/java/st/orm/core/template/Column.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,17 @@ public interface Column {
8181
*/
8282
boolean foreignKey();
8383

84+
/**
85+
* Gets the generation strategy for the primary key of the referenced (foreign) entity.
86+
*
87+
* <p>Only meaningful when {@link #foreignKey()} returns {@code true}. For non-foreign-key columns, this method
88+
* returns {@link GenerationStrategy#NONE}.</p>
89+
*
90+
* @return the generation strategy of the referenced entity's primary key.
91+
* @since 1.10
92+
*/
93+
GenerationStrategy foreignKeyGeneration();
94+
8495
/**
8596
* The 1-based index of the key.
8697
*

storm-core/src/main/java/st/orm/core/template/Model.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,20 @@ default SequencedMap<Column, Object> declaredValues(@Nonnull E record)
230230
return values(declaredColumns(), record);
231231
}
232232

233+
/**
234+
* Validates that foreign key columns in the given record do not contain default primary key values.
235+
*
236+
* <p>A default primary key value (such as {@code 0} for {@code Integer}) on a foreign key reference typically
237+
* indicates that the referenced entity has not been persisted yet. This method should be called before inserting
238+
* or updating a record.</p>
239+
*
240+
* @param columns the columns to validate.
241+
* @param record the record to validate.
242+
* @throws SqlTemplateException if extraction fails.
243+
* @since 1.10
244+
*/
245+
void validateForeignKeys(@Nonnull List<Column> columns, @Nonnull E record) throws SqlTemplateException;
246+
233247
/**
234248
* Finds a unique metamodel referring to the given entity type.
235249
*

storm-core/src/main/java/st/orm/core/template/impl/ColumnImpl.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
* @param generation the generation strategy.
3434
* @param sequence the sequence name.
3535
* @param foreignKey whether the column is a foreign key.
36+
* @param foreignKeyGeneration the generation strategy for the primary key of the referenced entity.
3637
* @param keyIndex the 1-based index of the key.
3738
* @param nullable whether the column is nullable.
3839
* @param insertable whether the column is insertable.
@@ -50,6 +51,7 @@ public record ColumnImpl(
5051
@Nonnull GenerationStrategy generation,
5152
@Nonnull String sequence,
5253
boolean foreignKey,
54+
@Nonnull GenerationStrategy foreignKeyGeneration,
5355
int keyIndex,
5456
boolean nullable,
5557
boolean insertable,

0 commit comments

Comments
 (0)