fix(redis): numeric db index and cli flag salvage on url import
This commit is contained in:
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";
|
||||
}
|
||||
Reference in new issue
Block a user