&d\" [label=<\n"))
+ #expect(document.contains("a"b<c>&d"))
+ #expect(document.contains("PORT=\"x_y\">x"y t<1>"))
+ #expect(document.contains("| back\\\\slash"))
+ }
+
+ @Test("A backslash is doubled in a label, so \\N and \\G stay text instead of the node and graph names")
+ func backslashInLabel() {
+ #expect(ERDiagramDOTWriter.html("a\\Nb \\G") == "a\\\\Nb \\\\G")
+ }
+
+ @Test("A port is the column name cut to letters, digits and underscores, unique in its table")
+ func portIds() {
+ #expect(ERDiagramDOTWriter.portIds(for: ["id", "id:s", "id_s", "名前", "user id", ""]) == [
+ "id": "id", "id:s": "id_s", "id_s": "id_s_2", "名前": "_", "user id": "user_id", "": "_2",
+ ])
+ }
+
+ @Test("A colon in a column name never reaches Graphviz as a compass point")
+ func colonInColumnName() {
+ let graph = ERDiagramGraphBuilder.build(
+ allColumns: [
+ "parent": [column("id", primaryKey: true), column("id:s", type: "text")],
+ "child": [column("id", primaryKey: true), column("parent_ref", type: "text")],
+ ],
+ allForeignKeys: ["child": [foreignKey("fk", column: "parent_ref", references: "parent", "id:s")]]
+ )
+ let document = ERDiagramDOTWriter.document(for: graph)
+
+ #expect(document.contains(" | id:s"))
+ #expect(document.contains(" \"child\":\"parent_ref\" -> \"parent\":\"id_s\" [arrowtail=crowtee, arrowhead=tee];\n"))
+ }
+
+ @Test("The same schema writes the same file whatever order its edges arrive in")
+ func stableOrder() {
+ var graph = ERDiagramGraphBuilder.build(
+ allColumns: [
+ "a": [column("id", primaryKey: true), column("b_id"), column("c_id")],
+ "b": [column("id", primaryKey: true)],
+ "c": [column("id", primaryKey: true), column("b_id")],
+ ],
+ allForeignKeys: [
+ "a": [foreignKey("a_b", column: "b_id", references: "b"), foreignKey("a_c", column: "c_id", references: "c")],
+ "c": [foreignKey("c_b", column: "b_id", references: "b")],
+ ]
+ )
+ let first = ERDiagramDOTWriter.document(for: graph)
+ graph.edges.reverse()
+ graph.nodes.reverse()
+
+ #expect(ERDiagramDOTWriter.document(for: graph) == first)
+ }
+}
diff --git a/apple/TableProTests/Models/ERDiagram/ERDiagramGraphBuilderTests.swift b/apple/TableProTests/Models/ERDiagram/ERDiagramGraphBuilderTests.swift
index 4dabad580f..949c5e4f29 100644
--- a/apple/TableProTests/Models/ERDiagram/ERDiagramGraphBuilderTests.swift
+++ b/apple/TableProTests/Models/ERDiagram/ERDiagramGraphBuilderTests.swift
@@ -293,4 +293,21 @@ struct ERDiagramGraphBuilderTests {
#expect(expanded.edges.count == 2)
#expect(!expanded.edges.contains { $0.cardinality == .manyToMany })
}
+
+ // MARK: - Order
+
+ @Test("Edges come out in table order, not in the order a dictionary happens to hold them")
+ func edgesFollowTableOrder() {
+ let tables = (0..<16).map { String(format: "t%02d", $0) }
+ var columns: [String: [ColumnInfo]] = ["parent": [column("id", primaryKey: true)]]
+ var foreignKeys: [String: [ForeignKeyInfo]] = [:]
+ for table in tables {
+ columns[table] = [column("id", primaryKey: true), column("parent_id")]
+ foreignKeys[table] = [foreignKey("\(table)_fk", column: "parent_id", references: "parent")]
+ }
+
+ let graph = ERDiagramGraphBuilder.build(allColumns: columns, allForeignKeys: foreignKeys)
+
+ #expect(graph.edges.map { $0.fromTable } == tables)
+ }
}
diff --git a/apple/TableProTests/Models/ERDiagram/ERDiagramMermaidWriterTests.swift b/apple/TableProTests/Models/ERDiagram/ERDiagramMermaidWriterTests.swift
new file mode 100644
index 0000000000..796fd0741b
--- /dev/null
+++ b/apple/TableProTests/Models/ERDiagram/ERDiagramMermaidWriterTests.swift
@@ -0,0 +1,199 @@
+import Foundation
+@testable import TablePro
+import Testing
+
+struct ERDiagramMermaidWriterTests {
+ private func column(
+ _ name: String,
+ type: String = "integer",
+ nullable: Bool = false,
+ primaryKey: Bool = false
+ ) -> ColumnInfo {
+ ColumnInfo(name: name, dataType: type, isNullable: nullable, isPrimaryKey: primaryKey)
+ }
+
+ private func foreignKey(_ name: String, column: String, references table: String, _ refColumn: String = "id") -> ForeignKeyInfo {
+ ForeignKeyInfo(name: name, column: column, referencedTable: table, referencedColumn: refColumn)
+ }
+
+ private var shop: ERDiagramGraph {
+ ERDiagramGraphBuilder.build(
+ allColumns: [
+ "users": [column("id", primaryKey: true), column("email", type: "character varying(255)")],
+ "orders": [
+ column("id", primaryKey: true),
+ column("user_id"),
+ column("total", type: "numeric(10,2)", nullable: true),
+ ],
+ ],
+ allForeignKeys: ["orders": [foreignKey("orders_user_id_fkey", column: "user_id", references: "users")]]
+ )
+ }
+
+ @Test("Each table lists its columns with types and keys, then each foreign key as a relationship")
+ func writesTablesAndRelationships() {
+ #expect(ERDiagramMermaidWriter.document(for: shop) == """
+ erDiagram
+ "orders" {
+ integer id PK
+ integer user_id FK
+ numeric(10_2) total
+ }
+ "users" {
+ integer id PK
+ character_varying(255) email
+ }
+ "orders" }|--|| "users" : "user_id"
+
+ """)
+ }
+
+ @Test("Each cardinality carries the markers the canvas draws at each end", arguments: [
+ (ERCardinality.oneToOne, "||--||"),
+ (.zeroOrOneToOne, "|o--||"),
+ (.manyToOne, "}|--||"),
+ (.zeroOrManyToOne, "}o--||"),
+ (.manyToMany, "}o--o{"),
+ ])
+ func cardinalityMarkers(cardinality: ERCardinality, expected: String) {
+ #expect(ERDiagramMermaidWriter.relationship(cardinality) == expected)
+ }
+
+ @Test("A column that is both keys is marked PK, FK")
+ func primaryAndForeignKey() {
+ let graph = ERDiagramGraphBuilder.build(
+ allColumns: [
+ "users": [column("id", primaryKey: true)],
+ "user_settings": [column("user_id", primaryKey: true)],
+ ],
+ allForeignKeys: ["user_settings": [foreignKey("fk", column: "user_id", references: "users")]]
+ )
+ let document = ERDiagramMermaidWriter.document(for: graph)
+
+ #expect(document.contains(" integer user_id PK, FK\n"))
+ #expect(document.contains(" \"user_settings\" ||--|| \"users\" : \"user_id\"\n"))
+ }
+
+ @Test("A word the Mermaid lexer cannot read is fitted to one it can", arguments: [
+ ("character varying(255)", "character_varying(255)"),
+ ("timestamp with time zone", "timestamp_with_time_zone"),
+ ("numeric(10,2)", "numeric(10_2)"),
+ ("text[]", "text[]"),
+ ("enum('a','b')", "enum(_a_b_)"),
+ ("2fa_secret", "_2fa_secret"),
+ ("pk", "_pk"),
+ ("FK", "_FK"),
+ ("uk-1", "_uk-1"),
+ ("pkey", "pkey"),
+ ("fk_user", "fk_user"),
+ ("user id", "user_id"),
+ ("名前", "名前"),
+ ("", "_"),
+ ])
+ func attributeWords(input: String, expected: String) {
+ #expect(ERDiagramMermaidWriter.attributeWord(input) == expected)
+ }
+
+ @Test("Table names are quoted, so keywords and spaces stay names")
+ func entityNames() {
+ #expect(ERDiagramMermaidWriter.entityName("end") == "\"end\"")
+ #expect(ERDiagramMermaidWriter.entityName("order items") == "\"order items\"")
+ #expect(ERDiagramMermaidWriter.entityName("a\"b%c\\d") == "\"a_b_c_d\"")
+ }
+
+ @Test("Two tables that fit to the same name stay two entities")
+ func fittedNamesStayUnique() {
+ let graph = ERDiagramGraphBuilder.build(
+ allColumns: [
+ "a%b": [column("id", primaryKey: true), column("ref_id")],
+ "a_b": [column("id", primaryKey: true)],
+ ],
+ allForeignKeys: ["a%b": [foreignKey("fk", column: "ref_id", references: "a_b")]]
+ )
+ let document = ERDiagramMermaidWriter.document(for: graph)
+
+ #expect(ERDiagramMermaidWriter.entityNames(for: ["a_b", "a%b", "a_b_2"])
+ == ["a%b": "\"a_b\"", "a_b": "\"a_b_2\"", "a_b_2": "\"a_b_2_2\""])
+ #expect(document.contains(" \"a_b\" {\n"))
+ #expect(document.contains(" \"a_b_2\" {\n"))
+ #expect(document.contains(" \"a_b\" }|--|| \"a_b_2\" : \"ref_id\"\n"))
+ }
+
+ @Test("A name holding a direction directive is not read as one", arguments: [
+ ("sort direction LR", "\"sort direction_LR\""),
+ ("Direction\ttb", "\"Direction_tb\""),
+ ("direction", "\"direction\""),
+ ])
+ func directionInNames(input: String, expected: String) {
+ #expect(ERDiagramMermaidWriter.entityName(input) == expected)
+ }
+
+ @Test("The same schema writes the same file whatever order its edges arrive in")
+ func stableOrder() {
+ var graph = ERDiagramGraphBuilder.build(
+ allColumns: [
+ "a": [column("id", primaryKey: true), column("b_id"), column("c_id")],
+ "b": [column("id", primaryKey: true)],
+ "c": [column("id", primaryKey: true), column("b_id")],
+ ],
+ allForeignKeys: [
+ "a": [foreignKey("a_b", column: "b_id", references: "b"), foreignKey("a_c", column: "c_id", references: "c")],
+ "c": [foreignKey("c_b", column: "b_id", references: "b")],
+ ]
+ )
+ let first = ERDiagramMermaidWriter.document(for: graph)
+ graph.edges.reverse()
+ graph.nodes.reverse()
+
+ #expect(ERDiagramMermaidWriter.document(for: graph) == first)
+ }
+
+ @Test("Compact mode writes only the columns the canvas shows")
+ func writesDisplayedColumns() {
+ var graph = shop
+ graph.nodes = graph.nodes.map { node in
+ var compact = node
+ compact.displayColumns = node.columns.filter { $0.isPrimaryKey || $0.isForeignKey }
+ return compact
+ }
+ let document = ERDiagramMermaidWriter.document(for: graph)
+
+ #expect(!document.contains("email"))
+ #expect(!document.contains("total"))
+ #expect(document.contains("integer user_id FK"))
+ }
+
+ @Test("A collapsed junction table is one many-to-many relationship named after it")
+ func collapsedJunction() {
+ let graph = ERDiagramGraphBuilder.build(
+ allColumns: [
+ "users": [column("id", primaryKey: true)],
+ "roles": [column("id", primaryKey: true)],
+ "user_roles": [column("user_id", primaryKey: true), column("role_id", primaryKey: true)],
+ ],
+ allForeignKeys: ["user_roles": [
+ foreignKey("ur_user", column: "user_id", references: "users"),
+ foreignKey("ur_role", column: "role_id", references: "roles"),
+ ]]
+ ).projected(collapseJunctions: true)
+ let document = ERDiagramMermaidWriter.document(for: graph)
+
+ #expect(document.contains(" \"users\" }o--o{ \"roles\" : \"user_roles\"\n"))
+ #expect(!document.contains("\"user_roles\" {"))
+ }
+
+ @Test("A table without columns is declared by name alone")
+ func tableWithoutColumns() {
+ let graph = ERDiagramGraphBuilder.build(allColumns: ["empty": []], allForeignKeys: [:])
+
+ #expect(ERDiagramMermaidWriter.document(for: graph) == "erDiagram\n \"empty\"\n")
+ }
+
+ @Test("Copy as Mermaid fences the diagram for Markdown")
+ func markdownBlock() {
+ let block = ERDiagramMermaidWriter.markdownBlock(for: shop)
+
+ #expect(block.hasPrefix("```mermaid\nerDiagram\n"))
+ #expect(block.hasSuffix("\n```\n"))
+ }
+}
diff --git a/apple/TableProTests/Views/ERDiagram/ERDiagramVectorExportTests.swift b/apple/TableProTests/Views/ERDiagram/ERDiagramVectorExportTests.swift
new file mode 100644
index 0000000000..117d5f2745
--- /dev/null
+++ b/apple/TableProTests/Views/ERDiagram/ERDiagramVectorExportTests.swift
@@ -0,0 +1,245 @@
+import AppKit
+import PDFKit
+@testable import TablePro
+import Testing
+
+@MainActor
+struct ERDiagramVectorExportTests {
+ private static let title = "shop.public"
+
+ private func column(_ name: String, type: String = "integer", primaryKey: Bool = false, foreignKey: Bool = false) -> ERColumnDisplay {
+ ERColumnDisplay(
+ id: name,
+ name: name,
+ dataType: type,
+ isPrimaryKey: primaryKey,
+ isForeignKey: foreignKey,
+ isNullable: false
+ )
+ }
+
+ private func node(_ name: String, columns: [ERColumnDisplay]) -> ERTableNode {
+ ERTableNode(id: UUID(), tableName: name, columns: columns, displayColumns: columns, clusterId: 0)
+ }
+
+ private func scene(usersName: String = "users", emailName: String = "email", selected: Bool = false) -> ERDiagramScene {
+ let users = node(usersName, columns: [column("id", primaryKey: true), column(emailName, type: "text")])
+ let orders = node("orders", columns: [column("id", primaryKey: true), column("user_id", foreignKey: true)])
+ let width = ERDiagramLayout.nodeWidth
+ let usersRect = CGRect(x: 120, y: 80, width: width, height: ERDiagramLayout.estimateHeight(columnCount: 2))
+ let ordersRect = CGRect(x: 120 + width + 120, y: 80, width: width, height: ERDiagramLayout.estimateHeight(columnCount: 2))
+ let edge = EREdge(
+ id: UUID(),
+ fkName: "orders_user_id_fkey",
+ fromTable: "orders",
+ fromColumn: "user_id",
+ toTable: usersName,
+ toColumn: "id",
+ cardinality: .zeroOrManyToOne
+ )
+ return ERDiagramScene(
+ nodes: [users, orders],
+ edges: [edge],
+ nodeRects: [users.id: usersRect, orders.id: ordersRect],
+ nodeIndex: [usersName: users.id, "orders": orders.id],
+ clusterColors: [users.id: .systemBlue, orders.id: .systemBlue],
+ selectedNodeId: selected ? users.id : nil,
+ size: CGSize(width: ordersRect.maxX + 80, height: ordersRect.maxY + 80)
+ )
+ }
+
+ private func expectedSize(of scene: ERDiagramScene) -> CGSize {
+ let bounds = ERDiagramSceneRenderer.drawnBounds(of: scene)
+ let padding = ERDiagramSceneRenderer.exportPadding
+ return CGSize(width: bounds.width + padding * 2, height: bounds.height + padding * 2)
+ }
+
+ private func appearance() throws -> NSAppearance {
+ try #require(NSAppearance(named: .aqua))
+ }
+
+ private func svg(_ scene: ERDiagramScene) throws -> XMLElement {
+ let text = ERDiagramSceneRenderer.svgDocument(scene, appearance: try appearance(), title: Self.title)
+ let document = try XMLDocument(data: Data(text.utf8))
+ return try #require(document.rootElement())
+ }
+
+ private func elements(named name: String, in root: XMLElement) -> [XMLElement] {
+ let children = (root.children ?? []).compactMap { $0 as? XMLElement }
+ return (root.localName == name ? [root] : []) + children.flatMap { elements(named: name, in: $0) }
+ }
+
+ // MARK: - PDF
+
+ @Test("The PDF is one page sized like the PNG: what is drawn plus the export padding")
+ func pdfIsOnePageSizedToTheDiagram() throws {
+ let scene = scene()
+ let data = try #require(ERDiagramSceneRenderer.pdfData(scene, appearance: try appearance(), title: Self.title))
+ let document = try #require(PDFDocument(data: data))
+ let page = try #require(document.page(at: 0))
+ let size = expectedSize(of: scene)
+
+ #expect(document.pageCount == 1)
+ #expect(abs(page.bounds(for: .mediaBox).width - size.width) < 0.5)
+ #expect(abs(page.bounds(for: .mediaBox).height - size.height) < 0.5)
+ #expect(document.documentAttributes?[PDFDocumentAttribute.titleAttribute] as? String == Self.title)
+ }
+
+ @Test("The PDF keeps table names, columns and types as text")
+ func pdfKeepsTextAsText() throws {
+ let data = try #require(ERDiagramSceneRenderer.pdfData(scene(), appearance: try appearance(), title: Self.title))
+ let text = try #require(PDFDocument(data: data)?.page(at: 0)?.string)
+
+ for word in ["users", "orders", "email", "user_id", "integer", "text"] {
+ #expect(text.contains(word), "missing \(word)")
+ }
+ }
+
+ @Test("A key badge draws upright in the flipped export, bow at the top as the SVG draws it")
+ func symbolsDrawUpright() throws {
+ let scale: CGFloat = 4
+ let scene = scene()
+ let image = try #require(ERDiagramSceneRenderer.image(scene, appearance: try appearance(), scale: scale))
+ let rep = try #require(image.representations.first as? NSBitmapImageRep)
+ let users = try #require(scene.nodes.first { $0.tableName == "users" })
+ let rect = try #require(scene.nodeRects[users.id])
+ let bounds = ERDiagramSceneRenderer.drawnBounds(of: scene)
+ let padding = ERDiagramSceneRenderer.exportPadding
+ let center = CGPoint(
+ x: (rect.minX - bounds.minX + padding + 14 * ERDiagramLayout.typeScale) * scale,
+ y: (rect.minY - bounds.minY + padding + ERDiagramLayout.headerHeight + ERDiagramLayout.columnRowHeight / 2) * scale
+ )
+
+ var upper = 0
+ var lower = 0
+ let reach = Int(8 * scale)
+ for dy in -reach...reach {
+ for dx in -reach...reach {
+ guard let color = rep.colorAt(x: Int(center.x) + dx, y: Int(center.y) + dy)?.usingColorSpace(.sRGB),
+ color.redComponent > 0.7, color.greenComponent > 0.5, color.blueComponent < 0.4
+ else { continue }
+ if dy < 0 { upper += 1 } else if dy > 0 { lower += 1 }
+ }
+ }
+
+ #expect(upper + lower > 0, "the key badge must be drawn")
+ #expect(upper > lower)
+ }
+
+ // MARK: - SVG
+
+ @Test("The SVG is well-formed and sized like the PNG")
+ func svgIsSizedToTheDiagram() throws {
+ let scene = scene()
+ let root = try svg(scene)
+ let size = expectedSize(of: scene)
+
+ #expect(root.name == "svg")
+ #expect(root.attribute(forName: "width")?.stringValue == ERDiagramSVGCanvas.number(size.width))
+ #expect(root.attribute(forName: "height")?.stringValue == ERDiagramSVGCanvas.number(size.height))
+ #expect(elements(named: "title", in: root).first?.stringValue == Self.title)
+ }
+
+ @Test("The SVG writes every table name, column and type as text")
+ func svgKeepsTextAsText() throws {
+ let texts = try elements(named: "text", in: svg(scene())).compactMap(\.stringValue)
+
+ for word in ["users", "orders", "id", "email", "user_id", "integer", "text"] {
+ #expect(texts.contains(word), "missing \(word)")
+ }
+ #expect(texts.count == 2 + 2 * 4)
+ }
+
+ @Test("The SVG draws an edge as its curve and the markers the canvas draws")
+ func svgDrawsEdgeAndMarkers() throws {
+ let paths = try elements(named: "path", in: svg(scene()))
+ let connectors = paths.filter { $0.attribute(forName: "stroke-linecap")?.stringValue == "round" }
+
+ // The curve, the crow's foot and circle at the foreign key, and the bar at the referenced end.
+ #expect(connectors.count == 4)
+ #expect(connectors.allSatisfy { $0.attribute(forName: "fill")?.stringValue == "none" })
+ }
+
+ @Test("The SVG embeds each symbol once and places it with a use")
+ func svgSymbolsAreShared() throws {
+ let root = try svg(scene())
+ let images = elements(named: "image", in: root)
+ let uses = elements(named: "use", in: root)
+
+ // tablecells, key.fill and link; two table icons, two keys and one link.
+ #expect(images.count == 3)
+ #expect(uses.count == 5)
+ #expect(images.allSatisfy { image in
+ image.attribute(forLocalName: "href", uri: "http://www.w3.org/1999/xlink")?.stringValue?
+ .hasPrefix("data:image/png;base64,") == true
+ })
+ }
+
+ @Test("The export never shows the selection")
+ func svgOmitsSelection() throws {
+ let paths = try elements(named: "path", in: svg(scene(selected: true)))
+
+ #expect(!paths.contains { $0.attribute(forName: "stroke-width")?.stringValue == "2" })
+ }
+
+ @Test("Names XML cannot hold as they are still give a well-formed file")
+ func svgEscapesNames() throws {
+ let texts = try elements(named: "text", in: svg(scene(usersName: "a&b\"d", emailName: "e\u{1}f")))
+ .compactMap(\.stringValue)
+
+ #expect(texts.contains("a&b\"d"))
+ #expect(texts.contains("e\u{FFFD}f"))
+ }
+
+ @Test("A column name too long for its row ends in the same ellipsis the canvas draws")
+ func svgTruncatesLikeTheCanvas() throws {
+ let long = String(repeating: "shipping_address_", count: 4)
+ let texts = try elements(named: "text", in: svg(scene(emailName: long))).compactMap(\.stringValue)
+ let shown = try #require(texts.first { $0.hasPrefix("shipping_address_") })
+
+ #expect(shown.hasSuffix("\u{2026}"))
+ #expect(shown.count < long.count)
+ }
+
+ // MARK: - Encoding
+
+ @Test("Text that fits is kept, and text that does not keeps the longest prefix that fits")
+ func fittedText() {
+ let font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular)
+ let text = "character varying(255)"
+ let full = ERDiagramTextRenderer.width(of: text, font: font)
+ let fitted = ERDiagramTextRenderer.fittedText(text, font: font, maxWidth: full / 2)
+ let longer = String(text.prefix(fitted.count)) + "\u{2026}"
+
+ #expect(ERDiagramTextRenderer.fittedText(text, font: font, maxWidth: full) == text)
+ #expect(fitted.hasSuffix("\u{2026}"))
+ #expect(ERDiagramTextRenderer.width(of: fitted, font: font) <= full / 2)
+ #expect(ERDiagramTextRenderer.width(of: longer, font: font) > full / 2)
+ }
+
+ @Test("A path is written with SVG's commands")
+ func pathData() {
+ let path = CGMutablePath()
+ path.move(to: CGPoint(x: 0, y: 0))
+ path.addLine(to: CGPoint(x: 10.5, y: 0))
+ path.addCurve(to: CGPoint(x: 20, y: 5), control1: CGPoint(x: 12, y: 1), control2: CGPoint(x: 18.333, y: 4))
+ path.closeSubpath()
+
+ #expect(ERDiagramSVGCanvas.pathData(path) == "M0 0 L10.5 0 C12 1 18.33 4 20 5 Z")
+ }
+
+ @Test("Numbers are short and never negative zero", arguments: [
+ (CGFloat(1), "1"), (1.5, "1.5"), (1.234, "1.23"), (-0.001, "0"), (120, "120"),
+ ])
+ func numbers(value: CGFloat, expected: String) {
+ #expect(ERDiagramSVGCanvas.number(value) == expected)
+ }
+
+ @Test("A translucent color is written as a hex color and an opacity")
+ func paint() throws {
+ let space = try #require(CGColorSpace(name: CGColorSpace.sRGB))
+ let color = try #require(CGColor(colorSpace: space, components: [1, 0.5, 0, 0.25]))
+
+ #expect(ERDiagramSVGCanvas.paint("fill", color) == " fill=\"#FF8000\" fill-opacity=\"0.25\"")
+ }
+}
diff --git a/apple/changelog.d/3738.added.md b/apple/changelog.d/3738.added.md
new file mode 100644
index 0000000000..ace5f93181
--- /dev/null
+++ b/apple/changelog.d/3738.added.md
@@ -0,0 +1 @@
+Export the ER diagram as SVG, PDF, Mermaid or Graphviz DOT, or copy it as Mermaid for Markdown.
diff --git a/apple/changelog.d/3738.fixed.md b/apple/changelog.d/3738.fixed.md
new file mode 100644
index 0000000000..fd285e9456
--- /dev/null
+++ b/apple/changelog.d/3738.fixed.md
@@ -0,0 +1 @@
+The key, link and junction table icons in the ER diagram and its PNG export were drawn upside down.
|