]> git.saurik.com Git - apple/security.git/blobdiff - Analytics/SQLite/SFSQLite.m
Security-58286.1.32.tar.gz
[apple/security.git] / Analytics / SQLite / SFSQLite.m
diff --git a/Analytics/SQLite/SFSQLite.m b/Analytics/SQLite/SFSQLite.m
new file mode 100644 (file)
index 0000000..4590e88
--- /dev/null
@@ -0,0 +1,1067 @@
+/*
+ * Copyright (c) 2017 Apple Inc. All Rights Reserved.
+ *
+ * @APPLE_LICENSE_HEADER_START@
+ *
+ * This file contains Original Code and/or Modifications of Original Code
+ * as defined in and that are subject to the Apple Public Source License
+ * Version 2.0 (the 'License'). You may not use this file except in
+ * compliance with the License. Please obtain a copy of the License at
+ * http://www.opensource.apple.com/apsl/ and read it before using this
+ * file.
+ *
+ * The Original Code and all software distributed under the License are
+ * distributed on an 'AS IS' basis, WITHOUT WARRANTY OF ANY KIND, EITHER
+ * EXPRESS OR IMPLIED, AND APPLE HEREBY DISCLAIMS ALL SUCH WARRANTIES,
+ * INCLUDING WITHOUT LIMITATION, ANY WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE, QUIET ENJOYMENT OR NON-INFRINGEMENT.
+ * Please see the License for the specific language governing rights and
+ * limitations under the License.
+ *
+ * @APPLE_LICENSE_HEADER_END@
+ */
+
+#import "SFSQLite.h"
+#import "SFSQLiteStatement.h"
+#include <sqlite3.h>
+#include <CommonCrypto/CommonDigest.h>
+
+
+#define kSFSQLiteBusyTimeout       (5*60*1000)
+
+// Vaccuum our databases approximately once a week
+#define kCKSQLVacuumInterval       ((60*60*24)*7)
+#define kSFSQLiteLastVacuumKey     @"LastVacuum"
+
+#define kSFSQLiteSchemaVersionKey  @"SchemaVersion"
+#define kSFSQLiteCreatedDateKey    @"Created"
+
+static NSString *const kSFSQLiteCreatePropertiesTableSQL =
+    @"create table if not exists Properties (\n"
+    @"    key    text primary key,\n"
+    @"    value  text\n"
+    @");\n";
+
+@interface SFSQLiteError : NSObject
++ (void)raise:(NSString *)reason code:(int)code extended:(int)extended;
+@end
+
+
+NSArray *SFSQLiteJournalSuffixes() {
+    return @[@"-journal", @"-wal", @"-shm"];
+}
+
+@interface NSObject (SFSQLiteAdditions)
++ (NSString *)SFSQLiteClassName;
+@end
+
+@implementation NSObject (SFSQLiteAdditions)
++ (NSString *)SFSQLiteClassName {
+    return NSStringFromClass(self);
+}
+@end
+
+@interface SFSQLite ()
+
+@property (nonatomic, assign)            sqlite3                *db;
+@property (nonatomic, assign)            NSUInteger              openCount;
+@property (nonatomic, assign)            BOOL                    corrupt;
+@property (nonatomic, readonly, strong)  NSMutableDictionary    *statementsBySQL;
+@property (nonatomic, strong)            NSDateFormatter        *dateFormatter;
+
+@end
+
+static char intToHexChar(uint8_t i)
+{
+    return i >= 10 ? 'a' + i - 10 : '0' + i;
+}
+
+static char *SecHexCharFromBytes(const uint8_t *bytes, NSUInteger length, NSUInteger *outlen) {
+    // Fudge the math a bit on the assert because we don't want a 1GB string anyway
+    if (length > (NSUIntegerMax / 3)) {
+        return nil;
+    }
+    char *hex = calloc(1, length * 2 * 9 / 8); // 9/8 so we can inline ' ' between every 8 character sequence
+    char *destPtr = hex;
+
+    NSUInteger i;
+
+    for (i = 0; length > 4; i += 4, length -= 4) {
+        for (NSUInteger offset = 0; offset < 4; offset++) {
+            *destPtr++ = intToHexChar((bytes[i+offset] & 0xF0) >> 4);
+            *destPtr++ = intToHexChar(bytes[i+offset] & 0x0F);
+        }
+        *destPtr++ = ' ';
+    }
+
+    /* Using the same i from the above loop */
+    for (; length > 0; i++, length--) {
+        *destPtr++ = intToHexChar((bytes[i] & 0xF0) >> 4);
+        *destPtr++ = intToHexChar(bytes[i] & 0x0F);
+    }
+
+    if (outlen) *outlen = destPtr - hex;
+
+    return hex;
+}
+
+static BOOL SecCreateDirectoryAtPath(NSString *path, NSError **error) {
+    BOOL success = YES;
+    NSError *localError;
+    NSFileManager *fileManager = [NSFileManager defaultManager];
+
+    if (![fileManager createDirectoryAtPath:path withIntermediateDirectories:YES attributes:nil error:&localError]) {
+        if (![localError.domain isEqualToString:NSCocoaErrorDomain] || localError.code != NSFileWriteFileExistsError) {
+            success = NO;
+        }
+    }
+
+#if TARGET_OS_IPHONE
+    if (success) {
+        NSDictionary *attributes = [fileManager attributesOfItemAtPath:path error:&localError];
+        if (![attributes[NSFileProtectionKey] isEqualToString:NSFileProtectionCompleteUntilFirstUserAuthentication]) {
+            [fileManager setAttributes:@{ NSFileProtectionKey: NSFileProtectionCompleteUntilFirstUserAuthentication }
+                          ofItemAtPath:path error:nil];
+        }
+    }
+#endif
+    if (!success) {
+        if (error) *error = localError;
+    }
+    return success;
+}
+
+@implementation NSData (CKUtilsAdditions)
+
+- (NSString *)CKHexString {
+    NSUInteger hexLen = 0;
+    NS_VALID_UNTIL_END_OF_SCOPE NSData *arcSafeSelf = self;
+    char *hex = SecHexCharFromBytes([arcSafeSelf bytes], [arcSafeSelf length], &hexLen);
+    return [[NSString alloc] initWithBytesNoCopy:hex length:hexLen encoding:NSASCIIStringEncoding freeWhenDone:YES];
+}
+
+- (NSString *)CKLowercaseHexStringWithoutSpaces {
+    NSMutableString *retVal = [[self CKHexString] mutableCopy];
+    [retVal replaceOccurrencesOfString:@" " withString:@"" options:0 range:NSMakeRange(0, [retVal length])];
+    return retVal;
+}
+
+- (NSString *)CKUppercaseHexStringWithoutSpaces {
+    NSMutableString *retVal = [[[self CKHexString] uppercaseString] mutableCopy];
+    [retVal replaceOccurrencesOfString:@" " withString:@"" options:0 range:NSMakeRange(0, [retVal length])];
+    return retVal;
+}
+
++ (NSData *)CKDataWithHexString:(NSString *)hexString stringIsUppercase:(BOOL)stringIsUppercase {
+    NSMutableData *retVal = [[NSMutableData alloc] init];
+    NSCharacterSet *hexCharacterSet = nil;
+    char aChar;
+    if (stringIsUppercase) {
+        hexCharacterSet = [NSCharacterSet characterSetWithCharactersInString:@"0123456789ABCDEF"];
+        aChar = 'A';
+    } else {
+        hexCharacterSet = [NSCharacterSet characterSetWithCharactersInString:@"0123456789abcdef"];
+        aChar = 'a';
+    }
+
+    unsigned int i;
+    for (i = 0; i < [hexString length] ; ) {
+        BOOL validFirstByte = NO;
+        BOOL validSecondByte = NO;
+        unichar firstByte = 0;
+        unichar secondByte = 0;
+
+        for ( ; i < [hexString length]; i++) {
+            firstByte = [hexString characterAtIndex:i];
+            if ([hexCharacterSet characterIsMember:firstByte]) {
+                i++;
+                validFirstByte = YES;
+                break;
+            }
+        }
+        for ( ; i < [hexString length]; i++) {
+            secondByte = [hexString characterAtIndex:i];
+            if ([hexCharacterSet characterIsMember:secondByte]) {
+                i++;
+                validSecondByte = YES;
+                break;
+            }
+        }
+        if (!validFirstByte || !validSecondByte) {
+            goto allDone;
+        }
+        if ((firstByte >= '0') && (firstByte <= '9')) {
+            firstByte -= '0';
+        } else {
+            firstByte = firstByte - aChar + 10;
+        }
+        if ((secondByte >= '0') && (secondByte <= '9')) {
+            secondByte -= '0';
+        } else {
+            secondByte = secondByte - aChar + 10;
+        }
+        char totalByteValue = (char)((firstByte << 4) + secondByte);
+
+        [retVal appendBytes:&totalByteValue length:1];
+    }
+allDone:
+    return retVal;
+}
+
++ (NSData *)CKDataWithHexString:(NSString *)hexString {
+    return [self CKDataWithHexString:hexString stringIsUppercase:NO];
+}
+
+@end
+
+@implementation SFSQLite
+
+@synthesize delegate = _delegate;
+@synthesize path = _path;
+@synthesize schema = _schema;
+@synthesize schemaVersion = _schemaVersion;
+@synthesize objectClassPrefix = _objectClassPrefix;
+@synthesize userVersion = _userVersion;
+@synthesize synchronousMode = _synchronousMode;
+@synthesize hasMigrated = _hasMigrated;
+@synthesize shouldVacuum = _shouldVacuum;
+@synthesize traced = _traced;
+@synthesize db = _db;
+@synthesize openCount = _openCount;
+@synthesize corrupt = _corrupt;
+@synthesize statementsBySQL = _statementsBySQL;
+@synthesize dateFormatter = _dateFormatter;
+#if DEBUG
+@synthesize unitTestOverrides = _unitTestOverrides;
+#endif
+
+- (instancetype)initWithPath:(NSString *)path schema:(NSString *)schema {
+    if ((self = [super init])) {
+        NSAssert([path length], @"Can't init a database with a zero-length path");
+        _path = path;
+        _schema = schema;
+        _schemaVersion = [self _createSchemaHash];
+        _statementsBySQL = [[NSMutableDictionary alloc] init];
+        _objectClassPrefix = @"CK";
+        _synchronousMode = SFSQLiteSynchronousModeNormal;
+        _hasMigrated = NO;
+        _shouldVacuum = YES;
+    }
+    return self;
+}
+
+- (void)dealloc {
+    @autoreleasepool {
+        [self close];
+    }
+}
+
+- (SInt32)userVersion {
+    if (self.delegate) {
+        return self.delegate.userVersion;
+    }
+    return _userVersion;
+}
+
+- (NSString *)_synchronousModeString {
+    switch (self.synchronousMode) {
+        case SFSQLiteSynchronousModeOff:
+            return @"off";
+        case SFSQLiteSynchronousModeFull:
+            return @"full";
+        case SFSQLiteSynchronousModeNormal:
+            break;
+        default:
+            assert(0 && "Unknown synchronous mode");
+    }
+    return @"normal";
+}
+
+- (NSString *)_createSchemaHash {
+    unsigned char hashBuffer[CC_SHA256_DIGEST_LENGTH] = {0};
+    NSData *hashData = [NSData dataWithBytesNoCopy:hashBuffer length:CC_SHA256_DIGEST_LENGTH freeWhenDone:NO];
+    NS_VALID_UNTIL_END_OF_SCOPE NSData *schemaData = [self.schema dataUsingEncoding:NSUTF8StringEncoding];
+    CC_SHA256([schemaData bytes], (CC_LONG)[schemaData length], hashBuffer);
+    return [hashData CKUppercaseHexStringWithoutSpaces];
+}
+
+- (BOOL)isOpen {
+    return _db != NULL;
+}
+
+- (void)_periodicVacuum {
+    // "When the auto-vacuum mode is 1 or "full", the freelist pages are moved to the end of the database file and the database file is truncated to remove the freelist pages at every transaction commit.
+    // Note, however, that auto-vacuum only truncates the freelist pages from the file. Auto-vacuum does not defragment the database nor repack individual database pages the way that the VACUUM command does.
+    // In fact, because it moves pages around within the file, auto-vacuum can actually make fragmentation worse."
+    // https://sqlite.org/pragma.html#pragma_auto_vacuum
+    NSDate *lastVacuumDate = [NSDate dateWithTimeIntervalSinceReferenceDate:[[self propertyForKey:kSFSQLiteLastVacuumKey] floatValue]];
+    if ([lastVacuumDate timeIntervalSinceNow] < -(kCKSQLVacuumInterval)) {
+        [self executeSQL:@"VACUUM"];
+
+        NSString *vacuumDateString = [NSString stringWithFormat:@"%f", [[NSDate date] timeIntervalSinceReferenceDate]];
+        [self setProperty:vacuumDateString forKey:kSFSQLiteLastVacuumKey];
+    }
+}
+
+- (BOOL)openWithError:(NSError **)error {
+    BOOL success = NO;
+    NSError *localError;
+    NSString *dbSchemaVersion, *dir;
+    NSArray *results;
+    NS_VALID_UNTIL_END_OF_SCOPE NSString *arcSafePath = _path;
+    
+    if (_openCount > 0) {
+        NSAssert(_db != NULL, @"Missing handle for open cache db");
+        _openCount += 1;
+        success = YES;
+        goto done;
+    }
+    
+    // Create the directory for the cache.
+    dir = [_path stringByDeletingLastPathComponent];
+    if (!SecCreateDirectoryAtPath(dir, &localError)) {
+        goto done;
+    }
+
+    int flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE;
+#if TARGET_OS_IPHONE
+    flags |= SQLITE_OPEN_FILEPROTECTION_COMPLETEUNTILFIRSTUSERAUTHENTICATION;
+#endif
+    int rc = sqlite3_open_v2([arcSafePath fileSystemRepresentation], &_db, flags, NULL);
+    if (rc != SQLITE_OK) {
+        localError = [NSError errorWithDomain:NSCocoaErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey : [NSString stringWithFormat:@"Error opening db at %@, rc=%d(0x%x)", _path, rc, rc]}];
+        goto done;
+    }
+    sqlite3_extended_result_codes(_db, 1);
+    rc = sqlite3_busy_timeout(_db, kSFSQLiteBusyTimeout);
+    if (rc != SQLITE_OK) {
+        goto done;
+    }
+    
+    // You don't argue with the Ben: rdar://12685305
+    [self executeSQL:@"pragma journal_mode = WAL"];
+    [self executeSQL:@"pragma synchronous = %@", [self _synchronousModeString]];
+    [self executeSQL:@"pragma auto_vacuum = FULL"];
+    
+    // rdar://problem/32168789
+    // [self executeSQL:@"pragma foreign_keys = 1"];
+    
+    // Initialize the db within a transaction in case there is a crash between creating the schema and setting the
+    // schema version, and to avoid multiple threads trying to re-create the db at once.
+    [self begin];
+
+    // Create the Properties table before trying to read the schema version from it. If the Properties table doesn't
+    // exist we can't prepare a statement to access it.
+    results = [self select:@[@"name"] from:@"sqlite_master" where:@"type = ? AND name = ?" bindings:@[@"table", @"Properties"]];
+    if (!results.count) {
+        [self executeSQL:kSFSQLiteCreatePropertiesTableSQL];
+    }
+    
+    // Check the schema version and create or re-create the db if needed.
+    BOOL create = NO;
+    dbSchemaVersion = [self propertyForKey:kSFSQLiteSchemaVersionKey];
+    SInt32 dbUserVersion = [self dbUserVersion];
+    
+    if (!dbSchemaVersion) {
+        // The schema version isn't set so the db was just created or we failed to initialize it previously.
+        create = YES;
+    } else if (![dbSchemaVersion isEqualToString:self.schemaVersion]
+               || (self.userVersion && dbUserVersion != self.userVersion)) {
+
+        if (self.delegate && [self.delegate migrateDatabase:self fromVersion:dbUserVersion]) {
+            _hasMigrated = YES;
+        }
+
+        if (!_hasMigrated) {
+            // The schema version doesn't match and we haven't migrated to the new version. Give up and throw away the db and re-create it instead of trying to migrate.
+            [self removeAllStatements];
+            [self dropAllTables];
+            create = YES;
+            _hasMigrated = YES;
+        }
+    }
+    if (create) {
+        [self executeSQL:kSFSQLiteCreatePropertiesTableSQL];
+        [self executeSQL:@"%@", self.schema];
+        NSString *createdDateString = [NSString stringWithFormat:@"%f", [[NSDate date] timeIntervalSinceReferenceDate]];
+        [self setProperty:createdDateString forKey:kSFSQLiteCreatedDateKey];
+    }
+    
+    [self end];
+    
+#if DEBUG
+    // TODO: <rdar://problem/33115830> Resolve Race Condition When Setting 'userVersion/schemaVersion' in SFSQLite
+    if ([self.unitTestOverrides[@"RacyUserVersionUpdate"] isEqual:@YES]) {
+        success = YES;
+        goto done;
+    }
+#endif
+    
+    if (self.shouldVacuum) [self _periodicVacuum];
+
+    if (create || _hasMigrated) {
+        [self setProperty:self.schemaVersion forKey:kSFSQLiteSchemaVersionKey];
+        if (self.userVersion) {
+            [self executeSQL:@"pragma user_version = %ld", (long)self.userVersion];
+        }
+    }
+
+    _openCount += 1;
+    success = YES;
+    
+done:
+    if (!success && error) {
+        *error = localError;
+    }
+    return success;
+}
+
+- (void)open {
+    NSError *error;
+    if (![self openWithError:&error]) {
+        [self raise:@"Error opening db at %@: %@", self.path, error];
+    }
+}
+
+
+- (void)close {
+    if (_openCount > 0) {
+        if (_openCount == 1) {
+            NSAssert(_db != NULL, @"Missing handle for open cache db");
+            
+            [self removeAllStatements];
+            
+            if (sqlite3_close(_db)) {
+                [self raise:@"Error closing database"];
+            }
+            _db = NULL;
+        }
+        _openCount -= 1;
+    }
+}
+
+- (void)remove {
+    NSAssert(_openCount == 0, @"Trying to remove db at: %@ while it is open", _path);
+    [[NSFileManager defaultManager] removeItemAtPath:_path error:nil];
+    for (NSString *suffix in SFSQLiteJournalSuffixes()) {
+        [[NSFileManager defaultManager] removeItemAtPath:[_path stringByAppendingString:suffix] error:nil];
+    }
+}
+
+- (void)begin {
+    [self executeSQL:@"begin exclusive"];
+}
+
+- (void)end {
+    [self executeSQL:@"end"];
+}
+
+- (void)rollback {
+    [self executeSQL:@"rollback"];
+}
+
+- (void)analyze {
+    [self executeSQL:@"analyze"];
+}
+
+- (void)vacuum {
+    [self executeSQL:@"vacuum"];
+}
+
+- (void)raise:(NSString *)format, ... {
+    va_list args;
+    va_start(args, format);
+    
+    NSString *reason = [[NSString alloc] initWithFormat:format arguments:args];
+    
+    int code = 0;
+    int extendedCode = 0;
+    if (_db) {
+        code = sqlite3_errcode(_db) & 0xFF;
+        extendedCode = sqlite3_extended_errcode(_db);
+        const char *errmsg = sqlite3_errmsg(_db);
+
+        NSDictionary *dbAttrs = [[NSFileManager defaultManager] attributesOfItemAtPath:self.path error:NULL];
+        NSDictionary *fsAttrs = [[NSFileManager defaultManager] attributesOfFileSystemForPath:self.path error:NULL];
+        reason = [reason stringByAppendingFormat:@" - errcode:%04x, msg:\"%s\", size: %@, path:%@, fs:%@/%@", extendedCode, errmsg, dbAttrs[NSFileSize], _path, fsAttrs[NSFileSystemFreeSize], fsAttrs[NSFileSystemSize]];
+
+        if (!_corrupt && (code == SQLITE_CORRUPT || code == SQLITE_NOTADB)) {
+            _corrupt = YES;
+            
+            @try {
+                [self close];
+            } @catch (NSException *x) {
+                NSLog(@"Warn: Error closing corrupt db: %@", x);
+            }
+            
+            [self remove];
+        }
+    }
+    
+    va_end(args);
+    
+    [SFSQLiteError raise:reason code:code extended:extendedCode];
+}
+
+- (SFSQLiteRowID)lastInsertRowID {
+    if (!_db) {
+        [self raise:@"Database is closed"];
+    }
+    
+    return sqlite3_last_insert_rowid(_db);
+}
+
+- (int)changes
+{
+    if (!_db) {
+        [self raise:@"Database is closed"];
+    }
+    
+    return sqlite3_changes(_db);
+}
+
+- (void)executeSQL:(NSString *)format, ... {
+    va_list args;
+    va_start(args, format);
+    [self executeSQL:format arguments:args];
+    va_end(args);
+}
+
+- (void)executeSQL:(NSString *)format arguments:(va_list)args {
+    NS_VALID_UNTIL_END_OF_SCOPE NSString *SQL = [[NSString alloc] initWithFormat:format arguments:args];
+    if (!_db) {
+        [self raise:@"Database is closed"];
+    }
+    int execRet = sqlite3_exec(_db, [SQL UTF8String], NULL, NULL, NULL);
+    if (execRet != SQLITE_OK) {
+        [self raise:@"Error executing SQL: \"%@\" (%d)", SQL, execRet];
+    }
+}
+
+- (SFSQLiteStatement *)statementForSQL:(NSString *)SQL {
+    if (!_db) {
+        [self raise:@"Database is closed"];
+    }
+    
+    SFSQLiteStatement *statement = _statementsBySQL[SQL];
+    if (statement) {
+        NSAssert(statement.isReset, @"Statement not reset after last use: \"%@\"", SQL);
+    } else {
+        sqlite3_stmt *handle = NULL;
+        NS_VALID_UNTIL_END_OF_SCOPE NSString *arcSafeSQL = SQL;
+        if (sqlite3_prepare_v2(_db, [arcSafeSQL UTF8String], -1, &handle, NULL)) {
+            [self raise:@"Error preparing statement: %@", SQL];
+        }
+        
+        statement = [[SFSQLiteStatement alloc] initWithSQLite:self SQL:SQL handle:handle];
+        _statementsBySQL[SQL] = statement;
+    }
+    
+    return statement;
+}
+
+- (void)removeAllStatements {
+    [[_statementsBySQL allValues] makeObjectsPerformSelector:@selector(finalizeStatement)];
+    [_statementsBySQL removeAllObjects];
+}
+
+- (NSArray *)allTableNames {
+    NSMutableArray *tableNames = [[NSMutableArray alloc] init];
+    
+    SFSQLiteStatement *statement = [self statementForSQL:@"select name from sqlite_master where type = 'table'"];
+    while ([statement step]) {
+        NSString *name = [statement textAtIndex:0];
+        [tableNames addObject:name];
+    }
+    [statement reset];
+    
+    return tableNames;
+}
+
+- (void)dropAllTables {
+    for (NSString *tableName in [self allTableNames]) {
+        [self executeSQL:@"drop table %@", tableName];
+    }
+}
+
+- (NSString *)propertyForKey:(NSString *)key {
+    NSAssert(key, @"Null key");
+    
+    NSString *value = nil;
+    
+    SFSQLiteStatement *statement = [self statementForSQL:@"select value from Properties where key = ?"];
+    [statement bindText:key atIndex:0];
+    if ([statement step]) {
+        value = [statement textAtIndex:0];
+    }
+    [statement reset];
+    
+    return value;
+}
+
+- (void)setProperty:(NSString *)value forKey:(NSString *)key {
+    NSAssert(key, @"Null key");
+    
+    if (value) {
+        SFSQLiteStatement *statement = [self statementForSQL:@"insert or replace into Properties (key, value) values (?,?)"];
+        [statement bindText:key atIndex:0];
+        [statement bindText:value atIndex:1];
+        [statement step];
+        [statement reset];
+    } else {
+        [self removePropertyForKey:key];
+    }
+}
+
+- (NSDateFormatter *)dateFormatter {
+    if (!_dateFormatter) {
+        NSDateFormatter* dateFormatter = [NSDateFormatter new];
+        dateFormatter.dateFormat = @"yyyy-MM-dd'T'HH:mm:ssZZZZZ";
+        _dateFormatter = dateFormatter;
+    }
+    return _dateFormatter;
+}
+
+- (NSDate *)datePropertyForKey:(NSString *)key {
+    NSString *dateStr = [self propertyForKey:key];
+    if (dateStr.length) {
+        return [self.dateFormatter dateFromString:dateStr];
+    }
+    return nil;
+}
+
+- (void)setDateProperty:(NSDate *)value forKey:(NSString *)key {
+    NSString *dateStr = nil;
+    if (value) {
+        dateStr = [self.dateFormatter stringFromDate:value];
+    }
+    [self setProperty:dateStr forKey:key];
+}
+
+- (void)removePropertyForKey:(NSString *)key {
+    NSAssert(key, @"Null key");
+    
+    SFSQLiteStatement *statement = [self statementForSQL:@"delete from Properties where key = ?"];
+    [statement bindText:key atIndex:0];
+    [statement step];
+    [statement reset];
+}
+
+- (NSDate *)creationDate {
+    return [NSDate dateWithTimeIntervalSinceReferenceDate:[[self propertyForKey:kSFSQLiteCreatedDateKey] floatValue]];
+}
+
+// https://sqlite.org/pragma.html#pragma_table_info
+- (NSSet<NSString*> *)columnNamesForTable:(NSString*)tableName {
+    SFSQLiteStatement *statement = [self statementForSQL:[NSString stringWithFormat:@"pragma table_info(%@)", tableName]];
+    NSMutableSet<NSString*>* columnNames = [[NSMutableSet alloc] init];
+    while ([statement step]) {
+        [columnNames addObject:[statement textAtIndex:1]];
+    }
+    [statement reset];
+    return columnNames;
+}
+
+- (NSArray *)select:(NSArray *)columns from:(NSString *)tableName {
+    return [self select:columns from:tableName where:nil bindings:nil];
+}
+
+- (NSArray *)select:(NSArray *)columns from:(NSString *)tableName where:(NSString *)whereSQL bindings:(NSArray *)bindings {
+    NSMutableArray *results = [[NSMutableArray alloc] init];
+    
+    NSMutableString *SQL = [NSMutableString stringWithFormat:@"select %@ from %@", [columns componentsJoinedByString:@", "], tableName];
+    if (whereSQL) {
+        [SQL appendFormat:@" where %@", whereSQL];
+    }
+    
+    SFSQLiteStatement *statement = [self statementForSQL:SQL];
+    [statement bindValues:bindings];
+    while ([statement step]) {
+        [results addObject:[statement allObjectsByColumnName]];
+    }
+    [statement reset];
+    
+    return results;
+}
+
+- (void)select:(NSArray *)columns from:(NSString *)tableName where:(NSString *)whereSQL bindings:(NSArray *)bindings orderBy:(NSArray *)orderBy limit:(NSNumber *)limit block:(void (^)(NSDictionary *resultDictionary, BOOL *stop))block {
+    @autoreleasepool {
+        NSMutableString *SQL = [[NSMutableString alloc] init];
+        NSString *columnsString = @"*";
+        if ([columns count]) columnsString = [columns componentsJoinedByString:@", "];
+        [SQL appendFormat:@"select %@ from %@", columnsString, tableName];
+
+        if (whereSQL.length) {
+            [SQL appendFormat:@" where %@", whereSQL];
+        }
+        if (orderBy) {
+            NSString *orderByString = [orderBy componentsJoinedByString:@", "];
+            [SQL appendFormat:@" order by %@", orderByString];
+        }
+        if (limit) {
+            [SQL appendFormat:@" limit %ld", (long)limit.integerValue];
+        }
+
+        SFSQLiteStatement *statement = [self statementForSQL:SQL];
+        [statement bindValues:bindings];
+        do {
+            @autoreleasepool {
+                if (![statement step]) {
+                    break;
+                }
+                NSDictionary *stepResult = [statement allObjectsByColumnName];
+                if (block) {
+                    BOOL stop = NO;
+                    block(stepResult, &stop);
+                    if (stop) {
+                        break;
+                    }
+                }
+            }
+        } while (1);
+        [statement reset];
+    }
+}
+
+- (void)selectFrom:(NSString *)tableName where:(NSString *)whereSQL bindings:(NSArray *)bindings orderBy:(NSArray *)orderBy limit:(NSNumber *)limit block:(void (^)(NSDictionary *resultDictionary, BOOL *stop))block {
+    @autoreleasepool {
+        NSMutableString *SQL = [[NSMutableString alloc] init];
+        [SQL appendFormat:@"select * from %@", tableName];
+        
+        if (whereSQL.length) {
+            [SQL appendFormat:@" where %@", whereSQL];
+        }
+        if (orderBy) {
+            NSString *orderByString = [orderBy componentsJoinedByString:@", "];
+            [SQL appendFormat:@" order by %@", orderByString];
+        }
+        if (limit) {
+            [SQL appendFormat:@" limit %ld", (long)limit.integerValue];
+        }
+        
+        SFSQLiteStatement *statement = [self statementForSQL:SQL];
+        [statement bindValues:bindings];
+        do {
+            @autoreleasepool {
+                if (![statement step]) {
+                    break;
+                }
+                NSDictionary *stepResult = [statement allObjectsByColumnName];
+                if (block) {
+                    BOOL stop = NO;
+                    block(stepResult, &stop);
+                    if (stop) {
+                        break;
+                    }
+                }
+            }
+        } while (1);
+        [statement reset];
+    }
+}
+
+- (NSArray *)selectFrom:(NSString *)tableName where:(NSString *)whereSQL bindings:(NSArray *)bindings limit:(NSNumber *)limit {
+    NSMutableString *SQL = [[NSMutableString alloc] init];
+    [SQL appendFormat:@"select * from %@", tableName];
+    
+    if (whereSQL.length) {
+        [SQL appendFormat:@" where %@", whereSQL];
+    }
+    if (limit) {
+        [SQL appendFormat:@" limit %ld", (long)limit.integerValue];
+    }
+
+    NSMutableArray *results = [[NSMutableArray alloc] init];
+
+    SFSQLiteStatement *statement = [self statementForSQL:SQL];
+    [statement bindValues:bindings];
+    while ([statement step]) {
+        [results addObject:[statement allObjectsByColumnName]];
+    }
+    [statement reset];
+    
+    return results;
+}
+
+- (void)update:(NSString *)tableName set:(NSString *)setSQL where:(NSString *)whereSQL bindings:(NSArray *)whereBindings limit:(NSNumber *)limit {
+    NSMutableString *SQL = [[NSMutableString alloc] init];
+    [SQL appendFormat:@"update %@", tableName];
+    
+    NSAssert(setSQL.length > 0, @"null set expression");
+
+    [SQL appendFormat:@" set %@", setSQL];
+    if (whereSQL.length) {
+        [SQL appendFormat:@" where %@", whereSQL];
+    }
+    if (limit) {
+        [SQL appendFormat:@" limit %ld", (long)limit.integerValue];
+    }
+
+    SFSQLiteStatement *statement = [self statementForSQL:SQL];
+    [statement bindValues:whereBindings];
+    while ([statement step]) {
+    }
+    [statement reset];
+}
+
+- (NSArray *)selectAllFrom:(NSString *)tableName where:(NSString *)whereSQL bindings:(NSArray *)bindings {
+    return [self selectFrom:tableName where:whereSQL bindings:bindings limit:nil];
+}
+
+- (NSUInteger)selectCountFrom:(NSString *)tableName where:(NSString *)whereSQL bindings:(NSArray *)bindings {
+    NSArray *results = [self select:@[@"count(*) as n"] from:tableName where:whereSQL bindings:bindings];
+    return [results[0][@"n"] unsignedIntegerValue];
+}
+
+- (SFSQLiteRowID)insertOrReplaceInto:(NSString *)tableName values:(NSDictionary *)valuesByColumnName {
+    NSArray *columnNames = [[valuesByColumnName allKeys] sortedArrayUsingSelector:@selector(compare:)];
+    NSMutableArray *values = [[NSMutableArray alloc] init];
+    for (NSUInteger i = 0; i < columnNames.count; i++) {
+        values[i] = valuesByColumnName[columnNames[i]];
+    }
+    
+    NSMutableString *SQL = [[NSMutableString alloc] initWithString:@"insert or replace into "];
+    [SQL appendString:tableName];
+    [SQL appendString:@" ("];
+    for (NSUInteger i = 0; i < columnNames.count; i++) {
+        [SQL appendString:columnNames[i]];
+        if (i != columnNames.count-1) {
+            [SQL appendString:@","];
+        }
+    }
+    [SQL appendString:@") values ("];
+    for (NSUInteger i = 0; i < columnNames.count; i++) {
+        if (i != columnNames.count-1) {
+            [SQL appendString:@"?,"];
+        } else {
+            [SQL appendString:@"?"];
+        }
+    }
+    [SQL appendString:@")"];
+    
+    SFSQLiteStatement *statement = [self statementForSQL:SQL];
+    [statement bindValues:values];
+    [statement step];
+    [statement reset];
+    
+    return [self lastInsertRowID];
+}
+
+- (void)deleteFrom:(NSString *)tableName matchingValues:(NSDictionary *)valuesByColumnName {
+    NSArray *columnNames = [[valuesByColumnName allKeys] sortedArrayUsingSelector:@selector(compare:)];
+    NSMutableArray *values = [[NSMutableArray alloc] init];
+    NSMutableString *whereSQL = [[NSMutableString alloc] init];
+    int bindingCount = 0;
+    for (NSUInteger i = 0; i < columnNames.count; i++) {
+        id value = valuesByColumnName[columnNames[i]];
+        [whereSQL appendString:columnNames[i]];
+        if (!value || [[NSNull null] isEqual:value]) {
+            [whereSQL appendString:@" is NULL"];
+        } else {
+            values[bindingCount++] = value;
+            [whereSQL appendString:@"=?"];
+        }
+        if (i != columnNames.count-1) {
+            [whereSQL appendString:@" AND "];
+        }
+    }
+    [self deleteFrom:tableName where:whereSQL bindings:values];
+}
+
+- (void)deleteFrom:(NSString *)tableName where:(NSString *)whereSQL bindings:(NSArray *)bindings {
+    NSString *SQL = [NSString stringWithFormat:@"delete from %@ where %@", tableName, whereSQL];
+
+    SFSQLiteStatement *statement = [self statementForSQL:SQL];
+    [statement bindValues:bindings];
+    [statement step];
+    [statement reset];
+}
+
+- (NSString *)_tableNameForClass:(Class)objectClass {
+    NSString *className = [objectClass SFSQLiteClassName];
+    if (![className hasPrefix:_objectClassPrefix]) {
+        [NSException raise:NSInvalidArgumentException format:@"Object class \"%@\" does not have prefix \"%@\"", className, _objectClassPrefix];
+    }
+    return [className substringFromIndex:_objectClassPrefix.length];
+}
+
+- (SInt32)dbUserVersion {
+    SInt32 userVersion = 0;
+    SFSQLiteStatement *statement = [self statementForSQL:@"pragma user_version"];
+    while ([statement step]) {
+        userVersion = [statement intAtIndex:0];
+    }
+    [statement reset];
+    
+    return userVersion;
+}
+
+@end
+
+
+#define SFSQLiteErrorRaiseMethod(SQLiteError) + (void)SQLiteError:(NSString *)reason { [NSException raise:NSGenericException format:@"%@", reason]; }
+#define SFSQLiteErrorCase(SQLiteError) case SQLITE_ ## SQLiteError: [self SQLiteError:reason]; break
+
+@implementation SFSQLiteError
+
+// SQLite error codes
+SFSQLiteErrorRaiseMethod(ERROR)
+SFSQLiteErrorRaiseMethod(INTERNAL)
+SFSQLiteErrorRaiseMethod(PERM)
+SFSQLiteErrorRaiseMethod(ABORT)
+SFSQLiteErrorRaiseMethod(BUSY)
+SFSQLiteErrorRaiseMethod(LOCKED)
+SFSQLiteErrorRaiseMethod(NOMEM)
+SFSQLiteErrorRaiseMethod(READONLY)
+SFSQLiteErrorRaiseMethod(INTERRUPT)
+SFSQLiteErrorRaiseMethod(IOERR)
+SFSQLiteErrorRaiseMethod(CORRUPT)
+SFSQLiteErrorRaiseMethod(NOTFOUND)
+SFSQLiteErrorRaiseMethod(FULL)
+SFSQLiteErrorRaiseMethod(CANTOPEN)
+SFSQLiteErrorRaiseMethod(PROTOCOL)
+SFSQLiteErrorRaiseMethod(SCHEMA)
+SFSQLiteErrorRaiseMethod(TOOBIG)
+SFSQLiteErrorRaiseMethod(CONSTRAINT)
+SFSQLiteErrorRaiseMethod(MISMATCH)
+SFSQLiteErrorRaiseMethod(MISUSE)
+SFSQLiteErrorRaiseMethod(RANGE)
+SFSQLiteErrorRaiseMethod(NOTADB)
+
+// SQLite extended error codes
+SFSQLiteErrorRaiseMethod(IOERR_READ)
+SFSQLiteErrorRaiseMethod(IOERR_SHORT_READ)
+SFSQLiteErrorRaiseMethod(IOERR_WRITE)
+SFSQLiteErrorRaiseMethod(IOERR_FSYNC)
+SFSQLiteErrorRaiseMethod(IOERR_DIR_FSYNC)
+SFSQLiteErrorRaiseMethod(IOERR_TRUNCATE)
+SFSQLiteErrorRaiseMethod(IOERR_FSTAT)
+SFSQLiteErrorRaiseMethod(IOERR_UNLOCK)
+SFSQLiteErrorRaiseMethod(IOERR_RDLOCK)
+SFSQLiteErrorRaiseMethod(IOERR_DELETE)
+SFSQLiteErrorRaiseMethod(IOERR_BLOCKED)
+SFSQLiteErrorRaiseMethod(IOERR_NOMEM)
+SFSQLiteErrorRaiseMethod(IOERR_ACCESS)
+SFSQLiteErrorRaiseMethod(IOERR_CHECKRESERVEDLOCK)
+SFSQLiteErrorRaiseMethod(IOERR_LOCK)
+SFSQLiteErrorRaiseMethod(IOERR_CLOSE)
+SFSQLiteErrorRaiseMethod(IOERR_DIR_CLOSE)
+SFSQLiteErrorRaiseMethod(IOERR_SHMOPEN)
+SFSQLiteErrorRaiseMethod(IOERR_SHMSIZE)
+SFSQLiteErrorRaiseMethod(IOERR_SHMLOCK)
+SFSQLiteErrorRaiseMethod(IOERR_SHMMAP)
+SFSQLiteErrorRaiseMethod(IOERR_SEEK)
+SFSQLiteErrorRaiseMethod(IOERR_DELETE_NOENT)
+SFSQLiteErrorRaiseMethod(IOERR_MMAP)
+SFSQLiteErrorRaiseMethod(IOERR_GETTEMPPATH)
+SFSQLiteErrorRaiseMethod(IOERR_CONVPATH)
+SFSQLiteErrorRaiseMethod(LOCKED_SHAREDCACHE)
+SFSQLiteErrorRaiseMethod(BUSY_RECOVERY)
+SFSQLiteErrorRaiseMethod(BUSY_SNAPSHOT)
+SFSQLiteErrorRaiseMethod(CANTOPEN_NOTEMPDIR)
+SFSQLiteErrorRaiseMethod(CANTOPEN_ISDIR)
+SFSQLiteErrorRaiseMethod(CANTOPEN_FULLPATH)
+SFSQLiteErrorRaiseMethod(CANTOPEN_CONVPATH)
+SFSQLiteErrorRaiseMethod(CORRUPT_VTAB)
+SFSQLiteErrorRaiseMethod(READONLY_RECOVERY)
+SFSQLiteErrorRaiseMethod(READONLY_CANTLOCK)
+SFSQLiteErrorRaiseMethod(READONLY_ROLLBACK)
+SFSQLiteErrorRaiseMethod(READONLY_DBMOVED)
+SFSQLiteErrorRaiseMethod(ABORT_ROLLBACK)
+SFSQLiteErrorRaiseMethod(CONSTRAINT_CHECK)
+SFSQLiteErrorRaiseMethod(CONSTRAINT_COMMITHOOK)
+SFSQLiteErrorRaiseMethod(CONSTRAINT_FOREIGNKEY)
+SFSQLiteErrorRaiseMethod(CONSTRAINT_FUNCTION)
+SFSQLiteErrorRaiseMethod(CONSTRAINT_NOTNULL)
+SFSQLiteErrorRaiseMethod(CONSTRAINT_PRIMARYKEY)
+SFSQLiteErrorRaiseMethod(CONSTRAINT_TRIGGER)
+SFSQLiteErrorRaiseMethod(CONSTRAINT_UNIQUE)
+SFSQLiteErrorRaiseMethod(CONSTRAINT_VTAB)
+SFSQLiteErrorRaiseMethod(CONSTRAINT_ROWID)
+SFSQLiteErrorRaiseMethod(NOTICE_RECOVER_WAL)
+SFSQLiteErrorRaiseMethod(NOTICE_RECOVER_ROLLBACK)
+
++ (void)raise:(NSString *)reason code:(int)code extended:(int)extended {
+    switch(extended) {
+            SFSQLiteErrorCase(IOERR_READ);
+            SFSQLiteErrorCase(IOERR_SHORT_READ);
+            SFSQLiteErrorCase(IOERR_WRITE);
+            SFSQLiteErrorCase(IOERR_FSYNC);
+            SFSQLiteErrorCase(IOERR_DIR_FSYNC);
+            SFSQLiteErrorCase(IOERR_TRUNCATE);
+            SFSQLiteErrorCase(IOERR_FSTAT);
+            SFSQLiteErrorCase(IOERR_UNLOCK);
+            SFSQLiteErrorCase(IOERR_RDLOCK);
+            SFSQLiteErrorCase(IOERR_DELETE);
+            SFSQLiteErrorCase(IOERR_BLOCKED);
+            SFSQLiteErrorCase(IOERR_NOMEM);
+            SFSQLiteErrorCase(IOERR_ACCESS);
+            SFSQLiteErrorCase(IOERR_CHECKRESERVEDLOCK);
+            SFSQLiteErrorCase(IOERR_LOCK);
+            SFSQLiteErrorCase(IOERR_CLOSE);
+            SFSQLiteErrorCase(IOERR_DIR_CLOSE);
+            SFSQLiteErrorCase(IOERR_SHMOPEN);
+            SFSQLiteErrorCase(IOERR_SHMSIZE);
+            SFSQLiteErrorCase(IOERR_SHMLOCK);
+            SFSQLiteErrorCase(IOERR_SHMMAP);
+            SFSQLiteErrorCase(IOERR_SEEK);
+            SFSQLiteErrorCase(IOERR_DELETE_NOENT);
+            SFSQLiteErrorCase(IOERR_MMAP);
+            SFSQLiteErrorCase(IOERR_GETTEMPPATH);
+            SFSQLiteErrorCase(IOERR_CONVPATH);
+            SFSQLiteErrorCase(LOCKED_SHAREDCACHE);
+            SFSQLiteErrorCase(BUSY_RECOVERY);
+            SFSQLiteErrorCase(BUSY_SNAPSHOT);
+            SFSQLiteErrorCase(CANTOPEN_NOTEMPDIR);
+            SFSQLiteErrorCase(CANTOPEN_ISDIR);
+            SFSQLiteErrorCase(CANTOPEN_FULLPATH);
+            SFSQLiteErrorCase(CANTOPEN_CONVPATH);
+            SFSQLiteErrorCase(CORRUPT_VTAB);
+            SFSQLiteErrorCase(READONLY_RECOVERY);
+            SFSQLiteErrorCase(READONLY_CANTLOCK);
+            SFSQLiteErrorCase(READONLY_ROLLBACK);
+            SFSQLiteErrorCase(READONLY_DBMOVED);
+            SFSQLiteErrorCase(ABORT_ROLLBACK);
+            SFSQLiteErrorCase(CONSTRAINT_CHECK);
+            SFSQLiteErrorCase(CONSTRAINT_COMMITHOOK);
+            SFSQLiteErrorCase(CONSTRAINT_FOREIGNKEY);
+            SFSQLiteErrorCase(CONSTRAINT_FUNCTION);
+            SFSQLiteErrorCase(CONSTRAINT_NOTNULL);
+            SFSQLiteErrorCase(CONSTRAINT_PRIMARYKEY);
+            SFSQLiteErrorCase(CONSTRAINT_TRIGGER);
+            SFSQLiteErrorCase(CONSTRAINT_UNIQUE);
+            SFSQLiteErrorCase(CONSTRAINT_VTAB);
+            SFSQLiteErrorCase(CONSTRAINT_ROWID);
+            SFSQLiteErrorCase(NOTICE_RECOVER_WAL);
+            SFSQLiteErrorCase(NOTICE_RECOVER_ROLLBACK);
+        default: break;
+    }
+    switch(code) {
+            SFSQLiteErrorCase(ERROR);
+            SFSQLiteErrorCase(INTERNAL);
+            SFSQLiteErrorCase(PERM);
+            SFSQLiteErrorCase(ABORT);
+            SFSQLiteErrorCase(BUSY);
+            SFSQLiteErrorCase(LOCKED);
+            SFSQLiteErrorCase(NOMEM);
+            SFSQLiteErrorCase(READONLY);
+            SFSQLiteErrorCase(INTERRUPT);
+            SFSQLiteErrorCase(IOERR);
+            SFSQLiteErrorCase(CORRUPT);
+            SFSQLiteErrorCase(NOTFOUND);
+            SFSQLiteErrorCase(FULL);
+            SFSQLiteErrorCase(CANTOPEN);
+            SFSQLiteErrorCase(PROTOCOL);
+            SFSQLiteErrorCase(SCHEMA);
+            SFSQLiteErrorCase(TOOBIG);
+            SFSQLiteErrorCase(CONSTRAINT);
+            SFSQLiteErrorCase(MISMATCH);
+            SFSQLiteErrorCase(MISUSE);
+            SFSQLiteErrorCase(RANGE);
+            SFSQLiteErrorCase(NOTADB);
+        default: break;
+    }
+    [NSException raise:NSGenericException format:@"%@", reason];
+}
+
+@end