From 857e92583df14f338e62257bef2c6814ed1facd5 Mon Sep 17 00:00:00 2001 From: "robin.bygrave" Date: Thu, 16 Jul 2026 23:15:15 +1200 Subject: [PATCH] enh: Add findOneOrThrow() with decent derived message for EntityNotFoundException Adding this as I am seeing a LOT of cases where ebean can derive a good enough error message (id case, plus single unique key case) that it is worth adding this syntax sugar. Yes we have discussed this a well it's not so useful for the multi-lingual / non-english case and also marginal when the error message needs to be built case. --- .../src/main/java/io/ebean/DtoQuery.java | 18 +++++ .../main/java/io/ebean/ExpressionList.java | 21 ++++++ .../src/main/java/io/ebean/MappedQuery.java | 22 ++++++ .../src/main/java/io/ebean/QueryBuilder.java | 33 +++++++++ .../src/main/java/io/ebean/SqlQuery.java | 34 +++++++++ .../expression/DefaultExpressionList.java | 18 +++++ .../server/querydefn/DefaultMappedQuery.java | 5 ++ .../server/querydefn/DefaultOrmQuery.java | 25 +++++++ .../org/tests/query/TestFindOneOrThrow.java | 70 +++++++++++++++++++ .../org/tests/dtomapping/TestQueryMapTo.java | 24 +++++++ 10 files changed, 270 insertions(+) create mode 100644 ebean-test/src/test/java/org/tests/query/TestFindOneOrThrow.java diff --git a/ebean-api/src/main/java/io/ebean/DtoQuery.java b/ebean-api/src/main/java/io/ebean/DtoQuery.java index 679d2f0a4..7ae1c2485 100644 --- a/ebean-api/src/main/java/io/ebean/DtoQuery.java +++ b/ebean-api/src/main/java/io/ebean/DtoQuery.java @@ -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 extends CancelableQuery { */ Optional 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 exceptionSupplier) { + return findOneOrEmpty().orElseThrow(exceptionSupplier); + } + /** * Bind all the parameters using index positions. *

diff --git a/ebean-api/src/main/java/io/ebean/ExpressionList.java b/ebean-api/src/main/java/io/ebean/ExpressionList.java index 743296906..e02140de4 100644 --- a/ebean-api/src/main/java/io/ebean/ExpressionList.java +++ b/ebean-api/src/main/java/io/ebean/ExpressionList.java @@ -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 { */ Optional 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 exceptionSupplier) { + return query().findOneOrThrow(exceptionSupplier); + } + /** * Execute find row count query in a background thread. *

diff --git a/ebean-api/src/main/java/io/ebean/MappedQuery.java b/ebean-api/src/main/java/io/ebean/MappedQuery.java index e09993cb0..0b053a296 100644 --- a/ebean-api/src/main/java/io/ebean/MappedQuery.java +++ b/ebean-api/src/main/java/io/ebean/MappedQuery.java @@ -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 { */ Optional findOneOrEmpty(); + /** + * Execute the query returning a single mapped DTO or throwing a + * {@link jakarta.persistence.EntityNotFoundException} if there is no matching row. + *

+ * 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 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. diff --git a/ebean-api/src/main/java/io/ebean/QueryBuilder.java b/ebean-api/src/main/java/io/ebean/QueryBuilder.java index f7f3d3a49..34628bf21 100644 --- a/ebean-api/src/main/java/io/ebean/QueryBuilder.java +++ b/ebean-api/src/main/java/io/ebean/QueryBuilder.java @@ -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, T> extends Que */ Optional findOneOrEmpty(); + /** + * Execute the query returning a single bean or throwing a {@link jakarta.persistence.EntityNotFoundException} + * if there is no matching bean. + *

+ * This is a convenience alternative to: + *

{@code
+   *   query.findOneOrEmpty()
+   *     .orElseThrow(() -> new EntityNotFoundException(...));
+   * }
+ *

+ * 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. + *

{@code
+   *   Customer customer = query
+   *     .findOneOrThrow(() -> new NotFoundException("Customer not found for id: " + id));
+   * }
+ */ + default T findOneOrThrow(Supplier exceptionSupplier) { + return findOneOrEmpty().orElseThrow(exceptionSupplier); + } + /** * Execute the query returning the list of objects. *

diff --git a/ebean-api/src/main/java/io/ebean/SqlQuery.java b/ebean-api/src/main/java/io/ebean/SqlQuery.java index 0eee73e5b..5056f23ed 100644 --- a/ebean-api/src/main/java/io/ebean/SqlQuery.java +++ b/ebean-api/src/main/java/io/ebean/SqlQuery.java @@ -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 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 exceptionSupplier) { + return findOneOrEmpty().orElseThrow(exceptionSupplier); + } + /** * Set one of more positioned parameters. *

@@ -391,6 +409,22 @@ public interface SqlQuery extends Serializable, CancelableQuery { */ Optional 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 exceptionSupplier) { + return findOneOrEmpty().orElseThrow(exceptionSupplier); + } + /** * Return the list of values. */ diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/expression/DefaultExpressionList.java b/ebean-core/src/main/java/io/ebeaninternal/server/expression/DefaultExpressionList.java index 9f156dcca..0eb99b67e 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/expression/DefaultExpressionList.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/expression/DefaultExpressionList.java @@ -1335,6 +1335,24 @@ public class DefaultExpressionList implements SpiExpressionList { return null; } + /** + * Return a "propertyName: value" description when this expression list is a single + * simple equality predicate (a candidate natural/unique key), otherwise null. + *

+ * 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 clear() { list.clear(); diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/DefaultMappedQuery.java b/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/DefaultMappedQuery.java index 45978970a..0d7ddbfa7 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/DefaultMappedQuery.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/DefaultMappedQuery.java @@ -90,6 +90,11 @@ public final class DefaultMappedQuery implements MappedQuery { return Optional.ofNullable(findOne()); } + @Override + public D findOneOrThrow() { + return mapper().map(query.findOneOrThrow()); + } + @Override public PagedList findPagedList() { DtoMapper m = mapper(); diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/DefaultOrmQuery.java b/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/DefaultOrmQuery.java index ebf016b82..32e31a466 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/DefaultOrmQuery.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/DefaultOrmQuery.java @@ -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 extends AbstractQuery implements SpiQuery { 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 findFutureIds() { return server.findFutureIds(this); diff --git a/ebean-test/src/test/java/org/tests/query/TestFindOneOrThrow.java b/ebean-test/src/test/java/org/tests/query/TestFindOneOrThrow.java new file mode 100644 index 000000000..a9bf3d5c2 --- /dev/null +++ b/ebean-test/src/test/java/org/tests/query/TestFindOneOrThrow.java @@ -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"); + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestQueryMapTo.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestQueryMapTo.java index 5cc0b54cc..d85174e41 100644 --- a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestQueryMapTo.java +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestQueryMapTo.java @@ -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())