diff --git a/ApolloSQLite.xcodeproj/project.pbxproj b/ApolloSQLite.xcodeproj/project.pbxproj index 9196fdd90f..6f37e0d82a 100644 --- a/ApolloSQLite.xcodeproj/project.pbxproj +++ b/ApolloSQLite.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 5438069A1EA56C2200F55B72 /* ApolloSQLite.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 54DDB0BE1EA1523E0009DD99 /* ApolloSQLite.framework */; }; 54DDB1AF1EA54F7E0009DD99 /* SQLiteNormalizedCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54DDB1AD1EA54F770009DD99 /* SQLiteNormalizedCache.swift */; }; + 9BD94B0822E329AF0090C943 /* SQLiteSerialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD94B0722E329AF0090C943 /* SQLiteSerialization.swift */; }; 9F26433B2074BADA00A4519E /* Apollo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9F26433A2074BADA00A4519E /* Apollo.framework */; }; 9F37A05320A3E5CC0009A911 /* StarWarsAPI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9F37A05220A3E5CC0009A911 /* StarWarsAPI.framework */; }; 9F65B12B1EC1B4080090B25F /* ApolloSQLiteTestSupport.h in Headers */ = {isa = PBXBuildFile; fileRef = 9F65B1291EC1B4080090B25F /* ApolloSQLiteTestSupport.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -102,6 +103,7 @@ 90690D10224334EC00FC2E54 /* ApolloSQLite-Target-Framework.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "ApolloSQLite-Target-Framework.xcconfig"; sourceTree = ""; }; 90690D11224334F200FC2E54 /* ApolloSQLite-Target-Tests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "ApolloSQLite-Target-Tests.xcconfig"; sourceTree = ""; }; 90690D262243404B00FC2E54 /* ApolloSQLite-Target-TestSupport.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "ApolloSQLite-Target-TestSupport.xcconfig"; sourceTree = ""; }; + 9BD94B0722E329AF0090C943 /* SQLiteSerialization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLiteSerialization.swift; sourceTree = ""; }; 9F26433A2074BADA00A4519E /* Apollo.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Apollo.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9F37A05220A3E5CC0009A911 /* StarWarsAPI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = StarWarsAPI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9F65B1271EC1B4080090B25F /* ApolloSQLiteTestSupport.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ApolloSQLiteTestSupport.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -172,6 +174,7 @@ isa = PBXGroup; children = ( 54DDB1AD1EA54F770009DD99 /* SQLiteNormalizedCache.swift */, + 9BD94B0722E329AF0090C943 /* SQLiteSerialization.swift */, 9FA86C5A1EA7B6D40073E1ED /* Info.plist */, ); name = ApolloSQLite; @@ -448,6 +451,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 9BD94B0822E329AF0090C943 /* SQLiteSerialization.swift in Sources */, 54DDB1AF1EA54F7E0009DD99 /* SQLiteNormalizedCache.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Sources/ApolloSQLite/SQLiteNormalizedCache.swift b/Sources/ApolloSQLite/SQLiteNormalizedCache.swift index 72fedb7de0..8ba4fb4797 100644 --- a/Sources/ApolloSQLite/SQLiteNormalizedCache.swift +++ b/Sources/ApolloSQLite/SQLiteNormalizedCache.swift @@ -9,41 +9,27 @@ public enum SQLiteNormalizedCacheError: Error { case invalidRecordValue(value: Any) } -public final class SQLiteNormalizedCache: NormalizedCache { - - public init(fileURL: URL) throws { - db = try Connection(.uri(fileURL.absoluteString), readonly: false) - try createTableIfNeeded() - } - - public func merge(records: RecordSet) -> Promise> { - return Promise { try mergeRecords(records: records) } - } - - public func loadRecords(forKeys keys: [CacheKey]) -> Promise<[Record?]> { - return Promise { - let records = try selectRecords(forKeys: keys) - let recordsOrNil: [Record?] = keys.map { key in - if let recordIndex = records.firstIndex(where: { $0.key == key }) { - return records[recordIndex] - } - return nil - } - return recordsOrNil - } - } - - public func clear() -> Promise { - return Promise { - return try clearRecords() - } - } - +/// A `NormalizedCache` implementation which uses a SQLite database to store data. +public final class SQLiteNormalizedCache { + private let db: Connection private let records = Table("records") private let id = Expression("_id") private let key = Expression("key") private let record = Expression("record") + private let shouldVacuumOnClear: Bool + + /// Designated initializer + /// + /// - Parameters: + /// - fileURL: The file URL to use for your database. + /// - shouldVacuumOnClear: If the database should also be `VACCUM`ed on clear to remove all traces of info. Defaults to `false` since this involves a performance hit, but this should be used if you are storing any Personally Identifiable Information in the cache. + /// - Throws: Any errors attempting to open or create the database. + public init(fileURL: URL, shouldVacuumOnClear: Bool = false) throws { + self.shouldVacuumOnClear = shouldVacuumOnClear + self.db = try Connection(.uri(fileURL.absoluteString), readonly: false) + try self.createTableIfNeeded() + } private func recordCacheKey(forFieldCacheKey fieldCacheKey: CacheKey) -> CacheKey { var components = fieldCacheKey.components(separatedBy: ".") @@ -54,18 +40,18 @@ public final class SQLiteNormalizedCache: NormalizedCache { } private func createTableIfNeeded() throws { - try db.run(records.create(ifNotExists: true) { table in + try self.db.run(self.records.create(ifNotExists: true) { table in table.column(id, primaryKey: .autoincrement) table.column(key, unique: true) table.column(record) }) - try db.run(records.createIndex(key, unique: true, ifNotExists: true)) + try self.db.run(self.records.createIndex(key, unique: true, ifNotExists: true)) } private func mergeRecords(records: RecordSet) throws -> Set { - var recordSet = RecordSet(records: try selectRecords(forKeys: records.keys)) + var recordSet = RecordSet(records: try self.selectRecords(forKeys: records.keys)) let changedFieldKeys = recordSet.merge(records: records) - let changedRecordKeys = changedFieldKeys.map { recordCacheKey(forFieldCacheKey: $0) } + let changedRecordKeys = changedFieldKeys.map { self.recordCacheKey(forFieldCacheKey: $0) } for recordKey in Set(changedRecordKeys) { if let recordFields = recordSet[recordKey]?.fields { let recordData = try SQLiteSerialization.serialize(fields: recordFields) @@ -73,19 +59,22 @@ public final class SQLiteNormalizedCache: NormalizedCache { assertionFailure("Serialization should yield UTF-8 data") continue } - try db.run(self.records.insert(or: .replace, self.key <- recordKey, self.record <- recordString)) + try self.db.run(self.records.insert(or: .replace, self.key <- recordKey, self.record <- recordString)) } } return Set(changedFieldKeys) } private func selectRecords(forKeys keys: [CacheKey]) throws -> [Record] { - let query = records.filter(keys.contains(key)) - return try db.prepare(query).map { try parse(row: $0) } + let query = self.records.filter(keys.contains(key)) + return try self.db.prepare(query).map { try parse(row: $0) } } private func clearRecords() throws { - try db.run(records.delete()) + try self.db.run(records.delete()) + if self.shouldVacuumOnClear { + try self.db.prepare("VACUUM;").run() + } } private func parse(row: Row) throws -> Record { @@ -100,51 +89,30 @@ public final class SQLiteNormalizedCache: NormalizedCache { } } -private let serializedReferenceKey = "$reference" +// MARK: - NormalizedCache conformance -private final class SQLiteSerialization { - static func serialize(fields: Record.Fields) throws -> Data { - var objectToSerialize = JSONObject() - for (key, value) in fields { - objectToSerialize[key] = try serialize(fieldValue: value) - } - return try JSONSerialization.data(withJSONObject: objectToSerialize, options: []) - } +extension SQLiteNormalizedCache: NormalizedCache { - private static func serialize(fieldValue: Record.Value) throws -> JSONValue { - switch fieldValue { - case let reference as Reference: - return [serializedReferenceKey: reference.key] - case let array as [Record.Value]: - return try array.map { try serialize(fieldValue: $0) } - default: - return fieldValue - } + public func merge(records: RecordSet) -> Promise> { + return Promise { try self.mergeRecords(records: records) } } - - static func deserialize(data: Data) throws -> Record.Fields { - let object = try JSONSerialization.jsonObject(with: data, options: []) - guard let jsonObject = object as? JSONObject else { - throw SQLiteNormalizedCacheError.invalidRecordShape(object: object) - } - var fields = Record.Fields() - for (key, value) in jsonObject { - fields[key] = try deserialize(fieldJSONValue: value) + + public func loadRecords(forKeys keys: [CacheKey]) -> Promise<[Record?]> { + return Promise { + let records = try self.selectRecords(forKeys: keys) + let recordsOrNil: [Record?] = keys.map { key in + if let recordIndex = records.firstIndex(where: { $0.key == key }) { + return records[recordIndex] + } + return nil + } + return recordsOrNil } - return fields } - - private static func deserialize(fieldJSONValue: JSONValue) throws -> Record.Value { - switch fieldJSONValue { - case let dictionary as JSONObject: - guard let reference = dictionary[serializedReferenceKey] as? String else { - throw SQLiteNormalizedCacheError.invalidRecordValue(value: fieldJSONValue) - } - return Reference(key: reference) - case let array as [JSONValue]: - return try array.map { try deserialize(fieldJSONValue: $0) } - default: - return fieldJSONValue + + public func clear() -> Promise { + return Promise { + return try self.clearRecords() } } } diff --git a/Sources/ApolloSQLite/SQLiteSerialization.swift b/Sources/ApolloSQLite/SQLiteSerialization.swift new file mode 100644 index 0000000000..f8b1a24812 --- /dev/null +++ b/Sources/ApolloSQLite/SQLiteSerialization.swift @@ -0,0 +1,53 @@ +import SQLite +#if !COCOAPODS +import Apollo +#endif + +private let serializedReferenceKey = "$reference" + +final class SQLiteSerialization { + static func serialize(fields: Record.Fields) throws -> Data { + var objectToSerialize = JSONObject() + for (key, value) in fields { + objectToSerialize[key] = try serialize(fieldValue: value) + } + return try JSONSerialization.data(withJSONObject: objectToSerialize, options: []) + } + + private static func serialize(fieldValue: Record.Value) throws -> JSONValue { + switch fieldValue { + case let reference as Reference: + return [serializedReferenceKey: reference.key] + case let array as [Record.Value]: + return try array.map { try serialize(fieldValue: $0) } + default: + return fieldValue + } + } + + static func deserialize(data: Data) throws -> Record.Fields { + let object = try JSONSerialization.jsonObject(with: data, options: []) + guard let jsonObject = object as? JSONObject else { + throw SQLiteNormalizedCacheError.invalidRecordShape(object: object) + } + var fields = Record.Fields() + for (key, value) in jsonObject { + fields[key] = try deserialize(fieldJSONValue: value) + } + return fields + } + + private static func deserialize(fieldJSONValue: JSONValue) throws -> Record.Value { + switch fieldJSONValue { + case let dictionary as JSONObject: + guard let reference = dictionary[serializedReferenceKey] as? String else { + throw SQLiteNormalizedCacheError.invalidRecordValue(value: fieldJSONValue) + } + return Reference(key: reference) + case let array as [JSONValue]: + return try array.map { try deserialize(fieldJSONValue: $0) } + default: + return fieldJSONValue + } + } +}