Skip to content

Commit 346af4c

Browse files
Add explicit FieldValue canonicalization (#2687)
1 parent 4083821 commit 346af4c

File tree

3 files changed

+139
-1
lines changed

3 files changed

+139
-1
lines changed

packages/firestore/src/model/document.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,9 @@ export class Document extends MaybeDocument {
9393

9494
toString(): string {
9595
return (
96-
`Document(${this.key}, ${this.version}, ${this.objectValue.toString()}, ` +
96+
`Document(${this.key}, ${
97+
this.version
98+
}, ${this.objectValue.toString()}, ` +
9799
`{hasLocalMutations: ${this.hasLocalMutations}}), ` +
98100
`{hasCommittedMutations: ${this.hasCommittedMutations}})`
99101
);

packages/firestore/src/model/proto_values.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,88 @@ function compareMaps(left: api.MapValue, right: api.MapValue): number {
335335
return primitiveComparator(leftKeys.length, rightKeys.length);
336336
}
337337

338+
/**
339+
* Generates the canonical ID for the provided field value (as used in Target
340+
* serialization).
341+
*/
342+
export function canonicalId(value: api.Value): string {
343+
return canonifyValue(value);
344+
}
345+
346+
function canonifyValue(value: api.Value): string {
347+
if ('nullValue' in value) {
348+
return 'null';
349+
} else if ('booleanValue' in value) {
350+
return '' + value.booleanValue!;
351+
} else if ('integerValue' in value) {
352+
return '' + value.integerValue!;
353+
} else if ('doubleValue' in value) {
354+
return '' + value.doubleValue!;
355+
} else if ('timestampValue' in value) {
356+
return canonifyTimestamp(value.timestampValue!);
357+
} else if ('stringValue' in value) {
358+
return value.stringValue!;
359+
} else if ('bytesValue' in value) {
360+
return canonifyByteString(value.bytesValue!);
361+
} else if ('referenceValue' in value) {
362+
// TODO(mrschmidt): Use document key only
363+
return value.referenceValue!;
364+
} else if ('geoPointValue' in value) {
365+
return canonifyGeoPoint(value.geoPointValue!);
366+
} else if ('arrayValue' in value) {
367+
return canonifyArray(value.arrayValue!);
368+
} else if ('mapValue' in value) {
369+
return canonifyMap(value.mapValue!);
370+
} else {
371+
return fail('Invalid value type: ' + JSON.stringify(value));
372+
}
373+
}
374+
375+
function canonifyByteString(byteString: string | Uint8Array): string {
376+
return normalizeByteString(byteString).toBase64();
377+
}
378+
379+
function canonifyTimestamp(timestamp: ProtoTimestampValue): string {
380+
const normalizedTimestamp = normalizeTimestamp(timestamp);
381+
return `time(${normalizedTimestamp.seconds},${normalizedTimestamp.nanos})`;
382+
}
383+
384+
function canonifyGeoPoint(geoPoint: api.LatLng): string {
385+
return `geo(${geoPoint.latitude},${geoPoint.longitude})`;
386+
}
387+
388+
function canonifyMap(mapValue: api.MapValue): string {
389+
// Iteration order in JavaScript is not guaranteed. To ensure that we generate
390+
// matching canonical IDs for identical maps, we need to sort the keys.
391+
const sortedKeys = keys(mapValue.fields || {}).sort();
392+
393+
let result = '{';
394+
let first = true;
395+
for (const key of sortedKeys) {
396+
if (!first) {
397+
result += ',';
398+
} else {
399+
first = false;
400+
}
401+
result += `${key}:${canonifyValue(mapValue.fields![key])}`;
402+
}
403+
return result + '}';
404+
}
405+
406+
function canonifyArray(arrayValue: api.ArrayValue): string {
407+
let result = '[';
408+
let first = true;
409+
for (const value of arrayValue.values || []) {
410+
if (!first) {
411+
result += ',';
412+
} else {
413+
first = false;
414+
}
415+
result += canonifyValue(value);
416+
}
417+
return result + ']';
418+
}
419+
338420
/**
339421
* Converts the possible Proto values for a timestamp value into a "seconds and
340422
* nanos" representation.

packages/firestore/test/unit/model/field_value.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
ObjectValue,
3030
PrimitiveValue
3131
} from '../../../src/model/proto_field_value';
32+
import { canonicalId } from '../../../src/model/proto_values';
3233
import { ByteString } from '../../../src/util/byte_string';
3334
import { primitiveComparator } from '../../../src/util/misc';
3435
import * as typeUtils from '../../../src/util/types';
@@ -720,6 +721,59 @@ describe('FieldValue', () => {
720721
}
721722
});
722723

724+
it('canonicalizes values', () => {
725+
expect(canonicalId(wrap(null).proto)).to.equal('null');
726+
expect(canonicalId(wrap(true).proto)).to.equal('true');
727+
expect(canonicalId(wrap(false).proto)).to.equal('false');
728+
expect(canonicalId(wrap(1).proto)).to.equal('1');
729+
expect(canonicalId(wrap(1.1).proto)).to.equal('1.1');
730+
expect(canonicalId(wrap(new Timestamp(30, 60)).proto)).to.equal(
731+
'time(30,60)'
732+
);
733+
expect(canonicalId(wrap('a').proto)).to.equal('a');
734+
expect(canonicalId(wrap(blob(1, 2, 3)).proto)).to.equal('AQID');
735+
expect(
736+
canonicalId(wrapRef(dbId('p1', 'd1'), key('c1/doc1')).proto)
737+
).to.equal('projects/p1/databases/d1/documents/c1/doc1');
738+
expect(canonicalId(wrap(new GeoPoint(30, 60)).proto)).to.equal(
739+
'geo(30,60)'
740+
);
741+
expect(canonicalId(wrap([1, 2, 3]).proto)).to.equal('[1,2,3]');
742+
expect(
743+
canonicalId(
744+
wrap({
745+
'a': 1,
746+
'b': 2,
747+
'c': '3'
748+
}).proto
749+
)
750+
).to.equal('{a:1,b:2,c:3}');
751+
expect(
752+
canonicalId(wrap({ 'a': ['b', { 'c': new GeoPoint(30, 60) }] }).proto)
753+
).to.equal('{a:[b,{c:geo(30,60)}]}');
754+
});
755+
756+
it('canonical IDs ignore sort order', () => {
757+
expect(
758+
canonicalId(
759+
wrap({
760+
'a': 1,
761+
'b': 2,
762+
'c': '3'
763+
}).proto
764+
)
765+
).to.equal('{a:1,b:2,c:3}');
766+
expect(
767+
canonicalId(
768+
wrap({
769+
'c': 3,
770+
'b': 2,
771+
'a': '1'
772+
}).proto
773+
)
774+
).to.equal('{a:1,b:2,c:3}');
775+
});
776+
723777
function setField(
724778
objectValue: ObjectValue,
725779
fieldPath: string,

0 commit comments

Comments
 (0)