diff --git a/google-cloud-spanner/clirr-ignored-differences.xml b/google-cloud-spanner/clirr-ignored-differences.xml index 14ccf81c057..bcea799a409 100644 --- a/google-cloud-spanner/clirr-ignored-differences.xml +++ b/google-cloud-spanner/clirr-ignored-differences.xml @@ -927,5 +927,23 @@ com/google/cloud/spanner/connection/ConnectionOptions VALID_PROPERTIES + + + + 7002 + com/google/cloud/spanner/connection/AbstractStatementParser + boolean supportsExplain() + + + 7002 + com/google/cloud/spanner/connection/PostgreSQLStatementParser + boolean supportsExplain() + + + 7002 + com/google/cloud/spanner/connection/SpannerStatementParser + boolean supportsExplain() + + diff --git a/google-cloud-spanner/pom.xml b/google-cloud-spanner/pom.xml index 6bdc111ed87..eed3735b857 100644 --- a/google-cloud-spanner/pom.xml +++ b/google-cloud-spanner/pom.xml @@ -517,6 +517,19 @@ + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.openjdk.jmh + jmh-generator-annprocess + 1.37 + + + + diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Statement.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Statement.java index a89c7c048fc..a9cf3e7dec4 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Statement.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Statement.java @@ -21,6 +21,7 @@ import com.google.cloud.spanner.ReadContext.QueryAnalyzeMode; import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableMap; import com.google.spanner.v1.ExecuteSqlRequest.QueryOptions; import java.io.Serializable; import java.util.Collections; @@ -140,7 +141,7 @@ Builder handle(Value value) { /** Creates a {@code Statement} with the given SQL text {@code sql}. */ public static Statement of(String sql) { - return newBuilder(sql).build(); + return new Statement(sql, ImmutableMap.of(), /*queryOptions=*/ null); } /** Creates a new statement builder with the SQL text {@code sql}. */ diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/AbstractStatementParser.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/AbstractStatementParser.java index 406622586d8..30bf861d380 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/AbstractStatementParser.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/AbstractStatementParser.java @@ -31,6 +31,7 @@ import com.google.cloud.spanner.connection.UnitOfWork.CallType; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; +import com.google.common.base.Splitter; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheStats; @@ -41,6 +42,7 @@ import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.Iterator; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -511,7 +513,7 @@ ParsedStatement parse(Statement statement, QueryOptions defaultQueryOptions) { return parsedStatement.copy(statement, defaultQueryOptions); } - private ParsedStatement internalParse(Statement statement, QueryOptions defaultQueryOptions) { + ParsedStatement internalParse(Statement statement, QueryOptions defaultQueryOptions) { StatementHintParser statementHintParser = new StatementHintParser(getDialect(), statement.getSql()); ReadQueryUpdateTransactionOption[] optionsFromHints = EMPTY_OPTIONS; @@ -521,16 +523,21 @@ private ParsedStatement internalParse(Statement statement, QueryOptions defaultQ statement.toBuilder().replace(statementHintParser.getSqlWithoutClientSideHints()).build(); optionsFromHints = convertHintsToOptions(statementHintParser.getClientSideStatementHints()); } + // TODO: Qualify statements without removing comments first. String sql = removeCommentsAndTrim(statement.getSql()); ClientSideStatementImpl client = parseClientSideStatement(sql); if (client != null) { return ParsedStatement.clientSideStatement(client, statement, sql); - } else if (isQuery(sql)) { - return ParsedStatement.query(statement, sql, defaultQueryOptions, optionsFromHints); - } else if (isUpdateStatement(sql)) { - return ParsedStatement.update(statement, sql, checkReturningClause(sql), optionsFromHints); - } else if (isDdlStatement(sql)) { - return ParsedStatement.ddl(statement, sql); + } else { + String sqlWithoutHints = + !sql.isEmpty() && sql.charAt(0) == '@' ? removeStatementHint(sql) : sql; + if (isQuery(sqlWithoutHints)) { + return ParsedStatement.query(statement, sql, defaultQueryOptions, optionsFromHints); + } else if (isUpdateStatement(sqlWithoutHints)) { + return ParsedStatement.update(statement, sql, checkReturningClause(sql), optionsFromHints); + } else if (isDdlStatement(sqlWithoutHints)) { + return ParsedStatement.ddl(statement, sql); + } } return ParsedStatement.unknown(statement, sql); } @@ -610,20 +617,16 @@ public boolean isUpdateStatement(String sql) { return statementStartsWith(sql, dmlStatements); } - protected abstract boolean supportsExplain(); - private boolean statementStartsWith(String sql, Iterable checkStatements) { Preconditions.checkNotNull(sql); - String[] tokens = sql.split("\\s+", 2); - int checkIndex = 0; - if (supportsExplain() && tokens[0].equalsIgnoreCase("EXPLAIN")) { - checkIndex = 1; - } - if (tokens.length > checkIndex) { - for (String check : checkStatements) { - if (tokens[checkIndex].equalsIgnoreCase(check)) { - return true; - } + Iterator tokens = Splitter.onPattern("\\s+").split(sql).iterator(); + if (!tokens.hasNext()) { + return false; + } + String token = tokens.next(); + for (String check : checkStatements) { + if (token.equalsIgnoreCase(check)) { + return true; } } return false; @@ -929,7 +932,8 @@ int skipQuoted( appendIfNotNull(result, startQuote); appendIfNotNull(result, startQuote); } - while (currentIndex < sql.length()) { + int length = sql.length(); + while (currentIndex < length) { char currentChar = sql.charAt(currentIndex); if (currentChar == startQuote) { if (supportsDollarQuotedStrings() && currentChar == DOLLAR) { @@ -940,7 +944,7 @@ int skipQuoted( return currentIndex + tag.length() + 2; } } else if (supportsEscapeQuoteWithQuote() - && sql.length() > currentIndex + 1 + && length > currentIndex + 1 && sql.charAt(currentIndex + 1) == startQuote) { // This is an escaped quote (e.g. 'foo''bar') appendIfNotNull(result, currentChar); @@ -949,7 +953,7 @@ int skipQuoted( continue; } else if (isTripleQuoted) { // Check if this is the end of the triple-quoted string. - if (sql.length() > currentIndex + 2 + if (length > currentIndex + 2 && sql.charAt(currentIndex + 1) == startQuote && sql.charAt(currentIndex + 2) == startQuote) { appendIfNotNull(result, currentChar); @@ -963,7 +967,7 @@ int skipQuoted( } } else if (supportsBackslashEscape() && currentChar == BACKSLASH - && sql.length() > currentIndex + 1 + && length > currentIndex + 1 && sql.charAt(currentIndex + 1) == startQuote) { // This is an escaped quote (e.g. 'foo\'bar'). // Note that in raw strings, the \ officially does not start an escape sequence, but the diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/PostgreSQLStatementParser.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/PostgreSQLStatementParser.java index 4f39c549de9..60b64b0cd4f 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/PostgreSQLStatementParser.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/PostgreSQLStatementParser.java @@ -46,15 +46,6 @@ Dialect getDialect() { return Dialect.POSTGRESQL; } - /** - * Indicates whether the parser supports the {@code EXPLAIN} clause. The PostgreSQL parser does - * not support it. - */ - @Override - protected boolean supportsExplain() { - return false; - } - @Override boolean supportsNestedComments() { return true; @@ -125,7 +116,8 @@ String removeCommentsAndTrimInternal(String sql) { int multiLineCommentStartIdx = -1; StringBuilder res = new StringBuilder(sql.length()); int index = 0; - while (index < sql.length()) { + int length = sql.length(); + while (index < length) { char c = sql.charAt(index); if (isInSingleLineComment) { if (c == '\n') { @@ -134,10 +126,10 @@ String removeCommentsAndTrimInternal(String sql) { res.append(c); } } else if (multiLineCommentLevel > 0) { - if (sql.length() > index + 1 && c == ASTERISK && sql.charAt(index + 1) == SLASH) { + if (length > index + 1 && c == ASTERISK && sql.charAt(index + 1) == SLASH) { multiLineCommentLevel--; if (multiLineCommentLevel == 0) { - if (!whitespaceBeforeOrAfterMultiLineComment && (sql.length() > index + 2)) { + if (!whitespaceBeforeOrAfterMultiLineComment && (length > index + 2)) { whitespaceBeforeOrAfterMultiLineComment = Character.isWhitespace(sql.charAt(index + 2)); } @@ -145,23 +137,23 @@ String removeCommentsAndTrimInternal(String sql) { // neither at the start nor at the end of SQL string, append an extra space. if (!whitespaceBeforeOrAfterMultiLineComment && (multiLineCommentStartIdx != 0) - && (index != sql.length() - 2)) { + && (index != length - 2)) { res.append(' '); } } index++; - } else if (sql.length() > index + 1 && c == SLASH && sql.charAt(index + 1) == ASTERISK) { + } else if (length > index + 1 && c == SLASH && sql.charAt(index + 1) == ASTERISK) { multiLineCommentLevel++; index++; } } else { // Check for -- which indicates the start of a single-line comment. - if (sql.length() > index + 1 && c == HYPHEN && sql.charAt(index + 1) == HYPHEN) { + if (length > index + 1 && c == HYPHEN && sql.charAt(index + 1) == HYPHEN) { // This is a single line comment. isInSingleLineComment = true; index += 2; continue; - } else if (sql.length() > index + 1 && c == SLASH && sql.charAt(index + 1) == ASTERISK) { + } else if (length > index + 1 && c == SLASH && sql.charAt(index + 1) == ASTERISK) { multiLineCommentLevel++; if (index >= 1) { whitespaceBeforeOrAfterMultiLineComment = Character.isWhitespace(sql.charAt(index - 1)); diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/SpannerStatementParser.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/SpannerStatementParser.java index fdd10bbf5ae..2689145b7ac 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/SpannerStatementParser.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/SpannerStatementParser.java @@ -46,15 +46,6 @@ Dialect getDialect() { return Dialect.GOOGLE_STANDARD_SQL; } - /** - * Indicates whether the parser supports the {@code EXPLAIN} clause. The Spanner parser does - * support it. - */ - @Override - protected boolean supportsExplain() { - return true; - } - @Override boolean supportsNestedComments() { return false; diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/StatementParserBenchmark.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/StatementParserBenchmark.java new file mode 100644 index 00000000000..e028f8027cf --- /dev/null +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/StatementParserBenchmark.java @@ -0,0 +1,81 @@ +/* + * Copyright 2025 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.connection; + +import com.google.cloud.spanner.Dialect; +import com.google.cloud.spanner.Statement; +import com.google.cloud.spanner.connection.AbstractStatementParser.ParsedStatement; +import com.google.spanner.v1.ExecuteSqlRequest.QueryOptions; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Warmup; + +@Fork(value = 1, warmups = 0) +@Warmup(iterations = 1, time = 5) +@Measurement(iterations = 5, time = 5) +public class StatementParserBenchmark { + private static final Dialect dialect = Dialect.POSTGRESQL; + private static final AbstractStatementParser PARSER = + AbstractStatementParser.getInstance(dialect); + + private static final String LONG_QUERY_TEXT = + generateLongStatement("SELECT * FROM foo WHERE 1", 100 * 1024); // 100kb + + private static final String LONG_DML_TEXT = + generateLongStatement("update foo set bar=1 WHERE 1", 100 * 1024); // 100kb + + /** Generates a long SQL-looking string. */ + private static String generateLongStatement(String prefix, int length) { + StringBuilder sb = new StringBuilder(length + 50); + sb.append(prefix); + while (sb.length() < length) { + sb.append(" OR abcdefghijklmnopqrstuvwxyz='abcdefghijklmnopqrstuvwxyz'"); + } + return sb.toString(); + } + + @Benchmark + public ParsedStatement isQueryTest() { + return PARSER.internalParse( + Statement.of("CREATE TABLE FOO (ID INT64, NAME STRING(100)) PRIMARY KEY (ID)"), + QueryOptions.getDefaultInstance()); + } + + @Benchmark + public ParsedStatement longQueryTest() { + return PARSER.internalParse(Statement.of(LONG_QUERY_TEXT), QueryOptions.getDefaultInstance()); + } + + @Benchmark + public ParsedStatement longDmlTest() { + return PARSER.internalParse(Statement.of(LONG_DML_TEXT), QueryOptions.getDefaultInstance()); + } + + public static void main(String[] args) throws Exception { + for (int i = 0; i < 100000; i++) { + if (PARSER.internalParse(Statement.of(LONG_QUERY_TEXT), QueryOptions.getDefaultInstance()) + == null) { + throw new AssertionError(); + } + if (PARSER.internalParse(Statement.of(LONG_DML_TEXT), QueryOptions.getDefaultInstance()) + == null) { + throw new AssertionError(); + } + } + } +}