callback_hell 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.
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CallbackHell
4
+ module Reports
5
+ module Callbacks
6
+ class Line < LineBase
7
+ private
8
+
9
+ def report_title
10
+ "Callback Hell callbacks report:"
11
+ end
12
+
13
+ def format_callback_name(callback)
14
+ "#{callback.kind}_#{callback.callback_group}"
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CallbackHell
4
+ module Reports
5
+ module Callbacks
6
+ class Table < TableBase
7
+ private
8
+
9
+ def report_title
10
+ "Callback Hell callbacks report"
11
+ end
12
+
13
+ def format_group_name(callback)
14
+ "#{timing_symbol(callback.kind)}/#{callback.callback_group}"
15
+ end
16
+
17
+ def timing_symbol(timing)
18
+ case timing
19
+ when :before, "before" then "⇥"
20
+ when :after, "after" then "↦"
21
+ when :around, "around" then "↔"
22
+ else
23
+ " "
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CallbackHell
4
+ module Reports
5
+ class GithubBase < Base
6
+ def generate
7
+ output = ["::group::#{report_title}"]
8
+
9
+ @stats.by_model.each_with_index do |(model_name, _), index|
10
+ output << "" if index > 0
11
+ output << "::group::#{model_name}"
12
+
13
+ # Add total row first
14
+ total_stats = @stats.stats_for(model_name)
15
+ output << format_group(
16
+ "all",
17
+ total_stats[:total],
18
+ total_stats[:own],
19
+ total_stats[:inherited],
20
+ total_stats[:rails],
21
+ total_stats[:association_generated],
22
+ total_stats[:attribute_generated],
23
+ total_stats[:gems],
24
+ total_stats[:conditional]
25
+ )
26
+
27
+ # Group and sort callbacks
28
+ grouped_callbacks = @stats.by_model[model_name].group_by { |cb| format_group_name(cb) }
29
+
30
+ grouped_callbacks.keys.sort.each do |group_name|
31
+ group_callbacks = grouped_callbacks[group_name]
32
+ output << format_group(
33
+ group_name,
34
+ group_callbacks.size,
35
+ group_callbacks.count { |cb| cb.origin == :own },
36
+ group_callbacks.count(&:inherited),
37
+ group_callbacks.count { |cb| cb.origin == :rails },
38
+ group_callbacks.count(&:association_generated),
39
+ group_callbacks.count(&:attribute_generated),
40
+ group_callbacks.count { |cb| cb.origin == :gems },
41
+ group_callbacks.count(&:conditional)
42
+ )
43
+ end
44
+
45
+ output << "::endgroup::"
46
+ end
47
+
48
+ output << "::endgroup::"
49
+ output.join("\n")
50
+ end
51
+
52
+ private
53
+
54
+ def report_title
55
+ raise NotImplementedError
56
+ end
57
+
58
+ def format_group(name, total, own, inherited, rails, association_generated, attribute_generated, gems, conditional)
59
+ "::debug::kind=#{name} total=#{total} own=#{own} inherited=#{inherited} rails=#{rails} association_generated=#{association_generated} attribute_generated=#{attribute_generated} gems=#{gems} conditional=#{conditional}"
60
+ end
61
+
62
+ def format_group_name(callback)
63
+ raise NotImplementedError
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CallbackHell
4
+ module Reports
5
+ class LineBase < Base
6
+ def generate
7
+ output = [report_title]
8
+
9
+ @stats.by_model.each do |model_name, model_callbacks|
10
+ output << "\n#{model_name}:"
11
+ model_callbacks.sort_by(&:kind).each do |callback|
12
+ output << format_callback(callback)
13
+ end
14
+ end
15
+
16
+ output.join("\n")
17
+ end
18
+
19
+ private
20
+
21
+ def report_title
22
+ raise NotImplementedError
23
+ end
24
+
25
+ def format_callback(callback)
26
+ [
27
+ " #{format_callback_name(callback)}",
28
+ "method_name: #{callback.human_method_name}",
29
+ "origin: #{callback.origin}",
30
+ "association_generated: #{callback.association_generated ? "yes" : "no"}",
31
+ "attribute_generated: #{callback.attribute_generated ? "yes" : "no"}",
32
+ "inherited: #{callback.inherited ? "inherited" : "own"}",
33
+ "conditional: #{callback.conditional ? "yes" : "no"}"
34
+ ].compact.join(", ")
35
+ end
36
+
37
+ def format_callback_name(callback)
38
+ raise NotImplementedError
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "table_tennis"
4
+
5
+ module CallbackHell
6
+ module Reports
7
+ class TableBase < Base
8
+ def generate
9
+ headers = ["Model", "Kind", "Total", "Own", "Inherited"]
10
+ headers << "Rails" if stats.rails?
11
+ headers << "Associations" if stats.associations?
12
+ headers << "Attributes" if stats.attributes?
13
+ headers.concat(["Gems", "Conditional"])
14
+
15
+ headers_mapping = headers.index_by { |h| h.parameterize.to_sym }
16
+
17
+ table = TableTennis.new(generate_rows(headers_mapping.keys)) do |t|
18
+ t.title = report_title
19
+ t.headers = headers_mapping
20
+ t.placeholder = ""
21
+ t.color_scales = {total: :gr}
22
+
23
+ # Don't shrink table if we run in a test environment,
24
+ # we need full labels and values to test
25
+ t.layout = false if defined?(Rails) && Rails.env.test?
26
+ end
27
+
28
+ table.to_s
29
+ end
30
+
31
+ private
32
+
33
+ def report_title
34
+ raise NotImplementedError
35
+ end
36
+
37
+ def generate_rows(headers)
38
+ rows = []
39
+ @stats.by_model.each_with_index do |(model_name, _), index|
40
+ # TODO: not supported by table_tennis
41
+ # rows << :separator if index > 0
42
+ rows.concat(model_rows(model_name))
43
+ end
44
+ rows.map { headers.zip(_1).to_h }
45
+ end
46
+
47
+ def model_rows(model_name)
48
+ rows = []
49
+ total_stats = @stats.stats_for(model_name)
50
+
51
+ # Add total row
52
+ rows << [
53
+ model_name,
54
+ "all",
55
+ total_stats[:total],
56
+ total_stats[:own],
57
+ total_stats[:inherited],
58
+ stats.rails? ? total_stats[:rails] : nil,
59
+ stats.associations? ? total_stats[:association_generated] : nil,
60
+ stats.attributes? ? total_stats[:attribute_generated] : nil,
61
+ total_stats[:gems],
62
+ total_stats[:conditional]
63
+ ].compact
64
+
65
+ # Group and sort callbacks
66
+ grouped_callbacks = @stats.by_model[model_name].group_by { |cb| format_group_name(cb) }
67
+
68
+ grouped_callbacks.keys.sort.each do |group_name|
69
+ group_callbacks = grouped_callbacks[group_name]
70
+ rows << [
71
+ "",
72
+ group_name,
73
+ group_callbacks.size,
74
+ group_callbacks.count { |cb| cb.origin == :own },
75
+ group_callbacks.count(&:inherited),
76
+ stats.rails? ? group_callbacks.count { |cb| cb.origin == :rails } : nil,
77
+ stats.associations? ? group_callbacks.count(&:association_generated) : nil,
78
+ stats.attributes? ? group_callbacks.count(&:attribute_generated) : nil,
79
+ group_callbacks.count { |cb| cb.origin == :gems },
80
+ group_callbacks.count(&:conditional)
81
+ ].compact
82
+ end
83
+
84
+ rows
85
+ end
86
+
87
+ def format_group_name(callback)
88
+ raise NotImplementedError
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CallbackHell
4
+ module Reports
5
+ module Validations
6
+ class Github < GithubBase
7
+ private
8
+
9
+ def report_title
10
+ "Callback Hell validations report"
11
+ end
12
+
13
+ def format_group_name(callback)
14
+ callback.validation_type
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CallbackHell
4
+ module Reports
5
+ module Validations
6
+ class Line < LineBase
7
+ private
8
+
9
+ def report_title
10
+ "Callback Hell validations report:"
11
+ end
12
+
13
+ def format_callback_name(callback)
14
+ if callback.method_name.is_a?(Symbol) || callback.method_name.is_a?(String)
15
+ type = callback.validation_type
16
+ (type == "custom") ? "custom (#{callback.method_name})" : type
17
+ else
18
+ callback.validation_type
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CallbackHell
4
+ module Reports
5
+ module Validations
6
+ class Table < TableBase
7
+ private
8
+
9
+ def report_title
10
+ "Callback Hell validations report"
11
+ end
12
+
13
+ def format_group_name(callback)
14
+ callback.validation_type
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CallbackHell
4
+ class Runner
5
+ DEFAULT_FORMAT = :table
6
+
7
+ def self.run(format: DEFAULT_FORMAT, model: nil, paths: nil, kind: :callbacks, **opts)
8
+ new(format: format, model: model, paths: paths, kind: kind, **opts).run
9
+ end
10
+
11
+ def initialize(format:, model:, paths:, kind: :callbacks, sort_by: :size, sort_order: :desc, mode: :default)
12
+ @format = (format || DEFAULT_FORMAT).to_sym
13
+ @model_name = model
14
+ @paths = paths
15
+ @kind = kind
16
+ @sort_by = sort_by
17
+ @sort_order = sort_order
18
+ @mode = mode
19
+ end
20
+
21
+ def run
22
+ if @kind == :report
23
+ [:callbacks, :validations].map do |ckind|
24
+ generate_report(collect_callbacks(ckind), ckind)
25
+ end.join("\n\n")
26
+ else
27
+ generate_report(collect_callbacks(@kind), @kind)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def collect_callbacks(ckind)
34
+ Collector.new(find_model_class, paths: @paths, kind: ckind, mode: @mode).collect
35
+ end
36
+
37
+ def generate_report(callbacks, ckind)
38
+ find_reporter_class(ckind).new(
39
+ callbacks, sort_by: @sort_by, sort_order: @sort_order,
40
+ mode: @mode, kind: ckind
41
+ ).generate
42
+ end
43
+
44
+ def find_reporter_class(ckind)
45
+ namespace = ckind.to_s.capitalize
46
+ format = @format.to_s.capitalize
47
+ class_name = "CallbackHell::Reports::#{namespace}::#{format}"
48
+ class_name.constantize
49
+ rescue NameError
50
+ raise CallbackHell::Error, "Unknown format: #{@format} for #{ckind}"
51
+ end
52
+
53
+ def find_model_class
54
+ return unless @model_name
55
+
56
+ if @model_name.match?(/^[A-Z]/)
57
+ @model_name.constantize
58
+ else
59
+ @model_name.classify.constantize
60
+ end
61
+ rescue NameError
62
+ raise CallbackHell::Error, "Cannot find model: #{@model_name}"
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CallbackHell
4
+ class Stats
5
+ COUNTERS = %i[
6
+ total own inherited rails gems conditional
7
+ association_generated attribute_generated
8
+ ].freeze
9
+
10
+ SORT = %i[size name].freeze
11
+ SORT_ORDER = %i[desc asc].freeze
12
+
13
+ MODE = %i[default full].freeze
14
+
15
+ attr_reader :callbacks
16
+ private attr_reader :sort_by, :sort_order
17
+
18
+ def initialize(callbacks, sort_by: :size, sort_order: :desc, mode: :default, kind: :callbacks)
19
+ @callbacks = callbacks
20
+ @stats_cache = {}
21
+ @sort_by = sort_by
22
+ raise ArgumentError, "Invalid sort_by: #{@sort_by}. Available: #{SORT.join(", ")}" unless SORT.include?(@sort_by)
23
+ @sort_order = sort_order
24
+ raise ArgumentError, "Invalid sort_order: #{@sort_order}. Available: #{SORT_ORDER.join(", ")}" unless SORT_ORDER.include?(@sort_order)
25
+ @mode = mode
26
+ raise ArgumentError, "Invalid mode: #{@mode}. Available: #{MODE.join(", ")}" unless MODE.include?(@mode)
27
+ @kind = kind
28
+ end
29
+
30
+ def by_model
31
+ @by_model ||= callbacks.group_by { |cb| cb.model.name }.sort_by do |name, callbacks|
32
+ if sort_by == :size
33
+ callbacks.size
34
+ elsif sort_by == :name
35
+ name
36
+ end
37
+ end.tap do |sorted|
38
+ sorted.reverse! if sort_order == :desc
39
+ end.to_h
40
+ end
41
+
42
+ def stats_for(model_name)
43
+ @stats_cache[model_name] ||= begin
44
+ model_callbacks = by_model[model_name]
45
+ return {} unless model_callbacks
46
+
47
+ collect_stats(model_callbacks)
48
+ end
49
+ end
50
+
51
+ def stats_for_group(model_name, group)
52
+ key = "#{model_name}_#{group}"
53
+ @stats_cache[key] ||= begin
54
+ model_callbacks = by_model[model_name]&.select { |cb| cb.callback_group == group }
55
+ return {} unless model_callbacks
56
+
57
+ collect_stats(model_callbacks)
58
+ end
59
+ end
60
+
61
+ def rails?
62
+ @mode == :full || @kind == :validations
63
+ end
64
+
65
+ def associations?
66
+ @mode == :full
67
+ end
68
+
69
+ def attributes?
70
+ @mode == :full
71
+ end
72
+
73
+ private
74
+
75
+ def collect_stats(callbacks)
76
+ callbacks.each_with_object(initial_stats) do |cb, stats|
77
+ stats[:total] += 1
78
+ stats[:own] += 1 if cb.origin == :own
79
+ stats[:inherited] += 1 if cb.inherited
80
+ stats[:rails] += 1 if cb.origin == :rails
81
+ stats[:gems] += 1 if cb.origin == :gems
82
+ stats[:conditional] += 1 if cb.conditional
83
+ stats[:association_generated] += 1 if cb.association_generated
84
+ stats[:attribute_generated] += 1 if cb.attribute_generated
85
+ end
86
+ end
87
+
88
+ def initial_stats
89
+ COUNTERS.to_h { |counter| [counter, 0] }
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CallbackHell
4
+ VERSION = "0.2.0"
5
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+
5
+ module CallbackHell
6
+ class Error < StandardError; end
7
+
8
+ def self.loader # @private
9
+ @loader ||= Zeitwerk::Loader.for_gem.tap do |loader|
10
+ loader.ignore("#{__dir__}/tasks")
11
+ loader.setup
12
+ end
13
+ end
14
+
15
+ def self.collect_callbacks(*models, **options)
16
+ collector = Collector.new(**options)
17
+ collector.collect(models)
18
+ end
19
+
20
+ def self.collect_validations(*models, **options)
21
+ collector = Collector.new(**options, kind: :validations)
22
+ collector.collect(models)
23
+ end
24
+ end
25
+
26
+ CallbackHell.loader
27
+
28
+ require "callback_hell/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake"
4
+ require "rails"
5
+
6
+ namespace :ch do
7
+ desc <<~DESC
8
+ Generate callbacks report for Active Record models.
9
+
10
+ Options:
11
+ format=table|line|github Report format (default: table)
12
+ model=ModelName Filter by model name (optional). Can be
13
+ specified as constant
14
+ name (MessageThread) or file path
15
+ (message_thread, admin/message_thread)
16
+ sort=total|name Sort by score or name (default: score:desc)
17
+ mode=default|full Default mode collects only user-defined callbacks,
18
+ full includes association-, attribute-, or validation-generated
19
+ callbacks
20
+ path=DIR1,DIR2 Additional model directories (comma-separated)
21
+
22
+ Examples:
23
+ # Show all models callbacks in table format (default)
24
+ rake ch:callbacks
25
+ DESC
26
+ task callbacks: :environment do
27
+ opts = {
28
+ kind: :callbacks,
29
+ format: ENV["format"],
30
+ model: ENV["model"],
31
+ mode: :default
32
+ }.compact
33
+
34
+ if ENV["path"]
35
+ opts[:paths] = ENV["path"]&.split(",")&.map { |p| Rails.root.join(p) }
36
+ end
37
+
38
+ if ENV["sort"]
39
+ sort_by, sort_order = ENV["sort"].split(":")
40
+ opts[:sort_by] = sort_by.to_sym
41
+ sort_order ||= ((sort_by == "size") ? :desc : :asc)
42
+ opts[:sort_order] = sort_order.to_sym
43
+ end
44
+
45
+ if ENV["mode"]
46
+ raise ArgumentError, "Mode must be either default or full" unless ENV["mode"].in?(%w[default full])
47
+ opts[:mode] = ENV["mode"].to_sym
48
+ end
49
+
50
+ puts CallbackHell::Runner.run(**opts)
51
+ end
52
+
53
+ desc <<~DESC
54
+ Generate validations report for Active Record models.
55
+
56
+ Options:
57
+ format=table|line|github Report format (default: table)
58
+ model=ModelName Filter by model name (optional). Can be
59
+ specified as constant
60
+ name (MessageThread) or file path
61
+ (message_thread, admin/message_thread)
62
+ sort=total|name Sort by score or name (default: score:desc)
63
+ mode=default|full Default mode collects only user-defined validations,
64
+ full includes association- or attribute-generated
65
+ validations
66
+ path=DIR1,DIR2 Additional model directories (comma-separated)
67
+
68
+ Examples:
69
+ # Show all models validations in table format (default)
70
+ rake ch:validations
71
+ DESC
72
+ task validations: :environment do
73
+ opts = {
74
+ kind: :validations,
75
+ format: ENV["format"],
76
+ model: ENV["model"]
77
+ }.compact
78
+
79
+ if ENV["path"]
80
+ opts[:paths] = ENV["path"]&.split(",")&.map { |p| Rails.root.join(p) }
81
+ end
82
+
83
+ if ENV["sort"]
84
+ sort_by, sort_order = ENV["sort"].split(":")
85
+ opts[:sort_by] = sort_by.to_sym
86
+ sort_order ||= ((sort_by == "size") ? :desc : :asc)
87
+ opts[:sort_order] = sort_order.to_sym
88
+ end
89
+
90
+ if ENV["mode"]
91
+ raise ArgumentError, "Mode must be either default or full" unless ENV["mode"].in?(%w[default full])
92
+ opts[:mode] = ENV["mode"].to_sym
93
+ end
94
+
95
+ puts CallbackHell::Runner.run(**opts)
96
+ end
97
+
98
+ desc <<~DESC
99
+ Generate combined report (callbacks and validations) for Active Record models.
100
+
101
+ Options:
102
+ format=table|line|github Report format (default: table)
103
+ model=ModelName Filter by model name (optional). Can be
104
+ specified as constant
105
+ name (MessageThread) or file path
106
+ (message_thread, admin/message_thread)
107
+ sort=total|name Sort by score or name (default: score:desc)
108
+ mode=default|full Default mode collects only user-defined callbacks,
109
+ full includes association-, attribute-, or validation-generated
110
+ callbacks/validations
111
+ path=DIR1,DIR2 Additional model directories (comma-separated)
112
+
113
+ Examples:
114
+ rake ch:report
115
+ DESC
116
+ task report: :environment do
117
+ opts = {
118
+ kind: :report,
119
+ format: ENV["format"],
120
+ model: ENV["model"]
121
+ }.compact
122
+
123
+ if ENV["path"]
124
+ opts[:paths] = ENV["path"]&.split(",")&.map { |p| Rails.root.join(p) }
125
+ end
126
+
127
+ if ENV["sort"]
128
+ sort_by, sort_order = ENV["sort"].split(":")
129
+ opts[:sort_by] = sort_by.to_sym
130
+ sort_order ||= ((sort_by == "size") ? :desc : :asc)
131
+ opts[:sort_order] = sort_order.to_sym
132
+ end
133
+
134
+ if ENV["mode"]
135
+ raise ArgumentError, "Mode must be either default or full" unless ENV["mode"].in?(%w[default full])
136
+ opts[:mode] = ENV["mode"].to_sym
137
+ end
138
+
139
+ puts CallbackHell::Runner.run(**opts)
140
+ end
141
+ end
142
+
143
+ # Top-level convenience task
144
+ desc "Generate callback and validation analysis report"
145
+ task ch: "ch:report"