dag_me 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +28 -6
- data/lib/dag_me/configuration.rb +1 -1
- data/lib/dag_me/ddl.rb +287 -100
- data/lib/dag_me/errors.rb +1 -1
- data/lib/dag_me/graph.rb +23 -4
- data/lib/dag_me/railtie.rb +1 -0
- data/lib/dag_me/task_helpers.rb +28 -0
- data/lib/dag_me/test_helper.rb +1 -1
- data/lib/dag_me/version.rb +1 -1
- data/lib/generators/dag_me/refresh_generator.rb +39 -0
- data/lib/generators/dag_me/templates/refresh_dag.rb.erb +9 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c1012817d36f748b2056aec9ea431941bccab8abb2f9e28bb6e1cf576dc40c87
|
|
4
|
+
data.tar.gz: 18e716d54532f6a25605454dcc1ae08fe91d3e1f6c54dca5e39ede20a2fe8ba2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 43b71fef3c469db2e0fbeb5ee2b8831a8ae9553015d813c68cbe72004db61c25ca5154a71563579ca0100e4f7839c0553edaa706037906be9d8b7a84200532c2
|
|
7
|
+
data.tar.gz: 05ef5140aa9a23e43f02414179a55ce9849b8b7809d1eb5923378bda3bacd1e3032ab89a73384081eefcc8a0d69011a3642c8f8e1e4cff7324618fedabed3f2a
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.5.0](https://github.com/ClosureTree/dag_me/compare/v0.4.2...v0.5.0) (2026-10-06)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* keep the dev bundle off activerecord 8.1.4 ([b488d73](https://github.com/ClosureTree/dag_me/commit/b488d735e945ecaef270045e8a53254b9558b8f2))
|
|
9
|
+
* recompute the closure without enumerating paths ([498c9dc](https://github.com/ClosureTree/dag_me/commit/498c9dced5d899de1c3ecd140accdda9f0c5c658))
|
data/README.md
CHANGED
|
@@ -89,7 +89,7 @@ For a `missions` table:
|
|
|
89
89
|
| `mission_dag_edge_insert_apply` | `AFTER INSERT`: incremental closure expansion |
|
|
90
90
|
| `mission_dag_edge_delete_apply` | `AFTER DELETE`: exact `path_count` decrement + `min_depth` repair |
|
|
91
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
|
|
92
|
+
| `mission_dag_rebuild_paths()` / `mission_dag_validate_paths()` | Rebuild from edges / diff against a recomputation from edges |
|
|
93
93
|
|
|
94
94
|
Generated by [rails_lens](https://github.com/seuros/rails_lens) from the test app
|
|
95
95
|
(`make erd`); it reflects the runtime classes dag_me defines, so your own models
|
|
@@ -299,7 +299,8 @@ flowchart LR
|
|
|
299
299
|
path counts explode combinatorially in dense DAGs).
|
|
300
300
|
- `min_depth` is the shortest-path length. Deleting an edge triggers exact decremental
|
|
301
301
|
maintenance: contributions through the deleted edge are subtracted, zero-count pairs
|
|
302
|
-
are dropped, and `min_depth` is repaired
|
|
302
|
+
are dropped, and `min_depth` is repaired: only pairs whose shortest path ran through
|
|
303
|
+
the deleted edge are recomputed, and changes propagate upward until nothing moves.
|
|
303
304
|
- Edge deletion in dense graphs is the expensive operation, by design. Reads are cheap,
|
|
304
305
|
inserts are `ancestors(parent) × descendants(child)`, deletes pay for exactness.
|
|
305
306
|
- Concurrent writers are serialized per graph with `pg_advisory_xact_lock` - two
|
|
@@ -310,6 +311,12 @@ flowchart LR
|
|
|
310
311
|
an in-flight edge into a cross-tenant graph.
|
|
311
312
|
- Destroying a node tears down its edges through the triggers (not FK-cascade ordering),
|
|
312
313
|
so the closure shrinks correctly.
|
|
314
|
+
- `rebuild!` and `validate` recompute the closure from the edges layer by layer (nodes
|
|
315
|
+
grouped by their longest distance from a root), so their cost is polynomial no matter
|
|
316
|
+
how many paths the graph holds. They use temp tables, so they need a writable
|
|
317
|
+
connection, and they raise `DagMe::CycleError` if edges written with triggers disabled
|
|
318
|
+
form a cycle. `validate` runs in its own REPEATABLE READ transaction; inside yours it
|
|
319
|
+
holds SHARE locks on the node and edge tables instead, so writers wait until it ends.
|
|
313
320
|
|
|
314
321
|
## Rake tasks
|
|
315
322
|
|
|
@@ -318,6 +325,20 @@ rake dag_me:status # doctor report per network: tables, triggers, funct
|
|
|
318
325
|
rake dag_me:rebuild # rebuild every closure (or MODEL=Mission for one)
|
|
319
326
|
```
|
|
320
327
|
|
|
328
|
+
## Upgrading
|
|
329
|
+
|
|
330
|
+
The triggers and functions live in your database, so a gem upgrade that changes them
|
|
331
|
+
needs a migration. Function bodies are stamped with `DagMe::DDL::REVISION`, and
|
|
332
|
+
`rake dag_me:status` flags graphs installed by an older one. Generate a refresh per model:
|
|
333
|
+
|
|
334
|
+
```bash
|
|
335
|
+
rails generate dag_me:refresh Mission
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
The migration calls `DagMe::DDL.refresh!(Mission)`, which replaces every function body
|
|
339
|
+
with `CREATE OR REPLACE` and reapplies the closure table's storage settings. Tables,
|
|
340
|
+
triggers, and rows are left alone.
|
|
341
|
+
|
|
321
342
|
## Testing your app's graphs
|
|
322
343
|
|
|
323
344
|
The gem ships Minitest assertions for host applications:
|
|
@@ -343,13 +364,14 @@ All assertions accept `dag:` for named networks.
|
|
|
343
364
|
|
|
344
365
|
```bash
|
|
345
366
|
make up # postgres:18 via docker compose (port 5438)
|
|
346
|
-
make check #
|
|
367
|
+
make check # dictator lint + full suite
|
|
347
368
|
```
|
|
348
369
|
|
|
349
370
|
The suite includes property tests that apply random edge insertions, edge deletions,
|
|
350
|
-
and node destructions (single- and multi-tenant) and validate the closure against
|
|
351
|
-
|
|
352
|
-
reverse edges across threads.
|
|
371
|
+
and node destructions (single- and multi-tenant) and validate the closure against a
|
|
372
|
+
recomputation from the edges after every single operation, plus concurrency tests racing
|
|
373
|
+
reverse edges across threads. The recomputation itself is checked against brute-force
|
|
374
|
+
path enumeration on small random graphs.
|
|
353
375
|
|
|
354
376
|
Large graph fixtures are generated, not committed: [vial](https://rubygems.org/gems/vial)
|
|
355
377
|
compiles `test/vials/*.vial.rb` into deterministic YAML fixtures at test boot
|
data/lib/dag_me/configuration.rb
CHANGED
|
@@ -29,7 +29,7 @@ module DagMe
|
|
|
29
29
|
# Ivar peek keeps class load DB-free; composite keys must be declared
|
|
30
30
|
# before the macro. Array === because AR seeds a BasicObject sentinel.
|
|
31
31
|
@composite_pk = model.instance_variable_defined?(:@primary_key) &&
|
|
32
|
-
Array === model.instance_variable_get(:@primary_key)
|
|
32
|
+
Array === model.instance_variable_get(:@primary_key)
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def closure?
|
data/lib/dag_me/ddl.rb
CHANGED
|
@@ -17,7 +17,11 @@ module DagMe
|
|
|
17
17
|
# task_dag_node_update -- BEFORE UPDATE on tasks: scope-change guard (scoped only)
|
|
18
18
|
# task_dag_node_delete -- BEFORE DELETE on tasks: drop edges through triggers
|
|
19
19
|
# task_dag_rebuild_paths() -- full closure rebuild from edges
|
|
20
|
-
# task_dag_validate_paths() -- closure vs
|
|
20
|
+
# task_dag_validate_paths() -- stored closure vs closure recomputed from edges
|
|
21
|
+
#
|
|
22
|
+
# rebuild and validate share truth_sql: a layered recomputation that is
|
|
23
|
+
# polynomial in the graph size. Function bodies are stamped with REVISION;
|
|
24
|
+
# refresh! upgrades an installed graph in place.
|
|
21
25
|
#
|
|
22
26
|
# Node identity is an ordered column list (Configuration#node_pk_columns):
|
|
23
27
|
# single-key models get the classic parent_id / child_id / ancestor_id /
|
|
@@ -35,6 +39,17 @@ module DagMe
|
|
|
35
39
|
# The paths table and its triggers are skipped for maintain: :recursive_cte;
|
|
36
40
|
# cycle rejection then uses a recursive CTE in the BEFORE INSERT trigger.
|
|
37
41
|
class DDL
|
|
42
|
+
# Bumped whenever generated function bodies change. Stamped onto the lock
|
|
43
|
+
# function, so `dag_me:status` can spot installs that predate a fix.
|
|
44
|
+
REVISION = 2
|
|
45
|
+
|
|
46
|
+
# Free space left in every closure page. Writes update path_count and
|
|
47
|
+
# min_depth, neither indexed, so with room on the page PostgreSQL can
|
|
48
|
+
# update in place (HOT) and skip both index inserts: on a dense
|
|
49
|
+
# 1,000-node graph that took HOT updates from 27% to 97% and halved
|
|
50
|
+
# mid-graph edge writes.
|
|
51
|
+
PATHS_FILLFACTOR = 70
|
|
52
|
+
|
|
38
53
|
class << self
|
|
39
54
|
# Installs / removes the SQL objects for every dag the model declares.
|
|
40
55
|
def install!(model)
|
|
@@ -44,6 +59,12 @@ module DagMe
|
|
|
44
59
|
def uninstall!(model)
|
|
45
60
|
model.dag_configs.each_value { |config| new(config).uninstall! }
|
|
46
61
|
end
|
|
62
|
+
|
|
63
|
+
# Replaces the function bodies of an installed graph with the current
|
|
64
|
+
# revision. Tables, triggers, and data are left alone.
|
|
65
|
+
def refresh!(model)
|
|
66
|
+
model.dag_configs.each_value { |config| new(config).refresh! }
|
|
67
|
+
end
|
|
47
68
|
end
|
|
48
69
|
|
|
49
70
|
attr_reader :config
|
|
@@ -60,22 +81,47 @@ module DagMe
|
|
|
60
81
|
execute_all(uninstall_sql)
|
|
61
82
|
end
|
|
62
83
|
|
|
84
|
+
# Function bodies plus the closure's storage settings. Tables, triggers,
|
|
85
|
+
# and rows are left alone; existing pages gain free space as they are
|
|
86
|
+
# rewritten (VACUUM FULL, or rows updated over time).
|
|
87
|
+
def refresh!
|
|
88
|
+
execute_all(function_sql + storage_sql)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def storage_sql
|
|
92
|
+
return [] unless config.closure?
|
|
93
|
+
|
|
94
|
+
["ALTER TABLE #{config.paths_table} SET (fillfactor = #{PATHS_FILLFACTOR});"]
|
|
95
|
+
end
|
|
96
|
+
|
|
63
97
|
def install_sql
|
|
64
98
|
statements = [edges_table_sql]
|
|
65
|
-
if config.closure?
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
statements << backfill_self_rows_sql
|
|
70
|
-
statements << rebuild_function_sql
|
|
71
|
-
statements << validate_function_sql
|
|
72
|
-
else
|
|
73
|
-
statements.concat(cte_function_sql)
|
|
74
|
-
statements.concat(cte_trigger_sql)
|
|
75
|
-
end
|
|
99
|
+
statements << paths_table_sql if config.closure?
|
|
100
|
+
statements.concat(function_sql)
|
|
101
|
+
statements.concat(config.closure? ? closure_trigger_sql : cte_trigger_sql)
|
|
102
|
+
statements << backfill_self_rows_sql if config.closure?
|
|
76
103
|
statements
|
|
77
104
|
end
|
|
78
105
|
|
|
106
|
+
# Every function as CREATE OR REPLACE, plus the revision stamp. Re-running
|
|
107
|
+
# it on an installed graph is how refresh! upgrades in place.
|
|
108
|
+
def function_sql
|
|
109
|
+
statements = if config.closure?
|
|
110
|
+
closure_function_sql + [rebuild_function_sql, validate_function_sql]
|
|
111
|
+
else
|
|
112
|
+
cte_function_sql
|
|
113
|
+
end
|
|
114
|
+
statements << revision_comment_sql
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def revision_comment_sql
|
|
118
|
+
"COMMENT ON FUNCTION #{config.function_ref('lock')}(text) IS '#{self.class.revision_tag}';"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.revision_tag
|
|
122
|
+
"dag_me ddl revision #{REVISION}"
|
|
123
|
+
end
|
|
124
|
+
|
|
79
125
|
# Graph#backfill_self_rows! reuses this to repair nodes inserted with
|
|
80
126
|
# triggers disabled (e.g. Rails fixture loading).
|
|
81
127
|
def backfill_self_rows_sql
|
|
@@ -221,7 +267,7 @@ module DagMe
|
|
|
221
267
|
FOREIGN KEY #{tuple(anc_cols)} REFERENCES #{config.node_table} #{tuple(pk_cols)} ON DELETE CASCADE,
|
|
222
268
|
FOREIGN KEY #{tuple(desc_cols)} REFERENCES #{config.node_table} #{tuple(pk_cols)} ON DELETE CASCADE,
|
|
223
269
|
PRIMARY KEY (#{list(anc_cols)}, #{list(desc_cols)})
|
|
224
|
-
);
|
|
270
|
+
) WITH (fillfactor = #{PATHS_FILLFACTOR});
|
|
225
271
|
CREATE INDEX ON #{config.paths_table} (#{list(desc_cols)}, #{list(anc_cols)});
|
|
226
272
|
SQL
|
|
227
273
|
end
|
|
@@ -315,6 +361,47 @@ module DagMe
|
|
|
315
361
|
SQL
|
|
316
362
|
end
|
|
317
363
|
|
|
364
|
+
def x_cols
|
|
365
|
+
pk_cols.map { |c| "x_#{c}" }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def y_cols
|
|
369
|
+
pk_cols.map { |c| "y_#{c}" }
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Every pair (x, y) whose paths run through the edge `row` (NEW or OLD):
|
|
373
|
+
# x reaches the edge's parent, the edge's child reaches y. `through` is
|
|
374
|
+
# how many x -> y paths use the edge, `via_depth` the shortest of them.
|
|
375
|
+
def through_edge_sql(row)
|
|
376
|
+
x = anc_cols.zip(x_cols).map { |a, c| "a.#{a} AS #{c}" }.join(', ')
|
|
377
|
+
y = desc_cols.zip(y_cols).map { |d, c| "d.#{d} AS #{c}" }.join(', ')
|
|
378
|
+
<<~SQL.chomp
|
|
379
|
+
SELECT #{x}, #{y},
|
|
380
|
+
a.path_count * d.path_count AS through,
|
|
381
|
+
a.min_depth + 1 + d.min_depth AS via_depth
|
|
382
|
+
FROM #{config.paths_table} a
|
|
383
|
+
JOIN #{config.paths_table} d ON #{eq(anc_cols, child_cols, left: 'd', right: row)}
|
|
384
|
+
WHERE #{eq(desc_cols, parent_cols, left: 'a', right: row)}
|
|
385
|
+
SQL
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# "p.ancestor_id = r.x_id AND p.descendant_id = r.y_id"
|
|
389
|
+
def pair_match(row = 'r', target: 'p')
|
|
390
|
+
"#{eq(anc_cols, x_cols, left: target, right: row)} AND #{eq(desc_cols, y_cols, left: target, right: row)}"
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Shortest distance from w's x to w's y over x's remaining out-edges.
|
|
394
|
+
def recomputed_depth_sql(row)
|
|
395
|
+
<<~SQL.chomp
|
|
396
|
+
SELECT MIN(1 + cp.min_depth) AS new_depth
|
|
397
|
+
FROM #{config.edge_table} e
|
|
398
|
+
JOIN #{config.paths_table} cp
|
|
399
|
+
ON #{eq(anc_cols, child_cols, left: 'cp', right: 'e')}
|
|
400
|
+
AND #{eq(desc_cols, y_cols, left: 'cp', right: row)}
|
|
401
|
+
WHERE #{eq(parent_cols, x_cols, left: 'e', right: row)}
|
|
402
|
+
SQL
|
|
403
|
+
end
|
|
404
|
+
|
|
318
405
|
def edge_insert_apply_sql
|
|
319
406
|
paths = config.paths_table
|
|
320
407
|
<<~SQL
|
|
@@ -340,75 +427,79 @@ module DagMe
|
|
|
340
427
|
|
|
341
428
|
def edge_delete_apply_sql
|
|
342
429
|
p = config.prefix
|
|
343
|
-
edges = config.edge_table
|
|
344
430
|
paths = config.paths_table
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
rect_defs = (col_defs(x_cols) + col_defs(y_cols)).map { |d| " #{d}," }.join("\n")
|
|
431
|
+
edges = config.edge_table
|
|
432
|
+
work_defs = (col_defs(x_cols) + col_defs(y_cols)).map { |d| " #{d}" }.join(",\n")
|
|
348
433
|
<<~SQL
|
|
349
434
|
CREATE OR REPLACE FUNCTION #{config.function_ref('edge_delete_apply')}() RETURNS trigger
|
|
350
435
|
LANGUAGE plpgsql AS $$
|
|
351
436
|
BEGIN
|
|
352
437
|
PERFORM #{config.function_ref('lock')}(#{scope_key_expr('OLD')});
|
|
353
438
|
|
|
354
|
-
--
|
|
355
|
-
--
|
|
356
|
-
--
|
|
357
|
-
--
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
439
|
+
-- Every pair through the deleted edge loses `through` paths: pairs
|
|
440
|
+
-- left with none are dropped, the rest decremented. Multipliers
|
|
441
|
+
-- cannot traverse the deleted edge (that would imply a cycle), so
|
|
442
|
+
-- the closure rows they come from are untouched here.
|
|
443
|
+
WITH r AS MATERIALIZED (
|
|
444
|
+
#{through_edge_sql('OLD')}
|
|
445
|
+
),
|
|
446
|
+
gone AS (
|
|
447
|
+
DELETE FROM #{paths} p USING r
|
|
448
|
+
WHERE #{pair_match} AND p.path_count <= r.through
|
|
449
|
+
)
|
|
450
|
+
UPDATE #{paths} p
|
|
451
|
+
SET path_count = p.path_count - r.through
|
|
452
|
+
FROM r
|
|
453
|
+
WHERE #{pair_match} AND p.path_count > r.through;
|
|
454
|
+
|
|
455
|
+
-- min_depth repair. Only a surviving pair whose shortest path ran
|
|
456
|
+
-- through the edge (min_depth = via_depth) can get longer:
|
|
457
|
+
-- min_depth(x, y) = min(1 + min_depth(c, y)) over edges x -> c
|
|
458
|
+
-- Pairs that change go to a worklist; their parents' pairs are
|
|
459
|
+
-- recomputed until nothing changes. Values only grow, from below.
|
|
460
|
+
CREATE TEMP TABLE IF NOT EXISTS #{p}_depth_work (
|
|
461
|
+
#{work_defs}
|
|
363
462
|
) ON COMMIT DROP;
|
|
364
|
-
DELETE FROM #{p}
|
|
365
|
-
|
|
366
|
-
INSERT INTO #{p}_delete_rect (#{list(x_cols)}, #{list(y_cols)}, removed, via_depth)
|
|
367
|
-
SELECT #{list(anc_cols, 'a')}, #{list(desc_cols, 'd')},
|
|
368
|
-
a.path_count * d.path_count,
|
|
369
|
-
a.min_depth + 1 + d.min_depth
|
|
370
|
-
FROM #{paths} a
|
|
371
|
-
JOIN #{paths} d ON #{eq(anc_cols, child_cols, left: 'd', right: 'OLD')}
|
|
372
|
-
WHERE #{eq(desc_cols, parent_cols, left: 'a', right: 'OLD')};
|
|
463
|
+
DELETE FROM #{p}_depth_work;
|
|
373
464
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
AND #{eq(desc_cols, y_cols, left: 'p', right: 'r')};
|
|
379
|
-
|
|
380
|
-
DELETE FROM #{paths} p
|
|
381
|
-
USING #{p}_delete_rect r
|
|
382
|
-
WHERE #{eq(anc_cols, x_cols, left: 'p', right: 'r')}
|
|
383
|
-
AND #{eq(desc_cols, y_cols, left: 'p', right: 'r')}
|
|
384
|
-
AND p.path_count <= 0;
|
|
385
|
-
|
|
386
|
-
-- min_depth repair: surviving rectangle pairs may have lost their
|
|
387
|
-
-- shortest path. Iterate the recurrence
|
|
388
|
-
-- min_depth(x, y) = min(1 + min_depth(c, y)) over edges x -> c reaching y
|
|
389
|
-
-- until fixpoint; converges in at most longest-affected-chain steps.
|
|
390
|
-
LOOP
|
|
465
|
+
WITH r AS MATERIALIZED (
|
|
466
|
+
#{through_edge_sql('OLD')}
|
|
467
|
+
),
|
|
468
|
+
raised AS (
|
|
391
469
|
UPDATE #{paths} p
|
|
392
|
-
SET min_depth =
|
|
393
|
-
FROM
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
470
|
+
SET min_depth = m.new_depth
|
|
471
|
+
FROM r
|
|
472
|
+
CROSS JOIN LATERAL (
|
|
473
|
+
#{recomputed_depth_sql('r')}
|
|
474
|
+
) m
|
|
475
|
+
WHERE #{pair_match}
|
|
476
|
+
AND p.min_depth = r.via_depth
|
|
477
|
+
AND m.new_depth > p.min_depth
|
|
478
|
+
RETURNING #{list(anc_cols, 'p')}, #{list(desc_cols, 'p')}
|
|
479
|
+
)
|
|
480
|
+
INSERT INTO #{p}_depth_work SELECT * FROM raised;
|
|
481
|
+
|
|
482
|
+
WHILE EXISTS (SELECT 1 FROM #{p}_depth_work) LOOP
|
|
483
|
+
WITH done AS (
|
|
484
|
+
DELETE FROM #{p}_depth_work RETURNING *
|
|
485
|
+
),
|
|
486
|
+
w AS (
|
|
487
|
+
SELECT DISTINCT #{parent_cols.zip(x_cols).map { |pc, x| "e.#{pc} AS #{x}" }.join(', ')}, #{list(y_cols, 'd')}
|
|
488
|
+
FROM done d
|
|
489
|
+
JOIN #{edges} e ON #{eq(child_cols, x_cols, left: 'e', right: 'd')}
|
|
490
|
+
),
|
|
491
|
+
raised AS (
|
|
492
|
+
UPDATE #{paths} p
|
|
493
|
+
SET min_depth = m.new_depth
|
|
494
|
+
FROM w
|
|
495
|
+
CROSS JOIN LATERAL (
|
|
496
|
+
#{recomputed_depth_sql('w')}
|
|
497
|
+
) m
|
|
498
|
+
WHERE #{pair_match('w')}
|
|
499
|
+
AND m.new_depth > p.min_depth
|
|
500
|
+
RETURNING #{list(anc_cols, 'p')}, #{list(desc_cols, 'p')}
|
|
501
|
+
)
|
|
502
|
+
INSERT INTO #{p}_depth_work SELECT * FROM raised;
|
|
412
503
|
END LOOP;
|
|
413
504
|
|
|
414
505
|
RETURN NULL;
|
|
@@ -499,48 +590,139 @@ module DagMe
|
|
|
499
590
|
SQL
|
|
500
591
|
end
|
|
501
592
|
|
|
502
|
-
|
|
593
|
+
# Scratch tables for truth_sql: one row per node with its layer, and the
|
|
594
|
+
# closure being computed. Temp, dropped at commit.
|
|
595
|
+
def truth_scratch_sql
|
|
596
|
+
p = config.prefix
|
|
597
|
+
layer_defs = col_defs(pk_cols).map { |d| " #{d}," }.join("\n")
|
|
598
|
+
truth_defs = (col_defs(anc_cols) + col_defs(desc_cols)).map { |d| " #{d}," }.join("\n")
|
|
599
|
+
<<~SQL.chomp
|
|
600
|
+
CREATE TEMP TABLE IF NOT EXISTS #{p}_layer (
|
|
601
|
+
#{layer_defs}
|
|
602
|
+
layer integer NOT NULL,
|
|
603
|
+
PRIMARY KEY (#{list(pk_cols)})
|
|
604
|
+
) ON COMMIT DROP;
|
|
605
|
+
CREATE INDEX IF NOT EXISTS #{p}_layer_by_layer ON #{p}_layer (layer);
|
|
606
|
+
DELETE FROM #{p}_layer;
|
|
607
|
+
CREATE TEMP TABLE IF NOT EXISTS #{p}_truth (
|
|
608
|
+
#{truth_defs}
|
|
609
|
+
min_depth integer NOT NULL,
|
|
610
|
+
path_count numeric NOT NULL,
|
|
611
|
+
PRIMARY KEY (#{list(desc_cols)}, #{list(anc_cols)})
|
|
612
|
+
) ON COMMIT DROP;
|
|
613
|
+
DELETE FROM #{p}_truth;
|
|
614
|
+
SQL
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# Computes the exact closure from the edges into <prefix>_truth, without
|
|
618
|
+
# walking paths one by one (path counts grow exponentially with every
|
|
619
|
+
# diamond, so an enumerating walk does too).
|
|
620
|
+
#
|
|
621
|
+
# Nodes are layered by their longest distance from a root, one set-based
|
|
622
|
+
# INSERT per layer. Every parent of a layer-L node sits in a lower layer,
|
|
623
|
+
# so one aggregate per layer extends the parents' finished rows:
|
|
624
|
+
#
|
|
625
|
+
# path_count(a, v) = sum of path_count(a, u) over edges u -> v
|
|
626
|
+
# min_depth(a, v) = 1 + min of min_depth(a, u) over edges u -> v
|
|
627
|
+
#
|
|
628
|
+
# Nodes left without a layer sit on or below a cycle, which only edges
|
|
629
|
+
# written with triggers disabled can produce: raise DGME1.
|
|
630
|
+
#
|
|
631
|
+
# Expects `depth integer` declared by the enclosing function.
|
|
632
|
+
def truth_sql
|
|
633
|
+
p = config.prefix
|
|
634
|
+
edges = config.edge_table
|
|
635
|
+
node = config.node_table
|
|
503
636
|
<<~SQL.chomp
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
637
|
+
#{truth_scratch_sql}
|
|
638
|
+
|
|
639
|
+
INSERT INTO #{p}_layer (#{list(pk_cols)}, layer)
|
|
640
|
+
SELECT #{list(pk_cols, 'n')}, 0 FROM #{node} n
|
|
641
|
+
WHERE NOT EXISTS (SELECT 1 FROM #{edges} e WHERE #{eq(child_cols, pk_cols, left: 'e', right: 'n')});
|
|
642
|
+
|
|
643
|
+
depth := 0;
|
|
644
|
+
LOOP
|
|
645
|
+
depth := depth + 1;
|
|
646
|
+
-- children of the previous layer whose parents all have a layer
|
|
647
|
+
INSERT INTO #{p}_layer (#{list(pk_cols)}, layer)
|
|
648
|
+
SELECT DISTINCT #{list(child_cols, 'e')}, depth
|
|
649
|
+
FROM #{p}_layer f
|
|
650
|
+
JOIN #{edges} e ON #{eq(parent_cols, pk_cols, left: 'e', right: 'f')}
|
|
651
|
+
WHERE f.layer = depth - 1
|
|
652
|
+
AND NOT EXISTS (
|
|
653
|
+
SELECT 1 FROM #{edges} w
|
|
654
|
+
WHERE #{eq(child_cols, child_cols, left: 'w', right: 'e')}
|
|
655
|
+
AND NOT EXISTS (SELECT 1 FROM #{p}_layer q WHERE #{eq(pk_cols, parent_cols, left: 'q', right: 'w')})
|
|
656
|
+
);
|
|
657
|
+
EXIT WHEN NOT FOUND;
|
|
658
|
+
END LOOP;
|
|
659
|
+
|
|
660
|
+
IF (SELECT count(*) FROM #{p}_layer) < (SELECT count(*) FROM #{node}) THEN
|
|
661
|
+
RAISE EXCEPTION 'dag_me: % contains a cycle (edges written with triggers disabled?)', '#{edges}'
|
|
662
|
+
USING ERRCODE = '#{SQLSTATE_CYCLE}';
|
|
663
|
+
END IF;
|
|
664
|
+
|
|
665
|
+
INSERT INTO #{p}_truth (#{list(anc_cols)}, #{list(desc_cols)}, min_depth, path_count)
|
|
666
|
+
SELECT #{list(pk_cols)}, #{list(pk_cols)}, 0, 1 FROM #{node};
|
|
667
|
+
|
|
668
|
+
FOR step IN 1 .. depth - 1 LOOP
|
|
669
|
+
INSERT INTO #{p}_truth (#{list(anc_cols)}, #{list(desc_cols)}, min_depth, path_count)
|
|
670
|
+
SELECT #{list(anc_cols, 't')}, #{list(child_cols, 'e')}, MIN(t.min_depth) + 1, SUM(t.path_count)
|
|
671
|
+
FROM #{p}_layer v
|
|
672
|
+
JOIN #{edges} e ON #{eq(child_cols, pk_cols, left: 'e', right: 'v')}
|
|
673
|
+
JOIN #{p}_truth t ON #{eq(desc_cols, parent_cols, left: 't', right: 'e')}
|
|
674
|
+
WHERE v.layer = step
|
|
675
|
+
GROUP BY #{list(anc_cols, 't')}, #{list(child_cols, 'e')};
|
|
676
|
+
END LOOP;
|
|
511
677
|
SQL
|
|
512
678
|
end
|
|
513
679
|
|
|
680
|
+
# ", n.account_id" (leading comma) or "" - scope taken from the ancestor
|
|
681
|
+
# node, joined as `n` by truth_scope_join.
|
|
682
|
+
def truth_scope_select
|
|
683
|
+
config.scope_columns.map { |c| ", n.#{c}" }.join
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def truth_scope_join
|
|
687
|
+
return '' unless scoped?
|
|
688
|
+
|
|
689
|
+
" JOIN #{config.node_table} n ON #{eq(pk_cols, anc_cols, left: 'n', right: 't')}"
|
|
690
|
+
end
|
|
691
|
+
|
|
514
692
|
def rebuild_function_sql
|
|
515
|
-
scope_join = scoped? ? "JOIN #{config.node_table} n ON #{eq(pk_cols, anc_cols, left: 'n', right: 'walk')}" : ''
|
|
516
|
-
scope_group = config.scope_columns.map { |c| ", n.#{c}" }.join
|
|
517
693
|
<<~SQL
|
|
518
694
|
CREATE OR REPLACE FUNCTION #{config.function_ref('rebuild_paths')}() RETURNS void
|
|
519
695
|
LANGUAGE plpgsql AS $$
|
|
696
|
+
DECLARE
|
|
697
|
+
depth integer;
|
|
520
698
|
BEGIN
|
|
521
699
|
LOCK TABLE #{config.node_table}, #{config.edge_table} IN SHARE ROW EXCLUSIVE MODE;
|
|
522
|
-
DELETE FROM #{config.paths_table};
|
|
523
700
|
|
|
524
|
-
|
|
525
|
-
SELECT #{list(pk_cols)}, #{list(pk_cols)}, 0, 1#{scope_column_list} FROM #{config.node_table};
|
|
701
|
+
#{truth_sql.gsub(/^/, ' ')}
|
|
526
702
|
|
|
527
|
-
#{
|
|
703
|
+
DELETE FROM #{config.paths_table};
|
|
528
704
|
INSERT INTO #{config.paths_table} (#{list(anc_cols)}, #{list(desc_cols)}, min_depth, path_count#{scope_column_list})
|
|
529
|
-
SELECT #{list(anc_cols, '
|
|
530
|
-
FROM
|
|
531
|
-
GROUP BY #{list(anc_cols, 'walk')}, #{list(desc_cols, 'walk')}#{scope_group};
|
|
705
|
+
SELECT #{list(anc_cols, 't')}, #{list(desc_cols, 't')}, t.min_depth, t.path_count#{truth_scope_select}
|
|
706
|
+
FROM #{config.prefix}_truth t#{truth_scope_join};
|
|
532
707
|
END;
|
|
533
708
|
$$;
|
|
534
709
|
SQL
|
|
535
710
|
end
|
|
536
711
|
|
|
712
|
+
# The truth is computed across several statements. Inside a REPEATABLE
|
|
713
|
+
# READ (or stricter) transaction they share one snapshot; under READ
|
|
714
|
+
# COMMITTED each statement would see fresh data, so writers are held off
|
|
715
|
+
# with SHARE locks for the duration instead.
|
|
537
716
|
def validate_function_sql
|
|
538
|
-
scope_join = scoped? ? "JOIN #{config.node_table} n ON #{eq(pk_cols, anc_cols, left: 'n', right: 'walk')}" : ''
|
|
539
|
-
scope_group = config.scope_columns.map { |c| ", n.#{c}" }.join
|
|
540
717
|
scope_mismatch = scoped? ? "OR #{scope_distinct_expr('s', 't')}" : ''
|
|
541
718
|
returns = (col_defs(anc_cols, not_null: false) + col_defs(desc_cols, not_null: false))
|
|
542
719
|
.map { |d| " #{d}," }.join("\n")
|
|
543
720
|
coalesced = (anc_cols + desc_cols).map { |c| "COALESCE(t.#{c}, s.#{c})" }.join(",\n ")
|
|
721
|
+
truth = if scoped?
|
|
722
|
+
"(SELECT t.*#{truth_scope_select} FROM #{config.prefix}_truth t#{truth_scope_join})"
|
|
723
|
+
else
|
|
724
|
+
"#{config.prefix}_truth"
|
|
725
|
+
end
|
|
544
726
|
<<~SQL
|
|
545
727
|
CREATE OR REPLACE FUNCTION #{config.function_ref('validate_paths')}()
|
|
546
728
|
RETURNS TABLE(
|
|
@@ -550,18 +732,22 @@ module DagMe
|
|
|
550
732
|
true_min_depth integer,
|
|
551
733
|
true_path_count numeric
|
|
552
734
|
)
|
|
553
|
-
LANGUAGE
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
735
|
+
LANGUAGE plpgsql AS $$
|
|
736
|
+
#variable_conflict use_column
|
|
737
|
+
DECLARE
|
|
738
|
+
depth integer;
|
|
739
|
+
BEGIN
|
|
740
|
+
IF current_setting('transaction_isolation') IN ('read committed', 'read uncommitted') THEN
|
|
741
|
+
LOCK TABLE #{config.node_table}, #{config.edge_table} IN SHARE MODE;
|
|
742
|
+
END IF;
|
|
743
|
+
|
|
744
|
+
#{truth_sql.gsub(/^/, ' ')}
|
|
745
|
+
|
|
746
|
+
RETURN QUERY
|
|
561
747
|
SELECT #{coalesced},
|
|
562
748
|
s.min_depth, s.path_count,
|
|
563
749
|
t.min_depth, t.path_count
|
|
564
|
-
FROM truth t
|
|
750
|
+
FROM #{truth} t
|
|
565
751
|
FULL OUTER JOIN #{config.paths_table} s
|
|
566
752
|
ON #{eq(anc_cols, anc_cols, left: 's', right: 't')}
|
|
567
753
|
AND #{eq(desc_cols, desc_cols, left: 's', right: 't')}
|
|
@@ -570,6 +756,7 @@ module DagMe
|
|
|
570
756
|
OR s.min_depth <> t.min_depth
|
|
571
757
|
OR s.path_count <> t.path_count
|
|
572
758
|
#{scope_mismatch};
|
|
759
|
+
END;
|
|
573
760
|
$$;
|
|
574
761
|
SQL
|
|
575
762
|
end
|
data/lib/dag_me/errors.rb
CHANGED
|
@@ -16,7 +16,7 @@ module DagMe
|
|
|
16
16
|
class IsolationError < Error; end
|
|
17
17
|
|
|
18
18
|
# Raised by Graph#validate! when the stored closure disagrees with the
|
|
19
|
-
#
|
|
19
|
+
# closure recomputed from the edges. Carries the offending rows.
|
|
20
20
|
class CorruptionError < Error
|
|
21
21
|
attr_reader :discrepancies
|
|
22
22
|
|
data/lib/dag_me/graph.rb
CHANGED
|
@@ -51,7 +51,9 @@ module DagMe
|
|
|
51
51
|
return self unless config.closure?
|
|
52
52
|
|
|
53
53
|
model.connection_pool.with_connection do |conn|
|
|
54
|
-
|
|
54
|
+
DagMe.translate_errors do
|
|
55
|
+
conn.execute("SELECT #{conn.quote_table_name(config.function_ref('rebuild_paths'))}();")
|
|
56
|
+
end
|
|
55
57
|
end
|
|
56
58
|
self
|
|
57
59
|
end
|
|
@@ -68,13 +70,19 @@ module DagMe
|
|
|
68
70
|
self
|
|
69
71
|
end
|
|
70
72
|
|
|
71
|
-
# Rows where the stored closure disagrees with the
|
|
72
|
-
# Empty means healthy.
|
|
73
|
+
# Rows where the stored closure disagrees with the closure recomputed
|
|
74
|
+
# from the edges. Empty means healthy. Raises CycleError when the edges
|
|
75
|
+
# themselves contain a cycle (possible only with triggers disabled).
|
|
76
|
+
# Needs a writable connection: the recomputation uses temp tables.
|
|
73
77
|
def validate
|
|
74
78
|
return [] unless config.closure?
|
|
75
79
|
|
|
76
80
|
model.connection_pool.with_connection do |conn|
|
|
77
|
-
|
|
81
|
+
DagMe.translate_errors do
|
|
82
|
+
with_stable_snapshot(conn) do
|
|
83
|
+
conn.select_all("SELECT * FROM #{conn.quote_table_name(config.function_ref('validate_paths'))}();").to_a
|
|
84
|
+
end
|
|
85
|
+
end
|
|
78
86
|
end
|
|
79
87
|
end
|
|
80
88
|
|
|
@@ -92,5 +100,16 @@ module DagMe
|
|
|
92
100
|
|
|
93
101
|
self
|
|
94
102
|
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# validate_paths() reads the graph across several statements. Its own
|
|
107
|
+
# REPEATABLE READ transaction gives them one snapshot without blocking
|
|
108
|
+
# writers; inside a caller's transaction the function locks instead.
|
|
109
|
+
def with_stable_snapshot(conn, &)
|
|
110
|
+
return yield if conn.transaction_open?
|
|
111
|
+
|
|
112
|
+
conn.transaction(isolation: :repeatable_read, &)
|
|
113
|
+
end
|
|
95
114
|
end
|
|
96
115
|
end
|
data/lib/dag_me/railtie.rb
CHANGED
data/lib/dag_me/task_helpers.rb
CHANGED
|
@@ -46,6 +46,7 @@ module DagMe
|
|
|
46
46
|
lines.concat(table_checks(model, config))
|
|
47
47
|
lines.concat(trigger_checks(model, config))
|
|
48
48
|
lines.concat(function_checks(model, config))
|
|
49
|
+
lines << revision_line(model, installed_revision(model, config))
|
|
49
50
|
lines << closure_check(model, config)
|
|
50
51
|
"#{lines.compact.join("\n ")}\n"
|
|
51
52
|
end
|
|
@@ -111,6 +112,31 @@ module DagMe
|
|
|
111
112
|
'n.nspname = ANY (current_schemas(false))'
|
|
112
113
|
end
|
|
113
114
|
|
|
115
|
+
# Function bodies carry the DDL revision they were generated from (a
|
|
116
|
+
# comment on the lock function). :current, :outdated, or nil when the
|
|
117
|
+
# lock function is missing (function_checks already reports that).
|
|
118
|
+
def installed_revision(model, config)
|
|
119
|
+
conn = model.connection
|
|
120
|
+
stamps = conn.select_values(<<~SQL)
|
|
121
|
+
SELECT COALESCE(obj_description(p.oid, 'pg_proc'), '') FROM pg_proc p
|
|
122
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
123
|
+
WHERE #{namespace_filter(conn, config)}
|
|
124
|
+
AND p.proname = #{conn.quote("#{config.prefix}_lock")}
|
|
125
|
+
SQL
|
|
126
|
+
return nil if stamps.empty?
|
|
127
|
+
|
|
128
|
+
stamps.first == DDL.revision_tag ? :current : :outdated
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def revision_line(model, revision)
|
|
132
|
+
case revision
|
|
133
|
+
when :current then ok("functions at revision #{DDL::REVISION}")
|
|
134
|
+
when :outdated
|
|
135
|
+
bad("functions predate revision #{DDL::REVISION} - run `rails generate dag_me:refresh #{model.name}` " \
|
|
136
|
+
'and migrate')
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
114
140
|
def closure_check(model, config)
|
|
115
141
|
return ok('closure: not materialized (recursive_cte)') unless config.closure?
|
|
116
142
|
return nil unless model.connection.table_exists?(config.paths_table)
|
|
@@ -123,6 +149,8 @@ module DagMe
|
|
|
123
149
|
facade = config.default? ? 'Model.dag' : "Model.dag(:#{config.name})"
|
|
124
150
|
bad("closure diverged: #{discrepancies.length} rows - run #{facade}.rebuild!")
|
|
125
151
|
end
|
|
152
|
+
rescue CycleError
|
|
153
|
+
bad("#{config.edge_table} contains a cycle - edges were written with triggers disabled")
|
|
126
154
|
end
|
|
127
155
|
end
|
|
128
156
|
end
|
data/lib/dag_me/test_helper.rb
CHANGED
|
@@ -29,7 +29,7 @@ module DagMe
|
|
|
29
29
|
"#{klass} should be scoped by #{Array(scope).join(', ')}"
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
# Asserts the stored closure agrees with the
|
|
32
|
+
# Asserts the stored closure agrees with the closure recomputed from the edges.
|
|
33
33
|
def assert_dag_valid(klass, dag: nil)
|
|
34
34
|
discrepancies = klass.dag(dag).validate
|
|
35
35
|
|
data/lib/dag_me/version.rb
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
require 'rails/generators/migration'
|
|
5
|
+
require 'rails/generators/active_record'
|
|
6
|
+
|
|
7
|
+
module DagMe
|
|
8
|
+
module Generators
|
|
9
|
+
# rails generate dag_me:refresh Task
|
|
10
|
+
#
|
|
11
|
+
# Upgrades an installed graph's function bodies to the current
|
|
12
|
+
# DagMe::DDL::REVISION. Named after the revision, so later refreshes
|
|
13
|
+
# get their own migration.
|
|
14
|
+
class RefreshGenerator < Rails::Generators::NamedBase
|
|
15
|
+
include Rails::Generators::Migration
|
|
16
|
+
|
|
17
|
+
source_root File.expand_path('templates', __dir__)
|
|
18
|
+
|
|
19
|
+
def create_migration_file
|
|
20
|
+
migration_template 'refresh_dag.rb.erb',
|
|
21
|
+
"db/migrate/refresh_dag_me_r#{revision}_for_#{file_name.pluralize}.rb"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.next_migration_number(dirname)
|
|
25
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def revision
|
|
31
|
+
DagMe::DDL::REVISION
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def migration_version
|
|
35
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class RefreshDagMeR<%= revision %>For<%= class_name.pluralize %> < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
# Replaces dag_me's function bodies with revision <%= revision %>.
|
|
5
|
+
# Tables, triggers, and rows are left alone.
|
|
6
|
+
def up
|
|
7
|
+
DagMe::DDL.refresh!(<%= class_name %>)
|
|
8
|
+
end
|
|
9
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: dag_me
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Abdelkader Boudih
|
|
@@ -46,6 +46,7 @@ executables: []
|
|
|
46
46
|
extensions: []
|
|
47
47
|
extra_rdoc_files: []
|
|
48
48
|
files:
|
|
49
|
+
- CHANGELOG.md
|
|
49
50
|
- MIT-LICENSE
|
|
50
51
|
- README.md
|
|
51
52
|
- lib/dag_me.rb
|
|
@@ -64,7 +65,9 @@ files:
|
|
|
64
65
|
- lib/dag_me/test_helper.rb
|
|
65
66
|
- lib/dag_me/version.rb
|
|
66
67
|
- lib/generators/dag_me/migration_generator.rb
|
|
68
|
+
- lib/generators/dag_me/refresh_generator.rb
|
|
67
69
|
- lib/generators/dag_me/templates/install_dag.rb.erb
|
|
70
|
+
- lib/generators/dag_me/templates/refresh_dag.rb.erb
|
|
68
71
|
homepage: https://github.com/ClosureTree/dag_me
|
|
69
72
|
licenses:
|
|
70
73
|
- MIT
|
|
@@ -87,7 +90,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
87
90
|
- !ruby/object:Gem::Version
|
|
88
91
|
version: '0'
|
|
89
92
|
requirements: []
|
|
90
|
-
rubygems_version: 4.0.
|
|
93
|
+
rubygems_version: 4.0.20
|
|
91
94
|
specification_version: 4
|
|
92
95
|
summary: Multi-parent DAGs for ActiveRecord, powered by PostgreSQL 18+
|
|
93
96
|
test_files: []
|