added typeaheead and introspetion
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = """
|
||||
<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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user