feat: add rqlite database support
This commit is contained in:
parent
a799958328
commit
0887b96236
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
|
|
@ -229,6 +229,7 @@ const driverProfiles: Record<
|
|||
},
|
||||
redis: { type: "redis", port: 6379, user: "", label: "Redis", icon: "redis" },
|
||||
sqlite: { type: "sqlite", port: 0, user: "", label: "SQLite", icon: "sqlite" },
|
||||
rqlite: { type: "rqlite", port: 4001, user: "", label: "RQLite", icon: "rqlite" },
|
||||
duckdb: { type: "duckdb", port: 0, user: "", label: "DuckDB", icon: "duckdb" },
|
||||
access: { type: "access", port: 0, user: "", label: "Microsoft Access", icon: "access" },
|
||||
mongodb: { type: "mongodb", port: 27017, user: "", label: "MongoDB", icon: "mongodb" },
|
||||
|
|
@ -551,6 +552,7 @@ const iconTypeMap: Record<string, string> = {
|
|||
mysql: "mysql",
|
||||
postgres: "postgres",
|
||||
sqlite: "sqlite",
|
||||
rqlite: "rqlite",
|
||||
access: "access",
|
||||
redis: "redis",
|
||||
mongodb: "mongodb",
|
||||
|
|
@ -611,6 +613,7 @@ const dbOptions = [
|
|||
{ value: "mysql", label: "MySQL" },
|
||||
{ value: "postgres", label: "PostgreSQL" },
|
||||
{ value: "sqlite", label: "SQLite" },
|
||||
{ value: "rqlite", label: "RQLite" },
|
||||
{ value: "access", label: "Microsoft Access" },
|
||||
{ value: "redis", label: "Redis" },
|
||||
{ value: "mongodb", label: "MongoDB" },
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const assetIcons: Record<string, string> = {
|
|||
postgres: "postgres",
|
||||
postgresql: "postgres",
|
||||
sqlite: "sqlite",
|
||||
rqlite: "rqlite.png",
|
||||
redis: "redis",
|
||||
mongodb: "mongodb",
|
||||
clickhouse: "clickhouse",
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ const activeSqlFormatDialect = computed<SqlFormatDialect>(() => {
|
|||
case "postgres":
|
||||
return "postgres";
|
||||
case "sqlite":
|
||||
case "rqlite":
|
||||
return "sqlite";
|
||||
case "sqlserver":
|
||||
return "sqlserver";
|
||||
|
|
|
|||
|
|
@ -192,6 +192,7 @@ const sourceFormatDialect = computed<SqlFormatDialect>(() => {
|
|||
case "mysql":
|
||||
case "postgres":
|
||||
case "sqlite":
|
||||
case "rqlite":
|
||||
case "sqlserver":
|
||||
return effectiveDatabaseType.value;
|
||||
case "gaussdb":
|
||||
|
|
|
|||
|
|
@ -102,6 +102,9 @@ export function connectionUrlPlaceholder(dbType: DatabaseType): string {
|
|||
case "sqlite":
|
||||
return "sqlite:///absolute/path/to/database.db";
|
||||
|
||||
case "rqlite":
|
||||
return "http://user:password@host:4001";
|
||||
|
||||
case "duckdb":
|
||||
return "duckdb:///absolute/path/to/database.duckdb";
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export const DIAGRAM_SUPPORTED_TYPES = new Set<DatabaseType>([
|
|||
"mysql",
|
||||
"postgres",
|
||||
"sqlite",
|
||||
"rqlite",
|
||||
"sqlserver",
|
||||
"oracle",
|
||||
"redshift",
|
||||
|
|
@ -66,6 +67,7 @@ export const DATABASE_SEARCH_SUPPORTED_TYPES = new Set<DatabaseType>([
|
|||
"mysql",
|
||||
"postgres",
|
||||
"sqlite",
|
||||
"rqlite",
|
||||
"sqlserver",
|
||||
"oracle",
|
||||
"redshift",
|
||||
|
|
@ -108,6 +110,7 @@ export const TABLE_IMPORT_SUPPORTED_TYPES = new Set<DatabaseType>([
|
|||
"mysql",
|
||||
"postgres",
|
||||
"sqlite",
|
||||
"rqlite",
|
||||
"duckdb",
|
||||
"clickhouse",
|
||||
"sqlserver",
|
||||
|
|
@ -129,6 +132,7 @@ export const TABLE_STRUCTURE_SUPPORTED_TYPES = new Set<DatabaseType>([
|
|||
"mysql",
|
||||
"postgres",
|
||||
"sqlite",
|
||||
"rqlite",
|
||||
"duckdb",
|
||||
"clickhouse",
|
||||
"sqlserver",
|
||||
|
|
@ -169,6 +173,7 @@ export const FIELD_LINEAGE_SUPPORTED_TYPES = new Set<DatabaseType>([
|
|||
"mysql",
|
||||
"postgres",
|
||||
"sqlite",
|
||||
"rqlite",
|
||||
"sqlserver",
|
||||
"oracle",
|
||||
"redshift",
|
||||
|
|
@ -258,6 +263,7 @@ export const TRANSFER_SQL_TYPES = new Set<DatabaseType>([
|
|||
"mysql",
|
||||
"postgres",
|
||||
"sqlite",
|
||||
"rqlite",
|
||||
"sqlserver",
|
||||
"oracle",
|
||||
"clickhouse",
|
||||
|
|
@ -272,6 +278,7 @@ export const DIAGRAM_SQL_TYPES = new Set<DatabaseType>([
|
|||
"mysql",
|
||||
"postgres",
|
||||
"sqlite",
|
||||
"rqlite",
|
||||
"sqlserver",
|
||||
"oracle",
|
||||
"redshift",
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export function supportsObjectBrowserTreeNode(dbType: DatabaseType | undefined,
|
|||
}
|
||||
|
||||
export function supportsTableTruncate(dbType?: DatabaseType): boolean {
|
||||
return !!dbType && dbType !== "sqlite" && dbType !== "duckdb";
|
||||
return !!dbType && dbType !== "sqlite" && dbType !== "rqlite" && dbType !== "duckdb";
|
||||
}
|
||||
|
||||
export function usesPostgresLikeStructureCopy(dbType?: DatabaseType): boolean {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ const NAVICAT_STYLE_TABLE_DATA_TYPES = new Set<DatabaseType>([
|
|||
"mysql",
|
||||
"postgres",
|
||||
"sqlite",
|
||||
"rqlite",
|
||||
"duckdb",
|
||||
"sqlserver",
|
||||
"oracle",
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function supportsObjectRename(
|
|||
if (objectType === "PROCEDURE" || objectType === "FUNCTION") {
|
||||
return false;
|
||||
}
|
||||
if (databaseType === "sqlite") return objectType === "TABLE";
|
||||
if (databaseType === "sqlite" || databaseType === "rqlite") return objectType === "TABLE";
|
||||
if (databaseType === "mysql" || databaseType === "goldendb") return objectType === "TABLE" || objectType === "VIEW";
|
||||
if (postgresLikeRenameTypes.has(databaseType)) return objectType === "TABLE" || objectType === "VIEW";
|
||||
if (oracleLikeRenameTypes.has(databaseType)) return objectType === "TABLE" || objectType === "VIEW";
|
||||
|
|
|
|||
|
|
@ -451,6 +451,7 @@ const DATABASE_SQL_KEYWORDS: Partial<Record<DatabaseType, string[]>> = {
|
|||
mysql: MYSQL_SQL_KEYWORDS,
|
||||
postgres: POSTGRES_SQL_KEYWORDS,
|
||||
sqlite: SQLITE_SQL_KEYWORDS,
|
||||
rqlite: SQLITE_SQL_KEYWORDS,
|
||||
sqlserver: SQLSERVER_SQL_KEYWORDS,
|
||||
};
|
||||
|
||||
|
|
@ -848,6 +849,7 @@ const DATABASE_FUNCTION_SIGNATURES: Partial<Record<DatabaseType, Map<string, str
|
|||
mysql: MYSQL_FUNCTION_SIGNATURES,
|
||||
postgres: POSTGRES_FUNCTION_SIGNATURES,
|
||||
sqlite: SQLITE_FUNCTION_SIGNATURES,
|
||||
rqlite: SQLITE_FUNCTION_SIGNATURES,
|
||||
sqlserver: SQLSERVER_FUNCTION_SIGNATURES,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -222,6 +222,7 @@ const capabilityByType: Partial<Record<DatabaseType, TableStructureCapabilities>
|
|||
vastbase: postgresCapabilities,
|
||||
kingbase: postgresCapabilities,
|
||||
sqlite: sqliteCapabilities,
|
||||
rqlite: sqliteCapabilities,
|
||||
duckdb: duckdbCapabilities,
|
||||
sqlserver: sqlserverCapabilities,
|
||||
oracle: oracleCapabilities,
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ export const DATA_TYPE_OPTIONS: Record<string, string[]> = {
|
|||
"oid",
|
||||
],
|
||||
sqlite: ["integer", "real", "text", "blob", "numeric"],
|
||||
rqlite: ["integer", "real", "text", "blob", "numeric"],
|
||||
sqlserver: [
|
||||
"bit",
|
||||
"tinyint",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ export type DatabaseType =
|
|||
| "mysql"
|
||||
| "postgres"
|
||||
| "sqlite"
|
||||
| "rqlite"
|
||||
| "redis"
|
||||
| "duckdb"
|
||||
| "clickhouse"
|
||||
|
|
|
|||
|
|
@ -30,6 +30,16 @@
|
|||
"metadataConnectionScoped": false,
|
||||
"skipTcpProbe": true
|
||||
},
|
||||
{
|
||||
"dbType": "rqlite",
|
||||
"label": "RQLite",
|
||||
"runtimeMode": "native",
|
||||
"mcpMode": "direct",
|
||||
"singleConnectionPool": true,
|
||||
"metadataConnectionScoped": false,
|
||||
"skipTcpProbe": false,
|
||||
"defaultPort": 4001
|
||||
},
|
||||
{
|
||||
"dbType": "redis",
|
||||
"label": "Redis",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ pub enum PoolKind {
|
|||
Mysql(db::mysql::MySqlPool, MysqlMode),
|
||||
Postgres(deadpool_postgres::Pool),
|
||||
Sqlite(db::sqlite::SqliteHandle),
|
||||
Rqlite(db::rqlite_driver::RqliteClient),
|
||||
Redis(db::redis_driver::RedisConnection),
|
||||
DuckDb(Arc<std::sync::Mutex<duckdb::Connection>>),
|
||||
MongoDb(mongodb::Client),
|
||||
|
|
@ -343,6 +344,18 @@ impl AppState {
|
|||
db::sqlite::connect_path_with_extensions(&expand_tilde(&db_config.host), extensions).await?,
|
||||
)
|
||||
}
|
||||
DatabaseType::Rqlite => {
|
||||
let client = db::rqlite_driver::RqliteClient::new(
|
||||
&url,
|
||||
db_config.url_params.as_deref(),
|
||||
&db_config.username,
|
||||
&db_config.password,
|
||||
db_config.ssl,
|
||||
connect_timeout,
|
||||
)?;
|
||||
db::rqlite_driver::test_connection(&client, connect_timeout).await?;
|
||||
PoolKind::Rqlite(client)
|
||||
}
|
||||
DatabaseType::Redis => {
|
||||
let con = if db_config.uses_redis_cluster() {
|
||||
db::redis_driver::RedisConnection::Cluster(db::redis_driver::connect_cluster(&db_config).await?)
|
||||
|
|
@ -919,6 +932,7 @@ pub async fn close_pool_kind(pool: PoolKind) {
|
|||
}
|
||||
PoolKind::Postgres(p) => p.close(),
|
||||
PoolKind::Sqlite(_) => {}
|
||||
PoolKind::Rqlite(_) => {}
|
||||
PoolKind::Redis(_) => {}
|
||||
PoolKind::DuckDb(con) => {
|
||||
crate::db::duckdb_driver::close_connection(con);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ pub fn is_single_connection_pool(db_type: &DatabaseType) -> bool {
|
|||
db_type,
|
||||
DatabaseType::Sqlite
|
||||
| DatabaseType::DuckDb
|
||||
| DatabaseType::Rqlite
|
||||
| DatabaseType::MongoDb
|
||||
| DatabaseType::Oracle
|
||||
| DatabaseType::Dameng
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ pub mod ob_oracle;
|
|||
pub mod postgres;
|
||||
pub mod proxy_tunnel;
|
||||
pub mod redis_driver;
|
||||
pub mod rqlite_driver;
|
||||
pub mod sqlite;
|
||||
pub mod sqlserver;
|
||||
pub mod ssh_tunnel;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,455 @@
|
|||
use reqwest::Client as HttpClient;
|
||||
use serde::Deserialize;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::with_connection_timeout;
|
||||
use crate::sql::starts_with_executable_sql_keyword;
|
||||
use crate::types::{
|
||||
ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, ObjectSource, ObjectSourceKind, QueryResult, TableInfo,
|
||||
TriggerInfo,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RqliteClient {
|
||||
http: HttpClient,
|
||||
base_url: String,
|
||||
query_params: String,
|
||||
auth: Option<(String, String)>,
|
||||
}
|
||||
|
||||
impl RqliteClient {
|
||||
pub fn new(
|
||||
url: &str,
|
||||
url_params: Option<&str>,
|
||||
username: &str,
|
||||
password: &str,
|
||||
tls_enabled: bool,
|
||||
timeout: Duration,
|
||||
) -> Result<Self, String> {
|
||||
let mut builder = HttpClient::builder().connect_timeout(timeout);
|
||||
if rqlite_accept_invalid_certs(tls_enabled, url_params) {
|
||||
builder = builder.danger_accept_invalid_certs(true);
|
||||
}
|
||||
if rqlite_should_bypass_system_proxy(url) {
|
||||
builder = builder.no_proxy();
|
||||
}
|
||||
let http = builder.build().map_err(|e| format!("Failed to configure rqlite HTTP client: {e}"))?;
|
||||
let auth = if username.trim().is_empty() { None } else { Some((username.to_string(), password.to_string())) };
|
||||
Ok(Self {
|
||||
http,
|
||||
base_url: url.trim_end_matches('/').split('?').next().unwrap_or(url).to_string(),
|
||||
query_params: normalize_rqlite_url_params(url_params),
|
||||
auth,
|
||||
})
|
||||
}
|
||||
|
||||
fn post_json(&self, path: &str, sql: &str) -> reqwest::RequestBuilder {
|
||||
let req = self.http.post(self.endpoint(path)).json(&[sql]);
|
||||
self.with_auth(req)
|
||||
}
|
||||
|
||||
fn with_auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
if let Some((ref user, ref pass)) = self.auth {
|
||||
req.basic_auth(user, Some(pass))
|
||||
} else {
|
||||
req
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint(&self, path: &str) -> String {
|
||||
if self.query_params.is_empty() {
|
||||
format!("{}{}", self.base_url, path)
|
||||
} else {
|
||||
format!("{}{}?{}", self.base_url, path, self.query_params)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RqliteResponse {
|
||||
results: Vec<RqliteResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RqliteResult {
|
||||
#[serde(default)]
|
||||
columns: Vec<String>,
|
||||
#[serde(default)]
|
||||
values: Vec<Vec<serde_json::Value>>,
|
||||
#[serde(default)]
|
||||
rows_affected: Option<u64>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
enum RqliteEndpoint {
|
||||
Query,
|
||||
Execute,
|
||||
}
|
||||
|
||||
impl RqliteEndpoint {
|
||||
fn path(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Query => "/db/query",
|
||||
Self::Execute => "/db/execute",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn test_connection(client: &RqliteClient, timeout: Duration) -> Result<(), String> {
|
||||
with_connection_timeout("rqlite", timeout, async { query_one(client, "SELECT 1").await.map(|_| ()) }).await
|
||||
}
|
||||
|
||||
pub async fn list_databases(_client: &RqliteClient) -> Result<Vec<DatabaseInfo>, String> {
|
||||
Ok(vec![DatabaseInfo { name: "main".to_string() }])
|
||||
}
|
||||
|
||||
pub async fn list_tables(client: &RqliteClient, _schema: &str) -> Result<Vec<TableInfo>, String> {
|
||||
let result = query_one(
|
||||
client,
|
||||
"SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name",
|
||||
)
|
||||
.await?;
|
||||
Ok(result
|
||||
.values
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let table_type = value_as_string(row.get(1)).unwrap_or_else(|| "table".to_string());
|
||||
TableInfo {
|
||||
name: value_as_string(row.first()).unwrap_or_default(),
|
||||
table_type: if table_type.eq_ignore_ascii_case("view") { "VIEW" } else { "BASE TABLE" }.to_string(),
|
||||
comment: None,
|
||||
parent_schema: None,
|
||||
parent_name: None,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn get_columns(client: &RqliteClient, _schema: &str, table: &str) -> Result<Vec<ColumnInfo>, String> {
|
||||
let result = query_one(client, &format!("PRAGMA table_info({})", sqlite_ident(table))).await?;
|
||||
Ok(result
|
||||
.values
|
||||
.into_iter()
|
||||
.map(|row| ColumnInfo {
|
||||
name: value_by_column(&result.columns, &row, "name").unwrap_or_default(),
|
||||
data_type: value_by_column(&result.columns, &row, "type").unwrap_or_default(),
|
||||
is_nullable: value_by_column(&result.columns, &row, "notnull")
|
||||
.and_then(|value| value.parse::<i64>().ok())
|
||||
.unwrap_or(0)
|
||||
== 0,
|
||||
column_default: value_by_column(&result.columns, &row, "dflt_value"),
|
||||
is_primary_key: value_by_column(&result.columns, &row, "pk")
|
||||
.and_then(|value| value.parse::<i64>().ok())
|
||||
.unwrap_or(0)
|
||||
> 0,
|
||||
extra: None,
|
||||
comment: None,
|
||||
numeric_precision: None,
|
||||
numeric_scale: None,
|
||||
character_maximum_length: None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn list_indexes(client: &RqliteClient, _schema: &str, table: &str) -> Result<Vec<IndexInfo>, String> {
|
||||
let result = query_one(client, &format!("PRAGMA index_list({})", sqlite_ident(table))).await?;
|
||||
let mut indexes = Vec::new();
|
||||
|
||||
for row in result.values {
|
||||
let name = value_by_column(&result.columns, &row, "name").unwrap_or_default();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let is_unique =
|
||||
value_by_column(&result.columns, &row, "unique").and_then(|value| value.parse::<i64>().ok()).unwrap_or(0)
|
||||
!= 0;
|
||||
let origin = value_by_column(&result.columns, &row, "origin").unwrap_or_default();
|
||||
let column_result = query_one(client, &format!("PRAGMA index_info({})", sqlite_ident(&name))).await?;
|
||||
let columns = column_result
|
||||
.values
|
||||
.iter()
|
||||
.filter_map(|row| value_by_column(&column_result.columns, row, "name"))
|
||||
.collect();
|
||||
indexes.push(IndexInfo {
|
||||
name,
|
||||
columns,
|
||||
is_unique,
|
||||
is_primary: origin == "pk",
|
||||
filter: None,
|
||||
index_type: None,
|
||||
included_columns: None,
|
||||
comment: None,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(indexes)
|
||||
}
|
||||
|
||||
pub async fn list_foreign_keys(
|
||||
client: &RqliteClient,
|
||||
_schema: &str,
|
||||
table: &str,
|
||||
) -> Result<Vec<ForeignKeyInfo>, String> {
|
||||
let result = query_one(client, &format!("PRAGMA foreign_key_list({})", sqlite_ident(table))).await?;
|
||||
Ok(result
|
||||
.values
|
||||
.into_iter()
|
||||
.map(|row| ForeignKeyInfo {
|
||||
name: format!("fk_{}", value_by_column(&result.columns, &row, "id").unwrap_or_else(|| "0".to_string())),
|
||||
column: value_by_column(&result.columns, &row, "from").unwrap_or_default(),
|
||||
ref_schema: None,
|
||||
ref_table: value_by_column(&result.columns, &row, "table").unwrap_or_default(),
|
||||
ref_column: value_by_column(&result.columns, &row, "to").unwrap_or_default(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn list_triggers(client: &RqliteClient, _schema: &str, table: &str) -> Result<Vec<TriggerInfo>, String> {
|
||||
let result = query_one(
|
||||
client,
|
||||
&format!(
|
||||
"SELECT name, sql FROM sqlite_master WHERE type = 'trigger' AND tbl_name = {} ORDER BY name",
|
||||
sqlite_string(table)
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
Ok(result
|
||||
.values
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let sql_text = value_as_string(row.get(1)).unwrap_or_default().to_uppercase();
|
||||
let timing = if sql_text.contains("BEFORE") {
|
||||
"BEFORE"
|
||||
} else if sql_text.contains("AFTER") {
|
||||
"AFTER"
|
||||
} else {
|
||||
"INSTEAD OF"
|
||||
};
|
||||
let event = if sql_text.contains("INSERT") {
|
||||
"INSERT"
|
||||
} else if sql_text.contains("UPDATE") {
|
||||
"UPDATE"
|
||||
} else {
|
||||
"DELETE"
|
||||
};
|
||||
TriggerInfo {
|
||||
name: value_as_string(row.first()).unwrap_or_default(),
|
||||
event: event.to_string(),
|
||||
timing: timing.to_string(),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn table_ddl(client: &RqliteClient, table: &str) -> Result<String, String> {
|
||||
first_string_cell(
|
||||
query_one(
|
||||
client,
|
||||
&format!("SELECT sql FROM sqlite_master WHERE type='table' AND name={}", sqlite_string(table)),
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn object_source(
|
||||
client: &RqliteClient,
|
||||
name: &str,
|
||||
object_type: &ObjectSourceKind,
|
||||
) -> Result<ObjectSource, String> {
|
||||
let kind = match object_type {
|
||||
ObjectSourceKind::View => "view",
|
||||
_ => return Err("Object source is not supported for this rqlite object type".to_string()),
|
||||
};
|
||||
let source = first_string_cell(
|
||||
query_one(
|
||||
client,
|
||||
&format!(
|
||||
"SELECT sql FROM sqlite_master WHERE type={} AND name={}",
|
||||
sqlite_string(kind),
|
||||
sqlite_string(name)
|
||||
),
|
||||
)
|
||||
.await?,
|
||||
)?;
|
||||
Ok(ObjectSource { name: name.to_string(), object_type: object_type.clone(), schema: None, source })
|
||||
}
|
||||
|
||||
pub async fn execute_query(client: &RqliteClient, sql: &str) -> Result<QueryResult, String> {
|
||||
execute_query_with_max_rows(client, sql, None).await
|
||||
}
|
||||
|
||||
pub async fn execute_query_with_max_rows(
|
||||
client: &RqliteClient,
|
||||
sql: &str,
|
||||
max_rows: Option<usize>,
|
||||
) -> Result<QueryResult, String> {
|
||||
let start = Instant::now();
|
||||
if starts_with_executable_sql_keyword(sql, &["SELECT", "PRAGMA", "EXPLAIN", "WITH"]) {
|
||||
let result = query_one(client, sql).await?;
|
||||
Ok(query_result_from_rqlite_result(result, start.elapsed().as_millis(), max_rows))
|
||||
} else {
|
||||
let result = execute_one(client, sql).await?;
|
||||
let affected_rows = result.rows_affected.unwrap_or(0);
|
||||
Ok(QueryResult {
|
||||
columns: vec![],
|
||||
rows: vec![],
|
||||
affected_rows,
|
||||
execution_time_ms: start.elapsed().as_millis(),
|
||||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn query_one(client: &RqliteClient, sql: &str) -> Result<RqliteResult, String> {
|
||||
request_one(client, RqliteEndpoint::Query, sql).await
|
||||
}
|
||||
|
||||
async fn execute_one(client: &RqliteClient, sql: &str) -> Result<RqliteResult, String> {
|
||||
request_one(client, RqliteEndpoint::Execute, sql).await
|
||||
}
|
||||
|
||||
async fn request_one(client: &RqliteClient, endpoint: RqliteEndpoint, sql: &str) -> Result<RqliteResult, String> {
|
||||
let resp =
|
||||
client.post_json(endpoint.path(), sql).send().await.map_err(|e| format!("rqlite request failed: {e}"))?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.map_err(|e| format!("rqlite response read failed: {e}"))?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("rqlite error ({status}): {body}"));
|
||||
}
|
||||
let response: RqliteResponse =
|
||||
serde_json::from_str(&body).map_err(|e| format!("rqlite parse error: {e}; body: {body}"))?;
|
||||
let result = response.results.into_iter().next().ok_or_else(|| "rqlite returned no result".to_string())?;
|
||||
if let Some(error) = result.error.as_ref().filter(|error| !error.is_empty()) {
|
||||
return Err(format!("rqlite error: {error}"));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn query_result_from_rqlite_result(
|
||||
mut result: RqliteResult,
|
||||
execution_time_ms: u128,
|
||||
max_rows: Option<usize>,
|
||||
) -> QueryResult {
|
||||
let row_limit = max_rows.unwrap_or(crate::query::MAX_ROWS).max(1);
|
||||
let truncated = result.values.len() > row_limit;
|
||||
if truncated {
|
||||
result.values.truncate(row_limit);
|
||||
}
|
||||
QueryResult {
|
||||
columns: result.columns,
|
||||
rows: result.values,
|
||||
affected_rows: 0,
|
||||
execution_time_ms,
|
||||
truncated,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn first_string_cell(result: RqliteResult) -> Result<String, String> {
|
||||
result
|
||||
.values
|
||||
.first()
|
||||
.and_then(|row| row.first())
|
||||
.and_then(|value| value_as_string(Some(value)))
|
||||
.ok_or_else(|| "Object not found".to_string())
|
||||
}
|
||||
|
||||
fn value_by_column(columns: &[String], row: &[serde_json::Value], name: &str) -> Option<String> {
|
||||
columns
|
||||
.iter()
|
||||
.position(|column| column.eq_ignore_ascii_case(name))
|
||||
.and_then(|index| row.get(index))
|
||||
.and_then(|value| value_as_string(Some(value)))
|
||||
}
|
||||
|
||||
fn value_as_string(value: Option<&serde_json::Value>) -> Option<String> {
|
||||
match value? {
|
||||
serde_json::Value::Null => None,
|
||||
serde_json::Value::String(value) => Some(value.clone()),
|
||||
serde_json::Value::Number(value) => Some(value.to_string()),
|
||||
serde_json::Value::Bool(value) => Some(value.to_string()),
|
||||
other => Some(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn sqlite_ident(value: &str) -> String {
|
||||
format!("\"{}\"", value.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
fn sqlite_string(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn normalize_rqlite_url_params(params: Option<&str>) -> String {
|
||||
params
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.trim_start_matches('?')
|
||||
.split('&')
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("&")
|
||||
}
|
||||
|
||||
fn rqlite_accept_invalid_certs(tls_enabled: bool, url_params: Option<&str>) -> bool {
|
||||
tls_enabled
|
||||
&& url_params
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.trim_start_matches('?')
|
||||
.split('&')
|
||||
.filter_map(|pair| pair.split_once('='))
|
||||
.any(|(key, value)| {
|
||||
matches!(key.trim().to_ascii_lowercase().as_str(), "insecure" | "tls_insecure" | "accept_invalid_certs")
|
||||
&& matches!(value.trim().to_ascii_lowercase().as_str(), "true" | "1" | "yes" | "on")
|
||||
})
|
||||
}
|
||||
|
||||
fn rqlite_should_bypass_system_proxy(base_url: &str) -> bool {
|
||||
let Ok(parsed) = reqwest::Url::parse(base_url) else {
|
||||
return false;
|
||||
};
|
||||
let Some(host) = parsed.host_str() else {
|
||||
return false;
|
||||
};
|
||||
let host = host.trim_matches(['[', ']']);
|
||||
host.eq_ignore_ascii_case("localhost") || host.parse::<std::net::IpAddr>().is_ok_and(|ip| ip.is_loopback())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn converts_query_result_and_truncates_probe_rows() {
|
||||
let result = RqliteResult {
|
||||
columns: vec!["id".to_string(), "name".to_string()],
|
||||
values: vec![
|
||||
vec![serde_json::json!(1), serde_json::json!("Ada")],
|
||||
vec![serde_json::json!(2), serde_json::json!("Linus")],
|
||||
],
|
||||
rows_affected: None,
|
||||
error: None,
|
||||
};
|
||||
|
||||
let result = query_result_from_rqlite_result(result, 8, Some(1));
|
||||
|
||||
assert_eq!(result.columns, vec!["id", "name"]);
|
||||
assert_eq!(result.rows, vec![vec![serde_json::json!(1), serde_json::json!("Ada")]]);
|
||||
assert_eq!(result.execution_time_ms, 8);
|
||||
assert!(result.truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_column_values_by_name() {
|
||||
let columns = vec!["name".to_string(), "notnull".to_string(), "pk".to_string()];
|
||||
let row = vec![serde_json::json!("id"), serde_json::json!(1), serde_json::json!(1)];
|
||||
|
||||
assert_eq!(value_by_column(&columns, &row, "NAME").as_deref(), Some("id"));
|
||||
assert_eq!(value_by_column(&columns, &row, "notnull").as_deref(), Some("1"));
|
||||
}
|
||||
}
|
||||
|
|
@ -169,6 +169,7 @@ pub enum DatabaseType {
|
|||
Mysql,
|
||||
Postgres,
|
||||
Sqlite,
|
||||
Rqlite,
|
||||
Redis,
|
||||
#[serde(rename = "duckdb")]
|
||||
DuckDb,
|
||||
|
|
@ -301,6 +302,7 @@ impl ConnectionConfig {
|
|||
},
|
||||
DatabaseType::Redshift => Some("dev"),
|
||||
DatabaseType::ClickHouse => Some("default"),
|
||||
DatabaseType::Rqlite => Some("main"),
|
||||
DatabaseType::Gaussdb | DatabaseType::OpenGauss => Some("postgres"),
|
||||
DatabaseType::Kingbase | DatabaseType::Vastbase => Some("postgres"),
|
||||
DatabaseType::Highgo => Some("highgo"),
|
||||
|
|
@ -386,6 +388,7 @@ impl ConnectionConfig {
|
|||
format!("postgres://{host}:{port}{db_part}{suffix}")
|
||||
}
|
||||
DatabaseType::ClickHouse => clickhouse_http_url(self, raw_host, port),
|
||||
DatabaseType::Rqlite => rqlite_http_url(self, raw_host, port),
|
||||
DatabaseType::SqlServer => {
|
||||
format!("server=tcp:{host},{port};database={}", self.database.as_deref().unwrap_or("master"))
|
||||
}
|
||||
|
|
@ -489,6 +492,7 @@ impl ConnectionConfig {
|
|||
format!("postgres://{}:{}@{host}:{port}{db_part}{suffix}", username, password)
|
||||
}
|
||||
DatabaseType::ClickHouse => clickhouse_http_url(self, raw_host, port),
|
||||
DatabaseType::Rqlite => rqlite_http_url(self, raw_host, port),
|
||||
DatabaseType::SqlServer => format!(
|
||||
"server=tcp:{host},{port};user={};password={};database={}",
|
||||
self.username,
|
||||
|
|
@ -825,6 +829,31 @@ fn clickhouse_http_url(config: &ConnectionConfig, host: &str, port: u16) -> Stri
|
|||
format!("{scheme}://{}:{port}", bracket_ipv6(trimmed))
|
||||
}
|
||||
|
||||
fn rqlite_http_url(config: &ConnectionConfig, host: &str, port: u16) -> String {
|
||||
let trimmed = host.trim();
|
||||
if let Some(rest) = trimmed.strip_prefix("https://") {
|
||||
return format!("https://{}", trim_http_host_port(rest, port));
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix("http://") {
|
||||
let scheme = if config.ssl { "https" } else { "http" };
|
||||
return format!("{scheme}://{}", trim_http_host_port(rest, port));
|
||||
}
|
||||
let scheme = if config.ssl { "https" } else { "http" };
|
||||
format!("{scheme}://{}:{port}", bracket_ipv6(trimmed))
|
||||
}
|
||||
|
||||
fn trim_http_host_port(value: &str, default_port: u16) -> String {
|
||||
let authority = value.trim_end_matches('/').split('/').next().unwrap_or(value).split('?').next().unwrap_or(value);
|
||||
if authority.starts_with('[') && !authority.contains("]:") {
|
||||
return format!("{authority}:{default_port}");
|
||||
}
|
||||
if authority.rsplit_once(':').is_some() {
|
||||
authority.to_string()
|
||||
} else {
|
||||
format!("{authority}:{default_port}")
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_clickhouse_host_port(value: &str, default_port: u16) -> String {
|
||||
let authority = value.trim_end_matches('/').split('/').next().unwrap_or(value).split('?').next().unwrap_or(value);
|
||||
if authority.starts_with('[') && !authority.contains("]:") {
|
||||
|
|
|
|||
|
|
@ -564,6 +564,17 @@ pub async fn do_execute(
|
|||
wait_for_query_opt(cancel_token, query_timeout, db::sqlite::execute_query_with_max_rows(&p, sql, max_rows))
|
||||
.await
|
||||
}
|
||||
PoolKind::Rqlite(client) => {
|
||||
let client = client.clone();
|
||||
let max_rows = options.max_rows;
|
||||
drop(connections);
|
||||
wait_for_query_opt(
|
||||
cancel_token,
|
||||
query_timeout,
|
||||
db::rqlite_driver::execute_query_with_max_rows(&client, sql, max_rows),
|
||||
)
|
||||
.await
|
||||
}
|
||||
PoolKind::ClickHouse(client) => {
|
||||
let client = client.clone();
|
||||
let database = pool_key.split(':').nth(1).unwrap_or("default").to_string();
|
||||
|
|
@ -1107,7 +1118,9 @@ pub async fn execute_statements_in_transaction(
|
|||
PoolKind::Postgres(pg) => TxPath::Pg(pg.clone()),
|
||||
PoolKind::Mysql(mp, _mode) => TxPath::Mysql(mp.clone(), false),
|
||||
PoolKind::Sqlite(sq) => TxPath::Sqlite(sq.clone()),
|
||||
PoolKind::ClickHouse(_) | PoolKind::SqlServer(_) | PoolKind::Agent(_) => TxPath::Explicit,
|
||||
PoolKind::ClickHouse(_) | PoolKind::Rqlite(_) | PoolKind::SqlServer(_) | PoolKind::Agent(_) => {
|
||||
TxPath::Explicit
|
||||
}
|
||||
PoolKind::DuckDb(_)
|
||||
| PoolKind::Redis(_)
|
||||
| PoolKind::MongoDb(_)
|
||||
|
|
|
|||
|
|
@ -300,6 +300,7 @@ async fn list_databases_once(state: &AppState, connection_id: &str) -> Result<Ve
|
|||
PoolKind::Mysql(p, mode) => dispatch_mysql!(p, mode, db::mysql::list_databases, db::ob_oracle::list_databases),
|
||||
PoolKind::Postgres(p) => db::postgres::list_databases(p).await,
|
||||
PoolKind::Sqlite(p) => db::sqlite::list_databases(p).await,
|
||||
PoolKind::Rqlite(client) => db::rqlite_driver::list_databases(client).await,
|
||||
PoolKind::DuckDb(con) => {
|
||||
let con = con.lock().map_err(|e| e.to_string())?;
|
||||
duckdb_list_databases_with_attached(&con, &duckdb_attached_names)
|
||||
|
|
@ -428,6 +429,9 @@ async fn list_tables_once(
|
|||
PoolKind::Sqlite(p) => {
|
||||
db::sqlite::list_tables(p, schema).await.map(|tables| filter_table_infos(tables, filter, limit))
|
||||
}
|
||||
PoolKind::Rqlite(client) => {
|
||||
db::rqlite_driver::list_tables(client, schema).await.map(|tables| filter_table_infos(tables, filter, limit))
|
||||
}
|
||||
PoolKind::MongoDb(client) => db::mongo_driver::list_collections(client, database)
|
||||
.await
|
||||
.map(|names| collection_names_to_tables(names, "COLLECTION"))
|
||||
|
|
@ -825,6 +829,9 @@ pub async fn get_columns_core(
|
|||
}
|
||||
PoolKind::Postgres(p) => db::postgres::get_columns(p, schema, table).await.map(deduplicate_column_infos),
|
||||
PoolKind::Sqlite(p) => db::sqlite::get_columns(p, schema, table).await.map(deduplicate_column_infos),
|
||||
PoolKind::Rqlite(client) => {
|
||||
db::rqlite_driver::get_columns(client, schema, table).await.map(deduplicate_column_infos)
|
||||
}
|
||||
_ => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
|
@ -896,6 +903,7 @@ pub async fn list_indexes_core(
|
|||
}
|
||||
PoolKind::Postgres(p) => db::postgres::list_indexes(p, schema, table).await,
|
||||
PoolKind::Sqlite(p) => db::sqlite::list_indexes(p, schema, table).await,
|
||||
PoolKind::Rqlite(client) => db::rqlite_driver::list_indexes(client, schema, table).await,
|
||||
_ => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
|
@ -924,6 +932,7 @@ pub async fn list_foreign_keys_core(
|
|||
}
|
||||
PoolKind::Postgres(p) => db::postgres::list_foreign_keys(p, schema, table).await,
|
||||
PoolKind::Sqlite(p) => db::sqlite::list_foreign_keys(p, schema, table).await,
|
||||
PoolKind::Rqlite(client) => db::rqlite_driver::list_foreign_keys(client, schema, table).await,
|
||||
_ => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
|
@ -952,6 +961,7 @@ pub async fn list_triggers_core(
|
|||
}
|
||||
PoolKind::Postgres(p) => db::postgres::list_triggers(p, schema, table).await,
|
||||
PoolKind::Sqlite(p) => db::sqlite::list_triggers(p, schema, table).await,
|
||||
PoolKind::Rqlite(client) => db::rqlite_driver::list_triggers(client, schema, table).await,
|
||||
_ => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
|
@ -1019,6 +1029,7 @@ pub async fn get_table_ddl_core(
|
|||
}
|
||||
PoolKind::Postgres(p) => pg_ddl(p, schema, table).await,
|
||||
PoolKind::Sqlite(p) => sqlite_ddl(p, table).await,
|
||||
PoolKind::Rqlite(client) => db::rqlite_driver::table_ddl(client, table).await,
|
||||
_ => Err("DDL not supported for this database type".to_string()),
|
||||
}
|
||||
}
|
||||
|
|
@ -1244,6 +1255,9 @@ pub async fn get_object_source_core(
|
|||
PoolKind::Sqlite(pool) => first_string_cell(
|
||||
db::sqlite::execute_query(pool, &sqlite_object_source_sql(name, &object_type)).await?,
|
||||
)?,
|
||||
PoolKind::Rqlite(client) => {
|
||||
return db::rqlite_driver::object_source(client, name, &object_type).await;
|
||||
}
|
||||
PoolKind::ClickHouse(client) if matches!(object_type, db::ObjectSourceKind::View) => {
|
||||
let result = db::clickhouse_driver::execute_query(
|
||||
client,
|
||||
|
|
|
|||
|
|
@ -241,7 +241,7 @@ fn capabilities_for(database_type: Option<DatabaseType>) -> TableStructureCapabi
|
|||
comment: true,
|
||||
..base
|
||||
},
|
||||
Some(DatabaseType::Sqlite) => TableStructureCapabilities {
|
||||
Some(DatabaseType::Sqlite | DatabaseType::Rqlite) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::Sqlite,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
|
|
@ -1966,6 +1966,34 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_rqlite_changes_with_sqlite_dialect() {
|
||||
let mut email = column("email");
|
||||
email.data_type = "text".to_string();
|
||||
email.is_nullable = false;
|
||||
let mut email_index = index("idx_users_email", &["email"]);
|
||||
email_index.filter = "email IS NOT NULL".to_string();
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Rqlite),
|
||||
schema: None,
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![email],
|
||||
indexes: vec![email_index],
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec![
|
||||
"ALTER TABLE \"users\" ADD COLUMN \"email\" text NOT NULL;",
|
||||
"CREATE INDEX \"idx_users_email\" ON \"users\" (\"email\") WHERE email IS NOT NULL;",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_mysql_column_reorder_statements() {
|
||||
let mut id = column("id");
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const expected: Record<string, string> = {
|
|||
redshift: "postgresql://user:password@host:port/database",
|
||||
redis: "redis://:password@host:port/0",
|
||||
sqlite: "sqlite:///absolute/path/to/database.db",
|
||||
rqlite: "http://user:password@host:4001",
|
||||
duckdb: "duckdb:///absolute/path/to/database.duckdb",
|
||||
access: "jdbc:ucanaccess:///absolute/path/to/database.accdb",
|
||||
mongodb: "mongodb://user:password@host:port/database",
|
||||
|
|
|
|||
|
|
@ -175,6 +175,14 @@ test("uses Navicat-style table editing defaults for updateable SQL table engines
|
|||
requiresTransactionalTableForExistingRows: false,
|
||||
transaction: true,
|
||||
});
|
||||
assert.deepEqual(getDatabaseCapability("rqlite").tableData, {
|
||||
insert: true,
|
||||
updateRequiresPrimaryKey: false,
|
||||
deleteRequiresPrimaryKey: false,
|
||||
keylessRowPredicate: true,
|
||||
requiresTransactionalTableForExistingRows: false,
|
||||
transaction: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps conservative table editing defaults for unknown database types", () => {
|
||||
|
|
@ -205,6 +213,7 @@ test("describes feature support through capability helpers", () => {
|
|||
assert.equal(supportsTableStructureEditing("opengauss"), true);
|
||||
assert.equal(supportsTableStructureEditing("redshift"), true);
|
||||
assert.equal(supportsTableStructureEditing("clickhouse"), true);
|
||||
assert.equal(supportsTableStructureEditing("rqlite"), true);
|
||||
assert.equal(supportsTableStructureEditing("mongodb"), false);
|
||||
assert.equal(supportsDatabaseCreation("clickhouse"), true);
|
||||
assert.equal(supportsDatabaseCreation("sqlite"), false);
|
||||
|
|
@ -220,6 +229,7 @@ test("describes feature support through capability helpers", () => {
|
|||
assert.equal(supportsObjectBrowser("mongodb"), false);
|
||||
assert.equal(supportsTableTruncate("mysql"), true);
|
||||
assert.equal(supportsTableTruncate("duckdb"), false);
|
||||
assert.equal(supportsTableTruncate("rqlite"), false);
|
||||
});
|
||||
|
||||
test("object browser entry follows database tree shape", () => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ test("recognizes object rename support for UI affordances", () => {
|
|||
assert.equal(supportsObjectRename("sqlserver", "PROCEDURE"), true);
|
||||
assert.equal(supportsObjectRename("sqlite", "TABLE"), true);
|
||||
assert.equal(supportsObjectRename("sqlite", "VIEW"), false);
|
||||
assert.equal(supportsObjectRename("rqlite", "TABLE"), true);
|
||||
assert.equal(supportsObjectRename("rqlite", "VIEW"), false);
|
||||
assert.equal(supportsObjectRename("oracle", "FUNCTION"), false);
|
||||
assert.equal(supportsObjectRename("dameng", "PROCEDURE"), false);
|
||||
assert.equal(supportsObjectRename("mysql", "PROCEDURE"), false);
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import {
|
|||
getTableStructureCapabilities,
|
||||
} from "../../apps/desktop/src/lib/tableStructureCapabilities.ts";
|
||||
|
||||
test("sqlite and duckdb do not support table comments", () => {
|
||||
for (const dbType of ["sqlite", "duckdb"] as const) {
|
||||
test("sqlite-family and duckdb do not support table comments", () => {
|
||||
for (const dbType of ["sqlite", "rqlite", "duckdb"] as const) {
|
||||
const caps = getTableStructureCapabilities(dbType);
|
||||
assert.equal(caps.comment, false, `${dbType} should not support comments`);
|
||||
assert.equal(caps.createTable, true, `${dbType} should still support creating tables`);
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ test("prints capabilities when invoked through an npm-style symlink", async () =
|
|||
assert.equal(result.status, 0);
|
||||
assert.equal(result.stderr, "");
|
||||
const payload = JSON.parse(result.stdout) as { directQueryTypes: string[]; bridgeRequiredTypes: string[] };
|
||||
assert.ok(payload.directQueryTypes.includes("postgres"));
|
||||
assert.ok(payload.directQueryTypes.includes("postgres"));
|
||||
assert.ok(payload.directQueryTypes.includes("rqlite"));
|
||||
assert.ok(payload.bridgeRequiredTypes.includes("oracle"));
|
||||
} finally {
|
||||
await rm(bin.dir, { recursive: true, force: true });
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const diagnostics: DbxDiagnostics = {
|
|||
loadedConnectionCount: 2,
|
||||
bridgePortFile: "/tmp/dbx/mcp-bridge-port",
|
||||
bridgePortFileExists: false,
|
||||
directQueryTypes: ["postgres", "mysql", "sqlite"],
|
||||
directQueryTypes: ["postgres", "mysql", "sqlite", "rqlite"],
|
||||
bridgeRequiredTypes: ["oracle", "mongodb"],
|
||||
};
|
||||
|
||||
|
|
@ -154,6 +154,7 @@ test("prints capabilities as json", async () => {
|
|||
const payload = JSON.parse(result.stdout) as { directQueryTypes: string[]; bridgeRequiredTypes: string[] };
|
||||
assert.ok(payload.directQueryTypes.includes("postgres"));
|
||||
assert.ok(payload.directQueryTypes.includes("sqlite"));
|
||||
assert.ok(payload.directQueryTypes.includes("rqlite"));
|
||||
assert.ok(payload.bridgeRequiredTypes.includes("oracle"));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ function formatQueryToolResult(result: QueryResult, title?: string) {
|
|||
}
|
||||
|
||||
export const DBX_CONNECTION_TYPE_DESCRIPTION =
|
||||
"Database type: postgres, mysql, sqlite, redis, duckdb, clickhouse, sqlserver, mongodb, oracle, elasticsearch, doris, starrocks, redshift, dameng, kingbase, highgo, vastbase, goldendb, gaussdb, yashandb, databricks, saphana, teradata, vertica, firebird, exasol, opengauss, oceanbase-oracle, gbase, h2, snowflake, trino, hive, db2, informix, iris, neo4j, cassandra, bigquery, kylin, sundb, tdengine, xugu, jdbc, access";
|
||||
"Database type: postgres, mysql, sqlite, rqlite, redis, duckdb, clickhouse, sqlserver, mongodb, oracle, elasticsearch, doris, starrocks, redshift, dameng, kingbase, highgo, vastbase, goldendb, gaussdb, yashandb, databricks, saphana, teradata, vertica, firebird, exasol, opengauss, oceanbase-oracle, gbase, h2, snowflake, trino, hive, db2, informix, iris, neo4j, cassandra, bigquery, kylin, sundb, tdengine, xugu, jdbc, access";
|
||||
|
||||
export function createDbxMcpServer(backend: Backend, options: { isWebMode?: boolean } = {}): McpServer {
|
||||
const isWebMode = options.isWebMode ?? !!process.env.DBX_WEB_URL;
|
||||
|
|
@ -181,7 +181,7 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
|
|||
const existing = await backend.findConnection(name);
|
||||
if (existing) return text(`Connection "${name}" already exists.`);
|
||||
const FILE_BASED_TYPES = new Set(["sqlite", "duckdb", "access"]);
|
||||
const DEFAULT_PORTS: Record<string, number> = { tdengine: 6041, xugu: 5138 };
|
||||
const DEFAULT_PORTS: Record<string, number> = { rqlite: 4001, tdengine: 6041, xugu: 5138 };
|
||||
const resolvedPort = port ?? DEFAULT_PORTS[db_type] ?? (FILE_BASED_TYPES.has(db_type) ? 0 : undefined);
|
||||
if (resolvedPort === undefined) return text("Port is required for this database type.");
|
||||
const config = await backend.addConnection({
|
||||
|
|
|
|||
|
|
@ -42,6 +42,17 @@ interface PoolEntry {
|
|||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
interface RqliteResult {
|
||||
columns?: string[];
|
||||
values?: unknown[][];
|
||||
rows_affected?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface RqliteResponse {
|
||||
results?: RqliteResult[];
|
||||
}
|
||||
|
||||
const pools = new Map<string, PoolEntry>();
|
||||
const proxyTunnels = new Map<string, { server: Server; port: number }>();
|
||||
|
||||
|
|
@ -365,6 +376,7 @@ async function mysqlQuery(config: ConnectionConfig, sql: string, params?: unknow
|
|||
|
||||
async function query(config: ConnectionConfig, sql: string, params?: unknown[], options?: QueryOptions): Promise<QueryResult> {
|
||||
if (config.db_type === "sqlite") return sqliteQuery(config, sql, options);
|
||||
if (config.db_type === "rqlite") return rqliteQuery(config, sql, options);
|
||||
if (isMysqlType(config.db_type)) return mysqlQuery(config, sql, params, options);
|
||||
return pgQuery(config, sql, params, options);
|
||||
}
|
||||
|
|
@ -398,6 +410,47 @@ function sqliteQuery(config: ConnectionConfig, sql: string, options?: QueryOptio
|
|||
}
|
||||
}
|
||||
|
||||
async function rqliteQuery(config: ConnectionConfig, sql: string, options?: QueryOptions): Promise<QueryResult> {
|
||||
const isReader = /^\s*(?:--[^\n]*\n|\s|\/\*[\s\S]*?\*\/)*(select|pragma|explain|with)\b/i.test(sql);
|
||||
const endpoint = isReader ? "/db/query" : "/db/execute";
|
||||
const result = await rqliteRequest(config, endpoint, sql);
|
||||
if (isReader) {
|
||||
const columns = result.columns ?? [];
|
||||
const rows = (result.values ?? []).slice(0, resolveMaxRows(options)).map((row) => {
|
||||
const record: Record<string, unknown> = {};
|
||||
columns.forEach((column, index) => {
|
||||
record[column] = row[index];
|
||||
});
|
||||
return record;
|
||||
});
|
||||
return { columns, rows, row_count: rows.length };
|
||||
}
|
||||
return { columns: [], rows: [], row_count: result.rows_affected ?? 0 };
|
||||
}
|
||||
|
||||
async function rqliteRequest(config: ConnectionConfig, endpoint: "/db/query" | "/db/execute", sql: string): Promise<RqliteResult> {
|
||||
const { host, port } = await connectionEndpoint(config);
|
||||
const scheme = config.ssl ? "https" : "http";
|
||||
const params = (config.url_params || "").trim().replace(/^\?/, "");
|
||||
const url = `${scheme}://${host}:${port}${endpoint}${params ? `?${params}` : ""}`;
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
if (config.username) {
|
||||
headers.authorization = `Basic ${Buffer.from(`${config.username}:${config.password || ""}`).toString("base64")}`;
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify([sql]),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) throw new Error(`rqlite error (${response.status}): ${text}`);
|
||||
const payload = JSON.parse(text) as RqliteResponse;
|
||||
const result = payload.results?.[0];
|
||||
if (!result) throw new Error("rqlite returned no result");
|
||||
if (result.error) throw new Error(`rqlite error: ${result.error}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function executeQuery(config: ConnectionConfig, sql: string, options?: QueryOptions): Promise<QueryResult> {
|
||||
if (config.db_type === "mongodb") {
|
||||
const find = parseMongoFindCommand(sql);
|
||||
|
|
@ -451,7 +504,7 @@ export async function listTables(config: ConnectionConfig, schema?: string): Pro
|
|||
});
|
||||
return collections.map((name) => ({ name, type: "COLLECTION" }));
|
||||
}
|
||||
if (config.db_type === "sqlite") {
|
||||
if (config.db_type === "sqlite" || config.db_type === "rqlite") {
|
||||
const result = await query(
|
||||
config,
|
||||
`SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name`,
|
||||
|
|
@ -484,7 +537,7 @@ export async function describeTable(config: ConnectionConfig, table: string, sch
|
|||
const result = await mongoFindDocuments(config, table, 0, 20, "{}");
|
||||
return inferMongoColumns(result.documents);
|
||||
}
|
||||
if (config.db_type === "sqlite") {
|
||||
if (config.db_type === "sqlite" || config.db_type === "rqlite") {
|
||||
const result = await query(config, `PRAGMA table_info(${quoteSqliteIdentifier(table)})`);
|
||||
return result.rows.map((r) => ({
|
||||
name: String(r.name || ""),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export const DIRECT_QUERY_TYPES = [
|
|||
"doris",
|
||||
"starrocks",
|
||||
"sqlite",
|
||||
"rqlite",
|
||||
"gaussdb",
|
||||
"opengauss",
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import test from "node:test";
|
||||
import type { ConnectionConfig } from "../src/connections.js";
|
||||
import { describeTable, executeQuery, listTables } from "../src/database.js";
|
||||
|
||||
function rqliteConfig(port: number): ConnectionConfig {
|
||||
return {
|
||||
id: "rqlite-test",
|
||||
name: "local-rqlite",
|
||||
db_type: "rqlite",
|
||||
host: "127.0.0.1",
|
||||
port,
|
||||
username: "dbx",
|
||||
password: "secret",
|
||||
database: "main",
|
||||
ssh_enabled: false,
|
||||
ssl: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function withRqliteServer(handler: (req: IncomingMessage, res: ServerResponse, body: string) => void) {
|
||||
const server = createServer((req, res) => {
|
||||
let body = "";
|
||||
req.setEncoding("utf8");
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => handler(req, res, body));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === "object");
|
||||
return {
|
||||
port: address.port,
|
||||
close: () => new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))),
|
||||
};
|
||||
}
|
||||
|
||||
function json(res: ServerResponse, body: unknown) {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
test("executes rqlite query through the HTTP API", async () => {
|
||||
const server = await withRqliteServer((req, res, body) => {
|
||||
assert.equal(req.url, "/db/query");
|
||||
assert.equal(req.headers.authorization, "Basic ZGJ4OnNlY3JldA==");
|
||||
assert.deepEqual(JSON.parse(body), ["select id, name from users"]);
|
||||
json(res, { results: [{ columns: ["id", "name"], values: [[1, "Ada"], [2, "Linus"]] }] });
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await executeQuery(rqliteConfig(server.port), "select id, name from users", { maxRows: 1 });
|
||||
assert.deepEqual(result, { columns: ["id", "name"], rows: [{ id: 1, name: "Ada" }], row_count: 1 });
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("lists rqlite tables and describes columns", async () => {
|
||||
const server = await withRqliteServer((_req, res, body) => {
|
||||
const sql = JSON.parse(body)[0] as string;
|
||||
if (sql.includes("sqlite_master")) {
|
||||
json(res, { results: [{ columns: ["name", "type"], values: [["users", "table"], ["active_users", "view"]] }] });
|
||||
return;
|
||||
}
|
||||
if (sql.includes("PRAGMA table_info")) {
|
||||
json(res, {
|
||||
results: [
|
||||
{
|
||||
columns: ["cid", "name", "type", "notnull", "dflt_value", "pk"],
|
||||
values: [
|
||||
[0, "id", "INTEGER", 1, null, 1],
|
||||
[1, "name", "TEXT", 0, "'unknown'", 0],
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
json(res, { results: [{ columns: [], values: [] }] });
|
||||
});
|
||||
|
||||
try {
|
||||
assert.deepEqual(await listTables(rqliteConfig(server.port)), [
|
||||
{ name: "users", type: "table" },
|
||||
{ name: "active_users", type: "view" },
|
||||
]);
|
||||
assert.deepEqual(await describeTable(rqliteConfig(server.port), "users"), [
|
||||
{
|
||||
name: "id",
|
||||
data_type: "INTEGER",
|
||||
is_nullable: false,
|
||||
column_default: null,
|
||||
is_primary_key: true,
|
||||
comment: null,
|
||||
},
|
||||
{
|
||||
name: "name",
|
||||
data_type: "TEXT",
|
||||
is_nullable: true,
|
||||
column_default: "'unknown'",
|
||||
is_primary_key: false,
|
||||
comment: null,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
|
@ -395,6 +395,19 @@ pub async fn test_connection(state: State<'_, Arc<AppState>>, config: Connection
|
|||
.await
|
||||
.map(|_| "Connection successful".to_string())
|
||||
}
|
||||
DatabaseType::Rqlite => {
|
||||
let client = db::rqlite_driver::RqliteClient::new(
|
||||
&url,
|
||||
config.url_params.as_deref(),
|
||||
&config.username,
|
||||
&config.password,
|
||||
config.ssl,
|
||||
connect_timeout,
|
||||
)?;
|
||||
db::rqlite_driver::test_connection(&client, connect_timeout)
|
||||
.await
|
||||
.map(|_| "Connection successful".to_string())
|
||||
}
|
||||
db_type if database_capabilities::is_agent_type(&db_type) => {
|
||||
test_agent_connection(state.inner(), &config, &host, port).await
|
||||
}
|
||||
|
|
@ -549,6 +562,18 @@ pub async fn connect_db(state: State<'_, Arc<AppState>>, config: ConnectionConfi
|
|||
db::elasticsearch_driver::test_connection(&mut client, connect_timeout).await?;
|
||||
PoolKind::Elasticsearch(client)
|
||||
}
|
||||
DatabaseType::Rqlite => {
|
||||
let client = db::rqlite_driver::RqliteClient::new(
|
||||
&url,
|
||||
db_config.url_params.as_deref(),
|
||||
&db_config.username,
|
||||
&db_config.password,
|
||||
db_config.ssl,
|
||||
connect_timeout,
|
||||
)?;
|
||||
db::rqlite_driver::test_connection(&client, connect_timeout).await?;
|
||||
PoolKind::Rqlite(client)
|
||||
}
|
||||
db_type if database_capabilities::is_agent_type(&db_type) => {
|
||||
connect_agent_pool(state.inner(), &db_config, &host, port).await?
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue