From 8de260afac1cf96a9175f9b372ce405584a0009b Mon Sep 17 00:00:00 2001 From: minleejae Date: Mon, 9 Nov 2026 10:23:54 +0900 Subject: [PATCH] feat: parse PostgreSQL two-phase commit and MySQL XA statements PostgreSQL PREPARE TRANSACTION, COMMIT PREPARED and ROLLBACK PREPARED and the MySQL XA statements failed to parse. Add TransactionId, one identifier model for MySQL's xid (gtrid [, bqual [, formatID]]) and the single string of PostgreSQL and of Oracle's ROLLBACK FORCE. Commit and RollbackStatement name a prepared transaction through TransactionCompletionStatement, with PREPARED or FORCE as the keyword; the legacy FORCE accessors derive from it. PREPARE TRANSACTION is a new PrepareTransactionStatement, dispatched before PREPARE name AS only when a string literal follows. The XA statements are one XaStatement with an Action and an Option. The PostgreSQL forms are read with that dialect or none and take a string constant; XA is read with the MySQL and MariaDB dialects or none and follows MySQL's xid grammar. XA stays usable as a name. Wire the statements into the deparser, visitors, table finder, feature analysis and validation, with new twoPhaseCommit and xa features. Signed-off-by: minleejae --- README.md | 1 + .../sf/jsqlparser/parser/feature/Feature.java | 14 + .../net/sf/jsqlparser/statement/Commit.java | 15 +- .../PrepareTransactionStatement.java | 58 ++ .../statement/RollbackStatement.java | 53 +- .../statement/StatementFeatureVisitor.java | 12 + .../statement/StatementVisitor.java | 16 + .../statement/StatementVisitorAdapter.java | 10 + .../TransactionCompletionStatement.java | 50 +- .../jsqlparser/statement/TransactionId.java | 129 +++++ .../sf/jsqlparser/statement/XaStatement.java | 152 +++++ .../sf/jsqlparser/util/TablesNamesFinder.java | 12 + .../util/deparser/StatementDeParser.java | 12 + .../validation/feature/MariaDbVersion.java | 3 +- .../util/validation/feature/MySqlVersion.java | 3 +- .../validation/feature/PostgresqlVersion.java | 3 +- .../validator/StatementValidator.java | 23 +- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 250 +++++++- src/site/sphinx/index.rst | 2 + src/site/sphinx/usage.rst | 39 +- .../parser/ASTNodeCoverageTest.java | 3 + .../statement/TransactionStatementTest.java | 2 +- .../statement/TwoPhaseTransactionTest.java | 548 ++++++++++++++++++ .../validator/StatementValidatorTest.java | 2 + 24 files changed, 1368 insertions(+), 44 deletions(-) create mode 100644 src/main/java/net/sf/jsqlparser/statement/PrepareTransactionStatement.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/TransactionId.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/XaStatement.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/TwoPhaseTransactionTest.java 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'",