From 78d24564f2f2c6162f1d49d5b8af4363852cf66c Mon Sep 17 00:00:00 2001 From: minleejae Date: Wed, 14 Oct 2026 12:50:12 +0900 Subject: [PATCH 1/2] feat: model PostgreSQL ON COMMIT, access method and DROP behavior CREATE TABLE ON COMMIT and USING clauses were split into one raw TableOption per word. Parse them as Kind.ON_COMMIT with a typed OnCommitAction and as Kind.ACCESS_METHOD; the legacy token lists and SQL output are unchanged. ALTER TABLE DROP [COLUMN] c RESTRICT was rejected. AlterExpressionDrop now keeps CASCADE/RESTRICT as a CascadeBehavior for column and constraint drops, and getParameters() still lists the keyword. Signed-off-by: minleejae --- .../statement/alter/AlterExpressionDrop.java | 44 ++++ .../statement/create/table/TableOption.java | 55 ++++- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 47 +++- src/site/sphinx/usage.rst | 34 ++- .../alter/AlterDropBehaviorTest.java | 143 ++++++++++++ .../create/PostgreSqlTableOptionsTest.java | 219 ++++++++++++++++++ 6 files changed, 529 insertions(+), 13 deletions(-) create mode 100644 src/test/java/net/sf/jsqlparser/statement/alter/AlterDropBehaviorTest.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlTableOptionsTest.java 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/TableOption.java b/src/main/java/net/sf/jsqlparser/statement/create/table/TableOption.java index a25474069..f305d547c 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 @@ -33,9 +33,24 @@ public class TableOption implements Serializable { private List unionTables; private List storageParameters; private ColumnOption.Storage tablespaceStorage; + private OnCommitAction onCommitAction; 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 +93,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; } @@ -140,6 +180,9 @@ public void setKind(Kind kind) { if (kind != Kind.UNION) { unionTables = null; } + if (kind != Kind.ON_COMMIT) { + onCommitAction = null; + } } public String getName() { @@ -158,6 +201,9 @@ public String getValue() { if (storageParameters != null) { return PlainSelect.getStringList(storageParameters, true, true); } + if (onCommitAction != null) { + return onCommitAction.toString(); + } return unionTables == null ? value : PlainSelect.getStringList(unionTables, true, true); } @@ -167,6 +213,7 @@ public void setValue(String value) { foreignTableOptions = null; unionTables = null; storageParameters = null; + onCommitAction = null; } /** Returns the mutable MERGE table sources, including an empty UNION list. */ @@ -214,7 +261,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 +295,7 @@ private void clearContents() { foreignTableOptions = null; unionTables = null; storageParameters = null; + onCommitAction = null; } public TableOption withKind(Kind kind) { diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 2210dbe9d..008c0ed9c 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; @@ -18920,7 +18948,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 +18989,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 +19006,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..970051cac 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,34 @@ 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 + 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/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()); + } +} From 3cae8b3bdde20a047389e10bf0d633882ebe3891 Mon Sep 17 00:00:00 2001 From: minleejae Date: Wed, 14 Oct 2026 18:20:13 +0900 Subject: [PATCH 2/2] feat: expose MySQL table and partition comments as string values Read the table COMMENT of CREATE TABLE, CREATE TABLE ... AS SELECT and ALTER TABLE, and the partition and subpartition COMMENT, with the stringValue() helper so the literal records the dialect's escaping and getNotExcapedValue() returns the stored text. TableOption gains getComment(), setComment(), withComment() and a comment() factory, following the table option state rules: the literal replaces the option's contents, while setValue() and setTokens() store unparsed text. PartitionDefinition gains getCommentValue() and setCommentValue(); getComment() and setComment(String) remain String accessors. getValue(), the token snapshots and the rendered SQL keep the literal as written. Signed-off-by: minleejae --- .../create/table/PartitionDefinition.java | 23 +- .../statement/create/table/TableOption.java | 49 +++ .../util/TableDefinitionTraversal.java | 3 +- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 11 +- src/site/sphinx/usage.rst | 41 ++ .../create/MySqlCommentLiteralTest.java | 366 ++++++++++++++++++ 6 files changed, 486 insertions(+), 7 deletions(-) create mode 100644 src/test/java/net/sf/jsqlparser/statement/create/MySqlCommentLiteralTest.java 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 f305d547c..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; @@ -34,6 +35,7 @@ public class TableOption implements Serializable { 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, ACCESS_METHOD, ON_COMMIT, OTHER @@ -129,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) { @@ -136,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()); } @@ -183,6 +221,11 @@ public void setKind(Kind kind) { 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() { @@ -204,9 +247,13 @@ public String getValue() { 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; @@ -214,6 +261,7 @@ public void setValue(String value) { unionTables = null; storageParameters = null; onCommitAction = null; + comment = null; } /** Returns the mutable MERGE table sources, including an empty UNION list. */ @@ -296,6 +344,7 @@ private void clearContents() { 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 008c0ed9c..814389d0e 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -16652,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; @@ -16722,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; @@ -18702,7 +18709,7 @@ void PartitionDefinitionOption(PartitionDefinition partitionDefinition): | [ "=" { useEquals = true; } ] tk= { - partitionDefinition.setComment(tk.image); + partitionDefinition.setCommentValue(stringValue(tk)); partitionDefinition.setCommentUseEquals(useEquals); } | diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 970051cac..e16e6fc23 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -404,6 +404,47 @@ Use ``TableOption.onCommit`` and ``TableOption.accessMethod`` to build them. .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/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()); + } +}