From 125bb0492c1f95d7f0590350fe7a5391c37b0118 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] Rewrite SingleRowTables, accounting for nullable columns This addresses https://github.com/groue/GRDB.swift/discussions/1526 --- GRDB/Documentation.docc/SingleRowTables.md | 135 ++++++++++++--------- 1 file changed, 81 insertions(+), 54 deletions(-) diff --git a/GRDB/Documentation.docc/SingleRowTables.md b/GRDB/Documentation.docc/SingleRowTables.md index cf90c69953..3d8bd09c19 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) } ``` @@ -124,12 +136,12 @@ We have seen in the 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 } } @@ -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,13 +160,14 @@ 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) @@ -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 } ``` @@ -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 @@ -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 } }