diff --git a/packages/firestore/src/index/ordered_code_writer.ts b/packages/firestore/src/index/ordered_code_writer.ts index 9f993ab9eb4..9f32c2d4fef 100644 --- a/packages/firestore/src/index/ordered_code_writer.ts +++ b/packages/firestore/src/index/ordered_code_writer.ts @@ -14,51 +14,189 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { fail } from '../util/assert'; +import { debugAssert, fail } from '../util/assert'; import { ByteString } from '../util/byte_string'; +const LONG_SIZE = 64; +const BYTE_SIZE = 8; + +/** + * The default size of the buffer. This is arbitrary, but likely larger than + * most index values so that less copies of the underlying buffer will be made. + * For large values, a single copy will made to double the buffer length. + */ +const DEFAULT_BUFFER_SIZE = 1024; + +/** Converts a JavaScript number to a byte array (using big endian encoding). */ +function doubleToLongBits(value: number): Uint8Array { + const dv = new DataView(new ArrayBuffer(8)); + dv.setFloat64(0, value, /* littleEndian= */ false); + return new Uint8Array(dv.buffer); +} + +/** + * Counts the number of zeros in a byte. + * + * Visible for testing. + */ +export function numberOfLeadingZerosInByte(x: number): number { + debugAssert(x < 256, 'Provided value is not a byte: ' + x); + if (x === 0) { + return 8; + } + + let zeros = 0; + if (x >> 4 === 0) { + // Test if the first four bits are zero. + zeros += 4; + x = x << 4; + } + if (x >> 6 === 0) { + // Test if the first two (or next two) bits are zero. + zeros += 2; + x = x << 2; + } + if (x >> 7 === 0) { + // Test if the remaining bit is zero. + zeros += 1; + } + return zeros; +} + +/** Counts the number of leading zeros in the given byte array. */ +function numberOfLeadingZeros(bytes: Uint8Array): number { + debugAssert( + bytes.length === 8, + 'Can only count leading zeros in 64-bit numbers' + ); + let leadingZeros = 0; + for (let i = 0; i < 8; ++i) { + const zeros = numberOfLeadingZerosInByte(bytes[i] & 0xff); + leadingZeros += zeros; + if (zeros !== 8) { + break; + } + } + return leadingZeros; +} + +/** + * Returns the number of bytes required to store "value". Leading zero bytes + * are skipped. + */ +function unsignedNumLength(value: Uint8Array): number { + // This is just the number of bytes for the unsigned representation of the number. + const numBits = LONG_SIZE - numberOfLeadingZeros(value); + return Math.ceil(numBits / BYTE_SIZE); +} + +/** + * OrderedCodeWriter is a minimal-allocation implementation of the writing + * behavior defined by the backend. + * + * The code is ported from its Java counterpart. + */ export class OrderedCodeWriter { - writeBytesAscending(value: ByteString): void { - fail('Not implemented'); + buffer = new Uint8Array(DEFAULT_BUFFER_SIZE); + position = 0; + + writeNumberAscending(val: number): void { + // Values are encoded with a single byte length prefix, followed by the + // actual value in big-endian format with leading 0 bytes dropped. + const value = this.toOrderedBits(val); + const len = unsignedNumLength(value); + this.ensureAvailable(1 + len); + this.buffer[this.position++] = len & 0xff; // Write the length + for (let i = value.length - len; i < value.length; ++i) { + this.buffer[this.position++] = value[i] & 0xff; + } } - writeBytesDescending(value: ByteString): void { - fail('Not implemented'); + writeNumberDescending(val: number): void { + // Values are encoded with a single byte length prefix, followed by the + // inverted value in big-endian format with leading 0 bytes dropped. + const value = this.toOrderedBits(val); + const len = unsignedNumLength(value); + this.ensureAvailable(1 + len); + this.buffer[this.position++] = ~(len & 0xff); // Write the length + for (let i = value.length - len; i < value.length; ++i) { + this.buffer[this.position++] = ~(value[i] & 0xff); + } } - writeUtf8Ascending(sequence: string): void { - fail('Not implemented'); + /** + * Encodes `val` into an encoding so that the order matches the IEEE 754 + * floating-point comparison results with the following exceptions: + * -0.0 < 0.0 + * all non-NaN < NaN + * NaN = NaN + */ + private toOrderedBits(val: number): Uint8Array { + const value = doubleToLongBits(val); + // Check if the first bit is set. We use a bit mask since value[0] is + // encoded as a number from 0 to 255. + const isNegative = (value[0] & 0x80) !== 0; + + // Revert the two complement to get natural ordering + value[0] ^= isNegative ? 0xff : 0x80; + for (let i = 1; i < value.length; ++i) { + value[i] ^= isNegative ? 0xff : 0x00; + } + return value; } - writeUtf8Descending(sequence: string): void { - fail('Not implemented'); + /** Resets the buffer such that it is the same as when it was newly constructed. */ + reset(): void { + this.position = 0; } - writeNumberAscending(val: number): void { + /** Makes a copy of the encoded bytes in this buffer. */ + encodedBytes(): Uint8Array { + return this.buffer.slice(0, this.position); + } + + writeBytesAscending(value: ByteString): void { fail('Not implemented'); } - writeNumberDescending(val: number): void { + writeBytesDescending(value: ByteString): void { fail('Not implemented'); } - writeInfinityAscending(): void { + writeUtf8Ascending(sequence: string): void { fail('Not implemented'); } - writeInfinityDescending(): void { + writeUtf8Descending(sequence: string): void { fail('Not implemented'); } - reset(): void { + writeInfinityAscending(): void { fail('Not implemented'); } - encodedBytes(): Uint8Array { + writeInfinityDescending(): void { fail('Not implemented'); } seed(encodedBytes: Uint8Array): void { fail('Not implemented'); } + + private ensureAvailable(bytes: number): void { + const minCapacity = bytes + this.position; + if (minCapacity <= this.buffer.length) { + return; + } + // Try doubling. + let newLength = this.buffer.length * 2; + // Still not big enough? Just allocate the right size. + if (newLength < minCapacity) { + newLength = minCapacity; + } + // Create the new buffer. + const newBuffer = new Uint8Array(newLength); + newBuffer.set(this.buffer); // copy old data + this.buffer = newBuffer; + } } diff --git a/packages/firestore/test/unit/index/ordered_code_writer.test.ts b/packages/firestore/test/unit/index/ordered_code_writer.test.ts new file mode 100644 index 00000000000..0ff46b373ce --- /dev/null +++ b/packages/firestore/test/unit/index/ordered_code_writer.test.ts @@ -0,0 +1,158 @@ +/** + * @license + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0x00 (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.0x00 + * + * 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. + */ +import { expect } from 'chai'; + +import { + numberOfLeadingZerosInByte, + OrderedCodeWriter +} from '../../../src/index/ordered_code_writer'; + +class ValueTestCase { + constructor( + readonly val: T, + readonly ascString: string, + readonly descString: string + ) {} +} + +const NUMBER_TEST_CASES: Array> = [ + new ValueTestCase( + Number.NEGATIVE_INFINITY, + // Note: This values are taken from the Android reference implementation + '070fffffffffffff', + 'f8f0000000000000' + ), + new ValueTestCase( + Number.MIN_SAFE_INTEGER, + '083cc0000000000000', + 'f7c33fffffffffffff' + ), + new ValueTestCase(-2, '083fffffffffffffff', 'f7c000000000000000'), + new ValueTestCase(-1, '08400fffffffffffff', 'f7bff0000000000000'), + new ValueTestCase(-0.1, '084046666666666665', 'f7bfb999999999999a'), + new ValueTestCase(-0.0, '087fffffffffffffff', 'f78000000000000000'), + new ValueTestCase(0, '088000000000000000', 'f77fffffffffffffff'), + new ValueTestCase( + Number.MIN_VALUE, + '088000000000000001', + 'f77ffffffffffffffe' + ), + new ValueTestCase(0.1, '08bfb999999999999a', 'f74046666666666665'), + new ValueTestCase(1, '08bff0000000000000', 'f7400fffffffffffff'), + new ValueTestCase(2, '08c000000000000000', 'f73fffffffffffffff'), + new ValueTestCase(4, '08c010000000000000', 'f73fefffffffffffff'), + new ValueTestCase(8, '08c020000000000000', 'f73fdfffffffffffff'), + new ValueTestCase(16, '08c030000000000000', 'f73fcfffffffffffff'), + new ValueTestCase(32, '08c040000000000000', 'f73fbfffffffffffff'), + new ValueTestCase(64, '08c050000000000000', 'f73fafffffffffffff'), + new ValueTestCase(128, '08c060000000000000', 'f73f9fffffffffffff'), + new ValueTestCase(255, '08c06fe00000000000', 'f73f901fffffffffff'), + new ValueTestCase(256, '08c070000000000000', 'f73f8fffffffffffff'), + new ValueTestCase(257, '08c070100000000000', 'f73f8fefffffffffff'), + new ValueTestCase( + Number.MAX_SAFE_INTEGER, + '08c33fffffffffffff', + 'f73cc0000000000000' + ), + new ValueTestCase( + Number.POSITIVE_INFINITY, + '08fff0000000000000', + 'f7000fffffffffffff' + ), + new ValueTestCase(Number.NaN, '08fff8000000000000', 'f70007ffffffffffff') +]; + +describe('Ordered Code Writer', () => { + it('computes number of leading zeros', () => { + for (let i = 0; i < 0xff; ++i) { + let zeros = 0; + for (let bit = 7; bit >= 0; --bit) { + if ((i & (1 << bit)) === 0) { + ++zeros; + } else { + break; + } + } + expect(numberOfLeadingZerosInByte(i)).to.equal(zeros, `for number ${i}`); + } + }); + + it('converts numbers to bits', () => { + for (let i = 0; i < NUMBER_TEST_CASES.length; ++i) { + const bytes = getBytes(NUMBER_TEST_CASES[i].val); + expect(bytes.asc).to.deep.equal( + fromHex(NUMBER_TEST_CASES[i].ascString), + 'Ascending for ' + NUMBER_TEST_CASES[i].val + ); + expect(bytes.desc).to.deep.equal( + fromHex(NUMBER_TEST_CASES[i].descString), + 'Descending for ' + NUMBER_TEST_CASES[i].val + ); + } + }); + + it('orders numbers correctly', () => { + for (let i = 0; i < NUMBER_TEST_CASES.length; ++i) { + for (let j = i; j < NUMBER_TEST_CASES.length; ++j) { + const left = NUMBER_TEST_CASES[i].val; + const leftBytes = getBytes(left); + const right = NUMBER_TEST_CASES[j].val; + const rightBytes = getBytes(right); + expect(compare(leftBytes.asc, rightBytes.asc)).to.equal( + i === j ? 0 : -1, + `Ascending order: ${left} vs ${right}` + ); + expect(compare(leftBytes.desc, rightBytes.desc)).to.equal( + i === j ? 0 : 1, + `Descending order: ${left} vs ${right}` + ); + } + } + }); +}); + +function fromHex(hexString: string): Uint8Array { + const bytes = new Uint8Array(hexString.length / 2); + for (let c = 0; c < hexString.length; c += 2) { + bytes[c / 2] = parseInt(hexString.substr(c, 2), 16); + } + return bytes; +} + +function compare(left: Uint8Array, right: Uint8Array): number { + for (let i = 0; i < Math.min(left.length, right.length); ++i) { + if (left[i] < right[i]) { + return -1; + } + if (left[i] > right[i]) { + return 1; + } + } + return left.length - right.length; +} + +function getBytes(val: unknown): { asc: Uint8Array; desc: Uint8Array } { + const ascWriter = new OrderedCodeWriter(); + const descWriter = new OrderedCodeWriter(); + if (typeof val === 'number') { + ascWriter.writeNumberAscending(val); + descWriter.writeNumberDescending(val); + } else { + throw new Error('Encoding not yet supported for ' + val); + } + return { asc: ascWriter.encodedBytes(), desc: descWriter.encodedBytes() }; +}