diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java
index 2e01e3d4ca..516451d748 100644
--- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java
+++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java
@@ -257,6 +257,7 @@ public static GcpChannelPoolOptions createDefaultDynamicChannelPoolOptions() {
private final boolean enableEndToEndTracing;
private final String monitoringHost;
private final TransactionOptions defaultTransactionOptions;
+ private final boolean isExperimentalHost;
enum TracingFramework {
OPEN_CENSUS,
@@ -914,7 +915,7 @@ protected SpannerOptions(Builder builder) {
openTelemetry = builder.openTelemetry;
enableApiTracing = builder.enableApiTracing;
enableExtendedTracing = builder.enableExtendedTracing;
- if (builder.experimentalHost != null) {
+ if (builder.isExperimentalHost) {
enableBuiltInMetrics = false;
} else {
enableBuiltInMetrics = builder.enableBuiltInMetrics;
@@ -922,6 +923,7 @@ protected SpannerOptions(Builder builder) {
enableEndToEndTracing = builder.enableEndToEndTracing;
monitoringHost = builder.monitoringHost;
defaultTransactionOptions = builder.defaultTransactionOptions;
+ isExperimentalHost = builder.isExperimentalHost;
}
private String getResolvedUniverseDomain() {
@@ -987,6 +989,15 @@ default String getMonitoringHost() {
default GoogleCredentials getDefaultExperimentalHostCredentials() {
return null;
}
+
+ /**
+ * Returns true if the experimental location API (SpanFE bypass) should be enabled. When
+ * enabled, the client will use location-aware routing to send requests directly to the
+ * appropriate Spanner server.
+ */
+ default boolean isEnableLocationApi() {
+ return false;
+ }
}
static final String DEFAULT_SPANNER_EXPERIMENTAL_HOST_CREDENTIALS =
@@ -1011,6 +1022,8 @@ private static class SpannerEnvironmentImpl implements SpannerEnvironment {
private static final String SPANNER_DISABLE_DIRECT_ACCESS_GRPC_BUILTIN_METRICS =
"SPANNER_DISABLE_DIRECT_ACCESS_GRPC_BUILTIN_METRICS";
private static final String SPANNER_MONITORING_HOST = "SPANNER_MONITORING_HOST";
+ private static final String GOOGLE_SPANNER_EXPERIMENTAL_LOCATION_API =
+ "GOOGLE_SPANNER_EXPERIMENTAL_LOCATION_API";
private SpannerEnvironmentImpl() {}
@@ -1069,6 +1082,11 @@ public String getMonitoringHost() {
public GoogleCredentials getDefaultExperimentalHostCredentials() {
return getOAuthTokenFromFile(System.getenv(DEFAULT_SPANNER_EXPERIMENTAL_HOST_CREDENTIALS));
}
+
+ @Override
+ public boolean isEnableLocationApi() {
+ return Boolean.parseBoolean(System.getenv(GOOGLE_SPANNER_EXPERIMENTAL_LOCATION_API));
+ }
}
/** Builder for {@link SpannerOptions} instances. */
@@ -1139,8 +1157,7 @@ public static class Builder
private boolean enableBuiltInMetrics = SpannerOptions.environment.isEnableBuiltInMetrics();
private String monitoringHost = SpannerOptions.environment.getMonitoringHost();
private SslContext mTLSContext = null;
- private String experimentalHost = null;
- private boolean usePlainText = false;
+ private boolean isExperimentalHost = false;
private TransactionOptions defaultTransactionOptions = TransactionOptions.getDefaultInstance();
private static String createCustomClientLibToken(String token) {
@@ -1149,56 +1166,26 @@ private static String createCustomClientLibToken(String token) {
protected Builder() {
// Manually set retry and polling settings that work.
- RetrySettings baseRetrySettings =
- RetrySettings.newBuilder()
- .setInitialRpcTimeoutDuration(Duration.ofSeconds(60L))
- .setMaxRpcTimeoutDuration(Duration.ofSeconds(600L))
- .setMaxRetryDelayDuration(Duration.ofSeconds(45L))
- .setRetryDelayMultiplier(1.5)
- .setRpcTimeoutMultiplier(1.5)
- .setTotalTimeoutDuration(Duration.ofHours(48L))
- .build();
-
- // The polling setting with a short initial delay as we expect
- // it to return soon.
- OperationTimedPollAlgorithm shortInitialPollingDelayAlgorithm =
+ OperationTimedPollAlgorithm longRunningPollingAlgorithm =
OperationTimedPollAlgorithm.create(
- baseRetrySettings.toBuilder()
- .setInitialRetryDelayDuration(Duration.ofSeconds(1L))
+ RetrySettings.newBuilder()
+ .setInitialRpcTimeoutDuration(Duration.ofSeconds(60L))
+ .setMaxRpcTimeoutDuration(Duration.ofSeconds(600L))
+ .setInitialRetryDelayDuration(Duration.ofSeconds(20L))
+ .setMaxRetryDelayDuration(Duration.ofSeconds(45L))
+ .setRetryDelayMultiplier(1.5)
+ .setRpcTimeoutMultiplier(1.5)
+ .setTotalTimeoutDuration(Duration.ofHours(48L))
.build());
databaseAdminStubSettingsBuilder
.createDatabaseOperationSettings()
- .setPollingAlgorithm(shortInitialPollingDelayAlgorithm);
-
- // The polling setting with a long initial delay as we expect
- // the operation to take a bit long time to return.
- OperationTimedPollAlgorithm longInitialPollingDelayAlgorithm =
- OperationTimedPollAlgorithm.create(
- baseRetrySettings.toBuilder()
- .setInitialRetryDelayDuration(Duration.ofSeconds(20L))
- .build());
+ .setPollingAlgorithm(longRunningPollingAlgorithm);
databaseAdminStubSettingsBuilder
.createBackupOperationSettings()
- .setPollingAlgorithm(longInitialPollingDelayAlgorithm);
+ .setPollingAlgorithm(longRunningPollingAlgorithm);
databaseAdminStubSettingsBuilder
.restoreDatabaseOperationSettings()
- .setPollingAlgorithm(longInitialPollingDelayAlgorithm);
-
- // updateDatabaseDdl requires a separate setting because
- // it has no existing overrides on RPC timeouts for LRO polling.
- databaseAdminStubSettingsBuilder
- .updateDatabaseDdlOperationSettings()
- .setPollingAlgorithm(
- OperationTimedPollAlgorithm.create(
- RetrySettings.newBuilder()
- .setInitialRetryDelayDuration(Duration.ofMillis(1000L))
- .setRetryDelayMultiplier(1.5)
- .setMaxRetryDelayDuration(Duration.ofMillis(45000L))
- .setInitialRpcTimeoutDuration(Duration.ZERO)
- .setRpcTimeoutMultiplier(1.0)
- .setMaxRpcTimeoutDuration(Duration.ZERO)
- .setTotalTimeoutDuration(Duration.ofHours(48L))
- .build()));
+ .setPollingAlgorithm(longRunningPollingAlgorithm);
}
Builder(SpannerOptions options) {
@@ -1676,19 +1663,10 @@ public Builder setHost(String host) {
@ExperimentalApi("https://github.com/googleapis/java-spanner/pull/3676")
public Builder setExperimentalHost(String host) {
- if (this.usePlainText) {
- Preconditions.checkArgument(
- !host.startsWith("https:"),
- "Please remove the 'https:' protocol prefix from the host string when using plain text"
- + " communication");
- if (!host.startsWith("http")) {
- host = "http://" + host;
- }
- }
super.setHost(host);
super.setProjectId(EXPERIMENTAL_HOST_PROJECT_ID);
setSessionPoolOption(SessionPoolOptions.newBuilder().setExperimentalHost().build());
- this.experimentalHost = host;
+ this.isExperimentalHost = true;
return this;
}
@@ -1799,23 +1777,6 @@ public Builder useClientCert(String clientCertificate, String clientCertificateK
return this;
}
- /**
- * {@code usePlainText} will configure the transport to use plaintext (no TLS) and will set
- * credentials to {@link com.google.cloud.NoCredentials} to avoid sending authentication over an
- * unsecured channel.
- */
- @ExperimentalApi("https://github.com/googleapis/java-spanner/pull/4264")
- public Builder usePlainText() {
- this.usePlainText = true;
- this.setChannelConfigurator(ManagedChannelBuilder::usePlaintext)
- .setCredentials(NoCredentials.getInstance());
- if (this.experimentalHost != null) {
- // Re-apply host settings to ensure http:// is prepended.
- setExperimentalHost(this.experimentalHost);
- }
- return this;
- }
-
/**
* Sets OpenTelemetry object to be used for Spanner Metrics and Traces. GlobalOpenTelemetry will
* be used as fallback if this options is not set.
@@ -1981,7 +1942,7 @@ public Builder setDefaultTransactionOptions(
@Override
public SpannerOptions build() {
// Set the host of emulator has been set.
- if (emulatorHost != null && experimentalHost == null) {
+ if (emulatorHost != null) {
if (!emulatorHost.startsWith("http")) {
emulatorHost = "http://" + emulatorHost;
}
@@ -1991,7 +1952,7 @@ public SpannerOptions build() {
this.setChannelConfigurator(ManagedChannelBuilder::usePlaintext);
// As we are using plain text, we should never send any credentials.
this.setCredentials(NoCredentials.getInstance());
- } else if (experimentalHost != null && credentials == null) {
+ } else if (isExperimentalHost && credentials == null) {
credentials = environment.getDefaultExperimentalHostCredentials();
}
if (this.numChannels == null) {
@@ -2033,6 +1994,12 @@ public static void useDefaultEnvironment() {
SpannerOptions.environment = SpannerEnvironmentImpl.INSTANCE;
}
+ /** Returns the current {@link SpannerEnvironment}. */
+ @InternalApi
+ public static SpannerEnvironment getEnvironment() {
+ return environment;
+ }
+
@InternalApi
public static GoogleCredentials getDefaultExperimentalCredentialsFromSysEnv() {
return getOAuthTokenFromFile(System.getenv(DEFAULT_SPANNER_EXPERIMENTAL_HOST_CREDENTIALS));
@@ -2379,6 +2346,10 @@ public TransactionOptions getDefaultTransactionOptions() {
return defaultTransactionOptions;
}
+ public boolean isExperimentalHost() {
+ return isExperimentalHost;
+ }
+
@BetaApi
public boolean isUseVirtualThreads() {
return useVirtualThreads;
diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ChannelFinder.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ChannelFinder.java
new file mode 100644
index 0000000000..0398eed1d4
--- /dev/null
+++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ChannelFinder.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2026 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.spi.v1;
+
+import com.google.spanner.v1.CacheUpdate;
+import com.google.spanner.v1.ReadRequest;
+import com.google.spanner.v1.RoutingHint;
+import java.util.concurrent.locks.ReadWriteLock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+import javax.annotation.Nullable;
+
+/**
+ * ChannelFinder is responsible for finding the correct Spanner server to route RPCs to.
+ *
+ *
It uses a {@link KeyRecipeCache} and a {@link KeyRangeCache} to store metadata about the
+ * database, including key recipes and range information. This metadata is updated through the
+ * {@link #update(CacheUpdate)} method.
+ *
+ *
The {@link #findServer(ReadRequest.Builder)} method is used to determine the appropriate
+ * server for a given read request.
+ */
+public final class ChannelFinder {
+ private final String deployment;
+ private final String databaseUri;
+ private final KeyRangeCache rangeCache;
+ private final KeyRecipeCache recipeCache;
+ private final ReadWriteLock lock = new ReentrantReadWriteLock();
+ private long databaseId = 0L;
+ private final ChannelFinderServerFactory serverFactory;
+
+ public ChannelFinder(
+ ChannelFinderServerFactory serverFactory, String deployment, String databaseUri) {
+ this.serverFactory = serverFactory;
+ this.deployment = deployment;
+ this.databaseUri = databaseUri;
+ this.rangeCache = new KeyRangeCache(serverFactory);
+ this.recipeCache = new KeyRecipeCache();
+ }
+
+ /**
+ * Updates the cache with new metadata.
+ *
+ * @param cacheUpdate The cache update information.
+ */
+ public void update(CacheUpdate cacheUpdate) {
+ lock.writeLock().lock();
+ try {
+ if (databaseId != cacheUpdate.getDatabaseId()) {
+ System.out.println(
+ "DEBUG [BYPASS]: Database ID changed from "
+ + databaseId
+ + " to "
+ + cacheUpdate.getDatabaseId()
+ + ", clearing caches");
+ recipeCache.clear();
+ rangeCache.clear();
+ databaseId = cacheUpdate.getDatabaseId();
+ }
+ recipeCache.addRecipes(cacheUpdate.getKeyRecipes());
+ rangeCache.addRanges(cacheUpdate);
+ System.out.println(
+ "DEBUG [BYPASS]: Cache updated. Current state:\n" + rangeCache.debugString());
+ } finally {
+ lock.writeLock().unlock();
+ }
+ }
+
+ /**
+ * Finds the server for a given ReadRequest.
+ *
+ * @param reqBuilder The ReadRequest builder.
+ * @return The server to route the request to, or null if an error occurs.
+ */
+ @Nullable
+ public ChannelFinderServer findServer(ReadRequest.Builder reqBuilder) {
+ RoutingHint.Builder hintBuilder = reqBuilder.getRoutingHintBuilder();
+ lock.readLock().lock();
+ try {
+ if (databaseId != 0) {
+ hintBuilder.setDatabaseId(databaseId);
+ }
+ System.out.println(
+ "DEBUG [BYPASS]: findServer - computing keys for table: " + reqBuilder.getTable());
+ recipeCache.computeKeys(reqBuilder); // Modifies hintBuilder within reqBuilder
+ System.out.println(
+ "DEBUG [BYPASS]: findServer - after computeKeys, key: "
+ + hintBuilder.getKey().toStringUtf8());
+ ChannelFinderServer server =
+ rangeCache.fillRoutingInfo(reqBuilder.getSession(), false, hintBuilder);
+ System.out.println(
+ "DEBUG [BYPASS]: findServer - fillRoutingInfo returned server: "
+ + (server != null ? server.getAddress() : "null"));
+ return server;
+ } finally {
+ lock.readLock().unlock();
+ }
+ }
+
+ /**
+ * Returns a debug string representation of the cache.
+ *
+ * @return A string containing debug information.
+ */
+ public String debugString() {
+ lock.readLock().lock();
+ try {
+ return rangeCache.debugString();
+ } finally {
+ lock.readLock().unlock();
+ }
+ }
+}
diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ChannelFinderServer.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ChannelFinderServer.java
new file mode 100644
index 0000000000..27a0b5d31a
--- /dev/null
+++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ChannelFinderServer.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2026 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.spi.v1;
+
+import io.grpc.ManagedChannel;
+
+/** Represents a Spanner server endpoint for location-aware routing. */
+public interface ChannelFinderServer {
+ String getAddress();
+
+ boolean isHealthy();
+
+ ManagedChannel getChannel(); // Added to get the underlying channel for RPC calls
+}
diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ChannelFinderServerFactory.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ChannelFinderServerFactory.java
new file mode 100644
index 0000000000..c81cf82c0d
--- /dev/null
+++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ChannelFinderServerFactory.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2026 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.spi.v1;
+
+/** Factory for creating and caching server connections for location-aware routing. */
+public interface ChannelFinderServerFactory {
+ ChannelFinderServer defaultServer();
+
+ ChannelFinderServer create(String address);
+}
diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/GapicSpannerRpc.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/GapicSpannerRpc.java
index 08a13f2ca9..d9b89c68e9 100644
--- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/GapicSpannerRpc.java
+++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/GapicSpannerRpc.java
@@ -57,6 +57,7 @@
import com.google.api.gax.rpc.UnavailableException;
import com.google.api.gax.rpc.WatchdogProvider;
import com.google.api.pathtemplate.PathTemplate;
+import com.google.auth.Credentials;
import com.google.cloud.RetryHelper;
import com.google.cloud.RetryHelper.RetryHelperException;
import com.google.cloud.grpc.GcpManagedChannel;
@@ -209,11 +210,14 @@
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
+import java.util.logging.Level;
+import java.util.logging.Logger;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.annotation.Nullable;
@@ -223,6 +227,7 @@
public class GapicSpannerRpc implements SpannerRpc {
private static final PathTemplate PROJECT_NAME_TEMPLATE =
PathTemplate.create("projects/{project}");
+ private static final Logger logger = Logger.getLogger(GapicSpannerRpc.class.getName());
private static final PathTemplate OPERATION_NAME_TEMPLATE =
PathTemplate.create("{database=projects/*/instances/*/databases/*}/operations/{operation}");
private static final int MAX_MESSAGE_SIZE = 256 * 1024 * 1024;
@@ -285,6 +290,89 @@ public class GapicSpannerRpc implements SpannerRpc {
private final GrpcCallContext baseGrpcCallContext;
+ private static class KeyAwareTransportChannelProvider implements TransportChannelProvider {
+ private final InstantiatingGrpcChannelProvider.Builder delegateBuilder;
+ private final TransportChannelProvider delegate;
+
+ public KeyAwareTransportChannelProvider(
+ InstantiatingGrpcChannelProvider.Builder delegateBuilder) {
+ this.delegateBuilder = delegateBuilder;
+ this.delegate = delegateBuilder.build();
+ }
+
+ @Override
+ public GrpcTransportChannel getTransportChannel() throws IOException {
+ return GrpcTransportChannel.newBuilder()
+ .setManagedChannel(KeyAwareChannel.create(delegateBuilder))
+ .build();
+ }
+
+ @Override
+ public String getTransportName() {
+ return delegate.getTransportName();
+ }
+
+ @Override
+ public boolean needsEndpoint() {
+ return delegate.needsEndpoint();
+ }
+
+ @Override
+ public boolean needsCredentials() {
+ return delegate.needsCredentials();
+ }
+
+ @Override
+ public boolean needsExecutor() {
+ return delegate.needsExecutor();
+ }
+
+ @Override
+ public boolean needsHeaders() {
+ return delegate.needsHeaders();
+ }
+
+ @Override
+ public boolean shouldAutoClose() {
+ return delegate.shouldAutoClose();
+ }
+
+ @Override
+ public TransportChannelProvider withEndpoint(String endpoint) {
+ return delegate.withEndpoint(endpoint);
+ }
+
+ @Override
+ public TransportChannelProvider withCredentials(Credentials credentials) {
+ return delegate.withCredentials(credentials);
+ }
+
+ @Override
+ public TransportChannelProvider withHeaders(java.util.Map headers) {
+ return delegate.withHeaders(headers);
+ }
+
+ @Override
+ public TransportChannelProvider withPoolSize(int poolSize) {
+ return delegate.withPoolSize(poolSize);
+ }
+
+ @Override
+ public TransportChannelProvider withExecutor(ScheduledExecutorService executor) {
+ return delegate.withExecutor(executor);
+ }
+
+ @Override
+ public TransportChannelProvider withExecutor(Executor executor) {
+ return delegate.withExecutor(executor);
+ }
+
+ @Override
+ public boolean acceptsPoolSize() {
+ return delegate.acceptsPoolSize();
+ }
+ }
+
public static GapicSpannerRpc create(SpannerOptions options) {
return new GapicSpannerRpc(options);
}
@@ -393,9 +481,35 @@ public GapicSpannerRpc(final SpannerOptions options) {
// If it is enabled in options uses the channel pool provided by the gRPC-GCP extension.
maybeEnableGrpcGcpExtension(defaultChannelProviderBuilder, options);
- TransportChannelProvider channelProvider =
- MoreObjects.firstNonNull(
- options.getChannelProvider(), defaultChannelProviderBuilder.build());
+ TransportChannelProvider channelProvider;
+ // Enable KeyAwareChannel (SpanFE bypass / location API) only when BOTH conditions are met:
+ // 1. Using experimental host (setExperimentalHost was called)
+ // 2. GOOGLE_SPANNER_EXPERIMENTAL_LOCATION_API env var is set to "true"
+ // Default is DISABLED even for experimental host.
+ String locationApiEnvVar = System.getenv("GOOGLE_SPANNER_EXPERIMENTAL_LOCATION_API");
+ boolean isExperimentalHost = options.isExperimentalHost();
+ boolean envVarEnabled = "true".equalsIgnoreCase(locationApiEnvVar);
+
+ // Both conditions must be true to enable bypass
+ boolean enableLocationApi = isExperimentalHost && envVarEnabled;
+
+ logger.log(
+ Level.INFO,
+ "SpanFE bypass (KeyAwareChannel) configuration: "
+ + "GOOGLE_SPANNER_EXPERIMENTAL_LOCATION_API={0}, "
+ + "isExperimentalHost={1}, "
+ + "enableLocationApi={2}",
+ new Object[] {locationApiEnvVar, isExperimentalHost, enableLocationApi});
+
+ if (enableLocationApi) {
+ channelProvider = new KeyAwareTransportChannelProvider(defaultChannelProviderBuilder);
+ logger.log(Level.INFO, "KeyAwareChannel (SpanFE bypass) ENABLED");
+ } else {
+ channelProvider =
+ MoreObjects.firstNonNull(
+ options.getChannelProvider(), defaultChannelProviderBuilder.build());
+ logger.log(Level.INFO, "KeyAwareChannel (SpanFE bypass) DISABLED - using standard routing");
+ }
CredentialsProvider credentialsProvider =
GrpcTransportOptions.setUpCredentialsProvider(options);
diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/GrpcChannelFinderServerFactory.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/GrpcChannelFinderServerFactory.java
new file mode 100644
index 0000000000..8c120f0773
--- /dev/null
+++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/GrpcChannelFinderServerFactory.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2026 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.spi.v1;
+
+import com.google.api.gax.grpc.GrpcTransportChannel;
+import com.google.api.gax.grpc.InstantiatingGrpcChannelProvider;
+import io.grpc.ManagedChannel;
+import java.io.IOException;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+class GrpcChannelFinderServerFactory implements ChannelFinderServerFactory {
+ private final InstantiatingGrpcChannelProvider.Builder channelBuilder;
+ private final Map servers = new ConcurrentHashMap<>();
+ private final GrpcChannelFinderServer defaultServer;
+
+ public GrpcChannelFinderServerFactory(InstantiatingGrpcChannelProvider.Builder channelBuilder)
+ throws IOException {
+ this.channelBuilder = channelBuilder;
+ // The "default" server will use the original endpoint from the builder.
+ this.defaultServer =
+ new GrpcChannelFinderServer(this.channelBuilder.getEndpoint(), channelBuilder.build());
+ this.servers.put(this.defaultServer.getAddress(), this.defaultServer);
+ }
+
+ @Override
+ public ChannelFinderServer defaultServer() {
+ return defaultServer;
+ }
+
+ @Override
+ public ChannelFinderServer create(String address) {
+ return servers.computeIfAbsent(
+ address,
+ addr -> {
+ try {
+ // Modify the builder to use the new address
+ synchronized (channelBuilder) {
+ InstantiatingGrpcChannelProvider.Builder newBuilder =
+ channelBuilder.setEndpoint(addr);
+ return new GrpcChannelFinderServer(addr, newBuilder.build());
+ }
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to create channel for address: " + addr, e);
+ }
+ });
+ }
+
+ static class GrpcChannelFinderServer implements ChannelFinderServer {
+ private final String address;
+ private final ManagedChannel channel;
+
+ public GrpcChannelFinderServer(String address, InstantiatingGrpcChannelProvider provider)
+ throws IOException {
+ this.address = address;
+ // It's assumed that getTransportChannel() returns a ManagedChannel or can be cast to one.
+ // For this example, GrpcTransportChannel is used as in KeyAwareChannel.
+ GrpcTransportChannel transportChannel = (GrpcTransportChannel) provider.getTransportChannel();
+ this.channel = (ManagedChannel) transportChannel.getChannel();
+ }
+
+ // Constructor for the default server that already has a channel
+ public GrpcChannelFinderServer(String address, ManagedChannel channel) {
+ this.address = address;
+ this.channel = channel;
+ }
+
+ @Override
+ public String getAddress() {
+ return address;
+ }
+
+ @Override
+ public boolean isHealthy() {
+ // A simple health check. In a real scenario, this might involve a ping or other checks.
+ return !channel.isShutdown() && !channel.isTerminated();
+ }
+
+ @Override
+ public ManagedChannel getChannel() {
+ return channel;
+ }
+ }
+}
diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyAwareChannel.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyAwareChannel.java
new file mode 100644
index 0000000000..b8cfc1b8e7
--- /dev/null
+++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyAwareChannel.java
@@ -0,0 +1,314 @@
+/*
+ * Copyright 2026 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.spi.v1;
+
+import com.google.api.gax.grpc.InstantiatingGrpcChannelProvider;
+import com.google.spanner.v1.PartialResultSet;
+import com.google.spanner.v1.ReadRequest;
+import io.grpc.CallOptions;
+import io.grpc.ClientCall;
+import io.grpc.ForwardingClientCall;
+import io.grpc.ForwardingClientCallListener.SimpleForwardingClientCallListener;
+import io.grpc.ManagedChannel;
+import io.grpc.Metadata;
+import io.grpc.MethodDescriptor;
+import java.io.IOException;
+import java.lang.ref.SoftReference;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import javax.annotation.Nullable;
+
+/**
+ * KeyAwareChannel is a ManagedChannel that intercepts calls to key-aware Spanner methods, primarily
+ * StreamingRead. It uses a ChannelFinder to select the appropriate server based on the request's
+ * key information. The ChannelFinder's cache is updated with information received in response
+ * headers.
+ */
+final class KeyAwareChannel extends ManagedChannel {
+ private final ManagedChannel defaultChannel; // The original channel from the builder
+ private final GrpcChannelFinderServerFactory serverFactory;
+ private final String authority; // Authority from the original channel
+ private final String deployment; // Global deployment ID, derived from endpoint
+ private final Map> channelFinders =
+ new ConcurrentHashMap<>();
+
+ private KeyAwareChannel(InstantiatingGrpcChannelProvider.Builder channelBuilder)
+ throws IOException {
+ this.serverFactory = new GrpcChannelFinderServerFactory(channelBuilder);
+ this.defaultChannel = this.serverFactory.defaultServer().getChannel();
+ this.authority = this.defaultChannel.authority();
+ // Use the builder's original endpoint as the deployment identifier
+ this.deployment = channelBuilder.build().getEndpoint();
+ }
+
+ static KeyAwareChannel create(InstantiatingGrpcChannelProvider.Builder channelBuilder)
+ throws IOException {
+ return new KeyAwareChannel(channelBuilder);
+ }
+
+ private String extractDatabaseIdFromSession(String session) {
+ if (session == null || session.isEmpty()) {
+ return null;
+ }
+ // Session format:
+ // projects/{project}/instances/{instance}/databases/{database}/sessions/{session_id}
+ // Database ID: projects/{project}/instances/{instance}/databases/{database}
+ int sessionsIndex = session.indexOf("/sessions/");
+ if (sessionsIndex == -1) {
+ return null;
+ }
+ return session.substring(0, sessionsIndex);
+ }
+
+ private ChannelFinder getOrCreateChannelFinder(String databaseId) {
+ SoftReference ref = channelFinders.get(databaseId);
+ ChannelFinder finder = (ref != null) ? ref.get() : null;
+ if (finder == null) {
+ synchronized (channelFinders) { // Synchronize to prevent duplicate creation
+ // Double-check after acquiring lock
+ ref = channelFinders.get(databaseId);
+ finder = (ref != null) ? ref.get() : null;
+ if (finder == null) {
+ // The databaseId (e.g., projects/../databases/DB_NAME) is used as the databaseUri
+ finder = new ChannelFinder(this.serverFactory, this.deployment, databaseId);
+ channelFinders.put(databaseId, new SoftReference<>(finder));
+ }
+ }
+ }
+ return finder;
+ }
+
+ @Override
+ public ManagedChannel shutdownNow() {
+ // TODO: Need to manage shutdown of all created channels in serverFactory
+ // and clear channelFinders map, potentially shutting down individual finders/channels.
+ return this;
+ }
+
+ @Override
+ public ManagedChannel shutdown() {
+ // TODO: Need to manage shutdown of all created channels in serverFactory
+ return this;
+ }
+
+ @Override
+ public ClientCall newCall(
+ MethodDescriptor methodDescriptor, CallOptions callOptions) {
+ if (isKeyAware(methodDescriptor)) {
+ return new KeyAwareClientCall<>(this, methodDescriptor, callOptions);
+ }
+ return defaultChannel.newCall(methodDescriptor, callOptions);
+ }
+
+ @Override
+ public boolean isTerminated() {
+ return defaultChannel.isTerminated();
+ }
+
+ @Override
+ public boolean isShutdown() {
+ return defaultChannel.isShutdown();
+ }
+
+ @Override
+ public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException {
+ return defaultChannel.awaitTermination(timeout, unit);
+ }
+
+ @Override
+ public String authority() {
+ return authority;
+ }
+
+ // Determines if a method is key-aware (e.g., StreamingRead)
+ boolean isKeyAware(MethodDescriptor, ?> methodDescriptor) {
+ return "google.spanner.v1.Spanner/StreamingRead".equals(methodDescriptor.getFullMethodName());
+ }
+
+ static class KeyAwareClientCall
+ extends ForwardingClientCall {
+ private final KeyAwareChannel parentChannel;
+ private final MethodDescriptor methodDescriptor;
+ private final CallOptions callOptions;
+ private Listener responseListener;
+ private Metadata headers;
+ @Nullable private ClientCall delegate;
+ private ChannelFinder channelFinder; // Set in sendMessage
+
+ KeyAwareClientCall(
+ KeyAwareChannel parentChannel,
+ MethodDescriptor methodDescriptor,
+ CallOptions callOptions) {
+ this.parentChannel = parentChannel;
+ this.methodDescriptor = methodDescriptor;
+ this.callOptions = callOptions;
+ }
+
+ @Override
+ protected ClientCall delegate() {
+ if (delegate == null) {
+ // This should not happen in normal flow as sendMessage initializes the delegate.
+ // If it does, it means a method like halfClose() or cancel() was called before
+ // sendMessage().
+ throw new IllegalStateException(
+ "Delegate call not initialized before use. sendMessage was likely not called.");
+ }
+ return delegate;
+ }
+
+ @Override
+ public void start(Listener responseListener, Metadata headers) {
+ this.responseListener = new KeyAwareClientCallListener<>(responseListener, this);
+ this.headers = headers;
+ }
+
+ @Override
+ public void sendMessage(RequestT message) {
+ ChannelFinderServer server = null;
+
+ if (message instanceof ReadRequest) {
+ ReadRequest.Builder reqBuilder = ((ReadRequest) message).toBuilder();
+ String databaseId = parentChannel.extractDatabaseIdFromSession(reqBuilder.getSession());
+
+ if (databaseId == null) {
+ server = parentChannel.serverFactory.defaultServer();
+ System.out.println(
+ "DEBUG [BYPASS]: No database ID found, using default server: " + server.getAddress());
+ } else {
+ this.channelFinder = parentChannel.getOrCreateChannelFinder(databaseId);
+ server = this.channelFinder.findServer(reqBuilder);
+ message = (RequestT) reqBuilder.build(); // Apply routing info changes
+
+ ReadRequest finalReq = (ReadRequest) message;
+ System.out.println("DEBUG [BYPASS]: === Request Details ===");
+ System.out.println("DEBUG [BYPASS]: Table: " + finalReq.getTable());
+ System.out.println("DEBUG [BYPASS]: KeySet: " + finalReq.getKeySet());
+ System.out.println("DEBUG [BYPASS]: Routing hint: " + finalReq.getRoutingHint());
+ System.out.println("DEBUG [BYPASS]: Selected server: " + server.getAddress());
+ System.out.println(
+ "DEBUG [BYPASS]: Is bypass routing: "
+ + (finalReq.getRoutingHint().getGroupUid() != 0));
+ System.out.println("DEBUG [BYPASS]: ========================");
+ }
+ } else {
+ // Other types of requests should never be passed to KeyAwareClientCall to begin with.
+ throw new IllegalStateException("Only ReadRequest is supported for key-aware calls.");
+ }
+
+ delegate = server.getChannel().newCall(methodDescriptor, callOptions);
+ delegate.start(responseListener, headers);
+ delegate.sendMessage(message);
+ }
+
+ @Override
+ public void halfClose() {
+ if (delegate != null) {
+ delegate.halfClose();
+ } else {
+ // Handle the case where sendMessage was never called, though this is unlikely
+ // in normal gRPC client flows.
+ throw new IllegalStateException("halfClose called before sendMessage");
+ }
+ }
+
+ @Override
+ public void cancel(@Nullable String message, @Nullable Throwable cause) {
+ if (delegate != null) {
+ delegate.cancel(message, cause);
+ } else {
+ // If cancel is called before sendMessage, there's no delegate to cancel.
+ // The listener's onClosed can be invoked to signal termination.
+ if (responseListener != null) {
+ responseListener.onClose(
+ io.grpc.Status.CANCELLED.withDescription(message).withCause(cause), new Metadata());
+ }
+ }
+ }
+ }
+
+ static class KeyAwareClientCallListener
+ extends SimpleForwardingClientCallListener {
+ private final KeyAwareClientCall, ResponseT> call;
+
+ KeyAwareClientCallListener(
+ ClientCall.Listener responseListener, KeyAwareClientCall, ResponseT> call) {
+ super(responseListener);
+ this.call = call;
+ }
+
+ @Override
+ public void onMessage(ResponseT message) {
+ if (message instanceof PartialResultSet) {
+ PartialResultSet response = (PartialResultSet) message;
+ if (response.hasCacheUpdate() && call.channelFinder != null) {
+ com.google.spanner.v1.CacheUpdate update = response.getCacheUpdate();
+ System.out.println("DEBUG [BYPASS]: === CacheUpdate Received ===");
+ System.out.println("DEBUG [BYPASS]: database_id: " + update.getDatabaseId());
+ System.out.println("DEBUG [BYPASS]: groups count: " + update.getGroupCount());
+ System.out.println("DEBUG [BYPASS]: ranges count: " + update.getRangeCount());
+ if (update.hasKeyRecipes()) {
+ System.out.println(
+ "DEBUG [BYPASS]: recipes count: " + update.getKeyRecipes().getRecipeCount());
+ System.out.println(
+ "DEBUG [BYPASS]: schema_generation: "
+ + update.getKeyRecipes().getSchemaGeneration());
+ }
+ for (int i = 0; i < update.getGroupCount(); i++) {
+ com.google.spanner.v1.Group g = update.getGroup(i);
+ System.out.println(
+ "DEBUG [BYPASS]: Group["
+ + i
+ + "]: uid="
+ + g.getGroupUid()
+ + ", tablets="
+ + g.getTabletsCount()
+ + ", leader_index="
+ + g.getLeaderIndex());
+ for (int t = 0; t < g.getTabletsCount(); t++) {
+ com.google.spanner.v1.Tablet tab = g.getTablets(t);
+ System.out.println(
+ "DEBUG [BYPASS]: Tablet["
+ + t
+ + "]: uid="
+ + tab.getTabletUid()
+ + ", server="
+ + tab.getServerAddress()
+ + ", distance="
+ + tab.getDistance()
+ + ", skip="
+ + tab.getSkip());
+ }
+ }
+ for (int i = 0; i < update.getRangeCount(); i++) {
+ com.google.spanner.v1.Range r = update.getRange(i);
+ System.out.println(
+ "DEBUG [BYPASS]: Range["
+ + i
+ + "]: group_uid="
+ + r.getGroupUid()
+ + ", split_id="
+ + r.getSplitId());
+ }
+ System.out.println("DEBUG [BYPASS]: ============================");
+ call.channelFinder.update(update);
+ }
+ }
+ super.onMessage(message);
+ }
+ }
+}
diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyRangeCache.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyRangeCache.java
new file mode 100644
index 0000000000..a16d6555e5
--- /dev/null
+++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyRangeCache.java
@@ -0,0 +1,643 @@
+/*
+ * Copyright 2026 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.spi.v1;
+
+import com.google.protobuf.ByteString;
+import com.google.spanner.v1.CacheUpdate;
+import com.google.spanner.v1.Group;
+import com.google.spanner.v1.Range;
+import com.google.spanner.v1.RoutingHint;
+import com.google.spanner.v1.Tablet;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.NavigableMap;
+import java.util.Objects;
+import java.util.TreeMap;
+
+/**
+ * Cache for routing information. - Tablets are stored directly within Groups - Groups are updated
+ * atomically with their tablets - Ranges reference groups
+ */
+public final class KeyRangeCache {
+
+ private final ChannelFinderServerFactory serverFactory;
+
+ // Map keyed by limit_key, value contains start_key and group reference
+ private final NavigableMap ranges =
+ new TreeMap<>(ByteString.unsignedLexicographicalComparator());
+
+ // Groups indexed by group_uid
+ private final Map groups = new HashMap<>();
+
+ // Servers indexed by address - shared across all tablets
+ private final Map servers = new HashMap<>();
+
+ public KeyRangeCache(ChannelFinderServerFactory serverFactory) {
+ this.serverFactory = Objects.requireNonNull(serverFactory);
+ }
+
+ private static class ServerEntry {
+ final ChannelFinderServer server;
+ int refs = 1;
+
+ ServerEntry(ChannelFinderServer server) {
+ this.server = server;
+ }
+
+ String debugString() {
+ return server.getAddress() + "#" + refs;
+ }
+ }
+
+ /**
+ * Represents a single tablet within a Group. Tablets are stored directly in the Group, not in a
+ * separate cache.
+ */
+ private class CachedTablet {
+ long tabletUid = 0;
+ ByteString incarnation = ByteString.EMPTY;
+ String serverAddress = "";
+ int distance = 0;
+ boolean skip = false;
+ Tablet.Role role = Tablet.Role.ROLE_UNSPECIFIED;
+ String location = "";
+
+ // Lazily initialized server connection
+ ChannelFinderServer server = null;
+
+ CachedTablet() {}
+
+ /** Updates tablet from proto, ignoring updates that are too old. */
+ void update(Tablet tabletIn) {
+ // Check incarnation - only update if newer
+ if (tabletUid > 0
+ && ByteString.unsignedLexicographicalComparator()
+ .compare(incarnation, tabletIn.getIncarnation())
+ > 0) {
+ return;
+ }
+
+ tabletUid = tabletIn.getTabletUid();
+ incarnation = tabletIn.getIncarnation();
+ distance = tabletIn.getDistance();
+ skip = tabletIn.getSkip();
+ role = tabletIn.getRole();
+ location = tabletIn.getLocation();
+
+ // Only reset server if address changed
+ if (!serverAddress.equals(tabletIn.getServerAddress())) {
+ serverAddress = tabletIn.getServerAddress();
+ server = null; // Will be lazily initialized
+ }
+ }
+
+ /** Returns true if tablet should be skipped (unhealthy, marked skip, or no address). */
+ boolean shouldSkip(RoutingHint.Builder hintBuilder) {
+ if (skip || serverAddress.isEmpty()) {
+ addSkippedTablet(hintBuilder);
+ return true;
+ }
+ // Check server health
+ if (server != null && !server.isHealthy()) {
+ addSkippedTablet(hintBuilder);
+ return true;
+ }
+ return false;
+ }
+
+ private void addSkippedTablet(RoutingHint.Builder hintBuilder) {
+ RoutingHint.SkippedTablet.Builder skipped = hintBuilder.addSkippedTabletUidBuilder();
+ skipped.setTabletUid(tabletUid);
+ skipped.setIncarnation(incarnation);
+ }
+
+ /** Picks this tablet for the request and returns the server. */
+ ChannelFinderServer pick(RoutingHint.Builder hintBuilder) {
+ hintBuilder.setTabletUid(tabletUid);
+ if (server == null && !serverAddress.isEmpty()) {
+ // Lazy server initialization
+ ServerEntry entry = findOrInsertServer(serverAddress);
+ server = entry.server;
+ }
+ return server;
+ }
+
+ String debugString() {
+ return tabletUid
+ + ":"
+ + serverAddress
+ + "@"
+ + incarnation
+ + "(location="
+ + location
+ + ",role="
+ + role
+ + ",distance="
+ + distance
+ + (skip ? ",skip" : "")
+ + ")";
+ }
+ }
+
+ /** Represents a paxos group with its tablets. Tablets are stored directly in the group. */
+ private class CachedGroup {
+ final long groupUid;
+ ByteString generation = ByteString.EMPTY;
+ List tablets = new ArrayList<>();
+ int leaderIndex = -1;
+ int refs = 1;
+
+ CachedGroup(long groupUid) {
+ this.groupUid = groupUid;
+ }
+
+ /** Updates group from proto, including its tablets. */
+ void update(Group groupIn) {
+ System.out.println(
+ "DEBUG [BYPASS]: Group.update for group "
+ + groupUid
+ + ", incoming tablets: "
+ + groupIn.getTabletsCount()
+ + ", leader_index: "
+ + groupIn.getLeaderIndex());
+
+ // Only update leader if generation is newer
+ if (ByteString.unsignedLexicographicalComparator()
+ .compare(groupIn.getGeneration(), generation)
+ > 0) {
+ generation = groupIn.getGeneration();
+
+ // Update leader index
+ if (groupIn.getLeaderIndex() >= 0 && groupIn.getLeaderIndex() < groupIn.getTabletsCount()) {
+ leaderIndex = groupIn.getLeaderIndex();
+ System.out.println("DEBUG [BYPASS]: Set leader_index to " + leaderIndex);
+ } else {
+ leaderIndex = -1;
+ System.out.println("DEBUG [BYPASS]: No valid leader, set to -1");
+ }
+ }
+
+ // Update tablet locations. Optimize for typical case where tablets haven't changed.
+ if (tablets.size() == groupIn.getTabletsCount()) {
+ boolean mismatch = false;
+ for (int t = 0; t < groupIn.getTabletsCount(); t++) {
+ if (tablets.get(t).tabletUid != groupIn.getTablets(t).getTabletUid()) {
+ mismatch = true;
+ break;
+ }
+ }
+ if (!mismatch) {
+ // Same tablets, just update them in place
+ System.out.println("DEBUG [BYPASS]: Tablets unchanged, updating in place");
+ for (int t = 0; t < groupIn.getTabletsCount(); t++) {
+ tablets.get(t).update(groupIn.getTablets(t));
+ }
+ return;
+ }
+ }
+
+ // Tablets changed - rebuild the list, reusing existing tablets where possible
+ System.out.println("DEBUG [BYPASS]: Rebuilding tablet list");
+ Map tabletsByUid = new HashMap<>();
+ for (CachedTablet tablet : tablets) {
+ tabletsByUid.put(tablet.tabletUid, tablet);
+ }
+
+ List newTablets = new ArrayList<>(groupIn.getTabletsCount());
+ for (int t = 0; t < groupIn.getTabletsCount(); t++) {
+ Tablet tabletIn = groupIn.getTablets(t);
+ CachedTablet tablet = tabletsByUid.get(tabletIn.getTabletUid());
+ if (tablet == null) {
+ tablet = new CachedTablet();
+ System.out.println(
+ "DEBUG [BYPASS]: Created new tablet for uid " + tabletIn.getTabletUid());
+ }
+ tablet.update(tabletIn);
+ System.out.println(
+ "DEBUG [BYPASS]: Tablet["
+ + t
+ + "]: uid="
+ + tablet.tabletUid
+ + ", server="
+ + tablet.serverAddress
+ + ", distance="
+ + tablet.distance);
+ newTablets.add(tablet);
+ }
+ tablets = newTablets;
+ System.out.println(
+ "DEBUG [BYPASS]: Group " + groupUid + " now has " + tablets.size() + " tablets");
+ }
+
+ /** Fills routing hint with tablet information and returns the server. */
+ ChannelFinderServer fillRoutingHint(boolean preferLeader, RoutingHint.Builder hintBuilder) {
+ System.out.println(
+ "DEBUG [BYPASS]: Group.fillRoutingHint - preferLeader: "
+ + preferLeader
+ + ", tablets count: "
+ + tablets.size());
+
+ // Try leader first if preferred
+ if (preferLeader && hasLeader()) {
+ CachedTablet leaderTablet = leader();
+ System.out.println(
+ "DEBUG [BYPASS]: Trying leader tablet: uid="
+ + leaderTablet.tabletUid
+ + ", address="
+ + leaderTablet.serverAddress
+ + ", skip="
+ + leaderTablet.skip);
+ if (!leaderTablet.shouldSkip(hintBuilder)) {
+ ChannelFinderServer server = leaderTablet.pick(hintBuilder);
+ System.out.println(
+ "DEBUG [BYPASS]: Leader tablet picked, server: "
+ + (server != null ? server.getAddress() : "null"));
+ return server;
+ }
+ }
+
+ // Try other tablets in order (they're ordered by distance)
+ for (int i = 0; i < tablets.size(); i++) {
+ CachedTablet tablet = tablets.get(i);
+ System.out.println(
+ "DEBUG [BYPASS]: Trying tablet["
+ + i
+ + "]: uid="
+ + tablet.tabletUid
+ + ", address="
+ + tablet.serverAddress
+ + ", distance="
+ + tablet.distance
+ + ", skip="
+ + tablet.skip);
+ if (!tablet.shouldSkip(hintBuilder)) {
+ ChannelFinderServer server = tablet.pick(hintBuilder);
+ System.out.println(
+ "DEBUG [BYPASS]: Tablet["
+ + i
+ + "] picked, server: "
+ + (server != null ? server.getAddress() : "null"));
+ return server;
+ }
+ }
+
+ System.out.println("DEBUG [BYPASS]: No suitable tablet found in group");
+ return null;
+ }
+
+ boolean hasLeader() {
+ return leaderIndex >= 0 && leaderIndex < tablets.size();
+ }
+
+ CachedTablet leader() {
+ return tablets.get(leaderIndex);
+ }
+
+ String debugString() {
+ StringBuilder sb = new StringBuilder();
+ sb.append(groupUid).append(":[");
+ for (int i = 0; i < tablets.size(); i++) {
+ sb.append(tablets.get(i).debugString());
+ if (hasLeader() && i == leaderIndex) {
+ sb.append(" (leader)");
+ }
+ if (i < tablets.size() - 1) {
+ sb.append(", ");
+ }
+ }
+ sb.append("]@").append(generation.toStringUtf8());
+ sb.append("#").append(refs);
+ return sb.toString();
+ }
+ }
+
+ /** Represents a cached range with its group and split information. */
+ private static class CachedRange {
+ final ByteString startKey;
+ CachedGroup group = null;
+ long splitId = 0;
+ ByteString generation;
+
+ CachedRange(ByteString startKey, CachedGroup group, long splitId, ByteString generation) {
+ this.startKey = startKey;
+ this.group = group;
+ this.splitId = splitId;
+ this.generation = generation;
+ }
+
+ String debugString() {
+ return (group != null ? group.groupUid : "null_group")
+ + ","
+ + splitId
+ + "@"
+ + (generation.isEmpty() ? "" : generation.toStringUtf8());
+ }
+ }
+
+ private ServerEntry findOrInsertServer(String address) {
+ ServerEntry entry = servers.get(address);
+ if (entry == null) {
+ entry = new ServerEntry(serverFactory.create(address));
+ servers.put(address, entry);
+ } else {
+ entry.refs++;
+ }
+ return entry;
+ }
+
+ private void unref(ServerEntry serverEntry) {
+ if (serverEntry == null) {
+ return;
+ }
+ if (--serverEntry.refs == 0) {
+ servers.remove(serverEntry.server.getAddress());
+ }
+ }
+
+ private CachedGroup findGroup(long groupUid) {
+ CachedGroup group = groups.get(groupUid);
+ if (group != null) {
+ group.refs++;
+ }
+ return group;
+ }
+
+ /** Finds or inserts a group and updates it with proto data. */
+ private CachedGroup findOrInsertGroup(Group groupIn) {
+ CachedGroup group = groups.get(groupIn.getGroupUid());
+ if (group == null) {
+ group = new CachedGroup(groupIn.getGroupUid());
+ groups.put(groupIn.getGroupUid(), group);
+ } else {
+ group.refs++;
+ }
+ group.update(groupIn);
+ return group;
+ }
+
+ private void unref(CachedGroup group) {
+ if (group == null) {
+ return;
+ }
+ if (--group.refs == 0) {
+ groups.remove(group.groupUid);
+ }
+ }
+
+ private void replaceRangeIfNewer(Range rangeIn) {
+ ByteString startKey = rangeIn.getStartKey();
+ ByteString limitKey = rangeIn.getLimitKey();
+
+ List affectedLimitKeys = new ArrayList<>();
+ boolean newerBlockingRangeExists = false;
+
+ // Find overlapping ranges
+ for (Map.Entry entry : ranges.tailMap(startKey, false).entrySet()) {
+ ByteString existingLimit = entry.getKey();
+ CachedRange existingRange = entry.getValue();
+ ByteString existingStart = existingRange.startKey;
+
+ if (ByteString.unsignedLexicographicalComparator().compare(existingStart, limitKey) >= 0) {
+ break;
+ }
+
+ if (isNewerOrSame(rangeIn, existingRange, existingLimit)) {
+ affectedLimitKeys.add(existingLimit);
+ } else {
+ newerBlockingRangeExists = true;
+ break;
+ }
+ }
+
+ if (newerBlockingRangeExists) {
+ return;
+ }
+
+ for (ByteString keyToRemove : affectedLimitKeys) {
+ CachedRange removed = ranges.remove(keyToRemove);
+ if (removed == null) {
+ continue;
+ }
+
+ if (ByteString.unsignedLexicographicalComparator().compare(limitKey, keyToRemove) < 0) {
+ CachedRange tailPart =
+ new CachedRange(limitKey, removed.group, removed.splitId, removed.generation);
+ if (tailPart.group != null) {
+ tailPart.group.refs++;
+ }
+ ranges.put(keyToRemove, tailPart);
+ }
+
+ if (ByteString.unsignedLexicographicalComparator().compare(removed.startKey, startKey) < 0) {
+ ranges.put(startKey, removed);
+ } else {
+ if (removed.group != null) {
+ unref(removed.group);
+ }
+ }
+ }
+
+ CachedRange newCachedRange =
+ new CachedRange(
+ startKey,
+ findGroup(rangeIn.getGroupUid()),
+ rangeIn.getSplitId(),
+ rangeIn.getGeneration());
+ ranges.put(limitKey, newCachedRange);
+ }
+
+ private boolean isNewerOrSame(
+ Range rangeIn, CachedRange existingCachedRange, ByteString existingMapKeyLimit) {
+ int genCompare =
+ ByteString.unsignedLexicographicalComparator()
+ .compare(rangeIn.getGeneration(), existingCachedRange.generation);
+ if (genCompare > 0) {
+ return true;
+ }
+ if (genCompare == 0) {
+ return rangeIn.getStartKey().equals(existingCachedRange.startKey)
+ && rangeIn.getLimitKey().equals(existingMapKeyLimit);
+ }
+ return false;
+ }
+
+ /** Applies cache updates. Tablets are processed inside group updates. */
+ public void addRanges(CacheUpdate cacheUpdate) {
+ System.out.println(
+ "DEBUG [BYPASS]: addRanges called with "
+ + cacheUpdate.getGroupCount()
+ + " groups, "
+ + cacheUpdate.getRangeCount()
+ + " ranges");
+
+ // Insert all groups. Tablets are processed inside findOrInsertGroup -> Group.update()
+ List newGroups = new ArrayList<>();
+ for (Group groupIn : cacheUpdate.getGroupList()) {
+ System.out.println(
+ "DEBUG [BYPASS]: Processing group "
+ + groupIn.getGroupUid()
+ + " with "
+ + groupIn.getTabletsCount()
+ + " tablets");
+ newGroups.add(findOrInsertGroup(groupIn));
+ }
+
+ // Process ranges
+ for (Range rangeIn : cacheUpdate.getRangeList()) {
+ System.out.println(
+ "DEBUG [BYPASS]: Processing range for group "
+ + rangeIn.getGroupUid()
+ + ", split_id="
+ + rangeIn.getSplitId());
+ replaceRangeIfNewer(rangeIn);
+ }
+
+ // Unref the groups we acquired (ranges hold their own refs)
+ for (CachedGroup g : newGroups) {
+ unref(g);
+ }
+
+ System.out.println(
+ "DEBUG [BYPASS]: After addRanges - ranges: "
+ + ranges.size()
+ + ", groups: "
+ + groups.size()
+ + ", servers: "
+ + servers.size());
+ }
+
+ /** Fills routing hint and returns the server to use. */
+ public ChannelFinderServer fillRoutingInfo(
+ String sessionUri, boolean preferLeader, RoutingHint.Builder hintBuilder) {
+ System.out.println(
+ "DEBUG [BYPASS]: fillRoutingInfo called, ranges in cache: "
+ + ranges.size()
+ + ", groups in cache: "
+ + groups.size());
+
+ if (hintBuilder.getKey().isEmpty()) {
+ System.out.println("DEBUG [BYPASS]: No key in hint, using default server");
+ return serverFactory.defaultServer();
+ }
+
+ ByteString requestKey = hintBuilder.getKey();
+ ByteString requestLimitKey = hintBuilder.getLimitKey();
+
+ // Find range containing the key
+ Map.Entry entry = ranges.higherEntry(requestKey);
+
+ CachedRange targetRange = null;
+ ByteString targetRangeLimitKey = null;
+
+ if (entry != null) {
+ ByteString rangeLimit = entry.getKey();
+ CachedRange range = entry.getValue();
+
+ // Check if key is within this range
+ if (ByteString.unsignedLexicographicalComparator().compare(requestKey, range.startKey) >= 0) {
+ targetRange = range;
+ targetRangeLimitKey = rangeLimit;
+ System.out.println(
+ "DEBUG [BYPASS]: Found range for key, group_uid: "
+ + (range.group != null ? range.group.groupUid : "null"));
+ }
+ }
+
+ if (targetRange == null) {
+ System.out.println("DEBUG [BYPASS]: No range found for key, using default server");
+ return serverFactory.defaultServer();
+ }
+
+ // For point reads (empty limit_key), check if key is in the split
+ // For range reads, check if the whole range is covered
+ if (!requestLimitKey.isEmpty()) {
+ // Range read - check if limit is within the split
+ if (ByteString.unsignedLexicographicalComparator()
+ .compare(requestLimitKey, targetRangeLimitKey)
+ > 0) {
+ // Range extends beyond this split
+ System.out.println("DEBUG [BYPASS]: Range extends beyond split, using default server");
+ return serverFactory.defaultServer();
+ }
+ }
+
+ if (targetRange.group == null) {
+ System.out.println("DEBUG [BYPASS]: Range has no group, using default server");
+ return serverFactory.defaultServer();
+ }
+
+ // Fill in routing hint with range/group/split info
+ hintBuilder.setGroupUid(targetRange.group.groupUid);
+ hintBuilder.setSplitId(targetRange.splitId);
+ hintBuilder.setKey(targetRange.startKey);
+ hintBuilder.setLimitKey(targetRangeLimitKey);
+
+ System.out.println(
+ "DEBUG [BYPASS]: Group "
+ + targetRange.group.groupUid
+ + " has "
+ + targetRange.group.tablets.size()
+ + " tablets"
+ + ", hasLeader: "
+ + targetRange.group.hasLeader()
+ + ", leaderIndex: "
+ + targetRange.group.leaderIndex);
+
+ // Let the group pick the tablet
+ ChannelFinderServer server = targetRange.group.fillRoutingHint(preferLeader, hintBuilder);
+ if (server != null) {
+ System.out.println("DEBUG [BYPASS]: Group returned server: " + server.getAddress());
+ return server;
+ }
+
+ System.out.println("DEBUG [BYPASS]: Group returned no server, using default");
+ return serverFactory.defaultServer();
+ }
+
+ public void clear() {
+ for (CachedRange range : ranges.values()) {
+ if (range.group != null) {
+ unref(range.group);
+ }
+ }
+ ranges.clear();
+ groups.clear();
+ servers.clear();
+ }
+
+ public String debugString() {
+ StringBuilder sb = new StringBuilder();
+ for (Map.Entry entry : ranges.entrySet()) {
+ CachedRange cachedRange = entry.getValue();
+ sb.append("Range[")
+ .append(cachedRange.startKey.toStringUtf8())
+ .append("-")
+ .append(entry.getKey().toStringUtf8())
+ .append("]: ");
+ sb.append(cachedRange.debugString()).append("\n");
+ }
+ for (CachedGroup g : groups.values()) {
+ sb.append(g.debugString()).append("\n");
+ }
+ for (ServerEntry s : servers.values()) {
+ sb.append(s.debugString()).append("\n");
+ }
+ return sb.toString();
+ }
+}
diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyRecipe.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyRecipe.java
new file mode 100644
index 0000000000..912a39c703
--- /dev/null
+++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyRecipe.java
@@ -0,0 +1,814 @@
+/*
+ * Copyright 2026 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.spi.v1;
+
+import com.google.protobuf.ByteString;
+import com.google.protobuf.ListValue;
+import com.google.protobuf.Struct;
+import com.google.protobuf.Value;
+import com.google.spanner.v1.KeyRange;
+import com.google.spanner.v1.KeySet;
+import com.google.spanner.v1.Mutation;
+import java.io.ByteArrayOutputStream;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+import java.util.function.BiFunction;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public final class KeyRecipe {
+
+ // kInfinity is "\xff" - the largest single byte, used as a sentinel for ranges
+ private static final ByteString K_INFINITY = ByteString.copyFrom(new byte[] {(byte) 0xFF});
+
+ private enum Kind {
+ TAG,
+ VALUE
+ }
+
+ private enum KeyType {
+ FULL_KEY,
+ PREFIX,
+ PREFIX_SUCCESSOR,
+ INDEX_KEY
+ }
+
+ private static final class Part {
+ private final Kind kind;
+ private final int tag; // if kind == TAG
+ private final com.google.spanner.v1.Type type; // if kind == VALUE
+ private final com.google.spanner.v1.KeyRecipe.Part.Order order; // if kind == VALUE
+ private final com.google.spanner.v1.KeyRecipe.Part.NullOrder nullOrder; // if kind == VALUE
+ private final String identifier; // if kind == VALUE
+ private final boolean random; // if kind == VALUE and random: true
+
+ private Part(
+ Kind kind,
+ int tag,
+ com.google.spanner.v1.Type type,
+ com.google.spanner.v1.KeyRecipe.Part.Order order,
+ com.google.spanner.v1.KeyRecipe.Part.NullOrder nullOrder,
+ String identifier,
+ boolean random) {
+ this.kind = kind;
+ this.tag = tag;
+ this.type = type;
+ this.order = order;
+ this.nullOrder = nullOrder;
+ this.identifier = identifier;
+ this.random = random;
+ }
+
+ static Part fromProto(com.google.spanner.v1.KeyRecipe.Part partProto) {
+ if (partProto.getTag() > 0) {
+ return new Part(Kind.TAG, partProto.getTag(), null, null, null, null, false);
+ } else {
+ if (!partProto.hasType()) {
+ throw new IllegalArgumentException(
+ "KeyRecipe.Part representing a value must have a type.");
+ }
+ if (partProto.getOrder() != com.google.spanner.v1.KeyRecipe.Part.Order.ASCENDING
+ && partProto.getOrder() != com.google.spanner.v1.KeyRecipe.Part.Order.DESCENDING) {
+ throw new IllegalArgumentException(
+ "KeyRecipe.Part order must be ASCENDING or DESCENDING.");
+ }
+ if (partProto.getNullOrder() != com.google.spanner.v1.KeyRecipe.Part.NullOrder.NULLS_FIRST
+ && partProto.getNullOrder() != com.google.spanner.v1.KeyRecipe.Part.NullOrder.NULLS_LAST
+ && partProto.getNullOrder()
+ != com.google.spanner.v1.KeyRecipe.Part.NullOrder.NOT_NULL) {
+ throw new IllegalArgumentException(
+ "KeyRecipe.Part null_order must be NULLS_FIRST or NULLS_LAST.");
+ }
+ String identifier = partProto.getIdentifier();
+ boolean isRandom = partProto.hasRandom();
+ return new Part(
+ Kind.VALUE,
+ 0, // tag is not used for VALUE kind in this simplified constructor
+ partProto.getType(),
+ partProto.getOrder(),
+ partProto.getNullOrder(),
+ identifier,
+ isRandom);
+ }
+ }
+ }
+
+ // For random value encoding - use seed 12345 for deterministic testing
+ private static final java.util.Random testRandom = new java.util.Random(12345);
+
+ private static void encodeRandomValuePart(Part part, ByteArrayOutputStream out) {
+ // Generate a random non-negative long (similar to absl::Uniform(bitgen_, 0, max))
+ long value = testRandom.nextLong() & Long.MAX_VALUE;
+ boolean ascending = part.order == com.google.spanner.v1.KeyRecipe.Part.Order.ASCENDING;
+ if (ascending) {
+ SsFormat.appendIntIncreasing(out, value);
+ } else {
+ SsFormat.appendIntDecreasing(out, value);
+ }
+ }
+
+ private final List parts;
+ private final int numValueParts;
+ private final boolean isIndex;
+
+ private KeyRecipe(List parts, int numValueParts, boolean isIndex) {
+ this.parts = parts;
+ this.numValueParts = numValueParts;
+ this.isIndex = isIndex;
+ }
+
+ public static KeyRecipe create(com.google.spanner.v1.KeyRecipe in) {
+ List partsList = new ArrayList<>();
+ int valuePartsCount = 0;
+ boolean isIndex = in.hasIndexName();
+ for (com.google.spanner.v1.KeyRecipe.Part partProto : in.getPartList()) {
+ Part part = Part.fromProto(partProto);
+ partsList.add(part);
+ if (part.kind == Kind.VALUE) {
+ valuePartsCount++;
+ }
+ }
+ if (partsList.isEmpty()) {
+ throw new IllegalArgumentException("KeyRecipe must have at least one part.");
+ }
+ return new KeyRecipe(partsList, valuePartsCount, isIndex);
+ }
+
+ private static void encodeNull(Part part, ByteArrayOutputStream out) {
+ switch (part.nullOrder) {
+ case NULLS_FIRST:
+ SsFormat.appendNullOrderedFirst(out);
+ break;
+ case NULLS_LAST:
+ SsFormat.appendNullOrderedLast(out);
+ break;
+ case NOT_NULL:
+ throw new IllegalArgumentException("Key part cannot be NULL");
+ default:
+ throw new IllegalArgumentException("Unknown null order: " + part.nullOrder);
+ }
+ }
+
+ private static void encodeNotNull(Part part, ByteArrayOutputStream out) {
+ switch (part.nullOrder) {
+ case NULLS_FIRST:
+ SsFormat.appendNotNullMarkerNullOrderedFirst(out);
+ break;
+ case NULLS_LAST:
+ SsFormat.appendNotNullMarkerNullOrderedLast(out);
+ break;
+ case NOT_NULL:
+ // No marker needed for NOT_NULL
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown null order: " + part.nullOrder);
+ }
+ }
+
+ private static void encodeSingleValuePart(Part part, Value value, ByteArrayOutputStream out) {
+ if (value.getKindCase() == Value.KindCase.NULL_VALUE) {
+ encodeNull(part, out);
+ return;
+ }
+
+ // Validate type compatibility BEFORE encoding anything
+ validateValueType(part, value);
+
+ // Now safe to encode the NOT_NULL marker
+ encodeNotNull(part, out);
+
+ boolean isAscending = (part.order == com.google.spanner.v1.KeyRecipe.Part.Order.ASCENDING);
+
+ switch (part.type.getCode()) {
+ case BOOL:
+ if (isAscending) {
+ SsFormat.appendUnsignedIntIncreasing(out, value.getBoolValue() ? 1 : 0);
+ } else {
+ SsFormat.appendUnsignedIntDecreasing(out, value.getBoolValue() ? 1 : 0);
+ }
+ break;
+ case INT64:
+ long intVal = Long.parseLong(value.getStringValue());
+ if (isAscending) {
+ SsFormat.appendIntIncreasing(out, intVal);
+ } else {
+ SsFormat.appendIntDecreasing(out, intVal);
+ }
+ break;
+ case FLOAT64:
+ if (value.getKindCase() == Value.KindCase.STRING_VALUE) {
+ // Handle special float values like Infinity, -Infinity, NaN
+ String strVal = value.getStringValue();
+ double dblVal;
+ if ("Infinity".equals(strVal)) {
+ dblVal = Double.POSITIVE_INFINITY;
+ } else if ("-Infinity".equals(strVal)) {
+ dblVal = Double.NEGATIVE_INFINITY;
+ } else if ("NaN".equals(strVal)) {
+ dblVal = Double.NaN;
+ } else {
+ throw new IllegalArgumentException("Invalid FLOAT64 string: " + strVal);
+ }
+ if (isAscending) {
+ SsFormat.appendDoubleIncreasing(out, dblVal);
+ } else {
+ SsFormat.appendDoubleDecreasing(out, dblVal);
+ }
+ } else {
+ if (isAscending) {
+ SsFormat.appendDoubleIncreasing(out, value.getNumberValue());
+ } else {
+ SsFormat.appendDoubleDecreasing(out, value.getNumberValue());
+ }
+ }
+ break;
+ case STRING:
+ if (isAscending) {
+ SsFormat.appendStringIncreasing(out, value.getStringValue());
+ } else {
+ SsFormat.appendStringDecreasing(out, value.getStringValue());
+ }
+ break;
+ case BYTES:
+ byte[] bytesDecoded = Base64.getDecoder().decode(value.getStringValue());
+ if (isAscending) {
+ SsFormat.appendBytesIncreasing(out, bytesDecoded);
+ } else {
+ SsFormat.appendBytesDecreasing(out, bytesDecoded);
+ }
+ break;
+ case TIMESTAMP:
+ {
+ String tsStr = value.getStringValue();
+ long[] parsed = parseTimestamp(tsStr);
+ byte[] encoded = SsFormat.encodeTimestamp(parsed[0], (int) parsed[1]);
+ if (isAscending) {
+ SsFormat.appendBytesIncreasing(out, encoded);
+ } else {
+ SsFormat.appendBytesDecreasing(out, encoded);
+ }
+ }
+ break;
+ case DATE:
+ {
+ String dateStr = value.getStringValue();
+ int daysSinceEpoch = parseDate(dateStr);
+ if (isAscending) {
+ SsFormat.appendIntIncreasing(out, daysSinceEpoch);
+ } else {
+ SsFormat.appendIntDecreasing(out, daysSinceEpoch);
+ }
+ }
+ break;
+ case UUID:
+ {
+ String uuidStr = value.getStringValue();
+ long[] parsed = parseUuid(uuidStr);
+ byte[] encoded = SsFormat.encodeUuid(parsed[0], parsed[1]);
+ if (isAscending) {
+ SsFormat.appendBytesIncreasing(out, encoded);
+ } else {
+ SsFormat.appendBytesDecreasing(out, encoded);
+ }
+ }
+ break;
+ case ENUM:
+ // ENUM values are sent as string representation of the enum number
+ long enumVal = Long.parseLong(value.getStringValue());
+ if (isAscending) {
+ SsFormat.appendIntIncreasing(out, enumVal);
+ } else {
+ SsFormat.appendIntDecreasing(out, enumVal);
+ }
+ break;
+ case NUMERIC:
+ case TYPE_CODE_UNSPECIFIED:
+ case ARRAY:
+ case STRUCT:
+ case PROTO:
+ case UNRECOGNIZED:
+ default:
+ throw new IllegalArgumentException(
+ "Unsupported type code for ssformat encoding: " + part.type.getCode());
+ }
+ }
+
+ private static void validateValueType(Part part, Value value) {
+ switch (part.type.getCode()) {
+ case BOOL:
+ if (value.getKindCase() != Value.KindCase.BOOL_VALUE) {
+ throw new IllegalArgumentException("Type mismatch for BOOL.");
+ }
+ break;
+ case INT64:
+ if (value.getKindCase() != Value.KindCase.STRING_VALUE) {
+ throw new IllegalArgumentException("Type mismatch for INT64, expecting decimal string.");
+ }
+ // Also validate it's a valid integer
+ try {
+ Long.parseLong(value.getStringValue());
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException("Invalid INT64 string: " + value.getStringValue(), e);
+ }
+ break;
+ case FLOAT64:
+ if (value.getKindCase() != Value.KindCase.NUMBER_VALUE
+ && value.getKindCase() != Value.KindCase.STRING_VALUE) {
+ throw new IllegalArgumentException("Type mismatch for FLOAT64.");
+ }
+ if (value.getKindCase() == Value.KindCase.STRING_VALUE) {
+ String strVal = value.getStringValue();
+ if (!"Infinity".equals(strVal) && !"-Infinity".equals(strVal) && !"NaN".equals(strVal)) {
+ throw new IllegalArgumentException("Invalid FLOAT64 string: " + strVal);
+ }
+ }
+ break;
+ case STRING:
+ if (value.getKindCase() != Value.KindCase.STRING_VALUE) {
+ throw new IllegalArgumentException("Type mismatch for STRING.");
+ }
+ break;
+ case BYTES:
+ if (value.getKindCase() != Value.KindCase.STRING_VALUE) {
+ throw new IllegalArgumentException("Type mismatch for BYTES, expecting base64 string.");
+ }
+ // Validate base64
+ try {
+ Base64.getDecoder().decode(value.getStringValue());
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("Invalid base64 for BYTES type.", e);
+ }
+ break;
+ case TIMESTAMP:
+ if (value.getKindCase() != Value.KindCase.STRING_VALUE) {
+ throw new IllegalArgumentException("Type mismatch for TIMESTAMP.");
+ }
+ // Validate timestamp format: must end with Z (UTC) and be RFC3339
+ validateTimestamp(value.getStringValue());
+ break;
+ case DATE:
+ if (value.getKindCase() != Value.KindCase.STRING_VALUE) {
+ throw new IllegalArgumentException("Type mismatch for DATE.");
+ }
+ // Validate date format: YYYY-MM-DD, exactly 10 chars
+ validateDate(value.getStringValue());
+ break;
+ case UUID:
+ if (value.getKindCase() != Value.KindCase.STRING_VALUE) {
+ throw new IllegalArgumentException("Type mismatch for UUID.");
+ }
+ // Validate UUID format
+ validateUuid(value.getStringValue());
+ break;
+ case ENUM:
+ if (value.getKindCase() != Value.KindCase.STRING_VALUE) {
+ throw new IllegalArgumentException("Type mismatch for ENUM, expecting string.");
+ }
+ // Validate it's a valid integer string
+ try {
+ Long.parseLong(value.getStringValue());
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException(
+ "Invalid ENUM string (expecting number): " + value.getStringValue(), e);
+ }
+ break;
+ case NUMERIC:
+ case TYPE_CODE_UNSPECIFIED:
+ case ARRAY:
+ case STRUCT:
+ case PROTO:
+ case UNRECOGNIZED:
+ default:
+ throw new IllegalArgumentException(
+ "Unsupported type code for ssformat encoding: " + part.type.getCode());
+ }
+ }
+
+ // RFC3339 timestamp pattern: YYYY-MM-DDTHH:MM:SS[.nnnnnnnnn]Z
+ // Allow any number of decimal places (will be truncated to 9)
+ private static final Pattern TIMESTAMP_PATTERN =
+ Pattern.compile("^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})(\\.\\d+)?Z$");
+
+ private static void validateTimestamp(String ts) {
+ if (!ts.endsWith("Z")) {
+ throw new IllegalArgumentException("Invalid TIMESTAMP string: " + ts);
+ }
+ Matcher m = TIMESTAMP_PATTERN.matcher(ts);
+ if (!m.matches()) {
+ throw new IllegalArgumentException("Invalid TIMESTAMP string: " + ts);
+ }
+ // Validate ranges
+ int year = Integer.parseInt(m.group(1));
+ int month = Integer.parseInt(m.group(2));
+ int day = Integer.parseInt(m.group(3));
+ int hour = Integer.parseInt(m.group(4));
+ int minute = Integer.parseInt(m.group(5));
+ int second = Integer.parseInt(m.group(6));
+ if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59) {
+ throw new IllegalArgumentException("Invalid TIMESTAMP string: " + ts);
+ }
+ // Year must be 0000-9999 (year 0 is allowed)
+ if (year < 0 || year > 9999) {
+ throw new IllegalArgumentException("Invalid TIMESTAMP string: " + ts);
+ }
+ }
+
+ private static long[] parseTimestamp(String ts) {
+ // Parse RFC3339 timestamp using Java time library
+ // Remove trailing Z and parse
+ String withoutZ = ts.substring(0, ts.length() - 1);
+
+ // Parse date-time parts
+ int dotIdx = withoutZ.indexOf('.');
+ String dateTimePart;
+ int nanos = 0;
+ if (dotIdx >= 0) {
+ dateTimePart = withoutZ.substring(0, dotIdx);
+ String fracStr = withoutZ.substring(dotIdx + 1);
+ // Pad to 9 digits
+ while (fracStr.length() < 9) {
+ fracStr = fracStr + "0";
+ }
+ // Truncate to 9 digits
+ if (fracStr.length() > 9) {
+ fracStr = fracStr.substring(0, 9);
+ }
+ nanos = Integer.parseInt(fracStr);
+ } else {
+ dateTimePart = withoutZ;
+ }
+
+ // Parse date and time components
+ // Format: YYYY-MM-DDTHH:MM:SS
+ String[] dateTime = dateTimePart.split("T");
+ String[] dateParts = dateTime[0].split("-");
+ String[] timeParts = dateTime[1].split(":");
+
+ int year = Integer.parseInt(dateParts[0]);
+ int month = Integer.parseInt(dateParts[1]);
+ int day = Integer.parseInt(dateParts[2]);
+ int hour = Integer.parseInt(timeParts[0]);
+ int minute = Integer.parseInt(timeParts[1]);
+ int second = Integer.parseInt(timeParts[2]);
+
+ // Compute days since epoch using proleptic Gregorian calendar
+ long days = civilDayNumber(year, month, day);
+ long seconds = days * 86400L + hour * 3600L + minute * 60L + second;
+
+ return new long[] {seconds, nanos};
+ }
+
+ // Compute the civil day number (days since Unix epoch 1970-01-01)
+ // This matches absl::CivilDay calculation
+ private static long civilDayNumber(int year, int month, int day) {
+ // Algorithm from http://howardhinnant.github.io/date_algorithms.html
+ // This produces the same results as absl::CivilDay
+ int y = year;
+ int m = month;
+ int d = day;
+
+ // Adjust year and month (March = month 1 in this algorithm)
+ if (m <= 2) {
+ y -= 1;
+ m += 12;
+ }
+ m -= 3;
+
+ // Days from era 0 (year 0 March 1) to given date
+ int era = (y >= 0 ? y : y - 399) / 400;
+ int yoe = y - era * 400; // year of era [0, 399]
+ int doy = (153 * m + 2) / 5 + d - 1; // day of year [0, 365]
+ int doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // day of era [0, 146096]
+ long dayNumber =
+ (long) era * 146097 + doe - 719468; // shift epoch from 0000-03-01 to 1970-01-01
+
+ return dayNumber;
+ }
+
+ private static final Pattern DATE_PATTERN = Pattern.compile("^(\\d{4})-(\\d{2})-(\\d{2})$");
+
+ private static void validateDate(String dateStr) {
+ if (dateStr.length() != 10) {
+ throw new IllegalArgumentException("Invalid DATE string: " + dateStr);
+ }
+ Matcher m = DATE_PATTERN.matcher(dateStr);
+ if (!m.matches()) {
+ throw new IllegalArgumentException("Invalid DATE string: " + dateStr);
+ }
+ int year = Integer.parseInt(m.group(1));
+ int month = Integer.parseInt(m.group(2));
+ int day = Integer.parseInt(m.group(3));
+ if (month < 1 || month > 12 || day < 1 || day > 31) {
+ throw new IllegalArgumentException("Invalid DATE string: " + dateStr);
+ }
+ // Year can be 0000-9999 for DATE
+ if (year < 0 || year > 9999) {
+ throw new IllegalArgumentException("Invalid DATE string: " + dateStr);
+ }
+ }
+
+ private static int parseDate(String dateStr) {
+ Matcher m = DATE_PATTERN.matcher(dateStr);
+ if (!m.matches()) {
+ throw new IllegalArgumentException("Invalid DATE string: " + dateStr);
+ }
+ int year = Integer.parseInt(m.group(1));
+ int month = Integer.parseInt(m.group(2));
+ int day = Integer.parseInt(m.group(3));
+ return (int) civilDayNumber(year, month, day);
+ }
+
+ private static void validateUuid(String uuid) {
+ long[] result = parseUuid(uuid);
+ // parseUuid throws if invalid
+ }
+
+ private static final int K_UUID_LENGTH = 36;
+
+ private static long[] parseUuid(String uuid) {
+ String originalUuid = uuid;
+
+ // Handle optional braces
+ if (uuid.startsWith("{")) {
+ if (!uuid.endsWith("}")) {
+ throw new IllegalArgumentException("Invalid UUID string: " + originalUuid);
+ }
+ uuid = uuid.substring(1, uuid.length() - 1);
+ }
+
+ // Minimum 36 characters required (standard UUID format: 8-4-4-4-12)
+ if (uuid.length() < K_UUID_LENGTH) {
+ throw new IllegalArgumentException("Invalid UUID string: " + originalUuid);
+ }
+
+ // Check for leading hyphen
+ if (uuid.startsWith("-")) {
+ throw new IllegalArgumentException("Invalid UUID string: " + originalUuid);
+ }
+
+ // Parse 32 hex digits (ignoring hyphens in between)
+ long high = 0;
+ long low = 0;
+ int hexCount = 0;
+
+ for (int i = 0; i < uuid.length(); i++) {
+ char c = uuid.charAt(i);
+ if (c == '-') {
+ continue; // Skip hyphens
+ }
+ int digit = hexDigit(c);
+ if (digit < 0) {
+ throw new IllegalArgumentException("Invalid UUID string: " + originalUuid);
+ }
+ if (hexCount < 16) {
+ high = (high << 4) | digit;
+ } else {
+ low = (low << 4) | digit;
+ }
+ hexCount++;
+ }
+
+ if (hexCount != 32) {
+ throw new IllegalArgumentException("Invalid UUID string: " + originalUuid);
+ }
+
+ // After parsing, verify there are no trailing characters
+ // (uuid must be exactly consumed)
+ if (uuid.length() > K_UUID_LENGTH) {
+ throw new IllegalArgumentException("Invalid UUID string: " + originalUuid);
+ }
+
+ return new long[] {high, low};
+ }
+
+ private static int hexDigit(char c) {
+ if (c >= '0' && c <= '9') return c - '0';
+ if (c >= 'a' && c <= 'f') return 10 + (c - 'a');
+ if (c >= 'A' && c <= 'F') return 10 + (c - 'A');
+ return -1;
+ }
+
+ private TargetRange encodeKeyInternal(
+ BiFunction valueFinder, KeyType keyType) {
+ ByteArrayOutputStream ssKey = new ByteArrayOutputStream();
+ int valueIdx = 0;
+ boolean ok = true;
+ int p = 0;
+ for (; p < parts.size(); ++p) {
+ final Part part = parts.get(p);
+ if (part.kind == Kind.TAG) {
+ SsFormat.appendCompositeTag(ssKey, part.tag);
+ } else if (part.kind == Kind.VALUE) {
+ // Handle random value parts
+ if (part.random) {
+ encodeRandomValuePart(part, ssKey);
+ continue;
+ }
+
+ String identifier = part.identifier.isEmpty() ? "" : part.identifier;
+ final Value value = valueFinder.apply(valueIdx++, identifier);
+ if (value == null) {
+ ok = false;
+ break;
+ }
+ try {
+ encodeSingleValuePart(part, value, ssKey);
+ } catch (IllegalArgumentException e) {
+ ok = false;
+ break;
+ }
+ } else {
+ ok = false;
+ break;
+ }
+ }
+
+ ByteString start = ByteString.copyFrom(ssKey.toByteArray());
+ ByteString limit = ByteString.EMPTY;
+ boolean approximate = false;
+
+ if (p == parts.size() || (keyType != KeyType.FULL_KEY && !ok)) {
+ if (keyType == KeyType.PREFIX_SUCCESSOR) {
+ start = SsFormat.makePrefixSuccessor(start);
+ } else if (keyType == KeyType.INDEX_KEY) {
+ limit = SsFormat.makePrefixSuccessor(start);
+ }
+ } else {
+ approximate = true;
+ limit = SsFormat.makePrefixSuccessor(start);
+ }
+ return new TargetRange(start, limit, approximate);
+ }
+
+ public TargetRange keyToTargetRange(ListValue in) {
+ return encodeKeyInternal(
+ (index, identifier) -> {
+ if (index < 0 || index >= in.getValuesCount()) {
+ return null;
+ }
+ return in.getValues(index);
+ },
+ isIndex ? KeyType.INDEX_KEY : KeyType.FULL_KEY);
+ }
+
+ public TargetRange keyRangeToTargetRange(KeyRange in) {
+ TargetRange start;
+ switch (in.getStartKeyTypeCase()) {
+ case START_CLOSED:
+ start =
+ encodeKeyInternal(
+ (index, id) -> {
+ if (index < 0 || index >= in.getStartClosed().getValuesCount()) return null;
+ return in.getStartClosed().getValues(index);
+ },
+ KeyType.PREFIX);
+ break;
+ case START_OPEN:
+ start =
+ encodeKeyInternal(
+ (index, id) -> {
+ if (index < 0 || index >= in.getStartOpen().getValuesCount()) return null;
+ return in.getStartOpen().getValues(index);
+ },
+ KeyType.PREFIX_SUCCESSOR);
+ break;
+ default:
+ start = new TargetRange(ByteString.EMPTY, ByteString.EMPTY, true);
+ break;
+ }
+
+ TargetRange limit;
+ switch (in.getEndKeyTypeCase()) {
+ case END_CLOSED:
+ limit =
+ encodeKeyInternal(
+ (index, id) -> {
+ if (index < 0 || index >= in.getEndClosed().getValuesCount()) return null;
+ return in.getEndClosed().getValues(index);
+ },
+ KeyType.PREFIX_SUCCESSOR);
+ break;
+ case END_OPEN:
+ limit =
+ encodeKeyInternal(
+ (index, id) -> {
+ if (index < 0 || index >= in.getEndOpen().getValuesCount()) return null;
+ return in.getEndOpen().getValues(index);
+ },
+ KeyType.PREFIX);
+ break;
+ default:
+ limit = new TargetRange(K_INFINITY, ByteString.EMPTY, true);
+ break;
+ }
+ return new TargetRange(start.start, limit.start, start.approximate || limit.approximate);
+ }
+
+ public TargetRange keySetToTargetRange(KeySet in) {
+ if (in.getAll()) {
+ return keyRangeToTargetRange(
+ KeyRange.newBuilder()
+ .setStartClosed(ListValue.getDefaultInstance())
+ .setEndClosed(ListValue.getDefaultInstance())
+ .build());
+ }
+ if (in.getRangesCount() == 0) {
+ if (in.getKeysCount() == 0) {
+ return new TargetRange(ByteString.EMPTY, K_INFINITY, true);
+ } else if (in.getKeysCount() == 1) {
+ return keyToTargetRange(in.getKeys(0));
+ }
+ }
+
+ TargetRange target = new TargetRange(K_INFINITY, ByteString.EMPTY, false);
+ for (ListValue key : in.getKeysList()) {
+ target.mergeFrom(keyToTargetRange(key));
+ }
+ for (KeyRange range : in.getRangesList()) {
+ target.mergeFrom(keyRangeToTargetRange(range));
+ }
+ return target;
+ }
+
+ public TargetRange queryParamsToTargetRange(Struct in) {
+ return encodeKeyInternal(
+ (index, identifier) -> {
+ if (!in.getFieldsMap().containsKey(identifier)) {
+ return null;
+ }
+ return in.getFieldsMap().get(identifier);
+ },
+ KeyType.FULL_KEY);
+ }
+
+ public TargetRange mutationToTargetRange(Mutation in) {
+ TargetRange target = new TargetRange(K_INFINITY, ByteString.EMPTY, false);
+
+ switch (in.getOperationCase()) {
+ case INSERT:
+ case UPDATE:
+ case INSERT_OR_UPDATE:
+ case REPLACE:
+ final Mutation.Write write = getWrite(in);
+ for (ListValue values : write.getValuesList()) {
+ target.mergeFrom(
+ encodeKeyInternal(
+ (index, id) -> {
+ int colIndex = write.getColumnsList().indexOf(id);
+ if (colIndex == -1 || colIndex >= values.getValuesCount()) {
+ return null;
+ }
+ return values.getValues(colIndex);
+ },
+ KeyType.FULL_KEY));
+ }
+ break;
+ case DELETE:
+ target.mergeFrom(keySetToTargetRange(in.getDelete().getKeySet()));
+ break;
+ case SEND:
+ target.mergeFrom(keyToTargetRange(in.getSend().getKey()));
+ break;
+ case ACK:
+ target.mergeFrom(keyToTargetRange(in.getAck().getKey()));
+ break;
+ default:
+ break;
+ }
+
+ if (target.start.equals(K_INFINITY)) {
+ target = new TargetRange(ByteString.EMPTY, K_INFINITY, true);
+ }
+ return target;
+ }
+
+ private Mutation.Write getWrite(Mutation in) {
+ switch (in.getOperationCase()) {
+ case INSERT:
+ return in.getInsert();
+ case UPDATE:
+ return in.getUpdate();
+ case INSERT_OR_UPDATE:
+ return in.getInsertOrUpdate();
+ case REPLACE:
+ return in.getReplace();
+ default:
+ throw new IllegalArgumentException("Mutation is not a write operation");
+ }
+ }
+}
diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyRecipeCache.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyRecipeCache.java
new file mode 100644
index 0000000000..b0b5c836ac
--- /dev/null
+++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyRecipeCache.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright 2026 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.spi.v1;
+
+import com.google.common.collect.ImmutableList;
+import com.google.protobuf.ByteString;
+import com.google.spanner.v1.ReadRequest;
+import com.google.spanner.v1.RecipeList;
+import com.google.spanner.v1.RoutingHint;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+
+public final class KeyRecipeCache {
+
+ // TODO: Implement robust fingerprinting algorithm like Fingerprint2011.
+ private static long fingerprint(ReadRequest req) {
+ long result = Objects.hash(req.getTable());
+ result = 31 * result + Objects.hash(PreparedRead.getKind(req));
+ for (String column : req.getColumnsList()) {
+ result = 31 * result + column.hashCode();
+ }
+ return result;
+ }
+
+ private final AtomicLong nextQueryUid = new AtomicLong(1);
+ private ByteString schemaGeneration = ByteString.EMPTY;
+
+ // query_recipes_ are not used for ReadRequest handling, so omitted for now.
+ // private final Map queryRecipes = new ConcurrentHashMap<>();
+ private final Map schemaRecipes = new ConcurrentHashMap<>();
+ private final Map preparedReads = new ConcurrentHashMap<>();
+
+ // For simplicity, miss reasons are not explicitly tracked with status in this version.
+ // enum MissReason { FINGERPRINT_COLLISION, SCHEMA_RECIPE_NOT_FOUND, FAILED_KEY_ENCODING,
+ // INELIGIBLE_READ }
+
+ public KeyRecipeCache() {}
+
+ public synchronized void addRecipes(RecipeList recipeList) {
+ int cmp =
+ ByteString.unsignedLexicographicalComparator()
+ .compare(recipeList.getSchemaGeneration(), schemaGeneration);
+ if (cmp < 0) {
+ return;
+ }
+ if (cmp > 0) {
+ schemaGeneration = recipeList.getSchemaGeneration();
+ // queryRecipes.clear(); // Not used for ReadRequest
+ schemaRecipes.clear();
+ }
+
+ for (com.google.spanner.v1.KeyRecipe recipeProto : recipeList.getRecipeList()) {
+ try {
+ KeyRecipe recipe = KeyRecipe.create(recipeProto);
+ if (recipeProto.hasTableName()) {
+ schemaRecipes.put(recipeProto.getTableName(), recipe);
+ } else if (recipeProto.hasIndexName()) {
+ schemaRecipes.put(recipeProto.getIndexName(), recipe);
+ } else if (recipeProto.hasOperationUid()) {
+ // Not handling query_uid recipes for ReadRequest
+ }
+ } catch (IllegalArgumentException e) {
+ // Log or handle failed recipe creation
+ System.err.println("Failed to add recipe: " + recipeProto + ", error: " + e.getMessage());
+ }
+ }
+ }
+
+ public void computeKeys(ReadRequest.Builder reqBuilder) {
+ long reqFp = fingerprint(reqBuilder.buildPartial()); // Partial build OK for fingerprinting
+
+ RoutingHint.Builder hintBuilder = reqBuilder.getRoutingHintBuilder();
+ if (!schemaGeneration.isEmpty()) {
+ hintBuilder.setSchemaGeneration(schemaGeneration);
+ }
+
+ PreparedRead preparedRead = preparedReads.get(reqFp);
+ if (preparedRead == null) {
+ preparedRead = PreparedRead.fromRequest(reqBuilder.buildPartial());
+ preparedRead.queryUid = nextQueryUid.getAndIncrement();
+ preparedReads.put(reqFp, preparedRead);
+ } else if (!preparedRead.matches(reqBuilder.buildPartial())) {
+ // recordMiss(MissReason.FINGERPRINT_COLLISION);
+ System.err.println("Fingerprint collision for ReadRequest: " + reqFp);
+ return;
+ }
+
+ hintBuilder.setOperationUid(preparedRead.queryUid);
+ String recipeKey = reqBuilder.getTable();
+ if (!reqBuilder.getIndex().isEmpty()) {
+ recipeKey = reqBuilder.getIndex();
+ }
+
+ KeyRecipe recipe = schemaRecipes.get(recipeKey);
+ if (recipe == null) {
+ // recordMiss(MissReason.SCHEMA_RECIPE_NOT_FOUND);
+ System.err.println("Schema recipe not found for: " + recipeKey);
+ return;
+ }
+
+ try {
+ switch (preparedRead.kind) {
+ case POINT:
+ if (reqBuilder.getKeySet().getKeysCount() == 0) {
+ System.err.println("POINT read has no keys in KeySet.");
+ return;
+ }
+ TargetRange pointTarget = recipe.keyToTargetRange(reqBuilder.getKeySet().getKeys(0));
+ hintBuilder.setKey(pointTarget.start);
+ break;
+ case RANGE:
+ case RANGE_WITH_LIMIT:
+ if (reqBuilder.getKeySet().getRangesCount() == 0) {
+ System.err.println("RANGE read has no ranges in KeySet.");
+ return;
+ }
+ TargetRange rangeTarget =
+ recipe.keyRangeToTargetRange(reqBuilder.getKeySet().getRanges(0));
+ hintBuilder.setKey(rangeTarget.start);
+ hintBuilder.setLimitKey(rangeTarget.limit);
+ break;
+ case INELIGIBLE:
+ // recordMiss(MissReason.INELIGIBLE_READ);
+ System.err.println("Ineligible read request for key computation.");
+ return;
+ }
+ } catch (IllegalArgumentException e) {
+ // recordMiss(MissReason.FAILED_KEY_ENCODING, e.getMessage());
+ System.err.println("Failed key encoding: " + e.getMessage());
+ }
+ }
+
+ public synchronized void clear() {
+ schemaGeneration = ByteString.EMPTY;
+ preparedReads.clear();
+ // queryRecipes.clear(); // Not used for ReadRequest
+ schemaRecipes.clear();
+ }
+
+ private static class PreparedRead {
+ final String table;
+ final ImmutableList columns;
+ final Kind kind;
+ long queryUid; // Not final, assigned after construction
+
+ enum Kind {
+ POINT,
+ RANGE,
+ RANGE_WITH_LIMIT,
+ INELIGIBLE
+ }
+
+ private PreparedRead(String table, List columns, Kind kind) {
+ this.table = table;
+ this.columns = ImmutableList.copyOf(columns);
+ this.kind = kind;
+ }
+
+ static Kind getKind(ReadRequest req) {
+ if (req.getKeySet().getAll()) {
+ return Kind.INELIGIBLE;
+ }
+ if (req.getKeySet().getKeysCount() == 1 && req.getKeySet().getRangesCount() == 0) {
+ return Kind.POINT;
+ }
+ if (req.getKeySet().getKeysCount() == 0 && req.getKeySet().getRangesCount() == 1) {
+ return req.getLimit() > 0 ? Kind.RANGE_WITH_LIMIT : Kind.RANGE;
+ }
+ return Kind.INELIGIBLE;
+ }
+
+ static PreparedRead fromRequest(ReadRequest req) {
+ return new PreparedRead(req.getTable(), req.getColumnsList(), getKind(req));
+ }
+
+ boolean matches(ReadRequest req) {
+ if (!Objects.equals(table, req.getTable())) {
+ return false;
+ }
+ if (!columns.equals(req.getColumnsList())) {
+ return false;
+ }
+ return kind == getKind(req);
+ }
+ }
+}
diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/SsFormat.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/SsFormat.java
new file mode 100644
index 0000000000..67ed2b3e39
--- /dev/null
+++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/SsFormat.java
@@ -0,0 +1,374 @@
+/*
+ * Copyright 2026 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.spi.v1;
+
+import com.google.protobuf.ByteString;
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+
+public final class SsFormat {
+
+ /**
+ * Makes the given key a prefix successor. This means that the returned key is the smallest
+ * possible key that is larger than the input key, and that does not have the input key as a
+ * prefix.
+ *
+ * This is done by flipping the least significant bit of the last byte of the key.
+ *
+ * @param key The key to make a prefix successor.
+ * @return The prefix successor key.
+ */
+ public static ByteString makePrefixSuccessor(ByteString key) {
+ if (key == null || key.isEmpty()) {
+ return ByteString.EMPTY;
+ }
+ byte[] bytes = key.toByteArray();
+ if (bytes.length > 0) {
+ bytes[bytes.length - 1] = (byte) (bytes[bytes.length - 1] | 1);
+ }
+ return ByteString.copyFrom(bytes);
+ }
+
+ private SsFormat() {}
+
+ // Constants from ssformat.cc
+ private static final int IS_KEY = 0x80;
+ private static final int TYPE_MASK = 0x7f;
+
+ // HeaderType enum values (selected)
+ private static final int TYPE_UINT_1 = 0;
+ private static final int TYPE_UINT_9 = 8;
+ private static final int TYPE_NEG_INT_8 = 9;
+ private static final int TYPE_NEG_INT_1 = 16;
+ private static final int TYPE_POS_INT_1 = 17;
+ private static final int TYPE_POS_INT_8 = 24;
+ private static final int TYPE_STRING = 25;
+ private static final int TYPE_NULL_ORDERED_FIRST = 27;
+ private static final int TYPE_NULLABLE_NOT_NULL_NULL_ORDERED_FIRST = 28;
+ private static final int TYPE_DECREASING_UINT_9 = 32;
+ private static final int TYPE_DECREASING_UINT_1 = 40;
+ private static final int TYPE_DECREASING_NEG_INT_8 = 41;
+ private static final int TYPE_DECREASING_NEG_INT_1 = 48;
+ private static final int TYPE_DECREASING_POS_INT_1 = 49;
+ private static final int TYPE_DECREASING_POS_INT_8 = 56;
+ private static final int TYPE_DECREASING_STRING = 57;
+ private static final int TYPE_NULLABLE_NOT_NULL_NULL_ORDERED_LAST = 59;
+ private static final int TYPE_NULL_ORDERED_LAST = 60;
+ private static final int TYPE_NEG_DOUBLE_8 = 66;
+ private static final int TYPE_NEG_DOUBLE_1 = 73;
+ private static final int TYPE_POS_DOUBLE_1 = 74;
+ private static final int TYPE_POS_DOUBLE_8 = 81;
+ private static final int TYPE_DECREASING_NEG_DOUBLE_8 = 82;
+ private static final int TYPE_DECREASING_NEG_DOUBLE_1 = 89;
+ private static final int TYPE_DECREASING_POS_DOUBLE_1 = 90;
+ private static final int TYPE_DECREASING_POS_DOUBLE_8 = 97;
+
+ // EscapeChar enum values
+ private static final byte ASCENDING_ZERO_ESCAPE = (byte) 0xf0;
+ private static final byte ASCENDING_FF_ESCAPE = (byte) 0x10;
+ private static final byte SEP = (byte) 0x78; // 'x'
+
+ // For AppendCompositeTag
+ private static final int K_OBJECT_EXISTENCE_TAG = 0x7e;
+ private static final int K_MAX_FIELD_TAG = 0xffff;
+
+ public static void appendCompositeTag(ByteArrayOutputStream out, int tag) {
+ if (tag == K_OBJECT_EXISTENCE_TAG || tag <= 0 || tag > K_MAX_FIELD_TAG) {
+ throw new IllegalArgumentException("Invalid tag value: " + tag);
+ }
+
+ if (tag < 16) {
+ // Short tag: 000 TTTT S (S is LSB of tag, but here tag is original, so S=0)
+ // Encodes as (tag << 1)
+ out.write((byte) (tag << 1));
+ } else {
+ // Long tag
+ int shiftedTag = tag << 1; // LSB is 0 for prefix successor
+ if (shiftedTag < (1 << (5 + 8))) { // Original tag < 4096
+ // Header: num_extra_bytes=1 (01xxxxx), P=payload bits from tag
+ // (1 << 5) is 00100000
+ // (shiftedTag >> 8) are the 5 MSBs of the payload part of the tag
+ out.write((byte) ((1 << 5) | (shiftedTag >> 8)));
+ out.write((byte) (shiftedTag & 0xFF));
+ } else { // Original tag >= 4096 and <= K_MAX_FIELD_TAG (65535)
+ // Header: num_extra_bytes=2 (10xxxxx)
+ // (2 << 5) is 01000000
+ out.write((byte) ((2 << 5) | (shiftedTag >> 16)));
+ out.write((byte) ((shiftedTag >> 8) & 0xFF));
+ out.write((byte) (shiftedTag & 0xFF));
+ }
+ }
+ }
+
+ public static void appendNullOrderedFirst(ByteArrayOutputStream out) {
+ out.write((byte) (IS_KEY | TYPE_NULL_ORDERED_FIRST));
+ out.write((byte) 0);
+ }
+
+ public static void appendNullOrderedLast(ByteArrayOutputStream out) {
+ out.write((byte) (IS_KEY | TYPE_NULL_ORDERED_LAST));
+ out.write((byte) 0);
+ }
+
+ public static void appendNotNullMarkerNullOrderedFirst(ByteArrayOutputStream out) {
+ out.write((byte) (IS_KEY | TYPE_NULLABLE_NOT_NULL_NULL_ORDERED_FIRST));
+ }
+
+ public static void appendNotNullMarkerNullOrderedLast(ByteArrayOutputStream out) {
+ out.write((byte) (IS_KEY | TYPE_NULLABLE_NOT_NULL_NULL_ORDERED_LAST));
+ }
+
+ public static void appendUnsignedIntIncreasing(ByteArrayOutputStream out, long val) {
+ if (val < 0) {
+ throw new IllegalArgumentException("Unsigned int cannot be negative: " + val);
+ }
+ byte[] buf = new byte[9]; // Max 9 bytes for value payload
+ int len = 0;
+
+ long tempVal = val;
+ buf[8 - len] = (byte) ((tempVal & 0x7F) << 1); // LSB is prefix-successor bit (0)
+ tempVal >>= 7;
+ len++;
+
+ while (tempVal > 0) {
+ buf[8 - len] = (byte) (tempVal & 0xFF);
+ tempVal >>= 8;
+ len++;
+ }
+
+ out.write((byte) (IS_KEY | (TYPE_UINT_1 + len - 1)));
+ for (int i = 0; i < len; i++) {
+ out.write((byte) (buf[8 - len + 1 + i] & 0xFF));
+ }
+ }
+
+ public static void appendUnsignedIntDecreasing(ByteArrayOutputStream out, long val) {
+ if (val < 0) {
+ throw new IllegalArgumentException("Unsigned int cannot be negative: " + val);
+ }
+ byte[] buf = new byte[9];
+ int len = 0;
+ long tempVal = val;
+
+ // InvertByte(val & 0x7f) << 1
+ buf[8 - len] = (byte) ((~(tempVal & 0x7F) & 0x7F) << 1);
+ tempVal >>= 7;
+ len++;
+
+ while (tempVal > 0) {
+ buf[8 - len] = (byte) (~(tempVal & 0xFF));
+ tempVal >>= 8;
+ len++;
+ }
+ // If val was 0, loop doesn't run for len > 1. If len is still 1, all bits of tempVal (0) are
+ // covered.
+ // If val was large, but remaining tempVal became 0, this is correct.
+ // If tempVal was 0 initially, buf[8] has (~0 & 0x7f) << 1. len = 1.
+ // If tempVal was >0 but became 0 after some shifts, buf[8-len] has inverted last byte.
+
+ out.write((byte) (IS_KEY | (TYPE_DECREASING_UINT_1 - len + 1)));
+ for (int i = 0; i < len; i++) {
+ out.write((byte) (buf[8 - len + 1 + i] & 0xFF));
+ }
+ }
+
+ private static void appendIntInternal(
+ ByteArrayOutputStream out, long val, boolean decreasing, boolean isDouble) {
+ if (decreasing) {
+ val = ~val;
+ }
+
+ byte[] buf = new byte[8]; // Max 8 bytes for payload
+ int len = 0;
+ long tempVal = val;
+
+ if (tempVal >= 0) {
+ buf[7 - len] = (byte) ((tempVal & 0x7F) << 1);
+ tempVal >>= 7;
+ len++;
+ while (tempVal > 0) {
+ buf[7 - len] = (byte) (tempVal & 0xFF);
+ tempVal >>= 8;
+ len++;
+ }
+ } else { // tempVal < 0
+ // For negative numbers, extend sign bit after shifting
+ buf[7 - len] = (byte) ((tempVal & 0x7F) << 1);
+ // Simulate sign extension for right shift of negative number
+ // (x >> 7) | 0xFE00000000000000ULL; (if x has 64 bits)
+ // In Java, right shift `>>` on negative longs performs sign extension.
+ tempVal >>= 7;
+ len++;
+ while (tempVal != -1L) { // Loop until all remaining bits are 1s (sign extension)
+ buf[7 - len] = (byte) (tempVal & 0xFF);
+ tempVal >>= 8;
+ len++;
+ if (len > 8) throw new AssertionError("Signed int encoding overflow");
+ }
+ }
+
+ int type;
+ if (val >= 0) { // Original val before potential bit-negation for decreasing
+ if (!decreasing) {
+ type = isDouble ? (TYPE_POS_DOUBLE_1 + len - 1) : (TYPE_POS_INT_1 + len - 1);
+ } else {
+ type =
+ isDouble
+ ? (TYPE_DECREASING_POS_DOUBLE_1 + len - 1)
+ : (TYPE_DECREASING_POS_INT_1 + len - 1);
+ }
+ } else {
+ if (!decreasing) {
+ type = isDouble ? (TYPE_NEG_DOUBLE_1 - len + 1) : (TYPE_NEG_INT_1 - len + 1);
+ } else {
+ type =
+ isDouble
+ ? (TYPE_DECREASING_NEG_DOUBLE_1 - len + 1)
+ : (TYPE_DECREASING_NEG_INT_1 - len + 1);
+ }
+ }
+ out.write((byte) (IS_KEY | type));
+ for (int i = 0; i < len; i++) {
+ out.write((byte) (buf[7 - len + 1 + i] & 0xFF));
+ }
+ }
+
+ public static void appendIntIncreasing(ByteArrayOutputStream out, long value) {
+ appendIntInternal(out, value, false, false);
+ }
+
+ public static void appendIntDecreasing(ByteArrayOutputStream out, long value) {
+ appendIntInternal(out, value, true, false);
+ }
+
+ public static void appendDoubleIncreasing(ByteArrayOutputStream out, double value) {
+ long enc = Double.doubleToRawLongBits(value);
+ if (enc < 0) {
+ enc =
+ Long.MIN_VALUE
+ - enc; // kint64min - enc (equivalent to ~enc for negative values due to 2's
+ // complement)
+ }
+ appendIntInternal(out, enc, false, true);
+ }
+
+ public static void appendDoubleDecreasing(ByteArrayOutputStream out, double value) {
+ long enc = Double.doubleToRawLongBits(value);
+ if (enc < 0) {
+ enc = Long.MIN_VALUE - enc;
+ }
+ appendIntInternal(out, enc, true, true);
+ }
+
+ private static void appendByteSequence(
+ ByteArrayOutputStream out, byte[] bytes, boolean decreasing) {
+ out.write((byte) (IS_KEY | (decreasing ? TYPE_DECREASING_STRING : TYPE_STRING)));
+
+ for (byte b : bytes) {
+ byte currentByte = decreasing ? (byte) ~b : b;
+ int unsignedByte = currentByte & 0xFF;
+ if (unsignedByte == 0x00) {
+ out.write((byte) 0x00);
+ out.write(
+ decreasing
+ ? ASCENDING_ZERO_ESCAPE
+ : ASCENDING_ZERO_ESCAPE); // After inversion, 0xFF becomes 0x00. Escape for 0x00
+ // (inverted) is F0.
+ // If increasing, 0x00 -> 0x00 F0.
+ } else if (unsignedByte == 0xFF) {
+ out.write((byte) 0xFF);
+ out.write(
+ decreasing
+ ? ASCENDING_FF_ESCAPE
+ : ASCENDING_FF_ESCAPE); // After inversion, 0x00 becomes 0xFF. Escape for 0xFF
+ // (inverted) is 0x10.
+ // If increasing, 0xFF -> 0xFF 0x10.
+ } else {
+ out.write((byte) unsignedByte);
+ }
+ }
+ // Terminator
+ out.write((byte) (decreasing ? 0xFF : 0x00));
+ out.write(SEP);
+ }
+
+ public static void appendStringIncreasing(ByteArrayOutputStream out, String value) {
+ appendByteSequence(out, value.getBytes(StandardCharsets.UTF_8), false);
+ }
+
+ public static void appendStringDecreasing(ByteArrayOutputStream out, String value) {
+ appendByteSequence(out, value.getBytes(StandardCharsets.UTF_8), true);
+ }
+
+ public static void appendBytesIncreasing(ByteArrayOutputStream out, byte[] value) {
+ appendByteSequence(out, value, false);
+ }
+
+ public static void appendBytesDecreasing(ByteArrayOutputStream out, byte[] value) {
+ appendByteSequence(out, value, true);
+ }
+
+ /**
+ * Encodes a timestamp as 12 bytes: 8 bytes for seconds since epoch (with offset to handle
+ * negative), 4 bytes for nanoseconds.
+ */
+ public static byte[] encodeTimestamp(long seconds, int nanos) {
+ // Add offset to make negative seconds sort correctly
+ long kSecondsOffset = 1L << 63;
+ long hi = seconds + kSecondsOffset;
+ int lo = nanos;
+
+ byte[] buf = new byte[12];
+ // Big-endian encoding
+ buf[0] = (byte) (hi >> 56);
+ buf[1] = (byte) (hi >> 48);
+ buf[2] = (byte) (hi >> 40);
+ buf[3] = (byte) (hi >> 32);
+ buf[4] = (byte) (hi >> 24);
+ buf[5] = (byte) (hi >> 16);
+ buf[6] = (byte) (hi >> 8);
+ buf[7] = (byte) hi;
+ buf[8] = (byte) (lo >> 24);
+ buf[9] = (byte) (lo >> 16);
+ buf[10] = (byte) (lo >> 8);
+ buf[11] = (byte) lo;
+ return buf;
+ }
+
+ /** Encodes a UUID (128-bit) as 16 bytes in big-endian order. */
+ public static byte[] encodeUuid(long high, long low) {
+ byte[] buf = new byte[16];
+ // Big-endian encoding
+ buf[0] = (byte) (high >> 56);
+ buf[1] = (byte) (high >> 48);
+ buf[2] = (byte) (high >> 40);
+ buf[3] = (byte) (high >> 32);
+ buf[4] = (byte) (high >> 24);
+ buf[5] = (byte) (high >> 16);
+ buf[6] = (byte) (high >> 8);
+ buf[7] = (byte) high;
+ buf[8] = (byte) (low >> 56);
+ buf[9] = (byte) (low >> 48);
+ buf[10] = (byte) (low >> 40);
+ buf[11] = (byte) (low >> 32);
+ buf[12] = (byte) (low >> 24);
+ buf[13] = (byte) (low >> 16);
+ buf[14] = (byte) (low >> 8);
+ buf[15] = (byte) low;
+ return buf;
+ }
+}
diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/TargetRange.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/TargetRange.java
new file mode 100644
index 0000000000..383cc0f830
--- /dev/null
+++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/TargetRange.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2026 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.spi.v1;
+
+import com.google.protobuf.ByteString;
+
+/** Represents a key range with start and limit boundaries for routing. */
+public class TargetRange {
+ public ByteString start;
+ public ByteString limit;
+ public boolean approximate;
+
+ public TargetRange(ByteString start, ByteString limit, boolean approximate) {
+ this.start = start;
+ this.limit = limit;
+ this.approximate = approximate;
+ }
+
+ public boolean isPoint() {
+ return limit.isEmpty();
+ }
+
+ /**
+ * Merges another TargetRange into this one. The resulting range will be the union of the two
+ * ranges, taking the minimum start key and maximum limit key.
+ */
+ public void mergeFrom(TargetRange other) {
+ if (ByteString.unsignedLexicographicalComparator().compare(other.start, this.start) < 0) {
+ this.start = other.start;
+ }
+ if (other.isPoint()
+ && ByteString.unsignedLexicographicalComparator().compare(other.start, this.limit) >= 0) {
+ this.limit = SsFormat.makePrefixSuccessor(other.start);
+ } else if (ByteString.unsignedLexicographicalComparator().compare(other.limit, this.limit)
+ > 0) {
+ this.limit = other.limit;
+ }
+ this.approximate |= other.approximate;
+ }
+}
diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/BypassPointReadTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/BypassPointReadTest.java
new file mode 100644
index 0000000000..0aa56494c6
--- /dev/null
+++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/BypassPointReadTest.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright 2026 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.cloud.NoCredentials;
+import io.grpc.ManagedChannelBuilder;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Simple test to verify bypass point read functionality against a bypass-enabled server.
+ *
+ *
Usage: Set the BYPASS_HOST environment variable or modify the DEFAULT_HOST constant, then run
+ * this test.
+ *
+ *
Prerequisites:
+ *
+ *
+ * - A bypass-enabled Spanner server running (e.g., box::TestEnv)
+ *
- A database with table T created and populated
+ *
+ */
+public class BypassPointReadTest {
+
+ // Configure these based on your bypass server setup
+ private static final String DEFAULT_HOST = "http://localhost:8080";
+ private static final String INSTANCE_ID = "default";
+ private static final String DATABASE_ID = "db";
+ private static final String TABLE_NAME = "T";
+ private static final String KEY_COLUMN = "Key";
+
+ public static void main(String[] args) {
+ String host = System.getenv("BYPASS_HOST");
+ if (host == null || host.isEmpty()) {
+ host = DEFAULT_HOST;
+ }
+
+ System.out.println("=== Bypass Point Read Test ===");
+ System.out.println("Connecting to bypass server: " + host);
+
+ SpannerOptions options =
+ SpannerOptions.newBuilder()
+ .setExperimentalHost(host)
+ .setChannelConfigurator(ManagedChannelBuilder::usePlaintext)
+ .setCredentials(NoCredentials.getInstance())
+ .build();
+
+ try (Spanner spanner = options.getService()) {
+ DatabaseClient dbClient =
+ spanner.getDatabaseClient(DatabaseId.of("default", INSTANCE_ID, DATABASE_ID));
+
+ System.out.println("\n--- Test 1: Point Read ---");
+ testPointRead(dbClient);
+
+ System.out.println("\n--- Test 2: Multiple Point Reads (cache warm-up) ---");
+ testMultiplePointReads(dbClient);
+
+ System.out.println("\n=== All tests completed successfully! ===");
+
+ } catch (Exception e) {
+ System.err.println("Test failed with exception:");
+ e.printStackTrace();
+ System.exit(1);
+ }
+ }
+
+ private static void testPointRead(DatabaseClient dbClient) {
+ long testKey = 1L;
+ List columns = Arrays.asList(KEY_COLUMN, "Value");
+
+ System.out.println("Reading key: " + testKey);
+ System.out.println("Columns: " + columns);
+
+ try (ResultSet resultSet =
+ dbClient.singleUse().read(TABLE_NAME, KeySet.singleKey(Key.of(testKey)), columns)) {
+
+ int rowCount = 0;
+ while (resultSet.next()) {
+ rowCount++;
+ System.out.println(" Row " + rowCount + ": Key=" + resultSet.getLong(KEY_COLUMN));
+ if (columns.size() > 1) {
+ try {
+ System.out.println(" Value=" + resultSet.getString("Value"));
+ } catch (Exception e) {
+ // Column might not exist
+ }
+ }
+ }
+ System.out.println("Total rows returned: " + rowCount);
+
+ if (rowCount == 0) {
+ System.out.println("WARNING: No rows returned. Make sure the table has data.");
+ }
+ }
+ }
+
+ private static void testMultiplePointReads(DatabaseClient dbClient) {
+ List columns = Arrays.asList(KEY_COLUMN);
+
+ // Perform multiple reads to test cache warm-up
+ // First read: cache miss, server returns CacheUpdate with recipe
+ // Second read: client computes ssformat key, server returns tablet info
+ // Third+ reads: should hit fast path
+
+ for (int i = 1; i <= 5; i++) {
+ long testKey = i;
+ long startTime = System.nanoTime();
+
+ try (ResultSet resultSet =
+ dbClient.singleUse().read(TABLE_NAME, KeySet.singleKey(Key.of(testKey)), columns)) {
+ int rowCount = 0;
+ while (resultSet.next()) {
+ rowCount++;
+ }
+ long elapsedUs = (System.nanoTime() - startTime) / 1000;
+ System.out.println(
+ "Read #" + i + " (key=" + testKey + "): " + rowCount + " row(s), " + elapsedUs + " us");
+ }
+ }
+
+ System.out.println("\nNote: Subsequent reads should be faster as the location cache warms up.");
+ }
+}
diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/RecipeGoldenTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/RecipeGoldenTest.java
new file mode 100644
index 0000000000..597dfb0507
--- /dev/null
+++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/RecipeGoldenTest.java
@@ -0,0 +1,411 @@
+/*
+ * Copyright 2026 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.spi.v1;
+
+import static org.junit.Assert.assertEquals;
+
+import com.google.protobuf.ByteString;
+import com.google.protobuf.ListValue;
+import com.google.protobuf.Struct;
+import com.google.protobuf.TextFormat;
+import com.google.spanner.v1.KeyRange;
+import com.google.spanner.v1.KeySet;
+import com.google.spanner.v1.Mutation;
+import com.google.spanner.v1.RecipeList;
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class RecipeGoldenTest {
+
+ @Test
+ public void goldenTest() throws Exception {
+ String content;
+ try (InputStream inputStream =
+ getClass().getClassLoader().getResourceAsStream("recipe_test.textproto")) {
+ content =
+ new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))
+ .lines()
+ .reduce("", (a, b) -> a + "\n" + b);
+ }
+
+ List testCases = parseTestCases(content);
+
+ for (TestCase testCase : testCases) {
+ System.out.println("Running test case: " + testCase.name);
+
+ // Skip test cases with invalid recipes that couldn't be parsed
+ if (testCase.invalidRecipe) {
+ System.out.println(" Skipped (invalid recipe)");
+ continue;
+ }
+
+ // Skip random tests due to PRNG differences
+ if (testCase.name.contains("Random")) {
+ System.out.println(" Skipped (random PRNG mismatch)");
+ continue;
+ }
+
+ KeyRecipe recipe;
+ try {
+ recipe = KeyRecipe.create(testCase.recipes.getRecipe(0));
+ } catch (IllegalArgumentException e) {
+ // Invalid recipe - verify all tests expect approximate: true
+ System.out.println(" Invalid recipe (caught in KeyRecipe.create): " + e.getMessage());
+ for (TestInstance test : testCase.tests) {
+ assertEquals(
+ "Invalid recipe should result in approximate=true in test case: " + testCase.name,
+ true,
+ test.expectedApproximate);
+ }
+ continue;
+ }
+
+ int testNum = 0;
+ for (TestInstance test : testCase.tests) {
+ testNum++;
+ System.out.println(" Test #" + testNum + ": type=" + test.operationType);
+ System.out.println(" Expected start: " + bytesToHex(test.expectedStart));
+ System.out.println(" Expected limit: " + bytesToHex(test.expectedLimit));
+ System.out.println(" Expected approx: " + test.expectedApproximate);
+
+ TargetRange target = null;
+ switch (test.operationType) {
+ case "key":
+ System.out.println(" Key: " + test.key);
+ target = recipe.keyToTargetRange(test.key);
+ break;
+ case "key_range":
+ target = recipe.keyRangeToTargetRange(test.keyRange);
+ break;
+ case "key_set":
+ target = recipe.keySetToTargetRange(test.keySet);
+ break;
+ case "mutation":
+ target = recipe.mutationToTargetRange(test.mutation);
+ break;
+ case "query_params":
+ target = recipe.queryParamsToTargetRange(test.queryParams);
+ break;
+ default:
+ throw new UnsupportedOperationException("Unsupported operation: " + test.operationType);
+ }
+
+ System.out.println(" Actual start: " + bytesToHex(target.start));
+ System.out.println(" Actual limit: " + bytesToHex(target.limit));
+ System.out.println(" Actual approx: " + target.approximate);
+
+ assertEquals(
+ "Start mismatch in test case: " + testCase.name + " test #" + testNum,
+ test.expectedStart,
+ target.start);
+ assertEquals(
+ "Limit mismatch in test case: " + testCase.name + " test #" + testNum,
+ test.expectedLimit,
+ target.limit);
+ assertEquals(
+ "Approximate mismatch in test case: " + testCase.name + " test #" + testNum,
+ test.expectedApproximate,
+ target.approximate);
+ }
+ }
+ }
+
+ private static class TestCase {
+ String name;
+ RecipeList recipes;
+ List tests = new ArrayList<>();
+ boolean invalidRecipe = false;
+ }
+
+ private static class TestInstance {
+ String operationType;
+ ListValue key;
+ KeyRange keyRange;
+ KeySet keySet;
+ Mutation mutation;
+ Struct queryParams;
+ ByteString expectedStart = ByteString.EMPTY;
+ ByteString expectedLimit = ByteString.EMPTY;
+ boolean expectedApproximate = false;
+ }
+
+ private List parseTestCases(String content) throws Exception {
+ List testCases = new ArrayList<>();
+ int pos = 0;
+
+ while (pos < content.length()) {
+ int testCaseStart = content.indexOf("test_case {", pos);
+ if (testCaseStart == -1) break;
+
+ int testCaseEnd = findMatchingBrace(content, testCaseStart + 10);
+ String testCaseContent = content.substring(testCaseStart + 11, testCaseEnd);
+
+ TestCase tc = parseTestCase(testCaseContent);
+ testCases.add(tc);
+
+ pos = testCaseEnd + 1;
+ }
+
+ return testCases;
+ }
+
+ private TestCase parseTestCase(String content) throws Exception {
+ TestCase tc = new TestCase();
+
+ // Parse name
+ Pattern namePattern = Pattern.compile("name:\\s*\"([^\"]+)\"");
+ Matcher nameMatcher = namePattern.matcher(content);
+ if (nameMatcher.find()) {
+ tc.name = nameMatcher.group(1);
+ }
+
+ // Parse recipes
+ int recipesStart = content.indexOf("recipes {");
+ if (recipesStart != -1) {
+ int recipesEnd = findMatchingBrace(content, recipesStart + 8);
+ String recipesContent = content.substring(recipesStart + 9, recipesEnd);
+ RecipeList.Builder recipesBuilder = RecipeList.newBuilder();
+ try {
+ TextFormat.merge(recipesContent, recipesBuilder);
+ tc.recipes = recipesBuilder.build();
+ } catch (TextFormat.ParseException e) {
+ // Invalid recipe - skip this test case but mark it as having invalid recipes
+ tc.invalidRecipe = true;
+ System.out.println("Skipping test case with invalid recipe: " + tc.name);
+ }
+ }
+
+ // Parse tests
+ int pos = 0;
+ while (pos < content.length()) {
+ // Find "test {" that's not part of "test_case"
+ int testStart = findNextTest(content, pos);
+ if (testStart == -1) break;
+
+ // "test {" is 6 chars, { is at position testStart + 5
+ int bracePos = testStart + 5;
+ int testEnd = findMatchingBrace(content, bracePos);
+ String testContent = content.substring(bracePos + 1, testEnd);
+
+ TestInstance test = parseTest(testContent);
+ tc.tests.add(test);
+
+ pos = testEnd + 1;
+ }
+
+ return tc;
+ }
+
+ private int findNextTest(String content, int start) {
+ int pos = start;
+ while (true) {
+ int testPos = content.indexOf("test {", pos);
+ if (testPos == -1) return -1;
+
+ // Make sure this is not part of "test_case {"
+ if (testPos >= 5) {
+ String before = content.substring(testPos - 5, testPos);
+ if (before.contains("_")) {
+ pos = testPos + 1;
+ continue;
+ }
+ }
+ return testPos;
+ }
+ }
+
+ private TestInstance parseTest(String content) throws Exception {
+ TestInstance test = new TestInstance();
+
+ // Determine operation type and parse operation
+ // NOTE: Check mutation FIRST since it can contain nested key_set/key_range/key
+ if (content.contains("mutation {")) {
+ test.operationType = "mutation";
+ int start = content.indexOf("mutation {");
+ int end = findMatchingBrace(content, start + 9);
+ String mutationContent = content.substring(start + 10, end);
+ Mutation.Builder builder = Mutation.newBuilder();
+ TextFormat.merge(mutationContent, builder);
+ test.mutation = builder.build();
+ } else if (content.contains("query_params {")) {
+ test.operationType = "query_params";
+ int start = content.indexOf("query_params {");
+ int end = findMatchingBrace(content, start + 13);
+ String queryParamsContent = content.substring(start + 14, end);
+ Struct.Builder builder = Struct.newBuilder();
+ TextFormat.merge(queryParamsContent, builder);
+ test.queryParams = builder.build();
+ } else if (content.contains("key_set {")) {
+ test.operationType = "key_set";
+ int start = content.indexOf("key_set {");
+ int end = findMatchingBrace(content, start + 8);
+ String keySetContent = content.substring(start + 9, end);
+ KeySet.Builder builder = KeySet.newBuilder();
+ TextFormat.merge(keySetContent, builder);
+ test.keySet = builder.build();
+ } else if (content.contains("key_range {")) {
+ test.operationType = "key_range";
+ int start = content.indexOf("key_range {");
+ int end = findMatchingBrace(content, start + 10);
+ String keyRangeContent = content.substring(start + 11, end);
+ KeyRange.Builder builder = KeyRange.newBuilder();
+ TextFormat.merge(keyRangeContent, builder);
+ test.keyRange = builder.build();
+ } else if (content.contains("key {")
+ && !content.contains("key_range")
+ && !content.contains("key_set")
+ && !content.contains("limit_key")) {
+ test.operationType = "key";
+ int keyStart = content.indexOf("key {");
+ int keyEnd = findMatchingBrace(content, keyStart + 4);
+ String keyContent = content.substring(keyStart + 5, keyEnd);
+ ListValue.Builder keyBuilder = ListValue.newBuilder();
+ TextFormat.merge(keyContent, keyBuilder);
+ test.key = keyBuilder.build();
+ }
+
+ // Parse expected start
+ Pattern startPattern = Pattern.compile("start:\\s*\"([^\"]*)\"");
+ Matcher startMatcher = startPattern.matcher(content);
+ if (startMatcher.find()) {
+ test.expectedStart = parseEscapedString(startMatcher.group(1));
+ }
+
+ // Parse expected limit
+ Pattern limitPattern = Pattern.compile("(? 0) {
+ char c = content.charAt(pos);
+
+ if (escape) {
+ escape = false;
+ pos++;
+ continue;
+ }
+
+ if (c == '\\') {
+ escape = true;
+ pos++;
+ continue;
+ }
+
+ if (c == '"') {
+ inString = !inString;
+ } else if (!inString) {
+ if (c == '{') {
+ depth++;
+ } else if (c == '}') {
+ depth--;
+ }
+ }
+ pos++;
+ }
+ return pos - 1;
+ }
+
+ private static String bytesToHex(ByteString bs) {
+ StringBuilder sb = new StringBuilder();
+ for (byte b : bs.toByteArray()) {
+ sb.append(String.format("%02x ", b & 0xFF));
+ }
+ return sb.toString();
+ }
+
+ private ByteString parseEscapedString(String escaped) {
+ byte[] bytes = new byte[escaped.length()];
+ int byteIndex = 0;
+ int i = 0;
+
+ while (i < escaped.length()) {
+ char c = escaped.charAt(i);
+ if (c == '\\' && i + 1 < escaped.length()) {
+ char next = escaped.charAt(i + 1);
+ if (next >= '0' && next <= '7') {
+ // Octal escape
+ int value = 0;
+ int count = 0;
+ while (i + 1 < escaped.length()
+ && count < 3
+ && escaped.charAt(i + 1) >= '0'
+ && escaped.charAt(i + 1) <= '7') {
+ value = value * 8 + (escaped.charAt(i + 1) - '0');
+ i++;
+ count++;
+ }
+ bytes[byteIndex++] = (byte) value;
+ } else if (next == 'n') {
+ bytes[byteIndex++] = '\n';
+ i++;
+ } else if (next == 't') {
+ bytes[byteIndex++] = '\t';
+ i++;
+ } else if (next == 'r') {
+ bytes[byteIndex++] = '\r';
+ i++;
+ } else if (next == '\\') {
+ bytes[byteIndex++] = '\\';
+ i++;
+ } else if (next == '"') {
+ bytes[byteIndex++] = '"';
+ i++;
+ } else if (next == 'x' && i + 3 < escaped.length()) {
+ // Hex escape \xNN
+ int value = Integer.parseInt(escaped.substring(i + 2, i + 4), 16);
+ bytes[byteIndex++] = (byte) value;
+ i += 3;
+ } else {
+ bytes[byteIndex++] = (byte) c;
+ }
+ } else {
+ bytes[byteIndex++] = (byte) c;
+ }
+ i++;
+ }
+
+ return ByteString.copyFrom(bytes, 0, byteIndex);
+ }
+}
diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/RecipeTestCases.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/RecipeTestCases.java
new file mode 100644
index 0000000000..f50f1c4222
--- /dev/null
+++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/RecipeTestCases.java
@@ -0,0 +1,247 @@
+/*
+ * Copyright 2026 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.spi.v1;
+
+import com.google.protobuf.ByteString;
+import com.google.protobuf.ListValue;
+import com.google.protobuf.Struct;
+import com.google.spanner.v1.KeyRange;
+import com.google.spanner.v1.KeySet;
+import com.google.spanner.v1.Mutation;
+import com.google.spanner.v1.RecipeList;
+import java.util.ArrayList;
+import java.util.List;
+
+public final class RecipeTestCases {
+
+ private final List testCases;
+
+ private RecipeTestCases(Builder builder) {
+ this.testCases = new ArrayList<>(builder.testCases);
+ }
+
+ public List getTestCaseList() {
+ return testCases;
+ }
+
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private final List testCases = new ArrayList<>();
+
+ public Builder addTestCase(RecipeTestCase testCase) {
+ this.testCases.add(testCase);
+ return this;
+ }
+
+ public RecipeTestCases build() {
+ return new RecipeTestCases(this);
+ }
+ }
+
+ public static final class RecipeTestCase {
+ private final String name;
+ private final RecipeList recipes;
+ private final List tests;
+
+ private RecipeTestCase(Builder builder) {
+ this.name = builder.name;
+ this.recipes = builder.recipes;
+ this.tests = new ArrayList<>(builder.tests);
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public RecipeList getRecipes() {
+ return recipes;
+ }
+
+ public List getTestList() {
+ return tests;
+ }
+
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private String name;
+ private RecipeList recipes;
+ private final List tests = new ArrayList<>();
+
+ public Builder setName(String name) {
+ this.name = name;
+ return this;
+ }
+
+ public Builder setRecipes(RecipeList recipes) {
+ this.recipes = recipes;
+ return this;
+ }
+
+ public Builder addTest(Test test) {
+ this.tests.add(test);
+ return this;
+ }
+
+ public RecipeTestCase build() {
+ return new RecipeTestCase(this);
+ }
+ }
+
+ public static final class Test {
+ private final OperationCase operationCase;
+ private final Object operation;
+ private final ByteString start;
+ private final ByteString limit;
+ private final boolean approximate;
+
+ public enum OperationCase {
+ KEY,
+ KEY_RANGE,
+ KEY_SET,
+ MUTATION,
+ QUERY_PARAMS,
+ OPERATION_NOT_SET
+ }
+
+ private Test(Builder builder) {
+ this.operationCase = builder.operationCase;
+ this.operation = builder.operation;
+ this.start = builder.start;
+ this.limit = builder.limit;
+ this.approximate = builder.approximate;
+ }
+
+ public OperationCase getOperationCase() {
+ return operationCase;
+ }
+
+ public ListValue getKey() {
+ if (operationCase == OperationCase.KEY) {
+ return (ListValue) operation;
+ }
+ return ListValue.getDefaultInstance();
+ }
+
+ public KeyRange getKeyRange() {
+ if (operationCase == OperationCase.KEY_RANGE) {
+ return (KeyRange) operation;
+ }
+ return KeyRange.getDefaultInstance();
+ }
+
+ public KeySet getKeySet() {
+ if (operationCase == OperationCase.KEY_SET) {
+ return (KeySet) operation;
+ }
+ return KeySet.getDefaultInstance();
+ }
+
+ public Mutation getMutation() {
+ if (operationCase == OperationCase.MUTATION) {
+ return (Mutation) operation;
+ }
+ return Mutation.getDefaultInstance();
+ }
+
+ public Struct getQueryParams() {
+ if (operationCase == OperationCase.QUERY_PARAMS) {
+ return (Struct) operation;
+ }
+ return Struct.getDefaultInstance();
+ }
+
+ public ByteString getStart() {
+ return start;
+ }
+
+ public ByteString getLimit() {
+ return limit;
+ }
+
+ public boolean getApproximate() {
+ return approximate;
+ }
+
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private OperationCase operationCase = OperationCase.OPERATION_NOT_SET;
+ private Object operation;
+ private ByteString start;
+ private ByteString limit;
+ private boolean approximate;
+
+ public Builder setKey(ListValue key) {
+ this.operationCase = OperationCase.KEY;
+ this.operation = key;
+ return this;
+ }
+
+ public Builder setKeyRange(KeyRange keyRange) {
+ this.operationCase = OperationCase.KEY_RANGE;
+ this.operation = keyRange;
+ return this;
+ }
+
+ public Builder setKeySet(KeySet keySet) {
+ this.operationCase = OperationCase.KEY_SET;
+ this.operation = keySet;
+ return this;
+ }
+
+ public Builder setMutation(Mutation mutation) {
+ this.operationCase = OperationCase.MUTATION;
+ this.operation = mutation;
+ return this;
+ }
+
+ public Builder setQueryParams(Struct queryParams) {
+ this.operationCase = OperationCase.QUERY_PARAMS;
+ this.operation = queryParams;
+ return this;
+ }
+
+ public Builder setStart(ByteString start) {
+ this.start = start;
+ return this;
+ }
+
+ public Builder setLimit(ByteString limit) {
+ this.limit = limit;
+ return this;
+ }
+
+ public Builder setApproximate(boolean approximate) {
+ this.approximate = approximate;
+ return this;
+ }
+
+ public Test build() {
+ return new Test(this);
+ }
+ }
+ }
+ }
+}
diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/SsFormatTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/SsFormatTest.java
new file mode 100644
index 0000000000..674ef0840f
--- /dev/null
+++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/SsFormatTest.java
@@ -0,0 +1,250 @@
+/*
+ * Copyright 2026 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.spi.v1;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import com.google.protobuf.ByteString;
+import java.io.ByteArrayOutputStream;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for {@link SsFormat}. */
+@RunWith(JUnit4.class)
+public class SsFormatTest {
+
+ @Test
+ public void testMakePrefixSuccessor() {
+ // Empty input returns empty
+ assertEquals(ByteString.EMPTY, SsFormat.makePrefixSuccessor(ByteString.EMPTY));
+ assertEquals(ByteString.EMPTY, SsFormat.makePrefixSuccessor(null));
+
+ // Single byte - LSB should be set
+ ByteString input = ByteString.copyFrom(new byte[] {0x00});
+ ByteString result = SsFormat.makePrefixSuccessor(input);
+ assertEquals(1, result.size());
+ assertEquals(0x01, result.byteAt(0) & 0xFF);
+
+ // Multiple bytes - only last byte's LSB should be set
+ input = ByteString.copyFrom(new byte[] {0x12, 0x34, 0x00});
+ result = SsFormat.makePrefixSuccessor(input);
+ assertEquals(3, result.size());
+ assertEquals(0x12, result.byteAt(0) & 0xFF);
+ assertEquals(0x34, result.byteAt(1) & 0xFF);
+ assertEquals(0x01, result.byteAt(2) & 0xFF);
+ }
+
+ @Test
+ public void testAppendCompositeTag() {
+ // Short tag (< 16)
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ SsFormat.appendCompositeTag(out, 5);
+ byte[] result = out.toByteArray();
+ assertEquals(1, result.length);
+ assertEquals(10, result[0] & 0xFF); // 5 << 1 = 10
+
+ // Medium tag (16 <= tag < 4096)
+ out = new ByteArrayOutputStream();
+ SsFormat.appendCompositeTag(out, 100);
+ result = out.toByteArray();
+ assertEquals(2, result.length);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testAppendCompositeTagInvalidTag() {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ SsFormat.appendCompositeTag(out, 0); // Invalid tag
+ }
+
+ @Test
+ public void testAppendUnsignedIntIncreasing() {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ SsFormat.appendUnsignedIntIncreasing(out, 0);
+ byte[] result = out.toByteArray();
+ assertTrue(result.length >= 2); // Header + at least 1 byte
+
+ // First byte should have IS_KEY bit set (0x80)
+ assertTrue((result[0] & 0x80) != 0);
+ }
+
+ @Test
+ public void testAppendUnsignedIntDecreasing() {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ SsFormat.appendUnsignedIntDecreasing(out, 0);
+ byte[] result = out.toByteArray();
+ assertTrue(result.length >= 2);
+ assertTrue((result[0] & 0x80) != 0);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testAppendUnsignedIntNegative() {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ SsFormat.appendUnsignedIntIncreasing(out, -1);
+ }
+
+ @Test
+ public void testAppendIntIncreasing() {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ SsFormat.appendIntIncreasing(out, 0);
+ byte[] result = out.toByteArray();
+ assertTrue(result.length >= 2);
+
+ // Test negative number
+ out = new ByteArrayOutputStream();
+ SsFormat.appendIntIncreasing(out, -1);
+ result = out.toByteArray();
+ assertTrue(result.length >= 2);
+ }
+
+ @Test
+ public void testAppendIntDecreasing() {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ SsFormat.appendIntDecreasing(out, 0);
+ byte[] result = out.toByteArray();
+ assertTrue(result.length >= 2);
+
+ out = new ByteArrayOutputStream();
+ SsFormat.appendIntDecreasing(out, -1);
+ result = out.toByteArray();
+ assertTrue(result.length >= 2);
+ }
+
+ @Test
+ public void testAppendStringIncreasing() {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ SsFormat.appendStringIncreasing(out, "hello");
+ byte[] result = out.toByteArray();
+ assertTrue(result.length > 5); // Header + string + terminator
+
+ // First byte should have IS_KEY bit set and TYPE_STRING
+ assertTrue((result[0] & 0x80) != 0);
+ }
+
+ @Test
+ public void testAppendStringDecreasing() {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ SsFormat.appendStringDecreasing(out, "hello");
+ byte[] result = out.toByteArray();
+ assertTrue(result.length > 5);
+ assertTrue((result[0] & 0x80) != 0);
+ }
+
+ @Test
+ public void testAppendBytesIncreasing() {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ SsFormat.appendBytesIncreasing(out, new byte[] {0x01, 0x02, 0x03});
+ byte[] result = out.toByteArray();
+ assertTrue(result.length > 3);
+ }
+
+ @Test
+ public void testAppendDoubleIncreasing() {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ SsFormat.appendDoubleIncreasing(out, 1.5);
+ byte[] result = out.toByteArray();
+ assertTrue(result.length >= 2);
+
+ // Test negative double
+ out = new ByteArrayOutputStream();
+ SsFormat.appendDoubleIncreasing(out, -1.5);
+ result = out.toByteArray();
+ assertTrue(result.length >= 2);
+ }
+
+ @Test
+ public void testAppendDoubleDecreasing() {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ SsFormat.appendDoubleDecreasing(out, 1.5);
+ byte[] result = out.toByteArray();
+ assertTrue(result.length >= 2);
+ }
+
+ @Test
+ public void testAppendNullMarkers() {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ SsFormat.appendNullOrderedFirst(out);
+ byte[] result = out.toByteArray();
+ assertEquals(2, result.length);
+ assertTrue((result[0] & 0x80) != 0);
+
+ out = new ByteArrayOutputStream();
+ SsFormat.appendNullOrderedLast(out);
+ result = out.toByteArray();
+ assertEquals(2, result.length);
+ }
+
+ @Test
+ public void testAppendNotNullMarkers() {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ SsFormat.appendNotNullMarkerNullOrderedFirst(out);
+ byte[] result = out.toByteArray();
+ assertEquals(1, result.length);
+
+ out = new ByteArrayOutputStream();
+ SsFormat.appendNotNullMarkerNullOrderedLast(out);
+ result = out.toByteArray();
+ assertEquals(1, result.length);
+ }
+
+ @Test
+ public void testEncodeTimestamp() {
+ byte[] result = SsFormat.encodeTimestamp(0, 0);
+ assertEquals(12, result.length);
+
+ result = SsFormat.encodeTimestamp(1234567890L, 123456789);
+ assertEquals(12, result.length);
+ }
+
+ @Test
+ public void testEncodeUuid() {
+ byte[] result = SsFormat.encodeUuid(0x1234567890ABCDEFL, 0xFEDCBA0987654321L);
+ assertEquals(16, result.length);
+
+ // Verify big-endian encoding
+ assertEquals(0x12, result[0] & 0xFF);
+ assertEquals(0x34, result[1] & 0xFF);
+ assertEquals(0xFE, result[8] & 0xFF);
+ assertEquals(0xDC, result[9] & 0xFF);
+ }
+
+ @Test
+ public void testStringEscaping() {
+ // Test that 0x00 and 0xFF bytes are properly escaped
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ SsFormat.appendBytesIncreasing(out, new byte[] {0x00, (byte) 0xFF, 0x42});
+ byte[] result = out.toByteArray();
+ // Result should be longer due to escaping
+ assertTrue(result.length > 5); // header + 3 original bytes + escapes + terminator
+ }
+
+ @Test
+ public void testOrderPreservation() {
+ // Verify that smaller integers encode to smaller byte sequences (lexicographically)
+ ByteArrayOutputStream out1 = new ByteArrayOutputStream();
+ SsFormat.appendIntIncreasing(out1, 100);
+
+ ByteArrayOutputStream out2 = new ByteArrayOutputStream();
+ SsFormat.appendIntIncreasing(out2, 200);
+
+ ByteString bs1 = ByteString.copyFrom(out1.toByteArray());
+ ByteString bs2 = ByteString.copyFrom(out2.toByteArray());
+
+ assertTrue(ByteString.unsignedLexicographicalComparator().compare(bs1, bs2) < 0);
+ }
+}
diff --git a/google-cloud-spanner/src/test/resources/recipe_test.textproto b/google-cloud-spanner/src/test/resources/recipe_test.textproto
new file mode 100644
index 0000000000..43fae04f5e
--- /dev/null
+++ b/google-cloud-spanner/src/test/resources/recipe_test.textproto
@@ -0,0 +1,3943 @@
+test_case {
+ name: "DataTypeTest_BOOL"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "DataTypeTest_BOOL"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: BOOL
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ bool_value: false
+ }
+ }
+ start: "A\206\310\002\234\200\000"
+ }
+ test {
+ key {
+ values {
+ bool_value: true
+ }
+ }
+ start: "A\206\310\002\234\200\002"
+ }
+ test {
+ key {
+ values {
+ null_value: NULL_VALUE
+ }
+ }
+ start: "A\206\310\002\233\000"
+ }
+ test {
+ key {
+ values {
+ string_value: "true"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ number_value: 0
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ bool_value: false
+ }
+ }
+ end_open {
+ values {
+ bool_value: true
+ }
+ }
+ }
+ start: "A\206\310\002\234\200\000"
+ limit: "A\206\310\002\234\200\002"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ bool_value: false
+ }
+ }
+ end_closed {
+ values {
+ bool_value: true
+ }
+ }
+ }
+ start: "A\206\310\002\234\200\001"
+ limit: "A\206\310\002\234\200\003"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ bool_value: false
+ }
+ }
+ end_closed {
+ values {
+ bool_value: true
+ }
+ }
+ }
+ start: "A\206\310\002\234\200\000"
+ limit: "A\206\310\002\234\200\003"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ bool_value: false
+ }
+ }
+ end_open {
+ values {
+ bool_value: true
+ }
+ }
+ }
+ start: "A\206\310\002\234\200\001"
+ limit: "A\206\310\002\234\200\002"
+ }
+}
+
+test_case {
+ name: "DataTypeTest_BOOL_Desc"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "DataTypeTest_BOOL_Desc"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: DESCENDING
+ null_order: NULLS_LAST
+ type {
+ code: BOOL
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ bool_value: true
+ }
+ }
+ start: "A\206\310\002\273\250\374"
+ }
+ test {
+ key {
+ values {
+ bool_value: false
+ }
+ }
+ start: "A\206\310\002\273\250\376"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ bool_value: true
+ }
+ }
+ end_open {
+ values {
+ bool_value: false
+ }
+ }
+ }
+ start: "A\206\310\002\273\250\374"
+ limit: "A\206\310\002\273\250\376"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ bool_value: true
+ }
+ }
+ end_closed {
+ values {
+ bool_value: false
+ }
+ }
+ }
+ start: "A\206\310\002\273\250\375"
+ limit: "A\206\310\002\273\250\377"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ bool_value: true
+ }
+ }
+ end_closed {
+ values {
+ bool_value: false
+ }
+ }
+ }
+ start: "A\206\310\002\273\250\374"
+ limit: "A\206\310\002\273\250\377"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ bool_value: true
+ }
+ }
+ end_open {
+ values {
+ bool_value: false
+ }
+ }
+ }
+ start: "A\206\310\002\273\250\375"
+ limit: "A\206\310\002\273\250\376"
+ }
+}
+
+test_case {
+ name: "DataTypeTest_ENUM"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "DataTypeTest_ENUM"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: ENUM
+ proto_type_fqn: "spanner.test.TestEnum"
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: "1"
+ }
+ }
+ start: "A\206\310\002\234\221\002"
+ }
+ test {
+ key {
+ values {
+ string_value: "2"
+ }
+ }
+ start: "A\206\310\002\234\221\004"
+ }
+ test {
+ key {
+ values {
+ null_value: NULL_VALUE
+ }
+ }
+ start: "A\206\310\002\233\000"
+ }
+ test {
+ key {
+ values {
+ string_value: "NUMBER_ONE"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ number_value: 0
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "1"
+ }
+ }
+ end_open {
+ values {
+ string_value: "2"
+ }
+ }
+ }
+ start: "A\206\310\002\234\221\002"
+ limit: "A\206\310\002\234\221\004"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: "1"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "2"
+ }
+ }
+ }
+ start: "A\206\310\002\234\221\003"
+ limit: "A\206\310\002\234\221\005"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "1"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "2"
+ }
+ }
+ }
+ start: "A\206\310\002\234\221\002"
+ limit: "A\206\310\002\234\221\005"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: "1"
+ }
+ }
+ end_open {
+ values {
+ string_value: "2"
+ }
+ }
+ }
+ start: "A\206\310\002\234\221\003"
+ limit: "A\206\310\002\234\221\004"
+ }
+}
+
+test_case {
+ name: "DataTypeTest_ENUM_Desc"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "DataTypeTest_ENUM_Desc"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: DESCENDING
+ null_order: NULLS_LAST
+ type {
+ code: ENUM
+ proto_type_fqn: "spanner.test.TestEnum"
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: "2"
+ }
+ }
+ start: "A\206\310\002\273\260\372"
+ }
+ test {
+ key {
+ values {
+ string_value: "1"
+ }
+ }
+ start: "A\206\310\002\273\260\374"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "2"
+ }
+ }
+ end_open {
+ values {
+ string_value: "1"
+ }
+ }
+ }
+ start: "A\206\310\002\273\260\372"
+ limit: "A\206\310\002\273\260\374"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: "2"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "1"
+ }
+ }
+ }
+ start: "A\206\310\002\273\260\373"
+ limit: "A\206\310\002\273\260\375"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "2"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "1"
+ }
+ }
+ }
+ start: "A\206\310\002\273\260\372"
+ limit: "A\206\310\002\273\260\375"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: "2"
+ }
+ }
+ end_open {
+ values {
+ string_value: "1"
+ }
+ }
+ }
+ start: "A\206\310\002\273\260\373"
+ limit: "A\206\310\002\273\260\374"
+ }
+}
+
+test_case {
+ name: "DataTypeTest_INT64"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "DataTypeTest_INT64"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: INT64
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: "-9223372036854775808"
+ }
+ }
+ start: "A\206\310\002\234\211\000\000\000\000\000\000\000\000"
+ }
+ test {
+ key {
+ values {
+ string_value: "9223372036854775807"
+ }
+ }
+ start: "A\206\310\002\234\230\377\377\377\377\377\377\377\376"
+ }
+ test {
+ key {
+ values {
+ null_value: NULL_VALUE
+ }
+ }
+ start: "A\206\310\002\233\000"
+ }
+ test {
+ key {
+ values {
+ string_value: "0"
+ }
+ }
+ start: "A\206\310\002\234\221\000"
+ }
+ test {
+ key {
+ values {
+ string_value: "-1"
+ }
+ }
+ start: "A\206\310\002\234\220\376"
+ }
+ test {
+ key {
+ values {
+ string_value: "1"
+ }
+ }
+ start: "A\206\310\002\234\221\002"
+ }
+ test {
+ key {
+ values {
+ number_value: 1
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "Infinity"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "-9223372036854775808"
+ }
+ }
+ end_open {
+ values {
+ string_value: "9223372036854775807"
+ }
+ }
+ }
+ start: "A\206\310\002\234\211\000\000\000\000\000\000\000\000"
+ limit: "A\206\310\002\234\230\377\377\377\377\377\377\377\376"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: "-9223372036854775808"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "9223372036854775807"
+ }
+ }
+ }
+ start: "A\206\310\002\234\211\000\000\000\000\000\000\000\001"
+ limit: "A\206\310\002\234\230\377\377\377\377\377\377\377\377"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "-9223372036854775808"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "9223372036854775807"
+ }
+ }
+ }
+ start: "A\206\310\002\234\211\000\000\000\000\000\000\000\000"
+ limit: "A\206\310\002\234\230\377\377\377\377\377\377\377\377"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: "-9223372036854775808"
+ }
+ }
+ end_open {
+ values {
+ string_value: "9223372036854775807"
+ }
+ }
+ }
+ start: "A\206\310\002\234\211\000\000\000\000\000\000\000\001"
+ limit: "A\206\310\002\234\230\377\377\377\377\377\377\377\376"
+ }
+}
+
+test_case {
+ name: "DataTypeTest_INT64_Desc"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "DataTypeTest_INT64_Desc"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: DESCENDING
+ null_order: NULLS_LAST
+ type {
+ code: INT64
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: "9223372036854775807"
+ }
+ }
+ start: "A\206\310\002\273\251\000\000\000\000\000\000\000\000"
+ }
+ test {
+ key {
+ values {
+ string_value: "-9223372036854775808"
+ }
+ }
+ start: "A\206\310\002\273\270\377\377\377\377\377\377\377\376"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "9223372036854775807"
+ }
+ }
+ end_open {
+ values {
+ string_value: "-9223372036854775808"
+ }
+ }
+ }
+ start: "A\206\310\002\273\251\000\000\000\000\000\000\000\000"
+ limit: "A\206\310\002\273\270\377\377\377\377\377\377\377\376"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: "9223372036854775807"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "-9223372036854775808"
+ }
+ }
+ }
+ start: "A\206\310\002\273\251\000\000\000\000\000\000\000\001"
+ limit: "A\206\310\002\273\270\377\377\377\377\377\377\377\377"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "9223372036854775807"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "-9223372036854775808"
+ }
+ }
+ }
+ start: "A\206\310\002\273\251\000\000\000\000\000\000\000\000"
+ limit: "A\206\310\002\273\270\377\377\377\377\377\377\377\377"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: "9223372036854775807"
+ }
+ }
+ end_open {
+ values {
+ string_value: "-9223372036854775808"
+ }
+ }
+ }
+ start: "A\206\310\002\273\251\000\000\000\000\000\000\000\001"
+ limit: "A\206\310\002\273\270\377\377\377\377\377\377\377\376"
+ }
+}
+
+test_case {
+ name: "DataTypeTest_FLOAT64"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "DataTypeTest_FLOAT64"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: FLOAT64
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ number_value: -1.7976931348623157e+308
+ }
+ }
+ start: "A\206\310\002\234\302\000 \000\000\000\000\000\002"
+ }
+ test {
+ key {
+ values {
+ number_value: 1.7976931348623157e+308
+ }
+ }
+ start: "A\206\310\002\234\321\377\337\377\377\377\377\377\376"
+ }
+ test {
+ key {
+ values {
+ null_value: NULL_VALUE
+ }
+ }
+ start: "A\206\310\002\233\000"
+ }
+ test {
+ key {
+ values {
+ number_value: 0
+ }
+ }
+ start: "A\206\310\002\234\312\000"
+ }
+ test {
+ key {
+ values {
+ number_value: -1
+ }
+ }
+ start: "A\206\310\002\234\302\200 \000\000\000\000\000\000"
+ }
+ test {
+ key {
+ values {
+ number_value: 1
+ }
+ }
+ start: "A\206\310\002\234\321\177\340\000\000\000\000\000\000"
+ }
+ test {
+ key {
+ values {
+ string_value: "Infinity"
+ }
+ }
+ start: "A\206\310\002\234\321\377\340\000\000\000\000\000\000"
+ }
+ test {
+ key {
+ values {
+ string_value: "-Infinity"
+ }
+ }
+ start: "A\206\310\002\234\302\000 \000\000\000\000\000\000"
+ }
+ test {
+ key {
+ values {
+ string_value: "NaN"
+ }
+ }
+ start: "A\206\310\002\234\321\377\360\000\000\000\000\000\000"
+ }
+ test {
+ key {
+ values {
+ string_value: "UnexpectedString"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ bool_value: true
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ number_value: -1.7976931348623157e+308
+ }
+ }
+ end_open {
+ values {
+ number_value: 1.7976931348623157e+308
+ }
+ }
+ }
+ start: "A\206\310\002\234\302\000 \000\000\000\000\000\002"
+ limit: "A\206\310\002\234\321\377\337\377\377\377\377\377\376"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ number_value: -1.7976931348623157e+308
+ }
+ }
+ end_closed {
+ values {
+ number_value: 1.7976931348623157e+308
+ }
+ }
+ }
+ start: "A\206\310\002\234\302\000 \000\000\000\000\000\003"
+ limit: "A\206\310\002\234\321\377\337\377\377\377\377\377\377"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ number_value: -1.7976931348623157e+308
+ }
+ }
+ end_closed {
+ values {
+ number_value: 1.7976931348623157e+308
+ }
+ }
+ }
+ start: "A\206\310\002\234\302\000 \000\000\000\000\000\002"
+ limit: "A\206\310\002\234\321\377\337\377\377\377\377\377\377"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ number_value: -1.7976931348623157e+308
+ }
+ }
+ end_open {
+ values {
+ number_value: 1.7976931348623157e+308
+ }
+ }
+ }
+ start: "A\206\310\002\234\302\000 \000\000\000\000\000\003"
+ limit: "A\206\310\002\234\321\377\337\377\377\377\377\377\376"
+ }
+}
+
+test_case {
+ name: "DataTypeTest_FLOAT64_Desc"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "DataTypeTest_FLOAT64_Desc"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: DESCENDING
+ null_order: NULLS_LAST
+ type {
+ code: FLOAT64
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ number_value: 1.7976931348623157e+308
+ }
+ }
+ start: "A\206\310\002\273\322\000 \000\000\000\000\000\000"
+ }
+ test {
+ key {
+ values {
+ number_value: -1.7976931348623157e+308
+ }
+ }
+ start: "A\206\310\002\273\341\377\337\377\377\377\377\377\374"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ number_value: 1.7976931348623157e+308
+ }
+ }
+ end_open {
+ values {
+ number_value: -1.7976931348623157e+308
+ }
+ }
+ }
+ start: "A\206\310\002\273\322\000 \000\000\000\000\000\000"
+ limit: "A\206\310\002\273\341\377\337\377\377\377\377\377\374"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ number_value: 1.7976931348623157e+308
+ }
+ }
+ end_closed {
+ values {
+ number_value: -1.7976931348623157e+308
+ }
+ }
+ }
+ start: "A\206\310\002\273\322\000 \000\000\000\000\000\001"
+ limit: "A\206\310\002\273\341\377\337\377\377\377\377\377\375"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ number_value: 1.7976931348623157e+308
+ }
+ }
+ end_closed {
+ values {
+ number_value: -1.7976931348623157e+308
+ }
+ }
+ }
+ start: "A\206\310\002\273\322\000 \000\000\000\000\000\000"
+ limit: "A\206\310\002\273\341\377\337\377\377\377\377\377\375"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ number_value: 1.7976931348623157e+308
+ }
+ }
+ end_open {
+ values {
+ number_value: -1.7976931348623157e+308
+ }
+ }
+ }
+ start: "A\206\310\002\273\322\000 \000\000\000\000\000\001"
+ limit: "A\206\310\002\273\341\377\337\377\377\377\377\377\374"
+ }
+}
+
+test_case {
+ name: "DataTypeTest_TIMESTAMP"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "DataTypeTest_TIMESTAMP"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: TIMESTAMP
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: "0001-01-01T00:00:00Z"
+ }
+ }
+ start: "A\206\310\002\234\231\177\377\020\377\020\361\210n\t\000\360\000\360\000\360\000\360\000\360\000x"
+ }
+ test {
+ key {
+ values {
+ string_value: "9999-12-31T23:59:59.999999999Z"
+ }
+ }
+ start: "A\206\310\002\234\231\200\000\360\000\360:\377\020\364A\177;\232\311\377\020\000x"
+ }
+ test {
+ key {
+ values {
+ null_value: NULL_VALUE
+ }
+ }
+ start: "A\206\310\002\233\000"
+ }
+ test {
+ key {
+ values {
+ string_value: "1970-01-01T00:00:00Z"
+ }
+ }
+ start: "A\206\310\002\234\231\200\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000x"
+ }
+ test {
+ key {
+ values {
+ string_value: "2023-10-26T10:00:00Z"
+ }
+ }
+ start: "A\206\310\002\234\231\200\000\360\000\360\000\360e:8\240\000\360\000\360\000\360\000\360\000x"
+ }
+ test {
+ key {
+ values {
+ string_value: "2023-10-26T10:00:00.1234567890Z"
+ }
+ }
+ start: "A\206\310\002\234\231\200\000\360\000\360\000\360e:8\240\007[\315\025\000x"
+ }
+ test {
+ key {
+ values {
+ string_value: "2023-10-26T10:00:00.1234567891Z"
+ }
+ }
+ start: "A\206\310\002\234\231\200\000\360\000\360\000\360e:8\240\007[\315\025\000x"
+ }
+ test {
+ key {
+ values {
+ string_value: "2023-10-26T10:00:00.1234567899Z"
+ }
+ }
+ start: "A\206\310\002\234\231\200\000\360\000\360\000\360e:8\240\007[\315\025\000x"
+ }
+ test {
+ key {
+ values {
+ string_value: "0000-10-26T10:00:00Z"
+ }
+ }
+ start: "A\206\310\002\234\231\177\377\020\377\020\361\210\026A \000\360\000\360\000\360\000\360\000x"
+ }
+ test {
+ key {
+ values {
+ string_value: "NOT A TIMESTAMP"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ number_value: 0
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "2023-10-26T10:00:00"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "2023-10-26T10:00:00z"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "2023-10-26T10:00:00+07:00"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "2023-13-26T10:00:00Z"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "2023-10-26T10:00:61Z"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "2023-10-26 10:00:00Z"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "10000-10-26T10:00:00Z"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "0001-01-01T00:00:00Z"
+ }
+ }
+ end_open {
+ values {
+ string_value: "9999-12-31T23:59:59.999999999Z"
+ }
+ }
+ }
+ start: "A\206\310\002\234\231\177\377\020\377\020\361\210n\t\000\360\000\360\000\360\000\360\000\360\000x"
+ limit: "A\206\310\002\234\231\200\000\360\000\360:\377\020\364A\177;\232\311\377\020\000x"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: "0001-01-01T00:00:00Z"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "9999-12-31T23:59:59.999999999Z"
+ }
+ }
+ }
+ start: "A\206\310\002\234\231\177\377\020\377\020\361\210n\t\000\360\000\360\000\360\000\360\000\360\000y"
+ limit: "A\206\310\002\234\231\200\000\360\000\360:\377\020\364A\177;\232\311\377\020\000y"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "0001-01-01T00:00:00Z"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "9999-12-31T23:59:59.999999999Z"
+ }
+ }
+ }
+ start: "A\206\310\002\234\231\177\377\020\377\020\361\210n\t\000\360\000\360\000\360\000\360\000\360\000x"
+ limit: "A\206\310\002\234\231\200\000\360\000\360:\377\020\364A\177;\232\311\377\020\000y"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: "0001-01-01T00:00:00Z"
+ }
+ }
+ end_open {
+ values {
+ string_value: "9999-12-31T23:59:59.999999999Z"
+ }
+ }
+ }
+ start: "A\206\310\002\234\231\177\377\020\377\020\361\210n\t\000\360\000\360\000\360\000\360\000\360\000y"
+ limit: "A\206\310\002\234\231\200\000\360\000\360:\377\020\364A\177;\232\311\377\020\000x"
+ }
+}
+
+test_case {
+ name: "DataTypeTest_TIMESTAMP_Desc"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "DataTypeTest_TIMESTAMP_Desc"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: DESCENDING
+ null_order: NULLS_LAST
+ type {
+ code: TIMESTAMP
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: "9999-12-31T23:59:59.999999999Z"
+ }
+ }
+ start: "A\206\310\002\273\271\177\377\020\377\020\305\000\360\013\276\200\304e6\000\360\377x"
+ }
+ test {
+ key {
+ values {
+ string_value: "0001-01-01T00:00:00Z"
+ }
+ }
+ start: "A\206\310\002\273\271\200\000\360\000\360\016w\221\366\377\020\377\020\377\020\377\020\377\020\377x"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "9999-12-31T23:59:59.999999999Z"
+ }
+ }
+ end_open {
+ values {
+ string_value: "0001-01-01T00:00:00Z"
+ }
+ }
+ }
+ start: "A\206\310\002\273\271\177\377\020\377\020\305\000\360\013\276\200\304e6\000\360\377x"
+ limit: "A\206\310\002\273\271\200\000\360\000\360\016w\221\366\377\020\377\020\377\020\377\020\377\020\377x"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: "9999-12-31T23:59:59.999999999Z"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "0001-01-01T00:00:00Z"
+ }
+ }
+ }
+ start: "A\206\310\002\273\271\177\377\020\377\020\305\000\360\013\276\200\304e6\000\360\377y"
+ limit: "A\206\310\002\273\271\200\000\360\000\360\016w\221\366\377\020\377\020\377\020\377\020\377\020\377y"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "9999-12-31T23:59:59.999999999Z"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "0001-01-01T00:00:00Z"
+ }
+ }
+ }
+ start: "A\206\310\002\273\271\177\377\020\377\020\305\000\360\013\276\200\304e6\000\360\377x"
+ limit: "A\206\310\002\273\271\200\000\360\000\360\016w\221\366\377\020\377\020\377\020\377\020\377\020\377y"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: "9999-12-31T23:59:59.999999999Z"
+ }
+ }
+ end_open {
+ values {
+ string_value: "0001-01-01T00:00:00Z"
+ }
+ }
+ }
+ start: "A\206\310\002\273\271\177\377\020\377\020\305\000\360\013\276\200\304e6\000\360\377y"
+ limit: "A\206\310\002\273\271\200\000\360\000\360\016w\221\366\377\020\377\020\377\020\377\020\377\020\377x"
+ }
+}
+
+test_case {
+ name: "DataTypeTest_DATE"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "DataTypeTest_DATE"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: DATE
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: "0000-01-01"
+ }
+ }
+ start: "A\206\310\002\234\216\352\n\260"
+ }
+ test {
+ key {
+ values {
+ string_value: "9999-12-31"
+ }
+ }
+ start: "A\206\310\002\234\223Y\201@"
+ }
+ test {
+ key {
+ values {
+ null_value: NULL_VALUE
+ }
+ }
+ start: "A\206\310\002\233\000"
+ }
+ test {
+ key {
+ values {
+ string_value: "1970-01-01"
+ }
+ }
+ start: "A\206\310\002\234\221\000"
+ }
+ test {
+ key {
+ values {
+ string_value: "2023-10-26"
+ }
+ }
+ start: "A\206\310\002\234\222\231\220"
+ }
+ test {
+ key {
+ values {
+ string_value: "NOT A DATE"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ number_value: 0
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "2023-13-01"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "2023-12-32"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "10000-01-01"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "2023-1-1"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "2023-01-001"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "2023/01/01"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "2023-01-01T10:00:00Z"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "0000-01-01"
+ }
+ }
+ end_open {
+ values {
+ string_value: "9999-12-31"
+ }
+ }
+ }
+ start: "A\206\310\002\234\216\352\n\260"
+ limit: "A\206\310\002\234\223Y\201@"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: "0000-01-01"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "9999-12-31"
+ }
+ }
+ }
+ start: "A\206\310\002\234\216\352\n\261"
+ limit: "A\206\310\002\234\223Y\201A"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "0000-01-01"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "9999-12-31"
+ }
+ }
+ }
+ start: "A\206\310\002\234\216\352\n\260"
+ limit: "A\206\310\002\234\223Y\201A"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: "0000-01-01"
+ }
+ }
+ end_open {
+ values {
+ string_value: "9999-12-31"
+ }
+ }
+ }
+ start: "A\206\310\002\234\216\352\n\261"
+ limit: "A\206\310\002\234\223Y\201@"
+ }
+}
+
+test_case {
+ name: "DataTypeTest_DATE_Desc"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "DataTypeTest_DATE_Desc"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: DESCENDING
+ null_order: NULLS_LAST
+ type {
+ code: DATE
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: "9999-12-31"
+ }
+ }
+ start: "A\206\310\002\273\256\246~\276"
+ }
+ test {
+ key {
+ values {
+ string_value: "0000-01-01"
+ }
+ }
+ start: "A\206\310\002\273\263\025\365N"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "9999-12-31"
+ }
+ }
+ end_open {
+ values {
+ string_value: "0000-01-01"
+ }
+ }
+ }
+ start: "A\206\310\002\273\256\246~\276"
+ limit: "A\206\310\002\273\263\025\365N"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: "9999-12-31"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "0000-01-01"
+ }
+ }
+ }
+ start: "A\206\310\002\273\256\246~\277"
+ limit: "A\206\310\002\273\263\025\365O"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "9999-12-31"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "0000-01-01"
+ }
+ }
+ }
+ start: "A\206\310\002\273\256\246~\276"
+ limit: "A\206\310\002\273\263\025\365O"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: "9999-12-31"
+ }
+ }
+ end_open {
+ values {
+ string_value: "0000-01-01"
+ }
+ }
+ }
+ start: "A\206\310\002\273\256\246~\277"
+ limit: "A\206\310\002\273\263\025\365N"
+ }
+}
+
+test_case {
+ name: "DataTypeTest_STRING"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "DataTypeTest_STRING"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: STRING
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: ""
+ }
+ }
+ start: "A\206\310\002\234\231\000x"
+ }
+ test {
+ key {
+ values {
+ string_value: "ZZZZZZZ"
+ }
+ }
+ start: "A\206\310\002\234\231ZZZZZZZ\000x"
+ }
+ test {
+ key {
+ values {
+ null_value: NULL_VALUE
+ }
+ }
+ start: "A\206\310\002\233\000"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: ""
+ }
+ }
+ end_open {
+ values {
+ string_value: "ZZZZZZZ"
+ }
+ }
+ }
+ start: "A\206\310\002\234\231\000x"
+ limit: "A\206\310\002\234\231ZZZZZZZ\000x"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: ""
+ }
+ }
+ end_closed {
+ values {
+ string_value: "ZZZZZZZ"
+ }
+ }
+ }
+ start: "A\206\310\002\234\231\000y"
+ limit: "A\206\310\002\234\231ZZZZZZZ\000y"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: ""
+ }
+ }
+ end_closed {
+ values {
+ string_value: "ZZZZZZZ"
+ }
+ }
+ }
+ start: "A\206\310\002\234\231\000x"
+ limit: "A\206\310\002\234\231ZZZZZZZ\000y"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: ""
+ }
+ }
+ end_open {
+ values {
+ string_value: "ZZZZZZZ"
+ }
+ }
+ }
+ start: "A\206\310\002\234\231\000y"
+ limit: "A\206\310\002\234\231ZZZZZZZ\000x"
+ }
+}
+
+test_case {
+ name: "DataTypeTest_STRING_Desc"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "DataTypeTest_STRING_Desc"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: DESCENDING
+ null_order: NULLS_LAST
+ type {
+ code: STRING
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: "ZZZZZZZ"
+ }
+ }
+ start: "A\206\310\002\273\271\245\245\245\245\245\245\245\377x"
+ }
+ test {
+ key {
+ values {
+ string_value: ""
+ }
+ }
+ start: "A\206\310\002\273\271\377x"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "ZZZZZZZ"
+ }
+ }
+ end_open {
+ values {
+ string_value: ""
+ }
+ }
+ }
+ start: "A\206\310\002\273\271\245\245\245\245\245\245\245\377x"
+ limit: "A\206\310\002\273\271\377x"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: "ZZZZZZZ"
+ }
+ }
+ end_closed {
+ values {
+ string_value: ""
+ }
+ }
+ }
+ start: "A\206\310\002\273\271\245\245\245\245\245\245\245\377y"
+ limit: "A\206\310\002\273\271\377y"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "ZZZZZZZ"
+ }
+ }
+ end_closed {
+ values {
+ string_value: ""
+ }
+ }
+ }
+ start: "A\206\310\002\273\271\245\245\245\245\245\245\245\377x"
+ limit: "A\206\310\002\273\271\377y"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: "ZZZZZZZ"
+ }
+ }
+ end_open {
+ values {
+ string_value: ""
+ }
+ }
+ }
+ start: "A\206\310\002\273\271\245\245\245\245\245\245\245\377y"
+ limit: "A\206\310\002\273\271\377x"
+ }
+}
+
+test_case {
+ name: "DataTypeTest_BYTES"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "DataTypeTest_BYTES"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: BYTES
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: ""
+ }
+ }
+ start: "A\206\310\002\234\231\000x"
+ }
+ test {
+ key {
+ values {
+ string_value: "/////w=="
+ }
+ }
+ start: "A\206\310\002\234\231\377\020\377\020\377\020\377\020\000x"
+ }
+ test {
+ key {
+ values {
+ null_value: NULL_VALUE
+ }
+ }
+ start: "A\206\310\002\233\000"
+ }
+ test {
+ key {
+ values {
+ string_value: ""
+ }
+ }
+ start: "A\206\310\002\234\231\000x"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: ""
+ }
+ }
+ end_open {
+ values {
+ string_value: "/////w=="
+ }
+ }
+ }
+ start: "A\206\310\002\234\231\000x"
+ limit: "A\206\310\002\234\231\377\020\377\020\377\020\377\020\000x"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: ""
+ }
+ }
+ end_closed {
+ values {
+ string_value: "/////w=="
+ }
+ }
+ }
+ start: "A\206\310\002\234\231\000y"
+ limit: "A\206\310\002\234\231\377\020\377\020\377\020\377\020\000y"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: ""
+ }
+ }
+ end_closed {
+ values {
+ string_value: "/////w=="
+ }
+ }
+ }
+ start: "A\206\310\002\234\231\000x"
+ limit: "A\206\310\002\234\231\377\020\377\020\377\020\377\020\000y"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: ""
+ }
+ }
+ end_open {
+ values {
+ string_value: "/////w=="
+ }
+ }
+ }
+ start: "A\206\310\002\234\231\000y"
+ limit: "A\206\310\002\234\231\377\020\377\020\377\020\377\020\000x"
+ }
+}
+
+test_case {
+ name: "DataTypeTest_BYTES_Desc"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "DataTypeTest_BYTES_Desc"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: DESCENDING
+ null_order: NULLS_LAST
+ type {
+ code: BYTES
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: "/////w=="
+ }
+ }
+ start: "A\206\310\002\273\271\000\360\000\360\000\360\000\360\377x"
+ }
+ test {
+ key {
+ values {
+ string_value: ""
+ }
+ }
+ start: "A\206\310\002\273\271\377x"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "/////w=="
+ }
+ }
+ end_open {
+ values {
+ string_value: ""
+ }
+ }
+ }
+ start: "A\206\310\002\273\271\000\360\000\360\000\360\000\360\377x"
+ limit: "A\206\310\002\273\271\377x"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: "/////w=="
+ }
+ }
+ end_closed {
+ values {
+ string_value: ""
+ }
+ }
+ }
+ start: "A\206\310\002\273\271\000\360\000\360\000\360\000\360\377y"
+ limit: "A\206\310\002\273\271\377y"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "/////w=="
+ }
+ }
+ end_closed {
+ values {
+ string_value: ""
+ }
+ }
+ }
+ start: "A\206\310\002\273\271\000\360\000\360\000\360\000\360\377x"
+ limit: "A\206\310\002\273\271\377y"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: "/////w=="
+ }
+ }
+ end_open {
+ values {
+ string_value: ""
+ }
+ }
+ }
+ start: "A\206\310\002\273\271\000\360\000\360\000\360\000\360\377y"
+ limit: "A\206\310\002\273\271\377x"
+ }
+}
+
+test_case {
+ name: "NumericBasic"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "NumericBasic"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: NUMERIC
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: "123"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ number_value: 123
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+}
+
+test_case {
+ name: "NumericMultiPart"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "NumericMultiPart"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: INT64
+ }
+ identifier: "user_id"
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: NUMERIC
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: "123"
+ }
+ values {
+ string_value: "456"
+ }
+ }
+ start: "A\206\310\002\234\221\366"
+ limit: "A\206\310\002\234\221\367"
+ approximate: true
+ }
+}
+
+test_case {
+ name: "DataTypeTest_UUID"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "DataTypeTest_UUID"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: UUID
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: "00000000-0000-0000-0000-000000000000"
+ }
+ }
+ start: "A\206\310\002\234\231\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000x"
+ }
+ test {
+ key {
+ values {
+ string_value: "ffffffff-ffff-ffff-ffff-ffffffffffff"
+ }
+ }
+ start: "A\206\310\002\234\231\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\000x"
+ }
+ test {
+ key {
+ values {
+ null_value: NULL_VALUE
+ }
+ }
+ start: "A\206\310\002\233\000"
+ }
+ test {
+ key {
+ values {
+ string_value: "00000000-0000-0000-0000-000000000000"
+ }
+ }
+ start: "A\206\310\002\234\231\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000x"
+ }
+ test {
+ key {
+ values {
+ string_value: "ffffffff-ffff-ffff-ffff-ffffffffffff"
+ }
+ }
+ start: "A\206\310\002\234\231\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\000x"
+ }
+ test {
+ key {
+ values {
+ string_value: "12345678-1234-1234-1234-1234567890ab"
+ }
+ }
+ start: "A\206\310\002\234\231\0224Vx\0224\0224\0224\0224Vx\220\253\000x"
+ }
+ test {
+ key {
+ values {
+ string_value: "12345678-1234-1234-1234-1234567890AB"
+ }
+ }
+ start: "A\206\310\002\234\231\0224Vx\0224\0224\0224\0224Vx\220\253\000x"
+ }
+ test {
+ key {
+ values {
+ string_value: "{12345678-1234-1234-1234-1234567890ad}"
+ }
+ }
+ start: "A\206\310\002\234\231\0224Vx\0224\0224\0224\0224Vx\220\255\000x"
+ }
+ test {
+ key {
+ values {
+ string_value: "{FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF}"
+ }
+ }
+ start: "A\206\310\002\234\231\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\000x"
+ }
+ test {
+ key {
+ values {
+ string_value: "NOT A UUID"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ number_value: 0
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "12345678x1234-1234-1234-1234567890ab"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "12345678-1234-1234-1234-1234567890a"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "12345678-1234-1234-1234-1234567890abc"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "12345678-1234-1234-1234-1234567890ag"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "123456781234-1234-1234-1234567890ab"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "12345678-12341234-1234-1234567890ab"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "12345678-1234-12341234-1234567890ab"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "12345678-1234-1234-12341234567890ab"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "-12345678-1234-1234-1234-1234567890ab"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "12345678-1234-1234-1234-1234567890ab-"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "12345678--1234-1234-1234-1234567890ab"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "{12345678-1234-1234-1234-1234567890ab"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "12345678-1234-1234-1234-1234567890ab}"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "{{12345678-1234-1234-1234-1234567890ab}}"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key {
+ values {
+ string_value: "12345678-{1234-1234-1234}-1234567890ab"
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "00000000-0000-0000-0000-000000000000"
+ }
+ }
+ end_open {
+ values {
+ string_value: "ffffffff-ffff-ffff-ffff-ffffffffffff"
+ }
+ }
+ }
+ start: "A\206\310\002\234\231\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000x"
+ limit: "A\206\310\002\234\231\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\000x"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: "00000000-0000-0000-0000-000000000000"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "ffffffff-ffff-ffff-ffff-ffffffffffff"
+ }
+ }
+ }
+ start: "A\206\310\002\234\231\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000y"
+ limit: "A\206\310\002\234\231\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\000y"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "00000000-0000-0000-0000-000000000000"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "ffffffff-ffff-ffff-ffff-ffffffffffff"
+ }
+ }
+ }
+ start: "A\206\310\002\234\231\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000x"
+ limit: "A\206\310\002\234\231\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\000y"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: "00000000-0000-0000-0000-000000000000"
+ }
+ }
+ end_open {
+ values {
+ string_value: "ffffffff-ffff-ffff-ffff-ffffffffffff"
+ }
+ }
+ }
+ start: "A\206\310\002\234\231\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000y"
+ limit: "A\206\310\002\234\231\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\000x"
+ }
+}
+
+test_case {
+ name: "DataTypeTest_UUID_Desc"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "DataTypeTest_UUID_Desc"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: DESCENDING
+ null_order: NULLS_LAST
+ type {
+ code: UUID
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: "ffffffff-ffff-ffff-ffff-ffffffffffff"
+ }
+ }
+ start: "A\206\310\002\273\271\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\377x"
+ }
+ test {
+ key {
+ values {
+ string_value: "00000000-0000-0000-0000-000000000000"
+ }
+ }
+ start: "A\206\310\002\273\271\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377x"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "ffffffff-ffff-ffff-ffff-ffffffffffff"
+ }
+ }
+ end_open {
+ values {
+ string_value: "00000000-0000-0000-0000-000000000000"
+ }
+ }
+ }
+ start: "A\206\310\002\273\271\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\377x"
+ limit: "A\206\310\002\273\271\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377x"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: "ffffffff-ffff-ffff-ffff-ffffffffffff"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "00000000-0000-0000-0000-000000000000"
+ }
+ }
+ }
+ start: "A\206\310\002\273\271\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\377y"
+ limit: "A\206\310\002\273\271\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377y"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "ffffffff-ffff-ffff-ffff-ffffffffffff"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "00000000-0000-0000-0000-000000000000"
+ }
+ }
+ }
+ start: "A\206\310\002\273\271\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\377x"
+ limit: "A\206\310\002\273\271\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377y"
+ }
+ test {
+ key_range {
+ start_open {
+ values {
+ string_value: "ffffffff-ffff-ffff-ffff-ffffffffffff"
+ }
+ }
+ end_open {
+ values {
+ string_value: "00000000-0000-0000-0000-000000000000"
+ }
+ }
+ }
+ start: "A\206\310\002\273\271\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\000\360\377y"
+ limit: "A\206\310\002\273\271\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377\020\377x"
+ }
+}
+
+test_case {
+ name: "NotNull"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "NotNull"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: ASCENDING
+ null_order: NOT_NULL
+ type {
+ code: STRING
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: ""
+ }
+ }
+ start: "A\206\310\002\231\000x"
+ }
+ test {
+ key {
+ values {
+ string_value: "foo"
+ }
+ }
+ start: "A\206\310\002\231foo\000x"
+ }
+ test {
+ key {
+ values {
+ null_value: NULL_VALUE
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+}
+
+test_case {
+ name: "NullsLast"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "NullsLast"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_LAST
+ type {
+ code: STRING
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: ""
+ }
+ }
+ start: "A\206\310\002\273\231\000x"
+ }
+ test {
+ key {
+ values {
+ string_value: "foo"
+ }
+ }
+ start: "A\206\310\002\273\231foo\000x"
+ }
+ test {
+ key {
+ values {
+ null_value: NULL_VALUE
+ }
+ }
+ start: "A\206\310\002\274\000"
+ }
+}
+
+test_case {
+ name: "MultiPart"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "MultiPart"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: STRING
+ }
+ identifier: "k1"
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: INT64
+ }
+ identifier: "k2"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: "foo"
+ }
+ values {
+ string_value: "8"
+ }
+ }
+ start: "A\206\310\002\234\231foo\000x\234\221\020"
+ }
+ test {
+ key {
+ values {
+ string_value: "foo"
+ }
+ values {
+ null_value: NULL_VALUE
+ }
+ }
+ start: "A\206\310\002\234\231foo\000x\233\000"
+ }
+ test {
+ key {
+ values {
+ null_value: NULL_VALUE
+ }
+ values {
+ string_value: "8"
+ }
+ }
+ start: "A\206\310\002\233\000\234\221\020"
+ }
+ test {
+ key {
+ values {
+ null_value: NULL_VALUE
+ }
+ values {
+ null_value: NULL_VALUE
+ }
+ }
+ start: "A\206\310\002\233\000\233\000"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "A"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "Z"
+ }
+ }
+ }
+ start: "A\206\310\002\234\231A\000x"
+ limit: "A\206\310\002\234\231Z\000y"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "A"
+ }
+ values {
+ string_value: "4"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "A"
+ }
+ values {
+ string_value: "7"
+ }
+ }
+ }
+ start: "A\206\310\002\234\231A\000x\234\221\010"
+ limit: "A\206\310\002\234\231A\000x\234\221\017"
+ }
+}
+
+test_case {
+ name: "Interleaved"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "C"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: STRING
+ }
+ identifier: "k"
+ }
+ part {
+ tag: 2
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: INT64
+ }
+ identifier: "k2"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: "foo"
+ }
+ values {
+ string_value: "99"
+ }
+ }
+ start: "A\206\310\002\234\231foo\000x\004\234\221\306"
+ }
+ test {
+ key {
+ values {
+ string_value: "foo"
+ }
+ values {
+ null_value: NULL_VALUE
+ }
+ }
+ start: "A\206\310\002\234\231foo\000x\004\233\000"
+ }
+ test {
+ key {
+ values {
+ null_value: NULL_VALUE
+ }
+ values {
+ string_value: "99"
+ }
+ }
+ start: "A\206\310\002\233\000\004\234\221\306"
+ }
+ test {
+ key {
+ values {
+ null_value: NULL_VALUE
+ }
+ values {
+ null_value: NULL_VALUE
+ }
+ }
+ start: "A\206\310\002\233\000\004\233\000"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "A"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "Z"
+ }
+ }
+ }
+ start: "A\206\310\002\234\231A\000x\004"
+ limit: "A\206\310\002\234\231Z\000x\005"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "A"
+ }
+ values {
+ string_value: "4"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "A"
+ }
+ values {
+ string_value: "7"
+ }
+ }
+ }
+ start: "A\206\310\002\234\231A\000x\004\234\221\010"
+ limit: "A\206\310\002\234\231A\000x\004\234\221\017"
+ }
+}
+
+test_case {
+ name: "GeneratedKeyColumns"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "T"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: STRING
+ }
+ identifier: "k"
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: INT64
+ }
+ identifier: "k3"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: "foo"
+ }
+ values {
+ string_value: "99"
+ }
+ }
+ start: "A\206\310\002\234\231foo\000x\234\221\306"
+ }
+ test {
+ key {
+ values {
+ string_value: "foo"
+ }
+ values {
+ null_value: NULL_VALUE
+ }
+ }
+ start: "A\206\310\002\234\231foo\000x\233\000"
+ }
+ test {
+ key {
+ values {
+ null_value: NULL_VALUE
+ }
+ values {
+ string_value: "99"
+ }
+ }
+ start: "A\206\310\002\233\000\234\221\306"
+ }
+ test {
+ key {
+ values {
+ null_value: NULL_VALUE
+ }
+ values {
+ null_value: NULL_VALUE
+ }
+ }
+ start: "A\206\310\002\233\000\233\000"
+ }
+ test {
+ key_range {
+ start_closed {
+ values {
+ string_value: "A"
+ }
+ values {
+ string_value: "4"
+ }
+ }
+ end_closed {
+ values {
+ string_value: "A"
+ }
+ values {
+ string_value: "7"
+ }
+ }
+ }
+ start: "A\206\310\002\234\231A\000x\234\221\010"
+ limit: "A\206\310\002\234\231A\000x\234\221\017"
+ }
+}
+
+test_case {
+ name: "GlobalIndex"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ index_name: "I"
+ part {
+ tag: 1
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: INT64
+ }
+ identifier: "k2"
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: STRING
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: "8"
+ }
+ }
+ start: "\002\002\234\221\020"
+ limit: "\002\002\234\221\021"
+ }
+ test {
+ key {
+ values {
+ null_value: NULL_VALUE
+ }
+ }
+ start: "\002\002\233\000"
+ limit: "\002\002\233\001"
+ }
+}
+
+test_case {
+ name: "LocalIndex"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ index_name: "I"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: STRING
+ }
+ identifier: "k"
+ }
+ part {
+ tag: 3
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: INT64
+ }
+ identifier: "k3"
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: INT64
+ }
+ identifier: "k2"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: "foo"
+ }
+ values {
+ string_value: "8"
+ }
+ }
+ start: "A\206\310\002\234\231foo\000x\006\234\221\020"
+ limit: "A\206\310\002\234\231foo\000x\006\234\221\021"
+ }
+ test {
+ key {
+ values {
+ string_value: "foo"
+ }
+ values {
+ null_value: NULL_VALUE
+ }
+ }
+ start: "A\206\310\002\234\231foo\000x\006\233\000"
+ limit: "A\206\310\002\234\231foo\000x\006\233\001"
+ }
+}
+
+test_case {
+ name: "KeySet"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "KeySet"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: INT64
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key_set {
+ keys {
+ values {
+ string_value: "99"
+ }
+ }
+ }
+ start: "A\206\310\002\234\221\306"
+ }
+ test {
+ key_set {
+ ranges {
+ start_closed {
+ values {
+ string_value: "1"
+ }
+ }
+ end_open {
+ values {
+ string_value: "10"
+ }
+ }
+ }
+ }
+ start: "A\206\310\002\234\221\002"
+ limit: "A\206\310\002\234\221\024"
+ }
+ test {
+ key_set {
+ keys {
+ values {
+ string_value: "99"
+ }
+ }
+ keys {
+ values {
+ string_value: "101"
+ }
+ }
+ }
+ start: "A\206\310\002\234\221\306"
+ limit: "A\206\310\002\234\221\313"
+ }
+ test {
+ key_set {
+ ranges {
+ start_closed {
+ values {
+ string_value: "1"
+ }
+ }
+ end_open {
+ values {
+ string_value: "10"
+ }
+ }
+ }
+ ranges {
+ start_closed {
+ values {
+ string_value: "20"
+ }
+ }
+ end_open {
+ values {
+ string_value: "30"
+ }
+ }
+ }
+ }
+ start: "A\206\310\002\234\221\002"
+ limit: "A\206\310\002\234\221<"
+ }
+ test {
+ key_set {
+ keys {
+ values {
+ string_value: "1"
+ }
+ }
+ ranges {
+ start_closed {
+ values {
+ string_value: "5"
+ }
+ }
+ end_open {
+ values {
+ string_value: "10"
+ }
+ }
+ }
+ }
+ start: "A\206\310\002\234\221\002"
+ limit: "A\206\310\002\234\221\024"
+ }
+ test {
+ key_set {
+ keys {
+ values {
+ string_value: "10"
+ }
+ }
+ ranges {
+ start_closed {
+ values {
+ string_value: "5"
+ }
+ }
+ end_open {
+ values {
+ string_value: "10"
+ }
+ }
+ }
+ }
+ start: "A\206\310\002\234\221\n"
+ limit: "A\206\310\002\234\221\025"
+ }
+}
+
+test_case {
+ name: "KeySet_All"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "T"
+ part {
+ tag: 50020
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: STRING
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key_set {
+ all: true
+ }
+ start: "A\206\310"
+ limit: "A\206\311"
+ }
+}
+
+test_case {
+ name: "InvalidRecipe_EmptyPart"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "BadRecipe"
+ part {
+ tag: 50020
+ }
+ part {
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: STRING
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: "A"
+ }
+ }
+ start: "A\206\310"
+ limit: "A\206\311"
+ approximate: true
+ }
+}
+
+test_case {
+ name: "InvalidRecipe_BadOrder"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "BadRecipe"
+ part {
+ tag: 50020
+ }
+ part {
+ order: 99
+ null_order: NULLS_FIRST
+ type {
+ code: STRING
+ }
+ identifier: "k1"
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: STRING
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: "A"
+ }
+ }
+ start: "A\206\310"
+ limit: "A\206\311"
+ approximate: true
+ }
+}
+
+test_case {
+ name: "InvalidRecipe_BadNullOrder"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "BadRecipe"
+ part {
+ tag: 50020
+ }
+ part {
+ order: ASCENDING
+ null_order: 99
+ type {
+ code: STRING
+ }
+ identifier: "k1"
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: STRING
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: "A"
+ }
+ }
+ start: "A\206\310"
+ limit: "A\206\311"
+ approximate: true
+ }
+}
+
+test_case {
+ name: "InvalidRecipe_BadType"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "BadRecipe"
+ part {
+ tag: 50020
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: TOKENLIST
+ }
+ identifier: "k1"
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: STRING
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ key {
+ values {
+ string_value: "A"
+ }
+ }
+ start: "A\206\310"
+ limit: "A\206\311"
+ approximate: true
+ }
+}
+
+test_case {
+ name: "SimpleMutations"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "SimpleMutations"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: INT64
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ mutation {
+ insert {
+ table: "SimpleMutations"
+ columns: "k"
+ values {
+ values {
+ string_value: "80"
+ }
+ }
+ }
+ }
+ start: "A\206\310\002\234\221\240"
+ limit: "A\206\310\002\234\221\241"
+ }
+ test {
+ mutation {
+ update {
+ table: "SimpleMutations"
+ columns: "k"
+ values {
+ values {
+ string_value: "80"
+ }
+ }
+ }
+ }
+ start: "A\206\310\002\234\221\240"
+ limit: "A\206\310\002\234\221\241"
+ }
+ test {
+ mutation {
+ insert_or_update {
+ table: "SimpleMutations"
+ columns: "k"
+ values {
+ values {
+ string_value: "80"
+ }
+ }
+ }
+ }
+ start: "A\206\310\002\234\221\240"
+ limit: "A\206\310\002\234\221\241"
+ }
+ test {
+ mutation {
+ replace {
+ table: "SimpleMutations"
+ columns: "k"
+ values {
+ values {
+ string_value: "80"
+ }
+ }
+ }
+ }
+ start: "A\206\310\002\234\221\240"
+ limit: "A\206\310\002\234\221\241"
+ }
+ test {
+ mutation {
+ delete {
+ table: "SimpleMutations"
+ key_set {
+ keys {
+ values {
+ string_value: "80"
+ }
+ }
+ }
+ }
+ }
+ start: "A\206\310\002\234\221\240"
+ limit: "A\206\310\002\234\221\241"
+ }
+ test {
+ mutation {
+ delete {
+ table: "SimpleMutations"
+ key_set {
+ ranges {
+ start_closed {
+ values {
+ string_value: "80"
+ }
+ }
+ end_open {
+ values {
+ string_value: "100"
+ }
+ }
+ }
+ }
+ }
+ }
+ start: "A\206\310\002\234\221\240"
+ limit: "A\206\310\002\234\221\310"
+ }
+}
+
+test_case {
+ name: "QueueMutations"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "Q"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: INT64
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ mutation {
+ send {
+ queue: "Q"
+ key {
+ values {
+ string_value: "80"
+ }
+ }
+ payload {
+ string_value: ""
+ }
+ }
+ }
+ start: "A\206\310\002\234\221\240"
+ limit: "A\206\310\002\234\221\241"
+ }
+ test {
+ mutation {
+ ack {
+ queue: "Q"
+ key {
+ values {
+ string_value: "80"
+ }
+ }
+ }
+ }
+ start: "A\206\310\002\234\221\240"
+ limit: "A\206\310\002\234\221\241"
+ }
+}
+
+test_case {
+ name: "CustomMutationCases"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ table_name: "T"
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: STRING
+ }
+ identifier: "k"
+ }
+ }
+ }
+ test {
+ mutation {
+ }
+ start: ""
+ limit: "\377"
+ approximate: true
+ }
+ test {
+ mutation {
+ delete {
+ key_set {
+ all: true
+ }
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ }
+ test {
+ mutation {
+ delete {
+ key_set {
+ keys {
+ values {
+ string_value: "123"
+ }
+ }
+ keys {
+ values {
+ string_value: "456"
+ }
+ }
+ }
+ }
+ }
+ start: "A\206\310\002\234\231123\000x"
+ limit: "A\206\310\002\234\231456\000y"
+ }
+ test {
+ mutation {
+ delete {
+ key_set {
+ ranges {
+ start_closed {
+ values {
+ string_value: "123"
+ }
+ }
+ end_open {
+ values {
+ string_value: "456"
+ }
+ }
+ }
+ ranges {
+ start_closed {
+ values {
+ string_value: "100"
+ }
+ }
+ end_open {
+ values {
+ string_value: "200"
+ }
+ }
+ }
+ ranges {
+ start_closed {
+ values {
+ string_value: "150"
+ }
+ }
+ end_open {
+ values {
+ string_value: "500"
+ }
+ }
+ }
+ }
+ }
+ }
+ start: "A\206\310\002\234\231100\000x"
+ limit: "A\206\310\002\234\231500\000x"
+ }
+ test {
+ mutation {
+ delete {
+ key_set {
+ ranges {
+ start_closed {
+ values {
+ string_value: "123"
+ }
+ }
+ end_open {
+ values {
+ string_value: "456"
+ }
+ }
+ }
+ all: true
+ }
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ }
+ test {
+ mutation {
+ delete {
+ key_set {
+ keys {
+ values {
+ string_value: "123"
+ }
+ }
+ keys {
+ values {
+ number_value: 456
+ }
+ }
+ }
+ }
+ }
+ start: "A\206\310\002"
+ limit: "A\206\310\003"
+ approximate: true
+ }
+}
+
+test_case {
+ name: "QueryEncoding"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ operation_uid: 6
+ part {
+ tag: 50020
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: STRING
+ }
+ identifier: "p1"
+ }
+ part {
+ order: ASCENDING
+ null_order: NULLS_FIRST
+ type {
+ code: STRING
+ }
+ identifier: "p0"
+ }
+ }
+ }
+ test {
+ query_params {
+ fields {
+ key: "p0"
+ value {
+ string_value: "foo"
+ }
+ }
+ fields {
+ key: "p1"
+ value {
+ string_value: "bar"
+ }
+ }
+ }
+ start: "A\206\310\002\234\231bar\000x\234\231foo\000x"
+ }
+ test {
+ query_params {
+ fields {
+ key: "p1"
+ value {
+ string_value: "bar"
+ }
+ }
+ }
+ start: "A\206\310\002\234\231bar\000x"
+ limit: "A\206\310\002\234\231bar\000y"
+ approximate: true
+ }
+}
+
+test_case {
+ name: "RandomQueryroot"
+ recipes {
+ schema_generation: "\001\001"
+ recipe {
+ operation_uid: 7
+ part {
+ tag: 50016
+ }
+ part {
+ tag: 1
+ }
+ part {
+ order: ASCENDING
+ null_order: NOT_NULL
+ type {
+ code: INT64
+ }
+ random: true
+ }
+ }
+ }
+ test {
+ query_params {
+ }
+ start: "A\206\300\002\230\327\342\351\276\316\214%$"
+ }
+}
\ No newline at end of file