ahnnotate 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +8 -0
  3. data/.travis.yml +7 -0
  4. data/Gemfile +6 -0
  5. data/Gemfile.lock +55 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +118 -0
  8. data/Rakefile +10 -0
  9. data/ahnnotate.gemspec +33 -0
  10. data/bin/console +14 -0
  11. data/bin/rake +29 -0
  12. data/bin/setup +8 -0
  13. data/exe/ahnnotate +14 -0
  14. data/lib/ahnnotate/cli.rb +116 -0
  15. data/lib/ahnnotate/column.rb +60 -0
  16. data/lib/ahnnotate/config.rb +67 -0
  17. data/lib/ahnnotate/error.rb +8 -0
  18. data/lib/ahnnotate/facet/models/main.rb +49 -0
  19. data/lib/ahnnotate/facet/models/module_node.rb +123 -0
  20. data/lib/ahnnotate/facet/models/processor.rb +91 -0
  21. data/lib/ahnnotate/facet/models/resolve_active_record_models.rb +42 -0
  22. data/lib/ahnnotate/facet/models/resolve_class_relationships.rb +67 -0
  23. data/lib/ahnnotate/facet/models/standin.rb +41 -0
  24. data/lib/ahnnotate/facet/models.rb +10 -0
  25. data/lib/ahnnotate/function/format.rb +36 -0
  26. data/lib/ahnnotate/function/main.rb +32 -0
  27. data/lib/ahnnotate/function/run.rb +24 -0
  28. data/lib/ahnnotate/function/tabularize.rb +53 -0
  29. data/lib/ahnnotate/index.rb +26 -0
  30. data/lib/ahnnotate/options.rb +48 -0
  31. data/lib/ahnnotate/rails.rake +44 -0
  32. data/lib/ahnnotate/railtie.rb +7 -0
  33. data/lib/ahnnotate/table.rb +44 -0
  34. data/lib/ahnnotate/tables.rb +55 -0
  35. data/lib/ahnnotate/version.rb +3 -0
  36. data/lib/ahnnotate/vfs.rb +69 -0
  37. data/lib/ahnnotate/vfs_driver/filesystem.rb +68 -0
  38. data/lib/ahnnotate/vfs_driver/hash.rb +59 -0
  39. data/lib/ahnnotate/vfs_driver/read_only_filesystem.rb +8 -0
  40. data/lib/ahnnotate.rb +32 -0
  41. metadata +202 -0
@@ -0,0 +1,123 @@
1
+ module Ahnnotate
2
+ module Facet
3
+ module Models
4
+ # ModuleNode is named as such since `Class.is_a?(Module) == true`.
5
+ class ModuleNode
6
+ # By including ClassMethods this way, I'm including the methods as
7
+ # instance methods. I'm doing this so that I can compute the table name
8
+ # on per-class basis.
9
+ #
10
+ # It's a bit unfortunate that this class will be used to both (1) keep
11
+ # track of how classes/modules relate to each other and (2) compute the
12
+ # table name.
13
+ include ActiveRecord::ModelSchema::ClassMethods
14
+
15
+ # Named to fit the ModelSchema interface. This is basically `Class#name`
16
+ attr_accessor :name
17
+ # Named to fit the ModelSchema interface. This is the "outer class"
18
+ attr_accessor :parent
19
+ # Named to fit the ModelSchema interface. This is the class that the
20
+ # current class inherits from. This is computed, whereas
21
+ # `claimed_superclass` is what is parsed from the source
22
+ attr_accessor :superclass
23
+ # Named to fit the ModelSchema interface. This is currently unsupported
24
+ attr_accessor :table_name_prefix
25
+
26
+ attr_writer :claimed_superclass
27
+ attr_writer :abstract_class
28
+ attr_writer :is_a_kind_of_activerecord_base
29
+ attr_accessor :explicit_table_name
30
+ attr_accessor :is_active_record_base
31
+ attr_accessor :path
32
+
33
+ def initialize(name,
34
+ parent: nil,
35
+ is_a_kind_of_activerecord_base: false,
36
+ claimed_superclass: nil,
37
+ explicit_table_name: nil,
38
+ abstract_class: nil)
39
+ self.name = name
40
+ self.parent = parent
41
+ self.is_a_kind_of_activerecord_base = is_a_kind_of_activerecord_base
42
+ self.claimed_superclass = claimed_superclass
43
+ self.explicit_table_name = explicit_table_name
44
+ self.abstract_class = abstract_class
45
+ end
46
+
47
+ # Named to fit the ModelSchema interface
48
+ def pluralize_table_names
49
+ true
50
+ end
51
+
52
+ def table_name_suffix
53
+ end
54
+
55
+ def is_a_kind_of_activerecord_base?
56
+ !!@is_a_kind_of_activerecord_base
57
+ end
58
+
59
+ # Named to fit the ModelSchema interface
60
+ def abstract_class?
61
+ !!@abstract_class
62
+ end
63
+
64
+ def claimed_superclass
65
+ @claimed_superclass.to_s
66
+ end
67
+
68
+ # Named to fit the ModelSchema interface. It was originally implemented
69
+ # in ActiveRecord::Inheritance. I've re-implemented it here via the
70
+ # documentation.
71
+ def base_class
72
+ if superclass.is_active_record_base
73
+ return self
74
+ end
75
+
76
+ if superclass.abstract_class?
77
+ return self
78
+ end
79
+
80
+ superclass.base_class
81
+ end
82
+
83
+ # Named to fit the ModelSchema interface. It was originally implemented
84
+ # in ActiveSupport::Introspection
85
+ def parents
86
+ if parent
87
+ [parent, *parent.parent]
88
+ else
89
+ []
90
+ end
91
+ end
92
+
93
+ def class_name
94
+ if @name
95
+ "#{parent.class_name}::#{@name}"
96
+ else
97
+ ""
98
+ end
99
+ end
100
+
101
+ def table_name
102
+ if explicit_table_name
103
+ return @explicit_table_name
104
+ end
105
+
106
+ super
107
+ end
108
+
109
+ def <(other)
110
+ other == ActiveRecord::Base && is_a_kind_of_activerecord_base?
111
+ end
112
+
113
+ def ==(other)
114
+ if is_active_record_base && other == ActiveRecord::Base
115
+ return true
116
+ end
117
+
118
+ super
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,91 @@
1
+ module Ahnnotate
2
+ module Facet
3
+ module Models
4
+ class Processor < Parser::AST::Processor
5
+ def call(content)
6
+ sexp = Parser::CurrentRuby.parse(content)
7
+
8
+ @current_class = ModuleNode.new(nil)
9
+ @classes = [@current_class]
10
+
11
+ process(sexp)
12
+
13
+ @classes.reject { |klass| klass.class_name == "" }
14
+ end
15
+
16
+ def on_class(node)
17
+ @current_class = module_node_create(node, parent: @current_class)
18
+ @classes.push(@current_class)
19
+
20
+ super
21
+
22
+ @current_class = @current_class.parent
23
+ end
24
+
25
+ alias on_module on_class
26
+
27
+ def on_send(node)
28
+ receiver, method_name, assigned_node = *node
29
+
30
+ if receiver == s(:self) && method_name == :table_name=
31
+ table_name = assigned_node.children.last
32
+ @current_class.table_name = table_name
33
+ end
34
+
35
+ if receiver == s(:self) && method_name == :abstract_class=
36
+ abstract_class =
37
+ if assigned_node.type == :true
38
+ true
39
+ else
40
+ false
41
+ end
42
+ @current_class.abstract_class = abstract_class
43
+ end
44
+ end
45
+
46
+ # ignore instance method definitions since method definition bodies
47
+ # can't contain class declarations
48
+ def on_def(_)
49
+ end
50
+
51
+ # ignore class method definitions since method definition bodies can't
52
+ # contain class declarations
53
+ def on_defs(_)
54
+ end
55
+
56
+ private
57
+
58
+ def module_node_create(node, parent:)
59
+ class_node, superclass_node, _body_node = *node
60
+
61
+ if node.type == :module
62
+ superclass_node = nil
63
+ end
64
+
65
+ class_name = resolve_class_name(class_node)
66
+ superclass_name = resolve_class_name(superclass_node)
67
+
68
+ classlike = ModuleNode.new(class_name.to_s)
69
+ classlike.claimed_superclass = superclass_name.to_s
70
+ classlike.parent = parent
71
+
72
+ classlike
73
+ end
74
+
75
+ def resolve_class_name(node)
76
+ outer, name = *node
77
+
78
+ if outer.nil?
79
+ name
80
+ else
81
+ "#{resolve_class_name(outer)}::#{name}"
82
+ end
83
+ end
84
+
85
+ def s(type, *children)
86
+ AST::Node.new(type, children)
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,42 @@
1
+ module Ahnnotate
2
+ module Facet
3
+ module Models
4
+ class ResolveActiveRecordModels
5
+ include ProcParty
6
+
7
+ def call(object_space)
8
+ tree = {}
9
+ object_space.each do |_class_name, extracted_class|
10
+ superclass = extracted_class.superclass
11
+
12
+ tree[superclass] ||= Set.new
13
+ tree[superclass].add(extracted_class)
14
+ end
15
+
16
+ activerecord_family =
17
+ gather_family(object_space["::ActiveRecord::Base"], tree)
18
+
19
+ activerecord_family.each do |individual|
20
+ individual.is_a_kind_of_activerecord_base = true
21
+ end
22
+
23
+ object_space.values - [object_space[""], object_space["::ActiveRecord::Base"]]
24
+ end
25
+
26
+ private
27
+
28
+ def gather_family(node, tree)
29
+ gather_family_helper(node, tree).flatten
30
+ end
31
+
32
+ def gather_family_helper(node, tree)
33
+ if tree[node].nil?
34
+ return node
35
+ end
36
+
37
+ [node] + tree[node].map { |child| gather_family_helper(child, tree) }
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,67 @@
1
+ module Ahnnotate
2
+ module Facet
3
+ module Models
4
+ class ResolveClassRelationships
5
+ include ProcParty
6
+
7
+ def call(extracted_classes)
8
+ object_space =
9
+ extracted_classes
10
+ .map(&method(:self_and_outer_class))
11
+ .flatten
12
+ .compact
13
+ .uniq
14
+ .map { |x| [x.class_name, x] }
15
+ .to_h
16
+
17
+ object_space[""] ||= ModuleNode.new(nil)
18
+
19
+ object_space["::ActiveRecord::Base"] =
20
+ ModuleNode.new(
21
+ "ActiveRecord::Base",
22
+ parent: object_space[""],
23
+ abstract_class: nil
24
+ )
25
+ object_space["::ActiveRecord::Base"].is_active_record_base = true
26
+
27
+ object_space.each do |class_name, extracted_class|
28
+ possible_namespace_levels = class_name.split("::")[1..-2] || []
29
+
30
+ if extracted_class.claimed_superclass == "" || extracted_class.claimed_superclass == nil
31
+ next
32
+ end
33
+
34
+ (possible_namespace_levels.size + 1).times do
35
+ class_uri_parts =
36
+ possible_namespace_levels + [extracted_class.claimed_superclass]
37
+
38
+ class_uri = "::#{class_uri_parts.join("::")}"
39
+
40
+ if object_space[class_uri]
41
+ extracted_class.superclass = object_space[class_uri]
42
+ break
43
+ end
44
+
45
+ possible_namespace_levels.pop
46
+ end
47
+ end
48
+
49
+ object_space
50
+ end
51
+
52
+ private
53
+
54
+ def self_and_outer_class(extracted_class)
55
+ if extracted_class.nil?
56
+ return nil
57
+ end
58
+
59
+ [
60
+ extracted_class,
61
+ self_and_outer_class(extracted_class.parent)
62
+ ]
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,41 @@
1
+ module Ahnnotate
2
+ module Facet
3
+ module Models
4
+ class Standin
5
+ # Lol. It's a little easier in this case to deal with instances here.
6
+ # I'll need to create many instances to recreate the class structure of
7
+ # the models in order to most correctly compute the table name from the
8
+ # model name 🥳
9
+ include ActiveRecord::ModelSchema::ClassMethods
10
+
11
+ # boolean, is this an abstract class
12
+ attr_writer :abstract_class
13
+ # the class that the current class inherits from
14
+ attr_accessor :superclass
15
+ attr_accessor :base_class
16
+ # the outer class
17
+ attr_accessor :parent
18
+
19
+ def initialize(abstract_class:, superclass:, base_class:, parent:, is_active_record_base: false)
20
+ @abstract_class = abstract_class
21
+ @superclass = superclass
22
+ @base_class = base_class
23
+ @parent = parent
24
+ @is_active_record_base = is_active_record_base
25
+ end
26
+
27
+ def abstract_class?
28
+ !!@abstract_class
29
+ end
30
+
31
+ def ==(other)
32
+ if @is_active_record_base && other == ActiveRecord::Base
33
+ return true
34
+ end
35
+
36
+ super
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,10 @@
1
+ module Ahnnotate
2
+ module Facet
3
+ module Models
4
+ def self.add(config, tables, vfs)
5
+ runner = Main.new(config, tables, vfs)
6
+ runner.call
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,36 @@
1
+ module Ahnnotate
2
+ module Function
3
+ class Format
4
+ attr_reader :comment
5
+
6
+ def initialize(comment:)
7
+ @comment = comment
8
+ end
9
+
10
+ def call(table, content)
11
+ table.string(comment: comment) + "\n" + strip_schema(content)
12
+ end
13
+
14
+ private
15
+
16
+ def strip_schema(content)
17
+ matches = pattern.match(content)
18
+
19
+ if matches
20
+ matches["post"]
21
+ else
22
+ content
23
+ end
24
+ end
25
+
26
+ def pattern
27
+ @pattern ||=
28
+ begin
29
+ newline = /\r?\n\r?/
30
+
31
+ /\A#{comment}\s==\sSchema\sInfo#{newline}?(?:^#{comment}[^\n]*$#{newline})*#{newline}(?<post>.*)/m
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,32 @@
1
+ module Ahnnotate
2
+ module Function
3
+ class Main
4
+ def initialize(root, options, config)
5
+ @root = root
6
+ @options = options
7
+ @config = config
8
+ end
9
+
10
+ def call
11
+ if @config["boot"]
12
+ eval @config["boot"]
13
+ end
14
+
15
+ vfs = Vfs.new(vfs_driver)
16
+
17
+ runner = Run.new(@config, vfs)
18
+ runner.call
19
+ end
20
+
21
+ private
22
+
23
+ def vfs_driver
24
+ if @options.fix?
25
+ VfsDriver::Filesystem.new(root: @root)
26
+ else
27
+ VfsDriver::ReadOnlyFilesystem.new(root: @root)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,24 @@
1
+ module Ahnnotate
2
+ module Function
3
+ class Run
4
+ def initialize(config, vfs)
5
+ @config = config
6
+ @vfs = vfs
7
+ end
8
+
9
+ def call
10
+ Facet::Models.add(@config, tables_hash, @vfs)
11
+ end
12
+
13
+ private
14
+
15
+ def tables_hash
16
+ @tables_hash = tables.to_h
17
+ end
18
+
19
+ def tables
20
+ @tables ||= Tables.new
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,53 @@
1
+ module Ahnnotate
2
+ module Function
3
+ class Tabularize
4
+ def initialize(prefix:, cell_divider:)
5
+ @prefix = prefix
6
+ @cell_divider = cell_divider
7
+ end
8
+
9
+ def call(data, column_names)
10
+ output = StringIO.new
11
+ minimum_column_lengths = Hash.new { 0 }
12
+
13
+ rows = data.map do |row|
14
+ row_hash = {}
15
+
16
+ column_names.each do |c|
17
+ value = row.public_send(c).to_s
18
+
19
+ row_hash[c] = value
20
+
21
+ if value.size > minimum_column_lengths[c]
22
+ minimum_column_lengths[c] = value.size
23
+ end
24
+ end
25
+
26
+ row_hash
27
+ end
28
+
29
+ rows.each do |row|
30
+ # Note: minimum_column_lengths shouldn't include any of the columns
31
+ # with a length of zero since they were never explicitly set (to 0)
32
+ minimum_column_lengths.each.with_index do |(column_name, column_max_length), index|
33
+ if index == 0
34
+ output.print(@prefix)
35
+ end
36
+
37
+ if_rightmost_column = index + 1 == minimum_column_lengths.size
38
+
39
+ if if_rightmost_column
40
+ output.puts "#{row[column_name]}"
41
+ else
42
+ column_length = row[column_name].size
43
+ spaces_length = column_max_length - column_length
44
+ output.print "#{row[column_name]}#{" " * spaces_length}#{@cell_divider}"
45
+ end
46
+ end
47
+ end
48
+
49
+ output.string.gsub(/ +$/, "")
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,26 @@
1
+ module Ahnnotate
2
+ class Index
3
+ attr_accessor :name
4
+ attr_accessor :columns
5
+ attr_accessor :comment
6
+ attr_accessor :unique
7
+
8
+ def initialize(**args)
9
+ args.each do |key, value|
10
+ public_send("#{key}=", value)
11
+ end
12
+ end
13
+
14
+ def presentable_columns
15
+ "(#{columns.join(", ")})"
16
+ end
17
+
18
+ def presentable_unique
19
+ if unique
20
+ "UNIQUE"
21
+ else
22
+ ""
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,48 @@
1
+ module Ahnnotate
2
+ class Options
3
+ def self.attribute_names
4
+ @attribute_names ||= []
5
+ end
6
+
7
+ def self.attr_writer(*names)
8
+ attribute_names.push(*names)
9
+ super
10
+ end
11
+
12
+ def self.attr_question(*names)
13
+ names.each do |name|
14
+ attr_writer(name)
15
+
16
+ define_method("#{name}?") do
17
+ !!instance_variable_get("@#{name}")
18
+ end
19
+ end
20
+ end
21
+
22
+ attr_question :exit
23
+ attr_question :fix
24
+
25
+ def initialize(**args)
26
+ args.each do |key, value|
27
+ public_send("#{key}=", value)
28
+ end
29
+ end
30
+
31
+ def to_s
32
+ output = StringIO.new
33
+
34
+ output.puts "🧐 options:"
35
+ self.class.attribute_names.each do |attribute_name|
36
+ output.print "🧐 #{attribute_name}: "
37
+
38
+ if instance_variable_defined?("@#{attribute_name}")
39
+ output.puts "undefined"
40
+ else
41
+ output.puts instance_variable_get("@#{attribute_name}").inspect
42
+ end
43
+ end
44
+
45
+ output.string
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,44 @@
1
+ namespace :db do
2
+ task :migrate do
3
+ $rake_ahnnotate_config ||= Ahnnotate::Config.load(root: Rails.root)
4
+
5
+ if $rake_ahnnotate_config["rake_db_autorun"]
6
+ Rake::Task["ahnnotate:all"].reenable
7
+ Rake::Task["ahnnotate:all"].invoke
8
+ end
9
+ end
10
+
11
+ task :rollback do
12
+ $rake_ahnnotate_config ||= Ahnnotate::Config.load(root: Rails.root)
13
+
14
+ if $rake_ahnnotate_config["rake_db_autorun"]
15
+ Rake::Task["ahnnotate:all"].reenable
16
+ Rake::Task["ahnnotate:all"].invoke
17
+ end
18
+ end
19
+ end
20
+
21
+ namespace :ahnnotate do
22
+ desc "Run ahnnotate"
23
+ task :all do
24
+ require "ahnnotate/cli"
25
+ require "shellwords"
26
+
27
+ # This should either be `rails` or `rake` (since newer versions of Rails
28
+ # can call rake tasks with either executable)
29
+ exe_name = File.basename($0)
30
+
31
+ argv = ENV.fetch("COMMENTATE", "--fix")
32
+ argv = Shellwords.split(argv)
33
+
34
+ puts "Commentating models..."
35
+
36
+ cli = Ahnnotate::Cli.new(name: "#{exe_name} ahnnotate")
37
+ cli.run(argv, $rake_ahnnotate_config)
38
+
39
+ puts "Done!"
40
+ end
41
+ end
42
+
43
+ desc "Run rake task `ahnnotate:all`"
44
+ task ahnnotate: "ahnnotate:all"
@@ -0,0 +1,7 @@
1
+ module Ahnnotate
2
+ class Railtie < Rails::Railtie
3
+ rake_tasks do
4
+ load "ahnnotate/rails.rake"
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,44 @@
1
+ module Ahnnotate
2
+ class Table
3
+ attr_accessor :name
4
+ attr_accessor :columns
5
+ attr_accessor :indexes
6
+
7
+ def initialize(**args)
8
+ args.each do |key, value|
9
+ public_send("#{key}=", value)
10
+ end
11
+ end
12
+
13
+ def string(comment:)
14
+ tabularizer =
15
+ Function::Tabularize.new(
16
+ prefix: "#{comment} ",
17
+ cell_divider: " "
18
+ )
19
+
20
+ output = StringIO.new
21
+ output.puts "#{comment} == Schema Info"
22
+ output.puts comment
23
+ output.puts "#{comment} Table name: #{@name}"
24
+ output.puts comment
25
+ output.print tabularizer.call(columns, [:name, :type, :details])
26
+ output.puts comment
27
+
28
+ if indexes.any?
29
+ output.puts "#{comment} Indexes:"
30
+ output.puts comment
31
+ output.print tabularizer.call(indexes, [:name, :presentable_columns, :presentable_unique, :comment])
32
+ output.puts comment
33
+ end
34
+
35
+ output.string
36
+ end
37
+
38
+ private
39
+
40
+ def longest_column_name_length
41
+ @longest_column_name_length ||= @columns.map(&:name).map(&:size).max
42
+ end
43
+ end
44
+ end