Skip to content

Commit e4b4850

Browse files
Add explicit FieldValue canonicalization (#1178)
1 parent c6b9d88 commit e4b4850

File tree

2 files changed

+122
-0
lines changed

2 files changed

+122
-0
lines changed

firebase-firestore/src/main/java/com/google/firebase/firestore/model/value/ProtoValues.java

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
import com.google.firestore.v1.Value;
2525
import com.google.protobuf.Timestamp;
2626
import com.google.type.LatLng;
27+
import java.util.ArrayList;
28+
import java.util.Collections;
2729
import java.util.Iterator;
2830
import java.util.List;
2931
import java.util.Map;
@@ -249,4 +251,93 @@ private static int compareMaps(MapValue left, MapValue right) {
249251
// Only equal if both iterators are exhausted.
250252
return Util.compareBooleans(iterator1.hasNext(), iterator2.hasNext());
251253
}
254+
255+
/** Generate the canonical ID for the provided field value (as used in Target serialization). */
256+
public static String canonicalId(Value value) {
257+
StringBuilder builder = new StringBuilder();
258+
canonifyValue(builder, value);
259+
return builder.toString();
260+
}
261+
262+
// TODO(mrschmidt): Use in target serialization and migrate all existing TargetData
263+
private static void canonifyValue(StringBuilder builder, Value value) {
264+
switch (value.getValueTypeCase()) {
265+
case NULL_VALUE:
266+
builder.append("null");
267+
break;
268+
case BOOLEAN_VALUE:
269+
builder.append(value.getBooleanValue());
270+
break;
271+
case INTEGER_VALUE:
272+
builder.append(value.getIntegerValue());
273+
break;
274+
case DOUBLE_VALUE:
275+
builder.append(value.getDoubleValue());
276+
break;
277+
case TIMESTAMP_VALUE:
278+
canonifyTimestamp(builder, value.getTimestampValue());
279+
break;
280+
case STRING_VALUE:
281+
builder.append(value.getStringValue());
282+
break;
283+
case BYTES_VALUE:
284+
builder.append(Util.toDebugString(value.getBytesValue()));
285+
break;
286+
case REFERENCE_VALUE:
287+
// TODO(mrschmidt): Use document key only
288+
builder.append(value.getReferenceValue());
289+
break;
290+
case GEO_POINT_VALUE:
291+
canonifyGeoPoint(builder, value.getGeoPointValue());
292+
break;
293+
case ARRAY_VALUE:
294+
canonifyArray(builder, value.getArrayValue());
295+
break;
296+
case MAP_VALUE:
297+
canonifyObject(builder, value.getMapValue());
298+
break;
299+
default:
300+
throw fail("Invalid value type: " + value.getValueTypeCase());
301+
}
302+
}
303+
304+
private static void canonifyTimestamp(StringBuilder builder, Timestamp timestamp) {
305+
builder.append(String.format("time(%s,%s)", timestamp.getSeconds(), timestamp.getNanos()));
306+
}
307+
308+
private static void canonifyGeoPoint(StringBuilder builder, LatLng latLng) {
309+
builder.append(String.format("geo(%s,%s)", latLng.getLatitude(), latLng.getLongitude()));
310+
}
311+
312+
private static void canonifyObject(StringBuilder builder, MapValue mapValue) {
313+
// Even though MapValue are likely sorted correctly based on their insertion order (e.g. when
314+
// received from the backend), local modifications can bring elements out of order. We need to
315+
// re-sort the elements to ensure that canonical IDs are independent of insertion order.
316+
List<String> keys = new ArrayList<>(mapValue.getFieldsMap().keySet());
317+
Collections.sort(keys);
318+
319+
builder.append("{");
320+
boolean first = true;
321+
for (String key : keys) {
322+
if (!first) {
323+
builder.append(",");
324+
} else {
325+
first = false;
326+
}
327+
builder.append(key).append(":");
328+
canonifyValue(builder, mapValue.getFieldsOrThrow(key));
329+
}
330+
builder.append("}");
331+
}
332+
333+
private static void canonifyArray(StringBuilder builder, ArrayValue arrayValue) {
334+
builder.append("[");
335+
for (int i = 0; i < arrayValue.getValuesCount(); ++i) {
336+
canonifyValue(builder, arrayValue.getValues(i));
337+
if (i != arrayValue.getValuesCount() - 1) {
338+
builder.append(",");
339+
}
340+
}
341+
builder.append("]");
342+
}
252343
}

firebase-firestore/src/test/java/com/google/firebase/firestore/model/FieldValueTest.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import com.google.firebase.firestore.model.protovalue.ObjectValue;
3636
import com.google.firebase.firestore.model.protovalue.PrimitiveValue;
3737
import com.google.firebase.firestore.model.value.FieldValue;
38+
import com.google.firebase.firestore.model.value.ProtoValues;
3839
import com.google.firebase.firestore.model.value.ServerTimestampValue;
3940
import com.google.firebase.firestore.testutil.ComparatorTester;
4041
import com.google.firestore.v1.Value;
@@ -366,6 +367,36 @@ public void testValueOrdering() {
366367
.testCompare();
367368
}
368369

370+
@Test
371+
public void testCanonicalIds() {
372+
assertCanonicalId(wrap(null), "null");
373+
assertCanonicalId(wrap(true), "true");
374+
assertCanonicalId(wrap(false), "false");
375+
assertCanonicalId(wrap(1), "1");
376+
assertCanonicalId(wrap(1.0), "1.0");
377+
assertCanonicalId(wrap(new Timestamp(30, 60)), "time(30,60)");
378+
assertCanonicalId(wrap("a"), "a");
379+
assertCanonicalId(wrap(blob(1, 2, 3)), "010203");
380+
assertCanonicalId(
381+
wrapRef(dbId("p1", "d1"), key("c1/doc1")), "projects/p1/databases/d1/documents/c1/doc1");
382+
assertCanonicalId(wrap(new GeoPoint(30, 60)), "geo(30.0,60.0)");
383+
assertCanonicalId(wrap(Arrays.asList(1, 2, 3)), "[1,2,3]");
384+
assertCanonicalId(wrap(map("a", 1, "b", 2, "c", "3")), "{a:1,b:2,c:3}");
385+
assertCanonicalId(
386+
wrap(map("a", Arrays.asList("b", map("c", new GeoPoint(30, 60))))),
387+
"{a:[b,{c:geo(30.0,60.0)}]}");
388+
}
389+
390+
@Test
391+
public void testObjectCanonicalIdsIgnoreSortOrder() {
392+
assertCanonicalId(wrap(map("a", 1, "b", 2, "c", "3")), "{a:1,b:2,c:3}");
393+
assertCanonicalId(wrap(map("c", 3, "b", 2, "a", "1")), "{a:1,b:2,c:3}");
394+
}
395+
396+
private void assertCanonicalId(PrimitiveValue fieldValue, String expectedCanonicalId) {
397+
assertEquals(expectedCanonicalId, ProtoValues.canonicalId(fieldValue.toProto()));
398+
}
399+
369400
private ObjectValue setField(ObjectValue objectValue, String fieldPath, PrimitiveValue value) {
370401
return objectValue.toBuilder().set(field(fieldPath), value.toProto()).build();
371402
}

0 commit comments

Comments
 (0)