Очередная реализация ActiveRecord на Objective-C

в 21:15, , рубрики: active record, iOS, objective-c, sqlite3, велосипед, разработка под iOS, метки: , , , ,

Хочу поделиться очередной реализацией паттерна 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

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js