diff --git a/GRDB/Core/DatabaseRegion.swift b/GRDB/Core/DatabaseRegion.swift index 9c9dd2da60..28590192bc 100644 --- a/GRDB/Core/DatabaseRegion.swift +++ b/GRDB/Core/DatabaseRegion.swift @@ -41,7 +41,7 @@ /// - ``isModified(byEventsOfKind:)`` /// - ``isModified(by:)`` public struct DatabaseRegion: Sendable { - private let tableRegions: [CaseInsensitiveIdentifier: TableRegion]? + private var tableRegions: [CaseInsensitiveIdentifier: TableRegion]? private init(tableRegions: [CaseInsensitiveIdentifier: TableRegion]?) { self.tableRegions = tableRegions @@ -162,6 +162,24 @@ public struct DatabaseRegion: Sendable { self = union(other) } + /// Adds one table-column read reported by the SQLite authorizer. + /// + /// This operation is O(1) amortized, so statement compilation does not + /// merge the accumulated region for each authorizer callback. + /// + /// - parameter table: A table name. + /// - parameter column: A column name, or nil or empty for all columns. + mutating func formUnion(table: String, column: String?) { + guard tableRegions != nil else { return } + + let table = CaseInsensitiveIdentifier(rawValue: table) + let column = column.flatMap { + $0.isEmpty ? nil : CaseInsensitiveIdentifier(rawValue: $0) + } + tableRegions?[table, default: TableRegion(columns: [], rowIds: nil)] + .formUnion(column: column) + } + /// Returns a region suitable for database observation func observableRegion(_ db: Database) throws -> DatabaseRegion { // SQLite does not expose schema changes to the @@ -370,6 +388,17 @@ private struct TableRegion: Equatable { return TableRegion(columns: columnsUnion, rowIds: rowIdsUnion) } + /// Inserts a column read across all rows. A nil column means all + /// columns. + mutating func formUnion(column: CaseInsensitiveIdentifier?) { + rowIds = nil + guard let column else { + columns = nil + return + } + columns?.insert(column) + } + func contains(rowID: Int64) -> Bool { guard let rowIds else { return true diff --git a/GRDB/Core/StatementAuthorizer.swift b/GRDB/Core/StatementAuthorizer.swift index a1e863f3d1..2df625bbdc 100644 --- a/GRDB/Core/StatementAuthorizer.swift +++ b/GRDB/Core/StatementAuthorizer.swift @@ -115,13 +115,7 @@ final class StatementAuthorizer { case SQLITE_READ: guard let tableName = cString1.map(String.init) else { return SQLITE_OK } guard let columnName = cString2.map(String.init) else { return SQLITE_OK } - if columnName.isEmpty { - // SELECT COUNT(*) FROM table - selectedRegion.formUnion(DatabaseRegion(table: tableName)) - } else { - // SELECT column FROM table - selectedRegion.formUnion(DatabaseRegion(table: tableName, columns: [columnName])) - } + selectedRegion.formUnion(table: tableName, column: columnName) return SQLITE_OK case SQLITE_INSERT: diff --git a/Tests/GRDBTests/Private/DatabaseRegionTests.swift b/Tests/GRDBTests/Private/DatabaseRegionTests.swift index fc2cf29d45..4fcf6afb86 100644 --- a/Tests/GRDBTests/Private/DatabaseRegionTests.swift +++ b/Tests/GRDBTests/Private/DatabaseRegionTests.swift @@ -142,6 +142,120 @@ class DatabaseRegionTests : GRDBTestCase { XCTAssertEqual(unions.map(\.description), ["foo(a)[1]", "foo(a,b)[1,2]", "foo(a,b)[1,2]", "foo(b)[2]"]) } + func testRegionFormUnionTableColumn() { + var region = DatabaseRegion() + region.formUnion(table: "foo", column: "name") + + var expectedRegion = DatabaseRegion() + expectedRegion.formUnion(DatabaseRegion(table: "foo", columns: ["name"])) + XCTAssertEqual(region, expectedRegion) + } + + func testRegionFormUnionTableColumnWholeTable() { + do { + var region = DatabaseRegion(table: "foo", columns: ["name"]) + region.formUnion(table: "foo", column: nil) + + var expectedRegion = DatabaseRegion(table: "foo", columns: ["name"]) + expectedRegion.formUnion(DatabaseRegion(table: "foo")) + XCTAssertEqual(region, expectedRegion) + } + do { + var region = DatabaseRegion(table: "foo") + region.formUnion(table: "foo", column: "name") + + var expectedRegion = DatabaseRegion(table: "foo") + expectedRegion.formUnion(DatabaseRegion(table: "foo", columns: ["name"])) + XCTAssertEqual(region, expectedRegion) + } + do { + var region = DatabaseRegion(table: "foo", columns: ["name"]) + region.formUnion(table: "foo", column: "") + + var expectedRegion = DatabaseRegion(table: "foo", columns: ["name"]) + expectedRegion.formUnion(DatabaseRegion(table: "foo")) + XCTAssertEqual(region, expectedRegion) + } + } + + func testRegionFormUnionTableColumnRepeatedColumn() { + var region = DatabaseRegion() + region.formUnion(table: "foo", column: "name") + let expectedRegion = region + region.formUnion(table: "foo", column: "name") + + XCTAssertEqual(region, expectedRegion) + } + + func testRegionFormUnionTableColumnMultipleTables() { + var region = DatabaseRegion() + region.formUnion(table: "foo", column: "name") + region.formUnion(table: "bar", column: "score") + + var expectedRegion = DatabaseRegion() + expectedRegion.formUnion(DatabaseRegion(table: "foo", columns: ["name"])) + expectedRegion.formUnion(DatabaseRegion(table: "bar", columns: ["score"])) + XCTAssertEqual(region, expectedRegion) + } + + func testRegionFormUnionTableColumnCaseInsensitiveTable() { + var region = DatabaseRegion() + region.formUnion(table: "foo", column: "name") + region.formUnion(table: "FOO", column: "SCORE") + + var expectedRegion = DatabaseRegion() + expectedRegion.formUnion(DatabaseRegion(table: "foo", columns: ["name"])) + expectedRegion.formUnion(DatabaseRegion(table: "FOO", columns: ["SCORE"])) + XCTAssertEqual(region, expectedRegion) + } + + func testRegionFormUnionTableColumnAndRowIds() { + var region = DatabaseRegion(table: "foo", columns: ["name"]) + .intersection(DatabaseRegion(table: "foo", rowIds: [1, 2])) + region.formUnion(table: "foo", column: "score") + + var expectedRegion = DatabaseRegion(table: "foo", columns: ["name"]) + .intersection(DatabaseRegion(table: "foo", rowIds: [1, 2])) + expectedRegion.formUnion(DatabaseRegion(table: "foo", columns: ["score"])) + XCTAssertEqual(region, expectedRegion) + XCTAssertEqual(region.description, "foo(name,score)") + } + + func testRegionFormUnionTableColumnFullDatabase() { + var region = DatabaseRegion.fullDatabase + region.formUnion(table: "foo", column: "name") + + XCTAssertEqual(region, .fullDatabase) + } + + func testRegionFormUnionTableColumnSequenceEquivalence() { + let eventSequences: [[(table: String, column: String?)]] = [ + [], + [("foo", "name")], + [("foo", "name"), ("foo", "name"), ("foo", "score")], + [("foo", "name"), ("bar", "score"), ("FOO", "email")], + [("foo", "name"), ("foo", nil), ("foo", "score")], + [("foo", nil), ("foo", "name")], + [("foo", "name"), ("foo", ""), ("bar", "score")], + ] + + for events in eventSequences { + var region = DatabaseRegion() + var expectedRegion = DatabaseRegion() + for event in events { + region.formUnion(table: event.table, column: event.column) + if let column = event.column, !column.isEmpty { + expectedRegion.formUnion(DatabaseRegion( + table: event.table, + columns: [column])) + } else { + expectedRegion.formUnion(DatabaseRegion(table: event.table)) + } + } + XCTAssertEqual(region, expectedRegion) + } + } + func testRegionIntersection() { let regions = [ DatabaseRegion.fullDatabase, diff --git a/Tests/GRDBTests/Private/StatementPreparationPerformanceTests.swift b/Tests/GRDBTests/Private/StatementPreparationPerformanceTests.swift new file mode 100644 index 0000000000..ef2a2e88d1 --- /dev/null +++ b/Tests/GRDBTests/Private/StatementPreparationPerformanceTests.swift @@ -0,0 +1,101 @@ +import Dispatch +import XCTest +@testable import GRDB + +/// Measures statement preparation performance in optimized builds. +/// +/// Run with: +/// +/// ```sh +/// swift test -c release --filter StatementPreparationPerformanceTests +/// ``` +class StatementPreparationPerformanceTests: XCTestCase { + private let iterationCount = 1_000 + private let sampleCount = 5 + + func testStatementPreparationPerformance() throws { +#if DEBUG + throw XCTSkip( + "Run with: swift test -c release --filter StatementPreparationPerformanceTests") +#else + let dbQueue = try DatabaseQueue() + try dbQueue.write { db in + try db.execute(sql: createTableSQL( + table: "wide", + columns: ["id INTEGER PRIMARY KEY"] + integerColumns(count: 119))) + try db.execute(sql: createTableSQL( + table: "author", + columns: ["id INTEGER PRIMARY KEY"] + integerColumns(count: 39))) + try db.execute(sql: createTableSQL( + table: "book", + columns: ["id INTEGER PRIMARY KEY", "authorId INTEGER"] + + integerColumns(count: 38))) + try db.execute(sql: createTableSQL( + table: "review", + columns: ["id INTEGER PRIMARY KEY", "bookId INTEGER"] + + integerColumns(count: 38))) + + let benchmarks = [ + ("wide", "SELECT * FROM wide"), + ("join", """ + SELECT author.*, book.*, review.* + FROM author + JOIN book ON book.authorId = author.id + JOIN review ON review.bookId = book.id + """), + ("narrow", "SELECT id FROM wide"), + ] + + let sqliteVersion = try String.fetchOne( + db, + sql: "SELECT sqlite_version()")! + print("Statement preparation benchmark") + print("SQLite: \(sqliteVersion)") + print("Iterations: \(iterationCount); samples: \(sampleCount)") + + for (name, sql) in benchmarks { + let result = try benchmark(db, sql: sql) + print(String( + format: "%@: %.3f ms total, %.3f us/prepare", + name, + result.totalMilliseconds, + result.microsecondsPerPrepare)) + } + } +#endif + } + + private func benchmark( + _ db: Database, + sql: String) + throws -> (totalMilliseconds: Double, microsecondsPerPrepare: Double) { + for _ in 0..<100 { + _ = try db.makeStatement(sql: sql) + } + + var samples: [UInt64] = [] + samples.reserveCapacity(sampleCount) + for _ in 0.. String { + "CREATE TABLE \(table) (\(columns.joined(separator: ", ")))" + } + + private func integerColumns(count: Int) -> [String] { + (1...count).map { "column\($0) INTEGER" } + } +}