diff --git a/packages/firestore/CHANGELOG.md b/packages/firestore/CHANGELOG.md index f7deb191656..5e295411fec 100644 --- a/packages/firestore/CHANGELOG.md +++ b/packages/firestore/CHANGELOG.md @@ -1,4 +1,10 @@ # Unreleased +- [changed] Changed the in-memory representation of Firestore documents to + reduce memory allocations and improve performance. Calls to + `DocumentSnapshot.getData()` and `DocumentSnapshot.toObject()` will see + the biggest improvement. + +# 1.10.1 - [fixed] Fixed an issue where the number value `-0.0` would lose its sign when stored in Firestore. diff --git a/packages/firestore/src/api/blob.ts b/packages/firestore/src/api/blob.ts index fe7afecac82..ca7ad6f648b 100644 --- a/packages/firestore/src/api/blob.ts +++ b/packages/firestore/src/api/blob.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,11 +23,7 @@ import { validateArgType, validateExactNumberOfArgs } from '../util/input_validation'; -import { primitiveComparator } from '../util/misc'; -import { - binaryStringFromUint8Array, - uint8ArrayFromBinaryString -} from '../util/byte_string'; +import { ByteString } from '../util/byte_string'; /** Helper function to assert Uint8Array is available at runtime. */ function assertUint8ArrayAvailable(): void { @@ -57,15 +53,13 @@ function assertBase64Available(): void { * using the hack above to make sure no-one outside this module can call it. */ export class Blob { - // Prefix with underscore to signal this is a private variable in JS and - // prevent it showing up for autocompletion. - // A binary string is a string with each char as Unicode code point in the - // range of [0, 255], essentially simulating a byte array. - private _binaryString: string; + // Prefix with underscore to signal that we consider this not part of the + // public API and to prevent it from showing up for autocompletion. + _byteString: ByteString; - private constructor(binaryString: string) { + constructor(byteString: ByteString) { assertBase64Available(); - this._binaryString = binaryString; + this._byteString = byteString; } static fromBase64String(base64: string): Blob { @@ -73,8 +67,7 @@ export class Blob { validateArgType('Blob.fromBase64String', 'string', 1, base64); assertBase64Available(); try { - const binaryString = PlatformSupport.getPlatform().atob(base64); - return new Blob(binaryString); + return new Blob(ByteString.fromBase64String(base64)); } catch (e) { throw new FirestoreError( Code.INVALID_ARGUMENT, @@ -89,21 +82,19 @@ export class Blob { if (!(array instanceof Uint8Array)) { throw invalidClassError('Blob.fromUint8Array', 'Uint8Array', 1, array); } - const binaryString = binaryStringFromUint8Array(array); - return new Blob(binaryString); + return new Blob(ByteString.fromUint8Array(array)); } toBase64(): string { validateExactNumberOfArgs('Blob.toBase64', arguments, 0); assertBase64Available(); - return PlatformSupport.getPlatform().btoa(this._binaryString); + return this._byteString.toBase64(); } toUint8Array(): Uint8Array { validateExactNumberOfArgs('Blob.toUint8Array', arguments, 0); assertUint8ArrayAvailable(); - const buffer = uint8ArrayFromBinaryString(this._binaryString); - return buffer; + return this._byteString.toUint8Array(); } toString(): string { @@ -111,20 +102,7 @@ export class Blob { } isEqual(other: Blob): boolean { - return this._binaryString === other._binaryString; - } - - _approximateByteSize(): number { - // Assume UTF-16 encoding in memory (see StringValue.approximateByteSize()) - return this._binaryString.length * 2; - } - - /** - * Actually private to JS consumers of our API, so this function is prefixed - * with an underscore. - */ - _compareTo(other: Blob): number { - return primitiveComparator(this._binaryString, other._binaryString); + return this._byteString.isEqual(other._byteString); } } diff --git a/packages/firestore/src/api/database.ts b/packages/firestore/src/api/database.ts index 7b46f08e421..ea878d76da9 100644 --- a/packages/firestore/src/api/database.ts +++ b/packages/firestore/src/api/database.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,8 +17,10 @@ import * as firestore from '@firebase/firestore-types'; +import * as api from '../protos/firestore_proto_api'; + import { FirebaseApp } from '@firebase/app-types'; -import { FirebaseService, _FirebaseApp } from '@firebase/app-types/private'; +import { _FirebaseApp, FirebaseService } from '@firebase/app-types/private'; import { DatabaseId, DatabaseInfo } from '../core/database_info'; import { ListenOptions } from '../core/event_manager'; import { FirestoreClient, PersistenceSettings } from '../core/firestore_client'; @@ -38,16 +40,11 @@ import { MemoryPersistenceProvider } from '../local/memory_persistence'; import { PersistenceProvider } from '../local/persistence'; import { Document, MaybeDocument, NoDocument } from '../model/document'; import { DocumentKey } from '../model/document_key'; -import { - ArrayValue, - FieldValue, - FieldValueOptions, - ObjectValue, - RefValue, - ServerTimestampValue -} from '../model/field_value'; import { DeleteMutation, Mutation, Precondition } from '../model/mutation'; import { FieldPath, ResourcePath } from '../model/path'; +import { JsonProtoSerializer } from '../remote/serializer'; +import { isServerTimestamp } from '../model/server_timestamps'; +import { refValue } from '../model/values'; import { PlatformSupport } from '../platform/platform'; import { makeConstructorPrivate } from '../util/api'; import { assert, fail } from '../util/assert'; @@ -98,8 +95,9 @@ import { import { DocumentKeyReference, fieldPathFromArgument, - UserDataConverter -} from './user_data_converter'; + UserDataReader +} from './user_data_reader'; +import { UserDataWriter } from './user_data_writer'; import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; import { Provider } from '@firebase/component'; @@ -304,7 +302,7 @@ export class Firestore implements firestore.FirebaseFirestore, FirebaseService { // TODO(mikelehen): Use modularized initialization instead. readonly _queue = new AsyncQueue(); - readonly _dataConverter: UserDataConverter; + readonly _dataReader: UserDataReader; // Note: We are using `MemoryPersistenceProvider` as a default // PersistenceProvider to ensure backwards compatibility with the format @@ -339,7 +337,7 @@ export class Firestore implements firestore.FirebaseFirestore, FirebaseService { this._persistenceProvider = persistenceProvider; this._settings = new FirestoreSettings({}); - this._dataConverter = this.createDataConverter(this._databaseId); + this._dataReader = this.createDataReader(this._databaseId); } settings(settingsLiteral: firestore.Settings): void { @@ -538,7 +536,7 @@ export class Firestore implements firestore.FirebaseFirestore, FirebaseService { ); } - private createDataConverter(databaseId: DatabaseId): UserDataConverter { + private createDataReader(databaseId: DatabaseId): UserDataReader { const preConverter = (value: unknown): unknown => { if (value instanceof DocumentReference) { const thisDb = databaseId; @@ -556,7 +554,10 @@ export class Firestore implements firestore.FirebaseFirestore, FirebaseService { return value; } }; - return new UserDataConverter(preConverter); + const serializer = new JsonProtoSerializer(databaseId, { + useProto3Json: PlatformSupport.getPlatform().useProto3Json + }); + return new UserDataReader(serializer, preConverter); } private static databaseIdFromApp(app: FirebaseApp): DatabaseId { @@ -763,12 +764,12 @@ export class Transaction implements firestore.Transaction { ); const parsed = options.merge || options.mergeFields - ? this._firestore._dataConverter.parseMergeData( + ? this._firestore._dataReader.parseMergeData( functionName, convertedValue, options.mergeFields ) - : this._firestore._dataConverter.parseSetData( + : this._firestore._dataReader.parseSetData( functionName, convertedValue ); @@ -805,7 +806,7 @@ export class Transaction implements firestore.Transaction { documentRef, this._firestore ); - parsed = this._firestore._dataConverter.parseUpdateVarargs( + parsed = this._firestore._dataReader.parseUpdateVarargs( 'Transaction.update', fieldOrUpdateData, value, @@ -818,7 +819,7 @@ export class Transaction implements firestore.Transaction { documentRef, this._firestore ); - parsed = this._firestore._dataConverter.parseUpdateData( + parsed = this._firestore._dataReader.parseUpdateData( 'Transaction.update', fieldOrUpdateData ); @@ -866,12 +867,12 @@ export class WriteBatch implements firestore.WriteBatch { ); const parsed = options.merge || options.mergeFields - ? this._firestore._dataConverter.parseMergeData( + ? this._firestore._dataReader.parseMergeData( functionName, convertedValue, options.mergeFields ) - : this._firestore._dataConverter.parseSetData( + : this._firestore._dataReader.parseSetData( functionName, convertedValue ); @@ -912,7 +913,7 @@ export class WriteBatch implements firestore.WriteBatch { documentRef, this._firestore ); - parsed = this._firestore._dataConverter.parseUpdateVarargs( + parsed = this._firestore._dataReader.parseUpdateVarargs( 'WriteBatch.update', fieldOrUpdateData, value, @@ -925,7 +926,7 @@ export class WriteBatch implements firestore.WriteBatch { documentRef, this._firestore ); - parsed = this._firestore._dataConverter.parseUpdateData( + parsed = this._firestore._dataReader.parseUpdateData( 'WriteBatch.update', fieldOrUpdateData ); @@ -1062,15 +1063,12 @@ export class DocumentReference ); const parsed = options.merge || options.mergeFields - ? this.firestore._dataConverter.parseMergeData( + ? this.firestore._dataReader.parseMergeData( functionName, convertedValue, options.mergeFields ) - : this.firestore._dataConverter.parseSetData( - functionName, - convertedValue - ); + : this.firestore._dataReader.parseSetData(functionName, convertedValue); return this._firestoreClient.write( parsed.toMutations(this._key, Precondition.NONE) ); @@ -1094,7 +1092,7 @@ export class DocumentReference fieldOrUpdateData instanceof ExternalFieldPath ) { validateAtLeastNumberOfArgs('DocumentReference.update', arguments, 2); - parsed = this.firestore._dataConverter.parseUpdateVarargs( + parsed = this.firestore._dataReader.parseUpdateVarargs( 'DocumentReference.update', fieldOrUpdateData, value, @@ -1102,7 +1100,7 @@ export class DocumentReference ); } else { validateExactNumberOfArgs('DocumentReference.update', arguments, 1); - parsed = this.firestore._dataConverter.parseUpdateData( + parsed = this.firestore._dataReader.parseUpdateData( 'DocumentReference.update', fieldOrUpdateData ); @@ -1388,13 +1386,13 @@ export class DocumentSnapshot ); return this._converter.fromFirestore(snapshot, options); } else { - return this.toJSObject( - this._document.data(), - FieldValueOptions.fromSnapshotOptions( - options, - this._firestore._areTimestampsInSnapshotsEnabled() - ) - ) as T; + const userDataWriter = new UserDataWriter( + this._firestore, + this._firestore._areTimestampsInSnapshotsEnabled(), + options.serverTimestamps, + /* converter= */ undefined + ); + return userDataWriter.convertValue(this._document.toProto()) as T; } } } @@ -1410,13 +1408,13 @@ export class DocumentSnapshot .data() .field(fieldPathFromArgument('DocumentSnapshot.get', fieldPath)); if (value !== null) { - return this.toJSValue( - value, - FieldValueOptions.fromSnapshotOptions( - options, - this._firestore._areTimestampsInSnapshotsEnabled() - ) + const userDataWriter = new UserDataWriter( + this._firestore, + this._firestore._areTimestampsInSnapshotsEnabled(), + options.serverTimestamps, + this._converter ); + return userDataWriter.convertValue(value); } } return undefined; @@ -1456,48 +1454,6 @@ export class DocumentSnapshot this._converter === other._converter ); } - - private toJSObject( - data: ObjectValue, - options: FieldValueOptions - ): firestore.DocumentData { - const result: firestore.DocumentData = {}; - data.forEach((key, value) => { - result[key] = this.toJSValue(value, options); - }); - return result; - } - - private toJSValue(value: FieldValue, options: FieldValueOptions): unknown { - if (value instanceof ObjectValue) { - return this.toJSObject(value, options); - } else if (value instanceof ArrayValue) { - return this.toJSArray(value, options); - } else if (value instanceof RefValue) { - const key = value.value(options); - const database = this._firestore.ensureClientConfigured().databaseId(); - if (!value.databaseId.isEqual(database)) { - // TODO(b/64130202): Somehow support foreign references. - log.error( - `Document ${this._key.path} contains a document ` + - `reference within a different database (` + - `${value.databaseId.projectId}/${value.databaseId.database}) which is not ` + - `supported. It will be treated as a reference in the current ` + - `database (${database.projectId}/${database.database}) ` + - `instead.` - ); - } - return new DocumentReference(key, this._firestore, this._converter); - } else { - return value.value(options); - } - } - - private toJSArray(data: ArrayValue, options: FieldValueOptions): unknown[] { - return data.internalValue.map(value => { - return this.toJSValue(value, options); - }); - } } export class QueryDocumentSnapshot @@ -1541,7 +1497,7 @@ export class Query implements firestore.Query { ]; validateStringEnum('Query.where', whereFilterOpEnums, 2, opStr); - let fieldValue: FieldValue; + let fieldValue: api.Value; const fieldPath = fieldPathFromArgument('Query.where', field); const operator = Operator.fromString(opStr); if (fieldPath.isKeyField()) { @@ -1556,11 +1512,11 @@ export class Query implements firestore.Query { ); } else if (operator === Operator.IN) { this.validateDisjunctiveFilterElements(value, operator); - const referenceList: FieldValue[] = []; - for (const arrayValue of value as FieldValue[]) { + const referenceList: api.Value[] = []; + for (const arrayValue of value as api.Value[]) { referenceList.push(this.parseDocumentIdValue(arrayValue)); } - fieldValue = new ArrayValue(referenceList); + fieldValue = { arrayValue: { values: referenceList } }; } else { fieldValue = this.parseDocumentIdValue(value); } @@ -1571,7 +1527,7 @@ export class Query implements firestore.Query { ) { this.validateDisjunctiveFilterElements(value, operator); } - fieldValue = this.firestore._dataConverter.parseQueryValue( + fieldValue = this.firestore._dataReader.parseQueryValue( 'Query.where', value, // We only allow nested arrays for IN queries. @@ -1766,7 +1722,7 @@ export class Query implements firestore.Query { `${methodName}().` ); } - return this.boundFromDocument(methodName, snap._document!, before); + return this.boundFromDocument(snap._document!, before); } else { const allFields = [docOrField].concat(fields); return this.boundFromFields(methodName, allFields, before); @@ -1784,12 +1740,8 @@ export class Query implements firestore.Query { * of the query or if any of the fields in the order by are an uncommitted * server timestamp. */ - private boundFromDocument( - methodName: string, - doc: Document, - before: boolean - ): Bound { - const components: FieldValue[] = []; + private boundFromDocument(doc: Document, before: boolean): Bound { + const components: api.Value[] = []; // Because people expect to continue/end a query at the exact document // provided, we need to use the implicit sort order rather than the explicit @@ -1800,10 +1752,10 @@ export class Query implements firestore.Query { // results. for (const orderBy of this._query.orderBy) { if (orderBy.field.isKeyField()) { - components.push(new RefValue(this.firestore._databaseId, doc.key)); + components.push(refValue(this.firestore._databaseId, doc.key)); } else { const value = doc.field(orderBy.field); - if (value instanceof ServerTimestampValue) { + if (isServerTimestamp(value)) { throw new FirestoreError( Code.INVALID_ARGUMENT, 'Invalid query. You are trying to start or end a query using a ' + @@ -1847,7 +1799,7 @@ export class Query implements firestore.Query { ); } - const components: FieldValue[] = []; + const components: api.Value[] = []; for (let i = 0; i < values.length; i++) { const rawValue = values[i]; const orderByComponent = orderBy[i]; @@ -1881,9 +1833,9 @@ export class Query implements firestore.Query { ); } const key = new DocumentKey(path); - components.push(new RefValue(this.firestore._databaseId, key)); + components.push(refValue(this.firestore._databaseId, key)); } else { - const wrapped = this.firestore._dataConverter.parseQueryValue( + const wrapped = this.firestore._dataReader.parseQueryValue( methodName, rawValue ); @@ -2080,7 +2032,7 @@ export class Query implements firestore.Query { * appropriate errors if the value is anything other than a DocumentReference * or String, or if the string is malformed. */ - private parseDocumentIdValue(documentIdValue: unknown): RefValue { + private parseDocumentIdValue(documentIdValue: unknown): api.Value { if (typeof documentIdValue === 'string') { if (documentIdValue === '') { throw new FirestoreError( @@ -2111,10 +2063,10 @@ export class Query implements firestore.Query { `but '${path}' is not because it has an odd number of segments (${path.length}).` ); } - return new RefValue(this.firestore._databaseId, new DocumentKey(path)); + return refValue(this.firestore._databaseId, new DocumentKey(path)); } else if (documentIdValue instanceof DocumentReference) { const ref = documentIdValue as DocumentReference; - return new RefValue(this.firestore._databaseId, ref._key); + return refValue(this.firestore._databaseId, ref._key); } else { throw new FirestoreError( Code.INVALID_ARGUMENT, diff --git a/packages/firestore/src/api/field_value.ts b/packages/firestore/src/api/field_value.ts index 576ea915869..06032b11282 100644 --- a/packages/firestore/src/api/field_value.ts +++ b/packages/firestore/src/api/field_value.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/packages/firestore/src/api/user_data_converter.ts b/packages/firestore/src/api/user_data_reader.ts similarity index 92% rename from packages/firestore/src/api/user_data_converter.ts rename to packages/firestore/src/api/user_data_reader.ts index a5307174e87..12d7e8d59bb 100644 --- a/packages/firestore/src/api/user_data_converter.ts +++ b/packages/firestore/src/api/user_data_reader.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,25 +17,11 @@ import * as firestore from '@firebase/firestore-types'; -import { Timestamp } from '../api/timestamp'; +import * as api from '../protos/firestore_proto_api'; + +import { Timestamp } from './timestamp'; import { DatabaseId } from '../core/database_info'; import { DocumentKey } from '../model/document_key'; -import { - FieldValue, - NumberValue, - ObjectValue, - ArrayValue, - BlobValue, - BooleanValue, - DoubleValue, - GeoPointValue, - IntegerValue, - NullValue, - RefValue, - StringValue, - TimestampValue -} from '../model/field_value'; - import { FieldMask, FieldTransform, @@ -49,17 +35,15 @@ import { FieldPath } from '../model/path'; import { assert, fail } from '../util/assert'; import { Code, FirestoreError } from '../util/error'; import { isPlainObject, valueDescription } from '../util/input_validation'; -import { primitiveComparator } from '../util/misc'; import { Dict, forEach, isEmpty } from '../util/obj'; -import { SortedMap } from '../util/sorted_map'; -import * as typeUtils from '../util/types'; - +import { ObjectValue } from '../model/field_value'; import { ArrayRemoveTransformOperation, ArrayUnionTransformOperation, NumericIncrementTransformOperation, ServerTimestampTransform } from '../model/transform_operation'; +import { JsonProtoSerializer } from '../remote/serializer'; import { SortedSet } from '../util/sorted_set'; import { Blob } from './blob'; import { @@ -291,7 +275,7 @@ class ParseContext { * avoiding a circular dependency between user_data_converter.ts and * database.ts * * Tests to convert test-only sentinels (e.g. '') into types - * compatible with UserDataConverter. + * compatible with UserDataReader. * * Returns the converted value (can return back the input to act as a no-op). * @@ -312,8 +296,11 @@ export class DocumentKeyReference { * Helper for parsing raw user input (provided via the API) into internal model * classes. */ -export class UserDataConverter { - constructor(private preConverter: DataPreConverter) {} +export class UserDataReader { + constructor( + private readonly serializer: JsonProtoSerializer, + private readonly preConverter: DataPreConverter + ) {} /** Parse document data from a non-merge set() call. */ parseSetData(methodName: string, input: unknown): ParsedSetData { @@ -323,11 +310,10 @@ export class UserDataConverter { FieldPath.EMPTY_PATH ); validatePlainObject('Data must be an object, but it was:', context, input); - - const updateData = this.parseData(input, context); + const updateData = this.parseObject(input, context)!; return new ParsedSetData( - updateData as ObjectValue, + new ObjectValue(updateData), /* fieldMask= */ null, context.fieldTransforms ); @@ -345,8 +331,8 @@ export class UserDataConverter { FieldPath.EMPTY_PATH ); validatePlainObject('Data must be an object, but it was:', context, input); + const updateData = this.parseObject(input, context); - const updateData = this.parseData(input, context) as ObjectValue; let fieldMask: FieldMask; let fieldTransforms: FieldTransform[]; @@ -388,7 +374,7 @@ export class UserDataConverter { ); } return new ParsedSetData( - updateData as ObjectValue, + new ObjectValue(updateData), fieldMask, fieldTransforms ); @@ -501,7 +487,7 @@ export class UserDataConverter { methodName: string, input: unknown, allowArrays = false - ): FieldValue { + ): api.Value { const context = new ParseContext( allowArrays ? UserDataSource.ArrayArgument : UserDataSource.Argument, methodName, @@ -535,11 +521,11 @@ export class UserDataConverter { * @return The parsed value, or null if the value was a FieldValue sentinel * that should not be included in the resulting parsed data. */ - private parseData(input: unknown, context: ParseContext): FieldValue | null { + private parseData(input: unknown, context: ParseContext): api.Value | null { input = this.runPreConverter(input, context); if (looksLikeJsonObject(input)) { validatePlainObject('Unsupported field value:', context, input); - return this.parseObject(input as Dict, context); + return this.parseObject(input, context); } else if (input instanceof FieldValueImpl) { // FieldValues usually parse into transforms (except FieldValue.delete()) // in which case we do not want to include this field in our parsed data @@ -575,8 +561,11 @@ export class UserDataConverter { } } - private parseObject(obj: Dict, context: ParseContext): FieldValue { - let result = new SortedMap(primitiveComparator); + private parseObject( + obj: Dict, + context: ParseContext + ): { mapValue: api.MapValue } { + const fields: Dict = {}; if (isEmpty(obj)) { // If we encounter an empty object, we explicitly add it to the update @@ -591,16 +580,16 @@ export class UserDataConverter { context.childContextForField(key) ); if (parsedValue != null) { - result = result.insert(key, parsedValue); + fields[key] = parsedValue; } }); } - return new ObjectValue(result); + return { mapValue: { fields } }; } - private parseArray(array: unknown[], context: ParseContext): FieldValue { - const result = [] as FieldValue[]; + private parseArray(array: unknown[], context: ParseContext): api.Value { + const values: api.Value[] = []; let entryIndex = 0; for (const entry of array) { let parsedEntry = this.parseData( @@ -610,12 +599,12 @@ export class UserDataConverter { if (parsedEntry == null) { // Just include nulls in the array for fields being replaced with a // sentinel. - parsedEntry = NullValue.INSTANCE; + parsedEntry = { nullValue: 'NULL_VALUE' }; } - result.push(parsedEntry); + values.push(parsedEntry); entryIndex++; } - return new ArrayValue(result); + return { arrayValue: { values } }; } /** @@ -686,8 +675,11 @@ export class UserDataConverter { const operand = this.parseQueryValue( 'FieldValue.increment', value._operand - ) as NumberValue; - const numericIncrement = new NumericIncrementTransformOperation(operand); + ); + const numericIncrement = new NumericIncrementTransformOperation( + this.serializer, + operand + ); context.fieldTransforms.push( new FieldTransform(context.path, numericIncrement) ); @@ -701,37 +693,43 @@ export class UserDataConverter { * * @return The parsed value */ - private parseScalarValue(value: unknown, context: ParseContext): FieldValue { + private parseScalarValue(value: unknown, context: ParseContext): api.Value { if (value === null) { - return NullValue.INSTANCE; + return { nullValue: 'NULL_VALUE' }; } else if (typeof value === 'number') { - if (typeUtils.isSafeInteger(value)) { - return new IntegerValue(value); - } else { - return new DoubleValue(value); - } + return this.serializer.toNumber(value); } else if (typeof value === 'boolean') { - return BooleanValue.of(value); + return { booleanValue: value }; } else if (typeof value === 'string') { - return new StringValue(value); + return { stringValue: value }; } else if (value instanceof Date) { - return new TimestampValue(Timestamp.fromDate(value)); + const timestamp = Timestamp.fromDate(value); + return { timestampValue: this.serializer.toTimestamp(timestamp) }; } else if (value instanceof Timestamp) { // Firestore backend truncates precision down to microseconds. To ensure // offline mode works the same with regards to truncation, perform the // truncation immediately without waiting for the backend to do that. - return new TimestampValue( - new Timestamp( - value.seconds, - Math.floor(value.nanoseconds / 1000) * 1000 - ) + const timestamp = new Timestamp( + value.seconds, + Math.floor(value.nanoseconds / 1000) * 1000 ); + return { timestampValue: this.serializer.toTimestamp(timestamp) }; } else if (value instanceof GeoPoint) { - return new GeoPointValue(value); + return { + geoPointValue: { + latitude: value.latitude, + longitude: value.longitude + } + }; } else if (value instanceof Blob) { - return new BlobValue(value); + return { bytesValue: this.serializer.toBytes(value) }; } else if (value instanceof DocumentKeyReference) { - return new RefValue(value.databaseId, value.key); + return { + referenceValue: this.serializer.toResourceName( + value.key.path, + value.databaseId + ) + }; } else { throw context.createError( `Unsupported field value: ${valueDescription(value)}` @@ -742,7 +740,7 @@ export class UserDataConverter { private parseArrayTransformElements( methodName: string, elements: unknown[] - ): FieldValue[] { + ): api.Value[] { return elements.map((element, i) => { // Although array transforms are used with writes, the actual elements // being unioned or removed are not considered writes since they cannot @@ -782,7 +780,7 @@ function validatePlainObject( message: string, context: ParseContext, input: unknown -): void { +): asserts input is Dict { if (!looksLikeJsonObject(input) || !isPlainObject(input)) { const description = valueDescription(input); if (description === 'an object') { diff --git a/packages/firestore/src/api/user_data_writer.ts b/packages/firestore/src/api/user_data_writer.ts new file mode 100644 index 00000000000..4f442d2db3f --- /dev/null +++ b/packages/firestore/src/api/user_data_writer.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright 2020 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 * as firestore from '@firebase/firestore-types'; + +import * as api from '../protos/firestore_proto_api'; +import * as log from '../util/log'; + +import { DocumentReference, Firestore } from './database'; +import { Blob } from './blob'; +import { GeoPoint } from './geo_point'; +import { Timestamp } from './timestamp'; +import { DatabaseId } from '../core/database_info'; +import { DocumentKey } from '../model/document_key'; +import { + normalizeByteString, + normalizeNumber, + normalizeTimestamp, + typeOrder +} from '../model/values'; +import { + getLocalWriteTime, + getPreviousValue +} from '../model/server_timestamps'; +import { assert, fail } from '../util/assert'; +import { forEach } from '../util/obj'; +import { TypeOrder } from '../model/field_value'; +import { ResourcePath } from '../model/path'; +import { isValidResourceName } from '../remote/serializer'; + +export type ServerTimestampBehavior = 'estimate' | 'previous' | 'none'; + +/** + * Converts Firestore's internal types to the JavaScript types that we expose + * to the user. + */ +export class UserDataWriter { + constructor( + private readonly firestore: Firestore, + private readonly timestampsInSnapshots: boolean, + private readonly serverTimestampBehavior?: ServerTimestampBehavior, + private readonly converter?: firestore.FirestoreDataConverter + ) {} + + convertValue(value: api.Value): unknown { + switch (typeOrder(value)) { + case TypeOrder.NullValue: + return null; + case TypeOrder.BooleanValue: + return value.booleanValue!; + case TypeOrder.NumberValue: + return normalizeNumber(value.integerValue || value.doubleValue); + case TypeOrder.TimestampValue: + return this.convertTimestamp(value.timestampValue!); + case TypeOrder.ServerTimestampValue: + return this.convertServerTimestamp(value); + case TypeOrder.StringValue: + return value.stringValue!; + case TypeOrder.BlobValue: + return new Blob(normalizeByteString(value.bytesValue!)); + case TypeOrder.RefValue: + return this.convertReference(value.referenceValue!); + case TypeOrder.GeoPointValue: + return new GeoPoint( + value.geoPointValue!.latitude!, + value.geoPointValue!.longitude! + ); + case TypeOrder.ArrayValue: + return this.convertArray(value.arrayValue!); + case TypeOrder.ObjectValue: + return this.convertObject(value.mapValue!); + default: + throw fail('Invalid value type: ' + JSON.stringify(value)); + } + } + + private convertObject(mapValue: api.MapValue): firestore.DocumentData { + const result: firestore.DocumentData = {}; + forEach(mapValue.fields || {}, (key, value) => { + result[key] = this.convertValue(value); + }); + return result; + } + + private convertArray(arrayValue: api.ArrayValue): unknown[] { + return (arrayValue.values || []).map(value => this.convertValue(value)); + } + + private convertServerTimestamp(value: api.Value): unknown { + switch (this.serverTimestampBehavior) { + case 'previous': + const previousValue = getPreviousValue(value); + if (previousValue == null) { + return null; + } + return this.convertValue(previousValue); + case 'estimate': + return this.convertTimestamp(getLocalWriteTime(value)); + default: + return null; + } + } + + private convertTimestamp(value: api.Timestamp): Timestamp | Date { + const normalizedValue = normalizeTimestamp(value); + const timestamp = new Timestamp( + normalizedValue.seconds, + normalizedValue.nanos + ); + if (this.timestampsInSnapshots) { + return timestamp; + } else { + return timestamp.toDate(); + } + } + + private convertReference(name: string): DocumentReference { + const resourcePath = ResourcePath.fromString(name); + assert( + isValidResourceName(resourcePath), + 'ReferenceValue is not valid ' + name + ); + const databaseId = new DatabaseId(resourcePath.get(1), resourcePath.get(3)); + const key = new DocumentKey(resourcePath.popFirst(5)); + + if (!databaseId.isEqual(this.firestore._databaseId)) { + // TODO(b/64130202): Somehow support foreign references. + log.error( + `Document ${key} contains a document ` + + `reference within a different database (` + + `${databaseId.projectId}/${databaseId.database}) which is not ` + + `supported. It will be treated as a reference in the current ` + + `database (${this.firestore._databaseId.projectId}/${this.firestore._databaseId.database}) ` + + `instead.` + ); + } + + return new DocumentReference(key, this.firestore, this.converter); + } +} diff --git a/packages/firestore/src/core/query.ts b/packages/firestore/src/core/query.ts index 424cb9243d5..1d139140881 100644 --- a/packages/firestore/src/core/query.ts +++ b/packages/firestore/src/core/query.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,15 +15,21 @@ * limitations under the License. */ +import * as api from '../protos/firestore_proto_api'; + import { Document } from '../model/document'; import { DocumentKey } from '../model/document_key'; import { - ArrayValue, - DoubleValue, - FieldValue, - NullValue, - RefValue -} from '../model/field_value'; + canonicalId, + valueCompare, + arrayValueContains, + valueEquals, + isArray, + isNanValue, + isNullValue, + isReferenceValue, + typeOrder +} from '../model/values'; import { FieldPath, ResourcePath } from '../model/path'; import { assert, fail } from '../util/assert'; import { Code, FirestoreError } from '../util/error'; @@ -504,7 +510,7 @@ export class FieldFilter extends Filter { protected constructor( public field: FieldPath, public op: Operator, - public value: FieldValue + public value: api.Value ) { super(); } @@ -512,27 +518,21 @@ export class FieldFilter extends Filter { /** * Creates a filter based on the provided arguments. */ - static create( - field: FieldPath, - op: Operator, - value: FieldValue - ): FieldFilter { + static create(field: FieldPath, op: Operator, value: api.Value): FieldFilter { if (field.isKeyField()) { if (op === Operator.IN) { assert( - value instanceof ArrayValue, + isArray(value), 'Comparing on key with IN, but filter value not an ArrayValue' ); assert( - value.internalValue.every(elem => { - return elem instanceof RefValue; - }), + (value.arrayValue.values || []).every(elem => isReferenceValue(elem)), 'Comparing on key with IN, but an array value was not a RefValue' ); return new KeyFieldInFilter(field, value); } else { assert( - value instanceof RefValue, + isReferenceValue(value), 'Comparing on key, but filter value not a RefValue' ); assert( @@ -541,7 +541,7 @@ export class FieldFilter extends Filter { ); return new KeyFieldFilter(field, op, value); } - } else if (value.isEqual(NullValue.INSTANCE)) { + } else if (isNullValue(value)) { if (op !== Operator.EQUAL) { throw new FirestoreError( Code.INVALID_ARGUMENT, @@ -549,7 +549,7 @@ export class FieldFilter extends Filter { ); } return new FieldFilter(field, op, value); - } else if (value.isEqual(DoubleValue.NAN)) { + } else if (isNanValue(value)) { if (op !== Operator.EQUAL) { throw new FirestoreError( Code.INVALID_ARGUMENT, @@ -561,13 +561,13 @@ export class FieldFilter extends Filter { return new ArrayContainsFilter(field, value); } else if (op === Operator.IN) { assert( - value instanceof ArrayValue, + isArray(value), 'IN filter has invalid value: ' + value.toString() ); return new InFilter(field, value); } else if (op === Operator.ARRAY_CONTAINS_ANY) { assert( - value instanceof ArrayValue, + isArray(value), 'ARRAY_CONTAINS_ANY filter has invalid value: ' + value.toString() ); return new ArrayContainsAnyFilter(field, value); @@ -582,8 +582,8 @@ export class FieldFilter extends Filter { // Only compare types with matching backend order (such as double and int). return ( other !== null && - this.value.typeOrder === other.typeOrder && - this.matchesComparison(other.compareTo(this.value)) + typeOrder(this.value) === typeOrder(other) && + this.matchesComparison(valueCompare(other, this.value)) ); } @@ -620,7 +620,9 @@ export class FieldFilter extends Filter { // the same description, such as the int 3 and the string "3". So we should // add the types in here somehow, too. return ( - this.field.canonicalString() + this.op.toString() + this.value.toString() + this.field.canonicalString() + + this.op.toString() + + canonicalId(this.value) ); } @@ -629,7 +631,7 @@ export class FieldFilter extends Filter { return ( this.op.isEqual(other.op) && this.field.isEqual(other.field) && - this.value.isEqual(other.value) + valueEquals(this.value, other.value) ); } else { return false; @@ -637,71 +639,88 @@ export class FieldFilter extends Filter { } toString(): string { - return `${this.field.canonicalString()} ${this.op} ${this.value.value()}`; + return `${this.field.canonicalString()} ${this.op} ${canonicalId( + this.value + )}`; } } /** Filter that matches on key fields (i.e. '__name__'). */ export class KeyFieldFilter extends FieldFilter { + private readonly key: DocumentKey; + + constructor(field: FieldPath, op: Operator, value: api.Value) { + super(field, op, value); + assert(isReferenceValue(value), 'KeyFieldFilter expects a ReferenceValue'); + this.key = DocumentKey.fromName(value.referenceValue); + } + matches(doc: Document): boolean { - const refValue = this.value as RefValue; - const comparison = DocumentKey.comparator(doc.key, refValue.key); + const comparison = DocumentKey.comparator(doc.key, this.key); return this.matchesComparison(comparison); } } /** Filter that matches on key fields within an array. */ export class KeyFieldInFilter extends FieldFilter { - constructor(field: FieldPath, public value: ArrayValue) { + private readonly keys: DocumentKey[]; + + constructor(field: FieldPath, value: api.Value) { super(field, Operator.IN, value); + assert(isArray(value), 'KeyFieldInFilter expects an ArrayValue'); + this.keys = (value.arrayValue.values || []).map(v => { + assert( + isReferenceValue(v), + 'Comparing on key with IN, but an array value was not a ReferenceValue' + ); + return DocumentKey.fromName(v.referenceValue); + }); } matches(doc: Document): boolean { - const arrayValue = this.value; - return arrayValue.internalValue.some(refValue => { - return doc.key.isEqual((refValue as RefValue).key); - }); + return this.keys.some(key => key.isEqual(doc.key)); } } /** A Filter that implements the array-contains operator. */ export class ArrayContainsFilter extends FieldFilter { - constructor(field: FieldPath, value: FieldValue) { + constructor(field: FieldPath, value: api.Value) { super(field, Operator.ARRAY_CONTAINS, value); } matches(doc: Document): boolean { const other = doc.field(this.field); - return other instanceof ArrayValue && other.contains(this.value); + return isArray(other) && arrayValueContains(other.arrayValue, this.value); } } /** A Filter that implements the IN operator. */ export class InFilter extends FieldFilter { - constructor(field: FieldPath, public value: ArrayValue) { + constructor(field: FieldPath, value: api.Value) { super(field, Operator.IN, value); + assert(isArray(value), 'InFilter expects an ArrayValue'); } matches(doc: Document): boolean { - const arrayValue = this.value; const other = doc.field(this.field); - return other !== null && arrayValue.contains(other); + return other !== null && arrayValueContains(this.value.arrayValue!, other); } } /** A Filter that implements the array-contains-any operator. */ export class ArrayContainsAnyFilter extends FieldFilter { - constructor(field: FieldPath, public value: ArrayValue) { + constructor(field: FieldPath, value: api.Value) { super(field, Operator.ARRAY_CONTAINS_ANY, value); + assert(isArray(value), 'ArrayContainsAnyFilter expects an ArrayValue'); } matches(doc: Document): boolean { const other = doc.field(this.field); - return ( - other instanceof ArrayValue && - other.internalValue.some(lhsElem => { - return this.value.contains(lhsElem); - }) + if (!isArray(other) || !other.arrayValue.values) { + return false; + } + return other.arrayValue.values.some(val => + arrayValueContains(this.value.arrayValue!, val) ); } } @@ -735,15 +754,13 @@ export class Direction { * just after the provided values. */ export class Bound { - constructor(readonly position: FieldValue[], readonly before: boolean) {} + constructor(readonly position: api.Value[], readonly before: boolean) {} canonicalId(): string { // TODO(b/29183165): Make this collision robust. - let canonicalId = this.before ? 'b:' : 'a:'; - for (const component of this.position) { - canonicalId += component.toString(); - } - return canonicalId; + return `${this.before ? 'b' : 'a'}:${this.position + .map(p => canonicalId(p)) + .join(',')}`; } /** @@ -761,17 +778,20 @@ export class Bound { const component = this.position[i]; if (orderByComponent.field.isKeyField()) { assert( - component instanceof RefValue, + isReferenceValue(component), 'Bound has a non-key value where the key path is being used.' ); - comparison = DocumentKey.comparator(component.key, doc.key); + comparison = DocumentKey.comparator( + DocumentKey.fromName(component.referenceValue), + doc.key + ); } else { const docValue = doc.field(orderByComponent.field); assert( docValue !== null, 'Field should exist since document matched the orderBy already.' ); - comparison = component.compareTo(docValue); + comparison = valueCompare(component, docValue); } if (orderByComponent.dir === Direction.DESCENDING) { comparison = comparison * -1; @@ -796,7 +816,7 @@ export class Bound { for (let i = 0; i < this.position.length; i++) { const thisPosition = this.position[i]; const otherPosition = other.position[i]; - if (!thisPosition.isEqual(otherPosition)) { + if (!valueEquals(thisPosition, otherPosition)) { return false; } } diff --git a/packages/firestore/src/core/target.ts b/packages/firestore/src/core/target.ts index cd7ca433666..4836d01a0bd 100644 --- a/packages/firestore/src/core/target.ts +++ b/packages/firestore/src/core/target.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2019 Google Inc. + * Copyright 2019 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -55,16 +55,10 @@ export class Target { canonicalId += '|cg:' + this.collectionGroup; } canonicalId += '|f:'; - for (const filter of this.filters) { - canonicalId += filter.canonicalId(); - canonicalId += ','; - } + canonicalId += this.filters.map(f => f.canonicalId()).join(','); canonicalId += '|ob:'; - // TODO(dimond): make this collision resistant - for (const orderBy of this.orderBy) { - canonicalId += orderBy.canonicalId(); - canonicalId += ','; - } + canonicalId += this.orderBy.map(o => o.canonicalId()).join(','); + if (!isNullOrUndefined(this.limit)) { canonicalId += '|l:'; canonicalId += this.limit!; diff --git a/packages/firestore/src/core/transaction.ts b/packages/firestore/src/core/transaction.ts index a623170db4d..9c1ee3c6e8d 100644 --- a/packages/firestore/src/core/transaction.ts +++ b/packages/firestore/src/core/transaction.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,7 +15,7 @@ * limitations under the License. */ -import { ParsedSetData, ParsedUpdateData } from '../api/user_data_converter'; +import { ParsedSetData, ParsedUpdateData } from '../api/user_data_reader'; import { documentVersionMap } from '../model/collections'; import { Document, NoDocument, MaybeDocument } from '../model/document'; diff --git a/packages/firestore/src/local/indexeddb_persistence.ts b/packages/firestore/src/local/indexeddb_persistence.ts index 25c64e4d8fd..dfce9efeaf3 100644 --- a/packages/firestore/src/local/indexeddb_persistence.ts +++ b/packages/firestore/src/local/indexeddb_persistence.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,7 +21,7 @@ import { PersistenceSettings } from '../core/firestore_client'; import { ListenSequence, SequenceNumberSyncer } from '../core/listen_sequence'; import { ListenSequenceNumber, TargetId } from '../core/types'; import { DocumentKey } from '../model/document_key'; -import { Platform } from '../platform/platform'; +import { Platform, PlatformSupport } from '../platform/platform'; import { JsonProtoSerializer } from '../remote/serializer'; import { assert, fail } from '../util/assert'; import { AsyncQueue, TimerId } from '../util/async_queue'; @@ -1326,7 +1326,7 @@ export class IndexedDbPersistenceProvider implements PersistenceProvider { // Opt to use proto3 JSON in case the platform doesn't support Uint8Array. const serializer = new JsonProtoSerializer(databaseInfo.databaseId, { - useProto3Json: true + useProto3Json: PlatformSupport.getPlatform().useProto3Json }); if (!WebStorageSharedClientState.isAvailable(platform)) { diff --git a/packages/firestore/src/local/indexeddb_schema.ts b/packages/firestore/src/local/indexeddb_schema.ts index fa282e6f560..b7bd71377e5 100644 --- a/packages/firestore/src/local/indexeddb_schema.ts +++ b/packages/firestore/src/local/indexeddb_schema.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -33,22 +33,23 @@ import { SimpleDbSchemaConverter, SimpleDbTransaction } from './simple_db'; /** * Schema Version for the Web client: - * 1. Initial version including Mutation Queue, Query Cache, and Remote Document - * Cache - * 2. Used to ensure a targetGlobal object exists and add targetCount to it. No - * longer required because migration 3 unconditionally clears it. - * 3. Dropped and re-created Query Cache to deal with cache corruption related - * to limbo resolution. Addresses - * https://github.com/firebase/firebase-ios-sdk/issues/1548 - * 4. Multi-Tab Support. - * 5. Removal of held write acks. - * 6. Create document global for tracking document cache size. - * 7. Ensure every cached document has a sentinel row with a sequence number. - * 8. Add collection-parent index for Collection Group queries. - * 9. Change RemoteDocumentChanges store to be keyed by readTime rather than - * an auto-incrementing ID. This is required for Index-Free queries. + * 1. Initial version including Mutation Queue, Query Cache, and Remote + * Document Cache + * 2. Used to ensure a targetGlobal object exists and add targetCount to it. No + * longer required because migration 3 unconditionally clears it. + * 3. Dropped and re-created Query Cache to deal with cache corruption related + * to limbo resolution. Addresses + * https://github.com/firebase/firebase-ios-sdk/issues/1548 + * 4. Multi-Tab Support. + * 5. Removal of held write acks. + * 6. Create document global for tracking document cache size. + * 7. Ensure every cached document has a sentinel row with a sequence number. + * 8. Add collection-parent index for Collection Group queries. + * 9. Change RemoteDocumentChanges store to be keyed by readTime rather than + * an auto-incrementing ID. This is required for Index-Free queries. + * 10. Rewrite the canonical IDs to the explicit Protobuf-based format. */ -export const SCHEMA_VERSION = 9; +export const SCHEMA_VERSION = 10; /** Performs database creation and schema upgrades. */ export class SchemaConverter implements SimpleDbSchemaConverter { @@ -71,7 +72,7 @@ export class SchemaConverter implements SimpleDbSchemaConverter { fromVersion < toVersion && fromVersion >= 0 && toVersion <= SCHEMA_VERSION, - `Unexpected schema upgrade from v${fromVersion} to v{toVersion}.` + `Unexpected schema upgrade from v${fromVersion} to v${toVersion}.` ); const simpleDbTransaction = new SimpleDbTransaction(txn); @@ -145,6 +146,10 @@ export class SchemaConverter implements SimpleDbSchemaConverter { createRemoteDocumentReadTimeIndex(txn); }); } + + if (fromVersion < 10 && toVersion >= 10) { + p = p.next(() => this.rewriteCanonicalIds(simpleDbTransaction)); + } return p; } @@ -299,6 +304,17 @@ export class SchemaConverter implements SimpleDbSchemaConverter { }); }); } + + private rewriteCanonicalIds( + txn: SimpleDbTransaction + ): PersistencePromise { + const targetStore = txn.store(DbTarget.store); + return targetStore.iterate((key, originalDbTarget) => { + const originalTargetData = this.serializer.fromDbTarget(originalDbTarget); + const updatedDbTarget = this.serializer.toDbTarget(originalTargetData); + return targetStore.put(updatedDbTarget); + }); + } } function sentinelKey(path: ResourcePath): DbTargetDocumentKey { @@ -1079,6 +1095,8 @@ export const V8_STORES = [...V6_STORES, DbCollectionParent.store]; // V9 does not change the set of stores. +// V10 does not change the set of stores. + /** * The list of all default IndexedDB stores used throughout the SDK. This is * used when creating transactions so that access across all stores is done diff --git a/packages/firestore/src/local/local_serializer.ts b/packages/firestore/src/local/local_serializer.ts index a40f77cbddb..f2901007ccd 100644 --- a/packages/firestore/src/local/local_serializer.ts +++ b/packages/firestore/src/local/local_serializer.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -79,9 +79,7 @@ export class LocalSerializer { const dbReadTime = this.toDbTimestampKey(readTime); const parentPath = maybeDoc.key.path.popLast().toArray(); if (maybeDoc instanceof Document) { - const doc = maybeDoc.proto - ? maybeDoc.proto - : this.remoteSerializer.toDocument(maybeDoc); + const doc = this.remoteSerializer.toDocument(maybeDoc); const hasCommittedMutations = maybeDoc.hasCommittedMutations; return new DbRemoteDocument( /* unknownDocument= */ null, diff --git a/packages/firestore/src/local/memory_persistence.ts b/packages/firestore/src/local/memory_persistence.ts index 207326fbdec..5c8c640ddb6 100644 --- a/packages/firestore/src/local/memory_persistence.ts +++ b/packages/firestore/src/local/memory_persistence.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -35,6 +35,7 @@ import { DatabaseInfo } from '../core/database_info'; import { PersistenceSettings } from '../core/firestore_client'; import { ListenSequence } from '../core/listen_sequence'; import { ListenSequenceNumber } from '../core/types'; +import { estimateByteSize } from '../model/values'; import { AsyncQueue } from '../util/async_queue'; import { MemoryIndexManager } from './memory_index_manager'; import { MemoryMutationQueue } from './memory_mutation_queue'; @@ -88,7 +89,7 @@ export class MemoryPersistence implements Persistence { private _started = false; readonly referenceDelegate: MemoryReferenceDelegate; - + /** * The constructor accepts a factory for creating a reference delegate. This * allows both the delegate and this instance to have strong references to @@ -474,7 +475,7 @@ export class MemoryLruDelegate implements ReferenceDelegate, LruDelegate { documentSize(maybeDoc: MaybeDocument): number { let documentSize = maybeDoc.key.toString().length; if (maybeDoc instanceof Document) { - documentSize += maybeDoc.data().approximateByteSize(); + documentSize += estimateByteSize(maybeDoc.toProto()); } return documentSize; } diff --git a/packages/firestore/src/model/document.ts b/packages/firestore/src/model/document.ts index 051af3c0548..9753d0d02ad 100644 --- a/packages/firestore/src/model/document.ts +++ b/packages/firestore/src/model/document.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,15 +15,15 @@ * limitations under the License. */ +import * as api from '../protos/firestore_proto_api'; + import { SnapshotVersion } from '../core/snapshot_version'; -import { assert, fail } from '../util/assert'; +import { fail } from '../util/assert'; import { DocumentKey } from './document_key'; -import { FieldValue, JsonObject, ObjectValue } from './field_value'; +import { ObjectValue } from './field_value'; import { FieldPath } from './path'; - -import * as api from '../protos/firestore_proto_api'; -import * as obj from '../util/obj'; +import { valueCompare } from './values'; export interface DocumentOptions { hasLocalMutations?: boolean; @@ -60,81 +60,27 @@ export class Document extends MaybeDocument { readonly hasLocalMutations: boolean; readonly hasCommittedMutations: boolean; - /** - * A cache of canonicalized FieldPaths to FieldValues that have already been - * deserialized in `getField()`. - */ - private fieldValueCache?: Map; - constructor( key: DocumentKey, version: SnapshotVersion, - options: DocumentOptions, - private objectValue?: ObjectValue, - readonly proto?: api.Document, - private readonly converter?: (value: api.Value) => FieldValue + private readonly objectValue: ObjectValue, + options: DocumentOptions ) { super(key, version); - assert( - this.objectValue !== undefined || - (this.proto !== undefined && this.converter !== undefined), - 'If objectValue is not defined, proto and converter need to be set.' - ); - this.hasLocalMutations = !!options.hasLocalMutations; this.hasCommittedMutations = !!options.hasCommittedMutations; } - field(path: FieldPath): FieldValue | null { - if (this.objectValue) { - return this.objectValue.field(path); - } else { - if (!this.fieldValueCache) { - // TODO(b/136090445): Remove the cache when `getField` is no longer - // called during Query ordering. - this.fieldValueCache = new Map(); - } - - const canonicalPath = path.canonicalString(); - - let fieldValue = this.fieldValueCache.get(canonicalPath); - - if (fieldValue === undefined) { - // Instead of deserializing the full Document proto, we only - // deserialize the value at the requested field path. This speeds up - // Query execution as query filters can discard documents based on a - // single field. - const protoValue = this.getProtoField(path); - if (protoValue === undefined) { - fieldValue = null; - } else { - fieldValue = this.converter!(protoValue); - } - this.fieldValueCache.set(canonicalPath, fieldValue); - } - - return fieldValue!; - } + field(path: FieldPath): api.Value | null { + return this.objectValue.field(path); } data(): ObjectValue { - if (!this.objectValue) { - const result = ObjectValue.newBuilder(); - obj.forEach(this.proto!.fields || {}, (key: string, value: api.Value) => { - result.set(new FieldPath([key]), this.converter!(value)); - }); - this.objectValue = result.build(); - - // Once objectValue is computed, values inside the fieldValueCache are no - // longer accessed. - this.fieldValueCache = undefined; - } - return this.objectValue; } - value(): JsonObject { - return this.data().value(); + toProto(): { mapValue: api.MapValue } { + return this.objectValue.proto; } isEqual(other: MaybeDocument | null | undefined): boolean { @@ -144,13 +90,15 @@ export class Document extends MaybeDocument { this.version.isEqual(other.version) && this.hasLocalMutations === other.hasLocalMutations && this.hasCommittedMutations === other.hasCommittedMutations && - this.data().isEqual(other.data()) + this.objectValue.isEqual(other.objectValue) ); } toString(): string { return ( - `Document(${this.key}, ${this.version}, ${this.data().toString()}, ` + + `Document(${this.key}, ${ + this.version + }, ${this.objectValue.toString()}, ` + `{hasLocalMutations: ${this.hasLocalMutations}}), ` + `{hasCommittedMutations: ${this.hasCommittedMutations}})` ); @@ -160,34 +108,11 @@ export class Document extends MaybeDocument { return this.hasLocalMutations || this.hasCommittedMutations; } - /** - * Returns the nested Protobuf value for 'path`. Can only be called if - * `proto` was provided at construction time. - */ - private getProtoField(path: FieldPath): api.Value | undefined { - assert( - this.proto !== undefined, - 'Can only call getProtoField() when proto is defined' - ); - - let protoValue: api.Value | undefined = this.proto!.fields - ? this.proto!.fields[path.firstSegment()] - : undefined; - for (let i = 1; i < path.length; ++i) { - if (!protoValue || !protoValue.mapValue || !protoValue.mapValue.fields) { - return undefined; - } - protoValue = protoValue.mapValue.fields[path.get(i)]; - } - - return protoValue; - } - static compareByField(field: FieldPath, d1: Document, d2: Document): number { const v1 = d1.field(field); const v2 = d2.field(field); if (v1 !== null && v2 !== null) { - return v1.compareTo(v2); + return valueCompare(v1, v2); } else { return fail("Trying to compare documents on fields that don't exist"); } diff --git a/packages/firestore/src/model/document_key.ts b/packages/firestore/src/model/document_key.ts index 1bc14bfdb17..956ecde1ba0 100644 --- a/packages/firestore/src/model/document_key.ts +++ b/packages/firestore/src/model/document_key.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,6 +28,10 @@ export class DocumentKey { ); } + static fromName(name: string): DocumentKey { + return new DocumentKey(ResourcePath.fromString(name).popFirst(5)); + } + /** Returns true if the document is in the specified collectionId. */ hasCollectionId(collectionId: string): boolean { return ( @@ -59,21 +63,10 @@ export class DocumentKey { /** * Creates and returns a new document key with the given segments. * - * @param path The segments of the path to the document + * @param segments The segments of the path to the document * @return A new instance of DocumentKey */ static fromSegments(segments: string[]): DocumentKey { return new DocumentKey(new ResourcePath(segments.slice())); } - - /** - * Creates and returns a new document key using '/' to split the string into - * segments. - * - * @param path The slash-separated path string to the document - * @return A new instance of DocumentKey - */ - static fromPathString(path: string): DocumentKey { - return new DocumentKey(ResourcePath.fromString(path)); - } } diff --git a/packages/firestore/src/model/field_value.ts b/packages/firestore/src/model/field_value.ts index bbc8d002d5c..849fff24964 100644 --- a/packages/firestore/src/model/field_value.ts +++ b/packages/firestore/src/model/field_value.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,633 +15,95 @@ * limitations under the License. */ -import { Blob } from '../api/blob'; -import { SnapshotOptions } from '../api/database'; -import { GeoPoint } from '../api/geo_point'; -import { Timestamp } from '../api/timestamp'; -import { DatabaseId } from '../core/database_info'; -import { assert, fail } from '../util/assert'; -import { primitiveComparator } from '../util/misc'; -import { DocumentKey } from './document_key'; +import * as api from '../protos/firestore_proto_api'; + +import { assert } from '../util/assert'; import { FieldMask } from './mutation'; import { FieldPath } from './path'; -import { SortedMap } from '../util/sorted_map'; +import { isServerTimestamp } from './server_timestamps'; +import { valueEquals, isMapValue, typeOrder } from './values'; +import { forEach } from '../util/obj'; import { SortedSet } from '../util/sorted_set'; -/** - * Supported data value types: - * - Null - * - Boolean - * - Long - * - Double - * - String - * - Object - * - Array - * - Binary - * - Timestamp - * - ServerTimestamp (a sentinel used in uncommitted writes) - * - GeoPoint - * - (Document) References - */ - export interface JsonObject { [name: string]: T; } export const enum TypeOrder { - // This order is defined by the backend. + // This order is based on the backend's ordering, but modified to support + // server timestamps. NullValue = 0, BooleanValue = 1, NumberValue = 2, TimestampValue = 3, - StringValue = 4, - BlobValue = 5, - RefValue = 6, - GeoPointValue = 7, - ArrayValue = 8, - ObjectValue = 9 + ServerTimestampValue = 4, + StringValue = 5, + BlobValue = 6, + RefValue = 7, + GeoPointValue = 8, + ArrayValue = 9, + ObjectValue = 10 } -/** Defines the return value for pending server timestamps. */ -export const enum ServerTimestampBehavior { - Default, - Estimate, - Previous -} - -/** Holds properties that define field value deserialization options. */ -export class FieldValueOptions { - constructor( - readonly serverTimestampBehavior: ServerTimestampBehavior, - readonly timestampsInSnapshots: boolean - ) {} - - static fromSnapshotOptions( - options: SnapshotOptions, - timestampsInSnapshots: boolean - ): FieldValueOptions { - switch (options.serverTimestamps) { - case 'estimate': - return new FieldValueOptions( - ServerTimestampBehavior.Estimate, - timestampsInSnapshots - ); - case 'previous': - return new FieldValueOptions( - ServerTimestampBehavior.Previous, - timestampsInSnapshots - ); - case 'none': // Fall-through intended. - case undefined: - return new FieldValueOptions( - ServerTimestampBehavior.Default, - timestampsInSnapshots - ); - default: - return fail('fromSnapshotOptions() called with invalid options.'); - } - } -} - -/** - * Potential types returned by FieldValue.value(). This could be stricter - * (instead of using {}), but there's little benefit. - * - * Note that currently we use AnyJs (which is identical except includes - * undefined) for incoming user data as a convenience to the calling code (but - * we'll throw if the data contains undefined). This should probably be changed - * to use FieldType, but all consuming code will have to be updated to - * explicitly handle undefined and then cast to FieldType or similar. Perhaps - * we should tackle this when adding robust argument validation to the API. - */ -export type FieldType = null | boolean | number | string | {}; - /** - * A field value represents a datatype as stored by Firestore. + * An ObjectValue represents a MapValue in the Firestore Proto and offers the + * ability to add and remove fields (via the ObjectValueBuilder). */ -export abstract class FieldValue { - abstract readonly typeOrder: TypeOrder; - - abstract value(options?: FieldValueOptions): FieldType; - abstract isEqual(other: FieldValue): boolean; - abstract compareTo(other: FieldValue): number; +export class ObjectValue { + static EMPTY = new ObjectValue({ mapValue: {} }); - /** - * Returns an approximate (and wildly inaccurate) in-memory size for the field - * value. - * - * The memory size takes into account only the actual user data as it resides - * in memory and ignores object overhead. - */ - abstract approximateByteSize(): number; - - toString(): string { - const val = this.value(); - return val === null ? 'null' : val.toString(); - } - - defaultCompareTo(other: FieldValue): number { + constructor(public readonly proto: { mapValue: api.MapValue }) { assert( - this.typeOrder !== other.typeOrder, - 'Default compareTo should not be used for values of same type.' - ); - const cmp = primitiveComparator(this.typeOrder, other.typeOrder); - return cmp; - } -} - -export class NullValue extends FieldValue { - typeOrder = TypeOrder.NullValue; - - // internalValue is unused but we add it to work around - // https://github.com/Microsoft/TypeScript/issues/15585 - readonly internalValue = null; - - private constructor() { - super(); - } - - value(options?: FieldValueOptions): null { - return null; - } - - isEqual(other: FieldValue): boolean { - return other instanceof NullValue; - } - - compareTo(other: FieldValue): number { - if (other instanceof NullValue) { - return 0; - } - return this.defaultCompareTo(other); - } - - approximateByteSize(): number { - return 4; - } - - static INSTANCE = new NullValue(); -} - -export class BooleanValue extends FieldValue { - typeOrder = TypeOrder.BooleanValue; - - private constructor(readonly internalValue: boolean) { - super(); - } - - value(options?: FieldValueOptions): boolean { - return this.internalValue; - } - - isEqual(other: FieldValue): boolean { - return ( - other instanceof BooleanValue && - this.internalValue === other.internalValue - ); - } - - compareTo(other: FieldValue): number { - if (other instanceof BooleanValue) { - return primitiveComparator(this.internalValue, other.internalValue); - } - return this.defaultCompareTo(other); - } - - approximateByteSize(): number { - return 4; - } - - static of(value: boolean): BooleanValue { - return value ? BooleanValue.TRUE : BooleanValue.FALSE; - } - - static TRUE = new BooleanValue(true); - static FALSE = new BooleanValue(false); -} - -/** Base class for IntegerValue and DoubleValue. */ -export abstract class NumberValue extends FieldValue { - typeOrder = TypeOrder.NumberValue; - - constructor(readonly internalValue: number) { - super(); - } - - value(options?: FieldValueOptions): number { - return this.internalValue; - } - - compareTo(other: FieldValue): number { - if (other instanceof NumberValue) { - return numericComparator(this.internalValue, other.internalValue); - } - return this.defaultCompareTo(other); - } - - approximateByteSize(): number { - return 8; - } -} - -/** Utility function to compare doubles (using Firestore semantics for NaN). */ -function numericComparator(left: number, right: number): number { - if (left < right) { - return -1; - } else if (left > right) { - return 1; - } else if (left === right) { - return 0; - } else { - // one or both are NaN. - if (isNaN(left)) { - return isNaN(right) ? 0 : -1; - } else { - return 1; - } - } -} - -/** - * Utility function to check numbers for equality using Firestore semantics - * (NaN === NaN, -0.0 !== 0.0). - */ -function numericEquals(left: number, right: number): boolean { - // Implemented based on Object.is() polyfill from - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is - if (left === right) { - // +0 != -0 - return left !== 0 || 1 / left === 1 / right; - } else { - // NaN == NaN - return left !== left && right !== right; - } -} - -export class IntegerValue extends NumberValue { - isEqual(other: FieldValue): boolean { - // NOTE: DoubleValue and IntegerValue instances may compareTo() the same, - // but that doesn't make them equal via isEqual(). - if (other instanceof IntegerValue) { - return numericEquals(this.internalValue, other.internalValue); - } else { - return false; - } - } - - // NOTE: compareTo() is implemented in NumberValue. -} - -export class DoubleValue extends NumberValue { - static NAN = new DoubleValue(NaN); - static POSITIVE_INFINITY = new DoubleValue(Infinity); - static NEGATIVE_INFINITY = new DoubleValue(-Infinity); - - isEqual(other: FieldValue): boolean { - // NOTE: DoubleValue and IntegerValue instances may compareTo() the same, - // but that doesn't make them equal via isEqual(). - if (other instanceof DoubleValue) { - return numericEquals(this.internalValue, other.internalValue); - } else { - return false; - } - } - - // NOTE: compareTo() is implemented in NumberValue. -} - -// TODO(b/37267885): Add truncation support -export class StringValue extends FieldValue { - typeOrder = TypeOrder.StringValue; - - constructor(readonly internalValue: string) { - super(); - } - - value(options?: FieldValueOptions): string { - return this.internalValue; - } - - isEqual(other: FieldValue): boolean { - return ( - other instanceof StringValue && this.internalValue === other.internalValue - ); - } - - compareTo(other: FieldValue): number { - if (other instanceof StringValue) { - return primitiveComparator(this.internalValue, other.internalValue); - } - return this.defaultCompareTo(other); - } - - approximateByteSize(): number { - // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures: - // "JavaScript's String type is [...] a set of elements of 16-bit unsigned - // integer values" - return this.internalValue.length * 2; - } -} - -export class TimestampValue extends FieldValue { - typeOrder = TypeOrder.TimestampValue; - - constructor(readonly internalValue: Timestamp) { - super(); - } - - value(options?: FieldValueOptions): Date | Timestamp { - if (!options || options.timestampsInSnapshots) { - return this.internalValue; - } else { - return this.internalValue.toDate(); - } - } - - isEqual(other: FieldValue): boolean { - return ( - other instanceof TimestampValue && - this.internalValue.isEqual(other.internalValue) - ); - } - - compareTo(other: FieldValue): number { - if (other instanceof TimestampValue) { - return this.internalValue._compareTo(other.internalValue); - } else if (other instanceof ServerTimestampValue) { - // Concrete timestamps come before server timestamps. - return -1; - } else { - return this.defaultCompareTo(other); - } - } - - approximateByteSize(): number { - // Timestamps are made up of two distinct numbers (seconds + nanoseconds) - return 16; - } -} - -/** - * Represents a locally-applied ServerTimestamp. - * - * Notes: - * - ServerTimestampValue instances are created as the result of applying a - * TransformMutation (see TransformMutation.applyTo()). They can only exist in - * the local view of a document. Therefore they do not need to be parsed or - * serialized. - * - When evaluated locally (e.g. for snapshot.data()), they by default - * evaluate to `null`. This behavior can be configured by passing custom - * FieldValueOptions to value(). - * - With respect to other ServerTimestampValues, they sort by their - * localWriteTime. - */ -export class ServerTimestampValue extends FieldValue { - typeOrder = TypeOrder.TimestampValue; - - constructor( - readonly localWriteTime: Timestamp, - readonly previousValue: FieldValue | null - ) { - super(); - } - - value(options?: FieldValueOptions): FieldType { - if ( - options && - options.serverTimestampBehavior === ServerTimestampBehavior.Estimate - ) { - return new TimestampValue(this.localWriteTime).value(options); - } else if ( - options && - options.serverTimestampBehavior === ServerTimestampBehavior.Previous - ) { - return this.previousValue ? this.previousValue.value(options) : null; - } else { - return null; - } - } - - isEqual(other: FieldValue): boolean { - return ( - other instanceof ServerTimestampValue && - this.localWriteTime.isEqual(other.localWriteTime) + !isServerTimestamp(proto), + 'ServerTimestamps should be converted to ServerTimestampValue' ); } - compareTo(other: FieldValue): number { - if (other instanceof ServerTimestampValue) { - return this.localWriteTime._compareTo(other.localWriteTime); - } else if (other instanceof TimestampValue) { - // Server timestamps come after all concrete timestamps. - return 1; - } else { - return this.defaultCompareTo(other); - } - } - - toString(): string { - return ''; - } - - approximateByteSize(): number { - return ( - /* localWriteTime */ 16 + - (this.previousValue ? this.previousValue.approximateByteSize() : 0) - ); - } -} - -export class BlobValue extends FieldValue { - typeOrder = TypeOrder.BlobValue; - - constructor(readonly internalValue: Blob) { - super(); - } - - value(options?: FieldValueOptions): Blob { - return this.internalValue; - } - - isEqual(other: FieldValue): boolean { - return ( - other instanceof BlobValue && - this.internalValue.isEqual(other.internalValue) - ); - } - - compareTo(other: FieldValue): number { - if (other instanceof BlobValue) { - return this.internalValue._compareTo(other.internalValue); - } - return this.defaultCompareTo(other); - } - - approximateByteSize(): number { - return this.internalValue._approximateByteSize(); - } -} - -export class RefValue extends FieldValue { - typeOrder = TypeOrder.RefValue; - - constructor(readonly databaseId: DatabaseId, readonly key: DocumentKey) { - super(); - } - - value(options?: FieldValueOptions): DocumentKey { - return this.key; - } - - isEqual(other: FieldValue): boolean { - if (other instanceof RefValue) { - return ( - this.key.isEqual(other.key) && this.databaseId.isEqual(other.databaseId) - ); - } else { - return false; - } - } - - compareTo(other: FieldValue): number { - if (other instanceof RefValue) { - const cmp = this.databaseId.compareTo(other.databaseId); - return cmp !== 0 ? cmp : DocumentKey.comparator(this.key, other.key); - } - return this.defaultCompareTo(other); - } - - approximateByteSize(): number { - return ( - this.databaseId.projectId.length + - this.databaseId.database.length + - this.key.toString().length - ); - } -} - -export class GeoPointValue extends FieldValue { - typeOrder = TypeOrder.GeoPointValue; - - constructor(readonly internalValue: GeoPoint) { - super(); - } - - value(options?: FieldValueOptions): GeoPoint { - return this.internalValue; - } - - isEqual(other: FieldValue): boolean { - return ( - other instanceof GeoPointValue && - this.internalValue.isEqual(other.internalValue) - ); - } - - compareTo(other: FieldValue): number { - if (other instanceof GeoPointValue) { - return this.internalValue._compareTo(other.internalValue); - } - return this.defaultCompareTo(other); - } - - approximateByteSize(): number { - // GeoPoints are made up of two distinct numbers (latitude + longitude) - return 16; - } -} - -export class ObjectValue extends FieldValue { - typeOrder = TypeOrder.ObjectValue; - - constructor(readonly internalValue: SortedMap) { - super(); - } - - /** Returns a new ObjectValueBuilder instance that is based on an empty object. */ + /** Returns a new Builder instance that is based on an empty object. */ static newBuilder(): ObjectValueBuilder { - return new ObjectValueBuilder(ObjectValue.EMPTY.internalValue); - } - - value(options?: FieldValueOptions): JsonObject { - const result: JsonObject = {}; - this.internalValue.inorderTraversal((key, val) => { - result[key] = val.value(options); - }); - return result; + return ObjectValue.EMPTY.toBuilder(); } - forEach(action: (key: string, value: FieldValue) => void): void { - this.internalValue.inorderTraversal(action); - } - - isEqual(other: FieldValue): boolean { - if (other instanceof ObjectValue) { - const it1 = this.internalValue.getIterator(); - const it2 = other.internalValue.getIterator(); - while (it1.hasNext() && it2.hasNext()) { - const next1 = it1.getNext(); - const next2 = it2.getNext(); - if (next1.key !== next2.key || !next1.value.isEqual(next2.value)) { - return false; + /** + * Returns the value at the given path or null. + * + * @param path the path to search + * @return The value at the path or if there it doesn't exist. + */ + field(path: FieldPath): api.Value | null { + if (path.isEmpty()) { + return this.proto; + } else { + let value: api.Value = this.proto; + for (let i = 0; i < path.length - 1; ++i) { + if (!value.mapValue!.fields) { + return null; } - } - - return !it1.hasNext() && !it2.hasNext(); - } - - return false; - } - - compareTo(other: FieldValue): number { - if (other instanceof ObjectValue) { - const it1 = this.internalValue.getIterator(); - const it2 = other.internalValue.getIterator(); - while (it1.hasNext() && it2.hasNext()) { - const next1: { key: string; value: FieldValue } = it1.getNext(); - const next2: { key: string; value: FieldValue } = it2.getNext(); - const cmp = - primitiveComparator(next1.key, next2.key) || - next1.value.compareTo(next2.value); - if (cmp) { - return cmp; + value = value.mapValue!.fields[path.get(i)]; + if (!isMapValue(value)) { + return null; } } - // Only equal if both iterators are exhausted - return primitiveComparator(it1.hasNext(), it2.hasNext()); - } else { - return this.defaultCompareTo(other); + value = (value.mapValue!.fields || {})[path.lastSegment()]; + return value || null; } } - contains(path: FieldPath): boolean { - return this.field(path) !== null; - } - - field(path: FieldPath): FieldValue | null { - assert(!path.isEmpty(), "Can't get field of empty path"); - let field: FieldValue | null = this; - path.forEach((pathSegment: string) => { - if (field instanceof ObjectValue) { - field = field.internalValue.get(pathSegment); - } else { - field = null; - } - }); - return field; - } - /** - * Returns a FieldMask built from all FieldPaths starting from this ObjectValue, - * including paths from nested objects. + * Returns a FieldMask built from all FieldPaths starting from this + * ObjectValue, including paths from nested objects. */ fieldMask(): FieldMask { + return this.extractFieldMask(this.proto.mapValue!); + } + + private extractFieldMask(value: api.MapValue): FieldMask { let fields = new SortedSet(FieldPath.comparator); - this.internalValue.forEach((key, value) => { + forEach(value.fields || {}, (key, value) => { const currentPath = new FieldPath([key]); - if (value instanceof ObjectValue) { - const nestedMask = value.fieldMask(); + if (typeOrder(value) === TypeOrder.ObjectValue) { + const nestedMask = this.extractFieldMask(value.mapValue!); const nestedFields = nestedMask.fields; if (nestedFields.isEmpty()) { // Preserve the empty map by adding it to the FieldMask. @@ -654,40 +116,43 @@ export class ObjectValue extends FieldValue { }); } } else { + // For nested and non-empty ObjectValues, add the FieldPath of the leaf + // nodes. fields = fields.add(currentPath); } }); return FieldMask.fromSet(fields); } - approximateByteSize(): number { - let size = 0; - this.internalValue.inorderTraversal((key, val) => { - size += key.length + val.approximateByteSize(); - }); - return size; - } - - toString(): string { - return this.internalValue.toString(); + isEqual(other: ObjectValue): boolean { + return valueEquals(this.proto, other.proto); } - static EMPTY = new ObjectValue( - new SortedMap(primitiveComparator) - ); - /** Creates a ObjectValueBuilder instance that is based on the current value. */ toBuilder(): ObjectValueBuilder { - return new ObjectValueBuilder(this.internalValue); + return new ObjectValueBuilder(this); } } +/** + * An Overlay, which contains an update to apply. Can either be Value proto, a + * map of Overlay values (to represent additional nesting at the given key) or + * `null` (to represent field deletes). + */ +type Overlay = Map | api.Value | null; + /** * An ObjectValueBuilder provides APIs to set and delete fields from an - * ObjectValue. All operations mutate the existing instance. + * ObjectValue. */ export class ObjectValueBuilder { - constructor(private internalValue: SortedMap) {} + /** A map that contains the accumulated changes in this builder. */ + private overlayMap = new Map(); + + /** + * @param baseObject The object to mutate. + */ + constructor(private readonly baseObject: ObjectValue) {} /** * Sets the field to the provided value. @@ -696,34 +161,17 @@ export class ObjectValueBuilder { * @param value The value to set. * @return The current Builder instance. */ - set(path: FieldPath, value: FieldValue): ObjectValueBuilder { + set(path: FieldPath, value: api.Value): ObjectValueBuilder { assert(!path.isEmpty(), 'Cannot set field for empty path on ObjectValue'); - const childName = path.firstSegment(); - if (path.length === 1) { - this.internalValue = this.internalValue.insert(childName, value); - } else { - // nested field - const child = this.internalValue.get(childName); - let obj: ObjectValue; - if (child instanceof ObjectValue) { - obj = child; - } else { - obj = ObjectValue.EMPTY; - } - const newChild = obj - .toBuilder() - .set(path.popFirst(), value) - .build(); - this.internalValue = this.internalValue.insert(childName, newChild); - } + this.setOverlay(path, value); return this; } /** - * Removes the field at the current path. If there is no field at the + * Removes the field at the specified path. If there is no field at the * specified path, nothing is changed. * - * @param path The field path to remove + * @param path The field path to remove. * @return The current Builder instance. */ delete(path: FieldPath): ObjectValueBuilder { @@ -731,111 +179,99 @@ export class ObjectValueBuilder { !path.isEmpty(), 'Cannot delete field for empty path on ObjectValue' ); - const childName = path.firstSegment(); - if (path.length === 1) { - this.internalValue = this.internalValue.remove(childName); - } else { - // nested field - const child = this.internalValue.get(childName); - if (child instanceof ObjectValue) { - const newChild = child - .toBuilder() - .delete(path.popFirst()) - .build(); - this.internalValue = this.internalValue.insert( - path.firstSegment(), - newChild - ); - } else { - // Don't actually change a primitive value to an object for a delete - } - } + this.setOverlay(path, null); return this; } - build(): ObjectValue { - return new ObjectValue(this.internalValue); - } -} - -export class ArrayValue extends FieldValue { - typeOrder = TypeOrder.ArrayValue; - - constructor(readonly internalValue: FieldValue[]) { - super(); - } - - value(options?: FieldValueOptions): FieldType[] { - return this.internalValue.map(v => v.value(options)); - } - /** - * Returns true if the given value is contained in this array. + * Adds `value` to the overlay map at `path`. Creates nested map entries if + * needed. */ - contains(value: FieldValue): boolean { - for (const element of this.internalValue) { - if (element.isEqual(value)) { - return true; + private setOverlay(path: FieldPath, value: api.Value | null): void { + let currentLevel = this.overlayMap; + + for (let i = 0; i < path.length - 1; ++i) { + const currentSegment = path.get(i); + let currentValue = currentLevel.get(currentSegment); + + if (currentValue instanceof Map) { + // Re-use a previously created map + currentLevel = currentValue; + } else if ( + currentValue && + typeOrder(currentValue) === TypeOrder.ObjectValue + ) { + // Convert the existing Protobuf MapValue into a map + currentValue = new Map( + Object.entries(currentValue.mapValue!.fields || {}) + ); + currentLevel.set(currentSegment, currentValue); + currentLevel = currentValue; + } else { + // Create an empty map to represent the current nesting level + currentValue = new Map(); + currentLevel.set(currentSegment, currentValue); + currentLevel = currentValue; } } - return false; - } - forEach(action: (value: FieldValue) => void): void { - this.internalValue.forEach(action); + currentLevel.set(path.lastSegment(), value); } - isEqual(other: FieldValue): boolean { - if (other instanceof ArrayValue) { - if (this.internalValue.length !== other.internalValue.length) { - return false; - } - - for (let i = 0; i < this.internalValue.length; i++) { - if (!this.internalValue[i].isEqual(other.internalValue[i])) { - return false; - } - } - - return true; + /** Returns an ObjectValue with all mutations applied. */ + build(): ObjectValue { + const mergedResult = this.applyOverlay( + FieldPath.EMPTY_PATH, + this.overlayMap + ); + if (mergedResult != null) { + return new ObjectValue(mergedResult); + } else { + return this.baseObject; } - - return false; } - compareTo(other: FieldValue): number { - if (other instanceof ArrayValue) { - const minLength = Math.min( - this.internalValue.length, - other.internalValue.length - ); - - for (let i = 0; i < minLength; i++) { - const cmp = this.internalValue[i].compareTo(other.internalValue[i]); - - if (cmp) { - return cmp; + /** + * Applies any overlays from `currentOverlays` that exist at `currentPath` + * and returns the merged data at `currentPath` (or null if there were no + * changes). + * + * @param currentPath The path at the current nesting level. Can be set to + * FieldValue.EMPTY_PATH to represent the root. + * @param currentOverlays The overlays at the current nesting level in the + * same format as `overlayMap`. + * @return The merged data at `currentPath` or null if no modifications + * were applied. + */ + private applyOverlay( + currentPath: FieldPath, + currentOverlays: Map + ): { mapValue: api.MapValue } | null { + let modified = false; + + const existingValue = this.baseObject.field(currentPath); + const resultAtPath = isMapValue(existingValue) + ? // If there is already data at the current path, base our + // modifications on top of the existing data. + { ...existingValue.mapValue.fields } + : {}; + + currentOverlays.forEach((value, pathSegment) => { + if (value instanceof Map) { + const nested = this.applyOverlay(currentPath.child(pathSegment), value); + if (nested != null) { + resultAtPath[pathSegment] = nested; + modified = true; } + } else if (value !== null) { + resultAtPath[pathSegment] = value; + modified = true; + } else if (resultAtPath.hasOwnProperty(pathSegment)) { + delete resultAtPath[pathSegment]; + modified = true; } + }); - return primitiveComparator( - this.internalValue.length, - other.internalValue.length - ); - } else { - return this.defaultCompareTo(other); - } - } - - approximateByteSize(): number { - return this.internalValue.reduce( - (totalSize, value) => totalSize + value.approximateByteSize(), - 0 - ); - } - - toString(): string { - const descriptions = this.internalValue.map(v => v.toString()); - return `[${descriptions.join(',')}]`; + return modified ? { mapValue: { fields: resultAtPath } } : null; } } diff --git a/packages/firestore/src/model/mutation.ts b/packages/firestore/src/model/mutation.ts index db01d8abe72..424ba7e833f 100644 --- a/packages/firestore/src/model/mutation.ts +++ b/packages/firestore/src/model/mutation.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,10 +15,11 @@ * limitations under the License. */ +import * as api from '../protos/firestore_proto_api'; + import { Timestamp } from '../api/timestamp'; import { SnapshotVersion } from '../core/snapshot_version'; import { assert, fail } from '../util/assert'; -import * as misc from '../util/misc'; import { SortedSet } from '../util/sorted_set'; import { @@ -28,9 +29,10 @@ import { UnknownDocument } from './document'; import { DocumentKey } from './document_key'; -import { FieldValue, ObjectValue, ObjectValueBuilder } from './field_value'; +import { ObjectValue, ObjectValueBuilder } from './field_value'; import { FieldPath } from './path'; import { TransformOperation } from './transform_operation'; +import { arrayEquals, equals } from '../util/misc'; /** * Provides a set of fields that can be used to partially patch a document. @@ -113,7 +115,7 @@ export class MutationResult { * * Will be null if the mutation was not a TransformMutation. */ - readonly transformResults: Array | null + readonly transformResults: Array | null ) {} } @@ -178,8 +180,7 @@ export class Precondition { isEqual(other: Precondition): boolean { return ( - misc.equals(this.updateTime, other.updateTime) && - this.exists === other.exists + equals(this.updateTime, other.updateTime) && this.exists === other.exists ); } } @@ -354,14 +355,9 @@ export class SetMutation extends Mutation { // have held. const version = mutationResult.version; - return new Document( - this.key, - version, - { - hasCommittedMutations: true - }, - this.value - ); + return new Document(this.key, version, this.value, { + hasCommittedMutations: true + }); } applyToLocalView( @@ -376,14 +372,9 @@ export class SetMutation extends Mutation { } const version = Mutation.getPostMutationVersion(maybeDoc); - return new Document( - this.key, - version, - { - hasLocalMutations: true - }, - this.value - ); + return new Document(this.key, version, this.value, { + hasLocalMutations: true + }); } extractBaseValue(maybeDoc: MaybeDocument | null): null { @@ -445,14 +436,9 @@ export class PatchMutation extends Mutation { } const newData = this.patchDocument(maybeDoc); - return new Document( - this.key, - mutationResult.version, - { - hasCommittedMutations: true - }, - newData - ); + return new Document(this.key, mutationResult.version, newData, { + hasCommittedMutations: true + }); } applyToLocalView( @@ -468,14 +454,9 @@ export class PatchMutation extends Mutation { const version = Mutation.getPostMutationVersion(maybeDoc); const newData = this.patchDocument(maybeDoc); - return new Document( - this.key, - version, - { - hasLocalMutations: true - }, - newData - ); + return new Document(this.key, version, newData, { + hasLocalMutations: true + }); } extractBaseValue(maybeDoc: MaybeDocument | null): null { @@ -573,14 +554,9 @@ export class TransformMutation extends Mutation { const version = mutationResult.version; const newData = this.transformObject(doc.data(), transformResults); - return new Document( - this.key, - version, - { - hasCommittedMutations: true - }, - newData - ); + return new Document(this.key, version, newData, { + hasCommittedMutations: true + }); } applyToLocalView( @@ -601,14 +577,9 @@ export class TransformMutation extends Mutation { baseDoc ); const newData = this.transformObject(doc.data(), transformResults); - return new Document( - this.key, - doc.version, - { - hasLocalMutations: true - }, - newData - ); + return new Document(this.key, doc.version, newData, { + hasLocalMutations: true + }); } extractBaseValue(maybeDoc: MaybeDocument | null): ObjectValue | null { @@ -640,7 +611,9 @@ export class TransformMutation extends Mutation { return ( other instanceof TransformMutation && this.key.isEqual(other.key) && - misc.arrayEquals(this.fieldTransforms, other.fieldTransforms) && + arrayEquals(this.fieldTransforms, other.fieldTransforms, (l, r) => + l.isEqual(r) + ) && this.precondition.isEqual(other.precondition) ); } @@ -674,9 +647,9 @@ export class TransformMutation extends Mutation { */ private serverTransformResults( baseDoc: MaybeDocument | null, - serverTransformResults: Array - ): FieldValue[] { - const transformResults = [] as FieldValue[]; + serverTransformResults: Array + ): api.Value[] { + const transformResults: api.Value[] = []; assert( this.fieldTransforms.length === serverTransformResults.length, `server transform result count (${serverTransformResults.length}) ` + @@ -686,7 +659,7 @@ export class TransformMutation extends Mutation { for (let i = 0; i < serverTransformResults.length; i++) { const fieldTransform = this.fieldTransforms[i]; const transform = fieldTransform.transform; - let previousValue: FieldValue | null = null; + let previousValue: api.Value | null = null; if (baseDoc instanceof Document) { previousValue = baseDoc.field(fieldTransform.field); } @@ -716,12 +689,12 @@ export class TransformMutation extends Mutation { localWriteTime: Timestamp, maybeDoc: MaybeDocument | null, baseDoc: MaybeDocument | null - ): FieldValue[] { - const transformResults = [] as FieldValue[]; + ): api.Value[] { + const transformResults: api.Value[] = []; for (const fieldTransform of this.fieldTransforms) { const transform = fieldTransform.transform; - let previousValue: FieldValue | null = null; + let previousValue: api.Value | null = null; if (maybeDoc instanceof Document) { previousValue = maybeDoc.field(fieldTransform.field); } @@ -743,7 +716,7 @@ export class TransformMutation extends Mutation { private transformObject( data: ObjectValue, - transformResults: FieldValue[] + transformResults: api.Value[] ): ObjectValue { assert( transformResults.length === this.fieldTransforms.length, diff --git a/packages/firestore/src/model/mutation_batch.ts b/packages/firestore/src/model/mutation_batch.ts index 276821e0877..a9f0fb5fe0f 100644 --- a/packages/firestore/src/model/mutation_batch.ts +++ b/packages/firestore/src/model/mutation_batch.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,7 +19,7 @@ import { Timestamp } from '../api/timestamp'; import { SnapshotVersion } from '../core/snapshot_version'; import { BatchId } from '../core/types'; import { assert } from '../util/assert'; -import * as misc from '../util/misc'; +import { arrayEquals } from '../util/misc'; import { ByteString } from '../util/byte_string'; import { documentKeySet, @@ -175,8 +175,10 @@ export class MutationBatch { isEqual(other: MutationBatch): boolean { return ( this.batchId === other.batchId && - misc.arrayEquals(this.mutations, other.mutations) && - misc.arrayEquals(this.baseMutations, other.baseMutations) + arrayEquals(this.mutations, other.mutations, (l, r) => l.isEqual(r)) && + arrayEquals(this.baseMutations, other.baseMutations, (l, r) => + l.isEqual(r) + ) ); } } diff --git a/packages/firestore/src/model/server_timestamps.ts b/packages/firestore/src/model/server_timestamps.ts new file mode 100644 index 00000000000..97327bfaaf8 --- /dev/null +++ b/packages/firestore/src/model/server_timestamps.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2020 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 * as api from '../protos/firestore_proto_api'; +import { Timestamp } from '../api/timestamp'; +import { normalizeTimestamp } from './values'; + +/** + * Represents a locally-applied ServerTimestamp. + * + * Server Timestamps are backed by MapValues that contain an internal field + * `__type__` with a value of `server_timestamp`. The previous value and local + * write time are stored in its `__previous_value__` and `__local_write_time__` + * fields respectively. + * + * Notes: + * - ServerTimestampValue instances are created as the result of applying a + * TransformMutation (see TransformMutation.applyTo()). They can only exist in + * the local view of a document. Therefore they do not need to be parsed or + * serialized. + * - When evaluated locally (e.g. for snapshot.data()), they by default + * evaluate to `null`. This behavior can be configured by passing custom + * FieldValueOptions to value(). + * - With respect to other ServerTimestampValues, they sort by their + * localWriteTime. + */ + +const SERVER_TIMESTAMP_SENTINEL = 'server_timestamp'; +const TYPE_KEY = '__type__'; +const PREVIOUS_VALUE_KEY = '__previous_value__'; +const LOCAL_WRITE_TIME_KEY = '__local_write_time__'; + +export function isServerTimestamp(value: api.Value | null): boolean { + const type = (value?.mapValue?.fields || {})[TYPE_KEY]?.stringValue; + return type === SERVER_TIMESTAMP_SENTINEL; +} + +/** + * Creates a new ServerTimestamp proto value (using the internal format). + */ +export function serverTimestamp( + localWriteTime: Timestamp, + previousValue: api.Value | null +): api.Value { + const mapValue: api.MapValue = { + fields: { + [TYPE_KEY]: { + stringValue: SERVER_TIMESTAMP_SENTINEL + }, + [LOCAL_WRITE_TIME_KEY]: { + timestampValue: { + seconds: localWriteTime.seconds, + nanos: localWriteTime.nanoseconds + } + } + } + }; + + if (previousValue) { + mapValue.fields![PREVIOUS_VALUE_KEY] = previousValue; + } + + return { mapValue }; +} + +/** + * Returns the value of the field before this ServerTimestamp was set. + * + * Preserving the previous values allows the user to display the last resoled + * value until the backend responds with the timestamp. + */ +export function getPreviousValue(value: api.Value): api.Value | null { + const previousValue = value.mapValue!.fields![PREVIOUS_VALUE_KEY]; + + if (isServerTimestamp(previousValue)) { + return getPreviousValue(previousValue); + } + return previousValue; +} + +/** + * Returns the local time at which this timestamp was first set. + */ +export function getLocalWriteTime(value: api.Value): Timestamp { + const localWriteTime = normalizeTimestamp( + value.mapValue!.fields![LOCAL_WRITE_TIME_KEY].timestampValue! + ); + return new Timestamp(localWriteTime.seconds, localWriteTime.nanos); +} diff --git a/packages/firestore/src/model/transform_operation.ts b/packages/firestore/src/model/transform_operation.ts index 4830f3af3be..fd887da2b16 100644 --- a/packages/firestore/src/model/transform_operation.ts +++ b/packages/firestore/src/model/transform_operation.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,17 +15,20 @@ * limitations under the License. */ +import * as api from '../protos/firestore_proto_api'; + import { Timestamp } from '../api/timestamp'; import { assert } from '../util/assert'; -import * as misc from '../util/misc'; +import { JsonProtoSerializer } from '../remote/serializer'; import { - ArrayValue, - DoubleValue, - FieldValue, - IntegerValue, - NumberValue, - ServerTimestampValue -} from './field_value'; + valueEquals, + isArray, + isInteger, + isNumber, + normalizeNumber +} from './values'; +import { serverTimestamp } from './server_timestamps'; +import { arrayEquals } from '../util/misc'; /** Represents a transform within a TransformMutation. */ export interface TransformOperation { @@ -34,18 +37,18 @@ export interface TransformOperation { * optionally using the provided localWriteTime. */ applyToLocalView( - previousValue: FieldValue | null, + previousValue: api.Value | null, localWriteTime: Timestamp - ): FieldValue; + ): api.Value; /** * Computes a final transform result after the transform has been acknowledged * by the server, potentially using the server-provided transformResult. */ applyToRemoteDocument( - previousValue: FieldValue | null, - transformResult: FieldValue | null - ): FieldValue; + previousValue: api.Value | null, + transformResult: api.Value | null + ): api.Value; /** * If this transform operation is not idempotent, returns the base value to @@ -62,7 +65,7 @@ export interface TransformOperation { * @return a base value to store along with the mutation, or null for * idempotent transforms. */ - computeBaseValue(previousValue: FieldValue | null): FieldValue | null; + computeBaseValue(previousValue: api.Value | null): api.Value | null; isEqual(other: TransformOperation): boolean; } @@ -73,20 +76,20 @@ export class ServerTimestampTransform implements TransformOperation { static instance = new ServerTimestampTransform(); applyToLocalView( - previousValue: FieldValue | null, + previousValue: api.Value | null, localWriteTime: Timestamp - ): FieldValue { - return new ServerTimestampValue(localWriteTime!, previousValue); + ): api.Value { + return serverTimestamp(localWriteTime!, previousValue); } applyToRemoteDocument( - previousValue: FieldValue | null, - transformResult: FieldValue | null - ): FieldValue { + previousValue: api.Value | null, + transformResult: api.Value | null + ): api.Value { return transformResult!; } - computeBaseValue(previousValue: FieldValue | null): FieldValue | null { + computeBaseValue(previousValue: api.Value | null): api.Value | null { return null; // Server timestamps are idempotent and don't require a base value. } @@ -97,84 +100,84 @@ export class ServerTimestampTransform implements TransformOperation { /** Transforms an array value via a union operation. */ export class ArrayUnionTransformOperation implements TransformOperation { - constructor(readonly elements: FieldValue[]) {} + constructor(readonly elements: api.Value[]) {} applyToLocalView( - previousValue: FieldValue | null, + previousValue: api.Value | null, localWriteTime: Timestamp - ): FieldValue { + ): api.Value { return this.apply(previousValue); } applyToRemoteDocument( - previousValue: FieldValue | null, - transformResult: FieldValue | null - ): FieldValue { + previousValue: api.Value | null, + transformResult: api.Value | null + ): api.Value { // The server just sends null as the transform result for array operations, // so we have to calculate a result the same as we do for local // applications. return this.apply(previousValue); } - private apply(previousValue: FieldValue | null): FieldValue { - const result = coercedFieldValuesArray(previousValue); + private apply(previousValue: api.Value | null): api.Value { + const values = coercedFieldValuesArray(previousValue); for (const toUnion of this.elements) { - if (!result.find(element => element.isEqual(toUnion))) { - result.push(toUnion); + if (!values.some(element => valueEquals(element, toUnion))) { + values.push(toUnion); } } - return new ArrayValue(result); + return { arrayValue: { values } }; } - computeBaseValue(previousValue: FieldValue | null): FieldValue | null { + computeBaseValue(previousValue: api.Value | null): api.Value | null { return null; // Array transforms are idempotent and don't require a base value. } isEqual(other: TransformOperation): boolean { return ( other instanceof ArrayUnionTransformOperation && - misc.arrayEquals(other.elements, this.elements) + arrayEquals(this.elements, other.elements, valueEquals) ); } } /** Transforms an array value via a remove operation. */ export class ArrayRemoveTransformOperation implements TransformOperation { - constructor(readonly elements: FieldValue[]) {} + constructor(readonly elements: api.Value[]) {} applyToLocalView( - previousValue: FieldValue | null, + previousValue: api.Value | null, localWriteTime: Timestamp - ): FieldValue { + ): api.Value { return this.apply(previousValue); } applyToRemoteDocument( - previousValue: FieldValue | null, - transformResult: FieldValue | null - ): FieldValue { + previousValue: api.Value | null, + transformResult: api.Value | null + ): api.Value { // The server just sends null as the transform result for array operations, // so we have to calculate a result the same as we do for local // applications. return this.apply(previousValue); } - private apply(previousValue: FieldValue | null): FieldValue { - let result = coercedFieldValuesArray(previousValue); + private apply(previousValue: api.Value | null): api.Value { + let values = coercedFieldValuesArray(previousValue); for (const toRemove of this.elements) { - result = result.filter(element => !element.isEqual(toRemove)); + values = values.filter(element => !valueEquals(element, toRemove)); } - return new ArrayValue(result); + return { arrayValue: { values } }; } - computeBaseValue(previousValue: FieldValue | null): FieldValue | null { + computeBaseValue(previousValue: api.Value | null): api.Value | null { return null; // Array transforms are idempotent and don't require a base value. } isEqual(other: TransformOperation): boolean { return ( other instanceof ArrayRemoveTransformOperation && - misc.arrayEquals(other.elements, this.elements) + arrayEquals(this.elements, other.elements, valueEquals) ); } } @@ -186,35 +189,36 @@ export class ArrayRemoveTransformOperation implements TransformOperation { * arithmetic is used and precision loss can occur for values greater than 2^53. */ export class NumericIncrementTransformOperation implements TransformOperation { - constructor(readonly operand: NumberValue) {} + constructor( + private readonly serializer: JsonProtoSerializer, + readonly operand: api.Value + ) { + assert( + isNumber(operand), + 'NumericIncrementTransform transform requires a NumberValue' + ); + } applyToLocalView( - previousValue: FieldValue | null, + previousValue: api.Value | null, localWriteTime: Timestamp - ): FieldValue { - const baseValue = this.computeBaseValue(previousValue); + ): api.Value { // PORTING NOTE: Since JavaScript's integer arithmetic is limited to 53 bit // precision and resolves overflows by reducing precision, we do not // manually cap overflows at 2^63. - - // Return an integer value iff the previous value and the operand is an - // integer. - if ( - baseValue instanceof IntegerValue && - this.operand instanceof IntegerValue - ) { - const sum = baseValue.internalValue + this.operand.internalValue; - return new IntegerValue(sum); + const baseValue = this.computeBaseValue(previousValue); + const sum = this.asNumber(baseValue) + this.asNumber(this.operand); + if (isInteger(baseValue) && isInteger(this.operand)) { + return this.serializer.toInteger(sum); } else { - const sum = baseValue.internalValue + this.operand.internalValue; - return new DoubleValue(sum); + return this.serializer.toDouble(sum); } } applyToRemoteDocument( - previousValue: FieldValue | null, - transformResult: FieldValue | null - ): FieldValue { + previousValue: api.Value | null, + transformResult: api.Value | null + ): api.Value { assert( transformResult !== null, "Didn't receive transformResult for NUMERIC_ADD transform" @@ -224,27 +228,26 @@ export class NumericIncrementTransformOperation implements TransformOperation { /** * Inspects the provided value, returning the provided value if it is already - * a NumberValue, otherwise returning a coerced IntegerValue of 0. + * a NumberValue, otherwise returning a coerced value of 0. */ - computeBaseValue(previousValue: FieldValue | null): NumberValue { - return previousValue instanceof NumberValue - ? previousValue - : new IntegerValue(0); + computeBaseValue(previousValue: api.Value | null): api.Value { + return isNumber(previousValue) ? previousValue! : { integerValue: 0 }; } isEqual(other: TransformOperation): boolean { return ( other instanceof NumericIncrementTransformOperation && - this.operand.isEqual(other.operand) + valueEquals(this.operand, other.operand) ); } -} -function coercedFieldValuesArray(value: FieldValue | null): FieldValue[] { - if (value instanceof ArrayValue) { - return value.internalValue.slice(); - } else { - // coerce to empty array. - return []; + private asNumber(value: api.Value): number { + return normalizeNumber(value.integerValue || value.doubleValue); } } + +function coercedFieldValuesArray(value: api.Value | null): api.Value[] { + return isArray(value) && value.arrayValue.values + ? value.arrayValue.values.slice() + : []; +} diff --git a/packages/firestore/src/model/values.ts b/packages/firestore/src/model/values.ts new file mode 100644 index 00000000000..26d0c863779 --- /dev/null +++ b/packages/firestore/src/model/values.ts @@ -0,0 +1,617 @@ +/** + * @license + * Copyright 2020 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 * as api from '../protos/firestore_proto_api'; + +import { TypeOrder } from './field_value'; +import { assert, fail } from '../util/assert'; +import { forEach, keys, size } from '../util/obj'; +import { ByteString } from '../util/byte_string'; +import { isNegativeZero } from '../util/types'; +import { DocumentKey } from './document_key'; +import { arrayEquals, primitiveComparator } from '../util/misc'; +import { DatabaseId } from '../core/database_info'; +import { + getLocalWriteTime, + getPreviousValue, + isServerTimestamp +} from './server_timestamps'; + +// A RegExp matching ISO 8601 UTC timestamps with optional fraction. +const ISO_TIMESTAMP_REG_EXP = new RegExp( + /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.(\d+))?Z$/ +); + +/** Extracts the backend's type order for the provided value. */ +export function typeOrder(value: api.Value): TypeOrder { + if ('nullValue' in value) { + return TypeOrder.NullValue; + } else if ('booleanValue' in value) { + return TypeOrder.BooleanValue; + } else if ('integerValue' in value || 'doubleValue' in value) { + return TypeOrder.NumberValue; + } else if ('timestampValue' in value) { + return TypeOrder.TimestampValue; + } else if ('stringValue' in value) { + return TypeOrder.StringValue; + } else if ('bytesValue' in value) { + return TypeOrder.BlobValue; + } else if ('referenceValue' in value) { + return TypeOrder.RefValue; + } else if ('geoPointValue' in value) { + return TypeOrder.GeoPointValue; + } else if ('arrayValue' in value) { + return TypeOrder.ArrayValue; + } else if ('mapValue' in value) { + if (isServerTimestamp(value)) { + return TypeOrder.ServerTimestampValue; + } + return TypeOrder.ObjectValue; + } else { + return fail('Invalid value type: ' + JSON.stringify(value)); + } +} + +/** Tests `left` and `right` for equality based on the backend semantics. */ +export function valueEquals(left: api.Value, right: api.Value): boolean { + const leftType = typeOrder(left); + const rightType = typeOrder(right); + if (leftType !== rightType) { + return false; + } + + switch (leftType) { + case TypeOrder.NullValue: + return true; + case TypeOrder.BooleanValue: + return left.booleanValue === right.booleanValue; + case TypeOrder.ServerTimestampValue: + return getLocalWriteTime(left).isEqual(getLocalWriteTime(right)); + case TypeOrder.TimestampValue: + return timestampEquals(left, right); + case TypeOrder.StringValue: + return left.stringValue === right.stringValue; + case TypeOrder.BlobValue: + return blobEquals(left, right); + case TypeOrder.RefValue: + return left.referenceValue === right.referenceValue; + case TypeOrder.GeoPointValue: + return geoPointEquals(left, right); + case TypeOrder.NumberValue: + return numberEquals(left, right); + case TypeOrder.ArrayValue: + return arrayEquals( + left.arrayValue!.values || [], + right.arrayValue!.values || [], + valueEquals + ); + case TypeOrder.ObjectValue: + return objectEquals(left, right); + default: + return fail('Unexpected value type: ' + JSON.stringify(left)); + } +} + +function timestampEquals(left: api.Value, right: api.Value): boolean { + if ( + typeof left.timestampValue === 'string' && + typeof right.timestampValue === 'string' && + left.timestampValue.length === right.timestampValue.length + ) { + // Use string equality for ISO 8601 timestamps + return left.timestampValue === right.timestampValue; + } + + const leftTimestamp = normalizeTimestamp(left.timestampValue!); + const rightTimestamp = normalizeTimestamp(right.timestampValue!); + return ( + leftTimestamp.seconds === rightTimestamp.seconds && + leftTimestamp.nanos === rightTimestamp.nanos + ); +} + +function geoPointEquals(left: api.Value, right: api.Value): boolean { + return ( + normalizeNumber(left.geoPointValue!.latitude) === + normalizeNumber(right.geoPointValue!.latitude) && + normalizeNumber(left.geoPointValue!.longitude) === + normalizeNumber(right.geoPointValue!.longitude) + ); +} + +function blobEquals(left: api.Value, right: api.Value): boolean { + return normalizeByteString(left.bytesValue!).isEqual( + normalizeByteString(right.bytesValue!) + ); +} + +export function numberEquals(left: api.Value, right: api.Value): boolean { + if ('integerValue' in left && 'integerValue' in right) { + return ( + normalizeNumber(left.integerValue) === normalizeNumber(right.integerValue) + ); + } else if ('doubleValue' in left && 'doubleValue' in right) { + const n1 = normalizeNumber(left.doubleValue!); + const n2 = normalizeNumber(right.doubleValue!); + + if (n1 === n2) { + return isNegativeZero(n1) === isNegativeZero(n2); + } else { + return isNaN(n1) && isNaN(n2); + } + } + + return false; +} + +function objectEquals(left: api.Value, right: api.Value): boolean { + const leftMap = left.mapValue!.fields || {}; + const rightMap = right.mapValue!.fields || {}; + + if (size(leftMap) !== size(rightMap)) { + return false; + } + + for (const key in leftMap) { + if (leftMap.hasOwnProperty(key)) { + if ( + rightMap[key] === undefined || + !valueEquals(leftMap[key], rightMap[key]) + ) { + return false; + } + } + } + return true; +} + +/** Returns true if the ArrayValue contains the specified element. */ +export function arrayValueContains( + haystack: api.ArrayValue, + needle: api.Value +): boolean { + return ( + (haystack.values || []).find(v => valueEquals(v, needle)) !== undefined + ); +} + +export function valueCompare(left: api.Value, right: api.Value): number { + const leftType = typeOrder(left); + const rightType = typeOrder(right); + + if (leftType !== rightType) { + return primitiveComparator(leftType, rightType); + } + + switch (leftType) { + case TypeOrder.NullValue: + return 0; + case TypeOrder.BooleanValue: + return primitiveComparator(left.booleanValue!, right.booleanValue!); + case TypeOrder.NumberValue: + return compareNumbers(left, right); + case TypeOrder.TimestampValue: + return compareTimestamps(left.timestampValue!, right.timestampValue!); + case TypeOrder.ServerTimestampValue: + return compareTimestamps( + getLocalWriteTime(left), + getLocalWriteTime(right) + ); + case TypeOrder.StringValue: + return primitiveComparator(left.stringValue!, right.stringValue!); + case TypeOrder.BlobValue: + return compareBlobs(left.bytesValue!, right.bytesValue!); + case TypeOrder.RefValue: + return compareReferences(left.referenceValue!, right.referenceValue!); + case TypeOrder.GeoPointValue: + return compareGeoPoints(left.geoPointValue!, right.geoPointValue!); + case TypeOrder.ArrayValue: + return compareArrays(left.arrayValue!, right.arrayValue!); + case TypeOrder.ObjectValue: + return compareMaps(left.mapValue!, right.mapValue!); + default: + throw fail('Invalid value type: ' + leftType); + } +} + +function compareNumbers(left: api.Value, right: api.Value): number { + const leftNumber = normalizeNumber(left.integerValue || left.doubleValue); + const rightNumber = normalizeNumber(right.integerValue || right.doubleValue); + + if (leftNumber < rightNumber) { + return -1; + } else if (leftNumber > rightNumber) { + return 1; + } else if (leftNumber === rightNumber) { + return 0; + } else { + // one or both are NaN. + if (isNaN(leftNumber)) { + return isNaN(rightNumber) ? 0 : -1; + } else { + return 1; + } + } +} + +function compareTimestamps(left: api.Timestamp, right: api.Timestamp): number { + if ( + typeof left === 'string' && + typeof right === 'string' && + left.length === right.length + ) { + return primitiveComparator(left, right); + } + + const leftTimestamp = normalizeTimestamp(left); + const rightTimestamp = normalizeTimestamp(right); + + const comparison = primitiveComparator( + leftTimestamp.seconds, + rightTimestamp.seconds + ); + if (comparison !== 0) { + return comparison; + } + return primitiveComparator(leftTimestamp.nanos, rightTimestamp.nanos); +} + +function compareReferences(leftPath: string, rightPath: string): number { + const leftSegments = leftPath.split('/'); + const rightSegments = rightPath.split('/'); + for (let i = 0; i < leftSegments.length && i < rightSegments.length; i++) { + const comparison = primitiveComparator(leftSegments[i], rightSegments[i]); + if (comparison !== 0) { + return comparison; + } + } + return primitiveComparator(leftSegments.length, rightSegments.length); +} + +function compareGeoPoints(left: api.LatLng, right: api.LatLng): number { + const comparison = primitiveComparator( + normalizeNumber(left.latitude), + normalizeNumber(right.latitude) + ); + if (comparison !== 0) { + return comparison; + } + return primitiveComparator( + normalizeNumber(left.longitude), + normalizeNumber(right.longitude) + ); +} + +function compareBlobs( + left: string | Uint8Array, + right: string | Uint8Array +): number { + const leftBytes = normalizeByteString(left); + const rightBytes = normalizeByteString(right); + return leftBytes.compareTo(rightBytes); +} + +function compareArrays(left: api.ArrayValue, right: api.ArrayValue): number { + const leftArray = left.values || []; + const rightArray = right.values || []; + + for (let i = 0; i < leftArray.length && i < rightArray.length; ++i) { + const compare = valueCompare(leftArray[i], rightArray[i]); + if (compare) { + return compare; + } + } + return primitiveComparator(leftArray.length, rightArray.length); +} + +function compareMaps(left: api.MapValue, right: api.MapValue): number { + const leftMap = left.fields || {}; + const leftKeys = keys(leftMap); + const rightMap = right.fields || {}; + const rightKeys = keys(rightMap); + + // Even though MapValues are likely sorted correctly based on their insertion + // order (e.g. when received from the backend), local modifications can bring + // elements out of order. We need to re-sort the elements to ensure that + // canonical IDs are independent of insertion order. + leftKeys.sort(); + rightKeys.sort(); + + for (let i = 0; i < leftKeys.length && i < rightKeys.length; ++i) { + const keyCompare = primitiveComparator(leftKeys[i], rightKeys[i]); + if (keyCompare !== 0) { + return keyCompare; + } + const compare = valueCompare(leftMap[leftKeys[i]], rightMap[rightKeys[i]]); + if (compare !== 0) { + return compare; + } + } + + return primitiveComparator(leftKeys.length, rightKeys.length); +} + +/** + * Generates the canonical ID for the provided field value (as used in Target + * serialization). + */ +export function canonicalId(value: api.Value): string { + return canonifyValue(value); +} + +function canonifyValue(value: api.Value): string { + if ('nullValue' in value) { + return 'null'; + } else if ('booleanValue' in value) { + return '' + value.booleanValue!; + } else if ('integerValue' in value) { + return '' + value.integerValue!; + } else if ('doubleValue' in value) { + return '' + value.doubleValue!; + } else if ('timestampValue' in value) { + return canonifyTimestamp(value.timestampValue!); + } else if ('stringValue' in value) { + return value.stringValue!; + } else if ('bytesValue' in value) { + return canonifyByteString(value.bytesValue!); + } else if ('referenceValue' in value) { + return canonifyReference(value.referenceValue!); + } else if ('geoPointValue' in value) { + return canonifyGeoPoint(value.geoPointValue!); + } else if ('arrayValue' in value) { + return canonifyArray(value.arrayValue!); + } else if ('mapValue' in value) { + return canonifyMap(value.mapValue!); + } else { + return fail('Invalid value type: ' + JSON.stringify(value)); + } +} + +function canonifyByteString(byteString: string | Uint8Array): string { + return normalizeByteString(byteString).toBase64(); +} + +function canonifyTimestamp(timestamp: api.Timestamp): string { + const normalizedTimestamp = normalizeTimestamp(timestamp); + return `time(${normalizedTimestamp.seconds},${normalizedTimestamp.nanos})`; +} + +function canonifyGeoPoint(geoPoint: api.LatLng): string { + return `geo(${geoPoint.latitude},${geoPoint.longitude})`; +} + +function canonifyReference(referenceValue: string): string { + return DocumentKey.fromName(referenceValue).toString(); +} + +function canonifyMap(mapValue: api.MapValue): string { + // Iteration order in JavaScript is not guaranteed. To ensure that we generate + // matching canonical IDs for identical maps, we need to sort the keys. + const sortedKeys = keys(mapValue.fields || {}).sort(); + + let result = '{'; + let first = true; + for (const key of sortedKeys) { + if (!first) { + result += ','; + } else { + first = false; + } + result += `${key}:${canonifyValue(mapValue.fields![key])}`; + } + return result + '}'; +} + +function canonifyArray(arrayValue: api.ArrayValue): string { + let result = '['; + let first = true; + for (const value of arrayValue.values || []) { + if (!first) { + result += ','; + } else { + first = false; + } + result += canonifyValue(value); + } + return result + ']'; +} + +/** + * Returns an approximate (and wildly inaccurate) in-memory size for the field + * value. + * + * The memory size takes into account only the actual user data as it resides + * in memory and ignores object overhead. + */ +export function estimateByteSize(value: api.Value): number { + switch (typeOrder(value)) { + case TypeOrder.NullValue: + return 4; + case TypeOrder.BooleanValue: + return 4; + case TypeOrder.NumberValue: + return 8; + case TypeOrder.TimestampValue: + // Timestamps are made up of two distinct numbers (seconds + nanoseconds) + return 16; + case TypeOrder.ServerTimestampValue: + const previousValue = getPreviousValue(value); + return previousValue ? 16 + estimateByteSize(previousValue) : 16; + case TypeOrder.StringValue: + // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures: + // "JavaScript's String type is [...] a set of elements of 16-bit unsigned + // integer values" + return value.stringValue!.length * 2; + case TypeOrder.BlobValue: + return normalizeByteString(value.bytesValue!).approximateByteSize(); + case TypeOrder.RefValue: + return value.referenceValue!.length; + case TypeOrder.GeoPointValue: + // GeoPoints are made up of two distinct numbers (latitude + longitude) + return 16; + case TypeOrder.ArrayValue: + return estimateArrayByteSize(value.arrayValue!); + case TypeOrder.ObjectValue: + return estimateMapByteSize(value.mapValue!); + default: + throw fail('Invalid value type: ' + JSON.stringify(value)); + } +} + +function estimateMapByteSize(mapValue: api.MapValue): number { + let size = 0; + forEach(mapValue.fields || {}, (key, val) => { + size += key.length + estimateByteSize(val); + }); + return size; +} + +function estimateArrayByteSize(arrayValue: api.ArrayValue): number { + return (arrayValue.values || []).reduce( + (previousSize, value) => previousSize + estimateByteSize(value), + 0 + ); +} + +/** + * Converts the possible Proto values for a timestamp value into a "seconds and + * nanos" representation. + */ +export function normalizeTimestamp( + date: api.Timestamp +): { seconds: number; nanos: number } { + assert(!!date, 'Cannot normalize null or undefined timestamp.'); + + // The json interface (for the browser) will return an iso timestamp string, + // while the proto js library (for node) will return a + // google.protobuf.Timestamp instance. + if (typeof date === 'string') { + // The date string can have higher precision (nanos) than the Date class + // (millis), so we do some custom parsing here. + + // Parse the nanos right out of the string. + let nanos = 0; + const fraction = ISO_TIMESTAMP_REG_EXP.exec(date); + assert(!!fraction, 'invalid timestamp: ' + date); + if (fraction[1]) { + // Pad the fraction out to 9 digits (nanos). + let nanoStr = fraction[1]; + nanoStr = (nanoStr + '000000000').substr(0, 9); + nanos = Number(nanoStr); + } + + // Parse the date to get the seconds. + const parsedDate = new Date(date); + const seconds = Math.floor(parsedDate.getTime() / 1000); + + return { seconds, nanos }; + } else { + // TODO(b/37282237): Use strings for Proto3 timestamps + // assert(!this.options.useProto3Json, + // 'The timestamp instance format requires Proto JS.'); + const seconds = normalizeNumber(date.seconds); + const nanos = normalizeNumber(date.nanos); + return { seconds, nanos }; + } +} + +/** + * Converts the possible Proto types for numbers into a JavaScript number. + * Returns 0 if the value is not numeric. + */ +export function normalizeNumber(value: number | string | undefined): number { + // TODO(bjornick): Handle int64 greater than 53 bits. + if (typeof value === 'number') { + return value; + } else if (typeof value === 'string') { + return Number(value); + } else { + return 0; + } +} + +/** Converts the possible Proto types for Blobs into a ByteString. */ +export function normalizeByteString(blob: string | Uint8Array): ByteString { + if (typeof blob === 'string') { + return ByteString.fromBase64String(blob); + } else { + return ByteString.fromUint8Array(blob); + } +} + +/** Returns a reference value for the provided database and key. */ +export function refValue(databaseId: DatabaseId, key: DocumentKey): api.Value { + return { + referenceValue: `projects/${databaseId.projectId}/databases/${ + databaseId.database + }/documents/${key.path.canonicalString()}` + }; +} + +/** Returns true if `value` is an IntegerValue . */ +export function isInteger( + value?: api.Value | null +): value is { integerValue: string | number } { + return !!value && 'integerValue' in value; +} + +/** Returns true if `value` is a DoubleValue. */ +export function isDouble( + value?: api.Value | null +): value is { doubleValue: string | number } { + return !!value && 'doubleValue' in value; +} + +/** Returns true if `value` is either an IntegerValue or a DoubleValue. */ +export function isNumber(value?: api.Value | null): boolean { + return isInteger(value) || isDouble(value); +} + +/** Returns true if `value` is an ArrayValue. */ +export function isArray( + value?: api.Value | null +): value is { arrayValue: api.ArrayValue } { + return !!value && 'arrayValue' in value; +} + +/** Returns true if `value` is a ReferenceValue. */ +export function isReferenceValue( + value?: api.Value | null +): value is { referenceValue: string } { + return !!value && 'referenceValue' in value; +} + +/** Returns true if `value` is a NullValue. */ +export function isNullValue( + value?: api.Value | null +): value is { nullValue: 'NULL_VALUE' } { + return !!value && 'nullValue' in value; +} + +/** Returns true if `value` is NaN. */ +export function isNanValue( + value?: api.Value | null +): value is { doubleValue: 'NaN' | number } { + return !!value && 'doubleValue' in value && isNaN(Number(value.doubleValue)); +} + +/** Returns true if `value` is a MapValue. */ +export function isMapValue( + value?: api.Value | null +): value is { mapValue: api.MapValue } { + return !!value && 'mapValue' in value; +} diff --git a/packages/firestore/src/platform/platform.ts b/packages/firestore/src/platform/platform.ts index 5536fb5ce22..7f6176e3a0d 100644 --- a/packages/firestore/src/platform/platform.ts +++ b/packages/firestore/src/platform/platform.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -57,6 +57,12 @@ export interface Platform { /** True if and only if the Base64 conversion functions are available. */ readonly base64Available: boolean; + + /** + * True if timestamps, bytes and numbers are represented in Proto3 JSON + * format (in-memory and on the wire) + */ + readonly useProto3Json: boolean; } /** diff --git a/packages/firestore/src/platform_browser/browser_platform.ts b/packages/firestore/src/platform_browser/browser_platform.ts index 333548a92ba..d1dc1a75124 100644 --- a/packages/firestore/src/platform_browser/browser_platform.ts +++ b/packages/firestore/src/platform_browser/browser_platform.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,7 @@ import { BrowserConnectivityMonitor } from './browser_connectivity_monitor'; import { WebChannelConnection } from './webchannel_connection'; export class BrowserPlatform implements Platform { + readonly useProto3Json = true; readonly base64Available: boolean; constructor() { diff --git a/packages/firestore/src/platform_node/node_platform.ts b/packages/firestore/src/platform_node/node_platform.ts index c42cd665dd3..9fd86703ee6 100644 --- a/packages/firestore/src/platform_node/node_platform.ts +++ b/packages/firestore/src/platform_node/node_platform.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,6 +30,7 @@ import { GrpcConnection } from './grpc_connection'; import { loadProtos } from './load_protos'; export class NodePlatform implements Platform { + readonly useProto3Json = false; readonly base64Available = true; readonly document = null; diff --git a/packages/firestore/src/protos/firestore_proto_api.d.ts b/packages/firestore/src/protos/firestore_proto_api.d.ts index 895dc1f3676..37d6aba18c1 100644 --- a/packages/firestore/src/protos/firestore_proto_api.d.ts +++ b/packages/firestore/src/protos/firestore_proto_api.d.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2019 Google Inc. + * Copyright 2019 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,9 @@ export declare type PromiseRequestService = any; export interface ApiClientObjectMap { [k: string]: T; } +export declare type Timestamp = + | string + | { seconds?: string | number; nanos?: number }; export declare type CompositeFilterOp = 'OPERATOR_UNSPECIFIED' | 'AND'; export interface ICompositeFilterOpEnum { @@ -180,7 +183,7 @@ export declare namespace firestoreV1ApiClientInterfaces { name?: string; fields?: ApiClientObjectMap; createTime?: string; - updateTime?: string; + updateTime?: Timestamp; } interface DocumentChange { document?: Document; @@ -190,7 +193,7 @@ export declare namespace firestoreV1ApiClientInterfaces { interface DocumentDelete { document?: string; removedTargetIds?: number[]; - readTime?: string; + readTime?: Timestamp; } interface DocumentMask { fieldPaths?: string[]; @@ -290,7 +293,7 @@ export declare namespace firestoreV1ApiClientInterfaces { } interface Precondition { exists?: boolean; - updateTime?: string; + updateTime?: Timestamp; } interface Projection { fields?: FieldReference[]; @@ -333,12 +336,12 @@ export declare namespace firestoreV1ApiClientInterfaces { startAt?: Cursor; endAt?: Cursor; offset?: number; - limit?: number; + limit?: number | { value: number }; } interface Target { query?: QueryTarget; documents?: DocumentsTarget; - resumeToken?: string; + resumeToken?: string | Uint8Array; readTime?: string; targetId?: number; once?: boolean; @@ -347,8 +350,8 @@ export declare namespace firestoreV1ApiClientInterfaces { targetChangeType?: TargetChangeTargetChangeType; targetIds?: number[]; cause?: Status; - resumeToken?: string; - readTime?: string; + resumeToken?: string | Uint8Array; + readTime?: Timestamp; } interface TransactionOptions { readOnly?: ReadOnly; @@ -361,9 +364,9 @@ export declare namespace firestoreV1ApiClientInterfaces { interface Value { nullValue?: ValueNullValue; booleanValue?: boolean; - integerValue?: string; - doubleValue?: number; - timestampValue?: string | { seconds: string; nanos: number }; + integerValue?: string | number; + doubleValue?: string | number; + timestampValue?: Timestamp; stringValue?: string; bytesValue?: string | Uint8Array; referenceValue?: string; @@ -382,17 +385,17 @@ export declare namespace firestoreV1ApiClientInterfaces { interface WriteRequest { streamId?: string; writes?: Write[]; - streamToken?: string; + streamToken?: string | Uint8Array; labels?: ApiClientObjectMap; } interface WriteResponse { streamId?: string; streamToken?: string; writeResults?: WriteResult[]; - commitTime?: string; + commitTime?: Timestamp; } interface WriteResult { - updateTime?: string; + updateTime?: Timestamp; transformResults?: Value[]; } } diff --git a/packages/firestore/src/remote/serializer.ts b/packages/firestore/src/remote/serializer.ts index 1764532f424..ff52027211c 100644 --- a/packages/firestore/src/remote/serializer.ts +++ b/packages/firestore/src/remote/serializer.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,7 +16,6 @@ */ import { Blob } from '../api/blob'; -import { GeoPoint } from '../api/geo_point'; import { Timestamp } from '../api/timestamp'; import { DatabaseId } from '../core/database_info'; import { @@ -35,7 +34,7 @@ import { TargetId } from '../core/types'; import { TargetData, TargetPurpose } from '../local/target_data'; import { Document, MaybeDocument, NoDocument } from '../model/document'; import { DocumentKey } from '../model/document_key'; -import * as fieldValue from '../model/field_value'; +import { ObjectValue } from '../model/field_value'; import { DeleteMutation, FieldMask, @@ -52,10 +51,12 @@ import { FieldPath, ResourcePath } from '../model/path'; import * as api from '../protos/firestore_proto_api'; import { assert, fail } from '../util/assert'; import { Code, FirestoreError } from '../util/error'; -import * as obj from '../util/obj'; import { ByteString } from '../util/byte_string'; -import * as typeUtils from '../util/types'; - +import { + isNegativeZero, + isNullOrUndefined, + isSafeInteger +} from '../util/types'; import { ArrayRemoveTransformOperation, ArrayUnionTransformOperation, @@ -72,6 +73,7 @@ import { WatchTargetChange, WatchTargetChangeState } from './watch_change'; +import { isNanValue, isNullValue, normalizeTimestamp } from '../model/values'; const DIRECTIONS = (() => { const dirs: { [dir: string]: api.OrderDirection } = {}; @@ -93,29 +95,8 @@ const OPERATORS = (() => { return ops; })(); -// A RegExp matching ISO 8601 UTC timestamps with optional fraction. -const ISO_REG_EXP = new RegExp(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.(\d+))?Z$/); - function assertPresent(value: unknown, description: string): asserts value { - assert(!typeUtils.isNullOrUndefined(value), description + ' is missing'); -} - -function parseInt64(value: number | string): number { - // TODO(bjornick): Handle int64 greater than 53 bits. - if (typeof value === 'number') { - return value; - } else if (typeof value === 'string') { - return Number(value); - } else { - return fail("can't parse " + value); - } -} - -// This is a supplement to the generated proto interfaces, which fail to account -// for the fact that a timestamp may be encoded as either a string OR this. -interface TimestampProto { - seconds?: string; - nanos?: number; + assert(!isNullOrUndefined(value), description + ' is missing'); } export interface SerializerOptions { @@ -157,46 +138,66 @@ export class JsonProtoSerializer { * our generated proto interfaces say Int32Value must be. But GRPC actually * expects a { value: } struct. */ - private toInt32Value(val: number | null): number | null { - if (this.options.useProto3Json || typeUtils.isNullOrUndefined(val)) { + private toInt32Proto(val: number | null): number | { value: number } | null { + if (this.options.useProto3Json || isNullOrUndefined(val)) { return val; } else { - // ProtobufJS requires that we wrap Int32Values. - // Use any because we need to match generated Proto types. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return { value: val } as any; + return { value: val }; } } /** * Returns a number (or null) from a google.protobuf.Int32Value proto. - * DO NOT USE THIS FOR ANYTHING ELSE. - * This method cheats. It's typed as accepting "number" because that's what - * our generated proto interfaces say Int32Value must be, but it actually - * accepts { value: number } to match our serialization in toInt32Value(). */ - private fromInt32Value(val: number | undefined): number | null { + private fromInt32Proto( + val: number | { value: number } | undefined + ): number | null { let result; if (typeof val === 'object') { - // Use any because we need to match generated Proto types. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - result = (val as any).value; + result = val.value; } else { - // We accept raw numbers (without the {value: ... } wrapper) for - // compatibility with legacy persisted data. result = val; } - return typeUtils.isNullOrUndefined(result) ? null : result; + return isNullOrUndefined(result) ? null : result; + } + + /** + * Returns an IntegerValue for `value`. + */ + toInteger(value: number): api.Value { + return { integerValue: '' + value }; + } + + /** + * Returns an DoubleValue for `value` that is encoded based the serializer's + * `useProto3Json` setting. + */ + toDouble(value: number): api.Value { + if (this.options.useProto3Json) { + if (isNaN(value)) { + return { doubleValue: 'NaN' }; + } else if (value === Infinity) { + return { doubleValue: 'Infinity' }; + } else if (value === -Infinity) { + return { doubleValue: '-Infinity' }; + } + } + return { doubleValue: isNegativeZero(value) ? '-0' : value }; + } + + /** + * Returns a value for a number that's appropriate to put into a proto. + * The return value is an IntegerValue if it can safely represent the value, + * otherwise a DoubleValue is returned. + */ + toNumber(value: number): api.Value { + return isSafeInteger(value) ? this.toInteger(value) : this.toDouble(value); } /** * Returns a value for a Date that's appropriate to put into a proto. - * DO NOT USE THIS FOR ANYTHING ELSE. - * This method cheats. It's typed as returning "string" because that's what - * our generated proto interfaces say dates must be. But it's easier and safer - * to actually return a Timestamp proto. */ - private toTimestamp(timestamp: Timestamp): string { + toTimestamp(timestamp: Timestamp): api.Timestamp { if (this.options.useProto3Json) { // Serialize to ISO-8601 date format, but with full nano resolution. // Since JS Date has only millis, let's only use it for the seconds and @@ -217,62 +218,21 @@ export class JsonProtoSerializer { } } - private fromTimestamp(date: string | TimestampProto): Timestamp { - // The json interface (for the browser) will return an iso timestamp string, - // while the proto js library (for node) will return a - // google.protobuf.Timestamp instance. - if (typeof date === 'string') { - // TODO(b/37282237): Use strings for Proto3 timestamps - // assert(this.options.useProto3Json, - // 'The timestamp string format requires Proto3.'); - return this.fromIso8601String(date); - } else { - assert(!!date, 'Cannot deserialize null or undefined timestamp.'); - // TODO(b/37282237): Use strings for Proto3 timestamps - // assert(!this.options.useProto3Json, - // 'The timestamp instance format requires Proto JS.'); - const seconds = parseInt64(date.seconds || '0'); - const nanos = date.nanos || 0; - return new Timestamp(seconds, nanos); - } - } - - private fromIso8601String(utc: string): Timestamp { - // The date string can have higher precision (nanos) than the Date class - // (millis), so we do some custom parsing here. - - // Parse the nanos right out of the string. - let nanos = 0; - const fraction = ISO_REG_EXP.exec(utc); - assert(!!fraction, 'invalid timestamp: ' + utc); - if (fraction[1]) { - // Pad the fraction out to 9 digits (nanos). - let nanoStr = fraction[1]; - nanoStr = (nanoStr + '000000000').substr(0, 9); - nanos = Number(nanoStr); - } - - // Parse the date to get the seconds. - const date = new Date(utc); - const seconds = Math.floor(date.getTime() / 1000); - - return new Timestamp(seconds, nanos); + private fromTimestamp(date: api.Timestamp): Timestamp { + const timestamp = normalizeTimestamp(date); + return new Timestamp(timestamp.seconds, timestamp.nanos); } /** * Returns a value for bytes that's appropriate to put in a proto. - * DO NOT USE THIS FOR ANYTHING ELSE. - * This method cheats. It's typed as returning "string" because that's what - * our generated proto interfaces say bytes must be. But it should return - * an Uint8Array in Node. * * Visible for testing. */ - toBytes(bytes: Blob | ByteString): string { + toBytes(bytes: Blob | ByteString): string | Uint8Array { if (this.options.useProto3Json) { return bytes.toBase64(); } else { - return (bytes.toUint8Array() as unknown) as string; + return bytes.toUint8Array(); } } @@ -295,38 +255,17 @@ export class JsonProtoSerializer { } } - /** - * Parse the blob from the protos into the internal Blob class. Note that the - * typings assume all blobs are strings, but they are actually Uint8Arrays - * on Node. - */ - private fromBlob(blob: string | Uint8Array): Blob { - if (typeof blob === 'string') { - assert( - this.options.useProto3Json, - 'Expected bytes to be passed in as Uint8Array, but got a string instead.' - ); - return Blob.fromBase64String(blob); - } else { - assert( - !this.options.useProto3Json, - 'Expected bytes to be passed in as Uint8Array, but got a string instead.' - ); - return Blob.fromUint8Array(blob); - } - } - - toVersion(version: SnapshotVersion): string { + toVersion(version: SnapshotVersion): api.Timestamp { return this.toTimestamp(version.toTimestamp()); } - fromVersion(version: string): SnapshotVersion { + fromVersion(version: api.Timestamp): SnapshotVersion { assert(!!version, "Trying to deserialize version that isn't set"); return SnapshotVersion.fromTimestamp(this.fromTimestamp(version)); } - toResourceName(databaseId: DatabaseId, path: ResourcePath): string { - return this.fullyQualifiedPrefixPath(databaseId) + toResourceName(path: ResourcePath, databaseId?: DatabaseId): string { + return this.fullyQualifiedPrefixPath(databaseId || this.databaseId) .child('documents') .child(path) .canonicalString(); @@ -335,14 +274,14 @@ export class JsonProtoSerializer { fromResourceName(name: string): ResourcePath { const resource = ResourcePath.fromString(name); assert( - this.isValidResourceName(resource), + isValidResourceName(resource), 'Tried to deserialize invalid key ' + resource.toString() ); return resource; } toName(key: DocumentKey): string { - return this.toResourceName(this.databaseId, key.path); + return this.toResourceName(key.path); } fromName(name: string): DocumentKey { @@ -366,7 +305,7 @@ export class JsonProtoSerializer { } toQueryPath(path: ResourcePath): string { - return this.toResourceName(this.databaseId, path); + return this.toResourceName(path); } fromQueryPath(name: string): ResourcePath { @@ -410,135 +349,11 @@ export class JsonProtoSerializer { return resourceName.popFirst(5); } - private isValidResourceName(path: ResourcePath): boolean { - // Resource names have at least 4 components (project ID, database ID) - return ( - path.length >= 4 && - path.get(0) === 'projects' && - path.get(2) === 'databases' - ); - } - - toValue(val: fieldValue.FieldValue): api.Value { - if (val instanceof fieldValue.NullValue) { - return { nullValue: 'NULL_VALUE' }; - } else if (val instanceof fieldValue.BooleanValue) { - return { booleanValue: val.value() }; - } else if (val instanceof fieldValue.IntegerValue) { - return { integerValue: '' + val.value() }; - } else if (val instanceof fieldValue.DoubleValue) { - const doubleValue = val.value(); - if (this.options.useProto3Json) { - // Proto 3 let's us encode NaN and Infinity as string values as - // expected by the backend. This is currently not checked by our unit - // tests because they rely on protobuf.js. - if (isNaN(doubleValue)) { - return { doubleValue: 'NaN' } as {}; - } else if (doubleValue === Infinity) { - return { doubleValue: 'Infinity' } as {}; - } else if (doubleValue === -Infinity) { - return { doubleValue: '-Infinity' } as {}; - } else if (typeUtils.isNegativeZero(doubleValue)) { - return { doubleValue: '-0' } as {}; - } - } - return { doubleValue: val.value() }; - } else if (val instanceof fieldValue.StringValue) { - return { stringValue: val.value() }; - } else if (val instanceof fieldValue.ObjectValue) { - return { mapValue: this.toMapValue(val) }; - } else if (val instanceof fieldValue.ArrayValue) { - return { arrayValue: this.toArrayValue(val) }; - } else if (val instanceof fieldValue.TimestampValue) { - return { - timestampValue: this.toTimestamp(val.internalValue) - }; - } else if (val instanceof fieldValue.GeoPointValue) { - return { - geoPointValue: { - latitude: val.value().latitude, - longitude: val.value().longitude - } - }; - } else if (val instanceof fieldValue.BlobValue) { - return { - bytesValue: this.toBytes(val.value()) - }; - } else if (val instanceof fieldValue.RefValue) { - return { - referenceValue: this.toResourceName(val.databaseId, val.key.path) - }; - } else { - return fail('Unknown FieldValue ' + JSON.stringify(val)); - } - } - - fromValue(obj: api.Value): fieldValue.FieldValue { - if ('nullValue' in obj) { - return fieldValue.NullValue.INSTANCE; - } else if ('booleanValue' in obj) { - return fieldValue.BooleanValue.of(obj.booleanValue!); - } else if ('integerValue' in obj) { - return new fieldValue.IntegerValue(parseInt64(obj.integerValue!)); - } else if ('doubleValue' in obj) { - if (this.options.useProto3Json) { - // Proto 3 uses the string values 'NaN' and 'Infinity'. - if ((obj.doubleValue as {}) === 'NaN') { - return fieldValue.DoubleValue.NAN; - } else if ((obj.doubleValue as {}) === 'Infinity') { - return fieldValue.DoubleValue.POSITIVE_INFINITY; - } else if ((obj.doubleValue as {}) === '-Infinity') { - return fieldValue.DoubleValue.NEGATIVE_INFINITY; - } else if ((obj.doubleValue as {}) === '-0') { - return new fieldValue.DoubleValue(-0); - } - } - - return new fieldValue.DoubleValue(obj.doubleValue!); - } else if ('stringValue' in obj) { - return new fieldValue.StringValue(obj.stringValue!); - } else if ('mapValue' in obj) { - return this.fromFields(obj.mapValue!.fields || {}); - } else if ('arrayValue' in obj) { - // "values" is not present if the array is empty - assertPresent(obj.arrayValue, 'arrayValue'); - const values = obj.arrayValue.values || []; - return new fieldValue.ArrayValue(values.map(v => this.fromValue(v))); - } else if ('timestampValue' in obj) { - assertPresent(obj.timestampValue, 'timestampValue'); - return new fieldValue.TimestampValue( - this.fromTimestamp(obj.timestampValue!) - ); - } else if ('geoPointValue' in obj) { - assertPresent(obj.geoPointValue, 'geoPointValue'); - const latitude = obj.geoPointValue.latitude || 0; - const longitude = obj.geoPointValue.longitude || 0; - return new fieldValue.GeoPointValue(new GeoPoint(latitude, longitude)); - } else if ('bytesValue' in obj) { - assertPresent(obj.bytesValue, 'bytesValue'); - const blob = this.fromBlob(obj.bytesValue); - return new fieldValue.BlobValue(blob); - } else if ('referenceValue' in obj) { - assertPresent(obj.referenceValue, 'referenceValue'); - const resourceName = this.fromResourceName(obj.referenceValue); - const dbId = new DatabaseId(resourceName.get(1), resourceName.get(3)); - const key = new DocumentKey( - this.extractLocalPathFromResourceName(resourceName) - ); - return new fieldValue.RefValue(dbId, key); - } else { - return fail('Unknown Value proto ' + JSON.stringify(obj)); - } - } - /** Creates an api.Document from key and fields (but no create/update time) */ - toMutationDocument( - key: DocumentKey, - fields: fieldValue.ObjectValue - ): api.Document { + toMutationDocument(key: DocumentKey, fields: ObjectValue): api.Document { return { name: this.toName(key), - fields: this.toFields(fields) + fields: fields.proto.mapValue.fields }; } @@ -549,7 +364,7 @@ export class JsonProtoSerializer { ); return { name: this.toName(document.key), - fields: this.toFields(document.data()), + fields: document.toProto().mapValue.fields, updateTime: this.toTimestamp(document.version.toTimestamp()) }; } @@ -560,46 +375,10 @@ export class JsonProtoSerializer { ): Document { const key = this.fromName(document.name!); const version = this.fromVersion(document.updateTime!); - return new Document( - key, - version, - { hasCommittedMutations: !!hasCommittedMutations }, - undefined, - document, - v => this.fromValue(v) - ); - } - - toFields(fields: fieldValue.ObjectValue): { [key: string]: api.Value } { - const result: { [key: string]: api.Value } = {}; - fields.forEach((key, value) => { - result[key] = this.toValue(value); - }); - return result; - } - - fromFields(object: {}): fieldValue.ObjectValue { - // Proto map gets mapped to Object, so cast it. - const map = object as { [key: string]: api.Value }; - const result = fieldValue.ObjectValue.newBuilder(); - obj.forEach(map, (key, value) => { - result.set(new FieldPath([key]), this.fromValue(value)); - }); - return result.build(); - } - - toMapValue(map: fieldValue.ObjectValue): api.MapValue { - return { - fields: this.toFields(map) - }; - } - - toArrayValue(array: fieldValue.ArrayValue): api.ArrayValue { - const result: api.Value[] = []; - array.forEach(value => { - result.push(this.toValue(value)); + const data = new ObjectValue({ mapValue: { fields: document.fields } }); + return new Document(key, version, data, { + hasCommittedMutations: !!hasCommittedMutations }); - return { values: result }; } private fromFound(doc: api.BatchGetDocumentsResponse): Document { @@ -611,9 +390,8 @@ export class JsonProtoSerializer { assertPresent(doc.found.updateTime, 'doc.found.updateTime'); const key = this.fromName(doc.found.name); const version = this.fromVersion(doc.found.updateTime); - return new Document(key, version, {}, undefined, doc.found, v => - this.fromValue(v) - ); + const data = new ObjectValue({ mapValue: { fields: doc.found.fields } }); + return new Document(key, version, data, {}); } private fromMissing(result: api.BatchGetDocumentsResponse): NoDocument { @@ -674,7 +452,7 @@ export class JsonProtoSerializer { documentChange: { document: { name: this.toName(doc.key), - fields: this.toFields(doc.data()), + fields: doc.toProto().mapValue.fields, updateTime: this.toVersion(doc.version) }, targetIds: watchChange.updatedTargetIds, @@ -750,14 +528,10 @@ export class JsonProtoSerializer { ); const key = this.fromName(entityChange.document.name); const version = this.fromVersion(entityChange.document.updateTime); - const doc = new Document( - key, - version, - {}, - undefined, - entityChange.document!, - v => this.fromValue(v) - ); + const data = new ObjectValue({ + mapValue: { fields: entityChange.document.fields } + }); + const doc = new Document(key, version, data, {}); const updatedTargetIds = entityChange.targetIds || []; const removedTargetIds = entityChange.removedTargetIds || []; watchChange = new DocumentWatchChange( @@ -879,7 +653,9 @@ export class JsonProtoSerializer { if (proto.update) { assertPresent(proto.update.name, 'name'); const key = this.fromName(proto.update.name); - const value = this.fromFields(proto.update.fields || {}); + const value = new ObjectValue({ + mapValue: { fields: proto.update.fields } + }); if (proto.updateMask) { const fieldMask = this.fromDocumentMask(proto.updateMask); return new PatchMutation(key, value, fieldMask, precondition); @@ -932,7 +708,7 @@ export class JsonProtoSerializer { private fromWriteResult( proto: api.WriteResult, - commitTime: string + commitTime: api.Timestamp ): MutationResult { // NOTE: Deletes don't have an updateTime. let version = proto.updateTime @@ -948,18 +724,16 @@ export class JsonProtoSerializer { version = this.fromVersion(commitTime); } - let transformResults: fieldValue.FieldValue[] | null = null; + let transformResults: api.Value[] | null = null; if (proto.transformResults && proto.transformResults.length > 0) { - transformResults = proto.transformResults.map(result => - this.fromValue(result) - ); + transformResults = proto.transformResults; } return new MutationResult(version, transformResults); } fromWriteResults( protos: api.WriteResult[] | undefined, - commitTime?: string + commitTime?: api.Timestamp ): MutationResult[] { if (protos && protos.length > 0) { assert( @@ -983,20 +757,20 @@ export class JsonProtoSerializer { return { fieldPath: fieldTransform.field.canonicalString(), appendMissingElements: { - values: transform.elements.map(v => this.toValue(v)) + values: transform.elements } }; } else if (transform instanceof ArrayRemoveTransformOperation) { return { fieldPath: fieldTransform.field.canonicalString(), removeAllFromArray: { - values: transform.elements.map(v => this.toValue(v)) + values: transform.elements } }; } else if (transform instanceof NumericIncrementTransformOperation) { return { fieldPath: fieldTransform.field.canonicalString(), - increment: this.toValue(transform.operand) + increment: transform.operand }; } else { throw fail('Unknown transform: ' + fieldTransform.transform); @@ -1013,22 +787,14 @@ export class JsonProtoSerializer { transform = ServerTimestampTransform.instance; } else if ('appendMissingElements' in proto) { const values = proto.appendMissingElements!.values || []; - transform = new ArrayUnionTransformOperation( - values.map(v => this.fromValue(v)) - ); + transform = new ArrayUnionTransformOperation(values); } else if ('removeAllFromArray' in proto) { const values = proto.removeAllFromArray!.values || []; - transform = new ArrayRemoveTransformOperation( - values.map(v => this.fromValue(v)) - ); + transform = new ArrayRemoveTransformOperation(values); } else if ('increment' in proto) { - const operand = this.fromValue(proto.increment!); - assert( - operand instanceof fieldValue.NumberValue, - 'NUMERIC_ADD transform requires a NumberValue' - ); transform = new NumericIncrementTransformOperation( - operand as fieldValue.NumberValue + this, + proto.increment! ); } else { fail('Unknown transform proto: ' + JSON.stringify(proto)); @@ -1086,7 +852,7 @@ export class JsonProtoSerializer { result.structuredQuery!.orderBy = orderBy; } - const limit = this.toInt32Value(target.limit); + const limit = this.toInt32Proto(target.limit); if (limit !== null) { result.structuredQuery!.limit = limit; } @@ -1132,7 +898,7 @@ export class JsonProtoSerializer { let limit: number | null = null; if (query.limit) { - limit = this.fromInt32Value(query.limit); + limit = this.fromInt32Proto(query.limit); } let startAt: Bound | null = null; @@ -1249,13 +1015,13 @@ export class JsonProtoSerializer { private toCursor(cursor: Bound): api.Cursor { return { before: cursor.before, - values: cursor.position.map(component => this.toValue(component)) + values: cursor.position }; } private fromCursor(cursor: api.Cursor): Bound { const before = !!cursor.before; - const position = cursor.values!.map(component => this.fromValue(component)); + const position = cursor.values || []; return new Bound(position, before); } @@ -1333,21 +1099,21 @@ export class JsonProtoSerializer { return FieldFilter.create( this.fromFieldPathReference(filter.fieldFilter!.field!), this.fromOperatorName(filter.fieldFilter!.op!), - this.fromValue(filter.fieldFilter!.value!) + filter.fieldFilter!.value! ); } // visible for testing toUnaryOrFieldFilter(filter: FieldFilter): api.Filter { if (filter.op === Operator.EQUAL) { - if (filter.value.isEqual(fieldValue.DoubleValue.NAN)) { + if (isNanValue(filter.value)) { return { unaryFilter: { field: this.toFieldPathReference(filter.field), op: 'IS_NAN' } }; - } else if (filter.value.isEqual(fieldValue.NullValue.INSTANCE)) { + } else if (isNullValue(filter.value)) { return { unaryFilter: { field: this.toFieldPathReference(filter.field), @@ -1360,7 +1126,7 @@ export class JsonProtoSerializer { fieldFilter: { field: this.toFieldPathReference(filter.field), op: this.toOperatorName(filter.op), - value: this.toValue(filter.value) + value: filter.value } }; } @@ -1371,20 +1137,16 @@ export class JsonProtoSerializer { const nanField = this.fromFieldPathReference( filter.unaryFilter!.field! ); - return FieldFilter.create( - nanField, - Operator.EQUAL, - fieldValue.DoubleValue.NAN - ); + return FieldFilter.create(nanField, Operator.EQUAL, { + doubleValue: NaN + }); case 'IS_NULL': const nullField = this.fromFieldPathReference( filter.unaryFilter!.field! ); - return FieldFilter.create( - nullField, - Operator.EQUAL, - fieldValue.NullValue.INSTANCE - ); + return FieldFilter.create(nullField, Operator.EQUAL, { + nullValue: 'NULL_VALUE' + }); case 'OPERATOR_UNSPECIFIED': return fail('Unspecified filter'); default: @@ -1408,3 +1170,12 @@ export class JsonProtoSerializer { return FieldMask.fromArray(fields); } } + +export function isValidResourceName(path: ResourcePath): boolean { + // Resource names have at least 4 components (project ID, database ID) + return ( + path.length >= 4 && + path.get(0) === 'projects' && + path.get(2) === 'databases' + ); +} diff --git a/packages/firestore/src/util/byte_string.ts b/packages/firestore/src/util/byte_string.ts index b6c0c3b9c00..79e2f1c6d70 100644 --- a/packages/firestore/src/util/byte_string.ts +++ b/packages/firestore/src/util/byte_string.ts @@ -16,6 +16,7 @@ */ import { PlatformSupport } from '../platform/platform'; +import { primitiveComparator } from './misc'; /** * Immutable class that represents a "proto" byte string. @@ -28,11 +29,7 @@ import { PlatformSupport } from '../platform/platform'; export class ByteString { static readonly EMPTY_BYTE_STRING = new ByteString(''); - private readonly _binaryString: string; - - private constructor(binaryString: string) { - this._binaryString = binaryString; - } + private constructor(private readonly binaryString: string) {} static fromBase64String(base64: string): ByteString { const binaryString = PlatformSupport.getPlatform().atob(base64); @@ -45,19 +42,23 @@ export class ByteString { } toBase64(): string { - return PlatformSupport.getPlatform().btoa(this._binaryString); + return PlatformSupport.getPlatform().btoa(this.binaryString); } toUint8Array(): Uint8Array { - return uint8ArrayFromBinaryString(this._binaryString); + return uint8ArrayFromBinaryString(this.binaryString); } approximateByteSize(): number { - return this._binaryString.length * 2; + return this.binaryString.length * 2; + } + + compareTo(other: ByteString): number { + return primitiveComparator(this.binaryString, other.binaryString); } isEqual(other: ByteString): boolean { - return this._binaryString === other._binaryString; + return this.binaryString === other.binaryString; } } diff --git a/packages/firestore/src/util/misc.ts b/packages/firestore/src/util/misc.ts index ab80ceb53fa..dd37a9d3b36 100644 --- a/packages/firestore/src/util/misc.ts +++ b/packages/firestore/src/util/misc.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -76,18 +76,15 @@ export function equals( } /** Helper to compare arrays using isEqual(). */ -export function arrayEquals(left: Array>, right: T[]): boolean { +export function arrayEquals( + left: T[], + right: T[], + comparator: (l: T, r: T) => boolean +): boolean { if (left.length !== right.length) { return false; } - - for (let i = 0; i < left.length; i++) { - if (!left[i].isEqual(right[i])) { - return false; - } - } - - return true; + return left.every((value, index) => comparator(value, right[index])); } /** * Returns the immediate lexicographically-following string. This is useful to diff --git a/packages/firestore/src/util/obj.ts b/packages/firestore/src/util/obj.ts index 237a8670689..19a11d8c0df 100644 --- a/packages/firestore/src/util/obj.ts +++ b/packages/firestore/src/util/obj.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,7 +30,7 @@ export function get(obj: Dict, key: string | number): V | null { return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : null; } -export function size(obj: Dict): number { +export function size(obj: object): number { let count = 0; for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { @@ -65,6 +65,12 @@ export function values(obj: Dict): V[] { return vs; } +export function keys(obj: Dict): string[] { + const ks: string[] = []; + forEach(obj, k => ks.push(k)); + return ks; +} + export function forEach( obj: Dict, fn: (key: string, val: V) => void diff --git a/packages/firestore/src/util/types.ts b/packages/firestore/src/util/types.ts index c0ca0db245c..3cfc3ca4533 100644 --- a/packages/firestore/src/util/types.ts +++ b/packages/firestore/src/util/types.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,7 +23,7 @@ export interface StringMap { /** * Returns whether a variable is either undefined or null. */ -export function isNullOrUndefined(value: unknown): boolean { +export function isNullOrUndefined(value: unknown): value is null | undefined { return value === null || value === undefined; } diff --git a/packages/firestore/test/integration/api/database.test.ts b/packages/firestore/test/integration/api/database.test.ts index 118042b1829..7da9bb8af7c 100644 --- a/packages/firestore/test/integration/api/database.test.ts +++ b/packages/firestore/test/integration/api/database.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -223,6 +223,24 @@ apiDescribe('Database', (persistence: boolean) => { }); }); + it('update with empty object replaces all fields', () => { + return withTestDoc(persistence, async doc => { + await doc.set({ a: 'a' }); + await doc.update('a', {}); + const docSnapshot = await doc.get(); + expect(docSnapshot.data()).to.be.deep.equal({ a: {} }); + }); + }); + + it('merge with empty object replaces all fields', () => { + return withTestDoc(persistence, async doc => { + await doc.set({ a: 'a' }); + await doc.set({ 'a': {} }, { merge: true }); + const docSnapshot = await doc.get(); + expect(docSnapshot.data()).to.be.deep.equal({ a: {} }); + }); + }); + it('can delete field using merge', () => { return withTestDoc(persistence, doc => { const initialData = { diff --git a/packages/firestore/test/unit/api/blob.test.ts b/packages/firestore/test/unit/api/blob.test.ts index 3a04f88d133..10bb93c0605 100644 --- a/packages/firestore/test/unit/api/blob.test.ts +++ b/packages/firestore/test/unit/api/blob.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,12 +17,7 @@ import { expect } from 'chai'; import { Blob, PublicBlob } from '../../../src/api/blob'; -import { - blob, - expectCorrectComparisons, - expectEqual, - expectNotEqual -} from '../../util/helpers'; +import { blob, expectEqual, expectNotEqual } from '../../util/helpers'; describe('Blob', () => { const base64Mappings: { [base64: string]: number[] } = { @@ -70,26 +65,6 @@ describe('Blob', () => { expect(Blob.fromBase64String('') instanceof PublicBlob).to.equal(true); }); - it('compares correctly', () => { - const values = [ - blob(0), - blob(0, 1), - blob(0, 1, 2), - blob(0, 2), - blob(0, 255), - blob(1), - blob(1, 0), - blob(1, 2), - blob(1, 255), - blob(2), - blob(255) - ]; - - expectCorrectComparisons(values, (left: Blob, right: Blob) => { - return left._compareTo(right); - }); - }); - it('support equality checking with isEqual()', () => { expectEqual(blob(1, 2, 3), blob(1, 2, 3)); expectNotEqual(blob(1, 2, 3), blob(4, 5, 6)); diff --git a/packages/firestore/test/unit/core/query.test.ts b/packages/firestore/test/unit/core/query.test.ts index e625b061433..a07930dd543 100644 --- a/packages/firestore/test/unit/core/query.test.ts +++ b/packages/firestore/test/unit/core/query.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,9 @@ */ import { expect } from 'chai'; +import { Blob } from '../../../src/api/blob'; +import { Timestamp } from '../../../src/api/timestamp'; +import { GeoPoint } from '../../../src/api/geo_point'; import { Bound, Query } from '../../../src/core/query'; import { DOCUMENT_KEY_NAME, ResourcePath } from '../../../src/model/path'; import { addEqualityMatcher } from '../../util/equality_matcher'; @@ -544,6 +547,107 @@ describe('Query', () => { }); }); + it('canonical ids are stable', () => { + // This test aims to ensure that we do not break canonical IDs, as they are + // used as keys in the TargetCache. + + const baseQuery = Query.atPath(path('collection')); + + assertCanonicalId(baseQuery, 'collection|f:|ob:__name__asc'); + assertCanonicalId( + baseQuery.addFilter(filter('a', '>', 'a')), + 'collection|f:a>a|ob:aasc,__name__asc' + ); + assertCanonicalId( + baseQuery.addFilter(filter('a', '<=', new GeoPoint(90.0, -90.0))), + 'collection|f:a<=geo(90,-90)|ob:aasc,__name__asc' + ); + assertCanonicalId( + baseQuery.addFilter(filter('a', '<=', new Timestamp(60, 3000))), + 'collection|f:a<=time(60,3000)|ob:aasc,__name__asc' + ); + assertCanonicalId( + baseQuery.addFilter( + filter('a', '>=', Blob.fromUint8Array(new Uint8Array([1, 2, 3]))) + ), + 'collection|f:a>=AQID|ob:aasc,__name__asc' + ); + assertCanonicalId( + baseQuery.addFilter(filter('a', '==', [1, 2, 3])), + 'collection|f:a==[1,2,3]|ob:__name__asc' + ); + assertCanonicalId( + baseQuery.addFilter(filter('a', '==', NaN)), + 'collection|f:a==NaN|ob:__name__asc' + ); + assertCanonicalId( + baseQuery.addFilter( + filter('__name__', '==', ref('test-project', 'collection/id')) + ), + 'collection|f:__name__==collection/id|ob:__name__asc' + ); + assertCanonicalId( + baseQuery.addFilter( + filter('a', '==', { 'a': 'b', 'inner': { 'd': 'c' } }) + ), + 'collection|f:a=={a:b,inner:{d:c}}|ob:__name__asc' + ); + assertCanonicalId( + baseQuery.addFilter(filter('a', 'in', [1, 2, 3])), + 'collection|f:ain[1,2,3]|ob:__name__asc' + ); + assertCanonicalId( + baseQuery.addFilter(filter('a', 'array-contains-any', [1, 2, 3])), + 'collection|f:aarray-contains-any[1,2,3]|ob:__name__asc' + ); + assertCanonicalId( + baseQuery.addFilter(filter('a', 'array-contains', 'a')), + 'collection|f:aarray-containsa|ob:__name__asc' + ); + assertCanonicalId( + baseQuery.addOrderBy(orderBy('a')), + 'collection|f:|ob:aasc,__name__asc' + ); + assertCanonicalId( + baseQuery + .addOrderBy(orderBy('a', 'asc')) + .addOrderBy(orderBy('b', 'asc')) + .withStartAt( + bound( + [ + ['a', 'foo', 'asc'], + ['b', [1, 2, 3], 'asc'] + ], + true + ) + ), + 'collection|f:|ob:aasc,basc,__name__asc|lb:b:foo,[1,2,3]' + ); + assertCanonicalId( + baseQuery + .addOrderBy(orderBy('a', 'desc')) + .addOrderBy(orderBy('b', 'desc')) + .withEndAt( + bound( + [ + ['a', 'foo', 'desc'], + ['b', [1, 2, 3], 'desc'] + ], + false + ) + ), + 'collection|f:|ob:adesc,bdesc,__name__desc|ub:a:foo,[1,2,3]' + ); + assertCanonicalId( + baseQuery.withLimitToFirst(5), + 'collection|f:|ob:__name__asc|l:5' + ); + assertCanonicalId( + baseQuery.withLimitToLast(5), + 'collection|f:|ob:__name__desc|l:5' + ); + }); + it("generates the correct implicit order by's", () => { const baseQuery = Query.atPath(path('foo')); // Default is ascending @@ -619,4 +723,8 @@ describe('Query', () => { query = baseQuery.withEndAt(bound([], true)); expect(query.matchesAllDocuments()).to.be.false; }); + + function assertCanonicalId(query: Query, expectedCanonicalId: string): void { + expect(query.toTarget().canonicalId()).to.equal(expectedCanonicalId); + } }); diff --git a/packages/firestore/test/unit/local/indexeddb_persistence.test.ts b/packages/firestore/test/unit/local/indexeddb_persistence.test.ts index d68d1addbc7..7f7b4a7a684 100644 --- a/packages/firestore/test/unit/local/indexeddb_persistence.test.ts +++ b/packages/firestore/test/unit/local/indexeddb_persistence.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ */ import { expect } from 'chai'; +import { Query } from '../../../src/core/query'; import { SnapshotVersion } from '../../../src/core/snapshot_version'; import { decode, encode } from '../../../src/local/encoded_resource_path'; import { IndexedDbPersistence } from '../../../src/local/indexeddb_persistence'; @@ -53,12 +54,13 @@ import { LruParams } from '../../../src/local/lru_garbage_collector'; import { PersistencePromise } from '../../../src/local/persistence_promise'; import { ClientId } from '../../../src/local/shared_client_state'; import { SimpleDb, SimpleDbTransaction } from '../../../src/local/simple_db'; +import { TargetData, TargetPurpose } from '../../../src/local/target_data'; import { PlatformSupport } from '../../../src/platform/platform'; import { firestoreV1ApiClientInterfaces } from '../../../src/protos/firestore_proto_api'; import { JsonProtoSerializer } from '../../../src/remote/serializer'; import { AsyncQueue } from '../../../src/util/async_queue'; import { FirestoreError } from '../../../src/util/error'; -import { doc, path, version } from '../../util/helpers'; +import { doc, filter, path, version } from '../../util/helpers'; import { SharedFakeWebStorage, TestPlatform } from '../../util/test_platform'; import { INDEXEDDB_TEST_DATABASE_NAME, @@ -720,6 +722,44 @@ describe('IndexedDbSchema: createOrUpgradeDb', () => { }); }); + it('rewrites canonical IDs during upgrade from version 9 to 10', async () => { + await withDb(9, db => { + const sdb = new SimpleDb(db); + return sdb.runTransaction('readwrite', V8_STORES, txn => { + const targetsStore = txn.store(DbTarget.store); + + const filteredQuery = Query.atPath(path('collection')).addFilter( + filter('foo', '==', 'bar') + ); + const initialTargetData = new TargetData( + filteredQuery.toTarget(), + /* targetId= */ 2, + TargetPurpose.Listen, + /* sequenceNumber= */ 1 + ); + + const serializedData = TEST_SERIALIZER.toDbTarget(initialTargetData); + serializedData.canonicalId = 'invalid_canonical_id'; + + return targetsStore.put(TEST_SERIALIZER.toDbTarget(initialTargetData)); + }); + }); + + await withDb(10, db => { + const sdb = new SimpleDb(db); + return sdb.runTransaction('readwrite', V8_STORES, txn => { + const targetsStore = txn.store(DbTarget.store); + return targetsStore.iterate((key, value) => { + const targetData = TEST_SERIALIZER.fromDbTarget(value).target; + const expectedCanonicalId = targetData.canonicalId(); + + const actualCanonicalId = value.canonicalId; + expect(actualCanonicalId).to.equal(expectedCanonicalId); + }); + }); + }); + }); + it('can use read-time index after schema migration', async () => { // This test creates a database with schema version 8 that has a few // remote documents, adds an index and then reads new documents back diff --git a/packages/firestore/test/unit/local/local_store.test.ts b/packages/firestore/test/unit/local/local_store.test.ts index 9600b4011f8..1432365b6e3 100644 --- a/packages/firestore/test/unit/local/local_store.test.ts +++ b/packages/firestore/test/unit/local/local_store.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,6 +15,8 @@ * limitations under the License. */ +import * as api from '../../../src/protos/firestore_proto_api'; + import { expect } from 'chai'; import { PublicFieldValue } from '../../../src/api/field_value'; import { Timestamp } from '../../../src/api/timestamp'; @@ -75,7 +77,6 @@ import { byteStringFromString } from '../../util/helpers'; -import { FieldValue, IntegerValue } from '../../../src/model/field_value'; import { CountingQueryEngine } from './counting_query_engine'; import * as persistenceHelpers from './persistence_test_helpers'; import { ByteString } from '../../../src/util/byte_string'; @@ -154,7 +155,7 @@ class LocalStoreTester { afterAcknowledgingMutation(options: { documentVersion: TestSnapshotVersion; - transformResult?: FieldValue; + transformResult?: api.Value; }): LocalStoreTester { this.prepareNextStep(); @@ -1251,7 +1252,7 @@ function genericLocalStoreTests( .toContain(doc('foo/bar', 1, { sum: 1 }, { hasLocalMutations: true })) .afterAcknowledgingMutation({ documentVersion: 2, - transformResult: new IntegerValue(1) + transformResult: { integerValue: 1 } }) .toReturnChanged( doc('foo/bar', 2, { sum: 1 }, { hasCommittedMutations: true }) @@ -1314,7 +1315,7 @@ function genericLocalStoreTests( .toContain(doc('foo/bar', 2, { sum: 3 }, { hasLocalMutations: true })) .afterAcknowledgingMutation({ documentVersion: 3, - transformResult: new IntegerValue(1) + transformResult: { integerValue: 1 } }) .toReturnChanged( doc('foo/bar', 3, { sum: 3 }, { hasLocalMutations: true }) @@ -1322,7 +1323,7 @@ function genericLocalStoreTests( .toContain(doc('foo/bar', 3, { sum: 3 }, { hasLocalMutations: true })) .afterAcknowledgingMutation({ documentVersion: 4, - transformResult: new IntegerValue(1339) + transformResult: { integerValue: 1339 } }) .toReturnChanged( doc('foo/bar', 4, { sum: 1339 }, { hasCommittedMutations: true }) diff --git a/packages/firestore/test/unit/local/lru_garbage_collector.test.ts b/packages/firestore/test/unit/local/lru_garbage_collector.test.ts index 074146df902..ac871879da0 100644 --- a/packages/firestore/test/unit/local/lru_garbage_collector.test.ts +++ b/packages/firestore/test/unit/local/lru_garbage_collector.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -49,7 +49,7 @@ import { SetMutation } from '../../../src/model/mutation'; import { AsyncQueue } from '../../../src/util/async_queue'; -import { path, wrapObject } from '../../util/helpers'; +import { key, path, wrapObject } from '../../util/helpers'; import { SortedMap } from '../../../src/util/sorted_map'; import * as PersistenceTestHelpers from './persistence_test_helpers'; import { primitiveComparator } from '../../../src/util/misc'; @@ -140,7 +140,7 @@ function genericLruGarbageCollectorTests( } function nextTestDocumentKey(): DocumentKey { - return DocumentKey.fromPathString('docs/doc_' + ++previousDocNum); + return key('docs/doc_' + ++previousDocNum); } function emptyTargetDataMap(): SortedMap { @@ -247,8 +247,11 @@ function genericLruGarbageCollectorTests( return new Document( key, SnapshotVersion.fromMicroseconds(1000), - {}, - wrapObject({ foo: 3, bar: false }) + wrapObject({ + foo: 3, + bar: false + }), + {} ); } @@ -782,8 +785,11 @@ function genericLruGarbageCollectorTests( const doc = new Document( middleDocToUpdate, SnapshotVersion.fromMicroseconds(2000), - {}, - wrapObject({ foo: 4, bar: true }) + wrapObject({ + foo: 4, + bar: true + }), + {} ); return saveDocument(txn, doc).next(() => { return updateTargetInTransaction(txn, middleTarget); diff --git a/packages/firestore/test/unit/model/document.test.ts b/packages/firestore/test/unit/model/document.test.ts index f42b74a33fb..c0cb5df279c 100644 --- a/packages/firestore/test/unit/model/document.test.ts +++ b/packages/firestore/test/unit/model/document.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,7 +16,13 @@ */ import { expect } from 'chai'; -import { expectEqual, expectNotEqual, doc, field } from '../../util/helpers'; +import { + doc, + expectEqual, + expectNotEqual, + field, + wrap +} from '../../util/helpers'; describe('Document', () => { it('can be constructed', () => { @@ -26,11 +32,13 @@ describe('Document', () => { }; const document = doc('rooms/Eros', 1, data); - const value = document.value(); - expect(value).to.deep.equal({ - desc: 'Discuss all the project related stuff', - owner: 'Jonny' - }); + const value = document.data(); + expect(value.proto).to.deep.equal( + wrap({ + desc: 'Discuss all the project related stuff', + owner: 'Jonny' + }) + ); expect(value).not.to.equal(data); expect(document.hasLocalMutations).to.equal(false); }); @@ -42,11 +50,11 @@ describe('Document', () => { }; const document = doc('rooms/Eros', 1, data, { hasLocalMutations: true }); - expect(document.field(field('desc'))!.value()).to.deep.equal( - 'Discuss all the project related stuff' + expect(document.field(field('desc'))).to.deep.equal( + wrap('Discuss all the project related stuff') ); - expect(document.field(field('owner.title'))!.value()).to.deep.equal( - 'scallywag' + expect(document.field(field('owner.title'))).to.deep.equal( + wrap('scallywag') ); expect(document.hasLocalMutations).to.equal(true); }); diff --git a/packages/firestore/test/unit/model/field_value.test.ts b/packages/firestore/test/unit/model/field_value.test.ts index 84d353a0530..c5833bda15a 100644 --- a/packages/firestore/test/unit/model/field_value.test.ts +++ b/packages/firestore/test/unit/model/field_value.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,85 +15,71 @@ * limitations under the License. */ +import * as api from '../../../src/protos/firestore_proto_api'; + import { expect } from 'chai'; -import { GeoPoint } from '../../../src/api/geo_point'; -import { Timestamp } from '../../../src/api/timestamp'; -import * as fieldValue from '../../../src/model/field_value'; -import { primitiveComparator } from '../../../src/util/misc'; -import { - blob, - dbId, - expectCorrectComparisonGroups, - expectEqualitySets, - field, - key, - mask, - ref, - wrap, - wrapObject -} from '../../util/helpers'; +import { ObjectValue, TypeOrder } from '../../../src/model/field_value'; +import { typeOrder } from '../../../src/model/values'; +import { wrap, wrapObject, field, mask } from '../../util/helpers'; describe('FieldValue', () => { - const date1 = new Date(2016, 4, 2, 1, 5); - const date2 = new Date(2016, 5, 20, 10, 20, 30); - it('can extract fields', () => { const objValue = wrapObject({ foo: { a: 1, b: true, c: 'string' } }); - expect(objValue).to.be.an.instanceof(fieldValue.ObjectValue); - - expect(objValue.field(field('foo'))).to.be.an.instanceof( - fieldValue.ObjectValue + expect(typeOrder(objValue.field(field('foo'))!)).to.equal( + TypeOrder.ObjectValue ); - expect(objValue.field(field('foo.a'))).to.be.an.instanceof( - fieldValue.IntegerValue + expect(typeOrder(objValue.field(field('foo.a'))!)).to.equal( + TypeOrder.NumberValue ); - expect(objValue.field(field('foo.b'))).to.be.an.instanceof( - fieldValue.BooleanValue + expect(typeOrder(objValue.field(field('foo.b'))!)).to.equal( + TypeOrder.BooleanValue ); - expect(objValue.field(field('foo.c'))).to.be.an.instanceof( - fieldValue.StringValue + expect(typeOrder(objValue.field(field('foo.c'))!)).to.equal( + TypeOrder.StringValue ); expect(objValue.field(field('foo.a.b'))).to.be.null; expect(objValue.field(field('bar'))).to.be.null; expect(objValue.field(field('bar.a'))).to.be.null; - expect(objValue.field(field('foo'))!.value()).to.deep.equal({ - a: 1, - b: true, - c: 'string' - }); - expect(objValue.field(field('foo.a'))!.value()).to.equal(1); - expect(objValue.field(field('foo.b'))!.value()).to.equal(true); - expect(objValue.field(field('foo.c'))!.value()).to.equal('string'); + expect(objValue.field(field('foo'))!).to.deep.equal( + wrap({ + a: 1, + b: true, + c: 'string' + }) + ); + 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')); }); it('can overwrite existing fields', () => { const objValue = wrapObject({ foo: 'foo-value' }); const objValue2 = setField(objValue, 'foo', wrap('new-foo-value')); - expect(objValue.value()).to.deep.equal({ + assertObjectEquals(objValue, { foo: 'foo-value' }); // unmodified original - expect(objValue2.value()).to.deep.equal({ foo: 'new-foo-value' }); + assertObjectEquals(objValue2, { foo: 'new-foo-value' }); }); it('can add new fields', () => { const objValue = wrapObject({ foo: 'foo-value' }); const objValue2 = setField(objValue, 'bar', wrap('bar-value')); - expect(objValue.value()).to.deep.equal({ + assertObjectEquals(objValue, { foo: 'foo-value' }); // unmodified original - expect(objValue2.value()).to.deep.equal({ + assertObjectEquals(objValue2, { foo: 'foo-value', bar: 'bar-value' }); }); it('can add multiple new fields', () => { - let objValue = fieldValue.ObjectValue.EMPTY; + let objValue = ObjectValue.EMPTY; objValue = objValue .toBuilder() .set(field('a'), wrap('a')) @@ -104,17 +90,17 @@ describe('FieldValue', () => { .set(field('c'), wrap('c')) .build(); - expect(objValue.value()).to.deep.equal({ a: 'a', b: 'b', c: 'c' }); + assertObjectEquals(objValue, { a: 'a', b: 'b', c: 'c' }); }); it('can implicitly create objects', () => { const objValue = wrapObject({ foo: 'foo-value' }); const objValue2 = setField(objValue, 'a.b', wrap('b-value')); - expect(objValue.value()).to.deep.equal({ + assertObjectEquals(objValue, { foo: 'foo-value' }); // unmodified original - expect(objValue2.value()).to.deep.equal({ + assertObjectEquals(objValue2, { foo: 'foo-value', a: { b: 'b-value' } }); @@ -124,20 +110,20 @@ describe('FieldValue', () => { const objValue = wrapObject({ foo: 'foo-value' }); const objValue2 = setField(objValue, 'foo.bar', wrap('bar-value')); - expect(objValue.value()).to.deep.equal({ + assertObjectEquals(objValue, { foo: 'foo-value' }); // unmodified original - expect(objValue2.value()).to.deep.equal({ foo: { bar: 'bar-value' } }); + assertObjectEquals(objValue2, { foo: { bar: 'bar-value' } }); }); it('can add to nested objects', () => { const objValue = wrapObject({ foo: { bar: 'bar-value' } }); const objValue2 = setField(objValue, 'foo.baz', wrap('baz-value')); - expect(objValue.value()).to.deep.equal({ + assertObjectEquals(objValue, { foo: { bar: 'bar-value' } }); // unmodified original - expect(objValue2.value()).to.deep.equal({ + assertObjectEquals(objValue2, { foo: { bar: 'bar-value', baz: 'baz-value' } }); }); @@ -146,11 +132,11 @@ describe('FieldValue', () => { const objValue = wrapObject({ foo: 'foo-value', bar: 'bar-value' }); const objValue2 = deleteField(objValue, 'foo'); - expect(objValue.value()).to.deep.equal({ + assertObjectEquals(objValue, { foo: 'foo-value', bar: 'bar-value' }); // unmodified original - expect(objValue2.value()).to.deep.equal({ bar: 'bar-value' }); + assertObjectEquals(objValue2, { bar: 'bar-value' }); }); it('can delete nested keys', () => { @@ -159,10 +145,10 @@ describe('FieldValue', () => { }); const objValue2 = deleteField(objValue, 'foo.bar'); - expect(objValue.value()).to.deep.equal({ + assertObjectEquals(objValue, { foo: { bar: 'bar-value', baz: 'baz-value' } }); // unmodified original - expect(objValue2.value()).to.deep.equal({ foo: { baz: 'baz-value' } }); + assertObjectEquals(objValue2, { foo: { baz: 'baz-value' } }); }); it('can delete added keys', () => { @@ -174,17 +160,17 @@ describe('FieldValue', () => { .delete(field('a')) .build(); - expect(objValue.value()).to.deep.equal({}); + assertObjectEquals(objValue, {}); }); it('can delete, resulting in empty object', () => { const objValue = wrapObject({ foo: { bar: 'bar-value' } }); const objValue2 = deleteField(objValue, 'foo.bar'); - expect(objValue.value()).to.deep.equal({ + assertObjectEquals(objValue, { foo: { bar: 'bar-value' } }); // unmodified original - expect(objValue2.value()).to.deep.equal({ foo: {} }); + assertObjectEquals(objValue2, { foo: {} }); }); it('will not delete nested keys on primitive values', () => { @@ -194,10 +180,10 @@ describe('FieldValue', () => { const objValue2 = deleteField(objValue, 'foo.baz'); const objValue3 = deleteField(objValue, 'foo.bar.baz'); const objValue4 = deleteField(objValue, 'a.b'); - expect(objValue.value()).to.deep.equal(expected); - expect(objValue2.value()).to.deep.equal(expected); - expect(objValue3.value()).to.deep.equal(expected); - expect(objValue4.value()).to.deep.equal(expected); + assertObjectEquals(objValue, expected); + assertObjectEquals(objValue2, expected); + assertObjectEquals(objValue3, expected); + assertObjectEquals(objValue4, expected); }); it('can delete multiple fields', () => { @@ -213,7 +199,7 @@ describe('FieldValue', () => { .delete(field('c')) .build(); - expect(objValue.value()).to.deep.equal({}); + assertObjectEquals(objValue, {}); }); it('provides field mask', () => { @@ -234,259 +220,11 @@ describe('FieldValue', () => { expect(actualMask.isEqual(expectedMask)).to.be.true; }); - it('compares values for equality', () => { - // Each subarray compares equal to each other and false to every other value - const values = [ - [wrap(true), fieldValue.BooleanValue.TRUE], - [wrap(false), fieldValue.BooleanValue.FALSE], - [wrap(null), fieldValue.NullValue.INSTANCE], - [wrap(0 / 0), wrap(Number.NaN), fieldValue.DoubleValue.NAN], - // -0.0 and 0.0 order the same but are not considered equal. - [wrap(-0.0)], - [wrap(0.0)], - [wrap(1), new fieldValue.IntegerValue(1)], - // Doubles and Integers order the same but are not considered equal. - [new fieldValue.DoubleValue(1)], - [wrap(1.1), new fieldValue.DoubleValue(1.1)], - [wrap(blob(0, 1, 2)), new fieldValue.BlobValue(blob(0, 1, 2))], - [new fieldValue.BlobValue(blob(0, 1))], - [wrap('string'), new fieldValue.StringValue('string')], - [new fieldValue.StringValue('strin')], - // latin small letter e + combining acute accent - [new fieldValue.StringValue('e\u0301b')], - // latin small letter e with acute accent - [new fieldValue.StringValue('\u00e9a')], - [wrap(date1), new fieldValue.TimestampValue(Timestamp.fromDate(date1))], - [new fieldValue.TimestampValue(Timestamp.fromDate(date2))], - [ - // NOTE: ServerTimestampValues can't be parsed via wrap(). - new fieldValue.ServerTimestampValue(Timestamp.fromDate(date1), null), - new fieldValue.ServerTimestampValue(Timestamp.fromDate(date1), null) - ], - [new fieldValue.ServerTimestampValue(Timestamp.fromDate(date2), null)], - [ - wrap(new GeoPoint(0, 1)), - new fieldValue.GeoPointValue(new GeoPoint(0, 1)) - ], - [new fieldValue.GeoPointValue(new GeoPoint(1, 0))], - [ - new fieldValue.RefValue(dbId('project'), key('coll/doc1')), - wrap(ref('project', 'coll/doc1')) - ], - [new fieldValue.RefValue(dbId('project'), key('coll/doc2'))], - [wrap(['foo', 'bar']), wrap(['foo', 'bar'])], - [wrap(['foo', 'bar', 'baz'])], - [wrap(['foo'])], - [wrap({ bar: 1, foo: 2 }), wrap({ foo: 2, bar: 1 })], - [wrap({ bar: 2, foo: 1 })], - [wrap({ bar: 1, foo: 1 })], - [wrap({ foo: 1 })] - ]; - expectEqualitySets(values, (v1, v2) => v1.isEqual(v2)); - }); - - it('orders types correctly', () => { - const groups = [ - // null first - [wrap(null)], - - // booleans - [wrap(false)], - [wrap(true)], - - // numbers - [wrap(NaN)], - [wrap(-Infinity)], - [wrap(-Number.MAX_VALUE)], - [wrap(Number.MIN_SAFE_INTEGER - 1)], - [wrap(Number.MIN_SAFE_INTEGER)], - [wrap(-1.1)], - // Integers and Doubles order the same. - [new fieldValue.IntegerValue(-1), new fieldValue.DoubleValue(-1)], - [wrap(-Number.MIN_VALUE)], - // zeros all compare the same. - [ - new fieldValue.IntegerValue(0), - new fieldValue.DoubleValue(0), - new fieldValue.DoubleValue(-0) - ], - [wrap(Number.MIN_VALUE)], - [new fieldValue.IntegerValue(1), new fieldValue.DoubleValue(1)], - [wrap(1.1)], - [wrap(Number.MAX_SAFE_INTEGER)], - [wrap(Number.MAX_SAFE_INTEGER + 1)], - [wrap(Infinity)], - - // timestamps - [wrap(date1)], - [wrap(date2)], - - // server timestamps come after all concrete timestamps. - [new fieldValue.ServerTimestampValue(Timestamp.fromDate(date1), null)], - [new fieldValue.ServerTimestampValue(Timestamp.fromDate(date2), null)], - - // strings - [wrap('')], - [wrap('\u0000\ud7ff\ue000\uffff')], - [wrap('(╯°□°)╯︵ ┻━┻')], - [wrap('a')], - [wrap('abc def')], - // latin small letter e + combining acute accent + latin small letter b - [wrap('e\u0301b')], - [wrap('æ')], - // latin small letter e with acute accent + latin small letter a - [wrap('\u00e9a')], - - // blobs - [wrap(blob())], - [wrap(blob(0))], - [wrap(blob(0, 1, 2, 3, 4))], - [wrap(blob(0, 1, 2, 4, 3))], - [wrap(blob(255))], - - // reference values - [new fieldValue.RefValue(dbId('p1', 'd1'), key('c1/doc1'))], - [new fieldValue.RefValue(dbId('p1', 'd1'), key('c1/doc2'))], - [new fieldValue.RefValue(dbId('p1', 'd1'), key('c10/doc1'))], - [new fieldValue.RefValue(dbId('p1', 'd1'), key('c2/doc1'))], - [new fieldValue.RefValue(dbId('p1', 'd2'), key('c1/doc1'))], - [new fieldValue.RefValue(dbId('p2', 'd1'), key('c1/doc1'))], - - // geo points - [wrap(new GeoPoint(-90, -180))], - [wrap(new GeoPoint(-90, 0))], - [wrap(new GeoPoint(-90, 180))], - [wrap(new GeoPoint(0, -180))], - [wrap(new GeoPoint(0, 0))], - [wrap(new GeoPoint(0, 180))], - [wrap(new GeoPoint(1, -180))], - [wrap(new GeoPoint(1, 0))], - [wrap(new GeoPoint(1, 180))], - [wrap(new GeoPoint(90, -180))], - [wrap(new GeoPoint(90, 0))], - [wrap(new GeoPoint(90, 180))], - - // arrays - [wrap([])], - [wrap(['bar'])], - [wrap(['foo'])], - [wrap(['foo', 1])], - [wrap(['foo', 2])], - [wrap(['foo', '0'])], - - // objects - [wrap({ bar: 0 })], - [wrap({ bar: 0, foo: 1 })], - [wrap({ foo: 1 })], - [wrap({ foo: 2 })], - [wrap({ foo: '0' })] - ]; - - expectCorrectComparisonGroups( - groups, - (left: fieldValue.FieldValue, right: fieldValue.FieldValue) => { - return left.compareTo(right); - } - ); - }); - - it('estimates size correctly for fixed sized values', () => { - // This test verifies that each member of a group takes up the same amount - // of space in memory (based on its estimated in-memory size). - const equalityGroups = [ - { expectedByteSize: 4, elements: [wrap(null), wrap(false), wrap(true)] }, - { - expectedByteSize: 4, - elements: [wrap(blob(0, 1)), wrap(blob(128, 129))] - }, - { - expectedByteSize: 8, - elements: [wrap(NaN), wrap(Infinity), wrap(1), wrap(1.1)] - }, - { - expectedByteSize: 16, - elements: [wrap(new GeoPoint(0, 0)), wrap(new GeoPoint(0, 0))] - }, - { - expectedByteSize: 16, - elements: [wrap(Timestamp.fromMillis(100)), wrap(Timestamp.now())] - }, - { - expectedByteSize: 16, - elements: [ - new fieldValue.ServerTimestampValue(Timestamp.fromMillis(100), null), - new fieldValue.ServerTimestampValue(Timestamp.now(), null) - ] - }, - { - expectedByteSize: 20, - elements: [ - new fieldValue.ServerTimestampValue( - Timestamp.fromMillis(100), - wrap(true) - ), - new fieldValue.ServerTimestampValue(Timestamp.now(), wrap(false)) - ] - }, - { - expectedByteSize: 11, - elements: [ - new fieldValue.RefValue(dbId('p1', 'd1'), key('c1/doc1')), - new fieldValue.RefValue(dbId('p2', 'd2'), key('c2/doc2')) - ] - }, - { expectedByteSize: 6, elements: [wrap('foo'), wrap('bar')] }, - { expectedByteSize: 4, elements: [wrap(['a', 'b']), wrap(['c', 'd'])] }, - { - expectedByteSize: 6, - elements: [wrap({ a: 'a', b: 'b' }), wrap({ c: 'c', d: 'd' })] - } - ]; - - for (const group of equalityGroups) { - for (const element of group.elements) { - expect(element.approximateByteSize()).to.equal(group.expectedByteSize); - } - } - }); - - it('estimates size correctly for relatively sized values', () => { - // This test verifies for each group that the estimated size increases - // as the size of the underlying data grows. - const relativeGroups = [ - [wrap(blob(0)), wrap(blob(0, 1))], - [ - new fieldValue.ServerTimestampValue(Timestamp.fromMillis(100), null), - new fieldValue.ServerTimestampValue(Timestamp.now(), wrap(null)) - ], - [ - new fieldValue.RefValue(dbId('p1', 'd1'), key('c1/doc1')), - new fieldValue.RefValue(dbId('p1', 'd1'), key('c1/doc1/c2/doc2')) - ], - [wrap('foo'), wrap('foobar')], - [wrap(['a', 'b']), wrap(['a', 'bc'])], - [wrap(['a', 'b']), wrap(['a', 'b', 'c'])], - [wrap({ a: 'a', b: 'b' }), wrap({ a: 'a', b: 'bc' })], - [wrap({ a: 'a', b: 'b' }), wrap({ a: 'a', bc: 'b' })], - [wrap({ a: 'a', b: 'b' }), wrap({ a: 'a', b: 'b', c: 'c' })] - ]; - - for (const group of relativeGroups) { - const expectedOrder = group; - const actualOrder = group - .slice() - .sort((l, r) => - primitiveComparator(l.approximateByteSize(), r.approximateByteSize()) - ); - expect(expectedOrder).to.deep.equal(actualOrder); - } - }); - function setField( - objectValue: fieldValue.ObjectValue, + objectValue: ObjectValue, fieldPath: string, - value: fieldValue.FieldValue - ): fieldValue.ObjectValue { + value: api.Value + ): ObjectValue { return objectValue .toBuilder() .set(field(fieldPath), value) @@ -494,12 +232,19 @@ describe('FieldValue', () => { } function deleteField( - objectValue: fieldValue.ObjectValue, + objectValue: ObjectValue, fieldPath: string - ): fieldValue.ObjectValue { + ): ObjectValue { return objectValue .toBuilder() .delete(field(fieldPath)) .build(); } + + function assertObjectEquals( + objValue: ObjectValue, + data: { [k: string]: unknown } + ): void { + expect(objValue.isEqual(wrapObject(data))); + } }); diff --git a/packages/firestore/test/unit/model/mutation.test.ts b/packages/firestore/test/unit/model/mutation.test.ts index a5d4748ca39..13834606de5 100644 --- a/packages/firestore/test/unit/model/mutation.test.ts +++ b/packages/firestore/test/unit/model/mutation.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,11 +19,7 @@ import { expect } from 'chai'; import { PublicFieldValue as FieldValue } from '../../../src/api/field_value'; import { Timestamp } from '../../../src/api/timestamp'; import { Document, MaybeDocument } from '../../../src/model/document'; -import { - IntegerValue, - ServerTimestampValue, - TimestampValue -} from '../../../src/model/field_value'; +import { serverTimestamp } from '../../../src/model/server_timestamps'; import { Mutation, MutationResult, @@ -200,21 +196,16 @@ describe('Mutation', () => { baz: 'baz-value' }) .toBuilder() - .set(field('foo.bar'), new ServerTimestampValue(timestamp, null)) + .set(field('foo.bar'), serverTimestamp(timestamp, null)) .build(); - const expectedDoc = new Document( - key('collection/key'), - version(0), - { - hasLocalMutations: true - }, - data - ); + const expectedDoc = new Document(key('collection/key'), version(0), data, { + hasLocalMutations: true + }); expect(transformedDoc).to.deep.equal(expectedDoc); }); - // NOTE: This is more a test of UserDataConverter code than Mutation code but + // NOTE: This is more a test of UserDataReader code than Mutation code but // we don't have unit tests for it currently. We could consider removing this // test once we have integration tests. it('can create arrayUnion() transform.', () => { @@ -240,7 +231,7 @@ describe('Mutation', () => { ); }); - // NOTE: This is more a test of UserDataConverter code than Mutation code but + // NOTE: This is more a test of UserDataReader code than Mutation code but // we don't have unit tests for it currently. We could consider removing this // test once we have integration tests. it('can create arrayRemove() transform.', () => { @@ -390,7 +381,12 @@ describe('Mutation', () => { }); const mutationResult = new MutationResult(version(1), [ - new TimestampValue(timestamp) + { + timestampValue: { + seconds: timestamp.seconds, + nanos: timestamp.nanoseconds + } + } ]); const transformedDoc = transform.applyToRemoteDocument( baseDoc, @@ -501,7 +497,7 @@ describe('Mutation', () => { }); const mutationResult = new MutationResult(version(1), [ - new IntegerValue(3) + { integerValue: 3 } ]); const transformedDoc = transform.applyToRemoteDocument( baseDoc, @@ -667,7 +663,7 @@ describe('Mutation', () => { }; const transform = transformMutation('collection/key', allTransforms); - const expectedBaseValue = wrap({ + const expectedBaseValue = wrapObject({ double: 42.0, long: 42, text: 0, @@ -698,6 +694,6 @@ describe('Mutation', () => { ); expect(mutatedDoc).to.be.an.instanceof(Document); - expect((mutatedDoc as Document).field(field('sum'))!.value()).to.equal(2); + expect((mutatedDoc as Document).field(field('sum'))).to.deep.equal(wrap(2)); }); }); diff --git a/packages/firestore/test/unit/model/object_value_builder.test.ts b/packages/firestore/test/unit/model/object_value_builder.test.ts new file mode 100644 index 00000000000..7b38ed873ad --- /dev/null +++ b/packages/firestore/test/unit/model/object_value_builder.test.ts @@ -0,0 +1,214 @@ +/** + * @license + * Copyright 2020 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 { expect } from 'chai'; + +import { field, wrap, wrapObject } from '../../util/helpers'; +import { ObjectValue } from '../../../src/model/field_value'; + +describe('ObjectValueBuilder', () => { + it('supports empty builders', () => { + const builder = ObjectValue.newBuilder(); + const object = builder.build(); + expect(object.isEqual(ObjectValue.EMPTY)).to.be.true; + }); + + it('sets single field', () => { + const builder = ObjectValue.newBuilder(); + builder.set(field('foo'), wrap('foo')); + const object = builder.build(); + expect(object.isEqual(wrapObject({ 'foo': 'foo' }))).to.be.true; + }); + + it('sets empty object', () => { + const builder = ObjectValue.newBuilder(); + builder.set(field('foo'), wrap({})); + const object = builder.build(); + expect(object.isEqual(wrapObject({ 'foo': {} }))).to.be.true; + }); + + it('sets multiple fields', () => { + const builder = ObjectValue.newBuilder(); + builder.set(field('foo'), wrap('foo')); + builder.set(field('bar'), wrap('bar')); + const object = builder.build(); + expect(object.isEqual(wrapObject({ 'foo': 'foo', 'bar': 'bar' }))).to.be + .true; + }); + + it('sets nested fields', () => { + const builder = ObjectValue.newBuilder(); + builder.set(field('a.b'), wrap('foo')); + builder.set(field('c.d.e'), wrap('bar')); + const object = builder.build(); + expect( + object.isEqual( + wrapObject({ 'a': { 'b': 'foo' }, 'c': { 'd': { 'e': 'bar' } } }) + ) + ).to.be.true; + }); + + it('sets two fields in nested object', () => { + const builder = ObjectValue.newBuilder(); + builder.set(field('a.b'), wrap('foo')); + builder.set(field('a.c'), wrap('bar')); + const object = builder.build(); + expect(object.isEqual(wrapObject({ 'a': { 'b': 'foo', 'c': 'bar' } }))).to + .be.true; + }); + + it('sets field in nested object', () => { + const builder = ObjectValue.newBuilder(); + builder.set(field('a'), wrap({ b: 'foo' })); + builder.set(field('a.c'), wrap('bar')); + const object = builder.build(); + expect(object.isEqual(wrapObject({ 'a': { 'b': 'foo', 'c': 'bar' } }))).to + .be.true; + }); + + it('sets deeply nested field in nested object', () => { + const builder = ObjectValue.newBuilder(); + builder.set(field('a.b.c.d.e.f'), wrap('foo')); + const object = builder.build(); + expect( + object.isEqual( + wrapObject({ + 'a': { 'b': { 'c': { 'd': { 'e': { 'f': 'foo' } } } } } + }) + ) + ).to.be.true; + }); + + it('sets nested field multiple times', () => { + const builder = ObjectValue.newBuilder(); + builder.set(field('a.c'), wrap('foo')); + builder.set(field('a'), wrap({ b: 'foo' })); + const object = builder.build(); + expect(object.isEqual(wrapObject({ 'a': { 'b': 'foo' } }))).to.be.true; + }); + + it('sets and deletes field', () => { + const builder = ObjectValue.newBuilder(); + builder.set(field('foo'), wrap('foo')); + builder.delete(field('foo')); + const object = builder.build(); + expect(object.isEqual(ObjectValue.EMPTY)).to.be.true; + }); + + it('sets and deletes nested field', () => { + const builder = ObjectValue.newBuilder(); + builder.set(field('a.b.c'), wrap('foo')); + builder.set(field('a.b.d'), wrap('foo')); + builder.set(field('f.g'), wrap('foo')); + builder.set(field('h'), wrap('foo')); + builder.delete(field('a.b.c')); + builder.delete(field('h')); + const object = builder.build(); + expect( + object.isEqual( + wrapObject({ 'a': { 'b': { 'd': 'foo' } }, 'f': { g: 'foo' } }) + ) + ).to.be.true; + }); + + it('sets single field in existing object', () => { + const builder = wrapObject({ a: 'foo' }).toBuilder(); + builder.set(field('b'), wrap('foo')); + const object = builder.build(); + expect(object.isEqual(wrapObject({ a: 'foo', b: 'foo' }))).to.be.true; + }); + + it('overwrites field', () => { + const builder = wrapObject({ a: 'foo' }).toBuilder(); + builder.set(field('a'), wrap('bar')); + const object = builder.build(); + expect(object.isEqual(wrapObject({ a: 'bar' }))).to.be.true; + }); + + it('overwrites nested field', () => { + const builder = wrapObject({ + a: { b: 'foo', c: { 'd': 'foo' } } + }).toBuilder(); + builder.set(field('a.b'), wrap('bar')); + builder.set(field('a.c.d'), wrap('bar')); + const object = builder.build(); + expect(object.isEqual(wrapObject({ a: { b: 'bar', c: { 'd': 'bar' } } }))) + .to.be.true; + }); + + it('overwrites deeply nested field', () => { + const builder = wrapObject({ a: { b: 'foo' } }).toBuilder(); + builder.set(field('a.b.c'), wrap('bar')); + const object = builder.build(); + expect(object.isEqual(wrapObject({ a: { b: { c: 'bar' } } }))).to.be.true; + }); + + it('merges existing object', () => { + const builder = wrapObject({ a: { b: 'foo' } }).toBuilder(); + builder.set(field('a.c'), wrap('foo')); + const object = builder.build(); + expect(object.isEqual(wrapObject({ a: { b: 'foo', c: 'foo' } }))).to.be + .true; + }); + + it('overwrites nested object', () => { + const builder = wrapObject({ + a: { b: { c: 'foo', d: 'foo' } } + }).toBuilder(); + builder.set(field('a.b'), wrap('bar')); + const object = builder.build(); + expect(object.isEqual(wrapObject({ a: { b: 'bar' } }))).to.be.true; + }); + + it('replaces nested object', () => { + const singleValueObject = wrap({ c: 'bar' }); + const builder = wrapObject({ a: { b: 'foo' } }).toBuilder(); + builder.set(field('a'), singleValueObject); + const object = builder.build(); + expect(object.isEqual(wrapObject({ a: { c: 'bar' } }))).to.be.true; + }); + + it('deletes single field', () => { + const builder = wrapObject({ a: 'foo', b: 'foo' }).toBuilder(); + builder.delete(field('a')); + const object = builder.build(); + expect(object.isEqual(wrapObject({ b: 'foo' }))).to.be.true; + }); + + it('deletes nested object', () => { + const builder = wrapObject({ + a: { b: { c: 'foo', d: 'foo' }, f: 'foo' } + }).toBuilder(); + builder.delete(field('a.b')); + const object = builder.build(); + expect(object.isEqual(wrapObject({ a: { f: 'foo' } }))).to.be.true; + }); + + it('deletes non-existing field', () => { + const builder = wrapObject({ a: 'foo' }).toBuilder(); + builder.delete(field('b')); + const object = builder.build(); + expect(object.isEqual(wrapObject({ a: 'foo' }))).to.be.true; + }); + + it('deletes non-existing nested field', () => { + const builder = wrapObject({ a: { b: 'foo' } }).toBuilder(); + builder.delete(field('a.b.c')); + const object = builder.build(); + expect(object.isEqual(wrapObject({ a: { b: 'foo' } }))).to.be.true; + }); +}); diff --git a/packages/firestore/test/unit/model/values.test.ts b/packages/firestore/test/unit/model/values.test.ts new file mode 100644 index 00000000000..67be9a08123 --- /dev/null +++ b/packages/firestore/test/unit/model/values.test.ts @@ -0,0 +1,409 @@ +/** + * @license + * Copyright 2020 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 * as api from '../../../src/protos/firestore_proto_api'; + +import { expect } from 'chai'; + +import { Timestamp } from '../../../src/api/timestamp'; +import { GeoPoint } from '../../../src/api/geo_point'; +import { + canonicalId, + valueCompare, + valueEquals, + estimateByteSize, + refValue +} from '../../../src/model/values'; +import { serverTimestamp } from '../../../src/model/server_timestamps'; +import { primitiveComparator } from '../../../src/util/misc'; +import { + blob, + dbId, + expectCorrectComparisonGroups, + expectEqualitySets, + key, + ref, + wrap +} from '../../util/helpers'; + +describe('Values', () => { + const date1 = new Date(2016, 4, 2, 1, 5); + const date2 = new Date(2016, 5, 20, 10, 20, 30); + + it('compares values for equality', () => { + // Each subarray compares equal to each other and false to every other value + const values: api.Value[][] = [ + [wrap(true), wrap(true)], + [wrap(false), wrap(false)], + [wrap(null), wrap(null)], + [wrap(0 / 0), wrap(Number.NaN), wrap(NaN)], + // -0.0 and 0.0 order the same but are not considered equal. + [wrap(-0.0)], + [wrap(0.0)], + [wrap(1), { integerValue: 1 }], + // Doubles and Integers order the same but are not considered equal. + [{ doubleValue: 1.0 }], + [wrap(1.1), wrap(1.1)], + [wrap(blob(0, 1, 2)), wrap(blob(0, 1, 2))], + [wrap(blob(0, 1))], + [wrap('string'), wrap('string')], + [wrap('strin')], + // latin small letter e + combining acute accent + [wrap('e\u0301b')], + // latin small letter e with acute accent + [wrap('\u00e9a')], + [wrap(date1), wrap(Timestamp.fromDate(date1))], + [wrap(date2)], + [ + // NOTE: ServerTimestampValues can't be parsed via wrap(). + serverTimestamp(Timestamp.fromDate(date1), null), + serverTimestamp(Timestamp.fromDate(date1), null) + ], + [serverTimestamp(Timestamp.fromDate(date2), null)], + [wrap(new GeoPoint(0, 1)), wrap(new GeoPoint(0, 1))], + [wrap(new GeoPoint(1, 0))], + [wrap(ref('project', 'coll/doc1')), wrap(ref('project', 'coll/doc1'))], + [wrap(ref('project', 'coll/doc2'))], + [wrap(['foo', 'bar']), wrap(['foo', 'bar'])], + [wrap(['foo', 'bar', 'baz'])], + [wrap(['foo'])], + [wrap({ bar: 1, foo: 2 }), wrap({ foo: 2, bar: 1 })], + [wrap({ bar: 2, foo: 1 })], + [wrap({ bar: 1, foo: 1 })], + [wrap({ foo: 1 })] + ]; + expectEqualitySets(values, (v1, v2) => valueEquals(v1, v2)); + }); + + it('normalizes values for equality', () => { + // Each subarray compares equal to each other and false to every other value + const values: api.Value[][] = [ + [{ integerValue: '1' }, { integerValue: 1 }], + [{ doubleValue: '1.0' }, { doubleValue: 1.0 }], + [ + { timestampValue: '2007-04-05T14:30:01Z' }, + { timestampValue: '2007-04-05T14:30:01.000Z' }, + { timestampValue: '2007-04-05T14:30:01.000000Z' }, + { + timestampValue: '2007-04-05T14:30:01.000000000Z' + }, + { timestampValue: { seconds: 1175783401 } }, + { timestampValue: { seconds: '1175783401' } }, + { + timestampValue: { seconds: 1175783401, nanos: 0 } + } + ], + [ + { timestampValue: '2007-04-05T14:30:01.100Z' }, + { + timestampValue: { seconds: 1175783401, nanos: 100000000 } + } + ], + [{ bytesValue: new Uint8Array([0, 1, 2]) }, { bytesValue: 'AAEC' }] + ]; + expectEqualitySets(values, (v1, v2) => valueEquals(v1, v2)); + }); + + it('orders types correctly', () => { + const groups = [ + // null first + [wrap(null)], + + // booleans + [wrap(false)], + [wrap(true)], + + // numbers + [wrap(NaN)], + [wrap(-Infinity)], + [wrap(-Number.MAX_VALUE)], + [wrap(Number.MIN_SAFE_INTEGER - 1)], + [wrap(Number.MIN_SAFE_INTEGER)], + [wrap(-1.1)], + // Integers and Doubles order the same. + [{ integerValue: -1 }, { doubleValue: -1 }], + [wrap(-Number.MIN_VALUE)], + // zeros all compare the same. + [{ integerValue: 0 }, { doubleValue: 0 }, { doubleValue: -0 }], + [wrap(Number.MIN_VALUE)], + [{ integerValue: 1 }, { doubleValue: 1 }], + [wrap(1.1)], + [wrap(Number.MAX_SAFE_INTEGER)], + [wrap(Number.MAX_SAFE_INTEGER + 1)], + [wrap(Infinity)], + + // timestamps + [wrap(date1)], + [wrap(date2)], + [ + { timestampValue: '2020-04-05T14:30:01Z' }, + { timestampValue: '2020-04-05T14:30:01.000Z' }, + { timestampValue: '2020-04-05T14:30:01.000000Z' }, + { timestampValue: '2020-04-05T14:30:01.000000000Z' } + ], + + // server timestamps come after all concrete timestamps. + [serverTimestamp(Timestamp.fromDate(date1), null)], + [serverTimestamp(Timestamp.fromDate(date2), null)], + + // strings + [wrap('')], + [wrap('\u0000\ud7ff\ue000\uffff')], + [wrap('(╯°□°)╯︵ ┻━┻')], + [wrap('a')], + [wrap('abc def')], + // latin small letter e + combining acute accent + latin small letter b + [wrap('e\u0301b')], + [wrap('æ')], + // latin small letter e with acute accent + latin small letter a + [wrap('\u00e9a')], + + // blobs + [wrap(blob())], + [wrap(blob(0))], + [wrap(blob(0, 1, 2, 3, 4))], + [wrap(blob(0, 1, 2, 4, 3))], + [wrap(blob(255))], + + // reference values + [refValue(dbId('p1', 'd1'), key('c1/doc1'))], + [refValue(dbId('p1', 'd1'), key('c1/doc2'))], + [refValue(dbId('p1', 'd1'), key('c10/doc1'))], + [refValue(dbId('p1', 'd1'), key('c2/doc1'))], + [refValue(dbId('p1', 'd2'), key('c1/doc1'))], + [refValue(dbId('p2', 'd1'), key('c1/doc1'))], + + // geo points + [wrap(new GeoPoint(-90, -180))], + [wrap(new GeoPoint(-90, 0))], + [wrap(new GeoPoint(-90, 180))], + [wrap(new GeoPoint(0, -180))], + [wrap(new GeoPoint(0, 0))], + [wrap(new GeoPoint(0, 180))], + [wrap(new GeoPoint(1, -180))], + [wrap(new GeoPoint(1, 0))], + [wrap(new GeoPoint(1, 180))], + [wrap(new GeoPoint(90, -180))], + [wrap(new GeoPoint(90, 0))], + [wrap(new GeoPoint(90, 180))], + + // arrays + [wrap([])], + [wrap(['bar'])], + [wrap(['foo'])], + [wrap(['foo', 1])], + [wrap(['foo', 2])], + [wrap(['foo', '0'])], + + // objects + [wrap({ bar: 0 })], + [wrap({ bar: 0, foo: 1 })], + [wrap({ foo: 1 })], + [wrap({ foo: 2 })], + [wrap({ foo: '0' })] + ]; + + expectCorrectComparisonGroups( + groups, + (left: api.Value, right: api.Value) => { + return valueCompare(left, right); + } + ); + }); + + it('normalizes values for comparison', () => { + const groups = [ + [{ integerValue: '1' }, { integerValue: 1 }], + [{ doubleValue: '2' }, { doubleValue: 2 }], + [ + { timestampValue: '2007-04-05T14:30:01Z' }, + { timestampValue: { seconds: 1175783401 } } + ], + [ + { timestampValue: '2007-04-05T14:30:01.999Z' }, + { + timestampValue: { seconds: 1175783401, nanos: 999000000 } + } + ], + [ + { timestampValue: '2007-04-05T14:30:02Z' }, + { timestampValue: { seconds: 1175783402 } } + ], + [ + { timestampValue: '2007-04-05T14:30:02.100Z' }, + { + timestampValue: { seconds: 1175783402, nanos: 100000000 } + } + ], + [ + { timestampValue: '2007-04-05T14:30:02.100001Z' }, + { + timestampValue: { seconds: 1175783402, nanos: 100001000 } + } + ], + [{ bytesValue: new Uint8Array([0, 1, 2]) }, { bytesValue: 'AAEC' }], + [{ bytesValue: new Uint8Array([0, 1, 3]) }, { bytesValue: 'AAED' }] + ]; + + expectCorrectComparisonGroups( + groups, + (left: api.Value, right: api.Value) => { + return valueCompare(left, right); + } + ); + }); + + it('estimates size correctly for fixed sized values', () => { + // This test verifies that each member of a group takes up the same amount + // of space in memory (based on its estimated in-memory size). + const equalityGroups = [ + { expectedByteSize: 4, elements: [wrap(null), wrap(false), wrap(true)] }, + { + expectedByteSize: 4, + elements: [wrap(blob(0, 1)), wrap(blob(128, 129))] + }, + { + expectedByteSize: 8, + elements: [wrap(NaN), wrap(Infinity), wrap(1), wrap(1.1)] + }, + { + expectedByteSize: 16, + elements: [wrap(new GeoPoint(0, 0)), wrap(new GeoPoint(0, 0))] + }, + { + expectedByteSize: 16, + elements: [wrap(Timestamp.fromMillis(100)), wrap(Timestamp.now())] + }, + { + expectedByteSize: 16, + elements: [ + serverTimestamp(Timestamp.fromMillis(100), null), + serverTimestamp(Timestamp.now(), null) + ] + }, + { + expectedByteSize: 20, + elements: [ + serverTimestamp(Timestamp.fromMillis(100), wrap(true)), + serverTimestamp(Timestamp.now(), wrap(false)) + ] + }, + { + expectedByteSize: 42, + elements: [ + refValue(dbId('p1', 'd1'), key('c1/doc1')), + refValue(dbId('p2', 'd2'), key('c2/doc2')) + ] + }, + { expectedByteSize: 6, elements: [wrap('foo'), wrap('bar')] }, + { expectedByteSize: 4, elements: [wrap(['a', 'b']), wrap(['c', 'd'])] }, + { + expectedByteSize: 6, + elements: [wrap({ a: 'a', b: 'b' }), wrap({ c: 'c', d: 'd' })] + } + ]; + + for (const group of equalityGroups) { + for (const element of group.elements) { + expect(estimateByteSize(element)).to.equal(group.expectedByteSize); + } + } + }); + + it('estimates size correctly for relatively sized values', () => { + // This test verifies for each group that the estimated size increases + // as the size of the underlying data grows. + const relativeGroups: api.Value[][] = [ + [wrap(blob(0)), wrap(blob(0, 1))], + [ + serverTimestamp(Timestamp.fromMillis(100), null), + serverTimestamp(Timestamp.now(), wrap(null)) + ], + [ + refValue(dbId('p1', 'd1'), key('c1/doc1')), + refValue(dbId('p1', 'd1'), key('c1/doc1/c2/doc2')) + ], + [wrap('foo'), wrap('foobar')], + [wrap(['a', 'b']), wrap(['a', 'bc'])], + [wrap(['a', 'b']), wrap(['a', 'b', 'c'])], + [wrap({ a: 'a', b: 'b' }), wrap({ a: 'a', b: 'bc' })], + [wrap({ a: 'a', b: 'b' }), wrap({ a: 'a', bc: 'b' })], + [wrap({ a: 'a', b: 'b' }), wrap({ a: 'a', b: 'b', c: 'c' })] + ]; + + for (const group of relativeGroups) { + const expectedOrder = group; + const actualOrder = group + .slice() + .sort((l, r) => + primitiveComparator(estimateByteSize(l), estimateByteSize(r)) + ); + expect(expectedOrder).to.deep.equal(actualOrder); + } + }); + + it('canonicalizes values', () => { + expect(canonicalId(wrap(null))).to.equal('null'); + expect(canonicalId(wrap(true))).to.equal('true'); + expect(canonicalId(wrap(false))).to.equal('false'); + expect(canonicalId(wrap(1))).to.equal('1'); + expect(canonicalId(wrap(1.1))).to.equal('1.1'); + expect(canonicalId(wrap(new Timestamp(30, 1000)))).to.equal( + 'time(30,1000)' + ); + expect(canonicalId(wrap('a'))).to.equal('a'); + expect(canonicalId(wrap(blob(1, 2, 3)))).to.equal('AQID'); + expect(canonicalId(refValue(dbId('p1', 'd1'), key('c1/doc1')))).to.equal( + 'c1/doc1' + ); + expect(canonicalId(wrap(new GeoPoint(30, 60)))).to.equal('geo(30,60)'); + expect(canonicalId(wrap([1, 2, 3]))).to.equal('[1,2,3]'); + expect( + canonicalId( + wrap({ + 'a': 1, + 'b': 2, + 'c': '3' + }) + ) + ).to.equal('{a:1,b:2,c:3}'); + expect( + canonicalId(wrap({ 'a': ['b', { 'c': new GeoPoint(30, 60) }] })) + ).to.equal('{a:[b,{c:geo(30,60)}]}'); + }); + + it('canonical IDs ignore sort order', () => { + expect( + canonicalId( + wrap({ + 'a': 1, + 'b': 2, + 'c': '3' + }) + ) + ).to.equal('{a:1,b:2,c:3}'); + expect( + canonicalId( + wrap({ + 'c': 3, + 'b': 2, + 'a': '1' + }) + ) + ).to.equal('{a:1,b:2,c:3}'); + }); +}); diff --git a/packages/firestore/test/unit/remote/remote_event.test.ts b/packages/firestore/test/unit/remote/remote_event.test.ts index e93ae0338ae..486bdbeefdd 100644 --- a/packages/firestore/test/unit/remote/remote_event.test.ts +++ b/packages/firestore/test/unit/remote/remote_event.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,7 +20,6 @@ import { SnapshotVersion } from '../../../src/core/snapshot_version'; import { TargetId } from '../../../src/core/types'; import { TargetData, TargetPurpose } from '../../../src/local/target_data'; import { DocumentKeySet, documentKeySet } from '../../../src/model/collections'; -import { DocumentKey } from '../../../src/model/document_key'; import { ExistenceFilter } from '../../../src/remote/existence_filter'; import { RemoteEvent, TargetChange } from '../../../src/remote/remote_event'; import { @@ -40,7 +39,8 @@ import { resumeTokenForSnapshot, size, updateMapping, - version + version, + key } from '../../util/helpers'; import { ByteString } from '../../../src/util/byte_string'; @@ -582,7 +582,7 @@ describe('RemoteEvent', () => { it('synthesizes deletes', () => { const targets = limboListens(1); - const limboKey = DocumentKey.fromPathString('coll/limbo'); + const limboKey = key('coll/limbo'); const resolveLimboTarget = new WatchTargetChange( WatchTargetChangeState.Current, [1] @@ -604,7 +604,7 @@ describe('RemoteEvent', () => { it("doesn't synthesize deletes in the wrong state", () => { const targets = limboListens(1); - const limboKey = DocumentKey.fromPathString('coll/limbo'); + const limboKey = key('coll/limbo'); const wrongState = new WatchTargetChange(WatchTargetChangeState.NoChange, [ 1 ]); diff --git a/packages/firestore/test/unit/remote/serializer.test.ts b/packages/firestore/test/unit/remote/serializer.test.ts index 08d837e5bed..a32502935b2 100644 --- a/packages/firestore/test/unit/remote/serializer.test.ts +++ b/packages/firestore/test/unit/remote/serializer.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,6 +21,8 @@ import { Blob } from '../../../src/api/blob'; import { PublicFieldValue as FieldValue } from '../../../src/api/field_value'; import { GeoPoint } from '../../../src/api/geo_point'; import { Timestamp } from '../../../src/api/timestamp'; +import { DocumentKeyReference } from '../../../src/api/user_data_reader'; +import { DocumentReference } from '../../../src/api/database'; import { DatabaseId } from '../../../src/core/database_info'; import { ArrayContainsAnyFilter, @@ -36,7 +38,6 @@ import { import { SnapshotVersion } from '../../../src/core/snapshot_version'; import { Target } from '../../../src/core/target'; import { TargetData, TargetPurpose } from '../../../src/local/target_data'; -import * as fieldValue from '../../../src/model/field_value'; import { DeleteMutation, FieldMask, @@ -46,6 +47,7 @@ import { VerifyMutation } from '../../../src/model/mutation'; import { DOCUMENT_KEY_NAME, FieldPath } from '../../../src/model/path'; +import { refValue } from '../../../src/model/values'; import * as api from '../../../src/protos/firestore_proto_api'; import { JsonProtoSerializer } from '../../../src/remote/serializer'; import { @@ -71,14 +73,21 @@ import { path, ref, setMutation, + testUserDataReader, + testUserDataWriter, transformMutation, version, wrap, wrapObject } from '../../util/helpers'; + import { ByteString } from '../../../src/util/byte_string'; import { isNode } from '../../util/test_platform'; +const userDataWriter = testUserDataWriter(); +const protobufJsonReader = testUserDataReader(/* useProto3Json= */ true); +const protoJsReader = testUserDataReader(/* useProto3Json= */ false); + let verifyProtobufJsRoundTrip: (jsonValue: api.Value) => void = () => {}; if (isNode()) { @@ -92,9 +101,6 @@ if (isNode()) { describe('Serializer', () => { const partition = new DatabaseId('p', 'd'); const s = new JsonProtoSerializer(partition, { useProto3Json: false }); - const proto3JsonSerializer = new JsonProtoSerializer(partition, { - useProto3Json: true - }); /** * Wraps the given target in TargetData. This is useful because the APIs we're @@ -116,37 +122,53 @@ describe('Serializer', () => { */ function verifyFieldValueRoundTrip(opts: { /** The FieldValue to test. */ - value: fieldValue.FieldValue; + value: unknown; /** The expected one_of field to be used (e.g. 'nullValue') */ valueType: string; /** The expected JSON value for the field (e.g. 'NULL_VALUE') */ jsonValue: unknown; + /** The expected ProtoJS value. */ + protoJsValue?: unknown; /** * If true, uses the proto3Json serializer (and skips the round-trip * through protobufJs). */ useProto3Json?: boolean; }): void { - const { value, valueType, jsonValue } = opts; - const serializer = opts.useProto3Json ? proto3JsonSerializer : s; + let { value, valueType, jsonValue, protoJsValue } = opts; + protoJsValue = protoJsValue ?? jsonValue; - // Convert FieldValue to JSON and verify. - const actualJsonProto = serializer.toValue(value); + // Convert value to JSON and verify. + const actualJsonProto = protobufJsonReader.parseQueryValue( + 'verifyFieldValueRoundTrip', + value + ); expect(actualJsonProto).to.deep.equal({ [valueType]: jsonValue }); + const actualReturnFieldValue = userDataWriter.convertValue( + actualJsonProto + ); + expect(actualReturnFieldValue).to.deep.equal(value); + + // Convert value to ProtoJs and verify. + const actualProtoJsProto = protoJsReader.parseQueryValue( + 'verifyFieldValueRoundTrip', + value + ); + expect(actualProtoJsProto).to.deep.equal({ [valueType]: protoJsValue }); + const actualProtoJsReturnFieldValue = userDataWriter.convertValue( + actualProtoJsProto + ); + expect(actualProtoJsReturnFieldValue).to.deep.equal(value); // If we're using protobufJs JSON (not Proto3Json), then round-trip through protobufjs. if (!opts.useProto3Json) { - verifyProtobufJsRoundTrip(actualJsonProto); + verifyProtobufJsRoundTrip(actualProtoJsProto); } - - // Convert JSON back to FieldValue. - const actualReturnFieldValue = serializer.fromValue(actualJsonProto); - expect(actualReturnFieldValue.isEqual(value)).to.be.true; } it('converts NullValue', () => { verifyFieldValueRoundTrip({ - value: fieldValue.NullValue.INSTANCE, + value: null, valueType: 'nullValue', jsonValue: 'NULL_VALUE' }); @@ -156,7 +178,7 @@ describe('Serializer', () => { const examples = [true, false]; for (const example of examples) { verifyFieldValueRoundTrip({ - value: fieldValue.BooleanValue.of(example), + value: example, valueType: 'booleanValue', jsonValue: example }); @@ -175,7 +197,7 @@ describe('Serializer', () => { ]; for (const example of examples) { verifyFieldValueRoundTrip({ - value: new fieldValue.IntegerValue(example), + value: example, valueType: 'integerValue', jsonValue: '' + example }); @@ -185,25 +207,47 @@ describe('Serializer', () => { it('converts DoubleValue', () => { const examples = [ Number.MIN_VALUE, - -10.0, - -1.0, - 0.0, - 1.0, - 10.0, - Number.MAX_VALUE, - NaN, - Number.POSITIVE_INFINITY, - Number.NEGATIVE_INFINITY + -10.1, + -1.1, + 0.1, + 1.1, + 10.1, + Number.MAX_VALUE ]; for (const example of examples) { verifyFieldValueRoundTrip({ - value: new fieldValue.DoubleValue(example), + value: example, valueType: 'doubleValue', jsonValue: example }); } }); + it('converts NaN', () => { + verifyFieldValueRoundTrip({ + value: NaN, + valueType: 'doubleValue', + jsonValue: 'NaN', + protoJsValue: NaN + }); + }); + + it('converts Infinity', () => { + verifyFieldValueRoundTrip({ + value: Number.POSITIVE_INFINITY, + valueType: 'doubleValue', + jsonValue: 'Infinity', + protoJsValue: Number.POSITIVE_INFINITY + }); + + verifyFieldValueRoundTrip({ + value: Number.NEGATIVE_INFINITY, + valueType: 'doubleValue', + jsonValue: '-Infinity', + protoJsValue: Number.NEGATIVE_INFINITY + }); + }); + it('converts StringValue', () => { const examples = [ '', @@ -215,7 +259,7 @@ describe('Serializer', () => { ]; for (const example of examples) { verifyFieldValueRoundTrip({ - value: new fieldValue.StringValue(example), + value: example, valueType: 'stringValue', jsonValue: example }); @@ -229,73 +273,77 @@ describe('Serializer', () => { ]; const expectedJson = [ + '2016-01-02T10:20:50.850000000Z', + '2016-06-17T10:50:15.000000000Z' + ]; + + const expectedProtoJs = [ { seconds: '1451730050', nanos: 850000000 }, { seconds: '1466160615', nanos: 0 } ]; for (let i = 0; i < examples.length; i++) { verifyFieldValueRoundTrip({ - value: new fieldValue.TimestampValue(Timestamp.fromDate(examples[i])), + value: examples[i], valueType: 'timestampValue', - jsonValue: expectedJson[i] + jsonValue: expectedJson[i], + protoJsValue: expectedProtoJs[i] }); } }); it('converts TimestampValue from string', () => { expect( - s.fromValue({ timestampValue: '2017-03-07T07:42:58.916123456Z' }) - ).to.deep.equal( - new fieldValue.TimestampValue(new Timestamp(1488872578, 916123456)) - ); + userDataWriter.convertValue({ + timestampValue: '2017-03-07T07:42:58.916123456Z' + }) + ).to.deep.equal(new Timestamp(1488872578, 916123456).toDate()); expect( - s.fromValue({ timestampValue: '2017-03-07T07:42:58.916123Z' }) - ).to.deep.equal( - new fieldValue.TimestampValue(new Timestamp(1488872578, 916123000)) - ); + userDataWriter.convertValue({ + timestampValue: '2017-03-07T07:42:58.916123Z' + }) + ).to.deep.equal(new Timestamp(1488872578, 916123000).toDate()); expect( - s.fromValue({ timestampValue: '2017-03-07T07:42:58.916Z' }) - ).to.deep.equal( - new fieldValue.TimestampValue(new Timestamp(1488872578, 916000000)) - ); + userDataWriter.convertValue({ + timestampValue: '2017-03-07T07:42:58.916Z' + }) + ).to.deep.equal(new Timestamp(1488872578, 916000000).toDate()); expect( - s.fromValue({ timestampValue: '2017-03-07T07:42:58Z' }) - ).to.deep.equal( - new fieldValue.TimestampValue(new Timestamp(1488872578, 0)) - ); + userDataWriter.convertValue({ + timestampValue: '2017-03-07T07:42:58Z' + }) + ).to.deep.equal(new Timestamp(1488872578, 0).toDate()); }); it('converts TimestampValue to string (useProto3Json=true)', () => { expect( - proto3JsonSerializer.toValue( - new fieldValue.TimestampValue(new Timestamp(1488872578, 916123456)) - ) - ).to.deep.equal({ timestampValue: '2017-03-07T07:42:58.916123456Z' }); - - expect( - proto3JsonSerializer.toValue( - new fieldValue.TimestampValue(new Timestamp(1488872578, 916123000)) + protobufJsonReader.parseQueryValue( + 'timestampConversion', + new Timestamp(1488872578, 916123000) ) ).to.deep.equal({ timestampValue: '2017-03-07T07:42:58.916123000Z' }); expect( - proto3JsonSerializer.toValue( - new fieldValue.TimestampValue(new Timestamp(1488872578, 916000000)) + protobufJsonReader.parseQueryValue( + 'timestampConversion', + new Timestamp(1488872578, 916000000) ) ).to.deep.equal({ timestampValue: '2017-03-07T07:42:58.916000000Z' }); expect( - proto3JsonSerializer.toValue( - new fieldValue.TimestampValue(new Timestamp(1488872578, 916000)) + protobufJsonReader.parseQueryValue( + 'timestampConversion', + new Timestamp(1488872578, 916000) ) ).to.deep.equal({ timestampValue: '2017-03-07T07:42:58.000916000Z' }); expect( - proto3JsonSerializer.toValue( - new fieldValue.TimestampValue(new Timestamp(1488872578, 0)) + protobufJsonReader.parseQueryValue( + 'timestampConversion', + new Timestamp(1488872578, 0) ) ).to.deep.equal({ timestampValue: '2017-03-07T07:42:58.000000000Z' }); }); @@ -308,36 +356,25 @@ describe('Serializer', () => { }; verifyFieldValueRoundTrip({ - value: new fieldValue.GeoPointValue(example), + value: example, valueType: 'geoPointValue', jsonValue: expected }); }); - it('converts BlobValue to Uint8Array', () => { - const bytes = [0, 1, 2, 3, 4, 5]; - const example = Blob.fromUint8Array(new Uint8Array(bytes)); - const expected = new Uint8Array(bytes); + it('converts BlobValue', () => { + const bytes = new Uint8Array([0, 1, 2, 3, 4, 5]); verifyFieldValueRoundTrip({ - value: new fieldValue.BlobValue(example), + value: Blob.fromUint8Array(bytes), valueType: 'bytesValue', - jsonValue: expected - }); - }); - - it('converts BlobValue to Base64 string (useProto3Json=true)', () => { - const base64 = 'AAECAwQF'; - verifyFieldValueRoundTrip({ - value: new fieldValue.BlobValue(Blob.fromBase64String(base64)), - valueType: 'bytesValue', - jsonValue: base64, - useProto3Json: true + jsonValue: 'AAECAwQF', + protoJsValue: bytes }); }); it('converts ArrayValue', () => { - const value = wrap([true, 'foo']); + const value = [true, 'foo']; const jsonValue = { values: [{ booleanValue: true }, { stringValue: 'foo' }] }; @@ -350,15 +387,15 @@ describe('Serializer', () => { it('converts empty ArrayValue', () => { verifyFieldValueRoundTrip({ - value: wrap([]), + value: [], valueType: 'arrayValue', jsonValue: { values: [] } }); }); - it('converts ObjectValue.EMPTY', () => { + it('converts empty ObjectValue', () => { verifyFieldValueRoundTrip({ - value: wrap({}), + value: {}, valueType: 'mapValue', jsonValue: { fields: {} } }); @@ -380,8 +417,8 @@ describe('Serializer', () => { }, s: 'foo' }; - const objValue = wrapObject(original); - expect(objValue.value()).to.deep.equal(original); + const objValue = wrap(original); + expect(userDataWriter.convertValue(objValue)).to.deep.equal(original); const expectedJson: api.Value = { mapValue: { @@ -426,24 +463,28 @@ describe('Serializer', () => { }; verifyFieldValueRoundTrip({ - value: objValue, + value: original, valueType: 'mapValue', jsonValue: expectedJson.mapValue }); }); it('converts RefValue', () => { - const example = 'projects/project1/databases/database1/documents/docs/1'; - const value: fieldValue.FieldValue = new fieldValue.RefValue( + // verifyFieldValueRoundTrip cannot be used for RefValues since the + // serializer takes a DocumentKeyReference but returns a DocumentReference + const example = new DocumentKeyReference( dbId('project1', 'database1'), key('docs/1') ); + const actualValue = protoJsReader.parseQueryValue('refValue', example); + expect(actualValue).to.deep.equal( + refValue(dbId('project1', 'database1'), key('docs/1')) + ); - verifyFieldValueRoundTrip({ - value, - valueType: 'referenceValue', - jsonValue: example - }); + const roundtripResult = userDataWriter.convertValue( + actualValue + ) as DocumentReference; + expect(roundtripResult._key.isEqual(key('docs/1'))).to.be.true; }); }); @@ -630,12 +671,12 @@ describe('Serializer', () => { { fieldPath: 'a', appendMissingElements: { - values: [s.toValue(wrap('a')), s.toValue(wrap(2))] + values: [wrap('a'), wrap(2)] } }, { fieldPath: 'bar.baz', - removeAllFromArray: { values: [s.toValue(wrap({ x: 1 }))] } + removeAllFromArray: { values: [wrap({ x: 1 })] } } ] }, @@ -678,7 +719,7 @@ describe('Serializer', () => { const d = doc('foo/bar', 42, { a: 5, b: 'b' }); const proto = { name: s.toName(d.key), - fields: s.toFields(d.data()), + fields: d.toProto().mapValue.fields, updateTime: s.toVersion(d.version) }; const serialized = s.toDocument(d); @@ -1378,7 +1419,7 @@ describe('Serializer', () => { documentChange: { document: { name: s.toName(key('coll/1')), - fields: s.toFields(wrapObject({ foo: 'bar' })), + fields: wrap({ foo: 'bar' }).mapValue!.fields, updateTime: s.toVersion(SnapshotVersion.fromMicroseconds(5)) }, targetIds: [1, 2] @@ -1398,7 +1439,7 @@ describe('Serializer', () => { documentChange: { document: { name: s.toName(key('coll/1')), - fields: s.toFields(wrapObject({ foo: 'bar' })), + fields: wrap({ foo: 'bar' }).mapValue!.fields, updateTime: s.toVersion(SnapshotVersion.fromMicroseconds(5)) }, targetIds: [2], diff --git a/packages/firestore/test/unit/specs/query_spec.test.ts b/packages/firestore/test/unit/specs/query_spec.test.ts index 5722a916a33..0dccafb9482 100644 --- a/packages/firestore/test/unit/specs/query_spec.test.ts +++ b/packages/firestore/test/unit/specs/query_spec.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2019 Google Inc. + * Copyright 2019 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -82,9 +82,9 @@ describeSpec('Queries:', [], () => { ); return specWithCachedDocs(...cachedDocs) - .userSets(toWrite1.key.toString(), toWrite1.data().value()) - .userSets(toWrite2.key.toString(), toWrite2.data().value()) - .userSets(toWrite3.key.toString(), toWrite3.data().value()) + .userSets(toWrite1.key.toString(), { val: 2 }) + .userSets(toWrite2.key.toString(), { val: 1 }) + .userSets(toWrite3.key.toString(), { val: 1 }) .userListens(cgQuery) .expectEvents(cgQuery, { added: [cachedDocs[0], toWrite1, toWrite2], diff --git a/packages/firestore/test/unit/specs/spec_builder.ts b/packages/firestore/test/unit/specs/spec_builder.ts index d988eaf91ce..3dc053cd5f7 100644 --- a/packages/firestore/test/unit/specs/spec_builder.ts +++ b/packages/firestore/test/unit/specs/spec_builder.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -36,7 +36,7 @@ import { assert, fail } from '../../../src/util/assert'; import { Code } from '../../../src/util/error'; import * as objUtils from '../../../src/util/obj'; import { isNullOrUndefined } from '../../../src/util/types'; -import { TestSnapshotVersion } from '../../util/helpers'; +import { TestSnapshotVersion, testUserDataWriter } from '../../util/helpers'; import { TimerId } from '../../../src/util/async_queue'; import { RpcError } from './spec_rpc_error'; @@ -55,6 +55,8 @@ import { SpecWriteFailure } from './spec_test_runner'; +const userDataWriter = testUserDataWriter(); + // These types are used in a protected API by SpecBuilder and need to be // exported. export interface LimboMap { @@ -885,7 +887,7 @@ export class SpecBuilder { return [ filter.field.canonicalString(), filter.op.name, - filter.value.value() + userDataWriter.convertValue(filter.value) ] as SpecQueryFilter; } else { return fail('Unknown filter: ' + filter); @@ -908,7 +910,9 @@ export class SpecBuilder { return { key: SpecBuilder.keyToSpec(doc.key), version: doc.version.toMicroseconds(), - value: doc.data().value(), + value: userDataWriter.convertValue(doc.toProto()) as JsonObject< + unknown + >, options: { hasLocalMutations: doc.hasLocalMutations, hasCommittedMutations: doc.hasCommittedMutations diff --git a/packages/firestore/test/unit/specs/spec_test_runner.ts b/packages/firestore/test/unit/specs/spec_test_runner.ts index b55cb9f9b60..f4f398737c8 100644 --- a/packages/firestore/test/unit/specs/spec_test_runner.ts +++ b/packages/firestore/test/unit/specs/spec_test_runner.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -200,7 +200,10 @@ class MockConnection implements Connection { return this.watchOpen.promise; } - ackWrite(commitTime?: string, mutationResults?: api.WriteResult[]): void { + ackWrite( + commitTime?: api.Timestamp, + mutationResults?: api.WriteResult[] + ): void { this.writeStream!.callOnMessage({ // Convert to base64 string so it can later be parsed into ByteString. streamToken: PlatformSupport.getPlatform().btoa( @@ -1155,9 +1158,7 @@ abstract class TestRunner { expect(actualTarget.query).to.deep.equal(expectedTarget.query); expect(actualTarget.targetId).to.equal(expectedTarget.targetId); expect(actualTarget.readTime).to.equal(expectedTarget.readTime); - expect(actualTarget.resumeToken || '').to.equal( - expectedTarget.resumeToken || '' - ); + expect(actualTarget.resumeToken).to.equal(expectedTarget.resumeToken); delete actualTargets[targetId]; }); expect(obj.size(actualTargets)).to.equal( diff --git a/packages/firestore/test/util/helpers.ts b/packages/firestore/test/util/helpers.ts index 7d9babd226c..b1492cb2e98 100644 --- a/packages/firestore/test/util/helpers.ts +++ b/packages/firestore/test/util/helpers.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,15 +16,19 @@ */ import * as firestore from '@firebase/firestore-types'; + +import * as api from '../../src/protos/firestore_proto_api'; + import { expect } from 'chai'; import { Blob } from '../../src/api/blob'; import { fromDotSeparatedString } from '../../src/api/field_path'; import { FieldValueImpl } from '../../src/api/field_value'; +import { UserDataWriter } from '../../src/api/user_data_writer'; import { DocumentKeyReference, - UserDataConverter -} from '../../src/api/user_data_converter'; + UserDataReader +} from '../../src/api/user_data_reader'; import { DatabaseId } from '../../src/core/database_info'; import { Bound, @@ -60,11 +64,7 @@ import { import { DocumentComparator } from '../../src/model/document_comparator'; import { DocumentKey } from '../../src/model/document_key'; import { DocumentSet } from '../../src/model/document_set'; -import { - FieldValue, - JsonObject, - ObjectValue -} from '../../src/model/field_value'; +import { JsonObject, ObjectValue } from '../../src/model/field_value'; import { DeleteMutation, FieldMask, @@ -90,6 +90,7 @@ import { SortedSet } from '../../src/util/sorted_set'; import { query } from './api_helpers'; import { ByteString } from '../../src/util/byte_string'; import { PlatformSupport } from '../../src/platform/platform'; +import { JsonProtoSerializer } from '../../src/remote/serializer'; export type TestSnapshotVersion = number; @@ -103,7 +104,26 @@ const preConverter = (input: unknown): unknown => { return input === DELETE_SENTINEL ? FieldValueImpl.delete() : input; }; -const dataConverter = new UserDataConverter(preConverter); +export function testUserDataWriter(): UserDataWriter { + // We should pass in a proper Firestore instance, but for now, only + // `ensureClientConfigured()` and `_databaseId` is used in our test usage of + // UserDataWriter. + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const firestore: any = { + ensureClientConfigured: () => {}, + _databaseId: new DatabaseId('test-project') + }; + return new UserDataWriter(firestore, /* timestampsInSnapshots= */ false); +} + +export function testUserDataReader(useProto3Json?: boolean): UserDataReader { + useProto3Json = useProto3Json ?? PlatformSupport.getPlatform().useProto3Json; + return new UserDataReader( + new JsonProtoSerializer(new DatabaseId('test-project'), { useProto3Json }), + preConverter + ); +} export function version(v: TestSnapshotVersion): SnapshotVersion { return SnapshotVersion.fromMicroseconds(v); @@ -125,7 +145,7 @@ export function doc( json: JsonObject, options: DocumentOptions = {} ): Document { - return new Document(key(keyStr), version(ver), options, wrapObject(json)); + return new Document(key(keyStr), version(ver), wrapObject(json), options); } export function deletedDoc( @@ -147,16 +167,15 @@ export function removedDoc(keyStr: string): NoDocument { return new NoDocument(key(keyStr), SnapshotVersion.forDeletedDoc()); } -export function wrap(value: unknown): FieldValue { +export function wrap(value: unknown): api.Value { // HACK: We use parseQueryValue() since it accepts scalars as well as // arrays / objects, and our tests currently use wrap() pretty generically so // we don't know the intent. - return dataConverter.parseQueryValue('wrap', value); + return testUserDataReader().parseQueryValue('wrap', value); } export function wrapObject(obj: JsonObject): ObjectValue { - // Cast is safe here because value passed in is a map - return wrap(obj) as ObjectValue; + return new ObjectValue(wrap(obj) as { mapValue: api.MapValue }); } export function dbId(project: string, database?: string): DatabaseId { @@ -226,7 +245,7 @@ export function patchMutation( precondition = Precondition.exists(true); } - const parsed = dataConverter.parseUpdateData('patchMutation', json); + const parsed = testUserDataReader().parseUpdateData('patchMutation', json); return new PatchMutation( key(keyStr), parsed.data, @@ -249,7 +268,10 @@ export function transformMutation( keyStr: string, data: Dict ): TransformMutation { - const result = dataConverter.parseUpdateData('transformMutation()', data); + const result = testUserDataReader().parseUpdateData( + 'transformMutation()', + data + ); return new TransformMutation(key(keyStr), result.fieldTransforms); } @@ -263,7 +285,7 @@ export function bound( values: Array<[string, {}, firestore.OrderByDirection]>, before: boolean ): Bound { - const components: FieldValue[] = []; + const components: api.Value[] = []; for (const value of values) { const [_, dataValue] = value; components.push(wrap(dataValue)); diff --git a/packages/firestore/test/util/test_platform.ts b/packages/firestore/test/util/test_platform.ts index 5cf549f3322..b3eaa034e0c 100644 --- a/packages/firestore/test/util/test_platform.ts +++ b/packages/firestore/test/util/test_platform.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2018 Google Inc. + * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -219,6 +219,10 @@ export class TestPlatform implements Platform { this.mockWindow = new FakeWindow(this.mockStorage); } + get useProto3Json(): boolean { + return this.basePlatform.useProto3Json; + } + get document(): Document | null { // FakeWindow doesn't support full Document interface. return this.mockDocument as any; // eslint-disable-line @typescript-eslint/no-explicit-any