Skip to content

Commit 33f7635

Browse files
committed
feat: model PostgreSQL temporal key and foreign key constraints
Signed-off-by: minleejae <mmj9808@gmail.com>
1 parent 0f58f7f commit 33f7635

4 files changed

Lines changed: 266 additions & 11 deletions

File tree

‎src/main/java/net/sf/jsqlparser/statement/create/table/ForeignKeyReference.java‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
import net.sf.jsqlparser.statement.ReferentialAction;
2222
import net.sf.jsqlparser.statement.ReferentialAction.Action;
2323
import net.sf.jsqlparser.statement.ReferentialAction.Type;
24-
import net.sf.jsqlparser.statement.select.PlainSelect;
2524

2625
/** The target and actions of a column- or table-level foreign-key reference. */
2726
public class ForeignKeyReference implements Serializable {
@@ -33,8 +32,23 @@ public enum MatchType {
3332
private Table table;
3433
private List<String> referencedColumnNames;
3534
private MatchType matchType;
35+
private boolean usingPeriod;
3636
private final Set<ReferentialAction> referentialActions = new LinkedHashSet<>(2);
3737

38+
/** Whether the final explicitly referenced column is prefixed with PERIOD. */
39+
public boolean isUsingPeriod() {
40+
return usingPeriod;
41+
}
42+
43+
public void setUsingPeriod(boolean usingPeriod) {
44+
this.usingPeriod = usingPeriod;
45+
}
46+
47+
public ForeignKeyReference withUsingPeriod(boolean usingPeriod) {
48+
setUsingPeriod(usingPeriod);
49+
return this;
50+
}
51+
3852
public Table getTable() {
3953
return table;
4054
}
@@ -124,7 +138,17 @@ public ForeignKeyReference addReferencedColumnNames(
124138
public String toString() {
125139
StringBuilder builder = new StringBuilder("REFERENCES ").append(table);
126140
if (referencedColumnNames != null) {
127-
builder.append(PlainSelect.getStringList(referencedColumnNames, true, true));
141+
builder.append('(');
142+
for (int i = 0; i < referencedColumnNames.size(); i++) {
143+
if (i > 0) {
144+
builder.append(", ");
145+
}
146+
if (usingPeriod && i == referencedColumnNames.size() - 1) {
147+
builder.append("PERIOD ");
148+
}
149+
builder.append(referencedColumnNames.get(i));
150+
}
151+
builder.append(')');
128152
}
129153
if (matchType != null) {
130154
builder.append(" MATCH ").append(matchType);

‎src/main/java/net/sf/jsqlparser/statement/create/table/Index.java‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,8 @@ public enum NullOrdering {
417417
private SortOrder sortOrder;
418418
private NullOrdering nullOrdering;
419419
private ExclusionOperator exclusionOperator;
420+
private boolean withoutOverlaps;
421+
private boolean period;
420422

421423
public String getExclusionOperator() {
422424
return exclusionOperator == null ? null : exclusionOperator.toString();
@@ -466,6 +468,34 @@ public ColumnParams(Expression expression, List<String> params) {
466468
this.expression = expression;
467469
}
468470

471+
/** Marks the final key of a PostgreSQL temporal PRIMARY KEY or UNIQUE constraint. */
472+
public boolean isWithoutOverlaps() {
473+
return withoutOverlaps;
474+
}
475+
476+
public void setWithoutOverlaps(boolean withoutOverlaps) {
477+
this.withoutOverlaps = withoutOverlaps;
478+
}
479+
480+
public ColumnParams withWithoutOverlaps(boolean withoutOverlaps) {
481+
setWithoutOverlaps(withoutOverlaps);
482+
return this;
483+
}
484+
485+
/** Marks the final referencing column of a temporal foreign key. */
486+
public boolean isPeriod() {
487+
return period;
488+
}
489+
490+
public void setPeriod(boolean period) {
491+
this.period = period;
492+
}
493+
494+
public ColumnParams withPeriod(boolean period) {
495+
setPeriod(period);
496+
return this;
497+
}
498+
469499
public String getColumnName() {
470500
return expression != null ? expression.toString() : columnName;
471501
}
@@ -569,6 +599,9 @@ public String toString() {
569599

570600
/** Renders expression keys through the caller's expression printer. */
571601
public void appendTo(StringBuilder builder, Consumer<Expression> expressionPrinter) {
602+
if (period) {
603+
builder.append("PERIOD ");
604+
}
572605
if (expression != null) {
573606
if (expressionParenthesized) {
574607
builder.append('(');
@@ -588,6 +621,9 @@ public void appendTo(StringBuilder builder, Consumer<Expression> expressionPrint
588621
if (exclusionOperator != null) {
589622
builder.append(" WITH ").append(exclusionOperator);
590623
}
624+
if (withoutOverlaps) {
625+
builder.append(" WITHOUT OVERLAPS");
626+
}
591627
}
592628

593629
private void appendParams(StringBuilder builder) {

‎src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt‎

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13816,7 +13816,7 @@ Index.ColumnParams IndexColumnWithParams(): {
1381613816
|| (Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect))
1381713817
&& getToken(1).kind != K_ASC && getToken(1).kind != K_DESC
1381813818
&& getToken(1).kind != K_NULLS && getToken(1).kind != K_COLLATE
13819-
&& getToken(1).kind != K_WITH) })
13819+
&& getToken(1).kind != K_WITH && getToken(1).kind != K_WITHOUT) })
1382013820
operatorClass=IndexKeyAttributeName()
1382113821
[ LOOKAHEAD(2) operatorClassParameters=PostgreSqlIndexOptions() ]
1382213822
{
@@ -13833,7 +13833,7 @@ Index.ColumnParams IndexColumnWithParams(): {
1383313833
{ if (!postgres) { columnParams.add(sortOrder.image); } } ]
1383413834
[ LOOKAHEAD(2) <K_NULLS> (nullOrdering=<K_FIRST> | nullOrdering=<K_LAST>)
1383513835
{ if (!postgres) { columnParams.add("NULLS"); columnParams.add(nullOrdering.image); } } ]
13836-
( LOOKAHEAD(2, { !Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect)) && getToken(1).kind != K_WITH }) parameter=CreateParameter() { columnParams.addAll(parameter); } )*
13836+
( LOOKAHEAD(2, { !Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect)) && getToken(1).kind != K_WITH && getToken(1).kind != K_WITHOUT }) parameter=CreateParameter() { columnParams.addAll(parameter); } )*
1383713837
{
1383813838
column = expression != null
1383913839
? new Index.ColumnParams(expression,
@@ -13894,7 +13894,11 @@ List<Index.Option> PostgreSqlIndexOptions():
1389413894
{ return options; }
1389513895
}
1389613896

13897-
List<Index.ColumnParams> IndexColumnsWithParamsList() : {
13897+
List<Index.ColumnParams> IndexColumnsWithParamsList():
13898+
{ List<Index.ColumnParams> columns; }
13899+
{ columns=IndexKeyColumns(false) { return columns; } }
13900+
13901+
List<Index.ColumnParams> IndexKeyColumns(boolean temporal) : {
1389813902
List<Index.ColumnParams> colNames = new ArrayList<Index.ColumnParams>();
1389913903
Index.ColumnParams column = null;
1390013904
}
@@ -13913,6 +13917,10 @@ List<Index.ColumnParams> IndexColumnsWithParamsList() : {
1391313917
}
1391413918
)*
1391513919

13920+
[ <K_WITHOUT> <K_OVERLAPS> {
13921+
requireDdlSyntax(temporal, "WITHOUT OVERLAPS requires a PRIMARY KEY or UNIQUE constraint");
13922+
column.setWithoutOverlaps(true);
13923+
} ]
1391613924
")"
1391713925

1391813926
{ return colNames; }
@@ -14679,7 +14687,7 @@ Index TableIndexSpec(boolean createContext):
1467914687
typeToken=<K_PRIMARY> keywordToken=<K_KEY>
1468014688
clustering=SqlServerIndexClustering()
1468114689
[ LOOKAHEAD({ clustering == null && getToken(1).kind != OPENING_BRACKET }) indexName=RelObjectName() ]
14682-
columns=IndexColumnsWithParamsList()
14690+
columns=IndexKeyColumns(true)
1468314691
TableIndexOptions(createContext, indexOptions)
1468414692
{
1468514693
index = new NamedConstraint()
@@ -14698,11 +14706,12 @@ Index TableIndexSpec(boolean createContext):
1469814706
[ LOOKAHEAD({ clustering == null && getToken(1).kind != OPENING_BRACKET
1469914707
&& getToken(1).kind != K_USING }) indexName=RelObjectName() ]
1470014708
[ using=UsingIndexType() ]
14701-
columns=IndexColumnsWithParamsList()
14709+
columns=IndexKeyColumns(true)
1470214710
TableIndexOptions(createContext, indexOptions)
1470314711
{
14704-
// MySQL ALTER preserves both the constraint symbol and the index name.
14705-
if (createContext || Dialect.MYSQL.name().equals(getAsString(Feature.dialect))) {
14712+
// PostgreSQL and MySQL ALTER preserve named constraints separately from index names.
14713+
if (createContext || Dialect.MYSQL.name().equals(getAsString(Feature.dialect))
14714+
|| Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect))) {
1470614715
index = new NamedConstraint()
1470714716
.withIndexName(indexName)
1470814717
.withType(typeToken.image
@@ -15748,16 +15757,55 @@ void ReferentialActions(ForeignKeyReference reference):
1574815757
)]
1574915758
}
1575015759

15760+
/** Shares column/period boundaries on both sides of a foreign key. */
15761+
List<Index.ColumnParams> ForeignKeyColumns(boolean allowParameters):
15762+
{
15763+
List<Index.ColumnParams> columns = new ArrayList<Index.ColumnParams>();
15764+
Index.ColumnParams column;
15765+
}
15766+
{
15767+
"(" column=ForeignKeyColumn(allowParameters) { columns.add(column); }
15768+
( "," {
15769+
requireDdlSyntax(!column.isPeriod(), "PERIOD must mark the final foreign key column");
15770+
} column=ForeignKeyColumn(allowParameters) { columns.add(column); } )* ")"
15771+
{ return columns; }
15772+
}
15773+
15774+
Index.ColumnParams ForeignKeyColumn(boolean allowParameters):
15775+
{
15776+
boolean period = false;
15777+
String name;
15778+
List<String> parameters = null;
15779+
}
15780+
{
15781+
[ LOOKAHEAD({ isKeywordAhead("PERIOD") && getToken(2).kind != K_COMMA
15782+
&& getToken(2).kind != CLOSING_BRACKET })
15783+
ContextualKeyword("PERIOD") { period = true; } ]
15784+
name=RelObjectName()
15785+
[ parameters=CreateParameter() {
15786+
requireDdlSyntax(allowParameters && !period, "Expected a foreign key column name");
15787+
} ]
15788+
{ return new Index.ColumnParams(name, parameters).withPeriod(period); }
15789+
}
15790+
1575115791
ForeignKeyReference ForeignKeyReferenceSpec():
1575215792
{
1575315793
ForeignKeyReference reference = new ForeignKeyReference();
1575415794
ForeignKeyReference.MatchType matchType;
1575515795
Token matchToken;
1575615796
List<String> refColNames = null;
15797+
List<Index.ColumnParams> referencedColumns;
1575715798
Table fkTable;
1575815799
}
1575915800
{
15760-
<K_REFERENCES> fkTable=Table() [ LOOKAHEAD(2) refColNames=ColumnsNamesList() ]
15801+
<K_REFERENCES> fkTable=Table()
15802+
[ LOOKAHEAD(2) referencedColumns=ForeignKeyColumns(false) {
15803+
refColNames = new ArrayList<String>();
15804+
for (Index.ColumnParams column : referencedColumns) {
15805+
refColNames.add(column.getColumnName());
15806+
}
15807+
reference.setUsingPeriod(referencedColumns.get(referencedColumns.size() - 1).isPeriod());
15808+
} ]
1576115809
{
1576215810
reference.setTable(fkTable);
1576315811
reference.setReferencedColumnNames(refColNames);
@@ -15823,7 +15871,7 @@ ForeignKeyIndex ForeignKeySpec(String constraintName):
1582315871
{
1582415872
tk=<K_FOREIGN> tk2=<K_KEY>
1582515873
[ LOOKAHEAD(2) indexName=RelObjectName() { fkIndex.setIndexName(indexName); } ]
15826-
colNames = ColumnNamesWithParamsList()
15874+
colNames = ForeignKeyColumns(true)
1582715875
{
1582815876
if (constraintName != null) { fkIndex.setName(constraintName); }
1582915877
fkIndex.withType(tk.image + " " + tk2.image).withColumns(colNames);
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/*-
2+
* #%L
3+
* JSQLParser library
4+
* %%
5+
* Copyright (C) 2004 - 2026 JSQLParser
6+
* %%
7+
* Dual licensed under GNU LGPL 2.1 or Apache License 2.0
8+
* #L%
9+
*/
10+
package net.sf.jsqlparser.statement.create;
11+
12+
import static org.junit.jupiter.api.Assertions.*;
13+
import java.util.List;
14+
import java.util.Set;
15+
import net.sf.jsqlparser.JSQLParserException;
16+
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
17+
import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect;
18+
import net.sf.jsqlparser.statement.Statement;
19+
import net.sf.jsqlparser.statement.alter.Alter;
20+
import net.sf.jsqlparser.statement.create.table.CreateTable;
21+
import net.sf.jsqlparser.statement.create.table.ForeignKeyIndex;
22+
import net.sf.jsqlparser.statement.create.table.Index;
23+
import net.sf.jsqlparser.util.TablesNamesFinder;
24+
import net.sf.jsqlparser.util.deparser.StatementDeParser;
25+
import org.junit.jupiter.api.Test;
26+
import org.junit.jupiter.params.ParameterizedTest;
27+
import org.junit.jupiter.params.provider.ValueSource;
28+
29+
class PostgreSqlTemporalConstraintTest {
30+
@ParameterizedTest
31+
@ValueSource(strings = {"PRIMARY KEY", "UNIQUE", "UNIQUE NULLS NOT DISTINCT"})
32+
void modelsTemporalKeysAcrossCreateAndAlter(String kind) throws JSQLParserException {
33+
for (String prefix : List.of("CREATE TABLE t (id INT, valid DATERANGE, ",
34+
"ALTER TABLE t ADD ")) {
35+
Statement statement = parse(prefix + "CONSTRAINT temporal_key " + kind
36+
+ " (id, valid WITHOUT OVERLAPS) INCLUDE (label) DEFERRABLE INITIALLY DEFERRED"
37+
+ (prefix.startsWith("CREATE") ? ", label TEXT)" : ", ADD COLUMN extra INT"));
38+
Index index = index(statement);
39+
assertEquals("temporal_key", index.getName());
40+
assertTrue(statement.toString().contains("CONSTRAINT temporal_key"));
41+
assertEquals(2, index.getColumns().size());
42+
assertFalse(index.getColumns().get(0).isWithoutOverlaps());
43+
Index.ColumnParams last = index.getColumns().get(1);
44+
assertTrue(last.isWithoutOverlaps());
45+
assertEquals("valid", last.getColumnName());
46+
assertNull(last.getOperatorClass());
47+
assertEquals(List.of("label"), index.getIncludeColumns());
48+
roundTrip(statement);
49+
last.setWithoutOverlaps(false);
50+
assertFalse(statement.toString().contains("WITHOUT OVERLAPS"));
51+
roundTrip(statement);
52+
}
53+
}
54+
55+
@ParameterizedTest
56+
@ValueSource(strings = {"", " (id, PERIOD valid)", " (id, PERIOD \"Valid Range\")"})
57+
void sharesReferencingAndReferencedPeriodLists(String referenced) throws JSQLParserException {
58+
for (String prefix : List.of("CREATE TABLE t (id INT, valid DATERANGE, ",
59+
"ALTER TABLE t ADD ")) {
60+
Statement statement =
61+
parse(prefix + "FOREIGN KEY (id, PERIOD valid) REFERENCES app.parent"
62+
+ referenced + " ON DELETE NO ACTION ON UPDATE NO ACTION"
63+
+ (prefix.startsWith("CREATE") ? ")" : ", ADD COLUMN extra INT"));
64+
ForeignKeyIndex foreign = (ForeignKeyIndex) index(statement);
65+
assertFalse(foreign.getColumns().get(0).isPeriod());
66+
assertTrue(foreign.getColumns().get(1).isPeriod());
67+
assertEquals("valid", foreign.getColumns().get(1).getColumnName());
68+
assertEquals(!referenced.isEmpty(), foreign.getReference().isUsingPeriod());
69+
assertEquals(Set.of("t", "app.parent"), new TablesNamesFinder().getTables(statement));
70+
roundTrip(statement);
71+
foreign.getColumns().get(1).setPeriod(false);
72+
foreign.getReference().setUsingPeriod(false);
73+
assertFalse(statement.toString().contains("PERIOD"));
74+
roundTrip(statement);
75+
}
76+
}
77+
78+
@Test
79+
void retainsQuotedAndUnquotedIdentifiersAndLegacyProjections() throws JSQLParserException {
80+
CreateTable table = (CreateTable) parse("CREATE TABLE t (id INT, period DATERANGE, "
81+
+ "FOREIGN KEY (id, period) REFERENCES p(id, period))");
82+
ForeignKeyIndex foreign = (ForeignKeyIndex) index(table);
83+
assertFalse(foreign.getColumns().get(1).isPeriod());
84+
assertFalse(foreign.getReference().isUsingPeriod());
85+
roundTrip(table);
86+
table = (CreateTable) parse("CREATE TABLE t (id INT, \"PERIOD\" DATERANGE, "
87+
+ "FOREIGN KEY (id, PERIOD \"PERIOD\") REFERENCES p(id, PERIOD \"PERIOD\"))");
88+
foreign = (ForeignKeyIndex) index(table);
89+
assertEquals(List.of("id", "\"PERIOD\""), foreign.getReferencedColumnNames());
90+
foreign.getReferencedColumnNames().set(1, "replacement");
91+
assertTrue(table.toString().contains("PERIOD replacement"));
92+
roundTrip(table);
93+
assertEquals(2, CCJSqlParserUtil.parseStatements(table + "; SELECT 1",
94+
p -> p.withDialect(Dialect.POSTGRESQL)).size());
95+
}
96+
97+
@Test
98+
void preservesNamedUniqueConstraintsWithAndWithoutTemporalKeys() throws JSQLParserException {
99+
Statement ordinary = parse("ALTER TABLE t ADD CONSTRAINT uq UNIQUE (id)");
100+
assertEquals("uq", index(ordinary).getName());
101+
assertEquals("ALTER TABLE t ADD CONSTRAINT uq UNIQUE (id)", ordinary.toString());
102+
roundTrip(ordinary);
103+
Statement temporal = parse("ALTER TABLE t ADD CONSTRAINT uq "
104+
+ "UNIQUE (id, valid WITHOUT OVERLAPS)");
105+
assertEquals("uq", index(temporal).getName());
106+
assertTrue(index(temporal).getColumns().get(1).isWithoutOverlaps());
107+
roundTrip(temporal);
108+
}
109+
110+
@Test
111+
void keepsOrdinaryIndexesAndCrossDialectForeignKeys() throws JSQLParserException {
112+
String mysql = "CREATE TABLE t (id INT, FOREIGN KEY (id) REFERENCES p (id))";
113+
assertNotNull(CCJSqlParserUtil.parse(mysql));
114+
assertEquals("valid WITHOUT OVERLAPS",
115+
new Index.ColumnParams("valid").withWithoutOverlaps(true).toString());
116+
assertEquals("PERIOD valid", new Index.ColumnParams("valid").withPeriod(true).toString());
117+
assertThrows(JSQLParserException.class,
118+
() -> parse("CREATE INDEX ix ON t (id, valid WITHOUT OVERLAPS)"));
119+
}
120+
121+
@ParameterizedTest
122+
@ValueSource(strings = {"UNIQUE (valid WITHOUT OVERLAPS, id)",
123+
"PRIMARY KEY (id, valid WITHOUT)",
124+
"UNIQUE (id, valid WITHOUT OVERLAPS WITHOUT OVERLAPS)",
125+
"FOREIGN KEY (PERIOD valid, id) REFERENCES p(id, valid)",
126+
"FOREIGN KEY (id, PERIOD valid) REFERENCES p(PERIOD valid, id)"})
127+
void rejectsMalformedTemporalMarkers(String constraint) {
128+
assertThrows(JSQLParserException.class,
129+
() -> parse("CREATE TABLE t (id INT, valid DATERANGE, " + constraint + ")"));
130+
}
131+
132+
private static Index index(Statement statement) {
133+
return statement instanceof CreateTable ? ((CreateTable) statement).getIndexes().get(0)
134+
: ((Alter) statement).getAlterExpressions().get(0).getIndex();
135+
}
136+
137+
private static Statement parse(String sql) throws JSQLParserException {
138+
return CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.POSTGRESQL));
139+
}
140+
141+
private static void roundTrip(Statement statement) throws JSQLParserException {
142+
StringBuilder out = new StringBuilder();
143+
statement.accept(new StatementDeParser(out));
144+
assertEquals(statement.toString(), out.toString());
145+
assertEquals(out.toString(), parse(out.toString()).toString());
146+
}
147+
}

0 commit comments

Comments
 (0)