diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlText.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlText.java index 2c0d9cd8fac..afff3a7c29f 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlText.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlText.java @@ -56,6 +56,9 @@ final class HqlText { private HqlText(String hql) { text = hql.trim(); + String group = firstQueryGroup(text); + HqlText analyzed = group.equals(text) ? null : new HqlText(group); + String lower = lowerCaseKeepingLength(text); SqlText scan = SqlText.hql(lower); selectIndex = scan.indexOfTopLevel(SELECT + " ", 0); @@ -82,15 +85,13 @@ private HqlText(String hql) { clauseOriginal = clauseOriginal.substring(prefix.length()).trim(); clauseLower = clauseLower.substring(prefix.length()).trim(); } - projectionCount = count; - selectClauseLower = clauseLower; - rootAlias = readRootAlias(lower); + String alias = readRootAlias(lower); // the normalized form: an unaliased root gets the alias e, and the projection is qualified with it String rewritten = text; String rewrittenClause = clauseLower; - String rewrittenRootAlias = rootAlias; - if (selectIndex >= 0 && fromIndex >= 0) { + String rewrittenRootAlias = alias; + if (analyzed == null && selectIndex >= 0 && fromIndex >= 0) { int afterFrom = fromIndex + FROM.length() + 2; int entityEnd = afterFrom; while (entityEnd < text.length() && !Character.isWhitespace(text.charAt(entityEnd))) entityEnd++; @@ -103,11 +104,39 @@ private HqlText(String hql) { rewrittenRootAlias = SYNTHETIC_ALIAS; } } + if (analyzed != null) { + count = analyzed.projectionCount; + clauseLower = analyzed.selectClauseLower; + alias = analyzed.rootAlias; + rewritten = text; + rewrittenClause = analyzed.normalizedClause; + rewrittenRootAlias = analyzed.normalizedRootAlias; + } + projectionCount = count; + selectClauseLower = clauseLower; + rootAlias = alias; normalized = rewritten; normalizedClause = rewrittenClause; normalizedRootAlias = rewrittenRootAlias; } + /** + * The interior of a leading parenthesized group, unwrapped while the text starts with one. The projections + * and the result type are read from that group, whether a set operator, {@code limit} or anything else follows + * it. An unclosed group is left as written. This is not the text sent to Hibernate. + */ + private static String firstQueryGroup(String text) { + String current = text; + while (current.startsWith("(")) { + int close = SqlText.hql(current).closingParen(0); + if (close < 0) { + return current; + } + current = current.substring(1, close).trim(); + } + return current; + } + /** * Lowercases the text character by character, so that every index of the lowercase text is the same index of * the original: {@code String.toLowerCase} can lengthen a text, as it does for the dotted capital I of a @@ -156,7 +185,8 @@ int projectionCount() { * {@code select Person from Person} becomes {@code select e from Person e}. Only the select and from of the * query itself are read, not those of a subquery, and what precedes the select, such as a comment or the * {@code with} clause of a common table expression, is kept. A query that already aliases its root, or has no - * select or from, is returned as it is. + * select or from, is returned as it is. A query that starts with a parenthesized group is returned as written: + * the group is read for the result type, and a later select is left unchanged. */ String normalized() { return normalized; diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/SqlText.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/SqlText.java index f44279ede57..f0c8024e95c 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/SqlText.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/SqlText.java @@ -117,6 +117,26 @@ int indexOfTopLevel(String keyword, int fromIndex) { return -1; } + /** + * Returns the index of the {@code )} that closes the {@code (} at {@code openIndex}. The two parentheses of a + * pair carry the same depth, and a parenthesis inside a string literal, a quoted identifier or a comment is not + * part of a pair. + * + * @return the index, or -1 when {@code openIndex} is not a plain {@code (} or the group is not closed + */ + int closingParen(int openIndex) { + if (openIndex < 0 || openIndex >= text.length() || kind[openIndex] != PLAIN || text.charAt(openIndex) != '(') { + return -1; + } + int opened = depth[openIndex]; + for (int i = openIndex + 1; i < text.length(); i++) { + if (kind[i] == PLAIN && text.charAt(i) == ')' && depth[i] == opened) { + return i; + } + } + return -1; + } + /** * Splits the text at the separators outside string literals, quoted identifiers, comments and parentheses, and * trims each part. A text without the separator is one part. diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/HqlSelectResultTypeSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/HqlSelectResultTypeSpec.groovy index f983e61baaa..a016560bdfd 100644 --- a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/HqlSelectResultTypeSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/HqlSelectResultTypeSpec.groovy @@ -71,6 +71,42 @@ class HqlSelectResultTypeSpec extends HibernateGormDatastoreSpec { books*.title == ['Emma', 'Persuasion', 'The Hobbit'] books.every { it instanceof HqlResultTypeBook } } + + void 'executeQuery infers scalar results from a parenthesized root query: #hql'() { + expect: + List results = HqlResultTypeBook.executeQuery(hql) + results.sort() == expected + results.every { resultType.isInstance(it) } + + where: + hql | expected | resultType + '(select b.title from HqlResultTypeBook b)' | ['Emma', 'Persuasion', 'The Hobbit'] | String + '((select b.title from HqlResultTypeBook b))' | ['Emma', 'Persuasion', 'The Hobbit'] | String + '( select b.title from HqlResultTypeBook b )' | ['Emma', 'Persuasion', 'The Hobbit'] | String + "(select b.title from HqlResultTypeBook b where b.title <> ')')" | ['Emma', 'Persuasion', 'The Hobbit'] | String + '(/* ignore ) and ( */ select b.title from HqlResultTypeBook b)' | ['Emma', 'Persuasion', 'The Hobbit'] | String + '(select b.title from HqlResultTypeBook b where b.author.id in (select a.id from HqlResultTypeAuthor a))' | ['Emma', 'Persuasion', 'The Hobbit'] | String + '(select b.title from HqlResultTypeBook b) union (select a.name from HqlResultTypeAuthor a)' | ['Austen', 'Emma', 'Persuasion', 'The Hobbit', 'Tolkien'] | String + "(select b.title from HqlResultTypeBook b) union select 'extra' from HqlResultTypeBook" | ['Emma', 'Persuasion', 'The Hobbit', 'extra'] | String + } + + void 'parenthesized root queries retain entity aggregate and tuple results'() { + expect: + HqlResultTypeBook.executeQuery('(select b from HqlResultTypeBook b order by b.title)')*.title == ['Emma', 'Persuasion', 'The Hobbit'] + HqlResultTypeBook.executeQuery('(select count(b) from HqlResultTypeBook b)') == [3L] + HqlResultTypeBook.executeQuery('(select b.title, b.author.name from HqlResultTypeBook b order by b.title)') + .collect { it as List } == [['Emma', 'Austen'], ['Persuasion', 'Austen'], ['The Hobbit', 'Tolkien']] + } + + void 'executeQuery infers a scalar from a parenthesized query followed by limit'() { + when: + List results = HqlResultTypeBook.executeQuery('(select b.title from HqlResultTypeBook b) limit 1') + + then: + results.size() == 1 + results[0] instanceof String + ['Emma', 'Persuasion', 'The Hobbit'].contains(results[0]) + } } @Entity diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HqlQueryContextSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HqlQueryContextSpec.groovy index d2ad34de6a0..a031b2159a0 100644 --- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HqlQueryContextSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HqlQueryContextSpec.groovy @@ -172,6 +172,9 @@ class HqlQueryContextSpec extends Specification { "select title, (select count(*) from Author) from Book" | "select title, (select count(*) from Author) from Book e" | Object[] "/* the book's (title) */ select title from Book" | "/* the book's (title) */ select e.title from Book e" | Object "SELECT DISTINCT title FROM Book" | "select distinct e.title from Book e" | Object + '(select b.title from Book b)' | '(select b.title from Book b)' | Object + '(select b.title from Book b) union (select a.name from Author a)' | '(select b.title from Book b) union (select a.name from Author a)' | Object + "(select b.title from Book b) union select 'extra' from Book" | "(select b.title from Book b) union select 'extra' from Book" | Object } @Unroll diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HqlTextSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HqlTextSpec.groovy index c61ce41a251..d0d496a0ab4 100644 --- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HqlTextSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HqlTextSpec.groovy @@ -56,6 +56,28 @@ class HqlTextSpec extends Specification { 'select b from Book b /* , not a projection */' | 1 | 'b' | 'b' | 'select b from Book b /* , not a projection */' | Book 'select m[1], b from Book b' | 2 | 'm[1], b' | 'b' | 'select m[1], b from Book b' | Object[] 'from Book b where b.height--1 > 0 select b.width' | 1 | 'b.width' | 'b' | 'from Book b where b.height--1 > 0 select b.width' | Object + '(select b.title from Book b)' | 1 | 'b.title' | 'b' | '(select b.title from Book b)' | Object + '((select b.title from Book b))' | 1 | 'b.title' | 'b' | '((select b.title from Book b))' | Object + '( select b.title from Book b )' | 1 | 'b.title' | 'b' | '( select b.title from Book b )' | Object + '(select b.title from Book b) union (select a.name from Author a)' | 1 | 'b.title' | 'b' | '(select b.title from Book b) union (select a.name from Author a)' | Object + "(select b.title from Book b) union select 'extra' from Book" | 1 | 'b.title' | 'b' | "(select b.title from Book b) union select 'extra' from Book" | Object + '(select b.title from Book b) union all (select a.name from Author a)' | 1 | 'b.title' | 'b' | '(select b.title from Book b) union all (select a.name from Author a)' | Object + '((select b.title from Book b) union all (select a.name from Author a))' | 1 | 'b.title' | 'b' | '((select b.title from Book b) union all (select a.name from Author a))' | Object + '(select b.title from Book b) /* ) select ignored from ignored */ union (select a.name from Author a)' | 1 | 'b.title' | 'b' | '(select b.title from Book b) /* ) select ignored from ignored */ union (select a.name from Author a)' | Object + "(select b.title from Book b where b.title <> ')')" | 1 | 'b.title' | 'b' | "(select b.title from Book b where b.title <> ')')" | Object + "(select 'it''s ), select x from y' from Book b)" | 1 | "'it''s ), select x from y'" | 'b' | "(select 'it''s ), select x from y' from Book b)" | Object + '(select b."title), from ignored" from Book b)' | 1 | 'b."title), from ignored"' | 'b' | '(select b."title), from ignored" from Book b)' | Object + '(select b.`title), from ignored` from Book b)' | 1 | 'b.`title), from ignored`' | 'b' | '(select b.`title), from ignored` from Book b)' | Object + '(/* ignore ) and ( select x from y */ select b.title from Book b)' | 1 | 'b.title' | 'b' | '(/* ignore ) and ( select x from y */ select b.title from Book b)' | Object + '(select b.title /* ), ignored */ from Book b)' | 1 | 'b.title /* ), ignored */' | 'b' | '(select b.title /* ), ignored */ from Book b)' | Object + '(select b.height--1 from Book b)' | 1 | 'b.height--1' | 'b' | '(select b.height--1 from Book b)' | Object + '(select b.title, b.author from Book b)' | 2 | 'b.title, b.author' | 'b' | '(select b.title, b.author from Book b)' | Object[] + '(select count(b) from Book b)' | 1 | 'count(b)' | 'b' | '(select count(b) from Book b)' | null + '(select distinct b from Book b)' | 1 | 'b' | 'b' | '(select distinct b from Book b)' | Book + '(select Book from Book)' | 1 | 'book' | null | '(select Book from Book)' | Book + '(select b from Book b where b.id in (select a.id from Author a))' | 1 | 'b' | 'b' | '(select b from Book b where b.id in (select a.id from Author a))' | Book + '(select b.title from Book b) limit 1' | 1 | 'b.title' | 'b' | '(select b.title from Book b) limit 1' | Object + '(select b.title from Book b) unionized (select a.name from Author a)' | 1 | 'b.title' | 'b' | '(select b.title from Book b) unionized (select a.name from Author a)' | Object '' | 0 | null | null | '' | Book }