Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support DML with Returning clause in Connection API #1978

Merged
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
55ae7db
feat: incorporate dml with returning clause
rajatbhatta Aug 17, 2022
f20d02f
feat: changes
rajatbhatta Aug 17, 2022
e06d436
feat: change handling of AsyncResultSet.
rajatbhatta Aug 17, 2022
bfbacfa
fix: lint
rajatbhatta Aug 17, 2022
3807e5f
doc: add comments
rajatbhatta Aug 17, 2022
595ee17
fix: lint
rajatbhatta Aug 17, 2022
f1326ff
test: add tests for executeBatchUpdate
rajatbhatta Aug 19, 2022
3f069bd
test: import fix
rajatbhatta Aug 19, 2022
ba692dc
test: remove redundant import
rajatbhatta Aug 19, 2022
ff3c0ff
test: add abort tests for dml returning
rajatbhatta Aug 19, 2022
5d1c0e5
test: add pg dml returning tests
rajatbhatta Aug 22, 2022
edaad92
feat: change error statement
rajatbhatta Aug 22, 2022
24cf255
doc: add doc for dml with returning clause usage
rajatbhatta Aug 22, 2022
3284f66
Merge branch 'googleapis:main' into dml-returning-parsing-changes
rajatbhatta Aug 22, 2022
4024412
Update google-cloud-spanner/src/main/java/com/google/cloud/spanner/co…
rajatbhatta Aug 23, 2022
4ae2f8d
Update google-cloud-spanner/src/main/java/com/google/cloud/spanner/co…
rajatbhatta Aug 23, 2022
c601a50
fix: incorporate review comments
rajatbhatta Aug 23, 2022
14e741f
test: add more test cases
rajatbhatta Aug 23, 2022
64422eb
test: add todo
rajatbhatta Aug 23, 2022
82e35fe
test: add separate abort tests for dml returning
rajatbhatta Aug 23, 2022
a7db0c1
fix: add try-with-resources block around ResultSet
rajatbhatta Aug 23, 2022
cc00396
feat: enhancement by adding a pre-check
rajatbhatta Aug 24, 2022
c093b9b
feat: changes
rajatbhatta Aug 24, 2022
4af9cbe
test: delete unnecessary test
rajatbhatta Aug 24, 2022
9f59715
test: add few more tests to PG parser
rajatbhatta Aug 24, 2022
218c3b7
feat: method doc update
rajatbhatta Aug 25, 2022
ea4152f
test: nit fixes
rajatbhatta Aug 26, 2022
e2a463e
feat: handle another corner case
rajatbhatta Aug 30, 2022
60fcb4c
test: add another test
rajatbhatta Aug 30, 2022
2c862c4
Merge branch 'main' into dml-returning-parsing-changes
rajatbhatta Nov 9, 2022
58d0278
clirr fixes
rajatbhatta Nov 9, 2022
81b7908
revert env for integration tests
rajatbhatta Nov 9, 2022
d0e173d
remove comments
rajatbhatta Nov 9, 2022
9e15159
skip returning tests in emulator
rajatbhatta Nov 9, 2022
ac3ea4f
fix: linting
rajatbhatta Nov 9, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -140,6 +140,7 @@ public static class ParsedStatement {
private final ClientSideStatementImpl clientSideStatement;
private final Statement statement;
private final String sqlWithoutComments;
private final boolean returningClause;

private static ParsedStatement clientSideStatement(
ClientSideStatementImpl clientSideStatement,
Expand All @@ -155,11 +156,13 @@ private static ParsedStatement ddl(Statement statement, String sqlWithoutComment
private static ParsedStatement query(
Statement statement, String sqlWithoutComments, QueryOptions defaultQueryOptions) {
return new ParsedStatement(
StatementType.QUERY, statement, sqlWithoutComments, defaultQueryOptions);
StatementType.QUERY, statement, sqlWithoutComments, defaultQueryOptions, false);
}

private static ParsedStatement update(Statement statement, String sqlWithoutComments) {
return new ParsedStatement(StatementType.UPDATE, statement, sqlWithoutComments);
private static ParsedStatement update(
Statement statement, String sqlWithoutComments, boolean returningClause) {
return new ParsedStatement(
StatementType.UPDATE, statement, sqlWithoutComments, returningClause);
}

private static ParsedStatement unknown(Statement statement, String sqlWithoutComments) {
Expand All @@ -176,23 +179,34 @@ private ParsedStatement(
this.clientSideStatement = clientSideStatement;
this.statement = statement;
this.sqlWithoutComments = sqlWithoutComments;
this.returningClause = false;
}

private ParsedStatement(
StatementType type,
Statement statement,
String sqlWithoutComments,
boolean returningClause) {
this(type, statement, sqlWithoutComments, null, returningClause);
}

private ParsedStatement(StatementType type, Statement statement, String sqlWithoutComments) {
this(type, statement, sqlWithoutComments, null);
this(type, statement, sqlWithoutComments, null, false);
}

private ParsedStatement(
StatementType type,
Statement statement,
String sqlWithoutComments,
QueryOptions defaultQueryOptions) {
QueryOptions defaultQueryOptions,
boolean returningClause) {
Preconditions.checkNotNull(type);
Preconditions.checkNotNull(statement);
this.type = type;
this.clientSideStatement = null;
this.statement = mergeQueryOptions(statement, defaultQueryOptions);
this.sqlWithoutComments = sqlWithoutComments;
this.returningClause = returningClause;
}

@Override
Expand All @@ -219,6 +233,12 @@ public StatementType getType() {
return type;
}

/** Returns whether the statement has a returning clause or not. * */
@InternalApi
public boolean hasReturningClause() {
return this.returningClause;
}

/**
* Returns true if the statement is a query that will return a {@link
* com.google.cloud.spanner.ResultSet}.
Expand Down Expand Up @@ -354,7 +374,7 @@ ParsedStatement parse(Statement statement, QueryOptions defaultQueryOptions) {
} else if (isQuery(sql)) {
return ParsedStatement.query(statement, sql, defaultQueryOptions);
} else if (isUpdateStatement(sql)) {
return ParsedStatement.update(statement, sql);
return ParsedStatement.update(statement, sql, checkReturningClause(sql));
} else if (isDdlStatement(sql)) {
return ParsedStatement.ddl(statement, sql);
}
Expand Down Expand Up @@ -521,4 +541,20 @@ static int countOccurrencesOf(char c, String string) {
}
return res;
}

/**
* Checks if the given SQL string contains a Returning clause. This method is used only in case of
* a DML statement.
*
* @param sql The sql string without comments that has to be evaluated.
* @return A boolean indicating whether the sql string has a Returning clause or not.
* @throws SpannerException If the input sql string contains an unclosed string/byte literal.
*/
@InternalApi
abstract boolean checkReturningClauseInternal(String sql);
rajatbhatta marked this conversation as resolved.
Show resolved Hide resolved

@InternalApi
public boolean checkReturningClause(String sql) {
return checkReturningClauseInternal(sql);
}
}
Expand Up @@ -853,6 +853,9 @@ public StatementResult execute(Statement statement) {
case QUERY:
return StatementResultImpl.of(internalExecuteQuery(parsedStatement, AnalyzeMode.NONE));
case UPDATE:
if (parsedStatement.hasReturningClause()) {
return StatementResultImpl.of(internalExecuteQuery(parsedStatement, AnalyzeMode.NONE));
}
return StatementResultImpl.of(get(internalExecuteUpdateAsync(parsedStatement)));
case DDL:
get(executeDdlAsync(parsedStatement));
Expand Down Expand Up @@ -881,6 +884,10 @@ public AsyncStatementResult executeAsync(Statement statement) {
return AsyncStatementResultImpl.of(
internalExecuteQueryAsync(parsedStatement, AnalyzeMode.NONE));
case UPDATE:
if (parsedStatement.hasReturningClause()) {
return AsyncStatementResultImpl.of(
internalExecuteQueryAsync(parsedStatement, AnalyzeMode.NONE));
}
return AsyncStatementResultImpl.of(internalExecuteUpdateAsync(parsedStatement));
case DDL:
return AsyncStatementResultImpl.noResult(executeDdlAsync(parsedStatement));
Expand Down Expand Up @@ -918,7 +925,7 @@ private ResultSet parseAndExecuteQuery(
Preconditions.checkNotNull(analyzeMode);
ConnectionPreconditions.checkState(!isClosed(), CLOSED_ERROR_MSG);
ParsedStatement parsedStatement = getStatementParser().parse(query, this.queryOptions);
if (parsedStatement.isQuery()) {
if (parsedStatement.isQuery() || parsedStatement.isUpdate()) {
switch (parsedStatement.getType()) {
case CLIENT_SIDE:
return parsedStatement
Expand All @@ -928,6 +935,19 @@ private ResultSet parseAndExecuteQuery(
case QUERY:
return internalExecuteQuery(parsedStatement, analyzeMode, options);
case UPDATE:
if (parsedStatement.hasReturningClause()) {
// Cannot execute in read-only mode or in READ_ONLY_TRANSACTION transaction mode.
if (this.isReadOnly()
|| (this.isInTransaction()
&& this.getTransactionMode() == TransactionMode.READ_ONLY_TRANSACTION)) {
throw SpannerExceptionFactory.newSpannerException(
ErrorCode.FAILED_PRECONDITION,
"DML statement with returning clause cannot be executed in read-only mode: "
+ parsedStatement.getSqlWithoutComments());
}
return internalExecuteQuery(parsedStatement, analyzeMode, options);
}

rajatbhatta marked this conversation as resolved.
Show resolved Hide resolved
case DDL:
case UNKNOWN:
default:
Expand All @@ -943,7 +963,7 @@ private AsyncResultSet parseAndExecuteQueryAsync(
Preconditions.checkNotNull(query);
ConnectionPreconditions.checkState(!isClosed(), CLOSED_ERROR_MSG);
ParsedStatement parsedStatement = getStatementParser().parse(query, this.queryOptions);
if (parsedStatement.isQuery()) {
if (parsedStatement.isQuery() || parsedStatement.isUpdate()) {
switch (parsedStatement.getType()) {
case CLIENT_SIDE:
return ResultSets.toAsyncResultSet(
Expand All @@ -956,6 +976,19 @@ private AsyncResultSet parseAndExecuteQueryAsync(
case QUERY:
return internalExecuteQueryAsync(parsedStatement, analyzeMode, options);
case UPDATE:
if (parsedStatement.hasReturningClause()) {
// Cannot execute in read-only mode or in READ_ONLY_TRANSACTION transaction mode.
if (this.isReadOnly()
|| (this.isInTransaction()
&& this.getTransactionMode() == TransactionMode.READ_ONLY_TRANSACTION)) {
throw SpannerExceptionFactory.newSpannerException(
ErrorCode.FAILED_PRECONDITION,
"DML statement with returning clause cannot be executed in read-only mode: "
+ parsedStatement.getSqlWithoutComments());
}
return internalExecuteQueryAsync(parsedStatement, analyzeMode, options);
}

rajatbhatta marked this conversation as resolved.
Show resolved Hide resolved
case DDL:
case UNKNOWN:
default:
Expand All @@ -974,6 +1007,13 @@ public long executeUpdate(Statement update) {
if (parsedStatement.isUpdate()) {
switch (parsedStatement.getType()) {
case UPDATE:
if (parsedStatement.hasReturningClause()) {
throw SpannerExceptionFactory.newSpannerException(
ErrorCode.FAILED_PRECONDITION,
"DML statement with returning clause cannot be executed over executeUpdate: "
rajatbhatta marked this conversation as resolved.
Show resolved Hide resolved
+ parsedStatement.getSqlWithoutComments()
+ ". Please use executeQuery instead.");
}
return get(internalExecuteUpdateAsync(parsedStatement));
case CLIENT_SIDE:
case QUERY:
Expand All @@ -995,6 +1035,13 @@ public ApiFuture<Long> executeUpdateAsync(Statement update) {
if (parsedStatement.isUpdate()) {
switch (parsedStatement.getType()) {
case UPDATE:
if (parsedStatement.hasReturningClause()) {
throw SpannerExceptionFactory.newSpannerException(
ErrorCode.FAILED_PRECONDITION,
"DML statement with returning clause cannot be executed over executeUpdateAsync: "
rajatbhatta marked this conversation as resolved.
Show resolved Hide resolved
+ parsedStatement.getSqlWithoutComments()
+ ". Please use executeQueryAsync instead.");
}
return internalExecuteUpdateAsync(parsedStatement);
case CLIENT_SIDE:
case QUERY:
Expand Down Expand Up @@ -1141,8 +1188,9 @@ private ResultSet internalExecuteQuery(
final QueryOption... options) {
Preconditions.checkArgument(
statement.getType() == StatementType.QUERY
|| (statement.getType() == StatementType.UPDATE && analyzeMode != AnalyzeMode.NONE),
"Statement must either be a query or a DML mode with analyzeMode!=NONE");
|| (statement.getType() == StatementType.UPDATE
&& (analyzeMode != AnalyzeMode.NONE || statement.hasReturningClause())),
"Statement must either be a query or a DML mode with analyzeMode!=NONE or returning clause");
UnitOfWork transaction = getCurrentUnitOfWorkOrStartNewUnitOfWork();
return get(
transaction.executeQueryAsync(
Expand All @@ -1154,7 +1202,9 @@ private AsyncResultSet internalExecuteQueryAsync(
final AnalyzeMode analyzeMode,
final QueryOption... options) {
Preconditions.checkArgument(
statement.getType() == StatementType.QUERY, "Statement must be a query");
(statement.getType() == StatementType.QUERY)
|| (statement.getType() == StatementType.UPDATE && statement.hasReturningClause()),
"Statement must be a query or DML with returning clause.");
UnitOfWork transaction = getCurrentUnitOfWorkOrStartNewUnitOfWork();
return ResultSets.toAsyncResultSet(
transaction.executeQueryAsync(
Expand Down
Expand Up @@ -25,6 +25,7 @@
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Pattern;
import javax.annotation.Nullable;

@InternalApi
Expand Down Expand Up @@ -65,6 +66,8 @@ String removeCommentsAndTrimInternal(String sql) {
Preconditions.checkNotNull(sql);
boolean isInSingleLineComment = false;
int multiLineCommentLevel = 0;
boolean whitespaceBeforeOrAfterMultiLineComment = false;
int multiLineCommentStartIdx = -1;
StringBuilder res = new StringBuilder(sql.length());
int index = 0;
while (index < sql.length()) {
Expand All @@ -78,6 +81,19 @@ String removeCommentsAndTrimInternal(String sql) {
} else if (multiLineCommentLevel > 0) {
if (sql.length() > index + 1 && c == ASTERISK && sql.charAt(index + 1) == SLASH) {
multiLineCommentLevel--;
if (multiLineCommentLevel == 0) {
if (!whitespaceBeforeOrAfterMultiLineComment && (sql.length() > index + 2)) {
whitespaceBeforeOrAfterMultiLineComment =
Character.isWhitespace(sql.charAt(index + 2));
}
// If the multiline comment does not have any whitespace before or after it, and it is
// neither at the start nor at the end of SQL string, append an extra space.
if (!whitespaceBeforeOrAfterMultiLineComment
&& (multiLineCommentStartIdx != 0)
&& (index != sql.length() - 2)) {
res.append(' ');
}
}
index++;
} else if (sql.length() > index + 1 && c == SLASH && sql.charAt(index + 1) == ASTERISK) {
multiLineCommentLevel++;
Expand All @@ -92,6 +108,10 @@ String removeCommentsAndTrimInternal(String sql) {
continue;
} else if (sql.length() > index + 1 && c == SLASH && sql.charAt(index + 1) == ASTERISK) {
multiLineCommentLevel++;
if (index >= 1) {
whitespaceBeforeOrAfterMultiLineComment = Character.isWhitespace(sql.charAt(index - 1));
}
multiLineCommentStartIdx = index;
rajatbhatta marked this conversation as resolved.
Show resolved Hide resolved
index += 2;
continue;
} else {
Expand Down Expand Up @@ -267,4 +287,36 @@ private void appendIfNotNull(
result.append(prefix).append(tag).append(suffix);
}
}

private boolean isReturning(String sql, int index) {
// RETURNING is a reserved keyword in PG, but requires a
// leading AS to be used as column label, to avoid ambiguity.
// We thus check for cases which do not have a leading AS.
// (https://www.postgresql.org/docs/current/sql-keywords-appendix.html)
return Pattern.compile("[\\s)]RETURNING[\\s(][\\s\\S]*", Pattern.CASE_INSENSITIVE)
rajatbhatta marked this conversation as resolved.
Show resolved Hide resolved
.matcher(sql.substring(index))
.matches()
&& !((index >= 3)
&& Pattern.compile("[\\s]AS[\\s]RETURNING[\\s(][\\s\\S]*", Pattern.CASE_INSENSITIVE)
.matcher(sql.substring(index - 3))
.matches());
}

@InternalApi
@Override
boolean checkReturningClauseInternal(String rawSql) {
Preconditions.checkNotNull(rawSql);
int index = 0;
String sql = rawSql.replaceAll("\\s+", " ");
boolean hasReturningClause = false;
while (index < sql.length()) {
if (!hasReturningClause && isReturning(sql, index)) {
hasReturningClause = true;
rajatbhatta marked this conversation as resolved.
Show resolved Hide resolved
index++;
} else {
index = skip(sql, index, null);
}
}
return hasReturningClause;
}
}
Expand Up @@ -45,6 +45,7 @@
import com.google.cloud.spanner.TransactionContext;
import com.google.cloud.spanner.TransactionManager;
import com.google.cloud.spanner.connection.AbstractStatementParser.ParsedStatement;
import com.google.cloud.spanner.connection.AbstractStatementParser.StatementType;
import com.google.cloud.spanner.connection.TransactionRetryListener.RetryResult;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
Expand Down Expand Up @@ -337,7 +338,10 @@ public ApiFuture<ResultSet> executeQueryAsync(
final ParsedStatement statement,
final AnalyzeMode analyzeMode,
final QueryOption... options) {
Preconditions.checkArgument(statement.isQuery(), "Statement is not a query");
Preconditions.checkArgument(
(statement.getType() == StatementType.QUERY)
|| (statement.getType() == StatementType.UPDATE && statement.hasReturningClause()),
"Statement must be a query or DML with returning clause");
checkValidTransaction();
rajatbhatta marked this conversation as resolved.
Show resolved Hide resolved

ApiFuture<ResultSet> res;
Expand Down
Expand Up @@ -179,12 +179,18 @@ public ApiFuture<ResultSet> executeQueryAsync(
final QueryOption... options) {
Preconditions.checkNotNull(statement);
Preconditions.checkArgument(
statement.isQuery() || (statement.isUpdate() && analyzeMode != AnalyzeMode.NONE),
statement.isQuery()
|| (statement.isUpdate()
&& (analyzeMode != AnalyzeMode.NONE || statement.hasReturningClause())),
"The statement must be a query, or the statement must be DML and AnalyzeMode must be PLAN or PROFILE");
checkAndMarkUsed();

if (statement.isUpdate() && analyzeMode != AnalyzeMode.NONE) {
return analyzeTransactionalUpdateAsync(statement, analyzeMode);
if (statement.isUpdate()) {
if (analyzeMode != AnalyzeMode.NONE) {
return analyzeTransactionalUpdateAsync(statement, analyzeMode);
}
// DML with returning clause.
return executeDmlReturningAsync(statement, options);
}

final ReadOnlyTransaction currentTransaction =
Expand Down Expand Up @@ -217,6 +223,30 @@ public ApiFuture<ResultSet> executeQueryAsync(
return executeStatementAsync(statement, callable, SpannerGrpc.getExecuteStreamingSqlMethod());
}

private ApiFuture<ResultSet> executeDmlReturningAsync(
final ParsedStatement update, QueryOption... options) {
Callable<ResultSet> callable =
() -> {
try {
writeTransaction = createWriteTransaction();
ResultSet resultSet =
writeTransaction.run(
transaction ->
DirectExecuteResultSet.ofResultSet(
transaction.executeQuery(update.getStatement(), options)));
state = UnitOfWorkState.COMMITTED;
return resultSet;
} catch (Throwable t) {
state = UnitOfWorkState.COMMIT_FAILED;
throw t;
}
};
return executeStatementAsync(
update,
callable,
ImmutableList.of(SpannerGrpc.getExecuteSqlMethod(), SpannerGrpc.getCommitMethod()));
}

@Override
public Timestamp getReadTimestamp() {
ConnectionPreconditions.checkState(
Expand Down