Skip to content

Commit 0e3f78b

Browse files
authored
Firestore: FIRQueryTests.mm: Add test testBloomFilterShouldCorrectlyEncodeComplexUnicodeCharacters (#11679)
1 parent 2eccf79 commit 0e3f78b

File tree

7 files changed

+361
-58
lines changed

7 files changed

+361
-58
lines changed

Firestore/Example/Tests/Integration/API/FIRQueryTests.mm

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,4 +1285,144 @@ - (void)testResumingAQueryShouldUseBloomFilterToAvoidFullRequery {
12851285
}
12861286
}
12871287

1288+
- (void)testBloomFilterShouldCorrectlyEncodeComplexUnicodeCharacters {
1289+
// TODO(b/291365820): Stop skipping this test when running against the Firestore emulator once
1290+
// the emulator is improved to include a bloom filter in the existence filter messages that it
1291+
// sends.
1292+
XCTSkipIf([FSTIntegrationTestCase isRunningAgainstEmulator],
1293+
"Skip this test when running against the Firestore emulator because the emulator does "
1294+
"not include a bloom filter when it sends existence filter messages, making it "
1295+
"impossible for this test to verify the correctness of the bloom filter.");
1296+
1297+
// Set this test to stop when the first failure occurs because some test assertion failures make
1298+
// the rest of the test not applicable or will even crash.
1299+
[self setContinueAfterFailure:NO];
1300+
1301+
// Define a comparator that compares `NSString` objects in a way that orders canonically-
1302+
// equivalent, but distinct, strings in a consistent manner by using `NSForcedOrderingSearch`.
1303+
// Otherwise, the bare `[NSString compare:]` method considers canonically-equivalent, but
1304+
// distinct, strings as "equal" and orders them indeterminately.
1305+
NSComparator sortComparator = ^(NSString *string1, NSString *string2) {
1306+
return [string1 compare:string2 options:NSForcedOrderingSearch];
1307+
};
1308+
1309+
// Firestore does not do any Unicode normalization on the document IDs. Therefore, two document
1310+
// IDs that are canonically-equivalent (i.e. they visually appear identical) but are represented
1311+
// by a different sequence of Unicode code points are treated as distinct document IDs.
1312+
NSArray<NSString *> *testDocIds;
1313+
{
1314+
NSMutableArray<NSString *> *testDocIdsAccumulator = [[NSMutableArray alloc] init];
1315+
[testDocIdsAccumulator addObject:@"DocumentToDelete"];
1316+
// The next two strings both end with "e" with an accent: the first uses the dedicated Unicode
1317+
// code point for this character, while the second uses the standard lowercase "e" followed by
1318+
// the accent combining character.
1319+
[testDocIdsAccumulator addObject:@"LowercaseEWithAcuteAccent_\u00E9"];
1320+
[testDocIdsAccumulator addObject:@"LowercaseEWithAcuteAccent_\u0065\u0301"];
1321+
// The next two strings both end with an "e" with two different accents applied via the
1322+
// following two combining characters. The combining characters are specified in a different
1323+
// order and Firestore treats these document IDs as unique, despite the order of the combining
1324+
// characters being irrelevant.
1325+
[testDocIdsAccumulator addObject:@"LowercaseEWithMultipleAccents_\u0065\u0301\u0327"];
1326+
[testDocIdsAccumulator addObject:@"LowercaseEWithMultipleAccents_\u0065\u0327\u0301"];
1327+
// The next string contains a character outside the BMP (the "basic multilingual plane"); that
1328+
// is, its code point is greater than 0xFFFF. Since NSString stores text in sequences of 16-bit
1329+
// code units, using the UTF-16 encoding (according to
1330+
// https://www.objc.io/issues/9-strings/unicode) it is stored as a surrogate pair, two 16-bit
1331+
// code units U+D83D and U+DE00, to represent this character. Make sure that its presence is
1332+
// correctly tested in the bloom filter, which uses UTF-8 encoding.
1333+
[testDocIdsAccumulator addObject:@"Smiley_\U0001F600"];
1334+
1335+
testDocIds = [NSArray arrayWithArray:testDocIdsAccumulator];
1336+
}
1337+
1338+
// Verify assumptions about the equivalence of strings in `testDocIds`.
1339+
XCTAssertEqualObjects(testDocIds[1].decomposedStringWithCanonicalMapping,
1340+
testDocIds[2].decomposedStringWithCanonicalMapping);
1341+
XCTAssertEqualObjects(testDocIds[3].decomposedStringWithCanonicalMapping,
1342+
testDocIds[4].decomposedStringWithCanonicalMapping);
1343+
XCTAssertEqual([testDocIds[5] characterAtIndex:7], 0xD83D);
1344+
XCTAssertEqual([testDocIds[5] characterAtIndex:8], 0xDE00);
1345+
1346+
// Create the mapping from document ID to document data for the document IDs specified in
1347+
// `testDocIds`.
1348+
NSMutableDictionary<NSString *, NSDictionary<NSString *, id> *> *testDocs =
1349+
[[NSMutableDictionary alloc] init];
1350+
for (NSString *testDocId in testDocIds) {
1351+
[testDocs setValue:@{@"foo" : @42} forKey:testDocId];
1352+
}
1353+
1354+
// Create the documents whose names contain complex Unicode characters in a new collection.
1355+
FIRCollectionReference *collRef = [self collectionRefWithDocuments:testDocs];
1356+
1357+
// Run a query to populate the local cache with documents that have names with complex Unicode
1358+
// characters.
1359+
{
1360+
FIRQuerySnapshot *querySnapshot1 = [self readDocumentSetForRef:collRef
1361+
source:FIRFirestoreSourceDefault];
1362+
XCTAssertEqualObjects(
1363+
[FIRQuerySnapshotGetIDs(querySnapshot1) sortedArrayUsingComparator:sortComparator],
1364+
[testDocIds sortedArrayUsingComparator:sortComparator],
1365+
@"querySnapshot1 has the wrong documents");
1366+
}
1367+
1368+
// Delete one of the documents so that the next call to collection.get() will experience an
1369+
// existence filter mismatch. Use a different Firestore instance to avoid affecting the local
1370+
// cache.
1371+
FIRDocumentReference *documentToDelete = [collRef documentWithPath:@"DocumentToDelete"];
1372+
{
1373+
FIRFirestore *db2 = [self firestore];
1374+
[self deleteDocumentRef:[db2 documentWithPath:documentToDelete.path]];
1375+
}
1376+
1377+
// Wait for 10 seconds, during which Watch will stop tracking the query and will send an
1378+
// existence filter rather than "delete" events when the query is resumed.
1379+
[NSThread sleepForTimeInterval:10.0f];
1380+
1381+
// Resume the query and save the resulting snapshot for verification. Use some internal testing
1382+
// hooks to "capture" the existence filter mismatches.
1383+
__block FIRQuerySnapshot *querySnapshot2;
1384+
NSArray<FSTTestingHooksExistenceFilterMismatchInfo *> *existenceFilterMismatches =
1385+
[FSTTestingHooks captureExistenceFilterMismatchesDuringBlock:^{
1386+
querySnapshot2 = [self readDocumentSetForRef:collRef source:FIRFirestoreSourceDefault];
1387+
}];
1388+
1389+
// Verify that the snapshot from the resumed query contains the expected documents; that is, that
1390+
// it contains the documents whose names contain complex Unicode characters and _not_ the document
1391+
// that was deleted.
1392+
{
1393+
NSMutableArray<NSString *> *querySnapshot2ExpectedDocumentIds =
1394+
[NSMutableArray arrayWithArray:testDocIds];
1395+
[querySnapshot2ExpectedDocumentIds removeObject:documentToDelete.documentID];
1396+
XCTAssertEqualObjects(
1397+
[FIRQuerySnapshotGetIDs(querySnapshot2) sortedArrayUsingComparator:sortComparator],
1398+
[querySnapshot2ExpectedDocumentIds sortedArrayUsingComparator:sortComparator],
1399+
@"querySnapshot2 has the wrong documents");
1400+
}
1401+
1402+
// Verify that Watch sent an existence filter with the correct counts.
1403+
XCTAssertEqual(existenceFilterMismatches.count, 1u,
1404+
@"Watch should have sent exactly 1 existence filter");
1405+
FSTTestingHooksExistenceFilterMismatchInfo *existenceFilterMismatchInfo =
1406+
existenceFilterMismatches[0];
1407+
XCTAssertEqual(existenceFilterMismatchInfo.localCacheCount, (int)testDocIds.count);
1408+
XCTAssertEqual(existenceFilterMismatchInfo.existenceFilterCount, (int)testDocIds.count - 1);
1409+
1410+
// Verify that Watch sent a valid bloom filter.
1411+
FSTTestingHooksBloomFilter *bloomFilter = existenceFilterMismatchInfo.bloomFilter;
1412+
XCTAssertNotNil(bloomFilter, "Watch should have included a bloom filter in the existence filter");
1413+
1414+
// The bloom filter application should statistically be successful almost every time; the _only_
1415+
// time when it would _not_ be successful is if there is a false positive when testing for
1416+
// 'DocumentToDelete' in the bloom filter. So verify that the bloom filter application is
1417+
// successful, unless there was a false positive.
1418+
BOOL isFalsePositive = [bloomFilter mightContain:documentToDelete];
1419+
XCTAssertEqual(bloomFilter.applied, !isFalsePositive);
1420+
1421+
// Verify that the bloom filter contains the document paths with complex Unicode characters.
1422+
for (FIRDocumentSnapshot *documentSnapshot in querySnapshot2.documents) {
1423+
XCTAssertTrue([bloomFilter mightContain:documentSnapshot.reference],
1424+
@"The bloom filter should contain %@", documentSnapshot.documentID);
1425+
}
1426+
}
1427+
12881428
@end

Firestore/Example/Tests/Util/FSTTestingHooks.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ NS_ASSUME_NONNULL_BEGIN
4747
/** The number of bits of padding in the last byte of the bloom filter. */
4848
@property(nonatomic, readonly) int padding;
4949

50+
/** Returns whether the bloom filter contains the given document. */
51+
- (BOOL)mightContain:(FIRDocumentReference*)documentRef;
52+
5053
@end // @interface FSTTestingHooksBloomFilter
5154

5255
#pragma mark - FSTTestingHooksExistenceFilterMismatchInfo

Firestore/Example/Tests/Util/FSTTestingHooks.mm

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,34 +47,63 @@ @interface FSTTestingHooksBloomFilter ()
4747
- (instancetype)initWithApplied:(BOOL)applied
4848
hashCount:(int)hashCount
4949
bitmapLength:(int)bitmapLength
50-
padding:(int)padding NS_DESIGNATED_INITIALIZER;
50+
padding:(int)padding
51+
projectId:(std::string)projectId
52+
databaseId:(std::string)databaseId
53+
bloomFilter:(absl::optional<BloomFilter>)bloomFilter NS_DESIGNATED_INITIALIZER;
5154

52-
- (instancetype)initWithBloomFilterInfo:(const TestingHooks::BloomFilterInfo&)bloomFilterInfo;
55+
- (instancetype)initWithBloomFilterInfo:(const TestingHooks::BloomFilterInfo&)bloomFilterInfo
56+
projectId:(std::string)projectId
57+
databaseId:(std::string)databaseId;
5358

5459
@end
5560

5661
#pragma mark - FSTTestingHooksBloomFilter implementation
5762

58-
@implementation FSTTestingHooksBloomFilter
63+
@implementation FSTTestingHooksBloomFilter {
64+
std::string _projectId;
65+
std::string _databaseId;
66+
absl::optional<BloomFilter> _bloomFilter;
67+
}
5968

6069
- (instancetype)initWithApplied:(BOOL)applied
6170
hashCount:(int)hashCount
6271
bitmapLength:(int)bitmapLength
63-
padding:(int)padding {
72+
padding:(int)padding
73+
projectId:(std::string)projectId
74+
databaseId:(std::string)databaseId
75+
bloomFilter:(absl::optional<BloomFilter>)bloomFilter {
6476
if (self = [super init]) {
6577
_applied = applied;
6678
_hashCount = hashCount;
6779
_bitmapLength = bitmapLength;
6880
_padding = padding;
81+
_projectId = std::move(projectId);
82+
_databaseId = std::move(databaseId);
83+
_bloomFilter = std::move(bloomFilter);
6984
}
7085
return self;
7186
}
7287

73-
- (instancetype)initWithBloomFilterInfo:(const TestingHooks::BloomFilterInfo&)bloomFilterInfo {
88+
- (instancetype)initWithBloomFilterInfo:(const TestingHooks::BloomFilterInfo&)bloomFilterInfo
89+
projectId:(std::string)projectId
90+
databaseId:(std::string)databaseId {
7491
return [self initWithApplied:bloomFilterInfo.applied
7592
hashCount:bloomFilterInfo.hash_count
7693
bitmapLength:bloomFilterInfo.bitmap_length
77-
padding:bloomFilterInfo.padding];
94+
padding:bloomFilterInfo.padding
95+
projectId:std::move(projectId)
96+
databaseId:std::move(databaseId)
97+
bloomFilter:bloomFilterInfo.bloom_filter];
98+
}
99+
100+
- (BOOL)mightContain:(FIRDocumentReference*)documentRef {
101+
if (!_bloomFilter.has_value()) {
102+
return NO;
103+
}
104+
std::string document_path = StringFormat("projects/%s/databases/%s/documents/%s", _projectId,
105+
_databaseId, MakeStringView(documentRef.path));
106+
return _bloomFilter->MightContain(document_path);
78107
}
79108

80109
@end
@@ -114,7 +143,9 @@ - (instancetype)initWithExistenceFilterMismatchInfo:
114143
FSTTestingHooksBloomFilter* bloomFilter;
115144
if (existenceFilterMismatchInfo.bloom_filter.has_value()) {
116145
bloomFilter = [[FSTTestingHooksBloomFilter alloc]
117-
initWithBloomFilterInfo:existenceFilterMismatchInfo.bloom_filter.value()];
146+
initWithBloomFilterInfo:existenceFilterMismatchInfo.bloom_filter.value()
147+
projectId:existenceFilterMismatchInfo.project_id
148+
databaseId:existenceFilterMismatchInfo.database_id];
118149
} else {
119150
bloomFilter = nil;
120151
}

Firestore/core/src/remote/remote_event.cc

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -216,21 +216,25 @@ namespace {
216216

217217
TestingHooks::ExistenceFilterMismatchInfo
218218
create_existence_filter_mismatch_info_for_testing_hooks(
219-
BloomFilterApplicationStatus status,
220219
int local_cache_count,
221-
const ExistenceFilterWatchChange& existence_filter) {
222-
absl::optional<TestingHooks::BloomFilterInfo> bloom_filter;
220+
const ExistenceFilterWatchChange& existence_filter,
221+
const DatabaseId& database_id,
222+
absl::optional<BloomFilter> bloom_filter,
223+
BloomFilterApplicationStatus status) {
224+
absl::optional<TestingHooks::BloomFilterInfo> bloom_filter_info;
223225
if (existence_filter.filter().bloom_filter_parameters().has_value()) {
224226
const BloomFilterParameters& bloom_filter_parameters =
225227
existence_filter.filter().bloom_filter_parameters().value();
226-
bloom_filter = {status == BloomFilterApplicationStatus::kSuccess,
227-
bloom_filter_parameters.hash_count,
228-
static_cast<int>(bloom_filter_parameters.bitmap.size()),
229-
bloom_filter_parameters.padding};
228+
bloom_filter_info = {
229+
status == BloomFilterApplicationStatus::kSuccess,
230+
bloom_filter_parameters.hash_count,
231+
static_cast<int>(bloom_filter_parameters.bitmap.size()),
232+
bloom_filter_parameters.padding, std::move(bloom_filter)};
230233
}
231234

232235
return {local_cache_count, existence_filter.filter().count(),
233-
std::move(bloom_filter)};
236+
database_id.project_id(), database_id.database_id(),
237+
std::move(bloom_filter_info)};
234238
}
235239

236240
} // namespace
@@ -264,8 +268,13 @@ void WatchChangeAggregator::HandleExistenceFilter(
264268
int current_size = GetCurrentDocumentCountForTarget(target_id);
265269
if (current_size != expected_count) {
266270
// Apply bloom filter to identify and mark removed documents.
271+
absl::optional<BloomFilter> bloom_filter =
272+
ParseBloomFilter(existence_filter);
267273
BloomFilterApplicationStatus status =
268-
ApplyBloomFilter(existence_filter, current_size);
274+
bloom_filter.has_value()
275+
? ApplyBloomFilter(bloom_filter.value(), existence_filter,
276+
current_size)
277+
: BloomFilterApplicationStatus::kSkipped;
269278
if (status != BloomFilterApplicationStatus::kSuccess) {
270279
// If bloom filter application fails, we reset the mapping and
271280
// trigger re-run of the query.
@@ -279,18 +288,20 @@ void WatchChangeAggregator::HandleExistenceFilter(
279288

280289
TestingHooks::GetInstance().NotifyOnExistenceFilterMismatch(
281290
create_existence_filter_mismatch_info_for_testing_hooks(
282-
status, current_size, existence_filter));
291+
current_size, existence_filter,
292+
target_metadata_provider_->GetDatabaseId(),
293+
std::move(bloom_filter), status));
283294
}
284295
}
285296
}
286297
}
287298

288-
BloomFilterApplicationStatus WatchChangeAggregator::ApplyBloomFilter(
289-
const ExistenceFilterWatchChange& existence_filter, int current_count) {
299+
absl::optional<BloomFilter> WatchChangeAggregator::ParseBloomFilter(
300+
const ExistenceFilterWatchChange& existence_filter) {
290301
const absl::optional<BloomFilterParameters>& bloom_filter_parameters =
291302
existence_filter.filter().bloom_filter_parameters();
292303
if (!bloom_filter_parameters.has_value()) {
293-
return BloomFilterApplicationStatus::kSkipped;
304+
return absl::nullopt;
294305
}
295306

296307
util::StatusOr<BloomFilter> maybe_bloom_filter =
@@ -300,23 +311,30 @@ BloomFilterApplicationStatus WatchChangeAggregator::ApplyBloomFilter(
300311
if (!maybe_bloom_filter.ok()) {
301312
LOG_WARN("Creating BloomFilter failed: %s",
302313
maybe_bloom_filter.status().error_message());
303-
return BloomFilterApplicationStatus::kSkipped;
314+
return absl::nullopt;
304315
}
305316

306317
BloomFilter bloom_filter = std::move(maybe_bloom_filter).ValueOrDie();
307318

308319
if (bloom_filter.bit_count() == 0) {
309-
return BloomFilterApplicationStatus::kSkipped;
320+
return absl::nullopt;
310321
}
311322

323+
return bloom_filter;
324+
}
325+
326+
BloomFilterApplicationStatus WatchChangeAggregator::ApplyBloomFilter(
327+
const BloomFilter& bloom_filter,
328+
const ExistenceFilterWatchChange& existence_filter,
329+
int current_count) {
330+
int expected_count = existence_filter.filter().count();
331+
312332
int removed_document_count =
313333
FilterRemovedDocuments(bloom_filter, existence_filter.target_id());
314334

315-
int expected_count = existence_filter.filter().count();
316-
if (expected_count != (current_count - removed_document_count)) {
317-
return BloomFilterApplicationStatus::kFalsePositive;
318-
}
319-
return BloomFilterApplicationStatus::kSuccess;
335+
return (expected_count == (current_count - removed_document_count))
336+
? BloomFilterApplicationStatus::kSuccess
337+
: BloomFilterApplicationStatus::kFalsePositive;
320338
}
321339

322340
int WatchChangeAggregator::FilterRemovedDocuments(

Firestore/core/src/remote/remote_event.h

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,12 +417,21 @@ class WatchChangeAggregator {
417417
bool TargetContainsDocument(model::TargetId target_id,
418418
const model::DocumentKey& key);
419419

420+
/**
421+
* Parse the bloom filter from the "unchanged_names" field of an existence
422+
* filter.
423+
*/
424+
static absl::optional<BloomFilter> ParseBloomFilter(
425+
const ExistenceFilterWatchChange& existence_filter);
426+
420427
/**
421428
* Apply bloom filter to remove the deleted documents, and return the
422429
* application status.
423430
*/
424431
BloomFilterApplicationStatus ApplyBloomFilter(
425-
const ExistenceFilterWatchChange& existence_filter, int current_count);
432+
const BloomFilter& bloom_filter,
433+
const ExistenceFilterWatchChange& existence_filter,
434+
int current_count);
426435

427436
/**
428437
* Filter out removed documents based on bloom filter membership result and

0 commit comments

Comments
 (0)