Skip to content

Commit e8879eb

Browse files
committed
fix(er-diagram): correct composite-PK cardinality, quote DDL defaults, narrow index fetch (#1335)
Claude-Session: https://claude.ai/code/session_01KVYHFmY2TShriMzSyxFwcZ
1 parent 46399e4 commit e8879eb

6 files changed

Lines changed: 101 additions & 11 deletions

File tree

TablePro/Core/Database/DatabaseDriver.swift

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

343-
func fetchAllIndexes() async throws -> [String: [IndexInfo]] {
344-
let allTables = try await fetchTables()
343+
func fetchIndexes(forTables tableNames: [String]) async throws -> [String: [IndexInfo]] {
345344
var result: [String: [IndexInfo]] = [:]
346-
for table in allTables {
345+
for tableName in tableNames {
347346
do {
348-
let indexes = try await fetchIndexes(table: table.name)
349-
if !indexes.isEmpty { result[table.name] = indexes }
347+
let indexes = try await fetchIndexes(table: tableName)
348+
if !indexes.isEmpty { result[tableName] = indexes }
350349
} catch {
351350
Logger(subsystem: "com.TablePro", category: "DatabaseDriver")
352-
.debug("Failed to fetch indexes for \(table.name): \(error.localizedDescription)")
351+
.debug("Failed to fetch indexes for \(tableName): \(error.localizedDescription)")
353352
}
354353
}
355354
return result

TablePro/Models/ERDiagram/ERDiagramModels.swift

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,17 @@ enum ERDiagramGraphBuilder {
9191
let columnsByTable: [String: [String: ColumnInfo]] = allColumns.mapValues { columns in
9292
Dictionary(columns.map { ($0.name, $0) }, uniquingKeysWith: { first, _ in first })
9393
}
94-
let uniqueSingleColumnsByTable: [String: Set<String>] = allIndexes.mapValues { indexes in
95-
Set(indexes.filter { $0.isUnique && $0.columns.count == 1 }.compactMap { $0.columns.first })
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] ?? [] where index.isUnique && index.columns.count == 1 {
102+
if let column = index.columns.first { unique.insert(column) }
103+
}
104+
result[tableName] = unique
96105
}
97106

98107
var junctionTableIds: Set<UUID> = []
@@ -182,7 +191,7 @@ enum ERDiagramGraphBuilder {
182191

183192
private static func inferCardinality(column: ColumnInfo?, uniqueColumns: Set<String>) -> ERCardinality {
184193
guard let column else { return .zeroOrManyToOne }
185-
let isUnique = column.isPrimaryKey || uniqueColumns.contains(column.name)
194+
let isUnique = uniqueColumns.contains(column.name)
186195
let isMandatory = !column.isNullable
187196
switch (isUnique, isMandatory) {
188197
case (true, true): return .oneToOne

TablePro/Models/ERDiagram/ERDiagramSQLExporter.swift

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,29 @@ enum ERDiagramSQLExporter {
8383
definition += " NOT NULL"
8484
}
8585
if let defaultValue = column.defaultValue, !defaultValue.isEmpty {
86-
definition += " DEFAULT \(defaultValue)"
86+
definition += " DEFAULT \(formatDefaultValue(defaultValue))"
8787
}
8888
if inlinePrimaryKey {
8989
definition += " PRIMARY KEY"
9090
}
9191
return definition
9292
}
9393

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 || Double(trimmed) != nil { return trimmed }
105+
let escaped = trimmed.replacingOccurrences(of: "'", with: "''")
106+
return "'\(escaped)'"
107+
}
108+
94109
private static func inlineForeignKeyClause(
95110
group: [ForeignKeyInfo],
96111
quoteIdentifier: (String) -> String

TablePro/ViewModels/ERDiagramViewModel.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ final class ERDiagramViewModel {
117117
) { driver in
118118
let cols = try await driver.fetchAllColumns()
119119
let fks = try await driver.fetchAllForeignKeys()
120-
let idx = try await driver.fetchAllIndexes()
120+
let idx = try await driver.fetchIndexes(forTables: Array(fks.keys))
121121
return (cols, fks, idx)
122122
}
123123

TableProTests/Models/ERDiagram/ERDiagramGraphBuilderTests.swift

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,42 @@ struct ERDiagramGraphBuilderTests {
123123
#expect(cardinality(from: "memberships", in: graph) == .manyToOne)
124124
}
125125

126+
@Test("A foreign key that is only part of a composite primary key is many-to-one")
127+
func compositePrimaryKeyMemberIsNotOneToOne() {
128+
let graph = ERDiagramGraphBuilder.build(
129+
allColumns: [
130+
"users": [column("id", primaryKey: true)],
131+
"audit": [
132+
column("user_id", nullable: false, primaryKey: true),
133+
column("seq", nullable: false, primaryKey: true)
134+
]
135+
],
136+
allForeignKeys: ["audit": [foreignKey(column: "user_id", references: "users")]]
137+
)
138+
#expect(cardinality(from: "audit", in: graph) == .manyToOne)
139+
}
140+
141+
@Test("Junction edges are many-to-one in the expanded graph")
142+
func junctionEdgesAreManyToOne() {
143+
let graph = ERDiagramGraphBuilder.build(
144+
allColumns: [
145+
"users": [column("id", primaryKey: true)],
146+
"roles": [column("id", primaryKey: true)],
147+
"user_roles": [
148+
column("user_id", nullable: false, primaryKey: true),
149+
column("role_id", nullable: false, primaryKey: true)
150+
]
151+
],
152+
allForeignKeys: ["user_roles": [
153+
foreignKey("fk_user", column: "user_id", references: "users"),
154+
foreignKey("fk_role", column: "role_id", references: "roles")
155+
]]
156+
)
157+
let junctionEdges = graph.edges.filter { $0.fromTable == "user_roles" }
158+
#expect(junctionEdges.count == 2)
159+
#expect(junctionEdges.allSatisfy { $0.cardinality == .manyToOne })
160+
}
161+
126162
@Test("Missing column metadata falls back to zero-or-many-to-one")
127163
func missingColumnFallsBack() {
128164
let graph = ERDiagramGraphBuilder.build(

TableProTests/Models/ERDiagram/ERDiagramSQLExporterTests.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,37 @@ struct ERDiagramSQLExporterTests {
8686
#expect(sql.contains("\"status\" varchar NOT NULL DEFAULT 'active'"))
8787
}
8888

89+
@Test("Unquoted string default is quoted")
90+
func unquotedStringDefaultIsQuoted() {
91+
let sql = ERDiagramSQLExporter.generate(
92+
tableNames: ["t"],
93+
allColumns: ["t": [column("status", type: "varchar", nullable: false, defaultValue: "active")]],
94+
allForeignKeys: [:],
95+
isSQLite: false,
96+
quoteIdentifier: quote
97+
)
98+
#expect(sql.contains("\"status\" varchar NOT NULL DEFAULT 'active'"))
99+
#expect(!sql.contains("DEFAULT active"))
100+
}
101+
102+
@Test("Numeric, expression, and keyword defaults pass through unquoted")
103+
func nonLiteralDefaultsPassThrough() {
104+
let sql = ERDiagramSQLExporter.generate(
105+
tableNames: ["t"],
106+
allColumns: ["t": [
107+
column("count", type: "integer", nullable: false, defaultValue: "0"),
108+
column("created", type: "timestamp", nullable: false, defaultValue: "CURRENT_TIMESTAMP"),
109+
column("uid", type: "uuid", nullable: false, defaultValue: "gen_random_uuid()")
110+
]],
111+
allForeignKeys: [:],
112+
isSQLite: false,
113+
quoteIdentifier: quote
114+
)
115+
#expect(sql.contains("\"count\" integer NOT NULL DEFAULT 0"))
116+
#expect(sql.contains("\"created\" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP"))
117+
#expect(sql.contains("\"uid\" uuid NOT NULL DEFAULT gen_random_uuid()"))
118+
}
119+
89120
@Test("Composite primary key becomes a trailing clause")
90121
func compositePrimaryKey() {
91122
let sql = ERDiagramSQLExporter.generate(

0 commit comments

Comments
 (0)