feat(spanner): add Google Cloud Spanner support

This commit is contained in:
Frost authored and GitHub committed 2026-08-20 16:33:09 +08:00
1 parent 2cc8d78e64
commit f494f90c30
74 files changed
+2920 -53

No files matched your search

@@ -106,10 +106,12 @@ import { canSaveVisibleDatabaseSelection, connectionUsesVisibleSchemaFilter, fil
import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureSupport";
import VisibleSchemasDialog from "@/components/sidebar/VisibleSchemasDialog.vue";
import CloudflareD1ConnectionFields from "@/components/connection/CloudflareD1ConnectionFields.vue";
import SpannerConnectionFields from "@/components/connection/SpannerConnectionFields.vue";
import { oceanbaseModeConnectionPatch, oceanbaseSubModeFromConfig } from "@/lib/database/oceanbaseConnectionMode";
import { translateBackendError } from "@/i18n/backend-errors";
import { applyHiveKerberosSubmitConfig, hiveKerberosFormConfig, type HiveKerberosAuthMode } from "@/lib/database/hiveKerberosOptions";
import { hasCloudflareD1Credentials, isCloudflareD1Connection, normalizeCloudflareD1Connection } from "@/lib/connection/cloudflareD1";
import { hasSpannerResourcePath, isSpannerConnection, normalizeSpannerConnection } from "@/lib/connection/spannerResourcePath";
import {
buildElasticsearchExternalConfig,
elasticsearchConnectionModeFromConfig,
@@ -1233,6 +1235,7 @@ const driverProfiles: Record<
icon: "bigquery",
host: "https://www.googleapis.com/bigquery/v2",
},
spanner: { type: "spanner", port: 443, user: "", label: "Cloud Spanner", icon: "spanner" },
kylin: { type: "kylin", port: 7070, user: "ADMIN", label: "Apache Kylin", icon: "kylin" },
ignite: { type: "ignite", port: 10800, user: "", label: "Apache Ignite", icon: "ignite" },
ignite3: { type: "ignite3", port: 10800, user: "", label: "Apache Ignite 3", icon: "ignite" },
@@ -2520,6 +2523,15 @@ function applyProfile(val: string, preserveConnectionFields = false) {
jdbcDriverPathsInput.value = "";
jdbcManualClasspathOpen.value = true;
}
if (profile.type === "spanner") {
// Google Cloud endpoints carry no host; the local emulator is opted into
// by typing host `localhost` and port 9010 explicitly.
form.value.host = "";
form.value.username = "";
form.value.password = "";
form.value.database = undefined;
form.value.connection_string = undefined;
}
if (profile.type === "mq") {
resetMqFields(defaultMqFieldsForProfile(val));
syncMqSystemKindFromSelectedType();
@@ -2974,6 +2986,7 @@ const iconTypeMap: Record<string, string> = {
neo4j: "neo4j",
cassandra: "cassandra",
bigquery: "bigquery",
spanner: "spanner",
kylin: "kylin",
ignite: "ignite",
ignite3: "ignite",
@@ -3057,6 +3070,7 @@ const dbOptions: DbOption[] = [
{ value: "neo4j", label: "Neo4j" },
{ value: "cassandra", label: "Cassandra" },
{ value: "bigquery", label: "BigQuery" },
{ value: "spanner", label: "Cloud Spanner" },
{ value: "kylin", label: "Kylin" },
{ value: "ignite", label: "Apache Ignite" },
{ value: "ignite3", label: "Apache Ignite 3" },
@@ -3093,7 +3107,7 @@ const dbCategoryDefinitions: Array<{
{
key: "sql",
titleKey: "connection.databaseCategorySql",
optionValues: ["postgres", "mysql", "oracle", "sqlserver", "mariadb", "cockroachdb", "db2", "informix", "firebird", "iris", "jdbcx", "custom_mysql", "custom_postgres", "dolt"],
optionValues: ["postgres", "mysql", "oracle", "sqlserver", "mariadb", "cockroachdb", "db2", "informix", "firebird", "iris", "spanner", "jdbcx", "custom_mysql", "custom_postgres", "dolt"],
},
{
key: "analytics",
@@ -3617,6 +3631,8 @@ const hasRequiredConnectionTarget = computed(() => {
if (form.value.db_type === "nacos") return !!nacosServerAddr.value.trim();
if (form.value.db_type === "consul") return !!consulServerAddr.value.trim();
if (isCloudflareD1Connection(form.value)) return hasCloudflareD1Credentials(form.value);
// Cloud Spanner has no host to fall back on: the resource path is the target.
if (isSpannerConnection(form.value)) return hasSpannerResourcePath(form.value);
if (isH2FileMode.value) return !!(form.value.host.trim() || h2FilePathFromJdbcUrl(form.value.connection_string));
return !!(form.value.host || (mongoUseUrl.value && form.value.connection_string) || (form.value.db_type === "jdbc" && form.value.connection_string) || connectionUrlInput.value.trim());
});
@@ -3956,6 +3972,12 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
throw new Error(t("connection.d1FieldsRequired"));
}
}
if (isSpannerConnection(config)) {
normalizeSpannerConnection(config);
if (!hasSpannerResourcePath(config)) {
throw new Error(t("connection.spannerFieldsRequired"));
}
}
config.transport_layers = (config.transport_layers || []).map(normalizeTransportLayer);
config.transport_layers = config.transport_layers.map((layer) => {
if (layer.type !== "ssh") return layer;
@@ -7310,17 +7332,17 @@ function openExternalUrl(url: string) {
<Input v-model="form.informix_server" class="col-span-3" placeholder="ol_informix1170" />
</div>
<div v-if="form.db_type !== 'meilisearch'" class="grid grid-cols-4 items-center gap-4">
<div v-if="form.db_type !== 'meilisearch' && form.db_type !== 'spanner'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.user") }}</Label>
<Input v-model="form.username" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<div v-if="form.db_type !== 'spanner'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ form.db_type === "meilisearch" ? t("connection.mqAuthApiKey") : t("connection.password") }}</Label>
<PasswordInput v-model="form.password" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<div v-if="form.db_type !== 'spanner'" class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3 flex items-center gap-1.5 text-sm">
<label class="flex items-center gap-2">
@@ -7333,11 +7355,20 @@ function openExternalUrl(url: string) {
</div>
</div>
<div v-if="form.db_type !== 'hbase' && form.db_type !== 'meilisearch'" class="grid grid-cols-4 items-center gap-4">
<div v-if="form.db_type !== 'hbase' && form.db_type !== 'meilisearch' && form.db_type !== 'spanner'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ databaseLabel }}</Label>
<Input v-model="form.database" class="col-span-3" :placeholder="databasePlaceholder" />
</div>
<!-- Cloud Spanner: project/instance/database resource path instead of user/password/database -->
<template v-if="form.db_type === 'spanner'">
<div class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.spannerHostHint") }}</p>
</div>
<SpannerConnectionFields v-model:database="form.database" @change="resetTestState" />
</template>
<div v-if="form.db_type === 'oracle' && form.oracle_connection_type === 'tns'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">TNS_ADMIN</Label>
<div class="col-span-3 flex items-center gap-1">
@@ -7493,13 +7524,15 @@ function openExternalUrl(url: string) {
? 'secure=true'
: form.db_type === 'bigquery'
? 'OAuthType=0;OAuthServiceAcctEmail=svc@project.iam.gserviceaccount.com;OAuthPvtKeyPath=/path/key.json'
: form.db_type === 'informix'
? 'CLIENT_LOCALE=en_US.utf8;DB_LOCALE=en_US.utf8'
: form.db_type === 'spark'
? 'catalog=paimon_catalog'
: form.db_type === 'cassandra'
? 'localdatacenter=dc1'
: 'sslmode=prefer'
: form.db_type === 'spanner'
? 'credentials=/path/key.json;autocommit=true'
: form.db_type === 'informix'
? 'CLIENT_LOCALE=en_US.utf8;DB_LOCALE=en_US.utf8'
: form.db_type === 'spark'
? 'catalog=paimon_catalog'
: form.db_type === 'cassandra'
? 'localdatacenter=dc1'
: 'sslmode=prefer'
"
/>
<p v-if="showGenericUrlParamsHint" class="text-xs leading-5 text-muted-foreground">
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { parseSpannerResourcePath, spannerResourceParts, withSpannerResourcePart, type SpannerResourceParts } from "@/lib/connection/spannerResourcePath";
const database = defineModel<string | undefined>("database");
const emit = defineEmits<{ change: [] }>();
const { t } = useI18n();
// The editing mode is derived, never persisted: a value that already parses as a
// resource path opens in the structured editor, anything else (hand-written or
// non-standard paths) opens in the raw editor so nothing is silently dropped.
const rawMode = ref(!!database.value?.trim() && !parseSpannerResourcePath(database.value));
function resourcePart(key: keyof SpannerResourceParts) {
return computed({
get: () => spannerResourceParts(database.value)[key],
set: (value: string) => {
database.value = withSpannerResourcePart(database.value, key, value) || undefined;
emit("change");
},
});
}
const project = resourcePart("project");
const instance = resourcePart("instance");
const databaseId = resourcePart("database");
const resourcePath = computed({
get: () => database.value ?? "",
set: (value: string) => {
database.value = value || undefined;
emit("change");
},
});
function setRawMode(value: boolean) {
rawMode.value = value;
}
</script>
<template>
<div data-spanner-resource-fields class="contents">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="justify-self-start text-left text-xs">{{ t("connection.mode") }}</Label>
<div class="col-span-3 grid h-8 grid-cols-2 overflow-hidden rounded-md border border-input bg-muted/30 p-0.5">
<button type="button" class="h-7 rounded-sm px-3 text-sm transition-colors" :class="rawMode ? 'text-muted-foreground hover:text-foreground' : 'bg-background text-foreground shadow-sm'" :aria-pressed="!rawMode" @click="setRawMode(false)">
{{ t("connection.spannerStructuredMode") }}
</button>
<button type="button" class="h-7 rounded-sm px-3 text-sm transition-colors" :class="rawMode ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" :aria-pressed="rawMode" @click="setRawMode(true)">
{{ t("connection.spannerResourcePathMode") }}
</button>
</div>
</div>
<template v-if="!rawMode">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="justify-self-start text-left">{{ t("connection.spannerProject") }}</Label>
<Input v-model="project" class="col-span-3" placeholder="my-project" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="justify-self-start text-left">{{ t("connection.spannerInstance") }}</Label>
<Input v-model="instance" class="col-span-3" placeholder="my-instance" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="justify-self-start text-left">{{ t("connection.spannerDatabase") }}</Label>
<Input v-model="databaseId" class="col-span-3" placeholder="my-database" />
</div>
</template>
<div v-else class="grid grid-cols-4 items-center gap-4">
<Label class="justify-self-start text-left">{{ t("connection.spannerResourcePathMode") }}</Label>
<Input v-model="resourcePath" class="col-span-3" :placeholder="t('connection.spannerResourcePathHint')" />
</div>
<div class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.spannerCredentialsHint") }}</p>
</div>
</div>
</template>
@@ -73,6 +73,7 @@ const assetIcons: Record<string, string> = {
db2: "db2",
dremio: "dremio",
bigquery: "bigquery",
spanner: "spanner",
cassandra: "cassandra",
doris: "doris",
manticoresearch: "manticoresearch.png",
@@ -1487,6 +1487,7 @@ async function buildDropSqlForRow(row: ObjectBrowserRow, options?: { cascade?: b
objectType: row.type,
schema: row.schema || selectedSchema.value,
name: row.name,
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connection.id),
});
}
@@ -2072,6 +2073,7 @@ async function buildDuplicateStructurePlan(sourceName: string, targetName: strin
targetName,
tableComment,
sourceColumns,
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connection.id),
});
}
@@ -2260,6 +2262,7 @@ async function confirmPasteTable() {
sourceName: entry.sourceName,
targetName,
normalizeNewTargetName: mode === "structure-and-data",
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connection.id),
...dataCopyColumnOptions,
});
const executed = await executeObjectBrowserSqlWithProductionGuard(dataSql, () => api.executeQuery(props.connection.id, props.database, dataSql, schema));
@@ -2304,6 +2307,8 @@ function tableAdminSqlOptions(row: ObjectBrowserRow, options?: { cascade?: boole
databaseType: effectiveDatabaseType.value,
schema: row.schema || selectedSchema.value,
tableName: row.name,
// Cloud Spanner's dialect decides the quote; the static per-type mapping cannot.
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connection.id),
};
if (options?.cascade) result.cascade = true;
return result;
@@ -105,6 +105,7 @@ import {
supportsPackageMemberExpansion,
usesTreeSchemaMode,
isSingleDatabase,
schemaNodeHasLoadableName,
} from "@/lib/database/databaseCapabilities";
import { copyDisplayPathForTreeNode, copyNameForTreeNode, isDirectNavigationTreeNode, isDocumentBrowserTreeNode, isRepeatableNavigationTreeNode, objectSourceTargetForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick";
import { customTypeCapabilities, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
@@ -896,7 +897,7 @@ async function toggle(requestId = beginNavigationRequest()) {
}
} else if (node.type === "doris-catalog" && node.connectionId) {
await connectionStore.loadDorisCatalogDatabases(node, treeLoadSearchOptions);
} else if (node.type === "schema" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.schema) {
} else if (node.type === "schema" && node.connectionId && hasTreeNodeDatabaseContext(node) && schemaNodeHasLoadableName(effectiveDatabaseTypeForConnection(connectionStore.getConfig(node.connectionId)), node.schema)) {
await connectionStore.loadTables(node.connectionId, node.database, node.schema, treeLoadSearchOptions);
} else if (node.type === "linked-server-root" && node.connectionId) {
await connectionStore.loadSqlServerLinkedServers(node.connectionId, treeLoadSearchOptions);
@@ -1990,6 +1991,8 @@ function dropObjectSqlOptionsForNode(node: TreeNode): DropObjectSqlOptions | nul
schema: node.schema,
name: node.objectName || node.label,
signature: node.signature,
// Cloud Spanner's dialect decides the quote; the static per-type mapping cannot.
identifierQuote: connectionStore.connectionIdentifierQuote?.(node.connectionId),
};
}
@@ -3638,6 +3641,7 @@ async function confirmDuplicateStructure() {
sourceName: node.label,
targetName: newName,
tableComment: node.comment,
identifierQuote: connectionStore.connectionIdentifierQuote?.(node.connectionId),
});
await executeTreeNodeSqlWithProductionGuard(node, plan.sql, {
database: node.database,
@@ -3688,6 +3692,7 @@ async function confirmPasteTable() {
sourceName: entry.sourceName,
targetName,
tableComment: entry.tableComment,
identifierQuote: connectionStore.connectionIdentifierQuote?.(entry.connectionId),
});
sourceColumns = plan.sourceColumns;
const structureExecuted = await executeTreeNodeSqlWithProductionGuard(entry, plan.sql, {
@@ -3716,6 +3721,7 @@ async function confirmPasteTable() {
sourceName: entry.sourceName,
targetName,
normalizeNewTargetName: mode === "structure-and-data",
identifierQuote: connectionStore.connectionIdentifierQuote?.(entry.connectionId),
...dataCopyColumnOptions,
});
const dataExecuted = await executeTreeNodeSqlWithProductionGuard(entry, dataSql, { database: entry.database, schema: entry.schema });
@@ -58,6 +58,7 @@ import { formatSidebarObjectStorage } from "@/lib/sidebar/sidebarDatabaseStorage
import { dataTabOpenModeFromTreeClick } from "@/lib/sidebar/dataTabOpenPolicy";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { connectionDisplayUrlScheme } from "@/lib/connection/connectionPresentation";
import { encodeSpannerResourcePath } from "@/lib/connection/spannerResourcePath";
import { hexToRgba } from "@/lib/common/color";
import { sidebarDisplayTableName } from "@/lib/sidebar/sidebarTableNameDisplay";
import { shouldMeasureSidebarLabelOverflow } from "@/lib/sidebar/sidebarLabelTooltip";
@@ -458,7 +459,8 @@ function connectionTooltipUrl(config: ConnectionConfig): string {
const user = cleanTooltipValue(config.username);
const userInfo = user ? `${encodeURIComponent(user)}@` : "";
const database = cleanTooltipValue(config.database);
const path = database ? `/${encodeURIComponent(database)}` : "";
const encodedDatabase = config.db_type === "spanner" ? encodeSpannerResourcePath(database) : encodeURIComponent(database);
const path = database ? `/${encodedDatabase}` : "";
const params = cleanTooltipValue(config.url_params);
const query = params ? (params.startsWith("?") ? params : `?${params}`) : "";
return redactedConnectionString(`${scheme}://${userInfo}${hostForDisplay(host)}${port}${path}${query}`);