Files

435 lines
17 KiB
Swift

import XCTest
@testable import GRDB
class DatabaseSnapshotTests: GRDBTestCase {
/// A helper type
private struct Counter {
init(dbPool: DatabasePool) throws {
try dbPool.write { db in
try db.execute(sql: "CREATE TABLE counter(id INTEGER PRIMARY KEY)")
}
}
func increment(_ db: Database) throws {
try db.execute(sql: "INSERT INTO counter DEFAULT VALUES")
}
func value(_ db: Database) throws -> Int {
try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM counter")!
}
}
// MARK: - Creation
func testSnapshotCanReadBeforeDatabaseModification() throws {
let dbPool = try makeDatabasePool()
let snapshot = try dbPool.makeSnapshot()
try XCTAssertEqual(snapshot.read { try $0.tableExists("foo") }, false)
}
func testSnapshotCreatedFromMainQueueCanRead() throws {
let dbPool = try makeDatabasePool()
let counter = try Counter(dbPool: dbPool)
let snapshot = try dbPool.makeSnapshot()
try XCTAssertEqual(snapshot.read(counter.value), 0)
}
func testSnapshotCreatedFromWriterOutsideOfTransactionCanRead() throws {
let dbPool = try makeDatabasePool()
let counter = try Counter(dbPool: dbPool)
let snapshot = try dbPool.writeWithoutTransaction { db -> DatabaseSnapshot in
XCTAssertFalse(db.isInsideTransaction)
let snapshot = try dbPool.makeSnapshot()
try counter.increment(db)
return snapshot
}
try XCTAssertEqual(snapshot.read(counter.value), 0)
}
func testSnapshotCreatedFromReaderTransactionCanRead() throws {
let dbPool = try makeDatabasePool()
let counter = try Counter(dbPool: dbPool)
let snapshot = try dbPool.read { db -> DatabaseSnapshot in
XCTAssertTrue(db.isInsideTransaction)
return try dbPool.makeSnapshot()
}
try XCTAssertEqual(snapshot.read(counter.value), 0)
}
func testSnapshotCreatedFromReaderOutsideOfTransactionCanRead() throws {
let dbPool = try makeDatabasePool()
let counter = try Counter(dbPool: dbPool)
let snapshot = try dbPool.unsafeRead { db -> DatabaseSnapshot in
XCTAssertFalse(db.isInsideTransaction)
return try dbPool.makeSnapshot()
}
try XCTAssertEqual(snapshot.read(counter.value), 0)
}
func testSnapshotCreatedFromTransactionObserver() throws {
// Creating a snapshot from a didCommit callback is an important use
// case. But we know SQLite snapshots created with
// sqlite3_snapshot_get() requires a transaction. This means that
// creating a snapshot will open a transaction. We must make sure this
// transaction does not create any deadlock of reentrancy issue with
// transaction observers.
class Observer: TransactionObserver {
let dbPool: DatabasePool
var snapshot: DatabaseSnapshot
init(dbPool: DatabasePool, snapshot: DatabaseSnapshot) {
self.dbPool = dbPool
self.snapshot = snapshot
}
func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool { false }
func databaseDidChange(with event: DatabaseEvent) { }
func databaseDidCommit(_ db: Database) {
snapshot = try! dbPool.makeSnapshot()
}
func databaseDidRollback(_ db: Database) { }
}
let dbPool = try makeDatabasePool()
let counter = try Counter(dbPool: dbPool)
let observer = try Observer(dbPool: dbPool, snapshot: dbPool.makeSnapshot())
dbPool.add(transactionObserver: observer)
try XCTAssertEqual(observer.snapshot.read(counter.value), 0)
try dbPool.write(counter.increment)
try XCTAssertEqual(observer.snapshot.read(counter.value), 1)
}
// MARK: - Behavior
func testSnapshotIsReadOnly() throws {
let dbPool = try makeDatabasePool()
let snapshot = try dbPool.makeSnapshot()
do {
try snapshot.read { db in
try db.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY")
}
XCTFail("Expected error")
} catch is DatabaseError { }
}
func testSnapshotIsImmutable() throws {
let dbPool = try makeDatabasePool()
let counter = try Counter(dbPool: dbPool)
try dbPool.writeWithoutTransaction { db in
try counter.increment(db)
let snapshot = try dbPool.makeSnapshot()
try counter.increment(db)
try XCTAssertEqual(counter.value(db), 2)
try XCTAssertEqual(snapshot.read(counter.value), 1)
try XCTAssertEqual(dbPool.read(counter.value), 2)
try XCTAssertEqual(snapshot.read(counter.value), 1)
try XCTAssertEqual(counter.value(db), 2)
try XCTAssertEqual(dbPool.read(counter.value), 2)
}
}
// MARK: - Functions
func testSnapshotInheritPoolFunctions() throws {
dbConfiguration.prepareDatabase { db in
let function = DatabaseFunction("foo", argumentCount: 0, pure: true) { _ in return "foo" }
db.add(function: function)
}
let dbPool = try makeDatabasePool()
let snapshot = try dbPool.makeSnapshot()
try snapshot.read { db in
try XCTAssertEqual(String.fetchOne(db, sql: "SELECT foo()")!, "foo")
}
}
// MARK: - Collations
func testSnapshotInheritPoolCollations() throws {
dbConfiguration.prepareDatabase { db in
let collation = DatabaseCollation("reverse") { (string1, string2) in
return (string1 == string2) ? .orderedSame : ((string1 < string2) ? .orderedDescending : .orderedAscending)
}
db.add(collation: collation)
}
let dbPool = try makeDatabasePool()
try dbPool.write { db in
try db.execute(sql: "CREATE TABLE items (text TEXT)")
try db.execute(sql: "INSERT INTO items (text) VALUES ('a')")
try db.execute(sql: "INSERT INTO items (text) VALUES ('b')")
try db.execute(sql: "INSERT INTO items (text) VALUES ('c')")
}
let snapshot = try dbPool.makeSnapshot()
try snapshot.read { db in
XCTAssertEqual(try String.fetchAll(db, sql: "SELECT text FROM items ORDER BY text COLLATE reverse"), ["c", "b", "a"])
}
}
// MARK: - Concurrency
func testReadBlockIsolationStartingWithRead() throws {
let dbPool = try makeDatabasePool()
try dbPool.write { db in
try db.execute(sql: "CREATE TABLE items (id INTEGER PRIMARY KEY)")
}
// Block 1 Block 2
// dbSnapshot.read {
// >
let s1 = DispatchSemaphore(value: 0)
// INSERT INTO items (id) VALUES (NULL)
// <
let s2 = DispatchSemaphore(value: 0)
// SELECT COUNT(*) FROM items -> 0
// >
let s3 = DispatchSemaphore(value: 0)
// INSERT INTO items (id) VALUES (NULL)
// <
let s4 = DispatchSemaphore(value: 0)
// SELECT COUNT(*) FROM items -> 0
// }
let block1 = { () in
let snapshot = try! dbPool.makeSnapshot()
try! snapshot.read { db in
s1.signal()
_ = s2.wait(timeout: .distantFuture)
// We read 0 due to snaphot isolation which was acquired before
// `s1` could let the writer insert an item.
XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM items")!, 0)
s3.signal()
_ = s4.wait(timeout: .distantFuture)
XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM items")!, 0)
}
}
let block2 = { () in
do {
_ = s1.wait(timeout: .distantFuture)
try dbPool.writeWithoutTransaction { db in
try db.execute(sql: "INSERT INTO items (id) VALUES (NULL)")
s2.signal()
_ = s3.wait(timeout: .distantFuture)
try db.execute(sql: "INSERT INTO items (id) VALUES (NULL)")
s4.signal()
}
} catch {
XCTFail("error: \(error)")
}
}
let blocks = [block1, block2]
DispatchQueue.concurrentPerform(iterations: blocks.count) { index in
blocks[index]()
}
}
func testDefaultLabel() throws {
let dbPool = try makeDatabasePool()
let snapshot1 = try dbPool.makeSnapshot()
snapshot1.unsafeRead { db in
XCTAssertEqual(db.configuration.label, nil)
XCTAssertEqual(db.description, "GRDB.DatabasePool.snapshot.1")
// This test CAN break in future releases: the dispatch queue labels
// are documented to be a debug-only tool.
let label = String(utf8String: __dispatch_queue_get_label(nil))
XCTAssertEqual(label, "GRDB.DatabasePool.snapshot.1")
}
let snapshot2 = try dbPool.makeSnapshot()
snapshot2.unsafeRead { db in
XCTAssertEqual(db.configuration.label, nil)
XCTAssertEqual(db.description, "GRDB.DatabasePool.snapshot.2")
// This test CAN break in future releases: the dispatch queue labels
// are documented to be a debug-only tool.
let label = String(utf8String: __dispatch_queue_get_label(nil))
XCTAssertEqual(label, "GRDB.DatabasePool.snapshot.2")
}
}
func testCustomLabel() throws {
dbConfiguration.label = "Toreador"
let dbPool = try makeDatabasePool()
let snapshot1 = try dbPool.makeSnapshot()
snapshot1.unsafeRead { db in
XCTAssertEqual(db.configuration.label, "Toreador")
XCTAssertEqual(db.description, "Toreador.snapshot.1")
// This test CAN break in future releases: the dispatch queue labels
// are documented to be a debug-only tool.
let label = String(utf8String: __dispatch_queue_get_label(nil))
XCTAssertEqual(label, "Toreador.snapshot.1")
}
let snapshot2 = try dbPool.makeSnapshot()
snapshot2.unsafeRead { db in
XCTAssertEqual(db.configuration.label, "Toreador")
XCTAssertEqual(db.description, "Toreador.snapshot.2")
// This test CAN break in future releases: the dispatch queue labels
// are documented to be a debug-only tool.
let label = String(utf8String: __dispatch_queue_get_label(nil))
XCTAssertEqual(label, "Toreador.snapshot.2")
}
}
// MARK: - Checkpoints
func testAutomaticCheckpointDoesNotInvalidateSnapshot() throws {
let dbPool = try makeDatabasePool()
let counter = try Counter(dbPool: dbPool)
try dbPool.write(counter.increment)
let snapshot = try dbPool.makeSnapshot()
try XCTAssertEqual(snapshot.read(counter.value), 1)
try dbPool.writeWithoutTransaction { db in
// 1000 is enough to trigger automatic snapshot
for _ in 0..<1000 {
try counter.increment(db)
}
}
try XCTAssertEqual(snapshot.read(counter.value), 1)
}
func testPassiveCheckpointDoesNotInvalidateSnapshot() throws {
let dbPool = try makeDatabasePool()
let counter = try Counter(dbPool: dbPool)
try dbPool.write(counter.increment)
let snapshot = try dbPool.makeSnapshot()
try? dbPool.writeWithoutTransaction { _ = try $0.checkpoint(.passive) } // ignore if error or not, that's not the point
try XCTAssertEqual(snapshot.read(counter.value), 1)
try dbPool.write(counter.increment)
try XCTAssertEqual(snapshot.read(counter.value), 1)
}
func testFullCheckpointDoesNotInvalidateSnapshot() throws {
let dbPool = try makeDatabasePool()
let counter = try Counter(dbPool: dbPool)
try dbPool.write(counter.increment)
let snapshot = try dbPool.makeSnapshot()
try? dbPool.writeWithoutTransaction { _ = try $0.checkpoint(.full) } // ignore if error or not, that's not the point
try XCTAssertEqual(snapshot.read(counter.value), 1)
try dbPool.write(counter.increment)
try XCTAssertEqual(snapshot.read(counter.value), 1)
}
func testRestartCheckpointDoesNotInvalidateSnapshot() throws {
let dbPool = try makeDatabasePool()
let counter = try Counter(dbPool: dbPool)
try dbPool.write(counter.increment)
let snapshot = try dbPool.makeSnapshot()
try? dbPool.writeWithoutTransaction { _ = try $0.checkpoint(.restart) } // ignore if error or not, that's not the point
try XCTAssertEqual(snapshot.read(counter.value), 1)
try dbPool.write(counter.increment)
try XCTAssertEqual(snapshot.read(counter.value), 1)
}
func testTruncateCheckpointDoesNotInvalidateSnapshot() throws {
let dbPool = try makeDatabasePool()
let counter = try Counter(dbPool: dbPool)
try dbPool.write(counter.increment)
let snapshot = try dbPool.makeSnapshot()
try? dbPool.writeWithoutTransaction { _ = try $0.checkpoint(.truncate) } // ignore if error or not, that's not the point
try XCTAssertEqual(snapshot.read(counter.value), 1)
try dbPool.write(counter.increment)
try XCTAssertEqual(snapshot.read(counter.value), 1)
}
// MARK: - Schema Cache
func testSnapshotSchemaCache() throws {
let dbPool = try makeDatabasePool()
try dbPool.write { db in
try db.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY)")
}
let snapshot = try dbPool.makeSnapshot()
try snapshot.read { db in
// Schema cache is updated
XCTAssertNil(db.schemaCache[.main].primaryKey("t"))
_ = try db.primaryKey("t")
XCTAssertNotNil(db.schemaCache[.main].primaryKey("t"))
}
snapshot.read { db in
// Schema cache is not cleared between reads
XCTAssertNotNil(db.schemaCache[.main].primaryKey("t"))
}
}
// MARK: - Closing
func testClose() throws {
let snapshot = try makeDatabasePool().makeSnapshot()
try snapshot.close()
// After close, access throws SQLITE_MISUSE
do {
try snapshot.read { db in
try db.execute(sql: "SELECT * FROM sqlite_master")
}
XCTFail("Expected Error")
} catch DatabaseError.SQLITE_MISUSE { }
// After close, closing is a noop
try snapshot.close()
}
func testCloseAfterUse() throws {
let snapshot = try makeDatabasePool().makeSnapshot()
try snapshot.read { db in
try db.execute(sql: "SELECT * FROM sqlite_master")
}
try snapshot.close()
// After close, access throws SQLITE_MISUSE
do {
try snapshot.read { db in
try db.execute(sql: "SELECT * FROM sqlite_master")
}
XCTFail("Expected Error")
} catch DatabaseError.SQLITE_MISUSE { }
// After close, closing is a noop
try snapshot.close()
}
func testCloseWithCachedStatement() throws {
let snapshot = try makeDatabasePool().makeSnapshot()
try snapshot.read { db in
_ = try db.cachedStatement(sql: "SELECT * FROM sqlite_master")
}
try snapshot.close()
// After close, access throws SQLITE_MISUSE
do {
try snapshot.read { db in
try db.execute(sql: "SELECT * FROM sqlite_master")
}
XCTFail("Expected Error")
} catch DatabaseError.SQLITE_MISUSE { }
// After close, closing is a noop
try snapshot.close()
}
func testFailedClose() throws {
let snapshot = try makeDatabasePool().makeSnapshot()
let statement = try snapshot.read { db in
try db.makeStatement(sql: "SELECT * FROM sqlite_master")
}
try withExtendedLifetime(statement) {
do {
try snapshot.close()
XCTFail("Expected Error")
} catch DatabaseError.SQLITE_BUSY { }
}
XCTAssert(lastMessage!.contains("unfinalized statement: SELECT * FROM sqlite_master"))
// Database is not closed: no error
try snapshot.read { db in
try db.execute(sql: "SELECT * FROM sqlite_master")
}
}
}