Когда только начинал использовать CoreData в iOS разработке, то уже тогда появлялись мысли о том, что это взаимодействие можно как-то упростить. Спустя некоторое время я познакомился с ActiveRecord из RubyOnRails, и тогда я понял чего мне не хватает.
Немного поискав на гитхабе нашел массу реализаций, но по разным причинам они мне не понравились. Одни написаны для CoreData, а мне она не нравится, в других нужно создавать таблицы руками, или писать raw sql-запросы. А в каких-то код был до неприличия ужасен, я и сам порой пишу не очень чисто, но огромный забор из вложенных if/switch/if/switch это чересчур.
В конце концов решил написать свой велосипед, без CoreData и без SQL для пользователя.
Главной причиной этой разработки был, есть и, надеюсь, будет — интерес к разработке.
Вот что из этого всего вышло.
А под катом небольшое описание возможностей и реализации (на самом деле много текста и кусков кода, резюме в самом конце статьи).
Создание таблиц
Первой проблемой было создание таблиц.
В случае с CoreData ничего создавать не надо, нужно только описать сущности, а все остальное сделает CD.
Я долго думал над тем как бы это лучше оформить, и через время меня осенило.
Objective-C позволяет получить список всех подклассов для любого класса, и кроме того получить список всех его свойств. Таким образом описанием сущности будет являться простое описание класса, а все что мне останется сделать, так это собрать эту информацию и скомпоновать SQL-запрос на ее основе.
Описание сущности
@interface User : ActiveRecord @property (nonatomic, retain) NSString *name; @end
Получение всех подклассов
static NSArray *class_getSubclasses(Class parentClass) { int numClasses = objc_getClassList(NULL, 0); Class *classes = NULL; classes = malloc(sizeof(Class) * numClasses); numClasses = objc_getClassList(classes, numClasses); NSMutableArray *result = [NSMutableArray array]; for (NSInteger i = 0; i < numClasses; i++) { Class superClass = classes[i]; do{ superClass = class_getSuperclass(superClass); } while(superClass && superClass != parentClass); if (superClass == nil) { continue; } [result addObject:classes[i]]; } return result; }
Получение всех свойств вплоть до базового класса
Class BaseClass = NSClassFromString(@"NSObject"); id CurrentClass = aRecordClass; while(nil != CurrentClass && CurrentClass != BaseClass){ unsigned int outCount, i; objc_property_t *properties = class_copyPropertyList(CurrentClass, &outCount); for (i = 0; i < outCount; i++) { // do something with concrete property => properties[i] } CurrentClass = class_getSuperclass(CurrentClass); }
Типы данных
Для большей гибкости пришлось отказаться от базовых типов данных (int, double etc.) и работать только с классами в качестве полей таблицы.
Таким образом в качестве поля таблицы можно использовать любой класс, единственное требование: он должен сам уметь сохранять и загружать себя.
Для этого он должен реализовывать ARRepresentationProtocol
@protocol ARRepresentationProtocol @required + (const char *)sqlType; - (NSString *)toSql; + (id)fromSql:(NSString *)sqlData; @end
Я реализовал эти методы для базовых типов Foundation фрэймворка при помощи категорий
— NSDecimalNumber — real
— NSNumber — integer
— NSString — text
— NSData — blob
— NSDate — date (real)
но набор этих классов может быть расширен в любой момент, без особого труда.
Того же позволяет добиться и CoreData с типом данных Transformable, но я до сих пор так и не осилил как с ним работать.
CRUD для записей
Create
Процесс создания новой записи очень прост и прозрачен
User *user = [User newRecord]; user.name = @"Alex"; [user save];
Read
Получение всех записей
NSArray *users = [User allRecords];
Зачастую все записи не нужны, по-этому я добавил реализацию фильтров, но о них позже.
Update
User *user = [User newRecord]; user.name = @"Alex"; [user save]; NSArray *users = [User allRecords]; User *userForUpdate = [users first]; userForUpdate.name = @"John"; [userForUpdate update]; // или [userForUpdate save];
ActiveRecord следит за изменением всех свойств, и при обновлении создает запрос только на обновление измененных полей.
Delete
NSArray *users = [User allRecords]; User *userForRemove = [users first]; [userForRemove dropRecord];
Все записи имеют свойство id(NSNumber), по которому производится удаление.
Ненужные поля
Как быть с полями которые нам не нужно сохранять в базу? Просто игнорировать их :)
Для этого в реализации класса нужно добавить следующую конструкцию, это простой сишный макрос.
@implementation User ... @synthesize ignoredProperty; ... ignore_fields_do( ignore_field(ignoredProperty) ) ... @end
Валидация
Одним из тербований, которые я ставил перед собой в разработке, это поддержка валидаций.
На данный момент реализовано два типа валидации: на наличие и на уникальность.
Синтаксис прост, и также использует сишные макросы. Кроме того класс должен реализовывать ARValidatableProtocol, от пользователя ничего не требуется, это сделано для того, чтобы не запускать механизм валидации для классов, которые ее не используют.
// User.h @interface User : ActiveRecord <ARValidatableProtocol> ... @property (nonatomic, copy) NSString *name; ... @end // User.m @implementation User ... validation_do( validate_uniqueness_of(name) validate_presence_of(name) ) ... @end
Кроме того я реализовал поддержку кастомных валидаторов, которые может добавлять сам пользователь.
Для этого необходимо создать класс-валидатор, который должен реализовывать ARValidatorProtocol и описать его в валидируемом классе.
ARValidatorProtocol
@protocol ARValidatorProtocol <NSObject> @optional - (NSString *)errorMessage; @required - (BOOL)validateField:(NSString *)aField ofRecord:(id)aRecord; @end
Custom validator
// PrefixValidator.h @interface PrefixValidator : NSObject <ARValidatorProtocol> @end // PrefixValidator.m @implementation PrefixValidator - (NSString *)errorMessage { return @"Invalid prefix"; } - (BOOL)validateField:(NSString *)aField ofRecord:(id)aRecord { NSString *aValue = [aRecord valueForKey:aField]; BOOL valid = [aValue hasPrefix:@"LOL"]; return valid; } @end
Обработка ошибок
Методы save, update и isValid возвращают булевые значения, в случае возврата false/NO можно получить список ошибок
[user errors];
после чего будет возвращен массив объектов класса ARError
@interface ARError : NSObject @property (nonatomic, copy) NSString *modelName; @property (nonatomic, copy) NSString *propertyName; @property (nonatomic, copy) NSString *errorName; - (id)initWithModel:(NSString *)aModel property:(NSString *)aProperty error:(NSString *)anError; @end
Этот класс не содержит никаких детальных сообщений об ошибке, а только ключевые слова, на основе которых можно создать локализованное сообщение и отобразить его пользователю приложения.
Миграции
Миграции реализованы на примитивном уровне: реагирует только на добавление новых полей к сущностям или на добавление новых сущностей.
Для использования миграций не нужно ничего нигде прописывать.
При первом запуске приложения создаются все таблицы, а при последующих запусках выполняется проверка на наличие новых полей ли таблиц, и если такие имеются, то производятся запросы alter table.
Для того чтобы не инстанциировать проверку на наличие изменения в структурах таблиц нужно перед всякими обращениями к ActiveRecord отправить следующее сообщение
[ActiveRecord disableMigrations];
Транзакции
Я также реализовал возможность использовать транзакции, для этого используются блоки
[ActiveRecord transaction:^{ User *alex = [User newRecord]; alex.name = @"Alex"; [alex save]; rollback }];
rollback — обыкновенный макрос который бросает исключение типа ARException.
Тарнзакции можно использовать не только для отката в случае неудачи, но и для увеличения скорости выполнения запросов при добавлении записей.
В одном из проектов был жуткий тормоз при попытке создать over9000 записей. Время выполнения дампа составляло около 180 секунд, после того как я обернул это в транзакцию BEGIN;… COMMIT; время снизилось до ~4-5 секунд. Так что советую всем, кто не в курсе.
Связи
Когда я познакомился с реализацией ActiveRecord в RoR, то я был восхищен простотой создания связанности между сущностями. По большому счету эта простота и послужила первой предпосылкой к созданию этого фрэймворка. И сейчас я считаю самой главной фичей в моем велосипеде как раз связи между сущностями, и их относительная простота.
HasMany <-> BelongsTo
// User.h @interface User : ActiveRecord ... @property (nonatomic, retain) NSNumber *groupId; ... belongs_to_dec(Group, group, ARDependencyNullify) ... @end // User.m @implementation User ... @synthesize groupId; ... belonsg_to_imp(Group, group, ARDependencyNullify) ... @end
Макросы belongs_to_dec belonsg_to_imp принимают три параметра: имя класса с которым мы «связываемся», имя getter'а и тип зависимости.
Типов зависимости реализовано два: ARDependencyNullify и ARDependencyDestroy, первый при удалении модели зануляет ее связи, а вторая удаляет все связанные сущности.
Поле для этой связи должно совпадать с именем модели и начинаться с буквы в нижнем регистре
Group <-> groupId
User <-> userId
ContentManager <-> contentManagerId
EMCategory <-> eMCategory // немного коряво, но так уж исторически сложилось
Обратная связь (HasMany)
// Group.h @interface Group : ActiveRecord ... has_many_dec(User, users, ARDependencyDestroy) ... @end // Group.m @implementation Group ... has_many_imp(User, users, ARDependencyDestroy) ... @end
Тоже самое что и в случае со связью типа BelongsTo.
Главное что нужно запомнить: перед созданием связи обе записи доолжны быть сохранены, иначе они не имеют id, а связи завязаны именно на нем.
HasManyThrough
Для создания этой связи нужно оздать еще одну модель, промежуточную.
// User.h @interface User : ActiveRecord ... has_many_through_dec(Project, UserProjectRelationship, projects, ARDependencyNullify) ... @end // User.m @implementation User ... has_many_through_imp(Project, UserProjectRelationship, projects, ARDependencyNullify) ... @end // Project.h @interface Project : ActiveRecord ... has_many_through_dec(User, UserProjectRelationship, users, ARDependencyDestroy) ... @end // Project.m @implementation Project ... has_many_through_imp(User, UserProjectRelationship, users, ARDependencyDestroy) ... @end
Промежуточная, связующая модель
// UserProjectRelationship.h @interface UserProjectRelationship : ActiveRecord @property (nonatomic, retain) NSNumber *userId; @property (nonatomic, retain) NSNumber *projectId; @end // UserProjectRelationship.m @implementation UserProjectRelationship @synthesize userId; @synthesize projectId; @end
Эта связь имеет те же недостатки что и HasMany
Макросы *_dec/*_imp добавляют вспомогательные методы для добавления связей
set#ModelName:(ActiveRecord *)aRecord; // BelongsTo add##ModelName:(ActiveRecord *)aRecord; // HasMany, HasManyThrough remove##ModelName:(ActiveRecord *)aRecord; // HasMany, HasManyThrough
Фильтры для запросов
Очень часто требуется как-то отфильтровать выборку из базы:
— поиск соответствующих какому-то шаблону записей (UISearchBar)
— вывод в таблицу только 5 записей из тысячи
— получение только текстовых полей записей, без доставания из базы кучи «тяжелых» картинок
— еще масса вариантов :)
Поначалу я также не представлял как реализовать все это в удобном виде, но потом снова вспомнил Ruby и присущую ему «ленивость», в итоге решил создать класс который будет доставать записи только по требованию, но принимать фильтры в любом порядке.
Вот что из этого получилось.
Limit/Offset
NSArray *users = [[[User lazyFetcher] limit:5] fetchRecords]; NSArray *users = [[[User lazyFetcher] offset:5] fetchRecords]; NSArray *users = [[[[User lazyFetcher] offset:5] limit:2] fetchRecords];
Only/Except
ARLazyFetcher *fetcher = [[User lazyFetcher] only:@"name", @"id", nil]; ARLazyFetcher *fetcher = [[User lazyFetcher] except:@"veryBigImage", nil];
Where
iActiveRecord подерживает базовые условия WHERE
- (ARLazyFetcher *)whereField:(NSString *)aField equalToValue:(id)aValue; - (ARLazyFetcher *)whereField:(NSString *)aField notEqualToValue:(id)aValue; - (ARLazyFetcher *)whereField:(NSString *)aField in:(NSArray *)aValues; - (ARLazyFetcher *)whereField:(NSString *)aField notIn:(NSArray *)aValues; - (ARLazyFetcher *)whereField:(NSString *)aField like:(NSString *)aPattern; - (ARLazyFetcher *)whereField:(NSString *)aField notLike:(NSString *)aPattern; - (ARLazyFetcher *)whereField:(NSString *)aField between:(id)startValue and:(id)endValue; - (ARLazyFetcher *)where:(NSString *)aFormat, ...;
Тоже самое можно описать в стиле привычных и удобных NSPredicate'ов
NSArray *ids = [NSArray arrayWithObjects: [NSNumber numberWithInt:1], [NSNumber numberWithInt:15], nil]; NSString *username = @"john"; ARLazyFetcher *fetcher = [User lazyFetcher]; [fetcher where:@"'user'.'name' = %@ or 'user'.'id' in %@", username, ids, nil]; NSArray *records = [fetcher fetchRecords];
Объединения (join)
Сам этим почти никогда не пользовался, но решил что для полноты нужно реализовать.
- (ARLazyFetcher *)join:(Class)aJoinRecord useJoin:(ARJoinType)aJoinType onField:(NSString *)aFirstField andField:(NSString *)aSecondField;
Подерживаются разные типы join'ов
— ARJoinLeft
— ARJoinRight
— ARJoinInner
— ARJoinOuter
думаю названия говорят сами за себя.
С этой возможностю связан один небольшой костыль, потому для получения объединенных записей нужно вызывать
- (NSArray *)fetchJoinedRecords;
вместо
- (NSArray *)fetchRecords;
Этот метод возвращает массив из словарей, где ключами являются имена сущностей, а значениями — данные из базы.
Сортировка
- (ARLazyFetcher *)orderBy:(NSString *)aField ascending:(BOOL)isAscending; - (ARLazyFetcher *)orderBy:(NSString *)aField;// ASC по умолчанию ARLazyFetcher *fetcher = [[[User lazyFetcher] offset:2] limit:10]; [[fetcher whereField:@"name" equalToValue:@"Alex"] orderBy:@"name"]; NSArray *users = [fetcher fetchRecords];
Хранилище
Базу можно хранить как в Caches, так и в Documents, при этом в случае хранения в Documents к файлу добавляется атрибут отключающий бэкап
u_int8_t b = 1; setxattr([[url path] fileSystemRepresentation], "com.apple.MobileBackup", &b, 1, 0, 0);
иначе приложение получит reject от Apple.
Резюме
Проект на github — iActiveRecord.
Возможности
- поддержка ARC
- поддержка unicode
- миграции
- валидации, с подержкой кастомных валидаторов
- транзакции
- кастомные тип данных
- связи (BelongsTo, HasMany, HasManyThrough)
- сортировка
- фильтры (where =, !=, IN, NOT IN и другие)
- объединения
- поддержка CocoaPods
Заключение
В качестве
С удовольствием выслушаю адекватную критику.
P.S. сообщения об ошибках пишите в ЛС, пожалуйста.

