rails_db_inspector 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.
Files changed (32) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +232 -0
  4. data/Rakefile +3 -0
  5. data/app/assets/stylesheets/rails_db_inspector/application.css +41 -0
  6. data/app/controllers/rails_db_inspector/application_controller.rb +15 -0
  7. data/app/controllers/rails_db_inspector/queries_controller.rb +42 -0
  8. data/app/controllers/rails_db_inspector/schema_controller.rb +13 -0
  9. data/app/helpers/rails_db_inspector/application_helper.rb +274 -0
  10. data/app/helpers/rails_db_inspector/plan_renderer.rb +887 -0
  11. data/app/jobs/rails_db_inspector/application_job.rb +4 -0
  12. data/app/mailers/rails_db_inspector/application_mailer.rb +6 -0
  13. data/app/models/rails_db_inspector/application_record.rb +5 -0
  14. data/app/views/layouts/rails_db_inspector/application.html.erb +55 -0
  15. data/app/views/rails_db_inspector/queries/explain.html.erb +128 -0
  16. data/app/views/rails_db_inspector/queries/index.html.erb +258 -0
  17. data/app/views/rails_db_inspector/queries/show.html.erb +103 -0
  18. data/app/views/rails_db_inspector/schema/index.html.erb +842 -0
  19. data/config/routes.rb +17 -0
  20. data/lib/rails_db_inspector/configuration.rb +17 -0
  21. data/lib/rails_db_inspector/dev_widget_middleware.rb +145 -0
  22. data/lib/rails_db_inspector/engine.rb +22 -0
  23. data/lib/rails_db_inspector/explain/my_sql.rb +28 -0
  24. data/lib/rails_db_inspector/explain/postgres.rb +32 -0
  25. data/lib/rails_db_inspector/explain.rb +27 -0
  26. data/lib/rails_db_inspector/query_store.rb +89 -0
  27. data/lib/rails_db_inspector/schema_inspector.rb +222 -0
  28. data/lib/rails_db_inspector/sql_subscriber.rb +42 -0
  29. data/lib/rails_db_inspector/version.rb +3 -0
  30. data/lib/rails_db_inspector.rb +25 -0
  31. data/lib/tasks/rails_db_inspector_tasks.rake +4 -0
  32. metadata +91 -0
data/config/routes.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ RailsDbInspector::Engine.routes.draw do
4
+ root to: "queries#index"
5
+
6
+ resources :queries, only: %i[index show] do
7
+ member do
8
+ get :explain
9
+ end
10
+
11
+ collection do
12
+ post :clear
13
+ end
14
+ end
15
+
16
+ get "schema", to: "schema#index", as: :schema_index
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDbInspector
4
+ class Configuration
5
+ attr_accessor :enabled
6
+ attr_accessor :max_queries
7
+ attr_accessor :allow_explain_analyze
8
+ attr_accessor :show_widget
9
+
10
+ def initialize
11
+ @enabled = true
12
+ @max_queries = 2_000
13
+ @allow_explain_analyze = false
14
+ @show_widget = true
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDbInspector
4
+ class DevWidgetMiddleware
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def call(env)
10
+ status, headers, response = @app.call(env)
11
+
12
+ # Only inject into HTML responses in development
13
+ return [ status, headers, response ] unless injectable?(status, headers, env)
14
+
15
+ body = +""
16
+ response.each { |part| body << part }
17
+ response.close if response.respond_to?(:close)
18
+
19
+ # Find the mount path for the engine
20
+ mount_path = find_mount_path
21
+
22
+ if body.include?("</body>") && mount_path
23
+ widget_html = render_widget(mount_path)
24
+ body.sub!("</body>", "#{widget_html}\n</body>")
25
+ headers["Content-Length"] = body.bytesize.to_s
26
+ end
27
+
28
+ [ status, headers, [ body ] ]
29
+ end
30
+
31
+ private
32
+
33
+ def injectable?(status, headers, env)
34
+ return false unless status == 200
35
+ return false unless headers["Content-Type"]&.include?("text/html")
36
+
37
+ # Don't inject into the engine's own pages
38
+ mount_path = find_mount_path
39
+ return false if mount_path && env["PATH_INFO"]&.start_with?(mount_path)
40
+
41
+ true
42
+ end
43
+
44
+ def find_mount_path
45
+ @mount_path ||= begin
46
+ Rails.application.routes.routes.each do |route|
47
+ if route.app.respond_to?(:app) && route.app.app == RailsDbInspector::Engine
48
+ return "/" + route.path.spec.to_s.sub(/\(.*\)/, "").gsub(%r{^/|/$}, "")
49
+ end
50
+ end
51
+ nil
52
+ end
53
+ end
54
+
55
+ def render_widget(mount_path)
56
+ queries_url = "#{mount_path}"
57
+ schema_url = "#{mount_path}/schema"
58
+
59
+ <<~HTML
60
+ <!-- Rails DB Inspector Dev Widget -->
61
+ <div id="rdi-widget" style="
62
+ position: fixed;
63
+ bottom: 16px;
64
+ right: 16px;
65
+ z-index: 99999;
66
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
67
+ ">
68
+ <div id="rdi-panel" style="
69
+ display: none;
70
+ background: #1f2937;
71
+ border-radius: 12px;
72
+ padding: 12px;
73
+ margin-bottom: 8px;
74
+ box-shadow: 0 10px 25px rgba(0,0,0,0.3);
75
+ min-width: 200px;
76
+ ">
77
+ <div style="color: #9ca3af; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; padding: 0 4px;">
78
+ DB Inspector
79
+ </div>
80
+ <a href="#{queries_url}" target="_blank" rel="noopener" style="
81
+ display: flex;
82
+ align-items: center;
83
+ padding: 8px 12px;
84
+ color: #e5e7eb;
85
+ text-decoration: none;
86
+ font-size: 13px;
87
+ font-weight: 500;
88
+ border-radius: 8px;
89
+ margin-bottom: 4px;
90
+ transition: background 0.15s;
91
+ " onmouseover="this.style.background='#374151'" onmouseout="this.style.background='transparent'">
92
+ <span style="margin-right: 8px; font-size: 16px;">🔍</span>
93
+ Query Monitor
94
+ </a>
95
+ <a href="#{schema_url}" target="_blank" rel="noopener" style="
96
+ display: flex;
97
+ align-items: center;
98
+ padding: 8px 12px;
99
+ color: #e5e7eb;
100
+ text-decoration: none;
101
+ font-size: 13px;
102
+ font-weight: 500;
103
+ border-radius: 8px;
104
+ transition: background 0.15s;
105
+ " onmouseover="this.style.background='#374151'" onmouseout="this.style.background='transparent'">
106
+ <span style="margin-right: 8px; font-size: 16px;">🗄️</span>
107
+ Schema Visualization
108
+ </a>
109
+ </div>
110
+ <button onclick="
111
+ var panel = document.getElementById('rdi-panel');
112
+ panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
113
+ " style="
114
+ width: 48px;
115
+ height: 48px;
116
+ border-radius: 50%;
117
+ background: #2563eb;
118
+ border: none;
119
+ color: white;
120
+ font-size: 20px;
121
+ cursor: pointer;
122
+ box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
123
+ display: flex;
124
+ align-items: center;
125
+ justify-content: center;
126
+ transition: transform 0.15s, box-shadow 0.15s;
127
+ margin-left: auto;
128
+ " onmouseover="this.style.transform='scale(1.1)';this.style.boxShadow='0 6px 16px rgba(37,99,235,0.5)'" onmouseout="this.style.transform='scale(1)';this.style.boxShadow='0 4px 12px rgba(37,99,235,0.4)'">
129
+ 🛢️
130
+ </button>
131
+ </div>
132
+ <script>
133
+ document.addEventListener('click', function(e) {
134
+ var widget = document.getElementById('rdi-widget');
135
+ var panel = document.getElementById('rdi-panel');
136
+ if (panel && widget && !widget.contains(e.target)) {
137
+ panel.style.display = 'none';
138
+ }
139
+ });
140
+ </script>
141
+ <!-- /Rails DB Inspector Dev Widget -->
142
+ HTML
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dev_widget_middleware"
4
+
5
+ module RailsDbInspector
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace RailsDbInspector
8
+
9
+ initializer "rails_db_inspector.subscribe_sql" do
10
+ next unless RailsDbInspector.configuration.enabled
11
+ RailsDbInspector::SqlSubscriber.install!
12
+ end
13
+
14
+ initializer "rails_db_inspector.dev_widget" do |app|
15
+ next unless RailsDbInspector.configuration.enabled
16
+ next unless RailsDbInspector.configuration.show_widget
17
+ next unless Rails.env.development?
18
+
19
+ app.middleware.use RailsDbInspector::DevWidgetMiddleware
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDbInspector
4
+ class Explain
5
+ class MySql
6
+ def initialize(connection)
7
+ @connection = connection
8
+ end
9
+
10
+ def explain(sql, analyze: false)
11
+ # Strip any existing EXPLAIN prefix to prevent doubling
12
+ clean_sql = sql.sub(/\A\s*EXPLAIN\s*(ANALYZE)?\s*/i, "")
13
+
14
+ statement =
15
+ if analyze
16
+ RailsDbInspector::Explain.select_only!(clean_sql)
17
+ "EXPLAIN ANALYZE #{clean_sql}"
18
+ else
19
+ "EXPLAIN #{clean_sql}"
20
+ end
21
+
22
+ result = @connection.exec_query(statement)
23
+
24
+ { adapter: "mysql", analyze: analyze, columns: result.columns, rows: result.rows }
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RailsDbInspector
6
+ class Explain
7
+ class Postgres
8
+ def initialize(connection)
9
+ @connection = connection
10
+ end
11
+
12
+ def explain(sql, analyze: false)
13
+ # Strip any existing EXPLAIN prefix to prevent doubling
14
+ clean_sql = sql.sub(/\A\s*EXPLAIN\s*(\([^)]*\))?\s*/i, "")
15
+
16
+ statement =
17
+ if analyze
18
+ RailsDbInspector::Explain.select_only!(clean_sql)
19
+ "EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT JSON) #{clean_sql}"
20
+ else
21
+ "EXPLAIN (FORMAT JSON) #{clean_sql}"
22
+ end
23
+
24
+ result = @connection.exec_query(statement)
25
+ raw = result.rows.dig(0, 0)
26
+ plan = raw.is_a?(String) ? JSON.parse(raw) : raw
27
+
28
+ { adapter: "postgres", analyze: analyze, plan: plan }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDbInspector
4
+ class Explain
5
+ class UnsupportedAdapter < StandardError; end
6
+ class DangerousQuery < StandardError; end
7
+
8
+ def self.for_connection(connection)
9
+ adapter = connection.adapter_name.to_s.downcase
10
+
11
+ case adapter
12
+ when /postgres/
13
+ RailsDbInspector::Explain::Postgres.new(connection)
14
+ when /mysql/
15
+ RailsDbInspector::Explain::MySql.new(connection)
16
+ else
17
+ raise UnsupportedAdapter, "Unsupported adapter: #{connection.adapter_name}"
18
+ end
19
+ end
20
+
21
+ def self.select_only!(sql)
22
+ return if sql.strip.match?(/\ASELECT\b/i)
23
+
24
+ raise DangerousQuery, "Only SELECT is allowed for EXPLAIN ANALYZE by default"
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+ require "securerandom"
5
+ require "thread"
6
+
7
+ module RailsDbInspector
8
+ class QueryStore
9
+ include Singleton
10
+
11
+ Query = Struct.new(
12
+ :id,
13
+ :sql,
14
+ :name,
15
+ :binds,
16
+ :duration_ms,
17
+ :connection_id,
18
+ :timestamp,
19
+ keyword_init: true
20
+ )
21
+
22
+ def initialize
23
+ @mutex = Mutex.new
24
+ @queries = []
25
+ @by_id = {}
26
+ end
27
+
28
+ def add(sql:, name:, binds:, duration_ms:, connection_id:, timestamp:)
29
+ q = Query.new(
30
+ id: SecureRandom.hex(8),
31
+ sql: sql,
32
+ name: name,
33
+ binds: normalize_binds(binds),
34
+ duration_ms: duration_ms.to_f,
35
+ connection_id: connection_id,
36
+ timestamp: timestamp
37
+ )
38
+
39
+ @mutex.synchronize do
40
+ @queries << q
41
+ @by_id[q.id] = q
42
+ trim!
43
+ end
44
+
45
+ q
46
+ end
47
+
48
+ def all
49
+ @mutex.synchronize { @queries.dup }
50
+ end
51
+
52
+ def find(id)
53
+ @mutex.synchronize { @by_id[id] }
54
+ end
55
+
56
+ def clear!
57
+ @mutex.synchronize do
58
+ @queries.clear
59
+ @by_id.clear
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def trim!
66
+ max = RailsDbInspector.configuration.max_queries.to_i
67
+ return if max <= 0
68
+ return if @queries.length <= max
69
+
70
+ drop_count = @queries.length - max
71
+ dropped = @queries.shift(drop_count)
72
+ dropped.each { |q| @by_id.delete(q.id) }
73
+ end
74
+
75
+ def normalize_binds(binds)
76
+ return [] unless binds.is_a?(Array)
77
+
78
+ binds.map do |b|
79
+ if b.respond_to?(:name) && b.respond_to?(:value_before_type_cast)
80
+ { name: b.name, value: b.value_before_type_cast, type: b.type&.type }
81
+ elsif b.is_a?(Array) && b.length == 2
82
+ { name: b[0].to_s, value: b[1], type: nil }
83
+ else
84
+ { name: nil, value: b.to_s, type: nil }
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDbInspector
4
+ class SchemaInspector
5
+ attr_reader :connection
6
+
7
+ def initialize(connection = ActiveRecord::Base.connection)
8
+ @connection = connection
9
+ end
10
+
11
+ IGNORED_TABLES = %w[schema_migrations ar_internal_metadata].freeze
12
+
13
+ # Returns a hash of table_name => { columns:, indexes:, foreign_keys: }
14
+ def introspect
15
+ tables = connection.tables.sort - IGNORED_TABLES
16
+ schema = {}
17
+
18
+ tables.each do |table|
19
+ columns = introspect_columns(table)
20
+ indexes = introspect_indexes(table)
21
+ schema[table] = {
22
+ columns: columns,
23
+ indexes: indexes,
24
+ foreign_keys: introspect_foreign_keys(table),
25
+ primary_key: introspect_primary_key(table),
26
+ row_count: safe_row_count(table),
27
+ associations: introspect_associations(table),
28
+ missing_indexes: detect_missing_indexes(columns, indexes),
29
+ polymorphic_columns: detect_polymorphic_columns(columns)
30
+ }
31
+ end
32
+
33
+ schema
34
+ end
35
+
36
+ # Returns relationships between tables for visualization
37
+ def relationships
38
+ rels = []
39
+
40
+ (connection.tables.sort - IGNORED_TABLES).each do |table|
41
+ # Foreign key-based relationships
42
+ if connection.respond_to?(:foreign_keys)
43
+ connection.foreign_keys(table).each do |fk|
44
+ rels << {
45
+ from_table: table,
46
+ from_column: fk.column,
47
+ to_table: fk.to_table,
48
+ to_column: fk.primary_key || "id",
49
+ type: :foreign_key
50
+ }
51
+ end
52
+ end
53
+
54
+ # Convention-based relationships (belongs_to via _id columns)
55
+ connection.columns(table).each do |col|
56
+ next unless col.name.end_with?("_id")
57
+
58
+ referenced_table = col.name.sub(/_id\z/, "").pluralize
59
+ next unless connection.tables.include?(referenced_table)
60
+
61
+ # Skip if already covered by a foreign key
62
+ already_covered = rels.any? do |r|
63
+ r[:from_table] == table && r[:from_column] == col.name
64
+ end
65
+ next if already_covered
66
+
67
+ rels << {
68
+ from_table: table,
69
+ from_column: col.name,
70
+ to_table: referenced_table,
71
+ to_column: "id",
72
+ type: :convention
73
+ }
74
+ end
75
+ end
76
+
77
+ rels
78
+ end
79
+
80
+ private
81
+
82
+ def introspect_columns(table)
83
+ connection.columns(table).map do |col|
84
+ {
85
+ name: col.name,
86
+ type: col.sql_type,
87
+ nullable: col.null,
88
+ default: col.default
89
+ }
90
+ end
91
+ end
92
+
93
+ def introspect_indexes(table)
94
+ connection.indexes(table).map do |idx|
95
+ {
96
+ name: idx.name,
97
+ columns: idx.columns,
98
+ unique: idx.unique
99
+ }
100
+ end
101
+ end
102
+
103
+ def introspect_foreign_keys(table)
104
+ return [] unless connection.respond_to?(:foreign_keys)
105
+
106
+ connection.foreign_keys(table).map do |fk|
107
+ {
108
+ column: fk.column,
109
+ to_table: fk.to_table,
110
+ primary_key: fk.primary_key || "id",
111
+ name: fk.name
112
+ }
113
+ end
114
+ end
115
+
116
+ def introspect_primary_key(table)
117
+ connection.primary_key(table)
118
+ end
119
+
120
+ def safe_row_count(table)
121
+ quoted = connection.quote_table_name(table)
122
+ result = connection.select_value("SELECT COUNT(*) FROM #{quoted}")
123
+ result.to_i
124
+ rescue StandardError
125
+ nil
126
+ end
127
+
128
+ def introspect_associations(table)
129
+ model = find_model_for_table(table)
130
+ return [] unless model
131
+
132
+ model.reflect_on_all_associations.map do |assoc|
133
+ target_table = begin
134
+ assoc.klass.table_name
135
+ rescue StandardError
136
+ nil
137
+ end
138
+
139
+ {
140
+ name: assoc.name.to_s,
141
+ macro: assoc.macro.to_s,
142
+ target_table: target_table,
143
+ foreign_key: assoc.foreign_key.to_s,
144
+ through: assoc.options[:through]&.to_s
145
+ }
146
+ end
147
+ rescue StandardError
148
+ []
149
+ end
150
+
151
+ # Detect _id columns that lack a corresponding index
152
+ def detect_missing_indexes(columns, indexes)
153
+ indexed_columns = indexes.flat_map { |idx| idx[:columns] }.to_set
154
+
155
+ columns
156
+ .select { |col| col[:name].end_with?("_id") }
157
+ .reject { |col| indexed_columns.include?(col[:name]) }
158
+ .map { |col| col[:name] }
159
+ end
160
+
161
+ # Detect polymorphic column pairs (*_type + *_id)
162
+ def detect_polymorphic_columns(columns)
163
+ col_names = columns.map { |c| c[:name] }.to_set
164
+ polymorphics = []
165
+
166
+ columns.each do |col|
167
+ next unless col[:name].end_with?("_type")
168
+
169
+ base = col[:name].sub(/_type\z/, "")
170
+ id_col = "#{base}_id"
171
+ next unless col_names.include?(id_col)
172
+
173
+ polymorphics << {
174
+ name: base,
175
+ type_column: col[:name],
176
+ id_column: id_col
177
+ }
178
+ end
179
+
180
+ polymorphics
181
+ end
182
+
183
+ def find_model_for_table(table)
184
+ # Force-load all models so descendants are populated
185
+ eager_load_models!
186
+
187
+ ActiveRecord::Base.descendants.detect do |klass|
188
+ klass.table_name == table && !klass.abstract_class?
189
+ rescue StandardError
190
+ false
191
+ end
192
+ rescue StandardError
193
+ nil
194
+ end
195
+
196
+ def eager_load_models!
197
+ return if @models_loaded
198
+ @models_loaded = true
199
+
200
+ # Use Zeitwerk autoloader to load all models
201
+ model_paths = Rails.application.config.paths["app/models"].to_a
202
+ model_paths.each do |dir|
203
+ Dir.glob("#{dir}/**/*.rb").sort.each do |file|
204
+ begin
205
+ require file
206
+ rescue StandardError, LoadError
207
+ # Skip models that fail to load
208
+ end
209
+ end
210
+ end
211
+
212
+ # Fallback: eager load the whole app if no descendants found
213
+ if ActiveRecord::Base.descendants.reject { |k| k.abstract_class? rescue true }.empty?
214
+ begin
215
+ Rails.application.eager_load!
216
+ rescue StandardError
217
+ # ignore
218
+ end
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDbInspector
4
+ class SqlSubscriber
5
+ IGNORED_NAMES = [
6
+ "SCHEMA",
7
+ "TRANSACTION",
8
+ "ActiveRecord::SchemaMigration Load",
9
+ "ActiveRecord::InternalMetadata Load"
10
+ ].freeze
11
+
12
+ def self.install!
13
+ return if @installed
14
+
15
+ @installed = true
16
+ RailsDbInspector::QueryStore.instance
17
+
18
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
19
+ event = ActiveSupport::Notifications::Event.new(*args)
20
+ payload = event.payload
21
+
22
+ name = payload[:name].to_s
23
+ sql = payload[:sql].to_s
24
+
25
+ next if sql.strip.empty?
26
+ next if IGNORED_NAMES.include?(name)
27
+ next if sql =~ /\A(?:BEGIN|COMMIT|ROLLBACK)\b/i
28
+ next if sql =~ /\A\s*EXPLAIN\b/i
29
+ next if payload[:cached]
30
+
31
+ RailsDbInspector::QueryStore.instance.add(
32
+ sql: sql,
33
+ name: name,
34
+ binds: payload[:binds],
35
+ duration_ms: event.duration,
36
+ connection_id: payload[:connection_id],
37
+ timestamp: (event.time.is_a?(Time) ? event.time : Time.at(event.time.to_f))
38
+ )
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,3 @@
1
+ module RailsDbInspector
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rails_db_inspector/version"
4
+ require_relative "rails_db_inspector/engine"
5
+ require_relative "rails_db_inspector/configuration"
6
+ require_relative "rails_db_inspector/query_store"
7
+ require_relative "rails_db_inspector/sql_subscriber"
8
+ require_relative "rails_db_inspector/explain"
9
+ require_relative "rails_db_inspector/explain/postgres"
10
+ require_relative "rails_db_inspector/explain/my_sql"
11
+ require_relative "rails_db_inspector/schema_inspector"
12
+
13
+ module RailsDbInspector
14
+ class Error < StandardError; end
15
+
16
+ class << self
17
+ def configuration
18
+ @configuration ||= Configuration.new
19
+ end
20
+
21
+ def configure
22
+ yield(configuration)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :rails_db_inspector do
3
+ # # Task goes here
4
+ # end