diff --git a/.changeset/seven-actors-kneel.md b/.changeset/seven-actors-kneel.md new file mode 100644 index 00000000000..6bc97d0e3ed --- /dev/null +++ b/.changeset/seven-actors-kneel.md @@ -0,0 +1,5 @@ +--- +"@firebase/firestore": minor +--- + +feat: Adds support for MinKey, MaxKey, RegexValue, Int32Value, BsonObjectId, BsonTimestamp, and BsonBinaryData. diff --git a/common/api-review/firestore-lite.api.md b/common/api-review/firestore-lite.api.md index 4a9ef4c0171..f0203c034b3 100644 --- a/common/api-review/firestore-lite.api.md +++ b/common/api-review/firestore-lite.api.md @@ -65,6 +65,34 @@ export function arrayUnion(...elements: unknown[]): FieldValue; // @public export function average(field: string | FieldPath): AggregateField; +// @public +export class BsonBinaryData { + constructor(subtype: number, data: Uint8Array); + // (undocumented) + readonly data: Uint8Array; + isEqual(other: BsonBinaryData): boolean; + // (undocumented) + readonly subtype: number; +} + +// @public +export class BsonObjectId { + constructor(value: string); + isEqual(other: BsonObjectId): boolean; + // (undocumented) + readonly value: string; +} + +// @public +export class BsonTimestamp { + constructor(seconds: number, increment: number); + // (undocumented) + readonly increment: number; + isEqual(other: BsonTimestamp): boolean; + // (undocumented) + readonly seconds: number; +} + // @public export class Bytes { static fromBase64String(base64: string): Bytes; @@ -249,6 +277,14 @@ export function initializeFirestore(app: FirebaseApp, settings: Settings): Fires // @beta export function initializeFirestore(app: FirebaseApp, settings: Settings, databaseId?: string): Firestore; +// @public +export class Int32Value { + constructor(value: number); + isEqual(other: Int32Value): boolean; + // (undocumented) + readonly value: number; +} + // @public export function limit(limit: number): QueryLimitConstraint; @@ -257,6 +293,20 @@ export function limitToLast(limit: number): QueryLimitConstraint; export { LogLevel } +// @public +export class MaxKey { + // (undocumented) + static instance(): MaxKey; + readonly type = "MaxKey"; +} + +// @public +export class MinKey { + // (undocumented) + static instance(): MinKey; + readonly type = "MinKey"; +} + // @public export type NestedUpdateFields> = UnionToIntersection<{ [K in keyof T & string]: ChildUpdateFields; @@ -360,6 +410,16 @@ export class QueryStartAtConstraint extends QueryConstraint { // @public export function refEqual(left: DocumentReference | CollectionReference, right: DocumentReference | CollectionReference): boolean; +// @public +export class RegexValue { + constructor(pattern: string, options: string); + isEqual(other: RegexValue): boolean; + // (undocumented) + readonly options: string; + // (undocumented) + readonly pattern: string; +} + // @public export function runTransaction(firestore: Firestore, updateFunction: (transaction: Transaction) => Promise, options?: TransactionOptions): Promise; diff --git a/common/api-review/firestore.api.md b/common/api-review/firestore.api.md index 34b56b97f21..90137f78b00 100644 --- a/common/api-review/firestore.api.md +++ b/common/api-review/firestore.api.md @@ -65,6 +65,34 @@ export function arrayUnion(...elements: unknown[]): FieldValue; // @public export function average(field: string | FieldPath): AggregateField; +// @public +export class BsonBinaryData { + constructor(subtype: number, data: Uint8Array); + // (undocumented) + readonly data: Uint8Array; + isEqual(other: BsonBinaryData): boolean; + // (undocumented) + readonly subtype: number; +} + +// @public +export class BsonObjectId { + constructor(value: string); + isEqual(other: BsonObjectId): boolean; + // (undocumented) + readonly value: string; +} + +// @public +export class BsonTimestamp { + constructor(seconds: number, increment: number); + // (undocumented) + readonly increment: number; + isEqual(other: BsonTimestamp): boolean; + // (undocumented) + readonly seconds: number; +} + // @public export class Bytes { static fromBase64String(base64: string): Bytes; @@ -344,6 +372,14 @@ export interface IndexField { // @public export function initializeFirestore(app: FirebaseApp, settings: FirestoreSettings, databaseId?: string): Firestore; +// @public +export class Int32Value { + constructor(value: number); + isEqual(other: Int32Value): boolean; + // (undocumented) + readonly value: number; +} + // @public export function limit(limit: number): QueryLimitConstraint; @@ -374,6 +410,13 @@ export interface LoadBundleTaskProgress { export { LogLevel } +// @public +export class MaxKey { + // (undocumented) + static instance(): MaxKey; + readonly type = "MaxKey"; +} + // @public export interface MemoryCacheSettings { garbageCollector?: MemoryGarbageCollector; @@ -411,6 +454,13 @@ export function memoryLruGarbageCollector(settings?: { cacheSizeBytes?: number; }): MemoryLruGarbageCollector; +// @public +export class MinKey { + // (undocumented) + static instance(): MinKey; + readonly type = "MinKey"; +} + // @public export function namedQuery(firestore: Firestore, name: string): Promise; @@ -620,6 +670,16 @@ export class QueryStartAtConstraint extends QueryConstraint { // @public export function refEqual(left: DocumentReference | CollectionReference, right: DocumentReference | CollectionReference): boolean; +// @public +export class RegexValue { + constructor(pattern: string, options: string); + isEqual(other: RegexValue): boolean; + // (undocumented) + readonly options: string; + // (undocumented) + readonly pattern: string; +} + // @public export function runTransaction(firestore: Firestore, updateFunction: (transaction: Transaction) => Promise, options?: TransactionOptions): Promise; diff --git a/packages/firestore/lite/index.ts b/packages/firestore/lite/index.ts index b751f0a8254..7eee71a9893 100644 --- a/packages/firestore/lite/index.ts +++ b/packages/firestore/lite/index.ts @@ -141,6 +141,20 @@ export { export { VectorValue } from '../src/lite-api/vector_value'; +export { Int32Value } from '../src/lite-api/int32_value'; + +export { RegexValue } from '../src/lite-api/regex_value'; + +export { BsonBinaryData } from '../src/lite-api/bson_binary_data'; + +export { BsonObjectId } from '../src/lite-api/bson_object_Id'; + +export { BsonTimestamp } from '../src/lite-api/bson_timestamp'; + +export { MinKey } from '../src/lite-api/min_key'; + +export { MaxKey } from '../src/lite-api/max_key'; + export { WriteBatch, writeBatch } from '../src/lite-api/write_batch'; export { TransactionOptions } from '../src/lite-api/transaction_options'; diff --git a/packages/firestore/src/api.ts b/packages/firestore/src/api.ts index ea969c6b94c..b9d14923bcd 100644 --- a/packages/firestore/src/api.ts +++ b/packages/firestore/src/api.ts @@ -178,6 +178,20 @@ export { export { VectorValue } from './lite-api/vector_value'; +export { Int32Value } from './lite-api/int32_value'; + +export { RegexValue } from './lite-api/regex_value'; + +export { BsonBinaryData } from './lite-api/bson_binary_data'; + +export { BsonObjectId } from './lite-api/bson_object_Id'; + +export { BsonTimestamp } from './lite-api/bson_timestamp'; + +export { MinKey } from './lite-api/min_key'; + +export { MaxKey } from './lite-api/max_key'; + export { LogLevelString as LogLevel, setLogLevel } from './util/log'; export { Bytes } from './api/bytes'; diff --git a/packages/firestore/src/core/target.ts b/packages/firestore/src/core/target.ts index 4b12857fc2a..cc2732e8f8a 100644 --- a/packages/firestore/src/core/target.ts +++ b/packages/firestore/src/core/target.ts @@ -25,9 +25,11 @@ import { import { FieldPath, ResourcePath } from '../model/path'; import { canonicalId, - MAX_VALUE, - MIN_VALUE, + INTERNAL_MAX_VALUE, + INTERNAL_MIN_VALUE, lowerBoundCompare, + MAX_KEY_VALUE, + MIN_KEY_VALUE, upperBoundCompare, valuesGetLowerBound, valuesGetUpperBound @@ -302,7 +304,7 @@ export function targetGetNotInValues( /** * Returns a lower bound of field values that can be used as a starting point to - * scan the index defined by `fieldIndex`. Returns `MIN_VALUE` if no lower bound + * scan the index defined by `fieldIndex`. Returns `INTERNAL_MIN_VALUE` if no lower bound * exists. */ export function targetGetLowerBound( @@ -328,7 +330,7 @@ export function targetGetLowerBound( /** * Returns an upper bound of field values that can be used as an ending point - * when scanning the index defined by `fieldIndex`. Returns `MAX_VALUE` if no + * when scanning the index defined by `fieldIndex`. Returns `INTERNAL_MAX_VALUE` if no * upper bound exists. */ export function targetGetUpperBound( @@ -362,13 +364,13 @@ function targetGetAscendingBound( fieldPath: FieldPath, bound: Bound | null ): { value: ProtoValue; inclusive: boolean } { - let value: ProtoValue = MIN_VALUE; + let value: ProtoValue = INTERNAL_MIN_VALUE; let inclusive = true; // Process all filters to find a value for the current field segment for (const fieldFilter of targetGetFieldFiltersForPath(target, fieldPath)) { - let filterValue: ProtoValue = MIN_VALUE; + let filterValue: ProtoValue = INTERNAL_MIN_VALUE; let filterInclusive = true; switch (fieldFilter.op) { @@ -387,7 +389,7 @@ function targetGetAscendingBound( break; case Operator.NOT_EQUAL: case Operator.NOT_IN: - filterValue = MIN_VALUE; + filterValue = MIN_KEY_VALUE; break; default: // Remaining filters cannot be used as lower bounds. @@ -437,12 +439,12 @@ function targetGetDescendingBound( fieldPath: FieldPath, bound: Bound | null ): { value: ProtoValue; inclusive: boolean } { - let value: ProtoValue = MAX_VALUE; + let value: ProtoValue = INTERNAL_MAX_VALUE; let inclusive = true; // Process all filters to find a value for the current field segment for (const fieldFilter of targetGetFieldFiltersForPath(target, fieldPath)) { - let filterValue: ProtoValue = MAX_VALUE; + let filterValue: ProtoValue = INTERNAL_MAX_VALUE; let filterInclusive = true; switch (fieldFilter.op) { @@ -462,7 +464,7 @@ function targetGetDescendingBound( break; case Operator.NOT_EQUAL: case Operator.NOT_IN: - filterValue = MAX_VALUE; + filterValue = MAX_KEY_VALUE; break; default: // Remaining filters cannot be used as upper bounds. diff --git a/packages/firestore/src/index/firestore_index_value_writer.ts b/packages/firestore/src/index/firestore_index_value_writer.ts index b76ca7a930a..5a5f04c9988 100644 --- a/packages/firestore/src/index/firestore_index_value_writer.ts +++ b/packages/firestore/src/index/firestore_index_value_writer.ts @@ -22,9 +22,16 @@ import { normalizeTimestamp } from '../model/normalize'; import { - isVectorValue, VECTOR_MAP_VECTORS_KEY, - isMaxValue + detectMapRepresentation, + RESERVED_BSON_TIMESTAMP_KEY, + RESERVED_REGEX_KEY, + RESERVED_BSON_OBJECT_ID_KEY, + RESERVED_BSON_BINARY_KEY, + MapRepresentation, + RESERVED_REGEX_PATTERN_KEY, + RESERVED_REGEX_OPTIONS_KEY, + RESERVED_INT32_KEY } from '../model/values'; import { ArrayValue, MapValue, Value } from '../protos/firestore_proto_api'; import { fail } from '../util/assert'; @@ -32,22 +39,32 @@ import { isNegativeZero } from '../util/types'; import { DirectionalIndexByteEncoder } from './directional_index_byte_encoder'; -// Note: This code is copied from the backend. Code that is not used by -// Firestore was removed. +// Note: This file is copied from the backend. Code that is not used by +// Firestore was removed. Code that has different behavior was modified. + +// The client SDK only supports references to documents from the same database. We can skip the +// first five segments. +const DOCUMENT_NAME_OFFSET = 5; const INDEX_TYPE_NULL = 5; +const INDEX_TYPE_MIN_KEY = 7; const INDEX_TYPE_BOOLEAN = 10; const INDEX_TYPE_NAN = 13; const INDEX_TYPE_NUMBER = 15; const INDEX_TYPE_TIMESTAMP = 20; +const INDEX_TYPE_BSON_TIMESTAMP = 22; const INDEX_TYPE_STRING = 25; const INDEX_TYPE_BLOB = 30; +const INDEX_TYPE_BSON_BINARY = 31; const INDEX_TYPE_REFERENCE = 37; +const INDEX_TYPE_BSON_OBJECT_ID = 43; const INDEX_TYPE_GEOPOINT = 45; +const INDEX_TYPE_REGEX = 47; const INDEX_TYPE_ARRAY = 50; const INDEX_TYPE_VECTOR = 53; const INDEX_TYPE_MAP = 55; const INDEX_TYPE_REFERENCE_SEGMENT = 60; +const INDEX_TYPE_MAX_KEY = 999; // A terminator that indicates that a truncatable value was not truncated. // This must be smaller than all other type labels. @@ -124,10 +141,30 @@ export class FirestoreIndexValueWriter { encoder.writeNumber(geoPoint.latitude || 0); encoder.writeNumber(geoPoint.longitude || 0); } else if ('mapValue' in indexValue) { - if (isMaxValue(indexValue)) { + const type = detectMapRepresentation(indexValue); + if (type === MapRepresentation.INTERNAL_MAX) { this.writeValueTypeLabel(encoder, Number.MAX_SAFE_INTEGER); - } else if (isVectorValue(indexValue)) { + } else if (type === MapRepresentation.VECTOR) { this.writeIndexVector(indexValue.mapValue!, encoder); + } else if (type === MapRepresentation.MAX_KEY) { + this.writeValueTypeLabel(encoder, INDEX_TYPE_MAX_KEY); + } else if (type === MapRepresentation.MIN_KEY) { + this.writeValueTypeLabel(encoder, INDEX_TYPE_MIN_KEY); + } else if (type === MapRepresentation.BSON_BINARY) { + this.writeIndexBsonBinaryData(indexValue.mapValue!, encoder); + } else if (type === MapRepresentation.REGEX) { + this.writeIndexRegex(indexValue.mapValue!, encoder); + } else if (type === MapRepresentation.BSON_TIMESTAMP) { + this.writeIndexBsonTimestamp(indexValue.mapValue!, encoder); + } else if (type === MapRepresentation.BSON_OBJECT_ID) { + this.writeIndexBsonObjectId(indexValue.mapValue!, encoder); + } else if (type === MapRepresentation.INT32) { + this.writeValueTypeLabel(encoder, INDEX_TYPE_NUMBER); + encoder.writeNumber( + normalizeNumber( + indexValue.mapValue!.fields![RESERVED_INT32_KEY]!.integerValue! + ) + ); } else { this.writeIndexMap(indexValue.mapValue!, encoder); this.writeTruncationMarker(encoder); @@ -201,7 +238,12 @@ export class FirestoreIndexValueWriter { encoder: DirectionalIndexByteEncoder ): void { this.writeValueTypeLabel(encoder, INDEX_TYPE_REFERENCE); - const path = DocumentKey.fromName(referenceValue).path; + const segments: string[] = referenceValue + .split('/') + .filter(segment => segment.length > 0); + const path = DocumentKey.fromSegments( + segments.slice(DOCUMENT_NAME_OFFSET) + ).path; path.forEach(segment => { this.writeValueTypeLabel(encoder, INDEX_TYPE_REFERENCE_SEGMENT); this.writeUnlabeledIndexString(segment, encoder); @@ -221,4 +263,55 @@ export class FirestoreIndexValueWriter { // references, arrays and maps). encoder.writeNumber(NOT_TRUNCATED); } + + private writeIndexBsonTimestamp( + mapValue: MapValue, + encoder: DirectionalIndexByteEncoder + ): void { + this.writeValueTypeLabel(encoder, INDEX_TYPE_BSON_TIMESTAMP); + const fields = mapValue.fields || {}; + if (fields) { + // The JS SDK encodes BSON timestamps differently than the backend. + // This is due to the limitation of `number` in JS which handles up to 53-bit precision. + this.writeIndexMap( + fields[RESERVED_BSON_TIMESTAMP_KEY].mapValue!, + encoder + ); + } + } + + private writeIndexBsonObjectId( + mapValue: MapValue, + encoder: DirectionalIndexByteEncoder + ): void { + this.writeValueTypeLabel(encoder, INDEX_TYPE_BSON_OBJECT_ID); + const fields = mapValue.fields || {}; + const oid = fields[RESERVED_BSON_OBJECT_ID_KEY]?.stringValue || ''; + encoder.writeBytes(normalizeByteString(oid)); + } + + private writeIndexBsonBinaryData( + mapValue: MapValue, + encoder: DirectionalIndexByteEncoder + ): void { + this.writeValueTypeLabel(encoder, INDEX_TYPE_BSON_BINARY); + const fields = mapValue.fields || {}; + const binary = fields[RESERVED_BSON_BINARY_KEY]?.bytesValue || ''; + encoder.writeBytes(normalizeByteString(binary)); + this.writeTruncationMarker(encoder); + } + + private writeIndexRegex( + mapValue: MapValue, + encoder: DirectionalIndexByteEncoder + ): void { + this.writeValueTypeLabel(encoder, INDEX_TYPE_REGEX); + const fields = mapValue.fields || {}; + const regex = fields[RESERVED_REGEX_KEY]?.mapValue?.fields || {}; + if (regex) { + encoder.writeString(regex[RESERVED_REGEX_PATTERN_KEY]?.stringValue || ''); + encoder.writeString(regex[RESERVED_REGEX_OPTIONS_KEY]?.stringValue || ''); + } + this.writeTruncationMarker(encoder); + } } diff --git a/packages/firestore/src/lite-api/bson_binary_data.ts b/packages/firestore/src/lite-api/bson_binary_data.ts new file mode 100644 index 00000000000..8b4b1fe0ef0 --- /dev/null +++ b/packages/firestore/src/lite-api/bson_binary_data.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ByteString } from '../util/byte_string'; +import { Code, FirestoreError } from '../util/error'; + +/** + * Represents a BSON Binary Data type in Firestore documents. + * + * @class BsonBinaryData + */ +export class BsonBinaryData { + readonly data: Uint8Array; + + constructor(readonly subtype: number, data: Uint8Array) { + if (subtype < 0 || subtype > 255) { + throw new FirestoreError( + Code.INVALID_ARGUMENT, + 'The subtype for BsonBinaryData must be a value in the inclusive [0, 255] range.' + ); + } + this.subtype = subtype; + // Make a copy of the data. + this.data = Uint8Array.from(data); + } + + /** + * Returns true if this `BsonBinaryData` is equal to the provided one. + * + * @param other - The `BsonBinaryData` to compare against. + * @return 'true' if this `BsonBinaryData` is equal to the provided one. + */ + isEqual(other: BsonBinaryData): boolean { + return ( + this.subtype === other.subtype && + ByteString.fromUint8Array(this.data).isEqual( + ByteString.fromUint8Array(other.data) + ) + ); + } +} diff --git a/packages/firestore/src/lite-api/bson_object_Id.ts b/packages/firestore/src/lite-api/bson_object_Id.ts new file mode 100644 index 00000000000..71ee13d8860 --- /dev/null +++ b/packages/firestore/src/lite-api/bson_object_Id.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Represents a BSON ObjectId type in Firestore documents. + * + * @class BsonObjectId + */ +export class BsonObjectId { + constructor(readonly value: string) {} + + /** + * Returns true if this `BsonObjectId` is equal to the provided one. + * + * @param other - The `BsonObjectId` to compare against. + * @return 'true' if this `BsonObjectId` is equal to the provided one. + */ + isEqual(other: BsonObjectId): boolean { + return this.value === other.value; + } +} diff --git a/packages/firestore/src/lite-api/bson_timestamp.ts b/packages/firestore/src/lite-api/bson_timestamp.ts new file mode 100644 index 00000000000..dc18db02bb1 --- /dev/null +++ b/packages/firestore/src/lite-api/bson_timestamp.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Represents a BSON Timestamp type in Firestore documents. + * + * @class BsonTimestamp + */ +export class BsonTimestamp { + constructor(readonly seconds: number, readonly increment: number) { + // Make sure 'seconds' and 'increment' are in the range of a 32-bit unsigned integer. + if (seconds < 0 || seconds > 4294967295) { + throw new Error( + "BsonTimestamp 'seconds' must be in the range of a 32-bit unsigned integer." + ); + } + if (increment < 0 || increment > 4294967295) { + throw new Error( + "BsonTimestamp 'increment' must be in the range of a 32-bit unsigned integer." + ); + } + } + + /** + * Returns true if this `BsonTimestamp` is equal to the provided one. + * + * @param other - The `BsonTimestamp` to compare against. + * @return 'true' if this `BsonTimestamp` is equal to the provided one. + */ + isEqual(other: BsonTimestamp): boolean { + return this.seconds === other.seconds && this.increment === other.increment; + } +} diff --git a/packages/firestore/src/lite-api/field_value_impl.ts b/packages/firestore/src/lite-api/field_value_impl.ts index 2c910bdace5..11db1005235 100644 --- a/packages/firestore/src/lite-api/field_value_impl.ts +++ b/packages/firestore/src/lite-api/field_value_impl.ts @@ -14,7 +14,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import { FieldValue } from './field_value'; import { ArrayRemoveFieldValueImpl, diff --git a/packages/firestore/src/lite-api/int32_value.ts b/packages/firestore/src/lite-api/int32_value.ts new file mode 100644 index 00000000000..cfa0003c0f6 --- /dev/null +++ b/packages/firestore/src/lite-api/int32_value.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Represents a 32-bit integer type in Firestore documents. + * + * @class Int32Value + */ +export class Int32Value { + constructor(readonly value: number) {} + + /** + * Returns true if this `Int32Value` is equal to the provided one. + * + * @param other - The `Int32Value` to compare against. + * @return 'true' if this `Int32Value` is equal to the provided one. + */ + isEqual(other: Int32Value): boolean { + return this.value === other.value; + } +} diff --git a/packages/firestore/src/lite-api/max_key.ts b/packages/firestore/src/lite-api/max_key.ts new file mode 100644 index 00000000000..3f37986315e --- /dev/null +++ b/packages/firestore/src/lite-api/max_key.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Represent a "Max Key" type in Firestore documents. + * + * @class MaxKey + */ +export class MaxKey { + private static MAX_KEY_VALUE_INSTANCE: MaxKey | null = null; + /** A type string to uniquely identify instances of this class. */ + readonly type = 'MaxKey'; + + private constructor() {} + + static instance(): MaxKey { + if (!MaxKey.MAX_KEY_VALUE_INSTANCE) { + MaxKey.MAX_KEY_VALUE_INSTANCE = new MaxKey(); + } + return MaxKey.MAX_KEY_VALUE_INSTANCE; + } +} diff --git a/packages/firestore/src/lite-api/min_key.ts b/packages/firestore/src/lite-api/min_key.ts new file mode 100644 index 00000000000..a901b9611a5 --- /dev/null +++ b/packages/firestore/src/lite-api/min_key.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Represent a "Min Key" type in Firestore documents. + * + * @class MinKey + */ +export class MinKey { + private static MIN_KEY_VALUE_INSTANCE: MinKey | null = null; + /** A type string to uniquely identify instances of this class. */ + readonly type = 'MinKey'; + + private constructor() {} + + static instance(): MinKey { + if (!MinKey.MIN_KEY_VALUE_INSTANCE) { + MinKey.MIN_KEY_VALUE_INSTANCE = new MinKey(); + } + return MinKey.MIN_KEY_VALUE_INSTANCE; + } +} diff --git a/packages/firestore/src/lite-api/query.ts b/packages/firestore/src/lite-api/query.ts index f0a357b828c..67245f96d07 100644 --- a/packages/firestore/src/lite-api/query.ts +++ b/packages/firestore/src/lite-api/query.ts @@ -811,6 +811,8 @@ export function newQueryFilter( value: unknown ): FieldFilter { let fieldValue: ProtoValue; + validateQueryOperator(value, op); + if (fieldPath.isKeyField()) { if (op === Operator.ARRAY_CONTAINS || op === Operator.ARRAY_CONTAINS_ANY) { throw new FirestoreError( @@ -1064,6 +1066,31 @@ function validateDisjunctiveFilterElements( } } +/** + * Validates the input string as a field comparison operator. + */ +export function validateQueryOperator( + value: unknown, + operator: Operator +): void { + if ( + typeof value === 'number' && + isNaN(value) && + operator !== '==' && + operator !== '!=' + ) { + throw new Error( + "Invalid query. You can only perform '==' and '!=' comparisons on NaN." + ); + } + + if (value === null && operator !== '==' && operator !== '!=') { + throw new Error( + "Invalid query. You can only perform '==' and '!=' comparisons on Null." + ); + } +} + /** * Given an operator, returns the set of operators that cannot be used with it. * diff --git a/packages/firestore/src/lite-api/regex_value.ts b/packages/firestore/src/lite-api/regex_value.ts new file mode 100644 index 00000000000..b4d4f70962b --- /dev/null +++ b/packages/firestore/src/lite-api/regex_value.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Represents a regular expression type in Firestore documents. + * + * @class RegexValue + */ +export class RegexValue { + constructor(readonly pattern: string, readonly options: string) {} + + /** + * Returns true if this `RegexValue` is equal to the provided one. + * + * @param other - The `RegexValue` to compare against. + * @return 'true' if this `RegexValue` is equal to the provided one. + */ + isEqual(other: RegexValue): boolean { + return this.pattern === other.pattern && this.options === other.options; + } +} diff --git a/packages/firestore/src/lite-api/user_data_reader.ts b/packages/firestore/src/lite-api/user_data_reader.ts index aa5f9eeb5bf..63b0b99db90 100644 --- a/packages/firestore/src/lite-api/user_data_reader.ts +++ b/packages/firestore/src/lite-api/user_data_reader.ts @@ -44,14 +44,25 @@ import { import { TYPE_KEY, VECTOR_MAP_VECTORS_KEY, - VECTOR_VALUE_SENTINEL + RESERVED_VECTOR_KEY, + RESERVED_REGEX_KEY, + RESERVED_REGEX_PATTERN_KEY, + RESERVED_REGEX_OPTIONS_KEY, + RESERVED_BSON_OBJECT_ID_KEY, + RESERVED_INT32_KEY, + RESERVED_BSON_TIMESTAMP_KEY, + RESERVED_BSON_TIMESTAMP_SECONDS_KEY, + RESERVED_BSON_TIMESTAMP_INCREMENT_KEY, + RESERVED_BSON_BINARY_KEY, + RESERVED_MIN_KEY, + RESERVED_MAX_KEY } from '../model/values'; import { newSerializer } from '../platform/serializer'; import { MapValue as ProtoMapValue, Value as ProtoValue } from '../protos/firestore_proto_api'; -import { toDouble, toNumber } from '../remote/number_serializer'; +import { toDouble, toInteger, toNumber } from '../remote/number_serializer'; import { JsonProtoSerializer, toBytes, @@ -59,20 +70,28 @@ import { toTimestamp } from '../remote/serializer'; import { debugAssert, fail } from '../util/assert'; +import { ByteString } from '../util/byte_string'; import { Code, FirestoreError } from '../util/error'; import { isPlainObject, valueDescription } from '../util/input_validation'; import { Dict, forEach, isEmpty } from '../util/obj'; +import { BsonBinaryData } from './bson_binary_data'; +import { BsonObjectId } from './bson_object_Id'; +import { BsonTimestamp } from './bson_timestamp'; import { Bytes } from './bytes'; import { Firestore } from './database'; import { FieldPath } from './field_path'; import { FieldValue } from './field_value'; import { GeoPoint } from './geo_point'; +import { Int32Value } from './int32_value'; +import { MaxKey } from './max_key'; +import { MinKey } from './min_key'; import { DocumentReference, PartialWithFieldValue, WithFieldValue } from './reference'; +import { RegexValue } from './regex_value'; import { Timestamp } from './timestamp'; import { VectorValue } from './vector_value'; @@ -911,6 +930,20 @@ function parseScalarValue( }; } else if (value instanceof VectorValue) { return parseVectorValue(value, context); + } else if (value instanceof RegexValue) { + return parseRegexValue(value); + } else if (value instanceof BsonObjectId) { + return parseBsonObjectId(value); + } else if (value instanceof Int32Value) { + return parseInt32Value(value); + } else if (value instanceof BsonTimestamp) { + return parseBsonTimestamp(value); + } else if (value instanceof BsonBinaryData) { + return parseBsonBinaryData(context.serializer, value); + } else if (value instanceof MinKey) { + return parseMinKey(); + } else if (value instanceof MaxKey) { + return parseMaxKey(); } else { throw context.createError( `Unsupported field value: ${valueDescription(value)}` @@ -928,7 +961,7 @@ export function parseVectorValue( const mapValue: ProtoMapValue = { fields: { [TYPE_KEY]: { - stringValue: VECTOR_VALUE_SENTINEL + stringValue: RESERVED_VECTOR_KEY }, [VECTOR_MAP_VECTORS_KEY]: { arrayValue: { @@ -949,6 +982,107 @@ export function parseVectorValue( return { mapValue }; } +export function parseRegexValue(value: RegexValue): ProtoValue { + const mapValue: ProtoMapValue = { + fields: { + [RESERVED_REGEX_KEY]: { + mapValue: { + fields: { + [RESERVED_REGEX_PATTERN_KEY]: { + stringValue: value.pattern + }, + [RESERVED_REGEX_OPTIONS_KEY]: { + stringValue: value.options + } + } + } + } + } + }; + + return { mapValue }; +} + +export function parseMinKey(): ProtoValue { + const mapValue: ProtoMapValue = { + fields: { + [RESERVED_MIN_KEY]: { + nullValue: 'NULL_VALUE' + } + } + }; + return { mapValue }; +} + +export function parseMaxKey(): ProtoValue { + const mapValue: ProtoMapValue = { + fields: { + [RESERVED_MAX_KEY]: { + nullValue: 'NULL_VALUE' + } + } + }; + return { mapValue }; +} + +export function parseBsonObjectId(value: BsonObjectId): ProtoValue { + const mapValue: ProtoMapValue = { + fields: { + [RESERVED_BSON_OBJECT_ID_KEY]: { + stringValue: value.value + } + } + }; + return { mapValue }; +} + +export function parseInt32Value(value: Int32Value): ProtoValue { + const mapValue: ProtoMapValue = { + fields: { + [RESERVED_INT32_KEY]: toInteger(value.value) + } + }; + return { mapValue }; +} + +export function parseBsonTimestamp(value: BsonTimestamp): ProtoValue { + const mapValue: ProtoMapValue = { + fields: { + [RESERVED_BSON_TIMESTAMP_KEY]: { + mapValue: { + fields: { + [RESERVED_BSON_TIMESTAMP_SECONDS_KEY]: toInteger(value.seconds), + [RESERVED_BSON_TIMESTAMP_INCREMENT_KEY]: toInteger(value.increment) + } + } + } + } + }; + return { mapValue }; +} + +export function parseBsonBinaryData( + serializer: JsonProtoSerializer, + value: BsonBinaryData +): ProtoValue { + const subtypeAndData = new Uint8Array(value.data.length + 1); + // This converts the subtype from `number` to a byte. + subtypeAndData[0] = value.subtype; + // Concatenate the rest of the data starting at index 1. + subtypeAndData.set(value.data, /* offset */ 1); + + const mapValue: ProtoMapValue = { + fields: { + [RESERVED_BSON_BINARY_KEY]: { + bytesValue: toBytes( + serializer, + ByteString.fromUint8Array(subtypeAndData) + ) + } + } + }; + return { mapValue }; +} /** * Checks whether an object looks like a JSON object that should be converted * into a struct. Normal class/prototype instances are considered to look like @@ -967,7 +1101,14 @@ function looksLikeJsonObject(input: unknown): boolean { !(input instanceof Bytes) && !(input instanceof DocumentReference) && !(input instanceof FieldValue) && - !(input instanceof VectorValue) + !(input instanceof VectorValue) && + !(input instanceof MinKey) && + !(input instanceof MaxKey) && + !(input instanceof Int32Value) && + !(input instanceof RegexValue) && + !(input instanceof BsonObjectId) && + !(input instanceof BsonTimestamp) && + !(input instanceof BsonBinaryData) ); } diff --git a/packages/firestore/src/lite-api/user_data_writer.ts b/packages/firestore/src/lite-api/user_data_writer.ts index 070c71c7832..ba9ade1c207 100644 --- a/packages/firestore/src/lite-api/user_data_writer.ts +++ b/packages/firestore/src/lite-api/user_data_writer.ts @@ -30,7 +30,19 @@ import { getPreviousValue } from '../model/server_timestamps'; import { TypeOrder } from '../model/type_order'; -import { VECTOR_MAP_VECTORS_KEY, typeOrder } from '../model/values'; +import { + RESERVED_BSON_BINARY_KEY, + RESERVED_INT32_KEY, + RESERVED_BSON_OBJECT_ID_KEY, + RESERVED_REGEX_KEY, + RESERVED_REGEX_OPTIONS_KEY, + RESERVED_REGEX_PATTERN_KEY, + RESERVED_BSON_TIMESTAMP_INCREMENT_KEY, + RESERVED_BSON_TIMESTAMP_KEY, + RESERVED_BSON_TIMESTAMP_SECONDS_KEY, + typeOrder, + VECTOR_MAP_VECTORS_KEY +} from '../model/values'; import { ApiClientObjectMap, ArrayValue as ProtoArrayValue, @@ -46,7 +58,14 @@ import { ByteString } from '../util/byte_string'; import { logError } from '../util/log'; import { forEach } from '../util/obj'; +import { BsonBinaryData } from './bson_binary_data'; +import { BsonObjectId } from './bson_object_Id'; +import { BsonTimestamp } from './bson_timestamp'; import { GeoPoint } from './geo_point'; +import { Int32Value } from './int32_value'; +import { MaxKey } from './max_key'; +import { MinKey } from './min_key'; +import { RegexValue } from './regex_value'; import { Timestamp } from './timestamp'; import { VectorValue } from './vector_value'; @@ -69,6 +88,9 @@ export abstract class AbstractUserDataWriter { case TypeOrder.BooleanValue: return value.booleanValue!; case TypeOrder.NumberValue: + if ('mapValue' in value) { + return this.convertToInt32Value(value.mapValue!); + } return normalizeNumber(value.integerValue || value.doubleValue); case TypeOrder.TimestampValue: return this.convertTimestamp(value.timestampValue!); @@ -88,6 +110,18 @@ export abstract class AbstractUserDataWriter { return this.convertObject(value.mapValue!, serverTimestampBehavior); case TypeOrder.VectorValue: return this.convertVectorValue(value.mapValue!); + case TypeOrder.RegexValue: + return this.convertToRegexValue(value.mapValue!); + case TypeOrder.BsonObjectIdValue: + return this.convertToBsonObjectId(value.mapValue!); + case TypeOrder.BsonBinaryValue: + return this.convertToBsonBinaryData(value.mapValue!); + case TypeOrder.BsonTimestampValue: + return this.convertToBsonTimestamp(value.mapValue!); + case TypeOrder.MaxKeyValue: + return MaxKey.instance(); + case TypeOrder.MinKeyValue: + return MinKey.instance(); default: throw fail(0xf2a2, 'Invalid value type', { value @@ -129,6 +163,58 @@ export abstract class AbstractUserDataWriter { return new VectorValue(values); } + private convertToBsonObjectId(mapValue: ProtoMapValue): BsonObjectId { + const oid = + mapValue!.fields?.[RESERVED_BSON_OBJECT_ID_KEY]?.stringValue ?? ''; + return new BsonObjectId(oid); + } + + private convertToBsonBinaryData(mapValue: ProtoMapValue): BsonBinaryData { + const fields = mapValue!.fields?.[RESERVED_BSON_BINARY_KEY]; + const subtypeAndData = fields?.bytesValue; + if (!subtypeAndData) { + throw new Error('Received incorrect bytesValue for BsonBinaryData'); + } + + const bytes = normalizeByteString(subtypeAndData).toUint8Array(); + if (bytes.length === 0) { + throw new Error('Received empty bytesValue for BsonBinaryData'); + } + const subtype = bytes.at(0); + const data = bytes.slice(1); + return new BsonBinaryData(Number(subtype), data); + } + + private convertToBsonTimestamp(mapValue: ProtoMapValue): BsonTimestamp { + const fields = mapValue!.fields?.[RESERVED_BSON_TIMESTAMP_KEY]; + const seconds = Number( + fields?.mapValue?.fields?.[RESERVED_BSON_TIMESTAMP_SECONDS_KEY] + ?.integerValue + ); + const increment = Number( + fields?.mapValue?.fields?.[RESERVED_BSON_TIMESTAMP_INCREMENT_KEY] + ?.integerValue + ); + return new BsonTimestamp(seconds, increment); + } + + private convertToRegexValue(mapValue: ProtoMapValue): RegexValue { + const pattern = + mapValue!.fields?.[RESERVED_REGEX_KEY]?.mapValue?.fields?.[ + RESERVED_REGEX_PATTERN_KEY + ]?.stringValue ?? ''; + const options = + mapValue!.fields?.[RESERVED_REGEX_KEY]?.mapValue?.fields?.[ + RESERVED_REGEX_OPTIONS_KEY + ]?.stringValue ?? ''; + return new RegexValue(pattern, options); + } + + private convertToInt32Value(mapValue: ProtoMapValue): Int32Value { + const value = Number(mapValue!.fields?.[RESERVED_INT32_KEY]?.integerValue); + return new Int32Value(value); + } + private convertGeoPoint(value: ProtoLatLng): GeoPoint { return new GeoPoint( normalizeNumber(value.latitude), diff --git a/packages/firestore/src/model/object_value.ts b/packages/firestore/src/model/object_value.ts index d5cb273eb9d..35d8733e40a 100644 --- a/packages/firestore/src/model/object_value.ts +++ b/packages/firestore/src/model/object_value.ts @@ -25,7 +25,7 @@ import { forEach } from '../util/obj'; import { FieldMask } from './field_mask'; import { FieldPath } from './path'; import { isServerTimestamp } from './server_timestamps'; -import { deepClone, isMapValue, valueEquals } from './values'; +import { deepClone, isBsonType, isMapValue, valueEquals } from './values'; export interface JsonObject { [name: string]: T; @@ -188,7 +188,8 @@ export function extractFieldMask(value: ProtoMapValue): FieldMask { const fields: FieldPath[] = []; forEach(value!.fields, (key, value) => { const currentPath = new FieldPath([key]); - if (isMapValue(value)) { + // BSON types do not need to extract reserved keys, ie,__regex__. + if (isMapValue(value) && !isBsonType(value)) { const nestedMask = extractFieldMask(value.mapValue!); const nestedFields = nestedMask.fields; if (nestedFields.length === 0) { diff --git a/packages/firestore/src/model/type_order.ts b/packages/firestore/src/model/type_order.ts index 749b8e8036d..a13e16f4211 100644 --- a/packages/firestore/src/model/type_order.ts +++ b/packages/firestore/src/model/type_order.ts @@ -24,18 +24,28 @@ */ export const enum TypeOrder { // This order is based on the backend's ordering, but modified to support - // server timestamps and `MAX_VALUE`. + // server timestamps and `MAX_VALUE` inside the SDK. + // NULL and MIN_KEY sort the same. NullValue = 0, - BooleanValue = 1, - NumberValue = 2, - TimestampValue = 3, - ServerTimestampValue = 4, - StringValue = 5, - BlobValue = 6, - RefValue = 7, - GeoPointValue = 8, - ArrayValue = 9, - VectorValue = 10, - ObjectValue = 11, + MinKeyValue = 1, + BooleanValue = 2, + // Note: all numbers (32-bit int, 64-bit int, 64-bit double, 128-bit decimal, + // etc.) are sorted together numerically. The `numberEquals` function + // distinguishes between different number types and compares them accordingly. + NumberValue = 3, + TimestampValue = 4, + BsonTimestampValue = 5, + ServerTimestampValue = 6, + StringValue = 7, + BlobValue = 8, + BsonBinaryValue = 9, + RefValue = 10, + BsonObjectIdValue = 11, + GeoPointValue = 12, + RegexValue = 13, + ArrayValue = 14, + VectorValue = 15, + ObjectValue = 16, + MaxKeyValue = 17, MaxValue = 9007199254740991 // Number.MAX_SAFE_INTEGER } diff --git a/packages/firestore/src/model/values.ts b/packages/firestore/src/model/values.ts index 1ef54a98ad6..fca9c34b9ea 100644 --- a/packages/firestore/src/model/values.ts +++ b/packages/firestore/src/model/values.ts @@ -21,7 +21,6 @@ import { LatLng, MapValue, Timestamp, - Value as ProtoValue, Value } from '../protos/firestore_proto_api'; import { fail } from '../util/assert'; @@ -39,30 +38,146 @@ import { normalizeNumber, normalizeTimestamp } from './normalize'; -import { - getLocalWriteTime, - getPreviousValue, - isServerTimestamp -} from './server_timestamps'; +import { getLocalWriteTime, getPreviousValue } from './server_timestamps'; import { TypeOrder } from './type_order'; export const TYPE_KEY = '__type__'; -const MAX_VALUE_TYPE = '__max__'; -export const MAX_VALUE: Value = { + +export const RESERVED_VECTOR_KEY = '__vector__'; +export const VECTOR_MAP_VECTORS_KEY = 'value'; + +const RESERVED_SERVER_TIMESTAMP_KEY = 'server_timestamp'; + +export const RESERVED_MIN_KEY = '__min__'; +export const RESERVED_MAX_KEY = '__max__'; + +export const RESERVED_REGEX_KEY = '__regex__'; +export const RESERVED_REGEX_PATTERN_KEY = 'pattern'; +export const RESERVED_REGEX_OPTIONS_KEY = 'options'; + +export const RESERVED_BSON_OBJECT_ID_KEY = '__oid__'; + +export const RESERVED_INT32_KEY = '__int__'; + +export const RESERVED_BSON_TIMESTAMP_KEY = '__request_timestamp__'; +export const RESERVED_BSON_TIMESTAMP_SECONDS_KEY = 'seconds'; +export const RESERVED_BSON_TIMESTAMP_INCREMENT_KEY = 'increment'; + +export const RESERVED_BSON_BINARY_KEY = '__binary__'; + +export const INTERNAL_MIN_VALUE: Value = { + nullValue: 'NULL_VALUE' +}; + +export const INTERNAL_MAX_VALUE: Value = { mapValue: { fields: { - '__type__': { stringValue: MAX_VALUE_TYPE } + '__type__': { stringValue: RESERVED_MAX_KEY } } } }; -export const VECTOR_VALUE_SENTINEL = '__vector__'; -export const VECTOR_MAP_VECTORS_KEY = 'value'; +export const MIN_VECTOR_VALUE: Value = { + mapValue: { + fields: { + [TYPE_KEY]: { stringValue: RESERVED_VECTOR_KEY }, + [VECTOR_MAP_VECTORS_KEY]: { + arrayValue: {} + } + } + } +}; -export const MIN_VALUE: Value = { - nullValue: 'NULL_VALUE' +export const MIN_KEY_VALUE: Value = { + mapValue: { + fields: { + [RESERVED_MIN_KEY]: { + nullValue: 'NULL_VALUE' + } + } + } }; +export const MAX_KEY_VALUE: Value = { + mapValue: { + fields: { + [RESERVED_MAX_KEY]: { + nullValue: 'NULL_VALUE' + } + } + } +}; + +export const MIN_BSON_OBJECT_ID_VALUE: Value = { + mapValue: { + fields: { + [RESERVED_BSON_OBJECT_ID_KEY]: { + stringValue: '' + } + } + } +}; + +export const MIN_BSON_TIMESTAMP_VALUE: Value = { + mapValue: { + fields: { + [RESERVED_BSON_TIMESTAMP_KEY]: { + mapValue: { + fields: { + // Both seconds and increment are 32 bit unsigned integers + [RESERVED_BSON_TIMESTAMP_SECONDS_KEY]: { + integerValue: 0 + }, + [RESERVED_BSON_TIMESTAMP_INCREMENT_KEY]: { + integerValue: 0 + } + } + } + } + } + } +}; + +export const MIN_REGEX_VALUE: Value = { + mapValue: { + fields: { + [RESERVED_REGEX_KEY]: { + mapValue: { + fields: { + [RESERVED_REGEX_PATTERN_KEY]: { stringValue: '' }, + [RESERVED_REGEX_OPTIONS_KEY]: { stringValue: '' } + } + } + } + } + } +}; + +export const MIN_BSON_BINARY_VALUE: Value = { + mapValue: { + fields: { + [RESERVED_BSON_BINARY_KEY]: { + // bsonBinaryValue should have at least one byte as subtype + bytesValue: Uint8Array.from([0]) + } + } + } +}; + +export enum MapRepresentation { + REGEX = 'regexValue', + BSON_OBJECT_ID = 'bsonObjectIdValue', + INT32 = 'int32Value', + BSON_TIMESTAMP = 'bsonTimestampValue', + BSON_BINARY = 'bsonBinaryValue', + MIN_KEY = 'minKeyValue', + MAX_KEY = 'maxKeyValue', + INTERNAL_MAX = 'maxValue', + VECTOR = 'vectorValue', + SERVER_TIMESTAMP = 'serverTimestampValue', + REGULAR_MAP = 'regularMapValue' +} + /** Extracts the backend's type order for the provided value. */ export function typeOrder(value: Value): TypeOrder { if ('nullValue' in value) { @@ -84,14 +199,31 @@ export function typeOrder(value: Value): TypeOrder { } else if ('arrayValue' in value) { return TypeOrder.ArrayValue; } else if ('mapValue' in value) { - if (isServerTimestamp(value)) { - return TypeOrder.ServerTimestampValue; - } else if (isMaxValue(value)) { - return TypeOrder.MaxValue; - } else if (isVectorValue(value)) { - return TypeOrder.VectorValue; + const valueType = detectMapRepresentation(value); + switch (valueType) { + case MapRepresentation.SERVER_TIMESTAMP: + return TypeOrder.ServerTimestampValue; + case MapRepresentation.INTERNAL_MAX: + return TypeOrder.MaxValue; + case MapRepresentation.VECTOR: + return TypeOrder.VectorValue; + case MapRepresentation.REGEX: + return TypeOrder.RegexValue; + case MapRepresentation.BSON_OBJECT_ID: + return TypeOrder.BsonObjectIdValue; + case MapRepresentation.INT32: + return TypeOrder.NumberValue; + case MapRepresentation.BSON_TIMESTAMP: + return TypeOrder.BsonTimestampValue; + case MapRepresentation.BSON_BINARY: + return TypeOrder.BsonBinaryValue; + case MapRepresentation.MIN_KEY: + return TypeOrder.MinKeyValue; + case MapRepresentation.MAX_KEY: + return TypeOrder.MaxKeyValue; + default: + return TypeOrder.ObjectValue; } - return TypeOrder.ObjectValue; } else { return fail(0x6e87, 'Invalid value type', { value }); } @@ -111,6 +243,11 @@ export function valueEquals(left: Value, right: Value): boolean { switch (leftType) { case TypeOrder.NullValue: + case TypeOrder.MaxValue: + // MaxKeys are all equal. + case TypeOrder.MaxKeyValue: + // MinKeys are all equal. + case TypeOrder.MinKeyValue: return true; case TypeOrder.BooleanValue: return left.booleanValue === right.booleanValue; @@ -137,8 +274,14 @@ export function valueEquals(left: Value, right: Value): boolean { case TypeOrder.VectorValue: case TypeOrder.ObjectValue: return objectEquals(left, right); - case TypeOrder.MaxValue: - return true; + case TypeOrder.BsonBinaryValue: + return compareBsonBinaryData(left, right) === 0; + case TypeOrder.BsonTimestampValue: + return compareBsonTimestamps(left, right) === 0; + case TypeOrder.RegexValue: + return compareRegex(left, right) === 0; + case TypeOrder.BsonObjectIdValue: + return compareBsonObjectIds(left, right) === 0; default: return fail(0xcbf8, 'Unexpected value type', { left }); } @@ -178,10 +321,12 @@ function blobEquals(left: Value, right: Value): boolean { } export function numberEquals(left: Value, right: Value): boolean { - if ('integerValue' in left && 'integerValue' in right) { - return ( - normalizeNumber(left.integerValue) === normalizeNumber(right.integerValue) - ); + if ( + ('integerValue' in left && 'integerValue' in right) || + (detectMapRepresentation(left) === MapRepresentation.INT32 && + detectMapRepresentation(right) === MapRepresentation.INT32) + ) { + return extractNumber(left) === extractNumber(right); } else if ('doubleValue' in left && 'doubleValue' in right) { const n1 = normalizeNumber(left.doubleValue!); const n2 = normalizeNumber(right.doubleValue!); @@ -241,6 +386,8 @@ export function valueCompare(left: Value, right: Value): number { switch (leftType) { case TypeOrder.NullValue: + case TypeOrder.MinKeyValue: + case TypeOrder.MaxKeyValue: case TypeOrder.MaxValue: return 0; case TypeOrder.BooleanValue: @@ -268,14 +415,33 @@ export function valueCompare(left: Value, right: Value): number { return compareVectors(left.mapValue!, right.mapValue!); case TypeOrder.ObjectValue: return compareMaps(left.mapValue!, right.mapValue!); + case TypeOrder.BsonTimestampValue: + return compareBsonTimestamps(left, right); + case TypeOrder.BsonBinaryValue: + return compareBsonBinaryData(left, right); + case TypeOrder.RegexValue: + return compareRegex(left, right); + case TypeOrder.BsonObjectIdValue: + return compareBsonObjectIds(left, right); + default: throw fail(0x5ae0, 'Invalid value type', { leftType }); } } +export function extractNumber(value: Value): number { + let numberValue; + if (detectMapRepresentation(value) === MapRepresentation.INT32) { + numberValue = value.mapValue!.fields![RESERVED_INT32_KEY].integerValue!; + } else { + numberValue = value.integerValue || value.doubleValue; + } + return normalizeNumber(numberValue); +} + function compareNumbers(left: Value, right: Value): number { - const leftNumber = normalizeNumber(left.integerValue || left.doubleValue); - const rightNumber = normalizeNumber(right.integerValue || right.doubleValue); + const leftNumber = extractNumber(left); + const rightNumber = extractNumber(right); if (leftNumber < rightNumber) { return -1; @@ -383,11 +549,14 @@ function compareVectors(left: MapValue, right: MapValue): number { } function compareMaps(left: MapValue, right: MapValue): number { - if (left === MAX_VALUE.mapValue && right === MAX_VALUE.mapValue) { + if ( + left === INTERNAL_MAX_VALUE.mapValue && + right === INTERNAL_MAX_VALUE.mapValue + ) { return 0; - } else if (left === MAX_VALUE.mapValue) { + } else if (left === INTERNAL_MAX_VALUE.mapValue) { return 1; - } else if (right === MAX_VALUE.mapValue) { + } else if (right === INTERNAL_MAX_VALUE.mapValue) { return -1; } @@ -417,6 +586,79 @@ function compareMaps(left: MapValue, right: MapValue): number { return primitiveComparator(leftKeys.length, rightKeys.length); } +function compareBsonTimestamps(left: Value, right: Value): number { + const leftSecondField = + left.mapValue!.fields?.[RESERVED_BSON_TIMESTAMP_KEY].mapValue?.fields?.[ + RESERVED_BSON_TIMESTAMP_SECONDS_KEY + ]; + const rightSecondField = + right.mapValue!.fields?.[RESERVED_BSON_TIMESTAMP_KEY].mapValue?.fields?.[ + RESERVED_BSON_TIMESTAMP_SECONDS_KEY + ]; + + const leftIncrementField = + left.mapValue!.fields?.[RESERVED_BSON_TIMESTAMP_KEY].mapValue?.fields?.[ + RESERVED_BSON_TIMESTAMP_INCREMENT_KEY + ]; + const rightIncrementField = + right.mapValue!.fields?.[RESERVED_BSON_TIMESTAMP_KEY].mapValue?.fields?.[ + RESERVED_BSON_TIMESTAMP_INCREMENT_KEY + ]; + + const secondsDiff = compareNumbers(leftSecondField!, rightSecondField!); + return secondsDiff !== 0 + ? secondsDiff + : compareNumbers(leftIncrementField!, rightIncrementField!); +} + +function compareBsonBinaryData(left: Value, right: Value): number { + const leftBytes = + left.mapValue!.fields?.[RESERVED_BSON_BINARY_KEY]?.bytesValue; + const rightBytes = + right.mapValue!.fields?.[RESERVED_BSON_BINARY_KEY]?.bytesValue; + if (!rightBytes || !leftBytes) { + throw new Error('Received incorrect bytesValue for BsonBinaryData'); + } + return compareBlobs(leftBytes, rightBytes); +} + +function compareRegex(left: Value, right: Value): number { + const leftFields = left.mapValue!.fields; + const leftPattern = + leftFields?.[RESERVED_REGEX_KEY]?.mapValue?.fields?.[ + RESERVED_REGEX_PATTERN_KEY + ]?.stringValue ?? ''; + const leftOptions = + leftFields?.[RESERVED_REGEX_KEY]?.mapValue?.fields?.[ + RESERVED_REGEX_OPTIONS_KEY + ]?.stringValue ?? ''; + + const rightFields = right.mapValue!.fields; + const rightPattern = + rightFields?.[RESERVED_REGEX_KEY]?.mapValue?.fields?.[ + RESERVED_REGEX_PATTERN_KEY + ]?.stringValue ?? ''; + const rightOptions = + rightFields?.[RESERVED_REGEX_KEY]?.mapValue?.fields?.[ + RESERVED_REGEX_OPTIONS_KEY + ]?.stringValue ?? ''; + + // First order by patterns, and then options. + const patternDiff = compareUtf8Strings(leftPattern, rightPattern); + return patternDiff !== 0 + ? patternDiff + : primitiveComparator(leftOptions, rightOptions); +} + +function compareBsonObjectIds(left: Value, right: Value): number { + const leftOid = + left.mapValue!.fields?.[RESERVED_BSON_OBJECT_ID_KEY]?.stringValue ?? ''; + const rightOid = + right.mapValue!.fields?.[RESERVED_BSON_OBJECT_ID_KEY]?.stringValue ?? ''; + + return compareUtf8Strings(leftOid, rightOid); +} + /** * Generates the canonical ID for the provided field value (as used in Target * serialization). @@ -516,6 +758,7 @@ export function estimateByteSize(value: Value): number { case TypeOrder.BooleanValue: return 4; case TypeOrder.NumberValue: + // TODO(Mila/BSON): return 16 if the value is 128 decimal value return 8; case TypeOrder.TimestampValue: // Timestamps are made up of two distinct numbers (seconds + nanoseconds) @@ -539,6 +782,12 @@ export function estimateByteSize(value: Value): number { return estimateArrayByteSize(value.arrayValue!); case TypeOrder.VectorValue: case TypeOrder.ObjectValue: + case TypeOrder.RegexValue: + case TypeOrder.BsonObjectIdValue: + case TypeOrder.BsonBinaryValue: + case TypeOrder.BsonTimestampValue: + case TypeOrder.MinKeyValue: + case TypeOrder.MaxKeyValue: return estimateMapByteSize(value.mapValue!); default: throw fail(0x34ae, 'Invalid value type', { value }); @@ -623,10 +872,63 @@ export function isMapValue( return !!value && 'mapValue' in value; } -/** Returns true if `value` is a VetorValue. */ -export function isVectorValue(value: ProtoValue | null): boolean { - const type = (value?.mapValue?.fields || {})[TYPE_KEY]?.stringValue; - return type === VECTOR_VALUE_SENTINEL; +export function detectMapRepresentation(value: Value): MapRepresentation { + if (!value || !value.mapValue || !value.mapValue.fields) { + return MapRepresentation.REGULAR_MAP; // Not a special map type + } + + const fields = value.mapValue.fields; + + // Check for type-based mappings + const type = fields[TYPE_KEY]?.stringValue; + if (type) { + const typeMap: Record = { + [RESERVED_VECTOR_KEY]: MapRepresentation.VECTOR, + [RESERVED_MAX_KEY]: MapRepresentation.INTERNAL_MAX, + [RESERVED_SERVER_TIMESTAMP_KEY]: MapRepresentation.SERVER_TIMESTAMP + }; + if (typeMap[type]) { + return typeMap[type]; + } + } + + if (objectSize(fields) !== 1) { + // All BSON types have 1 key in the map. To improve performance, we can + // return early if the number of keys in the map is not 1. + return MapRepresentation.REGULAR_MAP; + } + + // Check for BSON-related mappings + const bsonMap: Record = { + [RESERVED_REGEX_KEY]: MapRepresentation.REGEX, + [RESERVED_BSON_OBJECT_ID_KEY]: MapRepresentation.BSON_OBJECT_ID, + [RESERVED_INT32_KEY]: MapRepresentation.INT32, + [RESERVED_BSON_TIMESTAMP_KEY]: MapRepresentation.BSON_TIMESTAMP, + [RESERVED_BSON_BINARY_KEY]: MapRepresentation.BSON_BINARY, + [RESERVED_MIN_KEY]: MapRepresentation.MIN_KEY, + [RESERVED_MAX_KEY]: MapRepresentation.MAX_KEY + }; + + for (const key in bsonMap) { + if (fields[key]) { + return bsonMap[key]; + } + } + + return MapRepresentation.REGULAR_MAP; +} + +export function isBsonType(value: Value): boolean { + const bsonTypes = new Set([ + MapRepresentation.REGEX, + MapRepresentation.BSON_OBJECT_ID, + MapRepresentation.INT32, + MapRepresentation.BSON_TIMESTAMP, + MapRepresentation.BSON_BINARY, + MapRepresentation.MIN_KEY, + MapRepresentation.MAX_KEY + ]); + return bsonTypes.has(detectMapRepresentation(value)); } /** Creates a deep copy of `source`. */ @@ -656,29 +958,10 @@ export function deepClone(source: Value): Value { } } -/** Returns true if the Value represents the canonical {@link #MAX_VALUE} . */ -export function isMaxValue(value: Value): boolean { - return ( - (((value.mapValue || {}).fields || {})['__type__'] || {}).stringValue === - MAX_VALUE_TYPE - ); -} - -export const MIN_VECTOR_VALUE = { - mapValue: { - fields: { - [TYPE_KEY]: { stringValue: VECTOR_VALUE_SENTINEL }, - [VECTOR_MAP_VECTORS_KEY]: { - arrayValue: {} - } - } - } -}; - /** Returns the lowest value for the given value type (inclusive). */ export function valuesGetLowerBound(value: Value): Value { if ('nullValue' in value) { - return MIN_VALUE; + return INTERNAL_MIN_VALUE; } else if ('booleanValue' in value) { return { booleanValue: false }; } else if ('integerValue' in value || 'doubleValue' in value) { @@ -696,8 +979,24 @@ export function valuesGetLowerBound(value: Value): Value { } else if ('arrayValue' in value) { return { arrayValue: {} }; } else if ('mapValue' in value) { - if (isVectorValue(value)) { + const type = detectMapRepresentation(value); + if (type === MapRepresentation.VECTOR) { return MIN_VECTOR_VALUE; + } else if (type === MapRepresentation.BSON_OBJECT_ID) { + return MIN_BSON_OBJECT_ID_VALUE; + } else if (type === MapRepresentation.BSON_TIMESTAMP) { + return MIN_BSON_TIMESTAMP_VALUE; + } else if (type === MapRepresentation.BSON_BINARY) { + return MIN_BSON_BINARY_VALUE; + } else if (type === MapRepresentation.REGEX) { + return MIN_REGEX_VALUE; + } else if (type === MapRepresentation.INT32) { + // int32Value is treated the same as integerValue and doubleValue + return { doubleValue: NaN }; + } else if (type === MapRepresentation.MIN_KEY) { + return MIN_KEY_VALUE; + } else if (type === MapRepresentation.MAX_KEY) { + return MAX_KEY_VALUE; } return { mapValue: {} }; } else { @@ -708,28 +1007,44 @@ export function valuesGetLowerBound(value: Value): Value { /** Returns the largest value for the given value type (exclusive). */ export function valuesGetUpperBound(value: Value): Value { if ('nullValue' in value) { - return { booleanValue: false }; + return MIN_KEY_VALUE; } else if ('booleanValue' in value) { return { doubleValue: NaN }; } else if ('integerValue' in value || 'doubleValue' in value) { return { timestampValue: { seconds: Number.MIN_SAFE_INTEGER } }; } else if ('timestampValue' in value) { - return { stringValue: '' }; + return MIN_BSON_TIMESTAMP_VALUE; } else if ('stringValue' in value) { return { bytesValue: '' }; } else if ('bytesValue' in value) { - return refValue(DatabaseId.empty(), DocumentKey.empty()); + return MIN_BSON_BINARY_VALUE; } else if ('referenceValue' in value) { - return { geoPointValue: { latitude: -90, longitude: -180 } }; + return MIN_BSON_OBJECT_ID_VALUE; } else if ('geoPointValue' in value) { - return { arrayValue: {} }; + return MIN_REGEX_VALUE; } else if ('arrayValue' in value) { return MIN_VECTOR_VALUE; } else if ('mapValue' in value) { - if (isVectorValue(value)) { + const type = detectMapRepresentation(value); + if (type === MapRepresentation.VECTOR) { return { mapValue: {} }; + } else if (type === MapRepresentation.BSON_OBJECT_ID) { + return { geoPointValue: { latitude: -90, longitude: -180 } }; + } else if (type === MapRepresentation.BSON_TIMESTAMP) { + return { stringValue: '' }; + } else if (type === MapRepresentation.BSON_BINARY) { + return refValue(DatabaseId.empty(), DocumentKey.empty()); + } else if (type === MapRepresentation.REGEX) { + return { arrayValue: {} }; + } else if (type === MapRepresentation.INT32) { + // int32Value is treated the same as integerValue and doubleValue + return { timestampValue: { seconds: Number.MIN_SAFE_INTEGER } }; + } else if (type === MapRepresentation.MIN_KEY) { + return { booleanValue: false }; + } else if (type === MapRepresentation.MAX_KEY) { + return INTERNAL_MAX_VALUE; } - return MAX_VALUE; + return MAX_KEY_VALUE; } else { return fail(0xf207, 'Invalid value type', { value }); } diff --git a/packages/firestore/test/integration/api/database.test.ts b/packages/firestore/test/integration/api/database.test.ts index 9675e02efeb..ce5a3d34eae 100644 --- a/packages/firestore/test/integration/api/database.test.ts +++ b/packages/firestore/test/integration/api/database.test.ts @@ -61,13 +61,23 @@ import { WithFieldValue, Timestamp, FieldPath, - newTestFirestore, SnapshotOptions, newTestApp, FirestoreError, QuerySnapshot, vector, - getDocsFromServer + getDocsFromServer, + or, + newTestFirestore, + GeoPoint, + Bytes, + BsonBinaryData, + BsonObjectId, + Int32Value, + MaxKey, + MinKey, + RegexValue, + BsonTimestamp } from '../util/firebase_export'; import { apiDescribe, @@ -80,7 +90,9 @@ import { withNamedTestDbsOrSkipUnlessUsingEmulator, toDataArray, checkOnlineAndOfflineResultsMatch, - toIds + toIds, + withTestProjectIdAndCollectionSettings, + assertSDKQueryResultsConsistentWithBackend } from '../util/helpers'; import { DEFAULT_SETTINGS, DEFAULT_PROJECT_ID } from '../util/settings'; @@ -2691,4 +2703,817 @@ apiDescribe('Database', persistence => { } ); }); + + describe('BSON types', () => { + // TODO(Mila/BSON): simplify the test setup once prod support BSON and + // remove the cache population after the test helper is updated + const NIGHTLY_PROJECT_ID = 'firestore-sdk-nightly'; + const settings = { + ...DEFAULT_SETTINGS, + host: 'test-firestore.sandbox.googleapis.com' + }; + + it('can write and read BSON types', async () => { + return withTestProjectIdAndCollectionSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + {}, + async coll => { + const docRef = await addDoc(coll, { + binary: new BsonBinaryData(1, new Uint8Array([1, 2, 3])), + objectId: new BsonObjectId('507f191e810c19729de860ea'), + int32: new Int32Value(1), + min: MinKey.instance(), + max: MaxKey.instance(), + regex: new RegexValue('^foo', 'i') + }); + + await setDoc( + docRef, + { + binary: new BsonBinaryData(1, new Uint8Array([1, 2, 3])), + timestamp: new BsonTimestamp(1, 2), + int32: new Int32Value(2) + }, + { merge: true } + ); + + const snapshot = await getDoc(docRef); + expect( + snapshot + .get('objectId') + .isEqual(new BsonObjectId('507f191e810c19729de860ea')) + ).to.be.true; + expect(snapshot.get('int32').isEqual(new Int32Value(2))).to.be.true; + expect(snapshot.get('min') === MinKey.instance()).to.be.true; + expect(snapshot.get('max') === MaxKey.instance()).to.be.true; + expect( + snapshot + .get('binary') + .isEqual(new BsonBinaryData(1, new Uint8Array([1, 2, 3]))) + ).to.be.true; + expect(snapshot.get('timestamp').isEqual(new BsonTimestamp(1, 2))).to + .be.true; + expect(snapshot.get('regex').isEqual(new RegexValue('^foo', 'i'))).to + .be.true; + } + ); + }); + + it('can write and read BSON types offline', async () => { + return withTestProjectIdAndCollectionSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + {}, + async (coll, db) => { + await disableNetwork(db); + const docRef = doc(coll, 'testDoc'); + + // Adding docs to cache, do not wait for promise to resolve. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + setDoc(docRef, { + binary: new BsonBinaryData(1, new Uint8Array([1, 2, 3])), + objectId: new BsonObjectId('507f191e810c19729de860ea'), + int32: new Int32Value(1), + regex: new RegexValue('^foo', 'i'), + timestamp: new BsonTimestamp(1, 2), + min: MinKey.instance(), + max: MaxKey.instance() + }); + + const snapshot = await getDocFromCache(docRef); + expect( + snapshot + .get('binary') + .isEqual(new BsonBinaryData(1, new Uint8Array([1, 2, 3]))) + ).to.be.true; + expect( + snapshot + .get('objectId') + .isEqual(new BsonObjectId('507f191e810c19729de860ea')) + ).to.be.true; + expect(snapshot.get('int32').isEqual(new Int32Value(1))).to.be.true; + expect(snapshot.get('regex').isEqual(new RegexValue('^foo', 'i'))).to + .be.true; + expect(snapshot.get('timestamp').isEqual(new BsonTimestamp(1, 2))).to + .be.true; + expect(snapshot.get('min') === MinKey.instance()).to.be.true; + expect(snapshot.get('max') === MaxKey.instance()).to.be.true; + } + ); + }); + + it('can filter and order objectIds', async () => { + const testDocs = { + a: { key: new BsonObjectId('507f191e810c19729de860ea') }, + b: { key: new BsonObjectId('507f191e810c19729de860eb') }, + c: { key: new BsonObjectId('507f191e810c19729de860ec') } + }; + + return withTestProjectIdAndCollectionSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + testDocs, + async coll => { + let orderedQuery = query( + coll, + where('key', '>', new BsonObjectId('507f191e810c19729de860ea')), + orderBy('key', 'desc') + ); + + let snapshot = await getDocs(orderedQuery); + expect(toDataArray(snapshot)).to.deep.equal([ + testDocs['c'], + testDocs['b'] + ]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + orderedQuery, + testDocs, + toIds(snapshot) + ); + + orderedQuery = query( + coll, + where('key', 'in', [ + new BsonObjectId('507f191e810c19729de860ea'), + new BsonObjectId('507f191e810c19729de860eb') + ]), + orderBy('key', 'desc') + ); + + snapshot = await getDocs(orderedQuery); + expect(toDataArray(snapshot)).to.deep.equal([ + testDocs['b'], + testDocs['a'] + ]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + orderedQuery, + testDocs, + toIds(snapshot) + ); + } + ); + }); + + it('can filter and order Int32 values', async () => { + const testDocs = { + a: { key: new Int32Value(-1) }, + b: { key: new Int32Value(1) }, + c: { key: new Int32Value(2) } + }; + return withTestProjectIdAndCollectionSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + testDocs, + async coll => { + let orderedQuery = query( + coll, + where('key', '>=', new Int32Value(1)), + orderBy('key', 'desc') + ); + + let snapshot = await getDocs(orderedQuery); + expect(toDataArray(snapshot)).to.deep.equal([ + testDocs['c'], + testDocs['b'] + ]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + orderedQuery, + testDocs, + toIds(snapshot) + ); + + orderedQuery = query( + coll, + where('key', 'not-in', [new Int32Value(1)]), + orderBy('key', 'desc') + ); + + snapshot = await getDocs(orderedQuery); + expect(toDataArray(snapshot)).to.deep.equal([ + testDocs['c'], + testDocs['a'] + ]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + orderedQuery, + testDocs, + toIds(snapshot) + ); + } + ); + }); + + it('can filter and order Timestamp values', async () => { + const testDocs = { + a: { key: new BsonTimestamp(1, 1) }, + b: { key: new BsonTimestamp(1, 2) }, + c: { key: new BsonTimestamp(2, 1) } + }; + return withTestProjectIdAndCollectionSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + testDocs, + async coll => { + let orderedQuery = query( + coll, + where('key', '>', new BsonTimestamp(1, 1)), + orderBy('key', 'desc') + ); + + let snapshot = await getDocs(orderedQuery); + expect(toDataArray(snapshot)).to.deep.equal([ + testDocs['c'], + testDocs['b'] + ]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + orderedQuery, + testDocs, + toIds(snapshot) + ); + + orderedQuery = query( + coll, + where('key', '!=', new BsonTimestamp(1, 1)), + orderBy('key', 'desc') + ); + + snapshot = await getDocs(orderedQuery); + expect(toDataArray(snapshot)).to.deep.equal([ + testDocs['c'], + testDocs['b'] + ]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + orderedQuery, + testDocs, + toIds(snapshot) + ); + } + ); + }); + + it('can filter and order Binary values', async () => { + const testDocs = { + a: { key: new BsonBinaryData(1, new Uint8Array([1, 2, 3])) }, + b: { key: new BsonBinaryData(1, new Uint8Array([1, 2, 4])) }, + c: { key: new BsonBinaryData(2, new Uint8Array([1, 2, 3])) } + }; + return withTestProjectIdAndCollectionSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + testDocs, + async coll => { + let orderedQuery = query( + coll, + where('key', '>', new BsonBinaryData(1, new Uint8Array([1, 2, 3]))), + orderBy('key', 'desc') + ); + + let snapshot = await getDocs(orderedQuery); + expect(toDataArray(snapshot)).to.deep.equal([ + testDocs['c'], + testDocs['b'] + ]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + orderedQuery, + testDocs, + toIds(snapshot) + ); + + orderedQuery = query( + coll, + where( + 'key', + '>=', + new BsonBinaryData(1, new Uint8Array([1, 2, 3])) + ), + where('key', '<', new BsonBinaryData(2, new Uint8Array([1, 2, 3]))), + orderBy('key', 'desc') + ); + + snapshot = await getDocs(orderedQuery); + expect(toDataArray(snapshot)).to.deep.equal([ + testDocs['b'], + testDocs['a'] + ]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + orderedQuery, + testDocs, + toIds(snapshot) + ); + } + ); + }); + + it('can filter and order Regex values', async () => { + const testDocs = { + a: { key: new RegexValue('^bar', 'i') }, + b: { key: new RegexValue('^bar', 'x') }, + c: { key: new RegexValue('^baz', 'i') } + }; + return withTestProjectIdAndCollectionSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + testDocs, + async coll => { + const orderedQuery = query( + coll, + or( + where('key', '>', new RegexValue('^bar', 'x')), + where('key', '!=', new RegexValue('^bar', 'x')) + ), + orderBy('key', 'desc') + ); + + const snapshot = await getDocs(orderedQuery); + expect(toDataArray(snapshot)).to.deep.equal([ + testDocs['c'], + testDocs['a'] + ]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + orderedQuery, + testDocs, + toIds(snapshot) + ); + } + ); + }); + + it('can filter and order minKey values', async () => { + const testDocs = { + a: { key: MinKey.instance() }, + b: { key: MinKey.instance() }, + c: { key: null }, + d: { key: 1 }, + e: { key: MaxKey.instance() } + }; + return withTestProjectIdAndCollectionSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + testDocs, + async coll => { + let filteredQuery = query( + coll, + where('key', '==', MinKey.instance()) + ); + let snapshot = await getDocs(filteredQuery); + expect(toDataArray(snapshot)).to.deep.equal([ + testDocs['a'], + testDocs['b'] + ]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + filteredQuery, + testDocs, + toIds(snapshot) + ); + + filteredQuery = query(coll, where('key', '!=', MinKey.instance())); + snapshot = await getDocs(filteredQuery); + expect(toDataArray(snapshot)).to.deep.equal([ + testDocs['d'], + testDocs['e'] + ]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + filteredQuery, + testDocs, + toIds(snapshot) + ); + + filteredQuery = query(coll, where('key', '>=', MinKey.instance())); + snapshot = await getDocs(filteredQuery); + expect(toDataArray(snapshot)).to.deep.equal([ + testDocs['a'], + testDocs['b'] + ]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + filteredQuery, + testDocs, + toIds(snapshot) + ); + + filteredQuery = query(coll, where('key', '<=', MinKey.instance())); + snapshot = await getDocs(filteredQuery); + expect(toDataArray(snapshot)).to.deep.equal([ + testDocs['a'], + testDocs['b'] + ]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + filteredQuery, + testDocs, + toIds(snapshot) + ); + + filteredQuery = query(coll, where('key', '>', MinKey.instance())); + snapshot = await getDocs(filteredQuery); + expect(toDataArray(snapshot)).to.deep.equal([]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + filteredQuery, + testDocs, + toIds(snapshot) + ); + + filteredQuery = query(coll, where('key', '<', MinKey.instance())); + snapshot = await getDocs(filteredQuery); + expect(toDataArray(snapshot)).to.deep.equal([]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + filteredQuery, + testDocs, + toIds(snapshot) + ); + + filteredQuery = query(coll, where('key', '<', 1)); + snapshot = await getDocs(filteredQuery); + expect(toDataArray(snapshot)).to.deep.equal([]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + filteredQuery, + testDocs, + toIds(snapshot) + ); + } + ); + }); + + it('can filter and order maxKey values', async () => { + const testDocs = { + a: { key: MinKey.instance() }, + b: { key: 1 }, + c: { key: MaxKey.instance() }, + d: { key: MaxKey.instance() }, + e: { key: null } + }; + return withTestProjectIdAndCollectionSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + testDocs, + async coll => { + let filteredQuery = query( + coll, + where('key', '==', MaxKey.instance()) + ); + let snapshot = await getDocs(filteredQuery); + expect(toDataArray(snapshot)).to.deep.equal([ + testDocs['c'], + testDocs['d'] + ]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + filteredQuery, + testDocs, + toIds(snapshot) + ); + + filteredQuery = query(coll, where('key', '!=', MaxKey.instance())); + snapshot = await getDocs(filteredQuery); + expect(toDataArray(snapshot)).to.deep.equal([ + testDocs['a'], + testDocs['b'] + ]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + filteredQuery, + testDocs, + toIds(snapshot) + ); + + filteredQuery = query(coll, where('key', '>=', MaxKey.instance())); + snapshot = await getDocs(filteredQuery); + expect(toDataArray(snapshot)).to.deep.equal([ + testDocs['c'], + testDocs['d'] + ]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + filteredQuery, + testDocs, + toIds(snapshot) + ); + + filteredQuery = query(coll, where('key', '<=', MaxKey.instance())); + snapshot = await getDocs(filteredQuery); + expect(toDataArray(snapshot)).to.deep.equal([ + testDocs['c'], + testDocs['d'] + ]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + filteredQuery, + testDocs, + toIds(snapshot) + ); + + filteredQuery = query(coll, where('key', '>', MaxKey.instance())); + snapshot = await getDocs(filteredQuery); + expect(toDataArray(snapshot)).to.deep.equal([]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + filteredQuery, + testDocs, + toIds(snapshot) + ); + + filteredQuery = query(coll, where('key', '<', MaxKey.instance())); + snapshot = await getDocs(filteredQuery); + expect(toDataArray(snapshot)).to.deep.equal([]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + filteredQuery, + testDocs, + toIds(snapshot) + ); + + filteredQuery = query(coll, where('key', '>', 1)); + snapshot = await getDocs(filteredQuery); + expect(toDataArray(snapshot)).to.deep.equal([]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + filteredQuery, + testDocs, + toIds(snapshot) + ); + } + ); + }); + + it('can handle null with bson values', async () => { + const testDocs = { + a: { key: MinKey.instance() }, + b: { key: null }, + c: { key: null }, + d: { key: 1 }, + e: { key: MaxKey.instance() } + }; + + return withTestProjectIdAndCollectionSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + testDocs, + async coll => { + let filteredQuery = query(coll, where('key', '==', null)); + let snapshot = await getDocs(filteredQuery); + expect(toDataArray(snapshot)).to.deep.equal([ + testDocs['b'], + testDocs['c'] + ]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + filteredQuery, + testDocs, + toIds(snapshot) + ); + + filteredQuery = query(coll, where('key', '!=', null)); + snapshot = await getDocs(filteredQuery); + expect(toDataArray(snapshot)).to.deep.equal([ + testDocs['a'], + testDocs['d'], + testDocs['e'] + ]); + await assertSDKQueryResultsConsistentWithBackend( + coll, + filteredQuery, + testDocs, + toIds(snapshot) + ); + } + ); + }); + + it('can listen to documents with bson types', async () => { + const testDocs = { + a: { key: MaxKey.instance() }, + b: { key: MinKey.instance() }, + c: { key: new BsonTimestamp(1, 2) }, + d: { key: new BsonObjectId('507f191e810c19729de860ea') }, + e: { key: new BsonBinaryData(1, new Uint8Array([1, 2, 3])) }, + f: { key: new RegexValue('^foo', 'i') } + }; + return withTestProjectIdAndCollectionSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + testDocs, + async coll => { + const orderedQuery = query(coll, orderBy('key', 'asc')); + + const storeEvent = new EventsAccumulator(); + const unsubscribe = onSnapshot(orderedQuery, storeEvent.storeEvent); + + let listenSnapshot = await storeEvent.awaitEvent(); + expect(toDataArray(listenSnapshot)).to.deep.equal([ + testDocs['b'], + testDocs['c'], + testDocs['e'], + testDocs['d'], + testDocs['f'], + testDocs['a'] + ]); + + const newData = { key: new Int32Value(2) }; + await setDoc(doc(coll, 'g'), newData); + listenSnapshot = await storeEvent.awaitEvent(); + expect(toDataArray(listenSnapshot)).to.deep.equal([ + testDocs['b'], + newData, + testDocs['c'], + testDocs['e'], + testDocs['d'], + testDocs['f'], + testDocs['a'] + ]); + + unsubscribe(); + } + ); + }); + + // TODO(Mila/BSON): Skip the runTransaction tests against nightly when running on browsers. remove when it is supported by prod + // eslint-disable-next-line no-restricted-properties + it.skip('can run transactions on documents with bson types', async () => { + const testDocs = { + a: { key: new BsonTimestamp(1, 2) }, + b: { key: new RegexValue('^foo', 'i') }, + c: { key: new BsonBinaryData(1, new Uint8Array([1, 2, 3])) } + }; + return withTestProjectIdAndCollectionSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + {}, + async (coll, db) => { + const docA = await addDoc(coll, testDocs['a']); + const docB = await addDoc(coll, { key: 'place holder' }); + const docC = await addDoc(coll, testDocs['c']); + + await runTransaction(db, async transaction => { + const docSnapshot = await transaction.get(docA); + expect(docSnapshot.data()).to.deep.equal(testDocs['a']); + transaction.set(docB, testDocs['b']); + transaction.delete(docC); + }); + + const orderedQuery = query(coll, orderBy('key', 'asc')); + const snapshot = await getDocs(orderedQuery); + + expect(toDataArray(snapshot)).to.deep.equal([ + testDocs['a'], + testDocs['b'] + ]); + } + ); + }); + + // eslint-disable-next-line no-restricted-properties + (persistence.gc === 'lru' ? describe : describe.skip)('From Cache', () => { + it('SDK orders different value types together the same way online and offline', async () => { + const testDocs: { [key: string]: DocumentData } = { + a: { key: null }, + b: { key: MinKey.instance() }, + c: { key: true }, + d: { key: NaN }, + e: { key: new Int32Value(1) }, + f: { key: 2.0 }, + g: { key: 3 }, + h: { key: new Timestamp(100, 123456000) }, + i: { key: new BsonTimestamp(1, 2) }, + j: { key: 'string' }, + k: { key: Bytes.fromUint8Array(new Uint8Array([0, 1, 255])) }, + l: { key: new BsonBinaryData(1, new Uint8Array([1, 2, 3])) }, + n: { key: new BsonObjectId('507f191e810c19729de860ea') }, + o: { key: new GeoPoint(0, 0) }, + p: { key: new RegexValue('^foo', 'i') }, + q: { key: [1, 2] }, + r: { key: vector([1, 2]) }, + s: { key: { a: 1 } }, + t: { key: MaxKey.instance() } + }; + + return withTestProjectIdAndCollectionSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + testDocs, + async coll => { + // TODO(Mila/BSON): remove after prod supports bson, and use `ref` helper function instead + const docRef = doc(coll, 'doc'); + await setDoc(doc(coll, 'm'), { key: docRef }); + testDocs['m'] = { key: docRef }; + + const orderedQuery = query(coll, orderBy('key', 'desc')); + await assertSDKQueryResultsConsistentWithBackend( + coll, + orderedQuery, + testDocs, + [ + 't', + 's', + 'r', + 'q', + 'p', + 'o', + 'n', + 'm', + 'l', + 'k', + 'j', + 'i', + 'h', + 'g', + 'f', + 'e', + 'd', + 'c', + 'b', + 'a' + ] + ); + } + ); + }); + + it('SDK orders bson types the same way online and offline', async () => { + const testDocs: { [key: string]: DocumentData } = { + a: { key: MaxKey.instance() }, // maxKeys are all equal + b: { key: MaxKey.instance() }, + c: { key: new Int32Value(1) }, + d: { key: new Int32Value(-1) }, + e: { key: new Int32Value(0) }, + f: { key: new BsonTimestamp(1, 1) }, + g: { key: new BsonTimestamp(2, 1) }, + h: { key: new BsonTimestamp(1, 2) }, + i: { key: new BsonBinaryData(1, new Uint8Array([1, 2, 3])) }, + j: { key: new BsonBinaryData(1, new Uint8Array([1, 1, 4])) }, + k: { key: new BsonBinaryData(2, new Uint8Array([1, 0, 0])) }, + l: { key: new BsonObjectId('507f191e810c19729de860eb') }, + m: { key: new BsonObjectId('507f191e810c19729de860ea') }, + n: { key: new BsonObjectId('407f191e810c19729de860ea') }, + o: { key: new RegexValue('^foo', 'i') }, + p: { key: new RegexValue('^foo', 'm') }, + q: { key: new RegexValue('^bar', 'i') }, + r: { key: MinKey.instance() }, // minKeys are all equal + s: { key: MinKey.instance() } + }; + + return withTestProjectIdAndCollectionSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + testDocs, + async coll => { + const orderedQuery = query(coll, orderBy('key')); + await assertSDKQueryResultsConsistentWithBackend( + coll, + orderedQuery, + testDocs, + [ + 'r', + 's', + 'd', + 'e', + 'c', + 'f', + 'h', + 'g', + 'j', + 'i', + 'k', + 'n', + 'm', + 'l', + 'q', + 'o', + 'p', + 'a', + 'b' + ] + ); + } + ); + }); + }); + }); }); diff --git a/packages/firestore/test/integration/api/query.test.ts b/packages/firestore/test/integration/api/query.test.ts index 0f3c1c82a2d..9376652c438 100644 --- a/packages/firestore/test/integration/api/query.test.ts +++ b/packages/firestore/test/integration/api/query.test.ts @@ -925,20 +925,6 @@ apiDescribe('Queries', persistence => { { array: ['a', 42, 'c'] }, { array: [42], array2: ['bingo'] } ]); - - // NOTE: The backend doesn't currently support null, NaN, objects, or - // arrays, so there isn't much of anything else interesting to test. - // With null. - const snapshot3 = await getDocs( - query(coll, where('zip', 'array-contains', null)) - ); - expect(toDataArray(snapshot3)).to.deep.equal([]); - - // With NaN. - const snapshot4 = await getDocs( - query(coll, where('zip', 'array-contains', Number.NaN)) - ); - expect(toDataArray(snapshot4)).to.deep.equal([]); }); }); diff --git a/packages/firestore/test/integration/api/type.test.ts b/packages/firestore/test/integration/api/type.test.ts index 0fd9c19ccad..2f7cb7f9295 100644 --- a/packages/firestore/test/integration/api/type.test.ts +++ b/packages/firestore/test/integration/api/type.test.ts @@ -20,22 +20,43 @@ import { expect } from 'chai'; import { addEqualityMatcher } from '../../util/equality_matcher'; import { EventsAccumulator } from '../util/events_accumulator'; import { + BsonBinaryData, + BsonObjectId, + BsonTimestamp, Bytes, collection, doc, + DocumentData, + DocumentReference, DocumentSnapshot, Firestore, + FirestoreError, GeoPoint, getDoc, getDocs, + Int32Value, + MaxKey, + MinKey, onSnapshot, + orderBy, + query, QuerySnapshot, + refEqual, + RegexValue, runTransaction, setDoc, Timestamp, - updateDoc + updateDoc, + vector } from '../util/firebase_export'; -import { apiDescribe, withTestDb, withTestDoc } from '../util/helpers'; +import { + apiDescribe, + withTestProjectIdAndCollectionSettings, + withTestDb, + withTestDbsSettings, + withTestDoc +} from '../util/helpers'; +import { DEFAULT_SETTINGS } from '../util/settings'; apiDescribe('Firestore', persistence => { addEqualityMatcher(); @@ -82,6 +103,43 @@ apiDescribe('Firestore', persistence => { return docSnapshot; } + // TODO(Mila/BSON): Transactions against nightly is having issue, remove this after prod supports BSON + async function expectRoundtripWithoutTransaction( + db: Firestore, + data: {}, + validateSnapshots = true, + expectedData?: {} + ): Promise { + expectedData = expectedData ?? data; + + const collRef = collection(db, doc(collection(db, 'a')).id); + const docRef = doc(collRef); + + await setDoc(docRef, data); + let docSnapshot = await getDoc(docRef); + expect(docSnapshot.data()).to.deep.equal(expectedData); + + await updateDoc(docRef, data); + docSnapshot = await getDoc(docRef); + expect(docSnapshot.data()).to.deep.equal(expectedData); + + if (validateSnapshots) { + let querySnapshot = await getDocs(collRef); + docSnapshot = querySnapshot.docs[0]; + expect(docSnapshot.data()).to.deep.equal(expectedData); + + const eventsAccumulator = new EventsAccumulator(); + const unlisten = onSnapshot(collRef, eventsAccumulator.storeEvent); + querySnapshot = await eventsAccumulator.awaitEvent(); + docSnapshot = querySnapshot.docs[0]; + expect(docSnapshot.data()).to.deep.equal(expectedData); + + unlisten(); + } + + return docSnapshot; + } + it('can read and write null fields', () => { return withTestDb(persistence, async db => { await expectRoundtrip(db, { a: 1, b: null }); @@ -177,4 +235,340 @@ apiDescribe('Firestore', persistence => { await expectRoundtrip(db, { a: 42, refs: [doc] }); }); }); + + it('can read and write vector fields', () => { + return withTestDoc(persistence, async (doc, db) => { + await expectRoundtrip(db, { vector: vector([1, 2, 3]) }); + }); + }); + + // TODO(Mila/BSON): simplify the test setup once prod support BSON + const NIGHTLY_PROJECT_ID = 'firestore-sdk-nightly'; + const settings = { + ...DEFAULT_SETTINGS, + host: 'test-firestore.sandbox.googleapis.com' + }; + + it('can read and write minKey fields', () => { + return withTestDbsSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + 1, + async dbs => { + await expectRoundtripWithoutTransaction(dbs[0], { + min: MinKey.instance() + }); + } + ); + }); + + it('can read and write maxKey fields', () => { + return withTestDbsSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + 1, + async dbs => { + await expectRoundtripWithoutTransaction(dbs[0], { + max: MaxKey.instance() + }); + } + ); + }); + + it('can read and write regex fields', () => { + return withTestDbsSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + 1, + async dbs => { + await expectRoundtripWithoutTransaction(dbs[0], { + regex: new RegexValue('^foo', 'i') + }); + } + ); + }); + + it('can read and write int32 fields', () => { + return withTestDbsSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + 1, + async dbs => { + await expectRoundtripWithoutTransaction(dbs[0], { + int32: new Int32Value(1) + }); + } + ); + }); + + it('can read and write bsonTimestamp fields', () => { + return withTestDbsSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + 1, + async dbs => { + await expectRoundtripWithoutTransaction(dbs[0], { + bsonTimestamp: new BsonTimestamp(1, 2) + }); + } + ); + }); + + it('can read and write bsonObjectId fields', () => { + return withTestDbsSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + 1, + async dbs => { + await expectRoundtripWithoutTransaction(dbs[0], { + objectId: new BsonObjectId('507f191e810c19729de860ea') + }); + } + ); + }); + + it('can read and write bsonBinaryData fields', () => { + return withTestDbsSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + 1, + async dbs => { + await expectRoundtripWithoutTransaction(dbs[0], { + binary: new BsonBinaryData(1, new Uint8Array([1, 2, 3])) + }); + } + ); + }); + + it('can read and write bson fields in an array', () => { + return withTestDbsSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + 1, + async dbs => { + await expectRoundtripWithoutTransaction(dbs[0], { + array: [ + new BsonBinaryData(1, new Uint8Array([1, 2, 3])), + new BsonObjectId('507f191e810c19729de860ea'), + new Int32Value(1), + MinKey.instance(), + MaxKey.instance(), + new RegexValue('^foo', 'i') + ] + }); + } + ); + }); + + it('can read and write bson fields in an object', () => { + return withTestDbsSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + 1, + async dbs => { + await expectRoundtripWithoutTransaction(dbs[0], { + object: { + binary: new BsonBinaryData(1, new Uint8Array([1, 2, 3])), + objectId: new BsonObjectId('507f191e810c19729de860ea'), + int32: new Int32Value(1), + min: MinKey.instance(), + max: MaxKey.instance(), + regex: new RegexValue('^foo', 'i') + } + }); + } + ); + }); + + it('invalid 32-bit integer gets rejected', async () => { + return withTestProjectIdAndCollectionSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + {}, + async coll => { + const docRef = doc(coll, 'test-doc'); + let errorMessage; + try { + await setDoc(docRef, { key: new Int32Value(2147483648) }); + } catch (err) { + errorMessage = (err as FirestoreError)?.message; + } + expect(errorMessage).to.contains( + "The field '__int__' value (2,147,483,648) is too large to be converted to a 32-bit integer." + ); + + try { + await setDoc(docRef, { key: new Int32Value(-2147483650) }); + } catch (err) { + errorMessage = (err as FirestoreError)?.message; + } + expect(errorMessage).to.contains( + "The field '__int__' value (-2,147,483,650) is too large to be converted to a 32-bit integer." + ); + } + ); + }); + + it('invalid BSON timestamp gets rejected', async () => { + return withTestProjectIdAndCollectionSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + {}, + async coll => { + const docRef = doc(coll, 'test-doc'); + let errorMessage; + try { + // BSON timestamp larger than 32-bit integer gets rejected + await setDoc(docRef, { key: new BsonTimestamp(4294967296, 2) }); + } catch (err) { + errorMessage = (err as FirestoreError)?.message; + } + expect(errorMessage).to.contains( + "BsonTimestamp 'seconds' must be in the range of a 32-bit unsigned integer." + ); + + try { + // negative BSON timestamp gets rejected + await setDoc(docRef, { key: new BsonTimestamp(-1, 2) }); + } catch (err) { + errorMessage = (err as FirestoreError)?.message; + } + expect(errorMessage).to.contains( + "BsonTimestamp 'seconds' must be in the range of a 32-bit unsigned integer." + ); + } + ); + }); + + it('invalid regex value gets rejected', async () => { + return withTestProjectIdAndCollectionSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + {}, + async coll => { + const docRef = doc(coll, 'test-doc'); + let errorMessage; + try { + await setDoc(docRef, { key: new RegexValue('foo', 'a') }); + } catch (err) { + errorMessage = (err as FirestoreError)?.message; + } + expect(errorMessage).to.contains( + "Invalid regex option 'a'. Supported options are 'i', 'm', 's', 'u', and 'x'." + ); + } + ); + }); + + it('invalid bsonObjectId value gets rejected', async () => { + return withTestProjectIdAndCollectionSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + {}, + async coll => { + const docRef = doc(coll, 'test-doc'); + + let errorMessage; + try { + // bsonObjectId with length not equal to 24 gets rejected + await setDoc(docRef, { key: new BsonObjectId('foo') }); + } catch (err) { + errorMessage = (err as FirestoreError)?.message; + } + expect(errorMessage).to.contains( + 'Object ID hex string has incorrect length.' + ); + } + ); + }); + + it('invalid bsonBinaryData value gets rejected', async () => { + return withTestProjectIdAndCollectionSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + {}, + async coll => { + const docRef = doc(coll, 'test-doc'); + let errorMessage; + try { + await setDoc(docRef, { + key: new BsonBinaryData(1234, new Uint8Array([1, 2, 3])) + }); + } catch (err) { + errorMessage = (err as FirestoreError)?.message; + } + expect(errorMessage).to.contains( + 'The subtype for BsonBinaryData must be a value in the inclusive [0, 255] range.' + ); + } + ); + }); + + it('can order values of different TypeOrder together', async () => { + const testDocs: { [key: string]: DocumentData } = { + nullValue: { key: null }, + minValue: { key: MinKey.instance() }, + booleanValue: { key: true }, + nanValue: { key: NaN }, + int32Value: { key: new Int32Value(1) }, + doubleValue: { key: 2.0 }, + integerValue: { key: 3 }, + timestampValue: { key: new Timestamp(100, 123456000) }, + bsonTimestampValue: { key: new BsonTimestamp(1, 2) }, + stringValue: { key: 'string' }, + bytesValue: { key: Bytes.fromUint8Array(new Uint8Array([0, 1, 255])) }, + bsonBinaryValue: { + key: new BsonBinaryData(1, new Uint8Array([1, 2, 3])) + }, + // referenceValue: {key: ref('coll/doc')}, + referenceValue: { key: 'placeholder' }, + objectIdValue: { key: new BsonObjectId('507f191e810c19729de860ea') }, + geoPointValue: { key: new GeoPoint(0, 0) }, + regexValue: { key: new RegexValue('^foo', 'i') }, + arrayValue: { key: [1, 2] }, + vectorValue: { key: vector([1, 2]) }, + objectValue: { key: { a: 1 } }, + maxValue: { key: MaxKey.instance() } + }; + + return withTestProjectIdAndCollectionSettings( + persistence, + NIGHTLY_PROJECT_ID, + settings, + testDocs, + async coll => { + // TODO(Mila/BSON): remove after prod supports bson + const docRef = doc(coll, 'doc'); + await setDoc(doc(coll, 'referenceValue'), { key: docRef }); + + const orderedQuery = query(coll, orderBy('key')); + const snapshot = await getDocs(orderedQuery); + for (let i = 0; i < snapshot.docs.length; i++) { + const actualDoc = snapshot.docs[i].data().key; + const expectedDoc = + testDocs[snapshot.docs[i].id as keyof typeof testDocs].key; + if (actualDoc instanceof DocumentReference) { + // deep.equal doesn't work with DocumentReference + expect(refEqual(actualDoc, docRef)).to.be.true; + } else { + expect(actualDoc).to.deep.equal(expectedDoc); + } + } + } + ); + }); }); diff --git a/packages/firestore/test/integration/api/validation.test.ts b/packages/firestore/test/integration/api/validation.test.ts index 9c74634affa..72978f71fe3 100644 --- a/packages/firestore/test/integration/api/validation.test.ts +++ b/packages/firestore/test/integration/api/validation.test.ts @@ -856,6 +856,20 @@ apiDescribe('Validation:', persistence => { ).to.throw("Invalid query. You cannot use more than one '!=' filter."); }); + validationIt(persistence, 'rejects invalid NaN filter', db => { + const coll = collection(db, 'test'); + expect(() => query(coll, where('foo', '>', NaN))).to.throw( + "Invalid query. You can only perform '==' and '!=' comparisons on NaN." + ); + }); + + validationIt(persistence, 'rejects invalid Null filter', db => { + const coll = collection(db, 'test'); + expect(() => query(coll, where('foo', '>', null))).to.throw( + "Invalid query. You can only perform '==' and '!=' comparisons on Null." + ); + }); + validationIt(persistence, 'with != and not-in filters fail', db => { expect(() => query( diff --git a/packages/firestore/test/integration/util/helpers.ts b/packages/firestore/test/integration/util/helpers.ts index b36ed980295..7cfe7d3a7a4 100644 --- a/packages/firestore/test/integration/util/helpers.ts +++ b/packages/firestore/test/integration/util/helpers.ts @@ -18,6 +18,7 @@ import { isIndexedDBAvailable } from '@firebase/util'; import { expect } from 'chai'; +import { EventsAccumulator } from './events_accumulator'; import { clearIndexedDbPersistence, collection, @@ -44,7 +45,8 @@ import { Query, getDocsFromServer, getDocsFromCache, - _AutoId + _AutoId, + onSnapshot } from './firebase_export'; import { ALT_PROJECT_ID, @@ -444,10 +446,27 @@ export function withTestCollectionSettings( settings: PrivateSettings, docs: { [key: string]: DocumentData }, fn: (collection: CollectionReference, db: Firestore) => Promise +): Promise { + return withTestProjectIdAndCollectionSettings( + persistence, + DEFAULT_PROJECT_ID, + settings, + docs, + fn + ); +} + +export function withTestProjectIdAndCollectionSettings( + persistence: PersistenceMode | typeof PERSISTENCE_MODE_UNSPECIFIED, + projectId: string, + settings: PrivateSettings, + docs: { [key: string]: DocumentData }, + fn: (collection: CollectionReference, db: Firestore) => Promise ): Promise { const collectionId = _AutoId.newId(); - return batchCommitDocsToCollection( + return batchCommitDocsToCollectionWithSettings( persistence, + projectId, settings, docs, collectionId, @@ -462,10 +481,28 @@ export function batchCommitDocsToCollection( collectionId: string, fn: (collection: CollectionReference, db: Firestore) => Promise ): Promise { - return withTestDbsSettings( + return batchCommitDocsToCollectionWithSettings( persistence, DEFAULT_PROJECT_ID, settings, + docs, + collectionId, + fn + ); +} + +export function batchCommitDocsToCollectionWithSettings( + persistence: PersistenceMode | typeof PERSISTENCE_MODE_UNSPECIFIED, + projectId: string, + settings: PrivateSettings, + docs: { [key: string]: DocumentData }, + collectionId: string, + fn: (collection: CollectionReference, db: Firestore) => Promise +): Promise { + return withTestDbsSettings( + persistence, + projectId, + settings, 2, ([testDb, setupDb]) => { const testCollection = collection(testDb, collectionId); @@ -580,3 +617,77 @@ export async function checkOnlineAndOfflineResultsMatch( expect(expectedDocs).to.deep.equal(toIds(docsFromServer)); } } + +/** + * Asserts that the given query produces the expected result for all of the + * following scenarios: + * 1. Performing the given query using source=server, compare with expected result and populate + * cache. + * 2. Performing the given query using source=cache, compare with server result and expected + * result. + * 3. Using a snapshot listener to raise snapshots from cache and server, compare them with + * expected result. + * @param {firebase.firestore.Query} query The query to test. + * @param {Object>} allData A map of document IDs to their data. + * @param {string[]} expectedDocIds An array of expected document IDs in the result. + * @returns {Promise} A Promise that resolves when the assertions are complete. + */ +export async function assertSDKQueryResultsConsistentWithBackend( + collection: CollectionReference, + query: Query, + allData: { [key: string]: DocumentData }, + expectedDocIds: string[] +): Promise { + // Check the cache round trip first to make sure cache is properly populated, otherwise the + // snapshot listener below will return partial results from previous + // "assertSDKQueryResultsConsistentWithBackend" calls if it is called multiple times in one test + await checkOnlineAndOfflineResultsMatch(collection, query, ...expectedDocIds); + + const eventAccumulator = new EventsAccumulator(); + const unsubscribe = onSnapshot( + query, + { includeMetadataChanges: true }, + eventAccumulator.storeEvent + ); + let watchSnapshots; + try { + watchSnapshots = await eventAccumulator.awaitEvents(2); + } finally { + unsubscribe(); + } + + expect(watchSnapshots[0].metadata.fromCache).to.be.true; + verifySnapshot(watchSnapshots[0], allData, expectedDocIds); + expect(watchSnapshots[1].metadata.fromCache).to.be.false; + verifySnapshot(watchSnapshots[1], allData, expectedDocIds); +} + +/** + * Verifies that a QuerySnapshot matches the expected data and document IDs. + * @param {firebase.firestore.QuerySnapshot} snapshot The QuerySnapshot to verify. + * @param {Object>} allData A map of document IDs to their data. + * @param {string[]} expectedDocIds An array of expected document IDs in the result. + */ +function verifySnapshot( + snapshot: QuerySnapshot, + allData: { [key: string]: DocumentData }, + expectedDocIds: string[] +): void { + const snapshotDocIds = toIds(snapshot); + expect( + expectedDocIds.length === snapshotDocIds.length, + `Did not get the same document size. Expected doc size: ${expectedDocIds.length}, Actual doc size: ${snapshotDocIds.length} ` + ).to.be.true; + + expect( + expectedDocIds.every((id, index) => id === snapshotDocIds[index]), + `Did not get the expected document IDs. Expected doc IDs: ${expectedDocIds}, Actual doc IDs: ${snapshotDocIds} ` + ).to.be.true; + + const actualDocs = toDataMap(snapshot); + for (const docId of expectedDocIds) { + const expectedDoc = allData[docId]; + const actualDoc = actualDocs[docId]; + expect(expectedDoc).to.deep.equal(actualDoc); + } +} diff --git a/packages/firestore/test/lite/integration.test.ts b/packages/firestore/test/lite/integration.test.ts index 780db5f4f9c..25f372bcb95 100644 --- a/packages/firestore/test/lite/integration.test.ts +++ b/packages/firestore/test/lite/integration.test.ts @@ -28,6 +28,9 @@ import { sum, average } from '../../src/lite-api/aggregate'; +import { BsonBinaryData } from '../../src/lite-api/bson_binary_data'; +import { BsonObjectId } from '../../src/lite-api/bson_object_Id'; +import { BsonTimestamp } from '../../src/lite-api/bson_timestamp'; import { Bytes } from '../../src/lite-api/bytes'; import { Firestore, @@ -45,6 +48,9 @@ import { serverTimestamp, vector } from '../../src/lite-api/field_value_impl'; +import { Int32Value } from '../../src/lite-api/int32_value'; +import { MaxKey } from '../../src/lite-api/max_key'; +import { MinKey } from '../../src/lite-api/min_key'; import { endAt, endBefore, @@ -78,6 +84,7 @@ import { setDoc, updateDoc } from '../../src/lite-api/reference_impl'; +import { RegexValue } from '../../src/lite-api/regex_value'; import { FirestoreDataConverter, snapshotEqual, @@ -2960,3 +2967,48 @@ describe('Vectors', () => { }); }); }); + +// eslint-disable-next-line no-restricted-properties +describe.skip('BSON types', () => { + // TODO(Mila/BSON): enable this test once prod supports bson + it('can be read and written using the lite SDK', async () => { + return withTestCollection(async coll => { + const ref = await addDoc(coll, { + objectId: new BsonObjectId('507f191e810c19729de860ea'), + int32: new Int32Value(1), + min: MinKey.instance(), + max: MaxKey.instance(), + regex: new RegexValue('^foo', 'i') + }); + + await setDoc( + ref, + { + binary: new BsonBinaryData(1, new Uint8Array([1, 2, 3])), + timestamp: new BsonTimestamp(1, 2), + int32: new Int32Value(2) + }, + { merge: true } + ); + + const snap1 = await getDoc(ref); + expect( + snap1 + .get('objectId') + .isEqual(new BsonObjectId('507f191e810c19729de860ea')) + ).to.be.true; + expect(snap1.get('int32').isEqual(new Int32Value(2))).to.be.true; + expect(snap1.get('min') === MinKey.instance()).to.be.true; + expect(snap1.get('max') === MaxKey.instance()).to.be.true; + expect( + snap1 + .get('binary') + .isEqual(new BsonBinaryData(1, new Uint8Array([1, 2, 3]))) + ).to.be.true; + expect(snap1.get('timestamp').isEqual(new BsonTimestamp(1, 2))).to.be + .true; + expect(snap1.get('regex').isEqual(new RegexValue('^foo', 'i'))).to.be + .true; + }); + }); +}); diff --git a/packages/firestore/test/unit/index/firestore_index_value_writer.test.ts b/packages/firestore/test/unit/index/firestore_index_value_writer.test.ts index 8daa97eb77d..907881c262c 100644 --- a/packages/firestore/test/unit/index/firestore_index_value_writer.test.ts +++ b/packages/firestore/test/unit/index/firestore_index_value_writer.test.ts @@ -18,11 +18,31 @@ import { expect } from 'chai'; import { FirestoreIndexValueWriter } from '../../../src/index/firestore_index_value_writer'; import { IndexByteEncoder } from '../../../src/index/index_byte_encoder'; +import { BsonBinaryData } from '../../../src/lite-api/bson_binary_data'; +import { BsonObjectId } from '../../../src/lite-api/bson_object_Id'; +import { BsonTimestamp } from '../../../src/lite-api/bson_timestamp'; +import { Int32Value } from '../../../src/lite-api/int32_value'; +import { RegexValue } from '../../../src/lite-api/regex_value'; import { Timestamp } from '../../../src/lite-api/timestamp'; +import { + parseBsonBinaryData, + parseInt32Value, + parseMaxKey, + parseMinKey, + parseBsonObjectId, + parseRegexValue, + parseBsonTimestamp +} from '../../../src/lite-api/user_data_reader'; import { IndexKind } from '../../../src/model/field_index'; import type { Value } from '../../../src/protos/firestore_proto_api'; -import { toTimestamp } from '../../../src/remote/serializer'; -import { JSON_SERIALIZER } from '../local/persistence_test_helpers'; +import { + JsonProtoSerializer, + toTimestamp +} from '../../../src/remote/serializer'; +import { + JSON_SERIALIZER, + TEST_DATABASE_ID +} from '../local/persistence_test_helpers'; import { compare } from './ordered_code_writer.test'; @@ -247,4 +267,355 @@ describe('Firestore Index Value Writer', () => { ).to.equal(1); }); }); + + describe('can gracefully handle BSON types', () => { + it('can compare BSON ObjectIds', () => { + const value1 = { + mapValue: { + fields: { + '__oid__': { stringValue: '507f191e810c19729de860ea' } + } + } + }; + const value2 = { + mapValue: { + fields: { + '__oid__': { stringValue: '507f191e810c19729de860eb' } + } + } + }; + const value3 = parseBsonObjectId( + new BsonObjectId('507f191e810c19729de860ea') + ); + + expect( + compareIndexEncodedValues(value1, value2, IndexKind.ASCENDING) + ).to.equal(-1); + expect( + compareIndexEncodedValues(value2, value1, IndexKind.ASCENDING) + ).to.equal(1); + expect( + compareIndexEncodedValues(value1, value1, IndexKind.ASCENDING) + ).to.equal(0); + + expect( + compareIndexEncodedValues(value3, value2, IndexKind.ASCENDING) + ).to.equal(-1); + expect( + compareIndexEncodedValues(value2, value3, IndexKind.ASCENDING) + ).to.equal(1); + expect( + compareIndexEncodedValues(value3, value1, IndexKind.ASCENDING) + ).to.equal(0); + }); + + it('can compare BSON Timestamps', () => { + const value1 = { + mapValue: { + fields: { + '__request_timestamp__': { + mapValue: { + fields: { + seconds: { integerValue: 1 }, + increment: { integerValue: 2 } + } + } + } + } + } + }; + const value2 = { + mapValue: { + fields: { + '__request_timestamp__': { + mapValue: { + fields: { + seconds: { integerValue: 1 }, + increment: { integerValue: 3 } + } + } + } + } + } + }; + const value3 = parseBsonTimestamp(new BsonTimestamp(1, 2)); + const value4 = parseBsonTimestamp(new BsonTimestamp(2, 1)); + + expect( + compareIndexEncodedValues(value1, value2, IndexKind.ASCENDING) + ).to.equal(-1); + expect( + compareIndexEncodedValues(value2, value1, IndexKind.ASCENDING) + ).to.equal(1); + expect( + compareIndexEncodedValues(value1, value1, IndexKind.ASCENDING) + ).to.equal(0); + + expect( + compareIndexEncodedValues(value3, value1, IndexKind.ASCENDING) + ).to.equal(0); + expect( + compareIndexEncodedValues(value3, value2, IndexKind.ASCENDING) + ).to.equal(-1); + expect( + compareIndexEncodedValues(value2, value3, IndexKind.ASCENDING) + ).to.equal(1); + + expect( + compareIndexEncodedValues(value4, value1, IndexKind.ASCENDING) + ).to.equal(1); + expect( + compareIndexEncodedValues(value4, value2, IndexKind.ASCENDING) + ).to.equal(1); + expect( + compareIndexEncodedValues(value4, value3, IndexKind.ASCENDING) + ).to.equal(1); + }); + + it('can compare BSON Binary', () => { + const value1 = { + mapValue: { + fields: { + '__binary__': { + bytesValue: 'AQECAw==' // 1, 1, 2, 3 + } + } + } + }; + const value2 = { + mapValue: { + fields: { + '__binary__': { + bytesValue: 'AQECBA==' // 1, 1, 2, 4 + } + } + } + }; + + const serializer = new JsonProtoSerializer( + TEST_DATABASE_ID, + /* useProto3Json= */ false + ); + const value3 = parseBsonBinaryData( + serializer, + new BsonBinaryData(1, new Uint8Array([1, 2, 3])) + ); + + const jsonSerializer = new JsonProtoSerializer( + TEST_DATABASE_ID, + /* useProto3Json= */ true + ); + + const value4 = parseBsonBinaryData( + jsonSerializer, + new BsonBinaryData(1, new Uint8Array([1, 2, 3])) + ); + + expect( + compareIndexEncodedValues(value1, value2, IndexKind.ASCENDING) + ).to.equal(-1); + expect( + compareIndexEncodedValues(value2, value1, IndexKind.ASCENDING) + ).to.equal(1); + expect( + compareIndexEncodedValues(value1, value1, IndexKind.ASCENDING) + ).to.equal(0); + + expect( + compareIndexEncodedValues(value3, value2, IndexKind.ASCENDING) + ).to.equal(-1); + expect( + compareIndexEncodedValues(value2, value3, IndexKind.ASCENDING) + ).to.equal(1); + expect( + compareIndexEncodedValues(value3, value1, IndexKind.ASCENDING) + ).to.equal(0); + + expect( + compareIndexEncodedValues(value4, value2, IndexKind.ASCENDING) + ).to.equal(-1); + expect( + compareIndexEncodedValues(value2, value4, IndexKind.ASCENDING) + ).to.equal(1); + expect( + compareIndexEncodedValues(value4, value1, IndexKind.ASCENDING) + ).to.equal(0); + }); + + it('can compare BSON Regex', () => { + const value1 = { + mapValue: { + fields: { + '__regex__': { + mapValue: { + fields: { + 'pattern': { stringValue: '^foo' }, + 'options': { stringValue: 'i' } + } + } + } + } + } + }; + const value2 = { + mapValue: { + fields: { + '__regex__': { + mapValue: { + fields: { + 'pattern': { stringValue: '^foo' }, + 'options': { stringValue: 'm' } + } + } + } + } + } + }; + const value3 = parseRegexValue(new RegexValue('^foo', 'i')); + const value4 = parseRegexValue(new RegexValue('^zoo', 'i')); + + expect( + compareIndexEncodedValues(value1, value2, IndexKind.ASCENDING) + ).to.equal(-1); + expect( + compareIndexEncodedValues(value2, value1, IndexKind.ASCENDING) + ).to.equal(1); + expect( + compareIndexEncodedValues(value1, value1, IndexKind.ASCENDING) + ).to.equal(0); + + expect( + compareIndexEncodedValues(value3, value2, IndexKind.ASCENDING) + ).to.equal(-1); + expect( + compareIndexEncodedValues(value2, value3, IndexKind.ASCENDING) + ).to.equal(1); + expect( + compareIndexEncodedValues(value3, value1, IndexKind.ASCENDING) + ).to.equal(0); + + expect( + compareIndexEncodedValues(value4, value1, IndexKind.ASCENDING) + ).to.equal(1); + expect( + compareIndexEncodedValues(value4, value2, IndexKind.ASCENDING) + ).to.equal(1); + expect( + compareIndexEncodedValues(value4, value3, IndexKind.ASCENDING) + ).to.equal(1); + }); + + it('can compare BSON Int32', () => { + const value1 = { + mapValue: { + fields: { + '__int__': { integerValue: 1 } + } + } + }; + const value2 = { + mapValue: { + fields: { + '__int__': { integerValue: 2 } + } + } + }; + const value3 = parseInt32Value(new Int32Value(1)); + const value4 = parseInt32Value(new Int32Value(2)); + + expect( + compareIndexEncodedValues(value1, value2, IndexKind.ASCENDING) + ).to.equal(-1); + expect( + compareIndexEncodedValues(value2, value1, IndexKind.ASCENDING) + ).to.equal(1); + expect( + compareIndexEncodedValues(value1, value1, IndexKind.ASCENDING) + ).to.equal(0); + + expect( + compareIndexEncodedValues(value3, value2, IndexKind.ASCENDING) + ).to.equal(-1); + expect( + compareIndexEncodedValues(value2, value3, IndexKind.ASCENDING) + ).to.equal(1); + expect( + compareIndexEncodedValues(value3, value1, IndexKind.ASCENDING) + ).to.equal(0); + + expect( + compareIndexEncodedValues(value4, value1, IndexKind.ASCENDING) + ).to.equal(1); + expect( + compareIndexEncodedValues(value4, value2, IndexKind.ASCENDING) + ).to.equal(0); + expect( + compareIndexEncodedValues(value4, value3, IndexKind.ASCENDING) + ).to.equal(1); + }); + + it('can compare BSON MinKey', () => { + const value1 = { + mapValue: { + fields: { + '__min__': { + nullValue: 'NULL_VALUE' as const + } + } + } + }; + const value2 = { + mapValue: { + fields: { + '__min__': { + nullValue: 'NULL_VALUE' as const + } + } + } + }; + const value3 = parseMinKey(); + + expect( + compareIndexEncodedValues(value1, value2, IndexKind.ASCENDING) + ).to.equal(0); + expect( + compareIndexEncodedValues(value1, value3, IndexKind.DESCENDING) + ).to.equal(0); + expect( + compareIndexEncodedValues(value1, value1, IndexKind.ASCENDING) + ).to.equal(0); + }); + + it('can compare BSON MaxKey', () => { + const value1 = { + mapValue: { + fields: { + '__max__': { + nullValue: 'NULL_VALUE' as const + } + } + } + }; + const value2 = { + mapValue: { + fields: { + '__max__': { + nullValue: 'NULL_VALUE' as const + } + } + } + }; + const value3 = parseMaxKey(); + + expect( + compareIndexEncodedValues(value1, value2, IndexKind.ASCENDING) + ).to.equal(0); + expect( + compareIndexEncodedValues(value1, value3, IndexKind.DESCENDING) + ).to.equal(0); + expect( + compareIndexEncodedValues(value1, value1, IndexKind.ASCENDING) + ).to.equal(0); + }); + }); }); diff --git a/packages/firestore/test/unit/local/index_manager.test.ts b/packages/firestore/test/unit/local/index_manager.test.ts index 2521be99bf5..b6af448b2db 100644 --- a/packages/firestore/test/unit/local/index_manager.test.ts +++ b/packages/firestore/test/unit/local/index_manager.test.ts @@ -17,6 +17,17 @@ import { expect } from 'chai'; +import { + BsonBinaryData, + BsonObjectId, + BsonTimestamp, + Bytes, + GeoPoint, + Int32Value, + MaxKey, + MinKey, + RegexValue +} from '../../../src/'; import { User } from '../../../src/auth/user'; import { FieldFilter } from '../../../src/core/filter'; import { @@ -71,6 +82,7 @@ import { orFilter, path, query, + ref, version, wrap } from '../../util/helpers'; @@ -327,6 +339,14 @@ describe('IndexedDbIndexManager', async () => { await addDoc('coll/doc2', {}); }); + it('adds string', async () => { + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['exists', IndexKind.ASCENDING]] }) + ); + await addDoc('coll/doc1', { 'exists': 'a' }); + await addDoc('coll/doc2', { 'exists': 'b' }); + }); + it('applies orderBy', async () => { await indexManager.addFieldIndex( fieldIndex('coll', { fields: [['count', IndexKind.ASCENDING]] }) @@ -1856,6 +1876,652 @@ describe('IndexedDbIndexManager', async () => { await validateIsNoneIndex(query2); }); + describe('BSON type indexing', () => { + it('can index BSON ObjectId fields', async () => { + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['key', IndexKind.ASCENDING]] }) + ); + + await addDoc('coll/doc1', { + key: new BsonObjectId('507f191e810c19729de860ea') + }); + await addDoc('coll/doc2', { + key: new BsonObjectId('507f191e810c19729de860eb') + }); + await addDoc('coll/doc3', { + key: new BsonObjectId('507f191e810c19729de860ec') + }); + + const fieldIndexes = await indexManager.getFieldIndexes('coll'); + expect(fieldIndexes).to.have.length(1); + + let q = queryWithAddedOrderBy(query('coll'), orderBy('key')); + await verifyResults(q, 'coll/doc1', 'coll/doc2', 'coll/doc3'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '==', new BsonObjectId('507f191e810c19729de860ea')) + ); + await verifyResults(q, 'coll/doc1'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '!=', new BsonObjectId('507f191e810c19729de860ea')) + ); + await verifyResults(q, 'coll/doc2', 'coll/doc3'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '>=', new BsonObjectId('507f191e810c19729de860eb')) + ); + await verifyResults(q, 'coll/doc2', 'coll/doc3'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '<=', new BsonObjectId('507f191e810c19729de860eb')) + ); + await verifyResults(q, 'coll/doc1', 'coll/doc2'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '>', new BsonObjectId('507f191e810c19729de860eb')) + ); + await verifyResults(q, 'coll/doc3'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '<', new BsonObjectId('507f191e810c19729de860eb')) + ); + await verifyResults(q, 'coll/doc1'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '>', new BsonObjectId('507f191e810c19729de860ec')) + ); + await verifyResults(q); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '<', new BsonObjectId('507f191e810c19729de860ea')) + ); + await verifyResults(q); + }); + + it('can index BSON Binary Data fields', async () => { + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['key', IndexKind.ASCENDING]] }) + ); + await addDoc('coll/doc1', { + key: new BsonBinaryData(1, new Uint8Array([1, 2, 3])) + }); + await addDoc('coll/doc2', { + key: new BsonBinaryData(1, new Uint8Array([1, 2, 4])) + }); + await addDoc('coll/doc3', { + key: new BsonBinaryData(1, new Uint8Array([2, 1, 2])) + }); + + const fieldIndexes = await indexManager.getFieldIndexes('coll'); + expect(fieldIndexes).to.have.length(1); + + let q = queryWithAddedOrderBy(query('coll'), orderBy('key')); + await verifyResults(q, 'coll/doc1', 'coll/doc2', 'coll/doc3'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '==', new BsonBinaryData(1, new Uint8Array([1, 2, 3]))) + ); + await verifyResults(q, 'coll/doc1'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '!=', new BsonBinaryData(1, new Uint8Array([1, 2, 3]))) + ); + await verifyResults(q, 'coll/doc2', 'coll/doc3'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '>=', new BsonBinaryData(1, new Uint8Array([1, 2, 4]))) + ); + await verifyResults(q, 'coll/doc2', 'coll/doc3'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '<=', new BsonBinaryData(1, new Uint8Array([1, 2, 4]))) + ); + await verifyResults(q, 'coll/doc1', 'coll/doc2'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '>', new BsonBinaryData(1, new Uint8Array([1, 2, 4]))) + ); + await verifyResults(q, 'coll/doc3'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '<', new BsonBinaryData(1, new Uint8Array([1, 2, 4]))) + ); + await verifyResults(q, 'coll/doc1'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '>', new BsonBinaryData(1, new Uint8Array([2, 1, 2]))) + ); + await verifyResults(q); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '<', new BsonBinaryData(1, new Uint8Array([1, 2, 3]))) + ); + await verifyResults(q); + }); + + it('can index BSON Timestamp fields', async () => { + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['key', IndexKind.ASCENDING]] }) + ); + await addDoc('coll/doc1', { + key: new BsonTimestamp(1, 1) + }); + await addDoc('coll/doc2', { + key: new BsonTimestamp(1, 2) + }); + await addDoc('coll/doc3', { + key: new BsonTimestamp(2, 1) + }); + + const fieldIndexes = await indexManager.getFieldIndexes('coll'); + expect(fieldIndexes).to.have.length(1); + + let q = queryWithAddedOrderBy(query('coll'), orderBy('key')); + await verifyResults(q, 'coll/doc1', 'coll/doc2', 'coll/doc3'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '==', new BsonTimestamp(1, 1)) + ); + await verifyResults(q, 'coll/doc1'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '!=', new BsonTimestamp(1, 1)) + ); + await verifyResults(q, 'coll/doc2', 'coll/doc3'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '>=', new BsonTimestamp(1, 2)) + ); + await verifyResults(q, 'coll/doc2', 'coll/doc3'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '<=', new BsonTimestamp(1, 2)) + ); + await verifyResults(q, 'coll/doc1', 'coll/doc2'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '>', new BsonTimestamp(1, 2)) + ); + await verifyResults(q, 'coll/doc3'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '<', new BsonTimestamp(1, 2)) + ); + await verifyResults(q, 'coll/doc1'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '>', new BsonTimestamp(2, 1)) + ); + await verifyResults(q); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '<', new BsonTimestamp(1, 1)) + ); + await verifyResults(q); + }); + + it('can index Int32 fields', async () => { + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['key', IndexKind.ASCENDING]] }) + ); + await addDoc('coll/doc1', { + key: new Int32Value(1) + }); + await addDoc('coll/doc2', { + key: new Int32Value(2) + }); + await addDoc('coll/doc3', { + key: new Int32Value(3) + }); + const fieldIndexes = await indexManager.getFieldIndexes('coll'); + expect(fieldIndexes).to.have.length(1); + + let q = queryWithAddedOrderBy(query('coll'), orderBy('key')); + await verifyResults(q, 'coll/doc1', 'coll/doc2', 'coll/doc3'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '==', new Int32Value(1)) + ); + await verifyResults(q, 'coll/doc1'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '!=', new Int32Value(1)) + ); + await verifyResults(q, 'coll/doc2', 'coll/doc3'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '>=', new Int32Value(2)) + ); + await verifyResults(q, 'coll/doc2', 'coll/doc3'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '<=', new Int32Value(2)) + ); + await verifyResults(q, 'coll/doc1', 'coll/doc2'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '>', new Int32Value(2)) + ); + await verifyResults(q, 'coll/doc3'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '<', new Int32Value(2)) + ); + await verifyResults(q, 'coll/doc1'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '>', new Int32Value(3)) + ); + await verifyResults(q); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '<', new Int32Value(1)) + ); + await verifyResults(q); + }); + + it('can index regex fields', async () => { + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['key', IndexKind.ASCENDING]] }) + ); + await addDoc('coll/doc1', { + key: new RegexValue('a', 'i') + }); + await addDoc('coll/doc2', { + key: new RegexValue('a', 'm') + }); + await addDoc('coll/doc3', { + key: new RegexValue('b', 'i') + }); + const fieldIndexes = await indexManager.getFieldIndexes('coll'); + expect(fieldIndexes).to.have.length(1); + let q = queryWithAddedOrderBy(query('coll'), orderBy('key')); + await verifyResults(q, 'coll/doc1', 'coll/doc2', 'coll/doc3'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '==', new RegexValue('a', 'i')) + ); + await verifyResults(q, 'coll/doc1'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '!=', new RegexValue('a', 'i')) + ); + await verifyResults(q, 'coll/doc2', 'coll/doc3'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '>=', new RegexValue('a', 'm')) + ); + await verifyResults(q, 'coll/doc2', 'coll/doc3'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '<=', new RegexValue('a', 'm')) + ); + await verifyResults(q, 'coll/doc1', 'coll/doc2'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '>', new RegexValue('a', 'm')) + ); + await verifyResults(q, 'coll/doc3'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '<', new RegexValue('a', 'm')) + ); + await verifyResults(q, 'coll/doc1'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '>', new RegexValue('b', 'i')) + ); + await verifyResults(q); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '<', new RegexValue('a', 'i')) + ); + await verifyResults(q); + }); + + it('can index minKey fields', async () => { + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['key', IndexKind.ASCENDING]] }) + ); + await addDoc('coll/doc1', { + key: MinKey.instance() + }); + await addDoc('coll/doc2', { + key: MinKey.instance() + }); + await addDoc('coll/doc3', { + key: null + }); + await addDoc('coll/doc4', { + key: 1 + }); + await addDoc('coll/doc5', { + key: MaxKey.instance() + }); + + const fieldIndexes = await indexManager.getFieldIndexes('coll'); + expect(fieldIndexes).to.have.length(1); + + let q = queryWithAddedOrderBy(query('coll'), orderBy('key')); + await verifyResults( + q, + 'coll/doc3', + 'coll/doc1', + 'coll/doc2', + 'coll/doc4', + 'coll/doc5' + ); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '==', MinKey.instance()) + ); + await verifyResults(q, 'coll/doc1', 'coll/doc2'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '!=', MinKey.instance()) + ); + await verifyResults(q, 'coll/doc4', 'coll/doc5'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '>=', MinKey.instance()) + ); + await verifyResults(q, 'coll/doc1', 'coll/doc2'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '<=', MinKey.instance()) + ); + await verifyResults(q, 'coll/doc1', 'coll/doc2'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '>', MinKey.instance()) + ); + await verifyResults(q); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '<', MinKey.instance()) + ); + await verifyResults(q); + }); + + it('can index maxKey fields', async () => { + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['key', IndexKind.ASCENDING]] }) + ); + await addDoc('coll/doc1', { + key: MinKey.instance() + }); + await addDoc('coll/doc2', { + key: 1 + }); + await addDoc('coll/doc3', { + key: MaxKey.instance() + }); + await addDoc('coll/doc4', { + key: MaxKey.instance() + }); + await addDoc('coll/doc5', { + key: null + }); + + const fieldIndexes = await indexManager.getFieldIndexes('coll'); + expect(fieldIndexes).to.have.length(1); + + let q = queryWithAddedOrderBy(query('coll'), orderBy('key')); + await verifyResults( + q, + 'coll/doc5', + 'coll/doc1', + 'coll/doc2', + 'coll/doc3', + 'coll/doc4' + ); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '==', MaxKey.instance()) + ); + await verifyResults(q, 'coll/doc3', 'coll/doc4'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '!=', MaxKey.instance()) + ); + await verifyResults(q, 'coll/doc1', 'coll/doc2'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '>=', MaxKey.instance()) + ); + await verifyResults(q, 'coll/doc3', 'coll/doc4'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '<=', MaxKey.instance()) + ); + await verifyResults(q, 'coll/doc3', 'coll/doc4'); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '>', MaxKey.instance()) + ); + await verifyResults(q); + + q = queryWithAddedFilter( + query('coll'), + filter('key', '<', MaxKey.instance()) + ); + await verifyResults(q); + }); + + it('can index fields of BSON types together', async () => { + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['key', IndexKind.DESCENDING]] }) + ); + await addDoc('coll/doc1', { + key: MinKey.instance() + }); + + await addDoc('coll/doc2', { + key: new Int32Value(2) + }); + await addDoc('coll/doc3', { + key: new Int32Value(1) + }); + + await addDoc('coll/doc4', { + key: new BsonTimestamp(1, 2) + }); + await addDoc('coll/doc5', { + key: new BsonTimestamp(1, 1) + }); + + await addDoc('coll/doc6', { + key: new BsonBinaryData(1, new Uint8Array([1, 2, 4])) + }); + await addDoc('coll/doc7', { + key: new BsonBinaryData(1, new Uint8Array([1, 2, 3])) + }); + await addDoc('coll/doc8', { + key: new BsonObjectId('507f191e810c19729de860eb') + }); + await addDoc('coll/doc9', { + key: new BsonObjectId('507f191e810c19729de860ea') + }); + + await addDoc('coll/doc10', { + key: new RegexValue('a', 'm') + }); + await addDoc('coll/doc11', { + key: new RegexValue('a', 'i') + }); + + await addDoc('coll/doc12', { + key: MaxKey.instance() + }); + + const fieldIndexes = await indexManager.getFieldIndexes('coll'); + expect(fieldIndexes).to.have.length(1); + + const q = queryWithAddedOrderBy(query('coll'), orderBy('key', 'desc')); + await verifyResults( + q, + 'coll/doc12', + 'coll/doc10', + 'coll/doc11', + 'coll/doc8', + 'coll/doc9', + 'coll/doc6', + 'coll/doc7', + 'coll/doc4', + 'coll/doc5', + 'coll/doc2', + 'coll/doc3', + 'coll/doc1' + ); + }); + }); + + it('can index fields of all types together', async () => { + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['key', IndexKind.DESCENDING]] }) + ); + await addDoc('coll/doc1', { + key: null + }); + await addDoc('coll/doc2', { + key: MinKey.instance() + }); + await addDoc('coll/doc3', { + key: true + }); + await addDoc('coll/doc4', { + key: NaN + }); + await addDoc('coll/doc5', { + key: new Int32Value(1) + }); + await addDoc('coll/doc6', { + key: 2.0 + }); + await addDoc('coll/doc7', { + key: 3 + }); + await addDoc('coll/doc8', { + key: new Timestamp(100, 123456000) + }); + await addDoc('coll/doc9', { + key: new BsonTimestamp(1, 2) + }); + await addDoc('coll/doc10', { + key: 'string' + }); + await addDoc('coll/doc11', { + key: Bytes.fromUint8Array(new Uint8Array([0, 1, 255])) as Bytes + }); + await addDoc('coll/doc12', { + key: new BsonBinaryData(1, new Uint8Array([1, 2, 3])) + }); + await addDoc('coll/doc13', { + key: ref('coll/doc') + }); + await addDoc('coll/doc14', { + key: new BsonObjectId('507f191e810c19729de860ea') + }); + await addDoc('coll/doc15', { + key: new GeoPoint(0, 1) + }); + await addDoc('coll/doc16', { + key: new RegexValue('^foo', 'i') + }); + await addDoc('coll/doc17', { + key: [1, 2] + }); + await addDoc('coll/doc18', { + key: vector([1, 2]) + }); + await addDoc('coll/doc19', { + key: { a: 1 } + }); + await addDoc('coll/doc20', { + key: MaxKey.instance() + }); + + const fieldIndexes = await indexManager.getFieldIndexes('coll'); + expect(fieldIndexes).to.have.length(1); + + const q = queryWithAddedOrderBy(query('coll'), orderBy('key', 'desc')); + await verifyResults( + q, + 'coll/doc20', + 'coll/doc19', + 'coll/doc18', + 'coll/doc17', + 'coll/doc16', + 'coll/doc15', + 'coll/doc14', + 'coll/doc13', + 'coll/doc12', + 'coll/doc11', + 'coll/doc10', + 'coll/doc9', + 'coll/doc8', + 'coll/doc7', + 'coll/doc6', + 'coll/doc5', + 'coll/doc4', + 'coll/doc3', + 'coll/doc2', + 'coll/doc1' + ); + }); + async function validateIsPartialIndex(query: Query): Promise { await validateIndexType(query, IndexType.PARTIAL); } diff --git a/packages/firestore/test/unit/local/local_store_indexeddb.test.ts b/packages/firestore/test/unit/local/local_store_indexeddb.test.ts index 6f0275ab4ad..5f68684d193 100644 --- a/packages/firestore/test/unit/local/local_store_indexeddb.test.ts +++ b/packages/firestore/test/unit/local/local_store_indexeddb.test.ts @@ -18,17 +18,30 @@ import { isIndexedDBAvailable } from '@firebase/util'; import { expect } from 'chai'; -import { serverTimestamp, Timestamp } from '../../../src'; +import { + serverTimestamp, + Timestamp, + GeoPoint, + BsonObjectId, + BsonBinaryData, + BsonTimestamp, + Int32Value, + RegexValue, + MaxKey, + MinKey +} from '../../../src'; import { User } from '../../../src/auth/user'; import { BundleConverterImpl } from '../../../src/core/bundle_impl'; import { LimitType, + newQueryComparator, Query, queryToTarget, queryWithLimit } from '../../../src/core/query'; import { Target } from '../../../src/core/target'; import { TargetId } from '../../../src/core/types'; +import { vector } from '../../../src/lite-api/field_value_impl'; import { IndexBackfiller } from '../../../src/local/index_backfiller'; import { LocalStore } from '../../../src/local/local_store'; import { @@ -44,6 +57,7 @@ import { } from '../../../src/local/local_store_impl'; import { Persistence } from '../../../src/local/persistence'; import { DocumentMap } from '../../../src/model/collections'; +import { Document } from '../../../src/model/document'; import { DocumentKey } from '../../../src/model/document_key'; import { FieldIndex, @@ -53,6 +67,7 @@ import { import { Mutation, MutationType } from '../../../src/model/mutation'; import { MutationBatch } from '../../../src/model/mutation_batch'; import { RemoteEvent } from '../../../src/remote/remote_event'; +import { SortedSet } from '../../../src/util/sorted_set'; import { deletedDoc, deleteMutation, @@ -65,8 +80,10 @@ import { orderBy, orFilter, query, + ref, setMutation, - version + version, + blob } from '../../util/helpers'; import { CountingQueryEngine } from './counting_query_engine'; @@ -208,11 +225,20 @@ class AsyncLocalStoreTester { } } - assertQueryReturned(...keys: string[]): void { + assertQueryReturned(query: Query, ...keys: string[]): void { expect(this.lastChanges).to.exist; - for (const k of keys) { - expect(this.lastChanges?.get(key(k))).to.exist; - } + expect(this.lastChanges?.size === keys.length).to.be.true; + + // lastChanges is a DocumentMap sorted by document keys. Re-sort the documents by the query comparator. + let returnedDocs = new SortedSet(newQueryComparator(query)); + this.lastChanges!.forEach((key, doc) => { + returnedDocs = returnedDocs.add(doc); + }); + + let i = 0; + returnedDocs.forEach(doc => { + expect(keys[i++]).to.equal(doc.key.path.toString()); + }); } async backfillIndexes(config?: { @@ -331,7 +357,7 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { await test.executeQuery(queryMatches); test.assertRemoteDocumentsRead(1, 0); - test.assertQueryReturned('coll/a'); + test.assertQueryReturned(queryMatches, 'coll/a'); await test.applyRemoteEvent( docUpdateRemoteEvent(deletedDoc('coll/a', 0), [targetId]) @@ -340,7 +366,7 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { // No backfill needed for deleted document. await test.executeQuery(queryMatches); test.assertRemoteDocumentsRead(0, 0); - test.assertQueryReturned(); + test.assertQueryReturned(queryMatches); }); it('Uses Indexes', async () => { @@ -360,7 +386,7 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { await test.executeQuery(queryMatches); test.assertRemoteDocumentsRead(1, 0); - test.assertQueryReturned('coll/a'); + test.assertQueryReturned(queryMatches, 'coll/a'); }); it('Uses Partially Indexed Remote Documents When Available', async () => { @@ -384,7 +410,7 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { await test.executeQuery(queryMatches); test.assertRemoteDocumentsRead(1, 1); - test.assertQueryReturned('coll/a', 'coll/b'); + test.assertQueryReturned(queryMatches, 'coll/a', 'coll/b'); }); it('Uses Partially Indexed Overlays When Available', async () => { @@ -405,7 +431,7 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { [key('coll/a').toString()]: MutationType.Set, [key('coll/b').toString()]: MutationType.Set }); - test.assertQueryReturned('coll/a', 'coll/b'); + test.assertQueryReturned(queryMatches, 'coll/a', 'coll/b'); }); it('Does Not Use Limit When Index Is Outdated', async () => { @@ -443,7 +469,7 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { test.assertOverlaysRead(5, 1, { [key('coll/b').toString()]: MutationType.Delete }); - test.assertQueryReturned('coll/a', 'coll/c'); + test.assertQueryReturned(queryCount, 'coll/a', 'coll/c'); }); it('Uses Index For Limit Query When Index Is Updated', async () => { @@ -476,7 +502,7 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { await test.executeQuery(queryCount); test.assertRemoteDocumentsRead(2, 0); test.assertOverlaysRead(2, 0, {}); - test.assertQueryReturned('coll/a', 'coll/c'); + test.assertQueryReturned(queryCount, 'coll/a', 'coll/c'); }); it('Indexes Server Timestamps', async () => { @@ -496,7 +522,7 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { test.assertOverlaysRead(1, 0, { [key('coll/a').toString()]: MutationType.Set }); - test.assertQueryReturned('coll/a'); + test.assertQueryReturned(queryTime, 'coll/a'); }); it('can auto-create indexes', async () => { @@ -522,7 +548,7 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { // Full matched index should be created. await test.executeQuery(query_); test.assertRemoteDocumentsRead(0, 2); - test.assertQueryReturned('coll/a', 'coll/e'); + test.assertQueryReturned(query_, 'coll/a', 'coll/e'); await test.backfillIndexes(); @@ -532,7 +558,7 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { await test.executeQuery(query_); test.assertRemoteDocumentsRead(2, 1); - test.assertQueryReturned('coll/a', 'coll/e', 'coll/f'); + test.assertQueryReturned(query_, 'coll/a', 'coll/e', 'coll/f'); }); it('can auto-create indexes works with or query', async () => { @@ -561,7 +587,7 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { // Full matched index should be created. await test.executeQuery(query_); test.assertRemoteDocumentsRead(0, 2); - test.assertQueryReturned('coll/a', 'coll/e'); + test.assertQueryReturned(query_, 'coll/a', 'coll/e'); await test.backfillIndexes(); @@ -571,7 +597,7 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { await test.executeQuery(query_); test.assertRemoteDocumentsRead(2, 1); - test.assertQueryReturned('coll/a', 'coll/e', 'coll/f'); + test.assertQueryReturned(query_, 'coll/a', 'coll/e', 'coll/f'); }); it('does not auto-create indexes for small collections', async () => { @@ -597,7 +623,7 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { // SDK will not create indexes since collection size is too small. await test.executeQuery(query_); test.assertRemoteDocumentsRead(0, 2); - test.assertQueryReturned('coll/a', 'coll/e'); + test.assertQueryReturned(query_, 'coll/e', 'coll/a'); await test.backfillIndexes(); @@ -607,7 +633,7 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { await test.executeQuery(query_); test.assertRemoteDocumentsRead(0, 3); - test.assertQueryReturned('coll/a', 'coll/e', 'coll/f'); + test.assertQueryReturned(query_, 'coll/e', 'coll/f', 'coll/a'); }); it('does not auto create indexes when index lookup is expensive', async () => { @@ -632,7 +658,7 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { // SDK will not create indexes since relative read cost is too large. await test.executeQuery(query_); test.assertRemoteDocumentsRead(0, 2); - test.assertQueryReturned('coll/a', 'coll/e'); + test.assertQueryReturned(query_, 'coll/a', 'coll/e'); await test.backfillIndexes(); @@ -642,7 +668,7 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { await test.executeQuery(query_); test.assertRemoteDocumentsRead(0, 3); - test.assertQueryReturned('coll/a', 'coll/e', 'coll/f'); + test.assertQueryReturned(query_, 'coll/a', 'coll/e', 'coll/f'); }); it('index auto creation works when backfiller runs halfway', async () => { @@ -680,7 +706,7 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { // Full matched index should be created. await test.executeQuery(query_); test.assertRemoteDocumentsRead(0, 2); - test.assertQueryReturned('coll/a', 'coll/e'); + test.assertQueryReturned(query_, 'coll/a', 'coll/e'); await test.backfillIndexes({ maxDocumentsToProcess: 2 }); @@ -692,7 +718,7 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { await test.executeQuery(query_); test.assertRemoteDocumentsRead(1, 2); - test.assertQueryReturned('coll/a', 'coll/e', 'coll/f'); + test.assertQueryReturned(query_, 'coll/a', 'coll/f', 'coll/e'); }); it('index created by index auto creation exists after turn off auto creation', async () => { @@ -718,7 +744,7 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { // Full matched index should be created. await test.executeQuery(query_); test.assertRemoteDocumentsRead(0, 2); - test.assertQueryReturned('coll/a', 'coll/e'); + test.assertQueryReturned(query_, 'coll/e', 'coll/a'); test.configureIndexAutoCreation({ isEnabled: false }); await test.backfillIndexes(); @@ -729,7 +755,7 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { await test.executeQuery(query_); test.assertRemoteDocumentsRead(2, 1); - test.assertQueryReturned('coll/a', 'coll/e', 'coll/f'); + test.assertQueryReturned(query_, 'coll/e', 'coll/a', 'coll/f'); }); it('disable index auto creation works', async () => { @@ -757,13 +783,13 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { // Full matched index should be created. await test.executeQuery(query1); test.assertRemoteDocumentsRead(0, 2); - test.assertQueryReturned('coll/a', 'coll/e'); + test.assertQueryReturned(query1, 'coll/a', 'coll/e'); test.configureIndexAutoCreation({ isEnabled: false }); await test.backfillIndexes(); await test.executeQuery(query1); test.assertRemoteDocumentsRead(2, 0); - test.assertQueryReturned('coll/a', 'coll/e'); + test.assertQueryReturned(query1, 'coll/a', 'coll/e'); const targetId2 = await test.allocateQuery(query2); await test.applyRemoteEvents( @@ -776,14 +802,14 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { await test.executeQuery(query2); test.assertRemoteDocumentsRead(0, 2); - test.assertQueryReturned('foo/a', 'foo/e'); + test.assertQueryReturned(query2, 'foo/a', 'foo/e'); await test.backfillIndexes(); // Run the query in second time, test index won't be created await test.executeQuery(query2); test.assertRemoteDocumentsRead(0, 2); - test.assertQueryReturned('foo/a', 'foo/e'); + test.assertQueryReturned(query2, 'foo/a', 'foo/e'); }); it('index auto creation works with mutation', async () => { @@ -811,7 +837,7 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { await test.executeQuery(query_); test.assertRemoteDocumentsRead(0, 2); - test.assertQueryReturned('coll/a', 'coll/e'); + test.assertQueryReturned(query_, 'coll/a', 'coll/e'); await test.writeMutations(deleteMutation('coll/e')); await test.backfillIndexes(); @@ -820,7 +846,7 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { await test.executeQuery(query_); test.assertRemoteDocumentsRead(1, 0); test.assertOverlaysRead(1, 1); - test.assertQueryReturned('coll/a', 'coll/f'); + test.assertQueryReturned(query_, 'coll/a', 'coll/f'); }); it('delete all indexes works with index auto creation', async () => { @@ -847,24 +873,24 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { // Full matched index should be created. await test.executeQuery(query_); test.assertRemoteDocumentsRead(0, 2); - test.assertQueryReturned('coll/a', 'coll/e'); + test.assertQueryReturned(query_, 'coll/a', 'coll/e'); await test.backfillIndexes(); await test.executeQuery(query_); test.assertRemoteDocumentsRead(2, 0); - test.assertQueryReturned('coll/a', 'coll/e'); + test.assertQueryReturned(query_, 'coll/a', 'coll/e'); await test.deleteAllFieldIndexes(); await test.executeQuery(query_); test.assertRemoteDocumentsRead(0, 2); - test.assertQueryReturned('coll/a', 'coll/e'); + test.assertQueryReturned(query_, 'coll/a', 'coll/e'); // Field index is created again. await test.backfillIndexes(); await test.executeQuery(query_); test.assertRemoteDocumentsRead(2, 0); - test.assertQueryReturned('coll/a', 'coll/e'); + test.assertQueryReturned(query_, 'coll/a', 'coll/e'); }); it('delete all indexes works with manual added indexes', async () => { @@ -884,13 +910,13 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { await test.executeQuery(query_); test.assertRemoteDocumentsRead(1, 0); - test.assertQueryReturned('coll/a'); + test.assertQueryReturned(query_, 'coll/a'); await test.deleteAllFieldIndexes(); await test.executeQuery(query_); test.assertRemoteDocumentsRead(0, 1); - test.assertQueryReturned('coll/a'); + test.assertQueryReturned(query_, 'coll/a'); }); it('index auto creation does not work with multiple inequality', async () => { @@ -930,11 +956,762 @@ describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { // support multiple inequality. await test.executeQuery(query_); test.assertRemoteDocumentsRead(0, 2); - test.assertQueryReturned('coll/a', 'coll/e'); + test.assertQueryReturned(query_, 'coll/a', 'coll/e'); await test.backfillIndexes(); await test.executeQuery(query_); test.assertRemoteDocumentsRead(0, 2); - test.assertQueryReturned('coll/a', 'coll/e'); + test.assertQueryReturned(query_, 'coll/a', 'coll/e'); + }); + + describe('BSON type indexing', () => { + it('Indexes BSON ObjectId fields', async () => { + const index = fieldIndex('coll', { + id: 1, + fields: [['key', IndexKind.ASCENDING]] + }); + await test.configureFieldsIndexes(index); + + await test.writeMutations( + setMutation('coll/a', { + key: new BsonObjectId('507f191e810c19729de860ea') + }), + setMutation('coll/b', { + key: new BsonObjectId('507f191e810c19729de860eb') + }), + setMutation('coll/c', { + key: new BsonObjectId('507f191e810c19729de860ec') + }) + ); + await test.backfillIndexes(); + + let query_ = query('coll', orderBy('key', 'asc')); + await test.executeQuery(query_); + test.assertOverlaysRead(3, 0, { + [key('coll/a').toString()]: MutationType.Set, + [key('coll/b').toString()]: MutationType.Set, + [key('coll/c').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/a', 'coll/b', 'coll/c'); + + query_ = query( + 'coll', + filter('key', '==', new BsonObjectId('507f191e810c19729de860ea')) + ); + await test.executeQuery(query_); + test.assertOverlaysRead(1, 0, { + [key('coll/a').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/a'); + + query_ = query( + 'coll', + filter('key', '!=', new BsonObjectId('507f191e810c19729de860ea')) + ); + await test.executeQuery(query_); + test.assertOverlaysRead(2, 0, { + [key('coll/b').toString()]: MutationType.Set, + [key('coll/c').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/b', 'coll/c'); + + query_ = query( + 'coll', + filter('key', '>=', new BsonObjectId('507f191e810c19729de860eb')) + ); + await test.executeQuery(query_); + test.assertOverlaysRead(2, 0, { + [key('coll/b').toString()]: MutationType.Set, + [key('coll/c').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/b', 'coll/c'); + + query_ = query( + 'coll', + filter('key', '<', new BsonObjectId('507f191e810c19729de860ea')) + ); + await test.executeQuery(query_); + test.assertOverlaysRead(0, 0); + test.assertQueryReturned(query_); + + query_ = query( + 'coll', + filter('key', 'in', [ + new BsonObjectId('507f191e810c19729de860ea'), + new BsonObjectId('507f191e810c19729de860eb') + ]) + ); + await test.executeQuery(query_); + test.assertOverlaysRead(2, 0, { + [key('coll/a').toString()]: MutationType.Set, + [key('coll/b').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/a', 'coll/b'); + + query_ = query( + 'coll', + filter('key', 'not-in', [ + new BsonObjectId('507f191e810c19729de860ea'), + new BsonObjectId('507f191e810c19729de860eb') + ]) + ); + await test.executeQuery(query_); + test.assertOverlaysRead(1, 0, { + [key('coll/c').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/c'); + }); + + it('Indexes BSON Timestamp fields', async () => { + const index = fieldIndex('coll', { + id: 1, + fields: [['key', IndexKind.ASCENDING]] + }); + await test.configureFieldsIndexes(index); + await test.writeMutations( + setMutation('coll/a', { key: new BsonTimestamp(1000, 1000) }), + setMutation('coll/b', { key: new BsonTimestamp(1001, 1000) }), + setMutation('coll/c', { key: new BsonTimestamp(1000, 1001) }) + ); + await test.backfillIndexes(); + + let query_ = query('coll', orderBy('key', 'asc')); + await test.executeQuery(query_); + test.assertOverlaysRead(3, 0, { + [key('coll/a').toString()]: MutationType.Set, + [key('coll/b').toString()]: MutationType.Set, + [key('coll/c').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/a', 'coll/c', 'coll/b'); + + query_ = query( + 'coll', + filter('key', '==', new BsonTimestamp(1000, 1000)) + ); + await test.executeQuery(query_); + test.assertOverlaysRead(1, 0, { + [key('coll/a').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/a'); + + query_ = query( + 'coll', + filter('key', '!=', new BsonTimestamp(1000, 1000)) + ); + await test.executeQuery(query_); + test.assertOverlaysRead(2, 0, { + [key('coll/b').toString()]: MutationType.Set, + [key('coll/c').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/c', 'coll/b'); + + query_ = query( + 'coll', + filter('key', '>=', new BsonTimestamp(1000, 1001)) + ); + await test.executeQuery(query_); + test.assertOverlaysRead(2, 0, { + [key('coll/b').toString()]: MutationType.Set, + [key('coll/c').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/c', 'coll/b'); + + query_ = query('coll', filter('key', '<', new BsonTimestamp(1000, 1000))); + await test.executeQuery(query_); + test.assertOverlaysRead(0, 0); + test.assertQueryReturned(query_); + + query_ = query( + 'coll', + filter('key', 'in', [ + new BsonTimestamp(1000, 1000), + new BsonTimestamp(1001, 1000) + ]) + ); + await test.executeQuery(query_); + test.assertOverlaysRead(2, 0, { + [key('coll/a').toString()]: MutationType.Set, + [key('coll/b').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/a', 'coll/b'); + + query_ = query( + 'coll', + filter('key', 'not-in', [ + new BsonTimestamp(1000, 1000), + new BsonTimestamp(1001, 1000) + ]) + ); + await test.executeQuery(query_); + test.assertOverlaysRead(1, 0, { + [key('coll/c').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/c'); + }); + + it('Indexes BSON Binary Data fields', async () => { + const index = fieldIndex('coll', { + id: 1, + fields: [['key', IndexKind.ASCENDING]] + }); + await test.configureFieldsIndexes(index); + await test.writeMutations( + setMutation('coll/a', { + key: new BsonBinaryData(1, new Uint8Array([1, 2, 3])) + }), + setMutation('coll/b', { + key: new BsonBinaryData(1, new Uint8Array([1, 2])) + }), + setMutation('coll/c', { + key: new BsonBinaryData(1, new Uint8Array([1, 2, 4])) + }), + setMutation('coll/d', { + key: new BsonBinaryData(2, new Uint8Array([1, 2])) + }) + ); + await test.backfillIndexes(); + + let query_ = query('coll', orderBy('key', 'asc')); + await test.executeQuery(query_); + test.assertOverlaysRead(4, 0, { + [key('coll/a').toString()]: MutationType.Set, + [key('coll/b').toString()]: MutationType.Set, + [key('coll/c').toString()]: MutationType.Set, + [key('coll/d').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/b', 'coll/a', 'coll/c', 'coll/d'); + + query_ = query( + 'coll', + filter('key', '==', new BsonBinaryData(1, new Uint8Array([1, 2, 3]))) + ); + await test.executeQuery(query_); + test.assertOverlaysRead(1, 0, { + [key('coll/a').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/a'); + + query_ = query( + 'coll', + filter('key', '!=', new BsonBinaryData(1, new Uint8Array([1, 2, 3]))) + ); + await test.executeQuery(query_); + test.assertOverlaysRead(3, 0, { + [key('coll/b').toString()]: MutationType.Set, + [key('coll/c').toString()]: MutationType.Set, + [key('coll/d').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/b', 'coll/c', 'coll/d'); + + query_ = query( + 'coll', + filter('key', '>=', new BsonBinaryData(1, new Uint8Array([1, 2, 3]))) + ); + await test.executeQuery(query_); + test.assertOverlaysRead(3, 0, { + [key('coll/a').toString()]: MutationType.Set, + [key('coll/c').toString()]: MutationType.Set, + [key('coll/d').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/a', 'coll/c', 'coll/d'); + + query_ = query( + 'coll', + filter('key', '<', new BsonBinaryData(1, new Uint8Array([1, 2]))) + ); + await test.executeQuery(query_); + test.assertOverlaysRead(0, 0); + test.assertQueryReturned(query_); + + query_ = query( + 'coll', + filter('key', 'in', [ + new BsonBinaryData(1, new Uint8Array([1, 2, 3])), + new BsonBinaryData(1, new Uint8Array([1, 2])) + ]) + ); + await test.executeQuery(query_); + test.assertOverlaysRead(2, 0, { + [key('coll/a').toString()]: MutationType.Set, + [key('coll/b').toString()]: MutationType.Set + }); + // Note that `in` does not add implicit ordering, so the result is ordered by keys + test.assertQueryReturned(query_, 'coll/a', 'coll/b'); + + query_ = query( + 'coll', + filter('key', 'not-in', [ + new BsonBinaryData(1, new Uint8Array([1, 2, 3])), + new BsonBinaryData(1, new Uint8Array([1, 2])) + ]) + ); + await test.executeQuery(query_); + test.assertOverlaysRead(2, 0, { + [key('coll/c').toString()]: MutationType.Set, + [key('coll/d').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/c', 'coll/d'); + }); + + it('Indexes BSON Int32 fields', async () => { + const index = fieldIndex('coll', { + id: 1, + fields: [['key', IndexKind.ASCENDING]] + }); + await test.configureFieldsIndexes(index); + await test.writeMutations( + setMutation('coll/a', { key: new Int32Value(-1) }), + setMutation('coll/b', { key: new Int32Value(0) }), + setMutation('coll/c', { key: new Int32Value(1) }) + ); + await test.backfillIndexes(); + + let query_ = query('coll', orderBy('key', 'asc')); + await test.executeQuery(query_); + test.assertOverlaysRead(3, 0, { + [key('coll/a').toString()]: MutationType.Set, + [key('coll/b').toString()]: MutationType.Set, + [key('coll/c').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/a', 'coll/b', 'coll/c'); + + query_ = query('coll', filter('key', '==', new Int32Value(0))); + await test.executeQuery(query_); + test.assertOverlaysRead(1, 0, { + [key('coll/b').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/b'); + + query_ = query('coll', filter('key', '!=', new Int32Value(0))); + await test.executeQuery(query_); + test.assertOverlaysRead(2, 0, { + [key('coll/a').toString()]: MutationType.Set, + [key('coll/c').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/a', 'coll/c'); + + query_ = query('coll', filter('key', '>=', new Int32Value(0))); + await test.executeQuery(query_); + test.assertOverlaysRead(2, 0, { + [key('coll/b').toString()]: MutationType.Set, + [key('coll/c').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/b', 'coll/c'); + + query_ = query('coll', filter('key', '<', new Int32Value(-1))); + await test.executeQuery(query_); + test.assertOverlaysRead(0, 0); + test.assertQueryReturned(query_); + + query_ = query( + 'coll', + filter('key', 'in', [new Int32Value(0), new Int32Value(1)]) + ); + await test.executeQuery(query_); + test.assertOverlaysRead(2, 0, { + [key('coll/b').toString()]: MutationType.Set, + [key('coll/c').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/b', 'coll/c'); + + query_ = query( + 'coll', + filter('key', 'not-in', [new Int32Value(0), new Int32Value(1)]) + ); + await test.executeQuery(query_); + test.assertOverlaysRead(1, 0, { + [key('coll/a').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/a'); + }); + + it('Indexes BSON Regex fields', async () => { + const index = fieldIndex('coll', { + id: 1, + fields: [['key', IndexKind.ASCENDING]] + }); + await test.configureFieldsIndexes(index); + await test.writeMutations( + setMutation('coll/a', { key: new RegexValue('a', 'i') }), + setMutation('coll/b', { key: new RegexValue('a', 'm') }), + setMutation('coll/c', { key: new RegexValue('b', 'i') }) + ); + await test.backfillIndexes(); + + let query_ = query('coll', orderBy('key', 'asc')); + await test.executeQuery(query_); + test.assertOverlaysRead(3, 0, { + [key('coll/a').toString()]: MutationType.Set, + [key('coll/b').toString()]: MutationType.Set, + [key('coll/c').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/a', 'coll/b', 'coll/c'); + + query_ = query('coll', filter('key', '==', new RegexValue('a', 'i'))); + await test.executeQuery(query_); + test.assertOverlaysRead(1, 0, { + [key('coll/a').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/a'); + + query_ = query('coll', filter('key', '!=', new RegexValue('a', 'i'))); + await test.executeQuery(query_); + test.assertOverlaysRead(2, 0, { + [key('coll/b').toString()]: MutationType.Set, + [key('coll/c').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/b', 'coll/c'); + + query_ = query('coll', filter('key', '>=', new RegexValue('a', 'm'))); + await test.executeQuery(query_); + test.assertOverlaysRead(2, 0, { + [key('coll/b').toString()]: MutationType.Set, + [key('coll/c').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/b', 'coll/c'); + + query_ = query('coll', filter('key', '<', new RegexValue('a', 'i'))); + await test.executeQuery(query_); + test.assertOverlaysRead(0, 0); + test.assertQueryReturned(query_); + + query_ = query( + 'coll', + filter('key', 'in', [ + new RegexValue('a', 'i'), + new RegexValue('a', 'm') + ]) + ); + await test.executeQuery(query_); + test.assertOverlaysRead(2, 0, { + [key('coll/a').toString()]: MutationType.Set, + [key('coll/b').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/a', 'coll/b'); + + query_ = query( + 'coll', + filter('key', 'not-in', [ + new RegexValue('a', 'i'), + new RegexValue('a', 'm') + ]) + ); + await test.executeQuery(query_); + test.assertOverlaysRead(1, 0, { + [key('coll/c').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/c'); + }); + + it('Indexes BSON minKey fields', async () => { + const index = fieldIndex('coll', { + id: 1, + fields: [['key', IndexKind.ASCENDING]] + }); + await test.configureFieldsIndexes(index); + await test.writeMutations( + setMutation('coll/a', { key: MinKey.instance() }), + setMutation('coll/b', { key: MinKey.instance() }), + setMutation('coll/c', { key: null }), + setMutation('coll/d', { key: 1 }), + setMutation('coll/e', { key: MaxKey.instance() }) + ); + await test.backfillIndexes(); + + let query_ = query('coll', orderBy('key', 'asc')); + await test.executeQuery(query_); + test.assertOverlaysRead(5, 0, { + [key('coll/a').toString()]: MutationType.Set, + [key('coll/b').toString()]: MutationType.Set, + [key('coll/c').toString()]: MutationType.Set, + [key('coll/d').toString()]: MutationType.Set, + [key('coll/e').toString()]: MutationType.Set + }); + test.assertQueryReturned( + query_, + 'coll/c', + 'coll/a', + 'coll/b', + 'coll/d', + 'coll/e' + ); + + query_ = query('coll', filter('key', '==', MinKey.instance())); + await test.executeQuery(query_); + test.assertOverlaysRead(2, 0, { + [key('coll/a').toString()]: MutationType.Set, + [key('coll/b').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/a', 'coll/b'); + + query_ = query('coll', filter('key', '!=', MinKey.instance())); + await test.executeQuery(query_); + test.assertOverlaysRead(2, 0, { + [key('coll/d').toString()]: MutationType.Set, + [key('coll/e').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/d', 'coll/e'); + + query_ = query('coll', filter('key', '>=', MinKey.instance())); + await test.executeQuery(query_); + test.assertOverlaysRead(2, 0, { + [key('coll/a').toString()]: MutationType.Set, + [key('coll/b').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/a', 'coll/b'); + + query_ = query('coll', filter('key', '<', MinKey.instance())); + await test.executeQuery(query_); + test.assertOverlaysRead(0, 0, {}); + test.assertQueryReturned(query_); + + query_ = query('coll', filter('key', 'in', [MinKey.instance()])); + await test.executeQuery(query_); + test.assertOverlaysRead(2, 0, { + [key('coll/a').toString()]: MutationType.Set, + [key('coll/b').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/a', 'coll/b'); + + query_ = query('coll', filter('key', 'not-in', [MinKey.instance()])); + await test.executeQuery(query_); + test.assertOverlaysRead(2, 0, { + [key('coll/d').toString()]: MutationType.Set, + [key('coll/e').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/d', 'coll/e'); + }); + + it('Indexes BSON maxKey fields', async () => { + const index = fieldIndex('coll', { + id: 1, + fields: [['key', IndexKind.ASCENDING]] + }); + await test.configureFieldsIndexes(index); + await test.writeMutations( + setMutation('coll/a', { key: MaxKey.instance() }), + setMutation('coll/b', { key: MaxKey.instance() }), + setMutation('coll/c', { key: null }), + setMutation('coll/d', { key: 1 }), + setMutation('coll/e', { key: MinKey.instance() }) + ); + await test.backfillIndexes(); + + let query_ = query('coll', orderBy('key', 'asc')); + await test.executeQuery(query_); + test.assertOverlaysRead(5, 0, { + [key('coll/a').toString()]: MutationType.Set, + [key('coll/b').toString()]: MutationType.Set, + [key('coll/c').toString()]: MutationType.Set, + [key('coll/d').toString()]: MutationType.Set, + [key('coll/e').toString()]: MutationType.Set + }); + test.assertQueryReturned( + query_, + 'coll/c', + 'coll/e', + 'coll/d', + 'coll/a', + 'coll/b' + ); + + query_ = query('coll', filter('key', '==', MaxKey.instance())); + await test.executeQuery(query_); + test.assertOverlaysRead(2, 0, { + [key('coll/a').toString()]: MutationType.Set, + [key('coll/b').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/a', 'coll/b'); + + query_ = query('coll', filter('key', '!=', MaxKey.instance())); + await test.executeQuery(query_); + test.assertOverlaysRead(2, 0, { + [key('coll/d').toString()]: MutationType.Set, + [key('coll/e').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/e', 'coll/d'); + + query_ = query('coll', filter('key', '<=', MaxKey.instance())); + await test.executeQuery(query_); + test.assertOverlaysRead(2, 0, { + [key('coll/a').toString()]: MutationType.Set, + [key('coll/b').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/a', 'coll/b'); + + query_ = query('coll', filter('key', '>', MaxKey.instance())); + await test.executeQuery(query_); + test.assertOverlaysRead(0, 0, {}); + test.assertQueryReturned(query_); + + query_ = query('coll', filter('key', '<', MaxKey.instance())); + await test.executeQuery(query_); + test.assertOverlaysRead(0, 0, {}); + test.assertQueryReturned(query_); + + query_ = query('coll', filter('key', 'in', [MaxKey.instance()])); + await test.executeQuery(query_); + test.assertOverlaysRead(2, 0, { + [key('coll/a').toString()]: MutationType.Set, + [key('coll/b').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/a', 'coll/b'); + + query_ = query('coll', filter('key', 'not-in', [MaxKey.instance()])); + await test.executeQuery(query_); + test.assertOverlaysRead(2, 0, { + [key('coll/d').toString()]: MutationType.Set, + [key('coll/e').toString()]: MutationType.Set + }); + test.assertQueryReturned(query_, 'coll/e', 'coll/d'); + }); + + it('Indexes multiple BSON types together', async () => { + const index = fieldIndex('coll', { + id: 1, + fields: [['key', IndexKind.DESCENDING]] + }); + await test.configureFieldsIndexes(index); + + await test.writeMutations( + setMutation('coll/a', { key: MinKey.instance() }), + setMutation('coll/b', { key: new Int32Value(2) }), + setMutation('coll/c', { key: new Int32Value(1) }), + setMutation('coll/d', { key: new BsonTimestamp(1000, 1001) }), + setMutation('coll/e', { key: new BsonTimestamp(1000, 1000) }), + setMutation('coll/f', { + key: new BsonBinaryData(1, new Uint8Array([1, 2, 4])) + }), + setMutation('coll/g', { + key: new BsonBinaryData(1, new Uint8Array([1, 2, 3])) + }), + setMutation('coll/h', { + key: new BsonObjectId('507f191e810c19729de860eb') + }), + setMutation('coll/i', { + key: new BsonObjectId('507f191e810c19729de860ea') + }), + setMutation('coll/j', { key: new RegexValue('^bar', 'm') }), + setMutation('coll/k', { key: new RegexValue('^bar', 'i') }), + setMutation('coll/l', { key: MaxKey.instance() }) + ); + await test.backfillIndexes(); + + const query_ = query('coll', orderBy('key', 'desc')); + await test.executeQuery(query_); + test.assertOverlaysRead(12, 0, { + [key('coll/a').toString()]: MutationType.Set, + [key('coll/b').toString()]: MutationType.Set, + [key('coll/c').toString()]: MutationType.Set, + [key('coll/d').toString()]: MutationType.Set, + [key('coll/e').toString()]: MutationType.Set, + [key('coll/f').toString()]: MutationType.Set, + [key('coll/g').toString()]: MutationType.Set, + [key('coll/h').toString()]: MutationType.Set, + [key('coll/i').toString()]: MutationType.Set, + [key('coll/j').toString()]: MutationType.Set, + [key('coll/k').toString()]: MutationType.Set, + [key('coll/l').toString()]: MutationType.Set + }); + test.assertQueryReturned( + query_, + 'coll/l', + 'coll/j', + 'coll/k', + 'coll/h', + 'coll/i', + 'coll/f', + 'coll/g', + 'coll/d', + 'coll/e', + 'coll/b', + 'coll/c', + 'coll/a' + ); + }); + + it('Indexes all types together', async () => { + const index = fieldIndex('coll', { + id: 1, + fields: [['key', IndexKind.ASCENDING]] + }); + await test.configureFieldsIndexes(index); + + await test.writeMutations( + setMutation('coll/a', { key: null }), + setMutation('coll/b', { key: MinKey.instance() }), + setMutation('coll/c', { key: true }), + setMutation('coll/d', { key: NaN }), + setMutation('coll/e', { key: new Int32Value(1) }), + setMutation('coll/f', { key: 2.0 }), + setMutation('coll/g', { key: 3 }), + setMutation('coll/h', { key: new Timestamp(100, 123456000) }), + setMutation('coll/i', { key: new BsonTimestamp(1, 2) }), + setMutation('coll/j', { key: 'string' }), + setMutation('coll/k', { key: blob(1, 2, 3) }), + setMutation('coll/l', { + key: new BsonBinaryData(1, new Uint8Array([1, 2, 3])) + }), + setMutation('coll/m', { key: ref('foo/bar') }), + setMutation('coll/n', { + key: new BsonObjectId('507f191e810c19729de860ea') + }), + setMutation('coll/o', { key: new GeoPoint(1, 2) }), + setMutation('coll/p', { key: new RegexValue('^bar', 'm') }), + setMutation('coll/q', { key: [2, 'foo'] }), + setMutation('coll/r', { key: vector([1, 2, 3]) }), + setMutation('coll/s', { key: { bar: 1, foo: 2 } }), + setMutation('coll/t', { key: MaxKey.instance() }) + ); + await test.backfillIndexes(); + + const query_ = query('coll', orderBy('key', 'asc')); + await test.executeQuery(query_); + test.assertOverlaysRead(20, 0, { + [key('coll/a').toString()]: MutationType.Set, + [key('coll/b').toString()]: MutationType.Set, + [key('coll/c').toString()]: MutationType.Set, + [key('coll/d').toString()]: MutationType.Set, + [key('coll/e').toString()]: MutationType.Set, + [key('coll/f').toString()]: MutationType.Set, + [key('coll/g').toString()]: MutationType.Set, + [key('coll/h').toString()]: MutationType.Set, + [key('coll/i').toString()]: MutationType.Set, + [key('coll/j').toString()]: MutationType.Set, + [key('coll/k').toString()]: MutationType.Set, + [key('coll/l').toString()]: MutationType.Set, + [key('coll/m').toString()]: MutationType.Set, + [key('coll/n').toString()]: MutationType.Set, + [key('coll/o').toString()]: MutationType.Set, + [key('coll/p').toString()]: MutationType.Set, + [key('coll/q').toString()]: MutationType.Set, + [key('coll/r').toString()]: MutationType.Set, + [key('coll/s').toString()]: MutationType.Set, + [key('coll/t').toString()]: MutationType.Set + }); + test.assertQueryReturned( + query_, + 'coll/a', + 'coll/b', + 'coll/c', + 'coll/d', + 'coll/e', + 'coll/f', + 'coll/g', + 'coll/h', + 'coll/i', + 'coll/j', + 'coll/k', + 'coll/l', + 'coll/m', + 'coll/n', + 'coll/o', + 'coll/p', + 'coll/q', + 'coll/r', + 'coll/s', + 'coll/t' + ); + }); }); }); diff --git a/packages/firestore/test/unit/model/document.test.ts b/packages/firestore/test/unit/model/document.test.ts index cfb93d15e6f..f67e9d971a0 100644 --- a/packages/firestore/test/unit/model/document.test.ts +++ b/packages/firestore/test/unit/model/document.test.ts @@ -17,6 +17,15 @@ import { expect } from 'chai'; +import { + BsonBinaryData, + BsonObjectId, + BsonTimestamp, + Int32Value, + MaxKey, + MinKey, + RegexValue +} from '../../../src'; import { doc, expectEqual, @@ -44,6 +53,34 @@ describe('Document', () => { expect(document.hasLocalMutations).to.equal(false); }); + it('can be constructed with bson types', () => { + const data = { + objectId: new BsonObjectId('foo'), + binary: new BsonBinaryData(1, new Uint8Array([1, 2, 3])), + timestamp: new BsonTimestamp(1, 2), + min: MinKey.instance(), + max: MaxKey.instance(), + regex: new RegexValue('a', 'b'), + int32: new Int32Value(1) + }; + const document = doc('rooms/Eros', 1, data); + + const value = document.data; + expect(value.value).to.deep.equal( + wrap({ + objectId: new BsonObjectId('foo'), + binary: new BsonBinaryData(1, new Uint8Array([1, 2, 3])), + timestamp: new BsonTimestamp(1, 2), + min: MinKey.instance(), + max: MaxKey.instance(), + regex: new RegexValue('a', 'b'), + int32: new Int32Value(1) + }) + ); + expect(value).not.to.equal(data); + expect(document.hasLocalMutations).to.equal(false); + }); + it('returns fields correctly', () => { const data = { desc: 'Discuss all the project related stuff', diff --git a/packages/firestore/test/unit/model/object_value.test.ts b/packages/firestore/test/unit/model/object_value.test.ts index 9e96056d957..40b18893e68 100644 --- a/packages/firestore/test/unit/model/object_value.test.ts +++ b/packages/firestore/test/unit/model/object_value.test.ts @@ -17,6 +17,15 @@ import { expect } from 'chai'; +import { + BsonObjectId, + BsonBinaryData, + BsonTimestamp, + RegexValue, + Int32Value, + MaxKey, + MinKey +} from '../../../src'; import { vector } from '../../../src/lite-api/field_value_impl'; import { extractFieldMask, ObjectValue } from '../../../src/model/object_value'; import { TypeOrder } from '../../../src/model/type_order'; @@ -27,7 +36,16 @@ describe('ObjectValue', () => { it('can extract fields', () => { const objValue = wrapObject({ foo: { a: 1, b: true, c: 'string' }, - embedding: vector([1]) + embedding: vector([1]), + bson: { + objectId: new BsonObjectId('foo'), + binary: new BsonBinaryData(1, new Uint8Array([1, 2, 3])), + timestamp: new BsonTimestamp(1, 2), + min: MinKey.instance(), + max: MaxKey.instance(), + regex: new RegexValue('a', 'b'), + int32: new Int32Value(1) + } }); expect(typeOrder(objValue.field(field('foo'))!)).to.equal( @@ -45,6 +63,27 @@ describe('ObjectValue', () => { expect(typeOrder(objValue.field(field('embedding'))!)).to.equal( TypeOrder.VectorValue ); + expect(typeOrder(objValue.field(field('bson.objectId'))!)).to.equal( + TypeOrder.BsonObjectIdValue + ); + expect(typeOrder(objValue.field(field('bson.binary'))!)).to.equal( + TypeOrder.BsonBinaryValue + ); + expect(typeOrder(objValue.field(field('bson.timestamp'))!)).to.equal( + TypeOrder.BsonTimestampValue + ); + expect(typeOrder(objValue.field(field('bson.min'))!)).to.equal( + TypeOrder.MinKeyValue + ); + expect(typeOrder(objValue.field(field('bson.max'))!)).to.equal( + TypeOrder.MaxKeyValue + ); + expect(typeOrder(objValue.field(field('bson.regex'))!)).to.equal( + TypeOrder.RegexValue + ); + expect(typeOrder(objValue.field(field('bson.int32'))!)).to.equal( + TypeOrder.NumberValue + ); expect(objValue.field(field('foo.a.b'))).to.be.null; expect(objValue.field(field('bar'))).to.be.null; @@ -60,13 +99,48 @@ describe('ObjectValue', () => { expect(objValue.field(field('foo.a'))).to.deep.equal(wrap(1)); expect(objValue.field(field('foo.b'))).to.deep.equal(wrap(true)); expect(objValue.field(field('foo.c'))).to.deep.equal(wrap('string')); + + expect(objValue.field(field('bson'))!).to.deep.equal( + wrap({ + objectId: new BsonObjectId('foo'), + binary: new BsonBinaryData(1, new Uint8Array([1, 2, 3])), + timestamp: new BsonTimestamp(1, 2), + min: MinKey.instance(), + max: MaxKey.instance(), + regex: new RegexValue('a', 'b'), + int32: new Int32Value(1) + }) + ); + expect(objValue.field(field('bson.objectId'))!).to.deep.equal( + wrap(new BsonObjectId('foo')) + ); + expect(objValue.field(field('bson.binary'))!).to.deep.equal( + wrap(new BsonBinaryData(1, new Uint8Array([1, 2, 3]))) + ); + expect(objValue.field(field('bson.timestamp'))!).to.deep.equal( + wrap(new BsonTimestamp(1, 2)) + ); + expect(objValue.field(field('bson.min'))!).to.deep.equal( + wrap(MinKey.instance()) + ); + expect(objValue.field(field('bson.max'))!).to.deep.equal( + wrap(MaxKey.instance()) + ); + expect(objValue.field(field('bson.regex'))!).to.deep.equal( + wrap(new RegexValue('a', 'b')) + ); + expect(objValue.field(field('bson.int32'))!).to.deep.equal( + wrap(new Int32Value(1)) + ); }); it('can overwrite existing fields', () => { const objValue = wrapObject({ foo: 'foo-value' }); objValue.set(field('foo'), wrap('new-foo-value')); - assertObjectEquals(objValue, { foo: 'new-foo-value' }); + assertObjectEquals(objValue, { + foo: 'new-foo-value' + }); }); it('can add new fields', () => { @@ -163,11 +237,77 @@ describe('ObjectValue', () => { assertObjectEquals(objValue, {}); }); + it('can handle bson types in ObjectValue', () => { + const objValue = ObjectValue.empty(); + // Add new fields + objValue.set(field('objectId'), wrap(new BsonObjectId('foo-value'))); + objValue.set( + field('binary'), + wrap(new BsonBinaryData(1, new Uint8Array([1, 2, 3]))) + ); + objValue.set(field('timestamp'), wrap(new BsonTimestamp(1, 2))); + objValue.set(field('regex'), wrap(new RegexValue('a', 'b'))); + objValue.set(field('int32'), wrap(new Int32Value(1))); + objValue.set(field('min'), wrap(MinKey.instance())); + objValue.set(field('max'), wrap(MaxKey.instance())); + + assertObjectEquals(objValue, { + objectId: new BsonObjectId('foo-value'), + binary: new BsonBinaryData(1, new Uint8Array([1, 2, 3])), + timestamp: new BsonTimestamp(1, 2), + regex: new RegexValue('a', 'b'), + int32: new Int32Value(1), + min: MinKey.instance(), + max: MaxKey.instance() + }); + + // Overwrite existing fields + objValue.set(field('objectId'), wrap(new BsonObjectId('new-foo-value'))); + + // Create nested objects + objValue.set( + field('foo.binary'), + wrap(new BsonBinaryData(2, new Uint8Array([1, 2, 3]))) + ); + objValue.set(field('foo.timestamp'), wrap(new BsonTimestamp(1, 2))); + + // Delete fields + objValue.delete(field('binary')); + + // overwrite nested objects + objValue.set(field('foo.timestamp'), wrap(new BsonTimestamp(2, 1))); + + // Overwrite primitive values to create objects + objValue.set(field('min'), wrap(null)); + + assertObjectEquals(objValue, { + objectId: new BsonObjectId('new-foo-value'), + timestamp: new BsonTimestamp(1, 2), + regex: new RegexValue('a', 'b'), + int32: new Int32Value(1), + min: null, + max: MaxKey.instance(), + foo: { + binary: new BsonBinaryData(2, new Uint8Array([1, 2, 3])), + timestamp: new BsonTimestamp(2, 1) + } + }); + }); + it('provides field mask', () => { const objValue = wrapObject({ a: 'b', map: { a: 1, b: true, c: 'string', nested: { d: 'e' } }, - emptymap: {} + emptymap: {}, + bar: { + objectId: new BsonObjectId('foo'), + binary: new BsonBinaryData(1, new Uint8Array([1, 2, 3])), + timestamp: new BsonTimestamp(1, 2), + min: MinKey.instance(), + max: MaxKey.instance(), + regex: new RegexValue('a', 'b'), + int32: new Int32Value(1) + } }); const expectedMask = mask( 'a', @@ -175,7 +315,14 @@ describe('ObjectValue', () => { 'map.b', 'map.c', 'map.nested.d', - 'emptymap' + 'emptymap', + 'bar.objectId', + 'bar.binary', + 'bar.timestamp', + 'bar.min', + 'bar.max', + 'bar.regex', + 'bar.int32' ); const actualMask = extractFieldMask(objValue.value.mapValue); expect(actualMask.isEqual(expectedMask)).to.be.true; @@ -185,6 +332,6 @@ describe('ObjectValue', () => { objValue: ObjectValue, data: { [k: string]: unknown } ): void { - expect(objValue.isEqual(wrapObject(data))); + expect(objValue.isEqual(wrapObject(data))).to.be.true; } }); diff --git a/packages/firestore/test/unit/model/target.test.ts b/packages/firestore/test/unit/model/target.test.ts index bbeea5dec83..1fa2e58b298 100644 --- a/packages/firestore/test/unit/model/target.test.ts +++ b/packages/firestore/test/unit/model/target.test.ts @@ -31,8 +31,8 @@ import { import { IndexKind } from '../../../src/model/field_index'; import { canonicalId, - MAX_VALUE, - MIN_VALUE, + INTERNAL_MAX_VALUE, + INTERNAL_MIN_VALUE, valueEquals } from '../../../src/model/values'; import { @@ -207,11 +207,11 @@ describe('Target Bounds', () => { const index = fieldIndex('c', { fields: [['foo', IndexKind.ASCENDING]] }); const lowerBound = targetGetLowerBound(target, index); - expect(lowerBound?.position[0]).to.equal(MIN_VALUE); + expect(lowerBound?.position[0]).to.equal(INTERNAL_MIN_VALUE); expect(lowerBound?.inclusive).to.be.true; const upperBound = targetGetUpperBound(target, index); - expect(upperBound?.position[0]).to.equal(MAX_VALUE); + expect(upperBound?.position[0]).to.equal(INTERNAL_MAX_VALUE); expect(upperBound?.inclusive).to.be.true; }); @@ -241,7 +241,7 @@ describe('Target Bounds', () => { verifyBound(lowerBound, true, 'bar'); const upperBound = targetGetUpperBound(target, index); - expect(upperBound?.position[0]).to.equal(MAX_VALUE); + expect(upperBound?.position[0]).to.equal(INTERNAL_MAX_VALUE); expect(upperBound?.inclusive).to.be.true; }); @@ -337,7 +337,7 @@ describe('Target Bounds', () => { const index = fieldIndex('c', { fields: [['foo', IndexKind.ASCENDING]] }); const lowerBound = targetGetLowerBound(target, index); - expect(lowerBound?.position[0]).to.equal(MIN_VALUE); + expect(lowerBound?.position[0]).to.equal(INTERNAL_MIN_VALUE); expect(lowerBound?.inclusive).to.be.true; const upperBound = targetGetUpperBound(target, index); diff --git a/packages/firestore/test/unit/model/values.test.ts b/packages/firestore/test/unit/model/values.test.ts index 722d2db6fa5..dce8f1e123c 100644 --- a/packages/firestore/test/unit/model/values.test.ts +++ b/packages/firestore/test/unit/model/values.test.ts @@ -17,7 +17,17 @@ import { expect } from 'chai'; -import { GeoPoint, Timestamp } from '../../../src'; +import { + GeoPoint, + Timestamp, + BsonBinaryData, + BsonTimestamp, + BsonObjectId, + RegexValue, + Int32Value, + MaxKey, + MinKey +} from '../../../src'; import { DatabaseId } from '../../../src/core/database_info'; import { vector } from '../../../src/lite-api/field_value_impl'; import { serverTimestamp } from '../../../src/model/server_timestamps'; @@ -31,8 +41,15 @@ import { valuesGetLowerBound, valuesGetUpperBound, TYPE_KEY, - VECTOR_VALUE_SENTINEL, - VECTOR_MAP_VECTORS_KEY + RESERVED_VECTOR_KEY, + VECTOR_MAP_VECTORS_KEY, + MIN_BSON_TIMESTAMP_VALUE, + MIN_VECTOR_VALUE, + RESERVED_INT32_KEY, + MIN_BSON_BINARY_VALUE, + MIN_KEY_VALUE, + MIN_REGEX_VALUE, + MIN_BSON_OBJECT_ID_VALUE } from '../../../src/model/values'; import * as api from '../../../src/protos/firestore_proto_api'; import { primitiveComparator } from '../../../src/util/misc'; @@ -56,6 +73,7 @@ describe('Values', () => { [wrap(true), wrap(true)], [wrap(false), wrap(false)], [wrap(null), wrap(null)], + [wrap(MinKey.instance()), wrap(MinKey.instance())], [wrap(0 / 0), wrap(Number.NaN), wrap(NaN)], // -0.0 and 0.0 order the same but are not considered equal. [wrap(-0.0)], @@ -92,7 +110,21 @@ describe('Values', () => { [wrap({ bar: 1, foo: 1 })], [wrap({ foo: 1 })], [wrap(vector([]))], - [wrap(vector([1, 2.3, -4.0]))] + [wrap(vector([1, 2.3, -4.0]))], + [wrap(new RegexValue('^foo', 'i')), wrap(new RegexValue('^foo', 'i'))], + [wrap(new BsonTimestamp(57, 4)), wrap(new BsonTimestamp(57, 4))], + [ + wrap(new BsonBinaryData(128, Uint8Array.from([7, 8, 9]))), + wrap(new BsonBinaryData(128, Uint8Array.from([7, 8, 9]))), + wrap(new BsonBinaryData(128, Buffer.from([7, 8, 9]))), + wrap(new BsonBinaryData(128, Buffer.from([7, 8, 9]))) + ], + [ + wrap(new BsonObjectId('123456789012')), + wrap(new BsonObjectId('123456789012')) + ], + [wrap(new Int32Value(255)), wrap(new Int32Value(255))], + [wrap(MaxKey.instance()), wrap(MaxKey.instance())] ]; expectEqualitySets(values, (v1, v2) => valueEquals(v1, v2)); }); @@ -131,6 +163,9 @@ describe('Values', () => { // null first [wrap(null)], + // MinKey is after null + [wrap(MinKey.instance())], + // booleans [wrap(false)], [wrap(true)], @@ -141,15 +176,24 @@ describe('Values', () => { [wrap(-Number.MAX_VALUE)], [wrap(Number.MIN_SAFE_INTEGER - 1)], [wrap(Number.MIN_SAFE_INTEGER)], + // 64-bit and 32-bit integers order together numerically. + [{ integerValue: -2147483648 }, wrap(new Int32Value(-2147483648))], [wrap(-1.1)], - // Integers and Doubles order the same. - [{ integerValue: -1 }, { doubleValue: -1 }], + // Integers, Int32Values and Doubles order the same. + [{ integerValue: -1 }, { doubleValue: -1 }, wrap(new Int32Value(-1))], [wrap(-Number.MIN_VALUE)], // zeros all compare the same. - [{ integerValue: 0 }, { doubleValue: 0 }, { doubleValue: -0 }], + [ + { integerValue: 0 }, + { doubleValue: 0 }, + { doubleValue: -0 }, + wrap(new Int32Value(0)) + ], [wrap(Number.MIN_VALUE)], - [{ integerValue: 1 }, { doubleValue: 1 }], + [{ integerValue: 1 }, { doubleValue: 1.0 }, wrap(new Int32Value(1))], [wrap(1.1)], + [wrap(new Int32Value(2))], + [wrap(new Int32Value(2147483647))], [wrap(Number.MAX_SAFE_INTEGER)], [wrap(Number.MAX_SAFE_INTEGER + 1)], [wrap(Infinity)], @@ -164,6 +208,11 @@ describe('Values', () => { { timestampValue: '2020-04-05T14:30:01.000000000Z' } ], + // request timestamp + [wrap(new BsonTimestamp(123, 4))], + [wrap(new BsonTimestamp(123, 5))], + [wrap(new BsonTimestamp(124, 0))], + // server timestamps come after all concrete timestamps. [serverTimestamp(Timestamp.fromDate(date1), null)], [serverTimestamp(Timestamp.fromDate(date2), null)], @@ -187,6 +236,13 @@ describe('Values', () => { [wrap(blob(0, 1, 2, 4, 3))], [wrap(blob(255))], + [ + wrap(new BsonBinaryData(5, Buffer.from([1, 2, 3]))), + wrap(new BsonBinaryData(5, new Uint8Array([1, 2, 3]))) + ], + [wrap(new BsonBinaryData(7, Buffer.from([1])))], + [wrap(new BsonBinaryData(7, new Uint8Array([2])))], + // reference values [refValue(dbId('p1', 'd1'), key('c1/doc1'))], [refValue(dbId('p1', 'd1'), key('c1/doc2'))], @@ -195,6 +251,15 @@ describe('Values', () => { [refValue(dbId('p1', 'd2'), key('c1/doc1'))], [refValue(dbId('p2', 'd1'), key('c1/doc1'))], + // ObjectId + [wrap(new BsonObjectId('507f191e810c19729de860ea'))], + [wrap(new BsonObjectId('507f191e810c19729de860eb'))], + // latin small letter e + combining acute accent + latin small letter b + [wrap(new BsonObjectId('e\u0301b'))], + [wrap(new BsonObjectId('æ'))], + // latin small letter e with acute accent + latin small letter a + [wrap(new BsonObjectId('\u00e9a'))], + // geo points [wrap(new GeoPoint(-90, -180))], [wrap(new GeoPoint(-90, 0))], @@ -209,6 +274,12 @@ describe('Values', () => { [wrap(new GeoPoint(90, 0))], [wrap(new GeoPoint(90, 180))], + // regular expressions + [wrap(new RegexValue('a', 'bar1'))], + [wrap(new RegexValue('foo', 'bar1'))], + [wrap(new RegexValue('foo', 'bar2'))], + [wrap(new RegexValue('go', 'bar1'))], + // arrays [wrap([])], [wrap(['bar'])], @@ -227,7 +298,10 @@ describe('Values', () => { [wrap({ bar: 0, foo: 1 })], [wrap({ foo: 1 })], [wrap({ foo: 2 })], - [wrap({ foo: '0' })] + [wrap({ foo: '0' })], + + // MaxKey + [wrap(MaxKey.instance())] ]; expectCorrectComparisonGroups( @@ -331,6 +405,36 @@ describe('Values', () => { { expectedByteSize: 49, elements: [wrap(vector([1, 2])), wrap(vector([-100, 20000098.123445]))] + }, + { + expectedByteSize: 27, + elements: [ + wrap(new RegexValue('a', 'b')), + wrap(new RegexValue('c', 'd')) + ] + }, + { + expectedByteSize: 13, + elements: [wrap(new BsonObjectId('foo')), wrap(new BsonObjectId('bar'))] + }, + { + expectedByteSize: 53, + elements: [wrap(new BsonTimestamp(1, 2)), wrap(new BsonTimestamp(3, 4))] + }, + { + expectedByteSize: 8, + elements: [wrap(new Int32Value(1)), wrap(new Int32Value(2147483647))] + }, + { + expectedByteSize: 16, + elements: [ + wrap(new BsonBinaryData(1, new Uint8Array([127, 128]))), + wrap(new BsonBinaryData(128, new Uint8Array([1, 2]))) + ] + }, + { + expectedByteSize: 11, + elements: [wrap(MinKey.instance()), wrap(MaxKey.instance())] } ]; @@ -361,7 +465,13 @@ describe('Values', () => { [wrap({ a: 'a', b: 'b' }), wrap({ a: 'a', bc: 'b' })], [wrap({ a: 'a', b: 'b' }), wrap({ a: 'a', b: 'b', c: 'c' })], [wrap({ a: 'a', b: 'b' }), wrap({ a: 'a', b: 'b', c: 'c' })], - [wrap(vector([2, 3])), wrap(vector([1, 2, 3]))] + [wrap(vector([2, 3])), wrap(vector([1, 2, 3]))], + [wrap(new RegexValue('a', 'b')), wrap(new RegexValue('cc', 'dd'))], + [wrap(new BsonObjectId('foo')), wrap(new BsonObjectId('foobar'))], + [ + wrap(new BsonBinaryData(128, new Uint8Array([127, 128]))), + wrap(new BsonBinaryData(1, new Uint8Array([1, 2, 3]))) + ] ]; for (const group of relativeGroups) { @@ -377,15 +487,24 @@ describe('Values', () => { it('computes lower bound', () => { const groups = [ - // null first + // lower bound of null is null [valuesGetLowerBound({ nullValue: 'NULL_VALUE' }), wrap(null)], + // lower bound of MinKey is MinKey + [valuesGetLowerBound(MIN_KEY_VALUE), wrap(MinKey.instance())], + // booleans [valuesGetLowerBound({ booleanValue: true }), wrap(false)], [wrap(true)], // numbers - [valuesGetLowerBound({ doubleValue: 0 }), wrap(NaN)], + [ + valuesGetLowerBound({ doubleValue: 0 }), + valuesGetLowerBound({ + mapValue: { fields: { [RESERVED_INT32_KEY]: { integerValue: 0 } } } + }), + wrap(NaN) + ], [wrap(Number.NEGATIVE_INFINITY)], [wrap(Number.MIN_VALUE)], @@ -393,10 +512,31 @@ describe('Values', () => { [valuesGetLowerBound({ timestampValue: {} })], [wrap(date1)], + // bson timestamps + [ + valuesGetLowerBound(wrap(new BsonTimestamp(4294967295, 4294967295))), + MIN_BSON_TIMESTAMP_VALUE, + wrap(new BsonTimestamp(0, 0)) + ], + [wrap(new BsonTimestamp(1, 1))], + // strings - [valuesGetLowerBound({ stringValue: '' }), wrap('')], + [valuesGetLowerBound({ stringValue: 'Z' }), wrap('')], [wrap('\u0000')], + // blobs + [valuesGetLowerBound({ bytesValue: 'Z' }), wrap(blob())], + [wrap(blob(0))], + + // bson binary data + [ + valuesGetLowerBound( + wrap(new BsonBinaryData(128, new Uint8Array([128, 128]))) + ), + MIN_BSON_BINARY_VALUE + ], + [wrap(new BsonBinaryData(0, new Uint8Array([0])))], + // resource names [ valuesGetLowerBound({ referenceValue: '' }), @@ -404,6 +544,14 @@ describe('Values', () => { ], [refValue(DatabaseId.empty(), key('a/a'))], + // bson object ids + [ + valuesGetLowerBound(wrap(new BsonObjectId('ZZZ'))), + wrap(new BsonObjectId('')), + MIN_BSON_OBJECT_ID_VALUE + ], + [wrap(new BsonObjectId('a'))], + // geo points [ valuesGetLowerBound({ geoPointValue: {} }), @@ -411,6 +559,14 @@ describe('Values', () => { ], [wrap(new GeoPoint(-90, 0))], + // regular expressions + [ + valuesGetLowerBound(wrap(new RegexValue('ZZZ', 'i'))), + wrap(new RegexValue('', '')), + MIN_REGEX_VALUE + ], + [wrap(new RegexValue('a', 'i'))], + // arrays [valuesGetLowerBound({ arrayValue: {} }), wrap([])], [wrap([false])], @@ -420,7 +576,7 @@ describe('Values', () => { valuesGetLowerBound({ mapValue: { fields: { - [TYPE_KEY]: { stringValue: VECTOR_VALUE_SENTINEL }, + [TYPE_KEY]: { stringValue: RESERVED_VECTOR_KEY }, [VECTOR_MAP_VECTORS_KEY]: { arrayValue: { values: [{ doubleValue: 1 }] @@ -433,7 +589,10 @@ describe('Values', () => { ], // objects - [valuesGetLowerBound({ mapValue: {} }), wrap({})] + [valuesGetLowerBound({ mapValue: {} }), wrap({})], + + // MaxKey + [wrap(MaxKey.instance())] ]; expectCorrectComparisonGroups( @@ -448,13 +607,22 @@ describe('Values', () => { const groups = [ // null first [wrap(null)], - [valuesGetUpperBound({ nullValue: 'NULL_VALUE' })], + + // upper value of null is MinKey + [ + valuesGetUpperBound({ nullValue: 'NULL_VALUE' }), + wrap(MinKey.instance()) + ], + + // upper value of MinKey is boolean `false` + [valuesGetUpperBound(MIN_KEY_VALUE), wrap(false)], // booleans [wrap(true)], [valuesGetUpperBound({ booleanValue: false })], // numbers + [wrap(new Int32Value(2147483647))], //largest int32 value [wrap(Number.MAX_SAFE_INTEGER)], [wrap(Number.POSITIVE_INFINITY)], [valuesGetUpperBound({ doubleValue: NaN })], @@ -463,6 +631,10 @@ describe('Values', () => { [wrap(date1)], [valuesGetUpperBound({ timestampValue: {} })], + // bson timestamps + [wrap(new BsonTimestamp(4294967295, 4294967295))], // largest bson timestamp value + [valuesGetUpperBound(MIN_BSON_TIMESTAMP_VALUE)], + // strings [wrap('\u0000')], [valuesGetUpperBound({ stringValue: '' })], @@ -471,20 +643,39 @@ describe('Values', () => { [wrap(blob(255))], [valuesGetUpperBound({ bytesValue: '' })], + // bson binary data + [wrap(new BsonBinaryData(128, new Uint8Array([255, 255, 255])))], + [valuesGetUpperBound(MIN_BSON_BINARY_VALUE)], + // resource names [refValue(dbId('', ''), key('a/a'))], [valuesGetUpperBound({ referenceValue: '' })], + // bson object ids + [wrap(new BsonObjectId('foo'))], + [valuesGetUpperBound(MIN_BSON_OBJECT_ID_VALUE)], + // geo points [wrap(new GeoPoint(90, 180))], [valuesGetUpperBound({ geoPointValue: {} })], + // regular expressions + [wrap(new RegexValue('a', 'i'))], + [valuesGetUpperBound(MIN_REGEX_VALUE)], + // arrays [wrap([false])], [valuesGetUpperBound({ arrayValue: {} })], + // vectors + [wrap(vector([1, 2, 3]))], + [valuesGetUpperBound(MIN_VECTOR_VALUE)], + // objects - [wrap({ 'a': 'b' })] + [wrap({ 'a': 'b' })], + + // MaxKey + [wrap(MaxKey.instance())] ]; expectCorrectComparisonGroups( @@ -526,6 +717,21 @@ describe('Values', () => { expect( canonicalId(wrap({ 'a': ['b', { 'c': new GeoPoint(30, 60) }] })) ).to.equal('{a:[b,{c:geo(30,60)}]}'); + expect(canonicalId(wrap(new RegexValue('a', 'b')))).to.equal( + '{__regex__:{options:b,pattern:a}}' + ); + expect(canonicalId(wrap(new BsonObjectId('foo')))).to.equal( + '{__oid__:foo}' + ); + expect(canonicalId(wrap(new BsonTimestamp(1, 2)))).to.equal( + '{__request_timestamp__:{increment:2,seconds:1}}' + ); + expect(canonicalId(wrap(new Int32Value(1)))).to.equal('{__int__:1}'); + expect( + canonicalId(wrap(new BsonBinaryData(1, new Uint8Array([1, 2, 3])))) + ).to.equal('{__binary__:AQECAw==}'); + expect(canonicalId(wrap(MinKey.instance()))).to.equal('{__min__:null}'); + expect(canonicalId(wrap(MaxKey.instance()))).to.equal('{__max__:null}'); }); it('canonical IDs ignore sort order', () => { diff --git a/packages/firestore/test/unit/remote/serializer.helper.ts b/packages/firestore/test/unit/remote/serializer.helper.ts index d523c8fab83..24d7b039d0c 100644 --- a/packages/firestore/test/unit/remote/serializer.helper.ts +++ b/packages/firestore/test/unit/remote/serializer.helper.ts @@ -20,11 +20,18 @@ import { expect } from 'chai'; import { arrayRemove, arrayUnion, + BsonBinaryData, + BsonObjectId, + BsonTimestamp, Bytes, DocumentReference, GeoPoint, increment, + Int32Value, + MaxKey, + MinKey, refEqual, + RegexValue, serverTimestamp, Timestamp } from '../../../src'; @@ -565,6 +572,57 @@ export function serializerTest( jsonValue: expectedJson.mapValue }); }); + + it('converts BSON types in mapValue', () => { + const examples = [ + new BsonObjectId('foo'), + new BsonTimestamp(1, 2), + MinKey.instance(), + MaxKey.instance(), + new RegexValue('a', 'b'), + new Int32Value(1) + ]; + + for (const example of examples) { + expect(userDataWriter.convertValue(wrap(example))).to.deep.equal( + example + ); + + verifyFieldValueRoundTrip({ + value: example, + valueType: 'mapValue', + jsonValue: wrap(example).mapValue + }); + } + + // BsonBinaryData will be serialized differently Proto3Json VS. regular Protobuf format + const bsonBinary = new BsonBinaryData(1, new Uint8Array([1, 2, 3])); + const expectedJson: api.Value = { + mapValue: { + fields: { + '__binary__': { + 'bytesValue': 'AQECAw==' + } + } + } + }; + + const expectedProtoJson: api.Value = { + mapValue: { + fields: { + '__binary__': { + 'bytesValue': new Uint8Array([1, 1, 2, 3]) + } + } + } + }; + verifyFieldValueRoundTrip({ + value: bsonBinary, + valueType: 'mapValue', + jsonValue: expectedJson.mapValue, + protoJsValue: expectedProtoJson.mapValue + }); + }); }); describe('toKey', () => {