Skip to content

Commit

Permalink
feat: support PreparedStatement#getParameterMetaData() (#1218)
Browse files Browse the repository at this point in the history
* feat: support PreparedStatement#getParameterMetaData()

Add actual support for `PreparedStatement#getParameterMetaData()`. The first time
this method is called for a PreparedStatement, the connection will now send the
query to Cloud Spanner in analyze mode and without any parameter values. This
will instruct Cloud Spanner to return the names and types of any query parameters
in the statement.

Fixes #35

* fix: restore previous behavior

* fix: PostgreSQL string type name should be 'character varying'

* fix: update type name to 'character varying' in integration test
  • Loading branch information
olavloite committed Dec 22, 2023
1 parent 0e15ba1 commit 721ff45
Show file tree
Hide file tree
Showing 10 changed files with 1,140 additions and 63 deletions.
29 changes: 29 additions & 0 deletions src/main/java/com/google/cloud/spanner/JdbcDataTypeConverter.java
@@ -0,0 +1,29 @@
/*
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.cloud.spanner;

import com.google.api.core.InternalApi;

@InternalApi
public class JdbcDataTypeConverter {

/** Converts a protobuf type to a Spanner type. */
@InternalApi
public static Type toSpannerType(com.google.spanner.v1.Type proto) {
return Type.fromProto(proto);
}
}
Expand Up @@ -16,6 +16,7 @@

package com.google.cloud.spanner.jdbc;

import com.google.cloud.spanner.Dialect;
import com.google.cloud.spanner.Type;
import com.google.cloud.spanner.Type.Code;
import com.google.common.base.Preconditions;
Expand Down Expand Up @@ -69,7 +70,74 @@ static int extractColumnType(Type type) {
}
}

/** Extract Spanner type name from {@link java.sql.Types} code. */
static String getSpannerTypeName(Type type, Dialect dialect) {
// TODO: Use com.google.cloud.spanner.Type#getSpannerTypeName() when available.
Preconditions.checkNotNull(type);
switch (type.getCode()) {
case BOOL:
return dialect == Dialect.POSTGRESQL ? "boolean" : "BOOL";
case BYTES:
return dialect == Dialect.POSTGRESQL ? "bytea" : "BYTES";
case DATE:
return dialect == Dialect.POSTGRESQL ? "date" : "DATE";
case FLOAT64:
return dialect == Dialect.POSTGRESQL ? "double precision" : "FLOAT64";
case INT64:
return dialect == Dialect.POSTGRESQL ? "bigint" : "INT64";
case NUMERIC:
return "NUMERIC";
case PG_NUMERIC:
return "numeric";
case STRING:
return dialect == Dialect.POSTGRESQL ? "character varying" : "STRING";
case JSON:
return "JSON";
case PG_JSONB:
return "jsonb";
case TIMESTAMP:
return dialect == Dialect.POSTGRESQL ? "timestamp with time zone" : "TIMESTAMP";
case STRUCT:
return "STRUCT";
case ARRAY:
switch (type.getArrayElementType().getCode()) {
case BOOL:
return dialect == Dialect.POSTGRESQL ? "boolean[]" : "ARRAY<BOOL>";
case BYTES:
return dialect == Dialect.POSTGRESQL ? "bytea[]" : "ARRAY<BYTES>";
case DATE:
return dialect == Dialect.POSTGRESQL ? "date[]" : "ARRAY<DATE>";
case FLOAT64:
return dialect == Dialect.POSTGRESQL ? "double precision[]" : "ARRAY<FLOAT64>";
case INT64:
return dialect == Dialect.POSTGRESQL ? "bigint[]" : "ARRAY<INT64>";
case NUMERIC:
return "ARRAY<NUMERIC>";
case PG_NUMERIC:
return "numeric[]";
case STRING:
return dialect == Dialect.POSTGRESQL ? "character varying[]" : "ARRAY<STRING>";
case JSON:
return "ARRAY<JSON>";
case PG_JSONB:
return "jsonb[]";
case TIMESTAMP:
return dialect == Dialect.POSTGRESQL
? "timestamp with time zone[]"
: "ARRAY<TIMESTAMP>";
case STRUCT:
return "ARRAY<STRUCT>";
}
default:
return null;
}
}

/**
* Extract Spanner type name from {@link java.sql.Types} code.
*
* @deprecated Use {@link #getSpannerTypeName(Type, Dialect)} instead.
*/
@Deprecated
static String getSpannerTypeName(int sqlType) {
if (sqlType == Types.BOOLEAN) return Type.bool().getCode().name();
if (sqlType == Types.BINARY) return Type.bytes().getCode().name();
Expand All @@ -89,7 +157,12 @@ static String getSpannerTypeName(int sqlType) {
return OTHER_NAME;
}

/** Get corresponding Java class name from {@link java.sql.Types} code. */
/**
* Get corresponding Java class name from {@link java.sql.Types} code.
*
* @deprecated Use {@link #getClassName(Type)} instead.
*/
@Deprecated
static String getClassName(int sqlType) {
if (sqlType == Types.BOOLEAN) return Boolean.class.getName();
if (sqlType == Types.BINARY) return Byte[].class.getName();
Expand Down
8 changes: 6 additions & 2 deletions src/main/java/com/google/cloud/spanner/jdbc/JdbcDataType.java
Expand Up @@ -390,14 +390,18 @@ public Set<? extends Class<?>> getSupportedJavaClasses() {

public static JdbcDataType getType(Class<?> clazz) {
for (JdbcDataType type : JdbcDataType.values()) {
if (type.getSupportedJavaClasses().contains(clazz)) return type;
if (type.getSupportedJavaClasses().contains(clazz)) {
return type;
}
}
return null;
}

public static JdbcDataType getType(Code code) {
for (JdbcDataType type : JdbcDataType.values()) {
if (type.getCode() == code) return type;
if (type.getCode() == code) {
return type;
}
}
return null;
}
Expand Down
139 changes: 110 additions & 29 deletions src/main/java/com/google/cloud/spanner/jdbc/JdbcParameterMetaData.java
Expand Up @@ -16,7 +16,13 @@

package com.google.cloud.spanner.jdbc;

import com.google.cloud.spanner.connection.AbstractStatementParser.ParametersInfo;
import com.google.cloud.spanner.JdbcDataTypeConverter;
import com.google.cloud.spanner.ResultSet;
import com.google.rpc.Code;
import com.google.spanner.v1.StructType;
import com.google.spanner.v1.StructType.Field;
import com.google.spanner.v1.Type;
import com.google.spanner.v1.TypeCode;
import java.math.BigDecimal;
import java.sql.Date;
import java.sql.ParameterMetaData;
Expand All @@ -29,9 +35,23 @@
class JdbcParameterMetaData extends AbstractJdbcWrapper implements ParameterMetaData {
private final JdbcPreparedStatement statement;

JdbcParameterMetaData(JdbcPreparedStatement statement) throws SQLException {
private final StructType parameters;

JdbcParameterMetaData(JdbcPreparedStatement statement, ResultSet resultSet) {
this.statement = statement;
statement.getParameters().fetchMetaData(statement.getConnection());
this.parameters = resultSet.getMetadata().getUndeclaredParameters();
}

private Field getField(int param) throws SQLException {
JdbcPreconditions.checkArgument(param > 0 && param <= parameters.getFieldsCount(), param);
String paramName = "p" + param;
return parameters.getFieldsList().stream()
.filter(field -> field.getName().equals(paramName))
.findAny()
.orElseThrow(
() ->
JdbcSqlExceptionFactory.of(
"Unknown parameter: " + paramName, Code.INVALID_ARGUMENT));
}

@Override
Expand All @@ -41,8 +61,7 @@ public boolean isClosed() {

@Override
public int getParameterCount() {
ParametersInfo info = statement.getParametersInfo();
return info.numberOfParameters;
return parameters.getFieldsCount();
}

@Override
Expand All @@ -53,7 +72,7 @@ public int isNullable(int param) {
}

@Override
public boolean isSigned(int param) {
public boolean isSigned(int param) throws SQLException {
int type = getParameterType(param);
return type == Types.DOUBLE
|| type == Types.FLOAT
Expand All @@ -77,9 +96,34 @@ public int getScale(int param) {
}

@Override
public int getParameterType(int param) {
public int getParameterType(int param) throws SQLException {
JdbcPreconditions.checkArgument(param > 0 && param <= parameters.getFieldsCount(), param);
int typeFromValue = getParameterTypeFromValue(param);
if (typeFromValue != Types.OTHER) {
return typeFromValue;
}

Type type = getField(param).getType();
// JDBC only has a generic ARRAY type.
if (type.getCode() == TypeCode.ARRAY) {
return Types.ARRAY;
}
JdbcDataType jdbcDataType =
JdbcDataType.getType(JdbcDataTypeConverter.toSpannerType(type).getCode());
return jdbcDataType == null ? Types.OTHER : jdbcDataType.getSqlType();
}

/**
* This method returns the parameter type based on the parameter value that has been set. This was
* previously the only way to get the parameter types of a statement. Cloud Spanner can now return
* the types and names of parameters in a SQL string, which is what this method should return.
*/
// TODO: Remove this method for the next major version bump.
private int getParameterTypeFromValue(int param) {
Integer type = statement.getParameters().getType(param);
if (type != null) return type;
if (type != null) {
return type;
}

Object value = statement.getParameters().getParameter(param);
if (value == null) {
Expand Down Expand Up @@ -116,16 +160,49 @@ public int getParameterType(int param) {
}

@Override
public String getParameterTypeName(int param) {
return getSpannerTypeName(getParameterType(param));
public String getParameterTypeName(int param) throws SQLException {
JdbcPreconditions.checkArgument(param > 0 && param <= parameters.getFieldsCount(), param);
String typeNameFromValue = getParameterTypeNameFromValue(param);
if (typeNameFromValue != null) {
return typeNameFromValue;
}

com.google.cloud.spanner.Type type =
JdbcDataTypeConverter.toSpannerType(getField(param).getType());
return getSpannerTypeName(type, statement.getConnection().getDialect());
}

private String getParameterTypeNameFromValue(int param) {
int type = getParameterTypeFromValue(param);
if (type != Types.OTHER) {
return getSpannerTypeName(type);
}
return null;
}

@Override
public String getParameterClassName(int param) {
public String getParameterClassName(int param) throws SQLException {
JdbcPreconditions.checkArgument(param > 0 && param <= parameters.getFieldsCount(), param);
String classNameFromValue = getParameterClassNameFromValue(param);
if (classNameFromValue != null) {
return classNameFromValue;
}

com.google.cloud.spanner.Type type =
JdbcDataTypeConverter.toSpannerType(getField(param).getType());
return getClassName(type);
}

// TODO: Remove this method for the next major version bump.
private String getParameterClassNameFromValue(int param) {
Object value = statement.getParameters().getParameter(param);
if (value != null) return value.getClass().getName();
if (value != null) {
return value.getClass().getName();
}
Integer type = statement.getParameters().getType(param);
if (type != null) return getClassName(type);
if (type != null) {
return getClassName(type);
}
return null;
}

Expand All @@ -136,22 +213,26 @@ public int getParameterMode(int param) {

@Override
public String toString() {
StringBuilder res = new StringBuilder();
res.append("CloudSpannerPreparedStatementParameterMetaData, parameter count: ")
.append(getParameterCount());
for (int param = 1; param <= getParameterCount(); param++) {
res.append("\nParameter ")
.append(param)
.append(":\n\t Class name: ")
.append(getParameterClassName(param));
res.append(",\n\t Parameter type name: ").append(getParameterTypeName(param));
res.append(",\n\t Parameter type: ").append(getParameterType(param));
res.append(",\n\t Parameter precision: ").append(getPrecision(param));
res.append(",\n\t Parameter scale: ").append(getScale(param));
res.append(",\n\t Parameter signed: ").append(isSigned(param));
res.append(",\n\t Parameter nullable: ").append(isNullable(param));
res.append(",\n\t Parameter mode: ").append(getParameterMode(param));
try {
StringBuilder res = new StringBuilder();
res.append("CloudSpannerPreparedStatementParameterMetaData, parameter count: ")
.append(getParameterCount());
for (int param = 1; param <= getParameterCount(); param++) {
res.append("\nParameter ")
.append(param)
.append(":\n\t Class name: ")
.append(getParameterClassName(param));
res.append(",\n\t Parameter type name: ").append(getParameterTypeName(param));
res.append(",\n\t Parameter type: ").append(getParameterType(param));
res.append(",\n\t Parameter precision: ").append(getPrecision(param));
res.append(",\n\t Parameter scale: ").append(getScale(param));
res.append(",\n\t Parameter signed: ").append(isSigned(param));
res.append(",\n\t Parameter nullable: ").append(isNullable(param));
res.append(",\n\t Parameter mode: ").append(getParameterMode(param));
}
return res.toString();
} catch (SQLException exception) {
return "Failed to get parameter metadata: " + exception;
}
return res.toString();
}
}
Expand Up @@ -40,6 +40,7 @@ class JdbcPreparedStatement extends AbstractJdbcPreparedStatement
private static final char POS_PARAM_CHAR = '?';
private final String sql;
private final ParametersInfo parameters;
private JdbcParameterMetaData cachedParameterMetadata;
private final ImmutableList<String> generatedKeysColumns;

JdbcPreparedStatement(
Expand Down Expand Up @@ -118,7 +119,34 @@ public void addBatch() throws SQLException {
@Override
public JdbcParameterMetaData getParameterMetaData() throws SQLException {
checkClosed();
return new JdbcParameterMetaData(this);
if (cachedParameterMetadata == null) {
if (getConnection().getParser().isUpdateStatement(sql)
&& !getConnection().getParser().checkReturningClause(sql)) {
cachedParameterMetadata = getParameterMetadataForUpdate();
} else {
cachedParameterMetadata = getParameterMetadataForQuery();
}
}
return cachedParameterMetadata;
}

private JdbcParameterMetaData getParameterMetadataForUpdate() {
try (com.google.cloud.spanner.ResultSet resultSet =
getConnection()
.getSpannerConnection()
.analyzeUpdateStatement(
Statement.of(parameters.sqlWithNamedParameters), QueryAnalyzeMode.PLAN)) {
return new JdbcParameterMetaData(this, resultSet);
}
}

private JdbcParameterMetaData getParameterMetadataForQuery() {
try (com.google.cloud.spanner.ResultSet resultSet =
getConnection()
.getSpannerConnection()
.analyzeQuery(Statement.of(parameters.sqlWithNamedParameters), QueryAnalyzeMode.PLAN)) {
return new JdbcParameterMetaData(this, resultSet);
}
}

@Override
Expand Down

0 comments on commit 721ff45

Please sign in to comment.