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 +7 -0
- data/LICENSE +21 -0
- data/README.md +232 -0
- data/lib/dagable/ancestry.rb +17 -0
- data/lib/dagable/associations.rb +53 -0
- data/lib/dagable/edge.rb +64 -0
- data/lib/dagable/errors/cyclic_association.rb +16 -0
- data/lib/dagable/instance_methods.rb +134 -0
- data/lib/dagable/migration_helpers.rb +64 -0
- data/lib/dagable/migrations/helper.rb +53 -0
- data/lib/dagable/model.rb +66 -0
- data/lib/dagable/version.rb +5 -0
- data/lib/dagable.rb +38 -0
- metadata +144 -0
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
|
data/lib/dagable/edge.rb
ADDED
|
@@ -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
|
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: []
|