diff --git a/ebean-api/src/main/java/io/ebean/QueryBuilderProjection.java b/ebean-api/src/main/java/io/ebean/QueryBuilderProjection.java index 31c3e50218..54ff946571 100644 --- a/ebean-api/src/main/java/io/ebean/QueryBuilderProjection.java +++ b/ebean-api/src/main/java/io/ebean/QueryBuilderProjection.java @@ -40,6 +40,13 @@ public interface QueryBuilderProjection { */ SELF select(String fetchProperties); + /** + * Set DISTINCT ON clause. This is a Postgres only SQL feature. + * + * @param distinctOn The properties to include in the DISTINCT ON clause. + */ + SELF distinctOn(String distinctOn); + /** * Apply the fetchGroup which defines what part of the object graph to load. */ diff --git a/ebean-core/src/main/java/io/ebeaninternal/api/SpiQuery.java b/ebean-core/src/main/java/io/ebeaninternal/api/SpiQuery.java index a4696508df..317b66dce6 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/api/SpiQuery.java +++ b/ebean-core/src/main/java/io/ebeaninternal/api/SpiQuery.java @@ -439,6 +439,11 @@ public static TemporalMode of(SpiQuery query) { @Override SpiQuery copy(); + /** + * Return the distinct on clause. + */ + String distinctOn(); + /** * Return a copy of the query attaching to a different EbeanServer. */ @@ -549,7 +554,6 @@ public static TemporalMode of(SpiQuery query) { * Return true if the query should include the Id property. *

* distinct and single attribute queries exclude the Id property. - *

*/ boolean isWithId(); diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/expression/SubQueryExpression.java b/ebean-core/src/main/java/io/ebeaninternal/server/expression/SubQueryExpression.java index 70813b25df..3a2d506ecb 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/expression/SubQueryExpression.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/expression/SubQueryExpression.java @@ -75,7 +75,7 @@ public void queryBindKey(BindValuesKey key) { @Override public void addSql(SpiExpressionRequest request) { - request.property(propName).append(op.expression).append('(').parse(sql).append(')'); + request.property(propName).append(op.expression).append('(').append(sql).append(')'); } @Override diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryBuilder.java b/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryBuilder.java index 93bb5135e7..093d6933c6 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryBuilder.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryBuilder.java @@ -3,7 +3,6 @@ import io.ebean.*; import io.ebean.annotation.Platform; import io.ebean.config.dbplatform.DatabasePlatform; -import io.ebean.config.dbplatform.SqlLimitRequest; import io.ebean.config.dbplatform.SqlLimitResponse; import io.ebean.config.dbplatform.SqlLimiter; import io.ebean.event.readaudit.ReadAuditQueryPlan; @@ -552,7 +551,7 @@ private BuildReq(String selectClause, OrmQueryRequest request, CQueryPredicat this.countSingleAttribute = query.isCountDistinct() && query.isSingleAttribute(); this.useSqlLimiter = selectClause == null && query.hasMaxRowsOrFirstRow() - && (select.manyProperty() == null || query.isSingleAttribute()); + && (select.distinctOn() != null || select.manyProperty() == null || query.isSingleAttribute()); } private void appendSelect() { @@ -583,7 +582,7 @@ private void appendSelect() { if (request.isInlineCountDistinct()) { sb.append(')'); } - if (distinct && dbOrderBy != null) { + if (distinct && dbOrderBy != null && query.distinctOn() == null) { // add the orderBy columns to the select clause (due to distinct) String[] tokens = DbOrderByTrim.trim(dbOrderBy).split(","); for (String token : tokens) { diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryPredicates.java b/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryPredicates.java index 376dc9988f..2f4823f196 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryPredicates.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryPredicates.java @@ -62,6 +62,7 @@ public final class CQueryPredicates { */ private String dbFilterMany; private String dbOrderBy; + private String dbDistinctOn; private String dbUpdateClause; /** * Includes from where and order by clauses. @@ -209,6 +210,10 @@ public void prepare(boolean buildSql) { } } if (buildSql) { + final String distinctOn = query.distinctOn(); + if (distinctOn != null) { + dbDistinctOn = deployParser.parse(distinctOn); + } predicateIncludes = deployParser.includes(); } } @@ -231,6 +236,9 @@ void parseTableAlias(SqlTreeAlias alias) { if (dbOrderBy != null) { dbOrderBy = alias.parse(dbOrderBy); } + if (dbDistinctOn != null) { + dbDistinctOn = alias.parse(dbDistinctOn); + } } private String parseOrderBy() { @@ -351,6 +359,13 @@ String dbOrderBy() { return dbOrderBy; } + /** + * Return the db distinct on clause. + */ + String dbDistinctOn() { + return dbDistinctOn; + } + /** * Return the includes required for the where and order by clause. */ diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/query/DefaultFetchGroupQuery.java b/ebean-core/src/main/java/io/ebeaninternal/server/query/DefaultFetchGroupQuery.java index aca22b8bc4..9f3278d1d8 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/query/DefaultFetchGroupQuery.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/query/DefaultFetchGroupQuery.java @@ -59,6 +59,11 @@ public FetchGroup buildFetchGroup() { return new DFetchGroup<>(detail); } + @Override + public Query distinctOn(String distinctOn) { + throw new UnsupportedOperationException(); + } + @Override public Query select(String columns) { detail.select(columns); diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeBuilder.java b/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeBuilder.java index 25ca77d878..54fbe19693 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeBuilder.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeBuilder.java @@ -156,6 +156,10 @@ private String buildGroupByClause() { } private String buildDistinctOn() { + String distinctOn = predicates.dbDistinctOn(); + if (distinctOn != null) { + return distinctOn; + } if (rawSql || !distinctOnPlatform || !sqlDistinct || Type.COUNT == query.type()) { return null; } 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 abe89c40a5..5bb3b60279 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 @@ -72,6 +72,7 @@ public class DefaultOrmQuery extends AbstractQuery implements SpiQuery { * Lazy loading batch size (can override server wide default). */ private int lazyLoadBatchSize; + private String distinctOn; private OrderBy orderBy; private String loadMode; private String loadDescription; @@ -515,7 +516,10 @@ public final SpiQuerySecondary convertJoins() { * Limit the number of fetch joins to Many properties, mark as query joins as needed. */ private void markQueryJoins() { - detail.markQueryJoins(beanDescriptor, lazyLoadManyPath, isAllowOneManyFetch(), type.defaultSelect()); + if (distinctOn == null) { + // no automatic join to query join conversion when distinctOn is used + detail.markQueryJoins(beanDescriptor, lazyLoadManyPath, isAllowOneManyFetch(), type.defaultSelect()); + } } private boolean isAllowOneManyFetch() { @@ -639,12 +643,11 @@ public final CountDistinctOrder countDistinctOrder() { return countDistinctOrder; } - /** - * Return true if the Id should be included in the query. - */ @Override public final boolean isWithId() { - return !manualId && !distinct && !singleAttribute; + // distinctOn orm query will auto include the id property + // distinctOn dto query does NOT (via setting manualId to true) + return !manualId && !singleAttribute && (!distinct || distinctOn != null); } @Override @@ -740,6 +743,7 @@ public SpiQuery copy(SpiEbeanServer server) { copy.baseTable = baseTable; copy.rootTableAlias = rootTableAlias; copy.distinct = distinct; + copy.distinctOn = distinctOn; copy.allowLoadErrors = allowLoadErrors; copy.timeout = timeout; copy.mapKey = mapKey; @@ -799,6 +803,11 @@ public final void setType(Type type) { this.type = type; } + @Override + public String distinctOn() { + return distinctOn; + } + @Override public final String loadDescription() { return loadDescription; @@ -1087,6 +1096,9 @@ private String planDescription() { } if (distinct) { sb.append("/dt"); + if (distinctOn != null) { + sb.append("/o:").append(distinctOn); + } } if (allowLoadErrors) { sb.append("/ae"); @@ -1340,6 +1352,13 @@ public final void addNested(String name, OrmQueryDetail nestedDetail, FetchConfi detail.addNested(name, nestedDetail, config); } + @Override + public final Query distinctOn(String distinctOn) { + this.distinctOn = distinctOn; + this.distinct = true; + return this; + } + @Override public final Query select(String columns) { detail.select(columns); diff --git a/ebean-core/src/test/java/io/ebeaninternal/server/querydefn/DefaultOrmQueryTest.java b/ebean-core/src/test/java/io/ebeaninternal/server/querydefn/DefaultOrmQueryTest.java index cd61df7db7..70f557c3da 100644 --- a/ebean-core/src/test/java/io/ebeaninternal/server/querydefn/DefaultOrmQueryTest.java +++ b/ebean-core/src/test/java/io/ebeaninternal/server/querydefn/DefaultOrmQueryTest.java @@ -3,6 +3,7 @@ import io.ebean.*; import io.ebeaninternal.api.BindValuesKey; +import io.ebeaninternal.api.SpiQuery; import io.ebeaninternal.server.core.OrmQueryRequest; import io.ebeaninternal.server.core.OrmQueryRequestTestHelper; import io.ebeaninternal.server.expression.BaseExpressionTest; @@ -54,6 +55,32 @@ public void checkForId_when_setId_ok() { assertThat(q1.getId()).isEqualTo(42); } + @Test + void when_distinctOn_then_planChanges() { + DefaultOrmQuery q1 = (DefaultOrmQuery) DB.find(Order.class).distinctOn("name"); + DefaultOrmQuery q2 = (DefaultOrmQuery) DB.find(Order.class); + + prepare(q1, q2); + assertThat(q1.createQueryPlanKey()).isNotEqualTo(q2.createQueryPlanKey()); + } + + @Test + void when_distinctOn_match() { + DefaultOrmQuery q1 = (DefaultOrmQuery) DB.find(Order.class).distinctOn("name"); + DefaultOrmQuery q2 = (DefaultOrmQuery) DB.find(Order.class).distinctOn("name"); + + prepare(q1, q2); + assertThat(q1.createQueryPlanKey()).isEqualTo(q2.createQueryPlanKey()); + assertThat(bindKey(q1)).isEqualTo(bindKey(q2)); + } + + @Test + void when_distinctOn_copy() { + DefaultOrmQuery q1 = (DefaultOrmQuery) DB.find(Order.class).distinctOn("name"); + SpiQuery copy = q1.copy(); + assertThat(copy.distinctOn()).isEqualTo("name"); + } + @Test public void when_addWhere_then_planChanges() { diff --git a/ebean-querybean/src/main/java/io/ebean/typequery/IQueryBean.java b/ebean-querybean/src/main/java/io/ebean/typequery/IQueryBean.java index a2d6fa0943..3fff7c44bf 100644 --- a/ebean-querybean/src/main/java/io/ebean/typequery/IQueryBean.java +++ b/ebean-querybean/src/main/java/io/ebean/typequery/IQueryBean.java @@ -59,6 +59,13 @@ public interface IQueryBean extends QueryBuilder { */ FetchGroup buildFetchGroup(); + /** + * Set DISTINCT ON properties. This is a Postgres only SQL feature. + * + * @param properties The properties to include in the DISTINCT ON clause. + */ + R distinctOn(TQProperty... properties); + /** * Specify the properties to be loaded on the 'main' root level entity bean. *

diff --git a/ebean-querybean/src/main/java/io/ebean/typequery/QueryBean.java b/ebean-querybean/src/main/java/io/ebean/typequery/QueryBean.java index ee0f74ea28..9c3ddbf6c5 100644 --- a/ebean-querybean/src/main/java/io/ebean/typequery/QueryBean.java +++ b/ebean-querybean/src/main/java/io/ebean/typequery/QueryBean.java @@ -159,6 +159,12 @@ public Query query() { return query; } + @Override + public R distinctOn(String distinctOn) { + query.distinctOn(distinctOn); + return root; + } + @Override public R select(String properties) { query.select(properties); @@ -171,6 +177,17 @@ public R select(FetchGroup fetchGroup) { return root; } + @Override + @SafeVarargs + public final R distinctOn(TQProperty... properties) { + final var joiner = new StringJoiner(", "); + for (Query.Property property : properties) { + joiner.add(property.toString()); + } + distinctOn(joiner.toString()); + return root; + } + @Override @SafeVarargs public final R select(TQProperty... properties) { diff --git a/ebean-querybean/src/test/java/org/querytest/QCustomerTest.java b/ebean-querybean/src/test/java/org/querytest/QCustomerTest.java index 424c745d09..03897f585d 100644 --- a/ebean-querybean/src/test/java/org/querytest/QCustomerTest.java +++ b/ebean-querybean/src/test/java/org/querytest/QCustomerTest.java @@ -4,6 +4,7 @@ import io.ebean.annotation.Transactional; import io.ebean.test.LoggedSql; import io.ebean.types.Inet; +import io.ebeaninternal.api.SpiQuery; import org.example.domain.Address; import org.example.domain.Country; import org.example.domain.Customer; @@ -226,6 +227,22 @@ public void isEmpty() { .findList(); } + @Test + public void distinctOn() { + var c = QContact.alias(); + var q = new QContact() + .distinctOn(c.customer) + .select(c.lastName, c.whenCreated) + .orderBy() + .customer.id.asc() + .whenCreated.desc() + .query(); + + SpiQuery spiQuery = (SpiQuery) q; + assertThat(spiQuery.distinctOn()).isEqualTo("customer"); + assertThat(spiQuery.isDistinct()).isTrue(); + } + @Transactional @Test public void forUpdate() { diff --git a/ebean-test/src/test/java/org/tests/query/TestDistinctOnQuery.java b/ebean-test/src/test/java/org/tests/query/TestDistinctOnQuery.java new file mode 100644 index 0000000000..a288087e48 --- /dev/null +++ b/ebean-test/src/test/java/org/tests/query/TestDistinctOnQuery.java @@ -0,0 +1,187 @@ +package org.tests.query; + +import io.ebean.DB; +import io.ebean.Query; +import io.ebean.annotation.Platform; +import io.ebean.test.LoggedSql; +import io.ebean.xtest.BaseTestCase; +import io.ebean.xtest.ForPlatform; +import org.junit.jupiter.api.Test; +import org.tests.model.basic.Contact; +import org.tests.model.basic.Customer; +import org.tests.model.basic.OrderDetail; +import org.tests.model.basic.ResetBasicData; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class TestDistinctOnQuery extends BaseTestCase { + + @ForPlatform({Platform.POSTGRES, Platform.YUGABYTE}) + @Test + void distinctOn() { + ResetBasicData.reset(); + + LoggedSql.start(); + + List customers = DB.find(Customer.class) + .distinctOn("id") + .select("name") + .fetch("contacts", "firstName, lastName") + .orderBy("id, contacts.lastName") + .setMaxRows(10) + .findList(); + + for (Customer customer : customers) { + List contacts = customer.getContacts(); + assertThat(contacts.size()).isEqualTo(1); + } + + List sql = LoggedSql.stop(); + assertThat(sql).hasSize(1); + assertThat(sql.get(0)).contains("select distinct on (t0.id) t0.id, t0.name, t1.id, t1.first_name, t1.last_name from o_customer t0 left join contact t1 on t1.customer_id = t0.id order by t0.id, t1.last_name limit 10;"); + } + + @ForPlatform({Platform.POSTGRES, Platform.YUGABYTE}) + @Test + void distinctOnSubQuery() { + ResetBasicData.reset(); + + LoggedSql.start(); + + Query subQuery = DB.find(OrderDetail.class) + .distinctOn("product") + .select("id") + .orderBy("product, updtime desc") + .setMaxRows(10); + + List lines = DB.find(OrderDetail.class) + .select("product, orderQty, shipQty, created") + .where().in("id", subQuery) + .orderBy().asc("cretime") + .findList(); + + assertThat(lines).isNotEmpty(); + List sql = LoggedSql.stop(); + assertThat(sql).hasSize(1); + assertThat(sql.get(0)).contains("select t0.id, t0.product_id, t0.order_qty, t0.ship_qty, t0.id from o_order_detail t0 where t0.id in (select distinct on (t0.product_id) t0.id from o_order_detail t0 order by t0.product_id, t0.updtime desc limit 10) order by t0.cretime"); + } + + @ForPlatform({Platform.POSTGRES, Platform.YUGABYTE}) + @Test + void distinctOnManyToOne() { + ResetBasicData.reset(); + int totalCount = DB.find(Contact.class).findCount(); + + LoggedSql.start(); + List contacts = DB.find(Contact.class) + .distinctOn("customer") + .select("*") + .orderBy("customer, updtime desc") + .setMaxRows(10) + .findList(); + + assertThat(contacts.size()).isLessThan(totalCount); + + List sql = LoggedSql.stop(); + assertThat(sql).hasSize(1); + assertThat(sql.get(0)).contains("select distinct on (t0.customer_id) t0.id, t0.first_name, t0.last_name"); + assertThat(sql.get(0)).contains("from contact t0 order by t0.customer_id, t0.updtime desc limit 10"); + } + + @ForPlatform({Platform.POSTGRES, Platform.YUGABYTE}) + @Test + void distinctOnToDto() { + ResetBasicData.reset(); + int totalCount = DB.find(Contact.class).findCount(); + + LoggedSql.start(); + List contacts = DB.find(Contact.class) + .distinctOn("lastName") + .select("firstName, lastName") + .orderBy("lastName desc") + .setMaxRows(10) + .asDto(MyDto.class) + .findList(); + + assertThat(contacts.size()).isLessThan(totalCount); + for (MyDto contact : contacts) { + assertThat(contact.firstName()).isNotBlank(); + assertThat(contact.lastName()).isNotBlank(); + } + + List sql = LoggedSql.stop(); + assertThat(sql).hasSize(1); + assertThat(sql.get(0)).contains("select distinct on (t0.last_name) t0.first_name, t0.last_name from contact t0 order by t0.last_name desc limit 10"); + } + + @ForPlatform({Platform.POSTGRES, Platform.YUGABYTE}) + @Test + void distinctOnToDtoWithId() { + ResetBasicData.reset(); + int totalCount = DB.find(Contact.class).findCount(); + + LoggedSql.start(); + List contacts = DB.find(Contact.class) + .distinctOn("lastName") + .select("id, firstName, lastName") + .orderBy("lastName desc") + .setMaxRows(10) + .asDto(MyDtoWithId.class) + .findList(); + + assertThat(contacts.size()).isLessThan(totalCount); + for (MyDtoWithId contact : contacts) { + assertThat(contact.id()).isGreaterThan(0); + assertThat(contact.firstName()).isNotBlank(); + assertThat(contact.lastName()).isNotBlank(); + } + + List sql = LoggedSql.stop(); + assertThat(sql).hasSize(1); + assertThat(sql.get(0)).contains("select distinct on (t0.last_name) t0.id, t0.first_name, t0.last_name from contact t0 order by t0.last_name desc limit 10"); + } + + public static class MyDto { + private final String firstName; + private final String lastName; + + public MyDto(String firstName, String lastName) { + this.firstName = firstName; + this.lastName = lastName; + } + + public String firstName() { + return firstName; + } + + public String lastName() { + return lastName; + } + } + + public static class MyDtoWithId { + private final long id; + private final String firstName; + private final String lastName; + + public MyDtoWithId(long id, String firstName, String lastName) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + } + + public long id() { + return id; + } + + public String firstName() { + return firstName; + } + + public String lastName() { + return lastName; + } + } +}