Skip to content

Commit

Permalink
Rewrite SingleRowTables, accounting for nullable columns
Browse files Browse the repository at this point in the history
This addresses #1526
  • Loading branch information
groue committed Apr 21, 2024
1 parent 5a17d23 commit c55d42c
Showing 1 changed file with 81 additions and 54 deletions.
135 changes: 81 additions & 54 deletions GRDB/Documentation.docc/SingleRowTables.md
Original file line number Diff line number Diff line change
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 @@ -124,12 +136,12 @@ We have seen in the <doc:SingleRowTables#The-Single-Row-Table> section that by d
}
```

The standard GRDB method ``FetchableRecord/fetchOne(_:)`` returns an optional which is nil when the database table is empty. As a convenience, let's define a method that returns a non-optional (replacing the missing row with `default`):
The standard GRDB method ``FetchableRecord/fetchOne(_:)`` returns an optional which is nil when the database table is empty. As a convenience, let's define a method that returns a non-optional (replacing the missing row with `default`). We call it `find` because that's the name of GRDB methods that return non-optional records (see ``FetchableRecord/find(_:id:)`` for example):

```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,21 +152,22 @@ 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:
config.flag = true
try config.save(db)
try config.update(db)
try config.insert(db)
Expand All @@ -172,11 +185,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 +207,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 +241,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

0 comments on commit c55d42c

Please sign in to comment.