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.
data/lib/dag_me/ddl.rb ADDED
@@ -0,0 +1,602 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DagMe
4
+ # Generates and executes the SQL objects for a dag_me model.
5
+ #
6
+ # Everything is derived from the model's Configuration. For a `tasks` table
7
+ # with prefix `task_dag`, installs:
8
+ #
9
+ # task_dag_edges -- source of truth (parent_*, child_* [, scope cols])
10
+ # task_dag_paths -- transitive closure incl. self-rows
11
+ # (ancestor_*, descendant_*, min_depth, path_count [, scope cols])
12
+ # task_dag_lock(text) -- isolation guard + per-scope pg_advisory_xact_lock
13
+ # task_dag_edge_insert_check -- BEFORE INSERT: scope stamp + lock + cycle rejection
14
+ # task_dag_edge_insert_apply -- AFTER INSERT: incremental closure expansion
15
+ # task_dag_edge_delete_apply -- AFTER DELETE: path_count decrement + min_depth repair
16
+ # task_dag_node_insert -- AFTER INSERT on tasks: self-row
17
+ # task_dag_node_update -- BEFORE UPDATE on tasks: scope-change guard (scoped only)
18
+ # task_dag_node_delete -- BEFORE DELETE on tasks: drop edges through triggers
19
+ # task_dag_rebuild_paths() -- full closure rebuild from edges
20
+ # task_dag_validate_paths() -- closure vs recursive-CTE truth diff
21
+ #
22
+ # Node identity is an ordered column list (Configuration#node_pk_columns):
23
+ # single-key models get the classic parent_id / child_id / ancestor_id /
24
+ # descendant_id columns, composite keys get one column per key column
25
+ # (parent_org_id, parent_serial, ...). All joins and comparisons are
26
+ # generated as per-column AND lists, so both shapes share one code path.
27
+ #
28
+ # Integrity violations RAISE with the DagMe::SQLSTATE_* codes, so the Ruby
29
+ # layer translates them without depending on message wording.
30
+ #
31
+ # With scope columns, edges may only connect nodes whose scope values match;
32
+ # the trigger stamps the node's scope onto edge and closure rows, so raw SQL
33
+ # writers cannot cross tenants either.
34
+ #
35
+ # The paths table and its triggers are skipped for maintain: :recursive_cte;
36
+ # cycle rejection then uses a recursive CTE in the BEFORE INSERT trigger.
37
+ class DDL
38
+ class << self
39
+ # Installs / removes the SQL objects for every dag the model declares.
40
+ def install!(model)
41
+ model.dag_configs.each_value { |config| new(config).install! }
42
+ end
43
+
44
+ def uninstall!(model)
45
+ model.dag_configs.each_value { |config| new(config).uninstall! }
46
+ end
47
+ end
48
+
49
+ attr_reader :config
50
+
51
+ def initialize(config)
52
+ @config = config
53
+ end
54
+
55
+ def install!
56
+ execute_all(install_sql)
57
+ end
58
+
59
+ def uninstall!
60
+ execute_all(uninstall_sql)
61
+ end
62
+
63
+ def install_sql
64
+ statements = [edges_table_sql]
65
+ if config.closure?
66
+ statements << paths_table_sql
67
+ statements.concat(closure_function_sql)
68
+ statements.concat(closure_trigger_sql)
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
76
+ statements
77
+ end
78
+
79
+ def uninstall_sql
80
+ p = config.prefix
81
+ [
82
+ "DROP TRIGGER IF EXISTS #{p}_node_insert ON #{config.node_table};",
83
+ "DROP TRIGGER IF EXISTS #{p}_node_update ON #{config.node_table};",
84
+ "DROP TRIGGER IF EXISTS #{p}_node_delete ON #{config.node_table};",
85
+ "DROP TABLE IF EXISTS #{config.paths_table};",
86
+ "DROP TABLE IF EXISTS #{config.edge_table};",
87
+ "DROP FUNCTION IF EXISTS #{p}_lock(text);",
88
+ "DROP FUNCTION IF EXISTS #{p}_edge_insert_check();",
89
+ "DROP FUNCTION IF EXISTS #{p}_edge_insert_apply();",
90
+ "DROP FUNCTION IF EXISTS #{p}_edge_delete_apply();",
91
+ "DROP FUNCTION IF EXISTS #{p}_node_insert();",
92
+ "DROP FUNCTION IF EXISTS #{p}_node_update();",
93
+ "DROP FUNCTION IF EXISTS #{p}_node_delete();",
94
+ "DROP FUNCTION IF EXISTS #{p}_rebuild_paths();",
95
+ "DROP FUNCTION IF EXISTS #{p}_validate_paths();"
96
+ ]
97
+ end
98
+
99
+ private
100
+
101
+ def execute_all(statements)
102
+ config.model.connection_pool.with_connection do |conn|
103
+ statements.each { |sql| conn.execute(sql) }
104
+ end
105
+ end
106
+
107
+ def pk_cols
108
+ config.node_pk_columns
109
+ end
110
+
111
+ def parent_cols
112
+ config.edge_parent_columns
113
+ end
114
+
115
+ def child_cols
116
+ config.edge_child_columns
117
+ end
118
+
119
+ def anc_cols
120
+ config.paths_ancestor_columns
121
+ end
122
+
123
+ def desc_cols
124
+ config.paths_descendant_columns
125
+ end
126
+
127
+ # "a, b" or "q.a, q.b"
128
+ def list(cols, qualifier = nil)
129
+ cols.map { |c| qualifier ? "#{qualifier}.#{c}" : c.to_s }.join(', ')
130
+ end
131
+
132
+ # "(a, b)" - row constructor; parenthesized scalar for a single column.
133
+ def tuple(cols, qualifier = nil)
134
+ "(#{list(cols, qualifier)})"
135
+ end
136
+
137
+ # "l.a = r.x AND l.b = r.y" (qualifiers optional on either side)
138
+ def eq(left_cols, right_cols, left: nil, right: nil)
139
+ left_cols.zip(right_cols).map do |l, r|
140
+ "#{"#{left}." if left}#{l} = #{"#{right}." if right}#{r}"
141
+ end.join(' AND ')
142
+ end
143
+
144
+ # Column definitions typed after the node's pk columns.
145
+ def col_defs(cols, not_null: true)
146
+ cols.zip(pk_cols).map { |c, pk| "#{c} #{config.node_pk_type(pk)}#{' NOT NULL' if not_null}" }
147
+ end
148
+
149
+ # RAISE format for a node reference: '%' or '(%, %)'.
150
+ def node_fmt
151
+ pk_cols.length == 1 ? '%' : "(#{pk_cols.map { '%' }.join(', ')})"
152
+ end
153
+
154
+ def scoped?
155
+ config.scope_columns.any?
156
+ end
157
+
158
+ # ", account_id bigint, region text" (leading comma) or ""
159
+ def scope_column_defs
160
+ config.scope_columns.map { |c| ",\n #{c} #{config.scope_column_type(c)}" }.join
161
+ end
162
+
163
+ # ", account_id" / ", NEW.account_id" (leading comma) or ""
164
+ def scope_column_list(qualifier = nil)
165
+ config.scope_columns.map { |c| ", #{"#{qualifier}." if qualifier}#{c}" }.join
166
+ end
167
+
168
+ # Advisory-lock key for a row reference: COALESCE(row.account_id::text, '') || ':' || ...
169
+ def scope_key_expr(row)
170
+ return "''" unless scoped?
171
+
172
+ config.scope_columns.map { |c| "COALESCE(#{row}.#{c}::text, '')" }.join(" || ':' || ")
173
+ end
174
+
175
+ def scope_distinct_expr(left, right)
176
+ config.scope_columns.map { |c| "#{left}.#{c} IS DISTINCT FROM #{right}.#{c}" }.join(' OR ')
177
+ end
178
+
179
+ def edges_table_sql
180
+ defs = (col_defs(parent_cols) + col_defs(child_cols)).map { |d| " #{d}," }.join("\n")
181
+ <<~SQL
182
+ CREATE TABLE #{config.edge_table} (
183
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
184
+ #{defs}
185
+ created_at timestamptz NOT NULL DEFAULT now()#{scope_column_defs},
186
+ FOREIGN KEY #{tuple(parent_cols)} REFERENCES #{config.node_table} #{tuple(pk_cols)} ON DELETE CASCADE,
187
+ FOREIGN KEY #{tuple(child_cols)} REFERENCES #{config.node_table} #{tuple(pk_cols)} ON DELETE CASCADE,
188
+ UNIQUE (#{list(parent_cols)}, #{list(child_cols)}),
189
+ CHECK (#{tuple(parent_cols)} <> #{tuple(child_cols)})
190
+ );
191
+ CREATE INDEX ON #{config.edge_table} (#{list(child_cols)}, #{list(parent_cols)});
192
+ SQL
193
+ end
194
+
195
+ def paths_table_sql
196
+ defs = (col_defs(anc_cols) + col_defs(desc_cols)).map { |d| " #{d}," }.join("\n")
197
+ <<~SQL
198
+ CREATE TABLE #{config.paths_table} (
199
+ #{defs}
200
+ min_depth integer NOT NULL,
201
+ path_count numeric NOT NULL#{scope_column_defs},
202
+ FOREIGN KEY #{tuple(anc_cols)} REFERENCES #{config.node_table} #{tuple(pk_cols)} ON DELETE CASCADE,
203
+ FOREIGN KEY #{tuple(desc_cols)} REFERENCES #{config.node_table} #{tuple(pk_cols)} ON DELETE CASCADE,
204
+ PRIMARY KEY (#{list(anc_cols)}, #{list(desc_cols)})
205
+ );
206
+ CREATE INDEX ON #{config.paths_table} (#{list(desc_cols)}, #{list(anc_cols)});
207
+ SQL
208
+ end
209
+
210
+ # Every write path funnels through this. Lock-then-recheck needs a fresh
211
+ # snapshot after the lock wait, so anything above READ COMMITTED is refused.
212
+ def lock_function_sql
213
+ <<~SQL
214
+ CREATE OR REPLACE FUNCTION #{config.prefix}_lock(scope_key text) RETURNS void
215
+ LANGUAGE plpgsql AS $$
216
+ BEGIN
217
+ IF current_setting('transaction_isolation') NOT IN ('read committed', 'read uncommitted') THEN
218
+ RAISE EXCEPTION 'dag_me: writes require READ COMMITTED isolation, got %',
219
+ current_setting('transaction_isolation')
220
+ USING ERRCODE = '#{SQLSTATE_ISOLATION}';
221
+ END IF;
222
+ PERFORM pg_advisory_xact_lock(hashtextextended('dag_me:#{config.edge_table}:' || scope_key, 0));
223
+ END;
224
+ $$;
225
+ SQL
226
+ end
227
+
228
+ # Scope preamble for the BEFORE INSERT edge check: fetch both node rows,
229
+ # reject cross-scope edges, stamp NEW, and lock the scope. Unscoped graphs
230
+ # just lock the single '' key.
231
+ def edge_check_preamble
232
+ p = config.prefix
233
+ return " PERFORM #{p}_lock('');" unless scoped?
234
+
235
+ stamps = config.scope_columns.map { |c| " NEW.#{c} := parent_row.#{c};" }.join("\n")
236
+ # FOR SHARE pins both node rows so a concurrent scope UPDATE cannot race
237
+ # this edge into a cross-tenant graph.
238
+ <<~SQL.chomp
239
+ SELECT * INTO parent_row FROM #{config.node_table} WHERE #{eq(pk_cols, parent_cols, right: 'NEW')} FOR SHARE;
240
+ SELECT * INTO child_row FROM #{config.node_table} WHERE #{eq(pk_cols, child_cols, right: 'NEW')} FOR SHARE;
241
+ IF #{scope_distinct_expr('parent_row', 'child_row')} THEN
242
+ RAISE EXCEPTION 'dag_me: edge #{node_fmt} -> #{node_fmt} crosses scope', #{list(parent_cols, 'NEW')}, #{list(child_cols, 'NEW')}
243
+ USING ERRCODE = '#{SQLSTATE_CROSS_SCOPE}';
244
+ END IF;
245
+ #{stamps}
246
+ PERFORM #{p}_lock(#{scope_key_expr('parent_row')});
247
+ SQL
248
+ end
249
+
250
+ def edge_check_declarations
251
+ return '' unless scoped?
252
+
253
+ <<~SQL.chomp
254
+ DECLARE
255
+ parent_row #{config.node_table}%ROWTYPE;
256
+ child_row #{config.node_table}%ROWTYPE;
257
+ SQL
258
+ end
259
+
260
+ def cycle_raise_sql
261
+ <<~SQL.chomp
262
+ RAISE EXCEPTION 'dag_me: edge #{node_fmt} -> #{node_fmt} would create a cycle', #{list(parent_cols, 'NEW')}, #{list(child_cols, 'NEW')}
263
+ USING ERRCODE = '#{SQLSTATE_CYCLE}';
264
+ SQL
265
+ end
266
+
267
+ def closure_function_sql
268
+ functions = [
269
+ lock_function_sql,
270
+ closure_edge_insert_check_sql,
271
+ edge_insert_apply_sql,
272
+ edge_delete_apply_sql,
273
+ node_insert_function_sql,
274
+ node_delete_function_sql
275
+ ]
276
+ functions << node_update_function_sql if scoped?
277
+ functions
278
+ end
279
+
280
+ def closure_edge_insert_check_sql
281
+ <<~SQL
282
+ CREATE OR REPLACE FUNCTION #{config.prefix}_edge_insert_check() RETURNS trigger
283
+ LANGUAGE plpgsql AS $$
284
+ #{edge_check_declarations}
285
+ BEGIN
286
+ #{edge_check_preamble}
287
+ IF EXISTS (
288
+ SELECT 1 FROM #{config.paths_table}
289
+ WHERE #{eq(anc_cols, child_cols, right: 'NEW')}
290
+ AND #{eq(desc_cols, parent_cols, right: 'NEW')}
291
+ ) THEN
292
+ #{cycle_raise_sql}
293
+ END IF;
294
+ RETURN NEW;
295
+ END;
296
+ $$;
297
+ SQL
298
+ end
299
+
300
+ def edge_insert_apply_sql
301
+ paths = config.paths_table
302
+ <<~SQL
303
+ CREATE OR REPLACE FUNCTION #{config.prefix}_edge_insert_apply() RETURNS trigger
304
+ LANGUAGE plpgsql AS $$
305
+ BEGIN
306
+ -- ancestors-incl-self of parent x descendants-incl-self of child
307
+ INSERT INTO #{paths} (#{list(anc_cols)}, #{list(desc_cols)}, min_depth, path_count#{scope_column_list})
308
+ SELECT #{list(anc_cols, 'a')}, #{list(desc_cols, 'd')},
309
+ a.min_depth + 1 + d.min_depth,
310
+ a.path_count * d.path_count#{scope_column_list('NEW')}
311
+ FROM #{paths} a
312
+ JOIN #{paths} d ON #{eq(anc_cols, child_cols, left: 'd', right: 'NEW')}
313
+ WHERE #{eq(desc_cols, parent_cols, left: 'a', right: 'NEW')}
314
+ ON CONFLICT (#{list(anc_cols)}, #{list(desc_cols)}) DO UPDATE
315
+ SET path_count = #{paths}.path_count + EXCLUDED.path_count,
316
+ min_depth = LEAST(#{paths}.min_depth, EXCLUDED.min_depth);
317
+ RETURN NULL;
318
+ END;
319
+ $$;
320
+ SQL
321
+ end
322
+
323
+ def edge_delete_apply_sql
324
+ p = config.prefix
325
+ edges = config.edge_table
326
+ paths = config.paths_table
327
+ x_cols = pk_cols.map { |c| "x_#{c}" }
328
+ y_cols = pk_cols.map { |c| "y_#{c}" }
329
+ rect_defs = (col_defs(x_cols) + col_defs(y_cols)).map { |d| " #{d}," }.join("\n")
330
+ <<~SQL
331
+ CREATE OR REPLACE FUNCTION #{p}_edge_delete_apply() RETURNS trigger
332
+ LANGUAGE plpgsql AS $$
333
+ BEGIN
334
+ PERFORM #{p}_lock(#{scope_key_expr('OLD')});
335
+
336
+ -- The affected rectangle: every pair (x, y) with x reaching OLD's parent
337
+ -- and OLD's child reaching y lost `removed` paths through this edge.
338
+ -- Multipliers cannot themselves traverse the deleted edge (that would
339
+ -- imply a cycle), so pre-decrement closure values are exact here.
340
+ CREATE TEMP TABLE IF NOT EXISTS #{p}_delete_rect (
341
+ #{rect_defs}
342
+ removed numeric NOT NULL,
343
+ via_depth integer NOT NULL,
344
+ PRIMARY KEY (#{list(x_cols)}, #{list(y_cols)})
345
+ ) ON COMMIT DROP;
346
+ DELETE FROM #{p}_delete_rect;
347
+
348
+ INSERT INTO #{p}_delete_rect (#{list(x_cols)}, #{list(y_cols)}, removed, via_depth)
349
+ SELECT #{list(anc_cols, 'a')}, #{list(desc_cols, 'd')},
350
+ a.path_count * d.path_count,
351
+ a.min_depth + 1 + d.min_depth
352
+ FROM #{paths} a
353
+ JOIN #{paths} d ON #{eq(anc_cols, child_cols, left: 'd', right: 'OLD')}
354
+ WHERE #{eq(desc_cols, parent_cols, left: 'a', right: 'OLD')};
355
+
356
+ UPDATE #{paths} p
357
+ SET path_count = p.path_count - r.removed
358
+ FROM #{p}_delete_rect r
359
+ WHERE #{eq(anc_cols, x_cols, left: 'p', right: 'r')}
360
+ AND #{eq(desc_cols, y_cols, left: 'p', right: 'r')};
361
+
362
+ DELETE FROM #{paths} p
363
+ USING #{p}_delete_rect r
364
+ WHERE #{eq(anc_cols, x_cols, left: 'p', right: 'r')}
365
+ AND #{eq(desc_cols, y_cols, left: 'p', right: 'r')}
366
+ AND p.path_count <= 0;
367
+
368
+ -- min_depth repair: surviving rectangle pairs may have lost their
369
+ -- shortest path. Iterate the recurrence
370
+ -- min_depth(x, y) = min(1 + min_depth(c, y)) over edges x -> c reaching y
371
+ -- until fixpoint; converges in at most longest-affected-chain steps.
372
+ LOOP
373
+ UPDATE #{paths} p
374
+ SET min_depth = fix.new_md
375
+ FROM (
376
+ SELECT #{list(anc_cols, 's')}, #{list(desc_cols, 's')},
377
+ (SELECT MIN(1 + cp.min_depth)
378
+ FROM #{edges} e
379
+ JOIN #{paths} cp
380
+ ON #{eq(anc_cols, child_cols, left: 'cp', right: 'e')}
381
+ AND #{eq(desc_cols, desc_cols, left: 'cp', right: 's')}
382
+ WHERE #{eq(parent_cols, anc_cols, left: 'e', right: 's')}) AS new_md
383
+ FROM #{paths} s
384
+ JOIN #{p}_delete_rect r
385
+ ON #{eq(x_cols, anc_cols, left: 'r', right: 's')}
386
+ AND #{eq(y_cols, desc_cols, left: 'r', right: 's')}
387
+ WHERE #{tuple(anc_cols, 's')} <> #{tuple(desc_cols, 's')}
388
+ ) fix
389
+ WHERE #{eq(anc_cols, anc_cols, left: 'p', right: 'fix')}
390
+ AND #{eq(desc_cols, desc_cols, left: 'p', right: 'fix')}
391
+ AND fix.new_md IS NOT NULL
392
+ AND fix.new_md <> p.min_depth;
393
+ EXIT WHEN NOT FOUND;
394
+ END LOOP;
395
+
396
+ RETURN NULL;
397
+ END;
398
+ $$;
399
+ SQL
400
+ end
401
+
402
+ def node_insert_function_sql
403
+ <<~SQL
404
+ CREATE OR REPLACE FUNCTION #{config.prefix}_node_insert() RETURNS trigger
405
+ LANGUAGE plpgsql AS $$
406
+ BEGIN
407
+ INSERT INTO #{config.paths_table} (#{list(anc_cols)}, #{list(desc_cols)}, min_depth, path_count#{scope_column_list})
408
+ VALUES (#{list(pk_cols, 'NEW')}, #{list(pk_cols, 'NEW')}, 0, 1#{scope_column_list('NEW')});
409
+ RETURN NULL;
410
+ END;
411
+ $$;
412
+ SQL
413
+ end
414
+
415
+ def node_delete_function_sql
416
+ <<~SQL
417
+ CREATE OR REPLACE FUNCTION #{config.prefix}_node_delete() RETURNS trigger
418
+ LANGUAGE plpgsql AS $$
419
+ BEGIN
420
+ PERFORM #{config.prefix}_lock(#{scope_key_expr('OLD')});
421
+ -- Remove edges through their triggers so the closure shrinks
422
+ -- incrementally instead of relying on FK-cascade ordering.
423
+ DELETE FROM #{config.edge_table}
424
+ WHERE (#{eq(parent_cols, pk_cols, right: 'OLD')}) OR (#{eq(child_cols, pk_cols, right: 'OLD')});
425
+ DELETE FROM #{config.paths_table}
426
+ WHERE #{eq(anc_cols, pk_cols, right: 'OLD')} AND #{eq(desc_cols, pk_cols, right: 'OLD')};
427
+ RETURN OLD;
428
+ END;
429
+ $$;
430
+ SQL
431
+ end
432
+
433
+ # Guards against re-tenanting a connected node: the closure never spans
434
+ # scopes, so a scope change is only legal on an isolated node.
435
+ def node_update_function_sql
436
+ restamp = if config.closure?
437
+ updates = config.scope_columns.map { |c| "#{c} = NEW.#{c}" }.join(', ')
438
+ "UPDATE #{config.paths_table} SET #{updates} " \
439
+ "WHERE #{eq(anc_cols, pk_cols, right: 'OLD')} AND #{eq(desc_cols, pk_cols, right: 'OLD')};"
440
+ else
441
+ '-- edges only: nothing materialized to restamp'
442
+ end
443
+ <<~SQL
444
+ CREATE OR REPLACE FUNCTION #{config.prefix}_node_update() RETURNS trigger
445
+ LANGUAGE plpgsql AS $$
446
+ BEGIN
447
+ IF #{scope_distinct_expr('NEW', 'OLD')} THEN
448
+ IF EXISTS (
449
+ SELECT 1 FROM #{config.edge_table}
450
+ WHERE (#{eq(parent_cols, pk_cols, right: 'OLD')}) OR (#{eq(child_cols, pk_cols, right: 'OLD')})
451
+ ) THEN
452
+ RAISE EXCEPTION 'dag_me: cannot change scope of node #{node_fmt} while it has edges', #{list(pk_cols, 'OLD')}
453
+ USING ERRCODE = '#{SQLSTATE_SCOPE_CHANGE}';
454
+ END IF;
455
+ #{restamp}
456
+ END IF;
457
+ RETURN NEW;
458
+ END;
459
+ $$;
460
+ SQL
461
+ end
462
+
463
+ def closure_trigger_sql
464
+ p = config.prefix
465
+ triggers = [
466
+ trigger_sql("#{p}_edge_insert_check", 'BEFORE INSERT', config.edge_table),
467
+ trigger_sql("#{p}_edge_insert_apply", 'AFTER INSERT', config.edge_table),
468
+ trigger_sql("#{p}_edge_delete_apply", 'AFTER DELETE', config.edge_table),
469
+ trigger_sql("#{p}_node_insert", 'AFTER INSERT', config.node_table),
470
+ trigger_sql("#{p}_node_delete", 'BEFORE DELETE', config.node_table)
471
+ ]
472
+ triggers << trigger_sql("#{p}_node_update", 'BEFORE UPDATE', config.node_table) if scoped?
473
+ triggers
474
+ end
475
+
476
+ def trigger_sql(name, timing, table)
477
+ <<~SQL
478
+ CREATE TRIGGER #{name} #{timing} ON #{table}
479
+ FOR EACH ROW EXECUTE FUNCTION #{name}();
480
+ SQL
481
+ end
482
+
483
+ def backfill_self_rows_sql
484
+ <<~SQL
485
+ INSERT INTO #{config.paths_table} (#{list(anc_cols)}, #{list(desc_cols)}, min_depth, path_count#{scope_column_list})
486
+ SELECT #{list(pk_cols)}, #{list(pk_cols)}, 0, 1#{scope_column_list} FROM #{config.node_table}
487
+ ON CONFLICT DO NOTHING;
488
+ SQL
489
+ end
490
+
491
+ def truth_walk_sql
492
+ <<~SQL.chomp
493
+ WITH RECURSIVE walk(#{list(anc_cols)}, #{list(desc_cols)}, depth) AS (
494
+ SELECT #{list(parent_cols)}, #{list(child_cols)}, 1 FROM #{config.edge_table}
495
+ UNION ALL
496
+ SELECT #{list(anc_cols, 'w')}, #{list(child_cols, 'e')}, w.depth + 1
497
+ FROM walk w
498
+ JOIN #{config.edge_table} e ON #{eq(parent_cols, desc_cols, left: 'e', right: 'w')}
499
+ )
500
+ SQL
501
+ end
502
+
503
+ def rebuild_function_sql
504
+ scope_join = scoped? ? "JOIN #{config.node_table} n ON #{eq(pk_cols, anc_cols, left: 'n', right: 'walk')}" : ''
505
+ scope_group = config.scope_columns.map { |c| ", n.#{c}" }.join
506
+ <<~SQL
507
+ CREATE OR REPLACE FUNCTION #{config.prefix}_rebuild_paths() RETURNS void
508
+ LANGUAGE plpgsql AS $$
509
+ BEGIN
510
+ LOCK TABLE #{config.node_table}, #{config.edge_table} IN SHARE ROW EXCLUSIVE MODE;
511
+ DELETE FROM #{config.paths_table};
512
+
513
+ INSERT INTO #{config.paths_table} (#{list(anc_cols)}, #{list(desc_cols)}, min_depth, path_count#{scope_column_list})
514
+ SELECT #{list(pk_cols)}, #{list(pk_cols)}, 0, 1#{scope_column_list} FROM #{config.node_table};
515
+
516
+ #{truth_walk_sql}
517
+ INSERT INTO #{config.paths_table} (#{list(anc_cols)}, #{list(desc_cols)}, min_depth, path_count#{scope_column_list})
518
+ SELECT #{list(anc_cols, 'walk')}, #{list(desc_cols, 'walk')}, MIN(depth), COUNT(*)::numeric#{scope_group}
519
+ FROM walk #{scope_join}
520
+ GROUP BY #{list(anc_cols, 'walk')}, #{list(desc_cols, 'walk')}#{scope_group};
521
+ END;
522
+ $$;
523
+ SQL
524
+ end
525
+
526
+ def validate_function_sql
527
+ scope_join = scoped? ? "JOIN #{config.node_table} n ON #{eq(pk_cols, anc_cols, left: 'n', right: 'walk')}" : ''
528
+ scope_group = config.scope_columns.map { |c| ", n.#{c}" }.join
529
+ scope_mismatch = scoped? ? "OR #{scope_distinct_expr('s', 't')}" : ''
530
+ returns = (col_defs(anc_cols, not_null: false) + col_defs(desc_cols, not_null: false))
531
+ .map { |d| " #{d}," }.join("\n")
532
+ coalesced = (anc_cols + desc_cols).map { |c| "COALESCE(t.#{c}, s.#{c})" }.join(",\n ")
533
+ <<~SQL
534
+ CREATE OR REPLACE FUNCTION #{config.prefix}_validate_paths()
535
+ RETURNS TABLE(
536
+ #{returns}
537
+ stored_min_depth integer,
538
+ stored_path_count numeric,
539
+ true_min_depth integer,
540
+ true_path_count numeric
541
+ )
542
+ LANGUAGE sql AS $$
543
+ #{truth_walk_sql}, truth AS (
544
+ SELECT #{list(anc_cols, 'walk')}, #{list(desc_cols, 'walk')}, MIN(depth) AS min_depth, COUNT(*)::numeric AS path_count#{scope_group}
545
+ FROM walk #{scope_join}
546
+ GROUP BY #{list(anc_cols, 'walk')}, #{list(desc_cols, 'walk')}#{scope_group}
547
+ UNION ALL
548
+ SELECT #{list(pk_cols)}, #{list(pk_cols)}, 0, 1::numeric#{scope_column_list} FROM #{config.node_table}
549
+ )
550
+ SELECT #{coalesced},
551
+ s.min_depth, s.path_count,
552
+ t.min_depth, t.path_count
553
+ FROM truth t
554
+ FULL OUTER JOIN #{config.paths_table} s
555
+ ON #{eq(anc_cols, anc_cols, left: 's', right: 't')}
556
+ AND #{eq(desc_cols, desc_cols, left: 's', right: 't')}
557
+ WHERE t.#{anc_cols.first} IS NULL
558
+ OR s.#{anc_cols.first} IS NULL
559
+ OR s.min_depth <> t.min_depth
560
+ OR s.path_count <> t.path_count
561
+ #{scope_mismatch};
562
+ $$;
563
+ SQL
564
+ end
565
+
566
+ def cte_function_sql
567
+ functions = [lock_function_sql, cte_edge_insert_check_sql]
568
+ functions << node_update_function_sql if scoped?
569
+ functions
570
+ end
571
+
572
+ def cte_edge_insert_check_sql
573
+ <<~SQL
574
+ CREATE OR REPLACE FUNCTION #{config.prefix}_edge_insert_check() RETURNS trigger
575
+ LANGUAGE plpgsql AS $$
576
+ #{edge_check_declarations}
577
+ BEGIN
578
+ #{edge_check_preamble}
579
+ IF EXISTS (
580
+ WITH RECURSIVE walk(#{list(pk_cols)}) AS (
581
+ SELECT #{list(child_cols)} FROM #{config.edge_table} WHERE #{eq(parent_cols, child_cols, right: 'NEW')}
582
+ UNION
583
+ SELECT #{list(child_cols, 'e')} FROM #{config.edge_table} e JOIN walk w ON #{eq(parent_cols, pk_cols, left: 'e', right: 'w')}
584
+ )
585
+ SELECT 1 FROM walk WHERE #{eq(pk_cols, parent_cols, right: 'NEW')}
586
+ ) THEN
587
+ #{cycle_raise_sql}
588
+ END IF;
589
+ RETURN NEW;
590
+ END;
591
+ $$;
592
+ SQL
593
+ end
594
+
595
+ def cte_trigger_sql
596
+ p = config.prefix
597
+ triggers = [trigger_sql("#{p}_edge_insert_check", 'BEFORE INSERT', config.edge_table)]
598
+ triggers << trigger_sql("#{p}_node_update", 'BEFORE UPDATE', config.node_table) if scoped?
599
+ triggers
600
+ end
601
+ end
602
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DagMe
4
+ class Error < StandardError; end
5
+
6
+ # Raised when inserting an edge would make the graph cyclic. The rejection
7
+ # itself happens in the database trigger; this wraps the PG exception.
8
+ class CycleError < Error; end
9
+
10
+ # Raised when an edge would connect nodes in different scopes, or when a
11
+ # connected node's scope columns are changed.
12
+ class ScopeError < Error; end
13
+
14
+ # Raised when a write runs above READ COMMITTED: lock-then-recheck needs a
15
+ # fresh snapshot after the lock wait.
16
+ class IsolationError < Error; end
17
+
18
+ # Raised by Graph#validate! when the stored closure disagrees with the
19
+ # recursive-CTE truth. Carries the offending rows.
20
+ class CorruptionError < Error
21
+ attr_reader :discrepancies
22
+
23
+ def initialize(message, discrepancies = [])
24
+ super(message)
25
+ @discrepancies = discrepancies
26
+ end
27
+ end
28
+
29
+ # The triggers raise with these custom SQLSTATEs, so translation does not
30
+ # depend on message wording.
31
+ SQLSTATE_CYCLE = 'DGME1'
32
+ SQLSTATE_CROSS_SCOPE = 'DGME2'
33
+ SQLSTATE_SCOPE_CHANGE = 'DGME3'
34
+ SQLSTATE_ISOLATION = 'DGME4'
35
+
36
+ SQLSTATE_ERRORS = {
37
+ SQLSTATE_CYCLE => CycleError,
38
+ SQLSTATE_CROSS_SCOPE => ScopeError,
39
+ SQLSTATE_SCOPE_CHANGE => ScopeError,
40
+ SQLSTATE_ISOLATION => IsolationError
41
+ }.freeze
42
+
43
+ module_function
44
+
45
+ def translate_errors
46
+ yield
47
+ rescue ActiveRecord::StatementInvalid => e
48
+ error_class = SQLSTATE_ERRORS[sqlstate(e)]
49
+ raise error_class, e.message if error_class
50
+
51
+ raise
52
+ end
53
+
54
+ def sqlstate(error)
55
+ cause = error.cause
56
+ return unless cause.respond_to?(:result) && cause.result
57
+
58
+ cause.result.error_field(PG::PG_DIAG_SQLSTATE)
59
+ end
60
+ end