dagable 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1e1ce980aa13e26cc4f39e22e4af897ac32921a3e7a815da5294c129ce233da1
4
+ data.tar.gz: 0c2e385b11f1f9e1fef43667d07c82947e5bc36f12651741b544bb6fe1fccf56
5
+ SHA512:
6
+ metadata.gz: 2c0c364e091d5301f9fdc8d9fa19d7c40721c8cb808d402a84d5b4e60c2d82dbb9c6a50977b7e3fb7a5f306dd3f71facf0a4f8ded7979361d00b5c9c17dda91d
7
+ data.tar.gz: b2464c035a6ef4c969592182d1e71c25552144eed8083a953b079897d97d039fbeebd413c47ff8430081353eb4a474685cdb19f4db8fb3ac3cd310a10a1db125
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leandro Maduro
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,232 @@
1
+ # Dagable
2
+
3
+ Dagable provides **directed acyclic graph (DAG)** composition for ActiveRecord models using a dedicated **ancestry table**. It materializes all transitive paths so that traversal queries are single JOINs instead of recursive CTEs.
4
+
5
+ ## Features
6
+
7
+ - **Pre-computed ancestry** — all transitive paths are materialized in an ancestry table, so traversal queries are single JOINs instead of recursive CTEs
8
+ - **Cycle detection** — raises `Dagable::Errors::CyclicAssociation` before any invalid edge is persisted (self-referential, direct, and transitive cycles)
9
+ - **ActiveRecord::Relation returns** — all traversal methods (`self_and_successors`, `self_and_predecessors`, `successors`, `predecessors`) return chainable relations
10
+ - **Domain-agnostic** — no knowledge of your application domain; works with any ActiveRecord model
11
+ - **Pure ActiveRecord** — only requires ActiveRecord, not Rails
12
+
13
+ ## Installation
14
+
15
+ Add to your Gemfile:
16
+
17
+ ```ruby
18
+ gem 'dagable'
19
+ ```
20
+
21
+ Then run:
22
+
23
+ ```bash
24
+ bundle install
25
+ ```
26
+
27
+ Or install directly:
28
+
29
+ ```bash
30
+ gem install dagable
31
+ ```
32
+
33
+ ## Setup
34
+
35
+ ### 1. Create the database tables
36
+
37
+ Dagable requires two supporting tables per model: an **edges** table for direct relationships and an **ancestries** table for all transitive paths.
38
+
39
+ ```ruby
40
+ class CreateCategoryDag < ActiveRecord::Migration[8.1]
41
+ include Dagable::MigrationHelpers
42
+
43
+ def change
44
+ create_table :categories do |t|
45
+ t.string :name, null: false
46
+ t.timestamps
47
+ end
48
+
49
+ create_dagable_tables(:categories)
50
+ end
51
+ end
52
+ ```
53
+
54
+ `create_dagable_tables(:categories)` creates:
55
+
56
+ | Table | Columns | Indexes |
57
+ |-------|---------|---------|
58
+ | `categories_edges` | `parent_id`, `child_id` | Unique composite `[parent_id, child_id]` |
59
+ | `categories_ancestries` | `predecessor_id`, `successor_id`, `depth` | Unique composite `[predecessor_id, successor_id]`, individual on `predecessor_id` and `successor_id` |
60
+
61
+ Both tables include foreign keys back to the source table.
62
+
63
+ ### 2. Activate the model
64
+
65
+ ```ruby
66
+ class Category < ActiveRecord::Base
67
+ extend Dagable::Model
68
+
69
+ dagable
70
+ end
71
+ ```
72
+
73
+ Calling `dagable` will:
74
+
75
+ 1. Define `Category::Edge` and `Category::Ancestry` (dynamic ActiveRecord classes)
76
+ 2. Set up `has_many` associations for edges and ancestry connections
77
+ 3. Include traversal and edge management instance methods
78
+ 4. Register an `after_create` callback for self-referential ancestry rows
79
+
80
+ ## Usage
81
+
82
+ ### Adding edges
83
+
84
+ ```ruby
85
+ electronics = Category.create!(name: "Electronics")
86
+ phones = Category.create!(name: "Phones")
87
+ smartphones = Category.create!(name: "Smartphones")
88
+
89
+ electronics.add_child(phones)
90
+ phones.add_child(smartphones)
91
+
92
+ # Or equivalently:
93
+ smartphones.add_parent(phones)
94
+ ```
95
+
96
+ ### Traversal
97
+
98
+ All traversal methods return `ActiveRecord::Relation`, so you can chain scopes, pluck, count, etc.
99
+
100
+ ```ruby
101
+ electronics.self_and_successors
102
+ # => [Electronics, Phones, Smartphones]
103
+
104
+ electronics.successors
105
+ # => [Phones, Smartphones]
106
+
107
+ smartphones.self_and_predecessors
108
+ # => [Electronics, Phones, Smartphones]
109
+
110
+ smartphones.predecessors
111
+ # => [Electronics, Phones]
112
+ ```
113
+
114
+ ### Direct relationships
115
+
116
+ Access direct parents/children via the edge associations:
117
+
118
+ ```ruby
119
+ electronics.children
120
+ # => [Phones]
121
+
122
+ phones.parents
123
+ # => [Electronics]
124
+
125
+ phones.children
126
+ # => [Smartphones]
127
+ ```
128
+
129
+ ### Removing edges
130
+
131
+ ```ruby
132
+ phones.remove_child(smartphones)
133
+
134
+ # Or equivalently:
135
+ smartphones.remove_parent(phones)
136
+ ```
137
+
138
+ After removal, the ancestry table is automatically rebuilt to reflect the new graph state.
139
+
140
+ ### Cycle detection
141
+
142
+ Dagable prevents cycles at edge-creation time:
143
+
144
+ ```ruby
145
+ electronics = Category.create!(name: "Electronics")
146
+ phones = Category.create!(name: "Phones")
147
+ accessories = Category.create!(name: "Accessories")
148
+
149
+ electronics.add_child(phones)
150
+ phones.add_child(accessories)
151
+
152
+ accessories.add_child(electronics)
153
+ # => raises Dagable::Errors::CyclicAssociation
154
+
155
+ electronics.add_child(electronics)
156
+ # => raises Dagable::Errors::CyclicAssociation
157
+ ```
158
+
159
+ Valid DAG structures like diamonds (multiple paths converging) are allowed:
160
+
161
+ ```ruby
162
+ electronics = Category.create!(name: "Electronics")
163
+ phones = Category.create!(name: "Phones")
164
+ computers = Category.create!(name: "Computers")
165
+ chargers = Category.create!(name: "Chargers")
166
+
167
+ electronics.add_child(phones)
168
+ electronics.add_child(computers)
169
+ phones.add_child(chargers)
170
+ computers.add_child(chargers) # diamond — this is fine
171
+ ```
172
+
173
+ ### Seeding in migrations
174
+
175
+ Use `Dagable::Migrations::Helper` to create records with edges in a single transaction:
176
+
177
+ ```ruby
178
+ Dagable::Migrations::Helper.create_dagable_item(
179
+ Category,
180
+ { name: "Electronics" },
181
+ children: [phones, laptops],
182
+ )
183
+ ```
184
+
185
+ ## Architecture
186
+
187
+ ### How the ancestry table works
188
+
189
+ The ancestry table stores every reachable pair `(predecessor, successor)` with a `depth`:
190
+
191
+ | predecessor_id | successor_id | depth |
192
+ |----------------|--------------|-------|
193
+ | Electronics | Electronics | 0 |
194
+ | Phones | Phones | 0 |
195
+ | Smartphones | Smartphones | 0 |
196
+ | Electronics | Phones | 1 |
197
+ | Phones | Smartphones | 1 |
198
+ | Electronics | Smartphones | 2 |
199
+
200
+ - **Depth 0** — self-referential row (every node has one)
201
+ - **Depth 1** — direct parent-child relationship
202
+ - **Depth N** — transitive relationship N edges apart
203
+
204
+ This allows traversal queries to be simple JOINs:
205
+
206
+ ```sql
207
+ -- self_and_successors for Electronics
208
+ SELECT categories.*
209
+ FROM categories
210
+ INNER JOIN categories_ancestries ON categories_ancestries.successor_id = categories.id
211
+ WHERE categories_ancestries.predecessor_id = electronics.id
212
+ ```
213
+
214
+ ### Key design decisions
215
+
216
+ - **Two tables per model**: edges store direct relationships; ancestries store all transitive paths. This separation makes edge removal clean (delete edge, rebuild ancestries from remaining edges).
217
+ - **Self-referential ancestry rows**: every node has a depth-0 row pointing to itself. This simplifies JOIN-based traversal by ensuring `self_and_successors` naturally includes the node itself.
218
+ - **`link_ancestries` on Edge**: when an edge is created, the Edge computes the cross product of the parent's predecessors with the child's successors and bulk-inserts ancestry rows for each pair. This is idempotent (existing rows are skipped via `INSERT ... ON CONFLICT DO NOTHING`).
219
+ - **Rebuild on removal**: when an edge is removed or a node is destroyed, all non-self ancestry rows are deleted and rebuilt from the remaining edges. This is the safest approach for correctness.
220
+
221
+ ## Development
222
+
223
+ ```bash
224
+ bundle install
225
+ bundle exec rspec
226
+ ```
227
+
228
+ Tests run against an in-memory SQLite database — no external database required.
229
+
230
+ ## License
231
+
232
+ This gem is available as open source under the terms of the [MIT License](LICENSE).
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dagable
4
+ # Abstract base class for ancestry records. Each dagable
5
+ # model gets a concrete subclass (e.g. +Category::Ancestry+) backed by
6
+ # +{table}_ancestries+.
7
+ #
8
+ # Each row represents a path between a predecessor and successor with a
9
+ # given depth:
10
+ #
11
+ # - +depth 0+ — self-referential row (every node has one)
12
+ # - +depth 1+ — direct parent-child relationship
13
+ # - +depth N+ — transitive relationship N edges apart
14
+ class Ancestry < ActiveRecord::Base
15
+ self.abstract_class = true
16
+ end
17
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dagable
4
+ # A dynamic module that, when included into an ActiveRecord model, declares
5
+ # all +has_many+ associations needed for the DAG. Constructed with the class
6
+ # names of the concrete Edge and Ancestry subclasses.
7
+ #
8
+ # == Declared associations
9
+ #
10
+ # Edge associations (direct relationships):
11
+ # - +parent_edges+ / +parents+ — direct parent records via the edge table
12
+ # - +child_edges+ / +children+ — direct child records via the edge table
13
+ #
14
+ # Ancestry associations (raw ancestry table rows):
15
+ # - +predecessor_connections+ — ancestry rows where this node is the successor
16
+ # - +successor_connections+ — ancestry rows where this node is the predecessor
17
+ #
18
+ # Traversal methods (+predecessors+, +successors+, +self_and_predecessors+,
19
+ # +self_and_successors+) are provided by +Dagable::InstanceMethods+ instead
20
+ # of +has_many :through+ to correctly exclude self-referential rows.
21
+ class Associations < Module
22
+ attr_reader :edge_class, :ancestry_class
23
+
24
+ def initialize(edge_class, ancestry_class)
25
+ super()
26
+
27
+ @edge_class = edge_class
28
+ @ancestry_class = ancestry_class
29
+ end
30
+
31
+ def included(base)
32
+ base.has_many :parent_edges, class_name: edge_class, foreign_key: :child_id, dependent: :destroy,
33
+ inverse_of: :child
34
+ base.has_many :parents, through: :parent_edges
35
+
36
+ base.has_many :child_edges, class_name: edge_class, foreign_key: :parent_id, dependent: :destroy,
37
+ inverse_of: :parent
38
+ base.has_many :children, through: :child_edges
39
+
40
+ base.has_many :predecessor_connections,
41
+ class_name: ancestry_class,
42
+ foreign_key: :successor_id,
43
+ dependent: :destroy,
44
+ inverse_of: :successor
45
+
46
+ base.has_many :successor_connections,
47
+ class_name: ancestry_class,
48
+ foreign_key: :predecessor_id,
49
+ dependent: :destroy,
50
+ inverse_of: :predecessor
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dagable
4
+ # Abstract base class for direct edge records. Each dagable model gets a
5
+ # concrete subclass (e.g. +Category::Edge+) backed by +{table}_edges+.
6
+ #
7
+ # An edge represents a single direct parent-child relationship. When an edge
8
+ # is created, +link_ancestries+ must be called to update the transitive
9
+ # paths in the ancestry table.
10
+ class Edge < ActiveRecord::Base
11
+ self.abstract_class = true
12
+
13
+ # Materializes all transitive ancestry rows implied by this edge.
14
+ #
15
+ # For an edge (parent -> child), this computes the cross product of all
16
+ # predecessors of the parent with all successors of the child, and inserts
17
+ # an ancestry row for each pair. The depth is calculated as the sum of the
18
+ # predecessor's depth to the parent + the child's depth to the successor + 1.
19
+ #
20
+ # This is safe to call multiple times — existing ancestry rows are skipped
21
+ # via +INSERT ... ON CONFLICT DO NOTHING+.
22
+ def link_ancestries
23
+ ancestry_class = self.class.module_parent.const_get("Ancestry")
24
+ records = ancestry_records(ancestry_class)
25
+
26
+ ancestry_class.insert_all(records, unique_by: %i[predecessor_id successor_id]) if records.any?
27
+ end
28
+
29
+ private
30
+
31
+ # Builds the ancestry rows implied by this edge as a cross product.
32
+ #
33
+ # For an edge P -> C, every predecessor of P must be linked to every
34
+ # successor of C (including P and C themselves, via their depth-0 rows).
35
+ #
36
+ # == Example
37
+ #
38
+ # Given the chain A → B and a new edge B → C:
39
+ #
40
+ # predecessor_rows (ancestry rows ending at B):
41
+ # { predecessor_id: A, successor_id: B, depth: 1 }
42
+ # { predecessor_id: B, successor_id: B, depth: 0 } ← self-row
43
+ #
44
+ # successor_rows (ancestry rows starting at C):
45
+ # { predecessor_id: C, successor_id: C, depth: 0 } ← self-row
46
+ #
47
+ # cross product produces:
48
+ # { predecessor_id: A, successor_id: C, depth: 1 + 0 + 1 = 2 }
49
+ # { predecessor_id: B, successor_id: C, depth: 0 + 0 + 1 = 1 }
50
+ #
51
+ # @param ancestry_class [Class] the concrete Ancestry subclass
52
+ # @return [Array<Hash>] attribute hashes ready for +insert_all+
53
+ def ancestry_records(ancestry_class)
54
+ predecessor_rows = ancestry_class.where(successor_id: parent_id).to_a
55
+ successor_rows = ancestry_class.where(predecessor_id: child_id).to_a
56
+
57
+ predecessor_rows.flat_map do |pred|
58
+ successor_rows.map do |succ|
59
+ { predecessor_id: pred.predecessor_id, successor_id: succ.successor_id, depth: pred.depth + succ.depth + 1 }
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dagable
4
+ module Errors
5
+ # Raised when adding an edge would introduce a cycle into the DAG.
6
+ # This includes self-referential edges (A -> A), direct cycles (A -> B -> A),
7
+ # and transitive cycles (A -> B -> C -> A).
8
+ class CyclicAssociation < StandardError
9
+ ERROR_MESSAGE = "Cyclic association detected between %<parent>s and %<child>s"
10
+
11
+ def initialize(parent, child)
12
+ super(format(ERROR_MESSAGE, parent: "#{parent.class}##{parent.id}", child: "#{child.class}##{child.id}"))
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dagable
4
+ # Instance methods mixed into every dagable model. Provides the public API
5
+ # for managing edges and traversing the DAG.
6
+ module InstanceMethods
7
+ # Adds +parent+ as a direct parent of this node. Creates the edge record
8
+ # and updates the ancestry table with all implied transitive paths.
9
+ #
10
+ # @param parent [ActiveRecord::Base] the node to add as parent
11
+ # @raise [Dagable::Errors::CyclicAssociation] if adding this edge would
12
+ # create a cycle (including self-referential edges)
13
+ def add_parent(parent)
14
+ ActiveRecord::Base.transaction do
15
+ raise Errors::CyclicAssociation.new(parent, self) if self_and_successors.exists?(id: parent.id)
16
+
17
+ edge = parent_edges.find_or_create_by!(parent_id: parent.id)
18
+ edge.link_ancestries
19
+ end
20
+ end
21
+
22
+ # Adds +child+ as a direct child of this node. Creates the edge record
23
+ # and updates the ancestry table with all implied transitive paths.
24
+ #
25
+ # @param child [ActiveRecord::Base] the node to add as child
26
+ # @raise [Dagable::Errors::CyclicAssociation] if adding this edge would
27
+ # create a cycle (including self-referential edges)
28
+ def add_child(child)
29
+ ActiveRecord::Base.transaction do
30
+ raise Errors::CyclicAssociation.new(self, child) if self_and_predecessors.exists?(id: child.id)
31
+
32
+ edge = child_edges.find_or_create_by!(child_id: child.id)
33
+ edge.link_ancestries
34
+ end
35
+ end
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.
39
+ #
40
+ # @param child [ActiveRecord::Base] the child node to disconnect
41
+ def remove_child(child)
42
+ ActiveRecord::Base.transaction do
43
+ child_edges.where(child_id: child.id).delete_all
44
+ rebuild_ancestries
45
+ end
46
+ end
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.
50
+ #
51
+ # @param parent [ActiveRecord::Base] the parent node to disconnect
52
+ def remove_parent(parent)
53
+ ActiveRecord::Base.transaction do
54
+ parent_edges.where(parent_id: parent.id).delete_all
55
+ rebuild_ancestries
56
+ end
57
+ end
58
+
59
+ # Returns this node and all its transitive descendants as an
60
+ # +ActiveRecord::Relation+ via a single ancestry table JOIN.
61
+ #
62
+ # @return [ActiveRecord::Relation]
63
+ def self_and_successors
64
+ ancestry_table = ancestry_class.table_name
65
+ model_table = self.class.table_name
66
+
67
+ self.class
68
+ .joins("INNER JOIN #{ancestry_table} ON #{ancestry_table}.successor_id = #{model_table}.id")
69
+ .where("#{ancestry_table}.predecessor_id = ?", id)
70
+ end
71
+
72
+ # Returns this node and all its transitive ancestors as an
73
+ # +ActiveRecord::Relation+ via a single ancestry table JOIN.
74
+ #
75
+ # @return [ActiveRecord::Relation]
76
+ def self_and_predecessors
77
+ ancestry_table = ancestry_class.table_name
78
+ model_table = self.class.table_name
79
+
80
+ self.class
81
+ .joins("INNER JOIN #{ancestry_table} ON #{ancestry_table}.predecessor_id = #{model_table}.id")
82
+ .where("#{ancestry_table}.successor_id = ?", id)
83
+ end
84
+
85
+ # Returns all transitive descendants (excluding self) as an
86
+ # +ActiveRecord::Relation+.
87
+ #
88
+ # @return [ActiveRecord::Relation]
89
+ def successors
90
+ ancestry_table = ancestry_class.table_name
91
+ model_table = self.class.table_name
92
+
93
+ self.class
94
+ .joins("INNER JOIN #{ancestry_table} ON #{ancestry_table}.successor_id = #{model_table}.id")
95
+ .where("#{ancestry_table}.predecessor_id = ? AND #{ancestry_table}.depth > 0", id)
96
+ end
97
+
98
+ # Returns all transitive ancestors (excluding self) as an
99
+ # +ActiveRecord::Relation+.
100
+ #
101
+ # @return [ActiveRecord::Relation]
102
+ def predecessors
103
+ ancestry_table = ancestry_class.table_name
104
+ model_table = self.class.table_name
105
+
106
+ self.class
107
+ .joins("INNER JOIN #{ancestry_table} ON #{ancestry_table}.predecessor_id = #{model_table}.id")
108
+ .where("#{ancestry_table}.successor_id = ? AND #{ancestry_table}.depth > 0", id)
109
+ end
110
+
111
+ private
112
+
113
+ def edge_class = self.class.const_get("Edge")
114
+
115
+ def ancestry_class = self.class.const_get("Ancestry")
116
+
117
+ # Callback: inserts the self-referential ancestry row (depth 0) for this
118
+ # node. Every node must have one for the JOIN-based traversal to work.
119
+ def create_self_ancestry_row
120
+ ancestry_class.find_or_create_by!(
121
+ predecessor_id: id,
122
+ successor_id: id
123
+ ) { |row| row.depth = 0 }
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
+ end
134
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dagable
4
+ # Schema helpers for creating the two supporting tables that every dagable
5
+ # model requires. Include this module in your migration class.
6
+ #
7
+ # == Usage
8
+ #
9
+ # class CreateCategoryDag < ActiveRecord::Migration[8.1]
10
+ # include Dagable::MigrationHelpers
11
+ #
12
+ # def change
13
+ # create_dagable_tables(:categories)
14
+ # end
15
+ # end
16
+ #
17
+ # This creates:
18
+ # - +categories_edges+ with +parent_id+ and +child_id+ columns, a unique
19
+ # composite index, and foreign keys back to the source table
20
+ # - +categories_ancestries+ with +predecessor_id+, +successor_id+, and
21
+ # +depth+ columns, a unique composite index, individual indexes on each
22
+ # foreign key, and foreign keys back to the source table
23
+ module MigrationHelpers
24
+ # Creates the edge and ancestry tables for the given +source_table_name+.
25
+ #
26
+ # @param source_table_name [Symbol, String] the table name of the dagable
27
+ # model (e.g. +:categories+)
28
+ def create_dagable_tables(source_table_name)
29
+ create_dagable_edges_table(source_table_name)
30
+ create_dagable_ancestries_table(source_table_name)
31
+ end
32
+
33
+ private
34
+
35
+ def create_dagable_edges_table(source_table_name)
36
+ edges_table = "#{source_table_name}_edges"
37
+
38
+ create_table edges_table do |t|
39
+ t.bigint :parent_id, null: false
40
+ t.bigint :child_id, null: false
41
+ end
42
+
43
+ add_index edges_table, %i[parent_id child_id], unique: true
44
+ add_foreign_key edges_table, source_table_name, column: :parent_id
45
+ add_foreign_key edges_table, source_table_name, column: :child_id
46
+ end
47
+
48
+ def create_dagable_ancestries_table(source_table_name)
49
+ ancestries_table = "#{source_table_name}_ancestries"
50
+
51
+ create_table ancestries_table do |t|
52
+ t.bigint :predecessor_id, null: false
53
+ t.bigint :successor_id, null: false
54
+ t.integer :depth, null: false, default: 0
55
+ end
56
+
57
+ add_index ancestries_table, %i[predecessor_id successor_id], unique: true
58
+ add_index ancestries_table, :predecessor_id
59
+ add_index ancestries_table, :successor_id
60
+ add_foreign_key ancestries_table, source_table_name, column: :predecessor_id
61
+ add_foreign_key ancestries_table, source_table_name, column: :successor_id
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dagable
4
+ module Migrations
5
+ # Convenience methods for seeding dagable records inside migrations or
6
+ # seed files. Wraps creation and edge wiring in a single transaction.
7
+ #
8
+ # == Usage
9
+ #
10
+ # Dagable::Migrations::Helper.create_dagable_item(
11
+ # Category,
12
+ # { name: "Electronics" },
13
+ # children: [phones, laptops],
14
+ # )
15
+ module Helper
16
+ module_function
17
+
18
+ # Creates a dagable record and wires up parent/child edges in one
19
+ # transaction.
20
+ #
21
+ # @param model [Class] the dagable ActiveRecord model class
22
+ # @param attributes [Hash] attributes to pass to +model.create!+
23
+ # @param parents [Array] parent nodes or IDs to attach
24
+ # @param children [Array] child nodes or IDs to attach
25
+ # @return [ActiveRecord::Base] the created record
26
+ def create_dagable_item(model, attributes, parents: [], children: [])
27
+ ActiveRecord::Base.transaction do
28
+ item = model.create!(attributes)
29
+
30
+ Array(parents).each { |parent| item.add_parent(retrieve_item(model, parent)) }
31
+ Array(children).each { |child| item.add_child(retrieve_item(model, child)) }
32
+
33
+ item
34
+ end
35
+ end
36
+
37
+ # Resolves a reference to a dagable record. Accepts either a model
38
+ # instance or an integer ID.
39
+ #
40
+ # @param model [Class] the dagable ActiveRecord model class
41
+ # @param reference [ActiveRecord::Base, Integer] the record or its ID
42
+ # @return [ActiveRecord::Base]
43
+ # @raise [ArgumentError] if the reference type is unsupported
44
+ def retrieve_item(model, reference)
45
+ case reference
46
+ when model then reference
47
+ when Integer then model.find(reference)
48
+ else raise ArgumentError, "Invalid dagable item reference"
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dagable
4
+ # Provides the +dagable+ class method that activates DAG behaviour on an
5
+ # ActiveRecord model. Extend this module in your model, then call +dagable+
6
+ # to set up edge and ancestry classes, associations, and instance methods.
7
+ #
8
+ # == Usage
9
+ #
10
+ # class Category < ActiveRecord::Base
11
+ # extend Dagable::Model
12
+ #
13
+ # dagable
14
+ # end
15
+ #
16
+ # Calling +dagable+ will:
17
+ #
18
+ # 1. Define +Category::Edge+ (subclass of +Dagable::Edge+) backed by
19
+ # +categories_edges+
20
+ # 2. Define +Category::Ancestry+ (subclass of +Dagable::Ancestry+) backed by
21
+ # +categories_ancestries+
22
+ # 3. Include +Dagable::Associations+ setting up +has_many+ relationships for
23
+ # edges and ancestry connections
24
+ # 4. Include +Dagable::InstanceMethods+ providing +add_child+, +add_parent+,
25
+ # traversal methods, etc.
26
+ # 5. Register an +after_create+ callback to insert the self-referential
27
+ # ancestry row (depth 0) for every new record
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
35
+
36
+ after_create :create_self_ancestry_row
37
+ after_destroy :rebuild_ancestries
38
+ end
39
+
40
+ private
41
+
42
+ def edge_class_definition
43
+ klass_name = name
44
+ table = "#{table_name}_edges"
45
+
46
+ Class.new(Dagable::Edge) do
47
+ self.table_name = table
48
+
49
+ belongs_to :parent, class_name: klass_name, inverse_of: :child_edges
50
+ belongs_to :child, class_name: klass_name, inverse_of: :parent_edges
51
+ end
52
+ end
53
+
54
+ def ancestry_class_definition
55
+ klass_name = name
56
+ table = "#{table_name}_ancestries"
57
+
58
+ Class.new(Dagable::Ancestry) do
59
+ self.table_name = table
60
+
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
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dagable
4
+ VERSION = "0.1.0"
5
+ end
data/lib/dagable.rb ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ require_relative "dagable/version"
6
+ require_relative "dagable/errors/cyclic_association"
7
+ require_relative "dagable/edge"
8
+ require_relative "dagable/ancestry"
9
+ require_relative "dagable/associations"
10
+ require_relative "dagable/instance_methods"
11
+ require_relative "dagable/model"
12
+ require_relative "dagable/migration_helpers"
13
+ require_relative "dagable/migrations/helper"
14
+
15
+ # Dagable provides directed acyclic graph (DAG) composition for ActiveRecord
16
+ # models using a dedicated ancestry table. It materializes all transitive
17
+ # paths so that traversal queries are single JOINs instead of recursive CTEs.
18
+ #
19
+ # == Quick start
20
+ #
21
+ # class Category < ActiveRecord::Base
22
+ # extend Dagable::Model
23
+ #
24
+ # dagable
25
+ # end
26
+ #
27
+ # This gives the model +add_child+, +add_parent+, +remove_child+,
28
+ # +remove_parent+, and traversal methods (+self_and_successors+,
29
+ # +self_and_predecessors+, +successors+, +predecessors+).
30
+ #
31
+ # == Database tables
32
+ #
33
+ # Each dagable model requires two supporting tables that can be created with
34
+ # +Dagable::MigrationHelpers#create_dagable_tables+:
35
+ #
36
+ # - +{table}_edges+ — stores direct parent-child relationships
37
+ # - +{table}_ancestries+ — stores all transitive paths with depth
38
+ module Dagable; end
metadata ADDED
@@ -0,0 +1,144 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dagable
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Leandro Maduro
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '8.1'
19
+ - - ">="
20
+ - !ruby/object:Gem::Version
21
+ version: 8.1.3
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - "~>"
27
+ - !ruby/object:Gem::Version
28
+ version: '8.1'
29
+ - - ">="
30
+ - !ruby/object:Gem::Version
31
+ version: 8.1.3
32
+ - !ruby/object:Gem::Dependency
33
+ name: rake
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '13.3'
39
+ type: :development
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '13.3'
46
+ - !ruby/object:Gem::Dependency
47
+ name: rspec
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '3.13'
53
+ type: :development
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '3.13'
60
+ - !ruby/object:Gem::Dependency
61
+ name: rubocop
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '1.75'
67
+ type: :development
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '1.75'
74
+ - !ruby/object:Gem::Dependency
75
+ name: rubocop-rspec
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '3.6'
81
+ type: :development
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '3.6'
88
+ - !ruby/object:Gem::Dependency
89
+ name: sqlite3
90
+ requirement: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '2.9'
95
+ type: :development
96
+ prerelease: false
97
+ version_requirements: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - "~>"
100
+ - !ruby/object:Gem::Version
101
+ version: '2.9'
102
+ description: Provides reusable directed acyclic graph (DAG) composition via a dedicated
103
+ ancestry table for any ActiveRecord model.
104
+ email:
105
+ - leandromaduro1@gmail.com
106
+ executables: []
107
+ extensions: []
108
+ extra_rdoc_files: []
109
+ files:
110
+ - LICENSE
111
+ - README.md
112
+ - lib/dagable.rb
113
+ - lib/dagable/ancestry.rb
114
+ - lib/dagable/associations.rb
115
+ - lib/dagable/edge.rb
116
+ - lib/dagable/errors/cyclic_association.rb
117
+ - lib/dagable/instance_methods.rb
118
+ - lib/dagable/migration_helpers.rb
119
+ - lib/dagable/migrations/helper.rb
120
+ - lib/dagable/model.rb
121
+ - lib/dagable/version.rb
122
+ homepage: https://github.com/leandro-maduro/dagable
123
+ licenses:
124
+ - MIT
125
+ metadata:
126
+ rubygems_mfa_required: 'true'
127
+ rdoc_options: []
128
+ require_paths:
129
+ - lib
130
+ required_ruby_version: !ruby/object:Gem::Requirement
131
+ requirements:
132
+ - - ">="
133
+ - !ruby/object:Gem::Version
134
+ version: '3.4'
135
+ required_rubygems_version: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - ">="
138
+ - !ruby/object:Gem::Version
139
+ version: '0'
140
+ requirements: []
141
+ rubygems_version: 3.6.9
142
+ specification_version: 4
143
+ summary: DAG infrastructure for ActiveRecord models
144
+ test_files: []