Files
dbx/apps/desktop/src/lib/connection/spannerResourcePath.ts
T

134 lines
5.7 KiB
TypeScript

import type { ConnectionConfig } from "@/types/database";
/**
* Cloud Spanner has no host/port identity: a database is addressed by the
* resource path `projects/{project}/instances/{instance}/databases/{database}`.
* DBX stores that whole path in `ConnectionConfig.database` and lets the
* connection dialog edit it either as three IDs or as the raw path.
*
* Partial paths (`projects/p/instances//databases/`) are intentionally
* representable so that typing one field at a time never discards the fields
* already filled in; `hasSpannerResourcePath` is what gates Save/Test.
*/
export interface SpannerResourceParts {
project: string;
instance: string;
database: string;
}
export const SPANNER_DEFAULT_PORT = 443;
const EMPTY_PARTS: SpannerResourceParts = { project: "", instance: "", database: "" };
// Keywords are matched case-insensitively; segments are captured verbatim.
// The trailing segment is optional so a path whose database ID is still empty
// (`projects/p/instances/i/databases/`, trailing slash trimmed) still parses.
const SPANNER_RESOURCE_PATH_RE = /^projects\/([^/]*)\/instances\/([^/]*)\/databases(?:\/([^/]*))?$/i;
type MutableSpannerConfig = Pick<ConnectionConfig, "host" | "port" | "username" | "password" | "database">;
export function isSpannerConnection(config: Pick<ConnectionConfig, "db_type">): boolean {
return config.db_type === "spanner";
}
function normalizeResourcePath(path: string | undefined): string {
return (path ?? "").trim().replace(/^\/+/, "").replace(/\/+$/, "");
}
/**
* Lenient read: returns whatever segments the value carries, with empty strings
* for the missing ones. Anything that is not shaped like a Spanner resource path
* yields all-empty parts, which is what keeps the raw-path editor authoritative.
*/
export function spannerResourceParts(path: string | undefined): SpannerResourceParts {
const match = SPANNER_RESOURCE_PATH_RE.exec(normalizeResourcePath(path));
if (!match) return { ...EMPTY_PARTS };
return { project: match[1] ?? "", instance: match[2] ?? "", database: match[3] ?? "" };
}
/** Strict read: only a complete three-segment path parses. */
export function parseSpannerResourcePath(path: string | undefined): SpannerResourceParts | undefined {
const parts = spannerResourceParts(path);
return parts.project && parts.instance && parts.database ? parts : undefined;
}
/**
* Each field holds a single ID. When a value contains slashes (typically a whole
* resource path pasted into one box) only the last segment is kept, so
* `projects/p1` becomes `p1` instead of `projects/projects/p1/...`.
*/
function normalizeSegment(value: string): string {
const trimmed = value.trim();
if (!trimmed.includes("/")) return trimmed;
const segments = trimmed
.split("/")
.map((segment) => segment.trim())
.filter(Boolean);
return segments.length ? segments[segments.length - 1]! : "";
}
export function formatSpannerResourcePath(parts: SpannerResourceParts): string {
const project = normalizeSegment(parts.project);
const instance = normalizeSegment(parts.instance);
const database = normalizeSegment(parts.database);
if (!project && !instance && !database) return "";
return `projects/${project}/instances/${instance}/databases/${database}`;
}
/**
* Replaces one segment of the stored path. A full resource path pasted into any
* of the three fields replaces all three segments at once.
*/
export function withSpannerResourcePart(database: string | undefined, key: keyof SpannerResourceParts, value: string): string {
const pasted = parseSpannerResourcePath(value);
if (pasted) return formatSpannerResourcePath(pasted);
return formatSpannerResourcePath({ ...spannerResourceParts(database), [key]: value });
}
export function hasSpannerResourcePath(config: Pick<ConnectionConfig, "database">): boolean {
return !!parseSpannerResourcePath(config.database);
}
/**
* Submit-time normalization. Unlike Cloudflare D1 this must not clear
* `url_params` (Spanner passes `credentials=`, `autoConfigEmulator=` through it)
* and must not touch `host` (empty means Google Cloud, `localhost` means the
* local emulator).
*/
export function normalizeSpannerConnection(config: MutableSpannerConfig): void {
const parts = parseSpannerResourcePath(config.database);
config.database = (parts ? formatSpannerResourcePath(parts) : normalizeResourcePath(config.database)) || undefined;
config.host = config.host.trim();
config.username = "";
config.password = "";
if (!Number.isInteger(config.port) || config.port <= 0) config.port = SPANNER_DEFAULT_PORT;
}
/** Sidebar/subtitle label: the database ID, not the whole resource path. */
export function spannerDisplayDatabase(database: string | undefined): string {
return parseSpannerResourcePath(database)?.database ?? (database ?? "").trim();
}
/**
* GoogleSQL's user schema is literally the empty string, so the sidebar would otherwise render a
* nameless node next to any named schema. Mirrors `xuguSchemaDisplayName`: a display-only label
* that never replaces the real schema key used for routing.
*/
export const SPANNER_DEFAULT_SCHEMA_LABEL = "(default)";
export function spannerSchemaDisplayName(schema: string): string {
return schema === "" ? SPANNER_DEFAULT_SCHEMA_LABEL : schema;
}
/**
* Percent-encode a Spanner resource path for display, keeping the `/` separators
* intact — they are structure, not data. Mirrors `encode_spanner_resource_path`
* in crates/dbx-types/src/models/connection.rs.
*
* Every other database type keeps whole-value encoding, where a slash inside a
* database name really is data and should be escaped.
*/
export function encodeSpannerResourcePath(database: string): string {
return database.split("/").map(encodeURIComponent).join("/");
}