added typeaheead and introspetion

This commit is contained in:
Mark Gregory
2026-10-19 09:24:12 +01:00
parent c102bef2a6
commit 4cbb5624af
8 changed files with 836 additions and 4 deletions
+16 -2
View File
@@ -7,6 +7,8 @@
objects = {
/* Begin PBXBuildFile section */
B73200000000000000000001 /* SchemaDiagramPrintView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73200000000000000000002 /* SchemaDiagramPrintView.swift */; };
B73000000000000000000001 /* SchemaDiagramView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73000000000000000000002 /* SchemaDiagramView.swift */; };
000000000000000000000013 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000010 /* App.swift */; };
000000000000000000000014 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000011 /* MainView.swift */; };
000000000000000000000015 /* DatabaseModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000012 /* DatabaseModel.swift */; };
@@ -17,6 +19,8 @@
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
B73200000000000000000002 /* SchemaDiagramPrintView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaDiagramPrintView.swift; sourceTree = "<group>"; };
B73000000000000000000002 /* SchemaDiagramView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaDiagramView.swift; sourceTree = "<group>"; };
00000000000000000000000F /* DBSyn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DBSyn.app; sourceTree = BUILT_PRODUCTS_DIR; };
000000000000000000000010 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; };
000000000000000000000011 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
@@ -52,6 +56,8 @@
000000000000000000000010 /* App.swift */,
000000000000000000000011 /* MainView.swift */,
000000000000000000000012 /* DatabaseModel.swift */,
B73000000000000000000002 /* SchemaDiagramView.swift */,
B73200000000000000000002 /* SchemaDiagramPrintView.swift */,
000000000000000000000016 /* MySQLProtocol.swift */,
A10000000000000000000005 /* Resources */,);
@@ -147,6 +153,8 @@
000000000000000000000013 /* App.swift in Sources */,
000000000000000000000014 /* MainView.swift in Sources */,
000000000000000000000015 /* DatabaseModel.swift in Sources */,
B73000000000000000000001 /* SchemaDiagramView.swift in Sources */,
B73200000000000000000001 /* SchemaDiagramPrintView.swift in Sources */,
000000000000000000000017 /* MySQLProtocol.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -185,7 +193,10 @@
ENABLE_APP_SANDBOX = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "DBSyn";
INFOPLIST_KEY_CFBundleIconFile = DBSyn;
MARKETING_VERSION = 1.0.0;
CURRENT_PROJECT_VERSION = 72;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 ABSDEV. All rights reserved.";
INFOPLIST_KEY_CFBundleIconFile = "DBSyn.icns";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.DBSyn;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -203,7 +214,10 @@
ENABLE_APP_SANDBOX = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "DBSyn";
INFOPLIST_KEY_CFBundleIconFile = DBSyn;
MARKETING_VERSION = 1.0.0;
CURRENT_PROJECT_VERSION = 72;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 ABSDEV. All rights reserved.";
INFOPLIST_KEY_CFBundleIconFile = "DBSyn.icns";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.DBSyn;
PRODUCT_NAME = "$(TARGET_NAME)";
+240 -2
View File
@@ -25,6 +25,8 @@ struct MainView: View {
@State private var resultSortAscending = true
@State private var hiddenResultColumns: Set<Int> = []
@State private var resultPage = 0
@State private var selectedResultRow: [String]? = nil
@State private var editResultRow: [String]? = nil
private let resultPageSize = 1_000
@State private var showingEditor = false
@State private var showingDatabaseTransfer = false
@@ -35,6 +37,7 @@ struct MainView: View {
@State private var showingQueryPlan = false
@State private var schemaChangesDatabase: String? = nil
@State private var schemaCompareDatabase: String? = nil
@State private var schemaDiagramDatabase: String? = nil
@State private var dataCompareDatabase: String? = nil
@State private var showingQueryHistory = false
@State private var showingSavedQueries = false
@@ -115,6 +118,14 @@ struct MainView: View {
DBSynSchemaCompareView(model: model, sourceDatabase: database)
}
}
.sheet(isPresented: Binding(
get: { schemaDiagramDatabase != nil },
set: { if !$0 { schemaDiagramDatabase = nil } }
)) {
if let database = schemaDiagramDatabase {
DBSynSchemaDiagramView(model: model, database: database)
}
}
.sheet(isPresented: $showingQueryPlan) {
WorkbenchExplainView(model: model, statement: selectedSQL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? model.sql : selectedSQL)
}
@@ -507,6 +518,7 @@ struct MainView: View {
if let snapshot = model.introspection(for: db) {
Text("Cached: \(snapshot.tables.count) tables, \(snapshot.views.count) views, \(snapshot.columnsByTable.values.reduce(0) { $0 + $1.count }) columns")
}
Button("Generate Schema Diagram…") { schemaDiagramDatabase = db }
Button("Review Schema Changes…") { schemaChangesDatabase = db }
Button("Compare Schema Structures…") { schemaCompareDatabase = db }
Button("Compare Table Data…") { dataCompareDatabase = db }
@@ -1059,6 +1071,16 @@ struct MainView: View {
Spacer()
Button {
editResultRow = selectedResultRow
} 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."
: selectedResultRow == nil ? "Select a result row first." : "Edit selected row and preview SQL")
if !model.result.columns.isEmpty {
TextField("Filter rows", text: $resultFilter)
.textFieldStyle(.roundedBorder)
@@ -1126,6 +1148,16 @@ struct MainView: View {
.background(WB.header)
}
}
.sheet(isPresented: Binding(
get: { editResultRow != nil },
set: { if !$0 { editResultRow = nil } }
)) {
if let row = editResultRow, let target = editableResultTarget {
DBSynInlineRowEditor(model: model, database: target.database,
table: target.table, columns: model.result.columns, original: row)
}
}
.onChange(of: model.result.rows) { _, _ in selectedResultRow = nil }
.onChange(of: resultFilter) { _, _ in resultPage = 0 }
.onChange(of: resultSortColumn) { _, _ in resultPage = 0 }
.onChange(of: resultSortAscending) { _, _ in resultPage = 0 }
@@ -1139,7 +1171,30 @@ struct MainView: View {
let rows = displayedRows
let start = min(resultPage * resultPageSize, rows.count)
let end = min(start + resultPageSize, rows.count)
return NativeResultGrid(columns: model.result.columns, rows: Array(rows[start..<end]), hiddenColumnIndices: hiddenResultColumns)
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 }
})
}
// 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)? {
let statements = model.sql.components(separatedBy: ";")
guard let select = statements.last(where: { $0.range(of: #"(?i)\bSELECT\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+LIMIT\s+\d+(?:\s*,\s*\d+|\s+OFFSET\s+\d+)?)?\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 }
let parts = String(select[range]).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[table],
fields.map(\.name).map({ $0.lowercased() }) == model.result.columns.map({ $0.lowercased() }),
fields.contains(where: { $0.key == "PRI" }) else { return nil }
return (database, table)
}
private var displayedRows: [[String]] {
@@ -1487,7 +1542,10 @@ struct SQLTextView: NSViewRepresentable {
func makeNSView(context: Context) -> NSScrollView {
let scroll = NSTextView.scrollableTextView()
let editor = scroll.documentView as! NSTextView
guard let editor = scroll.documentView as? NSTextView else {
assertionFailure("NSTextView.scrollableTextView() did not provide an NSTextView")
return scroll
}
let nativeAppearance = NSAppearance(named: colorScheme == .dark ? .darkAqua : .aqua)
scroll.appearance = nativeAppearance
@@ -2463,6 +2521,8 @@ struct NativeResultGrid: NSViewRepresentable {
let columns: [String]
let rows: [[String]]
var hiddenColumnIndices: Set<Int> = []
var onSelect: (([String]?) -> Void)? = nil
var onEdit: (([String]) -> Void)? = nil
@Environment(\.colorScheme) private var colorScheme
func makeCoordinator() -> Coordinator {
@@ -2500,6 +2560,15 @@ struct NativeResultGrid: NSViewRepresentable {
context.coordinator.columns = columns
context.coordinator.rows = rows
context.coordinator.onSelect = onSelect
context.coordinator.onEdit = onEdit
context.coordinator.tableView = table
let menu = NSMenu()
let editItem = NSMenuItem(title: "Edit Row…", action: #selector(Coordinator.editClickedRow(_:)), keyEquivalent: "")
editItem.target = context.coordinator
menu.addItem(editItem)
table.menu = menu
table.delegate = context.coordinator
table.dataSource = context.coordinator
@@ -2530,6 +2599,8 @@ struct NativeResultGrid: NSViewRepresentable {
context.coordinator.columns = columns
context.coordinator.rows = rows
context.coordinator.onSelect = onSelect
context.coordinator.onEdit = onEdit
if oldColumns != columns || Set(table.tableColumns.compactMap { column -> Int? in
let identifier = column.identifier.rawValue
@@ -2572,6 +2643,22 @@ struct NativeResultGrid: NSViewRepresentable {
var columns: [String] = []
var rows: [[String]] = []
var onSelect: (([String]?) -> Void)?
var onEdit: (([String]) -> Void)?
weak var tableView: NSTableView?
@objc func editClickedRow(_ sender: NSMenuItem) {
guard let table = tableView else { return }
let index = table.clickedRow >= 0 ? table.clickedRow : table.selectedRow
guard rows.indices.contains(index) else { return }
onEdit?(rows[index])
}
func tableViewSelectionDidChange(_ notification: Notification) {
guard let table = notification.object as? NSTableView else { return }
let index = table.selectedRow
onSelect?(index >= 0 && index < rows.count ? rows[index] : nil)
}
func numberOfRows(in tableView: NSTableView) -> Int {
rows.count
@@ -6864,3 +6951,154 @@ private struct DBSynDataCompareView: View {
}
}
}
// MARK: - Reviewed SQL row editing (Phase 72)
private struct DBSynInlineRowEditor: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: DatabaseModel
let database: String
let table: String
let columns: [String]
let original: [String]
@State private var values: [String]
@State private var executing = false
@State private var errorText: String?
init(model: DatabaseModel, database: String, table: String,
columns: [String], original: [String]) {
self.model = model
self.database = database
self.table = table
self.columns = columns
self.original = original
_values = State(initialValue: original)
}
private var metadata: [DatabaseModel.IntrospectedColumn] {
model.introspection(for: database)?.columnsByTable[table] ?? []
}
private var primaryIndices: [Int] {
columns.indices.filter { i in
metadata.contains { $0.name.caseInsensitiveCompare(columns[i]) == .orderedSame && $0.key == "PRI" }
}
}
private var changedIndices: [Int] {
columns.indices.filter { $0 < values.count && $0 < original.count && values[$0] != original[$0] }
}
private func identifier(_ value: String) -> String {
"`" + value.replacingOccurrences(of: "`", with: "``") + "`"
}
private func literal(_ value: String) -> String {
// MySQL backslash escapes are SQL-mode-dependent. Hexadecimal UTF-8
// literals avoid SQL injection and preserve quote/backslash characters.
"CONVERT(X'" + Data(value.utf8).map { String(format: "%02X", $0) }.joined() + "' USING utf8mb4)"
}
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." }
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." }
// The native wire protocol currently renders SQL NULL as the text
// "NULL". Never generate SQL when this representation is ambiguous.
let checked = Set(primaryIndices + changedIndices)
for i in checked {
if metadata[i].nullable && original[i] == "NULL" {
return "Column \(columns[i]) may contain SQL NULL. Editing ambiguous NULL values is disabled."
}
}
return nil
}
private var generatedSQL: String {
guard validationError == nil else { return "" }
let assignments = changedIndices.map { i in
"\(identifier(columns[i])) = \(literal(values[i]))"
}.joined(separator: ",\n ")
// Optimistic checks prevent overwriting a value modified since the
// result grid was loaded. LIMIT 1 prevents accidental mass updates.
let checks = (primaryIndices + changedIndices).map { i in
"\(identifier(columns[i])) <=> \(literal(original[i]))"
}.joined(separator: " AND\n ")
return "UPDATE \(identifier(database)).\(identifier(table))\nSET \(assignments)\nWHERE \(checks)\nLIMIT 1;"
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Edit Row — \(database).\(table)").font(.title3.bold())
Text("Changes are staged locally. Review the SQL before applying them to MySQL.")
.foregroundStyle(.secondary)
ScrollView {
VStack(alignment: .leading, spacing: 8) {
ForEach(columns.indices, id: \.self) { i in
HStack {
Text(columns[i]).frame(width: 190, alignment: .leading)
TextField(columns[i], text: $values[i])
.textFieldStyle(.roundedBorder)
.disabled(primaryIndices.contains(i) || executing)
if primaryIndices.contains(i) {
Text("PRIMARY KEY").font(.caption).foregroundStyle(.secondary)
}
}
}
}
}
.frame(maxHeight: 290)
Text("SQL Preview").font(.headline)
ScrollView {
Text(validationError ?? generatedSQL)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
}
.frame(height: 140)
.background(Color(nsColor: .textBackgroundColor))
if let errorText {
Text(errorText).foregroundStyle(.red).textSelection(.enabled)
}
HStack {
Button("Copy SQL") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(generatedSQL, forType: .string)
}
.disabled(validationError != nil)
Spacer()
Button("Cancel") { dismiss() }.disabled(executing)
Button(executing ? "Applying…" : "Apply SQL Changes") { apply() }
.buttonStyle(.borderedProminent)
.disabled(validationError != nil || executing)
}
}
.padding(20)
.frame(width: 780, height: 620)
}
private func apply() {
guard validationError == nil, let profile = model.selected else { return }
let statement = generatedSQL
let password = PasswordStore.read(profile.id)
executing = true
errorText = nil
Task {
do {
let response = try await NativeMySQLRunner.run(
profile: profile, database: database, password: password, sql: statement)
// A successful UPDATE may affect zero rows when a concurrent
// change invalidates the optimistic predicate; do not claim a save.
model.status = "UPDATE completed: \(response.message). Refresh results to verify the row."
dismiss()
model.runSQL()
} catch {
errorText = error.localizedDescription
}
executing = false
}
}
}
+135
View File
@@ -0,0 +1,135 @@
import AppKit
/// Resolution-independent diagram renderer shared by PDF, PNG, SVG and macOS printing.
/// The exported palette is intentionally independent of the user's macOS appearance.
final class SchemaDiagramPrintView: NSView {
struct Column { let name: String; let type: String; let primary: Bool }
struct Entry { let name: String; let isView: Bool; let columns: [Column]; let origin: CGPoint }
struct Link { let from: String; let to: String; let column: String; let target: String }
let database: String
let diagram: String
let entries: [Entry]
let links: [Link]
private let cardWidth: CGFloat = 260
private let headerHeight: CGFloat = 34
private let rowHeight: CGFloat = 19
private let margin: CGFloat = 55
private let titleHeight: CGFloat = 76
private let navy = NSColor(calibratedRed: 0.12, green: 0.20, blue: 0.34, alpha: 1)
private let blue = NSColor(calibratedRed: 0.14, green: 0.38, blue: 0.64, alpha: 1)
private let violet = NSColor(calibratedRed: 0.34, green: 0.30, blue: 0.56, alpha: 1)
private let ink = NSColor(calibratedRed: 0.16, green: 0.23, blue: 0.32, alpha: 1)
private let muted = NSColor(calibratedRed: 0.43, green: 0.49, blue: 0.57, alpha: 1)
private let border = NSColor(calibratedRed: 0.79, green: 0.83, blue: 0.88, alpha: 1)
init(database: String, diagram: String, entries: [Entry], links: [Link]) {
self.database = database
self.diagram = diagram
self.entries = entries
self.links = links
let width = max(900, (entries.map { $0.origin.x + 260 }.max() ?? 750) + 110)
let height = max(650, (entries.map { $0.origin.y + CGFloat($0.columns.count) * 19 + 46 }.max() ?? 500) + 130)
super.init(frame: CGRect(x: 0, y: 0, width: width, height: height))
}
required init?(coder: NSCoder) { nil }
override var isFlipped: Bool { true }
private func cardHeight(_ entry: Entry) -> CGFloat { headerHeight + CGFloat(entry.columns.count) * rowHeight + 12 }
private func cardRect(_ entry: Entry) -> CGRect {
CGRect(x: entry.origin.x + margin, y: entry.origin.y + titleHeight,
width: cardWidth, height: cardHeight(entry))
}
private func text(_ string: String, at point: CGPoint, font: NSFont, color: NSColor) {
(string as NSString).draw(at: point, withAttributes: [.font: font, .foregroundColor: color])
}
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
NSColor.white.setFill()
NSBezierPath(rect: bounds).fill()
navy.setFill()
NSBezierPath(rect: CGRect(x: 0, y: 0, width: bounds.width, height: 8)).fill()
text(database, at: CGPoint(x: margin, y: 25), font: .systemFont(ofSize: 22, weight: .bold), color: navy)
text("SCHEMA DIAGRAM / \(diagram)", at: CGPoint(x: margin, y: 54), font: .systemFont(ofSize: 10, weight: .medium), color: muted)
text("\(entries.count) objects • \(links.count) relationships", at: CGPoint(x: margin, y: bounds.height - 27), font: .systemFont(ofSize: 10), color: muted)
let byName = Dictionary(uniqueKeysWithValues: entries.map { ($0.name, $0) })
for link in links {
guard let source = byName[link.from], let target = byName[link.to] else { continue }
let a = cardRect(source), b = cardRect(target)
let toRight = a.midX <= b.midX
let start = CGPoint(x: toRight ? a.maxX : a.minX, y: a.midY)
let end = CGPoint(x: toRight ? b.minX : b.maxX, y: b.midY)
let curve = NSBezierPath()
curve.move(to: start)
curve.curve(to: end,
controlPoint1: CGPoint(x: start.x + (toRight ? 55 : -55), y: start.y),
controlPoint2: CGPoint(x: end.x + (toRight ? -55 : 55), y: end.y))
NSColor(calibratedRed: 0.39, green: 0.53, blue: 0.69, alpha: 1).setStroke()
curve.lineWidth = 1.7
curve.stroke()
}
for entry in entries {
let rect = cardRect(entry)
NSColor.white.setFill()
NSBezierPath(roundedRect: rect, xRadius: 7, yRadius: 7).fill()
border.setStroke()
NSBezierPath(roundedRect: rect, xRadius: 7, yRadius: 7).stroke()
let header = CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: headerHeight)
(entry.isView ? violet : blue).setFill()
NSBezierPath(rect: header).fill()
text(entry.name, at: CGPoint(x: rect.minX + 12, y: rect.minY + 9),
font: .systemFont(ofSize: 12, weight: .semibold), color: .white)
for (i, column) in entry.columns.enumerated() {
let y = rect.minY + headerHeight + CGFloat(i) * rowHeight
if i.isMultiple(of: 2) {
NSColor(calibratedRed: 0.96, green: 0.97, blue: 0.99, alpha: 1).setFill()
NSBezierPath(rect: CGRect(x: rect.minX + 1, y: y, width: rect.width - 2, height: rowHeight)).fill()
}
let label = (column.primary ? "◆ " : " ") + column.name
text(String(label.prefix(29)), at: CGPoint(x: rect.minX + 9, y: y + 3),
font: .systemFont(ofSize: 10, weight: column.primary ? .semibold : .regular), color: ink)
let type = String(column.type.prefix(22)) as NSString
let attrs: [NSAttributedString.Key: Any] = [.font: NSFont.systemFont(ofSize: 9), .foregroundColor: muted]
let typeWidth = type.size(withAttributes: attrs).width
type.draw(at: CGPoint(x: rect.maxX - typeWidth - 10, y: y + 4), withAttributes: attrs)
}
}
}
private func escape(_ text: String) -> String {
text.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
}
func svgDocument() -> String {
var svg = """
<svg xmlns="http://www.w3.org/2000/svg" width="\(Int(bounds.width))" height="\(Int(bounds.height))" viewBox="0 0 \(Int(bounds.width)) \(Int(bounds.height))">
<rect width="100%" height="100%" fill="#ffffff"/>
<rect width="100%" height="8" fill="#1f3457"/>
<text x="55" y="44" font-family="-apple-system,Arial,sans-serif" font-size="22" font-weight="700" fill="#1f3457">\(escape(database))</text>
<text x="55" y="66" font-family="Arial,sans-serif" font-size="10" fill="#6e7d91">SCHEMA DIAGRAM / \(escape(diagram))</text>
"""
let byName = Dictionary(uniqueKeysWithValues: entries.map { ($0.name, $0) })
for link in links {
guard let source = byName[link.from], let target = byName[link.to] else { continue }
let a = cardRect(source), b = cardRect(target), right = a.midX <= b.midX
let sx = right ? a.maxX : a.minX, ex = right ? b.minX : b.maxX
let delta: CGFloat = right ? 55 : -55
svg += "<path d=\"M \(sx) \(a.midY) C \(sx + delta) \(a.midY), \(ex - delta) \(b.midY), \(ex) \(b.midY)\" fill=\"none\" stroke=\"#6488af\" stroke-width=\"1.7\"/>\n"
}
for entry in entries {
let r = cardRect(entry)
svg += "<rect x=\"\(r.minX)\" y=\"\(r.minY)\" width=\"\(r.width)\" height=\"\(r.height)\" rx=\"7\" fill=\"white\" stroke=\"#cbd4e0\"/>\n"
svg += "<rect x=\"\(r.minX)\" y=\"\(r.minY)\" width=\"\(r.width)\" height=\"34\" fill=\"\(entry.isView ? "#574c8f" : "#2461a3")\"/>\n"
svg += "<text x=\"\(r.minX + 12)\" y=\"\(r.minY + 23)\" font-family=\"Arial,sans-serif\" font-size=\"12\" font-weight=\"600\" fill=\"white\">\(escape(entry.name))</text>\n"
for (index, column) in entry.columns.enumerated() {
let y = r.minY + 34 + CGFloat(index) * 19
if index.isMultiple(of: 2) { svg += "<rect x=\"\(r.minX + 1)\" y=\"\(y)\" width=\"\(r.width - 2)\" height=\"19\" fill=\"#f5f8fc\"/>\n" }
svg += "<text x=\"\(r.minX + 9)\" y=\"\(y + 13)\" font-family=\"Arial,sans-serif\" font-size=\"10\" fill=\"#293b52\">\(escape((column.primary ? "◆ " : " ") + column.name))</text>\n"
svg += "<text x=\"\(r.maxX - 9)\" y=\"\(y + 13)\" text-anchor=\"end\" font-family=\"Arial,sans-serif\" font-size=\"9\" fill=\"#6e7d91\">\(escape(column.type))</text>\n"
}
}
svg += "<text x=\"55\" y=\"\(bounds.height - 20)\" font-family=\"Arial,sans-serif\" font-size=\"10\" fill=\"#6e7d91\">\(entries.count) objects / \(links.count) relationships</text>\n</svg>"
return svg
}
}
+354
View File
@@ -0,0 +1,354 @@
import SwiftUI
import AppKit
private struct DiagramRelation: Identifiable {
var id: String { "\(from).\(column).\(to).\(targetColumn)" }
let from: String
let column: String
let to: String
let targetColumn: String
}
private struct DiagramPoint: Codable {
var x: Double
var y: Double
}
@MainActor
struct DBSynSchemaDiagramView: View {
@ObservedObject var model: DatabaseModel
let database: String
@State private var positions: [String: CGPoint] = [:]
@State private var relations: [DiagramRelation] = []
@State private var zoom: CGFloat = 1
@State private var search = ""
@State private var selectedObject: String?
@State private var loadingRelationships = false
@State private var errorMessage: String?
@State private var showViews = true
@State private var relatedOnly = false
@State private var diagramName = "Main"
@State private var diagrams: [String] = ["Main"]
@State private var newDiagramName = ""
@State private var showingNewDiagram = false
@State private var showingDeleteConfirmation = false
@State private var exportError: String?
@State private var showingExportError = false
private let nodeWidth: CGFloat = 245
private let headerHeight: CGFloat = 36
private let rowHeight: CGFloat = 21
private let maxColumns = 10
private var snapshot: DatabaseModel.SchemaIntrospection? { model.introspection(for: database) }
private var objects: [String] {
guard let snapshot else { return [] }
let all = snapshot.tables + (showViews ? snapshot.views : [])
return all.filter { name in
(search.isEmpty || name.localizedCaseInsensitiveContains(search)) &&
(!relatedOnly || selectedObject == nil || name == selectedObject || relations.contains { ($0.from == selectedObject && $0.to == name) || ($0.to == selectedObject && $0.from == name) })
}
}
private var diagramNamespace: String {
"DBSyn.diagram.v2.\(model.selected?.id.uuidString ?? "unknown").\(database)"
}
private var storageKey: String { "\(diagramNamespace).layout.\(diagramName)" }
private var catalogKey: String { "\(diagramNamespace).catalog" }
private func columns(_ name: String) -> [DatabaseModel.IntrospectedColumn] {
snapshot?.columnsByTable[name] ?? []
}
private func height(_ name: String) -> CGFloat {
headerHeight + CGFloat(min(columns(name).count, maxColumns)) * rowHeight + 12
}
private func position(_ name: String) -> CGPoint {
positions[name] ?? CGPoint(x: 30, y: 30)
}
private func center(_ name: String, right: Bool) -> CGPoint {
let p = position(name)
return CGPoint(x: p.x + (right ? nodeWidth : 0), y: p.y + min(height(name), 260) / 2)
}
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 12) {
Label("Schema Diagram — \(database)", systemImage: "point.3.connected.trianglepath.dotted")
.font(.headline)
Picker("Diagram", selection: $diagramName) {
ForEach(diagrams, id: \.self) { Text($0).tag($0) }
}
.frame(width: 175)
.onChange(of: diagramName) { oldValue, _ in
savePositions(named: oldValue)
positions = [:]
restorePositions()
if positions.isEmpty { arrange() }
}
Button { showingNewDiagram = true } label: { Image(systemName: "plus") }
.help("New diagram")
Button { showingDeleteConfirmation = true } label: { Image(systemName: "trash") }
.disabled(diagrams.count <= 1)
.help("Delete current diagram")
Spacer()
TextField("Find table or view", text: $search)
.textFieldStyle(.roundedBorder)
.frame(width: 200)
Toggle("Views", isOn: $showViews).toggleStyle(.checkbox)
Toggle("Related", isOn: $relatedOnly).toggleStyle(.checkbox)
Button("Auto Layout") { arrange(); savePositions() }
Button { zoom = max(0.4, zoom - 0.15) } label: { Image(systemName: "minus.magnifyingglass") }
Text("\(Int(zoom * 100))%").monospacedDigit().frame(width: 46)
Button { zoom = min(2, zoom + 0.15) } label: { Image(systemName: "plus.magnifyingglass") }
Menu {
Button("Export PDF…") { exportDiagram(.pdf) }
Button("Export PNG…") { exportDiagram(.png) }
Button("Export SVG…") { exportDiagram(.svg) }
Divider()
Button("Print…") { printDiagram() }
} label: { Label("Export / Print", systemImage: "square.and.arrow.up") }
Button("Refresh") { model.introspect(database: database); loadRelationships() }
}
.padding(12)
Divider()
if snapshot == nil {
ContentUnavailableView("No Introspection Data", systemImage: "externaldrive.badge.questionmark", description: Text("Run Introspection on this database to load tables and views."))
Button("Introspect Now") { model.introspect(database: database); loadRelationships() }.padding()
} else {
ScrollView([.horizontal, .vertical]) {
ZStack(alignment: .topLeading) {
Canvas { context, _ in
for relation in relations where objects.contains(relation.from) && objects.contains(relation.to) {
let a = position(relation.from)
let b = position(relation.to)
let right = a.x <= b.x
let start = center(relation.from, right: right)
let end = center(relation.to, right: !right)
let bend: CGFloat = right ? 65 : -65
var path = Path()
path.move(to: start)
path.addCurve(to: end,
control1: CGPoint(x: start.x + bend, y: start.y),
control2: CGPoint(x: end.x - bend, y: end.y))
context.stroke(path, with: .color(.accentColor.opacity(0.7)), lineWidth: 1.7)
}
}
ForEach(objects, id: \.self) { name in
diagramNode(name)
.position(x: position(name).x + nodeWidth / 2,
y: position(name).y + height(name) / 2)
}
}
.frame(width: canvasWidth, height: canvasHeight, alignment: .topLeading)
.scaleEffect(zoom, anchor: .topLeading)
.frame(width: canvasWidth * zoom, height: canvasHeight * zoom, alignment: .topLeading)
}
.background(Color(nsColor: .textBackgroundColor))
.onChange(of: snapshot?.loadedAt) { _, _ in
if positions.isEmpty { arrange() }
loadRelationships()
}
}
Divider()
HStack {
Text("\(snapshot?.tables.count ?? 0) tables · \(snapshot?.views.count ?? 0) views · \(relations.count) foreign-key relationships")
if loadingRelationships { ProgressView().controlSize(.small) }
if let errorMessage { Text(errorMessage).foregroundStyle(.secondary).lineLimit(1) }
Spacer()
Text("Drag tables to arrange · Select to highlight relationships")
}
.font(.caption)
.padding(9)
}
.frame(minWidth: 950, minHeight: 650)
.onAppear { loadCatalog(); restorePositions(); if positions.isEmpty { arrange() }; loadRelationships() }
.onDisappear { savePositions() }
.alert("New Schema Diagram", isPresented: $showingNewDiagram) {
TextField("Diagram name", text: $newDiagramName)
Button("Create") { createDiagram() }
Button("Cancel", role: .cancel) { }
} message: { Text("Create a separate saved layout for this database.") }
.confirmationDialog("Delete \(diagramName)?", isPresented: $showingDeleteConfirmation) {
Button("Delete Diagram", role: .destructive) { deleteDiagram() }
} message: { Text("Only the saved diagram layout is removed. Database objects are not changed.") }
.alert("Diagram Export", isPresented: $showingExportError) {
Button("OK", role: .cancel) { }
} message: { Text(exportError ?? "Unable to export diagram") }
}
private var canvasWidth: CGFloat {
max(1200, objects.map { position($0).x + nodeWidth + 80 }.max() ?? 1200)
}
private var canvasHeight: CGFloat {
max(800, objects.map { position($0).y + height($0) + 80 }.max() ?? 800)
}
private func diagramNode(_ name: String) -> some View {
let isView = snapshot?.views.contains(name) == true
let related = selectedObject == nil || selectedObject == name || relations.contains { ($0.from == selectedObject && $0.to == name) || ($0.to == selectedObject && $0.from == name) }
return VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 7) {
Image(systemName: isView ? "eye" : "tablecells")
Text(name).fontWeight(.semibold).lineLimit(1)
Spacer()
if isView { Text("VIEW").font(.system(size: 9, weight: .bold)) }
}
.font(.system(size: 12))
.padding(.horizontal, 10)
.frame(height: headerHeight)
.foregroundStyle(.white)
.background(isView ? Color.indigo : Color.accentColor)
ForEach(Array(columns(name).prefix(maxColumns)), id: \.id) { column in
HStack(spacing: 5) {
Image(systemName: column.key == "PRI" ? "key.fill" : "circle.fill")
.font(.system(size: column.key == "PRI" ? 10 : 4))
.foregroundStyle(column.key == "PRI" ? Color.orange : Color.secondary)
.frame(width: 13)
Text(column.name).lineLimit(1)
Spacer(minLength: 4)
Text(column.dataType).foregroundStyle(.secondary).lineLimit(1)
}
.font(.system(size: 10))
.padding(.horizontal, 9)
.frame(height: rowHeight)
}
if columns(name).count > maxColumns {
Text("+ \(columns(name).count - maxColumns) more columns")
.font(.caption2).foregroundStyle(.secondary).padding(.horizontal, 10)
}
Spacer(minLength: 5)
}
.frame(width: nodeWidth, height: height(name))
.background(Color(nsColor: .controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 7))
.overlay(RoundedRectangle(cornerRadius: 7).stroke(selectedObject == name ? Color.accentColor : Color(nsColor: .separatorColor), lineWidth: selectedObject == name ? 2.5 : 1))
.shadow(color: .black.opacity(0.13), radius: 3, y: 2)
.opacity(related ? 1 : 0.4)
.onTapGesture { selectedObject = selectedObject == name ? nil : name }
.gesture(DragGesture(minimumDistance: 4)
.onChanged { value in
let current = position(name)
// DragGesture.translation is relative to the gesture start.
if dragOrigins[name] == nil { dragOrigins[name] = current }
let start = dragOrigins[name] ?? current
positions[name] = CGPoint(x: max(10, start.x + value.translation.width / zoom),
y: max(10, start.y + value.translation.height / zoom))
}
.onEnded { _ in dragOrigins.removeValue(forKey: name); savePositions() })
}
@State private var dragOrigins: [String: CGPoint] = [:]
private func arrange() {
let names = (snapshot?.tables ?? []) + (snapshot?.views ?? [])
let countPerRow = max(1, Int(ceil(sqrt(Double(max(1, names.count))))))
for (index, name) in names.enumerated() {
positions[name] = CGPoint(x: CGFloat(index % countPerRow) * 290 + 30,
y: CGFloat(index / countPerRow) * 300 + 30)
}
}
private func loadCatalog() {
let stored = UserDefaults.standard.stringArray(forKey: catalogKey) ?? ["Main"]
diagrams = stored.isEmpty ? ["Main"] : stored
if !diagrams.contains(diagramName) { diagramName = diagrams[0] }
}
private func createDiagram() {
let name = newDiagramName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, !diagrams.contains(name) else { return }
savePositions()
diagrams.append(name)
UserDefaults.standard.set(diagrams, forKey: catalogKey)
diagramName = name
newDiagramName = ""
}
private func deleteDiagram() {
guard diagrams.count > 1 else { return }
UserDefaults.standard.removeObject(forKey: storageKey)
diagrams.removeAll { $0 == diagramName }
UserDefaults.standard.set(diagrams, forKey: catalogKey)
diagramName = diagrams[0]
}
private func savePositions(named name: String) {
let points = positions.mapValues { DiagramPoint(x: Double($0.x), y: Double($0.y)) }
if let data = try? JSONEncoder().encode(points) {
UserDefaults.standard.set(data, forKey: "\(diagramNamespace).layout.\(name)")
}
}
private func savePositions() { savePositions(named: diagramName) }
private func restorePositions() {
guard let data = UserDefaults.standard.data(forKey: storageKey),
let saved = try? JSONDecoder().decode([String: DiagramPoint].self, from: data) else { return }
positions = saved.mapValues { CGPoint(x: $0.x, y: $0.y) }
}
private func loadRelationships() {
guard let profile = model.selected else { return }
loadingRelationships = true
errorMessage = nil
let password = PasswordStore.read(profile.id)
let escaped = database.replacingOccurrences(of: "'", with: "''")
Task {
do {
let result = try await NativeMySQLRunner.run(profile: profile, database: database, password: password,
sql: "SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = '\(escaped)' AND REFERENCED_TABLE_NAME IS NOT NULL ORDER BY TABLE_NAME, ORDINAL_POSITION")
guard model.selected?.id == profile.id else { loadingRelationships = false; return }
relations = result.rows.compactMap { row in
guard row.count >= 4 else { return nil }
return DiagramRelation(from: row[0], column: row[1], to: row[2], targetColumn: row[3])
}
} catch {
errorMessage = "Foreign keys unavailable: \(error.localizedDescription)"
}
loadingRelationships = false
}
}
private enum DiagramFormat: Equatable { case pdf, png, svg }
private func printLayout() -> SchemaDiagramPrintView {
let entries = objects.map { name in
SchemaDiagramPrintView.Entry(name: name,
isView: snapshot?.views.contains(name) == true,
columns: columns(name).map { .init(name: $0.name, type: $0.dataType, primary: $0.key == "PRI") },
origin: position(name))
}
let links = relations.filter { objects.contains($0.from) && objects.contains($0.to) }
.map { SchemaDiagramPrintView.Link(from: $0.from, to: $0.to, column: $0.column, target: $0.targetColumn) }
return SchemaDiagramPrintView(database: database, diagram: diagramName, entries: entries, links: links)
}
private func exportDiagram(_ format: DiagramFormat) {
let panel = NSSavePanel()
panel.title = "Export Schema Diagram"
panel.nameFieldStringValue = "\(database)-\(diagramName).\(format == .pdf ? "pdf" : format == .png ? "png" : "svg")"
panel.canCreateDirectories = true
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
let view = printLayout()
let data: Data
switch format {
case .pdf: data = view.dataWithPDF(inside: view.bounds)
case .png:
guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else {
throw NSError(domain: "DBSynDiagram", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unable to create bitmap"])
}
view.cacheDisplay(in: view.bounds, to: bitmap)
guard let png = bitmap.representation(using: .png, properties: [:]) else {
throw NSError(domain: "DBSynDiagram", code: 2, userInfo: [NSLocalizedDescriptionKey: "Unable to encode PNG"])
}
data = png
case .svg: data = Data(view.svgDocument().utf8)
}
try data.write(to: url, options: .atomic)
} catch {
exportError = error.localizedDescription
showingExportError = true
}
}
private func printDiagram() {
let view = printLayout()
let info = NSPrintInfo.shared.copy() as! NSPrintInfo
info.horizontalPagination = .fit
info.verticalPagination = .fit
info.isHorizontallyCentered = true
info.isVerticallyCentered = true
info.orientation = view.bounds.width > view.bounds.height ? .landscape : .portrait
NSPrintOperation(view: view, printInfo: info).run()
}
}
+41
View File
@@ -0,0 +1,41 @@
# DBSyn — Phase 71: Release readiness
## Scope
This is a **release candidate preparation**, not a certified production release.
The Xcode project now declares a bundle version (1.0.0 / build 71), sets the
Finder app icon file explicitly, and removes a forced NSTextView cast in the
SQL editor initialization path. The previous Phase 70 features remain in place.
## Required verification on a Mac
- [ ] Clean Debug and Release builds in Xcode with no compiler errors.
- [ ] Verify the About panel shows 1.0.0 and Finder displays the DBSyn icon.
- [ ] Test light, dark, and system appearance across all settings and editors.
- [ ] Connect to local and remote MySQL with supported authentication methods.
- [ ] Test `USE AAMEDIUK; SELECT * FROM ADCUSERS;` as sequential statements.
- [ ] Introspect tables and views; verify metadata survives a relaunch.
- [ ] Create, alter, and drop a test table; verify cache refresh detects changes.
- [ ] Test type-ahead table/column suggestions and keyboard selection repeatedly.
- [ ] Test query history, saved queries, EXPLAIN, and query diagnostics.
- [ ] Verify read-only schema/data comparisons and generated migration previews.
- [ ] Verify CSV import/export, backup history, checksum validation, and restore confirmation.
- [ ] Test slow or interrupted connections and queries with large result sets.
- [ ] Verify accessibility, keyboard navigation, window resizing, and scrolling.
- [ ] Verify no passwords are stored in plaintext outside Keychain.
- [ ] Archive with Developer ID signing and hardened runtime.
- [ ] Notarize and staple the exported app; assess with Gatekeeper.
- [ ] Create the DMG, notarize/staple it, verify both app and disk image icons.
- [ ] Install on a clean Mac and confirm first launch succeeds.
## Release blockers
A successful Swift syntax parse or ZIP check **does not** establish a successful
macOS build, functional MySQL interoperability, or a notarized distribution.
Do not call the release production-ready until the checks above pass.
## Distribution
This package contains the source project and documentation only. No new build,
run, or release scripts are included. The existing direct-distribution process
should be verified separately on macOS.
+19
View File
@@ -0,0 +1,19 @@
# Phase 72 — Reviewed row editing
Select a row in the Result Grid and choose **Edit Selected Row…**. Edit values in the sheet, inspect the generated `UPDATE`, then select **Apply SQL Changes**. The sheet also supports copying the SQL without executing it.
## Scope and safeguards
- Enabled only for a simple `SELECT * FROM <table> [LIMIT …]` result with matching introspection metadata for a **base table** and a primary key. Joins, arbitrary projections and views are read-only.
- Primary-key columns are read-only. A primary key is used in the `WHERE` predicate; changed columns' original values are also checked to detect concurrent changes.
- Uses `UPDATE … LIMIT 1`; the SQL is shown before execution and never applied automatically.
- MySQL values are encoded as hexadecimal UTF-8 expressions to avoid quote escaping and SQL injection from entered text.
- Nullable fields displaying `NULL` are blocked when they are part of the update or predicate because the existing MySQL protocol collapses SQL NULL into the string `NULL`.
- The initial implementation edits values as text. Explicit NULL assignment, binary/BLOB editing, INSERT, DELETE and arbitrary joined-result editing are not supported.
- Refreshes the original query after successful execution; verify affected-row counts and the refreshed result. An UPDATE can affect zero rows if a concurrent change occurred.
## Verification on macOS
Build in Xcode and test a base table with a primary key, including quotes and backslashes in edited text, multiple rows, changed data since SELECT, read-only views, and a table without a primary key. Verify System/Light/Dark appearance.
No build or release scripts are included.
+11
View File
@@ -0,0 +1,11 @@
# Phase 73.1 — Visual Schema Diagrams
Open the Schemas navigator, right-click a database, and select **Generate Schema Diagram…**.
The diagram uses cached introspection for tables, views and columns. Foreign-key edges are loaded read-only from `information_schema.KEY_COLUMN_USAGE`. Drag tables to position them, use zoom buttons, search, and toggle views. Layouts persist per connection and database in local preferences.
Run Introspection first if the schema cache is empty. Refresh reloads introspection and relationships. No schema-changing SQL is executed.
Limitations: automatic grid layout rather than graph optimisation; first 10 columns are shown per node; diagram export and multi-diagram management are deferred to Phase 73.2/73.3.
Validation: Swift syntax parsing and ZIP integrity only. Full Xcode build and runtime test are required on macOS.
@@ -0,0 +1,20 @@
# DBSyn Phase 73.2 + 73.3 — Diagram management, printing and export
## Included
- Multiple named diagram layouts for each connection and database, persisted using UserDefaults.
- Diagram picker, create and delete actions. Deleting a layout does not alter database objects.
- Professional, print-oriented rendering on a white background with navy headings, blue table cards, violet view cards, alternating row stripes, and subdued relationship lines.
- PDF export using AppKit's vector PDF generation.
- PNG export at diagram dimensions.
- SVG export as scalable editable XML artwork.
- Native macOS Print dialog, with automatic fit-to-page pagination settings.
- Full column lists in exports (screen preview still limits to 10 columns per node).
## Use
Open **Generate Schema Diagram…** from a database context menu. Choose or create a named diagram with the Diagram picker and plus button. Arrange tables, then choose **Export / Print** > **Export PDF…**, **Export PNG…**, **Export SVG…**, or **Print…**.
## Notes
Diagram exports are read-only and do not modify MySQL schema or data. PDF and SVG are vector-based. Relationship links are generated from MySQL foreign keys, when available. Printing is through the system print dialog. The application must be compiled and exercised on macOS for final validation.