rails-query-command 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 +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +25 -0
- data/README.md +98 -0
- data/lib/rails/commands/query/USAGE +51 -0
- data/lib/rails/commands/query/query_command.rb +309 -0
- data/lib/rails_query_command/version.rb +5 -0
- data/lib/rails_query_command.rb +5 -0
- metadata +93 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 04c9d9094284ac4f93952cfcdd3ecd4649d430ef7c187a74b5056cd845c90997
|
|
4
|
+
data.tar.gz: 54c59107e0b483c4be257be924202c5971b0c361bf3caba7dd4b4f852d6dd16a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2339fe0e1cdb277aa9b13c0c3a575a18b15c9eb6754fe44b373851161c88f80f1623f0f2110f8785ab948fa2ef41fc6fa59667b13bb19fe1ce9f30ed42f7e9ec
|
|
7
|
+
data.tar.gz: f97379b216efa767e874c5de05deb27bccdfd436ecbae7633a5ef497608aec4b1f65fdc2e20bef272218f17a9e2d59b54b5fdd3597d86a159685ed7b02ad49ca
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Copyright (c) 2026 Lázaro Nixon Amaral de Almeida
|
|
2
|
+
|
|
3
|
+
The query command and its tests are taken from Ruby on Rails
|
|
4
|
+
(https://github.com/rails/rails), which is released under the MIT License:
|
|
5
|
+
|
|
6
|
+
Copyright (c) David Heinemeier Hansson
|
|
7
|
+
|
|
8
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
9
|
+
a copy of this software and associated documentation files (the
|
|
10
|
+
"Software"), to deal in the Software without restriction, including
|
|
11
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
12
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
13
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
14
|
+
the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be
|
|
17
|
+
included in all copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
20
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
21
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
22
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
23
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
24
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
25
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# rails-query-command
|
|
2
|
+
|
|
3
|
+
A backport of `bin/rails query` for **Rails 7.2, 8.0 and 8.1**.
|
|
4
|
+
|
|
5
|
+
`rails query` was added to Rails in [rails/rails#57156](https://github.com/rails/rails/pull/57156) and is planned for Rails 8.2. It runs read-only database queries and prints structured JSON, so agents and scripts can query a database (for example with `kamal app exec`) without risking writes or parsing free-form `rails runner` output.
|
|
6
|
+
|
|
7
|
+
This gem ships the command exactly as it is on Rails `main` (commit [`e9aab74b13`](https://github.com/rails/rails/commit/e9aab74b13)), including the fixes merged after the original PR.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# Gemfile
|
|
13
|
+
gem "rails-query-command"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
You don't need any setup. `bin/rails` finds the command on the load path, and it shows up in `bin/rails --help`.
|
|
17
|
+
|
|
18
|
+
**Upgrading to Rails 8.2:** the command is built into Rails 8.2, so remove this gem when you upgrade. The gemspec requires `railties < 8.2`, so Bundler will remind you.
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# Active Record expressions
|
|
24
|
+
bin/rails query "Account.where(plan: 'premium').limit(10)"
|
|
25
|
+
bin/rails query "Account.count"
|
|
26
|
+
|
|
27
|
+
# Raw SQL
|
|
28
|
+
bin/rails query --sql "SELECT id, name FROM accounts"
|
|
29
|
+
|
|
30
|
+
# Read from stdin
|
|
31
|
+
echo "Account.first" | bin/rails query -
|
|
32
|
+
|
|
33
|
+
# Pagination (default: --page 1 --per 100, max per page 10000)
|
|
34
|
+
bin/rails query "Account.all" --page 2 --per 50
|
|
35
|
+
|
|
36
|
+
# Introspection
|
|
37
|
+
bin/rails query schema # list tables
|
|
38
|
+
bin/rails query schema accounts # columns, indexes, enums, associations
|
|
39
|
+
bin/rails query models # models with table names and associations
|
|
40
|
+
bin/rails query explain "Account.where(plan: 'premium')"
|
|
41
|
+
|
|
42
|
+
# Pick a database config in a multi-database app
|
|
43
|
+
bin/rails query "Account.count" --database primary_replica
|
|
44
|
+
|
|
45
|
+
# Pretty-print / CSV with jq
|
|
46
|
+
bin/rails query "Account.first" | jq
|
|
47
|
+
bin/rails query "Account.limit(5)" | jq -r '.rows[] | @csv'
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The output looks like this:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"columns": ["id", "name", "plan"],
|
|
55
|
+
"rows": [[1, "Acme", "premium"], [2, "Widgets Co", "premium"]],
|
|
56
|
+
"meta": {
|
|
57
|
+
"row_count": 2,
|
|
58
|
+
"query_time_ms": 4.2,
|
|
59
|
+
"page": 1,
|
|
60
|
+
"per_page": 100,
|
|
61
|
+
"has_more": false,
|
|
62
|
+
"sql": "SELECT \"accounts\".* FROM \"accounts\" WHERE \"accounts\".\"plan\" = 'premium' LIMIT 101"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Errors are also JSON, written to stderr, and the command exits with status 1:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{"error": "Write query attempted while in readonly mode: INSERT INTO posts (title) VALUES ('x') LIMIT 101", "meta": {"query_time_ms": 0}}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Safety
|
|
74
|
+
|
|
75
|
+
- If a `reading` role is configured, queries run against it. Otherwise they run inside `while_preventing_writes`, so `INSERT`, `UPDATE`, `DELETE`, `Post.delete_all` and `record.update!` are rejected.
|
|
76
|
+
- Active Record expressions are evaluated as Ruby, so they're as trusted as `rails runner` and `rails console`. Write protection covers the database, not arbitrary Ruby. Use `--sql` when you only want SQL to run.
|
|
77
|
+
- Every query is wrapped in a `query.rails` `ActiveSupport::Notifications` event with an `expression` payload, which you can use for audit logging:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
ActiveSupport::Notifications.subscribe("query.rails") do |event|
|
|
81
|
+
Rails.logger.info("rails query: #{event.payload[:expression]}")
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Development
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
bundle install
|
|
89
|
+
bundle exec rake test # Rails 8.1
|
|
90
|
+
RAILS_VERSION=8.0 bundle update && RAILS_VERSION=8.0 bundle exec rake test
|
|
91
|
+
RAILS_VERSION=7.2 bundle update && RAILS_VERSION=7.2 bundle exec rake test
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The tests are ported from `railties/test/commands/query_test.rb`. Each test runs the real `bin/rails query` against a fresh copy of the app in `test/dummy`.
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT. The command and its tests come from [Ruby on Rails](https://github.com/rails/rails) and are © David Heinemeier Hansson. See `LICENSE.txt`.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Run read-only queries against your database. Connects to the
|
|
3
|
+
reading replica role by default and prevents database writes.
|
|
4
|
+
|
|
5
|
+
The --sql flag restricts execution to SQL only. ActiveRecord
|
|
6
|
+
expressions are evaluated as Ruby, with the same trust model
|
|
7
|
+
as rails runner and rails console.
|
|
8
|
+
|
|
9
|
+
Output is JSON. Pipe through jq for human-readable formatting.
|
|
10
|
+
|
|
11
|
+
Subcommands:
|
|
12
|
+
<%= executable %> schema [TABLE] Show tables or table detail with associations
|
|
13
|
+
<%= executable %> models List ActiveRecord models with associations
|
|
14
|
+
<%= executable %> explain EXPRESSION Show EXPLAIN output
|
|
15
|
+
|
|
16
|
+
Examples:
|
|
17
|
+
Run an ActiveRecord query:
|
|
18
|
+
|
|
19
|
+
<%= executable %> "Account.where(plan: 'premium').limit(10)"
|
|
20
|
+
|
|
21
|
+
Pretty-print with jq:
|
|
22
|
+
|
|
23
|
+
<%= executable %> "Account.first" | jq
|
|
24
|
+
|
|
25
|
+
Extract just the rows:
|
|
26
|
+
|
|
27
|
+
<%= executable %> "Account.limit(5)" | jq -r '.rows[] | @csv'
|
|
28
|
+
|
|
29
|
+
Run raw SQL:
|
|
30
|
+
|
|
31
|
+
<%= executable %> --sql "SELECT * FROM accounts LIMIT 10"
|
|
32
|
+
|
|
33
|
+
Paginate results:
|
|
34
|
+
|
|
35
|
+
<%= executable %> "Account.all" --page 2 --per 50
|
|
36
|
+
|
|
37
|
+
Use a specific database config:
|
|
38
|
+
|
|
39
|
+
<%= executable %> "Account.count" --database primary_replica
|
|
40
|
+
|
|
41
|
+
Schema introspection:
|
|
42
|
+
|
|
43
|
+
<%= executable %> schema accounts
|
|
44
|
+
|
|
45
|
+
List models:
|
|
46
|
+
|
|
47
|
+
<%= executable %> models
|
|
48
|
+
|
|
49
|
+
Explain a query:
|
|
50
|
+
|
|
51
|
+
<%= executable %> explain "Account.where(plan: 'premium')"
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# :markup: markdown
|
|
4
|
+
|
|
5
|
+
# Backported from rails/rails main (railties/lib/rails/commands/query/query_command.rb
|
|
6
|
+
# at e9aab74b13). Only change: `usage_path` points at this gem's USAGE file.
|
|
7
|
+
|
|
8
|
+
require "json"
|
|
9
|
+
require "rails/command/environment_argument"
|
|
10
|
+
|
|
11
|
+
module Rails
|
|
12
|
+
module Command
|
|
13
|
+
class QueryCommand < Base # :nodoc:
|
|
14
|
+
include EnvironmentArgument
|
|
15
|
+
|
|
16
|
+
class_option :sql, type: :boolean,
|
|
17
|
+
desc: "Treat input as raw SQL instead of an Active Record expression"
|
|
18
|
+
|
|
19
|
+
class_option :database, aliases: "--db", type: :string,
|
|
20
|
+
desc: "Database configuration to use (e.g. primary_replica)"
|
|
21
|
+
|
|
22
|
+
class_option :page, type: :numeric, default: 1,
|
|
23
|
+
desc: "Page number for paginated results"
|
|
24
|
+
|
|
25
|
+
class_option :per, type: :numeric, default: 100,
|
|
26
|
+
desc: "Results per page (max 10000)"
|
|
27
|
+
|
|
28
|
+
def self.usage_path
|
|
29
|
+
File.expand_path("USAGE", __dir__)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
desc "query [EXPRESSION]", "Run a read-only query against the database"
|
|
33
|
+
def perform(expression = nil, *args)
|
|
34
|
+
boot_application!
|
|
35
|
+
Rails.application.load_runner
|
|
36
|
+
|
|
37
|
+
ActiveSupport::Notifications.instrument("query.rails", expression: expression) do
|
|
38
|
+
case expression
|
|
39
|
+
when "schema"
|
|
40
|
+
run_schema(args.first)
|
|
41
|
+
when "models"
|
|
42
|
+
run_models
|
|
43
|
+
when "explain"
|
|
44
|
+
run_explain(args.first)
|
|
45
|
+
else
|
|
46
|
+
run_query(expression)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
rescue StandardError, SyntaxError, NotImplementedError => e
|
|
50
|
+
output_error(e.message)
|
|
51
|
+
exit 1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
def run_schema(table = nil)
|
|
56
|
+
with_readonly_connection do |connection|
|
|
57
|
+
if table
|
|
58
|
+
say format_table_detail(connection, table)
|
|
59
|
+
else
|
|
60
|
+
say format_table_list(connection)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def run_models
|
|
66
|
+
Rails.application.eager_load!
|
|
67
|
+
|
|
68
|
+
models = ActiveRecord::Base.descendants
|
|
69
|
+
.reject(&:abstract_class?)
|
|
70
|
+
.select { |model| model.table_name.present? }
|
|
71
|
+
.sort_by(&:name)
|
|
72
|
+
|
|
73
|
+
data = models.map do |model|
|
|
74
|
+
{
|
|
75
|
+
model: model.name,
|
|
76
|
+
table_name: model.table_name,
|
|
77
|
+
associations: format_associations(model)
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
say JSON.generate(data)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def run_explain(expression = nil)
|
|
85
|
+
expression = resolve_expression(expression)
|
|
86
|
+
|
|
87
|
+
if options[:sql]
|
|
88
|
+
with_readonly_connection do |connection|
|
|
89
|
+
result = connection.select_all("EXPLAIN #{expression}")
|
|
90
|
+
say format_result(columns: result.columns, rows: result.rows, sql: "EXPLAIN #{expression}")
|
|
91
|
+
end
|
|
92
|
+
else
|
|
93
|
+
relation = with_readonly_connection { eval(expression, TOPLEVEL_BINDING, "(query)", 1) }
|
|
94
|
+
sql = relation.to_sql
|
|
95
|
+
with_readonly_connection_for(relation.model.connection_class_for_self) do |connection|
|
|
96
|
+
result = connection.select_all("EXPLAIN #{sql}")
|
|
97
|
+
say format_result(columns: result.columns, rows: result.rows, sql: "EXPLAIN #{sql}")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def run_query(expression)
|
|
103
|
+
expression = resolve_expression(expression)
|
|
104
|
+
page = [ options[:page], 1 ].max
|
|
105
|
+
per = [ [ options[:per], 1 ].max, 10_000 ].min
|
|
106
|
+
|
|
107
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
108
|
+
|
|
109
|
+
result = if options[:sql]
|
|
110
|
+
with_readonly_connection do |connection|
|
|
111
|
+
execute_sql(connection: connection, sql: expression, page: page, per: per)
|
|
112
|
+
end
|
|
113
|
+
else
|
|
114
|
+
execute_expression(expression: expression, page: page, per: per)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(1)
|
|
118
|
+
|
|
119
|
+
say format_result(**result, elapsed_ms: elapsed_ms, page: page, per: per)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def with_readonly_connection(&block)
|
|
123
|
+
with_readonly_connection_for(ActiveRecord::Base, &block)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def with_readonly_connection_for(connection_class, &block)
|
|
127
|
+
if options[:database]
|
|
128
|
+
with_explicit_database(options[:database], &block)
|
|
129
|
+
elsif reading_role_available?(connection_class)
|
|
130
|
+
connection_class.connected_to(role: :reading) do
|
|
131
|
+
connection_class.with_connection(&block)
|
|
132
|
+
end
|
|
133
|
+
else
|
|
134
|
+
connection_class.while_preventing_writes do
|
|
135
|
+
connection_class.with_connection(&block)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def with_explicit_database(database)
|
|
141
|
+
original = ActiveRecord::Base.connection_db_config
|
|
142
|
+
begin
|
|
143
|
+
ActiveRecord::Base.establish_connection(database.to_sym)
|
|
144
|
+
ActiveRecord::Base.while_preventing_writes do
|
|
145
|
+
yield ActiveRecord::Base.lease_connection
|
|
146
|
+
end
|
|
147
|
+
ensure
|
|
148
|
+
ActiveRecord::Base.establish_connection(original)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def reading_role_available?(connection_class)
|
|
153
|
+
connection_class.connected_to(role: :reading) do
|
|
154
|
+
connection_class.lease_connection
|
|
155
|
+
end
|
|
156
|
+
true
|
|
157
|
+
rescue ActiveRecord::ConnectionNotEstablished
|
|
158
|
+
false
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def execute_sql(connection:, sql:, page:, per:)
|
|
162
|
+
unless sql.gsub(/--[^\n]*|\/\*.*?\*\//m, "").match?(/\bLIMIT\b/i)
|
|
163
|
+
offset = (page - 1) * per
|
|
164
|
+
sql = "#{sql.rstrip.chomp(';')} LIMIT #{per + 1}"
|
|
165
|
+
sql += " OFFSET #{offset}" if offset > 0
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
active_record_result = connection.select_all(sql)
|
|
169
|
+
rows = active_record_result.rows
|
|
170
|
+
|
|
171
|
+
tabular_result(columns: active_record_result.columns, rows: rows.first(per), sql: sql, truncated: rows.length > per)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def execute_expression(expression:, page:, per:)
|
|
175
|
+
result = with_readonly_connection { eval(expression, TOPLEVEL_BINDING, "(query)", 1) }
|
|
176
|
+
columns, rows, sql, truncated = tabular_result_parts_for(result, expression: expression, page: page, per: per)
|
|
177
|
+
|
|
178
|
+
tabular_result(columns: columns, rows: rows, sql: sql, truncated: truncated)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def tabular_result_parts_for(result, expression:, page:, per:)
|
|
182
|
+
case result
|
|
183
|
+
when ActiveRecord::Relation
|
|
184
|
+
relation = if result.limit_value
|
|
185
|
+
result
|
|
186
|
+
else
|
|
187
|
+
result.offset((page - 1) * per).limit(per + 1)
|
|
188
|
+
end
|
|
189
|
+
relation_sql = relation.to_sql
|
|
190
|
+
|
|
191
|
+
with_readonly_connection_for(relation.model.connection_class_for_self) do |connection|
|
|
192
|
+
active_record_result = connection.select_all(relation_sql)
|
|
193
|
+
rows = active_record_result.rows
|
|
194
|
+
|
|
195
|
+
[ active_record_result.columns, rows.first(per), relation_sql, rows.length > per ]
|
|
196
|
+
end
|
|
197
|
+
when ActiveRecord::Result
|
|
198
|
+
[ result.columns, result.rows, expression, false ]
|
|
199
|
+
when ActiveRecord::Base
|
|
200
|
+
attributes = result.attributes
|
|
201
|
+
|
|
202
|
+
[ attributes.keys, [ attributes.values ], expression, false ]
|
|
203
|
+
when Hash
|
|
204
|
+
[ [ "key", "value" ], result.map { |key, val| [ key, val ] }, expression, false ]
|
|
205
|
+
when Array
|
|
206
|
+
peek_on_result = result.first
|
|
207
|
+
|
|
208
|
+
if peek_on_result.is_a?(ActiveRecord::Base)
|
|
209
|
+
columns = peek_on_result.attribute_names
|
|
210
|
+
rows = result.map { |record| record.attributes.values }
|
|
211
|
+
else
|
|
212
|
+
rows = result.map { |value| Array(value) }
|
|
213
|
+
columns = Array.new(rows.first&.length.to_i) { |index| "column_#{index}" }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
[ columns, rows, expression, false ]
|
|
217
|
+
else
|
|
218
|
+
[ [ "result" ], [ [ result ] ], expression, false ]
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def tabular_result(columns:, rows:, sql:, truncated: false)
|
|
223
|
+
{ columns: columns, rows: rows, sql: sql, truncated: truncated }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def format_result(columns:, rows:, sql:, elapsed_ms: 0, page: 1, per: rows.length, truncated: false)
|
|
227
|
+
JSON.generate({
|
|
228
|
+
columns: columns,
|
|
229
|
+
rows: rows,
|
|
230
|
+
meta: {
|
|
231
|
+
row_count: rows.length,
|
|
232
|
+
query_time_ms: elapsed_ms,
|
|
233
|
+
page: page,
|
|
234
|
+
per_page: per,
|
|
235
|
+
has_more: truncated,
|
|
236
|
+
sql: sql
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def format_table_list(connection)
|
|
242
|
+
tables = connection.tables.sort
|
|
243
|
+
rows = tables.map { |table| [ table ] }
|
|
244
|
+
|
|
245
|
+
format_result(columns: [ "table_name" ], rows: rows, sql: "")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def format_table_detail(connection, table)
|
|
249
|
+
raise ArgumentError, "Table '#{table}' does not exist" unless connection.table_exists?(table)
|
|
250
|
+
|
|
251
|
+
columns = connection.columns(table)
|
|
252
|
+
indexes = connection.indexes(table)
|
|
253
|
+
model = model_for_table(table)
|
|
254
|
+
|
|
255
|
+
JSON.generate({
|
|
256
|
+
table: table,
|
|
257
|
+
columns: columns.map do |col|
|
|
258
|
+
{ name: col.name, type: col.sql_type, null: col.null, default: col.default }
|
|
259
|
+
end,
|
|
260
|
+
indexes: indexes.map do |idx|
|
|
261
|
+
{ name: idx.name, columns: idx.columns, unique: idx.unique }
|
|
262
|
+
end,
|
|
263
|
+
enums: model&.defined_enums.presence,
|
|
264
|
+
associations: format_associations(model)
|
|
265
|
+
}.compact)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def model_for_table(table)
|
|
269
|
+
Rails.application.eager_load!
|
|
270
|
+
|
|
271
|
+
ActiveRecord::Base.descendants.find do |klass|
|
|
272
|
+
!klass.abstract_class? && klass.table_name == table
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def format_associations(model)
|
|
277
|
+
return unless model
|
|
278
|
+
|
|
279
|
+
model.reflect_on_all_associations.map do |assoc|
|
|
280
|
+
hash = {
|
|
281
|
+
type: assoc.macro,
|
|
282
|
+
name: assoc.name,
|
|
283
|
+
class_name: assoc.class_name
|
|
284
|
+
}
|
|
285
|
+
hash[:foreign_key] = assoc.foreign_key if assoc.respond_to?(:foreign_key)
|
|
286
|
+
hash[:through] = assoc.through_reflection.name if assoc.through_reflection?
|
|
287
|
+
hash
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def resolve_expression(expression)
|
|
292
|
+
if expression == "-" || (expression.nil? && !$stdin.tty?)
|
|
293
|
+
$stdin.read.strip
|
|
294
|
+
elsif expression
|
|
295
|
+
expression
|
|
296
|
+
else
|
|
297
|
+
raise ArgumentError, "No query expression provided. Run '#{self.class.executable} -h' for help."
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def output_error(message)
|
|
302
|
+
error JSON.generate({
|
|
303
|
+
error: message,
|
|
304
|
+
meta: { query_time_ms: 0 }
|
|
305
|
+
})
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rails-query-command
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Lázaro Nixon Amaral de Almeida
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-10-22 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: railties
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.2'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '8.2'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '7.2'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '8.2'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: activerecord
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '7.2'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '8.2'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '7.2'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '8.2'
|
|
52
|
+
description: |
|
|
53
|
+
Adds the `bin/rails query` command from Rails main (rails/rails#57156) to apps running
|
|
54
|
+
Rails 7.2, 8.0 and 8.1. Runs Active Record expressions or raw SQL against a read-only
|
|
55
|
+
connection and prints structured JSON, with pagination and schema/model introspection.
|
|
56
|
+
email:
|
|
57
|
+
- lazaronixon@hotmail.com
|
|
58
|
+
executables: []
|
|
59
|
+
extensions: []
|
|
60
|
+
extra_rdoc_files: []
|
|
61
|
+
files:
|
|
62
|
+
- CHANGELOG.md
|
|
63
|
+
- LICENSE.txt
|
|
64
|
+
- README.md
|
|
65
|
+
- lib/rails/commands/query/USAGE
|
|
66
|
+
- lib/rails/commands/query/query_command.rb
|
|
67
|
+
- lib/rails_query_command.rb
|
|
68
|
+
- lib/rails_query_command/version.rb
|
|
69
|
+
homepage: https://github.com/lazaronixon/rails-query-command
|
|
70
|
+
licenses:
|
|
71
|
+
- MIT
|
|
72
|
+
metadata:
|
|
73
|
+
source_code_uri: https://github.com/lazaronixon/rails-query-command
|
|
74
|
+
changelog_uri: https://github.com/lazaronixon/rails-query-command/blob/main/CHANGELOG.md
|
|
75
|
+
rdoc_options: []
|
|
76
|
+
require_paths:
|
|
77
|
+
- lib
|
|
78
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '3.1'
|
|
83
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '0'
|
|
88
|
+
requirements: []
|
|
89
|
+
rubygems_version: 3.6.2
|
|
90
|
+
specification_version: 4
|
|
91
|
+
summary: Backport of `bin/rails query` (read-only JSON database queries) for Rails
|
|
92
|
+
7.2, 8.0 and 8.1.
|
|
93
|
+
test_files: []
|