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;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "DBSyn";
MARKETING_VERSION = 1.0.0;
CURRENT_PROJECT_VERSION = 73;
MARKETING_VERSION = 1.0.3;
CURRENT_PROJECT_VERSION = 76;
ENABLE_TESTABILITY = YES;
ONLY_ACTIVE_ARCH = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 ABSDEV. All rights reserved.";
@@ -244,8 +244,8 @@
ENABLE_APP_SANDBOX = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "DBSyn";
MARKETING_VERSION = 1.0.0;
CURRENT_PROJECT_VERSION = 73;
MARKETING_VERSION = 1.0.3;
CURRENT_PROJECT_VERSION = 76;
ENABLE_TESTABILITY = YES;
ONLY_ACTIVE_ARCH = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 ABSDEV. All rights reserved.";
+47 -29
View File
@@ -1119,13 +1119,13 @@ struct MainView: View {
Spacer()
Button {
editResultRow = selectedResultRow
if let row = selectedResultRow { openResultRowEditor(row) }
} label: {
Label("Edit Row…", systemImage: "pencil")
}
.disabled(selectedResultRow == nil || editableResultTarget == nil)
.help(editableResultTarget == nil
? "Editing requires a SELECT * from a single base table with a primary key and loaded introspection metadata."
.disabled(selectedResultRow == nil || parsedResultTarget == nil)
.help(parsedResultTarget == nil
? "Editing requires a single-table SELECT containing its primary key."
: selectedResultRow == nil ? "Select a result row first." : "Edit selected row and preview SQL")
if !model.result.columns.isEmpty {
@@ -1197,9 +1197,9 @@ struct MainView: View {
}
.sheet(isPresented: Binding(
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,
table: target.table, columns: model.result.columns, original: row)
}
@@ -1220,39 +1220,57 @@ struct MainView: View {
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
selectedResultRow = row
if editableResultTarget != nil { editResultRow = row }
openResultRowEditor(row)
})
}
// Editing is restricted to a simple SELECT * from a single base table.
// Arbitrary joins, expressions and aliases cannot be mapped safely to rows.
private var editableResultTarget: (database: String, table: String)? {
@State private var editingResultTarget: (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: ";")
guard let select = statements.last(where: { $0.range(of: #"(?i)^\s*SELECT\b"#, options: .regularExpression) != nil }) else { return nil }
// Permit ordinary filtered/sorted single-table SELECT * results. The
// primary-key checks in DBSynInlineRowEditor still guard every UPDATE.
// 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|$)(.*)$"#
guard let select = statements.last(where: {
$0.range(of: #"(?i)^\s*SELECT\b"#, options: .regularExpression) != nil
}) else { return nil }
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),
let match = regex.firstMatch(in: select, range: NSRange(select.startIndex..., in: select)),
let range = Range(match.range(at: 1), in: select) else { return nil }
guard let tailRange = Range(match.range(at: 2), in: select) else { return nil }
let projectionRange = Range(match.range(at: 1), in: select),
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)
// Only permit WHERE / ORDER BY / LIMIT clauses; do not treat joins,
// unions, grouped rows, or derived expressions as directly editable.
if !tail.isEmpty {
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.contains(";") else { return 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 }
}
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 table = parts.last ?? ""
guard !database.isEmpty, let snapshot = model.introspection(for: database),
snapshot.tables.contains(where: { $0.caseInsensitiveCompare(table) == .orderedSame }),
let fields = snapshot.columnsByTable.first(where: { $0.key.caseInsensitiveCompare(table) == .orderedSame })?.value,
Set(fields.map { $0.name.lowercased() }) == Set(model.result.columns.map { $0.lowercased() }),
fields.contains(where: { $0.key == "PRI" }) else { return nil }
guard !database.isEmpty, let table = parts.last, !model.result.columns.isEmpty else { return nil }
let names = model.result.columns.map { $0.lowercased() }
guard Set(names).count == names.count else { return nil }
if projection != "*" {
let projected = projection.components(separatedBy: ",").map {
$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)
}
@@ -7722,7 +7740,7 @@ private struct DBSynInlineRowEditor: View {
private var validationError: String? {
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 !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." }