fix(jdbc): improve goldendb metadata handling
This commit is contained in:
1 parent
4bc8a7d101
commit
c62e323e16
5 files changed
+200
-35
No files matched your search
@@ -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: "databend", patterns: [/jdbc:databend:/i, /com\.databend\.jdbc\.DatabendDriver/i, /databend-jdbc/i] },
|
||||||
{ type: "starrocks", patterns: [/starrocks/i] },
|
{ type: "starrocks", patterns: [/starrocks/i] },
|
||||||
{ type: "doris", patterns: [/doris/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: "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: "mysql", patterns: [/jdbc:mysql:/i, /mysql/i, /mariadb/i, /kyuubi/i, /hive2/i] },
|
||||||
{ type: "postgres", patterns: [/jdbc:postgresql:/i, /postgres/i] },
|
{ type: "postgres", patterns: [/jdbc:postgresql:/i, /postgres/i] },
|
||||||
|
|||||||
@@ -313,25 +313,16 @@ impl PluginDriverSession {
|
|||||||
process.stdin.write_all(&line).await.map_err(|err| err.to_string())?;
|
process.stdin.write_all(&line).await.map_err(|err| err.to_string())?;
|
||||||
process.stdin.flush().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 {
|
let stdout = &mut process.stdout;
|
||||||
Ok(line) => line,
|
let child = &mut process.child;
|
||||||
Err(err) if err.contains("end of stream") => {
|
read_decoded_plugin_response(&self.plugin, request_id, stdout, || {
|
||||||
let status = process.child.try_wait().map_err(|err| err.to_string())?;
|
let status = child.try_wait().map_err(|err| err.to_string())?;
|
||||||
return Err(match status {
|
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),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
Some(status) => format!("Plugin '{}' exited with status {}", self.plugin.manifest.id, 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),
|
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) {
|
async fn kill(&self) {
|
||||||
@@ -368,14 +359,17 @@ where
|
|||||||
let stdout = child.stdout.take().ok_or("Plugin stdout unavailable")?;
|
let stdout = child.stdout.take().ok_or("Plugin stdout unavailable")?;
|
||||||
let mut stderr = child.stderr.take().ok_or("Plugin stderr unavailable")?;
|
let mut stderr = child.stderr.take().ok_or("Plugin stderr unavailable")?;
|
||||||
let mut reader = BufReader::new(stdout);
|
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();
|
let mut stderr_bytes = Vec::new();
|
||||||
stderr.read_to_end(&mut stderr_bytes).await.map_err(|err| err.to_string())?;
|
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 stderr_text = String::from_utf8_lossy(&stderr_bytes).into_owned();
|
||||||
let status = child.wait().await.map_err(|err| err.to_string())?;
|
let status = child.wait().await.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
let response_line = match response_line_result {
|
let response = match response_result {
|
||||||
Ok(line) => line,
|
Ok(response) => response,
|
||||||
Err(err) if err.contains("end of stream") => {
|
Err(err) if err.contains("end of stream") => {
|
||||||
let stderr = stderr_text.trim().to_string();
|
let stderr = stderr_text.trim().to_string();
|
||||||
return Err(if stderr.is_empty() {
|
return Err(if stderr.is_empty() {
|
||||||
@@ -386,14 +380,6 @@ where
|
|||||||
}
|
}
|
||||||
Err(err) => return Err(err),
|
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() {
|
if !status.success() {
|
||||||
let stderr = stderr_text.trim().to_string();
|
let stderr = stderr_text.trim().to_string();
|
||||||
return Err(if stderr.is_empty() {
|
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> {
|
fn encode_plugin_request_line(request: &PluginRequest) -> Result<Vec<u8>, String> {
|
||||||
@@ -453,6 +439,42 @@ where
|
|||||||
Ok(String::from_utf8_lossy(&bytes).into_owned())
|
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>
|
fn decode_plugin_response<T>(plugin: &InstalledPlugin, request_id: u64, response_line: &str) -> Result<T, String>
|
||||||
where
|
where
|
||||||
T: DeserializeOwned,
|
T: DeserializeOwned,
|
||||||
@@ -487,7 +509,8 @@ fn resolve_plugin_executable(plugin_dir: &Path, executable: &str) -> PathBuf {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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;
|
use tokio::io::BufReader;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -499,4 +522,29 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(line, format!("{{\"error\":{}}}\n", "\u{fffd}\u{fffd}"));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -973,7 +973,7 @@ public final class DbxJdbcPlugin {
|
|||||||
appendColumns(result, meta, null, schemaPattern, table, primaryKeys);
|
appendColumns(result, meta, null, schemaPattern, table, primaryKeys);
|
||||||
}
|
}
|
||||||
if (quirks.useCatalogFallbackSql()) {
|
if (quirks.useCatalogFallbackSql()) {
|
||||||
mergeShowFullColumnComments(conn, result, schemaPattern, table);
|
mergeShowFullColumnMetadata(conn, result, schemaPattern, table);
|
||||||
}
|
}
|
||||||
return result;
|
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);
|
String target = qualifiedJdbcTableName(schema, table);
|
||||||
try (Statement statement = conn.createStatement(); ResultSet rs = statement.executeQuery("SHOW FULL COLUMNS FROM " + target)) {
|
try (Statement statement = conn.createStatement(); ResultSet rs = statement.executeQuery("SHOW FULL COLUMNS FROM " + target)) {
|
||||||
int fieldIndex = resultSetColumnIndex(rs, "Field");
|
int fieldIndex = resultSetColumnIndex(rs, "Field");
|
||||||
|
int typeIndex = resultSetColumnIndex(rs, "Type");
|
||||||
|
int extraIndex = resultSetColumnIndex(rs, "Extra");
|
||||||
int commentIndex = resultSetColumnIndex(rs, "Comment");
|
int commentIndex = resultSetColumnIndex(rs, "Comment");
|
||||||
if (fieldIndex <= 0 || commentIndex <= 0) {
|
if (fieldIndex <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
String name = rs.getString(fieldIndex);
|
String name = rs.getString(fieldIndex);
|
||||||
String comment = rs.getString(commentIndex);
|
|
||||||
if (name != null) {
|
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) {
|
} catch (SQLException | AbstractMethodError | UnsupportedOperationException ignored) {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package app.dbx.jdbc;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
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.AfterEach;
|
||||||
import org.junit.jupiter.api.Test;
|
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());
|
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
|
@Test
|
||||||
void oracleMetadataObjectTypeAcceptsPackageBodyAliases() throws Exception {
|
void oracleMetadataObjectTypeAcceptsPackageBodyAliases() throws Exception {
|
||||||
Method method = DbxJdbcPlugin.class.getDeclaredMethod("oracleMetadataObjectType", String.class);
|
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 static final class BrokenResultSetDriver implements Driver {
|
||||||
private final String urlPrefix;
|
private final String urlPrefix;
|
||||||
private final boolean executeReturnsResultSet;
|
private final boolean executeReturnsResultSet;
|
||||||
|
|||||||
Reference in new issue
Block a user