diff --git a/src/main/java/net/sf/jsqlparser/statement/alter/AlterExpressionDrop.java b/src/main/java/net/sf/jsqlparser/statement/alter/AlterExpressionDrop.java index 1fbb5a95e..94ec92e7b 100644 --- a/src/main/java/net/sf/jsqlparser/statement/alter/AlterExpressionDrop.java +++ b/src/main/java/net/sf/jsqlparser/statement/alter/AlterExpressionDrop.java @@ -9,8 +9,11 @@ */ package net.sf.jsqlparser.statement.alter; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import net.sf.jsqlparser.statement.CascadeBehavior; import net.sf.jsqlparser.statement.select.PlainSelect; import net.sf.jsqlparser.statement.create.table.ConstraintKind; @@ -19,6 +22,47 @@ * DROP INDEX/KEY, DROP PRIMARY KEY, DROP UNIQUE, DROP FOREIGN KEY, and DROP PARTITION. */ public class AlterExpressionDrop extends AlterExpression { + private CascadeBehavior behavior; + + /** The explicit CASCADE or RESTRICT clause; null when omitted. */ + public CascadeBehavior getBehavior() { + return behavior; + } + + public void setBehavior(CascadeBehavior behavior) { + this.behavior = behavior; + } + + public AlterExpressionDrop withBehavior(CascadeBehavior behavior) { + setBehavior(behavior); + return this; + } + + /** + * Returns the raw parameters, such as Oracle's {@code INVALIDATE} or + * {@code CASCADE CONSTRAINTS}. With a {@link #getBehavior() behavior}, this is an unmodifiable + * snapshot that ends with its keyword; use {@link #addParameters(String...)} to append raw + * parameters. + */ + @Override + public List getParameters() { + List parameters = super.getParameters(); + if (behavior == null) { + return parameters; + } + List tokens = parameters == null ? new ArrayList<>() : new ArrayList<>(parameters); + tokens.add(behavior.name()); + return Collections.unmodifiableList(tokens); + } + + @Override + protected void appendCommonTail(StringBuilder b) { + super.appendCommonTail(b); + if (behavior != null) { + b.append(' ').append(behavior); + } + } + @Override public List getColumnIdentityActions() { return getColumnIdentityActionsFromDefinitions(); diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/PartitionDefinition.java b/src/main/java/net/sf/jsqlparser/statement/create/table/PartitionDefinition.java index 0f654326f..3d764b374 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/table/PartitionDefinition.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/PartitionDefinition.java @@ -11,6 +11,7 @@ import java.io.Serializable; import java.util.List; +import net.sf.jsqlparser.expression.StringValue; import net.sf.jsqlparser.statement.select.PlainSelect; public class PartitionDefinition implements Serializable { @@ -39,7 +40,7 @@ public String toString() { private boolean subPartition; private boolean storageKeyword; private boolean storageEngineUseEquals = true; - private String comment; + private StringValue commentValue; private boolean commentUseEquals; private String dataDirectory; private boolean dataDirectoryUseEquals; @@ -143,12 +144,26 @@ public void setStorageEngineUseEquals(boolean storageEngineUseEquals) { this.storageEngineUseEquals = storageEngineUseEquals; } + /** Returns the COMMENT literal's SQL text, for example {@code 'it''s'}, or null. */ public String getComment() { - return comment; + return commentValue != null ? commentValue.toString() : null; } + /** + * Sets the COMMENT from a string literal's SQL text, read with standard escaping. Use + * {@link #setCommentValue(StringValue)} for another escaping. Null removes the comment. + */ public void setComment(String comment) { - this.comment = comment; + commentValue = comment != null ? new StringValue(comment) : null; + } + + /** Returns the COMMENT string literal; {@link StringValue#getNotExcapedValue()} decodes it. */ + public StringValue getCommentValue() { + return commentValue; + } + + public void setCommentValue(StringValue commentValue) { + this.commentValue = commentValue; } public boolean isCommentUseEquals() { @@ -266,7 +281,7 @@ public String toString() { } appendOption(b, "ENGINE", storageEngine, storageEngineUseEquals); } - appendOption(b, "COMMENT", comment, commentUseEquals); + appendOption(b, "COMMENT", getComment(), commentUseEquals); appendOption(b, "DATA DIRECTORY", dataDirectory, dataDirectoryUseEquals); appendOption(b, "INDEX DIRECTORY", indexDirectory, indexDirectoryUseEquals); appendOption(b, "MAX_ROWS", maxRows, maxRowsUseEquals); diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/TableOption.java b/src/main/java/net/sf/jsqlparser/statement/create/table/TableOption.java index a25474069..003907831 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/table/TableOption.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/TableOption.java @@ -14,6 +14,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import net.sf.jsqlparser.expression.StringValue; import net.sf.jsqlparser.statement.select.PlainSelect; import net.sf.jsqlparser.schema.Table; @@ -33,9 +34,25 @@ public class TableOption implements Serializable { private List unionTables; private List storageParameters; private ColumnOption.Storage tablespaceStorage; + private OnCommitAction onCommitAction; + private StringValue comment; public enum Kind { - ENGINE, CHARACTER_SET, COLLATE, COMMENT, AUTO_INCREMENT, STATS_AUTO_RECALC, STATS_PERSISTENT, STATS_SAMPLE_PAGES, UNION, ENCRYPTION, PASSWORD, DATA_DIRECTORY, INDEX_DIRECTORY, SECONDARY_ENGINE, AUTOEXTEND_SIZE, INSERT_METHOD, PACK_KEYS, DELAY_KEY_WRITE, CHECKSUM, CONNECTION, COMPRESSION, STORAGE_PARAMETERS, WITHOUT_OIDS, ENGINE_ATTRIBUTE, SECONDARY_ENGINE_ATTRIBUTE, ROW_FORMAT, AVG_ROW_LENGTH, MAX_ROWS, MIN_ROWS, KEY_BLOCK_SIZE, TABLESPACE, FOREIGN_SERVER, OTHER + ENGINE, CHARACTER_SET, COLLATE, COMMENT, AUTO_INCREMENT, STATS_AUTO_RECALC, STATS_PERSISTENT, STATS_SAMPLE_PAGES, UNION, ENCRYPTION, PASSWORD, DATA_DIRECTORY, INDEX_DIRECTORY, SECONDARY_ENGINE, AUTOEXTEND_SIZE, INSERT_METHOD, PACK_KEYS, DELAY_KEY_WRITE, CHECKSUM, CONNECTION, COMPRESSION, STORAGE_PARAMETERS, WITHOUT_OIDS, ENGINE_ATTRIBUTE, SECONDARY_ENGINE_ATTRIBUTE, ROW_FORMAT, AVG_ROW_LENGTH, MAX_ROWS, MIN_ROWS, KEY_BLOCK_SIZE, TABLESPACE, FOREIGN_SERVER, ACCESS_METHOD, ON_COMMIT, OTHER + } + + /** + * The action of a temporary table's {@code ON COMMIT} clause. The DEFINITION actions belong to + * Oracle private temporary tables. + */ + public enum OnCommitAction { + PRESERVE_ROWS, DELETE_ROWS, DROP, PRESERVE_DEFINITION, DROP_DEFINITION; + + /** Returns the SQL keywords, for example {@code DELETE ROWS}. */ + @Override + public String toString() { + return name().replace('_', ' '); + } } public ForeignTableOptions getForeignTableOptions() { @@ -78,6 +95,31 @@ public void setTablespaceStorage(ColumnOption.Storage storage) { tablespaceStorage = storage; } + /** Creates a table access method option such as PostgreSQL's {@code USING heap}. */ + public static TableOption accessMethod(String method) { + return new TableOption(Kind.ACCESS_METHOD, "USING", method, false); + } + + /** Returns the {@code ON COMMIT} action, or null if this option has none. */ + public OnCommitAction getOnCommitAction() { + return onCommitAction; + } + + /** Replaces the current contents with an {@code ON COMMIT} clause. */ + public void setOnCommitAction(OnCommitAction action) { + clearContents(); + kind = Kind.ON_COMMIT; + name = "ON COMMIT"; + onCommitAction = action; + } + + /** Creates an {@code ON COMMIT} option, for example {@code ON COMMIT DROP}. */ + public static TableOption onCommit(OnCommitAction action) { + TableOption option = new TableOption(); + option.setOnCommitAction(action); + return option; + } + public List getStorageParameters() { return storageParameters; } @@ -89,6 +131,39 @@ public void setStorageParameters(List storageParameters) { name = "WITH"; } + /** + * Returns the COMMENT string literal, or null for another kind, opaque tokens or a plain value + * set with {@link #setValue(String)}. {@link StringValue#getNotExcapedValue()} decodes it. + */ + public StringValue getComment() { + return comment; + } + + /** + * Replaces the current contents with a COMMENT string literal and keeps the equals spelling; an + * existing COMMENT also keeps its name. A null literal omits the value. + */ + public void setComment(StringValue comment) { + String commentName = kind == Kind.COMMENT && name != null ? name : "COMMENT"; + boolean equals = useEquals; + clearContents(); + kind = Kind.COMMENT; + name = commentName; + useEquals = equals; + this.comment = comment; + } + + public TableOption withComment(StringValue comment) { + setComment(comment); + return this; + } + + public static TableOption comment(StringValue comment) { + TableOption option = new TableOption(); + option.setComment(comment); + return option; + } + public void appendTo(StringBuilder builder, java.util.function.Consumer expressionPrinter) { if (foreignTableOptions != null) { @@ -96,6 +171,9 @@ public void appendTo(StringBuilder builder, } else if (storageParameters != null) { builder.append(name).append(useEquals ? " = " : " "); IndexOption.appendListTo(builder, storageParameters, expressionPrinter); + } else if (comment != null) { + builder.append(name).append(useEquals ? " = " : " "); + expressionPrinter.accept(comment); } else { builder.append(toString()); } @@ -140,6 +218,14 @@ public void setKind(Kind kind) { if (kind != Kind.UNION) { unionTables = null; } + if (kind != Kind.ON_COMMIT) { + onCommitAction = null; + } + if (kind != Kind.COMMENT && comment != null) { + // keep the literal's SQL text, as for other scalar values + value = comment.toString(); + comment = null; + } } public String getName() { @@ -158,15 +244,24 @@ public String getValue() { if (storageParameters != null) { return PlainSelect.getStringList(storageParameters, true, true); } + if (onCommitAction != null) { + return onCommitAction.toString(); + } + if (comment != null) { + return comment.toString(); + } return unionTables == null ? value : PlainSelect.getStringList(unionTables, true, true); } + /** Sets a plain value; it replaces a COMMENT literal without being parsed. */ public void setValue(String value) { tokens = null; this.value = value; foreignTableOptions = null; unionTables = null; storageParameters = null; + onCommitAction = null; + comment = null; } /** Returns the mutable MERGE table sources, including an empty UNION list. */ @@ -214,7 +309,10 @@ public List getTokens() { result.add("="); } String renderedValue = getValue(); - if (renderedValue != null) { + if (onCommitAction != null) { + // one token per keyword, as in the legacy raw options + Collections.addAll(result, renderedValue.split(" ")); + } else if (renderedValue != null) { result.add(renderedValue); } if (kind == Kind.TABLESPACE && tablespaceStorage != null) { @@ -245,6 +343,8 @@ private void clearContents() { foreignTableOptions = null; unionTables = null; storageParameters = null; + onCommitAction = null; + comment = null; } public TableOption withKind(Kind kind) { diff --git a/src/main/java/net/sf/jsqlparser/util/TableDefinitionTraversal.java b/src/main/java/net/sf/jsqlparser/util/TableDefinitionTraversal.java index 739232035..e3008ae01 100644 --- a/src/main/java/net/sf/jsqlparser/util/TableDefinitionTraversal.java +++ b/src/main/java/net/sf/jsqlparser/util/TableDefinitionTraversal.java @@ -152,11 +152,12 @@ public static void visit(CreateTable table, Consumer expressions, visit(table.getPartitionBound(), expressions); } - /** Shared CREATE/ALTER table option traversal. */ + /** Shared CREATE/ALTER table option traversal, including COMMENT literals. */ public static void visit(net.sf.jsqlparser.statement.create.table.TableOption option, Consumer expressions, Consumer
tables) { if (option != null) { visitOptions(option.getStorageParameters(), expressions); + accept(option.getComment(), expressions); if (option.getUnionTables() != null) { option.getUnionTables().forEach(source -> accept(source, tables)); } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 2210dbe9d..814389d0e 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -16593,7 +16593,7 @@ void CreateTableOptions(List options): } TableOption CreateTableOption(): -{ TableOption option; List tokens; List storageParameters; } +{ TableOption option; List tokens; List storageParameters; String name; } { ( LOOKAHEAD( "(") storageParameters=PostgreSqlIndexOptions() { @@ -16604,6 +16604,12 @@ TableOption CreateTableOption(): option = new TableOption(TableOption.Kind.WITHOUT_OIDS, "WITHOUT OIDS", null, false); } | + LOOKAHEAD( ) option=OnCommitTableOption() + | + // USING INDEX TABLESPACE stays a raw parameter + LOOKAHEAD(2, { getToken(2).kind != K_INDEX }) name=RelObjectName() + { option = TableOption.accessMethod(name); } + | LOOKAHEAD({ isMySqlTableOptionAhead() }) option=MySqlTableOption() | // Snowflake: COPY is reserved because it starts the COPY statement @@ -16613,6 +16619,28 @@ TableOption CreateTableOption(): { return option; } } +/** Temporary table ON COMMIT action, including Oracle's private temporary table forms. */ +TableOption OnCommitTableOption(): +{ TableOption.OnCommitAction action; } +{ + + ( + { action = TableOption.OnCommitAction.DELETE_ROWS; } + | + { action = TableOption.OnCommitAction.DROP; } + [ LOOKAHEAD({ isKeywordAhead("DEFINITION") }) ContextualKeyword("DEFINITION") + { action = TableOption.OnCommitAction.DROP_DEFINITION; } ] + | + LOOKAHEAD({ isKeywordAhead("PRESERVE") }) ContextualKeyword("PRESERVE") + ( + { action = TableOption.OnCommitAction.PRESERVE_ROWS; } + | + ContextualKeyword("DEFINITION") { action = TableOption.OnCommitAction.PRESERVE_DEFINITION; } + ) + ) + { return TableOption.onCommit(action); } +} + TableOption MySqlTableOption(): { Token tk; Token tk2 = null; @@ -16624,6 +16652,7 @@ TableOption MySqlTableOption(): { TableOption.Kind kind = null; TableOption option; ColumnOption.Storage storage = null; + StringValue comment = null; } { ( tk= { name = tk.image; kind = TableOption.Kind.UNION; @@ -16694,10 +16723,16 @@ TableOption MySqlTableOption(): { kind = TableOption.Kind.COMMENT; } [ "=" { useEquals = true; } ] - value=MySqlTableOptionValue() + // A string literal keeps its escaping; other values remain plain text + ( + LOOKAHEAD({ getToken(1).kind == S_CHAR_LITERAL }) tk= + { comment = stringValue(tk); } + | value=MySqlTableOptionValue() + ) ) { option = new TableOption(kind, name, value, useEquals); + if (comment != null) { option.setComment(comment); } if (unionTables != null) { option.setUnionTables(unionTables); } option.setTablespaceStorage(storage); return option; @@ -18674,7 +18709,7 @@ void PartitionDefinitionOption(PartitionDefinition partitionDefinition): | [ "=" { useEquals = true; } ] tk= { - partitionDefinition.setComment(tk.image); + partitionDefinition.setCommentValue(stringValue(tk)); partitionDefinition.setCommentUseEquals(useEquals); } | @@ -18920,7 +18955,9 @@ void AlterExpressionAddConstraint(AlterExpression alterExp): */ AlterExpression AlterExpressionDrop(): { - AlterExpression alterExp = new AlterExpressionDrop(); + AlterExpressionDrop drop = new AlterExpressionDrop(); + AlterExpression alterExp = drop; + CascadeBehavior behavior; Token tk; Token tk2; List columnNames = null; @@ -18959,8 +18996,9 @@ AlterExpression AlterExpressionDrop(): (tk=KeywordOrIdentifier() ) { alterExp.setColumnName(tk.image); } [ "INVALIDATE" { alterExp.addParameters("INVALIDATE"); } ] [ - "CASCADE" { alterExp.addParameters("CASCADE"); } - [ "CONSTRAINTS" { alterExp.addParameters("CONSTRAINTS"); } ] + // Oracle CASCADE CONSTRAINTS remains a raw parameter + LOOKAHEAD(2) "CASCADE" "CONSTRAINTS" { alterExp.addParameters("CASCADE", "CONSTRAINTS"); } + | behavior=TypeAlterBehavior() { drop.setBehavior(behavior); } ] ) | @@ -18975,24 +19013,24 @@ AlterExpression AlterExpressionDrop(): ( tk= { alterExp.setOperation(AlterOperation.DROP_UNIQUE); } columnNames=ColumnsNamesList() { alterExp.setDropColumns(columnNames); columnNames = null; } - [ ( tk= | tk= ) { alterExp.addParameters(tk.image); } ] + [ behavior=TypeAlterBehavior() { drop.setBehavior(behavior); } ] ) | ( tk= tk2= { alterExp.setOperation(AlterOperation.DROP_PRIMARY_KEY); } - [ ( tk= | tk= ) { alterExp.addParameters(tk.image); } ] + [ behavior=TypeAlterBehavior() { drop.setBehavior(behavior); } ] ) | ( tk= tk2= { alterExp.setOperation(AlterOperation.DROP_FOREIGN_KEY); } columnNames=ColumnsNamesList() { alterExp.setDropColumns(columnNames); columnNames = null; } - [ ( tk= | tk= ) { alterExp.addParameters(tk.image); } ] + [ behavior=TypeAlterBehavior() { drop.setBehavior(behavior); } ] ) | ( [ { alterExp.setUsingIfExists(true); } ] ( tk= | tk=) { alterExp.setConstraintName(tk.image); } - [ ( tk= | tk= ) { alterExp.addParameters(tk.image); } ] + [ behavior=TypeAlterBehavior() { drop.setBehavior(behavior); } ] ) ) { return alterExp; } diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 901608d62..e16e6fc23 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -341,6 +341,14 @@ The existing getter and setter names remain, but their return and parameter type have changed, so clients must recompile. ``Truncate.setCascade(false)`` still clears the clause. Foreign-key referential actions retain their separate enum. +``ALTER TABLE`` drop actions expose the same enum through +``AlterExpressionDrop.getBehavior()``, for example in +``ALTER TABLE t DROP COLUMN IF EXISTS c RESTRICT`` and +``ALTER TABLE t DROP CONSTRAINT c CASCADE``. Oracle's ``CASCADE CONSTRAINTS`` and +``INVALIDATE`` remain raw parameters. ``getParameters()`` still ends with the +behavior keyword; when a behavior is set, it returns a read-only snapshot, so use +``setBehavior`` to change the clause. + Edit shared table options ------------------------- @@ -368,14 +376,75 @@ returns an unmodifiable snapshot. Any structured setter leaves the opaque representation; it never recovers previous values from those tokens. ``setTokens(null)`` clears opaque tokens without restoring discarded properties, and leaves structured options alone. -``setUnionTables``, ``setStorageParameters`` and ``setForeignTableOptions`` -replace the current contents with their respective structured payloads. Their -lists and child nodes remain editable. An empty UNION list means ``UNION ()``; +``setUnionTables``, ``setStorageParameters``, ``setForeignTableOptions`` and +``setOnCommitAction`` replace the current contents with their respective +structured payloads. Their lists and child nodes remain editable. An empty +UNION list means ``UNION ()``; a null payload omits its value. A non-null ``setTablespaceStorage`` also selects TABLESPACE; supply its tablespace name with ``setValue``. Choose the new kind and spelling explicitly when using the scalar setters. These methods edit the AST rather than checking database-specific DDL validity. +A temporary table's ``ON COMMIT`` clause and a table access method are single +options. ``Kind.ON_COMMIT`` exposes ``getOnCommitAction()``: ``PRESERVE_ROWS``, +``DELETE_ROWS`` or ``DROP``, plus ``PRESERVE_DEFINITION`` and ``DROP_DEFINITION`` +for Oracle private temporary tables. ``Kind.ACCESS_METHOD`` stores the method of +``USING method`` as its value, including ``CREATE TABLE … USING method AS query``. +Use ``TableOption.onCommit`` and ``TableOption.accessMethod`` to build them. +``getTableOptionsStrings()`` keeps the legacy word tokens, such as ``ON``, +``COMMIT``, ``DELETE`` and ``ROWS``. + +.. code-block:: java + + CreateTable table = (CreateTable) CCJSqlParserUtil.parse( + "CREATE TEMP TABLE t (id INT) USING heap ON COMMIT DROP", + parser -> parser.withDialect(Dialect.POSTGRESQL)); + table.getTableOption(TableOption.Kind.ACCESS_METHOD).orElseThrow().getValue(); // heap + table.getTableOption(TableOption.Kind.ON_COMMIT).orElseThrow() + .setOnCommitAction(TableOption.OnCommitAction.PRESERVE_ROWS); + // CREATE TEMP TABLE t (id INT) USING heap ON COMMIT PRESERVE ROWS + +MySQL table and partition comments +---------------------------------- + +A table ``COMMENT`` in CREATE TABLE, CREATE TABLE … AS SELECT and ALTER TABLE is a +``StringValue`` in ``TableOption.getComment()``. Partition and subpartition comments, +including those of ALTER TABLE ADD and REORGANIZE PARTITION, are in +``PartitionDefinition.getCommentValue()``. The literal records the escaping of the +parser configuration, so ``getNotExcapedValue()`` returns the text MySQL stores. + +.. code-block:: java + + CreateTable table = (CreateTable) CCJSqlParserUtil.parse( + "CREATE TABLE t (id INT) COMMENT = 'it\\'s' " + + "PARTITION BY HASH (id) (PARTITION p0 COMMENT 'a\\\\b')", + parser -> parser.withDialect(Dialect.MYSQL)); + TableOption option = table.getTableOption(TableOption.Kind.COMMENT).orElseThrow(); + option.getComment().getNotExcapedValue(); // it's + option.getValue(); // 'it\'s' + table.getPartitioning().getPartitionDefinitions().get(0) + .getCommentValue().getNotExcapedValue(); // a\b + +``getValue()``, ``getTokens()``, ``CreateTable.getTableOptionsStrings()``, +``AlterExpressionTableOption.getCommentText()`` and ``PartitionDefinition.getComment()`` +still return the literal as written, and the SQL output is unchanged except that a +string prefix such as ``n'…'`` is printed in upper case, as in other string literals. +The table comment literal is rendered by the expression deparser and visited like a +column COMMENT literal. + +``setComment``, ``withComment`` and the ``TableOption.comment`` factory replace the +option's contents with the literal and select COMMENT. They keep the equals spelling, +and an existing COMMENT also keeps its name; a null literal omits the value. +``setValue`` stores a plain value and ``setTokens`` opaque tokens. Neither is parsed, +so ``getComment()`` then returns null. Selecting another kind keeps the literal's SQL +text as a plain value. A COMMENT value that is not a string literal also stays a plain +value, for example a double-quoted string parsed without the MySQL dialect, where it +is a quoted identifier. + +``PartitionDefinition.setComment(String)`` reads the SQL text of a literal with +standard escaping; pass a ``StringValue`` with another escaping to +``setCommentValue``. + Inspect PostgreSQL schema statements ------------------------------------ diff --git a/src/test/java/net/sf/jsqlparser/statement/alter/AlterDropBehaviorTest.java b/src/test/java/net/sf/jsqlparser/statement/alter/AlterDropBehaviorTest.java new file mode 100644 index 000000000..3a0263388 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/alter/AlterDropBehaviorTest.java @@ -0,0 +1,143 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.alter; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.CascadeBehavior; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +class AlterDropBehaviorTest { + private static final Dialect[] DIALECTS = {Dialect.POSTGRESQL, null}; + + @ParameterizedTest + @CsvSource(value = {"DROP COLUMN b RESTRICT|RESTRICT", "DROP b RESTRICT|RESTRICT", + "DROP COLUMN IF EXISTS b RESTRICT|RESTRICT", "DROP IF EXISTS b CASCADE|CASCADE", + "DROP COLUMN b CASCADE|CASCADE", "DROP COLUMN b|"}, delimiter = '|') + void columnDropsExposeTheirBehavior(String action, CascadeBehavior behavior) + throws JSQLParserException { + String sql = "ALTER TABLE users " + action; + for (Dialect dialect : DIALECTS) { + Alter alter = parse(sql, dialect); + AlterExpressionDrop drop = + assertInstanceOf(AlterExpressionDrop.class, alter.getAlterExpressions().get(0)); + assertEquals("b", drop.getColumnName()); + assertEquals(action.contains("IF EXISTS"), drop.isUsingIfExists()); + assertEquals(behavior, drop.getBehavior()); + assertEquals(behavior == null ? null : List.of(behavior.name()), + drop.getParameters()); + assertRoundTrip(alter, sql, dialect); + } + } + + @Test + void restrictAppliesToItsOwnAction() throws JSQLParserException { + String sql = "ALTER TABLE users DROP a, DROP COLUMN b RESTRICT"; + for (Dialect dialect : DIALECTS) { + Alter alter = parse(sql, dialect); + assertNull(((AlterExpressionDrop) alter.getAlterExpressions().get(0)).getBehavior()); + assertEquals(CascadeBehavior.RESTRICT, + ((AlterExpressionDrop) alter.getAlterExpressions().get(1)).getBehavior()); + assertRoundTrip(alter, sql, dialect); + } + } + + @ParameterizedTest + @CsvSource(value = {"DROP CONSTRAINT IF EXISTS c RESTRICT|RESTRICT", + "DROP CONSTRAINT c CASCADE|CASCADE", "DROP PRIMARY KEY CASCADE|CASCADE", + "DROP UNIQUE (a, b) RESTRICT|RESTRICT", "DROP FOREIGN KEY (a) CASCADE|CASCADE"}, + delimiter = '|') + void constraintDropsShareTheBehavior(String action, CascadeBehavior behavior) + throws JSQLParserException { + String sql = "ALTER TABLE t " + action; + AlterExpressionDrop drop = assertInstanceOf(AlterExpressionDrop.class, + parse(sql, null).getAlterExpressions().get(0)); + assertEquals(behavior, drop.getBehavior()); + assertEquals(List.of(behavior.name()), drop.getParameters()); + assertRoundTrip(parse(sql, null), sql, null); + } + + @ParameterizedTest + @CsvSource(value = {"DROP COLUMN c CASCADE CONSTRAINTS||CASCADE,CONSTRAINTS", + "DROP COLUMN c INVALIDATE CASCADE CONSTRAINTS||INVALIDATE,CASCADE,CONSTRAINTS", + "DROP COLUMN c INVALIDATE CASCADE|CASCADE|INVALIDATE,CASCADE", + "DROP (a, b) CASCADE CONSTRAINTS||CASCADE,CONSTRAINTS"}, delimiter = '|') + void oracleClausesRemainRawParameters(String action, CascadeBehavior behavior, + String parameters) throws JSQLParserException { + String sql = "ALTER TABLE t " + action; + for (Dialect dialect : new Dialect[] {Dialect.ORACLE, null}) { + Alter alter = parse(sql, dialect); + AlterExpressionDrop drop = (AlterExpressionDrop) alter.getAlterExpressions().get(0); + assertEquals(behavior, drop.getBehavior()); + assertEquals(List.of(parameters.split(",")), drop.getParameters()); + assertRoundTrip(alter, sql, dialect); + } + } + + @Test + void editingTheBehaviorUpdatesBothRenderersAndLegacyParameters() + throws JSQLParserException { + Alter alter = parse("ALTER TABLE users DROP COLUMN b RESTRICT", Dialect.POSTGRESQL); + AlterExpressionDrop drop = (AlterExpressionDrop) alter.getAlterExpressions().get(0); + assertThrows(UnsupportedOperationException.class, () -> drop.getParameters().add("X")); + + drop.setBehavior(CascadeBehavior.CASCADE); + assertEquals(List.of("CASCADE"), drop.getParameters()); + assertRoundTrip(alter, "ALTER TABLE users DROP COLUMN b CASCADE", Dialect.POSTGRESQL); + + drop.setBehavior(null); + assertNull(drop.getParameters()); + assertRoundTrip(alter, "ALTER TABLE users DROP COLUMN b", Dialect.POSTGRESQL); + + AlterExpressionDrop built = + new AlterExpressionDrop().withBehavior(CascadeBehavior.RESTRICT); + built.setOperation(AlterOperation.DROP); + built.hasColumn(true); + built.setColumnName("b"); + Alter statement = new Alter(); + statement.setTable(new Table("users")); + statement.addAlterExpression(built); + assertRoundTrip(statement, "ALTER TABLE users DROP COLUMN b RESTRICT", Dialect.POSTGRESQL); + } + + @ParameterizedTest + @ValueSource(strings = {"DROP COLUMN b RESTRICT CASCADE", "DROP COLUMN b CASCADE RESTRICT", + "DROP COLUMN b RESTRICT RESTRICT"}) + void rejectsRepeatedBehaviors(String action) { + for (Dialect dialect : DIALECTS) { + assertThrows(JSQLParserException.class, + () -> parse("ALTER TABLE users " + action, dialect)); + } + } + + private static Alter parse(String sql, Dialect dialect) throws JSQLParserException { + return (Alter) (dialect == null + ? CCJSqlParserUtil.parse(sql) + : CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(dialect))); + } + + private static void assertRoundTrip(Alter alter, String expected, Dialect dialect) + throws JSQLParserException { + assertEquals(expected, alter.toString()); + StringBuilder deparsed = new StringBuilder(); + alter.accept(new StatementDeParser(deparsed), null); + assertEquals(expected, deparsed.toString()); + assertEquals(expected, parse(deparsed.toString(), dialect).toString()); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/create/MySqlCommentLiteralTest.java b/src/test/java/net/sf/jsqlparser/statement/create/MySqlCommentLiteralTest.java new file mode 100644 index 000000000..f8ffcd23c --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/MySqlCommentLiteralTest.java @@ -0,0 +1,366 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.create; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.alter.Alter; +import net.sf.jsqlparser.statement.alter.AlterExpression; +import net.sf.jsqlparser.statement.alter.AlterExpressionTableOption; +import net.sf.jsqlparser.statement.alter.AlterOperation; +import net.sf.jsqlparser.statement.create.table.CreateTable; +import net.sf.jsqlparser.statement.create.table.PartitionDefinition; +import net.sf.jsqlparser.statement.create.table.TableOption; +import net.sf.jsqlparser.util.TableDefinitionTraversal; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +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; + +/** MySQL table and partition COMMENT literals and the values a server stores for them. */ +class MySqlCommentLiteralTest { + + /** Literals with the comment MySQL 8.4 stores for them. */ + static Stream mySqlLiterals() { + return Stream.of( + Arguments.of("'users'", "users"), + Arguments.of("'사용자 테이블'", "사용자 테이블"), + Arguments.of("'it''s'", "it's"), + Arguments.of("'it\\'s'", "it's"), + Arguments.of("\"dq\"", "dq"), + Arguments.of("\"double \\\"quoted\\\" text\"", "double \"quoted\" text"), + Arguments.of("''", ""), + Arguments.of("'line1\\nline2'", "line1\nline2"), + Arguments.of("'a\\\\b'", "a\\b"), + Arguments.of("'tab\\there'", "tab\there"), + Arguments.of("'percent\\% underscore\\_'", "percent\\% underscore\\_"), + Arguments.of("'emoji 😀'", "emoji 😀"), + Arguments.of("'first line\nsecond line'", "first line\nsecond line")); + } + + /** Literals that read the same with and without a dialect. */ + static Stream portableLiterals() { + return Stream.of( + Arguments.of("'users'", "users"), + Arguments.of("'it''s'", "it's"), + Arguments.of("''", ""), + Arguments.of("'사용자 테이블'", "사용자 테이블"), + Arguments.of("'emoji 😀'", "emoji 😀")); + } + + @ParameterizedTest + @MethodSource("mySqlLiterals") + void tableCommentsDecodeToTheStoredValue(String literal, String stored) + throws JSQLParserException { + assertTableComments(literal, stored, Dialect.MYSQL); + } + + @ParameterizedTest + @MethodSource("portableLiterals") + void tableCommentsDecodeWithoutDialect(String literal, String stored) + throws JSQLParserException { + assertTableComments(literal, stored, null); + } + + private static void assertTableComments(String literal, String stored, Dialect dialect) + throws JSQLParserException { + String sql = "CREATE TABLE t (id INT) ENGINE = InnoDB COMMENT = " + literal + + " DEFAULT CHARSET = utf8mb4"; + CreateTable create = (CreateTable) parse(sql, dialect); + assertComment(create.getTableOptions().get(1), literal, stored); + assertEquals(List.of("ENGINE", "=", "InnoDB", "COMMENT", "=", literal, "DEFAULT", + "CHARSET", "=", "utf8mb4"), create.getTableOptionsStrings()); + assertEquals(sql, create.toString()); + assertRoundTrip(create, dialect); + + String ctas = "CREATE TABLE t (id INT) COMMENT " + literal + " AS SELECT 1 AS id"; + CreateTable query = (CreateTable) parse(ctas, dialect); + assertComment(query.getTableOptions().get(0), literal, stored); + assertEquals(List.of("COMMENT", literal), query.getTableOptionsStrings()); + assertEquals(ctas, query.toString()); + assertRoundTrip(query, dialect); + + Alter alter = (Alter) parse("ALTER TABLE t COMMENT = " + literal, dialect); + AlterExpressionTableOption action = + (AlterExpressionTableOption) alter.getAlterExpressions().get(0); + assertComment(action.getStructuredTableOption(), literal, stored); + assertEquals(literal, action.getCommentText()); + assertEquals(AlterOperation.COMMENT_WITH_EQUAL_SIGN, action.getOperation()); + assertEquals("ALTER TABLE t COMMENT = " + literal, alter.toString()); + assertRoundTrip(alter, dialect); + } + + private static void assertComment(TableOption option, String literal, String stored) { + assertEquals(TableOption.Kind.COMMENT, option.getKind()); + assertEquals(stored, option.getComment().getNotExcapedValue()); + assertEquals(literal, option.getComment().toString()); + assertEquals(literal, option.getValue()); + assertEquals(literal, option.getTokens().get(option.getTokens().size() - 1)); + } + + @Test + void mySqlEscapingIsRecordedOnTheLiteral() throws JSQLParserException { + CreateTable mySql = + (CreateTable) parse("CREATE TABLE t (id INT) COMMENT 'a\\\\b'", Dialect.MYSQL); + assertEquals(StringValue.Escaping.MYSQL_BACKSLASH, + mySql.getTableOptions().get(0).getComment().getEscaping()); + CreateTable standard = (CreateTable) parse("CREATE TABLE t (id INT) COMMENT 'a\\b'", null); + assertEquals(StringValue.Escaping.STANDARD, + standard.getTableOptions().get(0).getComment().getEscaping()); + assertEquals("a\\b", standard.getTableOptions().get(0).getComment().getNotExcapedValue()); + } + + @Test + void alterCommentsKeepTheirPositionAmongOtherActions() throws JSQLParserException { + Alter alter = (Alter) parse( + "ALTER TABLE t MODIFY id INT COMMENT 'col', ENGINE=InnoDB, comment='it\\'s'", + Dialect.MYSQL); + List actions = alter.getAlterExpressions(); + TableOption comment = + ((AlterExpressionTableOption) actions.get(2)).getStructuredTableOption(); + assertEquals("comment", comment.getName()); + assertTrue(comment.isUseEquals()); + assertEquals("it's", comment.getComment().getNotExcapedValue()); + assertNull(((AlterExpressionTableOption) actions.get(1)).getStructuredTableOption() + .getComment()); + assertRoundTrip(alter, Dialect.MYSQL); + + Alter algorithm = + (Alter) parse("ALTER TABLE t COMMENT 'x', ALGORITHM=INSTANT", Dialect.MYSQL); + AlterExpressionTableOption action = + (AlterExpressionTableOption) algorithm.getAlterExpressions().get(0); + assertEquals(AlterOperation.COMMENT, action.getOperation()); + assertEquals("x", action.getStructuredTableOption().getComment().getNotExcapedValue()); + assertRoundTrip(algorithm, Dialect.MYSQL); + } + + @Test + void valuesThatAreNotStringLiteralsRemainPlainValues() throws JSQLParserException { + // Accepted leniently; without the MySQL dialect "x" is a quoted identifier + for (String sql : new String[] {"CREATE TABLE t (id INT) COMMENT = \"x\"", + "CREATE TABLE t (id INT) COMMENT plain"}) { + CreateTable create = (CreateTable) parse(sql, null); + TableOption option = create.getTableOptions().get(0); + assertEquals(TableOption.Kind.COMMENT, option.getKind()); + assertNull(option.getComment()); + assertEquals(sql.substring(sql.lastIndexOf(' ') + 1), option.getValue()); + assertRoundTrip(create, null); + } + CreateTable mySql = + (CreateTable) parse("CREATE TABLE t (id INT) COMMENT = \"x\"", Dialect.MYSQL); + assertEquals("x", mySql.getTableOptions().get(0).getComment().getNotExcapedValue()); + } + + @Test + void commentLiteralEditsAreRenderedAndVisited() throws JSQLParserException { + CreateTable create = + (CreateTable) parse("CREATE TABLE t (id INT) comment = 'old'", Dialect.MYSQL); + TableOption option = create.getTableOptions().get(0); + StringValue replacement = new StringValue("'new'"); + option.setComment(replacement); + assertSame(replacement, option.getComment()); + assertEquals("comment = 'new'", option.toString()); + replacement.setValue("it''s"); + assertEquals("CREATE TABLE t (id INT) comment = 'it''s'", create.toString()); + assertEquals(List.of("comment", "=", "'it''s'"), option.getTokens()); + assertRoundTrip(create, Dialect.MYSQL); + + StringBuilder customized = new StringBuilder(); + option.appendTo(customized, expression -> customized.append("'printed'")); + assertEquals("comment = 'printed'", customized.toString()); + List visited = new ArrayList<>(); + TableDefinitionTraversal.visit(option, visited::add, table -> { + }); + assertEquals(List.of(replacement), visited); + assertEquals(Set.of("t"), new TablesNamesFinder().getTables(create)); + } + + @Test + void commentFactoryAndReplacementOfOtherOptions() throws JSQLParserException { + TableOption created = TableOption.comment(new StringValue("'x'")); + assertEquals(TableOption.Kind.COMMENT, created.getKind()); + assertEquals("COMMENT", created.getName()); + assertFalse(created.isUseEquals()); + assertEquals("COMMENT 'x'", created.toString()); + assertEquals("COMMENT = 'x'", created.withUseEquals(true).toString()); + + CreateTable create = + (CreateTable) parse("CREATE TABLE t (id INT) ENGINE=InnoDB", Dialect.MYSQL); + TableOption option = create.getTableOptions().get(0); + option.withComment(new StringValue("'x'")); + assertEquals(TableOption.Kind.COMMENT, option.getKind()); + assertEquals("COMMENT", option.getName()); + assertTrue(option.isUseEquals()); + assertEquals("CREATE TABLE t (id INT) COMMENT = 'x'", create.toString()); + assertTrue(create.getTableOption(TableOption.Kind.ENGINE).isEmpty()); + assertRoundTrip(create, Dialect.MYSQL); + + option.setComment(null); + assertEquals(TableOption.Kind.COMMENT, option.getKind()); + assertNull(option.getValue()); + assertEquals("COMMENT", option.toString()); + } + + @Test + void scalarAndRawSettersReplaceTheLiteralWithoutParsing() throws JSQLParserException { + CreateTable create = + (CreateTable) parse("CREATE TABLE t (id INT) COMMENT='it\\'s'", Dialect.MYSQL); + TableOption option = create.getTableOptions().get(0); + option.setValue("'plain'"); + assertNull(option.getComment()); + assertEquals(TableOption.Kind.COMMENT, option.getKind()); + assertEquals("COMMENT = 'plain'", option.toString()); + assertRoundTrip(create, Dialect.MYSQL); + + option.setComment(new StringValue("'typed'")); + option.setKind(TableOption.Kind.OTHER); + assertNull(option.getComment()); + assertEquals("'typed'", option.getValue()); + option.setKind(TableOption.Kind.COMMENT); + assertNull(option.getComment()); + assertEquals("COMMENT = 'typed'", option.toString()); + + option.setComment(new StringValue("'typed'")); + List raw = new ArrayList<>(List.of("COMMENT", "'raw'")); + option.setTokens(raw); + assertNull(option.getComment()); + assertEquals(TableOption.Kind.OTHER, option.getKind()); + assertSame(raw, option.getTokens()); + option.setTokens(null); + assertNull(option.getComment()); + + option.setTokens(raw); + option.setComment(new StringValue("'typed'")); + assertEquals(List.of("COMMENT", "'typed'"), option.getTokens()); + assertRoundTrip(create, Dialect.MYSQL); + + AlterExpressionTableOption action = (AlterExpressionTableOption) ((Alter) parse( + "ALTER TABLE t COMMENT 'old'", Dialect.MYSQL)).getAlterExpressions().get(0); + action.setCommentText("'new'"); + assertNull(action.getStructuredTableOption().getComment()); + assertEquals("'new'", action.getCommentText()); + } + + @Test + void partitionAndSubpartitionCommentsDecodeToTheStoredValue() throws JSQLParserException { + String sql = "CREATE TABLE t (id INT, d INT) PARTITION BY RANGE (id) " + + "SUBPARTITION BY HASH (d) (PARTITION p0 VALUES LESS THAN (10) " + + "COMMENT = 'it\\'s' (SUBPARTITION s0 COMMENT 'sub0', " + + "SUBPARTITION s1 COMMENT = 'line1\\nline2'), " + + "PARTITION p1 VALUES LESS THAN MAXVALUE COMMENT '사용자 😀' " + + "(SUBPARTITION s2, SUBPARTITION s3 COMMENT 'a\\\\b'))"; + CreateTable create = (CreateTable) parse(sql, Dialect.MYSQL); + List partitions = + create.getPartitioning().getPartitionDefinitions(); + assertPartitionComment(partitions.get(0), "'it\\'s'", "it's", true); + assertPartitionComment(partitions.get(0).getSubPartitionDefinitions().get(0), "'sub0'", + "sub0", false); + assertPartitionComment(partitions.get(0).getSubPartitionDefinitions().get(1), + "'line1\\nline2'", "line1\nline2", true); + assertPartitionComment(partitions.get(1), "'사용자 😀'", "사용자 😀", false); + assertNull(partitions.get(1).getSubPartitionDefinitions().get(0).getCommentValue()); + assertNull(partitions.get(1).getSubPartitionDefinitions().get(0).getComment()); + assertPartitionComment(partitions.get(1).getSubPartitionDefinitions().get(1), "'a\\\\b'", + "a\\b", false); + assertEquals(StringValue.Escaping.MYSQL_BACKSLASH, + partitions.get(0).getCommentValue().getEscaping()); + assertEquals(sql, create.toString()); + assertRoundTrip(create, Dialect.MYSQL); + } + + @Test + void alterPartitionCommentsDecodeToTheStoredValue() throws JSQLParserException { + for (Dialect dialect : new Dialect[] {Dialect.MYSQL, null}) { + Alter add = (Alter) parse("ALTER TABLE t ADD PARTITION (PARTITION p1 VALUES " + + "LESS THAN (20) COMMENT 'it''s added')", dialect); + PartitionDefinition added = + add.getAlterExpressions().get(0).getPartitionDefinitions().get(0); + assertPartitionComment(added, "'it''s added'", "it's added", false); + assertRoundTrip(add, dialect); + + Alter reorganize = (Alter) parse("ALTER TABLE t REORGANIZE PARTITION p1 INTO " + + "(PARTITION p1a VALUES LESS THAN (15) COMMENT = 'reorg', " + + "PARTITION p1b VALUES LESS THAN (20))", dialect); + List definitions = + reorganize.getAlterExpressions().get(0).getPartitionDefinitions(); + assertPartitionComment(definitions.get(0), "'reorg'", "reorg", true); + assertNull(definitions.get(1).getCommentValue()); + assertRoundTrip(reorganize, dialect); + } + } + + private static void assertPartitionComment(PartitionDefinition definition, String literal, + String stored, boolean useEquals) { + assertEquals(stored, definition.getCommentValue().getNotExcapedValue()); + assertEquals(literal, definition.getComment()); + assertEquals(useEquals, definition.isCommentUseEquals()); + } + + @Test + void partitionCommentStringAccessorsConvertTheLiteral() throws JSQLParserException { + CreateTable create = (CreateTable) parse("CREATE TABLE t (id INT) PARTITION BY " + + "RANGE (id) (PARTITION p0 VALUES LESS THAN (10) COMMENT 'old')", Dialect.MYSQL); + PartitionDefinition definition = create.getPartitioning().getPartitionDefinitions().get(0); + + definition.setComment("'it''s'"); + assertInstanceOf(StringValue.class, definition.getCommentValue()); + assertEquals(StringValue.Escaping.STANDARD, definition.getCommentValue().getEscaping()); + assertEquals("it's", definition.getCommentValue().getNotExcapedValue()); + assertEquals("'it''s'", definition.getComment()); + assertRoundTrip(create, Dialect.MYSQL); + + StringValue escaped = new StringValue("'a\\nb'") + .withEscaping(StringValue.Escaping.MYSQL_BACKSLASH); + definition.setCommentValue(escaped); + definition.setCommentUseEquals(true); + assertSame(escaped, definition.getCommentValue()); + assertEquals("'a\\nb'", definition.getComment()); + assertEquals("PARTITION p0 VALUES LESS THAN (10) COMMENT = 'a\\nb'", + definition.toString()); + assertRoundTrip(create, Dialect.MYSQL); + + definition.setComment(null); + assertNull(definition.getCommentValue()); + assertEquals("PARTITION p0 VALUES LESS THAN (10)", definition.toString()); + } + + private static Statement parse(String sql, Dialect dialect) throws JSQLParserException { + return CCJSqlParserUtil.parse(sql, parser -> { + if (dialect != null) { + parser.withDialect(dialect); + } + }); + } + + private static void assertRoundTrip(Statement statement, Dialect dialect) + throws JSQLParserException { + StringBuilder out = new StringBuilder(); + statement.accept(new StatementDeParser(out)); + assertEquals(statement.toString(), out.toString()); + assertEquals(out.toString(), parse(out.toString(), dialect).toString()); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlTableOptionsTest.java b/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlTableOptionsTest.java new file mode 100644 index 000000000..22376ee52 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlTableOptionsTest.java @@ -0,0 +1,219 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.create; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.create.table.ColDataType; +import net.sf.jsqlparser.statement.create.table.ColumnDefinition; +import net.sf.jsqlparser.statement.create.table.CreateTable; +import net.sf.jsqlparser.statement.create.table.TableOption; +import net.sf.jsqlparser.statement.create.table.TableOption.Kind; +import net.sf.jsqlparser.statement.create.table.TableOption.OnCommitAction; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +class PostgreSqlTableOptionsTest { + private static final Dialect[] DIALECTS = {Dialect.POSTGRESQL, null}; + + @ParameterizedTest + @CsvSource(value = {"PRESERVE_ROWS|ON COMMIT PRESERVE ROWS", + "DELETE_ROWS|ON COMMIT DELETE ROWS", "DROP|ON COMMIT DROP"}, delimiter = '|') + void onCommitIsOneTypedOption(OnCommitAction action, String clause) + throws JSQLParserException { + String sql = "CREATE TEMPORARY TABLE t (a INT) " + clause; + for (Dialect dialect : DIALECTS) { + CreateTable table = parse(sql, dialect); + assertEquals(1, table.getTableOptions().size()); + TableOption option = table.getTableOption(Kind.ON_COMMIT).orElseThrow(); + assertEquals(action, option.getOnCommitAction()); + assertEquals("ON COMMIT", option.getName()); + assertEquals(action.toString(), option.getValue()); + assertEquals(Arrays.asList(clause.split(" ")), option.getTokens()); + assertEquals(Arrays.asList(clause.split(" ")), table.getTableOptionsStrings()); + assertRoundTrip(table, sql, dialect); + } + } + + @ParameterizedTest + @ValueSource(strings = {"CREATE TABLE t (a INT) USING heap", + "CREATE TABLE t USING heap AS SELECT 1 AS a", + "CREATE TABLE t (a) USING heap WITH (fillfactor = 70) TABLESPACE ts AS SELECT 1 WITH NO DATA"}) + void accessMethodIsOneTypedOption(String sql) throws JSQLParserException { + for (Dialect dialect : DIALECTS) { + CreateTable table = parse(sql, dialect); + TableOption option = table.getTableOptions().get(0); + assertEquals(Kind.ACCESS_METHOD, option.getKind()); + assertEquals("USING", option.getName()); + assertEquals("heap", option.getValue()); + assertEquals(List.of("USING", "heap"), option.getTokens()); + assertEquals(List.of("USING", "heap"), table.getTableOptionsStrings().subList(0, 2)); + assertEquals(sql.contains(" AS "), table.getSelect() != null); + assertEquals(Set.of("t"), new TablesNamesFinder().getTables(table)); + assertRoundTrip(table, sql, dialect); + } + } + + @Test + void postgreSqlClausesKeepTheirOrderAndLegacyTokens() throws JSQLParserException { + String sql = "CREATE TEMP TABLE t (a INT) USING heap WITH (fillfactor = 70) " + + "ON COMMIT DELETE ROWS TABLESPACE ts"; + for (Dialect dialect : DIALECTS) { + CreateTable table = parse(sql, dialect); + assertEquals( + List.of(Kind.ACCESS_METHOD, Kind.STORAGE_PARAMETERS, Kind.ON_COMMIT, + Kind.TABLESPACE), + table.getTableOptions().stream().map(TableOption::getKind) + .collect(Collectors.toList())); + assertEquals(List.of("USING", "heap", "WITH", "(fillfactor = 70)", "ON", "COMMIT", + "DELETE", "ROWS", "TABLESPACE", "ts"), table.getTableOptionsStrings()); + assertRoundTrip(table, sql, dialect); + } + + String query = "CREATE TEMP TABLE t (a) USING heap ON COMMIT DROP AS SELECT 1 WITH NO DATA"; + for (Dialect dialect : DIALECTS) { + CreateTable table = parse(query, dialect); + assertEquals(List.of(Kind.ACCESS_METHOD, Kind.ON_COMMIT), + table.getTableOptions().stream().map(TableOption::getKind) + .collect(Collectors.toList())); + assertEquals(Boolean.FALSE, table.getWithData()); + assertRoundTrip(table, query, dialect); + } + } + + @Test + void accessMethodFollowsPostgreSqlPartitionKey() throws JSQLParserException { + String sql = "CREATE TABLE t (a INT) PARTITION BY RANGE (a) USING heap TABLESPACE ts"; + CreateTable table = parse(sql, Dialect.POSTGRESQL); + assertTrue(table.isTableOptionsAfterPartition()); + assertEquals("heap", + table.getTableOption(Kind.ACCESS_METHOD).orElseThrow().getValue()); + assertRoundTrip(table, sql, Dialect.POSTGRESQL); + } + + @ParameterizedTest + @CsvSource(value = {"PRESERVE_DEFINITION|ON COMMIT PRESERVE DEFINITION", + "DROP_DEFINITION|ON COMMIT DROP DEFINITION"}, delimiter = '|') + void oraclePrivateTemporaryTablesUseTheSameOption(OnCommitAction action, String clause) + throws JSQLParserException { + String sql = "CREATE PRIVATE TEMPORARY TABLE ora$ptt_t (a INT) " + clause; + for (Dialect dialect : new Dialect[] {Dialect.ORACLE, null}) { + CreateTable table = parse(sql, dialect); + assertEquals(1, table.getTableOptions().size()); + assertEquals(action, table.getTableOptions().get(0).getOnCommitAction()); + assertEquals(Arrays.asList(clause.split(" ")), table.getTableOptionsStrings()); + assertRoundTrip(table, sql, dialect); + } + } + + @Test + void usingIndexTablespaceRemainsRaw() throws JSQLParserException { + String sql = "CREATE TABLE t (a INT) USING INDEX TABLESPACE ts"; + CreateTable table = parse(sql, null); + assertEquals(Kind.OTHER, table.getTableOptions().get(0).getKind()); + assertEquals(List.of("USING INDEX", "TABLESPACE ts"), table.getTableOptionsStrings()); + assertRoundTrip(table, sql, null); + } + + @ParameterizedTest + @ValueSource(strings = {"ON COMMIT", "ON COMMIT DELETE", "ON COMMIT PRESERVE", + "ON COMMIT ROWS", "ON COMMIT DELETE DEFINITION"}) + void rejectsIncompleteOnCommitActions(String clause) { + for (Dialect dialect : DIALECTS) { + assertThrows(JSQLParserException.class, + () -> parse("CREATE TEMPORARY TABLE t (a INT) " + clause, dialect)); + } + } + + @Test + void editsReplaceTheOptionContents() throws JSQLParserException { + CreateTable table = parse("CREATE TEMP TABLE t (a INT) USING heap ON COMMIT DROP", + Dialect.POSTGRESQL); + TableOption accessMethod = table.getTableOption(Kind.ACCESS_METHOD).orElseThrow(); + TableOption onCommit = table.getTableOption(Kind.ON_COMMIT).orElseThrow(); + + accessMethod.setValue("columnar"); + onCommit.setOnCommitAction(OnCommitAction.PRESERVE_ROWS); + assertEquals(List.of("USING", "columnar", "ON", "COMMIT", "PRESERVE", "ROWS"), + table.getTableOptionsStrings()); + assertThrows(UnsupportedOperationException.class, () -> onCommit.getTokens().add("x")); + assertRoundTrip(table, + "CREATE TEMP TABLE t (a INT) USING columnar ON COMMIT PRESERVE ROWS", + Dialect.POSTGRESQL); + + // Raw tokens discard the action; a structured setter never restores it. + onCommit.setTokens(new ArrayList<>(List.of("ON", "COMMIT", "DROP"))); + assertEquals(Kind.OTHER, onCommit.getKind()); + assertNull(onCommit.getOnCommitAction()); + assertTrue(table.getTableOption(Kind.ON_COMMIT).isEmpty()); + onCommit.setOnCommitAction(OnCommitAction.DELETE_ROWS); + assertEquals(Kind.ON_COMMIT, onCommit.getKind()); + assertEquals("ON COMMIT DELETE ROWS", onCommit.toString()); + onCommit.setKind(Kind.COMMENT); + assertNull(onCommit.getOnCommitAction()); + onCommit.setOnCommitAction(OnCommitAction.DROP); + onCommit.setValue("DROP"); + assertNull(onCommit.getOnCommitAction()); + assertEquals("ON COMMIT DROP", onCommit.toString()); + + onCommit.setOnCommitAction(null); + assertEquals(Kind.ON_COMMIT, onCommit.getKind()); + assertNull(onCommit.getValue()); + assertEquals(List.of("ON", "COMMIT"), onCommit.getTokens()); + assertEquals("ON COMMIT", onCommit.toString()); + } + + @Test + void factoriesBuildOptionsWithoutParsing() throws JSQLParserException { + CreateTable table = new CreateTable().withTable(new Table("t")) + .withColumnDefinitions(List.of(new ColumnDefinition("a", new ColDataType("INT")))) + .withTableOptions(new ArrayList<>(List.of(TableOption.accessMethod("heap"), + TableOption.onCommit(OnCommitAction.DROP)))); + table.setCreateOptionsStrings(List.of("TEMPORARY")); + assertEquals(Kind.ON_COMMIT, table.getTableOptions().get(1).getKind()); + assertRoundTrip(table, "CREATE TEMPORARY TABLE t (a INT) USING heap ON COMMIT DROP", + Dialect.POSTGRESQL); + } + + private static CreateTable parse(String sql, Dialect dialect) throws JSQLParserException { + return (CreateTable) (dialect == null + ? CCJSqlParserUtil.parse(sql) + : CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(dialect))); + } + + private static void assertRoundTrip(CreateTable table, String expected, Dialect dialect) + throws JSQLParserException { + assertEquals(expected, table.toString()); + StringBuilder deparsed = new StringBuilder(); + table.accept(new StatementDeParser(deparsed), null); + assertEquals(expected, deparsed.toString()); + CreateTable reparsed = parse(deparsed.toString(), dialect); + assertEquals(expected, reparsed.toString()); + assertEquals(table.getTableOptionsStrings(), reparsed.getTableOptionsStrings()); + assertEquals(2, (dialect == null + ? CCJSqlParserUtil.parseStatements(expected + "; SELECT 1") + : CCJSqlParserUtil.parseStatements(expected + "; SELECT 1", + parser -> parser.withDialect(dialect))) + .size()); + } +}