Skip to content

Commit 658e0b6

Browse files
Limit/Offset MQL translation
1 parent 7573ef2 commit 658e0b6

File tree

10 files changed

+632
-15
lines changed

10 files changed

+632
-15
lines changed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
* Copyright 2025-present MongoDB, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.mongodb.hibernate;
18+
19+
import com.mongodb.hibernate.dialect.MongoDialect;
20+
import java.util.concurrent.atomic.AtomicInteger;
21+
import org.hibernate.dialect.Dialect;
22+
import org.hibernate.engine.jdbc.dialect.spi.DialectResolutionInfo;
23+
import org.hibernate.engine.spi.SessionFactoryImplementor;
24+
import org.hibernate.sql.ast.SqlAstTranslator;
25+
import org.hibernate.sql.ast.SqlAstTranslatorFactory;
26+
import org.hibernate.sql.ast.tree.MutationStatement;
27+
import org.hibernate.sql.ast.tree.select.SelectStatement;
28+
import org.hibernate.sql.exec.spi.JdbcOperationQueryMutation;
29+
import org.hibernate.sql.exec.spi.JdbcOperationQuerySelect;
30+
import org.hibernate.sql.model.ast.TableMutation;
31+
import org.hibernate.sql.model.jdbc.JdbcMutationOperation;
32+
33+
public final class TestDialect extends Dialect {
34+
private final AtomicInteger selectTranslatingCounter = new AtomicInteger();
35+
private final Dialect delegate;
36+
37+
public TestDialect(DialectResolutionInfo info) {
38+
super(info);
39+
delegate = new MongoDialect(info);
40+
}
41+
42+
@Override
43+
public SqlAstTranslatorFactory getSqlAstTranslatorFactory() {
44+
return new SqlAstTranslatorFactory() {
45+
@Override
46+
public SqlAstTranslator<JdbcOperationQuerySelect> buildSelectTranslator(
47+
SessionFactoryImplementor sessionFactory, SelectStatement statement) {
48+
selectTranslatingCounter.incrementAndGet();
49+
return delegate.getSqlAstTranslatorFactory().buildSelectTranslator(sessionFactory, statement);
50+
}
51+
52+
@Override
53+
public SqlAstTranslator<? extends JdbcOperationQueryMutation> buildMutationTranslator(
54+
SessionFactoryImplementor sessionFactory, MutationStatement statement) {
55+
return delegate.getSqlAstTranslatorFactory().buildMutationTranslator(sessionFactory, statement);
56+
}
57+
58+
@Override
59+
public <O extends JdbcMutationOperation> SqlAstTranslator<O> buildModelMutationTranslator(
60+
TableMutation<O> mutation, SessionFactoryImplementor sessionFactory) {
61+
return delegate.getSqlAstTranslatorFactory().buildModelMutationTranslator(mutation, sessionFactory);
62+
}
63+
};
64+
}
65+
66+
public int getSelectTranslatingCounter() {
67+
return selectTranslatingCounter.get();
68+
}
69+
}

src/integrationTest/java/com/mongodb/hibernate/query/select/Book.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,9 @@ class Book {
4343
this.publishYear = publishYear;
4444
this.outOfStock = outOfStock;
4545
}
46+
47+
@Override
48+
public String toString() {
49+
return "Book{" + "id=" + id + '}';
50+
}
4651
}
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
/*
2+
* Copyright 2025-present MongoDB, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.mongodb.hibernate.query.select;
18+
19+
import static com.mongodb.hibernate.internal.MongoAssertions.assertTrue;
20+
import static com.mongodb.hibernate.internal.MongoAssertions.fail;
21+
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
22+
23+
import com.mongodb.hibernate.TestDialect;
24+
import com.mongodb.hibernate.internal.FeatureNotSupportedException;
25+
import com.mongodb.hibernate.internal.MongoConstants;
26+
import java.util.Arrays;
27+
import java.util.List;
28+
import org.hibernate.cfg.AvailableSettings;
29+
import org.hibernate.query.SelectionQuery;
30+
import org.hibernate.query.sqm.FetchClauseType;
31+
import org.hibernate.testing.orm.junit.DomainModel;
32+
import org.hibernate.testing.orm.junit.ServiceRegistry;
33+
import org.hibernate.testing.orm.junit.Setting;
34+
import org.junit.jupiter.api.BeforeEach;
35+
import org.junit.jupiter.api.Nested;
36+
import org.junit.jupiter.api.Test;
37+
import org.junit.jupiter.params.ParameterizedTest;
38+
import org.junit.jupiter.params.provider.CsvSource;
39+
import org.junit.jupiter.params.provider.EnumSource;
40+
41+
@DomainModel(annotatedClasses = Book.class)
42+
class LimitOffsetFetchClauseIntegrationTests extends AbstractSelectionQueryIntegrationTests {
43+
44+
private static final List<Book> testingBooks = List.of(
45+
new Book(0, "Nostromo", 1904, true),
46+
new Book(1, "The Age of Innocence", 1920, false),
47+
new Book(2, "Remembrance of Things Past", 1913, true),
48+
new Book(3, "The Magic Mountain", 1924, false),
49+
new Book(4, "A Passage to India", 1924, true),
50+
new Book(5, "Ulysses", 1922, false),
51+
new Book(6, "Mrs. Dalloway", 1925, false),
52+
new Book(7, "The Trial", 1925, true),
53+
new Book(8, "Sons and Lovers", 1913, false),
54+
new Book(9, "The Sound and the Fury", 1929, false));
55+
56+
private static List<Book> getBooksByIds(int... ids) {
57+
return Arrays.stream(ids)
58+
.mapToObj(id -> testingBooks.stream()
59+
.filter(c -> c.id == id)
60+
.findFirst()
61+
.orElseThrow(() -> new IllegalArgumentException("id does not exist: " + id)))
62+
.toList();
63+
}
64+
65+
@BeforeEach
66+
void beforeEach() {
67+
getSessionFactoryScope().inTransaction(session -> testingBooks.forEach(session::persist));
68+
getTestCommandListener().clear();
69+
}
70+
71+
@Nested
72+
class WithoutQueryOptionsLimit {
73+
74+
@Test
75+
void testHqlLimitClauseOnly() {
76+
assertSelectionQuery(
77+
"from Book order by id LIMIT :limit",
78+
Book.class,
79+
q -> q.setParameter("limit", 5),
80+
"{'aggregate': 'books', 'pipeline': [{'$sort': {'_id': 1}}, {'$limit': 5}, {'$project': {'_id': true, 'discount': true, 'isbn13': true, 'outOfStock': true, 'price': true, 'publishYear': true, 'title': true}}]}",
81+
getBooksByIds(0, 1, 2, 3, 4));
82+
}
83+
84+
@Test
85+
void testHqlOffsetClauseOnly() {
86+
assertSelectionQuery(
87+
"from Book order by id OFFSET :offset",
88+
Book.class,
89+
q -> q.setParameter("offset", 7),
90+
"{'aggregate': 'books', 'pipeline': [{'$sort': {'_id': 1}}, {'$skip': 7}, {'$project': {'_id': true, 'discount': true, 'isbn13': true, 'outOfStock': true, 'price': true, 'publishYear': true, 'title': true}}]}",
91+
getBooksByIds(7, 8, 9));
92+
}
93+
94+
@Test
95+
void testHqlLimitAndOffsetClauses() {
96+
assertSelectionQuery(
97+
"from Book order by id LIMIT :limit OFFSET :offset",
98+
Book.class,
99+
q -> q.setParameter("offset", 3).setParameter("limit", 3),
100+
"{'aggregate': 'books', 'pipeline': [{'$sort': {'_id': 1}}, {'$skip': 3}, {'$limit': 3}, {'$project': {'_id': true, 'discount': true, 'isbn13': true, 'outOfStock': true, 'price': true, 'publishYear': true, 'title': true}}]}",
101+
getBooksByIds(3, 4, 5));
102+
}
103+
104+
@Test
105+
void testHqlFetchClauseOnly() {
106+
assertSelectionQuery(
107+
"from Book order by id FETCH FIRST :limit ROWS ONLY",
108+
Book.class,
109+
q -> q.setParameter("limit", 5),
110+
"{'aggregate': 'books', 'pipeline': [{'$sort': {'_id': 1}}, {'$limit': 5}, {'$project': {'_id': true, 'discount': true, 'isbn13': true, 'outOfStock': true, 'price': true, 'publishYear': true, 'title': true}}]}",
111+
getBooksByIds(0, 1, 2, 3, 4));
112+
}
113+
}
114+
115+
@Nested
116+
class WithQueryOptionsLimit {
117+
118+
@Nested
119+
class WithoutHqlClauses {
120+
@Test
121+
void testQueryOptionsSetFirstResultOnly() {
122+
assertSelectionQuery(
123+
"from Book order by id",
124+
Book.class,
125+
q -> q.setFirstResult(6),
126+
"{'aggregate': 'books', 'pipeline': [{'$sort': {'_id': 1}}, {'$skip': 6}, {'$project': {'_id': true, 'discount': true, 'isbn13': true, 'outOfStock': true, 'price': true, 'publishYear': true, 'title': true}}]}",
127+
getBooksByIds(6, 7, 8, 9));
128+
}
129+
130+
@Test
131+
void testQueryOptionsSetMaxResultOnly() {
132+
assertSelectionQuery(
133+
"from Book order by id",
134+
Book.class,
135+
q -> q.setMaxResults(3),
136+
"{'aggregate': 'books', 'pipeline': [{'$sort': {'_id': 1}}, {'$limit': 3}, {'$project': {'_id': true, 'discount': true, 'isbn13': true, 'outOfStock': true, 'price': true, 'publishYear': true, 'title': true}}]}",
137+
getBooksByIds(0, 1, 2));
138+
}
139+
140+
@Test
141+
void testQueryOptionsSetFirstResultAndMaxResults() {
142+
assertSelectionQuery(
143+
"from Book order by id",
144+
Book.class,
145+
q -> q.setFirstResult(2).setMaxResults(3),
146+
"{'aggregate': 'books', 'pipeline': [{'$sort': {'_id': 1}}, {'$skip': 2}, {'$limit': 3}, {'$project': {'_id': true, 'discount': true, 'isbn13': true, 'outOfStock': true, 'price': true, 'publishYear': true, 'title': true}}]}",
147+
getBooksByIds(2, 3, 4));
148+
}
149+
}
150+
151+
@Nested
152+
class WithHqlClauses {
153+
154+
@ParameterizedTest
155+
@CsvSource({"true,false", "false,true", "true,true"})
156+
void testWithDirectConflicts(boolean isFirstResultSet, boolean isMaxResultsSet) {
157+
assertTrue(isFirstResultSet || isMaxResultsSet);
158+
var firstResult = 5;
159+
var maxResults = 3;
160+
final List<Book> expectedBooks;
161+
if (!isMaxResultsSet) {
162+
expectedBooks = getBooksByIds(5, 6, 7, 8, 9); // firstResult: 5
163+
} else if (!isFirstResultSet) {
164+
expectedBooks = getBooksByIds(0, 1, 2); // maxResults: 3
165+
} else {
166+
expectedBooks = getBooksByIds(5, 6, 7); // firstResult: 5 && maxResults: 3
167+
}
168+
assertSelectionQuery(
169+
"from Book order by id LIMIT :limit OFFSET :offset",
170+
Book.class,
171+
q -> {
172+
q.setParameter("limit", 10)
173+
.setParameter("offset", 0); // hql clauses will be ignored totally
174+
if (isFirstResultSet) {
175+
q.setFirstResult(firstResult);
176+
}
177+
if (isMaxResultsSet) {
178+
q.setMaxResults(maxResults);
179+
}
180+
},
181+
"{'aggregate': 'books', 'pipeline': [{'$sort': {'_id': 1}},"
182+
+ (isFirstResultSet ? "{'$skip': " + firstResult + "}" : "")
183+
+ (isMaxResultsSet ? " {'$limit': " + maxResults + "}" : "")
184+
+ ", {'$project': {'_id': true, 'discount': true, 'isbn13': true, 'outOfStock': true, 'price': true, 'publishYear': true, 'title': true}}]}",
185+
expectedBooks);
186+
}
187+
}
188+
}
189+
190+
@Nested
191+
class FeatureNotSupportedTests {
192+
193+
@ParameterizedTest
194+
@EnumSource(
195+
value = FetchClauseType.class,
196+
names = {"ROWS_WITH_TIES", "PERCENT_ONLY", "PERCENT_WITH_TIES"})
197+
void testUnsupportedFetchClauseType(FetchClauseType fetchClauseType) {
198+
var hqlSuffix =
199+
switch (fetchClauseType) {
200+
case ROWS_ONLY -> fail("ROWS_ONLY should have been excluded from the test");
201+
case ROWS_WITH_TIES -> "FETCH FIRST :limit ROWS WITH TIES";
202+
case PERCENT_ONLY -> "FETCH FIRST :limit PERCENT ROWS ONLY";
203+
case PERCENT_WITH_TIES -> "FETCH FIRST :limit PERCENT ROWS WITH TIES";
204+
};
205+
var hql = "from Book order by id " + hqlSuffix;
206+
assertSelectQueryFailure(
207+
hql,
208+
Book.class,
209+
q -> q.setParameter("limit", 10),
210+
FeatureNotSupportedException.class,
211+
"%s does not support '%s' fetch clause type",
212+
MongoConstants.MONGO_DBMS_NAME,
213+
fetchClauseType);
214+
}
215+
}
216+
217+
@Nested
218+
@DomainModel(annotatedClasses = Book.class)
219+
@ServiceRegistry(
220+
settings = {
221+
@Setting(name = AvailableSettings.QUERY_PLAN_CACHE_ENABLED, value = "true"),
222+
@Setting(name = AvailableSettings.DIALECT, value = "com.mongodb.hibernate.TestDialect")
223+
})
224+
class QueryPlanCacheTests extends AbstractSelectionQueryIntegrationTests {
225+
226+
private static final String HQL = "from Book order by id";
227+
228+
private TestDialect testDialect;
229+
230+
@BeforeEach
231+
void beforeEach() {
232+
testDialect = (TestDialect) getSessionFactoryScope()
233+
.getSessionFactory()
234+
.getJdbcServices()
235+
.getDialect();
236+
}
237+
238+
@ParameterizedTest
239+
@CsvSource({"true,false", "false,true", "true,true"})
240+
void testQueryOptionsLimitCached(boolean isFirstResultSet, boolean isMaxResultsSet) {
241+
getSessionFactoryScope().inTransaction(session -> {
242+
var query = session.createSelectionQuery(HQL, Book.class);
243+
setQueryOptionsAndQuery(query, isFirstResultSet ? 5 : null, isMaxResultsSet ? 10 : null);
244+
var initialSelectTranslatingCount = testDialect.getSelectTranslatingCounter();
245+
246+
query = session.createSelectionQuery(HQL, Book.class);
247+
setQueryOptionsAndQuery(query, isFirstResultSet ? 3 : null, isMaxResultsSet ? 6 : null);
248+
assertThat(testDialect.getSelectTranslatingCounter()).isEqualTo(initialSelectTranslatingCount);
249+
});
250+
}
251+
252+
private void setQueryOptionsAndQuery(SelectionQuery<Book> query, Integer firstResult, Integer maxResults) {
253+
if (firstResult != null) {
254+
query.setFirstResult(firstResult);
255+
}
256+
if (maxResults != null) {
257+
query.setMaxResults(maxResults);
258+
}
259+
query.getResultList();
260+
}
261+
262+
@Test
263+
void testCacheInvalidatedDueToQueryOptionsAdded() {
264+
getSessionFactoryScope().inTransaction(session -> {
265+
session.createSelectionQuery(HQL, Book.class).getResultList();
266+
var initialSelectTranslatingCount = testDialect.getSelectTranslatingCounter();
267+
268+
session.createSelectionQuery(HQL, Book.class).setFirstResult(1).getResultList();
269+
assertThat(testDialect.getSelectTranslatingCounter()).isEqualTo(initialSelectTranslatingCount + 1);
270+
271+
session.createSelectionQuery(HQL, Book.class)
272+
.setFirstResult(1)
273+
.setMaxResults(5)
274+
.getResultList();
275+
assertThat(testDialect.getSelectTranslatingCounter()).isEqualTo(initialSelectTranslatingCount + 2);
276+
});
277+
}
278+
279+
@Test
280+
void testCacheInvalidatedDueToQueryOptionsRemoved() {
281+
getSessionFactoryScope().inTransaction(session -> {
282+
session.createSelectionQuery(HQL, Book.class).setFirstResult(10).getResultList();
283+
var initialSelectTranslatingCount = testDialect.getSelectTranslatingCounter();
284+
285+
session.createSelectionQuery(HQL, Book.class).getResultList();
286+
assertThat(testDialect.getSelectTranslatingCounter()).isEqualTo(initialSelectTranslatingCount + 1);
287+
});
288+
}
289+
290+
@Test
291+
void testCacheInvalidatedDueToDifferentQueryOptions() {
292+
getSessionFactoryScope().inTransaction(session -> {
293+
session.createSelectionQuery(HQL, Book.class).setFirstResult(10).getResultList();
294+
var initialSelectTranslatingCount = testDialect.getSelectTranslatingCounter();
295+
296+
session.createSelectionQuery(HQL, Book.class).setMaxResults(20).getResultList();
297+
assertThat(testDialect.getSelectTranslatingCounter()).isEqualTo(initialSelectTranslatingCount + 1);
298+
});
299+
}
300+
}
301+
}

0 commit comments

Comments
 (0)