diff --git a/CHANGELOG.md b/CHANGELOG.md index 193784a44..139bc9722 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Per-column value filter in the data grid. Hover a column header and click the funnel icon to pick which values to show from the loaded rows. Filter several columns at once, search the value list, and clear filters from the header menu. The filter runs on loaded rows without re-querying. (#1454) - Elasticsearch support. Connect to Elasticsearch 7.x and 8.x, browse indices, run Query DSL requests in a console, and edit documents in the data grid. Install from Settings > Plugins. (#1529) - The connection switcher and welcome list now show each connection's tags and group, so you can tell production from staging at a glance. (#1323) +- The ER diagram now reads each relationship's cardinality (one-to-one, one-to-many, and optional variants) from primary key and unique index data and marks the edges with crow's foot notation. Junction tables are detected and shown as a single many-to-many link, with a toolbar toggle to expand them back to the underlying tables. (#1335) +- Export the ER diagram to SQL. A new toolbar button opens a query tab with CREATE TABLE and foreign key statements for the current schema in the connection's SQL dialect. (#1335) ### Changed diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index ec4b06ec1..7d37446d2 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -340,6 +340,20 @@ extension DatabaseDriver { return all.filter { nameSet.contains($0.key) } } + func fetchIndexes(forTables tableNames: [String]) async throws -> [String: [IndexInfo]] { + var result: [String: [IndexInfo]] = [:] + for tableName in tableNames { + do { + let indexes = try await fetchIndexes(table: tableName) + if !indexes.isEmpty { result[tableName] = indexes } + } catch { + Logger(subsystem: "com.TablePro", category: "DatabaseDriver") + .debug("Failed to fetch indexes for \(tableName): \(error.localizedDescription)") + } + } + return result + } + /// Default fetchAllColumns: falls back to per-table fetchColumns (N+1). /// Drivers should override with a single bulk query where possible. func fetchAllColumns() async throws -> [String: [ColumnInfo]] { diff --git a/TablePro/Models/ERDiagram/ERDiagramModels.swift b/TablePro/Models/ERDiagram/ERDiagramModels.swift index 3d3929dd6..6d7dd4b49 100644 --- a/TablePro/Models/ERDiagram/ERDiagramModels.swift +++ b/TablePro/Models/ERDiagram/ERDiagramModels.swift @@ -9,6 +9,7 @@ struct ERTableNode: Identifiable, Sendable { let columns: [ERColumnDisplay] var displayColumns: [ERColumnDisplay] var clusterId: Int? + var isJunctionTable: Bool = false } struct ERColumnDisplay: Identifiable, Sendable { @@ -23,7 +24,11 @@ struct ERColumnDisplay: Identifiable, Sendable { // MARK: - Edge enum ERCardinality: Sendable { + case oneToOne + case zeroOrOneToOne case manyToOne + case zeroOrManyToOne + case manyToMany } struct EREdge: Identifiable, Sendable { @@ -42,8 +47,31 @@ struct ERDiagramGraph: Sendable { var nodes: [ERTableNode] var edges: [EREdge] var nodeIndex: [String: UUID] + var junctionTableIds: Set = [] + var manyToManyEdges: [EREdge] = [] static let empty = ERDiagramGraph(nodes: [], edges: [], nodeIndex: [:]) + + func projected(collapseJunctions: Bool) -> ERDiagramGraph { + guard collapseJunctions, !junctionTableIds.isEmpty else { return self } + + let visibleNodes = nodes.filter { !junctionTableIds.contains($0.id) } + let visibleNodeIndex = visibleNodes.reduce(into: [String: UUID]()) { result, node in + result[node.tableName] = node.id + } + let visibleEdges = edges.filter { edge in + guard let fromId = nodeIndex[edge.fromTable], let toId = nodeIndex[edge.toTable] else { return false } + return !junctionTableIds.contains(fromId) && !junctionTableIds.contains(toId) + } + + return ERDiagramGraph( + nodes: visibleNodes, + edges: visibleEdges + manyToManyEdges, + nodeIndex: visibleNodeIndex, + junctionTableIds: junctionTableIds, + manyToManyEdges: manyToManyEdges + ) + } } // MARK: - Graph Builder @@ -51,7 +79,8 @@ struct ERDiagramGraph: Sendable { enum ERDiagramGraphBuilder { static func build( allColumns: [String: [ColumnInfo]], - allForeignKeys: [String: [ForeignKeyInfo]] + allForeignKeys: [String: [ForeignKeyInfo]], + allIndexes: [String: [IndexInfo]] = [:] ) -> ERDiagramGraph { var nodeIndex: [String: UUID] = [:] var nodes: [ERTableNode] = [] @@ -59,6 +88,24 @@ enum ERDiagramGraphBuilder { let fkColumnsByTable: [String: Set] = allForeignKeys.mapValues { fks in Set(fks.map(\.column)) } + let columnsByTable: [String: [String: ColumnInfo]] = allColumns.mapValues { columns in + Dictionary(columns.map { ($0.name, $0) }, uniquingKeysWith: { first, _ in first }) + } + let uniqueSingleColumnsByTable: [String: Set] = allColumns.reduce(into: [:]) { result, entry in + let (tableName, columns) = entry + var unique: Set = [] + let primaryKeyColumns = columns.filter(\.isPrimaryKey).map(\.name) + if primaryKeyColumns.count == 1, let only = primaryKeyColumns.first { + unique.insert(only) + } + for index in allIndexes[tableName] ?? [] + where index.isUnique && index.whereClause == nil && index.columns.count == 1 { + if let column = index.columns.first { unique.insert(column) } + } + result[tableName] = unique + } + + var junctionTableIds: Set = [] for tableName in allColumns.keys.sorted() { let id = stableId(for: tableName) @@ -78,12 +125,20 @@ enum ERDiagramGraphBuilder { ) } + let isJunction = junctionParents( + tableName: tableName, + columns: columns, + foreignKeys: allForeignKeys[tableName] ?? [] + ) != nil + if isJunction { junctionTableIds.insert(id) } + nodes.append(ERTableNode( id: id, tableName: tableName, columns: displayColumns, displayColumns: displayColumns, - clusterId: nil + clusterId: nil, + isJunctionTable: isJunction )) } @@ -105,11 +160,20 @@ enum ERDiagramGraphBuilder { fromColumn: fk.column, toTable: fk.referencedTable, toColumn: fk.referencedColumn, - cardinality: .manyToOne + cardinality: inferCardinality( + column: columnsByTable[tableName]?[fk.column], + uniqueColumns: uniqueSingleColumnsByTable[tableName] ?? [] + ) )) } } + let manyToManyEdges = buildManyToManyEdges( + allColumns: allColumns, + allForeignKeys: allForeignKeys, + nodeIndex: nodeIndex + ) + let clusters = ERClusterAnalyzer.assignClusters(nodes: nodes, edges: edges, nodeIndex: nodeIndex) let clusteredNodes = nodes.map { node -> ERTableNode in var updated = node @@ -117,7 +181,73 @@ enum ERDiagramGraphBuilder { return updated } - return ERDiagramGraph(nodes: clusteredNodes, edges: edges, nodeIndex: nodeIndex) + return ERDiagramGraph( + nodes: clusteredNodes, + edges: edges, + nodeIndex: nodeIndex, + junctionTableIds: junctionTableIds, + manyToManyEdges: manyToManyEdges + ) + } + + private static func inferCardinality(column: ColumnInfo?, uniqueColumns: Set) -> ERCardinality { + guard let column else { return .zeroOrManyToOne } + let isUnique = uniqueColumns.contains(column.name) + let isMandatory = !column.isNullable + switch (isUnique, isMandatory) { + case (true, true): return .oneToOne + case (true, false): return .zeroOrOneToOne + case (false, true): return .manyToOne + case (false, false): return .zeroOrManyToOne + } + } + + private static func junctionParents( + tableName: String, + columns: [ColumnInfo], + foreignKeys: [ForeignKeyInfo] + ) -> (String, String)? { + let pkColumns = Set(columns.filter { $0.isPrimaryKey }.map(\.name)) + guard pkColumns.count >= 2 else { return nil } + + let fkColumns = Set(foreignKeys.map(\.column)) + guard pkColumns.isSubset(of: fkColumns) else { return nil } + + var orderedParents: [String] = [] + for fk in foreignKeys where pkColumns.contains(fk.column) { + if !orderedParents.contains(fk.referencedTable) { + orderedParents.append(fk.referencedTable) + } + } + guard orderedParents.count == 2 else { return nil } + return (orderedParents[0], orderedParents[1]) + } + + private static func buildManyToManyEdges( + allColumns: [String: [ColumnInfo]], + allForeignKeys: [String: [ForeignKeyInfo]], + nodeIndex: [String: UUID] + ) -> [EREdge] { + var edges: [EREdge] = [] + for tableName in allColumns.keys.sorted() { + guard let (parentA, parentB) = junctionParents( + tableName: tableName, + columns: allColumns[tableName] ?? [], + foreignKeys: allForeignKeys[tableName] ?? [] + ) else { continue } + guard nodeIndex[parentA] != nil, nodeIndex[parentB] != nil else { continue } + + edges.append(EREdge( + id: stableId(for: "mn.\(tableName)"), + fkName: tableName, + fromTable: parentA, + fromColumn: "", + toTable: parentB, + toColumn: "", + cardinality: .manyToMany + )) + } + return edges } private static func stableId(for name: String) -> UUID { diff --git a/TablePro/Models/ERDiagram/ERDiagramSQLExporter.swift b/TablePro/Models/ERDiagram/ERDiagramSQLExporter.swift new file mode 100644 index 000000000..ad0e44f67 --- /dev/null +++ b/TablePro/Models/ERDiagram/ERDiagramSQLExporter.swift @@ -0,0 +1,157 @@ +import Foundation + +enum ERDiagramSQLExporter { + static func generate( + tableNames: [String], + allColumns: [String: [ColumnInfo]], + allForeignKeys: [String: [ForeignKeyInfo]], + isSQLite: Bool, + quoteIdentifier: (String) -> String + ) -> String { + let orderedTables = tableNames.sorted() + let exportedTables = Set(orderedTables) + + var statements: [String] = [] + + for tableName in orderedTables { + guard let columns = allColumns[tableName], !columns.isEmpty else { continue } + let inlineForeignKeys = isSQLite + ? (allForeignKeys[tableName] ?? []).filter { exportedTables.contains($0.referencedTable) } + : [] + statements.append(createTableStatement( + tableName: tableName, + columns: columns, + inlineForeignKeys: inlineForeignKeys, + quoteIdentifier: quoteIdentifier + )) + } + + if !isSQLite { + for tableName in orderedTables { + guard allColumns[tableName]?.isEmpty == false else { continue } + let foreignKeys = (allForeignKeys[tableName] ?? []).filter { exportedTables.contains($0.referencedTable) } + for group in groupByConstraintName(foreignKeys) { + statements.append(alterTableForeignKeyStatement( + tableName: tableName, + group: group, + quoteIdentifier: quoteIdentifier + )) + } + } + } + + return statements.joined(separator: "\n\n") + } + + private static func createTableStatement( + tableName: String, + columns: [ColumnInfo], + inlineForeignKeys: [ForeignKeyInfo], + quoteIdentifier: (String) -> String + ) -> String { + let primaryKeyColumns = columns.filter(\.isPrimaryKey).map(\.name) + let singleColumnPrimaryKey = primaryKeyColumns.count == 1 ? primaryKeyColumns.first : nil + + var lines = columns.map { column in + columnDefinition( + column: column, + inlinePrimaryKey: column.name == singleColumnPrimaryKey, + quoteIdentifier: quoteIdentifier + ) + } + + if primaryKeyColumns.count > 1 { + let cols = primaryKeyColumns.map(quoteIdentifier).joined(separator: ", ") + lines.append("PRIMARY KEY (\(cols))") + } + + for group in groupByConstraintName(inlineForeignKeys) { + lines.append(inlineForeignKeyClause(group: group, quoteIdentifier: quoteIdentifier)) + } + + let body = lines.map { " \($0)" }.joined(separator: ",\n") + return "CREATE TABLE \(quoteIdentifier(tableName)) (\n\(body)\n);" + } + + private static func columnDefinition( + column: ColumnInfo, + inlinePrimaryKey: Bool, + quoteIdentifier: (String) -> String + ) -> String { + var definition = "\(quoteIdentifier(column.name)) \(column.dataType)" + if !column.isNullable { + definition += " NOT NULL" + } + if let defaultValue = column.defaultValue, !defaultValue.isEmpty { + definition += " DEFAULT \(formatDefaultValue(defaultValue))" + } + if inlinePrimaryKey { + definition += " PRIMARY KEY" + } + return definition + } + + private static func formatDefaultValue(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespaces) + let passthroughKeywords: Set = [ + "NULL", "TRUE", "FALSE", + "CURRENT_TIMESTAMP", "CURRENT_TIMESTAMP()", + "CURRENT_DATE", "CURRENT_TIME", "NOW()", "LOCALTIMESTAMP" + ] + if passthroughKeywords.contains(trimmed.uppercased()) { return trimmed } + if trimmed.hasPrefix("'") { return trimmed } + if trimmed.contains("(") || trimmed.contains("::") { return trimmed } + if Int64(trimmed) != nil { return trimmed } + if let number = Double(trimmed), number.isFinite { return trimmed } + let escaped = trimmed.replacingOccurrences(of: "'", with: "''") + return "'\(escaped)'" + } + + private static func inlineForeignKeyClause( + group: [ForeignKeyInfo], + quoteIdentifier: (String) -> String + ) -> String { + let cols = group.map { quoteIdentifier($0.column) }.joined(separator: ", ") + let refCols = group.map { quoteIdentifier($0.referencedColumn) }.joined(separator: ", ") + let refTable = quoteIdentifier(group[0].referencedTable) + var clause = "FOREIGN KEY (\(cols)) REFERENCES \(refTable) (\(refCols))" + clause += referentialActions(group[0]) + return clause + } + + private static func alterTableForeignKeyStatement( + tableName: String, + group: [ForeignKeyInfo], + quoteIdentifier: (String) -> String + ) -> String { + let cols = group.map { quoteIdentifier($0.column) }.joined(separator: ", ") + let refCols = group.map { quoteIdentifier($0.referencedColumn) }.joined(separator: ", ") + let refTable = quoteIdentifier(group[0].referencedTable) + let constraintName = quoteIdentifier(group[0].name) + var statement = "ALTER TABLE \(quoteIdentifier(tableName)) ADD CONSTRAINT \(constraintName)" + statement += " FOREIGN KEY (\(cols)) REFERENCES \(refTable) (\(refCols))" + statement += referentialActions(group[0]) + return statement + ";" + } + + private static func referentialActions(_ foreignKey: ForeignKeyInfo) -> String { + var actions = "" + let onDelete = foreignKey.onDelete.uppercased() + let onUpdate = foreignKey.onUpdate.uppercased() + if onDelete != "NO ACTION" { actions += " ON DELETE \(onDelete)" } + if onUpdate != "NO ACTION" { actions += " ON UPDATE \(onUpdate)" } + return actions + } + + private static func groupByConstraintName(_ foreignKeys: [ForeignKeyInfo]) -> [[ForeignKeyInfo]] { + var orderedNames: [String] = [] + var groups: [String: [ForeignKeyInfo]] = [:] + for foreignKey in foreignKeys { + if groups[foreignKey.name] == nil { + orderedNames.append(foreignKey.name) + } + groups[foreignKey.name, default: []].append(foreignKey) + } + return orderedNames.compactMap { groups[$0] } + } +} diff --git a/TablePro/ViewModels/ERDiagramViewModel.swift b/TablePro/ViewModels/ERDiagramViewModel.swift index db3d86440..4b82c7311 100644 --- a/TablePro/ViewModels/ERDiagramViewModel.swift +++ b/TablePro/ViewModels/ERDiagramViewModel.swift @@ -3,6 +3,7 @@ import Combine import Foundation import os import SwiftUI +import TableProPluginKit @MainActor @Observable @@ -35,9 +36,19 @@ final class ERDiagramViewModel { var graph: ERDiagramGraph = .empty var magnification: CGFloat = 1.0 var isCompactMode = false { - didSet { rebuildDisplayColumns() } + didSet { rebuildVisibleGraph() } } + var collapseJunctions = true { + didSet { rebuildVisibleGraph() } + } + + var hasJunctionTables: Bool { !fullGraph.junctionTableIds.isEmpty } + + @ObservationIgnored private var fullGraph: ERDiagramGraph = .empty + @ObservationIgnored private var allColumns: [String: [ColumnInfo]] = [:] + @ObservationIgnored private var allForeignKeys: [String: [ForeignKeyInfo]] = [:] + // MARK: - Canvas Viewport var canvasOffset: CGPoint = .zero @@ -101,23 +112,29 @@ final class ERDiagramViewModel { } do { - let (allColumns, allFKs) = try await services.databaseManager.withMetadataDriver( + let (columns, foreignKeys, indexes) = try await services.databaseManager.withMetadataDriver( connectionId: connectionId, workload: .bulk ) { driver in let cols = try await driver.fetchAllColumns() let fks = try await driver.fetchAllForeignKeys() - return (cols, fks) + let idx = try await driver.fetchIndexes(forTables: Array(fks.keys)) + return (cols, fks, idx) } - let builtGraph = ERDiagramGraphBuilder.build( - allColumns: allColumns, - allForeignKeys: allFKs + allColumns = columns + allForeignKeys = foreignKeys + fullGraph = ERDiagramGraphBuilder.build( + allColumns: columns, + allForeignKeys: foreignKeys, + allIndexes: indexes ) - graph = builtGraph - nodeIdToName = Dictionary(uniqueKeysWithValues: builtGraph.nodes.map { ($0.id, $0.tableName) }) + + nodeIdToName = Dictionary(uniqueKeysWithValues: fullGraph.nodes.map { ($0.id, $0.tableName) }) + let visibleGraph = makeVisibleGraph() + graph = visibleGraph let layout = await Task.detached { - ERDiagramLayout.compute(graph: builtGraph) + ERDiagramLayout.compute(graph: visibleGraph) }.value computedLayout = layout loadPersistedPositions() @@ -209,10 +226,11 @@ final class ERDiagramViewModel { } } - // MARK: - Compact Mode + // MARK: - Visible Graph (compact mode + junction collapse) - private func rebuildDisplayColumns() { - graph.nodes = graph.nodes.map { node in + private func makeVisibleGraph() -> ERDiagramGraph { + var projected = fullGraph.projected(collapseJunctions: collapseJunctions) + projected.nodes = projected.nodes.map { node in var updated = node updated.displayColumns = isCompactMode ? node.columns.filter { $0.isPrimaryKey || $0.isForeignKey } @@ -222,12 +240,18 @@ final class ERDiagramViewModel { } return updated } + return projected + } + + private func rebuildVisibleGraph() { + guard loadState == .loaded else { return } + let visibleGraph = makeVisibleGraph() + graph = visibleGraph invalidateCachedRects() - let currentGraph = graph layoutTask?.cancel() layoutTask = Task { let layout = await Task.detached { - ERDiagramLayout.compute(graph: currentGraph) + ERDiagramLayout.compute(graph: visibleGraph) }.value guard !Task.isCancelled else { return } computedLayout = layout @@ -235,6 +259,43 @@ final class ERDiagramViewModel { } } + // MARK: - SQL Export + + func exportSchemaAsSQL() { + guard loadState == .loaded, !fullGraph.nodes.isEmpty else { return } + guard let driver = services.databaseManager.driver(for: connectionId) else { return } + let databaseType = driver.connection.type + do { + let dialect = try resolveSQLDialect(for: databaseType) + let quote = quoteIdentifierFromDialect(dialect) + let sql = ERDiagramSQLExporter.generate( + tableNames: fullGraph.nodes.map(\.tableName), + allColumns: allColumns, + allForeignKeys: allForeignKeys, + isSQLite: databaseType == .sqlite, + quoteIdentifier: quote + ) + guard !sql.isEmpty else { return } + + let payload = EditorTabPayload( + connectionId: connectionId, + tabType: .query, + databaseName: services.databaseManager.activeDatabaseName(for: driver.connection), + initialQuery: sql, + skipAutoExecute: true, + tabTitle: String(localized: "Schema SQL") + ) + WindowManager.shared.openTab(payload: payload) + } catch { + Self.logger.error("Failed to export ER diagram as SQL: \(error.localizedDescription)") + AlertHelper.showErrorSheet( + title: String(localized: "Export Failed"), + message: error.localizedDescription, + window: nil + ) + } + } + // MARK: - Canvas Size private(set) var cachedCanvasSize = CGSize(width: 800, height: 600) @@ -441,7 +502,7 @@ final class ERDiagramViewModel { private func loadPersistedPositions() { let stored = ERDiagramPositionStorage.shared.load(connectionId: connectionId, schemaKey: schemaKey) for (tableName, point) in stored { - if let nodeId = graph.nodeIndex[tableName] { + if let nodeId = fullGraph.nodeIndex[tableName] { positionOverrides[nodeId] = point } } diff --git a/TablePro/Views/ERDiagram/ERDiagramEdgeRenderer.swift b/TablePro/Views/ERDiagram/ERDiagramEdgeRenderer.swift index 2e9b26f16..89f7a3111 100644 --- a/TablePro/Views/ERDiagram/ERDiagramEdgeRenderer.swift +++ b/TablePro/Views/ERDiagram/ERDiagramEdgeRenderer.swift @@ -79,11 +79,82 @@ enum ERDiagramEdgeRenderer { let (path, cp1, cp2) = bezierPath(from: srcPort, to: dstPort, verticalPorts: verticalPorts) context.stroke(path, with: .color(strokeColor), style: strokeStyle) - drawCrowFoot(context: context, at: srcPort, toward: cp1, color: strokeColor) - drawOneBar(context: context, at: dstPort, toward: cp2, color: strokeColor) + drawSourceMarker(context: context, cardinality: item.edge.cardinality, at: srcPort, toward: cp1, color: strokeColor) + drawDestinationMarker(context: context, cardinality: item.edge.cardinality, at: dstPort, toward: cp2, color: strokeColor) } } + // MARK: - Cardinality Markers + + private static func drawSourceMarker( + context: GraphicsContext, + cardinality: ERCardinality, + at point: CGPoint, + toward target: CGPoint, + color: Color + ) { + switch cardinality { + case .oneToOne: + drawCompoundEndMarker(context: context, at: point, toward: target, isMany: false, isMandatory: true, color: color) + case .zeroOrOneToOne: + drawCompoundEndMarker(context: context, at: point, toward: target, isMany: false, isMandatory: false, color: color) + case .manyToOne: + drawCompoundEndMarker(context: context, at: point, toward: target, isMany: true, isMandatory: true, color: color) + case .zeroOrManyToOne: + drawCompoundEndMarker(context: context, at: point, toward: target, isMany: true, isMandatory: false, color: color) + case .manyToMany: + drawCrowFoot(context: context, at: point, toward: target, color: color) + default: + drawCompoundEndMarker(context: context, at: point, toward: target, isMany: true, isMandatory: true, color: color) + } + } + + private static func drawDestinationMarker( + context: GraphicsContext, + cardinality: ERCardinality, + at point: CGPoint, + toward target: CGPoint, + color: Color + ) { + switch cardinality { + case .manyToMany: + drawCrowFoot(context: context, at: point, toward: target, color: color) + default: + drawOneBar(context: context, at: point, toward: target, color: color) + } + } + + private static func drawCompoundEndMarker( + context: GraphicsContext, + at point: CGPoint, + toward target: CGPoint, + isMany: Bool, + isMandatory: Bool, + color: Color + ) { + if isMany { + drawCrowFoot(context: context, at: point, toward: target, color: color) + } else { + drawOneBar(context: context, at: point, toward: target, color: color) + } + + let angle = atan2(target.y - point.y, target.x - point.x) + let innerOffset: CGFloat = 14 + let innerPoint = CGPoint(x: point.x + innerOffset * cos(angle), y: point.y + innerOffset * sin(angle)) + + if isMandatory { + drawOneBar(context: context, at: innerPoint, toward: target, color: color) + } else { + drawCircle(context: context, at: innerPoint, color: color) + } + } + + private static func drawCircle(context: GraphicsContext, at point: CGPoint, color: Color) { + let radius: CGFloat = 3.5 + let rect = CGRect(x: point.x - radius, y: point.y - radius, width: radius * 2, height: radius * 2) + context.stroke(Path(ellipseIn: rect), with: .color(color), style: StrokeStyle(lineWidth: 1.5)) + } + // MARK: - Port Selection /// Top-to-bottom Sugiyama layout: edges exit from bottom, enter from top. diff --git a/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift b/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift index f95484671..560b328f9 100644 --- a/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift +++ b/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift @@ -70,7 +70,8 @@ enum ERDiagramNodeRenderer { anchor: .leading ) - let iconText = Text(Image(systemName: "tablecells")) + let iconName = node.isJunctionTable ? "arrow.left.arrow.right" : "tablecells" + let iconText = Text(Image(systemName: iconName)) .font(.system(size: Self.iconPointSize * scale)) .foregroundStyle(.secondary) context.draw( diff --git a/TablePro/Views/ERDiagram/ERDiagramToolbar.swift b/TablePro/Views/ERDiagram/ERDiagramToolbar.swift index bd33076a1..6e426fbd1 100644 --- a/TablePro/Views/ERDiagram/ERDiagramToolbar.swift +++ b/TablePro/Views/ERDiagram/ERDiagramToolbar.swift @@ -52,6 +52,16 @@ struct ERDiagramToolbar: View { .help(String(localized: "Compact Mode")) .accessibilityLabel(String(localized: "Compact Mode")) + if viewModel.hasJunctionTables { + Toggle(isOn: $viewModel.collapseJunctions) { + Image(systemName: "arrow.left.arrow.right") + } + .toggleStyle(.button) + .buttonStyle(.borderless) + .help(String(localized: "Collapse junction tables into many-to-many relationships")) + .accessibilityLabel(String(localized: "Collapse Junction Tables")) + } + Divider().frame(height: 16) Button { @@ -69,6 +79,15 @@ struct ERDiagramToolbar: View { .buttonStyle(.borderless) .help(String(localized: "Export as PNG")) .accessibilityLabel(String(localized: "Export as PNG")) + + Button { + viewModel.exportSchemaAsSQL() + } label: { + Image(systemName: "doc.plaintext") + } + .buttonStyle(.borderless) + .help(String(localized: "Export as SQL")) + .accessibilityLabel(String(localized: "Export as SQL")) } .padding(.horizontal, 12) .padding(.vertical, 6) diff --git a/TableProTests/Models/ERDiagram/ERDiagramGraphBuilderTests.swift b/TableProTests/Models/ERDiagram/ERDiagramGraphBuilderTests.swift new file mode 100644 index 000000000..e1c430a4d --- /dev/null +++ b/TableProTests/Models/ERDiagram/ERDiagramGraphBuilderTests.swift @@ -0,0 +1,297 @@ +// +// ERDiagramGraphBuilderTests.swift +// TableProTests +// +// Tests relationship cardinality inference and junction-table detection. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("ER diagram graph builder") +struct ERDiagramGraphBuilderTests { + 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 = "fk", + column: String, + references table: String, + _ refColumn: String = "id" + ) -> ForeignKeyInfo { + ForeignKeyInfo(name: name, column: column, referencedTable: table, referencedColumn: refColumn) + } + + private func uniqueIndex(_ name: String, columns: [String]) -> IndexInfo { + IndexInfo(name: name, columns: columns, isUnique: true, isPrimary: false, type: "BTREE") + } + + private func cardinality(from table: String, in graph: ERDiagramGraph) -> ERCardinality? { + graph.edges.first { $0.fromTable == table && $0.cardinality != .manyToMany }?.cardinality + } + + private func isJunction(_ table: String, in graph: ERDiagramGraph) -> Bool { + guard let node = graph.nodes.first(where: { $0.tableName == table }) else { return false } + return node.isJunctionTable + } + + // MARK: - Cardinality + + @Test("Primary-key foreign key that is NOT NULL is one-to-one") + func primaryKeyFKIsOneToOne() { + let graph = ERDiagramGraphBuilder.build( + allColumns: [ + "users": [column("id", primaryKey: true)], + "user_settings": [column("user_id", nullable: false, primaryKey: true)] + ], + allForeignKeys: ["user_settings": [foreignKey(column: "user_id", references: "users")]] + ) + #expect(cardinality(from: "user_settings", in: graph) == .oneToOne) + } + + @Test("Single-column unique index on a NOT NULL foreign key is one-to-one") + func uniqueIndexedFKIsOneToOne() { + let graph = ERDiagramGraphBuilder.build( + allColumns: [ + "users": [column("id", primaryKey: true)], + "profiles": [column("id", primaryKey: true), column("user_id", nullable: false)] + ], + allForeignKeys: ["profiles": [foreignKey(column: "user_id", references: "users")]], + allIndexes: ["profiles": [uniqueIndex("uq_user", columns: ["user_id"])]] + ) + #expect(cardinality(from: "profiles", in: graph) == .oneToOne) + } + + @Test("Unique index on a nullable foreign key is zero-or-one-to-one") + func nullableUniqueFKIsZeroOrOneToOne() { + let graph = ERDiagramGraphBuilder.build( + allColumns: [ + "users": [column("id", primaryKey: true)], + "profiles": [column("id", primaryKey: true), column("user_id", nullable: true)] + ], + allForeignKeys: ["profiles": [foreignKey(column: "user_id", references: "users")]], + allIndexes: ["profiles": [uniqueIndex("uq_user", columns: ["user_id"])]] + ) + #expect(cardinality(from: "profiles", in: graph) == .zeroOrOneToOne) + } + + @Test("Non-unique NOT NULL foreign key is many-to-one") + func notNullNonUniqueFKIsManyToOne() { + let graph = ERDiagramGraphBuilder.build( + allColumns: [ + "users": [column("id", primaryKey: true)], + "orders": [column("id", primaryKey: true), column("user_id", nullable: false)] + ], + allForeignKeys: ["orders": [foreignKey(column: "user_id", references: "users")]] + ) + #expect(cardinality(from: "orders", in: graph) == .manyToOne) + } + + @Test("Non-unique nullable foreign key is zero-or-many-to-one") + func nullableNonUniqueFKIsZeroOrManyToOne() { + let graph = ERDiagramGraphBuilder.build( + allColumns: [ + "users": [column("id", primaryKey: true)], + "orders": [column("id", primaryKey: true), column("user_id", nullable: true)] + ], + allForeignKeys: ["orders": [foreignKey(column: "user_id", references: "users")]] + ) + #expect(cardinality(from: "orders", in: graph) == .zeroOrManyToOne) + } + + @Test("Composite unique index does not make a single column one-to-one") + func compositeUniqueIndexIsNotOneToOne() { + let graph = ERDiagramGraphBuilder.build( + allColumns: [ + "users": [column("id", primaryKey: true)], + "memberships": [ + column("id", primaryKey: true), + column("user_id", nullable: false), + column("org_id", nullable: false) + ] + ], + allForeignKeys: ["memberships": [foreignKey(column: "user_id", references: "users")]], + allIndexes: ["memberships": [uniqueIndex("uq_user_org", columns: ["user_id", "org_id"])]] + ) + #expect(cardinality(from: "memberships", in: graph) == .manyToOne) + } + + @Test("A partial unique index does not make a column one-to-one") + func partialUniqueIndexIsNotOneToOne() { + let partialIndex = IndexInfo( + name: "uq_active_user", + columns: ["user_id"], + isUnique: true, + isPrimary: false, + type: "BTREE", + whereClause: "deleted_at IS NULL" + ) + let graph = ERDiagramGraphBuilder.build( + allColumns: [ + "users": [column("id", primaryKey: true)], + "profiles": [column("id", primaryKey: true), column("user_id", nullable: false)] + ], + allForeignKeys: ["profiles": [foreignKey(column: "user_id", references: "users")]], + allIndexes: ["profiles": [partialIndex]] + ) + #expect(cardinality(from: "profiles", in: graph) == .manyToOne) + } + + @Test("A foreign key that is only part of a composite primary key is many-to-one") + func compositePrimaryKeyMemberIsNotOneToOne() { + let graph = ERDiagramGraphBuilder.build( + allColumns: [ + "users": [column("id", primaryKey: true)], + "audit": [ + column("user_id", nullable: false, primaryKey: true), + column("seq", nullable: false, primaryKey: true) + ] + ], + allForeignKeys: ["audit": [foreignKey(column: "user_id", references: "users")]] + ) + #expect(cardinality(from: "audit", in: graph) == .manyToOne) + } + + @Test("Junction edges are many-to-one in the expanded graph") + func junctionEdgesAreManyToOne() { + let graph = ERDiagramGraphBuilder.build( + allColumns: [ + "users": [column("id", primaryKey: true)], + "roles": [column("id", primaryKey: true)], + "user_roles": [ + column("user_id", nullable: false, primaryKey: true), + column("role_id", nullable: false, primaryKey: true) + ] + ], + allForeignKeys: ["user_roles": [ + foreignKey("fk_user", column: "user_id", references: "users"), + foreignKey("fk_role", column: "role_id", references: "roles") + ]] + ) + let junctionEdges = graph.edges.filter { $0.fromTable == "user_roles" } + #expect(junctionEdges.count == 2) + #expect(junctionEdges.allSatisfy { $0.cardinality == .manyToOne }) + } + + @Test("Missing column metadata falls back to zero-or-many-to-one") + func missingColumnFallsBack() { + let graph = ERDiagramGraphBuilder.build( + allColumns: [ + "users": [column("id", primaryKey: true)], + "orders": [column("id", primaryKey: true)] + ], + allForeignKeys: ["orders": [foreignKey(column: "user_id", references: "users")]] + ) + #expect(cardinality(from: "orders", in: graph) == .zeroOrManyToOne) + } + + // MARK: - Junction detection + + @Test("A table whose composite PK is two FKs is a junction table") + func junctionTableDetected() { + 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("fk_user", column: "user_id", references: "users"), + foreignKey("fk_role", column: "role_id", references: "roles") + ]] + ) + #expect(isJunction("user_roles", in: graph)) + #expect(graph.manyToManyEdges.count == 1) + let mn = graph.manyToManyEdges.first + #expect(mn?.cardinality == .manyToMany) + #expect([mn?.fromTable, mn?.toTable].compactMap { $0 }.sorted() == ["roles", "users"]) + } + + @Test("Composite PK with only one FK column is not a junction table") + func partialFKCompositePKIsNotJunction() { + let graph = ERDiagramGraphBuilder.build( + allColumns: [ + "users": [column("id", primaryKey: true)], + "audit": [ + column("user_id", primaryKey: true), + column("seq", primaryKey: true) + ] + ], + allForeignKeys: ["audit": [foreignKey("fk_user", column: "user_id", references: "users")]] + ) + #expect(!isJunction("audit", in: graph)) + #expect(graph.manyToManyEdges.isEmpty) + } + + @Test("Two FK columns referencing the same table is not a junction table") + func twoFKsSameTargetIsNotJunction() { + let graph = ERDiagramGraphBuilder.build( + allColumns: [ + "users": [column("id", primaryKey: true)], + "friendships": [ + column("from_user", primaryKey: true), + column("to_user", primaryKey: true) + ] + ], + allForeignKeys: ["friendships": [ + foreignKey("fk_from", column: "from_user", references: "users"), + foreignKey("fk_to", column: "to_user", references: "users") + ]] + ) + #expect(!isJunction("friendships", in: graph)) + #expect(graph.manyToManyEdges.isEmpty) + } + + @Test("A regular table with a non-PK foreign key is not a junction table") + func regularTableIsNotJunction() { + let graph = ERDiagramGraphBuilder.build( + allColumns: [ + "users": [column("id", primaryKey: true)], + "orders": [column("id", primaryKey: true), column("user_id", nullable: false)] + ], + allForeignKeys: ["orders": [foreignKey(column: "user_id", references: "users")]] + ) + #expect(!isJunction("orders", in: graph)) + #expect(graph.manyToManyEdges.isEmpty) + } + + // MARK: - Projection + + @Test("Collapsing junctions hides the junction node and shows a many-to-many edge") + func collapsedProjectionHidesJunction() { + 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("fk_user", column: "user_id", references: "users"), + foreignKey("fk_role", column: "role_id", references: "roles") + ]] + ) + + let collapsed = graph.projected(collapseJunctions: true) + #expect(!collapsed.nodes.contains { $0.tableName == "user_roles" }) + #expect(collapsed.edges.count == 1) + #expect(collapsed.edges.first?.cardinality == .manyToMany) + + let expanded = graph.projected(collapseJunctions: false) + #expect(expanded.nodes.contains { $0.tableName == "user_roles" }) + #expect(expanded.edges.count == 2) + #expect(!expanded.edges.contains { $0.cardinality == .manyToMany }) + } +} diff --git a/TableProTests/Models/ERDiagram/ERDiagramSQLExporterTests.swift b/TableProTests/Models/ERDiagram/ERDiagramSQLExporterTests.swift new file mode 100644 index 000000000..6db00a4fb --- /dev/null +++ b/TableProTests/Models/ERDiagram/ERDiagramSQLExporterTests.swift @@ -0,0 +1,284 @@ +// +// ERDiagramSQLExporterTests.swift +// TableProTests +// +// Tests CREATE TABLE + FOREIGN KEY DDL generation from diagram metadata. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("ER diagram SQL exporter") +struct ERDiagramSQLExporterTests { + private let quote: (String) -> String = { "\"\($0)\"" } + + private func column( + _ name: String, + type: String = "integer", + nullable: Bool = false, + primaryKey: Bool = false, + defaultValue: String? = nil + ) -> ColumnInfo { + ColumnInfo( + name: name, + dataType: type, + isNullable: nullable, + isPrimaryKey: primaryKey, + defaultValue: defaultValue + ) + } + + private func foreignKey( + _ name: String, + column: String, + references table: String, + _ refColumn: String = "id", + onDelete: String = "NO ACTION", + onUpdate: String = "NO ACTION" + ) -> ForeignKeyInfo { + ForeignKeyInfo( + name: name, + column: column, + referencedTable: table, + referencedColumn: refColumn, + onDelete: onDelete, + onUpdate: onUpdate + ) + } + + @Test("Generates a CREATE TABLE statement") + func createTableBasic() { + let sql = ERDiagramSQLExporter.generate( + tableNames: ["users"], + allColumns: ["users": [column("id", primaryKey: true), column("name", type: "varchar", nullable: true)]], + allForeignKeys: [:], + isSQLite: false, + quoteIdentifier: quote + ) + #expect(sql.contains("CREATE TABLE \"users\" (")) + #expect(sql.contains("\"id\" integer NOT NULL PRIMARY KEY")) + #expect(sql.contains("\"name\" varchar")) + #expect(sql.hasSuffix(");")) + } + + @Test("Nullable columns omit NOT NULL") + func nullableColumnOmitsNotNull() { + let sql = ERDiagramSQLExporter.generate( + tableNames: ["users"], + allColumns: ["users": [column("name", type: "varchar", nullable: true)]], + allForeignKeys: [:], + isSQLite: false, + quoteIdentifier: quote + ) + #expect(!sql.contains("\"name\" varchar NOT NULL")) + } + + @Test("DEFAULT clause is emitted when present") + func defaultValueEmitted() { + let sql = ERDiagramSQLExporter.generate( + tableNames: ["t"], + allColumns: ["t": [column("status", type: "varchar", nullable: false, defaultValue: "'active'")]], + allForeignKeys: [:], + isSQLite: false, + quoteIdentifier: quote + ) + #expect(sql.contains("\"status\" varchar NOT NULL DEFAULT 'active'")) + } + + @Test("Unquoted string default is quoted") + func unquotedStringDefaultIsQuoted() { + let sql = ERDiagramSQLExporter.generate( + tableNames: ["t"], + allColumns: ["t": [column("status", type: "varchar", nullable: false, defaultValue: "active")]], + allForeignKeys: [:], + isSQLite: false, + quoteIdentifier: quote + ) + #expect(sql.contains("\"status\" varchar NOT NULL DEFAULT 'active'")) + #expect(!sql.contains("DEFAULT active")) + } + + @Test("Numeric, expression, and keyword defaults pass through unquoted") + func nonLiteralDefaultsPassThrough() { + let sql = ERDiagramSQLExporter.generate( + tableNames: ["t"], + allColumns: ["t": [ + column("count", type: "integer", nullable: false, defaultValue: "0"), + column("created", type: "timestamp", nullable: false, defaultValue: "CURRENT_TIMESTAMP"), + column("uid", type: "uuid", nullable: false, defaultValue: "gen_random_uuid()") + ]], + allForeignKeys: [:], + isSQLite: false, + quoteIdentifier: quote + ) + #expect(sql.contains("\"count\" integer NOT NULL DEFAULT 0")) + #expect(sql.contains("\"created\" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP")) + #expect(sql.contains("\"uid\" uuid NOT NULL DEFAULT gen_random_uuid()")) + } + + @Test("Non-finite numeric-looking string defaults are quoted") + func infinityLikeStringDefaultIsQuoted() { + let sql = ERDiagramSQLExporter.generate( + tableNames: ["t"], + allColumns: ["t": [ + column("a", type: "varchar", nullable: false, defaultValue: "inf"), + column("b", type: "varchar", nullable: false, defaultValue: "nan") + ]], + allForeignKeys: [:], + isSQLite: false, + quoteIdentifier: quote + ) + #expect(sql.contains("\"a\" varchar NOT NULL DEFAULT 'inf'")) + #expect(sql.contains("\"b\" varchar NOT NULL DEFAULT 'nan'")) + #expect(!sql.contains("DEFAULT inf")) + #expect(!sql.contains("DEFAULT nan")) + } + + @Test("Composite primary key becomes a trailing clause") + func compositePrimaryKey() { + let sql = ERDiagramSQLExporter.generate( + tableNames: ["t"], + allColumns: ["t": [column("a", primaryKey: true), column("b", primaryKey: true)]], + allForeignKeys: [:], + isSQLite: false, + quoteIdentifier: quote + ) + #expect(sql.contains("PRIMARY KEY (\"a\", \"b\")")) + #expect(!sql.contains("\"a\" integer NOT NULL PRIMARY KEY")) + } + + @Test("Single primary key is inline on the column") + func singlePrimaryKeyInline() { + let sql = ERDiagramSQLExporter.generate( + tableNames: ["t"], + allColumns: ["t": [column("id", primaryKey: true)]], + allForeignKeys: [:], + isSQLite: false, + quoteIdentifier: quote + ) + #expect(sql.contains("\"id\" integer NOT NULL PRIMARY KEY")) + #expect(!sql.contains("PRIMARY KEY (")) + } + + @Test("SQLite inlines foreign keys and emits no ALTER TABLE") + func sqliteInlineForeignKey() { + let sql = ERDiagramSQLExporter.generate( + tableNames: ["users", "orders"], + allColumns: [ + "users": [column("id", primaryKey: true)], + "orders": [column("id", primaryKey: true), column("user_id")] + ], + allForeignKeys: ["orders": [foreignKey("fk_user", column: "user_id", references: "users")]], + isSQLite: true, + quoteIdentifier: quote + ) + #expect(sql.contains("FOREIGN KEY (\"user_id\") REFERENCES \"users\" (\"id\")")) + #expect(!sql.contains("ALTER TABLE")) + } + + @Test("Non-SQLite emits ALTER TABLE ADD CONSTRAINT for foreign keys") + func nonSQLiteAlterForeignKey() { + let sql = ERDiagramSQLExporter.generate( + tableNames: ["users", "orders"], + allColumns: [ + "users": [column("id", primaryKey: true)], + "orders": [column("id", primaryKey: true), column("user_id")] + ], + allForeignKeys: ["orders": [foreignKey("fk_user", column: "user_id", references: "users")]], + isSQLite: false, + quoteIdentifier: quote + ) + #expect(sql.contains( + "ALTER TABLE \"orders\" ADD CONSTRAINT \"fk_user\" FOREIGN KEY (\"user_id\") REFERENCES \"users\" (\"id\");" + )) + } + + @Test("All CREATE TABLE statements precede ALTER TABLE for circular foreign keys") + func circularForeignKeyOrdering() { + let sql = ERDiagramSQLExporter.generate( + tableNames: ["a", "b"], + allColumns: [ + "a": [column("id", primaryKey: true), column("b_id")], + "b": [column("id", primaryKey: true), column("a_id")] + ], + allForeignKeys: [ + "a": [foreignKey("fk_a_b", column: "b_id", references: "b")], + "b": [foreignKey("fk_b_a", column: "a_id", references: "a")] + ], + isSQLite: false, + quoteIdentifier: quote + ) + let firstAlter = sql.range(of: "ALTER TABLE") + let lastCreate = sql.range(of: "CREATE TABLE", options: .backwards) + #expect(firstAlter != nil) + #expect(lastCreate != nil) + if let firstAlter, let lastCreate { + #expect(lastCreate.lowerBound < firstAlter.lowerBound) + } + } + + @Test("Composite foreign key is grouped into one constraint") + func compositeForeignKeyGrouped() { + let sql = ERDiagramSQLExporter.generate( + tableNames: ["parent", "child"], + allColumns: [ + "parent": [column("p1", primaryKey: true), column("p2", primaryKey: true)], + "child": [column("id", primaryKey: true), column("c1"), column("c2")] + ], + allForeignKeys: ["child": [ + foreignKey("fk_x", column: "c1", references: "parent", "p1"), + foreignKey("fk_x", column: "c2", references: "parent", "p2") + ]], + isSQLite: false, + quoteIdentifier: quote + ) + #expect(sql.contains( + "ADD CONSTRAINT \"fk_x\" FOREIGN KEY (\"c1\", \"c2\") REFERENCES \"parent\" (\"p1\", \"p2\");" + )) + } + + @Test("ON DELETE CASCADE is included") + func onDeleteCascadeIncluded() { + let sql = ERDiagramSQLExporter.generate( + tableNames: ["users", "orders"], + allColumns: [ + "users": [column("id", primaryKey: true)], + "orders": [column("id", primaryKey: true), column("user_id")] + ], + allForeignKeys: ["orders": [foreignKey("fk_user", column: "user_id", references: "users", onDelete: "CASCADE")]], + isSQLite: false, + quoteIdentifier: quote + ) + #expect(sql.contains("ON DELETE CASCADE")) + } + + @Test("NO ACTION referential actions are omitted") + func noActionOmitted() { + let sql = ERDiagramSQLExporter.generate( + tableNames: ["users", "orders"], + allColumns: [ + "users": [column("id", primaryKey: true)], + "orders": [column("id", primaryKey: true), column("user_id")] + ], + allForeignKeys: ["orders": [foreignKey("fk_user", column: "user_id", references: "users")]], + isSQLite: false, + quoteIdentifier: quote + ) + #expect(!sql.contains("ON DELETE")) + #expect(!sql.contains("ON UPDATE")) + } + + @Test("Tables with no columns are skipped") + func emptyTableSkipped() { + let sql = ERDiagramSQLExporter.generate( + tableNames: ["users", "ghost"], + allColumns: ["users": [column("id", primaryKey: true)]], + allForeignKeys: [:], + isSQLite: false, + quoteIdentifier: quote + ) + #expect(sql.contains("CREATE TABLE \"users\"")) + #expect(!sql.contains("\"ghost\"")) + } +} diff --git a/docs/features/er-diagram.mdx b/docs/features/er-diagram.mdx index 4d69ac31f..af1b08ea2 100644 --- a/docs/features/er-diagram.mdx +++ b/docs/features/er-diagram.mdx @@ -57,17 +57,29 @@ Toggle compact mode with the filter button in the toolbar. In compact mode, each ## Edge Notation -Edges use crow's foot notation: +Edges use crow's foot notation. The referenced table always carries a single **bar** ("exactly one"). The foreign key side shows the relationship's cardinality, inferred from the column's primary key and unique index data: -- A **fork** (three lines) marks the "many" side of the relationship (the table that holds the foreign key) -- A **bar** (single perpendicular line) marks the "one" side (the referenced table) +| Foreign key column | Marker | Meaning | +|--------------------|--------|---------| +| Unique and NOT NULL | bar + bar | One-to-one | +| Unique and nullable | bar + circle | Zero-or-one-to-one | +| Not unique, NOT NULL | fork + bar | One-to-many | +| Not unique, nullable | fork + circle | Zero-or-many-to-one | -For example, an edge from `orders.user_id` to `users.id` has the fork at `orders` and the bar at `users`. +A **fork** (three lines) marks the "many" side, a **bar** marks "one", and a **circle** marks an optional (nullable) side. For example, an edge from `orders.user_id` to `users.id` shows a fork at `orders` and a bar at `users`. + +## Junction Tables + +When a table's primary key is made of two foreign keys (a junction or associative table, like `user_roles`), TablePro treats it as a many-to-many relationship. By default the junction table is hidden and a single many-to-many edge is drawn between the two related tables, with a fork at each end. + +Use the **junction toggle** in the toolbar (shown only when the schema has junction tables) to expand them back to the underlying table and its two one-to-many edges. Junction tables carry a distinct header icon when expanded. ## Export Click the **export button** in the toolbar to save the diagram as a PNG image. You can also press `Cmd C` to copy the diagram to the clipboard. +Click the **Export as SQL** button to open a new query tab with `CREATE TABLE` and foreign key statements for the diagram's tables, written in the connection's SQL dialect. SQLite inlines foreign keys in each `CREATE TABLE`; other databases add them with `ALTER TABLE ... ADD CONSTRAINT` after the tables are created, so the script handles circular references. + ## Database Support ER diagrams work with any database that supports foreign key introspection: