Merge pull request #3859 from ebean-orm/feature/findOneOrThrow

enh: Add findOneOrThrow() with decent derived message for EntityNotFo…
This commit is contained in:
Rob Bygrave
2026-07-16 23:28:12 +12:00
committed by GitHub
10 changed files with 270 additions and 0 deletions
@@ -3,6 +3,7 @@ package io.ebean;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import jakarta.persistence.EntityNotFoundException;
import javax.sql.DataSource;
import java.sql.Connection;
import java.util.Collection;
@@ -10,6 +11,7 @@ import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.stream.Stream;
/**
@@ -109,6 +111,22 @@ public interface DtoQuery<T> extends CancelableQuery {
*/
Optional<T> findOneOrEmpty();
/**
* Execute the query returning a single bean or throwing a
* {@link jakarta.persistence.EntityNotFoundException} if there is no matching row.
*/
default T findOneOrThrow() {
return findOneOrEmpty().orElseThrow(() -> new EntityNotFoundException("Not found"));
}
/**
* Execute the query returning a single bean or throwing the exception produced
* by the given supplier if there is no matching row.
*/
default T findOneOrThrow(Supplier<? extends RuntimeException> exceptionSupplier) {
return findOneOrEmpty().orElseThrow(exceptionSupplier);
}
/**
* Bind all the parameters using index positions.
* <p>
@@ -10,6 +10,7 @@ import java.sql.Timestamp;
import java.util.*;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.function.Supplier;
/**
* List of Expressions that make up a where or having clause.
@@ -421,6 +422,26 @@ public interface ExpressionList<T> {
*/
Optional<T> findOneOrEmpty();
/**
* Execute the query returning a single bean or throwing a {@link jakarta.persistence.EntityNotFoundException}
* if there is no matching bean.
*
* @see Query#findOneOrThrow()
*/
default T findOneOrThrow() {
return query().findOneOrThrow();
}
/**
* Execute the query returning a single bean or throwing the exception produced by the
* given supplier if there is no matching bean.
*
* @see Query#findOneOrThrow(Supplier)
*/
default T findOneOrThrow(Supplier<? extends RuntimeException> exceptionSupplier) {
return query().findOneOrThrow(exceptionSupplier);
}
/**
* Execute find row count query in a background thread.
* <p>
@@ -3,9 +3,11 @@ package io.ebean;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import jakarta.persistence.EntityNotFoundException;
import java.sql.Connection;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
import java.util.stream.Stream;
/**
@@ -73,6 +75,26 @@ public interface MappedQuery<D> {
*/
Optional<D> findOneOrEmpty();
/**
* Execute the query returning a single mapped DTO or throwing a
* {@link jakarta.persistence.EntityNotFoundException} if there is no matching row.
* <p>
* The exception message reflects the underlying entity type and its id or single
* equality predicate (a likely natural/unique key) when the query is that simple,
* otherwise a generic "not found" message.
*/
default D findOneOrThrow() {
return findOneOrEmpty().orElseThrow(() -> new EntityNotFoundException("Not found"));
}
/**
* Execute the query returning a single mapped DTO or throwing the exception produced
* by the given supplier if there is no matching row.
*/
default D findOneOrThrow(Supplier<? extends RuntimeException> exceptionSupplier) {
return findOneOrEmpty().orElseThrow(exceptionSupplier);
}
/**
* Ensure the master DataSource is used when useMaster is true. Otherwise, the read only
* data source can be used if defined.
@@ -2,6 +2,7 @@ package io.ebean;
import org.jspecify.annotations.Nullable;
import jakarta.persistence.EntityNotFoundException;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.Timestamp;
@@ -12,6 +13,7 @@ import java.util.Set;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.stream.Stream;
/**
@@ -723,6 +725,37 @@ public interface QueryBuilder<SELF extends QueryBuilder<SELF, T>, T> extends Que
*/
Optional<T> findOneOrEmpty();
/**
* Execute the query returning a single bean or throwing a {@link jakarta.persistence.EntityNotFoundException}
* if there is no matching bean.
* <p>
* This is a convenience alternative to:
* <pre>{@code
* query.findOneOrEmpty()
* .orElseThrow(() -> new EntityNotFoundException(...));
* }</pre>
* <p>
* The exception message is a best effort - it uses the id when this is effectively a
* find-by-id query, or the single equality predicate when the query is filtered by what
* looks like a natural/unique key, otherwise a generic "not found" message.
*/
default T findOneOrThrow() {
return findOneOrEmpty().orElseThrow(() ->
new EntityNotFoundException(getBeanType().getSimpleName() + " not found"));
}
/**
* Execute the query returning a single bean or throwing the exception produced by the
* given supplier if there is no matching bean.
* <pre>{@code
* Customer customer = query
* .findOneOrThrow(() -> new NotFoundException("Customer not found for id: " + id));
* }</pre>
*/
default T findOneOrThrow(Supplier<? extends RuntimeException> exceptionSupplier) {
return findOneOrEmpty().orElseThrow(exceptionSupplier);
}
/**
* Execute the query returning the list of objects.
* <p>
@@ -3,6 +3,7 @@ package io.ebean;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import jakarta.persistence.EntityNotFoundException;
import javax.sql.DataSource;
import java.io.Serializable;
import java.sql.Connection;
@@ -11,6 +12,7 @@ import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.function.Supplier;
/**
* Query object for performing native SQL queries that return SqlRow or directly read
@@ -144,6 +146,22 @@ public interface SqlQuery extends Serializable, CancelableQuery {
*/
Optional<SqlRow> findOneOrEmpty();
/**
* Execute the query returning a single row or throwing a
* {@link jakarta.persistence.EntityNotFoundException} if there is no matching row.
*/
default SqlRow findOneOrThrow() {
return findOneOrEmpty().orElseThrow(() -> new EntityNotFoundException("Not found"));
}
/**
* Execute the query returning a single row or throwing the exception produced
* by the given supplier if there is no matching row.
*/
default SqlRow findOneOrThrow(Supplier<? extends RuntimeException> exceptionSupplier) {
return findOneOrEmpty().orElseThrow(exceptionSupplier);
}
/**
* Set one of more positioned parameters.
* <p>
@@ -391,6 +409,22 @@ public interface SqlQuery extends Serializable, CancelableQuery {
*/
Optional<T> findOneOrEmpty();
/**
* Return the single value or throw a {@link jakarta.persistence.EntityNotFoundException}
* if there is no matching row.
*/
default T findOneOrThrow() {
return findOneOrEmpty().orElseThrow(() -> new EntityNotFoundException("Not found"));
}
/**
* Return the single value or throw the exception produced by the given supplier
* if there is no matching row.
*/
default T findOneOrThrow(Supplier<? extends RuntimeException> exceptionSupplier) {
return findOneOrEmpty().orElseThrow(exceptionSupplier);
}
/**
* Return the list of values.
*/
@@ -1335,6 +1335,24 @@ public class DefaultExpressionList<T> implements SpiExpressionList<T> {
return null;
}
/**
* Return a "propertyName: value" description when this expression list is a single
* simple equality predicate (a candidate natural/unique key), otherwise null.
* <p>
* Used to build a decent default message for {@code findOneOrThrow()} when the query
* isn't a simple find-by-id.
*/
@Nullable
public String singleEqDescription() {
if (list.size() == 1 && list.get(0) instanceof SimpleExpression) {
SimpleExpression simple = (SimpleExpression) list.get(0);
if (simple.isOpEquals()) {
return simple.getPropName() + ": " + simple.getValue();
}
}
return null;
}
@Override
public ExpressionList<T> clear() {
list.clear();
@@ -90,6 +90,11 @@ public final class DefaultMappedQuery<T, D> implements MappedQuery<D> {
return Optional.ofNullable(findOne());
}
@Override
public D findOneOrThrow() {
return mapper().map(query.findOneOrThrow());
}
@Override
public PagedList<D> findPagedList() {
DtoMapper<T, D> m = mapper();
@@ -23,6 +23,7 @@ import io.ebeaninternal.server.query.NativeSqlQueryPlanKey;
import io.ebeaninternal.server.rawsql.SpiRawSql;
import io.ebeaninternal.server.transaction.ExternalJdbcTransaction;
import jakarta.persistence.EntityNotFoundException;
import jakarta.persistence.PersistenceException;
import java.sql.Connection;
import java.sql.Timestamp;
@@ -1656,6 +1657,30 @@ public class DefaultOrmQuery<T> extends AbstractQuery implements SpiQuery<T> {
return server.findOneOrEmpty(this);
}
@Override
public final T findOneOrThrow() {
return findOneOrEmpty().orElseThrow(() -> new EntityNotFoundException(notFoundMessage()));
}
/**
* Build a decent default "not found" message using the id (if this is effectively a
* find-by-id query) or, failing that, a single simple equality predicate (a likely
* natural/unique key). Falls back to a generic message for anything more complex.
*/
private String notFoundMessage() {
String type = beanDescriptor.type().getSimpleName();
if (isFindById()) {
return type + " not found for id: " + id;
}
if (whereExpressions != null) {
String desc = whereExpressions.singleEqDescription();
if (desc != null) {
return type + " not found for " + desc;
}
}
return type + " not found";
}
@Override
public final FutureIds<T> findFutureIds() {
return server.findFutureIds(this);
@@ -0,0 +1,70 @@
package org.tests.query;
import io.ebean.DB;
import io.ebean.xtest.BaseTestCase;
import jakarta.persistence.EntityNotFoundException;
import org.junit.jupiter.api.Test;
import org.tests.model.basic.Customer;
import org.tests.model.basic.ResetBasicData;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Tests for {@code findOneOrThrow()} - a convenience alternative to
* {@code findOneOrEmpty().orElseThrow(...)} that produces a decent default message
* when the query is effectively a find-by-id or single natural/unique key lookup.
*/
class TestFindOneOrThrow extends BaseTestCase {
@Test
void findOneOrThrow_whenFound_returnsBean() {
ResetBasicData.reset();
Customer existing = DB.find(Customer.class).setMaxRows(1).findList().get(0);
Customer found = DB.find(Customer.class).setId(existing.getId()).findOneOrThrow();
assertThat(found.getId()).isEqualTo(existing.getId());
}
@Test
void findOneOrThrow_whenNotFoundById_messageIncludesId() {
ResetBasicData.reset();
assertThatThrownBy(() -> DB.find(Customer.class).setId(999999).findOneOrThrow())
.isInstanceOf(EntityNotFoundException.class)
.hasMessage("Customer not found for id: 999999");
}
@Test
void findOneOrThrow_whenNotFoundBySingleEqPredicate_messageIncludesPredicate() {
ResetBasicData.reset();
assertThatThrownBy(() -> DB.find(Customer.class)
.where().eq("name", "NonExistentCustomerXYZ")
.findOneOrThrow())
.isInstanceOf(EntityNotFoundException.class)
.hasMessage("Customer not found for name: NonExistentCustomerXYZ");
}
@Test
void findOneOrThrow_whenNotFoundByMultiplePredicates_fallsBackToGenericMessage() {
ResetBasicData.reset();
assertThatThrownBy(() -> DB.find(Customer.class)
.where().eq("name", "NonExistentCustomerXYZ").eq("id", 999999)
.findOneOrThrow())
.isInstanceOf(EntityNotFoundException.class)
.hasMessage("Customer not found");
}
@Test
void findOneOrThrow_withSupplier_usesSuppliedException() {
ResetBasicData.reset();
assertThatThrownBy(() -> DB.find(Customer.class).setId(999999)
.findOneOrThrow(() -> new IllegalStateException("custom message")))
.isInstanceOf(IllegalStateException.class)
.hasMessage("custom message");
}
}
@@ -5,6 +5,7 @@ import io.ebean.LazyInitialisationException;
import io.ebean.PagedList;
import io.ebean.Transaction;
import io.ebean.test.LoggedSql;
import jakarta.persistence.EntityNotFoundException;
import jakarta.persistence.PersistenceException;
import org.junit.jupiter.api.Test;
import org.tests.dtomapping.model.Address;
@@ -83,6 +84,29 @@ class TestQueryMapTo {
assertThat(dto).isEmpty();
}
@Test
void mapTo_findOneOrThrow_whenFound_expectPopulatedDto() {
Customer customer = new Customer("Gamma");
customer.save();
CustomerDto dto = DB.find(Customer.class)
.where().idEq(customer.getId())
.mapTo(CustomerDto.class)
.findOneOrThrow();
assertThat(dto.getName()).isEqualTo("Gamma");
}
@Test
void mapTo_findOneOrThrow_whenNoMatch_expectEntityNotFoundExceptionWithEntityTypeAndId() {
assertThatThrownBy(() -> DB.find(Customer.class)
.setId(-1L)
.mapTo(CustomerDto.class)
.findOneOrThrow())
.isInstanceOf(EntityNotFoundException.class)
.hasMessage("Customer not found for id: -1");
}
@Test
void mapTo_whenPairNotRegistered_expectPersistenceException() {
assertThatThrownBy(() -> DB.find(Contact.class).mapTo(CustomerDto.class).findList())