diff --git a/GRDB/Core/Database+Schema.swift b/GRDB/Core/Database+Schema.swift index bbc2e90405..ff8f9bf1d8 100644 --- a/GRDB/Core/Database+Schema.swift +++ b/GRDB/Core/Database+Schema.swift @@ -96,7 +96,15 @@ extension Database { // MARK: - Database Schema - /// Returns the current schema version. + /// Returns the current schema version (`PRAGMA schema_version`). + /// + /// For example: + /// + /// ```swift + /// let version = try dbQueue.read { db in + /// try db.schemaVersion() + /// } + /// ``` /// /// Related SQLite documentation: public func schemaVersion() throws -> Int32 { @@ -234,8 +242,19 @@ extension Database { /// Returns whether a table exists /// - /// When `schemaName` is not specified, known schemas are iterated in - /// SQLite resolution order and the first matching result is returned. + /// When `schemaName` is not specified, the result is true if any known + /// schema contains the table. + /// + /// For example: + /// + /// ```swift + /// try dbQueue.read { db in + /// if try db.tableExists("player") { ... } + /// if try db.tableExists("player", in: "main") { ... } + /// if try db.tableExists("player", in: "temp") { ... } + /// if try db.tableExists("player", in: "attached") { ... } + /// } + /// ``` /// /// - throws: A ``DatabaseError`` whenever an SQLite error occurs, or /// if the specified schema does not exist @@ -278,8 +297,19 @@ extension Database { /// Returns whether a view exists, in the main or temp schema, or in an /// attached database. /// - /// When `schemaName` is not specified, known schemas are iterated in - /// SQLite resolution order and the first matching result is returned. + /// When `schemaName` is not specified, the result is true if any known + /// schema contains the table. + /// + /// For example: + /// + /// ```swift + /// try dbQueue.read { db in + /// if try db.viewExists("player") { ... } + /// if try db.viewExists("player", in: "main") { ... } + /// if try db.viewExists("player", in: "temp") { ... } + /// if try db.viewExists("player", in: "attached") { ... } + /// } + /// ``` /// /// - throws: A ``DatabaseError`` whenever an SQLite error occurs, or /// if the specified schema does not exist @@ -296,8 +326,19 @@ extension Database { /// Returns whether a trigger exists, in the main or temp schema, or in an /// attached database. /// - /// When `schemaName` is not specified, known schemas are iterated in - /// SQLite resolution order and the first matching result is returned. + /// When `schemaName` is not specified, the result is true if any known + /// schema contains the table. + /// + /// For example: + /// + /// ```swift + /// try dbQueue.read { db in + /// if try db.triggerExists("on_player_update") { ... } + /// if try db.triggerExists("on_player_update", in: "main") { ... } + /// if try db.triggerExists("on_player_update", in: "temp") { ... } + /// if try db.triggerExists("on_player_update", in: "attached") { ... } + /// } + /// ``` /// /// - throws: A ``DatabaseError`` whenever an SQLite error occurs, or /// if the specified schema does not exist @@ -340,6 +381,15 @@ extension Database { /// table has no explicit primary key, the result is the hidden /// "rowid" column. /// + /// For example: + /// + /// ```swift + /// try dbQueue.read { db in + /// let primaryKey = try db.primaryKey("player") + /// print(primaryKey.columns) + /// } + /// ``` + /// /// When `schemaName` is not specified, known schemas are iterated in /// SQLite resolution order and the first matching result is returned. /// @@ -529,14 +579,26 @@ extension Database { /// The indexes on table named `tableName`. /// + /// For example: + /// + /// ```swift + /// try dbQueue.read { db in + /// let indexes = db.indexes(in: "player") + /// for index in indexes { + /// print(index.columns) + /// } + /// } + /// ``` + /// /// Only indexes on columns are returned. Indexes on expressions are /// not returned. /// - /// SQLite does not define any index for INTEGER PRIMARY KEY columns: this - /// method does not return any index that represents the primary key. + /// SQLite does not define any index for INTEGER PRIMARY KEY columns: + /// this method does not return any index that represents the + /// primary key. /// - /// If you want to know if a set of columns uniquely identifies a row, because - /// the columns contain the primary key or a unique index, use + /// If you want to know if a set of columns uniquely identifies a row, + /// because the columns contain the primary key or a unique index, use /// ``table(_:hasUniqueKey:)``. /// /// When `schemaName` is not specified, known schemas are iterated in @@ -617,16 +679,19 @@ extension Database { /// For example: /// /// ```swift - /// // One table with one primary key (id), and a unique index (a, b): - /// // - /// // > CREATE TABLE t(id INTEGER PRIMARY KEY, a, b, c); - /// // > CREATE UNIQUE INDEX i ON t(a, b); - /// try db.table("t", hasUniqueKey: ["id"]) // true - /// try db.table("t", hasUniqueKey: ["a", "b"]) // true - /// try db.table("t", hasUniqueKey: ["b", "a"]) // true - /// try db.table("t", hasUniqueKey: ["c"]) // false - /// try db.table("t", hasUniqueKey: ["id", "a"]) // true - /// try db.table("t", hasUniqueKey: ["id", "a", "b", "c"]) // true + /// try dbQueue.read { db in + /// // One table with one primary key (id) + /// // and a unique index (a, b): + /// // + /// // > CREATE TABLE t(id INTEGER PRIMARY KEY, a, b, c); + /// // > CREATE UNIQUE INDEX i ON t(a, b); + /// try db.table("t", hasUniqueKey: ["id"]) // true + /// try db.table("t", hasUniqueKey: ["a", "b"]) // true + /// try db.table("t", hasUniqueKey: ["b", "a"]) // true + /// try db.table("t", hasUniqueKey: ["c"]) // false + /// try db.table("t", hasUniqueKey: ["id", "a"]) // true + /// try db.table("t", hasUniqueKey: ["id", "a", "b", "c"]) // true + /// } /// ``` public func table( _ tableName: String, @@ -640,6 +705,17 @@ extension Database { /// When `schemaName` is not specified, known schemas are iterated in /// SQLite resolution order and the first matching result is returned. /// + /// For example: + /// + /// ```swift + /// try dbQueue.read { db in + /// let foreignKeys = try db.foreignKeys(in: "player") + /// for foreignKey in foreignKeys { + /// print(foreignKey.destinationTable) + /// } + /// } + /// ``` + /// /// - throws: A ``DatabaseError`` whenever an SQLite error occurs, if /// the specified schema does not exist, or if no such table or view /// with this name exists in the main or temp schema, or in an attached @@ -861,6 +937,17 @@ extension Database { /// When `schemaName` is not specified, known schemas are iterated in /// SQLite resolution order and the first matching result is returned. /// + /// For example: + /// + /// ```swift + /// try dbQueue.read { db in + /// let columns = try db.columns(in: "player") + /// for column in columns { + /// print(column.name) + /// } + /// } + /// ``` + /// /// - throws: A ``DatabaseError`` whenever an SQLite error occurs, if /// the specified schema does not exist,or if no such table or view /// with this name exists in the main or temp schema, or in an attached diff --git a/GRDB/Documentation.docc/DatabaseSchema.md b/GRDB/Documentation.docc/DatabaseSchema.md index b81073adc0..dde873346b 100644 --- a/GRDB/Documentation.docc/DatabaseSchema.md +++ b/GRDB/Documentation.docc/DatabaseSchema.md @@ -6,7 +6,7 @@ Define or query the database schema. **GRDB supports all database schemas, and has no requirement.** Any existing SQLite database can be opened, and you are free to structure your new databases as you wish. -You perform modifications to the database schema with methods such as ``Database/create(table:options:body:)``, listed at the end of this page. For example: +You perform modifications to the database schema with methods such as ``Database/create(table:options:body:)``, listed in . For example: ```swift try db.create(table: "player") { t in @@ -16,408 +16,19 @@ try db.create(table: "player") { t in } ``` -When you plan to evolve the schema as new versions of your application ship, wrap all schema changes in . - -Prefer Swift methods over raw SQL queries. They allow the compiler to check if a schema change is available on the target operating system. Only use a raw SQL query when no Swift method exist (when creating triggers, for example). - -When a schema change is not directly supported by SQLite, or not available on the target operating system, database tables have to be recreated. See for the detailed procedure. - -## Database Schema Recommendations - -Even though all schema are supported, some features of the library and of the Swift language are easier to use when the schema follows a few conventions described below. - -When those conventions are not applied, or not applicable, you will have to perform extra configurations. - -For recommendations specific to JSON columns, see . - -### Table names should be English, singular, and camelCased - -Make them look like singular Swift identifiers: `player`, `team`, `postalAddress`: - -```swift -// RECOMMENDED -try db.create(table: "player") { t in - // table columns and constraints -} - -// REQUIRES EXTRA CONFIGURATION -try db.create(table: "players") { t in - // table columns and constraints -} -``` - -☝️ **If table names follow a different naming convention**, record types (see ) will need explicit table names: - -```swift -extension Player: TableRecord { - // Required because table name is not 'player' - static let databaseTableName = "players" -} - -extension PostalAddress: TableRecord { - // Required because table name is not 'postalAddress' - static let databaseTableName = "postal_address" -} - -extension Award: TableRecord { - // Required because table name is not 'award' - static let databaseTableName = "Auszeichnung" -} -``` - -[Associations](https://github.com/groue/GRDB.swift/blob/master/Documentation/AssociationsBasics.md) will need explicit keys as well: - -```swift -extension Player: TableRecord { - // Explicit association key because the table name is not 'postalAddress' - static let postalAddress = belongsTo(PostalAddress.self, key: "postalAddress") - - // Explicit association key because the table name is not 'award' - static let awards = hasMany(Award.self, key: "awards") -} -``` - -As in the above example, make sure to-one associations use singular keys, and to-many associations use plural keys. - -### Column names should be camelCased - -Again, make them look like Swift identifiers: `fullName`, `score`, `creationDate`: - -```swift -// RECOMMENDED -try db.create(table: "player") { t in - t.autoIncrementedPrimaryKey("id") - t.column("fullName", .text).notNull() - t.column("score", .integer).notNull() - t.column("creationDate", .datetime).notNull() -} - -// REQUIRES EXTRA CONFIGURATION -try db.create(table: "player") { t in - t.autoIncrementedPrimaryKey("id") - t.column("full_name", .text).notNull() - t.column("score", .integer).notNull() - t.column("creation_date", .datetime).notNull() -} -``` - -☝️ **If the column names follow a different naming convention**, `Codable` record types will need an explicit `CodingKeys` enum: - -```swift -struct Player: Decodable, FetchableRecord { - var id: Int64 - var fullName: String - var score: Int - var creationDate: Date - - // Required CodingKeys customization because - // columns are not named like Swift properties - enum CodingKeys: String, CodingKey { - case id, fullName = "full_name", score, creationDate = "creation_date" - } -} -``` - -### Tables should have explicit primary keys - -A primary key uniquely identifies a row in a table. It is defined on one or several columns: - -```swift -// RECOMMENDED -try db.create(table: "player") { t in - // Auto-incremented primary key - t.autoIncrementedPrimaryKey("id") - t.column("name", .text).notNull() -} - -try db.create(table: "team") { t in - // Single-column primary key - t.primaryKey("id", .text) - t.column("name", .text).notNull() -} - -try db.create(table: "membership") { t in - // Composite primary key - t.primaryKey { - t.belongsTo("player") - t.belongsTo("team") - } - t.column("role", .text).notNull() -} -``` - -Primary keys support record fetching methods such as ``FetchableRecord/fetchOne(_:id:)``, and persistence methods such as ``MutablePersistableRecord/update(_:onConflict:)`` or ``MutablePersistableRecord/delete(_:)``. - -See when you need to define a table that contains a single row. - -☝️ **If the database table does not define any explicit primary key**, identifying specific rows in this table needs explicit support for the [hidden `rowid` column](https://www.sqlite.org/rowidtable.html) in the matching record types: - -```swift -// A table without any explicit primary key -try db.create(table: "player") { t in - t.column("name", .text).notNull() - t.column("score", .integer).notNull() -} - -// The record type for the 'player' table' -struct Player: Codable { - // Uniquely identifies a player. - var rowid: Int64? - var name: String - var score: Int -} - -extension Player: FetchableRecord, MutablePersistableRecord { - // Required because the primary key - // is the hidden rowid column. - static var databaseSelection: [any SQLSelectable] { - [AllColumns(), Column.rowID] - } - - // Update id upon successful insertion - mutating func didInsert(_ inserted: InsertionSuccess) { - rowid = inserted.rowID - } -} - -try dbQueue.read { db in - // SELECT *, rowid FROM player WHERE rowid = 1 - if let player = try Player.fetchOne(db, id: 1) { - // DELETE FROM player WHERE rowid = 1 - let deleted = try player.delete(db) - print(deleted) // true - } -} -``` - -### Single-column primary keys should be named 'id' - -This helps record types play well with the standard `Identifiable` protocol. - -```swift -// RECOMMENDED -try db.create(table: "player") { t in - t.primaryKey("id", .text) - t.column("name", .text).notNull() -} - -// REQUIRES EXTRA CONFIGURATION -try db.create(table: "player") { t in - t.primaryKey("uuid", .text) - t.column("name", .text).notNull() -} -``` -☝️ **If the primary key follows a different naming convention**, `Identifiable` record types will need a custom `CodingKeys` enum, or an extra property: - -```swift -// Custom coding keys -struct Player: Codable, Identifiable { - var id: String - var name: String - - // Required CodingKeys customization because - // columns are not named like Swift properties - enum CodingKeys: String, CodingKey { - case id = "uuid", name - } -} - -// Extra property -struct Player: Identifiable { - var uuid: String - var name: String - - // Required because the primary key column is not 'id' - var id: String { uuid } -} -``` - -### Unique keys should be supported by unique indexes - -Unique indexes makes sure SQLite prevents the insertion of conflicting rows: - -```swift -// RECOMMENDED -try db.create(table: "player") { t in - t.autoIncrementedPrimaryKey("id") - t.belongsTo("team").notNull() - t.column("position", .integer).notNull() - // Players must have distinct names - t.column("name", .text).unique() -} - -// One single player at any given position in a team -try db.create( - indexOn: "player", - columns: ["teamId", "position"], - options: .unique) -``` - -> Tip: SQLite does not support deferred unique indexes, and this creates undesired churn when you need to temporarily break them. This may happen, for example, when you want to reorder player positions in our above example. -> -> There exist several workarounds; one of them involves dropping and recreating the unique index after the temporary violations have been fixed. If you plan to use this technique, take care that only actual indexes can be dropped. Unique constraints created inside the table body can not: -> -> ```swift -> // Unique constraint on player(name) can not be dropped. -> try db.create(table: "player") { t in -> t.column("name", .text).unique() -> } -> -> // Unique index on team(name) can be dropped. -> try db.create(table: "team") { t in -> t.column("name", .text) -> } -> try db.create(indexOn: "team", columns: ["name"], options: .unique) -> ``` -> -> If you want to turn an undroppable constraint into a droppable index, you'll need to recreate the database table. See for the detailed procedure. - -☝️ **If a table misses unique indexes**, some record methods such as ``FetchableRecord/fetchOne(_:key:)-92b9m`` and ``TableRecord/deleteOne(_:key:)-5pdh5`` will raise a fatal error: - -```swift -try dbQueue.write { db in - // Fatal error: table player has no unique index on columns ... - let player = try Player.fetchOne(db, key: ["teamId": 42, "position": 1]) - try Player.deleteOne(db, key: ["name": "Arthur"]) - - // Use instead: - let player = try Player - .filter(Column("teamId") == 42 && Column("position") == 1) - .fetchOne(db) - - try Player - .filter(Column("name") == "Arthur") - .deleteAll(db) -} -``` - -### Relations between tables should be supported by foreign keys - -[Foreign Keys](https://www.sqlite.org/foreignkeys.html) have SQLite enforce valid relationships between tables: - -```swift -try db.create(table: "team") { t in - t.autoIncrementedPrimaryKey("id") - t.column("color", .text).notNull() -} - -// RECOMMENDED -try db.create(table: "player") { t in - t.autoIncrementedPrimaryKey("id") - t.column("name", .text).notNull() - // A player must refer to an existing team - t.belongsTo("team").notNull() -} - -// REQUIRES EXTRA CONFIGURATION -try db.create(table: "player") { t in - t.autoIncrementedPrimaryKey("id") - t.column("name", .text).notNull() - // No foreign key - t.column("teamId", .integer).notNull() -} -``` - -See ``TableDefinition/belongsTo(_:inTable:onDelete:onUpdate:deferred:indexed:)`` for more information about the creation of foreign keys. - -GRDB [Associations](https://github.com/groue/GRDB.swift/blob/master/Documentation/AssociationsBasics.md) are automatically configured from foreign keys declared in the database schema: - -```swift -extension Player: TableRecord { - static let team = belongsTo(Team.self) -} - -extension Team: TableRecord { - static let players = hasMany(Player.self) -} -``` - -See [Associations and the Database Schema](https://github.com/groue/GRDB.swift/blob/master/Documentation/AssociationsBasics.md#associations-and-the-database-schema) for more precise recommendations. - -☝️ **If a foreign key is not declared in the schema**, you will need to explicitly configure related associations: - -```swift -extension Player: TableRecord { - // Required configuration because the database does - // not declare any foreign key from players to their team. - static let teamForeignKey = ForeignKey(["teamId"]) - static let team = belongsTo(Team.self, - using: teamForeignKey) -} - -extension Team: TableRecord { - // Required configuration because the database does - // not declare any foreign key from players to their team. - static let players = hasMany(Player.self, - using: Player.teamForeignKey) -} -``` +Most applications modify the database schema as new versions ship: it is recommended to wrap all schema changes in . ## Topics -### Database Tables - -- ``Database/alter(table:body:)`` -- ``Database/create(table:options:body:)`` -- ``Database/create(virtualTable:ifNotExists:using:)`` -- ``Database/create(virtualTable:ifNotExists:using:_:)`` -- ``Database/drop(table:)`` -- ``Database/dropFTS4SynchronizationTriggers(forTable:)`` -- ``Database/dropFTS5SynchronizationTriggers(forTable:)`` -- ``Database/rename(table:to:)`` -- ``Database/ColumnType`` -- ``Database/ConflictResolution`` -- ``Database/ForeignKeyAction`` -- ``TableAlteration`` -- ``TableDefinition`` -- ``TableOptions`` -- ``VirtualTableModule`` - -### Database Views - -- ``Database/create(view:options:columns:as:)`` -- ``Database/create(view:options:columns:asLiteral:)`` -- ``Database/drop(view:)`` -- ``ViewOptions`` - -### Database Indexes - -- ``Database/create(indexOn:columns:options:condition:)`` -- ``Database/create(index:on:columns:options:condition:)`` -- ``Database/create(index:on:expressions:options:condition:)`` -- ``Database/drop(indexOn:columns:)`` -- ``Database/drop(index:)`` -- ``IndexOptions`` - -### Querying the Database Schema - -- ``Database/columns(in:in:)`` -- ``Database/foreignKeys(on:in:)`` -- ``Database/indexes(on:in:)`` -- ``Database/isGRDBInternalTable(_:)`` -- ``Database/isSQLiteInternalTable(_:)`` -- ``Database/primaryKey(_:in:)`` -- ``Database/schemaVersion()`` -- ``Database/table(_:hasUniqueKey:)`` -- ``Database/tableExists(_:in:)`` -- ``Database/triggerExists(_:in:)`` -- ``Database/viewExists(_:in:)`` -- ``ColumnInfo`` -- ``ForeignKeyInfo`` -- ``IndexInfo`` -- ``PrimaryKeyInfo`` +### Define the database schema -### Integrity Checks +- +- -- ``Database/checkForeignKeys()`` -- ``Database/checkForeignKeys(in:in:)`` -- ``Database/foreignKeyViolations()`` -- ``Database/foreignKeyViolations(in:in:)`` -- ``ForeignKeyViolation`` +### Introspect the database schema -### Sunsetted Methods +- -Those are legacy interfaces that are preserved for backwards compatibility. Their use is not recommended. +### Check the database schema -- ``Database/create(index:on:columns:unique:ifNotExists:condition:)`` -- ``Database/create(table:temporary:ifNotExists:withoutRowID:body:)`` +- diff --git a/GRDB/Documentation.docc/DatabaseSchemaIntegrityChecks.md b/GRDB/Documentation.docc/DatabaseSchemaIntegrityChecks.md new file mode 100644 index 0000000000..afa549e39e --- /dev/null +++ b/GRDB/Documentation.docc/DatabaseSchemaIntegrityChecks.md @@ -0,0 +1,13 @@ +# Integrity Checks + +Perform integrity checks of the database content + +## Topics + +### Integrity Checks + +- ``Database/checkForeignKeys()`` +- ``Database/checkForeignKeys(in:in:)`` +- ``Database/foreignKeyViolations()`` +- ``Database/foreignKeyViolations(in:in:)`` +- ``ForeignKeyViolation`` diff --git a/GRDB/Documentation.docc/DatabaseSchemaIntrospection.md b/GRDB/Documentation.docc/DatabaseSchemaIntrospection.md new file mode 100644 index 0000000000..ee114e8c12 --- /dev/null +++ b/GRDB/Documentation.docc/DatabaseSchemaIntrospection.md @@ -0,0 +1,35 @@ +# Database Schema Introspection + +Get information about schema objects such as tables, columns, indexes, foreign keys, etc. + +## Topics + +### Querying the Schema Version + +- ``Database/schemaVersion()`` + +### Existence Checks + +- ``Database/tableExists(_:in:)`` +- ``Database/triggerExists(_:in:)`` +- ``Database/viewExists(_:in:)`` + +### Table Structure + +- ``Database/columns(in:in:)`` +- ``Database/foreignKeys(on:in:)`` +- ``Database/indexes(on:in:)`` +- ``Database/primaryKey(_:in:)`` +- ``Database/table(_:hasUniqueKey:)`` + +### Reserved Tables + +- ``Database/isGRDBInternalTable(_:)`` +- ``Database/isSQLiteInternalTable(_:)`` + +### Supporting Types + +- ``ColumnInfo`` +- ``ForeignKeyInfo`` +- ``IndexInfo`` +- ``PrimaryKeyInfo`` diff --git a/GRDB/Documentation.docc/DatabaseSchemaModifications.md b/GRDB/Documentation.docc/DatabaseSchemaModifications.md new file mode 100644 index 0000000000..ccee535a77 --- /dev/null +++ b/GRDB/Documentation.docc/DatabaseSchemaModifications.md @@ -0,0 +1,290 @@ +# Modifying the Database Schema + +How to modify the database schema + +## Overview + +For modifying the database schema, prefer Swift methods over raw SQL queries. They allow the compiler to check if a schema change is available on the target operating system. Only use a raw SQL query when no Swift method exist (when creating triggers, for example). + +When a schema change is not directly supported by SQLite, or not available on the target operating system, database tables have to be recreated. See for the detailed procedure. + +## Create Tables + +The ``Database/create(table:options:body:)`` method covers nearly all SQLite table creation features. For virtual tables, see [Full-Text Search](https://github.com/groue/GRDB.swift/blob/master/Documentation/FullTextSearch.md), or use raw SQL. + +```swift +// CREATE TABLE place ( +// id INTEGER PRIMARY KEY AUTOINCREMENT, +// title TEXT, +// favorite BOOLEAN NOT NULL DEFAULT 0, +// latitude DOUBLE NOT NULL, +// longitude DOUBLE NOT NULL +// ) +try db.create(table: "place") { t in + t.autoIncrementedPrimaryKey("id") + t.column("title", .text) + t.column("favorite", .boolean).notNull().defaults(to: false) + t.column("longitude", .double).notNull() + t.column("latitude", .double).notNull() +} +``` + +**Configure table creation** + +```swift +// CREATE TABLE player ( ... ) +try db.create(table: "player") { t in ... } + +// CREATE TEMPORARY TABLE player IF NOT EXISTS ( +try db.create(table: "player", options: [.temporary, .ifNotExists]) { t in ... } +``` + +Reference: ``TableOptions`` + +**Add regular columns** with their name and eventual type (`text`, `integer`, `double`, `real`, `numeric`, `boolean`, `blob`, `date`, `datetime`, `any`, and `json`) - see [SQLite data types](https://www.sqlite.org/datatype3.html) and : + +```swift +// CREATE TABLE player ( +// score, +// name TEXT, +// creationDate DATETIME, +// address TEXT, +try db.create(table: "player") { t in + t.column("score") + t.column("name", .text) + t.column("creationDate", .datetime) + t.column("address", .json) +``` + +Reference: ``TableDefinition/column(_:_:)`` + +Define **not null** columns, and set **default values**: + +```swift + // email TEXT NOT NULL, + t.column("email", .text).notNull() + + // name TEXT NOT NULL DEFAULT 'Anonymous', + t.column("name", .text).notNull().defaults(to: "Anonymous") +``` + +Reference: ``ColumnDefinition`` + +**Define primary, unique, or foreign keys**. When defining a foreign key, the referenced column is the primary key of the referenced table (unless you specify otherwise): + +```swift + // id INTEGER PRIMARY KEY AUTOINCREMENT, + t.autoIncrementedPrimaryKey("id") + + // uuid TEXT PRIMARY KEY NOT NULL, + t.primaryKey("uuid", .text) + + // teamName TEXT NOT NULL, + // position INTEGER NOT NULL, + // PRIMARY KEY (teamName, position), + t.primaryKey { + t.column("teamName", .text) + t.column("position", .integer) + } + + // email TEXT UNIQUE, + t.column("email", .text).unique() + + // teamId TEXT REFERENCES team(id) ON DELETE CASCADE, + // countryCode TEXT REFERENCES country(code) NOT NULL, + t.belongsTo("team", onDelete: .cascade) + t.belongsTo("country").notNull() +``` + +Reference: ``TableDefinition``, ``ColumnDefinition/unique(onConflict:)`` + +**Create an index** on a column + +```swift + t.column("score", .integer).indexed() +``` + +Reference: ``ColumnDefinition`` + +For extra index options, see below. + +**Perform integrity checks** on individual columns, and SQLite will only let conforming rows in. In the example below, the `$0` closure variable is a column which lets you build any SQL expression. + +```swift + // name TEXT CHECK (LENGTH(name) > 0) + // score INTEGER CHECK (score > 0) + t.column("name", .text).check { length($0) > 0 } + t.column("score", .integer).check(sql: "score > 0") +``` + +Reference: ``ColumnDefinition`` + +Columns can also be defined with a raw sql String, or an [SQL literal](https://github.com/groue/GRDB.swift/blob/master/Documentation/SQLInterpolation.md#sql-literal) in which you can safely embed raw values without any risk of syntax errors or SQL injection: + +```swift + t.column(sql: "name TEXT") + + let defaultName: String = ... + t.column(literal: "name TEXT DEFAULT \(defaultName)") +``` + +Reference: ``TableDefinition`` + +Other **table constraints** can involve several columns: + +```swift + // PRIMARY KEY (a, b), + t.primaryKey(["a", "b"]) + + // UNIQUE (a, b) ON CONFLICT REPLACE, + t.uniqueKey(["a", "b"], onConflict: .replace) + + // FOREIGN KEY (a, b) REFERENCES parents(c, d), + t.foreignKey(["a", "b"], references: "parents") + + // CHECK (a + b < 10), + t.check(Column("a") + Column("b") < 10) + + // CHECK (a + b < 10) + t.check(sql: "a + b < 10") + + // Raw SQL constraints + t.constraint(sql: "CHECK (a + b < 10)") + t.constraint(literal: "CHECK (a + b < \(10))") +``` + +Reference: ``TableDefinition`` + +**Generated columns**: + +```swift + t.column("totalScore", .integer).generatedAs(sql: "score + bonus") + t.column("totalScore", .integer).generatedAs(Column("score") + Column("bonus")) +} +``` + +Reference: ``ColumnDefinition`` + +## Modify Tables + +SQLite lets you modify existing tables: + +```swift +// ALTER TABLE referer RENAME TO referrer +try db.rename(table: "referer", to: "referrer") + +// ALTER TABLE player ADD COLUMN hasBonus BOOLEAN +// ALTER TABLE player RENAME COLUMN url TO homeURL +// ALTER TABLE player DROP COLUMN score +try db.alter(table: "player") { t in + t.add(column: "hasBonus", .boolean) + t.rename(column: "url", to: "homeURL") + t.drop(column: "score") +} +``` + +Reference: ``TableAlteration`` + +> Note: SQLite restricts the possible table alterations, and may require you to recreate dependent triggers or views. See for more information. + +## Drop Tables + +Drop tables with the ``Database/drop(table:)`` method: + +```swift +try db.drop(table: "obsolete") +``` + +## Create Indexes + +Create an index on a column: + +```swift +try db.create(table: "player") { t in + t.column("email", .text).unique() + t.column("score", .integer).indexed() +} +``` + +Create indexes on an existing table: + +```swift +// CREATE INDEX index_player_on_email ON player(email) +try db.create(indexOn: "player", columns: ["email"]) + +// CREATE UNIQUE INDEX index_player_on_email ON player(email) +try db.create(indexOn: "player", columns: ["email"], options: .unique) +``` + +Create indexes with a specific collation: + +```swift +// CREATE INDEX index_player_on_email ON player(email COLLATE NOCASE) +try db.create( + index: "index_player_on_email", + on: "player", + expressions: [Column("email").collating(.nocase)]) +``` + +Create indexes on expressions: + +```swift +// CREATE INDEX index_player_on_total_score ON player(score+bonus) +try db.create( + index: "index_player_on_total_score", + on: "player", + expressions: [Column("score") + Column("bonus")]) + +// CREATE INDEX index_player_on_country ON player(address ->> 'country') +try db.create( + index: "index_player_on_country", + on: "player", + expressions: [ + JSONColumn("address")["country"], + ]) +``` + +Unique constraints and unique indexes are somewhat different: don't miss the tip in below. + +## Topics + +### Database Tables + +- ``Database/alter(table:body:)`` +- ``Database/create(table:options:body:)`` +- ``Database/create(virtualTable:ifNotExists:using:)`` +- ``Database/create(virtualTable:ifNotExists:using:_:)`` +- ``Database/drop(table:)`` +- ``Database/dropFTS4SynchronizationTriggers(forTable:)`` +- ``Database/dropFTS5SynchronizationTriggers(forTable:)`` +- ``Database/rename(table:to:)`` +- ``Database/ColumnType`` +- ``Database/ConflictResolution`` +- ``Database/ForeignKeyAction`` +- ``TableAlteration`` +- ``TableDefinition`` +- ``TableOptions`` +- ``VirtualTableModule`` + +### Database Views + +- ``Database/create(view:options:columns:as:)`` +- ``Database/create(view:options:columns:asLiteral:)`` +- ``Database/drop(view:)`` +- ``ViewOptions`` + +### Database Indexes + +- ``Database/create(indexOn:columns:options:condition:)`` +- ``Database/create(index:on:columns:options:condition:)`` +- ``Database/create(index:on:expressions:options:condition:)`` +- ``Database/drop(indexOn:columns:)`` +- ``Database/drop(index:)`` +- ``IndexOptions`` + +### Sunsetted Methods + +Those are legacy interfaces that are preserved for backwards compatibility. Their use is not recommended. + +- ``Database/create(index:on:columns:unique:ifNotExists:condition:)`` +- ``Database/create(table:temporary:ifNotExists:withoutRowID:body:)`` diff --git a/GRDB/Documentation.docc/DatabaseSchemaRecommendations.md b/GRDB/Documentation.docc/DatabaseSchemaRecommendations.md new file mode 100644 index 0000000000..997981ece7 --- /dev/null +++ b/GRDB/Documentation.docc/DatabaseSchemaRecommendations.md @@ -0,0 +1,334 @@ +# Database Schema Recommendations + +Recommendations for an ideal integration of the database schema with GRDB + +## Overview + +Even though all schema are supported, some features of the library and of the Swift language are easier to use when the schema follows a few conventions described below. + +When those conventions are not applied, or not applicable, you will have to perform extra configurations. + +For recommendations specific to JSON columns, see . + +## Table names should be English, singular, and camelCased + +Make them look like singular Swift identifiers: `player`, `team`, `postalAddress`: + +```swift +// RECOMMENDED +try db.create(table: "player") { t in + // table columns and constraints +} + +// REQUIRES EXTRA CONFIGURATION +try db.create(table: "players") { t in + // table columns and constraints +} +``` + +☝️ **If table names follow a different naming convention**, record types (see ) will need explicit table names: + +```swift +extension Player: TableRecord { + // Required because table name is not 'player' + static let databaseTableName = "players" +} + +extension PostalAddress: TableRecord { + // Required because table name is not 'postalAddress' + static let databaseTableName = "postal_address" +} + +extension Award: TableRecord { + // Required because table name is not 'award' + static let databaseTableName = "Auszeichnung" +} +``` + +[Associations](https://github.com/groue/GRDB.swift/blob/master/Documentation/AssociationsBasics.md) will need explicit keys as well: + +```swift +extension Player: TableRecord { + // Explicit association key because the table name is not 'postalAddress' + static let postalAddress = belongsTo(PostalAddress.self, key: "postalAddress") + + // Explicit association key because the table name is not 'award' + static let awards = hasMany(Award.self, key: "awards") +} +``` + +As in the above example, make sure to-one associations use singular keys, and to-many associations use plural keys. + +## Column names should be camelCased + +Again, make them look like Swift identifiers: `fullName`, `score`, `creationDate`: + +```swift +// RECOMMENDED +try db.create(table: "player") { t in + t.autoIncrementedPrimaryKey("id") + t.column("fullName", .text).notNull() + t.column("score", .integer).notNull() + t.column("creationDate", .datetime).notNull() +} + +// REQUIRES EXTRA CONFIGURATION +try db.create(table: "player") { t in + t.autoIncrementedPrimaryKey("id") + t.column("full_name", .text).notNull() + t.column("score", .integer).notNull() + t.column("creation_date", .datetime).notNull() +} +``` + +☝️ **If the column names follow a different naming convention**, `Codable` record types will need an explicit `CodingKeys` enum: + +```swift +struct Player: Decodable, FetchableRecord { + var id: Int64 + var fullName: String + var score: Int + var creationDate: Date + + // Required CodingKeys customization because + // columns are not named like Swift properties + enum CodingKeys: String, CodingKey { + case id, fullName = "full_name", score, creationDate = "creation_date" + } +} +``` + +## Tables should have explicit primary keys + +A primary key uniquely identifies a row in a table. It is defined on one or several columns: + +```swift +// RECOMMENDED +try db.create(table: "player") { t in + // Auto-incremented primary key + t.autoIncrementedPrimaryKey("id") + t.column("name", .text).notNull() +} + +try db.create(table: "team") { t in + // Single-column primary key + t.primaryKey("id", .text) + t.column("name", .text).notNull() +} + +try db.create(table: "membership") { t in + // Composite primary key + t.primaryKey { + t.belongsTo("player") + t.belongsTo("team") + } + t.column("role", .text).notNull() +} +``` + +Primary keys support record fetching methods such as ``FetchableRecord/fetchOne(_:id:)``, and persistence methods such as ``MutablePersistableRecord/update(_:onConflict:)`` or ``MutablePersistableRecord/delete(_:)``. + +See when you need to define a table that contains a single row. + +☝️ **If the database table does not define any explicit primary key**, identifying specific rows in this table needs explicit support for the [hidden `rowid` column](https://www.sqlite.org/rowidtable.html) in the matching record types: + +```swift +// A table without any explicit primary key +try db.create(table: "player") { t in + t.column("name", .text).notNull() + t.column("score", .integer).notNull() +} + +// The record type for the 'player' table' +struct Player: Codable { + // Uniquely identifies a player. + var rowid: Int64? + var name: String + var score: Int +} + +extension Player: FetchableRecord, MutablePersistableRecord { + // Required because the primary key + // is the hidden rowid column. + static var databaseSelection: [any SQLSelectable] { + [AllColumns(), Column.rowID] + } + + // Update id upon successful insertion + mutating func didInsert(_ inserted: InsertionSuccess) { + rowid = inserted.rowID + } +} + +try dbQueue.read { db in + // SELECT *, rowid FROM player WHERE rowid = 1 + if let player = try Player.fetchOne(db, id: 1) { + // DELETE FROM player WHERE rowid = 1 + let deleted = try player.delete(db) + print(deleted) // true + } +} +``` + +## Single-column primary keys should be named 'id' + +This helps record types play well with the standard `Identifiable` protocol. + +```swift +// RECOMMENDED +try db.create(table: "player") { t in + t.primaryKey("id", .text) + t.column("name", .text).notNull() +} + +// REQUIRES EXTRA CONFIGURATION +try db.create(table: "player") { t in + t.primaryKey("uuid", .text) + t.column("name", .text).notNull() +} +``` +☝️ **If the primary key follows a different naming convention**, `Identifiable` record types will need a custom `CodingKeys` enum, or an extra property: + +```swift +// Custom coding keys +struct Player: Codable, Identifiable { + var id: String + var name: String + + // Required CodingKeys customization because + // columns are not named like Swift properties + enum CodingKeys: String, CodingKey { + case id = "uuid", name + } +} + +// Extra property +struct Player: Identifiable { + var uuid: String + var name: String + + // Required because the primary key column is not 'id' + var id: String { uuid } +} +``` + +## Unique keys should be supported by unique indexes + +Unique indexes makes sure SQLite prevents the insertion of conflicting rows: + +```swift +// RECOMMENDED +try db.create(table: "player") { t in + t.autoIncrementedPrimaryKey("id") + t.belongsTo("team").notNull() + t.column("position", .integer).notNull() + // Players must have distinct names + t.column("name", .text).unique() +} + +// One single player at any given position in a team +try db.create( + indexOn: "player", + columns: ["teamId", "position"], + options: .unique) +``` + +> Tip: SQLite does not support deferred unique indexes, and this creates undesired churn when you need to temporarily break them. This may happen, for example, when you want to reorder player positions in our above example. +> +> There exist several workarounds; one of them involves dropping and recreating the unique index after the temporary violations have been fixed. If you plan to use this technique, take care that only actual indexes can be dropped. Unique constraints created inside the table body can not: +> +> ```swift +> // Unique constraint on player(name) can not be dropped. +> try db.create(table: "player") { t in +> t.column("name", .text).unique() +> } +> +> // Unique index on team(name) can be dropped. +> try db.create(table: "team") { t in +> t.column("name", .text) +> } +> try db.create(indexOn: "team", columns: ["name"], options: .unique) +> ``` +> +> If you want to turn an undroppable constraint into a droppable index, you'll need to recreate the database table. See for the detailed procedure. + +☝️ **If a table misses unique indexes**, some record methods such as ``FetchableRecord/fetchOne(_:key:)-92b9m`` and ``TableRecord/deleteOne(_:key:)-5pdh5`` will raise a fatal error: + +```swift +try dbQueue.write { db in + // Fatal error: table player has no unique index on columns ... + let player = try Player.fetchOne(db, key: ["teamId": 42, "position": 1]) + try Player.deleteOne(db, key: ["name": "Arthur"]) + + // Use instead: + let player = try Player + .filter(Column("teamId") == 42 && Column("position") == 1) + .fetchOne(db) + + try Player + .filter(Column("name") == "Arthur") + .deleteAll(db) +} +``` + +## Relations between tables should be supported by foreign keys + +[Foreign Keys](https://www.sqlite.org/foreignkeys.html) have SQLite enforce valid relationships between tables: + +```swift +try db.create(table: "team") { t in + t.autoIncrementedPrimaryKey("id") + t.column("color", .text).notNull() +} + +// RECOMMENDED +try db.create(table: "player") { t in + t.autoIncrementedPrimaryKey("id") + t.column("name", .text).notNull() + // A player must refer to an existing team + t.belongsTo("team").notNull() +} + +// REQUIRES EXTRA CONFIGURATION +try db.create(table: "player") { t in + t.autoIncrementedPrimaryKey("id") + t.column("name", .text).notNull() + // No foreign key + t.column("teamId", .integer).notNull() +} +``` + +See ``TableDefinition/belongsTo(_:inTable:onDelete:onUpdate:deferred:indexed:)`` for more information about the creation of foreign keys. + +GRDB [Associations](https://github.com/groue/GRDB.swift/blob/master/Documentation/AssociationsBasics.md) are automatically configured from foreign keys declared in the database schema: + +```swift +extension Player: TableRecord { + static let team = belongsTo(Team.self) +} + +extension Team: TableRecord { + static let players = hasMany(Player.self) +} +``` + +See [Associations and the Database Schema](https://github.com/groue/GRDB.swift/blob/master/Documentation/AssociationsBasics.md#associations-and-the-database-schema) for more precise recommendations. + +☝️ **If a foreign key is not declared in the schema**, you will need to explicitly configure related associations: + +```swift +extension Player: TableRecord { + // Required configuration because the database does + // not declare any foreign key from players to their team. + static let teamForeignKey = ForeignKey(["teamId"]) + static let team = belongsTo(Team.self, + using: teamForeignKey) +} + +extension Team: TableRecord { + // Required configuration because the database does + // not declare any foreign key from players to their team. + static let players = hasMany(Player.self, + using: Player.teamForeignKey) +} +``` diff --git a/GRDB/Documentation.docc/JSON.md b/GRDB/Documentation.docc/JSON.md index 5e3dcd6b89..d4964bc76b 100644 --- a/GRDB/Documentation.docc/JSON.md +++ b/GRDB/Documentation.docc/JSON.md @@ -27,8 +27,8 @@ try db.create(table: "player") { t in > Tip: When an application performs queries on values embedded inside JSON columns, indexes can help performance: > > ```swift -> // CREATE INDEX "player_on_country" -> // ON "player"("address" ->> 'country') +> // CREATE INDEX player_on_country +> // ON player(address ->> 'country') > try db.create( > index: "player_on_country", > on: "player", @@ -37,7 +37,7 @@ try db.create(table: "player") { t in > ]) > > // SELECT * FROM player -> // WHERE "address" ->> 'country' = 'DE' +> // WHERE address ->> 'country' = 'DE' > let germanPlayers = try Player > .filter(JSONColumn("address")["country"] == "DE") > .fetchAll(db) diff --git a/GRDB/Documentation.docc/RecordRecommendedPractices.md b/GRDB/Documentation.docc/RecordRecommendedPractices.md index 405bb8735a..222080a29c 100644 --- a/GRDB/Documentation.docc/RecordRecommendedPractices.md +++ b/GRDB/Documentation.docc/RecordRecommendedPractices.md @@ -39,7 +39,7 @@ migrator.registerMigration("createLibrary") { db in try migrator.migrate(dbQueue) ``` -1. Our database tables follow the : table names are English, singular, and camelCased. They look like Swift identifiers: `author`, `book`, `postalAddress`, `httpRequest`. +1. Our database tables follow the : table names are English, singular, and camelCased. They look like Swift identifiers: `author`, `book`, `postalAddress`, `httpRequest`. 2. Each author has a unique id. 3. An author must have a name. 4. The country of an author is not always known. diff --git a/Tests/GRDBTests/DatabaseQueueSchemaCacheTests.swift b/Tests/GRDBTests/DatabaseQueueSchemaCacheTests.swift index 3f54530f6c..cbd795bfa8 100644 --- a/Tests/GRDBTests/DatabaseQueueSchemaCacheTests.swift +++ b/Tests/GRDBTests/DatabaseQueueSchemaCacheTests.swift @@ -388,12 +388,35 @@ class DatabaseQueueSchemaCacheTests : GRDBTestCase { try main.inDatabase { db in try db.execute(literal: "ATTACH DATABASE \(attached.path) AS attached") - let tableExists = try db.tableExists("t") - let viewExists = try db.viewExists("v") - let triggerExists = try db.triggerExists("tr") - XCTAssertTrue(tableExists) - XCTAssertTrue(viewExists) - XCTAssertTrue(triggerExists) + try XCTAssertTrue(db.tableExists("t")) + try XCTAssertTrue(db.viewExists("v")) + try XCTAssertTrue(db.triggerExists("tr")) + + try XCTAssertFalse(db.tableExists("t", in: "main")) + try XCTAssertFalse(db.viewExists("v", in: "main")) + try XCTAssertFalse(db.triggerExists("tr", in: "main")) + + try XCTAssertTrue(db.tableExists("t", in: "attached")) + try XCTAssertTrue(db.viewExists("v", in: "attached")) + try XCTAssertTrue(db.triggerExists("tr", in: "attached")) + } + } + + func testExistsWithUnspecifiedSchemaFindsTempSchema() throws { + try makeDatabaseQueue().inDatabase { db in + try db.execute(sql: """ + CREATE TEMPORARY TABLE t (id INTEGER); + CREATE TEMPORARY VIEW v AS SELECT * FROM t; + """) + + try XCTAssertTrue(db.tableExists("t")) + try XCTAssertTrue(db.viewExists("v")) + + try XCTAssertTrue(db.tableExists("t", in: "temp")) + try XCTAssertTrue(db.viewExists("v", in: "temp")) + + try XCTAssertFalse(db.tableExists("t", in: "main")) + try XCTAssertFalse(db.viewExists("v", in: "main")) } } }