dag_me 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: ba16630068a6ea6417e5aee617ba2d9621e7e711dfc7d4459721b48f399d0da7
4
+ data.tar.gz: 04ffb8914db06228c7b7851155f8b6b779f6dc86756e7b2f2dacf0d6be3541f4
5
+ SHA512:
6
+ metadata.gz: 9ddb66c78132f8766f56aa7d786a158968c09d83549121336597d755d2115bd6c85ef5158867926c9ab786379f6da2d331f3edb1cea2b1603c8b1bdef5e9fc21
7
+ data.tar.gz: d16ee5de2122fe5a72ec4ffef79270c32f5d20a529781e1cbb0d15cfa27f9948f0c5c1f9b106066d28bf660b4edb56207eb3c016c8a3a21ed1becea67d4f9e4e
data/MIT-LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 Abdelkader Boudih
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,346 @@
1
+ # dag_me
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/dag_me.svg)](https://badge.fury.io/rb/dag_me)
4
+ [![CI](https://github.com/ClosureTree/dag_me/actions/workflows/ci.yml/badge.svg)](https://github.com/seuros/dag_me/actions/workflows/ci.yml)
5
+
6
+ Multi-parent directed acyclic graphs for ActiveRecord, powered by PostgreSQL 18+.
7
+
8
+ A companion to [closure_tree](https://github.com/ClosureTree/closure_tree): reach for
9
+ closure_tree when your hierarchy is a tree, and for dag_me when it isn't - missions with
10
+ multiple dependencies, categories with multiple parents, pipelines, dependency graphs,
11
+ org charts that lie.
12
+
13
+ ```mermaid
14
+ flowchart TD
15
+ subgraph tree ["Tree - every node has exactly one parent (closure_tree)"]
16
+ direction TB
17
+ t_mission[mission] --> t_design[design] & t_review[review]
18
+ t_design --> t_hull[hull] & t_guidance[guidance]
19
+ end
20
+ subgraph dag ["DAG - nodes can have many parents (dag_me)"]
21
+ direction TB
22
+ d_design[design] --> d_hull[hull] & d_guidance[guidance]
23
+ d_hull --> d_assembly[assembly]
24
+ d_guidance --> d_assembly
25
+ d_review[review] --> d_assembly
26
+ d_assembly --> d_launch[launch]
27
+ d_guidance --> d_launch
28
+ end
29
+ ```
30
+
31
+ A tree forbids the interesting part: `assembly` depends on `hull`, `guidance`, **and**
32
+ `review`, and two paths converge on `launch`. Those diamonds are exactly what dag_me
33
+ maintains - multiple parents, shared descendants, cycle-free, enforced in-database.
34
+
35
+ ```ruby
36
+ class Mission < ApplicationRecord
37
+ dag_me
38
+ end
39
+
40
+ design = Mission.create!(name: 'design')
41
+ review = Mission.create!(name: 'review')
42
+ launch = Mission.create!(name: 'launch')
43
+
44
+ launch.add_parent(design)
45
+ launch.add_parent(review) # multiple parents: the whole point
46
+
47
+ launch.parents # => [design, review]
48
+ design.descendants # => [launch]
49
+ launch.ancestors # => [design, review]
50
+ design.ancestor_of?(launch) # => true
51
+
52
+ launch.add_child(design) # => raises DagMe::CycleError, rejected in-database
53
+ ```
54
+
55
+ ```mermaid
56
+ flowchart LR
57
+ design --> launch
58
+ review --> launch
59
+ ```
60
+
61
+ ## Installation
62
+
63
+ ```ruby
64
+ gem 'dag_me'
65
+ ```
66
+
67
+ Generate the migration for each DAG model (tables, triggers, and functions are installed
68
+ per model - no dynamic SQL):
69
+
70
+ ```bash
71
+ rails generate dag_me:migration Mission
72
+ ```
73
+
74
+ Because the schema includes functions and triggers, use `structure.sql`:
75
+
76
+ ```ruby
77
+ config.active_record.schema_format = :sql
78
+ ```
79
+
80
+ ## What gets installed
81
+
82
+ For a `missions` table:
83
+
84
+ | Object | Role |
85
+ | --- | --- |
86
+ | `mission_dag_edges` | Source of truth: `(parent_id, child_id)`, unique, FK cascade |
87
+ | `mission_dag_paths` | Transitive closure incl. self-rows: `(ancestor_id, descendant_id, min_depth, path_count)` |
88
+ | `mission_dag_edge_insert_check` | `BEFORE INSERT`: advisory lock + cycle rejection |
89
+ | `mission_dag_edge_insert_apply` | `AFTER INSERT`: incremental closure expansion |
90
+ | `mission_dag_edge_delete_apply` | `AFTER DELETE`: exact `path_count` decrement + `min_depth` repair |
91
+ | `mission_dag_node_insert` / `mission_dag_node_delete` | Self-row lifecycle, edge teardown through triggers |
92
+ | `mission_dag_rebuild_paths()` / `mission_dag_validate_paths()` | Rebuild from edges / diff against CTE truth |
93
+
94
+ Generated by [rails_lens](https://github.com/seuros/rails_lens) from the test app
95
+ (`make erd`); it reflects the runtime classes dag_me defines, so your own models
96
+ get the same diagram for free:
97
+
98
+ ```mermaid
99
+ erDiagram
100
+ "Mission" {
101
+ int id PK
102
+ varchar name
103
+ }
104
+ "Mission::DagEdge" {
105
+ int id PK
106
+ int parent_id FK "UK"
107
+ int child_id FK "UK"
108
+ timestamptz created_at
109
+ }
110
+ "Mission::DagPath" {
111
+ int ancestor_id PK
112
+ int descendant_id PK
113
+ int min_depth
114
+ decimal path_count
115
+ }
116
+ "Mission::DagEdge" }o--|| "Mission" : "parent"
117
+ "Mission::DagEdge" }o--|| "Mission" : "child"
118
+ ```
119
+
120
+ Reads never recurse: `ancestors` and `descendants` are index joins against the closure.
121
+
122
+ ## Maintenance modes
123
+
124
+ The model API talks to a reachability adapter, not to the storage directly:
125
+
126
+ ```ruby
127
+ class Mission < ApplicationRecord
128
+ dag_me # maintain: :postgresql_closure (default)
129
+ end
130
+
131
+ class Maneuver < ApplicationRecord
132
+ dag_me maintain: :recursive_cte # edges only, WITH RECURSIVE at read time
133
+ end
134
+ ```
135
+
136
+ `:recursive_cte` skips the closure table entirely - good for small graphs, high mutation
137
+ rates, and as the truth oracle. Cycle rejection stays in-database either way.
138
+
139
+ ## Multi-tenancy
140
+
141
+ ```ruby
142
+ class Satellite < ApplicationRecord
143
+ dag_me scope: :constellation_id # or scope: [:system_id, :sector]
144
+ end
145
+ ```
146
+
147
+ Scope columns are stamped onto edge and closure rows by the trigger - always copied from
148
+ the node, so raw SQL cannot forge them. Edges connecting nodes in different scopes are
149
+ rejected in-database (`DagMe::ScopeError` through the gem API). Advisory locks are hashed
150
+ per scope, so tenants don't serialize each other's writes. Changing a node's scope columns
151
+ is rejected while the node has edges; isolated nodes restamp their closure self-row.
152
+
153
+ ## One model, many networks
154
+
155
+ `dag_me` takes an optional name; each named declaration is a fully independent
156
+ graph over the same rows, with its own tables, triggers, constants, and adapter:
157
+
158
+ ```ruby
159
+ class Relay < ApplicationRecord
160
+ dag_me :power # relay_power_dag_edges / _paths
161
+ dag_me :comms, maintain: :recursive_cte # relay_comms_dag_edges only
162
+ end
163
+
164
+ relay.add_child(other, dag: :power)
165
+ relay.power_children # named associations per network
166
+ relay.comms_parents
167
+ relay.ancestor_of?(other, dag: :comms)
168
+ Relay.roots(dag: :power)
169
+ Relay.topologically(:comms)
170
+ Relay.dag(:power).rebuild! # named graph facade
171
+ ```
172
+
173
+ Cycles are rejected per network: `a -> b` in `:power` plus `b -> a` in `:comms` is
174
+ legal (different graphs); a second `b -> a` in `:power` raises `DagMe::CycleError`.
175
+ The bare `dag_me` remains the default graph - the `dag:` keyword and `Model.dag`
176
+ with no argument keep meaning it - and a model may mix a default dag with named ones.
177
+ `DagMe::DDL.install!(Model)` and the generated migration install every declared
178
+ network.
179
+
180
+ ## Topological ordering & subgraphs
181
+
182
+ ```ruby
183
+ Mission.topologically # whole graph, ancestors first
184
+ mission.descendants.topologically # composes with any relation
185
+ Mission.dag.between(a, d) # nodes on any path a ~> d, endpoints included
186
+ Mission.dag.between(a, d).topologically
187
+ mission.subgraph # self_and_descendants
188
+ mission.subgraph_edges # induced edge set (for dot/mermaid exports)
189
+ Mission.dag.edges_among(some_relation) # induced edges of an arbitrary node set
190
+ ```
191
+
192
+ Ordering sorts by global ancestor count: for any edge `u -> v`, `ancestors(v)` strictly
193
+ contains `ancestors(u) ∪ {u}`, so the count increases along every edge - a valid
194
+ topological order for any sub-relation, computed with one index-only subquery per row
195
+ in closure mode. Ties break deterministically by primary key.
196
+
197
+ ## API
198
+
199
+ ```ruby
200
+ node.parents / node.children # direct relations (has_many :through)
201
+ node.ancestors / node.descendants # transitive, excludes self
202
+ node.self_and_ancestors / node.self_and_descendants
203
+ node.add_parent(n) / node.add_child(n) # raises DagMe::CycleError on cycles
204
+ node.remove_parent(n) / node.remove_child(n)
205
+ node.ancestor_of?(n) / node.descendant_of?(n)
206
+ node.root? / node.leaf?
207
+ node.subgraph / node.subgraph_edges
208
+ Model.roots / Model.leaves # relation scopes
209
+ Model.topologically
210
+
211
+ Model.dag # the graph facade (default dag)
212
+ Model.dag(:power) # a named dag's facade
213
+ Model.dag.between(a, d)
214
+ Model.dag.edges / Model.dag.edges_among(relation)
215
+ Model.dag.rebuild!
216
+ Model.dag.validate # discrepancy rows ([] = healthy)
217
+ Model.dag.valid?
218
+ Model.dag.validate! # raises DagMe::CorruptionError with the rows
219
+ ```
220
+
221
+ Every instance method and `Model.roots` / `Model.leaves` accept `dag:` to target a
222
+ named network (`node.add_child(n, dag: :power)`); `Model.topologically` takes the
223
+ name positionally so it stays composable as a scope.
224
+
225
+ uuid primary keys (e.g. `uuidv7()`) work out of the box - graph tables inherit the
226
+ node table's primary-key type.
227
+
228
+ ## Composite primary keys
229
+
230
+ Declare the key **before** the macro; `dag_me` derives one graph column per key
231
+ column (`parent_ship_id`, `parent_slot`, `ancestor_ship_id`, ...), and every
232
+ join and cycle check compares full tuples:
233
+
234
+ ```ruby
235
+ class PowerCell < ApplicationRecord
236
+ self.primary_key = [:ship_id, :slot]
237
+ dag_me
238
+ end
239
+ ```
240
+
241
+ Single-column keys keep the classic `parent_id` / `child_id` / `ancestor_id` /
242
+ `descendant_id` layout. Declaration order matters: `dag_me` reads the declared
243
+ key, not the schema (class load stays DB-free).
244
+
245
+ ## The name
246
+
247
+ **D**irected **A**cyclic **G**raph **M**anagement **E**ngine. Not to be
248
+ confused with the Intel Management Engine: this one also runs below your
249
+ application with privileges you can't revoke, but it's open source, you asked
250
+ for it, and the only ring it operates in is `pg_advisory_xact_lock`.
251
+
252
+ It's also the macro - a model that wants to be a graph says `dag_me`.
253
+
254
+ ## Errors
255
+
256
+ The triggers RAISE with custom SQLSTATEs (`DGME1` cycle, `DGME2` cross-scope edge,
257
+ `DGME3` scope change while connected, `DGME4` write above READ COMMITTED), so
258
+ translation never depends on message text. Through the gem's write API these surface
259
+ as `DagMe::CycleError` / `DagMe::ScopeError` / `DagMe::IsolationError`; writes outside
260
+ it (raw SQL, `update!` on scope columns) raise the underlying
261
+ `ActiveRecord::StatementInvalid` carrying the same SQLSTATE.
262
+
263
+ ## Semantics worth knowing
264
+
265
+ Solid arrows are edges; the dashed one is what the closure materializes:
266
+
267
+ ```mermaid
268
+ flowchart LR
269
+ a --> b & c
270
+ b --> d
271
+ c --> d
272
+ a -. "min_depth 2, path_count 2" .-> d
273
+ ```
274
+
275
+ - `path_count` is the exact number of distinct paths between two nodes (`numeric`, because
276
+ path counts explode combinatorially in dense DAGs).
277
+ - `min_depth` is the shortest-path length. Deleting an edge triggers exact decremental
278
+ maintenance: contributions through the deleted edge are subtracted, zero-count pairs
279
+ are dropped, and `min_depth` is repaired by fixpoint iteration.
280
+ - Edge deletion in dense graphs is the expensive operation, by design. Reads are cheap,
281
+ inserts are `ancestors(parent) × descendants(child)`, deletes pay for exactness.
282
+ - Concurrent writers are serialized per graph with `pg_advisory_xact_lock` - two
283
+ transactions cannot sneak a cycle in by racing the check.
284
+ - Writes require READ COMMITTED: lock-then-recheck needs a fresh snapshot after
285
+ the lock wait, so higher isolation is rejected with `DagMe::IsolationError`.
286
+ - Edge inserts take `FOR SHARE` on both node rows; scope changes cannot race
287
+ an in-flight edge into a cross-tenant graph.
288
+ - Destroying a node tears down its edges through the triggers (not FK-cascade ordering),
289
+ so the closure shrinks correctly.
290
+
291
+ ## Rake tasks
292
+
293
+ ```bash
294
+ rake dag_me:status # doctor report per network: tables, triggers, functions, closure health
295
+ rake dag_me:rebuild # rebuild every closure (or MODEL=Mission for one)
296
+ ```
297
+
298
+ ## Testing your app's graphs
299
+
300
+ The gem ships Minitest assertions for host applications:
301
+
302
+ ```ruby
303
+ class GraphSetupTest < ActiveSupport::TestCase
304
+ include DagMe::TestHelper
305
+
306
+ test 'missions form a healthy DAG' do
307
+ assert_dag_model Mission, maintain: :postgresql_closure
308
+ assert_dag_model Satellite, scope: :constellation_id
309
+ assert_dag_model Relay, dag: :power, maintain: :postgresql_closure
310
+ assert_dag_valid Mission
311
+ assert_dag_reachable design, launch
312
+ assert_topological_order Mission, Mission.topologically.to_a
313
+ end
314
+ end
315
+ ```
316
+
317
+ All assertions accept `dag:` for named networks.
318
+
319
+ ## Development
320
+
321
+ ```bash
322
+ make up # postgres:18 via docker compose (port 5438)
323
+ make check # rubocop + full suite
324
+ ```
325
+
326
+ The suite includes property tests that apply random edge insertions, edge deletions,
327
+ and node destructions (single- and multi-tenant) and validate the closure against
328
+ recursive-CTE truth after every single operation, plus concurrency tests racing
329
+ reverse edges across threads.
330
+
331
+ Large graph fixtures are generated, not committed: [vial](https://rubygems.org/gems/vial)
332
+ compiles `test/vials/*.vial.rb` into deterministic YAML fixtures at test boot
333
+ (`test/fixtures/` is gitignored). The layered 120-node / 300-edge graph exercises
334
+ the bulk-import path - Rails fixture loading bypasses triggers, so the pattern is:
335
+
336
+ ```ruby
337
+ ActiveRecord::FixtureSet.create_fixtures(...) # raw edges, no closure maintenance
338
+ Mission.dag.rebuild! # reconstruct closure from edges
339
+ Mission.dag.validate! # prove it
340
+ ```
341
+
342
+ The same recipe applies to any bulk import (`COPY`, `insert_all`, ETL).
343
+
344
+ ## License
345
+
346
+ MIT
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DagMe
4
+ module Adapters
5
+ # The reachability interface. The model layer only talks to this; whether
6
+ # answers come from the materialized closure or a recursive CTE is an
7
+ # adapter concern.
8
+ class Base
9
+ attr_reader :config
10
+
11
+ def initialize(config)
12
+ @config = config
13
+ end
14
+
15
+ def model
16
+ config.model
17
+ end
18
+
19
+ def ancestors(node)
20
+ raise NotImplementedError
21
+ end
22
+
23
+ def descendants(node)
24
+ raise NotImplementedError
25
+ end
26
+
27
+ def self_and_ancestors(node)
28
+ raise NotImplementedError
29
+ end
30
+
31
+ def self_and_descendants(node)
32
+ raise NotImplementedError
33
+ end
34
+
35
+ # True when `ancestor` reaches `descendant` through one or more edges.
36
+ def reachable?(ancestor, descendant)
37
+ raise NotImplementedError
38
+ end
39
+
40
+ # Nodes on any path from `ancestor` to `descendant`, endpoints included:
41
+ # self_and_descendants(ancestor) ∩ self_and_ancestors(descendant).
42
+ def between(ancestor, descendant)
43
+ self_and_descendants(ancestor).and(self_and_ancestors(descendant))
44
+ end
45
+
46
+ # Orders a relation so ancestors always precede descendants. Both
47
+ # adapters sort by global ancestor count: for any edge u -> v,
48
+ # ancestors(v) ⊇ ancestors(u) ∪ {u}, so the count strictly increases
49
+ # along every edge - a valid topological order for any sub-relation.
50
+ def apply_topological_order(relation)
51
+ raise NotImplementedError
52
+ end
53
+
54
+ private
55
+
56
+ def pk_columns
57
+ config.node_pk_columns
58
+ end
59
+
60
+ # Node identity values in pk-column order (composite ids are arrays).
61
+ def node_values(node)
62
+ Array(node.id)
63
+ end
64
+
65
+ def node_key(node)
66
+ pk_columns.zip(node_values(node)).to_h
67
+ end
68
+
69
+ def same_node?(one, other)
70
+ node_values(one) == node_values(other)
71
+ end
72
+
73
+ # ("tasks"."id") / ("widgets"."org_id", "widgets"."serial") - the node's
74
+ # pk as a row value, for tuple membership tests against a subquery.
75
+ def pk_tuple_in(subquery_ast)
76
+ tuple = Arel::Nodes::Grouping.new(pk_columns.map { |c| model.arel_table[c] })
77
+ Arel::Nodes::In.new(tuple, subquery_ast)
78
+ end
79
+
80
+ def ordered_by(relation, count_subquery)
81
+ relation.order(Arel::Nodes::Ascending.new(Arel::Nodes::Grouping.new(count_subquery.ast)))
82
+ .order(**pk_order)
83
+ end
84
+
85
+ def pk_order
86
+ pk_columns.to_h { |c| [c.to_sym, :asc] }
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DagMe
4
+ module Adapters
5
+ # Answers reachability from the trigger-maintained closure table.
6
+ # Every query is a plain index join; no recursion at read time.
7
+ class PostgresqlClosure < Base
8
+ def ancestors(node)
9
+ self_and_ancestors(node).where.not(node_key(node))
10
+ end
11
+
12
+ def descendants(node)
13
+ self_and_descendants(node).where.not(node_key(node))
14
+ end
15
+
16
+ def self_and_ancestors(node)
17
+ reach(node, select_cols: config.paths_ancestor_columns,
18
+ match_cols: config.paths_descendant_columns)
19
+ end
20
+
21
+ def self_and_descendants(node)
22
+ reach(node, select_cols: config.paths_descendant_columns,
23
+ match_cols: config.paths_ancestor_columns)
24
+ end
25
+
26
+ def reachable?(ancestor, descendant)
27
+ return false if same_node?(ancestor, descendant)
28
+
29
+ key = config.paths_ancestor_columns.zip(node_values(ancestor))
30
+ .concat(config.paths_descendant_columns.zip(node_values(descendant)))
31
+ .to_h
32
+ paths.exists?(key)
33
+ end
34
+
35
+ def apply_topological_order(relation)
36
+ tp = paths.arel_table
37
+ count = tp.project(Arel.star.count).where(
38
+ config.paths_descendant_columns.zip(pk_columns)
39
+ .map { |dc, pk| tp[dc].eq(model.arel_table[pk]) }.inject(:and)
40
+ )
41
+ ordered_by(relation, count)
42
+ end
43
+
44
+ private
45
+
46
+ # Nodes whose pk tuple appears in the paths table on `select_cols`,
47
+ # restricted by the node's identity on `match_cols`.
48
+ def reach(node, select_cols:, match_cols:)
49
+ sub = paths.where(match_cols.zip(node_values(node)).to_h)
50
+ .select(*select_cols)
51
+ model.where(pk_tuple_in(sub.arel.ast))
52
+ end
53
+
54
+ def paths
55
+ config.paths_class
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DagMe
4
+ module Adapters
5
+ # Answers reachability by walking the edges table with WITH RECURSIVE.
6
+ # No materialized state; suited to small graphs, high mutation rates,
7
+ # and as the truth oracle when validating the closure adapter.
8
+ class RecursiveCte < Base
9
+ def ancestors(node)
10
+ walk(node, from: config.edge_child_columns, to: config.edge_parent_columns)
11
+ end
12
+
13
+ def descendants(node)
14
+ walk(node, from: config.edge_parent_columns, to: config.edge_child_columns)
15
+ end
16
+
17
+ def self_and_ancestors(node)
18
+ ancestors(node).or(model.where(node_key(node)))
19
+ end
20
+
21
+ def self_and_descendants(node)
22
+ descendants(node).or(model.where(node_key(node)))
23
+ end
24
+
25
+ def reachable?(ancestor, descendant)
26
+ return false if same_node?(ancestor, descendant)
27
+
28
+ descendants(ancestor).where(node_key(descendant)).exists?
29
+ end
30
+
31
+ def apply_topological_order(relation)
32
+ cte, definition = reach_cte('up',
33
+ from: config.edge_child_columns,
34
+ to: config.edge_parent_columns) do |edge|
35
+ config.edge_child_columns.zip(pk_columns)
36
+ .map { |c, pk| edge[c].eq(model.arel_table[pk]) }.inject(:and)
37
+ end
38
+ count = cte.project(Arel.star.count).with(:recursive, definition)
39
+ ordered_by(relation, count)
40
+ end
41
+
42
+ private
43
+
44
+ def walk(node, from:, to:)
45
+ cte, definition = reach_cte('walk', from: from, to: to) do |edge|
46
+ from.zip(node_values(node)).map { |c, v| edge[c].eq(v) }.inject(:and)
47
+ end
48
+ reachable = cte.project(*pk_columns.map { |c| cte[c] })
49
+ .with(:recursive, definition)
50
+ model.where(pk_tuple_in(reachable.ast))
51
+ end
52
+
53
+ # WITH RECURSIVE <name>(pk...) walking the edges table from `from`
54
+ # towards `to`, seeded by the condition the block builds on the edge
55
+ # table. Returns the CTE table and its definition.
56
+ def reach_cte(name, from:, to:)
57
+ edge = Arel::Table.new(config.edge_table)
58
+ cte = Arel::Table.new(name)
59
+ conn = model.connection
60
+ base = edge.project(*to.zip(pk_columns).map { |c, pk| edge[c].as(conn.quote_column_name(pk)) })
61
+ .where(yield(edge))
62
+ step = edge.project(*to.map { |c| edge[c] })
63
+ .join(cte).on(from.zip(pk_columns).map { |c, pk| edge[c].eq(cte[pk]) }.inject(:and))
64
+ [cte, Arel::Nodes::As.new(cte, base.union(step))]
65
+ end
66
+ end
67
+ end
68
+ end