diff --git a/DBSyn.xcodeproj/project.pbxproj b/DBSyn.xcodeproj/project.pbxproj index f02b8ca..6df8c05 100644 --- a/DBSyn.xcodeproj/project.pbxproj +++ b/DBSyn.xcodeproj/project.pbxproj @@ -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 = ""; }; + B73000000000000000000002 /* SchemaDiagramView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaDiagramView.swift; sourceTree = ""; }; 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 = ""; }; 000000000000000000000011 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; @@ -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)"; diff --git a/DBSyn/MainView.swift b/DBSyn/MainView.swift index 8d59d1b..2d35778 100644 --- a/DBSyn/MainView.swift +++ b/DBSyn/MainView.swift @@ -25,6 +25,8 @@ struct MainView: View { @State private var resultSortAscending = true @State private var hiddenResultColumns: Set = [] @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.. 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 = [] + 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 + } + } +} diff --git a/DBSyn/SchemaDiagramPrintView.swift b/DBSyn/SchemaDiagramPrintView.swift new file mode 100644 index 0000000..966bf6b --- /dev/null +++ b/DBSyn/SchemaDiagramPrintView.swift @@ -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: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + } + func svgDocument() -> String { + var svg = """ + + + + \(escape(database)) + SCHEMA DIAGRAM / \(escape(diagram)) + """ + 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 += "\n" + } + for entry in entries { + let r = cardRect(entry) + svg += "\n" + svg += "\n" + svg += "\(escape(entry.name))\n" + for (index, column) in entry.columns.enumerated() { + let y = r.minY + 34 + CGFloat(index) * 19 + if index.isMultiple(of: 2) { svg += "\n" } + svg += "\(escape((column.primary ? "◆ " : " ") + column.name))\n" + svg += "\(escape(column.type))\n" + } + } + svg += "\(entries.count) objects / \(links.count) relationships\n" + return svg + } +} diff --git a/DBSyn/SchemaDiagramView.swift b/DBSyn/SchemaDiagramView.swift new file mode 100644 index 0000000..e1805f8 --- /dev/null +++ b/DBSyn/SchemaDiagramView.swift @@ -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() + } + +} diff --git a/docs/PHASE71-RELEASE-READINESS.md b/docs/PHASE71-RELEASE-READINESS.md new file mode 100644 index 0000000..2db1a73 --- /dev/null +++ b/docs/PHASE71-RELEASE-READINESS.md @@ -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. diff --git a/docs/PHASE72-REVIEWED-ROW-EDITING.md b/docs/PHASE72-REVIEWED-ROW-EDITING.md new file mode 100644 index 0000000..f4da5ad --- /dev/null +++ b/docs/PHASE72-REVIEWED-ROW-EDITING.md @@ -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 [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. diff --git a/docs/PHASE73-1-SCHEMA-DIAGRAMS.md b/docs/PHASE73-1-SCHEMA-DIAGRAMS.md new file mode 100644 index 0000000..9785b72 --- /dev/null +++ b/docs/PHASE73-1-SCHEMA-DIAGRAMS.md @@ -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. diff --git a/docs/PHASE73-2-73-3-DIAGRAM-MANAGEMENT-EXPORT.md b/docs/PHASE73-2-73-3-DIAGRAM-MANAGEMENT-EXPORT.md new file mode 100644 index 0000000..825ebaa --- /dev/null +++ b/docs/PHASE73-2-73-3-DIAGRAM-MANAGEMENT-EXPORT.md @@ -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.