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.
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DagMe
4
+ VERSION = '0.1.0'
5
+ 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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class InstallDagMeFor<%= class_name.pluralize %> < ActiveRecord::Migration<%= migration_version %>
4
+ def up
5
+ DagMe::DDL.install!(<%= class_name %>)
6
+ end
7
+
8
+ def down
9
+ DagMe::DDL.uninstall!(<%= class_name %>)
10
+ end
11
+ end