diff --git a/README.md b/README.md index e7a8db452..9b0787dc7 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ and missing syntax gets added on demand — [open an issue](https://github.com/J | **DDL** | `CREATE …` · `ALTER …` · `DROP …` | | **Oracle vector types** | `VECTOR(*, *)` · `VECTOR(*, FLOAT32)` · `VECTOR(3, *)`, including dense and sparse storage formats | | **PostgreSQL ownership** | `DROP OWNED` · `REASSIGN OWNED` | +| **Two-phase commit** | PostgreSQL `PREPARE TRANSACTION` · `COMMIT PREPARED` · `ROLLBACK PREPARED` · MySQL `XA START`/`END`/`PREPARE`/`COMMIT`/`ROLLBACK`/`RECOVER` | | **MySQL table maintenance** | `ANALYZE` · `CHECK` · `CHECKSUM` · `OPTIMIZE` · `REPAIR TABLE` | | **PostgreSQL maintenance** | `VACUUM` · `ANALYZE` · `CLUSTER` · `CHECKPOINT` | | **MySQL locks** | `LOCK TABLES` · `UNLOCK TABLES` · `LOCK INSTANCE FOR BACKUP` · `UNLOCK INSTANCE` | 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 447245f4a..b762b5efd 100644 --- a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java +++ b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java @@ -22,6 +22,7 @@ import net.sf.jsqlparser.statement.DeclareStatement; import net.sf.jsqlparser.statement.DescribeStatement; import net.sf.jsqlparser.statement.ExplainStatement; +import net.sf.jsqlparser.statement.PrepareTransactionStatement; import net.sf.jsqlparser.statement.ResetStatement; import net.sf.jsqlparser.statement.SetConstraintsStatement; import net.sf.jsqlparser.statement.SetStatement; @@ -30,6 +31,7 @@ import net.sf.jsqlparser.statement.ShowStatement; import net.sf.jsqlparser.statement.TableMaintenanceStatement; import net.sf.jsqlparser.statement.UseStatement; +import net.sf.jsqlparser.statement.XaStatement; import net.sf.jsqlparser.statement.alter.Alter; import net.sf.jsqlparser.statement.alter.AlterEventTrigger; import net.sf.jsqlparser.statement.alter.sequence.AlterSequence; @@ -764,6 +766,18 @@ public enum Feature { * @see SetConstraintsStatement */ setConstraints, + /** + * PostgreSQL two-phase commit: PREPARE TRANSACTION, COMMIT PREPARED and ROLLBACK PREPARED + * + * @see PrepareTransactionStatement + */ + twoPhaseCommit, + /** + * MySQL and MariaDB XA statements: XA START, END, PREPARE, COMMIT, ROLLBACK and RECOVER + * + * @see XaStatement + */ + xa, /** * SQL "LOCK TABLE" statement is allowed * diff --git a/src/main/java/net/sf/jsqlparser/statement/Commit.java b/src/main/java/net/sf/jsqlparser/statement/Commit.java index d1aa230bf..a0738414d 100644 --- a/src/main/java/net/sf/jsqlparser/statement/Commit.java +++ b/src/main/java/net/sf/jsqlparser/statement/Commit.java @@ -11,7 +11,10 @@ import java.util.Objects; -/** COMMIT, or its PostgreSQL spelling END. */ +/** + * COMMIT, or its PostgreSQL spelling END. PostgreSQL {@code COMMIT PREPARED 'id'} commits a + * prepared transaction instead of the current one. + */ public class Commit extends TransactionCompletionStatement { /** The keyword that starts the statement; both commit the current transaction. */ public enum Command { @@ -48,6 +51,15 @@ public Commit withRelease(TransactionRelease release) { return this; } + /** + * Names the prepared transaction of PostgreSQL {@code COMMIT PREPARED 'id'}; null commits the + * current transaction. + */ + public Commit withPreparedTransaction(TransactionId id) { + setPreparedTransaction(PreparedTransactionKeyword.PREPARED, id); + return this; + } + @Override public boolean isPostgresSpelling() { return command == Command.END; @@ -62,6 +74,7 @@ public T accept(StatementVisitor statementVisitor, S context) { public StringBuilder appendTo(StringBuilder builder) { builder.append(command); appendKeyword(builder); + appendPreparedTransaction(builder); return appendCompletion(builder); } } diff --git a/src/main/java/net/sf/jsqlparser/statement/PrepareTransactionStatement.java b/src/main/java/net/sf/jsqlparser/statement/PrepareTransactionStatement.java new file mode 100644 index 000000000..1b75e8ae6 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/PrepareTransactionStatement.java @@ -0,0 +1,58 @@ +/*- + * #%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; + +import java.util.Objects; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; + +/** + * PostgreSQL {@code PREPARE TRANSACTION 'id'}, which prepares the current transaction for two-phase + * commit. {@link Commit} and {@link RollbackStatement} complete it later with + * {@code COMMIT PREPARED 'id'} and {@code ROLLBACK PREPARED 'id'}. Not to be confused with + * {@link PrepareStatement}, which prepares a statement. + * + * @see PostgreSQL + * PREPARE TRANSACTION + */ +public class PrepareTransactionStatement extends ASTNodeAccessImpl implements Statement { + private TransactionId transactionId; + + public PrepareTransactionStatement(TransactionId transactionId) { + setTransactionId(transactionId); + } + + /** The identifier that COMMIT PREPARED and ROLLBACK PREPARED name later. */ + public TransactionId getTransactionId() { + return transactionId; + } + + public void setTransactionId(TransactionId transactionId) { + this.transactionId = Objects.requireNonNull(transactionId, "transactionId"); + } + + public PrepareTransactionStatement withTransactionId(TransactionId transactionId) { + setTransactionId(transactionId); + return this; + } + + public StringBuilder appendTo(StringBuilder builder) { + return transactionId.appendTo(builder.append("PREPARE TRANSACTION ")); + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/RollbackStatement.java b/src/main/java/net/sf/jsqlparser/statement/RollbackStatement.java index 797503ac5..0bab0f2cc 100644 --- a/src/main/java/net/sf/jsqlparser/statement/RollbackStatement.java +++ b/src/main/java/net/sf/jsqlparser/statement/RollbackStatement.java @@ -26,14 +26,16 @@ package net.sf.jsqlparser.statement; import java.util.Objects; +import net.sf.jsqlparser.expression.StringValue; /** - * ROLLBACK, or its PostgreSQL spelling ABORT. + * ROLLBACK, or its PostgreSQL spelling ABORT. PostgreSQL {@code ROLLBACK PREPARED 'id'} and Oracle + * {@code ROLLBACK FORCE 'id'} roll back a prepared transaction instead of the current one. * * @author are */ public class RollbackStatement extends TransactionCompletionStatement { - /** The keyword that starts the statement; ABORT has no savepoint or FORCE form. */ + /** The keyword that starts the statement; ABORT has no savepoint, FORCE or PREPARED form. */ public enum Command { ROLLBACK, ABORT } @@ -41,7 +43,6 @@ public enum Command { private Command command = Command.ROLLBACK; private boolean usingSavepointKeyword = false; private String savepointName = null; - private String forceDistributedTransactionIdentifier = null; public Command getCommand() { return command; @@ -71,6 +72,16 @@ public RollbackStatement withRelease(TransactionRelease release) { return this; } + /** + * Names the prepared transaction of PostgreSQL {@code ROLLBACK PREPARED 'id'} or Oracle + * {@code ROLLBACK FORCE 'id'}; a null identifier rolls back the current transaction. + */ + public RollbackStatement withPreparedTransaction(PreparedTransactionKeyword keyword, + TransactionId id) { + setPreparedTransaction(keyword, id); + return this; + } + public boolean isUsingWorkKeyword() { return getKeyword() == TransactionKeyword.WORK; } @@ -114,18 +125,43 @@ public RollbackStatement withSavepointName(String savepointName) { return this; } + /** + * The identifier of Oracle {@code ROLLBACK FORCE 'id'} as written, with its quotes, or null. + * Derived from {@link #getPreparedTransactionId()} when the keyword is + * {@link PreparedTransactionKeyword#FORCE}. + */ public String getForceDistributedTransactionIdentifier() { - return forceDistributedTransactionIdentifier; + return getPreparedTransactionKeyword() == PreparedTransactionKeyword.FORCE + ? getPreparedTransactionId().toString() + : null; } + /** + * Sets the identifier of Oracle {@code ROLLBACK FORCE 'id'} as it is to be written. It is + * stored as the {@link StringValue} of {@link #getPreparedTransactionId()}; text that is not a + * string literal is kept verbatim. Null or blank removes a FORCE identifier. + */ public void setForceDistributedTransactionIdentifier( String forceDistributedTransactionIdentifier) { - this.forceDistributedTransactionIdentifier = forceDistributedTransactionIdentifier; + if (forceDistributedTransactionIdentifier != null + && !forceDistributedTransactionIdentifier.trim().isEmpty()) { + setPreparedTransaction(PreparedTransactionKeyword.FORCE, + new TransactionId(asWritten(forceDistributedTransactionIdentifier))); + } else if (getPreparedTransactionKeyword() == PreparedTransactionKeyword.FORCE) { + setPreparedTransaction(null, null); + } + } + + /** The literal {@code text} spells, or the text itself when it is not a string literal. */ + private static StringValue asWritten(String text) { + StringValue literal = new StringValue(text); + return literal.toString().equals(text) ? literal + : new StringValue().withValue(text).setQuoteStr(""); } public RollbackStatement withForceDistributedTransactionIdentifier( String forceDistributedTransactionIdentifier) { - this.forceDistributedTransactionIdentifier = forceDistributedTransactionIdentifier; + setForceDistributedTransactionIdentifier(forceDistributedTransactionIdentifier); return this; } @@ -141,9 +177,8 @@ public StringBuilder appendTo(StringBuilder builder) { if (savepointName != null && !savepointName.trim().isEmpty()) { builder.append(" TO ").append(usingSavepointKeyword ? "SAVEPOINT " : "") .append(savepointName); - } else if (forceDistributedTransactionIdentifier != null - && !forceDistributedTransactionIdentifier.trim().isEmpty()) { - builder.append(" FORCE ").append(forceDistributedTransactionIdentifier); + } else { + appendPreparedTransaction(builder); } return appendCompletion(builder); } diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java index f91ea876a..047c88cda 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -915,6 +915,18 @@ public Void visit(SetConstraintsStatement statement, S context) { return transactionOnly(); } + @Override + public Void visit(PrepareTransactionStatement statement, S context) { + return transactionOnly(); + } + + /** XA RECOVER lists the prepared transactions; the other actions control a branch. */ + @Override + public Void visit(XaStatement statement, S context) { + return statement.getAction() == XaStatement.Action.RECOVER ? metadataOnly() + : transactionOnly(); + } + @Override public Void visit(Commit commit, S context) { return transactionOnly(); diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java index bd9119530..991b0dff8 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java @@ -180,6 +180,22 @@ default void visit(SetConstraintsStatement statement) { visit(statement, null); } + default T visit(PrepareTransactionStatement statement, S context) { + return null; + } + + default void visit(PrepareTransactionStatement statement) { + visit(statement, null); + } + + default T visit(XaStatement statement, S context) { + return null; + } + + default void visit(XaStatement statement) { + visit(statement, null); + } + T visit(Commit commit, S context); default void visit(Commit commit) { diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index 32a947382..40a27dc4b 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -250,6 +250,16 @@ public T visit(SetConstraintsStatement statement, S context) { return null; } + @Override + public T visit(PrepareTransactionStatement statement, S context) { + return null; + } + + @Override + public T visit(XaStatement statement, S context) { + return null; + } + @Override public T visit(Commit commit, S context) { diff --git a/src/main/java/net/sf/jsqlparser/statement/TransactionCompletionStatement.java b/src/main/java/net/sf/jsqlparser/statement/TransactionCompletionStatement.java index a8f8f4f32..9c704d9e1 100644 --- a/src/main/java/net/sf/jsqlparser/statement/TransactionCompletionStatement.java +++ b/src/main/java/net/sf/jsqlparser/statement/TransactionCompletionStatement.java @@ -9,17 +9,31 @@ */ package net.sf.jsqlparser.statement; +import java.util.Objects; import net.sf.jsqlparser.parser.ASTNodeAccessImpl; /** * The clauses shared by {@link Commit} and {@link RollbackStatement}: - * {@code [WORK | TRANSACTION] ... [AND [NO] CHAIN] [[NO] RELEASE]}. Null fields are omitted. + * {@code [WORK | TRANSACTION] ... [AND [NO] CHAIN] [[NO] RELEASE]}, or the identifier of a prepared + * transaction that the statement completes instead of the current one. Null fields are omitted. */ public abstract class TransactionCompletionStatement extends ASTNodeAccessImpl implements Statement { + /** The keyword before the identifier of a prepared transaction. */ + public enum PreparedTransactionKeyword { + /** PostgreSQL {@code COMMIT PREPARED 'id'} and {@code ROLLBACK PREPARED 'id'}. */ + PREPARED, + /** + * Oracle {@code ROLLBACK [WORK] FORCE 'id'}, naming an in-doubt distributed transaction. + */ + FORCE + } + private TransactionKeyword keyword; private TransactionChain chain; private TransactionRelease release; + private PreparedTransactionKeyword preparedTransactionKeyword; + private TransactionId preparedTransactionId; public TransactionKeyword getKeyword() { return keyword; @@ -45,6 +59,31 @@ public void setRelease(TransactionRelease release) { this.release = release; } + /** {@code PREPARED} or {@code FORCE} when a prepared transaction is named, otherwise null. */ + public PreparedTransactionKeyword getPreparedTransactionKeyword() { + return preparedTransactionKeyword; + } + + /** + * The identifier of the prepared transaction to complete, written after + * {@link #getPreparedTransactionKeyword()}, or null when the current transaction completes. + */ + public TransactionId getPreparedTransactionId() { + return preparedTransactionId; + } + + /** + * Names the prepared transaction to complete. A null identifier completes the current + * transaction and clears the keyword. + * + * @throws NullPointerException if an identifier is given without a keyword + */ + public void setPreparedTransaction(PreparedTransactionKeyword keyword, TransactionId id) { + this.preparedTransactionKeyword = + id == null ? null : Objects.requireNonNull(keyword, "keyword"); + this.preparedTransactionId = id; + } + /** Whether the statement is spelled END or ABORT, PostgreSQL's names of COMMIT and ROLLBACK. */ public abstract boolean isPostgresSpelling(); @@ -55,6 +94,15 @@ protected StringBuilder appendKeyword(StringBuilder builder) { return keyword == null ? builder : builder.append(' ').append(keyword); } + /** Appends {@code PREPARED 'id'} or {@code FORCE 'id'} when a prepared transaction is named. */ + protected StringBuilder appendPreparedTransaction(StringBuilder builder) { + if (preparedTransactionId != null) { + builder.append(' ').append(preparedTransactionKeyword).append(' '); + preparedTransactionId.appendTo(builder); + } + return builder; + } + /** Appends {@code AND [NO] CHAIN} and {@code [NO] RELEASE}, which end the statement. */ protected StringBuilder appendCompletion(StringBuilder builder) { if (chain != null) { diff --git a/src/main/java/net/sf/jsqlparser/statement/TransactionId.java b/src/main/java/net/sf/jsqlparser/statement/TransactionId.java new file mode 100644 index 000000000..dc9966a62 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/TransactionId.java @@ -0,0 +1,129 @@ +/*- + * #%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; + +import java.util.Objects; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; + +/** + * The identifier of a transaction that two-phase commit statements name: MySQL's XA xid + * {@code gtrid [, bqual [, formatID]]}, and the single string of PostgreSQL's + * {@code PREPARE TRANSACTION}, {@code COMMIT PREPARED} and {@code ROLLBACK PREPARED} and of + * Oracle's {@code ROLLBACK FORCE}, which has only the global part. + * + *

+ * The global transaction identifier and the branch qualifier are literals as written: a + * {@link net.sf.jsqlparser.expression.StringValue}, or in MySQL also a + * {@link net.sf.jsqlparser.expression.HexValue} or a bit-value {@code StringValue} with the + * {@code B} prefix. + * + * @see MySQL XA Transaction + * SQL Statements + * @see PostgreSQL + * PREPARE TRANSACTION + */ +public class TransactionId extends ASTNodeAccessImpl { + private Expression globalTransactionId; + private Expression branchQualifier; + private Long formatId; + + public TransactionId(Expression globalTransactionId) { + setGlobalTransactionId(globalTransactionId); + } + + public TransactionId(Expression globalTransactionId, Expression branchQualifier, + Long formatId) { + this(globalTransactionId); + setBranchQualifier(branchQualifier); + setFormatId(formatId); + } + + /** The global transaction identifier: MySQL's gtrid, or the whole PostgreSQL identifier. */ + public Expression getGlobalTransactionId() { + return globalTransactionId; + } + + public void setGlobalTransactionId(Expression globalTransactionId) { + this.globalTransactionId = + Objects.requireNonNull(globalTransactionId, "globalTransactionId"); + } + + public TransactionId withGlobalTransactionId(Expression globalTransactionId) { + setGlobalTransactionId(globalTransactionId); + return this; + } + + /** MySQL's bqual, or null when the xid has only a global transaction identifier. */ + public Expression getBranchQualifier() { + return branchQualifier; + } + + /** + * Sets the branch qualifier; null removes it. + * + * @throws IllegalStateException if it is removed while a format ID is set + */ + public void setBranchQualifier(Expression branchQualifier) { + if (branchQualifier == null && formatId != null) { + throw new IllegalStateException("A format ID requires a branch qualifier"); + } + this.branchQualifier = branchQualifier; + } + + public TransactionId withBranchQualifier(Expression branchQualifier) { + setBranchQualifier(branchQualifier); + return this; + } + + /** MySQL's formatID, or null when the xid does not write one. */ + public Long getFormatId() { + return formatId; + } + + /** + * Sets the format ID, written after the branch qualifier; null removes it. + * + * @throws IllegalArgumentException if it is negative + * @throws IllegalStateException if it is set without a branch qualifier + */ + public void setFormatId(Long formatId) { + if (formatId != null) { + if (formatId < 0) { + throw new IllegalArgumentException("A format ID is unsigned: " + formatId); + } + if (branchQualifier == null) { + throw new IllegalStateException("A format ID requires a branch qualifier"); + } + } + this.formatId = formatId; + } + + public TransactionId withFormatId(Long formatId) { + setFormatId(formatId); + return this; + } + + public StringBuilder appendTo(StringBuilder builder) { + builder.append(globalTransactionId); + if (branchQualifier != null) { + builder.append(", ").append(branchQualifier); + if (formatId != null) { + builder.append(", ").append(formatId); + } + } + return builder; + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/XaStatement.java b/src/main/java/net/sf/jsqlparser/statement/XaStatement.java new file mode 100644 index 000000000..a17fed345 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/XaStatement.java @@ -0,0 +1,152 @@ +/*- + * #%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; + +import java.util.Objects; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; + +/** + * The MySQL and MariaDB XA statements, which control a branch of a distributed transaction: + * {@code XA {START | BEGIN} xid [JOIN | RESUME]}, {@code XA END xid [SUSPEND [FOR MIGRATE]]}, + * {@code XA PREPARE xid}, {@code XA COMMIT xid [ONE PHASE]}, {@code XA ROLLBACK xid} and + * {@code XA RECOVER [CONVERT XID]}, which names no xid. + * + * @see MySQL XA Transaction + * SQL Statements + */ +public class XaStatement extends ASTNodeAccessImpl implements Statement { + /** The word after XA; START and BEGIN are synonyms. */ + public enum Action { + START, BEGIN, END, PREPARE, COMMIT, ROLLBACK, RECOVER + } + + /** The optional words after the xid, or after RECOVER. */ + public enum Option { + JOIN("JOIN"), RESUME("RESUME"), SUSPEND("SUSPEND"), SUSPEND_FOR_MIGRATE( + "SUSPEND FOR MIGRATE"), ONE_PHASE("ONE PHASE"), CONVERT_XID("CONVERT XID"); + + private final String keywords; + + Option(String keywords) { + this.keywords = keywords; + } + + /** Whether the option follows {@code action}. */ + public boolean appliesTo(Action action) { + switch (this) { + case JOIN: + case RESUME: + return action == Action.START || action == Action.BEGIN; + case SUSPEND: + case SUSPEND_FOR_MIGRATE: + return action == Action.END; + case ONE_PHASE: + return action == Action.COMMIT; + default: + return action == Action.RECOVER; + } + } + + @Override + public String toString() { + return keywords; + } + } + + private Action action; + private TransactionId transactionId; + private Option option; + + /** An XA statement; {@code transactionId} is null for {@link Action#RECOVER}. */ + public XaStatement(Action action, TransactionId transactionId) { + this.action = Objects.requireNonNull(action, "action"); + this.transactionId = transactionId; + } + + public Action getAction() { + return action; + } + + /** + * Sets the action; the xid is kept. + * + * @throws IllegalArgumentException if the option does not apply to the action + */ + public void setAction(Action action) { + Objects.requireNonNull(action, "action"); + if (option != null && !option.appliesTo(action)) { + throw new IllegalArgumentException(option + " does not apply to XA " + action); + } + this.action = action; + } + + public XaStatement withAction(Action action) { + setAction(action); + return this; + } + + /** The xid, or null for XA RECOVER. */ + public TransactionId getTransactionId() { + return transactionId; + } + + /** Sets the xid, which every action but RECOVER names. */ + public void setTransactionId(TransactionId transactionId) { + this.transactionId = transactionId; + } + + public XaStatement withTransactionId(TransactionId transactionId) { + setTransactionId(transactionId); + return this; + } + + /** The option after the xid, or after RECOVER, or null. */ + public Option getOption() { + return option; + } + + /** + * Sets the option; null removes it. + * + * @throws IllegalArgumentException if it does not apply to the action + */ + public void setOption(Option option) { + if (option != null && !option.appliesTo(action)) { + throw new IllegalArgumentException(option + " does not apply to XA " + action); + } + this.option = option; + } + + public XaStatement withOption(Option option) { + setOption(option); + return this; + } + + public StringBuilder appendTo(StringBuilder builder) { + builder.append("XA ").append(action); + if (transactionId != null) { + transactionId.appendTo(builder.append(' ')); + } + if (option != null) { + builder.append(' ').append(option); + } + return builder; + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 87444c8e5..6507f202c 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -92,6 +92,7 @@ import net.sf.jsqlparser.statement.OutputClause; import net.sf.jsqlparser.statement.PragmaStatement; import net.sf.jsqlparser.statement.PrepareStatement; +import net.sf.jsqlparser.statement.PrepareTransactionStatement; import net.sf.jsqlparser.statement.PurgeObjectType; import net.sf.jsqlparser.statement.PurgeStatement; import net.sf.jsqlparser.statement.ReleaseSavepointStatement; @@ -116,6 +117,7 @@ import net.sf.jsqlparser.statement.Statements; import net.sf.jsqlparser.statement.UnsupportedStatement; import net.sf.jsqlparser.statement.UseStatement; +import net.sf.jsqlparser.statement.XaStatement; import net.sf.jsqlparser.statement.alter.Alter; import net.sf.jsqlparser.statement.alter.AlterCollation; import net.sf.jsqlparser.statement.alter.AlterRoutine; @@ -2247,6 +2249,16 @@ public Void visit(SetConstraintsStatement statement, S context) { return null; } + @Override + public Void visit(PrepareTransactionStatement statement, S context) { + return null; + } + + @Override + public Void visit(XaStatement statement, S context) { + return null; + } + @Override public Void visit(Commit commit, 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 c7c959128..4d918ca67 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -86,6 +86,7 @@ import net.sf.jsqlparser.statement.Statements; import net.sf.jsqlparser.statement.UnsupportedStatement; import net.sf.jsqlparser.statement.UseStatement; +import net.sf.jsqlparser.statement.XaStatement; import net.sf.jsqlparser.statement.SetIdentityInsertStatement; import net.sf.jsqlparser.statement.TableMaintenanceStatement; import net.sf.jsqlparser.statement.alter.Alter; @@ -151,6 +152,7 @@ import net.sf.jsqlparser.statement.ConnectStatement; import net.sf.jsqlparser.statement.DisconnectStatement; import net.sf.jsqlparser.statement.PrepareStatement; +import net.sf.jsqlparser.statement.PrepareTransactionStatement; import net.sf.jsqlparser.statement.DeallocateStatement; import net.sf.jsqlparser.statement.CopyStatement; import net.sf.jsqlparser.statement.create.macro.CreateMacro; @@ -489,6 +491,16 @@ public StringBuilder visit(SetConstraintsStatement statement, S context) { return statement.appendTo(builder); } + @Override + public StringBuilder visit(PrepareTransactionStatement statement, S context) { + return statement.appendTo(builder); + } + + @Override + public StringBuilder visit(XaStatement statement, S context) { + return statement.appendTo(builder); + } + @Override public StringBuilder visit(Commit commit, S context) { return commit.appendTo(builder); diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java index f4ff88e85..3b70763cf 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java @@ -154,7 +154,8 @@ public enum MariaDbVersion implements Version { Feature.revoke, // https://mariadb.com/kb/en/commit/ Feature.commit, // https://mariadb.com/kb/en/rollback/ Feature.rollback, // https://mariadb.com/kb/en/savepoint/ - Feature.savepoint, // https://mariadb.com/kb/en/optimizer-hints/ + Feature.savepoint, // https://mariadb.com/kb/en/xa-transactions/ + Feature.xa, // https://mariadb.com/kb/en/optimizer-hints/ Feature.mySqlHintStraightJoin, Feature.mysqlSelectModifiers, Feature.mysqlCalcFoundRows, Feature.mysqlSqlCacheFlag)), diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/MySqlVersion.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/MySqlVersion.java index 26c630ea3..0b0eaf25e 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/feature/MySqlVersion.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/feature/MySqlVersion.java @@ -153,7 +153,8 @@ public enum MySqlVersion implements Version { Feature.transactionChain, Feature.transactionConsistentSnapshot, Feature.transactionRelease, // https://dev.mysql.com/doc/refman/8.4/en/set-transaction.html Feature.setTransaction, Feature.setTransactionGlobal, - Feature.setTransactionPersist, // + Feature.setTransactionPersist, // https://dev.mysql.com/doc/refman/8.4/en/xa-statements.html + Feature.xa, // Feature.mySqlHintStraightJoin, Feature.mysqlSqlCacheFlag, Feature.mysqlSelectModifiers, Feature.mysqlCalcFoundRows)); 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 ca9d67e08..813fb7413 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 @@ -215,7 +215,8 @@ public enum PostgresqlVersion implements Version { Feature.transactionEndAbort, // https://www.postgresql.org/docs/current/sql-set-transaction.html Feature.setTransaction, Feature.setTransactionSessionCharacteristics, Feature.setTransactionSnapshot, // https://www.postgresql.org/docs/current/sql-set-constraints.html - Feature.setConstraints)), V11("11", + Feature.setConstraints, // https://www.postgresql.org/docs/current/sql-prepare-transaction.html + Feature.twoPhaseCommit)), V11("11", V10.copy().getFeatures()), V12("12", V11.copy() .add(Feature.createAccessMethodTable, 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 ee7cfd848..585d3ace1 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 @@ -87,6 +87,7 @@ import net.sf.jsqlparser.statement.Statements; import net.sf.jsqlparser.statement.UnsupportedStatement; import net.sf.jsqlparser.statement.UseStatement; +import net.sf.jsqlparser.statement.XaStatement; import net.sf.jsqlparser.statement.SetIdentityInsertStatement; import net.sf.jsqlparser.statement.SetScope; import net.sf.jsqlparser.statement.TableMaintenanceStatement; @@ -159,6 +160,7 @@ import net.sf.jsqlparser.statement.ConnectStatement; import net.sf.jsqlparser.statement.DisconnectStatement; import net.sf.jsqlparser.statement.PrepareStatement; +import net.sf.jsqlparser.statement.PrepareTransactionStatement; import net.sf.jsqlparser.statement.DeallocateStatement; import net.sf.jsqlparser.statement.CopyStatement; import net.sf.jsqlparser.statement.create.macro.CreateMacro; @@ -443,6 +445,18 @@ public Void visit(SetConstraintsStatement statement, S context) { return null; } + @Override + public Void visit(PrepareTransactionStatement statement, S context) { + validateFeature(Feature.twoPhaseCommit); + return null; + } + + @Override + public Void visit(XaStatement statement, S context) { + validateFeature(Feature.xa); + return null; + } + @Override public Void visit(Commit commit, S context) { validateFeature(Feature.commit); @@ -450,8 +464,15 @@ public Void visit(Commit commit, S context) { return null; } - /** Checks the clauses shared by COMMIT and ROLLBACK and their END and ABORT spellings. */ + /** + * Checks the clauses shared by COMMIT and ROLLBACK and their END and ABORT spellings. Oracle's + * ROLLBACK FORCE has no feature of its own. + */ private void validateTransactionCompletion(TransactionCompletionStatement statement) { + if (statement + .getPreparedTransactionKeyword() == TransactionCompletionStatement.PreparedTransactionKeyword.PREPARED) { + validateFeature(Feature.twoPhaseCommit); + } if (statement.isPostgresSpelling()) { validateFeature(Feature.transactionEndAbort); } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 13f4f1b62..aaac9f5f0 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -186,7 +186,7 @@ public class CCJSqlParser extends AbstractJSqlParser { || "REASSIGN".equalsIgnoreCase(next.image) || "REVOKE".equalsIgnoreCase(next.image) || "RELEASE".equalsIgnoreCase(next.image) - || isAbortTransactionAhead(); + || isAbortTransactionAhead() || isXaStatementAhead(); } } @@ -3431,6 +3431,89 @@ public class CCJSqlParser extends AbstractJSqlParser { && isTransactionCompletionTailAt(2); } + /** + * Whether PostgreSQL PREPARE TRANSACTION 'id' starts here; read with the PostgreSQL dialect or + * none. The string literal tells it from PREPARE transaction AS statement, which prepares a + * statement named transaction. + */ + private boolean isPrepareTransactionAhead() { + return getToken(1).kind == K_PREPARE && isWordAt(2, "TRANSACTION") + && getToken(3).kind == S_CHAR_LITERAL && isPostgreSqlTwoPhaseDialect(); + } + + /** Whether PostgreSQL's PREPARED 'id' follows COMMIT or ROLLBACK. */ + private boolean isPreparedTransactionAhead() { + return isWordAt(1, "PREPARED") && isPostgreSqlTwoPhaseDialect(); + } + + private boolean isPostgreSqlTwoPhaseDialect() { + String dialect = getAsString(Feature.dialect); + return dialect == null || Dialect.POSTGRESQL.name().equals(dialect); + } + + /** + * Whether a MySQL XA statement starts here: XA followed by its action. Read with the MySQL and + * MariaDB dialects or none; XA stays an identifier elsewhere. + */ + private boolean isXaStatementAhead() { + if (!isWordAt(1, "XA") || getAsString(Feature.dialect) != null && !isMySqlDialect()) { + return false; + } + int action = getToken(2).kind; + return action == K_START || action == K_BEGIN || action == K_END || action == K_PREPARE + || action == K_COMMIT || action == K_ROLLBACK || isWordAt(2, "RECOVER"); + } + + /** + * Whether PostgreSQL reads the literal as a string constant: '...', E'...', U&'...' or a + * dollar-quoted string, not N'...', B'...' or another product's prefix. + */ + private static boolean isPostgreSqlStringConstant(StringValue value) { + String prefix = value.getPrefix(); + return !"\"".equals(value.getQuoteStr()) && (prefix == null + || "E".equalsIgnoreCase(prefix) || "U&".equalsIgnoreCase(prefix)); + } + + /** + * Whether a MySQL XA gtrid or bqual string is a text_string: a quoted string without a prefix, + * or a bit-value literal B'0101'. MySQL rejects N'...', _charset'...' and the others. + */ + private static boolean isXaStringPart(StringValue value) { + String prefix = value.getPrefix(); + String quote = value.getQuoteStr(); + if (prefix == null) { + return "'".equals(quote) || "\"".equals(quote); + } + return "B".equalsIgnoreCase(prefix) && value.getValue().matches("[01]*"); + } + + /** + * Whether a hexadecimal literal is one MySQL reads: X'...' with whole bytes, or 0x... with a + * lower-case x. + */ + private static boolean isXaHexPart(String image) { + return image.matches("[Xx]'([0-9A-Fa-f]{2})*'") || image.matches("0x[0-9A-Fa-f]+"); + } + + /** + * The unsigned formatID of an XA xid. MySQL's grammar rejects a value above LONG_MAX, MariaDB's + * one above INT32_MAX. + */ + private long xaFormatId(Token token) throws ParseException { + long limit = Dialect.MARIADB.name().equals(getAsString(Feature.dialect)) + ? Integer.MAX_VALUE + : Long.MAX_VALUE; + try { + long formatId = Long.parseLong(token.image); + if (formatId <= limit) { + return formatId; + } + } catch (NumberFormatException e) { + // out of range, reported below + } + throw new ParseException("XA formatID out of range: " + token.image); + } + private boolean isTransactionCompletionTailAt(int offset) { Token token = getToken(offset); return token.kind == EOF || token.kind == ST_SEMICOLON || token.kind == K_WORK @@ -3459,6 +3542,9 @@ public class CCJSqlParser extends AbstractJSqlParser { "END and ABORT do not accept RELEASE"); requireDdlSyntax(statement.getChain() != TransactionChain.CHAIN || release != TransactionRelease.RELEASE, "AND CHAIN cannot be combined with RELEASE"); + requireDdlSyntax(statement.getPreparedTransactionKeyword() + != TransactionCompletionStatement.PreparedTransactionKeyword.PREPARED + || !statement.isPostgresSpelling(), "END and ABORT do not accept PREPARED"); } /** @@ -5448,6 +5534,10 @@ Statement SingleStatement() : | stm = SavepointStatement() | + LOOKAHEAD({ isPrepareTransactionAhead() }) stm = PrepareTransactionStatement() + | + LOOKAHEAD({ isXaStatementAhead() }) stm = XaStatement() + | LOOKAHEAD({ getToken(1).kind == K_ROLLBACK || isAbortTransactionAhead() }) stm = RollbackStatement() | @@ -23824,7 +23914,7 @@ RollbackStatement RollbackStatement(): TransactionChain chain; TransactionRelease release; String name; - Token identifier; + TransactionId id; } { ( @@ -23834,40 +23924,59 @@ RollbackStatement RollbackStatement(): LOOKAHEAD({ isWordAt(1, "ABORT") }) { statement.setCommand(RollbackStatement.Command.ABORT); } consumeTransactionWord() ) - [ LOOKAHEAD({ getToken(1).kind == K_WORK || isKeywordAhead("TRANSACTION") }) - keyword=TransactionKeyword() { statement.setKeyword(keyword); } ] - // FORCE INSTALL starts a new statement, whereas FORCE 'id' belongs here. - [ - LOOKAHEAD(2) - ( - [ LOOKAHEAD({ getToken(1).kind == K_SAVEPOINT }) { statement.setUsingSavepointKeyword(true); } ] - name=RelObjectName() { statement.setSavepointName(name); } - | - identifier= { statement.setForceDistributedTransactionIdentifier(identifier.image); } - | - chain=TransactionChain() { statement.setChain(chain); } - ) - ] - [ LOOKAHEAD({ statement.getSavepointName() == null - && statement.getForceDistributedTransactionIdentifier() == null && isTransactionReleaseAhead() }) - release=TransactionRelease() { statement.setRelease(release); } ] + ( + // PostgreSQL ROLLBACK PREPARED 'id' takes no other clause. + LOOKAHEAD({ isPreparedTransactionAhead() }) ContextualKeyword("PREPARED") id=StringTransactionId(true) + { statement.setPreparedTransaction(TransactionCompletionStatement.PreparedTransactionKeyword.PREPARED, id); } + | + [ LOOKAHEAD({ getToken(1).kind == K_WORK || isKeywordAhead("TRANSACTION") }) + keyword=TransactionKeyword() { statement.setKeyword(keyword); } ] + // FORCE INSTALL starts a new statement, whereas FORCE 'id' belongs here. + [ + LOOKAHEAD(2) + ( + [ LOOKAHEAD({ getToken(1).kind == K_SAVEPOINT }) { statement.setUsingSavepointKeyword(true); } ] + name=RelObjectName() { statement.setSavepointName(name); } + | + id=StringTransactionId(false) + { statement.setPreparedTransaction(TransactionCompletionStatement.PreparedTransactionKeyword.FORCE, id); } + | + chain=TransactionChain() { statement.setChain(chain); } + ) + ] + [ LOOKAHEAD({ statement.getSavepointName() == null + && statement.getPreparedTransactionId() == null && isTransactionReleaseAhead() }) + release=TransactionRelease() { statement.setRelease(release); } ] + ) { requireDdlSyntax(statement.getCommand() != RollbackStatement.Command.ABORT - || statement.getSavepointName() == null && statement.getForceDistributedTransactionIdentifier() == null, - "ABORT does not accept TO SAVEPOINT or FORCE"); + || statement.getSavepointName() == null && statement.getPreparedTransactionId() == null, + "ABORT does not accept TO SAVEPOINT, FORCE or PREPARED"); requireTransactionCompletionSyntax(statement); return statement; } } Commit Commit(): -{ Commit commit = new Commit(); TransactionKeyword keyword; TransactionChain chain; TransactionRelease release; } +{ + Commit commit = new Commit(); + TransactionKeyword keyword; + TransactionChain chain; + TransactionRelease release; + TransactionId id; +} { ( | { commit.setCommand(Commit.Command.END); } ) - [ LOOKAHEAD({ getToken(1).kind == K_WORK || isKeywordAhead("TRANSACTION") }) - keyword=TransactionKeyword() { commit.setKeyword(keyword); } ] - [ chain=TransactionChain() { commit.setChain(chain); } ] - [ LOOKAHEAD({ isTransactionReleaseAhead() }) release=TransactionRelease() { commit.setRelease(release); } ] + ( + // PostgreSQL COMMIT PREPARED 'id' takes no other clause. + LOOKAHEAD({ isPreparedTransactionAhead() }) ContextualKeyword("PREPARED") id=StringTransactionId(true) + { commit.setPreparedTransaction(TransactionCompletionStatement.PreparedTransactionKeyword.PREPARED, id); } + | + [ LOOKAHEAD({ getToken(1).kind == K_WORK || isKeywordAhead("TRANSACTION") }) + keyword=TransactionKeyword() { commit.setKeyword(keyword); } ] + [ chain=TransactionChain() { commit.setChain(chain); } ] + [ LOOKAHEAD({ isTransactionReleaseAhead() }) release=TransactionRelease() { commit.setRelease(release); } ] + ) { requireTransactionCompletionSyntax(commit); return commit; @@ -23883,6 +23992,95 @@ void consumeTransactionWord() { getNextToken(); } +/** + * A transaction identifier written as one string literal: the identifier of PostgreSQL's PREPARE + * TRANSACTION, COMMIT PREPARED and ROLLBACK PREPARED, which must be a PostgreSQL string constant, + * or of Oracle's ROLLBACK FORCE. + */ +TransactionId StringTransactionId(boolean postgreSql): +{ Token token; StringValue value; } +{ + token= + { + value = stringValue(token); + requireDdlSyntax(!postgreSql || isPostgreSqlStringConstant(value), + "PostgreSQL expects a string constant: " + token.image); + return linkToLastToken(new TransactionId(value)); + } +} + +/** + * PostgreSQL PREPARE TRANSACTION 'id'. isPrepareTransactionAhead() tells it from + * PREPARE name AS statement. + */ +PrepareTransactionStatement PrepareTransactionStatement(): +{ TransactionId id; } +{ + ContextualKeyword("TRANSACTION") id=StringTransactionId(true) + { return new PrepareTransactionStatement(id); } +} + +/** + * MySQL XA {START | BEGIN} xid [JOIN | RESUME], XA END xid [SUSPEND [FOR MIGRATE]], + * XA PREPARE xid, XA COMMIT xid [ONE PHASE], XA ROLLBACK xid and XA RECOVER [CONVERT XID]. + * XA is not a token; isXaStatementAhead() routes here. MariaDB has no CONVERT XID. + */ +XaStatement XaStatement(): +{ XaStatement.Action action; TransactionId xid = null; XaStatement.Option option = null; } +{ + // The action keeps the JAVACODE call from being the production's first unit. + { } consumeTransactionWord() + ( + ( { action = XaStatement.Action.START; } | { action = XaStatement.Action.BEGIN; } ) + xid=XaTransactionId() + [ { option = XaStatement.Option.JOIN; } | { option = XaStatement.Option.RESUME; } ] + | + { action = XaStatement.Action.END; } xid=XaTransactionId() + [ { option = XaStatement.Option.SUSPEND; } + [ ContextualKeyword("MIGRATE") { option = XaStatement.Option.SUSPEND_FOR_MIGRATE; } ] ] + | + { action = XaStatement.Action.PREPARE; } xid=XaTransactionId() + | + { action = XaStatement.Action.COMMIT; } xid=XaTransactionId() + [ LOOKAHEAD({ isKeywordAhead("ONE") }) ContextualKeyword("ONE") ContextualKeyword("PHASE") + { option = XaStatement.Option.ONE_PHASE; } ] + | + { action = XaStatement.Action.ROLLBACK; } xid=XaTransactionId() + | + ContextualKeyword("RECOVER") { action = XaStatement.Action.RECOVER; } + [ ContextualKeyword("XID") { option = XaStatement.Option.CONVERT_XID; + requireDdlSyntax(!Dialect.MARIADB.name().equals(getAsString(Feature.dialect)), + "MariaDB does not accept XA RECOVER CONVERT XID"); } ] + ) + { return new XaStatement(action, xid).withOption(option); } +} + +/** The xid of the MySQL XA statements: gtrid [, bqual [, formatID]]. */ +TransactionId XaTransactionId(): +{ Token first = getToken(1); TransactionId xid; Expression part; Token formatId; } +{ + part=XaTransactionIdPart() { xid = new TransactionId(part); } + [ "," part=XaTransactionIdPart() { xid.setBranchQualifier(part); } + [ "," formatId= { xid.setFormatId(xaFormatId(formatId)); } ] ] + { return linked(xid, first); } +} + +/** A gtrid or bqual: a string, hexadecimal or bit-value literal. */ +Expression XaTransactionIdPart(): +{ Token token; Expression part; } +{ + ( + token= + { part = stringValue(token); + requireDdlSyntax(isXaStringPart((StringValue) part), "XA expects a string, hexadecimal or bit-value literal: " + token.image); } + | + token= + { requireDdlSyntax(isXaHexPart(token.image), "Invalid hexadecimal literal: " + token.image); + part = linkToLastToken(new HexValue(token.image)); } + ) + { return part; } +} + /** * SET [scope] TRANSACTION modes, SET [scope] SESSION CHARACTERISTICS AS TRANSACTION modes and * SET [scope] TRANSACTION SNAPSHOT 'id'. The modes are those of BEGIN and START TRANSACTION, and diff --git a/src/site/sphinx/index.rst b/src/site/sphinx/index.rst index e0ff2d8a4..740c29499 100644 --- a/src/site/sphinx/index.rst +++ b/src/site/sphinx/index.rst @@ -158,6 +158,8 @@ One grammar covers the SQL standard plus all major RDBMS. Missing syntax gets ad - ``CREATE …`` · ``ALTER …`` · ``DROP …`` * - **PostgreSQL ownership** - ``DROP OWNED`` · ``REASSIGN OWNED`` + * - **Two-phase commit** + - PostgreSQL ``PREPARE TRANSACTION`` · ``COMMIT PREPARED`` · ``ROLLBACK PREPARED`` · MySQL ``XA START``/``END``/``PREPARE``/``COMMIT``/``ROLLBACK``/``RECOVER`` * - **MySQL table maintenance** - ``ANALYZE`` · ``CHECK`` · ``CHECKSUM`` · ``OPTIMIZE`` · ``REPAIR TABLE`` * - **PostgreSQL maintenance** diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 1fe9a8718..45109af19 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -2896,8 +2896,43 @@ feature analysis. The validator checks ``rollback``, ``savepoint``, ``setTransac ``setTransactionPersist``, ``setTransactionSessionCharacteristics``, ``setTransactionSnapshot``) against the MySQL and PostgreSQL capability sets. PostgreSQL ``AND [NO] CHAIN`` is enabled beginning with its version 12 capability set. -This support does not include PostgreSQL ``COMMIT PREPARED`` / ``ROLLBACK PREPARED`` or -XA commands. + +Two-phase commit +---------------- + +The two-phase commit statements share one ``TransactionId``: a global transaction +identifier, and for MySQL's xid an optional branch qualifier and format ID +(``gtrid [, bqual [, formatID]]``). The identifier parts are the literals as written, a +``StringValue`` or a ``HexValue``. + +PostgreSQL ``PREPARE TRANSACTION 'id'`` produces a ``PrepareTransactionStatement``; it is +read before ``PREPARE name AS statement``, which keeps producing ``PrepareStatement``. +``COMMIT PREPARED 'id'`` and ``ROLLBACK PREPARED 'id'`` produce a ``Commit`` and a +``RollbackStatement`` whose ``getPreparedTransactionKeyword()`` is ``PREPARED`` and whose +``getPreparedTransactionId()`` names the transaction. Oracle's ``ROLLBACK FORCE 'id'`` +uses the same pair with the keyword ``FORCE``; ``getForceDistributedTransactionIdentifier()`` +still returns the identifier as written. The PostgreSQL forms are read with the PostgreSQL +dialect or without one, and take a PostgreSQL string constant. + +MySQL ``XA {START | BEGIN} xid [JOIN | RESUME]``, ``XA END xid [SUSPEND [FOR MIGRATE]]``, +``XA PREPARE xid``, ``XA COMMIT xid [ONE PHASE]``, ``XA ROLLBACK xid`` and +``XA RECOVER [CONVERT XID]`` produce an ``XaStatement`` with an ``Action`` and an +optional ``Option``. They are read with the MySQL and MariaDB dialects or without one; +MariaDB does not accept ``CONVERT XID``. The gtrid and bqual are string, hexadecimal or +bit-value literals, and the formatID is an unsigned integer. + +.. code-block:: java + + XaStatement xa = (XaStatement) CCJSqlParserUtil.parse("XA START X'0a', 'b', 1 JOIN", + parser -> parser.withDialect(Dialect.MYSQL)); + TransactionId xid = xa.getTransactionId(); + // START, X'0a', 'b', 1, JOIN + String parts = xa.getAction() + ", " + xid.getGlobalTransactionId() + ", " + + xid.getBranchQualifier() + ", " + xid.getFormatId() + ", " + xa.getOption(); + +The validator checks ``twoPhaseCommit`` (PostgreSQL) and ``xa`` (MySQL and MariaDB). +Feature analysis reports ``XA RECOVER`` as returning a result set and the other +statements as transaction control. MySQL SHOW statements ===================== diff --git a/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java b/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java index 25dd26d38..7cc70022d 100644 --- a/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java +++ b/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java @@ -133,6 +133,9 @@ public void testStatementsTheCorpusLacks() throws JSQLParserException { "WITH x AS (INSERT INTO t VALUES (1) RETURNING *) SELECT * FROM x", "WITH x AS (UPDATE t SET a = 1 RETURNING *) SELECT * FROM x", "WITH x AS (DELETE FROM t RETURNING *) SELECT * FROM x", + "PREPARE TRANSACTION 'x'", "COMMIT PREPARED 'x'", "ROLLBACK PREPARED 'x'", + "ROLLBACK WORK FORCE '1.2.3'", "XA START X'0a', B'01', 1 JOIN", + "XA END 'x' SUSPEND FOR MIGRATE", "XA RECOVER CONVERT XID", "CREATE SCHEMA s CREATE TABLE t (a INT) CREATE VIEW v AS SELECT 1", "CREATE TABLE t AS EXECUTE p", "ALTER DEFAULT PRIVILEGES GRANT SELECT ON TABLES TO r", diff --git a/src/test/java/net/sf/jsqlparser/statement/TransactionStatementTest.java b/src/test/java/net/sf/jsqlparser/statement/TransactionStatementTest.java index 850c3edd2..2ec359b4f 100644 --- a/src/test/java/net/sf/jsqlparser/statement/TransactionStatementTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/TransactionStatementTest.java @@ -286,7 +286,7 @@ public String visit(ReleaseSavepointStatement statement, S context) { "COMMIT AND", "COMMIT AND NO", "COMMIT AND CHAIN AND CHAIN", "ROLLBACK AND", "ROLLBACK TO SAVEPOINT sp AND CHAIN", "ROLLBACK AND CHAIN TO sp", "RELEASE", "RELEASE SAVEPOINT", "RELEASE SAVEPOINT sp garbage", - "COMMIT PREPARED 'id'", "ROLLBACK PREPARED 'id'", "XA START 'id'", + "COMMIT PREPARED", "ROLLBACK PREPARED 'id' AND CHAIN", "XA START 'id'", "START TRANSACTION WITH CONSISTENT SNAPSHOT"}) void malformedAndDeferredPostgresSyntaxIsRejected(String sql) { assertThrows(JSQLParserException.class, () -> parse(sql, Dialect.POSTGRESQL)); diff --git a/src/test/java/net/sf/jsqlparser/statement/TwoPhaseTransactionTest.java b/src/test/java/net/sf/jsqlparser/statement/TwoPhaseTransactionTest.java new file mode 100644 index 000000000..23008330b --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/TwoPhaseTransactionTest.java @@ -0,0 +1,548 @@ +/*- + * #%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; + +import static net.sf.jsqlparser.statement.TransactionStatementTest.assertSpans; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Stream; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.HexValue; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.ASTNodeAccess; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.parser.feature.Feature; +import net.sf.jsqlparser.parser.feature.FeatureConfiguration; +import net.sf.jsqlparser.statement.TransactionCompletionStatement.PreparedTransactionKeyword; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import net.sf.jsqlparser.util.validation.Validation; +import net.sf.jsqlparser.util.validation.ValidationCapability; +import net.sf.jsqlparser.util.validation.feature.FeaturesAllowed; +import net.sf.jsqlparser.util.validation.feature.MariaDbVersion; +import net.sf.jsqlparser.util.validation.feature.MySqlVersion; +import net.sf.jsqlparser.util.validation.feature.OracleVersion; +import net.sf.jsqlparser.util.validation.feature.PostgresqlVersion; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * PostgreSQL PREPARE TRANSACTION, COMMIT PREPARED and ROLLBACK PREPARED, and the MySQL XA + * statements, which share {@link TransactionId}. + */ +class TwoPhaseTransactionTest { + private static final Dialect[] MYSQL_FAMILY = {Dialect.MYSQL, Dialect.MARIADB, null}; + + private static Statement parse(String sql, Dialect dialect) throws JSQLParserException { + return dialect == null ? CCJSqlParserUtil.parse(sql) + : CCJSqlParserUtil.parse(sql, p -> p.withDialect(dialect)); + } + + /** + * Parses {@code sql}, which must print as written, and checks the statement span, the deparser, + * the reparse, the feature analysis and the table finder. + */ + private static Statement roundTrip(String sql, Dialect dialect, StmtFeature feature) + throws Exception { + Statement statement = parse(sql, dialect); + assertEquals(sql, statement.toString()); + assertSpans(sql, sql, (ASTNodeAccess) statement); + StringBuilder output = new StringBuilder(); + statement.accept(new StatementDeParser(output), null); + assertEquals(sql, output.toString()); + Statement reparsed = parse(sql, dialect); + assertEquals(statement.getClass(), reparsed.getClass()); + assertEquals(sql, reparsed.toString()); + assertEquals(Set.of(feature), statement.getFeatures().getCertain()); + assertTrue(statement.getFeatures().getUncertain().isEmpty()); + assertTrue(new TablesNamesFinder<>().getTables(statement).isEmpty()); + return statement; + } + + private static Statement roundTrip(String sql, Dialect dialect) throws Exception { + return roundTrip(sql, dialect, StmtFeature.MODIFIES_TRANSACTION); + } + + private static StringValue string(Object value) { + return assertInstanceOf(StringValue.class, value); + } + + // ---- PostgreSQL ---------------------------------------------------------------------------- + + @ParameterizedTest + @ValueSource(strings = {"PREPARE TRANSACTION 'foobar'", "COMMIT PREPARED 'foobar'", + "ROLLBACK PREPARED 'foobar'"}) + void postgresTwoPhaseStatementsNameTheTransaction(String sql) throws Exception { + for (Dialect dialect : new Dialect[] {Dialect.POSTGRESQL, null}) { + Statement statement = roundTrip(sql, dialect); + TransactionId id; + if (statement instanceof PrepareTransactionStatement) { + id = ((PrepareTransactionStatement) statement).getTransactionId(); + } else { + TransactionCompletionStatement completion = + assertInstanceOf(TransactionCompletionStatement.class, statement); + assertEquals(PreparedTransactionKeyword.PREPARED, + completion.getPreparedTransactionKeyword()); + assertNull(completion.getKeyword()); + assertNull(completion.getChain()); + assertNull(completion.getRelease()); + assertFalse(completion.isPostgresSpelling()); + id = completion.getPreparedTransactionId(); + } + assertEquals("foobar", string(id.getGlobalTransactionId()).getValue()); + assertNull(id.getBranchQualifier()); + assertNull(id.getFormatId()); + assertSpans("'foobar'", sql, id); + assertSpans("'foobar'", sql, string(id.getGlobalTransactionId())); + } + assertInstanceOf(Commit.class, parse("COMMIT PREPARED 'foobar'", null)); + assertNull(((RollbackStatement) parse("ROLLBACK PREPARED 'foobar'", null)) + .getForceDistributedTransactionIdentifier()); + } + + @ParameterizedTest + @ValueSource(strings = {"PREPARE TRANSACTION E'a\\'b'", "COMMIT PREPARED U&'\\0041'", + "ROLLBACK PREPARED $$x$$", "COMMIT PREPARED $t$x$t$", "prepare transaction 'x'", + "commit prepared 'x'", "rollback prepared 'x'"}) + void postgresTakesEveryStringConstant(String sql) throws Exception { + Statement statement = parse(sql, Dialect.POSTGRESQL); + assertEquals(sql.toUpperCase(Locale.ROOT).startsWith("PREPARE"), + statement instanceof PrepareTransactionStatement); + assertSpans(sql, sql, (ASTNodeAccess) statement); + assertEquals(statement.toString(), + parse(statement.toString(), Dialect.POSTGRESQL).toString()); + } + + @Test + void escapeStringsKeepTheirValue() throws Exception { + PrepareTransactionStatement statement = assertInstanceOf( + PrepareTransactionStatement.class, + parse("PREPARE TRANSACTION E'a\\'b'", Dialect.POSTGRESQL)); + StringValue value = string(statement.getTransactionId().getGlobalTransactionId()); + assertEquals("E", value.getPrefix()); + assertEquals("a'b", value.getNotExcapedValue()); + } + + @ParameterizedTest + @ValueSource(strings = {"PREPARE TRANSACTION", "PREPARE TRANSACTION N'x'", + "PREPARE TRANSACTION B'01'", "PREPARE TRANSACTION X'0A'", + "PREPARE TRANSACTION 'x' 'y'", "PREPARE TRANSACTION 'x', 'y'", + "COMMIT PREPARED", "COMMIT PREPARED x", "COMMIT PREPARED 1", "COMMIT PREPARED N'x'", + "COMMIT PREPARED B'01'", "COMMIT PREPARED 'x' AND CHAIN", + "COMMIT WORK PREPARED 'x'", "COMMIT TRANSACTION PREPARED 'x'", + "COMMIT PREPARED 'x', 'y'", "COMMIT PREPARED 'x' 'y'", "ROLLBACK PREPARED", + "ROLLBACK PREPARED x", "ROLLBACK PREPARED X'0A'", "ROLLBACK PREPARED 'x' AND NO CHAIN", + "ROLLBACK WORK PREPARED 'x'", "ROLLBACK PREPARED 'x' TO SAVEPOINT sp", + "ROLLBACK PREPARED 'x' FORCE 'y'", "END PREPARED 'x'", "ABORT PREPARED 'x'", + "XA START 'x'", "XA RECOVER"}) + void postgresRejectsMalformedTwoPhaseStatements(String sql) { + assertThrows(JSQLParserException.class, () -> parse(sql, Dialect.POSTGRESQL), sql); + } + + @Test + void doubleQuotedIdentifiersAreNoTransactionIds() { + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse("COMMIT PREPARED \"x\"", + p -> p.withDialect(Dialect.POSTGRESQL))); + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil + .parse("COMMIT PREPARED \"x\"", p -> p.withDoubleQuotedStrings(true))); + } + + @ParameterizedTest + @ValueSource(strings = {"PREPARE TRANSACTION 'x'", "COMMIT PREPARED 'x'", + "ROLLBACK PREPARED 'x'"}) + void otherDialectsKeepRejectingPostgresTwoPhaseStatements(String sql) { + for (Dialect dialect : new Dialect[] {Dialect.MYSQL, Dialect.MARIADB, Dialect.ORACLE, + Dialect.SQLSERVER}) { + assertThrows(JSQLParserException.class, () -> parse(sql, dialect), sql); + } + } + + @Test + void preparedStatementsKeepTheirReading() throws Exception { + for (Dialect dialect : new Dialect[] {Dialect.POSTGRESQL, null}) { + for (String sql : List.of("PREPARE transaction AS SELECT 1", + "PREPARE transaction (int) AS SELECT $1", "PREPARE p AS SELECT 1")) { + PrepareStatement statement = + assertInstanceOf(PrepareStatement.class, parse(sql, dialect)); + assertEquals(sql.substring(8, sql.indexOf(' ', 8)), statement.getName()); + } + } + for (String sql : List.of("SELECT prepared FROM prepared", "CREATE TABLE prepared (a INT)", + "SAVEPOINT prepared", "ROLLBACK TO prepared")) { + assertEquals(sql, parse(sql, Dialect.POSTGRESQL).toString()); + assertEquals(sql, parse(sql, null).toString()); + } + } + + @Test + void oracleForceSharesTheTransactionId() throws Exception { + RollbackStatement rollback = assertInstanceOf(RollbackStatement.class, + roundTrip("ROLLBACK WORK FORCE '25.32.87'", Dialect.ORACLE)); + assertEquals(PreparedTransactionKeyword.FORCE, rollback.getPreparedTransactionKeyword()); + assertEquals("'25.32.87'", rollback.getForceDistributedTransactionIdentifier()); + assertEquals("25.32.87", + string(rollback.getPreparedTransactionId().getGlobalTransactionId()).getValue()); + assertSpans("'25.32.87'", "ROLLBACK WORK FORCE '25.32.87'", + rollback.getPreparedTransactionId()); + assertEquals("ROLLBACK FORCE 'x'", parse("ROLLBACK FORCE 'x'", null).toString()); + assertThrows(JSQLParserException.class, + () -> parse("ROLLBACK FORCE 'x' AND CHAIN", Dialect.ORACLE)); + } + + @Test + void legacyForceAccessorsDeriveFromTheTransactionId() { + RollbackStatement rollback = + new RollbackStatement().withForceDistributedTransactionIdentifier("'1.2.3'"); + assertEquals("ROLLBACK FORCE '1.2.3'", rollback.toString()); + assertEquals(PreparedTransactionKeyword.FORCE, rollback.getPreparedTransactionKeyword()); + assertEquals("'1.2.3'", rollback.getForceDistributedTransactionIdentifier()); + assertEquals("1.2.3", + string(rollback.getPreparedTransactionId().getGlobalTransactionId()).getValue()); + // text that is not a string literal is kept as written, as before + rollback.setForceDistributedTransactionIdentifier("4.5.6"); + assertEquals("ROLLBACK FORCE 4.5.6", rollback.toString()); + assertEquals("4.5.6", rollback.getForceDistributedTransactionIdentifier()); + rollback.setForceDistributedTransactionIdentifier(" "); + assertNull(rollback.getPreparedTransactionId()); + assertNull(rollback.getForceDistributedTransactionIdentifier()); + assertEquals("ROLLBACK", rollback.toString()); + + TransactionId id = new TransactionId(new StringValue("'g'")); + rollback.withPreparedTransaction(PreparedTransactionKeyword.PREPARED, id); + assertNull(rollback.getForceDistributedTransactionIdentifier()); + rollback.setForceDistributedTransactionIdentifier(null); + assertSame(id, rollback.getPreparedTransactionId()); + assertEquals("ROLLBACK PREPARED 'g'", rollback.toString()); + rollback.setPreparedTransaction(null, null); + assertNull(rollback.getPreparedTransactionKeyword()); + assertThrows(NullPointerException.class, () -> rollback.setPreparedTransaction(null, id)); + + Commit commit = new Commit().withPreparedTransaction(id); + assertEquals("COMMIT PREPARED 'g'", commit.toString()); + StringBuilder output = new StringBuilder(); + commit.accept(new StatementDeParser(output), null); + assertEquals("COMMIT PREPARED 'g'", output.toString()); + commit.withPreparedTransaction(null).withChain(TransactionChain.CHAIN); + assertEquals("COMMIT AND CHAIN", commit.toString()); + PrepareTransactionStatement prepare = new PrepareTransactionStatement(id); + assertEquals("PREPARE TRANSACTION 'g'", prepare.toString()); + assertThrows(NullPointerException.class, () -> prepare.setTransactionId(null)); + } + + // ---- MySQL XA ------------------------------------------------------------------------------ + + static Stream xaStatements() { + return Stream.of( + Arguments.of("XA START 'x'", XaStatement.Action.START, null), + Arguments.of("XA BEGIN 'x'", XaStatement.Action.BEGIN, null), + Arguments.of("XA START 'x' JOIN", XaStatement.Action.START, + XaStatement.Option.JOIN), + Arguments.of("XA BEGIN 'x' RESUME", XaStatement.Action.BEGIN, + XaStatement.Option.RESUME), + Arguments.of("XA END 'x'", XaStatement.Action.END, null), + Arguments.of("XA END 'x' SUSPEND", XaStatement.Action.END, + XaStatement.Option.SUSPEND), + Arguments.of("XA END 'x' SUSPEND FOR MIGRATE", XaStatement.Action.END, + XaStatement.Option.SUSPEND_FOR_MIGRATE), + Arguments.of("XA PREPARE 'x'", XaStatement.Action.PREPARE, null), + Arguments.of("XA COMMIT 'x'", XaStatement.Action.COMMIT, null), + Arguments.of("XA COMMIT 'x' ONE PHASE", XaStatement.Action.COMMIT, + XaStatement.Option.ONE_PHASE), + Arguments.of("XA ROLLBACK 'x'", XaStatement.Action.ROLLBACK, null)); + } + + @ParameterizedTest + @MethodSource("xaStatements") + void xaStatementsKeepActionXidAndOption(String sql, XaStatement.Action action, + XaStatement.Option option) throws Exception { + for (Dialect dialect : MYSQL_FAMILY) { + XaStatement statement = + assertInstanceOf(XaStatement.class, roundTrip(sql, dialect)); + assertEquals(action, statement.getAction()); + assertEquals(option, statement.getOption()); + TransactionId xid = statement.getTransactionId(); + assertEquals("x", string(xid.getGlobalTransactionId()).getValue()); + assertNull(xid.getBranchQualifier()); + assertSpans("'x'", sql, xid); + } + } + + @ParameterizedTest + @ValueSource(strings = {"XA RECOVER", "XA RECOVER CONVERT XID"}) + void xaRecoverListsPreparedTransactions(String sql) throws Exception { + for (Dialect dialect : new Dialect[] {Dialect.MYSQL, null}) { + XaStatement statement = assertInstanceOf(XaStatement.class, + roundTrip(sql, dialect, StmtFeature.RETURNS_RESULT_SET)); + assertEquals(XaStatement.Action.RECOVER, statement.getAction()); + assertNull(statement.getTransactionId()); + assertEquals(sql.endsWith("XID") ? XaStatement.Option.CONVERT_XID : null, + statement.getOption()); + } + assertInstanceOf(XaStatement.class, roundTrip("XA RECOVER", Dialect.MARIADB, + StmtFeature.RETURNS_RESULT_SET)); + // MariaDB writes XA RECOVER FORMAT = 'SQL' instead + assertThrows(JSQLParserException.class, + () -> parse("XA RECOVER CONVERT XID", Dialect.MARIADB)); + } + + @Test + void xidTakesBranchQualifierFormatIdAndEveryLiteralForm() throws Exception { + String sql = "XA START X'0a', B'0101', 7 JOIN"; + XaStatement statement = assertInstanceOf(XaStatement.class, roundTrip(sql, Dialect.MYSQL)); + TransactionId xid = statement.getTransactionId(); + assertEquals("X'0a'", assertInstanceOf(HexValue.class, xid.getGlobalTransactionId()) + .getValue()); + StringValue bits = string(xid.getBranchQualifier()); + assertEquals("B", bits.getPrefix()); + assertEquals("0101", bits.getValue()); + assertEquals(7L, xid.getFormatId()); + assertSpans("X'0a', B'0101', 7", sql, xid); + assertSpans("X'0a'", sql, (ASTNodeAccess) xid.getGlobalTransactionId()); + assertSpans("B'0101'", sql, bits); + + for (String form : List.of("XA PREPARE 0x6162, 'b'", "XA COMMIT 'a', '', 0", + "XA ROLLBACK x'', X'00ff'", "XA END 'a', 'b', 9223372036854775807", + "XA START \"a\", \"b\", 1")) { + assertEquals(form, roundTrip(form, Dialect.MYSQL).toString()); + } + assertEquals("XA START 'a', 'b', 2147483647", + roundTrip("XA START 'a', 'b', 2147483647", Dialect.MARIADB).toString()); + assertEquals("XA START B'01'", parse("XA START b'01'", Dialect.MYSQL).toString()); + assertEquals("XA START 'a', 'b', 7", + parse("XA START 'a', 'b', 007", Dialect.MYSQL).toString()); + assertEquals("XA START 'it''s'", roundTrip("XA START 'it''s'", null).toString()); + assertEquals("it's", string(((XaStatement) parse("XA START 'it\\'s'", Dialect.MYSQL)) + .getTransactionId().getGlobalTransactionId()).getNotExcapedValue()); + } + + @ParameterizedTest + @ValueSource(strings = {"XA", "XA START", "XA START x", "XA START 1", "XA START NULL", + "XA START @v", "XA START ?", "XA START N'x'", "XA START _utf8'x'", "XA START E'x'", + "XA START $$x$$", "XA START X'0'", "XA START X'0 a'", "XA START X'0a' '0b'", + "XA START 0X10", "XA START B'012'", "XA START 'a' 'b'", "XA START 'a',", + "XA START 'a', 'b',", "XA START 'a', 'b', 'c'", "XA START 'a', 'b', -1", + "XA START 'a', 'b', x", "XA START 'a', 'b', 1, 2", "XA START 'a', 1", + "XA START 'a', 'b', 9223372036854775808", "XA START 'x' JOIN RESUME", + "XA START 'x' SUSPEND", "XA START 'x' ONE PHASE", "XA BEGIN WORK", + "XA END 'x' FOR MIGRATE", "XA END 'x' SUSPEND FOR", "XA END 'x' JOIN", + "XA END 'x' SUSPEND MIGRATE", "XA PREPARE 'x' ONE PHASE", "XA PREPARE", + "XA COMMIT 'x' ONE", "XA COMMIT 'x' PHASE", "XA COMMIT 'x' SUSPEND", + "XA COMMIT 'x' AND CHAIN", "XA ROLLBACK 'x' JOIN", "XA ROLLBACK 'x' TO sp", + "XA RECOVER 'x'", "XA RECOVER CONVERT", "XA RECOVER XID", "XA RECOVER CONVERT 'x'", + "XA COMMIT", "XA ROLLBACK", "XA END"}) + void xaRejectsMalformedStatements(String sql) { + for (Dialect dialect : MYSQL_FAMILY) { + assertThrows(JSQLParserException.class, () -> parse(sql, dialect), sql); + } + } + + @Test + void mariaDbFormatIdsAreSigned32Bit() { + assertThrows(JSQLParserException.class, + () -> parse("XA START 'a', 'b', 2147483648", Dialect.MARIADB)); + } + + @ParameterizedTest + @ValueSource(strings = {"XA START 'x'", "XA END 'x'", "XA COMMIT 'x' ONE PHASE", + "XA RECOVER"}) + void otherDialectsKeepRejectingXa(String sql) { + for (Dialect dialect : new Dialect[] {Dialect.POSTGRESQL, Dialect.ORACLE, + Dialect.SQLSERVER, Dialect.H2}) { + assertThrows(JSQLParserException.class, () -> parse(sql, dialect), sql); + } + } + + @Test + void xaRemainsAnIdentifier() throws Exception { + for (Dialect dialect : MYSQL_FAMILY) { + for (String sql : List.of("SELECT xa FROM xa", "CREATE TABLE xa (xa INT)", + "SELECT recover, migrate, phase, xid FROM t", + "ALTER TABLE t ADD COLUMN xa INT", "INSERT INTO xa (xa) VALUES (1)")) { + assertEquals(sql, parse(sql, dialect).toString()); + } + } + } + + // ---- statement boundaries, visitors and validation ----------------------------------------- + + static Stream newStatementStarts() { + return Stream.of( + Arguments.of(Dialect.POSTGRESQL, "PREPARE TRANSACTION 'x'", + PrepareTransactionStatement.class), + Arguments.of(Dialect.POSTGRESQL, "COMMIT PREPARED 'x'", Commit.class), + Arguments.of(null, "ROLLBACK PREPARED 'x'", RollbackStatement.class), + Arguments.of(Dialect.MYSQL, "XA START 'x', 'b', 1 JOIN", XaStatement.class), + Arguments.of(Dialect.MYSQL, "XA END 'x' SUSPEND FOR MIGRATE", XaStatement.class), + Arguments.of(Dialect.MARIADB, "XA COMMIT 'x' ONE PHASE", XaStatement.class), + Arguments.of(null, "XA RECOVER", XaStatement.class), + Arguments.of(Dialect.MYSQL, "XA RECOVER CONVERT XID", XaStatement.class)); + } + + @ParameterizedTest + @MethodSource("newStatementStarts") + void newStatementStartsEndThePreviousStatement(Dialect dialect, String sql, + Class type) throws Exception { + // COMMIT on its own line is closed by the next statement start rather than a semicolon + String script = "SELECT 1;\nCOMMIT\n" + sql + "\n;SAVEPOINT sp"; + for (boolean opaque : new boolean[] {false, true}) { + Statements statements = CCJSqlParserUtil.parseStatements(script, p -> { + if (dialect != null) { + p.withDialect(dialect); + } + p.withUnsupportedStatements(opaque); + }); + assertEquals(4, statements.size(), script); + assertEquals("COMMIT", statements.get(1).toString()); + assertInstanceOf(type, statements.get(2)); + assertEquals(sql, statements.get(2).toString()); + assertSpans(sql, script, (ASTNodeAccess) statements.get(2)); + assertInstanceOf(SavepointStatement.class, statements.get(3)); + } + } + + @Test + void scriptsKeepEachStatement() throws Exception { + String mysql = "XA START 'x'; INSERT INTO t VALUES (1); XA END 'x'; XA PREPARE 'x';" + + " XA RECOVER; XA COMMIT 'x'"; + Statements statements = + CCJSqlParserUtil.parseStatements(mysql, p -> p.withDialect(Dialect.MYSQL)); + assertEquals(6, statements.size()); + assertEquals(Set.of("t"), new TablesNamesFinder<>().getTables(statements.get(1))); + assertEquals(XaStatement.Action.RECOVER, ((XaStatement) statements.get(4)).getAction()); + String postgres = "BEGIN; UPDATE t SET a = 1; PREPARE TRANSACTION 'x'; COMMIT PREPARED 'x';" + + " ROLLBACK PREPARED 'y'; END"; + statements = CCJSqlParserUtil.parseStatements(postgres, + p -> p.withDialect(Dialect.POSTGRESQL)); + assertEquals(6, statements.size()); + assertInstanceOf(PrepareTransactionStatement.class, statements.get(2)); + assertEquals(Commit.Command.END, ((Commit) statements.get(5)).getCommand()); + assertEquals(statements.toString(), CCJSqlParserUtil.parseStatements( + statements.toString(), p -> p.withDialect(Dialect.POSTGRESQL)).toString()); + } + + @Test + void visitorDispatchPassesContextAndResult() throws Exception { + StatementVisitorAdapter visitor = new StatementVisitorAdapter<>() { + @Override + public String visit(PrepareTransactionStatement statement, S context) { + return context + ":" + statement.getTransactionId(); + } + + @Override + public String visit(XaStatement statement, S context) { + return context + ":" + statement.getAction(); + } + }; + assertEquals("ctx:'x'", parse("PREPARE TRANSACTION 'x'", null).accept(visitor, "ctx")); + assertEquals("ctx:END", parse("XA END 'x'", null).accept(visitor, "ctx")); + assertNull(parse("XA END 'x'", null).accept(new StatementVisitorAdapter<>(), null)); + assertNull(parse("PREPARE TRANSACTION 'x'", null) + .accept(new StatementVisitorAdapter<>(), null)); + } + + private static boolean isValid(Dialect dialect, String sql, + ValidationCapability... capabilities) { + FeatureConfiguration configuration = new FeatureConfiguration(); + if (dialect != null) { + configuration.setValue(Feature.dialect, dialect.name()); + } + return new Validation(configuration, Arrays.asList(capabilities), sql).validate() + .isEmpty(); + } + + @Test + void validationKnowsWhichServersTakeWhichForm() { + String postgres = "PREPARE TRANSACTION 'x'; COMMIT PREPARED 'x'; ROLLBACK PREPARED 'x'"; + assertTrue(isValid(Dialect.POSTGRESQL, postgres, PostgresqlVersion.V10)); + assertTrue(isValid(null, postgres, PostgresqlVersion.V14)); + for (String sql : postgres.split("; ")) { + assertFalse(isValid(null, sql, MySqlVersion.V8_0), sql); + assertFalse(isValid(null, sql, OracleVersion.V19C), sql); + } + assertFalse(isValid(Dialect.POSTGRESQL, "COMMIT PREPARED 'x'", + new FeaturesAllowed(Feature.commit))); + assertTrue(isValid(Dialect.POSTGRESQL, "COMMIT PREPARED 'x'", + new FeaturesAllowed(Feature.commit, Feature.twoPhaseCommit))); + assertTrue(isValid(Dialect.POSTGRESQL, "ROLLBACK PREPARED 'x'", + new FeaturesAllowed(Feature.rollback, Feature.twoPhaseCommit))); + assertTrue(isValid(Dialect.ORACLE, "ROLLBACK FORCE 'x'", OracleVersion.V19C)); + + String xa = "XA START 'x'; XA END 'x'; XA PREPARE 'x'; XA COMMIT 'x'; XA RECOVER"; + assertTrue(isValid(Dialect.MYSQL, xa, MySqlVersion.V8_0)); + assertTrue(isValid(Dialect.MARIADB, xa, MariaDbVersion.V10_5_4)); + assertTrue(isValid(null, xa, new FeaturesAllowed(Feature.xa))); + assertFalse(isValid(null, "XA RECOVER", PostgresqlVersion.V14)); + assertFalse(isValid(null, "XA ROLLBACK 'x'", new FeaturesAllowed(Feature.rollback))); + } + + // ---- the shared model ---------------------------------------------------------------------- + + @Test + void transactionIdKeepsTheXidShape() { + TransactionId id = new TransactionId(new StringValue("'g'")); + assertEquals("'g'", id.toString()); + id.withBranchQualifier(new HexValue("X'0a'")).withFormatId(3L); + assertEquals("'g', X'0a', 3", id.toString()); + assertThrows(IllegalStateException.class, () -> id.setBranchQualifier(null)); + assertThrows(IllegalArgumentException.class, () -> id.setFormatId(-1L)); + id.withFormatId(null).withBranchQualifier(null); + assertEquals("'g'", id.toString()); + assertThrows(IllegalStateException.class, () -> id.setFormatId(1L)); + assertThrows(NullPointerException.class, () -> id.setGlobalTransactionId(null)); + assertEquals("'h', 'b', 1", + new TransactionId(new StringValue("'h'"), new StringValue("'b'"), 1L).toString()); + assertThrows(IllegalStateException.class, + () -> new TransactionId(new StringValue("'h'"), null, 1L)); + id.withGlobalTransactionId(new HexValue("0x01")); + assertEquals("0x01", id.appendTo(new StringBuilder()).toString()); + } + + @Test + void xaStatementKeepsOptionsWithTheirAction() { + TransactionId xid = new TransactionId(new StringValue("'x'")); + XaStatement statement = new XaStatement(XaStatement.Action.END, xid) + .withOption(XaStatement.Option.SUSPEND_FOR_MIGRATE); + assertEquals("XA END 'x' SUSPEND FOR MIGRATE", statement.toString()); + assertThrows(IllegalArgumentException.class, + () -> statement.setAction(XaStatement.Action.COMMIT)); + assertThrows(IllegalArgumentException.class, + () -> statement.setOption(XaStatement.Option.ONE_PHASE)); + statement.withOption(null).withAction(XaStatement.Action.COMMIT) + .withOption(XaStatement.Option.ONE_PHASE); + assertEquals("XA COMMIT 'x' ONE PHASE", statement.toString()); + StringBuilder output = new StringBuilder(); + statement.accept(new StatementDeParser(output), null); + assertEquals("XA COMMIT 'x' ONE PHASE", output.toString()); + assertSame(xid, statement.getTransactionId()); + XaStatement recover = new XaStatement(XaStatement.Action.RECOVER, null) + .withOption(XaStatement.Option.CONVERT_XID); + assertEquals("XA RECOVER CONVERT XID", recover.toString()); + recover.withOption(null).withAction(XaStatement.Action.BEGIN).withTransactionId(xid) + .withOption(XaStatement.Option.RESUME); + assertEquals("XA BEGIN 'x' RESUME", recover.toString()); + assertThrows(NullPointerException.class, () -> recover.setAction(null)); + assertThrows(NullPointerException.class, () -> new XaStatement(null, xid)); + for (XaStatement.Option option : XaStatement.Option.values()) { + long actions = Arrays.stream(XaStatement.Action.values()).filter(option::appliesTo) + .count(); + assertEquals(option == XaStatement.Option.JOIN + || option == XaStatement.Option.RESUME ? 2 : 1, actions, option.name()); + } + } +} diff --git a/src/test/java/net/sf/jsqlparser/util/validation/validator/StatementValidatorTest.java b/src/test/java/net/sf/jsqlparser/util/validation/validator/StatementValidatorTest.java index 51d471763..a9967d6c5 100644 --- a/src/test/java/net/sf/jsqlparser/util/validation/validator/StatementValidatorTest.java +++ b/src/test/java/net/sf/jsqlparser/util/validation/validator/StatementValidatorTest.java @@ -50,6 +50,8 @@ static Stream statementsOutsideSelect() { Arguments.of("DEALLOCATE PREPARE p", Feature.deallocate), Arguments.of("SAVEPOINT s", Feature.savepoint), Arguments.of("ROLLBACK", Feature.rollback), + Arguments.of("PREPARE TRANSACTION 'x'", Feature.twoPhaseCommit), + Arguments.of("XA RECOVER", Feature.xa), Arguments.of("LOCK TABLE t IN EXCLUSIVE MODE", Feature.lock), Arguments.of("ALTER SESSION SET CURRENT_SCHEMA = other", Feature.alterSession), Arguments.of("ALTER SYSTEM SET archive_command = 'cp %p /tmp/%f'",