From 78d24564f2f2c6162f1d49d5b8af4363852cf66c Mon Sep 17 00:00:00 2001 From: minleejae Date: Wed, 14 Oct 2026 12:50:12 +0900 Subject: [PATCH] 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()); + } +}