dagable 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/dagable/edge.rb +41 -3
- data/lib/dagable/instance_methods.rb +8 -16
- data/lib/dagable/model.rb +32 -24
- data/lib/dagable/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1105295586a21290d1fefcaec67cc0fb4e4e7dc40e737c6542526d829e5618ee
|
|
4
|
+
data.tar.gz: b2e61fd515503636536364a7bc3b4694f0dbfa03bedeb5b1d38f1163c24ba074
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ee09a550c55af660d7cf30e3ab45203c9a205f6a133525bb40cdf02dc65de9bca6a70ef0506c72f364b8e7bd29880029c16d00ccc004feecb16d2e7bd74087eb
|
|
7
|
+
data.tar.gz: fe634af62642fb0e0989fcdca6da2b0601be0e5271c225200944020e1c20acaa1d8eed1f6c70d8405c8fcd8e058a33da816890776b99b74c9dbdb67b4b713a86
|
data/lib/dagable/edge.rb
CHANGED
|
@@ -10,6 +10,9 @@ module Dagable
|
|
|
10
10
|
class Edge < ActiveRecord::Base
|
|
11
11
|
self.abstract_class = true
|
|
12
12
|
|
|
13
|
+
before_destroy :snapshot_affected_region
|
|
14
|
+
after_destroy :repair_affected_region
|
|
15
|
+
|
|
13
16
|
# Materializes all transitive ancestry rows implied by this edge.
|
|
14
17
|
#
|
|
15
18
|
# For an edge (parent -> child), this computes the cross product of all
|
|
@@ -20,14 +23,49 @@ module Dagable
|
|
|
20
23
|
# This is safe to call multiple times — existing ancestry rows are skipped
|
|
21
24
|
# via +INSERT ... ON CONFLICT DO NOTHING+.
|
|
22
25
|
def link_ancestries
|
|
23
|
-
|
|
24
|
-
records = ancestry_records(ancestry_class)
|
|
26
|
+
records = ancestry_records(ancestry_model)
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
ancestry_model.insert_all(records, unique_by: %i[predecessor_id successor_id]) if records.any?
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
private
|
|
30
32
|
|
|
33
|
+
def ancestry_model = self.class.module_parent.const_get("Ancestry")
|
|
34
|
+
|
|
35
|
+
# Captures the nodes whose ancestry rows could have routed through this
|
|
36
|
+
# edge — ancestors of the parent (A) and descendants of the child (D).
|
|
37
|
+
# Any transitive ancestry row (P, S) with P ∈ A and S ∈ D potentially
|
|
38
|
+
# relied on this edge; everything outside A × D is untouched by its
|
|
39
|
+
# removal. Run in +before_destroy+ so the snapshot is taken before any
|
|
40
|
+
# dependent-destroy cascade perturbs the ancestry table.
|
|
41
|
+
def snapshot_affected_region
|
|
42
|
+
ancestry = ancestry_model
|
|
43
|
+
@affected_predecessors = ancestry.where(successor_id: parent_id).pluck(:predecessor_id)
|
|
44
|
+
@affected_successors = ancestry.where(predecessor_id: child_id).pluck(:successor_id)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Rebuilds ancestry only within the affected region: deletes every
|
|
48
|
+
# transitive (depth > 0) row in A × D, then replays +link_ancestries+
|
|
49
|
+
# on remaining edges that touch the region. A single pass suffices —
|
|
50
|
+
# any surviving path from p ∈ A to s ∈ D must cross a "bridge" edge
|
|
51
|
+
# whose parent is in A, and replaying that edge re-derives (p, s)
|
|
52
|
+
# directly from still-intact ancestry outside the region.
|
|
53
|
+
def repair_affected_region
|
|
54
|
+
preds = @affected_predecessors
|
|
55
|
+
succs = @affected_successors
|
|
56
|
+
return if preds.blank? || succs.blank?
|
|
57
|
+
|
|
58
|
+
ancestry = ancestry_model
|
|
59
|
+
ancestry.where(predecessor_id: preds, successor_id: succs)
|
|
60
|
+
.where.not(depth: 0)
|
|
61
|
+
.delete_all
|
|
62
|
+
|
|
63
|
+
region_nodes = preds | succs
|
|
64
|
+
self.class
|
|
65
|
+
.where("parent_id IN (:nodes) OR child_id IN (:nodes)", nodes: region_nodes)
|
|
66
|
+
.find_each(&:link_ancestries)
|
|
67
|
+
end
|
|
68
|
+
|
|
31
69
|
# Builds the ancestry rows implied by this edge as a cross product.
|
|
32
70
|
#
|
|
33
71
|
# For an edge P -> C, every predecessor of P must be linked to every
|
|
@@ -34,25 +34,25 @@ module Dagable
|
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
# Removes +child+ as a direct child of this node.
|
|
38
|
-
#
|
|
37
|
+
# Removes +child+ as a direct child of this node. Destroys the edge
|
|
38
|
+
# record; the edge's destroy callbacks repair ancestry incrementally,
|
|
39
|
+
# touching only the region reachable through the removed edge.
|
|
39
40
|
#
|
|
40
41
|
# @param child [ActiveRecord::Base] the child node to disconnect
|
|
41
42
|
def remove_child(child)
|
|
42
43
|
ActiveRecord::Base.transaction do
|
|
43
|
-
child_edges.where(child_id: child.id).
|
|
44
|
-
rebuild_ancestries
|
|
44
|
+
child_edges.where(child_id: child.id).destroy_all
|
|
45
45
|
end
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
# Removes +parent+ as a direct parent of this node.
|
|
49
|
-
# record
|
|
48
|
+
# Removes +parent+ as a direct parent of this node. Destroys the edge
|
|
49
|
+
# record; the edge's destroy callbacks repair ancestry incrementally,
|
|
50
|
+
# touching only the region reachable through the removed edge.
|
|
50
51
|
#
|
|
51
52
|
# @param parent [ActiveRecord::Base] the parent node to disconnect
|
|
52
53
|
def remove_parent(parent)
|
|
53
54
|
ActiveRecord::Base.transaction do
|
|
54
|
-
parent_edges.where(parent_id: parent.id).
|
|
55
|
-
rebuild_ancestries
|
|
55
|
+
parent_edges.where(parent_id: parent.id).destroy_all
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
|
|
@@ -122,13 +122,5 @@ module Dagable
|
|
|
122
122
|
successor_id: id
|
|
123
123
|
) { |row| row.depth = 0 }
|
|
124
124
|
end
|
|
125
|
-
|
|
126
|
-
# Rebuilds the entire ancestry table from direct edges. Deletes all
|
|
127
|
-
# non-self rows and replays +link_ancestries+ on every edge.
|
|
128
|
-
def rebuild_ancestries
|
|
129
|
-
ancestry = ancestry_class
|
|
130
|
-
ancestry.where.not(depth: 0).delete_all
|
|
131
|
-
edge_class.find_each(&:link_ancestries)
|
|
132
|
-
end
|
|
133
125
|
end
|
|
134
126
|
end
|
data/lib/dagable/model.rb
CHANGED
|
@@ -26,41 +26,49 @@ module Dagable
|
|
|
26
26
|
# 5. Register an +after_create+ callback to insert the self-referential
|
|
27
27
|
# ancestry row (depth 0) for every new record
|
|
28
28
|
module Model
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
# Serializes +dagable+ invocations across threads. The window between
|
|
30
|
+
# +Class.new+ (which registers an anonymous descendant on +Dagable::Edge+
|
|
31
|
+
# / +Dagable::Ancestry+) and +const_set+ (which names it) is a race if
|
|
32
|
+
# two threads can be inside +dagable+ at once — concurrent invocations
|
|
33
|
+
# could observe each other's unnamed clones in the shared descendants
|
|
34
|
+
# tracker. Holding this mutex ensures at most one anonymous clone of
|
|
35
|
+
# each base class exists at any instant.
|
|
36
|
+
@mutex = Mutex.new
|
|
35
37
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
class << self
|
|
39
|
+
attr_reader :mutex
|
|
38
40
|
end
|
|
39
41
|
|
|
40
|
-
|
|
42
|
+
def dagable
|
|
43
|
+
Dagable::Model.mutex.synchronize do
|
|
44
|
+
edge_klass = const_set("Edge", Class.new(Dagable::Edge))
|
|
45
|
+
ancestry_klass = const_set("Ancestry", Class.new(Dagable::Ancestry))
|
|
41
46
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
table = "#{table_name}_edges"
|
|
47
|
+
configure_edge_class(edge_klass)
|
|
48
|
+
configure_ancestry_class(ancestry_klass)
|
|
45
49
|
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
include Dagable::Associations.new(edge_klass.name, ancestry_klass.name)
|
|
51
|
+
include Dagable::InstanceMethods
|
|
48
52
|
|
|
49
|
-
|
|
50
|
-
belongs_to :child, class_name: klass_name, inverse_of: :parent_edges
|
|
53
|
+
after_create :create_self_ancestry_row
|
|
51
54
|
end
|
|
52
55
|
end
|
|
53
56
|
|
|
54
|
-
|
|
55
|
-
klass_name = name
|
|
56
|
-
table = "#{table_name}_ancestries"
|
|
57
|
+
private
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
# Configure the edge subclass after it has been named via +const_set+,
|
|
60
|
+
# so +belongs_to+ reflections and +table_name+ are registered against a
|
|
61
|
+
# class with a stable name rather than an anonymous placeholder.
|
|
62
|
+
def configure_edge_class(klass)
|
|
63
|
+
klass.table_name = "#{table_name}_edges"
|
|
64
|
+
klass.belongs_to :parent, class_name: name, inverse_of: :child_edges
|
|
65
|
+
klass.belongs_to :child, class_name: name, inverse_of: :parent_edges
|
|
66
|
+
end
|
|
60
67
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
68
|
+
def configure_ancestry_class(klass)
|
|
69
|
+
klass.table_name = "#{table_name}_ancestries"
|
|
70
|
+
klass.belongs_to :predecessor, class_name: name, inverse_of: :successor_connections
|
|
71
|
+
klass.belongs_to :successor, class_name: name, inverse_of: :predecessor_connections
|
|
64
72
|
end
|
|
65
73
|
end
|
|
66
74
|
end
|
data/lib/dagable/version.rb
CHANGED