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 +7 -0
- data/MIT-LICENSE +19 -0
- data/README.md +346 -0
- data/lib/dag_me/adapters/base.rb +90 -0
- data/lib/dag_me/adapters/postgresql_closure.rb +59 -0
- data/lib/dag_me/adapters/recursive_cte.rb +68 -0
- data/lib/dag_me/configuration.rb +126 -0
- data/lib/dag_me/ddl.rb +602 -0
- data/lib/dag_me/errors.rb +60 -0
- data/lib/dag_me/graph.rb +84 -0
- data/lib/dag_me/macro.rb +31 -0
- data/lib/dag_me/model.rb +171 -0
- data/lib/dag_me/railtie.rb +15 -0
- data/lib/dag_me/railties/tasks.rake +27 -0
- data/lib/dag_me/task_helpers.rb +120 -0
- data/lib/dag_me/test_helper.rb +72 -0
- data/lib/dag_me/version.rb +5 -0
- data/lib/dag_me.rb +32 -0
- data/lib/generators/dag_me/migration_generator.rb +30 -0
- data/lib/generators/dag_me/templates/install_dag.rb.erb +11 -0
- metadata +93 -0
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
|