From 5b60c2efcde20b07a61b13648c7cf5535e6f19ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E8=B6=85?= Date: Wed, 16 Sep 2026 20:31:14 +0800 Subject: [PATCH] fix(redis): numeric db index and cli flag salvage on url import --- .../connection/ConnectionDialog.vue | 13 +++- .../src/components/sidebar/TreeItem.vue | 12 +++- .../connection/connectionUrl.redis.spec.ts | 68 +++++++++++++++++++ .../connection/connectionUrlBuilder.spec.ts | 8 +++ .../redis/redisDatabaseIndex.spec.ts | 37 ++++++++++ .../src/lib/connection/connectionUrl.ts | 36 +++++++++- .../lib/connection/connectionUrlBuilder.ts | 7 +- .../src/lib/redis/redisDatabaseIndex.ts | 23 +++++++ 8 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/connection/connectionUrl.redis.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/redis/redisDatabaseIndex.spec.ts create mode 100644 apps/desktop/src/lib/redis/redisDatabaseIndex.ts diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 1f9f813459..cfc1c26b03 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -49,6 +49,7 @@ import { canPersistConnectionTestResult, connectionEditDraftSyncAction } from ". import { createConnectionNoteVisibilityDraft, persistConnectionNoteVisibilityDraft as persistConnectionNoteVisibilityDraftState, resetConnectionNoteVisibilityDraft, setConnectionNoteVisibilityDraft, syncConnectionNoteVisibilityDraft } from "./connectionNoteVisibilityDraft"; import { REDIS_SCAN_PAGE_SIZE_DEFAULT, REDIS_SCAN_PAGE_SIZE_MIN, REDIS_SCAN_PAGE_SIZE_MAX, REDIS_SCAN_PAGE_SIZE_OPTIONS } from "@/lib/redis/redisKeyPattern"; import { normalizeRedisKeyTemplates, redisKeyTemplatesToTextarea } from "@/lib/redis/redisKeyTemplates"; +import { normalizeRedisDatabaseValue } from "@/lib/redis/redisDatabaseIndex"; import { normalizeGlobalConnectTimeoutSecs, normalizeGlobalQueryTimeoutSecs, useSettingsStore } from "@/stores/settingsStore"; import { useToast } from "@/composables/useToast"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; @@ -2637,7 +2638,9 @@ watch( port: profile === "tdengine" && (config.port === 0 || config.port === 6030) ? 6041 : config.port, username: config.username, password: config.password, - database: config.database, + // Show the index the backend actually connects with; legacy dirty values + // (e.g. redis-cli flags in the field) are healed when the form is saved. + database: config.db_type === "redis" ? normalizeRedisDatabaseValue(config.database) || "" : config.database, color: config.color || "", transport_layers: transportLayersForConfig(legacyConfig), connect_timeout_secs: config.connect_timeout_inherit === true ? settingsStore.editorSettings.globalConnectTimeoutSecs : config.connect_timeout_secs || 10, @@ -2886,6 +2889,7 @@ const transportPathSegments = computed(() => { function defaultDatabaseForProfile() { if (form.value.db_type === "redshift") return "dev"; + if (form.value.db_type === "redis") return "0"; if (form.value.db_type === "gaussdb") return "postgres"; if (form.value.db_type === "kwdb") return "defaultdb"; if (form.value.db_type === "databend") return "default"; @@ -4404,6 +4408,13 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi config.redis_key_separator = config.redis_key_separator?.trim() ?? ":"; const scanSize = Number(config.redis_scan_page_size); config.redis_scan_page_size = Number.isFinite(scanSize) && scanSize >= REDIS_SCAN_PAGE_SIZE_MIN && scanSize <= REDIS_SCAN_PAGE_SIZE_MAX ? Math.round(scanSize) : REDIS_SCAN_PAGE_SIZE_DEFAULT; + { + // A Redis database is a numeric index; dirty values (e.g. redis-cli flags + // pasted into the field) are stored as the index the backend connects with. + const database = normalizeRedisDatabaseValue(config.database); + config.database = database; + form.value.database = database || ""; + } { const templates = normalizeRedisKeyTemplates(redisKeyTemplatesText.value); config.redis_key_templates = templates.length > 0 ? templates : undefined; diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 86e943440d..333781f298 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -70,6 +70,7 @@ import { import { AI_ASSISTANT_TABLE_DROP_ROOT_SELECTOR } from "@/lib/ai/aiTableReferenceDrop"; import { beginTableReferenceDragFeedback, isOverSqlEditorTarget, type TableReferenceDragFeedback } from "@/lib/editor/tableReferenceDragFeedback"; import { formatSidebarObjectStorage } from "@/lib/sidebar/sidebarDatabaseStorage"; +import { effectiveRedisDatabaseIndex } from "@/lib/redis/redisDatabaseIndex"; import { dataTabOpenModeFromTreeClick } from "@/lib/sidebar/dataTabOpenPolicy"; import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { connectionDisplayUrlScheme } from "@/lib/connection/connectionPresentation"; @@ -516,6 +517,13 @@ function hostForDisplay(host: string): string { return `[${host}]`; } +// A Redis database is a numeric index; dirty stored values (e.g. redis-cli flags +// pasted into the field) resolve to the index the backend actually connects with. +function tooltipDatabaseValue(config: ConnectionConfig): string { + const database = cleanTooltipValue(config.database); + return config.db_type === "redis" && database ? effectiveRedisDatabaseIndex(database) : database; +} + function connectionTooltipUrl(config: ConnectionConfig): string { const explicit = cleanTooltipValue(config.connection_string); if (explicit) return redactedConnectionString(explicit); @@ -533,7 +541,7 @@ function connectionTooltipUrl(config: ConnectionConfig): string { const port = Number(config.port) > 0 ? `:${config.port}` : ""; const user = cleanTooltipValue(config.username); const userInfo = user ? `${encodeURIComponent(user)}@` : ""; - const database = cleanTooltipValue(config.database); + const database = tooltipDatabaseValue(config); const encodedDatabase = config.db_type === "spanner" ? encodeSpannerResourcePath(database) : encodeURIComponent(database); const path = database ? `/${encodedDatabase}` : ""; const params = cleanTooltipValue(config.url_params); @@ -569,7 +577,7 @@ const detailTooltip = computed(() => { { label: "URL", value: connectionTooltipUrl(config), multiline: true }, ...(hostValues.length > 0 ? [{ label: hostLabel, value: hostValues[0], values: hostValues } as DetailTooltipRow] : [{ label: hostLabel, value: hostValue, multiline: isLocalFileConnection(config) } as DetailTooltipRow]), { label: "Port", value: Number(config.port) > 0 ? String(config.port) : "" }, - { label: t("connection.database"), value: cleanTooltipValue(config.database) }, + { label: t("connection.database"), value: tooltipDatabaseValue(config) }, { label: t("connection.user"), value: cleanTooltipValue(config.username) }, { label: t("connection.type"), value: config.driver_label || config.driver_profile || config.db_type }, { label: t("connection.databaseInfo.productVersion"), value: cleanTooltipValue(config.database_info?.productVersion) }, diff --git a/apps/desktop/src/lib/__tests__/connection/connectionUrl.redis.spec.ts b/apps/desktop/src/lib/__tests__/connection/connectionUrl.redis.spec.ts new file mode 100644 index 0000000000..32e1ee8390 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/connection/connectionUrl.redis.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { parseConnectionUrl } from "@/lib/connection/connectionUrl"; + +describe("Redis connection URLs", () => { + it("parses a numeric database index from the path", () => { + const parsed = parseConnectionUrl("rediss://coupon@cache.example.com:6379/3?insecure=true"); + expect(parsed.dbType).toBe("redis"); + expect(parsed.host).toBe("cache.example.com"); + expect(parsed.port).toBe(6379); + expect(parsed.username).toBe("coupon"); + expect(parsed.database).toBe("3"); + expect(parsed.ssl).toBe(true); + expect(parsed.urlParams).toBe("insecure=true"); + }); + + it("treats the rediss scheme as TLS", () => { + expect(parseConnectionUrl("rediss://cache.example.com:6379/0").ssl).toBe(true); + expect(parseConnectionUrl("redis://cache.example.com:6379/0").ssl).toBe(false); + }); + + it("salvages pasted redis-cli --tls and --insecure flags from the path", () => { + const parsed = parseConnectionUrl("redis://coupon:secret@qas-cnapacdd-invisclub-cnn2.redis.rds.aliyuncs.com:6379/0 --tls --insecure"); + expect(parsed.dbType).toBe("redis"); + expect(parsed.host).toBe("qas-cnapacdd-invisclub-cnn2.redis.rds.aliyuncs.com"); + expect(parsed.username).toBe("coupon"); + expect(parsed.password).toBe("secret"); + expect(parsed.database).toBe("0"); + expect(parsed.ssl).toBe(true); + expect(parsed.urlParams).toBe("insecure=true"); + }); + + it("salvages flags from an already percent-encoded dirty path", () => { + const parsed = parseConnectionUrl("rediss://coupon@cache.example.com:6379/0%20--tls%20--insecure?insecure=true"); + expect(parsed.database).toBe("0"); + expect(parsed.ssl).toBe(true); + // The explicit query param already carries insecure; it must not be duplicated. + expect(parsed.urlParams).toBe("insecure=true"); + }); + + it("salvages --tls without --insecure", () => { + const parsed = parseConnectionUrl("redis://cache.example.com:6379/2 --tls"); + expect(parsed.database).toBe("2"); + expect(parsed.ssl).toBe(true); + expect(parsed.urlParams).toBe(""); + }); + + it("salvages flags when the path carries no db index", () => { + const parsed = parseConnectionUrl("redis://cache.example.com:6379/--tls --insecure"); + expect(parsed.database).toBeUndefined(); + expect(parsed.ssl).toBe(true); + expect(parsed.urlParams).toBe("insecure=true"); + }); + + it("collapses a non-numeric path with no flags to the db index the backend uses", () => { + expect(parseConnectionUrl("redis://cache.example.com:6379/mydb").database).toBe("0"); + }); + + it("leaves the database unset when the URL has no path", () => { + expect(parseConnectionUrl("redis://cache.example.com:6379").database).toBeUndefined(); + expect(parseConnectionUrl("redis://cache.example.com:6379/").database).toBeUndefined(); + }); + + it("maps the #insecure fragment to the insecure url param", () => { + const parsed = parseConnectionUrl("rediss://cache.example.com:6379/0#insecure"); + expect(parsed.database).toBe("0"); + expect(parsed.urlParams).toBe("insecure=true"); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/connection/connectionUrlBuilder.spec.ts b/apps/desktop/src/lib/__tests__/connection/connectionUrlBuilder.spec.ts index 0fb2bc6e30..6edc13fa43 100644 --- a/apps/desktop/src/lib/__tests__/connection/connectionUrlBuilder.spec.ts +++ b/apps/desktop/src/lib/__tests__/connection/connectionUrlBuilder.spec.ts @@ -80,6 +80,14 @@ describe("buildConnectionUrlCopy standard URL", () => { expect(buildConnectionUrlCopy(redis, "urlWithPassword")).toBe("rediss://:secret@cache.example.com:6379/0"); }); + it("falls back to db 0 for non-numeric Redis database values instead of percent-encoding them", () => { + // Mirrors dbx-core redis_database_index(): "0 --tls --insecure" fails to + // parse as an index and the backend silently uses 0. + const redis = config({ db_type: "redis", host: "cache.example.com", port: 6379, username: "coupon", password: "", database: "0 --tls --insecure", url_params: "insecure=true", ssl: true }); + expect(buildConnectionUrlCopy(redis, "url")).toBe("rediss://coupon@cache.example.com:6379/0?insecure=true"); + expect(buildConnectionUrlCopy(config({ db_type: "redis", database: "3" }), "url")).toBe("redis://app_user@db.example.com:5432/3"); + }); + it("maps the MySQL family profiles to their own schemes", () => { expect(buildConnectionUrlCopy(config({ db_type: "mysql", port: 3306 }), "url")).toBe("mysql://app_user@db.example.com:3306/appdb"); expect(buildConnectionUrlCopy(config({ db_type: "mysql", port: 3306, driver_profile: "mariadb" }), "urlWithPassword")).toBe("mariadb://app_user:secret@db.example.com:3306/appdb"); diff --git a/apps/desktop/src/lib/__tests__/redis/redisDatabaseIndex.spec.ts b/apps/desktop/src/lib/__tests__/redis/redisDatabaseIndex.spec.ts new file mode 100644 index 0000000000..a0f8daa5f6 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/redis/redisDatabaseIndex.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { effectiveRedisDatabaseIndex, normalizeRedisDatabaseValue } from "@/lib/redis/redisDatabaseIndex"; + +describe("effectiveRedisDatabaseIndex", () => { + it("keeps numeric indexes as-is", () => { + expect(effectiveRedisDatabaseIndex("0")).toBe("0"); + expect(effectiveRedisDatabaseIndex("3")).toBe("3"); + expect(effectiveRedisDatabaseIndex(" 15 ")).toBe("15"); + }); + + it("falls back to db 0 for dirty values, mirroring dbx-core redis_database_index()", () => { + expect(effectiveRedisDatabaseIndex("0 --tls --insecure")).toBe("0"); + expect(effectiveRedisDatabaseIndex("3 --foo")).toBe("0"); + expect(effectiveRedisDatabaseIndex("mydb")).toBe("0"); + expect(effectiveRedisDatabaseIndex("")).toBe("0"); + expect(effectiveRedisDatabaseIndex(undefined)).toBe("0"); + expect(effectiveRedisDatabaseIndex(null)).toBe("0"); + }); +}); + +describe("normalizeRedisDatabaseValue", () => { + it("passes numeric indexes through trimmed", () => { + expect(normalizeRedisDatabaseValue("3")).toBe("3"); + expect(normalizeRedisDatabaseValue(" 3 ")).toBe("3"); + }); + + it("collapses dirty values to the index the backend actually uses", () => { + expect(normalizeRedisDatabaseValue("0 --tls --insecure")).toBe("0"); + expect(normalizeRedisDatabaseValue("cache")).toBe("0"); + }); + + it("keeps empty values unset", () => { + expect(normalizeRedisDatabaseValue("")).toBeUndefined(); + expect(normalizeRedisDatabaseValue(" ")).toBeUndefined(); + expect(normalizeRedisDatabaseValue(undefined)).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/lib/connection/connectionUrl.ts b/apps/desktop/src/lib/connection/connectionUrl.ts index 35abc556a3..cba18708bb 100644 --- a/apps/desktop/src/lib/connection/connectionUrl.ts +++ b/apps/desktop/src/lib/connection/connectionUrl.ts @@ -1,6 +1,7 @@ import type { ConnectionConfig, DatabaseType } from "@/types/database"; import { h2JdbcUrlHasPasswordParam, h2JdbcUrlHasUserParam, parseH2JdbcUrl } from "@/lib/database/h2Connection"; import { damengSslFormConfig } from "@/lib/database/damengSslOptions"; +import { normalizeRedisDatabaseValue } from "@/lib/redis/redisDatabaseIndex"; export interface ParsedConnectionUrl { name?: string; @@ -200,6 +201,29 @@ function databaseFromPath(pathname: string): string | undefined { return decodeUrlPart(value.split("/")[0]); } +interface RedisUrlPathParts { + database: string | undefined; + tls: boolean; + insecure: boolean; +} + +// Users paste redis-cli invocations into the URL field (e.g. +// "redis://host:6379/0 --tls --insecure"); WHATWG URL folds the flags into the +// path. Salvage them: the first non-flag token is the numeric db index, --tls +// enables TLS (like the rediss:// scheme) and --insecure skips certificate +// verification (like the #insecure fragment). +function parseRedisUrlPath(rawPath: string): RedisUrlPathParts { + const parts: RedisUrlPathParts = { database: undefined, tls: false, insecure: false }; + for (const token of rawPath.split(/\s+/)) { + if (!token) continue; + const flag = token.toLowerCase(); + if (flag === "--tls") parts.tls = true; + else if (flag === "--insecure") parts.insecure = true; + else if (parts.database === undefined) parts.database = normalizeRedisDatabaseValue(token); + } + return parts; +} + function dynamodbRegionFromHost(hostname: string): string | undefined { return hostname.toLowerCase().match(/^dynamodb(?:-fips)?\.([a-z0-9-]+)\.(?:amazonaws\.com(?:\.cn)?|api\.aws)$/)?.[1]; } @@ -716,7 +740,11 @@ export function parseConnectionUrl(value: string, preferredProfile?: string): Pa const name = connectionNameParam(parsed); const urlParamsWithoutName = stripConnectionNameParam(urlParams); const normalizedFragment = decodeUrlPart(parsed.hash.replace(/^#/, "")).trim().toLowerCase(); - const parsedUrlParams = profile.type === "redis" && normalizedFragment === "insecure" ? [urlParamsWithoutName, "insecure=true"].filter(Boolean).join("&") : urlParamsWithoutName; + // A Redis path can carry pasted redis-cli flags ("…/0 --tls --insecure"); + // salvage the db index plus the TLS/insecure intent before they are dropped. + const redisPath = profile.type === "redis" ? parseRedisUrlPath(databaseFromPath(parsed.pathname) || "") : null; + const redisInsecure = redisPath?.insecure || normalizedFragment === "insecure"; + const parsedUrlParams = profile.type === "redis" && redisInsecure && !/(^|&)insecure=/i.test(urlParamsWithoutName) ? [urlParamsWithoutName, "insecure=true"].filter(Boolean).join("&") : urlParamsWithoutName; const jdbcCredentials = isJdbcUrl && (profile.type === "mysql" || profile.profile === "oceanbase-oracle") ? extractMysqlCredentialParams(parsedUrlParams) : undefined; const effectiveUrlParams = jdbcCredentials?.urlParams ?? parsedUrlParams; if (profile.type === "mongodb") { @@ -754,6 +782,8 @@ export function parseConnectionUrl(value: string, preferredProfile?: string): Pa const isMeilisearch = profile.type === "meilisearch"; const defaultPort = isJdbcUrl && scheme === "oceanbase" ? 3306 : isMeilisearch && scheme === "http" ? 80 : isMeilisearch && scheme === "https" ? 443 : profile.defaultPort; + // A Redis URL path is a numeric db index; redis-cli flags salvaged above. + const pathDatabase = redisPath ? redisPath.database : databaseFromPath(parsed.pathname); return { ...(name ? { name } : {}), @@ -765,9 +795,9 @@ export function parseConnectionUrl(value: string, preferredProfile?: string): Pa ...(profile.type === "sqlserver" && parsed.port ? { portExplicit: true } : {}), username: jdbcCredentials?.username ?? decodeUrlPart(parsed.username), password: jdbcCredentials?.password ?? decodeUrlPart(parsed.password), - database: profile.type === "victoriametrics" ? "metrics" : profile.type === "dynamodb" ? dynamodbRegionFromHost(parsed.hostname) : isMeilisearch ? undefined : databaseFromPath(parsed.pathname), + database: profile.type === "victoriametrics" ? "metrics" : profile.type === "dynamodb" ? dynamodbRegionFromHost(parsed.hostname) : isMeilisearch ? undefined : pathDatabase, urlParams: effectiveUrlParams, - ssl: scheme === "rediss" || scheme === "https" || urlParamsRequireTls(profile.type, effectiveUrlParams) || (profile.type === "mysql" && isTidbCloudHost(parsed.hostname)), + ssl: scheme === "rediss" || scheme === "https" || (redisPath?.tls ?? false) || urlParamsRequireTls(profile.type, effectiveUrlParams) || (profile.type === "mysql" && isTidbCloudHost(parsed.hostname)), ...(profile.type === "victoriametrics" ? { apiPath: parsed.pathname.replace(/\/+$/, "") } : {}), ...(isMeilisearch ? { basePath: parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, "") } : {}), }; diff --git a/apps/desktop/src/lib/connection/connectionUrlBuilder.ts b/apps/desktop/src/lib/connection/connectionUrlBuilder.ts index f95f8d3b63..25f8458f77 100644 --- a/apps/desktop/src/lib/connection/connectionUrlBuilder.ts +++ b/apps/desktop/src/lib/connection/connectionUrlBuilder.ts @@ -1,5 +1,6 @@ import type { ConnectionConfig, DatabaseType } from "@/types/database"; import { GAUSSDB_M_JDBC_DRIVER_PROFILE } from "@/lib/database/jdbcDialect"; +import { effectiveRedisDatabaseIndex } from "@/lib/redis/redisDatabaseIndex"; /** * Builds copy-ready connection strings (standard URL / JDBC URL / libpq DSN / @@ -145,7 +146,11 @@ export function connectionSupportsUrlCopy(config: ConnectionUrlCopyConfig | unde } function effectiveDatabase(config: ConnectionUrlCopyConfig, options?: ConnectionUrlCopyOptions): string { - return options?.database?.trim() || config.database?.trim() || ""; + const database = options?.database?.trim() || config.database?.trim() || ""; + // A Redis database is a numeric index; dirty values resolve to the index the + // backend actually connects with (see effectiveRedisDatabaseIndex). + if (database && config.db_type === "redis") return effectiveRedisDatabaseIndex(database); + return database; } function queryHasParam(params: string, keys: string[]): boolean { diff --git a/apps/desktop/src/lib/redis/redisDatabaseIndex.ts b/apps/desktop/src/lib/redis/redisDatabaseIndex.ts new file mode 100644 index 0000000000..309c36c1ab --- /dev/null +++ b/apps/desktop/src/lib/redis/redisDatabaseIndex.ts @@ -0,0 +1,23 @@ +// Mirrors dbx-core `redis_database_index()` (crates/dbx-core/src/db/redis_driver.rs): +// a Redis database is a numeric index. Anything else (e.g. redis-cli flags like +// "0 --tls --insecure" pasted into the field or imported from a dirty URL) fails +// to parse on the backend and silently falls back to db 0, so every frontend +// surface must present the index the connection actually uses. +const REDIS_DATABASE_INDEX_PATTERN = /^[+-]?\d+$/; + +/** Returns the numeric Redis database index a stored value resolves to; dirty values fall back to "0". */ +export function effectiveRedisDatabaseIndex(database: string | null | undefined): string { + const trimmed = String(database ?? "").trim(); + return trimmed && REDIS_DATABASE_INDEX_PATTERN.test(trimmed) ? trimmed : "0"; +} + +/** + * Cleans a Redis database value for storage: numeric indexes pass through + * (trimmed), dirty non-numeric values collapse to the index the backend + * actually uses, and empty values stay unset. + */ +export function normalizeRedisDatabaseValue(database: string | null | undefined): string | undefined { + const trimmed = String(database ?? "").trim(); + if (!trimmed) return undefined; + return REDIS_DATABASE_INDEX_PATTERN.test(trimmed) ? trimmed : "0"; +}