droppable_table 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,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "json"
5
+
6
+ module DroppableTable
7
+ class Analyzer
8
+ attr_reader :config, :schema_tables, :model_tables, :sti_base_tables,
9
+ :habtm_tables, :droppable_tables, :excluded_tables
10
+
11
+ def initialize(config = nil)
12
+ @config = config.is_a?(Config) ? config : Config.new(config)
13
+ @schema_tables = {} # DB name => array of table info
14
+ @model_tables = Set.new # Table names with corresponding models
15
+ @sti_base_tables = Set.new # STI base tables
16
+ @habtm_tables = Set.new # HABTM join tables
17
+ @droppable_tables = [] # Potentially droppable tables
18
+ @excluded_tables = @config.all_excluded_tables # Rails internal + gems + user defined
19
+ @model_collector = nil
20
+ @schema_format = nil
21
+ end
22
+
23
+ def analyze
24
+ check_migration_status # Check for pending migrations
25
+ detect_schema_format # Detect schema.rb vs structure.sql
26
+ collect_schema_tables # Collect tables from all schema files
27
+ eager_load_models # Ensure all models are loaded
28
+ collect_model_tables # Collect from ActiveRecord::Base.descendants
29
+ collect_sti_tables # Detect STI hierarchies
30
+ collect_habtm_tables # Detect HABTM associations
31
+ identify_droppable_tables # Identify droppable tables
32
+
33
+ self
34
+ end
35
+
36
+ def report(format: :text)
37
+ # TODO: Implement report generation
38
+ case format
39
+ when :json
40
+ generate_json_report
41
+ else
42
+ generate_text_report
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def load_excluded_tables
49
+ @config.all_excluded_tables
50
+ end
51
+
52
+ def check_migration_status
53
+ # Skip migration check in test environment or if Rails/ActiveRecord not available
54
+ return if ENV["RAILS_ENV"] == "test"
55
+ return unless defined?(ActiveRecord::Base) && defined?(Rails)
56
+
57
+ begin
58
+ # Ensure database connection exists
59
+ ActiveRecord::Base.connection
60
+
61
+ # Try to check for pending migrations using available methods
62
+ if ActiveRecord::Base.connection.respond_to?(:migration_context)
63
+ # Rails 6.0+
64
+ context = ActiveRecord::Base.connection.migration_context
65
+ if context.needs_migration?
66
+ raise MigrationPendingError, "There are pending migrations. Please run migrations before analyzing."
67
+ end
68
+ end
69
+ # If migration check methods aren't available, skip the check
70
+ rescue ActiveRecord::NoDatabaseError
71
+ raise Error, "Database does not exist. Please create and migrate the database first."
72
+ rescue StandardError
73
+ # If any other error occurs during migration check, skip it
74
+ # This ensures the tool remains functional even with different Rails versions
75
+ end
76
+ end
77
+
78
+ def detect_schema_format
79
+ schema_rb = File.join(Dir.pwd, "db", "schema.rb")
80
+ structure_sql = File.join(Dir.pwd, "db", "structure.sql")
81
+
82
+ if File.exist?(schema_rb)
83
+ @schema_format = :ruby
84
+ elsif File.exist?(structure_sql)
85
+ @schema_format = :sql
86
+ else
87
+ raise SchemaNotFoundError, "No schema.rb or structure.sql found in db/ directory"
88
+ end
89
+ end
90
+
91
+ def collect_schema_tables
92
+ # Find all schema files (including from multiple databases)
93
+ schema_files = find_schema_files
94
+
95
+ schema_files.each do |schema_file|
96
+ parser = SchemaParser.new(schema_file)
97
+ tables = parser.parse
98
+
99
+ # Determine database name from file path
100
+ db_name = extract_database_name(schema_file)
101
+ @schema_tables[db_name] = tables
102
+ end
103
+ end
104
+
105
+ def find_schema_files
106
+ files = []
107
+
108
+ # Main schema file
109
+ main_schema = File.join(Dir.pwd, "db", @schema_format == :sql ? "structure.sql" : "schema.rb")
110
+ files << main_schema if File.exist?(main_schema)
111
+
112
+ # Look for additional schema files (e.g., secondary_schema.rb)
113
+ Dir.glob(File.join(Dir.pwd, "db", "*_schema.rb")).each do |file|
114
+ files << file unless file == main_schema
115
+ end
116
+
117
+ files
118
+ end
119
+
120
+ def extract_database_name(schema_file)
121
+ basename = File.basename(schema_file, ".*")
122
+
123
+ case basename
124
+ when "schema", "structure"
125
+ "primary"
126
+ else
127
+ # Extract database name from filename like 'secondary_schema.rb'
128
+ basename.sub(/_schema$/, "")
129
+ end
130
+ end
131
+
132
+ def eager_load_models
133
+ @model_collector = ModelCollector.new
134
+ @model_collector.collect
135
+ end
136
+
137
+ def collect_model_tables
138
+ return unless @model_collector
139
+
140
+ @model_tables = @model_collector.table_names
141
+ end
142
+
143
+ def collect_sti_tables
144
+ return unless @model_collector
145
+
146
+ @sti_base_tables = @model_collector.sti_base_tables
147
+ end
148
+
149
+ def collect_habtm_tables
150
+ return unless @model_collector
151
+
152
+ @habtm_tables = @model_collector.habtm_tables
153
+ end
154
+
155
+ def identify_droppable_tables
156
+ all_schema_tables = @schema_tables.values.flatten.to_set { |t| t[:name] }
157
+
158
+ # Tables that exist in schema but have no corresponding model
159
+ @droppable_tables = all_schema_tables.reject do |table|
160
+ # Skip if table is excluded
161
+ @excluded_tables.include?(table) ||
162
+ # Skip if table has a model
163
+ @model_tables.include?(table) ||
164
+ # Skip if it's a HABTM join table
165
+ @habtm_tables.include?(table) ||
166
+ # Skip if it's an STI base table (these are important)
167
+ @sti_base_tables.include?(table)
168
+ end.to_a.sort
169
+ end
170
+
171
+ def generate_json_report
172
+ {
173
+ summary: {
174
+ total_tables: @schema_tables.values.flatten.map { |t| t[:name] }.uniq.size,
175
+ tables_with_models: @model_tables.size,
176
+ sti_base_tables: @sti_base_tables.size,
177
+ habtm_tables: @habtm_tables.size,
178
+ excluded_tables: @excluded_tables.size,
179
+ droppable_tables: @droppable_tables.size
180
+ },
181
+ droppable_tables: @droppable_tables,
182
+ tables_by_database: @schema_tables.transform_values { |tables| tables.map { |t| t[:name] } },
183
+ model_tables: @model_tables.to_a.sort,
184
+ sti_base_tables: @sti_base_tables.to_a.sort,
185
+ habtm_tables: @habtm_tables.to_a.sort,
186
+ excluded_tables: @excluded_tables.to_a.sort
187
+ }
188
+ end
189
+
190
+ def generate_text_report
191
+ report = []
192
+ report << "DroppableTable Analysis Report"
193
+ report << ("=" * 40)
194
+ report << ""
195
+
196
+ # Summary
197
+ all_tables = @schema_tables.values.flatten.map { |t| t[:name] }.uniq
198
+ report << "Summary:"
199
+ report << " Total tables in schema: #{all_tables.size}"
200
+ report << " Tables with models: #{@model_tables.size}"
201
+ report << " STI base tables: #{@sti_base_tables.size}"
202
+ report << " HABTM join tables: #{@habtm_tables.size}"
203
+ report << " Excluded tables: #{@excluded_tables.size}"
204
+ report << " Potentially droppable: #{@droppable_tables.size}"
205
+ report << ""
206
+
207
+ # Droppable tables
208
+ if @droppable_tables.empty?
209
+ report << "No droppable tables found."
210
+ else
211
+ report << "Potentially droppable tables:"
212
+ @droppable_tables.each do |table|
213
+ report << " - #{table}"
214
+ end
215
+ end
216
+ report << ""
217
+
218
+ # Tables by database
219
+ if @schema_tables.size > 1
220
+ report << "Tables by database:"
221
+ @schema_tables.each do |db_name, tables|
222
+ report << " #{db_name}: #{tables.size} tables"
223
+ end
224
+ report << ""
225
+ end
226
+
227
+ report.join("\n")
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "json"
5
+
6
+ module DroppableTable
7
+ class CLI < Thor
8
+ package_name "DroppableTable"
9
+
10
+ desc "analyze", "Analyze Rails application for potentially droppable tables"
11
+ option :json, type: :boolean, desc: "Output results in JSON format"
12
+ option :config, type: :string, desc: "Path to configuration file"
13
+ option :strict, type: :boolean, desc: "Strict mode for CI (fail if new droppable tables found)"
14
+ def analyze
15
+ # Load configuration
16
+ config = Config.new(options[:config])
17
+
18
+ # Run analysis
19
+ analyzer = Analyzer.new(config)
20
+ analyzer.analyze
21
+
22
+ # Generate report
23
+ if options[:json]
24
+ puts JSON.pretty_generate(analyzer.report(format: :json))
25
+ else
26
+ puts analyzer.report(format: :text)
27
+ end
28
+
29
+ # Handle strict mode
30
+ handle_strict_mode(analyzer, config) if options[:strict] && config.strict_mode_enabled?
31
+
32
+ # Exit with appropriate code
33
+ exit(analyzer.droppable_tables.empty? ? 0 : 1) if options[:strict]
34
+ rescue RailsNotFoundError => e
35
+ error_exit("Rails application not found: #{e.message}")
36
+ rescue MigrationPendingError => e
37
+ error_exit("Migration pending: #{e.message}")
38
+ rescue SchemaNotFoundError => e
39
+ error_exit("Schema not found: #{e.message}")
40
+ rescue StandardError => e
41
+ error_exit("Error: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
42
+ end
43
+
44
+ desc "version", "Display version"
45
+ def version
46
+ puts "DroppableTable #{DroppableTable::VERSION}"
47
+ end
48
+ map %w[-v --version] => :version
49
+
50
+ def self.exit_on_failure?
51
+ true
52
+ end
53
+
54
+ default_task :analyze
55
+
56
+ private
57
+
58
+ def handle_strict_mode(analyzer, config)
59
+ baseline_file = config.baseline_file
60
+ current_droppable = analyzer.droppable_tables.sort
61
+
62
+ if File.exist?(baseline_file)
63
+ baseline = JSON.parse(File.read(baseline_file))["droppable_tables"] || []
64
+ new_tables = current_droppable - baseline
65
+
66
+ if new_tables.any?
67
+ puts "\nERROR: New droppable tables found in strict mode:"
68
+ new_tables.each { |table| puts " - #{table}" }
69
+ puts "\nTo update the baseline, run without --strict flag."
70
+ exit(1)
71
+ end
72
+ else
73
+ # Create baseline file
74
+ File.write(baseline_file, JSON.pretty_generate({
75
+ droppable_tables: current_droppable,
76
+ generated_at: Time.now.iso8601
77
+ }))
78
+ puts "\nCreated baseline file: #{baseline_file}"
79
+ end
80
+ end
81
+
82
+ def error_exit(message)
83
+ warn "ERROR: #{message}"
84
+ exit(1)
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "pathname"
5
+
6
+ module DroppableTable
7
+ class Config
8
+ DEFAULT_CONFIG_FILE = "droppable_table.yml"
9
+ RAILS_INTERNAL_TABLES_FILE = File.expand_path("../../config/rails_internal_tables.yml", __dir__)
10
+ KNOWN_GEMS_FILE = File.expand_path("../../config/known_gems.yml", __dir__)
11
+
12
+ attr_reader :excluded_tables, :excluded_gems, :strict_mode, :config_file_path
13
+
14
+ def initialize(config_file_path = nil)
15
+ @config_file_path = config_file_path || DEFAULT_CONFIG_FILE
16
+ @excluded_tables = Set.new
17
+ @excluded_gems = Set.new
18
+ @strict_mode = {}
19
+
20
+ load_default_config
21
+ load_user_config if File.exist?(@config_file_path)
22
+ end
23
+
24
+ def rails_internal_tables
25
+ @rails_internal_tables ||= load_yaml_file(RAILS_INTERNAL_TABLES_FILE)
26
+ end
27
+
28
+ def known_gem_tables
29
+ @known_gem_tables ||= load_yaml_file(KNOWN_GEMS_FILE)
30
+ end
31
+
32
+ def all_excluded_tables
33
+ Set.new(excluded_tables) + Set.new(rails_internal_tables) + gem_excluded_tables
34
+ end
35
+
36
+ def strict_mode_enabled?
37
+ strict_mode["enabled"] == true
38
+ end
39
+
40
+ def baseline_file
41
+ strict_mode["baseline_file"] || ".droppable_table_baseline.json"
42
+ end
43
+
44
+ private
45
+
46
+ def load_default_config
47
+ @rails_internal_tables = load_yaml_file(RAILS_INTERNAL_TABLES_FILE) || []
48
+ @known_gem_tables = load_yaml_file(KNOWN_GEMS_FILE) || {}
49
+ end
50
+
51
+ def load_user_config
52
+ # TODO: Load user configuration from YAML file
53
+ config = load_yaml_file(@config_file_path)
54
+ return unless config.is_a?(Hash)
55
+
56
+ @excluded_tables = Set.new(config["excluded_tables"] || [])
57
+ @excluded_gems = Set.new(config["excluded_gems"] || [])
58
+ @strict_mode = config["strict_mode"] || {}
59
+ end
60
+
61
+ def load_yaml_file(path)
62
+ return [] unless File.exist?(path)
63
+
64
+ content = File.read(path)
65
+ YAML.safe_load(content, permitted_classes: [Symbol], aliases: true) || []
66
+ rescue StandardError
67
+ []
68
+ end
69
+
70
+ def gem_excluded_tables
71
+ tables = Set.new
72
+
73
+ excluded_gems.each do |gem_name|
74
+ tables += known_gem_tables[gem_name] if known_gem_tables.is_a?(Hash) && known_gem_tables[gem_name]
75
+ end
76
+
77
+ tables
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module DroppableTable
6
+ class ModelCollector
7
+ attr_reader :models, :table_mapping
8
+
9
+ def initialize
10
+ @models = []
11
+ @table_mapping = {} # model_class => table_name
12
+ end
13
+
14
+ def collect
15
+ ensure_rails_loaded
16
+ eager_load_all_models
17
+ collect_all_descendants
18
+ build_table_mapping
19
+
20
+ self
21
+ end
22
+
23
+ def table_names
24
+ Set.new(table_mapping.values)
25
+ end
26
+
27
+ def sti_base_tables
28
+ sti_tables = Set.new
29
+
30
+ models.each do |model|
31
+ next if model.abstract_class?
32
+
33
+ # Check if this model uses STI (has a 'type' column)
34
+ sti_tables << model.table_name if model.columns_hash.key?("type") && model.base_class == model
35
+ rescue StandardError
36
+ # Skip models that can't be inspected
37
+ end
38
+
39
+ sti_tables
40
+ end
41
+
42
+ def habtm_tables
43
+ habtm_join_tables = Set.new
44
+
45
+ models.each do |model|
46
+ next if model.abstract_class?
47
+
48
+ # Find HABTM associations
49
+ model.reflect_on_all_associations(:has_and_belongs_to_many).each do |association|
50
+ join_table = association.join_table
51
+ habtm_join_tables << join_table if join_table
52
+ end
53
+ rescue StandardError
54
+ # Skip models that can't be inspected
55
+ end
56
+
57
+ habtm_join_tables
58
+ end
59
+
60
+ private
61
+
62
+ def ensure_rails_loaded
63
+ return if defined?(Rails)
64
+
65
+ # Try to load Rails if we're in a Rails directory
66
+ rails_path = File.join(Dir.pwd, "config", "environment.rb")
67
+ unless File.exist?(rails_path)
68
+ raise RailsNotFoundError, "Rails not found. Please run this command from a Rails application directory."
69
+ end
70
+
71
+ ENV["RAILS_ENV"] ||= "development"
72
+ require rails_path
73
+ end
74
+
75
+ def eager_load_all_models
76
+ return unless defined?(Rails)
77
+
78
+ # Eager load the application to ensure all models are loaded
79
+ Rails.application.eager_load!
80
+
81
+ # Also load models from engines
82
+ Rails::Engine.subclasses.each do |engine|
83
+ engine.instance.eager_load!
84
+ end
85
+ end
86
+
87
+ def collect_all_descendants
88
+ return unless defined?(ActiveRecord::Base)
89
+
90
+ # Collect all ActiveRecord descendants, excluding abstract classes
91
+ @models = ActiveRecord::Base.descendants.reject(&:abstract_class?)
92
+ end
93
+
94
+ def build_table_mapping
95
+ models.each do |model|
96
+ next if model.abstract_class?
97
+
98
+ begin
99
+ # Check if model responds to table_name
100
+ if model.respond_to?(:table_name)
101
+ table_name = model.table_name
102
+ @table_mapping[model] = table_name if table_name && !table_name.empty?
103
+ end
104
+ rescue StandardError
105
+ # Skip models that raise errors when accessing table_name
106
+ # This can happen with models that have database connection issues
107
+ end
108
+ end
109
+ end
110
+
111
+ def resolve_table_name(model)
112
+ return nil unless model.respond_to?(:table_name)
113
+
114
+ # Get the table name, considering custom configurations
115
+ table_name = model.table_name
116
+
117
+ # Apply any table name prefix/suffix if configured
118
+ if model.respond_to?(:table_name_prefix) && model.table_name_prefix
119
+ table_name = "#{model.table_name_prefix}#{table_name}"
120
+ end
121
+
122
+ if model.respond_to?(:table_name_suffix) && model.table_name_suffix
123
+ table_name = "#{table_name}#{model.table_name_suffix}"
124
+ end
125
+
126
+ table_name
127
+ rescue StandardError
128
+ nil
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DroppableTable
4
+ class SchemaParser
5
+ attr_reader :schema_path, :format
6
+
7
+ def initialize(schema_path)
8
+ @schema_path = schema_path
9
+ @format = detect_format
10
+ end
11
+
12
+ def parse
13
+ case format
14
+ when :ruby
15
+ parse_ruby_schema
16
+ when :sql
17
+ parse_sql_schema
18
+ else
19
+ raise SchemaNotFoundError, "Unknown schema format: #{schema_path}"
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def detect_format
26
+ # TODO: Detect schema format based on file extension
27
+ case File.basename(schema_path)
28
+ when /\.rb$/
29
+ :ruby
30
+ when /\.sql$/
31
+ :sql
32
+ end
33
+ end
34
+
35
+ def parse_ruby_schema
36
+ return [] unless File.exist?(schema_path)
37
+
38
+ tables = []
39
+ File.readlines(schema_path).each do |line|
40
+ next unless (match = line.match(/^\s*create_table\s+"([^"]+)"/))
41
+
42
+ table_name = match[1]
43
+ tables << {
44
+ name: table_name,
45
+ type: "table"
46
+ }
47
+ end
48
+
49
+ tables
50
+ end
51
+
52
+ def parse_sql_schema
53
+ # TODO: Parse structure.sql
54
+ []
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DroppableTable
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "droppable_table/version"
4
+ require_relative "droppable_table/cli"
5
+ require_relative "droppable_table/analyzer"
6
+ require_relative "droppable_table/schema_parser"
7
+ require_relative "droppable_table/model_collector"
8
+ require_relative "droppable_table/config"
9
+
10
+ module DroppableTable
11
+ class Error < StandardError; end
12
+ class RailsNotFoundError < Error; end
13
+ class MigrationPendingError < Error; end
14
+ class SchemaNotFoundError < Error; end
15
+ end