From c2b5a6ae30a2fe274378961b1cae2e9b0f1ea58b Mon Sep 17 00:00:00 2001 From: Minjae Lee Date: Fri, 4 Sep 2026 19:06:10 +0900 Subject: [PATCH] feat(parser): model MySQL CREATE TRIGGER --- src/main/java/module-info.java | 1 + .../statement/StatementVisitor.java | 7 + .../statement/StatementVisitorAdapter.java | 6 + .../create/trigger/CreateTrigger.java | 122 ++++++++++++++++++ .../create/trigger/TriggerDefiner.java | 55 ++++++++ .../sf/jsqlparser/util/TablesNamesFinder.java | 15 +++ .../util/deparser/StatementDeParser.java | 7 + .../validator/StatementValidator.java | 13 ++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 83 +++++++++++- .../statement/UnsupportedStatementTest.java | 3 +- .../create/trigger/CreateTriggerTest.java | 74 +++++++++++ 11 files changed, 383 insertions(+), 3 deletions(-) create mode 100644 src/main/java/net/sf/jsqlparser/statement/create/trigger/CreateTrigger.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/create/trigger/TriggerDefiner.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/create/trigger/CreateTriggerTest.java diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 60ac14125..14ed418f0 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -34,6 +34,7 @@ exports net.sf.jsqlparser.statement.create.sequence; exports net.sf.jsqlparser.statement.create.synonym; exports net.sf.jsqlparser.statement.create.table; + exports net.sf.jsqlparser.statement.create.trigger; exports net.sf.jsqlparser.statement.create.user; exports net.sf.jsqlparser.statement.create.view; exports net.sf.jsqlparser.statement.delete; diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java index 9c4bdd3cd..fd787910d 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java @@ -25,6 +25,7 @@ import net.sf.jsqlparser.statement.create.sequence.CreateSequence; import net.sf.jsqlparser.statement.create.synonym.CreateSynonym; import net.sf.jsqlparser.statement.create.table.CreateTable; +import net.sf.jsqlparser.statement.create.trigger.CreateTrigger; import net.sf.jsqlparser.statement.create.user.CreateUser; import net.sf.jsqlparser.statement.create.view.AlterView; import net.sf.jsqlparser.statement.create.view.CreateView; @@ -146,6 +147,12 @@ default void visit(CreateTable createTable) { this.visit(createTable, null); } + T visit(CreateTrigger createTrigger, S context); + + default void visit(CreateTrigger createTrigger) { + this.visit(createTrigger, null); + } + T visit(CreateView createView, S context); default void visit(CreateView createView) { diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index ebc8b88e5..bc72947e9 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -29,6 +29,7 @@ import net.sf.jsqlparser.statement.create.sequence.CreateSequence; import net.sf.jsqlparser.statement.create.synonym.CreateSynonym; import net.sf.jsqlparser.statement.create.table.CreateTable; +import net.sf.jsqlparser.statement.create.trigger.CreateTrigger; import net.sf.jsqlparser.statement.create.user.CreateUser; import net.sf.jsqlparser.statement.create.view.AlterView; import net.sf.jsqlparser.statement.create.view.CreateView; @@ -285,6 +286,11 @@ public T visit(CreateTable createTable, S context) { return createTable.getTable().accept(fromItemVisitor, context); } + @Override + public T visit(CreateTrigger createTrigger, S context) { + return null; + } + @Override public T visit(CreateView createView, S context) { return null; diff --git a/src/main/java/net/sf/jsqlparser/statement/create/trigger/CreateTrigger.java b/src/main/java/net/sf/jsqlparser/statement/create/trigger/CreateTrigger.java new file mode 100644 index 000000000..22d7dc0ef --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/trigger/CreateTrigger.java @@ -0,0 +1,122 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.create.trigger; + +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitor; + +/** MySQL {@code CREATE TRIGGER} statement with a structured trigger definition. */ +public class CreateTrigger implements Statement { + + public enum Timing { + BEFORE, AFTER + } + + public enum Event { + INSERT, UPDATE, DELETE + } + + public enum Order { + FOLLOWS, PRECEDES + } + + private TriggerDefiner definer; + private Table trigger; + private Timing timing; + private Event event; + private Table table; + private Order order; + private Table otherTrigger; + private Statement body; + + public TriggerDefiner getDefiner() { + return definer; + } + + public void setDefiner(TriggerDefiner definer) { + this.definer = definer; + } + + public Table getTrigger() { + return trigger; + } + + public void setTrigger(Table trigger) { + this.trigger = trigger; + } + + public Timing getTiming() { + return timing; + } + + public void setTiming(Timing timing) { + this.timing = timing; + } + + public Event getEvent() { + return event; + } + + public void setEvent(Event event) { + this.event = event; + } + + public Table getTable() { + return table; + } + + public void setTable(Table table) { + this.table = table; + } + + public Order getOrder() { + return order; + } + + public void setOrder(Order order) { + this.order = order; + } + + public Table getOtherTrigger() { + return otherTrigger; + } + + public void setOtherTrigger(Table otherTrigger) { + this.otherTrigger = otherTrigger; + } + + public Statement getBody() { + return body; + } + + public void setBody(Statement body) { + this.body = body; + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder("CREATE "); + if (definer != null) { + builder.append("DEFINER = ").append(definer).append(" "); + } + builder.append("TRIGGER ").append(trigger).append(" ").append(timing).append(" ") + .append(event).append(" ON ").append(table).append(" FOR EACH ROW "); + if (order != null) { + builder.append(order).append(" ").append(otherTrigger).append(" "); + } + return builder.append(body).toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/create/trigger/TriggerDefiner.java b/src/main/java/net/sf/jsqlparser/statement/create/trigger/TriggerDefiner.java new file mode 100644 index 000000000..a4ba3e14e --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/trigger/TriggerDefiner.java @@ -0,0 +1,55 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.create.trigger; + +import java.io.Serializable; +import net.sf.jsqlparser.expression.StringValue; + +/** Structured account used by a MySQL trigger {@code DEFINER} clause. */ +public class TriggerDefiner implements Serializable { + + private StringValue user; + private StringValue host; + + public StringValue getUser() { + return user; + } + + public void setUser(StringValue user) { + this.user = user; + } + + public StringValue getHost() { + return host; + } + + public void setHost(StringValue host) { + this.host = host; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder().append(user); + if (host != null) { + builder.append("@").append(host); + } + return builder.toString(); + } + + public TriggerDefiner withUser(StringValue user) { + setUser(user); + return this; + } + + public TriggerDefiner withHost(StringValue host) { + setHost(host); + return this; + } +} diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index d91bf06a3..1e77eda3c 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -106,6 +106,7 @@ import net.sf.jsqlparser.statement.create.sequence.CreateSequence; import net.sf.jsqlparser.statement.create.synonym.CreateSynonym; import net.sf.jsqlparser.statement.create.table.CreateTable; +import net.sf.jsqlparser.statement.create.trigger.CreateTrigger; import net.sf.jsqlparser.statement.create.user.CreateUser; import net.sf.jsqlparser.statement.create.view.AlterView; import net.sf.jsqlparser.statement.create.view.CreateView; @@ -1595,6 +1596,20 @@ public void visit(CreateTable createTable) { StatementVisitor.super.visit(createTable); } + @Override + public Void visit(CreateTrigger createTrigger, S context) { + createTrigger.getTable().accept(this, context); + if (createTrigger.getBody() != null) { + createTrigger.getBody().accept(this, context); + } + return null; + } + + @Override + public void visit(CreateTrigger createTrigger) { + StatementVisitor.super.visit(createTrigger); + } + @Override public Void visit(CreateView create, S context) { visit(create.getView(), null); diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java index 9878fd4e8..0b23109ec 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -50,6 +50,7 @@ import net.sf.jsqlparser.statement.create.sequence.CreateSequence; import net.sf.jsqlparser.statement.create.synonym.CreateSynonym; import net.sf.jsqlparser.statement.create.table.CreateTable; +import net.sf.jsqlparser.statement.create.trigger.CreateTrigger; import net.sf.jsqlparser.statement.create.user.CreateUser; import net.sf.jsqlparser.statement.create.view.AlterView; import net.sf.jsqlparser.statement.create.view.CreateView; @@ -137,6 +138,12 @@ public StringBuilder visit(CreateTable createTable, S context) { return builder; } + @Override + public StringBuilder visit(CreateTrigger createTrigger, S context) { + builder.append(createTrigger); + return builder; + } + @Override public StringBuilder visit(CreateView createView, S context) { CreateViewDeParser createViewDeParser = new CreateViewDeParser(builder); diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java index 4a4f01905..30ae5f6ab 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java @@ -48,6 +48,7 @@ import net.sf.jsqlparser.statement.create.sequence.CreateSequence; import net.sf.jsqlparser.statement.create.synonym.CreateSynonym; import net.sf.jsqlparser.statement.create.table.CreateTable; +import net.sf.jsqlparser.statement.create.trigger.CreateTrigger; import net.sf.jsqlparser.statement.create.user.CreateUser; import net.sf.jsqlparser.statement.create.view.AlterView; import net.sf.jsqlparser.statement.create.view.CreateView; @@ -91,6 +92,14 @@ public Void visit(CreateTable createTable, S context) { return null; } + @Override + public Void visit(CreateTrigger createTrigger, S context) { + if (createTrigger.getBody() != null) { + createTrigger.getBody().accept(this, context); + } + return null; + } + @Override public Void visit(CreateView createView, S context) { getValidator(CreateViewValidator.class).validate(createView); @@ -447,6 +456,10 @@ public void visit(CreateTable createTable) { visit(createTable, null); } + public void visit(CreateTrigger createTrigger) { + visit(createTrigger, null); + } + public void visit(CreateView createView) { visit(createView, null); } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 003715701..06a77c92f 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -57,6 +57,7 @@ import net.sf.jsqlparser.statement.create.schema.*; import net.sf.jsqlparser.statement.create.synonym.*; import net.sf.jsqlparser.statement.create.sequence.*; import net.sf.jsqlparser.statement.create.table.*; +import net.sf.jsqlparser.statement.create.trigger.*; import net.sf.jsqlparser.statement.create.user.*; import net.sf.jsqlparser.statement.create.view.*; import net.sf.jsqlparser.statement.delete.*; @@ -11757,6 +11758,80 @@ AlterEvent AlterEvent(): { return alterEvent; } } +TriggerDefiner MySqlTriggerDefiner(): +{ + TriggerDefiner definer = new TriggerDefiner(); + StringValue user = null; + StringValue host = null; +} +{ + LOOKAHEAD({ "DEFINER".equalsIgnoreCase(getToken(1).image) }) + "=" user=MySqlAccountNamePart() { definer.setUser(user); } + [ LOOKAHEAD({ getToken(1).kind == K_AT_SIGN }) + host=MySqlAccountNamePart() { definer.setHost(host); } ] + { return definer; } +} + +CreateTrigger.Timing MySqlTriggerTiming(): +{ + CreateTrigger.Timing timing = null; +} +{ + ( + { timing = CreateTrigger.Timing.BEFORE; } + | + LOOKAHEAD({ "AFTER".equalsIgnoreCase(getToken(1).image) }) + { timing = CreateTrigger.Timing.AFTER; } + ) + { return timing; } +} + +CreateTrigger.Order MySqlTriggerOrder(): +{ + CreateTrigger.Order order = null; + Token token = null; +} +{ + LOOKAHEAD({ "FOLLOWS".equalsIgnoreCase(getToken(1).image) + || "PRECEDES".equalsIgnoreCase(getToken(1).image) }) + token= { + order = "FOLLOWS".equalsIgnoreCase(token.image) + ? CreateTrigger.Order.FOLLOWS : CreateTrigger.Order.PRECEDES; + } + { return order; } +} + +CreateTrigger CreateTrigger(): +{ + CreateTrigger createTrigger = new CreateTrigger(); + TriggerDefiner definer = null; + Table trigger = null; + CreateTrigger.Timing timing = null; + Token event = null; + Table table = null; + CreateTrigger.Order order = null; + Table otherTrigger = null; + Statement body = null; +} +{ + [ LOOKAHEAD({ "DEFINER".equalsIgnoreCase(getToken(1).image) }) + definer=MySqlTriggerDefiner() { createTrigger.setDefiner(definer); } ] + trigger=Table() { createTrigger.setTrigger(trigger); } + timing=MySqlTriggerTiming() { createTrigger.setTiming(timing); } + ( event= { createTrigger.setEvent(CreateTrigger.Event.INSERT); } + | event= { createTrigger.setEvent(CreateTrigger.Event.UPDATE); } + | event= { createTrigger.setEvent(CreateTrigger.Event.DELETE); } ) + table=Table() { createTrigger.setTable(table); } + + LOOKAHEAD({ "EACH".equalsIgnoreCase(getToken(1).image) }) + [ order=MySqlTriggerOrder() otherTrigger=Table() { + createTrigger.setOrder(order); + createTrigger.setOtherTrigger(otherTrigger); + } ] + ( body=SingleStatement() | body=Block() ) { createTrigger.setBody(body); } + { return createTrigger; } +} + CreateSchema CreateSchema(): { Token tk = null; @@ -14951,6 +15026,10 @@ Statement Create(): | statement = CreateEvent() | + LOOKAHEAD({ getToken(1).kind == K_TRIGGER + || "DEFINER".equalsIgnoreCase(getToken(1).image) }) + statement = CreateTrigger() + | statement = CreateFunctionStatement(isUsingOrReplace) | statement = CreateSchema() @@ -14967,8 +15046,8 @@ Statement Create(): | statement = CreatePolicy() | - // @fixme: must appear with TRIGGER before INDEX or it will collide with INDEX's CreateParameter() production - ( tk= | tk= ) captureRest = captureRest() + // @fixme: must appear before INDEX or it will collide with INDEX's CreateParameter() production + tk= captureRest = captureRest() { statement = new UnsupportedStatement("CREATE " + tk.image, captureRest); } diff --git a/src/test/java/net/sf/jsqlparser/statement/UnsupportedStatementTest.java b/src/test/java/net/sf/jsqlparser/statement/UnsupportedStatementTest.java index 4ff4c8333..41c61e14f 100644 --- a/src/test/java/net/sf/jsqlparser/statement/UnsupportedStatementTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/UnsupportedStatementTest.java @@ -15,6 +15,7 @@ import net.sf.jsqlparser.JSQLParserException; import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.create.trigger.CreateTrigger; import net.sf.jsqlparser.statement.select.Select; import net.sf.jsqlparser.test.TestUtils; import org.junit.jupiter.api.Assertions; @@ -134,7 +135,7 @@ void testCreate() throws JSQLParserException { String sqlStr = "create trigger stud_marks before INSERT on Student for each row set Student.total = Student.subj1 + Student.subj2, Student.per = Student.total * 60 / 100"; Statement statement = TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); - assertTrue(statement instanceof UnsupportedStatement); + assertInstanceOf(CreateTrigger.class, statement); sqlStr = "create domain TNOTIFICATION_ACTION as ENUM ('ADD', 'CHANGE', 'DEL')"; diff --git a/src/test/java/net/sf/jsqlparser/statement/create/trigger/CreateTriggerTest.java b/src/test/java/net/sf/jsqlparser/statement/create/trigger/CreateTriggerTest.java new file mode 100644 index 000000000..f199bc293 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/trigger/CreateTriggerTest.java @@ -0,0 +1,74 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.create.trigger; + +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; + +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.statement.SetStatement; +import net.sf.jsqlparser.statement.insert.Insert; +import org.junit.jupiter.api.Test; + +class CreateTriggerTest { + + @Test + void parsesTriggerDefinitionAndSetBody() throws JSQLParserException { + String sql = "CREATE TRIGGER normalize_name BEFORE INSERT ON customer " + + "FOR EACH ROW SET NEW.name = UPPER(NEW.name)"; + CreateTrigger trigger = + assertInstanceOf(CreateTrigger.class, assertSqlCanBeParsedAndDeparsed(sql)); + + assertEquals("normalize_name", trigger.getTrigger().getFullyQualifiedName()); + assertEquals(CreateTrigger.Timing.BEFORE, trigger.getTiming()); + assertEquals(CreateTrigger.Event.INSERT, trigger.getEvent()); + assertEquals("customer", trigger.getTable().getFullyQualifiedName()); + assertNull(trigger.getOrder()); + SetStatement body = assertInstanceOf(SetStatement.class, trigger.getBody()); + assertEquals("NEW.name", body.getName().toString()); + assertEquals("UPPER(NEW.name)", body.getExpressions().get(0).toString()); + } + + @Test + void parsesAfterTriggerWithInsertBody() throws JSQLParserException { + String sql = "CREATE TRIGGER record_delete AFTER DELETE ON customer FOR EACH ROW " + + "INSERT INTO audit_log (customer_id) VALUES (OLD.id)"; + CreateTrigger trigger = + assertInstanceOf(CreateTrigger.class, assertSqlCanBeParsedAndDeparsed(sql)); + + assertEquals(CreateTrigger.Timing.AFTER, trigger.getTiming()); + assertEquals(CreateTrigger.Event.DELETE, trigger.getEvent()); + assertInstanceOf(Insert.class, trigger.getBody()); + } + + @Test + void parsesDefinerWithoutReparsingAccountValues() throws JSQLParserException { + String sql = "CREATE DEFINER = 'automation'@'localhost' TRIGGER normalize_name " + + "BEFORE UPDATE ON customer FOR EACH ROW SET NEW.name = UPPER(NEW.name)"; + CreateTrigger trigger = + assertInstanceOf(CreateTrigger.class, assertSqlCanBeParsedAndDeparsed(sql)); + + assertEquals("automation", trigger.getDefiner().getUser().getValue()); + assertEquals("localhost", trigger.getDefiner().getHost().getValue()); + } + + @Test + void parsesTriggerOrdering() throws JSQLParserException { + String sql = "CREATE TRIGGER normalize_name_second BEFORE INSERT ON customer " + + "FOR EACH ROW FOLLOWS normalize_name SET NEW.name = TRIM(NEW.name)"; + CreateTrigger trigger = + assertInstanceOf(CreateTrigger.class, assertSqlCanBeParsedAndDeparsed(sql)); + + assertEquals(CreateTrigger.Order.FOLLOWS, trigger.getOrder()); + assertEquals("normalize_name", trigger.getOtherTrigger().getFullyQualifiedName()); + } +}