From 9f4da8e41d9b3376b7466ccaeb4b8cde0d93aed8 Mon Sep 17 00:00:00 2001 From: minleejae Date: Fri, 25 Sep 2026 16:31:46 +0900 Subject: [PATCH] fix: share PostgreSQL column key and storage parameter parsing Signed-off-by: minleejae --- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 36 ++++-- .../PostgreSqlColumnIndexOptionsTest.java | 114 ++++++++++++++++++ 2 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlColumnIndexOptionsTest.java diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 2c2608ae8..b7270cc84 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -13852,7 +13852,7 @@ Index.ColumnParams IndexColumnWithParams(): { && getToken(1).kind != K_NULLS && getToken(1).kind != K_COLLATE && getToken(1).kind != K_WITH && getToken(1).kind != K_WITHOUT) }) operatorClass=IndexKeyAttributeName() - [ LOOKAHEAD(2) operatorClassParameters=PostgreSqlIndexOptions() ] + [ LOOKAHEAD(2) operatorClassParameters=PostgreSqlOptions(false) ] { if (!postgres) { columnParams.add(operatorClass); @@ -13906,15 +13906,23 @@ String IndexKeyAttributeName(): { return name; } } -Index.Option PostgreSqlIndexOption(): +Index.Option PostgreSqlIndexOption(boolean allowBare): { String name = null; Expression value = null; boolean useEquals = false; } { - name=PostgreSqlStorageParameterName() [ "=" { useEquals = true; } ] value=Expression() - { return new Index.Option(name, value, useEquals); } + name=PostgreSqlStorageParameterName() + [ [ "=" { useEquals = true; } ] value=Expression() ] + { + requireDdlSyntax(allowBare || value != null, + "Operator-class parameters require a value"); + requireDdlSyntax(value == null || useEquals + || !Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect)), + "PostgreSQL parameter values require an equals sign"); + return new Index.Option(name, value, useEquals); + } } /** Storage parameters can have namespaces, such as toast.autovacuum_enabled. */ @@ -13926,14 +13934,19 @@ String PostgreSqlStorageParameterName(): { return name; } } +/** Storage parameters allow a bare name; operator-class parameters require a value. */ List PostgreSqlIndexOptions(): +{ List options; } +{ options=PostgreSqlOptions(true) { return options; } } + +List PostgreSqlOptions(boolean allowBare): { List options = new ArrayList(); Index.Option option = null; } { - "(" option=PostgreSqlIndexOption() { options.add(option); } - ( "," option=PostgreSqlIndexOption() { options.add(option); } )* ")" + "(" option=PostgreSqlIndexOption(allowBare) { options.add(option); } + ( "," option=PostgreSqlIndexOption(allowBare) { options.add(option); } )* ")" { return options; } } @@ -14277,6 +14290,7 @@ NamedConstraint ColumnKeyConstraint(): constraint.setNullsDistinct(nullsDistinct == null ? true : nullsDistinct); } ] ) + PostgreSqlIndexStorageOptions(constraint) PostgreSqlConstraintAttributes(constraint) { requireDdlSyntax(constraint.getConstraintAttributes() == null @@ -14933,11 +14947,19 @@ void TableIndexOptions(boolean createContext, List options): void PostgreSqlConstraintOptions(Index index): { List columns; +} +{ + [ columns=ColumnsNamesList() { index.setIncludeColumns(columns); } ] + PostgreSqlIndexStorageOptions(index) +} + +/** Column keys share storage/tablespace options, but do not allow INCLUDE. */ +void PostgreSqlIndexStorageOptions(Index index): +{ List parameters; String tableSpace; } { - [ columns=ColumnsNamesList() { index.setIncludeColumns(columns); } ] [ LOOKAHEAD(2) parameters=PostgreSqlIndexOptions() { index.setStorageParameters(parameters); } ] [ LOOKAHEAD(3) tableSpace=RelObjectName() { index.setTableSpace(tableSpace); diff --git a/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlColumnIndexOptionsTest.java b/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlColumnIndexOptionsTest.java new file mode 100644 index 000000000..d3911f551 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlColumnIndexOptionsTest.java @@ -0,0 +1,114 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.create; + +import static org.junit.jupiter.api.Assertions.*; +import java.util.List; +import net.sf.jsqlparser.JSQLParserException; +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.statement.alter.Alter; +import net.sf.jsqlparser.statement.create.index.CreateIndex; +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.Index; +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 PostgreSqlColumnIndexOptionsTest { + @ParameterizedTest + @ValueSource(strings = {"PRIMARY KEY", "UNIQUE", "UNIQUE NULLS NOT DISTINCT"}) + void columnStorageAndTablespaceStayOnTheConstraint(String kind) throws JSQLParserException { + for (boolean alter : new boolean[] {false, true}) { + String prefix = alter ? "ALTER TABLE t ADD COLUMN " : "CREATE TABLE t ("; + Statement statement = parse(prefix + "id INT CONSTRAINT uq " + kind + + " WITH (fillfactor=70) USING INDEX TABLESPACE old_space DEFERRABLE INITIALLY DEFERRED" + + (alter ? "" : ")")); + ColumnDefinition column = alter + ? ((Alter) statement).getAlterExpressions().get(0).getColDataTypeList().get(0) + : ((CreateTable) statement).getColumnDefinitions().get(0); + assertEquals(1, column.getColumnOptions().size()); + ColumnOption option = column.getColumnOptions().get(0); + assertEquals(ColumnOption.Kind.CONSTRAINT, option.getKind()); + Index key = option.getConstraint(); + assertEquals("uq", key.getName()); + assertEquals("old_space", key.getTableSpace()); + assertEquals("70", key.getStorageParameters().get(0).getValue().toString()); + assertNotNull(key.getConstraintAttributes()); + key.setStorageParameters( + List.of(new Index.Option("fillfactor", new LongValue(80), true))); + key.setTableSpace("new_space"); + String sql = statement.toString(); + assertTrue(sql.contains("WITH (fillfactor = 80) USING INDEX TABLESPACE new_space")); + assertFalse(sql.contains("70")); + assertFalse(sql.contains("old_space")); + assertRoundTrip(statement); + } + } + + @ParameterizedTest + @ValueSource(strings = { + "CREATE TABLE t (id INT) WITH (autovacuum_enabled,toast.autovacuum_enabled=false)", + "CREATE TABLE t (id INT PRIMARY KEY WITH (deduplicate_items))", + "CREATE TABLE t (id INT, CONSTRAINT uq UNIQUE(id) WITH (deduplicate_items))", + "ALTER TABLE t ADD CONSTRAINT uq UNIQUE(id) WITH (deduplicate_items)", + "CREATE INDEX ix ON t(id) WITH (deduplicate_items)", + "CREATE INDEX ix ON t USING gin(tags) WITH (fastupdate)", + "CREATE MATERIALIZED VIEW mv WITH (autovacuum_enabled) AS SELECT * FROM t"}) + void bareStorageOptionsRoundTrip(String sql) throws JSQLParserException { + assertRoundTrip(parse(sql)); + assertEquals(2, CCJSqlParserUtil.parseStatements(sql + "; SELECT 1", + p -> p.withDialect(Dialect.POSTGRESQL)).size()); + } + + @Test + void bareOptionsCanBeAssignedAndCleared() throws JSQLParserException { + CreateIndex index = (CreateIndex) parse("CREATE INDEX ix ON t(id) WITH(deduplicate_items)"); + Index.Option option = index.getStorageParameters().get(0); + assertNull(option.getValue()); + assertFalse(option.isUseEquals()); + option.setName("fillfactor"); + option.setValue(new LongValue(80)); + option.setUseEquals(true); + assertTrue(index.toString().contains("WITH (fillfactor = 80)")); + assertRoundTrip(index); + option.setName("deduplicate_items"); + option.setValue(null); + assertTrue(index.toString().contains("WITH (deduplicate_items)")); + assertRoundTrip(index); + } + + @Test + void columnIncludeAndIncompleteParametersAreRejected() { + for (String sql : new String[] { + "CREATE TABLE t(id INT UNIQUE INCLUDE(label),label TEXT)", + "CREATE TABLE t(id INT) WITH (fillfactor=)", + "CREATE TABLE t(id INT) WITH (fillfactor 70)", + "CREATE INDEX ix ON t(id int4_ops(deduplicate_items))"}) { + assertThrows(JSQLParserException.class, () -> parse(sql), sql); + } + } + + private static Statement parse(String sql) throws JSQLParserException { + return CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.POSTGRESQL)); + } + + private static void assertRoundTrip(Statement statement) throws JSQLParserException { + StringBuilder sql = new StringBuilder(); + statement.accept(new StatementDeParser(sql), null); + assertEquals(statement.toString(), sql.toString()); + assertEquals(statement.toString(), parse(sql.toString()).toString()); + } +}