Skip to content

Commit ec78a26

Browse files
authored
feat(er-diagram): infer relationship cardinality and export schema to SQL (#1335) (#1759)
* feat(er-diagram): infer relationship cardinality and export schema to SQL (#1335) Claude-Session: https://claude.ai/code/session_01KVYHFmY2TShriMzSyxFwcZ * fix(er-diagram): correct composite-PK cardinality, quote DDL defaults, narrow index fetch (#1335) Claude-Session: https://claude.ai/code/session_01KVYHFmY2TShriMzSyxFwcZ * fix(er-diagram): ignore partial unique indexes, quote non-finite defaults, persist junction positions (#1335) Claude-Session: https://claude.ai/code/session_01KVYHFmY2TShriMzSyxFwcZ
1 parent e061b29 commit ec78a26

11 files changed

Lines changed: 1074 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- 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)
1414
- 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)
1515
- 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)
16+
- 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)
17+
- 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)
1618

1719
### Changed
1820

TablePro/Core/Database/DatabaseDriver.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,20 @@ extension DatabaseDriver {
340340
return all.filter { nameSet.contains($0.key) }
341341
}
342342

343+
func fetchIndexes(forTables tableNames: [String]) async throws -> [String: [IndexInfo]] {
344+
var result: [String: [IndexInfo]] = [:]
345+
for tableName in tableNames {
346+
do {
347+
let indexes = try await fetchIndexes(table: tableName)
348+
if !indexes.isEmpty { result[tableName] = indexes }
349+
} catch {
350+
Logger(subsystem: "com.TablePro", category: "DatabaseDriver")
351+
.debug("Failed to fetch indexes for \(tableName): \(error.localizedDescription)")
352+
}
353+
}
354+
return result
355+
}
356+
343357
/// Default fetchAllColumns: falls back to per-table fetchColumns (N+1).
344358
/// Drivers should override with a single bulk query where possible.
345359
func fetchAllColumns() async throws -> [String: [ColumnInfo]] {

TablePro/Models/ERDiagram/ERDiagramModels.swift

Lines changed: 134 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ struct ERTableNode: Identifiable, Sendable {
99
let columns: [ERColumnDisplay]
1010
var displayColumns: [ERColumnDisplay]
1111
var clusterId: Int?
12+
var isJunctionTable: Bool = false
1213
}
1314

1415
struct ERColumnDisplay: Identifiable, Sendable {
@@ -23,7 +24,11 @@ struct ERColumnDisplay: Identifiable, Sendable {
2324
// MARK: - Edge
2425

2526
enum ERCardinality: Sendable {
27+
case oneToOne
28+
case zeroOrOneToOne
2629
case manyToOne
30+
case zeroOrManyToOne
31+
case manyToMany
2732
}
2833

2934
struct EREdge: Identifiable, Sendable {
@@ -42,23 +47,65 @@ struct ERDiagramGraph: Sendable {
4247
var nodes: [ERTableNode]
4348
var edges: [EREdge]
4449
var nodeIndex: [String: UUID]
50+
var junctionTableIds: Set<UUID> = []
51+
var manyToManyEdges: [EREdge] = []
4552

4653
static let empty = ERDiagramGraph(nodes: [], edges: [], nodeIndex: [:])
54+
55+
func projected(collapseJunctions: Bool) -> ERDiagramGraph {
56+
guard collapseJunctions, !junctionTableIds.isEmpty else { return self }
57+
58+
let visibleNodes = nodes.filter { !junctionTableIds.contains($0.id) }
59+
let visibleNodeIndex = visibleNodes.reduce(into: [String: UUID]()) { result, node in
60+
result[node.tableName] = node.id
61+
}
62+
let visibleEdges = edges.filter { edge in
63+
guard let fromId = nodeIndex[edge.fromTable], let toId = nodeIndex[edge.toTable] else { return false }
64+
return !junctionTableIds.contains(fromId) && !junctionTableIds.contains(toId)
65+
}
66+
67+
return ERDiagramGraph(
68+
nodes: visibleNodes,
69+
edges: visibleEdges + manyToManyEdges,
70+
nodeIndex: visibleNodeIndex,
71+
junctionTableIds: junctionTableIds,
72+
manyToManyEdges: manyToManyEdges
73+
)
74+
}
4775
}
4876

4977
// MARK: - Graph Builder
5078

5179
enum ERDiagramGraphBuilder {
5280
static func build(
5381
allColumns: [String: [ColumnInfo]],
54-
allForeignKeys: [String: [ForeignKeyInfo]]
82+
allForeignKeys: [String: [ForeignKeyInfo]],
83+
allIndexes: [String: [IndexInfo]] = [:]
5584
) -> ERDiagramGraph {
5685
var nodeIndex: [String: UUID] = [:]
5786
var nodes: [ERTableNode] = []
5887

5988
let fkColumnsByTable: [String: Set<String>] = allForeignKeys.mapValues { fks in
6089
Set(fks.map(\.column))
6190
}
91+
let columnsByTable: [String: [String: ColumnInfo]] = allColumns.mapValues { columns in
92+
Dictionary(columns.map { ($0.name, $0) }, uniquingKeysWith: { first, _ in first })
93+
}
94+
let uniqueSingleColumnsByTable: [String: Set<String>] = allColumns.reduce(into: [:]) { result, entry in
95+
let (tableName, columns) = entry
96+
var unique: Set<String> = []
97+
let primaryKeyColumns = columns.filter(\.isPrimaryKey).map(\.name)
98+
if primaryKeyColumns.count == 1, let only = primaryKeyColumns.first {
99+
unique.insert(only)
100+
}
101+
for index in allIndexes[tableName] ?? []
102+
where index.isUnique && index.whereClause == nil && index.columns.count == 1 {
103+
if let column = index.columns.first { unique.insert(column) }
104+
}
105+
result[tableName] = unique
106+
}
107+
108+
var junctionTableIds: Set<UUID> = []
62109

63110
for tableName in allColumns.keys.sorted() {
64111
let id = stableId(for: tableName)
@@ -78,12 +125,20 @@ enum ERDiagramGraphBuilder {
78125
)
79126
}
80127

128+
let isJunction = junctionParents(
129+
tableName: tableName,
130+
columns: columns,
131+
foreignKeys: allForeignKeys[tableName] ?? []
132+
) != nil
133+
if isJunction { junctionTableIds.insert(id) }
134+
81135
nodes.append(ERTableNode(
82136
id: id,
83137
tableName: tableName,
84138
columns: displayColumns,
85139
displayColumns: displayColumns,
86-
clusterId: nil
140+
clusterId: nil,
141+
isJunctionTable: isJunction
87142
))
88143
}
89144

@@ -105,19 +160,94 @@ enum ERDiagramGraphBuilder {
105160
fromColumn: fk.column,
106161
toTable: fk.referencedTable,
107162
toColumn: fk.referencedColumn,
108-
cardinality: .manyToOne
163+
cardinality: inferCardinality(
164+
column: columnsByTable[tableName]?[fk.column],
165+
uniqueColumns: uniqueSingleColumnsByTable[tableName] ?? []
166+
)
109167
))
110168
}
111169
}
112170

171+
let manyToManyEdges = buildManyToManyEdges(
172+
allColumns: allColumns,
173+
allForeignKeys: allForeignKeys,
174+
nodeIndex: nodeIndex
175+
)
176+
113177
let clusters = ERClusterAnalyzer.assignClusters(nodes: nodes, edges: edges, nodeIndex: nodeIndex)
114178
let clusteredNodes = nodes.map { node -> ERTableNode in
115179
var updated = node
116180
updated.clusterId = clusters[node.id]
117181
return updated
118182
}
119183

120-
return ERDiagramGraph(nodes: clusteredNodes, edges: edges, nodeIndex: nodeIndex)
184+
return ERDiagramGraph(
185+
nodes: clusteredNodes,
186+
edges: edges,
187+
nodeIndex: nodeIndex,
188+
junctionTableIds: junctionTableIds,
189+
manyToManyEdges: manyToManyEdges
190+
)
191+
}
192+
193+
private static func inferCardinality(column: ColumnInfo?, uniqueColumns: Set<String>) -> ERCardinality {
194+
guard let column else { return .zeroOrManyToOne }
195+
let isUnique = uniqueColumns.contains(column.name)
196+
let isMandatory = !column.isNullable
197+
switch (isUnique, isMandatory) {
198+
case (true, true): return .oneToOne
199+
case (true, false): return .zeroOrOneToOne
200+
case (false, true): return .manyToOne
201+
case (false, false): return .zeroOrManyToOne
202+
}
203+
}
204+
205+
private static func junctionParents(
206+
tableName: String,
207+
columns: [ColumnInfo],
208+
foreignKeys: [ForeignKeyInfo]
209+
) -> (String, String)? {
210+
let pkColumns = Set(columns.filter { $0.isPrimaryKey }.map(\.name))
211+
guard pkColumns.count >= 2 else { return nil }
212+
213+
let fkColumns = Set(foreignKeys.map(\.column))
214+
guard pkColumns.isSubset(of: fkColumns) else { return nil }
215+
216+
var orderedParents: [String] = []
217+
for fk in foreignKeys where pkColumns.contains(fk.column) {
218+
if !orderedParents.contains(fk.referencedTable) {
219+
orderedParents.append(fk.referencedTable)
220+
}
221+
}
222+
guard orderedParents.count == 2 else { return nil }
223+
return (orderedParents[0], orderedParents[1])
224+
}
225+
226+
private static func buildManyToManyEdges(
227+
allColumns: [String: [ColumnInfo]],
228+
allForeignKeys: [String: [ForeignKeyInfo]],
229+
nodeIndex: [String: UUID]
230+
) -> [EREdge] {
231+
var edges: [EREdge] = []
232+
for tableName in allColumns.keys.sorted() {
233+
guard let (parentA, parentB) = junctionParents(
234+
tableName: tableName,
235+
columns: allColumns[tableName] ?? [],
236+
foreignKeys: allForeignKeys[tableName] ?? []
237+
) else { continue }
238+
guard nodeIndex[parentA] != nil, nodeIndex[parentB] != nil else { continue }
239+
240+
edges.append(EREdge(
241+
id: stableId(for: "mn.\(tableName)"),
242+
fkName: tableName,
243+
fromTable: parentA,
244+
fromColumn: "",
245+
toTable: parentB,
246+
toColumn: "",
247+
cardinality: .manyToMany
248+
))
249+
}
250+
return edges
121251
}
122252

123253
private static func stableId(for name: String) -> UUID {
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import Foundation
2+
3+
enum ERDiagramSQLExporter {
4+
static func generate(
5+
tableNames: [String],
6+
allColumns: [String: [ColumnInfo]],
7+
allForeignKeys: [String: [ForeignKeyInfo]],
8+
isSQLite: Bool,
9+
quoteIdentifier: (String) -> String
10+
) -> String {
11+
let orderedTables = tableNames.sorted()
12+
let exportedTables = Set(orderedTables)
13+
14+
var statements: [String] = []
15+
16+
for tableName in orderedTables {
17+
guard let columns = allColumns[tableName], !columns.isEmpty else { continue }
18+
let inlineForeignKeys = isSQLite
19+
? (allForeignKeys[tableName] ?? []).filter { exportedTables.contains($0.referencedTable) }
20+
: []
21+
statements.append(createTableStatement(
22+
tableName: tableName,
23+
columns: columns,
24+
inlineForeignKeys: inlineForeignKeys,
25+
quoteIdentifier: quoteIdentifier
26+
))
27+
}
28+
29+
if !isSQLite {
30+
for tableName in orderedTables {
31+
guard allColumns[tableName]?.isEmpty == false else { continue }
32+
let foreignKeys = (allForeignKeys[tableName] ?? []).filter { exportedTables.contains($0.referencedTable) }
33+
for group in groupByConstraintName(foreignKeys) {
34+
statements.append(alterTableForeignKeyStatement(
35+
tableName: tableName,
36+
group: group,
37+
quoteIdentifier: quoteIdentifier
38+
))
39+
}
40+
}
41+
}
42+
43+
return statements.joined(separator: "\n\n")
44+
}
45+
46+
private static func createTableStatement(
47+
tableName: String,
48+
columns: [ColumnInfo],
49+
inlineForeignKeys: [ForeignKeyInfo],
50+
quoteIdentifier: (String) -> String
51+
) -> String {
52+
let primaryKeyColumns = columns.filter(\.isPrimaryKey).map(\.name)
53+
let singleColumnPrimaryKey = primaryKeyColumns.count == 1 ? primaryKeyColumns.first : nil
54+
55+
var lines = columns.map { column in
56+
columnDefinition(
57+
column: column,
58+
inlinePrimaryKey: column.name == singleColumnPrimaryKey,
59+
quoteIdentifier: quoteIdentifier
60+
)
61+
}
62+
63+
if primaryKeyColumns.count > 1 {
64+
let cols = primaryKeyColumns.map(quoteIdentifier).joined(separator: ", ")
65+
lines.append("PRIMARY KEY (\(cols))")
66+
}
67+
68+
for group in groupByConstraintName(inlineForeignKeys) {
69+
lines.append(inlineForeignKeyClause(group: group, quoteIdentifier: quoteIdentifier))
70+
}
71+
72+
let body = lines.map { " \($0)" }.joined(separator: ",\n")
73+
return "CREATE TABLE \(quoteIdentifier(tableName)) (\n\(body)\n);"
74+
}
75+
76+
private static func columnDefinition(
77+
column: ColumnInfo,
78+
inlinePrimaryKey: Bool,
79+
quoteIdentifier: (String) -> String
80+
) -> String {
81+
var definition = "\(quoteIdentifier(column.name)) \(column.dataType)"
82+
if !column.isNullable {
83+
definition += " NOT NULL"
84+
}
85+
if let defaultValue = column.defaultValue, !defaultValue.isEmpty {
86+
definition += " DEFAULT \(formatDefaultValue(defaultValue))"
87+
}
88+
if inlinePrimaryKey {
89+
definition += " PRIMARY KEY"
90+
}
91+
return definition
92+
}
93+
94+
private static func formatDefaultValue(_ value: String) -> String {
95+
let trimmed = value.trimmingCharacters(in: .whitespaces)
96+
let passthroughKeywords: Set<String> = [
97+
"NULL", "TRUE", "FALSE",
98+
"CURRENT_TIMESTAMP", "CURRENT_TIMESTAMP()",
99+
"CURRENT_DATE", "CURRENT_TIME", "NOW()", "LOCALTIMESTAMP"
100+
]
101+
if passthroughKeywords.contains(trimmed.uppercased()) { return trimmed }
102+
if trimmed.hasPrefix("'") { return trimmed }
103+
if trimmed.contains("(") || trimmed.contains("::") { return trimmed }
104+
if Int64(trimmed) != nil { return trimmed }
105+
if let number = Double(trimmed), number.isFinite { return trimmed }
106+
let escaped = trimmed.replacingOccurrences(of: "'", with: "''")
107+
return "'\(escaped)'"
108+
}
109+
110+
private static func inlineForeignKeyClause(
111+
group: [ForeignKeyInfo],
112+
quoteIdentifier: (String) -> String
113+
) -> String {
114+
let cols = group.map { quoteIdentifier($0.column) }.joined(separator: ", ")
115+
let refCols = group.map { quoteIdentifier($0.referencedColumn) }.joined(separator: ", ")
116+
let refTable = quoteIdentifier(group[0].referencedTable)
117+
var clause = "FOREIGN KEY (\(cols)) REFERENCES \(refTable) (\(refCols))"
118+
clause += referentialActions(group[0])
119+
return clause
120+
}
121+
122+
private static func alterTableForeignKeyStatement(
123+
tableName: String,
124+
group: [ForeignKeyInfo],
125+
quoteIdentifier: (String) -> String
126+
) -> String {
127+
let cols = group.map { quoteIdentifier($0.column) }.joined(separator: ", ")
128+
let refCols = group.map { quoteIdentifier($0.referencedColumn) }.joined(separator: ", ")
129+
let refTable = quoteIdentifier(group[0].referencedTable)
130+
let constraintName = quoteIdentifier(group[0].name)
131+
var statement = "ALTER TABLE \(quoteIdentifier(tableName)) ADD CONSTRAINT \(constraintName)"
132+
statement += " FOREIGN KEY (\(cols)) REFERENCES \(refTable) (\(refCols))"
133+
statement += referentialActions(group[0])
134+
return statement + ";"
135+
}
136+
137+
private static func referentialActions(_ foreignKey: ForeignKeyInfo) -> String {
138+
var actions = ""
139+
let onDelete = foreignKey.onDelete.uppercased()
140+
let onUpdate = foreignKey.onUpdate.uppercased()
141+
if onDelete != "NO ACTION" { actions += " ON DELETE \(onDelete)" }
142+
if onUpdate != "NO ACTION" { actions += " ON UPDATE \(onUpdate)" }
143+
return actions
144+
}
145+
146+
private static func groupByConstraintName(_ foreignKeys: [ForeignKeyInfo]) -> [[ForeignKeyInfo]] {
147+
var orderedNames: [String] = []
148+
var groups: [String: [ForeignKeyInfo]] = [:]
149+
for foreignKey in foreignKeys {
150+
if groups[foreignKey.name] == nil {
151+
orderedNames.append(foreignKey.name)
152+
}
153+
groups[foreignKey.name, default: []].append(foreignKey)
154+
}
155+
return orderedNames.compactMap { groups[$0] }
156+
}
157+
}

0 commit comments

Comments
 (0)