diff --git a/java-spanner-jdbc/documentation/connection_properties.md b/java-spanner-jdbc/documentation/connection_properties.md index 8417aaf3c52a..4a08f2f50bab 100644 --- a/java-spanner-jdbc/documentation/connection_properties.md +++ b/java-spanner-jdbc/documentation/connection_properties.md @@ -14,6 +14,7 @@ The 'Context' value indicates whether the property can only be set when a connec | autoconfigemulator | Automatically configure the connection to try to connect to the Cloud Spanner emulator (true/false). The instance and database in the connection string will automatically be created if these do not yet exist on the emulator. Add dialect=postgresql to the connection string to make sure that the database that is created uses the PostgreSQL dialect. | false | true, false | STARTUP | | autopartitionmode | Execute all queries on this connection as partitioned queries. Executing a query that cannot be partitioned will fail. Executing a query in a read/write transaction will also fail. | false | true, false | USER | | batch_dml_update_count | The update count that is returned for DML statements that are executed in an explicit DML batch. The default is -1 | -1 | | USER | +| cacertificate | Specifies the file path to the server root CA certificate for SSL/TLS validation. | | | STARTUP | | channelprovider | The name of the channel provider class. The name must reference an implementation of ExternalChannelProvider. If this property is not set, the connection will use the default grpc channel provider. | | | STARTUP | | clientcertificate | Specifies the file path to the client certificate required for establishing an mTLS connection. | | | STARTUP | | clientkey | Specifies the file path to the client private key required for establishing an mTLS connection. | | | STARTUP | diff --git a/java-spanner-jdbc/src/main/java/com/google/cloud/spanner/connection/ConnectionOptionsHelper.java b/java-spanner-jdbc/src/main/java/com/google/cloud/spanner/connection/ConnectionOptionsHelper.java index 75aef4947a60..cadb47a888e3 100644 --- a/java-spanner-jdbc/src/main/java/com/google/cloud/spanner/connection/ConnectionOptionsHelper.java +++ b/java-spanner-jdbc/src/main/java/com/google/cloud/spanner/connection/ConnectionOptionsHelper.java @@ -17,25 +17,322 @@ package com.google.cloud.spanner.connection; import com.google.api.core.InternalApi; +import com.google.cloud.spanner.ErrorCode; +import com.google.cloud.spanner.SpannerException; +import com.google.cloud.spanner.SpannerExceptionFactory; import com.google.cloud.spanner.connection.StatementExecutor.StatementExecutorType; +import io.opentelemetry.api.OpenTelemetry; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.TreeMap; +import org.jspecify.annotations.Nullable; @InternalApi public class ConnectionOptionsHelper { + private static final String OPEN_TELEMETRY_PROPERTY_KEY = "openTelemetry"; + private static final String USER_PROPERTY_NAME = "user"; + + @InternalApi + public static ConnectionOptions.Builder useDirectExecutorIfNotUseVirtualThreads( + String connectionUri, ConnectionOptions.Builder builder) { + return useDirectExecutorIfNotUseVirtualThreads(connectionUri, null, builder); + } + @InternalApi public static ConnectionOptions.Builder useDirectExecutorIfNotUseVirtualThreads( - String uri, ConnectionOptions.Builder builder) { - ConnectionState connectionState = new ConnectionState(ConnectionProperties.parseValues(uri)); - if (!connectionState.getValue(ConnectionProperties.USE_VIRTUAL_THREADS).getValue()) { + String connectionUri, @Nullable Properties info, ConnectionOptions.Builder builder) { + boolean useVirtualThreads = false; + String uriValue = + ConnectionOptions.parseUriProperty( + connectionUri, ConnectionProperties.USE_VIRTUAL_THREADS.getKey()); + if (uriValue != null) { + useVirtualThreads = Boolean.parseBoolean(uriValue); + } else if (info != null) { + String infoValue = getPropertyFromInfo(info, ConnectionProperties.USE_VIRTUAL_THREADS); + if (infoValue != null) { + useVirtualThreads = Boolean.parseBoolean(infoValue); + } + } + if (!useVirtualThreads) { return builder.setStatementExecutorType(StatementExecutorType.DIRECT_EXECUTOR); } return builder; } + /** + * Applies properties from the given {@link Properties} object to the {@link + * ConnectionOptions.Builder} without string concatenation or delimiter injection. + * + *

Properties specified in the connection URI take precedence over properties in the {@link + * Properties} object, conforming to JDBC 4.3 specification ยง9.2. + * + * @return warning message if lenient mode is enabled and unknown properties were found, or {@code + * null} otherwise. + * @throws SpannerException if lenient mode is not enabled and unknown properties are present in + * {@code info}. + */ + @Nullable + @InternalApi + public static String applyProperties( + ConnectionOptions.Builder builder, String connectionUri, @Nullable Properties info) { + applyUserFromUri(builder, connectionUri); + Map allProperties = collectProperties(info); + if (allProperties.isEmpty()) { + return null; + } + boolean isLenient = isLenient(connectionUri, allProperties); + String warning = validateProperties(allProperties, isLenient); + applyPropertiesToBuilder(builder, connectionUri, allProperties); + return warning; + } + + private static void applyUserFromUri(ConnectionOptions.Builder builder, String connectionUri) { + if (ConnectionOptions.parseUriProperty(connectionUri, ConnectionOptions.USERNAME_PROPERTY_NAME) + == null) { + String uriUser = ConnectionOptions.parseUriProperty(connectionUri, USER_PROPERTY_NAME); + if (uriUser != null) { + setPropertyValue(builder, ConnectionProperties.USERNAME, uriUser); + } + } + } + + @Nullable + private static String validateProperties(Map allProperties, boolean isLenient) { + List invalidProperties = findInvalidProperties(allProperties); + if (invalidProperties.isEmpty()) { + return null; + } + if (!isLenient) { + throw SpannerExceptionFactory.newSpannerException( + ErrorCode.INVALID_ARGUMENT, + String.format( + "Invalid properties found in connection properties. Add lenient=true to ignore" + + " unknown properties. Invalid properties: %s", + String.join(", ", invalidProperties))); + } + return String.format( + "Invalid properties found in connection properties: %s", + String.join(", ", invalidProperties)); + } + + private static List findInvalidProperties(Map allProperties) { + List invalidProperties = new ArrayList<>(); + for (Map.Entry entry : allProperties.entrySet()) { + String propertyName = entry.getKey(); + Object rawValue = entry.getValue(); + if (OPEN_TELEMETRY_PROPERTY_KEY.equalsIgnoreCase(propertyName)) { + continue; + } + boolean isKnown = + ConnectionProperties.CONNECTION_PROPERTIES.containsKey( + propertyName.toLowerCase(Locale.ENGLISH)); + if (!isKnown) { + // Unknown property: + // If the value is not a String (e.g. an internal connection pool config object) + // or is an empty string, skip it to maintain backwards compatibility with + // connection pools. + if (!(rawValue instanceof String) || ((String) rawValue).isEmpty()) { + continue; + } + invalidProperties.add(propertyName); + } + } + return invalidProperties; + } + + private static void applyPropertiesToBuilder( + ConnectionOptions.Builder builder, String connectionUri, Map properties) { + for (Map.Entry entry : properties.entrySet()) { + if (entry.getValue() == null) { + continue; + } + String propertyName = entry.getKey(); + if (OPEN_TELEMETRY_PROPERTY_KEY.equalsIgnoreCase(propertyName)) { + continue; + } + ConnectionProperty property = + ConnectionProperties.CONNECTION_PROPERTIES.get(propertyName.toLowerCase(Locale.ENGLISH)); + if (property != null) { + applyPropertyIfAbsentInUri(builder, connectionUri, property, entry.getValue()); + } + } + } + + private static void applyPropertyIfAbsentInUri( + ConnectionOptions.Builder builder, + String connectionUri, + ConnectionProperty property, + Object value) { + String uriValue = parseUriProperty(connectionUri, property.getKey()); + if (uriValue == null && !property.getKey().equalsIgnoreCase(property.getName())) { + uriValue = parseUriProperty(connectionUri, property.getName()); + } + if (uriValue == null) { + setPropertyValue(builder, property, String.valueOf(value)); + } + } + + @InternalApi + public static Map collectProperties(@Nullable Properties info) { + if (info == null || (info.isEmpty() && info.stringPropertyNames().isEmpty())) { + return Collections.emptyMap(); + } + Map allProperties = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (Map.Entry entry : info.entrySet()) { + if (entry.getKey() instanceof String) { + allProperties.put((String) entry.getKey(), entry.getValue()); + } + } + // Normalize 'user' to 'username' for direct properties first so that an explicit 'user' + // in main properties takes precedence over any 'username' or 'user' in default properties. + normalizeUserProperty(allProperties); + applyDefaultUserProperty(allProperties, info); + for (String stringName : info.stringPropertyNames()) { + if (!USER_PROPERTY_NAME.equalsIgnoreCase(stringName) + && !ConnectionOptions.USERNAME_PROPERTY_NAME.equalsIgnoreCase(stringName) + && !allProperties.containsKey(stringName)) { + allProperties.put(stringName, info.getProperty(stringName)); + } + } + return allProperties; + } + + /** + * Applies the default 'username' or 'user' property from the underlying defaults in {@code info} + * to {@code allProperties} if neither was explicitly set in the main properties. If both + * 'username' and 'user' exist in defaults, 'username' is preferred deterministically. + */ + private static void applyDefaultUserProperty(Map allProperties, Properties info) { + if (allProperties.containsKey(ConnectionOptions.USERNAME_PROPERTY_NAME)) { + return; + } + String defaultUsernameKey = + findStringKeyIgnoreCase(info, ConnectionOptions.USERNAME_PROPERTY_NAME); + if (defaultUsernameKey != null) { + allProperties.put( + ConnectionOptions.USERNAME_PROPERTY_NAME, info.getProperty(defaultUsernameKey)); + } else { + String defaultUserKey = findStringKeyIgnoreCase(info, USER_PROPERTY_NAME); + if (defaultUserKey != null) { + allProperties.put( + ConnectionOptions.USERNAME_PROPERTY_NAME, info.getProperty(defaultUserKey)); + } + } + } + + private static @Nullable String findStringKeyIgnoreCase(Properties info, String key) { + for (String stringName : info.stringPropertyNames()) { + if (stringName.equalsIgnoreCase(key)) { + return stringName; + } + } + return null; + } + + private static void normalizeUserProperty(Map properties) { + if (properties.containsKey(ConnectionOptions.USERNAME_PROPERTY_NAME)) { + properties.remove(USER_PROPERTY_NAME); + } else if (properties.containsKey(USER_PROPERTY_NAME)) { + properties.put( + ConnectionOptions.USERNAME_PROPERTY_NAME, properties.remove(USER_PROPERTY_NAME)); + } + } + + private static boolean isLenient(String connectionUri, Map properties) { + String uriLenient = + ConnectionOptions.parseUriProperty(connectionUri, ConnectionOptions.LENIENT_PROPERTY_NAME); + if (uriLenient != null) { + return Boolean.parseBoolean(uriLenient); + } + Object lenientValue = properties.get(ConnectionOptions.LENIENT_PROPERTY_NAME); + if (lenientValue != null) { + return Boolean.parseBoolean(String.valueOf(lenientValue)); + } + return false; + } + + @Nullable + @InternalApi + public static String getPropertyFromProperties( + @Nullable Map properties, ConnectionProperty connectionProperty) { + if (properties == null || properties.isEmpty()) { + return null; + } + Object value = properties.get(connectionProperty.getKey()); + if (value == null + && !connectionProperty.getKey().equalsIgnoreCase(connectionProperty.getName())) { + value = properties.get(connectionProperty.getName()); + } + return value == null ? null : String.valueOf(value); + } + + @Nullable + @InternalApi + public static String getPropertyFromInfo( + @Nullable Properties info, ConnectionProperty connectionProperty) { + if (info == null) { + return null; + } + String key = findStringKeyIgnoreCase(info, connectionProperty.getKey()); + if (key == null + && !connectionProperty.getKey().equalsIgnoreCase(connectionProperty.getName())) { + key = findStringKeyIgnoreCase(info, connectionProperty.getName()); + } + if (key == null + && connectionProperty.getKey().equalsIgnoreCase(ConnectionOptions.USERNAME_PROPERTY_NAME)) { + key = findStringKeyIgnoreCase(info, USER_PROPERTY_NAME); + } + return key == null ? null : info.getProperty(key); + } + + @Nullable + @InternalApi + public static String parseUriProperty(String uri, String property) { + String value = ConnectionOptions.parseUriProperty(uri, property); + if (value == null && ConnectionOptions.USERNAME_PROPERTY_NAME.equalsIgnoreCase(property)) { + return ConnectionOptions.parseUriProperty(uri, USER_PROPERTY_NAME); + } + return value; + } + + private static void setPropertyValue( + ConnectionOptions.Builder builder, ConnectionProperty property, String stringValue) { + try { + ConnectionPropertyValue converted = property.convert(stringValue); + if (converted != null) { + builder.setConnectionPropertyValue(property, converted.getValue()); + } + } catch (SpannerException e) { + if (!stringValue.isEmpty()) { + throw e; + } + // Silently skip empty string values that cannot be converted (e.g. autocommit="", + // minSessions="") to ensure compatibility with connection pools. + } + } + @InternalApi public static boolean usesDirectExecutor(ConnectionOptions options) { return options.getStatementExecutorType() == StatementExecutorType.DIRECT_EXECUTOR; } + @InternalApi + public static OpenTelemetry getOpenTelemetry(ConnectionOptions options) { + return options.getOpenTelemetry(); + } + + @Nullable + @InternalApi + public static Object getInitialPropertyValue(ConnectionOptions options, String propertyName) { + ConnectionPropertyValue value = + options.getInitialConnectionPropertyValues().get(propertyName.toLowerCase(Locale.ENGLISH)); + return value == null ? null : value.getValue(); + } + private ConnectionOptionsHelper() {} } diff --git a/java-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/ConnectionPropertiesHelper.java b/java-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/ConnectionPropertiesHelper.java index 4f8db86b0bfb..beb1241dce89 100644 --- a/java-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/ConnectionPropertiesHelper.java +++ b/java-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/ConnectionPropertiesHelper.java @@ -16,16 +16,18 @@ package com.google.cloud.spanner.jdbc; +import com.google.cloud.spanner.connection.ConnectionOptionsHelper; import com.google.cloud.spanner.connection.ConnectionProperties; import com.google.cloud.spanner.connection.ConnectionProperty; import com.google.common.collect.ImmutableList; import java.sql.DriverPropertyInfo; import java.util.Arrays; import java.util.Comparator; +import java.util.Map; import java.util.Objects; -import java.util.regex.Matcher; -import java.util.regex.Pattern; +import java.util.Properties; import java.util.stream.Collectors; +import org.jspecify.annotations.Nullable; class ConnectionPropertiesHelper { static ImmutableList> VALID_CONNECTION_PROPERTIES = @@ -35,16 +37,25 @@ class ConnectionPropertiesHelper { .collect(Collectors.toList())); static DriverPropertyInfo toDriverPropertyInfo( - String connectionUri, ConnectionProperty connectionProperty) { - DriverPropertyInfo result = - new DriverPropertyInfo( - connectionProperty.getName(), - parseUriProperty( - connectionUri, - connectionProperty.getName(), - connectionProperty.getDefaultValue() == null - ? null - : connectionProperty.getDefaultValue().toString())); + String connectionUri, + @Nullable Map properties, + ConnectionProperty connectionProperty) { + String value = + ConnectionOptionsHelper.parseUriProperty(connectionUri, connectionProperty.getKey()); + if (value == null + && !connectionProperty.getKey().equalsIgnoreCase(connectionProperty.getName())) { + value = ConnectionOptionsHelper.parseUriProperty(connectionUri, connectionProperty.getName()); + } + if (value == null && properties != null) { + value = ConnectionOptionsHelper.getPropertyFromProperties(properties, connectionProperty); + } + if (value == null) { + value = + connectionProperty.getDefaultValue() == null + ? null + : connectionProperty.getDefaultValue().toString(); + } + DriverPropertyInfo result = new DriverPropertyInfo(connectionProperty.getName(), value); result.description = connectionProperty.getDescription(); result.choices = connectionProperty.getValidValues() == null @@ -55,17 +66,16 @@ static DriverPropertyInfo toDriverPropertyInfo( return result; } - static String getConnectionPropertyName(ConnectionProperty connectionProperty) { - return connectionProperty.getName(); + static DriverPropertyInfo toDriverPropertyInfo( + String connectionUri, @Nullable Properties info, ConnectionProperty connectionProperty) { + return toDriverPropertyInfo( + connectionUri, + info == null ? null : ConnectionOptionsHelper.collectProperties(info), + connectionProperty); } - private static String parseUriProperty(String uri, String property, String defaultValue) { - Pattern pattern = Pattern.compile(String.format("(?is)(?:;|\\?)%s=(.*?)(?:;|$)", property)); - Matcher matcher = pattern.matcher(uri); - if (matcher.find() && matcher.groupCount() == 1) { - return matcher.group(1); - } - return defaultValue; + static String getConnectionPropertyName(ConnectionProperty connectionProperty) { + return connectionProperty.getName(); } private ConnectionPropertiesHelper() {} diff --git a/java-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/JdbcDataSource.java b/java-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/JdbcDataSource.java index f0f073aad29c..d4649ced5e19 100644 --- a/java-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/JdbcDataSource.java +++ b/java-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/JdbcDataSource.java @@ -16,10 +16,10 @@ package com.google.cloud.spanner.jdbc; -import static com.google.cloud.spanner.jdbc.JdbcDriver.appendPropertiesToUrl; import static com.google.cloud.spanner.jdbc.JdbcDriver.buildConnectionOptions; import static com.google.cloud.spanner.jdbc.JdbcDriver.maybeAddUserAgent; +import com.google.cloud.spanner.SpannerException; import com.google.cloud.spanner.connection.ConnectionOptions; import com.google.rpc.Code; import java.io.PrintWriter; @@ -91,8 +91,15 @@ public Connection getConnection() throws SQLException { } Properties properties = createProperties(); maybeAddUserAgent(properties); - String connectionUri = appendPropertiesToUrl(url.substring(5), properties); - cachedConnectionOptions = buildConnectionOptions(connectionUri, properties); + try { + cachedConnectionOptions = buildConnectionOptions(getUrl().substring(5), properties); + } catch (SpannerException e) { + throw JdbcSqlExceptionFactory.of(e); + } catch (IllegalArgumentException e) { + throw JdbcSqlExceptionFactory.of(e.getMessage(), Code.INVALID_ARGUMENT, e); + } catch (Exception e) { + throw JdbcSqlExceptionFactory.of(e.getMessage(), Code.UNKNOWN, e); + } } } } @@ -105,23 +112,24 @@ public Connection getConnection(String username, String password) throws SQLExce } private Properties createProperties() { - Properties props = new Properties(); + Properties properties = new Properties(); if (this.credentials != null) { - props.setProperty(ConnectionOptions.CREDENTIALS_PROPERTY_NAME, this.credentials); + properties.setProperty(ConnectionOptions.CREDENTIALS_PROPERTY_NAME, this.credentials); } if (this.autocommit != null) { - props.setProperty( + properties.setProperty( ConnectionOptions.AUTOCOMMIT_PROPERTY_NAME, String.valueOf(this.autocommit)); } if (this.readonly != null) { - props.setProperty(ConnectionOptions.READONLY_PROPERTY_NAME, String.valueOf(this.readonly)); + properties.setProperty( + ConnectionOptions.READONLY_PROPERTY_NAME, String.valueOf(this.readonly)); } if (this.retryAbortsInternally != null) { - props.setProperty( + properties.setProperty( ConnectionOptions.RETRY_ABORTS_INTERNALLY_PROPERTY_NAME, String.valueOf(this.retryAbortsInternally)); } - return props; + return properties; } @Override diff --git a/java-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/JdbcDriver.java b/java-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/JdbcDriver.java index 77362b605d02..f7092bbfa42b 100644 --- a/java-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/JdbcDriver.java +++ b/java-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/JdbcDriver.java @@ -25,6 +25,7 @@ import com.google.cloud.spanner.connection.ConnectionOptionsHelper; import com.google.cloud.spanner.connection.ConnectionProperty; import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; import com.google.common.base.Suppliers; import com.google.rpc.Code; import io.opentelemetry.api.OpenTelemetry; @@ -35,12 +36,13 @@ import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException; import java.sql.SQLWarning; -import java.util.Map.Entry; +import java.util.Map; import java.util.Properties; import java.util.function.Supplier; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; /** * JDBC {@link Driver} for Google Cloud Spanner. @@ -140,6 +142,8 @@ public class JdbcDriver implements Driver { */ public static final String OPEN_TELEMETRY_PROPERTY_KEY = "openTelemetry"; + private static final String USER_PROPERTY_NAME = "user"; + private static final String JDBC_API_CLIENT_LIB_TOKEN = "sp-jdbc"; // Updated to version 2 when upgraded to Java 8 (JDBC 4.2) static final int MAJOR_VERSION = 2; @@ -218,22 +222,30 @@ static JdbcDriver getRegisteredDriver() throws SQLException { public JdbcDriver() {} @Override - public Connection connect(String url, Properties info) throws SQLException { + public @Nullable Connection connect(String url, @Nullable Properties info) throws SQLException { if (url != null && (url.startsWith("jdbc:cloudspanner") || url.startsWith("jdbc:spanner"))) { try { Matcher matcher = URL_PATTERN.matcher(url); Matcher matcherSpannerOmni = SPANNER_OMNI_URL_PATTERN.matcher(url); if (matcher.matches() || matcherSpannerOmni.matches()) { - // strip 'jdbc:' from the URL, add any extra properties and pass on to the generic + // strip 'jdbc:' from the URL, apply any extra properties and pass on to the generic // Connection API. Also set the user-agent if we detect that the connection // comes from known framework like Hibernate, and there is no other user-agent set. - maybeAddUserAgent(info); - String connectionUri = appendPropertiesToUrl(url.substring(5), info); - ConnectionOptions options = buildConnectionOptions(connectionUri, info); + Properties properties = copyProperties(info); + maybeAddUserAgent(properties); + String connectionUri = url.substring(5); + ConnectionOptions.Builder builder = + ConnectionOptions.newBuilder().setTracingPrefix("JDBC").setUri(connectionUri); + String extraPropertyWarnings = + ConnectionOptionsHelper.applyProperties(builder, connectionUri, properties); + ConnectionOptions options = buildConnectionOptions(builder, connectionUri, properties); JdbcConnection connection = new JdbcConnection(url, options); - if (options.getWarnings() != null) { + if (hasMeaningfulWarning(options.getWarnings())) { connection.pushWarning(new SQLWarning(options.getWarnings())); } + if (extraPropertyWarnings != null) { + connection.pushWarning(new SQLWarning(extraPropertyWarnings)); + } return connection; } } catch (SpannerException e) { @@ -248,24 +260,91 @@ public Connection connect(String url, Properties info) throws SQLException { return null; } - static ConnectionOptions buildConnectionOptions(String connectionUrl, Properties info) { + static ConnectionOptions buildConnectionOptions(String connectionUrl, @Nullable Properties info) { ConnectionOptions.Builder builder = ConnectionOptions.newBuilder().setTracingPrefix("JDBC").setUri(connectionUrl); - if (info.containsKey(OPEN_TELEMETRY_PROPERTY_KEY) - && info.get(OPEN_TELEMETRY_PROPERTY_KEY) instanceof OpenTelemetry) { - builder.setOpenTelemetry((OpenTelemetry) info.get(OPEN_TELEMETRY_PROPERTY_KEY)); + ConnectionOptionsHelper.applyProperties(builder, connectionUrl, info); + return buildConnectionOptions(builder, connectionUrl, info); + } + + private static ConnectionOptions buildConnectionOptions( + ConnectionOptions.Builder builder, String connectionUrl, @Nullable Properties info) { + if (info != null) { + OpenTelemetry openTelemetry = getOpenTelemetryFromInfo(info); + if (openTelemetry != null) { + builder.setOpenTelemetry(openTelemetry); + } } // Enable multiplexed sessions by default for the JDBC driver. builder.setSessionPoolOptions( SessionPoolOptionsHelper.useMultiplexedSessions(SessionPoolOptions.newBuilder()).build()); // Enable direct executor for JDBC, as we don't use the async API. builder = - ConnectionOptionsHelper.useDirectExecutorIfNotUseVirtualThreads(connectionUrl, builder); + ConnectionOptionsHelper.useDirectExecutorIfNotUseVirtualThreads( + connectionUrl, info, builder); return builder.build(); } - static void maybeAddUserAgent(Properties properties) { - if (properties.containsKey("userAgent")) { + @Nullable + private static OpenTelemetry getOpenTelemetryFromInfo(@Nullable Properties info) { + if (info == null) { + return null; + } + for (Map.Entry entry : info.entrySet()) { + if (entry.getKey() instanceof String + && OPEN_TELEMETRY_PROPERTY_KEY.equalsIgnoreCase((String) entry.getKey()) + && entry.getValue() instanceof OpenTelemetry) { + return (OpenTelemetry) entry.getValue(); + } + } + return null; + } + + static Properties copyProperties(@Nullable Properties info) { + Properties properties = new Properties(); + if (info != null) { + for (Map.Entry entry : info.entrySet()) { + properties.put(entry.getKey(), entry.getValue()); + } + boolean hasUserOrUsername = + containsKeyIgnoreCase(properties, ConnectionOptions.USERNAME_PROPERTY_NAME) + || containsKeyIgnoreCase(properties, USER_PROPERTY_NAME); + if (!hasUserOrUsername) { + String defaultUsernameKey = + findKeyIgnoreCase(info, ConnectionOptions.USERNAME_PROPERTY_NAME); + if (defaultUsernameKey != null) { + properties.setProperty(defaultUsernameKey, info.getProperty(defaultUsernameKey)); + } else { + String defaultUserKey = findKeyIgnoreCase(info, USER_PROPERTY_NAME); + if (defaultUserKey != null) { + properties.setProperty(defaultUserKey, info.getProperty(defaultUserKey)); + } + } + } + for (String stringName : info.stringPropertyNames()) { + if (USER_PROPERTY_NAME.equalsIgnoreCase(stringName) + || ConnectionOptions.USERNAME_PROPERTY_NAME.equalsIgnoreCase(stringName)) { + continue; + } + if (!containsKeyIgnoreCase(properties, stringName)) { + properties.setProperty(stringName, info.getProperty(stringName)); + } + } + } + return properties; + } + + private static @Nullable String findKeyIgnoreCase(Properties properties, String key) { + for (String stringName : properties.stringPropertyNames()) { + if (stringName.equalsIgnoreCase(key)) { + return stringName; + } + } + return null; + } + + static void maybeAddUserAgent(@Nullable Properties properties) { + if (properties == null || containsKeyIgnoreCase(properties, "userAgent")) { return; } if (isHibernate()) { @@ -273,6 +352,15 @@ static void maybeAddUserAgent(Properties properties) { } } + private static boolean containsKeyIgnoreCase(Properties properties, String key) { + for (Object name : properties.keySet()) { + if (name instanceof String && ((String) name).equalsIgnoreCase(key)) { + return true; + } + } + return false; + } + private static final Supplier isHibernate = Suppliers.memoize( () -> { @@ -308,14 +396,15 @@ static boolean isHibernate() { return isHibernate.get(); } - static String appendPropertiesToUrl(String url, Properties info) { - StringBuilder res = new StringBuilder(url); - for (Entry entry : info.entrySet()) { - if (entry.getValue() instanceof String && !"".equals(entry.getValue())) { - res.append(";").append(entry.getKey()).append("=").append(entry.getValue()); - } + private static boolean hasMeaningfulWarning(@Nullable String warning) { + if (Strings.isNullOrEmpty(warning)) { + return false; + } + String prefix = "Invalid properties found in connection URI:"; + if (warning.startsWith(prefix)) { + warning = warning.substring(prefix.length()); } - return res.toString(); + return !Strings.isNullOrEmpty(warning.trim()); } @Override @@ -324,16 +413,19 @@ public boolean acceptsURL(String url) { } @Override - public DriverPropertyInfo[] getPropertyInfo(String url, Properties info) { - String connectionUri = appendPropertiesToUrl(url.substring(5), info); - DriverPropertyInfo[] res = + public DriverPropertyInfo[] getPropertyInfo(String url, @Nullable Properties info) { + String connectionUri = url.substring(5); + Map properties = + info == null ? null : ConnectionOptionsHelper.collectProperties(info); + DriverPropertyInfo[] results = new DriverPropertyInfo[ConnectionPropertiesHelper.VALID_CONNECTION_PROPERTIES.size()]; - int i = 0; - for (ConnectionProperty prop : ConnectionPropertiesHelper.VALID_CONNECTION_PROPERTIES) { - res[i] = ConnectionPropertiesHelper.toDriverPropertyInfo(connectionUri, prop); - i++; + int index = 0; + for (ConnectionProperty property : ConnectionPropertiesHelper.VALID_CONNECTION_PROPERTIES) { + results[index] = + ConnectionPropertiesHelper.toDriverPropertyInfo(connectionUri, properties, property); + index++; } - return res; + return results; } @Override diff --git a/java-spanner-jdbc/src/test/java/com/google/cloud/spanner/jdbc/JdbcDataSourceTest.java b/java-spanner-jdbc/src/test/java/com/google/cloud/spanner/jdbc/JdbcDataSourceTest.java index 196cb9169df5..df43861acaa0 100644 --- a/java-spanner-jdbc/src/test/java/com/google/cloud/spanner/jdbc/JdbcDataSourceTest.java +++ b/java-spanner-jdbc/src/test/java/com/google/cloud/spanner/jdbc/JdbcDataSourceTest.java @@ -17,10 +17,18 @@ package com.google.cloud.spanner.jdbc; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; import com.google.cloud.spanner.connection.AbstractMockServerTest; +import com.google.rpc.Code; +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.sql.Connection; import java.sql.SQLException; +import java.util.Objects; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -60,4 +68,46 @@ public void testGetConnectionFromCachedDataSource() throws SQLException { } } } + + @Test + public void testPropertiesPrecedence() throws SQLException { + JdbcDataSource dataSource = new JdbcDataSource(); + dataSource.setUrl(getBaseUrl() + ";autocommit=false"); + dataSource.setAutocommit(true); + try (Connection connection = dataSource.getConnection()) { + assertFalse(connection.getAutoCommit()); + } + } + + @Test + public void testCredentialsWithSemicolons() throws Exception { + File tempKeyFile = File.createTempFile("key;with=semicolons", ".json"); + try { + Files.copy( + Paths.get( + Objects.requireNonNull(JdbcDriverTest.class.getResource("test-key.json")).toURI()), + tempKeyFile.toPath(), + StandardCopyOption.REPLACE_EXISTING); + + JdbcDataSource dataSource = new JdbcDataSource(); + dataSource.setUrl(getBaseUrl()); + dataSource.setCredentials(tempKeyFile.getAbsolutePath()); + try (Connection connection = dataSource.getConnection()) { + JdbcConnection jdbcConnection = connection.unwrap(JdbcConnection.class); + assertEquals( + tempKeyFile.getAbsolutePath(), + jdbcConnection.getConnectionOptions().getCredentialsUrl()); + } + } finally { + Files.deleteIfExists(tempKeyFile.toPath()); + } + } + + @Test + public void testInvalidPropertyThrowsSQLException() { + JdbcDataSource dataSource = new JdbcDataSource(); + dataSource.setUrl(getBaseUrl() + ";unknownProperty=value"); + SQLException sqlException = assertThrows(SQLException.class, dataSource::getConnection); + assertEquals(Code.INVALID_ARGUMENT_VALUE, sqlException.getErrorCode()); + } } diff --git a/java-spanner-jdbc/src/test/java/com/google/cloud/spanner/jdbc/JdbcDriverTest.java b/java-spanner-jdbc/src/test/java/com/google/cloud/spanner/jdbc/JdbcDriverTest.java index 3acc194348d7..ba1eb52749c1 100644 --- a/java-spanner-jdbc/src/test/java/com/google/cloud/spanner/jdbc/JdbcDriverTest.java +++ b/java-spanner-jdbc/src/test/java/com/google/cloud/spanner/jdbc/JdbcDriverTest.java @@ -21,11 +21,15 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import com.google.cloud.ServiceOptions; import com.google.cloud.spanner.MockSpannerServiceImpl; +import com.google.cloud.spanner.connection.ConnectionOptions; +import com.google.cloud.spanner.connection.ConnectionOptionsHelper; import com.google.cloud.spanner.connection.SpannerPool; import com.google.common.collect.Collections2; import com.google.common.collect.ImmutableList; @@ -39,13 +43,17 @@ import io.grpc.ServerCallHandler; import io.grpc.ServerInterceptor; import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import io.opentelemetry.api.OpenTelemetry; import java.io.IOException; import java.net.InetSocketAddress; import java.sql.Connection; +import java.sql.Driver; import java.sql.DriverManager; import java.sql.DriverPropertyInfo; import java.sql.SQLException; +import java.sql.SQLWarning; import java.util.Collection; +import java.util.Map; import java.util.Objects; import java.util.Properties; import java.util.regex.Matcher; @@ -253,4 +261,848 @@ public void testJdbcSpannerOmniFormat() { "jdbc:cloudspanner:/instances/default/databases/singers-db"); assertFalse(matcherWithoutHost.matches()); } + + @Test + public void testDelimiterInjectionInProperties() throws SQLException { + String url = + String.format( + "jdbc:cloudspanner://localhost:%d/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s", + server.getPort(), TEST_KEY_PATH); + Properties properties = new Properties(); + properties.setProperty( + "databaseRole", "reader;usePlainText=true;endpoint=attacker.example.com:443"); + try (Connection connection = DriverManager.getConnection(url, properties)) { + assertFalse(connection.isClosed()); + JdbcConnection jdbcConnection = connection.unwrap(JdbcConnection.class); + assertEquals( + "reader;usePlainText=true;endpoint=attacker.example.com:443", + jdbcConnection.getConnectionOptions().getDatabaseRole()); + assertEquals( + String.format("http://localhost:%d", server.getPort()), + jdbcConnection.getConnectionOptions().getHost()); + } + } + + @Test + public void testPasswordWithSemicolons() throws SQLException { + String url = + String.format( + "jdbc:cloudspanner://localhost:%d/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s", + server.getPort(), TEST_KEY_PATH); + Properties properties = new Properties(); + properties.setProperty("password", "secret;password=123;foo=bar"); + try (Connection connection = DriverManager.getConnection(url, properties)) { + assertFalse(connection.isClosed()); + JdbcConnection jdbcConnection = connection.unwrap(JdbcConnection.class); + assertEquals( + "secret;password=123;foo=bar", + ConnectionOptionsHelper.getInitialPropertyValue( + jdbcConnection.getConnectionOptions(), "password")); + } + } + + @Test + public void testPropertiesPrecedence() throws SQLException { + String url = + String.format( + "jdbc:cloudspanner://localhost:%d/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s;autocommit=false;readonly=true", + server.getPort(), TEST_KEY_PATH); + Properties properties = new Properties(); + properties.setProperty("autocommit", "true"); + properties.setProperty("readonly", "false"); + try (Connection connection = DriverManager.getConnection(url, properties)) { + assertFalse(connection.isClosed()); + assertFalse(connection.getAutoCommit()); + assertTrue(connection.isReadOnly()); + } + } + + @Test + public void testLenientAndNonLenientExtraProperties() throws SQLException { + String url = + String.format( + "jdbc:cloudspanner://localhost:%d/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s", + server.getPort(), TEST_KEY_PATH); + + // Without lenient=true in URL or Properties: unknown property should throw SQLException. + Properties nonLenientProperties = new Properties(); + nonLenientProperties.setProperty("unknownProperty", "value"); + SQLException sqlException = + assertThrows( + SQLException.class, () -> DriverManager.getConnection(url, nonLenientProperties)); + assertTrue(sqlException.getMessage().contains("unknownProperty")); + assertEquals(Code.INVALID_ARGUMENT_VALUE, sqlException.getErrorCode()); + + // With lenient=true in Properties: unknown property should produce a warning. + Properties lenientProperties = new Properties(); + lenientProperties.setProperty("unknownProperty", "value"); + lenientProperties.setProperty("lenient", "true"); + try (Connection connection = DriverManager.getConnection(url, lenientProperties)) { + assertFalse(connection.isClosed()); + assertNotNull(connection.getWarnings()); + assertTrue(connection.getWarnings().getMessage().contains("unknownProperty")); + } + + // With lenient=true in URL: unknown property in Properties should produce a warning. + String lenientUrl = url + ";lenient=true"; + try (Connection connection = DriverManager.getConnection(lenientUrl, nonLenientProperties)) { + assertFalse(connection.isClosed()); + assertNotNull(connection.getWarnings()); + assertTrue(connection.getWarnings().getMessage().contains("unknownProperty")); + } + } + + @Test + public void testUserAliasForUsername() throws SQLException { + String url = + String.format( + "jdbc:cloudspanner://localhost:%d/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s", + server.getPort(), TEST_KEY_PATH); + Properties properties = new Properties(); + properties.setProperty("user", "test-user-from-properties"); + try (Connection connection = DriverManager.getConnection(url, properties)) { + assertFalse(connection.isClosed()); + JdbcConnection jdbcConnection = connection.unwrap(JdbcConnection.class); + assertEquals( + "test-user-from-properties", + ConnectionOptionsHelper.getInitialPropertyValue( + jdbcConnection.getConnectionOptions(), "username")); + } + } + + @Test + public void testGetPropertyInfoNoDelimiterInjection() throws SQLException { + Properties properties = new Properties(); + properties.setProperty( + "databaseRole", "reader;usePlainText=true;endpoint=attacker.example.com:443"); + DriverPropertyInfo[] propertyInfos = + JdbcDriver.getRegisteredDriver() + .getPropertyInfo("jdbc:cloudspanner:/projects/p/instances/i/databases/d", properties); + + DriverPropertyInfo databaseRoleInfo = null; + DriverPropertyInfo endpointInfo = null; + DriverPropertyInfo usePlainTextInfo = null; + for (DriverPropertyInfo propertyInfo : propertyInfos) { + if ("databaseRole".equalsIgnoreCase(propertyInfo.name)) { + databaseRoleInfo = propertyInfo; + } else if ("endpoint".equalsIgnoreCase(propertyInfo.name)) { + endpointInfo = propertyInfo; + } else if ("usePlainText".equalsIgnoreCase(propertyInfo.name)) { + usePlainTextInfo = propertyInfo; + } + } + assertNotNull(databaseRoleInfo); + assertEquals( + "reader;usePlainText=true;endpoint=attacker.example.com:443", databaseRoleInfo.value); + + assertNotNull(endpointInfo); + assertNull(endpointInfo.value); + + assertNotNull(usePlainTextInfo); + assertEquals("false", usePlainTextInfo.value); + } + + @Test + public void testUserAndUsernameCollision() throws SQLException { + String url = + String.format( + "jdbc:cloudspanner://localhost:%d/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s", + server.getPort(), TEST_KEY_PATH); + Properties properties = new Properties(); + properties.setProperty("user", "user-alias-value"); + properties.setProperty("username", "primary-username-value"); + try (Connection connection = DriverManager.getConnection(url, properties)) { + assertFalse(connection.isClosed()); + JdbcConnection jdbcConnection = connection.unwrap(JdbcConnection.class); + assertEquals( + "primary-username-value", + ConnectionOptionsHelper.getInitialPropertyValue( + jdbcConnection.getConnectionOptions(), "username")); + } + } + + @Test + public void testCaseInsensitiveProperties() throws SQLException { + String url = + String.format( + "jdbc:cloudspanner://localhost:%d/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s", + server.getPort(), TEST_KEY_PATH); + Properties properties = new Properties(); + properties.setProperty("AutoCommit", "false"); + properties.setProperty("READONLY", "true"); + try (Connection connection = DriverManager.getConnection(url, properties)) { + assertFalse(connection.isClosed()); + assertFalse(connection.getAutoCommit()); + assertTrue(connection.isReadOnly()); + } + } + + @Test + public void testNonStringPropertyValues() throws SQLException { + String url = + String.format( + "jdbc:cloudspanner://localhost:%d/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s", + server.getPort(), TEST_KEY_PATH); + Properties properties = new Properties(); + properties.put("autocommit", Boolean.FALSE); + properties.put("internalPoolConfig", new Object()); + try (Connection connection = DriverManager.getConnection(url, properties)) { + assertFalse(connection.isClosed()); + assertFalse(connection.getAutoCommit()); + } + } + + @Test + public void testEmptyStringPropertyValues() throws SQLException { + String url = + String.format( + "jdbc:cloudspanner://localhost:%d/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s", + server.getPort(), TEST_KEY_PATH); + Properties properties = new Properties(); + properties.setProperty("autocommit", ""); + properties.setProperty("unknownProp", ""); + properties.setProperty("password", ""); + try (Connection connection = DriverManager.getConnection(url, properties)) { + assertFalse(connection.isClosed()); + assertTrue(connection.getAutoCommit()); + JdbcConnection jdbcConnection = connection.unwrap(JdbcConnection.class); + assertEquals( + "", + ConnectionOptionsHelper.getInitialPropertyValue( + jdbcConnection.getConnectionOptions(), "password")); + } + } + + @Test + public void testConflictingLenientFlags() { + String url = + String.format( + "jdbc:cloudspanner://localhost:%d/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s;lenient=false", + server.getPort(), TEST_KEY_PATH); + Properties properties = new Properties(); + properties.setProperty("lenient", "true"); + properties.setProperty("unknownProp", "someValue"); + SQLException sqlException = + assertThrows(SQLException.class, () -> DriverManager.getConnection(url, properties)); + assertTrue(sqlException.getMessage().contains("unknownProp")); + } + + @Test + public void testMultipleUnknownProperties() { + String url = + String.format( + "jdbc:cloudspanner://localhost:%d/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s", + server.getPort(), TEST_KEY_PATH); + Properties properties = new Properties(); + properties.setProperty("unknownProp1", "val1"); + properties.setProperty("unknownProp2", "val2"); + SQLException sqlException = + assertThrows(SQLException.class, () -> DriverManager.getConnection(url, properties)); + assertTrue(sqlException.getMessage().contains("unknownProp1")); + assertTrue(sqlException.getMessage().contains("unknownProp2")); + } + + @Test + public void testMultipleUnknownPropertiesWithLenient() throws SQLException { + String url = + String.format( + "jdbc:cloudspanner://localhost:%d/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s;lenient=true", + server.getPort(), TEST_KEY_PATH); + Properties properties = new Properties(); + properties.setProperty("unknownProp1", "val1"); + properties.setProperty("unknownProp2", "val2"); + try (Connection connection = DriverManager.getConnection(url, properties)) { + assertFalse(connection.isClosed()); + assertNotNull(connection.getWarnings()); + assertTrue(connection.getWarnings().getMessage().contains("unknownProp1")); + assertTrue(connection.getWarnings().getMessage().contains("unknownProp2")); + } + } + + @Test + public void testChainedWarningsWithLenient() throws SQLException { + String url = + String.format( + "jdbc:cloudspanner://localhost:%d/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s;unknownUrlProp=urlVal;lenient=true", + server.getPort(), TEST_KEY_PATH); + Properties properties = new Properties(); + properties.setProperty("unknownInfoProp", "infoVal"); + try (Connection connection = DriverManager.getConnection(url, properties)) { + assertFalse(connection.isClosed()); + SQLWarning firstWarning = connection.getWarnings(); + assertNotNull(firstWarning); + assertTrue(firstWarning.getMessage().contains("unknownUrlProp")); + SQLWarning secondWarning = firstWarning.getNextWarning(); + assertNotNull(secondWarning); + assertTrue(secondWarning.getMessage().contains("unknownInfoProp")); + } + } + + @Test + public void testOpenTelemetryInProperties() throws SQLException { + String url = + String.format( + "jdbc:cloudspanner://localhost:%d/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s", + server.getPort(), TEST_KEY_PATH); + Properties properties = new Properties(); + OpenTelemetry openTelemetry = OpenTelemetry.noop(); + properties.put("openTelemetry", openTelemetry); + try (Connection connection = DriverManager.getConnection(url, properties)) { + assertFalse(connection.isClosed()); + JdbcConnection jdbcConnection = connection.unwrap(JdbcConnection.class); + assertEquals( + openTelemetry, + ConnectionOptionsHelper.getOpenTelemetry(jdbcConnection.getConnectionOptions())); + } + + // Verify case-insensitivity: "OpenTelemetry" + Properties mixedCaseProperties = new Properties(); + mixedCaseProperties.put("OpenTelemetry", openTelemetry); + try (Connection connection = DriverManager.getConnection(url, mixedCaseProperties)) { + assertFalse(connection.isClosed()); + JdbcConnection jdbcConnection = connection.unwrap(JdbcConnection.class); + assertEquals( + openTelemetry, + ConnectionOptionsHelper.getOpenTelemetry(jdbcConnection.getConnectionOptions())); + } + } + + @Test + public void testUseVirtualThreadsInProperties() throws SQLException { + String url = + String.format( + "jdbc:cloudspanner://localhost:%d/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s", + server.getPort(), TEST_KEY_PATH); + Properties properties = new Properties(); + properties.setProperty("useVirtualThreads", "true"); + try (Connection connection = DriverManager.getConnection(url, properties)) { + assertFalse(connection.isClosed()); + JdbcConnection jdbcConnection = connection.unwrap(JdbcConnection.class); + assertFalse( + ConnectionOptionsHelper.usesDirectExecutor(jdbcConnection.getConnectionOptions())); + } + } + + @Test + public void testNullInfoArgument() throws SQLException { + String url = + String.format( + "jdbc:cloudspanner://localhost:%d/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s", + server.getPort(), TEST_KEY_PATH); + Driver registeredDriver = JdbcDriver.getRegisteredDriver(); + try (Connection connection = registeredDriver.connect(url, null)) { + assertNotNull(connection); + assertFalse(connection.isClosed()); + } + DriverPropertyInfo[] propertyInfos = registeredDriver.getPropertyInfo(url, null); + assertNotNull(propertyInfos); + assertTrue(propertyInfos.length > 0); + + ConnectionOptions connectionOptions = JdbcDriver.buildConnectionOptions(url.substring(5), null); + assertNotNull(connectionOptions); + } + + @Test + public void testGetPropertyInfoEdgeCases() throws SQLException { + Properties properties = new Properties(); + properties.setProperty("user", "test-user-from-props"); + properties.setProperty("AUTOCOMMIT", "false"); + DriverPropertyInfo[] propertyInfos = + JdbcDriver.getRegisteredDriver() + .getPropertyInfo( + "jdbc:cloudspanner:/projects/p/instances/i/databases/d;autocommit=true", + properties); + + DriverPropertyInfo usernameInfo = null; + DriverPropertyInfo autocommitInfo = null; + for (DriverPropertyInfo propertyInfo : propertyInfos) { + if ("username".equalsIgnoreCase(propertyInfo.name)) { + usernameInfo = propertyInfo; + } else if ("autocommit".equalsIgnoreCase(propertyInfo.name)) { + autocommitInfo = propertyInfo; + } + } + assertNotNull(usernameInfo); + assertEquals("test-user-from-props", usernameInfo.value); + + assertNotNull(autocommitInfo); + assertEquals("true", autocommitInfo.value); + } + + @Test + public void testInvalidPropertyValueThrowsSQLException() { + String url = + String.format( + "jdbc:cloudspanner://localhost:%d/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s", + server.getPort(), TEST_KEY_PATH); + Properties properties = new Properties(); + properties.setProperty("autocommit", "not-a-boolean"); + SQLException sqlException = + assertThrows(SQLException.class, () -> DriverManager.getConnection(url, properties)); + assertTrue(sqlException.getMessage().contains("autocommit")); + assertEquals(Code.INVALID_ARGUMENT_VALUE, sqlException.getErrorCode()); + } + + @Test + public void testUnknownPropertyThrowsSQLException() { + String url = + String.format( + "jdbc:cloudspanner://localhost:%d/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s", + server.getPort(), TEST_KEY_PATH); + Properties properties = new Properties(); + properties.setProperty("unknownProperty", "value"); + SQLException sqlException = + assertThrows(SQLException.class, () -> DriverManager.getConnection(url, properties)); + assertTrue(sqlException.getMessage().contains("unknownProperty")); + assertEquals(Code.INVALID_ARGUMENT_VALUE, sqlException.getErrorCode()); + } + + @Test + public void testPropertiesWithDefaults() throws SQLException { + String url = + String.format( + "jdbc:cloudspanner://localhost:%d/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s", + server.getPort(), TEST_KEY_PATH); + Properties defaultProperties = new Properties(); + defaultProperties.setProperty("autocommit", "false"); + defaultProperties.setProperty("readonly", "true"); + Properties properties = new Properties(defaultProperties); + assertTrue(properties.isEmpty()); + assertFalse(properties.stringPropertyNames().isEmpty()); + + try (Connection connection = DriverManager.getConnection(url, properties)) { + assertFalse(connection.isClosed()); + assertFalse(connection.getAutoCommit()); + assertTrue(connection.isReadOnly()); + } + + // Verify Driver.getPropertyInfo with Properties that only contain defaults + DriverPropertyInfo[] propertyInfos = + JdbcDriver.getRegisteredDriver().getPropertyInfo(url, properties); + boolean foundAutocommit = false; + for (DriverPropertyInfo info : propertyInfos) { + if ("autocommit".equalsIgnoreCase(info.name)) { + foundAutocommit = true; + assertEquals("false", info.value); + } + } + assertTrue(foundAutocommit); + + // Verify JdbcDriver.buildConnectionOptions with Properties that only contain defaults + ConnectionOptions options = JdbcDriver.buildConnectionOptions(url.substring(5), properties); + assertFalse(options.isAutocommit()); + assertTrue(options.isReadOnly()); + + // Verify ConnectionOptionsHelper.applyProperties directly + ConnectionOptions.Builder builder = + ConnectionOptions.newBuilder().setTracingPrefix("JDBC").setUri(url.substring(5)); + ConnectionOptionsHelper.applyProperties(builder, url.substring(5), properties); + ConnectionOptions builtOptions = builder.build(); + assertFalse(builtOptions.isAutocommit()); + assertTrue(builtOptions.isReadOnly()); + } + + @Test + public void testUserAgentCaseInsensitive() throws SQLException { + String url = + String.format( + "jdbc:cloudspanner://localhost:%d/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s", + server.getPort(), TEST_KEY_PATH); + Properties properties = new Properties(); + properties.setProperty("UserAgent", "sp-hib"); + try (Connection connection = DriverManager.getConnection(url, properties)) { + assertFalse(connection.isClosed()); + JdbcConnection jdbcConnection = connection.unwrap(JdbcConnection.class); + assertEquals( + "sp-hib", + ConnectionOptionsHelper.getInitialPropertyValue( + jdbcConnection.getConnectionOptions(), "userAgent")); + } + + // Also verify that maybeAddUserAgent respects case-insensitivity and does not add userAgent. + Properties customProperties = new Properties(); + customProperties.setProperty("USERAGENT", "custom"); + JdbcDriver.maybeAddUserAgent(customProperties); + assertFalse(customProperties.containsKey("userAgent")); + + // Also verify that copyProperties preserves userAgent present in defaults. + Properties defaultProperties = new Properties(); + defaultProperties.setProperty("UserAgent", "custom-from-defaults"); + Properties propertiesWithDefaults = + JdbcDriver.copyProperties(new Properties(defaultProperties)); + assertTrue(propertiesWithDefaults.containsKey("UserAgent")); + JdbcDriver.maybeAddUserAgent(propertiesWithDefaults); + assertFalse(propertiesWithDefaults.containsKey("userAgent")); + } + + @Test + public void testNonStringOverrideOfDefaultProperty() throws SQLException { + String url = + String.format( + "jdbc:cloudspanner://localhost:%d/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s", + server.getPort(), TEST_KEY_PATH); + Properties defaultProperties = new Properties(); + defaultProperties.setProperty("openTelemetry", "dummy"); + defaultProperties.setProperty("autocommit", "true"); + Properties properties = new Properties(defaultProperties); + OpenTelemetry openTelemetry = OpenTelemetry.noop(); + properties.put("OpenTelemetry", openTelemetry); + properties.put("AutoCommit", "false"); + + try (Connection connection = DriverManager.getConnection(url, properties)) { + assertFalse(connection.isClosed()); + assertFalse(connection.getAutoCommit()); + JdbcConnection jdbcConnection = connection.unwrap(JdbcConnection.class); + assertEquals( + openTelemetry, + ConnectionOptionsHelper.getOpenTelemetry(jdbcConnection.getConnectionOptions())); + } + + Map collectedProperties = ConnectionOptionsHelper.collectProperties(properties); + assertEquals(openTelemetry, collectedProperties.get("openTelemetry")); + assertEquals("false", collectedProperties.get("autocommit")); + + Properties copiedProperties = JdbcDriver.copyProperties(properties); + assertEquals(openTelemetry, copiedProperties.get("OpenTelemetry")); + assertFalse(copiedProperties.containsKey("autocommit")); + } + + @Test + public void testUserInUriTakesPrecedenceOverUsernameInProperties() throws SQLException { + String url = + String.format( + "jdbc:cloudspanner://localhost:%d/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s;user=uri-user;lenient=true", + server.getPort(), TEST_KEY_PATH); + Properties properties = new Properties(); + properties.setProperty("username", "properties-user"); + + try (Connection connection = DriverManager.getConnection(url, properties)) { + assertFalse(connection.isClosed()); + JdbcConnection jdbcConnection = connection.unwrap(JdbcConnection.class); + assertEquals( + "uri-user", + ConnectionOptionsHelper.getInitialPropertyValue( + jdbcConnection.getConnectionOptions(), "username")); + } + } + + @Test + public void testUserAliasWithDefaultProperties() throws SQLException { + String url = + String.format( + "jdbc:cloudspanner://localhost:%d/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s", + server.getPort(), TEST_KEY_PATH); + + // Case 1: defaults specify username, main properties specify user + Properties defaultProperties1 = new Properties(); + defaultProperties1.setProperty("username", "default-user"); + Properties properties1 = new Properties(defaultProperties1); + properties1.setProperty("user", "main-user"); + + try (Connection connection = DriverManager.getConnection(url, properties1)) { + assertFalse(connection.isClosed()); + JdbcConnection jdbcConnection = connection.unwrap(JdbcConnection.class); + assertEquals( + "main-user", + ConnectionOptionsHelper.getInitialPropertyValue( + jdbcConnection.getConnectionOptions(), "username")); + } + Map collected1 = ConnectionOptionsHelper.collectProperties(properties1); + assertEquals("main-user", collected1.get("username")); + assertFalse(collected1.containsKey("user")); + + Properties copied1 = JdbcDriver.copyProperties(properties1); + assertEquals("main-user", copied1.getProperty("user")); + assertFalse(copied1.containsKey("username")); + + // Case 2: defaults specify user, main properties specify username + Properties defaultProperties2 = new Properties(); + defaultProperties2.setProperty("user", "default-user"); + Properties properties2 = new Properties(defaultProperties2); + properties2.setProperty("username", "main-user"); + + try (Connection connection = DriverManager.getConnection(url, properties2)) { + assertFalse(connection.isClosed()); + JdbcConnection jdbcConnection = connection.unwrap(JdbcConnection.class); + assertEquals( + "main-user", + ConnectionOptionsHelper.getInitialPropertyValue( + jdbcConnection.getConnectionOptions(), "username")); + } + Map collected2 = ConnectionOptionsHelper.collectProperties(properties2); + assertEquals("main-user", collected2.get("username")); + assertFalse(collected2.containsKey("user")); + + Properties copied2 = JdbcDriver.copyProperties(properties2); + assertEquals("main-user", copied2.getProperty("username")); + assertFalse(copied2.containsKey("user")); + } + + private enum UserSettingSource { + NONE, + USER, + USERNAME, + BOTH + } + + @Test + public void testUserAndUsernamePrecedenceMatrix() throws SQLException { + for (UserSettingSource uriSource : UserSettingSource.values()) { + for (UserSettingSource mainPropertiesSource : UserSettingSource.values()) { + for (UserSettingSource defaultPropertiesSource : UserSettingSource.values()) { + testPrecedenceCombination(uriSource, mainPropertiesSource, defaultPropertiesSource); + } + } + } + } + + private void testPrecedenceCombination( + UserSettingSource uriSource, + UserSettingSource mainPropertiesSource, + UserSettingSource defaultPropertiesSource) + throws SQLException { + String expectedWinner; + if (uriSource == UserSettingSource.USERNAME || uriSource == UserSettingSource.BOTH) { + expectedWinner = "uri-username"; + } else if (uriSource == UserSettingSource.USER) { + expectedWinner = "uri-user"; + } else if (mainPropertiesSource == UserSettingSource.USERNAME + || mainPropertiesSource == UserSettingSource.BOTH) { + expectedWinner = "main-username"; + } else if (mainPropertiesSource == UserSettingSource.USER) { + expectedWinner = "main-user"; + } else if (defaultPropertiesSource == UserSettingSource.USERNAME + || defaultPropertiesSource == UserSettingSource.BOTH) { + expectedWinner = "default-username"; + } else if (defaultPropertiesSource == UserSettingSource.USER) { + expectedWinner = "default-user"; + } else { + expectedWinner = ""; + } + + StringBuilder urlBuilder = + new StringBuilder( + String.format( + "jdbc:cloudspanner:/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s;lenient=true", + TEST_KEY_PATH)); + if (uriSource == UserSettingSource.USER) { + urlBuilder.append(";user=uri-user"); + } else if (uriSource == UserSettingSource.USERNAME) { + urlBuilder.append(";username=uri-username"); + } else if (uriSource == UserSettingSource.BOTH) { + urlBuilder.append(";username=uri-username;user=uri-user"); + } + String url = urlBuilder.toString(); + + Properties defaultProperties = null; + if (defaultPropertiesSource != UserSettingSource.NONE) { + defaultProperties = new Properties(); + if (defaultPropertiesSource == UserSettingSource.USER) { + defaultProperties.setProperty("user", "default-user"); + } else if (defaultPropertiesSource == UserSettingSource.USERNAME) { + defaultProperties.setProperty("username", "default-username"); + } else if (defaultPropertiesSource == UserSettingSource.BOTH) { + defaultProperties.setProperty("username", "default-username"); + defaultProperties.setProperty("user", "default-user"); + } + } + + Properties properties = + defaultProperties == null ? new Properties() : new Properties(defaultProperties); + if (mainPropertiesSource == UserSettingSource.USER) { + properties.setProperty("user", "main-user"); + } else if (mainPropertiesSource == UserSettingSource.USERNAME) { + properties.setProperty("username", "main-username"); + } else if (mainPropertiesSource == UserSettingSource.BOTH) { + properties.setProperty("username", "main-username"); + properties.setProperty("user", "main-user"); + } + + String testContext = + String.format( + "Combination failed for URI=%s, Main=%s, Defaults=%s", + uriSource, mainPropertiesSource, defaultPropertiesSource); + + // 1. Verify buildConnectionOptions + ConnectionOptions connectionOptions = + JdbcDriver.buildConnectionOptions(url.substring(5), properties); + assertEquals( + testContext, + expectedWinner, + ConnectionOptionsHelper.getInitialPropertyValue(connectionOptions, "username")); + + // 2. Verify getPropertyInfo + DriverPropertyInfo[] propertyInfos = + JdbcDriver.getRegisteredDriver().getPropertyInfo(url, properties); + DriverPropertyInfo usernamePropertyInfo = findPropertyInfo(propertyInfos, "username"); + assertNotNull(testContext, usernamePropertyInfo); + assertEquals(testContext, expectedWinner, usernamePropertyInfo.value); + + // 3. Verify collectProperties + String expectedPropertiesWinner; + if (mainPropertiesSource == UserSettingSource.USERNAME + || mainPropertiesSource == UserSettingSource.BOTH) { + expectedPropertiesWinner = "main-username"; + } else if (mainPropertiesSource == UserSettingSource.USER) { + expectedPropertiesWinner = "main-user"; + } else if (defaultPropertiesSource == UserSettingSource.USERNAME + || defaultPropertiesSource == UserSettingSource.BOTH) { + expectedPropertiesWinner = "default-username"; + } else if (defaultPropertiesSource == UserSettingSource.USER) { + expectedPropertiesWinner = "default-user"; + } else { + expectedPropertiesWinner = null; + } + Map collected = ConnectionOptionsHelper.collectProperties(properties); + assertEquals(testContext, expectedPropertiesWinner, collected.get("username")); + assertFalse(testContext, collected.containsKey("user")); + + // 4. Verify copyProperties + Properties copied = JdbcDriver.copyProperties(properties); + if (mainPropertiesSource == UserSettingSource.USER) { + assertEquals(testContext, "main-user", copied.getProperty("user")); + assertFalse(testContext, copied.containsKey("username")); + } else if (mainPropertiesSource == UserSettingSource.USERNAME) { + assertEquals(testContext, "main-username", copied.getProperty("username")); + assertFalse(testContext, copied.containsKey("user")); + } else if (mainPropertiesSource == UserSettingSource.BOTH) { + assertEquals(testContext, "main-username", copied.getProperty("username")); + assertEquals(testContext, "main-user", copied.getProperty("user")); + } else if (mainPropertiesSource == UserSettingSource.NONE) { + if (defaultPropertiesSource == UserSettingSource.USERNAME + || defaultPropertiesSource == UserSettingSource.BOTH) { + assertEquals(testContext, "default-username", copied.getProperty("username")); + assertFalse(testContext, copied.containsKey("user")); + } else if (defaultPropertiesSource == UserSettingSource.USER) { + assertEquals(testContext, "default-user", copied.getProperty("user")); + assertFalse(testContext, copied.containsKey("username")); + } else { + assertFalse(testContext, copied.containsKey("username")); + assertFalse(testContext, copied.containsKey("user")); + } + } + } + + private static DriverPropertyInfo findPropertyInfo( + DriverPropertyInfo[] propertyInfos, String name) { + for (DriverPropertyInfo propertyInfo : propertyInfos) { + if (name.equalsIgnoreCase(propertyInfo.name)) { + return propertyInfo; + } + } + return null; + } + + @Test + public void testUserAndUsernameCaseInsensitivity() throws SQLException { + String[] userPropertyKeys = {"USER", "User", "uSeR"}; + String[] usernamePropertyKeys = {"USERNAME", "UserName", "uSeRnAmE"}; + + for (String userKey : userPropertyKeys) { + // In URI + String url = + String.format( + "jdbc:cloudspanner:/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s;lenient=true;%s=case-user", + TEST_KEY_PATH, userKey); + ConnectionOptions connectionOptions = + JdbcDriver.buildConnectionOptions(url.substring(5), null); + assertEquals( + "case-user", + ConnectionOptionsHelper.getInitialPropertyValue(connectionOptions, "username")); + + // In Main Properties + Properties mainProperties = new Properties(); + mainProperties.setProperty(userKey, "case-user-property"); + Map collectedProperties = + ConnectionOptionsHelper.collectProperties(mainProperties); + assertEquals("case-user-property", collectedProperties.get("username")); + assertFalse(collectedProperties.containsKey("user")); + Properties copiedProperties = JdbcDriver.copyProperties(mainProperties); + assertEquals("case-user-property", copiedProperties.getProperty(userKey)); + + // In Default Properties + Properties defaultProperties = new Properties(); + defaultProperties.setProperty(userKey, "case-user-default"); + Properties propertiesWithDefaults = new Properties(defaultProperties); + Map collectedDefaults = + ConnectionOptionsHelper.collectProperties(propertiesWithDefaults); + assertEquals("case-user-default", collectedDefaults.get("username")); + assertFalse(collectedDefaults.containsKey("user")); + Properties copiedDefaults = JdbcDriver.copyProperties(propertiesWithDefaults); + assertEquals("case-user-default", copiedDefaults.getProperty(userKey)); + } + + for (String usernameKey : usernamePropertyKeys) { + // In URI + String url = + String.format( + "jdbc:cloudspanner:/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s;lenient=true;%s=case-username", + TEST_KEY_PATH, usernameKey); + ConnectionOptions connectionOptions = + JdbcDriver.buildConnectionOptions(url.substring(5), null); + assertEquals( + "case-username", + ConnectionOptionsHelper.getInitialPropertyValue(connectionOptions, "username")); + + // In Main Properties + Properties mainProperties = new Properties(); + mainProperties.setProperty(usernameKey, "case-username-property"); + Map collectedProperties = + ConnectionOptionsHelper.collectProperties(mainProperties); + assertEquals("case-username-property", collectedProperties.get("username")); + assertFalse(collectedProperties.containsKey("user")); + Properties copiedProperties = JdbcDriver.copyProperties(mainProperties); + assertEquals("case-username-property", copiedProperties.getProperty(usernameKey)); + + // In Default Properties + Properties defaultProperties = new Properties(); + defaultProperties.setProperty(usernameKey, "case-username-default"); + Properties propertiesWithDefaults = new Properties(defaultProperties); + Map collectedDefaults = + ConnectionOptionsHelper.collectProperties(propertiesWithDefaults); + assertEquals("case-username-default", collectedDefaults.get("username")); + assertFalse(collectedDefaults.containsKey("user")); + Properties copiedDefaults = JdbcDriver.copyProperties(propertiesWithDefaults); + assertEquals("case-username-default", copiedDefaults.getProperty(usernameKey)); + } + } + + @Test + public void testUserAndUsernameLiveConnectionPermutations() throws SQLException { + String baseUrl = + String.format( + "jdbc:cloudspanner://localhost:%d/projects/p/instances/i/databases/d;usePlainText=true;credentials=%s;lenient=true", + server.getPort(), TEST_KEY_PATH); + + // 1. user in URI alone + try (Connection connection = + DriverManager.getConnection(baseUrl + ";user=uri-only-user", new Properties())) { + assertEquals( + "uri-only-user", + ConnectionOptionsHelper.getInitialPropertyValue( + connection.unwrap(JdbcConnection.class).getConnectionOptions(), "username")); + } + + // 2. username in URI beats user in properties + Properties propertiesWithUser = new Properties(); + propertiesWithUser.setProperty("user", "properties-user"); + try (Connection connection = + DriverManager.getConnection(baseUrl + ";username=uri-username", propertiesWithUser)) { + assertEquals( + "uri-username", + ConnectionOptionsHelper.getInitialPropertyValue( + connection.unwrap(JdbcConnection.class).getConnectionOptions(), "username")); + } + + // 3. user in defaults alone (no main properties) + Properties defaultProperties = new Properties(); + defaultProperties.setProperty("user", "default-user"); + Properties propertiesWithDefaults = new Properties(defaultProperties); + try (Connection connection = DriverManager.getConnection(baseUrl, propertiesWithDefaults)) { + assertEquals( + "default-user", + ConnectionOptionsHelper.getInitialPropertyValue( + connection.unwrap(JdbcConnection.class).getConnectionOptions(), "username")); + } + } }