feat(spanner): add Google Cloud Spanner support
This commit is contained in:
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}`);
|
||||
|
||||
Reference in new issue
Block a user