Skip to content

Commit 3f3cfa5

Browse files
authored
Merge pull request #1534 from groue/dev/singleton-rewrite
Update the "Single-Row Tables" guide, with support for default values
2 parents 218774d + bde61b2 commit 3f3cfa5

File tree

7 files changed

+294
-72
lines changed

7 files changed

+294
-72
lines changed

GRDB.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@
276276
56AFEF2F29969F6E00CA1E51 /* TransactionClock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56AFEF2E29969F6E00CA1E51 /* TransactionClock.swift */; };
277277
56AFEF372996B9DC00CA1E51 /* TransactionDateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56AFEF362996B9DC00CA1E51 /* TransactionDateTests.swift */; };
278278
56B021C91D8C0D3900B239BB /* MutablePersistableRecordPersistenceConflictPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B021C81D8C0D3900B239BB /* MutablePersistableRecordPersistenceConflictPolicyTests.swift */; };
279+
56B6AB062BD3DCAC009A0B71 /* SingletonUserDefaultsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B6AB052BD3DCAC009A0B71 /* SingletonUserDefaultsTest.swift */; };
279280
56B6EF56208CB4E3002F0ACB /* ColumnExpressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B6EF55208CB4E3002F0ACB /* ColumnExpressionTests.swift */; };
280281
56B7EE832863781300C0525F /* WALSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B7EE822863781300C0525F /* WALSnapshot.swift */; };
281282
56B7F43A1BEB42D500E39BBF /* Migration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B7F4391BEB42D500E39BBF /* Migration.swift */; };
@@ -770,6 +771,7 @@
770771
56AFEF362996B9DC00CA1E51 /* TransactionDateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDateTests.swift; sourceTree = "<group>"; };
771772
56B021C81D8C0D3900B239BB /* MutablePersistableRecordPersistenceConflictPolicyTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MutablePersistableRecordPersistenceConflictPolicyTests.swift; sourceTree = "<group>"; };
772773
56B14E7E1D4DAE54000BF4A3 /* RowFromDictionaryLiteralTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RowFromDictionaryLiteralTests.swift; sourceTree = "<group>"; };
774+
56B6AB052BD3DCAC009A0B71 /* SingletonUserDefaultsTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SingletonUserDefaultsTest.swift; sourceTree = "<group>"; };
773775
56B6EF55208CB4E3002F0ACB /* ColumnExpressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColumnExpressionTests.swift; sourceTree = "<group>"; };
774776
56B7EE822863781300C0525F /* WALSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WALSnapshot.swift; sourceTree = "<group>"; };
775777
56B7F4291BE14A1900E39BBF /* CGFloatTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGFloatTests.swift; sourceTree = "<group>"; };
@@ -1552,6 +1554,7 @@
15521554
children = (
15531555
564E73DE203D50B9000C443C /* JoinSupportTests.swift */,
15541556
5616B4FA28B5F5220052017E /* SingletonRecordTest.swift */,
1557+
56B6AB052BD3DCAC009A0B71 /* SingletonUserDefaultsTest.swift */,
15551558
5674A7251F30A8EF0095F066 /* FetchableRecord */,
15561559
560B3FA41C19DFF800C58EC7 /* PersistableRecord */,
15571560
56176C9E1EACEDF9000F3F2B /* Record */,
@@ -2086,6 +2089,7 @@
20862089
562393181DECC02000A6B01F /* RowFetchTests.swift in Sources */,
20872090
56677C0D241CD0D00050755D /* ValueObservationRecorder.swift in Sources */,
20882091
5653EADA20944B4F00F46237 /* AssociationRowScopeSearchTests.swift in Sources */,
2092+
56B6AB062BD3DCAC009A0B71 /* SingletonUserDefaultsTest.swift in Sources */,
20892093
563B5336267E2F90009549B5 /* TableTests.swift in Sources */,
20902094
56D4965A1D81304E008276D7 /* FoundationNSDataTests.swift in Sources */,
20912095
56D496791D81309E008276D7 /* RecordWithColumnNameManglingTests.swift in Sources */,

GRDB/Documentation.docc/SingleRowTables.md

Lines changed: 85 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ Database tables that contain a single row can store configuration values, user p
88

99
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.
1010

11-
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.
11+
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.
1212

13-
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.
13+
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 <doc:JSON>.
14+
15+
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).
1416

1517
## The Single-Row Table
1618

@@ -20,63 +22,43 @@ We want to instruct SQLite that our table must never contain more than one row.
2022

2123
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.
2224

23-
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 <doc:SingleRowTables#The-Single-Row-Record> section below. Right now, we instruct SQLite to just replace the eventual existing row in case of conflicting inserts:
24-
25-
```swift
26-
// CREATE TABLE appConfiguration (
27-
// id INTEGER PRIMARY KEY ON CONFLICT REPLACE CHECK (id = 1),
28-
// flag BOOLEAN NOT NULL,
29-
// ...)
30-
try db.create(table: "appConfiguration") { t in
31-
// Single row guarantee: have inserts replace the existing row
32-
t.primaryKey("id", .integer, onConflict: .replace)
33-
// Make sure the id column is always 1
34-
.check { $0 == 1 }
35-
36-
// The configuration columns
37-
t.column("flag", .boolean).notNull()
38-
// ... other columns
39-
}
40-
```
41-
42-
When you use <doc:Migrations>, 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:
25+
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 <doc:SingleRowTables#The-Single-Row-Record> section below. Right now, we instruct SQLite to just replace the eventual existing row in case of conflicting inserts.
4326

4427
```swift
45-
// NOT RECOMMENDED
4628
migrator.registerMigration("appConfiguration") { db in
29+
// CREATE TABLE appConfiguration (
30+
// id INTEGER PRIMARY KEY ON CONFLICT REPLACE CHECK (id = 1),
31+
// storedFlag BOOLEAN,
32+
// ...)
4733
try db.create(table: "appConfiguration") { t in
48-
// The single row guarantee
49-
t.primaryKey("id", .integer, onConflict: .replace).check { $0 == 1 }
34+
// Single row guarantee: have inserts replace the existing row,
35+
// and make sure the id column is always 1.
36+
t.primaryKey("id", .integer, onConflict: .replace)
37+
.check { $0 == 1 }
5038

51-
// Define sensible defaults for each column
52-
t.column("flag", .boolean).notNull()
53-
.defaults(to: false)
39+
// The configuration columns
40+
t.column("storedFlag", .boolean)
5441
// ... other columns
5542
}
56-
57-
// Populate the table
58-
try db.execute(sql: "INSERT INTO appConfiguration DEFAULT VALUES")
5943
}
6044
```
6145

62-
It is not a good idea to populate the table in a migration, for two reasons:
46+
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 <doc:Migrations> for more information.
6347

64-
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.
48+
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:
6549

66-
2. Migrations that have been deployed on the users' devices should never change (see <doc:Migrations#Good-Practices-for-Defining-Migrations>). Inserting an initial row in a migration makes it difficult for the application to adjust the sensible default values in a future version.
50+
- 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`.
51+
- 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.
6752

68-
The recommended migration creates the table, nothing more:
53+
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:
6954

7055
```swift
71-
// RECOMMENDED
56+
// DO NOT hesitate requiring NOT NULL columns when the app requires it.
7257
migrator.registerMigration("appConfiguration") { db in
7358
try db.create(table: "appConfiguration") { t in
74-
// The single row guarantee
7559
t.primaryKey("id", .integer, onConflict: .replace).check { $0 == 1 }
7660

77-
// The configuration columns
78-
t.column("flag", .boolean).notNull()
79-
// ... other columns
61+
t.column("flag", .boolean).notNull() // required
8062
}
8163
}
8264
```
@@ -91,7 +73,37 @@ struct AppConfiguration: Codable {
9173
// Support for the single row guarantee
9274
private var id = 1
9375

94-
// The configuration properties
76+
// The stored properties
77+
private var storedFlag: Bool?
78+
// ... other properties
79+
}
80+
```
81+
82+
The `storedFlag` property is private, because we want to expose a nice `flag` property that has a default value when `storedFlag` is nil:
83+
84+
```swift
85+
// Support for default values
86+
extension AppConfiguration {
87+
var flag: Bool {
88+
get { storedFlag ?? true /* the default value */ }
89+
set { storedFlag = newValue }
90+
}
91+
92+
mutating func resetFlag() {
93+
storedFlag = nil
94+
}
95+
}
96+
```
97+
98+
This ceremony is not needed when the column can not be null:
99+
100+
```swift
101+
// The simplified setup for non-nullable columns
102+
struct AppConfiguration: Codable {
103+
// Support for the single row guarantee
104+
private var id = 1
105+
106+
// The stored properties
95107
var flag: Bool
96108
// ... other properties
97109
}
@@ -102,7 +114,7 @@ In case the database table would be empty, we need a default configuration:
102114
```swift
103115
extension AppConfiguration {
104116
/// The default configuration
105-
static let `default` = AppConfiguration(flag: false)
117+
static let `default` = AppConfiguration(flag: nil)
106118
}
107119
```
108120

@@ -129,7 +141,7 @@ The standard GRDB method ``FetchableRecord/fetchOne(_:)`` returns an optional wh
129141
```swift
130142
/// Returns the persisted configuration, or the default one if the
131143
/// database table is empty.
132-
static func fetch(_ db: Database) throws -> AppConfiguration {
144+
static func find(_ db: Database) throws -> AppConfiguration {
133145
try fetchOne(db) ?? .default
134146
}
135147
}
@@ -140,25 +152,27 @@ And that's it! Now we can use our singleton record:
140152
```swift
141153
// READ
142154
let config = try dbQueue.read { db in
143-
try AppConfiguration.fetch(db)
155+
try AppConfiguration.find(db)
144156
}
145157
if config.flag {
146158
// ...
147159
}
148160

149161
// WRITE
150162
try dbQueue.write { db in
151-
// Saves a new config in the database
152-
var config = try AppConfiguration.fetch(db)
163+
// Update the config in the database
164+
var config = try AppConfiguration.find(db)
153165
try config.updateChanges(db) {
154166
$0.flag = true
155167
}
156168

157169
// Other possible ways to save the config:
158-
try config.save(db)
159-
try config.update(db)
160-
try config.insert(db)
161-
try config.upsert(db)
170+
var config = try AppConfiguration.find(db)
171+
config.flag = true
172+
try config.save(db) // all the same
173+
try config.update(db) // all the same
174+
try config.insert(db) // all the same
175+
try config.upsert(db) // all the same
162176
}
163177
```
164178

@@ -172,11 +186,13 @@ We all love to copy and paste, don't we? Just customize the template code below:
172186
```swift
173187
// Table creation
174188
try db.create(table: "appConfiguration") { t in
175-
// The single row guarantee
176-
t.primaryKey("id", .integer, onConflict: .replace).check { $0 == 1 }
189+
// Single row guarantee: have inserts replace the existing row,
190+
// and make sure the id column is always 1.
191+
t.primaryKey("id", .integer, onConflict: .replace)
192+
.check { $0 == 1 }
177193

178194
// The configuration columns
179-
t.column("flag", .boolean).notNull()
195+
t.column("storedFlag", .boolean)
180196
// ... other columns
181197
}
182198
```
@@ -192,14 +208,26 @@ struct AppConfiguration: Codable {
192208
// Support for the single row guarantee
193209
private var id = 1
194210

195-
// The configuration properties
196-
var flag: Bool
211+
// The stored properties
212+
private var storedFlag: Bool?
197213
// ... other properties
198214
}
199215

216+
// Support for default values
217+
extension AppConfiguration {
218+
var flag: Bool {
219+
get { storedFlag ?? true /* the default value */ }
220+
set { storedFlag = newValue }
221+
}
222+
223+
mutating func resetFlag() {
224+
storedFlag = nil
225+
}
226+
}
227+
200228
extension AppConfiguration {
201229
/// The default configuration
202-
static let `default` = AppConfiguration(flag: false, ...)
230+
static let `default` = AppConfiguration(storedFlag: nil)
203231
}
204232

205233
// Database Access
@@ -214,7 +242,7 @@ extension AppConfiguration: FetchableRecord, PersistableRecord {
214242

215243
/// Returns the persisted configuration, or the default one if the
216244
/// database table is empty.
217-
static func fetch(_ db: Database) throws -> AppConfiguration {
245+
static func find(_ db: Database) throws -> AppConfiguration {
218246
try fetchOne(db) ?? .default
219247
}
220248
}

GRDBCustom.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@
267267
56AFEF3A2996B9EE00CA1E51 /* TransactionDateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56AFEF382996B9EE00CA1E51 /* TransactionDateTests.swift */; };
268268
56B021CC1D8C0D3900B239BB /* MutablePersistableRecordPersistenceConflictPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B021C81D8C0D3900B239BB /* MutablePersistableRecordPersistenceConflictPolicyTests.swift */; };
269269
56B14E821D4DAE54000BF4A3 /* RowFromDictionaryLiteralTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B14E7E1D4DAE54000BF4A3 /* RowFromDictionaryLiteralTests.swift */; };
270+
56B6AB092BD3DCE0009A0B71 /* SingletonUserDefaultsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B6AB072BD3DCE0009A0B71 /* SingletonUserDefaultsTest.swift */; };
270271
56B6EF60208CB746002F0ACB /* ColumnExpressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B6EF5E208CB746002F0ACB /* ColumnExpressionTests.swift */; };
271272
56B86E70220FF4C900524C16 /* SQLLiteralTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B86E6E220FF4C800524C16 /* SQLLiteralTests.swift */; };
272273
56B9649F1DA51B4C0002DA19 /* FTS5.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B9649C1DA51B4C0002DA19 /* FTS5.swift */; };
@@ -783,6 +784,7 @@
783784
56AFEF382996B9EE00CA1E51 /* TransactionDateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDateTests.swift; sourceTree = "<group>"; };
784785
56B021C81D8C0D3900B239BB /* MutablePersistableRecordPersistenceConflictPolicyTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MutablePersistableRecordPersistenceConflictPolicyTests.swift; sourceTree = "<group>"; };
785786
56B14E7E1D4DAE54000BF4A3 /* RowFromDictionaryLiteralTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RowFromDictionaryLiteralTests.swift; sourceTree = "<group>"; };
787+
56B6AB072BD3DCE0009A0B71 /* SingletonUserDefaultsTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SingletonUserDefaultsTest.swift; sourceTree = "<group>"; };
786788
56B6EF5E208CB746002F0ACB /* ColumnExpressionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColumnExpressionTests.swift; sourceTree = "<group>"; };
787789
56B7F4291BE14A1900E39BBF /* CGFloatTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGFloatTests.swift; sourceTree = "<group>"; };
788790
56B7F4391BEB42D500E39BBF /* Migration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Migration.swift; sourceTree = "<group>"; };
@@ -1558,6 +1560,7 @@
15581560
children = (
15591561
564E73E7203DA278000C443C /* JoinSupportTests.swift */,
15601562
5616B4FE28B5F5490052017E /* SingletonRecordTest.swift */,
1563+
56B6AB072BD3DCE0009A0B71 /* SingletonUserDefaultsTest.swift */,
15611564
5674A7251F30A8EF0095F066 /* FetchableRecord */,
15621565
560B3FA41C19DFF800C58EC7 /* PersistableRecord */,
15631566
56176C9E1EACEDF9000F3F2B /* Record */,
@@ -2330,6 +2333,7 @@
23302333
5698AC431DA2BED90056AF8C /* FTS3PatternTests.swift in Sources */,
23312334
563B533B267E2FA4009549B5 /* TableTests.swift in Sources */,
23322335
5653EB6E20961FB200F46237 /* AssociationBelongsToSQLDerivationTests.swift in Sources */,
2336+
56B6AB092BD3DCE0009A0B71 /* SingletonUserDefaultsTest.swift in Sources */,
23332337
561F38F62AC9CE5A0051EEE9 /* DatabaseDataDecodingStrategyTests.swift in Sources */,
23342338
564CE5C621B8FFE600652B19 /* DatabaseRegionObservationTests.swift in Sources */,
23352339
F3BA80E11CFB300F003DC1BA /* DatabaseValueConversionTests.swift in Sources */,

0 commit comments

Comments
 (0)