Add the transactions module, and move the migration project under migration-behavior/
The repository now aggregates two independent modules. migration-behavior/ is the
original project, moved unchanged; it stays on Spring Boot 4.0.6 / JDK 21 because
that is what the four published migration articles were verified against, and
upgrading it would silently invalidate output they quote. The article-tagged trees
are untouched, so links into a tag are unaffected.
transactions/ Companion code for "@Transactional in Spring: Propagation, Isolation,
and the Six Ways It Silently Does Nothing". Spring Boot 4.1.1 / JDK 25.
Every row of the propagation matrix is produced by calling the method and asking the
transaction manager what it did. The transaction NAME is the exhibit: a scope that
joined reports its caller's name, a scope that started its own reports its own.
Three things the transcripts settle:
- Propagation.NESTED cannot be used with JpaTransactionManager. It fails twice,
with two different messages, the second of which blames your JPA provider. The
savepoint manager comes from the object the JpaDialect returns when it begins the
transaction, and Hibernate's does not implement one. It works on
DataSourceTransactionManager, because a savepoint is a JDBC concept -- shown
working there rather than only failing here.
- Catching a REQUIRED inner failure does not save the transaction. The inner scope
has already marked it rollback-only, so the commit throws
UnexpectedRollbackException from a place with no connection to the cause.
- A checked exception commits, and so does a swallowed one. Those two do not merely
fail to start a transaction; they commit work the code was abandoning.
19 contract tests, six captured transcripts, all regenerated by scripts/run-all.sh.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gip4srpzMwjgoba6uEfbr5
This commit is contained in:
64
migration-behavior/pom.xml
Normal file
64
migration-behavior/pom.xml
Normal file
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.0.6</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>sdjpa4-demo</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>sdjpa4-demo</name>
|
||||
<description>Spring Data JPA 3 to 4 migration demo (ankurm.com)</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<!-- JSpecify: nullability annotations used by Spring Data 4 -->
|
||||
<dependency>
|
||||
<groupId>org.jspecify</groupId>
|
||||
<artifactId>jspecify</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<annotationProcessorPaths>
|
||||
<!-- Spring Data JPA 4: hibernate-jpamodelgen was renamed to hibernate-processor -->
|
||||
<path>
|
||||
<groupId>org.hibernate.orm</groupId>
|
||||
<artifactId>hibernate-processor</artifactId>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ankurm.sdjpa4demo;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class Application {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(Application.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.ankurm.sdjpa4demo;
|
||||
|
||||
import com.ankurm.sdjpa4demo.domain.Author;
|
||||
import com.ankurm.sdjpa4demo.domain.Book;
|
||||
import com.ankurm.sdjpa4demo.domain.BookSummary;
|
||||
import com.ankurm.sdjpa4demo.domain.Money;
|
||||
import com.ankurm.sdjpa4demo.repo.AuthorRepository;
|
||||
import com.ankurm.sdjpa4demo.repo.BookRepository;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.dao.IncorrectResultSizeDataAccessException;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.JpaSort;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static com.ankurm.sdjpa4demo.repo.AuthorSpecifications.byCountryDelete;
|
||||
import static com.ankurm.sdjpa4demo.repo.AuthorSpecifications.hasCountry;
|
||||
import static com.ankurm.sdjpa4demo.repo.AuthorSpecifications.relabelCountry;
|
||||
|
||||
@Component
|
||||
@Profile("!test")
|
||||
public class DemoRunner implements CommandLineRunner {
|
||||
|
||||
private final AuthorRepository authors;
|
||||
private final BookRepository books;
|
||||
|
||||
public DemoRunner(AuthorRepository authors, BookRepository books) {
|
||||
this.authors = authors;
|
||||
this.books = books;
|
||||
}
|
||||
|
||||
private static void section(String title) {
|
||||
System.out.println("\n================ " + title + " ================");
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(String... args) {
|
||||
Author gof = authors.save(new Author("Erich Gamma", "CH"));
|
||||
Author bloch = authors.save(new Author("Joshua Bloch", "US"));
|
||||
Author anon = authors.save(new Author("Anonymous", null)); // null country
|
||||
authors.save(new Author("Martin Fowler", "UK"));
|
||||
authors.save(new Author("Kent Beck", "DE")); // bookless: safe to delete in section G
|
||||
Author wall1 = authors.save(new Author("Grady Booch", "US"));
|
||||
Author wall2 = authors.save(new Author("Robert Martin", "US"));
|
||||
|
||||
books.save(new Book("Design Patterns", new Money(new BigDecimal("42.00"), "USD"), gof));
|
||||
books.save(new Book("Effective Java", new Money(new BigDecimal("45.00"), "USD"), bloch));
|
||||
books.save(new Book("Refactoring", new Money(new BigDecimal("45.00"), "USD"), anon)); // duplicate price 45.00
|
||||
|
||||
// (A) Optional vs non-null vs @Nullable single-result semantics
|
||||
section("A. Null-handling of single-result query methods");
|
||||
Optional<Author> opt = authors.findByName("No Such Author");
|
||||
System.out.println("findByName(Optional) missing -> " + opt);
|
||||
|
||||
try {
|
||||
authors.getByName("No Such Author");
|
||||
} catch (EmptyResultDataAccessException ex) {
|
||||
System.out.println("getByName(non-null) missing -> throws " + ex.getClass().getSimpleName());
|
||||
}
|
||||
|
||||
Author byCountry = authors.findByCountry("ZZ");
|
||||
System.out.println("findByCountry(@Nullable) missing -> " + byCountry);
|
||||
|
||||
// (B) JSpecify @NullMarked rejects null arguments at runtime
|
||||
section("B. JSpecify @NullMarked parameter enforcement");
|
||||
try {
|
||||
authors.getByName(null);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
System.out.println("getByName(null) -> throws " + ex.getClass().getSimpleName()
|
||||
+ ": " + ex.getMessage());
|
||||
}
|
||||
System.out.println("findByCountry(null) is allowed (@Nullable param) -> "
|
||||
+ authors.findByCountry(null));
|
||||
|
||||
// (C) NULLS precedence in Sort (consistent in SD JPA 4)
|
||||
section("C. NULLS FIRST vs NULLS LAST in Sort");
|
||||
List<Author> nullsFirst = authors.findByNameStartingWith(
|
||||
"", Sort.by(Sort.Order.asc("country").nullsFirst()));
|
||||
System.out.println("country ASC NULLS FIRST -> "
|
||||
+ nullsFirst.stream().map(Author::getCountry).toList());
|
||||
List<Author> nullsLast = authors.findByNameStartingWith(
|
||||
"", Sort.by(Sort.Order.asc("country").nullsLast()));
|
||||
System.out.println("country ASC NULLS LAST -> "
|
||||
+ nullsLast.stream().map(Author::getCountry).toList());
|
||||
|
||||
// (D) Derived query now flows through JPQL (see generated SQL just above in log)
|
||||
section("D. Derived query -> JPQL path");
|
||||
System.out.println("findByNameStartingWith('J') -> "
|
||||
+ authors.findByNameStartingWithOrderByNameAsc("J")
|
||||
.stream().map(Author::getName).toList());
|
||||
|
||||
// (E) New JPA 3.2 JPQL: || concatenation + replace()
|
||||
section("E. New JPQL functions ( || and replace() )");
|
||||
System.out.println("badgeFor(gof) -> " + authors.badgeFor(gof.getId()));
|
||||
System.out.println("badgeFor(anon) -> " + authors.badgeFor(anon.getId()));
|
||||
|
||||
// (F) Single-result that matches multiple rows -> non-unique edge case (embedded-path version)
|
||||
section("F. Non-unique single result edge case (embedded value object path)");
|
||||
try {
|
||||
Book b = books.findByPriceAmount(new BigDecimal("45.00"));
|
||||
System.out.println("findByPriceAmount(45.00) -> " + b);
|
||||
} catch (IncorrectResultSizeDataAccessException ex) {
|
||||
System.out.println("findByPriceAmount(45.00) matches 2 rows -> throws "
|
||||
+ ex.getClass().getSimpleName());
|
||||
}
|
||||
|
||||
// (G) Derived DELETE returns affected count
|
||||
section("G. Derived delete returns count");
|
||||
long deleted = authors.deleteByCountry("DE");
|
||||
System.out.println("deleteByCountry('DE') removed -> " + deleted + " row(s)");
|
||||
|
||||
// (H) PredicateSpecification: the SAME instance reused for a read and a bulk delete
|
||||
section("H. PredicateSpecification reused across read and delete");
|
||||
List<Author> usAuthors = authors.findAll(hasCountry("US"));
|
||||
System.out.println("findAll(hasCountry(\"US\")) -> "
|
||||
+ usAuthors.stream().map(Author::getName).toList());
|
||||
|
||||
// (I) DeleteSpecification: explicit CriteriaDelete-typed bulk delete
|
||||
section("I. DeleteSpecification bulk delete");
|
||||
long removedGB = authors.delete(byCountryDelete("GB")); // no GB rows on purpose -> 0
|
||||
System.out.println("delete(byCountryDelete(\"GB\")) removed -> " + removedGB + " row(s)");
|
||||
long removedViaPredicate = authors.delete(hasCountry("US").and((root, cb) ->
|
||||
cb.equal(root.get("name"), "Grady Booch")));
|
||||
System.out.println("delete(hasCountry(\"US\").and(name=\"Grady Booch\")) removed -> "
|
||||
+ removedViaPredicate + " row(s)");
|
||||
|
||||
// (J) UpdateSpecification: CriteriaUpdate-backed bulk update
|
||||
section("J. UpdateSpecification bulk update");
|
||||
long updated = authors.update(relabelCountry("UK", "GB"));
|
||||
System.out.println("update(relabelCountry(\"UK\" -> \"GB\")) updated -> " + updated + " row(s)");
|
||||
System.out.println("findAll(hasCountry(\"GB\")) -> "
|
||||
+ authors.findAll(hasCountry("GB")).stream().map(Author::getName).toList());
|
||||
|
||||
// (K) JpaSort.unsafe(...) with a CASE expression, combined with a plain derived query
|
||||
section("K. JpaSort.unsafe with a CASE expression");
|
||||
Sort usFirst = JpaSort.unsafe(Sort.Direction.ASC,
|
||||
"CASE WHEN country = 'US' THEN 0 ELSE 1 END");
|
||||
List<Author> caseOrdered = authors.findByNameStartingWith("", usFirst);
|
||||
System.out.println("JpaSort.unsafe(CASE WHEN country='US' ...) -> "
|
||||
+ caseOrdered.stream().map(a -> a.getName() + "(" + a.getCountry() + ")").toList());
|
||||
|
||||
// (L) Value-object corner scenarios: embedded-path traversal + record projection
|
||||
section("L. Money value object: embedded-path query and record projection");
|
||||
List<Book> pricey = books.findByPriceAmountGreaterThanEqual(new BigDecimal("45.00"));
|
||||
System.out.println("findByPriceAmountGreaterThanEqual(45.00) -> "
|
||||
+ pricey.stream().map(Book::getTitle).toList());
|
||||
List<BookSummary> summaries = books.findByPriceAmountLessThanEqual(new BigDecimal("42.00"));
|
||||
summaries.forEach(s -> System.out.println("BookSummary projection -> " + s));
|
||||
|
||||
section("DONE");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.ankurm.sdjpa4demo.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
public class Author {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
private String name;
|
||||
|
||||
// Nullable on purpose: used to demonstrate NULLS FIRST/LAST precedence in Sort.
|
||||
private String country;
|
||||
|
||||
protected Author() { }
|
||||
|
||||
public Author(String name, String country) {
|
||||
this.name = name;
|
||||
this.country = country;
|
||||
}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public String getName() { return name; }
|
||||
public String getCountry() { return country; }
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Author{id=" + id + ", name='" + name + "', country=" + country + "}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ankurm.sdjpa4demo.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
public class Book {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
private String title;
|
||||
|
||||
// Money's fields become columns on the book table: amount -> price_amount, currency -> price_currency.
|
||||
@Embedded
|
||||
private Money price;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
private Author author;
|
||||
|
||||
protected Book() { }
|
||||
|
||||
public Book(String title, Money price, Author author) {
|
||||
this.title = title;
|
||||
this.price = price;
|
||||
this.author = author;
|
||||
}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public String getTitle() { return title; }
|
||||
public Money getPrice() { return price; }
|
||||
public Author getAuthor() { return author; }
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Book{title='" + title + "', price=" + price + "}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ankurm.sdjpa4demo.domain;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
// Projection / DTO value object - only what the caller needs. Spring Data populates
|
||||
// this via constructor-matching (a class-based projection), no @Query required.
|
||||
public record BookSummary(String title, BigDecimal amount) {}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ankurm.sdjpa4demo.domain;
|
||||
|
||||
import jakarta.persistence.Embeddable;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
// A value object: no identity, just its two values. Records make ideal @Embeddable
|
||||
// types in Spring Data JPA 4 / Hibernate 7 - immutable, and equals()/hashCode() come free.
|
||||
@Embeddable
|
||||
public record Money(BigDecimal amount, String currency) {}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.ankurm.sdjpa4demo.repo;
|
||||
|
||||
import com.ankurm.sdjpa4demo.domain.Author;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AuthorRepository
|
||||
extends JpaRepository<Author, Long>, JpaSpecificationExecutor<Author> {
|
||||
|
||||
// (1) Optional single result -> empty when not found (never throws for "not found").
|
||||
Optional<Author> findByName(String name);
|
||||
|
||||
// (2) Non-null single result. In a @NullMarked package this is the DEFAULT.
|
||||
// Missing row -> EmptyResultDataAccessException. Null argument -> IllegalArgumentException.
|
||||
Author getByName(String name);
|
||||
|
||||
// (3) Explicitly @Nullable single result -> returns null when not found,
|
||||
// and tolerates a null argument.
|
||||
@Nullable
|
||||
Author findByCountry(@Nullable String country);
|
||||
|
||||
// (4) Derived query used to demonstrate the Criteria -> JPQL shift (watch the SQL log).
|
||||
List<Author> findByNameStartingWithOrderByNameAsc(String prefix);
|
||||
|
||||
// (5) Sort with explicit NULLS precedence. Consistent for derived queries in SD JPA 4.
|
||||
List<Author> findByNameStartingWith(String prefix, Sort sort);
|
||||
|
||||
// (6) New JPA 3.2 / Hibernate 7 JPQL features: || concatenation + replace() function.
|
||||
@Query("select replace(a.name, ' ', '_') || ':' || coalesce(a.country, 'N/A') from Author a where a.id = :id")
|
||||
String badgeFor(@Param("id") Long id);
|
||||
|
||||
// (7) Derived DELETE returning the affected count.
|
||||
@Modifying
|
||||
@Transactional
|
||||
long deleteByCountry(@Nullable String country);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.ankurm.sdjpa4demo.repo;
|
||||
|
||||
import com.ankurm.sdjpa4demo.domain.Author;
|
||||
import org.springframework.data.jpa.domain.DeleteSpecification;
|
||||
import org.springframework.data.jpa.domain.PredicateSpecification;
|
||||
import org.springframework.data.jpa.domain.UpdateSpecification;
|
||||
|
||||
/**
|
||||
* Reusable Specification-family helpers for Author, exercising the refined Specification API
|
||||
* introduced in Spring Data JPA 4.0: {@link PredicateSpecification} (context-agnostic, reusable
|
||||
* across select/update/delete), {@link DeleteSpecification} (CriteriaDelete-backed bulk delete),
|
||||
* and {@link UpdateSpecification} (CriteriaUpdate-backed bulk update).
|
||||
*/
|
||||
public final class AuthorSpecifications {
|
||||
|
||||
private AuthorSpecifications() { }
|
||||
|
||||
// PredicateSpecification: a bare Predicate, usable regardless of whether the surrounding
|
||||
// query is a select, update, or delete - this exact instance is reused for both below.
|
||||
public static PredicateSpecification<Author> hasCountry(String country) {
|
||||
return (root, cb) -> cb.equal(root.get("country"), country);
|
||||
}
|
||||
|
||||
// DeleteSpecification: explicit CriteriaDelete-typed specification for a bulk delete.
|
||||
public static DeleteSpecification<Author> byCountryDelete(String country) {
|
||||
return (root, delete, cb) -> cb.equal(root.get("country"), country);
|
||||
}
|
||||
|
||||
// UpdateSpecification: composes an UpdateOperation (the SET clause, over CriteriaUpdate)
|
||||
// with a PredicateSpecification (the WHERE clause) via UpdateSpecification.update(...).where(...).
|
||||
public static UpdateSpecification<Author> relabelCountry(String from, String to) {
|
||||
return UpdateSpecification
|
||||
.<Author>update((root, update, cb) -> update.set(root.get("country"), to))
|
||||
.where(hasCountry(from));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ankurm.sdjpa4demo.repo;
|
||||
|
||||
import com.ankurm.sdjpa4demo.domain.Book;
|
||||
import com.ankurm.sdjpa4demo.domain.BookSummary;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
public interface BookRepository extends JpaRepository<Book, Long> {
|
||||
|
||||
// Embedded-value-object path traversal: price -> amount. Resolves to: where b.price.amount >= :min
|
||||
List<Book> findByPriceAmountGreaterThanEqual(BigDecimal min);
|
||||
|
||||
// Single-result derived query over an embedded path that can match MORE than one row on bad data:
|
||||
// demonstrates IncorrectResultSizeDataAccessException (the "non-unique" edge case).
|
||||
Book findByPriceAmount(BigDecimal price);
|
||||
|
||||
// CORNER CASE: a derived-query class-based (record) projection matches constructor-parameter names
|
||||
// against DIRECT entity properties only. BookSummary's "amount" component does not resolve against
|
||||
// the nested b.price.amount path this way - PropertyReferenceException: No property 'amount' found
|
||||
// for type 'Book' (verified by actually running the derived-method form before falling back to this).
|
||||
// A @Query constructor expression works for nested/embedded paths; the derived-method-only form does not.
|
||||
@Query("select new com.ankurm.sdjpa4demo.domain.BookSummary(b.title, b.price.amount) "
|
||||
+ "from Book b where b.price.amount <= :max")
|
||||
List<BookSummary> findByPriceAmountLessThanEqual(@Param("max") BigDecimal max);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Repositories live in a @NullMarked package. As of Spring Data 4, JSpecify's
|
||||
* @NullMarked makes non-null the DEFAULT for parameters and return values,
|
||||
* and Spring Data enforces it at runtime.
|
||||
*/
|
||||
@NullMarked
|
||||
package com.ankurm.sdjpa4demo.repo;
|
||||
|
||||
import org.jspecify.annotations.NullMarked;
|
||||
13
migration-behavior/src/main/resources/application.properties
Normal file
13
migration-behavior/src/main/resources/application.properties
Normal file
@@ -0,0 +1,13 @@
|
||||
spring.datasource.url=jdbc:h2:mem:demo;DB_CLOSE_DELAY=-1
|
||||
spring.datasource.driverClassName=org.h2.Driver
|
||||
spring.datasource.username=sa
|
||||
spring.datasource.password=
|
||||
|
||||
spring.jpa.hibernate.ddl-auto=create-drop
|
||||
spring.jpa.show-sql=true
|
||||
spring.jpa.properties.hibernate.format_sql=false
|
||||
|
||||
# Keep the console readable: quiet the banner and framework noise.
|
||||
spring.main.banner-mode=off
|
||||
logging.level.root=WARN
|
||||
logging.level.org.hibernate.SQL=DEBUG
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.ankurm.sdjpa4demo;
|
||||
|
||||
import com.ankurm.sdjpa4demo.domain.Author;
|
||||
import com.ankurm.sdjpa4demo.domain.Book;
|
||||
import com.ankurm.sdjpa4demo.domain.BookSummary;
|
||||
import com.ankurm.sdjpa4demo.domain.Money;
|
||||
import com.ankurm.sdjpa4demo.repo.AuthorRepository;
|
||||
import com.ankurm.sdjpa4demo.repo.BookRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.dao.IncorrectResultSizeDataAccessException;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.JpaSort;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import static com.ankurm.sdjpa4demo.repo.AuthorSpecifications.byCountryDelete;
|
||||
import static com.ankurm.sdjpa4demo.repo.AuthorSpecifications.hasCountry;
|
||||
import static com.ankurm.sdjpa4demo.repo.AuthorSpecifications.relabelCountry;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class MigrationBehaviorTests {
|
||||
|
||||
@Autowired AuthorRepository authors;
|
||||
@Autowired BookRepository books;
|
||||
|
||||
private void seed() {
|
||||
Author a1 = authors.save(new Author("Joshua Bloch", "US"));
|
||||
authors.save(new Author("Anonymous", null));
|
||||
books.save(new Book("Effective Java", new Money(new BigDecimal("45.00"), "USD"), a1));
|
||||
books.save(new Book("Refactoring", new Money(new BigDecimal("45.00"), "USD"), a1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void optionalMissingReturnsEmpty() {
|
||||
seed();
|
||||
assertTrue(authors.findByName("nope").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonNullMissingThrows() {
|
||||
seed();
|
||||
assertThrows(EmptyResultDataAccessException.class, () -> authors.getByName("nope"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullMarkedRejectsNullArgument() {
|
||||
seed();
|
||||
assertThrows(IllegalArgumentException.class, () -> authors.getByName(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullableParamAcceptsNull() {
|
||||
seed();
|
||||
assertDoesNotThrow(() -> authors.findByCountry(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullsPrecedenceIsHonored() {
|
||||
seed();
|
||||
List<String> first = authors.findByNameStartingWith("",
|
||||
Sort.by(Sort.Order.asc("country").nullsFirst()))
|
||||
.stream().map(Author::getCountry).toList();
|
||||
assertNull(first.get(0), "NULLS FIRST should put null country first");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonUniqueSingleResultThrows() {
|
||||
seed();
|
||||
assertThrows(IncorrectResultSizeDataAccessException.class,
|
||||
() -> books.findByPriceAmount(new BigDecimal("45.00")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void newJpqlFunctionsWork() {
|
||||
Author a = authors.save(new Author("Erich Gamma", "CH"));
|
||||
assertEquals("Erich_Gamma:CH", authors.badgeFor(a.getId()));
|
||||
}
|
||||
|
||||
// --- Corner scenarios: refined Specification API (DeleteSpecification / UpdateSpecification / PredicateSpecification) ---
|
||||
|
||||
@Test
|
||||
void predicateSpecificationReusedAcrossReadAndDelete() {
|
||||
authors.save(new Author("Grady Booch", "US"));
|
||||
authors.save(new Author("Robert Martin", "US"));
|
||||
authors.save(new Author("Martin Fowler", "UK"));
|
||||
|
||||
assertEquals(2, authors.findAll(hasCountry("US")).size());
|
||||
|
||||
long removed = authors.delete(hasCountry("US"));
|
||||
assertEquals(2, removed);
|
||||
assertTrue(authors.findAll(hasCountry("US")).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteSpecificationBulkDelete() {
|
||||
authors.save(new Author("Kent Beck", "DE"));
|
||||
authors.save(new Author("Erich Gamma", "CH"));
|
||||
|
||||
long removed = authors.delete(byCountryDelete("DE"));
|
||||
|
||||
assertEquals(1, removed);
|
||||
assertTrue(authors.findByCountry("DE") == null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateSpecificationBulkUpdate() {
|
||||
authors.save(new Author("Martin Fowler", "UK"));
|
||||
authors.save(new Author("Sam Newman", "UK"));
|
||||
|
||||
long updated = authors.update(relabelCountry("UK", "GB"));
|
||||
|
||||
assertEquals(2, updated);
|
||||
assertEquals(2, authors.findAll(hasCountry("GB")).size());
|
||||
assertTrue(authors.findAll(hasCountry("UK")).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void jpaSortUnsafeCaseExpressionOrdersResults() {
|
||||
authors.save(new Author("Alpha Author", "DE"));
|
||||
authors.save(new Author("Beta Author", "US"));
|
||||
|
||||
Sort usFirst = JpaSort.unsafe(Sort.Direction.ASC, "CASE WHEN country = 'US' THEN 0 ELSE 1 END");
|
||||
List<Author> ordered = authors.findByNameStartingWith("", usFirst);
|
||||
|
||||
assertEquals("US", ordered.get(0).getCountry());
|
||||
}
|
||||
|
||||
// --- Corner scenarios: Money embeddable value object ---
|
||||
|
||||
@Test
|
||||
void embeddedValueObjectPathTraversalWorks() {
|
||||
seed();
|
||||
List<Book> found = books.findByPriceAmountGreaterThanEqual(new BigDecimal("45.00"));
|
||||
assertEquals(2, found.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordProjectionPopulatesFromEmbeddedPath() {
|
||||
Author a = authors.save(new Author("Kathy Sierra", "US"));
|
||||
books.save(new Book("Head First Java", new Money(new BigDecimal("39.99"), "USD"), a));
|
||||
|
||||
List<BookSummary> summaries = books.findByPriceAmountLessThanEqual(new BigDecimal("40.00"));
|
||||
|
||||
assertEquals(1, summaries.size());
|
||||
assertEquals("Head First Java", summaries.get(0).title());
|
||||
assertEquals(new BigDecimal("39.99"), summaries.get(0).amount());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user