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
58 changes: 49 additions & 9 deletions Sources/GraphKit/Graph/Graph+Edges.swift
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,52 @@ extension Graph {
///
@discardableResult
public mutating func connect(_ source: Node, to target: Node, value: Value, at position: EdgePosition = .end) throws(GraphError) -> Edge {
let (sourceSlot, targetSlot, index) = try validateConnection(source, to: target, at: position)

let edge = allocateEdge(source: sourceSlot, target: targetSlot, value: value)
nodeRecords[sourceSlot]!.outgoing.insert(edge, at: index)
nodeRecords[targetSlot]!.incoming.append(edge)

appendChange(.edgeInserted(edge, source: source, target: target, index: index))

return edge
}

/// Whether connecting one node to another would be accepted.
///
/// Asks exactly the questions ``connect(_:to:value:at:)`` asks before it
/// modifies anything, so a `true` here means that connect will not throw.
/// Use it to refuse a connection up front, such as a drop destination
/// declining a drag, rather than attempting it and handling the error.
///
/// Like ``connect(_:to:value:at:)``, both nodes must already be members of
/// the graph. A connection involving a node that is not a member is not
/// accepted.
///
/// - Parameters:
/// - source: The node the edge would leave.
/// - target: The node the edge would arrive at.
/// - position: The position the edge would be inserted at within the source
/// node's ordered outgoing edges. Defaults to ``EdgePosition/end``.
///
/// - Returns: True if connecting the source node to the target node at the
/// provided position would succeed.
///
public func canConnect(_ source: Node, to target: Node, at position: EdgePosition = .end) -> Bool {
(try? validateConnection(source, to: target, at: position)) != nil
}

/// Validates a connection against the membership of its nodes, the
/// ``options`` describing the graph, and the position it would be inserted
/// at, without modifying anything.
///
/// The single source of truth for what ``connect(_:to:value:at:)`` accepts,
/// shared with ``canConnect(_:to:at:)`` so the two can never disagree.
///
/// - Returns: The slots of the source and target nodes, and the index the
/// edge would be inserted at within the source node's outgoing edges.
///
private func validateConnection(_ source: Node, to target: Node, at position: EdgePosition) throws(GraphError) -> (source: Int, target: Int, index: Int) {
guard let sourceSlot = nodeSlots[source] else {
throw GraphError.notFound(what: GraphError.node, reason: "The node is not a member of the graph.")
}
Expand Down Expand Up @@ -376,18 +422,12 @@ extension Graph {
}
}

// Resolve the position before allocating, so that an invalid position
// does not leave a vacated slot behind.
// Resolved here, before the caller allocates, so that an invalid
// position does not leave a vacated slot behind.
//
let index = try position.resolved(insertingInto: nodeRecords[sourceSlot]!.outgoing.count)

let edge = allocateEdge(source: sourceSlot, target: targetSlot, value: value)
nodeRecords[sourceSlot]!.outgoing.insert(edge, at: index)
nodeRecords[targetSlot]!.incoming.append(edge)

appendChange(.edgeInserted(edge, source: source, target: target, index: index))

return edge
return (sourceSlot, targetSlot, index)
}

/// Disconnects an edge, removing it from both of the nodes it connects.
Expand Down
46 changes: 46 additions & 0 deletions Tests/GraphKit/Graph/Options.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,50 @@ struct OptionTests {
#expect(graph.wouldCreateCycle(connecting: "a", to: "c") == false)
#expect(graph.wouldCreateCycle(connecting: "a", to: "unknown") == false)
}

/// Tests that asking whether a connection would be accepted refuses exactly
/// what connecting rejects, for every option.
///
@Test("Can Connect")
func canConnect() throws {
var tree = Graph<String, Void>(nodes: ["root", "a", "b", "c"], options: .tree)
try tree.connect("root", to: "a")
try tree.connect("a", to: "b")

#expect(tree.canConnect("a", to: "a") == false)
#expect(tree.canConnect("root", to: "a") == false)
#expect(tree.canConnect("root", to: "b") == false)
#expect(tree.canConnect("b", to: "root") == false)
#expect(tree.canConnect("b", to: "c"))
#expect(tree.canConnect("root", to: "c"))

var unconstrained = Graph<String, Void>(nodes: ["a", "b"], options: [.allowsCycles, .allowsParallelEdges, .allowsSelfLoops, .allowsMultipleIncomingEdges])
try unconstrained.connect("a", to: "b")

#expect(unconstrained.canConnect("a", to: "a"))
#expect(unconstrained.canConnect("a", to: "b"))
#expect(unconstrained.canConnect("b", to: "a"))

// Asking modifies nothing.
//
#expect(tree.edgeCount == 2)
#expect(unconstrained.edgeCount == 1)
}

/// Tests that a connection involving a node that is not a member of the
/// graph, or an out of range position, is not accepted, as connecting would
/// reject it.
///
@Test("Can Connect Rejects Unknown Nodes And Positions")
func canConnectRejectsUnknownNodesAndPositions() throws {
var graph = Graph<String, Void>(nodes: ["a", "b", "c"], options: .acyclic)
try graph.connect("a", to: "b")

#expect(graph.canConnect("a", to: "z") == false)
#expect(graph.canConnect("z", to: "a") == false)

#expect(graph.canConnect("a", to: "c", at: .index(1)))
#expect(graph.canConnect("a", to: "c", at: .index(2)) == false)
#expect(graph.canConnect("a", to: "c", at: .start))
}
}
Loading