Хочу поделиться очередной реализацией паттерна ActiveRecord на Objective-C, а конкретно для iOS.
Когда только начинал использовать 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
Заключение
В качестве оправдания заключения хочу сказать, что проект начинался just for fun, и он продолжает развиваться, в планах в конце концов вычистить кучу «грязного» кода и добавить другие полезные возможности.
С удовольствием выслушаю адекватную критику.
P.S. сообщения об ошибках пишите в ЛС, пожалуйста.
Автор: 1101_debian