Skip to content

Commit 66540a9

Browse files
committed
feat: DDL statement parser with JSqlParser 5.2 normalization shim
1 parent ccc55f2 commit 66540a9

5 files changed

Lines changed: 246 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.dbaagent.service.migration;
2+
3+
public record DdlFacts(
4+
DdlOperation operation,
5+
String table,
6+
String column,
7+
String newColumnName,
8+
String dataType,
9+
String defaultExpression,
10+
String defaultFunction,
11+
boolean notNull,
12+
boolean notValid,
13+
boolean concurrently,
14+
String referencedTable,
15+
String rawSql) {}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.dbaagent.service.migration;
2+
3+
public enum DdlOperation {
4+
ADD_COLUMN, DROP_COLUMN, RENAME_COLUMN, ALTER_COLUMN_TYPE, SET_NOT_NULL,
5+
ADD_CHECK, ADD_FOREIGN_KEY, VALIDATE_CONSTRAINT, CREATE_INDEX, UNKNOWN
6+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package com.dbaagent.service.migration;
2+
3+
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
4+
import net.sf.jsqlparser.statement.Statement;
5+
import net.sf.jsqlparser.statement.alter.Alter;
6+
import net.sf.jsqlparser.statement.alter.AlterExpression;
7+
import net.sf.jsqlparser.statement.create.index.CreateIndex;
8+
import org.springframework.stereotype.Component;
9+
10+
import java.util.List;
11+
import java.util.Locale;
12+
import java.util.Optional;
13+
import java.util.regex.Pattern;
14+
15+
@Component
16+
public class DdlStatementParser {
17+
18+
private static final Pattern NOT_VALID = Pattern.compile("(?i)\\s+NOT\\s+VALID\\s*;?\\s*$");
19+
private static final Pattern CONCURRENTLY =
20+
Pattern.compile("(?i)\\bCREATE\\s+(UNIQUE\\s+)?INDEX\\s+CONCURRENTLY\\b");
21+
private static final Pattern DEFAULT_FN =
22+
Pattern.compile("(?i)DEFAULT\\s+([a-z_][a-z0-9_]*)\\s*\\(");
23+
private static final Pattern REFERENCES =
24+
Pattern.compile("(?i)REFERENCES\\s+([a-z_][a-z0-9_.\"]*)\\s*\\(");
25+
private static final Pattern VALIDATE_CONSTRAINT =
26+
Pattern.compile("(?i)^\\s*ALTER\\s+TABLE\\s+(\\S+)\\s+VALIDATE\\s+CONSTRAINT\\b");
27+
28+
public Optional<DdlFacts> parse(String sql) {
29+
if (sql == null || sql.isBlank()) return Optional.empty();
30+
31+
boolean notValid = NOT_VALID.matcher(sql).find();
32+
String normalized = notValid ? NOT_VALID.matcher(sql).replaceAll("") : sql;
33+
34+
boolean concurrently = CONCURRENTLY.matcher(normalized).find();
35+
if (concurrently) {
36+
normalized = CONCURRENTLY.matcher(normalized)
37+
.replaceAll(m -> "CREATE " + (m.group(1) == null ? "" : m.group(1)) + "INDEX");
38+
}
39+
40+
// VALIDATE CONSTRAINT degrades to UNSPECIFIC in JSqlParser; match it textually first.
41+
var vc = VALIDATE_CONSTRAINT.matcher(sql);
42+
if (vc.find()) {
43+
return Optional.of(new DdlFacts(DdlOperation.VALIDATE_CONSTRAINT, strip(vc.group(1)),
44+
null, null, null, null, null, false, notValid, false, null, sql));
45+
}
46+
47+
Statement stmt;
48+
try {
49+
stmt = CCJSqlParserUtil.parse(normalized);
50+
} catch (Exception e) {
51+
return Optional.empty();
52+
}
53+
54+
if (stmt instanceof CreateIndex ci) {
55+
return Optional.of(new DdlFacts(DdlOperation.CREATE_INDEX,
56+
strip(ci.getTable().getName()), null, null, null, null, null,
57+
false, notValid, concurrently, null, sql));
58+
}
59+
if (stmt instanceof Alter alter) {
60+
return fromAlter(alter, sql, notValid);
61+
}
62+
return Optional.empty();
63+
}
64+
65+
private Optional<DdlFacts> fromAlter(Alter alter, String rawSql, boolean notValid) {
66+
List<AlterExpression> exprs = alter.getAlterExpressions();
67+
if (exprs == null || exprs.isEmpty()) return Optional.empty();
68+
AlterExpression e = exprs.get(0);
69+
String table = strip(alter.getTable().getName());
70+
String op = e.getOperation() == null ? "" : e.getOperation().name().toUpperCase(Locale.ROOT);
71+
72+
if ("DROP".equals(op)) {
73+
return Optional.of(new DdlFacts(DdlOperation.DROP_COLUMN, table, strip(e.getColumnName()),
74+
null, null, null, null, false, notValid, false, null, rawSql));
75+
}
76+
if ("RENAME".equals(op)) {
77+
// JSqlParser exposes only the NEW name here; the old name is not recoverable.
78+
return Optional.of(new DdlFacts(DdlOperation.RENAME_COLUMN, table, null,
79+
strip(e.getColumnName()), null, null, null, false, notValid, false, null, rawSql));
80+
}
81+
if ("ADD".equals(op)) {
82+
if (e.getIndex() != null && e.getIndex().getType() != null
83+
&& e.getIndex().getType().toUpperCase(Locale.ROOT).contains("FOREIGN")) {
84+
return Optional.of(new DdlFacts(DdlOperation.ADD_FOREIGN_KEY, table, null, null, null,
85+
null, null, false, notValid, false, referencedTable(rawSql), rawSql));
86+
}
87+
if (rawSql.toUpperCase(Locale.ROOT).contains("CHECK")) {
88+
return Optional.of(new DdlFacts(DdlOperation.ADD_CHECK, table, null, null, null,
89+
null, null, false, notValid, false, null, rawSql));
90+
}
91+
if (e.getColDataTypeList() != null && !e.getColDataTypeList().isEmpty()) {
92+
var cdt = e.getColDataTypeList().get(0);
93+
List<String> specs = cdt.getColumnSpecs() == null ? List.of() : cdt.getColumnSpecs();
94+
String joined = String.join(" ", specs).toUpperCase(Locale.ROOT);
95+
return Optional.of(new DdlFacts(DdlOperation.ADD_COLUMN, table,
96+
strip(cdt.getColumnName()), null,
97+
String.valueOf(cdt.getColDataType()), joined.contains("DEFAULT") ? joined : null,
98+
defaultFunction(rawSql), joined.contains("NOT NULL"),
99+
notValid, false, null, rawSql));
100+
}
101+
}
102+
if ("ALTER".equals(op) && e.getColDataTypeList() != null && !e.getColDataTypeList().isEmpty()) {
103+
var cdt = e.getColDataTypeList().get(0);
104+
String type = String.valueOf(cdt.getColDataType());
105+
// "SET NOT NULL" arrives with type=SET, specs=[NOT, NULL].
106+
if ("SET".equalsIgnoreCase(type)) {
107+
return Optional.of(new DdlFacts(DdlOperation.SET_NOT_NULL, table,
108+
strip(cdt.getColumnName()), null, null, null, null, true,
109+
notValid, false, null, rawSql));
110+
}
111+
return Optional.of(new DdlFacts(DdlOperation.ALTER_COLUMN_TYPE, table,
112+
strip(cdt.getColumnName()), null, type, null, null, false,
113+
notValid, false, null, rawSql));
114+
}
115+
return Optional.empty();
116+
}
117+
118+
private String defaultFunction(String sql) {
119+
var m = DEFAULT_FN.matcher(sql);
120+
return m.find() ? m.group(1).toLowerCase(Locale.ROOT) : null;
121+
}
122+
123+
private String referencedTable(String sql) {
124+
var m = REFERENCES.matcher(sql);
125+
return m.find() ? strip(m.group(1)) : null;
126+
}
127+
128+
private String strip(String s) {
129+
return s == null ? null : s.replace("\"", "");
130+
}
131+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package com.dbaagent.service.migration;
2+
3+
import org.junit.jupiter.api.Test;
4+
import static org.assertj.core.api.Assertions.assertThat;
5+
6+
class DdlStatementParserTest {
7+
8+
private final DdlStatementParser parser = new DdlStatementParser();
9+
10+
@Test
11+
void addColumnWithVolatileDefault_extractsFunctionName() {
12+
var f = parser.parse("ALTER TABLE orders ADD COLUMN c uuid DEFAULT gen_random_uuid()").orElseThrow();
13+
assertThat(f.operation()).isEqualTo(DdlOperation.ADD_COLUMN);
14+
assertThat(f.table()).isEqualTo("orders");
15+
assertThat(f.column()).isEqualTo("c");
16+
assertThat(f.defaultFunction()).isEqualTo("gen_random_uuid");
17+
}
18+
19+
@Test
20+
void addColumnWithoutDefault_hasNoDefaultFunction() {
21+
var f = parser.parse("ALTER TABLE orders ADD COLUMN c text").orElseThrow();
22+
assertThat(f.defaultFunction()).isNull();
23+
assertThat(f.notNull()).isFalse();
24+
}
25+
26+
@Test
27+
void addColumnNotNull_setsFlag() {
28+
var f = parser.parse("ALTER TABLE orders ADD COLUMN c text NOT NULL").orElseThrow();
29+
assertThat(f.notNull()).isTrue();
30+
}
31+
32+
// JSqlParser 5.2 cannot parse NOT VALID — the shim must strip it into a flag.
33+
@Test
34+
void addCheckNotValid_parsesViaShim() {
35+
var f = parser.parse("ALTER TABLE orders ADD CONSTRAINT ck CHECK (id > 0) NOT VALID").orElseThrow();
36+
assertThat(f.operation()).isEqualTo(DdlOperation.ADD_CHECK);
37+
assertThat(f.notValid()).isTrue();
38+
}
39+
40+
@Test
41+
void addForeignKeyNotValid_capturesReferencedTable() {
42+
var f = parser.parse(
43+
"ALTER TABLE child ADD CONSTRAINT fk FOREIGN KEY (t_id) REFERENCES t(id) NOT VALID").orElseThrow();
44+
assertThat(f.operation()).isEqualTo(DdlOperation.ADD_FOREIGN_KEY);
45+
assertThat(f.notValid()).isTrue();
46+
assertThat(f.referencedTable()).isEqualTo("t");
47+
}
48+
49+
// JSqlParser 5.2 cannot parse CONCURRENTLY — the shim must strip it into a flag.
50+
@Test
51+
void createIndexConcurrently_parsesViaShim() {
52+
var f = parser.parse("CREATE INDEX CONCURRENTLY idx ON orders (a)").orElseThrow();
53+
assertThat(f.operation()).isEqualTo(DdlOperation.CREATE_INDEX);
54+
assertThat(f.concurrently()).isTrue();
55+
assertThat(f.table()).isEqualTo("orders");
56+
}
57+
58+
@Test
59+
void createIndexPlain_isNotConcurrent() {
60+
var f = parser.parse("CREATE INDEX idx ON orders (a)").orElseThrow();
61+
assertThat(f.concurrently()).isFalse();
62+
}
63+
64+
@Test
65+
void setNotNull_isDistinguishedFromAlterType() {
66+
var f = parser.parse("ALTER TABLE orders ALTER COLUMN a SET NOT NULL").orElseThrow();
67+
assertThat(f.operation()).isEqualTo(DdlOperation.SET_NOT_NULL);
68+
}
69+
70+
@Test
71+
void alterColumnType_capturesTargetType() {
72+
var f = parser.parse("ALTER TABLE orders ALTER COLUMN a TYPE varchar(50)").orElseThrow();
73+
assertThat(f.operation()).isEqualTo(DdlOperation.ALTER_COLUMN_TYPE);
74+
assertThat(f.dataType()).containsIgnoringCase("varchar");
75+
}
76+
77+
@Test
78+
void garbageInput_returnsEmptySoCallerFailsClosed() {
79+
assertThat(parser.parse("this is not sql at all")).isEmpty();
80+
}
81+
82+
@Test
83+
void selectStatement_returnsEmpty() {
84+
assertThat(parser.parse("SELECT * FROM orders")).isEmpty();
85+
}
86+
}

‎scripts/jtest.sh‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#!/usr/bin/env bash
2+
# No JDK on the host — run Maven goals in the same JDK image the backend builds with.
3+
set -euo pipefail
4+
exec docker run --rm \
5+
-v "$(git rev-parse --show-toplevel)/backend:/app" \
6+
-v "$HOME/.m2:/root/.m2" \
7+
-w /app maven:3-eclipse-temurin-25 \
8+
mvn -q -o test "$@"

0 commit comments

Comments
 (0)