row editing
This commit is contained in:
@@ -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
@@ -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." }
|
||||
|
||||
Reference in New Issue
Block a user