Skip to content

Commit 8d8d07c

Browse files
authored
Merge pull request #93 from apple/persistent-cache
[TSCUtility] Add support for creating a persistent cache
2 parents e5c7760 + 3ca5062 commit 8d8d07c

File tree

6 files changed

+373
-0
lines changed

6 files changed

+373
-0
lines changed

Diff for: .gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
xcuserdata/
66
.swiftpm
77
build
8+
.vscode

Diff for: Sources/TSCUtility/PersistenceCache.swift

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2014 - 2020 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See http://swift.org/LICENSE.txt for license information
8+
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import TSCBasic
12+
import Foundation
13+
14+
/// A protocol for Data -> Data cache.
15+
public protocol PersistentCacheProtocol {
16+
func get(key: Data) throws -> Data?
17+
func put(key: Data, value: Data) throws
18+
}
19+
20+
/// SQLite backed persistent cache.
21+
public final class SQLiteBackedPersistentCache: PersistentCacheProtocol {
22+
let db: SQLite
23+
24+
init(db: SQLite) throws {
25+
self.db = db
26+
27+
let table = """
28+
CREATE TABLE IF NOT EXISTS TSCCACHE (
29+
key BLOB PRIMARY KEY NOT NULL,
30+
value BLOB NOT NULL
31+
);
32+
"""
33+
34+
try db.exec(query: table)
35+
try db.exec(query: "PRAGMA journal_mode=WAL;")
36+
}
37+
38+
deinit {
39+
try? db.close()
40+
}
41+
42+
public convenience init(cacheFilePath: AbsolutePath) throws {
43+
let db = try SQLite(dbPath: cacheFilePath)
44+
try self.init(db: db)
45+
}
46+
47+
public func get(key: Data) throws -> Data? {
48+
let readStmt = try self.db.prepare(query: "SELECT value FROM TSCCACHE WHERE key == ? LIMIT 1;")
49+
try readStmt.bind([.blob(key)])
50+
let row = try readStmt.step()
51+
let blob = row?.blob(at: 0)
52+
try readStmt.finalize()
53+
return blob
54+
}
55+
56+
public func put(key: Data, value: Data) throws {
57+
let writeStmt = try self.db.prepare(query: "INSERT OR IGNORE INTO TSCCACHE VALUES (?, ?)")
58+
let bindings: [SQLite.SQLiteValue] = [
59+
.blob(key),
60+
.blob(value),
61+
]
62+
try writeStmt.bind(bindings)
63+
try writeStmt.step()
64+
try writeStmt.finalize()
65+
}
66+
}

Diff for: Sources/TSCUtility/SQLite.swift

+235
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2014 - 2020 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See http://swift.org/LICENSE.txt for license information
8+
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Foundation
12+
import TSCBasic
13+
14+
@_implementationOnly import CSQLite3
15+
16+
/// A minimal SQLite wrapper.
17+
public struct SQLite {
18+
/// Represents an sqlite value.
19+
public enum SQLiteValue {
20+
case null
21+
case string(String)
22+
case int(Int)
23+
case blob(Data)
24+
}
25+
26+
/// Represents a row returned by called step() on a prepared statement.
27+
public struct Row {
28+
/// The pointer to the prepared statment.
29+
let stmt: OpaquePointer
30+
31+
/// Get integer at the given column index.
32+
func int(at index: Int32) -> Int {
33+
Int(sqlite3_column_int64(stmt, index))
34+
}
35+
36+
/// Get blob data at the given column index.
37+
func blob(at index: Int32) -> Data {
38+
let bytes = sqlite3_column_blob(stmt, index)!
39+
let count = sqlite3_column_bytes(stmt, index)
40+
return Data(bytes: bytes, count: Int(count))
41+
}
42+
43+
/// Get string at the given column index.
44+
func string(at index: Int32) -> String {
45+
return String(cString: sqlite3_column_text(stmt, index))
46+
}
47+
}
48+
49+
/// Represents a prepared statement.
50+
public struct PreparedStatement {
51+
typealias sqlite3_destructor_type = (@convention(c) (UnsafeMutableRawPointer?) -> Void)
52+
static let SQLITE_STATIC = unsafeBitCast(0, to: sqlite3_destructor_type.self)
53+
static let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
54+
55+
/// The pointer to the prepared statment.
56+
let stmt: OpaquePointer
57+
58+
public init(db: OpaquePointer, query: String) throws {
59+
var stmt: OpaquePointer?
60+
try sqlite { sqlite3_prepare_v2(db, query, -1, &stmt, nil) }
61+
self.stmt = stmt!
62+
}
63+
64+
/// Evaluate the prepared statement.
65+
@discardableResult
66+
public func step() throws -> Row? {
67+
let result = sqlite3_step(stmt)
68+
69+
switch result {
70+
case SQLITE_DONE:
71+
return nil
72+
case SQLITE_ROW:
73+
return Row(stmt: stmt)
74+
default:
75+
throw StringError(String(cString: sqlite3_errstr(result)))
76+
}
77+
}
78+
79+
/// Bind the given arguments to the statement.
80+
public func bind(_ arguments: [SQLiteValue]) throws {
81+
for (idx, argument) in arguments.enumerated() {
82+
let idx = Int32(idx) + 1
83+
switch argument {
84+
case .null:
85+
try sqlite { sqlite3_bind_null(stmt, idx) }
86+
case .int(let int):
87+
try sqlite { sqlite3_bind_int64(stmt, idx, Int64(int)) }
88+
case .string(let str):
89+
try sqlite { sqlite3_bind_text(stmt, idx, str, -1, Self.SQLITE_TRANSIENT) }
90+
case .blob(let blob):
91+
try sqlite {
92+
blob.withUnsafeBytes { ptr in
93+
sqlite3_bind_blob(
94+
stmt,
95+
idx,
96+
ptr.baseAddress,
97+
Int32(blob.count),
98+
Self.SQLITE_TRANSIENT
99+
)
100+
}
101+
}
102+
}
103+
}
104+
}
105+
106+
/// Reset the prepared statement.
107+
public func reset() throws {
108+
try sqlite { sqlite3_reset(stmt) }
109+
}
110+
111+
/// Clear bindings from the prepared statment.
112+
public func clearBindings() throws {
113+
try sqlite { sqlite3_clear_bindings(stmt) }
114+
}
115+
116+
/// Finalize the statement and free up resources.
117+
public func finalize() throws {
118+
try sqlite { sqlite3_finalize(stmt) }
119+
}
120+
}
121+
122+
/// The path to the database file.
123+
public let dbPath: AbsolutePath
124+
125+
/// Pointer to the database.
126+
let db: OpaquePointer
127+
128+
/// Create or open the database at the given path.
129+
///
130+
/// The database is opened in serialized mode.
131+
public init(dbPath: AbsolutePath) throws {
132+
self.dbPath = dbPath
133+
134+
var db: OpaquePointer? = nil
135+
try sqlite("unable to open database at \(dbPath)") {
136+
sqlite3_open_v2(
137+
dbPath.pathString,
138+
&db,
139+
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX,
140+
nil
141+
)
142+
}
143+
144+
self.db = db!
145+
try sqlite { sqlite3_extended_result_codes(db, 1) }
146+
try sqlite { sqlite3_busy_timeout(db, 5 * 1000 /* 5s */) }
147+
}
148+
149+
/// Prepare the given query.
150+
public func prepare(query: String) throws -> PreparedStatement {
151+
try PreparedStatement(db: db, query: query)
152+
}
153+
154+
/// Directly execute the given query.
155+
///
156+
/// Note: Use withCString for string arguments.
157+
public func exec(query: String, args: [CVarArg] = [], _ callback: SQLiteExecCallback? = nil) throws {
158+
let query = withVaList(args) { ptr in
159+
sqlite3_vmprintf(query, ptr)
160+
}
161+
162+
let wcb = callback.map { CallbackWrapper($0) }
163+
let callbackCtx = wcb.map { Unmanaged.passUnretained($0).toOpaque() }
164+
165+
var err: UnsafeMutablePointer<Int8>? = nil
166+
try sqlite { sqlite3_exec(db, query, sqlite_callback, callbackCtx, &err) }
167+
168+
if let err = err {
169+
let errorString = String(cString: err)
170+
sqlite3_free(err)
171+
throw StringError(errorString)
172+
}
173+
174+
sqlite3_free(query)
175+
}
176+
177+
public func close() throws {
178+
try sqlite { sqlite3_close(db) }
179+
}
180+
181+
public struct Column {
182+
public var name: String
183+
public var value: String
184+
}
185+
186+
public typealias SQLiteExecCallback = ([Column]) -> Void
187+
}
188+
189+
private class CallbackWrapper {
190+
var callback: SQLite.SQLiteExecCallback
191+
init(_ callback: @escaping SQLite.SQLiteExecCallback) {
192+
self.callback = callback
193+
}
194+
}
195+
196+
private func sqlite_callback(
197+
_ ctx: UnsafeMutableRawPointer?,
198+
_ numColumns: Int32,
199+
_ columns: UnsafeMutablePointer<UnsafeMutablePointer<Int8>?>?,
200+
_ columnNames: UnsafeMutablePointer<UnsafeMutablePointer<Int8>?>?
201+
) -> Int32 {
202+
guard let ctx = ctx else { return 0 }
203+
guard let columnNames = columnNames, let columns = columns else { return 0 }
204+
let numColumns = Int(numColumns)
205+
var result: [SQLite.Column] = []
206+
207+
for idx in 0..<numColumns {
208+
var name = ""
209+
if let ptr = columnNames.advanced(by: idx).pointee {
210+
name = String(cString: ptr)
211+
}
212+
var value = ""
213+
if let ptr = columns.advanced(by: idx).pointee {
214+
value = String(cString: ptr)
215+
}
216+
result.append(SQLite.Column(name: name, value: value))
217+
}
218+
219+
let wcb = Unmanaged<CallbackWrapper>.fromOpaque(ctx).takeUnretainedValue()
220+
wcb.callback(result)
221+
222+
return 0
223+
}
224+
225+
private func sqlite(_ errorPrefix: String? = nil, _ fn: () -> Int32) throws {
226+
let result = fn()
227+
if result != SQLITE_OK {
228+
var error = ""
229+
if let errorPrefix = errorPrefix {
230+
error += errorPrefix + ": "
231+
}
232+
error += String(cString: sqlite3_errstr(result))
233+
throw StringError(error)
234+
}
235+
}

Diff for: Sources/TSCclibc/include/csqlite3.h

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
#include <sqlite3.h>

Diff for: Sources/TSCclibc/include/module.modulemap

+6
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,9 @@ module TSCclibc {
44
header "process.h"
55
export *
66
}
7+
8+
module CSQLite3 [system] {
9+
header "csqlite3.h"
10+
link "sqlite3"
11+
export *
12+
}

Diff for: Tests/TSCUtilityTests/PersistentCacheTests.swift

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright 2020 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See http://swift.org/LICENSE.txt for license information
8+
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import XCTest
12+
import TSCBasic
13+
import TSCTestSupport
14+
15+
import TSCUtility
16+
17+
struct Key: Codable {
18+
var key: String
19+
}
20+
21+
struct Value: Codable, Equatable {
22+
var str: String
23+
var int: Int
24+
}
25+
26+
class PersistentCacheTests: XCTestCase {
27+
func testBasics() throws {
28+
mktmpdir { tmpPath in
29+
let encoder = JSONEncoder()
30+
let decoder = JSONDecoder()
31+
let cacheFilePath = tmpPath.appending(component: "cache.db")
32+
let cache = try SQLiteBackedPersistentCache(cacheFilePath: cacheFilePath)
33+
34+
let key1 = Key(key: "key1")
35+
let value1 = Value(str: "value1", int: 1)
36+
37+
let key2 = Key(key: "key2")
38+
let value2 = Value(str: "value2", int: 2)
39+
40+
XCTAssertNil(try cache.get(key: encoder.encode(key1)))
41+
XCTAssertNil(try cache.get(key: encoder.encode(key2)))
42+
43+
try cache.put(key: encoder.encode(key1), value: encoder.encode(value1))
44+
try cache.put(key: encoder.encode(key2), value: encoder.encode(value2))
45+
46+
let retVal1 = try cache.get(key: encoder.encode(key1)).map {
47+
try decoder.decode(Value.self, from: $0)
48+
}
49+
50+
let retVal2 = try cache.get(key: encoder.encode(key2)).map {
51+
try decoder.decode(Value.self, from: $0)
52+
}
53+
54+
XCTAssertEqual(retVal1, value1)
55+
XCTAssertEqual(retVal2, value2)
56+
57+
try cache.put(key: encoder.encode(key1), value: encoder.encode(value2))
58+
let retVal3 = try cache.get(key: encoder.encode(key1)).map {
59+
try decoder.decode(Value.self, from: $0)
60+
}
61+
XCTAssertEqual(retVal3, value1)
62+
}
63+
}
64+
}

0 commit comments

Comments
 (0)