Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add option to Vacuum SQLite on clearing, clean up and document SQLite public stuff #652

Merged
merged 7 commits into from
Jul 23, 2019
4 changes: 4 additions & 0 deletions ApolloSQLite.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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, ); }; };
Expand Down Expand Up @@ -102,6 +103,7 @@
90690D10224334EC00FC2E54 /* ApolloSQLite-Target-Framework.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "ApolloSQLite-Target-Framework.xcconfig"; sourceTree = "<group>"; };
90690D11224334F200FC2E54 /* ApolloSQLite-Target-Tests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "ApolloSQLite-Target-Tests.xcconfig"; sourceTree = "<group>"; };
90690D262243404B00FC2E54 /* ApolloSQLite-Target-TestSupport.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "ApolloSQLite-Target-TestSupport.xcconfig"; sourceTree = "<group>"; };
9BD94B0722E329AF0090C943 /* SQLiteSerialization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLiteSerialization.swift; sourceTree = "<group>"; };
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; };
Expand Down Expand Up @@ -172,6 +174,7 @@
isa = PBXGroup;
children = (
54DDB1AD1EA54F770009DD99 /* SQLiteNormalizedCache.swift */,
9BD94B0722E329AF0090C943 /* SQLiteSerialization.swift */,
9FA86C5A1EA7B6D40073E1ED /* Info.plist */,
);
name = ApolloSQLite;
Expand Down Expand Up @@ -448,6 +451,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
9BD94B0822E329AF0090C943 /* SQLiteSerialization.swift in Sources */,
54DDB1AF1EA54F7E0009DD99 /* SQLiteNormalizedCache.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
124 changes: 46 additions & 78 deletions Sources/ApolloSQLite/SQLiteNormalizedCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Set<CacheKey>> {
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<Void> {
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<Int64>("_id")
private let key = Expression<CacheKey>("key")
private let record = Expression<String>("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: ".")
Expand All @@ -54,38 +40,41 @@ 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<CacheKey> {
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)
guard let recordString = String(data: recordData, encoding: .utf8) else {
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("records VACUUM;").run()
designatednerd marked this conversation as resolved.
Show resolved Hide resolved
}
}

private func parse(row: Row) throws -> Record {
Expand All @@ -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<Set<CacheKey>> {
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<Void> {
return Promise {
return try self.clearRecords()
}
}
}
53 changes: 53 additions & 0 deletions Sources/ApolloSQLite/SQLiteSerialization.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}