fix(redis): numeric db index and cli flag salvage on url import

This commit is contained in:
邹超 authored and GitHub committed 2026-09-16 20:31:14 +08:00
1 parent 94a8e63fde
commit 5b60c2efcd
8 files changed
+197 -7

No files matched your search

@@ -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;
@@ -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) },
@@ -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");
});
});
@@ -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");
@@ -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();
});
});
@@ -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(/\/+$/, "") } : {}),
};
@@ -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 {
@@ -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";
}