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..1882a12a83 --- /dev/null +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/SsFormat.java @@ -0,0 +1,371 @@ +/* + * 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() {} + + 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) { + // Transform negative doubles to maintain lexicographic sort order + enc = Long.MIN_VALUE - enc; + } + 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..bfcd2e30a8 --- /dev/null +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/TargetRange.java @@ -0,0 +1,56 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.cloud.spanner.spi.v1; + +import com.google.api.core.InternalApi; +import com.google.protobuf.ByteString; + +/** Represents a key range with start and limit boundaries for routing. */ +@InternalApi +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/SsFormatTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/SsFormatTest.java new file mode 100644 index 0000000000..2641f28f1b --- /dev/null +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/SsFormatTest.java @@ -0,0 +1,834 @@ +/* + * 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.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.protobuf.ByteString; +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.TreeSet; +import org.junit.BeforeClass; +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 { + + private static List signedIntTestValues; + private static List unsignedIntTestValues; + private static List doubleTestValues; + + /** Comparator for unsigned lexicographic comparison of byte arrays. */ + private static final Comparator UNSIGNED_BYTE_COMPARATOR = + (a, b) -> + ByteString.unsignedLexicographicalComparator() + .compare(ByteString.copyFrom(a), ByteString.copyFrom(b)); + + @BeforeClass + public static void setUpTestData() { + signedIntTestValues = buildSignedIntTestValues(); + unsignedIntTestValues = buildUnsignedIntTestValues(); + doubleTestValues = buildDoubleTestValues(); + } + + private static List buildSignedIntTestValues() { + TreeSet values = new TreeSet<>(); + + // Range of small values + for (int i = -300; i < 300; i++) { + values.add((long) i); + } + + // Powers of 2 and boundaries + for (int i = 0; i < 63; i++) { + long powerOf2 = 1L << i; + values.add(powerOf2); + values.add(powerOf2 - 1); + values.add(powerOf2 + 1); + values.add(-powerOf2); + values.add(-powerOf2 - 1); + values.add(-powerOf2 + 1); + } + + // Edge cases + values.add(Long.MIN_VALUE); + values.add(Long.MAX_VALUE); + + return new ArrayList<>(values); + } + + private static List buildUnsignedIntTestValues() { + TreeSet values = new TreeSet<>(Long::compareUnsigned); + + // Range of small values + for (int i = 0; i < 600; i++) { + values.add((long) i); + } + + // Powers of 2 and boundaries (treating as unsigned) + for (int i = 0; i < 64; i++) { + long powerOf2 = 1L << i; + values.add(powerOf2); + if (powerOf2 > 0) { + values.add(powerOf2 - 1); + } + values.add(powerOf2 + 1); + } + + // Max unsigned value (all bits set) + values.add(-1L); // 0xFFFFFFFFFFFFFFFF as unsigned + + return new ArrayList<>(values); + } + + private static List buildDoubleTestValues() { + TreeSet values = + new TreeSet<>( + (a, b) -> { + // Handle NaN specially - put at end + if (Double.isNaN(a) && Double.isNaN(b)) return 0; + if (Double.isNaN(a)) return 1; + if (Double.isNaN(b)) return -1; + return Double.compare(a, b); + }); + + // Basic values + values.add(0.0); + values.add(-0.0); + values.add(Double.POSITIVE_INFINITY); + values.add(Double.NEGATIVE_INFINITY); + values.add(Double.MIN_VALUE); + values.add(Double.MAX_VALUE); + values.add(-Double.MIN_VALUE); + values.add(-Double.MAX_VALUE); + + // Powers of 10 + double value = 1.0; + for (int i = 0; i < 10; i++) { + values.add(value); + values.add(-value); + value /= 10; + } + + long[] signs = {0, 1}; + long[] exponents = { + 0, 1, 2, 100, 200, 512, 1000, 1020, 1021, 1022, 1023, 1024, 1025, 1026, 1027, 1028, 1029, + 2000, 2045, 2046, 2047 + }; + long[] fractions = { + 0, + 1, + 2, + 10, + 16, + 255, + 256, + 32767, + 32768, + 65535, + 65536, + 1000000, + 0x7ffffffeL, + 0x7fffffffL, + 0x80000000L, + 0x80000001L, + 0x80000002L, + 0x0003456789abcdefL, + 0x0007fffffffffffeL, + 0x0007ffffffffffffL, + 0x0008000000000000L, + 0x0008000000000001L, + 0x000cba9876543210L, + 0x000fffffffff0000L, + 0x000ffffffffff000L, + 0x000fffffffffff00L, + 0x000ffffffffffff0L, + 0x000ffffffffffff8L, + 0x000ffffffffffffcL, + 0x000ffffffffffffeL, + 0x000fffffffffffffL + }; + + for (long sign : signs) { + for (long exponent : exponents) { + for (long fraction : fractions) { + long bits = (sign << 63) | (exponent << 52) | fraction; + values.add(Double.longBitsToDouble(bits)); + } + } + } + + return new ArrayList<>(values); + } + + // ==================== Prefix Successor Tests ==================== + + @Test + public void makePrefixSuccessor_emptyInput_returnsEmpty() { + assertEquals(ByteString.EMPTY, SsFormat.makePrefixSuccessor(ByteString.EMPTY)); + assertEquals(ByteString.EMPTY, SsFormat.makePrefixSuccessor(null)); + } + + @Test + public void makePrefixSuccessor_singleByte_setsLsb() { + ByteString input = ByteString.copyFrom(new byte[] {0x00}); + ByteString result = SsFormat.makePrefixSuccessor(input); + + assertEquals(1, result.size()); + assertEquals(0x01, result.byteAt(0) & 0xFF); + } + + @Test + public void makePrefixSuccessor_multipleBytes_onlyModifiesLastByte() { + ByteString input = ByteString.copyFrom(new byte[] {0x12, 0x34, 0x00}); + ByteString 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 makePrefixSuccessor_resultIsGreaterThanOriginal() { + byte[] original = new byte[] {0x10, 0x20, 0x30}; + ByteString successor = SsFormat.makePrefixSuccessor(ByteString.copyFrom(original)); + + assertTrue( + ByteString.unsignedLexicographicalComparator() + .compare(ByteString.copyFrom(original), successor) + < 0); + } + + // ==================== Composite Tag Tests ==================== + + @Test + public void appendCompositeTag_shortTag_encodesInOneByte() { + // Tags 1-15 should fit in 1 byte + for (int tag = 1; tag <= 15; tag++) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendCompositeTag(out, tag); + byte[] result = out.toByteArray(); + + assertEquals("Tag " + tag + " should encode to 1 byte", 1, result.length); + assertEquals("Tag " + tag + " should encode as tag << 1", tag << 1, result[0] & 0xFF); + } + } + + @Test + public void appendCompositeTag_mediumTag_encodesInTwoBytes() { + // Tags 16-4095 should fit in 2 bytes + int[] testTags = {16, 100, 1000, 4095}; + for (int tag : testTags) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendCompositeTag(out, tag); + byte[] result = out.toByteArray(); + + assertEquals("Tag " + tag + " should encode to 2 bytes", 2, result.length); + } + } + + @Test + public void appendCompositeTag_largeTag_encodesInThreeBytes() { + // Tags 4096-65535 should fit in 3 bytes + int[] testTags = {4096, 10000, 65535}; + for (int tag : testTags) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendCompositeTag(out, tag); + byte[] result = out.toByteArray(); + + assertEquals("Tag " + tag + " should encode to 3 bytes", 3, result.length); + } + } + + @Test + public void appendCompositeTag_invalidTag_throws() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + assertThrows(IllegalArgumentException.class, () -> SsFormat.appendCompositeTag(out, 0)); + assertThrows(IllegalArgumentException.class, () -> SsFormat.appendCompositeTag(out, -1)); + assertThrows(IllegalArgumentException.class, () -> SsFormat.appendCompositeTag(out, 65536)); + } + + @Test + public void appendCompositeTag_preservesOrdering() { + // Verify smaller tags encode to lexicographically smaller byte sequences + for (int tag1 = 1; tag1 <= 100; tag1++) { + for (int tag2 = tag1 + 1; tag2 <= 101 && tag2 <= tag1 + 10; tag2++) { + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendCompositeTag(out1, tag1); + SsFormat.appendCompositeTag(out2, tag2); + + assertTrue( + "Tag " + tag1 + " should encode smaller than tag " + tag2, + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) < 0); + } + } + } + + // ==================== Signed Integer Tests ==================== + + @Test + public void appendIntIncreasing_preservesOrdering() { + // Verify that encoded integers maintain their natural ordering + for (int i = 0; i < signedIntTestValues.size() - 1; i++) { + long v1 = signedIntTestValues.get(i); + long v2 = signedIntTestValues.get(i + 1); + + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendIntIncreasing(out1, v1); + SsFormat.appendIntIncreasing(out2, v2); + + assertTrue( + "Encoded " + v1 + " should be less than encoded " + v2, + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) < 0); + } + } + + @Test + public void appendIntDecreasing_reversesOrdering() { + // Verify that decreasing encoding reverses the ordering + for (int i = 0; i < signedIntTestValues.size() - 1; i++) { + long v1 = signedIntTestValues.get(i); + long v2 = signedIntTestValues.get(i + 1); + + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendIntDecreasing(out1, v1); + SsFormat.appendIntDecreasing(out2, v2); + + assertTrue( + "Decreasing encoded " + v1 + " should be greater than encoded " + v2, + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) > 0); + } + } + + @Test + public void appendIntIncreasing_hasIsKeyBitSet() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendIntIncreasing(out, 42); + byte[] result = out.toByteArray(); + + assertTrue("IS_KEY bit (0x80) should be set", (result[0] & 0x80) != 0); + } + + @Test + public void appendIntIncreasing_edgeCases() { + long[] edgeCases = {Long.MIN_VALUE, -1, 0, 1, Long.MAX_VALUE}; + + for (long value : edgeCases) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendIntIncreasing(out, value); + byte[] result = out.toByteArray(); + + assertTrue("Result should have at least 2 bytes for value " + value, result.length >= 2); + assertTrue("IS_KEY bit should be set for value " + value, (result[0] & 0x80) != 0); + } + } + + // ==================== Unsigned Integer Tests ==================== + + @Test + public void appendUnsignedIntIncreasing_preservesOrdering() { + // Filter to only non-negative values for unsigned comparison + List positiveValues = new ArrayList<>(); + for (long v : unsignedIntTestValues) { + if (v >= 0) positiveValues.add(v); + } + positiveValues.sort(Long::compareUnsigned); + + for (int i = 0; i < positiveValues.size() - 1; i++) { + long v1 = positiveValues.get(i); + long v2 = positiveValues.get(i + 1); + + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendUnsignedIntIncreasing(out1, v1); + SsFormat.appendUnsignedIntIncreasing(out2, v2); + + assertTrue( + "Unsigned encoded " + + Long.toUnsignedString(v1) + + " should be less than " + + Long.toUnsignedString(v2), + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) < 0); + } + } + + @Test + public void appendUnsignedIntIncreasing_rejectsNegativeValues() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + assertThrows( + IllegalArgumentException.class, () -> SsFormat.appendUnsignedIntIncreasing(out, -1)); + } + + @Test + public void appendUnsignedIntDecreasing_reversesOrdering() { + long[] values = {0, 1, 100, 1000, Long.MAX_VALUE}; + + for (int i = 0; i < values.length - 1; i++) { + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendUnsignedIntDecreasing(out1, values[i]); + SsFormat.appendUnsignedIntDecreasing(out2, values[i + 1]); + + assertTrue( + "Decreasing unsigned encoded " + values[i] + " should be greater than " + values[i + 1], + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) > 0); + } + } + + // ==================== String Tests ==================== + + @Test + public void appendStringIncreasing_preservesOrdering() { + String[] strings = {"", "a", "aa", "ab", "b", "hello", "world", "\u00ff"}; + Arrays.sort(strings); + + for (int i = 0; i < strings.length - 1; i++) { + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendStringIncreasing(out1, strings[i]); + SsFormat.appendStringIncreasing(out2, strings[i + 1]); + + assertTrue( + "Encoded '" + strings[i] + "' should be less than '" + strings[i + 1] + "'", + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) < 0); + } + } + + @Test + public void appendStringDecreasing_reversesOrdering() { + String[] strings = {"", "a", "b", "hello"}; + + for (int i = 0; i < strings.length - 1; i++) { + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendStringDecreasing(out1, strings[i]); + SsFormat.appendStringDecreasing(out2, strings[i + 1]); + + assertTrue( + "Decreasing encoded '" + strings[i] + "' should be greater than '" + strings[i + 1] + "'", + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) > 0); + } + } + + @Test + public void appendStringIncreasing_escapesSpecialBytes() { + // 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: + // header (1) + escaped 0x00 (2) + escaped 0xFF (2) + 0x42 (1) + terminator (2) = 8 + assertTrue("Result should include escape sequences", result.length > 5); + } + + @Test + public void appendStringIncreasing_emptyString() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendStringIncreasing(out, ""); + byte[] result = out.toByteArray(); + + // Empty string should still have header + terminator + assertTrue("Empty string encoding should have at least 3 bytes", result.length >= 3); + assertTrue("IS_KEY bit should be set", (result[0] & 0x80) != 0); + } + + // ==================== Bytes Tests ==================== + + @Test + public void appendBytesIncreasing_preservesOrdering() { + byte[][] testBytes = { + new byte[] {}, + new byte[] {0x00}, + new byte[] {0x01}, + new byte[] {0x01, 0x02}, + new byte[] {(byte) 0xFF} + }; + + for (int i = 0; i < testBytes.length - 1; i++) { + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendBytesIncreasing(out1, testBytes[i]); + SsFormat.appendBytesIncreasing(out2, testBytes[i + 1]); + + assertTrue( + "Encoded bytes should maintain lexicographic order", + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) < 0); + } + } + + // ==================== Double Tests ==================== + + @Test + public void appendDoubleIncreasing_preservesOrdering() { + // Filter out NaN as it has special comparison semantics + List sortedDoubles = new ArrayList<>(); + for (double d : doubleTestValues) { + if (!Double.isNaN(d)) { + sortedDoubles.add(d); + } + } + sortedDoubles.sort(Double::compare); + + for (int i = 0; i < sortedDoubles.size() - 1; i++) { + double v1 = sortedDoubles.get(i); + double v2 = sortedDoubles.get(i + 1); + + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendDoubleIncreasing(out1, v1); + SsFormat.appendDoubleIncreasing(out2, v2); + + int cmp = UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()); + + // Note: -0.0 and 0.0 encode identically (both map to 0 internally), so allow equality + assertTrue("Encoded " + v1 + " should be <= encoded " + v2, cmp <= 0); + } + } + + @Test + public void appendDoubleDecreasing_reversesOrdering() { + double[] values = {-Double.MAX_VALUE, -1.0, 0.0, 1.0, Double.MAX_VALUE}; + + for (int i = 0; i < values.length - 1; i++) { + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendDoubleDecreasing(out1, values[i]); + SsFormat.appendDoubleDecreasing(out2, values[i + 1]); + + assertTrue( + "Decreasing encoded " + values[i] + " should be greater than " + values[i + 1], + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) > 0); + } + } + + @Test + public void appendDoubleIncreasing_specialValues() { + // Test special double values + // Note: -0.0 is excluded because it encodes identically to 0.0 + // (both have internal representation mapping to 0) + double[] specialValues = { + Double.NEGATIVE_INFINITY, + -Double.MAX_VALUE, + -1.0, + -Double.MIN_VALUE, + 0.0, // -0.0 encodes the same as 0.0 + Double.MIN_VALUE, + 1.0, + Double.MAX_VALUE, + Double.POSITIVE_INFINITY + }; + + // Verify ordering is preserved + for (int i = 0; i < specialValues.length - 1; i++) { + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendDoubleIncreasing(out1, specialValues[i]); + SsFormat.appendDoubleIncreasing(out2, specialValues[i + 1]); + + assertTrue( + "Special value " + specialValues[i] + " should encode less than " + specialValues[i + 1], + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) < 0); + } + } + + @Test + public void appendDoubleIncreasing_negativeZeroEqualsPositiveZero() { + // Verify that -0.0 and 0.0 encode identically + // This is correct behavior: both map to internal representation 0 + ByteArrayOutputStream outNegZero = new ByteArrayOutputStream(); + ByteArrayOutputStream outPosZero = new ByteArrayOutputStream(); + + SsFormat.appendDoubleIncreasing(outNegZero, -0.0); + SsFormat.appendDoubleIncreasing(outPosZero, 0.0); + + assertArrayEquals( + "-0.0 and 0.0 should encode identically", + outNegZero.toByteArray(), + outPosZero.toByteArray()); + } + + @Test + public void appendDoubleIncreasing_nan() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendDoubleIncreasing(out, Double.NaN); + byte[] result = out.toByteArray(); + + assertTrue("NaN encoding should have at least 2 bytes", result.length >= 2); + assertTrue("IS_KEY bit should be set for NaN", (result[0] & 0x80) != 0); + } + + // ==================== Null Marker Tests ==================== + + @Test + public void appendNullOrderedFirst_encoding() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendNullOrderedFirst(out); + byte[] result = out.toByteArray(); + + assertEquals("Null ordered first should encode to 2 bytes", 2, result.length); + assertTrue("IS_KEY bit should be set", (result[0] & 0x80) != 0); + } + + @Test + public void appendNullOrderedLast_encoding() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendNullOrderedLast(out); + byte[] result = out.toByteArray(); + + assertEquals("Null ordered last should encode to 2 bytes", 2, result.length); + assertTrue("IS_KEY bit should be set", (result[0] & 0x80) != 0); + } + + @Test + public void appendNotNullMarkerNullOrderedFirst_encoding() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendNotNullMarkerNullOrderedFirst(out); + byte[] result = out.toByteArray(); + + assertEquals("Not-null marker (nulls first) should encode to 1 byte", 1, result.length); + } + + @Test + public void appendNotNullMarkerNullOrderedLast_encoding() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendNotNullMarkerNullOrderedLast(out); + byte[] result = out.toByteArray(); + + assertEquals("Not-null marker (nulls last) should encode to 1 byte", 1, result.length); + } + + @Test + public void nullOrderedFirst_sortsBeforeValues() { + ByteArrayOutputStream nullOut = new ByteArrayOutputStream(); + ByteArrayOutputStream valueOut = new ByteArrayOutputStream(); + + SsFormat.appendNullOrderedFirst(nullOut); + SsFormat.appendNotNullMarkerNullOrderedFirst(valueOut); + SsFormat.appendIntIncreasing(valueOut, Long.MIN_VALUE); + + assertTrue( + "Null (ordered first) should sort before any value", + UNSIGNED_BYTE_COMPARATOR.compare(nullOut.toByteArray(), valueOut.toByteArray()) < 0); + } + + @Test + public void nullOrderedLast_sortsAfterValues() { + ByteArrayOutputStream nullOut = new ByteArrayOutputStream(); + ByteArrayOutputStream valueOut = new ByteArrayOutputStream(); + + SsFormat.appendNullOrderedLast(nullOut); + SsFormat.appendNotNullMarkerNullOrderedLast(valueOut); + SsFormat.appendIntIncreasing(valueOut, Long.MAX_VALUE); + + assertTrue( + "Null (ordered last) should sort after any value", + UNSIGNED_BYTE_COMPARATOR.compare(nullOut.toByteArray(), valueOut.toByteArray()) > 0); + } + + // ==================== Timestamp Tests ==================== + + @Test + public void encodeTimestamp_length() { + byte[] result = SsFormat.encodeTimestamp(0, 0); + assertEquals("Timestamp should encode to 12 bytes", 12, result.length); + } + + @Test + public void encodeTimestamp_preservesOrdering() { + long[][] timestamps = { + {0, 0}, + {0, 1}, + {0, 999999999}, + {1, 0}, + {100, 500000000}, + {Long.MAX_VALUE / 2, 0} + }; + + for (int i = 0; i < timestamps.length - 1; i++) { + byte[] t1 = SsFormat.encodeTimestamp(timestamps[i][0], (int) timestamps[i][1]); + byte[] t2 = SsFormat.encodeTimestamp(timestamps[i + 1][0], (int) timestamps[i + 1][1]); + + assertTrue( + "Earlier timestamp should encode smaller", UNSIGNED_BYTE_COMPARATOR.compare(t1, t2) < 0); + } + } + + // ==================== UUID Tests ==================== + + @Test + public void encodeUuid_length() { + byte[] result = SsFormat.encodeUuid(0, 0); + assertEquals("UUID should encode to 16 bytes", 16, result.length); + } + + @Test + public void encodeUuid_bigEndianEncoding() { + byte[] result = SsFormat.encodeUuid(0x0102030405060708L, 0x090A0B0C0D0E0F10L); + + // Verify big-endian encoding of high bits + assertEquals(0x01, result[0] & 0xFF); + assertEquals(0x02, result[1] & 0xFF); + assertEquals(0x03, result[2] & 0xFF); + assertEquals(0x04, result[3] & 0xFF); + assertEquals(0x05, result[4] & 0xFF); + assertEquals(0x06, result[5] & 0xFF); + assertEquals(0x07, result[6] & 0xFF); + assertEquals(0x08, result[7] & 0xFF); + + // Verify big-endian encoding of low bits + assertEquals(0x09, result[8] & 0xFF); + assertEquals(0x0A, result[9] & 0xFF); + assertEquals(0x0B, result[10] & 0xFF); + assertEquals(0x0C, result[11] & 0xFF); + assertEquals(0x0D, result[12] & 0xFF); + assertEquals(0x0E, result[13] & 0xFF); + assertEquals(0x0F, result[14] & 0xFF); + assertEquals(0x10, result[15] & 0xFF); + } + + @Test + public void encodeUuid_preservesOrdering() { + // UUIDs compared as unsigned 128-bit integers should preserve order + long[][] uuids = { + {0, 0}, + {0, 1}, + {0, Long.MAX_VALUE}, + {1, 0}, + {Long.MAX_VALUE, Long.MAX_VALUE} + }; + + for (int i = 0; i < uuids.length - 1; i++) { + byte[] u1 = SsFormat.encodeUuid(uuids[i][0], uuids[i][1]); + byte[] u2 = SsFormat.encodeUuid(uuids[i + 1][0], uuids[i + 1][1]); + + assertTrue("UUID ordering should be preserved", UNSIGNED_BYTE_COMPARATOR.compare(u1, u2) < 0); + } + } + + // ==================== Composite Key Tests ==================== + + @Test + public void compositeKey_tagPlusIntPreservesOrdering() { + int tag = 5; + long[] values = {Long.MIN_VALUE, -1, 0, 1, Long.MAX_VALUE}; + + for (int i = 0; i < values.length - 1; i++) { + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendCompositeTag(out1, tag); + SsFormat.appendIntIncreasing(out1, values[i]); + + SsFormat.appendCompositeTag(out2, tag); + SsFormat.appendIntIncreasing(out2, values[i + 1]); + + assertTrue( + "Composite key with " + values[i] + " should be less than with " + values[i + 1], + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) < 0); + } + } + + @Test + public void compositeKey_differentTagsSortByTag() { + long value = 100; + + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendCompositeTag(out1, 5); + SsFormat.appendIntIncreasing(out1, value); + + SsFormat.appendCompositeTag(out2, 10); + SsFormat.appendIntIncreasing(out2, value); + + assertTrue( + "Key with smaller tag should sort first", + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) < 0); + } + + @Test + public void compositeKey_multipleKeyParts() { + // Simulate encoding a composite key with multiple parts: tag + int + string + ByteArrayOutputStream out1 = new ByteArrayOutputStream(); + ByteArrayOutputStream out2 = new ByteArrayOutputStream(); + + SsFormat.appendCompositeTag(out1, 1); + SsFormat.appendIntIncreasing(out1, 100); + SsFormat.appendStringIncreasing(out1, "alice"); + + SsFormat.appendCompositeTag(out2, 1); + SsFormat.appendIntIncreasing(out2, 100); + SsFormat.appendStringIncreasing(out2, "bob"); + + assertTrue( + "Keys with same prefix but different strings should order by string", + UNSIGNED_BYTE_COMPARATOR.compare(out1.toByteArray(), out2.toByteArray()) < 0); + } + + // ==================== Order Preservation Summary Test ==================== + + @Test + public void orderPreservation_comprehensiveIntTest() { + // Take a sample of values to avoid O(n^2) test time + int step = Math.max(1, signedIntTestValues.size() / 100); + List sample = new ArrayList<>(); + for (int i = 0; i < signedIntTestValues.size(); i += step) { + sample.add(signedIntTestValues.get(i)); + } + + // Encode all values + List encoded = new ArrayList<>(); + for (long v : sample) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SsFormat.appendIntIncreasing(out, v); + encoded.add(out.toByteArray()); + } + + // Verify the encoded values are in the same order as the original values + for (int i = 0; i < sample.size() - 1; i++) { + int comparison = UNSIGNED_BYTE_COMPARATOR.compare(encoded.get(i), encoded.get(i + 1)); + assertTrue( + "Order should be preserved: " + sample.get(i) + " < " + sample.get(i + 1), + comparison < 0); + } + } +}