diff --git a/.codacy.yml b/.codacy.yml index d98077f4f..772fed89d 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -1,3 +1,8 @@ --- +engines: + tsqllint: + exclude_paths: + # These fixtures use PostgreSQL grammar, not Transact-SQL. + - "src/test/resources/postgresql/**" exclude_paths: - "site/**" diff --git a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java index bb68fa387..62c27a8b1 100644 --- a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java +++ b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java @@ -559,6 +559,8 @@ public enum Feature { * @see CreateSequence */ createSequence, + /** Publication and subscription definitions. */ + createPublication, alterPublication, createSubscription, alterSubscription, /** * SQL "CREATE SYNONYM" statement is allowed * diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java index b083cca6b..9163bb87a 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -9,6 +9,12 @@ */ package net.sf.jsqlparser.statement; +import net.sf.jsqlparser.statement.create.publication.CreatePublication; +import net.sf.jsqlparser.statement.alter.AlterPublication; +import net.sf.jsqlparser.statement.create.subscription.CreateSubscription; +import net.sf.jsqlparser.statement.create.subscription.SubscriptionOption; +import net.sf.jsqlparser.statement.alter.AlterSubscription; + import net.sf.jsqlparser.JSQLParserException; import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.ExpressionVisitor; @@ -846,4 +852,48 @@ public Void visit(TableFunction tableFunction, S context) { return super.visit(tableFunction, context); } } + + @Override + public Void visit(CreatePublication statement, S context) { + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_SCHEMA); + + return null; + } + + @Override + public Void visit(AlterPublication statement, S context) { + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_SCHEMA); + + return null; + } + + @Override + public Void visit(CreateSubscription statement, S context) { + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_SCHEMA); + if (statement.getOptions().stream() + .noneMatch(option -> (option.getKind() == SubscriptionOption.Kind.CONNECT + || option.getKind() == SubscriptionOption.Kind.ENABLED) + && Boolean.FALSE.equals(option.getBooleanValue()))) { + // A subscription may start asynchronous replication; the remote contents are unknown. + analysis.possible(StmtFeature.MODIFIES_DATA); + } + return null; + } + + @Override + public Void visit(AlterSubscription statement, S context) { + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_SCHEMA); + if (statement.getAction() == AlterSubscription.Action.ENABLE + || statement.getAction() == AlterSubscription.Action.REFRESH_PUBLICATION + || statement.getAction() == AlterSubscription.Action.SET_PUBLICATION + || statement.getAction() == AlterSubscription.Action.ADD_PUBLICATION + || statement.getAction() == AlterSubscription.Action.DROP_PUBLICATION) { + analysis.possible(StmtFeature.MODIFIES_DATA); + } + return null; + } } diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java index fd787910d..5d18a4136 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java @@ -9,6 +9,11 @@ */ package net.sf.jsqlparser.statement; +import net.sf.jsqlparser.statement.create.publication.CreatePublication; +import net.sf.jsqlparser.statement.alter.AlterPublication; +import net.sf.jsqlparser.statement.create.subscription.CreateSubscription; +import net.sf.jsqlparser.statement.alter.AlterSubscription; + import net.sf.jsqlparser.statement.alter.Alter; import net.sf.jsqlparser.statement.alter.AlterSession; import net.sf.jsqlparser.statement.alter.AlterSystemStatement; @@ -393,4 +398,35 @@ default void visit(CreatePolicy createPolicy) { this.visit(createPolicy, null); } + default T visit(CreatePublication statement, S context) { + return null; + } + + default void visit(CreatePublication statement) { + visit(statement, null); + } + + default T visit(AlterPublication statement, S context) { + return null; + } + + default void visit(AlterPublication statement) { + visit(statement, null); + } + + default T visit(CreateSubscription statement, S context) { + return null; + } + + default void visit(CreateSubscription statement) { + visit(statement, null); + } + + default T visit(AlterSubscription statement, S context) { + return null; + } + + default void visit(AlterSubscription statement) { + visit(statement, null); + } } diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index 706fd9466..78b0ab7e3 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -9,6 +9,11 @@ */ package net.sf.jsqlparser.statement; +import net.sf.jsqlparser.statement.create.publication.CreatePublication; +import net.sf.jsqlparser.statement.alter.AlterPublication; +import net.sf.jsqlparser.statement.create.subscription.CreateSubscription; +import net.sf.jsqlparser.statement.alter.AlterSubscription; + import net.sf.jsqlparser.expression.ExpressionVisitor; import net.sf.jsqlparser.expression.ExpressionVisitorAdapter; import net.sf.jsqlparser.schema.Column; @@ -596,4 +601,56 @@ public T visit(Import imprt, S context) { public T visit(Export export, S context) { return null; } + + @Override + public T visit(CreatePublication statement, S context) { + statement.getTargets().forEach(target -> target.visit( + table -> table.accept(fromItemVisitor, context), + expression -> expression.accept(expressionVisitor, context))); + statement.getOptions().forEach(option -> { + if (option.getValue() != null) { + option.getValue().accept(expressionVisitor, context); + } + }); + return null; + } + + @Override + public T visit(AlterPublication statement, S context) { + statement.getTargets().forEach(target -> target.visit( + table -> table.accept(fromItemVisitor, context), + expression -> expression.accept(expressionVisitor, context))); + statement.getOptions().forEach(option -> { + if (option.getValue() != null) { + option.getValue().accept(expressionVisitor, context); + } + }); + return null; + } + + @Override + public T visit(CreateSubscription statement, S context) { + if (statement.getConnection() != null) { + statement.getConnection().accept(expressionVisitor, context); + } + statement.getOptions().forEach(option -> { + if (option.getValue() != null) { + option.getValue().accept(expressionVisitor, context); + } + }); + return null; + } + + @Override + public T visit(AlterSubscription statement, S context) { + if (statement.getConnection() != null) { + statement.getConnection().accept(expressionVisitor, context); + } + statement.getOptions().forEach(option -> { + if (option.getValue() != null) { + option.getValue().accept(expressionVisitor, context); + } + }); + return null; + } } diff --git a/src/main/java/net/sf/jsqlparser/statement/alter/AlterPublication.java b/src/main/java/net/sf/jsqlparser/statement/alter/AlterPublication.java new file mode 100644 index 000000000..621640395 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/alter/AlterPublication.java @@ -0,0 +1,118 @@ +/*- + * #%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.alter; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.statement.create.publication.PublicationTarget; +import net.sf.jsqlparser.statement.create.publication.PublicationOption; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitor; + +public class AlterPublication implements Statement { + private String name; + private Action action; + private List targets = new ArrayList<>(); + private List options = new ArrayList<>(); + private String newName; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Action getAction() { + return action; + } + + public void setAction(Action action) { + this.action = action; + } + + public List getTargets() { + return targets; + } + + public void setTargets(List targets) { + this.targets = targets; + } + + public List getOptions() { + return options; + } + + public void setOptions(List options) { + this.options = options; + } + + public String getNewName() { + return newName; + } + + public void setNewName(String newName) { + this.newName = newName; + } + + @Override + public T accept(StatementVisitor visitor, S context) { + return visitor.visit(this, context); + } + + public enum Action { + ADD, SET, DROP, SET_OPTIONS, OWNER, RENAME + } + + public void appendTo(StringBuilder sql, Consumer expressions) { + sql.append("ALTER PUBLICATION ").append(name).append(' '); + switch (action) { + case OWNER: + sql.append("OWNER TO ").append(newName); + break; + case RENAME: + sql.append("RENAME TO ").append(newName); + break; + case SET_OPTIONS: + sql.append("SET ("); + for (int i = 0; i < options.size(); i++) { + if (i > 0) { + sql.append(", "); + } + options.get(i).appendTo(sql, expressions); + } + sql.append(')'); + break; + case ADD: + case SET: + case DROP: + sql.append(action).append(' '); + for (int i = 0; i < targets.size(); i++) { + if (i > 0) { + sql.append(", "); + } + targets.get(i).appendTo(sql, expressions); + } + break; + default: + throw new IllegalStateException("Unknown publication alteration: " + action); + } + } + + @Override + public String toString() { + StringBuilder sql = new StringBuilder(); + appendTo(sql, sql::append); + return sql.toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/alter/AlterSubscription.java b/src/main/java/net/sf/jsqlparser/statement/alter/AlterSubscription.java new file mode 100644 index 000000000..486ebb2d3 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/alter/AlterSubscription.java @@ -0,0 +1,127 @@ +/*- + * #%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.alter; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.statement.create.subscription.SubscriptionOption; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitor; + +public class AlterSubscription implements Statement { + private String name; + private Action action; + private StringValue connection; + private List publications = new ArrayList<>(); + private List options = new ArrayList<>(); + private String newName; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Action getAction() { + return action; + } + + public void setAction(Action action) { + this.action = action; + } + + public StringValue getConnection() { + return connection; + } + + public void setConnection(StringValue connection) { + this.connection = connection; + } + + public List getPublications() { + return publications; + } + + public void setPublications(List publications) { + this.publications = publications; + } + + public List getOptions() { + return options; + } + + public void setOptions(List options) { + this.options = options; + } + + public String getNewName() { + return newName; + } + + public void setNewName(String newName) { + this.newName = newName; + } + + @Override + public T accept(StatementVisitor visitor, S context) { + return visitor.visit(this, context); + } + + public enum Action { + CONNECTION, SET_PUBLICATION, ADD_PUBLICATION, DROP_PUBLICATION, REFRESH_PUBLICATION, ENABLE, DISABLE, SET_OPTIONS, SKIP, OWNER, RENAME + } + + public void appendTo(StringBuilder sql, Consumer expressions) { + sql.append("ALTER SUBSCRIPTION ").append(name).append(' '); + switch (action) { + case CONNECTION: + sql.append("CONNECTION "); + expressions.accept(connection); + break; + case OWNER: + sql.append("OWNER TO ").append(newName); + break; + case RENAME: + sql.append("RENAME TO ").append(newName); + break; + case SET_OPTIONS: + sql.append("SET"); + break; + default: + sql.append(action.name().replace('_', ' ')); + if (!publications.isEmpty()) { + sql.append(' ').append(String.join(", ", publications)); + } + break; + } + if (!options.isEmpty()) { + sql.append(action == Action.SET_OPTIONS || action == Action.SKIP ? " (" : " WITH ("); + for (int i = 0; i < options.size(); i++) { + if (i > 0) { + sql.append(", "); + } + options.get(i).appendTo(sql, expressions); + } + sql.append(')'); + } + } + + @Override + public String toString() { + StringBuilder sql = new StringBuilder(); + appendTo(sql, sql::append); + return sql.toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/create/publication/CreatePublication.java b/src/main/java/net/sf/jsqlparser/statement/create/publication/CreatePublication.java new file mode 100644 index 000000000..b749f11d6 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/publication/CreatePublication.java @@ -0,0 +1,96 @@ +/*- + * #%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.publication; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitor; + +public class CreatePublication implements Statement { + private String name; + private boolean allTables; + private List targets = new ArrayList<>(); + private List options = new ArrayList<>(); + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public boolean isAllTables() { + return allTables; + } + + public void setAllTables(boolean allTables) { + this.allTables = allTables; + } + + public List getTargets() { + return targets; + } + + public void setTargets(List targets) { + this.targets = targets; + } + + public List getOptions() { + return options; + } + + public void setOptions(List options) { + this.options = options; + } + + @Override + public T accept(StatementVisitor visitor, S context) { + return visitor.visit(this, context); + } + + public void appendTo(StringBuilder sql, Consumer expressions) { + if (allTables && !targets.isEmpty()) { + throw new IllegalArgumentException("FOR ALL TABLES cannot have explicit targets"); + } + sql.append("CREATE PUBLICATION ").append(name); + if (allTables) { + sql.append(" FOR ALL TABLES"); + } else if (!targets.isEmpty()) { + sql.append(" FOR "); + for (int i = 0; i < targets.size(); i++) { + if (i > 0) { + sql.append(", "); + } + targets.get(i).appendTo(sql, expressions); + } + } + if (!options.isEmpty()) { + sql.append(" WITH ("); + for (int i = 0; i < options.size(); i++) { + if (i > 0) { + sql.append(", "); + } + options.get(i).appendTo(sql, expressions); + } + sql.append(')'); + } + } + + @Override + public String toString() { + StringBuilder sql = new StringBuilder(); + appendTo(sql, sql::append); + return sql.toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/create/publication/PublicationOption.java b/src/main/java/net/sf/jsqlparser/statement/create/publication/PublicationOption.java new file mode 100644 index 000000000..f843d8d36 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/publication/PublicationOption.java @@ -0,0 +1,56 @@ +/*- + * #%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.publication; + +import java.util.EnumSet; +import java.util.Locale; +import java.util.Set; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.statement.replication.ReplicationOption; + +public class PublicationOption extends ReplicationOption { + public enum Kind { + PUBLISH, PUBLISH_VIA_PARTITION_ROOT, PUBLISH_GENERATED_COLUMNS + } + public enum Operation { + INSERT, UPDATE, DELETE, TRUNCATE + } + public enum GeneratedColumns { + NONE, STORED + } + + public PublicationOption(String name, Kind kind, Expression value) { + super(name, kind, value); + } + + public Boolean getBooleanValue() { + return getKind() == Kind.PUBLISH_VIA_PARTITION_ROOT ? booleanValue() : null; + } + + public GeneratedColumns getGeneratedColumns() { + return getKind() == Kind.PUBLISH_GENERATED_COLUMNS ? enumValue(GeneratedColumns.class) + : null; + } + + public Set getPublishOperations() { + if (getKind() != Kind.PUBLISH || getValueText() == null) { + return null; + } + Set result = EnumSet.noneOf(Operation.class); + for (String operation : getValueText().split(",")) { + try { + result.add(Operation.valueOf(operation.trim().toUpperCase(Locale.ROOT))); + } catch (IllegalArgumentException exception) { + return null; + } + } + return result; + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/create/publication/PublicationTable.java b/src/main/java/net/sf/jsqlparser/statement/create/publication/PublicationTable.java new file mode 100644 index 000000000..ba7e30916 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/publication/PublicationTable.java @@ -0,0 +1,107 @@ +/*- + * #%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.publication; + +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.schema.Column; +import java.io.Serializable; + +public class PublicationTable implements Serializable { + private Table table; + private boolean only; + private boolean includeDescendants; + private ExpressionList columns; + private Expression where; + + public Table getTable() { + return table; + } + + public void setTable(Table table) { + this.table = table; + } + + public boolean isOnly() { + return only; + } + + public void setOnly(boolean only) { + this.only = only; + } + + public boolean isIncludeDescendants() { + return includeDescendants; + } + + public void setIncludeDescendants(boolean includeDescendants) { + this.includeDescendants = includeDescendants; + } + + public ExpressionList getColumns() { + return columns; + } + + public void setColumns(ExpressionList columns) { + this.columns = columns; + } + + public Expression getWhere() { + return where; + } + + public void setWhere(Expression where) { + this.where = where; + } + + public void appendTo(StringBuilder sql, Consumer expressions) { + if (only) { + sql.append("ONLY "); + } + sql.append(table); + if (includeDescendants) { + sql.append(" *"); + } + if (columns != null) { + sql.append(" ("); + for (int i = 0; i < columns.size(); i++) { + if (i > 0) { + sql.append(", "); + } + expressions.accept(columns.get(i)); + } + sql.append(')'); + } + if (where != null) { + sql.append(" WHERE ("); + expressions.accept(where); + sql.append(')'); + } + } + + public void visit(Consumer tables, Consumer expressions) { + tables.accept(table); + if (columns != null) { + columns.forEach(expressions); + } + if (where != null) { + expressions.accept(where); + } + } + + @Override + public String toString() { + StringBuilder sql = new StringBuilder(); + appendTo(sql, sql::append); + return sql.toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/create/publication/PublicationTarget.java b/src/main/java/net/sf/jsqlparser/statement/create/publication/PublicationTarget.java new file mode 100644 index 000000000..97205da8c --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/publication/PublicationTarget.java @@ -0,0 +1,78 @@ +/*- + * #%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.publication; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.schema.Table; +import java.io.Serializable; + +public class PublicationTarget implements Serializable { + private Kind kind; + private List tables = new ArrayList<>(); + private List schemas = new ArrayList<>(); + + public Kind getKind() { + return kind; + } + + public void setKind(Kind kind) { + this.kind = kind; + } + + public List getTables() { + return tables; + } + + public void setTables(List tables) { + this.tables = tables; + } + + public List getSchemas() { + return schemas; + } + + public void setSchemas(List schemas) { + this.schemas = schemas; + } + + public enum Kind { + TABLE, TABLES_IN_SCHEMA + } + + public void appendTo(StringBuilder sql, Consumer expressions) { + if (kind == Kind.TABLES_IN_SCHEMA) { + sql.append("TABLES IN SCHEMA ").append(String.join(", ", schemas)); + } else { + sql.append("TABLE "); + for (int i = 0; i < tables.size(); i++) { + if (i > 0) { + sql.append(", "); + } + tables.get(i).appendTo(sql, expressions); + } + } + } + + public void visit(Consumer
tableVisitor, Consumer expressions) { + if (kind == Kind.TABLE) { + tables.forEach(table -> table.visit(tableVisitor, expressions)); + } + } + + @Override + public String toString() { + StringBuilder sql = new StringBuilder(); + appendTo(sql, sql::append); + return sql.toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/create/subscription/CreateSubscription.java b/src/main/java/net/sf/jsqlparser/statement/create/subscription/CreateSubscription.java new file mode 100644 index 000000000..75d193e0e --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/subscription/CreateSubscription.java @@ -0,0 +1,85 @@ +/*- + * #%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.subscription; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitor; + +public class CreateSubscription implements Statement { + private String name; + private StringValue connection; + private List publications = new ArrayList<>(); + private List options = new ArrayList<>(); + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public StringValue getConnection() { + return connection; + } + + public void setConnection(StringValue connection) { + this.connection = connection; + } + + public List getPublications() { + return publications; + } + + public void setPublications(List publications) { + this.publications = publications; + } + + public List getOptions() { + return options; + } + + public void setOptions(List options) { + this.options = options; + } + + @Override + public T accept(StatementVisitor visitor, S context) { + return visitor.visit(this, context); + } + + public void appendTo(StringBuilder sql, Consumer expressions) { + sql.append("CREATE SUBSCRIPTION ").append(name).append(" CONNECTION "); + expressions.accept(connection); + sql.append(" PUBLICATION ").append(String.join(", ", publications)); + if (!options.isEmpty()) { + sql.append(" WITH ("); + for (int i = 0; i < options.size(); i++) { + if (i > 0) { + sql.append(", "); + } + options.get(i).appendTo(sql, expressions); + } + sql.append(')'); + } + } + + @Override + public String toString() { + StringBuilder sql = new StringBuilder(); + appendTo(sql, sql::append); + return sql.toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/create/subscription/SubscriptionOption.java b/src/main/java/net/sf/jsqlparser/statement/create/subscription/SubscriptionOption.java new file mode 100644 index 000000000..62cad8708 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/subscription/SubscriptionOption.java @@ -0,0 +1,70 @@ +/*- + * #%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.subscription; + +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.statement.replication.ReplicationOption; + +public class SubscriptionOption extends ReplicationOption { + public enum Kind { + CONNECT, CREATE_SLOT, ENABLED, SLOT_NAME, BINARY, COPY_DATA, STREAMING, SYNCHRONOUS_COMMIT, TWO_PHASE, DISABLE_ON_ERROR, PASSWORD_REQUIRED, RUN_AS_OWNER, ORIGIN, FAILOVER, REFRESH, LSN + } + public enum Streaming { + ON, OFF, PARALLEL + } + public enum Origin { + ANY, NONE + } + public enum SynchronousCommit { + OFF, LOCAL, REMOTE_WRITE, ON, REMOTE_APPLY + } + + public SubscriptionOption(String name, Kind kind, Expression value) { + super(name, kind, value); + } + + public Boolean getBooleanValue() { + switch (getKind()) { + case CONNECT: + case CREATE_SLOT: + case ENABLED: + case BINARY: + case COPY_DATA: + case TWO_PHASE: + case DISABLE_ON_ERROR: + case PASSWORD_REQUIRED: + case RUN_AS_OWNER: + case FAILOVER: + case REFRESH: + return booleanValue(); + default: + return null; + } + } + + public Streaming getStreaming() { + return getKind() == Kind.STREAMING ? enumValue(Streaming.class) : null; + } + + public Origin getOrigin() { + return getKind() == Kind.ORIGIN ? enumValue(Origin.class) : null; + } + + public SynchronousCommit getSynchronousCommit() { + return getKind() == Kind.SYNCHRONOUS_COMMIT ? enumValue(SynchronousCommit.class) : null; + } + + /** Quoted 'NONE' remains a literal slot name, not the NONE keyword. */ + public boolean isSlotNameNone() { + return getKind() == Kind.SLOT_NAME && !(getValue() instanceof StringValue) + && "NONE".equalsIgnoreCase(getValueText()); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/replication/ReplicationOption.java b/src/main/java/net/sf/jsqlparser/statement/replication/ReplicationOption.java new file mode 100644 index 000000000..a95ba2a37 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/replication/ReplicationOption.java @@ -0,0 +1,96 @@ +/*- + * #%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.replication; + +import java.io.Serializable; +import java.util.Locale; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.StringValue; + +/** + * Common spelling and literal handling; publication and subscription keys remain distinct enums. + */ +public abstract class ReplicationOption> implements Serializable { + private final String name; + private final K kind; + private final Expression value; + + protected ReplicationOption(String name, K kind, Expression value) { + this.name = name; + this.kind = kind; + this.value = value; + } + + public String getName() { + return name; + } + + public K getKind() { + return kind; + } + + public Expression getValue() { + return value; + } + + public String getValueText() { + return value instanceof StringValue ? ((StringValue) value).getValue() + : value == null ? null : value.toString(); + } + + protected Boolean booleanValue() { + String text = getValueText(); + if (text == null) { + return true; + } + switch (text.toLowerCase(Locale.ROOT)) { + case "true": + case "on": + case "yes": + case "1": + return true; + case "false": + case "off": + case "no": + case "0": + return false; + default: + return null; + } + } + + protected > E enumValue(Class type) { + String text = getValueText(); + if (text == null) { + return null; + } + try { + return Enum.valueOf(type, text.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + return null; + } + } + + public void appendTo(StringBuilder sql, Consumer expressions) { + sql.append(name); + if (value != null) { + sql.append(" = "); + expressions.accept(value); + } + } + + @Override + public String toString() { + StringBuilder sql = new StringBuilder(); + appendTo(sql, sql::append); + return sql.toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 167c54e15..a8c0d365b 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -9,6 +9,11 @@ */ package net.sf.jsqlparser.util; +import net.sf.jsqlparser.statement.create.publication.CreatePublication; +import net.sf.jsqlparser.statement.alter.AlterPublication; +import net.sf.jsqlparser.statement.create.subscription.CreateSubscription; +import net.sf.jsqlparser.statement.alter.AlterSubscription; + import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -2497,4 +2502,32 @@ public Void visit(CreatePolicy createPolicy, S context) { public void visit(CreatePolicy createPolicy) { StatementVisitor.super.visit(createPolicy); } + + @Override + public Void visit(CreatePublication statement, S context) { + statement.getTargets().forEach(target -> target.visit( + table -> table.accept(this, context), + expression -> expression.accept(this, context))); + return null; + } + + @Override + public Void visit(AlterPublication statement, S context) { + statement.getTargets().forEach(target -> target.visit( + table -> table.accept(this, context), + expression -> expression.accept(this, context))); + return null; + } + + @Override + public Void visit(CreateSubscription statement, S context) { + + return null; + } + + @Override + public Void visit(AlterSubscription statement, S context) { + + return 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 3069e7d3c..574132b15 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -9,6 +9,11 @@ */ package net.sf.jsqlparser.util.deparser; +import net.sf.jsqlparser.statement.create.publication.CreatePublication; +import net.sf.jsqlparser.statement.alter.AlterPublication; +import net.sf.jsqlparser.statement.create.subscription.CreateSubscription; +import net.sf.jsqlparser.statement.alter.AlterSubscription; + import java.lang.reflect.InvocationTargetException; import java.util.List; import java.util.stream.Collectors; @@ -561,4 +566,28 @@ public StringBuilder visit(CreatePolicy createPolicy, S context) { new CreatePolicyDeParser(expressionDeParser, builder).deParse(createPolicy); return builder; } + + @Override + public StringBuilder visit(CreatePublication statement, S context) { + statement.appendTo(builder, expression -> expression.accept(expressionDeParser, context)); + return builder; + } + + @Override + public StringBuilder visit(AlterPublication statement, S context) { + statement.appendTo(builder, expression -> expression.accept(expressionDeParser, context)); + return builder; + } + + @Override + public StringBuilder visit(CreateSubscription statement, S context) { + statement.appendTo(builder, expression -> expression.accept(expressionDeParser, context)); + return builder; + } + + @Override + public StringBuilder visit(AlterSubscription statement, S context) { + statement.appendTo(builder, expression -> expression.accept(expressionDeParser, context)); + return builder; + } } diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java index a45dfc697..b6d763679 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java @@ -91,7 +91,9 @@ public enum PostgresqlVersion implements Version { Feature.commentOnView, // https://www.postgresql.org/docs/current/sql-createsequence.html - Feature.createSequence, // https://www.postgresql.org/docs/current/sql-altersequence.html + Feature.createSequence, + Feature.createPublication, Feature.alterPublication, + Feature.createSubscription, Feature.alterSubscription, // https://www.postgresql.org/docs/current/sql-altersequence.html Feature.alterSequence, // https://www.postgresql.org/docs/current/sql-createschema.html Feature.createSchema, // https://www.postgresql.org/docs/current/sql-createindex.html Feature.createIndex, // https://www.postgresql.org/docs/current/sql-createtable.html 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 185760962..1cac49cb7 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 @@ -9,6 +9,11 @@ */ package net.sf.jsqlparser.util.validation.validator; +import net.sf.jsqlparser.statement.create.publication.CreatePublication; +import net.sf.jsqlparser.statement.alter.AlterPublication; +import net.sf.jsqlparser.statement.create.subscription.CreateSubscription; +import net.sf.jsqlparser.statement.alter.AlterSubscription; + import net.sf.jsqlparser.parser.feature.Feature; import net.sf.jsqlparser.statement.Block; import net.sf.jsqlparser.statement.Commit; @@ -659,4 +664,34 @@ public Void visit(CreatePolicy createPolicy, S context) { public void visit(CreatePolicy createPolicy) { visit(createPolicy, null); } + + @Override + public Void visit(CreatePublication statement, S context) { + validateFeature(Feature.createPublication); + statement.getTargets().forEach(target -> target.visit( + this::validateOptionalFromItem, this::validateOptionalExpression)); + return null; + } + + @Override + public Void visit(AlterPublication statement, S context) { + validateFeature(Feature.alterPublication); + statement.getTargets().forEach(target -> target.visit( + this::validateOptionalFromItem, this::validateOptionalExpression)); + return null; + } + + @Override + public Void visit(CreateSubscription statement, S context) { + validateFeature(Feature.createSubscription); + + return null; + } + + @Override + public Void visit(AlterSubscription statement, S context) { + validateFeature(Feature.alterSubscription); + + return null; + } } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index ebb26f492..895762966 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -48,6 +48,8 @@ import net.sf.jsqlparser.statement.alter.*; import net.sf.jsqlparser.statement.alter.sequence.*; import net.sf.jsqlparser.statement.comment.*; import net.sf.jsqlparser.statement.create.database.*; +import net.sf.jsqlparser.statement.create.publication.*; +import net.sf.jsqlparser.statement.create.subscription.*; import net.sf.jsqlparser.statement.create.event.*; import net.sf.jsqlparser.statement.create.function.*; import net.sf.jsqlparser.statement.create.index.*; @@ -1200,6 +1202,15 @@ public class CCJSqlParser extends AbstractJSqlParser { return false; } + private static void requireReplicationSyntax(boolean valid, String message) throws ParseException { + if (!valid) { throw new ParseException(message); } + } + + private static > E replicationEnum(Class type, String value) throws ParseException { + try { return Enum.valueOf(type, MultiPartName.unquote(value).toUpperCase(Locale.ROOT)); } + catch (IllegalArgumentException exception) { throw new ParseException("Unknown " + type.getSimpleName() + ": " + value); } + } + private boolean isKeywordAhead(String keyword) { Token token = getToken(1); return token.image != null && keyword.equalsIgnoreCase(token.image); @@ -14858,6 +14869,10 @@ Statement Alter(): ( ( + LOOKAHEAD({ isKeywordAhead("PUBLICATION") }) statement = AlterPublication() + | + LOOKAHEAD({ isKeywordAhead("SUBSCRIPTION") }) statement = AlterSubscription() + | statement = AlterEvent() | statement = AlterTable() @@ -15348,6 +15363,14 @@ Statement Create(): { [ { isUsingOrReplace = true; } ] ( + LOOKAHEAD({ isKeywordAhead("PUBLICATION") }) + { requireReplicationSyntax(!isUsingOrReplace, "OR REPLACE is not supported for CREATE PUBLICATION"); } + statement = CreatePublication() + | + LOOKAHEAD({ isKeywordAhead("SUBSCRIPTION") }) + { requireReplicationSyntax(!isUsingOrReplace, "OR REPLACE is not supported for CREATE SUBSCRIPTION"); } + statement = CreateSubscription() + | statement = CreateUser() | statement = CreateEvent() @@ -15452,6 +15475,168 @@ Synonym Synonym() #Synonym : } } + +void ReplicationKeyword(String expected): +{ Token token; } +{ token= { requireReplicationSyntax(expected.equalsIgnoreCase(token.image), "Expected " + expected); } } + +Expression ReplicationOptionValue(): +{ Token token; Expression result; } +{ + ( LOOKAHEAD(1) token= { result = new Column(token.image); } | result=PrimaryExpression() ) + { return result; } +} + +PublicationOption PublicationOption(): +{ String name; Expression value = null; } +{ + name=RelObjectName() [ "=" value=ReplicationOptionValue() ] + { return new PublicationOption(name, replicationEnum(PublicationOption.Kind.class, name), value); } +} + +List PublicationOptions(): +{ List options = new ArrayList(); PublicationOption option; } +{ + "(" option=PublicationOption() { options.add(option); } + ( "," option=PublicationOption() { options.add(option); } )* ")" + { return options; } +} + +SubscriptionOption SubscriptionOption(): +{ String name; Expression value = null; } +{ + name=RelObjectName() [ "=" value=ReplicationOptionValue() ] + { return new SubscriptionOption(name, replicationEnum(SubscriptionOption.Kind.class, name), value); } +} + +List SubscriptionOptions(): +{ List options = new ArrayList(); SubscriptionOption option; } +{ + "(" option=SubscriptionOption() { options.add(option); } + ( "," option=SubscriptionOption() { options.add(option); } )* ")" + { return options; } +} + +PublicationTable PublicationTable(boolean drop): +{ PublicationTable result = new PublicationTable(); Table table; String name; Expression expression; ExpressionList columns; } +{ + [ { result.setOnly(true); } ] + table=Table() { result.setTable(table); } + [ "*" { result.setIncludeDescendants(true); } ] + [ LOOKAHEAD(1) "(" { columns = new ExpressionList(); } + name=RelObjectName() { columns.add(new Column(name)); } + ( "," name=RelObjectName() { columns.add(new Column(name)); } )* ")" + { result.setColumns(columns); } ] + [ "(" expression=Expression() ")" { result.setWhere(expression); } ] + { + requireReplicationSyntax(!(result.isOnly() && result.isIncludeDescendants()), "ONLY and * cannot be combined"); + requireReplicationSyntax(!drop || (result.getColumns() == null && result.getWhere() == null), "DROP publication tables cannot have column lists or filters"); + return result; + } +} + +PublicationTarget PublicationTarget(boolean drop): +{ PublicationTarget result = new PublicationTarget(); PublicationTable table; String schema; } +{ + ( + { result.setKind(PublicationTarget.Kind.TABLE); } + table=PublicationTable(drop) { result.getTables().add(table); } + ( LOOKAHEAD({ getToken(1).image.equals(",") && getToken(2).kind != K_TABLE && getToken(2).kind != K_TABLES }) + "," table=PublicationTable(drop) { result.getTables().add(table); } )* + | + { result.setKind(PublicationTarget.Kind.TABLES_IN_SCHEMA); } + schema=RelObjectName() { result.getSchemas().add(schema); } + ( LOOKAHEAD({ getToken(1).image.equals(",") && getToken(2).kind != K_TABLE && getToken(2).kind != K_TABLES }) + "," schema=RelObjectName() { result.getSchemas().add(schema); } )* + ) + { return result; } +} + +List PublicationTargets(boolean drop): +{ List targets = new ArrayList(); PublicationTarget target; } +{ + target=PublicationTarget(drop) { targets.add(target); } + ( "," target=PublicationTarget(drop) { targets.add(target); } )* + { return targets; } +} + +CreatePublication CreatePublication(): +{ CreatePublication result = new CreatePublication(); String name; List targets; List options; } +{ + ReplicationKeyword("PUBLICATION") name=RelObjectName() { result.setName(name); } + [ + + ( { result.setAllTables(true); } + | targets=PublicationTargets(false) { result.setTargets(targets); } ) + ] + [ LOOKAHEAD(2) options=PublicationOptions() { result.setOptions(options); } ] + { return result; } +} + +AlterPublication AlterPublication(): +{ AlterPublication result = new AlterPublication(); String name; List targets; List options; boolean drop = false; } +{ + ReplicationKeyword("PUBLICATION") name=RelObjectName() { result.setName(name); } + ( + LOOKAHEAD({ isKeywordAhead("OWNER") }) ReplicationKeyword("OWNER") + name=RelObjectName() { result.setNewName(name); result.setAction(AlterPublication.Action.OWNER); } + | name=RelObjectName() { result.setNewName(name); result.setAction(AlterPublication.Action.RENAME); } + | LOOKAHEAD( "(") options=PublicationOptions() { result.setOptions(options); result.setAction(AlterPublication.Action.SET_OPTIONS); } + | ( { result.setAction(AlterPublication.Action.ADD); } + | { result.setAction(AlterPublication.Action.SET); } + | { result.setAction(AlterPublication.Action.DROP); drop = true; } ) + targets=PublicationTargets(drop) { result.setTargets(targets); } + ) + { return result; } +} + +List SubscriptionPublications(): +{ List names = new ArrayList(); String name; } +{ + name=RelObjectName() { names.add(name); } + ( "," name=RelObjectName() { names.add(name); } )* + { return names; } +} + +CreateSubscription CreateSubscription(): +{ CreateSubscription result = new CreateSubscription(); String name; Token connection; List publications; List options; } +{ + ReplicationKeyword("SUBSCRIPTION") name=RelObjectName() { result.setName(name); } + ReplicationKeyword("CONNECTION") connection= { result.setConnection(new StringValue(connection.image)); } + ReplicationKeyword("PUBLICATION") publications=SubscriptionPublications() { result.setPublications(publications); } + [ LOOKAHEAD(2) options=SubscriptionOptions() { result.setOptions(options); } ] + { return result; } +} + +AlterSubscription AlterSubscription(): +{ AlterSubscription result = new AlterSubscription(); String name; Token connection; List publications; List options; } +{ + ReplicationKeyword("SUBSCRIPTION") name=RelObjectName() { result.setName(name); } + ( + LOOKAHEAD({ isKeywordAhead("CONNECTION") }) ReplicationKeyword("CONNECTION") + connection= { result.setConnection(new StringValue(connection.image)); result.setAction(AlterSubscription.Action.CONNECTION); } + | LOOKAHEAD({ isKeywordAhead("OWNER") }) ReplicationKeyword("OWNER") + name=RelObjectName() { result.setNewName(name); result.setAction(AlterSubscription.Action.OWNER); } + | name=RelObjectName() { result.setNewName(name); result.setAction(AlterSubscription.Action.RENAME); } + | { result.setAction(AlterSubscription.Action.ENABLE); } + | { result.setAction(AlterSubscription.Action.DISABLE); } + | options=SubscriptionOptions() { + requireReplicationSyntax(options.size() == 1 && options.get(0).getKind() == SubscriptionOption.Kind.LSN + && options.get(0).getValue() != null, "SKIP requires one lsn = value option"); + result.setOptions(options); result.setAction(AlterSubscription.Action.SKIP); + } + | LOOKAHEAD( "(") options=SubscriptionOptions() { result.setOptions(options); result.setAction(AlterSubscription.Action.SET_OPTIONS); } + | ReplicationKeyword("PUBLICATION") { result.setAction(AlterSubscription.Action.REFRESH_PUBLICATION); } + [ LOOKAHEAD(2) options=SubscriptionOptions() { result.setOptions(options); } ] + | ( { result.setAction(AlterSubscription.Action.SET_PUBLICATION); } + | { result.setAction(AlterSubscription.Action.ADD_PUBLICATION); } + | { result.setAction(AlterSubscription.Action.DROP_PUBLICATION); } ) + ReplicationKeyword("PUBLICATION") publications=SubscriptionPublications() { result.setPublications(publications); } + [ LOOKAHEAD(2) options=SubscriptionOptions() { result.setOptions(options); } ] + ) + { return result; } +} + CreatePolicy CreatePolicy() #CreatePolicy: { CreatePolicy createPolicy = new CreatePolicy(); diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 3d45609df..5abdab3e1 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -273,6 +273,39 @@ Table constraints expose ``Index.getNullsDistinct()``, ``getIncludeColumns()``, Identity alterations are available as ``ColumnDataType.getIdentityAlterations()``. Sequence ownership is shared by ``CreateSequence`` and ``AlterSequence`` through ``Sequence.getOwnership()``: ``null`` means omitted, ``isNone()`` means explicit ``OWNED BY NONE``, and ``getColumn()`` identifies an owner. ``TablesNamesFinder`` includes ``LIKE`` sources and sequence owners without treating sequence or type names as tables. See `ALTER TABLE `_ and `ALTER SEQUENCE `_. +Inspect logical replication statements +====================================== + +PostgreSQL publications and subscriptions have separate statement and option models. No database connection is opened when these statements are parsed. + +.. code-block:: java + + CreatePublication publication = (CreatePublication) CCJSqlParserUtil.parse( + "CREATE PUBLICATION changes FOR TABLE accounts (id) WHERE (active = true)"); + PublicationTable target = publication.getTargets().get(0).getTables().get(0); + Table table = target.getTable(); + List columns = target.getColumns(); + Expression filter = target.getWhere(); + +A ``PublicationTarget`` distinguishes a group of explicit tables from ``TABLES IN SCHEMA``. Target order, repeated ``TABLE`` groups, ``ONLY`` and an explicit descendant ``*`` are preserved. ``CreatePublication.isAllTables()`` represents ``FOR ALL TABLES``; an empty target list without that flag means no target clause was specified. ``AlterPublication.getAction()`` distinguishes adding, replacing or removing targets from option, owner and name changes. + +``PublicationOption`` exposes typed operation sets, partition-root booleans and generated-column modes. ``SubscriptionOption`` has a separate key enum and typed streaming, origin, synchronous-commit and boolean accessors. The ordered option lists contain only explicitly written options; server defaults, which can differ across PostgreSQL versions, are not injected into the AST. A missing value on a boolean option represents its explicit short form, equivalent to ``= true``. + +.. code-block:: java + + CreateSubscription subscription = (CreateSubscription) CCJSqlParserUtil.parse( + "CREATE SUBSCRIPTION changes_sub CONNECTION 'dbname=app' " + + "PUBLICATION changes WITH (connect = false)"); + StringValue connection = subscription.getConnection(); + List publications = subscription.getPublications(); + Boolean connect = subscription.getOptions().get(0).getBooleanValue(); + +Connection strings remain string literals for lossless SQL regeneration. They can contain credentials and should not be logged without redaction. ``SubscriptionOption.isSlotNameNone()`` distinguishes the unquoted ``NONE`` keyword from a literal slot named ``'NONE'``. ``AlterSubscription`` covers connection changes, publication lists and refresh, enable/disable, options, skip LSN, ownership and renaming. + +Publication table columns and row filters participate in visitors and custom expression deparsers. ``TablesNamesFinder`` reports explicitly named publication tables, but cannot enumerate ``ALL TABLES`` or schema-wide targets without a catalog. Publication and subscription names are not table names. Feature classification reports schema modification; a subscription that may start asynchronous replication can additionally report possible data modification. + +See `CREATE PUBLICATION `_, `ALTER PUBLICATION `_, `CREATE SUBSCRIPTION `_ and `ALTER SUBSCRIPTION `_. + Classify a Statement ============================== diff --git a/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlReplicationDdlTest.java b/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlReplicationDdlTest.java new file mode 100644 index 000000000..02e6c5566 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlReplicationDdlTest.java @@ -0,0 +1,209 @@ +/*- + * #%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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static net.sf.jsqlparser.util.validation.ValidationTestAsserts.validateNoErrors; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.ExpressionVisitorAdapter; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitorAdapter; +import net.sf.jsqlparser.statement.StmtFeature; +import net.sf.jsqlparser.statement.alter.AlterPublication; +import net.sf.jsqlparser.statement.alter.AlterSubscription; +import net.sf.jsqlparser.statement.create.publication.CreatePublication; +import net.sf.jsqlparser.statement.create.publication.PublicationOption; +import net.sf.jsqlparser.statement.create.publication.PublicationTable; +import net.sf.jsqlparser.statement.create.publication.PublicationTarget; +import net.sf.jsqlparser.statement.create.subscription.CreateSubscription; +import net.sf.jsqlparser.statement.create.subscription.SubscriptionOption; +import net.sf.jsqlparser.statement.select.SelectVisitorAdapter; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.ExpressionDeParser; +import net.sf.jsqlparser.util.deparser.SelectDeParser; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import net.sf.jsqlparser.util.validation.feature.DatabaseType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +class PostgreSqlReplicationDdlTest { + static Stream statements() throws Exception { + try (BufferedReader reader = new BufferedReader(new InputStreamReader( + PostgreSqlReplicationDdlTest.class + .getResourceAsStream("/postgresql/replication-ddl.sql"), + StandardCharsets.UTF_8))) { + return reader.lines() + .filter(line -> !line.trim().isEmpty() && !line.trim().startsWith("--")) + .collect(Collectors.toList()).stream(); + } + } + + @ParameterizedTest + @MethodSource("statements") + void testRoundTripAndValidation(String sql) throws Exception { + Statement statement = assertSqlCanBeParsedAndDeparsed(sql); + assertThat(statement).isInstanceOfAny(CreatePublication.class, AlterPublication.class, + CreateSubscription.class, AlterSubscription.class); + assertThat(CCJSqlParserUtil.parse(statement.toString()).toString()) + .isEqualTo(statement.toString()); + assertThat(statement.getFeatures().getCertain()).contains(StmtFeature.MODIFIES_SCHEMA) + .doesNotContain(StmtFeature.RETURNS_RESULT_SET); + validateNoErrors(sql, 1, DatabaseType.POSTGRESQL); + } + + @Test + void testPublicationTargets() throws Exception { + CreatePublication publication = (CreatePublication) CCJSqlParserUtil.parse( + "CREATE PUBLICATION mixed FOR TABLE ONLY app.users WHERE (id > 0), orders *, TABLES IN SCHEMA app, audit, TABLE events"); + assertThat(publication.isAllTables()).isFalse(); + assertThat(publication.getTargets()).extracting(PublicationTarget::getKind) + .containsExactly(PublicationTarget.Kind.TABLE, + PublicationTarget.Kind.TABLES_IN_SCHEMA, PublicationTarget.Kind.TABLE); + PublicationTable first = publication.getTargets().get(0).getTables().get(0); + assertThat(first.isOnly()).isTrue(); + assertThat(first.getWhere()).isNotNull(); + assertThat(publication.getTargets().get(0).getTables().get(1).isIncludeDescendants()) + .isTrue(); + assertThat(publication.getTargets().get(1).getSchemas()).containsExactly("app", "audit"); + assertThat(TablesNamesFinder.findTables(publication.toString())) + .containsExactlyInAnyOrder("app.users", "orders", "events"); + assertThat(TablesNamesFinder.findTables("CREATE PUBLICATION p FOR ALL TABLES")).isEmpty(); + assertThat(TablesNamesFinder.findTables("CREATE PUBLICATION p FOR TABLES IN SCHEMA app")) + .isEmpty(); + } + + @Test + void testPublicationOptions() throws Exception { + CreatePublication publication = (CreatePublication) CCJSqlParserUtil.parse( + "CREATE PUBLICATION p FOR ALL TABLES WITH (publish = 'insert, update', publish_via_partition_root, publish_generated_columns = stored)"); + assertThat(publication.getOptions().get(0).getPublishOperations()) + .containsExactlyInAnyOrder(PublicationOption.Operation.INSERT, + PublicationOption.Operation.UPDATE); + assertThat(publication.getOptions().get(1).getBooleanValue()).isTrue(); + assertThat(publication.getOptions().get(1).getValue()).isNull(); + assertThat(publication.getOptions().get(2).getGeneratedColumns()) + .isEqualTo(PublicationOption.GeneratedColumns.STORED); + assertThat( + ((CreatePublication) CCJSqlParserUtil.parse("CREATE PUBLICATION p")).getOptions()) + .isEmpty(); + publication.setName("new_publication"); + assertThat(CCJSqlParserUtil.parse(publication.toString()).toString()) + .startsWith("CREATE PUBLICATION new_publication"); + } + + @Test + void testSubscriptionOptionsAndNames() throws Exception { + CreateSubscription subscription = (CreateSubscription) CCJSqlParserUtil.parse( + "CREATE SUBSCRIPTION sub CONNECTION 'dbname=app' PUBLICATION first_pub, second_pub WITH (connect = false, streaming = parallel, slot_name = NONE)"); + assertThat(subscription.getConnection().getValue()).isEqualTo("dbname=app"); + assertThat(subscription.getPublications()).containsExactly("first_pub", "second_pub"); + assertThat(subscription.getOptions().get(0).getBooleanValue()).isFalse(); + assertThat(subscription.getOptions().get(1).getStreaming()) + .isEqualTo(SubscriptionOption.Streaming.PARALLEL); + assertThat(subscription.getOptions().get(2).isSlotNameNone()).isTrue(); + assertThat(new SubscriptionOption("slot_name", SubscriptionOption.Kind.SLOT_NAME, + new StringValue("NONE")).isSlotNameNone()).isFalse(); + assertThat(TablesNamesFinder.findTables(subscription.toString())).isEmpty(); + assertThat(subscription.getFeatures().getUncertain()) + .doesNotContain(StmtFeature.MODIFIES_DATA); + } + + @Test + void testAlterActions() throws Exception { + AlterPublication publication = + (AlterPublication) CCJSqlParserUtil.parse("ALTER PUBLICATION p DROP TABLE users"); + assertThat(publication.getAction()).isEqualTo(AlterPublication.Action.DROP); + assertThat(publication.getTargets().get(0).getTables().get(0).getColumns()).isNull(); + AlterSubscription subscription = (AlterSubscription) CCJSqlParserUtil + .parse("ALTER SUBSCRIPTION sub SET PUBLICATION p WITH (refresh = false)"); + assertThat(subscription.getAction()).isEqualTo(AlterSubscription.Action.SET_PUBLICATION); + assertThat(subscription.getOptions().get(0).getKind()) + .isEqualTo(SubscriptionOption.Kind.REFRESH); + AlterSubscription skip = (AlterSubscription) CCJSqlParserUtil + .parse("ALTER SUBSCRIPTION sub SKIP (lsn = '0/123')"); + assertThat(skip.getAction()).isEqualTo(AlterSubscription.Action.SKIP); + assertThat(skip.getOptions().get(0).getValue()).isInstanceOf(StringValue.class); + } + + @Test + void testVisitorAndCustomDeparser() throws Exception { + String sql = "CREATE PUBLICATION p FOR TABLE users (id) WHERE (id > 0)"; + Statement statement = CCJSqlParserUtil.parse(sql); + List columns = new ArrayList<>(); + ExpressionVisitorAdapter expressions = new ExpressionVisitorAdapter() { + @Override + public Void visit(Column column, S context) { + assertThat(context).isEqualTo("publication"); + columns.add(column.getColumnName()); + return null; + } + }; + statement.accept(new StatementVisitorAdapter<>(new SelectVisitorAdapter<>(expressions)), + "publication"); + assertThat(columns).containsExactly("id", "id"); + + StringBuilder output = new StringBuilder(); + ExpressionDeParser deparser = new ExpressionDeParser() { + @Override + public StringBuilder visit(Column column, S context) { + return getBuilder().append("new_").append(column.getColumnName()); + } + }; + statement.accept(new StatementDeParser(deparser, new SelectDeParser(), output)); + assertThat(output.toString()).contains("users (new_id) WHERE (new_id > 0)"); + assertThat(statement.toString()).doesNotContain("new_id"); + CCJSqlParserUtil.parse(output.toString()); + } + + @Test + void testProgrammaticConstruction() throws Exception { + CreateSubscription subscription = new CreateSubscription(); + subscription.setName("sub"); + subscription.setConnection(new StringValue("dbname=app")); + subscription.getPublications().add("pub"); + subscription.getOptions() + .add(new SubscriptionOption("connect", SubscriptionOption.Kind.CONNECT, + CCJSqlParserUtil.parseExpression("false"))); + assertThat(CCJSqlParserUtil.parse(subscription.toString()).toString()) + .isEqualTo(subscription.toString()); + } + + @ParameterizedTest + @ValueSource(strings = {"ALTER SUBSCRIPTION sub SKIP (lsn)", + "ALTER SUBSCRIPTION sub SKIP (copy_data = false)", "CREATE OR REPLACE PUBLICATION p", + "CREATE OR REPLACE SUBSCRIPTION sub CONNECTION 'dbname=app' PUBLICATION p", + "CREATE PUBLICATION p FOR TABLE", "CREATE PUBLICATION p FOR TABLE users ()", + "CREATE PUBLICATION p FOR TABLE users WHERE id > 0", + "CREATE PUBLICATION p FOR TABLE ONLY users *", + "ALTER PUBLICATION p DROP TABLE users (id)", + "ALTER PUBLICATION p DROP TABLE users WHERE (id > 0)", + "CREATE SUBSCRIPTION sub PUBLICATION p", + "CREATE SUBSCRIPTION sub CONNECTION 'dbname=app' PUBLICATION", + "ALTER SUBSCRIPTION sub SET ()", "CREATE PUBLICATION p WITH (unknown = true)"}) + void testInvalidSyntax(String sql) { + assertThatThrownBy(() -> CCJSqlParserUtil.parse(sql)) + .isInstanceOf(JSQLParserException.class); + } +} diff --git a/src/test/resources/postgresql/replication-ddl.sql b/src/test/resources/postgresql/replication-ddl.sql new file mode 100644 index 000000000..d42e8bfe3 --- /dev/null +++ b/src/test/resources/postgresql/replication-ddl.sql @@ -0,0 +1,53 @@ +--- +-- #%L +-- JSQLParser library +-- %% +-- Copyright (C) 2004 - 2026 JSQLParser +-- %% +-- Dual licensed under GNU LGPL 2.1 or Apache License 2.0 +-- #L% +--- +CREATE PUBLICATION empty_publication +CREATE PUBLICATION all_tables FOR ALL TABLES +CREATE PUBLICATION dbz_publication FOR TABLE users, orders WITH (publish = 'insert, update, delete') +CREATE PUBLICATION filtered FOR TABLE ONLY app.users (id, email) WHERE (active = true) WITH (publish_via_partition_root = true) +CREATE PUBLICATION inherited FOR TABLE app.users * +CREATE PUBLICATION schemas FOR TABLES IN SCHEMA public, app +CREATE PUBLICATION current_schema_pub FOR TABLES IN SCHEMA CURRENT_SCHEMA +CREATE PUBLICATION mixed FOR TABLE users, orders, TABLES IN SCHEMA app, audit, TABLE events +CREATE PUBLICATION explicit_groups FOR TABLE users, TABLE orders +CREATE PUBLICATION stored_columns FOR ALL TABLES WITH (publish_generated_columns = stored, publish_via_partition_root) +CREATE PUBLICATION flags FOR ALL TABLES WITH (publish_via_partition_root = off) +ALTER PUBLICATION dbz_publication ADD TABLE events +ALTER PUBLICATION dbz_publication ADD TABLE users (id, email), orders WHERE (amount > 10) +ALTER PUBLICATION dbz_publication SET TABLE ONLY users, TABLES IN SCHEMA app +ALTER PUBLICATION dbz_publication DROP TABLE users *, orders +ALTER PUBLICATION dbz_publication DROP TABLES IN SCHEMA app, audit +ALTER PUBLICATION dbz_publication SET (publish = 'update, delete, truncate') +ALTER PUBLICATION dbz_publication SET (publish_via_partition_root = on, publish_generated_columns = 'stored') +ALTER PUBLICATION dbz_publication OWNER TO CURRENT_USER +ALTER PUBLICATION dbz_publication RENAME TO live_publication +CREATE SUBSCRIPTION app_sub CONNECTION 'host=localhost dbname=appdb' PUBLICATION dbz_publication WITH (connect = false) +CREATE SUBSCRIPTION app_sub CONNECTION 'dbname=app' PUBLICATION one, two +CREATE SUBSCRIPTION app_sub CONNECTION 'dbname=app' PUBLICATION one WITH (enabled = false, create_slot = false, slot_name = NONE) +CREATE SUBSCRIPTION app_sub CONNECTION 'dbname=app' PUBLICATION one WITH (slot_name = 'NONE', enabled = false, create_slot = false) +CREATE SUBSCRIPTION app_sub CONNECTION 'dbname=app' PUBLICATION one WITH (binary, copy_data = false, streaming = parallel, synchronous_commit = remote_apply) +CREATE SUBSCRIPTION app_sub CONNECTION 'dbname=app' PUBLICATION one WITH (two_phase = true, disable_on_error = true, password_required = true, run_as_owner = false, origin = none, failover = true) +CREATE SUBSCRIPTION app_sub CONNECTION 'dbname=app' PUBLICATION one WITH (streaming = on, synchronous_commit = off) +CREATE SUBSCRIPTION app_sub CONNECTION 'dbname=app' PUBLICATION one WITH (binary = 'yes', enabled = 0) +ALTER SUBSCRIPTION app_sub CONNECTION 'host=localhost dbname=app2' +ALTER SUBSCRIPTION app_sub SET PUBLICATION one, two WITH (refresh = false) +ALTER SUBSCRIPTION app_sub ADD PUBLICATION three WITH (copy_data = false) +ALTER SUBSCRIPTION app_sub DROP PUBLICATION two WITH (refresh = true, copy_data = false) +ALTER SUBSCRIPTION app_sub REFRESH PUBLICATION WITH (copy_data = false) +ALTER SUBSCRIPTION app_sub REFRESH PUBLICATION +ALTER SUBSCRIPTION app_sub ENABLE +ALTER SUBSCRIPTION app_sub DISABLE +ALTER SUBSCRIPTION app_sub SET (slot_name = NONE) +ALTER SUBSCRIPTION app_sub SET (streaming = off, synchronous_commit = local, origin = any) +ALTER SUBSCRIPTION app_sub SET (binary = true, two_phase = false, failover = false) +ALTER SUBSCRIPTION app_sub SKIP (lsn = '0/14C0378') +ALTER SUBSCRIPTION app_sub SKIP (lsn = NONE) +ALTER SUBSCRIPTION app_sub OWNER TO CURRENT_ROLE +ALTER SUBSCRIPTION app_sub RENAME TO next_sub +CREATE PUBLICATION "Pub" FOR TABLE "App"."Orders" ("Id") WHERE ("Id" > 0)