From 28fe4882b75c434af342e812ddc86e5b7b5dc727 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 23:02:51 +0700 Subject: [PATCH 1/2] feat(query): build a join query from the foreign keys between tables --- .../TablePro/Core/JoinBuilder/JoinGraph.swift | 167 +++++++++++ .../Core/JoinBuilder/JoinQueryDraft.swift | 157 ++++++++++ .../Core/JoinBuilder/JoinQuerySQLWriter.swift | 73 +++++ .../Core/Menu/DatabaseMenuBuilder.swift | 4 + ...itViewController+DatabaseMenuActions.swift | 4 + ...inSplitViewController+MenuValidation.swift | 5 + .../Query/ForeignKeyTargetScope.swift | 18 ++ .../Models/ERDiagram/ERDiagramModels.swift | 17 +- .../Models/Query/ForeignKeyCatalogNames.swift | 67 +++++ .../TablePro/Resources/Localizable.xcstrings | 45 +++ .../ViewModels/ERDiagramViewModel.swift | 34 ++- .../ViewModels/JoinBuilderViewModel.swift | 245 ++++++++++++++++ .../ERDiagram/ERDiagramAccessibility.swift | 2 +- .../Views/ERDiagram/ERDiagramScene.swift | 11 +- .../Views/ERDiagram/ERDiagramSceneView.swift | 50 +++- .../Views/ERDiagram/ERDiagramToolbar.swift | 10 + .../Views/ERDiagram/ERDiagramView.swift | 16 +- .../Views/JoinBuilder/JoinBuilderSheet.swift | 277 ++++++++++++++++++ .../Main/Child/MainEditorContentView.swift | 10 +- .../MainContentCoordinator+JoinBuilder.swift | 41 +++ .../Main/MainContentCommandActions.swift | 8 + .../Views/Main/MainContentCoordinator.swift | 2 + .../TablePro/Views/Main/MainContentView.swift | 4 + .../Core/JoinBuilder/JoinGraphTests.swift | 202 +++++++++++++ .../JoinBuilder/JoinQueryDraftTests.swift | 116 ++++++++ .../JoinBuilder/JoinQuerySQLWriterTests.swift | 143 +++++++++ .../Core/Menu/ChromeSnapshot.json | 1 + .../Core/Menu/MainMenuBuilderTests.swift | 14 + .../Services/ForeignKeyTargetScopeTests.swift | 22 ++ .../Helpers/ERDiagramLoadFixture.swift | 12 +- .../ERDiagramGraphBuilderTests.swift | 16 + .../ERDiagramViewModelLoadTests.swift | 24 ++ .../ERDiagram/DiagramPaintCoverageTests.swift | 2 +- .../ERDiagramAccessibilityTests.swift | 6 +- .../ERDiagramPointerInputTests.swift | 109 ++++++- .../ERDiagramSelfReferenceTests.swift | 4 +- .../Views/ERDiagram/ERDiagramThemeTests.swift | 2 +- .../ERDiagramVectorExportTests.swift | 2 +- .../Views/Main/JoinBuilderOpeningTests.swift | 101 +++++++ .../TableProUITests/JoinBuilderUITests.swift | 57 ++++ 40 files changed, 2062 insertions(+), 38 deletions(-) create mode 100644 apple/TablePro/Core/JoinBuilder/JoinGraph.swift create mode 100644 apple/TablePro/Core/JoinBuilder/JoinQueryDraft.swift create mode 100644 apple/TablePro/Core/JoinBuilder/JoinQuerySQLWriter.swift create mode 100644 apple/TablePro/Models/Query/ForeignKeyCatalogNames.swift create mode 100644 apple/TablePro/ViewModels/JoinBuilderViewModel.swift create mode 100644 apple/TablePro/Views/JoinBuilder/JoinBuilderSheet.swift create mode 100644 apple/TablePro/Views/Main/Extensions/MainContentCoordinator+JoinBuilder.swift create mode 100644 apple/TableProTests/Core/JoinBuilder/JoinGraphTests.swift create mode 100644 apple/TableProTests/Core/JoinBuilder/JoinQueryDraftTests.swift create mode 100644 apple/TableProTests/Core/JoinBuilder/JoinQuerySQLWriterTests.swift create mode 100644 apple/TableProTests/Views/Main/JoinBuilderOpeningTests.swift create mode 100644 apple/TableProUITests/JoinBuilderUITests.swift diff --git a/apple/TablePro/Core/JoinBuilder/JoinGraph.swift b/apple/TablePro/Core/JoinBuilder/JoinGraph.swift new file mode 100644 index 0000000000..208aca606e --- /dev/null +++ b/apple/TablePro/Core/JoinBuilder/JoinGraph.swift @@ -0,0 +1,167 @@ +// +// JoinGraph.swift +// TablePro +// + +import Foundation + +internal struct JoinRelationship: Hashable, Sendable, Identifiable { + internal struct ColumnPair: Hashable, Sendable { + internal let child: String + internal let parent: String + } + + internal let name: String + internal let childTable: String + internal let parentTable: String + internal let columnPairs: [ColumnPair] + + internal var id: String { + IdentityPath.joined([childTable, name, parentTable], separator: "|") + } + + internal func links(_ table: String, to other: String) -> Bool { + (childTable == table && parentTable == other) || (childTable == other && parentTable == table) + } + + internal func partner(of table: String) -> String? { + if childTable == table { return parentTable } + if parentTable == table { return childTable } + return nil + } +} + +internal struct JoinStep: Hashable, Sendable { + internal let table: String + internal let relationship: JoinRelationship +} + +internal struct JoinGraph: Sendable { + internal let columnsByTable: [String: [String]] + internal let relationships: [JoinRelationship] + // A key from a table to itself is left out: it joins nothing between two tables. + private let relationshipsByTable: [String: [JoinRelationship]] + + internal static let empty = JoinGraph(columnsByTable: [:], relationships: []) + + internal var tableNames: [String] { + columnsByTable.keys.sorted { $0.localizedStandardCompare($1) == .orderedAscending } + } + + internal init(columnsByTable: [String: [String]], relationships: [JoinRelationship]) { + self.columnsByTable = columnsByTable + self.relationships = relationships + var byTable: [String: [JoinRelationship]] = [:] + for relationship in relationships where relationship.childTable != relationship.parentTable { + byTable[relationship.childTable, default: []].append(relationship) + byTable[relationship.parentTable, default: []].append(relationship) + } + relationshipsByTable = byTable + } + + // A composite key arrives as one row per column under one name. `isLocal` drops a key into another + // schema, which would otherwise join a same-named table of this one. + internal init( + columns: [String: [String]], + foreignKeys: [String: [ForeignKeyInfo]], + isLocal: (ForeignKeyInfo) -> Bool + ) { + struct KeyIdentity: Hashable { + let name: String + let parent: String + } + + let foreignKeys = ForeignKeyCatalogNames.canonicalized(foreignKeys, columns: columns) + var relationships: [JoinRelationship] = [] + for childTable in foreignKeys.keys.sorted() where columns[childTable] != nil { + var order: [KeyIdentity] = [] + var pairsByKey: [KeyIdentity: [JoinRelationship.ColumnPair]] = [:] + for key in foreignKeys[childTable] ?? [] { + guard columns[key.referencedTable] != nil, isLocal(key) else { continue } + let identity = KeyIdentity(name: key.name, parent: key.referencedTable) + let pair = JoinRelationship.ColumnPair(child: key.column, parent: key.referencedColumn) + if pairsByKey[identity] == nil { + order.append(identity) + } + if pairsByKey[identity]?.contains(pair) != true { + pairsByKey[identity, default: []].append(pair) + } + } + for identity in order { + relationships.append(JoinRelationship( + name: identity.name, + childTable: childTable, + parentTable: identity.parent, + columnPairs: pairsByKey[identity] ?? [] + )) + } + } + self.init(columnsByTable: columns, relationships: relationships) + } + + internal func columns(of table: String) -> [String] { + columnsByTable[table] ?? [] + } + + internal func relationships(between table: String, and other: String) -> [JoinRelationship] { + (relationshipsByTable[table] ?? []).filter { $0.partner(of: table) == other } + } + + // Two tables that only share a parent multiply each other's rows, so that hop costs extra and a + // junction or a chain of the same length wins. + internal func path(to table: String, from joined: [String]) -> [JoinStep]? { + guard !joined.contains(table) else { return [] } + guard columnsByTable[table] != nil else { return nil } + + struct State: Hashable { + let table: String + let arrivedAsParent: Bool + } + struct Entry { + let cost: Int + let order: Int + let state: State + } + + let joinedTables = Set(joined) + var bestCost: [State: Int] = [:] + var cameFrom: [State: (previous: State, step: JoinStep)] = [:] + var frontier: [Entry] = [] + var order = 0 + for start in joined { + let state = State(table: start, arrivedAsParent: false) + guard bestCost[state] == nil else { continue } + bestCost[state] = 0 + frontier.append(Entry(cost: 0, order: order, state: state)) + order += 1 + } + + while let index = frontier.indices.min(by: { lhs, rhs in + (frontier[lhs].cost, frontier[lhs].order) < (frontier[rhs].cost, frontier[rhs].order) + }) { + let entry = frontier.remove(at: index) + guard bestCost[entry.state] == entry.cost else { continue } + if entry.state.table == table { + var steps: [JoinStep] = [] + var current = entry.state + while let link = cameFrom[current] { + steps.append(link.step) + current = link.previous + } + return steps.reversed() + } + for relationship in relationshipsByTable[entry.state.table] ?? [] { + guard let next = relationship.partner(of: entry.state.table), !joinedTables.contains(next) else { continue } + let leavesForChild = relationship.parentTable == entry.state.table + let cost = entry.cost + 2 + (entry.state.arrivedAsParent && leavesForChild ? 1 : 0) + let nextState = State(table: next, arrivedAsParent: relationship.parentTable == next) + if let known = bestCost[nextState], known <= cost { continue } + bestCost[nextState] = cost + cameFrom[nextState] = (entry.state, JoinStep(table: next, relationship: relationship)) + frontier.append(Entry(cost: cost, order: order, state: nextState)) + order += 1 + } + } + return nil + } +} diff --git a/apple/TablePro/Core/JoinBuilder/JoinQueryDraft.swift b/apple/TablePro/Core/JoinBuilder/JoinQueryDraft.swift new file mode 100644 index 0000000000..2534fae587 --- /dev/null +++ b/apple/TablePro/Core/JoinBuilder/JoinQueryDraft.swift @@ -0,0 +1,157 @@ +// +// JoinQueryDraft.swift +// TablePro +// + +import Foundation + +internal enum JoinKind: String, CaseIterable, Identifiable, Sendable { + case inner + case left + + internal var id: String { rawValue } + + internal var keyword: String { + switch self { + case .inner: "JOIN" + case .left: "LEFT JOIN" + } + } + + internal var displayName: String { + switch self { + case .inner: String(localized: "Inner Join") + case .left: String(localized: "Left Join") + } + } +} + +internal struct JoinColumnRef: Hashable, Sendable { + internal let table: String + internal let column: String +} + +internal struct JoinFilter: Identifiable, Equatable, Sendable { + internal let id: UUID + internal var column: JoinColumnRef + internal var filterOperator: FilterOperator + internal var value: String + internal var secondValue: String + + internal init( + id: UUID = UUID(), + column: JoinColumnRef, + filterOperator: FilterOperator = .equal, + value: String = "", + secondValue: String = "" + ) { + self.id = id + self.column = column + self.filterOperator = filterOperator + self.value = value + self.secondValue = secondValue + } + + internal var tableFilter: TableFilter { + TableFilter( + id: id, + columnName: column.column, + filterOperator: filterOperator, + value: value, + secondValue: filterOperator.requiresSecondValue ? secondValue : nil, + isEnabled: true + ) + } +} + +internal struct JoinQueryDraft: Equatable, Sendable { + internal struct Table: Identifiable, Equatable, Sendable { + internal let name: String + internal var kind: JoinKind = .inner + internal var condition: JoinRelationship? + internal var selectedColumns: [String] = [] + + internal var id: String { name } + } + + internal private(set) var tables: [Table] = [] + internal var filters: [JoinFilter] = [] + + internal var isEmpty: Bool { tables.isEmpty } + + internal func contains(_ table: String) -> Bool { + tables.contains { $0.name == table } + } + + internal func index(of table: String) -> Int? { + tables.firstIndex { $0.name == table } + } + + internal mutating func add(_ table: String, in graph: JoinGraph) { + guard !contains(table), graph.columnsByTable[table] != nil else { return } + guard !tables.isEmpty else { + tables.append(Table(name: table)) + return + } + guard let steps = graph.path(to: table, from: tables.map(\.name)), !steps.isEmpty else { + tables.append(Table(name: table)) + return + } + for step in steps where !contains(step.table) { + tables.append(Table(name: step.table, condition: step.relationship)) + } + } + + internal mutating func remove(_ table: String, in graph: JoinGraph) { + guard let removed = index(of: table) else { return } + tables.remove(at: removed) + filters.removeAll { $0.column.table == table } + relinkConditions(afterRemoving: table, in: graph) + } + + // A join condition can name only tables already in the FROM clause. + internal func candidates(forTableAt index: Int, in graph: JoinGraph) -> [JoinRelationship] { + guard tables.indices.contains(index), index > 0 else { return [] } + let table = tables[index].name + return tables[.. 0 else { return } + guard let condition else { + tables[index].condition = nil + return + } + guard candidates(forTableAt: index, in: graph).contains(condition) else { return } + tables[index].condition = condition + } + + internal func isSelected(_ column: JoinColumnRef) -> Bool { + tables.first { $0.name == column.table }?.selectedColumns.contains(column.column) ?? false + } + + internal mutating func setSelected(_ isSelected: Bool, column: JoinColumnRef, in graph: JoinGraph) { + guard let tableIndex = index(of: column.table) else { return } + var chosen = Set(tables[tableIndex].selectedColumns) + if isSelected { chosen.insert(column.column) } else { chosen.remove(column.column) } + tables[tableIndex].selectedColumns = graph.columns(of: column.table).filter(chosen.contains) + } + + // Only a join that named the removed table changes; a cross join the user chose stays one. + private mutating func relinkConditions(afterRemoving removed: String, in graph: JoinGraph) { + for index in tables.indices { + guard index > 0 else { + tables[index].condition = nil + continue + } + guard let condition = tables[index].condition, + condition.partner(of: tables[index].name) == removed else { continue } + tables[index].condition = candidates(forTableAt: index, in: graph).first + } + } +} diff --git a/apple/TablePro/Core/JoinBuilder/JoinQuerySQLWriter.swift b/apple/TablePro/Core/JoinBuilder/JoinQuerySQLWriter.swift new file mode 100644 index 0000000000..439d461983 --- /dev/null +++ b/apple/TablePro/Core/JoinBuilder/JoinQuerySQLWriter.swift @@ -0,0 +1,73 @@ +// +// JoinQuerySQLWriter.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +internal struct JoinQuerySQLWriter { + internal let graph: JoinGraph + internal let columnTypes: [String: [String: ColumnType]] + internal let dialect: SQLDialectDescriptor + internal let databaseType: DatabaseType + internal let schema: String? + internal let quote: (String) -> String + + internal func sql(for draft: JoinQueryDraft) -> String? { + guard let first = draft.tables.first else { return nil } + + let selected = draft.tables.flatMap { table in + table.selectedColumns.map { column(table.name, $0) } + } + var lines = [ + "SELECT \(selected.isEmpty ? "*" : selected.joined(separator: ", "))", + "FROM \(source(first.name))" + ] + for table in draft.tables.dropFirst() { + guard let condition = table.condition else { + lines.append("CROSS JOIN \(source(table.name))") + continue + } + lines.append("\(table.kind.keyword) \(source(table.name)) ON \(onClause(condition))") + } + + let conditions = draft.filters.compactMap(condition(for:)) + if !conditions.isEmpty { + lines.append("WHERE \(conditions.joined(separator: " AND "))") + } + return lines.joined(separator: "\n") + } + + private func source(_ table: String) -> String { + guard let schema else { return quote(table) } + return "\(quote(schema)).\(quote(table))" + } + + private func column(_ table: String, _ column: String) -> String { + "\(quote(table)).\(quote(column))" + } + + private func onClause(_ relationship: JoinRelationship) -> String { + relationship.columnPairs + .map { "\(column(relationship.childTable, $0.child)) = \(column(relationship.parentTable, $0.parent))" } + .joined(separator: " AND ") + } + + // The filter bar's own generator, so a value is typed and escaped exactly as a table filter's is. + private func condition(for filter: JoinFilter) -> String? { + let table = filter.column.table + let types = columnTypes[table] ?? [:] + let names = graph.columns(of: table).filter { types[$0] != nil } + let quote = self.quote + let generator = FilterSQLGenerator( + dialect: dialect, + columns: names, + columnTypes: names.compactMap { types[$0] }, + quoteIdentifier: { "\(quote(table)).\(quote($0))" }, + stringLiteralPrefix: SQLStringLiteralPrefix.forDatabaseType(databaseType), + databaseType: databaseType + ) + return generator.generateCondition(from: filter.tableFilter) + } +} diff --git a/apple/TablePro/Core/Menu/DatabaseMenuBuilder.swift b/apple/TablePro/Core/Menu/DatabaseMenuBuilder.swift index f5fdd8f841..44c9fe5afa 100644 --- a/apple/TablePro/Core/Menu/DatabaseMenuBuilder.swift +++ b/apple/TablePro/Core/Menu/DatabaseMenuBuilder.swift @@ -127,6 +127,10 @@ enum DatabaseMenuBuilder { String(localized: "View ER Diagram"), action: #selector(MainSplitViewController.showERDiagram(_:)) ), + MenuItemFactory.item( + String(localized: "Build Join Query…"), + action: #selector(MainSplitViewController.buildJoinQuery(_:)) + ), MenuItemFactory.item( String(localized: "Server Dashboard"), action: #selector(MainSplitViewController.showServerDashboard(_:)) diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift index 78da76acaf..0211db530c 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift @@ -237,6 +237,10 @@ extension MainSplitViewController { commandActions?.showERDiagram() } + @objc func buildJoinQuery(_ sender: Any?) { + commandActions?.buildJoinQuery() + } + @objc func showServerDashboard(_ sender: Any?) { commandActions?.showServerDashboard() } diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index 4659aef339..c1cf1872bc 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -136,6 +136,7 @@ struct MenuValidationContext: Equatable { var canCopyObjects = false var canDuplicateDatabase = false var canSearchDatabase = false + var canBuildJoinQuery = false var hasMaintenanceOperations = false var canUndo = false var canRedo = false @@ -310,6 +311,8 @@ extension MainSplitViewController: NSMenuItemValidation { return context.canDuplicateDatabase case #selector(searchDatabase(_:)): return context.isConnected && context.canSearchDatabase + case #selector(buildJoinQuery(_:)): + return context.isConnected && context.canBuildJoinQuery case #selector(showTableStructure(_:)), #selector(editViewDefinition(_:)), #selector(showObjectDDL(_:)), @@ -522,6 +525,7 @@ extension MainSplitViewController: NSMenuItemValidation { #selector(navigateForward(_:)), #selector(moveTabToConnection(_:)), #selector(searchDatabase(_:)), + #selector(buildJoinQuery(_:)), ] /// What AppKit is told. A command this window does not own is left enabled, which is what keeps @@ -708,6 +712,7 @@ extension MainSplitViewController: NSMenuItemValidation { canCopyObjects: actions.canCopyObjects, canDuplicateDatabase: actions.canDuplicateDatabase, canSearchDatabase: actions.canSearchDatabase, + canBuildJoinQuery: actions.canBuildJoinQuery, hasMaintenanceOperations: !actions.maintenanceOperations.isEmpty, canUndo: actions.canUndo, canRedo: actions.canRedo, diff --git a/apple/TablePro/Core/Services/Query/ForeignKeyTargetScope.swift b/apple/TablePro/Core/Services/Query/ForeignKeyTargetScope.swift index 331b3a3cec..cc4676dd27 100644 --- a/apple/TablePro/Core/Services/Query/ForeignKeyTargetScope.swift +++ b/apple/TablePro/Core/Services/Query/ForeignKeyTargetScope.swift @@ -52,6 +52,24 @@ internal enum ForeignKeyTargetScope { } } + // A part the origin or the engine leaves unnamed cannot rule a key out. + internal static func pointsWithin( + _ origin: DatabaseScope, + referencedDatabase: String?, + referencedSchema: String?, + slot: EngineNamespaceSlot + ) -> Bool { + let target = resolve( + origin: origin, + referencedDatabase: referencedDatabase, + referencedSchema: referencedSchema, + slot: slot + ) + let sameDatabase = origin.database.isEmpty || target.database == origin.database + guard let schema = origin.schema, let targetSchema = target.schema else { return sameDatabase } + return sameDatabase && targetSchema == schema + } + internal static func tableScope( origin: DatabaseScope, referencedDatabase: String? = nil, diff --git a/apple/TablePro/Models/ERDiagram/ERDiagramModels.swift b/apple/TablePro/Models/ERDiagram/ERDiagramModels.swift index 93eec473ef..f0870c85e0 100644 --- a/apple/TablePro/Models/ERDiagram/ERDiagramModels.swift +++ b/apple/TablePro/Models/ERDiagram/ERDiagramModels.swift @@ -80,8 +80,15 @@ enum ERDiagramGraphBuilder { static func build( allColumns: [String: [ColumnInfo]], allForeignKeys: [String: [ForeignKeyInfo]], - allIndexes: [String: [IndexInfo]] = [:] + allIndexes: [String: [IndexInfo]] = [:], + isLocal: (ForeignKeyInfo) -> Bool = { _ in true } ) -> ERDiagramGraph { + let allForeignKeys = ForeignKeyCatalogNames.canonicalized( + allForeignKeys, + columns: allColumns.mapValues { $0.map(\.name) } + ) + // A key into another schema still badges its column, but draws no edge to a same-named local table. + let localForeignKeys = allForeignKeys.mapValues { $0.filter(isLocal) } var nodeIndex: [String: UUID] = [:] var nodes: [ERTableNode] = [] @@ -128,7 +135,7 @@ enum ERDiagramGraphBuilder { let isJunction = junctionParents( tableName: tableName, columns: columns, - foreignKeys: allForeignKeys[tableName] ?? [] + foreignKeys: localForeignKeys[tableName] ?? [] ) != nil if isJunction { junctionTableIds.insert(id) } @@ -146,8 +153,8 @@ enum ERDiagramGraphBuilder { var seenFKNames: Set = [] // Sorted, because a dictionary's order changes per launch and decides port order on ties. - for tableName in allForeignKeys.keys.sorted() { - for fk in allForeignKeys[tableName] ?? [] { + for tableName in localForeignKeys.keys.sorted() { + for fk in localForeignKeys[tableName] ?? [] { let edgeKey = "\(tableName).\(fk.name).\(fk.column)" guard !seenFKNames.contains(edgeKey) else { continue } seenFKNames.insert(edgeKey) @@ -171,7 +178,7 @@ enum ERDiagramGraphBuilder { let manyToManyEdges = buildManyToManyEdges( allColumns: allColumns, - allForeignKeys: allForeignKeys, + allForeignKeys: localForeignKeys, nodeIndex: nodeIndex ) diff --git a/apple/TablePro/Models/Query/ForeignKeyCatalogNames.swift b/apple/TablePro/Models/Query/ForeignKeyCatalogNames.swift new file mode 100644 index 0000000000..c3563c8bb0 --- /dev/null +++ b/apple/TablePro/Models/Query/ForeignKeyCatalogNames.swift @@ -0,0 +1,67 @@ +// +// ForeignKeyCatalogNames.swift +// TablePro +// + +import Foundation + +// SQLite reports a key's target as its REFERENCES clause spells it: `parent(id)` for the table `Parent`. +internal enum ForeignKeyCatalogNames { + internal static func canonicalized( + _ foreignKeys: [String: [ForeignKeyInfo]], + columns: [String: [String]] + ) -> [String: [ForeignKeyInfo]] { + let tables = Lookup(columns.keys) + var columnLookups: [String: Lookup] = [:] + func columnLookup(_ table: String) -> Lookup { + if let cached = columnLookups[table] { return cached } + let lookup = Lookup(columns[table] ?? []) + columnLookups[table] = lookup + return lookup + } + + var result: [String: [ForeignKeyInfo]] = [:] + for (childTable, keys) in foreignKeys { + result[childTable] = keys.map { key in + guard let parent = tables.canonical(key.referencedTable) else { return key } + let column = columnLookup(childTable).canonical(key.column) ?? key.column + let referencedColumn = columnLookup(parent).canonical(key.referencedColumn) ?? key.referencedColumn + guard parent != key.referencedTable || column != key.column || referencedColumn != key.referencedColumn + else { return key } + return ForeignKeyInfo( + name: key.name, + column: column, + referencedTable: parent, + referencedColumn: referencedColumn, + referencedDatabase: key.referencedDatabase, + referencedSchema: key.referencedSchema, + onDelete: key.onDelete, + onUpdate: key.onUpdate + ) + } + } + return result + } + + private struct Lookup { + private let exact: Set + private let folded: [String: [String]] + + init(_ names: some Sequence) { + var exact: Set = [] + var folded: [String: [String]] = [:] + for name in names { + exact.insert(name) + folded[name.lowercased(), default: []].append(name) + } + self.exact = exact + self.folded = folded + } + + func canonical(_ name: String) -> String? { + if exact.contains(name) { return name } + guard let matches = folded[name.lowercased()], matches.count == 1 else { return nil } + return matches[0] + } + } +} diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index a9ef84d3eb..117b9c8c14 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -134,6 +134,9 @@ }, "Pick the result to compare this one with." : { + }, + "Pick the tables to join. Their foreign keys become the join conditions." : { + }, "Result %1$d: %2$@" : { @@ -6807,6 +6810,9 @@ } } } + }, + "%1$@ column of %2$@" : { + }, "%1$@ could not be read again before generating the script. %2$@" : { "localizations" : { @@ -31827,6 +31833,15 @@ "Bug" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Build a join query from the selected tables" : { + + }, + "Build Join Query" : { + + }, + "Build Join Query…" : { + }, "Build the SQL that brings the target in line" : { "localizations" : { @@ -84137,6 +84152,9 @@ } } } + }, + "From" : { + }, "From %@…" : { "localizations" : { @@ -93105,6 +93123,9 @@ }, "Inline suggestions and Review, Explain, Optimize and Fix run on this Mac and send nothing over the network." : { + }, + "Inner Join" : { + }, "Insert" : { "extractionState" : "manual", @@ -96154,6 +96175,9 @@ }, "Join condition:" : { + }, + "Joins" : { + }, "Joins this column with the one to its right." : { "localizations" : { @@ -99615,6 +99639,9 @@ } } } + }, + "Left Join" : { + }, "Left out" : { "localizations" : { @@ -115589,6 +115616,9 @@ } } } + }, + "No foreign key links %@ to the tables before it, so it is cross joined." : { + }, "No Foreign Keys Yet" : { "localizations" : { @@ -118873,6 +118903,9 @@ } } } + }, + "No tables picked" : { + }, "No tables selected for export" : { "localizations" : { @@ -119708,6 +119741,9 @@ } } } + }, + "None (Cross Join)" : { + }, "None (Top Level)" : { "localizations" : { @@ -125903,6 +125939,9 @@ } } } + }, + "Open in Query Tab" : { + }, "Open in Window" : { "localizations" : { @@ -157683,6 +157722,9 @@ } } } + }, + "Second value" : { + }, "Second value is required for BETWEEN" : { "localizations" : { @@ -193352,6 +193394,9 @@ } } } + }, + "This database reports no tables to join." : { + }, "This database reports no tables to search." : { diff --git a/apple/TablePro/ViewModels/ERDiagramViewModel.swift b/apple/TablePro/ViewModels/ERDiagramViewModel.swift index 4aa76e760b..7fc4f007e4 100644 --- a/apple/TablePro/ViewModels/ERDiagramViewModel.swift +++ b/apple/TablePro/ViewModels/ERDiagramViewModel.swift @@ -86,8 +86,25 @@ final class ERDiagramViewModel: ObservableObject { /// back at 100% scrolled to the origin every time the user left the tab and returned. let viewport = DiagramViewportController() - /// Selection outlives the view for the same reason. - @Published var selectedNodeId: UUID? + // Outlives the view for the same reason, in pick order so a join starts at the first table. + @Published var selectedNodeIds: [UUID] = [] + + var selectedNodeId: UUID? { + get { selectedNodeIds.last } + set { selectedNodeIds = newValue.map { [$0] } ?? [] } + } + + func toggleSelection(_ nodeId: UUID) { + if let index = selectedNodeIds.firstIndex(of: nodeId) { + selectedNodeIds.remove(at: index) + } else { + selectedNodeIds.append(nodeId) + } + } + + var selectedTableNames: [String] { + selectedNodeIds.compactMap { id in graph.nodes.first { $0.id == id }?.tableName } + } // MARK: - Drag State @@ -187,7 +204,8 @@ final class ERDiagramViewModel: ObservableObject { fullGraph = ERDiagramGraphBuilder.build( allColumns: columns, allForeignKeys: foreignKeys, - allIndexes: indexes + allIndexes: indexes, + isLocal: isLocalKey(in: scope) ) nodeIdToName = Dictionary(uniqueKeysWithValues: fullGraph.nodes.map { ($0.id, $0.tableName) }) @@ -210,6 +228,16 @@ final class ERDiagramViewModel: ObservableObject { } } + private func isLocalKey(in scope: DatabaseScope) -> (ForeignKeyInfo) -> Bool { + guard let driver = services.databaseManager.driver(for: connectionId) else { return { _ in true } } + let slot = EngineNamespaceSlot(databaseType: driver.connection.type) + return { key in + ForeignKeyTargetScope.pointsWithin( + scope, referencedDatabase: key.referencedDatabase, referencedSchema: key.referencedSchema, slot: slot + ) + } + } + private func waitForConnection() async { await withCheckedContinuation { (continuation: CheckedContinuation) in let resumed = OSAllocatedUnfairLock(initialState: false) diff --git a/apple/TablePro/ViewModels/JoinBuilderViewModel.swift b/apple/TablePro/ViewModels/JoinBuilderViewModel.swift new file mode 100644 index 0000000000..94c111a207 --- /dev/null +++ b/apple/TablePro/ViewModels/JoinBuilderViewModel.swift @@ -0,0 +1,245 @@ +// +// JoinBuilderViewModel.swift +// TablePro +// + +import Combine +import Foundation +import os +import TableProPluginKit + +internal struct JoinBuilderLaunchRequest: Identifiable, Equatable, Sendable { + internal let id: UUID + internal let database: String + internal let schema: String? + internal let tables: [String] + + internal init(id: UUID = UUID(), database: String, schema: String?, tables: [String]) { + self.id = id + self.database = database + self.schema = schema?.nilIfEmpty + self.tables = tables + } +} + +internal enum JoinBuilderEligibility { + internal static func supportsJoinBuilder( + editorLanguage: EditorLanguage, + dialect: SQLDialectDescriptor?, + supportsForeignKeys: Bool + ) -> Bool { + editorLanguage == .sql && dialect != nil && supportsForeignKeys + } + + @MainActor + internal static func supportsJoinBuilder(_ databaseType: DatabaseType) -> Bool { + supportsJoinBuilder( + editorLanguage: PluginManager.shared.editorLanguage(for: databaseType), + dialect: try? resolveSQLDialect(for: databaseType), + supportsForeignKeys: databaseType.supportsForeignKeys + ) + } +} + +@MainActor +internal final class JoinBuilderViewModel: ObservableObject { + private static let logger = Logger(subsystem: "com.TablePro", category: "JoinBuilder") + + internal enum LoadState: Equatable { + case loading + case loaded + case failed(String) + } + + internal struct Catalog { + let graph: JoinGraph + let columnTypes: [String: [String: ColumnType]] + } + + internal let launch: JoinBuilderLaunchRequest + internal let connection: DatabaseConnection + + @Published internal private(set) var loadState: LoadState = .loading + @Published internal private(set) var graph: JoinGraph = .empty + @Published internal private(set) var draft = JoinQueryDraft() + @Published internal var tableFilter = "" + + private var columnTypes: [String: [String: ColumnType]] = [:] + private var qualifyingSchema: String? + private let services: AppServices + + internal init(launch: JoinBuilderLaunchRequest, connection: DatabaseConnection, services: AppServices = .live) { + self.launch = launch + self.connection = connection + self.services = services + } + + internal var scopeDescription: String { + DatabaseEndpoint.from( + connection: connection, + database: launch.database, + schema: launch.schema ?? scope?.schema + ).qualifiedDescription + } + + internal var filteredTables: [String] { + let term = tableFilter.trimmingCharacters(in: .whitespacesAndNewlines) + guard !term.isEmpty else { return graph.tableNames } + return graph.tableNames.filter { $0.localizedCaseInsensitiveContains(term) || draft.contains($0) } + } + + internal var sql: String? { + guard let dialect = try? resolveSQLDialect(for: connection.type) else { return nil } + return JoinQuerySQLWriter( + graph: graph, + columnTypes: columnTypes, + dialect: dialect, + databaseType: connection.type, + schema: qualifyingSchema, + quote: quoteIdentifier(dialect: dialect) + ).sql(for: draft) + } + + internal var filterableColumns: [JoinColumnRef] { + draft.tables.flatMap { table in + graph.columns(of: table.name).map { JoinColumnRef(table: table.name, column: $0) } + } + } + + internal var crossJoinedTables: [String] { + draft.tables.dropFirst().filter { $0.condition == nil }.map(\.name) + } + + private var scope: DatabaseScope? { + services.databaseManager.resolvedScope(database: launch.database, schema: launch.schema, for: connection.id) + } + + // MARK: - Loading + + internal func loadIfNeeded() async { + guard loadState != .loaded else { return } + await load() + } + + internal func load() async { + guard let scope else { + loadState = .failed(String(localized: "No database connection")) + return + } + if graph.columnsByTable.isEmpty { + loadState = .loading + } + do { + let catalog = try await fetchCatalog(scope: scope) + adopt(catalog, scope: scope) + } catch { + Self.logger.error("Failed to read the join builder catalog: \(error.publicLogShape, privacy: .public)") + if graph.columnsByTable.isEmpty { + loadState = .failed(error.localizedDescription) + } + } + } + + internal func adopt(_ catalog: Catalog, scope: DatabaseScope) { + graph = catalog.graph + columnTypes = catalog.columnTypes + // Picking a schema in the sidebar does not move SQL Server's name resolution, so qualify as browsing does. + qualifyingSchema = SchemaQualifiedName.explicitSchema(scope.schema, databaseType: connection.type) + var seeded = JoinQueryDraft() + for table in launch.tables { + seeded.add(table, in: graph) + } + draft = seeded + loadState = .loaded + } + + private func fetchCatalog(scope: DatabaseScope) async throws -> Catalog { + let (columns, foreignKeys) = try await services.databaseManager.withMetadataDriver( + scope: scope, workload: .bulk + ) { driver in + let columns = try await driver.fetchAllColumns() + let foreignKeys = try await driver.fetchAllForeignKeys() + return (columns, foreignKeys) + } + let slot = EngineNamespaceSlot(databaseType: connection.type) + let graph = JoinGraph( + columns: columns.mapValues { $0.map(\.name) }, + foreignKeys: foreignKeys, + isLocal: { key in + ForeignKeyTargetScope.pointsWithin( + scope, referencedDatabase: key.referencedDatabase, referencedSchema: key.referencedSchema, slot: slot + ) + } + ) + let classifier = ColumnTypeClassifier(family: SQLTypeFamily.of(connection.type)) + let types = columns.mapValues { infos in + Dictionary( + infos.map { ($0.name, classifier.classify(rawTypeName: $0.typeNameForClassification)) }, + uniquingKeysWith: { first, _ in first } + ) + } + return Catalog(graph: graph, columnTypes: types) + } + + private func quoteIdentifier(dialect: SQLDialectDescriptor) -> (String) -> String { + guard let driver = services.databaseManager.driver(for: connection.id) else { + return quoteIdentifierFromDialect(dialect) + } + return { driver.quoteIdentifier($0) } + } + + // MARK: - Editing + + internal func isIncluded(_ table: String) -> Bool { + draft.contains(table) + } + + internal func setIncluded(_ isIncluded: Bool, table: String) { + if isIncluded { + draft.add(table, in: graph) + } else { + draft.remove(table, in: graph) + } + } + + internal func candidates(forTableAt index: Int) -> [JoinRelationship] { + draft.candidates(forTableAt: index, in: graph) + } + + internal func setCondition(_ condition: JoinRelationship?, forTableAt index: Int) { + draft.setCondition(condition, forTableAt: index, in: graph) + } + + internal func setKind(_ kind: JoinKind, forTableAt index: Int) { + draft.setKind(kind, forTableAt: index) + } + + internal func isSelected(_ column: JoinColumnRef) -> Bool { + draft.isSelected(column) + } + + internal func setSelected(_ isSelected: Bool, column: JoinColumnRef) { + draft.setSelected(isSelected, column: column, in: graph) + } + + internal func addFilter() { + guard let column = filterableColumns.first else { return } + draft.filters.append(JoinFilter(column: column)) + } + + internal func removeFilter(_ id: UUID) { + draft.filters.removeAll { $0.id == id } + } + + // Applied to the stored filter, never to the copy a row was drawn from. + internal func updateFilter(_ id: UUID, _ change: (inout JoinFilter) -> Void) { + guard let index = draft.filters.firstIndex(where: { $0.id == id }) else { return } + change(&draft.filters[index]) + } + + internal func conditionTitle(_ relationship: JoinRelationship) -> String { + relationship.columnPairs + .map { "\(relationship.childTable).\($0.child) = \(relationship.parentTable).\($0.parent)" } + .joined(separator: " AND ") + } +} diff --git a/apple/TablePro/Views/ERDiagram/ERDiagramAccessibility.swift b/apple/TablePro/Views/ERDiagram/ERDiagramAccessibility.swift index de08a35184..4d65b9e1dd 100644 --- a/apple/TablePro/Views/ERDiagram/ERDiagramAccessibility.swift +++ b/apple/TablePro/Views/ERDiagram/ERDiagramAccessibility.swift @@ -141,7 +141,7 @@ final class ERDiagramAccessibilityTree { node: node, rect: rect, relationships: joins.description(of: node.tableName), - isSelected: scene.selectedNodeId == node.id + isSelected: scene.selectedNodeIds.contains(node.id) ) kept[node.id] = element next.append(element) diff --git a/apple/TablePro/Views/ERDiagram/ERDiagramScene.swift b/apple/TablePro/Views/ERDiagram/ERDiagramScene.swift index 160cd87516..94d8207915 100644 --- a/apple/TablePro/Views/ERDiagram/ERDiagramScene.swift +++ b/apple/TablePro/Views/ERDiagram/ERDiagramScene.swift @@ -6,8 +6,13 @@ struct ERDiagramScene { var nodeRects: [UUID: CGRect] = [:] var nodeIndex: [String: UUID] = [:] var clusterColors: [UUID: NSColor] = [:] - var selectedNodeId: UUID? + var selectedNodeIds: [UUID] = [] var size = CGSize(width: 800, height: 600) + + var selectedNodeId: UUID? { + get { selectedNodeIds.last } + set { selectedNodeIds = newValue.map { [$0] } ?? [] } + } } @MainActor @@ -42,7 +47,7 @@ enum ERDiagramSceneRenderer { ERDiagramNodeRenderer.drawNode( node: node, rect: rect, - isSelected: scene.selectedNodeId == node.id, + isSelected: scene.selectedNodeIds.contains(node.id), clusterColor: scene.clusterColors[node.id], palette: palette, on: canvas @@ -72,7 +77,7 @@ enum ERDiagramSceneRenderer { private static func unselected(_ scene: ERDiagramScene) -> ERDiagramScene { var flat = scene - flat.selectedNodeId = nil + flat.selectedNodeIds = [] return flat } diff --git a/apple/TablePro/Views/ERDiagram/ERDiagramSceneView.swift b/apple/TablePro/Views/ERDiagram/ERDiagramSceneView.swift index 7813ecf13a..d6d4b37192 100644 --- a/apple/TablePro/Views/ERDiagram/ERDiagramSceneView.swift +++ b/apple/TablePro/Views/ERDiagram/ERDiagramSceneView.swift @@ -36,6 +36,8 @@ struct ERDiagramCanvasActions { let endDrag: () -> Void let scrollBy: (CGSize) -> Void let copyImage: () -> Void + var toggleSelection: (UUID) -> Void = { _ in } + var buildJoinQuery: (() -> Void)? } final class ERDiagramSceneView: NSView { @@ -49,7 +51,7 @@ final class ERDiagramSceneView: NSView { if accessibilityTree.hasBeenAsked { _ = accessibilityTree.elements(for: scene, owner: self) } - announceSelectionChange(from: oldValue.selectedNodeId) + announceSelectionChange(from: oldValue.selectedNodeIds) } } @@ -60,6 +62,7 @@ final class ERDiagramSceneView: NSView { let windowStart: CGPoint var lastWindowPoint: CGPoint let nodeId: UUID? + let togglesSelection: Bool var isDragging = false } @@ -156,16 +159,40 @@ final class ERDiagramSceneView: NSView { actions?.copyImage() } + // While the diagram has focus, Database > Build Join Query starts from its tables, not the sidebar's. + @objc func buildJoinQuery(_ sender: Any?) { + actions?.buildJoinQuery?() + } + + override func menu(for event: NSEvent) -> NSMenu? { + guard let actions, actions.buildJoinQuery != nil else { return nil } + guard let nodeId = actions.nodeAt(convert(event.locationInWindow, from: nil)) else { return nil } + if !scene.selectedNodeIds.contains(nodeId) { + actions.select(nodeId) + } + let menu = NSMenu() + let item = NSMenuItem( + title: String(localized: "Build Join Query…"), + action: #selector(buildJoinQuery(_:)), + keyEquivalent: "" + ) + item.target = self + menu.addItem(item) + return menu + } + // MARK: - Pointer override func mouseDown(with event: NSEvent) { window?.makeFirstResponder(self) let point = convert(event.locationInWindow, from: nil) + let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) press = Press( documentStart: point, windowStart: event.locationInWindow, lastWindowPoint: event.locationInWindow, - nodeId: actions?.nodeAt(point) + nodeId: actions?.nodeAt(point), + togglesSelection: modifiers.contains(.command) || modifiers.contains(.shift) ) } @@ -204,6 +231,10 @@ final class ERDiagramSceneView: NSView { self.press = nil if press.isDragging { actions?.endDrag() + } else if press.togglesSelection { + if let nodeId = press.nodeId { + actions?.toggleSelection(nodeId) + } } else { actions?.select(press.nodeId) } @@ -288,7 +319,9 @@ final class ERDiagramSceneView: NSView { } override func accessibilitySelectedChildren() -> [Any]? { - selectedElement.map { [$0] } ?? [] + guard !scene.selectedNodeIds.isEmpty else { return [] } + _ = accessibilityTree.elements(for: scene, owner: self) + return scene.selectedNodeIds.compactMap { accessibilityTree.element(for: $0) } } /// A layout area answers for its focus as well as its selection, or VoiceOver never moves to @@ -303,10 +336,17 @@ final class ERDiagramSceneView: NSView { return accessibilityTree.element(for: selected) } - private func announceSelectionChange(from previous: UUID?) { - guard accessibilityTree.hasBeenAsked, previous != scene.selectedNodeId else { return } + private func announceSelectionChange(from previous: [UUID]) { + guard accessibilityTree.hasBeenAsked, previous != scene.selectedNodeIds else { return } NSAccessibility.post(element: self, notification: .selectedChildrenChanged) guard let element = selectedElement else { return } NSAccessibility.post(element: element, notification: .focusedUIElementChanged) } } + +extension ERDiagramSceneView: NSMenuItemValidation { + func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + guard menuItem.action == #selector(buildJoinQuery(_:)) else { return true } + return actions?.buildJoinQuery != nil + } +} diff --git a/apple/TablePro/Views/ERDiagram/ERDiagramToolbar.swift b/apple/TablePro/Views/ERDiagram/ERDiagramToolbar.swift index dd38f011e5..ab9895a6e7 100644 --- a/apple/TablePro/Views/ERDiagram/ERDiagramToolbar.swift +++ b/apple/TablePro/Views/ERDiagram/ERDiagramToolbar.swift @@ -5,6 +5,7 @@ struct ERDiagramToolbar: View { @ObservedObject var viewport: DiagramViewportController let onExport: (ERDiagramExportFormat) -> Void let onCopyMermaid: () -> Void + var onBuildJoinQuery: (() -> Void)? var body: some View { DiagramZoomToolbar(viewport: viewport) { @@ -36,6 +37,15 @@ struct ERDiagramToolbar: View { .help(String(localized: "Reset Layout")) .accessibilityLabel(String(localized: "Reset Layout")) + if let onBuildJoinQuery { + Button(action: onBuildJoinQuery) { + Image(systemName: "arrow.triangle.merge") + } + .help(String(localized: "Build a join query from the selected tables")) + .accessibilityLabel(String(localized: "Build Join Query")) + .accessibilityIdentifier("er-diagram-build-join") + } + exportMenu } .padding(12) diff --git a/apple/TablePro/Views/ERDiagram/ERDiagramView.swift b/apple/TablePro/Views/ERDiagram/ERDiagramView.swift index f088eac1aa..6c1114b17f 100644 --- a/apple/TablePro/Views/ERDiagram/ERDiagramView.swift +++ b/apple/TablePro/Views/ERDiagram/ERDiagramView.swift @@ -3,6 +3,7 @@ import SwiftUI struct ERDiagramView: View { @ObservedObject var viewModel: ERDiagramViewModel + var onBuildJoinQuery: (([String]) -> Void)? @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor private var viewport: DiagramViewportController { viewModel.viewport } @@ -48,7 +49,8 @@ struct ERDiagramView: View { viewModel: viewModel, viewport: viewport, onExport: export, - onCopyMermaid: copyMermaid + onCopyMermaid: copyMermaid, + onBuildJoinQuery: buildJoinQuery ) } } @@ -73,6 +75,12 @@ struct ERDiagramView: View { ) } + private var buildJoinQuery: (() -> Void)? { + guard let onBuildJoinQuery else { return nil } + let viewModel = viewModel + return { onBuildJoinQuery(viewModel.selectedTableNames) } + } + private var canvasActions: ERDiagramCanvasActions { let viewModel = viewModel return ERDiagramCanvasActions( @@ -85,7 +93,9 @@ struct ERDiagramView: View { copyImage: { guard let image = exportImage() else { return } ClipboardService.shared.writeImage(image) - } + }, + toggleSelection: { viewModel.toggleSelection($0) }, + buildJoinQuery: buildJoinQuery ) } @@ -96,7 +106,7 @@ struct ERDiagramView: View { nodeRects: viewModel.cachedNodeRects, nodeIndex: viewModel.graph.nodeIndex, clusterColors: nodeClusterColors(nodes: viewModel.graph.nodes), - selectedNodeId: viewModel.selectedNodeId, + selectedNodeIds: viewModel.selectedNodeIds, size: viewModel.cachedCanvasSize ) } diff --git a/apple/TablePro/Views/JoinBuilder/JoinBuilderSheet.swift b/apple/TablePro/Views/JoinBuilder/JoinBuilderSheet.swift new file mode 100644 index 0000000000..502e301406 --- /dev/null +++ b/apple/TablePro/Views/JoinBuilder/JoinBuilderSheet.swift @@ -0,0 +1,277 @@ +// +// JoinBuilderSheet.swift +// TablePro +// + +import SwiftUI + +internal struct JoinBuilderSheet: View { + @Environment(\.dismiss) private var dismiss + @StateObject internal var model: JoinBuilderViewModel + internal let onOpen: (String) -> Void + + internal var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + Divider() + footer + } + .frame(width: 860, height: 620) + .task { await model.loadIfNeeded() } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text(String(localized: "Build Join Query")) + .font(.title3.weight(.semibold)) + Text(model.scopeDescription) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + } + + @ViewBuilder + private var content: some View { + switch model.loadState { + case .loading: + VStack(spacing: 8) { + ProgressView().controlSize(.small) + Text(String(localized: "Reading the tables…")) + .font(.callout) + .foregroundStyle(.secondary) + } + case .failed(let message): + UnavailableStateView { + Label(String(localized: "Cannot Read the Tables"), systemImage: "exclamationmark.triangle") + } description: { + RevealedTextView(message) + } actions: { + Button(String(localized: "Try Again")) { Task { await model.load() } } + } + case .loaded: + if model.graph.columnsByTable.isEmpty { + UnavailableStateView { + Label(String(localized: "No Tables"), systemImage: "tray") + } description: { + Text(String(localized: "This database reports no tables to join.")) + } + } else { + HStack(spacing: 0) { + JoinBuilderTableList(model: model) + .frame(width: 260) + Divider() + JoinBuilderDetails(model: model) + .frame(maxWidth: .infinity) + } + } + } + } + + private var footer: some View { + DialogFooter { + statusText + } actions: { + Button(String(localized: "Cancel"), role: .cancel) { dismiss() } + .keyboardShortcut(.cancelAction) + Button(String(localized: "Open in Query Tab")) { + guard let sql = model.sql else { return } + onOpen(sql) + } + .keyboardShortcut(.defaultAction) + .disabled(model.sql == nil) + .accessibilityIdentifier("join-builder-open") + } + .padding(20) + } + + @ViewBuilder + private var statusText: some View { + let crossJoined = model.crossJoinedTables + if model.loadState == .loaded, model.draft.isEmpty { + Text(String(localized: "Pick the tables to join. Their foreign keys become the join conditions.")) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(2) + } else if !crossJoined.isEmpty { + Text(String( + format: String(localized: "No foreign key links %@ to the tables before it, so it is cross joined."), + ListFormatter.localizedString(byJoining: crossJoined) + )) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } +} + +private struct JoinBuilderTableList: View { + @ObservedObject var model: JoinBuilderViewModel + + var body: some View { + VStack(spacing: 0) { + NativeSearchField( + text: $model.tableFilter, + placeholder: String(localized: "Filter Tables"), + controlSize: .small, + accessibilityIdentifier: "join-builder-table-filter" + ) + .padding(.horizontal, 12) + .padding(.vertical, 8) + Divider() + List { + ForEach(model.filteredTables, id: \.self) { table in + tableRow(table) + if model.isIncluded(table) { + ForEach(model.graph.columns(of: table), id: \.self) { column in + columnRow(JoinColumnRef(table: table, column: column)) + } + } + } + } + .listStyle(.inset) + } + } + + private func tableRow(_ table: String) -> some View { + Toggle(isOn: Binding( + get: { model.isIncluded(table) }, + set: { model.setIncluded($0, table: table) } + )) { + Label(table, systemImage: "tablecells") + .lineLimit(1) + .truncationMode(.middle) + } + .toggleStyle(.checkbox) + .accessibilityIdentifier("join-builder-table-\(table)") + } + + private func columnRow(_ column: JoinColumnRef) -> some View { + Toggle(isOn: Binding( + get: { model.isSelected(column) }, + set: { model.setSelected($0, column: column) } + )) { + Text(column.column) + .lineLimit(1) + .truncationMode(.middle) + } + .toggleStyle(.checkbox) + .padding(.leading, 22) + .accessibilityLabel(String(format: String(localized: "%1$@ column of %2$@"), column.column, column.table)) + } +} + +private struct JoinBuilderDetails: View { + @ObservedObject var model: JoinBuilderViewModel + + var body: some View { + Form { + Section(String(localized: "Joins")) { + if model.draft.isEmpty { + Text(String(localized: "No tables picked")) + .foregroundStyle(.secondary) + } else { + ForEach(Array(model.draft.tables.enumerated()), id: \.element.id) { index, table in + joinRow(index: index, table: table) + } + } + } + Section(String(localized: "Filters")) { + ForEach(model.draft.filters) { filter in + filterRow(filter) + } + Button(String(localized: "Add Filter")) { model.addFilter() } + .disabled(model.draft.isEmpty) + } + Section(String(localized: "SQL Preview")) { + HighlightedSQLTextView( + sql: model.sql ?? "", + databaseType: model.connection.type, + accessibilityIdentifier: "join-builder-sql", + lineLayout: .fitted(maxLines: 10) + ) + } + } + .formStyle(.grouped) + } + + @ViewBuilder + private func joinRow(index: Int, table: JoinQueryDraft.Table) -> some View { + if index == 0 { + LabeledContent(String(localized: "From"), value: table.name) + } else { + VStack(alignment: .leading, spacing: 6) { + Picker(table.name, selection: Binding( + get: { table.kind }, + set: { model.setKind($0, forTableAt: index) } + )) { + ForEach(JoinKind.allCases) { kind in + Text(kind.displayName).tag(kind) + } + } + .disabled(table.condition == nil) + Picker(String(localized: "On"), selection: Binding( + get: { table.condition }, + set: { model.setCondition($0, forTableAt: index) } + )) { + ForEach(model.candidates(forTableAt: index)) { relationship in + Text(model.conditionTitle(relationship)).tag(Optional(relationship)) + } + Text(String(localized: "None (Cross Join)")).tag(JoinRelationship?.none) + } + } + } + } + + private func filterRow(_ filter: JoinFilter) -> some View { + HStack(spacing: 8) { + Picker(String(localized: "Column"), selection: Binding( + get: { filter.column }, + set: { column in model.updateFilter(filter.id) { $0.column = column } } + )) { + ForEach(model.filterableColumns, id: \.self) { column in + Text(verbatim: "\(column.table).\(column.column)").tag(column) + } + } + .labelsHidden() + Picker(String(localized: "Filter operator"), selection: Binding( + get: { filter.filterOperator }, + set: { op in model.updateFilter(filter.id) { $0.filterOperator = op } } + )) { + ForEach(FilterOperator.allCases) { op in + Text(op.displayName).tag(op) + } + } + .labelsHidden() + .fixedSize() + if filter.filterOperator.requiresValue { + TextField(String(localized: "Value"), text: Binding( + get: { filter.value }, + set: { value in model.updateFilter(filter.id) { $0.value = value } } + )) + .labelsHidden() + } + if filter.filterOperator.requiresSecondValue { + TextField(String(localized: "Second value"), text: Binding( + get: { filter.secondValue }, + set: { value in model.updateFilter(filter.id) { $0.secondValue = value } } + )) + .labelsHidden() + } + Button { + model.removeFilter(filter.id) + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + .help(String(localized: "Remove Filter")) + .accessibilityLabel(String(localized: "Remove Filter")) + } + } +} diff --git a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift index 73f1e83d78..60161ffa16 100644 --- a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -403,7 +403,7 @@ struct MainEditorContentView: View { private func erDiagramContent(tab: QueryTab) -> some View { Group { if let vm = erDiagramViewModels[tab.id] { - ERDiagramView(viewModel: vm) + ERDiagramView(viewModel: vm, onBuildJoinQuery: buildJoinQuery(from: vm)) } else { ProgressView(String(localized: "Loading schema…")) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -421,6 +421,14 @@ struct MainEditorContentView: View { .id(tab.id) } + private func buildJoinQuery(from diagram: ERDiagramViewModel) -> (([String]) -> Void)? { + guard coordinator.canBuildJoinQuery else { return nil } + let coordinator = coordinator + return { tables in + coordinator.openJoinBuilder(database: diagram.databaseName, schema: diagram.schemaName, tables: tables) + } + } + // MARK: - Per-tab container picker private var containerSwitchTarget: ContainerSwitchTarget? { diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+JoinBuilder.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+JoinBuilder.swift new file mode 100644 index 0000000000..a8db0d7519 --- /dev/null +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+JoinBuilder.swift @@ -0,0 +1,41 @@ +// +// MainContentCoordinator+JoinBuilder.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +@MainActor +extension MainContentCoordinator { + var canBuildJoinQuery: Bool { + JoinBuilderEligibility.supportsJoinBuilder(connection.type) + } + + func openJoinBuilder(database: String?, schema: String?, tables: [String]) { + guard canBuildJoinQuery else { return } + activeSheet = .joinBuilder(JoinBuilderLaunchRequest( + database: database?.nilIfEmpty ?? browseDatabaseName, + schema: schema, + tables: tables + )) + } + + // One query joins one schema, so only the tables sharing the first one's are seeded. + func openJoinBuilder(seededWith selection: Set) { + let refs = selection.sorted { $0.table.name.localizedStandardCompare($1.table.name) == .orderedAscending } + guard let first = refs.first else { + openJoinBuilder(database: nil, schema: nil, tables: []) + return + } + let tables = refs + .filter { $0.database == first.database && $0.qualifyingSchema == first.qualifyingSchema } + .map(\.table.name) + openJoinBuilder(database: first.database, schema: first.qualifyingSchema, tables: tables) + } + + func openJoinQuery(_ sql: String, database: String) { + activeSheet = nil + loadQueryIntoEditor(sql, databaseName: database, forceNewTab: true) + } +} diff --git a/apple/TablePro/Views/Main/MainContentCommandActions.swift b/apple/TablePro/Views/Main/MainContentCommandActions.swift index 69edb9d819..a0c94332c2 100644 --- a/apple/TablePro/Views/Main/MainContentCommandActions.swift +++ b/apple/TablePro/Views/Main/MainContentCommandActions.swift @@ -528,6 +528,14 @@ final class MainContentCommandActions: ObservableObject { !selectedTables.wrappedValue.isEmpty } + var canBuildJoinQuery: Bool { + isConnected && coordinator?.canBuildJoinQuery == true + } + + func buildJoinQuery() { + coordinator?.openJoinBuilder(seededWith: selectedTables.wrappedValue) + } + /// A selection can be perfectly valid and still hold nothing truncatable, so the menu bar asks /// this rather than `hasTableSelection`, which is what let it stage a `TRUNCATE` on a view. var canTruncateSelectedTables: Bool { diff --git a/apple/TablePro/Views/Main/MainContentCoordinator.swift b/apple/TablePro/Views/Main/MainContentCoordinator.swift index 7a780869f4..b25d816683 100644 --- a/apple/TablePro/Views/Main/MainContentCoordinator.swift +++ b/apple/TablePro/Views/Main/MainContentCoordinator.swift @@ -96,6 +96,7 @@ enum ActiveSheet: Identifiable { /// browser points by the time Save is pressed. case documentEditor(DocumentEditorRequest) case searchDatabase(DatabaseSearchSession) + case joinBuilder(JoinBuilderLaunchRequest) var id: String { switch self { @@ -125,6 +126,7 @@ enum ActiveSheet: Identifiable { case .tableRebuildReview: "tableRebuildReview" case .documentEditor(let request): "documentEditor-\(request.id.uuidString)" case .searchDatabase(let session): "searchDatabase-\(session.id.uuidString)" + case .joinBuilder(let launch): "joinBuilder-\(launch.id.uuidString)" } } } diff --git a/apple/TablePro/Views/Main/MainContentView.swift b/apple/TablePro/Views/Main/MainContentView.swift index dfac877c35..7b35d165ae 100644 --- a/apple/TablePro/Views/Main/MainContentView.swift +++ b/apple/TablePro/Views/Main/MainContentView.swift @@ -247,6 +247,10 @@ struct MainContentView: View { DatabaseSearchSheet(session: session) { result in coordinator.openDatabaseSearchResult(result, in: session) } + case .joinBuilder(let launch): + JoinBuilderSheet(model: JoinBuilderViewModel(launch: launch, connection: connection)) { sql in + coordinator.openJoinQuery(sql, database: launch.database) + } case .exportDialog, .exportQueryResults, .importDialog, .rowImport, .rowImportBatch, .transferTables, .backupDatabase, .restoreDatabase: transferSheetContent(for: sheet, dismiss: dismissBinding) diff --git a/apple/TableProTests/Core/JoinBuilder/JoinGraphTests.swift b/apple/TableProTests/Core/JoinBuilder/JoinGraphTests.swift new file mode 100644 index 0000000000..2a17d995d5 --- /dev/null +++ b/apple/TableProTests/Core/JoinBuilder/JoinGraphTests.swift @@ -0,0 +1,202 @@ +import Foundation +import Testing + +@testable import TablePro + +struct JoinGraphTests { + static let columns: [String: [String]] = [ + "customers": ["id", "name"], + "orders": ["id", "customer_id", "status"], + "products": ["id", "title"], + "order_items": ["order_id", "product_id", "quantity"], + "notes": ["id", "body"] + ] + + static let foreignKeys: [String: [ForeignKeyInfo]] = [ + "orders": [ + ForeignKeyInfo(name: "orders_customer_fk", column: "customer_id", referencedTable: "customers", referencedColumn: "id") + ], + "order_items": [ + ForeignKeyInfo(name: "items_order_fk", column: "order_id", referencedTable: "orders", referencedColumn: "id"), + ForeignKeyInfo(name: "items_product_fk", column: "product_id", referencedTable: "products", referencedColumn: "id") + ] + ] + + static func graph( + columns: [String: [String]] = columns, + foreignKeys: [String: [ForeignKeyInfo]] = foreignKeys, + isLocal: (ForeignKeyInfo) -> Bool = { _ in true } + ) -> JoinGraph { + JoinGraph(columns: columns, foreignKeys: foreignKeys, isLocal: isLocal) + } + + @Test("A key between two picked tables is found from either side") + func directKeyEitherDirection() throws { + let graph = Self.graph() + + let fromOrders = try #require(graph.path(to: "customers", from: ["orders"])) + let fromCustomers = try #require(graph.path(to: "orders", from: ["customers"])) + + #expect(fromOrders.map { $0.table } == ["customers"]) + #expect(fromOrders.first?.relationship.childTable == "orders") + #expect(fromOrders.first?.relationship.columnPairs == [.init(child: "customer_id", parent: "id")]) + #expect(fromCustomers.map { $0.table } == ["orders"]) + #expect(fromCustomers.first?.relationship == fromOrders.first?.relationship) + } + + @Test("A many-to-many pair is linked through its junction table, the junction first") + func junctionPath() throws { + let steps = try #require(Self.graph().path(to: "products", from: ["orders"])) + + #expect(steps.map { $0.table } == ["order_items", "products"]) + #expect(steps.map { $0.relationship.name } == ["items_order_fk", "items_product_fk"]) + } + + @Test("A junction wins over a parent the two tables only share, whatever the tables are named") + func junctionBeatsSharedParent() throws { + let columns = [ + "orders": ["id", "created_by"], + "products": ["id", "created_by"], + "users": ["id"], + "zz_lines": ["order_id", "product_id"] + ] + let keys = [ + "orders": [ForeignKeyInfo(name: "orders_user_fk", column: "created_by", referencedTable: "users", referencedColumn: "id")], + "products": [ForeignKeyInfo(name: "products_user_fk", column: "created_by", referencedTable: "users", referencedColumn: "id")], + "zz_lines": [ + ForeignKeyInfo(name: "lines_order_fk", column: "order_id", referencedTable: "orders", referencedColumn: "id"), + ForeignKeyInfo(name: "lines_product_fk", column: "product_id", referencedTable: "products", referencedColumn: "id") + ] + ] + + let steps = try #require(Self.graph(columns: columns, foreignKeys: keys).path(to: "products", from: ["orders"])) + + #expect(steps.map { $0.table } == ["zz_lines", "products"]) + } + + @Test("A shared parent still links two tables when nothing else does") + func sharedParentWhenAlone() throws { + let columns = ["orders": ["id", "created_by"], "products": ["id", "created_by"], "users": ["id"]] + let keys = [ + "orders": [ForeignKeyInfo(name: "orders_user_fk", column: "created_by", referencedTable: "users", referencedColumn: "id")], + "products": [ForeignKeyInfo(name: "products_user_fk", column: "created_by", referencedTable: "users", referencedColumn: "id")] + ] + + let steps = try #require(Self.graph(columns: columns, foreignKeys: keys).path(to: "products", from: ["orders"])) + + #expect(steps.map { $0.table } == ["users", "products"]) + } + + @Test("A chain through a parent reaches its grandparent") + func chainThroughParent() throws { + let columns = ["track": ["id", "album_id"], "album": ["id", "artist_id"], "artist": ["id"]] + let keys = [ + "track": [ForeignKeyInfo(name: "track_album_fk", column: "album_id", referencedTable: "album", referencedColumn: "id")], + "album": [ForeignKeyInfo(name: "album_artist_fk", column: "artist_id", referencedTable: "artist", referencedColumn: "id")] + ] + + let steps = try #require(Self.graph(columns: columns, foreignKeys: keys).path(to: "artist", from: ["track"])) + + #expect(steps.map { $0.table } == ["album", "artist"]) + } + + @Test("The shortest chain wins over a longer one, from any table already picked") + func shortestFromAnyPickedTable() throws { + let steps = try #require(Self.graph().path(to: "products", from: ["customers", "order_items"])) + + #expect(steps.map { $0.table } == ["products"]) + } + + @Test("A table no key reaches has no path, and a picked one an empty path") + func unreachableAndAlreadyJoined() { + let graph = Self.graph() + + #expect(graph.path(to: "notes", from: ["orders"]) == nil) + #expect(graph.path(to: "orders", from: ["orders"]) == []) + #expect(graph.path(to: "missing", from: ["orders"]) == nil) + } + + @Test("A composite key is one relationship holding every column pair in order") + func compositeKeyIsOneRelationship() throws { + let columns = ["shipments": ["order_id", "line", "id"], "lines": ["order_id", "line"]] + let keys = ["shipments": [ + ForeignKeyInfo(name: "ship_line_fk", column: "order_id", referencedTable: "lines", referencedColumn: "order_id"), + ForeignKeyInfo(name: "ship_line_fk", column: "line", referencedTable: "lines", referencedColumn: "line") + ]] + + let graph = Self.graph(columns: columns, foreignKeys: keys) + + #expect(graph.relationships.count == 1) + let relationship = try #require(graph.relationships.first) + #expect(relationship.columnPairs == [ + .init(child: "order_id", parent: "order_id"), + .init(child: "line", parent: "line") + ]) + } + + @Test("Two keys to the same table stay two relationships") + func twoKeysToOneTable() { + let columns = ["orders": ["billing_id", "shipping_id"], "addresses": ["id"]] + let keys = ["orders": [ + ForeignKeyInfo(name: "billing_fk", column: "billing_id", referencedTable: "addresses", referencedColumn: "id"), + ForeignKeyInfo(name: "shipping_fk", column: "shipping_id", referencedTable: "addresses", referencedColumn: "id") + ]] + + let graph = Self.graph(columns: columns, foreignKeys: keys) + + #expect(graph.relationships(between: "orders", and: "addresses").map { $0.name } == ["billing_fk", "shipping_fk"]) + } + + @Test("A key into another schema never joins a same-named table of this one") + func foreignScopeKeyIsDropped() { + let keys = ["orders": [ + ForeignKeyInfo( + name: "orders_customer_fk", column: "customer_id", + referencedTable: "customers", referencedColumn: "id", referencedSchema: "billing" + ) + ]] + + let graph = Self.graph(foreignKeys: keys, isLocal: { $0.referencedSchema == nil }) + + #expect(graph.relationships.isEmpty) + #expect(graph.path(to: "customers", from: ["orders"]) == nil) + } + + @Test("A key spelled in another case than the catalog, as SQLite reports one, still links the tables") + func referenceSpellingFollowsTheCatalog() throws { + let columns = ["Parent": ["ID", "name"], "child": ["id", "Parent_Id"]] + let keys = ["child": [ + ForeignKeyInfo(name: "fk_child_0", column: "parent_id", referencedTable: "parent", referencedColumn: "id") + ]] + + let relationship = try #require(Self.graph(columns: columns, foreignKeys: keys).relationships.first) + + #expect(relationship.parentTable == "Parent") + #expect(relationship.columnPairs == [.init(child: "Parent_Id", parent: "ID")]) + } + + @Test("An exact spelling wins over another table that differs only in case") + func exactSpellingWins() throws { + let columns = ["Parent": ["id"], "parent": ["id"], "child": ["parent_id"]] + let keys = ["child": [ + ForeignKeyInfo(name: "fk", column: "parent_id", referencedTable: "parent", referencedColumn: "id") + ]] + + let relationship = try #require(Self.graph(columns: columns, foreignKeys: keys).relationships.first) + + #expect(relationship.parentTable == "parent") + } + + @Test("A self-referencing key is never offered as a join between two tables") + func selfReferenceIsNotAJoin() { + let columns = ["employees": ["id", "manager_id"], "teams": ["id"]] + let keys = ["employees": [ + ForeignKeyInfo(name: "manager_fk", column: "manager_id", referencedTable: "employees", referencedColumn: "id") + ]] + + let graph = Self.graph(columns: columns, foreignKeys: keys) + + #expect(graph.relationships(between: "employees", and: "employees").isEmpty) + #expect(graph.path(to: "teams", from: ["employees"]) == nil) + } +} diff --git a/apple/TableProTests/Core/JoinBuilder/JoinQueryDraftTests.swift b/apple/TableProTests/Core/JoinBuilder/JoinQueryDraftTests.swift new file mode 100644 index 0000000000..c03b38a25a --- /dev/null +++ b/apple/TableProTests/Core/JoinBuilder/JoinQueryDraftTests.swift @@ -0,0 +1,116 @@ +import Foundation +import Testing + +@testable import TablePro + +struct JoinQueryDraftTests { + private let graph = JoinGraphTests.graph() + + private func draft(_ tables: [String]) -> JoinQueryDraft { + var draft = JoinQueryDraft() + for table in tables { + draft.add(table, in: graph) + } + return draft + } + + @Test("Picking orders then customers joins customers on the order's key") + func secondTableTakesTheKey() { + let draft = draft(["orders", "customers"]) + + #expect(draft.tables.map { $0.name } == ["orders", "customers"]) + #expect(draft.tables[0].condition == nil) + #expect(draft.tables[1].condition?.name == "orders_customer_fk") + #expect(draft.tables[1].kind == .inner) + } + + @Test("Picking a many-to-many pair brings in the junction between them") + func junctionIsAdded() { + let draft = draft(["orders", "products"]) + + #expect(draft.tables.map { $0.name } == ["orders", "order_items", "products"]) + #expect(draft.tables[1].condition?.name == "items_order_fk") + #expect(draft.tables[2].condition?.name == "items_product_fk") + } + + @Test("A table no key reaches is added without a condition") + func unlinkedTableIsCrossJoined() { + let draft = draft(["orders", "notes"]) + + #expect(draft.tables.map { $0.name } == ["orders", "notes"]) + #expect(draft.tables[1].condition == nil) + } + + @Test("Removing the table a later one joined on relinks it to a remaining one, or cross joins it") + func removeRelinks() { + var linked = draft(["customers", "orders", "order_items"]) + linked.remove("orders", in: graph) + #expect(linked.tables.map { $0.name } == ["customers", "order_items"]) + #expect(linked.tables[1].condition == nil) + + var restarted = draft(["orders", "customers"]) + restarted.remove("orders", in: graph) + #expect(restarted.tables.map { $0.name } == ["customers"]) + #expect(restarted.tables[0].condition == nil) + + var relinked = draft(["orders", "order_items", "products"]) + relinked.add("customers", in: graph) + relinked.remove("order_items", in: graph) + #expect(relinked.tables.map { $0.name } == ["orders", "products", "customers"]) + #expect(relinked.tables[1].condition == nil) + #expect(relinked.tables[2].condition?.name == "orders_customer_fk") + } + + @Test("A cross join the user chose survives removing an unrelated table") + func chosenCrossJoinSurvives() { + var draft = draft(["orders", "customers"]) + draft.setCondition(nil, forTableAt: 1, in: graph) + draft.add("notes", in: graph) + + draft.remove("notes", in: graph) + + #expect(draft.tables.map { $0.name } == ["orders", "customers"]) + #expect(draft.tables[1].condition == nil) + } + + @Test("Removing a table drops the filters on its columns") + func removeDropsFilters() { + var draft = draft(["orders", "customers"]) + draft.filters = [ + JoinFilter(column: JoinColumnRef(table: "customers", column: "name"), value: "Ada"), + JoinFilter(column: JoinColumnRef(table: "orders", column: "status"), value: "paid") + ] + + draft.remove("customers", in: graph) + + #expect(draft.filters.map { $0.column.table } == ["orders"]) + } + + @Test("Only a key to an earlier table can be set as a condition") + func conditionMustReachAnEarlierTable() throws { + var draft = draft(["orders", "customers", "notes"]) + let key = try #require(graph.relationships(between: "orders", and: "customers").first) + + draft.setCondition(key, forTableAt: 2, in: graph) + #expect(draft.tables[2].condition == nil) + + draft.setCondition(nil, forTableAt: 1, in: graph) + #expect(draft.tables[1].condition == nil) + draft.setCondition(key, forTableAt: 1, in: graph) + #expect(draft.tables[1].condition == key) + #expect(draft.candidates(forTableAt: 1, in: graph) == [key]) + } + + @Test("Selected columns keep the table's own order whatever order they were ticked in") + func columnsKeepTableOrder() { + var draft = draft(["orders"]) + + draft.setSelected(true, column: JoinColumnRef(table: "orders", column: "status"), in: graph) + draft.setSelected(true, column: JoinColumnRef(table: "orders", column: "id"), in: graph) + #expect(draft.tables[0].selectedColumns == ["id", "status"]) + + draft.setSelected(false, column: JoinColumnRef(table: "orders", column: "id"), in: graph) + #expect(draft.tables[0].selectedColumns == ["status"]) + #expect(draft.isSelected(JoinColumnRef(table: "orders", column: "status"))) + } +} diff --git a/apple/TableProTests/Core/JoinBuilder/JoinQuerySQLWriterTests.swift b/apple/TableProTests/Core/JoinBuilder/JoinQuerySQLWriterTests.swift new file mode 100644 index 0000000000..f03b912426 --- /dev/null +++ b/apple/TableProTests/Core/JoinBuilder/JoinQuerySQLWriterTests.swift @@ -0,0 +1,143 @@ +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +struct JoinQuerySQLWriterTests { + private static let ansi = SQLDialectDescriptor( + identifierQuote: "\"", keywords: [], functions: [], dataTypes: [], + regexSyntax: .tilde, booleanLiteralStyle: .truefalse, + likeEscapeStyle: .explicit, paginationStyle: .limit + ) + + private static let backtick = SQLDialectDescriptor( + identifierQuote: "`", keywords: [], functions: [], dataTypes: [], + booleanLiteralStyle: .numeric, likeEscapeStyle: .explicit, paginationStyle: .limit, + requiresBackslashEscaping: true + ) + + private static let bracket = SQLDialectDescriptor( + identifierQuote: "[", keywords: [], functions: [], dataTypes: [], + booleanLiteralStyle: .numeric, likeEscapeStyle: .explicit, + paginationStyle: .offsetFetch, autoLimitStyle: .top + ) + + private let graph = JoinGraphTests.graph() + + private static let columnTypes: [String: [String: ColumnType]] = [ + "orders": [ + "id": .integer(rawType: "int"), + "customer_id": .integer(rawType: "int"), + "status": .text(rawType: "varchar") + ], + "customers": ["id": .integer(rawType: "int"), "name": .text(rawType: "varchar")] + ] + + private func writer( + dialect: SQLDialectDescriptor = Self.ansi, + databaseType: DatabaseType = .postgresql, + schema: String? = nil + ) -> JoinQuerySQLWriter { + JoinQuerySQLWriter( + graph: graph, + columnTypes: Self.columnTypes, + dialect: dialect, + databaseType: databaseType, + schema: schema, + quote: quoteIdentifierFromDialect(dialect) + ) + } + + private func draft(_ tables: [String]) -> JoinQueryDraft { + var draft = JoinQueryDraft() + for table in tables { + draft.add(table, in: graph) + } + return draft + } + + @Test("Picking orders and customers writes the join on the order's key") + func doneCriterion() { + let sql = writer().sql(for: draft(["orders", "customers"])) + + #expect(sql == """ + SELECT * + FROM "orders" + JOIN "customers" ON "orders"."customer_id" = "customers"."id" + """) + } + + @Test("Identifiers are quoted for the engine") + func quotingFollowsTheDialect() { + let mysql = writer(dialect: Self.backtick, databaseType: .mysql).sql(for: draft(["orders", "customers"])) + let sqlServer = writer(dialect: Self.bracket, databaseType: .mssql).sql(for: draft(["orders", "customers"])) + + #expect(mysql?.contains("JOIN `customers` ON `orders`.`customer_id` = `customers`.`id`") == true) + #expect(sqlServer?.contains("JOIN [customers] ON [orders].[customer_id] = [customers].[id]") == true) + } + + @Test("A junction pair writes two joins, and a left join says so") + func junctionAndLeftJoin() { + var draft = draft(["orders", "products"]) + draft.setKind(.left, forTableAt: 2) + + let sql = writer().sql(for: draft) + + #expect(sql == """ + SELECT * + FROM "orders" + JOIN "order_items" ON "order_items"."order_id" = "orders"."id" + LEFT JOIN "products" ON "order_items"."product_id" = "products"."id" + """) + } + + @Test("A table no key links is cross joined, whatever join type it was given") + func crossJoin() { + var draft = draft(["orders", "notes"]) + draft.setKind(.left, forTableAt: 1) + + #expect(writer().sql(for: draft)?.hasSuffix("\nCROSS JOIN \"notes\"") == true) + } + + @Test("Chosen columns replace the star, each named with its table") + func chosenColumns() { + var draft = draft(["orders", "customers"]) + draft.setSelected(true, column: JoinColumnRef(table: "customers", column: "name"), in: graph) + draft.setSelected(true, column: JoinColumnRef(table: "orders", column: "id"), in: graph) + + let sql = writer().sql(for: draft) + + #expect(sql?.hasPrefix("SELECT \"orders\".\"id\", \"customers\".\"name\"\nFROM") == true) + } + + @Test("Filters are typed and escaped the way the filter bar writes them, and joined with AND") + func filters() { + var draft = draft(["orders", "customers"]) + draft.filters = [ + JoinFilter(column: JoinColumnRef(table: "customers", column: "name"), value: "O'Hara"), + JoinFilter(column: JoinColumnRef(table: "orders", column: "id"), filterOperator: .greaterThan, value: "10"), + JoinFilter(column: JoinColumnRef(table: "orders", column: "status"), value: "") + ] + + let sql = writer().sql(for: draft) + + #expect(sql?.hasSuffix("\nWHERE \"customers\".\"name\" = 'O''Hara' AND \"orders\".\"id\" > 10") == true) + } + + @Test("Tables are named in the builder's schema only when the tab would not resolve them there") + func schemaQualification() { + let sql = writer(schema: "sales").sql(for: draft(["orders", "customers"])) + + #expect(sql == """ + SELECT * + FROM "sales"."orders" + JOIN "sales"."customers" ON "orders"."customer_id" = "customers"."id" + """) + } + + @Test("Nothing picked writes nothing") + func emptyDraft() { + #expect(writer().sql(for: JoinQueryDraft()) == nil) + } +} diff --git a/apple/TableProTests/Core/Menu/ChromeSnapshot.json b/apple/TableProTests/Core/Menu/ChromeSnapshot.json index b89ef0c615..9d709aff7a 100644 --- a/apple/TableProTests/Core/Menu/ChromeSnapshot.json +++ b/apple/TableProTests/Core/Menu/ChromeSnapshot.json @@ -219,6 +219,7 @@ {"path": "Database > Truncate Table", "identifier": "shortcut.truncateTable", "action": "truncateTable:", "key": "backspace", "modifiers": ["option"]}, {"path": "Database", "separator": true}, {"path": "Database > View ER Diagram", "action": "showERDiagram:"}, + {"path": "Database > Build Join Query…", "action": "buildJoinQuery:"}, {"path": "Database > Server Dashboard", "action": "showServerDashboard:"}, {"path": "Database > Users & Roles", "action": "showUsersAndRoles:"}, {"path": "Database > Query Insights", "action": "showQueryInsights:"}, diff --git a/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift index bc8b29de0e..32c435490a 100644 --- a/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -1105,6 +1105,20 @@ struct MainMenuValidationTests { #expect(enabled(#selector(MainSplitViewController.duplicateCurrentDatabase(_:)), context)) } + @Test("Build Join Query needs a connection and an engine that can join on foreign keys") + func buildJoinQueryNeedsAnEligibleEngine() { + var context = MenuValidationContext() + context.canBuildJoinQuery = true + #expect(!enabled(#selector(MainSplitViewController.buildJoinQuery(_:)), context)) + context.isConnected = true + #expect(enabled(#selector(MainSplitViewController.buildJoinQuery(_:)), context)) + context.isAgentMode = true + #expect(!enabled(#selector(MainSplitViewController.buildJoinQuery(_:)), context)) + context.isAgentMode = false + context.canBuildJoinQuery = false + #expect(!enabled(#selector(MainSplitViewController.buildJoinQuery(_:)), context)) + } + @Test("Search Database needs a connection and an engine it can search") func searchDatabaseNeedsAnEligibleEngine() { var context = MenuValidationContext() diff --git a/apple/TableProTests/Core/Services/ForeignKeyTargetScopeTests.swift b/apple/TableProTests/Core/Services/ForeignKeyTargetScopeTests.swift index ff4b7baa07..abcff8a8af 100644 --- a/apple/TableProTests/Core/Services/ForeignKeyTargetScopeTests.swift +++ b/apple/TableProTests/Core/Services/ForeignKeyTargetScopeTests.swift @@ -24,6 +24,28 @@ struct ForeignKeyTargetScopeTests { #expect(EngineNamespaceSlot(supportsSchemas: false, supportsDatabases: false) == .unqualified) } + // MARK: - Within the origin + + @Test("A key into another schema or database points outside, one into the same one or nowhere stays") + func pointsWithinTheOrigin() { + let sales = origin(schema: "sales") + #expect(ForeignKeyTargetScope.pointsWithin(sales, referencedDatabase: nil, referencedSchema: "sales", slot: .schema)) + #expect(ForeignKeyTargetScope.pointsWithin(sales, referencedDatabase: nil, referencedSchema: nil, slot: .schema)) + #expect(!ForeignKeyTargetScope.pointsWithin(sales, referencedDatabase: nil, referencedSchema: "billing", slot: .schema)) + #expect(!ForeignKeyTargetScope.pointsWithin(sales, referencedDatabase: "archive", referencedSchema: "sales", slot: .schema)) + + let shop = origin(database: "shop") + #expect(ForeignKeyTargetScope.pointsWithin(shop, referencedDatabase: nil, referencedSchema: "shop", slot: .database)) + #expect(!ForeignKeyTargetScope.pointsWithin(shop, referencedDatabase: nil, referencedSchema: "audit", slot: .database)) + #expect(!ForeignKeyTargetScope.pointsWithin(sales, referencedDatabase: nil, referencedSchema: "Sales", slot: .schema)) + } + + @Test("A part the origin or the engine leaves unnamed keeps the key") + func unnamedPartKeepsKeys() { + #expect(ForeignKeyTargetScope.pointsWithin(origin(), referencedDatabase: nil, referencedSchema: "main", slot: .schema)) + #expect(ForeignKeyTargetScope.pointsWithin(origin(schema: "main"), referencedDatabase: nil, referencedSchema: "x", slot: .unqualified)) + } + // MARK: - Resolution /// DuckDB, Trino, BigQuery, SurrealDB and CloudflareR2SQL all declare schemas and never report a diff --git a/apple/TableProTests/Helpers/ERDiagramLoadFixture.swift b/apple/TableProTests/Helpers/ERDiagramLoadFixture.swift index 34d8583fa6..14fb3815fa 100644 --- a/apple/TableProTests/Helpers/ERDiagramLoadFixture.swift +++ b/apple/TableProTests/Helpers/ERDiagramLoadFixture.swift @@ -43,6 +43,7 @@ internal final class ERDiagramCatalogDriver: DatabaseDriver, @unchecked Sendable internal var serverVersion: String? { nil } private let columns: [String: [ColumnInfo]] + private let foreignKeys: [String: [ForeignKeyInfo]] private let holds: [CatalogReadHold] private let failingReads: Int private let lock = NSLock() @@ -51,11 +52,13 @@ internal final class ERDiagramCatalogDriver: DatabaseDriver, @unchecked Sendable internal init( connection: DatabaseConnection, columns: [String: [ColumnInfo]], + foreignKeys: [String: [ForeignKeyInfo]] = [:], holds: [CatalogReadHold], failingReads: Int ) { self.connection = connection self.columns = columns + self.foreignKeys = foreignKeys self.holds = holds self.failingReads = failingReads } @@ -77,7 +80,7 @@ internal final class ERDiagramCatalogDriver: DatabaseDriver, @unchecked Sendable return columns } - internal func fetchAllForeignKeys() async throws -> [String: [ForeignKeyInfo]] { [:] } + internal func fetchAllForeignKeys() async throws -> [String: [ForeignKeyInfo]] { foreignKeys } internal func connect() async throws {} internal func disconnect() {} @@ -153,7 +156,9 @@ internal struct ERDiagramLoadFixture { tableCount: Int = 3, columnsPerTable: Int = 50, holds: [CatalogReadHold] = [], - failingReads: Int = 0 + failingReads: Int = 0, + foreignKeys: [String: [ForeignKeyInfo]] = [:], + schema: String? = nil ) { let connection = TestFixtures.makeConnection(database: "main", type: .duckdb) let columns = Dictionary(uniqueKeysWithValues: (0 ..< tableCount).map { table in @@ -165,6 +170,7 @@ internal struct ERDiagramLoadFixture { let driver = ERDiagramCatalogDriver( connection: connection, columns: columns, + foreignKeys: foreignKeys, holds: holds, failingReads: failingReads ) @@ -180,7 +186,7 @@ internal struct ERDiagramLoadFixture { self.viewModel = ERDiagramViewModel( connectionId: connection.id, databaseName: connection.database, - schemaKey: "\(connection.database).default", + schemaKey: ERDiagramViewModel.schemaKey(databaseName: connection.database, schema: schema), services: Self.services(with: databaseManager) ) } diff --git a/apple/TableProTests/Models/ERDiagram/ERDiagramGraphBuilderTests.swift b/apple/TableProTests/Models/ERDiagram/ERDiagramGraphBuilderTests.swift index 949c5e4f29..ddcbc0e897 100644 --- a/apple/TableProTests/Models/ERDiagram/ERDiagramGraphBuilderTests.swift +++ b/apple/TableProTests/Models/ERDiagram/ERDiagramGraphBuilderTests.swift @@ -41,6 +41,22 @@ struct ERDiagramGraphBuilderTests { return node.isJunctionTable } + @Test("A key spelled in another case than the catalog, as SQLite reports one, still draws its edge") + func referenceSpellingFollowsTheCatalog() { + let graph = ERDiagramGraphBuilder.build( + allColumns: [ + "Parent": [column("ID", primaryKey: true)], + "child": [column("id", primaryKey: true), column("Parent_Id")] + ], + allForeignKeys: ["child": [foreignKey(column: "parent_id", references: "parent", "id")]] + ) + + #expect(graph.edges.map { "\($0.fromTable).\($0.fromColumn) -> \($0.toTable).\($0.toColumn)" } == [ + "child.Parent_Id -> Parent.ID" + ]) + #expect(graph.nodes.first { $0.tableName == "child" }?.columns.first { $0.name == "Parent_Id" }?.isForeignKey == true) + } + // MARK: - Cardinality @Test("Primary-key foreign key that is NOT NULL is one-to-one") diff --git a/apple/TableProTests/ViewModels/ERDiagramViewModelLoadTests.swift b/apple/TableProTests/ViewModels/ERDiagramViewModelLoadTests.swift index 9460034dce..4dad38ac4b 100644 --- a/apple/TableProTests/ViewModels/ERDiagramViewModelLoadTests.swift +++ b/apple/TableProTests/ViewModels/ERDiagramViewModelLoadTests.swift @@ -66,4 +66,28 @@ struct ERDiagramViewModelLoadTests { #expect(fixture.viewModel.loadState == .loaded) #expect(fixture.driver.catalogReadCount == 2) } + + @Test("A key into another schema is not drawn to a same-named table of this one") + func keyIntoAnotherSchemaIsNotDrawn() async { + let fixture = ERDiagramLoadFixture( + foreignKeys: [ + "table_1": [ForeignKeyInfo( + name: "local_fk", column: "column_1", referencedTable: "table_0", + referencedColumn: "column_0", referencedSchema: "main" + )], + "table_2": [ForeignKeyInfo( + name: "audit_fk", column: "column_1", referencedTable: "table_0", + referencedColumn: "column_0", referencedSchema: "audit" + )] + ], + schema: "main" + ) + defer { fixture.tearDown() } + + await fixture.viewModel.loadDiagram() + + #expect(fixture.viewModel.graph.edges.map { $0.fkName } == ["local_fk"]) + let audited = fixture.viewModel.graph.nodes.first { $0.tableName == "table_2" } + #expect(audited?.columns.first { $0.name == "column_1" }?.isForeignKey == true) + } } diff --git a/apple/TableProTests/Views/ERDiagram/DiagramPaintCoverageTests.swift b/apple/TableProTests/Views/ERDiagram/DiagramPaintCoverageTests.swift index deff235b29..5fbfc86da5 100644 --- a/apple/TableProTests/Views/ERDiagram/DiagramPaintCoverageTests.swift +++ b/apple/TableProTests/Views/ERDiagram/DiagramPaintCoverageTests.swift @@ -37,7 +37,7 @@ struct DiagramPaintCoverageTests { ], nodeIndex: [near.tableName: near.id, far.tableName: far.id], clusterColors: [:], - selectedNodeId: nil, + selectedNodeIds: [], size: Self.canvasSize ) } diff --git a/apple/TableProTests/Views/ERDiagram/ERDiagramAccessibilityTests.swift b/apple/TableProTests/Views/ERDiagram/ERDiagramAccessibilityTests.swift index 0ed0447ccc..a0f20aa78b 100644 --- a/apple/TableProTests/Views/ERDiagram/ERDiagramAccessibilityTests.swift +++ b/apple/TableProTests/Views/ERDiagram/ERDiagramAccessibilityTests.swift @@ -49,7 +49,7 @@ struct ERDiagramAccessibilityTests { ], nodeIndex: ["employees": employees.id, "organization": organization.id], clusterColors: [:], - selectedNodeId: nil, + selectedNodeIds: [], size: CGSize(width: 800, height: 600) ) } @@ -191,7 +191,7 @@ struct ERDiagramAccessibilityTests { ], nodeIndex: ["users": left.id, "roles": right.id], clusterColors: [:], - selectedNodeId: nil, + selectedNodeIds: [], size: CGSize(width: 800, height: 600) ) let joins = ERDiagramAccessibilityTree.Joins(scene: scene) @@ -306,7 +306,7 @@ struct ERDiagramAccessibilityTests { nodeRects: [employees.id: CGRect(x: 40, y: 40, width: ERDiagramLayout.nodeWidth, height: 80)], nodeIndex: ["employees": employees.id], clusterColors: [:], - selectedNodeId: nil, + selectedNodeIds: [], size: CGSize(width: 800, height: 600) ) diff --git a/apple/TableProTests/Views/ERDiagram/ERDiagramPointerInputTests.swift b/apple/TableProTests/Views/ERDiagram/ERDiagramPointerInputTests.swift index 6ba5e5528f..dd95ef41b9 100644 --- a/apple/TableProTests/Views/ERDiagram/ERDiagramPointerInputTests.swift +++ b/apple/TableProTests/Views/ERDiagram/ERDiagramPointerInputTests.swift @@ -16,12 +16,15 @@ struct ERDiagramPointerInputTests { var endedDrags = 0 var scrolls: [CGSize] = [] var copies = 0 + var toggles: [UUID] = [] + var joinRequests = 0 } private struct Fixture { let window: NSWindow let sceneView: ERDiagramSceneView let recorder: Recorder + let nearId: UUID let farId: UUID } @@ -34,7 +37,7 @@ struct ERDiagramPointerInputTests { CGRect(x: centre.x - 100, y: centre.y - 50, width: 200, height: 100) } - private func makeFixture(magnification: CGFloat) -> Fixture { + private func makeFixture(magnification: CGFloat, offersJoins: Bool = true) -> Fixture { let near = ERTableNode(id: UUID(), tableName: "near", columns: [], displayColumns: [], clusterId: nil) let far = ERTableNode(id: UUID(), tableName: "far", columns: [], displayColumns: [], clusterId: nil) let scene = ERDiagramScene( @@ -43,7 +46,7 @@ struct ERDiagramPointerInputTests { nodeRects: [near.id: rect(centredOn: Self.nearCentre), far.id: rect(centredOn: Self.farCentre)], nodeIndex: [near.tableName: near.id, far.tableName: far.id], clusterColors: [:], - selectedNodeId: nil, + selectedNodeIds: [], size: Self.canvasSize ) @@ -64,7 +67,9 @@ struct ERDiagramPointerInputTests { updateDrag: { translation, _ in recorder.dragTranslations.append(translation) }, endDrag: { recorder.endedDrags += 1 }, scrollBy: { recorder.scrolls.append($0) }, - copyImage: { recorder.copies += 1 } + copyImage: { recorder.copies += 1 }, + toggleSelection: { recorder.toggles.append($0) }, + buildJoinQuery: offersJoins ? { recorder.joinRequests += 1 } : nil ) scrollView.documentView = sceneView @@ -72,14 +77,19 @@ struct ERDiagramPointerInputTests { scrollView.magnification = magnification sceneView.scroll(CGPoint(x: Self.farCentre.x - 300 / magnification, y: Self.farCentre.y - 200 / magnification)) window.layoutIfNeeded() - return Fixture(window: window, sceneView: sceneView, recorder: recorder, farId: far.id) + return Fixture(window: window, sceneView: sceneView, recorder: recorder, nearId: near.id, farId: far.id) } - private func mouseEvent(_ type: NSEvent.EventType, at windowPoint: CGPoint, in window: NSWindow) throws -> NSEvent { + private func mouseEvent( + _ type: NSEvent.EventType, + at windowPoint: CGPoint, + in window: NSWindow, + modifiers: NSEvent.ModifierFlags = [] + ) throws -> NSEvent { try #require(NSEvent.mouseEvent( with: type, location: windowPoint, - modifierFlags: [], + modifierFlags: modifiers, timestamp: 0, windowNumber: window.windowNumber, context: nil, @@ -201,6 +211,93 @@ struct ERDiagramPointerInputTests { #expect(fixture.recorder.selections.isEmpty) } + @Test("Command- or Shift-click toggles a table instead of replacing the selection", arguments: [true, false]) + func modifiedClickToggles(usesCommand: Bool) throws { + let modifier: NSEvent.ModifierFlags = usesCommand ? .command : .shift + let fixture = makeFixture(magnification: 0.5) + let pointer = fixture.sceneView.convert(Self.farCentre, to: nil) + + fixture.sceneView.mouseDown(with: try mouseEvent(.leftMouseDown, at: pointer, in: fixture.window, modifiers: modifier)) + fixture.sceneView.mouseUp(with: try mouseEvent(.leftMouseUp, at: pointer, in: fixture.window, modifiers: modifier)) + + #expect(fixture.recorder.toggles == [fixture.farId]) + #expect(fixture.recorder.selections.isEmpty) + } + + @Test("A Command-click on empty canvas keeps the selection") + func modifiedClickOnEmptyCanvasKeepsSelection() throws { + let fixture = makeFixture(magnification: 1.0) + let pointer = fixture.sceneView.convert(Self.emptyPoint, to: nil) + + fixture.sceneView.mouseDown(with: try mouseEvent(.leftMouseDown, at: pointer, in: fixture.window, modifiers: .command)) + fixture.sceneView.mouseUp(with: try mouseEvent(.leftMouseUp, at: pointer, in: fixture.window, modifiers: .command)) + + #expect(fixture.recorder.toggles.isEmpty) + #expect(fixture.recorder.selections.isEmpty) + } + + @Test("A right-click on an unselected table selects it and offers Build Join Query") + func contextMenuOffersTheJoin() throws { + let fixture = makeFixture(magnification: 0.5) + var scene = fixture.sceneView.scene + scene.selectedNodeIds = [fixture.nearId] + fixture.sceneView.scene = scene + let pointer = fixture.sceneView.convert(Self.farCentre, to: nil) + + let menu = try #require(fixture.sceneView.menu( + for: try mouseEvent(.rightMouseDown, at: pointer, in: fixture.window) + )) + #expect(fixture.recorder.selections == [fixture.farId]) + let item = try #require(menu.items.first) + #expect(item.title == String(localized: "Build Join Query…")) + #expect(fixture.sceneView.validateMenuItem(item)) + + NSApp.sendAction(try #require(item.action), to: item.target, from: item) + #expect(fixture.recorder.joinRequests == 1) + } + + @Test("A right-click inside the selection keeps it, and empty canvas offers no menu") + func contextMenuKeepsSelection() throws { + let fixture = makeFixture(magnification: 0.5) + var scene = fixture.sceneView.scene + scene.selectedNodeIds = [fixture.nearId, fixture.farId] + fixture.sceneView.scene = scene + + let onTable = fixture.sceneView.convert(Self.farCentre, to: nil) + let onEmpty = fixture.sceneView.convert(Self.emptyPoint, to: nil) + + #expect(fixture.sceneView.menu(for: try mouseEvent(.rightMouseDown, at: onTable, in: fixture.window)) != nil) + #expect(fixture.recorder.selections.isEmpty) + #expect(fixture.sceneView.menu(for: try mouseEvent(.rightMouseDown, at: onEmpty, in: fixture.window)) == nil) + } + + @Test("An engine that cannot build joins gets no menu and a disabled command") + func noJoinsNoCommand() throws { + let fixture = makeFixture(magnification: 1.0, offersJoins: false) + let pointer = fixture.sceneView.convert(Self.farCentre, to: nil) + let item = NSMenuItem( + title: "", + action: #selector(ERDiagramSceneView.buildJoinQuery(_:)), + keyEquivalent: "" + ) + + #expect(fixture.sceneView.menu(for: try mouseEvent(.rightMouseDown, at: pointer, in: fixture.window)) == nil) + #expect(!fixture.sceneView.validateMenuItem(item)) + } + + @Test("Every selected table is reported selected, the last one picked focused") + func multipleSelectionReachesAccessibility() { + let fixture = makeFixture(magnification: 0.5) + var scene = fixture.sceneView.scene + scene.selectedNodeIds = [fixture.farId, fixture.nearId] + fixture.sceneView.scene = scene + + let selected = fixture.sceneView.accessibilitySelectedChildren() as? [ERDiagramNodeElement] ?? [] + #expect(selected.map { $0.accessibilityLabel() } == ["far", "near"]) + let focused = fixture.sceneView.accessibilityFocusedUIElement as? ERDiagramNodeElement + #expect(focused?.accessibilityLabel() == "near") + } + @Test("The selected table's element reports itself selected") func selectionReachesAccessibility() { let fixture = makeFixture(magnification: 0.5) diff --git a/apple/TableProTests/Views/ERDiagram/ERDiagramSelfReferenceTests.swift b/apple/TableProTests/Views/ERDiagram/ERDiagramSelfReferenceTests.swift index 69529f82fc..57faa43eef 100644 --- a/apple/TableProTests/Views/ERDiagram/ERDiagramSelfReferenceTests.swift +++ b/apple/TableProTests/Views/ERDiagram/ERDiagramSelfReferenceTests.swift @@ -108,7 +108,7 @@ struct ERDiagramSelfReferenceTests { nodeRects: [table.id: nodeRect], nodeIndex: ["employees": table.id], clusterColors: [:], - selectedNodeId: nil, + selectedNodeIds: [], size: CGSize(width: 800, height: 600) ) @@ -218,7 +218,7 @@ struct ERDiagramSelfReferenceTests { nodeRects: [node.id: nodeRect], nodeIndex: ["employees": node.id], clusterColors: [:], - selectedNodeId: nil, + selectedNodeIds: [], size: CGSize(width: nodeRect.maxX + 160, height: nodeRect.maxY + 160) ) diff --git a/apple/TableProTests/Views/ERDiagram/ERDiagramThemeTests.swift b/apple/TableProTests/Views/ERDiagram/ERDiagramThemeTests.swift index 9f95a0cef3..baddf3ebcf 100644 --- a/apple/TableProTests/Views/ERDiagram/ERDiagramThemeTests.swift +++ b/apple/TableProTests/Views/ERDiagram/ERDiagramThemeTests.swift @@ -63,7 +63,7 @@ struct ERDiagramThemeTests { nodeRects: [node.id: rect], nodeIndex: [node.tableName: node.id], clusterColors: clusterColor.map { [node.id: $0] } ?? [:], - selectedNodeId: nil, + selectedNodeIds: [], size: CGSize(width: rect.maxX + 20, height: rect.maxY + 20) ) return (scene, rect) diff --git a/apple/TableProTests/Views/ERDiagram/ERDiagramVectorExportTests.swift b/apple/TableProTests/Views/ERDiagram/ERDiagramVectorExportTests.swift index 117d5f2745..5664b5c80b 100644 --- a/apple/TableProTests/Views/ERDiagram/ERDiagramVectorExportTests.swift +++ b/apple/TableProTests/Views/ERDiagram/ERDiagramVectorExportTests.swift @@ -43,7 +43,7 @@ struct ERDiagramVectorExportTests { nodeRects: [users.id: usersRect, orders.id: ordersRect], nodeIndex: [usersName: users.id, "orders": orders.id], clusterColors: [users.id: .systemBlue, orders.id: .systemBlue], - selectedNodeId: selected ? users.id : nil, + selectedNodeIds: selected ? [users.id] : [], size: CGSize(width: ordersRect.maxX + 80, height: ordersRect.maxY + 80) ) } diff --git a/apple/TableProTests/Views/Main/JoinBuilderOpeningTests.swift b/apple/TableProTests/Views/Main/JoinBuilderOpeningTests.swift new file mode 100644 index 0000000000..b815faa15a --- /dev/null +++ b/apple/TableProTests/Views/Main/JoinBuilderOpeningTests.swift @@ -0,0 +1,101 @@ +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +struct JoinBuilderOpeningTests { + private func makeCoordinator(type: DatabaseType = .mysql) -> MainContentCoordinator { + MainContentCoordinator( + connection: TestFixtures.makeConnection(database: "shop", type: type), + tabManager: QueryTabManager(), + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + } + + private func launch(of coordinator: MainContentCoordinator) -> JoinBuilderLaunchRequest? { + guard case .joinBuilder(let launch) = coordinator.activeSheet else { return nil } + return launch + } + + private func ref(_ name: String, database: String? = "shop", schema: String? = nil) -> DatabaseTreeTableRef { + DatabaseTreeTableRef(database: database, schema: schema, table: TableInfo(name: name, type: .table, rowCount: nil)) + } + + @Test("The generated query opens in a new query tab on the builder's database and leaves the open tab alone") + func openingTheQuery() { + let coordinator = makeCoordinator() + defer { coordinator.teardown() } + var opened: [EditorTabPayload] = [] + coordinator.openTabInNewWindow = { opened.append($0) } + coordinator.tabManager.addTab(initialQuery: "SELECT 1", databaseName: "shop") + coordinator.activeSheet = .joinBuilder(JoinBuilderLaunchRequest(database: "archive", schema: nil, tables: [])) + + coordinator.openJoinQuery("SELECT *\nFROM \"orders\"", database: "archive") + + #expect(coordinator.activeSheet == nil) + #expect(opened.count == 1) + #expect(opened.first?.tabType == .query) + #expect(opened.first?.initialQuery == "SELECT *\nFROM \"orders\"") + #expect(opened.first?.databaseName == "archive") + #expect(coordinator.tabManager.tabs.first?.content.query == "SELECT 1") + } + + @Test("The sidebar selection seeds the builder with the tables that share the first one's schema") + func sidebarSelectionSeeds() throws { + let coordinator = makeCoordinator() + defer { coordinator.teardown() } + #expect(coordinator.canBuildJoinQuery) + + coordinator.openJoinBuilder(seededWith: [ + ref("orders"), + ref("customers"), + ref("accounts", database: "billing") + ]) + + let launch = try #require(launch(of: coordinator)) + #expect(launch.database == "billing") + #expect(launch.tables == ["accounts"]) + + coordinator.openJoinBuilder(seededWith: [ref("orders"), ref("customers")]) + let seeded = try #require(self.launch(of: coordinator)) + #expect(seeded.database == "shop") + #expect(seeded.tables == ["customers", "orders"]) + } + + @Test("An empty selection opens the builder on the browsed database with nothing picked") + func emptySelection() throws { + let coordinator = makeCoordinator() + defer { coordinator.teardown() } + + coordinator.openJoinBuilder(seededWith: []) + + let launch = try #require(launch(of: coordinator)) + #expect(launch.tables.isEmpty) + #expect(launch.schema == nil) + } + + @Test("The sheet names the connection and the database, not a file-based engine's whole path") + func scopeDescriptionUsesTheEndpointLabel() { + let connection = TestFixtures.makeConnection(name: "Fixture", database: "/tmp/shop.sqlite", type: .sqlite) + let model = JoinBuilderViewModel( + launch: JoinBuilderLaunchRequest(database: "/tmp/shop.sqlite", schema: nil, tables: []), + connection: connection + ) + + #expect(model.scopeDescription == DatabaseEndpoint.from(connection: connection, database: "/tmp/shop.sqlite").qualifiedDescription) + #expect(!model.scopeDescription.contains("/tmp/")) + } + + @Test("Only an engine with SQL and foreign keys gets the builder") + func eligibility() { + let dialect = SQLDialectDescriptor(identifierQuote: "\"", keywords: [], functions: [], dataTypes: []) + + #expect(JoinBuilderEligibility.supportsJoinBuilder(editorLanguage: .sql, dialect: dialect, supportsForeignKeys: true)) + #expect(!JoinBuilderEligibility.supportsJoinBuilder(editorLanguage: .sql, dialect: dialect, supportsForeignKeys: false)) + #expect(!JoinBuilderEligibility.supportsJoinBuilder(editorLanguage: .sql, dialect: nil, supportsForeignKeys: true)) + #expect(!JoinBuilderEligibility.supportsJoinBuilder(editorLanguage: .javascript, dialect: dialect, supportsForeignKeys: true)) + } +} diff --git a/apple/TableProUITests/JoinBuilderUITests.swift b/apple/TableProUITests/JoinBuilderUITests.swift new file mode 100644 index 0000000000..888532993d --- /dev/null +++ b/apple/TableProUITests/JoinBuilderUITests.swift @@ -0,0 +1,57 @@ +import XCTest + +// Two related tables picked on the ER diagram build a join whose condition comes from their foreign key, +// and the query lands in a new query tab with nothing typed. +final class JoinBuilderUITests: UITestCase { + func testBuildingAJoinFromTwoTablesPickedOnTheDiagram() throws { + let launch = try launchWithFixture() + let window = launch.window + let app = launch.app + + XCTAssertTrue(app.menuBars.firstMatch.waitToExist(timeout: 20), "The app must publish its menu bar") + objectBrowser(in: window).coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9)).rightClick() + XCTAssertTrue( + waitForPredicate(timeout: 10) { self.contextMenuItem("View ER Diagram", in: app).isHittable }, + "The sidebar's empty-area menu must offer View ER Diagram" + ) + contextMenuItem("View ER Diagram", in: app).click() + + let canvas = window.scrollViews.matching(identifier: "er-diagram-canvas").firstMatch + XCTAssertTrue(canvas.waitToExist(timeout: 30), "The ER diagram must open on the fixture") + let tables = canvas.descendants(matching: .layoutItem) + let album = tables.matching(NSPredicate(format: "label == %@", "album")).firstMatch + let artist = tables.matching(NSPredicate(format: "label == %@", "artist")).firstMatch + XCTAssertTrue(album.waitToExist(timeout: 30), "The diagram must draw the album table") + XCTAssertTrue(artist.waitToExist(timeout: 10), "The diagram must draw the artist table") + + album.click() + XCTAssertTrue(waitForPredicate(timeout: 5) { album.isSelected }, "A click must select the album table") + XCUIElement.perform(withKeyModifiers: .command) { + artist.click() + } + XCTAssertTrue( + waitForPredicate(timeout: 5) { album.isSelected && artist.isSelected }, + "A Command-click must add the artist table to the selection" + ) + + let build = window.buttons.matching(identifier: "er-diagram-build-join").firstMatch + XCTAssertTrue(build.waitToExist(timeout: 10), "The diagram must offer Build Join Query") + build.click() + + let sheet = window.sheets.firstMatch + XCTAssertTrue(sheet.waitToExist(timeout: 10), "Build Join Query must open its sheet") + let open = sheet.buttons.matching(identifier: "join-builder-open").firstMatch + XCTAssertTrue( + waitForPredicate(timeout: 30) { open.exists && open.isEnabled && open.isHittable }, + "The builder must read the schema and offer the query" + ) + open.click() + + let editor = editorTextView(in: window) + let condition = "JOIN `artist` ON `album`.`artist_id` = `artist`.`id`" + XCTAssertTrue( + waitForEditorText(editor, timeout: 20) { $0.contains("FROM `album`") && $0.contains(condition) }, + "The new query tab must hold the join on album's foreign key, got: \(editorText(editor))" + ) + } +} From d7dc1540a49136c751839e597803d469f3800ced Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 23:05:37 +0700 Subject: [PATCH 2/2] docs(changelog): add the join builder and ER diagram fragments --- apple/changelog.d/3763.added.md | 1 + apple/changelog.d/3763.fixed.1.md | 1 + apple/changelog.d/3763.fixed.md | 1 + 3 files changed, 3 insertions(+) create mode 100644 apple/changelog.d/3763.added.md create mode 100644 apple/changelog.d/3763.fixed.1.md create mode 100644 apple/changelog.d/3763.fixed.md diff --git a/apple/changelog.d/3763.added.md b/apple/changelog.d/3763.added.md new file mode 100644 index 0000000000..b06cbdbe05 --- /dev/null +++ b/apple/changelog.d/3763.added.md @@ -0,0 +1 @@ +Build a join query from the foreign keys between tables, from the Database menu or the ER diagram. diff --git a/apple/changelog.d/3763.fixed.1.md b/apple/changelog.d/3763.fixed.1.md new file mode 100644 index 0000000000..fb2580bad6 --- /dev/null +++ b/apple/changelog.d/3763.fixed.1.md @@ -0,0 +1 @@ +The ER diagram drew a foreign key into another schema to a same-named table of the current one. diff --git a/apple/changelog.d/3763.fixed.md b/apple/changelog.d/3763.fixed.md new file mode 100644 index 0000000000..baab4e9c5a --- /dev/null +++ b/apple/changelog.d/3763.fixed.md @@ -0,0 +1 @@ +The ER diagram drew no edge for a SQLite foreign key that spelled its table in another case.