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 09585394c..a25474069 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 @@ -17,7 +17,11 @@ import net.sf.jsqlparser.statement.select.PlainSelect; import net.sf.jsqlparser.schema.Table; -/** A structured option following a {@code CREATE TABLE} definition. */ +/** + * A shared CREATE/ALTER TABLE option with either opaque tokens or structured properties. Setting + * non-null tokens discards all structured properties. A structured setter discards the tokens; + * their contents are never parsed or restored by a later setter. + */ public class TableOption implements Serializable { private ForeignTableOptions foreignTableOptions; @@ -38,9 +42,17 @@ public ForeignTableOptions getForeignTableOptions() { return foreignTableOptions; } + /** Replaces the current contents with a structured PostgreSQL SERVER clause. */ + public void setForeignTableOptions(ForeignTableOptions options) { + clearContents(); + kind = Kind.FOREIGN_SERVER; + name = "SERVER"; + foreignTableOptions = options; + } + public static TableOption foreignServer(ForeignTableOptions options) { - TableOption option = new TableOption(Kind.FOREIGN_SERVER, "SERVER", null, false); - option.foreignTableOptions = options; + TableOption option = new TableOption(); + option.setForeignTableOptions(options); return option; } @@ -48,11 +60,21 @@ public ColumnOption.Storage getTablespaceStorage() { return tablespaceStorage; } + /** + * Sets the TABLESPACE storage modifier. A non-null modifier selects TABLESPACE and discards + * another option's contents; an existing TABLESPACE keeps its name, value and equals spelling. + */ public void setTablespaceStorage(ColumnOption.Storage storage) { if (storage != null && storage != ColumnOption.Storage.DISK && storage != ColumnOption.Storage.MEMORY) { throw new IllegalArgumentException("TABLESPACE storage must be DISK or MEMORY"); } + if (storage != null && kind != Kind.TABLESPACE) { + clearContents(); + kind = Kind.TABLESPACE; + name = "TABLESPACE"; + } + tokens = null; tablespaceStorage = storage; } @@ -61,14 +83,10 @@ public List getStorageParameters() { } public void setStorageParameters(List storageParameters) { + clearContents(); this.storageParameters = storageParameters; - foreignTableOptions = null; - tablespaceStorage = null; kind = Kind.STORAGE_PARAMETERS; name = "WITH"; - value = null; - tokens = null; - unionTables = null; } public void appendTo(StringBuilder builder, @@ -76,7 +94,7 @@ public void appendTo(StringBuilder builder, if (foreignTableOptions != null) { foreignTableOptions.appendTo(builder, expressionPrinter); } else if (storageParameters != null) { - builder.append("WITH "); + builder.append(name).append(useEquals ? " = " : " "); IndexOption.appendListTo(builder, storageParameters, expressionPrinter); } else { builder.append(toString()); @@ -108,6 +126,7 @@ public Kind getKind() { } public void setKind(Kind kind) { + tokens = null; this.kind = kind; if (kind != Kind.TABLESPACE) { tablespaceStorage = null; @@ -128,6 +147,7 @@ public String getName() { } public void setName(String name) { + tokens = null; this.name = name; } @@ -142,6 +162,7 @@ public String getValue() { } public void setValue(String value) { + tokens = null; this.value = value; foreignTableOptions = null; unionTables = null; @@ -155,14 +176,12 @@ public List getUnionTables() { /** Replaces raw option contents with structured UNION table references. */ public void setUnionTables(List
unionTables) { + boolean equals = useEquals; + clearContents(); this.unionTables = unionTables; - foreignTableOptions = null; - tablespaceStorage = null; kind = Kind.UNION; name = "UNION"; - value = null; - tokens = null; - storageParameters = null; + useEquals = equals; } public TableOption withUnionTables(List
unionTables) { @@ -175,10 +194,14 @@ public boolean isUseEquals() { } public void setUseEquals(boolean useEquals) { + tokens = null; this.useEquals = useEquals; } - /** Returns the original token groups used by the legacy table-options API. */ + /** + * Returns the original, mutable token list for an opaque option, or an unmodifiable snapshot of + * the current structured properties. Editing tokens does not classify or parse them. + */ public List getTokens() { if (tokens != null) { return tokens; @@ -201,14 +224,27 @@ public List getTokens() { return Collections.unmodifiableList(result); } + /** + * Replaces all structured properties with opaque tokens and {@link Kind#OTHER}. Null clears + * opaque tokens without restoring discarded properties; it leaves structured options alone. + */ public void setTokens(List tokens) { - this.tokens = tokens; if (tokens != null) { - tablespaceStorage = null; - foreignTableOptions = null; - unionTables = null; - storageParameters = null; + clearContents(); } + this.tokens = tokens; + } + + private void clearContents() { + kind = Kind.OTHER; + name = null; + value = null; + useEquals = false; + tokens = null; + tablespaceStorage = null; + foreignTableOptions = null; + unionTables = null; + storageParameters = null; } public TableOption withKind(Kind kind) { diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 218d6dcf7..7c50575c7 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -337,6 +337,41 @@ 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. +Edit shared table options +------------------------- + +``CreateTable.getTableOptions()`` and +``AlterExpressionTableOption.getStructuredTableOption()`` use the same mutable +``TableOption`` model. Structured values and opaque tokens are alternative +representations. ``setTokens(nonNullList)`` discards the previous kind, name, +value and child nodes: the option becomes ``Kind.OTHER`` without parsing the +tokens. Raw token lists remain mutable; ``getTokens()`` on a structured option +returns an unmodifiable snapshot. + +.. code-block:: java + + CreateTable table = (CreateTable) CCJSqlParserUtil.parse( + "CREATE TABLE t (id INT) ENGINE=InnoDB"); + TableOption option = table.getTableOption(TableOption.Kind.ENGINE).orElseThrow(); + option.setTokens(List.of("ROW_FORMAT", "=", "DYNAMIC")); + table.getTableOption(TableOption.Kind.ENGINE).isEmpty(); // true + + // Supply structured properties explicitly; the old tokens are discarded. + option.withKind(TableOption.Kind.COMMENT).withName("COMMENT") + .withValue("'new'").withUseEquals(true); + // CREATE TABLE t (id INT) COMMENT = 'new' + +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 ()``; +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. + Inspect PostgreSQL schema statements ------------------------------------ diff --git a/src/test/java/net/sf/jsqlparser/statement/create/TableOptionStateTest.java b/src/test/java/net/sf/jsqlparser/statement/create/TableOptionStateTest.java new file mode 100644 index 000000000..4261e65b1 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/TableOptionStateTest.java @@ -0,0 +1,231 @@ +/*- + * #%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.List; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Stream; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.alter.Alter; +import net.sf.jsqlparser.statement.alter.AlterExpressionTableOption; +import net.sf.jsqlparser.statement.alter.AlterOperation; +import net.sf.jsqlparser.statement.create.table.ColumnOption; +import net.sf.jsqlparser.statement.create.table.CreateTable; +import net.sf.jsqlparser.statement.create.table.ForeignTableOptions; +import net.sf.jsqlparser.statement.create.table.IndexOption; +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.MethodSource; + +class TableOptionStateTest { + @Test + void replacingTokensUpdatesCreateLookupAndAlterOperation() throws JSQLParserException { + CreateTable create = create("ENGINE=InnoDB", Dialect.MYSQL); + Alter alter = (Alter) parse("ALTER TABLE t ENGINE=InnoDB", Dialect.MYSQL); + AlterExpressionTableOption action = + (AlterExpressionTableOption) alter.getAlterExpressions().get(0); + for (TableOption option : List.of(create.getTableOptions().get(0), + action.getStructuredTableOption())) { + List raw = new ArrayList<>(List.of("ROW_FORMAT", "=", "DYNAMIC")); + option.setTokens(raw); + assertSame(raw, option.getTokens()); + assertEquals(TableOption.Kind.OTHER, option.getKind()); + assertNull(option.getName()); + assertNull(option.getValue()); + assertFalse(option.isUseEquals()); + raw.set(2, "COMPRESSED"); + assertEquals("ROW_FORMAT = COMPRESSED", option.toString()); + } + assertTrue(create.getTableOption(TableOption.Kind.ENGINE).isEmpty()); + assertTrue(create.getTableOption(TableOption.Kind.ROW_FORMAT).isEmpty()); + assertSame(create.getTableOptions().get(0), + create.getTableOption(TableOption.Kind.OTHER).orElseThrow()); + assertEquals(AlterOperation.SET_TABLE_OPTION, action.getOperation()); + assertNull(action.getEngineOption()); + assertRoundTrip(create, Dialect.MYSQL); + assertRoundTrip(alter, Dialect.MYSQL); + } + + @ParameterizedTest + @MethodSource("structuredSetters") + void eachStructuredSetterLeavesOpaqueTokens(Consumer setter) { + TableOption option = TableOption.raw("CUSTOM", "opaque text"); + setter.accept(option); + assertFalse(option.getTokens().contains("CUSTOM")); + assertFalse(option.getTokens().contains("opaque text")); + } + + private static Stream> structuredSetters() { + return Stream.of(option -> option.setKind(TableOption.Kind.COMMENT), + option -> option.setName("COMMENT"), option -> option.setValue("'new'"), + option -> option.setUseEquals(true), + option -> option.setTablespaceStorage(ColumnOption.Storage.MEMORY)); + } + + @Test + void rawToStructuredEditsAreOrderIndependentAndDoNotRestoreOldValues() + throws JSQLParserException { + CreateTable create = create("ENGINE=InnoDB", Dialect.MYSQL); + TableOption option = create.getTableOptions().get(0); + option.setTokens(List.of("ROW_FORMAT", "=", "DYNAMIC")); + option.setValue("'new'"); + assertEquals("'new'", option.getValue()); + assertNull(option.getName()); + assertEquals(TableOption.Kind.OTHER, option.getKind()); + option.withName("COMMENT").withUseEquals(true).withKind(TableOption.Kind.COMMENT); + assertEquals(List.of("COMMENT", "=", "'new'"), option.getTokens()); + assertThrows(UnsupportedOperationException.class, () -> option.getTokens().add("ignored")); + assertEquals("CREATE TABLE t (id INT) COMMENT = 'new'", create.toString()); + assertRoundTrip(create, Dialect.MYSQL); + option.setTokens(null); + assertEquals("COMMENT = 'new'", option.toString()); + option.setTokens(List.of("ENGINE", "CSV")); + option.setTokens(null); + assertEquals(TableOption.Kind.OTHER, option.getKind()); + assertNull(option.getName()); + assertNull(option.getValue()); + assertTrue(option.getTokens().isEmpty()); + } + + @Test + void unionReplacementStopsVisitingDetachedSourcesAndPreservesEmptyLists() + throws JSQLParserException { + CreateTable create = create("UNION=(old_source)", Dialect.MYSQL); + TableOption option = create.getTableOptions().get(0); + List
oldSources = option.getUnionTables(); + option.setTokens(List.of("UNION", "=", "(raw_source)")); + assertNull(option.getUnionTables()); + assertEquals(Set.of("t"), new TablesNamesFinder().getTables(create)); + oldSources.get(0).setName("detached"); + assertEquals("UNION = (raw_source)", option.toString()); + assertRoundTrip(create, Dialect.MYSQL); + option.setUnionTables(new ArrayList<>()); + assertEquals("()", option.getValue()); + assertEquals("UNION ()", option.toString()); + option.getUnionTables().add(new Table("new_source")); + assertEquals(Set.of("t", "new_source"), new TablesNamesFinder().getTables(create)); + assertRoundTrip(create, Dialect.MYSQL); + option.setUnionTables(null); + assertNull(option.getUnionTables()); + assertNull(option.getValue()); + assertEquals("UNION", option.toString()); + } + + @Test + void storageSelectionResetsScalarSpellingAndKeepsExpressionRendering() + throws JSQLParserException { + CreateTable create = create("ENGINE=InnoDB", Dialect.MYSQL); + TableOption option = create.getTableOptions().get(0); + LongValue fillFactor = new LongValue(70); + List parameters = new ArrayList<>( + List.of(new IndexOption("fillfactor", fillFactor, true))); + option.setStorageParameters(parameters); + assertSame(parameters, option.getStorageParameters()); + assertFalse(option.isUseEquals()); + assertEquals("WITH (fillfactor = 70)", option.toString()); + assertRoundTrip(create, Dialect.POSTGRESQL); + StringBuilder customized = new StringBuilder(); + option.appendTo(customized, expression -> customized.append("80")); + assertEquals("WITH (fillfactor = 80)", customized.toString()); + List visited = new ArrayList<>(); + TableDefinitionTraversal.visit(option, visited::add, ignored -> { + }); + assertEquals(List.of(fillFactor), visited); + option.setTokens(List.of("WITH", "(fillfactor = 90)")); + assertNull(option.getStorageParameters()); + visited.clear(); + TableDefinitionTraversal.visit(option, visited::add, ignored -> { + }); + assertTrue(visited.isEmpty()); + assertRoundTrip(create, Dialect.POSTGRESQL); + option.setStorageParameters(null); + assertEquals(TableOption.Kind.STORAGE_PARAMETERS, option.getKind()); + assertNull(option.getValue()); + assertEquals("WITH", option.toString()); + } + + @Test + void foreignServerReplacementUpdatesCreateProjectionAndDoesNotReviveOldOptions() + throws JSQLParserException { + CreateTable create = (CreateTable) parse( + "CREATE FOREIGN TABLE t(id INT) SERVER old_srv OPTIONS(table_name 'old')", + Dialect.POSTGRESQL); + TableOption option = create.getTableOptions().get(0); + ForeignTableOptions original = option.getForeignTableOptions(); + option.setTokens(List.of("SERVER", "raw_srv")); + assertNull(create.getForeignTableOptions()); + assertNull(option.getForeignTableOptions()); + original.setServer("detached"); + assertRoundTrip(create, Dialect.POSTGRESQL); + ForeignTableOptions replacement = new ForeignTableOptions(); + replacement.setServer("new_srv"); + option.setForeignTableOptions(replacement); + assertSame(replacement, create.getForeignTableOptions()); + assertEquals(TableOption.Kind.FOREIGN_SERVER, option.getKind()); + assertEquals(List.of("SERVER", "new_srv"), option.getTokens()); + assertRoundTrip(create, Dialect.POSTGRESQL); + option.setValue("scalar_srv"); + assertNull(create.getForeignTableOptions()); + assertEquals("SERVER scalar_srv", option.toString()); + assertRoundTrip(create, Dialect.POSTGRESQL); + } + + @Test + void tablespaceStorageDoesNotLeakAcrossRawAndStructuredReplacements() + throws JSQLParserException { + CreateTable create = create("TABLESPACE=old_ts STORAGE DISK", Dialect.MYSQL); + TableOption option = create.getTableOptions().get(0); + option.setTokens(List.of("TABLESPACE", "raw_ts")); + assertNull(option.getTablespaceStorage()); + option.setTablespaceStorage(ColumnOption.Storage.MEMORY); + assertEquals(TableOption.Kind.TABLESPACE, option.getKind()); + assertEquals("TABLESPACE", option.getName()); + assertNull(option.getValue()); + option.setValue("new_ts"); + assertEquals(List.of("TABLESPACE", "new_ts", "STORAGE", "MEMORY"), option.getTokens()); + assertRoundTrip(create, Dialect.MYSQL); + option.setUnionTables(List.of(new Table("source"))); + assertNull(option.getTablespaceStorage()); + assertRoundTrip(create, Dialect.MYSQL); + assertThrows(IllegalArgumentException.class, + () -> option.setTablespaceStorage(ColumnOption.Storage.PLAIN)); + assertEquals("UNION (source)", option.toString()); + } + + private static CreateTable create(String options, Dialect dialect) throws JSQLParserException { + return (CreateTable) parse("CREATE TABLE t(id INT) " + options, dialect); + } + + private static Statement parse(String sql, Dialect dialect) throws JSQLParserException { + return CCJSqlParserUtil.parse(sql, parser -> 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()); + } +}