Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion GRDB/Core/DatabaseRegion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 1 addition & 7 deletions GRDB/Core/StatementAuthorizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
114 changes: 114 additions & 0 deletions Tests/GRDBTests/Private/DatabaseRegionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
101 changes: 101 additions & 0 deletions Tests/GRDBTests/Private/StatementPreparationPerformanceTests.swift
Original file line number Diff line number Diff line change
@@ -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..<sampleCount {
let start = DispatchTime.now().uptimeNanoseconds
for _ in 0..<iterationCount {
_ = try db.makeStatement(sql: sql)
}
samples.append(DispatchTime.now().uptimeNanoseconds - start)
}

let medianNanoseconds = samples.sorted()[sampleCount / 2]
let totalMilliseconds = Double(medianNanoseconds) / 1_000_000
let microsecondsPerPrepare = Double(medianNanoseconds)
/ Double(iterationCount)
/ 1_000
return (totalMilliseconds, microsecondsPerPrepare)
}

private func createTableSQL(table: String, columns: [String]) -> String {
"CREATE TABLE \(table) (\(columns.joined(separator: ", ")))"
}

private func integerColumns(count: Int) -> [String] {
(1...count).map { "column\($0) INTEGER" }
}
}