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 c5ad32b53..3b4bc996b 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 @@ -21,7 +21,6 @@ import net.sf.jsqlparser.statement.ReferentialAction; import net.sf.jsqlparser.statement.ReferentialAction.Action; import net.sf.jsqlparser.statement.ReferentialAction.Type; -import net.sf.jsqlparser.statement.select.PlainSelect; /** The target and actions of a column- or table-level foreign-key reference. */ public class ForeignKeyReference implements Serializable { @@ -33,6 +32,7 @@ public enum MatchType { private Table table; private List referencedColumnNames; private MatchType matchType; + private boolean usingPeriod; private ConstraintAttributes constraintAttributes; private final Set referentialActions = new LinkedHashSet<>(2); @@ -47,6 +47,20 @@ public void setConstraintAttributes(ConstraintAttributes constraintAttributes) { this.constraintAttributes = constraintAttributes; } + /** Whether the final explicitly referenced column is prefixed with PERIOD. */ + public boolean isUsingPeriod() { + return usingPeriod; + } + + public void setUsingPeriod(boolean usingPeriod) { + this.usingPeriod = usingPeriod; + } + + public ForeignKeyReference withUsingPeriod(boolean usingPeriod) { + setUsingPeriod(usingPeriod); + return this; + } + public Table getTable() { return table; } @@ -136,7 +150,17 @@ public ForeignKeyReference addReferencedColumnNames( public String toString() { StringBuilder builder = new StringBuilder("REFERENCES ").append(table); if (referencedColumnNames != null) { - builder.append(PlainSelect.getStringList(referencedColumnNames, true, true)); + builder.append('('); + for (int i = 0; i < referencedColumnNames.size(); i++) { + if (i > 0) { + builder.append(", "); + } + if (usingPeriod && i == referencedColumnNames.size() - 1) { + builder.append("PERIOD "); + } + builder.append(referencedColumnNames.get(i)); + } + builder.append(')'); } if (matchType != null) { builder.append(" MATCH ").append(matchType); diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/Index.java b/src/main/java/net/sf/jsqlparser/statement/create/table/Index.java index 45b0799ce..8f8cb02a6 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/table/Index.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/Index.java @@ -417,6 +417,8 @@ public enum NullOrdering { private SortOrder sortOrder; private NullOrdering nullOrdering; private ExclusionOperator exclusionOperator; + private boolean withoutOverlaps; + private boolean period; public String getExclusionOperator() { return exclusionOperator == null ? null : exclusionOperator.toString(); @@ -466,6 +468,34 @@ public ColumnParams(Expression expression, List params) { this.expression = expression; } + /** Marks the final key of a PostgreSQL temporal PRIMARY KEY or UNIQUE constraint. */ + public boolean isWithoutOverlaps() { + return withoutOverlaps; + } + + public void setWithoutOverlaps(boolean withoutOverlaps) { + this.withoutOverlaps = withoutOverlaps; + } + + public ColumnParams withWithoutOverlaps(boolean withoutOverlaps) { + setWithoutOverlaps(withoutOverlaps); + return this; + } + + /** Marks the final referencing column of a temporal foreign key. */ + public boolean isPeriod() { + return period; + } + + public void setPeriod(boolean period) { + this.period = period; + } + + public ColumnParams withPeriod(boolean period) { + setPeriod(period); + return this; + } + public String getColumnName() { return expression != null ? expression.toString() : columnName; } @@ -569,6 +599,9 @@ public String toString() { /** Renders expression keys through the caller's expression printer. */ public void appendTo(StringBuilder builder, Consumer expressionPrinter) { + if (period) { + builder.append("PERIOD "); + } if (expression != null) { if (expressionParenthesized) { builder.append('('); @@ -588,6 +621,9 @@ public void appendTo(StringBuilder builder, Consumer expressionPrint if (exclusionOperator != null) { builder.append(" WITH ").append(exclusionOperator); } + if (withoutOverlaps) { + builder.append(" WITHOUT OVERLAPS"); + } } private void appendParams(StringBuilder builder) { diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 0203fcde6..1cb05eaff 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -13835,7 +13835,7 @@ Index.ColumnParams IndexColumnWithParams(): { || (Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect)) && getToken(1).kind != K_ASC && getToken(1).kind != K_DESC && getToken(1).kind != K_NULLS && getToken(1).kind != K_COLLATE - && getToken(1).kind != K_WITH) }) + && getToken(1).kind != K_WITH && getToken(1).kind != K_WITHOUT) }) operatorClass=IndexKeyAttributeName() [ LOOKAHEAD(2) operatorClassParameters=PostgreSqlIndexOptions() ] { @@ -13852,7 +13852,7 @@ Index.ColumnParams IndexColumnWithParams(): { { if (!postgres) { columnParams.add(sortOrder.image); } } ] [ LOOKAHEAD(2) (nullOrdering= | nullOrdering=) { if (!postgres) { columnParams.add("NULLS"); columnParams.add(nullOrdering.image); } } ] - ( LOOKAHEAD(2, { !Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect)) && getToken(1).kind != K_WITH }) parameter=CreateParameter() { columnParams.addAll(parameter); } )* + ( LOOKAHEAD(2, { !Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect)) && getToken(1).kind != K_WITH && getToken(1).kind != K_WITHOUT }) parameter=CreateParameter() { columnParams.addAll(parameter); } )* { column = expression != null ? new Index.ColumnParams(expression, @@ -13913,7 +13913,11 @@ List PostgreSqlIndexOptions(): { return options; } } -List IndexColumnsWithParamsList() : { +List IndexColumnsWithParamsList(): +{ List columns; } +{ columns=IndexKeyColumns(false) { return columns; } } + +List IndexKeyColumns(boolean temporal) : { List colNames = new ArrayList(); Index.ColumnParams column = null; } @@ -13932,6 +13936,10 @@ List IndexColumnsWithParamsList() : { } )* + [ { + requireDdlSyntax(temporal, "WITHOUT OVERLAPS requires a PRIMARY KEY or UNIQUE constraint"); + column.setWithoutOverlaps(true); + } ] ")" { return colNames; } @@ -14709,7 +14717,7 @@ Index TableIndexSpec(boolean createContext): typeToken= keywordToken= clustering=SqlServerIndexClustering() [ LOOKAHEAD({ clustering == null && getToken(1).kind != OPENING_BRACKET }) indexName=RelObjectName() ] - columns=IndexColumnsWithParamsList() + columns=IndexKeyColumns(true) TableIndexOptions(createContext, indexOptions) { index = new NamedConstraint() @@ -14728,11 +14736,12 @@ Index TableIndexSpec(boolean createContext): [ LOOKAHEAD({ clustering == null && getToken(1).kind != OPENING_BRACKET && getToken(1).kind != K_USING }) indexName=RelObjectName() ] [ using=UsingIndexType() ] - columns=IndexColumnsWithParamsList() + columns=IndexKeyColumns(true) TableIndexOptions(createContext, indexOptions) { - // MySQL ALTER preserves both the constraint symbol and the index name. - if (createContext || Dialect.MYSQL.name().equals(getAsString(Feature.dialect))) { + // PostgreSQL and MySQL ALTER preserve named constraints separately from index names. + if (createContext || Dialect.MYSQL.name().equals(getAsString(Feature.dialect)) + || Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect))) { index = new NamedConstraint() .withIndexName(indexName) .withType(typeToken.image @@ -15870,16 +15879,55 @@ void ReferentialActionSpec(ForeignKeyReference reference): } } +/** Shares column/period boundaries on both sides of a foreign key. */ +List ForeignKeyColumns(boolean allowParameters): +{ + List columns = new ArrayList(); + Index.ColumnParams column; +} +{ + "(" column=ForeignKeyColumn(allowParameters) { columns.add(column); } + ( "," { + requireDdlSyntax(!column.isPeriod(), "PERIOD must mark the final foreign key column"); + } column=ForeignKeyColumn(allowParameters) { columns.add(column); } )* ")" + { return columns; } +} + +Index.ColumnParams ForeignKeyColumn(boolean allowParameters): +{ + boolean period = false; + String name; + List parameters = null; +} +{ + [ LOOKAHEAD({ isKeywordAhead("PERIOD") && getToken(2).kind != K_COMMA + && getToken(2).kind != CLOSING_BRACKET }) + ContextualKeyword("PERIOD") { period = true; } ] + name=RelObjectName() + [ parameters=CreateParameter() { + requireDdlSyntax(allowParameters && !period, "Expected a foreign key column name"); + } ] + { return new Index.ColumnParams(name, parameters).withPeriod(period); } +} + ForeignKeyReference ForeignKeyReferenceSpec(boolean columnContext): { ForeignKeyReference reference = new ForeignKeyReference(); ForeignKeyReference.MatchType matchType; Token matchToken; List refColNames = null; + List referencedColumns; Table fkTable; } { - fkTable=Table() [ LOOKAHEAD(2) refColNames=ColumnsNamesList() ] + fkTable=Table() + [ LOOKAHEAD(2) referencedColumns=ForeignKeyColumns(false) { + refColNames = new ArrayList(); + for (Index.ColumnParams column : referencedColumns) { + refColNames.add(column.getColumnName()); + } + reference.setUsingPeriod(referencedColumns.get(referencedColumns.size() - 1).isPeriod()); + } ] { reference.setTable(fkTable); reference.setReferencedColumnNames(refColNames); @@ -15948,7 +15996,7 @@ ForeignKeyIndex ForeignKeySpec(String constraintName): { tk= tk2= [ LOOKAHEAD(2) indexName=RelObjectName() { fkIndex.setIndexName(indexName); } ] - colNames = ColumnNamesWithParamsList() + colNames = ForeignKeyColumns(true) { if (constraintName != null) { fkIndex.setName(constraintName); } fkIndex.withType(tk.image + " " + tk2.image).withColumns(colNames); diff --git a/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlTemporalConstraintTest.java b/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlTemporalConstraintTest.java new file mode 100644 index 000000000..cb2f20ab3 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlTemporalConstraintTest.java @@ -0,0 +1,169 @@ +/*- + * #%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 java.util.Set; +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.CreateTable; +import net.sf.jsqlparser.statement.create.table.ForeignKeyIndex; +import net.sf.jsqlparser.statement.create.table.Index; +import net.sf.jsqlparser.util.TablesNamesFinder; +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 PostgreSqlTemporalConstraintTest { + @ParameterizedTest + @ValueSource(strings = {"PRIMARY KEY", "UNIQUE", "UNIQUE NULLS NOT DISTINCT"}) + void modelsTemporalKeysAcrossCreateAndAlter(String kind) throws JSQLParserException { + for (String prefix : List.of("CREATE TABLE t (id INT, valid DATERANGE, ", + "ALTER TABLE t ADD ")) { + Statement statement = parse(prefix + "CONSTRAINT temporal_key " + kind + + " (id, valid WITHOUT OVERLAPS) INCLUDE (label) DEFERRABLE INITIALLY DEFERRED" + + (prefix.startsWith("CREATE") ? ", label TEXT)" : ", ADD COLUMN extra INT")); + Index index = index(statement); + assertEquals("temporal_key", index.getName()); + assertTrue(statement.toString().contains("CONSTRAINT temporal_key")); + assertEquals(2, index.getColumns().size()); + assertFalse(index.getColumns().get(0).isWithoutOverlaps()); + Index.ColumnParams last = index.getColumns().get(1); + assertTrue(last.isWithoutOverlaps()); + assertEquals("valid", last.getColumnName()); + assertNull(last.getOperatorClass()); + assertEquals(List.of("label"), index.getIncludeColumns()); + roundTrip(statement); + last.setWithoutOverlaps(false); + assertFalse(statement.toString().contains("WITHOUT OVERLAPS")); + roundTrip(statement); + } + } + + @ParameterizedTest + @ValueSource(strings = {"", " (id, PERIOD valid)", " (id, PERIOD \"Valid Range\")"}) + void sharesReferencingAndReferencedPeriodLists(String referenced) throws JSQLParserException { + for (String prefix : List.of("CREATE TABLE t (id INT, valid DATERANGE, ", + "ALTER TABLE t ADD ")) { + Statement statement = + parse(prefix + "FOREIGN KEY (id, PERIOD valid) REFERENCES app.parent" + + referenced + " ON DELETE NO ACTION ON UPDATE NO ACTION" + + (prefix.startsWith("CREATE") ? ")" : ", ADD COLUMN extra INT")); + ForeignKeyIndex foreign = (ForeignKeyIndex) index(statement); + assertFalse(foreign.getColumns().get(0).isPeriod()); + assertTrue(foreign.getColumns().get(1).isPeriod()); + assertEquals("valid", foreign.getColumns().get(1).getColumnName()); + assertEquals(!referenced.isEmpty(), foreign.getReference().isUsingPeriod()); + assertEquals(Set.of("t", "app.parent"), new TablesNamesFinder().getTables(statement)); + roundTrip(statement); + foreign.getColumns().get(1).setPeriod(false); + foreign.getReference().setUsingPeriod(false); + assertFalse(statement.toString().contains("PERIOD")); + roundTrip(statement); + } + } + + @Test + void preservesPeriodAndEnforcementTogether() throws JSQLParserException { + for (String prefix : List.of("CREATE TABLE t (id INT, valid DATERANGE, ", + "ALTER TABLE t ADD ")) { + Statement statement = parse(prefix + + "CONSTRAINT fk FOREIGN KEY (id, PERIOD valid) REFERENCES p(id, PERIOD valid)" + + " ON DELETE NO ACTION NOT ENFORCED" + + (prefix.startsWith("CREATE") ? ")" : "")); + ForeignKeyIndex foreign = (ForeignKeyIndex) index(statement); + assertTrue(foreign.getColumns().get(1).isPeriod()); + assertTrue(foreign.getReference().isUsingPeriod()); + assertEquals(false, foreign.getConstraintAttributes().getEnforced()); + assertNull(foreign.getReference().getConstraintAttributes()); + roundTrip(statement); + foreign.getConstraintAttributes().setEnforced(true); + assertTrue(statement.toString().endsWith(prefix.startsWith("CREATE") + ? " ENFORCED)" + : " ENFORCED")); + roundTrip(statement); + } + } + + @Test + void retainsQuotedAndUnquotedIdentifiersAndLegacyProjections() throws JSQLParserException { + CreateTable table = (CreateTable) parse("CREATE TABLE t (id INT, period DATERANGE, " + + "FOREIGN KEY (id, period) REFERENCES p(id, period))"); + ForeignKeyIndex foreign = (ForeignKeyIndex) index(table); + assertFalse(foreign.getColumns().get(1).isPeriod()); + assertFalse(foreign.getReference().isUsingPeriod()); + roundTrip(table); + table = (CreateTable) parse("CREATE TABLE t (id INT, \"PERIOD\" DATERANGE, " + + "FOREIGN KEY (id, PERIOD \"PERIOD\") REFERENCES p(id, PERIOD \"PERIOD\"))"); + foreign = (ForeignKeyIndex) index(table); + assertEquals(List.of("id", "\"PERIOD\""), foreign.getReferencedColumnNames()); + foreign.getReferencedColumnNames().set(1, "replacement"); + assertTrue(table.toString().contains("PERIOD replacement")); + roundTrip(table); + assertEquals(2, CCJSqlParserUtil.parseStatements(table + "; SELECT 1", + p -> p.withDialect(Dialect.POSTGRESQL)).size()); + } + + @Test + void preservesNamedUniqueConstraintsWithAndWithoutTemporalKeys() throws JSQLParserException { + Statement ordinary = parse("ALTER TABLE t ADD CONSTRAINT uq UNIQUE (id)"); + assertEquals("uq", index(ordinary).getName()); + assertEquals("ALTER TABLE t ADD CONSTRAINT uq UNIQUE (id)", ordinary.toString()); + roundTrip(ordinary); + Statement temporal = parse("ALTER TABLE t ADD CONSTRAINT uq " + + "UNIQUE (id, valid WITHOUT OVERLAPS)"); + assertEquals("uq", index(temporal).getName()); + assertTrue(index(temporal).getColumns().get(1).isWithoutOverlaps()); + roundTrip(temporal); + } + + @Test + void keepsOrdinaryIndexesAndCrossDialectForeignKeys() throws JSQLParserException { + String mysql = "CREATE TABLE t (id INT, FOREIGN KEY (id) REFERENCES p (id))"; + assertNotNull(CCJSqlParserUtil.parse(mysql)); + assertEquals("valid WITHOUT OVERLAPS", + new Index.ColumnParams("valid").withWithoutOverlaps(true).toString()); + assertEquals("PERIOD valid", new Index.ColumnParams("valid").withPeriod(true).toString()); + assertThrows(JSQLParserException.class, + () -> parse("CREATE INDEX ix ON t (id, valid WITHOUT OVERLAPS)")); + } + + @ParameterizedTest + @ValueSource(strings = {"UNIQUE (valid WITHOUT OVERLAPS, id)", + "PRIMARY KEY (id, valid WITHOUT)", + "UNIQUE (id, valid WITHOUT OVERLAPS WITHOUT OVERLAPS)", + "FOREIGN KEY (PERIOD valid, id) REFERENCES p(id, valid)", + "FOREIGN KEY (id, PERIOD valid) REFERENCES p(PERIOD valid, id)"}) + void rejectsMalformedTemporalMarkers(String constraint) { + assertThrows(JSQLParserException.class, + () -> parse("CREATE TABLE t (id INT, valid DATERANGE, " + constraint + ")")); + } + + private static Index index(Statement statement) { + return statement instanceof CreateTable ? ((CreateTable) statement).getIndexes().get(0) + : ((Alter) statement).getAlterExpressions().get(0).getIndex(); + } + + 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()); + } +}