Skip to content

Commit

Permalink
Merge pull request #1534 from groue/dev/singleton-rewrite
Browse files Browse the repository at this point in the history
Update the "Single-Row Tables" guide, with support for default values
  • Loading branch information
groue committed Apr 21, 2024
2 parents 218774d + bde61b2 commit 3f3cfa5
Show file tree
Hide file tree
Showing 7 changed files with 294 additions and 72 deletions.
4 changes: 4 additions & 0 deletions GRDB.xcodeproj/project.pbxproj
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -770,6 +771,7 @@
56AFEF362996B9DC00CA1E51 /* TransactionDateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDateTests.swift; sourceTree = "<group>"; };
56B021C81D8C0D3900B239BB /* MutablePersistableRecordPersistenceConflictPolicyTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MutablePersistableRecordPersistenceConflictPolicyTests.swift; sourceTree = "<group>"; };
56B14E7E1D4DAE54000BF4A3 /* RowFromDictionaryLiteralTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RowFromDictionaryLiteralTests.swift; sourceTree = "<group>"; };
56B6AB052BD3DCAC009A0B71 /* SingletonUserDefaultsTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SingletonUserDefaultsTest.swift; sourceTree = "<group>"; };
56B6EF55208CB4E3002F0ACB /* ColumnExpressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColumnExpressionTests.swift; sourceTree = "<group>"; };
56B7EE822863781300C0525F /* WALSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WALSnapshot.swift; sourceTree = "<group>"; };
56B7F4291BE14A1900E39BBF /* CGFloatTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGFloatTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1552,6 +1554,7 @@
children = (
564E73DE203D50B9000C443C /* JoinSupportTests.swift */,
5616B4FA28B5F5220052017E /* SingletonRecordTest.swift */,
56B6AB052BD3DCAC009A0B71 /* SingletonUserDefaultsTest.swift */,
5674A7251F30A8EF0095F066 /* FetchableRecord */,
560B3FA41C19DFF800C58EC7 /* PersistableRecord */,
56176C9E1EACEDF9000F3F2B /* Record */,
Expand Down Expand Up @@ -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 */,
Expand Down
142 changes: 85 additions & 57 deletions GRDB/Documentation.docc/SingleRowTables.md
Expand Up @@ -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 <doc:JSON>.

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

Expand All @@ -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 <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:

```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 <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:
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.

```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 <doc:Migrations> 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 <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.
- 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
}
}
```
Expand All @@ -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
}
Expand All @@ -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)
}
```

Expand All @@ -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
}
}
Expand All @@ -140,25 +152,27 @@ 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 {
// ...
}

// 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
}
```

Expand All @@ -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
}
```
Expand All @@ -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
Expand All @@ -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
}
}
Expand Down
4 changes: 4 additions & 0 deletions GRDBCustom.xcodeproj/project.pbxproj
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -783,6 +784,7 @@
56AFEF382996B9EE00CA1E51 /* TransactionDateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDateTests.swift; sourceTree = "<group>"; };
56B021C81D8C0D3900B239BB /* MutablePersistableRecordPersistenceConflictPolicyTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MutablePersistableRecordPersistenceConflictPolicyTests.swift; sourceTree = "<group>"; };
56B14E7E1D4DAE54000BF4A3 /* RowFromDictionaryLiteralTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RowFromDictionaryLiteralTests.swift; sourceTree = "<group>"; };
56B6AB072BD3DCE0009A0B71 /* SingletonUserDefaultsTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SingletonUserDefaultsTest.swift; sourceTree = "<group>"; };
56B6EF5E208CB746002F0ACB /* ColumnExpressionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColumnExpressionTests.swift; sourceTree = "<group>"; };
56B7F4291BE14A1900E39BBF /* CGFloatTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGFloatTests.swift; sourceTree = "<group>"; };
56B7F4391BEB42D500E39BBF /* Migration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Migration.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1558,6 +1560,7 @@
children = (
564E73E7203DA278000C443C /* JoinSupportTests.swift */,
5616B4FE28B5F5490052017E /* SingletonRecordTest.swift */,
56B6AB072BD3DCE0009A0B71 /* SingletonUserDefaultsTest.swift */,
5674A7251F30A8EF0095F066 /* FetchableRecord */,
560B3FA41C19DFF800C58EC7 /* PersistableRecord */,
56176C9E1EACEDF9000F3F2B /* Record */,
Expand Down Expand Up @@ -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 */,
Expand Down

0 comments on commit 3f3cfa5

Please sign in to comment.