├── .gitignore ├── README.md ├── SNRSearchIndex.h └── SNRSearchIndex.m /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.swp 3 | *~.nib 4 | 5 | build/ 6 | 7 | *.pbxuser 8 | *.perspective 9 | *.perspectivev3 10 | *.mode1v3 11 | *.mode2v3 12 | xcuserdata 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## SNRSearchIndex: Core Data search backed by SearchKit 2 | 3 | `SNRSearchIndex` is a simple wrapper for the [SearchKit framework](http://developer.apple.com/library/mac/#documentation/UserExperience/Reference/SearchKit/Reference/reference.html) that is specifically focused around providing lightning fast search for Core Data databases. SearchKit is the framework that Apple's Spotlight is built on, and it's primary use is full text search of documents. That said, it's so fast that it's applicable to a wide variety of cases. I tried using SearchKit with Core Data on a hunch, and the results were far better than I expected. It's really not the code itself that's the valuable part of this project, but the concept of using a FTS framework for searching a Core Data DB. 4 | 5 | **SNRSearchIndex must be compiled under ARC.** If your project has not been converted to use ARC yet, you can add the `-f-objc-arc` flag to `SNRSearchIndex.m` in the Compile Sources build phase for your project. 6 | 7 | ## Usage 8 | 9 | ### Creating/Opening a Search Index 10 | 11 | This is fairly self explanatory. The `-initByOpeningSearchIndexAtURL:persistentStoreCoordinator` and `-initByCreatingSearchIndexAtURL:persistentStoreCoordinator` methods of `SNRSearchIndex` will open or create a search index file at the specified URL. If no URL is specified, it will automatically set the URL as **[directory that the first NSPersistentStore is located in]/searchindex**. 12 | 13 | SNRSearchIndex *index = [[SNRSearchIndex alloc] initByCreatingSearchIndexAtURL:nil persistentStoreCoordinator:self.persistentStoreCoordinator]; 14 | 15 | ### Using a Shared Search Index 16 | 17 | Often times, your application will only use a single search index, and it's inconvenient having to maintain references to a search index from all of the various objects that use it. Therefore, `SNRSearchIndex` has the `+sharedIndex` and `+setSharedIndex:` class methods to get and set a shared index that will be easily accessible from anywhere in your code. 18 | 19 | // Set the shared index when you first create it 20 | [SNRSearchIndex setSharedIndex:index] 21 | 22 | // From another class... 23 | SNRSearchIndex *theIndex = [SNRSearchIndex sharedIndex]; 24 | 25 | ### Setting Keywords for Managed Objects 26 | 27 | Setting keywords to save in the search index for your managed objects is simple. Create an `NSArray` of `NSString`s of the keywords for that object and call `-setKeywords:forObjectID:`. The reason that `NSManagedObjectID`'s are used vs. the `NSManagedObject`s themselves is because `SearchKit`, and therefore `SNRSearchIndex`, is threadsafe and passing around the managed objects themselves is not thread safe because they are confined to a single managed object context. 28 | 29 | Car *car = …; // NSManagedObject subclass 30 | NSArray *keywords = [NSArray arrayWithObjects:@"car", @"red", @"leather", @"v6", nil]; 31 | [[SNRSearchIndex sharedIndex] setKeywords:keywords forObjectID:[car objectID]]; 32 | 33 | One of the main annoyances with using a separate search index is that you need to keep it in sync with the managed object context when an attribute of a model object changes that affects the keywords in the search index. Therefore, it would be convenient to use custom accessors or KVO in your `NSManagedObject` subclass to automatically update the keywords in the search index. Example: 34 | 35 | @interface Game : NSManagedObject 36 | @property (nonatomic, retain) NSString *name; 37 | @end 38 | 39 | @interface Game (CoreDataGeneratedPrimitiveAccessors) 40 | - (void)setPrimitiveName:(NSString*)primitiveName; 41 | @end 42 | 43 | @implementation Game 44 | - (void)setName:(NSString*)value 45 | { 46 | [self willChangeValueForKey:@"name"]; 47 | [self setPrimitiveName:value]; 48 | [self didChangeValueForKey:@"name"]; 49 | NSArray *keywords = [value componentsSeparatedByString:@" "]; // Create keywords by separating the name by its spaces 50 | [[SNRSearchIndex sharedIndex] setKeywords:keywords forObjectID:[self objectID]]; 51 | } 52 | @end 53 | 54 | ### Synchronizing Saves with NSManagedObjectContext 55 | 56 | Calling `-flush` on an `SNRSearchIndex` will commit any changes to the backing store, much like calling `-save:` on an `NSManagedObjectContext`. Instead of having to call save on both every time changes are made, it is far simpler to implement a method in a category for `NSManagedObjectContext` that saves both the search index and the MOC: 57 | 58 | @interface NSManagedObjectContext (SearchIndex) 59 | - (BOOL)saveContextAndSearchIndex:(NSError**)error; 60 | @end 61 | 62 | @implementation NSManagedObjectContext (SearchIndex) 63 | - (BOOL)saveContextAndSearchIndex:(NSError**)error 64 | { 65 | BOOL success = [[SNRSearchIndex sharedIndex] flush]; 66 | if (![self save:error]) { 67 | return NO; 68 | } 69 | return success; 70 | } 71 | @end 72 | 73 | ### Creating a Search Query 74 | 75 | The `SNRSearchIndexSearch` object encapsulates a single search query. Use the `-searchForQuery:options:` method of `SNRSearchIndex` to create a search query object. The values for the `options` parameter are documented in the header. `SearchKit` supports a specific set of query operators that are described [here](http://developer.apple.com/library/mac/documentation/UserExperience/Reference/SearchKit/Reference/reference.html#//apple_ref/c/func/SKSearchCreate). 76 | 77 | // Substring search for 'berry' 78 | 79 | SNRSearchIndexSearch *search = [[SNRSearchIndex sharedIndex] searchForQuery:@"*berry" options:0]; 80 | 81 | // this would return results for 'strawberry', 'blackberry', etc. 82 | 83 | ### Performing a Search Query 84 | 85 | Search queries are performed using the `-findMatchesWithFetchLimit:maximumTime:handler` method of `SNRSearchIndexSearch`. These parameters are documented in the header. This method can be called from a background thread, and once the search results are retrieved, they are passed to the handler block as an array of `SNRSearchIndexSearchResult` objects. Mapping these results to actual `NSManagedObject`'s is trivial: 86 | 87 | [search findMatchesWithFetchLimit:10 maximumTime:1.0 handler:^(NSArray *results) { 88 | NSMutableArray *objects = [NSMutableArray arrayWithCapacity:[results count]]; 89 | for (SNRSearchIndexSearchResult *result in results) { 90 | NSManagedObject *object = [self.managedObjectContext existingObjectWithID:result.objectID error:nil]; 91 | if (object) { [objects addObject:object]; } 92 | } 93 | // Do something with the objects array 94 | }]; 95 | 96 | **Note:** If you're implementing a "type to search" feature, it would be a good idea to cancel the previous search query before executing a new one. Implement a property setter that retains a reference to the current search query and cancels the old one when a new one is set: 97 | 98 | - (void)setCurrentSearch:(SNRSearchIndexSearch*)search 99 | { 100 | if (_currentSearch != search) { 101 | [_currentSearch cancel]; 102 | _currentSearch = search; 103 | } 104 | } 105 | 106 | ### Relevance Scores 107 | 108 | `SearchKit` has support for relevance scores, which rank search results based on some algorithms that determine their similarity to the search query. `SNRSearchIndex` exposes these relevance scores, but they probably aren't that accurate nor useful for this purpose (they're more useful for full text search). 109 | 110 | ## About Me 111 | 112 | I'm Indragie Karunaratne, a 17 year old Mac and iOS developer. Visit my [website](http://indragie.com) to check out my portfolio and get in touch with me. 113 | 114 | ## Licensing 115 | 116 | `SNRSearchIndex` is licensed under the [BSD license](http://www.opensource.org/licenses/bsd-license.php). 117 | -------------------------------------------------------------------------------- /SNRSearchIndex.h: -------------------------------------------------------------------------------- 1 | // 2 | // SNRSearchIndex.h 3 | // Sonora 4 | // 5 | // Created by Indragie Karunaratne on 11-07-22. 6 | // Copyright 2011 indragie.com. All rights reserved. 7 | // 8 | 9 | #import 10 | #import 11 | 12 | /** 13 | @class SNRSearchIndex 14 | A wrapper for SearchKit created specifically for Core Data. From the SearchKit documentation: 15 | "Search Kit is thread-safe. You can use separate indexing and searching threads. Your application is responsible for ensuring that no more than one process is open at a time for writing to an index." 16 | Since SearchKit itself is thread-safe, this wrapper is also thread-safe. 17 | */ 18 | 19 | typedef enum { 20 | SNRSearchIndexSearchOptionDefault, // compute relevance scores, spaces in query interpreted as AND, no similarity searching 21 | SNRSearchIndexSearchOptionNoRelevanceScores, // do not compute relevance scores 22 | SNRSearchIndexSearchOptionSpaceMeansOR, // spaces in ths search query are interpreted as OR instead of AND 23 | SNRSearchIndexSearchOptionFindSimilar // alters the search to find similar objects instead of exact matches 24 | } SNRSearchIndexSearchOptions; 25 | 26 | @class SNRSearchIndexSearch; 27 | @interface SNRSearchIndex : NSObject 28 | @property (nonatomic, readonly, retain) NSPersistentStoreCoordinator *persistentStoreCoordinator; 29 | /** The URL to the file that contains this search index */ 30 | @property (nonatomic, readonly, copy) NSURL *fileURL; 31 | /** The underlying SKIndexRef, if you need to access it directly */ 32 | @property (nonatomic, readonly) SKIndexRef index; 33 | /** The number of objects in the search index. (CFIndex = signed long) */ 34 | @property (nonatomic, readonly) CFIndex numberOfObjects; 35 | /** 36 | Opens an existing search index at the specified URL. If it does not exist, this method will return nil 37 | @param url The URL to the file that stores this search index. If url is nil, then SNRSearchIndex will attempt to open the search index in the same directory as the first Core Data persistent store 38 | @param coordinator The persistent store coordinator for your Core Data persistent store 39 | */ 40 | - (id)initByOpeningSearchIndexAtURL:(NSURL*)url persistentStoreCoordinator:(NSPersistentStoreCoordinator*)coordinator; 41 | /** 42 | Creates a new search index at the specified URL. If there is an error while creating, this method will return nil 43 | @param url The URL to the file that stores this search index. If url is nil, then SNRSearchIndex will automatically create an index file in the same directory as the first Core Data persistent store 44 | @param coordinator The persistent store coordinator for your Core Data persistent store 45 | */ 46 | - (id)initByCreatingSearchIndexAtURL:(NSURL*)url persistentStoreCoordinator:(NSPersistentStoreCoordinator*)coordinator; 47 | /** 48 | @return A previously set shared instance of this object 49 | */ 50 | + (id)sharedIndex; 51 | /** 52 | Set a shared instance of SNRSearchIndex for easy access 53 | @param index An SNRSearchIndex object 54 | */ 55 | + (void)setSharedIndex:(SNRSearchIndex*)index; 56 | /** 57 | Compacts the search index to reduce fragmentation and commits changes to the backing store. Compacting can take a considerable amount of time, so do not call this method on the main thread or else it will block UI 58 | @return YES if the operation was successful, otherwise NO 59 | */ 60 | - (BOOL)compact; 61 | /** 62 | Commits all in-memory changes to the backing store. 63 | @return YES if the operation was successful, otherwise NO 64 | */ 65 | - (BOOL)flush; 66 | /** 67 | Sets the specified keywords under the object's entry in the index 68 | @param keywords An array of NSString's of keywords that describe the contents of the object 69 | @param objectID The object ID representing the managed object (safe to use across managed object contexts) 70 | */ 71 | - (BOOL)setKeywords:(NSArray*)keywords forObjectID:(NSManagedObjectID*)objectID; 72 | /** 73 | Creates an autoreleased search object that can be used to execute a search 74 | @param query The search query. Check here for more information on the query format. 75 | @param options The search options 76 | @return An autoreleased SNRSearchIndexSearch object 77 | */ 78 | - (SNRSearchIndexSearch*)searchForQuery:(NSString*)query options:(SNRSearchIndexSearchOptions)options; 79 | @end 80 | 81 | /** 82 | @class SNRSearchIndexSearch 83 | Represents an asynchronous search operation for an SNRSearchIndex 84 | */ 85 | @interface SNRSearchIndexSearch : NSObject 86 | /** The search index that this search operation searches */ 87 | @property (nonatomic, weak, readonly) SNRSearchIndex *searchIndex; 88 | /** The raw SKSearchRef for this wrapper object */ 89 | @property (nonatomic, readonly) SKSearchRef search; 90 | /** 91 | Begins an asynchronous search operation 92 | @param fetchLimit the total (maximum) number of results to fetch in the given maximum time. If the fetchLimit is 0, then as many search results as possible are returned 93 | @param time the maximum amount of time given to execute the search. If the maximumTime is 0, then results will be returned immediately 94 | @param handler The results handler for the search operation. 95 | @discussion This method can be called from a background thread to avoid blocking, and the search operation can be cancelled from the main thread. 96 | */ 97 | - (BOOL)findMatchesWithFetchLimit:(CFIndex)limit maximumTime:(NSTimeInterval)time handler:(void(^)(NSArray *results))handler; 98 | /** 99 | Cancel the current search operation (if there is one) 100 | */ 101 | - (void)cancel; 102 | @end 103 | 104 | @interface SNRSearchIndexSearchResult : NSObject 105 | /** The Core Data managed object ID. 106 | @discussion Use NSManagedObjectContext's -objectWithID: to retrieve the object for this ID 107 | */ 108 | @property (nonatomic, retain, readonly) NSManagedObjectID *objectID; 109 | /** The relevance score for the result. Scores can be scaled to a linear scale of 0.0 - 1.0 by dividing all the scores by the largest score. */ 110 | @property (nonatomic, readonly) float score; 111 | @end -------------------------------------------------------------------------------- /SNRSearchIndex.m: -------------------------------------------------------------------------------- 1 | // 2 | // SNRSearchIndex.m 3 | // Sonora 4 | // 5 | // Created by Indragie Karunaratne on 11-07-22. 6 | // Copyright 2011 indragie.com. All rights reserved. 7 | // 8 | 9 | #import "SNRSearchIndex.h" 10 | 11 | static SNRSearchIndex *_sharedIndex = nil; 12 | 13 | @interface SNRSearchIndexSearch () 14 | - (id)initWithSearchIndex:(SNRSearchIndex*)index query:(NSString*)query options:(SNRSearchIndexSearchOptions)options; 15 | @end 16 | 17 | @interface SNRSearchIndexSearchResult () 18 | - (id)initWithManagedObjectID:(NSManagedObjectID*)objectID relevanceScore:(float)score; 19 | @end 20 | 21 | @implementation SNRSearchIndex { 22 | SKIndexRef _index; 23 | } 24 | @synthesize persistentStoreCoordinator = _persistentStoreCoordinator; 25 | @synthesize fileURL = _fileURL; 26 | @synthesize index = _index; 27 | 28 | - (id)initByOpeningSearchIndexAtURL:(NSURL*)url persistentStoreCoordinator:(NSPersistentStoreCoordinator*)coordinator 29 | { 30 | if (!url) { 31 | NSPersistentStore *store = [[coordinator persistentStores] objectAtIndex:0]; 32 | url = [[[coordinator URLForPersistentStore:store] URLByDeletingLastPathComponent] URLByAppendingPathComponent:@"searchindex"]; 33 | } 34 | if ((self = [super init])) { 35 | _index = SKIndexOpenWithURL((__bridge CFURLRef)url, NULL, true); 36 | if (_index == NULL) { 37 | return nil; 38 | } 39 | _persistentStoreCoordinator = coordinator; 40 | _fileURL = [url copy]; 41 | } 42 | return self; 43 | } 44 | 45 | - (id)initByCreatingSearchIndexAtURL:(NSURL*)url persistentStoreCoordinator:(NSPersistentStoreCoordinator*)coordinator 46 | { 47 | if (!url) { 48 | NSPersistentStore *store = [[coordinator persistentStores] objectAtIndex:0]; 49 | url = [[[coordinator URLForPersistentStore:store] URLByDeletingLastPathComponent] URLByAppendingPathComponent:@"searchindex"]; 50 | } 51 | if ((self = [super init])) { 52 | _index = SKIndexCreateWithURL((__bridge CFURLRef)url, NULL, kSKIndexInvertedVector, NULL); 53 | if (_index == NULL) { 54 | return nil; 55 | } 56 | _persistentStoreCoordinator = coordinator; 57 | _fileURL = [url copy]; 58 | } 59 | return self; 60 | } 61 | 62 | + (id)sharedIndex 63 | { 64 | return _sharedIndex; 65 | } 66 | 67 | + (void)setSharedIndex:(SNRSearchIndex *)sharedIndex 68 | { 69 | if (sharedIndex != _sharedIndex) { 70 | _sharedIndex = sharedIndex; 71 | } 72 | } 73 | 74 | - (void)dealloc 75 | { 76 | SKIndexClose(_index); 77 | } 78 | 79 | - (BOOL)compact 80 | { 81 | return (BOOL)SKIndexCompact(_index); 82 | } 83 | 84 | - (BOOL)flush 85 | { 86 | return (BOOL)SKIndexFlush(_index); 87 | } 88 | 89 | - (BOOL)setKeywords:(NSArray*)keywords forObjectID:(NSManagedObjectID*)objectID 90 | { 91 | if (![keywords count]) { 92 | keywords = [NSArray arrayWithObject:@""]; 93 | } 94 | NSString *keywordString = [keywords componentsJoinedByString:@" "]; 95 | NSURL *objectURL = [objectID URIRepresentation]; 96 | SKDocumentRef document = SKDocumentCreateWithURL((__bridge CFURLRef)objectURL); 97 | BOOL result = SKIndexAddDocumentWithText(_index, document, (__bridge CFStringRef)keywordString, true); 98 | CFRelease(document); 99 | return result; 100 | } 101 | 102 | - (SNRSearchIndexSearch*)searchForQuery:(NSString*)query options:(SNRSearchIndexSearchOptions)options 103 | { 104 | SNRSearchIndexSearch *search = [[SNRSearchIndexSearch alloc] initWithSearchIndex:self query:query options:options]; 105 | return search; 106 | } 107 | 108 | - (CFIndex)numberOfObjects 109 | { 110 | return SKIndexGetDocumentCount(_index); 111 | } 112 | 113 | @end 114 | 115 | @implementation SNRSearchIndexSearch { 116 | BOOL _isSearching; 117 | SNRSearchIndexSearchOptions _options; 118 | SKSearchRef _search; 119 | } 120 | @synthesize searchIndex = _searchIndex; 121 | @synthesize search = _search; 122 | 123 | - (id)initWithSearchIndex:(SNRSearchIndex*)index query:(NSString*)query options:(SNRSearchIndexSearchOptions)options 124 | { 125 | if ((self = [super init])) { 126 | _searchIndex = index; 127 | _options = options; 128 | _search = SKSearchCreate(_searchIndex.index, (__bridge CFStringRef)query, _options); 129 | if (_search == NULL) { 130 | return nil; 131 | } 132 | } 133 | return self; 134 | } 135 | 136 | - (BOOL)findMatchesWithFetchLimit:(CFIndex)limit maximumTime:(NSTimeInterval)time handler:(void(^)(NSArray *results))handler 137 | { 138 | // Cancel an existing search if one is already going 139 | // Flush the index to commit all in-memory changes to the backing store 140 | [_searchIndex flush]; 141 | // Loop until there are no more matches 142 | SKDocumentID documentIDs[limit]; 143 | CFURLRef urls[limit]; 144 | float scores[limit]; 145 | CFIndex foundCount; 146 | Boolean result = SKSearchFindMatches(_search, limit, documentIDs, scores, time, &foundCount); 147 | // Copy the object URLs for the results 148 | SKIndexCopyDocumentURLsForDocumentIDs(_searchIndex.index, foundCount, documentIDs, urls); 149 | // Loop through the results and create search result objects for each match 150 | NSMutableArray *results = [NSMutableArray array]; 151 | for (CFIndex i = 0; i < foundCount; i++) { 152 | float score = scores[i]; 153 | CFURLRef url = urls[i]; 154 | NSManagedObjectID *objectID = [_searchIndex.persistentStoreCoordinator managedObjectIDForURIRepresentation:(__bridge NSURL*)url]; 155 | if (!objectID) { 156 | CFRelease(url); 157 | continue; 158 | } 159 | SNRSearchIndexSearchResult *result = [[SNRSearchIndexSearchResult alloc] initWithManagedObjectID:objectID relevanceScore:score]; 160 | [results addObject:result]; 161 | CFRelease(url); 162 | } 163 | // Call the completion handler 164 | if (handler) { 165 | handler(results); 166 | } 167 | return (BOOL)result; 168 | } 169 | 170 | - (void)cancel 171 | { 172 | SKSearchCancel(_search); 173 | } 174 | 175 | - (void)dealloc 176 | { 177 | if (_isSearching) { 178 | [self cancel]; 179 | } 180 | CFRelease(_search); 181 | } 182 | @end 183 | 184 | @implementation SNRSearchIndexSearchResult 185 | @synthesize objectID = _objectID; 186 | @synthesize score = _score; 187 | 188 | - (id)initWithManagedObjectID:(NSManagedObjectID *)objectID relevanceScore:(float)score 189 | { 190 | assert(objectID); 191 | if ((self = [super init])) { 192 | _objectID = objectID; 193 | _score = score; 194 | } 195 | return self; 196 | } 197 | 198 | @end 199 | --------------------------------------------------------------------------------