diff --git a/src/main/java/net/sf/jsqlparser/statement/alter/AlterExpression.java b/src/main/java/net/sf/jsqlparser/statement/alter/AlterExpression.java index 046e6bb23..daa9b20d0 100644 --- a/src/main/java/net/sf/jsqlparser/statement/alter/AlterExpression.java +++ b/src/main/java/net/sf/jsqlparser/statement/alter/AlterExpression.java @@ -1890,6 +1890,7 @@ public static final class ColumnDataType extends ColumnDefinition private final boolean withType; private boolean useSetData; private Expression usingExpression; + private ColumnPosition position; public boolean isUseSetData() { return useSetData; @@ -1924,6 +1925,48 @@ public void setIdentityAlterations(List identityAlterations) identityAlterations == null ? null : new ArrayList<>(identityAlterations); } + /** + * Returns the MySQL FIRST/AFTER placement rendered after this definition, or null when the + * column keeps its position. + */ + public ColumnPosition getPosition() { + return position; + } + + public void setPosition(ColumnPosition position) { + this.position = position; + } + + public ColumnDataType withPosition(ColumnPosition position) { + setPosition(position); + return this; + } + + /** + * Returns the legacy specification snapshot: the option tokens followed by the tokens of + * {@link #getPosition()}. Returns null only when both are unset. + */ + @Override + public List getColumnSpecs() { + List specs = super.getColumnSpecs(); + if (position == null) { + return specs; + } + List tokens = specs == null ? new ArrayList<>() : new ArrayList<>(specs); + tokens.addAll(position.getTokens()); + return Collections.unmodifiableList(tokens); + } + + /** + * Replaces every option and the position with opaque tokens, matching + * {@link #getColumnSpecs()}. Tokens are never parsed. + */ + @Override + public void setColumnSpecs(List list) { + super.setColumnSpecs(list); + position = null; + } + public ColumnDataType(boolean withType) { super(); this.withType = withType; @@ -1958,6 +2001,9 @@ public void appendTo(StringBuilder builder, Consumer expressionPrint builder.append(" USING "); expressionPrinter.accept(usingExpression); } + if (position != null) { + builder.append(' ').append(position); + } } @Override diff --git a/src/main/java/net/sf/jsqlparser/statement/alter/ColumnPosition.java b/src/main/java/net/sf/jsqlparser/statement/alter/ColumnPosition.java new file mode 100644 index 000000000..19b9312c4 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/alter/ColumnPosition.java @@ -0,0 +1,86 @@ +/*- + * #%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 java.io.Serializable; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * The MySQL/MariaDB {@code FIRST} or {@code AFTER column} placement that follows the column + * definition of an ALTER TABLE ADD, CHANGE or MODIFY action. Instances are immutable; replace the + * position of a definition to move the column. + */ +public final class ColumnPosition implements Serializable { + + public enum Kind { + FIRST, AFTER + } + + private static final ColumnPosition FIRST = new ColumnPosition(Kind.FIRST, null); + + private final Kind kind; + private final String columnName; + + private ColumnPosition(Kind kind, String columnName) { + this.kind = kind; + this.columnName = columnName; + } + + /** Places the column at the start of the table. */ + public static ColumnPosition first() { + return FIRST; + } + + /** Places the column after the named existing column. */ + public static ColumnPosition after(String columnName) { + return new ColumnPosition(Kind.AFTER, Objects.requireNonNull(columnName, "columnName")); + } + + public Kind getKind() { + return kind; + } + + /** Returns the preceding column of an AFTER position, or null for FIRST. */ + public String getColumnName() { + return columnName; + } + + /** Returns the SQL tokens of this position as an unmodifiable list. */ + public List getTokens() { + return kind == Kind.FIRST + ? Collections.singletonList("FIRST") + : Collections.unmodifiableList(Arrays.asList("AFTER", columnName)); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof ColumnPosition)) { + return false; + } + ColumnPosition position = (ColumnPosition) other; + return kind == position.kind && Objects.equals(columnName, position.columnName); + } + + @Override + public int hashCode() { + return Objects.hash(kind, columnName); + } + + @Override + public String toString() { + return kind == Kind.FIRST ? "FIRST" : "AFTER " + columnName; + } +} 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 155336d47..f0e7fec94 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 @@ -24,7 +24,7 @@ public class ColumnOption implements Serializable { public enum Kind { - SERIAL_DEFAULT_VALUE, REFERENCE, IDENTITY, CONSTRAINT, DEFAULT, NULLABILITY, COLLATE, COMMENT, ON_UPDATE, GENERATED, AUTO_INCREMENT, VISIBILITY, STORAGE, COMPRESSION, FOREIGN_OPTIONS, OTHER + SERIAL_DEFAULT_VALUE, REFERENCE, IDENTITY, CONSTRAINT, DEFAULT, NULLABILITY, COLLATE, COMMENT, ON_UPDATE, GENERATED, AUTO_INCREMENT, VISIBILITY, STORAGE, COMPRESSION, FOREIGN_OPTIONS, SRID, COLUMN_FORMAT, ENGINE_ATTRIBUTE, SECONDARY_ENGINE_ATTRIBUTE, OTHER } /** PostgreSQL storage strategies and MySQL column storage locations. */ @@ -32,7 +32,23 @@ public enum Storage { PLAIN, EXTERNAL, EXTENDED, MAIN, DEFAULT, DISK, MEMORY } + /** MySQL {@code COLUMN_FORMAT} values. */ + public enum ColumnFormat { + FIXED, DYNAMIC, DEFAULT + } + + /** + * Inline key spellings whose legacy tokens are split on spaces. KEY is MySQL's column-level + * synonym for PRIMARY KEY. + */ + private static final List KEY_SPELLINGS = + Arrays.asList("PRIMARY KEY", "UNIQUE KEY", "KEY"); + private List foreignOptions; + private Long srid; + private ColumnFormat columnFormat; + private StringValue engineAttribute; + private boolean useEquals; public List getForeignOptions() { return foreignOptions; @@ -83,6 +99,77 @@ public void setCompression(String compression) { this.compression = Objects.requireNonNull(compression, "compression"); } + /** Creates a MySQL {@code SRID} option for a spatial column. */ + public static ColumnOption srid(long srid) { + ColumnOption option = new ColumnOption(); + option.kind = Kind.SRID; + option.setSrid(srid); + return option; + } + + /** Returns the spatial reference system identifier of an SRID option, or null. */ + public Long getSrid() { + return srid; + } + + public void setSrid(long srid) { + this.srid = srid; + } + + /** Creates a MySQL {@code COLUMN_FORMAT} option. */ + public static ColumnOption columnFormat(ColumnFormat columnFormat) { + ColumnOption option = new ColumnOption(); + option.kind = Kind.COLUMN_FORMAT; + option.setColumnFormat(columnFormat); + return option; + } + + public ColumnFormat getColumnFormat() { + return columnFormat; + } + + public void setColumnFormat(ColumnFormat columnFormat) { + this.columnFormat = Objects.requireNonNull(columnFormat, "columnFormat"); + } + + /** Creates a MySQL {@code ENGINE_ATTRIBUTE} option with a JSON string literal. */ + public static ColumnOption engineAttribute(StringValue attribute) { + ColumnOption option = new ColumnOption(); + option.kind = Kind.ENGINE_ATTRIBUTE; + option.setEngineAttribute(attribute); + return option; + } + + /** Creates a MySQL {@code SECONDARY_ENGINE_ATTRIBUTE} option with a JSON string literal. */ + public static ColumnOption secondaryEngineAttribute(StringValue attribute) { + ColumnOption option = new ColumnOption(); + option.kind = Kind.SECONDARY_ENGINE_ATTRIBUTE; + option.setEngineAttribute(attribute); + return option; + } + + /** Returns the literal of an ENGINE_ATTRIBUTE or SECONDARY_ENGINE_ATTRIBUTE option, or null. */ + public StringValue getEngineAttribute() { + return engineAttribute; + } + + public void setEngineAttribute(StringValue attribute) { + engineAttribute = Objects.requireNonNull(attribute, "attribute"); + } + + /** Whether an engine attribute is written with the optional equals sign. */ + public boolean isUseEquals() { + return useEquals; + } + + public void setUseEquals(boolean useEquals) { + this.useEquals = useEquals; + } + + private boolean isEngineAttribute() { + return kind == Kind.ENGINE_ATTRIBUTE || kind == Kind.SECONDARY_ENGINE_ATTRIBUTE; + } + private Kind kind = Kind.OTHER; private List tokens; private ForeignKeyReference foreignKeyReference; @@ -192,7 +279,7 @@ public void setGeneratedDefinition(GeneratedColumnDefinition definition) { generatedDefinition = Objects.requireNonNull(definition, "definition"); } - /** Visits expressions of the selected option kind, including comment literals. */ + /** Visits expressions of the selected option kind, including comment and attribute literals. */ public void visitExpressions(Consumer visitor) { if (kind == Kind.FOREIGN_OPTIONS) { ForeignDataOption.visitExpressions(foreignOptions, visitor); @@ -200,6 +287,8 @@ public void visitExpressions(Consumer visitor) { visitor.accept(defaultExpression); } else if (kind == Kind.COMMENT) { visitor.accept(comment); + } else if (isEngineAttribute()) { + visitor.accept(engineAttribute); } else if (kind == Kind.ON_UPDATE) { visitor.accept(onUpdateExpression); } else if (kind == Kind.GENERATED) { @@ -207,7 +296,6 @@ public void visitExpressions(Consumer visitor) { } } - /** Creates a DEFAULT option. Use a NullValue expression for SQL NULL. */ public static ColumnOption defaultValue(Expression expression) { ColumnOption option = new ColumnOption(); @@ -321,10 +409,17 @@ public List getTokens() { case GENERATED: return generatedDefinition.getTokens(); case CONSTRAINT: - if ("PRIMARY KEY".equals(constraint.toString())) { - return Collections.unmodifiableList(Arrays.asList("PRIMARY", "KEY")); - } - return Collections.singletonList(toString()); + return constraintTokens(); + case SRID: + return Collections.unmodifiableList(Arrays.asList("SRID", String.valueOf(srid))); + case COLUMN_FORMAT: + return Collections.unmodifiableList( + Arrays.asList("COLUMN_FORMAT", columnFormat.name())); + case ENGINE_ATTRIBUTE: + case SECONDARY_ENGINE_ATTRIBUTE: + return Collections.unmodifiableList(useEquals + ? Arrays.asList(kind.name(), "=", engineAttribute.toString()) + : Arrays.asList(kind.name(), engineAttribute.toString())); case OTHER: case SERIAL_DEFAULT_VALUE: return tokens == null ? null @@ -334,6 +429,14 @@ public List getTokens() { } } + private List constraintTokens() { + String sql = constraint.toString(); + if (KEY_SPELLINGS.contains(sql)) { + return Collections.unmodifiableList(Arrays.asList(sql.split(" "))); + } + return Collections.singletonList(sql); + } + /** * 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 @@ -374,6 +477,17 @@ public void appendTo(StringBuilder builder, Consumer expressionPrint case COMPRESSION: builder.append("COMPRESSION ").append(compression); break; + case SRID: + builder.append("SRID ").append(srid); + break; + case COLUMN_FORMAT: + builder.append("COLUMN_FORMAT ").append(columnFormat); + break; + case ENGINE_ATTRIBUTE: + case SECONDARY_ENGINE_ATTRIBUTE: + builder.append(kind).append(useEquals ? " = " : " "); + expressionPrinter.accept(engineAttribute); + break; case DEFAULT: builder.append("DEFAULT "); expressionPrinter.accept(defaultExpression); diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/AlterValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/AlterValidator.java index f52f9cd85..f23981652 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/AlterValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/AlterValidator.java @@ -12,6 +12,7 @@ import static java.util.stream.Collectors.toList; import java.util.EnumSet; +import java.util.List; import net.sf.jsqlparser.parser.feature.Feature; import net.sf.jsqlparser.statement.alter.Alter; import net.sf.jsqlparser.statement.alter.AlterExpression; @@ -19,6 +20,7 @@ import net.sf.jsqlparser.statement.alter.AlterExpression.ColumnDropNotNull; import net.sf.jsqlparser.statement.alter.AlterExpression.ColumnSetNotNull; import net.sf.jsqlparser.statement.alter.AlterOperation; +import net.sf.jsqlparser.statement.alter.ColumnPosition; import net.sf.jsqlparser.statement.create.table.NamedConstraint; import net.sf.jsqlparser.statement.create.table.KeyColumnSource; import net.sf.jsqlparser.statement.create.table.KeyElement; @@ -71,6 +73,7 @@ public void validate(Alter alter, AlterExpression e) { ValidationUtil.map(e.getColDataTypeList(), ColumnDataType::getColumnName), validateForExist, NamedObject.table); + validateOptionalColumnNames(c, precedingColumnNames(e), NamedObject.table); } validateOptionalName(c, NamedObject.constraint, e.getConstraintName()); @@ -148,4 +151,11 @@ private void validateIndex(ValidationCapability capability, AlterExpression expr } } + /** Returns the existing columns named by AFTER positions. */ + private static List precedingColumnNames(AlterExpression expression) { + return expression.getColDataTypeList().stream().map(ColumnDataType::getPosition) + .filter(position -> position != null + && position.getKind() == ColumnPosition.Kind.AFTER) + .map(ColumnPosition::getColumnName).collect(toList()); + } } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 2210dbe9d..80ea933d2 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -15321,6 +15321,8 @@ ColumnOption ColumnDefinitionOption(): { ObjectNames collationNames; String collation; String compression; + Token engineAttribute; + boolean useEquals = false; } { ( LOOKAHEAD({ Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect)) && isKeywordAhead("OPTIONS") }) @@ -15356,6 +15358,26 @@ ColumnOption ColumnDefinitionOption(): { LOOKAHEAD({ isKeywordAhead("COMPRESSION") }) ContextualKeyword("COMPRESSION") compression=RelObjectName() { option = ColumnOption.compression(compression); } | + // MySQL column attributes. Other values keep the raw fallback, e.g. Percona's COMPRESSED. + LOOKAHEAD({ isKeywordAhead("COLUMN_FORMAT") && (getToken(2).kind == K_DEFAULT + || "FIXED".equalsIgnoreCase(getToken(2).image) + || "DYNAMIC".equalsIgnoreCase(getToken(2).image)) }) + ContextualKeyword("COLUMN_FORMAT") ( tk= | tk= ) + { option = ColumnOption.columnFormat(accessEnum(ColumnOption.ColumnFormat.class, tk.image)); } + | + LOOKAHEAD({ isKeywordAhead("SRID") && getToken(2).kind == S_LONG }) + ContextualKeyword("SRID") tk= { option = ColumnOption.srid(Long.parseLong(tk.image)); } + | + LOOKAHEAD({ isMySqlEngineAttributeAhead() && (getToken(2).kind == S_CHAR_LITERAL + || "=".equals(getToken(2).image) && getToken(3).kind == S_CHAR_LITERAL) }) + engineAttribute= [ "=" { useEquals = true; } ] tk= + { + option = "ENGINE_ATTRIBUTE".equalsIgnoreCase(engineAttribute.image) + ? ColumnOption.engineAttribute(stringValue(tk)) + : ColumnOption.secondaryEngineAttribute(stringValue(tk)); + option.setUseEquals(useEquals); + } + | LOOKAHEAD() ( tk= { collation = tk.image; } | collationNames=RelObjectNames() { collation = String.join(".", collationNames.getNames()); } ) @@ -15367,7 +15389,10 @@ ColumnOption ColumnDefinitionOption(): { LOOKAHEAD( ) defaultExpression=Expression() { option = ColumnOption.onUpdate(defaultExpression); } | - LOOKAHEAD([ RelObjectName() ] ( | )) + // Keep SQL Server's inline FOREIGN KEY prefix together so its KEY is not a primary key. + LOOKAHEAD( ) { option = ColumnOption.raw("FOREIGN", "KEY"); } + | + LOOKAHEAD([ RelObjectName() ] ( | | )) constraint=ColumnKeyConstraint() { option = ColumnOption.constraint(constraint); } | LOOKAHEAD([ RelObjectName() ] ) @@ -15432,7 +15457,10 @@ Expression ColumnGenerationExpression(): { return expression; } } -/** Keeps a column key's name and attributes on the same node in CREATE and ALTER. */ +/** + * Keeps a column key's name and attributes on the same node in CREATE and ALTER, including MySQL's + * UNIQUE KEY and KEY (PRIMARY KEY) spellings. + */ KeyConstraint ColumnKeyConstraint(): { KeyConstraint constraint = new KeyConstraint(); @@ -15444,7 +15472,11 @@ KeyConstraint ColumnKeyConstraint(): ( { constraint.setType("PRIMARY KEY"); } | + // MySQL accepts KEY as a column-level synonym for PRIMARY KEY; keep its spelling. + { constraint.setType("KEY"); constraint.setKind(ConstraintKind.PRIMARY_KEY); } + | { constraint.setType("UNIQUE"); } + [ LOOKAHEAD(1) { constraint.setType("UNIQUE KEY"); } ] [ [ { nullsDistinct = false; } ] { constraint.setNullsDistinct(nullsDistinct == null ? true : nullsDistinct); } ] @@ -18074,6 +18106,7 @@ AlterExpression.ColumnDataType AlterExpressionColumnDataType(): Expression usingExpression = null; List identityAlterations = null; IdentityAlteration identityAlteration; + ColumnPosition position = null; } { columnName = RelObjectName() @@ -18087,10 +18120,14 @@ AlterExpression.ColumnDataType AlterExpressionColumnDataType(): [ LOOKAHEAD({ getToken(1).kind == K_TYPE || getToken(1).kind == K_SET && getToken(2).kind == K_DATA }) useSetData=TypeChangePrefix() { withType = true; } ] ( LOOKAHEAD(2) dataType = ColDataType() )? - ( LOOKAHEAD(2, { !(withType && getToken(1).kind == K_USING) }) option = ColumnDefinitionOption() { + ( LOOKAHEAD(2, { !(withType && getToken(1).kind == K_USING) + && getToken(1).kind != K_FIRST && !isKeywordAhead("AFTER") }) + option = ColumnDefinitionOption() { columnOptions.add(option); } )* [ LOOKAHEAD({ withType && getToken(1).kind == K_USING }) usingExpression=Expression() ] + [ LOOKAHEAD({ getToken(1).kind == K_FIRST || isKeywordAhead("AFTER") }) + position = ColumnPosition() ] ) { requireDdlSyntax(!withType || dataType != null, "TYPE requires a data type"); @@ -18099,10 +18136,27 @@ AlterExpression.ColumnDataType AlterExpressionColumnDataType(): result.setUsingExpression(usingExpression); result.setIdentityAlterations(identityAlterations); result.setColumnOptions(columnOptions); + result.setPosition(position); return result; } } +/** MySQL places an ADD, CHANGE or MODIFY column definition with a trailing FIRST or AFTER col. */ +ColumnPosition ColumnPosition(): +{ + ColumnPosition position; + String columnName; +} +{ + ( + { position = ColumnPosition.first(); } + | + ContextualKeyword("AFTER") columnName = RelObjectName() + { position = ColumnPosition.after(columnName); } + ) + { return position; } +} + /** Type changes in ALTER TABLE and ALTER TYPE accept the same optional SET DATA prefix. */ boolean TypeChangePrefix(): { boolean useSetData = false; } diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 901608d62..ab284e7d3 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -718,6 +718,38 @@ 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. +MySQL column attributes have their own kinds: ``SRID`` (``getSrid()``), +``COLUMN_FORMAT`` (``getColumnFormat()``: FIXED, DYNAMIC or DEFAULT), and +``ENGINE_ATTRIBUTE``/``SECONDARY_ENGINE_ATTRIBUTE`` (``getEngineAttribute()`` +and ``isUseEquals()`` for the optional equals sign). Engine attribute literals +are visited and rendered like comment literals. Inline ``UNIQUE KEY`` is one +``KeyConstraint`` of type ``UNIQUE KEY``. A bare inline ``KEY``, MySQL's column +synonym for PRIMARY KEY, keeps its ``KEY`` spelling and reports +``ConstraintKind.PRIMARY_KEY``. Other values, such as Percona Server's +``COLUMN_FORMAT COMPRESSED``, remain opaque options. + +.. code-block:: java + + CreateTable table = (CreateTable) CCJSqlParserUtil.parse( + "CREATE TABLE t (id INT KEY, g POINT SRID 4326 COLUMN_FORMAT FIXED)"); + List columns = table.getColumnDefinitions(); + columns.get(0).getColumnOptions().get(0).getConstraintKind(); // PRIMARY_KEY + ColumnOption srid = columns.get(1).getColumnOptions().get(0); + srid.getSrid(); // 4326 + srid.setSrid(0); + columns.get(1).addColumnOptions( + ColumnOption.engineAttribute(new StringValue("{}"))); + // CREATE TABLE t (id INT KEY, g POINT SRID 0 COLUMN_FORMAT FIXED ENGINE_ATTRIBUTE '{}') + +Migration: these attributes were previously one opaque option per token, and +``UNIQUE KEY`` was a UNIQUE constraint followed by an opaque ``KEY``. Inspect the +kinds instead of the opaque nodes. ``getColumnSpecs()`` and ``getTokens()`` keep +the previous token split, for example ``[SRID, 4326]`` and ``[UNIQUE, KEY]``. +When constructing the KEY spelling, set the kind after the type: +``new KeyConstraint().withType("KEY").withKind(ConstraintKind.PRIMARY_KEY)``. +``CHAR(n) BINARY`` remains an opaque option: MySQL treats ``BINARY`` as part of +the data type's character set clause, not as a column attribute. + ``ColumnDefinition`` stores all options in one ordered list, including opaque ``ColumnOption.Kind.OTHER`` nodes. ``getColumnOptions()`` returns that mutable list. ``getColumnSpecs()`` returns an **unmodifiable snapshot** for both raw and @@ -762,10 +794,10 @@ snapshot. Raw tokens are never parsed or classified. .. code-block:: java - ColumnOption raw = ColumnOption.raw("COLUMN_FORMAT", "DYNAMIC"); + ColumnOption raw = ColumnOption.raw("SELECTIVITY", "10"); List tokens = new ArrayList<>(raw.getTokens()); - tokens.set(1, "FIXED"); - raw.setTokens(tokens); // COLUMN_FORMAT FIXED + tokens.set(1, "20"); + raw.setTokens(tokens); // SELECTIVITY 20 Migration: raw token getter edits now throw ``UnsupportedOperationException``, as do edits to structured token snapshots that previously changed a discarded @@ -778,6 +810,28 @@ structured option with raw text, replace its node in the column's option list: column.getColumnOptions().set(0, ColumnOption.raw("DEFAULT", "8")); +MySQL column positions +~~~~~~~~~~~~~~~~~~~~~~ + +The trailing ``FIRST`` or ``AFTER column`` of an ALTER TABLE ADD, CHANGE or +MODIFY column definition is a ``ColumnPosition`` on the +``AlterExpression.ColumnDataType``, not a column option. ``getColumnSpecs()`` +still ends with its tokens, and ``setColumnSpecs`` replaces the options and the +position. Migration: ``getColumnOptions()`` no longer contains the two opaque +``AFTER`` and column-name nodes. As in MySQL, the position must follow the +column attributes; a position followed by further attributes is now rejected. + +.. code-block:: java + + Alter alter = (Alter) CCJSqlParserUtil.parse( + "ALTER TABLE t ADD COLUMN c INT NOT NULL AFTER id"); + AlterExpression.ColumnDataType column = + alter.getAlterExpressions().get(0).getColDataTypeList().get(0); + column.getPosition().getColumnName(); // id + column.getColumnSpecs(); // [NOT, NULL, AFTER, id] + column.setPosition(ColumnPosition.first()); + // ALTER TABLE t ADD COLUMN c INT NOT NULL FIRST + Inspect logical replication statements ====================================== diff --git a/src/test/java/net/sf/jsqlparser/statement/alter/MySqlColumnPositionTest.java b/src/test/java/net/sf/jsqlparser/statement/alter/MySqlColumnPositionTest.java new file mode 100644 index 000000000..b9408cb65 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/alter/MySqlColumnPositionTest.java @@ -0,0 +1,171 @@ +/*- + * #%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 net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +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.ColumnOption; +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.ValueSource; + +class MySqlColumnPositionTest { + private static final Dialect[] DIALECTS = {null, Dialect.MYSQL}; + + @ParameterizedTest + @ValueSource(strings = { + "ALTER TABLE t1 ADD COLUMN c1 INT NOT NULL DEFAULT 0 COMMENT 'x' AFTER id", + "ALTER TABLE t1 ADD c2 INT FIRST", + "ALTER TABLE t1 ADD COLUMN c5 INT, ADD COLUMN c6 INT AFTER c5, ADD INDEX idx_c5 (c5), " + + "ALGORITHM = INPLACE, LOCK = NONE", + "ALTER TABLE t1 MODIFY COLUMN name VARCHAR (200) CHARACTER SET utf8mb4 " + + "COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT 'n' AFTER id", + "ALTER TABLE t1 CHANGE name name VARCHAR (10) FIRST", + "ALTER TABLE t1 ADD COLUMN geo POINT NOT NULL SRID 4326 AFTER id", + "ALTER TABLE t1 MODIFY c1 INT FIRST, MODIFY c2 INT AFTER `c1`", + "ALTER TABLE t1 CHANGE COLUMN a b INT UNIQUE KEY AFTER c"}) + void roundTripsWithAndWithoutDialect(String sql) throws JSQLParserException { + for (Dialect dialect : DIALECTS) { + Alter alter = (Alter) assertSqlCanBeParsedAndDeparsed(sql, true, dialect(dialect)); + for (AlterExpression action : alter.getAlterExpressions()) { + if (action.getColDataTypeList() == null) { + continue; + } + for (AlterExpression.ColumnDataType column : action.getColDataTypeList()) { + assertTrue(column.getColumnOptions().stream() + .noneMatch(option -> option.getKind() == ColumnOption.Kind.OTHER), + sql); + } + } + } + } + + @Test + void exposesPositionSeparatelyFromOptions() throws JSQLParserException { + for (Dialect dialect : DIALECTS) { + Alter alter = parse("ALTER TABLE t1 ADD COLUMN c1 INT NOT NULL AFTER `id`, " + + "MODIFY c2 INT FIRST, MODIFY c3 INT", dialect); + AlterExpression.ColumnDataType after = column(alter, 0); + assertEquals(ColumnPosition.Kind.AFTER, after.getPosition().getKind()); + assertEquals("`id`", after.getPosition().getColumnName()); + assertEquals(List.of(ColumnOption.Kind.NULLABILITY), after.getColumnOptions().stream() + .map(ColumnOption::getKind).collect(Collectors.toList())); + assertEquals(List.of("NOT", "NULL", "AFTER", "`id`"), after.getColumnSpecs()); + + AlterExpression.ColumnDataType first = column(alter, 1); + assertEquals(ColumnPosition.first(), first.getPosition()); + assertNull(first.getPosition().getColumnName()); + assertTrue(first.getColumnOptions().isEmpty()); + assertEquals(List.of("FIRST"), first.getColumnSpecs()); + assertNull(column(alter, 2).getPosition()); + assertEquals(List.of(), column(alter, 2).getColumnSpecs()); + } + } + + @Test + void editsRenderAfterOptionsInBothRenderers() throws JSQLParserException { + Alter alter = parse("ALTER TABLE t1 MODIFY c1 INT NOT NULL FIRST", Dialect.MYSQL); + AlterExpression.ColumnDataType column = column(alter, 0); + List snapshot = column.getColumnSpecs(); + assertThrows(UnsupportedOperationException.class, () -> snapshot.add("AFTER")); + + column.setPosition(ColumnPosition.after("c0")); + column.addColumnSpecs("INVISIBLE"); + assertEquals(List.of("NOT", "NULL", "FIRST"), snapshot); + assertEquals(List.of("NOT", "NULL", "INVISIBLE", "AFTER", "c0"), column.getColumnSpecs()); + assertEquals("ALTER TABLE t1 MODIFY c1 INT NOT NULL INVISIBLE AFTER c0", alter.toString()); + assertRoundTrip(alter); + + column.setPosition(null); + assertEquals("ALTER TABLE t1 MODIFY c1 INT NOT NULL INVISIBLE", alter.toString()); + assertRoundTrip(alter); + } + + @Test + void replacingSpecificationsAlsoReplacesThePosition() throws JSQLParserException { + Alter alter = parse("ALTER TABLE t1 ADD COLUMN c1 INT NOT NULL AFTER id", Dialect.MYSQL); + AlterExpression.ColumnDataType column = column(alter, 0); + column.setColumnSpecs(column.getColumnSpecs()); + assertNull(column.getPosition()); + assertEquals(1, column.getColumnOptions().size()); + assertEquals(ColumnOption.Kind.OTHER, column.getColumnOptions().get(0).getKind()); + assertEquals("ALTER TABLE t1 ADD COLUMN c1 INT NOT NULL AFTER id", alter.toString()); + + column.withColumnSpecs(null).withPosition(ColumnPosition.first()); + assertEquals(List.of("FIRST"), column.getColumnSpecs()); + assertEquals("ALTER TABLE t1 ADD COLUMN c1 INT FIRST", alter.toString()); + assertRoundTrip(alter); + } + + @Test + void buildsPositionedDefinitions() throws JSQLParserException { + AlterExpression action = new AlterExpression(); + action.setOperation(AlterOperation.ADD); + action.hasColumn(true); + action.addColDataType(new AlterExpression.ColumnDataType("c1", false, + new ColDataType("INT"), null).withPosition(ColumnPosition.after("id"))); + Alter alter = new Alter().withTable(new Table("t1")).addAlterExpressions(action); + assertEquals("ALTER TABLE t1 ADD COLUMN c1 INT AFTER id", alter.toString()); + assertRoundTrip(alter); + + assertEquals(ColumnPosition.after("id"), ColumnPosition.after("id")); + assertEquals(ColumnPosition.after("id").hashCode(), + ColumnPosition.after("id").hashCode()); + assertNotEquals(ColumnPosition.after("id"), ColumnPosition.first()); + assertThrows(NullPointerException.class, () -> ColumnPosition.after(null)); + assertThrows(UnsupportedOperationException.class, + () -> ColumnPosition.after("id").getTokens().set(0, "BEFORE")); + } + + @ParameterizedTest + @ValueSource(strings = {"ALTER TABLE t1 ADD COLUMN c1 INT AFTER id NOT NULL", + "ALTER TABLE t1 MODIFY c1 INT FIRST AFTER id"}) + void requiresThePositionToEndTheDefinition(String sql) { + for (Dialect dialect : DIALECTS) { + assertThrows(JSQLParserException.class, () -> parse(sql, dialect), sql); + } + } + + private static AlterExpression.ColumnDataType column(Alter alter, int action) { + return alter.getAlterExpressions().get(action).getColDataTypeList().get(0); + } + + private static Consumer dialect(Dialect dialect) { + return parser -> { + if (dialect != null) { + parser.withDialect(dialect); + } + }; + } + + private static Alter parse(String sql, Dialect dialect) throws JSQLParserException { + return (Alter) CCJSqlParserUtil.parse(sql, dialect(dialect)); + } + + private static void assertRoundTrip(Alter alter) throws JSQLParserException { + StringBuilder sql = new StringBuilder(); + alter.accept(new StatementDeParser(sql), null); + assertEquals(alter.toString(), sql.toString()); + for (Dialect dialect : DIALECTS) { + assertEquals(sql.toString(), parse(sql.toString(), dialect).toString()); + } + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/create/ColumnAttributeTest.java b/src/test/java/net/sf/jsqlparser/statement/create/ColumnAttributeTest.java index fc5864f37..761976862 100644 --- a/src/test/java/net/sf/jsqlparser/statement/create/ColumnAttributeTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/create/ColumnAttributeTest.java @@ -121,15 +121,16 @@ void postgresCollationAndCharsetAreSeparateAttributes(String name) throws Except 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)", + + "REFERENCES parent(id) ON UPDATE CASCADE COMMENT 'body' COLUMN_FORMAT FIXED " + + "NOT SECONDARY)", 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) + ColumnOption.Kind.COMMENT, ColumnOption.Kind.COLUMN_FORMAT), + options.subList(0, 6).stream().map(ColumnOption::getKind) .collect(Collectors.toList())); - assertTrue(options.stream().skip(5).allMatch(o -> o.getKind() == ColumnOption.Kind.OTHER)); + assertTrue(options.stream().skip(6).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, 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 afa47da14..de85eca5b 100644 --- a/src/test/java/net/sf/jsqlparser/statement/create/ColumnDefinitionStateTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/create/ColumnDefinitionStateTest.java @@ -39,8 +39,8 @@ class ColumnDefinitionStateTest { @ParameterizedTest - @ValueSource(strings = {"CREATE TABLE t (id INT COLUMN_FORMAT DYNAMIC)", - "ALTER TABLE t ADD COLUMN id INT COLUMN_FORMAT DYNAMIC", + @ValueSource(strings = {"CREATE TABLE t (id INT COLUMN_FORMAT COMPRESSED)", + "ALTER TABLE t ADD COLUMN id INT COLUMN_FORMAT COMPRESSED", "CREATE TABLE t (id INT DEFAULT 1)", "ALTER TABLE t ADD COLUMN id INT DEFAULT 1"}) void specificationSnapshotsAreReadOnlyForRawAndStructuredOptions(String sql) throws JSQLParserException { @@ -55,12 +55,12 @@ void specificationSnapshotsAreReadOnlyForRawAndStructuredOptions(String sql) assertTrue(column.getColumnOptions().stream() .allMatch(raw -> raw.getKind() == ColumnOption.Kind.OTHER)); ColumnOption rawOption = column.getColumnOptions().stream() - .filter(raw -> raw.getTokens().contains("DYNAMIC")) + .filter(raw -> raw.getTokens().contains("COMPRESSED")) .findFirst().orElseThrow(); List rawTokens = new ArrayList<>(rawOption.getTokens()); - rawTokens.set(rawTokens.indexOf("DYNAMIC"), "FIXED"); + rawTokens.set(rawTokens.indexOf("COMPRESSED"), "FIXED"); rawOption.setTokens(rawTokens); - assertEquals(List.of("COLUMN_FORMAT", "DYNAMIC"), snapshot); + assertEquals(List.of("COLUMN_FORMAT", "COMPRESSED"), snapshot); assertEquals(List.of("COLUMN_FORMAT", "FIXED"), column.getColumnSpecs()); } else { option.setDefaultExpression(new LongValue(2)); diff --git a/src/test/java/net/sf/jsqlparser/statement/create/ColumnOptionTokenTest.java b/src/test/java/net/sf/jsqlparser/statement/create/ColumnOptionTokenTest.java index 55c90a8d2..289651d33 100644 --- a/src/test/java/net/sf/jsqlparser/statement/create/ColumnOptionTokenTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/create/ColumnOptionTokenTest.java @@ -42,20 +42,21 @@ import org.junit.jupiter.params.provider.ValueSource; class ColumnOptionTokenTest { + // Percona Server's COLUMN_FORMAT COMPRESSED is not modeled and stays opaque. @ParameterizedTest - @ValueSource(strings = {"CREATE TABLE t (id INT COLUMN_FORMAT DYNAMIC)", - "ALTER TABLE t ADD COLUMN id INT COLUMN_FORMAT DYNAMIC"}) + @ValueSource(strings = {"CREATE TABLE t (id INT COLUMN_FORMAT COMPRESSED)", + "ALTER TABLE t ADD COLUMN id INT COLUMN_FORMAT COMPRESSED"}) void parsedRawOptionsUseExplicitTokenReplacement(String sql) throws JSQLParserException { Statement statement = parse(sql); ColumnOption option = column(statement).getColumnOptions().stream() - .filter(raw -> raw.getTokens().contains("DYNAMIC")).findFirst().orElseThrow(); + .filter(raw -> raw.getTokens().contains("COMPRESSED")).findFirst().orElseThrow(); List snapshot = option.getTokens(); assertReadOnly(snapshot); List replacement = new ArrayList<>(snapshot); - replacement.set(replacement.indexOf("DYNAMIC"), "FIXED"); + replacement.set(replacement.indexOf("COMPRESSED"), "FIXED"); option.setTokens(replacement); replacement.clear(); - assertTrue(snapshot.contains("DYNAMIC")); + assertTrue(snapshot.contains("COMPRESSED")); assertTrue(option.getTokens().contains("FIXED")); assertEquals(ColumnOption.Kind.OTHER, option.getKind()); assertTrue(statement.toString().contains("COLUMN_FORMAT FIXED")); diff --git a/src/test/java/net/sf/jsqlparser/statement/create/MySqlColumnAttributeTest.java b/src/test/java/net/sf/jsqlparser/statement/create/MySqlColumnAttributeTest.java new file mode 100644 index 000000000..b535ff5a5 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/MySqlColumnAttributeTest.java @@ -0,0 +1,277 @@ +/*- + * #%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 net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +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.ColumnOption.ColumnFormat; +import net.sf.jsqlparser.statement.create.table.ConstraintDeclaration; +import net.sf.jsqlparser.statement.create.table.ConstraintKind; +import net.sf.jsqlparser.statement.create.table.CreateTable; +import net.sf.jsqlparser.statement.create.table.KeyConstraint; +import net.sf.jsqlparser.util.TableDefinitionTraversal; +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 MySqlColumnAttributeTest { + private static final Dialect[] DIALECTS = {null, Dialect.MYSQL}; + + @ParameterizedTest + @ValueSource(strings = { + "CREATE TABLE t4 (id INT PRIMARY KEY, g GEOMETRY NOT NULL SRID 4326)", + "CREATE TABLE ty5 (c3 MULTIPOLYGON SRID 0, c4 LINESTRING)", + "CREATE TABLE t1 (id INT PRIMARY KEY AUTO_INCREMENT, g POINT SRID 4326 NOT NULL)", + "CREATE TABLE t18 (a INT COLUMN_FORMAT FIXED STORAGE DISK, b INT COLUMN_FORMAT DYNAMIC, " + + "c INT COLUMN_FORMAT DEFAULT STORAGE MEMORY)", + "CREATE TABLE t19 (a INT ENGINE_ATTRIBUTE '{\"k\":1}' SECONDARY_ENGINE_ATTRIBUTE '{}', " + + "b JSON ENGINE_ATTRIBUTE = '{}' SECONDARY_ENGINE_ATTRIBUTE = '{}')", + "CREATE TABLE t6 (id INT NOT NULL PRIMARY KEY, code VARCHAR (10) UNIQUE KEY, " + + "qty INT CHECK (qty > 0))", + "CREATE TABLE t1 (code VARCHAR (10) UNIQUE)", "CREATE TABLE t1 (id INT KEY)", + "CREATE TABLE t1 (id INT KEY AUTO_INCREMENT, code INT UNIQUE KEY NOT NULL)", + "ALTER TABLE t1 ADD COLUMN geo POINT NOT NULL SRID 4326", + "ALTER TABLE t1 MODIFY COLUMN c INT COLUMN_FORMAT FIXED ENGINE_ATTRIBUTE = '{}'", + "ALTER TABLE t1 ADD COLUMN code INT UNIQUE KEY", + "ALTER TABLE t1 CHANGE COLUMN a id INT KEY"}) + void roundTripsWithAndWithoutDialect(String sql) throws JSQLParserException { + for (Dialect dialect : DIALECTS) { + Statement statement = assertSqlCanBeParsedAndDeparsed(sql, true, dialect(dialect)); + assertTrue(columns(statement).stream().flatMap(MySqlColumnAttributeTest::options) + .noneMatch(option -> option.getKind() == ColumnOption.Kind.OTHER), sql); + } + } + + @Test + void exposesTypedAttributesWithLegacyTokens() throws JSQLParserException { + for (Dialect dialect : DIALECTS) { + CreateTable table = (CreateTable) parse("CREATE TABLE t (" + + "g GEOMETRY NOT NULL SRID 4326, a INT COLUMN_FORMAT FIXED STORAGE DISK, " + + "b INT COLUMN_FORMAT DEFAULT, c JSON ENGINE_ATTRIBUTE '{\"k\":1}' " + + "SECONDARY_ENGINE_ATTRIBUTE = '{}')", dialect); + List columns = table.getColumnDefinitions(); + + ColumnOption srid = columns.get(0).getColumnOptions().get(1); + assertEquals(ColumnOption.Kind.SRID, srid.getKind()); + assertEquals(4326L, srid.getSrid()); + assertEquals(List.of("NOT", "NULL", "SRID", "4326"), columns.get(0).getColumnSpecs()); + + ColumnOption fixed = columns.get(1).getColumnOptions().get(0); + assertEquals(ColumnOption.Kind.COLUMN_FORMAT, fixed.getKind()); + assertEquals(ColumnFormat.FIXED, fixed.getColumnFormat()); + assertEquals(ColumnOption.Kind.STORAGE, + columns.get(1).getColumnOptions().get(1).getKind()); + assertEquals(ColumnFormat.DEFAULT, + columns.get(2).getColumnOptions().get(0).getColumnFormat()); + assertEquals(List.of("COLUMN_FORMAT", "DEFAULT"), columns.get(2).getColumnSpecs()); + + ColumnOption engine = columns.get(3).getColumnOptions().get(0); + ColumnOption secondary = columns.get(3).getColumnOptions().get(1); + assertEquals(ColumnOption.Kind.ENGINE_ATTRIBUTE, engine.getKind()); + assertEquals("{\"k\":1}", engine.getEngineAttribute().getValue()); + assertFalse(engine.isUseEquals()); + assertEquals(ColumnOption.Kind.SECONDARY_ENGINE_ATTRIBUTE, secondary.getKind()); + assertTrue(secondary.isUseEquals()); + assertEquals(List.of("ENGINE_ATTRIBUTE", "'{\"k\":1}'", "SECONDARY_ENGINE_ATTRIBUTE", + "=", "'{}'"), columns.get(3).getColumnSpecs()); + assertEquals(List.of(ConstraintKind.NOT_NULL), table.getConstraints().stream() + .map(ConstraintDeclaration::getKind).collect(Collectors.toList())); + } + } + + @Test + void typedSettersChangeBothRenderersAndLeaveSnapshotsDetached() throws JSQLParserException { + Statement statement = parse("CREATE TABLE t (g POINT SRID 4326, a INT COLUMN_FORMAT FIXED, " + + "c JSON SECONDARY_ENGINE_ATTRIBUTE '{}')", Dialect.MYSQL); + List columns = columns(statement); + ColumnOption srid = columns.get(0).getColumnOptions().get(0); + ColumnOption format = columns.get(1).getColumnOptions().get(0); + ColumnOption attribute = columns.get(2).getColumnOptions().get(0); + List snapshot = attribute.getTokens(); + assertReadOnly(snapshot); + + srid.setSrid(0); + format.setColumnFormat(ColumnFormat.DYNAMIC); + attribute.setEngineAttribute(new StringValue("{\"k\":2}")); + attribute.setUseEquals(true); + + assertEquals(List.of("SECONDARY_ENGINE_ATTRIBUTE", "'{}'"), snapshot); + assertEquals(List.of("SECONDARY_ENGINE_ATTRIBUTE", "=", "'{\"k\":2}'"), + attribute.getTokens()); + assertEquals("CREATE TABLE t (g POINT SRID 0, a INT COLUMN_FORMAT DYNAMIC, " + + "c JSON SECONDARY_ENGINE_ATTRIBUTE = '{\"k\":2}')", statement.toString()); + assertRoundTrip(statement); + assertThrows(IllegalStateException.class, () -> srid.setTokens(List.of("SRID", "1"))); + assertThrows(NullPointerException.class, () -> format.setColumnFormat(null)); + assertThrows(NullPointerException.class, () -> attribute.setEngineAttribute(null)); + } + + @Test + void engineAttributeLiteralsAreVisitedLikeComments() throws JSQLParserException { + Statement statement = parse( + "ALTER TABLE t ADD COLUMN c JSON COMMENT 'c' ENGINE_ATTRIBUTE '{}'", Dialect.MYSQL); + ColumnDefinition column = columns(statement).get(0); + List visited = new ArrayList<>(); + TableDefinitionTraversal.visit(column, visited::add, ignored -> { + }); + assertEquals(2, visited.size()); + assertSame(column.getColumnOptions().get(1).getEngineAttribute(), visited.get(1)); + } + + @ParameterizedTest + @MethodSource("factories") + void factoriesRenderCanonicalSql(ColumnOption option, String sql, List tokens) { + assertEquals(sql, option.toString()); + assertEquals(tokens, option.getTokens()); + assertReadOnly(option.getTokens()); + assertThrows(IllegalStateException.class, () -> option.setTokens(List.of("raw"))); + } + + private static Stream factories() { + ColumnOption withEquals = ColumnOption.engineAttribute(new StringValue("{}")); + withEquals.setUseEquals(true); + return Stream.of( + new Object[] {ColumnOption.srid(4326), "SRID 4326", List.of("SRID", "4326")}, + new Object[] {ColumnOption.columnFormat(ColumnFormat.FIXED), "COLUMN_FORMAT FIXED", + List.of("COLUMN_FORMAT", "FIXED")}, + new Object[] {ColumnOption.engineAttribute(new StringValue("{}")), + "ENGINE_ATTRIBUTE '{}'", List.of("ENGINE_ATTRIBUTE", "'{}'")}, + new Object[] {withEquals, "ENGINE_ATTRIBUTE = '{}'", + List.of("ENGINE_ATTRIBUTE", "=", "'{}'")}, + new Object[] {ColumnOption.secondaryEngineAttribute(new StringValue("{}")), + "SECONDARY_ENGINE_ATTRIBUTE '{}'", + List.of("SECONDARY_ENGINE_ATTRIBUTE", "'{}'")}, + new Object[] { + ColumnOption.constraint(new KeyConstraint().withType("KEY") + .withKind(ConstraintKind.PRIMARY_KEY)), + "KEY", List.of("KEY")}, + new Object[] {ColumnOption.constraint(new KeyConstraint().withType("UNIQUE KEY")), + "UNIQUE KEY", List.of("UNIQUE", "KEY")}); + } + + @Test + void unsupportedColumnFormatsStayOpaque() throws JSQLParserException { + // Percona Server's compressed columns are not modeled. + Statement statement = + parse("CREATE TABLE t (c BLOB COLUMN_FORMAT COMPRESSED)", Dialect.MYSQL); + assertTrue(options(columns(statement).get(0)) + .allMatch(option -> option.getKind() == ColumnOption.Kind.OTHER)); + assertEquals("CREATE TABLE t (c BLOB COLUMN_FORMAT COMPRESSED)", statement.toString()); + } + + @ParameterizedTest + @ValueSource(strings = {"CREATE TABLE t (code VARCHAR(10) UNIQUE KEY NOT NULL)", + "ALTER TABLE t ADD COLUMN code VARCHAR(10) UNIQUE KEY NOT NULL"}) + void uniqueKeyIsOneConstraint(String sql) throws JSQLParserException { + for (Dialect dialect : DIALECTS) { + Statement statement = parse(sql, dialect); + ColumnDefinition column = columns(statement).get(0); + assertEquals(2, column.getColumnOptions().size()); + ColumnOption unique = column.getColumnOptions().get(0); + assertEquals(ColumnOption.Kind.CONSTRAINT, unique.getKind()); + assertEquals(ConstraintKind.UNIQUE, unique.getConstraintKind()); + assertEquals("UNIQUE KEY", unique.getConstraint().getType()); + assertEquals(List.of("UNIQUE", "KEY", "NOT", "NULL"), column.getColumnSpecs()); + assertRoundTrip(statement); + } + } + + @Test + void bareKeyIsAPrimaryKeyThatKeepsItsSpelling() throws JSQLParserException { + for (Dialect dialect : DIALECTS) { + CreateTable table = + (CreateTable) parse("CREATE TABLE t (id INT KEY, name TEXT)", dialect); + ColumnOption key = table.getColumnDefinitions().get(0).getColumnOptions().get(0); + KeyConstraint constraint = assertInstanceOf(KeyConstraint.class, key.getConstraint()); + assertEquals("KEY", constraint.getType()); + assertEquals(ConstraintKind.PRIMARY_KEY, constraint.getKind()); + assertEquals(ConstraintKind.PRIMARY_KEY, key.getConstraintKind()); + assertEquals(List.of("KEY"), key.getTokens()); + ConstraintDeclaration declaration = table.getConstraints().get(0); + assertEquals(ConstraintKind.PRIMARY_KEY, declaration.getKind()); + assertSame(key, declaration.getColumnOption()); + assertEquals("CREATE TABLE t (id INT KEY, name TEXT)", table.toString()); + assertRoundTrip(table); + + constraint.setType("PRIMARY KEY"); + assertEquals(ConstraintKind.PRIMARY_KEY, key.getConstraintKind()); + assertEquals("CREATE TABLE t (id INT PRIMARY KEY, name TEXT)", table.toString()); + } + } + + @Test + void inlineForeignKeyPrefixIsNotAPrimaryKey() throws JSQLParserException { + CreateTable table = (CreateTable) parse( + "CREATE TABLE t (user_id INT FOREIGN KEY REFERENCES ra_user(id))", null); + List options = table.getColumnDefinitions().get(0).getColumnOptions(); + assertEquals(List.of(ColumnOption.Kind.OTHER, ColumnOption.Kind.REFERENCE), + options.stream().map(ColumnOption::getKind).collect(Collectors.toList())); + assertEquals(List.of("FOREIGN", "KEY"), options.get(0).getTokens()); + assertEquals(List.of(ConstraintKind.FOREIGN_KEY), table.getConstraints().stream() + .map(ConstraintDeclaration::getKind).collect(Collectors.toList())); + assertRoundTrip(table); + } + + private static Consumer dialect(Dialect dialect) { + return parser -> { + if (dialect != null) { + parser.withDialect(dialect); + } + }; + } + + private static Statement parse(String sql, Dialect dialect) throws JSQLParserException { + return CCJSqlParserUtil.parse(sql, dialect(dialect)); + } + + private static List columns(Statement statement) { + if (statement instanceof CreateTable) { + return ((CreateTable) statement).getColumnDefinitions(); + } + return new ArrayList<>( + ((Alter) statement).getAlterExpressions().get(0).getColDataTypeList()); + } + + private static Stream options(ColumnDefinition column) { + return column.getColumnOptions() == null ? Stream.empty() + : column.getColumnOptions().stream(); + } + + private static void assertReadOnly(List tokens) { + assertThrows(UnsupportedOperationException.class, () -> tokens.set(0, "changed")); + assertThrows(UnsupportedOperationException.class, () -> tokens.add("changed")); + } + + 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(), Dialect.MYSQL).toString()); + assertEquals(sql.toString(), parse(sql.toString(), null).toString()); + } +} diff --git a/src/test/java/net/sf/jsqlparser/util/validation/validator/AlterValidatorTest.java b/src/test/java/net/sf/jsqlparser/util/validation/validator/AlterValidatorTest.java index 1dae3bf75..dd3201896 100644 --- a/src/test/java/net/sf/jsqlparser/util/validation/validator/AlterValidatorTest.java +++ b/src/test/java/net/sf/jsqlparser/util/validation/validator/AlterValidatorTest.java @@ -100,6 +100,28 @@ void validatesForeignKeysFromTheirCurrentStructuredDefinition() throws JSQLParse new Named(NamedObject.column, "new_parent_id")), names); } + @Test + void validatesTheColumnNamedByAnAfterPosition() { + List names = new ArrayList<>(); + DatabaseMetaDataValidation metadata = name -> { + names.add(name); + return !"c1".equals(name.getFqn()); + }; + List errors = new Validation(List.of(metadata), + "ALTER TABLE t ADD COLUMN c1 INT AFTER id, MODIFY c2 INT FIRST").validate(); + assertTrue(errors.isEmpty(), errors.toString()); + assertEquals(List.of(new Named(NamedObject.table, "t"), + new Named(NamedObject.column, "c1"), new Named(NamedObject.column, "id"), + new Named(NamedObject.column, "c2")), names); + + DatabaseMetaDataValidation missing = name -> !"id".equals(name.getFqn()) + && !"c1".equals(name.getFqn()); + errors = new Validation(List.of(missing), "ALTER TABLE t ADD COLUMN c1 INT AFTER id") + .validate(); + assertEquals(1, errors.size()); + assertTrue(errors.toString().contains("id does not exist"), errors.toString()); + } + @Test void validatesRenamedConstraintTargetAsANewConstraint() { List names = new ArrayList<>();