feat(spanner): add Google Cloud Spanner support
This commit is contained in:
1 parent
2cc8d78e64
commit
f494f90c30
74 files changed
+2920
-53
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user