25
Nov
2008
 

Adding iTunes-style search to your Core Data application

by Fraser Hess

iTunes has a very neat way of searching your library, where it takes each word in your search and tries to find that word in multiple fields. For example, you can search for “yesterday beatles” and it will match “yesterday” in the Name field and “beatles” in the Artist field. The basic predicate binding for NSSearchField provided by Interface Builder is not complex enough to archive this kind of search. I need to build the predicate dynamically since I can’t assume what field the user is trying to search and that each additional word should filter the list further – just like iTunes. Here is how to go about adding iTunes-style searching.

In my application I have a simple Core Data model with one entity (song) and 3 attributes (name, album and artist).

Add the following to iTunesFilter_AppDelegate.h:

IBOutlet NSSearchField *searchField;
IBOutlet NSArrayController *songArrayController;

and

- (IBAction)filterSongs:(id)sender;

With those changes I can go into Interface Builder and wire up the NSSearchField to searchField and my NSArrayController to songArrayController. I also make the action of the NSSearchField, filterSongs:.

I have one more change and that is to make the Action of the NSSearchField “Sent on End Editing”

All we need now is to add the code for -filterSongs to iTunesFilter_AppDelegate.m:

- (IBAction)filterSongs:(id)sender {
NSMutableString *searchText = [NSMutableString stringWithString:[searchField stringValue]];

// Remove extraenous whitespace
while ([searchText rangeOfString:@"Â  "].location != NSNotFound) {
    [searchText replaceOccurrencesOfString:@"Â  " withString:@" " options:0 range:NSMakeRange(0, [searchText length])];
}

//Remove leading space
if ([searchText length] != 0) [searchText replaceOccurrencesOfString:@" " withString:@"" options:0 range:NSMakeRange(0,1)];

//Remove trailing space
if ([searchText length] != 0) [searchText replaceOccurrencesOfString:@" " withString:@"" options:0 range:NSMakeRange([searchText length]-1, 1)];

if ([searchText length] == 0) {
    [songArrayController setFilterPredicate:nil];
    return;
}

NSArray *searchTerms = [searchText componentsSeparatedByString:@" "];

if ([searchTerms count] == 1) {
    NSPredicate *p = [NSPredicate predicateWithFormat:@"(name contains[cd] %@) OR (album contains[cd] %@) OR (artist contains[cd] %@)", searchText, searchText, searchText];
    [songArrayController setFilterPredicate:p];
} else {
    NSMutableArray *subPredicates = [[NSMutableArray alloc] init];
    for (NSString *term in searchTerms) {
        NSPredicate *p = [NSPredicate predicateWithFormat:@"(name contains[cd] %@) OR (album contains[cd] %@) OR (artist contains[cd] %@)", term, term, term];
        [subPredicates addObject:p];
    }
    NSPredicate *cp = [NSCompoundPredicate andPredicateWithSubpredicates:subPredicates];

    [songArrayController setFilterPredicate:cp];
}
}

Here’s how it works:
After we remove extra whitespace in the string, we breakdown the string into each word (-componentsSeparatedByString:). Then we build an NSPredicate for each word in the string, before the real magic happens by combining these together using NSCompoundPredicate andPredicateWithSubpredicates:

In the end I get a predicate that looks like this:
((name contains[cd] yesterday) or (artist contains[cd] yesterday) or (album contains[cd] yesterday)) and ((name contains[cd] beatles) or (artist contains[cd] beatles) or (album contains[cd] beatles))

iTunesFilter Sample Application

Comments

benzado says:

How well does that perform?

An alternative would be to maintain an indexed “all text” field that contains the concatenation of all the text of the other fields. Then the query need only be performed against one index.

Just in case you didn’t know, you can very easily add this functionality via the predicate binding in Interface Builder. In my case I am binding to an array controller w/ a controller key of filterPredicate and my predicate format is as follows:

firstName contains[c] $value || lastName contains[c] $value || agent contains[c] $value || role.title contains[c] $value

I also have additional predicate bindings for each individual field so the user can click on the arrow to choose more specifically what they want to search.

Fraser Hess says:

I don’t think your predicate format will get my desired result: searching for every term in every field. The big problem I ran into is that the predicate format has to change when the user adds a second term.
One term: “world” in name OR “world” in artist OR “world” in album
Two terms: “world” in name OR “world” in artist OR “world” in album AND “mayer” in name OR “mayer” in artist OR “mayer” in album

I hope that clears up my intention and desired result.

Also, in my app I don’t want the user to have to think about what field they are searching in. I just want them to type.

Fraser Hess says:

Good question, cause I’m not sure. However even if we concatenated the fields together, we’d still have to rip apart the search string and look for each term individually since we’re searching for terms and not and exact phrase.

[…] a nice solution for this iTunes like search from Cocoa Is My Girlfriend. Category: Core Data You can follow any responses to this entry through the RSS 2.0 feed. […]