dag_me 0.1.0 → 0.2.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/README.md +14 -0
- data/lib/dag_me/configuration.rb +30 -4
- data/lib/dag_me/ddl.rb +39 -41
- data/lib/dag_me/graph.rb +2 -2
- data/lib/dag_me/task_helpers.rb +10 -2
- data/lib/dag_me/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 700c8b25bcaadebf2b1d273e1d640585b0941a1b755e4e68b8b4cde46b4dcfc7
|
|
4
|
+
data.tar.gz: 17f8e047810f1fdcefedbf0717d34fab12482bdbc356a1391a999b31bf5988b9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 40c39bced539dbdab504c0f271d86dfb40efecdc28dde66f63077effd077fee53b303eb0e0e765ec32db000e9bfdaf59a415cd9db60f50ae98edf68545c50266
|
|
7
|
+
data.tar.gz: 047dd04b53fb61c367fca26038652e1bc4ca42984849565aec2ec45cf4917f94a8917cf829eec3bc36f4459c1941cd80f1d4209196afb793269343b962f8f6de
|
data/README.md
CHANGED
|
@@ -136,6 +136,20 @@ end
|
|
|
136
136
|
`:recursive_cte` skips the closure table entirely - good for small graphs, high mutation
|
|
137
137
|
rates, and as the truth oracle. Cycle rejection stays in-database either way.
|
|
138
138
|
|
|
139
|
+
## Schema-qualified tables
|
|
140
|
+
|
|
141
|
+
Node tables living in a named PostgreSQL schema work out of the box:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
class Station < ApplicationRecord
|
|
145
|
+
self.table_name = 'orbital.stations'
|
|
146
|
+
dag_me
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Generated tables (`orbital.station_dag_edges`, `orbital.station_dag_paths`) and
|
|
151
|
+
functions land in the node table's schema; trigger names stay plain identifiers.
|
|
152
|
+
|
|
139
153
|
## Multi-tenancy
|
|
140
154
|
|
|
141
155
|
```ruby
|
data/lib/dag_me/configuration.rb
CHANGED
|
@@ -4,7 +4,7 @@ module DagMe
|
|
|
4
4
|
class Configuration
|
|
5
5
|
MAINTAIN_MODES = %i[postgresql_closure recursive_cte].freeze
|
|
6
6
|
|
|
7
|
-
attr_reader :model, :name, :maintain, :prefix, :edge_table, :paths_table, :scope_columns
|
|
7
|
+
attr_reader :model, :name, :maintain, :prefix, :schema, :edge_table, :paths_table, :scope_columns
|
|
8
8
|
|
|
9
9
|
def initialize(model:, name: nil, maintain: :postgresql_closure, scope: nil, edge_table: nil, paths_table: nil)
|
|
10
10
|
unless MAINTAIN_MODES.include?(maintain)
|
|
@@ -15,9 +15,13 @@ module DagMe
|
|
|
15
15
|
@name = name&.to_sym
|
|
16
16
|
@maintain = maintain
|
|
17
17
|
@scope_columns = Array(scope).map(&:to_sym).freeze
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
# Schema-qualified node tables ("ai.skills") keep the prefix a plain
|
|
19
|
+
# identifier - trigger and temp-table names cannot carry a schema - and
|
|
20
|
+
# place generated tables and functions in the node table's schema.
|
|
21
|
+
@schema, base_table = split_schema(model.table_name)
|
|
22
|
+
@prefix = [base_table.singularize, @name, 'dag'].compact.join('_')
|
|
23
|
+
@edge_table = edge_table || qualify("#{prefix}_edges")
|
|
24
|
+
@paths_table = paths_table || qualify("#{prefix}_paths")
|
|
21
25
|
# Ivar peek keeps class load DB-free; composite keys must be declared
|
|
22
26
|
# before the macro. Array === because AR seeds a BasicObject sentinel.
|
|
23
27
|
@composite_pk = model.instance_variable_defined?(:@primary_key) &&
|
|
@@ -28,6 +32,22 @@ module DagMe
|
|
|
28
32
|
maintain == :postgresql_closure
|
|
29
33
|
end
|
|
30
34
|
|
|
35
|
+
# "skill_dag_edges" -> "ai.skill_dag_edges" when the node table lives in a
|
|
36
|
+
# named schema; identity otherwise.
|
|
37
|
+
def qualify(identifier)
|
|
38
|
+
schema ? "#{schema}.#{identifier}" : identifier
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Schema-qualified reference for a generated function ("ai.skill_dag_lock").
|
|
42
|
+
def function_ref(suffix)
|
|
43
|
+
qualify("#{prefix}_#{suffix}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Trigger names are plain identifiers; placement comes from ON <table>.
|
|
47
|
+
def trigger_name(suffix)
|
|
48
|
+
"#{prefix}_#{suffix}"
|
|
49
|
+
end
|
|
50
|
+
|
|
31
51
|
# The unnamed dag from a bare `dag_me` call. Named dags come from
|
|
32
52
|
# `dag_me :power` and get name-prefixed tables, constants, and
|
|
33
53
|
# associations so several graphs can coexist on one model.
|
|
@@ -117,6 +137,12 @@ module DagMe
|
|
|
117
137
|
|
|
118
138
|
private
|
|
119
139
|
|
|
140
|
+
def split_schema(table_name)
|
|
141
|
+
return table_name.split('.', 2) if table_name.include?('.')
|
|
142
|
+
|
|
143
|
+
[nil, table_name]
|
|
144
|
+
end
|
|
145
|
+
|
|
120
146
|
def role_columns(role)
|
|
121
147
|
return ["#{role}_id"].freeze unless composite_pk?
|
|
122
148
|
|
data/lib/dag_me/ddl.rb
CHANGED
|
@@ -77,22 +77,21 @@ module DagMe
|
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
def uninstall_sql
|
|
80
|
-
p = config.prefix
|
|
81
80
|
[
|
|
82
|
-
"DROP TRIGGER IF EXISTS #{
|
|
83
|
-
"DROP TRIGGER IF EXISTS #{
|
|
84
|
-
"DROP TRIGGER IF EXISTS #{
|
|
81
|
+
"DROP TRIGGER IF EXISTS #{config.trigger_name('node_insert')} ON #{config.node_table};",
|
|
82
|
+
"DROP TRIGGER IF EXISTS #{config.trigger_name('node_update')} ON #{config.node_table};",
|
|
83
|
+
"DROP TRIGGER IF EXISTS #{config.trigger_name('node_delete')} ON #{config.node_table};",
|
|
85
84
|
"DROP TABLE IF EXISTS #{config.paths_table};",
|
|
86
85
|
"DROP TABLE IF EXISTS #{config.edge_table};",
|
|
87
|
-
"DROP FUNCTION IF EXISTS #{
|
|
88
|
-
"DROP FUNCTION IF EXISTS #{
|
|
89
|
-
"DROP FUNCTION IF EXISTS #{
|
|
90
|
-
"DROP FUNCTION IF EXISTS #{
|
|
91
|
-
"DROP FUNCTION IF EXISTS #{
|
|
92
|
-
"DROP FUNCTION IF EXISTS #{
|
|
93
|
-
"DROP FUNCTION IF EXISTS #{
|
|
94
|
-
"DROP FUNCTION IF EXISTS #{
|
|
95
|
-
"DROP FUNCTION IF EXISTS #{
|
|
86
|
+
"DROP FUNCTION IF EXISTS #{config.function_ref('lock')}(text);",
|
|
87
|
+
"DROP FUNCTION IF EXISTS #{config.function_ref('edge_insert_check')}();",
|
|
88
|
+
"DROP FUNCTION IF EXISTS #{config.function_ref('edge_insert_apply')}();",
|
|
89
|
+
"DROP FUNCTION IF EXISTS #{config.function_ref('edge_delete_apply')}();",
|
|
90
|
+
"DROP FUNCTION IF EXISTS #{config.function_ref('node_insert')}();",
|
|
91
|
+
"DROP FUNCTION IF EXISTS #{config.function_ref('node_update')}();",
|
|
92
|
+
"DROP FUNCTION IF EXISTS #{config.function_ref('node_delete')}();",
|
|
93
|
+
"DROP FUNCTION IF EXISTS #{config.function_ref('rebuild_paths')}();",
|
|
94
|
+
"DROP FUNCTION IF EXISTS #{config.function_ref('validate_paths')}();"
|
|
96
95
|
]
|
|
97
96
|
end
|
|
98
97
|
|
|
@@ -211,7 +210,7 @@ module DagMe
|
|
|
211
210
|
# snapshot after the lock wait, so anything above READ COMMITTED is refused.
|
|
212
211
|
def lock_function_sql
|
|
213
212
|
<<~SQL
|
|
214
|
-
CREATE OR REPLACE FUNCTION #{config.
|
|
213
|
+
CREATE OR REPLACE FUNCTION #{config.function_ref('lock')}(scope_key text) RETURNS void
|
|
215
214
|
LANGUAGE plpgsql AS $$
|
|
216
215
|
BEGIN
|
|
217
216
|
IF current_setting('transaction_isolation') NOT IN ('read committed', 'read uncommitted') THEN
|
|
@@ -229,8 +228,7 @@ module DagMe
|
|
|
229
228
|
# reject cross-scope edges, stamp NEW, and lock the scope. Unscoped graphs
|
|
230
229
|
# just lock the single '' key.
|
|
231
230
|
def edge_check_preamble
|
|
232
|
-
|
|
233
|
-
return " PERFORM #{p}_lock('');" unless scoped?
|
|
231
|
+
return " PERFORM #{config.function_ref('lock')}('');" unless scoped?
|
|
234
232
|
|
|
235
233
|
stamps = config.scope_columns.map { |c| " NEW.#{c} := parent_row.#{c};" }.join("\n")
|
|
236
234
|
# FOR SHARE pins both node rows so a concurrent scope UPDATE cannot race
|
|
@@ -243,7 +241,7 @@ module DagMe
|
|
|
243
241
|
USING ERRCODE = '#{SQLSTATE_CROSS_SCOPE}';
|
|
244
242
|
END IF;
|
|
245
243
|
#{stamps}
|
|
246
|
-
PERFORM #{
|
|
244
|
+
PERFORM #{config.function_ref('lock')}(#{scope_key_expr('parent_row')});
|
|
247
245
|
SQL
|
|
248
246
|
end
|
|
249
247
|
|
|
@@ -279,7 +277,7 @@ module DagMe
|
|
|
279
277
|
|
|
280
278
|
def closure_edge_insert_check_sql
|
|
281
279
|
<<~SQL
|
|
282
|
-
CREATE OR REPLACE FUNCTION #{config.
|
|
280
|
+
CREATE OR REPLACE FUNCTION #{config.function_ref('edge_insert_check')}() RETURNS trigger
|
|
283
281
|
LANGUAGE plpgsql AS $$
|
|
284
282
|
#{edge_check_declarations}
|
|
285
283
|
BEGIN
|
|
@@ -300,7 +298,7 @@ module DagMe
|
|
|
300
298
|
def edge_insert_apply_sql
|
|
301
299
|
paths = config.paths_table
|
|
302
300
|
<<~SQL
|
|
303
|
-
CREATE OR REPLACE FUNCTION #{config.
|
|
301
|
+
CREATE OR REPLACE FUNCTION #{config.function_ref('edge_insert_apply')}() RETURNS trigger
|
|
304
302
|
LANGUAGE plpgsql AS $$
|
|
305
303
|
BEGIN
|
|
306
304
|
-- ancestors-incl-self of parent x descendants-incl-self of child
|
|
@@ -328,10 +326,10 @@ module DagMe
|
|
|
328
326
|
y_cols = pk_cols.map { |c| "y_#{c}" }
|
|
329
327
|
rect_defs = (col_defs(x_cols) + col_defs(y_cols)).map { |d| " #{d}," }.join("\n")
|
|
330
328
|
<<~SQL
|
|
331
|
-
CREATE OR REPLACE FUNCTION #{
|
|
329
|
+
CREATE OR REPLACE FUNCTION #{config.function_ref('edge_delete_apply')}() RETURNS trigger
|
|
332
330
|
LANGUAGE plpgsql AS $$
|
|
333
331
|
BEGIN
|
|
334
|
-
PERFORM #{
|
|
332
|
+
PERFORM #{config.function_ref('lock')}(#{scope_key_expr('OLD')});
|
|
335
333
|
|
|
336
334
|
-- The affected rectangle: every pair (x, y) with x reaching OLD's parent
|
|
337
335
|
-- and OLD's child reaching y lost `removed` paths through this edge.
|
|
@@ -401,7 +399,7 @@ module DagMe
|
|
|
401
399
|
|
|
402
400
|
def node_insert_function_sql
|
|
403
401
|
<<~SQL
|
|
404
|
-
CREATE OR REPLACE FUNCTION #{config.
|
|
402
|
+
CREATE OR REPLACE FUNCTION #{config.function_ref('node_insert')}() RETURNS trigger
|
|
405
403
|
LANGUAGE plpgsql AS $$
|
|
406
404
|
BEGIN
|
|
407
405
|
INSERT INTO #{config.paths_table} (#{list(anc_cols)}, #{list(desc_cols)}, min_depth, path_count#{scope_column_list})
|
|
@@ -414,10 +412,10 @@ module DagMe
|
|
|
414
412
|
|
|
415
413
|
def node_delete_function_sql
|
|
416
414
|
<<~SQL
|
|
417
|
-
CREATE OR REPLACE FUNCTION #{config.
|
|
415
|
+
CREATE OR REPLACE FUNCTION #{config.function_ref('node_delete')}() RETURNS trigger
|
|
418
416
|
LANGUAGE plpgsql AS $$
|
|
419
417
|
BEGIN
|
|
420
|
-
PERFORM #{config.
|
|
418
|
+
PERFORM #{config.function_ref('lock')}(#{scope_key_expr('OLD')});
|
|
421
419
|
-- Remove edges through their triggers so the closure shrinks
|
|
422
420
|
-- incrementally instead of relying on FK-cascade ordering.
|
|
423
421
|
DELETE FROM #{config.edge_table}
|
|
@@ -441,7 +439,7 @@ module DagMe
|
|
|
441
439
|
'-- edges only: nothing materialized to restamp'
|
|
442
440
|
end
|
|
443
441
|
<<~SQL
|
|
444
|
-
CREATE OR REPLACE FUNCTION #{config.
|
|
442
|
+
CREATE OR REPLACE FUNCTION #{config.function_ref('node_update')}() RETURNS trigger
|
|
445
443
|
LANGUAGE plpgsql AS $$
|
|
446
444
|
BEGIN
|
|
447
445
|
IF #{scope_distinct_expr('NEW', 'OLD')} THEN
|
|
@@ -461,22 +459,23 @@ module DagMe
|
|
|
461
459
|
end
|
|
462
460
|
|
|
463
461
|
def closure_trigger_sql
|
|
464
|
-
p = config.prefix
|
|
465
462
|
triggers = [
|
|
466
|
-
trigger_sql(
|
|
467
|
-
trigger_sql(
|
|
468
|
-
trigger_sql(
|
|
469
|
-
trigger_sql(
|
|
470
|
-
trigger_sql(
|
|
463
|
+
trigger_sql('edge_insert_check', 'BEFORE INSERT', config.edge_table),
|
|
464
|
+
trigger_sql('edge_insert_apply', 'AFTER INSERT', config.edge_table),
|
|
465
|
+
trigger_sql('edge_delete_apply', 'AFTER DELETE', config.edge_table),
|
|
466
|
+
trigger_sql('node_insert', 'AFTER INSERT', config.node_table),
|
|
467
|
+
trigger_sql('node_delete', 'BEFORE DELETE', config.node_table)
|
|
471
468
|
]
|
|
472
|
-
triggers << trigger_sql(
|
|
469
|
+
triggers << trigger_sql('node_update', 'BEFORE UPDATE', config.node_table) if scoped?
|
|
473
470
|
triggers
|
|
474
471
|
end
|
|
475
472
|
|
|
476
|
-
|
|
473
|
+
# Trigger names are plain identifiers; the executed function carries the
|
|
474
|
+
# schema qualification.
|
|
475
|
+
def trigger_sql(suffix, timing, table)
|
|
477
476
|
<<~SQL
|
|
478
|
-
CREATE TRIGGER #{
|
|
479
|
-
FOR EACH ROW EXECUTE FUNCTION #{
|
|
477
|
+
CREATE TRIGGER #{config.trigger_name(suffix)} #{timing} ON #{table}
|
|
478
|
+
FOR EACH ROW EXECUTE FUNCTION #{config.function_ref(suffix)}();
|
|
480
479
|
SQL
|
|
481
480
|
end
|
|
482
481
|
|
|
@@ -504,7 +503,7 @@ module DagMe
|
|
|
504
503
|
scope_join = scoped? ? "JOIN #{config.node_table} n ON #{eq(pk_cols, anc_cols, left: 'n', right: 'walk')}" : ''
|
|
505
504
|
scope_group = config.scope_columns.map { |c| ", n.#{c}" }.join
|
|
506
505
|
<<~SQL
|
|
507
|
-
CREATE OR REPLACE FUNCTION #{config.
|
|
506
|
+
CREATE OR REPLACE FUNCTION #{config.function_ref('rebuild_paths')}() RETURNS void
|
|
508
507
|
LANGUAGE plpgsql AS $$
|
|
509
508
|
BEGIN
|
|
510
509
|
LOCK TABLE #{config.node_table}, #{config.edge_table} IN SHARE ROW EXCLUSIVE MODE;
|
|
@@ -531,7 +530,7 @@ module DagMe
|
|
|
531
530
|
.map { |d| " #{d}," }.join("\n")
|
|
532
531
|
coalesced = (anc_cols + desc_cols).map { |c| "COALESCE(t.#{c}, s.#{c})" }.join(",\n ")
|
|
533
532
|
<<~SQL
|
|
534
|
-
CREATE OR REPLACE FUNCTION #{config.
|
|
533
|
+
CREATE OR REPLACE FUNCTION #{config.function_ref('validate_paths')}()
|
|
535
534
|
RETURNS TABLE(
|
|
536
535
|
#{returns}
|
|
537
536
|
stored_min_depth integer,
|
|
@@ -571,7 +570,7 @@ module DagMe
|
|
|
571
570
|
|
|
572
571
|
def cte_edge_insert_check_sql
|
|
573
572
|
<<~SQL
|
|
574
|
-
CREATE OR REPLACE FUNCTION #{config.
|
|
573
|
+
CREATE OR REPLACE FUNCTION #{config.function_ref('edge_insert_check')}() RETURNS trigger
|
|
575
574
|
LANGUAGE plpgsql AS $$
|
|
576
575
|
#{edge_check_declarations}
|
|
577
576
|
BEGIN
|
|
@@ -593,9 +592,8 @@ module DagMe
|
|
|
593
592
|
end
|
|
594
593
|
|
|
595
594
|
def cte_trigger_sql
|
|
596
|
-
|
|
597
|
-
triggers
|
|
598
|
-
triggers << trigger_sql("#{p}_node_update", 'BEFORE UPDATE', config.node_table) if scoped?
|
|
595
|
+
triggers = [trigger_sql('edge_insert_check', 'BEFORE INSERT', config.edge_table)]
|
|
596
|
+
triggers << trigger_sql('node_update', 'BEFORE UPDATE', config.node_table) if scoped?
|
|
599
597
|
triggers
|
|
600
598
|
end
|
|
601
599
|
end
|
data/lib/dag_me/graph.rb
CHANGED
|
@@ -51,7 +51,7 @@ module DagMe
|
|
|
51
51
|
return self unless config.closure?
|
|
52
52
|
|
|
53
53
|
model.connection_pool.with_connection do |conn|
|
|
54
|
-
conn.execute("SELECT #{conn.quote_table_name(
|
|
54
|
+
conn.execute("SELECT #{conn.quote_table_name(config.function_ref('rebuild_paths'))}();")
|
|
55
55
|
end
|
|
56
56
|
self
|
|
57
57
|
end
|
|
@@ -62,7 +62,7 @@ module DagMe
|
|
|
62
62
|
return [] unless config.closure?
|
|
63
63
|
|
|
64
64
|
model.connection_pool.with_connection do |conn|
|
|
65
|
-
conn.select_all("SELECT * FROM #{conn.quote_table_name(
|
|
65
|
+
conn.select_all("SELECT * FROM #{conn.quote_table_name(config.function_ref('validate_paths'))}();").to_a
|
|
66
66
|
end
|
|
67
67
|
end
|
|
68
68
|
|
data/lib/dag_me/task_helpers.rb
CHANGED
|
@@ -76,7 +76,7 @@ module DagMe
|
|
|
76
76
|
JOIN pg_class c ON c.oid = t.tgrelid
|
|
77
77
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
78
78
|
WHERE NOT t.tgisinternal
|
|
79
|
-
AND
|
|
79
|
+
AND #{namespace_filter(conn, config)}
|
|
80
80
|
AND t.tgname LIKE #{conn.quote("#{config.prefix}\\_%")}
|
|
81
81
|
SQL
|
|
82
82
|
(expected - installed).map { |name| bad("trigger #{name} missing") }
|
|
@@ -96,13 +96,21 @@ module DagMe
|
|
|
96
96
|
installed = conn.select_values(<<~SQL)
|
|
97
97
|
SELECT p.proname FROM pg_proc p
|
|
98
98
|
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
99
|
-
WHERE
|
|
99
|
+
WHERE #{namespace_filter(conn, config)}
|
|
100
100
|
AND p.proname LIKE #{conn.quote("#{config.prefix}\\_%")}
|
|
101
101
|
SQL
|
|
102
102
|
(expected - installed).map { |name| bad("function #{name} missing") }
|
|
103
103
|
.presence || [ok("functions (#{expected.length})")]
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
+
# Objects for schema-qualified node tables live in that schema regardless
|
|
107
|
+
# of search_path; unqualified ones resolve through it.
|
|
108
|
+
def namespace_filter(conn, config)
|
|
109
|
+
return "n.nspname = #{conn.quote(config.schema)}" if config.schema
|
|
110
|
+
|
|
111
|
+
'n.nspname = ANY (current_schemas(false))'
|
|
112
|
+
end
|
|
113
|
+
|
|
106
114
|
def closure_check(model, config)
|
|
107
115
|
return ok('closure: not materialized (recursive_cte)') unless config.closure?
|
|
108
116
|
return nil unless model.connection.table_exists?(config.paths_table)
|
data/lib/dag_me/version.rb
CHANGED