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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1e1ce980aa13e26cc4f39e22e4af897ac32921a3e7a815da5294c129ce233da1
4
- data.tar.gz: 0c2e385b11f1f9e1fef43667d07c82947e5bc36f12651741b544bb6fe1fccf56
3
+ metadata.gz: 6659e16d0c901d24bf885a03eb19e340081420a651084fa7b87c5578dec42052
4
+ data.tar.gz: e6456fa8ee41246b382a0bbf298c647f5f7286f7e21ed3aab44d918b8073e983
5
5
  SHA512:
6
- metadata.gz: 2c0c364e091d5301f9fdc8d9fa19d7c40721c8cb808d402a84d5b4e60c2d82dbb9c6a50977b7e3fb7a5f306dd3f71facf0a4f8ded7979361d00b5c9c17dda91d
7
- data.tar.gz: b2464c035a6ef4c969592182d1e71c25552144eed8083a953b079897d97d039fbeebd413c47ff8430081353eb4a474685cdb19f4db8fb3ac3cd310a10a1db125
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
- ancestry_class = self.class.module_parent.const_get("Ancestry")
24
- records = ancestry_records(ancestry_class)
26
+ records = ancestry_records(ancestry_model)
25
27
 
26
- ancestry_class.insert_all(records, unique_by: %i[predecessor_id successor_id]) if records.any?
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. Deletes the edge record
38
- # and rebuilds the ancestry table for the entire graph.
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).delete_all
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. Deletes the edge
49
- # record and rebuilds the ancestry table for the entire graph.
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).delete_all
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.bigint :parent_id, null: false
40
- t.bigint :child_id, null: false
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.bigint :predecessor_id, null: false
53
- t.bigint :successor_id, null: false
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 ID.
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
- def dagable
30
- const_set("Edge", edge_class_definition)
31
- const_set("Ancestry", ancestry_class_definition)
32
-
33
- include Dagable::Associations.new(const_get("Edge").name, const_get("Ancestry").name)
34
- include Dagable::InstanceMethods
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
- after_create :create_self_ancestry_row
37
- after_destroy :rebuild_ancestries
38
+ class << self
39
+ attr_reader :mutex
38
40
  end
39
41
 
40
- private
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
- def edge_class_definition
43
- klass_name = name
44
- table = "#{table_name}_edges"
47
+ configure_edge_class(edge_klass)
48
+ configure_ancestry_class(ancestry_klass)
45
49
 
46
- Class.new(Dagable::Edge) do
47
- self.table_name = table
50
+ include Dagable::Associations.new(edge_klass.name, ancestry_klass.name)
51
+ include Dagable::InstanceMethods
48
52
 
49
- belongs_to :parent, class_name: klass_name, inverse_of: :child_edges
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
- def ancestry_class_definition
55
- klass_name = name
56
- table = "#{table_name}_ancestries"
57
+ private
57
58
 
58
- Class.new(Dagable::Ancestry) do
59
- self.table_name = table
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
- belongs_to :predecessor, class_name: klass_name, inverse_of: :successor_connections
62
- belongs_to :successor, class_name: klass_name, inverse_of: :predecessor_connections
63
- end
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dagable
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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.1.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: 3.6.9
141
+ rubygems_version: 4.0.6
142
142
  specification_version: 4
143
143
  summary: DAG infrastructure for ActiveRecord models
144
144
  test_files: []