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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions apple/TablePro/Core/JoinBuilder/JoinGraph.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
157 changes: 157 additions & 0 deletions apple/TablePro/Core/JoinBuilder/JoinQueryDraft.swift
Original file line number Diff line number Diff line change
@@ -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[..<index].flatMap { earlier in graph.relationships(between: table, and: earlier.name) }
}

internal mutating func setKind(_ kind: JoinKind, forTableAt index: Int) {
guard tables.indices.contains(index) else { return }
tables[index].kind = kind
}

internal mutating func setCondition(_ condition: JoinRelationship?, forTableAt index: Int, in graph: JoinGraph) {
guard tables.indices.contains(index), index > 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
}
}
}
73 changes: 73 additions & 0 deletions apple/TablePro/Core/JoinBuilder/JoinQuerySQLWriter.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading