472 lines
17 KiB
Swift
472 lines
17 KiB
Swift
/// An observable region of the database.
|
||
///
|
||
/// A `DatabaseRegion` is the union of any number of "table regions", which can
|
||
/// cover a full table, or the combination of columns and rows identified by
|
||
/// their rowids:
|
||
///
|
||
/// |Table1 | |Table2 | |Table3 | |Table4 | |Table5 |
|
||
/// |-------| |-------| |-------| |-------| |-------|
|
||
/// |x|x|x|x| |x| | | | |x|x|x|x| |x|x| |x| | | | | |
|
||
/// |x|x|x|x| |x| | | | | | | | | | | | | | | |x| | |
|
||
/// |x|x|x|x| |x| | | | | | | | | |x|x| |x| | | | | |
|
||
/// |x|x|x|x| |x| | | | | | | | | | | | | | | | | | |
|
||
///
|
||
/// It is dedicated to help ``TransactionObserver`` types detect impactful
|
||
/// database changes.
|
||
///
|
||
/// You get `DatabaseRegion` instances from a ``DatabaseRegionConvertible``
|
||
/// value, a prepared ``Statement``, or from the initializers described below.
|
||
///
|
||
/// ## Topics
|
||
///
|
||
/// ### Creating Regions
|
||
///
|
||
/// - ``fullDatabase-3ir3p``
|
||
/// - ``init()``
|
||
///
|
||
/// ### Instance Properties
|
||
///
|
||
/// - ``isEmpty``
|
||
/// - ``isFullDatabase``
|
||
///
|
||
/// ### Combining Regions
|
||
///
|
||
/// - ``formUnion(_:)``
|
||
/// - ``union(_:)``
|
||
///
|
||
/// ### Detecting Database Changes
|
||
///
|
||
/// Use those methods from ``TransactionObserver`` methods.
|
||
///
|
||
/// - ``isModified(byEventsOfKind:)``
|
||
/// - ``isModified(by:)``
|
||
public struct DatabaseRegion: Sendable {
|
||
private let tableRegions: [CaseInsensitiveIdentifier: TableRegion]?
|
||
|
||
private init(tableRegions: [CaseInsensitiveIdentifier: TableRegion]?) {
|
||
self.tableRegions = tableRegions
|
||
}
|
||
|
||
/// Returns whether the region is empty.
|
||
public var isEmpty: Bool {
|
||
guard let tableRegions else {
|
||
// full database
|
||
return false
|
||
}
|
||
return tableRegions.isEmpty
|
||
}
|
||
|
||
/// Returns whether the region covers the full database.
|
||
public var isFullDatabase: Bool {
|
||
tableRegions == nil
|
||
}
|
||
|
||
/// The region that covers the full database.
|
||
public static let fullDatabase = DatabaseRegion(tableRegions: nil)
|
||
|
||
/// The empty database region.
|
||
public init() {
|
||
self.init(tableRegions: [:])
|
||
}
|
||
|
||
/// Creates a region that spans all rows and columns of a database table.
|
||
///
|
||
/// - parameter table: A table name.
|
||
init(table: String) {
|
||
let table = CaseInsensitiveIdentifier(rawValue: table)
|
||
self.init(tableRegions: [table: TableRegion(columns: nil, rowIds: nil)])
|
||
}
|
||
|
||
/// Full columns in a table: (some columns in a table) × (all rows)
|
||
init(table: String, columns: Set<String>) {
|
||
let table = CaseInsensitiveIdentifier(rawValue: table)
|
||
let columns = Set(columns.map(CaseInsensitiveIdentifier.init))
|
||
self.init(tableRegions: [table: TableRegion(columns: columns, rowIds: nil)])
|
||
}
|
||
|
||
/// Full rows in a table: (all columns in a table) × (some rows)
|
||
init(table: String, rowIds: Set<Int64>) {
|
||
let table = CaseInsensitiveIdentifier(rawValue: table)
|
||
self.init(tableRegions: [table: TableRegion(columns: nil, rowIds: rowIds)])
|
||
}
|
||
|
||
/// Returns the intersection of this region and the given one.
|
||
///
|
||
/// This method is not public because there is no known public use case for
|
||
/// this intersection. It is currently only used as support for
|
||
/// the isModified(byEventsOfKind:) method.
|
||
func intersection(_ other: DatabaseRegion) -> DatabaseRegion {
|
||
guard let tableRegions else { return other }
|
||
guard let otherTableRegions = other.tableRegions else { return self }
|
||
|
||
var tableRegionsIntersection: [CaseInsensitiveIdentifier: TableRegion] = [:]
|
||
for (table, tableRegion) in tableRegions {
|
||
guard let otherTableRegion = otherTableRegions
|
||
.first(where: { (otherTable, _) in otherTable == table })?
|
||
.value else { continue }
|
||
let tableRegionIntersection = tableRegion.intersection(otherTableRegion)
|
||
guard !tableRegionIntersection.isEmpty else { continue }
|
||
tableRegionsIntersection[table] = tableRegionIntersection
|
||
}
|
||
|
||
return DatabaseRegion(tableRegions: tableRegionsIntersection)
|
||
}
|
||
|
||
/// Only keeps those rowIds in the given table
|
||
func tableIntersection(_ table: String, rowIds: Set<Int64>) -> DatabaseRegion {
|
||
guard var tableRegions else {
|
||
return DatabaseRegion(table: table, rowIds: rowIds)
|
||
}
|
||
|
||
let table = CaseInsensitiveIdentifier(rawValue: table)
|
||
guard let tableRegion = tableRegions[table] else {
|
||
return self
|
||
}
|
||
|
||
let intersection = tableRegion.intersection(TableRegion(columns: nil, rowIds: rowIds))
|
||
if intersection.isEmpty {
|
||
tableRegions.removeValue(forKey: table)
|
||
} else {
|
||
tableRegions[table] = intersection
|
||
}
|
||
return DatabaseRegion(tableRegions: tableRegions)
|
||
}
|
||
|
||
/// Returns the union of this region and the given one.
|
||
public func union(_ other: DatabaseRegion) -> DatabaseRegion {
|
||
guard let tableRegions else { return .fullDatabase }
|
||
guard let otherTableRegions = other.tableRegions else { return .fullDatabase }
|
||
|
||
var tableRegionsUnion: [CaseInsensitiveIdentifier: TableRegion] = [:]
|
||
let tableNames = Set(tableRegions.keys).union(Set(otherTableRegions.keys))
|
||
for table in tableNames {
|
||
let tableRegion = tableRegions[table]
|
||
let otherTableRegion = otherTableRegions[table]
|
||
let tableRegionUnion: TableRegion
|
||
switch (tableRegion, otherTableRegion) {
|
||
case (nil, nil):
|
||
preconditionFailure()
|
||
case let (nil, tableRegion?), let (tableRegion?, nil):
|
||
tableRegionUnion = tableRegion
|
||
case let (tableRegion?, otherTableRegion?):
|
||
tableRegionUnion = tableRegion.union(otherTableRegion)
|
||
}
|
||
tableRegionsUnion[table] = tableRegionUnion
|
||
}
|
||
|
||
return DatabaseRegion(tableRegions: tableRegionsUnion)
|
||
}
|
||
|
||
/// Inserts the given region into this region
|
||
public mutating func formUnion(_ other: DatabaseRegion) {
|
||
self = union(other)
|
||
}
|
||
|
||
/// Returns a region suitable for database observation
|
||
func observableRegion(_ db: Database) throws -> DatabaseRegion {
|
||
// SQLite does not expose schema changes to the
|
||
// TransactionObserver protocol. By removing internal SQLite tables from
|
||
// the observed region, we optimize database observation.
|
||
//
|
||
// And by canonicalizing table names, we remove views, and help the
|
||
// `isModified` methods. (TODO: is this comment still accurate?
|
||
// Isn't it about providing TransactionObserver.observes() with
|
||
// real tables names, instead?)
|
||
try ignoringInternalSQLiteTables().canonicalTables(db)
|
||
}
|
||
|
||
/// Returns a region only made of actual tables with their canonical names.
|
||
///
|
||
/// This method removes views.
|
||
func canonicalTables(_ db: Database) throws -> DatabaseRegion {
|
||
guard let tableRegions else { return .fullDatabase }
|
||
var region = DatabaseRegion()
|
||
for (table, tableRegion) in tableRegions {
|
||
if let canonicalTableName = try db.canonicalTableName(table.rawValue) {
|
||
let table = CaseInsensitiveIdentifier(rawValue: canonicalTableName)
|
||
region.formUnion(DatabaseRegion(tableRegions: [table: tableRegion]))
|
||
}
|
||
}
|
||
return region
|
||
}
|
||
|
||
/// Returns a region which doesn't contain any SQLite internal table.
|
||
private func ignoringInternalSQLiteTables() -> DatabaseRegion {
|
||
guard let tableRegions else { return .fullDatabase }
|
||
let filteredRegions = tableRegions.filter {
|
||
!Database.isSQLiteInternalTable($0.key.rawValue)
|
||
}
|
||
return DatabaseRegion(tableRegions: filteredRegions)
|
||
}
|
||
}
|
||
|
||
extension DatabaseRegion {
|
||
|
||
// MARK: - Database Events
|
||
|
||
/// Returns whether the content in the region would be impacted if the
|
||
/// database were modified by an event of this kind.
|
||
public func isModified(byEventsOfKind eventKind: DatabaseEventKind) -> Bool {
|
||
intersection(eventKind.modifiedRegion).isEmpty == false
|
||
}
|
||
|
||
/// Returns whether the content in the region is impacted by this event.
|
||
///
|
||
/// - precondition: event has been filtered by the same region
|
||
/// in the TransactionObserver.observes(eventsOfKind:) method, by calling
|
||
/// region.isModified(byEventsOfKind:)
|
||
public func isModified(by event: DatabaseEvent) -> Bool {
|
||
guard let tableRegions else {
|
||
// Full database: all changes are impactful
|
||
return true
|
||
}
|
||
|
||
guard let tableRegion = tableRegions[CaseInsensitiveIdentifier(rawValue: event.tableName)] else {
|
||
// FTS4 (and maybe other virtual tables) perform unadvertised
|
||
// changes. For example, an "INSERT INTO document ..." statement
|
||
// advertises an insertion in the `document` table, but the
|
||
// actual change events happen in the `document_content` shadow
|
||
// table. When such a non-advertised event happens, assume that
|
||
// the region is modified.
|
||
// See https://github.com/groue/GRDB.swift/issues/620
|
||
return true
|
||
}
|
||
return tableRegion.contains(rowID: event.rowID)
|
||
}
|
||
|
||
/// Returns an array of all event kinds that can impact this region.
|
||
///
|
||
/// - precondition: the region is canonical.
|
||
func impactfulEventKinds(_ db: Database) throws -> [DatabaseEventKind] {
|
||
if let tableRegions {
|
||
return try tableRegions.flatMap { (table, tableRegion) -> [DatabaseEventKind] in
|
||
let tableName = table.rawValue // canonical table name
|
||
let columnNames: Set<String>
|
||
if let columns = tableRegion.columns {
|
||
columnNames = Set(columns.map(\.rawValue))
|
||
} else {
|
||
columnNames = try Set(db.columns(in: tableName).map(\.name))
|
||
}
|
||
|
||
return [
|
||
DatabaseEventKind.delete(tableName: tableName),
|
||
DatabaseEventKind.insert(tableName: tableName),
|
||
DatabaseEventKind.update(tableName: tableName, columnNames: columnNames),
|
||
]
|
||
}
|
||
} else {
|
||
// full database
|
||
return try db.schemaIdentifiers().flatMap { schemaIdentifier in
|
||
let schema = try db.schema(schemaIdentifier)
|
||
return try schema.objects
|
||
.filter { $0.type == .table }
|
||
.flatMap { table in
|
||
let columnNames = try Set(db.columns(in: table.name).map(\.name))
|
||
return [
|
||
DatabaseEventKind.delete(tableName: table.name),
|
||
DatabaseEventKind.insert(tableName: table.name),
|
||
DatabaseEventKind.update(tableName: table.name, columnNames: columnNames),
|
||
]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
extension DatabaseRegion: Equatable {
|
||
public static func == (lhs: DatabaseRegion, rhs: DatabaseRegion) -> Bool {
|
||
switch (lhs.tableRegions, rhs.tableRegions) {
|
||
case (nil, nil):
|
||
return true
|
||
case let (ltableRegions?, rtableRegions?):
|
||
let ltableNames = Set(ltableRegions.keys)
|
||
let rtableNames = Set(rtableRegions.keys)
|
||
guard ltableNames == rtableNames else {
|
||
return false
|
||
}
|
||
for tableName in ltableNames where ltableRegions[tableName]! != rtableRegions[tableName]! {
|
||
return false
|
||
}
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
|
||
extension DatabaseRegion: CustomStringConvertible {
|
||
public var description: String {
|
||
guard let tableRegions else {
|
||
return "full database"
|
||
}
|
||
if tableRegions.isEmpty {
|
||
return "empty"
|
||
}
|
||
return tableRegions
|
||
.sorted(by: { (l, r) in l.key.rawValue < r.key.rawValue })
|
||
.map { (table, tableRegion) in
|
||
var desc = table.rawValue
|
||
if let columns = tableRegion.columns {
|
||
desc += "(" + columns.map(\.rawValue).sorted().joined(separator: ",") + ")"
|
||
} else {
|
||
desc += "(*)"
|
||
}
|
||
if let rowIds = tableRegion.rowIds {
|
||
desc += "[" + rowIds.sorted().map { "\($0)" }.joined(separator: ",") + "]"
|
||
}
|
||
return desc
|
||
}
|
||
.joined(separator: ",")
|
||
}
|
||
}
|
||
|
||
private struct TableRegion: Equatable {
|
||
var columns: Set<CaseInsensitiveIdentifier>? // nil means "all columns"
|
||
var rowIds: Set<Int64>? // nil means "all rowids"
|
||
|
||
var isEmpty: Bool {
|
||
if let columns, columns.isEmpty { return true }
|
||
if let rowIds, rowIds.isEmpty { return true }
|
||
return false
|
||
}
|
||
|
||
func intersection(_ other: TableRegion) -> TableRegion {
|
||
let columnsIntersection: Set<CaseInsensitiveIdentifier>?
|
||
switch (self.columns, other.columns) {
|
||
case let (nil, columns), let (columns, nil):
|
||
columnsIntersection = columns
|
||
case let (columns?, other?):
|
||
columnsIntersection = columns.intersection(other)
|
||
}
|
||
|
||
let rowIdsIntersection: Set<Int64>?
|
||
switch (self.rowIds, other.rowIds) {
|
||
case let (nil, rowIds), let (rowIds, nil):
|
||
rowIdsIntersection = rowIds
|
||
case let (rowIds?, other?):
|
||
rowIdsIntersection = rowIds.intersection(other)
|
||
}
|
||
|
||
return TableRegion(columns: columnsIntersection, rowIds: rowIdsIntersection)
|
||
}
|
||
|
||
func union(_ other: TableRegion) -> TableRegion {
|
||
let columnsUnion: Set<CaseInsensitiveIdentifier>?
|
||
switch (self.columns, other.columns) {
|
||
case (nil, _), (_, nil):
|
||
columnsUnion = nil
|
||
case let (columns?, other?):
|
||
columnsUnion = columns.union(other)
|
||
}
|
||
|
||
let rowIdsUnion: Set<Int64>?
|
||
switch (self.rowIds, other.rowIds) {
|
||
case (nil, _), (_, nil):
|
||
rowIdsUnion = nil
|
||
case let (rowIds?, other?):
|
||
rowIdsUnion = rowIds.union(other)
|
||
}
|
||
|
||
return TableRegion(columns: columnsUnion, rowIds: rowIdsUnion)
|
||
}
|
||
|
||
func contains(rowID: Int64) -> Bool {
|
||
guard let rowIds else {
|
||
return true
|
||
}
|
||
return rowIds.contains(rowID)
|
||
}
|
||
}
|
||
|
||
// MARK: - DatabaseRegionConvertible
|
||
|
||
/// A type that operates on a specific ``DatabaseRegion``.
|
||
///
|
||
/// A `DatabaseRegionConvertible` instance feeds database observation tools such
|
||
/// as ``DatabaseRegionObservation`` and ``ValueObservation``:
|
||
///
|
||
/// ```swift
|
||
/// // An observation triggered by all changes to the database
|
||
/// DatabaseRegionObservation(tracking: .fullDatabase)
|
||
///
|
||
/// // An observation triggered by all changes to the 'player' table
|
||
/// DatabaseRegionObservation(tracking: Table("player"))
|
||
///
|
||
/// // An observation triggered by all changes to the row
|
||
/// // with rowid 1 in the 'player' table
|
||
/// DatabaseRegionObservation(tracking: Player.filter(id: 1))
|
||
///
|
||
/// // An observation triggered by all changes to the 'score' column
|
||
/// // of the 'player' table
|
||
/// DatabaseRegionObservation(tracking: SQLRequest("SELECT score FROM player"))
|
||
/// ```
|
||
///
|
||
/// Specifying a region from a ``FetchRequest`` does not execute the request.
|
||
/// In the above example, `Player.filter(id: 1)` and `SELECT score FROM player`
|
||
/// are only compiled by SQLite, so that GRDB can understand the tables, rows,
|
||
/// and columns that constitute the database region.
|
||
///
|
||
/// ## Topics
|
||
///
|
||
/// ### Creating a DatabaseRegion
|
||
///
|
||
/// - ``fullDatabase``
|
||
/// - ``databaseRegion(_:)``
|
||
///
|
||
/// ### Supporting Types
|
||
///
|
||
/// - ``AnyDatabaseRegionConvertible``
|
||
public protocol DatabaseRegionConvertible {
|
||
/// Returns a database region.
|
||
///
|
||
/// - parameter db: A database connection.
|
||
func databaseRegion(_ db: Database) throws -> DatabaseRegion
|
||
}
|
||
|
||
extension DatabaseRegionConvertible where Self == DatabaseRegion {
|
||
/// The region that covers the full database: all columns and all rows
|
||
/// from all tables.
|
||
public static var fullDatabase: Self { DatabaseRegion.fullDatabase }
|
||
}
|
||
|
||
extension DatabaseRegion: DatabaseRegionConvertible {
|
||
public func databaseRegion(_ db: Database) throws -> DatabaseRegion {
|
||
self
|
||
}
|
||
}
|
||
|
||
/// A type-erased DatabaseRegionConvertible
|
||
public struct AnyDatabaseRegionConvertible: DatabaseRegionConvertible {
|
||
let _region: (Database) throws -> DatabaseRegion
|
||
|
||
public init(_ region: @escaping (Database) throws -> DatabaseRegion) {
|
||
_region = region
|
||
}
|
||
|
||
public init(_ region: some DatabaseRegionConvertible) {
|
||
_region = region.databaseRegion
|
||
}
|
||
|
||
public func databaseRegion(_ db: Database) throws -> DatabaseRegion {
|
||
try _region(db)
|
||
}
|
||
}
|
||
|
||
// MARK: - Utils
|
||
|
||
extension DatabaseRegion {
|
||
static func union(_ regions: DatabaseRegion...) -> DatabaseRegion {
|
||
regions.reduce(into: DatabaseRegion()) { union, region in
|
||
union.formUnion(region)
|
||
}
|
||
}
|
||
|
||
static func union(_ regions: [any DatabaseRegionConvertible]) -> (Database) throws -> DatabaseRegion {
|
||
return { db in
|
||
try regions.reduce(into: DatabaseRegion()) { union, region in
|
||
try union.formUnion(region.databaseRegion(db))
|
||
}
|
||
}
|
||
}
|
||
}
|