diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bc39ee84..8fe440fc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 000000000..15906305c --- /dev/null +++ b/clippy.toml @@ -0,0 +1 @@ +too-many-arguments-threshold = 10 diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index b7fe749bf..658e32fbb 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -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() } } diff --git a/crates/dbx-core/src/db/duckdb_driver.rs b/crates/dbx-core/src/db/duckdb_driver.rs index 5d30a0f8f..c64cd1815 100644 --- a/crates/dbx-core/src/db/duckdb_driver.rs +++ b/crates/dbx-core/src/db/duckdb_driver.rs @@ -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 { self.connection.into_inner() } diff --git a/crates/dbx-core/src/db/duckdb_sql.rs b/crates/dbx-core/src/db/duckdb_sql.rs index 4593a8ae4..b65b98381 100644 --- a/crates/dbx-core/src/db/duckdb_sql.rs +++ b/crates/dbx-core/src/db/duckdb_sql.rs @@ -183,13 +183,15 @@ fn find_as_keyword_outside_quotes(sql: &str) -> Option { } 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); } _ => {} } diff --git a/crates/dbx-core/src/db/duckdb_worker_process.rs b/crates/dbx-core/src/db/duckdb_worker_process.rs index da01c7c7a..ecce7e9c2 100644 --- a/crates/dbx-core/src/db/duckdb_worker_process.rs +++ b/crates/dbx-core/src/db/duckdb_worker_process.rs @@ -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::>(); ids.into_iter().filter_map(|id| pending.remove(&id).map(|request| (id, request.sender))).collect::>() }; @@ -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::>(); ids.into_iter().filter_map(|id| pending.remove(&id).map(|request| (id, request.sender))).collect::>() }; diff --git a/crates/dbx-core/src/db/influxdb_driver.rs b/crates/dbx-core/src/db/influxdb_driver.rs index 234ae865f..3d0c05e13 100644 --- a/crates/dbx-core/src/db/influxdb_driver.rs +++ b/crates/dbx-core/src/db/influxdb_driver.rs @@ -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 { 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 { 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 { diff --git a/crates/dbx-core/src/db/mongo_driver.rs b/crates/dbx-core/src/db/mongo_driver.rs index 2d5fafb73..f81de6f2f 100644 --- a/crates/dbx-core/src/db/mongo_driver.rs +++ b/crates/dbx-core/src/db/mongo_driver.rs @@ -1692,7 +1692,7 @@ fn parse_legacy_mongo_date_display(value: &str) -> Option { 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) -> Option { @@ -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)); diff --git a/crates/dbx-core/src/db/mysql.rs b/crates/dbx-core/src/db/mysql.rs index 8d2f32cff..755325fe6 100644 --- a/crates/dbx-core/src/db/mysql.rs +++ b/crates/dbx-core/src/db/mysql.rs @@ -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] diff --git a/crates/dbx-core/src/db/sqlite.rs b/crates/dbx-core/src/db/sqlite.rs index 8b818117c..556a1e355 100644 --- a/crates/dbx-core/src/db/sqlite.rs +++ b/crates/dbx-core/src/db/sqlite.rs @@ -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::>().join(", "); + let placeholders = std::iter::repeat_n("?", type_filters.len()).collect::>().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), diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs index 9d57735a9..fdcd97961 100644 --- a/crates/dbx-core/src/db/sqlserver.rs +++ b/crates/dbx-core/src/db/sqlserver.rs @@ -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 { diff --git a/crates/dbx-core/src/db/vector_driver.rs b/crates/dbx-core/src/db/vector_driver.rs index 36510e8e7..3660300da 100644 --- a/crates/dbx-core/src/db/vector_driver.rs +++ b/crates/dbx-core/src/db/vector_driver.rs @@ -198,7 +198,7 @@ async fn list_milvus_databases(client: &VectorClient) -> Result, 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) } diff --git a/crates/dbx-core/src/document_ops.rs b/crates/dbx-core/src/document_ops.rs index 1a4b9450c..d9e6be0c1 100644 --- a/crates/dbx-core/src/document_ops.rs +++ b/crates/dbx-core/src/document_ops.rs @@ -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::>().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?); diff --git a/crates/dbx-core/src/mq/adapters/pulsar.rs b/crates/dbx-core/src/mq/adapters/pulsar.rs index 5cc0fda3c..85ea74e17 100644 --- a/crates/dbx-core/src/mq/adapters/pulsar.rs +++ b/crates/dbx-core/src/mq/adapters/pulsar.rs @@ -161,7 +161,7 @@ impl MessageQueueAdmin for PulsarAdmin { async fn list_tenants(&self) -> Result, String> { let names: Vec = 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, String> { // Returns fully-qualified `tenant/namespace` strings. let names: Vec = 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 = 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 { diff --git a/crates/dbx-core/src/mq/auth.rs b/crates/dbx-core/src/mq/auth.rs index 124041786..55a31e1c9 100644 --- a/crates/dbx-core/src/mq/auth.rs +++ b/crates/dbx-core/src/mq/auth.rs @@ -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: ` 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)] diff --git a/crates/dbx-core/src/mq/types.rs b/crates/dbx-core/src/mq/types.rs index d07361808..17996f000 100644 --- a/crates/dbx-core/src/mq/types.rs +++ b/crates/dbx-core/src/mq/types.rs @@ -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)] diff --git a/crates/dbx-core/src/nacos/config.rs b/crates/dbx-core/src/nacos/config.rs index e8f8df934..c375e93bc 100644 --- a/crates/dbx-core/src/nacos/config.rs +++ b/crates/dbx-core/src/nacos/config.rs @@ -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)] diff --git a/crates/dbx-core/src/nacos/http.rs b/crates/dbx-core/src/nacos/http.rs index 7ba8d79a9..4de492711 100644 --- a/crates/dbx-core/src/nacos/http.rs +++ b/crates/dbx-core/src/nacos/http.rs @@ -1063,7 +1063,9 @@ fn push_optional(params: &mut Vec<(String, String)>, key: &str, value: Option (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()), diff --git a/crates/dbx-core/src/nacos/mod.rs b/crates/dbx-core/src/nacos/mod.rs index 9d60c393f..89a664d46 100644 --- a/crates/dbx-core/src/nacos/mod.rs +++ b/crates/dbx-core/src/nacos/mod.rs @@ -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); + #[derive(Default)] pub struct NacosAdminRegistry { - instances: RwLock)>>, + instances: RwLock>, build_locks: RwLock>>>, } diff --git a/crates/dbx-core/src/nacos/service.rs b/crates/dbx-core/src/nacos/service.rs index 4acb36bab..5ea9b931d 100644 --- a/crates/dbx-core/src/nacos/service.rs +++ b/crates/dbx-core/src/nacos/service.rs @@ -129,7 +129,7 @@ pub async fn nacos_raw_request_core( req: NacosRawRequest, ) -> Result { 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?; diff --git a/crates/dbx-core/src/object_source_sql.rs b/crates/dbx-core/src/object_source_sql.rs index b4afe6dac..d723c17e2 100644 --- a/crates/dbx-core/src/object_source_sql.rs +++ b/crates/dbx-core/src/object_source_sql.rs @@ -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 { diff --git a/crates/dbx-core/src/production_safety.rs b/crates/dbx-core/src/production_safety.rs index 13d7990ce..70341e28f 100644 --- a/crates/dbx-core/src/production_safety.rs +++ b/crates/dbx-core/src/production_safety.rs @@ -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 = 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::().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; } diff --git a/crates/dbx-core/src/query_cancel.rs b/crates/dbx-core/src/query_cancel.rs index 305d62556..42e50e0e6 100644 --- a/crates/dbx-core/src/query_cancel.rs +++ b/crates/dbx-core/src/query_cancel.rs @@ -12,21 +12,16 @@ pub struct RunningQueryDiagnostics { type InterruptFn = Box; -#[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, diff --git a/crates/dbx-core/src/query_result_export.rs b/crates/dbx-core/src/query_result_export.rs index 49db4b22b..d8ec5812b 100644 --- a/crates/dbx-core/src/query_result_export.rs +++ b/crates/dbx-core/src/query_result_export.rs @@ -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 { diff --git a/crates/dbx-core/src/query_result_sql.rs b/crates/dbx-core/src/query_result_sql.rs index dde65458b..214ba3e91 100644 --- a/crates/dbx-core/src/query_result_sql.rs +++ b/crates/dbx-core/src/query_result_sql.rs @@ -963,7 +963,7 @@ fn add_standard_limit( offset: usize, dedup_projection_count: Option, ) -> 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() { diff --git a/crates/dbx-core/src/redis_ops.rs b/crates/dbx-core/src/redis_ops.rs index 5c2bb8f77..e5da5ac36 100644 --- a/crates/dbx-core/src/redis_ops.rs +++ b/crates/dbx-core/src/redis_ops.rs @@ -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( diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 14320f057..bc1f8f134 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -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") } diff --git a/crates/dbx-core/src/sql.rs b/crates/dbx-core/src/sql.rs index acd18f78d..898ec6637 100644 --- a/crates/dbx-core/src/sql.rs +++ b/crates/dbx-core/src/sql.rs @@ -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 { } } +#[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)) diff --git a/crates/dbx-core/src/sql_diagnostics.rs b/crates/dbx-core/src/sql_diagnostics.rs index 7e26e2310..d2839ef60 100644 --- a/crates/dbx-core/src/sql_diagnostics.rs +++ b/crates/dbx-core/src/sql_diagnostics.rs @@ -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); } diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index a24bd4618..d9490dc39 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -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(); diff --git a/crates/dbx-core/src/table_export.rs b/crates/dbx-core/src/table_export.rs index a426c1dac..bf2799ef6 100644 --- a/crates/dbx-core/src/table_export.rs +++ b/crates/dbx-core/src/table_export.rs @@ -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(), diff --git a/crates/dbx-core/src/table_import.rs b/crates/dbx-core/src/table_import.rs index 7b940ea96..37a993abc 100644 --- a/crates/dbx-core/src/table_import.rs +++ b/crates/dbx-core/src/table_import.rs @@ -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; } diff --git a/crates/dbx-core/src/transfer.rs b/crates/dbx-core/src/transfer.rs index 3f76ee726..1c9059e48 100644 --- a/crates/dbx-core/src/transfer.rs +++ b/crates/dbx-core/src/transfer.rs @@ -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(), diff --git a/crates/dbx-mcp/src/server.rs b/crates/dbx-mcp/src/server.rs index aa1d92de6..81f0110b2 100644 --- a/crates/dbx-mcp/src/server.rs +++ b/crates/dbx-mcp/src/server.rs @@ -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, diff --git a/crates/dbx-web/Cargo.toml b/crates/dbx-web/Cargo.toml index 60be15343..2f4e183bc 100644 --- a/crates/dbx-web/Cargo.toml +++ b/crates/dbx-web/Cargo.toml @@ -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"] } diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 5c637fb6c..b40b1a498 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -66,63 +66,6 @@ fn normalize_public_base_path(value: Option) -> 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 { - 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>) -> Router> { 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 { + 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") + ); + } +} diff --git a/crates/dbx-web/src/routes/connection.rs b/crates/dbx-web/src/routes/connection.rs index dd7f526a5..5289df329 100644 --- a/crates/dbx-web/src/routes/connection.rs +++ b/crates/dbx-web/src/routes/connection.rs @@ -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); diff --git a/crates/dbx-web/src/routes/jdbc.rs b/crates/dbx-web/src/routes/jdbc.rs index db0589f6d..ad6604aba 100644 --- a/crates/dbx-web/src/routes/jdbc.rs +++ b/crates/dbx-web/src/routes/jdbc.rs @@ -172,6 +172,23 @@ pub async fn list_system_fonts() -> Result>, AppError> { Ok(Json(jdbc::list_system_fonts())) } +async fn progress_sender(state: &WebState, operation_id: &str) -> broadcast::Sender { + let mut channels = state.sse_channels.write().await; + channels + .entry(format!("agent-install-progress:{operation_id}")) + .or_insert_with(|| { + let (tx, _) = broadcast::channel::(256); + tx + }) + .clone() +} + +fn send_progress_event(tx: &broadcast::Sender, 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 { - let mut channels = state.sse_channels.write().await; - channels - .entry(format!("agent-install-progress:{operation_id}")) - .or_insert_with(|| { - let (tx, _) = broadcast::channel::(256); - tx - }) - .clone() -} - -fn send_progress_event(tx: &broadcast::Sender, event: AgentProgressEvent) { - if let Ok(payload) = serde_json::to_string(&event) { - let _ = tx.send(payload); - } -} diff --git a/crates/dbx-web/src/routes/query.rs b/crates/dbx-web/src/routes/query.rs index a46f185f4..5e2ca8688 100644 --- a/crates/dbx-web/src/routes/query.rs +++ b/crates/dbx-web/src/routes/query.rs @@ -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) -> Json { Json(dbx_core::db_admin_sql::build_duckdb_attach_database_sql(req.options)) } diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 80cc9b1d9..06108364f 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -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) => { diff --git a/src-tauri/src/commands/mcp_bridge.rs b/src-tauri/src/commands/mcp_bridge.rs index 2110e8543..5e4861ed9 100644 --- a/src-tauri/src/commands/mcp_bridge.rs +++ b/src-tauri/src/commands/mcp_bridge.rs @@ -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, 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()) + ); + } +} diff --git a/src-tauri/src/commands/sql_file.rs b/src-tauri/src/commands/sql_file.rs index 410329e5c..a4c5aba19 100644 --- a/src-tauri/src/commands/sql_file.rs +++ b/src-tauri/src/commands/sql_file.rs @@ -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(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index befc01a6b..e1dc6a4d1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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::().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![ diff --git a/src-tauri/src/macos_app_delegate.rs b/src-tauri/src/macos_app_delegate.rs index de0f55f92..50c3c5040 100644 --- a/src-tauri/src/macos_app_delegate.rs +++ b/src-tauri/src/macos_app_delegate.rs @@ -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