diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/ColumnDefinition.java b/src/main/java/net/sf/jsqlparser/statement/create/table/ColumnDefinition.java index 25f3de620..ccede4eb8 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/table/ColumnDefinition.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/ColumnDefinition.java @@ -75,8 +75,9 @@ public void setColumnSpecs(List list) { } /** - * Returns column options in source order, including structured defaults, references and MySQL - * {@code SERIAL DEFAULT VALUE}. + * Returns column options in source order, including defaults, references, generated columns, + * nullability, collation, comments, visibility and MySQL attributes. Unrecognized options + * remain raw. Structured option keywords use canonical capitalization when rendered. */ public List getColumnOptions() { return columnOptions; 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 563b099dd..23f2ef91f 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 @@ -16,13 +16,14 @@ import java.util.Objects; import java.util.function.Consumer; import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.StringValue; import net.sf.jsqlparser.statement.select.PlainSelect; /** A structured option following a column data type. */ public class ColumnOption implements Serializable { public enum Kind { - SERIAL_DEFAULT_VALUE, REFERENCE, IDENTITY, CONSTRAINT, DEFAULT, OTHER + SERIAL_DEFAULT_VALUE, REFERENCE, IDENTITY, CONSTRAINT, DEFAULT, NULLABILITY, COLLATE, COMMENT, ON_UPDATE, GENERATED, AUTO_INCREMENT, VISIBILITY, OTHER } private Kind kind = Kind.OTHER; @@ -31,6 +32,122 @@ public enum Kind { private IdentityDefinition identityDefinition; private Index constraint; private Expression defaultExpression; + private Boolean nullable; + private Boolean visible; + private String collation; + private StringValue comment; + private Expression onUpdateExpression; + private GeneratedColumnDefinition generatedDefinition; + + public static ColumnOption nullability(boolean nullable) { + ColumnOption option = new ColumnOption(); + option.kind = Kind.NULLABILITY; + option.nullable = nullable; + return option; + } + + public Boolean getNullable() { + return nullable; + } + + public void setNullable(boolean nullable) { + this.nullable = nullable; + } + + public static ColumnOption visibility(boolean visible) { + ColumnOption option = new ColumnOption(); + option.kind = Kind.VISIBILITY; + option.visible = visible; + return option; + } + + public Boolean getVisible() { + return visible; + } + + public void setVisible(boolean visible) { + this.visible = visible; + } + + public static ColumnOption autoIncrement() { + ColumnOption option = new ColumnOption(); + option.kind = Kind.AUTO_INCREMENT; + return option; + } + + public static ColumnOption collate(String collation) { + ColumnOption option = new ColumnOption(); + option.kind = Kind.COLLATE; + option.setCollation(collation); + return option; + } + + public String getCollation() { + return collation; + } + + public void setCollation(String collation) { + this.collation = Objects.requireNonNull(collation, "collation"); + } + + public static ColumnOption comment(StringValue comment) { + ColumnOption option = new ColumnOption(); + option.kind = Kind.COMMENT; + option.setComment(comment); + return option; + } + + public StringValue getComment() { + return comment; + } + + public void setComment(StringValue comment) { + this.comment = Objects.requireNonNull(comment, "comment"); + } + + public static ColumnOption onUpdate(Expression expression) { + ColumnOption option = new ColumnOption(); + option.kind = Kind.ON_UPDATE; + option.setOnUpdateExpression(expression); + return option; + } + + public Expression getOnUpdateExpression() { + return onUpdateExpression; + } + + public void setOnUpdateExpression(Expression expression) { + onUpdateExpression = Objects.requireNonNull(expression, "expression"); + } + + public static ColumnOption generated(GeneratedColumnDefinition definition) { + ColumnOption option = new ColumnOption(); + option.kind = Kind.GENERATED; + option.setGeneratedDefinition(definition); + return option; + } + + public GeneratedColumnDefinition getGeneratedDefinition() { + return generatedDefinition; + } + + public void setGeneratedDefinition(GeneratedColumnDefinition definition) { + generatedDefinition = Objects.requireNonNull(definition, "definition"); + } + + /** Visits expressions of the selected option kind, including comment literals. */ + public void visitExpressions(Consumer visitor) { + if (kind == Kind.DEFAULT) { + visitor.accept(defaultExpression); + } else if (kind == Kind.COMMENT) { + visitor.accept(comment); + } else if (kind == Kind.ON_UPDATE) { + visitor.accept(onUpdateExpression); + } else if (kind == Kind.GENERATED) { + visitor.accept(generatedDefinition.getExpression()); + } + } + /** Creates a DEFAULT option. Use a NullValue expression for SQL NULL. */ public static ColumnOption defaultValue(Expression expression) { @@ -99,11 +216,30 @@ public Kind getKind() { } public List getTokens() { - if (kind == Kind.DEFAULT) { - return Arrays.asList("DEFAULT", String.valueOf(defaultExpression)); + switch (kind) { + case DEFAULT: + return Arrays.asList("DEFAULT", String.valueOf(defaultExpression)); + case NULLABILITY: + return nullable ? Collections.singletonList("NULL") : Arrays.asList("NOT", "NULL"); + case COLLATE: + return Arrays.asList("COLLATE", collation); + case COMMENT: + return Arrays.asList("COMMENT", comment.toString()); + case ON_UPDATE: + return 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.singletonList(toString()); + case OTHER: + case SERIAL_DEFAULT_VALUE: + return tokens; + default: + return Collections.singletonList(toString()); } - return kind == Kind.OTHER || kind == Kind.SERIAL_DEFAULT_VALUE ? tokens - : Collections.singletonList(toString()); } public ForeignKeyReference getForeignKeyReference() { @@ -124,6 +260,29 @@ public void appendTo(StringBuilder builder, Consumer expressionPrint builder.append("DEFAULT "); expressionPrinter.accept(defaultExpression); break; + case NULLABILITY: + builder.append(nullable ? "NULL" : "NOT NULL"); + break; + case VISIBILITY: + builder.append(visible ? "VISIBLE" : "INVISIBLE"); + break; + case AUTO_INCREMENT: + builder.append("AUTO_INCREMENT"); + break; + case COLLATE: + builder.append("COLLATE ").append(collation); + break; + case COMMENT: + builder.append("COMMENT "); + expressionPrinter.accept(comment); + break; + case ON_UPDATE: + builder.append("ON UPDATE "); + expressionPrinter.accept(onUpdateExpression); + break; + case GENERATED: + generatedDefinition.appendTo(builder, expressionPrinter); + break; case REFERENCE: builder.append(foreignKeyReference); break; 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 new file mode 100644 index 000000000..cfc6bce5a --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/GeneratedColumnDefinition.java @@ -0,0 +1,90 @@ +/*- + * #%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.table; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; + +/** A parenthesized generated column expression, distinct from an identity declaration. */ +public class GeneratedColumnDefinition implements Serializable { + public enum Storage { + STORED, VIRTUAL + } + + private Expression expression; + private boolean generatedAlways; + private Storage storage; + + public GeneratedColumnDefinition(Expression expression) { + setExpression(expression); + } + + public Expression getExpression() { + return expression; + } + + public void setExpression(Expression expression) { + this.expression = Objects.requireNonNull(expression, "expression"); + } + + public boolean isGeneratedAlways() { + return generatedAlways; + } + + public void setGeneratedAlways(boolean generatedAlways) { + this.generatedAlways = generatedAlways; + } + + /** Returns null when the SQL does not specify STORED or VIRTUAL. */ + public Storage getStorage() { + return storage; + } + + public void setStorage(Storage storage) { + this.storage = storage; + } + + public List getTokens() { + List tokens = new ArrayList<>(); + if (generatedAlways) { + tokens.add("GENERATED"); + tokens.add("ALWAYS"); + } + tokens.add("AS"); + tokens.add("(" + expression + ")"); + if (storage != null) { + tokens.add(storage.name()); + } + return tokens; + } + + public StringBuilder appendTo(StringBuilder builder, Consumer expressionPrinter) { + if (generatedAlways) { + builder.append("GENERATED ALWAYS "); + } + builder.append("AS ("); + expressionPrinter.accept(expression); + builder.append(')'); + if (storage != null) { + builder.append(' ').append(storage); + } + return builder; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/util/TableDefinitionTraversal.java b/src/main/java/net/sf/jsqlparser/util/TableDefinitionTraversal.java index 1b0e76b15..f6883922e 100644 --- a/src/main/java/net/sf/jsqlparser/util/TableDefinitionTraversal.java +++ b/src/main/java/net/sf/jsqlparser/util/TableDefinitionTraversal.java @@ -133,7 +133,7 @@ public static void visit(TableElement element, Consumer expressions, ColumnDefinition column = (ColumnDefinition) element; if (column.getColumnOptions() != null) { for (ColumnOption option : column.getColumnOptions()) { - accept(option.getDefaultExpression(), expressions); + option.visitExpressions(expressions); if (option.getForeignKeyReference() != null) { accept(option.getForeignKeyReference().getTable(), tables); } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 4ab8d1505..50b3b77e9 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -12651,12 +12651,44 @@ ColumnOption ColumnDefinitionOption(): { IdentityDefinition identity; NamedConstraint constraint; Expression defaultExpression; + GeneratedColumnDefinition generated; + ObjectNames collationNames; + String collation; } { ( LOOKAHEAD({ isKeywordAhead("GENERATED") && (getToken(4).kind == K_IDENTITY || getToken(5).kind == K_IDENTITY) }) identity=IdentityDefinition() { option = ColumnOption.identity(identity); } | + LOOKAHEAD({ (isKeywordAhead("GENERATED") && getToken(2).kind == K_ALWAYS + && getToken(3).kind == K_AS && "(".equals(getToken(4).image)) + || (getToken(1).kind == K_AS && "(".equals(getToken(2).image)) }) + generated=GeneratedColumnDefinition() { option = ColumnOption.generated(generated); } + | + LOOKAHEAD( ) { option = ColumnOption.nullability(false); } + | + LOOKAHEAD() { option = ColumnOption.nullability(true); } + | + LOOKAHEAD() { option = ColumnOption.visibility(true); } + | + LOOKAHEAD() { option = ColumnOption.visibility(false); } + | + LOOKAHEAD() { option = ColumnOption.autoIncrement(); } + | + LOOKAHEAD() + ( tk= { collation = tk.image; } + | collationNames=RelObjectNames() { collation = String.join(".", collationNames.getNames()); } ) + { option = ColumnOption.collate(collation); } + | + LOOKAHEAD() tk= + { option = ColumnOption.comment(new StringValue(tk.image)); } + | + LOOKAHEAD( ) defaultExpression=Expression() + { option = ColumnOption.onUpdate(defaultExpression); } + | + LOOKAHEAD( ) + { option = ColumnOption.constraint(new NamedConstraint().withType("PRIMARY KEY")); } + | LOOKAHEAD() constraint=ColumnUniqueConstraint() { option = ColumnOption.constraint(constraint); } | @@ -12685,6 +12717,28 @@ ColumnOption ColumnDefinitionOption(): { { return option; } } +GeneratedColumnDefinition GeneratedColumnDefinition(): +{ + Expression expression; + boolean generatedAlways = false; + GeneratedColumnDefinition.Storage storage = null; + GeneratedColumnDefinition result; +} +{ + [ LOOKAHEAD({ isKeywordAhead("GENERATED") }) ContextualKeyword("GENERATED") + { generatedAlways = true; } ] + "(" expression=Expression() ")" + [ LOOKAHEAD({ getToken(1).kind == K_STORED || isKeywordAhead("VIRTUAL") }) + ( { storage = GeneratedColumnDefinition.Storage.STORED; } + | ContextualKeyword("VIRTUAL") { storage = GeneratedColumnDefinition.Storage.VIRTUAL; } ) ] + { + result = new GeneratedColumnDefinition(expression); + result.setGeneratedAlways(generatedAlways); + result.setStorage(storage); + return result; + } +} + NamedConstraint ColumnUniqueConstraint(): { NamedConstraint constraint = new NamedConstraint().withType("UNIQUE"); diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 814888bef..01164ecdb 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -273,6 +273,37 @@ Table constraints expose ``Index.getNullsDistinct()``, ``getIncludeColumns()``, Identity alterations are available as ``ColumnDataType.getIdentityAlterations()``. Sequence ownership is shared by ``CreateSequence`` and ``AlterSequence`` through ``Sequence.getOwnership()``: ``null`` means omitted, ``isNone()`` means explicit ``OWNED BY NONE``, and ``getColumn()`` identifies an owner. ``TablesNamesFinder`` includes ``LIKE`` sources and sequence owners without treating sequence or type names as tables. See `ALTER TABLE `_ and `ALTER SEQUENCE `_. +Structured column attributes +============================ + +``ColumnDefinition.getColumnOptions()`` exposes column nullability, ``COLLATE``, +``COMMENT``, ``ON UPDATE``, ``AUTO_INCREMENT``, visibility, inline ``PRIMARY KEY`` +and parenthesized generated expressions alongside existing defaults, references +and identity declarations. CREATE and ALTER column definitions use the same model. + +``ColumnOption.Kind`` selects the relevant accessor: ``getNullable()``, +``getCollation()``, ``getComment()``, ``getOnUpdateExpression()``, +``getVisible()``, ``getConstraint()`` or ``getGeneratedDefinition()``. +The AUTO_INCREMENT kind has no additional payload. A missing option does not +imply a server default. Option order and unknown raw extensions are preserved. + +``GeneratedColumnDefinition`` contains an ``Expression``, an explicit +``GENERATED ALWAYS`` flag, and nullable ``Storage`` (STORED/VIRTUAL). Identity +columns continue to use ``IdentityDefinition``. Visitors, validators and custom +expression deparsers traverse generation and ON UPDATE expressions and comment +literals. This is syntax modeling; server restrictions on permissible generation +expressions are not evaluated. + +Charset remains in ``ColDataType.getCharacterSet()``; column collation is in its +COLLATE option. Consumers can map both directly without reconstructing tokens. +StringValue comment bodies retain their SQL escape representation. + +The legacy ``getColumnSpecs()`` returns a token snapshot when options are +structured; expression fragments may occupy one token and structured keywords +use canonical capitalization. Mutate the option objects to change the AST. +``addColumnSpecs`` preserves existing options; ``setColumnSpecs`` explicitly +replaces them with raw specifications. + Inspect logical replication statements ====================================== diff --git a/src/test/java/net/sf/jsqlparser/statement/DeclareStatementTest.java b/src/test/java/net/sf/jsqlparser/statement/DeclareStatementTest.java index 725a60a29..7ec516f7f 100644 --- a/src/test/java/net/sf/jsqlparser/statement/DeclareStatementTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/DeclareStatementTest.java @@ -16,6 +16,7 @@ import net.sf.jsqlparser.statement.DeclareStatement.TypeDefExpr; import net.sf.jsqlparser.statement.create.table.ColDataType; import net.sf.jsqlparser.statement.create.table.ColumnDefinition; +import net.sf.jsqlparser.statement.create.table.ColumnOption; import static net.sf.jsqlparser.test.TestUtils.asList; import static net.sf.jsqlparser.test.TestUtils.assertDeparse; import static net.sf.jsqlparser.test.TestUtils.assertEqualsObjectTree; @@ -87,8 +88,8 @@ public void testDeclareTable() throws JSQLParserException { .withUserVariable(new UserVariable("MyTableVar")) .withColumnDefinitions(new ArrayList<>()) .addColumnDefinitions( - new ColumnDefinition("EmpID", new ColDataType().withDataType("int"), - asList("NOT", "NULL")), + new ColumnDefinition("EmpID", new ColDataType().withDataType("int")) + .addColumnOptions(ColumnOption.nullability(false)), new ColumnDefinition("OldVacationHours", new ColDataType("int"))) .addColumnDefinitions( asList( diff --git a/src/test/java/net/sf/jsqlparser/statement/alter/AlterTest.java b/src/test/java/net/sf/jsqlparser/statement/alter/AlterTest.java index b6446de06..fa82730d4 100644 --- a/src/test/java/net/sf/jsqlparser/statement/alter/AlterTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/alter/AlterTest.java @@ -370,12 +370,12 @@ public void testAlterTableAddColumn5() throws JSQLParserException { public void testAlterTableAddColumn6() throws JSQLParserException { final String sql = "ALTER TABLE mytable ADD COLUMN col1 timestamp (3) not null"; Statement stmt = CCJSqlParserUtil.parse(sql); - assertStatementCanBeDeparsedAs(stmt, sql); + assertStatementCanBeDeparsedAs(stmt, sql.replace("not null", "NOT NULL")); Alter alter = (Alter) stmt; List alterExps = alter.getAlterExpressions(); AlterExpression col1Exp = alterExps.get(0); - assertEquals("not", col1Exp.getColDataTypeList().get(0).getColumnSpecs().get(0)); - assertEquals("null", col1Exp.getColDataTypeList().get(0).getColumnSpecs().get(1)); + assertEquals("NOT", col1Exp.getColDataTypeList().get(0).getColumnSpecs().get(0)); + assertEquals("NULL", col1Exp.getColDataTypeList().get(0).getColumnSpecs().get(1)); assertTrue(col1Exp.hasColumn()); } diff --git a/src/test/java/net/sf/jsqlparser/statement/create/ColumnAttributeTest.java b/src/test/java/net/sf/jsqlparser/statement/create/ColumnAttributeTest.java new file mode 100644 index 000000000..faf18fdbf --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/ColumnAttributeTest.java @@ -0,0 +1,239 @@ +/*- + * #%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 static net.sf.jsqlparser.util.validation.ValidationTestAsserts.validateNotAllowed; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import java.util.stream.Collectors; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.ExpressionVisitorAdapter; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.parser.feature.Feature; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitorAdapter; +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.GeneratedColumnDefinition; +import net.sf.jsqlparser.statement.select.SelectVisitorAdapter; +import net.sf.jsqlparser.util.deparser.ExpressionDeParser; +import net.sf.jsqlparser.util.deparser.SelectDeParser; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import net.sf.jsqlparser.util.validation.feature.FeaturesAllowed; +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; +import org.junit.jupiter.params.provider.ValueSource; + +class ColumnAttributeTest { + static Stream attributes() { + return Stream.of( + Arguments.of("NOT NULL", ColumnOption.Kind.NULLABILITY), + Arguments.of("NULL", ColumnOption.Kind.NULLABILITY), + Arguments.of("AUTO_INCREMENT", ColumnOption.Kind.AUTO_INCREMENT), + Arguments.of("VISIBLE", ColumnOption.Kind.VISIBILITY), + Arguments.of("INVISIBLE", ColumnOption.Kind.VISIBILITY), + Arguments.of("COLLATE utf8mb4_bin", ColumnOption.Kind.COLLATE), + Arguments.of("COLLATE `a.b`", ColumnOption.Kind.COLLATE), + Arguments.of("COMMENT 'quote''s \\n body'", ColumnOption.Kind.COMMENT), + Arguments.of("COMMENT ''", ColumnOption.Kind.COMMENT), + Arguments.of("ON UPDATE CURRENT_TIMESTAMP", ColumnOption.Kind.ON_UPDATE), + Arguments.of("ON UPDATE CURRENT_TIMESTAMP(6)", ColumnOption.Kind.ON_UPDATE), + Arguments.of("ON UPDATE NOW()", ColumnOption.Kind.ON_UPDATE), + Arguments.of("PRIMARY KEY", ColumnOption.Kind.CONSTRAINT)); + } + + @ParameterizedTest + @MethodSource("attributes") + void createAndAlterStructureAttributes(String attribute, ColumnOption.Kind kind) + throws Exception { + for (String sql : statements("VARCHAR(20) " + attribute)) { + for (Statement statement : roundTrip(sql, Dialect.MYSQL)) { + ColumnOption option = column(statement).getColumnOptions().get(0); + assertEquals(kind, option.getKind()); + assertEquals(attribute, option.toString()); + assertNull(option.getDefaultExpression()); + } + } + } + + @ParameterizedTest + @ValueSource(strings = {"x + 1", "COALESCE(x, 0) * 2", "CASE WHEN x > 0 THEN x ELSE 0 END", + "CAST(x AS CHAR(10))", "CONCAT('prefix,', x)", "((x + 1) * 2)"}) + void generatedExpressionsKeepPrefixStorageAndFollowingOptions(String expression) + throws Exception { + for (String prefix : List.of("", "GENERATED ALWAYS ")) { + for (String storage : List.of("", " STORED", " VIRTUAL")) { + for (String sql : statements("INT " + prefix + "AS (" + expression + ")" + storage + + " NOT NULL COMMENT 'body'")) { + for (Statement statement : roundTrip(sql, Dialect.MYSQL)) { + List options = column(statement).getColumnOptions(); + assertEquals( + List.of(ColumnOption.Kind.GENERATED, ColumnOption.Kind.NULLABILITY, + ColumnOption.Kind.COMMENT), + options.stream().map(ColumnOption::getKind) + .collect(Collectors.toList())); + GeneratedColumnDefinition generated = + options.get(0).getGeneratedDefinition(); + assertEquals(!prefix.isEmpty(), generated.isGeneratedAlways()); + assertEquals( + storage.isEmpty() ? null + : GeneratedColumnDefinition.Storage.valueOf(storage.trim()), + generated.getStorage()); + assertEquals(CCJSqlParserUtil.parseExpression(expression).toString(), + generated.getExpression().toString()); + } + } + } + } + } + + @ParameterizedTest + @ValueSource(strings = {"\"C\"", "pg_catalog.\"C\"", "\"s.p\".\"a.b\""}) + void postgresCollationAndCharsetAreSeparateAttributes(String name) throws Exception { + for (Statement statement : roundTrip( + "CREATE TABLE t (c TEXT COLLATE " + name + " NOT NULL)", Dialect.POSTGRESQL)) { + assertEquals(name, column(statement).getColumnOptions().get(0).getCollation()); + } + Statement mysql = + parse("CREATE TABLE t (c VARCHAR(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin)", + Dialect.MYSQL); + assertEquals("utf8mb4", column(mysql).getColDataType().getCharacterSet()); + assertEquals("utf8mb4_bin", column(mysql).getColumnOptions().get(0).getCollation()); + } + + @Test + void keepsIdentityReferencesDefaultsAndRawExtensionsDistinct() throws Exception { + Statement statement = parse( + "CREATE TABLE t (c INT NOT NULL DEFAULT 0 ON UPDATE CURRENT_TIMESTAMP(6) " + + "REFERENCES parent(id) ON UPDATE CASCADE COMMENT 'body' COLUMN_FORMAT FIXED)", + Dialect.MYSQL); + List options = column(statement).getColumnOptions(); + assertEquals(List.of(ColumnOption.Kind.NULLABILITY, ColumnOption.Kind.DEFAULT, + ColumnOption.Kind.ON_UPDATE, ColumnOption.Kind.REFERENCE, + ColumnOption.Kind.COMMENT), + options.subList(0, 5).stream().map(ColumnOption::getKind) + .collect(Collectors.toList())); + assertTrue(options.stream().skip(5).allMatch(o -> o.getKind() == ColumnOption.Kind.OTHER)); + for (String definition : List.of("INT GENERATED ALWAYS AS IDENTITY", + "INT GENERATED BY DEFAULT AS IDENTITY")) { + assertEquals(ColumnOption.Kind.IDENTITY, + column(parse("CREATE TABLE t (c " + definition + ")", Dialect.POSTGRESQL)) + .getColumnOptions().get(0).getKind()); + } + } + + @Test + void mutationsUpdateLegacyTokensAndBothRenderers() throws Exception { + Statement statement = + parse("CREATE TABLE t (c INT NOT NULL INVISIBLE COMMENT 'old' AS (x + 1) STORED)", + Dialect.MYSQL); + List options = column(statement).getColumnOptions(); + options.get(0).setNullable(true); + options.get(1).setVisible(true); + options.get(2).setComment(new StringValue("'new'")); + options.get(3).getGeneratedDefinition().setExpression(new LongValue(42)); + options.get(3).getGeneratedDefinition() + .setStorage(GeneratedColumnDefinition.Storage.VIRTUAL); + assertEquals(List.of("NULL", "VISIBLE", "COMMENT", "'new'", "AS", "(42)", "VIRTUAL"), + column(statement).getColumnSpecs()); + roundTrip(statement.toString(), Dialect.MYSQL); + column(statement).addColumnSpecs("CUSTOM"); + assertEquals(ColumnOption.Kind.OTHER, options.get(options.size() - 1).getKind()); + column(statement).setColumnSpecs(List.of("NOT", "NULL")); + assertNull(column(statement).getColumnOptions()); + assertEquals("CREATE TABLE t (c INT NOT NULL)", statement.toString()); + } + + @ParameterizedTest + @ValueSource(strings = {"CREATE TABLE t (c INT AS (1 + 2) COMMENT 'body')", + "ALTER TABLE t ADD c INT AS (1 + 2) COMMENT 'body'"}) + void visitorsAndCustomDeparsersReachExpressionsWithContext(String sql) throws Exception { + Statement statement = parse(sql, Dialect.MYSQL); + List visited = new ArrayList<>(); + ExpressionVisitorAdapter expressions = new ExpressionVisitorAdapter() { + @Override + public Void visit(LongValue value, S context) { + assertEquals("ctx", context); + visited.add(value.toString()); + return null; + } + + @Override + public Void visit(StringValue value, S context) { + assertEquals("ctx", context); + visited.add(value.getValue()); + return null; + } + }; + statement.accept(new StatementVisitorAdapter<>(new SelectVisitorAdapter<>(expressions)), + "ctx"); + assertEquals(List.of("1", "2", "body"), visited); + StringBuilder builder = new StringBuilder(); + ExpressionDeParser printer = new ExpressionDeParser() { + @Override + public StringBuilder visit(LongValue value, S context) { + return getBuilder().append(value.getValue() + 10); + } + }; + statement.accept(new StatementDeParser(printer, new SelectDeParser(), builder), null); + assertEquals(statement.toString().replace("1 + 2", "11 + 12"), builder.toString()); + } + + @ParameterizedTest + @ValueSource(strings = {"AS (?)", "ON UPDATE ?"}) + void validationReachesNewExpressions(String attribute) { + validateNotAllowed("CREATE TABLE t (c INT " + attribute + ")", 1, 1, FeaturesAllowed.DDL, + Feature.jdbcParameter); + } + + @ParameterizedTest + @ValueSource( + strings = {"COMMENT", "COLLATE", "ON UPDATE", "AS (1 +)", "GENERATED ALWAYS AS (1 +)"}) + void rejectsIncompleteStructuredAttributes(String attribute) { + assertThrows(JSQLParserException.class, + () -> parse("CREATE TABLE t (c INT " + attribute + ")", Dialect.MYSQL)); + } + + private static List statements(String definition) { + return List.of("CREATE TABLE t (c " + definition + ")", + "ALTER TABLE t ADD COLUMN c " + definition, + "ALTER TABLE t MODIFY COLUMN c " + definition, + "ALTER TABLE t CHANGE COLUMN old_c c " + definition); + } + + private static Statement parse(String sql, Dialect dialect) throws JSQLParserException { + return CCJSqlParserUtil.parse(sql, p -> p.withDialect(dialect)); + } + + private static ColumnDefinition column(Statement statement) { + return statement instanceof CreateTable + ? ((CreateTable) statement).getColumnDefinitions().get(0) + : ((Alter) statement).getAlterExpressions().get(0).getColDataTypeList().get(0); + } + + private static List roundTrip(String sql, Dialect dialect) + throws JSQLParserException { + Statement statement = parse(sql, dialect); + StringBuilder builder = new StringBuilder(); + statement.accept(new StatementDeParser(builder), null); + assertEquals(statement.toString(), builder.toString()); + return List.of(statement, parse(statement.toString(), dialect), + parse(builder.toString(), dialect)); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/create/ColumnOptionMutationTest.java b/src/test/java/net/sf/jsqlparser/statement/create/ColumnOptionMutationTest.java index 3b6233e66..bceb012c4 100644 --- a/src/test/java/net/sf/jsqlparser/statement/create/ColumnOptionMutationTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/create/ColumnOptionMutationTest.java @@ -49,8 +49,9 @@ void legacyAppendPreservesReferenceAndTraversal() throws JSQLParserException { @Test void addingStructuredOptionPreservesExistingRawSpecifications() throws JSQLParserException { - CreateTable table = parse("id INT NOT NULL"); + CreateTable table = parse("id INT"); ColumnDefinition column = table.getColumnDefinitions().get(0); + column.setColumnSpecs(Arrays.asList("NOT", "NULL")); assertNull(column.getColumnOptions()); ForeignKeyReference reference = parse("id INT REFERENCES parent(id)") .getColumnDefinitions().get(0).getForeignKeyReference();