fix(ci): split checks and enforce warning-free Rust builds

This commit is contained in:
t8y2 2026-07-20 13:39:01 +08:00
parent dcbb1c69c5
commit 083ffe7a35
44 changed files with 363 additions and 324 deletions

View File

@ -18,11 +18,43 @@ jobs:
needs: changes
if: needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-22.04
env:
# The workspace intentionally contains platform-specific CLI/MCP packages for every release target.
NPM_CONFIG_LOGLEVEL: error
steps:
- uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 10.27.0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22.13.0
cache: pnpm
- name: Install frontend dependencies
run: pnpm --filter dbx... install --frozen-lockfile
- name: Frontend check
run: pnpm check
packages:
needs: changes
if: needs.changes.outputs.packages == 'true'
runs-on: ubuntu-22.04
env:
# Unsupported-platform package warnings are expected while validating cross-platform package metadata.
NPM_CONFIG_LOGLEVEL: error
steps:
- uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 10.27.0
- name: Setup Node.js
uses: actions/setup-node@v6
@ -38,8 +70,8 @@ jobs:
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Frontend check
run: pnpm check
- name: Setup Rust
uses: dtolnay/rust-toolchain@1.97.1
- name: Node package tests
run: pnpm test:packages
@ -55,6 +87,9 @@ jobs:
# sccache cannot reuse Cargo incremental artifacts, so avoid generating them in CI.
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
# The fast lane skips only bundled DuckDB while retaining the other default capabilities.
RUST_FEATURE_MODE: ${{ github.event_name == 'pull_request' && needs.changes.outputs.rust_full != 'true' && 'fast' || 'full' }}
RUST_FAST_FEATURES: dbx/mq-admin,dbx/sqlite-sqlcipher,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher
# Fork PRs cannot read repository secrets, so retain the GHA backend for them.
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
steps:
@ -103,12 +138,19 @@ jobs:
shared-key: ci-rust-fmt-clippy-x86_64-unknown-linux-gnu
# Preserve completed dependency builds when a later lint step fails.
cache-on-failure: true
# PR caches are large and branch-scoped; restore them from main without saving per-PR copies.
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Cargo fmt check
run: cargo fmt --check
- name: Cargo clippy
run: cargo clippy --workspace --locked --all-targets
run: |
if [ "$RUST_FEATURE_MODE" = "fast" ]; then
cargo clippy --workspace --locked --all-targets --no-default-features --features "$RUST_FAST_FEATURES" -- -D warnings
else
cargo clippy --workspace --locked --all-targets -- -D warnings
fi
- name: Show sccache stats
if: always()
@ -123,6 +165,9 @@ jobs:
# sccache cannot reuse Cargo incremental artifacts, so avoid generating them in CI.
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
# The fast lane skips only bundled DuckDB while retaining the other default capabilities.
RUST_FEATURE_MODE: ${{ github.event_name == 'pull_request' && needs.changes.outputs.rust_full != 'true' && 'fast' || 'full' }}
RUST_FAST_FEATURES: dbx/mq-admin,dbx/sqlite-sqlcipher,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher
# Fork PRs cannot read repository secrets, so retain the GHA backend for them.
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
steps:
@ -171,9 +216,16 @@ jobs:
cache-workspace-crates: true
# Preserve completed dependency builds when a later test step fails.
cache-on-failure: true
# PR caches are large and branch-scoped; restore them from main without saving per-PR copies.
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Cargo test
run: cargo test --workspace --locked
run: |
if [ "$RUST_FEATURE_MODE" = "fast" ]; then
cargo test --workspace --locked --no-default-features --features "$RUST_FAST_FEATURES"
else
cargo test --workspace --locked
fi
- name: Show sccache stats
if: always()
@ -228,7 +280,9 @@ jobs:
runs-on: ubuntu-22.04
outputs:
frontend: ${{ steps.filter.outputs.frontend }}
packages: ${{ steps.filter.outputs.packages }}
rust: ${{ steps.filter.outputs.rust }}
rust_full: ${{ steps.rust-mode.outputs.full }}
jdbc: ${{ steps.filter.outputs.jdbc }}
agents: ${{ steps.filter.outputs.agents }}
steps:
@ -237,7 +291,7 @@ jobs:
fetch-depth: 0
- name: Detect changed areas
uses: dorny/paths-filter@v3
uses: dorny/paths-filter@v4
id: filter
with:
filters: |
@ -249,6 +303,17 @@ jobs:
- '.oxfmtrc.json'
- 'scripts/run-check.mjs'
- '.github/workflows/ci.yml'
packages:
- 'packages/cli/**'
- 'packages/mcp-server/**'
- 'crates/dbx-cli/**'
- 'crates/dbx-mcp/**'
- 'scripts/verify-package-install.mjs'
- 'package.json'
- 'pnpm-lock.yaml'
- 'Cargo.toml'
- 'Cargo.lock'
- '.github/workflows/ci.yml'
rust:
- 'crates/**'
- 'src-tauri/**'
@ -261,6 +326,42 @@ jobs:
agents:
- 'agents/**'
- name: Select Rust feature coverage
id: rust-mode
shell: bash
env:
BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
RUST_CHANGED: ${{ steps.filter.outputs.rust }}
run: |
full=false
if [ "$RUST_CHANGED" = "true" ] && [ "${{ github.event_name }}" = "push" ]; then
full=true
elif [ "$RUST_CHANGED" = "true" ]; then
if [ -z "$BASE_SHA" ] || echo "$BASE_SHA" | grep -Eq '^0+$'; then
BASE_SHA="HEAD~1"
fi
while IFS= read -r file; do
case "$file" in
Cargo.toml|Cargo.lock|rust-toolchain*|.github/workflows/ci.yml|*/Cargo.toml)
full=true
break
;;
esac
# Feature-gated DuckDB code must keep the full default-feature checks on its PR.
if echo "$file" | grep -Eqi 'duckdb' \
|| { [ -f "$file" ] && grep -Eqi 'duckdb-bundled|cfg[^[:cntrl:]]*duckdb|feature[^[:cntrl:]]*duckdb' "$file"; } \
|| git diff "$BASE_SHA" HEAD -- "$file" | grep -Eqi 'duckdb-bundled|cfg[^[:cntrl:]]*duckdb|feature[^[:cntrl:]]*duckdb'; then
full=true
break
fi
done < <(git diff --name-only "$BASE_SHA" HEAD -- Cargo.toml Cargo.lock 'rust-toolchain*' crates src-tauri .github/workflows/ci.yml)
fi
echo "full=$full" >> "$GITHUB_OUTPUT"
agents:
needs: changes
if: needs.changes.outputs.agents == 'true'

1
clippy.toml Normal file
View File

@ -0,0 +1 @@
too-many-arguments-threshold = 10

View File

@ -3139,9 +3139,9 @@ fn clone_pool_kind(pool: &PoolKind) -> PoolKind {
#[cfg(feature = "duckdb-bundled")]
PoolKind::DuckDbWorker(client) => PoolKind::DuckDbWorker(client.clone()),
#[cfg(not(feature = "duckdb-bundled"))]
PoolKind::DuckDb(con) => PoolKind::DuckDb(con.clone()),
PoolKind::DuckDb(_) => PoolKind::DuckDb(()),
#[cfg(not(feature = "duckdb-bundled"))]
PoolKind::DuckDbWorker(client) => PoolKind::DuckDbWorker(client.clone()),
PoolKind::DuckDbWorker(_) => PoolKind::DuckDbWorker(()),
PoolKind::MongoDb(client) => PoolKind::MongoDb(client.clone()),
PoolKind::ClickHouse(client) => PoolKind::ClickHouse(client.clone()),
PoolKind::SqlServer(client) => PoolKind::SqlServer(client.clone()),
@ -3149,7 +3149,10 @@ fn clone_pool_kind(pool: &PoolKind) -> PoolKind {
PoolKind::VectorDb(client) => PoolKind::VectorDb(client.clone()),
PoolKind::InfluxDb(client) => PoolKind::InfluxDb(client.clone()),
PoolKind::Agent(client) => PoolKind::Agent(client.clone()),
#[cfg(feature = "duckdb-bundled")]
PoolKind::ExternalTabular(ext) => PoolKind::ExternalTabular(ext.clone()),
#[cfg(not(feature = "duckdb-bundled"))]
PoolKind::ExternalTabular(_) => PoolKind::ExternalTabular(()),
PoolKind::ExternalDriver { driver_id, config, session } => {
PoolKind::ExternalDriver { driver_id: driver_id.clone(), config: config.clone(), session: session.clone() }
}

View File

@ -38,6 +38,8 @@ impl DuckDbConnection {
self.draining.load(Ordering::SeqCst)
}
// Preserve PoisonError ownership so close_connection can still release the contained DuckDB handle.
#[allow(clippy::result_large_err)]
fn into_inner(self) -> std::sync::LockResult<duckdb::Connection> {
self.connection.into_inner()
}

View File

@ -183,13 +183,15 @@ fn find_as_keyword_outside_quotes(sql: &str) -> Option<usize> {
}
in_double = !in_double;
}
b'a' | b'A' if !in_single && !in_double && i + 1 < bytes.len() => {
if (bytes[i + 1] == b's' || bytes[i + 1] == b'S')
b'a' | b'A'
if !in_single
&& !in_double
&& i + 1 < bytes.len()
&& (bytes[i + 1] == b's' || bytes[i + 1] == b'S')
&& is_sql_word_boundary(bytes.get(i.wrapping_sub(1)).copied())
&& is_sql_word_boundary(bytes.get(i + 2).copied())
{
return Some(i);
}
&& is_sql_word_boundary(bytes.get(i + 2).copied()) =>
{
return Some(i);
}
_ => {}
}

View File

@ -513,7 +513,8 @@ impl DuckDbWorkerClient {
let mut pending = self.inner.pending.lock().await;
let ids = pending
.iter()
.filter_map(|(id, request)| (request.generation == generation).then(|| id.clone()))
.filter(|&(_id, request)| request.generation == generation)
.map(|(id, _request)| id.clone())
.collect::<Vec<_>>();
ids.into_iter().filter_map(|id| pending.remove(&id).map(|request| (id, request.sender))).collect::<Vec<_>>()
};
@ -631,7 +632,8 @@ fn spawn_stdout_reader(
let mut pending = pending.lock().await;
let ids = pending
.iter()
.filter_map(|(id, request)| (request.generation == generation).then(|| id.clone()))
.filter(|&(_id, request)| request.generation == generation)
.map(|(id, _request)| id.clone())
.collect::<Vec<_>>();
ids.into_iter().filter_map(|id| pending.remove(&id).map(|request| (id, request.sender))).collect::<Vec<_>>()
};

View File

@ -222,7 +222,7 @@ fn build_query_url(client: &InfluxdbClient, database: Option<&str>, sql: &str) -
}
let encoded_sql = utf8_percent_encode(sql, NON_ALPHANUMERIC);
params.push(format!("q={encoded_sql}"));
format!("{}/query?{}", &client.base_url, params.join("&").as_str())
format!("{}/query?{}", client.base_url, params.join("&").as_str())
}
fn encode_url_param(value: &str) -> String {
@ -231,12 +231,12 @@ fn encode_url_param(value: &str) -> String {
fn build_v2_buckets_url(client: &InfluxdbClient, offset: usize) -> Result<String, String> {
let org = client.org.as_deref().ok_or_else(|| "InfluxDB 2.x organization is required".to_string())?;
Ok(format!("{}/api/v2/buckets?org={}&limit=100&offset={offset}", &client.base_url, encode_url_param(org)))
Ok(format!("{}/api/v2/buckets?org={}&limit=100&offset={offset}", client.base_url, encode_url_param(org)))
}
fn build_v2_query_url(client: &InfluxdbClient) -> Result<String, String> {
let org = client.org.as_deref().ok_or_else(|| "InfluxDB 2.x organization is required".to_string())?;
Ok(format!("{}/api/v2/query?org={}", &client.base_url, encode_url_param(org)))
Ok(format!("{}/api/v2/query?org={}", client.base_url, encode_url_param(org)))
}
fn build_request(client: &InfluxdbClient, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {

View File

@ -1692,7 +1692,7 @@ fn parse_legacy_mongo_date_display(value: &str) -> Option<DateTime> {
if !millis.chars().all(|ch| ch.is_ascii_digit()) {
return None;
}
DateTime::parse_rfc3339_str(format!("{date}T{seconds}.{}Z", format!("{millis:0<3}"))).ok()
DateTime::parse_rfc3339_str(format!("{date}T{seconds}.{millis:0<3}Z")).ok()
}
fn parse_extended_json_date(obj: &serde_json::Map<String, serde_json::Value>) -> Option<DateTime> {
@ -2003,7 +2003,7 @@ mod tests {
#[test]
fn document_id_filters_try_object_id_then_string_for_hex_ids() {
let id = "507f1f77bcf86cd799439011";
let filters = document_id_filters(&id);
let filters = document_id_filters(id);
assert_eq!(filters.len(), 2);
assert!(matches!(filters[0].get("_id"), Some(Bson::ObjectId(_))));
@ -2013,7 +2013,7 @@ mod tests {
#[test]
fn document_id_filters_use_string_only_for_non_hex_ids() {
let id = "customer-42";
let filters = document_id_filters(&id);
let filters = document_id_filters(id);
assert_eq!(filters.len(), 1);
assert!(matches!(filters[0].get("_id"), Some(Bson::String(value)) if value == id));

View File

@ -4547,7 +4547,6 @@ UNIQUE KEY(`tenant_id`, `name``part`)
#[test]
fn mysql_tcp_keepalive_uses_milliseconds_not_seconds() {
assert_eq!(MYSQL_TCP_KEEPALIVE_MS, 30_000);
assert!(MYSQL_TCP_KEEPALIVE_MS >= 1_000);
}
#[test]

View File

@ -1400,7 +1400,7 @@ fn sqlite_completion_tables(
if type_filters.is_empty() {
type_filters.extend(["table", "view"]);
}
let placeholders = std::iter::repeat("?").take(type_filters.len()).collect::<Vec<_>>().join(", ");
let placeholders = std::iter::repeat_n("?", type_filters.len()).collect::<Vec<_>>().join(", ");
let sql = format!(
"SELECT name, type FROM {}.sqlite_master WHERE type IN ({}) AND name NOT LIKE 'sqlite_%' AND {} ORDER BY name LIMIT ?",
sqlite_quote_ident(&schema),

View File

@ -1315,7 +1315,7 @@ fn sqlserver_completion_assistant_sql(request: &crate::types::CompletionAssistan
}
if queries.is_empty() {
format!("SELECT TOP (0) CAST('' AS NVARCHAR(128)) AS name, CAST('' AS NVARCHAR(128)) AS schema_name, CAST('' AS NVARCHAR(60)) AS object_type, CAST(NULL AS NVARCHAR(128)) AS parent_schema, CAST(NULL AS NVARCHAR(128)) AS parent_name, CAST(NULL AS NVARCHAR(MAX)) AS object_comment, CAST(NULL AS NVARCHAR(128)) AS data_type")
"SELECT TOP (0) CAST('' AS NVARCHAR(128)) AS name, CAST('' AS NVARCHAR(128)) AS schema_name, CAST('' AS NVARCHAR(60)) AS object_type, CAST(NULL AS NVARCHAR(128)) AS parent_schema, CAST(NULL AS NVARCHAR(128)) AS parent_name, CAST(NULL AS NVARCHAR(MAX)) AS object_comment, CAST(NULL AS NVARCHAR(128)) AS data_type".to_string()
} else if queries.len() == 1 {
format!("SELECT * FROM ({}) AS dbx_completion ORDER BY name", queries.remove(0))
} else {

View File

@ -198,7 +198,7 @@ async fn list_milvus_databases(client: &VectorClient) -> Result<Vec<String>, Str
if !names.iter().any(|name| name == "default") {
names.push("default".to_string());
}
names.sort_by(|a, b| a.cmp(b));
names.sort();
Ok(names)
}

View File

@ -53,7 +53,7 @@ pub async fn list_databases_core(state: &AppState, connection_id: &str) -> Resul
Err(error) => Err(error),
},
PoolKind::Elasticsearch(_) => Ok(vec!["default".to_string()]),
PoolKind::VectorDb(client) => vector_driver::list_databases(&client).await,
PoolKind::VectorDb(client) => vector_driver::list_databases(client).await,
PoolKind::Agent(client) => {
let mut client = client.lock().await;
match client.mongo_list_databases::<Vec<serde_json::Value>>().await {
@ -216,7 +216,7 @@ pub async fn list_collections_core(
.map(|n| CollectionInfo { name: n.clone(), id: n, dimension: None, kind: None, bucket_name: None })
.collect())
}
PoolKind::VectorDb(client) => vector_driver::list_collections_with_db(&client, database).await,
PoolKind::VectorDb(client) => vector_driver::list_collections_with_db(client, database).await,
PoolKind::Agent(client) => {
let mut client = client.lock().await;
let names = sort_names(client.mongo_list_collections(database).await?);

View File

@ -161,7 +161,7 @@ impl MessageQueueAdmin for PulsarAdmin {
async fn list_tenants(&self) -> Result<Vec<TenantInfo>, String> {
let names: Vec<String> = self.get_json(&self.profile.tenants_path()).await?;
stream::iter(names.into_iter())
stream::iter(names)
.map(|name| async move {
match self.get_tenant(&name).await {
Ok(tenant) => Ok(tenant),
@ -214,7 +214,7 @@ impl MessageQueueAdmin for PulsarAdmin {
async fn list_namespaces(&self, tenant: &str) -> Result<Vec<NamespaceInfo>, String> {
// Returns fully-qualified `tenant/namespace` strings.
let names: Vec<String> = self.get_json(&self.profile.namespaces_path(tenant)).await?;
stream::iter(names.into_iter())
stream::iter(names)
.map(|full| async move {
let namespace = full.rsplit('/').next().unwrap_or(&full).to_string();
let admin_roles = match self.namespace_admin_roles(tenant, &namespace).await {
@ -282,7 +282,7 @@ impl MessageQueueAdmin for PulsarAdmin {
};
let partitioned_set: std::collections::HashSet<String> = partitioned.iter().cloned().collect();
let domain = if persistent { "persistent" } else { "non-persistent" };
let partitioned_topics = stream::iter(partitioned.into_iter())
let partitioned_topics = stream::iter(partitioned)
.map(|full| async move {
let partitions = self.partition_count_for_topic(domain, &full).await?;
Ok::<_, String>(TopicInfo {

View File

@ -16,22 +16,16 @@ use super::util::truncate;
/// project's encrypted connection-secrets / keychain path and are never logged.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
#[derive(Default)]
pub enum MqAuth {
#[default]
None,
/// Pulsar JWT bearer token.
Token {
token: String,
},
Token { token: String },
/// HTTP Basic auth.
Basic {
username: String,
password: String,
},
Basic { username: String, password: String },
/// Arbitrary API key header, e.g. `Authorization: <value>` or a custom header.
ApiKey {
header: String,
value: String,
},
ApiKey { header: String, value: String },
/// OAuth2 client-credentials flow (Pulsar's `oauth2` auth plugin).
OAuth2 {
issuer_url: String,
@ -44,12 +38,6 @@ pub enum MqAuth {
},
}
impl Default for MqAuth {
fn default() -> Self {
MqAuth::None
}
}
/// Caches an OAuth2 access token with its expiry so repeated requests reuse it.
/// Also tracks in-flight token requests to prevent concurrent fetches.
#[derive(Debug, Default, Clone)]

View File

@ -31,6 +31,7 @@ impl MqSystemKind {
/// features are hidden rather than failing at call time.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(Default)]
pub struct MqCapabilities {
pub supports_tenants: bool,
pub supports_namespaces: bool,
@ -54,31 +55,6 @@ pub struct MqCapabilities {
pub supports_send_message: bool,
}
impl Default for MqCapabilities {
fn default() -> Self {
Self {
supports_tenants: false,
supports_namespaces: false,
supports_partitioned_topics: false,
supports_subscriptions: false,
supports_create_subscription: false,
supports_reset_cursor: false,
supports_skip_messages: false,
supports_clear_backlog: false,
supports_peek_messages: false,
supports_expire_messages: false,
supports_rate_limits: false,
supports_backlog_quota: false,
supports_retention: false,
supports_permissions: false,
supports_geo_replication: false,
supports_token_management: false,
supports_raw_admin_api: false,
supports_send_message: false,
}
}
}
/// Result of a connectivity test, including the detected server version and how
/// it was determined (probe vs. fallback) so the UI can warn appropriately.
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@ -4,15 +4,14 @@ use crate::models::connection::ConnectionConfig;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "camelCase")]
#[derive(Default)]
pub enum NacosAuthConfig {
#[default]
None,
UsernamePassword { username: String, password: String },
}
impl Default for NacosAuthConfig {
fn default() -> Self {
Self::None
}
UsernamePassword {
username: String,
password: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]

View File

@ -1063,7 +1063,9 @@ fn push_optional(params: &mut Vec<(String, String)>, key: &str, value: Option<St
}
}
fn build_publish_forms(req: NacosConfigUpsert, namespace: String) -> (Vec<(String, String)>, Vec<(String, String)>) {
type NacosForm = Vec<(String, String)>;
fn build_publish_forms(req: NacosConfigUpsert, namespace: String) -> (NacosForm, NacosForm) {
let mut v3_form = vec![
("dataId".to_string(), req.data_id.clone()),
("groupName".to_string(), req.group.clone()),

View File

@ -25,9 +25,11 @@ use crate::nacos::port::NacosAdmin;
pub use crate::nacos::config::{NacosAdminConfig as NacosConfig, NacosAuthConfig};
pub use crate::nacos::types::*;
type NacosAdminEntry = (NacosAdminConfig, Arc<dyn NacosAdmin>);
#[derive(Default)]
pub struct NacosAdminRegistry {
instances: RwLock<HashMap<String, (NacosAdminConfig, Arc<dyn NacosAdmin>)>>,
instances: RwLock<HashMap<String, NacosAdminEntry>>,
build_locks: RwLock<HashMap<String, Arc<Mutex<()>>>>,
}

View File

@ -129,7 +129,7 @@ pub async fn nacos_raw_request_core(
req: NacosRawRequest,
) -> Result<NacosRawResponse, String> {
crate::nacos::http::validate_raw_api_path(&req.path)?;
if req.method.to_ascii_uppercase() != "GET" {
if !req.method.eq_ignore_ascii_case("GET") {
ensure_connection_writable(state, conn_id, "Run mutating Nacos raw request").await?;
}
let admin = get_admin(state, conn_id).await?;

View File

@ -445,7 +445,7 @@ fn informix_view_definition(schema: Option<&str>, name: &str, source: &str) -> (
let view_name = captures.get(1).unwrap();
let target_name = strip_informix_owner_qualifiers(view_name.as_str(), schema);
let body = strip_informix_owner_qualifiers(&trimmed[view_name.end()..], schema);
return (target_name.trim().to_string(), body);
(target_name.trim().to_string(), body)
} else {
let body = strip_informix_owner_qualifiers(trimmed, schema);
(informix_identifier(name), format!(" AS\n{body}"))
@ -579,7 +579,7 @@ fn informix_owner_qualifier_replacement<'a>(source: &'a str, start: usize, schem
return None;
}
let ident_start = skip_sql_whitespace(source, dot + 1);
read_informix_identifier_text(source, ident_start).map(|(ident_end, ident_text)| (ident_end, ident_text))
read_informix_identifier_text(source, ident_start)
}
fn sql_single_quoted_literal_end(source: &str, start: usize) -> Option<usize> {

View File

@ -407,7 +407,7 @@ fn append_sql_target_safety_text(chars: &[char], result: &mut SqlTargetSafetyTex
continue;
}
if ch == '/' && next == Some('*') {
if let Some((body, close_index)) = mysql_executable_comment_body(&chars, index) {
if let Some((body, close_index)) = mysql_executable_comment_body(chars, index) {
result.text.push(' ');
let body_chars: Vec<char> = body.chars().collect();
append_sql_target_safety_text(&body_chars, result);
@ -423,7 +423,7 @@ fn append_sql_target_safety_text(chars: &[char], result: &mut SqlTargetSafetyTex
}
continue;
}
if let Some((tag, tag_len)) = dollar_quote_tag_at(&chars, index) {
if let Some((tag, tag_len)) = dollar_quote_tag_at(chars, index) {
index += tag_len;
while index + tag_len <= chars.len() && !chars[index..index + tag_len].iter().collect::<String>().eq(&tag) {
index += 1;
@ -433,7 +433,7 @@ fn append_sql_target_safety_text(chars: &[char], result: &mut SqlTargetSafetyTex
continue;
}
if ch == '\'' {
index = skip_string_literal(&chars, index, '\'', '\'');
index = skip_string_literal(chars, index, '\'', '\'');
result.text.push(' ');
continue;
}

View File

@ -12,21 +12,16 @@ pub struct RunningQueryDiagnostics {
type InterruptFn = Box<dyn Fn() + Send + 'static>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum RunningTaskKind {
Query,
Count,
Explain,
Export,
#[default]
Unknown,
}
impl Default for RunningTaskKind {
fn default() -> Self {
Self::Unknown
}
}
#[derive(Clone, Debug, Default)]
pub struct RunningTaskMetadata {
pub kind: RunningTaskKind,

View File

@ -158,7 +158,7 @@ fn effective_row_limit(format: &str, request: &QueryResultExportRequest) -> Opti
}
fn xlsx_hard_limit_active(format: &str, request: &QueryResultExportRequest) -> bool {
format == "xlsx" && request.row_limit.map_or(true, |limit| limit > XLSX_MAX_DATA_ROWS)
format == "xlsx" && request.row_limit.is_none_or(|limit| limit > XLSX_MAX_DATA_ROWS)
}
fn format_text_export_header(format: &str, columns: &[String]) -> String {

View File

@ -963,7 +963,7 @@ fn add_standard_limit(
offset: usize,
dedup_projection_count: Option<usize>,
) -> String {
let order_sql = dedup_projection_count.map_or(String::new(), |count| format_positional_order_by(count));
let order_sql = dedup_projection_count.map_or(String::new(), format_positional_order_by);
if has_top_level_limit(statement) {
if !order_sql.is_empty() {

View File

@ -774,12 +774,10 @@ fn command_may_target_first_key(argv: &[String]) -> bool {
if argv.len() < 2 {
return false;
}
match argv[0].to_ascii_uppercase().as_str() {
"PING" | "INFO" | "DBSIZE" | "TIME" | "ROLE" | "CLUSTER" | "CLIENT" | "COMMAND" | "HELLO" | "AUTH" | "QUIT" => {
false
}
_ => true,
}
!matches!(
argv[0].to_ascii_uppercase().as_str(),
"PING" | "INFO" | "DBSIZE" | "TIME" | "ROLE" | "CLUSTER" | "CLIENT" | "COMMAND" | "HELLO" | "AUTH" | "QUIT"
)
}
pub async fn redis_load_more_in_db_core(

View File

@ -1611,7 +1611,7 @@ fn oracle_object_info_can_have_table_comment(object: &db::ObjectInfo) -> bool {
}
fn oracle_type_is_table_or_view(value: &str) -> bool {
let normalized = value.to_ascii_uppercase().replace(' ', "_").replace('-', "_");
let normalized = value.to_ascii_uppercase().replace([' ', '-'], "_");
matches!(normalized.as_str(), "TABLE" | "BASE_TABLE" | "VIEW")
}

View File

@ -395,9 +395,9 @@ impl SqlStatementSplitter {
self.buffer.push(ch);
}
';' if !self.in_single_quote && !self.in_double_quote && !self.in_backtick => {
if self.options.profile.supports_custom_delimiter_commands && self.on_delimiter_line() {
self.buffer.push(ch);
} else if self.custom_delimiter.is_some() {
if (self.options.profile.supports_custom_delimiter_commands && self.on_delimiter_line())
|| self.custom_delimiter.is_some()
{
self.buffer.push(ch);
} else if self.options.profile.supports_mysql_routine_blocks
&& starts_with_mysql_routine_block(&self.buffer)
@ -1567,6 +1567,7 @@ fn parse_insert_values_tail(tail: &str) -> Option<String> {
}
}
#[derive(Default)]
struct SqlScanner {
profile: SqlDialectProfile,
in_single_quote: bool,
@ -1609,9 +1610,7 @@ impl SqlScanner {
}
if !self.in_single_quote && !self.in_double_quote && !self.in_backtick {
if ch == '-' && next == Some('-') {
self.in_line_comment = true;
} else if self.profile.supports_hash_line_comments && ch == '#' {
if (ch == '-' && next == Some('-')) || (self.profile.supports_hash_line_comments && ch == '#') {
self.in_line_comment = true;
} else if ch == '/' && next == Some('*') {
self.in_block_comment = true;
@ -1647,21 +1646,6 @@ impl SqlScanner {
}
}
impl Default for SqlScanner {
fn default() -> Self {
Self {
profile: SqlDialectProfile::default(),
in_single_quote: false,
in_double_quote: false,
in_backtick: false,
in_line_comment: false,
in_block_comment: false,
dollar_quote_tag: None,
previous: None,
}
}
}
fn keyword_at(sql: &str, idx: usize, keyword: &str) -> bool {
let end = idx + keyword.len();
sql.get(idx..end).is_some_and(|candidate| candidate.eq_ignore_ascii_case(keyword))

View File

@ -158,25 +158,21 @@ fn redact_sensitive_assignments(sql: &str) -> String {
while j < chars.len() && chars[j].is_whitespace() {
j += 1;
}
if j < chars.len() && (chars[j] == '=' || chars[j] == ':') {
if is_sensitive_key(&key) {
out.push_str(&key);
for k in i..j {
out.push(chars[k]);
}
if j < chars.len() && (chars[j] == '=' || chars[j] == ':') && is_sensitive_key(&key) {
out.push_str(&key);
out.extend(chars[i..j].iter().copied());
out.push(chars[j]);
j += 1;
while j < chars.len() && chars[j].is_whitespace() {
out.push(chars[j]);
j += 1;
while j < chars.len() && chars[j].is_whitespace() {
out.push(chars[j]);
j += 1;
}
while j < chars.len() && !chars[j].is_whitespace() {
j += 1;
}
out.push_str("[REDACTED]");
i = j;
continue;
}
while j < chars.len() && !chars[j].is_whitespace() {
j += 1;
}
out.push_str("[REDACTED]");
i = j;
continue;
}
out.push_str(&key);
}

View File

@ -1063,10 +1063,10 @@ fn merge_missing_tunnel_profile_secrets(profile: &mut TransportLayerConfig, prev
current.password = previous.password.clone();
}
}
(TransportLayerConfig::HttpTunnel(current), TransportLayerConfig::HttpTunnel(previous)) => {
if current.token.is_empty() {
current.token = previous.token.clone();
}
(TransportLayerConfig::HttpTunnel(current), TransportLayerConfig::HttpTunnel(previous))
if current.token.is_empty() =>
{
current.token = previous.token.clone();
}
_ => {}
}
@ -3246,7 +3246,7 @@ mod tests {
let storage = Storage::open(&path).await.unwrap();
let original = mq_connection("pulsar", "existing-token");
storage.save_connections(&[original.clone()]).await.unwrap();
storage.save_connections(std::slice::from_ref(&original)).await.unwrap();
let mut metadata = original;
metadata.name = "Pulsar renamed".to_string();

View File

@ -547,7 +547,7 @@ async fn try_export_native_table_stream(
let row_csv = format_csv_rows(&[formatted]);
write!(file, "\n{row_csv}").map_err(|e| format!("Failed to write CSV rows: {e}"))?;
rows_exported += 1;
if rows_exported % progress_interval == 0 {
if rows_exported.is_multiple_of(progress_interval) {
on_progress(TableExportProgress {
export_id: request.export_id.clone(),
table_name: request.table_name.clone(),
@ -591,7 +591,7 @@ async fn try_export_native_table_stream(
let row_tsv = format_tsv_rows(&[formatted]);
write!(file, "\n{row_tsv}").map_err(|e| format!("Failed to write TXT rows: {e}"))?;
rows_exported += 1;
if rows_exported % progress_interval == 0 {
if rows_exported.is_multiple_of(progress_interval) {
on_progress(TableExportProgress {
export_id: request.export_id.clone(),
table_name: request.table_name.clone(),
@ -636,7 +636,7 @@ async fn try_export_native_table_stream(
);
writer.write_row(&formatted).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
rows_exported += 1;
if rows_exported % progress_interval == 0 {
if rows_exported.is_multiple_of(progress_interval) {
on_progress(TableExportProgress {
export_id: request.export_id.clone(),
table_name: request.table_name.clone(),
@ -691,7 +691,7 @@ async fn try_export_native_table_stream(
write_json_row_object(&mut file, col_names, &formatted)?;
is_first_row = false;
rows_exported += 1;
if rows_exported % progress_interval == 0 {
if rows_exported.is_multiple_of(progress_interval) {
on_progress(TableExportProgress {
export_id: request.export_id.clone(),
table_name: request.table_name.clone(),
@ -742,7 +742,7 @@ async fn try_export_native_table_stream(
wrote_rows = true;
}
rows_exported += 1;
if rows_exported % progress_interval == 0 {
if rows_exported.is_multiple_of(progress_interval) {
on_progress(TableExportProgress {
export_id: request.export_id.clone(),
table_name: request.table_name.clone(),
@ -808,7 +808,7 @@ async fn try_export_native_table_stream(
flush_pending(&mut file, &mut pending_rows)?;
}
rows_exported += 1;
if rows_exported % progress_interval == 0 {
if rows_exported.is_multiple_of(progress_interval) {
on_progress(TableExportProgress {
export_id: request.export_id.clone(),
table_name: request.table_name.clone(),
@ -839,7 +839,7 @@ async fn try_export_native_table_stream(
match stream_result {
Ok(false) => Ok(false),
Ok(true) => {
if rows_exported % progress_interval != 0 {
if !rows_exported.is_multiple_of(progress_interval) {
on_progress(TableExportProgress {
export_id: request.export_id.clone(),
table_name: request.table_name.clone(),

View File

@ -2085,9 +2085,7 @@ where
pending_rows.push(delimited_record_to_row(&record, columns.len(), config));
}
let mut source_row_number = config.row_range.data_start_row;
for record in reader.records() {
source_row_number += 1;
for (source_row_number, record) in (config.row_range.data_start_row.saturating_add(1)..).zip(reader.records()) {
if config.row_range.last_data_row.is_some_and(|last| source_row_number > last) {
break;
}

View File

@ -344,12 +344,7 @@ pub(crate) fn wrap_dameng_identity_insert_sql(insert_sql: &str, table: &str, sch
pub(crate) fn wrap_dameng_identity_insert_sql_for_table(insert_sql: &str, full_table: &str) -> String {
let trimmed = insert_sql.trim().trim_end_matches(';').trim();
format!(
"{};\n{};\n{};",
format!("SET IDENTITY_INSERT {full_table} ON"),
trimmed,
format!("SET IDENTITY_INSERT {full_table} OFF")
)
format!("SET IDENTITY_INSERT {full_table} ON;\n{trimmed};\nSET IDENTITY_INSERT {full_table} OFF;")
}
async fn execute_transfer_write_statement(
@ -4139,7 +4134,7 @@ where
can_reuse_source_table_ddl(source_db_type, target_db_type, preserves_target_table_name);
let ddl = if can_reuse_source_ddl {
let source_ddl = crate::schema::get_table_ddl_core(
&state,
state,
&request.source_connection_id,
&request.source_database,
&request.source_schema,
@ -5222,19 +5217,13 @@ mod tests {
#[test]
fn transfer_create_table_result_treats_existing_table_as_preexisting() {
assert_eq!(
transfer_create_table_created(
Err("ERROR: relation \"items\" already exists (SQLSTATE 42P07)".to_string()),
"create"
)
.unwrap(),
false
);
assert_eq!(
transfer_create_table_created(Err("错误: 关系 \"items\" 已经存在".to_string()), "create").unwrap(),
false
);
assert_eq!(transfer_create_table_created(Ok(()), "create").unwrap(), true);
assert!(!transfer_create_table_created(
Err("ERROR: relation \"items\" already exists (SQLSTATE 42P07)".to_string()),
"create"
)
.unwrap());
assert!(!transfer_create_table_created(Err("错误: 关系 \"items\" 已经存在".to_string()), "create").unwrap());
assert!(transfer_create_table_created(Ok(()), "create").unwrap());
assert_eq!(
transfer_create_table_created(Err("permission denied for schema public".to_string()), "create")
.unwrap_err(),

View File

@ -630,6 +630,8 @@ fn default_permissions() -> dbx_core::agent_tools::AgentSqlPermissions {
}
}
// CallToolResult is the transport-native error payload; boxing it would complicate every MCP call site.
#[allow(clippy::result_large_err)]
fn validate_mongo_command(
connection: &dbx_core::models::connection::ConnectionConfig,
database: &str,

View File

@ -9,11 +9,13 @@ name = "dbx-web"
path = "src/main.rs"
[features]
default = ["mq-admin"]
default = ["duckdb-bundled", "mq-admin", "sqlite-sqlcipher"]
duckdb-bundled = ["dbx-core/duckdb-bundled"]
mq-admin = ["dbx-core/mq-admin"]
sqlite-sqlcipher = ["dbx-core/sqlite-sqlcipher"]
[dependencies]
dbx-core = { path = "../dbx-core" }
dbx-core = { path = "../dbx-core", default-features = false }
redis = { version = "0.32", features = ["tokio-comp"] }
axum = { version = "0.8", features = ["multipart", "ws"] }
tower-http = { version = "0.6", features = ["cors", "fs", "compression-gzip", "trace"] }

View File

@ -66,63 +66,6 @@ fn normalize_public_base_path(value: Option<String>) -> String {
}
}
#[cfg(test)]
mod tests {
use super::{normalize_public_base_path, web_agent_dir_from_env, web_compression_predicate, XLSX_CONTENT_TYPE};
use axum::body::Body;
use axum::http::header::CONTENT_TYPE;
use axum::http::Response;
use tower_http::compression::predicate::Predicate;
fn compression_response(content_type: &str) -> Response<Body> {
Response::builder().header(CONTENT_TYPE, content_type).body(Body::from(vec![b'x'; 64])).unwrap()
}
#[test]
fn web_compression_skips_streams_and_precompressed_exports() {
let predicate = web_compression_predicate();
assert!(predicate.should_compress(&compression_response("application/json")));
assert!(!predicate.should_compress(&compression_response("text/event-stream")));
assert!(!predicate.should_compress(&compression_response(XLSX_CONTENT_TYPE)));
}
#[test]
fn normalize_public_base_path_defaults_to_root() {
assert_eq!(normalize_public_base_path(None), "/");
assert_eq!(normalize_public_base_path(Some("".to_string())), "/");
assert_eq!(normalize_public_base_path(Some("/".to_string())), "/");
}
#[test]
fn normalize_public_base_path_trims_and_preserves_segments() {
assert_eq!(normalize_public_base_path(Some("dbx".to_string())), "/dbx");
assert_eq!(normalize_public_base_path(Some("/dbx/".to_string())), "/dbx");
assert_eq!(normalize_public_base_path(Some("/tools/dbx/?v=1".to_string())), "/tools/dbx");
}
#[test]
#[should_panic(expected = "DBX_PUBLIC_BASE_PATH contains invalid characters")]
fn normalize_public_base_path_rejects_invalid_characters() {
normalize_public_base_path(Some("/dbx admin".to_string()));
}
#[test]
fn web_agent_dir_defaults_under_data_dir() {
let data_dir = std::path::PathBuf::from("/app/data");
assert_eq!(web_agent_dir_from_env(&data_dir, None), data_dir.join("agents"));
}
#[test]
fn web_agent_dir_uses_explicit_env_override() {
let data_dir = std::path::PathBuf::from("/app/data");
assert_eq!(
web_agent_dir_from_env(&data_dir, Some("/custom/agents".to_string())),
std::path::PathBuf::from("/custom/agents")
);
}
}
#[cfg(feature = "mq-admin")]
fn add_mq_routes(router: Router<Arc<WebState>>) -> Router<Arc<WebState>> {
router
@ -363,7 +306,6 @@ async fn main() {
.route("/query/build-search-result-where", post(routes::query::build_search_result_where))
.route("/query/build-rename-object-sql", post(routes::query::build_rename_object_sql))
.route("/query/build-create-database-sql", post(routes::query::build_create_database_sql))
.route("/query/build-duckdb-attach-database-sql", post(routes::query::build_duckdb_attach_database_sql))
.route("/query/build-sqlite-attach-database-sql", post(routes::query::build_sqlite_attach_database_sql))
.route("/query/build-drop-object-sql", post(routes::query::build_drop_object_sql))
.route("/query/build-drop-table-sql", post(routes::query::build_drop_table_sql))
@ -632,6 +574,11 @@ async fn main() {
.route("/cloud-sync/snippet/upload", post(routes::cloud_sync::snippet_sync_upload))
.route("/cloud-sync/snippet/download", post(routes::cloud_sync::snippet_sync_download));
// Do not expose DuckDB-only handlers from builds that intentionally omit bundled DuckDB.
#[cfg(feature = "duckdb-bundled")]
let api =
api.route("/query/build-duckdb-attach-database-sql", post(routes::query::build_duckdb_attach_database_sql));
let api = add_mq_routes(api)
.layer(middleware::from_fn_with_state(web_state.clone(), auth::auth_middleware))
.with_state(web_state.clone());
@ -681,3 +628,60 @@ async fn main() {
.expect("Server error");
shutdown_state.shutdown_background_tasks(std::time::Duration::from_secs(3)).await;
}
#[cfg(test)]
mod tests {
use super::{normalize_public_base_path, web_agent_dir_from_env, web_compression_predicate, XLSX_CONTENT_TYPE};
use axum::body::Body;
use axum::http::header::CONTENT_TYPE;
use axum::http::Response;
use tower_http::compression::predicate::Predicate;
fn compression_response(content_type: &str) -> Response<Body> {
Response::builder().header(CONTENT_TYPE, content_type).body(Body::from(vec![b'x'; 64])).unwrap()
}
#[test]
fn web_compression_skips_streams_and_precompressed_exports() {
let predicate = web_compression_predicate();
assert!(predicate.should_compress(&compression_response("application/json")));
assert!(!predicate.should_compress(&compression_response("text/event-stream")));
assert!(!predicate.should_compress(&compression_response(XLSX_CONTENT_TYPE)));
}
#[test]
fn normalize_public_base_path_defaults_to_root() {
assert_eq!(normalize_public_base_path(None), "/");
assert_eq!(normalize_public_base_path(Some("".to_string())), "/");
assert_eq!(normalize_public_base_path(Some("/".to_string())), "/");
}
#[test]
fn normalize_public_base_path_trims_and_preserves_segments() {
assert_eq!(normalize_public_base_path(Some("dbx".to_string())), "/dbx");
assert_eq!(normalize_public_base_path(Some("/dbx/".to_string())), "/dbx");
assert_eq!(normalize_public_base_path(Some("/tools/dbx/?v=1".to_string())), "/tools/dbx");
}
#[test]
#[should_panic(expected = "DBX_PUBLIC_BASE_PATH contains invalid characters")]
fn normalize_public_base_path_rejects_invalid_characters() {
normalize_public_base_path(Some("/dbx admin".to_string()));
}
#[test]
fn web_agent_dir_defaults_under_data_dir() {
let data_dir = std::path::PathBuf::from("/app/data");
assert_eq!(web_agent_dir_from_env(&data_dir, None), data_dir.join("agents"));
}
#[test]
fn web_agent_dir_uses_explicit_env_override() {
let data_dir = std::path::PathBuf::from("/app/data");
assert_eq!(
web_agent_dir_from_env(&data_dir, Some("/custom/agents".to_string())),
std::path::PathBuf::from("/custom/agents")
);
}
}

View File

@ -673,7 +673,7 @@ mod tests {
let (state, dir) = test_web_state().await;
let initial = mq_config("mq-conn", "http://127.0.0.1:8080");
let updated = mq_config("mq-conn", "http://127.0.0.1:8081");
state.app.storage.save_connections(&[updated.clone()]).await.unwrap();
state.app.storage.save_connections(std::slice::from_ref(&updated)).await.unwrap();
state.app.configs.write().await.insert(initial.id.clone(), initial.clone());
state.app.connections.write().await.insert(initial.id.clone(), PoolKind::MessageQueue);

View File

@ -172,6 +172,23 @@ pub async fn list_system_fonts() -> Result<Json<Vec<String>>, AppError> {
Ok(Json(jdbc::list_system_fonts()))
}
async fn progress_sender(state: &WebState, operation_id: &str) -> broadcast::Sender<String> {
let mut channels = state.sse_channels.write().await;
channels
.entry(format!("agent-install-progress:{operation_id}"))
.or_insert_with(|| {
let (tx, _) = broadcast::channel::<String>(256);
tx
})
.clone()
}
fn send_progress_event(tx: &broadcast::Sender<String>, event: AgentProgressEvent) {
if let Ok(payload) = serde_json::to_string(&event) {
let _ = tx.send(payload);
}
}
#[cfg(test)]
mod tests {
use super::safe_upload_file_name;
@ -191,20 +208,3 @@ mod tests {
assert_eq!(file_name, "postgresql-42.7.jar");
}
}
async fn progress_sender(state: &WebState, operation_id: &str) -> broadcast::Sender<String> {
let mut channels = state.sse_channels.write().await;
channels
.entry(format!("agent-install-progress:{operation_id}"))
.or_insert_with(|| {
let (tx, _) = broadcast::channel::<String>(256);
tx
})
.clone()
}
fn send_progress_event(tx: &broadcast::Sender<String>, event: AgentProgressEvent) {
if let Ok(payload) = serde_json::to_string(&event) {
let _ = tx.send(payload);
}
}

View File

@ -137,6 +137,7 @@ pub struct BuildCreateDatabaseSqlRequest {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg(feature = "duckdb-bundled")]
pub struct BuildDuckDbAttachDatabaseSqlRequest {
pub options: dbx_core::db_admin_sql::DuckDbAttachDatabaseSqlOptions,
}
@ -599,6 +600,7 @@ pub async fn build_create_database_sql(
dbx_core::db_admin_sql::build_create_database_sql(req.options).map(Json).map_err(AppError)
}
#[cfg(feature = "duckdb-bundled")]
pub async fn build_duckdb_attach_database_sql(Json(req): Json<BuildDuckDbAttachDatabaseSqlRequest>) -> Json<String> {
Json(dbx_core::db_admin_sql::build_duckdb_attach_database_sql(req.options))
}

View File

@ -436,7 +436,7 @@ mod tests {
let first = state.mq_registry.get_or_build(&initial).await.unwrap();
let updated = mq_config("mq-conn", "http://127.0.0.1:8081");
save_connection_configs(&state, &[updated.clone()]).await.unwrap();
save_connection_configs(&state, std::slice::from_ref(&updated)).await.unwrap();
let cached_admin_url = state
.configs
@ -465,7 +465,7 @@ mod tests {
let state = AppState::new_with_plugin_dir(storage, dir.join("plugins"));
let initial = mq_config("mq-conn", "http://127.0.0.1:8080");
let updated = mq_config("mq-conn", "http://127.0.0.1:8081");
state.storage.save_connections(&[updated.clone()]).await.unwrap();
state.storage.save_connections(std::slice::from_ref(&updated)).await.unwrap();
state.configs.write().await.insert(initial.id.clone(), initial.clone());
state.connections.write().await.insert(initial.id.clone(), PoolKind::MessageQueue);
@ -503,7 +503,7 @@ mod tests {
}
let stale = state.mq_registry.get_or_build(&removed).await.unwrap();
save_connection_configs(&state, &[kept.clone()]).await.unwrap();
save_connection_configs(&state, std::slice::from_ref(&kept)).await.unwrap();
let configs = state.configs.read().await;
assert!(configs.contains_key(&kept.id));
@ -532,7 +532,7 @@ mod tests {
}
state.connections.write().await.insert(removed.id.clone(), PoolKind::MessageQueue);
save_connection_configs(&state, &[kept.clone()]).await.unwrap();
save_connection_configs(&state, std::slice::from_ref(&kept)).await.unwrap();
assert!(!state.connections.read().await.contains_key(&removed.id));
@ -985,7 +985,7 @@ async fn test_connection_with_info_inner(
#[cfg(feature = "mq-admin")]
DatabaseType::MessageQueue => {
let mqc = state.mq_admin_config_for_connection(connection_id, &config).await?;
let kafka_launch = dbx_core::mq::service::resolve_kafka_launch_spec(&mqc, &state);
let kafka_launch = dbx_core::mq::service::resolve_kafka_launch_spec(&mqc, state);
let adapter = match state.mq_registry.get_or_build_config(connection_id, mqc, kafka_launch).await {
Ok(adapter) => adapter,
Err(err) => {

View File

@ -290,57 +290,6 @@ fn find_config_by_name<'a>(
configs.iter().find(|c| c.name.eq_ignore_ascii_case(name))
}
#[cfg(test)]
mod tests {
use super::{resolve_mongo_database, resolve_mongo_target_values, write_port_file};
#[test]
fn writes_bridge_port_file_to_resolved_data_dir() {
let root = std::env::temp_dir().join(format!(
"dbx-mcp-bridge-port-test-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
let default_data_dir = root.join("default-app-data");
let resolved_data_dir = root.join("resolved-data");
std::fs::create_dir_all(&default_data_dir).unwrap();
let port_file = write_port_file(&resolved_data_dir, 49152).unwrap();
assert_eq!(port_file, resolved_data_dir.join("mcp-bridge-port"));
assert_eq!(std::fs::read_to_string(port_file).unwrap(), "49152");
assert!(!default_data_dir.join("mcp-bridge-port").exists());
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn mongo_database_uses_configured_default_for_missing_or_blank_request() {
let configured = Some("sample_db".to_string());
assert_eq!(resolve_mongo_database(None, configured.clone()), "sample_db");
assert_eq!(resolve_mongo_database(Some(String::new()), configured.clone()), "sample_db");
assert_eq!(resolve_mongo_database(Some(" ".to_string()), configured), "sample_db");
}
#[test]
fn mongo_database_preserves_explicit_target() {
assert_eq!(resolve_mongo_database(Some("admin".to_string()), Some("sample_db".to_string())), "admin");
}
#[test]
fn mongo_target_keeps_connection_id_separate_from_database() {
assert_eq!(
resolve_mongo_target_values(
"connection-id".to_string(),
Some("sample_db".to_string()),
Some("default_db".to_string()),
),
("connection-id".to_string(), "sample_db".to_string())
);
}
}
async fn respond(stream: &mut tokio::net::TcpStream, status: &str, body: &str) {
let resp = format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\n\r\n{body}", body.len());
let _ = stream.write_all(resp.as_bytes()).await;
@ -969,3 +918,54 @@ async fn handle_execute_query_data(state: &Arc<AppState>, body: &str, stream: &m
Err(e) => respond_error(stream, "500 Internal Server Error", &e).await,
}
}
#[cfg(test)]
mod tests {
use super::{resolve_mongo_database, resolve_mongo_target_values, write_port_file};
#[test]
fn writes_bridge_port_file_to_resolved_data_dir() {
let root = std::env::temp_dir().join(format!(
"dbx-mcp-bridge-port-test-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
let default_data_dir = root.join("default-app-data");
let resolved_data_dir = root.join("resolved-data");
std::fs::create_dir_all(&default_data_dir).unwrap();
let port_file = write_port_file(&resolved_data_dir, 49152).unwrap();
assert_eq!(port_file, resolved_data_dir.join("mcp-bridge-port"));
assert_eq!(std::fs::read_to_string(port_file).unwrap(), "49152");
assert!(!default_data_dir.join("mcp-bridge-port").exists());
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn mongo_database_uses_configured_default_for_missing_or_blank_request() {
let configured = Some("sample_db".to_string());
assert_eq!(resolve_mongo_database(None, configured.clone()), "sample_db");
assert_eq!(resolve_mongo_database(Some(String::new()), configured.clone()), "sample_db");
assert_eq!(resolve_mongo_database(Some(" ".to_string()), configured), "sample_db");
}
#[test]
fn mongo_database_preserves_explicit_target() {
assert_eq!(resolve_mongo_database(Some("admin".to_string()), Some("sample_db".to_string())), "admin");
}
#[test]
fn mongo_target_keeps_connection_id_separate_from_database() {
assert_eq!(
resolve_mongo_target_values(
"connection-id".to_string(),
Some("sample_db".to_string()),
Some("default_db".to_string()),
),
("connection-id".to_string(), "sample_db".to_string())
);
}
}

View File

@ -238,7 +238,6 @@ mod execution_tests {
assert_eq!(summary.status, SqlFileStatus::Cancelled);
}
#[test]
#[test]
fn duplicate_execution_id_is_rejected_without_replacing_token() {
let mut executions = HashMap::new();

View File

@ -334,9 +334,7 @@ fn linux_appimage_system_gtk_immodules_cache(
let Some(gtk_im_module_file) = gtk_im_module_file else {
return Some(system_cache_path);
};
let Some(appdir) = appdir else {
return None;
};
let appdir = appdir?;
if std::path::Path::new(gtk_im_module_file).starts_with(std::path::Path::new(appdir)) {
Some(system_cache_path)
@ -526,7 +524,7 @@ fn apply_macos_app_icon_theme(app: &tauri::AppHandle, icon_theme: DesktopIconThe
fn apply_desktop_icon_theme(app: &tauri::AppHandle, icon_theme: DesktopIconTheme) -> tauri::Result<()> {
#[cfg(target_os = "macos")]
{
return apply_macos_app_icon_theme(app, icon_theme);
apply_macos_app_icon_theme(app, icon_theme)
}
#[cfg(not(target_os = "macos"))]
@ -1010,11 +1008,11 @@ pub fn run() {
let app = window.app_handle();
if app.try_state::<CloseBehaviorState>().is_none() {
api.prevent_close();
hide_main_window_for_close(&app, window);
hide_main_window_for_close(app, window);
return;
}
api.prevent_close();
request_app_close(&app, "settings");
request_app_close(app, "settings");
}
})
.invoke_handler(tauri::generate_handler![

View File

@ -1,7 +1,4 @@
use std::{
ffi::CStr,
sync::{Once, OnceLock},
};
use std::sync::{Once, OnceLock};
use objc2::{
ffi,
@ -19,9 +16,7 @@ static INSTALL_DOCK_QUIT_HANDLER: Once = Once::new();
pub(crate) fn install_dock_quit_handler(app: &AppHandle) {
let _ = APP_HANDLE.set(app.clone());
INSTALL_DOCK_QUIT_HANDLER.call_once(|| {
let Some(delegate_class) =
AnyClass::get(CStr::from_bytes_with_nul(b"TaoAppDelegateParent\0").expect("valid class name"))
else {
let Some(delegate_class) = AnyClass::get(c"TaoAppDelegateParent") else {
eprintln!("[WARN] failed to install macOS Dock quit handler: TaoAppDelegateParent not found");
return;
};
@ -32,7 +27,7 @@ pub(crate) fn install_dock_quit_handler(app: &AppHandle) {
as extern "C-unwind" fn(&AnyObject, Sel, &AnyObject) -> NSApplicationTerminateReply,
)
};
let method_types = CStr::from_bytes_with_nul(b"Q@:@\0").expect("valid Objective-C method type encoding");
let method_types = c"Q@:@";
// Tao's app delegate does not implement applicationShouldTerminate:, so Dock Quit can
// bypass Tauri's ExitRequested event. Add the method to Tao's registered delegate class