mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(spanner): add Google Cloud Spanner support
This commit is contained in:
@@ -55,6 +55,7 @@ const extraAliases = {
|
||||
db2: ["ibm db2"],
|
||||
informix: ["ibm informix"],
|
||||
bigquery: ["google bigquery"],
|
||||
spanner: ["cloud spanner", "google spanner", "google cloud spanner"],
|
||||
kylin: ["apache kylin"],
|
||||
oscar: ["shentong", "oscar", "神通"],
|
||||
xugu: ["xugudb", "xugu", "虚谷"],
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
<img src="https://img.shields.io/badge/SelectDB-22C1C3?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/StarRocks-5C2D91?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Redshift-8C4FFF?logo=amazonredshift&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Cloud%20Spanner-4285F4?logo=googlecloudspanner&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/DM-3857FF?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/OceanBase-006AFF?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/openGauss-2B7BD9?logoColor=white" />
|
||||
@@ -91,7 +92,7 @@
|
||||
|
||||
### 90+ Databases, One Tool
|
||||
|
||||
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Meilisearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingbaseES, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, Dolt, and more. Agent-based profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, Consul KV, IRIS, and more. Message queue admin is also available for Pulsar, Kafka, and RocketMQ. All in a single ~20 MB app. No bundled Chromium.
|
||||
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Meilisearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingbaseES, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, Dolt, and more. Agent-based profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Cloud Spanner, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, Consul KV, IRIS, and more. Message queue admin is also available for Pulsar, Kafka, and RocketMQ. All in a single ~20 MB app. No bundled Chromium.
|
||||
|
||||
### Query Editor
|
||||
|
||||
|
||||
+3
-2
@@ -34,6 +34,7 @@
|
||||
<img src="https://img.shields.io/badge/SelectDB-22C1C3?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/StarRocks-5C2D91?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Redshift-8C4FFF?logo=amazonredshift&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Cloud%20Spanner-4285F4?logo=googlecloudspanner&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/DM-3857FF?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/OceanBase-006AFF?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/openGauss-2B7BD9?logoColor=white" />
|
||||
@@ -91,7 +92,7 @@
|
||||
|
||||
### 90+ 种数据库,一个工具搞定
|
||||
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingbaseES、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB、Dolt 等数据库都能直接连接。Agent 配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、JDBCX 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 等。全部装进约 20 MB 的应用里,不内嵌 Chromium。
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingbaseES、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB、Dolt 等数据库都能直接连接。Agent 配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Cloud Spanner、Kylin、SunDB、JDBCX 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 等。全部装进约 20 MB 的应用里,不内嵌 Chromium。
|
||||
|
||||
### 查询编辑器
|
||||
|
||||
@@ -487,7 +488,7 @@ DBX 仅 20 MB,无需运行时依赖(无需 Java、无需 Python)。AI 和
|
||||
|
||||
<details>
|
||||
<summary><strong>支持哪些数据库?</strong></summary>
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、Qdrant、Milvus、Weaviate、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingbaseES、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB 等。Agent 配置可扩展到 H2、Snowflake、Trino、PrestoSQL、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、JDBCX、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase 8a/8s、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 及自定义 JDBC 连接,并支持消息队列管理(Pulsar、Kafka、RocketMQ)。
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、Qdrant、Milvus、Weaviate、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingbaseES、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB 等。Agent 配置可扩展到 H2、Snowflake、Trino、PrestoSQL、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Cloud Spanner、Kylin、SunDB、JDBCX、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase 8a/8s、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 及自定义 JDBC 连接,并支持消息队列管理(Pulsar、Kafka、RocketMQ)。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
@@ -36,6 +36,7 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo
|
||||
| neo4j | Neo4j | Official Neo4j Go Driver native agent |
|
||||
| cassandra | Apache Cassandra 2.1+ | Apache cassandra-gocql-driver native agent |
|
||||
| bigquery | Google BigQuery | BigQuery JDBC |
|
||||
| spanner | Google Cloud Spanner | Google Cloud Spanner JDBC |
|
||||
| kylin | Apache Kylin | Kylin JDBC |
|
||||
| ignite | Apache Ignite | Ignite JDBC thin (ignite-core) |
|
||||
| ignite3 | Apache Ignite 3 | Ignite 3 JDBC thin (ignite-jdbc) |
|
||||
|
||||
@@ -36,6 +36,7 @@ DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数
|
||||
| neo4j | Neo4j | 官方 Neo4j Go Driver 原生 Agent |
|
||||
| cassandra | Apache Cassandra 2.1+ | Apache cassandra-gocql-driver 原生 Agent |
|
||||
| bigquery | Google BigQuery | BigQuery JDBC |
|
||||
| spanner | Google Cloud Spanner | Google Cloud Spanner JDBC |
|
||||
| kylin | Apache Kylin | Kylin JDBC |
|
||||
| ignite | Apache Ignite | Ignite JDBC thin(ignite-core) |
|
||||
| ignite3 | Apache Ignite 3 | Ignite 3 JDBC thin(ignite-jdbc) |
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ def pooledJdbcProjects = [
|
||||
'access', 'bigquery', 'dameng', 'databend', 'databricks', 'db2', 'exasol',
|
||||
'firebird', 'gbase8a', 'gbase8s', 'goldendb', 'h2', 'h2-legacy', 'highgo',
|
||||
'ignite', 'ignite3', 'informix', 'iris', 'kylin', 'oceanbase-oracle', 'oscar', 'saphana',
|
||||
'snowflake', 'spark', 'sqlserver-legacy', 'sundb', 'teradata', 'trino', 'uxdb',
|
||||
'snowflake', 'spanner', 'spark', 'sqlserver-legacy', 'sundb', 'teradata', 'trino', 'uxdb',
|
||||
'vertica', 'yashandb'
|
||||
] as Set
|
||||
def agentProjects = subprojects.findAll { !infrastructureProjects.contains(it.name) }
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
dependencies {
|
||||
// Google publishes an officially shaded fat jar for the Spanner JDBC driver. It already bundles
|
||||
// gRPC/protobuf/netty with relocated packages, which keeps shadowJar free of the relocation and
|
||||
// META-INF/services conflicts a plain dependency tree would introduce. The transitive tree is
|
||||
// dropped because the bundle already contains it, and resolving it would both duplicate every
|
||||
// shaded class and upgrade the Gson version the shared agent runtime is built against.
|
||||
implementation('com.google.cloud:google-cloud-spanner-jdbc:2.42.0:single-jar-with-dependencies') {
|
||||
transitive = false
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named('shadowJar') {
|
||||
dependencies {
|
||||
exclude(dependency('com.google.code.gson:gson'))
|
||||
exclude(dependency('com.google.errorprone:error_prone_annotations'))
|
||||
}
|
||||
// The published fat jar still carries stale BouncyCastle signature files (META-INF/BC2048KE.SF
|
||||
// and its .DSA). Their digests no longer match the rewritten manifest, so keeping them makes the
|
||||
// JVM fail with "SecurityException: Invalid signature file digest for Manifest main attributes".
|
||||
exclude 'META-INF/*.SF'
|
||||
exclude 'META-INF/*.DSA'
|
||||
exclude 'META-INF/*.RSA'
|
||||
exclude 'META-INF/*.EC'
|
||||
mergeServiceFiles()
|
||||
manifest {
|
||||
attributes(
|
||||
'Agent-Label': 'Google Cloud Spanner',
|
||||
'Main-Class': 'com.dbx.agent.spanner.SpannerAgent'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,779 @@
|
||||
package com.dbx.agent.spanner;
|
||||
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.CompletionAssistantCandidate;
|
||||
import com.dbx.agent.CompletionAssistantCandidateKind;
|
||||
import com.dbx.agent.CompletionAssistantMatchMode;
|
||||
import com.dbx.agent.CompletionAssistantObjectKind;
|
||||
import com.dbx.agent.CompletionAssistantRequest;
|
||||
import com.dbx.agent.CompletionAssistantResponse;
|
||||
import com.dbx.agent.ConfiguredJdbcAgent;
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.DatabaseInfo;
|
||||
import com.dbx.agent.DdlBuilder;
|
||||
import com.dbx.agent.ForeignKeyInfo;
|
||||
import com.dbx.agent.IndexInfo;
|
||||
import com.dbx.agent.JdbcAgentProfile;
|
||||
import com.dbx.agent.MetadataListConstraints;
|
||||
import com.dbx.agent.MultiSessionJsonRpcServer;
|
||||
import com.dbx.agent.ObjectInfo;
|
||||
import com.dbx.agent.StandardJdbcMetadata;
|
||||
import com.dbx.agent.TableInfo;
|
||||
import com.google.cloud.spanner.Database;
|
||||
import com.google.cloud.spanner.DatabaseId;
|
||||
import com.google.cloud.spanner.Dialect;
|
||||
import com.google.cloud.spanner.jdbc.CloudSpannerJdbcConnection;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Google Cloud Spanner agent.
|
||||
*
|
||||
* <p>The official driver already ships dialect-aware {@code DatabaseMetaData} implementations
|
||||
* (separate SQL resources for GoogleSQL and the PostgreSQL dialect), so no INFORMATION_SCHEMA SQL is
|
||||
* written here. What the shared {@code StandardJdbcMetadata} adapter cannot express is Spanner's
|
||||
* schema model: in GoogleSQL the user schema name <em>is</em> the empty string, while the shared
|
||||
* adapter maps blank schemas to {@code null} ("unspecified") and therefore leaks the 28+
|
||||
* INFORMATION_SCHEMA views into every listing. The thin adapter below passes the schema through
|
||||
* verbatim and fixes three field-level mismatches: the primary key index is named
|
||||
* {@code PRIMARY_KEY}, {@code ORDINAL_POSITION} is NULL for STORING/INCLUDE index columns, and
|
||||
* GoogleSQL reports INTERLEAVE parent links as unnamed foreign keys.
|
||||
*/
|
||||
public final class SpannerAgent extends ConfiguredJdbcAgent {
|
||||
/** GoogleSQL exposes user objects under the empty schema name. */
|
||||
private static final String GOOGLE_SQL_SCHEMA = "";
|
||||
/** The PostgreSQL dialect always reports {@code public}; used only if the driver stops telling us. */
|
||||
private static final String POSTGRES_FALLBACK_SCHEMA = "public";
|
||||
private static final String PRIMARY_KEY_INDEX = "PRIMARY_KEY";
|
||||
private static final String URL_PREFIX = "jdbc:cloudspanner:";
|
||||
/** A loopback endpoint is always the local emulator: the real service is addressed host-less. */
|
||||
private static final Set<String> LOOPBACK_HOSTS =
|
||||
new HashSet<>(Arrays.asList("localhost", "127.0.0.1", "::1", "[::1]"));
|
||||
private static final String PLAIN_TEXT_PARAM = "usePlainText=true";
|
||||
/**
|
||||
* Works verbatim in both dialects: the PostgreSQL dialect folds the unquoted upper-case
|
||||
* identifiers to lower case, so one statement serves GoogleSQL and PostgreSQL alike.
|
||||
*/
|
||||
private static final String SCHEMATA_SQL = "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA";
|
||||
/**
|
||||
* The driver reports GoogleSQL spellings from {@code getTypeInfo()} for both dialects (only
|
||||
* JSON/JSONB differs), so the PostgreSQL-dialect names are substituted here. Every entry was
|
||||
* read back from {@code information_schema.columns} on a PostgreSQL-dialect database rather than
|
||||
* transcribed from documentation.
|
||||
*/
|
||||
private static final Map<String, String> POSTGRES_DIALECT_TYPE_NAMES = postgresDialectTypeNames();
|
||||
|
||||
private static Map<String, String> postgresDialectTypeNames() {
|
||||
Map<String, String> names = new LinkedHashMap<>();
|
||||
names.put("STRING", "character varying");
|
||||
names.put("INT64", "bigint");
|
||||
names.put("BYTES", "bytea");
|
||||
names.put("FLOAT32", "real");
|
||||
names.put("FLOAT64", "double precision");
|
||||
names.put("BOOL", "boolean");
|
||||
names.put("DATE", "date");
|
||||
names.put("TIMESTAMP", "timestamp with time zone");
|
||||
names.put("NUMERIC", "numeric");
|
||||
names.put("UUID", "uuid");
|
||||
// The driver already answers JSONB for the PostgreSQL dialect, but in upper case.
|
||||
names.put("JSON", "jsonb");
|
||||
names.put("JSONB", "jsonb");
|
||||
return Collections.unmodifiableMap(names);
|
||||
}
|
||||
private static final int DEFAULT_COMPLETION_LIMIT = 100;
|
||||
private static final int MAX_COMPLETION_LIMIT = 1000;
|
||||
|
||||
public static final JdbcAgentProfile SPANNER_PROFILE = new JdbcAgentProfile(
|
||||
"com.google.cloud.spanner.jdbc.JdbcDriver",
|
||||
URL_PREFIX + "//{host}:{port}/{database}",
|
||||
443,
|
||||
// Spanner has no SQL-level schema switch statement; SET SCHEMA and set search_path are both
|
||||
// rejected as unknown statements, so setSchemaSQL must resolve to an empty string.
|
||||
true,
|
||||
new HashSet<>(Arrays.asList("INFORMATION_SCHEMA", "SPANNER_SYS", "PG_CATALOG")),
|
||||
Arrays.asList("TABLE", "VIEW"),
|
||||
"`",
|
||||
"SET SCHEMA",
|
||||
// The catalog is the empty string in GoogleSQL and the database name in the PostgreSQL
|
||||
// dialect; retrying metadata with it only adds unrelated rows.
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
);
|
||||
|
||||
private volatile boolean postgresDialect;
|
||||
private volatile String defaultSchema = GOOGLE_SQL_SCHEMA;
|
||||
|
||||
public SpannerAgent() {
|
||||
super(SPANNER_PROFILE);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String buildJdbcUrl(ConnectParams params) {
|
||||
return spannerUrl(params);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterConnect(ConnectParams params, Connection connection) {
|
||||
super.afterConnect(params, connection);
|
||||
detectDialect(connection);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getIdentifierQuote() {
|
||||
return postgresDialect ? "\"" : "`";
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DatabaseInfo> listDatabases() {
|
||||
// JDBC alone cannot enumerate an instance (and in the PostgreSQL dialect getCatalogs() plus
|
||||
// the configured resource path would duplicate the current entry), but the bundled driver
|
||||
// ships the Admin API and the connection already carries the credentials, so ask it.
|
||||
List<DatabaseInfo> databases = adminListDatabases();
|
||||
return databases.isEmpty() ? Collections.singletonList(new DatabaseInfo(configuredDatabaseName())) : databases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the sibling databases through {@code DatabaseAdminClient}, which the JDBC connection
|
||||
* exposes via {@code getSpanner()}. Returns an empty list — never throws — when the caller lacks
|
||||
* {@code spanner.databases.list}, when the driver is loaded from a classloader that cannot see
|
||||
* the admin classes, or when anything else about the unwrap fails; {@link #listDatabases()} then
|
||||
* falls back to the connected database alone, which is what shipped before.
|
||||
*/
|
||||
private List<DatabaseInfo> adminListDatabases() {
|
||||
try {
|
||||
CloudSpannerJdbcConnection connection = requireConnection().unwrap(CloudSpannerJdbcConnection.class);
|
||||
DatabaseId databaseId = connection.getDatabaseId();
|
||||
String instance = databaseId.getInstanceId().getInstance();
|
||||
List<DatabaseInfo> result = new ArrayList<>();
|
||||
for (Database database : connection.getSpanner().getDatabaseAdminClient().listDatabases(instance).iterateAll()) {
|
||||
// getName(), not getDatabase(): the reported name round-trips as
|
||||
// ConnectParams.database, and only the full resource path builds a valid JDBC URL.
|
||||
// The sidebar shortens it for display through `spannerDisplayDatabase`.
|
||||
result.add(new DatabaseInfo(database.getId().getName()));
|
||||
}
|
||||
result.sort(Comparator.comparing(DatabaseInfo::getName));
|
||||
return result;
|
||||
} catch (Throwable ignored) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> listDataTypes() {
|
||||
// The driver reports GoogleSQL spellings for both dialects except JSON/JSONB, so a
|
||||
// PostgreSQL-dialect database would otherwise offer INT64/STRING/BOOL in the schema-diff
|
||||
// field mapping. Substitute the PostgreSQL names for the types the driver enumerates.
|
||||
List<String> types = super.listDataTypes();
|
||||
if (!postgresDialect) {
|
||||
return types;
|
||||
}
|
||||
List<String> result = new ArrayList<>(types.size());
|
||||
for (String type : types) {
|
||||
result.add(POSTGRES_DIALECT_TYPE_NAMES.getOrDefault(type.toUpperCase(Locale.ROOT), type));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTableDdl(String schema, String table) {
|
||||
String resolved = resolveSchema(schema);
|
||||
List<IndexInfo> indexes;
|
||||
try {
|
||||
indexes = listIndexes(resolved, table);
|
||||
} catch (RuntimeException e) {
|
||||
indexes = Collections.emptyList();
|
||||
}
|
||||
List<ForeignKeyInfo> foreignKeys;
|
||||
try {
|
||||
foreignKeys = listForeignKeys(resolved, table);
|
||||
} catch (RuntimeException e) {
|
||||
foreignKeys = Collections.emptyList();
|
||||
}
|
||||
String tableComment = null;
|
||||
try {
|
||||
tableComment = getTableComment(resolved, table);
|
||||
} catch (RuntimeException e) {
|
||||
// Optional; DDL generation should still succeed without it.
|
||||
}
|
||||
// Same as the inherited implementation except for the quote: it hardcodes ANSI double quotes,
|
||||
// which GoogleSQL reads as a string literal rather than an identifier.
|
||||
return DdlBuilder.buildTableDdl(
|
||||
resolved,
|
||||
table,
|
||||
getColumns(resolved, table),
|
||||
indexes,
|
||||
foreignKeys,
|
||||
Collections.emptyList(),
|
||||
!postgresDialect,
|
||||
false,
|
||||
tableComment
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> listSchemas() {
|
||||
// Not getSchemas(): the shared adapter drops blank names, and GoogleSQL's user schema *is*
|
||||
// the empty string, so the default schema would disappear and take the object tree with it.
|
||||
// Query the catalog directly instead, the way H2, DB2, Trino and Snowflake already do.
|
||||
Connection connection = requireConnection();
|
||||
Set<String> names = new LinkedHashSet<>();
|
||||
names.add(defaultSchema);
|
||||
List<String> named = new ArrayList<>();
|
||||
try (java.sql.Statement statement = connection.createStatement();
|
||||
ResultSet rs = statement.executeQuery(SCHEMATA_SQL)) {
|
||||
while (rs.next()) {
|
||||
String name = rs.getString(1);
|
||||
if (name == null || name.equals(defaultSchema)) {
|
||||
continue;
|
||||
}
|
||||
// Upper-cased before the comparison because the PostgreSQL dialect reports its
|
||||
// system schemas in lower case.
|
||||
if (SPANNER_PROFILE.getExcludedSchemas().contains(name.toUpperCase(Locale.ROOT))) {
|
||||
continue;
|
||||
}
|
||||
named.add(name);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// A database that cannot be queried still browses under its default schema.
|
||||
return new ArrayList<>(names);
|
||||
}
|
||||
Collections.sort(named);
|
||||
names.addAll(named);
|
||||
return new ArrayList<>(names);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TableInfo> listTables(String schema) {
|
||||
return listTables(schema, MetadataListConstraints.NONE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TableInfo> listTables(String schema, List<String> objectTypes) {
|
||||
return listTables(schema, new MetadataListConstraints(null, null, null, objectTypes));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TableInfo> listTables(String schema, MetadataListConstraints constraints) {
|
||||
MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints);
|
||||
if (!normalized.includesTableLikeTypes()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return normalized.filterTables(spannerTables(requireConnection(), resolveSchema(schema)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ObjectInfo> listObjects(String schema) {
|
||||
return listObjects(schema, MetadataListConstraints.NONE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ObjectInfo> listObjects(String schema, MetadataListConstraints constraints) {
|
||||
MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints);
|
||||
// Spanner has no stored procedures or functions, so table-like objects are the whole set.
|
||||
String resolved = resolveSchema(schema);
|
||||
List<TableInfo> tables = spannerTables(requireConnection(), resolved);
|
||||
return normalized.filterObjects(StandardJdbcMetadata.INSTANCE.listObjects(tables, resolved));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ColumnInfo> getColumns(String schema, String table) {
|
||||
return spannerColumns(requireConnection(), resolveSchema(schema), table);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IndexInfo> listIndexes(String schema, String table) {
|
||||
return spannerIndexes(requireConnection(), resolveSchema(schema), table);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ForeignKeyInfo> listForeignKeys(String schema, String table) {
|
||||
return spannerForeignKeys(requireConnection(), resolveSchema(schema), table);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public CompletionAssistantResponse completionAssistantSearch(CompletionAssistantRequest request) {
|
||||
Connection conn = requireConnection();
|
||||
String schema = resolveSchema(completionSchema(request));
|
||||
int limit = completionLimit(request.getMax_results());
|
||||
List<CompletionAssistantObjectKind> kinds = completionKinds(request);
|
||||
List<CompletionAssistantCandidate> candidates = new ArrayList<>();
|
||||
|
||||
if (kinds.contains(CompletionAssistantObjectKind.SCHEMA)
|
||||
&& !schema.isEmpty()
|
||||
&& completionMatches(schema, request)) {
|
||||
candidates.add(new CompletionAssistantCandidate(
|
||||
schema,
|
||||
CompletionAssistantCandidateKind.SCHEMA,
|
||||
request.getDatabase(),
|
||||
schema,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
|
||||
if (kinds.contains(CompletionAssistantObjectKind.TABLE) || kinds.contains(CompletionAssistantObjectKind.VIEW)) {
|
||||
for (TableInfo table : spannerTables(conn, schema)) {
|
||||
if (candidates.size() >= limit) {
|
||||
break;
|
||||
}
|
||||
boolean view = "VIEW".equalsIgnoreCase(table.getTable_type());
|
||||
CompletionAssistantObjectKind requiredKind = view
|
||||
? CompletionAssistantObjectKind.VIEW
|
||||
: CompletionAssistantObjectKind.TABLE;
|
||||
if (!kinds.contains(requiredKind) || !completionMatches(table.getName(), request)) {
|
||||
continue;
|
||||
}
|
||||
candidates.add(new CompletionAssistantCandidate(
|
||||
table.getName(),
|
||||
view ? CompletionAssistantCandidateKind.VIEW : CompletionAssistantCandidateKind.TABLE,
|
||||
request.getDatabase(),
|
||||
schema,
|
||||
null,
|
||||
null,
|
||||
table.getComment(),
|
||||
null
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
String parentName = request.getParent_name();
|
||||
if (kinds.contains(CompletionAssistantObjectKind.COLUMN)
|
||||
&& parentName != null
|
||||
&& !parentName.trim().isEmpty()) {
|
||||
for (ColumnInfo column : spannerColumns(conn, schema, parentName)) {
|
||||
if (candidates.size() >= limit) {
|
||||
break;
|
||||
}
|
||||
if (!completionMatches(column.getName(), request)) {
|
||||
continue;
|
||||
}
|
||||
candidates.add(new CompletionAssistantCandidate(
|
||||
column.getName(),
|
||||
CompletionAssistantCandidateKind.COLUMN,
|
||||
request.getDatabase(),
|
||||
schema,
|
||||
schema,
|
||||
parentName,
|
||||
column.getComment(),
|
||||
column.getData_type()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return new CompletionAssistantResponse(candidates, candidates.size() >= limit, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Spanner JDBC URL. The profile template cannot express the endpoint-less form used
|
||||
* for the real service, where the URL is {@code jdbc:cloudspanner:/<resource path>} without any
|
||||
* {@code //host:port} authority.
|
||||
*/
|
||||
static String spannerUrl(ConnectParams params) {
|
||||
if (!params.getConnection_string().trim().isEmpty()) {
|
||||
return params.getConnection_string();
|
||||
}
|
||||
String host = trimmed(params.getHost());
|
||||
StringBuilder url = new StringBuilder(URL_PREFIX);
|
||||
if (!host.isEmpty()) {
|
||||
int port = params.getPort() > 0 ? params.getPort() : SPANNER_PROFILE.getDefaultPort();
|
||||
url.append("//").append(host).append(':').append(port);
|
||||
}
|
||||
// params.database carries the full resource path projects/{p}/instances/{i}/databases/{d}.
|
||||
url.append('/').append(stripLeadingSlashes(trimmed(params.getDatabase())));
|
||||
return appendUrlParams(url.toString(), emulatorAwareUrlParams(host, params.getUrl_params()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The emulator speaks plaintext gRPC, but the driver defaults to TLS plus Application Default
|
||||
* Credentials because nothing in the URL distinguishes an emulator endpoint from a private one.
|
||||
* Pointing a connection at localhost without a plaintext flag therefore burns the whole connect
|
||||
* timeout while gRPC retries a TLS handshake against a plaintext port, and reports only
|
||||
* {@code UNAVAILABLE: io exception} — so a loopback host opts into plaintext by default.
|
||||
*
|
||||
* <p>An explicit {@code usePlainText} or {@code autoConfigEmulator} always wins, including
|
||||
* {@code usePlainText=false}. {@code autoConfigEmulator} implies plaintext but additionally
|
||||
* creates a missing instance and database, which is a choice only the user should make.
|
||||
*/
|
||||
private static String emulatorAwareUrlParams(String host, String urlParams) {
|
||||
String configured = trimmed(urlParams);
|
||||
if (!LOOPBACK_HOSTS.contains(host.toLowerCase(Locale.ROOT))) {
|
||||
return configured;
|
||||
}
|
||||
if (hasUrlParam(configured, "usePlainText") || hasUrlParam(configured, "autoConfigEmulator")) {
|
||||
return configured;
|
||||
}
|
||||
return configured.isEmpty() ? PLAIN_TEXT_PARAM : configured + ";" + PLAIN_TEXT_PARAM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a property name at a separator boundary so that {@code usePlainText} is not reported
|
||||
* for an unrelated property that merely ends with it. Spanner property names are
|
||||
* case-insensitive.
|
||||
*/
|
||||
private static boolean hasUrlParam(String urlParams, String name) {
|
||||
String haystack = urlParams.toLowerCase(Locale.ROOT);
|
||||
String needle = name.toLowerCase(Locale.ROOT);
|
||||
for (int from = 0; from <= haystack.length() - needle.length(); ) {
|
||||
int index = haystack.indexOf(needle, from);
|
||||
if (index < 0) {
|
||||
return false;
|
||||
}
|
||||
boolean startsToken = index == 0 || "?;&".indexOf(haystack.charAt(index - 1)) >= 0;
|
||||
int after = index + needle.length();
|
||||
boolean endsToken = after >= haystack.length() || haystack.charAt(after) == '=';
|
||||
if (startsToken && endsToken) {
|
||||
return true;
|
||||
}
|
||||
from = index + 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static List<TableInfo> spannerTables(Connection conn, String schema) {
|
||||
return unchecked(() -> {
|
||||
String[] tableTypes = SPANNER_PROFILE.getTableTypes().toArray(new String[0]);
|
||||
List<TableInfo> result = new ArrayList<>();
|
||||
// The schema is passed through verbatim: an empty string is GoogleSQL's user schema, and
|
||||
// mapping it to null would return every INFORMATION_SCHEMA/SPANNER_SYS view as well.
|
||||
try (ResultSet rs = conn.getMetaData().getTables(null, schema, "%", tableTypes)) {
|
||||
while (rs.next()) {
|
||||
result.add(new TableInfo(
|
||||
rs.getString("TABLE_NAME"),
|
||||
normalizeTableType(rs.getString("TABLE_TYPE")),
|
||||
rs.getString("REMARKS")
|
||||
));
|
||||
}
|
||||
}
|
||||
result.sort(Comparator.comparing(TableInfo::getName));
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
static List<ColumnInfo> spannerColumns(Connection conn, String schema, String table) {
|
||||
return unchecked(() -> {
|
||||
DatabaseMetaData meta = conn.getMetaData();
|
||||
Set<String> primaryKeys = new LinkedHashSet<>();
|
||||
try (ResultSet rs = meta.getPrimaryKeys(null, schema, table)) {
|
||||
while (rs.next()) {
|
||||
String name = rs.getString("COLUMN_NAME");
|
||||
if (name != null) {
|
||||
primaryKeys.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
List<ColumnInfo> result = new ArrayList<>();
|
||||
try (ResultSet rs = meta.getColumns(null, schema, table, "%")) {
|
||||
while (rs.next()) {
|
||||
String name = rs.getString("COLUMN_NAME");
|
||||
// TYPE_NAME already carries the native Spanner type (STRING(100), ARRAY<INT64>).
|
||||
String typeName = rs.getString("TYPE_NAME");
|
||||
result.add(new ColumnInfo(
|
||||
name,
|
||||
typeName,
|
||||
rs.getInt("NULLABLE") != DatabaseMetaData.columnNoNulls,
|
||||
rs.getString("COLUMN_DEF"),
|
||||
primaryKeys.contains(name),
|
||||
null,
|
||||
rs.getString("REMARKS"),
|
||||
intOrNull(rs, "COLUMN_SIZE"),
|
||||
intOrNull(rs, "DECIMAL_DIGITS"),
|
||||
characterLength(typeName, intOrNull(rs, "COLUMN_SIZE"))
|
||||
));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
static List<IndexInfo> spannerIndexes(Connection conn, String schema, String table) {
|
||||
return unchecked(() -> {
|
||||
Map<String, Boolean> uniqueByIndex = new LinkedHashMap<>();
|
||||
Map<String, List<IndexColumn>> keyColumns = new LinkedHashMap<>();
|
||||
Map<String, List<String>> storingColumns = new LinkedHashMap<>();
|
||||
try (ResultSet rs = conn.getMetaData().getIndexInfo(null, schema, table, false, false)) {
|
||||
while (rs.next()) {
|
||||
String name = rs.getString("INDEX_NAME");
|
||||
String column = rs.getString("COLUMN_NAME");
|
||||
if (name == null || column == null) {
|
||||
continue;
|
||||
}
|
||||
uniqueByIndex.putIfAbsent(name, !rs.getBoolean("NON_UNIQUE"));
|
||||
Integer ordinal = intOrNull(rs, "ORDINAL_POSITION");
|
||||
if (ordinal == null) {
|
||||
// Spanner reports STORING (GoogleSQL) / INCLUDE (PostgreSQL) columns with a
|
||||
// NULL ORDINAL_POSITION. Reading them with getShort() would turn them into
|
||||
// key column 0 and put a non-key column in front of the real key columns.
|
||||
storingColumns.computeIfAbsent(name, ignored -> new ArrayList<>()).add(column);
|
||||
continue;
|
||||
}
|
||||
keyColumns.computeIfAbsent(name, ignored -> new ArrayList<>())
|
||||
.add(new IndexColumn(ordinal, column));
|
||||
}
|
||||
}
|
||||
|
||||
List<IndexInfo> result = new ArrayList<>();
|
||||
for (Map.Entry<String, Boolean> entry : uniqueByIndex.entrySet()) {
|
||||
String name = entry.getKey();
|
||||
List<IndexColumn> ordered = keyColumns.getOrDefault(name, new ArrayList<>());
|
||||
ordered.sort(Comparator.comparingInt(IndexColumn::getOrdinal));
|
||||
List<String> columns = new ArrayList<>();
|
||||
for (IndexColumn column : ordered) {
|
||||
columns.add(column.getName());
|
||||
}
|
||||
result.add(new IndexInfo(
|
||||
name,
|
||||
columns,
|
||||
Boolean.TRUE.equals(entry.getValue()),
|
||||
// Spanner names the primary key index PRIMARY_KEY, not PRIMARY.
|
||||
PRIMARY_KEY_INDEX.equalsIgnoreCase(name),
|
||||
null,
|
||||
null,
|
||||
storingColumns.get(name),
|
||||
null
|
||||
));
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
static List<ForeignKeyInfo> spannerForeignKeys(Connection conn, String schema, String table) {
|
||||
return unchecked(() -> {
|
||||
List<ForeignKeyInfo> result = new ArrayList<>();
|
||||
try (ResultSet rs = conn.getMetaData().getImportedKeys(null, schema, table)) {
|
||||
while (rs.next()) {
|
||||
String name = rs.getString("FK_NAME");
|
||||
if (name == null || name.trim().isEmpty()) {
|
||||
// GoogleSQL reports INTERLEAVE IN PARENT links as unnamed imported keys while
|
||||
// the PostgreSQL dialect omits them. Skipping keeps both dialects consistent
|
||||
// and avoids showing an interleaving relationship as a nameless foreign key.
|
||||
continue;
|
||||
}
|
||||
result.add(new ForeignKeyInfo(
|
||||
name,
|
||||
rs.getString("FKCOLUMN_NAME"),
|
||||
rs.getString("PKTABLE_NAME"),
|
||||
rs.getString("PKCOLUMN_NAME")
|
||||
));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/** Probes the database dialect once per connection; visible for testing. */
|
||||
void detectDialect(Connection connection) {
|
||||
boolean postgres = isPostgresDialect(connection);
|
||||
postgresDialect = postgres;
|
||||
defaultSchema = postgres ? postgresSchema(connection) : GOOGLE_SQL_SCHEMA;
|
||||
}
|
||||
|
||||
static boolean isPostgresDialect(Connection connection) {
|
||||
Boolean fromDriver = driverDialectIsPostgres(connection);
|
||||
if (fromDriver != null) {
|
||||
return fromDriver.booleanValue();
|
||||
}
|
||||
return productNameIsPostgres(connection);
|
||||
}
|
||||
|
||||
private static Boolean driverDialectIsPostgres(Connection connection) {
|
||||
try {
|
||||
CloudSpannerJdbcConnection spannerConnection =
|
||||
unwrapConnection(connection, CloudSpannerJdbcConnection.class);
|
||||
if (spannerConnection == null) {
|
||||
return null;
|
||||
}
|
||||
Dialect dialect = spannerConnection.getDialect();
|
||||
if (dialect == null) {
|
||||
return null;
|
||||
}
|
||||
return Boolean.valueOf(dialect == Dialect.POSTGRESQL);
|
||||
} catch (Throwable ignored) {
|
||||
// Linking the driver interface is not always possible, so this probe stays optional:
|
||||
// the published fat jar carries stale BouncyCastle signatures that only the shadow jar
|
||||
// strips (a plain classpath, for example the unit tests, throws SecurityException while
|
||||
// loading any driver class), and a user-supplied driver jar is loaded through a child
|
||||
// class loader whose interface is a different type. Both cases use the product name.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean productNameIsPostgres(Connection connection) {
|
||||
try {
|
||||
// GoogleSQL reports "Google Cloud Spanner", the PostgreSQL dialect appends " PostgreSQL".
|
||||
String product = connection.getMetaData().getDatabaseProductName();
|
||||
return product != null && product.contains("PostgreSQL");
|
||||
} catch (Exception ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static String postgresSchema(Connection connection) {
|
||||
try {
|
||||
String schema = connection.getSchema();
|
||||
if (schema != null && !schema.trim().isEmpty()) {
|
||||
return schema;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Fall through to the dialect default.
|
||||
}
|
||||
return POSTGRES_FALLBACK_SCHEMA;
|
||||
}
|
||||
|
||||
private String configuredDatabaseName() {
|
||||
String database = trimmed(getConfiguredDatabase());
|
||||
if (!database.isEmpty()) {
|
||||
return database;
|
||||
}
|
||||
try {
|
||||
return trimmed(requireConnection().getCatalog());
|
||||
} catch (Exception ignored) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Never maps a blank schema to {@code null}: GoogleSQL's user schema name is the empty string,
|
||||
* and the PostgreSQL dialect needs the literal {@code public}.
|
||||
*
|
||||
* <p>A value that cannot be a Spanner schema name is treated as "unspecified" as well. Hosts
|
||||
* that have no schema level commonly send the database identifier as the metadata schema, and
|
||||
* for Spanner that identifier is the resource path {@code projects/{p}/instances/{i}/databases/{d}}.
|
||||
* Passing it through would make every metadata lookup match nothing, and a Spanner schema name
|
||||
* can never contain a slash (identifiers are letters, digits and underscores).
|
||||
*/
|
||||
private String resolveSchema(String schema) {
|
||||
if (schema == null || schema.trim().isEmpty() || schema.indexOf('/') >= 0) {
|
||||
return defaultSchema;
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
private static List<CompletionAssistantObjectKind> completionKinds(CompletionAssistantRequest request) {
|
||||
List<CompletionAssistantObjectKind> kinds = request.getObject_kinds();
|
||||
if (kinds.isEmpty()) {
|
||||
return Arrays.asList(CompletionAssistantObjectKind.TABLE, CompletionAssistantObjectKind.VIEW);
|
||||
}
|
||||
return kinds;
|
||||
}
|
||||
|
||||
private static String completionSchema(CompletionAssistantRequest request) {
|
||||
String parentSchema = request.getParent_schema();
|
||||
if (parentSchema != null && !parentSchema.trim().isEmpty()) {
|
||||
return parentSchema;
|
||||
}
|
||||
return request.getSchema();
|
||||
}
|
||||
|
||||
private static int completionLimit(Integer requested) {
|
||||
if (requested == null) {
|
||||
return DEFAULT_COMPLETION_LIMIT;
|
||||
}
|
||||
return Math.max(1, Math.min(MAX_COMPLETION_LIMIT, requested));
|
||||
}
|
||||
|
||||
private static boolean completionMatches(String name, CompletionAssistantRequest request) {
|
||||
if (name == null) {
|
||||
return false;
|
||||
}
|
||||
String mask = request.getMask();
|
||||
if (mask.trim().isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
String candidate = request.getCase_sensitive() ? name : name.toLowerCase(Locale.ROOT);
|
||||
String expected = request.getCase_sensitive() ? mask : mask.toLowerCase(Locale.ROOT);
|
||||
if (request.getMatch_mode() == CompletionAssistantMatchMode.CONTAINS) {
|
||||
return candidate.contains(expected);
|
||||
}
|
||||
return candidate.startsWith(expected);
|
||||
}
|
||||
|
||||
private static String normalizeTableType(String type) {
|
||||
if (type == null || type.trim().isEmpty()) {
|
||||
return "TABLE";
|
||||
}
|
||||
if ("BASE TABLE".equalsIgnoreCase(type.trim())) {
|
||||
return "TABLE";
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
private static Integer characterLength(String typeName, Integer columnSize) {
|
||||
if (typeName == null) {
|
||||
return null;
|
||||
}
|
||||
String normalized = typeName.toLowerCase(Locale.ROOT);
|
||||
if (!normalized.contains("char") && !normalized.contains("text")) {
|
||||
return null;
|
||||
}
|
||||
return columnSize;
|
||||
}
|
||||
|
||||
private static Integer intOrNull(ResultSet rs, String column) throws Exception {
|
||||
Object value = rs.getObject(column);
|
||||
return value instanceof Number ? ((Number) value).intValue() : null;
|
||||
}
|
||||
|
||||
private static String appendUrlParams(String url, String urlParams) {
|
||||
String params = trimmed(urlParams);
|
||||
while (params.startsWith("?") || params.startsWith(";") || params.startsWith("&")) {
|
||||
params = params.substring(1);
|
||||
}
|
||||
if (params.isEmpty()) {
|
||||
return url;
|
||||
}
|
||||
// Spanner takes properties as ?name=value;name=value.
|
||||
return url + (url.indexOf('?') >= 0 ? ";" : "?") + params;
|
||||
}
|
||||
|
||||
private static String stripLeadingSlashes(String value) {
|
||||
String result = value;
|
||||
while (result.startsWith("/")) {
|
||||
result = result.substring(1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static String trimmed(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static final class IndexColumn {
|
||||
private final int ordinal;
|
||||
private final String name;
|
||||
|
||||
private IndexColumn(int ordinal, String name) {
|
||||
this.ordinal = ordinal;
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
private int getOrdinal() {
|
||||
return ordinal;
|
||||
}
|
||||
|
||||
private String getName() {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
new MultiSessionJsonRpcServer(SpannerAgent::new).run();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
package com.dbx.agent.spanner;
|
||||
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.DatabaseAgent;
|
||||
import com.dbx.agent.DatabaseInfo;
|
||||
import com.dbx.agent.ForeignKeyInfo;
|
||||
import com.dbx.agent.IndexInfo;
|
||||
import com.dbx.agent.TableInfo;
|
||||
import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest;
|
||||
import com.dbx.agent.test.TestSupport;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class SpannerAgentTest extends JdbcFakeExecutionBehaviorTest {
|
||||
private static final String GOOGLE_SQL_PRODUCT = "Google Cloud Spanner";
|
||||
private static final String POSTGRES_PRODUCT = "Google Cloud Spanner PostgreSQL";
|
||||
|
||||
@Override
|
||||
protected DatabaseAgent createAgent() {
|
||||
return new SpannerAgent();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String resultSetSql() {
|
||||
return "SHOW VARIABLE AUTOCOMMIT";
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesBacktickIdentifierQuoteForGoogleSqlDialect() {
|
||||
SpannerAgent agent = new SpannerAgent();
|
||||
|
||||
agent.detectDialect(fakeConnection(GOOGLE_SQL_PRODUCT, null, new LinkedHashMap<>()));
|
||||
|
||||
assertEquals("`", agent.getIdentifierQuote());
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesDoubleQuoteIdentifierQuoteForPostgresDialect() {
|
||||
SpannerAgent agent = new SpannerAgent();
|
||||
|
||||
agent.detectDialect(fakeConnection(POSTGRES_PRODUCT, "public", new LinkedHashMap<>()));
|
||||
|
||||
assertEquals("\"", agent.getIdentifierQuote());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsBacktickQuoteBeforeTheDialectIsProbed() {
|
||||
assertEquals("`", new SpannerAgent().getIdentifierQuote());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsTheEmptyGoogleSqlSchemaInsteadOfDroppingIt() {
|
||||
SpannerAgent agent = connectedAgent(GOOGLE_SQL_PRODUCT, null, new LinkedHashMap<>());
|
||||
|
||||
// The fake connection cannot create statements, so this exercises the fallback taken when
|
||||
// INFORMATION_SCHEMA.SCHEMATA is unreadable: the default schema is still reported, and it is
|
||||
// the empty string that GoogleSQL uses for its user schema. Named-schema discovery runs
|
||||
// against the emulator instead — a proxy cannot answer a real catalog query.
|
||||
assertEquals(Collections.singletonList(""), agent.listSchemas());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsThePostgresDialectSchema() {
|
||||
SpannerAgent agent = connectedAgent(POSTGRES_PRODUCT, "public", new LinkedHashMap<>());
|
||||
|
||||
// Same fallback path as above; the PostgreSQL dialect's default schema is `public`.
|
||||
assertEquals(Collections.singletonList("public"), agent.listSchemas());
|
||||
}
|
||||
|
||||
@Test
|
||||
void passesTheEmptyGoogleSqlSchemaVerbatimToTheDriver() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
Map<String, List<Map<String, Object>>> rows = new LinkedHashMap<>();
|
||||
rows.put("getTables", Arrays.asList(
|
||||
row("TABLE_NAME", "singers", "TABLE_TYPE", "TABLE"),
|
||||
row("TABLE_NAME", "v_singer_albums", "TABLE_TYPE", "VIEW")
|
||||
));
|
||||
SpannerAgent agent = connectedAgent(GOOGLE_SQL_PRODUCT, null, rows, calls);
|
||||
|
||||
List<TableInfo> tables = agent.listTables("");
|
||||
|
||||
assertEquals(Arrays.asList("singers", "v_singer_albums"), names(tables));
|
||||
assertEquals("TABLE", tables.get(0).getTable_type());
|
||||
assertEquals("VIEW", tables.get(1).getTable_type());
|
||||
// Empty string, never null: null would also return the INFORMATION_SCHEMA/SPANNER_SYS views.
|
||||
assertEquals(Collections.singletonList("getTables(catalog=null, schema=[], pattern=%, types=[TABLE, VIEW])"), calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToTheDialectSchemaWhenTheCallerSendsNone() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
Map<String, List<Map<String, Object>>> rows = new LinkedHashMap<>();
|
||||
rows.put("getTables", Collections.emptyList());
|
||||
SpannerAgent agent = connectedAgent(POSTGRES_PRODUCT, "public", rows, calls);
|
||||
|
||||
agent.listTables(null);
|
||||
|
||||
assertEquals(
|
||||
Collections.singletonList("getTables(catalog=null, schema=[public], pattern=%, types=[TABLE, VIEW])"),
|
||||
calls
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void treatsTheResourcePathAsAnUnspecifiedSchema() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
Map<String, List<Map<String, Object>>> rows = new LinkedHashMap<>();
|
||||
rows.put("getTables", Collections.singletonList(row("TABLE_NAME", "singers", "TABLE_TYPE", "TABLE")));
|
||||
SpannerAgent agent = connectedAgent(GOOGLE_SQL_PRODUCT, null, rows, calls);
|
||||
|
||||
// Hosts without a schema level send the database identifier as the metadata schema, and for
|
||||
// Spanner that identifier is the resource path. Forwarding it verbatim matches nothing.
|
||||
assertEquals(
|
||||
Collections.singletonList("singers"),
|
||||
names(agent.listTables("projects/test-project/instances/test-instance/databases/gsqldb"))
|
||||
);
|
||||
assertEquals(Collections.singletonList("getTables(catalog=null, schema=[], pattern=%, types=[TABLE, VIEW])"), calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesTheSchemaBeforeRenderingTableDdl() {
|
||||
Map<String, List<Map<String, Object>>> rows = new LinkedHashMap<>();
|
||||
rows.put("getColumns", Collections.singletonList(row(
|
||||
"COLUMN_NAME", "singer_id",
|
||||
"TYPE_NAME", "INT64",
|
||||
"NULLABLE", DatabaseMetaData.columnNoNulls
|
||||
)));
|
||||
SpannerAgent agent = connectedAgent(GOOGLE_SQL_PRODUCT, null, rows);
|
||||
|
||||
String ddl = agent.getTableDdl("projects/test-project/instances/test-instance/databases/gsqldb", "singers");
|
||||
|
||||
// The resource path must not become a DDL qualifier, and the columns must still resolve.
|
||||
assertFalse(ddl.contains("projects/"));
|
||||
assertTrue(ddl.contains("singers"));
|
||||
assertTrue(ddl.contains("singer_id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void quotesGeneratedDdlWithTheDialectQuote() {
|
||||
Map<String, List<Map<String, Object>>> rows = new LinkedHashMap<>();
|
||||
rows.put("getColumns", Collections.singletonList(row(
|
||||
"COLUMN_NAME", "singer_id",
|
||||
"TYPE_NAME", "INT64",
|
||||
"NULLABLE", DatabaseMetaData.columnNoNulls
|
||||
)));
|
||||
|
||||
// The shared builder hardcodes ANSI double quotes, which GoogleSQL reads as a string
|
||||
// literal rather than an identifier.
|
||||
String googleSql = connectedAgent(GOOGLE_SQL_PRODUCT, null, rows).getTableDdl("", "singers");
|
||||
assertTrue(googleSql.contains("`singers`"), googleSql);
|
||||
assertFalse(googleSql.contains("\"singers\""), googleSql);
|
||||
|
||||
String postgres = connectedAgent(POSTGRES_PRODUCT, "public", rows).getTableDdl("public", "singers");
|
||||
assertTrue(postgres.contains("\"singers\""), postgres);
|
||||
assertFalse(postgres.contains("`singers`"), postgres);
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsStoringIndexColumnsThatHaveNoOrdinalPosition() {
|
||||
Map<String, List<Map<String, Object>>> rows = new LinkedHashMap<>();
|
||||
rows.put("getIndexInfo", Arrays.asList(
|
||||
indexRow("PRIMARY_KEY", "singer_id", 1, false),
|
||||
indexRow("PRIMARY_KEY", "album_id", 2, false),
|
||||
// STORING (GoogleSQL) / INCLUDE (PostgreSQL) column: ORDINAL_POSITION is NULL and the
|
||||
// driver returns it before the real key column.
|
||||
indexRow("idx_albums_title", "release_ts", null, true),
|
||||
indexRow("idx_albums_title", "title", 1, true)
|
||||
));
|
||||
SpannerAgent agent = connectedAgent(GOOGLE_SQL_PRODUCT, null, rows);
|
||||
|
||||
List<IndexInfo> indexes = agent.listIndexes("", "albums");
|
||||
|
||||
assertEquals(2, indexes.size());
|
||||
IndexInfo primaryKey = indexes.get(0);
|
||||
assertEquals("PRIMARY_KEY", primaryKey.getName());
|
||||
assertEquals(Arrays.asList("singer_id", "album_id"), primaryKey.getColumns());
|
||||
assertTrue(primaryKey.getIs_unique());
|
||||
// Spanner names the primary key index PRIMARY_KEY, so a "PRIMARY" comparison never matches.
|
||||
assertTrue(primaryKey.getIs_primary());
|
||||
assertNull(primaryKey.getIncluded_columns());
|
||||
|
||||
IndexInfo secondary = indexes.get(1);
|
||||
assertEquals("idx_albums_title", secondary.getName());
|
||||
assertEquals(Collections.singletonList("title"), secondary.getColumns());
|
||||
assertEquals(Collections.singletonList("release_ts"), secondary.getIncluded_columns());
|
||||
assertFalse(secondary.getIs_unique());
|
||||
assertFalse(secondary.getIs_primary());
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsUnnamedInterleaveRelationshipsReportedAsForeignKeys() {
|
||||
Map<String, List<Map<String, Object>>> rows = new LinkedHashMap<>();
|
||||
rows.put("getImportedKeys", Arrays.asList(
|
||||
row(
|
||||
"FK_NAME", null,
|
||||
"FKCOLUMN_NAME", "singer_id",
|
||||
"PKTABLE_NAME", "singers",
|
||||
"PKCOLUMN_NAME", "singer_id"
|
||||
),
|
||||
row(
|
||||
"FK_NAME", "fk_concerts_singer",
|
||||
"FKCOLUMN_NAME", "singer_id",
|
||||
"PKTABLE_NAME", "singers",
|
||||
"PKCOLUMN_NAME", "singer_id"
|
||||
)
|
||||
));
|
||||
SpannerAgent agent = connectedAgent(GOOGLE_SQL_PRODUCT, null, rows);
|
||||
|
||||
List<ForeignKeyInfo> foreignKeys = agent.listForeignKeys("", "albums");
|
||||
|
||||
assertEquals(1, foreignKeys.size());
|
||||
assertEquals("fk_concerts_singer", foreignKeys.get(0).getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsNativeSpannerTypeNamesOnColumns() {
|
||||
Map<String, List<Map<String, Object>>> rows = new LinkedHashMap<>();
|
||||
rows.put("getPrimaryKeys", Collections.singletonList(row("COLUMN_NAME", "singer_id")));
|
||||
rows.put("getColumns", Arrays.asList(
|
||||
row(
|
||||
"COLUMN_NAME", "singer_id",
|
||||
"TYPE_NAME", "INT64",
|
||||
"NULLABLE", DatabaseMetaData.columnNoNulls
|
||||
),
|
||||
row(
|
||||
"COLUMN_NAME", "tags",
|
||||
"TYPE_NAME", "ARRAY<STRING(MAX)>",
|
||||
"NULLABLE", DatabaseMetaData.columnNullable
|
||||
)
|
||||
));
|
||||
SpannerAgent agent = connectedAgent(GOOGLE_SQL_PRODUCT, null, rows);
|
||||
|
||||
List<ColumnInfo> columns = agent.getColumns("", "singers");
|
||||
|
||||
assertEquals(Arrays.asList("singer_id", "tags"), Arrays.asList(
|
||||
columns.get(0).getName(),
|
||||
columns.get(1).getName()
|
||||
));
|
||||
assertEquals("INT64", columns.get(0).getData_type());
|
||||
assertTrue(columns.get(0).getIs_primary_key());
|
||||
assertFalse(columns.get(0).getIs_nullable());
|
||||
assertEquals("ARRAY<STRING(MAX)>", columns.get(1).getData_type());
|
||||
assertFalse(columns.get(1).getIs_primary_key());
|
||||
assertTrue(columns.get(1).getIs_nullable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsTheConfiguredDatabaseOnlyOnce() {
|
||||
SpannerAgent agent = connectedAgent(POSTGRES_PRODUCT, "public", new LinkedHashMap<>());
|
||||
String resourcePath = "projects/test-project/instances/test-instance/databases/pgdb";
|
||||
setConfiguredDatabase(agent, resourcePath);
|
||||
|
||||
// The shared adapter reports [pgdb, projects/.../databases/pgdb] for the PostgreSQL dialect.
|
||||
// The fake connection cannot unwrap to CloudSpannerJdbcConnection, so this also pins the
|
||||
// fallback taken whenever the Admin API is unavailable — a caller without
|
||||
// spanner.databases.list still sees the database it connected to.
|
||||
//
|
||||
// The invariant both paths share: a reported name is the *full resource path*, because it
|
||||
// round-trips as ConnectParams.database and only the path builds a resolvable JDBC URL. A
|
||||
// bare database id would make every click on a sibling database hang until the connect
|
||||
// timeout. The Admin API branch cannot be reached through this proxy, so that half is
|
||||
// verified against the emulator by reconnecting with each name it reports.
|
||||
assertEquals(Collections.singletonList(resourcePath), databaseNames(agent.listDatabases()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void translatesDataTypeNamesForThePostgresDialect() {
|
||||
Map<String, List<Map<String, Object>>> rows = new LinkedHashMap<>();
|
||||
rows.put("getTypeInfo", Arrays.asList(
|
||||
row("TYPE_NAME", "INT64"),
|
||||
row("TYPE_NAME", "STRING"),
|
||||
row("TYPE_NAME", "FLOAT64"),
|
||||
row("TYPE_NAME", "JSON")
|
||||
));
|
||||
Map<String, List<Map<String, Object>>> postgresRows = new LinkedHashMap<>();
|
||||
postgresRows.put("getTypeInfo", Arrays.asList(
|
||||
row("TYPE_NAME", "INT64"),
|
||||
row("TYPE_NAME", "STRING"),
|
||||
row("TYPE_NAME", "FLOAT64"),
|
||||
// The driver answers JSONB here rather than JSON, but still in upper case.
|
||||
row("TYPE_NAME", "JSONB")
|
||||
));
|
||||
|
||||
// The driver answers getTypeInfo() with GoogleSQL spellings for both dialects, so a
|
||||
// PostgreSQL-dialect database would otherwise be offered INT64/STRING in field mapping.
|
||||
assertEquals(
|
||||
Arrays.asList("INT64", "STRING", "FLOAT64", "JSON"),
|
||||
connectedAgent(GOOGLE_SQL_PRODUCT, null, rows).listDataTypes()
|
||||
);
|
||||
assertEquals(
|
||||
Arrays.asList("bigint", "character varying", "double precision", "jsonb"),
|
||||
connectedAgent(POSTGRES_PRODUCT, "public", postgresRows).listDataTypes()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void omitsTheEndpointWhenNoHostIsConfigured() {
|
||||
ConnectParams params = new ConnectParams();
|
||||
params.setDatabase("projects/p/instances/i/databases/d");
|
||||
|
||||
assertEquals("jdbc:cloudspanner:/projects/p/instances/i/databases/d", SpannerAgent.spannerUrl(params));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildsAnEndpointUrlForTheEmulator() {
|
||||
ConnectParams params = new ConnectParams();
|
||||
params.setHost("localhost");
|
||||
params.setPort(9010);
|
||||
params.setDatabase("projects/p/instances/i/databases/d");
|
||||
params.setUrl_params("usePlainText=true;autoConfigEmulator=true");
|
||||
|
||||
assertEquals(
|
||||
"jdbc:cloudspanner://localhost:9010/projects/p/instances/i/databases/d"
|
||||
+ "?usePlainText=true;autoConfigEmulator=true",
|
||||
SpannerAgent.spannerUrl(params)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultsALoopbackEndpointToPlaintextBecauseTheEmulatorRejectsTls() {
|
||||
ConnectParams params = new ConnectParams();
|
||||
params.setHost("127.0.0.1");
|
||||
params.setPort(9010);
|
||||
params.setDatabase("projects/p/instances/i/databases/d");
|
||||
|
||||
// Without this the driver treats the emulator as a production endpoint, retries a TLS
|
||||
// handshake against a plaintext port for the whole connect timeout, and fails with
|
||||
// `UNAVAILABLE: io exception`.
|
||||
assertEquals(
|
||||
"jdbc:cloudspanner://127.0.0.1:9010/projects/p/instances/i/databases/d?usePlainText=true",
|
||||
SpannerAgent.spannerUrl(params)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void appendsPlaintextAfterTheConfiguredParamsForALoopbackEndpoint() {
|
||||
ConnectParams params = new ConnectParams();
|
||||
params.setHost("localhost");
|
||||
params.setPort(9010);
|
||||
params.setDatabase("projects/p/instances/i/databases/d");
|
||||
params.setUrl_params("autocommit=true");
|
||||
|
||||
assertEquals(
|
||||
"jdbc:cloudspanner://localhost:9010/projects/p/instances/i/databases/d"
|
||||
+ "?autocommit=true;usePlainText=true",
|
||||
SpannerAgent.spannerUrl(params)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsAnExplicitPlaintextChoiceIncludingOptingOut() {
|
||||
ConnectParams optOut = new ConnectParams();
|
||||
optOut.setHost("localhost");
|
||||
optOut.setPort(9010);
|
||||
optOut.setDatabase("projects/p/instances/i/databases/d");
|
||||
optOut.setUrl_params("usePlainText=false");
|
||||
|
||||
assertEquals(
|
||||
"jdbc:cloudspanner://localhost:9010/projects/p/instances/i/databases/d?usePlainText=false",
|
||||
SpannerAgent.spannerUrl(optOut)
|
||||
);
|
||||
|
||||
// autoConfigEmulator already implies plaintext, and it additionally creates a missing
|
||||
// instance and database, so it must not be silently paired with usePlainText.
|
||||
ConnectParams autoConfig = new ConnectParams();
|
||||
autoConfig.setHost("localhost");
|
||||
autoConfig.setPort(9010);
|
||||
autoConfig.setDatabase("projects/p/instances/i/databases/d");
|
||||
autoConfig.setUrl_params("autoConfigEmulator=true");
|
||||
|
||||
assertEquals(
|
||||
"jdbc:cloudspanner://localhost:9010/projects/p/instances/i/databases/d?autoConfigEmulator=true",
|
||||
SpannerAgent.spannerUrl(autoConfig)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void leavesRemoteEndpointsOnTls() {
|
||||
// A non-loopback host may well be a private or proxied Spanner endpoint, so plaintext stays
|
||||
// opt-in there. The host-less form used for the real service is covered separately.
|
||||
ConnectParams params = new ConnectParams();
|
||||
params.setHost("spanner.internal.example.com");
|
||||
params.setPort(443);
|
||||
params.setDatabase("projects/p/instances/i/databases/d");
|
||||
|
||||
assertEquals(
|
||||
"jdbc:cloudspanner://spanner.internal.example.com:443/projects/p/instances/i/databases/d",
|
||||
SpannerAgent.spannerUrl(params)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesTheDefaultPortWhenNoneIsConfigured() {
|
||||
ConnectParams params = new ConnectParams();
|
||||
params.setHost("spanner.googleapis.com");
|
||||
params.setDatabase("projects/p/instances/i/databases/d");
|
||||
|
||||
assertEquals(
|
||||
"jdbc:cloudspanner://spanner.googleapis.com:443/projects/p/instances/i/databases/d",
|
||||
SpannerAgent.spannerUrl(params)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsSchemaSwitchingBecauseSpannerHasNoSchemaStatement() {
|
||||
assertEquals("", new SpannerAgent().setSchemaSQL("public"));
|
||||
}
|
||||
|
||||
private static List<String> names(List<TableInfo> tables) {
|
||||
List<String> result = new ArrayList<>();
|
||||
for (TableInfo table : tables) {
|
||||
result.add(table.getName());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<String> databaseNames(List<DatabaseInfo> databases) {
|
||||
List<String> result = new ArrayList<>();
|
||||
for (DatabaseInfo database : databases) {
|
||||
result.add(database.getName());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void setConfiguredDatabase(SpannerAgent agent, String database) {
|
||||
for (Class<?> type = agent.getClass(); type != null; type = type.getSuperclass()) {
|
||||
try {
|
||||
Field field = type.getDeclaredField("configuredDatabase");
|
||||
field.setAccessible(true);
|
||||
field.set(agent, database);
|
||||
} catch (NoSuchFieldException ignored) {
|
||||
continue;
|
||||
} catch (IllegalAccessException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static SpannerAgent connectedAgent(
|
||||
String product,
|
||||
String schema,
|
||||
Map<String, List<Map<String, Object>>> rowsByCall
|
||||
) {
|
||||
return connectedAgent(product, schema, rowsByCall, new ArrayList<>());
|
||||
}
|
||||
|
||||
private static SpannerAgent connectedAgent(
|
||||
String product,
|
||||
String schema,
|
||||
Map<String, List<Map<String, Object>>> rowsByCall,
|
||||
List<String> calls
|
||||
) {
|
||||
SpannerAgent agent = new SpannerAgent();
|
||||
Connection conn = fakeConnection(product, schema, rowsByCall, calls);
|
||||
agent.detectDialect(conn);
|
||||
TestSupport.setPrivateConnection(agent, conn);
|
||||
return agent;
|
||||
}
|
||||
|
||||
private static Connection fakeConnection(
|
||||
String product,
|
||||
String schema,
|
||||
Map<String, List<Map<String, Object>>> rowsByCall
|
||||
) {
|
||||
return fakeConnection(product, schema, rowsByCall, new ArrayList<>());
|
||||
}
|
||||
|
||||
private static Connection fakeConnection(
|
||||
String product,
|
||||
String schema,
|
||||
Map<String, List<Map<String, Object>>> rowsByCall,
|
||||
List<String> calls
|
||||
) {
|
||||
DatabaseMetaData metaData = fakeMetaData(product, rowsByCall, calls);
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("getMetaData".equals(name)) {
|
||||
return metaData;
|
||||
}
|
||||
if ("getSchema".equals(name)) {
|
||||
return schema;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static DatabaseMetaData fakeMetaData(
|
||||
String product,
|
||||
Map<String, List<Map<String, Object>>> rowsByCall,
|
||||
List<String> calls
|
||||
) {
|
||||
return proxy(DatabaseMetaData.class, (method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("getDatabaseProductName".equals(name)) {
|
||||
return product;
|
||||
}
|
||||
if ("getTables".equals(name)) {
|
||||
calls.add("getTables(catalog=" + args[0] + ", schema=[" + args[1] + "], pattern=" + args[2]
|
||||
+ ", types=" + Arrays.toString((String[]) args[3]) + ")");
|
||||
return fakeResultSet(rowsByCall.get("getTables"));
|
||||
}
|
||||
if ("getColumns".equals(name) || "getPrimaryKeys".equals(name)
|
||||
|| "getIndexInfo".equals(name) || "getImportedKeys".equals(name)) {
|
||||
calls.add(name + "(catalog=" + args[0] + ", schema=[" + args[1] + "], table=" + args[2] + ")");
|
||||
return fakeResultSet(rowsByCall.get(name));
|
||||
}
|
||||
if ("getTypeInfo".equals(name)) {
|
||||
// Takes no arguments, so it is not recorded alongside the schema-scoped calls.
|
||||
return fakeResultSet(rowsByCall.get("getTypeInfo"));
|
||||
}
|
||||
if ("getSearchStringEscape".equals(name)) {
|
||||
return "\\";
|
||||
}
|
||||
if ("getIdentifierQuoteString".equals(name)) {
|
||||
// The driver hardcodes a backtick for both dialects; the agent must not trust it.
|
||||
return "`";
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static ResultSet fakeResultSet(List<Map<String, Object>> rows) {
|
||||
List<Map<String, Object>> values = rows == null ? Collections.emptyList() : rows;
|
||||
int[] index = {-1};
|
||||
return proxy(ResultSet.class, (method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("next".equals(name)) {
|
||||
index[0] += 1;
|
||||
return index[0] < values.size();
|
||||
}
|
||||
if ("getString".equals(name)) {
|
||||
Object value = values.get(index[0]).get((String) args[0]);
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
if ("getObject".equals(name)) {
|
||||
return values.get(index[0]).get((String) args[0]);
|
||||
}
|
||||
if ("getInt".equals(name)) {
|
||||
Object value = values.get(index[0]).get((String) args[0]);
|
||||
return value instanceof Number ? ((Number) value).intValue() : 0;
|
||||
}
|
||||
if ("getBoolean".equals(name)) {
|
||||
Object value = values.get(index[0]).get((String) args[0]);
|
||||
return Boolean.TRUE.equals(value);
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static Map<String, Object> row(Object... keyValues) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
for (int i = 0; i < keyValues.length; i += 2) {
|
||||
result.put((String) keyValues[i], keyValues[i + 1]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Map<String, Object> indexRow(String indexName, String column, Integer ordinal, boolean nonUnique) {
|
||||
return row(
|
||||
"INDEX_NAME", indexName,
|
||||
"COLUMN_NAME", column,
|
||||
"ORDINAL_POSITION", ordinal,
|
||||
"NON_UNIQUE", nonUnique
|
||||
);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static <T> T proxy(Class<T> type, MethodHandler handler) {
|
||||
InvocationHandler invocationHandler = new InvocationHandler() {
|
||||
@Override
|
||||
public Object invoke(Object instance, Method method, Object[] args) throws Throwable {
|
||||
return handler.handle(method, args == null ? new Object[0] : args);
|
||||
}
|
||||
};
|
||||
return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, invocationHandler);
|
||||
}
|
||||
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if (Boolean.TYPE.equals(type)) {
|
||||
return false;
|
||||
}
|
||||
if (Integer.TYPE.equals(type)) {
|
||||
return 0;
|
||||
}
|
||||
if (Short.TYPE.equals(type)) {
|
||||
return (short) 0;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private interface MethodHandler {
|
||||
Object handle(Method method, Object[] args) throws Throwable;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ neo4j-go shared-fallback native-go Uses Neo4j schema procedures with stable loca
|
||||
oceanbase-oracle native-pushdown java-sql Uses Oracle-compatible metadata SQL with type, filter, stable order, and ROWNUM paging.
|
||||
oracle-go native-pushdown native-go Uses Oracle-compatible metadata SQL with type, filter, stable order, and ROWNUM paging, with legacy profile guard.
|
||||
snowflake native-pushdown java-sql Uses INFORMATION_SCHEMA with type, filter, stable order, and literal LIMIT/OFFSET.
|
||||
spanner intentional-fallback java-jdbc-metadata Uses the dialect-aware Spanner JDBC metadata with the schema passed through verbatim because GoogleSQL names the user schema with an empty string; JDBC exposes no portable server-side paging API, common constraints filter locally.
|
||||
spark intentional-fallback java-show-metadata Uses SHOW TABLES/SHOW DATABASES; stable server-side fuzzy paging is not portable, common constraints filter locally.
|
||||
sundb native-pushdown java-sql Uses information_schema with type, filter, stable order, and LIMIT/OFFSET.
|
||||
tdengine shared-fallback native-rust Uses SHOW STABLES/TABLES with information_schema parent enrichment, then applies stable filtering and paging in the native agent.
|
||||
|
||||
|
@@ -4,7 +4,7 @@ def infrastructureModules = ['common', 'test-support']
|
||||
def driverModules = [
|
||||
'access', 'dameng', 'goldendb', 'databend', 'databricks', 'saphana',
|
||||
'teradata', 'vertica', 'firebird', 'exasol', 'oceanbase-oracle', 'gbase8a', 'gbase8s',
|
||||
'bigquery', 'kylin', 'ignite', 'ignite3', 'sundb', 'h2', 'h2-legacy', 'snowflake', 'trino', 'spark',
|
||||
'bigquery', 'kylin', 'ignite', 'ignite3', 'spanner', 'sundb', 'h2', 'h2-legacy', 'snowflake', 'trino', 'spark',
|
||||
'db2', 'informix', 'mongodb', 'highgo', 'uxdb', 'yashandb', 'oscar',
|
||||
'iris', 'etcd', 'kafka', 'sqlserver-legacy'
|
||||
]
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"oceanbase-oracle": "0.1.44",
|
||||
"oracle": "0.1.52",
|
||||
"snowflake": "0.1.47",
|
||||
"spanner": "0.1.0",
|
||||
"saphana": "0.1.42",
|
||||
"sundb": "0.1.47",
|
||||
"teradata": "0.1.42",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<!-- Google Cloud Spanner product mark. Path data from Simple Icons (slug: googlecloudspanner), which
|
||||
traces the mark to Google's official icon library at https://cloud.google.com/icons. Google does not
|
||||
publish a redistribution license for its product icons; used here descriptively, to identify Spanner
|
||||
as a supported data source, consistent with the other vendor marks in this directory (see
|
||||
bigquery.svg). Geometry and color (#4285F4) are unchanged. -->
|
||||
<svg fill="#4285F4" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google Cloud Spanner</title><path d="M12 9.06 7.944 6.864V2.388L10.38.924v3.66l1.62.744 1.62-.744V.924l2.436 1.464v4.476L12 9.06zm-8.124 4.752L0 16.056v2.988l3.228-1.86 1.404.912.096 1.632-3.24 1.872 2.616 1.476 3.828-2.268-.132-4.596-3.924-2.4zm9.732-.9V8.758l-1.37.742-.238.129-.238-.13-1.37-.741v4.154l-3.613 2.09 1.282.783.231.142.008.27.046 1.612L12 15.696l3.595 2.079.045-1.59.008-.27.231-.142 1.301-.795-3.572-2.066zm7.164 4.272L24 19.044v-2.988L20.064 13.8l-3.924 2.4-.132 4.596 3.888 2.244 2.616-1.44-3.24-1.836.096-1.668 1.404-.912z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -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}`);
|
||||
|
||||
@@ -77,6 +77,9 @@ export function useSidebarTableMutationRuntime(options: SidebarTableMutationRunt
|
||||
databaseType: currentDatabaseType(),
|
||||
schema: activeNode.value.schema,
|
||||
tableName: activeNode.value.label,
|
||||
// Cloud Spanner's two dialects quote differently, so admin SQL needs the quote the connected
|
||||
// agent reported rather than the static per-type mapping.
|
||||
identifierQuote: connectionStore.connectionIdentifierQuote?.(activeNode.value.connectionId),
|
||||
};
|
||||
if (optionsOverride?.cascade) result.cascade = true;
|
||||
return result;
|
||||
@@ -87,6 +90,7 @@ export function useSidebarTableMutationRuntime(options: SidebarTableMutationRunt
|
||||
databaseType: databaseTypeForNode(node),
|
||||
schema: node.schema,
|
||||
tableName: node.label,
|
||||
identifierQuote: connectionStore.connectionIdentifierQuote?.(node.connectionId),
|
||||
};
|
||||
if (optionsOverride?.cascade) result.cascade = true;
|
||||
return result;
|
||||
|
||||
@@ -516,6 +516,15 @@ export default {
|
||||
d1ApiToken: "API Token",
|
||||
d1TokenHint: "The token needs D1 Read permission; writes, DDL, and imports also require D1 Write.",
|
||||
d1FieldsRequired: "Cloudflare Account ID, D1 Database ID, and API Token are required.",
|
||||
spannerStructuredMode: "Resource IDs",
|
||||
spannerResourcePathMode: "Resource path",
|
||||
spannerProject: "Project ID",
|
||||
spannerInstance: "Instance ID",
|
||||
spannerDatabase: "Database ID",
|
||||
spannerResourcePathHint: "projects/my-project/instances/my-instance/databases/my-database",
|
||||
spannerHostHint: "Leave the host empty for Google Cloud. For the local emulator, set the host to localhost with port 9010; plaintext gRPC is enabled automatically for loopback hosts.",
|
||||
spannerCredentialsHint: "Authentication uses Application Default Credentials. To use a service account key file, add credentials=/path/key.json to URL Params.",
|
||||
spannerFieldsRequired: "Cloud Spanner project, instance, and database are required.",
|
||||
hiveAuthMode: "Auth",
|
||||
hiveAuthNone: "None",
|
||||
hivePrincipal: "Principal",
|
||||
|
||||
@@ -506,6 +506,15 @@ export default withEnglishFallback({
|
||||
d1ApiToken: "Token de API",
|
||||
d1TokenHint: "El token necesita al menos el permiso D1 Read; las escrituras, el DDL y las importaciones también requieren D1 Write.",
|
||||
d1FieldsRequired: "Se requieren el ID de cuenta de Cloudflare, el ID de la base de datos D1 y el token de API.",
|
||||
spannerStructuredMode: "IDs de recurso",
|
||||
spannerResourcePathMode: "Ruta del recurso",
|
||||
spannerProject: "ID de proyecto",
|
||||
spannerInstance: "ID de instancia",
|
||||
spannerDatabase: "ID de base de datos",
|
||||
spannerResourcePathHint: "projects/my-project/instances/my-instance/databases/my-database",
|
||||
spannerHostHint: "Deja el host vacío para Google Cloud. Para el emulador local basta con usar el host localhost y el puerto 9010: el gRPC en texto plano se activa automáticamente en hosts de bucle invertido.",
|
||||
spannerCredentialsHint: "La autenticación usa Application Default Credentials. Para usar un archivo de clave de cuenta de servicio, añade credentials=/path/key.json a los parámetros de URL.",
|
||||
spannerFieldsRequired: "Se requieren el proyecto, la instancia y la base de datos de Cloud Spanner.",
|
||||
gbaseServer: "GBASEDBTSERVER",
|
||||
gbaseServerHint: "Debe coincidir con el DBSERVERNAME del servidor o con DBSERVERALIASES.",
|
||||
gbaseServerMismatch: "Falló la conexión de GBase 8s: GBASEDBTSERVER no coincide con el DBSERVERNAME del servidor ni con sus DBSERVERALIASES. Introduce el nombre real del servidor.",
|
||||
|
||||
@@ -505,6 +505,15 @@ export default withEnglishFallback({
|
||||
d1ApiToken: "Token API",
|
||||
d1TokenHint: "Il token richiede almeno l'autorizzazione D1 Read; per scritture, DDL e importazioni è necessaria anche D1 Write.",
|
||||
d1FieldsRequired: "Sono obbligatori l'ID account Cloudflare, l'ID database D1 e il token API.",
|
||||
spannerStructuredMode: "ID risorsa",
|
||||
spannerResourcePathMode: "Percorso risorsa",
|
||||
spannerProject: "ID progetto",
|
||||
spannerInstance: "ID istanza",
|
||||
spannerDatabase: "ID database",
|
||||
spannerResourcePathHint: "projects/my-project/instances/my-instance/databases/my-database",
|
||||
spannerHostHint: "Lascia vuoto l'host per Google Cloud. Per l'emulatore locale è sufficiente impostare l'host localhost con la porta 9010: il gRPC in chiaro viene abilitato automaticamente sugli host di loopback.",
|
||||
spannerCredentialsHint: "L'autenticazione usa Application Default Credentials. Per usare un file di chiave del service account, aggiungi credentials=/path/key.json ai parametri URL.",
|
||||
spannerFieldsRequired: "Progetto, istanza e database di Cloud Spanner sono obbligatori.",
|
||||
gbaseServer: "GBASEDBTSERVER",
|
||||
gbaseServerHint: "Deve corrispondere al DBSERVERNAME del server o a DBSERVERALIASES.",
|
||||
gbaseServerMismatch: "Connessione GBase 8s non riuscita: GBASEDBTSERVER non corrisponde al DBSERVERNAME del server o a uno dei DBSERVERALIASES. Inserisci il nome server effettivo.",
|
||||
|
||||
@@ -505,6 +505,15 @@ export default withEnglishFallback({
|
||||
d1ApiToken: "APIトークン",
|
||||
d1TokenHint: "トークンには少なくともD1 Read権限が必要です。書き込み、DDL、インポートにはD1 Write権限も必要です。",
|
||||
d1FieldsRequired: "CloudflareアカウントID、D1データベースID、APIトークンは必須です。",
|
||||
spannerStructuredMode: "リソース ID",
|
||||
spannerResourcePathMode: "リソースパス",
|
||||
spannerProject: "プロジェクト ID",
|
||||
spannerInstance: "インスタンス ID",
|
||||
spannerDatabase: "データベース ID",
|
||||
spannerResourcePathHint: "projects/my-project/instances/my-instance/databases/my-database",
|
||||
spannerHostHint: "Google Cloud に接続する場合はホストを空にします。ローカルエミュレータではホストに localhost、ポートに 9010 を指定するだけでよく、ループバックホストでは平文 gRPC が自動的に有効になります。",
|
||||
spannerCredentialsHint: "認証には Application Default Credentials を使用します。サービスアカウントキーファイルを使う場合は、URL パラメータに credentials=/path/key.json を追加してください。",
|
||||
spannerFieldsRequired: "Cloud Spanner のプロジェクト、インスタンス、データベースは必須です。",
|
||||
gbaseServer: "GBASEDBTSERVER",
|
||||
gbaseServerHint: "サーバー側の DBSERVERNAME または DBSERVERALIASES と一致させる必要があります。",
|
||||
gbaseServerMismatch: "GBase 8s の接続に失敗しました。GBASEDBTSERVER がサーバー側の DBSERVERNAME または DBSERVERALIASES と一致していません。接続設定に実際のサーバー名を入力してください。",
|
||||
|
||||
@@ -510,6 +510,15 @@ export default withEnglishFallback({
|
||||
d1ApiToken: "API 토큰",
|
||||
d1TokenHint: "토큰에는 D1 Read 권한이 필요하며, 쓰기, DDL 및 가져오기에는 D1 Write도 필요합니다.",
|
||||
d1FieldsRequired: "Cloudflare 계정 ID, D1 데이터베이스 ID 및 API 토큰은 필수입니다.",
|
||||
spannerStructuredMode: "리소스 ID",
|
||||
spannerResourcePathMode: "리소스 경로",
|
||||
spannerProject: "프로젝트 ID",
|
||||
spannerInstance: "인스턴스 ID",
|
||||
spannerDatabase: "데이터베이스 ID",
|
||||
spannerResourcePathHint: "projects/my-project/instances/my-instance/databases/my-database",
|
||||
spannerHostHint: "Google Cloud에 연결할 때는 호스트를 비워 둡니다. 로컬 에뮬레이터는 호스트를 localhost, 포트를 9010으로만 지정하면 되며, 루프백 호스트에서는 일반 텍스트 gRPC가 자동으로 활성화됩니다.",
|
||||
spannerCredentialsHint: "인증은 Application Default Credentials를 사용합니다. 서비스 계정 키 파일을 사용하려면 URL 매개변수에 credentials=/path/key.json을 추가하세요.",
|
||||
spannerFieldsRequired: "Cloud Spanner 프로젝트, 인스턴스, 데이터베이스는 필수입니다.",
|
||||
hiveAuthMode: "인증",
|
||||
hiveAuthNone: "없음",
|
||||
hivePrincipal: "프린시펄",
|
||||
|
||||
@@ -506,6 +506,15 @@ export default withEnglishFallback({
|
||||
d1ApiToken: "Token da API",
|
||||
d1TokenHint: "O token precisa de pelo menos a permissão D1 Read; gravações, DDL e importações também exigem D1 Write.",
|
||||
d1FieldsRequired: "O ID da conta Cloudflare, o ID do banco de dados D1 e o token da API são obrigatórios.",
|
||||
spannerStructuredMode: "IDs de recurso",
|
||||
spannerResourcePathMode: "Caminho do recurso",
|
||||
spannerProject: "ID do projeto",
|
||||
spannerInstance: "ID da instância",
|
||||
spannerDatabase: "ID do banco de dados",
|
||||
spannerResourcePathHint: "projects/my-project/instances/my-instance/databases/my-database",
|
||||
spannerHostHint: "Deixe o host vazio para o Google Cloud. Para o emulador local basta usar o host localhost com a porta 9010: o gRPC em texto simples é ativado automaticamente em hosts de loopback.",
|
||||
spannerCredentialsHint: "A autenticação usa Application Default Credentials. Para usar um arquivo de chave de conta de serviço, adicione credentials=/path/key.json aos parâmetros de URL.",
|
||||
spannerFieldsRequired: "O projeto, a instância e o banco de dados do Cloud Spanner são obrigatórios.",
|
||||
gbaseServer: "GBASEDBTSERVER",
|
||||
gbaseServerHint: "Deve corresponder ao DBSERVERNAME do servidor ou a DBSERVERALIASES.",
|
||||
gbaseServerMismatch: "Falha na conexão de GBase 8s: GBASEDBTSERVER não corresponde ao DBSERVERNAME do servidor nem a um dos DBSERVERALIASES. Informe o nome real do servidor.",
|
||||
|
||||
@@ -441,6 +441,15 @@ export default withEnglishFallback({
|
||||
d1ApiToken: "API Token",
|
||||
d1TokenHint: "Token 至少需要 D1 Read 权限;执行写入、DDL 或导入时还需要 D1 Write 权限。",
|
||||
d1FieldsRequired: "必须填写 Cloudflare Account ID、D1 Database ID 和 API Token。",
|
||||
spannerStructuredMode: "资源 ID",
|
||||
spannerResourcePathMode: "资源路径",
|
||||
spannerProject: "Project ID",
|
||||
spannerInstance: "Instance ID",
|
||||
spannerDatabase: "Database ID",
|
||||
spannerResourcePathHint: "projects/my-project/instances/my-instance/databases/my-database",
|
||||
spannerHostHint: "连接 Google Cloud 时主机留空。使用本地 emulator 时,主机填 localhost、端口填 9010 即可,回环地址会自动启用明文 gRPC。",
|
||||
spannerCredentialsHint: "认证使用 Application Default Credentials。若要使用服务账号密钥文件,请在 URL 参数中加入 credentials=/path/key.json。",
|
||||
spannerFieldsRequired: "必须填写 Cloud Spanner 的 project、instance 和 database。",
|
||||
hiveAuthMode: "认证",
|
||||
hiveAuthNone: "无",
|
||||
hivePrincipal: "Principal",
|
||||
|
||||
@@ -506,6 +506,15 @@ export default withEnglishFallback({
|
||||
d1ApiToken: "API Token",
|
||||
d1TokenHint: "Token 至少需要 D1 Read 權限;執行寫入、DDL 或匯入時還需要 D1 Write 權限。",
|
||||
d1FieldsRequired: "必須填寫 Cloudflare Account ID、D1 Database ID 和 API Token。",
|
||||
spannerStructuredMode: "資源 ID",
|
||||
spannerResourcePathMode: "資源路徑",
|
||||
spannerProject: "Project ID",
|
||||
spannerInstance: "Instance ID",
|
||||
spannerDatabase: "Database ID",
|
||||
spannerResourcePathHint: "projects/my-project/instances/my-instance/databases/my-database",
|
||||
spannerHostHint: "連線 Google Cloud 時主機留空。使用本機 emulator 時,主機填 localhost、連接埠填 9010 即可,回環位址會自動啟用明文 gRPC。",
|
||||
spannerCredentialsHint: "驗證使用 Application Default Credentials。若要使用服務帳號金鑰檔案,請在 URL 參數中加入 credentials=/path/key.json。",
|
||||
spannerFieldsRequired: "必須填寫 Cloud Spanner 的 project、instance 和 database。",
|
||||
gbaseServer: "GBASEDBTSERVER",
|
||||
gbaseServerHint: "必須與服務端 DBSERVERNAME 或 DBSERVERALIASES 一致。",
|
||||
gbaseServerMismatch: "GBase 8s 連線失敗:GBASEDBTSERVER 與服務端 DBSERVERNAME 或 DBSERVERALIASES 不匹配。請在連線設定中填寫實際服務名。",
|
||||
|
||||
@@ -43,6 +43,10 @@ describe("connectionAttemptTimeout", () => {
|
||||
expect(connectionAttemptTimeoutMs({ db_type: "zookeeper", connect_timeout_secs: 5, transport_layers: [] })).toBe(AGENT_DRIVER_MIN_CONNECT_TIMEOUT_SECS * 1000 + CONNECTION_ATTEMPT_TIMEOUT_BUFFER_MS);
|
||||
});
|
||||
|
||||
it("uses the startup floor for Cloud Spanner agent connections", () => {
|
||||
expect(connectionAttemptTimeoutMs({ db_type: "spanner", connect_timeout_secs: 5, transport_layers: [] })).toBe(AGENT_DRIVER_MIN_CONNECT_TIMEOUT_SECS * 1000 + CONNECTION_ATTEMPT_TIMEOUT_BUFFER_MS);
|
||||
});
|
||||
|
||||
it("uses a 30s Access agent startup floor", () => {
|
||||
expect(connectionAttemptTimeoutMs({ db_type: "access", connect_timeout_secs: 5, transport_layers: [] })).toBe(ACCESS_AGENT_MIN_CONNECT_TIMEOUT_SECS * 1000 + CONNECTION_ATTEMPT_TIMEOUT_BUFFER_MS);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dialogSource = readFileSync(new URL("../../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8");
|
||||
const fieldsSource = readFileSync(new URL("../../../components/connection/SpannerConnectionFields.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("Cloud Spanner connection dialog", () => {
|
||||
it("registers the bundled agent profile in the SQL category", () => {
|
||||
expect(dialogSource).toContain('spanner: { type: "spanner", port: 443, user: "", label: "Cloud Spanner", icon: "spanner" }');
|
||||
expect(dialogSource).toContain('{ value: "spanner", label: "Cloud Spanner" }');
|
||||
expect(dialogSource).toContain('spanner: "spanner",');
|
||||
|
||||
// Cloud Spanner is an OLTP relational database, so it belongs to the SQL
|
||||
// category rather than the analytics one.
|
||||
const sqlCategory = dialogSource.match(/key: "sql",\s*\n\s*titleKey: "connection\.databaseCategorySql",\s*\n\s*optionValues: \[([\s\S]*?)\]/)?.[1] ?? "";
|
||||
const analyticsCategory = dialogSource.match(/key: "analytics",[\s\S]*?optionValues: \[([\s\S]*?)\]/)?.[1] ?? "";
|
||||
expect(sqlCategory).toContain('"spanner"');
|
||||
expect(analyticsCategory).not.toContain('"spanner"');
|
||||
});
|
||||
|
||||
it("does not expose external JDBC driver configuration because the driver is bundled", () => {
|
||||
const nativeAgentDriverSupport = dialogSource.match(/function supportsNativeAgentJdbcDriverConfigType\(dbType: DatabaseType\): boolean \{([\s\S]*?)\}/)?.[1] ?? "";
|
||||
const jdbcBackedDatabaseTypes = dialogSource.match(/const jdbcBackedDatabaseTypes = new Set<DatabaseType>\(\[([\s\S]*?)\]\);/)?.[1] ?? "";
|
||||
|
||||
expect(nativeAgentDriverSupport).toContain('dbType === "bigquery"');
|
||||
expect(nativeAgentDriverSupport).not.toContain('dbType === "spanner"');
|
||||
expect(jdbcBackedDatabaseTypes).not.toContain('"spanner"');
|
||||
});
|
||||
|
||||
it("collects the resource path through dedicated fields instead of host and credentials", () => {
|
||||
expect(dialogSource).toContain('import SpannerConnectionFields from "@/components/connection/SpannerConnectionFields.vue";');
|
||||
expect(dialogSource).toContain('<SpannerConnectionFields v-model:database="form.database" @change="resetTestState" />');
|
||||
// User, password, and the generic database row are hidden for Spanner.
|
||||
// These pin the whole v-if because the rows are shared, so adding another
|
||||
// database that opts out of one of them means updating the string here too.
|
||||
expect(dialogSource).toContain("<div v-if=\"form.db_type !== 'meilisearch' && form.db_type !== 'spanner'\" class=\"grid grid-cols-4 items-center gap-4\">");
|
||||
expect(dialogSource).toContain('<div v-if="form.db_type !== \'spanner\'" class="grid grid-cols-4 items-center gap-4">');
|
||||
expect(dialogSource).toContain("<div v-if=\"form.db_type !== 'hbase' && form.db_type !== 'meilisearch' && form.db_type !== 'spanner'\" class=\"grid grid-cols-4 items-center gap-4\">");
|
||||
// Switching to the Spanner profile clears host and credentials.
|
||||
expect(dialogSource).toContain('if (profile.type === "spanner") {');
|
||||
// Save/Test gating and submit-time normalization.
|
||||
expect(dialogSource).toContain("if (isSpannerConnection(form.value)) return hasSpannerResourcePath(form.value);");
|
||||
expect(dialogSource).toContain("normalizeSpannerConnection(config);");
|
||||
expect(dialogSource).toContain('throw new Error(t("connection.spannerFieldsRequired"));');
|
||||
});
|
||||
|
||||
it("stays inside the generic branch so the agent install hint and URL params remain available", () => {
|
||||
const spannerBlockStart = dialogSource.indexOf("<!-- Cloud Spanner: project/instance/database resource path instead of user/password/database -->");
|
||||
const genericBranchStart = dialogSource.indexOf("<!-- MySQL / PostgreSQL: host, port, user, password, database -->");
|
||||
const urlParamsRow = dialogSource.indexOf('v-if="supportsGenericUrlParams"');
|
||||
const agentInstallHint = dialogSource.indexOf('v-if="shouldShowAgentDriverInstallHint"');
|
||||
|
||||
expect(genericBranchStart).toBeGreaterThan(0);
|
||||
expect(spannerBlockStart).toBeGreaterThan(genericBranchStart);
|
||||
expect(urlParamsRow).toBeGreaterThan(spannerBlockStart);
|
||||
expect(agentInstallHint).toBeGreaterThan(spannerBlockStart);
|
||||
expect(dialogSource).toContain("'credentials=/path/key.json;autocommit=true'");
|
||||
});
|
||||
|
||||
it("offers both the structured editor and the raw resource path editor", () => {
|
||||
expect(fieldsSource).toContain('const database = defineModel<string | undefined>("database");');
|
||||
expect(fieldsSource).toContain("data-spanner-resource-fields");
|
||||
expect(fieldsSource).toContain('t("connection.spannerProject")');
|
||||
expect(fieldsSource).toContain('t("connection.spannerInstance")');
|
||||
expect(fieldsSource).toContain('t("connection.spannerDatabase")');
|
||||
expect(fieldsSource).toContain('t("connection.spannerResourcePathMode")');
|
||||
expect(fieldsSource).toContain('emit("change")');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
encodeSpannerResourcePath,
|
||||
spannerSchemaDisplayName,
|
||||
formatSpannerResourcePath,
|
||||
hasSpannerResourcePath,
|
||||
isSpannerConnection,
|
||||
normalizeSpannerConnection,
|
||||
parseSpannerResourcePath,
|
||||
spannerDisplayDatabase,
|
||||
spannerResourceParts,
|
||||
withSpannerResourcePart,
|
||||
} from "@/lib/connection/spannerResourcePath";
|
||||
|
||||
const RESOURCE_PATH = "projects/p1/instances/i1/databases/db1";
|
||||
|
||||
describe("spannerResourcePath", () => {
|
||||
it("detects Cloud Spanner connections", () => {
|
||||
expect(isSpannerConnection({ db_type: "spanner" })).toBe(true);
|
||||
expect(isSpannerConnection({ db_type: "bigquery" })).toBe(false);
|
||||
});
|
||||
|
||||
it("parses a standard resource path", () => {
|
||||
expect(parseSpannerResourcePath(RESOURCE_PATH)).toEqual({ project: "p1", instance: "i1", database: "db1" });
|
||||
});
|
||||
|
||||
it("tolerates surrounding whitespace, leading and trailing slashes, and keyword casing", () => {
|
||||
expect(parseSpannerResourcePath(" /projects/p1/instances/i1/databases/db1/ ")).toEqual({ project: "p1", instance: "i1", database: "db1" });
|
||||
expect(parseSpannerResourcePath("Projects/p1/Instances/i1/Databases/db1")).toEqual({ project: "p1", instance: "i1", database: "db1" });
|
||||
});
|
||||
|
||||
it("rejects paths that are not exactly project/instance/database", () => {
|
||||
expect(parseSpannerResourcePath("projects/p1/databases/db1")).toBeUndefined();
|
||||
expect(parseSpannerResourcePath("projects/p1/instances/i1/databases/db1/backups/b1")).toBeUndefined();
|
||||
expect(parseSpannerResourcePath("projects/p1/instances//databases/db1")).toBeUndefined();
|
||||
expect(parseSpannerResourcePath(undefined)).toBeUndefined();
|
||||
expect(parseSpannerResourcePath("")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads partial paths so a half-filled form keeps the segments already typed", () => {
|
||||
expect(spannerResourceParts("projects/p1/instances//databases/")).toEqual({ project: "p1", instance: "", database: "" });
|
||||
expect(spannerResourceParts("jdbc:cloudspanner:/whatever")).toEqual({ project: "", instance: "", database: "" });
|
||||
});
|
||||
|
||||
it("formats the three segments into a resource path", () => {
|
||||
expect(formatSpannerResourcePath({ project: "p1", instance: "i1", database: "db1" })).toBe(RESOURCE_PATH);
|
||||
});
|
||||
|
||||
it("keeps partially filled paths instead of discarding typed segments", () => {
|
||||
expect(formatSpannerResourcePath({ project: "p1", instance: "", database: "db1" })).toBe("projects/p1/instances//databases/db1");
|
||||
expect(formatSpannerResourcePath({ project: "", instance: "", database: "" })).toBe("");
|
||||
});
|
||||
|
||||
it("keeps only the last segment when a whole path is pasted into one field", () => {
|
||||
expect(formatSpannerResourcePath({ project: "projects/p1", instance: "i1", database: "db1" })).toBe(RESOURCE_PATH);
|
||||
});
|
||||
|
||||
it("round-trips a canonical path", () => {
|
||||
expect(formatSpannerResourcePath(parseSpannerResourcePath(RESOURCE_PATH)!)).toBe(RESOURCE_PATH);
|
||||
});
|
||||
|
||||
it("replaces a single segment and adopts a pasted full path", () => {
|
||||
expect(withSpannerResourcePart(RESOURCE_PATH, "database", "db2")).toBe("projects/p1/instances/i1/databases/db2");
|
||||
expect(withSpannerResourcePart(undefined, "project", "p1")).toBe("projects/p1/instances//databases/");
|
||||
expect(withSpannerResourcePart("projects/px/instances/ix/databases/dx", "project", RESOURCE_PATH)).toBe(RESOURCE_PATH);
|
||||
});
|
||||
|
||||
it("requires all three segments before the connection target counts as complete", () => {
|
||||
expect(hasSpannerResourcePath({ database: RESOURCE_PATH })).toBe(true);
|
||||
expect(hasSpannerResourcePath({ database: undefined })).toBe(false);
|
||||
expect(hasSpannerResourcePath({ database: "projects/p1/instances//databases/db1" })).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes credentials and port while preserving host and url params", () => {
|
||||
const config = { host: " ", port: 0, username: "root", password: "secret", database: ` /${RESOURCE_PATH}/ `, url_params: "credentials=/path/key.json" };
|
||||
normalizeSpannerConnection(config);
|
||||
expect(config).toEqual({ host: "", port: 443, username: "", password: "", database: RESOURCE_PATH, url_params: "credentials=/path/key.json" });
|
||||
});
|
||||
|
||||
it("keeps an emulator host and port untouched", () => {
|
||||
const config = { host: "localhost", port: 9010, username: "", password: "", database: RESOURCE_PATH };
|
||||
normalizeSpannerConnection(config);
|
||||
expect(config.host).toBe("localhost");
|
||||
expect(config.port).toBe(9010);
|
||||
});
|
||||
|
||||
it("shows the database ID rather than the whole resource path", () => {
|
||||
expect(spannerDisplayDatabase(RESOURCE_PATH)).toBe("db1");
|
||||
expect(spannerDisplayDatabase("some-other-path")).toBe("some-other-path");
|
||||
expect(spannerDisplayDatabase(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("gives GoogleSQL's nameless default schema something to render", () => {
|
||||
// The empty string is the literal name of the GoogleSQL user schema, so the sidebar would
|
||||
// otherwise show a blank node beside any named schema. Display only: routing still uses "".
|
||||
expect(spannerSchemaDisplayName("")).toBe("(default)");
|
||||
expect(spannerSchemaDisplayName("public")).toBe("public");
|
||||
expect(spannerSchemaDisplayName("sales")).toBe("sales");
|
||||
});
|
||||
|
||||
it("keeps the resource path separators when encoding it for display", () => {
|
||||
// The connection tooltip used to run the whole value through
|
||||
// encodeURIComponent, rendering every separator as %2F and making the URL
|
||||
// unreadable. Separators are structure here, so only the segments escape.
|
||||
expect(encodeSpannerResourcePath(RESOURCE_PATH)).toBe(RESOURCE_PATH);
|
||||
expect(encodeSpannerResourcePath("projects/p 1/instances/i/databases/d")).toBe("projects/p%201/instances/i/databases/d");
|
||||
expect(encodeSpannerResourcePath("")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Admin SQL (drop / empty / truncate / clone / copy) is built in Rust from the database type alone,
|
||||
* which cannot describe a database whose quote character depends on the connection. Cloud Spanner is
|
||||
* the case that forces the connection-reported quote to be threaded through: GoogleSQL quotes with
|
||||
* backticks, its PostgreSQL dialect quotes with double quotes, and the dialect is fixed when the
|
||||
* database is created. Emitting the static answer produces admin SQL that is a syntax error for
|
||||
* every PostgreSQL-dialect Spanner database.
|
||||
*
|
||||
* These assertions pin the call sites rather than the generated SQL, because the plumbing is what
|
||||
* regressed: the backend already knew how to honour a reported quote (`data_grid_sql.rs` carries the
|
||||
* same field) and the builders simply were not given one.
|
||||
*/
|
||||
const mutationRuntimeSource = readFileSync(new URL("../../../composables/useSidebarTableMutationRuntime.ts", import.meta.url), "utf8");
|
||||
const treeRuntimeSource = readFileSync(new URL("../../../components/sidebar/SidebarTreeRuntimeHost.vue", import.meta.url), "utf8");
|
||||
const objectBrowserSource = readFileSync(new URL("../../../components/objects/ObjectBrowser.vue", import.meta.url), "utf8");
|
||||
const adminSqlSource = readFileSync(new URL("../../database/dbAdminSql.ts", import.meta.url), "utf8");
|
||||
|
||||
describe("admin SQL carries the connection identifier quote", () => {
|
||||
it("declares the field on every option type whose SQL qualifies an object name", () => {
|
||||
for (const type of ["DropObjectSqlOptions", "TableAdminSqlOptions", "DuplicateTableStructureSqlOptions", "CopyTableDataSqlOptions"]) {
|
||||
const body = adminSqlSource.match(new RegExp(`export interface ${type} \\{([\\s\\S]*?)\\n\\}`))?.[1] ?? "";
|
||||
expect(body, `${type} must accept identifierQuote`).toContain("identifierQuote?: string;");
|
||||
}
|
||||
});
|
||||
|
||||
it("threads the quote through drop, empty and truncate from the sidebar", () => {
|
||||
// Both factories: one reads the active node, the other an explicit node during multi-select.
|
||||
expect(mutationRuntimeSource).toMatch(/tableName: activeNode\.value\.label,[\s\S]*?identifierQuote: connectionStore\.connectionIdentifierQuote\?\.\(activeNode\.value\.connectionId\)/);
|
||||
expect(mutationRuntimeSource).toMatch(/tableName: node\.label,\s*identifierQuote: connectionStore\.connectionIdentifierQuote\?\.\(node\.connectionId\)/);
|
||||
});
|
||||
|
||||
it("threads the quote through dropping a view or routine", () => {
|
||||
expect(treeRuntimeSource).toMatch(/signature: node\.signature,[\s\S]*?identifierQuote: connectionStore\.connectionIdentifierQuote\?\.\(node\.connectionId\)/);
|
||||
expect(objectBrowserSource).toMatch(/buildDropObjectSql\(\{[\s\S]*?identifierQuote: connectionStore\.connectionIdentifierQuote\?\.\(props\.connection\.id\)/);
|
||||
});
|
||||
|
||||
it("threads the quote through clone and cross-database paste", () => {
|
||||
expect(treeRuntimeSource).toMatch(/buildDuplicateTableStructurePlan\(\{[\s\S]*?identifierQuote: connectionStore\.connectionIdentifierQuote\?\.\(node\.connectionId\)/);
|
||||
expect(treeRuntimeSource).toMatch(/buildDuplicateTableStructurePlan\(\{[\s\S]*?identifierQuote: connectionStore\.connectionIdentifierQuote\?\.\(entry\.connectionId\)/);
|
||||
expect(objectBrowserSource).toMatch(/buildSharedDuplicateTableStructurePlan\(\{[\s\S]*?identifierQuote: connectionStore\.connectionIdentifierQuote\?\.\(props\.connection\.id\)/);
|
||||
// The plan helper must forward it to the SQL builder rather than swallowing it.
|
||||
expect(adminSqlSource).toMatch(/buildDuplicateTableStructureSql\(\{[\s\S]*?identifierQuote: options\.identifierQuote/);
|
||||
});
|
||||
|
||||
it("threads the quote through copying table data", () => {
|
||||
expect(treeRuntimeSource).toMatch(/buildCopyTableDataSql\(\{[\s\S]*?identifierQuote: connectionStore\.connectionIdentifierQuote\?\.\(entry\.connectionId\)/);
|
||||
expect(objectBrowserSource).toMatch(/buildCopyTableDataSql\(\{[\s\S]*?identifierQuote: connectionStore\.connectionIdentifierQuote\?\.\(props\.connection\.id\)/);
|
||||
});
|
||||
});
|
||||
@@ -15,12 +15,34 @@ import {
|
||||
supportsTableVacuum,
|
||||
supportsTransaction,
|
||||
usesConnectionOnlyQueryTarget,
|
||||
usesTreeSchemaMode,
|
||||
schemaNodeHasLoadableName,
|
||||
} from "@/lib/database/databaseFeatureSupport";
|
||||
|
||||
describe("schema awareness", () => {
|
||||
it("keeps SQLite database aliases separate from schema-capable databases", () => {
|
||||
expect(isSchemaAware("sqlite")).toBe(false);
|
||||
});
|
||||
|
||||
it("puts Cloud Spanner in both schema sets because they gate different surfaces", () => {
|
||||
// Spanner 2024+ has named schemas and they are queryable, so both have to be true. The two sets
|
||||
// are not interchangeable: SCHEMA_AWARE_TYPES reaches the schema pickers in dialogs, while
|
||||
// TREE_SCHEMA_TYPES is what makes a database node load schemas instead of tables. With only the
|
||||
// former, `sales` showed up in the schema-diff dropdown but was unreachable in the object tree.
|
||||
expect(isSchemaAware("spanner")).toBe(true);
|
||||
expect(usesTreeSchemaMode("spanner")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats Cloud Spanner's blank schema as a loadable node name", () => {
|
||||
// The GoogleSQL default schema node carries "", so a truthiness check would render an
|
||||
// expandable node that never loads its tables. Other types keep the truthiness test, which is
|
||||
// what filters the undefined schema on nodes that have no schema level at all.
|
||||
expect(schemaNodeHasLoadableName("spanner", "")).toBe(true);
|
||||
expect(schemaNodeHasLoadableName("spanner", "sales")).toBe(true);
|
||||
expect(schemaNodeHasLoadableName("spanner", undefined)).toBe(false);
|
||||
expect(schemaNodeHasLoadableName("postgres", "")).toBe(false);
|
||||
expect(schemaNodeHasLoadableName("postgres", "public")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connection database browser", () => {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDatabaseTreeNodes } from "@/lib/database/databaseTree";
|
||||
import { spannerDisplayDatabase } from "@/lib/connection/spannerResourcePath";
|
||||
import type { DatabaseInfo } from "@/types/database";
|
||||
|
||||
const SPANNER_PATH = "projects/test-project/instances/test-instance/databases/gsqldb";
|
||||
|
||||
function databases(...names: string[]): DatabaseInfo[] {
|
||||
return names.map((name) => ({ name }) as DatabaseInfo);
|
||||
}
|
||||
|
||||
describe("buildDatabaseTreeNodes", () => {
|
||||
it("labels nodes with the reported name when no displayLabel is given", () => {
|
||||
const [node] = buildDatabaseTreeNodes("conn-1", databases("analytics"));
|
||||
expect(node.label).toBe("analytics");
|
||||
expect(node.database).toBe("analytics");
|
||||
expect(node.id).toBe("conn-1:analytics");
|
||||
});
|
||||
|
||||
it("shortens the Cloud Spanner label but keeps the resource path as the node identity", () => {
|
||||
const [node] = buildDatabaseTreeNodes("conn-1", databases(SPANNER_PATH), { displayLabel: spannerDisplayDatabase });
|
||||
|
||||
// Visible text is the database id only — a 60-char resource path would be
|
||||
// truncated to `projects/test-proj…` in a narrow sidebar.
|
||||
expect(node.label).toBe("gsqldb");
|
||||
|
||||
// Identity must stay the full path: it round-trips to the agent as
|
||||
// ConnectParams.database, and the short name would break URL building.
|
||||
expect(node.database).toBe(SPANNER_PATH);
|
||||
expect(node.id).toBe(`conn-1:${SPANNER_PATH}`);
|
||||
});
|
||||
|
||||
it("falls back to the reported name when displayLabel yields nothing", () => {
|
||||
const [node] = buildDatabaseTreeNodes("conn-1", databases("analytics"), { displayLabel: () => "" });
|
||||
expect(node.label).toBe("analytics");
|
||||
});
|
||||
|
||||
it("leaves every other database type untouched when a displayLabel is absent", () => {
|
||||
const nodes = buildDatabaseTreeNodes("conn-1", databases("beta", "alpha"));
|
||||
expect(nodes.map((node) => node.label)).toEqual(["alpha", "beta"]);
|
||||
});
|
||||
});
|
||||
@@ -425,6 +425,25 @@ describe("buildDuplicateTableStructurePlan", () => {
|
||||
expect(plan).toEqual({ sql: 'CREATE TABLE "copy" (LIKE "source" INCLUDING ALL);', sourceColumns: undefined, executeAsScript: false });
|
||||
});
|
||||
|
||||
it("forwards the connection identifier quote so dual-dialect clones stay executable", async () => {
|
||||
// Cloud Spanner's PostgreSQL dialect quotes with double quotes while GoogleSQL uses backticks,
|
||||
// and only the connected agent knows which. Dropping the quote here would make the backend fall
|
||||
// back to the static per-type mapping and emit a syntax error for one of the two dialects.
|
||||
apiMock.buildDuplicateTableStructureSql.mockResolvedValue('CREATE TABLE "copy" AS SELECT * FROM "source" WHERE false;');
|
||||
|
||||
await buildDuplicateTableStructurePlan({
|
||||
connectionId: "spanner-1",
|
||||
database: "projects/p/instances/i/databases/d",
|
||||
databaseType: "spanner",
|
||||
schema: "public",
|
||||
sourceName: "source",
|
||||
targetName: "copy",
|
||||
identifierQuote: '"',
|
||||
});
|
||||
|
||||
expect(apiMock.buildDuplicateTableStructureSql).toHaveBeenCalledWith(expect.objectContaining({ identifierQuote: '"' }));
|
||||
});
|
||||
|
||||
it("keeps Oracle cloning available when optional table comment loading fails", async () => {
|
||||
const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const columns = [{ name: "ID", data_type: "NUMBER", is_nullable: false, column_default: null, is_primary_key: true }];
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import { databaseObjectTreeNodeSchema } from "@/lib/database/databaseFeatureSupport";
|
||||
import {
|
||||
GAUSSDB_M_JDBC_DRIVER_CLASS,
|
||||
connectionDatabaseMetadataSchema,
|
||||
connectionObjectTreeNodeSchema,
|
||||
connectionObjectTreeQuerySchema,
|
||||
connectionQueryExecutionSchema,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
gaussdbIdentifierQuoteStyle,
|
||||
gaussdbTargetServerType,
|
||||
inferJdbcDialect,
|
||||
metadataSchemaForConnection,
|
||||
setGaussdbConnectionMode,
|
||||
setGaussdbCountQueryDop,
|
||||
setGaussdbIdentifierQuoteStyle,
|
||||
@@ -112,6 +115,10 @@ describe("jdbc dialect inference", () => {
|
||||
expect(connectionShouldLoadIdentifierQuote({ db_type: "gaussdb" })).toBe(true);
|
||||
expect(connectionShouldLoadIdentifierQuote({ db_type: "gbase", driver_profile: "gbase8s" })).toBe(true);
|
||||
expect(connectionShouldLoadIdentifierQuote({ db_type: "gbase", driver_profile: "gbase8a" })).toBe(false);
|
||||
// Cloud Spanner reports a backtick for GoogleSQL and a double quote for the
|
||||
// PostgreSQL dialect; both are only known from the connection.
|
||||
expect(connectionShouldLoadIdentifierQuote({ db_type: "spanner" })).toBe(true);
|
||||
expect(connectionShouldLoadIdentifierQuote({ db_type: "bigquery" })).toBe(false);
|
||||
expect(
|
||||
connectionShouldLoadIdentifierQuote({
|
||||
db_type: "jdbc",
|
||||
@@ -311,4 +318,47 @@ describe("object tree node schema", () => {
|
||||
expect(connectionObjectTreeQuerySchema({ db_type: "informix" }, "prulife", "xtdpcky")).toBe("xtdpcky");
|
||||
expect(connectionObjectTreeNodeSchema({ db_type: "informix" }, "prulife", "xtdpcky")).toBe("xtdpcky");
|
||||
});
|
||||
|
||||
it("never sends the Cloud Spanner resource path as a metadata schema", () => {
|
||||
const connection = { db_type: "spanner" as const };
|
||||
const resourcePath = "projects/p/instances/i/databases/db";
|
||||
|
||||
// GoogleSQL's default schema is the empty string, which must survive the
|
||||
// `schema || database` default: sending the resource path matches no objects.
|
||||
expect(connectionObjectTreeQuerySchema(connection, resourcePath, "")).toBe("");
|
||||
expect(connectionObjectTreeQuerySchema(connection, resourcePath)).toBe("");
|
||||
expect(metadataSchemaForConnection(connection, resourcePath, "")).toBe("");
|
||||
// PostgreSQL-dialect databases and named schemas pass through unchanged.
|
||||
expect(connectionObjectTreeQuerySchema(connection, resourcePath, "public")).toBe("public");
|
||||
expect(connectionObjectTreeQuerySchema(connection, resourcePath, "analytics")).toBe("analytics");
|
||||
expect(metadataSchemaForConnection(connection, resourcePath, "analytics")).toBe("analytics");
|
||||
// The tree node resolves to GoogleSQL's blank default schema rather than to the resource
|
||||
// path, so the path can never reach qualifiedTableName as a qualifier. It is the empty
|
||||
// string and not undefined because Spanner does have a schema level: "" is the literal
|
||||
// name of the GoogleSQL user schema.
|
||||
expect(connectionObjectTreeNodeSchema(connection, resourcePath)).toBe("");
|
||||
expect(connectionObjectTreeNodeSchema(connection, resourcePath, "")).toBe("");
|
||||
expect(connectionObjectTreeNodeSchema(connection, resourcePath, "sales")).toBe("sales");
|
||||
expect(connectionObjectTreeNodeSchema(connection, resourcePath, resourcePath)).toBe("");
|
||||
// Named schemas (Spanner 2024+) reach the tree unchanged now that Spanner is schema-aware.
|
||||
expect(connectionObjectTreeQuerySchema(connection, resourcePath, "sales")).toBe("sales");
|
||||
expect(databaseObjectTreeNodeSchema("spanner", resourcePath, "sales")).toBe("sales");
|
||||
expect(databaseObjectTreeNodeSchema("spanner", resourcePath, "")).toBe("");
|
||||
expect(databaseObjectTreeNodeSchema("spanner", resourcePath)).toBe("");
|
||||
// Callers that collapsed `node.schema || node.database` before calling in (the sidebar
|
||||
// SQL template and DDL paths) hand over the resource path as the schema.
|
||||
expect(connectionObjectTreeQuerySchema(connection, resourcePath, resourcePath)).toBe("");
|
||||
expect(metadataSchemaForConnection(connection, resourcePath, resourcePath)).toBe("");
|
||||
});
|
||||
|
||||
it("keeps the database-as-schema fallback for flat engines and blanks it for Cloud Spanner", () => {
|
||||
// Editor completion paths send the database name as the metadata schema when a
|
||||
// connection has no schema level; only Spanner must send a blank schema instead.
|
||||
expect(connectionDatabaseMetadataSchema({ db_type: "mysql" }, "shop")).toBe("shop");
|
||||
expect(connectionDatabaseMetadataSchema({ db_type: "hbase" }, "shop")).toBe("shop");
|
||||
expect(connectionDatabaseMetadataSchema({ db_type: "mysql" }, "shop", "other")).toBe("other");
|
||||
expect(connectionDatabaseMetadataSchema({ db_type: "spanner" }, "projects/p/instances/i/databases/db")).toBe("");
|
||||
expect(connectionDatabaseMetadataSchema({ db_type: "spanner" }, "projects/p/instances/i/databases/db", "")).toBe("");
|
||||
expect(connectionDatabaseMetadataSchema({ db_type: "spanner" }, "projects/p/instances/i/databases/db", "public")).toBe("public");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,6 +59,42 @@ describe("quoteTableIdentifier", () => {
|
||||
expect(quoteTableIdentifier("bigquery", "a`b")).toBe("`a\\`b`");
|
||||
});
|
||||
|
||||
it("backtick-quotes Cloud Spanner GoogleSQL identifiers by default", () => {
|
||||
expect(quoteTableIdentifier("spanner", "order")).toBe("`order`");
|
||||
expect(quoteTableIdentifier("spanner", "a`b")).toBe("`a\\`b`");
|
||||
});
|
||||
|
||||
it("uses the connection-reported quote for Cloud Spanner table-data identifiers", () => {
|
||||
// GoogleSQL dialect: the agent reports a backtick.
|
||||
expect(quoteTableDataIdentifier("spanner", "order", "`")).toBe("`order`");
|
||||
// PostgreSQL dialect: the agent reports a double quote.
|
||||
expect(quoteTableDataIdentifier("spanner", "MixedCase", '"')).toBe('"MixedCase"');
|
||||
});
|
||||
|
||||
it("keeps Cloud Spanner GoogleSQL names single-segment and qualifies PostgreSQL-dialect names", () => {
|
||||
// GoogleSQL user schema is the empty string; a two-part name would be a syntax error.
|
||||
expect(qualifiedTableName({ databaseType: "spanner", schema: "", tableName: "singers", identifierQuote: "`" })).toBe("`singers`");
|
||||
expect(qualifiedTableName({ databaseType: "spanner", schema: "public", tableName: "singers", identifierQuote: '"' })).toBe('"public"."singers"');
|
||||
// No reported quote: fall back to the GoogleSQL default.
|
||||
expect(qualifiedTableName({ databaseType: "spanner", tableName: "singers" })).toBe("`singers`");
|
||||
});
|
||||
|
||||
it("never qualifies a Cloud Spanner table with the resource path", () => {
|
||||
// The sidebar SQL template path collapses `node.schema || node.database`, and for Spanner the
|
||||
// database is the resource path, which is not a schema and is not valid inside a table name.
|
||||
const resourcePath = "projects/p/instances/i/databases/db";
|
||||
expect(qualifiedTableName({ databaseType: "spanner", schema: resourcePath, database: resourcePath, tableName: "singers", identifierQuote: "`" })).toBe("`singers`");
|
||||
expect(qualifiedTableName({ databaseType: "spanner", schema: resourcePath, database: resourcePath, tableName: "singers", identifierQuote: '"' })).toBe('"singers"');
|
||||
expect(qualifiedTableName({ databaseType: "spanner", schema: resourcePath, tableName: "singers" })).toBe("`singers`");
|
||||
});
|
||||
|
||||
it("keeps the Cloud Spanner schema qualifier even before the connection quote loads", () => {
|
||||
// The quote is fetched asynchronously after connect and can be missing, so the
|
||||
// branch is unconditional: a named schema must never lose its qualifier.
|
||||
expect(qualifiedTableName({ databaseType: "spanner", schema: "analytics", tableName: "singers" })).toBe("`analytics`.`singers`");
|
||||
expect(qualifiedTableName({ databaseType: "spanner", schema: " ", tableName: "singers" })).toBe("`singers`");
|
||||
});
|
||||
|
||||
it("bracket-quotes sqlserver identifiers", () => {
|
||||
expect(quoteTableIdentifier("sqlserver", "orders")).toBe("[orders]");
|
||||
});
|
||||
|
||||
@@ -81,6 +81,7 @@ describe("AGENT_DRIVER_CATEGORY_MAP integrity", () => {
|
||||
"informix",
|
||||
"iris",
|
||||
"oracle",
|
||||
"spanner",
|
||||
"sqlserver-legacy",
|
||||
// analytics
|
||||
"bigquery",
|
||||
|
||||
@@ -40,6 +40,7 @@ const DRIVER_STARTUP_FLOOR_TYPES = new Set<DatabaseType>([
|
||||
"neo4j",
|
||||
"cassandra",
|
||||
"bigquery",
|
||||
"spanner",
|
||||
"kylin",
|
||||
"ignite",
|
||||
"ignite3",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ConnectionConfig, DatabaseType } from "@/types/database";
|
||||
import { GAUSSDB_M_JDBC_DRIVER_PROFILE } from "@/lib/database/jdbcDialect";
|
||||
import { parseGaussdbHosts, serializeGaussdbHosts } from "@/lib/connection/gaussdbHosts";
|
||||
import { spannerDisplayDatabase } from "@/lib/connection/spannerResourcePath";
|
||||
|
||||
type ConnectionPresentationConfig = Pick<ConnectionConfig, "db_type" | "driver_profile" | "driver_label" | "host" | "port" | "database">;
|
||||
type ConnectionNamePresentationConfig = ConnectionPresentationConfig & Pick<ConnectionConfig, "name">;
|
||||
@@ -20,6 +21,9 @@ export function connectionDriverLabel(connection?: Pick<ConnectionConfig, "db_ty
|
||||
export function connectionEndpointLabel(connection?: ConnectionPresentationConfig): string {
|
||||
if (!connection) return "";
|
||||
if (connection.db_type === "cloudflare-d1") return [connection.host, connection.database].filter(Boolean).join("/");
|
||||
// Cloud Spanner stores the whole resource path in `database`; only the trailing
|
||||
// database ID is short enough for a subtitle, and the host is empty on Google Cloud.
|
||||
if (connection.db_type === "spanner") return connection.host ? `${connection.host}:${connection.port}` : spannerDisplayDatabase(connection.database);
|
||||
if (LOCAL_DATABASE_TYPES.has(connection.db_type) || (connection.db_type === "h2" && connection.port === 0)) {
|
||||
return connection.host || connection.database || "local";
|
||||
}
|
||||
@@ -80,6 +84,10 @@ function redactSingleHost(host: string): string {
|
||||
export function connectionRedactedEndpointLabel(connection?: ConnectionPresentationConfig): string {
|
||||
if (!connection) return "";
|
||||
if (connection.db_type === "cloudflare-d1") return `${REDACTED_HOST_SEGMENT}/${REDACTED_HOST_SEGMENT}`;
|
||||
// The Spanner endpoint label already drops project and instance, so it carries
|
||||
// no more than any other database name; without this branch the fallback below
|
||||
// would print the full `projects/.../databases/...` path.
|
||||
if (connection.db_type === "spanner") return connection.host ? `${redactConnectionHost(connection.host)}:${REDACTED_PORT}` : spannerDisplayDatabase(connection.database);
|
||||
if (LOCAL_DATABASE_TYPES.has(connection.db_type) || (connection.db_type === "h2" && connection.port === 0)) {
|
||||
return connectionEndpointLabel(connection);
|
||||
}
|
||||
@@ -242,6 +250,9 @@ export function connectionUrlPlaceholder(dbType: DatabaseType): string {
|
||||
case "bigquery":
|
||||
return "bigquery://https://www.googleapis.com/bigquery/v2:443/project-id";
|
||||
|
||||
case "spanner":
|
||||
return "spanner:///projects/{project}/instances/{instance}/databases/{database}";
|
||||
|
||||
case "iris":
|
||||
return "iris://user:password@host:port/namespace";
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ export const AGENT_DRIVER_CATEGORY_MAP: Readonly<Record<string, DriverCategoryKe
|
||||
rocketmq: "mq",
|
||||
saphana: "analytics",
|
||||
snowflake: "analytics",
|
||||
spanner: "sql",
|
||||
spark: "analytics",
|
||||
"sqlserver-legacy": "sql",
|
||||
sundb: "domestic",
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
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-core/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("/");
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import type { DatabaseType } from "@/types/database";
|
||||
|
||||
export const SCHEMA_AWARE_TYPES = new Set<DatabaseType>([
|
||||
"postgres",
|
||||
// Cloud Spanner supports named schemas; GoogleSQL's default schema is the empty string, which
|
||||
// `spannerObjectTreeSchema` keeps intact through the `schema || database` fallbacks.
|
||||
"spanner",
|
||||
"sqlserver",
|
||||
"oracle",
|
||||
"redshift",
|
||||
@@ -55,6 +58,12 @@ export const FETCH_FIRST_TYPES = new Set<DatabaseType>(["oracle", "dameng"]);
|
||||
|
||||
export const TREE_SCHEMA_TYPES = new Set<DatabaseType>([
|
||||
"postgres",
|
||||
// Cloud Spanner needs the schema level for the same reason it is in SCHEMA_AWARE_TYPES: named
|
||||
// schemas exist and are queryable, so the tree has to expose them. Membership here is what makes
|
||||
// a database node load schemas instead of tables; SCHEMA_AWARE_TYPES alone only reaches the
|
||||
// schema pickers in dialogs. The sites gated on `database === ""` stay unreachable because a
|
||||
// Spanner database is always the resource path.
|
||||
"spanner",
|
||||
"redshift",
|
||||
"sqlserver",
|
||||
"db2",
|
||||
|
||||
@@ -64,8 +64,33 @@ export function databaseObjectTreeQuerySchema(dbType: DatabaseType | undefined,
|
||||
return schema || database;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloud Spanner is the one schema-aware type whose default schema is the empty string: that is the
|
||||
* literal name of GoogleSQL's user schema, and the agent forwards it to the driver verbatim. Every
|
||||
* `schema || database` fallback therefore has to be bypassed, because `database` holds a resource
|
||||
* path (`projects/…/databases/db`) that is never a schema name and matches no metadata.
|
||||
*
|
||||
* Named schemas (Spanner 2024+) pass through unchanged. Callers that already collapsed
|
||||
* `schema || node.database` are normalized back to the blank schema, which is safe because a Spanner
|
||||
* schema identifier is letters, digits and underscores and can never contain the path separator.
|
||||
*/
|
||||
export function spannerObjectTreeSchema(schema?: string): string {
|
||||
return schema && !schema.includes("/") ? schema : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a schema tree node carries a name its children can be loaded for. Cloud Spanner is the one
|
||||
* type where the empty string is a real schema name (GoogleSQL's user schema), so a plain truthiness
|
||||
* check would leave that node expandable but permanently empty. Every other type keeps the
|
||||
* truthiness test, which also filters the undefined schema on nodes that have no schema level.
|
||||
*/
|
||||
export function schemaNodeHasLoadableName(dbType: DatabaseType | undefined, schema?: string): boolean {
|
||||
return dbType === "spanner" ? schema != null : !!schema;
|
||||
}
|
||||
|
||||
export function databaseObjectTreeNodeSchema(dbType: DatabaseType | undefined, database: string, schema?: string): string | undefined {
|
||||
if (usesDatabaseObjectTreeMode(dbType)) return undefined;
|
||||
if (dbType === "spanner") return spannerObjectTreeSchema(schema);
|
||||
if (schema) return schema;
|
||||
return isSchemaAware(dbType) ? database : undefined;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ export const DATABASE_NAMESPACE_CREATION_MATRIX = {
|
||||
neo4j: { deferred: "database creation depends on edition/admin privileges" },
|
||||
cassandra: { deferred: "keyspace creation requires replication options" },
|
||||
bigquery: { deferred: "dataset creation needs project/location options" },
|
||||
spanner: { deferred: "database creation requires the Cloud Spanner Admin API" },
|
||||
kylin: { deferred: "project/model lifecycle is not SQL database creation" },
|
||||
ignite: { deferred: "schema lifecycle is managed through cluster/cache configuration" },
|
||||
ignite3: { deferred: "schema lifecycle is managed through cluster configuration" },
|
||||
|
||||
@@ -77,6 +77,9 @@ const DATABASE_TYPE_OBJECTS = new Map<DatabaseType, SidebarObjectKind[]>([
|
||||
["prestosql", TABLE_VIEW_OBJECTS],
|
||||
["cassandra", TABLE_VIEW_OBJECTS],
|
||||
["bigquery", TABLE_VIEW_OBJECTS],
|
||||
// Cloud Spanner has no triggers, routines, sequences, or synonyms; without an
|
||||
// explicit entry the sidebar would fall back to ROUTINE_OBJECTS.
|
||||
["spanner", TABLE_VIEW_OBJECTS],
|
||||
["kylin", TABLE_VIEW_OBJECTS],
|
||||
["ignite", TABLE_VIEW_OBJECTS],
|
||||
["ignite3", TABLE_VIEW_OBJECTS],
|
||||
|
||||
@@ -75,6 +75,7 @@ export const DATABASE_PROPERTY_EDITING_MATRIX = {
|
||||
neo4j: { deferred: "database properties depend on edition/admin privileges" },
|
||||
cassandra: { deferred: "keyspace properties require replication option handling" },
|
||||
bigquery: { deferred: "dataset properties need project/location-specific handling" },
|
||||
spanner: { deferred: "database/schema properties are Cloud Spanner Admin API operations" },
|
||||
kylin: { deferred: "project/model lifecycle is not SQL database property editing" },
|
||||
ignite: { deferred: "schema properties need product-specific handling" },
|
||||
ignite3: { deferred: "schema properties need product-specific handling" },
|
||||
|
||||
@@ -84,6 +84,7 @@ const NAVICAT_STYLE_TABLE_DATA_TYPES = new Set<DatabaseType>([
|
||||
"db2",
|
||||
"informix",
|
||||
"bigquery",
|
||||
"spanner",
|
||||
"sundb",
|
||||
"oscar",
|
||||
"databend",
|
||||
|
||||
@@ -19,14 +19,22 @@ export function sortSidebarDatabases(databases: readonly DatabaseInfo[]): Databa
|
||||
return [...databases].sort((left, right) => sidebarNameCollator.compare(left.name, right.name));
|
||||
}
|
||||
|
||||
export function buildDatabaseTreeNodes(connectionId: string, databases: DatabaseInfo[], options: { includeDefaultWhenEmpty?: boolean } = {}): TreeNode[] {
|
||||
export function buildDatabaseTreeNodes(
|
||||
connectionId: string,
|
||||
databases: DatabaseInfo[],
|
||||
// `displayLabel` only rewrites the visible text. `id` and `database` keep the
|
||||
// backend-reported name because that value round-trips to the agent — Cloud
|
||||
// Spanner reports the full `projects/../instances/../databases/..` resource
|
||||
// path and would break URL building if the shortened label were sent back.
|
||||
options: { includeDefaultWhenEmpty?: boolean; displayLabel?: (name: string) => string } = {},
|
||||
): TreeNode[] {
|
||||
const nodes = sortSidebarDatabases(databases).flatMap((db) => {
|
||||
const name = db.name;
|
||||
if (!name.trim()) return [];
|
||||
return [
|
||||
{
|
||||
id: `${connectionId}:${name}`,
|
||||
label: name,
|
||||
label: options.displayLabel?.(name) || name,
|
||||
type: "database" as const,
|
||||
connectionId,
|
||||
database: name,
|
||||
|
||||
@@ -9,6 +9,10 @@ export interface DropObjectSqlOptions {
|
||||
schema?: string | null;
|
||||
name: string;
|
||||
signature?: string | null;
|
||||
/** Quote character reported by the connected server, for types whose quote is not fixed by the
|
||||
* database type alone (Cloud Spanner's two dialects differ). Mirrors `identifierQuote` on the
|
||||
* table-data SQL options. */
|
||||
identifierQuote?: string;
|
||||
}
|
||||
|
||||
export interface TableAdminSqlOptions {
|
||||
@@ -16,6 +20,10 @@ export interface TableAdminSqlOptions {
|
||||
schema?: string | null;
|
||||
tableName: string;
|
||||
cascade?: boolean;
|
||||
/** Quote character reported by the connected server, for types whose quote is not fixed by the
|
||||
* database type alone (Cloud Spanner's two dialects differ). Mirrors `identifierQuote` on the
|
||||
* table-data SQL options. */
|
||||
identifierQuote?: string;
|
||||
}
|
||||
|
||||
export interface VacuumTableSqlOptions {
|
||||
@@ -75,6 +83,10 @@ export interface DuplicateTableStructureSqlOptions {
|
||||
targetName: string;
|
||||
tableComment?: string | null;
|
||||
columnComments?: Array<{ name: string; comment: string }>;
|
||||
/** Quote character reported by the connected server, for types whose quote is not fixed by the
|
||||
* database type alone (Cloud Spanner's two dialects differ). Mirrors `identifierQuote` on the
|
||||
* table-data SQL options. */
|
||||
identifierQuote?: string;
|
||||
}
|
||||
|
||||
export interface DuplicateTableStructurePlanOptions extends DuplicateTableStructureSqlOptions {
|
||||
@@ -259,6 +271,7 @@ export async function buildDuplicateTableStructurePlan(options: DuplicateTableSt
|
||||
targetName: options.targetName,
|
||||
tableComment: options.tableComment,
|
||||
columnComments: [],
|
||||
identifierQuote: options.identifierQuote,
|
||||
});
|
||||
return { sql, sourceColumns: options.sourceColumns, executeAsScript: duplicateTableStructureRequiresScript(sql) };
|
||||
}
|
||||
@@ -272,6 +285,10 @@ export interface CopyTableDataSqlOptions {
|
||||
postgresOverridingSystemValue?: boolean;
|
||||
sqlserverIdentityInsert?: boolean;
|
||||
normalizeNewTargetName?: boolean;
|
||||
/** Quote character reported by the connected server, for types whose quote is not fixed by the
|
||||
* database type alone (Cloud Spanner's two dialects differ). Mirrors `identifierQuote` on the
|
||||
* table-data SQL options. */
|
||||
identifierQuote?: string;
|
||||
}
|
||||
|
||||
export function buildDropObjectSql(options: DropObjectSqlOptions): Promise<string> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ConnectionConfig, DatabaseType } from "@/types/database";
|
||||
import { isSchemaAware, usesDatabaseObjectTreeMode, usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport";
|
||||
import { isSchemaAware, spannerObjectTreeSchema, usesDatabaseObjectTreeMode, usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport";
|
||||
import type { CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect";
|
||||
|
||||
type JdbcDialectConnection = Partial<Pick<ConnectionConfig, "db_type" | "driver_profile" | "driver_label" | "connection_string" | "url_params" | "jdbc_driver_class" | "jdbc_driver_paths" | "database_info" | "external_config">>;
|
||||
@@ -81,6 +81,12 @@ export function connectionShouldLoadIdentifierQuote(connection: JdbcDialectConne
|
||||
if (!connection) return false;
|
||||
if (connection.db_type === "gbase" && isGbase8sProfile(connection.driver_profile)) return true;
|
||||
if (connection.db_type === "kingbase") return true;
|
||||
// Cloud Spanner is dual-dialect: the agent reports a backtick for GoogleSQL and
|
||||
// a double quote for PostgreSQL-dialect databases. The backend counts Spanner
|
||||
// unconditionally in `uses_connection_identifier_quote`, so the UI must fetch
|
||||
// the reported quote or every locally built statement would use the GoogleSQL
|
||||
// default against a PostgreSQL-dialect database.
|
||||
if (connection.db_type === "spanner") return true;
|
||||
if (gaussdbIdentifierQuoteStyle(connection) !== "auto") return false;
|
||||
if (connection.db_type === "gaussdb") return true;
|
||||
if (connection.db_type !== "jdbc") return false;
|
||||
@@ -207,10 +213,24 @@ export function connectionQueryExecutionSchema(connection: JdbcDialectConnection
|
||||
export function connectionObjectTreeQuerySchema(connection: JdbcDialectConnection | undefined, database: string, schema?: string): string {
|
||||
if (connection?.db_type === "jdbc" && inferJdbcDialect(connection) === "databend") return schema || database;
|
||||
if (connectionUsesDatabaseObjectTreeMode(connection)) return "";
|
||||
if (effectiveDatabaseTypeForConnection(connection) === "informix") return schema || "";
|
||||
const type = effectiveDatabaseTypeForConnection(connection);
|
||||
if (type === "informix") return schema || "";
|
||||
if (type === "spanner") return spannerObjectTreeSchema(schema);
|
||||
return schema || database;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata schema for query paths that treat the database name as the schema when a
|
||||
* connection exposes no schema level (MySQL, HBase, and the other flat engines).
|
||||
* Cloud Spanner is the exception: its database is a resource path, so the blank
|
||||
* GoogleSQL schema has to be sent instead of the path. Behavior for every other
|
||||
* database type is exactly `schema || database`.
|
||||
*/
|
||||
export function connectionDatabaseMetadataSchema(connection: JdbcDialectConnection | undefined, database: string, schema?: string): string {
|
||||
if (schema) return schema;
|
||||
return effectiveDatabaseTypeForConnection(connection) === "spanner" ? "" : database;
|
||||
}
|
||||
|
||||
export function metadataSchemaForConnection(connection: JdbcDialectConnection | undefined, database: string, schema?: string): string {
|
||||
const type = effectiveDatabaseTypeForConnection(connection);
|
||||
if (type === "sqlserver") return schema || "dbo";
|
||||
@@ -223,6 +243,7 @@ export function connectionObjectTreeNodeSchema(connection: JdbcDialectConnection
|
||||
const type = effectiveDatabaseTypeForConnection(connection);
|
||||
if (type === "informix") return schema || undefined;
|
||||
if (type === "sqlite") return schema || database;
|
||||
if (type === "spanner") return spannerObjectTreeSchema(schema);
|
||||
if (!type) return schema;
|
||||
return isSchemaAware(type) ? schema || database : undefined;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ export function quoteTableIdentifier(databaseType: DatabaseType | undefined, nam
|
||||
// (DatabaseMetaData.getIdentifierQuoteString()) — pass through unquoted.
|
||||
if (databaseType === "jdbc") return name;
|
||||
if (databaseType === "bigquery") return `\`${name.replace(/`/g, "\\`")}\``;
|
||||
// Cloud Spanner defaults to the GoogleSQL dialect (backticks). PostgreSQL-dialect
|
||||
// databases report `"` on connect and take the quoteTableDataIdentifier path.
|
||||
if (databaseType === "spanner") return `\`${name.replace(/`/g, "\\`")}\``;
|
||||
if (
|
||||
databaseType === "mysql" ||
|
||||
databaseType === "clickhouse" ||
|
||||
@@ -55,7 +58,7 @@ export function quoteTableIdentifier(databaseType: DatabaseType | undefined, nam
|
||||
|
||||
export function quoteTableDataIdentifier(databaseType: DatabaseType | undefined, name: string, identifierQuote?: string): string {
|
||||
if ((databaseType === "gaussdb" || databaseType === "opengauss" || databaseType === "postgres") && identifierQuote != null) return quoteGaussDbJdbcIdentifier(name, identifierQuote);
|
||||
if ((databaseType === "kingbase" || databaseType === "informix") && identifierQuote != null) {
|
||||
if ((databaseType === "kingbase" || databaseType === "informix" || databaseType === "spanner") && identifierQuote != null) {
|
||||
if (!identifierQuote) return name;
|
||||
return `${identifierQuote}${name.replaceAll(identifierQuote, identifierQuote + identifierQuote)}${identifierQuote}`;
|
||||
}
|
||||
@@ -100,6 +103,23 @@ export function qualifiedTableName(options: Pick<BuildTableSelectSqlOptions, "da
|
||||
}
|
||||
return quotedTable;
|
||||
}
|
||||
// Cloud Spanner mirrors `uses_connection_identifier_quote` on the backend, which
|
||||
// counts Spanner unconditionally: the branch must not be gated on a reported
|
||||
// quote, otherwise a connection whose quote has not loaded yet would fall through
|
||||
// to the SCHEMA_AWARE_TYPES path and silently drop the schema qualifier. A blank
|
||||
// schema (GoogleSQL's default) drops the dot separator with it, because
|
||||
// `` `s`.`t` `` with an empty `s` is a Spanner syntax error; a missing quote falls
|
||||
// back to the GoogleSQL backtick default, exactly like `identifiers.rs`.
|
||||
if (databaseType === "spanner") {
|
||||
const quotedTable = quoteTableDataIdentifier(databaseType, tableName, identifierQuote);
|
||||
// `database` holds the resource path `projects/{p}/instances/{i}/databases/{d}`, and callers
|
||||
// that treat the database as the schema (the sidebar SQL templates collapse
|
||||
// `node.schema || node.database`) would otherwise emit `` `projects/…`.`singers` ``. A Spanner
|
||||
// schema name is letters, digits and underscores, so the path separator identifies it.
|
||||
const trimmedSchema = schema?.trim();
|
||||
const schemaQualifier = trimmedSchema && !trimmedSchema.includes("/") ? trimmedSchema : undefined;
|
||||
return schemaQualifier ? `${quoteTableDataIdentifier(databaseType, schemaQualifier, identifierQuote)}.${quotedTable}` : quotedTable;
|
||||
}
|
||||
if (databaseType === "informix" && identifierQuote != null) {
|
||||
const quotedTable = quoteTableDataIdentifier(databaseType, tableName, identifierQuote);
|
||||
const trimmedSchema = schema?.trim();
|
||||
|
||||
@@ -68,9 +68,19 @@ import { mergeSqlObjectNavigationType, sqlObjectNavigationTypeFromTableType } fr
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
|
||||
import { connectionIsDorisFamilyCatalogCapable, isInternalDorisCatalog, isSchemaAware, normalizeSidebarObjectKind, sidebarObjectKindsForDatabase, supportsPackageMemberExpansion, usesTreeSchemaMode } from "@/lib/database/databaseCapabilities";
|
||||
import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionShouldDiscoverJdbcSchemas, connectionShouldLoadIdentifierQuote, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, gaussdbIdentifierQuoteOverride } from "@/lib/database/jdbcDialect";
|
||||
import { connectionIsDorisFamilyCatalogCapable, isInternalDorisCatalog, isSchemaAware, normalizeSidebarObjectKind, schemaNodeHasLoadableName, sidebarObjectKindsForDatabase, supportsPackageMemberExpansion, usesTreeSchemaMode } from "@/lib/database/databaseCapabilities";
|
||||
import {
|
||||
connectionDatabaseMetadataSchema,
|
||||
connectionObjectTreeNodeSchema,
|
||||
connectionObjectTreeQuerySchema,
|
||||
connectionShouldDiscoverJdbcSchemas,
|
||||
connectionShouldLoadIdentifierQuote,
|
||||
connectionUsesDatabaseObjectTreeMode,
|
||||
effectiveDatabaseTypeForConnection,
|
||||
gaussdbIdentifierQuoteOverride,
|
||||
} from "@/lib/database/jdbcDialect";
|
||||
import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes, compareSidebarNames, sortSidebarDatabases, sortSidebarNames, shouldIncludeDefaultDatabaseNode } from "@/lib/database/databaseTree";
|
||||
import { spannerDisplayDatabase, spannerSchemaDisplayName } from "@/lib/connection/spannerResourcePath";
|
||||
import { buildSqlServerDatabaseTreeNodes } from "@/lib/database/sqlServerTree";
|
||||
import { collapseExpandedTreeNodes } from "@/lib/sidebar/sidebarTreeCollapse";
|
||||
import { findNodePathByIdentity, nodeMatchesRegexScopeIdentity, type SidebarRegexScopeIdentity } from "@/lib/sidebar/sidebarSearchTree";
|
||||
@@ -1149,6 +1159,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
neo4j: "Neo4j",
|
||||
cassandra: "Cassandra",
|
||||
bigquery: "BigQuery",
|
||||
spanner: "Cloud Spanner",
|
||||
kylin: "Kylin",
|
||||
ignite: "Apache Ignite",
|
||||
ignite3: "Apache Ignite 3",
|
||||
@@ -1602,11 +1613,17 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
return ownerAwareMetadataCacheVersion(config, scopedVersion);
|
||||
}
|
||||
|
||||
function sortSidebarSchemaInfos(schemas: readonly SchemaInfo[]): SchemaInfo[] {
|
||||
/**
|
||||
* The blank guard drops junk entries reported by drivers that pad their schema list. Cloud Spanner
|
||||
* is the exception: in GoogleSQL the empty string *is* the name of the user schema, so dropping it
|
||||
* hides every table that is not in a named schema. `keepBlankSchema` is therefore opt-in per
|
||||
* database type rather than a global relaxation.
|
||||
*/
|
||||
function sortSidebarSchemaInfos(schemas: readonly SchemaInfo[], keepBlankSchema = false): SchemaInfo[] {
|
||||
const byName = new Map<string, SchemaInfo>();
|
||||
for (const schema of schemas) {
|
||||
const name = schema.name.trim();
|
||||
if (!name) continue;
|
||||
if (!name && !keepBlankSchema) continue;
|
||||
byName.set(name, { name, comment: schema.comment ?? null });
|
||||
}
|
||||
return sortXuguSchemaInfos([...byName.values()], compareSidebarNames);
|
||||
@@ -3880,6 +3897,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const effectiveDbType = effectiveDatabaseTypeForConnection(config);
|
||||
const databaseNodes = buildDatabaseTreeNodes(connectionId, visibleDatabases, {
|
||||
includeDefaultWhenEmpty: usesTreeSchemaMode(effectiveDbType) || shouldIncludeDefaultDatabaseNode(config, visibleDatabases),
|
||||
// Cloud Spanner reports the full resource path as the database
|
||||
// name; show only the database id so the sidebar stays readable.
|
||||
displayLabel: effectiveDbType === "spanner" ? spannerDisplayDatabase : undefined,
|
||||
});
|
||||
if (config?.db_type === "sqlserver") {
|
||||
const linkedServers = await withMetadataLoadTimeout(connectionId, api.listSqlServerLinkedServers(connectionId), "linked servers").catch(() => []);
|
||||
@@ -4588,7 +4608,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
}
|
||||
|
||||
const schemas = sortSidebarSchemaInfos(await withMetadataLoadTimeout(connectionId, api.listSchemaInfos(connectionId, database), "schemas"));
|
||||
const schemas = sortSidebarSchemaInfos(await withMetadataLoadTimeout(connectionId, api.listSchemaInfos(connectionId, database), "schemas"), effectiveDatabaseTypeForConnection(config) === "spanner");
|
||||
const visibleSchemaNames = new Set(
|
||||
filterSchemaNamesForConnection(
|
||||
schemas.map((schema) => schema.name),
|
||||
@@ -4609,7 +4629,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const s = schema.name;
|
||||
return {
|
||||
id: `${connectionId}:${database}:${s}`,
|
||||
label: config?.db_type === "xugu" ? xuguSchemaDisplayName(s) : s,
|
||||
label: config?.db_type === "xugu" ? xuguSchemaDisplayName(s) : config?.db_type === "spanner" ? spannerSchemaDisplayName(s) : s,
|
||||
type: "schema" as const,
|
||||
connectionId,
|
||||
database,
|
||||
@@ -4961,7 +4981,11 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
nodeKind: simpleObjectDisplayForScope ? "simple-tables" : "group-tables",
|
||||
});
|
||||
if (!options?.force && simpleObjectDisplayForScope && !searchFilter && !options?.sidebarTableSearchParentId && !tableNameFilterForScope) {
|
||||
const nodeId = schema ? `${connectionId}:${database}:${schema}` : `${connectionId}:${database}`;
|
||||
// `schema != null`, not truthiness: Cloud Spanner's GoogleSQL default schema is the empty
|
||||
// string, and its node id carries the trailing separator (`conn:db:`). A truthiness test
|
||||
// collapses that to the database node id, so the loaded tables were attached to the
|
||||
// database node instead of the schema node, leaving `(default)` permanently empty.
|
||||
const nodeId = schema != null ? `${connectionId}:${database}:${schema}` : `${connectionId}:${database}`;
|
||||
const cacheKey = schemaCacheKey(connectionId, database, schema || "", objectTreeCacheVersion(configForScope, schema, "objects-simple-v8"));
|
||||
if (await hydrateTreeNodeFromCache(findNode(treeNodes.value, nodeId), cacheKey)) {
|
||||
void loadTables(connectionId, database, schema, { ...options, force: true }).catch(() => undefined);
|
||||
@@ -4974,7 +4998,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
connectionId,
|
||||
database,
|
||||
schema,
|
||||
nodeKind: schema ? "schema" : "database",
|
||||
nodeKind: schema != null ? "schema" : "database",
|
||||
objectTypes: objectTypesForScope,
|
||||
searchFilter: activeTreeLoadSearchFilter(options),
|
||||
limit: simpleObjectDisplayForScope ? sidebarObjectGroupPageSize() + 1 : undefined,
|
||||
@@ -4984,7 +5008,11 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
extra: options?.sidebarTableSearchParentId ? { sidebarTableSearchParentId: options.sidebarTableSearchParentId } : undefined,
|
||||
},
|
||||
async () => {
|
||||
const nodeId = schema ? `${connectionId}:${database}:${schema}` : `${connectionId}:${database}`;
|
||||
// `schema != null`, not truthiness: Cloud Spanner's GoogleSQL default schema is the empty
|
||||
// string, and its node id carries the trailing separator (`conn:db:`). A truthiness test
|
||||
// collapses that to the database node id, so the loaded tables were attached to the
|
||||
// database node instead of the schema node, leaving `(default)` permanently empty.
|
||||
const nodeId = schema != null ? `${connectionId}:${database}:${schema}` : `${connectionId}:${database}`;
|
||||
const node = findNode(treeNodes.value, nodeId);
|
||||
if (!node) return;
|
||||
let load = beginTreeNodeLoad(node);
|
||||
@@ -6107,7 +6135,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
await loadTables(node.connectionId, node.database, undefined, options);
|
||||
}
|
||||
}
|
||||
} else if (node.type === "schema" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.schema) {
|
||||
} else if (node.type === "schema" && node.connectionId && hasTreeNodeDatabaseContext(node) && schemaNodeHasLoadableName(effectiveDatabaseTypeForConnection(getConfig(node.connectionId)), node.schema)) {
|
||||
await loadTables(node.connectionId, node.database, node.schema, options);
|
||||
} else if (node.type === "linked-server-root" && node.connectionId) {
|
||||
await loadSqlServerLinkedServers(node.connectionId, options);
|
||||
@@ -7135,7 +7163,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
return completionTablesCache.value[cacheKey];
|
||||
}
|
||||
|
||||
const querySchema = catalog ? "" : database;
|
||||
const querySchema = catalog ? "" : connectionDatabaseMetadataSchema(getConfig(connectionId), database);
|
||||
let tables = await listCompletionTableMetadata(connectionId, database, querySchema, trimmedFilter, limit, catalog);
|
||||
if (tables.length === 0 && relaxedFilter) {
|
||||
tables = await listCompletionTableMetadata(connectionId, database, querySchema, relaxedFilter, expandedCompletionLimit(limit), catalog);
|
||||
@@ -7214,12 +7242,12 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
if (objectKinds.length === 1 && objectKinds[0] === "sequence") {
|
||||
completionObjectsCache.value[cacheKey] = [];
|
||||
} else {
|
||||
const objects = isSchemaAwareDatabase(connectionId) ? await listSchemaAwareCompletionObjects(connectionId, database, schema) : await api.listCompletionObjects(connectionId, database, schema || database);
|
||||
const objects = isSchemaAwareDatabase(connectionId) ? await listSchemaAwareCompletionObjects(connectionId, database, schema) : await api.listCompletionObjects(connectionId, database, connectionDatabaseMetadataSchema(getConfig(connectionId), database, schema));
|
||||
completionObjectsCache.value[cacheKey] = dedupeCompletionObjects(objects.map(toSqlCompletionObject).filter((object): object is SqlCompletionObject => object != null));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const objects = isSchemaAwareDatabase(connectionId) ? await listSchemaAwareCompletionObjects(connectionId, database, schema) : await api.listCompletionObjects(connectionId, database, schema || database);
|
||||
const objects = isSchemaAwareDatabase(connectionId) ? await listSchemaAwareCompletionObjects(connectionId, database, schema) : await api.listCompletionObjects(connectionId, database, connectionDatabaseMetadataSchema(getConfig(connectionId), database, schema));
|
||||
completionObjectsCache.value[cacheKey] = dedupeCompletionObjects(objects.map(toSqlCompletionObject).filter((object): object is SqlCompletionObject => object != null));
|
||||
}
|
||||
indexCompletionObjects(connectionId, database, schema, completionObjectsCache.value[cacheKey]);
|
||||
|
||||
@@ -62,6 +62,7 @@ export type DatabaseType =
|
||||
| "neo4j"
|
||||
| "cassandra"
|
||||
| "bigquery"
|
||||
| "spanner"
|
||||
| "kylin"
|
||||
| "ignite"
|
||||
| "ignite3"
|
||||
|
||||
@@ -65,6 +65,7 @@ const BRIDGE_REQUIRED_TYPES: &[&str] = &[
|
||||
"neo4j",
|
||||
"cassandra",
|
||||
"bigquery",
|
||||
"spanner",
|
||||
"kylin",
|
||||
"ignite",
|
||||
"ignite3",
|
||||
|
||||
@@ -1698,6 +1698,36 @@
|
||||
"driverManagement": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"dbType": "spanner",
|
||||
"label": "Google Cloud Spanner",
|
||||
"runtimeMode": "agent",
|
||||
"mcpMode": "bridge",
|
||||
"agentKey": "spanner",
|
||||
"singleConnectionPool": false,
|
||||
"metadataConnectionScoped": false,
|
||||
"skipTcpProbe": true,
|
||||
"defaultPort": 443,
|
||||
"supportLevel": "operate",
|
||||
"capabilities": {
|
||||
"queryExecution": true,
|
||||
"metadataBrowse": true,
|
||||
"objectBrowser": true,
|
||||
"objectSource": false,
|
||||
"schemaSearch": true,
|
||||
"diagram": false,
|
||||
"tableDataEdit": true,
|
||||
"tableStructureEdit": false,
|
||||
"tableImport": false,
|
||||
"dataTransfer": false,
|
||||
"sqlFileExecution": true,
|
||||
"databaseCreate": false,
|
||||
"fieldLineage": false,
|
||||
"sqlExplain": false,
|
||||
"userAdmin": false,
|
||||
"driverManagement": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"dbType": "kylin",
|
||||
"label": "Apache Kylin",
|
||||
|
||||
@@ -253,6 +253,13 @@ const AGENT_CATALOG: &[AgentCatalogEntry] = &[
|
||||
store_visible: true,
|
||||
profiles: &[],
|
||||
},
|
||||
AgentCatalogEntry {
|
||||
db_type: DatabaseType::Spanner,
|
||||
key: "spanner",
|
||||
label: "Google Cloud Spanner",
|
||||
store_visible: true,
|
||||
profiles: &[],
|
||||
},
|
||||
AgentCatalogEntry {
|
||||
db_type: DatabaseType::Kylin,
|
||||
key: "kylin",
|
||||
|
||||
@@ -221,6 +221,7 @@ macro_rules! agent_connection_pool_database_type {
|
||||
| DatabaseType::Neo4j
|
||||
| DatabaseType::Cassandra
|
||||
| DatabaseType::Bigquery
|
||||
| DatabaseType::Spanner
|
||||
| DatabaseType::Kylin
|
||||
| DatabaseType::Ignite
|
||||
| DatabaseType::Ignite3
|
||||
|
||||
@@ -5341,6 +5341,87 @@ mod tests {
|
||||
.all(|statement| statement.contains("`gc`.`docfileinfo`") && !statement.contains('"')));
|
||||
}
|
||||
|
||||
/// Spanner's dialect is fixed at database creation time and only the connected agent
|
||||
/// knows it: GoogleSQL quotes with backticks (and has an empty default schema),
|
||||
/// the PostgreSQL dialect quotes with double quotes and defaults to `public`.
|
||||
#[test]
|
||||
fn spanner_save_uses_connection_identifier_quote_per_dialect() {
|
||||
let googlesql = prepare_data_grid_save(DataGridSaveStatementOptions {
|
||||
database_type: Some(DatabaseType::Spanner),
|
||||
identifier_quote: Some("`".to_string()),
|
||||
table_meta: DataGridTableMeta {
|
||||
catalog: None,
|
||||
database: None,
|
||||
schema: Some(String::new()),
|
||||
table_name: "singers".to_string(),
|
||||
primary_keys: vec!["id".to_string()],
|
||||
columns: Some(vec![column("id", "INT64", false, None), column("name", "STRING(100)", false, None)]),
|
||||
},
|
||||
columns: vec!["id".to_string(), "name".to_string()],
|
||||
source_columns: None,
|
||||
rows: vec![vec![json!(1), json!("old")]],
|
||||
dirty_rows: vec![(0, vec![(1, json!("Ada"))])],
|
||||
deleted_rows: vec![],
|
||||
new_rows: vec![],
|
||||
});
|
||||
|
||||
assert_eq!(googlesql.validation_error, None);
|
||||
// Single-segment table name: `` ``.`singers` `` would be `Invalid empty identifier`.
|
||||
assert_eq!(googlesql.statements, vec!["UPDATE `singers` SET `name` = 'Ada' WHERE `id` = 1;"]);
|
||||
|
||||
let postgres_dialect = prepare_data_grid_save(DataGridSaveStatementOptions {
|
||||
database_type: Some(DatabaseType::Spanner),
|
||||
identifier_quote: Some("\"".to_string()),
|
||||
table_meta: DataGridTableMeta {
|
||||
catalog: None,
|
||||
database: None,
|
||||
schema: Some("public".to_string()),
|
||||
table_name: "singers".to_string(),
|
||||
primary_keys: vec!["id".to_string()],
|
||||
columns: Some(vec![
|
||||
column("id", "bigint", false, None),
|
||||
column("name", "character varying", false, None),
|
||||
]),
|
||||
},
|
||||
columns: vec!["id".to_string(), "name".to_string()],
|
||||
source_columns: None,
|
||||
rows: vec![vec![json!(1), json!("old")]],
|
||||
dirty_rows: vec![(0, vec![(1, json!("Ada"))])],
|
||||
deleted_rows: vec![],
|
||||
new_rows: vec![],
|
||||
});
|
||||
|
||||
assert_eq!(postgres_dialect.validation_error, None);
|
||||
assert_eq!(
|
||||
postgres_dialect.statements,
|
||||
vec!["UPDATE \"public\".\"singers\" SET \"name\" = 'Ada' WHERE \"id\" = 1;"]
|
||||
);
|
||||
|
||||
// No quote reported: fall back to the GoogleSQL default (backticks), never to the
|
||||
// ANSI double quote, which GoogleSQL parses as a string literal.
|
||||
let no_reported_quote = prepare_data_grid_save(DataGridSaveStatementOptions {
|
||||
database_type: Some(DatabaseType::Spanner),
|
||||
identifier_quote: None,
|
||||
table_meta: DataGridTableMeta {
|
||||
catalog: None,
|
||||
database: None,
|
||||
schema: Some(String::new()),
|
||||
table_name: "singers".to_string(),
|
||||
primary_keys: vec!["id".to_string()],
|
||||
columns: Some(vec![column("id", "INT64", false, None), column("name", "STRING(100)", false, None)]),
|
||||
},
|
||||
columns: vec!["id".to_string(), "name".to_string()],
|
||||
source_columns: None,
|
||||
rows: vec![vec![json!(1), json!("old")]],
|
||||
dirty_rows: vec![(0, vec![(1, json!("Ada"))])],
|
||||
deleted_rows: vec![],
|
||||
new_rows: vec![],
|
||||
});
|
||||
|
||||
assert_eq!(no_reported_quote.validation_error, None);
|
||||
assert_eq!(no_reported_quote.statements, vec!["UPDATE `singers` SET `name` = 'Ada' WHERE `id` = 1;"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kingbase_mysql_compat_copy_insert_uses_backtick_identifiers() {
|
||||
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::sql_dialect::{is_schema_aware, profile_for, qualified_table_name, quote_table_identifier};
|
||||
use crate::sql_dialect::{
|
||||
is_schema_aware, profile_for, qualified_table_name, quote_table_data_identifier, quote_table_identifier,
|
||||
uses_connection_identifier_quote,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
@@ -87,6 +90,8 @@ pub struct DropObjectSqlOptions {
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub signature: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub identifier_quote: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -99,6 +104,10 @@ pub struct TableAdminSqlOptions {
|
||||
pub table_name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cascade: Option<bool>,
|
||||
/// Quote character reported by the connected server, for types whose quote is not a property of
|
||||
/// the database type alone. See [`qualified_name_with_quote`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub identifier_quote: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -193,6 +202,8 @@ pub struct DuplicateTableStructureSqlOptions {
|
||||
pub table_comment: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub column_comments: Vec<DuplicateTableColumnComment>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub identifier_quote: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -219,6 +230,8 @@ pub struct CopyTableDataSqlOptions {
|
||||
pub sqlserver_identity_insert: bool,
|
||||
#[serde(default)]
|
||||
pub normalize_new_target_name: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub identifier_quote: Option<String>,
|
||||
}
|
||||
|
||||
const MYSQL_COMPATIBLE_PROFILES: &[&str] = &["mysql", "mariadb", "tidb", "oceanbase", "custom_mysql"];
|
||||
@@ -384,13 +397,23 @@ pub fn build_drop_object_sql(options: DropObjectSqlOptions) -> String {
|
||||
format!(
|
||||
"DROP {} {}{};",
|
||||
object_type_keyword(options.object_type),
|
||||
qualified_name(options.database_type, options.schema.as_deref(), &options.name),
|
||||
qualified_name_with_quote(
|
||||
options.database_type,
|
||||
options.schema.as_deref(),
|
||||
&options.name,
|
||||
options.identifier_quote.as_deref(),
|
||||
),
|
||||
signature
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_drop_table_sql(options: TableAdminSqlOptions) -> String {
|
||||
let table = qualified_name(options.database_type, options.schema.as_deref(), &options.table_name);
|
||||
let table = qualified_name_with_quote(
|
||||
options.database_type,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
options.identifier_quote.as_deref(),
|
||||
);
|
||||
if matches!(options.database_type, Some(DatabaseType::Iotdb)) {
|
||||
return format!("DELETE TIMESERIES {};", iotdb_timeseries_pattern(&table));
|
||||
} else if matches!(options.database_type, Some(DatabaseType::InfluxDb)) {
|
||||
@@ -499,10 +522,17 @@ pub fn build_drop_table_child_object_sql(options: DropTableChildObjectSqlOptions
|
||||
}
|
||||
|
||||
pub fn build_empty_table_sql(options: TableAdminSqlOptions) -> String {
|
||||
let table = qualified_name(options.database_type, options.schema.as_deref(), &options.table_name);
|
||||
let table = qualified_name_with_quote(
|
||||
options.database_type,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
options.identifier_quote.as_deref(),
|
||||
);
|
||||
match options.database_type {
|
||||
Some(DatabaseType::ClickHouse) => format!("ALTER TABLE {table} DELETE WHERE 1 = 1;"),
|
||||
Some(DatabaseType::Bigquery) => format!("DELETE FROM {table} WHERE TRUE;"),
|
||||
// BigQuery and Spanner both reject an unqualified `DELETE FROM t`; DML requires a
|
||||
// WHERE clause.
|
||||
Some(DatabaseType::Bigquery | DatabaseType::Spanner) => format!("DELETE FROM {table} WHERE TRUE;"),
|
||||
Some(
|
||||
DatabaseType::Cassandra
|
||||
| DatabaseType::Hive
|
||||
@@ -518,9 +548,18 @@ pub fn build_empty_table_sql(options: TableAdminSqlOptions) -> String {
|
||||
}
|
||||
|
||||
pub fn build_truncate_table_sql(options: TableAdminSqlOptions) -> String {
|
||||
let table = qualified_name(options.database_type, options.schema.as_deref(), &options.table_name);
|
||||
let table = qualified_name_with_quote(
|
||||
options.database_type,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
options.identifier_quote.as_deref(),
|
||||
);
|
||||
if matches!(options.database_type, Some(DatabaseType::Iotdb)) {
|
||||
format!("DELETE FROM {};", iotdb_timeseries_pattern(&table))
|
||||
} else if matches!(options.database_type, Some(DatabaseType::Spanner)) {
|
||||
// Spanner has no TRUNCATE statement (it reports `Unknown statement`), and its DML
|
||||
// requires a WHERE clause. Unlike BigQuery it cannot use the TRUNCATE fallback.
|
||||
format!("DELETE FROM {table} WHERE TRUE;")
|
||||
} else if matches!(options.database_type, Some(DatabaseType::Sqlite | DatabaseType::DuckDb)) {
|
||||
format!("DELETE FROM {table};")
|
||||
} else {
|
||||
@@ -688,7 +727,12 @@ pub fn build_drop_schema_sql(options: SchemaNameSqlOptions) -> String {
|
||||
}
|
||||
|
||||
pub fn build_duplicate_table_structure_sql(options: DuplicateTableStructureSqlOptions) -> String {
|
||||
let source = qualified_name(options.database_type, options.schema.as_deref(), &options.source_name);
|
||||
let source = qualified_name_with_quote(
|
||||
options.database_type,
|
||||
options.schema.as_deref(),
|
||||
&options.source_name,
|
||||
options.identifier_quote.as_deref(),
|
||||
);
|
||||
let target =
|
||||
qualified_duplicate_target_name(options.database_type, options.schema.as_deref(), &options.target_name);
|
||||
let structure_sql =
|
||||
@@ -733,11 +777,21 @@ pub fn build_duplicate_table_structure_sql(options: DuplicateTableStructureSqlOp
|
||||
}
|
||||
|
||||
pub fn build_copy_table_data_sql(options: CopyTableDataSqlOptions) -> String {
|
||||
let source = qualified_name(options.database_type, options.schema.as_deref(), &options.source_name);
|
||||
let source = qualified_name_with_quote(
|
||||
options.database_type,
|
||||
options.schema.as_deref(),
|
||||
&options.source_name,
|
||||
options.identifier_quote.as_deref(),
|
||||
);
|
||||
let target = if options.normalize_new_target_name {
|
||||
qualified_duplicate_target_name(options.database_type, options.schema.as_deref(), &options.target_name)
|
||||
} else {
|
||||
qualified_name(options.database_type, options.schema.as_deref(), &options.target_name)
|
||||
qualified_name_with_quote(
|
||||
options.database_type,
|
||||
options.schema.as_deref(),
|
||||
&options.target_name,
|
||||
options.identifier_quote.as_deref(),
|
||||
)
|
||||
};
|
||||
let Some(columns) = options.columns.filter(|columns| !columns.is_empty()) else {
|
||||
return format!("INSERT INTO {target} SELECT * FROM {source};");
|
||||
@@ -931,6 +985,33 @@ fn qualified_name(database_type: Option<DatabaseType>, schema: Option<&str>, nam
|
||||
}
|
||||
}
|
||||
|
||||
/// Same as [`qualified_name`], but honours the quote character the connected server reported.
|
||||
///
|
||||
/// The static per-type mapping cannot describe a database whose quote depends on the connection.
|
||||
/// Cloud Spanner is the case that forces this: GoogleSQL quotes with backticks while its PostgreSQL
|
||||
/// dialect quotes with double quotes, the dialect is fixed when the database is created, and only
|
||||
/// the connected agent knows which one applies. Emitting the static answer produces admin SQL that
|
||||
/// is a syntax error for every PostgreSQL-dialect Spanner database.
|
||||
///
|
||||
/// Falls back to [`qualified_name`] whenever no connection quote applies, so every other database
|
||||
/// type — and Spanner itself before the agent has reported a quote — keeps its existing output.
|
||||
fn qualified_name_with_quote(
|
||||
database_type: Option<DatabaseType>,
|
||||
schema: Option<&str>,
|
||||
name: &str,
|
||||
identifier_quote: Option<&str>,
|
||||
) -> String {
|
||||
if !uses_connection_identifier_quote(database_type, identifier_quote) {
|
||||
return qualified_name(database_type, schema, name);
|
||||
}
|
||||
let quoted = |value: &str| quote_table_data_identifier(database_type, value, identifier_quote);
|
||||
if database_type.is_some_and(is_schema_aware) && schema.is_some_and(|schema| !schema.is_empty()) {
|
||||
format!("{}.{}", quoted(schema.unwrap()), quoted(name))
|
||||
} else {
|
||||
quoted(name)
|
||||
}
|
||||
}
|
||||
|
||||
fn qualified_duplicate_target_name(database_type: Option<DatabaseType>, schema: Option<&str>, name: &str) -> String {
|
||||
if database_type != Some(DatabaseType::Dameng) {
|
||||
return qualified_name(database_type, schema, name);
|
||||
@@ -1402,6 +1483,7 @@ mod tests {
|
||||
schema: Some("public".to_string()),
|
||||
table_name: "events".to_string(),
|
||||
cascade: None,
|
||||
identifier_quote: None,
|
||||
};
|
||||
assert_eq!(build_drop_table_sql(options.clone()), "DROP TABLE \"public\".\"events\";");
|
||||
assert_eq!(
|
||||
@@ -1410,6 +1492,7 @@ mod tests {
|
||||
schema: Some("public".to_string()),
|
||||
table_name: "events".to_string(),
|
||||
cascade: Some(true),
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"DROP TABLE \"public\".\"events\" CASCADE;"
|
||||
);
|
||||
@@ -1419,6 +1502,7 @@ mod tests {
|
||||
schema: None,
|
||||
table_name: "events".to_string(),
|
||||
cascade: Some(true),
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"DROP TABLE `events`;"
|
||||
);
|
||||
@@ -1430,6 +1514,7 @@ mod tests {
|
||||
schema: Some("public".to_string()),
|
||||
table_name: "events".to_string(),
|
||||
cascade: Some(true),
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"TRUNCATE TABLE \"public\".\"events\" CASCADE;"
|
||||
);
|
||||
@@ -1439,6 +1524,7 @@ mod tests {
|
||||
schema: None,
|
||||
table_name: "events".to_string(),
|
||||
cascade: Some(true),
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"TRUNCATE TABLE `events`;"
|
||||
);
|
||||
@@ -1448,6 +1534,7 @@ mod tests {
|
||||
schema: None,
|
||||
table_name: "PresetSubjectInfo".to_string(),
|
||||
cascade: None,
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"ALTER TABLE `PresetSubjectInfo` DELETE WHERE 1 = 1;"
|
||||
);
|
||||
@@ -1457,6 +1544,7 @@ mod tests {
|
||||
schema: None,
|
||||
table_name: "PresetSubjectInfo".to_string(),
|
||||
cascade: None,
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"TRUNCATE TABLE `PresetSubjectInfo`;"
|
||||
);
|
||||
@@ -1466,15 +1554,84 @@ mod tests {
|
||||
schema: None,
|
||||
table_name: "events".to_string(),
|
||||
cascade: None,
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"DELETE FROM `events` WHERE TRUE;"
|
||||
);
|
||||
// Spanner rejects both a WHERE-less DELETE and TRUNCATE TABLE (`Unknown statement`),
|
||||
// so unlike BigQuery it must not fall through to the TRUNCATE default.
|
||||
assert_eq!(
|
||||
build_empty_table_sql(TableAdminSqlOptions {
|
||||
database_type: Some(DatabaseType::Spanner),
|
||||
schema: None,
|
||||
table_name: "events".to_string(),
|
||||
cascade: None,
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"DELETE FROM `events` WHERE TRUE;"
|
||||
);
|
||||
assert_eq!(
|
||||
build_truncate_table_sql(TableAdminSqlOptions {
|
||||
database_type: Some(DatabaseType::Spanner),
|
||||
schema: None,
|
||||
table_name: "events".to_string(),
|
||||
cascade: None,
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"DELETE FROM `events` WHERE TRUE;"
|
||||
);
|
||||
// A `public` schema means the PostgreSQL dialect, where backticks are a syntax error, so the
|
||||
// quote has to come from the connection rather than from the static per-type mapping.
|
||||
assert_eq!(
|
||||
build_truncate_table_sql(TableAdminSqlOptions {
|
||||
database_type: Some(DatabaseType::Spanner),
|
||||
schema: Some("public".to_string()),
|
||||
table_name: "events".to_string(),
|
||||
cascade: Some(true),
|
||||
identifier_quote: Some("\"".to_string()),
|
||||
}),
|
||||
"DELETE FROM \"public\".\"events\" WHERE TRUE;"
|
||||
);
|
||||
assert_eq!(
|
||||
build_empty_table_sql(TableAdminSqlOptions {
|
||||
database_type: Some(DatabaseType::Spanner),
|
||||
schema: Some("public".to_string()),
|
||||
table_name: "events".to_string(),
|
||||
cascade: None,
|
||||
identifier_quote: Some("\"".to_string()),
|
||||
}),
|
||||
"DELETE FROM \"public\".\"events\" WHERE TRUE;"
|
||||
);
|
||||
// GoogleSQL reports a backtick, which matches the static mapping.
|
||||
assert_eq!(
|
||||
build_truncate_table_sql(TableAdminSqlOptions {
|
||||
database_type: Some(DatabaseType::Spanner),
|
||||
schema: None,
|
||||
table_name: "OrderItems".to_string(),
|
||||
cascade: None,
|
||||
identifier_quote: Some("`".to_string()),
|
||||
}),
|
||||
"DELETE FROM `OrderItems` WHERE TRUE;"
|
||||
);
|
||||
// Before the agent has reported a quote the static GoogleSQL answer is the only one
|
||||
// available; this documents the fallback rather than a PostgreSQL-dialect expectation.
|
||||
assert_eq!(
|
||||
build_truncate_table_sql(TableAdminSqlOptions {
|
||||
database_type: Some(DatabaseType::Spanner),
|
||||
schema: Some("public".to_string()),
|
||||
table_name: "events".to_string(),
|
||||
cascade: None,
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"DELETE FROM `public`.`events` WHERE TRUE;"
|
||||
);
|
||||
assert_eq!(
|
||||
build_empty_table_sql(TableAdminSqlOptions {
|
||||
database_type: Some(DatabaseType::Cassandra),
|
||||
schema: None,
|
||||
table_name: "events".to_string(),
|
||||
cascade: None,
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"TRUNCATE TABLE \"events\";"
|
||||
);
|
||||
@@ -1484,6 +1641,7 @@ mod tests {
|
||||
schema: None,
|
||||
table_name: "events".to_string(),
|
||||
cascade: None,
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"DELETE FROM \"events\";"
|
||||
);
|
||||
@@ -1493,6 +1651,7 @@ mod tests {
|
||||
schema: Some("root.test".to_string()),
|
||||
table_name: "DCU_101".to_string(),
|
||||
cascade: None,
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"DELETE TIMESERIES root.test.DCU_101.*;"
|
||||
);
|
||||
@@ -1502,6 +1661,7 @@ mod tests {
|
||||
schema: Some("root.test".to_string()),
|
||||
table_name: "root.test.DCU_101".to_string(),
|
||||
cascade: None,
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"DELETE FROM root.test.DCU_101.*;"
|
||||
);
|
||||
@@ -1511,6 +1671,7 @@ mod tests {
|
||||
schema: Some("root.test".to_string()),
|
||||
table_name: "DCU_101".to_string(),
|
||||
cascade: None,
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"DELETE FROM root.test.DCU_101.*;"
|
||||
);
|
||||
@@ -1521,6 +1682,7 @@ mod tests {
|
||||
schema: None,
|
||||
table_name: "table_sample".to_string(),
|
||||
cascade: None,
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"TRUNCATE TABLE `table_sample`;"
|
||||
);
|
||||
@@ -1530,6 +1692,7 @@ mod tests {
|
||||
schema: None,
|
||||
table_name: "table_sample".to_string(),
|
||||
cascade: None,
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"TRUNCATE TABLE `table_sample`;"
|
||||
);
|
||||
@@ -1594,6 +1757,7 @@ mod tests {
|
||||
schema: Some("dbo".to_string()),
|
||||
name: "refresh_cache".to_string(),
|
||||
signature: None,
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"DROP PROCEDURE [dbo].[refresh_cache];"
|
||||
);
|
||||
@@ -1604,6 +1768,7 @@ mod tests {
|
||||
schema: Some("public".to_string()),
|
||||
name: "calc".to_string(),
|
||||
signature: Some("integer, integer".to_string()),
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"DROP FUNCTION \"public\".\"calc\"(integer, integer);"
|
||||
);
|
||||
@@ -1771,6 +1936,7 @@ mod tests {
|
||||
target_name: "users_copy".to_string(),
|
||||
table_comment: None,
|
||||
column_comments: vec![],
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"CREATE TABLE `users_copy` LIKE `users`;"
|
||||
);
|
||||
@@ -1782,6 +1948,7 @@ mod tests {
|
||||
target_name: "users_copy".to_string(),
|
||||
table_comment: None,
|
||||
column_comments: vec![],
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"CREATE TABLE \"public\".\"users_copy\" (LIKE \"public\".\"users\" INCLUDING ALL);"
|
||||
);
|
||||
@@ -1793,6 +1960,7 @@ mod tests {
|
||||
target_name: "connection_test_copy".to_string(),
|
||||
table_comment: None,
|
||||
column_comments: vec![],
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"CREATE TABLE `dbx_demo`.`connection_test_copy` LIKE `dbx_demo`.`connection_test`;"
|
||||
);
|
||||
@@ -1804,6 +1972,7 @@ mod tests {
|
||||
target_name: "orders_copy".to_string(),
|
||||
table_comment: None,
|
||||
column_comments: vec![],
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"CREATE TABLE `dbx_demo`.`orders_copy` LIKE `dbx_demo`.`orders`;"
|
||||
);
|
||||
@@ -1815,6 +1984,7 @@ mod tests {
|
||||
target_name: "customer_orders_copy".to_string(),
|
||||
table_comment: Some(" Customer's orders; archive ".to_string()),
|
||||
column_comments: vec![],
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"CREATE TABLE \"public\".\"customer_orders_copy\" (LIKE \"public\".\"customer_orders\" INCLUDING ALL);\nCOMMENT ON TABLE \"public\".\"customer_orders_copy\" IS ' Customer''s orders; archive ';"
|
||||
);
|
||||
@@ -1826,6 +1996,7 @@ mod tests {
|
||||
target_name: "users_copy".to_string(),
|
||||
table_comment: None,
|
||||
column_comments: vec![],
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"CREATE TABLE \"public\".\"users_copy\" (LIKE \"public\".\"users\" INCLUDING ALL);"
|
||||
);
|
||||
@@ -1837,6 +2008,7 @@ mod tests {
|
||||
target_name: "users_copy".to_string(),
|
||||
table_comment: None,
|
||||
column_comments: vec![],
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"SELECT TOP 0 * INTO [dbo].[users_copy] FROM [dbo].[users];"
|
||||
);
|
||||
@@ -1848,6 +2020,7 @@ mod tests {
|
||||
target_name: "USERS_COPY".to_string(),
|
||||
table_comment: None,
|
||||
column_comments: vec![],
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"CREATE TABLE \"HR\".\"USERS_COPY\" AS SELECT * FROM \"HR\".\"USERS\" WHERE 1=0"
|
||||
);
|
||||
@@ -1865,6 +2038,7 @@ mod tests {
|
||||
DuplicateTableColumnComment { name: "STATUS".to_string(), comment: "active ".to_string() },
|
||||
DuplicateTableColumnComment { name: "EMPTY".to_string(), comment: " \t\n".to_string() },
|
||||
],
|
||||
identifier_quote: None,
|
||||
});
|
||||
assert_eq!(
|
||||
dameng_sql,
|
||||
@@ -1887,6 +2061,7 @@ mod tests {
|
||||
target_name: "users_copy".to_string(),
|
||||
table_comment: Some("line1\\path\nline2".to_string()),
|
||||
column_comments: vec![],
|
||||
identifier_quote: None,
|
||||
});
|
||||
assert_eq!(
|
||||
dameng_escape_sql,
|
||||
@@ -1901,6 +2076,7 @@ mod tests {
|
||||
target_name: "UsersCopy".to_string(),
|
||||
table_comment: None,
|
||||
column_comments: vec![],
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"CREATE TABLE \"APP\".\"UsersCopy\" AS SELECT * FROM \"APP\".\"USERS\" WHERE 1=0"
|
||||
);
|
||||
@@ -1918,6 +2094,7 @@ mod tests {
|
||||
target_name: "copy".to_string(),
|
||||
table_comment: Some("owner\\'s; archive".to_string()),
|
||||
column_comments: vec![],
|
||||
identifier_quote: None,
|
||||
});
|
||||
let expected_literal = if database_type == DatabaseType::Redshift {
|
||||
"'owner\\\\\\'s; archive'"
|
||||
@@ -1941,6 +2118,7 @@ mod tests {
|
||||
target_name: "tb_a_copy".to_string(),
|
||||
table_comment: None,
|
||||
column_comments: vec![],
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"CREATE TABLE \"SQLUSER\".\"tb_a_copy\" AS SELECT * FROM \"SQLUSER\".\"tb_a\" WHERE 1=0"
|
||||
);
|
||||
@@ -1952,6 +2130,7 @@ mod tests {
|
||||
target_name: "users_copy".to_string(),
|
||||
table_comment: Some("ignored by QuestDB".to_string()),
|
||||
column_comments: vec![],
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"CREATE TABLE `users_copy` (LIKE `users`);"
|
||||
);
|
||||
@@ -1969,6 +2148,7 @@ mod tests {
|
||||
postgres_overriding_system_value: false,
|
||||
sqlserver_identity_insert: false,
|
||||
normalize_new_target_name: false,
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"INSERT INTO \"users_copy\" SELECT * FROM \"users\";"
|
||||
);
|
||||
@@ -1982,6 +2162,7 @@ mod tests {
|
||||
postgres_overriding_system_value: false,
|
||||
sqlserver_identity_insert: false,
|
||||
normalize_new_target_name: false,
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"INSERT INTO `users_copy` (`id`, `name`) SELECT `id`, `name` FROM `users`;"
|
||||
);
|
||||
@@ -1995,6 +2176,7 @@ mod tests {
|
||||
postgres_overriding_system_value: true,
|
||||
sqlserver_identity_insert: false,
|
||||
normalize_new_target_name: false,
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"INSERT INTO \"public\".\"users_copy\" (\"id\", \"name\") OVERRIDING SYSTEM VALUE SELECT \"id\", \"name\" FROM \"public\".\"users\";"
|
||||
);
|
||||
@@ -2008,6 +2190,7 @@ mod tests {
|
||||
postgres_overriding_system_value: false,
|
||||
sqlserver_identity_insert: true,
|
||||
normalize_new_target_name: false,
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"SET IDENTITY_INSERT [dbo].[users_copy] ON;\nINSERT INTO [dbo].[users_copy] ([id], [name]) SELECT [id], [name] FROM [dbo].[users];\nSET IDENTITY_INSERT [dbo].[users_copy] OFF;"
|
||||
);
|
||||
@@ -2021,6 +2204,7 @@ mod tests {
|
||||
postgres_overriding_system_value: false,
|
||||
sqlserver_identity_insert: false,
|
||||
normalize_new_target_name: true,
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"INSERT INTO \"APP\".USERS_COPY SELECT * FROM \"APP\".\"users\";"
|
||||
);
|
||||
@@ -2034,6 +2218,7 @@ mod tests {
|
||||
postgres_overriding_system_value: false,
|
||||
sqlserver_identity_insert: false,
|
||||
normalize_new_target_name: false,
|
||||
identifier_quote: None,
|
||||
}),
|
||||
"INSERT INTO \"APP\".\"users_copy\" SELECT * FROM \"APP\".\"users\";"
|
||||
);
|
||||
|
||||
@@ -577,6 +577,8 @@ pub enum DatabaseType {
|
||||
Cassandra,
|
||||
#[serde(rename = "bigquery")]
|
||||
Bigquery,
|
||||
#[serde(rename = "spanner")]
|
||||
Spanner,
|
||||
Kylin,
|
||||
Sundb,
|
||||
Oscar,
|
||||
@@ -929,12 +931,22 @@ impl ConnectionConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cloud Spanner schema changes are long-running operations rather than plain
|
||||
/// statements. Measured against the real service, `CREATE INDEX` on an *empty*
|
||||
/// table took 22.9-33.6s over seven runs, so the generic default fails
|
||||
/// intermittently — and the failure is misleading, because the operation keeps
|
||||
/// running server-side and completes, leaving a retry to report
|
||||
/// `Duplicate name in schema`. Raising a floor mirrors what
|
||||
/// `connection::agent_connect_timeout` already does for Access.
|
||||
const SPANNER_MIN_QUERY_TIMEOUT_SECS: u64 = 120;
|
||||
|
||||
pub fn effective_query_timeout_secs(&self) -> u64 {
|
||||
if self.query_timeout_secs == 0 {
|
||||
0
|
||||
} else {
|
||||
self.query_timeout_secs.max(1)
|
||||
// An explicit 0 is the UI's "no limit"; a floor must not impose one.
|
||||
return 0;
|
||||
}
|
||||
let floor = if self.db_type == DatabaseType::Spanner { Self::SPANNER_MIN_QUERY_TIMEOUT_SECS } else { 1 };
|
||||
self.query_timeout_secs.max(floor)
|
||||
}
|
||||
|
||||
pub fn effective_database(&self) -> Option<&str> {
|
||||
@@ -1176,6 +1188,7 @@ impl ConnectionConfig {
|
||||
DatabaseType::Neo4j => format!("neo4j://{host}:{port}{db_part}"),
|
||||
DatabaseType::Cassandra => format!("cassandra://{host}:{port}{db_part}"),
|
||||
DatabaseType::Bigquery => format!("bigquery://{host}/{db_part}"),
|
||||
DatabaseType::Spanner => self.spanner_display_url(&host, port),
|
||||
DatabaseType::Kylin => format!("kylin://{host}:{port}{db_part}"),
|
||||
DatabaseType::Sundb => format!("sundb://{host}:{port}{db_part}"),
|
||||
DatabaseType::Oscar => format!("oscar://{host}:{port}{db_part}"),
|
||||
@@ -1426,6 +1439,9 @@ impl ConnectionConfig {
|
||||
DatabaseType::Bigquery => {
|
||||
format!("bigquery://{}:{}@{host}/{db_part}", username, password)
|
||||
}
|
||||
// Spanner authenticates through driver properties (`credentials=…`), never
|
||||
// through username/password, so the display URL carries neither.
|
||||
DatabaseType::Spanner => self.spanner_display_url(&host, port),
|
||||
DatabaseType::Kylin => {
|
||||
format!("kylin://{}:{}@{host}:{port}{db_part}", username, password)
|
||||
}
|
||||
@@ -1480,6 +1496,25 @@ impl ConnectionConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Display-only URL for Cloud Spanner connections.
|
||||
///
|
||||
/// Spanner is agent-backed: the real JDBC URL is assembled inside the Spanner
|
||||
/// agent from the structured `ConnectParams`, so this string is used purely for
|
||||
/// the sidebar / connection card / logs. `database` holds the full resource path
|
||||
/// `projects/{p}/instances/{i}/databases/{d}`, which the shared
|
||||
/// [`encode_url_part`] would mangle into `projects%2Fmy%2Dproject%2F…` because it
|
||||
/// escapes every non-alphanumeric byte. [`encode_spanner_resource_path`] keeps the
|
||||
/// separators and unreserved characters readable instead. `host` is empty for real
|
||||
/// Cloud Spanner and only set for the emulator or a custom endpoint.
|
||||
fn spanner_display_url(&self, host: &str, port: u16) -> String {
|
||||
let path = self.effective_database().map(encode_spanner_resource_path).unwrap_or_default();
|
||||
if host.is_empty() {
|
||||
format!("spanner:///{path}")
|
||||
} else {
|
||||
format!("spanner://{host}:{port}/{path}")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sqlserver_port_explicit(&self) -> bool {
|
||||
if self.db_type != DatabaseType::SqlServer {
|
||||
return false;
|
||||
@@ -2402,6 +2437,30 @@ fn encode_url_part(value: &str) -> String {
|
||||
utf8_percent_encode(value, NON_ALPHANUMERIC).to_string()
|
||||
}
|
||||
|
||||
/// Percent-encode set for Spanner resource paths: everything [`NON_ALPHANUMERIC`]
|
||||
/// escapes, minus the RFC 3986 *unreserved* characters, which must stay literal.
|
||||
///
|
||||
/// `-` matters most in practice: GCP project and instance IDs allow only lowercase
|
||||
/// letters, digits and hyphens, so without this a hyphenated project would render as
|
||||
/// `my%2Dproject` in every Spanner connection.
|
||||
const SPANNER_PATH_ENCODE_SET: &percent_encoding::AsciiSet =
|
||||
&NON_ALPHANUMERIC.remove(b'-').remove(b'_').remove(b'.').remove(b'~');
|
||||
|
||||
/// Percent-encode a Spanner resource path, keeping the `/` separators and the RFC 3986
|
||||
/// unreserved characters literal while still escaping everything else (spaces become
|
||||
/// `%20`).
|
||||
///
|
||||
/// Display-only. Native drivers must keep using [`encode_url_part`] — their URLs are
|
||||
/// real DSNs, where an unescaped `/` or `-` in a database name can change how the
|
||||
/// connection string parses.
|
||||
fn encode_spanner_resource_path(value: &str) -> String {
|
||||
value
|
||||
.split('/')
|
||||
.map(|segment| utf8_percent_encode(segment, SPANNER_PATH_ENCODE_SET).to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
fn bracket_ipv6(host: &str) -> String {
|
||||
if host.contains(':') && !host.starts_with('[') {
|
||||
format!("[{host}]")
|
||||
@@ -2698,6 +2757,32 @@ mod tests {
|
||||
assert_eq!(config.connection_url(), "postgres://root:secret@10.1.2.3:2883/%20analytics%20?sslmode=prefer");
|
||||
}
|
||||
|
||||
/// Spanner keeps the full resource path in `database`. Its URL is display-only (the
|
||||
/// agent builds the real JDBC URL), so the separators and the RFC 3986 unreserved
|
||||
/// characters must stay literal instead of being mangled into
|
||||
/// `projects%2Fmy%2Dproject%2F…` by the shared `encode_url_part`. Hyphens matter in
|
||||
/// practice: GCP project and instance IDs allow only lowercase letters, digits and
|
||||
/// hyphens. Characters that genuinely need escaping (here a space) still are.
|
||||
#[test]
|
||||
fn spanner_display_url_keeps_resource_path_separators() {
|
||||
let mut config = mysql_config("", "", Some("projects/my-project/instances/my-instance/databases/my db"));
|
||||
config.db_type = DatabaseType::Spanner;
|
||||
config.host = String::new();
|
||||
config.port = 443;
|
||||
|
||||
assert_eq!(config.connection_url(), "spanner:///projects/my-project/instances/my-instance/databases/my%20db");
|
||||
// Display and redacted forms match: Spanner carries no credentials in the URL.
|
||||
assert_eq!(config.redacted_connection_url(), config.connection_url());
|
||||
|
||||
// Emulator / custom endpoint: the host and port are shown.
|
||||
config.host = "localhost".to_string();
|
||||
config.port = 9010;
|
||||
assert_eq!(
|
||||
config.connection_url(),
|
||||
"spanner://localhost:9010/projects/my-project/instances/my-instance/databases/my%20db"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_only_database_uses_database_type_default() {
|
||||
let mut config = mysql_config("root", "secret", Some(" "));
|
||||
@@ -3002,6 +3087,45 @@ mod tests {
|
||||
assert_eq!(config.effective_query_timeout_secs(), 3600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spanner_query_timeout_floor_covers_schema_change_latency() {
|
||||
// A Cloud Spanner CREATE INDEX is a long-running operation: measured against
|
||||
// the real service it took 22.9-33.6s on an empty table, so the generic
|
||||
// desktop default of 30s fails intermittently.
|
||||
let mut config = mysql_config("root", "", None);
|
||||
config.db_type = DatabaseType::Spanner;
|
||||
config.query_timeout_secs = 30;
|
||||
|
||||
assert_eq!(config.effective_query_timeout_secs(), 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spanner_query_timeout_floor_never_lowers_a_higher_setting() {
|
||||
let mut config = mysql_config("root", "", None);
|
||||
config.db_type = DatabaseType::Spanner;
|
||||
config.query_timeout_secs = 600;
|
||||
|
||||
assert_eq!(config.effective_query_timeout_secs(), 600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spanner_query_timeout_floor_respects_the_no_limit_setting() {
|
||||
// 0 is the UI's "unlimited"; a floor must not turn that into a 120s cap.
|
||||
let mut config = mysql_config("root", "", None);
|
||||
config.db_type = DatabaseType::Spanner;
|
||||
config.query_timeout_secs = 0;
|
||||
|
||||
assert_eq!(config.effective_query_timeout_secs(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_timeout_floor_is_scoped_to_spanner() {
|
||||
let mut config = mysql_config("root", "", None);
|
||||
config.query_timeout_secs = 30;
|
||||
|
||||
assert_eq!(config.effective_query_timeout_secs(), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_url_encodes_proxy_style_username_with_colons_and_at() {
|
||||
let config = mysql_config("1234:xxx@xx.xx:abc123", "p@ss:word", None);
|
||||
|
||||
@@ -426,6 +426,9 @@ fn schema_first_qualifier_type(db_type: &DatabaseType) -> bool {
|
||||
| DatabaseType::PrestoSql
|
||||
| DatabaseType::Databricks
|
||||
| DatabaseType::Bigquery
|
||||
// A Spanner JDBC connection is bound to a single database, so there is no
|
||||
// cross-database `db.table` syntax — the first part of `a.b` is always a schema.
|
||||
| DatabaseType::Spanner
|
||||
)
|
||||
}
|
||||
|
||||
@@ -768,6 +771,18 @@ mod tests {
|
||||
assert!(!targets_production_database(&sqlserver, "staging", "DELETE FROM prod_app.users WHERE id = 1"));
|
||||
}
|
||||
|
||||
/// A Spanner JDBC connection is bound to exactly one database, so `a.b` can only be
|
||||
/// `schema.table`. Treating `a` as a database name would match a database that cannot
|
||||
/// be addressed from this connection at all.
|
||||
#[test]
|
||||
fn resolves_spanner_qualifiers_as_schema_not_database() {
|
||||
let mut spanner = config();
|
||||
spanner.db_type = DatabaseType::Spanner;
|
||||
|
||||
assert!(!targets_production_database(&spanner, "staging", "DELETE FROM prod_app.users WHERE TRUE"));
|
||||
assert!(targets_production_database(&spanner, "prod_app", "DELETE FROM public.users WHERE TRUE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_cross_database_mongo_aggregate_write_targets() {
|
||||
let mut mongo = config();
|
||||
|
||||
@@ -646,7 +646,9 @@ impl DbOperationBudget {
|
||||
}
|
||||
|
||||
pub fn from_connection_config(config: &ConnectionConfig) -> Self {
|
||||
Self::from_config(config.effective_connect_timeout_secs(), Some(config.query_timeout_secs))
|
||||
// effective_query_timeout_secs, not the raw field: it preserves 0 ("no limit")
|
||||
// and applies the per-database-type floor Cloud Spanner needs for DDL.
|
||||
Self::from_config(config.effective_connect_timeout_secs(), Some(config.effective_query_timeout_secs()))
|
||||
}
|
||||
|
||||
/// Use global default values (when no connection config is available).
|
||||
|
||||
@@ -63,6 +63,11 @@ pub fn is_schema_aware(database_type: DatabaseType) -> bool {
|
||||
| DatabaseType::Sqlite
|
||||
| DatabaseType::DuckDb
|
||||
| DatabaseType::Iris
|
||||
// Spanner supports named schemas; the PostgreSQL dialect defaults to `public`.
|
||||
// GoogleSQL's default schema is the empty string, which the blank-schema filters
|
||||
// in `qualified_table_name` / `table_data_qualified_table_name` drop along with
|
||||
// the dot separator (`` `s`.`t` `` with an empty `s` is a Spanner syntax error).
|
||||
| DatabaseType::Spanner
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -670,9 +670,8 @@ pub fn profile_for(db_type: DatabaseType) -> DdlDialectProfile {
|
||||
ClickHouse => clickhouse_profile(),
|
||||
|
||||
DuckDb | Questdb | SapHana | Teradata | Snowflake | Trino | PrestoSql | Hive | Kyuubi | Impala | Spark
|
||||
| Db2 | Informix | Bigquery | Kylin | Ignite | Ignite3 | Oscar | Tdengine | Iotdb | Databricks | Jdbc => {
|
||||
conservative_ansi(db_type)
|
||||
}
|
||||
| Db2 | Informix | Bigquery | Spanner | Kylin | Ignite | Ignite3 | Oscar | Tdengine | Iotdb | Databricks
|
||||
| Jdbc => conservative_ansi(db_type),
|
||||
|
||||
// Non-tabular / not applicable for relational CREATE TABLE
|
||||
Redis | MongoDb | DynamoDb | Elasticsearch | Easysearch | Meilisearch | Qdrant | Milvus | Weaviate
|
||||
|
||||
@@ -136,6 +136,11 @@ pub fn quote_table_identifier(database_type: Option<DatabaseType>, name: &str) -
|
||||
| DatabaseType::Tdengine
|
||||
| DatabaseType::Access
|
||||
| DatabaseType::Bigquery
|
||||
// GoogleSQL (Spanner's default dialect) quotes identifiers with backticks;
|
||||
// double quotes are string literals there, so `SELECT * FROM "users"` is a
|
||||
// syntax error. PostgreSQL-dialect Spanner databases override this through
|
||||
// the identifier quote reported by the agent on connect.
|
||||
| DatabaseType::Spanner
|
||||
| DatabaseType::Questdb,
|
||||
) => {
|
||||
format!("`{}`", name.replace('`', "``"))
|
||||
|
||||
@@ -385,6 +385,11 @@ pub(crate) fn uses_connection_identifier_quote(
|
||||
identifier_quote: Option<&str>,
|
||||
) -> bool {
|
||||
database_type == Some(DatabaseType::Kingbase)
|
||||
// Spanner is dual-dialect: GoogleSQL uses backticks, the PostgreSQL dialect uses
|
||||
// double quotes, and only the connected agent knows which. Unconditional like
|
||||
// Kingbase — when no quote was reported the callers fall back to
|
||||
// `quote_table_identifier`, whose static mapping is GoogleSQL-correct.
|
||||
|| database_type == Some(DatabaseType::Spanner)
|
||||
|| (database_type == Some(DatabaseType::Informix) && identifier_quote.is_some())
|
||||
|| (matches!(database_type, Some(DatabaseType::Gaussdb | DatabaseType::OpenGauss | DatabaseType::Postgres))
|
||||
&& identifier_quote.is_some())
|
||||
|
||||
@@ -34,6 +34,37 @@ fn quotes_identifiers_by_database_type() {
|
||||
assert_eq!(quote_table_identifier(Some(DatabaseType::Jdbc), "users_1"), "users_1");
|
||||
assert_eq!(quote_table_identifier(Some(DatabaseType::Jdbc), "user name"), "user name");
|
||||
assert_eq!(quote_table_identifier(Some(DatabaseType::Iotdb), "root.test.device2"), "root.test.device2");
|
||||
assert_eq!(quote_table_identifier(Some(DatabaseType::Spanner), "user`name"), "`user``name`");
|
||||
}
|
||||
|
||||
/// Spanner databases are created in one of two immutable dialects. The connected
|
||||
/// agent reports the correct identifier quote; when it is missing the static mapping
|
||||
/// must fall back to GoogleSQL (backticks), because GoogleSQL treats double quotes as
|
||||
/// string literals and would reject `SELECT * FROM "users"`.
|
||||
#[test]
|
||||
fn quotes_spanner_identifiers_by_connection_dialect() {
|
||||
// GoogleSQL dialect: agent reports a backtick.
|
||||
assert_eq!(quote_table_data_identifier(Some(DatabaseType::Spanner), "order", Some("`")), "`order`");
|
||||
assert_eq!(quote_table_data_identifier(Some(DatabaseType::Spanner), "user`name", Some("`")), "`user``name`");
|
||||
|
||||
// PostgreSQL dialect: agent reports a double quote.
|
||||
assert_eq!(quote_table_data_identifier(Some(DatabaseType::Spanner), "order", Some("\"")), "\"order\"");
|
||||
assert_eq!(quote_table_data_identifier(Some(DatabaseType::Spanner), "user\"name", Some("\"")), "\"user\"\"name\"");
|
||||
|
||||
// No quote reported (metadata probe failed / caller outside the desktop store):
|
||||
// fall back to the GoogleSQL default rather than the ANSI double quote.
|
||||
assert_eq!(quote_table_data_identifier(Some(DatabaseType::Spanner), "order", None), "`order`");
|
||||
|
||||
// GoogleSQL's default schema is the empty string and must not produce `` ``.`t` ``,
|
||||
// which Spanner rejects with `Invalid empty identifier`.
|
||||
assert_eq!(
|
||||
table_data_qualified_table_name(Some(DatabaseType::Spanner), Some(""), "singers", Some("`")),
|
||||
"`singers`"
|
||||
);
|
||||
assert_eq!(
|
||||
table_data_qualified_table_name(Some(DatabaseType::Spanner), Some("public"), "singers", Some("\"")),
|
||||
"\"public\".\"singers\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -82,6 +113,11 @@ fn qualifies_schema_only_for_schema_aware_databases() {
|
||||
assert_eq!(qualified_table_name(Some(DatabaseType::Informix), Some("xtdpcky"), "users"), "xtdpcky.users");
|
||||
assert_eq!(qualified_table_name(Some(DatabaseType::Sqlite), Some("analytics"), "users"), "\"analytics\".\"users\"");
|
||||
assert_eq!(qualified_table_name(Some(DatabaseType::Jdbc), Some("cbsdw_dwd"), "dwd_test_df"), "dwd_test_df");
|
||||
// GoogleSQL's default schema is the empty string: the qualifier (and its dot) must be
|
||||
// dropped entirely, otherwise Spanner reports `Invalid empty identifier`.
|
||||
assert_eq!(qualified_table_name(Some(DatabaseType::Spanner), Some(""), "users"), "`users`");
|
||||
assert_eq!(qualified_table_name(Some(DatabaseType::Spanner), None, "users"), "`users`");
|
||||
assert_eq!(qualified_table_name(Some(DatabaseType::Spanner), Some("public"), "users"), "`public`.`users`");
|
||||
assert_eq!(qualified_table_name(Some(DatabaseType::Iotdb), Some("root.test"), "device2"), "root.test.device2");
|
||||
assert_eq!(
|
||||
qualified_table_name(Some(DatabaseType::Iotdb), Some("root.test"), "root.test.device2"),
|
||||
@@ -127,6 +163,8 @@ fn maps_table_pagination_strategy_by_database_type() {
|
||||
TablePaginationStrategy::Unbounded
|
||||
);
|
||||
assert_eq!(table_pagination_strategy(Some(DatabaseType::Jdbc)), TablePaginationStrategy::AgentMaxRows);
|
||||
// Both Spanner dialects support `LIMIT n OFFSET m`; pin the fallback.
|
||||
assert_eq!(table_pagination_strategy(Some(DatabaseType::Spanner)), TablePaginationStrategy::LimitOffset);
|
||||
assert_eq!(table_pagination_strategy(None), TablePaginationStrategy::LimitOffset);
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ fn maps_agent_database_types_to_driver_keys() {
|
||||
assert_eq!(agent_key(&DatabaseType::InfluxDb, None), Some("influxdb"));
|
||||
assert_eq!(agent_key(&DatabaseType::Uxdb, None), Some("uxdb"));
|
||||
assert_eq!(agent_key(&DatabaseType::ZooKeeper, None), Some("zookeeper"));
|
||||
assert_eq!(agent_key(&DatabaseType::Spanner, None), Some("spanner"));
|
||||
assert_eq!(agent_key(&DatabaseType::Oracle, Some("oracle-legacy")), Some("oracle"));
|
||||
assert_eq!(agent_key(&DatabaseType::Oracle, Some("oracle-10g")), Some("oracle"));
|
||||
assert_eq!(agent_key(&DatabaseType::SqlServer, Some("sqlserver-legacy")), Some("sqlserver-legacy"));
|
||||
@@ -158,6 +159,7 @@ fn classifies_agent_database_types() {
|
||||
assert!(is_agent_type(&DatabaseType::Databend));
|
||||
assert!(is_agent_type(&DatabaseType::InfluxDb));
|
||||
assert!(is_agent_type(&DatabaseType::ZooKeeper));
|
||||
assert!(is_agent_type(&DatabaseType::Spanner));
|
||||
assert!(!is_agent_type(&DatabaseType::Mysql));
|
||||
assert!(!is_agent_type(&DatabaseType::Jdbc));
|
||||
assert!(!is_agent_type(&DatabaseType::Gaussdb));
|
||||
@@ -180,6 +182,8 @@ fn identifies_single_connection_pool_types() {
|
||||
assert!(is_single_connection_pool(&DatabaseType::Jdbc));
|
||||
assert!(is_single_connection_pool(&DatabaseType::VictoriaMetrics));
|
||||
assert!(!is_single_connection_pool(&DatabaseType::Trino));
|
||||
// Spanner is HikariCP-pooled in the agent; a single connection would be a regression.
|
||||
assert!(!is_single_connection_pool(&DatabaseType::Spanner));
|
||||
assert!(!is_single_connection_pool(&DatabaseType::Postgres));
|
||||
assert!(!is_single_connection_pool(&DatabaseType::Kwdb));
|
||||
}
|
||||
@@ -215,6 +219,8 @@ fn skips_tcp_probe_for_local_file_plugin_and_agent_types() {
|
||||
assert!(skips_tcp_probe(&DatabaseType::VictoriaMetrics));
|
||||
assert!(skips_tcp_probe(&DatabaseType::MessageQueue));
|
||||
assert!(skips_tcp_probe(&DatabaseType::ZooKeeper));
|
||||
// Real Cloud Spanner connections have no host to probe.
|
||||
assert!(skips_tcp_probe(&DatabaseType::Spanner));
|
||||
assert!(!skips_tcp_probe(&DatabaseType::Postgres));
|
||||
assert!(!skips_tcp_probe(&DatabaseType::Mysql));
|
||||
assert!(!skips_tcp_probe(&DatabaseType::Gaussdb));
|
||||
@@ -354,6 +360,30 @@ fn driver_manifest_declares_expected_product_capabilities() {
|
||||
assert!(!uxdb.capabilities.data_transfer);
|
||||
assert!(!uxdb.capabilities.user_admin);
|
||||
|
||||
let spanner = find_driver(DatabaseType::Spanner);
|
||||
assert_eq!(spanner.label, "Google Cloud Spanner");
|
||||
assert_eq!(spanner.runtime_mode, "agent");
|
||||
assert_eq!(spanner.agent_key.as_deref(), Some("spanner"));
|
||||
assert_eq!(spanner.support_level, "operate");
|
||||
assert!(spanner.capabilities.query_execution);
|
||||
assert!(spanner.capabilities.metadata_browse);
|
||||
assert!(spanner.capabilities.object_browser);
|
||||
assert!(spanner.capabilities.schema_search);
|
||||
assert!(spanner.capabilities.sql_file_execution);
|
||||
assert!(spanner.capabilities.driver_management);
|
||||
// Every table in Spanner has a primary key, so grid edits can locate rows reliably.
|
||||
assert!(spanner.capabilities.table_data_edit);
|
||||
// Structure edits need per-dialect DDL descriptors; database creation needs the
|
||||
// Admin API (`CreateDatabase` gRPC), which the JDBC driver cannot reach.
|
||||
assert!(!spanner.capabilities.table_structure_edit);
|
||||
assert!(!spanner.capabilities.database_create);
|
||||
assert!(!spanner.capabilities.table_import);
|
||||
assert!(!spanner.capabilities.data_transfer);
|
||||
assert!(!spanner.capabilities.object_source);
|
||||
assert!(!spanner.capabilities.diagram);
|
||||
assert!(!spanner.capabilities.sql_explain);
|
||||
assert!(!spanner.capabilities.user_admin);
|
||||
|
||||
let starrocks = find_driver(DatabaseType::StarRocks);
|
||||
assert!(starrocks.capabilities.user_admin);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ DBX 的新建连接窗口提供 90+ 数据库和服务 profile。Profile 决定
|
||||
- openGauss、GaussDB、KingbaseES、HighGo、Vastbase、CockroachDB 等 PostgreSQL 兼容 profile
|
||||
- DM、KWDB、YashanDB、GBase、Teradata、Vertica、Exasol、Firebird、SAP HANA 等厂商数据库
|
||||
- Snowflake、Trino、PrestoSQL、Hive、Spark、DB2、Informix、Databricks、BigQuery 等 Agent/JDBC 分析数据库
|
||||
- Google Cloud Spanner 等云托管关系型服务(Agent/JDBC,支持 GoogleSQL 与 PostgreSQL 两种方言)
|
||||
|
||||
兼容协议只说明连接和部分方言可以复用。数据库版本、系统表、DDL、权限和管理接口不同,DBX 会用独立 profile 或能力覆盖避免错误地启用功能。
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ The new-connection picker provides 90+ database and service profiles. A profile
|
||||
- PostgreSQL-compatible profiles such as openGauss, GaussDB, KingbaseES, HighGo, Vastbase, and CockroachDB
|
||||
- Vendor databases including DM, KWDB, YashanDB, GBase, Teradata, Vertica, Exasol, Firebird, and SAP HANA
|
||||
- Agent/JDBC analytics engines such as Snowflake, Trino, PrestoSQL, Hive, Spark, DB2, Informix, Databricks, and BigQuery
|
||||
- Cloud-managed relational services such as Google Cloud Spanner (Agent/JDBC, GoogleSQL and PostgreSQL dialects)
|
||||
|
||||
Protocol compatibility covers only connection and selected dialect behavior. Versions, system catalogs, DDL, privileges, and management APIs differ, so DBX uses separate profiles and capability overrides instead of enabling features by family name alone.
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ export const databaseSupport: DatabaseSupportItem[] = [
|
||||
{ id: "databricks", name: "Databricks", icon: "/icons/database/databricks.svg", tone: "#ff5a1f" },
|
||||
{ id: "snowflake", name: "Snowflake", icon: "/icons/database/snowflake.svg", tone: "#29b5e8" },
|
||||
{ id: "bigquery", name: "BigQuery", icon: "/icons/database/bigquery.svg", tone: "#4285f4" },
|
||||
{ id: "spanner", name: "Cloud Spanner", icon: "/icons/database/spanner.svg", tone: "#4285f4" },
|
||||
{ id: "trino", name: "Trino", icon: "/icons/database/trino.svg", tone: "#dd00a1" },
|
||||
{ id: "prestosql", name: "PrestoSQL", icon: "/icons/database/presto.svg", tone: "#5890ff" },
|
||||
{ id: "hive", name: "Hive", icon: "/icons/database/hive.svg", tone: "#fdcb00" },
|
||||
|
||||
@@ -134,6 +134,7 @@ const driverLabels: Record<string, string> = {
|
||||
rocketmq: "Apache RocketMQ",
|
||||
saphana: "SAP HANA",
|
||||
snowflake: "Snowflake",
|
||||
spanner: "Cloud Spanner",
|
||||
sundb: "SunDB",
|
||||
tdengine: "TDengine",
|
||||
teradata: "Teradata",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<!-- Google Cloud Spanner product mark. Path data from Simple Icons (slug: googlecloudspanner), which
|
||||
traces the mark to Google's official icon library at https://cloud.google.com/icons. Google does not
|
||||
publish a redistribution license for its product icons; used here descriptively, to identify Spanner
|
||||
as a supported data source, consistent with the other vendor marks in this directory (see
|
||||
bigquery.svg). Geometry and color (#4285F4) are unchanged. -->
|
||||
<svg fill="#4285F4" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google Cloud Spanner</title><path d="M12 9.06 7.944 6.864V2.388L10.38.924v3.66l1.62.744 1.62-.744V.924l2.436 1.464v4.476L12 9.06zm-8.124 4.752L0 16.056v2.988l3.228-1.86 1.404.912.096 1.632-3.24 1.872 2.616 1.476 3.828-2.268-.132-4.596-3.924-2.4zm9.732-.9V8.758l-1.37.742-.238.129-.238-.13-1.37-.741v4.154l-3.613 2.09 1.282.783.231.142.008.27.046 1.612L12 15.696l3.595 2.079.045-1.59.008-.27.231-.142 1.301-.795-3.572-2.066zm7.164 4.272L24 19.044v-2.988L20.064 13.8l-3.924 2.4-.132 4.596 3.888 2.244 2.616-1.44-3.24-1.836.096-1.668 1.404-.912z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
Reference in New Issue
Block a user