Skip to content

Commit 154bb24

Browse files
hayssamsclaude
andcommitted
Support DuckDB 2.0 triggers with transition tables and a statement body
DuckDB writes CREATE TRIGGER ... REFERENCING OLD TABLE AS o NEW TABLE AS n FOR EACH STATEMENT <statement>, i.e. the standard header with a SQL statement instead of EXECUTE FUNCTION. The standard-form parser now accepts a statement body, and such triggers are recognised and rendered as standard rather than MySQL-style triggers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqinwHBKuAtPEmtXr3b5P2
1 parent 1df0033 commit 154bb24

3 files changed

Lines changed: 56 additions & 9 deletions

File tree

src/main/java/net/sf/jsqlparser/statement/create/trigger/CreateTrigger.java

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,9 @@ public void visit(Consumer<Table> tables, Consumer<Expression> expressions) {
241241
}
242242

243243
public void appendTo(StringBuilder sql, Consumer<Expression> visitor) {
244-
if (routine == null) {
244+
// MySQL's shorter form has neither a routine nor any of the standard clauses
245+
if (routine == null && transitionRelations.isEmpty() && !constraint
246+
&& orientation != Orientation.STATEMENT) {
245247
sql.append(mysqlSql());
246248
return;
247249
}
@@ -252,8 +254,13 @@ public void appendTo(StringBuilder sql, Consumer<Expression> visitor) {
252254
visitor.accept(whenExpression);
253255
sql.append(')');
254256
}
255-
sql.append(" EXECUTE ").append(executeKeyword).append(' ');
256-
visitor.accept(routine);
257+
if (routine != null) {
258+
sql.append(" EXECUTE ").append(executeKeyword).append(' ');
259+
visitor.accept(routine);
260+
} else {
261+
// DuckDB 2.0 runs a statement rather than a function
262+
sql.append(' ').append(body);
263+
}
257264
}
258265

259266
private void appendPostgreSqlHeader(StringBuilder sql, Consumer<Expression> visitor) {

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

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1515,6 +1515,8 @@ public class CCJSqlParser extends AbstractJSqlParser<CCJSqlParser> {
15151515
Token token = getToken(i);
15161516
if (token.kind == EOF || ";".equals(token.image) || token.kind == K_BEGIN) { return false; }
15171517
if (token.kind == K_EXECUTE && (getToken(i + 1).kind == K_FUNCTION || getToken(i + 1).kind == K_PROCEDURE)) { return true; }
1518+
// DuckDB 2.0 uses the standard header with a statement body instead of EXECUTE
1519+
if ("REFERENCING".equalsIgnoreCase(token.image) || token.kind == K_STATEMENT) { return true; }
15181520
}
15191521
}
15201522

@@ -17092,6 +17094,7 @@ CreateTrigger PostgreSqlTrigger(boolean replace):
1709217094
{
1709317095
CreateTrigger result = new CreateTrigger(); String name; Table table; CreateTrigger.Timing timing;
1709417096
TriggerEvent event; TransitionRelation transition; Expression expression; Token token; boolean negative = false;
17097+
Statement body = null;
1709517098
Function routine = new Function(); ExpressionList<Expression> arguments = new ExpressionList<Expression>();
1709617099
}
1709717100
{
@@ -17104,7 +17107,8 @@ CreateTrigger PostgreSqlTrigger(boolean replace):
1710417107
event=PostgreSqlTriggerEvent() { result.getEvents().add(event); }
1710517108
( <K_OR> event=PostgreSqlTriggerEvent() { result.getEvents().add(event); } )*
1710617109
<K_ON> table=Table() { result.setTable(table); }
17107-
[ <K_FROM> table=Table() { result.setReferencedTable(table); } ]
17110+
// greedy: FROM here names the referenced table, it never starts the trigger body
17111+
[ LOOKAHEAD(2) <K_FROM> table=Table() { result.setReferencedTable(table); } ]
1710817112
[ [ <K_NOT> { negative = true; } ] <K_DEFERRABLE> { result.getConstraintAttributes().setDeferrable(!negative); } ]
1710917113
[ LOOKAHEAD({ isAccessKeywordAhead("INITIALLY") })
1711017114
AccessKeyword("INITIALLY") ( token=<S_IDENTIFIER> )
@@ -17117,12 +17121,18 @@ CreateTrigger PostgreSqlTrigger(boolean replace):
1711717121
( <K_ROW> { result.setOrientation(CreateTrigger.Orientation.ROW); }
1711817122
| <K_STATEMENT> { result.setOrientation(CreateTrigger.Orientation.STATEMENT); } ) ]
1711917123
[ <K_WHEN> "(" expression=Expression() ")" { result.setWhenExpression(expression); } ]
17120-
<K_EXECUTE> ( <K_FUNCTION> | <K_PROCEDURE> { result.setExecuteKeyword(CreateTrigger.ExecuteKeyword.PROCEDURE); } )
17121-
name=AccessQualifiedName() { routine.setName(name); }
17122-
"(" [ expression=PostgreSqlTriggerArgument() { arguments.add(expression); }
17123-
( "," expression=PostgreSqlTriggerArgument() { arguments.add(expression); } )* ] ")"
17124+
(
17125+
LOOKAHEAD(2)
17126+
<K_EXECUTE> ( <K_FUNCTION> | <K_PROCEDURE> { result.setExecuteKeyword(CreateTrigger.ExecuteKeyword.PROCEDURE); } )
17127+
name=AccessQualifiedName() { routine.setName(name); }
17128+
"(" [ expression=PostgreSqlTriggerArgument() { arguments.add(expression); }
17129+
( "," expression=PostgreSqlTriggerArgument() { arguments.add(expression); } )* ] ")"
17130+
{ routine.setParameters(arguments); result.setRoutine(routine); }
17131+
|
17132+
// DuckDB 2.0 runs a statement rather than a function
17133+
body=SingleStatement() { result.setBody(body); }
17134+
)
1712417135
{
17125-
routine.setParameters(arguments); result.setRoutine(routine);
1712617136
requireAccessSyntax(!result.isConstraint() || !replace && result.getTiming() == CreateTrigger.Timing.AFTER
1712717137
&& result.getOrientation() == CreateTrigger.Orientation.ROW, "A constraint trigger requires AFTER FOR EACH ROW without OR REPLACE");
1712817138
requireAccessSyntax(result.isConstraint() || result.getReferencedTable() == null

src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import net.sf.jsqlparser.statement.CopyStatement;
2626
import net.sf.jsqlparser.statement.create.macro.CreateMacro;
2727
import net.sf.jsqlparser.statement.create.extension.CreateExtensionRepository;
28+
import net.sf.jsqlparser.statement.create.trigger.CreateTrigger;
2829
import net.sf.jsqlparser.test.TestUtils;
2930
import org.junit.jupiter.api.Assertions;
3031
import org.junit.jupiter.api.Test;
@@ -527,4 +528,33 @@ void testCreatePostgresExtensionStillParses() throws JSQLParserException {
527528
String sqlStr = "CREATE EXTENSION IF NOT EXISTS hstore WITH SCHEMA public CASCADE";
528529
TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true);
529530
}
531+
532+
@Test
533+
void testCreateTriggerWithTransitionTablesAndStatementBody() throws JSQLParserException {
534+
String sqlStr = "CREATE TRIGGER trg_audit AFTER UPDATE ON target "
535+
+ "REFERENCING OLD TABLE AS o NEW TABLE AS n FOR EACH STATEMENT "
536+
+ "INSERT INTO audit SELECT n.id, o.val, n.val FROM o JOIN n ON o.id = n.id";
537+
CreateTrigger createTrigger =
538+
(CreateTrigger) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true);
539+
540+
Assertions.assertEquals(2, createTrigger.getTransitionRelations().size());
541+
Assertions.assertEquals(CreateTrigger.Orientation.STATEMENT,
542+
createTrigger.getOrientation());
543+
Assertions.assertNotNull(createTrigger.getBody());
544+
}
545+
546+
@Test
547+
void testCreateTriggerBeforeDeleteWithStatementBody() throws JSQLParserException {
548+
String sqlStr = "CREATE TRIGGER trg BEFORE DELETE ON t REFERENCING OLD TABLE AS o "
549+
+ "FOR EACH STATEMENT DELETE FROM audit WHERE id IN (SELECT id FROM o)";
550+
TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true);
551+
}
552+
553+
@Test
554+
void testPostgreSqlTriggerStillParses() throws JSQLParserException {
555+
String sqlStr = "CREATE TRIGGER trg AFTER UPDATE ON target "
556+
+ "REFERENCING OLD TABLE AS o NEW TABLE AS n FOR EACH STATEMENT "
557+
+ "EXECUTE FUNCTION f()";
558+
TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true);
559+
}
530560
}

0 commit comments

Comments
 (0)