|
14 | 14 |
|
15 | 15 | package com.google.firebase.firestore;
|
16 | 16 |
|
| 17 | +import static com.google.common.truth.Truth.assertThat; |
17 | 18 | import static com.google.common.truth.Truth.assertWithMessage;
|
18 | 19 | import static com.google.firebase.firestore.remote.TestingHooksUtil.captureExistenceFilterMismatches;
|
19 | 20 | import static com.google.firebase.firestore.testutil.IntegrationTestUtil.isRunningAgainstEmulator;
|
|
32 | 33 | import static org.junit.Assert.assertFalse;
|
33 | 34 | import static org.junit.Assert.assertNull;
|
34 | 35 | import static org.junit.Assert.assertTrue;
|
| 36 | +import static org.junit.Assume.assumeFalse; |
35 | 37 | import static org.junit.Assume.assumeTrue;
|
36 | 38 |
|
37 | 39 | import androidx.test.ext.junit.runners.AndroidJUnit4;
|
|
42 | 44 | import com.google.firebase.firestore.remote.TestingHooksUtil.ExistenceFilterMismatchInfo;
|
43 | 45 | import com.google.firebase.firestore.testutil.EventAccumulator;
|
44 | 46 | import com.google.firebase.firestore.testutil.IntegrationTestUtil;
|
| 47 | +import java.text.Normalizer; |
45 | 48 | import java.util.ArrayList;
|
46 | 49 | import java.util.HashMap;
|
47 | 50 | import java.util.HashSet;
|
@@ -1169,6 +1172,160 @@ public void resumingAQueryShouldUseBloomFilterToAvoidFullRequery() throws Except
|
1169 | 1172 | }
|
1170 | 1173 | }
|
1171 | 1174 |
|
| 1175 | + private static String unicodeNormalize(String s) { |
| 1176 | + return Normalizer.normalize(s, Normalizer.Form.NFC); |
| 1177 | + } |
| 1178 | + |
| 1179 | + @Test |
| 1180 | + public void bloomFilterShouldCorrectlyEncodeComplexUnicodeCharacters() throws Exception { |
| 1181 | + assumeFalse( |
| 1182 | + "Skip this test when running against the Firestore emulator because the Firestore emulator " |
| 1183 | + + "fails to send existence filters when queries are resumed (b/270731363), and even " |
| 1184 | + + "if it did send an existence filter it probably wouldn't include a bloom filter.", |
| 1185 | + isRunningAgainstEmulator()); |
| 1186 | + |
| 1187 | + // Firestore does not do any Unicode normalization on the document IDs. Therefore, two document |
| 1188 | + // IDs that are canonically-equivalent (i.e. they visually appear identical) but are represented |
| 1189 | + // by a different sequence of Unicode code points are treated as distinct document IDs. |
| 1190 | + ArrayList<String> testDocIds = new ArrayList<>(); |
| 1191 | + testDocIds.add("DocumentToDelete"); |
| 1192 | + // The next two strings both end with "e" with an accent: the first uses the dedicated Unicode |
| 1193 | + // code point for this character, while the second uses the standard lowercase "e" followed by |
| 1194 | + // the accent combining character. |
| 1195 | + testDocIds.add("LowercaseEWithAcuteAccent_\u00E9"); |
| 1196 | + testDocIds.add("LowercaseEWithAcuteAccent_\u0065\u0301"); |
| 1197 | + // The next two strings both end with an "e" with two different accents applied via the |
| 1198 | + // following two combining characters. The combining characters are specified in a different |
| 1199 | + // order and Firestore treats these document IDs as unique, despite the order of the combining |
| 1200 | + // characters being irrelevant. |
| 1201 | + testDocIds.add("LowercaseEWithMultipleAccents_\u0065\u0301\u0327"); |
| 1202 | + testDocIds.add("LowercaseEWithMultipleAccents_\u0065\u0327\u0301"); |
| 1203 | + // The next string contains a character outside the BMP (the "basic multilingual plane"); that |
| 1204 | + // is, its code point is greater than 0xFFFF. Since "The Java programming language represents |
| 1205 | + // text in sequences of 16-bit code units, using the UTF-16 encoding" (according to the "Java |
| 1206 | + // Language Specification" at https://docs.oracle.com/javase/specs/jls/se11/html/index.html) |
| 1207 | + // this requires a surrogate pair, two 16-bit code units, to represent this character. Make sure |
| 1208 | + // that its presence is correctly tested in the bloom filter, which uses UTF-8 encoding. |
| 1209 | + testDocIds.add("Smiley_\uD83D\uDE00"); |
| 1210 | + |
| 1211 | + // Verify assumptions about the equivalence of strings in `testDocIds`. |
| 1212 | + assertThat(unicodeNormalize(testDocIds.get(1))).isEqualTo(unicodeNormalize(testDocIds.get(2))); |
| 1213 | + assertThat(unicodeNormalize(testDocIds.get(3))).isEqualTo(unicodeNormalize(testDocIds.get(4))); |
| 1214 | + assertThat(testDocIds.get(5).codePointAt(7)).isEqualTo(0x1F600); |
| 1215 | + |
| 1216 | + // Create the mapping from document ID to document data for the document IDs specified in |
| 1217 | + // `testDocIds`. |
| 1218 | + Map<String, Map<String, Object>> testDocs = new HashMap<>(); |
| 1219 | + for (String docId : testDocIds) { |
| 1220 | + testDocs.put(docId, map("foo", 42)); |
| 1221 | + } |
| 1222 | + |
| 1223 | + // Each iteration of the "while" loop below runs a single iteration of the test. The test will |
| 1224 | + // be run multiple times only if a bloom filter false positive occurs. |
| 1225 | + int attemptNumber = 0; |
| 1226 | + while (true) { |
| 1227 | + attemptNumber++; |
| 1228 | + |
| 1229 | + // Create the documents whose names contain complex Unicode characters in a new collection. |
| 1230 | + CollectionReference collection = testCollectionWithDocs(testDocs); |
| 1231 | + |
| 1232 | + // Run a query to populate the local cache with documents that have names with complex Unicode |
| 1233 | + // characters. |
| 1234 | + List<DocumentReference> createdDocuments = new ArrayList<>(); |
| 1235 | + { |
| 1236 | + QuerySnapshot querySnapshot1 = waitFor(collection.get()); |
| 1237 | + for (DocumentSnapshot documentSnapshot : querySnapshot1.getDocuments()) { |
| 1238 | + createdDocuments.add(documentSnapshot.getReference()); |
| 1239 | + } |
| 1240 | + HashSet<String> createdDocumentIds = new HashSet<>(); |
| 1241 | + for (DocumentSnapshot documentSnapshot : querySnapshot1.getDocuments()) { |
| 1242 | + createdDocumentIds.add(documentSnapshot.getId()); |
| 1243 | + } |
| 1244 | + assertWithMessage("createdDocumentIds") |
| 1245 | + .that(createdDocumentIds) |
| 1246 | + .containsExactlyElementsIn(testDocIds); |
| 1247 | + } |
| 1248 | + |
| 1249 | + // Delete one of the documents so that the next call to getDocs() will |
| 1250 | + // experience an existence filter mismatch. Do this deletion in a |
| 1251 | + // transaction, rather than using deleteDoc(), to avoid affecting the |
| 1252 | + // local cache. |
| 1253 | + waitFor( |
| 1254 | + collection |
| 1255 | + .getFirestore() |
| 1256 | + .runTransaction( |
| 1257 | + transaction -> { |
| 1258 | + DocumentReference documentToDelete = collection.document("DocumentToDelete"); |
| 1259 | + DocumentSnapshot documentToDeleteSnapshot = transaction.get(documentToDelete); |
| 1260 | + assertWithMessage("documentToDeleteSnapshot.exists()") |
| 1261 | + .that(documentToDeleteSnapshot.exists()) |
| 1262 | + .isTrue(); |
| 1263 | + transaction.delete(documentToDelete); |
| 1264 | + return null; |
| 1265 | + })); |
| 1266 | + |
| 1267 | + // Wait for 10 seconds, during which Watch will stop tracking the query and will send an |
| 1268 | + // existence filter rather than "delete" events when the query is resumed. |
| 1269 | + Thread.sleep(10000); |
| 1270 | + |
| 1271 | + // Resume the query and save the resulting snapshot for verification. Use some internal |
| 1272 | + // testing hooks to "capture" the existence filter mismatches. |
| 1273 | + AtomicReference<QuerySnapshot> querySnapshot2Ref = new AtomicReference<>(); |
| 1274 | + ArrayList<ExistenceFilterMismatchInfo> existenceFilterMismatches = |
| 1275 | + captureExistenceFilterMismatches( |
| 1276 | + () -> { |
| 1277 | + QuerySnapshot querySnapshot = waitFor(collection.get()); |
| 1278 | + querySnapshot2Ref.set(querySnapshot); |
| 1279 | + }); |
| 1280 | + QuerySnapshot querySnapshot2 = querySnapshot2Ref.get(); |
| 1281 | + |
| 1282 | + // Verify that the snapshot from the resumed query contains the expected documents; that is, |
| 1283 | + // that it contains the documents whose names contain complex Unicode characters and _not_ the |
| 1284 | + // document that was deleted. |
| 1285 | + HashSet<String> querySnapshot2DocumentIds = new HashSet<>(); |
| 1286 | + for (DocumentSnapshot documentSnapshot : querySnapshot2.getDocuments()) { |
| 1287 | + querySnapshot2DocumentIds.add(documentSnapshot.getId()); |
| 1288 | + } |
| 1289 | + HashSet<String> querySnapshot2ExpectedDocumentIds = new HashSet<>(testDocIds); |
| 1290 | + querySnapshot2ExpectedDocumentIds.remove("DocumentToDelete"); |
| 1291 | + assertWithMessage("querySnapshot2DocumentIds") |
| 1292 | + .that(querySnapshot2DocumentIds) |
| 1293 | + .containsExactlyElementsIn(querySnapshot2ExpectedDocumentIds); |
| 1294 | + |
| 1295 | + // Verify that Watch sent an existence filter with the correct counts. |
| 1296 | + assertWithMessage("Watch should have sent exactly 1 existence filter") |
| 1297 | + .that(existenceFilterMismatches) |
| 1298 | + .hasSize(1); |
| 1299 | + ExistenceFilterMismatchInfo existenceFilterMismatchInfo = existenceFilterMismatches.get(0); |
| 1300 | + assertWithMessage("localCacheCount") |
| 1301 | + .that(existenceFilterMismatchInfo.localCacheCount()) |
| 1302 | + .isEqualTo(testDocIds.size()); |
| 1303 | + assertWithMessage("existenceFilterCount") |
| 1304 | + .that(existenceFilterMismatchInfo.existenceFilterCount()) |
| 1305 | + .isEqualTo(testDocIds.size() - 1); |
| 1306 | + |
| 1307 | + // Verify that Watch sent a valid bloom filter. |
| 1308 | + ExistenceFilterBloomFilterInfo bloomFilter = existenceFilterMismatchInfo.bloomFilter(); |
| 1309 | + assertWithMessage("The bloom filter specified in the existence filter") |
| 1310 | + .that(bloomFilter) |
| 1311 | + .isNotNull(); |
| 1312 | + |
| 1313 | + // Verify that the bloom filter was successfully used to avert a full requery. If a false |
| 1314 | + // positive occurred, which is statistically rare, but technically possible, then retry the |
| 1315 | + // entire test. |
| 1316 | + if (attemptNumber == 1 && !bloomFilter.applied()) { |
| 1317 | + continue; |
| 1318 | + } |
| 1319 | + |
| 1320 | + assertWithMessage("bloom filter successfully applied with attemptNumber=" + attemptNumber) |
| 1321 | + .that(bloomFilter.applied()) |
| 1322 | + .isTrue(); |
| 1323 | + |
| 1324 | + // Break out of the test loop now that the test passes. |
| 1325 | + break; |
| 1326 | + } |
| 1327 | + } |
| 1328 | + |
1172 | 1329 | @Test
|
1173 | 1330 | public void testOrQueries() {
|
1174 | 1331 | Map<String, Map<String, Object>> testDocs =
|
|
0 commit comments