// // RKSearchIndexer.m // RestKit // // Created by Blake Watters on 7/27/12. // Copyright (c) 2012 RestKit. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // #import "RKSearchIndexer.h" #import "RKSearchWordEntity.h" #import "RKSearchWord.h" #import "RKLog.h" #import "RKSearchTokenizer.h" #import "NSManagedObjectContext+RKAdditions.h" // Set Logging Component #undef RKLogComponent #define RKLogComponent RKlcl_cRestKitSearch NSString * const RKSearchableAttributeNamesUserInfoKey = @"RestKitSearchableAttributes"; @interface RKSearchIndexer () @property (nonatomic, strong) NSOperationQueue *operationQueue; @property (nonatomic, assign) NSUInteger totalIndexingOperationCount; @end @implementation RKSearchIndexer + (void)addSearchIndexingToEntity:(NSEntityDescription *)entity onAttributes:(NSArray *)attributes { NSParameterAssert(entity); NSParameterAssert(attributes); // Create a relationship from the RKSearchWordEntity to the given searchable entity NSEntityDescription *searchWordEntity = [[entity.managedObjectModel entitiesByName] objectForKey:RKSearchWordEntityName]; if (! searchWordEntity) { searchWordEntity = [[RKSearchWordEntity alloc] init]; // Add the entity to the model NSArray *entities = [entity.managedObjectModel entities]; [entity.managedObjectModel setEntities:[entities arrayByAddingObject:searchWordEntity]]; } NSMutableArray *attributeNames = [NSMutableArray arrayWithCapacity:[attributes count]]; for (id attributeIdentifier in attributes) { NSAttributeDescription *attribute = nil; if ([attributeIdentifier isKindOfClass:[NSString class]]) { // Look it up by name attribute = [[entity attributesByName] objectForKey:attributeIdentifier]; NSAssert(attribute, @"Invalid attribute identifier given: No attribute with the name '%@' found in the '%@' entity.", attributeIdentifier, entity.name); } else if ([attributeIdentifier isKindOfClass:[NSAttributeDescription class]]) { attribute = attributeIdentifier; } else { @throw [NSException exceptionWithName:NSInvalidArgumentException reason:[NSString stringWithFormat:@"Unable to configure search indexing: Invalid attribute identifier of type '%@' given, expected an NSString or NSAttributeDescription. (Value: %@)", [attributeIdentifier class], attributeIdentifier] userInfo:nil]; } NSAssert(attribute.attributeType == NSStringAttributeType, @"Invalid attribute identifier given: Expected an attribute of type NSStringAttributeType, got %ld.", (unsigned long) attribute.attributeType); [attributeNames addObject:attribute.name]; } // Store the searchable attributes into the user info dictionary NSMutableDictionary *userInfo = [[entity userInfo] mutableCopy]; [userInfo setObject:attributeNames forKey:RKSearchableAttributeNamesUserInfoKey]; [entity setUserInfo:userInfo]; // Create a relationship from our indexed entity to the RKSearchWord entity NSRelationshipDescription *relationship = [[NSRelationshipDescription alloc] init]; [relationship setName:RKSearchWordsRelationshipName]; [relationship setDestinationEntity:searchWordEntity]; [relationship setMaxCount:0]; // Make it to-many [relationship setDeleteRule:NSNullifyDeleteRule]; NSArray *properties = [entity properties]; [entity setProperties:[properties arrayByAddingObject:relationship]]; // Create an inverse relationship from the searchWords to the searchable entity NSRelationshipDescription *inverseRelationship = [[NSRelationshipDescription alloc] init]; [inverseRelationship setName:entity.name]; [inverseRelationship setDestinationEntity:entity]; [inverseRelationship setDeleteRule:NSNullifyDeleteRule]; NSArray *searchWordProperties = [searchWordEntity properties]; [searchWordEntity setProperties:[searchWordProperties arrayByAddingObject:inverseRelationship]]; // Connect the relationships as inverses [relationship setInverseRelationship:inverseRelationship]; [inverseRelationship setInverseRelationship:relationship]; } - (id)init { self = [super init]; if (self) { // Setup serial operation queue to enable cancellation of indexing self.operationQueue = [NSOperationQueue new]; self.operationQueue.maxConcurrentOperationCount = 1; [self.operationQueue addObserver:self forKeyPath:@"operationCount" options:0 context:NULL]; } return self; } - (void)dealloc { [self cancelAllIndexingOperations]; [[NSNotificationCenter defaultCenter] removeObserver:self]; } - (void)startObservingManagedObjectContext:(NSManagedObjectContext *)managedObjectContext { NSParameterAssert(managedObjectContext); if (self.indexingContext) { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleManagedObjectContextDidSaveNotification:) name:NSManagedObjectContextDidSaveNotification object:managedObjectContext]; } else { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleManagedObjectContextWillSaveNotification:) name:NSManagedObjectContextWillSaveNotification object:managedObjectContext]; } } - (void)stopObservingManagedObjectContext:(NSManagedObjectContext *)managedObjectContext { NSParameterAssert(managedObjectContext); [[NSNotificationCenter defaultCenter] removeObserver:self name:nil object:managedObjectContext]; } - (NSUInteger)indexManagedObject:(NSManagedObject *)managedObject withProgressBlock:(void (^)(NSManagedObject *managedObject, RKSearchWord *searchWord, BOOL *stop))progressBlock; { @autoreleasepool { RKLogDebug(@"Indexing searchable attributes of managed object: %@", managedObject); NSArray *searchableAttributes = [managedObject.entity.userInfo objectForKey:RKSearchableAttributeNamesUserInfoKey]; if (! searchableAttributes) { [NSException raise:NSInvalidArgumentException format:@"The given managed object %@ is for an entity (%@) that does not define any searchable attributes. Perhaps you forgot to invoke addSearchIndexingToEntity:onAttributes:?", managedObject, managedObject.entity]; return NSNotFound; } RKSearchTokenizer *searchTokenizer = [RKSearchTokenizer new]; searchTokenizer.stopWords = self.stopWords; __block NSUInteger searchWordCount; NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:RKSearchWordEntityName]; fetchRequest.fetchLimit = 1; NSPredicate *predicateTemplate = [NSPredicate predicateWithFormat:@"%K == $SEARCH_WORD", RKSearchWordAttributeName]; NSManagedObjectContext *managedObjectContext = managedObject.managedObjectContext; __block BOOL stop = NO; [managedObjectContext performBlockAndWait:^{ NSMutableSet *searchWords = [NSMutableSet set]; for (NSString *searchableAttribute in searchableAttributes) { NSString *attributeValue = [managedObject valueForKey:searchableAttribute]; if (attributeValue) { RKLogTrace(@"Generating search words for searchable attribute: %@", searchableAttribute); NSSet *tokens = [searchTokenizer tokenize:attributeValue]; for (NSString *word in tokens) { if (word && [word length] > 0) { RKSearchWord *searchWord = nil; NSError *error = nil; if ([self.delegate respondsToSelector:@selector(searchIndexer:searchWordForWord:inManagedObjectContext:error:)]) { // Let our delegate retrieve an existing search word searchWord = [self.delegate searchIndexer:self searchWordForWord:word inManagedObjectContext:managedObjectContext error:&error]; } else { // Fall back to vanilla fetch request fetchRequest.predicate = [predicateTemplate predicateWithSubstitutionVariables:@{ @"SEARCH_WORD" : word }]; NSArray *results = [managedObjectContext executeFetchRequest:fetchRequest error:&error]; searchWord = ([results count] > 0) ? [results objectAtIndex:0] : nil; } if (error == nil) { if (! searchWord) { if ([self.delegate respondsToSelector:@selector(searchIndexer:shouldInsertSearchWordForWord:inManagedObjectContext:)]) { if (! [self.delegate searchIndexer:self shouldInsertSearchWordForWord:word inManagedObjectContext:managedObjectContext]) { continue; } } searchWord = [NSEntityDescription insertNewObjectForEntityForName:RKSearchWordEntityName inManagedObjectContext:managedObjectContext]; searchWord.word = word; if ([self.delegate respondsToSelector:@selector(searchIndexer:didInsertSearchWord:forWord:inManagedObjectContext:)]) { [self.delegate searchIndexer:self didInsertSearchWord:searchWord forWord:word inManagedObjectContext:managedObjectContext]; } } NSAssert([[searchWord managedObjectContext] isEqual:managedObjectContext], @"Serious Core Data error: Expected `NSManagedObject` for the 'RKSearchWord' entity in context %@, but got one in %@", managedObject, [searchWord managedObjectContext]); [searchWords addObject:searchWord]; if (progressBlock) progressBlock(managedObject, searchWord, &stop); } else { RKLogError(@"Failed to retrieve search word: %@", error); } } if (stop) break; } } if (stop) break; } if (! stop) { [managedObject setValue:searchWords forKey:RKSearchWordsRelationshipName]; RKLogTrace(@"Indexed search words: %@", [searchWords valueForKey:RKSearchWordAttributeName]); searchWordCount = [searchWords count]; if ([self.delegate respondsToSelector:@selector(searchIndexer:didIndexManagedObject:)]) { [self.delegate searchIndexer:self didIndexManagedObject:managedObject]; } } }]; return searchWordCount; } } - (NSUInteger)indexManagedObject:(NSManagedObject *)managedObject { return [self indexManagedObject:managedObject withProgressBlock:nil]; } /** NOTE: Does **NOT** use the indexing context as unsaved objects would not be available for indexing in that context */ - (void)indexChangedObjectsInManagedObjectContext:(NSManagedObjectContext *)managedObjectContext waitUntilFinished:(BOOL)wait { NSParameterAssert(managedObjectContext); NSSet *candidateObjects = [[NSSet setWithSet:managedObjectContext.insertedObjects] setByAddingObjectsFromSet:managedObjectContext.updatedObjects]; NSSet *objectsToIndex = [self objectsToIndexFromCandidateObjects:candidateObjects checkChangedValues:YES]; if (wait) { // Synchronous indexing NSUInteger totalObjects = [objectsToIndex count]; __block NSMutableSet *indexedIDs = [NSMutableSet setWithCapacity:totalObjects]; for (NSManagedObject *managedObject in objectsToIndex) { if ([self.delegate respondsToSelector:@selector(searchIndexer:shouldIndexManagedObject:)]) { if (! [self.delegate searchIndexer:self shouldIndexManagedObject:managedObject]) continue; } [self indexManagedObject:managedObject withProgressBlock:^(NSManagedObject *managedObject, RKSearchWord *searchWord, BOOL *stop) { if (totalObjects < 250) return; if ([indexedIDs containsObject:[managedObject objectID]]) return; [indexedIDs addObject:[managedObject objectID]]; double percentage = (((float)[indexedIDs count]) / (float)totalObjects) * 100; if ([indexedIDs count] % 250 == 0 || percentage >= 100.0) RKLogInfo(@"Indexing object %ld of %ld (%.2f%% complete)", (unsigned long) [indexedIDs count], (unsigned long) totalObjects, percentage); }]; } if (totalObjects >= 250) RKLogInfo(@"Finished indexing."); } else { // Perform asynchronous indexing for (NSManagedObject *managedObject in objectsToIndex) { if ([self.delegate respondsToSelector:@selector(searchIndexer:shouldIndexManagedObject:)]) { if (! [self.delegate searchIndexer:self shouldIndexManagedObject:managedObject]) continue; } [self.operationQueue addOperationWithBlock:^{ [self indexManagedObject:managedObject]; }]; } self.totalIndexingOperationCount = [self.operationQueue operationCount]; } } - (void)indexChangedObjectsFromManagedObjectContextDidSaveNotification:(NSNotification *)notification { if (! self.indexingContext) { RKLogWarning(@"Received `NSManagedObjectContextDidSaveNotification` with nil indexing context: ignoring..."); return; } NSDictionary *userInfo = [notification userInfo]; NSSet *candidateObjects = [[NSSet setWithSet:[userInfo objectForKey:NSInsertedObjectsKey]] setByAddingObjectsFromSet:[userInfo objectForKey:NSUpdatedObjectsKey]]; NSSet *objectsToIndex = [self objectsToIndexFromCandidateObjects:candidateObjects checkChangedValues:NO]; // After all indexing is complete, save the indexing context __block NSBlockOperation *saveOperation = [NSBlockOperation blockOperationWithBlock:^{ if ([saveOperation isCancelled]) return; [self.indexingContext performBlockAndWait:^{ NSError *error = nil; RKLogInfo(@"Indexing completed. Saving indexing context..."); BOOL success = [self.indexingContext saveToPersistentStore:&error]; if (! success) { RKLogError(@"Failed to save indexing context: %@", error); } }]; }]; NSMutableSet *failedObjectIDs = [NSMutableSet set]; // Enqueue an operation for each object to index NSArray *objectIDsForObjectsToIndex = [objectsToIndex valueForKey:@"objectID"]; for (NSManagedObjectID *objectID in objectIDsForObjectsToIndex) { __block NSBlockOperation *indexingOperation = [NSBlockOperation blockOperationWithBlock:^{ if ([indexingOperation isCancelled]) return; [self.indexingContext performBlockAndWait:^{ NSError *error = nil; NSManagedObject *managedObject = [self.indexingContext existingObjectWithID:objectID error:&error]; NSAssert(managedObject == nil || [[managedObject managedObjectContext] isEqual:self.indexingContext], @"Serious Core Data error: Asked for an `NSManagedObject` with ID %@ in indexing context %@, but got one in %@", objectID, self.indexingContext, [managedObject managedObjectContext]); if (managedObject && error == nil) { BOOL performIndexing = YES; if ([self.delegate respondsToSelector:@selector(searchIndexer:shouldIndexManagedObject:)]) { performIndexing = [self.delegate searchIndexer:self shouldIndexManagedObject:managedObject]; } if (performIndexing) { [self indexManagedObject:managedObject withProgressBlock:^(NSManagedObject *managedObject, RKSearchWord *searchWord, BOOL *stop) { // Stop the indexing process if we have been cancelled if ([indexingOperation isCancelled]) *stop = YES; }]; } } else { RKLogError(@"Failed indexing of object %@ with error: %@", managedObject, error); } }]; }]; [saveOperation addDependency:indexingOperation]; [self.operationQueue addOperation:indexingOperation]; } // Assert that we indexed everything sucessfully [self.operationQueue addOperationWithBlock:^{ NSAssert([failedObjectIDs count] == 0, @"Expected no indexing failures, got %ld", (long) [failedObjectIDs count]); }]; [self.operationQueue addOperation:saveOperation]; self.totalIndexingOperationCount = [self.operationQueue operationCount]; } - (void)cancelAllIndexingOperations { [self.operationQueue cancelAllOperations]; } - (void)waitUntilAllIndexingOperationsAreFinished { [self.operationQueue waitUntilAllOperationsAreFinished]; } #pragma mark - Private - (void)handleManagedObjectContextWillSaveNotification:(NSNotification *)notification { NSManagedObjectContext *managedObjectContext = [notification object]; RKLogInfo(@"Managed object context will save notification received. Checking changed and inserted objects for searchable entities..."); // We wait until finished to ensure that the indexed objects are persisted with the save [self indexChangedObjectsInManagedObjectContext:managedObjectContext waitUntilFinished:YES]; } - (void)handleManagedObjectContextDidSaveNotification:(NSNotification *)notification { RKLogInfo(@"Managed object context did save notification received. Checking changed and inserted objects for searchable entities..."); [self indexChangedObjectsFromManagedObjectContextDidSaveNotification:notification]; } - (NSSet *)objectsToIndexFromCandidateObjects:(NSSet *)objects checkChangedValues:(BOOL)checkChangedValues { NSUInteger totalObjects = [objects count]; NSMutableSet *objectsNeedingIndexing = [[NSMutableSet alloc] initWithCapacity:totalObjects]; RKLogInfo(@"Indexing %ld changed objects", (unsigned long) totalObjects); for (NSManagedObject *managedObject in objects) { NSArray *searchableAttributes = [managedObject.entity.userInfo objectForKey:RKSearchableAttributeNamesUserInfoKey]; if (! searchableAttributes) { RKLogTrace(@"Skipping indexing for managed object for entity '%@': no searchable attributes found.", managedObject.entity.name); continue; } for (NSString *attribute in searchableAttributes) { if (!checkChangedValues || [[managedObject changedValues] objectForKey:attribute]) { RKLogTrace(@"Detected change to searchable attribute '%@' for managed object '%@': updating search index.", attribute, managedObject); [objectsNeedingIndexing addObject:managedObject]; break; } } } return objectsNeedingIndexing; } - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { if ([keyPath isEqualToString:@"operationCount"]) { if (self.totalIndexingOperationCount > 0 && self.operationQueue.operationCount > 0) { NSUInteger index = self.totalIndexingOperationCount - self.operationQueue.operationCount; double percentage = (((float)index) / (float)self.totalIndexingOperationCount) * 100; if (index % 250 == 0) RKLogInfo(@"Indexing object %ld of %ld (%.2f%% complete)", (unsigned long) index, (unsigned long) self.totalIndexingOperationCount, percentage); if (self.operationQueue.operationCount == 0) { if (self.totalIndexingOperationCount >= 250) RKLogInfo(@"Finished indexing."); self.totalIndexingOperationCount = 0; } } } } @end