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