Skip to content

Commit 44df3ef

Browse files
committed
CountQuery creation and getProjection(...) with JSqlParser.
Inside `JSqlParserQueryUtils` it is now possible to create count queries with `createCountQueryFor(...)`. Furthermore, the `getProjection(...)` method utilizes the power of the JSQlParser. Related tickets spring-projects#2409
1 parent 679435d commit 44df3ef

File tree

2 files changed

+262
-10
lines changed

2 files changed

+262
-10
lines changed

src/main/java/org/springframework/data/jpa/repository/query/JSqlParserQueryUtils.java

+105-4
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
package org.springframework.data.jpa.repository.query;
22

3+
import static org.springframework.data.jpa.repository.query.QueryUtils.checkSortExpression;
4+
35
import java.util.*;
46
import java.util.stream.Collectors;
57

6-
import net.sf.jsqlparser.statement.select.*;
78
import org.springframework.data.domain.Sort;
89
import org.springframework.data.domain.Sort.Order;
910
import org.springframework.data.util.Streamable;
@@ -22,12 +23,12 @@
2223
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
2324
import net.sf.jsqlparser.schema.Column;
2425
import net.sf.jsqlparser.schema.Table;
26+
import net.sf.jsqlparser.statement.select.*;
2527
import net.sf.jsqlparser.util.SelectUtils;
2628

27-
import static org.springframework.data.jpa.repository.query.QueryUtils.checkSortExpression;
28-
2929
/**
30-
* Simple utility class to create JPA queries using JSqlParser.
30+
* Simple utility class to create SQL queries using JSqlParser. The usage of this class is limited to valid SQL syntax.
31+
* If the query or the statement is not valid any usage of this class will throw an exception.
3132
*
3233
* @author Diego Krupitza
3334
*/
@@ -95,7 +96,17 @@ public static String getQueryString(String template, String entityName) {
9596
public static String detectAlias(String query) {
9697
Select selectStatement = parseSelectStatement(query);
9798
PlainSelect selectBody = (PlainSelect) selectStatement.getSelectBody();
99+
return detectAlias(selectBody);
100+
}
98101

102+
/**
103+
* Resolves the alias for the entity to be retrieved from the given {@link PlainSelect}. Note that you only provide
104+
* valid Query strings. Things such as <code>from User u</code> will throw an {@link IllegalArgumentException}.
105+
*
106+
* @param selectBody must not be {@literal null}.
107+
* @return Might return {@literal null}.
108+
*/
109+
private static String detectAlias(PlainSelect selectBody) {
99110
Alias alias = selectBody.getFromItem().getAlias();
100111
return alias == null ? null : alias.getName();
101112
}
@@ -262,6 +273,96 @@ private static OrderByElement getOrderClause(final Set<String> joinAliases, fina
262273
return orderByElement;
263274
}
264275

276+
/**
277+
* Creates a count projected query from the given original query.
278+
*
279+
* @param originalQuery must not be {@literal null} or empty.
280+
* @return Guaranteed to be not {@literal null}.
281+
*/
282+
public static String createCountQueryFor(String originalQuery) {
283+
return createCountQueryFor(originalQuery, null);
284+
}
285+
286+
/**
287+
* Creates a count projected query from the given original query.
288+
*
289+
* @param originalQuery must not be {@literal null}.
290+
* @param countProjection may be {@literal null}.
291+
* @return a query String to be used a count query for pagination. Guaranteed to be not {@literal null}.
292+
*/
293+
public static String createCountQueryFor(String originalQuery, @Nullable String countProjection) {
294+
295+
Assert.hasText(originalQuery, "OriginalQuery must not be null or empty!");
296+
297+
Select selectStatement = parseSelectStatement(originalQuery);
298+
PlainSelect selectBody = (PlainSelect) selectStatement.getSelectBody();
299+
300+
// remove order by
301+
selectBody.setOrderByElements(null);
302+
303+
if (StringUtils.hasText(countProjection)) {
304+
Function jSqlCount = getJSqlCount(Collections.singletonList(countProjection), false);
305+
selectBody.setSelectItems(Collections.singletonList(new SelectExpressionItem(jSqlCount)));
306+
return selectBody.toString();
307+
}
308+
309+
boolean distinct = selectBody.getDistinct() != null;
310+
selectBody.setDistinct(null); // reset possible distinct
311+
312+
String tableAlias = detectAlias(selectBody);
313+
314+
// is never null
315+
List<SelectItem> selectItems = selectBody.getSelectItems();
316+
317+
if (onlyASingleColumnProjection(selectItems)) {
318+
SelectExpressionItem singleProjection = (SelectExpressionItem) selectItems.get(0);
319+
320+
Column column = (Column) singleProjection.getExpression();
321+
String countProp = column.getFullyQualifiedName();
322+
323+
Function jSqlCount = getJSqlCount(Collections.singletonList(countProp), distinct);
324+
selectBody.setSelectItems(Collections.singletonList(new SelectExpressionItem(jSqlCount)));
325+
return selectBody.toString();
326+
}
327+
328+
String countProp = tableAlias == null ? "*" : tableAlias;
329+
330+
Function jSqlCount = getJSqlCount(Collections.singletonList(countProp), distinct);
331+
selectBody.setSelectItems(Collections.singletonList(new SelectExpressionItem(jSqlCount)));
332+
333+
return selectBody.toString();
334+
}
335+
336+
/**
337+
* Checks whether a given projection only contains a single column definition (aka without functions, etc)
338+
*
339+
* @param projection the projection to analyse
340+
* @return <code>true</code> when the projection only contains a single column definition otherwise <code>false</code>
341+
*/
342+
private static boolean onlyASingleColumnProjection(List<SelectItem> projection) {
343+
// this is unfortunately the only way to check without any hacky & hard string regex magic
344+
return projection.size() == 1 && projection.get(0) instanceof SelectExpressionItem
345+
&& (((SelectExpressionItem) projection.get(0)).getExpression()) instanceof Column;
346+
}
347+
348+
/**
349+
* Returns the projection part of the query, i.e. everything between {@code select} and {@code from}.
350+
*
351+
* @param query must not be {@literal null} or empty.
352+
* @return the projection part of the query.
353+
*/
354+
public static String getProjection(String query) {
355+
Assert.hasText(query, "Query must not be null or empty!");
356+
357+
Select selectStatement = parseSelectStatement(query);
358+
PlainSelect selectBody = (PlainSelect) selectStatement.getSelectBody();
359+
360+
return selectBody.getSelectItems() //
361+
.stream() //
362+
.map(Object::toString) //
363+
.collect(Collectors.joining(", ")).trim();
364+
}
365+
265366
/**
266367
* Generates a JSqlParser table from an entity name and an optional alias name
267368
*

src/test/java/org/springframework/data/jpa/repository/query/JSqlParserQueryUtilsUnitTests.java

+157-6
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
package org.springframework.data.jpa.repository.query;
22

33
import static org.assertj.core.api.Assertions.*;
4-
import static org.springframework.data.jpa.repository.query.QueryUtils.getOuterJoinAliases;
4+
import static org.springframework.data.jpa.repository.query.QueryUtils.detectAlias;
55

66
import java.util.Collections;
77
import java.util.Set;
88

9+
import org.assertj.core.api.SoftAssertions;
910
import org.junit.jupiter.api.Test;
1011
import org.springframework.dao.InvalidDataAccessApiUsageException;
1112
import org.springframework.data.domain.Sort;
@@ -58,11 +59,6 @@ void detectAliasThrowsErrorOnInvalidSQL() {
5859
.isInstanceOf(IllegalArgumentException.class);
5960
}
6061

61-
@Test
62-
void allowsFullyQualifiedEntityNamesInQuery() {
63-
assertThat(JSqlParserQueryUtils.detectAlias(FQ_QUERY)).isEqualTo("u");
64-
}
65-
6662
@Test // DATAJPA-798
6763
void detectsAliasInQueryContainingLineBreaks() {
6864
assertThat(JSqlParserQueryUtils.detectAlias("select \n u \n from \n User \nu")).isEqualTo("u");
@@ -347,6 +343,161 @@ void discoversCorrectAliasForJoinFetch() {
347343
assertThat(aliases).containsExactly("authority");
348344
}
349345

346+
@Test // DATAJPA-420
347+
void createsCountQueryForScalarSelects() {
348+
assertCountQuery("select p.lastname,p.firstname from Person p", "select count(p) from Person p");
349+
}
350+
351+
@Test // DATAJPA-456
352+
void createCountQueryFromTheGivenCountProjection() {
353+
assertThat(JSqlParserQueryUtils.createCountQueryFor("select p.lastname,p.firstname from Person p", "p.lastname"))
354+
.isEqualToIgnoringCase("select count(p.lastname) from Person p");
355+
}
356+
357+
@Test // DATAJPA-736
358+
void supportsNonAsciiCharactersInEntityNames() {
359+
assertThat(JSqlParserQueryUtils.createCountQueryFor("select u from Usèr u"))
360+
.isEqualToIgnoringCase("select count(u) from Usèr u");
361+
}
362+
363+
@Test // DATAJPA-1500
364+
void createCountQuerySupportsWhitespaceCharacters() {
365+
366+
assertThat(JSqlParserQueryUtils.createCountQueryFor("select * from User user\n" + //
367+
" where user.age = 18\n" + //
368+
" order by user.name\n ")).isEqualToIgnoringCase("select count(user) from User user where user.age = 18");
369+
}
370+
371+
@Test
372+
void createCountQuerySupportsLineBreaksInSelectClause() {
373+
374+
assertThat(JSqlParserQueryUtils.createCountQueryFor("select user.age,\n" + //
375+
" user.name\n" + //
376+
" from User user\n" + //
377+
" where user.age = 18\n" + //
378+
" order\nby\nuser.name\n ")).isEqualToIgnoringCase("select count(user) from User user where user.age = 18");
379+
}
380+
381+
@Test
382+
void createCountQuerySupportsLineBreakRightAfterDistinct() {
383+
384+
assertThat(JSqlParserQueryUtils.createCountQueryFor("select\ndistinct\nuser.age,\n" + //
385+
"user.name\n" + //
386+
"from\nUser\nuser")).isEqualTo(JSqlParserQueryUtils.createCountQueryFor("select\ndistinct user.age,\n" + //
387+
"user.name\n" + //
388+
"from\nUser\nuser"));
389+
}
390+
391+
@Test
392+
void createsCountQueryCorrectly() {
393+
assertCountQuery(QUERY, COUNT_QUERY);
394+
}
395+
396+
@Test
397+
void createsCountQueriesCorrectlyForCapitalLetter() {
398+
assertCountQuery("SELECT u FROM User u where u.foo.bar = ?", "select count(u) FROM User u where u.foo.bar = ?");
399+
}
400+
401+
@Test
402+
void createsCountQueryForDistinctQueries() {
403+
404+
assertCountQuery("select distinct u from User u where u.foo = ?",
405+
"select count(distinct u) from User u where u.foo = ?");
406+
}
407+
408+
@Test
409+
void failsOnConstructorQueries() {
410+
411+
assertThatThrownBy(
412+
() -> JSqlParserQueryUtils.createCountQueryFor("select distinct new User(u.name) from User u where u.foo = ?"))
413+
.isInstanceOf(IllegalArgumentException.class);
414+
}
415+
416+
@Test
417+
void createsCountQueryForJoins() {
418+
419+
assertCountQuery("select distinct u.name from User u left outer join u.roles r WHERE r = ?",
420+
"select count(distinct u.name) from User u left outer join u.roles r WHERE r = ?");
421+
}
422+
423+
@Test
424+
void createsCountQueryForQueriesWithSubSelects() {
425+
426+
assertCountQuery("select u from User u left outer join u.roles r where r in (select r from Role)",
427+
"select count(u) from User u left outer join u.roles r where r in (select r from Role)");
428+
}
429+
430+
@Test
431+
void createsCountQueryForAliasesCorrectly() {
432+
433+
assertCountQuery("select u from User as u", "select count(u) from User as u");
434+
}
435+
436+
@Test
437+
void doesNotAllowShortJpaSyntax() {
438+
assertThatThrownBy(() -> assertCountQuery(INVALID_QUERY, COUNT_QUERY)).isInstanceOf(IllegalArgumentException.class);
439+
}
440+
441+
@Test
442+
void allowsFullyQualifiedEntityNamesInQuery() {
443+
444+
assertThat(detectAlias(FQ_QUERY)).isEqualTo("u");
445+
assertCountQuery(FQ_QUERY, "select count(u) from org.acme.domain.User$Foo_Bar u");
446+
}
447+
448+
@Test // DATAJPA-342
449+
void usesReturnedVariableInCOuntProjectionIfSet() {
450+
451+
assertCountQuery("select distinct m.genre from Media m where m.user = ?1 order by m.genre asc",
452+
"select count(distinct m.genre) from Media m where m.user = ?1");
453+
}
454+
455+
@Test // DATAJPA-343
456+
void projectsCOuntQueriesForQueriesWithSubselects() {
457+
458+
assertCountQuery("select o from Foo o where cb.id in (select b from Bar b)",
459+
"select count(o) from Foo o where cb.id in (select b from Bar b)");
460+
}
461+
462+
@Test // DATAJPA-377
463+
void removesOrderByInGeneratedCountQueryFromOriginalQueryIfPresent() {
464+
465+
assertCountQuery("select distinct m.genre from Media m where m.user = ?1 OrDer By m.genre ASC",
466+
"select count(distinct m.genre) from Media m where m.user = ?1");
467+
}
468+
469+
@Test // DATAJPA-409
470+
void createsCountQueryForNestedReferenceCorrectly() {
471+
assertCountQuery("select a.b from A a", "select count(a.b) from A a");
472+
}
473+
474+
@Test // DATAJPA-1679
475+
void findProjectionClauseWithDistinct() {
476+
477+
SoftAssertions.assertSoftly(sofly -> {
478+
sofly.assertThat(JSqlParserQueryUtils.getProjection("select * from x")).isEqualTo("*");
479+
sofly.assertThat(JSqlParserQueryUtils.getProjection("select a, b, c from x")).isEqualTo("a, b, c");
480+
sofly.assertThat(JSqlParserQueryUtils.getProjection("select distinct a, b, c from x")).isEqualTo("a, b, c");
481+
sofly.assertThat(JSqlParserQueryUtils.getProjection("select DISTINCT a, b, c from x")).isEqualTo("a, b, c");
482+
});
483+
}
484+
485+
@Test // DATAJPA-1696
486+
void findProjectionClauseWithSubselect() {
487+
488+
// This is not a required behavior the testcase in QueryUtilsUnitTests#findProjectionClauseWithSubselect tells why
489+
assertThat(JSqlParserQueryUtils.getProjection("select * from (select x from y)")).isEqualTo("*");
490+
}
491+
492+
@Test // DATAJPA-1696
493+
void findProjectionClauseWithIncludedFrom() {
494+
assertThat(JSqlParserQueryUtils.getProjection("select x, frommage, y from t")).isEqualTo("x, frommage, y");
495+
}
496+
497+
private static void assertCountQuery(String originalQuery, String countQuery) {
498+
assertThat(JSqlParserQueryUtils.createCountQueryFor(originalQuery)).isEqualToIgnoringCase(countQuery);
499+
}
500+
350501
private static void endsIgnoringCase(String original, String endWithIgnoreCase) {
351502
// https://github.com/assertj/assertj-core/pull/2451
352503
// can be removed when upgrading to version 3.23.0 assertJ

0 commit comments

Comments
 (0)