Merge pull request #2016 from mhalbritter

* pr/2016:
  Upgrade to Spring Boot 4.0.0-M3
  Add NullAway and JSpecify annotations
  Upgrade to Spring Boot 4.0.0-M2

Closes gh-2016
This commit is contained in:
Stéphane Nicoll 2025-10-14 12:26:25 +02:00
commit 684adfe2ba
32 changed files with 195 additions and 114 deletions

View file

@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
java: [ '17' ] java: [ '24' ]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View file

@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
java: [ '17' ] java: [ '24' ]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

10
.mvn/jvm.config Normal file
View file

@ -0,0 +1,10 @@
--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
--add-opens jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED
--add-opens jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED

View file

@ -13,7 +13,8 @@ See the presentation here:
## Run Petclinic locally ## Run Petclinic locally
Spring Petclinic is a [Spring Boot](https://spring.io/guides/gs/spring-boot) application built using [Maven](https://spring.io/guides/gs/maven/) or [Gradle](https://spring.io/guides/gs/gradle/). You can build a JAR file and run it from the command line (it should work just as well with Java 17 or newer): Spring Petclinic is a [Spring Boot](https://spring.io/guides/gs/spring-boot) application built using [Maven](https://spring.io/guides/gs/maven/) or [Gradle](https://spring.io/guides/gs/gradle/).
Java 24 or later is required for the build, but the application can run with Java 17 or newer:
```bash ```bash
git clone https://github.com/spring-projects/spring-petclinic.git git clone https://github.com/spring-projects/spring-petclinic.git
@ -97,7 +98,7 @@ There is a `petclinic.css` in `src/main/resources/static/resources/css`. It was
The following items should be installed in your system: The following items should be installed in your system:
- Java 17 or newer (full JDK, not a JRE) - Java 24 or newer (full JDK, not a JRE)
- [Git command line tool](https://help.github.com/articles/set-up-git) - [Git command line tool](https://help.github.com/articles/set-up-git)
- Your preferred IDE - Your preferred IDE
- Eclipse with the m2e plugin. Note: when m2e is available, there is an m2 icon in `Help -> About` dialog. If m2e is - Eclipse with the m2e plugin. Note: when m2e is available, there is an m2 icon in `Help -> About` dialog. If m2e is

View file

@ -1,22 +1,23 @@
plugins { plugins {
id 'java' id 'java'
id 'checkstyle' id 'checkstyle'
id 'org.springframework.boot' version '3.5.6' id 'org.springframework.boot' version '4.0.0-M3'
id 'io.spring.dependency-management' version '1.1.7' id 'io.spring.dependency-management' version '1.1.7'
id 'org.graalvm.buildtools.native' version '0.11.1' id 'org.graalvm.buildtools.native' version '0.11.1'
id 'org.cyclonedx.bom' version '3.0.0' id 'org.cyclonedx.bom' version '3.0.0'
id 'io.spring.javaformat' version '0.0.47' id 'io.spring.javaformat' version '0.0.47'
id "io.spring.nohttp" version "0.0.11" id "io.spring.nohttp" version "0.0.11"
id 'net.ltgt.errorprone' version '4.3.0'
} }
gradle.startParameter.excludedTaskNames += [ "checkFormatAot", "checkFormatAotTest" ] gradle.startParameter.excludedTaskNames += [ "checkFormatAot", "checkFormatAotTest" ]
group = 'org.springframework.samples' group = 'org.springframework.samples'
version = '3.5.0' version = '4.0.0-SNAPSHOT'
java { java {
toolchain { toolchain {
languageVersion = JavaLanguageVersion.of(17) languageVersion = JavaLanguageVersion.of(24)
} }
} }
@ -29,6 +30,8 @@ ext.springJavaformatCheckstyleVersion = "0.0.47"
ext.webjarsLocatorLiteVersion = "1.1.1" ext.webjarsLocatorLiteVersion = "1.1.1"
ext.webjarsFontawesomeVersion = "4.7.0" ext.webjarsFontawesomeVersion = "4.7.0"
ext.webjarsBootstrapVersion = "5.3.8" ext.webjarsBootstrapVersion = "5.3.8"
ext.errorProneVersion = "2.42.0"
ext.nullAwayVersion = "0.12.10"
dependencies { dependencies {
implementation 'org.springframework.boot:spring-boot-starter-cache' implementation 'org.springframework.boot:spring-boot-starter-cache'
@ -48,12 +51,15 @@ dependencies {
runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'org.postgresql:postgresql'
developmentOnly 'org.springframework.boot:spring-boot-devtools' developmentOnly 'org.springframework.boot:spring-boot-devtools'
testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-starter-restclient'
testImplementation 'org.springframework.boot:spring-boot-testcontainers' testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.springframework.boot:spring-boot-docker-compose' testImplementation 'org.springframework.boot:spring-boot-docker-compose'
testImplementation 'org.testcontainers:junit-jupiter' testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mysql' testImplementation 'org.testcontainers:mysql'
checkstyle "io.spring.javaformat:spring-javaformat-checkstyle:${springJavaformatCheckstyleVersion}" checkstyle "io.spring.javaformat:spring-javaformat-checkstyle:${springJavaformatCheckstyleVersion}"
checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}" checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}"
errorprone "com.google.errorprone:error_prone_core:${errorProneVersion}"
errorprone "com.uber.nullaway:nullaway:${nullAwayVersion}"
} }
tasks.named('test') { tasks.named('test') {
@ -70,6 +76,21 @@ checkstyleNohttp {
configFile = file('src/checkstyle/nohttp-checkstyle.xml') configFile = file('src/checkstyle/nohttp-checkstyle.xml')
} }
tasks.withType(JavaCompile).configureEach {
options.release = 17
options.errorprone {
disableAllChecks = true
}
if (name.equals("compileJava")) {
options.errorprone {
error("NullAway")
option("NullAway:OnlyNullMarked", "true")
option("NullAway:CustomContractAnnotations", "org.springframework.lang.Contract")
option("NullAway:JSpecifyMode", "true")
}
}
}
tasks.named("formatMain").configure { dependsOn("checkstyleMain") } tasks.named("formatMain").configure { dependsOn("checkstyleMain") }
tasks.named("formatMain").configure { dependsOn("checkstyleNohttp") } tasks.named("formatMain").configure { dependsOn("checkstyleNohttp") }

84
pom.xml
View file

@ -5,20 +5,21 @@
<parent> <parent>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId> <artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.6</version> <version>4.0.0-M3</version>
<relativePath></relativePath> <relativePath></relativePath>
</parent> </parent>
<groupId>org.springframework.samples</groupId> <groupId>org.springframework.samples</groupId>
<artifactId>spring-petclinic</artifactId> <artifactId>spring-petclinic</artifactId>
<version>3.5.0-SNAPSHOT</version> <version>4.0.0-SNAPSHOT</version>
<name>petclinic</name> <name>petclinic</name>
<properties> <properties>
<!-- Generic properties --> <!-- Generic properties -->
<java.version>17</java.version> <java.version>24</java.version>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<!-- Important for reproducible builds. Update using e.g. ./mvnw versions:set -DnewVersion=... --> <!-- Important for reproducible builds. Update using e.g. ./mvnw versions:set -DnewVersion=... -->
@ -30,13 +31,14 @@
<webjars-font-awesome.version>4.7.0</webjars-font-awesome.version> <webjars-font-awesome.version>4.7.0</webjars-font-awesome.version>
<checkstyle.version>11.1.0</checkstyle.version> <checkstyle.version>11.1.0</checkstyle.version>
<error-prone.version>2.42.0</error-prone.version>
<jacoco.version>0.8.13</jacoco.version> <jacoco.version>0.8.13</jacoco.version>
<libsass.version>0.3.4</libsass.version> <libsass.version>0.3.4</libsass.version>
<lifecycle-mapping>1.0.0</lifecycle-mapping> <lifecycle-mapping>1.0.0</lifecycle-mapping>
<maven-checkstyle.version>3.6.0</maven-checkstyle.version> <maven-checkstyle.version>3.6.0</maven-checkstyle.version>
<nohttp-checkstyle.version>0.0.11</nohttp-checkstyle.version> <nohttp-checkstyle.version>0.0.11</nohttp-checkstyle.version>
<nullaway.version>0.12.10</nullaway.version>
<spring-format.version>0.0.47</spring-format.version> <spring-format.version>0.0.47</spring-format.version>
</properties> </properties>
<dependencies> <dependencies>
@ -70,6 +72,11 @@
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient</artifactId>
<scope>test</scope>
</dependency>
<!-- Databases - Uses H2 by default --> <!-- Databases - Uses H2 by default -->
<dependency> <dependency>
@ -271,6 +278,38 @@
<failOnUnableToExtractRepoInfo>false</failOnUnableToExtractRepoInfo> <failOnUnableToExtractRepoInfo>false</failOnUnableToExtractRepoInfo>
</configuration> </configuration>
</plugin> </plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<executions>
<execution>
<id>default-compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
<configuration>
<compilerArgs>
<arg>-XDcompilePolicy=simple</arg>
<arg>--should-stop=ifError=FLOW</arg>
<arg>-Xplugin:ErrorProne -XepDisableAllChecks -Xep:NullAway:ERROR -XepOpt:NullAway:OnlyNullMarked=true -XepOpt:NullAway:CustomContractAnnotations=org.springframework.lang.Contract -XepOpt:NullAway:JSpecifyMode=true</arg>
</compilerArgs>
<annotationProcessorPaths>
<path>
<groupId>com.google.errorprone</groupId>
<artifactId>error_prone_core</artifactId>
<version>${error-prone.version}</version>
</path>
<path>
<groupId>com.uber.nullaway</groupId>
<artifactId>nullaway</artifactId>
<version>${nullaway.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</execution>
</executions>
</plugin>
<!-- Spring Boot Actuator displays sbom-related information if a CycloneDX SBOM file is <!-- Spring Boot Actuator displays sbom-related information if a CycloneDX SBOM file is
present at the classpath --> present at the classpath -->
<plugin> <plugin>
@ -288,43 +327,6 @@
</license> </license>
</licenses> </licenses>
<repositories>
<repository>
<snapshots>
<enabled>true</enabled>
</snapshots>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
</repository>
<repository>
<snapshots>
<enabled>false</enabled>
</snapshots>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<snapshots>
<enabled>true</enabled>
</snapshots>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
</pluginRepository>
<pluginRepository>
<snapshots>
<enabled>false</enabled>
</snapshots>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
</pluginRepository>
</pluginRepositories>
<profiles> <profiles>
<profile> <profile>
<id>css</id> <id>css</id>

View file

@ -16,6 +16,8 @@
package org.springframework.samples.petclinic; package org.springframework.samples.petclinic;
import org.jspecify.annotations.Nullable;
import org.springframework.aot.hint.RuntimeHints; import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar; import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.samples.petclinic.model.BaseEntity; import org.springframework.samples.petclinic.model.BaseEntity;
@ -25,7 +27,7 @@ import org.springframework.samples.petclinic.vet.Vet;
public class PetClinicRuntimeHints implements RuntimeHintsRegistrar { public class PetClinicRuntimeHints implements RuntimeHintsRegistrar {
@Override @Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) { public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
hints.resources().registerPattern("db/*"); // https://github.com/spring-projects/spring-boot/issues/32654 hints.resources().registerPattern("db/*"); // https://github.com/spring-projects/spring-boot/issues/32654
hints.resources().registerPattern("messages/*"); hints.resources().registerPattern("messages/*");
hints.resources().registerPattern("mysql-default-conf"); hints.resources().registerPattern("mysql-default-conf");

View file

@ -21,6 +21,7 @@ import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType; import jakarta.persistence.GenerationType;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.MappedSuperclass; import jakarta.persistence.MappedSuperclass;
import org.jspecify.annotations.Nullable;
/** /**
* Simple JavaBean domain object with an id property. Used as a base class for objects * Simple JavaBean domain object with an id property. Used as a base class for objects
@ -34,13 +35,13 @@ public class BaseEntity implements Serializable {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id; private @Nullable Integer id;
public Integer getId() { public @Nullable Integer getId() {
return id; return id;
} }
public void setId(Integer id) { public void setId(@Nullable Integer id) {
this.id = id; this.id = id;
} }

View file

@ -18,6 +18,7 @@ package org.springframework.samples.petclinic.model;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.MappedSuperclass; import jakarta.persistence.MappedSuperclass;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import org.jspecify.annotations.Nullable;
/** /**
* Simple JavaBean domain object adds a name property to <code>BaseEntity</code>. Used as * Simple JavaBean domain object adds a name property to <code>BaseEntity</code>. Used as
@ -32,19 +33,20 @@ public class NamedEntity extends BaseEntity {
@Column(name = "name") @Column(name = "name")
@NotBlank @NotBlank
private String name; private @Nullable String name;
public String getName() { public @Nullable String getName() {
return this.name; return this.name;
} }
public void setName(String name) { public void setName(@Nullable String name) {
this.name = name; this.name = name;
} }
@Override @Override
public String toString() { public String toString() {
return this.getName(); String name = this.getName();
return (name != null) ? name : "<null>";
} }
} }

View file

@ -18,6 +18,7 @@ package org.springframework.samples.petclinic.model;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.MappedSuperclass; import jakarta.persistence.MappedSuperclass;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import org.jspecify.annotations.Nullable;
/** /**
* Simple JavaBean domain object representing an person. * Simple JavaBean domain object representing an person.
@ -29,25 +30,25 @@ public class Person extends BaseEntity {
@Column(name = "first_name") @Column(name = "first_name")
@NotBlank @NotBlank
private String firstName; private @Nullable String firstName;
@Column(name = "last_name") @Column(name = "last_name")
@NotBlank @NotBlank
private String lastName; private @Nullable String lastName;
public String getFirstName() { public @Nullable String getFirstName() {
return this.firstName; return this.firstName;
} }
public void setFirstName(String firstName) { public void setFirstName(@Nullable String firstName) {
this.firstName = firstName; this.firstName = firstName;
} }
public String getLastName() { public @Nullable String getLastName() {
return this.lastName; return this.lastName;
} }
public void setLastName(String lastName) { public void setLastName(@Nullable String lastName) {
this.lastName = lastName; this.lastName = lastName;
} }

View file

@ -17,4 +17,7 @@
/** /**
* The classes in this package represent utilities used by the domain. * The classes in this package represent utilities used by the domain.
*/ */
@NullMarked
package org.springframework.samples.petclinic.model; package org.springframework.samples.petclinic.model;
import org.jspecify.annotations.NullMarked;

View file

@ -17,6 +17,7 @@ package org.springframework.samples.petclinic.owner;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects;
import org.springframework.core.style.ToStringCreator; import org.springframework.core.style.ToStringCreator;
import org.springframework.samples.petclinic.model.Person; import org.springframework.samples.petclinic.model.Person;
@ -32,6 +33,7 @@ import jakarta.persistence.OrderBy;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import org.jspecify.annotations.Nullable;
/** /**
* Simple JavaBean domain object representing an owner. * Simple JavaBean domain object representing an owner.
@ -49,43 +51,43 @@ public class Owner extends Person {
@Column(name = "address") @Column(name = "address")
@NotBlank @NotBlank
private String address; private @Nullable String address;
@Column(name = "city") @Column(name = "city")
@NotBlank @NotBlank
private String city; private @Nullable String city;
@Column(name = "telephone") @Column(name = "telephone")
@NotBlank @NotBlank
@Pattern(regexp = "\\d{10}", message = "{telephone.invalid}") @Pattern(regexp = "\\d{10}", message = "{telephone.invalid}")
private String telephone; private @Nullable String telephone;
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER) @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
@JoinColumn(name = "owner_id") @JoinColumn(name = "owner_id")
@OrderBy("name") @OrderBy("name")
private final List<Pet> pets = new ArrayList<>(); private final List<Pet> pets = new ArrayList<>();
public String getAddress() { public @Nullable String getAddress() {
return this.address; return this.address;
} }
public void setAddress(String address) { public void setAddress(@Nullable String address) {
this.address = address; this.address = address;
} }
public String getCity() { public @Nullable String getCity() {
return this.city; return this.city;
} }
public void setCity(String city) { public void setCity(@Nullable String city) {
this.city = city; this.city = city;
} }
public String getTelephone() { public @Nullable String getTelephone() {
return this.telephone; return this.telephone;
} }
public void setTelephone(String telephone) { public void setTelephone(@Nullable String telephone) {
this.telephone = telephone; this.telephone = telephone;
} }
@ -104,7 +106,7 @@ public class Owner extends Person {
* @param name to test * @param name to test
* @return the Pet with the given name, or null if no such Pet exists for this Owner * @return the Pet with the given name, or null if no such Pet exists for this Owner
*/ */
public Pet getPet(String name) { public @Nullable Pet getPet(String name) {
return getPet(name, false); return getPet(name, false);
} }
@ -113,11 +115,11 @@ public class Owner extends Person {
* @param id to test * @param id to test
* @return the Pet with the given id, or null if no such Pet exists for this Owner * @return the Pet with the given id, or null if no such Pet exists for this Owner
*/ */
public Pet getPet(Integer id) { public @Nullable Pet getPet(Integer id) {
for (Pet pet : getPets()) { for (Pet pet : getPets()) {
if (!pet.isNew()) { if (!pet.isNew()) {
Integer compId = pet.getId(); Integer compId = pet.getId();
if (compId.equals(id)) { if (Objects.equals(compId, id)) {
return pet; return pet;
} }
} }
@ -131,7 +133,7 @@ public class Owner extends Person {
* @param ignoreNew whether to ignore new pets (pets that are not saved yet) * @param ignoreNew whether to ignore new pets (pets that are not saved yet)
* @return the Pet with the given name, or null if no such Pet exists for this Owner * @return the Pet with the given name, or null if no such Pet exists for this Owner
*/ */
public Pet getPet(String name, boolean ignoreNew) { public @Nullable Pet getPet(String name, boolean ignoreNew) {
for (Pet pet : getPets()) { for (Pet pet : getPets()) {
String compName = pet.getName(); String compName = pet.getName();
if (compName != null && compName.equalsIgnoreCase(name)) { if (compName != null && compName.equalsIgnoreCase(name)) {

View file

@ -16,6 +16,7 @@
package org.springframework.samples.petclinic.owner; package org.springframework.samples.petclinic.owner;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@ -34,6 +35,8 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.ModelAndView;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.jspecify.annotations.Nullable;
import org.springframework.web.servlet.mvc.support.RedirectAttributes; import org.springframework.web.servlet.mvc.support.RedirectAttributes;
/** /**
@ -60,7 +63,7 @@ class OwnerController {
} }
@ModelAttribute("owner") @ModelAttribute("owner")
public Owner findOwner(@PathVariable(name = "ownerId", required = false) Integer ownerId) { public Owner findOwner(@PathVariable(name = "ownerId", required = false) @Nullable Integer ownerId) {
return ownerId == null ? new Owner() return ownerId == null ? new Owner()
: this.owners.findById(ownerId) : this.owners.findById(ownerId)
.orElseThrow(() -> new IllegalArgumentException("Owner not found with id: " + ownerId .orElseThrow(() -> new IllegalArgumentException("Owner not found with id: " + ownerId
@ -93,12 +96,13 @@ class OwnerController {
public String processFindForm(@RequestParam(defaultValue = "1") int page, Owner owner, BindingResult result, public String processFindForm(@RequestParam(defaultValue = "1") int page, Owner owner, BindingResult result,
Model model) { Model model) {
// allow parameterless GET request for /owners to return all records // allow parameterless GET request for /owners to return all records
if (owner.getLastName() == null) { String lastName = owner.getLastName();
owner.setLastName(""); // empty string signifies broadest possible search if (lastName == null) {
lastName = ""; // empty string signifies broadest possible search
} }
// find owners by last name // find owners by last name
Page<Owner> ownersResults = findPaginatedForOwnersLastName(page, owner.getLastName()); Page<Owner> ownersResults = findPaginatedForOwnersLastName(page, lastName);
if (ownersResults.isEmpty()) { if (ownersResults.isEmpty()) {
// no owners found // no owners found
result.rejectValue("lastName", "notFound", "not found"); result.rejectValue("lastName", "notFound", "not found");
@ -143,7 +147,7 @@ class OwnerController {
return VIEWS_OWNER_CREATE_OR_UPDATE_FORM; return VIEWS_OWNER_CREATE_OR_UPDATE_FORM;
} }
if (owner.getId() != ownerId) { if (!Objects.equals(owner.getId(), ownerId)) {
result.rejectValue("id", "mismatch", "The owner ID in the form does not match the URL."); result.rejectValue("id", "mismatch", "The owner ID in the form does not match the URL.");
redirectAttributes.addFlashAttribute("error", "Owner ID mismatch. Please try again."); redirectAttributes.addFlashAttribute("error", "Owner ID mismatch. Please try again.");
return "redirect:/owners/{ownerId}/edit"; return "redirect:/owners/{ownerId}/edit";

View file

@ -58,6 +58,6 @@ public interface OwnerRepository extends JpaRepository<Owner, Integer> {
* @throws IllegalArgumentException if the id is null (assuming null is not a valid * @throws IllegalArgumentException if the id is null (assuming null is not a valid
* input for id) * input for id)
*/ */
Optional<Owner> findById(@Nonnull Integer id); Optional<Owner> findById(Integer id);
} }

View file

@ -32,6 +32,7 @@ import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany; import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy; import jakarta.persistence.OrderBy;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import org.jspecify.annotations.Nullable;
/** /**
* Simple business object representing a pet. * Simple business object representing a pet.
@ -47,30 +48,30 @@ public class Pet extends NamedEntity {
@Column(name = "birth_date") @Column(name = "birth_date")
@DateTimeFormat(pattern = "yyyy-MM-dd") @DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate birthDate; private @Nullable LocalDate birthDate;
@ManyToOne @ManyToOne
@JoinColumn(name = "type_id") @JoinColumn(name = "type_id")
private PetType type; private @Nullable PetType type;
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER) @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
@JoinColumn(name = "pet_id") @JoinColumn(name = "pet_id")
@OrderBy("date ASC") @OrderBy("date ASC")
private final Set<Visit> visits = new LinkedHashSet<>(); private final Set<Visit> visits = new LinkedHashSet<>();
public void setBirthDate(LocalDate birthDate) { public void setBirthDate(@Nullable LocalDate birthDate) {
this.birthDate = birthDate; this.birthDate = birthDate;
} }
public LocalDate getBirthDate() { public @Nullable LocalDate getBirthDate() {
return this.birthDate; return this.birthDate;
} }
public PetType getType() { public @Nullable PetType getType() {
return this.type; return this.type;
} }
public void setType(PetType type) { public void setType(@Nullable PetType type) {
this.type = type; this.type = type;
} }

View file

@ -17,10 +17,12 @@ package org.springframework.samples.petclinic.owner;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Collection; import java.util.Collection;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap; import org.springframework.ui.ModelMap;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.validation.BindingResult; import org.springframework.validation.BindingResult;
import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.WebDataBinder;
@ -32,6 +34,8 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.jspecify.annotations.Nullable;
import org.springframework.web.servlet.mvc.support.RedirectAttributes; import org.springframework.web.servlet.mvc.support.RedirectAttributes;
/** /**
@ -69,8 +73,8 @@ class PetController {
} }
@ModelAttribute("pet") @ModelAttribute("pet")
public Pet findPet(@PathVariable("ownerId") int ownerId, public @Nullable Pet findPet(@PathVariable("ownerId") int ownerId,
@PathVariable(name = "petId", required = false) Integer petId) { @PathVariable(name = "petId", required = false) @Nullable Integer petId) {
if (petId == null) { if (petId == null) {
return new Pet(); return new Pet();
@ -135,7 +139,7 @@ class PetController {
// checking if the pet name already exists for the owner // checking if the pet name already exists for the owner
if (StringUtils.hasText(petName)) { if (StringUtils.hasText(petName)) {
Pet existingPet = owner.getPet(petName, false); Pet existingPet = owner.getPet(petName, false);
if (existingPet != null && !existingPet.getId().equals(pet.getId())) { if (existingPet != null && !Objects.equals(existingPet.getId(), pet.getId())) {
result.rejectValue("name", "duplicate", "already exists"); result.rejectValue("name", "duplicate", "already exists");
} }
} }
@ -160,7 +164,9 @@ class PetController {
* @param pet The pet with updated details * @param pet The pet with updated details
*/ */
private void updatePetDetails(Owner owner, Pet pet) { private void updatePetDetails(Owner owner, Pet pet) {
Pet existingPet = owner.getPet(pet.getId()); Integer id = pet.getId();
Assert.state(id != null, "'pet.getId()' must not be null");
Pet existingPet = owner.getPet(id);
if (existingPet != null) { if (existingPet != null) {
// Update existing pet's properties // Update existing pet's properties
existingPet.setName(pet.getName()); existingPet.setName(pet.getName());

View file

@ -21,6 +21,7 @@ import org.springframework.stereotype.Component;
import java.text.ParseException; import java.text.ParseException;
import java.util.Collection; import java.util.Collection;
import java.util.Locale; import java.util.Locale;
import java.util.Objects;
/** /**
* Instructs Spring MVC on how to parse and print elements of type 'PetType'. Starting * Instructs Spring MVC on how to parse and print elements of type 'PetType'. Starting
@ -43,14 +44,15 @@ public class PetTypeFormatter implements Formatter<PetType> {
@Override @Override
public String print(PetType petType, Locale locale) { public String print(PetType petType, Locale locale) {
return petType.getName(); String name = petType.getName();
return (name != null) ? name : "<null>";
} }
@Override @Override
public PetType parse(String text, Locale locale) throws ParseException { public PetType parse(String text, Locale locale) throws ParseException {
Collection<PetType> findPetTypes = this.types.findPetTypes(); Collection<PetType> findPetTypes = this.types.findPetTypes();
for (PetType type : findPetTypes) { for (PetType type : findPetTypes) {
if (type.getName().equals(text)) { if (Objects.equals(type.getName(), text)) {
return type; return type;
} }
} }

View file

@ -24,6 +24,7 @@ import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import org.jspecify.annotations.Nullable;
/** /**
* Simple JavaBean domain object representing a visit. * Simple JavaBean domain object representing a visit.
@ -37,10 +38,10 @@ public class Visit extends BaseEntity {
@Column(name = "visit_date") @Column(name = "visit_date")
@DateTimeFormat(pattern = "yyyy-MM-dd") @DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate date; private @Nullable LocalDate date;
@NotBlank @NotBlank
private String description; private @Nullable String description;
/** /**
* Creates a new instance of Visit for the current date * Creates a new instance of Visit for the current date
@ -49,19 +50,19 @@ public class Visit extends BaseEntity {
this.date = LocalDate.now(); this.date = LocalDate.now();
} }
public LocalDate getDate() { public @Nullable LocalDate getDate() {
return this.date; return this.date;
} }
public void setDate(LocalDate date) { public void setDate(@Nullable LocalDate date) {
this.date = date; this.date = date;
} }
public String getDescription() { public @Nullable String getDescription() {
return this.description; return this.description;
} }
public void setDescription(String description) { public void setDescription(@Nullable String description) {
this.description = description; this.description = description;
} }

View file

@ -67,6 +67,10 @@ class VisitController {
"Owner not found with id: " + ownerId + ". Please ensure the ID is correct ")); "Owner not found with id: " + ownerId + ". Please ensure the ID is correct "));
Pet pet = owner.getPet(petId); Pet pet = owner.getPet(petId);
if (pet == null) {
throw new IllegalArgumentException(
"Pet with id " + petId + " not found for owner with id " + ownerId + ".");
}
model.put("pet", pet); model.put("pet", pet);
model.put("owner", owner); model.put("owner", owner);

View file

@ -0,0 +1,4 @@
@NullMarked
package org.springframework.samples.petclinic.owner;
import org.jspecify.annotations.NullMarked;

View file

@ -0,0 +1,4 @@
@NullMarked
package org.springframework.samples.petclinic;
import org.jspecify.annotations.NullMarked;

View file

@ -16,7 +16,7 @@
package org.springframework.samples.petclinic.system; package org.springframework.samples.petclinic.system;
import org.springframework.boot.autoconfigure.cache.JCacheManagerCustomizer; import org.springframework.boot.cache.autoconfigure.JCacheManagerCustomizer;
import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;

View file

@ -0,0 +1,4 @@
@NullMarked
package org.springframework.samples.petclinic.system;
import org.jspecify.annotations.NullMarked;

View file

@ -31,6 +31,7 @@ import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany; import jakarta.persistence.ManyToMany;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.xml.bind.annotation.XmlElement; import jakarta.xml.bind.annotation.XmlElement;
import org.jspecify.annotations.Nullable;
/** /**
* Simple JavaBean domain object representing a veterinarian. * Simple JavaBean domain object representing a veterinarian.
@ -47,7 +48,7 @@ public class Vet extends Person {
@ManyToMany(fetch = FetchType.EAGER) @ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "vet_specialties", joinColumns = @JoinColumn(name = "vet_id"), @JoinTable(name = "vet_specialties", joinColumns = @JoinColumn(name = "vet_id"),
inverseJoinColumns = @JoinColumn(name = "specialty_id")) inverseJoinColumns = @JoinColumn(name = "specialty_id"))
private Set<Specialty> specialties; private @Nullable Set<Specialty> specialties;
protected Set<Specialty> getSpecialtiesInternal() { protected Set<Specialty> getSpecialtiesInternal() {
if (this.specialties == null) { if (this.specialties == null) {

View file

@ -20,6 +20,7 @@ import java.util.List;
import jakarta.xml.bind.annotation.XmlElement; import jakarta.xml.bind.annotation.XmlElement;
import jakarta.xml.bind.annotation.XmlRootElement; import jakarta.xml.bind.annotation.XmlRootElement;
import org.jspecify.annotations.Nullable;
/** /**
* Simple domain object representing a list of veterinarians. Mostly here to be used for * Simple domain object representing a list of veterinarians. Mostly here to be used for
@ -30,7 +31,7 @@ import jakarta.xml.bind.annotation.XmlRootElement;
@XmlRootElement @XmlRootElement
public class Vets { public class Vets {
private List<Vet> vets; private @Nullable List<Vet> vets;
@XmlElement @XmlElement
public List<Vet> getVetList() { public List<Vet> getVetList() {

View file

@ -0,0 +1,4 @@
@NullMarked
package org.springframework.samples.petclinic.vet;
import org.jspecify.annotations.NullMarked;

View file

@ -21,11 +21,11 @@ import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.DisabledInNativeImage; import org.junit.jupiter.api.condition.DisabledInNativeImage;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.restclient.RestTemplateBuilder;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.boot.web.server.test.LocalServerPort;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.RequestEntity; import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;

View file

@ -21,10 +21,10 @@ import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.restclient.RestTemplateBuilder;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.boot.web.server.test.LocalServerPort;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.RequestEntity; import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;

View file

@ -32,10 +32,10 @@ import org.junit.jupiter.api.condition.DisabledInNativeImage;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.context.event.ApplicationPreparedEvent; import org.springframework.boot.context.event.ApplicationPreparedEvent;
import org.springframework.boot.restclient.RestTemplateBuilder;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.boot.web.server.test.LocalServerPort;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.ApplicationListener; import org.springframework.context.ApplicationListener;
import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.EnumerablePropertySource; import org.springframework.core.env.EnumerablePropertySource;

View file

@ -82,7 +82,7 @@ class ClinicServiceTests {
@Autowired @Autowired
protected VetRepository vets; protected VetRepository vets;
Pageable pageable; private final Pageable pageable = Pageable.unpaged();
@Test @Test
void shouldFindOwnersByLastName() { void shouldFindOwnersByLastName() {

View file

@ -43,7 +43,7 @@ public abstract class EntityUtils {
public static <T extends BaseEntity> T getById(Collection<T> entities, Class<T> entityClass, int entityId) public static <T extends BaseEntity> T getById(Collection<T> entities, Class<T> entityClass, int entityId)
throws ObjectRetrievalFailureException { throws ObjectRetrievalFailureException {
for (T entity : entities) { for (T entity : entities) {
if (entity.getId() == entityId && entityClass.isInstance(entity)) { if (entity.getId() != null && entity.getId() == entityId && entityClass.isInstance(entity)) {
return entity; return entity;
} }
} }

View file

@ -26,11 +26,11 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; import org.springframework.boot.jdbc.autoconfigure.DataSourceTransactionManagerAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.web.server.test.client.TestRestTemplate;
import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;