attribute-stats 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 02a8733b9a9fee7f8391d8855ee3d1a9106f1196
4
+ data.tar.gz: 2b07627c92d58fa5ac459ed1fbb8ab27493d1390
5
+ SHA512:
6
+ metadata.gz: bcdfba6645eea57014decabf2e8ba5ee935cf6fa3285c545622a59b8e2c13c87512b628824d551b0be8bfc7f95e3fa113043facc720025084371f2a9676bdac0
7
+ data.tar.gz: f6f439d0aeac841c04846708bdd90a40550afeda98710a1d497f8f5a0ce9968f27a3f304c8ef24e8d15dd39647a9b45a2c51251322bee2459c7290a28ac280cb
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015 Steve Hodges
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,122 @@
1
+ Attribute Stats gives you insight into which persisted attributes are actually used in your Rails models. Whether you're joining an existing project or have been using it for years, get a quick look at the landscape of the database.
2
+
3
+ It helps you find smells in your project:
4
+ - Attributes which have never had data set (indicates a potentially forgotten attribute)
5
+ - Tables which haven't been updated for X years (indicates a potentially unused or legacy model)
6
+ - Attributes used by very few objects in your table (is this being used? Should it be an attribute?)
7
+ - Attributes which are all set to the default value (if they're all default, is this really being used?)
8
+
9
+ ## Installation
10
+
11
+ [![Gem Version](https://badge.fury.io/rb/attribute-stats.svg)](https://badge.fury.io/rb/attribute-stats)
12
+
13
+ Add `gem 'attribute-stats'` to your Gemfile, bundle, and follow the Usage instructions below.
14
+
15
+ View [attribute-stats on RubyGems](https://rubygems.org/gems/attribute-stats) for more info, or just read the [gemspec](attribute-stats.gemspec).
16
+
17
+ ## Usage
18
+
19
+ ### Programmatic
20
+
21
+ You can use `AttributeStats` from within Rails (or a Rails console):
22
+
23
+ irb:1> stats = AttributeStats::StatsGenerator.new()
24
+ irb:2> stats.attribute_usage
25
+ => # a list of your attributes and what % of records have a value set
26
+ irb:3> stats.unused_attributes
27
+ => # a list of your attributes which have no value set in the database
28
+ irb:4> stats.dormant_tables
29
+ => # a list of your tables which have not been updated in awhile
30
+ irb:5> stats.migration
31
+ => # sample migration up/down to remove your unused attributes
32
+ irb:6> stats.set_formatter :json
33
+ irb:7> stats.migration # returns json instead of a hash
34
+
35
+ ### Rake Tasks
36
+
37
+ Rake tasks are available once you've installed the gem in your Gemfile and bundled:
38
+
39
+ * rake db:stats:dormant_tables
40
+ * rake db:stats:attribute_usage
41
+ * rake db:stats:unused_attributes
42
+ * rake attribute-stats:generate_migration
43
+
44
+ Each allows you to change the output to JSON if you'd like to pipe it into another application.
45
+
46
+ ---
47
+
48
+ #### rake db:stats:unused_attributes
49
+ Lists all attributes which are unused (have a nil or empty value).
50
+
51
+ **Argument Options:**
52
+ 1. consider_defaults_unused: true or false (default: false). This option considers attributes set to the databse default value to be unused.
53
+ 2. format: tabular, json (default: tabular)
54
+ 3. verbose: true, false (default: true)
55
+
56
+ i.e. `rake db:stats:unused_attributes[true,json,false]`
57
+
58
+ ---
59
+
60
+ #### rake db:stats:attribute_usage
61
+ Lists usage statistics for all attributes (count and percent which are unused). Note: this does a full scan of your tables, so it will be slow for tables with a lot of data.
62
+
63
+ **Argument Options:**
64
+ 1. consider_defaults_unused: true or false (default: false). This option considers attributes set to the databse default value to be unused.
65
+ 2. format: tabular, json (default: tabular)
66
+ 3. verbose: true, false (default: true)
67
+
68
+ i.e. `rake db:stats:attribute_usage[true,json,false]`
69
+
70
+ ---
71
+
72
+ #### rake db:stats:dormant_tables
73
+ Lists tables which have not been updated in the past X months (default is 3.months.ago)
74
+
75
+ **Argument Options:**
76
+ 1. date_expression: A Rails date expression, i.e. '3.months.ago'. Tables with updated_at values after that date are not considered dormant.
77
+ 2. format: tabular, json (default: tabular)
78
+ 3. verbose: true, false (default: true)
79
+
80
+ i.e. `rake db:stats:dormant_tables['1.year.ago',json,false]`
81
+
82
+ ---
83
+
84
+ #### rake attribute-stats:migration
85
+ Generates a sample migration syntax to remove all unused attributes. (This is just output, not saved to disk. See the TODO below.)
86
+
87
+ **Argument Options:**
88
+ 1. consider_defaults_unused: true or false (default: false). This option considers attributes set to the databse default value to be unused.
89
+
90
+ *TODO: actually save that generated file to the db/migrate path of the host Rails app.*
91
+
92
+ ## Caveats
93
+
94
+ The gem does not support:
95
+
96
+ 1. Detection of unset encrypted attributes. `attribute-stats` works by searching for empty values using SQL. If you are encrypting data before storing it in the database, a value of `nil` might have a value in the database which, decrypted, equals `nil`.
97
+ 1. Custom table names. If you are defining a model called House, and configure the model to use the table 'domiciles' instead of 'houses', `attribute-stats` skips analysis of the table. *(TODO: fix)*
98
+ 1. Tables not associated with a model. If your database has tables which do not correspond with a model (see point #2), `attribute-stats` skips analysis of that table. *(TODO: fix)*
99
+
100
+ ## Compatability
101
+
102
+ The gem is tested and works with Rails version 4.2 through 5.2, with `mysql2`, `sqlite3`, and `postgresql` database adapters. You can view all tested dependency sets in [Appraisals](Appraisals)
103
+
104
+ *Due to changes in ActiveRecord between 4.1 and 4.2, the gem breaks in versions below 4.2. In a future version of `attribute-stats`, I intend to add support to previous versions of Rails (at least back to 4.0).*
105
+
106
+ ## Testing the gem
107
+
108
+ To test the gem against the current version of Rails (in [Gemfile.lock](Gemfile.lock)) and sqlite:
109
+
110
+ 1. `bundle install`
111
+ 2. `bundle exec rspec`
112
+
113
+ Or, you can run tests for all supported Rails versions and supported databases:
114
+
115
+ 1. Add your database config at `spec/database.yml` pointing to empty local mysql and postgres test databases (see [database.yml.sample](spec/database.yml.sample))
116
+ 1. `gem install appraisal`
117
+ 1. `bundle exec appraisal install` *(this Generates gemfiles for all permutations of our dependencies, so you'll see lots of bundler output))*
118
+ 1. `bundle exec appraisal rspec`. *(This runs rspec for each dependency permutation. If one fails, appraisal exits immediately and does not test permutations it hasn't gotten to yet. Tests are not considered passing until all 12 permutations are passing)*
119
+
120
+ If you only want to test a certain dependency set, such as Rails 5.2 for MySQL: `bundle exec appraisals rails-5-2-mysql`. In this case, you would *not* need to configure postgresql in your database.yml, nor have postgres running on your machine.
121
+
122
+ You can view all available dependency sets in [Appraisals](Appraisals)
@@ -0,0 +1 @@
1
+ Dir[File.join(__dir__, '**', '*.rb')].each{|f| require f }
@@ -0,0 +1,9 @@
1
+ require 'rails'
2
+
3
+ module AttributeStats
4
+ class Railtie < Rails::Railtie
5
+ rake_tasks do
6
+ Dir[File.join(__dir__, 'tasks/*.rake')].each { |f| load f }
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,15 @@
1
+ require 'attribute-stats'
2
+ namespace :db do
3
+ namespace :stats do
4
+ desc "Usage statistics for all attributes [options CONSIDER_DEFAULTS_UNUSED: false, FORMAT: json, tabular, VERBOSE: false]"
5
+ task :attribute_usage, [:consider_defaults_unused,:format,:verbose] => :environment do |task, args|
6
+ args.with_defaults(consider_defaults_unused: 'false', format: 'tabular', verbose: 'true')
7
+ options = {
8
+ consider_defaults_unused: args[:consider_defaults_unused].downcase != 'false',
9
+ formatter: args[:format],
10
+ verbose: args[:verbose].downcase != 'false',
11
+ source: :cli }
12
+ AttributeStats::StatsGenerator.new(options).attribute_usage
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ require 'attribute-stats'
2
+ namespace :db do
3
+ namespace :stats do
4
+ desc "View tables not updated in the past months [options DATE_EXPRESSION: X.months.ago, FORMAT: json, tabular, VERBOSE: false]"
5
+ task :dormant_tables, [:date_expression,:format,:verbose] => :environment do |task, args|
6
+ args.with_defaults(date_expression: '3.months.ago', format: 'tabular', verbose: 'true')
7
+ options = {
8
+ dormant_table_age: args[:date_expression],
9
+ formatter: args[:format],
10
+ verbose: args[:verbose] != 'false',
11
+ source: :cli }
12
+ AttributeStats::StatsGenerator.new(options).dormant_tables
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ require 'attribute-stats'
2
+ namespace :'attribute-stats' do
3
+ desc "Generate sample migration to remove unused attributes (and optionally those using default values) [options CONSIDER_DEFAULTS_UNUSED: false, FORMAT: json, tabular, VERBOSE: false]"
4
+ task :migration, [:consider_defaults_unused,:format,:verbose] => :environment do |task, args|
5
+ args.with_defaults(consider_defaults_unused: 'false', format: 'tabular', verbose: 'true')
6
+ options = {
7
+ consider_defaults_unused: args[:consider_defaults_unused].downcase != 'false',
8
+ formatter: args[:format],
9
+ verbose: args[:verbose].downcase != 'false',
10
+ source: :cli }
11
+ AttributeStats::StatsGenerator.new(options).migration
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ require 'attribute-stats'
2
+ namespace :db do
3
+ namespace :stats do
4
+ desc "View attributes with no data in the database (and optionally those using default values) [options CONSIDER_DEFAULTS_UNUSED: false, FORMAT: json, tabular, VERBOSE: false]"
5
+ task :unused_attributes, [:consider_defaults_unused,:format,:verbose] => :environment do |task, args|
6
+ args.with_defaults(consider_defaults_unused: 'false', format: 'tabular', verbose: 'true')
7
+ options = {
8
+ consider_defaults_unused: args[:consider_defaults_unused].downcase != 'false',
9
+ formatter: args[:format],
10
+ verbose: args[:verbose].downcase != 'false',
11
+ source: :cli }
12
+ AttributeStats::StatsGenerator.new(options).unused_attributes
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,3 @@
1
+ module AttributeStats
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,23 @@
1
+ module AttributeStats
2
+ class AttributeInfo
3
+ attr_reader :count, :usage_percent, :name, :empty
4
+
5
+ def initialize(attribute_name)
6
+ @name = attribute_name
7
+ end
8
+
9
+ def set_usage(record_count, table_total_record_count)
10
+ @count = record_count
11
+ @usage_percent = (record_count / table_total_record_count.to_f).round(5)
12
+ @empty = record_count == 0
13
+ end
14
+
15
+ def set_emptyness(is_empty)
16
+ @empty = is_empty
17
+ end
18
+
19
+ def empty?
20
+ @empty
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,50 @@
1
+ module AttributeStats
2
+ class TableData
3
+ attr_reader :count, :table_name, :attributes, :name, :last_updated, :model
4
+ def initialize(model)
5
+ @model = model
6
+ @name = model.name
7
+ @table_name = model.table_name
8
+ @attributes = []
9
+ @dormant = false
10
+ @count = 0
11
+ @column_names = model.columns.map(&:name)
12
+ end
13
+
14
+ def column_names
15
+ @column_names
16
+ end
17
+
18
+ def make_dormant(last_updated)
19
+ @dormant = true
20
+ last_updated = last_updated.to_datetime unless last_updated.nil?
21
+ @last_updated = last_updated
22
+ end
23
+
24
+ def dormant?
25
+ @dormant
26
+ end
27
+
28
+ def set_count(total_record_count)
29
+ @count = total_record_count
30
+ end
31
+
32
+ def attribute_for(attribute_name)
33
+ unless attribute = attributes.detect{|a| a.name == attribute_name }
34
+ attribute = AttributeInfo.new(attribute_name)
35
+ @attributes << attribute
36
+ end
37
+ attribute
38
+ end
39
+
40
+ def unused_attributes
41
+ @unused_attributes ||= begin
42
+ attrs = []
43
+ @attributes.each do |attribute|
44
+ attrs << attribute.name if attribute.empty?
45
+ end
46
+ attrs
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,49 @@
1
+ module AttributeStats
2
+ class HashFormatter
3
+
4
+ def initialize(options: {}, table_info: {}, migration: [])
5
+ @options, @table_info, @migration = options, table_info, migration
6
+ end
7
+
8
+ def output_all_attributes
9
+ output = []
10
+ @table_info.each do |table_info|
11
+ table_info.attributes.each do |attribute|
12
+ output << {
13
+ model: table_info.name,
14
+ attribute: attribute.name,
15
+ count: attribute.count,
16
+ usage_percent: attribute.usage_percent
17
+ }
18
+ end
19
+ end
20
+ output
21
+ end
22
+
23
+ def output_dormant_tables
24
+ output = []
25
+ @table_info.each do |table_info|
26
+ output << table_info.table_name if table_info.dormant?
27
+ end
28
+ output
29
+ end
30
+
31
+ def output_unused_attributes
32
+ output = []
33
+ @table_info.each do |table_info|
34
+ table_info.attributes.sort_by(&:name).each do |attribute|
35
+ next unless attribute.empty?
36
+ output << {
37
+ model: table_info.name,
38
+ attribute: attribute.name
39
+ }
40
+ end
41
+ end
42
+ output
43
+ end
44
+
45
+ def output_migration
46
+ @migration.data
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,20 @@
1
+ module AttributeStats
2
+ class JSONFormatter < HashFormatter
3
+
4
+ def output_all_attributes
5
+ super.to_json
6
+ end
7
+
8
+ def output_dormant_tables
9
+ super.to_json
10
+ end
11
+
12
+ def output_unused_attributes
13
+ super.to_json
14
+ end
15
+
16
+ def output_migration
17
+ super.to_json
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,124 @@
1
+ require 'hirb'
2
+
3
+ module AttributeStats
4
+ class TabularFormatter
5
+
6
+ def initialize(options: {}, table_info: {}, migration: nil)
7
+ @options, @table_info, @migration = options, table_info, migration
8
+ @buffer = ''
9
+ end
10
+
11
+ def output_all_attributes
12
+ attribute_results = []
13
+ @table_info.each do |table_info|
14
+ table_info.attributes.sort_by(&:usage_percent).each do |attribute|
15
+ attribute_results << {
16
+ model: table_info.name,
17
+ set_count: attribute.count,
18
+ set_percent: (attribute.usage_percent * 100).round(1).to_s.rjust(5),
19
+ attribute_name: attribute.name
20
+ }
21
+ end
22
+ end
23
+ if attribute_results.empty?
24
+ puts "No attributes found"
25
+ else
26
+ print_table attribute_results, title: 'Attributes Utilization'
27
+ end
28
+ @buffer
29
+ end
30
+
31
+ def output_dormant_tables
32
+ output = []
33
+ @table_info.each do |table_info|
34
+ date = table_info.last_updated
35
+ output << {
36
+ model: table_info.name,
37
+ last_updated: date.nil? ? 'Never Updated' : table_info.last_updated.to_date.to_s(:long)
38
+ } if table_info.dormant?
39
+ end
40
+ if output.empty?
41
+ puts "No dormant tables"
42
+ else
43
+ print_table output, title: ['Dormant Tables', "No updated_ats after #{@options[:dormant_table_age].to_date.to_s(:long)}"]
44
+ end
45
+ @buffer
46
+ end
47
+
48
+ def output_unused_attributes
49
+ output = []
50
+ @table_info.each do |table_info|
51
+ table_info.attributes.sort_by(&:name).each do |attribute|
52
+ output << {
53
+ model: table_info.name,
54
+ attribute_name: attribute.name
55
+ } if attribute.empty?
56
+ end
57
+ end
58
+ unused_values = ['Nil', 'Empty']
59
+ unused_values << 'Default Values' if @options[:consider_defaults_unused]
60
+ if output.empty?
61
+ puts "No unused attributes (good for you!)"
62
+ else
63
+ print_table output, title: ['Unused Attributes', unused_values.join(', ')]
64
+ end
65
+ @buffer
66
+ end
67
+
68
+ def output_migration
69
+ print_section_header "Migration Syntax to delete unused attributes"
70
+ @migration.to_migration_file
71
+ end
72
+
73
+ private
74
+
75
+ def puts(*values)
76
+ @buffer << values.join("\n")
77
+ @buffer << "\n"
78
+ end
79
+
80
+ def print_table_header(table_info)
81
+ line = '-'*(table_info.name.length+4)
82
+ puts '', line, " #{table_info.name} ", line
83
+ end
84
+
85
+ def print_section_header(text)
86
+ line = '#'*(text.length+4)
87
+ puts '', line, " #{text} ", line
88
+ end
89
+
90
+ def print_table(data, title: nil)
91
+ column_names = data.first.keys
92
+ output = Hirb::Helpers::AutoTable.render(data,
93
+ fields: header_order(column_names),
94
+ headers: formatted_headers(column_names))
95
+ output_table_title(title, output) unless title.blank?
96
+ puts output
97
+ end
98
+
99
+ def output_table_title(title_rows, table_output)
100
+ header_line = table_output.split(/\n/).first
101
+ puts '', header_line
102
+
103
+ Array(title_rows).each do |title|
104
+ if header_line.length > title.length+4
105
+ puts "| #{title.center(header_line.length - 4)} |"
106
+ else
107
+ puts "| #{title}"
108
+ end
109
+ end
110
+ end
111
+
112
+ def header_order(column_names)
113
+ [:model, :table_name, :attribute_name,
114
+ :last_updated, :set_count, :set_percent] & column_names
115
+ end
116
+
117
+ def formatted_headers(column_names)
118
+ column_names.inject({}) do |hash, name|
119
+ hash[name] = name.to_s.titleize
120
+ hash
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,151 @@
1
+ module AttributeStats
2
+ class GenerateMigration
3
+
4
+ def initialize(table_info: [], options: {})
5
+ @table_info, @options = table_info, options
6
+ @up_buffer = []
7
+ @down_buffer = []
8
+ @table_info.each do |table_info|
9
+ add_migrations_for_table_to_buffer(table_info)
10
+ end
11
+ end
12
+
13
+ def data
14
+ {
15
+ up_commands: @up_buffer,
16
+ down_commands: @down_buffer,
17
+ warning: warning
18
+ }
19
+ end
20
+
21
+ def to_migration_file
22
+ output = "class RemoveUnusedAttributes < ActiveRecord::Migration"
23
+ output << "[#{Rails::VERSION::STRING}]" if Rails::VERSION::MAJOR >= 5
24
+ output << "\n#{warning_to_width}"
25
+ output << <<-OUTPUT
26
+ def up
27
+ # #{@up_buffer.join("\n # ")}
28
+ end
29
+
30
+ def down
31
+ # #{@down_buffer.join("\n # ")}
32
+ end
33
+ end
34
+ OUTPUT
35
+ output
36
+ end
37
+
38
+ def to_json
39
+ {
40
+ up_commands: @up_buffer,
41
+ down_commands: @down_buffer,
42
+ warning: warning
43
+ }
44
+ end
45
+
46
+ private
47
+
48
+ # This is a modified version of the ActiveRecord::SchemaDumper#table method.
49
+ # Unfortunately, Rails generators cannot accept multiple tables in a single RemoveXXXFromXXX
50
+ # migration (maybe I should submit a PR to Rails?)
51
+ # ActiveRecord::SchemaDumper cannot be reused for this case, so I had to extract it.
52
+ def add_migrations_for_table_to_buffer(table_info)
53
+ @connection ||= ActiveRecord::Base.connection
54
+ @types ||= @connection.native_database_types
55
+
56
+ column_specs = []
57
+ @connection.columns(table_info.table_name).each do |column|
58
+ next unless column.name.to_s.in? table_info.unused_attributes
59
+ column_specs << column_spec_for(column)
60
+ end
61
+
62
+ quoted_table_name = @connection.quote_table_name(table_info.table_name)
63
+ column_specs.each do |colspec|
64
+ down_syntax = [quoted_table_name, colspec[:name], ":#{colspec[:type]}", colspec[:options].presence].compact.join(', ')
65
+ @down_buffer << "add_column #{down_syntax}"
66
+ @up_buffer << "remove_column #{quoted_table_name}, #{colspec[:name]}"
67
+ end
68
+ end
69
+
70
+ def warning_to_width(target_width=78)
71
+ line_start = " # "
72
+ output = ""
73
+ warning.split("\n").each do |line|
74
+ line_buffer = "\n#{line_start}"
75
+ line.split(/ +/).each do |word|
76
+ if word.length + line_buffer.length > target_width
77
+ output << line_buffer
78
+ line_buffer = "\n#{line_start}"
79
+ end
80
+ line_buffer << "#{word} "
81
+ end
82
+ output << line_buffer+"\n#{line_start}"
83
+ end
84
+ output + "\n"
85
+ end
86
+
87
+ def column_spec_for(column)
88
+ # Rails 5.2
89
+ # SchemaDumper returns an array like: [:text, {:limit=>"255"}]
90
+ # Rails 5.1
91
+ # connection.column_spec returns an array like: [:text, {:limit=>"255"}]
92
+ # prior to Rails 5.1
93
+ # connection.column_spec returns a hash like: {:name=>"\"line_2\"", :type=>"string", :limit=>"limit: 255"}
94
+ if Rails::VERSION::MAJOR >= 5 && Rails::VERSION::MINOR == 2
95
+ column_spec_for_5_2(column)
96
+ elsif Rails::VERSION::MAJOR >= 5 && Rails::VERSION::MINOR >= 1
97
+ column_spec_for_5_1(column)
98
+ elsif Rails::VERSION::MAJOR >= 5 && Rails::VERSION::MINOR == 0
99
+ column_spec_for_5_0(column)
100
+ else
101
+ column_spec_for_4(column)
102
+ end
103
+ end
104
+
105
+ def column_spec_for_5_2(column)
106
+ # Unfortunately Rails has moved this logic from Connection to a private method on SchemaDumper...
107
+ # Rails really doesn't want people using this stuff!
108
+ colspec = ActiveRecord::ConnectionAdapters::SchemaDumper
109
+ .send(:new, @connection, {})
110
+ .send(:column_spec, column)
111
+ spec = {
112
+ name: @connection.quote_column_name(column.name),
113
+ type: colspec[0]
114
+ }
115
+ spec[:options] = colspec[1].map{|k,v| "#{k}: #{v}"}.join(', ') if colspec[1].is_a?(Hash)
116
+ spec
117
+ end
118
+
119
+
120
+ def column_spec_for_5_1(column)
121
+ colspec = @connection.column_spec(column)
122
+ spec = {
123
+ name: @connection.quote_column_name(column.name),
124
+ type: colspec[0],
125
+ }
126
+ spec[:options] = colspec[1].map{|k,v| "#{k}: #{v}"}.join(', ') if colspec[1].is_a?(Hash)
127
+ spec
128
+ end
129
+
130
+ def column_spec_for_5_0(column)
131
+ colspec = @connection.column_spec(column)
132
+ colspec[:options] = colspec.except(:type, :name).values.join(', ')
133
+ colspec
134
+ end
135
+
136
+ def column_spec_for_4(column)
137
+ colspec = @connection.column_spec(column, @types)
138
+ colspec[:options] = colspec.except(:type, :name).values.join(', ')
139
+ colspec
140
+ end
141
+
142
+ def warning
143
+ <<-WARNING
144
+ BUYER BEWARE/CAVEAT EMPTOR. Review this migration carefully!
145
+ This migration code was autogenerated by analyzing database columns which are empty at the time the script was executed.
146
+ IT IS YOUR RESPONSIBILITY to verify that these attributes are indeed unused in your application before running this migration. For your protection, the generated commands are COMMENTED OUT.
147
+ If you were to uncomment the below commands and migrate your database, it is extremely likely that your application will break due to references to the removed attributes in your application code.
148
+ WARNING
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,93 @@
1
+ require_relative 'terminal'
2
+ module AttributeStats
3
+ class SetAttributeStats
4
+ include Terminal
5
+
6
+ def initialize(table_info: [], options: {})
7
+ @table_info, @options = table_info, options
8
+ end
9
+
10
+ def set_counts
11
+ @query_method = :count
12
+ set_stats
13
+ end
14
+
15
+ def set_empty
16
+ @query_method = :exists?
17
+ set_stats
18
+ end
19
+
20
+ def set_stats
21
+ @table_info.each_with_index do |t,index|
22
+ @table_count = index
23
+ fetch_table_stats(t)
24
+ end
25
+ erase_line if @options[:verbose]
26
+ true
27
+ end
28
+
29
+ private
30
+
31
+ def fetch_table_stats(table)
32
+ @current_table = table
33
+ @current_model = table.model
34
+ set_table_count
35
+ print("Scanning #{in_color(table.name,@table_count)} ") if @options[:verbose]
36
+ return if @current_table.count == 0
37
+ set_attribute_counts
38
+ erase_line if @options[:verbose]
39
+ end
40
+
41
+ def set_table_count
42
+ @current_table.set_count(@current_model.all.count)
43
+ end
44
+
45
+ def set_attribute_counts
46
+ attribs = @current_model.columns.dup
47
+ attribs.reject!{|c| ["id","created_at","updated_at"].include? c.name } unless @options[:defaults]
48
+
49
+ attribs.each do |column_specification|
50
+ attribute_info = @current_table.attribute_for(column_specification.name)
51
+ if @query_method == :count
52
+ next if attribute_info.count
53
+ record_count = attribute_query(column_specification).count
54
+ empty = record_count == 0
55
+ attribute_info.set_usage record_count, @current_table.count
56
+ else
57
+ next unless attribute_info.empty.nil?
58
+ empty = !attribute_query(column_specification).exists?
59
+ attribute_info.set_emptyness empty
60
+ end
61
+ next unless @options[:verbose]
62
+ print empty ? red('E') : green('.')
63
+ end
64
+ end
65
+
66
+ def attribute_query(column_specification)
67
+ query = @current_model.where.not(column_specification.name => nil)
68
+ query = safe_add_not_empty_query(query, column_specification)
69
+ query = add_condition_for_default_value(query, column_specification) if @options[:consider_defaults_unused]
70
+ query
71
+ end
72
+
73
+ def add_condition_for_default_value(query, attrib)
74
+ default_value = @current_model.columns_hash[attrib.name].default
75
+ return query if default_value.blank?
76
+ query.where.not(attrib.name => default_value)
77
+ end
78
+
79
+ # In addition to normal string attributes, this allows us to query serialized columns.
80
+ # If you serialize an attribute as an array or hash or whatever, Rails still stores empty
81
+ # array/hashes as an empty string in the db. Using the more rails-y
82
+ # where.not(serialized_attribute_name => '')
83
+ # results in a SerializationTypeMismatch error. So we construct the subquery by hand.
84
+ def safe_add_not_empty_query(query, column_specification)
85
+ return query unless ([:text, :string].include?(column_specification.type))
86
+ query.where.not("#{quoted_column_name(column_specification.name)} = ''")
87
+ end
88
+
89
+ def quoted_column_name(column_name)
90
+ ActiveRecord::Base.connection.quote_column_name column_name
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,39 @@
1
+ require_relative 'terminal'
2
+
3
+ module AttributeStats
4
+ class SetDormantTables
5
+ include Terminal
6
+
7
+ def initialize(table_info: [], options: {})
8
+ @table_info, @options = table_info, options
9
+ end
10
+
11
+ def call
12
+ @table_info.each_with_index do |table_data,index|
13
+ @table_count = index
14
+ set_dormant_table(table_data)
15
+ end
16
+ erase_line if @options[:verbose]
17
+ true
18
+ end
19
+
20
+ private
21
+
22
+ def set_dormant_table(table_data)
23
+ query_column = (['updated_at', 'created_at'] & table_data.column_names)[0]
24
+ return if query_column.nil?
25
+ print("Scanning #{in_color(table_data.name,@table_count)} ") if @options[:verbose]
26
+ updated_at = table_data.model.maximum(query_column)
27
+ table_data.make_dormant(updated_at) if updated_at.nil? || updated_at <= dormant_table_age
28
+ erase_line if @options[:verbose]
29
+ end
30
+
31
+ def dormant_table_age
32
+ return @options[:dormant_table_age] if @options[:dormant_table_age].respond_to?(:strftime)
33
+ # Safely generate Rails duration (instead of risker eval)
34
+ parts = @options[:dormant_table_age].split('.')
35
+ duration_expression = parts[0].to_i.send(parts[1]).send(parts[2])
36
+ @options[:dormant_table_age] = duration_expression
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,127 @@
1
+ module AttributeStats
2
+ class StatsGenerator
3
+ attr_reader :options, :table_info, :migration
4
+
5
+ DEFAULT_OPTIONS = {
6
+ consider_defaults_unused: true,
7
+ defaults: false,
8
+ formatter: :hash,
9
+ source: :code,
10
+ dormant_table_age: '3.months.ago',
11
+ verbose: false }
12
+
13
+ def initialize(opts={})
14
+ @options = DEFAULT_OPTIONS.merge(opts)
15
+ end
16
+
17
+ def attribute_usage
18
+ fetch_attribute_usage
19
+ output formatter.output_all_attributes
20
+ end
21
+
22
+ def dormant_tables
23
+ fetch_dormant_tables
24
+ output formatter.output_dormant_tables
25
+ end
26
+
27
+ def unused_attributes
28
+ fetch_empty_attributes
29
+ output formatter.output_unused_attributes
30
+ end
31
+
32
+ def migration
33
+ generate_migration
34
+ output formatter.output_migration
35
+ end
36
+
37
+ def set_formatter(formatter_type)
38
+ formatter_was = options[:formatter]
39
+ case formatter_type.to_s.downcase
40
+ when 'json'
41
+ options[:formatter] = :json
42
+ when 'tabular'
43
+ options[:formatter] = :tabular
44
+ when 'hash'
45
+ options[:formatter] = :hash
46
+ end
47
+ @formatter = nil unless options[:formatter] == formatter_was
48
+ options[:formatter]
49
+ end
50
+
51
+ def inspect
52
+ "StatsGenerator(results: #{table_info.to_s[0..200]}, options: #{options})"
53
+ end
54
+
55
+ private
56
+
57
+ def formatter
58
+ @formatter ||= begin
59
+ output = case options[:formatter].to_s.downcase
60
+ when 'json'
61
+ JSONFormatter
62
+ when 'hash'
63
+ HashFormatter
64
+ else
65
+ TabularFormatter
66
+ end.new(options: options, table_info: table_info, migration: @migration)
67
+ end
68
+ end
69
+
70
+ def output(value)
71
+ return if value.nil?
72
+ if options[:source] == :cli || value.is_a?(String)
73
+ puts value
74
+ else
75
+ value
76
+ end
77
+ end
78
+
79
+ def fetch_attribute_usage
80
+ @fetch_attribute_usage ||= begin
81
+ initialize_tables
82
+ attribute_stats_setter.set_counts
83
+ true
84
+ end
85
+ end
86
+
87
+ def fetch_empty_attributes
88
+ @fetch_empty_attributes ||= begin
89
+ initialize_tables
90
+ attribute_stats_setter.set_empty
91
+ true
92
+ end
93
+ end
94
+
95
+ def attribute_stats_setter
96
+ @attribute_stats_setter ||= SetAttributeStats.new(table_info: table_info, options: options)
97
+ end
98
+
99
+ def fetch_dormant_tables
100
+ @fetched_dormant_tables ||= begin
101
+ initialize_tables
102
+ SetDormantTables.new(table_info: table_info, options: options).call
103
+ true
104
+ end
105
+ end
106
+
107
+ def generate_migration
108
+ fetch_empty_attributes
109
+ @migration ||= GenerateMigration.new(table_info: table_info, options: options)
110
+ end
111
+
112
+ def initialize_tables
113
+ return unless table_info.nil?
114
+ @table_info = []
115
+ tables.sort.each{|table| setup_table_and_model(table) }
116
+ end
117
+
118
+ def setup_table_and_model(table)
119
+ @table_info << TableData.new(table.classify.constantize)
120
+ rescue NameError => ex
121
+ end
122
+
123
+ def tables
124
+ ActiveRecord::Base.connection.data_sources
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,29 @@
1
+ module AttributeStats
2
+ module Terminal
3
+ # https://en.wikipedia.org/wiki/ANSI_escape_code#Escape_sequences
4
+ START_OF_NEXT_LINE = "\e[1E"
5
+ LINE_ABOVE = "\e[1A"
6
+ CLEAR_LINE_TO_RIGHT = "\e[K"
7
+ RED = "\e[31m"
8
+ GREEN = "\e[32m"
9
+
10
+ def erase_line
11
+ print START_OF_NEXT_LINE, LINE_ABOVE, CLEAR_LINE_TO_RIGHT
12
+ end
13
+
14
+ RESET_COLOR = "\e[0m"
15
+
16
+ def in_color(text, index=0)
17
+ code = (31..37).to_a[index % 7]
18
+ "\e[#{code}m#{text}#{RESET_COLOR}"
19
+ end
20
+
21
+ def red(text)
22
+ print RED, text, RESET_COLOR
23
+ end
24
+
25
+ def green(text)
26
+ print GREEN, text, RESET_COLOR
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,4 @@
1
+ require "rubygems"
2
+ require "bundler/setup"
3
+ require "bundler/gem_tasks"
4
+ require 'rake/testtask'
metadata ADDED
@@ -0,0 +1,176 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: attribute-stats
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Steve Hodges
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2018-09-08 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '4.2'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '6'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '4.2'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '6'
33
+ - !ruby/object:Gem::Dependency
34
+ name: hirb
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.7'
40
+ type: :runtime
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.7'
47
+ - !ruby/object:Gem::Dependency
48
+ name: appraisal
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.2'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.2'
61
+ - !ruby/object:Gem::Dependency
62
+ name: bundler
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.9'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.9'
75
+ - !ruby/object:Gem::Dependency
76
+ name: rake
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '10.0'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '10.0'
89
+ - !ruby/object:Gem::Dependency
90
+ name: rspec
91
+ requirement: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '3'
96
+ type: :development
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '3'
103
+ - !ruby/object:Gem::Dependency
104
+ name: sqlite3
105
+ requirement: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '1.0'
110
+ type: :development
111
+ prerelease: false
112
+ version_requirements: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '1.0'
117
+ description: |
118
+ Attribute Stats gives you insight into which attributes are actually used in your Rails models. Whether you're joining an existing project or have been using it for years, get quick info.
119
+
120
+ The stats help you find smells in your project:
121
+
122
+ Attributes which have never had data set (indicates a potentially forgotten attribute); Tables which haven't been updated for X years (indicates a potentially unused or legacy model); Attributes used by very few objects in your table (is this being used? Should it be an attribute?)
123
+
124
+ It also generates sample Rails database migration code to allow you to drop the database columns.
125
+ email:
126
+ - sjhodges@gmail.com
127
+ executables: []
128
+ extensions: []
129
+ extra_rdoc_files: []
130
+ files:
131
+ - LICENSE
132
+ - README.md
133
+ - lib/attribute-stats.rb
134
+ - lib/attribute-stats/railtie.rb
135
+ - lib/attribute-stats/tasks/attribute_usage.rake
136
+ - lib/attribute-stats/tasks/dormant.rake
137
+ - lib/attribute-stats/tasks/migration.rake
138
+ - lib/attribute-stats/tasks/unused_attributes.rake
139
+ - lib/attribute-stats/version.rb
140
+ - lib/entities/attribute_info.rb
141
+ - lib/entities/table_data.rb
142
+ - lib/formatters/hash_formatter.rb
143
+ - lib/formatters/json_formatter.rb
144
+ - lib/formatters/tabular_formatter.rb
145
+ - lib/stats_generation/generate_migration.rb
146
+ - lib/stats_generation/set_attribute_stats.rb
147
+ - lib/stats_generation/set_dormant_tables.rb
148
+ - lib/stats_generation/stats_generator.rb
149
+ - lib/stats_generation/terminal.rb
150
+ - rakefile.rb
151
+ homepage: https://github.com/stevehodges/attribute_stats
152
+ licenses:
153
+ - MIT
154
+ metadata: {}
155
+ post_install_message:
156
+ rdoc_options: []
157
+ require_paths:
158
+ - lib
159
+ required_ruby_version: !ruby/object:Gem::Requirement
160
+ requirements:
161
+ - - ">="
162
+ - !ruby/object:Gem::Version
163
+ version: 2.2.0
164
+ required_rubygems_version: !ruby/object:Gem::Requirement
165
+ requirements:
166
+ - - ">="
167
+ - !ruby/object:Gem::Version
168
+ version: '0'
169
+ requirements: []
170
+ rubyforge_project:
171
+ rubygems_version: 2.6.14.1
172
+ signing_key:
173
+ specification_version: 4
174
+ summary: Statistics about attribute usage, unused attributes, and dormant tables in
175
+ Rails projects
176
+ test_files: []