@@ -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>"{"</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>"}"</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>{\...\}</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