diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/ColumnOption.java b/src/main/java/net/sf/jsqlparser/statement/create/table/ColumnOption.java index ddbae8f48..155336d47 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/table/ColumnOption.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/ColumnOption.java @@ -10,6 +10,7 @@ package net.sf.jsqlparser.statement.create.table; import java.io.Serializable; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -268,9 +269,10 @@ public ConstraintKind getConstraintKind() { } } + /** Creates an opaque option with its own copy of the supplied tokens, without parsing them. */ public static ColumnOption raw(List tokens) { ColumnOption option = new ColumnOption(); - option.tokens = tokens; + option.setTokens(tokens); return option; } @@ -295,33 +297,60 @@ public Kind getKind() { return kind; } + /** + * Returns an unmodifiable, detached token snapshot for both raw and structured options. An + * unset raw option returns null; an explicitly empty raw option returns an empty snapshot. Use + * {@link #setTokens(List)} to edit an opaque option, or the typed setters and mutable child + * nodes to edit structured options. Later changes to the option do not change this snapshot. + */ public List getTokens() { switch (kind) { case DEFAULT: - return Arrays.asList("DEFAULT", String.valueOf(defaultExpression)); + return Collections.unmodifiableList( + Arrays.asList("DEFAULT", String.valueOf(defaultExpression))); case NULLABILITY: - return nullable ? Collections.singletonList("NULL") : Arrays.asList("NOT", "NULL"); + return nullable ? Collections.singletonList("NULL") + : Collections.unmodifiableList(Arrays.asList("NOT", "NULL")); case COLLATE: - return Arrays.asList("COLLATE", collation); + return Collections.unmodifiableList(Arrays.asList("COLLATE", collation)); case COMMENT: - return Arrays.asList("COMMENT", comment.toString()); + return Collections.unmodifiableList(Arrays.asList("COMMENT", comment.toString())); case ON_UPDATE: - return Arrays.asList("ON", "UPDATE", onUpdateExpression.toString()); + return Collections.unmodifiableList( + Arrays.asList("ON", "UPDATE", onUpdateExpression.toString())); case GENERATED: return generatedDefinition.getTokens(); case CONSTRAINT: if ("PRIMARY KEY".equals(constraint.toString())) { - return Arrays.asList("PRIMARY", "KEY"); + return Collections.unmodifiableList(Arrays.asList("PRIMARY", "KEY")); } return Collections.singletonList(toString()); case OTHER: case SERIAL_DEFAULT_VALUE: - return tokens; + return tokens == null ? null + : Collections.unmodifiableList(new ArrayList<>(tokens)); default: return Collections.singletonList(toString()); } } + /** + * Replaces the tokens of an opaque {@link Kind#OTHER} option with a copy of the supplied list. + * Null unsets the tokens; an empty list retains an explicitly empty option. Tokens are never + * parsed or classified. To replace a structured option, including SERIAL DEFAULT VALUE, with + * raw text, replace its node in {@link ColumnDefinition#getColumnOptions()} using + * {@link #raw(List)} instead. + * + * @throws IllegalStateException if this is a structured option + */ + public void setTokens(List tokens) { + if (kind != Kind.OTHER) { + throw new IllegalStateException( + "Only opaque options accept tokens; use typed setters or replace the option"); + } + this.tokens = tokens == null ? null : new ArrayList<>(tokens); + } + public ForeignKeyReference getForeignKeyReference() { return foreignKeyReference; } diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/GeneratedColumnDefinition.java b/src/main/java/net/sf/jsqlparser/statement/create/table/GeneratedColumnDefinition.java index 448ddc5b4..ac069d6e4 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/table/GeneratedColumnDefinition.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/GeneratedColumnDefinition.java @@ -11,6 +11,7 @@ import java.io.Serializable; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.function.Consumer; @@ -55,6 +56,10 @@ public void setStorage(Storage storage) { this.storage = storage; } + /** + * Returns an unmodifiable token snapshot. Use the setters or edit {@link #getExpression()} to + * change the definition; later changes do not affect previously returned snapshots. + */ public List getTokens() { List tokens = new ArrayList<>(); if (generatedAlways) { @@ -66,7 +71,7 @@ public List getTokens() { if (storage != null) { tokens.add(storage.name()); } - return tokens; + return Collections.unmodifiableList(tokens); } public StringBuilder appendTo(StringBuilder builder, Consumer expressionPrinter) { diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 498a9de48..96cbe3b7e 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -683,6 +683,35 @@ setter receives null. Appending preserves the current nodes and accepts an immutable input list. An empty append preserves existing options and creates an empty option list when none was set. +``ColumnOption.getTokens()`` and ``GeneratedColumnDefinition.getTokens()`` also +return unmodifiable, detached snapshots. Reading tokens does not detach the +underlying expressions or other child nodes: their setters and direct edits +remain visible in SQL output, visitors and newly requested snapshots. + +For opaque ``Kind.OTHER`` options, copy the tokens, edit the copy, then call +``setTokens``. The setter and both ``ColumnOption.raw`` factories copy their +inputs; subsequent changes to the supplied list or array do not edit the AST. +Null raw tokens remain unset and return null; an empty list returns an empty +snapshot. Raw tokens are never parsed or classified. + +.. code-block:: java + + ColumnOption raw = ColumnOption.raw("COLUMN_FORMAT", "DYNAMIC"); + List tokens = new ArrayList<>(raw.getTokens()); + tokens.set(1, "FIXED"); + raw.setTokens(tokens); // COLUMN_FORMAT FIXED + +Migration: raw token getter edits now throw ``UnsupportedOperationException``, +as do edits to structured token snapshots that previously changed a discarded +copy. ``setTokens`` only accepts opaque options; it throws +``IllegalStateException`` for structured options, including SERIAL DEFAULT +VALUE. Use typed setters for structured edits. To explicitly replace a +structured option with raw text, replace its node in the column's option list: + +.. code-block:: java + + column.getColumnOptions().set(0, ColumnOption.raw("DEFAULT", "8")); + Inspect logical replication statements ====================================== diff --git a/src/test/java/net/sf/jsqlparser/statement/create/ColumnDefinitionStateTest.java b/src/test/java/net/sf/jsqlparser/statement/create/ColumnDefinitionStateTest.java index 1e23f0d8e..afa47da14 100644 --- a/src/test/java/net/sf/jsqlparser/statement/create/ColumnDefinitionStateTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/create/ColumnDefinitionStateTest.java @@ -54,10 +54,12 @@ void specificationSnapshotsAreReadOnlyForRawAndStructuredOptions(String sql) if (option.getKind() == ColumnOption.Kind.OTHER) { assertTrue(column.getColumnOptions().stream() .allMatch(raw -> raw.getKind() == ColumnOption.Kind.OTHER)); - List rawTokens = column.getColumnOptions().stream() - .map(ColumnOption::getTokens).filter(tokens -> tokens.contains("DYNAMIC")) + ColumnOption rawOption = column.getColumnOptions().stream() + .filter(raw -> raw.getTokens().contains("DYNAMIC")) .findFirst().orElseThrow(); + List rawTokens = new ArrayList<>(rawOption.getTokens()); rawTokens.set(rawTokens.indexOf("DYNAMIC"), "FIXED"); + rawOption.setTokens(rawTokens); assertEquals(List.of("COLUMN_FORMAT", "DYNAMIC"), snapshot); assertEquals(List.of("COLUMN_FORMAT", "FIXED"), column.getColumnSpecs()); } else { diff --git a/src/test/java/net/sf/jsqlparser/statement/create/ColumnOptionTokenTest.java b/src/test/java/net/sf/jsqlparser/statement/create/ColumnOptionTokenTest.java new file mode 100644 index 000000000..55c90a8d2 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/ColumnOptionTokenTest.java @@ -0,0 +1,246 @@ +/*- + * #%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.stream.Stream; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.expression.operators.arithmetic.Addition; +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.create.table.ColumnDefinition; +import net.sf.jsqlparser.statement.create.table.ColumnOption; +import net.sf.jsqlparser.statement.create.table.CreateTable; +import net.sf.jsqlparser.statement.create.table.ForeignDataOption; +import net.sf.jsqlparser.statement.create.table.ForeignKeyReference; +import net.sf.jsqlparser.statement.create.table.GeneratedColumnDefinition; +import net.sf.jsqlparser.statement.create.table.IdentityDefinition; +import net.sf.jsqlparser.statement.create.table.KeyConstraint; +import net.sf.jsqlparser.util.TableDefinitionTraversal; +import net.sf.jsqlparser.util.deparser.ExpressionDeParser; +import net.sf.jsqlparser.util.deparser.SelectDeParser; +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; +import org.junit.jupiter.params.provider.ValueSource; + +class ColumnOptionTokenTest { + @ParameterizedTest + @ValueSource(strings = {"CREATE TABLE t (id INT COLUMN_FORMAT DYNAMIC)", + "ALTER TABLE t ADD COLUMN id INT COLUMN_FORMAT DYNAMIC"}) + void parsedRawOptionsUseExplicitTokenReplacement(String sql) throws JSQLParserException { + Statement statement = parse(sql); + ColumnOption option = column(statement).getColumnOptions().stream() + .filter(raw -> raw.getTokens().contains("DYNAMIC")).findFirst().orElseThrow(); + List snapshot = option.getTokens(); + assertReadOnly(snapshot); + List replacement = new ArrayList<>(snapshot); + replacement.set(replacement.indexOf("DYNAMIC"), "FIXED"); + option.setTokens(replacement); + replacement.clear(); + assertTrue(snapshot.contains("DYNAMIC")); + assertTrue(option.getTokens().contains("FIXED")); + assertEquals(ColumnOption.Kind.OTHER, option.getKind()); + assertTrue(statement.toString().contains("COLUMN_FORMAT FIXED")); + assertRoundTrip(statement); + } + + @Test + void rawFactoriesAndSetterOwnTheirTokensWithoutParsingThem() { + List input = new ArrayList<>(List.of("DEFAULT", "1")); + ColumnOption option = ColumnOption.raw(input); + input.set(1, "99"); + List snapshot = option.getTokens(); + assertReadOnly(snapshot); + assertEquals(List.of("DEFAULT", "1"), snapshot); + assertNull(option.getDefaultExpression()); + assertEquals(ColumnOption.Kind.OTHER, option.getKind()); + option.setTokens(input); + input.clear(); + assertEquals("DEFAULT 99", option.toString()); + assertEquals(List.of("DEFAULT", "1"), snapshot); + option.setTokens(option.getTokens()); + assertEquals("DEFAULT 99", option.toString()); + + String[] array = {"NOT", "NULL"}; + ColumnOption fromArray = ColumnOption.raw(array); + array[0] = "changed"; + assertEquals("NOT NULL", fromArray.toString()); + assertReadOnly(fromArray.getTokens()); + } + + @Test + void unsetAndEmptyRawTokensRemainDistinct() { + ColumnOption option = ColumnOption.raw((List) null); + assertNull(option.getTokens()); + assertEquals("", option.toString()); + option.setTokens(List.of()); + List empty = option.getTokens(); + assertTrue(empty.isEmpty()); + assertThrows(UnsupportedOperationException.class, () -> empty.add("changed")); + option.setTokens(List.of("CUSTOM")); + assertTrue(empty.isEmpty()); + option.setTokens(null); + assertNull(option.getTokens()); + assertEquals("", option.toString()); + } + + @ParameterizedTest + @MethodSource("structuredOptions") + void tokenReplacementRejectsStructuredOptionsWithoutChangingThem(ColumnOption option) { + ColumnOption.Kind kind = option.getKind(); + String sql = option.toString(); + List snapshot = option.getTokens(); + assertReadOnly(snapshot); + assertThrows(IllegalStateException.class, () -> option.setTokens(List.of("raw"))); + assertThrows(IllegalStateException.class, () -> option.setTokens(null)); + assertEquals(kind, option.getKind()); + assertEquals(sql, option.toString()); + assertEquals(snapshot, option.getTokens()); + } + + private static Stream structuredOptions() { + return Stream.of(ColumnOption.serialDefaultValue(), + ColumnOption.reference(new ForeignKeyReference().withTable(new Table("parent")) + .withReferencedColumnNames(List.of("id"))), + ColumnOption + .identity(new IdentityDefinition(IdentityDefinition.GenerationMode.ALWAYS)), + ColumnOption.constraint(new KeyConstraint().withType("PRIMARY KEY")), + ColumnOption.constraint(new KeyConstraint().withType("UNIQUE")), + ColumnOption.defaultValue(new LongValue(1)), + ColumnOption.nullability(false), ColumnOption.nullability(true), + ColumnOption.collate("utf8mb4_bin"), + ColumnOption.comment(new StringValue("comment")), + ColumnOption.onUpdate(new LongValue(1)), + ColumnOption.generated(new GeneratedColumnDefinition(new LongValue(1))), + ColumnOption.autoIncrement(), ColumnOption.visibility(false), + ColumnOption.storage(ColumnOption.Storage.MEMORY), + ColumnOption.compression("pglz"), + ColumnOption.foreignOptions(List.of( + new ForeignDataOption("column_name", new StringValue("remote_id"))))); + } + + @ParameterizedTest + @ValueSource(strings = {"CREATE TABLE t (id INT DEFAULT 1)", + "ALTER TABLE t ADD COLUMN id INT DEFAULT 1"}) + void parsedDefaultsKeepLiveExpressionsAndStableTokenSnapshots(String sql) + throws JSQLParserException { + Statement statement = parse(sql); + ColumnDefinition column = column(statement); + ColumnOption option = column.getColumnOptions().get(0); + List snapshot = option.getTokens(); + assertReadOnly(snapshot); + LongValue original = (LongValue) option.getDefaultExpression(); + original.setValue(7); + assertEquals(List.of("DEFAULT", "7"), option.getTokens()); + assertEquals(List.of("DEFAULT", "1"), snapshot); + LongValue replacement = new LongValue(2); + option.setDefaultExpression(replacement); + original.setValue(99); + assertEquals(List.of("DEFAULT", "2"), option.getTokens()); + assertVisited(column, replacement); + assertCustomRendering(statement, "DEFAULT 2", "DEFAULT 42"); + assertRoundTrip(statement); + + ColumnOption raw = ColumnOption.raw(List.of("DEFAULT", "8")); + column.getColumnOptions().set(0, raw); + assertEquals(ColumnOption.Kind.OTHER, raw.getKind()); + assertNull(raw.getDefaultExpression()); + assertVisited(column); + replacement.setValue(100); + assertEquals(List.of("DEFAULT", "8"), raw.getTokens()); + assertRoundTrip(statement); + } + + @ParameterizedTest + @ValueSource(strings = { + "CREATE TABLE t (source INT, computed INT GENERATED ALWAYS AS (source + 1) STORED)", + "ALTER TABLE t ADD COLUMN computed INT GENERATED ALWAYS AS (source + 1) STORED"}) + void generatedDefinitionAndOptionExposeReadOnlySnapshotsWithLiveChildren(String sql) + throws JSQLParserException { + Statement statement = parse(sql); + ColumnDefinition column = column(statement); + ColumnOption option = column.getColumnOptions().get(0); + GeneratedColumnDefinition generated = option.getGeneratedDefinition(); + List snapshot = generated.getTokens(); + List optionSnapshot = option.getTokens(); + assertReadOnly(snapshot); + assertReadOnly(optionSnapshot); + generated.setGeneratedAlways(false); + generated.setStorage(GeneratedColumnDefinition.Storage.VIRTUAL); + Addition expression = (Addition) CCJSqlParserUtil.parseExpression("source + 2"); + generated.setExpression(expression); + ((LongValue) expression.getRightExpression()).setValue(3); + assertSame(generated, option.getGeneratedDefinition()); + assertEquals(List.of("AS", "(source + 3)", "VIRTUAL"), generated.getTokens()); + assertEquals(generated.getTokens(), option.getTokens()); + assertEquals(List.of("GENERATED", "ALWAYS", "AS", "(source + 1)", "STORED"), snapshot); + assertEquals(snapshot, optionSnapshot); + assertVisited(column, expression); + assertCustomRendering(statement, "source + 3", "source + 42"); + assertRoundTrip(statement); + } + + private static void assertReadOnly(List tokens) { + assertThrows(UnsupportedOperationException.class, () -> tokens.set(0, "changed")); + assertThrows(UnsupportedOperationException.class, () -> tokens.add("changed")); + assertThrows(UnsupportedOperationException.class, tokens::clear); + } + + private static void assertVisited(ColumnDefinition column, Expression... expected) { + List expressions = new ArrayList<>(); + TableDefinitionTraversal.visit(column, expressions::add, ignored -> { + }); + assertEquals(List.of(expected), expressions); + } + + private static Statement parse(String sql) throws JSQLParserException { + return CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(Dialect.MYSQL)); + } + + private static ColumnDefinition column(Statement statement) { + if (statement instanceof CreateTable) { + List columns = ((CreateTable) statement).getColumnDefinitions(); + return columns.get(columns.size() - 1); + } + return ((Alter) statement).getAlterExpressions().get(0).getColDataTypeList().get(0); + } + + private static void assertCustomRendering(Statement statement, String before, String after) + throws JSQLParserException { + StringBuilder sql = new StringBuilder(); + ExpressionDeParser expressions = new ExpressionDeParser() { + @Override + public StringBuilder visit(LongValue value, S context) { + return getBuilder().append(42); + } + }; + statement.accept(new StatementDeParser(expressions, new SelectDeParser(), sql), null); + assertEquals(statement.toString().replace(before, after), sql.toString()); + assertEquals(sql.toString(), parse(sql.toString()).toString()); + } + + private static void assertRoundTrip(Statement statement) throws JSQLParserException { + StringBuilder sql = new StringBuilder(); + statement.accept(new StatementDeParser(sql), null); + assertEquals(statement.toString(), sql.toString()); + assertEquals(sql.toString(), parse(sql.toString()).toString()); + } +}