Skip to content

Commit 2fc6e3e

Browse files
authored
fix: preserve statement boundaries after PostgreSQL routines (#2650)
1 parent 2806656 commit 2fc6e3e

3 files changed

Lines changed: 276 additions & 7 deletions

File tree

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

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19248,17 +19248,18 @@ List<String> captureRest() {
1924819248
}
1924919249

1925019250
/**
19251-
* Reads the tokens of a function or procedure body.
19252-
* A function body can end in 2 ways:
19253-
* 1) BEGIN...END;
19254-
* 2) Postgres: $$...$$...;
19255-
*/
19251+
* Captures opaque routine declarations through their terminating semicolon.
19252+
* PostgreSQL AS string bodies can use ordinary, escape or dollar quotes.
19253+
* Strings and END tokens in parenthesized arguments are not body boundaries.
19254+
*/
1925619255

1925719256
JAVACODE
1925819257
List<String> captureFunctionBody() {
1925919258
List<String> tokens = new LinkedList<String>();
1926019259
Token tok;
1926119260
boolean foundEnd = false;
19261+
boolean bodyStarted = false;
19262+
int parentheses = 0;
1926219263
while(true) {
1926319264
tok = getToken(1);
1926419265
int l = tokens.size();
@@ -19267,19 +19268,45 @@ List<String> captureFunctionBody() {
1926719268
tokens.add(tok.image);
1926819269
}
1926919270
break;
19271+
} else if (parentheses == 0 && !bodyStarted && tok.kind == K_AS
19272+
&& getToken(2).kind == S_CHAR_LITERAL) {
19273+
tokens.add(getNextToken().image);
19274+
tokens.add(captureRoutineBodyLiteral());
19275+
foundEnd = true;
19276+
bodyStarted = true;
19277+
continue;
1927019278
} else if ( l>0 && ( tok.image.equals(".") || tokens.get(l-1).endsWith(".")) ) {
1927119279
tokens.set(l-1, tokens.get(l-1) + tok.image);
1927219280
} else {
1927319281
tokens.add(tok.image);
1927419282
}
19275-
foundEnd |= (tok.kind == K_END)
19276-
|| (tok.kind == S_CHAR_LITERAL && StringValue.getDollarQuoteDelimiter(tok.image) != null);
19283+
foundEnd |= parentheses == 0 && tok.kind == K_END;
19284+
if (parentheses == 0 && (tok.kind == K_AS || tok.kind == K_BEGIN)) bodyStarted = true;
19285+
if (tok.kind == OPENING_BRACKET) parentheses++;
19286+
if (tok.kind == CLOSING_BRACKET) parentheses--;
1927719287

1927819288
tok = getNextToken();
1927919289
}
1928019290
return tokens;
1928119291
}
1928219292

19293+
/**
19294+
* Keeps the separator required by PostgreSQL's continued string literals.
19295+
* The routine's legacy token list otherwise renders every gap as one space.
19296+
*/
19297+
JAVACODE
19298+
String captureRoutineBodyLiteral() {
19299+
String literal = getNextToken().image;
19300+
if (literal.startsWith("$") || !isAdjacentStringConcat()) {
19301+
return literal;
19302+
}
19303+
StringBuilder body = new StringBuilder(literal);
19304+
while (isAdjacentStringConcat() && getToken(1).image.startsWith("'")) {
19305+
body.append('\n').append(getNextToken().image);
19306+
}
19307+
return body.toString();
19308+
}
19309+
1928319310
/**
1928419311
* Reads the tokens of a Postgres dollar quoted string,
1928519312
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
@@ -896,6 +896,15 @@ Enable the PostgreSQL dialect when parsing a script containing a ``DO`` block:
896896
Semicolons and SQL statements inside the body remain part of its string literal;
897897
they do not split the surrounding script into additional statements.
898898

899+
PostgreSQL ``CREATE FUNCTION`` and ``CREATE PROCEDURE`` declarations also preserve
900+
``AS '...'``, ``AS E'...'`` and dollar-quoted bodies as opaque text. Their
901+
``getFunctionDeclarationParts()`` list retains the body and trailing options,
902+
while a following statement is parsed separately. Newline-separated body string
903+
continuations retain the newline needed when the declaration is rendered again.
904+
Use ``Dialect.POSTGRESQL`` for tagged dollar quotes and PostgreSQL-specific DDL
905+
such as schema-qualified index collations and operator classes. This support
906+
does not validate PL/pgSQL source or build an AST for statements inside the body.
907+
899908
With ``Dialect.POSTGRESQL``, ``#`` terminates an unquoted identifier, so JSON
900909
operators such as ``js#>>'{a}'`` and ``js#>'{a}'`` work without surrounding
901910
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)