71

Learning Core Data on the iPhone. There seem to be few examples on Core Data populating a table view with sections. The CoreDataBooks example uses sections, but they're generated from full strings within the model. I want to organize the Core Data table into sections by the first letter of a last name, a la the Address Book.

I could go in and create another attribute, i.e. a single letter, for each person in order to act as the section division, but this seems kludgy.

Here's what I'm starting with ... the trick seems to be fooling the sectionNameKeyPath:

- (NSFetchedResultsController *)fetchedResultsController {
//.........SOME STUFF DELETED
    // Edit the sort key as appropriate.
    NSSortDescriptor *orderDescriptor = [[NSSortDescriptor alloc] initWithKey:@"personName" ascending:YES];
    NSArray *sortDescriptors = [[NSArray alloc] initWithObjects:orderDescriptor, nil];

    [fetchRequest setSortDescriptors:sortDescriptors];
    // Edit the section name key path and cache name if appropriate.
    // nil for section name key path means "no sections".
    NSFetchedResultsController *aFetchedResultsController = 
            [[NSFetchedResultsController alloc] initWithFetchRequest:fetchRequest 
            managedObjectContext:managedObjectContext 
            sectionNameKeyPath:@"personName" cacheName:@"Root"];
//....
}
jszumski
  • 7,430
  • 11
  • 40
  • 53
Greg Combs
  • 4,252
  • 3
  • 33
  • 47
  • IMO, going ahead and creating another attribute in DB would be justified because you could then create an index on that field which would very much benefit in terms of performance. That would go well with `sectionNameKeyPath` even in the case when you have thousands of records in DB. – mixtly87 Oct 06 '19 at 09:22

7 Answers7

62

Dave DeLong's approach is good, at least in my case, as long as you omit a couple of things. Here's how it's working for me:

  • Add a new optional string attribute to the entity called "lastNameInitial" (or something to that effect).

    Make this property transient. This means that Core Data won't bother saving it into your data file. This property will only exist in memory, when you need it.

    Generate the class files for this entity.

    Don't worry about a setter for this property. Create this getter (this is half the magic, IMHO)


// THIS ATTRIBUTE GETTER GOES IN YOUR OBJECT MODEL
- (NSString *) committeeNameInitial {
    [self willAccessValueForKey:@"committeeNameInitial"];
    NSString * initial = [[self committeeName] substringToIndex:1];
    [self didAccessValueForKey:@"committeeNameInitial"];
    return initial;
}


// THIS GOES IN YOUR fetchedResultsController: METHOD
// Edit the sort key as appropriate.
NSSortDescriptor *nameInitialSortOrder = [[NSSortDescriptor alloc] 
        initWithKey:@"committeeName" ascending:YES];

[fetchRequest setSortDescriptors:[NSArray arrayWithObject:nameInitialSortOrder]];

NSFetchedResultsController *aFetchedResultsController = 
        [[NSFetchedResultsController alloc] initWithFetchRequest:fetchRequest 
        managedObjectContext:managedObjectContext 
        sectionNameKeyPath:@"committeeNameInitial" cacheName:@"Root"];

PREVIOUSLY: Following Dave's initial steps to the letter generated issues where it dies upon setPropertiesToFetch with an invalid argument exception. I've logged the code and the debugging information below:

NSDictionary * entityProperties = [entity propertiesByName];
NSPropertyDescription * nameInitialProperty = [entityProperties objectForKey:@"committeeNameInitial"];
NSArray * tempPropertyArray = [NSArray arrayWithObject:nameInitialProperty];

//  NSARRAY * tempPropertyArray RETURNS:
//    <CFArray 0xf54090 [0x30307a00]>{type = immutable, count = 1, values = (
//    0 : (<NSAttributeDescription: 0xf2df80>), 
//    name committeeNameInitial, isOptional 1, isTransient 1,
//    entity CommitteeObj, renamingIdentifier committeeNameInitial, 
//    validation predicates (), warnings (), versionHashModifier (null), 
//    attributeType 700 , attributeValueClassName NSString, defaultValue (null)
//    )}

//  NSInvalidArgumentException AT THIS LINE vvvv
[fetchRequest setPropertiesToFetch:tempPropertyArray];

//  *** Terminating app due to uncaught exception 'NSInvalidArgumentException',
//    reason: 'Invalid property (<NSAttributeDescription: 0xf2dfb0>), 
//    name committeeNameInitial, isOptional 1, isTransient 1, entity CommitteeObj, 
//    renamingIdentifier committeeNameInitial, 
//    validation predicates (), warnings (), 
//    versionHashModifier (null), 
//    attributeType 700 , attributeValueClassName NSString, 
//    defaultValue (null) passed to setPropertiesToFetch: (property is transient)'

[fetchRequest setReturnsDistinctResults:YES];

NSSortDescriptor * nameInitialSortOrder = [[[NSSortDescriptor alloc]
    initWithKey:@"committeeNameInitial" ascending:YES] autorelease];

[fetchRequest setSortDescriptors:[NSArray arrayWithObject:nameInitialSortOrder]];

NSFetchedResultsController *aFetchedResultsController = [[NSFetchedResultsController alloc] 
    initWithFetchRequest:fetchRequest 
    managedObjectContext:managedObjectContext 
    sectionNameKeyPath:@"committeeNameInitial" cacheName:@"Root"];
Greg Combs
  • 4,252
  • 3
  • 33
  • 47
  • 1
    Major kudos - using 'committeeName' for the sort descriptor vs 'committeeNameInitial' for the sectionNameKeyPath was a huge help. – Luther Baker Jul 24 '10 at 16:16
  • 3
    How do you avoid "keypath X not found in entity" ? do you have to put this in the model designer file as well? – M. Ryan Jan 25 '12 at 17:43
  • In my project, I used the transient property as the sectionNameKeyPath and the non-transient property as the key for the sole sort descriptor of the fetched result controller's fetch request, and this fixed the keypath-not-found problem for me. – Jacob Nov 09 '12 at 00:02
  • How does this implementation perform with large data sets? Isn't it necessary to load the whole data into memory to get the section indexes when doing it like this? – fabb Dec 22 '14 at 11:39
55

I think I've got yet another option, this one uses a category on NSString...

@implementation NSString (FetchedGroupByString)
- (NSString *)stringGroupByFirstInitial {
    if (!self.length || self.length == 1)
        return self;
    return [self substringToIndex:1];
}
@end

Now a little bit later on, while constructing your FRC:

- (NSFetchedResultsController *)newFRC {
    NSFetchedResultsController *frc = [[NSFetchedResultsController alloc] initWithFetchRequest:awesomeRequest
            managedObjectContext:coolManagedObjectContext
            sectionNameKeyPath:@"lastName.stringGroupByFirstInitial"
            cacheName:@"CoolCat"];
    return frc;
}

This is now my favorite approach. Much cleaner/easier to implement. Moreover, you don't have to make any changes to your object model class to support it. This means that it'll work on any object model, provided the section name points to a property based on NSString

Greg Combs
  • 4,252
  • 3
  • 33
  • 47
  • 6
    Be warned that if you have an extremely large number of rows/objects, this pseudo-transient property in a category approach will cause each FRC fetch to be orders of magnitude slower than if you were grouping by an existing real attribute on the model. (By extremely large, I mean tens of thousands of rows). – Greg Combs Nov 20 '12 at 20:43
  • @GregCombs Is the methods willAccessValueForKey: and didAccessValueForKey: the only way to avoid the problem you are saying ? – klefevre Dec 16 '14 at 10:30
  • 1
    @kl94,the willAccessValueForKey:/didAccessValueForKey: approach won't solve the giant-collection performance problem, since it's basically doing the same thing at runtime -- string munging for each row in the collection. If performance is a concern, it's best to make a concrete string property in the data model for lastNameInitial and then update that calculated value whenever the "lastName" property changes. That way you're only calculating strings just once per item in the list (+ any future edits), not every time you load the data table. – Greg Combs Jan 05 '15 at 16:58
  • Implemented and getting this error; Error: { reason = "The fetched object at index 14 has an out of order section name 'P. Objects must be sorted by section name'"; – user3404693 Jan 14 '15 at 12:26
  • This method only works for relatively simple scenarios where the value derived from `lastName` does not change the sort order, right? – Drux Feb 08 '15 at 13:33
  • 3
    just an important hint: when using this solution and setting sort descriptions where you get different Characaters (A or a), use in your sort descriptor this way with selector: ` selector:@selector(localizedCaseInsensitiveCompare:)` . then you should not get the warning `The fetched object at index 14 has an out of order section name 'P. Objects must be sorted by section name'` – brush51 Mar 23 '15 at 12:18
  • To follow up with @brush51 - If the app is used with other NSLocales, definitely should take advantage of UILocalizedIndexedCollation and use that for grouping instead. `NSInteger section = [[UILocalizedIndexedCollation currentCollation] sectionForObject:lastInitial collationStringSelector:@selector(description)];` – Greg Combs Mar 31 '16 at 18:03
15

Here's how you might get it to work:

  • Add a new optional string attribute to the entity called "lastNameInitial" (or something to that effect).
  • Make this property transient. This means that Core Data won't bother saving it into your data file. This property will only exist in memory, when you need it.
  • Generate the class files for this entity.
  • Don't worry about a setter for this property. Create this getter (this is half the magic, IMHO)

    - (NSString *) lastNameInitial {
    [self willAccessValueForKey:@"lastNameInitial"];
    NSString * initial = [[self lastName] substringToIndex:1];
    [self didAccessValueForKey:@"lastNameInitial"];
    return initial;
    }
  • In your fetch request, request ONLY this PropertyDescription, like so (this is another quarter of the magic):

    NSDictionary * entityProperties = [myEntityDescription propertiesByName];
    NSPropertyDescription * lastNameInitialProperty = [entityProperties objectForKey:@"lastNameInitial"];
    [fetchRequest setPropertiesToFetch:[NSArray arrayWithObject:lastNameInitialProperty]];
  • Make sure your fetch request ONLY returns distinct results (this is the last quarter of the magic):

    [fetchRequest setReturnsDistinctResults:YES];
  • Order your results by this letter:

    NSSortDescriptor * lastNameInitialSortOrder = [[[NSSortDescriptor alloc] initWithKey:@"lastNameInitial" ascending:YES] autorelease];
    [fetchRequest setSortDescriptors:[NSArray arrayWithObject:lastNameInitialSortOrder]];
  • execute the request, and see what it gives you.

If I understand how this works, then I'm guessing it will return an array of NSManagedObjects, each of which only has the lastNameInitial property loaded into memory, and who are a set of distinct last name initials.

Good luck, and report back on how this works. I just made this up off the top of my head and want to know if this works. =)

Dave DeLong
  • 242,470
  • 58
  • 448
  • 498
  • This certainly sounds promising, thank you so much! I'll let you know how it works, as I imagine others will face this same issue soon enough. – Greg Combs Jul 11 '09 at 05:21
  • [Update] It looks like you may be more right than wrong. If I just use the getter-driven attribute in the standard example code, without all the property setting business, I get the proper number of sections. – Greg Combs Jul 11 '09 at 15:39
  • @Greg cool! I wasn't sure if the PropertyDescription was necessary, but I thought it might be. – Dave DeLong Jul 11 '09 at 17:36
  • I'm wondering what the impact is on performance though, if you have many many records. With N records, I think that this method would have to make N queries into the backing store, whereas if you used a "real" key path it might be able to do it in only a single query. – Simon Woodside Aug 10 '09 at 04:57
  • @sbwoodside I don't know. I'm using it with 181 records (tableview cells) and it does fine. But I don't know what would happen if you had to do this thousands of times. I suspect if that were the case, you'd want to work up a proper dictionary or something. I was more aimed at simplicity and clarity, since I don't have that many records anyway. – Greg Combs Aug 16 '09 at 21:53
  • @sbwoodside -- Updated, you're correct, performance is a problem with a large-N. From my follow-up comment to kl94 for one of the alternative answers above, and like Mike-Baldus: If performance is a concern, it's best to make a concrete string property in the data model for lastNameInitial and then update that calculated value whenever the "lastName" property changes. That way you're only calculating strings just once per item in the list (+ any future edits), not every time you load the data table. – Greg Combs Jan 05 '15 at 17:04
8

I like Greg Combs answer above. I've made a slight modification so that strings like "Smith" and "smith" can appear in the same section by converting the strings to upper case:

- (NSString *)stringGroupByFirstInitial {
    NSString *temp = [self uppercaseString];
    if (!temp.length || temp.length == 1)
        return self;
    return [temp substringToIndex:1];
}
fawsha1
  • 780
  • 8
  • 13
6

I encounter this issue all the time. The solution that seems best that i always come back to is to just give the entity a real first initial property. Being a real field provides for more efficient searching and ordering as you can set the field to indexed. It doesn't seem like it's too much work to pull the first initial out and populate a second field with it when the data is first imported / created. You have to write that initial parsing code either way, but you could do it once per entity and never again. The drawbacks seem to be you are storing one extra character per entity (and the indexing) really, that's likely insignificant.

One extra note. I shy away from modifying the generated entity code. Maybe i'm missing something, but the tools for generating CoreData entities do not respect any code i might have put in there. Either option i pick when generating the code removes any customizations i might have made. If i fill up my entities with clever little functions, then i need to add a bunch of properties to that entity, i can't regenerate it easily.

Mike Baldus
  • 61
  • 1
  • 2
  • 4
    I get around this by keeping the core data generated files pristine, then simply create categories for any additional helper methods for these classes. – Greg Combs Jun 14 '11 at 04:18
  • 1
    Use mogenerator to create entity code. It will create two classes: one you don't touch which will be updated as you make your core data evolve, the other one in which you can do everything you want. (Old WebObjects trick) – Cyril Godefroy Dec 14 '11 at 09:06
3

swift 3

first, create extension to NSString (because CoreData is using basically NSString)

extension NSString{
    func firstChar() -> String{
        if self.length == 0{
            return ""
        }
        return self.substring(to: 1)
    }
}

Then sort using firstChar keypath, in my case, lastname.firstChar

request.sortDescriptors = [
            NSSortDescriptor(key: "lastname.firstChar", ascending: true),
            NSSortDescriptor(key: "lastname", ascending: true),
            NSSortDescriptor(key: "firstname", ascending: true)
        ]

And Finally Use the firstChar keypath for sectionNameKeyPath

let controller = NSFetchedResultsController(fetchRequest: request, managedObjectContext: context, sectionNameKeyPath: "lastname.firstChar", cacheName: "your_cache_name")
Benny Davidovitz
  • 1,152
  • 15
  • 18
  • I am trying to use this method. Running into: ```NSString - this class is not key value coding-compliant for the key firstLetter``` I am obviously using a more recent version of XCode (12.3). I tried adding @objc but that didn't help. Any idea what's wrong? – benjamin ratelade Jan 17 '21 at 09:26
-1

I think I have a better way to do this. Instead of using transient property, in view will appear. Recalculate the derived property of the NSManagedObject and save the context.After the changes you can just reload the table view.

Here is an example of calculating the number of edges of each vertex, then sort the vertexes by the number of the edges. In this example, Capsid is vertex, touch is edge.

- (void)controllerDidChangeContent:(NSFetchedResultsController *)controller
{
    [self.tableView endUpdates];
    [self.tableView reloadData];
}

- (void)viewWillAppear:(BOOL)animated
{
    [super viewWillAppear:animated];
    NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:@"Capsid"];
    NSError *error = nil;
    NSArray *results = [self.managedObjectContext executeFetchRequest:request error:&error];
    if (error) {
        NSLog(@"refresh error");
        abort();
    }
    for (Capsid *capsid in results) {
        unsigned long long sum = 0;
        for (Touch *touch in capsid.vs) {
            sum += touch.count.unsignedLongLongValue;
        }
        for (Touch *touch in capsid.us) {
            sum += touch.count.unsignedLongLongValue;
        }
        capsid.sum = [NSNumber numberWithUnsignedLongLong:sum];
    }
    if (![self.managedObjectContext save:&error]) {
        NSLog(@"save error");
        abort();
    }
}

- (NSFetchedResultsController *)fetchedResultsController
{
    if (__fetchedResultsController != nil) {
        return __fetchedResultsController;
    }

    // Set up the fetched results controller.
    // Create the fetch request for the entity.
    NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] init];
    // Edit the entity name as appropriate.
    NSEntityDescription *entity = [NSEntityDescription entityForName:@"Capsid" inManagedObjectContext:self.managedObjectContext];
    [fetchRequest setEntity:entity];

    // Set the batch size to a suitable number.
    [fetchRequest setFetchBatchSize:20];

    // Edit the sort key as appropriate.
    //    NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"timeStamp" ascending:NO];
//    NSSortDescriptor *sumSortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"sum" ascending:NO];
//    NSArray *sortDescriptors = [NSArray arrayWithObjects:sumSortDescriptor, nil];
    [fetchRequest setReturnsDistinctResults:YES];
    NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"sum" ascending:NO];
    NSArray *sortDescriptors = [NSArray arrayWithObject:sortDescriptor];
    [fetchRequest setSortDescriptors:sortDescriptors];


    // Edit the section name key path and cache name if appropriate.
    // nil for section name key path means "no sections".
    NSFetchedResultsController *aFetchedResultsController = [[NSFetchedResultsController alloc] initWithFetchRequest:fetchRequest managedObjectContext:self.managedObjectContext sectionNameKeyPath:nil cacheName:nil];
    aFetchedResultsController.delegate = self;
    self.fetchedResultsController = aFetchedResultsController;

    NSError *error = nil;
    if (![self.fetchedResultsController performFetch:&error]) {
        /*
         Replace this implementation with code to handle the error appropriately.

         abort() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. 
         */
        NSLog(@"Unresolved error %@, %@", error, [error userInfo]);
        abort();
    }

    return __fetchedResultsController;
} 
user698333
  • 78
  • 1