FMDBMigrationManager.m 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. //
  2. // FMDBMigrationManager.m
  3. // FMDBMigrationManager
  4. //
  5. // Created by Blake Watters on 6/4/14.
  6. // Copyright (c) 2014 Layer Inc. All rights reserved.
  7. //
  8. // Licensed under the Apache License, Version 2.0 (the "License");
  9. // you may not use this file except in compliance with the License.
  10. // You may obtain a copy of the License at
  11. //
  12. // http://www.apache.org/licenses/LICENSE-2.0
  13. //
  14. // Unless required by applicable law or agreed to in writing, software
  15. // distributed under the License is distributed on an "AS IS" BASIS,
  16. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  17. // See the License for the specific language governing permissions and
  18. // limitations under the License.
  19. //
  20. #import "FMDBMigrationManager.h"
  21. #import <objc/runtime.h>
  22. // Public Constants
  23. NSString *const FMDBMigrationManagerErrorDomain = @"com.layer.FMDBMigrationManager.errors";
  24. NSString *const FMDBMigrationManagerProgressVersionUserInfoKey = @"version";
  25. NSString *const FMDBMigrationManagerProgressMigrationUserInfoKey = @"migration";
  26. // Private Constants
  27. static NSString *const FMDBMigrationFilenameRegexString = @"^(\\d+)_?((?<=_)[\\w\\s-]+)?(?<!_)\\.sql$";
  28. BOOL FMDBIsMigrationAtPath(NSString *path)
  29. {
  30. static NSRegularExpression *migrationRegex;
  31. static dispatch_once_t onceToken;
  32. dispatch_once(&onceToken, ^{
  33. migrationRegex = [NSRegularExpression regularExpressionWithPattern:FMDBMigrationFilenameRegexString options:0 error:nil];
  34. });
  35. NSString *filename = [path lastPathComponent];
  36. return [migrationRegex rangeOfFirstMatchInString:filename options:0 range:NSMakeRange(0, [filename length])].location != NSNotFound;
  37. }
  38. static NSArray *FMDBClassesConformingToProtocol(Protocol *protocol)
  39. {
  40. NSMutableArray *conformingClasses = [NSMutableArray new];
  41. Class *classes = NULL;
  42. int numClasses = objc_getClassList(NULL, 0);
  43. if (numClasses > 0 ) {
  44. classes = (Class *)malloc(sizeof(Class) * numClasses);
  45. numClasses = objc_getClassList(classes, numClasses);
  46. for (int index = 0; index < numClasses; index++) {
  47. Class nextClass = classes[index];
  48. if (class_conformsToProtocol(nextClass, protocol)) {
  49. [conformingClasses addObject:nextClass];
  50. }
  51. }
  52. free(classes);
  53. }
  54. return conformingClasses;
  55. }
  56. @interface FMDBMigrationManager ()
  57. @property (nonatomic) FMDatabase *database;
  58. @property (nonatomic, assign) BOOL shouldCloseOnDealloc;
  59. @property (nonatomic) NSArray *migrations;
  60. @property (nonatomic) NSMutableArray *externalMigrations;
  61. @end
  62. @implementation FMDBMigrationManager
  63. + (instancetype)managerWithDatabaseAtPath:(NSString *)path migrationsBundle:(NSBundle *)bundle
  64. {
  65. FMDatabase *database = [FMDatabase databaseWithPath:path];
  66. return [[self alloc] initWithDatabase:database migrationsBundle:bundle];
  67. }
  68. + (instancetype)managerWithDatabase:(FMDatabase *)database migrationsBundle:(NSBundle *)bundle
  69. {
  70. return [[self alloc] initWithDatabase:database migrationsBundle:bundle];
  71. }
  72. // Designated initializer
  73. - (id)initWithDatabase:(FMDatabase *)database migrationsBundle:(NSBundle *)migrationsBundle
  74. {
  75. if (!database) [NSException raise:NSInvalidArgumentException format:@"Cannot initialize a `%@` with nil `database`.", [self class]];
  76. if (!migrationsBundle) [NSException raise:NSInvalidArgumentException format:@"Cannot initialize a `%@` with nil `migrationsBundle`.", [self class]];
  77. self = [super init];
  78. if (self) {
  79. _database = database;
  80. _migrationsBundle = migrationsBundle;
  81. _dynamicMigrationsEnabled = YES;
  82. _externalMigrations = [NSMutableArray new];
  83. if (![database goodConnection]) {
  84. self.shouldCloseOnDealloc = YES;
  85. [database open];
  86. }
  87. }
  88. return self;
  89. }
  90. - (id)init
  91. {
  92. @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"Failed to call designated initializer." userInfo:nil];
  93. }
  94. - (void)dealloc
  95. {
  96. if (self.shouldCloseOnDealloc) [_database close];
  97. }
  98. - (BOOL)hasMigrationsTable
  99. {
  100. FMResultSet *resultSet = [self.database executeQuery:@"SELECT name FROM sqlite_master WHERE type='table' AND name=?", @"schema_migrations"];
  101. if ([resultSet next]) {
  102. [resultSet close];
  103. return YES;
  104. }
  105. return NO;
  106. }
  107. - (BOOL)needsMigration
  108. {
  109. return !self.hasMigrationsTable || [self.pendingVersions count] > 0;
  110. }
  111. - (BOOL)createMigrationsTable:(NSError **)error
  112. {
  113. BOOL success = [self.database executeStatements:@"CREATE TABLE schema_migrations(version INTEGER UNIQUE NOT NULL)"];
  114. if (!success && error) *error = self.database.lastError;
  115. return success;
  116. }
  117. - (uint64_t)currentVersion
  118. {
  119. if (!self.hasMigrationsTable) return 0;
  120. uint64_t version = 0;
  121. FMResultSet *resultSet = [self.database executeQuery:@"SELECT MAX(version) FROM schema_migrations"];
  122. if ([resultSet next]) {
  123. version = [resultSet unsignedLongLongIntForColumnIndex:0];
  124. }
  125. [resultSet close];
  126. return version;;
  127. }
  128. - (uint64_t)originVersion
  129. {
  130. if (!self.hasMigrationsTable) return 0;
  131. uint64_t version = 0;
  132. FMResultSet *resultSet = [self.database executeQuery:@"SELECT MIN(version) FROM schema_migrations"];
  133. if ([resultSet next]) {
  134. version = [resultSet unsignedLongLongIntForColumnIndex:0];
  135. }
  136. [resultSet close];
  137. return version;
  138. }
  139. - (NSArray *)appliedVersions
  140. {
  141. if (!self.hasMigrationsTable) return nil;
  142. NSMutableArray *versions = [NSMutableArray new];
  143. FMResultSet *resultSet = [self.database executeQuery:@"SELECT version FROM schema_migrations"];
  144. while ([resultSet next]) {
  145. uint64_t version = [resultSet unsignedLongLongIntForColumnIndex:0];
  146. [versions addObject:@(version)];
  147. }
  148. [resultSet close];
  149. return [versions sortedArrayUsingSelector:@selector(compare:)];
  150. }
  151. - (NSArray *)pendingVersions
  152. {
  153. if (!self.hasMigrationsTable) return [[self.migrations valueForKey:@"version"] sortedArrayUsingSelector:@selector(compare:)];
  154. NSMutableArray *pendingVersions = [[[self migrations] valueForKey:@"version"] mutableCopy];
  155. [pendingVersions removeObjectsInArray:self.appliedVersions];
  156. return [pendingVersions sortedArrayUsingSelector:@selector(compare:)];
  157. }
  158. - (void)addMigration:(id<FMDBMigrating>)migration
  159. {
  160. NSParameterAssert(migration);
  161. [self addMigrationsAndSortByVersion:@[ migration ]];
  162. }
  163. - (void)addMigrations:(NSArray *)migrations
  164. {
  165. NSParameterAssert(migrations);
  166. if (![migrations isKindOfClass:[NSArray class]]) {
  167. @throw [NSException exceptionWithName:NSInvalidArgumentException reason:@"Failed to add migrations because `migrations` argument is not an array." userInfo:nil];
  168. }
  169. for (id<NSObject> migration in migrations) {
  170. if (![migration conformsToProtocol:@protocol(FMDBMigrating)]) {
  171. @throw [NSException exceptionWithName:NSInvalidArgumentException reason:@"Failed to add migrations because an object in `migrations` array doesn't conform to the `FMDBMigrating` protocol." userInfo:nil];
  172. }
  173. }
  174. [self addMigrationsAndSortByVersion:migrations];
  175. }
  176. - (NSArray *)migrations
  177. {
  178. // Memoize the migrations list
  179. if (_migrations) return _migrations;
  180. NSArray *migrationPaths = [self.migrationsBundle pathsForResourcesOfType:@"sql" inDirectory:nil];
  181. NSRegularExpression *migrationRegex = [NSRegularExpression regularExpressionWithPattern:FMDBMigrationFilenameRegexString options:0 error:nil];
  182. NSMutableArray *migrations = [NSMutableArray new];
  183. for (NSString *path in migrationPaths) {
  184. NSString *filename = [path lastPathComponent];
  185. if ([migrationRegex rangeOfFirstMatchInString:filename options:0 range:NSMakeRange(0, [filename length])].location != NSNotFound) {
  186. FMDBFileMigration *migration = [FMDBFileMigration migrationWithPath:path];
  187. [migrations addObject:migration];
  188. }
  189. }
  190. // Find all classes implementing FMDBMigrating
  191. if (self.dynamicMigrationsEnabled) {
  192. NSArray *conformingClasses = FMDBClassesConformingToProtocol(@protocol(FMDBMigrating));
  193. for (Class migrationClass in conformingClasses) {
  194. if ([migrationClass isSubclassOfClass:[FMDBFileMigration class]]) continue;
  195. id<FMDBMigrating> migration = [migrationClass new];
  196. [migrations addObject:migration];
  197. }
  198. }
  199. // Append any externally added migrations
  200. [migrations addObjectsFromArray:self.externalMigrations];
  201. // Sort into our final set
  202. _migrations = [migrations sortedArrayUsingDescriptors:@[ [NSSortDescriptor sortDescriptorWithKey:@"version" ascending:YES] ]];
  203. return _migrations;
  204. }
  205. - (id<FMDBMigrating>)migrationForVersion:(uint64_t)version
  206. {
  207. for (id<FMDBMigrating>migration in [self migrations]) {
  208. if (migration.version == version) return migration;
  209. }
  210. return nil;
  211. }
  212. - (id<FMDBMigrating>)migrationForName:(NSString *)name
  213. {
  214. for (id<FMDBMigrating>migration in [self migrations]) {
  215. if ([migration.name isEqualToString:name]) return migration;
  216. }
  217. return nil;
  218. }
  219. - (BOOL)migrateDatabaseToVersion:(uint64_t)version progress:(void (^)(NSProgress *progress))progressBlock error:(NSError **)error
  220. {
  221. BOOL success = YES;
  222. NSArray *pendingVersions = self.pendingVersions;
  223. NSProgress *progress = [NSProgress progressWithTotalUnitCount:[pendingVersions count]];
  224. for (NSNumber *migrationVersionNumber in pendingVersions) {
  225. [self.database beginTransaction];
  226. uint64_t migrationVersion = [migrationVersionNumber unsignedLongLongValue];
  227. if (migrationVersion > version) {
  228. [self.database commit];
  229. break;
  230. }
  231. id<FMDBMigrating> migration = [self migrationForVersion:migrationVersion];
  232. success = [migration migrateDatabase:self.database error:error];
  233. if (!success) {
  234. [self.database rollback];
  235. break;
  236. }
  237. success = [self.database executeUpdate:@"INSERT INTO schema_migrations(version) VALUES (?)", @(migration.version)];
  238. if (!success) {
  239. [self.database rollback];
  240. break;
  241. }
  242. // Emit progress tracking and check for cancellation
  243. progress.completedUnitCount++;
  244. if (progressBlock) {
  245. [progress setUserInfoObject:@(migrationVersion) forKey:FMDBMigrationManagerProgressVersionUserInfoKey];
  246. [progress setUserInfoObject:migration forKey:FMDBMigrationManagerProgressMigrationUserInfoKey];
  247. progressBlock(progress);
  248. if (progress.cancelled) {
  249. success = NO;
  250. NSDictionary *userInfo = @{ NSLocalizedDescriptionKey: @"Migration was halted due to cancellation." };
  251. if (error) *error = [NSError errorWithDomain:FMDBMigrationManagerErrorDomain code:FMDBMigrationManagerErrorMigrationCancelled userInfo:userInfo];
  252. [self.database rollback];
  253. break;
  254. }
  255. }
  256. [self.database commit];
  257. }
  258. return success;
  259. }
  260. - (void)addMigrationsAndSortByVersion:(NSArray *)migrations
  261. {
  262. [self.externalMigrations addObjectsFromArray:migrations];
  263. // Append to the existing list if already computed
  264. if (_migrations) {
  265. NSMutableArray *currentMigrations = [_migrations mutableCopy];
  266. [currentMigrations addObjectsFromArray:migrations];
  267. _migrations = [currentMigrations sortedArrayUsingDescriptors:@[ [NSSortDescriptor sortDescriptorWithKey:@"version" ascending:YES] ]];
  268. }
  269. }
  270. @end
  271. static BOOL FMDBMigrationScanMetadataFromPath(NSString *path, uint64_t *version, NSString **name)
  272. {
  273. NSError *error = nil;
  274. NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:FMDBMigrationFilenameRegexString options:0 error:&error];
  275. if (!regex) {
  276. NSLog(@"[FMDBMigration] Failed constructing regex: %@", error);
  277. return NO;
  278. }
  279. NSString *migrationName = [path lastPathComponent];
  280. NSTextCheckingResult *result = [regex firstMatchInString:migrationName options:0 range:NSMakeRange(0, [migrationName length])];
  281. if ([result numberOfRanges] != 3) {
  282. return NO;
  283. }
  284. NSString *versionString = [migrationName substringWithRange:[result rangeAtIndex:1]];
  285. if (!versionString) {
  286. return NO;
  287. }
  288. *version = strtoull([versionString UTF8String], NULL, 10);
  289. NSRange range = [result rangeAtIndex:2];
  290. *name = (range.length) ? [migrationName substringWithRange:[result rangeAtIndex:2]] : nil;
  291. return YES;
  292. }
  293. @interface FMDBFileMigration ()
  294. @property (nonatomic, readwrite) NSString *name;
  295. @property (nonatomic, readwrite) uint64_t version;
  296. @end
  297. @implementation FMDBFileMigration
  298. + (instancetype)migrationWithPath:(NSString *)path
  299. {
  300. return [[self alloc] initWithPath:path];
  301. }
  302. - (id)initWithPath:(NSString *)path
  303. {
  304. NSString *name;
  305. uint64_t version;
  306. if (!FMDBMigrationScanMetadataFromPath(path, &version, &name)) return nil;
  307. self = [super init];
  308. if (self) {
  309. _path = path;
  310. _version = version;
  311. _name = name;
  312. }
  313. return self;
  314. }
  315. - (id)init
  316. {
  317. @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"Failed to call designated initializer." userInfo:nil];
  318. }
  319. - (NSString *)SQL
  320. {
  321. return [NSString stringWithContentsOfFile:self.path encoding:NSUTF8StringEncoding error:nil];
  322. }
  323. - (BOOL)migrateDatabase:(FMDatabase *)database error:(out NSError *__autoreleasing *)error
  324. {
  325. BOOL success = [database executeStatements:self.SQL];
  326. if (!success && error) *error = database.lastError;
  327. return success;
  328. }
  329. @end