row editing

This commit is contained in:
Mark Gregory
2026-10-20 09:14:25 +01:00
parent 499dd49ec2
commit 6768ef5fff
2 changed files with 51 additions and 33 deletions
+4 -4
View File
@@ -221,8 +221,8 @@
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "DBSyn"; INFOPLIST_KEY_CFBundleDisplayName = "DBSyn";
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.0.3;
CURRENT_PROJECT_VERSION = 73; CURRENT_PROJECT_VERSION = 76;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 ABSDEV. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 ABSDEV. All rights reserved.";
@@ -244,8 +244,8 @@
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "DBSyn"; INFOPLIST_KEY_CFBundleDisplayName = "DBSyn";
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.0.3;
CURRENT_PROJECT_VERSION = 73; CURRENT_PROJECT_VERSION = 76;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 ABSDEV. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 ABSDEV. All rights reserved.";
+47 -29
View File
@@ -1119,13 +1119,13 @@ struct MainView: View {
Spacer() Spacer()
Button { Button {
editResultRow = selectedResultRow if let row = selectedResultRow { openResultRowEditor(row) }
} label: { } label: {
Label("Edit Row…", systemImage: "pencil") Label("Edit Row…", systemImage: "pencil")
} }
.disabled(selectedResultRow == nil || editableResultTarget == nil) .disabled(selectedResultRow == nil || parsedResultTarget == nil)
.help(editableResultTarget == nil .help(parsedResultTarget == nil
? "Editing requires a SELECT * from a single base table with a primary key and loaded introspection metadata." ? "Editing requires a single-table SELECT containing its primary key."
: selectedResultRow == nil ? "Select a result row first." : "Edit selected row and preview SQL") : selectedResultRow == nil ? "Select a result row first." : "Edit selected row and preview SQL")
if !model.result.columns.isEmpty { if !model.result.columns.isEmpty {
@@ -1197,9 +1197,9 @@ struct MainView: View {
} }
.sheet(isPresented: Binding( .sheet(isPresented: Binding(
get: { editResultRow != nil }, get: { editResultRow != nil },
set: { if !$0 { editResultRow = nil } } set: { if !$0 { editResultRow = nil; editingResultTarget = nil } }
)) { )) {
if let row = editResultRow, let target = editableResultTarget { if let row = editResultRow, let target = editingResultTarget {
DBSynInlineRowEditor(model: model, database: target.database, DBSynInlineRowEditor(model: model, database: target.database,
table: target.table, columns: model.result.columns, original: row) table: target.table, columns: model.result.columns, original: row)
} }
@@ -1220,39 +1220,57 @@ struct MainView: View {
let end = min(start + resultPageSize, rows.count) let end = min(start + resultPageSize, rows.count)
return NativeResultGrid(columns: model.result.columns, rows: Array(rows[start..<end]), hiddenColumnIndices: hiddenResultColumns, onSelect: { selectedResultRow = $0 }, onEdit: { row in return NativeResultGrid(columns: model.result.columns, rows: Array(rows[start..<end]), hiddenColumnIndices: hiddenResultColumns, onSelect: { selectedResultRow = $0 }, onEdit: { row in
selectedResultRow = row selectedResultRow = row
if editableResultTarget != nil { editResultRow = row } openResultRowEditor(row)
}) })
} }
// Editing is restricted to a simple SELECT * from a single base table. @State private var editingResultTarget: (database: String, table: String)?
// Arbitrary joins, expressions and aliases cannot be mapped safely to rows.
private var editableResultTarget: (database: String, table: String)? { private func openResultRowEditor(_ row: [String]) {
guard let target = parsedResultTarget else {
model.status = "Row editing requires a simple SELECT from one table."
return
}
editingResultTarget = target
// Fetch primary-key metadata when the user first edits; do not silently
// disable the editor because the schema browser has not been opened.
if model.introspection(for: target.database) == nil {
model.introspect(database: target.database)
}
editResultRow = row
}
// Parse the result query independently of the asynchronous schema cache.
// The editor validates actual primary keys before allowing an UPDATE.
private var parsedResultTarget: (database: String, table: String)? {
let statements = model.sql.components(separatedBy: ";") let statements = model.sql.components(separatedBy: ";")
guard let select = statements.last(where: { $0.range(of: #"(?i)^\s*SELECT\b"#, options: .regularExpression) != nil }) else { return nil } guard let select = statements.last(where: {
// Permit ordinary filtered/sorted single-table SELECT * results. The $0.range(of: #"(?i)^\s*SELECT\b"#, options: .regularExpression) != nil
// primary-key checks in DBSynInlineRowEditor still guard every UPDATE. }) else { return nil }
// Joins, grouping, unions and computed result sets remain read-only. let pattern = #"(?is)^\s*SELECT\s+(.+?)\s+FROM\s+((?:`[^`]+`|[A-Za-z_][A-Za-z0-9_]*)(?:\.(?:`[^`]+`|[A-Za-z_][A-Za-z0-9_]*))?)(?=\s|$)(.*)$"#
let pattern = #"(?is)^\s*SELECT\s+\*\s+FROM\s+((?:`[^`]+`|[A-Za-z_][A-Za-z0-9_]*)(?:\.(?:`[^`]+`|[A-Za-z_][A-Za-z0-9_]*))?)(?=\s|$)(.*)$"#
guard let regex = try? NSRegularExpression(pattern: pattern), guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: select, range: NSRange(select.startIndex..., in: select)), let match = regex.firstMatch(in: select, range: NSRange(select.startIndex..., in: select)),
let range = Range(match.range(at: 1), in: select) else { return nil } let projectionRange = Range(match.range(at: 1), in: select),
guard let tailRange = Range(match.range(at: 2), in: select) else { return nil } let tableRange = Range(match.range(at: 2), in: select),
let tailRange = Range(match.range(at: 3), in: select) else { return nil }
let projection = String(select[projectionRange]).trimmingCharacters(in: .whitespacesAndNewlines)
let tail = String(select[tailRange]).trimmingCharacters(in: .whitespacesAndNewlines) let tail = String(select[tailRange]).trimmingCharacters(in: .whitespacesAndNewlines)
// Only permit WHERE / ORDER BY / LIMIT clauses; do not treat joins,
// unions, grouped rows, or derived expressions as directly editable.
if !tail.isEmpty { if !tail.isEmpty {
guard tail.range(of: #"(?i)^(WHERE|ORDER\s+BY|LIMIT)\b"#, options: .regularExpression) != nil, guard tail.range(of: #"(?i)^(WHERE|ORDER\s+BY|LIMIT)\b"#, options: .regularExpression) != nil,
tail.range(of: #"(?i)\b(JOIN|UNION|GROUP\s+BY|HAVING|INTO|FOR\s+UPDATE|LOCK\s+IN\s+SHARE\s+MODE)\b"#, options: .regularExpression) == nil, tail.range(of: #"(?i)\b(JOIN|UNION|GROUP\s+BY|HAVING|INTO|FOR\s+UPDATE|LOCK\s+IN\s+SHARE\s+MODE)\b"#, options: .regularExpression) == nil else { return nil }
!tail.contains(";") else { return nil }
} }
let parts = String(select[range]).replacingOccurrences(of: "`", with: "").components(separatedBy: ".") let parts = String(select[tableRange]).replacingOccurrences(of: "`", with: "").components(separatedBy: ".")
let database = parts.count == 2 ? parts[0] : model.completionDatabase let database = parts.count == 2 ? parts[0] : model.completionDatabase
let table = parts.last ?? "" guard !database.isEmpty, let table = parts.last, !model.result.columns.isEmpty else { return nil }
guard !database.isEmpty, let snapshot = model.introspection(for: database), let names = model.result.columns.map { $0.lowercased() }
snapshot.tables.contains(where: { $0.caseInsensitiveCompare(table) == .orderedSame }), guard Set(names).count == names.count else { return nil }
let fields = snapshot.columnsByTable.first(where: { $0.key.caseInsensitiveCompare(table) == .orderedSame })?.value, if projection != "*" {
Set(fields.map { $0.name.lowercased() }) == Set(model.result.columns.map { $0.lowercased() }), let projected = projection.components(separatedBy: ",").map {
fields.contains(where: { $0.key == "PRI" }) else { return nil } $0.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "`", with: "")
}
guard projected.count == names.count,
zip(projected, names).allSatisfy({ $0.0.lowercased() == $0.1 }) else { return nil }
}
return (database, table) return (database, table)
} }
@@ -7722,7 +7740,7 @@ private struct DBSynInlineRowEditor: View {
private var validationError: String? { private var validationError: String? {
guard columns.count == original.count, values.count == columns.count, guard columns.count == original.count, values.count == columns.count,
metadata.count == columns.count else { return "Result columns do not match introspection metadata. Refresh introspection." } columns.allSatisfy({ column in metadata.contains(where: { $0.name.caseInsensitiveCompare(column) == .orderedSame }) }) else { return "Result columns are missing from introspection metadata. Refresh introspection." }
guard !primaryIndices.isEmpty else { return "A primary key is required for safe row updates." } guard !primaryIndices.isEmpty else { return "A primary key is required for safe row updates." }
guard !changedIndices.isEmpty else { return "Edit at least one non-key value to generate SQL." } guard !changedIndices.isEmpty else { return "Edit at least one non-key value to generate SQL." }
guard !changedIndices.contains(where: { primaryIndices.contains($0) }) else { return "Primary-key editing is not supported." } guard !changedIndices.contains(where: { primaryIndices.contains($0) }) else { return "Primary-key editing is not supported." }