-
-
Notifications
You must be signed in to change notification settings - Fork 298
feat(er-diagram): infer relationship cardinality and export schema to SQL (#1335) #1759
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
46399e4
e8879eb
7a45cc7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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))") | ||
|
Comment on lines
+63
to
+65
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a foreign key references a natural key or other unique non-primary column, this exporter creates the parent table without any 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)" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When exporting a table whose generated key is represented in metadata, such as MySQL 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 { 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: ", ") | ||
|
Comment on lines
+127
to
+128
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For SQL Server composite foreign keys, 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] } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a composite primary key contains more than two FK columns but only two distinct referenced tables, such as
buyer_idandseller_idboth referencingusersplusproduct_idreferencingproducts, 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 👍 / 👎.