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 @@
-**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