Add NullAway and JSpecify annotations

See gh-2016

Signed-off-by: Moritz Halbritter <moritz.halbritter@broadcom.com>
This commit is contained in:
Moritz Halbritter 2025-09-02 13:12:56 +02:00 committed by Stéphane Nicoll
parent 3aa79e3944
commit a0aea8e8f8
26 changed files with 171 additions and 59 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

@ -7,6 +7,7 @@ plugins {
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" ]
@ -16,7 +17,7 @@ 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.41.0"
ext.nullAwayVersion = "0.12.9"
dependencies { dependencies {
implementation 'org.springframework.boot:spring-boot-starter-cache' implementation 'org.springframework.boot:spring-boot-starter-cache'
@ -55,6 +58,8 @@ dependencies {
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') {
@ -71,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") }

37
pom.xml
View file

@ -18,7 +18,8 @@
<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=... -->
@ -37,6 +38,8 @@
<nohttp-checkstyle.version>0.0.11</nohttp-checkstyle.version> <nohttp-checkstyle.version>0.0.11</nohttp-checkstyle.version>
<spring-format.version>0.0.47</spring-format.version> <spring-format.version>0.0.47</spring-format.version>
<error-prone.version>2.41.0</error-prone.version>
<nullaway.version>0.12.9</nullaway.version>
</properties> </properties>
<dependencies> <dependencies>
@ -275,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>

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

@ -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

@ -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;
} }
} }