schema_sherlock 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5c7488ddd91bb0bb1721c72fc0d3e5045461274a68ce2fea71d0ebe280f1c627
4
+ data.tar.gz: 400b9b670b80856f543659a55e24e2d138509f578125af5b2821b96fd766d17f
5
+ SHA512:
6
+ metadata.gz: 61fb4224786228806820ebf244672a8d493fa31a6561d8d9ce4f85ed9bdd03f9f16e869653a3dc0275d61b4c5d7e3fff083538fdd383c6887b2f05b793811221
7
+ data.tar.gz: 0234a850f322b90c9479542affc4ae8fae40df67c38395fd580f1f72ff683e2a17dee230d4fa918e336968a718cb4ad996165e59253adca3dc9f62b9acca0afe
data/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2025-01-23
4
+
5
+ ### Added
6
+ - Smart association detection based on foreign keys
7
+ - Usage-based filtering with configurable thresholds
8
+ - Codebase scanning for foreign key usage patterns
9
+ - CLI interface with analyze command
10
+ - Configurable minimum usage threshold
11
+
12
+ ### Features
13
+ - Detects missing `belongs_to` associations from foreign key columns
14
+ - Validates foreign key references against existing tables
15
+ - Tracks foreign key usage across Rails application
16
+ - Filters suggestions based on actual usage frequency
17
+ - Provides detailed analysis reports
18
+ - Supports complex foreign key types (integer, bigint, UUID, string)
19
+ - Smart table and model inference
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "rspec", "~> 3.0"
data/Gemfile.lock ADDED
@@ -0,0 +1,235 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ schema_sherlock (0.1.0)
5
+ activerecord (>= 6.0)
6
+ rails (>= 6.0)
7
+ thor (~> 1.0)
8
+
9
+ GEM
10
+ remote: https://rubygems.org/
11
+ specs:
12
+ actioncable (8.0.2)
13
+ actionpack (= 8.0.2)
14
+ activesupport (= 8.0.2)
15
+ nio4r (~> 2.0)
16
+ websocket-driver (>= 0.6.1)
17
+ zeitwerk (~> 2.6)
18
+ actionmailbox (8.0.2)
19
+ actionpack (= 8.0.2)
20
+ activejob (= 8.0.2)
21
+ activerecord (= 8.0.2)
22
+ activestorage (= 8.0.2)
23
+ activesupport (= 8.0.2)
24
+ mail (>= 2.8.0)
25
+ actionmailer (8.0.2)
26
+ actionpack (= 8.0.2)
27
+ actionview (= 8.0.2)
28
+ activejob (= 8.0.2)
29
+ activesupport (= 8.0.2)
30
+ mail (>= 2.8.0)
31
+ rails-dom-testing (~> 2.2)
32
+ actionpack (8.0.2)
33
+ actionview (= 8.0.2)
34
+ activesupport (= 8.0.2)
35
+ nokogiri (>= 1.8.5)
36
+ rack (>= 2.2.4)
37
+ rack-session (>= 1.0.1)
38
+ rack-test (>= 0.6.3)
39
+ rails-dom-testing (~> 2.2)
40
+ rails-html-sanitizer (~> 1.6)
41
+ useragent (~> 0.16)
42
+ actiontext (8.0.2)
43
+ actionpack (= 8.0.2)
44
+ activerecord (= 8.0.2)
45
+ activestorage (= 8.0.2)
46
+ activesupport (= 8.0.2)
47
+ globalid (>= 0.6.0)
48
+ nokogiri (>= 1.8.5)
49
+ actionview (8.0.2)
50
+ activesupport (= 8.0.2)
51
+ builder (~> 3.1)
52
+ erubi (~> 1.11)
53
+ rails-dom-testing (~> 2.2)
54
+ rails-html-sanitizer (~> 1.6)
55
+ activejob (8.0.2)
56
+ activesupport (= 8.0.2)
57
+ globalid (>= 0.3.6)
58
+ activemodel (8.0.2)
59
+ activesupport (= 8.0.2)
60
+ activerecord (8.0.2)
61
+ activemodel (= 8.0.2)
62
+ activesupport (= 8.0.2)
63
+ timeout (>= 0.4.0)
64
+ activestorage (8.0.2)
65
+ actionpack (= 8.0.2)
66
+ activejob (= 8.0.2)
67
+ activerecord (= 8.0.2)
68
+ activesupport (= 8.0.2)
69
+ marcel (~> 1.0)
70
+ activesupport (8.0.2)
71
+ base64
72
+ benchmark (>= 0.3)
73
+ bigdecimal
74
+ concurrent-ruby (~> 1.0, >= 1.3.1)
75
+ connection_pool (>= 2.2.5)
76
+ drb
77
+ i18n (>= 1.6, < 2)
78
+ logger (>= 1.4.2)
79
+ minitest (>= 5.1)
80
+ securerandom (>= 0.3)
81
+ tzinfo (~> 2.0, >= 2.0.5)
82
+ uri (>= 0.13.1)
83
+ base64 (0.2.0)
84
+ benchmark (0.4.0)
85
+ bigdecimal (3.1.9)
86
+ builder (3.3.0)
87
+ concurrent-ruby (1.3.5)
88
+ connection_pool (2.5.3)
89
+ crass (1.0.6)
90
+ date (3.4.1)
91
+ diff-lcs (1.6.2)
92
+ drb (2.2.3)
93
+ erb (5.0.1)
94
+ erubi (1.13.1)
95
+ globalid (1.2.1)
96
+ activesupport (>= 6.1)
97
+ i18n (1.14.7)
98
+ concurrent-ruby (~> 1.0)
99
+ io-console (0.8.0)
100
+ irb (1.15.2)
101
+ pp (>= 0.6.0)
102
+ rdoc (>= 4.0.0)
103
+ reline (>= 0.4.2)
104
+ logger (1.7.0)
105
+ loofah (2.24.1)
106
+ crass (~> 1.0.2)
107
+ nokogiri (>= 1.12.0)
108
+ mail (2.8.1)
109
+ mini_mime (>= 0.1.1)
110
+ net-imap
111
+ net-pop
112
+ net-smtp
113
+ marcel (1.0.4)
114
+ mini_mime (1.1.5)
115
+ minitest (5.25.5)
116
+ net-imap (0.5.8)
117
+ date
118
+ net-protocol
119
+ net-pop (0.1.2)
120
+ net-protocol
121
+ net-protocol (0.2.2)
122
+ timeout
123
+ net-smtp (0.5.1)
124
+ net-protocol
125
+ nio4r (2.7.4)
126
+ nokogiri (1.18.8-aarch64-linux-gnu)
127
+ racc (~> 1.4)
128
+ nokogiri (1.18.8-aarch64-linux-musl)
129
+ racc (~> 1.4)
130
+ nokogiri (1.18.8-arm-linux-gnu)
131
+ racc (~> 1.4)
132
+ nokogiri (1.18.8-arm-linux-musl)
133
+ racc (~> 1.4)
134
+ nokogiri (1.18.8-arm64-darwin)
135
+ racc (~> 1.4)
136
+ nokogiri (1.18.8-x86_64-darwin)
137
+ racc (~> 1.4)
138
+ nokogiri (1.18.8-x86_64-linux-gnu)
139
+ racc (~> 1.4)
140
+ nokogiri (1.18.8-x86_64-linux-musl)
141
+ racc (~> 1.4)
142
+ pp (0.6.2)
143
+ prettyprint
144
+ prettyprint (0.2.0)
145
+ psych (5.2.6)
146
+ date
147
+ stringio
148
+ racc (1.8.1)
149
+ rack (3.1.15)
150
+ rack-session (2.1.1)
151
+ base64 (>= 0.1.0)
152
+ rack (>= 3.0.0)
153
+ rack-test (2.2.0)
154
+ rack (>= 1.3)
155
+ rackup (2.2.1)
156
+ rack (>= 3)
157
+ rails (8.0.2)
158
+ actioncable (= 8.0.2)
159
+ actionmailbox (= 8.0.2)
160
+ actionmailer (= 8.0.2)
161
+ actionpack (= 8.0.2)
162
+ actiontext (= 8.0.2)
163
+ actionview (= 8.0.2)
164
+ activejob (= 8.0.2)
165
+ activemodel (= 8.0.2)
166
+ activerecord (= 8.0.2)
167
+ activestorage (= 8.0.2)
168
+ activesupport (= 8.0.2)
169
+ bundler (>= 1.15.0)
170
+ railties (= 8.0.2)
171
+ rails-dom-testing (2.3.0)
172
+ activesupport (>= 5.0.0)
173
+ minitest
174
+ nokogiri (>= 1.6)
175
+ rails-html-sanitizer (1.6.2)
176
+ loofah (~> 2.21)
177
+ nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
178
+ railties (8.0.2)
179
+ actionpack (= 8.0.2)
180
+ activesupport (= 8.0.2)
181
+ irb (~> 1.13)
182
+ rackup (>= 1.0.0)
183
+ rake (>= 12.2)
184
+ thor (~> 1.0, >= 1.2.2)
185
+ zeitwerk (~> 2.6)
186
+ rake (13.2.1)
187
+ rdoc (6.14.0)
188
+ erb
189
+ psych (>= 4.0.0)
190
+ reline (0.6.1)
191
+ io-console (~> 0.5)
192
+ rspec (3.13.0)
193
+ rspec-core (~> 3.13.0)
194
+ rspec-expectations (~> 3.13.0)
195
+ rspec-mocks (~> 3.13.0)
196
+ rspec-core (3.13.3)
197
+ rspec-support (~> 3.13.0)
198
+ rspec-expectations (3.13.4)
199
+ diff-lcs (>= 1.2.0, < 2.0)
200
+ rspec-support (~> 3.13.0)
201
+ rspec-mocks (3.13.4)
202
+ diff-lcs (>= 1.2.0, < 2.0)
203
+ rspec-support (~> 3.13.0)
204
+ rspec-support (3.13.3)
205
+ securerandom (0.4.1)
206
+ stringio (3.1.7)
207
+ thor (1.3.2)
208
+ timeout (0.4.3)
209
+ tzinfo (2.0.6)
210
+ concurrent-ruby (~> 1.0)
211
+ uri (1.0.3)
212
+ useragent (0.16.11)
213
+ websocket-driver (0.7.7)
214
+ base64
215
+ websocket-extensions (>= 0.1.0)
216
+ websocket-extensions (0.1.5)
217
+ zeitwerk (2.7.3)
218
+
219
+ PLATFORMS
220
+ aarch64-linux-gnu
221
+ aarch64-linux-musl
222
+ arm-linux-gnu
223
+ arm-linux-musl
224
+ arm64-darwin
225
+ x86_64-darwin
226
+ x86_64-linux-gnu
227
+ x86_64-linux-musl
228
+
229
+ DEPENDENCIES
230
+ rake (~> 13.0)
231
+ rspec (~> 3.0)
232
+ schema_sherlock!
233
+
234
+ BUNDLED WITH
235
+ 2.6.3
data/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Schema Sherlock
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # SchemaSherlock
2
+
3
+ Intelligent Rails model analysis and annotation tool that extends beyond traditional schema annotation to provide intelligent analysis and actionable suggestions for Rails model code quality, performance, and maintainability.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'schema_sherlock', group: :development
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ $ bundle install
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install schema_sherlock
20
+
21
+ ## Usage
22
+
23
+ ### Basic Commands
24
+
25
+ ```bash
26
+ # Analyze all models
27
+ schema_sherlock analyze
28
+
29
+ # Analyze specific model
30
+ schema_sherlock analyze User
31
+
32
+ # Override minimum usage threshold
33
+ schema_sherlock analyze --min-usage 1
34
+
35
+ # Use rake tasks instead
36
+ rake schema_sherlock:analyze
37
+ rake schema_sherlock:analyze_model[User]
38
+ ```
39
+
40
+ ### Configuration
41
+
42
+ Create a configuration file in your Rails application:
43
+
44
+ ```ruby
45
+ # config/initializers/schema_sherlock.rb
46
+ SchemaSherlock.configure do |config|
47
+ config.exclude_models = ['ActiveRecord::Base'] # Models to exclude from analysis
48
+ config.min_usage_threshold = 3 # Minimum usage count for foreign key suggestions
49
+ end
50
+ ```
51
+
52
+ ## Features
53
+
54
+ - **Smart Association Detection**: Identifies missing associations based on foreign keys
55
+ - **Usage-Based Filtering**: Only suggests associations for frequently used foreign keys
56
+ - **Codebase Analysis**: Scans your code to track foreign key usage patterns
57
+ - **Configurable Thresholds**: Set minimum usage requirements for suggestions
58
+ - **Rails Integration**: Works via CLI, rake tasks, or directly in models
59
+
60
+ ## Development
61
+
62
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
63
+
64
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
65
+
66
+ ## Contributing
67
+
68
+ Bug reports and pull requests are welcome on GitHub at https://github.com/prateekkish/schema_sherlock.
69
+
70
+ ## License
71
+
72
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: [:spec]
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative "../lib/schema_sherlock"
4
+ require_relative "../lib/schema_sherlock/commands/analyze_command"
5
+
6
+ SchemaSherlock::Commands::AnalyzeCommand.start(ARGV)
@@ -0,0 +1,34 @@
1
+ module SchemaSherlock
2
+ module Analyzers
3
+ class BaseAnalyzer
4
+ attr_reader :model_class, :results
5
+
6
+ def initialize(model_class)
7
+ @model_class = model_class
8
+ @results = {}
9
+ end
10
+
11
+ def analyze
12
+ raise NotImplementedError, "Subclasses must implement #analyze"
13
+ end
14
+
15
+ private
16
+
17
+ def model_name
18
+ @model_class.name
19
+ end
20
+
21
+ def table_name
22
+ @model_class.table_name
23
+ end
24
+
25
+ def columns
26
+ @model_class.columns
27
+ end
28
+
29
+ def associations
30
+ @model_class.reflect_on_all_associations
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,180 @@
1
+ require_relative "base_analyzer"
2
+ require_relative "../usage_tracker"
3
+
4
+ module SchemaSherlock
5
+ module Analyzers
6
+ class ForeignKeyDetector < BaseAnalyzer
7
+ # Common integer types that can reference each other
8
+ INTEGER_TYPES = %w[integer bigint].freeze
9
+ # UUID types that can reference each other
10
+ UUID_TYPES = %w[uuid].freeze
11
+ # String types that might be used for UUIDs
12
+ STRING_TYPES = %w[string text].freeze
13
+
14
+ def analyze
15
+ @results = {
16
+ missing_associations: find_missing_associations,
17
+ orphaned_foreign_keys: find_orphaned_foreign_keys,
18
+ usage_stats: get_usage_stats
19
+ }
20
+ end
21
+
22
+ private
23
+
24
+ def find_missing_associations
25
+ usage_stats = get_usage_stats
26
+ min_threshold = SchemaSherlock.configuration.min_usage_threshold
27
+
28
+ foreign_key_columns.reject do |column|
29
+ has_association_for_column?(column)
30
+ end.select do |column|
31
+ # Only suggest if usage meets minimum threshold
32
+ usage_count = usage_stats[column.name] || 0
33
+ usage_count >= min_threshold
34
+ end.map do |column|
35
+ {
36
+ column: column.name,
37
+ suggested_association: suggest_association_name(column),
38
+ type: :belongs_to,
39
+ usage_count: usage_stats[column.name] || 0
40
+ }
41
+ end
42
+ end
43
+
44
+ def find_orphaned_foreign_keys
45
+ # Find foreign key columns that don't have corresponding tables
46
+ foreign_key_columns.select do |column|
47
+ referenced_table = infer_table_name(column)
48
+ !table_exists?(referenced_table)
49
+ end.map do |column|
50
+ {
51
+ column: column.name,
52
+ inferred_table: infer_table_name(column),
53
+ issue: "Referenced table does not exist"
54
+ }
55
+ end
56
+ end
57
+
58
+ def foreign_key_columns
59
+ columns.select { |col| col.name.end_with?('_id') && col.name != 'id' && valid_foreign_key?(col) }
60
+ end
61
+
62
+ def has_association_for_column?(column)
63
+ association_name = column.name.gsub(/_id$/, '')
64
+ associations.any? do |assoc|
65
+ assoc.name.to_s == association_name ||
66
+ assoc.foreign_key == column.name
67
+ end
68
+ end
69
+
70
+ def suggest_association_name(column)
71
+ column.name.gsub(/_id$/, '')
72
+ end
73
+
74
+ def infer_table_name(column)
75
+ association_name = suggest_association_name(column)
76
+ association_name.pluralize
77
+ end
78
+
79
+ def table_exists?(table_name)
80
+ ActiveRecord::Base.connection.table_exists?(table_name)
81
+ end
82
+
83
+ def valid_foreign_key?(column)
84
+ # Check if the column actually references an existing table's primary key
85
+ referenced_table = infer_table_name(column)
86
+
87
+ # First check if the table exists
88
+ return false unless table_exists?(referenced_table)
89
+
90
+ # Then check if the referenced table has a primary key that matches the column type
91
+ begin
92
+ referenced_model_class = referenced_table.classify.constantize
93
+ primary_key_column = referenced_model_class.columns.find { |col| col.name == referenced_model_class.primary_key }
94
+
95
+ # Compare column types to ensure they're compatible
96
+ return false unless primary_key_column
97
+
98
+ # Check if the types are compatible (both should be integer-like for _id columns)
99
+ compatible_types?(column, primary_key_column)
100
+ rescue NameError
101
+ # If we can't find the model class, check if table has an 'id' column
102
+ check_table_primary_key(referenced_table, column)
103
+ end
104
+ end
105
+
106
+ private
107
+
108
+ def compatible_types?(foreign_key_column, primary_key_column)
109
+ fk_type = foreign_key_column.type.to_s
110
+ pk_type = primary_key_column.type.to_s
111
+
112
+ # Check for integer compatibility
113
+ return true if INTEGER_TYPES.include?(fk_type) && INTEGER_TYPES.include?(pk_type)
114
+
115
+ # Check for UUID compatibility
116
+ return true if UUID_TYPES.include?(fk_type) && UUID_TYPES.include?(pk_type)
117
+
118
+ # Check for string-based UUID compatibility (common when using string columns for UUIDs)
119
+ return true if STRING_TYPES.include?(fk_type) && STRING_TYPES.include?(pk_type) &&
120
+ likely_uuid_column?(foreign_key_column, primary_key_column)
121
+
122
+ # Cross-compatibility: string foreign key referencing UUID primary key (or vice versa)
123
+ return true if (STRING_TYPES.include?(fk_type) && UUID_TYPES.include?(pk_type)) ||
124
+ (UUID_TYPES.include?(fk_type) && STRING_TYPES.include?(pk_type))
125
+
126
+ false
127
+ end
128
+
129
+ def likely_uuid_column?(foreign_key_column, primary_key_column)
130
+ # Check if string columns are likely to be UUIDs based on common patterns
131
+ # This helps when applications use string columns to store UUIDs
132
+
133
+ # Check column limits (UUIDs are typically 36 characters with dashes, 32 without)
134
+ fk_limit = foreign_key_column.respond_to?(:limit) ? foreign_key_column.limit : nil
135
+ pk_limit = primary_key_column.respond_to?(:limit) ? primary_key_column.limit : nil
136
+
137
+ # Common UUID string lengths
138
+ uuid_lengths = [32, 36]
139
+
140
+ # If both columns have limits that match UUID lengths, likely UUIDs
141
+ return true if fk_limit && pk_limit &&
142
+ uuid_lengths.include?(fk_limit) && uuid_lengths.include?(pk_limit)
143
+
144
+ # Check column names for UUID patterns
145
+ uuid_name_patterns = %w[uuid guid]
146
+ fk_name_lower = foreign_key_column.name.downcase
147
+ pk_name_lower = primary_key_column.name.downcase
148
+
149
+ uuid_name_patterns.any? do |pattern|
150
+ fk_name_lower.include?(pattern) || pk_name_lower.include?(pattern)
151
+ end
152
+ end
153
+
154
+ def check_table_primary_key(table_name, foreign_key_column)
155
+ # Fallback method when model class is not available
156
+ # Check if the table has an 'id' column with compatible type
157
+ begin
158
+ connection = ActiveRecord::Base.connection
159
+ primary_key_name = connection.primary_key(table_name)
160
+
161
+ return false unless primary_key_name
162
+
163
+ table_columns = connection.columns(table_name)
164
+ primary_key_column = table_columns.find { |col| col.name == primary_key_name }
165
+
166
+ return false unless primary_key_column
167
+
168
+ compatible_types?(foreign_key_column, primary_key_column)
169
+ rescue
170
+ # If there's any error accessing the table structure, be conservative and return false
171
+ false
172
+ end
173
+ end
174
+
175
+ def get_usage_stats
176
+ @usage_stats ||= UsageTracker.track_foreign_key_usage(@model_class)
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,111 @@
1
+ require_relative "base_command"
2
+ require_relative "../analyzers/foreign_key_detector"
3
+
4
+ module SchemaSherlock
5
+ module Commands
6
+ class AnalyzeCommand < BaseCommand
7
+ desc "analyze [MODEL]", "Analyze models for missing associations and optimization opportunities"
8
+ option :output, type: :string, desc: "Output file for analysis results"
9
+ option :min_usage, type: :numeric, desc: "Minimum usage threshold for suggestions (overrides config)"
10
+
11
+ def analyze(model_name = nil)
12
+ load_rails_environment
13
+
14
+ # Override configuration if min_usage option provided
15
+ if options[:min_usage]
16
+ original_threshold = SchemaSherlock.configuration.min_usage_threshold
17
+ SchemaSherlock.configuration.min_usage_threshold = options[:min_usage]
18
+ end
19
+
20
+ models = model_name ? [find_model(model_name)] : all_models
21
+
22
+ puts "Analyzing #{models.length} model(s)..."
23
+
24
+ results = {}
25
+
26
+ models.each do |model|
27
+ puts " Analyzing #{model.name}..."
28
+ analysis = analyze_model(model)
29
+
30
+ # Only include models with issues in results
31
+ if has_issues?(analysis)
32
+ results[model.name] = analysis
33
+ end
34
+ end
35
+
36
+ display_results(results, models.length)
37
+ save_results(results) if options[:output]
38
+ rescue SchemaSherlock::Error => e
39
+ say e.message, :red
40
+ exit 1
41
+ ensure
42
+ # Restore original threshold if it was overridden
43
+ if options[:min_usage] && defined?(original_threshold)
44
+ SchemaSherlock.configuration.min_usage_threshold = original_threshold
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def analyze_model(model)
51
+ {
52
+ foreign_key_analysis: run_foreign_key_analysis(model)
53
+ }
54
+ end
55
+
56
+ def run_foreign_key_analysis(model)
57
+ analyzer = SchemaSherlock::Analyzers::ForeignKeyDetector.new(model)
58
+ analyzer.analyze
59
+ analyzer.results
60
+ end
61
+
62
+ def has_issues?(analysis)
63
+ foreign_key_analysis = analysis[:foreign_key_analysis]
64
+ missing = foreign_key_analysis[:missing_associations]
65
+ orphaned = foreign_key_analysis[:orphaned_foreign_keys]
66
+
67
+ missing.any? || orphaned.any?
68
+ end
69
+
70
+ def display_results(results, total_models)
71
+ puts "\n" + "="*50
72
+ puts "Schema Sherlock Investigation Report"
73
+ puts "="*50
74
+
75
+ results.each do |model_name, analysis|
76
+ puts "\n#{model_name}:"
77
+
78
+ missing = analysis[:foreign_key_analysis][:missing_associations]
79
+ if missing.any?
80
+ puts " Missing Associations:"
81
+ missing.each do |assoc|
82
+ usage_info = assoc[:usage_count] ? " (used #{assoc[:usage_count]} times)" : ""
83
+ puts " belongs_to :#{assoc[:suggested_association]} # #{assoc[:column]} foreign key exists#{usage_info}"
84
+ end
85
+ end
86
+
87
+ orphaned = analysis[:foreign_key_analysis][:orphaned_foreign_keys]
88
+ if orphaned.any?
89
+ puts " Orphaned Foreign Keys:"
90
+ orphaned.each do |key|
91
+ puts " #{key[:column]} -> #{key[:issue]}"
92
+ end
93
+ end
94
+ end
95
+
96
+ puts "\n" + "="*50
97
+ puts "SUMMARY"
98
+ puts "="*50
99
+ puts "Models Analyzed: #{total_models}"
100
+ puts "Models with Issues: #{results.length}"
101
+ puts "Models without Issues: #{total_models - results.length}"
102
+ puts "Usage Threshold: #{SchemaSherlock.configuration.min_usage_threshold} occurrences"
103
+ end
104
+
105
+ def save_results(results)
106
+ File.write(options[:output], results.to_json)
107
+ puts "\nResults saved to #{options[:output]}"
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,32 @@
1
+ require "thor"
2
+ require_relative "../model_loader"
3
+
4
+ module SchemaSherlock
5
+ module Commands
6
+ class BaseCommand < Thor
7
+ protected
8
+
9
+ def load_rails_environment
10
+ unless defined?(Rails)
11
+ # Try to load Rails if not already loaded
12
+ config_path = File.expand_path("config/environment.rb", Dir.pwd)
13
+ if File.exist?(config_path)
14
+ require config_path
15
+ else
16
+ raise SchemaSherlock::Error, "Rails environment not found. Make sure you're running this from a Rails application root."
17
+ end
18
+ end
19
+ rescue LoadError => e
20
+ raise SchemaSherlock::Error, "Could not load Rails environment: #{e.message}"
21
+ end
22
+
23
+ def all_models
24
+ ModelLoader.all_models
25
+ end
26
+
27
+ def find_model(name)
28
+ ModelLoader.find_model(name)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,11 @@
1
+ module SchemaSherlock
2
+ class Configuration
3
+ attr_accessor :exclude_models,
4
+ :min_usage_threshold
5
+
6
+ def initialize
7
+ @exclude_models = ['ActiveRecord::Base']
8
+ @min_usage_threshold = 3
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,76 @@
1
+ module SchemaSherlock
2
+ module ModelLoader
3
+ class << self
4
+ def all_models
5
+ ensure_rails_loaded!
6
+ load_application_models
7
+
8
+ ActiveRecord::Base.descendants.select do |klass|
9
+ includable_model?(klass)
10
+ end
11
+ end
12
+
13
+ def find_model(name)
14
+ ensure_rails_loaded!
15
+
16
+ klass = name.safe_constantize || name.camelize.safe_constantize
17
+
18
+ unless klass
19
+ raise SchemaSherlock::Error, "Could not find model: #{name}"
20
+ end
21
+
22
+ unless klass < ActiveRecord::Base
23
+ raise SchemaSherlock::Error, "#{name} is not an ActiveRecord model"
24
+ end
25
+
26
+ unless klass.table_exists?
27
+ raise SchemaSherlock::Error, "Table for #{name} does not exist"
28
+ end
29
+
30
+ klass
31
+ end
32
+
33
+ private
34
+
35
+ def ensure_rails_loaded!
36
+ unless defined?(Rails) && Rails.application
37
+ raise SchemaSherlock::Error, "Rails application not loaded"
38
+ end
39
+ end
40
+
41
+ def load_application_models
42
+ # Use Rails standard eager loading
43
+ Rails.application.eager_load!
44
+
45
+ # Also try to load models from common directories
46
+ %w[app/models app/models/concerns].each do |dir|
47
+ path = Rails.root.join(dir)
48
+ next unless path.exist?
49
+
50
+ Dir.glob(path.join("**/*.rb")).each do |file|
51
+ require_dependency file
52
+ rescue LoadError, NameError
53
+ # Skip files that can't be loaded
54
+ end
55
+ end
56
+ end
57
+
58
+ def includable_model?(klass)
59
+ return false unless klass.name
60
+ return false if klass.abstract_class?
61
+ return false if klass.name.start_with?("HABTM_")
62
+ return false unless klass.table_exists?
63
+ return false if excluded_model?(klass)
64
+
65
+ true
66
+ rescue => e
67
+ # Skip models that raise errors
68
+ false
69
+ end
70
+
71
+ def excluded_model?(klass)
72
+ SchemaSherlock.configuration.exclude_models.include?(klass.name)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,79 @@
1
+ module SchemaSherlock
2
+ class UsageTracker
3
+ class << self
4
+ def track_foreign_key_usage(model_class)
5
+ return {} unless SchemaSherlock.configuration.min_usage_threshold
6
+
7
+ scan_codebase_for_usage(model_class)
8
+ end
9
+
10
+
11
+ private
12
+
13
+ def scan_codebase_for_usage(model_class)
14
+ usage_counts = {}
15
+ table_name = model_class.table_name
16
+
17
+ foreign_key_columns(model_class).each do |column|
18
+ count = scan_for_column_usage(table_name, column.name)
19
+ usage_counts[column.name] = count if count > 0
20
+ end
21
+
22
+ usage_counts
23
+ end
24
+
25
+ def foreign_key_columns(model_class)
26
+ model_class.columns.select { |col| col.name.end_with?('_id') && col.name != 'id' }
27
+ end
28
+
29
+ def scan_for_column_usage(table_name, column_name)
30
+ count = 0
31
+
32
+ # Scan common Rails directories for usage patterns
33
+ scan_directories.each do |dir|
34
+ next unless Dir.exist?(dir)
35
+
36
+ Dir.glob("#{dir}/**/*.rb").each do |file|
37
+ content = File.read(file)
38
+ count += count_column_references(content, table_name, column_name)
39
+ rescue
40
+ # Skip files that can't be read
41
+ end
42
+ end
43
+
44
+ count
45
+ end
46
+
47
+ def scan_directories
48
+ return [] unless defined?(Rails) && Rails.root
49
+
50
+ [
51
+ Rails.root.join('app', 'controllers'),
52
+ Rails.root.join('app', 'models'),
53
+ Rails.root.join('app', 'services'),
54
+ Rails.root.join('app', 'jobs'),
55
+ Rails.root.join('lib')
56
+ ].map(&:to_s)
57
+ end
58
+
59
+
60
+ def count_column_references(content, table_name, column_name)
61
+ count = 0
62
+
63
+ # Count WHERE clauses using the foreign key
64
+ count += content.scan(/\.where\s*\(\s*['":]?#{column_name}['":]?\s*[=:]/i).length
65
+ count += content.scan(/\.find_by\s*\(\s*['":]?#{column_name}['":]?\s*[=:]/i).length
66
+
67
+ # Count joins using the foreign key
68
+ association_name = column_name.gsub(/_id$/, '')
69
+ count += content.scan(/\.joins\s*\(\s*['":]?#{association_name}['":]?\s*\)/i).length
70
+ count += content.scan(/\.includes\s*\(\s*['":]?#{association_name}['":]?\s*\)/i).length
71
+
72
+ # Count direct foreign key access
73
+ count += content.scan(/\.#{column_name}\b/i).length
74
+
75
+ count
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,3 @@
1
+ module SchemaSherlock
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,19 @@
1
+ require_relative "schema_sherlock/version"
2
+ require_relative "schema_sherlock/configuration"
3
+ require_relative "schema_sherlock/railtie" if defined?(Rails)
4
+
5
+ module SchemaSherlock
6
+ class Error < StandardError; end
7
+
8
+ def self.configure
9
+ yield(configuration)
10
+ end
11
+
12
+ def self.configuration
13
+ @configuration ||= Configuration.new
14
+ end
15
+
16
+ def self.reset_configuration!
17
+ @configuration = Configuration.new
18
+ end
19
+ end
@@ -0,0 +1,36 @@
1
+ require_relative "lib/schema_sherlock/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "schema_sherlock"
5
+ spec.version = SchemaSherlock::VERSION
6
+ spec.authors = ["Prateek Choudhary"]
7
+ spec.email = ["prateekkish@gmail.com"]
8
+
9
+ spec.summary = "Intelligent Rails model analysis and annotation tool"
10
+ spec.description = "Extends beyond traditional schema annotation to provide intelligent analysis and actionable suggestions for Rails model code quality, performance, and maintainability."
11
+ spec.homepage = "https://github.com/prateekkish/schema_sherlock"
12
+ spec.license = "MIT"
13
+ spec.required_ruby_version = ">= 2.7.0"
14
+
15
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
16
+ spec.metadata["homepage_uri"] = spec.homepage
17
+ spec.metadata["source_code_uri"] = "https://github.com/prateekkish/schema_sherlock"
18
+ spec.metadata["changelog_uri"] = "https://github.com/prateekkish/schema_sherlock/blob/main/CHANGELOG.md"
19
+
20
+ # Specify which files should be added to the gem when it is released.
21
+ spec.files = Dir.chdir(__dir__) do
22
+ `git ls-files -z`.split("\x0").reject do |f|
23
+ (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
24
+ end
25
+ end
26
+ spec.executables = ["schema_sherlock"]
27
+ spec.require_paths = ["lib"]
28
+
29
+ # Dependencies
30
+ spec.add_dependency "rails", ">= 6.0"
31
+ spec.add_dependency "thor", "~> 1.0"
32
+ spec.add_dependency "activerecord", ">= 6.0"
33
+
34
+ # Development dependencies
35
+ spec.add_development_dependency "rspec", "~> 3.0"
36
+ end
metadata ADDED
@@ -0,0 +1,122 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: schema_sherlock
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Prateek Choudhary
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-05-23 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: '6.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '6.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: thor
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: activerecord
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '6.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '6.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ description: Extends beyond traditional schema annotation to provide intelligent analysis
70
+ and actionable suggestions for Rails model code quality, performance, and maintainability.
71
+ email:
72
+ - prateekkish@gmail.com
73
+ executables:
74
+ - schema_sherlock
75
+ extensions: []
76
+ extra_rdoc_files: []
77
+ files:
78
+ - CHANGELOG.md
79
+ - Gemfile
80
+ - Gemfile.lock
81
+ - LICENSE.md
82
+ - README.md
83
+ - Rakefile
84
+ - bin/schema_sherlock
85
+ - lib/schema_sherlock.rb
86
+ - lib/schema_sherlock/analyzers/base_analyzer.rb
87
+ - lib/schema_sherlock/analyzers/foreign_key_detector.rb
88
+ - lib/schema_sherlock/commands/analyze_command.rb
89
+ - lib/schema_sherlock/commands/base_command.rb
90
+ - lib/schema_sherlock/configuration.rb
91
+ - lib/schema_sherlock/model_loader.rb
92
+ - lib/schema_sherlock/usage_tracker.rb
93
+ - lib/schema_sherlock/version.rb
94
+ - schema_sherlock.gemspec
95
+ homepage: https://github.com/prateekkish/schema_sherlock
96
+ licenses:
97
+ - MIT
98
+ metadata:
99
+ allowed_push_host: https://rubygems.org
100
+ homepage_uri: https://github.com/prateekkish/schema_sherlock
101
+ source_code_uri: https://github.com/prateekkish/schema_sherlock
102
+ changelog_uri: https://github.com/prateekkish/schema_sherlock/blob/main/CHANGELOG.md
103
+ post_install_message:
104
+ rdoc_options: []
105
+ require_paths:
106
+ - lib
107
+ required_ruby_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: 2.7.0
112
+ required_rubygems_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ requirements: []
118
+ rubygems_version: 3.5.16
119
+ signing_key:
120
+ specification_version: 4
121
+ summary: Intelligent Rails model analysis and annotation tool
122
+ test_files: []