diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fc36ccc5f..5ea4253475 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception: #### 5.x Releases -- `5.7.x` Releases - [5.7.0](#570) | [5.7.1](#571) | [5.7.2](#572) +- `5.7.x` Releases - [5.7.0](#570) | [5.7.1](#571) | [5.7.2](#572) | [5.7.3](#573) - `5.6.x` Releases - [5.6.0](#560) - `5.5.x` Releases - [5.5.0](#550) - `5.4.x` Releases - [5.4.0](#540) @@ -74,6 +74,15 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception: --- +## 5.7.3 + +Released April 5, 2021 • [diff](https://github.com/groue/GRDB.swift/compare/v5.7.2...v5.7.3) + +- **Fixed**: [#950](https://github.com/groue/GRDB.swift/pull/950) by [@MartinP7r](https://github.com/MartinP7r): Fix memory consumption when encoding JSON columns +- **Fixed**: [#951](https://github.com/groue/GRDB.swift/pull/951) by [@alexwlchan](https://github.com/alexwlchan): Fix documentation typo +- **Fixed**: [#952](https://github.com/groue/GRDB.swift/pull/952) by [@holsety](https://github.com/holsety): Fix documentation typo +- **Documentation Update**: [#953](https://github.com/groue/GRDB.swift/pull/953): Refactor the SwiftUI demo app + #### 5.7.2 - **Fixed**: Really fix breaking change and restore `SQLLiteral` as a deprecated alias for `SQL`. diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo.xcodeproj/project.pbxproj b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo.xcodeproj/project.pbxproj index 853e801c34..cca13534bb 100644 --- a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo.xcodeproj/project.pbxproj +++ b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo.xcodeproj/project.pbxproj @@ -9,6 +9,9 @@ /* Begin PBXBuildFile section */ 56026CAC25B8A7EF00D1DF3F /* PlayerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56026CAA25B8A7EF00D1DF3F /* PlayerTests.swift */; }; 56026CAD25B8A7EF00D1DF3F /* AppDatabaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56026CAB25B8A7EF00D1DF3F /* AppDatabaseTests.swift */; }; + 5671722A261A185300423B6F /* Query.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56717229261A185300423B6F /* Query.swift */; }; + 5671723A261B23C800423B6F /* PlayerList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56717239261B23C800423B6F /* PlayerList.swift */; }; + 56717252261B334D00423B6F /* PlayerRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56717251261B334D00423B6F /* PlayerRequestTests.swift */; }; 567C3E1A2520B6DE0011F6E9 /* GRDBCombineDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E192520B6DE0011F6E9 /* GRDBCombineDemoApp.swift */; }; 567C3E1E2520B6DF0011F6E9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 567C3E1D2520B6DF0011F6E9 /* Assets.xcassets */; }; 567C3E212520B6DF0011F6E9 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 567C3E202520B6DF0011F6E9 /* Preview Assets.xcassets */; }; @@ -16,15 +19,14 @@ 567C3E4F2520B70E0011F6E9 /* GRDB.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 567C3E3B2520B7000011F6E9 /* GRDB.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 567C3E5D2520B75C0011F6E9 /* Player.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E532520B75C0011F6E9 /* Player.swift */; }; 567C3E5E2520B75C0011F6E9 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E542520B75C0011F6E9 /* Persistence.swift */; }; - 567C3E5F2520B75C0011F6E9 /* PlayerListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E562520B75C0011F6E9 /* PlayerListViewModel.swift */; }; - 567C3E602520B75C0011F6E9 /* PlayerFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E572520B75C0011F6E9 /* PlayerFormViewModel.swift */; }; - 567C3E612520B75D0011F6E9 /* PlayerForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E592520B75C0011F6E9 /* PlayerForm.swift */; }; - 567C3E622520B75D0011F6E9 /* PlayerList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E5A2520B75C0011F6E9 /* PlayerList.swift */; }; - 567C3E632520B75D0011F6E9 /* PlayerCreationSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E5B2520B75C0011F6E9 /* PlayerCreationSheet.swift */; }; + 567C3E612520B75D0011F6E9 /* PlayerFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E592520B75C0011F6E9 /* PlayerFormView.swift */; }; + 567C3E622520B75D0011F6E9 /* AppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E5A2520B75C0011F6E9 /* AppView.swift */; }; + 567C3E632520B75D0011F6E9 /* PlayerCreationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E5B2520B75C0011F6E9 /* PlayerCreationView.swift */; }; 567C3E642520B75D0011F6E9 /* PlayerEditionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E5C2520B75C0011F6E9 /* PlayerEditionView.swift */; }; 567C3E662520B7880011F6E9 /* AppDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E652520B7880011F6E9 /* AppDatabase.swift */; }; 567C3E792520BB650011F6E9 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 567C3E752520BB650011F6E9 /* Localizable.stringsdict */; }; 567C3E7A2520BB650011F6E9 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 567C3E772520BB650011F6E9 /* LaunchScreen.storyboard */; }; + 56B6D1092619EC1B003CC455 /* PlayerRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B6D1082619EC1B003CC455 /* PlayerRequest.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -119,6 +121,9 @@ 56026C9C25B8A7D000D1DF3F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 56026CAA25B8A7EF00D1DF3F /* PlayerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerTests.swift; sourceTree = ""; }; 56026CAB25B8A7EF00D1DF3F /* AppDatabaseTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDatabaseTests.swift; sourceTree = ""; }; + 56717229261A185300423B6F /* Query.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Query.swift; sourceTree = ""; }; + 56717239261B23C800423B6F /* PlayerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerList.swift; sourceTree = ""; }; + 56717251261B334D00423B6F /* PlayerRequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRequestTests.swift; sourceTree = ""; }; 567C3E162520B6DE0011F6E9 /* GRDBCombineDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GRDBCombineDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 567C3E192520B6DE0011F6E9 /* GRDBCombineDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRDBCombineDemoApp.swift; sourceTree = ""; }; 567C3E1D2520B6DF0011F6E9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -127,15 +132,14 @@ 567C3E292520B7000011F6E9 /* GRDB.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = GRDB.xcodeproj; path = ../../../GRDB.xcodeproj; sourceTree = ""; }; 567C3E532520B75C0011F6E9 /* Player.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Player.swift; sourceTree = ""; }; 567C3E542520B75C0011F6E9 /* Persistence.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; - 567C3E562520B75C0011F6E9 /* PlayerListViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerListViewModel.swift; sourceTree = ""; }; - 567C3E572520B75C0011F6E9 /* PlayerFormViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerFormViewModel.swift; sourceTree = ""; }; - 567C3E592520B75C0011F6E9 /* PlayerForm.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerForm.swift; sourceTree = ""; }; - 567C3E5A2520B75C0011F6E9 /* PlayerList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerList.swift; sourceTree = ""; }; - 567C3E5B2520B75C0011F6E9 /* PlayerCreationSheet.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerCreationSheet.swift; sourceTree = ""; }; + 567C3E592520B75C0011F6E9 /* PlayerFormView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerFormView.swift; sourceTree = ""; }; + 567C3E5A2520B75C0011F6E9 /* AppView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppView.swift; sourceTree = ""; }; + 567C3E5B2520B75C0011F6E9 /* PlayerCreationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerCreationView.swift; sourceTree = ""; }; 567C3E5C2520B75C0011F6E9 /* PlayerEditionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerEditionView.swift; sourceTree = ""; }; 567C3E652520B7880011F6E9 /* AppDatabase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDatabase.swift; sourceTree = ""; }; 567C3E762520BB650011F6E9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = ""; }; 567C3E782520BB650011F6E9 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 56B6D1082619EC1B003CC455 /* PlayerRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRequest.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -162,6 +166,7 @@ children = ( 56026C9C25B8A7D000D1DF3F /* Info.plist */, 56026CAB25B8A7EF00D1DF3F /* AppDatabaseTests.swift */, + 56717251261B334D00423B6F /* PlayerRequestTests.swift */, 56026CAA25B8A7EF00D1DF3F /* PlayerTests.swift */, ); path = GRDBCombineDemoTests; @@ -212,10 +217,11 @@ 567C3E192520B6DE0011F6E9 /* GRDBCombineDemoApp.swift */, 567C3E542520B75C0011F6E9 /* Persistence.swift */, 567C3E532520B75C0011F6E9 /* Player.swift */, + 56B6D1082619EC1B003CC455 /* PlayerRequest.swift */, + 56717229261A185300423B6F /* Query.swift */, 567C3E1F2520B6DF0011F6E9 /* Preview Content */, 56185BC125B8047D00B9C30F /* Resources */, 56185BC225B8048B00B9C30F /* Support */, - 567C3E552520B75C0011F6E9 /* ViewModels */, 567C3E582520B75C0011F6E9 /* Views */, ); path = GRDBCombineDemo; @@ -251,22 +257,14 @@ name = Frameworks; sourceTree = ""; }; - 567C3E552520B75C0011F6E9 /* ViewModels */ = { - isa = PBXGroup; - children = ( - 567C3E562520B75C0011F6E9 /* PlayerListViewModel.swift */, - 567C3E572520B75C0011F6E9 /* PlayerFormViewModel.swift */, - ); - path = ViewModels; - sourceTree = ""; - }; 567C3E582520B75C0011F6E9 /* Views */ = { isa = PBXGroup; children = ( - 567C3E592520B75C0011F6E9 /* PlayerForm.swift */, - 567C3E5A2520B75C0011F6E9 /* PlayerList.swift */, - 567C3E5B2520B75C0011F6E9 /* PlayerCreationSheet.swift */, + 567C3E5A2520B75C0011F6E9 /* AppView.swift */, + 567C3E5B2520B75C0011F6E9 /* PlayerCreationView.swift */, 567C3E5C2520B75C0011F6E9 /* PlayerEditionView.swift */, + 567C3E592520B75C0011F6E9 /* PlayerFormView.swift */, + 56717239261B23C800423B6F /* PlayerList.swift */, ); path = Views; sourceTree = ""; @@ -441,6 +439,7 @@ files = ( 56026CAC25B8A7EF00D1DF3F /* PlayerTests.swift in Sources */, 56026CAD25B8A7EF00D1DF3F /* AppDatabaseTests.swift in Sources */, + 56717252261B334D00423B6F /* PlayerRequestTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -448,16 +447,17 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5671722A261A185300423B6F /* Query.swift in Sources */, 567C3E5E2520B75C0011F6E9 /* Persistence.swift in Sources */, 567C3E5D2520B75C0011F6E9 /* Player.swift in Sources */, - 567C3E612520B75D0011F6E9 /* PlayerForm.swift in Sources */, - 567C3E632520B75D0011F6E9 /* PlayerCreationSheet.swift in Sources */, + 56B6D1092619EC1B003CC455 /* PlayerRequest.swift in Sources */, + 5671723A261B23C800423B6F /* PlayerList.swift in Sources */, + 567C3E612520B75D0011F6E9 /* PlayerFormView.swift in Sources */, + 567C3E632520B75D0011F6E9 /* PlayerCreationView.swift in Sources */, 567C3E662520B7880011F6E9 /* AppDatabase.swift in Sources */, - 567C3E622520B75D0011F6E9 /* PlayerList.swift in Sources */, + 567C3E622520B75D0011F6E9 /* AppView.swift in Sources */, 567C3E642520B75D0011F6E9 /* PlayerEditionView.swift in Sources */, 567C3E1A2520B6DE0011F6E9 /* GRDBCombineDemoApp.swift in Sources */, - 567C3E5F2520B75C0011F6E9 /* PlayerListViewModel.swift in Sources */, - 567C3E602520B75C0011F6E9 /* PlayerFormViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/AppDatabase.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/AppDatabase.swift index e1a5acb2b7..018ae34e4f 100644 --- a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/AppDatabase.swift +++ b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/AppDatabase.swift @@ -54,9 +54,25 @@ struct AppDatabase { // MARK: - Database Access: Writes extension AppDatabase { + /// A validation error that prevents some players from being saved into + /// the database. + enum ValidationError: LocalizedError { + case missingName + + var errorDescription: String? { + switch self { + case .missingName: + return "Please provide a name" + } + } + } + /// Saves (inserts or updates) a player. When the method returns, the /// player is present in the database, and its id is not nil. func savePlayer(_ player: inout Player) throws { + if player.name.isEmpty { + throw ValidationError.missingName + } try dbWriter.write { db in try player.save(db) } @@ -125,19 +141,8 @@ extension AppDatabase { // MARK: - Database Access: Reads extension AppDatabase { - /// Returns a publisher that tracks changes in players ordered by name - func playersOrderedByNamePublisher() -> AnyPublisher<[Player], Error> { - ValueObservation - .tracking(Player.all().orderedByName().fetchAll) - .publisher(in: dbWriter, scheduling: .immediate) - .eraseToAnyPublisher() - } - - /// Returns a publisher that tracks changes in players ordered by score - func playersOrderedByScorePublisher() -> AnyPublisher<[Player], Error> { - ValueObservation - .tracking(Player.all().orderedByScore().fetchAll) - .publisher(in: dbWriter, scheduling: .immediate) - .eraseToAnyPublisher() + /// Provides a read-only access to the database + var databaseReader: DatabaseReader { + dbWriter } } diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/GRDBCombineDemoApp.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/GRDBCombineDemoApp.swift index 224200cecc..aef733adec 100644 --- a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/GRDBCombineDemoApp.swift +++ b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/GRDBCombineDemoApp.swift @@ -1,12 +1,23 @@ +import GRDB import SwiftUI @main struct GRDBCombineDemoApp: App { - let appDatabase = AppDatabase.shared - var body: some Scene { WindowGroup { - PlayerList(viewModel: PlayerListViewModel(database: appDatabase)) + AppView().environment(\.appDatabase, AppDatabase.shared) } } } + +// Let SwiftUI views access the database through the SwiftUI environment +private struct AppDatabaseKey: EnvironmentKey { + static let defaultValue: AppDatabase? = nil +} + +extension EnvironmentValues { + var appDatabase: AppDatabase? { + get { self[AppDatabaseKey.self] } + set { self[AppDatabaseKey.self] = newValue } + } +} diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/PlayerRequest.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/PlayerRequest.swift new file mode 100644 index 0000000000..7950554804 --- /dev/null +++ b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/PlayerRequest.swift @@ -0,0 +1,23 @@ +import GRDB + +/// A player request defines how to feed the player list +struct PlayerRequest { + enum Ordering { + case byScore + case byName + } + + var ordering: Ordering +} + +/// Make `PlayerRequest` able to be used with the `@Query` property wrapper. +extension PlayerRequest: Queryable { + static var defaultValue: [Player] { [] } + + func fetchValue(_ db: Database) throws -> [Player] { + switch ordering { + case .byScore: return try Player.all().orderedByScore().fetchAll(db) + case .byName: return try Player.all().orderedByName().fetchAll(db) + } + } +} diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Query.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Query.swift new file mode 100644 index 0000000000..083227e006 --- /dev/null +++ b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Query.swift @@ -0,0 +1,109 @@ +// +// Query.swift +// +// A property wrapper inspired from +// https://davedelong.com/blog/2021/04/03/core-data-and-swiftui/ +// + +import Combine +import GRDB +import SwiftUI + +/// The protocol that feeds the `@Query` property wrapper. +protocol Queryable: Equatable { + /// The type of the fetched value + associatedtype Value + + /// The default value, used whenever the database is not available + static var defaultValue: Value { get } + + /// Fetches the database value + func fetchValue(_ db: Database) throws -> Value +} + +/// The property wrapper that observes a database query +@propertyWrapper +struct Query: DynamicProperty { + /// The database reader that makes it possible to observe the database + @Environment(\.appDatabase?.databaseReader) private var databaseReader: DatabaseReader? + @StateObject private var core = Core() + private var baseQuery: Query + + /// The fetched value + var wrappedValue: Query.Value { + core.value ?? Query.defaultValue + } + + /// A binding to the query, that lets your views modify it. + /// + /// This is how the demo app changes the player ordering. + var projectedValue: Binding { + Binding( + get: { core.query ?? baseQuery }, + set: { core.query = $0 }) + } + + init(_ query: Query) { + baseQuery = query + } + + func update() { + guard let databaseReader = databaseReader else { + fatalError("Attempting to use @Query without any database in the environment") + } + // Feed core with necessary information, and make sure tracking has started + if core.query == nil { core.query = baseQuery } + core.startTrackingIfNecessary(in: databaseReader) + } + + private class Core: ObservableObject { + private(set) var value: Query.Value? + var databaseReader: DatabaseReader? + var query: Query? { + willSet { + if query != newValue { + // Stop tracking, and tell SwiftUI about the update + objectWillChange.send() + cancellable = nil + } + } + } + private var cancellable: AnyCancellable? + + init() { } + + func startTrackingIfNecessary(in databaseReader: DatabaseReader) { + if databaseReader !== self.databaseReader { + // Database has changed. Stop tracking. + self.databaseReader = databaseReader + cancellable = nil + } + + guard let query = query else { + // No query set + return + } + + guard cancellable == nil else { + // Already tracking + return + } + + cancellable = ValueObservation + .tracking(query.fetchValue) + .publisher( + in: databaseReader, + scheduling: .immediate) + .sink( + receiveCompletion: { _ in + // Ignore errors + }, + receiveValue: { [weak self] value in + guard let self = self else { return } + // Tell SwiftUI about the new value + self.objectWillChange.send() + self.value = value + }) + } + } +} diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/ViewModels/PlayerFormViewModel.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/ViewModels/PlayerFormViewModel.swift deleted file mode 100644 index 169a73cb99..0000000000 --- a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/ViewModels/PlayerFormViewModel.swift +++ /dev/null @@ -1,67 +0,0 @@ -import Combine -import Foundation - -/// The view model that validates and saves an edited player into the database. -/// -/// It feeds `PlayerForm`, `PlayerCreationSheet` and `PlayerEditionView`. -final class PlayerFormViewModel: ObservableObject { - /// A validation error that prevents the player from being saved into - /// the database. - enum ValidationError: LocalizedError { - case missingName - - var errorDescription: String? { - switch self { - case .missingName: - return "Please give a name to this player." - } - } - } - - @Published var name: String = "" - @Published var score: String = "" - - private let database: AppDatabase - private var player: Player - - init(database: AppDatabase, player: Player) { - self.database = database - self.player = player - updateViewFromPlayer() - } - - // MARK: - Manage the Player Form - - /// Validates and saves the player into the database. - func savePlayer() throws { - if name.isEmpty { - throw ValidationError.missingName - } - player.name = name - player.score = Int(score) ?? 0 - try database.savePlayer(&player) - } - - /// Resets form values to the original player values. - func reset() { - updateViewFromPlayer() - } - - /// Edits a new player - func editNewPlayer() { - player = .new() - updateViewFromPlayer() - } - - // MARK: - Private - - private func updateViewFromPlayer() { - self.name = player.name - if player.score == 0 && player.id == nil { - // Avoid displaying "0" for a new player: it does not look good. - self.score = "" - } else { - self.score = "\(player.score)" - } - } -} diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/ViewModels/PlayerListViewModel.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/ViewModels/PlayerListViewModel.swift deleted file mode 100644 index 22dd15c5e5..0000000000 --- a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/ViewModels/PlayerListViewModel.swift +++ /dev/null @@ -1,118 +0,0 @@ -import Combine -import Foundation - -/// The view model that feeds `PlayerList`, and performs list modifications -/// in the database. -final class PlayerListViewModel: ObservableObject { - enum Ordering { - case byScore - case byName - } - - struct PlayerList { - var players: [Player] - var animatedChanges: Bool - } - - /// The list ordering - @Published var ordering: Ordering = .byScore - - /// The players in the list - @Published var playerList = PlayerList(players: [], animatedChanges: false) - - /// The view model that edits a new player - let newPlayerViewModel: PlayerFormViewModel - - private let database: AppDatabase - private var playersCancellable: AnyCancellable? - - init(database: AppDatabase) { - self.database = database - newPlayerViewModel = PlayerFormViewModel(database: database, player: .new()) - playersCancellable = playersPublisher(in: database) - .scan(nil) { (previousList: PlayerList?, players: [Player]) in - if previousList == nil { - // Do not animate first view update - return PlayerList(players: players, animatedChanges: false) - } else { - return PlayerList(players: players, animatedChanges: true) - } - } - .compactMap { $0 } - .sink { [weak self] playerList in - self?.playerList = playerList - } - } - - // MARK: - Players List Management - - /// Deletes all players - func deleteAllPlayers() { - // Eventual error presentation is left as an exercise for the reader. - try! database.deleteAllPlayers() - } - - func deletePlayers(atOffsets offsets: IndexSet) { - // Eventual error presentation is left as an exercise for the reader. - let playerIDs = offsets.compactMap { playerList.players[$0].id } - try! database.deletePlayers(ids: playerIDs) - } - - /// Refreshes the list of players - func refreshPlayers() { - // Eventual error presentation is left as an exercise for the reader. - try! database.refreshPlayers() - } - - /// Spawns many concurrent database updates, for demo purpose - func stressTest() { - for _ in 0..<50 { - DispatchQueue.global().async { - self.refreshPlayers() - } - } - } - - // MARK: - Change Player Ordering - - /// Toggles between the available orderings - func toggleOrdering() { - switch ordering { - case .byName: - ordering = .byScore - case .byScore: - ordering = .byName - } - } - - // MARK: - Player Edition - - /// Returns a view model suitable for editing a player. - func formViewModel(for player: Player) -> PlayerFormViewModel { - PlayerFormViewModel(database: database, player: player) - } - - // MARK: - Private - - /// Returns a publisher of the players in the list - private func playersPublisher(in database: AppDatabase) -> AnyPublisher<[Player], Never> { - // Players depend on the current ordering - $ordering.map { ordering -> AnyPublisher<[Player], Error> in - switch ordering { - case .byScore: - return database.playersOrderedByScorePublisher() - case .byName: - return database.playersOrderedByNamePublisher() - } - } - .map { playersPublisher in - // Turn database errors into an empty players list. - // Eventual error presentation is left as an exercise for the reader. - playersPublisher.catch { error in - Just<[Player]>([]) - } - } - .switchToLatest() - .eraseToAnyPublisher() - } -} diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/AppView.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/AppView.swift new file mode 100644 index 0000000000..2e4d911340 --- /dev/null +++ b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/AppView.swift @@ -0,0 +1,92 @@ +import SwiftUI + +/// The main application view +struct AppView: View { + /// Database access + @Environment(\.appDatabase) private var appDatabase + + /// The `players` property is kept up-to-date with the list of players. + @Query(PlayerRequest(ordering: .byScore)) private var players: [Player] + + /// Tracks the presentation of the player creation sheet. + @State private var newPlayerIsPresented = false + + var body: some View { + NavigationView { + PlayerList(players: players) + .navigationBarTitle(Text("\(players.count) Players")) + .navigationBarItems( + leading: HStack { + EditButton() + newPlayerButton + }, + trailing: ToggleOrderingButton(ordering: $players.ordering)) + .toolbar { toolbarContent } + } + } + + private var toolbarContent: some ToolbarContent { + ToolbarItemGroup(placement: .bottomBar) { + Button( + action: { try? appDatabase?.deleteAllPlayers() }, + label: { Image(systemName: "trash").imageScale(.large) }) + Spacer() + Button( + action: { try? appDatabase?.refreshPlayers() }, + label: { Image(systemName: "arrow.clockwise").imageScale(.large) }) + } + } + + /// The button that presents the player creation sheet. + private var newPlayerButton: some View { + Button( + action: { newPlayerIsPresented = true }, + label: { Image(systemName: "plus") }) + .sheet( + isPresented: $newPlayerIsPresented, + content: { + PlayerCreationView(dismissAction: { + newPlayerIsPresented = false + }) + }) + } +} + +private struct ToggleOrderingButton: View { + @Binding var ordering: PlayerRequest.Ordering + + var body: some View { + switch ordering { + case .byName: + Button( + action: { ordering = .byScore }, + label: { + HStack { + Text("Name") + Image(systemName: "arrowtriangle.up.fill") + } + }) + case .byScore: + Button( + action: { ordering = .byName }, + label: { + HStack { + Text("Score") + Image(systemName: "arrowtriangle.down.fill") + } + }) + } + } +} + +struct AppView_Previews: PreviewProvider { + static var previews: some View { + Group { + // Preview a database of random players + AppView().environment(\.appDatabase, .random()) + + // Preview an empty database + AppView().environment(\.appDatabase, .empty()) + } + } +} diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerCreationSheet.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerCreationView.swift similarity index 65% rename from Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerCreationSheet.swift rename to Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerCreationView.swift index 1adc4c698d..122e0811d6 100644 --- a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerCreationSheet.swift +++ b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerCreationView.swift @@ -1,36 +1,37 @@ import SwiftUI -/// The Player creation sheet -struct PlayerCreationSheet: View { - /// Manages the player form - let viewModel: PlayerFormViewModel - +/// The view that creates a new player. +struct PlayerCreationView: View { /// Executed when user cancels or saves the new user. let dismissAction: () -> Void + @Environment(\.appDatabase) private var appDatabase + @State private var name = "" + @State private var score = "" @State private var errorAlertIsPresented = false @State private var errorAlertTitle = "" var body: some View { NavigationView { - PlayerForm(viewModel: viewModel) + PlayerFormView(name: $name, score: $score) .alert( isPresented: $errorAlertIsPresented, content: { Alert(title: Text(errorAlertTitle)) }) .navigationBarTitle("New Player") .navigationBarItems( leading: Button( - action: self.dismissAction, + action: dismissAction, label: { Text("Cancel") }), trailing: Button( - action: self.save, + action: save, label: { Text("Save") })) } } private func save() { do { - try viewModel.savePlayer() + var player = Player(id: nil, name: name, score: Int(score) ?? 0) + try appDatabase?.savePlayer(&player) dismissAction() } catch { errorAlertTitle = (error as? LocalizedError)?.errorDescription ?? "An error occurred" @@ -41,12 +42,7 @@ struct PlayerCreationSheet: View { struct PlayerCreationSheet_Previews: PreviewProvider { static var previews: some View { - let viewModel = PlayerFormViewModel( - database: .empty(), - player: .new()) - - return PlayerCreationSheet( - viewModel: viewModel, - dismissAction: { }) + PlayerCreationView(dismissAction: { }) + .environment(\.appDatabase, .empty()) } } diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerEditionView.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerEditionView.swift index 85652a52c4..eff839e2f9 100644 --- a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerEditionView.swift +++ b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerEditionView.swift @@ -1,28 +1,27 @@ import SwiftUI -/// The Player edition view, designed to be the destination of -/// a NavigationLink. +/// The view that edits an existing player. struct PlayerEditionView: View { - /// Manages the player form - let viewModel: PlayerFormViewModel + @Environment(\.appDatabase) private var appDatabase + @State var player: Player var body: some View { - PlayerForm(viewModel: viewModel) - .onDisappear(perform: { - // Ignore validation errors - try? self.viewModel.savePlayer() - }) + PlayerFormView( + name: $player.name, + score: Binding( + get: { "\(player.score)" }, + set: { player.score = Int($0) ?? 0 })) + .onDisappear { + // save and ignore error + try? appDatabase?.savePlayer(&player) + } } } struct PlayerEditionView_Previews: PreviewProvider { static var previews: some View { - let viewModel = PlayerFormViewModel( - database: .empty(), - player: .newRandom()) - - return NavigationView { - PlayerEditionView(viewModel: viewModel) + NavigationView { + PlayerEditionView(player: Player.newRandom()) .navigationBarTitle("Player Edition") } } diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerForm.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerForm.swift deleted file mode 100644 index b780d195b3..0000000000 --- a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerForm.swift +++ /dev/null @@ -1,39 +0,0 @@ -import SwiftUI - -/// The Player editing form, embedded in both -/// `PlayerCreationSheet` and `PlayerEditionView`. -struct PlayerForm: View { - /// Manages the player form - @ObservedObject var viewModel: PlayerFormViewModel - - var body: some View { - List { - TextField("Name", text: $viewModel.name) - TextField("Score", text: $viewModel.score) - .keyboardType(.numberPad) - } - .listStyle(GroupedListStyle()) - // Make sure the form is reset, in case a previous edition ended - // with a validation error. - // - // The bug we want to prevent is the following: - // - // 1. Launch the app - // 2. Tap a player - // 3. Erase the name so that validation fails - // 4. Hit the back button - // 5. Tap the same player - // 6. Bug: the form displays an empty name. - .onAppear(perform: viewModel.reset) - } -} - -struct PlayerFormView_Previews: PreviewProvider { - static var previews: some View { - let viewModel = PlayerFormViewModel( - database: .empty(), - player: .newRandom()) - - return PlayerForm(viewModel: viewModel) - } -} diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerFormView.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerFormView.swift new file mode 100644 index 0000000000..aa35e2a70d --- /dev/null +++ b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerFormView.swift @@ -0,0 +1,29 @@ +import SwiftUI + +/// The Player editing form, embedded in both +/// `PlayerCreationView` and `PlayerEditionView`. +struct PlayerFormView: View { + @Binding var name: String + @Binding var score: String + + var body: some View { + List { + TextField("Name", text: $name) + TextField("Score", text: $score).keyboardType(.numberPad) + } + .listStyle(InsetGroupedListStyle()) + } +} + +struct PlayerFormView_Previews: PreviewProvider { + static var previews: some View { + Group { + PlayerFormView( + name: .constant(""), + score: .constant("")) + PlayerFormView( + name: .constant(Player.randomName()), + score: .constant("\(Player.randomScore())")) + } + } +} diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerList.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerList.swift index 654cb3354d..76dbe3783f 100644 --- a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerList.swift +++ b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerList.swift @@ -1,117 +1,35 @@ import SwiftUI -/// The list of players struct PlayerList: View { - /// Manages the list of players - @ObservedObject var viewModel: PlayerListViewModel + /// Database access + @Environment(\.appDatabase) private var appDatabase - /// Controls the presentation of the player creation sheet. - @State private var newPlayerIsPresented = false + /// The players in the list + var players: [Player] var body: some View { - NavigationView { - VStack { - playerList - toolbar - } - .navigationBarTitle(Text("\(viewModel.playerList.players.count) Players")) - .navigationBarItems( - leading: HStack { - EditButton() - newPlayerButton - }, - trailing: toggleOrderingButton) - } - } - - private var playerList: some View { List { - ForEach(viewModel.playerList.players) { player in - NavigationLink(destination: self.editionView(for: player)) { - PlayerRow(player: player) - .animation(nil) + ForEach(players) { player in + NavigationLink(destination: editionView(for: player)) { + PlayerRow(player: player).animation(nil) } } .onDelete(perform: { offsets in - self.viewModel.deletePlayers(atOffsets: offsets) + let playerIds = offsets.compactMap { players[$0].id } + try? appDatabase?.deletePlayers(ids: playerIds) }) } + .animation(.default) .listStyle(PlainListStyle()) - .animation(viewModel.playerList.animatedChanges ? .default : nil) - } - - private var toolbar: some View { - HStack { - Button( - action: viewModel.deleteAllPlayers, - label: { Image(systemName: "trash").imageScale(.large) }) - Spacer() - Button( - action: viewModel.refreshPlayers, - label: { Image(systemName: "arrow.clockwise").imageScale(.large) }) - Spacer() - Button( - action: viewModel.stressTest, - label: { Image(systemName: "tornado").imageScale(.large) }) - } - - .padding() - } - - /// The button that toggles between name/score ordering. - private var toggleOrderingButton: some View { - switch viewModel.ordering { - case .byName: - return Button(action: viewModel.toggleOrdering, label: { - HStack { - Text("Name") - Image(systemName: "arrowtriangle.up.fill") - .imageScale(.small) - } - }) - case .byScore: - return Button(action: viewModel.toggleOrdering, label: { - HStack { - Text("Score") - Image(systemName: "arrowtriangle.down.fill") - .imageScale(.small) - } - }) - } } /// The view that edits a player in the list. private func editionView(for player: Player) -> some View { - PlayerEditionView( - viewModel: viewModel.formViewModel(for: player)) - .navigationBarTitle(player.name) - } - - /// The button that presents the player creation sheet. - private var newPlayerButton: some View { - Button( - action: { - // Make sure we do not edit a previously created player. - self.viewModel.newPlayerViewModel.editNewPlayer() - self.newPlayerIsPresented = true - }, - label: { Image(systemName: "plus").imageScale(.large) }) - .sheet( - isPresented: $newPlayerIsPresented, - content: { self.newPlayerCreationSheet }) - } - - /// The player creation sheet. - private var newPlayerCreationSheet: some View { - PlayerCreationSheet( - viewModel: self.viewModel.newPlayerViewModel, - dismissAction: { - self.newPlayerIsPresented = false - }) + PlayerEditionView(player: player).navigationBarTitle(player.name) } } -struct PlayerRow: View { +private struct PlayerRow: View { var player: Player var body: some View { @@ -123,9 +41,14 @@ struct PlayerRow: View { } } -struct PlayerListView_Previews: PreviewProvider { +struct PlayerList_Previews: PreviewProvider { static var previews: some View { - let viewModel = PlayerListViewModel(database: .random()) - return PlayerList(viewModel: viewModel) + NavigationView { + PlayerList(players: [ + Player(id: 1, name: "Arthur", score: 100), + Player(id: 2, name: "Barbara", score: 1000), + ]) + .navigationTitle("Preview") + } } } diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemoTests/AppDatabaseTests.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemoTests/AppDatabaseTests.swift index 20e319249f..78d21fb12a 100644 --- a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemoTests/AppDatabaseTests.swift +++ b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemoTests/AppDatabaseTests.swift @@ -143,112 +143,4 @@ class AppDatabaseTests: XCTestCase { let players = try dbQueue.read(Player.fetchAll) XCTAssertEqual(players, [player]) } - - func test_playersOrderedByNamePublisher_publishes_well_ordered_players() throws { - // Given a players database that contains two players - let dbQueue = DatabaseQueue() - let appDatabase = try AppDatabase(dbQueue) - var player1 = Player(id: nil, name: "Arthur", score: 100) - var player2 = Player(id: nil, name: "Barbara", score: 1000) - try dbQueue.write { db in - try player1.insert(db) - try player2.insert(db) - } - - // When we observe players and wait for the first value - let exp = expectation(description: "Players") - var players: [Player]? - let cancellable = appDatabase.playersOrderedByNamePublisher().sink { completion in - if case let .failure(error) = completion { - XCTFail("Unexpected error \(error)") - } - } receiveValue: { - players = $0 - exp.fulfill() - } - withExtendedLifetime(cancellable) { - waitForExpectations(timeout: 1, handler: nil) - } - - // Then the players are the two players ordered by name - XCTAssertEqual(players, [player1, player2]) - } - - func test_playersOrderedByNamePublisher_publishes_right_on_subscripion() throws { - // Our SwiftUI views have no "waiting" state, and must be fed with - // players without any delay in order to avoid any rendering glitch. - // This test makes sure `playersOrderedByNamePublisher` publishes right - // on subscription. - - // Given a players database - let dbQueue = DatabaseQueue() - let appDatabase = try AppDatabase(dbQueue) - - // When we observe players - var players: [Player]? - _ = appDatabase.playersOrderedByNamePublisher().sink { completion in - if case let .failure(error) = completion { - XCTFail("Unexpected error \(error)") - } - } receiveValue: { - players = $0 - } - - // Then the players are published right on subscription - XCTAssertNotNil(players) - } - - func test_playersOrderedByScorePublisher_publishes_well_ordered_players() throws { - // Given a players database that contains two players - let dbQueue = DatabaseQueue() - let appDatabase = try AppDatabase(dbQueue) - var player1 = Player(id: nil, name: "Arthur", score: 100) - var player2 = Player(id: nil, name: "Barbara", score: 1000) - try dbQueue.write { db in - try player1.insert(db) - try player2.insert(db) - } - - // When we observe players and wait for the first value - let exp = expectation(description: "Players") - var players: [Player]? - let cancellable = appDatabase.playersOrderedByScorePublisher().sink { completion in - if case let .failure(error) = completion { - XCTFail("Unexpected error \(error)") - } - } receiveValue: { - players = $0 - exp.fulfill() - } - withExtendedLifetime(cancellable) { - waitForExpectations(timeout: 1, handler: nil) - } - - // Then the players are the two players ordered by score descending - XCTAssertEqual(players, [player2, player1]) - } - - func test_playersOrderedByScorePublisher_publishes_right_on_subscripion() throws { - // Our SwiftUI views have no "waiting" state, and must be fed with - // players without any delay in order to avoid any rendering glitch. - // This test makes sure `playersOrderedByScorePublisher` publishes right - // on subscription. - - // Given a players database - let dbQueue = DatabaseQueue() - let appDatabase = try AppDatabase(dbQueue) - - // When we observe players - var players: [Player]? - _ = appDatabase.playersOrderedByScorePublisher().sink { completion in - if case let .failure(error) = completion { - XCTFail("Unexpected error \(error)") - } - } receiveValue: { - players = $0 - } - - // Then the players are published right on subscription - XCTAssertNotNil(players) - } } diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemoTests/PlayerRequestTests.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemoTests/PlayerRequestTests.swift new file mode 100644 index 0000000000..e0e7f23f65 --- /dev/null +++ b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemoTests/PlayerRequestTests.swift @@ -0,0 +1,43 @@ +import XCTest +import GRDB +@testable import GRDBCombineDemo + +class PlayerRequestTests: XCTestCase { + func test_PlayerRequest_byName_fetches_well_ordered_players() throws { + // Given a players database that contains two players + let dbQueue = DatabaseQueue() + _ = try AppDatabase(dbQueue) + var player1 = Player(id: nil, name: "Arthur", score: 100) + var player2 = Player(id: nil, name: "Barbara", score: 1000) + try dbQueue.write { db in + try player1.insert(db) + try player2.insert(db) + } + + // When we fetch players ordered by name + let playerRequest = PlayerRequest(ordering: .byName) + let players = try dbQueue.read(playerRequest.fetchValue) + + // Then the players are the two players ordered by name + XCTAssertEqual(players, [player1, player2]) + } + + func test_PlayerRequest_byScore_fetches_well_ordered_players() throws { + // Given a players database that contains two players + let dbQueue = DatabaseQueue() + _ = try AppDatabase(dbQueue) + var player1 = Player(id: nil, name: "Arthur", score: 100) + var player2 = Player(id: nil, name: "Barbara", score: 1000) + try dbQueue.write { db in + try player1.insert(db) + try player2.insert(db) + } + + // When we fetch players ordered by score + let playerRequest = PlayerRequest(ordering: .byScore) + let players = try dbQueue.read(playerRequest.fetchValue) + + // Then the players are the two players ordered by score descending + XCTAssertEqual(players, [player2, player1]) + } +} diff --git a/Documentation/DemoApps/GRDBCombineDemo/README.md b/Documentation/DemoApps/GRDBCombineDemo/README.md index 620a245525..29d09fcf90 100644 --- a/Documentation/DemoApps/GRDBCombineDemo/README.md +++ b/Documentation/DemoApps/GRDBCombineDemo/README.md @@ -3,7 +3,7 @@ Combine + SwiftUI Demo Application -**This demo application is a Combine + SwiftUI application, based on the MVVM design pattern.** For a demo application that uses UIKit, see [GRDBDemoiOS](../GRDBDemoiOS/README.md). +**This demo application is a Combine + SwiftUI application.** For a demo application that uses UIKit, see [GRDBDemoiOS](../GRDBDemoiOS/README.md). > :point_up: **Note**: This demo app is not a project template. Do not copy it as a starting point for your application. Instead, create a new project, choose a GRDB [installation method](../../../README.md#installation), and use the demo as an inspiration. @@ -19,7 +19,7 @@ The topics covered in this demo are: - [GRDBCombineDemoApp.swift](GRDBCombineDemo/GRDBCombineDemoApp.swift) - `GRDBCombineDemoApp` feeds the app views with a database. + `GRDBCombineDemoApp` feeds the app views with a database, through the SwiftUI environment. - [AppDatabase.swift](GRDBCombineDemo/AppDatabase.swift) @@ -31,18 +31,19 @@ The topics covered in this demo are: - [Player.swift](GRDBCombineDemo/Player.swift) - `Player` is a [Record](../../../README.md#records) type, able to read and write in the database. It conforms to the standard Codable protocol in order to gain all advantages of [Codable Records](../../../README.md#codable-records). It defines the database requests used by the application. + `Player` is a [Record](../../../README.md#records) type, able to read and write in the database. It conforms to the standard Codable protocol in order to gain all advantages of [Codable Records](../../../README.md#codable-records). -- [PlayerList.swift](GRDBCombineDemo/Views/PlayerList.swift) and [PlayerListViewModel.swift](GRDBCombineDemo/ViewModels/PlayerListViewModel.swift) +- [PlayerRequest.swift](GRDBCombineDemo/PlayerRequest.swift), [Query.swift](GRDBCombineDemo/Query.swift), [AppView.swift](GRDBCombineDemo/Views/AppView.swift) - `PlayerList` is the SwiftUI view that displays the list of players, fed by `PlayerListViewModel`. - -- [PlayerForm.swift](GRDBCombineDemo/Views/PlayerForm.swift), [PlayerEditionView.swift](GRDBCombineDemo/Views/PlayerEditionView.swift), [PlayerCreationSheet.swift](GRDBCombineDemo/Views/PlayerCreationSheet.swift) and [PlayerFormViewModel.swift](GRDBCombineDemo/ViewModels/PlayerFormViewModel.swift). + `PlayerRequest` defines the player requests used by the app (sorted by score, or by name). + + `PlayerRequest` feeds the `@Query` property wrapper. `@Query`, inspired by [this article](https://davedelong.com/blog/2021/04/03/core-data-and-swiftui/), allows SwiftUI views to display up-to-date database content thanks to GRDB's [ValueObservation](../../../README.md#valueobservation). - `PlayerForm` is the SwiftUI view that displays a Player editing form. It is embedded in `PlayerEditionView` and `PlayerCreationSheet`, two SwiftUI views that edit or create a player. All those views are fed by `PlayerFormViewModel`. + `AppView` is the SwiftUI view that uses `@Query` in order to feed its player list. - [GRDBCombineDemoTests](GRDBCombineDemoTests) - Test the database schema - Test the `Player` record and its requests + - Test the `PlayerRequest` methods that feed the list of players. - Test the `AppDatabase` methods that let the app access the database. diff --git a/Documentation/DemoApps/GRDBDemoiOS/GRDBDemoiOS.xcodeproj/xcshareddata/xcschemes/GRDBDemoWatchOS.xcscheme b/Documentation/DemoApps/GRDBDemoiOS/GRDBDemoiOS.xcodeproj/xcshareddata/xcschemes/GRDBDemoWatchOS.xcscheme index 4fbad19f2b..237e9f7b3e 100644 --- a/Documentation/DemoApps/GRDBDemoiOS/GRDBDemoiOS.xcodeproj/xcshareddata/xcschemes/GRDBDemoWatchOS.xcscheme +++ b/Documentation/DemoApps/GRDBDemoiOS/GRDBDemoiOS.xcodeproj/xcshareddata/xcschemes/GRDBDemoWatchOS.xcscheme @@ -78,10 +78,8 @@ debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> - + - + - + - - - - - + diff --git a/Documentation/DemoApps/GRDBDemoiOS/README.md b/Documentation/DemoApps/GRDBDemoiOS/README.md index 1227e514a1..9c8d6b1ae6 100644 --- a/Documentation/DemoApps/GRDBDemoiOS/README.md +++ b/Documentation/DemoApps/GRDBDemoiOS/README.md @@ -3,7 +3,7 @@ UIKit Demo Application -**This demo application is a storyboard-based UIKit application, based on the MVC design pattern.** For a demo application that uses Combine and SwiftUI, see [GRDBCombineDemo](../GRDBCombineDemo/README.md). +**This demo application is a storyboard-based UIKit application.** For a demo application that uses Combine and SwiftUI, see [GRDBCombineDemo](../GRDBCombineDemo/README.md). > :point_up: **Note**: This demo app is not a project template. Do not copy it as a starting point for your application. Instead, create a new project, choose a GRDB [installation method](../../../README.md#installation), and use the demo as an inspiration. diff --git a/Documentation/DemoApps/README.md b/Documentation/DemoApps/README.md index 6be27437e9..6316ac0394 100644 --- a/Documentation/DemoApps/README.md +++ b/Documentation/DemoApps/README.md @@ -1,6 +1,5 @@ Demo Applications ================= -- [UIKit Demo Application](GRDBDemoiOS/README.md): a storyboard-based UIKit application, based on the MVC design pattern. -- [Combine + SwiftUI Demo Application](GRDBCombineDemo/README.md): a Combine + SwiftUI application, based on the MVVM design pattern. - +- [UIKit Demo Application](GRDBDemoiOS/README.md): a storyboard-based UIKit application. +- [Combine + SwiftUI Demo Application](GRDBCombineDemo/README.md): a Combine + SwiftUI application. diff --git a/GRDB.swift.podspec b/GRDB.swift.podspec index ea10cf58cb..4a68fcdd58 100644 --- a/GRDB.swift.podspec +++ b/GRDB.swift.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'GRDB.swift' - s.version = '5.7.2' + s.version = '5.7.3' s.license = { :type => 'MIT', :file => 'LICENSE' } s.summary = 'A toolkit for SQLite databases, with a focus on application development.' diff --git a/GRDB.xcodeproj/project.pbxproj b/GRDB.xcodeproj/project.pbxproj index 7154e86a34..68a630095b 100755 --- a/GRDB.xcodeproj/project.pbxproj +++ b/GRDB.xcodeproj/project.pbxproj @@ -2445,8 +2445,8 @@ DC37742D19C8CC90004FCF85 /* GRDB */ = { isa = PBXGroup; children = ( - 56A2386F1B9C75030082EB20 /* Core */, 56A2FA3524424D2A00E97D23 /* Export.swift */, + 56A2386F1B9C75030082EB20 /* Core */, 56D7E449221595FE0052464B /* Fixit */, 5698AC291D9E5A480056AF8C /* FTS */, 56A238911B9C750B0082EB20 /* Migration */, diff --git a/GRDB/Core/Database.swift b/GRDB/Core/Database.swift index 71ff76904c..8b6dcea144 100644 --- a/GRDB/Core/Database.swift +++ b/GRDB/Core/Database.swift @@ -6,6 +6,7 @@ public typealias SQLiteConnection = OpaquePointer /// A raw SQLite function argument. typealias SQLiteValue = OpaquePointer +@usableFromInline let SQLITE_TRANSIENT = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_destructor_type.self) /// A Database connection. @@ -892,7 +893,7 @@ public final class Database: CustomStringConvertible, CustomDebugStringConvertib arguments: @autoclosure () -> StatementArguments? = nil) throws { - if isInsideTransactionBlock && !isInsideTransaction { + if isInsideTransactionBlock && sqlite3_get_autocommit(sqliteConnection) != 0 { throw DatabaseError( resultCode: .SQLITE_ABORT, message: "Transaction was aborted", @@ -1019,11 +1020,6 @@ public final class Database: CustomStringConvertible, CustomDebugStringConvertib return } - // If the savepoint is top-level, we'll use ROLLBACK TRANSACTION in - // order to perform the special error handling of rollbacks (see - // the rollback method). - let topLevelSavepoint = !isInsideTransaction - // Begin savepoint // // We use a single name for savepoints because there is no need @@ -1057,7 +1053,7 @@ public final class Database: CustomStringConvertible, CustomDebugStringConvertib isInsideTransactionBlock = wasInsideTransactionBlock try execute(sql: "RELEASE SAVEPOINT grdb") - assert(!topLevelSavepoint || !isInsideTransaction) + assert(sqlite3_get_autocommit(sqliteConnection) == 0) needsRollback = false case .rollback: needsRollback = true @@ -1069,15 +1065,11 @@ public final class Database: CustomStringConvertible, CustomDebugStringConvertib if needsRollback { do { - if topLevelSavepoint { - try rollback() - } else { - // Rollback, and release the savepoint. - // Rollback alone is not enough to clear the savepoint from - // the SQLite savepoint stack. - try execute(sql: "ROLLBACK TRANSACTION TO SAVEPOINT grdb") - try execute(sql: "RELEASE SAVEPOINT grdb") - } + // Rollback, and release the savepoint. + // Rollback alone is not enough to clear the savepoint from + // the SQLite savepoint stack. + try execute(sql: "ROLLBACK TRANSACTION TO SAVEPOINT grdb") + try execute(sql: "RELEASE SAVEPOINT grdb") } catch { if firstError == nil { firstError = error @@ -1100,7 +1092,7 @@ public final class Database: CustomStringConvertible, CustomDebugStringConvertib public func beginTransaction(_ kind: TransactionKind? = nil) throws { let kind = kind ?? configuration.defaultTransactionKind try execute(sql: "BEGIN \(kind.rawValue) TRANSACTION") - assert(isInsideTransaction) + assert(sqlite3_get_autocommit(sqliteConnection) == 0) } /// Rollbacks a database transaction. @@ -1147,13 +1139,13 @@ public final class Database: CustomStringConvertible, CustomDebugStringConvertib if isInsideTransaction { try execute(sql: "ROLLBACK TRANSACTION") } - assert(!isInsideTransaction) + assert(sqlite3_get_autocommit(sqliteConnection) != 0) } /// Commits a database transaction. public func commit() throws { try execute(sql: "COMMIT TRANSACTION") - assert(!isInsideTransaction) + assert(sqlite3_get_autocommit(sqliteConnection) != 0) } // MARK: - Memory Management diff --git a/GRDB/Core/DatabaseError.swift b/GRDB/Core/DatabaseError.swift index 5efe515fae..ab10baaf0b 100644 --- a/GRDB/Core/DatabaseError.swift +++ b/GRDB/Core/DatabaseError.swift @@ -248,6 +248,7 @@ public struct DatabaseError: Error, CustomStringConvertible, CustomNSError { /// /// This initializer is not public because library user is not supposed to /// be exposed to raw result codes. + @usableFromInline init(resultCode: CInt, message: String? = nil, sql: String? = nil, arguments: StatementArguments? = nil) { self.init(resultCode: ResultCode(rawValue: resultCode), message: message, sql: sql, arguments: arguments) } diff --git a/GRDB/Core/DatabaseReader.swift b/GRDB/Core/DatabaseReader.swift index feef59b493..74c5ab21f4 100644 --- a/GRDB/Core/DatabaseReader.swift +++ b/GRDB/Core/DatabaseReader.swift @@ -120,7 +120,7 @@ public protocol DatabaseReader: AnyObject { /// database updates are not visible inside the block: /// /// try reader.asyncRead { dbResult in - /// do ( + /// do { /// let db = try dbResult.get() /// // Those two values are guaranteed to be equal, even if the /// // `player` table is modified between the two requests: diff --git a/GRDB/Core/DatabaseValue.swift b/GRDB/Core/DatabaseValue.swift index b6545bcdaa..f5f1f9ffec 100644 --- a/GRDB/Core/DatabaseValue.swift +++ b/GRDB/Core/DatabaseValue.swift @@ -86,6 +86,7 @@ public struct DatabaseValue: Hashable, CustomStringConvertible, DatabaseValueCon // MARK: - Not Public + @inlinable init(storage: Storage) { self.storage = storage } @@ -140,6 +141,24 @@ public struct DatabaseValue: Hashable, CustomStringConvertible, DatabaseValueCon } } +extension DatabaseValue: StatementBinding { + @inlinable + public func bind(to sqliteStatement: SQLiteStatement, at index: CInt) -> CInt { + switch storage { + case .null: + return sqlite3_bind_null(sqliteStatement, index) + case .int64(let int64): + return int64.bind(to: sqliteStatement, at: index) + case .double(let double): + return double.bind(to: sqliteStatement, at: index) + case .string(let string): + return string.bind(to: sqliteStatement, at: index) + case .blob(let data): + return data.bind(to: sqliteStatement, at: index) + } + } +} + // MARK: - Hashable & Equatable // Hashable @@ -202,6 +221,7 @@ extension DatabaseValue { // DatabaseValueConvertible extension DatabaseValue { /// Returns self + @inlinable public var databaseValue: DatabaseValue { self } diff --git a/GRDB/Core/DatabaseValueConvertible.swift b/GRDB/Core/DatabaseValueConvertible.swift index 5183169ee8..c6a9b1aaf7 100644 --- a/GRDB/Core/DatabaseValueConvertible.swift +++ b/GRDB/Core/DatabaseValueConvertible.swift @@ -14,7 +14,7 @@ /// try String.fetchOne(statement, arguments:...) // String? /// /// DatabaseValueConvertible is adopted by Bool, Int, String, etc. -public protocol DatabaseValueConvertible: SQLExpressible { +public protocol DatabaseValueConvertible: SQLExpressible, StatementBinding { /// Returns a value that can be stored in the database. var databaseValue: DatabaseValue { get } @@ -26,6 +26,11 @@ extension DatabaseValueConvertible { public var sqlExpression: SQLExpression { .databaseValue(databaseValue) } + + @inlinable + public func bind(to sqliteStatement: SQLiteStatement, at index: CInt) -> CInt { + databaseValue.bind(to: sqliteStatement, at: index) + } } // MARK: - Conversions diff --git a/GRDB/Core/Statement.swift b/GRDB/Core/Statement.swift index 258b57cc31..e318ce8a12 100644 --- a/GRDB/Core/Statement.swift +++ b/GRDB/Core/Statement.swift @@ -35,6 +35,7 @@ public class Statement { sqlite3_stmt_readonly(sqliteStatement) != 0 } + @usableFromInline unowned let database: Database /// Creates a prepared statement. Returns nil if the compiled string is @@ -95,6 +96,7 @@ public class Statement { sqlite3_finalize(sqliteStatement) } + @usableFromInline final func reset() throws { SchedulingWatchdog.preconditionValidQueue(database) let code = sqlite3_reset(sqliteStatement) @@ -106,9 +108,12 @@ public class Statement { // MARK: Arguments + @usableFromInline var argumentsNeedValidation = true - var _arguments = StatementArguments() + @usableFromInline + var _arguments = StatementArguments() + lazy var sqliteArgumentCount: Int = { Int(sqlite3_bind_parameter_count(self.sqliteStatement)) }() @@ -188,8 +193,6 @@ public class Statement { bind(value, at: index) } else if let value = valuesIterator.next() { bind(value, at: index) - } else { - bind(.null, at: index) } } } @@ -224,22 +227,9 @@ public class Statement { } // 1-based index - private func bind(_ dbValue: DatabaseValue, at index: Int32) { - let code: Int32 - switch dbValue.storage { - case .null: - code = sqlite3_bind_null(sqliteStatement, index) - case .int64(let int64): - code = sqlite3_bind_int64(sqliteStatement, index, int64) - case .double(let double): - code = sqlite3_bind_double(sqliteStatement, index, double) - case .string(let string): - code = sqlite3_bind_text(sqliteStatement, index, string, -1, SQLITE_TRANSIENT) - case .blob(let data): - code = data.withUnsafeBytes { - sqlite3_bind_blob(sqliteStatement, index, $0.baseAddress, Int32($0.count), SQLITE_TRANSIENT) - } - } + @inlinable + func bind(_ value: T, at index: CInt) { + let code = value.bind(to: sqliteStatement, at: index) // It looks like sqlite3_bind_xxx() functions do not access the file system. // They should thus succeed, unless a GRDB bug: there is no point throwing any error. @@ -249,7 +239,8 @@ public class Statement { } // Don't make this one public unless we keep the arguments property in sync. - private func clearBindings() { + @usableFromInline + func clearBindings() { // It looks like sqlite3_clear_bindings() does not access the file system. // This function call should thus succeed, unless a GRDB bug: there is // no point throwing any error. @@ -259,12 +250,13 @@ public class Statement { } } - fileprivate func prepare(withArguments arguments: StatementArguments?) { + func reset(withArguments arguments: StatementArguments?) { // Force arguments validity: it is a programmer error to provide // arguments that do not match the statement. if let arguments = arguments { try! setArguments(arguments) } else if argumentsNeedValidation { + try! reset() try! validateArguments(self.arguments) } } @@ -388,12 +380,6 @@ public final class SelectStatement: Statement { try StatementCursor(statement: self, arguments: arguments) } - /// Utility function for cursors - func reset(withArguments arguments: StatementArguments? = nil) { - prepare(withArguments: arguments) - try! reset() - } - /// Utility function for cursors @usableFromInline func didFail(withResultCode resultCode: Int32) throws -> Never { @@ -542,8 +528,7 @@ public final class UpdateStatement: Statement { /// - throws: A DatabaseError whenever an SQLite error occurs. public func execute(arguments: StatementArguments? = nil) throws { SchedulingWatchdog.preconditionValidQueue(database) - prepare(withArguments: arguments) - try reset() + reset(withArguments: arguments) // Statement does not know how to execute itself, because it does not // know how to handle its errors, or if truncate optimisation should be @@ -552,6 +537,17 @@ public final class UpdateStatement: Statement { } } +// MARK: - StatementBinding + +public protocol StatementBinding { + /// Binds a statement argument. + /// + /// - parameter sqliteStatement: An SQLite statement. + /// - parameter index: 1-based index to statement arguments + /// - returns: the code returned by the `sqlite3_bind_xxx` function. + func bind(to sqliteStatement: SQLiteStatement, at index: CInt) -> CInt +} + // MARK: - StatementArguments /// StatementArguments provide values to argument placeholders in raw @@ -636,8 +632,8 @@ public final class UpdateStatement: Statement { public struct StatementArguments: CustomStringConvertible, Equatable, ExpressibleByArrayLiteral, ExpressibleByDictionaryLiteral { - private(set) var values: [DatabaseValue] = [] - private(set) var namedValues: [String: DatabaseValue] = [:] + private(set) var values: [DatabaseValue] + private(set) var namedValues: [String: DatabaseValue] public var isEmpty: Bool { values.isEmpty && namedValues.isEmpty @@ -648,6 +644,8 @@ public struct StatementArguments: CustomStringConvertible, Equatable, /// Creates empty StatementArguments. public init() { + values = .init() + namedValues = .init() } // MARK: Positional Arguments @@ -661,6 +659,7 @@ public struct StatementArguments: CustomStringConvertible, Equatable, /// - returns: A StatementArguments. public init(_ sequence: Sequence) where Sequence.Element == DatabaseValueConvertible? { values = sequence.map { $0?.databaseValue ?? .null } + namedValues = .init() } /// Creates statement arguments from a sequence of optional values. @@ -672,6 +671,7 @@ public struct StatementArguments: CustomStringConvertible, Equatable, /// - returns: A StatementArguments. public init(_ sequence: Sequence) where Sequence.Element: DatabaseValueConvertible { values = sequence.map(\.databaseValue) + namedValues = .init() } /// Creates statement arguments from any array. The result is nil unless all @@ -690,6 +690,11 @@ public struct StatementArguments: CustomStringConvertible, Equatable, self.init(values) } + @usableFromInline + mutating func set(databaseValues: [DatabaseValue]) { + self.values = databaseValues + namedValues.removeAll(keepingCapacity: true) + } // MARK: Named Arguments @@ -703,6 +708,7 @@ public struct StatementArguments: CustomStringConvertible, Equatable, /// - returns: A StatementArguments. public init(_ dictionary: [String: DatabaseValueConvertible?]) { namedValues = dictionary.mapValues { $0?.databaseValue ?? .null } + values = .init() } /// Creates statement arguments from a sequence of (key, value) pairs, such @@ -716,7 +722,11 @@ public struct StatementArguments: CustomStringConvertible, Equatable, public init(_ sequence: Sequence) where Sequence: Swift.Sequence, Sequence.Element == (String, DatabaseValueConvertible?) { - namedValues = Dictionary(uniqueKeysWithValues: sequence.map { ($0.0, $0.1?.databaseValue ?? .null) }) + namedValues = .init(minimumCapacity: sequence.underestimatedCount) + for (key, value) in sequence { + namedValues[key] = value?.databaseValue ?? .null + } + values = .init() } /// Creates statement arguments from [AnyHashable: Any]. diff --git a/GRDB/Core/Support/Foundation/Data.swift b/GRDB/Core/Support/Foundation/Data.swift index 7e50947b33..54fa319b2a 100644 --- a/GRDB/Core/Support/Foundation/Data.swift +++ b/GRDB/Core/Support/Foundation/Data.swift @@ -13,6 +13,7 @@ extension Data: DatabaseValueConvertible, StatementColumnConvertible { } /// Returns a value that can be stored in the database. + @inlinable public var databaseValue: DatabaseValue { DatabaseValue(storage: .blob(self)) } @@ -31,6 +32,13 @@ extension Data: DatabaseValueConvertible, StatementColumnConvertible { return nil } } + + @inlinable + public func bind(to sqliteStatement: SQLiteStatement, at index: CInt) -> CInt { + withUnsafeBytes { + sqlite3_bind_blob(sqliteStatement, index, $0.baseAddress, Int32($0.count), SQLITE_TRANSIENT) + } + } } // MARK: - Conversions diff --git a/GRDB/Core/Support/StandardLibrary/DatabaseValueConvertible+RawRepresentable.swift b/GRDB/Core/Support/StandardLibrary/DatabaseValueConvertible+RawRepresentable.swift index 8547c73617..947b9a1603 100644 --- a/GRDB/Core/Support/StandardLibrary/DatabaseValueConvertible+RawRepresentable.swift +++ b/GRDB/Core/Support/StandardLibrary/DatabaseValueConvertible+RawRepresentable.swift @@ -17,6 +17,13 @@ extension SQLExpressible where Self: RawRepresentable, Self.RawValue: SQLExpress } } +extension StatementBinding where Self: RawRepresentable, Self.RawValue: StatementBinding { + @inlinable + public func bind(to sqliteStatement: SQLiteStatement, at index: CInt) -> CInt { + rawValue.bind(to: sqliteStatement, at: index) + } +} + /// `DatabaseValueConvertible` is free for `RawRepresentable` types whose raw /// value is itself `DatabaseValueConvertible`. /// @@ -31,6 +38,7 @@ extension SQLExpressible where Self: RawRepresentable, Self.RawValue: SQLExpress /// // adopt DatabaseValueConvertible: /// extension Color: DatabaseValueConvertible { } extension DatabaseValueConvertible where Self: RawRepresentable, Self.RawValue: DatabaseValueConvertible { + @inlinable public var databaseValue: DatabaseValue { rawValue.databaseValue } diff --git a/GRDB/Core/Support/StandardLibrary/StandardLibrary.swift b/GRDB/Core/Support/StandardLibrary/StandardLibrary.swift index a6d0e5a103..e0faf23ac9 100644 --- a/GRDB/Core/Support/StandardLibrary/StandardLibrary.swift +++ b/GRDB/Core/Support/StandardLibrary/StandardLibrary.swift @@ -14,6 +14,7 @@ extension Bool: DatabaseValueConvertible, StatementColumnConvertible { } /// Returns a value that can be stored in the database. + @inlinable public var databaseValue: DatabaseValue { (self ? 1 : 0).databaseValue } @@ -85,6 +86,11 @@ extension Bool: DatabaseValueConvertible, StatementColumnConvertible { return nil } } + + @inlinable + public func bind(to sqliteStatement: SQLiteStatement, at index: CInt) -> CInt { + sqlite3_bind_int64(sqliteStatement, index, self ? 1 : 0) + } } /// Int adopts DatabaseValueConvertible and StatementColumnConvertible. @@ -103,6 +109,7 @@ extension Int: DatabaseValueConvertible, StatementColumnConvertible { } /// Returns a value that can be stored in the database. + @inlinable public var databaseValue: DatabaseValue { Int64(self).databaseValue } @@ -111,6 +118,11 @@ extension Int: DatabaseValueConvertible, StatementColumnConvertible { public static func fromDatabaseValue(_ dbValue: DatabaseValue) -> Int? { Int64.fromDatabaseValue(dbValue).flatMap { Int(exactly: $0) } } + + @inlinable + public func bind(to sqliteStatement: SQLiteStatement, at index: CInt) -> CInt { + sqlite3_bind_int64(sqliteStatement, index, Int64(self)) + } } /// Int8 adopts DatabaseValueConvertible and StatementColumnConvertible. @@ -129,6 +141,7 @@ extension Int8: DatabaseValueConvertible, StatementColumnConvertible { } /// Returns a value that can be stored in the database. + @inlinable public var databaseValue: DatabaseValue { Int64(self).databaseValue } @@ -137,6 +150,11 @@ extension Int8: DatabaseValueConvertible, StatementColumnConvertible { public static func fromDatabaseValue(_ dbValue: DatabaseValue) -> Int8? { Int64.fromDatabaseValue(dbValue).flatMap { Int8(exactly: $0) } } + + @inlinable + public func bind(to sqliteStatement: SQLiteStatement, at index: CInt) -> CInt { + sqlite3_bind_int64(sqliteStatement, index, Int64(self)) + } } /// Int16 adopts DatabaseValueConvertible and StatementColumnConvertible. @@ -155,6 +173,7 @@ extension Int16: DatabaseValueConvertible, StatementColumnConvertible { } /// Returns a value that can be stored in the database. + @inlinable public var databaseValue: DatabaseValue { Int64(self).databaseValue } @@ -163,6 +182,11 @@ extension Int16: DatabaseValueConvertible, StatementColumnConvertible { public static func fromDatabaseValue(_ dbValue: DatabaseValue) -> Int16? { Int64.fromDatabaseValue(dbValue).flatMap { Int16(exactly: $0) } } + + @inlinable + public func bind(to sqliteStatement: SQLiteStatement, at index: CInt) -> CInt { + sqlite3_bind_int64(sqliteStatement, index, Int64(self)) + } } /// Int32 adopts DatabaseValueConvertible and StatementColumnConvertible. @@ -181,6 +205,7 @@ extension Int32: DatabaseValueConvertible, StatementColumnConvertible { } /// Returns a value that can be stored in the database. + @inlinable public var databaseValue: DatabaseValue { Int64(self).databaseValue } @@ -189,6 +214,11 @@ extension Int32: DatabaseValueConvertible, StatementColumnConvertible { public static func fromDatabaseValue(_ dbValue: DatabaseValue) -> Int32? { Int64.fromDatabaseValue(dbValue).flatMap { Int32(exactly: $0) } } + + @inlinable + public func bind(to sqliteStatement: SQLiteStatement, at index: CInt) -> CInt { + sqlite3_bind_int64(sqliteStatement, index, Int64(self)) + } } /// Int64 adopts DatabaseValueConvertible and StatementColumnConvertible. @@ -205,6 +235,7 @@ extension Int64: DatabaseValueConvertible, StatementColumnConvertible { } /// Returns a value that can be stored in the database. + @inlinable public var databaseValue: DatabaseValue { DatabaseValue(storage: .int64(self)) } @@ -222,6 +253,11 @@ extension Int64: DatabaseValueConvertible, StatementColumnConvertible { return nil } } + + @inlinable + public func bind(to sqliteStatement: SQLiteStatement, at index: CInt) -> CInt { + sqlite3_bind_int64(sqliteStatement, index, self) + } } /// UInt adopts DatabaseValueConvertible and StatementColumnConvertible. @@ -240,6 +276,7 @@ extension UInt: DatabaseValueConvertible, StatementColumnConvertible { } /// Returns a value that can be stored in the database. + @inlinable public var databaseValue: DatabaseValue { Int64(self).databaseValue } @@ -248,6 +285,11 @@ extension UInt: DatabaseValueConvertible, StatementColumnConvertible { public static func fromDatabaseValue(_ dbValue: DatabaseValue) -> UInt? { Int64.fromDatabaseValue(dbValue).flatMap { UInt(exactly: $0) } } + + @inlinable + public func bind(to sqliteStatement: SQLiteStatement, at index: CInt) -> CInt { + sqlite3_bind_int64(sqliteStatement, index, Int64(self)) + } } /// UInt8 adopts DatabaseValueConvertible and StatementColumnConvertible. @@ -266,6 +308,7 @@ extension UInt8: DatabaseValueConvertible, StatementColumnConvertible { } /// Returns a value that can be stored in the database. + @inlinable public var databaseValue: DatabaseValue { Int64(self).databaseValue } @@ -274,6 +317,11 @@ extension UInt8: DatabaseValueConvertible, StatementColumnConvertible { public static func fromDatabaseValue(_ dbValue: DatabaseValue) -> UInt8? { Int64.fromDatabaseValue(dbValue).flatMap { UInt8(exactly: $0) } } + + @inlinable + public func bind(to sqliteStatement: SQLiteStatement, at index: CInt) -> CInt { + sqlite3_bind_int64(sqliteStatement, index, Int64(self)) + } } /// UInt16 adopts DatabaseValueConvertible and StatementColumnConvertible. @@ -292,6 +340,7 @@ extension UInt16: DatabaseValueConvertible, StatementColumnConvertible { } /// Returns a value that can be stored in the database. + @inlinable public var databaseValue: DatabaseValue { Int64(self).databaseValue } @@ -300,6 +349,11 @@ extension UInt16: DatabaseValueConvertible, StatementColumnConvertible { public static func fromDatabaseValue(_ dbValue: DatabaseValue) -> UInt16? { Int64.fromDatabaseValue(dbValue).flatMap { UInt16(exactly: $0) } } + + @inlinable + public func bind(to sqliteStatement: SQLiteStatement, at index: CInt) -> CInt { + sqlite3_bind_int64(sqliteStatement, index, Int64(self)) + } } /// UInt32 adopts DatabaseValueConvertible and StatementColumnConvertible. @@ -318,6 +372,7 @@ extension UInt32: DatabaseValueConvertible, StatementColumnConvertible { } /// Returns a value that can be stored in the database. + @inlinable public var databaseValue: DatabaseValue { Int64(self).databaseValue } @@ -326,6 +381,11 @@ extension UInt32: DatabaseValueConvertible, StatementColumnConvertible { public static func fromDatabaseValue(_ dbValue: DatabaseValue) -> UInt32? { Int64.fromDatabaseValue(dbValue).flatMap { UInt32(exactly: $0) } } + + @inlinable + public func bind(to sqliteStatement: SQLiteStatement, at index: CInt) -> CInt { + sqlite3_bind_int64(sqliteStatement, index, Int64(self)) + } } /// UInt64 adopts DatabaseValueConvertible and StatementColumnConvertible. @@ -344,6 +404,7 @@ extension UInt64: DatabaseValueConvertible, StatementColumnConvertible { } /// Returns a value that can be stored in the database. + @inlinable public var databaseValue: DatabaseValue { Int64(self).databaseValue } @@ -352,6 +413,11 @@ extension UInt64: DatabaseValueConvertible, StatementColumnConvertible { public static func fromDatabaseValue(_ dbValue: DatabaseValue) -> UInt64? { Int64.fromDatabaseValue(dbValue).flatMap { UInt64(exactly: $0) } } + + @inlinable + public func bind(to sqliteStatement: SQLiteStatement, at index: CInt) -> CInt { + sqlite3_bind_int64(sqliteStatement, index, Int64(self)) + } } /// Double adopts DatabaseValueConvertible and StatementColumnConvertible. @@ -368,6 +434,7 @@ extension Double: DatabaseValueConvertible, StatementColumnConvertible { } /// Returns a value that can be stored in the database. + @inlinable public var databaseValue: DatabaseValue { DatabaseValue(storage: .double(self)) } @@ -383,6 +450,11 @@ extension Double: DatabaseValueConvertible, StatementColumnConvertible { return nil } } + + @inlinable + public func bind(to sqliteStatement: SQLiteStatement, at index: CInt) -> CInt { + sqlite3_bind_double(sqliteStatement, index, self) + } } /// Float adopts DatabaseValueConvertible and StatementColumnConvertible. @@ -399,6 +471,7 @@ extension Float: DatabaseValueConvertible, StatementColumnConvertible { } /// Returns a value that can be stored in the database. + @inlinable public var databaseValue: DatabaseValue { Double(self).databaseValue } @@ -414,6 +487,11 @@ extension Float: DatabaseValueConvertible, StatementColumnConvertible { return nil } } + + @inlinable + public func bind(to sqliteStatement: SQLiteStatement, at index: CInt) -> CInt { + sqlite3_bind_double(sqliteStatement, index, Double(self)) + } } /// String adopts DatabaseValueConvertible and StatementColumnConvertible. @@ -430,6 +508,7 @@ extension String: DatabaseValueConvertible, StatementColumnConvertible { } /// Returns a value that can be stored in the database. + @inlinable public var databaseValue: DatabaseValue { DatabaseValue(storage: .string(self)) } @@ -447,6 +526,11 @@ extension String: DatabaseValueConvertible, StatementColumnConvertible { return nil } } + + @inlinable + public func bind(to sqliteStatement: SQLiteStatement, at index: CInt) -> CInt { + sqlite3_bind_text(sqliteStatement, index, self, -1, SQLITE_TRANSIENT) + } } diff --git a/GRDB/Core/TransactionObserver.swift b/GRDB/Core/TransactionObserver.swift index 68efda018f..1529f7b3ef 100644 --- a/GRDB/Core/TransactionObserver.swift +++ b/GRDB/Core/TransactionObserver.swift @@ -202,84 +202,86 @@ class DatabaseObservationBroker { /// statement, and returns the authorizer that should be used during /// statement execution. func updateStatementWillExecute(_ statement: UpdateStatement) -> StatementAuthorizer? { - // As statement executes, it may trigger database changes that will - // be notified to transaction observers. As a consequence, observers - // may disable themselves with stopObservingDatabaseChangesUntilNextTransaction() - // - // This method takes no argument, and requires access to the "current - // broker", which is a per-thread global stored in - // SchedulingWatchdog.current: - SchedulingWatchdog.current!.databaseObservationBroker = self - - // Fill statementObservations with observations that are interested in - // the kind of events performed by the statement. - // - // Those statementObservations will be notified of individual changes - // in databaseWillChange() and databaseDidChange(). - let eventKinds = statement.databaseEventKinds - // If any observer observes row deletions, we'll have to disable // [truncate optimization](https://www.sqlite.org/lang_delete.html#truncateopt) // so that observers are notified. var observesRowDeletion = false - switch eventKinds.count { - case 0: - // Statement has no effect on any database table. + if transactionObservations.isEmpty == false { + // As statement executes, it may trigger database changes that will + // be notified to transaction observers. As a consequence, observers + // may disable themselves with stopObservingDatabaseChangesUntilNextTransaction() // - // For example: PRAGMA foreign_keys = ON - statementObservations = [] - case 1: - // We'll execute a simple statement without any side effect. - // Eventual database events will thus all have the same kind. All - // detabase events can be notified to interested observations. - // - // For example, if one observes all deletions in the table T, then - // all individual deletions of DELETE FROM T are notified: - let eventKind = eventKinds[0] - statementObservations = transactionObservations.compactMap { observation in - guard observation.observes(eventsOfKind: eventKind) else { - // observation is not interested - return nil - } - - if case .delete = eventKind { - observesRowDeletion = true - } - - // observation will be notified of all individual events - return (observation, DatabaseEventPredicate.true) - } - default: - // We'll execute a complex statement with side effects performed by - // an SQL trigger or a foreign key action. Eventual database events - // may not all have the same kind: we need to filter them before - // notifying interested observations. + // This method takes no argument, and requires access to the "current + // broker", which is a per-thread global stored in + // SchedulingWatchdog.current: + SchedulingWatchdog.current!.databaseObservationBroker = self + + // Fill statementObservations with observations that are interested in + // the kind of events performed by the statement. // - // For example, if DELETE FROM T1 generates deletions in T1 and T2 - // by the mean of a foreign key action, then when one only observes - // deletions in T1, one must not be notified of deletions in T2: - statementObservations = transactionObservations.compactMap { observation in - let observedKinds = eventKinds.filter(observation.observes) - if observedKinds.isEmpty { - // observation is not interested - return nil - } - - for eventKind in observedKinds { + // Those statementObservations will be notified of individual changes + // in databaseWillChange() and databaseDidChange(). + let eventKinds = statement.databaseEventKinds + + switch eventKinds.count { + case 0: + // Statement has no effect on any database table. + // + // For example: PRAGMA foreign_keys = ON + statementObservations = [] + case 1: + // We'll execute a simple statement without any side effect. + // Eventual database events will thus all have the same kind. All + // detabase events can be notified to interested observations. + // + // For example, if one observes all deletions in the table T, then + // all individual deletions of DELETE FROM T are notified: + let eventKind = eventKinds[0] + statementObservations = transactionObservations.compactMap { observation in + guard observation.observes(eventsOfKind: eventKind) else { + // observation is not interested + return nil + } + if case .delete = eventKind { observesRowDeletion = true - break } + + // observation will be notified of all individual events + return (observation, DatabaseEventPredicate.true) + } + default: + // We'll execute a complex statement with side effects performed by + // an SQL trigger or a foreign key action. Eventual database events + // may not all have the same kind: we need to filter them before + // notifying interested observations. + // + // For example, if DELETE FROM T1 generates deletions in T1 and T2 + // by the mean of a foreign key action, then when one only observes + // deletions in T1, one must not be notified of deletions in T2: + statementObservations = transactionObservations.compactMap { observation in + let observedKinds = eventKinds.filter(observation.observes) + if observedKinds.isEmpty { + // observation is not interested + return nil + } + + for eventKind in observedKinds { + if case .delete = eventKind { + observesRowDeletion = true + break + } + } + + // observation will only be notified of individual events that + // match one of the observed kinds. + return ( + observation, + DatabaseEventPredicate.matching( + observedKinds: observedKinds, + advertisedKinds: eventKinds)) } - - // observation will only be notified of individual events that - // match one of the observed kinds. - return ( - observation, - DatabaseEventPredicate.matching( - observedKinds: observedKinds, - advertisedKinds: eventKinds)) } } @@ -327,8 +329,10 @@ class DatabaseObservationBroker { func updateStatementDidExecute(_ statement: UpdateStatement) throws { // Undo updateStatementWillExecute - statementObservations = [] - SchedulingWatchdog.current!.databaseObservationBroker = nil + if transactionObservations.isEmpty == false { + statementObservations = [] + SchedulingWatchdog.current!.databaseObservationBroker = nil + } // Has statement any effect on transaction/savepoints? if let transactionEffect = statement.transactionEffect { diff --git a/GRDB/Record/EncodableRecord+Encodable.swift b/GRDB/Record/EncodableRecord+Encodable.swift index ff5088d7f6..0e511bc40c 100644 --- a/GRDB/Record/EncodableRecord+Encodable.swift +++ b/GRDB/Record/EncodableRecord+Encodable.swift @@ -148,16 +148,19 @@ private class RecordEncoder: Encoder { } } catch is JSONRequiredError { // Encode to JSON - let jsonData = try Record.databaseJSONEncoder(for: key.stringValue).encode(value) - - // Store JSON String in the database for easier debugging and - // database inspection. Thanks to SQLite weak typing, we won't - // have any trouble decoding this string into data when we - // eventually perform JSON decoding. - // TODO: possible optimization: avoid this conversion to string, - // and store raw data bytes as an SQLite string - let jsonString = String(data: jsonData, encoding: .utf8)! - persist(jsonString, forKey: key) + try autoreleasepool { + + let jsonData = try Record.databaseJSONEncoder(for: key.stringValue).encode(value) + + // Store JSON String in the database for easier debugging and + // database inspection. Thanks to SQLite weak typing, we won't + // have any trouble decoding this string into data when we + // eventually perform JSON decoding. + // TODO: possible optimization: avoid this conversion to string, + // and store raw data bytes as an SQLite string + let jsonString = String(data: jsonData, encoding: .utf8)! + persist(jsonString, forKey: key) + } } } } diff --git a/GRDB/Utils/Utils.swift b/GRDB/Utils/Utils.swift index 25eeb027e6..d881e0875d 100644 --- a/GRDB/Utils/Utils.swift +++ b/GRDB/Utils/Utils.swift @@ -53,7 +53,7 @@ func GRDBPrecondition( } @inlinable -func fatalError(_ error: Error) -> Never { +func fatalError(_ error: E) -> Never { try! { throw error }() } diff --git a/README.md b/README.md index 37839d3ee8..fe29630766 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,13 @@ --- -**Latest release**: March 29, 2021 • version 5.7.2 • [CHANGELOG](CHANGELOG.md) • [Migrating From GRDB 4 to GRDB 5](Documentation/GRDB5MigrationGuide.md) +**Latest release**: April 5, 2021 • version 5.7.3 • [CHANGELOG](CHANGELOG.md) • [Migrating From GRDB 4 to GRDB 5](Documentation/GRDB5MigrationGuide.md) **Requirements**: iOS 10.0+ / macOS 10.10+ / tvOS 9.0+ / watchOS 2.0+ • SQLite 3.8.5+ • Swift 5.2+ / Xcode 11.4+ | Swift version | GRDB version | | -------------- | ----------------------------------------------------------- | -| **Swift 5.2+** | **v5.7.2** | +| **Swift 5.2+** | **v5.7.3** | | Swift 5.1 | [v4.14.0](https://github.com/groue/GRDB.swift/tree/v4.14.0) | | Swift 5 | [v4.14.0](https://github.com/groue/GRDB.swift/tree/v4.14.0) | | Swift 4.2 | [v4.14.0](https://github.com/groue/GRDB.swift/tree/v4.14.0) | @@ -8245,7 +8245,7 @@ Sample Code **Thanks** - [Pierlis](http://pierlis.com), where we write great software. -- [@alextrob](https://github.com/alextrob), [@bellebethcooper](https://github.com/bellebethcooper), [@bfad](https://github.com/bfad), [@cfilipov](https://github.com/cfilipov), [@charlesmchen-signal](https://github.com/charlesmchen-signal), [@Chiliec](https://github.com/Chiliec), [@chrisballinger](https://github.com/chrisballinger), [@darrenclark](https://github.com/darrenclark), [@davidkraus](https://github.com/davidkraus), [@eburns-vmware](https://github.com/eburns-vmware), [@fpillet](http://github.com/fpillet), [@gcox](https://github.com/gcox), [@GetToSet](https://github.com/GetToSet), [@gjeck](https://github.com/gjeck), [@gusrota](https://github.com/gusrota), [@haikusw](https://github.com/haikusw), [@hartbit](https://github.com/hartbit), [@kdubb](https://github.com/kdubb), [@kluufger](https://github.com/kluufger), [@KyleLeneau](https://github.com/KyleLeneau), [@mallman](https://github.com/mallman), [@Marus](https://github.com/Marus), [@MaxDesiatov](https://github.com/MaxDesiatov), [@michaelkirk-signal](https://github.com/michaelkirk-signal), [@mtancock](https://github.com/mtancock), [@pakko972](https://github.com/pakko972), [@peter-ss](https://github.com/peter-ss), [@pierlo](https://github.com/pierlo), [@pocketpixels](https://github.com/pocketpixels), [@robcas3](https://github.com/robcas3), [@runhum](https://github.com/runhum), [@schveiguy](https://github.com/schveiguy), [@SD10](https://github.com/SD10), [@sobri909](https://github.com/sobri909), [@sroddy](https://github.com/sroddy), [@swiftlyfalling](https://github.com/swiftlyfalling), [@Timac](https://github.com/Timac), [@valexa](https://github.com/valexa), [@wuyuehyang](https://github.com/wuyuehyang), and [@zmeyc](https://github.com/zmeyc) for their contributions, help, and feedback on GRDB. +- [@alextrob](https://github.com/alextrob), [@bellebethcooper](https://github.com/bellebethcooper), [@bfad](https://github.com/bfad), [@cfilipov](https://github.com/cfilipov), [@charlesmchen-signal](https://github.com/charlesmchen-signal), [@Chiliec](https://github.com/Chiliec), [@chrisballinger](https://github.com/chrisballinger), [@darrenclark](https://github.com/darrenclark), [@davidkraus](https://github.com/davidkraus), [@eburns-vmware](https://github.com/eburns-vmware), [@fpillet](http://github.com/fpillet), [@gcox](https://github.com/gcox), [@GetToSet](https://github.com/GetToSet), [@gjeck](https://github.com/gjeck), [@gusrota](https://github.com/gusrota), [@haikusw](https://github.com/haikusw), [@hartbit](https://github.com/hartbit), [@kdubb](https://github.com/kdubb), [@kluufger](https://github.com/kluufger), [@KyleLeneau](https://github.com/KyleLeneau), [@mallman](https://github.com/mallman), [@MartinP7r](https://github.com/MartinP7r), [@Marus](https://github.com/Marus), [@MaxDesiatov](https://github.com/MaxDesiatov), [@michaelkirk-signal](https://github.com/michaelkirk-signal), [@mtancock](https://github.com/mtancock), [@pakko972](https://github.com/pakko972), [@peter-ss](https://github.com/peter-ss), [@pierlo](https://github.com/pierlo), [@pocketpixels](https://github.com/pocketpixels), [@robcas3](https://github.com/robcas3), [@runhum](https://github.com/runhum), [@schveiguy](https://github.com/schveiguy), [@SD10](https://github.com/SD10), [@sobri909](https://github.com/sobri909), [@sroddy](https://github.com/sroddy), [@swiftlyfalling](https://github.com/swiftlyfalling), [@Timac](https://github.com/Timac), [@valexa](https://github.com/valexa), [@wuyuehyang](https://github.com/wuyuehyang), and [@zmeyc](https://github.com/zmeyc) for their contributions, help, and feedback on GRDB. - [@aymerick](https://github.com/aymerick) and [@kali](https://github.com/kali) because SQL. - [ccgus/fmdb](https://github.com/ccgus/fmdb) for its excellency. diff --git a/Support/Info.plist b/Support/Info.plist index fee814985f..938884c1e3 100644 --- a/Support/Info.plist +++ b/Support/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 5.7.2 + 5.7.3 CFBundleSignature ???? CFBundleVersion diff --git a/Tests/GRDBTests/DatabasePoolBackupTests.swift b/Tests/GRDBTests/DatabasePoolBackupTests.swift index 45e6818939..c59afd57d7 100644 --- a/Tests/GRDBTests/DatabasePoolBackupTests.swift +++ b/Tests/GRDBTests/DatabasePoolBackupTests.swift @@ -9,25 +9,25 @@ class DatabasePoolBackupTests: GRDBTestCase { let destination = try makeDatabasePool(filename: "destination.sqlite", configuration: Configuration()) try source.write { db in - try db.execute(sql: "CREATE TABLE items (id INTEGER PRIMARY KEY)") - try db.execute(sql: "INSERT INTO items (id) VALUES (NULL)") - XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM items")!, 1) + try db.execute(sql: "CREATE TABLE item (id INTEGER PRIMARY KEY)") + try db.execute(sql: "INSERT INTO item (id) VALUES (NULL)") + XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM item")!, 1) } try source.backup(to: destination) try destination.read { db in - XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM items")!, 1) + XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM item")!, 1) } try source.write { db in - try db.execute(sql: "DROP TABLE items") + try db.execute(sql: "DROP TABLE item") } try source.backup(to: destination) try destination.read { db in - XCTAssertFalse(try db.tableExists("items")) + XCTAssertFalse(try db.tableExists("item")) } } @@ -37,9 +37,9 @@ class DatabasePoolBackupTests: GRDBTestCase { // let destination = try makeDatabasePool(filename: "destination.sqlite") // // try source.write { db in -// try db.execute(sql: "CREATE TABLE items (id INTEGER PRIMARY KEY)") -// try db.execute(sql: "INSERT INTO items (id) VALUES (NULL)") -// XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM items")!, 1) +// try db.execute(sql: "CREATE TABLE item (id INTEGER PRIMARY KEY)") +// try db.execute(sql: "INSERT INTO item (id) VALUES (NULL)") +// XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM item")!, 1) // } // // let s1 = DispatchSemaphore(value: 0) @@ -47,7 +47,7 @@ class DatabasePoolBackupTests: GRDBTestCase { // DispatchQueue.global().async { // _ = s1.wait(timeout: .distantFuture) // try! source.writeInTransaction(.immediate) { db in -// try db.execute(sql: "INSERT INTO items (id) VALUES (NULL)") +// try db.execute(sql: "INSERT INTO item (id) VALUES (NULL)") // s2.signal() // return .commit // } @@ -62,19 +62,19 @@ class DatabasePoolBackupTests: GRDBTestCase { // }, // afterBackupStep: { // try! source.write { db in -// try db.execute(sql: "INSERT INTO items (id) VALUES (NULL)") +// try db.execute(sql: "INSERT INTO item (id) VALUES (NULL)") // } // }) // } // // try source.read { db in -// XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM items")!, 3) +// XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM item")!, 3) // } // try destination.read { db in // // TODO: understand why the fix for https://github.com/groue/GRDB.swift/issues/102 // // had this value change from 2 to 1. // // TODO: Worse, this test is fragile. I've seen not 1 but 2 once. -// XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM items")!, 1) +// XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM item")!, 1) // } // } } diff --git a/Tests/GRDBTests/RawRepresentable+DatabaseValueConvertibleTests.swift b/Tests/GRDBTests/RawRepresentable+DatabaseValueConvertibleTests.swift index b557bb97da..6d2931bf93 100644 --- a/Tests/GRDBTests/RawRepresentable+DatabaseValueConvertibleTests.swift +++ b/Tests/GRDBTests/RawRepresentable+DatabaseValueConvertibleTests.swift @@ -36,6 +36,7 @@ extension Grape : SQLExpressible { } extension Wrapper: SQLOrderingTerm where RawValue: SQLOrderingTerm { } extension Wrapper: SQLSelectable where RawValue: SQLSelectable { } extension Wrapper: SQLExpressible where RawValue: SQLExpressible { } +extension Wrapper: StatementBinding where RawValue: StatementBinding { } extension Color32 : DatabaseValueConvertible { } extension Color64 : DatabaseValueConvertible { } diff --git a/Tests/GRDBTests/RecordInitializersTests.swift b/Tests/GRDBTests/RecordInitializersTests.swift index 2e3448a009..3355c317c5 100644 --- a/Tests/GRDBTests/RecordInitializersTests.swift +++ b/Tests/GRDBTests/RecordInitializersTests.swift @@ -58,7 +58,7 @@ class RecordWithImmutableProperty : Record { let initializedFromRow: Bool required init(row: Row) { // An initializer is required, and the minimum is init(row) - initializedFromRow = true // property must bet set before super.init(row: row) + initializedFromRow = true // property must be set before super.init(row: row) super.init(row: row) // super.init(row: row) is required } } @@ -68,12 +68,12 @@ class RecordWithPedigree : Record { let initializedFromRow: Bool override init() { - initializedFromRow = false // property must bet set before super.init(row: row) + initializedFromRow = false // property must be set before super.init(row: row) super.init() // super.init() is required } required init(row: Row) { // An initializer is required, and the minimum is init(row) - initializedFromRow = true // property must bet set before super.init(row: row) + initializedFromRow = true // property must be set before super.init(row: row) super.init(row: row) // super.init(row: row) is required } } @@ -83,12 +83,12 @@ class RecordWithImmutablePropertyAndCustomInitializer : Record { let initializedFromRow: Bool init(name: String? = nil) { - initializedFromRow = false // property must bet set before super.init(row: row) + initializedFromRow = false // property must be set before super.init(row: row) super.init() // super.init() is required } required init(row: Row) { // An initializer is required, and the minimum is init(row) - initializedFromRow = true // property must bet set before super.init(row: row) + initializedFromRow = true // property must be set before super.init(row: row) super.init(row: row) // super.init(row: row) is required } } diff --git a/Tests/Performance/GRDBPerformance/FetchNamedValuesTests.swift b/Tests/Performance/GRDBPerformance/FetchNamedValuesTests.swift index eda7f0767e..31accf5c28 100644 --- a/Tests/Performance/GRDBPerformance/FetchNamedValuesTests.swift +++ b/Tests/Performance/GRDBPerformance/FetchNamedValuesTests.swift @@ -6,7 +6,7 @@ import SQLite private let expectedRowCount = 100_000 -/// Here we test the extraction of values by column name. +/// Here we test the extraction of row values by column name. class FetchNamedValuesTests: XCTestCase { func testGRDB() throws { @@ -18,7 +18,7 @@ class FetchNamedValuesTests: XCTestCase { var count = 0 try! dbQueue.inDatabase { db in - let rows = try Row.fetchCursor(db, sql: "SELECT * FROM items") + let rows = try Row.fetchCursor(db, sql: "SELECT * FROM item") while let row = try rows.next() { _ = row["i0"] as Int _ = row["i1"] as Int @@ -49,7 +49,7 @@ class FetchNamedValuesTests: XCTestCase { var count = 0 dbQueue.inDatabase { db in - let rs = try! db.executeQuery("SELECT * FROM items", values: nil) + let rs = try! db.executeQuery("SELECT * FROM item", values: nil) while rs.next() { _ = rs.long(forColumn: "i0") _ = rs.long(forColumn: "i1") @@ -78,17 +78,17 @@ class FetchNamedValuesTests: XCTestCase { measure { var count = 0 - for item in try! db.prepare(itemsTable) { - _ = item[i0Column] - _ = item[i1Column] - _ = item[i2Column] - _ = item[i3Column] - _ = item[i4Column] - _ = item[i5Column] - _ = item[i6Column] - _ = item[i7Column] - _ = item[i8Column] - _ = item[i9Column] + for row in try! db.prepare(itemTable) { + _ = row[i0Column] + _ = row[i1Column] + _ = row[i2Column] + _ = row[i3Column] + _ = row[i4Column] + _ = row[i5Column] + _ = row[i6Column] + _ = row[i7Column] + _ = row[i8Column] + _ = row[i9Column] count += 1 } diff --git a/Tests/Performance/GRDBPerformance/FetchPositionalValuesTests.swift b/Tests/Performance/GRDBPerformance/FetchPositionalValuesTests.swift index 610cf2cd2d..7c7196c4e0 100644 --- a/Tests/Performance/GRDBPerformance/FetchPositionalValuesTests.swift +++ b/Tests/Performance/GRDBPerformance/FetchPositionalValuesTests.swift @@ -6,7 +6,7 @@ import SQLite private let expectedRowCount = 100_000 -/// Here we test the extraction of values by column index. +/// Here we test the extraction of row values by column index. class FetchPositionalValuesTests: XCTestCase { func testSQLite() throws { @@ -18,7 +18,7 @@ class FetchPositionalValuesTests: XCTestCase { measure { var count = 0 var statement: OpaquePointer? = nil - sqlite3_prepare_v2(connection, "SELECT * FROM items", -1, &statement, nil) + sqlite3_prepare_v2(connection, "SELECT * FROM item", -1, &statement, nil) loop: while true { switch sqlite3_step(statement) { @@ -60,7 +60,7 @@ class FetchPositionalValuesTests: XCTestCase { var count = 0 try! dbQueue.inDatabase { db in - let rows = try Row.fetchCursor(db, sql: "SELECT * FROM items") + let rows = try Row.fetchCursor(db, sql: "SELECT * FROM item") while let row = try rows.next() { _ = row[0] as Int _ = row[1] as Int @@ -91,7 +91,7 @@ class FetchPositionalValuesTests: XCTestCase { var count = 0 dbQueue.inDatabase { db in - let rs = try! db.executeQuery("SELECT * FROM items", values: nil) + let rs = try! db.executeQuery("SELECT * FROM item", values: nil) while rs.next() { _ = rs.long(forColumnIndex: 0) _ = rs.long(forColumnIndex: 1) @@ -120,7 +120,7 @@ class FetchPositionalValuesTests: XCTestCase { measure { var count = 0 - for row in try! db.prepare("SELECT * FROM items") { + for row in try! db.prepare("SELECT * FROM item") { // Direct Int extraction is not supported. _ = Int(row[0] as! Int64) _ = Int(row[1] as! Int64) diff --git a/Tests/Performance/GRDBPerformance/FetchRecordClassTests.swift b/Tests/Performance/GRDBPerformance/FetchRecordClassTests.swift index 67230aecef..96257b0d66 100644 --- a/Tests/Performance/GRDBPerformance/FetchRecordClassTests.swift +++ b/Tests/Performance/GRDBPerformance/FetchRecordClassTests.swift @@ -7,17 +7,51 @@ import RealmSwift private let expectedRowCount = 100_000 -/// Here we test the extraction of models from rows +/// Here we test the extraction of model objects able to tell if they were +/// modified since last fetched from the database. class FetchRecordClassTests: XCTestCase { func testGRDB() throws { + /// Record is the superclass of objects able to tell if they were + /// modified since last fetched from the database. + class Item: Record { + var i0: Int + var i1: Int + var i2: Int + var i3: Int + var i4: Int + var i5: Int + var i6: Int + var i7: Int + var i8: Int + var i9: Int + + override class var databaseTableName: String { + "item" + } + + required init(row: GRDB.Row) { + i0 = row["i0"] + i1 = row["i1"] + i2 = row["i2"] + i3 = row["i3"] + i4 = row["i4"] + i5 = row["i5"] + i6 = row["i6"] + i7 = row["i7"] + i8 = row["i8"] + i9 = row["i9"] + super.init(row: row) + } + } + let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("GRDBPerformanceTests.sqlite") try generateSQLiteDatabaseIfMissing(at: url, insertedRowCount: expectedRowCount) let dbQueue = try DatabaseQueue(path: url.path) measure { let items = try! dbQueue.inDatabase { db in - try ItemClass.fetchAll(db, sql: "SELECT * FROM items") + try Item.fetchAll(db) } XCTAssertEqual(items.count, expectedRowCount) XCTAssertEqual(items[0].i0, 0) diff --git a/Tests/Performance/GRDBPerformance/FetchRecordCodableTests.swift b/Tests/Performance/GRDBPerformance/FetchRecordDecodableTests.swift similarity index 59% rename from Tests/Performance/GRDBPerformance/FetchRecordCodableTests.swift rename to Tests/Performance/GRDBPerformance/FetchRecordDecodableTests.swift index c08d45cf24..1deb6f675c 100644 --- a/Tests/Performance/GRDBPerformance/FetchRecordCodableTests.swift +++ b/Tests/Performance/GRDBPerformance/FetchRecordDecodableTests.swift @@ -1,22 +1,32 @@ import XCTest import GRDB -#if GRDB_COMPARE -import SQLite -#endif private let expectedRowCount = 100_000 -/// Here we test the extraction of models from rows -class FetchRecordCodableTests: XCTestCase { +/// Here we test the extraction of Decodable GRDB records. +class FetchRecordDecodableTests: XCTestCase { func testGRDB() throws { + struct Item: Decodable, FetchableRecord, TableRecord { + var i0: Int + var i1: Int + var i2: Int + var i3: Int + var i4: Int + var i5: Int + var i6: Int + var i7: Int + var i8: Int + var i9: Int + } + let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("GRDBPerformanceTests.sqlite") try generateSQLiteDatabaseIfMissing(at: url, insertedRowCount: expectedRowCount) let dbQueue = try DatabaseQueue(path: url.path) measure { let items = try! dbQueue.inDatabase { db in - try ItemCodable.fetchAll(db, sql: "SELECT * FROM items") + try Item.fetchAll(db) } XCTAssertEqual(items.count, expectedRowCount) XCTAssertEqual(items[0].i0, 0) diff --git a/Tests/Performance/GRDBPerformance/FetchRecordOptimizedTests.swift b/Tests/Performance/GRDBPerformance/FetchRecordOptimizedTests.swift new file mode 100644 index 0000000000..2c024f5f07 --- /dev/null +++ b/Tests/Performance/GRDBPerformance/FetchRecordOptimizedTests.swift @@ -0,0 +1,63 @@ +import XCTest +import GRDB + +private let expectedRowCount = 100_000 + +/// A record optimized for fetching performance +private struct Item: Codable, FetchableRecord, PersistableRecord { + var i0: Int + var i1: Int + var i2: Int + var i3: Int + var i4: Int + var i5: Int + var i6: Int + var i7: Int + var i8: Int + var i9: Int + + init(row: Row) { + i0 = row[0] + i1 = row[1] + i2 = row[2] + i3 = row[3] + i4 = row[4] + i5 = row[5] + i6 = row[6] + i7 = row[7] + i8 = row[8] + i9 = row[9] + } + + static let databaseSelection: [SQLSelectable] = [ + Column("i0"), + Column("i1"), + Column("i2"), + Column("i3"), + Column("i4"), + Column("i5"), + Column("i6"), + Column("i7"), + Column("i8"), + Column("i9"), + ] +} + +/// Here we test the extraction of a plain Swift struct +class FetchRecordOptimizedTests: XCTestCase { + func testGRDB() throws { + let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("GRDBPerformanceTests.sqlite") + try generateSQLiteDatabaseIfMissing(at: url, insertedRowCount: expectedRowCount) + let dbQueue = try DatabaseQueue(path: url.path) + + measure { + let items = try! dbQueue.inDatabase { db in + try Item.fetchAll(db) + } + XCTAssertEqual(items.count, expectedRowCount) + XCTAssertEqual(items[0].i0, 0) + XCTAssertEqual(items[1].i1, 1) + XCTAssertEqual(items[expectedRowCount-1].i9, expectedRowCount-1) + } + } +} diff --git a/Tests/Performance/GRDBPerformance/FetchRecordStructTests.swift b/Tests/Performance/GRDBPerformance/FetchRecordStructTests.swift index 433b917e9f..7b78b61b70 100644 --- a/Tests/Performance/GRDBPerformance/FetchRecordStructTests.swift +++ b/Tests/Performance/GRDBPerformance/FetchRecordStructTests.swift @@ -6,7 +6,36 @@ import SQLite private let expectedRowCount = 100_000 -/// Here we test the extraction of models from rows +private struct Item { + var i0: Int + var i1: Int + var i2: Int + var i3: Int + var i4: Int + var i5: Int + var i6: Int + var i7: Int + var i8: Int + var i9: Int +} + +// GRDB support +extension Item: FetchableRecord, TableRecord { + init(row: GRDB.Row) { + i0 = row["i0"] + i1 = row["i1"] + i2 = row["i2"] + i3 = row["i3"] + i4 = row["i4"] + i5 = row["i5"] + i6 = row["i6"] + i7 = row["i7"] + i8 = row["i8"] + i9 = row["i9"] + } +} + +/// Here we test the extraction of a plain Swift struct class FetchRecordStructTests: XCTestCase { func testSQLite() throws { @@ -17,7 +46,7 @@ class FetchRecordStructTests: XCTestCase { measure { var statement: OpaquePointer? = nil - sqlite3_prepare_v2(connection, "SELECT * FROM items", -1, &statement, nil) + sqlite3_prepare_v2(connection, "SELECT * FROM item", -1, &statement, nil) let columnNames = (Int32(0)..<10).map { String(cString: sqlite3_column_name(statement, $0)) } let index0 = Int32(columnNames.firstIndex(of: "i0")!) @@ -31,13 +60,13 @@ class FetchRecordStructTests: XCTestCase { let index8 = Int32(columnNames.firstIndex(of: "i8")!) let index9 = Int32(columnNames.firstIndex(of: "i9")!) - var items = [ItemStruct]() + var items = [Item]() loop: while true { switch sqlite3_step(statement) { case 101 /*SQLITE_DONE*/: break loop case 100 /*SQLITE_ROW*/: - let item = ItemStruct( + let item = Item( i0: Int(sqlite3_column_int64(statement, index0)), i1: Int(sqlite3_column_int64(statement, index1)), i2: Int(sqlite3_column_int64(statement, index2)), @@ -73,7 +102,7 @@ class FetchRecordStructTests: XCTestCase { measure { let items = try! dbQueue.inDatabase { db in - try ItemStruct.fetchAll(db, sql: "SELECT * FROM items") + try Item.fetchAll(db) } XCTAssertEqual(items.count, expectedRowCount) XCTAssertEqual(items[0].i0, 0) @@ -91,11 +120,22 @@ class FetchRecordStructTests: XCTestCase { let dbQueue = FMDatabaseQueue(path: url.path)! measure { - var items = [ItemStruct]() + var items = [Item]() dbQueue.inDatabase { db in - let rs = try! db.executeQuery("SELECT * FROM items", values: nil) + let rs = try! db.executeQuery("SELECT * FROM item", values: nil) while rs.next() { - let item = ItemStruct(dictionary: rs.resultDictionary!) + let dict = rs.resultDictionary! + let item = Item( + i0: dict["i0"] as! Int, + i1: dict["i1"] as! Int, + i2: dict["i2"] as! Int, + i3: dict["i3"] as! Int, + i4: dict["i4"] as! Int, + i5: dict["i5"] as! Int, + i6: dict["i6"] as! Int, + i7: dict["i7"] as! Int, + i8: dict["i8"] as! Int, + i9: dict["i9"] as! Int) items.append(item) } } @@ -112,9 +152,9 @@ class FetchRecordStructTests: XCTestCase { let db = try Connection(url.path) measure { - var items = [ItemStruct]() - for row in try! db.prepare(itemsTable) { - let item = ItemStruct( + var items = [Item]() + for row in try! db.prepare(itemTable) { + let item = Item( i0: row[i0Column], i1: row[i1Column], i2: row[i2Column], diff --git a/Tests/Performance/GRDBPerformance/GRDBPerformance.xcodeproj/project.pbxproj b/Tests/Performance/GRDBPerformance/GRDBPerformance.xcodeproj/project.pbxproj index 889ee5996e..94644bf5e6 100755 --- a/Tests/Performance/GRDBPerformance/GRDBPerformance.xcodeproj/project.pbxproj +++ b/Tests/Performance/GRDBPerformance/GRDBPerformance.xcodeproj/project.pbxproj @@ -27,10 +27,14 @@ 56707201208A509C006AD95A /* DateParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567071FA208A509C006AD95A /* DateParsingTests.swift */; }; 5679870223A37A6A0076902D /* GRDB.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 567986B923A378CD0076902D /* GRDB.framework */; }; 5679870523A37A790076902D /* GRDB.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 567986B923A378CD0076902D /* GRDB.framework */; }; - 5690AFD82120589A001530EA /* InsertRecordCodableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5690AFD72120589A001530EA /* InsertRecordCodableTests.swift */; }; - 5690AFD92120589A001530EA /* InsertRecordCodableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5690AFD72120589A001530EA /* InsertRecordCodableTests.swift */; }; - 5690AFDB212058CB001530EA /* FetchRecordCodableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5690AFDA212058CB001530EA /* FetchRecordCodableTests.swift */; }; - 5690AFDC212058CB001530EA /* FetchRecordCodableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5690AFDA212058CB001530EA /* FetchRecordCodableTests.swift */; }; + 5690AFD82120589A001530EA /* InsertRecordEncodableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5690AFD72120589A001530EA /* InsertRecordEncodableTests.swift */; }; + 5690AFD92120589A001530EA /* InsertRecordEncodableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5690AFD72120589A001530EA /* InsertRecordEncodableTests.swift */; }; + 5690AFDB212058CB001530EA /* FetchRecordDecodableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5690AFDA212058CB001530EA /* FetchRecordDecodableTests.swift */; }; + 5690AFDC212058CB001530EA /* FetchRecordDecodableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5690AFDA212058CB001530EA /* FetchRecordDecodableTests.swift */; }; + 56B6D0E52618BF78003CC455 /* FetchRecordOptimizedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B6D0E42618BF78003CC455 /* FetchRecordOptimizedTests.swift */; }; + 56B6D0E62618BF78003CC455 /* FetchRecordOptimizedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B6D0E42618BF78003CC455 /* FetchRecordOptimizedTests.swift */; }; + 56B6D0EA2618C00C003CC455 /* InsertRecordOptimizedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B6D0E92618C00C003CC455 /* InsertRecordOptimizedTests.swift */; }; + 56B6D0EB2618C00C003CC455 /* InsertRecordOptimizedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B6D0E92618C00C003CC455 /* InsertRecordOptimizedTests.swift */; }; 56D3BE711F4EB1A00034C6D2 /* FetchRecordStructTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56D3BE701F4EB1900034C6D2 /* FetchRecordStructTests.swift */; }; 56D3BE721F4EB1A00034C6D2 /* FetchRecordStructTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56D3BE701F4EB1900034C6D2 /* FetchRecordStructTests.swift */; }; 56D507831F6D7B2E00AE1C5B /* InsertRecordStructTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56D507821F6D7A4500AE1C5B /* InsertRecordStructTests.swift */; }; @@ -133,8 +137,10 @@ 565BC5E72517645D00D2B53E /* Generation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generation.swift; sourceTree = ""; }; 567071FA208A509C006AD95A /* DateParsingTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DateParsingTests.swift; sourceTree = ""; }; 567986AD23A378CD0076902D /* GRDB.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = GRDB.xcodeproj; path = ../../../GRDB.xcodeproj; sourceTree = ""; }; - 5690AFD72120589A001530EA /* InsertRecordCodableTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InsertRecordCodableTests.swift; sourceTree = ""; }; - 5690AFDA212058CB001530EA /* FetchRecordCodableTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FetchRecordCodableTests.swift; sourceTree = ""; }; + 5690AFD72120589A001530EA /* InsertRecordEncodableTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InsertRecordEncodableTests.swift; sourceTree = ""; }; + 5690AFDA212058CB001530EA /* FetchRecordDecodableTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FetchRecordDecodableTests.swift; sourceTree = ""; }; + 56B6D0E42618BF78003CC455 /* FetchRecordOptimizedTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FetchRecordOptimizedTests.swift; sourceTree = ""; }; + 56B6D0E92618C00C003CC455 /* InsertRecordOptimizedTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InsertRecordOptimizedTests.swift; sourceTree = ""; }; 56BB86121BA9886D001F9168 /* InsertRecordClassTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InsertRecordClassTests.swift; sourceTree = ""; }; 56BB862D1BA98933001F9168 /* GRDBPerformanceComparisonTests-Bridging.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "GRDBPerformanceComparisonTests-Bridging.h"; sourceTree = ""; }; 56CA22211BB41565009A04C5 /* PerformanceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PerformanceTests.swift; sourceTree = ""; }; @@ -206,13 +212,15 @@ 56DE7B271C41302500861EB8 /* FetchNamedValuesTests.swift */, 56DE7B291C4130AF00861EB8 /* FetchPositionalValuesTests.swift */, 56DE7B2B1C41311900861EB8 /* FetchRecordClassTests.swift */, - 5690AFDA212058CB001530EA /* FetchRecordCodableTests.swift */, + 5690AFDA212058CB001530EA /* FetchRecordDecodableTests.swift */, + 56B6D0E42618BF78003CC455 /* FetchRecordOptimizedTests.swift */, 56D3BE701F4EB1900034C6D2 /* FetchRecordStructTests.swift */, 565BC5E72517645D00D2B53E /* Generation.swift */, 56DE7B251C412FDA00861EB8 /* InsertNamedValuesTests.swift */, 56DE7B231C412F7E00861EB8 /* InsertPositionalValuesTests.swift */, 56BB86121BA9886D001F9168 /* InsertRecordClassTests.swift */, - 5690AFD72120589A001530EA /* InsertRecordCodableTests.swift */, + 5690AFD72120589A001530EA /* InsertRecordEncodableTests.swift */, + 56B6D0E92618C00C003CC455 /* InsertRecordOptimizedTests.swift */, 56D507821F6D7A4500AE1C5B /* InsertRecordStructTests.swift */, 56CA22211BB41565009A04C5 /* PerformanceTests.swift */, 56DE7B341C42B37E00861EB8 /* CoreData.framework */, @@ -426,11 +434,13 @@ 56707201208A509C006AD95A /* DateParsingTests.swift in Sources */, 56DE7B2C1C41311900861EB8 /* FetchRecordClassTests.swift in Sources */, 56DE7B281C41302500861EB8 /* FetchNamedValuesTests.swift in Sources */, - 5690AFD82120589A001530EA /* InsertRecordCodableTests.swift in Sources */, - 5690AFDB212058CB001530EA /* FetchRecordCodableTests.swift in Sources */, + 5690AFD82120589A001530EA /* InsertRecordEncodableTests.swift in Sources */, + 56B6D0EA2618C00C003CC455 /* InsertRecordOptimizedTests.swift in Sources */, + 5690AFDB212058CB001530EA /* FetchRecordDecodableTests.swift in Sources */, 56D507831F6D7B2E00AE1C5B /* InsertRecordStructTests.swift in Sources */, 56DE7B241C412F7E00861EB8 /* InsertPositionalValuesTests.swift in Sources */, 560C98241C0E23BB00BF8471 /* PerformanceTests.swift in Sources */, + 56B6D0E52618BF78003CC455 /* FetchRecordOptimizedTests.swift in Sources */, 56DE7B261C412FDA00861EB8 /* InsertNamedValuesTests.swift in Sources */, 56DE7B2A1C4130AF00861EB8 /* FetchPositionalValuesTests.swift in Sources */, ); @@ -446,12 +456,14 @@ 56439B381F4CA1DC0066043F /* FetchNamedValuesTests.swift in Sources */, 56439B391F4CA1DC0066043F /* InsertPositionalValuesTests.swift in Sources */, 56D507841F6D7B2F00AE1C5B /* InsertRecordStructTests.swift in Sources */, - 5690AFD92120589A001530EA /* InsertRecordCodableTests.swift in Sources */, + 5690AFD92120589A001530EA /* InsertRecordEncodableTests.swift in Sources */, + 56B6D0EB2618C00C003CC455 /* InsertRecordOptimizedTests.swift in Sources */, 56439B3C1F4CA1DC0066043F /* PerformanceTests.swift in Sources */, 56D3BE721F4EB1A00034C6D2 /* FetchRecordStructTests.swift in Sources */, 56439B3D1F4CA1DC0066043F /* InsertNamedValuesTests.swift in Sources */, 56439B3E1F4CA1DC0066043F /* PerformanceModel.xcdatamodeld in Sources */, - 5690AFDC212058CB001530EA /* FetchRecordCodableTests.swift in Sources */, + 56B6D0E62618BF78003CC455 /* FetchRecordOptimizedTests.swift in Sources */, + 5690AFDC212058CB001530EA /* FetchRecordDecodableTests.swift in Sources */, 56439B3F1F4CA1DC0066043F /* FetchPositionalValuesTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Tests/Performance/GRDBPerformance/GRDBPerformance.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Tests/Performance/GRDBPerformance/GRDBPerformance.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6440727434..a79b099bbd 100644 --- a/Tests/Performance/GRDBPerformance/GRDBPerformance.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tests/Performance/GRDBPerformance/GRDBPerformance.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "repositoryURL": "https://github.com/realm/realm-cocoa.git", "state": { "branch": null, - "revision": "75cfd26033574d75aa81ceca2809b8ef46ecaa18", - "version": "10.5.2" + "revision": "ae8e646590396dfc13c1abbf8aa2e48c43766dce", + "version": "10.7.2" } }, { @@ -24,8 +24,8 @@ "repositoryURL": "https://github.com/realm/realm-core", "state": { "branch": null, - "revision": "b93ab22d2ff79b105f2a83450e5630d830166cfc", - "version": "10.4.0" + "revision": "bab46acdca91c417a0d4849b8f4992a3c17e29a5", + "version": "10.5.5" } }, { diff --git a/Tests/Performance/GRDBPerformance/Generation.swift b/Tests/Performance/GRDBPerformance/Generation.swift index a0bf320704..128bce9175 100644 --- a/Tests/Performance/GRDBPerformance/Generation.swift +++ b/Tests/Performance/GRDBPerformance/Generation.swift @@ -8,17 +8,17 @@ import RealmSwift func generateSQLiteDatabaseIfMissing(at url: URL, insertedRowCount: Int) throws { try DatabaseQueue(path: url.path).write { db in - if try db.tableExists("items") { - let count = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM items")! + if try db.tableExists("item") { + let count = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM item")! if count == insertedRowCount { return } else { - try db.execute(sql: "DROP TABLE items") + try db.execute(sql: "DROP TABLE item") } } - try db.execute(sql: "CREATE TABLE items (i0 INT, i1 INT, i2 INT, i3 INT, i4 INT, i5 INT, i6 INT, i7 INT, i8 INT, i9 INT)") + try db.execute(sql: "CREATE TABLE item (i0 INT, i1 INT, i2 INT, i3 INT, i4 INT, i5 INT, i6 INT, i7 INT, i8 INT, i9 INT)") - let statement = try! db.makeUpdateStatement(sql: "INSERT INTO items (i0, i1, i2, i3, i4, i5, i6, i7, i8, i9) VALUES (?,?,?,?,?,?,?,?,?,?)") + let statement = try! db.makeUpdateStatement(sql: "INSERT INTO item (i0, i1, i2, i3, i4, i5, i6, i7, i8, i9) VALUES (?,?,?,?,?,?,?,?,?,?)") for i in 0.. Void in db.shouldCacheStatements = true for i in 0.. Void in db.shouldCacheStatements = true for i in 0.. UpdateStatement { + try db.makeUpdateStatement(literal: """ + INSERT INTO \(self) ( + \(CodingKeys.i0), + \(CodingKeys.i1), + \(CodingKeys.i2), + \(CodingKeys.i3), + \(CodingKeys.i4), + \(CodingKeys.i5), + \(CodingKeys.i6), + \(CodingKeys.i7), + \(CodingKeys.i8), + \(CodingKeys.i9)) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """) + } + + func insert(with statement: UpdateStatement) throws { + statement.setUncheckedArguments([ + i0, + i1, + i2, + i3, + i4, + i5, + i6, + i7, + i8, + i9]) + try statement.execute() + } +} + +class InsertRecordOptimizedTests: XCTestCase { + func testGRDB() { + let databaseFileName = "GRDBPerformanceTests-\(ProcessInfo.processInfo.globallyUniqueString).sqlite" + let databasePath = (NSTemporaryDirectory() as NSString).appendingPathComponent(databaseFileName) + _ = try? FileManager.default.removeItem(atPath: databasePath) + defer { + let dbQueue = try! DatabaseQueue(path: databasePath) + try! dbQueue.inDatabase { db in + XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM item")!, insertedRowCount) + XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT MIN(i0) FROM item")!, 0) + XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT MAX(i9) FROM item")!, insertedRowCount - 1) + } + try! FileManager.default.removeItem(atPath: databasePath) + } + + measure { + _ = try? FileManager.default.removeItem(atPath: databasePath) + + let dbQueue = try! DatabaseQueue(path: databasePath) + try! dbQueue.inDatabase { db in + try db.execute(sql: "CREATE TABLE item (i0 INT, i1 INT, i2 INT, i3 INT, i4 INT, i5 INT, i6 INT, i7 INT, i8 INT, i9 INT)") + } + + try! dbQueue.inTransaction { db in + let statement = try Item.optimizedInsertStatement(db) + for i in 0..("i0") let i1Column = Expression("i1") let i2Column = Expression("i2") @@ -164,30 +21,9 @@ let i8Column = Expression("i8") let i9Column = Expression("i9") -// MARK:- FMDB - -extension ItemClass { - - convenience init(dictionary: [AnyHashable: Any]) { - self.init( - i0: dictionary["i0"] as! Int, - i1: dictionary["i1"] as! Int, - i2: dictionary["i2"] as! Int, - i3: dictionary["i3"] as! Int, - i4: dictionary["i4"] as! Int, - i5: dictionary["i5"] as! Int, - i6: dictionary["i6"] as! Int, - i7: dictionary["i7"] as! Int, - i8: dictionary["i8"] as! Int, - i9: dictionary["i9"] as! Int) - } - -} - - // MARK: - Realm -class RealmItem : RealmSwift.Object { +class RealmItem: RealmSwift.Object { @objc dynamic var i0: Int = 0 @objc dynamic var i1: Int = 0 @objc dynamic var i2: Int = 0 diff --git a/Tests/generatePerformanceReport.rb b/Tests/generatePerformanceReport.rb index 996ea5fa3b..1dcb8cd83b 100755 --- a/Tests/generatePerformanceReport.rb +++ b/Tests/generatePerformanceReport.rb @@ -100,8 +100,11 @@ def formatted_samples(samples, test) | Fetch | #{formatted_samples(samples, 'FetchRecordStruct').join(" | ")} | | Insert | #{formatted_samples(samples, 'InsertRecordStruct').join(" | ")} | | **Codable Records** | | | | | | | -| Fetch | #{formatted_samples(samples, 'FetchRecordCodable').join(" | ")} | -| Insert | #{formatted_samples(samples, 'InsertRecordCodable').join(" | ")} | +| Fetch | #{formatted_samples(samples, 'FetchRecordDecodable').join(" | ")} | +| Insert | #{formatted_samples(samples, 'InsertRecordEncodable').join(" | ")} | +| **Optimized Records** | | | | | | | +| Fetch | #{formatted_samples(samples, 'FetchRecordOptimized').join(" | ")} | +| Insert | #{formatted_samples(samples, 'InsertRecordOptimized').join(" | ")} | | **Records with change tracking** | | | | | | | | Fetch | #{formatted_samples(samples, 'FetchRecordClass').join(" | ")} | | Insert | #{formatted_samples(samples, 'InsertRecordClass').join(" | ")} | @@ -110,13 +113,13 @@ def formatted_samples(samples, test) - **Column indexes**: - - **Fetch** ([source](https://github.com/groue/GRDB.swift/blob/master/Tests/Performance/FetchPositionalValuesTests.swift)) + - **Fetch** ([source](https://github.com/groue/GRDB.swift/blob/master/Tests/Performance/GRDBPerformance/FetchPositionalValuesTests.swift)) This test fetches 100000 rows of 10 ints and extracts each int given its position in the row. It uses FMDB's `-[FMResultSet longForColumnIndex:]`, GRDB's `Row.value(atIndex:)`, and the low-level SQL API of SQLite.swift. - - **Insert** ([source](https://github.com/groue/GRDB.swift/blob/master/Tests/Performance/InsertPositionalValuesTests.swift)) + - **Insert** ([source](https://github.com/groue/GRDB.swift/blob/master/Tests/Performance/GRDBPerformance/InsertPositionalValuesTests.swift)) This test inserts 20000 rows of 10 ints, by setting query arguments given their position. @@ -124,13 +127,13 @@ def formatted_samples(samples, test) - **Column names**: - - **Fetch** ([source](https://github.com/groue/GRDB.swift/blob/master/Tests/Performance/FetchNamedValuesTests.swift)) + - **Fetch** ([source](https://github.com/groue/GRDB.swift/blob/master/Tests/Performance/GRDBPerformance/FetchNamedValuesTests.swift)) This test fetches 100000 rows of 10 ints and extracts each int given its column name. It uses FMDB's `-[FMResultSet longForColumn:]`, GRDB's `Row.value(named:)`, and the high-level query builder of SQLite.swift. - - **Insert** ([source](https://github.com/groue/GRDB.swift/blob/master/Tests/Performance/InsertNamedValuesTests.swift)) + - **Insert** ([source](https://github.com/groue/GRDB.swift/blob/master/Tests/Performance/GRDBPerformance/InsertNamedValuesTests.swift)) This test inserts 20000 rows of 10 ints, by setting query arguments given their argument name. @@ -138,37 +141,47 @@ def formatted_samples(samples, test) - **Records**: - - **Fetch** ([source](https://github.com/groue/GRDB.swift/blob/master/Tests/Performance/FetchRecordStructTests.swift)) + - **Fetch** ([source](https://github.com/groue/GRDB.swift/blob/master/Tests/Performance/GRDBPerformance/FetchRecordStructTests.swift)) This test fetches an array of 100000 record objects initiated from rows of 10 ints. It builds records from FMDB's `-[FMResultSet resultDictionary]`, GRDB's built-in [FetchableRecord](https://github.com/groue/GRDB.swift/blob/master/README.md#fetchablerecord-protocol) protocol, and the values returned by the high-level query builder of SQLite.swift. - - **Insert** ([source](https://github.com/groue/GRDB.swift/blob/master/Tests/Performance/InsertRecordStructTests.swift)) + - **Insert** ([source](https://github.com/groue/GRDB.swift/blob/master/Tests/Performance/GRDBPerformance/InsertRecordStructTests.swift)) This tests inserts 20000 records with the persistence method provided by GRDB's [PersistableRecord](https://github.com/groue/GRDB.swift/blob/master/README.md#persistablerecord-protocol) protocol. - **Codable Records**: - - **Fetch** ([source](https://github.com/groue/GRDB.swift/blob/master/Tests/Performance/FetchRecordCodableTests.swift)) + - **Fetch** ([source](https://github.com/groue/GRDB.swift/blob/master/Tests/Performance/GRDBPerformance/FetchRecordDecodableTests.swift)) This test fetches an array of 100000 record objects initiated from rows of 10 ints. It builds records from GRDB's built-in support for the [Decodable standard protocols](https://github.com/groue/GRDB.swift/blob/master/README.md#codable-records). - - **Insert** ([source](https://github.com/groue/GRDB.swift/blob/master/Tests/Performance/InsertRecordCodableTests.swift)) + - **Insert** ([source](https://github.com/groue/GRDB.swift/blob/master/Tests/Performance/GRDBPerformance/InsertRecordEncodableTests.swift)) This tests inserts 20000 records with the persistence method provided by GRDB's built-in support for the [Encodable standard protocols](https://github.com/groue/GRDB.swift/blob/master/README.md#codable-records). +- **Optimized Records**: + + - **Fetch** ([source](https://github.com/groue/GRDB.swift/blob/master/Tests/Performance/GRDBPerformance/FetchRecordDecodableTests.swift)) + + This test shows how to optimize Decodable Records for fetching. + + - **Insert** ([source](https://github.com/groue/GRDB.swift/blob/master/Tests/Performance/GRDBPerformance/InsertRecordEncodableTests.swift)) + + This test shows how to optimize Encodable Records for batch inserts. + - **Records with change tracking**: - - **Fetch** ([source](https://github.com/groue/GRDB.swift/blob/master/Tests/Performance/FetchRecordClassTests.swift)) + - **Fetch** ([source](https://github.com/groue/GRDB.swift/blob/master/Tests/Performance/GRDBPerformance/FetchRecordClassTests.swift)) This test fetches an array of 100000 record objects initiated from rows of 10 ints. It builds records from FMDB's `-[FMResultSet resultDictionary]`, GRDB's built-in [Record](https://github.com/groue/GRDB.swift/blob/master/README.md#record-class) class. - - **Insert** ([source](https://github.com/groue/GRDB.swift/blob/master/Tests/Performance/InsertRecordClassTests.swift)) + - **Insert** ([source](https://github.com/groue/GRDB.swift/blob/master/Tests/Performance/GRDBPerformance/InsertRecordClassTests.swift)) This tests inserts 20000 records with the persistence method provided by GRDB's [Record](https://github.com/groue/GRDB.swift/blob/master/README.md#record-class) class. REPORT