|
| 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