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/graph.rb
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DagMe
|
|
4
|
+
# Class-level facade for graph-wide operations, reached via `Model.dag`
|
|
5
|
+
# (default graph) or `Model.dag(:name)` (named graph):
|
|
6
|
+
#
|
|
7
|
+
# Task.dag.between(a, b)
|
|
8
|
+
# Task.dag.edges_among(relation)
|
|
9
|
+
# Relay.dag(:power).rebuild!
|
|
10
|
+
# Task.dag.valid?
|
|
11
|
+
# Task.dag.validate!
|
|
12
|
+
class Graph
|
|
13
|
+
attr_reader :model, :config
|
|
14
|
+
|
|
15
|
+
def initialize(model, config = nil)
|
|
16
|
+
@model = model
|
|
17
|
+
@config = config || model.dag_config
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def adapter
|
|
21
|
+
config.adapter
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Every node lying on some path from `ancestor` to `descendant`,
|
|
25
|
+
# both endpoints included.
|
|
26
|
+
def between(ancestor, descendant)
|
|
27
|
+
adapter.between(ancestor, descendant)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def edges
|
|
31
|
+
config.edge_class.all
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Edges whose both endpoints are inside the given node relation -
|
|
35
|
+
# the induced subgraph's edge set (for exports, visualization, Kahn
|
|
36
|
+
# walks in Ruby, ...).
|
|
37
|
+
def edges_among(relation)
|
|
38
|
+
edges = config.edge_class
|
|
39
|
+
sub = relation.select(*config.node_pk_columns).arel.ast
|
|
40
|
+
endpoint_in = lambda do |columns|
|
|
41
|
+
tuple = Arel::Nodes::Grouping.new(columns.map { |c| edges.arel_table[c] })
|
|
42
|
+
Arel::Nodes::In.new(tuple, sub)
|
|
43
|
+
end
|
|
44
|
+
edges.where(endpoint_in.call(config.edge_parent_columns))
|
|
45
|
+
.where(endpoint_in.call(config.edge_child_columns))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Rebuilds the closure table from the edges table. No-op for
|
|
49
|
+
# maintain: :recursive_cte (there is nothing materialized).
|
|
50
|
+
def rebuild!
|
|
51
|
+
return self unless config.closure?
|
|
52
|
+
|
|
53
|
+
model.connection_pool.with_connection do |conn|
|
|
54
|
+
conn.execute("SELECT #{conn.quote_table_name("#{config.prefix}_rebuild_paths")}();")
|
|
55
|
+
end
|
|
56
|
+
self
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Rows where the stored closure disagrees with the recursive-CTE truth.
|
|
60
|
+
# Empty means healthy.
|
|
61
|
+
def validate
|
|
62
|
+
return [] unless config.closure?
|
|
63
|
+
|
|
64
|
+
model.connection_pool.with_connection do |conn|
|
|
65
|
+
conn.select_all("SELECT * FROM #{conn.quote_table_name("#{config.prefix}_validate_paths")}();").to_a
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def valid?
|
|
70
|
+
validate.empty?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def validate!
|
|
74
|
+
rows = validate
|
|
75
|
+
if rows.any?
|
|
76
|
+
raise CorruptionError.new(
|
|
77
|
+
"dag_me: closure for #{model.name} diverged from edge truth (#{rows.length} rows)", rows
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
self
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/dag_me/macro.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DagMe
|
|
4
|
+
module Macro
|
|
5
|
+
# Declares a DAG on the model. Called bare it defines the model's default
|
|
6
|
+
# graph; called with a name it defines an independent named graph, so one
|
|
7
|
+
# node can belong to many networks:
|
|
8
|
+
#
|
|
9
|
+
# class Relay < ApplicationRecord
|
|
10
|
+
# dag_me :power
|
|
11
|
+
# dag_me :comms, maintain: :recursive_cte
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# relay.add_child(other, dag: :power)
|
|
15
|
+
# relay.comms_children
|
|
16
|
+
# Relay.dag(:power).rebuild!
|
|
17
|
+
def dag_me(name = nil, maintain: :postgresql_closure, scope: nil, edge_table: nil, paths_table: nil)
|
|
18
|
+
key = name&.to_sym || :default
|
|
19
|
+
unless include?(DagMe::Model)
|
|
20
|
+
class_attribute :dag_configs, instance_writer: false, instance_predicate: false, default: {}.freeze
|
|
21
|
+
include DagMe::Model
|
|
22
|
+
end
|
|
23
|
+
raise ArgumentError, "#{self.name}: dag #{key.inspect} is already defined" if dag_configs.key?(key)
|
|
24
|
+
|
|
25
|
+
config = Configuration.new(model: self, name: name&.to_sym, maintain:, scope:, edge_table:, paths_table:)
|
|
26
|
+
self.dag_configs = dag_configs.merge(key => config).freeze
|
|
27
|
+
DagMe::Model.attach(self, config)
|
|
28
|
+
self
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/dag_me/model.rb
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DagMe
|
|
4
|
+
module Model
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
# Builds the per-dag machinery: edge/paths constants and associations.
|
|
8
|
+
# Called by the macro once per dag_me declaration, so a model hosting
|
|
9
|
+
# several named graphs gets one full set each.
|
|
10
|
+
def self.attach(model, config)
|
|
11
|
+
parent_fk = config.composite_pk? ? config.edge_parent_columns.map(&:to_sym) : :parent_id
|
|
12
|
+
child_fk = config.composite_pk? ? config.edge_child_columns.map(&:to_sym) : :child_id
|
|
13
|
+
pk_opt = config.composite_pk? ? { primary_key: config.node_pk_columns.map(&:to_sym) } : {}
|
|
14
|
+
|
|
15
|
+
child_edges = config.association_name('dag_child_edges')
|
|
16
|
+
parent_edges = config.association_name('dag_parent_edges')
|
|
17
|
+
|
|
18
|
+
edge_class = Class.new(ActiveRecord::Base)
|
|
19
|
+
model.const_set(config.edge_class_name, edge_class)
|
|
20
|
+
edge_class.table_name = config.edge_table
|
|
21
|
+
edge_class.belongs_to :parent, class_name: model.name, foreign_key: parent_fk,
|
|
22
|
+
inverse_of: child_edges, **pk_opt
|
|
23
|
+
edge_class.belongs_to :child, class_name: model.name, foreign_key: child_fk,
|
|
24
|
+
inverse_of: parent_edges, **pk_opt
|
|
25
|
+
|
|
26
|
+
if config.closure?
|
|
27
|
+
paths_class = Class.new(ActiveRecord::Base)
|
|
28
|
+
model.const_set(config.paths_class_name, paths_class)
|
|
29
|
+
paths_class.table_name = config.paths_table
|
|
30
|
+
paths_class.primary_key = config.paths_ancestor_columns + config.paths_descendant_columns
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
model.has_many child_edges, class_name: "#{model.name}::#{config.edge_class_name}",
|
|
34
|
+
foreign_key: parent_fk, inverse_of: :parent, dependent: nil, **pk_opt
|
|
35
|
+
model.has_many config.association_name('children'), through: child_edges, source: :child
|
|
36
|
+
model.has_many parent_edges, class_name: "#{model.name}::#{config.edge_class_name}",
|
|
37
|
+
foreign_key: child_fk, inverse_of: :child, dependent: nil, **pk_opt
|
|
38
|
+
model.has_many config.association_name('parents'), through: parent_edges, source: :parent
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
included do
|
|
42
|
+
# A valid topological order: nodes with fewer ancestors always come
|
|
43
|
+
# before their descendants. Composes with any relation, e.g.
|
|
44
|
+
# `node.descendants.topologically` or `Relay.topologically(:power)`.
|
|
45
|
+
scope :topologically, ->(dag_name = nil) { dag(dag_name).adapter.apply_topological_order(all) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class_methods do
|
|
49
|
+
# Graph-wide operations: Task.dag.between(a, b), Task.dag.rebuild!,
|
|
50
|
+
# Relay.dag(:power).valid?, Task.dag.edges_among(relation), ...
|
|
51
|
+
def dag(name = nil)
|
|
52
|
+
config = dag_config_for(name)
|
|
53
|
+
@dags ||= {}
|
|
54
|
+
@dags[name&.to_sym || :default] ||= DagMe::Graph.new(self, config)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The default (unnamed) dag's configuration; nil when the model only
|
|
58
|
+
# declares named dags.
|
|
59
|
+
def dag_config
|
|
60
|
+
dag_configs[:default]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def dag_config_for(name)
|
|
64
|
+
key = name&.to_sym || :default
|
|
65
|
+
dag_configs.fetch(key) do
|
|
66
|
+
raise ArgumentError,
|
|
67
|
+
"#{self.name}: unknown dag #{key.inspect} (defined: #{dag_configs.keys.map(&:inspect).join(', ')})"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def roots(dag: nil)
|
|
72
|
+
dag_nodes_without(dag_config_for(dag), :edge_child_columns)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def leaves(dag: nil)
|
|
76
|
+
dag_nodes_without(dag_config_for(dag), :edge_parent_columns)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# Nodes with no edge row in the given role (child -> roots, parent -> leaves).
|
|
82
|
+
def dag_nodes_without(config, role)
|
|
83
|
+
edge = Arel::Table.new(config.edge_table)
|
|
84
|
+
conds = config.node_pk_columns.zip(config.public_send(role))
|
|
85
|
+
.map { |pk, edge_col| edge[edge_col].eq(arel_table[pk]) }.inject(:and)
|
|
86
|
+
where.not(edge.project(1).where(conds).exists)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def add_child(node, dag: nil)
|
|
91
|
+
config = self.class.dag_config_for(dag)
|
|
92
|
+
DagMe.translate_errors { config.edge_class.create!(parent: self, child: node) }
|
|
93
|
+
node
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def add_parent(node, dag: nil)
|
|
97
|
+
config = self.class.dag_config_for(dag)
|
|
98
|
+
DagMe.translate_errors { config.edge_class.create!(parent: node, child: self) }
|
|
99
|
+
node
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def remove_child(node, dag: nil)
|
|
103
|
+
config = self.class.dag_config_for(dag)
|
|
104
|
+
DagMe.translate_errors do
|
|
105
|
+
config.edge_class.where(dag_edge_key(config, parent: self, child: node)).delete_all
|
|
106
|
+
end
|
|
107
|
+
node
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def remove_parent(node, dag: nil)
|
|
111
|
+
config = self.class.dag_config_for(dag)
|
|
112
|
+
DagMe.translate_errors do
|
|
113
|
+
config.edge_class.where(dag_edge_key(config, parent: node, child: self)).delete_all
|
|
114
|
+
end
|
|
115
|
+
node
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def ancestors(dag: nil)
|
|
119
|
+
self.class.dag(dag).adapter.ancestors(self)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def descendants(dag: nil)
|
|
123
|
+
self.class.dag(dag).adapter.descendants(self)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self_and_ancestors(dag: nil)
|
|
127
|
+
self.class.dag(dag).adapter.self_and_ancestors(self)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self_and_descendants(dag: nil)
|
|
131
|
+
self.class.dag(dag).adapter.self_and_descendants(self)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def ancestor_of?(node, dag: nil)
|
|
135
|
+
self.class.dag(dag).adapter.reachable?(self, node)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def descendant_of?(node, dag: nil)
|
|
139
|
+
self.class.dag(dag).adapter.reachable?(node, self)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The induced subgraph rooted here: this node, its descendants, and the
|
|
143
|
+
# edges among them.
|
|
144
|
+
def subgraph(dag: nil)
|
|
145
|
+
self_and_descendants(dag:)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def subgraph_edges(dag: nil)
|
|
149
|
+
self.class.dag(dag).edges_among(self_and_descendants(dag:))
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def root?(dag: nil)
|
|
153
|
+
config = self.class.dag_config_for(dag)
|
|
154
|
+
!public_send(config.association_name('dag_parent_edges')).exists?
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def leaf?(dag: nil)
|
|
158
|
+
config = self.class.dag_config_for(dag)
|
|
159
|
+
!public_send(config.association_name('dag_child_edges')).exists?
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
# {parent_id: ..., child_id: ...} - one pair per pk column for composite keys.
|
|
165
|
+
def dag_edge_key(config, parent:, child:)
|
|
166
|
+
config.edge_parent_columns.zip(Array(parent.id))
|
|
167
|
+
.concat(config.edge_child_columns.zip(Array(child.id)))
|
|
168
|
+
.to_h
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/railtie'
|
|
4
|
+
|
|
5
|
+
module DagMe
|
|
6
|
+
class Railtie < Rails::Railtie # :nodoc:
|
|
7
|
+
generators do
|
|
8
|
+
require_relative '../generators/dag_me/migration_generator'
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
rake_tasks do
|
|
12
|
+
load 'dag_me/railties/tasks.rake'
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :dag_me do
|
|
4
|
+
desc 'Report the health of every dag_me model (tables, triggers, functions, closure)'
|
|
5
|
+
task status: :environment do
|
|
6
|
+
Rails.application.eager_load!
|
|
7
|
+
models = ActiveRecord::Base.descendants.select { |klass| klass.include?(DagMe::Model) }
|
|
8
|
+
DagMe::TaskHelpers.status(models)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
desc 'Rebuild the closure table for every dag_me model (or MODEL=Task for one)'
|
|
12
|
+
task rebuild: :environment do
|
|
13
|
+
Rails.application.eager_load!
|
|
14
|
+
models = if ENV['MODEL']
|
|
15
|
+
model = ENV['MODEL'].constantize
|
|
16
|
+
abort "#{model.name} is not a dag_me model (no dag_me call)" unless model.include?(DagMe::Model)
|
|
17
|
+
[model]
|
|
18
|
+
else
|
|
19
|
+
ActiveRecord::Base.descendants.select { |klass| klass.include?(DagMe::Model) }
|
|
20
|
+
end
|
|
21
|
+
models.each do |model|
|
|
22
|
+
print "Rebuilding #{model.name}... "
|
|
23
|
+
model.dag_configs.each_value { |config| model.dag(config.name).rebuild! }
|
|
24
|
+
puts 'done'
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DagMe
|
|
4
|
+
# Backs the `dag_me:status` rake task: a doctor-style report of every
|
|
5
|
+
# dag_me model's database objects and closure health.
|
|
6
|
+
module TaskHelpers
|
|
7
|
+
COLORS = {
|
|
8
|
+
green: "\e[32m",
|
|
9
|
+
red: "\e[31m",
|
|
10
|
+
yellow: "\e[33m",
|
|
11
|
+
cyan: "\e[36m",
|
|
12
|
+
reset: "\e[0m"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def colorize(text, color)
|
|
18
|
+
"#{COLORS[color]}#{text}#{COLORS[:reset]}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def ok(text)
|
|
22
|
+
"#{colorize('✓', :green)} #{text}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def bad(text)
|
|
26
|
+
"#{colorize('✗', :red)} #{text}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def status(models, io: $stdout)
|
|
30
|
+
if models.empty?
|
|
31
|
+
io.puts colorize('No dag_me models found.', :yellow)
|
|
32
|
+
return
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
models.each { |model| io.puts model_report(model) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def model_report(model)
|
|
39
|
+
model.dag_configs.each_value.map { |config| config_report(model, config) }.join
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def config_report(model, config)
|
|
43
|
+
label = config.default? ? model.name : "#{model.name} [#{config.name}]"
|
|
44
|
+
lines = ["#{colorize(label, :cyan)} (maintain: #{config.maintain}" \
|
|
45
|
+
"#{", scope: #{config.scope_columns.join(', ')}" if config.scope_columns.any?})"]
|
|
46
|
+
lines.concat(table_checks(model, config))
|
|
47
|
+
lines.concat(trigger_checks(model, config))
|
|
48
|
+
lines.concat(function_checks(model, config))
|
|
49
|
+
lines << closure_check(model, config)
|
|
50
|
+
"#{lines.compact.join("\n ")}\n"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def table_checks(model, config)
|
|
54
|
+
tables = [config.edge_table]
|
|
55
|
+
tables << config.paths_table if config.closure?
|
|
56
|
+
tables.map do |table|
|
|
57
|
+
if model.connection.table_exists?(table)
|
|
58
|
+
ok("table #{table}")
|
|
59
|
+
else
|
|
60
|
+
bad("table #{table} missing - run the dag_me migration")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def trigger_checks(model, config)
|
|
66
|
+
expected = ["#{config.prefix}_edge_insert_check"]
|
|
67
|
+
if config.closure?
|
|
68
|
+
expected.push("#{config.prefix}_edge_insert_apply", "#{config.prefix}_edge_delete_apply",
|
|
69
|
+
"#{config.prefix}_node_insert", "#{config.prefix}_node_delete")
|
|
70
|
+
end
|
|
71
|
+
expected << "#{config.prefix}_node_update" if config.scope_columns.any?
|
|
72
|
+
|
|
73
|
+
conn = model.connection
|
|
74
|
+
installed = conn.select_values(<<~SQL)
|
|
75
|
+
SELECT t.tgname FROM pg_trigger t
|
|
76
|
+
JOIN pg_class c ON c.oid = t.tgrelid
|
|
77
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
78
|
+
WHERE NOT t.tgisinternal
|
|
79
|
+
AND n.nspname = ANY (current_schemas(false))
|
|
80
|
+
AND t.tgname LIKE #{conn.quote("#{config.prefix}\\_%")}
|
|
81
|
+
SQL
|
|
82
|
+
(expected - installed).map { |name| bad("trigger #{name} missing") }
|
|
83
|
+
.presence || [ok("triggers (#{expected.length})")]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def function_checks(model, config)
|
|
87
|
+
expected = ["#{config.prefix}_lock", "#{config.prefix}_edge_insert_check"]
|
|
88
|
+
if config.closure?
|
|
89
|
+
expected.push("#{config.prefix}_edge_insert_apply", "#{config.prefix}_edge_delete_apply",
|
|
90
|
+
"#{config.prefix}_node_insert", "#{config.prefix}_node_delete",
|
|
91
|
+
"#{config.prefix}_rebuild_paths", "#{config.prefix}_validate_paths")
|
|
92
|
+
end
|
|
93
|
+
expected << "#{config.prefix}_node_update" if config.scope_columns.any?
|
|
94
|
+
|
|
95
|
+
conn = model.connection
|
|
96
|
+
installed = conn.select_values(<<~SQL)
|
|
97
|
+
SELECT p.proname FROM pg_proc p
|
|
98
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
99
|
+
WHERE n.nspname = ANY (current_schemas(false))
|
|
100
|
+
AND p.proname LIKE #{conn.quote("#{config.prefix}\\_%")}
|
|
101
|
+
SQL
|
|
102
|
+
(expected - installed).map { |name| bad("function #{name} missing") }
|
|
103
|
+
.presence || [ok("functions (#{expected.length})")]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def closure_check(model, config)
|
|
107
|
+
return ok('closure: not materialized (recursive_cte)') unless config.closure?
|
|
108
|
+
return nil unless model.connection.table_exists?(config.paths_table)
|
|
109
|
+
|
|
110
|
+
graph = model.dag(config.name)
|
|
111
|
+
discrepancies = graph.validate
|
|
112
|
+
if discrepancies.empty?
|
|
113
|
+
ok("closure valid (#{graph.edges.count} edges)")
|
|
114
|
+
else
|
|
115
|
+
facade = config.default? ? 'Model.dag' : "Model.dag(:#{config.name})"
|
|
116
|
+
bad("closure diverged: #{discrepancies.length} rows - run #{facade}.rebuild!")
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DagMe
|
|
4
|
+
# Minitest assertions for host applications:
|
|
5
|
+
#
|
|
6
|
+
# class GraphSetupTest < ActiveSupport::TestCase
|
|
7
|
+
# include DagMe::TestHelper
|
|
8
|
+
#
|
|
9
|
+
# test 'tasks form a healthy DAG' do
|
|
10
|
+
# assert_dag_model Task, maintain: :postgresql_closure
|
|
11
|
+
# assert_dag_valid Task
|
|
12
|
+
# end
|
|
13
|
+
# end
|
|
14
|
+
module TestHelper
|
|
15
|
+
# Asserts the class is wired up as a dag_me model, optionally pinning
|
|
16
|
+
# the maintain mode and scope columns. Pass dag: for a named graph.
|
|
17
|
+
def assert_dag_model(klass, maintain: nil, scope: nil, dag: nil)
|
|
18
|
+
assert klass.include?(DagMe::Model), "#{klass} should call dag_me"
|
|
19
|
+
|
|
20
|
+
config = klass.dag_config_for(dag)
|
|
21
|
+
if maintain
|
|
22
|
+
assert_equal maintain, config.maintain,
|
|
23
|
+
"#{klass} should maintain its DAG via #{maintain}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
return unless scope
|
|
27
|
+
|
|
28
|
+
assert_equal Array(scope).map(&:to_sym), config.scope_columns,
|
|
29
|
+
"#{klass} should be scoped by #{Array(scope).join(', ')}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Asserts the stored closure agrees with the recursive-CTE truth.
|
|
33
|
+
def assert_dag_valid(klass, dag: nil)
|
|
34
|
+
discrepancies = klass.dag(dag).validate
|
|
35
|
+
|
|
36
|
+
assert_empty discrepancies,
|
|
37
|
+
"#{klass} closure diverged from edge truth: #{discrepancies.inspect}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def assert_dag_reachable(ancestor, descendant, dag: nil)
|
|
41
|
+
assert ancestor.ancestor_of?(descendant, dag:),
|
|
42
|
+
"expected #{node_label(ancestor)} to reach #{node_label(descendant)}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def refute_dag_reachable(ancestor, descendant, dag: nil)
|
|
46
|
+
refute ancestor.ancestor_of?(descendant, dag:),
|
|
47
|
+
"expected #{node_label(ancestor)} not to reach #{node_label(descendant)}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Asserts the ordered node list is a valid topological order: for every
|
|
51
|
+
# edge among the listed nodes, the parent appears before the child.
|
|
52
|
+
def assert_topological_order(model, ordered_nodes, dag: nil)
|
|
53
|
+
config = model.dag_config_for(dag)
|
|
54
|
+
position = ordered_nodes.each_with_index.to_h { |node, i| [Array(node.id), i] }
|
|
55
|
+
|
|
56
|
+
model.dag(dag).edges.find_each do |edge|
|
|
57
|
+
parent_key = config.edge_parent_columns.map { |c| edge[c] }
|
|
58
|
+
child_key = config.edge_child_columns.map { |c| edge[c] }
|
|
59
|
+
next unless position.key?(parent_key) && position.key?(child_key)
|
|
60
|
+
|
|
61
|
+
assert_operator position[parent_key], :<, position[child_key],
|
|
62
|
+
"edge #{parent_key.join('/')} -> #{child_key.join('/')} violates topological order"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def node_label(node)
|
|
69
|
+
"#{node.class.name}##{node.id}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/dag_me.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support'
|
|
4
|
+
require 'active_record'
|
|
5
|
+
|
|
6
|
+
require_relative 'dag_me/version'
|
|
7
|
+
require_relative 'dag_me/errors'
|
|
8
|
+
require 'dag_me/railtie' if defined?(Rails)
|
|
9
|
+
|
|
10
|
+
module DagMe
|
|
11
|
+
extend ActiveSupport::Autoload
|
|
12
|
+
|
|
13
|
+
autoload :Configuration
|
|
14
|
+
autoload :DDL
|
|
15
|
+
autoload :Graph
|
|
16
|
+
autoload :Macro
|
|
17
|
+
autoload :Model
|
|
18
|
+
autoload :TaskHelpers
|
|
19
|
+
autoload :TestHelper
|
|
20
|
+
|
|
21
|
+
module Adapters
|
|
22
|
+
extend ActiveSupport::Autoload
|
|
23
|
+
|
|
24
|
+
autoload :Base
|
|
25
|
+
autoload :PostgresqlClosure
|
|
26
|
+
autoload :RecursiveCte
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
ActiveSupport.on_load(:active_record) do
|
|
31
|
+
extend DagMe::Macro
|
|
32
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
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:migration Task
|
|
10
|
+
class MigrationGenerator < Rails::Generators::NamedBase
|
|
11
|
+
include Rails::Generators::Migration
|
|
12
|
+
|
|
13
|
+
source_root File.expand_path('templates', __dir__)
|
|
14
|
+
|
|
15
|
+
def create_migration_file
|
|
16
|
+
migration_template 'install_dag.rb.erb', "db/migrate/install_dag_me_for_#{file_name.pluralize}.rb"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.next_migration_number(dirname)
|
|
20
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def migration_version
|
|
26
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|