From 0a5bef4c0e5e09990c3a576355f6a9353c826deb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sat, 20 Apr 2024 14:03:39 +0200 Subject: [PATCH 1/2] SingletonUserDefaultsTest --- GRDB.xcodeproj/project.pbxproj | 4 + GRDBCustom.xcodeproj/project.pbxproj | 4 + .../GRDBTests.xcodeproj/project.pbxproj | 6 + .../GRDBTests.xcodeproj/project.pbxproj | 6 + Tests/GRDBTests/SingletonRecordTest.swift | 30 +-- .../GRDBTests/SingletonUserDefaultsTest.swift | 174 ++++++++++++++++++ 6 files changed, 209 insertions(+), 15 deletions(-) create mode 100644 Tests/GRDBTests/SingletonUserDefaultsTest.swift diff --git a/GRDB.xcodeproj/project.pbxproj b/GRDB.xcodeproj/project.pbxproj index 28ecb12530..9c13f9eb07 100755 --- a/GRDB.xcodeproj/project.pbxproj +++ b/GRDB.xcodeproj/project.pbxproj @@ -276,6 +276,7 @@ 56AFEF2F29969F6E00CA1E51 /* TransactionClock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56AFEF2E29969F6E00CA1E51 /* TransactionClock.swift */; }; 56AFEF372996B9DC00CA1E51 /* TransactionDateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56AFEF362996B9DC00CA1E51 /* TransactionDateTests.swift */; }; 56B021C91D8C0D3900B239BB /* MutablePersistableRecordPersistenceConflictPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B021C81D8C0D3900B239BB /* MutablePersistableRecordPersistenceConflictPolicyTests.swift */; }; + 56B6AB062BD3DCAC009A0B71 /* SingletonUserDefaultsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B6AB052BD3DCAC009A0B71 /* SingletonUserDefaultsTest.swift */; }; 56B6EF56208CB4E3002F0ACB /* ColumnExpressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B6EF55208CB4E3002F0ACB /* ColumnExpressionTests.swift */; }; 56B7EE832863781300C0525F /* WALSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B7EE822863781300C0525F /* WALSnapshot.swift */; }; 56B7F43A1BEB42D500E39BBF /* Migration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B7F4391BEB42D500E39BBF /* Migration.swift */; }; @@ -770,6 +771,7 @@ 56AFEF362996B9DC00CA1E51 /* TransactionDateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDateTests.swift; sourceTree = ""; }; 56B021C81D8C0D3900B239BB /* MutablePersistableRecordPersistenceConflictPolicyTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MutablePersistableRecordPersistenceConflictPolicyTests.swift; sourceTree = ""; }; 56B14E7E1D4DAE54000BF4A3 /* RowFromDictionaryLiteralTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RowFromDictionaryLiteralTests.swift; sourceTree = ""; }; + 56B6AB052BD3DCAC009A0B71 /* SingletonUserDefaultsTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SingletonUserDefaultsTest.swift; sourceTree = ""; }; 56B6EF55208CB4E3002F0ACB /* ColumnExpressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColumnExpressionTests.swift; sourceTree = ""; }; 56B7EE822863781300C0525F /* WALSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WALSnapshot.swift; sourceTree = ""; }; 56B7F4291BE14A1900E39BBF /* CGFloatTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGFloatTests.swift; sourceTree = ""; }; @@ -1552,6 +1554,7 @@ children = ( 564E73DE203D50B9000C443C /* JoinSupportTests.swift */, 5616B4FA28B5F5220052017E /* SingletonRecordTest.swift */, + 56B6AB052BD3DCAC009A0B71 /* SingletonUserDefaultsTest.swift */, 5674A7251F30A8EF0095F066 /* FetchableRecord */, 560B3FA41C19DFF800C58EC7 /* PersistableRecord */, 56176C9E1EACEDF9000F3F2B /* Record */, @@ -2086,6 +2089,7 @@ 562393181DECC02000A6B01F /* RowFetchTests.swift in Sources */, 56677C0D241CD0D00050755D /* ValueObservationRecorder.swift in Sources */, 5653EADA20944B4F00F46237 /* AssociationRowScopeSearchTests.swift in Sources */, + 56B6AB062BD3DCAC009A0B71 /* SingletonUserDefaultsTest.swift in Sources */, 563B5336267E2F90009549B5 /* TableTests.swift in Sources */, 56D4965A1D81304E008276D7 /* FoundationNSDataTests.swift in Sources */, 56D496791D81309E008276D7 /* RecordWithColumnNameManglingTests.swift in Sources */, diff --git a/GRDBCustom.xcodeproj/project.pbxproj b/GRDBCustom.xcodeproj/project.pbxproj index a0888b0b42..253e90b00e 100755 --- a/GRDBCustom.xcodeproj/project.pbxproj +++ b/GRDBCustom.xcodeproj/project.pbxproj @@ -267,6 +267,7 @@ 56AFEF3A2996B9EE00CA1E51 /* TransactionDateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56AFEF382996B9EE00CA1E51 /* TransactionDateTests.swift */; }; 56B021CC1D8C0D3900B239BB /* MutablePersistableRecordPersistenceConflictPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B021C81D8C0D3900B239BB /* MutablePersistableRecordPersistenceConflictPolicyTests.swift */; }; 56B14E821D4DAE54000BF4A3 /* RowFromDictionaryLiteralTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B14E7E1D4DAE54000BF4A3 /* RowFromDictionaryLiteralTests.swift */; }; + 56B6AB092BD3DCE0009A0B71 /* SingletonUserDefaultsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B6AB072BD3DCE0009A0B71 /* SingletonUserDefaultsTest.swift */; }; 56B6EF60208CB746002F0ACB /* ColumnExpressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B6EF5E208CB746002F0ACB /* ColumnExpressionTests.swift */; }; 56B86E70220FF4C900524C16 /* SQLLiteralTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B86E6E220FF4C800524C16 /* SQLLiteralTests.swift */; }; 56B9649F1DA51B4C0002DA19 /* FTS5.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B9649C1DA51B4C0002DA19 /* FTS5.swift */; }; @@ -783,6 +784,7 @@ 56AFEF382996B9EE00CA1E51 /* TransactionDateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDateTests.swift; sourceTree = ""; }; 56B021C81D8C0D3900B239BB /* MutablePersistableRecordPersistenceConflictPolicyTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MutablePersistableRecordPersistenceConflictPolicyTests.swift; sourceTree = ""; }; 56B14E7E1D4DAE54000BF4A3 /* RowFromDictionaryLiteralTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RowFromDictionaryLiteralTests.swift; sourceTree = ""; }; + 56B6AB072BD3DCE0009A0B71 /* SingletonUserDefaultsTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SingletonUserDefaultsTest.swift; sourceTree = ""; }; 56B6EF5E208CB746002F0ACB /* ColumnExpressionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColumnExpressionTests.swift; sourceTree = ""; }; 56B7F4291BE14A1900E39BBF /* CGFloatTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGFloatTests.swift; sourceTree = ""; }; 56B7F4391BEB42D500E39BBF /* Migration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Migration.swift; sourceTree = ""; }; @@ -1558,6 +1560,7 @@ children = ( 564E73E7203DA278000C443C /* JoinSupportTests.swift */, 5616B4FE28B5F5490052017E /* SingletonRecordTest.swift */, + 56B6AB072BD3DCE0009A0B71 /* SingletonUserDefaultsTest.swift */, 5674A7251F30A8EF0095F066 /* FetchableRecord */, 560B3FA41C19DFF800C58EC7 /* PersistableRecord */, 56176C9E1EACEDF9000F3F2B /* Record */, @@ -2330,6 +2333,7 @@ 5698AC431DA2BED90056AF8C /* FTS3PatternTests.swift in Sources */, 563B533B267E2FA4009549B5 /* TableTests.swift in Sources */, 5653EB6E20961FB200F46237 /* AssociationBelongsToSQLDerivationTests.swift in Sources */, + 56B6AB092BD3DCE0009A0B71 /* SingletonUserDefaultsTest.swift in Sources */, 561F38F62AC9CE5A0051EEE9 /* DatabaseDataDecodingStrategyTests.swift in Sources */, 564CE5C621B8FFE600652B19 /* DatabaseRegionObservationTests.swift in Sources */, F3BA80E11CFB300F003DC1BA /* DatabaseValueConversionTests.swift in Sources */, diff --git a/Tests/CocoaPods/SQLCipher3/GRDBTests.xcodeproj/project.pbxproj b/Tests/CocoaPods/SQLCipher3/GRDBTests.xcodeproj/project.pbxproj index 89f7c12005..2869b204eb 100644 --- a/Tests/CocoaPods/SQLCipher3/GRDBTests.xcodeproj/project.pbxproj +++ b/Tests/CocoaPods/SQLCipher3/GRDBTests.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* Begin PBXBuildFile section */ 5603CECF2AC8636E00CF097D /* JSONExpressionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5603CECE2AC8636E00CF097D /* JSONExpressionsTests.swift */; }; 5603CED02AC8636E00CF097D /* JSONExpressionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5603CECE2AC8636E00CF097D /* JSONExpressionsTests.swift */; }; + 56071DE52BD3DDB5000802B6 /* SingletonUserDefaultsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56071DE42BD3DDB5000802B6 /* SingletonUserDefaultsTest.swift */; }; + 56071DE62BD3DDB5000802B6 /* SingletonUserDefaultsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56071DE42BD3DDB5000802B6 /* SingletonUserDefaultsTest.swift */; }; 561F38DD2AC891710051EEE9 /* JSONColumnTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 561F38DC2AC891710051EEE9 /* JSONColumnTests.swift */; }; 561F38DE2AC891710051EEE9 /* JSONColumnTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 561F38DC2AC891710051EEE9 /* JSONColumnTests.swift */; }; 561F38F92AC9CE6D0051EEE9 /* DatabaseDataDecodingStrategyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 561F38F72AC9CE6D0051EEE9 /* DatabaseDataDecodingStrategyTests.swift */; }; @@ -502,6 +504,7 @@ 04298D834C818285823558AB /* Pods-GRDBTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GRDBTests.release.xcconfig"; path = "Target Support Files/Pods-GRDBTests/Pods-GRDBTests.release.xcconfig"; sourceTree = ""; }; 47C5D1B9AFFE795AA1D6EA5D /* Pods-GRDBTestsEncrypted.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GRDBTestsEncrypted.release.xcconfig"; path = "Target Support Files/Pods-GRDBTestsEncrypted/Pods-GRDBTestsEncrypted.release.xcconfig"; sourceTree = ""; }; 5603CECE2AC8636E00CF097D /* JSONExpressionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONExpressionsTests.swift; sourceTree = ""; }; + 56071DE42BD3DDB5000802B6 /* SingletonUserDefaultsTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SingletonUserDefaultsTest.swift; sourceTree = ""; }; 561F38DC2AC891710051EEE9 /* JSONColumnTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONColumnTests.swift; sourceTree = ""; }; 561F38F72AC9CE6D0051EEE9 /* DatabaseDataDecodingStrategyTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatabaseDataDecodingStrategyTests.swift; sourceTree = ""; }; 561F38F82AC9CE6D0051EEE9 /* DatabaseDataEncodingStrategyTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatabaseDataEncodingStrategyTests.swift; sourceTree = ""; }; @@ -1034,6 +1037,7 @@ 56419CFA24A5405A004967E1 /* SelectStatementTests.swift */, 567B5C282AD32A2D00629622 /* SharedValueObservationTests.swift */, 567B5C1E2AD32A2D00629622 /* SingletonRecordTest.swift */, + 56071DE42BD3DDB5000802B6 /* SingletonUserDefaultsTest.swift */, 567B5C232AD32A2D00629622 /* SQLExpressionIsConstantTests.swift */, 56419CCA24A54056004967E1 /* SQLExpressionLiteralTests.swift */, 567B5C1D2AD32A2D00629622 /* SQLIdentifyingColumnsTests.swift */, @@ -1459,6 +1463,7 @@ 56419E0324A54062004967E1 /* SchedulingWatchdogTests.swift in Sources */, 56419E7D24A54063004967E1 /* DropWhileCursorTests.swift in Sources */, 56419EBD24A54063004967E1 /* AssociationHasOneThroughSQLDerivationTests.swift in Sources */, + 56071DE52BD3DDB5000802B6 /* SingletonUserDefaultsTest.swift in Sources */, 56419E8F24A54063004967E1 /* AssociationHasManySQLTests.swift in Sources */, 5641A1A424A540D6004967E1 /* Support.swift in Sources */, 5641A1BC24A540D6004967E1 /* PublisherExpectation.swift in Sources */, @@ -1706,6 +1711,7 @@ 56419E0424A54062004967E1 /* SchedulingWatchdogTests.swift in Sources */, 56419E7E24A54063004967E1 /* DropWhileCursorTests.swift in Sources */, 56419EBE24A54063004967E1 /* AssociationHasOneThroughSQLDerivationTests.swift in Sources */, + 56071DE62BD3DDB5000802B6 /* SingletonUserDefaultsTest.swift in Sources */, 56419E9024A54063004967E1 /* AssociationHasManySQLTests.swift in Sources */, 5641A1A524A540D6004967E1 /* Support.swift in Sources */, 5641A1BD24A540D6004967E1 /* PublisherExpectation.swift in Sources */, diff --git a/Tests/CocoaPods/SQLCipher4/GRDBTests.xcodeproj/project.pbxproj b/Tests/CocoaPods/SQLCipher4/GRDBTests.xcodeproj/project.pbxproj index 8fba6727d5..f8c3e463fe 100644 --- a/Tests/CocoaPods/SQLCipher4/GRDBTests.xcodeproj/project.pbxproj +++ b/Tests/CocoaPods/SQLCipher4/GRDBTests.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 56071DE22BD3DDA5000802B6 /* SingletonUserDefaultsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56071DE12BD3DDA5000802B6 /* SingletonUserDefaultsTest.swift */; }; + 56071DE32BD3DDA5000802B6 /* SingletonUserDefaultsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56071DE12BD3DDA5000802B6 /* SingletonUserDefaultsTest.swift */; }; 561F38E12AC891890051EEE9 /* JSONExpressionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 561F38DF2AC891890051EEE9 /* JSONExpressionsTests.swift */; }; 561F38E22AC891890051EEE9 /* JSONExpressionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 561F38DF2AC891890051EEE9 /* JSONExpressionsTests.swift */; }; 561F38E32AC891890051EEE9 /* JSONColumnTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 561F38E02AC891890051EEE9 /* JSONColumnTests.swift */; }; @@ -503,6 +505,7 @@ /* Begin PBXFileReference section */ 04298D834C818285823558AB /* Pods-GRDBTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GRDBTests.release.xcconfig"; path = "Target Support Files/Pods-GRDBTests/Pods-GRDBTests.release.xcconfig"; sourceTree = ""; }; 47C5D1B9AFFE795AA1D6EA5D /* Pods-GRDBTestsEncrypted.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GRDBTestsEncrypted.release.xcconfig"; path = "Target Support Files/Pods-GRDBTestsEncrypted/Pods-GRDBTestsEncrypted.release.xcconfig"; sourceTree = ""; }; + 56071DE12BD3DDA5000802B6 /* SingletonUserDefaultsTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SingletonUserDefaultsTest.swift; sourceTree = ""; }; 561F38DF2AC891890051EEE9 /* JSONExpressionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONExpressionsTests.swift; sourceTree = ""; }; 561F38E02AC891890051EEE9 /* JSONColumnTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONColumnTests.swift; sourceTree = ""; }; 561F38FD2AC9CE870051EEE9 /* DatabaseDataEncodingStrategyTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatabaseDataEncodingStrategyTests.swift; sourceTree = ""; }; @@ -1038,6 +1041,7 @@ 56419F5B24A54098004967E1 /* SelectStatementTests.swift */, 567B5C0B2AD32A0000629622 /* SharedValueObservationTests.swift */, 567B5C0A2AD32A0000629622 /* SingletonRecordTest.swift */, + 56071DE12BD3DDA5000802B6 /* SingletonUserDefaultsTest.swift */, 567B5C0C2AD32A0000629622 /* SQLExpressionIsConstantTests.swift */, 56419F1C24A54094004967E1 /* SQLExpressionLiteralTests.swift */, 567B5C0D2AD32A0000629622 /* SQLIdentifyingColumnsTests.swift */, @@ -1465,6 +1469,7 @@ 5641A04424A540A1004967E1 /* FTS3TokenizerTests.swift in Sources */, 5641A14024A540A2004967E1 /* RecordPrimaryKeyRowIDTests.swift in Sources */, 561F38FF2AC9CE870051EEE9 /* DatabaseDataEncodingStrategyTests.swift in Sources */, + 56071DE22BD3DDA5000802B6 /* SingletonUserDefaultsTest.swift in Sources */, 5641A08A24A540A1004967E1 /* EncryptionTests.swift in Sources */, 5641A11E24A540A1004967E1 /* IndexInfoTests.swift in Sources */, 56419FEC24A540A1004967E1 /* FTS4TableBuilderTests.swift in Sources */, @@ -1712,6 +1717,7 @@ 5641A04524A540A1004967E1 /* FTS3TokenizerTests.swift in Sources */, 5641A14124A540A2004967E1 /* RecordPrimaryKeyRowIDTests.swift in Sources */, 561F39002AC9CE870051EEE9 /* DatabaseDataEncodingStrategyTests.swift in Sources */, + 56071DE32BD3DDA5000802B6 /* SingletonUserDefaultsTest.swift in Sources */, 5641A08B24A540A1004967E1 /* EncryptionTests.swift in Sources */, 5641A11F24A540A1004967E1 /* IndexInfoTests.swift in Sources */, 56419FED24A540A1004967E1 /* FTS4TableBuilderTests.swift in Sources */, diff --git a/Tests/GRDBTests/SingletonRecordTest.swift b/Tests/GRDBTests/SingletonRecordTest.swift index ea8008c254..a3fb0766ee 100644 --- a/Tests/GRDBTests/SingletonRecordTest.swift +++ b/Tests/GRDBTests/SingletonRecordTest.swift @@ -31,7 +31,7 @@ extension AppConfiguration: FetchableRecord, PersistableRecord { /// Returns the persisted configuration, or the default one if the /// database table is empty. - static func fetch(_ db: Database) throws -> AppConfiguration { + static func find(_ db: Database) throws -> AppConfiguration { try fetchOne(db) ?? .default } } @@ -54,7 +54,7 @@ class SingletonRecordTest: GRDBTestCase { // Given try createEmptyAppConfigurationTable(db) // When - let config = try AppConfiguration.fetch(db) + let config = try AppConfiguration.find(db) // Then XCTAssertEqual(config.text, "default") } @@ -66,7 +66,7 @@ class SingletonRecordTest: GRDBTestCase { try createEmptyAppConfigurationTable(db) try AppConfiguration(text: "initial").insert(db) // When - let config = try AppConfiguration.fetch(db) + let config = try AppConfiguration.find(db) // Then XCTAssertEqual(config.text, "initial") } @@ -79,7 +79,7 @@ class SingletonRecordTest: GRDBTestCase { // When try AppConfiguration(text: "test").insert(db) // Then - try XCTAssertEqual(AppConfiguration.fetch(db).text, "test") + try XCTAssertEqual(AppConfiguration.find(db).text, "test") let row = try XCTUnwrap(Row.fetchOne(db, sql: "SELECT * FROM appConfiguration")) XCTAssertEqual(row, ["id": 1, "text": "test"]) } @@ -93,7 +93,7 @@ class SingletonRecordTest: GRDBTestCase { // When try AppConfiguration(text: "test").insert(db) // Then - try XCTAssertEqual(AppConfiguration.fetch(db).text, "test") + try XCTAssertEqual(AppConfiguration.find(db).text, "test") let row = try XCTUnwrap(Row.fetchOne(db, sql: "SELECT * FROM appConfiguration")) XCTAssertEqual(row, ["id": 1, "text": "test"]) } @@ -106,7 +106,7 @@ class SingletonRecordTest: GRDBTestCase { // When try AppConfiguration(text: "test").update(db) // Then - try XCTAssertEqual(AppConfiguration.fetch(db).text, "test") + try XCTAssertEqual(AppConfiguration.find(db).text, "test") let row = try XCTUnwrap(Row.fetchOne(db, sql: "SELECT * FROM appConfiguration")) XCTAssertEqual(row, ["id": 1, "text": "test"]) } @@ -120,7 +120,7 @@ class SingletonRecordTest: GRDBTestCase { // When try AppConfiguration(text: "test").update(db) // Then - try XCTAssertEqual(AppConfiguration.fetch(db).text, "test") + try XCTAssertEqual(AppConfiguration.find(db).text, "test") let row = try XCTUnwrap(Row.fetchOne(db, sql: "SELECT * FROM appConfiguration")) XCTAssertEqual(row, ["id": 1, "text": "test"]) } @@ -131,12 +131,12 @@ class SingletonRecordTest: GRDBTestCase { // Given try createEmptyAppConfigurationTable(db) // When - var config = try AppConfiguration.fetch(db) + var config = try AppConfiguration.find(db) try config.updateChanges(db) { $0.text = "test" } // Then - try XCTAssertEqual(AppConfiguration.fetch(db).text, "test") + try XCTAssertEqual(AppConfiguration.find(db).text, "test") let row = try XCTUnwrap(Row.fetchOne(db, sql: "SELECT * FROM appConfiguration")) XCTAssertEqual(row, ["id": 1, "text": "test"]) } @@ -148,12 +148,12 @@ class SingletonRecordTest: GRDBTestCase { try createEmptyAppConfigurationTable(db) try AppConfiguration(text: "initial").insert(db) // When - var config = try AppConfiguration.fetch(db) + var config = try AppConfiguration.find(db) try config.updateChanges(db) { $0.text = "test" } // Then - try XCTAssertEqual(AppConfiguration.fetch(db).text, "test") + try XCTAssertEqual(AppConfiguration.find(db).text, "test") let row = try XCTUnwrap(Row.fetchOne(db, sql: "SELECT * FROM appConfiguration")) XCTAssertEqual(row, ["id": 1, "text": "test"]) } @@ -166,7 +166,7 @@ class SingletonRecordTest: GRDBTestCase { // When try AppConfiguration(text: "test").save(db) // Then - try XCTAssertEqual(AppConfiguration.fetch(db).text, "test") + try XCTAssertEqual(AppConfiguration.find(db).text, "test") let row = try XCTUnwrap(Row.fetchOne(db, sql: "SELECT * FROM appConfiguration")) XCTAssertEqual(row, ["id": 1, "text": "test"]) } @@ -180,7 +180,7 @@ class SingletonRecordTest: GRDBTestCase { // When try AppConfiguration(text: "test").save(db) // Then - try XCTAssertEqual(AppConfiguration.fetch(db).text, "test") + try XCTAssertEqual(AppConfiguration.find(db).text, "test") let row = try XCTUnwrap(Row.fetchOne(db, sql: "SELECT * FROM appConfiguration")) XCTAssertEqual(row, ["id": 1, "text": "test"]) } @@ -203,7 +203,7 @@ class SingletonRecordTest: GRDBTestCase { // When try AppConfiguration(text: "test").upsert(db) // Then - try XCTAssertEqual(AppConfiguration.fetch(db).text, "test") + try XCTAssertEqual(AppConfiguration.find(db).text, "test") let row = try XCTUnwrap(Row.fetchOne(db, sql: "SELECT * FROM appConfiguration")) XCTAssertEqual(row, ["id": 1, "text": "test"]) } @@ -227,7 +227,7 @@ class SingletonRecordTest: GRDBTestCase { // When try AppConfiguration(text: "test").upsert(db) // Then - try XCTAssertEqual(AppConfiguration.fetch(db).text, "test") + try XCTAssertEqual(AppConfiguration.find(db).text, "test") let row = try XCTUnwrap(Row.fetchOne(db, sql: "SELECT * FROM appConfiguration")) XCTAssertEqual(row, ["id": 1, "text": "test"]) } diff --git a/Tests/GRDBTests/SingletonUserDefaultsTest.swift b/Tests/GRDBTests/SingletonUserDefaultsTest.swift new file mode 100644 index 0000000000..7db9f80e31 --- /dev/null +++ b/Tests/GRDBTests/SingletonUserDefaultsTest.swift @@ -0,0 +1,174 @@ +import XCTest +import GRDB + +private struct AppConfiguration: Codable { + // Support for the single row guarantee + private var id = 1 + + // The stored properties + private var storedText: String? + // ... other properties + + // The public properties + var text: String { + get { storedText ?? "default" } + set { storedText = newValue } + } + + mutating func resetText() { + storedText = nil + } +} + +extension AppConfiguration { + /// The default configuration + static let `default` = AppConfiguration() +} + +// Database Access +extension AppConfiguration: FetchableRecord, PersistableRecord { + // Customize the default PersistableRecord behavior + func willUpdate(_ db: Database, columns: Set) throws { + // Insert the default configuration if it does not exist yet. + if try !exists(db) { + try AppConfiguration.default.insert(db) + } + } + + /// Returns the persisted configuration, or the default one if the + /// database table is empty. + static func find(_ db: Database) throws -> AppConfiguration { + try fetchOne(db) ?? .default + } +} + +class SingletonUserDefaultsTest: GRDBTestCase { + private func createEmptyAppConfigurationTable(_ db: Database) throws { + // Table creation + try db.create(table: "appConfiguration") { t in + // Single row guarantee + t.primaryKey("id", .integer, onConflict: .replace).check { $0 == 1 } + + // The configuration columns + t.column("storedText", .text) + // ... other columns + } + } + + func test_find_in_empty_database() throws { + try makeDatabaseQueue().write { db in + // Given + try createEmptyAppConfigurationTable(db) + // When + let config = try AppConfiguration.find(db) + // Then + XCTAssertEqual(config.text, "default") + } + } + + func test_find_in_populated_database_null() throws { + try makeDatabaseQueue().write { db in + // Given + try createEmptyAppConfigurationTable(db) + try AppConfiguration().insert(db) + // When + let config = try AppConfiguration.find(db) + // Then + XCTAssertEqual(config.text, "default") + } + } + + func test_find_from_populated_database_not_null() throws { + try makeDatabaseQueue().write { db in + // Given + try createEmptyAppConfigurationTable(db) + try db.execute(sql: "INSERT INTO appConfiguration(storedText) VALUES ('initial')") + // When + let config = try AppConfiguration.find(db) + // Then + XCTAssertEqual(config.text, "initial") + } + } + + func test_save_in_empty_database() throws { + try makeDatabaseQueue().write { db in + // Given + try createEmptyAppConfigurationTable(db) + // When + var appConfiguration = try AppConfiguration.find(db) + appConfiguration.text = "test" + try appConfiguration.save(db) + // Then + try XCTAssertEqual(AppConfiguration.find(db).text, "test") + let row = try XCTUnwrap(Row.fetchOne(db, sql: "SELECT * FROM appConfiguration")) + XCTAssertEqual(row, ["id": 1, "storedText": "test"]) + } + } + + func test_save_in_populated_database() throws { + try makeDatabaseQueue().write { db in + // Given + try createEmptyAppConfigurationTable(db) + try db.execute(sql: "INSERT INTO appConfiguration(storedText) VALUES ('initial')") + // When + var appConfiguration = try AppConfiguration.find(db) + appConfiguration.text = "test" + try appConfiguration.save(db) + // Then + try XCTAssertEqual(AppConfiguration.find(db).text, "test") + let row = try XCTUnwrap(Row.fetchOne(db, sql: "SELECT * FROM appConfiguration")) + XCTAssertEqual(row, ["id": 1, "storedText": "test"]) + } + } + + func test_reset_and_save_in_populated_database() throws { + try makeDatabaseQueue().write { db in + // Given + try createEmptyAppConfigurationTable(db) + try db.execute(sql: "INSERT INTO appConfiguration(storedText) VALUES ('initial')") + // When + var appConfiguration = try AppConfiguration.find(db) + appConfiguration.resetText() + try appConfiguration.save(db) + // Then + try XCTAssertEqual(AppConfiguration.find(db).text, "default") + let row = try XCTUnwrap(Row.fetchOne(db, sql: "SELECT * FROM appConfiguration")) + XCTAssertEqual(row, ["id": 1, "storedText": nil]) + } + } + + func test_update_changes_in_empty_database() throws { + try makeDatabaseQueue().write { db in + // Given + try createEmptyAppConfigurationTable(db) + // When + var config = try AppConfiguration.find(db) + try config.updateChanges(db) { + $0.text = "test" + } + // Then + XCTAssertEqual(config.text, "test") + try XCTAssertEqual(AppConfiguration.find(db).text, "test") + let row = try XCTUnwrap(Row.fetchOne(db, sql: "SELECT * FROM appConfiguration")) + XCTAssertEqual(row, ["id": 1, "storedText": "test"]) + } + } + + func test_update_changes_in_populated_database() throws { + try makeDatabaseQueue().write { db in + // Given + try createEmptyAppConfigurationTable(db) + try db.execute(sql: "INSERT INTO appConfiguration(storedText) VALUES ('initial')") + // When + var config = try AppConfiguration.find(db) + try config.updateChanges(db) { + $0.text = "test" + } + // Then + XCTAssertEqual(config.text, "test") + try XCTAssertEqual(AppConfiguration.find(db).text, "test") + let row = try XCTUnwrap(Row.fetchOne(db, sql: "SELECT * FROM appConfiguration")) + XCTAssertEqual(row, ["id": 1, "storedText": "test"]) + } + } +} From bde61b26abe13b0708f992a057f42662ae632b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sat, 20 Apr 2024 14:42:14 +0200 Subject: [PATCH 2/2] Rewrite SingleRowTables, accounting for nullable columns This addresses https://github.com/groue/GRDB.swift/discussions/1526 --- GRDB/Documentation.docc/SingleRowTables.md | 142 ++++++++++++--------- 1 file changed, 85 insertions(+), 57 deletions(-) diff --git a/GRDB/Documentation.docc/SingleRowTables.md b/GRDB/Documentation.docc/SingleRowTables.md index cf90c69953..b3c842fcf9 100644 --- a/GRDB/Documentation.docc/SingleRowTables.md +++ b/GRDB/Documentation.docc/SingleRowTables.md @@ -8,9 +8,11 @@ Database tables that contain a single row can store configuration values, user p They are a suitable alternative to `UserDefaults` in some applications, especially when configuration refers to values found in other database tables, and database integrity is a concern. -An alternative way to store such configuration is a table of key-value pairs: two columns, and one row for each configuration value. This technique works, but it has a few drawbacks: you will have to deal with the various types of configuration values (strings, integers, dates, etc), and you won't be able to define foreign keys. This is why we won't explore key-value tables. +A possible way to store such configuration is a table of key-value pairs: two columns, and one row for each configuration value. This technique works, but it has a few drawbacks: one has to deal with the various types of configuration values (strings, integers, dates, etc), and it is not possible to define foreign keys. This is why we won't explore key-value tables. -This guide helps implementing a single-row table with GRDB, with recommendations on the database schema, migrations, and the design of a matching record type. +In this guide, we'll implement a single-row table, with recommendations on the database schema, migrations, and the design of a Swift API for accessing the configuration values. The schema will define one column for each configuration value, because we aim at being able to deal with foreign keys and references to other tables. You may prefer storing configuration values in a single JSON column. In this case, take inspiration from this guide, as well as . + +We will also aim at providing a default value for a given configuration, even when it is not stored on disk yet. This is a feature similar to [`UserDefaults.register(defaults:)`](https://developer.apple.com/documentation/foundation/userdefaults/1417065-register). ## The Single-Row Table @@ -20,63 +22,43 @@ We want to instruct SQLite that our table must never contain more than one row. SQLite is not able to guarantee that the table is never empty, so we have to deal with two cases: either the table is empty, or it contains one row. -Those two cases can create a nagging question for the application. By default, inserts fail when the row already exists, and updates fail when the table is empty. In order to avoid those errors, we will have the app deal with updates in the section below. Right now, we instruct SQLite to just replace the eventual existing row in case of conflicting inserts: - -```swift -// CREATE TABLE appConfiguration ( -// id INTEGER PRIMARY KEY ON CONFLICT REPLACE CHECK (id = 1), -// flag BOOLEAN NOT NULL, -// ...) -try db.create(table: "appConfiguration") { t in - // Single row guarantee: have inserts replace the existing row - t.primaryKey("id", .integer, onConflict: .replace) - // Make sure the id column is always 1 - .check { $0 == 1 } - - // The configuration columns - t.column("flag", .boolean).notNull() - // ... other columns -} -``` - -When you use , you may wonder if it is a good idea or not to perform an initial insert just after the table is created. Well, this is not recommended: +Those two cases can create a nagging question for the application. By default, inserts fail when the row already exists, and updates fail when the table is empty. In order to avoid those errors, we will have the app deal with updates in the section below. Right now, we instruct SQLite to just replace the eventual existing row in case of conflicting inserts. ```swift -// NOT RECOMMENDED migrator.registerMigration("appConfiguration") { db in + // CREATE TABLE appConfiguration ( + // id INTEGER PRIMARY KEY ON CONFLICT REPLACE CHECK (id = 1), + // storedFlag BOOLEAN, + // ...) try db.create(table: "appConfiguration") { t in - // The single row guarantee - t.primaryKey("id", .integer, onConflict: .replace).check { $0 == 1 } + // Single row guarantee: have inserts replace the existing row, + // and make sure the id column is always 1. + t.primaryKey("id", .integer, onConflict: .replace) + .check { $0 == 1 } - // Define sensible defaults for each column - t.column("flag", .boolean).notNull() - .defaults(to: false) + // The configuration columns + t.column("storedFlag", .boolean) // ... other columns } - - // Populate the table - try db.execute(sql: "INSERT INTO appConfiguration DEFAULT VALUES") } ``` -It is not a good idea to populate the table in a migration, for two reasons: +Note how the database table is defined in a migration. That's because most apps evolve, and need to add other configuration columns eventually. See for more information. -1. This migration is not a hard guarantee that the table will never be empty. As a consequence, this won't prevent the application code from dealing with the possibility of a missing row. On top of that, this application code may not use the same default values as the SQLite schema, with unclear consequences. +We have defined a `storedFlag` column that can be NULL. That may be surprising, because optional booleans are usually a bad idea! But we can deal with this NULL at runtime, and nullable columns have a few advantages: -2. Migrations that have been deployed on the users' devices should never change (see ). Inserting an initial row in a migration makes it difficult for the application to adjust the sensible default values in a future version. +- NULL means that the application user had not made a choice yet. When `storedFlag` is NULL, the app can use a default value, such as `true`. +- As application evolves, application will need to add new configuration columns. It is not always possible to provide a sensible default value for these new columns, at the moment the table is modified. On the other side, it is generally possible to deal with those NULL values at runtime. -The recommended migration creates the table, nothing more: +Despite those arguments, some apps absolutely require a value. In this case, don't weaken the application logic and make sure the database can't store a NULL value: ```swift -// RECOMMENDED +// DO NOT hesitate requiring NOT NULL columns when the app requires it. migrator.registerMigration("appConfiguration") { db in try db.create(table: "appConfiguration") { t in - // The single row guarantee t.primaryKey("id", .integer, onConflict: .replace).check { $0 == 1 } - // The configuration columns - t.column("flag", .boolean).notNull() - // ... other columns + t.column("flag", .boolean).notNull() // required } } ``` @@ -91,7 +73,37 @@ struct AppConfiguration: Codable { // Support for the single row guarantee private var id = 1 - // The configuration properties + // The stored properties + private var storedFlag: Bool? + // ... other properties +} +``` + +The `storedFlag` property is private, because we want to expose a nice `flag` property that has a default value when `storedFlag` is nil: + +```swift +// Support for default values +extension AppConfiguration { + var flag: Bool { + get { storedFlag ?? true /* the default value */ } + set { storedFlag = newValue } + } + + mutating func resetFlag() { + storedFlag = nil + } +} +``` + +This ceremony is not needed when the column can not be null: + +```swift +// The simplified setup for non-nullable columns +struct AppConfiguration: Codable { + // Support for the single row guarantee + private var id = 1 + + // The stored properties var flag: Bool // ... other properties } @@ -102,7 +114,7 @@ In case the database table would be empty, we need a default configuration: ```swift extension AppConfiguration { /// The default configuration - static let `default` = AppConfiguration(flag: false) + static let `default` = AppConfiguration(flag: nil) } ``` @@ -129,7 +141,7 @@ The standard GRDB method ``FetchableRecord/fetchOne(_:)`` returns an optional wh ```swift /// Returns the persisted configuration, or the default one if the /// database table is empty. - static func fetch(_ db: Database) throws -> AppConfiguration { + static func find(_ db: Database) throws -> AppConfiguration { try fetchOne(db) ?? .default } } @@ -140,7 +152,7 @@ And that's it! Now we can use our singleton record: ```swift // READ let config = try dbQueue.read { db in - try AppConfiguration.fetch(db) + try AppConfiguration.find(db) } if config.flag { // ... @@ -148,17 +160,19 @@ if config.flag { // WRITE try dbQueue.write { db in - // Saves a new config in the database - var config = try AppConfiguration.fetch(db) + // Update the config in the database + var config = try AppConfiguration.find(db) try config.updateChanges(db) { $0.flag = true } // Other possible ways to save the config: - try config.save(db) - try config.update(db) - try config.insert(db) - try config.upsert(db) + var config = try AppConfiguration.find(db) + config.flag = true + try config.save(db) // all the same + try config.update(db) // all the same + try config.insert(db) // all the same + try config.upsert(db) // all the same } ``` @@ -172,11 +186,13 @@ We all love to copy and paste, don't we? Just customize the template code below: ```swift // Table creation try db.create(table: "appConfiguration") { t in - // The single row guarantee - t.primaryKey("id", .integer, onConflict: .replace).check { $0 == 1 } + // Single row guarantee: have inserts replace the existing row, + // and make sure the id column is always 1. + t.primaryKey("id", .integer, onConflict: .replace) + .check { $0 == 1 } // The configuration columns - t.column("flag", .boolean).notNull() + t.column("storedFlag", .boolean) // ... other columns } ``` @@ -192,14 +208,26 @@ struct AppConfiguration: Codable { // Support for the single row guarantee private var id = 1 - // The configuration properties - var flag: Bool + // The stored properties + private var storedFlag: Bool? // ... other properties } +// Support for default values +extension AppConfiguration { + var flag: Bool { + get { storedFlag ?? true /* the default value */ } + set { storedFlag = newValue } + } + + mutating func resetFlag() { + storedFlag = nil + } +} + extension AppConfiguration { /// The default configuration - static let `default` = AppConfiguration(flag: false, ...) + static let `default` = AppConfiguration(storedFlag: nil) } // Database Access @@ -214,7 +242,7 @@ extension AppConfiguration: FetchableRecord, PersistableRecord { /// Returns the persisted configuration, or the default one if the /// database table is empty. - static func fetch(_ db: Database) throws -> AppConfiguration { + static func find(_ db: Database) throws -> AppConfiguration { try fetchOne(db) ?? .default } }