diff --git a/packages/firestore/src/api/database.ts b/packages/firestore/src/api/database.ts index 7e97b10a4d2..40e37f36d20 100644 --- a/packages/firestore/src/api/database.ts +++ b/packages/firestore/src/api/database.ts @@ -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,14 +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, - 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'; @@ -555,7 +554,10 @@ export class Firestore implements firestore.FirebaseFirestore, FirebaseService { return value; } }; - return new UserDataReader(preConverter); + const serializer = new JsonProtoSerializer(databaseId, { + useProto3Json: PlatformSupport.getPlatform().useProto3Json + }); + return new UserDataReader(serializer, preConverter); } private static databaseIdFromApp(app: FirebaseApp): DatabaseId { @@ -1390,7 +1392,7 @@ export class DocumentSnapshot options.serverTimestamps, /* converter= */ undefined ); - return userDataWriter.convertValue(this._document.data()) as T; + return userDataWriter.convertValue(this._document.toProto()) as T; } } } @@ -1495,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()) { @@ -1510,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); } @@ -1720,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); @@ -1738,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 @@ -1754,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 ' + @@ -1801,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]; @@ -1835,7 +1833,7 @@ 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._dataReader.parseQueryValue( methodName, @@ -2034,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( @@ -2065,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_reader.ts b/packages/firestore/src/api/user_data_reader.ts index 2e4de8b1bf1..094982ef43c 100644 --- a/packages/firestore/src/api/user_data_reader.ts +++ b/packages/firestore/src/api/user_data_reader.ts @@ -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 { @@ -313,7 +297,10 @@ export class DocumentKeyReference { * classes. */ export class UserDataReader { - constructor(private preConverter: DataPreConverter) {} + 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 UserDataReader { 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 UserDataReader { 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 UserDataReader { ); } return new ParsedSetData( - updateData as ObjectValue, + new ObjectValue(updateData), fieldMask, fieldTransforms ); @@ -501,7 +487,7 @@ export class UserDataReader { 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 UserDataReader { * @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 UserDataReader { } } - 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 UserDataReader { 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 UserDataReader { 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 UserDataReader { 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 UserDataReader { * * @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._byteString); + 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 UserDataReader { 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 index 1acae3d0b0d..4f442d2db3f 100644 --- a/packages/firestore/src/api/user_data_writer.ts +++ b/packages/firestore/src/api/user_data_writer.ts @@ -17,19 +17,30 @@ import * as firestore from '@firebase/firestore-types'; -import { - ArrayValue, - BlobValue, - FieldValue, - ObjectValue, - RefValue, - ServerTimestampValue, - TimestampValue -} from '../model/field_value'; -import { DocumentReference, Firestore } from './database'; +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'; @@ -37,7 +48,7 @@ export type ServerTimestampBehavior = 'estimate' | 'previous' | 'none'; * Converts Firestore's internal types to the JavaScript types that we expose * to the user. */ -export class UserDataWriter { +export class UserDataWriter { constructor( private readonly firestore: Firestore, private readonly timestampsInSnapshots: boolean, @@ -45,50 +56,71 @@ export class UserDataWriter { private readonly converter?: firestore.FirestoreDataConverter ) {} - convertValue(value: FieldValue): unknown { - if (value instanceof ObjectValue) { - return this.convertObject(value); - } else if (value instanceof ArrayValue) { - return this.convertArray(value); - } else if (value instanceof RefValue) { - return this.convertReference(value); - } else if (value instanceof BlobValue) { - return new Blob(value.internalValue); - } else if (value instanceof TimestampValue) { - return this.convertTimestamp(value.value()); - } else if (value instanceof ServerTimestampValue) { - return this.convertServerTimestamp(value); - } else { - return value.value(); + 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(data: ObjectValue): firestore.DocumentData { + private convertObject(mapValue: api.MapValue): firestore.DocumentData { const result: firestore.DocumentData = {}; - data.forEach((key, value) => { + forEach(mapValue.fields || {}, (key, value) => { result[key] = this.convertValue(value); }); return result; } - private convertArray(data: ArrayValue): unknown[] { - return data.internalValue.map(value => this.convertValue(value)); + private convertArray(arrayValue: api.ArrayValue): unknown[] { + return (arrayValue.values || []).map(value => this.convertValue(value)); } - private convertServerTimestamp(value: ServerTimestampValue): unknown { + private convertServerTimestamp(value: api.Value): unknown { switch (this.serverTimestampBehavior) { case 'previous': - return value.previousValue - ? this.convertValue(value.previousValue) - : null; + const previousValue = getPreviousValue(value); + if (previousValue == null) { + return null; + } + return this.convertValue(previousValue); case 'estimate': - return this.convertTimestamp(value.localWriteTime); + return this.convertTimestamp(getLocalWriteTime(value)); default: - return value.value(); + return null; } } - private convertTimestamp(timestamp: Timestamp): Timestamp | Date { + 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 { @@ -96,20 +128,27 @@ export class UserDataWriter { } } - private convertReference(value: RefValue): DocumentReference { - const key = value.value(); - const database = this.firestore.ensureClientConfigured().databaseId(); - if (!value.databaseId.isEqual(database)) { + 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 ${value.key} contains a document ` + + `Document ${key} contains a document ` + `reference within a different database (` + - `${value.databaseId.projectId}/${value.databaseId.database}) which is not ` + + `${databaseId.projectId}/${databaseId.database}) which is not ` + `supported. It will be treated as a reference in the current ` + - `database (${database.projectId}/${database.database}) ` + + `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..a806f4d7f63 100644 --- a/packages/firestore/src/core/query.ts +++ b/packages/firestore/src/core/query.ts @@ -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..1264e2b9374 100644 --- a/packages/firestore/src/core/target.ts +++ b/packages/firestore/src/core/target.ts @@ -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/local/indexeddb_persistence.ts b/packages/firestore/src/local/indexeddb_persistence.ts index 408fbcd579c..05473ea4c22 100644 --- a/packages/firestore/src/local/indexeddb_persistence.ts +++ b/packages/firestore/src/local/indexeddb_persistence.ts @@ -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'; @@ -1338,7 +1338,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/memory_persistence.ts b/packages/firestore/src/local/memory_persistence.ts index d6ffa96ab4f..0360fe03813 100644 --- a/packages/firestore/src/local/memory_persistence.ts +++ b/packages/firestore/src/local/memory_persistence.ts @@ -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'; @@ -485,7 +486,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 5fe125d0eaa..80a8ebbc3c9 100644 --- a/packages/firestore/src/model/document.ts +++ b/packages/firestore/src/model/document.ts @@ -15,12 +15,15 @@ * limitations under the License. */ +import * as api from '../protos/firestore_proto_api'; + import { SnapshotVersion } from '../core/snapshot_version'; 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 { valueCompare } from './values'; export interface DocumentOptions { hasLocalMutations?: boolean; @@ -68,7 +71,7 @@ export class Document extends MaybeDocument { this.hasCommittedMutations = !!options.hasCommittedMutations; } - field(path: FieldPath): FieldValue | null { + field(path: FieldPath): api.Value | null { return this.objectValue.field(path); } @@ -76,8 +79,8 @@ export class Document extends MaybeDocument { return this.objectValue; } - value(): JsonObject { - return this.data().value(); + toProto(): { mapValue: api.MapValue } { + return this.objectValue.proto; } isEqual(other: MaybeDocument | null | undefined): boolean { @@ -109,7 +112,7 @@ export class Document extends MaybeDocument { 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 6f66b2ec486..823a0046134 100644 --- a/packages/firestore/src/model/document_key.ts +++ b/packages/firestore/src/model/document_key.ts @@ -63,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 5a83f5dce5b..da67f4680dc 100644 --- a/packages/firestore/src/model/field_value.ts +++ b/packages/firestore/src/model/field_value.ts @@ -15,548 +15,95 @@ * limitations under the License. */ -import { GeoPoint } from '../api/geo_point'; -import { Timestamp } from '../api/timestamp'; -import { DatabaseId } from '../core/database_info'; +import * as api from '../protos/firestore_proto_api'; + import { assert } from '../util/assert'; -import { - numericComparator, - numericEquals, - primitiveComparator -} from '../util/misc'; -import { DocumentKey } from './document_key'; import { FieldMask } from './mutation'; import { FieldPath } from './path'; -import { ByteString } from '../util/byte_string'; -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 } /** - * Potential types returned by FieldValue.value(). This could be stricter - * (instead of using {}), but there's little benefit. - * - * Note that currently we use `unknown` (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; +export class ObjectValue { + static EMPTY = new ObjectValue({ mapValue: {} }); - abstract value(): FieldType; - abstract isEqual(other: FieldValue): boolean; - abstract compareTo(other: FieldValue): number; - - /** - * 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(): 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(): 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(): 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; - } -} - -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(): 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(): Timestamp { - return this.internalValue; - } - - 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 { - // TODO(mrschmidt): Represent ServerTimestamps as a PrimitiveType with a - // Map containing a private `__type__` field (or similar). - - typeOrder = TypeOrder.TimestampValue; - - constructor( - readonly localWriteTime: Timestamp, - readonly previousValue: FieldValue | null - ) { - super(); - } - - value(): null { - return null; - } - - isEqual(other: FieldValue): boolean { - return ( - other instanceof ServerTimestampValue && - this.localWriteTime.isEqual(other.localWriteTime) - ); - } - - compareTo(other: FieldValue): number { - if (other instanceof ServerTimestampValue) { - return this.localWriteTime._compareTo(other.localWriteTime); - } else if (other.typeOrder === TypeOrder.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: ByteString) { - super(); - } - - value(): ByteString { - 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(): 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(): GeoPoint { - return this.internalValue; - } - - isEqual(other: FieldValue): boolean { - return ( - other instanceof GeoPointValue && - this.internalValue.isEqual(other.internalValue) + !isServerTimestamp(proto), + 'ServerTimestamps should be converted to ServerTimestampValue' ); } - 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); + return ObjectValue.EMPTY.toBuilder(); } - value(): JsonObject { - const result: JsonObject = {}; - this.internalValue.inorderTraversal((key, val) => { - result[key] = val.value(); - }); - return result; - } - - 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. @@ -569,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. @@ -611,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 { @@ -646,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(): FieldType[] { - return this.internalValue.map(v => v.value()); - } - /** - * 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 eeadda4bd5f..c5b20fc06ca 100644 --- a/packages/firestore/src/model/mutation.ts +++ b/packages/firestore/src/model/mutation.ts @@ -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 ); } } @@ -610,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) ); } @@ -644,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}) ` + @@ -656,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); } @@ -686,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); } @@ -713,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..d31ee731212 100644 --- a/packages/firestore/src/model/mutation_batch.ts +++ b/packages/firestore/src/model/mutation_batch.ts @@ -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/proto_field_value.ts b/packages/firestore/src/model/proto_field_value.ts deleted file mode 100644 index 435091671a3..00000000000 --- a/packages/firestore/src/model/proto_field_value.ts +++ /dev/null @@ -1,381 +0,0 @@ -/** - * @license - * Copyright 2020 Google Inc. - * - * 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 { DocumentKey } from './document_key'; -import { - FieldType, - FieldValue, - ServerTimestampValue, - TypeOrder -} from './field_value'; -import { FieldPath, ResourcePath } from './path'; -import { FieldMask } from './mutation'; -import { - compare, - equals, - isType, - normalizeByteString, - normalizeTimestamp, - typeOrder -} from './values'; -import { Blob } from '../api/blob'; -import { GeoPoint } from '../api/geo_point'; -import { Timestamp } from '../api/timestamp'; -import { assert, fail } from '../util/assert'; -import { forEach } from '../util/obj'; -import { SortedSet } from '../util/sorted_set'; - -/** - * Represents a FieldValue that is backed by a single Firestore V1 Value proto - * and implements Firestore's Value semantics for ordering and equality. - */ -export class PrimitiveValue extends FieldValue { - constructor(public readonly proto: api.Value) { - super(); - } - - get typeOrder(): number { - return typeOrder(this.proto); - } - - value(): FieldType { - return this.convertValue(this.proto); - } - - private convertValue(value: api.Value): FieldType { - 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) { - const normalizedTimestamp = normalizeTimestamp(value.timestampValue!); - return new Timestamp( - normalizedTimestamp.seconds, - normalizedTimestamp.nanos - ); - } else if ('stringValue' in value) { - return value.stringValue!; - } else if ('bytesValue' in value) { - return new Blob(normalizeByteString(value.bytesValue!)); - } else if ('referenceValue' in value) { - return this.convertReference(value.referenceValue!); - } else if ('geoPointValue' in value) { - return new GeoPoint( - value.geoPointValue!.latitude || 0, - value.geoPointValue!.longitude || 0 - ); - } else if ('arrayValue' in value) { - return this.convertArray(value.arrayValue!.values || []); - } else if ('mapValue' in value) { - return this.convertMap(value.mapValue!.fields || {}); - } else { - return fail('Unknown value type: ' + JSON.stringify(value)); - } - } - - private convertReference(value: string): DocumentKey { - // TODO(mrschmidt): Move `value()` and `convertValue()` to DocumentSnapshot, - // which would allow us to validate that the resource name points to the - // current project. - const resourceName = ResourcePath.fromString(value); - assert( - resourceName.length > 4 && resourceName.get(4) === 'documents', - 'Tried to deserialize invalid key ' + resourceName.toString() - ); - return new DocumentKey(resourceName.popFirst(5)); - } - - private convertArray(values: api.Value[]): unknown[] { - return values.map(v => this.convertValue(v)); - } - - private convertMap( - value: api.ApiClientObjectMap - ): { [k: string]: unknown } { - const result: { [k: string]: unknown } = {}; - forEach(value, (k, v) => { - result[k] = this.convertValue(v); - }); - return result; - } - - approximateByteSize(): number { - // TODO(mrschmidt): Replace JSON stringify with an implementation in ProtoValues - return JSON.stringify(this.proto).length; - } - - isEqual(other: FieldValue): boolean { - if (this === other) { - return true; - } - if (other instanceof PrimitiveValue) { - return equals(this.proto, other.proto); - } - return false; - } - - compareTo(other: FieldValue): number { - if (other instanceof PrimitiveValue) { - return compare(this.proto, other.proto); - } else if ( - isType(this.proto, TypeOrder.TimestampValue) && - other instanceof ServerTimestampValue - ) { - // TODO(mrschmidt): Handle timestamps directly in PrimitiveValue - return -1; - } else { - return this.defaultCompareTo(other); - } - } -} - -/** - * An ObjectValue represents a MapValue in the Firestore Proto and offers the - * ability to add and remove fields (via the ObjectValueBuilder). - */ -export class ObjectValue extends PrimitiveValue { - static EMPTY = new ObjectValue({ mapValue: {} }); - - constructor(proto: api.Value) { - super(proto); - assert( - isType(proto, TypeOrder.ObjectValue), - 'ObjectValues must be backed by a MapValue' - ); - } - - /** Returns a new Builder instance that is based on an empty object. */ - static newBuilder(): ObjectValueBuilder { - return ObjectValue.EMPTY.toBuilder(); - } - - /** - * 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): FieldValue | null { - if (path.isEmpty()) { - return this; - } else { - let value = this.proto; - for (let i = 0; i < path.length - 1; ++i) { - if (!value.mapValue!.fields) { - return null; - } - value = value.mapValue!.fields[path.get(i)]; - if (!isType(value, TypeOrder.ObjectValue)) { - return null; - } - } - - value = (value.mapValue!.fields || {})[path.lastSegment()]; - // TODO(mrschmidt): Simplify/remove - return isType(value, TypeOrder.ObjectValue) - ? new ObjectValue(value) - : value !== undefined - ? new PrimitiveValue(value) - : null; - } - } - - /** - * 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); - forEach(value.fields || {}, (key, value) => { - const currentPath = new FieldPath([key]); - if (isType(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. - fields = fields.add(currentPath); - } else { - // For nested and non-empty ObjectValues, add the FieldPath of the - // leaf nodes. - nestedFields.forEach(nestedPath => { - fields = fields.add(currentPath.child(nestedPath)); - }); - } - } else { - // For nested and non-empty ObjectValues, add the FieldPath of the leaf - // nodes. - fields = fields.add(currentPath); - } - }); - return FieldMask.fromSet(fields); - } - - /** Creates a ObjectValueBuilder instance that is based on the current value. */ - toBuilder(): ObjectValueBuilder { - 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. - */ -export class ObjectValueBuilder { - /** 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. - * - * @param path The field path to set. - * @param value The value to set. - * @return The current Builder instance. - */ - set(path: FieldPath, value: api.Value): ObjectValueBuilder { - assert(!path.isEmpty(), 'Cannot set field for empty path on ObjectValue'); - this.setOverlay(path, value); - return this; - } - - /** - * 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. - * @return The current Builder instance. - */ - delete(path: FieldPath): ObjectValueBuilder { - assert( - !path.isEmpty(), - 'Cannot delete field for empty path on ObjectValue' - ); - this.setOverlay(path, null); - return this; - } - - /** - * Adds `value` to the overlay map at `path`. Creates nested map entries if - * needed. - */ - 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 (isType(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; - } - } - - currentLevel.set(path.lastSegment(), value); - } - - /** 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; - } - } - - /** - * 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 - ): api.Value | null { - let modified = false; - - const existingValue = this.baseObject.field(currentPath); - const resultAtPath = - existingValue instanceof ObjectValue - ? // If there is already data at the current path, base our - // modifications on top of the existing data. - { ...existingValue.proto.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 modified ? { mapValue: { fields: resultAtPath } } : null; - } -} diff --git a/packages/firestore/src/model/transform_operation.ts b/packages/firestore/src/model/transform_operation.ts index 4830f3af3be..8fd0f9e1233 100644 --- a/packages/firestore/src/model/transform_operation.ts +++ b/packages/firestore/src/model/transform_operation.ts @@ -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 index 03f9f8d723e..26d0c863779 100644 --- a/packages/firestore/src/model/values.ts +++ b/packages/firestore/src/model/values.ts @@ -21,12 +21,15 @@ 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 { - numericComparator, - numericEquals, - primitiveComparator -} from '../util/misc'; + getLocalWriteTime, + getPreviousValue, + isServerTimestamp +} from './server_timestamps'; // A RegExp matching ISO 8601 UTC timestamps with optional fraction. const ISO_TIMESTAMP_REG_EXP = new RegExp( @@ -54,22 +57,17 @@ export function typeOrder(value: api.Value): TypeOrder { } 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)); } } -/** Returns whether `value` is defined and corresponds to the given type order. */ -export function isType( - value: api.Value | null | undefined, - expectedTypeOrder: TypeOrder -): value is api.Value { - return !!value && typeOrder(value) === expectedTypeOrder; -} - /** Tests `left` and `right` for equality based on the backend semantics. */ -export function equals(left: api.Value, right: api.Value): boolean { +export function valueEquals(left: api.Value, right: api.Value): boolean { const leftType = typeOrder(left); const rightType = typeOrder(right); if (leftType !== rightType) { @@ -81,6 +79,8 @@ export function equals(left: api.Value, right: api.Value): boolean { 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: @@ -94,7 +94,11 @@ export function equals(left: api.Value, right: api.Value): boolean { case TypeOrder.NumberValue: return numberEquals(left, right); case TypeOrder.ArrayValue: - return arrayEquals(left, right); + return arrayEquals( + left.arrayValue!.values || [], + right.arrayValue!.values || [], + valueEquals + ); case TypeOrder.ObjectValue: return objectEquals(left, right); default: @@ -137,34 +141,21 @@ function blobEquals(left: api.Value, right: api.Value): boolean { export function numberEquals(left: api.Value, right: api.Value): boolean { if ('integerValue' in left && 'integerValue' in right) { - return numericEquals( - normalizeNumber(left.integerValue), - normalizeNumber(right.integerValue) + return ( + normalizeNumber(left.integerValue) === normalizeNumber(right.integerValue) ); } else if ('doubleValue' in left && 'doubleValue' in right) { - return numericEquals( - normalizeNumber(left.doubleValue), - normalizeNumber(right.doubleValue) - ); - } - - return false; -} - -function arrayEquals(left: api.Value, right: api.Value): boolean { - const leftArray = left.arrayValue!.values || []; - const rightArray = right.arrayValue!.values || []; + const n1 = normalizeNumber(left.doubleValue!); + const n2 = normalizeNumber(right.doubleValue!); - if (leftArray.length !== rightArray.length) { - return false; - } - - for (let i = 0; i < leftArray.length; ++i) { - if (!equals(leftArray[i], rightArray[i])) { - return false; + if (n1 === n2) { + return isNegativeZero(n1) === isNegativeZero(n2); + } else { + return isNaN(n1) && isNaN(n2); } } - return true; + + return false; } function objectEquals(left: api.Value, right: api.Value): boolean { @@ -177,7 +168,10 @@ function objectEquals(left: api.Value, right: api.Value): boolean { for (const key in leftMap) { if (leftMap.hasOwnProperty(key)) { - if (rightMap[key] === undefined || !equals(leftMap[key], rightMap[key])) { + if ( + rightMap[key] === undefined || + !valueEquals(leftMap[key], rightMap[key]) + ) { return false; } } @@ -185,7 +179,17 @@ function objectEquals(left: api.Value, right: api.Value): boolean { return true; } -export function compare(left: api.Value, right: api.Value): number { +/** 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); @@ -202,6 +206,11 @@ export function compare(left: api.Value, right: api.Value): number { 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: @@ -220,24 +229,32 @@ export function compare(left: api.Value, right: api.Value): number { } function compareNumbers(left: api.Value, right: api.Value): number { - const leftNumber = - 'doubleValue' in left - ? normalizeNumber(left.doubleValue) - : normalizeNumber(left.integerValue); - const rightNumber = - 'doubleValue' in right - ? normalizeNumber(right.doubleValue) - : normalizeNumber(right.integerValue); - return numericComparator(leftNumber, rightNumber); + 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') { - // Use string ordering for ISO 8601 timestamps, but strip the timezone - // suffix to ensure proper ordering for timestamps of different precision. - // The only supported timezone is UTC (i.e. 'Z') based on - // ISO_TIMESTAMP_REG_EXP. - return primitiveComparator(left.slice(0, -1), right.slice(0, -1)); + if ( + typeof left === 'string' && + typeof right === 'string' && + left.length === right.length + ) { + return primitiveComparator(left, right); } const leftTimestamp = normalizeTimestamp(left); @@ -293,9 +310,9 @@ function compareArrays(left: api.ArrayValue, right: api.ArrayValue): number { const rightArray = right.values || []; for (let i = 0; i < leftArray.length && i < rightArray.length; ++i) { - const valueCompare = compare(leftArray[i], rightArray[i]); - if (valueCompare) { - return valueCompare; + const compare = valueCompare(leftArray[i], rightArray[i]); + if (compare) { + return compare; } } return primitiveComparator(leftArray.length, rightArray.length); @@ -319,9 +336,9 @@ function compareMaps(left: api.MapValue, right: api.MapValue): number { if (keyCompare !== 0) { return keyCompare; } - const valueCompare = compare(leftMap[leftKeys[i]], rightMap[rightKeys[i]]); - if (valueCompare !== 0) { - return valueCompare; + const compare = valueCompare(leftMap[leftKeys[i]], rightMap[rightKeys[i]]); + if (compare !== 0) { + return compare; } } @@ -421,36 +438,37 @@ function canonifyArray(arrayValue: api.ArrayValue): string { * in memory and ignores object overhead. */ export function estimateByteSize(value: api.Value): number { - if ('nullValue' in value) { - return 4; - } else if ('booleanValue' in value) { - return 4; - } else if ('integerValue' in value) { - return 8; - } else if ('doubleValue' in value) { - return 8; - } else if ('timestampValue' in value) { - // TODO(mrschmidt: Add ServerTimestamp support - // Timestamps are made up of two distinct numbers (seconds + nanoseconds) - return 16; - } else if ('stringValue' in value) { - // 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; - } else if ('bytesValue' in value) { - return normalizeByteString(value.bytesValue!).approximateByteSize(); - } else if ('referenceValue' in value) { - return value.referenceValue!.length; - } else if ('geoPointValue' in value) { - // GeoPoints are made up of two distinct numbers (latitude + longitude) - return 16; - } else if ('arrayValue' in value) { - return estimateArrayByteSize(value.arrayValue!); - } else if ('mapValue' in value) { - return estimateMapByteSize(value.mapValue!); - } else { - return fail('Invalid value type: ' + JSON.stringify(value)); + 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)); } } @@ -511,7 +529,10 @@ export function normalizeTimestamp( } } -/** Converts the possible Proto types for numbers into a JavaScript number. */ +/** + * 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') { @@ -532,7 +553,16 @@ export function normalizeByteString(blob: string | Uint8Array): ByteString { } } -/** Returns true if `value` is an IntegerValue. */ +/** 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 } { @@ -576,7 +606,7 @@ export function isNullValue( export function isNanValue( value?: api.Value | null ): value is { doubleValue: 'NaN' | number } { - return isDouble(value) && isNaN(Number(value.doubleValue)); + return !!value && 'doubleValue' in value && isNaN(Number(value.doubleValue)); } /** Returns true if `value` is a MapValue. */ @@ -584,4 +614,4 @@ 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 2263fed1d6b..7158a8c3e13 100644 --- a/packages/firestore/src/platform/platform.ts +++ b/packages/firestore/src/platform/platform.ts @@ -51,6 +51,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 c047def3773..12e2d3be98d 100644 --- a/packages/firestore/src/platform_browser/browser_platform.ts +++ b/packages/firestore/src/platform_browser/browser_platform.ts @@ -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 8937f3fbe4d..333881253e9 100644 --- a/packages/firestore/src/platform_node/node_platform.ts +++ b/packages/firestore/src/platform_node/node_platform.ts @@ -29,6 +29,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 04b8c553e84..37b21270478 100644 --- a/packages/firestore/src/protos/firestore_proto_api.d.ts +++ b/packages/firestore/src/protos/firestore_proto_api.d.ts @@ -362,7 +362,7 @@ export declare namespace firestoreV1ApiClientInterfaces { field?: FieldReference; } interface Value { - nullValue?: ValueNullValue; + nullValue?: ValueNullValue | 0; booleanValue?: boolean; integerValue?: string | number; doubleValue?: string | number; diff --git a/packages/firestore/src/remote/serializer.ts b/packages/firestore/src/remote/serializer.ts index eee99a1f121..e0ca38a0740 100644 --- a/packages/firestore/src/remote/serializer.ts +++ b/packages/firestore/src/remote/serializer.ts @@ -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 { isNegativeZero, isNullOrUndefined } from '../util/types'; - +import { + isNegativeZero, + isNullOrUndefined, + isSafeInteger +} from '../util/types'; import { ArrayRemoveTransformOperation, ArrayUnionTransformOperation, @@ -72,11 +73,7 @@ import { WatchTargetChange, WatchTargetChangeState } from './watch_change'; -import { - normalizeByteString, - normalizeNumber, - normalizeTimestamp -} from '../model/values'; +import { isNanValue, isNullValue, normalizeTimestamp } from '../model/values'; const DIRECTIONS = (() => { const dirs: { [dir: string]: api.OrderDirection } = {}; @@ -164,10 +161,43 @@ export class JsonProtoSerializer { 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. */ - private toTimestamp(timestamp: Timestamp): api.Timestamp { + 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 @@ -234,8 +264,8 @@ export class JsonProtoSerializer { 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(); @@ -244,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 { @@ -275,7 +305,7 @@ export class JsonProtoSerializer { } toQueryPath(path: ResourcePath): string { - return this.toResourceName(this.databaseId, path); + return this.toResourceName(path); } fromQueryPath(name: string): ResourcePath { @@ -319,127 +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' - ); - } - - // TODO(mrschmidt): Even when we remove our custom FieldValues, we still - // need to re-encode field values to their expected type based on the - // `useProto3Json` setting. - 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 (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(normalizeNumber(obj.integerValue!)); - } else if ('doubleValue' in obj) { - // Note: Proto 3 uses the string values 'NaN' and 'Infinity'. - const parsedNumber = Number(obj.doubleValue!); - return new fieldValue.DoubleValue(parsedNumber); - } 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 byteString = normalizeByteString(obj.bytesValue); - return new fieldValue.BlobValue(byteString); - } 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 }; } @@ -450,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()) }; } @@ -461,44 +375,12 @@ export class JsonProtoSerializer { ): Document { const key = this.fromName(document.name!); const version = this.fromVersion(document.updateTime!); - const data = this.fromFields(document.fields!); + const data = new ObjectValue({ mapValue: { fields: document.fields } }); return new Document(key, version, data, { hasCommittedMutations: !!hasCommittedMutations }); } - 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)); - }); - return { values: result }; - } - private fromFound(doc: api.BatchGetDocumentsResponse): Document { assert( !!doc.found, @@ -508,7 +390,7 @@ export class JsonProtoSerializer { assertPresent(doc.found.updateTime, 'doc.found.updateTime'); const key = this.fromName(doc.found.name); const version = this.fromVersion(doc.found.updateTime); - const data = this.fromFields(doc.found.fields!); + const data = new ObjectValue({ mapValue: { fields: doc.found.fields } }); return new Document(key, version, data, {}); } @@ -570,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, @@ -646,7 +528,9 @@ export class JsonProtoSerializer { ); const key = this.fromName(entityChange.document.name); const version = this.fromVersion(entityChange.document.updateTime); - const data = this.fromFields(entityChange.document.fields!); + const data = new ObjectValue({ + mapValue: { fields: entityChange.document.fields } + }); const doc = new Document(key, version, data, {}); const updatedTargetIds = entityChange.targetIds || []; const removedTargetIds = entityChange.removedTargetIds || []; @@ -769,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); @@ -838,11 +724,9 @@ 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); } @@ -873,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); @@ -903,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)); @@ -1139,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); } @@ -1223,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), @@ -1250,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 } }; } @@ -1261,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: @@ -1298,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/misc.ts b/packages/firestore/src/util/misc.ts index 11760d1574e..4b75bd7e50e 100644 --- a/packages/firestore/src/util/misc.ts +++ b/packages/firestore/src/util/misc.ts @@ -46,40 +46,6 @@ export function primitiveComparator(left: T, right: T): number { return 0; } -/** Utility function to compare doubles (using Firestore semantics for NaN). */ -export 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). - */ -export 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; - } -} - /** Duck-typed interface for objects that have an isEqual() method. */ export interface Equatable { isEqual(other: T): boolean; @@ -100,18 +66,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/test/unit/core/query.test.ts b/packages/firestore/test/unit/core/query.test.ts index e625b061433..20f2faf65d6 100644 --- a/packages/firestore/test/unit/core/query.test.ts +++ b/packages/firestore/test/unit/core/query.test.ts @@ -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/local_store.test.ts b/packages/firestore/test/unit/local/local_store.test.ts index 16d8f49c729..19c7c61ad30 100644 --- a/packages/firestore/test/unit/local/local_store.test.ts +++ b/packages/firestore/test/unit/local/local_store.test.ts @@ -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 19178d461ce..e8195ce2dd5 100644 --- a/packages/firestore/test/unit/local/lru_garbage_collector.test.ts +++ b/packages/firestore/test/unit/local/lru_garbage_collector.test.ts @@ -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 { diff --git a/packages/firestore/test/unit/model/document.test.ts b/packages/firestore/test/unit/model/document.test.ts index f42b74a33fb..2c9cb11fedc 100644 --- a/packages/firestore/test/unit/model/document.test.ts +++ b/packages/firestore/test/unit/model/document.test.ts @@ -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 dfbbc58c1ea..50b883d4b58 100644 --- a/packages/firestore/test/unit/model/field_value.test.ts +++ b/packages/firestore/test/unit/model/field_value.test.ts @@ -15,31 +15,27 @@ * limitations under the License. */ +import * as api from '../../../src/protos/firestore_proto_api'; + import { expect } from 'chai'; -import { TypeOrder } from '../../../src/model/field_value'; -import { - ObjectValue, - PrimitiveValue -} from '../../../src/model/proto_field_value'; -import { field, mask } from '../../util/helpers'; -import { valueOf } from '../../util/values'; +import { ObjectValue, TypeOrder } from '../../../src/model/field_value'; +import { typeOrder } from '../../../src/model/values'; +import { wrap, wrapObject, field, mask } from '../../util/helpers'; describe('FieldValue', () => { it('can extract fields', () => { const objValue = wrapObject({ foo: { a: 1, b: true, c: 'string' } }); - expect(objValue.typeOrder).to.equal(TypeOrder.ObjectValue); - - expect(objValue.field(field('foo'))?.typeOrder).to.equal( + expect(typeOrder(objValue.field(field('foo'))!)).to.equal( TypeOrder.ObjectValue ); - expect(objValue.field(field('foo.a'))?.typeOrder).to.equal( + expect(typeOrder(objValue.field(field('foo.a'))!)).to.equal( TypeOrder.NumberValue ); - expect(objValue.field(field('foo.b'))?.typeOrder).to.equal( + expect(typeOrder(objValue.field(field('foo.b'))!)).to.equal( TypeOrder.BooleanValue ); - expect(objValue.field(field('foo.c'))?.typeOrder).to.equal( + expect(typeOrder(objValue.field(field('foo.c'))!)).to.equal( TypeOrder.StringValue ); @@ -47,34 +43,36 @@ describe('FieldValue', () => { 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' }); @@ -84,25 +82,25 @@ describe('FieldValue', () => { let objValue = ObjectValue.EMPTY; objValue = objValue .toBuilder() - .set(field('a'), valueOf('a')) + .set(field('a'), wrap('a')) .build(); objValue = objValue .toBuilder() - .set(field('b'), valueOf('b')) - .set(field('c'), valueOf('c')) + .set(field('b'), wrap('b')) + .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' } }); @@ -112,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' } }); }); @@ -134,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', () => { @@ -147,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', () => { @@ -158,21 +156,21 @@ describe('FieldValue', () => { objValue = objValue .toBuilder() - .set(field('a'), valueOf('a')) + .set(field('a'), wrap('a')) .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', () => { @@ -182,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', () => { @@ -201,7 +199,7 @@ describe('FieldValue', () => { .delete(field('c')) .build(); - expect(objValue.value()).to.deep.equal({}); + assertObjectEquals(objValue, {}); }); it('provides field mask', () => { @@ -225,11 +223,11 @@ describe('FieldValue', () => { function setField( objectValue: ObjectValue, fieldPath: string, - value: PrimitiveValue + value: api.Value ): ObjectValue { return objectValue .toBuilder() - .set(field(fieldPath), value.proto) + .set(field(fieldPath), value) .build(); } @@ -243,12 +241,10 @@ describe('FieldValue', () => { .build(); } - // TODO(mrschmidt): Clean up the helpers and merge wrap() with TestUtil.wrap() - function wrapObject(value: object): ObjectValue { - return new ObjectValue(valueOf(value)); - } - - function wrap(value: unknown): PrimitiveValue { - return new PrimitiveValue(valueOf(value)); + 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 fe2bf269e46..bb6ce40cfeb 100644 --- a/packages/firestore/test/unit/model/mutation.test.ts +++ b/packages/firestore/test/unit/model/mutation.test.ts @@ -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,7 +196,7 @@ 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), data, { hasLocalMutations: true @@ -385,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, @@ -496,7 +497,7 @@ describe('Mutation', () => { }); const mutationResult = new MutationResult(version(1), [ - new IntegerValue(3) + { integerValue: 3 } ]); const transformedDoc = transform.applyToRemoteDocument( baseDoc, @@ -662,7 +663,7 @@ describe('Mutation', () => { }; const transform = transformMutation('collection/key', allTransforms); - const expectedBaseValue = wrap({ + const expectedBaseValue = wrapObject({ double: 42.0, long: 42, text: 0, @@ -693,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 index 3955b9be26f..7b38ed873ad 100644 --- a/packages/firestore/test/unit/model/object_value_builder.test.ts +++ b/packages/firestore/test/unit/model/object_value_builder.test.ts @@ -17,9 +17,8 @@ import { expect } from 'chai'; -import { valueOf } from '../../util/values'; -import { ObjectValue } from '../../../src/model/proto_field_value'; -import { field } from '../../util/helpers'; +import { field, wrap, wrapObject } from '../../util/helpers'; +import { ObjectValue } from '../../../src/model/field_value'; describe('ObjectValueBuilder', () => { it('supports empty builders', () => { @@ -30,22 +29,22 @@ describe('ObjectValueBuilder', () => { it('sets single field', () => { const builder = ObjectValue.newBuilder(); - builder.set(field('foo'), valueOf('foo')); + 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'), valueOf({})); + 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'), valueOf('foo')); - builder.set(field('bar'), valueOf('bar')); + 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; @@ -53,8 +52,8 @@ describe('ObjectValueBuilder', () => { it('sets nested fields', () => { const builder = ObjectValue.newBuilder(); - builder.set(field('a.b'), valueOf('foo')); - builder.set(field('c.d.e'), valueOf('bar')); + builder.set(field('a.b'), wrap('foo')); + builder.set(field('c.d.e'), wrap('bar')); const object = builder.build(); expect( object.isEqual( @@ -65,8 +64,8 @@ describe('ObjectValueBuilder', () => { it('sets two fields in nested object', () => { const builder = ObjectValue.newBuilder(); - builder.set(field('a.b'), valueOf('foo')); - builder.set(field('a.c'), valueOf('bar')); + 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; @@ -74,8 +73,8 @@ describe('ObjectValueBuilder', () => { it('sets field in nested object', () => { const builder = ObjectValue.newBuilder(); - builder.set(field('a'), valueOf({ b: 'foo' })); - builder.set(field('a.c'), valueOf('bar')); + 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; @@ -83,7 +82,7 @@ describe('ObjectValueBuilder', () => { it('sets deeply nested field in nested object', () => { const builder = ObjectValue.newBuilder(); - builder.set(field('a.b.c.d.e.f'), valueOf('foo')); + builder.set(field('a.b.c.d.e.f'), wrap('foo')); const object = builder.build(); expect( object.isEqual( @@ -96,15 +95,15 @@ describe('ObjectValueBuilder', () => { it('sets nested field multiple times', () => { const builder = ObjectValue.newBuilder(); - builder.set(field('a.c'), valueOf('foo')); - builder.set(field('a'), valueOf({ b: 'foo' })); + 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'), valueOf('foo')); + builder.set(field('foo'), wrap('foo')); builder.delete(field('foo')); const object = builder.build(); expect(object.isEqual(ObjectValue.EMPTY)).to.be.true; @@ -112,10 +111,10 @@ describe('ObjectValueBuilder', () => { it('sets and deletes nested field', () => { const builder = ObjectValue.newBuilder(); - builder.set(field('a.b.c'), valueOf('foo')); - builder.set(field('a.b.d'), valueOf('foo')); - builder.set(field('f.g'), valueOf('foo')); - builder.set(field('h'), valueOf('foo')); + 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(); @@ -128,14 +127,14 @@ describe('ObjectValueBuilder', () => { it('sets single field in existing object', () => { const builder = wrapObject({ a: 'foo' }).toBuilder(); - builder.set(field('b'), valueOf('foo')); + 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'), valueOf('bar')); + builder.set(field('a'), wrap('bar')); const object = builder.build(); expect(object.isEqual(wrapObject({ a: 'bar' }))).to.be.true; }); @@ -144,8 +143,8 @@ describe('ObjectValueBuilder', () => { const builder = wrapObject({ a: { b: 'foo', c: { 'd': 'foo' } } }).toBuilder(); - builder.set(field('a.b'), valueOf('bar')); - builder.set(field('a.c.d'), valueOf('bar')); + 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; @@ -153,14 +152,14 @@ describe('ObjectValueBuilder', () => { it('overwrites deeply nested field', () => { const builder = wrapObject({ a: { b: 'foo' } }).toBuilder(); - builder.set(field('a.b.c'), valueOf('bar')); + 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'), valueOf('foo')); + builder.set(field('a.c'), wrap('foo')); const object = builder.build(); expect(object.isEqual(wrapObject({ a: { b: 'foo', c: 'foo' } }))).to.be .true; @@ -170,13 +169,13 @@ describe('ObjectValueBuilder', () => { const builder = wrapObject({ a: { b: { c: 'foo', d: 'foo' } } }).toBuilder(); - builder.set(field('a.b'), valueOf('bar')); + 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 = valueOf({ c: 'bar' }); + const singleValueObject = wrap({ c: 'bar' }); const builder = wrapObject({ a: { b: 'foo' } }).toBuilder(); builder.set(field('a'), singleValueObject); const object = builder.build(); @@ -212,9 +211,4 @@ describe('ObjectValueBuilder', () => { const object = builder.build(); expect(object.isEqual(wrapObject({ a: { b: 'foo' } }))).to.be.true; }); - - // TODO(mrschmidt): Clean up the helpers and merge wrap() with TestUtil.wrap() - function wrapObject(value: object): ObjectValue { - return new ObjectValue(valueOf(value)); - } }); diff --git a/packages/firestore/test/unit/model/values.test.ts b/packages/firestore/test/unit/model/values.test.ts index af0839eb52b..67be9a08123 100644 --- a/packages/firestore/test/unit/model/values.test.ts +++ b/packages/firestore/test/unit/model/values.test.ts @@ -21,15 +21,14 @@ import { expect } from 'chai'; import { Timestamp } from '../../../src/api/timestamp'; import { GeoPoint } from '../../../src/api/geo_point'; -import { DatabaseId } from '../../../src/core/database_info'; import { canonicalId, - compare, - equals, - estimateByteSize + valueCompare, + valueEquals, + estimateByteSize, + refValue } from '../../../src/model/values'; -import { DocumentKey } from '../../../src/model/document_key'; -import { PrimitiveValue } from '../../../src/model/proto_field_value'; +import { serverTimestamp } from '../../../src/model/server_timestamps'; import { primitiveComparator } from '../../../src/util/misc'; import { blob, @@ -37,9 +36,9 @@ import { expectCorrectComparisonGroups, expectEqualitySets, key, - ref + ref, + wrap } from '../../util/helpers'; -import { valueOf } from '../../util/values'; describe('Values', () => { const date1 = new Date(2016, 4, 2, 1, 5); @@ -69,13 +68,12 @@ describe('Values', () => { [wrap('\u00e9a')], [wrap(date1), wrap(Timestamp.fromDate(date1))], [wrap(date2)], - // TODO(mrschmidt): Support ServerTimestamps - // [ - // // NOTE: ServerTimestampValues can't be parsed via wrap(). - // serverTimestamp(Timestamp.fromDate(date1), null), - // serverTimestamp(Timestamp.fromDate(date1), null) - // ], - // [serverTimestamp(Timestamp.fromDate(date2), null)], + [ + // 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'))], @@ -88,7 +86,7 @@ describe('Values', () => { [wrap({ bar: 1, foo: 1 })], [wrap({ foo: 1 })] ]; - expectEqualitySets(values, (v1, v2) => equals(v1, v2)); + expectEqualitySets(values, (v1, v2) => valueEquals(v1, v2)); }); it('normalizes values for equality', () => { @@ -117,7 +115,7 @@ describe('Values', () => { ], [{ bytesValue: new Uint8Array([0, 1, 2]) }, { bytesValue: 'AAEC' }] ]; - expectEqualitySets(values, (v1, v2) => equals(v1, v2)); + expectEqualitySets(values, (v1, v2) => valueEquals(v1, v2)); }); it('orders types correctly', () => { @@ -151,11 +149,16 @@ describe('Values', () => { // 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' } + ], - // TODO(mrschmidt): Support ServerTimestamps - // // server timestamps come after all concrete timestamps. - // [serverTimestamp(Timestamp.fromDate(date1), null)], - // [serverTimestamp(Timestamp.fromDate(date2), null)], + // server timestamps come after all concrete timestamps. + [serverTimestamp(Timestamp.fromDate(date1), null)], + [serverTimestamp(Timestamp.fromDate(date2), null)], // strings [wrap('')], @@ -217,7 +220,7 @@ describe('Values', () => { expectCorrectComparisonGroups( groups, (left: api.Value, right: api.Value) => { - return compare(left, right); + return valueCompare(left, right); } ); }); @@ -259,7 +262,7 @@ describe('Values', () => { expectCorrectComparisonGroups( groups, (left: api.Value, right: api.Value) => { - return compare(left, right); + return valueCompare(left, right); } ); }); @@ -285,21 +288,20 @@ describe('Values', () => { expectedByteSize: 16, elements: [wrap(Timestamp.fromMillis(100)), wrap(Timestamp.now())] }, - // TODO(mrschmidt): Support ServerTimestamps - // { - // 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: 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: [ @@ -327,11 +329,10 @@ describe('Values', () => { // as the size of the underlying data grows. const relativeGroups: api.Value[][] = [ [wrap(blob(0)), wrap(blob(0, 1))], - // TODO(mrschmidt): Support ServerTimestamps - // [ - // serverTimestamp(Timestamp.fromMillis(100), null), - // serverTimestamp(Timestamp.now(), wrap(null)) - // ], + [ + 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')) @@ -406,15 +407,3 @@ describe('Values', () => { ).to.equal('{a:1,b:2,c:3}'); }); }); - -// TODO(mrschmidt): Clean up the helpers and merge wrap() with TestUtil.wrap() -function wrap(value: unknown): api.Value { - return new PrimitiveValue(valueOf(value)).proto; -} - -/** Creates a referenceValue Proto for `databaseId` and `key`. */ -export function refValue(databaseId: DatabaseId, key: DocumentKey): api.Value { - return { - referenceValue: `projects/${databaseId.projectId}/databases/${databaseId.database}/documents/${key}` - }; -} diff --git a/packages/firestore/test/unit/remote/remote_event.test.ts b/packages/firestore/test/unit/remote/remote_event.test.ts index e93ae0338ae..b40aa803faa 100644 --- a/packages/firestore/test/unit/remote/remote_event.test.ts +++ b/packages/firestore/test/unit/remote/remote_event.test.ts @@ -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 f8c773133ac..56cd334ef3f 100644 --- a/packages/firestore/test/unit/remote/serializer.test.ts +++ b/packages/firestore/test/unit/remote/serializer.test.ts @@ -17,9 +17,12 @@ import { expect } from 'chai'; +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, @@ -35,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, @@ -45,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 { @@ -70,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()) { @@ -91,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 @@ -115,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' }); @@ -155,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 }); @@ -174,7 +197,7 @@ describe('Serializer', () => { ]; for (const example of examples) { verifyFieldValueRoundTrip({ - value: new fieldValue.IntegerValue(example), + value: example, valueType: 'integerValue', jsonValue: '' + example }); @@ -184,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 = [ '', @@ -214,7 +259,7 @@ describe('Serializer', () => { ]; for (const example of examples) { verifyFieldValueRoundTrip({ - value: new fieldValue.StringValue(example), + value: example, valueType: 'stringValue', jsonValue: example }); @@ -228,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' }); }); @@ -307,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 = ByteString.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(ByteString.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' }] }; @@ -349,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: {} } }); @@ -379,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: { @@ -425,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; }); }); @@ -629,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 })] } } ] }, @@ -677,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); @@ -1377,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] @@ -1397,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..55efacc3edf 100644 --- a/packages/firestore/test/unit/specs/query_spec.test.ts +++ b/packages/firestore/test/unit/specs/query_spec.test.ts @@ -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..72d82a904a6 100644 --- a/packages/firestore/test/unit/specs/spec_builder.ts +++ b/packages/firestore/test/unit/specs/spec_builder.ts @@ -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/util/helpers.ts b/packages/firestore/test/util/helpers.ts index e91c10213c7..1226de4280d 100644 --- a/packages/firestore/test/util/helpers.ts +++ b/packages/firestore/test/util/helpers.ts @@ -16,11 +16,15 @@ */ 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, UserDataReader @@ -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 dataReader = new UserDataReader(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); @@ -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 dataReader.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 = dataReader.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 = dataReader.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 75ab52945c5..262ce2e8847 100644 --- a/packages/firestore/test/util/test_platform.ts +++ b/packages/firestore/test/util/test_platform.ts @@ -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 diff --git a/packages/firestore/test/util/values.ts b/packages/firestore/test/util/values.ts deleted file mode 100644 index 4f5c8d32798..00000000000 --- a/packages/firestore/test/util/values.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * @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 * as typeUtils from '../../src/util/types'; -import { Blob } from '../../src/api/blob'; -import { Timestamp } from '../../src/api/timestamp'; -import { GeoPoint } from '../../src/api/geo_point'; -import { DocumentKeyReference } from '../../src/api/user_data_reader'; -import { DatabaseId } from '../../src/core/database_info'; -import { DocumentKey } from '../../src/model/document_key'; -import { fail } from '../../src/util/assert'; -import { Dict, forEach } from '../../src/util/obj'; - -/** Test helper to create Firestore Value protos from JavaScript types. */ - -// TODO(mrschmidt): Move into UserDataReader -export function valueOf( - input: unknown, - useProto3Json: boolean = false -): api.Value { - if (input === null) { - return { nullValue: 'NULL_VALUE' }; - } else if (typeof input === 'number') { - if (typeUtils.isSafeInteger(input)) { - return { integerValue: input }; - } else { - if (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(input)) { - return { doubleValue: 'NaN' } as {}; - } else if (input === Infinity) { - return { doubleValue: 'Infinity' } as {}; - } else if (input === -Infinity) { - return { doubleValue: '-Infinity' } as {}; - } - } - return { doubleValue: input }; - } - } else if (typeof input === 'boolean') { - return { booleanValue: input }; - } else if (typeof input === 'string') { - return { stringValue: input }; - } else if (input instanceof Date) { - const timestamp = Timestamp.fromDate(input); - return { - timestampValue: { - seconds: String(timestamp.seconds), - nanos: timestamp.nanoseconds - } - }; - } else if (input instanceof Timestamp) { - return { - timestampValue: { - seconds: input.seconds, - nanos: input.nanoseconds - } - }; - } else if (input instanceof GeoPoint) { - return { - geoPointValue: { - latitude: input.latitude, - longitude: input.longitude - } - }; - } else if (input instanceof Blob) { - if (useProto3Json) { - return { bytesValue: input._byteString.toBase64() }; - } else { - return { bytesValue: input._byteString.toUint8Array() }; - } - } else if (input instanceof DocumentKeyReference) { - return { - referenceValue: - 'projects/project/databases/(default)/documents/' + input.key.path - }; - } else if (Array.isArray(input)) { - return { - arrayValue: { values: input.map(el => valueOf(el, useProto3Json)) } - }; - } else if (typeof input === 'object') { - const result: api.Value = { mapValue: { fields: {} } }; - forEach(input as Dict, (key: string, val: unknown) => { - result.mapValue!.fields![key] = valueOf(val, useProto3Json); - }); - return result; - } else { - fail(`Failed to serialize field: ${input}`); - } -} - -/** Creates a MapValue from a list of key/value arguments. */ -export function mapOf(...entries: unknown[]): api.Value { - const result: api.Value = { mapValue: { fields: {} } }; - for (let i = 0; i < entries.length; i += 2) { - result.mapValue!.fields![entries[i] as string] = valueOf( - entries[i + 1], - /* useProto3Json= */ false - ); - } - return result; -} - -export function refValue(dbId: DatabaseId, key: DocumentKey): api.Value { - return { - referenceValue: `projects/${dbId.projectId}/databases/${dbId.database}/documents/${key.path}` - }; -}