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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions TablePro/Core/Database/DatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]] {
Expand Down
137 changes: 133 additions & 4 deletions TablePro/Models/ERDiagram/ERDiagramModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -42,23 +47,64 @@ struct ERDiagramGraph: Sendable {
var nodes: [ERTableNode]
var edges: [EREdge]
var nodeIndex: [String: UUID]
var junctionTableIds: Set<UUID> = []
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

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] = []

let fkColumnsByTable: [String: Set<String>] = 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<String>] = allColumns.reduce(into: [:]) { result, entry in
let (tableName, columns) = entry
var unique: Set<String> = []
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.columns.count == 1 {
if let column = index.columns.first { unique.insert(column) }
}
result[tableName] = unique
}

var junctionTableIds: Set<UUID> = []

for tableName in allColumns.keys.sorted() {
let id = stableId(for: tableName)
Expand All @@ -78,12 +124,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
))
}

Expand All @@ -105,19 +159,94 @@ 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
updated.clusterId = clusters[node.id]
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<String>) -> 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 }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require binary PKs before collapsing junctions

When a composite primary key contains more than two FK columns but only two distinct referenced tables, such as buyer_id and seller_id both referencing users plus product_id referencing products, this still classifies the table as a junction. The default collapsed view then hides the real table and its edges and replaces the ternary association with a single users-products many-to-many edge, so require exactly two PK FK columns or otherwise skip non-binary associations.

Useful? React with 👍 / 👎.


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 {
Expand Down
156 changes: 156 additions & 0 deletions TablePro/Models/ERDiagram/ERDiagramSQLExporter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
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))")
Comment on lines +63 to +65

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Emit unique keys needed by exported FKs

When a foreign key references a natural key or other unique non-primary column, this exporter creates the parent table without any UNIQUE constraint and then emits the child FK. PostgreSQL rejects the later ADD CONSTRAINT because the referenced columns are not declared unique, and SQLite schemas hit foreign-key mismatch behavior at use time; include the relevant unique index/constraint metadata before emitting those FKs.

Useful? React with 👍 / 👎.

}

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)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve identity clauses during SQL export

When exporting a table whose generated key is represented in metadata, such as MySQL extra containing auto_increment or SQL Server setting extra == "IDENTITY", this column definition starts from only the quoted name and data type and then appends null/default/PK clauses, never using ColumnInfo.extra. The exported DDL recreates those primary keys as plain integer columns, so subsequent inserts that rely on database-generated ids fail or require manual values; please emit the dialect-specific identity/auto-increment clause or delegate this part to the driver's DDL builder.

Useful? React with 👍 / 👎.

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<String> = [
"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 || Double(trimmed) != nil { 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: ", ")
Comment on lines +127 to +128

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve composite FK column order

For SQL Server composite foreign keys, fetchAllForeignKeys orders rows only by table/fk name (Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift:374) and not by fkc.constraint_column_id, so the order inside group is nondeterministic. Mapping child and referenced columns by that order can emit FOREIGN KEY (a, b) REFERENCES parent (b, a), recreating the wrong relationship or failing; include and preserve the FK ordinal before joining these arrays.

Useful? React with 👍 / 👎.

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] }
}
}
Loading
Loading