Skip to content

Commit c1a611d

Browse files
authored
Support SQL Server constraint clustering with explicit dialect (#2582)
1 parent 329ee6b commit c1a611d

5 files changed

Lines changed: 195 additions & 5 deletions

File tree

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

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ public enum Kind {
2727
PRIMARY_KEY, UNIQUE, INDEX, FULLTEXT, SPATIAL, FOREIGN_KEY, CHECK, EXCLUDE, DEFAULT, OTHER
2828
}
2929

30+
public enum Clustering {
31+
CLUSTERED, NONCLUSTERED
32+
}
33+
3034
private final List<String> name = new ArrayList<>();
3135
private String type;
3236
private String using;
@@ -35,12 +39,31 @@ public enum Kind {
3539
private String commentText;
3640
private String indexKeyword;
3741
private Kind kind = Kind.OTHER;
42+
private Clustering clustering;
3843
private Boolean nullsDistinct;
3944
private List<String> includeColumns;
4045
private List<Option> storageParameters;
4146
private String tableSpace;
4247
private ConstraintAttributes constraintAttributes;
4348

49+
/** Returns the explicit SQL Server clustering option, or null when it was omitted. */
50+
public Clustering getClustering() {
51+
return clustering;
52+
}
53+
54+
public void setClustering(Clustering clustering) {
55+
this.clustering = clustering;
56+
}
57+
58+
public Index withClustering(Clustering clustering) {
59+
setClustering(clustering);
60+
return this;
61+
}
62+
63+
public String clusteringClause() {
64+
return clustering == null ? "" : " " + clustering;
65+
}
66+
4467
public Boolean getNullsDistinct() {
4568
return nullsDistinct;
4669
}
@@ -277,7 +300,8 @@ public String toString() {
277300
: "")
278301
+ (!idxSpecText.isEmpty() ? " " + idxSpecText : "");
279302

280-
StringBuilder sql = new StringBuilder(head).append(nullsDistinctClause());
303+
StringBuilder sql = new StringBuilder(head).append(nullsDistinctClause())
304+
.append(clusteringClause());
281305
if (!tail.isEmpty()) {
282306
sql.append(' ').append(tail);
283307
}

src/main/java/net/sf/jsqlparser/statement/create/table/NamedConstraint.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ public String toString() {
9191
String tail = getType()
9292
+ nullsDistinctClause()
9393
+ keyword
94+
+ clusteringClause()
9495
+ (indexName != null ? " " + indexName : "")
9596
+ (getUsing() != null ? " USING " + getUsing() : "")
9697
+ (getColumns() == null ? ""
@@ -113,6 +114,12 @@ public NamedConstraint withIndexName(String indexName) {
113114
return this;
114115
}
115116

117+
@Override
118+
public NamedConstraint withClustering(Clustering clustering) {
119+
setClustering(clustering);
120+
return this;
121+
}
122+
116123
public NamedConstraint withUseConstraintKeyword(boolean useConstraintKeyword) {
117124
setUseConstraintKeyword(useConstraintKeyword);
118125
return this;

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

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12471,6 +12471,22 @@ CreateDatabase CreateDatabase():
1247112471
}
1247212472
}
1247312473

12474+
/** Shares optional SQL Server constraint modifiers across CREATE and ALTER. */
12475+
Index.Clustering SqlServerIndexClustering():
12476+
{
12477+
Token token;
12478+
Index.Clustering clustering = null;
12479+
}
12480+
{
12481+
[ LOOKAHEAD({ Dialect.SQLSERVER.name().equals(getAsString(Feature.dialect))
12482+
&& (isKeywordAhead("CLUSTERED") || isKeywordAhead("NONCLUSTERED")) })
12483+
token=<S_IDENTIFIER> {
12484+
clustering = Index.Clustering.valueOf(token.image.toUpperCase(Locale.ROOT));
12485+
}
12486+
]
12487+
{ return clustering; }
12488+
}
12489+
1247412490
/** Parses PRIMARY, UNIQUE, plain, FULLTEXT, and SPATIAL indexes for CREATE and ALTER. */
1247512491
Index TableIndexSpec(boolean createContext):
1247612492
{
@@ -12482,11 +12498,13 @@ Index TableIndexSpec(boolean createContext):
1248212498
List<String> indexOptions = new ArrayList<String>();
1248312499
Index index;
1248412500
Boolean nullsDistinct = null;
12501+
Index.Clustering clustering = null;
1248512502
}
1248612503
{
1248712504
(
1248812505
typeToken=<K_PRIMARY> keywordToken=<K_KEY>
12489-
[ LOOKAHEAD({ getToken(1).kind != OPENING_BRACKET }) indexName=RelObjectName() ]
12506+
clustering=SqlServerIndexClustering()
12507+
[ LOOKAHEAD({ clustering == null && getToken(1).kind != OPENING_BRACKET }) indexName=RelObjectName() ]
1249012508
columns=IndexColumnsWithParamsList()
1249112509
TableIndexOptions(createContext, indexOptions)
1249212510
{
@@ -12502,7 +12520,8 @@ Index TableIndexSpec(boolean createContext):
1250212520
if (nullsDistinct == null) { nullsDistinct = true; }
1250312521
} ]
1250412522
[ LOOKAHEAD(2) (keywordToken=<K_KEY> | keywordToken=<K_INDEX>) ]
12505-
[ LOOKAHEAD({ getToken(1).kind != OPENING_BRACKET
12523+
clustering=SqlServerIndexClustering()
12524+
[ LOOKAHEAD({ clustering == null && getToken(1).kind != OPENING_BRACKET
1250612525
&& getToken(1).kind != K_USING }) indexName=RelObjectName() ]
1250712526
[ using=UsingIndexType() ]
1250812527
columns=IndexColumnsWithParamsList()
@@ -12562,7 +12581,7 @@ Index TableIndexSpec(boolean createContext):
1256212581
.withName(indexName).withColumns(columns).withIndexSpec(indexOptions);
1256312582
}
1256412583
)
12565-
{ index.setNullsDistinct(nullsDistinct); }
12584+
{ index.setNullsDistinct(nullsDistinct); index.setClustering(clustering); }
1256612585
PostgreSqlConstraintOptions(index)
1256712586
{ return index; }
1256812587
}
@@ -14600,6 +14619,7 @@ void AlterExpressionAddConstraint(AlterExpression alterExp):
1460014619
Table fkTable;
1460114620
List<ConstraintState> constraints = null;
1460214621
CheckConstraint checkCs = null;
14622+
Index.Clustering clustering = null;
1460314623
}
1460414624
{
1460514625
<K_CONSTRAINT>
@@ -14627,11 +14647,13 @@ void AlterExpressionAddConstraint(AlterExpression alterExp):
1462714647
)
1462814648
|
1462914649
( tk=<K_PRIMARY> tk2=<K_KEY>
14650+
clustering=SqlServerIndexClustering()
1463014651
columnNames=ColumnsNamesList()
1463114652
{
1463214653
index = new NamedConstraint()
1463314654
.withName(sk3)
1463414655
.withType(tk.image + " " + tk2.image)
14656+
.withClustering(clustering)
1463514657
.withColumnsNames(columnNames);
1463614658
alterExp.setIndex(index);
1463714659
}
@@ -14655,11 +14677,13 @@ void AlterExpressionAddConstraint(AlterExpression alterExp):
1465514677
|
1465614678
(
1465714679
tk=<K_UNIQUE> (tk2=<K_KEY> { alterExp.setUk(true); } | tk2=<K_INDEX>)?
14680+
clustering=SqlServerIndexClustering()
1465814681
columnNames=ColumnsNamesList()
1465914682
{
1466014683
index = new NamedConstraint()
1466114684
.withName(sk3)
1466214685
.withType(tk.image + (tk2!=null?" " + tk2.image:""))
14686+
.withClustering(clustering)
1466314687
.withColumnsNames(columnNames);
1466414688
alterExp.setIndex(index);
1466514689
}

src/site/sphinx/usage.rst

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -702,7 +702,7 @@ One grammar covers every supported RDBMS, but a few pieces of syntax mean differ
702702
* - ``MYSQL``
703703
- ``withBackslashEscapeCharacter``, ``withHashLineComments``, ``withDoubleQuotedStrings`` (MySQL and MariaDB, the last for the default ``sql_mode``)
704704
* - ``SQLSERVER``
705-
- ``withSquareBracketQuotation``
705+
- ``withSquareBracketQuotation`` and ``CLUSTERED`` / ``NONCLUSTERED`` options on table-level primary key and unique constraints
706706
* - ``POSTGRESQL``, ``ANSI_SQL``
707707
- the newline rule for adjacent string literals
708708
* - ``BIGQUERY``
@@ -716,6 +716,11 @@ One grammar covers every supported RDBMS, but a few pieces of syntax mean differ
716716

717717
Features set explicitly *after* the preset win over it.
718718

719+
With ``Dialect.SQLSERVER``, ``PRIMARY KEY NONCLUSTERED (id)`` and
720+
``UNIQUE CLUSTERED (id)`` store their clustering option in ``Index.getClustering()``
721+
for both ``CREATE TABLE`` and ``ALTER TABLE``. Without that dialect, these words
722+
retain their existing interpretation as optional index names.
723+
719724
Informix's constraint form requires an explicit dialect selection:
720725

721726
.. code-block:: java
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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.assertEquals;
13+
import static org.junit.jupiter.api.Assertions.assertNull;
14+
import static org.junit.jupiter.api.Assertions.assertThrows;
15+
16+
import java.util.List;
17+
import net.sf.jsqlparser.JSQLParserException;
18+
import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect;
19+
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
20+
import net.sf.jsqlparser.statement.Statement;
21+
import net.sf.jsqlparser.statement.alter.Alter;
22+
import net.sf.jsqlparser.statement.create.table.CreateTable;
23+
import net.sf.jsqlparser.statement.create.table.Index;
24+
import net.sf.jsqlparser.statement.create.table.NamedConstraint;
25+
import net.sf.jsqlparser.test.TestUtils;
26+
import net.sf.jsqlparser.util.deparser.StatementDeParser;
27+
import org.junit.jupiter.api.Test;
28+
import org.junit.jupiter.params.ParameterizedTest;
29+
import org.junit.jupiter.params.provider.EnumSource;
30+
31+
class SqlServerConstraintClusteringTest {
32+
private static Index constraint(Statement statement) {
33+
return statement instanceof CreateTable
34+
? ((CreateTable) statement).getIndexes().get(0)
35+
: ((Alter) statement).getAlterExpressions().get(0).getIndex();
36+
}
37+
38+
private static Statement parse(String sql) throws JSQLParserException {
39+
return CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(Dialect.SQLSERVER));
40+
}
41+
42+
@Test
43+
void parsesSakilaReproducerIssue1589() throws Exception {
44+
String sql = "CREATE TABLE actor (actor_id INT NOT NULL IDENTITY, "
45+
+ "first_name VARCHAR (45) NOT NULL, last_name VARCHAR (45) NOT NULL, "
46+
+ "last_update DATETIME NOT NULL, PRIMARY KEY NONCLUSTERED (actor_id))";
47+
Index index = constraint(TestUtils.assertSqlCanBeParsedAndDeparsed(sql, true,
48+
parser -> parser.withDialect(Dialect.SQLSERVER)));
49+
assertEquals(Index.Clustering.NONCLUSTERED, index.getClustering());
50+
assertNull(((NamedConstraint) index).getIndexName());
51+
assertEquals(List.of("actor_id"), index.getColumnsNames());
52+
}
53+
54+
@ParameterizedTest
55+
@EnumSource(Index.Clustering.class)
56+
void sharesClusteringAcrossCreateAndAlter(Index.Clustering clustering) throws Exception {
57+
for (String type : List.of("PRIMARY KEY", "UNIQUE")) {
58+
for (String name : List.of("", "CONSTRAINT [key name] ")) {
59+
String definition = name + type + " " + clustering + " (id)";
60+
for (String sql : List.of("CREATE TABLE t (id INT, " + definition + ")",
61+
"ALTER TABLE t ADD " + definition)) {
62+
Statement statement = TestUtils.assertSqlCanBeParsedAndDeparsed(sql, true,
63+
parser -> parser.withDialect(Dialect.SQLSERVER));
64+
Index index = constraint(statement);
65+
assertEquals(clustering, index.getClustering(), sql);
66+
assertEquals(type, index.getType());
67+
assertEquals(name.isEmpty() ? null : "[key name]", index.getName());
68+
if (index instanceof NamedConstraint) {
69+
assertNull(((NamedConstraint) index).getIndexName());
70+
}
71+
StringBuilder output = new StringBuilder();
72+
statement.accept(new StatementDeParser(output), null);
73+
assertEquals(clustering, constraint(parse(output.toString())).getClustering());
74+
assertEquals(clustering,
75+
constraint(parse(statement.toString())).getClustering());
76+
}
77+
}
78+
}
79+
}
80+
81+
@Test
82+
void preservesNamesOutsideSqlServerAndQuotedNames() throws Exception {
83+
for (String type : List.of("PRIMARY KEY", "UNIQUE")) {
84+
String sql = "CREATE TABLE t (id INT, " + type + " NONCLUSTERED (id))";
85+
NamedConstraint defaultIndex =
86+
(NamedConstraint) constraint(CCJSqlParserUtil.parse(sql));
87+
assertNull(defaultIndex.getClustering());
88+
assertEquals("NONCLUSTERED", defaultIndex.getIndexName());
89+
for (Dialect dialect : Dialect.values()) {
90+
if (dialect != Dialect.SQLSERVER) {
91+
NamedConstraint index = (NamedConstraint) constraint(CCJSqlParserUtil.parse(sql,
92+
parser -> parser.withDialect(dialect)));
93+
assertNull(index.getClustering(), dialect.name());
94+
assertEquals("NONCLUSTERED", index.getIndexName());
95+
}
96+
}
97+
NamedConstraint quoted = (NamedConstraint) constraint(
98+
parse("CREATE TABLE t (id INT, " + type + " [NONCLUSTERED] (id))"));
99+
assertNull(quoted.getClustering());
100+
assertEquals("[NONCLUSTERED]", quoted.getIndexName());
101+
}
102+
}
103+
104+
@Test
105+
void supportsMutationAndLeavesOmittedOptionUnspecified() throws Exception {
106+
Statement statement = parse("CREATE TABLE t (id INT, PRIMARY KEY (id))");
107+
Index index = constraint(statement);
108+
assertNull(index.getClustering());
109+
index.setClustering(Index.Clustering.CLUSTERED);
110+
TestUtils.assertDeparse(statement, "CREATE TABLE t (id INT, PRIMARY KEY CLUSTERED (id))");
111+
index.setClustering(null);
112+
TestUtils.assertDeparse(statement, "CREATE TABLE t (id INT, PRIMARY KEY (id))");
113+
NamedConstraint built = new NamedConstraint().withType("UNIQUE").withName("uq_t")
114+
.withClustering(Index.Clustering.NONCLUSTERED).withColumnsNames(List.of("id"));
115+
assertEquals("CONSTRAINT uq_t UNIQUE NONCLUSTERED (id)", built.toString());
116+
}
117+
118+
@Test
119+
void keepsFollowingConstraintsAndRejectsDuplicateModifiers() throws Exception {
120+
CreateTable table = (CreateTable) parse("CREATE TABLE t (id INT, other_id INT, "
121+
+ "PRIMARY KEY NONCLUSTERED (id), UNIQUE (other_id))");
122+
assertNull(table.getIndexes().get(1).getClustering());
123+
for (String sql : List.of(
124+
"CREATE TABLE t (id INT, PRIMARY KEY NONCLUSTERED CLUSTERED (id))",
125+
"ALTER TABLE t ADD CONSTRAINT pk PRIMARY KEY CLUSTERED NONCLUSTERED (id)",
126+
"ALTER TABLE t ADD UNIQUE NONCLUSTERED")) {
127+
assertThrows(JSQLParserException.class, () -> parse(sql));
128+
}
129+
}
130+
}

0 commit comments

Comments
 (0)