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/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