Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions apple/TablePro/Models/ERDiagram/ERDiagramDOTWriter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import Foundation

enum ERDiagramDOTWriter {
private static let secondaryColor = "#6E6E73"
private static let headerColor = "#EFEFF4"

static func document(for graph: ERDiagramGraph) -> String {
let nodes = ERDiagramExportOrder.nodes(of: graph)
var ports: [String: [String: String]] = [:]
for node in nodes {
let all = portIds(for: node.columns.map(\.name))
ports[node.tableName] = all.filter { name, _ in node.displayColumns.contains { $0.name == name } }
}

var lines = [
"digraph \"ER Diagram\" {",
" graph [rankdir=LR, nodesep=0.5, ranksep=1.2];",
" node [shape=plain, fontname=\"Helvetica\", fontsize=11];",
" edge [dir=both, color=\"\(secondaryColor)\", arrowsize=0.8];",
]

for node in nodes {
lines.append("")
lines.append(" \(quoted(node.tableName)) [label=<")
lines.append(" <TABLE BORDER=\"1\" CELLBORDER=\"0\" CELLSPACING=\"0\" CELLPADDING=\"4\">")
lines.append(
" <TR><TD ALIGN=\"LEFT\" BGCOLOR=\"\(headerColor)\"><B>\(html(node.tableName))</B></TD></TR>"
)
for column in node.displayColumns {
lines.append(" <TR>\(cell(for: column, port: ports[node.tableName]?[column.name]))</TR>")
}
lines.append(" </TABLE>")
lines.append(" >];")
}

let edges = ERDiagramExportOrder.edges(of: graph)
if !edges.isEmpty {
lines.append("")
}
for edge in edges {
let tail = endpoint(edge.fromTable, column: edge.fromColumn, ports: ports)
let head = endpoint(edge.toTable, column: edge.toColumn, ports: ports)
let (tailArrow, headArrow) = arrows(edge.cardinality)
lines.append(" \(tail) -> \(head) [arrowtail=\(tailArrow), arrowhead=\(headArrow)];")
}

lines.append("}")
return lines.joined(separator: "\n") + "\n"
}

// Graphviz puts the first shape against the table, as the canvas puts the crow or outer bar.
static func arrows(_ cardinality: ERCardinality) -> (tail: String, head: String) {
switch cardinality {
case .oneToOne: return ("teetee", "tee")
case .zeroOrOneToOne: return ("teeodot", "tee")
case .manyToOne: return ("crowtee", "tee")
case .zeroOrManyToOne: return ("crowodot", "tee")
case .manyToMany: return ("crow", "crow")
}
}

// One cell per row keeps the port as wide as the table, so an edge stops at the border.
private static func cell(for column: ERColumnDisplay, port: String?) -> String {
let port = port.map { " PORT=\"\($0)\"" } ?? ""
var text = html(column.name)
if !column.dataType.isEmpty {
text += " <FONT COLOR=\"\(secondaryColor)\">\(html(column.dataType))</FONT>"
}
let keys = [column.isPrimaryKey ? "PK" : nil, column.isForeignKey ? "FK" : nil].compactMap { $0 }
if !keys.isEmpty {
text += " <FONT COLOR=\"\(secondaryColor)\" POINT-SIZE=\"9\">\(keys.joined(separator: ", "))</FONT>"
}
return "<TD ALIGN=\"LEFT\"\(port)>\(text)</TD>"
}

// A column the view hides, or a many-to-many edge, has no port: the edge meets the table.
private static func endpoint(_ table: String, column: String, ports: [String: [String: String]]) -> String {
guard let port = ports[table]?[column] else { return quoted(table) }
return "\(quoted(table)):\(quoted(port))"
}

// Graphviz splits a port reference at `:` into port and compass point, even quoted, and keeps an
// escaped backslash doubled, so a port is the name cut to `[A-Za-z0-9_]`, unique per table.
static func portIds(for columns: [String]) -> [String: String] {
var ids: [String: String] = [:]
var taken: Set<String> = []
for name in columns where ids[name] == nil {
let base = name.unicodeScalars.reduce(into: "") { result, scalar in
let isWord = scalar.isASCII && (scalar.properties.isAlphabetic || ("0"..."9").contains(scalar) || scalar == "_")
if isWord {
result.unicodeScalars.append(scalar)
} else if !result.hasSuffix("_") {
result.append("_")
}
}
var candidate = base.isEmpty ? "_" : base
var suffix = 2
while taken.contains(candidate) {
candidate = "\(base)_\(suffix)"
suffix += 1
}
taken.insert(candidate)
ids[name] = candidate
}
return ids
}

static func quoted(_ id: String) -> String {
let escaped = id
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}

// Graphviz expands `\N` and `\G` even in an HTML label, so a backslash is doubled.
static func html(_ text: String) -> String {
var result = ""
for scalar in text.unicodeScalars {
switch scalar {
case "\\": result += "\\\\"
case "&": result += "&amp;"
case "<": result += "&lt;"
case ">": result += "&gt;"
case "\"": result += "&quot;"
case "\t", "\n", "\r": result += " "
default:
let value = scalar.value
let allowed = (0x20...0xD7FF).contains(value) || (0xE000...0xFFFD).contains(value) || value >= 0x10000
result.unicodeScalars.append(allowed ? scalar : "\u{FFFD}")
}
}
return result
}
}
156 changes: 156 additions & 0 deletions apple/TablePro/Models/ERDiagram/ERDiagramMermaidWriter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import Foundation

// Names are fitted to the lexer of Mermaid 11.6 and later, which GitHub renders with.
enum ERDiagramMermaidWriter {
static func document(for graph: ERDiagramGraph) -> String {
let nodes = ERDiagramExportOrder.nodes(of: graph)
let names = entityNames(for: nodes.map(\.tableName))
var lines = ["erDiagram"]

for node in nodes {
let name = names[node.tableName] ?? entityName(node.tableName)
guard !node.displayColumns.isEmpty else {
lines.append(" \(name)")
continue
}
lines.append(" \(name) {")
for column in node.displayColumns {
var line = " \(attributeWord(column.dataType)) \(attributeWord(column.name))"
let keys = [column.isPrimaryKey ? "PK" : nil, column.isForeignKey ? "FK" : nil].compactMap { $0 }
if !keys.isEmpty {
line += " " + keys.joined(separator: ", ")
}
lines.append(line)
}
lines.append(" }")
}

for edge in ERDiagramExportOrder.edges(of: graph) {
let role = edge.cardinality == .manyToMany ? edge.fkName : edge.fromColumn
lines.append(
" \(names[edge.fromTable] ?? entityName(edge.fromTable)) \(relationship(edge.cardinality))"
+ " \(names[edge.toTable] ?? entityName(edge.toTable))"
+ " : \(quoted(role))"
)
}

return lines.joined(separator: "\n") + "\n"
}

// Fenced, so a paste into GitHub or any Markdown that renders Mermaid draws the diagram.
static func markdownBlock(for graph: ERDiagramGraph) -> String {
"```mermaid\n" + document(for: graph) + "```\n"
}

// Each end carries the marker the canvas draws there; the foreign key's table is on the left.
static func relationship(_ cardinality: ERCardinality) -> String {
switch cardinality {
case .oneToOne: return "||--||"
case .zeroOrOneToOne: return "|o--||"
case .manyToOne: return "}|--||"
case .zeroOrManyToOne: return "}o--||"
case .manyToMany: return "}o--o{"
}
}

// Two tables fitted to one name would merge into one entity, so the later one takes a suffix.
static func entityNames(for tables: [String]) -> [String: String] {
var names: [String: String] = [:]
var taken: Set<String> = []
for table in tables.sorted() {
let base = fitted(table)
var candidate = base
var suffix = 2
while taken.contains(candidate) {
candidate = "\(base)_\(suffix)"
suffix += 1
}
taken.insert(candidate)
names[table] = "\"\(candidate)\""
}
return names
}

// Quoted, so a keyword (`end`, `one`, `to`) or a space stays a name.
static func entityName(_ name: String) -> String {
quoted(name)
}

private static func quoted(_ text: String) -> String {
"\"\(fitted(text))\""
}

// The lexer reads `direction LR` anywhere on a line as a directive, quoted or not.
private static func fitted(_ text: String) -> String {
let forbidden: Set<Character> = ["\"", "%", "\\", "\r", "\n", "\u{0B}", "\u{08}", "\r\n"]
let cleaned = text.isEmpty ? "_" : String(text.map { forbidden.contains($0) ? "_" : $0 })
return cleaned.replacingOccurrences(
of: "(direction)\\s+(TB|BT|RL|LR)",
with: "$1_$2",
options: [.regularExpression, .caseInsensitive]
)
}

// The lexer takes `pk` or `FK-1` for a key, so such a word gets a leading `_`.
static func attributeWord(_ text: String) -> String {
var word = ""
var replacing = false
for scalar in text.unicodeScalars {
if isWordScalar(scalar) {
word.unicodeScalars.append(scalar)
replacing = false
} else if !replacing {
word.append("_")
replacing = true
}
}
guard let first = word.unicodeScalars.first else { return "_" }
if !isLeadingScalar(first) || startsWithKey(word) {
word = "_" + word
}
return word
}

// The lexer reads UTF-16, so a character past U+FFFF is two units it accepts.
private static func isLeadingScalar(_ scalar: Unicode.Scalar) -> Bool {
switch scalar.value {
case 0x41...0x5A, 0x61...0x7A, 0x5F: return true
default: return scalar.value >= 0xC0
}
}

private static func isWordScalar(_ scalar: Unicode.Scalar) -> Bool {
if isLeadingScalar(scalar) { return true }
switch scalar {
case "0"..."9", "-", "[", "]", "(", ")": return true
default: return false
}
}

private static func startsWithKey(_ word: String) -> Bool {
let lowered = word.lowercased()
guard ["pk", "fk", "uk"].contains(where: { lowered.hasPrefix($0) }) else { return false }
guard let next = word.unicodeScalars.dropFirst(2).first else { return true }
switch next {
case "a"..."z", "A"..."Z", "0"..."9", "_": return false
default: return true
}
}
}

// Ordered by name only, so an unchanged schema always writes the same file.
enum ERDiagramExportOrder {
static func nodes(of graph: ERDiagramGraph) -> [ERTableNode] {
graph.nodes.sorted { $0.tableName < $1.tableName }
}

static func edges(of graph: ERDiagramGraph) -> [EREdge] {
let tables = Set(graph.nodes.map(\.tableName))
return graph.edges
.filter { tables.contains($0.fromTable) && tables.contains($0.toTable) }
.sorted { lhs, rhs in
(lhs.fromTable, lhs.toTable, lhs.fromColumn, lhs.toColumn, lhs.fkName)
< (rhs.fromTable, rhs.toTable, rhs.fromColumn, rhs.toColumn, rhs.fkName)
}
}
}
5 changes: 3 additions & 2 deletions apple/TablePro/Models/ERDiagram/ERDiagramModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,9 @@ enum ERDiagramGraphBuilder {
var edges: [EREdge] = []
var seenFKNames: Set<String> = []

for (tableName, fks) in allForeignKeys {
for fk in fks {
// Sorted, because a dictionary's order changes per launch and decides port order on ties.
for tableName in allForeignKeys.keys.sorted() {
for fk in allForeignKeys[tableName] ?? [] {
let edgeKey = "\(tableName).\(fk.name).\(fk.column)"
guard !seenFKNames.contains(edgeKey) else { continue }
seenFKNames.insert(edgeKey)
Expand Down
9 changes: 9 additions & 0 deletions apple/TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -36817,6 +36817,9 @@
}
}
}
},
"Choose a location to save the diagram as %@." : {

},
"Choose a location to save the diagram as PNG." : {
"localizations" : {
Expand Down Expand Up @@ -49525,6 +49528,9 @@
},
"Copy as Markdown" : {

},
"Copy as Mermaid" : {

},
"Copy as UPDATE Statements" : {

Expand Down Expand Up @@ -183274,6 +183280,9 @@
}
}
}
},
"The diagram could not be rendered as %@." : {

},
"The disconnect was cancelled." : {
"localizations" : {
Expand Down
64 changes: 64 additions & 0 deletions apple/TablePro/Views/Components/DiagramExporter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import AppKit
import os
import SwiftUI
import UniformTypeIdentifiers

// Save and Copy for the ER and query plan diagrams, so both export the same way.
enum DiagramExporter {
private static let logger = Logger(subsystem: "com.TablePro", category: "DiagramExporter")
static let renderScale: CGFloat = 2.0

@MainActor
static func image(of view: some View) -> NSImage? {
let renderer = ImageRenderer(content: view)
renderer.scale = renderScale
return renderer.nsImage
}

@MainActor
static func export(_ view: some View, defaultFileName: String, title: String) {
save(pngData(image(of: view)), contentType: .png, formatName: "PNG", defaultFileName: defaultFileName, title: title)
}

static func pngData(_ image: NSImage?) -> Data? {
guard let tiffData = image?.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffData)
else { return nil }
return bitmap.representation(using: .png, properties: [:])
}

// `data` is nil when the diagram could not be rendered in that format.
@MainActor
static func save(_ data: Data?, contentType: UTType, formatName: String, defaultFileName: String, title: String) {
guard let window = AlertHelper.resolveWindow(nil) else { return }
guard let data else {
logger.error("Failed to render the diagram as \(formatName, privacy: .public)")
AlertHelper.showErrorSheet(
title: String(localized: "Export Failed"),
message: String(format: String(localized: "The diagram could not be rendered as %@."), formatName),
window: window
)
return
}

let panel = NSSavePanel()
panel.allowedContentTypes = [contentType]
panel.nameFieldStringValue = defaultFileName
panel.title = title
panel.message = String(format: String(localized: "Choose a location to save the diagram as %@."), formatName)

panel.beginSheetModal(for: window) { response in
guard response == .OK, let url = panel.url else { return }
do {
try data.write(to: url, options: .atomic)
} catch {
logger.error("Failed to write the diagram: \(error.localizedDescription)")
AlertHelper.showErrorSheet(
title: String(localized: "Could not export the diagram"),
message: error.localizedDescription,
window: window
)
}
}
}
}
Loading
Loading