Skip to content

Commit f386a48

Browse files
committed
fix: preserve statement boundaries after PostgreSQL routines
1 parent 0036c75 commit f386a48

3 files changed

Lines changed: 279 additions & 8 deletions

File tree

src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2927,8 +2927,10 @@ TOKEN:
29272927
// which contains the <SPECIAL_ESC>, then we will need to
29282928
// 1) break the <S_CHAR_LITERAL> at <SPECIAL_ESC> close it with a "'"
29292929
// 2) continue tokenizing after that <SPECIAL_ESC> with a new <S_CHAR_LITERAL> or any other Token
2930-
boolean allowEscape = configuration.getAsBoolean(Feature.allowBackslashEscapeCharacter);
29312930
String img = matchedToken.image;
2931+
// PostgreSQL's explicit E prefix enables escapes independently of ordinary strings.
2932+
boolean allowEscape = configuration.getAsBoolean(Feature.allowBackslashEscapeCharacter)
2933+
|| img.regionMatches(true, 0, "E'", 0, 2);
29322934
int pos;
29332935
if (!allowEscape) {
29342936
pos = indexOfSequence(img, "\\'");
@@ -19116,17 +19118,18 @@ List<String> captureRest() {
1911619118
}
1911719119

1911819120
/**
19119-
* Reads the tokens of a function or procedure body.
19120-
* A function body can end in 2 ways:
19121-
* 1) BEGIN...END;
19122-
* 2) Postgres: $$...$$...;
19123-
*/
19121+
* Captures opaque routine declarations through their terminating semicolon.
19122+
* PostgreSQL AS string bodies can use ordinary, escape or dollar quotes.
19123+
* Strings and END tokens in parenthesized arguments are not body boundaries.
19124+
*/
1912419125

1912519126
JAVACODE
1912619127
List<String> captureFunctionBody() {
1912719128
List<String> tokens = new LinkedList<String>();
1912819129
Token tok;
1912919130
boolean foundEnd = false;
19131+
boolean bodyStarted = false;
19132+
int parentheses = 0;
1913019133
while(true) {
1913119134
tok = getToken(1);
1913219135
int l = tokens.size();
@@ -19135,19 +19138,45 @@ List<String> captureFunctionBody() {
1913519138
tokens.add(tok.image);
1913619139
}
1913719140
break;
19141+
} else if (parentheses == 0 && !bodyStarted && tok.kind == K_AS
19142+
&& getToken(2).kind == S_CHAR_LITERAL) {
19143+
tokens.add(getNextToken().image);
19144+
tokens.add(captureRoutineBodyLiteral());
19145+
foundEnd = true;
19146+
bodyStarted = true;
19147+
continue;
1913819148
} else if ( l>0 && ( tok.image.equals(".") || tokens.get(l-1).endsWith(".")) ) {
1913919149
tokens.set(l-1, tokens.get(l-1) + tok.image);
1914019150
} else {
1914119151
tokens.add(tok.image);
1914219152
}
19143-
foundEnd |= (tok.kind == K_END)
19144-
|| (tok.kind == S_CHAR_LITERAL && StringValue.getDollarQuoteDelimiter(tok.image) != null);
19153+
foundEnd |= parentheses == 0 && tok.kind == K_END;
19154+
if (parentheses == 0 && (tok.kind == K_AS || tok.kind == K_BEGIN)) bodyStarted = true;
19155+
if (tok.kind == OPENING_BRACKET) parentheses++;
19156+
if (tok.kind == CLOSING_BRACKET) parentheses--;
1914519157

1914619158
tok = getNextToken();
1914719159
}
1914819160
return tokens;
1914919161
}
1915019162

19163+
/**
19164+
* Keeps the separator required by PostgreSQL's continued string literals.
19165+
* The routine's legacy token list otherwise renders every gap as one space.
19166+
*/
19167+
JAVACODE
19168+
String captureRoutineBodyLiteral() {
19169+
String literal = getNextToken().image;
19170+
if (literal.startsWith("$") || !isAdjacentStringConcat()) {
19171+
return literal;
19172+
}
19173+
StringBuilder body = new StringBuilder(literal);
19174+
while (isAdjacentStringConcat() && getToken(1).image.startsWith("'")) {
19175+
body.append('\n').append(getNextToken().image);
19176+
}
19177+
return body.toString();
19178+
}
19179+
1915119180
/**
1915219181
* Reads the tokens of a Postgres dollar quoted string,
1915319182
rebuilding the white space of the text based on each token's position and length

src/site/sphinx/usage.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -851,6 +851,15 @@ Enable the PostgreSQL dialect when parsing a script containing a ``DO`` block:
851851
Semicolons and SQL statements inside the body remain part of its string literal;
852852
they do not split the surrounding script into additional statements.
853853

854+
PostgreSQL ``CREATE FUNCTION`` and ``CREATE PROCEDURE`` declarations also preserve
855+
``AS '...'``, ``AS E'...'`` and dollar-quoted bodies as opaque text. Their
856+
``getFunctionDeclarationParts()`` list retains the body and trailing options,
857+
while a following statement is parsed separately. Newline-separated body string
858+
continuations retain the newline needed when the declaration is rendered again.
859+
Use ``Dialect.POSTGRESQL`` for tagged dollar quotes and PostgreSQL-specific DDL
860+
such as schema-qualified index collations and operator classes. This support
861+
does not validate PL/pgSQL source or build an AST for statements inside the body.
862+
854863
With ``Dialect.POSTGRESQL``, ``#`` terminates an unquoted identifier, so JSON
855864
operators such as ``js#>>'{a}'`` and ``js#>'{a}'`` work without surrounding
856865
spaces. Quote identifiers containing ``#``, for example ``"js#"``. Other
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
/*-
2+
* #%L
3+
* JSQLParser library
4+
* %%
5+
* Copyright (C) 2004 - 2026 JSQLParser
6+
* %%
7+
* Dual licensed under GNU LGPL 2.1 or Apache License 2.0
8+
* #L%
9+
*/
10+
package net.sf.jsqlparser.statement.create;
11+
12+
import static org.assertj.core.api.Assertions.assertThat;
13+
import static org.junit.jupiter.api.Assertions.assertThrows;
14+
15+
import java.util.List;
16+
import java.util.stream.Stream;
17+
import net.sf.jsqlparser.JSQLParserException;
18+
import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect;
19+
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
20+
import net.sf.jsqlparser.statement.CreateFunctionalStatement;
21+
import net.sf.jsqlparser.statement.Statement;
22+
import net.sf.jsqlparser.statement.Statements;
23+
import net.sf.jsqlparser.statement.create.function.CreateFunction;
24+
import net.sf.jsqlparser.statement.create.index.CreateIndex;
25+
import net.sf.jsqlparser.statement.create.procedure.CreateProcedure;
26+
import net.sf.jsqlparser.statement.drop.Drop;
27+
import net.sf.jsqlparser.statement.select.PlainSelect;
28+
import net.sf.jsqlparser.util.deparser.StatementDeParser;
29+
import org.junit.jupiter.api.Test;
30+
import org.junit.jupiter.params.ParameterizedTest;
31+
import org.junit.jupiter.params.provider.Arguments;
32+
import org.junit.jupiter.params.provider.MethodSource;
33+
import org.junit.jupiter.params.provider.ValueSource;
34+
35+
class PostgreSqlRoutineBoundaryTest {
36+
private static final String FOLLOWING = "SELECT 1994 AS after_routine";
37+
38+
static Stream<Arguments> quotedRoutines() {
39+
List<String> bodies = List.of(
40+
"'BEGIN PERFORM ''end; SELECT 9;''; END;'",
41+
"E'BEGIN\\nPERFORM 1;\\nEND;'",
42+
"E'BEGIN PERFORM \\'a;b\\'; END;'",
43+
"$$BEGIN PERFORM 'end;'; END;$$",
44+
"$Body$BEGIN PERFORM $inner$semi; $$ END$inner$; END;$Body$",
45+
"'BEGIN PERFORM 1;'\n' PERFORM 2; END;'",
46+
"E'BEGIN\\n'\n'PERFORM 1; END;'");
47+
return Stream.of("FUNCTION", "PROCEDURE").flatMap(kind -> bodies.stream().map(body -> {
48+
String returns = "FUNCTION".equals(kind) ? " RETURNS void" : "";
49+
return Arguments.of(kind + " " + body,
50+
"CREATE OR REPLACE " + kind + " boundary_routine()" + returns
51+
+ " AS " + body + " LANGUAGE plpgsql",
52+
body);
53+
}));
54+
}
55+
56+
static Stream<Arguments> routineOptions() {
57+
String body = "'BEGIN PERFORM 1; END;'";
58+
return Stream.of(
59+
Arguments.of("language before body",
60+
"CREATE FUNCTION boundary_routine() RETURNS void LANGUAGE 'plpgsql' AS "
61+
+ body,
62+
body),
63+
Arguments.of("options after body",
64+
"CREATE FUNCTION boundary_routine() RETURNS void AS " + body
65+
+ " LANGUAGE plpgsql VOLATILE COST 100 SET search_path TO public",
66+
body),
67+
Arguments.of("comments around body",
68+
"CREATE FUNCTION boundary_routine() RETURNS void AS /* END; */ " + body
69+
+ " -- next option\n LANGUAGE plpgsql /* ; SELECT 9; */",
70+
body),
71+
Arguments.of("continued body with comments",
72+
"CREATE FUNCTION boundary_routine() RETURNS void AS 'BEGIN PERFORM 1;'"
73+
+ " -- continuation\n ' END;' LANGUAGE plpgsql",
74+
"'BEGIN PERFORM 1;'\n' END;'"),
75+
Arguments.of("SQL function",
76+
"CREATE FUNCTION boundary_routine() RETURNS integer AS 'SELECT 42;' LANGUAGE sql",
77+
"'SELECT 42;'"),
78+
Arguments.of("internal function",
79+
"CREATE FUNCTION boundary_routine(integer) RETURNS integer"
80+
+ " AS 'int4abs' LANGUAGE internal IMMUTABLE STRICT",
81+
"'int4abs'"),
82+
Arguments.of("parameter defaults",
83+
"CREATE FUNCTION boundary_routine(x text DEFAULT $d$end; AS 'x'$d$,"
84+
+ " y integer DEFAULT CASE WHEN true THEN 1 ELSE 2 END)"
85+
+ " RETURNS void AS " + body + " LANGUAGE plpgsql",
86+
body),
87+
Arguments.of("quoted names",
88+
"CREATE FUNCTION public.\"end; routine\"(\"AS\" text DEFAULT 'END;')"
89+
+ " RETURNS void AS " + body + " LANGUAGE plpgsql",
90+
body));
91+
}
92+
93+
@ParameterizedTest(name = "{0}")
94+
@MethodSource({"quotedRoutines", "routineOptions"})
95+
void preservesQuotedRoutineAndFollowingStatement(String name, String declaration,
96+
String body) throws Exception {
97+
Statements statements = parse(declaration + "; " + FOLLOWING + ";");
98+
assertRoutineAndFollowing(statements, body);
99+
Class<?> expected = declaration.contains("PROCEDURE") ? CreateProcedure.class
100+
: CreateFunction.class;
101+
assertThat(statements.get(0)).isExactlyInstanceOf(expected);
102+
assertRoutineAndFollowing(parse(statements.toString()), body);
103+
StringBuilder deparsed = new StringBuilder();
104+
StatementDeParser deparser = new StatementDeParser(deparsed);
105+
for (Statement statement : statements) {
106+
statement.accept(deparser, null);
107+
deparsed.append(";\n");
108+
}
109+
assertRoutineAndFollowing(parse(deparsed.toString()), body);
110+
}
111+
112+
@ParameterizedTest
113+
@ValueSource(strings = {"'BEGIN RETURN; END;'", "E'BEGIN\\nRETURN;\\nEND;'",
114+
"$$BEGIN RETURN; END;$$"})
115+
void supportsUnambiguousBodiesWithoutDialect(String body) throws Exception {
116+
String sql = "CREATE FUNCTION f() RETURNS void AS " + body
117+
+ " LANGUAGE plpgsql; " + FOLLOWING + ";";
118+
assertRoutineAndFollowing(CCJSqlParserUtil.parseStatements(sql), body);
119+
}
120+
121+
@ParameterizedTest
122+
@ValueSource(strings = {"'BEGIN RETURN; END;'", "$tag$BEGIN RETURN; END;$tag$"})
123+
void acceptsBodyAtEndOfInputWithoutFinalSemicolon(String body) throws Exception {
124+
Statements statements = parse("CREATE FUNCTION f() RETURNS void AS " + body
125+
+ " LANGUAGE plpgsql");
126+
assertThat(statements).hasSize(1);
127+
assertThat(((CreateFunction) statements.get(0)).getFunctionDeclarationParts())
128+
.contains(body);
129+
}
130+
131+
@Test
132+
void keepsConsecutiveFunctionsProceduresAndDdlSeparate() throws Exception {
133+
String sql = "CREATE FUNCTION f() RETURNS integer AS 'SELECT 1;' LANGUAGE sql;"
134+
+ "CREATE PROCEDURE p() AS 'BEGIN NULL; END;' LANGUAGE plpgsql;"
135+
+ "CREATE FUNCTION g() RETURNS integer AS $g$ SELECT 2; $g$ LANGUAGE sql;"
136+
+ "CREATE TABLE after_routines(id integer); " + FOLLOWING + ";";
137+
Statements statements = parse(sql);
138+
assertThat(statements).hasSize(5);
139+
assertThat(statements.get(0)).isInstanceOf(CreateFunction.class);
140+
assertThat(statements.get(1)).isInstanceOf(CreateProcedure.class);
141+
assertThat(statements.get(2)).isInstanceOf(CreateFunction.class);
142+
assertThat(statements.get(3).toString()).startsWith("CREATE TABLE after_routines");
143+
assertThat(statements.get(4).toString()).isEqualTo(FOLLOWING);
144+
assertThat(parse(statements.toString())).hasSize(5);
145+
}
146+
147+
@Test
148+
void preservesTwoLiteralExternalFunctionBody() throws Exception {
149+
Statements statements = parse("CREATE FUNCTION f(integer) RETURNS integer"
150+
+ " AS 'library', 'symbol' LANGUAGE c; " + FOLLOWING + ";");
151+
assertRoutineAndFollowing(statements, "'library'");
152+
assertThat(((CreateFunction) statements.get(0)).getFunctionDeclarationParts())
153+
.containsSequence("'library'", ",", "'symbol'", "LANGUAGE", "c", ";");
154+
}
155+
156+
@Test
157+
void preservesReportedScriptAndIndexIssue1994() throws Exception {
158+
// The issue deliberately omits part of the PL/pgSQL body. It stays opaque.
159+
String sql = "DROP FUNCTION IF EXISTS \"fin\".\"restore_fund_data\"(\"in_fund_id\" int8);\n"
160+
+ "CREATE OR REPLACE FUNCTION \"fin\".\"restore_fund_data\"(\"in_fund_id\" int8)\n"
161+
+ "RETURNS \"pg_catalog\".\"varchar\" AS $BODY$\nDECLARE\nmessage VARCHAR;\n"
162+
+ "open rec_cur;\nclose proj_info_cur;\n********\nRETURN 'success';\nEND $BODY$\n"
163+
+ "LANGUAGE plpgsql VOLATILE COST 100;\n"
164+
+ "CREATE INDEX \"index_keyword\" ON \"inter\".\"inter_ti_rec\" USING btree "
165+
+ "(\"keyword\" COLLATE \"pg_catalog\".\"default\" "
166+
+ "\"pg_catalog\".\"text_ops\" ASC NULLS LAST);\n" + FOLLOWING + ";";
167+
Statements statements = parse(sql);
168+
assertThat(statements).hasSize(4);
169+
assertThat(statements.get(0)).isInstanceOf(Drop.class);
170+
CreateFunction function = (CreateFunction) statements.get(1);
171+
assertThat(function.getFunctionDeclarationParts()).anySatisfy(part -> assertThat(part)
172+
.startsWith("$BODY$").endsWith("$BODY$").contains("********", "RETURN 'success';"));
173+
CreateIndex index = (CreateIndex) statements.get(2);
174+
assertThat(index.getIndex().getColumns().get(0).getOperatorClass())
175+
.isEqualTo("\"pg_catalog\".\"text_ops\"");
176+
assertThat(statements.get(3).toString()).isEqualTo(FOLLOWING);
177+
assertThat(parse(statements.toString())).hasSize(4);
178+
}
179+
180+
@ParameterizedTest
181+
@ValueSource(strings = {"'BEGIN RETURN;", "$body$BEGIN RETURN; END;$other$"})
182+
void rejectsUnterminatedBody(String body) {
183+
assertThrows(JSQLParserException.class, () -> parse("CREATE FUNCTION f() RETURNS void AS "
184+
+ body + " LANGUAGE plpgsql; SELECT 1994;"));
185+
}
186+
187+
@Test
188+
void reportsInvalidStatementAfterQuotedRoutine() {
189+
String routine = "CREATE FUNCTION f() RETURNS integer AS 'SELECT 1;' LANGUAGE sql; ";
190+
assertThrows(JSQLParserException.class, () -> parse(routine + "SELEC 1994;"));
191+
}
192+
193+
@Test
194+
void escapePrefixDoesNotChangeOrdinaryStringHandling() throws Exception {
195+
String body = "e'BEGIN PERFORM \\'a;b\\'; END;'";
196+
Statements statements = parse("CREATE FUNCTION f() RETURNS void AS " + body
197+
+ " LANGUAGE plpgsql; SELECT 'C:\\' AS path, 'after' AS marker;");
198+
assertThat(statements).hasSize(2);
199+
assertThat(((CreateFunction) statements.get(0)).getFunctionDeclarationParts())
200+
.contains(body);
201+
assertThat(statements.get(1).toString())
202+
.isEqualTo("SELECT 'C:\\' AS path, 'after' AS marker");
203+
assertThat(parse(statements.toString())).hasSize(2);
204+
}
205+
206+
@Test
207+
void retainsLegacyUnquotedRoutineBodies() throws Exception {
208+
for (String sql : List.of("CREATE PROCEDURE p AS BEGIN NULL; END;",
209+
"CREATE PROCEDURE p() BEGIN SELECT 1 AS 'label'; SELECT 2; END;",
210+
"CREATE PROCEDURE p AS BEGIN SELECT $$text;$$; SELECT 2; END;",
211+
"CREATE FUNCTION f(x integer DEFAULT CASE WHEN 1 = 1 THEN 1 ELSE 2 END) "
212+
+ "RETURN integer AS BEGIN RETURN x; END;")) {
213+
Statements statements = CCJSqlParserUtil.parseStatements(sql + FOLLOWING + ";");
214+
assertThat(statements).hasSize(2);
215+
assertThat(statements.get(1).toString()).isEqualTo(FOLLOWING);
216+
}
217+
}
218+
219+
private static Statements parse(String sql) throws JSQLParserException {
220+
return CCJSqlParserUtil.parseStatements(sql,
221+
parser -> parser.withDialect(Dialect.POSTGRESQL).withUnsupportedStatements(false));
222+
}
223+
224+
private static void assertRoutineAndFollowing(Statements statements, String body) {
225+
assertThat(statements).hasSize(2);
226+
assertThat(statements.get(0)).isInstanceOf(CreateFunctionalStatement.class);
227+
CreateFunctionalStatement routine = (CreateFunctionalStatement) statements.get(0);
228+
assertThat(routine.getFunctionDeclarationParts()).contains(body)
229+
.doesNotContain("after_routine", "1994");
230+
assertThat(statements.get(1)).isInstanceOf(PlainSelect.class);
231+
assertThat(statements.get(1).toString()).isEqualTo(FOLLOWING);
232+
}
233+
}

0 commit comments

Comments
 (0)