fix(jdbc): improve goldendb metadata handling

This commit is contained in:
t8y2 2026-06-18 16:47:46 +08:00
parent 4bc8a7d101
commit c62e323e16
5 changed files with 200 additions and 35 deletions

View File

@ -7,6 +7,7 @@ const JDBC_DIALECT_MATCHERS: Array<{ type: DatabaseType; patterns: RegExp[] }> =
{ type: "databend", patterns: [/jdbc:databend:/i, /com\.databend\.jdbc\.DatabendDriver/i, /databend-jdbc/i] },
{ type: "starrocks", patterns: [/starrocks/i] },
{ type: "doris", patterns: [/doris/i] },
{ type: "goldendb", patterns: [/jdbc:goldendb:/i, /goldendb/i] },
{ type: "hive", patterns: [/org\.apache\.hive\.jdbc\.HiveDriver/i, /hive-jdbc/i] },
{ type: "mysql", patterns: [/jdbc:mysql:/i, /mysql/i, /mariadb/i, /kyuubi/i, /hive2/i] },
{ type: "postgres", patterns: [/jdbc:postgresql:/i, /postgres/i] },

View File

@ -313,25 +313,16 @@ impl PluginDriverSession {
process.stdin.write_all(&line).await.map_err(|err| err.to_string())?;
process.stdin.flush().await.map_err(|err| err.to_string())?;
let response_line = match read_plugin_line(&mut process.stdout, "response").await {
Ok(line) => line,
Err(err) if err.contains("end of stream") => {
let status = process.child.try_wait().map_err(|err| err.to_string())?;
return Err(match status {
Some(status) => format!("Plugin '{}' exited with status {}", self.plugin.manifest.id, status),
None => format!("Plugin '{}' closed stdout without a response", self.plugin.manifest.id),
});
}
Err(err) => return Err(err),
};
if response_line.is_empty() {
let status = process.child.try_wait().map_err(|err| err.to_string())?;
return Err(match status {
let stdout = &mut process.stdout;
let child = &mut process.child;
read_decoded_plugin_response(&self.plugin, request_id, stdout, || {
let status = child.try_wait().map_err(|err| err.to_string())?;
Ok(match status {
Some(status) => format!("Plugin '{}' exited with status {}", self.plugin.manifest.id, status),
None => format!("Plugin '{}' closed stdout without a response", self.plugin.manifest.id),
});
}
decode_plugin_response(&self.plugin, request_id, &response_line)
})
})
.await
}
async fn kill(&self) {
@ -368,14 +359,17 @@ where
let stdout = child.stdout.take().ok_or("Plugin stdout unavailable")?;
let mut stderr = child.stderr.take().ok_or("Plugin stderr unavailable")?;
let mut reader = BufReader::new(stdout);
let response_line_result = read_plugin_line(&mut reader, "response").await;
let response_result = read_decoded_plugin_response(plugin, request.id, &mut reader, || {
Ok(format!("Plugin '{}' exited without a response", plugin.manifest.id))
})
.await;
let mut stderr_bytes = Vec::new();
stderr.read_to_end(&mut stderr_bytes).await.map_err(|err| err.to_string())?;
let stderr_text = String::from_utf8_lossy(&stderr_bytes).into_owned();
let status = child.wait().await.map_err(|err| err.to_string())?;
let response_line = match response_line_result {
Ok(line) => line,
let response = match response_result {
Ok(response) => response,
Err(err) if err.contains("end of stream") => {
let stderr = stderr_text.trim().to_string();
return Err(if stderr.is_empty() {
@ -386,14 +380,6 @@ where
}
Err(err) => return Err(err),
};
if response_line.is_empty() {
let stderr = stderr_text.trim().to_string();
return Err(if stderr.is_empty() {
format!("Plugin '{}' exited without a response", plugin.manifest.id)
} else {
format!("Plugin '{}' exited without a response: {stderr}", plugin.manifest.id)
});
}
if !status.success() {
let stderr = stderr_text.trim().to_string();
return Err(if stderr.is_empty() {
@ -403,7 +389,7 @@ where
});
}
decode_plugin_response(plugin, request.id, &response_line)
Ok(response)
}
fn encode_plugin_request_line(request: &PluginRequest) -> Result<Vec<u8>, String> {
@ -453,6 +439,42 @@ where
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
async fn read_decoded_plugin_response<T, R, F>(
plugin: &InstalledPlugin,
request_id: u64,
reader: &mut R,
end_of_stream_message: F,
) -> Result<T, String>
where
T: DeserializeOwned,
R: tokio::io::AsyncBufRead + Unpin,
F: FnMut() -> Result<String, String>,
{
let mut end_of_stream_message = end_of_stream_message;
const MAX_NOISY_LINES: usize = 20;
for _ in 0..MAX_NOISY_LINES {
let line = match read_plugin_line(reader, "response").await {
Ok(line) => line,
Err(err) if err.contains("end of stream") => return Err(end_of_stream_message()?),
Err(err) => return Err(err),
};
match decode_plugin_response(plugin, request_id, &line) {
Ok(response) => return Ok(response),
Err(err) if err.starts_with(&format!("Failed to parse plugin '{}' response:", plugin.manifest.id)) => {
log::warn!("[plugin:{}] ignored non-protocol stdout: {}", plugin.manifest.id, line.trim_end());
}
Err(err) => return Err(err),
}
}
match read_plugin_line(reader, "response").await {
Ok(_) => {
Err(format!("Plugin '{}' wrote too many non-protocol stdout lines before its response", plugin.manifest.id))
}
Err(err) if err.contains("end of stream") => Err(end_of_stream_message()?),
Err(err) => Err(err),
}
}
fn decode_plugin_response<T>(plugin: &InstalledPlugin, request_id: u64, response_line: &str) -> Result<T, String>
where
T: DeserializeOwned,
@ -487,7 +509,8 @@ fn resolve_plugin_executable(plugin_dir: &Path, executable: &str) -> PathBuf {
#[cfg(test)]
mod tests {
use super::read_plugin_line;
use super::{read_decoded_plugin_response, read_plugin_line, InstalledPlugin, PluginManifest};
use std::path::PathBuf;
use tokio::io::BufReader;
#[tokio::test]
@ -499,4 +522,29 @@ mod tests {
assert_eq!(line, format!("{{\"error\":{}}}\n", "\u{fffd}\u{fffd}"));
}
#[tokio::test]
async fn skips_noisy_plugin_stdout_before_json_response() {
let plugin = InstalledPlugin {
manifest: PluginManifest {
id: "jdbc".to_string(),
name: "JDBC".to_string(),
version: "test".to_string(),
protocol_version: 1,
description: String::new(),
executable: None,
drivers: Vec::new(),
},
path: PathBuf::new(),
};
let bytes = b"driver banner\n{\"id\":1,\"result\":{\"ok\":true}}\n";
let mut reader = BufReader::new(std::io::Cursor::new(bytes));
let result: serde_json::Value =
read_decoded_plugin_response(&plugin, 1, &mut reader, || Ok("closed".to_string()))
.await
.expect("response should decode after noisy line");
assert_eq!(result["ok"], true);
}
}

View File

@ -0,0 +1,20 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { effectiveDatabaseTypeForConnection, inferJdbcDialect } from "../../apps/desktop/src/lib/jdbcDialect.ts";
test("infers GoldenDB for generic JDBC connections", () => {
assert.equal(
inferJdbcDialect({
db_type: "jdbc",
connection_string: "jdbc:goldendb://127.0.0.1:3306/app",
}),
"goldendb",
);
assert.equal(
effectiveDatabaseTypeForConnection({
db_type: "jdbc",
jdbc_driver_class: "com.goldendb.jdbc.Driver",
}),
"goldendb",
);
});

View File

@ -973,7 +973,7 @@ public final class DbxJdbcPlugin {
appendColumns(result, meta, null, schemaPattern, table, primaryKeys);
}
if (quirks.useCatalogFallbackSql()) {
mergeShowFullColumnComments(conn, result, schemaPattern, table);
mergeShowFullColumnMetadata(conn, result, schemaPattern, table);
}
return result;
}
@ -1148,19 +1148,29 @@ public final class DbxJdbcPlugin {
}
}
private static void mergeShowFullColumnComments(Connection conn, ArrayNode result, String schema, String table) {
private static void mergeShowFullColumnMetadata(Connection conn, ArrayNode result, String schema, String table) {
String target = qualifiedJdbcTableName(schema, table);
try (Statement statement = conn.createStatement(); ResultSet rs = statement.executeQuery("SHOW FULL COLUMNS FROM " + target)) {
int fieldIndex = resultSetColumnIndex(rs, "Field");
int typeIndex = resultSetColumnIndex(rs, "Type");
int extraIndex = resultSetColumnIndex(rs, "Extra");
int commentIndex = resultSetColumnIndex(rs, "Comment");
if (fieldIndex <= 0 || commentIndex <= 0) {
if (fieldIndex <= 0) {
return;
}
while (rs.next()) {
String name = rs.getString(fieldIndex);
String comment = rs.getString(commentIndex);
if (name != null) {
putNullablePreferValue(columnNode(result, name), "comment", comment);
ObjectNode item = columnNode(result, name);
if (typeIndex > 0) {
putNullablePreferValue(item, "data_type", rs.getString(typeIndex));
}
if (extraIndex > 0) {
putNullablePreferValue(item, "extra", rs.getString(extraIndex));
}
if (commentIndex > 0) {
putNullablePreferValue(item, "comment", rs.getString(commentIndex));
}
}
}
} catch (SQLException | AbstractMethodError | UnsupportedOperationException ignored) {

View File

@ -2,6 +2,8 @@ package app.dbx.jdbc;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
@ -557,6 +559,30 @@ final class DbxJdbcPluginTest {
assertEquals(true, response.path("result").path(0).path("is_primary_key").asBoolean());
}
@Test
void showFullColumnsMetadataCompletesMysqlCompatibleTypesAndComments() throws Exception {
Method method = DbxJdbcPlugin.class.getDeclaredMethod(
"mergeShowFullColumnMetadata",
Connection.class,
ArrayNode.class,
String.class,
String.class
);
method.setAccessible(true);
ArrayNode columns = MAPPER.createArrayNode();
ObjectNode column = columns.addObject();
column.put("name", "name");
column.put("data_type", "varchar");
column.putNull("extra");
column.putNull("comment");
method.invoke(null, showFullColumnsConnection(), columns, "app", "people");
assertEquals("varchar(32)", columns.path(0).path("data_type").asText());
assertEquals("auto_increment", columns.path(0).path("extra").asText());
assertEquals("姓名", columns.path(0).path("comment").asText());
}
@Test
void oracleMetadataObjectTypeAcceptsPackageBodyAliases() throws Exception {
Method method = DbxJdbcPlugin.class.getDeclaredMethod("oracleMetadataObjectType", String.class);
@ -744,6 +770,66 @@ final class DbxJdbcPluginTest {
);
}
private static Connection showFullColumnsConnection() {
return (Connection) Proxy.newProxyInstance(
DbxJdbcPluginTest.class.getClassLoader(),
new Class<?>[] { Connection.class },
(proxy, method, args) -> switch (method.getName()) {
case "createStatement" -> showFullColumnsStatement();
case "isClosed" -> false;
case "close" -> null;
default -> defaultValue(method.getReturnType());
}
);
}
private static Statement showFullColumnsStatement() {
return (Statement) Proxy.newProxyInstance(
DbxJdbcPluginTest.class.getClassLoader(),
new Class<?>[] { Statement.class },
(proxy, method, args) -> switch (method.getName()) {
case "executeQuery" -> showFullColumnsResultSet();
case "close" -> null;
default -> defaultValue(method.getReturnType());
}
);
}
private static ResultSet showFullColumnsResultSet() {
String[] labels = { "Field", "Type", "Extra", "Comment" };
String[][] rows = { { "name", "varchar(32)", "auto_increment", "姓名" } };
return (ResultSet) Proxy.newProxyInstance(
DbxJdbcPluginTest.class.getClassLoader(),
new Class<?>[] { ResultSet.class },
new java.lang.reflect.InvocationHandler() {
private int index = -1;
@Override
public Object invoke(Object proxy, Method method, Object[] args) {
return switch (method.getName()) {
case "next" -> ++index < rows.length;
case "getMetaData" -> resultSetMeta(labels);
case "getString" -> rows[index][((Integer) args[0]) - 1];
case "close" -> null;
default -> defaultValue(method.getReturnType());
};
}
}
);
}
private static ResultSetMetaData resultSetMeta(String[] labels) {
return (ResultSetMetaData) Proxy.newProxyInstance(
DbxJdbcPluginTest.class.getClassLoader(),
new Class<?>[] { ResultSetMetaData.class },
(proxy, method, args) -> switch (method.getName()) {
case "getColumnCount" -> labels.length;
case "getColumnLabel", "getColumnName" -> labels[((Integer) args[0]) - 1];
default -> defaultValue(method.getReturnType());
}
);
}
private static final class BrokenResultSetDriver implements Driver {
private final String urlPrefix;
private final boolean executeReturnsResultSet;