diff --git a/hibernate-core/src/main/java/org/hibernate/sql/Template.java b/hibernate-core/src/main/java/org/hibernate/sql/Template.java index 9e16aba66f8b..ceb81a41aa2d 100644 --- a/hibernate-core/src/main/java/org/hibernate/sql/Template.java +++ b/hibernate-core/src/main/java/org/hibernate/sql/Template.java @@ -89,6 +89,10 @@ public final class Template { = Set.of("date", "time"); private static final Set LITERAL_PREFIXES = Set.of("n", "x", "varbyte", "bx", "bytea", "date", "time", "timestamp", "zone"); + private static final Set FETCH_BIGRAMS + = Set.of("first", "next"); + private static final Set CURRENT_BIGRAMS + = Set.of("date", "time", "timestamp"); private static final String PUNCTUATION = "=> "'".equals(nextToken) + nextToken -> "'".equals(nextToken) || lcToken.equals("time") && "with".equals(nextToken) || lcToken.equals("timestamp") && "with".equals(nextToken) || lcToken.equals("time") && "zone".equals(nextToken) ); @@ -319,7 +393,7 @@ private static boolean lookPastBlankTokens( String sqlWhereString, String symbols, StringTokenizer tokens, @SuppressWarnings("SameParameterValue") int skip, Function check) { - final StringTokenizer lookahead = lookahead( sqlWhereString, symbols, tokens, skip ); + final var lookahead = lookahead( sqlWhereString, symbols, tokens, skip ); if ( lookahead.hasMoreTokens() ) { String nextToken; do { @@ -344,8 +418,7 @@ private static boolean lookPastBlankTokens( * @return a cloned token stream */ private static StringTokenizer lookahead(String sql, String symbols, StringTokenizer tokens, int skip) { - final StringTokenizer lookahead = - new StringTokenizer( sql, symbols, true ); + final var lookahead = new StringTokenizer( sql, symbols, true ); while ( lookahead.countTokens() > tokens.countTokens() + skip ) { lookahead.nextToken(); } @@ -382,21 +455,18 @@ public static List collectColumnNames(String template) { } private static boolean isNamedParameter(String token) { - return token.startsWith( ":" ); + return token.charAt(0) == ':'; } - private static boolean isFunctionOrKeyword( + private static boolean isKeyword( String lcToken, - String nextToken, + boolean afterCurrent, Dialect dialect, TypeConfiguration typeConfiguration) { - if ( "(".equals( nextToken ) ) { - return true; - } - else if ( SOFT_KEYWORDS.contains( lcToken ) ) { + if ( SOFT_KEYWORDS.contains( lcToken ) ) { // these can be column names on some databases - // TODO: treat 'current date' as a function - return false; + // but treat 'current date', 'current time' bigrams as keywords + return afterCurrent; } else { return KEYWORDS.contains( lcToken ) @@ -410,15 +480,15 @@ private static boolean isType(String lcToken, TypeConfiguration typeConfiguratio return typeConfiguration.getDdlTypeRegistry().isTypeNameRegistered( lcToken ); } - private static boolean isIdentifier(String token) { - return token.charAt( 0 ) == '`' // allow any identifier quoted with backtick - || isLetter( token.charAt( 0 ) ) // only recognizes identifiers beginning with a letter - && token.indexOf( '.' ) < 0 - && !isBoolean( token ); + private static boolean isUnqualifiedIdentifier(String token) { + final char initialChar = token.charAt( 0 ); + return initialChar == '`' // allow any identifier quoted with backtick + || isLetter( initialChar ) // only recognizes identifiers beginning with a letter + && token.indexOf( '.' ) < 0; // don't qualify already-qualified identifiers } - private static boolean isBoolean(String token) { - return switch ( token.toLowerCase( Locale.ROOT ) ) { + private static boolean isBoolean(String lcToken) { + return switch ( lcToken ) { case "true", "false" -> true; default -> false; }; diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/sql/TemplateTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/sql/TemplateTest.java index 6d69db02620d..dad99a32a977 100644 --- a/hibernate-core/src/test/java/org/hibernate/orm/test/sql/TemplateTest.java +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/sql/TemplateTest.java @@ -80,6 +80,61 @@ public void templateLiterals(SessionFactoryScope scope) { "CAST({@}.foo AS signed)", factory ); } + @Test + @JiraKey("HHH-19695") + public void testFetchGrammarVsColumnNames(SessionFactoryScope scope) { + SessionFactoryImplementor factory = scope.getSessionFactory(); + + // Test that "first" and "next" are treated as keywords when part of FETCH grammar + assertWhereStringTemplate( "fetch first 10 rows only", "fetch first 10 rows only", factory ); + assertWhereStringTemplate( "fetch next 5 rows only", "fetch next 5 rows only", factory ); + assertWhereStringTemplate( "select * from table fetch first 1 row only", + "select * from table fetch first 1 row only", factory ); + + // Mixed scenarios: ensure identifiers around FETCH grammar are still qualified + assertWhereStringTemplate( "select first_name from users fetch first 10 rows only", + "select {@}.first_name from users fetch first 10 rows only", factory ); + assertWhereStringTemplate( "where fetch_count > 5 and fetch next 1 row only", + "where {@}.fetch_count > 5 and fetch next 1 row only", factory ); + assertWhereStringTemplate( "select first from users fetch first 10 rows only", + "select {@}.first from users fetch first 10 rows only", factory ); + assertWhereStringTemplate( "select next from users fetch next 10 rows only", + "select {@}.next from users fetch next 10 rows only", factory ); + } + + @Test + @JiraKey("HHH-19695") + public void testFetchGrammarVariants(SessionFactoryScope scope) { + SessionFactoryImplementor factory = scope.getSessionFactory(); + Dialect dialect = factory.getJdbcServices().getDialect(); + + // Variants of FETCH FIRST/NEXT + assertWhereStringTemplate( "fetch first 1 row only", "fetch first 1 row only", factory ); + assertWhereStringTemplate( "fetch next 10 rows only", "fetch next 10 rows only", factory ); + + // Parameterized row count + assertWhereStringTemplate( "fetch next ? rows only", "fetch next ? rows only", factory ); + + // Casing variants + assertWhereStringTemplate( "FETCH First 10 ROWS ONLY", "FETCH First 10 ROWS ONLY", factory ); + + // Extra whitespace and newlines + assertWhereStringTemplate( "fetch first 10 rows only", "fetch first 10 rows only", factory ); + assertWhereStringTemplate( "fetch\nfirst 3 rows only", "fetch\nfirst 3 rows only", factory ); + + // State reset after ONLY: trailing 'next' should be qualified + assertWhereStringTemplate( "fetch next 1 rows only and next > 5", + "fetch next 1 rows only and {@}.next > 5", factory ); + + // Qualified identifier should remain as-is + assertWhereStringTemplate( "select u.first from users u fetch first 1 row only", + "select u.first from users u fetch first 1 row only", factory ); + + // Quoted identifier should be qualified, while FETCH clause remains unqualified + assertWhereStringTemplate( "select `first` from users fetch first 1 row only", + "select {@}." + dialect.quote("`first`") + " from users fetch first 1 row only", factory ); + } + private static void assertWhereStringTemplate(String sql, SessionFactoryImplementor sf) { assertEquals( sql, Template.renderWhereStringTemplate(