From 338c6bb5a2ddf96de827a37b6a628ac9466804d3 Mon Sep 17 00:00:00 2001 From: minleejae Date: Thu, 24 Sep 2026 22:25:56 +0900 Subject: [PATCH] feat: share PostgreSQL foreign key enforcement attributes Signed-off-by: minleejae --- .../create/table/CheckConstraint.java | 17 ++- .../create/table/ConstraintAttributes.java | 13 ++ .../create/table/ForeignKeyReference.java | 15 ++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 91 +++++++++--- .../PostgreSqlConstraintEnforcementTest.java | 135 ++++++++++++++++++ 5 files changed, 242 insertions(+), 29 deletions(-) create mode 100644 src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlConstraintEnforcementTest.java diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/CheckConstraint.java b/src/main/java/net/sf/jsqlparser/statement/create/table/CheckConstraint.java index e3f06db40..33f2ad7a9 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/table/CheckConstraint.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/CheckConstraint.java @@ -22,8 +22,6 @@ public class CheckConstraint extends NamedConstraint { private Expression expression; - private Boolean enforced; - private boolean noInherit; public CheckConstraint() { @@ -61,11 +59,19 @@ public CheckConstraint withNoInherit(boolean noInherit) { } public Boolean getEnforced() { - return enforced; + return getConstraintAttributes() == null ? null : getConstraintAttributes().getEnforced(); } public void setEnforced(Boolean enforced) { - this.enforced = enforced; + ConstraintAttributes attributes = getConstraintAttributes(); + if (attributes == null) { + if (enforced == null) { + return; + } + attributes = new ConstraintAttributes(); + setConstraintAttributes(attributes); + } + attributes.setEnforced(enforced); } @Override @@ -81,9 +87,6 @@ public void appendTo(StringBuilder b, Consumer expressionPrinter) { if (noInherit) { b.append(" NO INHERIT"); } - if (enforced != null) { - b.append(enforced ? " ENFORCED" : " NOT ENFORCED"); - } appendConstraintSuffixTo(b); appendConstraintAttributesTo(b); } diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/ConstraintAttributes.java b/src/main/java/net/sf/jsqlparser/statement/create/table/ConstraintAttributes.java index ca063d603..7f8b68bd0 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/table/ConstraintAttributes.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/ConstraintAttributes.java @@ -20,6 +20,7 @@ public enum Initially { private Boolean deferrable; private Initially initially; private boolean notValid; + private Boolean enforced; public Boolean getDeferrable() { return deferrable; @@ -45,6 +46,15 @@ public void setNotValid(boolean notValid) { this.notValid = notValid; } + /** Null preserves an omitted ENFORCED clause. */ + public Boolean getEnforced() { + return enforced; + } + + public void setEnforced(Boolean enforced) { + this.enforced = enforced; + } + public void appendTo(StringBuilder sql) { if (deferrable != null) { sql.append(deferrable ? " DEFERRABLE" : " NOT DEFERRABLE"); @@ -52,6 +62,9 @@ public void appendTo(StringBuilder sql) { if (initially != null) { sql.append(" INITIALLY ").append(initially); } + if (enforced != null) { + sql.append(enforced ? " ENFORCED" : " NOT ENFORCED"); + } if (notValid) { sql.append(" NOT VALID"); } diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/ForeignKeyReference.java b/src/main/java/net/sf/jsqlparser/statement/create/table/ForeignKeyReference.java index 607a9d0df..c5ad32b53 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/table/ForeignKeyReference.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/ForeignKeyReference.java @@ -33,8 +33,20 @@ public enum MatchType { private Table table; private List referencedColumnNames; private MatchType matchType; + private ConstraintAttributes constraintAttributes; private final Set referentialActions = new LinkedHashSet<>(2); + /** + * Attributes of a column REFERENCES clause; table constraints own their attributes on Index. + */ + public ConstraintAttributes getConstraintAttributes() { + return constraintAttributes; + } + + public void setConstraintAttributes(ConstraintAttributes constraintAttributes) { + this.constraintAttributes = constraintAttributes; + } + public Table getTable() { return table; } @@ -130,6 +142,9 @@ public String toString() { builder.append(" MATCH ").append(matchType); } referentialActions.forEach(builder::append); + if (constraintAttributes != null) { + constraintAttributes.appendTo(builder); + } return builder.toString(); } } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 0df99f424..fdc025deb 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1535,6 +1535,14 @@ public class CCJSqlParser extends AbstractJSqlParser { || "\"XML\"".equalsIgnoreCase(name); } + private boolean isPostgreSqlConstraintAttributeAhead() { + int kind = getToken(1).kind; + int next = getToken(2).kind; + return kind == K_DEFERRABLE || kind == K_ENFORCED || isKeywordAhead("INITIALLY") + || kind == K_NOT && (next == K_DEFERRABLE || next == K_ENFORCED + || "VALID".equalsIgnoreCase(getToken(2).image)); + } + private boolean isMySqlStatisticsOptionAhead() { String name = getToken(1).image; return "STATS_AUTO_RECALC".equalsIgnoreCase(name) @@ -14157,7 +14165,7 @@ ColumnOption ColumnDefinitionOption(): { tk= { option = ColumnOption.serialDefaultValue(); } | - LOOKAHEAD() reference=ForeignKeyReferenceSpec() + LOOKAHEAD() reference=ForeignKeyReferenceSpec(true) { option = ColumnOption.reference(reference); } | LOOKAHEAD( ) @@ -14794,25 +14802,61 @@ void PostgreSqlConstraintOptions(Index index): } void PostgreSqlConstraintAttributes(Index index): +{ ConstraintAttributes attributes; } +{ + attributes=PostgreSqlConstraintAttributeList(index.getConstraintAttributes()) + { + requireDdlSyntax(attributes == null || attributes.getEnforced() == null + || index instanceof CheckConstraint || index instanceof ForeignKeyIndex, + "ENFORCED is supported only for CHECK and foreign key constraints"); + index.setConstraintAttributes(attributes); + } +} + +/** Shared attribute parsing for table constraints and column REFERENCES clauses. */ +ConstraintAttributes PostgreSqlConstraintAttributeList(ConstraintAttributes attributes): { - ConstraintAttributes attributes = new ConstraintAttributes(); - boolean present = false; - boolean deferrable = true; + boolean present = attributes != null; + boolean negative; + Boolean enforced; Token token; } { - [ LOOKAHEAD(2) [ { deferrable = false; } ] { - attributes.setDeferrable(deferrable); present = true; - } ] - [ LOOKAHEAD({ isKeywordAhead("INITIALLY") }) token= token= { - requireDdlSyntax("IMMEDIATE".equalsIgnoreCase(token.image) || "DEFERRED".equalsIgnoreCase(token.image), - "Expected IMMEDIATE or DEFERRED"); - attributes.setInitially(ConstraintAttributes.Initially.valueOf(token.image.toUpperCase(Locale.ROOT))); - present = true; - } ] - [ LOOKAHEAD({ getToken(1).kind == K_NOT && "VALID".equalsIgnoreCase(getToken(2).image) }) - token= { attributes.setNotValid(true); present = true; } ] - { if (present) { index.setConstraintAttributes(attributes); } } + { if (attributes == null) { attributes = new ConstraintAttributes(); } } + ( LOOKAHEAD({ isPostgreSqlConstraintAttributeAhead() }) ( + LOOKAHEAD(2) { negative = false; } + [ { negative = true; } ] { + requireDdlSyntax(attributes.getDeferrable() == null, "Duplicate DEFERRABLE clause"); + attributes.setDeferrable(!negative); present = true; + } + | + LOOKAHEAD({ isKeywordAhead("INITIALLY") }) token= { + requireDdlSyntax(attributes.getInitially() == null, "Duplicate INITIALLY clause"); + requireDdlSyntax("IMMEDIATE".equalsIgnoreCase(token.image) || "DEFERRED".equalsIgnoreCase(token.image), + "Expected IMMEDIATE or DEFERRED"); + attributes.setInitially(ConstraintAttributes.Initially.valueOf(token.image.toUpperCase(Locale.ROOT))); + present = true; + } + | + LOOKAHEAD(2) enforced=ConstraintEnforcement() { + requireDdlSyntax(attributes.getEnforced() == null, "Duplicate ENFORCED clause"); + attributes.setEnforced(enforced); present = true; + } + | + LOOKAHEAD({ getToken(1).kind == K_NOT && "VALID".equalsIgnoreCase(getToken(2).image) }) + { + requireDdlSyntax(!attributes.isNotValid(), "Duplicate NOT VALID clause"); + attributes.setNotValid(true); present = true; + } + ) )* + { return present ? attributes : null; } +} + +Boolean ConstraintEnforcement(): +{ boolean enforced = true; } +{ + [ { enforced = false; } ] + { return enforced; } } /** @@ -15748,7 +15792,7 @@ void ReferentialActions(ForeignKeyReference reference): )] } -ForeignKeyReference ForeignKeyReferenceSpec(): +ForeignKeyReference ForeignKeyReferenceSpec(boolean columnContext): { ForeignKeyReference reference = new ForeignKeyReference(); ForeignKeyReference.MatchType matchType; @@ -15775,6 +15819,12 @@ ForeignKeyReference ForeignKeyReferenceSpec(): ] ReferentialActions(reference) { + if (columnContext) { + ConstraintAttributes attributes = PostgreSqlConstraintAttributeList(null); + requireDdlSyntax(attributes == null || !attributes.isNotValid(), + "NOT VALID requires a table constraint"); + reference.setConstraintAttributes(attributes); + } return reference; } } @@ -15795,10 +15845,7 @@ CheckConstraint CheckConstraintSpec(String constraintName): [ LOOKAHEAD({ Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect)) && getToken(1).kind == K_NO && "INHERIT".equalsIgnoreCase(getToken(2).image) }) TypeDdlKeyword("INHERIT") { noInherit = true; } ] - [ LOOKAHEAD(2) - [ { enforced = false; } ] - { if (enforced == null) { enforced = true; } } - ] + [ LOOKAHEAD(2) enforced=ConstraintEnforcement() ] { checkConstraint = new CheckConstraint().withName(constraintName).withExpression(exp) .withEnforced(enforced).withNoInherit(noInherit); @@ -15828,7 +15875,7 @@ ForeignKeyIndex ForeignKeySpec(String constraintName): if (constraintName != null) { fkIndex.setName(constraintName); } fkIndex.withType(tk.image + " " + tk2.image).withColumns(colNames); } - reference=ForeignKeyReferenceSpec() { fkIndex.setReference(reference); } + reference=ForeignKeyReferenceSpec(false) { fkIndex.setReference(reference); } { return fkIndex; } diff --git a/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlConstraintEnforcementTest.java b/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlConstraintEnforcementTest.java new file mode 100644 index 000000000..b3a70a200 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlConstraintEnforcementTest.java @@ -0,0 +1,135 @@ +/*- + * #%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.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.alter.Alter; +import net.sf.jsqlparser.statement.create.table.*; +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 PostgreSqlConstraintEnforcementTest { + @ParameterizedTest + @ValueSource(strings = {"ENFORCED", "NOT ENFORCED", + "DEFERRABLE INITIALLY DEFERRED NOT ENFORCED", + "NOT ENFORCED DEFERRABLE INITIALLY DEFERRED", + "INITIALLY IMMEDIATE NOT DEFERRABLE ENFORCED"}) + void sharesForeignKeyAttributesAcrossCreateAndAlter(String attributes) + throws JSQLParserException { + for (String prefix : List.of("CREATE TABLE t (id INT, ", "ALTER TABLE t ADD ")) { + Statement statement = + parse(prefix + "CONSTRAINT fk FOREIGN KEY (id) REFERENCES public.p (id) " + + "MATCH SIMPLE ON DELETE CASCADE " + attributes + + (prefix.startsWith("CREATE") ? ")" : ", ADD COLUMN extra INT")); + ForeignKeyIndex fk = (ForeignKeyIndex) (statement instanceof CreateTable + ? ((CreateTable) statement).getIndexes().get(0) + : ((Alter) statement).getAlterExpressions().get(0).getIndex()); + assertEquals(!attributes.contains("NOT ENFORCED"), + fk.getConstraintAttributes().getEnforced()); + roundTrip(statement); + fk.getConstraintAttributes().setEnforced(true); + roundTrip(statement); + assertFalse(statement.toString().contains("NOT ENFORCED")); + fk.getConstraintAttributes().setEnforced(null); + roundTrip(statement); + assertFalse(statement.toString().contains("ENFORCED")); + } + } + + @ParameterizedTest + @ValueSource(strings = {"ENFORCED", "NOT ENFORCED DEFERRABLE INITIALLY DEFERRED"}) + void columnReferencesRetainTheirOwnAttributesAndFollowingOptions(String attributes) + throws JSQLParserException { + for (String prefix : List.of("CREATE TABLE t (", "ALTER TABLE t ADD COLUMN ")) { + Statement statement = parse(prefix + "id INT REFERENCES public.p (id) " + attributes + + " NOT NULL" + + (prefix.startsWith("CREATE") ? ", value INT)" : ", ADD COLUMN value INT")); + ColumnDefinition column = statement instanceof CreateTable + ? ((CreateTable) statement).getColumnDefinitions().get(0) + : ((Alter) statement).getAlterExpressions().get(0).getColDataTypeList().get(0); + ForeignKeyReference reference = + column.getColumnOptions().get(0).getForeignKeyReference(); + assertNotNull(reference.getConstraintAttributes()); + assertEquals(!attributes.contains("NOT ENFORCED"), + reference.getConstraintAttributes().getEnforced()); + assertEquals(ColumnOption.Kind.NULLABILITY, column.getColumnOptions().get(1).getKind()); + roundTrip(statement); + reference.getConstraintAttributes().setEnforced(null); + roundTrip(statement); + } + } + + @Test + void checkLegacyAccessorsUseTheSharedAttributeState() throws JSQLParserException { + for (String prefix : List.of("CREATE TABLE t (id INT, ", "ALTER TABLE t ADD ")) { + Statement statement = parse(prefix + "CHECK (id > 0) NOT ENFORCED" + + (prefix.startsWith("CREATE") ? ")" : " NOT VALID")); + CheckConstraint check = (CheckConstraint) (statement instanceof CreateTable + ? ((CreateTable) statement).getIndexes().get(0) + : ((Alter) statement).getAlterExpressions().get(0).getIndex()); + assertEquals(false, check.getConstraintAttributes().getEnforced()); + check.getConstraintAttributes().setEnforced(true); + assertEquals(true, check.getEnforced()); + check.setEnforced(false); + assertEquals(false, check.getConstraintAttributes().getEnforced()); + roundTrip(statement); + check.setEnforced(null); + assertNull(check.getEnforced()); + roundTrip(statement); + } + assertNull(new CheckConstraint().withEnforced(null).getConstraintAttributes()); + } + + @Test + void preservesAlterEnforcementAndStatementBoundaries() throws JSQLParserException { + for (String flag : List.of("ENFORCED", "NOT ENFORCED")) { + Alter alter = (Alter) parse("ALTER TABLE t ALTER CONSTRAINT fk " + flag); + assertEquals(!flag.startsWith("NOT"), alter.getAlterExpressions().get(0).isEnforced()); + roundTrip(alter); + } + assertEquals(2, CCJSqlParserUtil.parseStatements( + "CREATE TABLE t(id INT REFERENCES p NOT ENFORCED); SELECT 1").size()); + } + + @ParameterizedTest + @ValueSource(strings = {"FOREIGN KEY(id) REFERENCES p ENFORCED NOT ENFORCED", + "FOREIGN KEY(id) REFERENCES p DEFERRABLE NOT DEFERRABLE", + "FOREIGN KEY(id) REFERENCES p ENFORCED INITIALLY wrong", + "PRIMARY KEY(id) NOT ENFORCED", "CHECK(id > 0) ENFORCED ENFORCED"}) + void rejectsMalformedAttributeTails(String constraint) { + assertThrows(JSQLParserException.class, + () -> parse("CREATE TABLE t(id INT, " + constraint + ")")); + } + + @ParameterizedTest + @ValueSource(strings = {"CREATE TABLE t(id INT REFERENCES p ENFORCED NOT VALID)", + "ALTER TABLE t ADD COLUMN id INT REFERENCES p NOT VALID"}) + void rejectsNotValidOnColumnReferences(String sql) { + assertThrows(JSQLParserException.class, () -> parse(sql)); + } + + private static Statement parse(String sql) throws JSQLParserException { + return CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.POSTGRESQL)); + } + + private static void roundTrip(Statement statement) throws JSQLParserException { + StringBuilder out = new StringBuilder(); + statement.accept(new StatementDeParser(out)); + assertEquals(statement.toString(), out.toString()); + assertEquals(out.toString(), parse(out.toString()).toString()); + } +}