From f7156b94903786cdd27b812df585c466ab19cf3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Wed, 1 Nov 2023 18:47:11 +0100 Subject: [PATCH 01/28] Custom SQLite 3.44.0 --- Documentation/CustomSQLiteBuilds.md | 2 +- SQLiteCustom/src | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Documentation/CustomSQLiteBuilds.md b/Documentation/CustomSQLiteBuilds.md index 2aa955f5ba..c49092d476 100644 --- a/Documentation/CustomSQLiteBuilds.md +++ b/Documentation/CustomSQLiteBuilds.md @@ -3,7 +3,7 @@ Custom SQLite Builds By default, GRDB uses the version of SQLite that ships with the target operating system. -**You can build GRDB with a custom build of [SQLite 3.42.0](https://www.sqlite.org/changes.html).** +**You can build GRDB with a custom build of [SQLite 3.44.0](https://www.sqlite.org/changes.html).** A custom SQLite build can activate extra SQLite features, and extra GRDB features as well, such as support for the [FTS5 full-text search engine](../../../#full-text-search), and [SQLite Pre-Update Hooks](https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/transactionobserver). diff --git a/SQLiteCustom/src b/SQLiteCustom/src index 59f4dc65f0..31e6aa6618 160000 --- a/SQLiteCustom/src +++ b/SQLiteCustom/src @@ -1 +1 @@ -Subproject commit 59f4dc65f053482934ad184a88f2394a7dfc2f7b +Subproject commit 31e6aa66188e59616f062df77329d6ee9ee45929 From 2603b6ea0314b4cd57bac21b81278e0a32732890 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Wed, 1 Nov 2023 19:16:50 +0100 Subject: [PATCH 02/28] Support for ORDER BY and FILTER in aggregate functions --- GRDB/Core/DatabaseFunction.swift | 32 +- GRDB/JSON/SQLJSONFunctions.swift | 48 ++- GRDB/QueryInterface/SQL/SQLExpression.swift | 406 +++++++++++------- GRDB/QueryInterface/SQL/SQLFunctions.swift | 207 ++++++++- Tests/GRDBTests/JSONExpressionsTests.swift | 93 ++++ .../QueryInterfaceExpressionsTests.swift | 157 +++++++ 6 files changed, 762 insertions(+), 181 deletions(-) diff --git a/GRDB/Core/DatabaseFunction.swift b/GRDB/Core/DatabaseFunction.swift index e74b22b730..4b13c39c27 100644 --- a/GRDB/Core/DatabaseFunction.swift +++ b/GRDB/Core/DatabaseFunction.swift @@ -34,19 +34,6 @@ public final class DatabaseFunction: Hashable { private let kind: Kind private var eTextRep: CInt { (SQLITE_UTF8 | (isPure ? SQLITE_DETERMINISTIC : 0)) } - var functionFlags: SQLFunctionFlags { - var flags = SQLFunctionFlags(isPure: isPure) - - switch kind { - case .function: - break - case .aggregate: - flags.isAggregate = true - } - - return flags - } - /// Creates an SQL function. /// /// For example: @@ -183,9 +170,24 @@ public final class DatabaseFunction: Hashable { /// } /// ``` public func callAsFunction(_ arguments: any SQLExpressible...) -> SQLExpression { - .function(name, arguments.map(\.sqlExpression), flags: functionFlags) + switch kind { + case .function: + return .simpleFunction( + name, + arguments.map(\.sqlExpression), + isPure: isPure, + isJSONValue: false) + case .aggregate: + return .aggregateFunction( + name, + arguments.map(\.sqlExpression), + isDistinct: false, + ordering: nil, + filter: nil, + isJSONValue: false) + } } - + /// Calls sqlite3_create_function_v2 /// See func install(in db: Database) { diff --git a/GRDB/JSON/SQLJSONFunctions.swift b/GRDB/JSON/SQLJSONFunctions.swift index 41c87c9ba3..2fd86aae22 100644 --- a/GRDB/JSON/SQLJSONFunctions.swift +++ b/GRDB/JSON/SQLJSONFunctions.swift @@ -374,15 +374,32 @@ extension Database { /// The `JSON_GROUP_ARRAY` SQL function. /// /// Related SQLite documentation: - public static func jsonGroupArray(_ value: some SQLExpressible) -> SQLExpression { - .function("JSON_GROUP_ARRAY", [value.sqlExpression.jsonBuilderExpression]) + public static func jsonGroupArray( + _ value: some SQLExpressible, + orderBy ordering: (any SQLOrderingTerm)? = nil, + filter: (any SQLSpecificExpressible)? = nil) + -> SQLExpression { + .aggregateFunction( + "JSON_GROUP_ARRAY", + [value.sqlExpression.jsonBuilderExpression], + ordering: ordering?.sqlOrdering, + filter: filter?.sqlExpression, + isJSONValue: true) } /// The `JSON_GROUP_OBJECT` SQL function. /// /// Related SQLite documentation: - public static func jsonGroupObject(key: some SQLExpressible, value: some SQLExpressible) -> SQLExpression { - .function("JSON_GROUP_OBJECT", [key.sqlExpression, value.sqlExpression.jsonBuilderExpression]) + public static func jsonGroupObject( + key: some SQLExpressible, + value: some SQLExpressible, + filter: (any SQLSpecificExpressible)? = nil + ) -> SQLExpression { + .aggregateFunction( + "JSON_GROUP_OBJECT", + [key.sqlExpression, value.sqlExpression.jsonBuilderExpression], + filter: filter?.sqlExpression, + isJSONValue: true) } } #else @@ -781,16 +798,31 @@ extension Database { /// /// Related SQLite documentation: @available(iOS 16, macOS 10.15, tvOS 17, watchOS 9, *) // SQLite 3.38+ with exceptions for macOS - public static func jsonGroupArray(_ value: some SQLExpressible) -> SQLExpression { - .function("JSON_GROUP_ARRAY", [value.sqlExpression.jsonBuilderExpression]) + public static func jsonGroupArray( + _ value: some SQLExpressible, + filter: (any SQLSpecificExpressible)? = nil) + -> SQLExpression { + .aggregateFunction( + "JSON_GROUP_ARRAY", + [value.sqlExpression.jsonBuilderExpression], + filter: filter?.sqlExpression, + isJSONValue: true) } /// The `JSON_GROUP_OBJECT` SQL function. /// /// Related SQLite documentation: @available(iOS 16, macOS 10.15, tvOS 17, watchOS 9, *) // SQLite 3.38+ with exceptions for macOS - public static func jsonGroupObject(key: some SQLExpressible, value: some SQLExpressible) -> SQLExpression { - .function("JSON_GROUP_OBJECT", [key.sqlExpression, value.sqlExpression.jsonBuilderExpression]) + public static func jsonGroupObject( + key: some SQLExpressible, + value: some SQLExpressible, + filter: (any SQLSpecificExpressible)? = nil + ) -> SQLExpression { + .aggregateFunction( + "JSON_GROUP_OBJECT", + [key.sqlExpression, value.sqlExpression.jsonBuilderExpression], + filter: filter?.sqlExpression, + isJSONValue: true) } } #endif diff --git a/GRDB/QueryInterface/SQL/SQLExpression.swift b/GRDB/QueryInterface/SQL/SQLExpression.swift index 781f091fe4..e7579b4735 100644 --- a/GRDB/QueryInterface/SQL/SQLExpression.swift +++ b/GRDB/QueryInterface/SQL/SQLExpression.swift @@ -165,8 +165,14 @@ public struct SQLExpression { /// /// (, ...) /// (DISTINCT ) - case function(String, flags: SQLFunctionFlags, arguments: [SQLExpression]) + indirect case simpleFunction(SQLSimpleFunctionInvocation) + /// An aggregate function call. + /// + /// (, ...) + /// (DISTINCT ) + indirect case aggregateFunction(SQLAggregateFunctionInvocation) + /// An expression that checks for zero or positive values. /// /// = 0 @@ -268,11 +274,11 @@ public struct SQLExpression { case .countAll: return .countAll - case let .function(name, flags: flags, arguments: arguments): - return .function( - name, - flags: flags, - arguments: arguments.map { $0.qualified(with: alias) }) + case let .simpleFunction(invocation): + return .simpleFunction(invocation.qualified(with: alias)) + + case let .aggregateFunction(invocation): + return .aggregateFunction(invocation.qualified(with: alias)) case let .isEmpty(expression, isNegated: isNegated): return .isEmpty(expression.qualified(with: alias), isNegated: isNegated) @@ -916,9 +922,154 @@ extension SQLExpression { // MARK: Functions + // TODO: add missing pure functions: + // https://www.sqlite.org/lang_aggfunc.html + // https://www.sqlite.org/lang_datefunc.html + // https://www.sqlite.org/lang_mathfunc.html + private static let knownPureFunctions: Set = [ + "ABS", + "CHAR", + "COALESCE", + "GLOB", + "HEX", + "IFNULL", + "IIF", + "INSTR", + "JSON", + "JSON_ARRAY", + "JSON_GROUP_ARRAY", + "JSON_GROUP_OBJECT", + "JSON_INSERT", + "JSON_OBJECT", + "JSON_PATCH", + "JSON_REMOVE", + "JSON_REPLACE", + "JSON_SET", + "JSON_QUOTE", + "LENGTH", + "LIKE", + "LIKELIHOOD", + "LIKELY", + "LOAD_EXTENSION", + "LOWER", + "LTRIM", + "NULLIF", + "PRINTF", + "QUOTE", + "REPLACE", + "ROUND", + "RTRIM", + "SOUNDEX", + "SQLITE_COMPILEOPTION_GET", + "SQLITE_COMPILEOPTION_USED", + "SQLITE_SOURCE_ID", + "SQLITE_VERSION", + "SUBSTR", + "TRIM", + "TYPEOF", + "UNICODE", + "UNLIKELY", + "UPPER", + "ZEROBLOB", + ] + + private static let knownAggregateFunctions: Set = [ + "AVG", + "COUNT", + "GROUP_CONCAT", + "JSON_GROUP_ARRAY", + "JSON_GROUP_OBJECT", + "MAX", // when single argument + "MIN", // when single argument + "SUM", + "TOTAL", + ] + + private static let knownFunctionsReturningJSONValue: Set = [ + "JSON", + "JSON_ARRAY", + "JSON_GROUP_ARRAY", + "JSON_GROUP_OBJECT", + "JSON_INSERT", + "JSON_OBJECT", + "JSON_PATCH", + "JSON_REMOVE", + "JSON_REPLACE", + "JSON_SET", + "JSON_QUOTE", + ] + /// The `COUNT(*)` expression. static let countAll = SQLExpression(impl: .countAll) + /// A function call. + static func function(_ functionName: String, _ arguments: [SQLExpression]) -> Self { + let name = functionName.uppercased() + + if (name == "MAX" || name == "MIN") && arguments.count > 1 { + return .simpleFunction( + functionName, + arguments, + isPure: true, + isJSONValue: false) + + } else if Self.knownAggregateFunctions.contains(name) { + return .aggregateFunction( + functionName, + arguments, + isJSONValue: Self.knownFunctionsReturningJSONValue.contains(name)) + + } else { + let isJSONValue: Bool + if name == "JSON_EXTRACT" && arguments.count > 2 { + isJSONValue = true + } else { + isJSONValue = Self.knownFunctionsReturningJSONValue.contains(name) + } + + return .simpleFunction( + functionName, arguments, + isPure: Self.knownPureFunctions.contains(name), + isJSONValue: isJSONValue) + } + } + + /// A simple function call. + /// + /// - warning: Don't use this method for aggregate functions! + static func simpleFunction( + _ name: String, + _ arguments: [SQLExpression], + isPure: Bool = false, + isJSONValue: Bool = false) + -> Self + { + .init(impl: .simpleFunction(SQLSimpleFunctionInvocation( + name: name, + arguments: arguments, + isPure: isPure, + isJSONValue: isJSONValue))) + } + + /// An aggregate function call. + static func aggregateFunction( + _ name: String, + _ arguments: [SQLExpression], + isDistinct: Bool = false, + ordering: SQLOrdering? = nil, + filter: SQLExpression? = nil, + isJSONValue: Bool = false) + -> Self + { + .init(impl: .aggregateFunction(.init( + name: name, + arguments: arguments, + isDistinct: isDistinct, + ordering: ordering, + filter: filter, + isJSONValue: isJSONValue))) + } + /// The `COUNT` function. /// /// COUNT() @@ -930,24 +1081,7 @@ extension SQLExpression { /// /// COUNT(DISTINCT ) static func countDistinct(_ expression: SQLExpression) -> Self { - function("COUNT", [expression], flags: .init(isAggregate: true, isDistinct: true)) - } - - /// A function call. - /// - /// (, ...) - static func function(_ name: String, _ arguments: [SQLExpression]) -> Self { - .init(impl: .function( - name, - flags: .defaultFlags(for: name, argumentCount: arguments.count), - arguments: arguments)) - } - - /// A function call with explicit flags. - /// - /// (, ...) - static func function(_ name: String, _ arguments: [SQLExpression], flags: SQLFunctionFlags) -> Self { - .init(impl: .function(name, flags: flags, arguments: arguments)) + aggregateFunction("COUNT", [expression], isDistinct: true) } /// An expression that checks for zero or positive values. @@ -1071,18 +1205,8 @@ extension SQLExpression { case let .collated(expression, _): return try expression.column(db, for: alias, acceptsBijection: acceptsBijection) - case let .function(name, flags: flags, arguments: arguments) where !flags.isAggregate: - guard acceptsBijection else { - return nil - } - let name = name.uppercased() - if ["HEX", "QUOTE"].contains(name) && arguments.count == 1 { - return try arguments[0].column(db, for: alias, acceptsBijection: acceptsBijection) - } else if name == "IFNULL" && arguments.count == 2 && arguments[1].isConstantInRequest { - return try arguments[0].column(db, for: alias, acceptsBijection: acceptsBijection) - } else { - return nil - } + case let .simpleFunction(invocation) where acceptsBijection: + return try invocation.column(db, for: alias) case let .qualifiedFastPrimaryKey(a): if alias == a { @@ -1266,13 +1390,11 @@ extension SQLExpression { case .countAll: return "COUNT(*)" - case let .function(name, flags: flags, arguments: arguments): - assert(!flags.isDistinct || flags.isAggregate, "distinct requires aggregate") - assert(!flags.isDistinct || arguments.count == 1, "distinct requires a single argument") - return try name - + (flags.isDistinct ? "(DISTINCT " : "(") - + arguments.map { try $0.sql(context) }.joined(separator: ", ") - + ")" + case let .simpleFunction(invocation): + return try invocation.sql(context) + + case let .aggregateFunction(invocation): + return try invocation.sql(context, wrappedInParenthesis: wrappedInParenthesis) case let .isEmpty(expression, isNegated: isNegated): var resultSQL = try """ @@ -1721,10 +1843,8 @@ extension SQLExpression { let .collated(expression, _): return expression.isConstantInRequest - case let .function(_, flags: flags, arguments: arguments) - where flags.isPure && !flags.isAggregate: - - return arguments.allSatisfy(\.isConstantInRequest) + case let .simpleFunction(invocation): + return invocation.isConstantInRequest default: return false @@ -1783,7 +1903,7 @@ extension SQLExpression { case .countAll: return true - case let .function(_, flags: flags, arguments: _) where flags.isAggregate: + case .aggregateFunction: return true default: @@ -1792,131 +1912,108 @@ extension SQLExpression { } } -// MARK: - Function Flags - -/// Information about a function -struct SQLFunctionFlags { +/// https://www.sqlite.org/syntax/simple-function-invocation.html +struct SQLSimpleFunctionInvocation { + var name: String + var arguments: [SQLExpression] + /// A boolean value indicating if a function is known to be pure. /// /// A false value does not provide any information. - var isPure = false + var isPure: Bool - /// A boolean value indicating if a function is known to be - /// an aggregate. + /// A boolean value indicating if a function is known to return a + /// JSON value. /// /// A false value does not provide any information. - var isAggregate = false + var isJSONValue: Bool + + var isConstantInRequest: Bool { + isPure && arguments.allSatisfy(\.isConstantInRequest) + } + + func qualified(with alias: TableAlias) -> Self { + SQLSimpleFunctionInvocation( + name: name, + arguments: arguments.map { $0.qualified(with: alias) }, + isPure: isPure, + isJSONValue: isJSONValue) + } + + func column(_ db: Database, for alias: TableAlias) throws -> String? { + let name = name.uppercased() + if ["HEX", "QUOTE"].contains(name) && arguments.count == 1 { + return try arguments[0].column(db, for: alias, acceptsBijection: true) + } else if name == "IFNULL" && arguments.count == 2 && arguments[1].isConstantInRequest { + return try arguments[0].column(db, for: alias, acceptsBijection: true) + } else { + return nil + } + } - /// A boolean value indicating if the function should have `DISTINCT` - /// in its SQL generation (as in `COUNT(DISTINCT ...)`). + func sql(_ context: SQLGenerationContext) throws -> String { + var sql = name + sql += "(" + sql += try arguments + .map { try $0.sql(context) } + .joined(separator: ", ") + sql += ")" + return sql + } +} + +/// https://www.sqlite.org/syntax/aggregate-function-invocation.html +struct SQLAggregateFunctionInvocation { + var name: String + var arguments: [SQLExpression] var isDistinct = false + var ordering: SQLOrdering? = nil // SQLite 3.44.0+ + var filter: SQLExpression? = nil // @available(iOS 14, macOS 10.16, tvOS 14, watchOS 7, *) SQLite 3.30+ /// A boolean value indicating if a function is known to return a /// JSON value. /// /// A false value does not provide any information. - var isJSONValue = false -} - -extension SQLFunctionFlags { - // TODO: add missing pure functions: - // https://www.sqlite.org/lang_aggfunc.html - // https://www.sqlite.org/lang_datefunc.html - // https://www.sqlite.org/lang_mathfunc.html - private static let knownPureFunctions: Set = [ - "ABS", - "CHAR", - "COALESCE", - "GLOB", - "HEX", - "IFNULL", - "IIF", - "INSTR", - "JSON", - "JSON_ARRAY", - "JSON_GROUP_ARRAY", - "JSON_GROUP_OBJECT", - "JSON_INSERT", - "JSON_OBJECT", - "JSON_PATCH", - "JSON_REMOVE", - "JSON_REPLACE", - "JSON_SET", - "JSON_QUOTE", - "LENGTH", - "LIKE", - "LIKELIHOOD", - "LIKELY", - "LOAD_EXTENSION", - "LOWER", - "LTRIM", - "NULLIF", - "PRINTF", - "QUOTE", - "REPLACE", - "ROUND", - "RTRIM", - "SOUNDEX", - "SQLITE_COMPILEOPTION_GET", - "SQLITE_COMPILEOPTION_USED", - "SQLITE_SOURCE_ID", - "SQLITE_VERSION", - "SUBSTR", - "TRIM", - "TYPEOF", - "UNICODE", - "UNLIKELY", - "UPPER", - "ZEROBLOB", - ] - - private static let knownAggregateFunctions: Set = [ - "AVG", - "COUNT", - "GROUP_CONCAT", - "JSON_GROUP_ARRAY", - "JSON_GROUP_OBJECT", - "MAX", // when single argument - "MIN", // when single argument - "SUM", - "TOTAL", - ] + var isJSONValue: Bool - private static let knownFunctionsReturningJSONValue: Set = [ - "JSON", - "JSON_ARRAY", - "JSON_GROUP_ARRAY", - "JSON_GROUP_OBJECT", - "JSON_INSERT", - "JSON_OBJECT", - "JSON_PATCH", - "JSON_REMOVE", - "JSON_REPLACE", - "JSON_SET", - "JSON_QUOTE", - ] + func qualified(with alias: TableAlias) -> Self { + SQLAggregateFunctionInvocation( + name: name, + arguments: arguments.map { $0.qualified(with: alias) }, + isDistinct: isDistinct, + ordering: ordering?.qualified(with: alias), + filter: filter?.qualified(with: alias), + isJSONValue: isJSONValue) + } - /// Infers flags from the function name and number of arguments. - static func defaultFlags(for functionName: String, argumentCount: Int) -> Self { - var flags = SQLFunctionFlags() + func sql(_ context: SQLGenerationContext, wrappedInParenthesis: Bool) throws -> String { + var sql = name - let name = functionName.uppercased() + if isDistinct { + sql += "(DISTINCT " + } else { + sql += "(" + } - flags.isPure = Self.knownPureFunctions.contains(name) + sql += try arguments + .map { try $0.sql(context) } + .joined(separator: ", ") - if (name == "MAX" || name == "MIN") && argumentCount > 1 { - flags.isPure = true - } else { - flags.isAggregate = Self.knownAggregateFunctions.contains(name.uppercased()) + if let ordering { + sql += try " ORDER BY \(ordering.sql(context))" } - if name == "JSON_EXTRACT" && argumentCount > 2 { - flags.isJSONValue = true - } else { - flags.isJSONValue = Self.knownFunctionsReturningJSONValue.contains(name) + sql += ")" + + if let filter { + sql += try " FILTER (WHERE \(filter.sql(context)))" } - return flags + if wrappedInParenthesis && filter != nil { + return "(\(sql))" + } else { + return sql + } } } @@ -1960,8 +2057,11 @@ extension SQLExpression { case let .collated(expression, _): return expression.isJSONValue - case let .function(_, flags: flags, arguments: _): - return flags.isJSONValue + case let .simpleFunction(invocation): + return invocation.isJSONValue + + case let .aggregateFunction(invocation): + return invocation.isJSONValue default: return false diff --git a/GRDB/QueryInterface/SQL/SQLFunctions.swift b/GRDB/QueryInterface/SQL/SQLFunctions.swift index a1eb3c0f27..a6938dab5b 100644 --- a/GRDB/QueryInterface/SQL/SQLFunctions.swift +++ b/GRDB/QueryInterface/SQL/SQLFunctions.swift @@ -10,6 +10,40 @@ public func abs(_ value: some SQLSpecificExpressible) -> SQLExpression { .function("ABS", [value.sqlExpression]) } +#if GRDBCUSTOMSQLITE || GRDBCIPHER +/// The `AVG` SQL function. +/// +/// For example: +/// +/// ```swift +/// // AVG(length) +/// average(Column("length")) +/// ``` +public func average( + _ value: some SQLSpecificExpressible, + filter: (any SQLSpecificExpressible)? = nil) +-> SQLExpression { + .aggregateFunction("AVG", [value.sqlExpression], filter: filter?.sqlExpression) +} +#else +/// The `AVG` SQL function. +/// +/// For example: +/// +/// ```swift +/// // AVG(length) FILTER (WHERE length > 0) +/// average(Column("length"), filter: Column("length") > 0) +/// ``` +@available(iOS 14, macOS 10.16, tvOS 14, watchOS 7, *) // SQLite 3.30+ +public func average( + _ value: some SQLSpecificExpressible, + filter: some SQLSpecificExpressible) +-> SQLExpression { + .aggregateFunction( + "AVG", [value.sqlExpression], + filter: filter.sqlExpression) +} + /// The `AVG` SQL function. /// /// For example: @@ -19,8 +53,9 @@ public func abs(_ value: some SQLSpecificExpressible) -> SQLExpression { /// average(Column("length")) /// ``` public func average(_ value: some SQLSpecificExpressible) -> SQLExpression { - .function("AVG", [value.sqlExpression]) + .aggregateFunction("AVG", [value.sqlExpression]) } +#endif /// The `COUNT` SQL function. /// @@ -72,6 +107,38 @@ public func length(_ value: some SQLSpecificExpressible) -> SQLExpression { .function("LENGTH", [value.sqlExpression]) } +#if GRDBCUSTOMSQLITE || GRDBCIPHER +/// The `MAX` SQL function. +/// +/// For example: +/// +/// ```swift +/// // MAX(score) +/// max(Column("score")) +/// ``` +public func max( + _ value: some SQLSpecificExpressible, + filter: (any SQLSpecificExpressible)? = nil) +-> SQLExpression { + .aggregateFunction("MAX", [value.sqlExpression], filter: filter?.sqlExpression) +} +#else +/// The `MAX` SQL function. +/// +/// For example: +/// +/// ```swift +/// // MAX(score) FILTER (WHERE score < 0) +/// max(Column("score"), filter: Column("score") < 0) +/// ``` +@available(iOS 14, macOS 10.16, tvOS 14, watchOS 7, *) // SQLite 3.30+ +public func max( + _ value: some SQLSpecificExpressible, + filter: some SQLSpecificExpressible) +-> SQLExpression { + .aggregateFunction("MAX", [value.sqlExpression], filter: filter.sqlExpression) +} + /// The `MAX` SQL function. /// /// For example: @@ -81,7 +148,40 @@ public func length(_ value: some SQLSpecificExpressible) -> SQLExpression { /// max(Column("score")) /// ``` public func max(_ value: some SQLSpecificExpressible) -> SQLExpression { - .function("MAX", [value.sqlExpression]) + .aggregateFunction("MAX", [value.sqlExpression]) +} +#endif + +#if GRDBCUSTOMSQLITE || GRDBCIPHER +/// The `MIN` SQL function. +/// +/// For example: +/// +/// ```swift +/// // MIN(score) +/// min(Column("score")) +/// ``` +public func min( + _ value: some SQLSpecificExpressible, + filter: (any SQLSpecificExpressible)? = nil) +-> SQLExpression { + .aggregateFunction("MIN", [value.sqlExpression], filter: filter?.sqlExpression) +} +#else +/// The `MIN` SQL function. +/// +/// For example: +/// +/// ```swift +/// // MIN(score) FILTER (WHERE score > 0) +/// min(Column("score"), filter: Column("score") > 0) +/// ``` +@available(iOS 14, macOS 10.16, tvOS 14, watchOS 7, *) // SQLite 3.30+ +public func min( + _ value: some SQLSpecificExpressible, + filter: some SQLSpecificExpressible) +-> SQLExpression { + .aggregateFunction("MIN", [value.sqlExpression], filter: filter.sqlExpression) } /// The `MIN` SQL function. @@ -93,7 +193,55 @@ public func max(_ value: some SQLSpecificExpressible) -> SQLExpression { /// min(Column("score")) /// ``` public func min(_ value: some SQLSpecificExpressible) -> SQLExpression { - .function("MIN", [value.sqlExpression]) + .aggregateFunction("MIN", [value.sqlExpression]) +} +#endif + +#if GRDBCUSTOMSQLITE || GRDBCIPHER +/// The `SUM` SQL function. +/// +/// For example: +/// +/// ```swift +/// // SUM(amount) +/// sum(Column("amount")) +/// ``` +/// +/// See also ``total(_:)``. +/// +/// Related SQLite documentation: . +public func sum( + _ value: some SQLSpecificExpressible, + orderBy ordering: (any SQLOrderingTerm)? = nil, + filter: (any SQLSpecificExpressible)? = nil) +-> SQLExpression +{ + .aggregateFunction( + "SUM", [value.sqlExpression], + ordering: ordering?.sqlOrdering, + filter: filter?.sqlExpression) +} +#else +/// The `SUM` SQL function. +/// +/// For example: +/// +/// ```swift +/// // SUM(amount) FILTER (WHERE amount > 0) +/// sum(Column("amount"), filter: Column("amount") > 0) +/// ``` +/// +/// See also ``total(_:)``. +/// +/// Related SQLite documentation: . +@available(iOS 14, macOS 10.16, tvOS 14, watchOS 7, *) // SQLite 3.30+ +public func sum( + _ value: some SQLSpecificExpressible, + filter: some SQLSpecificExpressible) +-> SQLExpression { + .aggregateFunction( + "SUM", [value.sqlExpression], + filter: filter.sqlExpression) } /// The `SUM` SQL function. @@ -109,7 +257,55 @@ public func min(_ value: some SQLSpecificExpressible) -> SQLExpression { /// /// Related SQLite documentation: . public func sum(_ value: some SQLSpecificExpressible) -> SQLExpression { - .function("SUM", [value.sqlExpression]) + .aggregateFunction("SUM", [value.sqlExpression]) +} +#endif + +#if GRDBCUSTOMSQLITE || GRDBCIPHER +/// The `TOTAL` SQL function. +/// +/// For example: +/// +/// ```swift +/// // TOTAL(amount) +/// total(Column("amount")) +/// ``` +/// +/// See also ``sum(_:)``. +/// +/// Related SQLite documentation: . +public func total( + _ value: some SQLSpecificExpressible, + orderBy ordering: (any SQLOrderingTerm)? = nil, + filter: (any SQLSpecificExpressible)? = nil) +-> SQLExpression +{ + .aggregateFunction( + "TOTAL", [value.sqlExpression], + ordering: ordering?.sqlOrdering, + filter: filter?.sqlExpression) +} +#else +/// The `TOTAL` SQL function. +/// +/// For example: +/// +/// ```swift +/// // TOTAL(amount) FILTER (WHERE amount > 0) +/// total(Column("amount"), filter: Column("amount") > 0) +/// ``` +/// +/// See also ``total(_:)``. +/// +/// Related SQLite documentation: . +@available(iOS 14, macOS 10.16, tvOS 14, watchOS 7, *) // SQLite 3.30+ +public func total( + _ value: some SQLSpecificExpressible, + filter: some SQLSpecificExpressible) +-> SQLExpression { + .aggregateFunction( + "TOTAL", [value.sqlExpression], + filter: filter.sqlExpression) } /// The `TOTAL` SQL function. @@ -125,8 +321,9 @@ public func sum(_ value: some SQLSpecificExpressible) -> SQLExpression { /// /// Related SQLite documentation: . public func total(_ value: some SQLSpecificExpressible) -> SQLExpression { - .function("TOTAL", [value.sqlExpression]) + .aggregateFunction("TOTAL", [value.sqlExpression]) } +#endif // MARK: - String functions diff --git a/Tests/GRDBTests/JSONExpressionsTests.swift b/Tests/GRDBTests/JSONExpressionsTests.swift index 5c91ea3c8d..5758037abf 100644 --- a/Tests/GRDBTests/JSONExpressionsTests.swift +++ b/Tests/GRDBTests/JSONExpressionsTests.swift @@ -1211,6 +1211,72 @@ final class JSONExpressionsTests: GRDBTestCase { } } + func test_Database_jsonGroupArray_filter() throws { +#if GRDBCUSTOMSQLITE || GRDBCIPHER + // Prevent SQLCipher failures + guard sqlite3_libversion_number() >= 3038000 else { + throw XCTSkip("JSON support is not available") + } +#else + guard #available(iOS 16, macOS 10.15, tvOS 17, watchOS 9, *) else { + throw XCTSkip("JSON support is not available") + } +#endif + + try makeDatabaseQueue().inDatabase { db in + try db.create(table: "player") { t in + t.column("name", .text) + t.column("info", .jsonText) + } + let player = Table("player") + let nameColumn = Column("name") + let infoColumn = JSONColumn("info") + + try assertEqualSQL(db, player.select(Database.jsonGroupArray(nameColumn, filter: length(nameColumn) > 0)), """ + SELECT JSON_GROUP_ARRAY("name") FILTER (WHERE LENGTH("name") > 0) FROM "player" + """) + + try assertEqualSQL(db, player.select(Database.jsonGroupArray(infoColumn, filter: length(nameColumn) > 0)), """ + SELECT JSON_GROUP_ARRAY(JSON("info")) FILTER (WHERE LENGTH("name") > 0) FROM "player" + """) + } + } + +#if GRDBCUSTOMSQLITE || GRDBCIPHER + func test_Database_jsonGroupArray_order() throws { + // Prevent SQLCipher failures + guard sqlite3_libversion_number() >= 3038000 else { + throw XCTSkip("JSON support is not available") + } + + try makeDatabaseQueue().inDatabase { db in + try db.create(table: "player") { t in + t.column("name", .text) + t.column("info", .jsonText) + } + let player = Table("player") + let nameColumn = Column("name") + let infoColumn = JSONColumn("info") + + try assertEqualSQL(db, player.select(Database.jsonGroupArray(nameColumn, orderBy: nameColumn)), """ + SELECT JSON_GROUP_ARRAY("name" ORDER BY "name") FROM "player" + """) + + try assertEqualSQL(db, player.select(Database.jsonGroupArray(infoColumn, orderBy: nameColumn.desc)), """ + SELECT JSON_GROUP_ARRAY(JSON("info") ORDER BY "name" DESC) FROM "player" + """) + + try assertEqualSQL(db, player.select(Database.jsonGroupArray(nameColumn, orderBy: nameColumn, filter: length(nameColumn) > 0)), """ + SELECT JSON_GROUP_ARRAY("name" ORDER BY "name") FILTER (WHERE LENGTH("name") > 0) FROM "player" + """) + + try assertEqualSQL(db, player.select(Database.jsonGroupArray(infoColumn, orderBy: nameColumn.desc, filter: length(nameColumn) > 0)), """ + SELECT JSON_GROUP_ARRAY(JSON("info") ORDER BY "name" DESC) FILTER (WHERE LENGTH("name") > 0) FROM "player" + """) + } + } +#endif + func test_Database_jsonGroupObject() throws { #if GRDBCUSTOMSQLITE || GRDBCIPHER // Prevent SQLCipher failures @@ -1238,6 +1304,33 @@ final class JSONExpressionsTests: GRDBTestCase { } } + func test_Database_jsonGroupObject_filter() throws { +#if GRDBCUSTOMSQLITE || GRDBCIPHER + // Prevent SQLCipher failures + guard sqlite3_libversion_number() >= 3038000 else { + throw XCTSkip("JSON support is not available") + } +#else + guard #available(iOS 16, macOS 10.15, tvOS 17, watchOS 9, *) else { + throw XCTSkip("JSON support is not available") + } +#endif + + try makeDatabaseQueue().inDatabase { db in + try db.create(table: "player") { t in + t.column("key", .text) + t.column("value", .jsonText) + } + let player = Table("player") + let keyColumn = Column("key") + let valueColumn = JSONColumn("value") + + try assertEqualSQL(db, player.select(Database.jsonGroupObject(key: keyColumn, value: valueColumn, filter: length(valueColumn) > 0)), """ + SELECT JSON_GROUP_OBJECT("key", JSON("value")) FILTER (WHERE LENGTH("value") > 0) FROM "player" + """) + } + } + func test_index_and_generated_columns() throws { #if GRDBCUSTOMSQLITE || GRDBCIPHER // Prevent SQLCipher failures diff --git a/Tests/GRDBTests/QueryInterfaceExpressionsTests.swift b/Tests/GRDBTests/QueryInterfaceExpressionsTests.swift index c4b0c0046e..67c33c4d9b 100644 --- a/Tests/GRDBTests/QueryInterfaceExpressionsTests.swift +++ b/Tests/GRDBTests/QueryInterfaceExpressionsTests.swift @@ -1505,6 +1505,28 @@ class QueryInterfaceExpressionsTests: GRDBTestCase { "SELECT AVG(\"age\" / 2) FROM \"readers\"") } + func testAvgExpression_filter() throws { + #if GRDBCUSTOMSQLITE || GRDBCIPHER + // Prevent SQLCipher failures + guard sqlite3_libversion_number() >= 3030000 else { + throw XCTSkip("FILTER clause on aggregate functions is not available") + } + #else + guard #available(iOS 14, macOS 10.16, tvOS 14, watchOS 7, *) else { + throw XCTSkip("FILTER clause on aggregate functions is not available") + } + #endif + + let dbQueue = try makeDatabaseQueue() + + XCTAssertEqual( + sql(dbQueue, tableRequest.select(average(Col.age, filter: Col.age > 0))), + "SELECT AVG(\"age\") FILTER (WHERE \"age\" > 0) FROM \"readers\"") + XCTAssertEqual( + sql(dbQueue, tableRequest.select(average(Col.age / 2, filter: Col.age > 0))), + "SELECT AVG(\"age\" / 2) FILTER (WHERE \"age\" > 0) FROM \"readers\"") + } + func testLengthExpression() throws { let dbQueue = try makeDatabaseQueue() @@ -1524,6 +1546,28 @@ class QueryInterfaceExpressionsTests: GRDBTestCase { "SELECT MIN(\"age\" / 2) FROM \"readers\"") } + func testMinExpression_filter() throws { + #if GRDBCUSTOMSQLITE || GRDBCIPHER + // Prevent SQLCipher failures + guard sqlite3_libversion_number() >= 3030000 else { + throw XCTSkip("FILTER clause on aggregate functions is not available") + } + #else + guard #available(iOS 14, macOS 10.16, tvOS 14, watchOS 7, *) else { + throw XCTSkip("FILTER clause on aggregate functions is not available") + } + #endif + + let dbQueue = try makeDatabaseQueue() + + XCTAssertEqual( + sql(dbQueue, tableRequest.select(min(Col.age, filter: Col.age > 0))), + "SELECT MIN(\"age\") FILTER (WHERE \"age\" > 0) FROM \"readers\"") + XCTAssertEqual( + sql(dbQueue, tableRequest.select(min(Col.age / 2, filter: Col.age > 0))), + "SELECT MIN(\"age\" / 2) FILTER (WHERE \"age\" > 0) FROM \"readers\"") + } + func testMaxExpression() throws { let dbQueue = try makeDatabaseQueue() @@ -1535,6 +1579,28 @@ class QueryInterfaceExpressionsTests: GRDBTestCase { "SELECT MAX(\"age\" / 2) FROM \"readers\"") } + func testMaxExpression_filter() throws { + #if GRDBCUSTOMSQLITE || GRDBCIPHER + // Prevent SQLCipher failures + guard sqlite3_libversion_number() >= 3030000 else { + throw XCTSkip("FILTER clause on aggregate functions is not available") + } + #else + guard #available(iOS 14, macOS 10.16, tvOS 14, watchOS 7, *) else { + throw XCTSkip("FILTER clause on aggregate functions is not available") + } + #endif + + let dbQueue = try makeDatabaseQueue() + + XCTAssertEqual( + sql(dbQueue, tableRequest.select(max(Col.age, filter: Col.age < 0))), + "SELECT MAX(\"age\") FILTER (WHERE \"age\" < 0) FROM \"readers\"") + XCTAssertEqual( + sql(dbQueue, tableRequest.select(max(Col.age / 2, filter: Col.age < 0))), + "SELECT MAX(\"age\" / 2) FILTER (WHERE \"age\" < 0) FROM \"readers\"") + } + func testSumExpression() throws { let dbQueue = try makeDatabaseQueue() @@ -1546,6 +1612,52 @@ class QueryInterfaceExpressionsTests: GRDBTestCase { "SELECT SUM(\"age\" / 2) FROM \"readers\"") } + func testSumExpression_filter() throws { + #if GRDBCUSTOMSQLITE || GRDBCIPHER + // Prevent SQLCipher failures + guard sqlite3_libversion_number() >= 3030000 else { + throw XCTSkip("FILTER clause on aggregate functions is not available") + } + #else + guard #available(iOS 14, macOS 10.16, tvOS 14, watchOS 7, *) else { + throw XCTSkip("FILTER clause on aggregate functions is not available") + } + #endif + + let dbQueue = try makeDatabaseQueue() + + XCTAssertEqual( + sql(dbQueue, tableRequest.select(sum(Col.age, filter: Col.age > 0))), + "SELECT SUM(\"age\") FILTER (WHERE \"age\" > 0) FROM \"readers\"") + XCTAssertEqual( + sql(dbQueue, tableRequest.select(sum(Col.age / 2, filter: Col.age > 0))), + "SELECT SUM(\"age\" / 2) FILTER (WHERE \"age\" > 0) FROM \"readers\"") + } + +#if GRDBCUSTOMSQLITE || GRDBCIPHER + func testSumExpression_order() throws { + // Prevent SQLCipher failures + guard sqlite3_libversion_number() >= 3044000 else { + throw XCTSkip("ORDER BY clause on aggregate functions is not available") + } + + let dbQueue = try makeDatabaseQueue() + + XCTAssertEqual( + sql(dbQueue, tableRequest.select(sum(Col.age, orderBy: Col.age))), + "SELECT SUM(\"age\" ORDER BY \"age\") FROM \"readers\"") + XCTAssertEqual( + sql(dbQueue, tableRequest.select(sum(Col.age / 2, orderBy: Col.age.desc))), + "SELECT SUM(\"age\" / 2 ORDER BY \"age\" DESC) FROM \"readers\"") + XCTAssertEqual( + sql(dbQueue, tableRequest.select(sum(Col.age, orderBy: Col.age, filter: Col.age > 0))), + "SELECT SUM(\"age\" ORDER BY \"age\") FILTER (WHERE \"age\" > 0) FROM \"readers\"") + XCTAssertEqual( + sql(dbQueue, tableRequest.select(sum(Col.age / 2, orderBy: Col.age.desc, filter: Col.age > 0))), + "SELECT SUM(\"age\" / 2 ORDER BY \"age\" DESC) FILTER (WHERE \"age\" > 0) FROM \"readers\"") + } +#endif + func testTotalExpression() throws { let dbQueue = try makeDatabaseQueue() @@ -1557,6 +1669,51 @@ class QueryInterfaceExpressionsTests: GRDBTestCase { "SELECT TOTAL(\"age\" / 2) FROM \"readers\"") } + func testTotalExpression_filter() throws { + #if GRDBCUSTOMSQLITE || GRDBCIPHER + // Prevent SQLCipher failures + guard sqlite3_libversion_number() >= 3030000 else { + throw XCTSkip("FILTER clause on aggregate functions is not available") + } + #else + guard #available(iOS 14, macOS 10.16, tvOS 14, watchOS 7, *) else { + throw XCTSkip("FILTER clause on aggregate functions is not available") + } + #endif + + let dbQueue = try makeDatabaseQueue() + + XCTAssertEqual( + sql(dbQueue, tableRequest.select(total(Col.age, filter: Col.age > 0))), + "SELECT TOTAL(\"age\") FILTER (WHERE \"age\" > 0) FROM \"readers\"") + XCTAssertEqual( + sql(dbQueue, tableRequest.select(total(Col.age / 2, filter: Col.age > 0))), + "SELECT TOTAL(\"age\" / 2) FILTER (WHERE \"age\" > 0) FROM \"readers\"") + } + +#if GRDBCUSTOMSQLITE || GRDBCIPHER + func testTotalExpression_order() throws { + // Prevent SQLCipher failures + guard sqlite3_libversion_number() >= 3044000 else { + throw XCTSkip("ORDER BY clause on aggregate functions is not available") + } + + let dbQueue = try makeDatabaseQueue() + + XCTAssertEqual( + sql(dbQueue, tableRequest.select(total(Col.age, orderBy: Col.age))), + "SELECT TOTAL(\"age\" ORDER BY \"age\") FROM \"readers\"") + XCTAssertEqual( + sql(dbQueue, tableRequest.select(total(Col.age / 2, orderBy: Col.age.desc))), + "SELECT TOTAL(\"age\" / 2 ORDER BY \"age\" DESC) FROM \"readers\"") + XCTAssertEqual( + sql(dbQueue, tableRequest.select(total(Col.age, orderBy: Col.age, filter: Col.age > 0))), + "SELECT TOTAL(\"age\" ORDER BY \"age\") FILTER (WHERE \"age\" > 0) FROM \"readers\"") + XCTAssertEqual( + sql(dbQueue, tableRequest.select(total(Col.age / 2, orderBy: Col.age.desc, filter: Col.age > 0))), + "SELECT TOTAL(\"age\" / 2 ORDER BY \"age\" DESC) FILTER (WHERE \"age\" > 0) FROM \"readers\"") + } +#endif // MARK: - LIKE operator From 46ad19d2e359609570401ee0ae1b1d32d7e80764 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Thu, 2 Nov 2023 13:24:46 +0100 Subject: [PATCH 03/28] Documentation --- GRDB/Documentation.docc/JSON.md | 4 +- GRDB/JSON/SQLJSONFunctions.swift | 53 +++++++++++++++++++++ GRDB/QueryInterface/SQL/SQLExpression.swift | 5 ++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/GRDB/Documentation.docc/JSON.md b/GRDB/Documentation.docc/JSON.md index 49eec990f0..581561b67d 100644 --- a/GRDB/Documentation.docc/JSON.md +++ b/GRDB/Documentation.docc/JSON.md @@ -133,8 +133,8 @@ The `->` and `->>` SQL operators are available on the ``SQLJSONExpressible`` pro - ``Database/jsonArray(_:)-469db`` - ``Database/jsonObject(_:)`` - ``Database/jsonQuote(_:)`` -- ``Database/jsonGroupArray(_:)`` -- ``Database/jsonGroupObject(key:value:)`` +- ``Database/jsonGroupArray(_:filter:)`` +- ``Database/jsonGroupObject(key:value:filter:)`` ### Modify JSON values at the SQL level diff --git a/GRDB/JSON/SQLJSONFunctions.swift b/GRDB/JSON/SQLJSONFunctions.swift index 2fd86aae22..0972c2509c 100644 --- a/GRDB/JSON/SQLJSONFunctions.swift +++ b/GRDB/JSON/SQLJSONFunctions.swift @@ -373,6 +373,19 @@ extension Database { /// The `JSON_GROUP_ARRAY` SQL function. /// + /// For example: + /// + /// ```swift + /// // SELECT JSON_GROUP_ARRAY(name) FROM player + /// Player.select(Database.jsonGroupArray(Column("name"))) + /// + /// // SELECT JSON_GROUP_ARRAY(name) FILTER (WHERE score > 0) FROM player + /// Player.select(Database.jsonGroupArray(Column("name"), filter: Column("score") > 0)) + /// + /// // SELECT JSON_GROUP_ARRAY(name ORDER BY name) FROM player + /// Player.select(Database.jsonGroupArray(Column("name"), orderBy: Column("name"))) + /// ``` + /// /// Related SQLite documentation: public static func jsonGroupArray( _ value: some SQLExpressible, @@ -389,6 +402,21 @@ extension Database { /// The `JSON_GROUP_OBJECT` SQL function. /// + /// For example: + /// + /// ```swift + /// // SELECT JSON_GROUP_OBJECT(name, score) FROM player + /// Player.select(Database.jsonGroupObject( + /// key: Column("name"), + /// value: Column("score"))) + /// + /// // SELECT JSON_GROUP_OBJECT(name, score) FILTER (WHERE score > 0) FROM player + /// Player.select(Database.jsonGroupObject( + /// key: Column("name"), + /// value: Column("score"), + /// filter: Column("score") > 0)) + /// ``` + /// /// Related SQLite documentation: public static func jsonGroupObject( key: some SQLExpressible, @@ -796,6 +824,16 @@ extension Database { /// The `JSON_GROUP_ARRAY` SQL function. /// + /// For example: + /// + /// ```swift + /// // SELECT JSON_GROUP_ARRAY(name) FROM player + /// Player.select(Database.jsonGroupArray(Column("name"))) + /// + /// // SELECT JSON_GROUP_ARRAY(name) FILTER (WHERE score > 0) FROM player + /// Player.select(Database.jsonGroupArray(Column("name"), filter: Column("score") > 0)) + /// ``` + /// /// Related SQLite documentation: @available(iOS 16, macOS 10.15, tvOS 17, watchOS 9, *) // SQLite 3.38+ with exceptions for macOS public static func jsonGroupArray( @@ -811,6 +849,21 @@ extension Database { /// The `JSON_GROUP_OBJECT` SQL function. /// + /// For example: + /// + /// ```swift + /// // SELECT JSON_GROUP_OBJECT(name, score) FROM player + /// Player.select(Database.jsonGroupObject( + /// key: Column("name"), + /// value: Column("score"))) + /// + /// // SELECT JSON_GROUP_OBJECT(name, score) FILTER (WHERE score > 0) FROM player + /// Player.select(Database.jsonGroupObject( + /// key: Column("name"), + /// value: Column("score"), + /// filter: Column("score") > 0)) + /// ``` + /// /// Related SQLite documentation: @available(iOS 16, macOS 10.15, tvOS 17, watchOS 9, *) // SQLite 3.38+ with exceptions for macOS public static func jsonGroupObject( diff --git a/GRDB/QueryInterface/SQL/SQLExpression.swift b/GRDB/QueryInterface/SQL/SQLExpression.swift index e7579b4735..3613bdb16c 100644 --- a/GRDB/QueryInterface/SQL/SQLExpression.swift +++ b/GRDB/QueryInterface/SQL/SQLExpression.swift @@ -2144,6 +2144,7 @@ extension SQLExpressible where Self == Column { /// /// - ``abs(_:)-5l6xp`` /// - ``average(_:)`` +/// - ``average(_:filter:)`` /// - ``capitalized`` /// - ``count(_:)`` /// - ``count(distinct:)`` @@ -2156,9 +2157,13 @@ extension SQLExpressible where Self == Column { /// - ``localizedUppercased`` /// - ``lowercased`` /// - ``min(_:)`` +/// - ``min(_:filter:)`` /// - ``max(_:)`` +/// - ``max(_:filter:)`` /// - ``sum(_:)`` +/// - ``sum(_:filter:)`` /// - ``total(_:)`` +/// - ``total(_:filter:)`` /// - ``uppercased`` /// - ``SQLDateModifier`` /// From 225d7a8c2d52ebc16df568833af4939fffef0e1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Thu, 2 Nov 2023 14:01:48 +0100 Subject: [PATCH 04/28] Fix test for SQLCipher --- Tests/GRDBTests/JSONExpressionsTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/GRDBTests/JSONExpressionsTests.swift b/Tests/GRDBTests/JSONExpressionsTests.swift index 5758037abf..666cdde8b1 100644 --- a/Tests/GRDBTests/JSONExpressionsTests.swift +++ b/Tests/GRDBTests/JSONExpressionsTests.swift @@ -1245,7 +1245,7 @@ final class JSONExpressionsTests: GRDBTestCase { #if GRDBCUSTOMSQLITE || GRDBCIPHER func test_Database_jsonGroupArray_order() throws { // Prevent SQLCipher failures - guard sqlite3_libversion_number() >= 3038000 else { + guard sqlite3_libversion_number() >= 3044000 else { throw XCTSkip("JSON support is not available") } From f0fe5ff017b16fac8792142302dae02bde0b0a4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Thu, 2 Nov 2023 14:08:15 +0100 Subject: [PATCH 05/28] TODO GRDB7 --- GRDB/Core/DatabaseFunction.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/GRDB/Core/DatabaseFunction.swift b/GRDB/Core/DatabaseFunction.swift index 4b13c39c27..81fe825748 100644 --- a/GRDB/Core/DatabaseFunction.swift +++ b/GRDB/Core/DatabaseFunction.swift @@ -144,6 +144,7 @@ public final class DatabaseFunction: Hashable { self.kind = .aggregate { Aggregate() } } + // TODO: GRDB7 -> expose ORDER BY and FILTER when we have distinct types for simple functions and aggregates. /// Returns an SQL expression that applies the function. /// /// You can use a `DatabaseFunction` as a regular Swift function. It returns From 7eaf67fb6a761bb0bc316566ed1ac84aea163300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Thu, 2 Nov 2023 15:04:45 +0100 Subject: [PATCH 06/28] CHANGELOG --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8d22747e1..c491eb9af6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,10 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception: --- +## Next Release + +- **New**: [#1452](https://github.com/groue/GRDB.swift/pull/1452) by [@groue](https://github.com/groue): SQLite 3.44.0, FILTER and ORDER BY clauses in aggregate functions + ## 6.21.0 Released October 29, 2023 From 8b19c3424cb125a2406e6fdaa8cc8c860ebab6c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sat, 11 Nov 2023 13:38:03 +0100 Subject: [PATCH 07/28] Test async read after async write --- Tests/GRDBTests/DatabaseWriterTests.swift | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Tests/GRDBTests/DatabaseWriterTests.swift b/Tests/GRDBTests/DatabaseWriterTests.swift index 6a49d50d56..9139b1f9eb 100644 --- a/Tests/GRDBTests/DatabaseWriterTests.swift +++ b/Tests/GRDBTests/DatabaseWriterTests.swift @@ -395,4 +395,27 @@ class DatabaseWriterTests : GRDBTestCase { try await test(setup(makeDatabaseQueue())) try await test(setup(makeDatabasePool())) } + + /// A test related to + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) + func testAsyncWriteThenRead() async throws { + /// An async read performed after an async write should see the write. + func test(_ dbWriter: some DatabaseWriter) async throws { + try await dbWriter.write { db in + try db.execute(sql: """ + CREATE TABLE t (id INTEGER PRIMARY KEY); + INSERT INTO t VALUES (1); + """) + } + + let count = try await dbWriter.read { db in + try Table("t").fetchCount(db) + } + + XCTAssertEqual(count, 1) + } + + try await test(makeDatabaseQueue()) + try await test(makeDatabasePool()) + } } From 836e7733daf96dfbfa6d36bc9e9e52d2bb5e6ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Rou=C3=A9?= Date: Fri, 24 Nov 2023 13:13:25 +0100 Subject: [PATCH 08/28] Prepare TransactionObserverTests for unspecified change notifications --- .../GRDBTests/TransactionObserverTests.swift | 82 +++++++++---------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/Tests/GRDBTests/TransactionObserverTests.swift b/Tests/GRDBTests/TransactionObserverTests.swift index 730edc1030..f951dc074b 100644 --- a/Tests/GRDBTests/TransactionObserverTests.swift +++ b/Tests/GRDBTests/TransactionObserverTests.swift @@ -21,13 +21,13 @@ private class Observer : TransactionObserver { } } - var didChangeCount: Int = 0 + var didChangeWithEventCount: Int = 0 var willCommitCount: Int = 0 var didCommitCount: Int = 0 var didRollbackCount: Int = 0 func resetCounts() { - didChangeCount = 0 + didChangeWithEventCount = 0 willCommitCount = 0 didCommitCount = 0 didRollbackCount = 0 @@ -51,7 +51,7 @@ private class Observer : TransactionObserver { } func databaseDidChange(with event: DatabaseEvent) { - didChangeCount += 1 + didChangeWithEventCount += 1 events.append(event.copy()) } @@ -796,7 +796,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 1) #endif - XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -826,7 +826,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 3) // 3 deletes #endif - XCTAssertEqual(observer.didChangeCount, 3) // 3 deletes + XCTAssertEqual(observer.didChangeWithEventCount, 3) // 3 deletes XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -894,7 +894,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 1) #endif - XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -907,7 +907,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 1) #endif - XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -917,7 +917,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 1) #endif - XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -928,7 +928,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 0) #endif - XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1003,7 +1003,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 3) // 3 deletes #endif - XCTAssertEqual(observer.didChangeCount, 3) // 3 deletes + XCTAssertEqual(observer.didChangeWithEventCount, 3) // 3 deletes XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1014,7 +1014,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 0) #endif - XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1132,7 +1132,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 0) #endif - XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 1) @@ -1154,7 +1154,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 0) #endif - XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1167,7 +1167,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 0) #endif - XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1191,7 +1191,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 0) #endif - XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1205,7 +1205,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 0) #endif - XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 1) @@ -1229,7 +1229,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 1) #endif - XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 1) @@ -1260,7 +1260,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 1) #endif - XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 1) @@ -1284,7 +1284,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 0) #endif - XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1297,7 +1297,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 0) #endif - XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1322,7 +1322,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 0) #endif - XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1336,7 +1336,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 0) #endif - XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 1) @@ -1369,7 +1369,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 1) #endif - XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1478,7 +1478,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 0) #endif - XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1503,7 +1503,7 @@ class TransactionObserverTests: GRDBTestCase { return .commit } - XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1522,7 +1522,7 @@ class TransactionObserverTests: GRDBTestCase { return .commit } - XCTAssertEqual(observer.didChangeCount, 3) + XCTAssertEqual(observer.didChangeWithEventCount, 3) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1550,7 +1550,7 @@ class TransactionObserverTests: GRDBTestCase { return .commit } - XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1578,7 +1578,7 @@ class TransactionObserverTests: GRDBTestCase { return .commit } - XCTAssertEqual(observer.didChangeCount, 2) + XCTAssertEqual(observer.didChangeWithEventCount, 2) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1613,7 +1613,7 @@ class TransactionObserverTests: GRDBTestCase { return .commit } - XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1646,7 +1646,7 @@ class TransactionObserverTests: GRDBTestCase { return .commit } - XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1659,13 +1659,13 @@ class TransactionObserverTests: GRDBTestCase { let dbQueue = try makeDatabaseQueue() class Observer: TransactionObserver { - var didChangeCount: Int = 0 + var didChangeWithEventCount: Int = 0 var willCommitCount: Int = 0 var didCommitCount: Int = 0 var didRollbackCount: Int = 0 func resetCounts() { - didChangeCount = 0 + didChangeWithEventCount = 0 willCommitCount = 0 didCommitCount = 0 didRollbackCount = 0 @@ -1682,7 +1682,7 @@ class TransactionObserverTests: GRDBTestCase { func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool { true } func databaseDidChange(with event: DatabaseEvent) { - didChangeCount += 1 + didChangeWithEventCount += 1 if event.tableName == "ignore" { stopObservingDatabaseChangesUntilNextTransaction() } @@ -1720,7 +1720,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 2) #endif - XCTAssertEqual(observer.didChangeCount, 2) + XCTAssertEqual(observer.didChangeWithEventCount, 2) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1739,7 +1739,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 1) #endif - XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1758,7 +1758,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 2) #endif - XCTAssertEqual(observer.didChangeCount, 2) + XCTAssertEqual(observer.didChangeWithEventCount, 2) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1777,7 +1777,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 3) #endif - XCTAssertEqual(observer.didChangeCount, 3) + XCTAssertEqual(observer.didChangeWithEventCount, 3) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1803,7 +1803,7 @@ class TransactionObserverTests: GRDBTestCase { BEGIN; COMMIT; """) - XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1818,7 +1818,7 @@ class TransactionObserverTests: GRDBTestCase { SELECT * FROM artists; COMMIT; """) - XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1843,7 +1843,7 @@ class TransactionObserverTests: GRDBTestCase { BEGIN; COMMIT; """) - XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1858,7 +1858,7 @@ class TransactionObserverTests: GRDBTestCase { SELECT * FROM artists; COMMIT; """) - XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) From 5e749eb50bd1442fcc187e2573eb8b55c0a41149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Rou=C3=A9?= Date: Fri, 24 Nov 2023 14:10:57 +0100 Subject: [PATCH 09/28] Document that changes performed by SQLite statements that are not compiled and executed by GRDB are not detected. --- GRDB/Documentation.docc/Extension/DatabaseRegionObservation.md | 1 + GRDB/Documentation.docc/Extension/TransactionObserver.md | 1 + GRDB/Documentation.docc/Extension/ValueObservation.md | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/GRDB/Documentation.docc/Extension/DatabaseRegionObservation.md b/GRDB/Documentation.docc/Extension/DatabaseRegionObservation.md index 01c1e1f066..a07bb89708 100644 --- a/GRDB/Documentation.docc/Extension/DatabaseRegionObservation.md +++ b/GRDB/Documentation.docc/Extension/DatabaseRegionObservation.md @@ -9,6 +9,7 @@ The only changes that are not notified are: - Changes performed by external database connections. +- Changes performed by SQLite statements that are not compiled and executed by GRDB. - Changes to the database schema, changes to internal system tables such as `sqlite_master`. - Changes to [`WITHOUT ROWID`](https://www.sqlite.org/withoutrowid.html) tables. diff --git a/GRDB/Documentation.docc/Extension/TransactionObserver.md b/GRDB/Documentation.docc/Extension/TransactionObserver.md index 026833eac8..8971f1e0a2 100644 --- a/GRDB/Documentation.docc/Extension/TransactionObserver.md +++ b/GRDB/Documentation.docc/Extension/TransactionObserver.md @@ -29,6 +29,7 @@ The only changes and transactions that are not notified are: - Read-only transactions. - Changes and transactions performed by external database connections. +- Changes performed by SQLite statements that are not compiled and executed by GRDB. - Changes to the database schema, changes to internal system tables such as `sqlite_master`. - Changes to [`WITHOUT ROWID`](https://www.sqlite.org/withoutrowid.html) tables. - The deletion of duplicate rows triggered by [`ON CONFLICT REPLACE`](https://www.sqlite.org/lang_conflict.html) clauses (this last exception might change in a future release of SQLite). diff --git a/GRDB/Documentation.docc/Extension/ValueObservation.md b/GRDB/Documentation.docc/Extension/ValueObservation.md index 4b61af0959..f1891e9d2d 100644 --- a/GRDB/Documentation.docc/Extension/ValueObservation.md +++ b/GRDB/Documentation.docc/Extension/ValueObservation.md @@ -8,7 +8,8 @@ The only changes that are not notified are: -- Changes performed by external database connections. See for more information. +- Changes performed by external database connections. +- Changes performed by SQLite statements that are not compiled and executed by GRDB. - Changes to the database schema, changes to internal system tables such as `sqlite_master`. - Changes to [`WITHOUT ROWID`](https://www.sqlite.org/withoutrowid.html) tables. From 1a16bdeac83faf4b317f127de8fa5c43938c30a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sat, 25 Nov 2023 13:29:30 +0100 Subject: [PATCH 10/28] TransactionObserver.databaseDidChange() --- GRDB/Core/TransactionObserver.swift | 12 ++++- .../Extension/TransactionObserver.md | 1 + .../GRDBTests/TransactionObserverTests.swift | 47 +++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/GRDB/Core/TransactionObserver.swift b/GRDB/Core/TransactionObserver.swift index 5330dfb5d5..44d6171d7a 100644 --- a/GRDB/Core/TransactionObserver.swift +++ b/GRDB/Core/TransactionObserver.swift @@ -782,6 +782,13 @@ public protocol TransactionObserver: AnyObject { /// from being applied on the observed tables. func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool + /// Called when the database was modified in some unspecified way. + /// + /// This method allows a transaction observer to handle changes that are + /// not automatically detected. See + /// and ``Database/notifyChanges(in:)`` for more information. + func databaseDidChange() + /// Called when the database is changed by an insert, update, or /// delete event. /// @@ -857,6 +864,9 @@ extension TransactionObserver { public func databaseWillChange(with event: DatabasePreUpdateEvent) { } #endif + /// The default implementation does nothing. + public func databaseDidChange() { } + /// Prevents the observer from receiving further change notifications until /// the next transaction. /// @@ -889,7 +899,7 @@ extension TransactionObserver { guard let broker = SchedulingWatchdog.current?.databaseObservationBroker else { fatalError(""" stopObservingDatabaseChangesUntilNextTransaction must be called \ - from the databaseDidChange method + from the databaseDidChange(with:) method """) } broker.disableUntilNextTransaction(transactionObserver: self) diff --git a/GRDB/Documentation.docc/Extension/TransactionObserver.md b/GRDB/Documentation.docc/Extension/TransactionObserver.md index 8971f1e0a2..446cf1ee0a 100644 --- a/GRDB/Documentation.docc/Extension/TransactionObserver.md +++ b/GRDB/Documentation.docc/Extension/TransactionObserver.md @@ -245,6 +245,7 @@ This extra API can be activated in two ways: ### Handling Database Changes +- ``databaseDidChange()-7olv7`` - ``databaseDidChange(with:)`` - ``stopObservingDatabaseChangesUntilNextTransaction()`` - ``DatabaseEvent`` diff --git a/Tests/GRDBTests/TransactionObserverTests.swift b/Tests/GRDBTests/TransactionObserverTests.swift index f951dc074b..d59ce64a98 100644 --- a/Tests/GRDBTests/TransactionObserverTests.swift +++ b/Tests/GRDBTests/TransactionObserverTests.swift @@ -21,12 +21,14 @@ private class Observer : TransactionObserver { } } + var didChangeCount: Int = 0 var didChangeWithEventCount: Int = 0 var willCommitCount: Int = 0 var didCommitCount: Int = 0 var didRollbackCount: Int = 0 func resetCounts() { + didChangeCount = 0 didChangeWithEventCount = 0 willCommitCount = 0 didCommitCount = 0 @@ -50,6 +52,10 @@ private class Observer : TransactionObserver { observesBlock(eventKind) } + func databaseDidChange() { + didChangeCount += 1 + } + func databaseDidChange(with event: DatabaseEvent) { didChangeWithEventCount += 1 events.append(event.copy()) @@ -796,6 +802,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 1) #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) @@ -826,6 +833,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 3) // 3 deletes #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 3) // 3 deletes XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) @@ -894,6 +902,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 1) #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) @@ -907,6 +916,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 1) #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) @@ -917,6 +927,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 1) #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) @@ -928,6 +939,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 0) #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) @@ -1003,6 +1015,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 3) // 3 deletes #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 3) // 3 deletes XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) @@ -1014,6 +1027,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 0) #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) @@ -1132,6 +1146,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 0) #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) @@ -1154,6 +1169,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 0) #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) @@ -1167,6 +1183,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 0) #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) @@ -1191,6 +1208,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 0) #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) @@ -1205,6 +1223,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 0) #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) @@ -1229,6 +1248,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 1) #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 0) @@ -1260,6 +1280,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 1) #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 0) @@ -1284,6 +1305,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 0) #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) @@ -1297,6 +1319,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 0) #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) @@ -1322,6 +1345,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 0) #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) @@ -1336,6 +1360,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 0) #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) @@ -1369,6 +1394,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 1) #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) @@ -1478,6 +1504,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 0) #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) @@ -1503,6 +1530,7 @@ class TransactionObserverTests: GRDBTestCase { return .commit } + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) @@ -1522,6 +1550,7 @@ class TransactionObserverTests: GRDBTestCase { return .commit } + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 3) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) @@ -1550,6 +1579,7 @@ class TransactionObserverTests: GRDBTestCase { return .commit } + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) @@ -1578,6 +1608,7 @@ class TransactionObserverTests: GRDBTestCase { return .commit } + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 2) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) @@ -1613,6 +1644,7 @@ class TransactionObserverTests: GRDBTestCase { return .commit } + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) @@ -1646,6 +1678,7 @@ class TransactionObserverTests: GRDBTestCase { return .commit } + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) @@ -1659,12 +1692,14 @@ class TransactionObserverTests: GRDBTestCase { let dbQueue = try makeDatabaseQueue() class Observer: TransactionObserver { + var didChangeCount: Int = 0 var didChangeWithEventCount: Int = 0 var willCommitCount: Int = 0 var didCommitCount: Int = 0 var didRollbackCount: Int = 0 func resetCounts() { + didChangeCount = 0 didChangeWithEventCount = 0 willCommitCount = 0 didCommitCount = 0 @@ -1681,6 +1716,10 @@ class TransactionObserverTests: GRDBTestCase { func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool { true } + func databaseDidChange() { + didChangeCount += 1 + } + func databaseDidChange(with event: DatabaseEvent) { didChangeWithEventCount += 1 if event.tableName == "ignore" { @@ -1720,6 +1759,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 2) #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 2) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) @@ -1739,6 +1779,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 1) #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) @@ -1758,6 +1799,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 2) #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 2) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) @@ -1777,6 +1819,7 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 3) #endif + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 3) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) @@ -1803,6 +1846,7 @@ class TransactionObserverTests: GRDBTestCase { BEGIN; COMMIT; """) + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) @@ -1818,6 +1862,7 @@ class TransactionObserverTests: GRDBTestCase { SELECT * FROM artists; COMMIT; """) + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) @@ -1843,6 +1888,7 @@ class TransactionObserverTests: GRDBTestCase { BEGIN; COMMIT; """) + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) @@ -1858,6 +1904,7 @@ class TransactionObserverTests: GRDBTestCase { SELECT * FROM artists; COMMIT; """) + XCTAssertEqual(observer.didChangeCount, 0) XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) From b5cb8fadf05be6f78ec30c29ada0fae3d3adef7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sat, 25 Nov 2023 14:36:34 +0100 Subject: [PATCH 11/28] Failing tests for Database.notifyChanges(in:) --- GRDB/Core/Database.swift | 46 ++ .../GRDBTests/TransactionObserverTests.swift | 605 ++++++++++++++++++ 2 files changed, 651 insertions(+) diff --git a/GRDB/Core/Database.swift b/GRDB/Core/Database.swift index 9826711728..d3f17f3865 100644 --- a/GRDB/Core/Database.swift +++ b/GRDB/Core/Database.swift @@ -820,6 +820,52 @@ public final class Database: CustomStringConvertible, CustomDebugStringConvertib } } + /// Notifies that some changes were performed in the provided + /// database region. + /// + /// This method makes it possible to notify undetected changes, such as + /// changes performed by another process, changes performed by + /// direct calls to SQLite C functions, or changes to the + /// database schema. + /// See + /// for a detailed list of undetected database modifications. + /// + /// It triggers active transaction observers (``TransactionObserver``). + /// In particular, ``ValueObservation`` that observe the input `region` + /// will fetch and notify a fresh value. + /// + /// For example: + /// + /// ```swift + /// try dbQueue.write { db in + /// // Notify all observers of regular tables + /// try db.notifyChanges(in: .fullDatabase) + /// + /// // Notify all observers of the player table + /// try db.notifyChanges(in: Player.all()) + /// + /// // Equivalent alternative + /// try db.notifyChanges(in: Table("player")) + /// } + /// ``` + /// + /// This method has no effect when called from a read-only + /// database access. + /// + /// > Caveat: Individual rowids in the input region are ignored. + /// > Notifying a change to a specific rowid is the same as notifying a + /// > change in the whole table: + /// > + /// > ```swift + /// > try dbQueue.write { db in + /// > // Equivalent + /// > try db.notifyChanges(in: Player.all()) + /// > try db.notifyChanges(in: Player.filter(id: 1)) + /// > } + /// > ``` + public func notifyChanges(in region: some DatabaseRegionConvertible) throws { + } + /// Extends the `region` argument with the database region selected by all /// statements executed by the closure, and all regions explicitly tracked /// with the ``registerAccess(to:)`` method. diff --git a/Tests/GRDBTests/TransactionObserverTests.swift b/Tests/GRDBTests/TransactionObserverTests.swift index d59ce64a98..de1f6083fb 100644 --- a/Tests/GRDBTests/TransactionObserverTests.swift +++ b/Tests/GRDBTests/TransactionObserverTests.swift @@ -1828,6 +1828,611 @@ class TransactionObserverTests: GRDBTestCase { } } + // MARK: - Unspecified changes + + func testUnspecifiedChangeInFullDatabase() throws { + let dbQueue = try makeDatabaseQueue() + try setupArtistDatabase(in: dbQueue) + + do { + let observer = Observer(observes: { _ in true }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: .fullDatabase) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { _ in false }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: .fullDatabase) + } + + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + switch eventKind { + case .insert: + return true + case .update: + return false + case .delete: + return false + } + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: .fullDatabase) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + switch eventKind { + case .insert: + return false + case .update: + return true + case .delete: + return false + } + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: .fullDatabase) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + switch eventKind { + case .insert: + return false + case .update: + return false + case .delete: + return true + } + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: .fullDatabase) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + eventKind.tableName == "artists" + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: .fullDatabase) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + eventKind.tableName == "non_existing" + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: .fullDatabase) + } + + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + } + + func testUnspecifiedChangeInEmptyRegion() throws { + let dbQueue = try makeDatabaseQueue() + try setupArtistDatabase(in: dbQueue) + + let observer = Observer(observes: { _ in true }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: DatabaseRegion()) + } + + // No change detected because changed region is empty + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + func testUnspecifiedChangeInEmptyDatabase() throws { + let dbQueue = try makeDatabaseQueue() + + let observer = Observer(observes: { _ in true }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: .fullDatabase) + } + + // No change detected because there is no table + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + func testUnspecifiedChange_sqlite_master() throws { + do { + let dbQueue = try makeDatabaseQueue() + let observer = Observer(observes: { eventKind in + eventKind.tableName == "sqlite_master" + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: .fullDatabase) + } + + // Undetected because the full database region does not include sqlite_master + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let dbQueue = try makeDatabaseQueue() + let observer = Observer(observes: { eventKind in + eventKind.tableName == "sqlite_master" + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("sqlite_master")) + } + + // Detected because explicit sqlite_master region + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + } + + func testUnspecifiedChange_sqlite_temp_master() throws { + do { + let dbQueue = try makeDatabaseQueue() + let observer = Observer(observes: { eventKind in + eventKind.tableName == "sqlite_temp_master" + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("sqlite_temp_master")) + } + + // Undetected because there is no temp schema + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let dbQueue = try makeDatabaseQueue() + let observer = Observer(observes: { eventKind in + eventKind.tableName == "sqlite_temp_master" + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + // Create temp schema + try db.execute(sql: "CREATE TEMPORARY TABLE t(a)") + + // Explicit sqlite_temp_master + try db.notifyChanges(in: Table("sqlite_temp_master")) + } + + // Detected because the temp schema exists. + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + } + + func testUnspecifiedChangeToTable() throws { + let dbQueue = try makeDatabaseQueue() + try setupArtistDatabase(in: dbQueue) + + do { + let observer = Observer(observes: { _ in true }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("artists")) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { _ in false }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("artists")) + } + + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + switch eventKind { + case .insert: + return true + case .update: + return false + case .delete: + return false + } + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("artists")) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + switch eventKind { + case .insert: + return false + case .update: + return true + case .delete: + return false + } + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("artists")) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + switch eventKind { + case .insert: + return false + case .update: + return false + case .delete: + return true + } + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("artists")) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + eventKind.tableName == "artists" + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("artists")) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + eventKind.tableName == "artists" + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + // Case insensitivity (observer has to use the canonical name). + try db.notifyChanges(in: Table("ARTISTS")) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + eventKind.tableName == "artworks" + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("artists")) + } + + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + } + + func testUnspecifiedChangeToColumn() throws { + let dbQueue = try makeDatabaseQueue() + try dbQueue.write { db in + try db.create(table: "test", options: .temporary) { t in + t.autoIncrementedPrimaryKey("id") + t.column("a") + t.column("b") + } + } + + do { + let observer = Observer(observes: { eventKind in + if case .update("test", ["a"]) = eventKind { + return true + } + return false + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("test")) + } + + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + if case .update("test", ["a"]) = eventKind { + return true + } + return false + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("test").select(Column("a"))) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + if case .update("test", ["a"]) = eventKind { + return true + } + return false + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + // Case insensitivity + try db.notifyChanges(in: Table("TEST").select(Column("A"))) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + if case .update("test", ["a"]) = eventKind { + return true + } + return false + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("test").select(Column("b"))) + } + + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + } + + func testUnspecifiedChangeToTemporaryTable() throws { + let dbQueue = try makeDatabaseQueue() + try dbQueue.write { db in + try db.create(table: "test", options: .temporary) { t in + t.autoIncrementedPrimaryKey("id") + } + } + + do { + let observer = Observer(observes: { eventKind in + eventKind.tableName == "test" + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: .fullDatabase) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + eventKind.tableName == "test" + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("test")) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + } + + func testUnspecifiedChangeFromReadOnlyAccess() throws { + let dbQueue = try makeDatabaseQueue() + try setupArtistDatabase(in: dbQueue) + + let observer = Observer(observes: { _ in true }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.read { db in + try db.notifyChanges(in: .fullDatabase) + } + + // No change detected from read-only access + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 0) + XCTAssertEqual(observer.didCommitCount, 0) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + // MARK: - Read-Only Connection func testReadOnlyConnection() throws { From 918afbd33e315c01c94ae96c12b30947aaf4281b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sat, 25 Nov 2023 14:38:24 +0100 Subject: [PATCH 12/28] Fix failing tests for Database.notifyChanges(in:) and regular tables --- GRDB/Core/Database+Schema.swift | 2 +- GRDB/Core/Database.swift | 6 ++++ GRDB/Core/DatabaseRegion.swift | 48 ++++++++++++++++++++++++++--- GRDB/Core/TransactionObserver.swift | 18 +++++++++++ 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/GRDB/Core/Database+Schema.swift b/GRDB/Core/Database+Schema.swift index e114c851c9..00f5437b91 100644 --- a/GRDB/Core/Database+Schema.swift +++ b/GRDB/Core/Database+Schema.swift @@ -1367,7 +1367,7 @@ struct SchemaObject: Hashable, FetchableRecord { /// All objects in a database schema (tables, views, indexes, triggers). struct SchemaInfo: Equatable { - private var objects: Set + let objects: Set /// Returns whether there exists a object of given type with this name /// (case-insensitive). diff --git a/GRDB/Core/Database.swift b/GRDB/Core/Database.swift index d3f17f3865..1e3f11ee5a 100644 --- a/GRDB/Core/Database.swift +++ b/GRDB/Core/Database.swift @@ -864,6 +864,12 @@ public final class Database: CustomStringConvertible, CustomDebugStringConvertib /// > } /// > ``` public func notifyChanges(in region: some DatabaseRegionConvertible) throws { + // Don't do anything when read-only, because read-only transactions + // are not notified. We don't want to notify transactions observers + // of changes, and have them wait for a commit that will never come! + if !isReadOnly { + try observationBroker?.notifyChanges(in: region.databaseRegion(self)) + } } /// Extends the `region` argument with the database region selected by all diff --git a/GRDB/Core/DatabaseRegion.swift b/GRDB/Core/DatabaseRegion.swift index 232cd472d7..0099ddb028 100644 --- a/GRDB/Core/DatabaseRegion.swift +++ b/GRDB/Core/DatabaseRegion.swift @@ -169,16 +169,16 @@ public struct DatabaseRegion { // the observed region, we optimize database observation. // // And by canonicalizing table names, we remove views, and help the - // `isModified` methods. + // `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. - /// Canonical names help the `isModified` methods. /// - /// This method removes views (assuming no table exists with the same name - /// as a view). - private func canonicalTables(_ db: Database) throws -> DatabaseRegion { + /// This method removes views. + func canonicalTables(_ db: Database) throws -> DatabaseRegion { guard let tableRegions else { return .fullDatabase } var region = DatabaseRegion() for (table, tableRegion) in tableRegions { @@ -233,6 +233,44 @@ extension DatabaseRegion { } 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 + 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 { diff --git a/GRDB/Core/TransactionObserver.swift b/GRDB/Core/TransactionObserver.swift index 44d6171d7a..77ef5c2adf 100644 --- a/GRDB/Core/TransactionObserver.swift +++ b/GRDB/Core/TransactionObserver.swift @@ -282,6 +282,19 @@ class DatabaseObservationBroker { } } + func notifyChanges(in region: DatabaseRegion) throws { + // Use canonical table names for case insensitivity of the input. + let eventKinds = try region + .canonicalTables(database) + .impactfulEventKinds(database) + + for observation in transactionObservations where observation.isEnabled { + if eventKinds.contains(where: { observation.observes(eventsOfKind: $0) }) { + observation.databaseDidChange() + } + } + } + // MARK: - Statement execution /// Returns true if there exists some transaction observer interested in @@ -952,6 +965,11 @@ final class TransactionObservation { } #endif + func databaseDidChange() { + guard isEnabled else { return } + observer?.databaseDidChange() + } + func databaseDidChange(with event: DatabaseEvent) { guard isEnabled else { return } observer?.databaseDidChange(with: event) From 8afd986790de329b6f205221b3541030a2058192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sat, 25 Nov 2023 14:38:45 +0100 Subject: [PATCH 13/28] Fix failing tests for Database.notifyChanges(in:) and master tables --- GRDB/Core/Database+Schema.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/GRDB/Core/Database+Schema.swift b/GRDB/Core/Database+Schema.swift index 00f5437b91..a8d92fae88 100644 --- a/GRDB/Core/Database+Schema.swift +++ b/GRDB/Core/Database+Schema.swift @@ -57,6 +57,14 @@ extension Database { case let .attached(name): return "\(name).sqlite_master" } } + + /// The name of the master sqlite table, without the schema name. + var unqualifiedMasterTableName: String { // swiftlint:disable:this inclusive_language + switch self { + case .main, .attached: return "sqlite_master" + case .temp: return "sqlite_temp_master" + } + } } /// The identifier of a database table or view. @@ -658,9 +666,17 @@ extension Database { /// attached database. func canonicalTableName(_ tableName: String) throws -> String? { for schemaIdentifier in try schemaIdentifiers() { + // Regular tables if let result = try schema(schemaIdentifier).canonicalName(tableName, ofType: .table) { return result } + + // Master table (sqlite_master, sqlite_temp_master) + // swiftlint:disable:next inclusive_language + let masterTableName = schemaIdentifier.unqualifiedMasterTableName + if tableName.lowercased() == masterTableName.lowercased() { + return masterTableName + } } return nil } From 56ad833ab7b26590433627a3035155047beb0e3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sat, 25 Nov 2023 14:39:07 +0100 Subject: [PATCH 14/28] New Dealing with Undetected Changes documentation chapter --- .../Extension/TransactionObserver.md | 50 +++++++++++++++---- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/GRDB/Documentation.docc/Extension/TransactionObserver.md b/GRDB/Documentation.docc/Extension/TransactionObserver.md index 446cf1ee0a..a82566fdca 100644 --- a/GRDB/Documentation.docc/Extension/TransactionObserver.md +++ b/GRDB/Documentation.docc/Extension/TransactionObserver.md @@ -25,14 +25,7 @@ Database changes are notified to the ``databaseDidChange(with:)`` callback. This Transaction completions are notified to the ``databaseWillCommit()-7mksu``, ``databaseDidCommit(_:)`` and ``databaseDidRollback(_:)`` callbacks. -The only changes and transactions that are not notified are: - -- Read-only transactions. -- Changes and transactions performed by external database connections. -- Changes performed by SQLite statements that are not compiled and executed by GRDB. -- Changes to the database schema, changes to internal system tables such as `sqlite_master`. -- Changes to [`WITHOUT ROWID`](https://www.sqlite.org/withoutrowid.html) tables. -- The deletion of duplicate rows triggered by [`ON CONFLICT REPLACE`](https://www.sqlite.org/lang_conflict.html) clauses (this last exception might change in a future release of SQLite). +> Important: Some changes and transactions are not automatically notified. See below. Notified changes are not actually written to disk until the transaction commits, and the `databaseDidCommit` callback is called. On the other side, `databaseDidRollback` confirms their invalidation: @@ -190,7 +183,7 @@ class PlayerObserver: TransactionObserver { } ``` -### Support for SQLite Pre-Update Hooks +## Support for SQLite Pre-Update Hooks When SQLite is built with the `SQLITE_ENABLE_PREUPDATE_HOOK` option, `TransactionObserver` gets an extra callback which lets you observe individual column values in the rows modified by a transaction: @@ -236,6 +229,45 @@ This extra API can be activated in two ways: 2. Use a [custom SQLite build](http://github.com/groue/GRDB.swift/blob/master/Documentation/CustomSQLiteBuilds.md) and activate the `SQLITE_ENABLE_PREUPDATE_HOOK` compilation option. +## Dealing with Undetected Changes + +Some changes and transactions are not automatically notified to transaction observers: + +- Read-only transactions. +- Changes and transactions performed by external database connections. +- Changes performed by SQLite statements that are not both compiled and executed through GRDB APIs. +- Changes to the database schema, changes to internal system tables such as `sqlite_master`. +- Changes to [`WITHOUT ROWID`](https://www.sqlite.org/withoutrowid.html) tables. +- The deletion of duplicate rows triggered by [`ON CONFLICT REPLACE`](https://www.sqlite.org/lang_conflict.html) clauses (this last exception might change in a future release of SQLite). + +Undetected changes are notified to the ``databaseDidChange()-7olv7`` callback when you perform an explicit call to the ``Database/notifyChanges(in:)`` `Database` method. + +Event filtering described in still applies: the `databaseDidChange()` callback is not called for changes that are not observed. + +For example: + +```swift +try dbQueue.write { db in + // Notify all observers of regular tables + try db.notifyChanges(in: .fullDatabase) + + // Notify all observers of the player table + try db.notifyChanges(in: Player.all()) + + // Equivalent alternative + try db.notifyChanges(in: Table("player")) +} +``` + +To notify a change in the database schema, notify a change to the `sqlite_master` table: + +```swift +try dbQueue.write { db in + // Notify all observers of the sqlite_master table + try db.notifyChanges(in: Table("sqlite_master")) +} +``` + ## Topics ### Filtering Database Changes From abf48eb86f0d22e38348ffa3c2876010f4c32b90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sat, 25 Nov 2023 14:54:43 +0100 Subject: [PATCH 15/28] Failing test for stopObservingDatabaseChangesUntilNextTransaction() from databaseDidChange() --- .../GRDBTests/TransactionObserverTests.swift | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/Tests/GRDBTests/TransactionObserverTests.swift b/Tests/GRDBTests/TransactionObserverTests.swift index de1f6083fb..bf54a8ff3b 100644 --- a/Tests/GRDBTests/TransactionObserverTests.swift +++ b/Tests/GRDBTests/TransactionObserverTests.swift @@ -2433,6 +2433,61 @@ class TransactionObserverTests: GRDBTestCase { XCTAssertEqual(observer.lastCommittedEvents.count, 0) } + func test_stopObservingDatabaseChangesUntilNextTransaction_from_databaseDidChange() throws { + class Observer: TransactionObserver { + var didChangeCount: Int = 0 + var didChangeWithEventCount: Int = 0 + var willCommitCount: Int = 0 + var didCommitCount: Int = 0 + var didRollbackCount: Int = 0 + + #if SQLITE_ENABLE_PREUPDATE_HOOK + var willChangeCount: Int = 0 + func databaseWillChange(with event: DatabasePreUpdateEvent) { willChangeCount += 1 } + #endif + + func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool { true } + + func databaseDidChange() { + didChangeCount += 1 + stopObservingDatabaseChangesUntilNextTransaction() + } + + func databaseDidChange(with event: DatabaseEvent) { + didChangeWithEventCount += 1 + } + + func databaseWillCommit() throws { willCommitCount += 1 } + func databaseDidCommit(_ db: Database) { didCommitCount += 1 } + func databaseDidRollback(_ db: Database) { didRollbackCount += 1 } + } + + let dbQueue = try makeDatabaseQueue() + try dbQueue.write { db in + try db.execute(sql: "CREATE TABLE test(a)") + } + + let observer = Observer() + dbQueue.add(transactionObserver: observer, extent: .databaseLifetime) + + try dbQueue.write { db in + // detected + try db.execute(sql: "INSERT INTO test (a) VALUES (1)") + try db.notifyChanges(in: .fullDatabase) + // ignored + try db.execute(sql: "INSERT INTO test (a) VALUES (2)") + } + + #if SQLITE_ENABLE_PREUPDATE_HOOK + XCTAssertEqual(observer.willChangeCount, 1) + #endif + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 1) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + } + // MARK: - Read-Only Connection func testReadOnlyConnection() throws { From 3e52e265cea7d7c99bede25f03c02feae160ae22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sat, 25 Nov 2023 14:54:53 +0100 Subject: [PATCH 16/28] Fix failing test for stopObservingDatabaseChangesUntilNextTransaction() from databaseDidChange() --- GRDB/Core/TransactionObserver.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/GRDB/Core/TransactionObserver.swift b/GRDB/Core/TransactionObserver.swift index 77ef5c2adf..44363b7803 100644 --- a/GRDB/Core/TransactionObserver.swift +++ b/GRDB/Core/TransactionObserver.swift @@ -288,6 +288,12 @@ class DatabaseObservationBroker { .canonicalTables(database) .impactfulEventKinds(database) + // Support for stopObservingDatabaseChangesUntilNextTransaction() + SchedulingWatchdog.current!.databaseObservationBroker = self + defer { + SchedulingWatchdog.current!.databaseObservationBroker = nil + } + for observation in transactionObservations where observation.isEnabled { if eventKinds.contains(where: { observation.observes(eventsOfKind: $0) }) { observation.databaseDidChange() @@ -912,7 +918,7 @@ extension TransactionObserver { guard let broker = SchedulingWatchdog.current?.databaseObservationBroker else { fatalError(""" stopObservingDatabaseChangesUntilNextTransaction must be called \ - from the databaseDidChange(with:) method + from the `databaseDidChange()` or `databaseDidChange(with:)` methods """) } broker.disableUntilNextTransaction(transactionObserver: self) From 3df718c1a6152d8e2648acf2c18b57e985ddc1ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sat, 25 Nov 2023 15:07:11 +0100 Subject: [PATCH 17/28] Failing test for ValueObservation and Database.notifyChanges(in:) --- Tests/GRDBTests/ValueObservationTests.swift | 43 +++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/Tests/GRDBTests/ValueObservationTests.swift b/Tests/GRDBTests/ValueObservationTests.swift index 31510bee78..a0d04fb5ba 100644 --- a/Tests/GRDBTests/ValueObservationTests.swift +++ b/Tests/GRDBTests/ValueObservationTests.swift @@ -523,6 +523,49 @@ class ValueObservationTests: GRDBTestCase { } #endif + // MARK: - Unspecified Changes + + func test_ValueObservation_is_triggered_by_explicit_change_notification() throws { + let dbQueue1 = try makeDatabaseQueue(filename: "test.sqlite") + try dbQueue1.write { db in + try db.execute(sql: "CREATE TABLE test(a)") + } + + let undetectedExpectation = expectation(description: "undetected") + undetectedExpectation.expectedFulfillmentCount = 2 // initial value and change + undetectedExpectation.isInverted = true + + let detectedExpectation = expectation(description: "detected") + detectedExpectation.expectedFulfillmentCount = 2 // initial value and change + + let observation = ValueObservation.tracking { db in + try Table("test").fetchCount(db) + } + let cancellable = observation.start( + in: dbQueue1, + scheduling: .immediate, + onError: { error in XCTFail("Unexpected error: \(error)") }, + onChange: { _ in + undetectedExpectation.fulfill() + detectedExpectation.fulfill() + }) + + try withExtendedLifetime(cancellable) { + // Change performed from external connection is not detected... + let dbQueue2 = try makeDatabaseQueue(filename: "test.sqlite") + try dbQueue2.write { db in + try db.execute(sql: "INSERT INTO test (a) VALUES (1)") + } + wait(for: [undetectedExpectation], timeout: 2) + + // ... until we perform an explicit change notification + try dbQueue1.write { db in + try db.notifyChanges(in: Table("test")) + } + wait(for: [detectedExpectation], timeout: 2) + } + } + // MARK: - Cancellation func testCancellableLifetime() throws { From dff7971cd71717e72679599bacc4a644a82f6ef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sat, 25 Nov 2023 15:07:19 +0100 Subject: [PATCH 18/28] Fix failing test for ValueObservation and Database.notifyChanges(in:) --- .../Observers/ValueConcurrentObserver.swift | 7 +++++++ .../Observers/ValueWriteOnlyObserver.swift | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/GRDB/ValueObservation/Observers/ValueConcurrentObserver.swift b/GRDB/ValueObservation/Observers/ValueConcurrentObserver.swift index d9a81fd509..78cc9352cb 100644 --- a/GRDB/ValueObservation/Observers/ValueConcurrentObserver.swift +++ b/GRDB/ValueObservation/Observers/ValueConcurrentObserver.swift @@ -710,6 +710,13 @@ extension ValueConcurrentObserver: TransactionObserver { } } + func databaseDidChange() { + // Database was modified! + observationState.isModified = true + // We can stop observing the current transaction + stopObservingDatabaseChangesUntilNextTransaction() + } + func databaseDidChange(with event: DatabaseEvent) { if let region = observationState.region, region.isModified(by: event) { // Database was modified! diff --git a/GRDB/ValueObservation/Observers/ValueWriteOnlyObserver.swift b/GRDB/ValueObservation/Observers/ValueWriteOnlyObserver.swift index c81ac1bcc0..ea084cce88 100644 --- a/GRDB/ValueObservation/Observers/ValueWriteOnlyObserver.swift +++ b/GRDB/ValueObservation/Observers/ValueWriteOnlyObserver.swift @@ -345,6 +345,13 @@ extension ValueWriteOnlyObserver: TransactionObserver { } } + func databaseDidChange() { + // Database was modified! + observationState.isModified = true + // We can stop observing the current transaction + stopObservingDatabaseChangesUntilNextTransaction() + } + func databaseDidChange(with event: DatabaseEvent) { if let region = observationState.region, region.isModified(by: event) { // Database was modified! From 9d6e12782288a3f80d9a1ef9574ace7433832771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sat, 25 Nov 2023 15:14:48 +0100 Subject: [PATCH 19/28] Failing test for DatabaseRegionObservation and Database.notifyChanges(in:) --- .../DatabaseRegionObservationTests.swift | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/Tests/GRDBTests/DatabaseRegionObservationTests.swift b/Tests/GRDBTests/DatabaseRegionObservationTests.swift index bec4f9cf9f..dd4218a00a 100644 --- a/Tests/GRDBTests/DatabaseRegionObservationTests.swift +++ b/Tests/GRDBTests/DatabaseRegionObservationTests.swift @@ -224,6 +224,42 @@ class DatabaseRegionObservationTests: GRDBTestCase { XCTAssertEqual(count, 1) } } + + func test_DatabaseRegionObservation_is_triggered_by_explicit_change_notification() throws { + let dbQueue1 = try makeDatabaseQueue(filename: "test.sqlite") + try dbQueue1.write { db in + try db.execute(sql: "CREATE TABLE test(a)") + } + + let undetectedExpectation = expectation(description: "undetected") + undetectedExpectation.isInverted = true + + let detectedExpectation = expectation(description: "detected") + + let observation = DatabaseRegionObservation(tracking: Table("test")) + let cancellable = observation.start( + in: dbQueue1, + onError: { error in XCTFail("Unexpected error: \(error)") }, + onChange: { _ in + undetectedExpectation.fulfill() + detectedExpectation.fulfill() + }) + + try withExtendedLifetime(cancellable) { + // Change performed from external connection is not detected... + let dbQueue2 = try makeDatabaseQueue(filename: "test.sqlite") + try dbQueue2.write { db in + try db.execute(sql: "INSERT INTO test (a) VALUES (1)") + } + wait(for: [undetectedExpectation], timeout: 2) + + // ... until we perform an explicit change notification + try dbQueue1.write { db in + try db.notifyChanges(in: Table("test")) + } + wait(for: [detectedExpectation], timeout: 2) + } + } // Regression test for https://github.com/groue/GRDB.swift/issues/514 // TODO: uncomment and make this test pass. From bced461c4fd51834badaf4b15c50960f489992b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sat, 25 Nov 2023 15:14:52 +0100 Subject: [PATCH 20/28] Fix failing test for DatabaseRegionObservation and Database.notifyChanges(in:) --- GRDB/Core/DatabaseRegionObservation.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/GRDB/Core/DatabaseRegionObservation.swift b/GRDB/Core/DatabaseRegionObservation.swift index a831e063b0..4f1e1473c0 100644 --- a/GRDB/Core/DatabaseRegionObservation.swift +++ b/GRDB/Core/DatabaseRegionObservation.swift @@ -161,6 +161,11 @@ private class DatabaseRegionObserver: TransactionObserver { region.isModified(byEventsOfKind: eventKind) } + func databaseDidChange() { + isChanged = true + stopObservingDatabaseChangesUntilNextTransaction() + } + func databaseDidChange(with event: DatabaseEvent) { if region.isModified(by: event) { isChanged = true From 079c0c7f2674328b0039d3bec97bee60b529926a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sat, 25 Nov 2023 16:44:18 +0100 Subject: [PATCH 21/28] The unit of change notification is the event kind, not the region --- GRDB/Core/Database.swift | 13 ++++++++++--- GRDB/Core/TransactionObserver.swift | 7 +------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/GRDB/Core/Database.swift b/GRDB/Core/Database.swift index 1e3f11ee5a..4a3d679e9b 100644 --- a/GRDB/Core/Database.swift +++ b/GRDB/Core/Database.swift @@ -866,9 +866,16 @@ public final class Database: CustomStringConvertible, CustomDebugStringConvertib public func notifyChanges(in region: some DatabaseRegionConvertible) throws { // Don't do anything when read-only, because read-only transactions // are not notified. We don't want to notify transactions observers - // of changes, and have them wait for a commit that will never come! - if !isReadOnly { - try observationBroker?.notifyChanges(in: region.databaseRegion(self)) + // of changes, and have them wait for a commit notification that + // will never come. + if !isReadOnly, let observationBroker { + let eventKinds = try region + .databaseRegion(self) + // Use canonical table names for case insensitivity of the input. + .canonicalTables(self) + .impactfulEventKinds(self) + + try observationBroker.notifyChanges(withEventsOfKind: eventKinds) } } diff --git a/GRDB/Core/TransactionObserver.swift b/GRDB/Core/TransactionObserver.swift index 44363b7803..54459cc379 100644 --- a/GRDB/Core/TransactionObserver.swift +++ b/GRDB/Core/TransactionObserver.swift @@ -282,12 +282,7 @@ class DatabaseObservationBroker { } } - func notifyChanges(in region: DatabaseRegion) throws { - // Use canonical table names for case insensitivity of the input. - let eventKinds = try region - .canonicalTables(database) - .impactfulEventKinds(database) - + func notifyChanges(withEventsOfKind eventKinds: [DatabaseEventKind]) throws { // Support for stopObservingDatabaseChangesUntilNextTransaction() SchedulingWatchdog.current!.databaseObservationBroker = self defer { From f1a8c5ac924958d0861bd918aee56c5ad5b58250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sat, 25 Nov 2023 16:49:37 +0100 Subject: [PATCH 22/28] Wording --- GRDB/Core/Database.swift | 4 ++-- GRDB/Documentation.docc/Extension/TransactionObserver.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/GRDB/Core/Database.swift b/GRDB/Core/Database.swift index 4a3d679e9b..1ee35d3e3c 100644 --- a/GRDB/Core/Database.swift +++ b/GRDB/Core/Database.swift @@ -838,10 +838,10 @@ public final class Database: CustomStringConvertible, CustomDebugStringConvertib /// /// ```swift /// try dbQueue.write { db in - /// // Notify all observers of regular tables + /// // Notify observers that some changes were performed in the database /// try db.notifyChanges(in: .fullDatabase) /// - /// // Notify all observers of the player table + /// // Notify observers that some changes were performed in the player table /// try db.notifyChanges(in: Player.all()) /// /// // Equivalent alternative diff --git a/GRDB/Documentation.docc/Extension/TransactionObserver.md b/GRDB/Documentation.docc/Extension/TransactionObserver.md index a82566fdca..7d97612860 100644 --- a/GRDB/Documentation.docc/Extension/TransactionObserver.md +++ b/GRDB/Documentation.docc/Extension/TransactionObserver.md @@ -248,10 +248,10 @@ For example: ```swift try dbQueue.write { db in - // Notify all observers of regular tables + // Notify observers that some changes were performed in the database try db.notifyChanges(in: .fullDatabase) - // Notify all observers of the player table + // Notify observers that some changes were performed in the player table try db.notifyChanges(in: Player.all()) // Equivalent alternative From a8ed98e4141f492fa812bfee32a72fbddeb84e46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sat, 25 Nov 2023 16:50:01 +0100 Subject: [PATCH 23/28] Mention undetected changes in the Database Sharing Guide --- GRDB/Documentation.docc/DatabaseSharing.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/GRDB/Documentation.docc/DatabaseSharing.md b/GRDB/Documentation.docc/DatabaseSharing.md index 3cc64120ec..1f2c3928ec 100644 --- a/GRDB/Documentation.docc/DatabaseSharing.md +++ b/GRDB/Documentation.docc/DatabaseSharing.md @@ -228,9 +228,7 @@ In applications that use the background modes supported by iOS, post `resumeNoti features are not able to detect database changes performed by other processes. -Whenever you need to notify other processes that the database has been changed, you will have to use a cross-process notification mechanism such as [NSFileCoordinator] or [CFNotificationCenterGetDarwinNotifyCenter]. - -You can trigger those notifications automatically with ``DatabaseRegionObservation``: +Whenever you need to notify other processes that the database has been changed, you will have to use a cross-process notification mechanism such as [NSFileCoordinator] or [CFNotificationCenterGetDarwinNotifyCenter]. You can trigger those notifications automatically with ``DatabaseRegionObservation``: ```swift // Notify all changes made to the database @@ -246,6 +244,8 @@ let observer = try observation.start(in: dbPool) { db in } ``` +The processes that observe the database can catch those notifications, and deal with the notified changes. See for some related techniques. + [NSFileCoordinator]: https://developer.apple.com/documentation/foundation/nsfilecoordinator [CFNotificationCenterGetDarwinNotifyCenter]: https://developer.apple.com/documentation/corefoundation/1542572-cfnotificationcentergetdarwinnot [WAL mode]: https://www.sqlite.org/wal.html From dc281cee25d0caec7261c9456ecd0957692e7781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sat, 25 Nov 2023 17:01:11 +0100 Subject: [PATCH 24/28] Cleanup tests for unspecified changes to a column --- .../GRDBTests/TransactionObserverTests.swift | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/Tests/GRDBTests/TransactionObserverTests.swift b/Tests/GRDBTests/TransactionObserverTests.swift index bf54a8ff3b..0730f42f29 100644 --- a/Tests/GRDBTests/TransactionObserverTests.swift +++ b/Tests/GRDBTests/TransactionObserverTests.swift @@ -2275,7 +2275,7 @@ class TransactionObserverTests: GRDBTestCase { func testUnspecifiedChangeToColumn() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { db in - try db.create(table: "test", options: .temporary) { t in + try db.create(table: "test") { t in t.autoIncrementedPrimaryKey("id") t.column("a") t.column("b") @@ -2284,7 +2284,28 @@ class TransactionObserverTests: GRDBTestCase { do { let observer = Observer(observes: { eventKind in - if case .update("test", ["a"]) = eventKind { + if case .update("test", let columns) = eventKind, columns.contains("a") { + return true + } + return false + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: .fullDatabase) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + if case .update("test", let columns) = eventKind, columns.contains("a") { return true } return false @@ -2295,7 +2316,7 @@ class TransactionObserverTests: GRDBTestCase { try db.notifyChanges(in: Table("test")) } - XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeCount, 1) XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) @@ -2305,7 +2326,7 @@ class TransactionObserverTests: GRDBTestCase { do { let observer = Observer(observes: { eventKind in - if case .update("test", ["a"]) = eventKind { + if case .update("test", let columns) = eventKind, columns.contains("a") { return true } return false @@ -2326,7 +2347,7 @@ class TransactionObserverTests: GRDBTestCase { do { let observer = Observer(observes: { eventKind in - if case .update("test", ["a"]) = eventKind { + if case .update("test", let columns) = eventKind, columns.contains("a") { return true } return false @@ -2348,7 +2369,7 @@ class TransactionObserverTests: GRDBTestCase { do { let observer = Observer(observes: { eventKind in - if case .update("test", ["a"]) = eventKind { + if case .update("test", let columns) = eventKind, columns.contains("a") { return true } return false From d8760253ff51ca65d3f14fd39f38ef79db43ccf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sat, 25 Nov 2023 17:10:40 +0100 Subject: [PATCH 25/28] Add missing DocC reference --- GRDB/Core/Database.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/GRDB/Core/Database.swift b/GRDB/Core/Database.swift index 1ee35d3e3c..46fca6c062 100644 --- a/GRDB/Core/Database.swift +++ b/GRDB/Core/Database.swift @@ -79,6 +79,7 @@ let SQLITE_TRANSIENT = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_ /// - ``add(transactionObserver:extent:)`` /// - ``remove(transactionObserver:)`` /// - ``afterNextTransaction(onCommit:onRollback:)`` +/// - ``notifyChanges(in:)`` /// - ``registerAccess(to:)`` /// /// ### Collations From 93462cfd2cd821b1cc8f89c7f5a36d6a1b7c5f5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sun, 26 Nov 2023 08:38:12 +0100 Subject: [PATCH 26/28] Documentation --- GRDB/Core/TransactionObserver.swift | 8 +++++ .../Extension/DatabaseRegionObservation.md | 31 +++++++++++++++---- .../Extension/TransactionObserver.md | 8 ++--- .../Extension/ValueObservation.md | 31 +++++++++++++++---- 4 files changed, 60 insertions(+), 18 deletions(-) diff --git a/GRDB/Core/TransactionObserver.swift b/GRDB/Core/TransactionObserver.swift index 54459cc379..647c14d192 100644 --- a/GRDB/Core/TransactionObserver.swift +++ b/GRDB/Core/TransactionObserver.swift @@ -580,6 +580,11 @@ class DatabaseObservationBroker { // even if we actually execute an empty deferred transaction. // // For better or for worse, let's simulate a transaction: + // + // 2023-11-26: I'm glad we did, because that's how we support calls + // to `Database.notifyChanges(in:)` from an empty transaction, as a + // way to tell transaction observers about changes performed by some + // external connection. do { try databaseWillCommit() @@ -801,6 +806,9 @@ public protocol TransactionObserver: AnyObject { /// This method allows a transaction observer to handle changes that are /// not automatically detected. See /// and ``Database/notifyChanges(in:)`` for more information. + /// + /// The exact nature of changes is unknown, but they comply to the + /// ``observes(eventsOfKind:)`` test. func databaseDidChange() /// Called when the database is changed by an insert, update, or diff --git a/GRDB/Documentation.docc/Extension/DatabaseRegionObservation.md b/GRDB/Documentation.docc/Extension/DatabaseRegionObservation.md index a07bb89708..9bfee067f8 100644 --- a/GRDB/Documentation.docc/Extension/DatabaseRegionObservation.md +++ b/GRDB/Documentation.docc/Extension/DatabaseRegionObservation.md @@ -6,12 +6,7 @@ `DatabaseRegionObservation` tracks insertions, updates, and deletions that impact the tracked region, whether performed with raw SQL, or . This includes indirect changes triggered by [foreign keys actions](https://www.sqlite.org/foreignkeys.html#fk_actions) or [SQL triggers](https://www.sqlite.org/lang_createtrigger.html). -The only changes that are not notified are: - -- Changes performed by external database connections. -- Changes performed by SQLite statements that are not compiled and executed by GRDB. -- Changes to the database schema, changes to internal system tables such as `sqlite_master`. -- Changes to [`WITHOUT ROWID`](https://www.sqlite.org/withoutrowid.html) tables. +See below for the list of exceptions. `DatabaseRegionObservation` calls your application right after changes have been committed in the database, and before any other thread had any opportunity to perform further changes. *This is a pretty strong guarantee, that most applications do not really need.* Instead, most applications prefer to be notified with fresh values: make sure you check ``ValueObservation`` before using `DatabaseRegionObservation`. @@ -79,6 +74,30 @@ let observation = DatabaseRegionObservation( tracking: .fullDatabase) ``` +## Dealing with Undetected Changes + +`DatabaseRegionObservation` will not notify impactful transactions whenever the database is modified in an undetectable way: + +- Changes performed by external database connections. +- Changes performed by SQLite statements that are not compiled and executed by GRDB. +- Changes to the database schema, changes to internal system tables such as `sqlite_master`. +- Changes to [`WITHOUT ROWID`](https://www.sqlite.org/withoutrowid.html) tables. + +To have observations notify such undetected changes, applications can take explicit action: call the ``Database/notifyChanges(in:)`` `Database` method from a write transaction: + +```swift +try dbQueue.write { db in + // Notify observations that some changes were performed in the database + try db.notifyChanges(in: .fullDatabase) + + // Notify observations that some changes were performed in the player table + try db.notifyChanges(in: Player.all()) + + // Equivalent alternative + try db.notifyChanges(in: Table("player")) +} +``` + ## Topics ### Creating DatabaseRegionObservation diff --git a/GRDB/Documentation.docc/Extension/TransactionObserver.md b/GRDB/Documentation.docc/Extension/TransactionObserver.md index 7d97612860..9815e08520 100644 --- a/GRDB/Documentation.docc/Extension/TransactionObserver.md +++ b/GRDB/Documentation.docc/Extension/TransactionObserver.md @@ -231,7 +231,7 @@ This extra API can be activated in two ways: ## Dealing with Undetected Changes -Some changes and transactions are not automatically notified to transaction observers: +The changes and transactions that are not automatically notified to transaction observers are: - Read-only transactions. - Changes and transactions performed by external database connections. @@ -240,11 +240,7 @@ Some changes and transactions are not automatically notified to transaction obse - Changes to [`WITHOUT ROWID`](https://www.sqlite.org/withoutrowid.html) tables. - The deletion of duplicate rows triggered by [`ON CONFLICT REPLACE`](https://www.sqlite.org/lang_conflict.html) clauses (this last exception might change in a future release of SQLite). -Undetected changes are notified to the ``databaseDidChange()-7olv7`` callback when you perform an explicit call to the ``Database/notifyChanges(in:)`` `Database` method. - -Event filtering described in still applies: the `databaseDidChange()` callback is not called for changes that are not observed. - -For example: +To notify undetected changes to transaction observers, perform an explicit call to the ``Database/notifyChanges(in:)`` `Database` method. The ``databaseDidChange()-7olv7`` callback will be called accordingly. For example: ```swift try dbQueue.write { db in diff --git a/GRDB/Documentation.docc/Extension/ValueObservation.md b/GRDB/Documentation.docc/Extension/ValueObservation.md index f1891e9d2d..153b81cd52 100644 --- a/GRDB/Documentation.docc/Extension/ValueObservation.md +++ b/GRDB/Documentation.docc/Extension/ValueObservation.md @@ -6,12 +6,7 @@ `ValueObservation` tracks insertions, updates, and deletions that impact the tracked value, whether performed with raw SQL, or . This includes indirect changes triggered by [foreign keys actions](https://www.sqlite.org/foreignkeys.html#fk_actions) or [SQL triggers](https://www.sqlite.org/lang_createtrigger.html). -The only changes that are not notified are: - -- Changes performed by external database connections. -- Changes performed by SQLite statements that are not compiled and executed by GRDB. -- Changes to the database schema, changes to internal system tables such as `sqlite_master`. -- Changes to [`WITHOUT ROWID`](https://www.sqlite.org/withoutrowid.html) tables. +See below for the list of exceptions. ## ValueObservation Usage @@ -175,6 +170,30 @@ let observation = ValueObservation.tracking( This ``tracking(region:_:fetch:)`` method lets you entirely separate the **observed region(s)** from the **fetched value** itself, for maximum flexibility. See ``DatabaseRegionConvertible`` for more information about the regions that can be tracked. +## Dealing with Undetected Changes + +`ValueObservation` will not fetch and notify a fresh value whenever the database is modified in an undetectable way: + +- Changes performed by external database connections. +- Changes performed by SQLite statements that are not compiled and executed by GRDB. +- Changes to the database schema, changes to internal system tables such as `sqlite_master`. +- Changes to [`WITHOUT ROWID`](https://www.sqlite.org/withoutrowid.html) tables. + +To have observations notify a fresh values after such an undetected change was performed, applications can take explicit action. For example, cancel and restart observations. Alternatively, call the ``Database/notifyChanges(in:)`` `Database` method from a write transaction: + +```swift +try dbQueue.write { db in + // Notify observations that some changes were performed in the database + try db.notifyChanges(in: .fullDatabase) + + // Notify observations that some changes were performed in the player table + try db.notifyChanges(in: Player.all()) + + // Equivalent alternative + try db.notifyChanges(in: Table("player")) +} +``` + ## ValueObservation Performance This section further describes runtime aspects of `ValueObservation`, and provides some optimization tips for demanding applications. From b671bb0faab7b4f0077871ed9a72de3912ece411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sun, 26 Nov 2023 10:37:27 +0100 Subject: [PATCH 27/28] CHANGELOG --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c491eb9af6..528c804f93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -122,6 +122,8 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception: ## Next Release - **New**: [#1452](https://github.com/groue/GRDB.swift/pull/1452) by [@groue](https://github.com/groue): SQLite 3.44.0, FILTER and ORDER BY clauses in aggregate functions +- **New**: [#1460](https://github.com/groue/GRDB.swift/pull/1460) by [@groue](https://github.com/groue): Explicit change notifications help applications deal with undetected database changes. +- **Documentation Update**: The documentations of [`ValueObservation`](https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/valueobservation), [`DatabaseRegionObservation`](https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseregionobservation), and [`TransactionObserver`](https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/transactionobserver) have a new "Dealing with Undetected Changes" that documents possible strategies for notifying applications of undetected database changes. ## 6.21.0 From fae845a986a12da4bb08e11707b696e4bf68a967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sun, 26 Nov 2023 10:48:42 +0100 Subject: [PATCH 28/28] v6.22.0 --- CHANGELOG.md | 5 ++++- GRDB.swift.podspec | 2 +- README.md | 2 +- Support/Info.plist | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 528c804f93..08e2543f85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception: #### 6.x Releases +- `6.22.x` Releases - [6.22.0](#6220) - `6.21.x` Releases - [6.21.0](#6210) - `6.20.x` Releases - [6.20.0](#6200) - [6.20.1](#6201) - [6.20.2](#6202) - `6.19.x` Releases - [6.19.0](#6190) @@ -119,7 +120,9 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception: --- -## Next Release +## 6.22.0 + +Released November 26, 2023 - **New**: [#1452](https://github.com/groue/GRDB.swift/pull/1452) by [@groue](https://github.com/groue): SQLite 3.44.0, FILTER and ORDER BY clauses in aggregate functions - **New**: [#1460](https://github.com/groue/GRDB.swift/pull/1460) by [@groue](https://github.com/groue): Explicit change notifications help applications deal with undetected database changes. diff --git a/GRDB.swift.podspec b/GRDB.swift.podspec index 8ca8dcc880..ae651ad9cc 100644 --- a/GRDB.swift.podspec +++ b/GRDB.swift.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'GRDB.swift' - s.version = '6.21.0' + s.version = '6.22.0' s.license = { :type => 'MIT', :file => 'LICENSE' } s.summary = 'A toolkit for SQLite databases, with a focus on application development.' diff --git a/README.md b/README.md index d9044de4fe..f62d22fef6 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ CI Status

-**Latest release**: October 29, 2023 • [version 6.21.0](https://github.com/groue/GRDB.swift/tree/v6.21.0) • [CHANGELOG](CHANGELOG.md) • [Migrating From GRDB 5 to GRDB 6](Documentation/GRDB6MigrationGuide.md) +**Latest release**: November 26, 2023 • [version 6.22.0](https://github.com/groue/GRDB.swift/tree/v6.22.0) • [CHANGELOG](CHANGELOG.md) • [Migrating From GRDB 5 to GRDB 6](Documentation/GRDB6MigrationGuide.md) **Requirements**: iOS 11.0+ / macOS 10.13+ / tvOS 11.0+ / watchOS 4.0+ • SQLite 3.19.3+ • Swift 5.7+ / Xcode 14+ diff --git a/Support/Info.plist b/Support/Info.plist index f7a10172d7..30ecbeebfb 100644 --- a/Support/Info.plist +++ b/Support/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 6.21.0 + 6.22.0 CFBundleSignature ???? CFBundleVersion