Skip to content

HHH-19695 alternative solution #10725

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Aug 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 127 additions & 57 deletions hibernate-core/src/main/java/org/hibernate/sql/Template.java
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ public final class Template {
= Set.of("date", "time");
private static final Set<String> LITERAL_PREFIXES
= Set.of("n", "x", "varbyte", "bx", "bytea", "date", "time", "timestamp", "zone");
private static final Set<String> FETCH_BIGRAMS
= Set.of("first", "next");
private static final Set<String> CURRENT_BIGRAMS
= Set.of("date", "time", "timestamp");

private static final String PUNCTUATION = "=><!+-*/()',|&`";

Expand Down Expand Up @@ -155,8 +159,8 @@ public static String renderWhereStringTemplate(
// lookahead is truly necessary, use the lookahead() function provided below.

final String symbols = PUNCTUATION + WHITESPACE + dialect.openQuote() + dialect.closeQuote();
final StringTokenizer tokens = new StringTokenizer( sql, symbols, true );
final StringBuilder result = new StringBuilder();
final var tokens = new StringTokenizer( sql, symbols, true );
final var result = new StringBuilder();

boolean quoted = false;
boolean quotedIdentifier = false;
Expand All @@ -166,6 +170,8 @@ public static String renderWhereStringTemplate(
boolean inExtractOrTrim = false;
boolean inCast = false;
boolean afterCastAs = false;
boolean afterFetch = false;
boolean afterCurrent = false;

boolean hasMore = tokens.hasMoreTokens();
String nextToken = hasMore ? tokens.nextToken() : null;
Expand Down Expand Up @@ -215,10 +221,17 @@ else if ( quotedIdentifier && dialect.closeQuote()==token.charAt(0) ) {
}
}

final boolean quotedOrWhitespace =
quoted || quotedIdentifier || isQuoteCharacter
|| token.isBlank();
if ( quotedOrWhitespace ) {
final boolean isWhitespace = token.isBlank();

// handle bigrams here
final boolean wasAfterFetch = afterFetch;
afterFetch = afterFetch && isWhitespace;
final boolean wasAfterCurrent = afterCurrent;
afterCurrent = afterCurrent && isWhitespace;

final boolean isQuoted =
quoted || quotedIdentifier || isQuoteCharacter;
if ( isQuoted || isWhitespace ) {
result.append( token );
}
else if ( beforeTable ) {
Expand All @@ -227,51 +240,76 @@ else if ( beforeTable ) {
afterFromTable = true;
}
else if ( afterFromTable ) {
if ( !"as".equals(lcToken) ) {
afterFromTable = false;
afterFromTable = "as".equals(lcToken);
result.append(token);
}
else if ( "(".equals(lcToken) ) {
result.append(token);
}
else if ( ")".equals(lcToken) ) {
inExtractOrTrim = false;
inCast = false;
afterCastAs = false;
result.append(token);
}
else if ( ",".equals(lcToken) ) {
if ( inFromClause ) {
beforeTable = true;
}
result.append(token);
}
else if ( isNamedParameter(token) ) {
else if ( lcToken.length()==1 && symbols.contains(lcToken) ) {
result.append(token);
}
else if ( FUNCTION_WITH_FROM_KEYWORDS.contains(lcToken) && "(".equals( nextToken ) ) {
else if ( BEFORE_TABLE_KEYWORDS.contains(lcToken) ) {
if ( !inExtractOrTrim ) {
beforeTable = true;
inFromClause = true;
}
result.append(token);
inExtractOrTrim = true;
}
else if ( "cast".equals( lcToken ) ) {
else if ( inFromClause || afterCastAs ) {
// Don't want to append alias to:
// 1. tokens inside the FROM clause
// 2. type names after 'CAST(expression AS'
result.append( token );
inCast = true;
}
else if ( inCast && ("as".equals( lcToken ) || afterCastAs) ) {
else if ( isNamedParameter(token) ) {
result.append(token);
}
else if ( "as".equals( lcToken ) ) {
result.append( token );
afterCastAs = true;
afterCastAs = inCast;
}
else if ( !inFromClause // don't want to append alias to tokens inside the FROM clause
&& isIdentifier( token )
&& !isFunctionOrKeyword( lcToken, nextToken, dialect, typeConfiguration )
&& !isLiteral( lcToken, nextToken, sql, symbols, tokens ) ) {
result.append(alias)
.append('.')
.append( dialect.quote(token) );
else if ( isFetch( dialect, lcToken ) ) {
result.append( token );
afterFetch = true;
}
else {
if ( ")".equals( lcToken) ) {
inExtractOrTrim = false;
inCast = false;
afterCastAs = false;
}
else if ( !inExtractOrTrim
&& BEFORE_TABLE_KEYWORDS.contains(lcToken) ) {
beforeTable = true;
inFromClause = true;
}
else if ( inFromClause && ",".equals(lcToken) ) {
beforeTable = true;
else if ( wasAfterFetch && FETCH_BIGRAMS.contains( lcToken ) ) {
result.append( token );
}
else if ( isCurrent( lcToken, nextToken, sql, symbols, tokens ) ) {
result.append(token);
afterCurrent = true;
}
else if ( isBoolean( lcToken ) ) {
result.append( dialect.toBooleanValueString( parseBoolean( token ) ) );
}
else if ( isFunctionCall( nextToken, sql, symbols, tokens ) ) {
result.append(token);
if ( FUNCTION_WITH_FROM_KEYWORDS.contains( lcToken ) ) {
inExtractOrTrim = true;
}
if ( isBoolean( token ) ) {
token = dialect.toBooleanValueString( parseBoolean( token ) );
if ( "cast".equals( lcToken ) ) {
inCast = true;
}
}
else if ( isAliasableIdentifier( token, lcToken, nextToken,
sql, symbols, tokens, wasAfterCurrent,
dialect, typeConfiguration ) ) {
result.append(alias).append('.').append( dialect.quote(token) );
}
else {
result.append(token);
}

Expand All @@ -286,6 +324,42 @@ else if ( inFromClause && ",".equals(lcToken) ) {
return result.toString();
}

private static boolean isAliasableIdentifier(
String token, String lcToken, String nextToken,
String sql, String symbols, StringTokenizer tokens,
boolean wasAfterCurrent,
Dialect dialect, TypeConfiguration typeConfiguration) {
return isUnqualifiedIdentifier( token )
&& !isKeyword( lcToken, wasAfterCurrent, dialect, typeConfiguration )
&& !isLiteral( lcToken, nextToken, sql, symbols, tokens );
}

private static boolean isFunctionCall(
String nextToken,
String sql, String symbols, StringTokenizer tokens) {
if ( nextToken == null ) {
return false;
}
else {
return nextToken.isBlank()
? lookPastBlankTokens( sql, symbols, tokens, 1, "("::equals )
: "(".equals( nextToken );
}
}

private static boolean isCurrent(
String lcToken, String nextToken,
String sql, String symbols, StringTokenizer tokens) {
return "current".equals( lcToken )
&& nextToken.isBlank()
&& lookPastBlankTokens( sql, symbols, tokens, 1, CURRENT_BIGRAMS::contains );
}

private static boolean isFetch(Dialect dialect, String lcToken) {
return "fetch".equals( lcToken )
&& dialect.getKeywords().contains( "fetch" );
}

private static boolean endsWithDot(String token) {
return token != null && token.endsWith( "." );
}
Expand All @@ -301,7 +375,7 @@ else if ( LITERAL_PREFIXES.contains( lcToken ) ) {
// we need to look ahead in the token stream
// to find the first non-blank token
return lookPastBlankTokens( sqlWhereString, symbols, tokens, 1,
(nextToken) -> "'".equals(nextToken)
nextToken -> "'".equals(nextToken)
|| lcToken.equals("time") && "with".equals(nextToken)
|| lcToken.equals("timestamp") && "with".equals(nextToken)
|| lcToken.equals("time") && "zone".equals(nextToken) );
Expand All @@ -319,7 +393,7 @@ private static boolean lookPastBlankTokens(
String sqlWhereString, String symbols, StringTokenizer tokens,
@SuppressWarnings("SameParameterValue") int skip,
Function<String, Boolean> check) {
final StringTokenizer lookahead = lookahead( sqlWhereString, symbols, tokens, skip );
final var lookahead = lookahead( sqlWhereString, symbols, tokens, skip );
if ( lookahead.hasMoreTokens() ) {
String nextToken;
do {
Expand All @@ -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();
}
Expand Down Expand Up @@ -382,21 +455,18 @@ public static List<String> 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 )
Expand All @@ -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;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down