Skip to content

Commit 736d50c

Browse files
committed
#909 JDBC 4.5 support: "disable escape processing" JDBC escape
1 parent 45161de commit 736d50c

3 files changed

Lines changed: 336 additions & 50 deletions

File tree

devdoc/jdp/jdp-2026-03-jdbc-escape-to-disable-escape-processing.adoc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55

66
== Status
77

8-
* Draft
9-
* Proposed for: Jaybird 5.0.12, Jaybird 6.0.5, Jaybird 7
8+
* Published: 2026-03-10
9+
* Implemented in: Jaybird 5.0.12, Jaybird 6.0.5, Jaybird 7
1010

1111
== Type
1212

src/main/org/firebirdsql/jdbc/escape/FBEscapedParser.java

Lines changed: 236 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,14 @@ public final class FBEscapedParser {
4747
private static final String ESCAPE_ESCAPE_KEYWORD = "escape";
4848
private static final String ESCAPE_OUTERJOIN_KEYWORD = "oj";
4949
private static final String ESCAPE_LIMIT_KEYWORD = "limit";
50+
// NOTE: The "disable escape processing" escape ({\ ... \}) a.k.a. "disabled escape" is handled separately
5051

5152
/**
52-
* Regular expression to check for existence of JDBC escapes, is used to
53-
* stop processing the entire SQL statement if it does not contain any of
54-
* the escape introducers.
53+
* Regular expression to check for existence of JDBC escapes. It is used to skip processing the entire SQL statement
54+
* if it does not contain any of the escape introducers.
5555
*/
5656
private static final Pattern CHECK_ESCAPE_PATTERN = Pattern.compile(
57-
"\\{(?:(?:\\?\\s*=\\s*)?call|d|ts?|escape|fn|oj|limit)\\s", Pattern.CASE_INSENSITIVE);
57+
"\\{(?:(?:(?:\\?\\s*=\\s*)?call|d|ts?|escape|fn|oj|limit)\\s|\\\\)", Pattern.CASE_INSENSITIVE);
5858

5959
private static final String LIMIT_OFFSET_CLAUSE = " offset ";
6060

@@ -68,13 +68,9 @@ private FBEscapedParser(AbstractVersion firebirdVersion, QuoteStrategy quoteStra
6868

6969
/**
7070
* Get an instance of the escape parser for the specified Firebird version and quote strategy.
71-
* <p>
72-
* The implementation may substitute the passed {@code firebirdVersion} with
73-
* a {@link org.firebirdsql.jaybird.util.BasicVersion} with the same {@code major.minor}.
74-
* </p>
7571
*
7672
* @param firebirdVersion
77-
* firebird version
73+
* Firebird version
7874
* @param quoteStrategy
7975
* quote strategy
8076
* @since 7
@@ -123,11 +119,11 @@ public String toNative(String sql) throws SQLException {
123119
// Note initialising to 8 as that was the minimum size in Oracle Java at some point, and we (usually) need less
124120
// than the default of 16
125121
final var bufferStack = new ArrayDeque<StringBuilder>(8);
126-
final int sqlLength = sql.length();
127-
var buffer = new StringBuilder(sqlLength);
122+
final var charAccess = new CharAccess(sql);
123+
var buffer = new StringBuilder(sql.length());
128124

129-
for (int i = 0; i < sqlLength; i++) {
130-
char currentChar = sql.charAt(i);
125+
while (charAccess.hasNext()) {
126+
char currentChar = charAccess.next(state);
131127
state = state.nextState(currentChar);
132128
switch (state) {
133129
case INITIAL_STATE -> {
@@ -137,8 +133,14 @@ public String toNative(String sql) throws SQLException {
137133
START_LINE_COMMENT, LINE_COMMENT, START_BLOCK_COMMENT, BLOCK_COMMENT, END_BLOCK_COMMENT,
138134
POSSIBLE_Q_LITERAL_ENTER -> buffer.append(currentChar);
139135
case ESCAPE_ENTER_STATE -> {
140-
bufferStack.push(buffer);
141-
buffer = new StringBuilder();
136+
if (charAccess.hasNext() && charAccess.peekNext(state) == '\\') {
137+
// Disable escape processing
138+
charAccess.skipNext();
139+
state = processDisabledEscape(charAccess, buffer);
140+
} else {
141+
bufferStack.push(buffer);
142+
buffer = new StringBuilder();
143+
}
142144
}
143145
case ESCAPE_EXIT_STATE -> {
144146
if (bufferStack.isEmpty()) {
@@ -150,21 +152,19 @@ public String toNative(String sql) throws SQLException {
150152
}
151153
case Q_LITERAL_START -> {
152154
buffer.append(currentChar);
153-
if (++i >= sqlLength) {
154-
throw new FBSQLParseException("Unexpected end of string at parser state " + state);
155-
}
156-
final char alternateStartChar = sql.charAt(i);
155+
final char alternateStartChar = charAccess.next(state);
157156
buffer.append(alternateStartChar);
158-
final char alternateEndChar = qLiteralEndChar(alternateStartChar);
159-
for (i++; i < sqlLength; i++) {
160-
currentChar = sql.charAt(i);
157+
var qLiteralParser = new QLiteralParser(alternateStartChar);
158+
while (charAccess.hasNext()) {
159+
currentChar = charAccess.next(state);
161160
buffer.append(currentChar);
162-
if (currentChar == alternateEndChar && i + 1 < sqlLength && sql.charAt(i + 1) == '\'') {
163-
state = ParserState.Q_LITERAL_END;
161+
162+
if (qLiteralParser.isQLiteralEnd(currentChar)) {
163+
state = ParserState.NORMAL_STATE;
164164
break;
165165
}
166166
}
167-
if (i == sqlLength) {
167+
if (state == ParserState.Q_LITERAL_START) {
168168
throw new FBSQLParseException("Unexpected end of string at parser state " + state);
169169
}
170170
}
@@ -177,16 +177,6 @@ public String toNative(String sql) throws SQLException {
177177
return buffer.toString();
178178
}
179179

180-
private static char qLiteralEndChar(char startChar) {
181-
return switch (startChar) {
182-
case '(' -> ')';
183-
case '{' -> '}';
184-
case '[' -> ']';
185-
case '<' -> '>';
186-
default -> startChar;
187-
};
188-
}
189-
190180
private static void processEscaped(final String escaped, final StringBuilder keyword, final StringBuilder payload) {
191181
assert keyword.isEmpty() && payload.isEmpty() : "StringBuilders keyword and payload should be empty";
192182

@@ -375,6 +365,100 @@ private static void convertEscapedFunction(final StringBuilder target, final Cha
375365
target.append(templateResult != null ? templateResult : escapedFunction);
376366
}
377367

368+
private static ParserState processDisabledEscape(CharAccess charAccess, StringBuilder buffer)
369+
throws FBSQLParseException {
370+
ParserState state = ParserState.NORMAL_STATE;
371+
372+
boolean inEscape = true;
373+
while (charAccess.hasNext()) {
374+
char currentChar = charAccess.next(state, ParserState.DISABLED_ESCAPE);
375+
if (currentChar == '\\') {
376+
currentChar = charAccess.next(state, ParserState.DISABLED_ESCAPE);
377+
if (currentChar == '}'
378+
&& (state == ParserState.NORMAL_STATE || state == ParserState.POSSIBLE_Q_LITERAL_ENTER)) {
379+
// End of disabled escape
380+
inEscape = false;
381+
break;
382+
}
383+
checkEscapeInDisabledEscape(currentChar, charAccess.position(), state);
384+
}
385+
state = nextDisabledEscapeParserState(state, currentChar);
386+
switch (state) {
387+
case NORMAL_STATE, LITERAL_STATE, DELIMITED_IDENTIFIER,
388+
START_LINE_COMMENT, LINE_COMMENT, START_BLOCK_COMMENT, BLOCK_COMMENT, END_BLOCK_COMMENT,
389+
POSSIBLE_Q_LITERAL_ENTER -> buffer.append(currentChar);
390+
case Q_LITERAL_START -> {
391+
buffer.append(currentChar);
392+
currentChar = charAccess.next(state, ParserState.DISABLED_ESCAPE);
393+
if (currentChar == '\\') {
394+
currentChar = charAccess.next(state, ParserState.DISABLED_ESCAPE);
395+
checkEscapeInDisabledEscape(currentChar, charAccess.position(), state);
396+
}
397+
buffer.append(currentChar);
398+
var qLiteralParser = new QLiteralParser(currentChar);
399+
while (charAccess.hasNext()) {
400+
currentChar = charAccess.next(state, ParserState.DISABLED_ESCAPE);
401+
if (currentChar == '\\') {
402+
currentChar = charAccess.next(state, ParserState.DISABLED_ESCAPE);
403+
checkEscapeInDisabledEscape(currentChar, charAccess.position(), state);
404+
}
405+
buffer.append(currentChar);
406+
407+
if (qLiteralParser.isQLiteralEnd(currentChar)) {
408+
state = ParserState.NORMAL_STATE;
409+
break;
410+
}
411+
}
412+
if (state == ParserState.Q_LITERAL_START) {
413+
throw new FBSQLParseException(
414+
"Unexpected end of string at parser state " + state + " at " + ParserState.DISABLED_ESCAPE);
415+
}
416+
}
417+
default -> throw new FBSQLParseException("Unexpected parser state " + state + " at "
418+
+ ParserState.DISABLED_ESCAPE);
419+
}
420+
}
421+
422+
if (inEscape) {
423+
throw new FBSQLParseException("Unexpected end of string at parser state " + state + " at "
424+
+ ParserState.DISABLED_ESCAPE);
425+
}
426+
427+
return state;
428+
}
429+
430+
private static void checkEscapeInDisabledEscape(char ch, int position, ParserState state)
431+
throws FBSQLParseException {
432+
if (ch == '}') {
433+
// This only covers cases within a comment, literal or quoted identifier (see jdp-2026-03); the callers
434+
// handle the case where '}' is valid and does end the escape.
435+
throw new FBSQLParseException("Unescaped backslash: occurrence of unescaped \\} in comment, literal, or "
436+
+ "quoted identifier is not valid");
437+
} else if (ch != '\\') {
438+
throw new FBSQLParseException("Unescaped backslash at position " + (position - 1) + " at parser state "
439+
+ state + " at " + ParserState.DISABLED_ESCAPE);
440+
}
441+
}
442+
443+
/**
444+
* Remaps normal parser state values to parser state values during disabled escape processing.
445+
*
446+
* @param currentState
447+
* current parser state
448+
* @param inputChar
449+
* character for next state
450+
* @return state for disabled escape processing
451+
*/
452+
private static ParserState nextDisabledEscapeParserState(ParserState currentState, char inputChar)
453+
throws FBSQLParseException {
454+
ParserState nextState = currentState.nextState(inputChar);
455+
return switch (nextState) {
456+
// No JDBC escape processing inside disabled escape
457+
case ESCAPE_ENTER_STATE, ESCAPE_EXIT_STATE -> ParserState.NORMAL_STATE;
458+
default -> nextState;
459+
};
460+
}
461+
378462
private enum ParserState {
379463
/**
380464
* Initial parser state (to ignore leading whitespace)
@@ -422,7 +506,7 @@ protected ParserState nextState(char inputChar) {
422506
}
423507
},
424508
/**
425-
* Start of JDBC escape ({ character encountered).
509+
* Start of JDBC escape (<code>"&#123;"</code> character encountered).
426510
*/
427511
ESCAPE_ENTER_STATE {
428512
@Override
@@ -445,14 +529,15 @@ protected ParserState nextState(char inputChar) throws FBSQLParseException {
445529
'o', 'O',
446530
// start of {limit ...}
447531
'l', 'L' -> NORMAL_STATE;
532+
// NOTE: Escape disabled ({\ ... \}) is handled separately
448533
//@formatter:on
449534
default -> throw new FBSQLParseException(
450535
"Unexpected first character inside JDBC escape: " + inputChar);
451536
};
452537
}
453538
},
454539
/**
455-
* End of JDBC escape (} character encountered)
540+
* End of JDBC escape (<code>"&#125;"</code> character encountered)
456541
*/
457542
ESCAPE_EXIT_STATE {
458543
@Override
@@ -524,13 +609,16 @@ protected ParserState nextState(char inputChar) throws FBSQLParseException {
524609
throw new FBSQLParseException("Q-literal handling needs to be performed separately");
525610
}
526611
},
527-
Q_LITERAL_END {
612+
/**
613+
* Processing the "disabled escape" (<code>&#123;\...\&#125;</code>).
614+
* <p>
615+
* This state is not used during parsing, but only used for reporting a state name in exception messages.
616+
* </p>
617+
*/
618+
DISABLED_ESCAPE {
528619
@Override
529620
protected ParserState nextState(char inputChar) throws FBSQLParseException {
530-
if (inputChar != '\'') {
531-
throw new FBSQLParseException("Invalid char " + inputChar + " for state Q_LITERAL_END");
532-
}
533-
return NORMAL_STATE;
621+
throw new FBSQLParseException("Disabled escape handling needs to be performed separately");
534622
}
535623
};
536624

@@ -545,4 +633,110 @@ protected ParserState nextState(char inputChar) throws FBSQLParseException {
545633
*/
546634
protected abstract ParserState nextState(char inputChar) throws FBSQLParseException;
547635
}
636+
637+
private static final class QLiteralParser {
638+
639+
private final char endChar;
640+
private boolean possibleEnd = false;
641+
642+
QLiteralParser(char startChar) {
643+
this.endChar = endChar(startChar);
644+
}
645+
646+
boolean isQLiteralEnd(char ch) {
647+
if (possibleEnd && ch == '\'') return true;
648+
possibleEnd = ch == endChar;
649+
return false;
650+
}
651+
652+
private static char endChar(char startChar) {
653+
return switch (startChar) {
654+
case '(' -> ')';
655+
case '{' -> '}';
656+
case '[' -> ']';
657+
case '<' -> '>';
658+
default -> startChar;
659+
};
660+
}
661+
662+
}
663+
664+
private static final class CharAccess {
665+
666+
private final String sql;
667+
private final int length;
668+
// Position of the next character to be returned
669+
private int pos;
670+
671+
CharAccess(String sql) {
672+
this(sql, 0);
673+
}
674+
675+
CharAccess(String sql, int startPosition) {
676+
if (startPosition < 0) {
677+
throw new IllegalArgumentException("startPosition must be >= 0, was " + startPosition);
678+
}
679+
this.sql = sql;
680+
length = sql.length();
681+
pos = startPosition;
682+
}
683+
684+
boolean hasNext() {
685+
return pos < length;
686+
}
687+
688+
/**
689+
* Skip the next character.
690+
* <p>
691+
* Attempts to position beyond the end of the contained string will position at the end of string.
692+
* </p>
693+
*/
694+
void skipNext() {
695+
pos = Math.min(pos + 1, length);
696+
}
697+
698+
void checkPosition(ParserState state) throws FBSQLParseException {
699+
if (pos >= length) {
700+
throw new FBSQLParseException("Unexpected end of string at parser state " + state);
701+
}
702+
}
703+
704+
void checkPosition(ParserState state, ParserState outerState) throws FBSQLParseException {
705+
if (pos >= length) {
706+
throw new FBSQLParseException(
707+
"Unexpected end of string at parser state " + state + " at " + outerState);
708+
}
709+
}
710+
711+
char next(ParserState state) throws FBSQLParseException {
712+
checkPosition(state);
713+
return sql.charAt(pos++);
714+
}
715+
716+
char next(ParserState state, ParserState outerState) throws FBSQLParseException {
717+
checkPosition(state, outerState);
718+
return sql.charAt(pos++);
719+
}
720+
721+
char peekNext(ParserState state) throws FBSQLParseException {
722+
checkPosition(state);
723+
return sql.charAt(pos);
724+
}
725+
726+
/**
727+
* Position of the last returned character, or the position immediately before the next character.
728+
* <p>
729+
* The return value can be {@code -1} if {@link #CharAccess(String)} was called, or
730+
* {@link #CharAccess(String, int)} with {@code startPosition = 0}, and {@link #next(ParserState)} or
731+
* {@link #next(ParserState, ParserState)} was never called.
732+
* </p>
733+
*
734+
* @return position of the last character returned by {@code next}
735+
*/
736+
int position() {
737+
return pos - 1;
738+
}
739+
740+
}
741+
548742
}

0 commit comments

Comments
 (0)