diff --git a/packages/firestore/src/model/document.ts b/packages/firestore/src/model/document.ts index 747f8bf49ef..5fe125d0eaa 100644 --- a/packages/firestore/src/model/document.ts +++ b/packages/firestore/src/model/document.ts @@ -93,7 +93,9 @@ export class Document extends MaybeDocument { toString(): string { return ( - `Document(${this.key}, ${this.version}, ${this.objectValue.toString()}, ` + + `Document(${this.key}, ${ + this.version + }, ${this.objectValue.toString()}, ` + `{hasLocalMutations: ${this.hasLocalMutations}}), ` + `{hasCommittedMutations: ${this.hasCommittedMutations}})` ); diff --git a/packages/firestore/src/model/proto_values.ts b/packages/firestore/src/model/proto_values.ts index 7d0d381deeb..89db3a32883 100644 --- a/packages/firestore/src/model/proto_values.ts +++ b/packages/firestore/src/model/proto_values.ts @@ -335,6 +335,88 @@ function compareMaps(left: api.MapValue, right: api.MapValue): number { return primitiveComparator(leftKeys.length, rightKeys.length); } +/** + * Generates the canonical ID for the provided field value (as used in Target + * serialization). + */ +export function canonicalId(value: api.Value): string { + return canonifyValue(value); +} + +function canonifyValue(value: api.Value): string { + if ('nullValue' in value) { + return 'null'; + } else if ('booleanValue' in value) { + return '' + value.booleanValue!; + } else if ('integerValue' in value) { + return '' + value.integerValue!; + } else if ('doubleValue' in value) { + return '' + value.doubleValue!; + } else if ('timestampValue' in value) { + return canonifyTimestamp(value.timestampValue!); + } else if ('stringValue' in value) { + return value.stringValue!; + } else if ('bytesValue' in value) { + return canonifyByteString(value.bytesValue!); + } else if ('referenceValue' in value) { + // TODO(mrschmidt): Use document key only + return value.referenceValue!; + } else if ('geoPointValue' in value) { + return canonifyGeoPoint(value.geoPointValue!); + } else if ('arrayValue' in value) { + return canonifyArray(value.arrayValue!); + } else if ('mapValue' in value) { + return canonifyMap(value.mapValue!); + } else { + return fail('Invalid value type: ' + JSON.stringify(value)); + } +} + +function canonifyByteString(byteString: string | Uint8Array): string { + return normalizeByteString(byteString).toBase64(); +} + +function canonifyTimestamp(timestamp: ProtoTimestampValue): string { + const normalizedTimestamp = normalizeTimestamp(timestamp); + return `time(${normalizedTimestamp.seconds},${normalizedTimestamp.nanos})`; +} + +function canonifyGeoPoint(geoPoint: api.LatLng): string { + return `geo(${geoPoint.latitude},${geoPoint.longitude})`; +} + +function canonifyMap(mapValue: api.MapValue): string { + // Iteration order in JavaScript is not guaranteed. To ensure that we generate + // matching canonical IDs for identical maps, we need to sort the keys. + const sortedKeys = keys(mapValue.fields || {}).sort(); + + let result = '{'; + let first = true; + for (const key of sortedKeys) { + if (!first) { + result += ','; + } else { + first = false; + } + result += `${key}:${canonifyValue(mapValue.fields![key])}`; + } + return result + '}'; +} + +function canonifyArray(arrayValue: api.ArrayValue): string { + let result = '['; + let first = true; + for (const value of arrayValue.values || []) { + if (!first) { + result += ','; + } else { + first = false; + } + result += canonifyValue(value); + } + return result + ']'; +} + /** * Converts the possible Proto values for a timestamp value into a "seconds and * nanos" representation. diff --git a/packages/firestore/test/unit/model/field_value.test.ts b/packages/firestore/test/unit/model/field_value.test.ts index b11f702cb88..2818b35bf44 100644 --- a/packages/firestore/test/unit/model/field_value.test.ts +++ b/packages/firestore/test/unit/model/field_value.test.ts @@ -29,6 +29,7 @@ import { ObjectValue, PrimitiveValue } from '../../../src/model/proto_field_value'; +import { canonicalId } from '../../../src/model/proto_values'; import { ByteString } from '../../../src/util/byte_string'; import { primitiveComparator } from '../../../src/util/misc'; import * as typeUtils from '../../../src/util/types'; @@ -720,6 +721,59 @@ describe('FieldValue', () => { } }); + it('canonicalizes values', () => { + expect(canonicalId(wrap(null).proto)).to.equal('null'); + expect(canonicalId(wrap(true).proto)).to.equal('true'); + expect(canonicalId(wrap(false).proto)).to.equal('false'); + expect(canonicalId(wrap(1).proto)).to.equal('1'); + expect(canonicalId(wrap(1.1).proto)).to.equal('1.1'); + expect(canonicalId(wrap(new Timestamp(30, 60)).proto)).to.equal( + 'time(30,60)' + ); + expect(canonicalId(wrap('a').proto)).to.equal('a'); + expect(canonicalId(wrap(blob(1, 2, 3)).proto)).to.equal('AQID'); + expect( + canonicalId(wrapRef(dbId('p1', 'd1'), key('c1/doc1')).proto) + ).to.equal('projects/p1/databases/d1/documents/c1/doc1'); + expect(canonicalId(wrap(new GeoPoint(30, 60)).proto)).to.equal( + 'geo(30,60)' + ); + expect(canonicalId(wrap([1, 2, 3]).proto)).to.equal('[1,2,3]'); + expect( + canonicalId( + wrap({ + 'a': 1, + 'b': 2, + 'c': '3' + }).proto + ) + ).to.equal('{a:1,b:2,c:3}'); + expect( + canonicalId(wrap({ 'a': ['b', { 'c': new GeoPoint(30, 60) }] }).proto) + ).to.equal('{a:[b,{c:geo(30,60)}]}'); + }); + + it('canonical IDs ignore sort order', () => { + expect( + canonicalId( + wrap({ + 'a': 1, + 'b': 2, + 'c': '3' + }).proto + ) + ).to.equal('{a:1,b:2,c:3}'); + expect( + canonicalId( + wrap({ + 'c': 3, + 'b': 2, + 'a': '1' + }).proto + ) + ).to.equal('{a:1,b:2,c:3}'); + }); + function setField( objectValue: ObjectValue, fieldPath: string,