dagable 0.1.0 → 0.2.0
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/migration_helpers.rb +14 -4
- data/lib/dagable/migrations/helper.rb +3 -3
- data/lib/dagable/model.rb +32 -24
- data/lib/dagable/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6659e16d0c901d24bf885a03eb19e340081420a651084fa7b87c5578dec42052
|
|
4
|
+
data.tar.gz: e6456fa8ee41246b382a0bbf298c647f5f7286f7e21ed3aab44d918b8073e983
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3395f73b85288c51e2e04db6fad33d08e8e0de752c37ecb1dfdc9902746b2af1fa5a95251f01e6e8d110b9b3477e399d8b85a9b822432148a23dd8e8ad72c046
|
|
7
|
+
data.tar.gz: c042d70a98bdd693168e953a4ed964fcd4641d8654656ed237b6ada29cda2bf40102e0e773d179c8866b61e796da040bf3064ce649d7446d2557e7936408ade1
|
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
|
|
@@ -34,10 +34,11 @@ module Dagable
|
|
|
34
34
|
|
|
35
35
|
def create_dagable_edges_table(source_table_name)
|
|
36
36
|
edges_table = "#{source_table_name}_edges"
|
|
37
|
+
id_type = source_primary_key_type(source_table_name)
|
|
37
38
|
|
|
38
39
|
create_table edges_table do |t|
|
|
39
|
-
t.
|
|
40
|
-
t.
|
|
40
|
+
t.column :parent_id, id_type, null: false
|
|
41
|
+
t.column :child_id, id_type, null: false
|
|
41
42
|
end
|
|
42
43
|
|
|
43
44
|
add_index edges_table, %i[parent_id child_id], unique: true
|
|
@@ -47,10 +48,11 @@ module Dagable
|
|
|
47
48
|
|
|
48
49
|
def create_dagable_ancestries_table(source_table_name)
|
|
49
50
|
ancestries_table = "#{source_table_name}_ancestries"
|
|
51
|
+
id_type = source_primary_key_type(source_table_name)
|
|
50
52
|
|
|
51
53
|
create_table ancestries_table do |t|
|
|
52
|
-
t.
|
|
53
|
-
t.
|
|
54
|
+
t.column :predecessor_id, id_type, null: false
|
|
55
|
+
t.column :successor_id, id_type, null: false
|
|
54
56
|
t.integer :depth, null: false, default: 0
|
|
55
57
|
end
|
|
56
58
|
|
|
@@ -60,5 +62,13 @@ module Dagable
|
|
|
60
62
|
add_foreign_key ancestries_table, source_table_name, column: :predecessor_id
|
|
61
63
|
add_foreign_key ancestries_table, source_table_name, column: :successor_id
|
|
62
64
|
end
|
|
65
|
+
|
|
66
|
+
# Edge and ancestry columns must reference the source table's records, so
|
|
67
|
+
# their type mirrors whatever the source primary key is (bigint, uuid,
|
|
68
|
+
# string, ...), read from the live schema.
|
|
69
|
+
def source_primary_key_type(source_table_name)
|
|
70
|
+
pk_name = primary_key(source_table_name)
|
|
71
|
+
columns(source_table_name).find { |column| column.name == pk_name }.sql_type
|
|
72
|
+
end
|
|
63
73
|
end
|
|
64
74
|
end
|
|
@@ -35,16 +35,16 @@ module Dagable
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
# Resolves a reference to a dagable record. Accepts either a model
|
|
38
|
-
# instance or an integer
|
|
38
|
+
# instance or an ID (integer or UUID/string).
|
|
39
39
|
#
|
|
40
40
|
# @param model [Class] the dagable ActiveRecord model class
|
|
41
|
-
# @param reference [ActiveRecord::Base, Integer] the record or its ID
|
|
41
|
+
# @param reference [ActiveRecord::Base, Integer, String] the record or its ID
|
|
42
42
|
# @return [ActiveRecord::Base]
|
|
43
43
|
# @raise [ArgumentError] if the reference type is unsupported
|
|
44
44
|
def retrieve_item(model, reference)
|
|
45
45
|
case reference
|
|
46
46
|
when model then reference
|
|
47
|
-
when Integer then model.find(reference)
|
|
47
|
+
when Integer, String then model.find(reference)
|
|
48
48
|
else raise ArgumentError, "Invalid dagable item reference"
|
|
49
49
|
end
|
|
50
50
|
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
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: dagable
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Leandro Maduro
|
|
@@ -138,7 +138,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
138
138
|
- !ruby/object:Gem::Version
|
|
139
139
|
version: '0'
|
|
140
140
|
requirements: []
|
|
141
|
-
rubygems_version:
|
|
141
|
+
rubygems_version: 4.0.6
|
|
142
142
|
specification_version: 4
|
|
143
143
|
summary: DAG infrastructure for ActiveRecord models
|
|
144
144
|
test_files: []
|