fix(saphana): include column comments in table DDL

Closes #8710
This commit is contained in:
t8y2
2026-09-23 09:22:34 +08:00
parent 2c0d2bfb63
commit a1c641cbef
4 changed files with 425 additions and 1 deletions
@@ -151,7 +151,14 @@ public abstract class ConfiguredJdbcAgent extends AbstractJdbcAgent {
// Table comment is optional; DDL generation should still succeed without it.
}
return DdlBuilder.buildTableDdl(schema, table, getColumns(schema, table), indexes, foreignKeys, Collections.emptyList(), false, false, tableComment);
return DdlBuilder.buildTableDdl(
schema, table, getColumns(schema, table), indexes, foreignKeys,
Collections.emptyList(), false, includeColumnCommentsInTableDdl(), tableComment
);
}
protected boolean includeColumnCommentsInTableDdl() {
return false;
}
@Override
@@ -1,10 +1,78 @@
package com.dbx.agent;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ConfiguredJdbcAgentTest {
@Test
void tableDdlKeepsColumnCommentsDisabledForGenericConfiguredAgents() {
List<String> calls = new ArrayList<>();
ConfiguredJdbcAgent agent = ddlAgent(calls, false);
assertEquals(
"CREATE TABLE \"APP\".\"ITEMS\" (\n \"ID\" INTEGER NOT NULL DEFAULT 1,\n PRIMARY KEY (\"ID\")\n);\n"
+ "\nCOMMENT ON TABLE \"APP\".\"ITEMS\" IS 'table''s comment';",
agent.getTableDdl("APP", "ITEMS")
);
assertEquals(Arrays.asList("indexes", "foreignKeys", "tableComment", "columns"), calls);
}
@Test
void tableDdlKeepsGenericOptionalMetadataFailureFallbacks() {
List<String> calls = new ArrayList<>();
ConfiguredJdbcAgent agent = ddlAgent(calls, true);
assertEquals(
"CREATE TABLE \"APP\".\"ITEMS\" (\n \"ID\" INTEGER NOT NULL DEFAULT 1,\n PRIMARY KEY (\"ID\")\n);\n",
agent.getTableDdl("APP", "ITEMS")
);
assertEquals(Arrays.asList("indexes", "foreignKeys", "tableComment", "columns"), calls);
}
private static ConfiguredJdbcAgent ddlAgent(List<String> calls, boolean failOptionalMetadata) {
return new ConfiguredJdbcAgent(new JdbcAgentProfile("com.example.Driver", "jdbc:example:{database}")) {
@Override
public List<ColumnInfo> getColumns(String schema, String table) {
calls.add("columns");
return Collections.singletonList(new ColumnInfo(
"ID", "INTEGER", false, "1", true, null, "column's 注释\nsecond line", null, null, null
));
}
@Override
public List<IndexInfo> listIndexes(String schema, String table) {
calls.add("indexes");
if (failOptionalMetadata) {
throw new IllegalStateException("indexes unavailable");
}
return Collections.emptyList();
}
@Override
public List<ForeignKeyInfo> listForeignKeys(String schema, String table) {
calls.add("foreignKeys");
if (failOptionalMetadata) {
throw new IllegalStateException("foreign keys unavailable");
}
return Collections.emptyList();
}
@Override
public String getTableComment(String schema, String table) {
calls.add("tableComment");
if (failOptionalMetadata) {
throw new IllegalStateException("table comment unavailable");
}
return "table's comment";
}
};
}
@Test
void buildsJdbcUrlFromProfileTemplateAndUrlParams() {
JdbcAgentProfile profile = new JdbcAgentProfile(
@@ -24,6 +24,11 @@ public final class SapHanaAgent extends ConfiguredJdbcAgent {
super(SAPHANA_PROFILE);
}
@Override
protected boolean includeColumnCommentsInTableDdl() {
return true;
}
@Override
public ObjectSource getObjectSource(String schema, String name, String objectType) {
String normalizedType = objectType == null ? "" : objectType.trim().toUpperCase(Locale.ROOT);
@@ -0,0 +1,344 @@
package com.dbx.agent.saphana;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.test.TestSupport;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class SapHanaAgentDdlTest {
@Test
void includesColumnCommentsVisibleInReportedTableMetadata() {
MetadataFixture metadata = new MetadataFixture("CRMAI_P02", "ET_DATA");
metadata.tableComment = "管家婆同步过来的终端主数据";
metadata.columns.add(column("ID", "BIGINT", null, "id"));
metadata.columns.get(0).put("NULLABLE", DatabaseMetaData.columnNoNulls);
metadata.primaryKeys.add(row("COLUMN_NAME", "ID"));
String[] names = {
"DATA_SOURCE", "ET_ID", "TERMINAL_NAME", "TERMINAL_ADDRESS", "DEALER_CODE", "DEALER_NAME",
"APPROVE_STATUS", "APPROVE_ID", "APPROVE_REMARK", "PARTNER", "DELETE_FLAG", "CREATE_BY",
"CREATE_NAME", "CREATE_TIME", "UPDATE_BY", "UPDATE_NAME", "UPDATE_TIME", "REGION"
};
Integer[] sizes = {50, 100, 40, 100, 10, 40, 2, 50, 1000, 20, 1, 255, 255, null, 255, 255, null, 14};
String[] comments = {
"数据来源,管家婆,其他系统", "外部系统终端ID", "终端名称", "终端地址", "经销商编码,在CRM系统中存在", "经销商名称"
};
for (int index = 0; index < names.length; index++) {
metadata.columns.add(column(
names[index], sizes[index] == null ? "TIMESTAMP" : "NVARCHAR", sizes[index],
index < comments.length ? comments[index] : null
));
}
SapHanaAgent agent = metadata.agent();
List<ColumnInfo> columns = agent.getColumns(metadata.schema, metadata.table);
assertEquals(19, columns.size());
assertEquals("id", columns.get(0).getComment());
for (int index = 0; index < comments.length; index++) {
assertEquals(comments[index], columns.get(index + 1).getComment());
}
metadata.calls.clear();
String ddl = agent.getTableDdl(metadata.schema, metadata.table);
String originalDdl = "CREATE TABLE \"CRMAI_P02\".\"ET_DATA\" (\n"
+ " \"ID\" BIGINT NOT NULL,\n"
+ " \"DATA_SOURCE\" NVARCHAR(50),\n"
+ " \"ET_ID\" NVARCHAR(100),\n"
+ " \"TERMINAL_NAME\" NVARCHAR(40),\n"
+ " \"TERMINAL_ADDRESS\" NVARCHAR(100),\n"
+ " \"DEALER_CODE\" NVARCHAR(10),\n"
+ " \"DEALER_NAME\" NVARCHAR(40),\n"
+ " \"APPROVE_STATUS\" NVARCHAR(2),\n"
+ " \"APPROVE_ID\" NVARCHAR(50),\n"
+ " \"APPROVE_REMARK\" NVARCHAR(1000),\n"
+ " \"PARTNER\" NVARCHAR(20),\n"
+ " \"DELETE_FLAG\" NVARCHAR(1),\n"
+ " \"CREATE_BY\" NVARCHAR(255),\n"
+ " \"CREATE_NAME\" NVARCHAR(255),\n"
+ " \"CREATE_TIME\" TIMESTAMP,\n"
+ " \"UPDATE_BY\" NVARCHAR(255),\n"
+ " \"UPDATE_NAME\" NVARCHAR(255),\n"
+ " \"UPDATE_TIME\" TIMESTAMP,\n"
+ " \"REGION\" NVARCHAR(14),\n"
+ " PRIMARY KEY (\"ID\")\n);\n"
+ "\nCOMMENT ON TABLE \"CRMAI_P02\".\"ET_DATA\" IS '管家婆同步过来的终端主数据';";
StringBuilder expected = new StringBuilder(originalDdl);
expected.append("\nCOMMENT ON COLUMN \"CRMAI_P02\".\"ET_DATA\".\"ID\" IS 'id';");
for (int index = 0; index < comments.length; index++) {
expected.append("\nCOMMENT ON COLUMN \"CRMAI_P02\".\"ET_DATA\".\"")
.append(names[index]).append("\" IS '").append(comments[index]).append("';");
}
assertEquals(expected.toString(), ddl);
metadata.assertSingleMetadataRead();
}
@Test
void reusesStandardEscapingForIdentifiersAndUnicodeMultilineComments() {
MetadataFixture metadata = new MetadataFixture("S\"模式", "T\"表");
metadata.tableComment = "表's\n说明";
metadata.columns.add(column("C\"列", "NVARCHAR", 50, " 用户's 数据\n第二行\r\n終端 🚀 "));
assertEquals(
"CREATE TABLE \"S\"\"模式\".\"T\"\"表\" (\n \"C\"\"列\" NVARCHAR(50)\n);\n"
+ "\nCOMMENT ON TABLE \"S\"\"模式\".\"T\"\"表\" IS '表''s\n说明';"
+ "\nCOMMENT ON COLUMN \"S\"\"模式\".\"T\"\"表\".\"C\"\"列\" IS ' 用户''s 数据\n第二行\r\n終端 🚀 ';",
metadata.ddl()
);
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t\r\n"})
void omitsBlankColumnCommentsWithoutLosingTableComments(String comment) {
MetadataFixture metadata = new MetadataFixture("APP", "ITEMS");
metadata.tableComment = "table only";
metadata.columns.add(column("ID", "INTEGER", null, comment));
assertEquals(
"CREATE TABLE \"APP\".\"ITEMS\" (\n \"ID\" INTEGER\n);\n"
+ "\nCOMMENT ON TABLE \"APP\".\"ITEMS\" IS 'table only';",
metadata.ddl()
);
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t\r\n"})
void includesColumnCommentsWithoutATableComment(String comment) {
MetadataFixture metadata = new MetadataFixture("APP", "ITEMS");
metadata.tableComment = comment;
metadata.columns.add(column("ID", "INTEGER", null, "column only"));
assertEquals(
"CREATE TABLE \"APP\".\"ITEMS\" (\n \"ID\" INTEGER\n);\n"
+ "\nCOMMENT ON COLUMN \"APP\".\"ITEMS\".\"ID\" IS 'column only';",
metadata.ddl()
);
}
@ParameterizedTest
@NullAndEmptySource
void supportsUnqualifiedTables(String schema) {
MetadataFixture metadata = new MetadataFixture(schema, "ITEMS");
metadata.columns.add(column("ID", "INTEGER", null, "identifier"));
assertEquals(
"CREATE TABLE \"ITEMS\" (\n \"ID\" INTEGER\n);\n"
+ "\nCOMMENT ON COLUMN \"ITEMS\".\"ID\" IS 'identifier';",
metadata.ddl()
);
}
@Test
void preservesTypesDefaultsPrimaryKeysIndexesAndForeignKeys() {
MetadataFixture metadata = relatedTable();
assertEquals(
"CREATE TABLE \"APP\".\"ITEMS\" (\n"
+ " \"ID\" BIGINT NOT NULL DEFAULT 7,\n"
+ " \"SOURCE\" NVARCHAR(50) DEFAULT 'ERP',\n"
+ " \"PRICE\" DECIMAL(12, 2) DEFAULT 0.00,\n"
+ " \"ACTIVE\" BOOLEAN DEFAULT TRUE,\n"
+ " \"CREATED_AT\" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n"
+ " PRIMARY KEY (\"ID\"),\n"
+ " CONSTRAINT \"FK_SOURCE\" FOREIGN KEY (\"SOURCE\", \"ID\") REFERENCES \"PARENT\"(\"CODE\", \"ID\")\n);\n"
+ "\nCOMMENT ON COLUMN \"APP\".\"ITEMS\".\"ID\" IS 'identifier';"
+ "\nCREATE UNIQUE INDEX \"UQ_SOURCE\" ON \"APP\".\"ITEMS\" (\"SOURCE\", \"ID\");"
+ "\nCREATE INDEX \"IX_PRICE\" ON \"APP\".\"ITEMS\" (\"PRICE\");",
metadata.ddl()
);
}
@ParameterizedTest
@ValueSource(strings = {"getIndexInfo", "getImportedKeys", "getTables", "getPrimaryKeys", "allOptional"})
void keepsOptionalMetadataFailureFallbacks(String failure) {
MetadataFixture metadata = relatedTable();
metadata.tableComment = "table comment";
metadata.failures = "allOptional".equals(failure)
? List.of("getIndexInfo", "getImportedKeys", "getTables", "getPrimaryKeys")
: List.of(failure);
String ddl = metadata.ddl();
assertTrue(ddl.contains("\"ID\" BIGINT NOT NULL DEFAULT 7"));
assertTrue(ddl.contains("COMMENT ON COLUMN \"APP\".\"ITEMS\".\"ID\" IS 'identifier';"));
assertEquals(!metadata.failures.contains("getIndexInfo"), ddl.contains("CREATE UNIQUE INDEX \"UQ_SOURCE\""));
assertEquals(!metadata.failures.contains("getImportedKeys"), ddl.contains("CONSTRAINT \"FK_SOURCE\""));
assertEquals(!metadata.failures.contains("getTables"), ddl.contains("COMMENT ON TABLE"));
assertEquals(!"allOptional".equals(failure), ddl.contains("PRIMARY KEY (\"ID\")"));
}
@Test
void propagatesColumnMetadataFailureInsteadOfReturningIncompleteDdl() {
MetadataFixture metadata = relatedTable();
metadata.failures = List.of("getColumns");
RuntimeException error = assertThrows(RuntimeException.class, metadata::ddl);
assertEquals("metadata unavailable: getColumns", error.getCause().getMessage());
metadata.assertSingleMetadataRead();
}
@Test
void preservesEmptyColumnMetadataResult() {
MetadataFixture metadata = new MetadataFixture("APP", "ITEMS");
assertEquals("CREATE TABLE \"APP\".\"ITEMS\" (\n\n);\n", metadata.ddl());
}
private static MetadataFixture relatedTable() {
MetadataFixture metadata = new MetadataFixture("APP", "ITEMS");
metadata.columns.add(column("ID", "BIGINT", null, "identifier"));
metadata.columns.get(0).putAll(row("NULLABLE", DatabaseMetaData.columnNoNulls, "COLUMN_DEF", "7"));
metadata.columns.add(column("SOURCE", "NVARCHAR", 50, null));
metadata.columns.get(1).put("COLUMN_DEF", "'ERP'");
metadata.columns.add(column("PRICE", "DECIMAL", 12, null));
metadata.columns.get(2).putAll(row("DECIMAL_DIGITS", 2, "COLUMN_DEF", "0.00"));
metadata.columns.add(column("ACTIVE", "BOOLEAN", null, null));
metadata.columns.get(3).put("COLUMN_DEF", "TRUE");
metadata.columns.add(column("CREATED_AT", "TIMESTAMP", null, null));
metadata.columns.get(4).put("COLUMN_DEF", "CURRENT_TIMESTAMP");
metadata.primaryKeys.add(row("COLUMN_NAME", "ID"));
metadata.indexes.add(row("INDEX_NAME", "PRIMARY", "COLUMN_NAME", "ID", "ORDINAL_POSITION", 1));
metadata.indexes.add(row("INDEX_NAME", "UQ_SOURCE", "COLUMN_NAME", "ID", "ORDINAL_POSITION", 2));
metadata.indexes.add(row("INDEX_NAME", "UQ_SOURCE", "COLUMN_NAME", "SOURCE", "ORDINAL_POSITION", 1));
metadata.indexes.add(row(
"INDEX_NAME", "IX_PRICE", "COLUMN_NAME", "PRICE", "NON_UNIQUE", true, "ORDINAL_POSITION", 1
));
metadata.foreignKeys.add(row(
"FK_NAME", "FK_SOURCE", "FKCOLUMN_NAME", "SOURCE", "PKTABLE_NAME", "PARENT", "PKCOLUMN_NAME", "CODE"
));
metadata.foreignKeys.add(row(
"FK_NAME", "FK_SOURCE", "FKCOLUMN_NAME", "ID", "PKTABLE_NAME", "PARENT", "PKCOLUMN_NAME", "ID"
));
return metadata;
}
private static Map<String, Object> column(String name, String type, Integer size, String comment) {
return row(
"COLUMN_NAME", name, "TYPE_NAME", type, "COLUMN_SIZE", size,
"NULLABLE", DatabaseMetaData.columnNullable, "REMARKS", comment
);
}
private static Map<String, Object> row(Object... values) {
Map<String, Object> row = new LinkedHashMap<>();
for (int index = 0; index < values.length; index += 2) {
row.put((String) values[index], values[index + 1]);
}
return row;
}
private static final class MetadataFixture {
private final String schema;
private final String table;
private final List<Map<String, Object>> columns = new ArrayList<>();
private final List<Map<String, Object>> primaryKeys = new ArrayList<>();
private final List<Map<String, Object>> indexes = new ArrayList<>();
private final List<Map<String, Object>> foreignKeys = new ArrayList<>();
private final Map<String, Integer> calls = new LinkedHashMap<>();
private List<String> failures = List.of();
private String tableComment;
private MetadataFixture(String schema, String table) {
this.schema = schema;
this.table = table;
}
private SapHanaAgent agent() {
DatabaseMetaData metadata = proxy(DatabaseMetaData.class, (method, args) -> {
String name = method.getName();
if (List.of("getColumns", "getPrimaryKeys", "getIndexInfo", "getImportedKeys", "getTables").contains(name)) {
calls.merge(name, 1, Integer::sum);
assertNull(args[0]);
if (!"getTables".equals(name)) {
assertEquals(schema == null || schema.isEmpty() ? null : schema, args[1]);
assertEquals(table, args[2]);
}
}
if (failures.contains(name)) {
throw new SQLException("metadata unavailable: " + name);
}
switch (name) {
case "getColumns": return resultSet(columns);
case "getPrimaryKeys": return resultSet(primaryKeys);
case "getIndexInfo": return resultSet(indexes);
case "getImportedKeys": return resultSet(foreignKeys);
case "getTableTypes": return resultSet(List.of(row("TABLE_TYPE", "COLUMN TABLE")));
case "getTables": return resultSet(List.of(row(
"TABLE_NAME", table, "TABLE_TYPE", "COLUMN TABLE", "REMARKS", tableComment
)));
case "getSearchStringEscape": return "\\";
default: throw new AssertionError("Unexpected metadata call: " + name);
}
});
Connection connection = proxy(Connection.class, (method, args) -> {
switch (method.getName()) {
case "getMetaData": return metadata;
case "isClosed": return false;
default: throw new AssertionError("Unexpected connection call: " + method.getName());
}
});
SapHanaAgent agent = new SapHanaAgent();
TestSupport.setPrivateConnection(agent, connection);
return agent;
}
private String ddl() {
String ddl = agent().getTableDdl(schema, table);
assertSingleMetadataRead();
return ddl;
}
private void assertSingleMetadataRead() {
assertEquals(
Map.of("getColumns", 1, "getPrimaryKeys", 1, "getIndexInfo", 1, "getImportedKeys", 1, "getTables", 1),
calls
);
}
}
private static ResultSet resultSet(List<Map<String, Object>> rows) {
int[] cursor = {-1};
return proxy(ResultSet.class, (method, args) -> {
switch (method.getName()) {
case "next": return ++cursor[0] < rows.size();
case "close": return null;
case "getString":
case "getObject": return rows.get(cursor[0]).get(args[0]);
case "getBoolean": return Boolean.TRUE.equals(rows.get(cursor[0]).get(args[0]));
case "getInt": return ((Number) rows.get(cursor[0]).getOrDefault(args[0], 0)).intValue();
case "getShort": return ((Number) rows.get(cursor[0]).getOrDefault(args[0], 0)).shortValue();
default: throw new AssertionError("Unexpected result set call: " + method.getName());
}
});
}
private static <T> T proxy(Class<T> type, Handler handler) {
return type.cast(Proxy.newProxyInstance(
type.getClassLoader(), new Class<?>[]{type},
(proxy, method, args) -> handler.handle(method, args)
));
}
@FunctionalInterface
private interface Handler {
Object handle(Method method, Object[] args) throws Throwable;
}
}