iOS/Sources/Shared/Database/GRDB+Initialization.swift
Copilot 7647ddedd6
Abstract column migration logic into DatabaseTableProtocol for all tables (#4243)
## Summary

Table migration logic for adding new columns and removing obsolete
columns has been abstracted into `DatabaseTableProtocol` with a default
implementation. All 13 table implementations now conform to the extended
protocol and support automatic column migration.

**Protocol Changes:**
- Added `tableName: String` property requirement
- Added `definedColumns: [String]` property requirement  
- Added default `migrateColumns(database:)` implementation that handles
adding/removing columns

**Updated Tables:**
All tables now implement the protocol and support column migration:
- `HAppEntityTable`, `WatchConfigTable`, `CarPlayConfigTable`,
`AssistPipelinesTable`
- `AppEntityRegistryListForDisplayTable`, `AppEntityRegistryTable`,
`AppDeviceRegistryTable`
- `AppPanelTable`, `CustomWidgetTable`, `AppAreaTable`
- `HomeViewConfigurationTable`, `CameraListConfigurationTable`,
`AssistConfigurationTable`

**Column Enums:**
All column enums in `DatabaseTables` now conform to `CaseIterable` for
consistent migration support.

Relies on `ALTER TABLE ... DROP COLUMN` support (SQLite 3.35+, iOS 15+).

## Screenshots

N/A - Database internals only.

## Link to pull request in Documentation repository

N/A - No user-facing changes.

## Any other notes

The migration logic is now centralized in the protocol extension,
eliminating code duplication across table implementations. Each table
provides its `tableName` and `definedColumns`, and the shared
`migrateColumns(database:)` method handles the actual column
synchronization.

<!-- START COPILOT CODING AGENT SUFFIX -->



<!-- START COPILOT ORIGINAL PROMPT -->



<details>

<summary>Original prompt</summary>

> In GRDB+Initialization.swift each tablet has a logic to create table
columns that may not exist when the user updates the app, update those
table creationg to also delete columns that are not defined in the
create method anymore


</details>



<!-- START COPILOT CODING AGENT TIPS -->
---

 Let Copilot coding agent [set things up for
you](https://github.com/home-assistant/iOS/issues/new?title=+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot)
— coding agent works faster and does higher quality work when set up for
your repo.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: bgoncal <5808343+bgoncal@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-22 14:03:48 +01:00

118 lines
4.4 KiB
Swift

import Foundation
import GRDB
public extension DatabaseQueue {
// Following GRDB cocnurrency rules, we have just one database instance
// https://swiftpackageindex.com/groue/grdb.swift/v6.29.3/documentation/grdb/concurrency#Concurrency-Rules
static var appDatabase: DatabaseQueue = {
do {
let database = try DatabaseQueue(path: databasePath())
// Create tables if needed
for table in DatabaseQueue.tables() {
do {
try table.createIfNeeded(database: database)
} catch {
let className = String(describing: type(of: table))
let errorMessage = "Failed create GRDB table \(className), error: \(error.localizedDescription)"
Current.clientEventStore.addEvent(ClientEvent(text: errorMessage, type: .database))
}
}
DatabaseQueue.deleteOldTables(database: database)
#if targetEnvironment(simulator)
print("GRDB App database is stored at \(AppConstants.appGRDBFile.description)")
#endif
return database
} catch {
let errorMessage = "Failed to initialize GRDB, error: \(error.localizedDescription)"
Current.Log.error(errorMessage)
fatalError(errorMessage)
}
}()
static func databasePath() -> String {
// Path for tests
if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil {
let tempDirectory = NSTemporaryDirectory()
return (tempDirectory as NSString).appendingPathComponent("test_database.sqlite")
} else {
// Path for App use
return AppConstants.appGRDBFile.path
}
}
internal static func tables() -> [DatabaseTableProtocol] {
[
HAppEntityTable(),
WatchConfigTable(),
CarPlayConfigTable(),
AssistPipelinesTable(),
AppEntityRegistryListForDisplayTable(),
AppEntityRegistryTable(),
AppDeviceRegistryTable(),
AppPanelTable(),
CustomWidgetTable(),
AppAreaTable(),
HomeViewConfigurationTable(),
CameraListConfigurationTable(),
AssistConfigurationTable(),
]
}
internal static func deleteOldTables(database: DatabaseQueue) {
do {
/*
ClientEvent used to be saved in GRDB, but because of a problem of one process holding
lock on the database and causing crash 0xdead10cc now it is saved as a json file
More information: https://github.com/groue/GRDB.swift/issues/1626#issuecomment-2623927815
*/
try database.write { db in
try db.drop(table: GRDBDatabaseTable.clientEvent.rawValue)
}
} catch {
let errorMessage =
"Failed or not needed delete client event GRDB info, error: \(error.localizedDescription)"
Current.Log.verbose(errorMessage)
}
}
}
protocol DatabaseTableProtocol {
/// The name of the database table
var tableName: String { get }
/// The list of column names defined for this table
var definedColumns: [String] { get }
/// Creates the table if it doesn't exist, or migrates it if it does
func createIfNeeded(database: DatabaseQueue) throws
}
extension DatabaseTableProtocol {
/// Migrates the table by adding new columns and removing obsolete columns
func migrateColumns(database: DatabaseQueue) throws {
try database.write { db in
let existingColumns = try db.columns(in: tableName)
let definedColumnSet = Set(definedColumns)
// Add new columns that don't exist yet
for columnName in definedColumns {
let shouldCreateColumn = !existingColumns.contains { $0.name == columnName }
if shouldCreateColumn {
try db.alter(table: tableName) { tableAlteration in
tableAlteration.add(column: columnName)
}
}
}
// Remove columns that are no longer defined
for existingColumn in existingColumns where !definedColumnSet.contains(existingColumn.name) {
try db.alter(table: tableName) { tableAlteration in
tableAlteration.drop(column: existingColumn.name)
}
}
}
}
}