From d4b28450c42951f8242c35772d265e6381a77af3 Mon Sep 17 00:00:00 2001 From: minleejae Date: Tue, 22 Sep 2026 16:27:25 +0900 Subject: [PATCH] feat: attach PostgreSQL constraints to existing indexes Signed-off-by: minleejae --- .../create/table/ConstraintUsingIndex.java | 63 ++++++++++ .../validation/validator/AlterValidator.java | 8 +- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 18 +++ src/site/sphinx/usage.rst | 23 ++++ .../PostgreSqlConstraintUsingIndexTest.java | 111 ++++++++++++++++++ 5 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 src/main/java/net/sf/jsqlparser/statement/create/table/ConstraintUsingIndex.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/alter/PostgreSqlConstraintUsingIndexTest.java diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/ConstraintUsingIndex.java b/src/main/java/net/sf/jsqlparser/statement/create/table/ConstraintUsingIndex.java new file mode 100644 index 000000000..f7f1798b8 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/ConstraintUsingIndex.java @@ -0,0 +1,63 @@ +/*- + * #%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.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; + +/** + * A PostgreSQL UNIQUE or PRIMARY KEY constraint backed by an existing index. The referenced index + * is distinct from the constraint name and from a newly declared index. + */ +public class ConstraintUsingIndex extends NamedConstraint { + private String existingIndexName; + + public ConstraintUsingIndex() { + setType("UNIQUE"); + } + + /** Returns the referenced index identifier, retaining SQL quotes. */ + public String getExistingIndexName() { + return existingIndexName; + } + + public void setExistingIndexName(String existingIndexName) { + this.existingIndexName = existingIndexName; + } + + public ConstraintUsingIndex withExistingIndexName(String existingIndexName) { + setExistingIndexName(existingIndexName); + return this; + } + + @Override + public ConstraintUsingIndex withName(String name) { + super.withName(name); + return this; + } + + @Override + public ConstraintUsingIndex withType(String type) { + super.withType(type); + return this; + } + + public ConstraintUsingIndex withConstraintAttributes(ConstraintAttributes attributes) { + setConstraintAttributes(attributes); + return this; + } + + @Override + public void appendTo(StringBuilder sql, Consumer expressionPrinter) { + appendConstraintPrefixTo(sql); + sql.append(getType()).append(" USING INDEX ").append(existingIndexName); + appendConstraintAttributesTo(sql); + } +} 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 7702630e0..f1b4ccd54 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 @@ -20,6 +20,7 @@ import net.sf.jsqlparser.statement.alter.AlterExpression.ColumnSetNotNull; import net.sf.jsqlparser.statement.alter.AlterOperation; import net.sf.jsqlparser.statement.create.table.DefaultConstraint; +import net.sf.jsqlparser.statement.create.table.ConstraintUsingIndex; import net.sf.jsqlparser.util.TableDefinitionTraversal; import net.sf.jsqlparser.util.validation.ValidationCapability; import net.sf.jsqlparser.util.validation.ValidationUtil; @@ -82,7 +83,12 @@ public void validate(Alter alter, AlterExpression e) { validateOptionalColumnNames(c, e.getUkColumns(), NamedObject.uniqueConstraint); } - if (e.getIndex() instanceof DefaultConstraint) { + if (e.getIndex() instanceof ConstraintUsingIndex) { + ConstraintUsingIndex constraint = (ConstraintUsingIndex) e.getIndex(); + validateOptionalName(c, NamedObject.constraint, constraint.getName(), null, false, + NamedObject.table); + validateName(c, NamedObject.index, constraint.getExistingIndexName()); + } else if (e.getIndex() instanceof DefaultConstraint) { validateOptionalName(c, NamedObject.constraint, e.getIndex().getName(), null, false, NamedObject.table); } else if (e.getIndex() != null) { diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 1411612de..3b75019af 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -14845,6 +14845,18 @@ Index CreateTableConstraint(): { Index index; } { index=TableConstraint(true) { return index; } } +/** Attaches an existing PostgreSQL index without declaring new index columns. */ +ConstraintUsingIndex PostgreSqlConstraintUsingIndex(): +{ ConstraintUsingIndex constraint = new ConstraintUsingIndex(); String name; } +{ + [ name=RelObjectName() { constraint.setName(name); } ] + ( { constraint.setType("UNIQUE"); } + | { constraint.setType("PRIMARY KEY"); } ) + name=RelObjectName() { constraint.setExistingIndexName(name); } + PostgreSqlConstraintAttributes(constraint) + { return constraint; } +} + /** Shared table-constraint body; index option boundaries depend on CREATE versus ALTER. */ Index TableConstraint(boolean createContext): { @@ -17248,6 +17260,12 @@ AlterExpression AlterExpressionAddAlterModify(): alterExp.setIndex(index); } | + LOOKAHEAD([ RelObjectName() ] + ( | ) , + { alterExp.getOperation() == AlterOperation.ADD + && Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect)) }) + index=PostgreSqlConstraintUsingIndex() { alterExp.setIndex(index); } + | LOOKAHEAD({ alterExp.getOperation() == AlterOperation.ADD && (Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect)) || Dialect.MYSQL.name().equals(getAsString(Feature.dialect))) diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 6e4c96feb..18d5a8e86 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -1300,3 +1300,26 @@ This API identifies expression metadata rather than performing database validation. Precision reports the requested value, without applying defaults, server range checks or clamping. For example, PostgreSQL accepts precision 7 with a warning and clamps it to 6, whereas MySQL rejects it. + +Attach a PostgreSQL constraint to an existing index +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +With ``Dialect.POSTGRESQL``, ``ALTER TABLE ... ADD UNIQUE USING INDEX`` and +``ADD PRIMARY KEY USING INDEX`` expose a ``ConstraintUsingIndex`` through +``AlterExpression.getIndex()``. Its ``getName()`` is the optional new constraint +name, while ``getExistingIndexName()`` identifies the existing index. This is +separate from an index declaration's name, columns and access method. + +.. code-block:: java + + Alter alter = (Alter) CCJSqlParserUtil.parse( + "ALTER TABLE t ADD CONSTRAINT uq UNIQUE USING INDEX i", + parser -> parser.withDialect(Dialect.POSTGRESQL)); + ConstraintUsingIndex constraint = (ConstraintUsingIndex) + alter.getAlterExpressions().get(0).getIndex(); + constraint.setExistingIndexName("replacement_index"); + constraint.setName("replacement_constraint"); + +``getConstraintAttributes()`` exposes deferrability and initial timing when +present. The same AST can be constructed with ``new ConstraintUsingIndex()`` +and its fluent ``withName``, ``withType`` and ``withExistingIndexName`` methods. diff --git a/src/test/java/net/sf/jsqlparser/statement/alter/PostgreSqlConstraintUsingIndexTest.java b/src/test/java/net/sf/jsqlparser/statement/alter/PostgreSqlConstraintUsingIndexTest.java new file mode 100644 index 000000000..d9cb97f84 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/alter/PostgreSqlConstraintUsingIndexTest.java @@ -0,0 +1,111 @@ +/*- + * #%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 java.util.Set; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.create.table.ConstraintUsingIndex; +import net.sf.jsqlparser.statement.create.table.Index; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import net.sf.jsqlparser.util.validation.ValidationContext; +import net.sf.jsqlparser.util.validation.metadata.Named; +import net.sf.jsqlparser.util.validation.metadata.NamedObject; +import net.sf.jsqlparser.util.validation.metadata.DatabaseMetaDataValidation; +import net.sf.jsqlparser.util.validation.validator.AlterValidator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class PostgreSqlConstraintUsingIndexTest { + @ParameterizedTest + @ValueSource(strings = {"UNIQUE USING INDEX i", "PRIMARY KEY USING INDEX i", + "CONSTRAINT uq UNIQUE USING INDEX i DEFERRABLE INITIALLY DEFERRED", + "CONSTRAINT pk PRIMARY KEY USING INDEX i NOT DEFERRABLE INITIALLY IMMEDIATE", + "CONSTRAINT \"Unique Name\" UNIQUE USING INDEX \"Index Name\""}) + void roundTripsExistingIndexConstraints(String body) throws JSQLParserException { + String sql = "ALTER TABLE t ADD " + body; + Alter alter = parse(sql); + ConstraintUsingIndex constraint = assertInstanceOf(ConstraintUsingIndex.class, + alter.getAlterExpressions().get(0).getIndex()); + assertNull(constraint.getColumns()); + assertNull(constraint.getIndexName()); + assertNull(constraint.getUsing()); + assertEquals(sql, alter.toString()); + roundTrip(alter); + } + + @Test + void supportsMutationAndConstruction() throws JSQLParserException { + Alter alter = parse("ALTER TABLE t ADD CONSTRAINT uq UNIQUE USING INDEX i DEFERRABLE"); + ConstraintUsingIndex constraint = + (ConstraintUsingIndex) alter.getAlterExpressions().get(0).getIndex(); + assertEquals(Boolean.TRUE, constraint.getConstraintAttributes().getDeferrable()); + constraint.setName("pk"); + constraint.setType("PRIMARY KEY"); + constraint.setExistingIndexName("other_index"); + assertEquals(Index.Kind.PRIMARY_KEY, constraint.getKind()); + assertEquals( + "ALTER TABLE t ADD CONSTRAINT pk PRIMARY KEY USING INDEX other_index DEFERRABLE", + alter.toString()); + roundTrip(alter); + Alter created = new Alter().withTable(new Table("t")); + created.addAlterExpressions(new AlterExpression().withOperation(AlterOperation.ADD) + .withIndex(new ConstraintUsingIndex().withName("uq").withExistingIndexName("i"))); + assertEquals("ALTER TABLE t ADD CONSTRAINT uq UNIQUE USING INDEX i", created.toString()); + roundTrip(created); + } + + @Test + void distinguishesTableAndIndexAndNewConstraint() throws JSQLParserException { + Alter alter = parse("ALTER TABLE t ADD CONSTRAINT uq UNIQUE USING INDEX i"); + assertEquals(Set.of("t"), new TablesNamesFinder().getTables(alter)); + List visited = new ArrayList<>(); + DatabaseMetaDataValidation metadata = named -> { + visited.add(named); + return named.getNamedObject() != NamedObject.constraint; + }; + AlterValidator validator = new AlterValidator(); + validator.setContext(new ValidationContext().setCapabilities(List.of(metadata))); + validator.validate(alter); + assertTrue(validator.getValidationErrors().isEmpty()); + assertTrue(visited.stream() + .anyMatch(n -> n.getNamedObject() == NamedObject.index && "i".equals(n.getFqn()))); + assertTrue(visited.stream().anyMatch( + n -> n.getNamedObject() == NamedObject.constraint && "uq".equals(n.getFqn()))); + } + + @Test + void preservesOrdinaryConstraintsAndActionBoundaries() throws JSQLParserException { + roundTrip(parse("ALTER TABLE t ADD CONSTRAINT uq UNIQUE (id)")); + Alter alter = parse("ALTER TABLE t ADD UNIQUE USING INDEX i, ADD COLUMN extra INT"); + assertEquals(2, alter.getAlterExpressions().size()); + roundTrip(alter); + String oracle = "ALTER TABLE t ADD CONSTRAINT pk PRIMARY KEY (id) USING INDEX i"; + assertEquals(oracle, CCJSqlParserUtil.parse(oracle).toString()); + } + + private static Alter parse(String sql) throws JSQLParserException { + return (Alter) CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.POSTGRESQL)); + } + + private static void roundTrip(Alter alter) throws JSQLParserException { + StringBuilder sql = new StringBuilder(); + alter.accept(new StatementDeParser(sql), null); + assertEquals(alter.toString(), sql.toString()); + assertEquals(sql.toString(), parse(sql.toString()).toString()); + } +}