From d54a0e6176c557609eb7065b51842a69958b50ae Mon Sep 17 00:00:00 2001 From: minleejae Date: Fri, 25 Sep 2026 22:45:00 +0900 Subject: [PATCH] fix: structure PostgreSQL column type and generated expression replacements Signed-off-by: minleejae --- .../statement/alter/AlterExpression.java | 13 +- .../statement/alter/RelationAlterAction.java | 20 ++- .../table/GeneratedColumnDefinition.java | 12 +- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 40 +++++- .../PostgreSqlColumnReplacementTest.java | 123 ++++++++++++++++++ 5 files changed, 199 insertions(+), 9 deletions(-) create mode 100644 src/test/java/net/sf/jsqlparser/statement/alter/PostgreSqlColumnReplacementTest.java 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 0f62330c2..aa0fba53b 100644 --- a/src/main/java/net/sf/jsqlparser/statement/alter/AlterExpression.java +++ b/src/main/java/net/sf/jsqlparser/statement/alter/AlterExpression.java @@ -1536,7 +1536,17 @@ public void setHasEqualForCollate(boolean hasEqualForCollate) { public static final class ColumnDataType extends ColumnDefinition { private final boolean withType; + private boolean useSetData; private Expression usingExpression; + + public boolean isUseSetData() { + return useSetData; + } + + public void setUseSetData(boolean useSetData) { + this.useSetData = useSetData; + } + private List identityAlterations; public boolean isWithType() { @@ -1587,7 +1597,8 @@ public void appendTo(StringBuilder builder, Consumer expressionPrint .append(PlainSelect.getStringList(identityAlterations, false, false)); return; } - builder.append(withType ? " TYPE " : getColDataType() == null ? "" : " "); + builder.append(withType ? (useSetData ? " SET DATA TYPE " : " TYPE ") + : getColDataType() == null ? "" : " "); appendDataTypeAndSpecTo(builder, expressionPrinter); if (usingExpression != null) { builder.append(" USING "); diff --git a/src/main/java/net/sf/jsqlparser/statement/alter/RelationAlterAction.java b/src/main/java/net/sf/jsqlparser/statement/alter/RelationAlterAction.java index a8584c828..ef84738b1 100644 --- a/src/main/java/net/sf/jsqlparser/statement/alter/RelationAlterAction.java +++ b/src/main/java/net/sf/jsqlparser/statement/alter/RelationAlterAction.java @@ -29,6 +29,16 @@ public class RelationAlterAction extends AlterExpression { private Long statistics; private boolean statisticsDefault; private Expression defaultExpression; + private Expression generationExpression; + + public Expression getGenerationExpression() { + return generationExpression; + } + + public void setGenerationExpression(Expression expression) { + generationExpression = expression; + } + private Table relation; private boolean noInherit; private boolean noDependency; @@ -40,7 +50,7 @@ public enum Kind { } public enum ColumnAction { - SET_DEFAULT, DROP_DEFAULT, SET_STATISTICS, SET_STORAGE, SET_COMPRESSION, DROP_EXPRESSION, SET_OPTIONS, RESET_OPTIONS + SET_DEFAULT, DROP_DEFAULT, SET_STATISTICS, SET_STORAGE, SET_COMPRESSION, SET_EXPRESSION, DROP_EXPRESSION, SET_OPTIONS, RESET_OPTIONS } public enum ReplicaIdentity { @@ -311,6 +321,12 @@ private void appendColumnChange(StringBuilder builder, Consumer expr case SET_COMPRESSION: builder.append(" SET COMPRESSION ").append(value); break; + case SET_EXPRESSION: + builder.append(" SET EXPRESSION "); + net.sf.jsqlparser.statement.create.table.GeneratedColumnDefinition + .appendExpressionTo( + builder, generationExpression, expressionPrinter); + break; case DROP_EXPRESSION: builder.append(" DROP EXPRESSION"); if (isUsingIfExists()) { @@ -325,6 +341,8 @@ private void appendColumnChange(StringBuilder builder, Consumer expr public void visitExpressions(Consumer visitor) { if (kind == Kind.ALTER_COLUMN && columnAction == ColumnAction.SET_DEFAULT) { visitor.accept(defaultExpression); + } else if (kind == Kind.ALTER_COLUMN && columnAction == ColumnAction.SET_EXPRESSION) { + visitor.accept(generationExpression); } else if ((kind == Kind.SET_OPTIONS || kind == Kind.ALTER_COLUMN && columnAction == ColumnAction.SET_OPTIONS) && options != null) { options.stream().map(Index.Option::getValue).filter(java.util.Objects::nonNull) 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 cfc6bce5a..448ddc5b4 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 @@ -73,15 +73,21 @@ public StringBuilder appendTo(StringBuilder builder, Consumer expres if (generatedAlways) { builder.append("GENERATED ALWAYS "); } - builder.append("AS ("); - expressionPrinter.accept(expression); - builder.append(')'); + appendExpressionTo(builder, expression, expressionPrinter); if (storage != null) { builder.append(' ').append(storage); } return builder; } + /** Prints the common AS (expression) body of declarations and expression replacements. */ + public static StringBuilder appendExpressionTo(StringBuilder builder, Expression expression, + Consumer expressionPrinter) { + builder.append("AS ("); + expressionPrinter.accept(expression); + return builder.append(')'); + } + @Override public String toString() { StringBuilder builder = new StringBuilder(); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 4b505dab0..fae29aa84 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1821,8 +1821,13 @@ public class CCJSqlParser extends AbstractJSqlParser { } private boolean isPostgreSqlRelationActionAhead() { - if (!Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect))) { return false; } Token first = getToken(1); + int columnActionOffset = getToken(2).kind == K_COLUMN ? 4 : 3; + if (first.kind == K_ALTER && getToken(columnActionOffset).kind == K_SET + && "EXPRESSION".equalsIgnoreCase(getToken(columnActionOffset + 1).image)) { + return true; + } + if (!Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect))) { return false; } if (first.kind == K_VALIDATE || first.kind == K_RESET || isKeywordAhead("INHERIT") || isKeywordAhead("REPLICA") || isKeywordAhead("OWNER") || isKeywordAhead("CLUSTER") || first.kind == K_OF || first.kind == K_NOT && getToken(2).kind == K_OF @@ -14343,7 +14348,7 @@ GeneratedColumnDefinition GeneratedColumnDefinition(): { [ LOOKAHEAD({ isKeywordAhead("GENERATED") }) ContextualKeyword("GENERATED") { generatedAlways = true; } ] - "(" expression=Expression() ")" + expression=ColumnGenerationExpression() [ LOOKAHEAD({ getToken(1).kind == K_STORED || isKeywordAhead("VIRTUAL") }) ( { storage = GeneratedColumnDefinition.Storage.STORED; } | ContextualKeyword("VIRTUAL") { storage = GeneratedColumnDefinition.Storage.VIRTUAL; } ) ] @@ -14355,6 +14360,14 @@ GeneratedColumnDefinition GeneratedColumnDefinition(): } } +/** Shared expression body for generated declarations and ALTER SET EXPRESSION. */ +Expression ColumnGenerationExpression(): +{ Expression expression; } +{ + "(" expression=Expression() ")" + { return expression; } +} + /** Keeps a column key's name and attributes on the same node in CREATE and ALTER. */ NamedConstraint ColumnKeyConstraint(): { @@ -16909,6 +16922,7 @@ AlterExpression.ColumnDataType AlterExpressionColumnDataType(): { String columnName = null; boolean withType = false; + boolean useSetData = false; ColDataType dataType = null; List columnSpecs = new ArrayList(); List columnOptions = new ArrayList(); @@ -16927,7 +16941,8 @@ AlterExpression.ColumnDataType AlterExpressionColumnDataType(): ( LOOKAHEAD({ isIdentityAlterationAhead() }) identityAlteration=ColumnIdentityAlteration() { identityAlterations.add(identityAlteration); } )* | - ( LOOKAHEAD(2) { withType = true; } )? + [ 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() { columnOptions.add(option); @@ -16936,7 +16951,9 @@ AlterExpression.ColumnDataType AlterExpressionColumnDataType(): [ LOOKAHEAD({ withType && getToken(1).kind == K_USING }) usingExpression=Expression() ] ) { + requireDdlSyntax(!withType || dataType != null, "TYPE requires a data type"); result = new AlterExpression.ColumnDataType(columnName, withType, dataType, columnSpecs); + result.setUseSetData(useSetData); result.setUsingExpression(usingExpression); result.setIdentityAlterations(identityAlterations); if (hasStructuredColumnOption(columnOptions)) { @@ -16946,6 +16963,14 @@ AlterExpression.ColumnDataType AlterExpressionColumnDataType(): } } +/** Type changes in ALTER TABLE and ALTER TYPE accept the same optional SET DATA prefix. */ +boolean TypeChangePrefix(): +{ boolean useSetData = false; } +{ + [ { useSetData = true; } ] + { return useSetData; } +} + IdentityAlteration ColumnIdentityAlteration(): { IdentityAlteration alteration; @@ -18651,6 +18676,12 @@ RelationAlterAction PostgreSqlRelationAction(): action.setDefaultExpression(expression); } | + LOOKAHEAD({ isKeywordAhead("EXPRESSION") }) ContextualKeyword("EXPRESSION") + expression=ColumnGenerationExpression() { + action.setColumnAction(RelationAlterAction.ColumnAction.SET_EXPRESSION); + action.setGenerationExpression(expression); + } + | LOOKAHEAD({ isKeywordAhead("STATISTICS") }) ContextualKeyword("STATISTICS") { action.setColumnAction(RelationAlterAction.ColumnAction.SET_STATISTICS); } ( { action.setStatisticsDefault(true); } @@ -19960,6 +19991,7 @@ AlterType.AttributeChange TypeAttributeChange(): AlterType.AttributeChange result = new AlterType.AttributeChange(); TypeAttribute attribute = new TypeAttribute(); String name; ColDataType type; AlterType.Behavior behavior; + boolean useSetData; } { ( @@ -19968,7 +20000,7 @@ AlterType.AttributeChange TypeAttributeChange(): | TypeDdlKeyword("ATTRIBUTE") [ LOOKAHEAD(2) { result.setIfExists(true); } ] name=RelObjectName() { attribute.setName(name); result.setKind(AlterType.AttributeChange.Kind.DROP); } | TypeDdlKeyword("ATTRIBUTE") name=RelObjectName() { attribute.setName(name); } - [ { result.setUseSetData(true); } ] + useSetData=TypeChangePrefix() { result.setUseSetData(useSetData); } type=ColDataType() { attribute.setDataType(type); result.setKind(AlterType.AttributeChange.Kind.ALTER); } [ name=TypeDdlName() { attribute.setCollation(name); } ] ) diff --git a/src/test/java/net/sf/jsqlparser/statement/alter/PostgreSqlColumnReplacementTest.java b/src/test/java/net/sf/jsqlparser/statement/alter/PostgreSqlColumnReplacementTest.java new file mode 100644 index 000000000..efee37101 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/alter/PostgreSqlColumnReplacementTest.java @@ -0,0 +1,123 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.alter; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.List; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.util.TableDefinitionTraversal; +import net.sf.jsqlparser.util.deparser.AlterDeParser; +import net.sf.jsqlparser.util.deparser.ExpressionDeParser; +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 PostgreSqlColumnReplacementTest { + @ParameterizedTest + @ValueSource(strings = {"BIGINT", "BIGINT USING id::bigint", + "VARCHAR(20) COLLATE \"C\" USING id::text"}) + void sharesTypeChangePrefixes(String type) throws JSQLParserException { + for (String column : new String[] {"COLUMN ", ""}) { + for (String prefix : new String[] {"TYPE ", "SET DATA TYPE "}) { + String sql = "ALTER TABLE t ALTER " + column + "id " + prefix + type; + Alter table = (Alter) parse(sql); + AlterExpression.ColumnDataType definition = table.getAlterExpressions().get(0) + .getColDataTypeList().get(0); + assertTrue(definition.isWithType()); + assertEquals(prefix.startsWith("SET"), definition.isUseSetData()); + assertNotEquals("SET", definition.getColDataType().getDataType()); + if (type.contains("USING")) { + assertNotNull(definition.getUsingExpression()); + } + roundTrip(table); + assertEquals(2, CCJSqlParserUtil.parseStatements(sql + "; SELECT 1").size()); + definition.setUseSetData(!definition.isUseSetData()); + roundTrip(table); + } + } + for (String prefix : new String[] {"TYPE ", "SET DATA TYPE "}) { + roundTrip(parse("ALTER TYPE row_type ALTER ATTRIBUTE id " + prefix + "BIGINT")); + } + } + + @ParameterizedTest + @ValueSource(strings = {"id + 2", "COALESCE(id, 0) * 2", "CASE WHEN id > 0 THEN id ELSE 0 END"}) + void generatedExpressionIsAnActionAndNotADataType(String expression) + throws JSQLParserException { + for (String column : new String[] {"COLUMN ", ""}) { + String sql = "ALTER TABLE t ALTER " + column + "g SET EXPRESSION AS (" + expression + + "), ADD COLUMN extra INT"; + for (Statement statement : List.of(parse(sql), CCJSqlParserUtil.parse(sql))) { + Alter table = (Alter) statement; + RelationAlterAction action = assertInstanceOf(RelationAlterAction.class, + table.getAlterExpressions().get(0)); + assertEquals(RelationAlterAction.ColumnAction.SET_EXPRESSION, + action.getColumnAction()); + assertEquals("g", action.getColumnName()); + assertNull(action.getColDataTypeList()); + assertNotNull(action.getGenerationExpression()); + List expressions = new ArrayList<>(); + TableDefinitionTraversal.visit(action, expressions::add, ignored -> { + }); + assertEquals(List.of(action.getGenerationExpression()), expressions); + action.setGenerationExpression(new LongValue(42)); + assertTrue(table.toString().contains("SET EXPRESSION AS (42)")); + roundTrip(table); + } + assertEquals(2, CCJSqlParserUtil.parseStatements(sql + "; SELECT 1").size()); + } + } + + @Test + void replacementUsesTheExpressionDeparser() throws JSQLParserException { + Alter table = (Alter) parse("ALTER TABLE t ALTER COLUMN g SET EXPRESSION AS (1)"); + StringBuilder output = new StringBuilder(); + ExpressionDeParser expressions = new ExpressionDeParser() { + @Override + public StringBuilder visit(LongValue value, S context) { + return getBuilder().append(2); + } + }; + expressions.setBuilder(output); + new AlterDeParser(output, expressions).deParse(table); + assertEquals("ALTER TABLE t ALTER COLUMN g SET EXPRESSION AS (2)", output.toString()); + } + + @ParameterizedTest + @ValueSource(strings = {"ALTER TABLE t ALTER COLUMN id SET DATA TYPE", + "ALTER TABLE t ALTER COLUMN id SET DATA BIGINT", + "ALTER TABLE t ALTER COLUMN g SET EXPRESSION AS ()", + "ALTER TABLE t ALTER COLUMN g SET EXPRESSION AS id + 2", + "ALTER TABLE t ALTER COLUMN g SET EXPRESSION AS (id+2) STORED", + "ALTER VIEW v ALTER COLUMN g SET EXPRESSION AS (id+2)", + "ALTER INDEX ix ALTER COLUMN 1 SET EXPRESSION AS (id+2)"}) + void rejectsIncompleteOrWrongContextActions(String sql) { + assertThrows(JSQLParserException.class, () -> parse(sql)); + } + + private static Statement parse(String sql) throws JSQLParserException { + return CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(Dialect.POSTGRESQL)); + } + + private static void roundTrip(Statement statement) throws JSQLParserException { + StringBuilder output = new StringBuilder(); + statement.accept(new StatementDeParser(output)); + assertEquals(statement.toString(), output.toString()); + assertEquals(output.toString(), parse(output.toString()).toString()); + } +}