Почти всем, кто использует CoreData, рано или поздно приходится создавать запросы с агрегатными функциями и группировками. Однако, синтаксис таких запросов в CoreData сложен для понимания и неоправданно многословен.

Используя конструктор запросов мы можем, например, сделать вот такой запрос:
NSDictionary *productTotalSumAndAveragePriceGroupedByCountries = 
[[[[[Product all
] aggregatedBy:@[
                 @[kAggregateSum, @"amount"],
                 @[kAggregatorAverage, @"price"]]
] groupedBy:@[@"country"]
] having:predicate
] execute];

Этот запрос эквивалентен такому:
Запросу в CoreData
NSFetchRequest *fetchRequest = [[ALFetchRequest alloc] init];
fetchRequest.managedObjectContext = managedObjectContext;

NSString *entityName = @"Product";
NSEntityDescription *entity = [NSEntityDescription entityForName:entityName inManagedObjectContext:managedObjectContext];

[fetchRequest setEntity:entity];
[fetchRequest setIncludesPendingChanges:YES];

// sum amount
NSExpression *fieldExp1 = [NSExpression expressionForKeyPath:@"amount"];
NSExpression *agrExp1 = [NSExpression expressionForFunction:agr arguments:@[fieldExp1]];
NSExpressionDescription *resultDescription1 = [[NSExpressionDescription alloc] init];
NSString *resultName1 = @"sumAmount";
[resultDescription1 setName:resultName1];
[resultDescription1 setExpression:agrExp1];
[resultDescription1 setExpressionResultType:NSInteger64AttributeType];

// average price
NSExpression *fieldExp2 = [NSExpression expressionForKeyPath:@"price"];
NSExpression *agrExp2 = [NSExpression expressionForFunction:agr arguments:@[fieldExp1]];
NSExpressionDescription *resultDescription2 = [[NSExpressionDescription alloc] init];
NSString *resultName2 = @"sumAmount";
[resultDescription2 setName:resultName2];
[resultDescription2 setExpression:agrExp2];
[resultDescription2 setExpressionResultType:NSInteger64AttributeType];

// country
NSDictionary *availableKeys = [entity attributesByName];
NSAttributeDescription *country = [availableKeys valueForKey:@"country"];

fetch.propertiesToFetch = [NSArray arrayWithObjects:country, resultDescription1, resultDescription2, nil];
fetch.propertiesToGroupBy = [NSArray arrayWithObject:country];
fetch.resultType = NSDictionaryResultType;

NSError *error;
NSManagedObjectContext *managedObjectContext = self.managedObjectContext;
NSArray *fetchedObjects = [managedObjectContext executeFetchRequest:self error:&error];
if (!fetchedObjects || error) {
    NSLog(@"Error: Execution of the fetchRequest: %@, Failed with Description: %@",self,error);
}
return fetchedObjects;



В данной статье мне бы хотелось рассказать о небольшой библиотеке для работы с CoreData, которая появилась как обобщение моего скромного опыта разработки под iOS. Библиотека доступна в cocoapods.

Ядром библиотеки является синглтон класс ALCoreDataManager, который отвечает за инициализацию и подключение стэка CoreData и возвращает NSManagedObjectContext. Замечу, что это совершенно ординарная вещь и аналогов в этом плане — очень много. Все вкусности содержатся в категории ALFetchRequest+QueryBuilder и фактори классе ALManagedObjectFactory. Но обо всем по порядку.

Возможности библиотеки


Будем полагать, что модель Product определена так:
@interface Product : NSManagedObject
@property (nonatomic, retain) NSString *title;
@property (nonatomic, retain) NSNumber *price;
@property (nonatomic, retain) NSNumber *amount;
@property (nonatomic, retain) NSString *country;
@end

Запросы


Используя конструктор запросов, мы можем формировать, например, следующие запросы:
NSArray *allProducts = 
[[Product all] execute];

NSArray *productsFilteredWithPredicate = 
[[[Product all] where:predicate] execute];

NSArray *singleProduct = 
[[[[Product all] where:predicate] limit:1] execute];

NSArray *onlyDistinctProductTitles = 
[[[[Product all] properties:@[@"title"]] distinct] execute];

NSArray *countProducts =
[[[[Product all] where:predicate] count] execute]; // NSInteger count = [[countProducts firstObject] integerValue];

NSArray *productsOrderedByTitleAndPrice = 
[[[Product all
] orderedBy:@[
              @[@"title", kOrderDESC],
              @[@"price", kOrderASC],
              @[@"amount"]]
] execute];

NSArray *totalAmountAndAveragePriceForProducts = 
[[[[[Product all
] aggregatedBy:@[
                 @[kAggregateSum, @"amount"],
                 @[kAggregateAverage, @"price"]]
] groupedBy:@[@"country"]
] having:predicate
] execute];

Метод execute используется непосредственно для выполнения запроса. Для получения сформированного NSFetchRequest предназначен метод request. Например,
NSFetchRequest *request = [[[Product all] orderedBy:@[@"title", @"price"]] request];
NSManagedObjectContext *context = [ALCoreDataManager defaultManager].managedObjectContext;
NSFetchedResultsController *controller =
[[NSFetchedResultsController alloc] initWithFetchRequest:request
                                    managedObjectContext:context
                                      sectionNameKeyPath:nil
                                               cacheName:nil];
[controller performFetch:nil];

UITableViewDataSource


По имеющемуся запросу можно получить обьект, реализующий протокол UITableViewDataSource и управлеяемый NSFetchedResultsControllerом:
ALTableViewDataSource *dataSource =    
    [[[Product all] orderedBy:@[kTitle, kPrice]] tableViewDataSource];

self.dataSource.tableView = self.tableView;

Что избавит нас от написания скучного кода для реализации протокола UITableViewDataSource и делегата для NSFetchedResultsController-а. Аналогичный объект можно получить для UICollectionViewDataSource используя метод collectionViewDataSource.

Создание и удаление объектов


Создание и удаление обьектов возможно с использованием такого API:
Product *a = [Product create];
Product *b = [Product createWithDictionary:@{ @"title" : @"best product" }];

// или используя Factory-класс
NSManagedObjectContext *context = [ALCoreDataManager defaultManager].managedObjectContext;
ALManagedObjectFactory *factory =
[[ALManagedObjectFactory alloc] initWithManagedObjectContext:context];

Product *c = [Product createWithDictionary:nil 
                              usingFactory:factory];
c.title = @"best product 2";

Product *d = [Product createWithDictionary:@{ @"title" : @"best product 3", @"price" : @(100) } 
                              usingFactory:factory];

[d remove]; // удаляем объект

Последнее, что нужно отметить — вы обязаны перегрузить метод +entityName, если Entity Name некоторого ManagedObject-а не совпадает с его Class Name (естественно, сделать это необходимо в соответствующей категории).
@implementation Product
+ (NSString*)entityName
{
    return @"AnItem";
}
@end


Example


Продемонстрируем профит от использования библиотеки на примере. После скачивания и разархивирования библиотеки необходимо установить зависимости:
cd /Users/you/Downloads/ALCoreDataManager-master/Example
pod install


В Storyboard-е все достаточно просто:

В первом TableViewController-е отображается список всех Product-ов; во втором отображается информация по выбранному Product-у, которую можно там же отредактировать.

Заполнение таблицы происходит с помощью упоминавшегося ранее ALTableViewDataSource-а:
- (void)viewDidLoad
{
    [super viewDidLoad];
    
    self.dataSource = [[[Product all] orderedBy:@[kTitle, kPrice]] tableViewDataSource];
    __weak typeof(self) weakSelf = self;
    self.dataSource.cellConfigurationBlock = ^(UITableViewCell *cell, NSIndexPath *indexPath){
        [weakSelf configureCell:cell atIndexPath:indexPath];
    };
    self.dataSource.reuseIdentifierBlock = ^(NSIndexPath *indexPath){
        return TableViewCellReuseIdentifier;
    };
    self.dataSource.tableView = self.tableView;
}

Верите или нет, но это весь код для TableView.

По нажатию на Add, создаем элемент, сказав:
[Product createWithFields:@{
                            kTitle : title,
                            kPrice : @(0),
                            kAmount : @(0)
                            }
             usingFactory:[ALManagedObjectFactory defaultFactory]];

Покажем некоторую статистическую информацию по нажатию на Statistics:


После выбора типа статистики отрабатывает код:
ALFetchRequest *request = nil;
switch (st) {
    case ALStatsTypeTotalAmount:
        request = [[Product all] aggregatedBy:@[@[kAggregatorSum, kAmount]]];
        break;
    case ALStatsTypeMedianPrice:
        request = [[Product all] aggregatedBy:@[@[kAggregatorAverage, kPrice]]];
        break;
    default:
        break;
}
NSArray *result = [request execute]; // request будет иметь тип NSDictionaryResultType
NSDictionary *d = [result firstObject];
// Примерный результат:
// {
//    sumAmount = 1473;
// }

Вот и весь код с агрегаторными функциями (для сравнения — request с агрегатной функцией (stackoverflow)).

После выполнения получим такой AlertView:


Как это работает


Формирование запроса начинается с вызова:
+ (ALFetchRequest*)allInManagedObjectContext:(NSManagedObjectContext*)managedObjectContext;
+ (ALFetchRequest*)all; // внутри вызов allInManagedObjectContext с defaultContext

То есть просто приводит к созданию NSFetchRequest-а:
+ (ALFetchRequest*)allInManagedObjectContext:(NSManagedObjectContext*)managedObjectContext
{
	ALFetchRequest *fetchRequest = [[ALFetchRequest alloc] init];
	fetchRequest.managedObjectContext = managedObjectContext;
	NSEntityDescription *entity = [self entityDescriptionWithMangedObjectContext:managedObjectContext];

	[fetchRequest setEntity:entity];
	[fetchRequest setIncludesPendingChanges:YES];

	return fetchRequest;
}

Практически весь код builder-а находится в ALFetchRequest+QueryBuilder.m.

Каждый из вызовов вида
[[[Product all] orderedBy:@[kTitle, kPrice]] limit:1];

Просто приводит к добавлению необходимых настроек в созданный NSFetchRequest, например:
- (ALFetchRequest*)limit:(NSInteger)limit
{
	self.fetchLimit = limit;
	return self;
}

Мотоды execute и request:
- (NSArray*)execute
{
	NSError *error;
	NSManagedObjectContext *managedObjectContext = self.managedObjectContext;
	NSArray *fetchedObjects = [managedObjectContext executeFetchRequest:self error:&error];
	if (!fetchedObjects || error) {
		NSLog(@"Error: Execution of the fetchRequest: %@, Failed with Description: %@",self,error);
	}
	return fetchedObjects;
}
- (NSFetchRequest *)request
{
	return (NSFetchRequest*)self;
}

Можно считать, что это просто syntactic sugar для NSFetchRequest. Очевидно, overhead практически равен нулю. Чуть больше примеров можно найти в тестах.

На этом повествование спешу закончить. Благодарю за внимание.

Комментарии (9)


  1. ncix
    24.08.2015 15:50

    NSDictionary *productTotalSumAndAveragePriceGroupedByCountries =
    [[[[[Product all
    ] aggregatedBy:@[
    @[kAggregateSum, @«amount»],
    @[kAggregatorAverage, @«price»]]
    ] groupedBy:@[@«country»]
    ] having:predicate
    ] execute];
    Это вместо обычного SQL-запроса?


    1. Naftic
      24.08.2015 17:00

      Это вместо NSFetchRequest-а. Упомянутый вами request полностью эквивалентен

      NSFetchRequest *fetchRequest = [[ALFetchRequest alloc] init];
      fetchRequest.managedObjectContext = managedObjectContext;
      
      NSString *entityName = @"Product";
      NSEntityDescription *entity = [NSEntityDescription entityForName:entityName inManagedObjectContext:managedObjectContext];
      
      [fetchRequest setEntity:entity];
      [fetchRequest setIncludesPendingChanges:YES];
      
      // sum amount
      NSExpression *fieldExp1 = [NSExpression expressionForKeyPath:@"amount"];
      NSExpression *agrExp1 = [NSExpression expressionForFunction:agr arguments:@[fieldExp]];
      NSExpressionDescription *resultDescription1 = [[NSExpressionDescription alloc] init];
      NSString *resultName1 = @"sumAmount";
      [resultDescription1 setName:resultName1];
      [resultDescription1 setExpression:agrExp1];
      [resultDescription1 setExpressionResultType:NSInteger64AttributeType];
      
      // average price
      NSExpression *fieldExp2 = [NSExpression expressionForKeyPath:@"price"];
      NSExpression *agrExp2 = [NSExpression expressionForFunction:agr arguments:@[fieldExp]];
      NSExpressionDescription *resultDescription2 = [[NSExpressionDescription alloc] init];
      NSString *resultName2 = @"sumAmount";
      [resultDescription2 setName:resultName2];
      [resultDescription2 setExpression:agrExp2];
      [resultDescription2 setExpressionResultType:NSInteger64AttributeType];
      
      // country
      NSDictionary *availableKeys = [entity attributesByName];
      NSAttributeDescription *country = [availableKeys valueForKey:@"country"];
      
      fetch.propertiesToFetch = [NSArray arrayWithObjects:country, resultDescription1, resultDescription2, nil];
      fetch.propertiesToGroupBy = [NSArray arrayWithObject:country];
      fetch.resultType = NSDictionaryResultType;
      
      NSError *error;
      NSManagedObjectContext *managedObjectContext = self.managedObjectContext;
      NSArray *fetchedObjects = [managedObjectContext executeFetchRequest:self error:&error];
      if (!fetchedObjects || error) {
          NSLog(@"Error: Execution of the fetchRequest: %@, Failed with Description: %@",self,error);
      }
      return fetchedObjects;
      


      Но читать его легче, на мой взгляд.


      1. ncix
        24.08.2015 17:29

        Я просто немного не понимаю в чем тут «сахар», если есть язык SQL, на котором все это выглядит куда понятнее. И этому языку уже не один десяток лет.

        Я довольно далек от iOS — разработки, наверное дело в том, что нельзя просто так взять и сделать SQL-запрос из данного приложения к этим данным?


        1. Naftic
          24.08.2015 18:46

          Да, просто SQL запрос в CoreData сделать не получится. Но дело не только в этом.

          В CoreData есть такая замечательная пара классов как NSFetchedResultsController и UITableViewController. Они очень оптимизированы и удобны в использовании. Так например, даже если в результате выполнения запроса мы получаем миллион записей, то FetchedResultsController загрузит в память только ту часть, которая видна из TableView. Если где-то в другом контроллере кто-то добавит, удалит или изменит содержимое отображаемых данных, то FRC автоматически стянет измененные данные в TableView с использованием приятных глазу анимаций.


          1. greenkaktus
            25.08.2015 01:28
            +1

            Выгоды от CoreData не всегда могут быть столь однозначны. Можно к SQLite взять ActiveRecords или еще какой FMDB и количество кода для UITableViewContoller будет почти таким же, а запросы будут на чистом SQL.

            Тут каждый решает сам в каждом конкретном кейсе.


            1. Naftic
              25.08.2015 09:33

              По правде, я не использовал FMDB и интересно извещает ли он об изменениях. То есть, допустим я сделал селект всех продуктов, перешел на другой скрин и там добавил новый продукт. В первый TableView мне придет извещение, что данные изменились и их надо забрать?


            1. stuit
              25.08.2015 17:51

              Да, если Вы работаете только с sqlite. Но возможности CoreData намного шире чем работа только с одной базой данных.


              1. greenkaktus
                25.08.2015 17:58

                Конечно, я про sqlite паттерны только и написал. UIDocument никто не отменял вообщем-то)


  1. Naftic
    24.08.2015 16:59

    =