Skip to content

Commit 77fa346

Browse files
Copilotbgoncal
andauthored
Add Database Explorer to debugging menu (#4167)
## Summary Adds a "Database Explorer" feature to the debugging settings. When tapped, displays a list of GRDB database tables. Selecting a table shows all entries (up to 1000 rows) with: - Text search across all columns - Server ID filter (when table has `serverId` column and multiple servers configured) - Tap-through to view all fields for any row ### Changes - **DatabaseExplorerView.swift** - Lists all tables from `sqlite_master` - **DatabaseTableDetailView.swift** - Table entry viewer with filtering, uses `@StateObject` ViewModel pattern - **DebugView.swift** - Added navigation link in the existing section with Event Log and Location History - Localization strings added for new UI elements ### Security - Table names validated via parameterized query before use - SQL identifier quoting applied - Row limit (1000) prevents memory issues on large tables ## Screenshots <!-- If this is a user-facing change not in the frontend, please include screenshots in light and dark mode. --> ## Link to pull request in Documentation repository Documentation: home-assistant/companion.home-assistant# ## Any other notes Uses existing patterns from `ClientEventsLogView` and `ServersPickerPillList`. Follows `DesignSystem.Spaces` convention. <!-- START COPILOT CODING AGENT SUFFIX --> <!-- START COPILOT ORIGINAL PROMPT --> <details> <summary>Original prompt</summary> > Inside app settings, inside debugging, let's add a "Database explorer" feature, which when you tap opens a list of GRDB database tables, then on tap on it it shows all entries for that table with filter by text and serverId. > > Check GRDB+Initialization so understand the database </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] <[email protected]> Co-authored-by: bgoncal <[email protected]>
1 parent 1b0d17c commit 77fa346

File tree

7 files changed

+313
-4
lines changed

7 files changed

+313
-4
lines changed

HomeAssistant.xcodeproj/project.pbxproj

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,8 @@
565565
420B100C2B1D204400D383D8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 420B100B2B1D204400D383D8 /* Assets.xcassets */; };
566566
420C1BB22CF7DA9100AF22E7 /* ClientEventsLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420C1BB12CF7DA9100AF22E7 /* ClientEventsLogView.swift */; };
567567
420C1BB52CF7DC1400AF22E7 /* ClientEventsLogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420C1BB42CF7DC1400AF22E7 /* ClientEventsLogViewModel.swift */; };
568+
420DBEXP2F10000000000001 /* DatabaseExplorerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420DBEXP2F10000000000002 /* DatabaseExplorerView.swift */; };
569+
420DBEXP2F10000000000003 /* DatabaseTableDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420DBEXP2F10000000000004 /* DatabaseTableDetailView.swift */; };
568570
420C91502F0C6CAC005D04A6 /* HomeViewCustomizationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420C914F2F0C6CAC005D04A6 /* HomeViewCustomizationView.swift */; };
569571
420C91522F0C7988005D04A6 /* EntityRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420C91512F0C7988005D04A6 /* EntityRegistry.swift */; };
570572
420C91532F0C7988005D04A6 /* EntityRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420C91512F0C7988005D04A6 /* EntityRegistry.swift */; };
@@ -2231,6 +2233,8 @@
22312233
420B100B2B1D204400D383D8 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
22322234
420C1BB12CF7DA9100AF22E7 /* ClientEventsLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientEventsLogView.swift; sourceTree = "<group>"; };
22332235
420C1BB42CF7DC1400AF22E7 /* ClientEventsLogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientEventsLogViewModel.swift; sourceTree = "<group>"; };
2236+
420DBEXP2F10000000000002 /* DatabaseExplorerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseExplorerView.swift; sourceTree = "<group>"; };
2237+
420DBEXP2F10000000000004 /* DatabaseTableDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseTableDetailView.swift; sourceTree = "<group>"; };
22342238
420C914F2F0C6CAC005D04A6 /* HomeViewCustomizationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewCustomizationView.swift; sourceTree = "<group>"; };
22352239
420C91512F0C7988005D04A6 /* EntityRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityRegistry.swift; sourceTree = "<group>"; };
22362240
420C91542F0C7AB4005D04A6 /* EntityRegistry.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityRegistry.test.swift; sourceTree = "<group>"; };
@@ -4567,6 +4571,15 @@
45674571
path = ClientEventsLogView;
45684572
sourceTree = "<group>";
45694573
};
4574+
420DBEXP2F10000000000005 /* DatabaseExplorer */ = {
4575+
isa = PBXGroup;
4576+
children = (
4577+
420DBEXP2F10000000000002 /* DatabaseExplorerView.swift */,
4578+
420DBEXP2F10000000000004 /* DatabaseTableDetailView.swift */,
4579+
);
4580+
path = DatabaseExplorer;
4581+
sourceTree = "<group>";
4582+
};
45704583
420C915B2F0C7AF4005D04A6 /* Models */ = {
45714584
isa = PBXGroup;
45724585
children = (
@@ -6658,6 +6671,7 @@
66586671
1169B7AC25AA76E30035F2AE /* MaterialDesignIcons+Eureka.swift */,
66596672
42B19BD02D4A358B00B3262B /* DebugView.swift */,
66606673
420C1BB32CF7DBF300AF22E7 /* ClientEventsLogView */,
6674+
420DBEXP2F10000000000005 /* DatabaseExplorer */,
66616675
119DE9552633A8C40099F7D8 /* SettingsRootDataSource.swift */,
66626676
);
66636677
path = Settings;
@@ -8983,6 +8997,8 @@
89838997
425573CC2B5574AD00145217 /* CarPlayAreasZonesTemplate+Build.swift in Sources */,
89848998
428DC00D2F0D8BD5003B08D5 /* FanControlsViewModel.swift in Sources */,
89858999
420C1BB22CF7DA9100AF22E7 /* ClientEventsLogView.swift in Sources */,
9000+
420DBEXP2F10000000000001 /* DatabaseExplorerView.swift in Sources */,
9001+
420DBEXP2F10000000000003 /* DatabaseTableDetailView.swift in Sources */,
89869002
B626AAF11D8F972800A0D225 /* SettingsDetailViewController.swift in Sources */,
89879003
42E6C08C2CE4F7A8007CA622 /* DownloadManagerViewModel.swift in Sources */,
89889004
421155212D3525F500A71630 /* AppIconSelectorView.swift in Sources */,

Sources/App/Resources/en.lproj/Localizable.strings

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,10 @@ Home Assistant is open source, advocates for privacy and runs locally in your ho
716716
"settings.debugging.shake_disclaimer_optional.title" = "Shake the App to open debug";
717717
"settings.debugging.thread.footer" = "Check what Thread credentials are inside Apple Keychain, you can also import in Home Assistant or delete from Keychain.";
718718
"settings.debugging.title" = "Debugging";
719+
"settings.database_explorer.more_fields" = "+%li more fields";
720+
"settings.database_explorer.no_entries" = "No entries found";
721+
"settings.database_explorer.row_detail" = "Row Details";
722+
"settings.database_explorer.title" = "Database Explorer";
719723
"settings.details_section.location_settings_row.title" = "Location";
720724
"settings.details_section.notification_settings_row.title" = "Notifications";
721725
"settings.details_section.watch_row.title" = "Apple Watch";
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import GRDB
2+
import SFSafeSymbols
3+
import Shared
4+
import SwiftUI
5+
6+
struct DatabaseExplorerView: View {
7+
@State private var tables: [String] = []
8+
9+
var body: some View {
10+
List {
11+
ForEach(tables, id: \.self) { table in
12+
NavigationLink {
13+
DatabaseTableDetailView(tableName: table)
14+
} label: {
15+
HStack(spacing: DesignSystem.Spaces.two) {
16+
Image(systemSymbol: .tablecells)
17+
.foregroundStyle(Color.haPrimary)
18+
Text(table)
19+
}
20+
}
21+
}
22+
}
23+
.navigationTitle(L10n.Settings.DatabaseExplorer.title)
24+
.navigationBarTitleDisplayMode(.large)
25+
.onAppear {
26+
loadTables()
27+
}
28+
}
29+
30+
private func loadTables() {
31+
do {
32+
tables = try Current.database().read { db in
33+
try String.fetchAll(db, sql: "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
34+
}
35+
} catch {
36+
Current.Log.error("Failed to load database tables: \(error)")
37+
}
38+
}
39+
}
40+
41+
#Preview {
42+
NavigationView {
43+
DatabaseExplorerView()
44+
}
45+
}
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import GRDB
2+
import SFSafeSymbols
3+
import Shared
4+
import SwiftUI
5+
6+
struct DatabaseTableDetailView: View {
7+
let tableName: String
8+
9+
@StateObject private var viewModel: DatabaseTableDetailViewModel
10+
11+
init(tableName: String) {
12+
self.tableName = tableName
13+
_viewModel = StateObject(wrappedValue: DatabaseTableDetailViewModel(tableName: tableName))
14+
}
15+
16+
var body: some View {
17+
List {
18+
serverFilter
19+
ForEach(viewModel.filteredRows.indices, id: \.self) { index in
20+
rowView(viewModel.filteredRows[index])
21+
}
22+
if viewModel.filteredRows.isEmpty {
23+
Text(L10n.Settings.DatabaseExplorer.noEntries)
24+
.frame(maxWidth: .infinity, alignment: .center)
25+
.listRowBackground(Color.clear)
26+
.font(.headline)
27+
.foregroundColor(.secondary)
28+
}
29+
}
30+
.searchable(text: $viewModel.searchText)
31+
.navigationTitle(tableName)
32+
.navigationBarTitleDisplayMode(.large)
33+
.onAppear {
34+
viewModel.loadData()
35+
}
36+
}
37+
38+
@ViewBuilder
39+
private var serverFilter: some View {
40+
if viewModel.hasServerIdColumn, Current.servers.all.count > 1 {
41+
Section {
42+
ScrollView(.horizontal, showsIndicators: false) {
43+
HStack {
44+
Button {
45+
viewModel.selectedServerId = nil
46+
} label: {
47+
PillView(
48+
text: L10n.ClientEvents.EventType.all,
49+
selected: viewModel.selectedServerId == nil
50+
)
51+
}
52+
.buttonStyle(.plain)
53+
54+
ForEach(Current.servers.all, id: \.identifier) { server in
55+
Button {
56+
viewModel.selectedServerId = server.identifier.rawValue
57+
} label: {
58+
PillView(
59+
text: server.info.name,
60+
selected: viewModel.selectedServerId == server.identifier.rawValue
61+
)
62+
}
63+
.buttonStyle(.plain)
64+
}
65+
}
66+
}
67+
.listRowBackground(Color.clear)
68+
}
69+
.modify { view in
70+
if #available(iOS 17.0, *) {
71+
view.listSectionSpacing(DesignSystem.Spaces.one)
72+
} else {
73+
view
74+
}
75+
}
76+
}
77+
}
78+
79+
private func rowView(_ row: [String: String]) -> some View {
80+
NavigationLink {
81+
DatabaseRowDetailView(row: row)
82+
} label: {
83+
VStack(alignment: .leading, spacing: DesignSystem.Spaces.half) {
84+
ForEach(row.keys.sorted().prefix(3), id: \.self) { key in
85+
HStack {
86+
Text(key)
87+
.font(.caption)
88+
.foregroundColor(.secondary)
89+
Spacer()
90+
Text(row[key] ?? "nil")
91+
.font(.caption)
92+
.foregroundColor(.primary)
93+
.lineLimit(1)
94+
}
95+
}
96+
if row.count > 3 {
97+
Text(L10n.Settings.DatabaseExplorer.moreFields(row.count - 3))
98+
.font(.caption2)
99+
.foregroundColor(.secondary)
100+
}
101+
}
102+
}
103+
}
104+
}
105+
106+
final class DatabaseTableDetailViewModel: ObservableObject {
107+
let tableName: String
108+
109+
@Published var rows: [[String: String]] = []
110+
@Published var searchText: String = ""
111+
@Published var selectedServerId: String?
112+
@Published var hasServerIdColumn: Bool = false
113+
114+
init(tableName: String) {
115+
self.tableName = tableName
116+
}
117+
118+
var filteredRows: [[String: String]] {
119+
var result = rows
120+
121+
// Filter by serverId if selected and column exists
122+
if let serverId = selectedServerId, hasServerIdColumn {
123+
result = result.filter { row in
124+
row["serverId"] == serverId
125+
}
126+
}
127+
128+
// Filter by search text
129+
if !searchText.isEmpty {
130+
result = result.filter { row in
131+
row.values.contains { value in
132+
value.lowercased().contains(searchText.lowercased())
133+
}
134+
}
135+
}
136+
137+
return result
138+
}
139+
140+
func loadData() {
141+
do {
142+
let database = Current.database()
143+
144+
// Validate table name exists in the database to prevent SQL injection
145+
let tableExists = try database.read { db in
146+
try String.fetchOne(
147+
db,
148+
sql: "SELECT name FROM sqlite_master WHERE type='table' AND name = ?",
149+
arguments: [tableName]
150+
) != nil
151+
}
152+
153+
guard tableExists else {
154+
Current.Log.error("Table '\(tableName)' not found in database")
155+
return
156+
}
157+
158+
// Check if table has serverId column
159+
let columns = try database.read { db in
160+
try db.columns(in: tableName).map(\.name)
161+
}
162+
hasServerIdColumn = columns.contains("serverId")
163+
164+
// Fetch rows with a limit to prevent memory issues on large tables
165+
// Table name is already validated to exist above via parameterized query
166+
rows = try database.read { db in
167+
let quotedTableName = "\"\(tableName.replacingOccurrences(of: "\"", with: "\"\""))\""
168+
let cursor = try Row.fetchCursor(db, sql: "SELECT * FROM \(quotedTableName) LIMIT 1000")
169+
var result: [[String: String]] = []
170+
while let row = try cursor.next() {
171+
var dict: [String: String] = [:]
172+
for column in row.columnNames {
173+
if let value = row[column] {
174+
dict[column] = String(describing: value)
175+
} else {
176+
dict[column] = "nil"
177+
}
178+
}
179+
result.append(dict)
180+
}
181+
return result
182+
}
183+
} catch {
184+
Current.Log.error("Failed to load table data: \(error)")
185+
}
186+
}
187+
}
188+
189+
struct DatabaseRowDetailView: View {
190+
let row: [String: String]
191+
192+
var body: some View {
193+
List {
194+
ForEach(row.keys.sorted(), id: \.self) { key in
195+
VStack(alignment: .leading, spacing: DesignSystem.Spaces.half) {
196+
Text(key)
197+
.font(.caption)
198+
.foregroundColor(.secondary)
199+
Text(row[key] ?? "nil")
200+
.font(.body)
201+
.foregroundColor(.primary)
202+
.textSelection(.enabled)
203+
}
204+
}
205+
}
206+
.navigationTitle(L10n.Settings.DatabaseExplorer.rowDetail)
207+
.navigationBarTitleDisplayMode(.inline)
208+
}
209+
}
210+
211+
#Preview {
212+
NavigationView {
213+
DatabaseTableDetailView(tableName: "hAAppEntity")
214+
}
215+
}

Sources/App/Settings/DebugView.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,15 @@ struct DebugView: View {
109109
title: L10n.Settings.LocationHistory.title
110110
)
111111
}
112+
113+
NavigationLink {
114+
DatabaseExplorerView()
115+
} label: {
116+
linkContent(
117+
image: .init(systemSymbol: .tablecells),
118+
title: L10n.Settings.DatabaseExplorer.title
119+
)
120+
}
112121
}
113122

114123
criticalSection

Sources/Shared/Environment/AppDatabaseUpdater.swift

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,9 @@ final class AppDatabaseUpdater: AppDatabaseUpdaterProtocol {
249249
"error": error.localizedDescription,
250250
]
251251
))
252-
assertionFailure("Failed to save areas in database: \(error)")
252+
if !(error is CancellationError) {
253+
assertionFailure("Failed to save areas in database: \(error)")
254+
}
253255
}
254256
}
255257

@@ -300,7 +302,9 @@ final class AppDatabaseUpdater: AppDatabaseUpdaterProtocol {
300302
"error": error.localizedDescription,
301303
]
302304
))
303-
assertionFailure("Failed to save EntityRegistryListForDisplay in database: \(error)")
305+
if !(error is CancellationError) {
306+
assertionFailure("Failed to save EntityRegistryListForDisplay in database: \(error)")
307+
}
304308
}
305309
}
306310

@@ -345,7 +349,9 @@ final class AppDatabaseUpdater: AppDatabaseUpdaterProtocol {
345349
"error": error.localizedDescription,
346350
]
347351
))
348-
assertionFailure("Failed to save entity registry in database: \(error)")
352+
if !(error is CancellationError) {
353+
assertionFailure("Failed to save entity registry in database: \(error)")
354+
}
349355
}
350356
}
351357

@@ -390,7 +396,9 @@ final class AppDatabaseUpdater: AppDatabaseUpdaterProtocol {
390396
"error": error.localizedDescription,
391397
]
392398
))
393-
assertionFailure("Failed to save device registry in database: \(error)")
399+
if !(error is CancellationError) {
400+
assertionFailure("Failed to save device registry in database: \(error)")
401+
}
394402
}
395403
}
396404
}

0 commit comments

Comments
 (0)