activeadmin-graphql 0.1.2 → 0.2.1
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 +4 -4
- data/CHANGELOG.md +21 -0
- data/activeadmin-graphql.gemspec +5 -2
- data/app/controllers/active_admin/graphql_controller.rb +83 -31
- data/docs/graphql-api.md +28 -4
- data/lib/active_admin/graphql/integration.rb +33 -13
- data/lib/active_admin/graphql/key_value_pair_input.rb +20 -12
- data/lib/active_admin/graphql/policy_set_cache.rb +64 -0
- data/lib/active_admin/graphql/record_source.rb +24 -11
- data/lib/active_admin/graphql/resource_query_proxy/controller.rb +22 -6
- data/lib/active_admin/graphql/resource_query_proxy.rb +32 -4
- data/lib/active_admin/graphql/run_action_mutation_config.rb +2 -0
- data/lib/active_admin/graphql/run_action_mutation_dsl.rb +4 -0
- data/lib/active_admin/graphql/run_action_payload.rb +2 -1
- data/lib/active_admin/graphql/schema_builder/mutation_batch.rb +9 -1
- data/lib/active_admin/graphql/schema_builder/mutation_collection.rb +11 -2
- data/lib/active_admin/graphql/schema_builder/mutation_member.rb +11 -2
- data/lib/active_admin/graphql/schema_builder/query_type.rb +7 -0
- data/lib/active_admin/graphql/schema_builder/query_type_policies.rb +160 -0
- data/lib/active_admin/graphql/schema_builder/types_object.rb +27 -2
- data/lib/active_admin/graphql/schema_builder/wire.rb +11 -1
- data/lib/active_admin/graphql/schema_builder.rb +1 -0
- data/lib/active_admin/graphql/schema_field.rb +21 -1
- data/lib/active_admin/graphql/version.rb +1 -1
- data/lib/active_admin/graphql.rb +37 -16
- data/lib/active_admin/primary_key.rb +61 -39
- data/sig/active_admin/graphql.rbs +7 -0
- metadata +47 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '00998f8445b9a5f9a7d4a939029d6e6460527432d658711b713df1ad293abc9e'
|
|
4
|
+
data.tar.gz: fd5f948edd2277b99e68ee384ccf7d31c8ffa8c890bf2f277837106213498652
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fb9a627765be0dfb70171b6c2b9be70828e35a86fb0be2c79df237b0dbadc0559376e176b04ab6a2de49d22404c9773a4f9bee1427d1007b139dc71114afb991
|
|
7
|
+
data.tar.gz: 7c2d075e817702fd05648cf0f6dee228938d7d09400be066f99d4d50b23eb4d301f8ee0ac1632974ea57317f8a2ee7bcee81f5282481e9c477c9e8fd312e5db1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.2.1 (2026-07-14)
|
|
6
|
+
|
|
7
|
+
- Add RBS type signatures to the published gem for Steep and other Ruby type checkers
|
|
8
|
+
- Add Ruby 4.0 to the supported compatibility matrix
|
|
9
|
+
- Return HTTP 400 with a clear error when the GraphQL request body or variables contain invalid JSON
|
|
10
|
+
- Return `null` for member queries when the record id does not exist
|
|
11
|
+
- Enforce read authorization on `belongs_to` association fields so denied parent records resolve as `null`
|
|
12
|
+
- Build GraphQL schemas safely when `schema_for` runs concurrently
|
|
13
|
+
|
|
14
|
+
## 0.2.0 (2026-04-29)
|
|
15
|
+
|
|
16
|
+
- Add `activeadmin_policies` GraphQL policy surfaces:
|
|
17
|
+
- global `activeadmin_policies` for resources/pages
|
|
18
|
+
- per-object `activeadmin_policies` on resource objects
|
|
19
|
+
- preflight `activeadmin_policies_for(type_name:, ids:, path:)` for per-record checks before running queries/mutations/actions
|
|
20
|
+
- Switch policy payloads to allow-lists (`allowed_actions`, `allowed_member_actions`, `allowed_collection_actions`, `allowed_batch_actions`) and add namespace customization hooks (`graphql_policy_actions`, `graphql_policy_action_mapper`, `graphql_policy_extra`, `graphql_policy_transform`)
|
|
21
|
+
- Enforce authorization-by-default for custom GraphQL fields/mutations with explicit opt-out (`authorize: false` / mutation DSL `authorize false`)
|
|
22
|
+
- Add namespace defaults/settings and request specs covering policies, customization hooks, and auth-toggle behavior
|
|
23
|
+
|
|
3
24
|
## 0.1.2 (2026-03-30)
|
|
4
25
|
|
|
5
26
|
- Fix TruffleRuby compatibility issue with JSON.dump
|
data/activeadmin-graphql.gemspec
CHANGED
|
@@ -36,7 +36,8 @@ Gem::Specification.new do |spec|
|
|
|
36
36
|
].select { |f| File.file?(f) } +
|
|
37
37
|
Dir["docs/**/*.md"] +
|
|
38
38
|
Dir["app/**/*.rb"] +
|
|
39
|
-
Dir["lib/**/*.rb"]
|
|
39
|
+
Dir["lib/**/*.rb"] +
|
|
40
|
+
Dir["sig/**/*"]
|
|
40
41
|
end
|
|
41
42
|
|
|
42
43
|
spec.require_paths = ["lib"]
|
|
@@ -51,11 +52,12 @@ Gem::Specification.new do |spec|
|
|
|
51
52
|
spec.add_development_dependency "parallel_tests", "~> 4.7"
|
|
52
53
|
spec.add_development_dependency "rails", ">= 6.1"
|
|
53
54
|
spec.add_development_dependency "rspec", "~> 3"
|
|
55
|
+
spec.add_development_dependency "polyrun", ">= 2.2.0"
|
|
56
|
+
spec.add_development_dependency "prosopite", "~> 2.0"
|
|
54
57
|
spec.add_development_dependency "rspec-rails", ">= 6"
|
|
55
58
|
spec.add_development_dependency "rubocop-rails", "~> 2.34"
|
|
56
59
|
spec.add_development_dependency "rubocop-rspec", "~> 3.8"
|
|
57
60
|
spec.add_development_dependency "rubocop-thread_safety", "~> 0.7"
|
|
58
|
-
spec.add_development_dependency "simplecov", "~> 0.22"
|
|
59
61
|
spec.add_development_dependency "sprockets-rails", ">= 3.4"
|
|
60
62
|
spec.add_development_dependency "sqlite3", ">= 1"
|
|
61
63
|
spec.add_development_dependency "standard", "~> 1.52"
|
|
@@ -63,4 +65,5 @@ Gem::Specification.new do |spec|
|
|
|
63
65
|
spec.add_development_dependency "standard-performance", "~> 1.8"
|
|
64
66
|
spec.add_development_dependency "standard-rails", "~> 1.5"
|
|
65
67
|
spec.add_development_dependency "standard-rspec", "~> 0.3"
|
|
68
|
+
spec.add_development_dependency "rbs", "~> 3"
|
|
66
69
|
end
|
|
@@ -9,40 +9,18 @@ module ActiveAdmin
|
|
|
9
9
|
class GraphqlController < ApplicationController
|
|
10
10
|
protect_from_forgery with: :exception
|
|
11
11
|
|
|
12
|
+
rescue_from ActionDispatch::Http::Parameters::ParseError do
|
|
13
|
+
render json: {errors: [{message: "Invalid JSON"}]}, status: :bad_request
|
|
14
|
+
end
|
|
15
|
+
|
|
12
16
|
before_action :ensure_graphql_enabled!
|
|
13
17
|
before_action :authenticate_graphql!
|
|
14
18
|
|
|
15
19
|
def execute
|
|
16
20
|
schema = ActiveAdmin::GraphQL.schema_for(active_admin_namespace)
|
|
21
|
+
return render_multiplex(schema) if multiplex_request?
|
|
17
22
|
|
|
18
|
-
|
|
19
|
-
operations = multiplex_operations
|
|
20
|
-
max_n = active_admin_namespace.graphql_multiplex_max || 20
|
|
21
|
-
if operations.size > max_n
|
|
22
|
-
return render json: {errors: [{message: "Multiplex exceeds maximum of #{max_n}"}]},
|
|
23
|
-
status: :content_too_large
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
results = schema.multiplex(
|
|
27
|
-
operations.map do |op|
|
|
28
|
-
{
|
|
29
|
-
query: op[:query],
|
|
30
|
-
variables: ensure_variables(op[:variables]),
|
|
31
|
-
operation_name: op[:operation_name],
|
|
32
|
-
context: graphql_context
|
|
33
|
-
}
|
|
34
|
-
end
|
|
35
|
-
)
|
|
36
|
-
render json: results.map(&:to_h), status: :ok
|
|
37
|
-
else
|
|
38
|
-
result = schema.execute(
|
|
39
|
-
query: query_string,
|
|
40
|
-
variables: ensure_variables(variables_hash),
|
|
41
|
-
operation_name: operation_name,
|
|
42
|
-
context: graphql_context
|
|
43
|
-
)
|
|
44
|
-
render json: result.to_h, status: :ok
|
|
45
|
-
end
|
|
23
|
+
render_single(schema)
|
|
46
24
|
end
|
|
47
25
|
|
|
48
26
|
def active_admin_namespace
|
|
@@ -130,6 +108,8 @@ module ActiveAdmin
|
|
|
130
108
|
|
|
131
109
|
def request_body_hash
|
|
132
110
|
json = request_body_json
|
|
111
|
+
return nil if json.nil? || json == :invalid_json
|
|
112
|
+
|
|
133
113
|
json if json.is_a?(Hash)
|
|
134
114
|
end
|
|
135
115
|
|
|
@@ -145,13 +125,21 @@ module ActiveAdmin
|
|
|
145
125
|
body.present? ? JSON.parse(body) : nil
|
|
146
126
|
end
|
|
147
127
|
rescue JSON::ParserError
|
|
148
|
-
@request_body_json =
|
|
128
|
+
@request_body_json = :invalid_json
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def invalid_request_json?
|
|
132
|
+
request_body_json == :invalid_json
|
|
149
133
|
end
|
|
150
134
|
|
|
151
135
|
def ensure_variables(raw)
|
|
152
136
|
case raw
|
|
153
137
|
when String
|
|
154
|
-
raw.
|
|
138
|
+
if raw.blank?
|
|
139
|
+
{}
|
|
140
|
+
else
|
|
141
|
+
JSON.parse(raw)
|
|
142
|
+
end
|
|
155
143
|
when Hash
|
|
156
144
|
raw
|
|
157
145
|
when ActionController::Parameters
|
|
@@ -162,7 +150,71 @@ module ActiveAdmin
|
|
|
162
150
|
{}
|
|
163
151
|
end
|
|
164
152
|
rescue JSON::ParserError
|
|
165
|
-
|
|
153
|
+
:invalid_json
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def render_invalid_json!
|
|
157
|
+
render json: {errors: [{message: "Invalid JSON"}]}, status: :bad_request
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def render_multiplex(schema)
|
|
161
|
+
return render_invalid_json! if invalid_request_json?
|
|
162
|
+
|
|
163
|
+
operations = multiplex_operations
|
|
164
|
+
return render_multiplex_limit_error!(operations.size) if exceeds_multiplex_limit?(operations)
|
|
165
|
+
|
|
166
|
+
payloads = build_multiplex_payloads(operations)
|
|
167
|
+
return if performed?
|
|
168
|
+
|
|
169
|
+
results = schema.multiplex(payloads)
|
|
170
|
+
render json: results.map(&:to_h), status: :ok
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def render_single(schema)
|
|
174
|
+
return render_invalid_json! if invalid_request_json?
|
|
175
|
+
|
|
176
|
+
variables = ensure_variables(variables_hash)
|
|
177
|
+
return render_invalid_json! if variables == :invalid_json
|
|
178
|
+
|
|
179
|
+
result = schema.execute(
|
|
180
|
+
query: query_string,
|
|
181
|
+
variables: variables,
|
|
182
|
+
operation_name: operation_name,
|
|
183
|
+
context: graphql_context
|
|
184
|
+
)
|
|
185
|
+
render json: result.to_h, status: :ok
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def exceeds_multiplex_limit?(operations)
|
|
189
|
+
operations.size > max_multiplex_operations
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def max_multiplex_operations
|
|
193
|
+
active_admin_namespace.graphql_multiplex_max || 20
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def render_multiplex_limit_error!(count)
|
|
197
|
+
max_n = max_multiplex_operations
|
|
198
|
+
msg = "Multiplex exceeds maximum of #{max_n} (received #{count})"
|
|
199
|
+
render json: {errors: [{message: msg}]}, status: :content_too_large
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def build_multiplex_payloads(operations)
|
|
203
|
+
context = graphql_context
|
|
204
|
+
operations.map do |operation|
|
|
205
|
+
variables = ensure_variables(operation[:variables])
|
|
206
|
+
if variables == :invalid_json
|
|
207
|
+
render_invalid_json!
|
|
208
|
+
return []
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
{
|
|
212
|
+
query: operation[:query],
|
|
213
|
+
variables: variables,
|
|
214
|
+
operation_name: operation[:operation_name],
|
|
215
|
+
context: context
|
|
216
|
+
}
|
|
217
|
+
end
|
|
166
218
|
end
|
|
167
219
|
end
|
|
168
220
|
end
|
data/docs/graphql-api.md
CHANGED
|
@@ -46,10 +46,16 @@ Each namespace inherits these options (defaults in parentheses):
|
|
|
46
46
|
* `graphql_path` — path segment under the namespace mount (`"graphql"`).
|
|
47
47
|
* `graphql_multiplex_max` — maximum operations per multiplexed JSON array
|
|
48
48
|
request (`20`).
|
|
49
|
-
* `graphql_max_complexity` — passed to graphql-ruby `max_complexity`
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
* `graphql_max_complexity` — passed to graphql-ruby `max_complexity` (`300` by
|
|
50
|
+
default; set a higher value if legitimate queries are rejected). Set to
|
|
51
|
+
`nil` to disable the limit.
|
|
52
|
+
* `graphql_max_depth` — passed to `max_depth` (`18` by default). Set to `nil`
|
|
53
|
+
to disable.
|
|
54
|
+
* `graphql_batch_action_max_ids` — maximum length of the `ids` argument on
|
|
55
|
+
`*_batch_action` mutations (`5000` by default; set `0` to disable the cap).
|
|
56
|
+
* `graphql_default_page_size` / `graphql_default_max_page_size` — schema
|
|
57
|
+
connection defaults (`25` / `100`); needed for graphql-ruby complexity scoring
|
|
58
|
+
on connection fields when `max_complexity` is enabled.
|
|
53
59
|
* `graphql_dataloader` — graphql-ruby dataloader plugin (`nil` = use
|
|
54
60
|
`GraphQL::Dataloader`). Set to `GraphQL::Dataloader::AsyncDataloader` when
|
|
55
61
|
the [`async` gem](https://github.com/socketry/async) is loaded if you want
|
|
@@ -155,6 +161,24 @@ separate route with CSRF disabled. Treat this endpoint like any other
|
|
|
155
161
|
The namespace `authentication_method` runs before any GraphQL work, including
|
|
156
162
|
schema introspection. Unauthenticated clients should not receive a schema.
|
|
157
163
|
|
|
164
|
+
If you need to turn off schema introspection entirely (even for authenticated
|
|
165
|
+
sessions or specific environments), call
|
|
166
|
+
[`schema.disable_introspection_entry_points`](https://graphql-ruby.org/schema/definition.html#schema-configuration)
|
|
167
|
+
from `graphql_configure_schema`. The hook receives the generated schema class,
|
|
168
|
+
so you can toggle introspection based on Rails environment or other app
|
|
169
|
+
settings:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
ActiveAdmin.setup do |config|
|
|
173
|
+
config.namespace :admin do |admin|
|
|
174
|
+
admin.graphql = true
|
|
175
|
+
admin.graphql_configure_schema = lambda do |schema|
|
|
176
|
+
schema.disable_introspection_entry_points if Rails.env.production?
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
```
|
|
181
|
+
|
|
158
182
|
The GraphQL context exposes the same user as `current_active_admin_user` (from
|
|
159
183
|
the namespace `current_user_method`), wrapped for authorization checks.
|
|
160
184
|
|
|
@@ -31,11 +31,18 @@ module ActiveAdmin
|
|
|
31
31
|
ns.register :graphql_visibility, nil
|
|
32
32
|
ns.register :graphql_visibility_profile, nil
|
|
33
33
|
ns.register :graphql_schema_visible, nil
|
|
34
|
-
ns.register :graphql_max_complexity,
|
|
35
|
-
ns.register :graphql_max_depth,
|
|
36
|
-
ns.register :
|
|
37
|
-
ns.register :
|
|
34
|
+
ns.register :graphql_max_complexity, 300
|
|
35
|
+
ns.register :graphql_max_depth, 18
|
|
36
|
+
ns.register :graphql_batch_action_max_ids, 5000
|
|
37
|
+
ns.register :graphql_default_page_size, 25
|
|
38
|
+
ns.register :graphql_default_max_page_size, 100
|
|
38
39
|
ns.register :graphql_configure_schema, nil
|
|
40
|
+
ns.register :graphql_policy_actions, nil
|
|
41
|
+
ns.register :graphql_policy_action_mapper, nil
|
|
42
|
+
ns.register :graphql_policy_extra, nil
|
|
43
|
+
ns.register :graphql_policy_transform, nil
|
|
44
|
+
ns.register :graphql_custom_field_authorization_default, true
|
|
45
|
+
ns.register :graphql_custom_mutation_authorization_default, true
|
|
39
46
|
end
|
|
40
47
|
|
|
41
48
|
def register_railtie!
|
|
@@ -114,18 +121,31 @@ module ActiveAdmin
|
|
|
114
121
|
private
|
|
115
122
|
|
|
116
123
|
def define_graphql_routes
|
|
117
|
-
|
|
118
|
-
|
|
124
|
+
graphql_namespaces.each do |namespace|
|
|
125
|
+
segment = graphql_segment_for(namespace)
|
|
126
|
+
defaults = graphql_route_defaults(namespace)
|
|
127
|
+
mount_graphql_route(namespace, segment, defaults)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def graphql_namespaces
|
|
132
|
+
namespaces.select(&:graphql)
|
|
133
|
+
end
|
|
119
134
|
|
|
120
|
-
|
|
121
|
-
|
|
135
|
+
def graphql_segment_for(namespace)
|
|
136
|
+
namespace.graphql_path.to_s.delete_prefix("/").presence || "graphql"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def graphql_route_defaults(namespace)
|
|
140
|
+
{active_admin_namespace: namespace.name}
|
|
141
|
+
end
|
|
122
142
|
|
|
123
|
-
|
|
143
|
+
def mount_graphql_route(namespace, segment, defaults)
|
|
144
|
+
if namespace.root?
|
|
145
|
+
router.post segment, controller: "/active_admin/graphql", action: "execute", defaults: defaults
|
|
146
|
+
else
|
|
147
|
+
router.namespace namespace.name, **namespace.route_options.dup do
|
|
124
148
|
router.post segment, controller: "/active_admin/graphql", action: "execute", defaults: defaults
|
|
125
|
-
else
|
|
126
|
-
router.namespace namespace.name, **namespace.route_options.dup do
|
|
127
|
-
router.post segment, controller: "/active_admin/graphql", action: "execute", defaults: defaults
|
|
128
|
-
end
|
|
129
149
|
end
|
|
130
150
|
end
|
|
131
151
|
end
|
|
@@ -30,18 +30,26 @@ module ActiveAdmin
|
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def extract_pair(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
33
|
+
def extract_pair(entry)
|
|
34
|
+
return extract_from_struct(entry) if key_value_struct?(entry)
|
|
35
|
+
return extract_from_hash(entry) if entry.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
raise ::GraphQL::ExecutionError, "invalid key/value pair entry: #{entry.class}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def key_value_struct?(entry)
|
|
41
|
+
entry.respond_to?(:key) && entry.respond_to?(:value) && !entry.is_a?(Hash)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def extract_from_struct(entry)
|
|
45
|
+
[entry.key, entry.value]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def extract_from_hash(entry)
|
|
49
|
+
key = entry["key"] || entry[:key]
|
|
50
|
+
raise ::GraphQL::ExecutionError, "key/value pair missing key" if key.nil?
|
|
51
|
+
|
|
52
|
+
[key, entry["value"] || entry[:value]]
|
|
45
53
|
end
|
|
46
54
|
end
|
|
47
55
|
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAdmin
|
|
4
|
+
module GraphQL
|
|
5
|
+
# Request-scoped memoization for ActiveAdmin policy sets on GraphQL objects.
|
|
6
|
+
class PolicySetCache
|
|
7
|
+
class << self
|
|
8
|
+
def fetch(context, subject_owner:, subject:)
|
|
9
|
+
namespace = context[:namespace]
|
|
10
|
+
unless namespace
|
|
11
|
+
return policy_builder(context).send(
|
|
12
|
+
:build_policy_set,
|
|
13
|
+
auth: context[:auth],
|
|
14
|
+
subject_owner: subject_owner,
|
|
15
|
+
subject: subject,
|
|
16
|
+
context: context
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
cache = context[:policy_set_cache] ||= {}
|
|
21
|
+
key = cache_key(subject_owner, subject)
|
|
22
|
+
cache[key] ||= policy_builder(context).send(
|
|
23
|
+
:build_policy_set,
|
|
24
|
+
auth: context[:auth],
|
|
25
|
+
subject_owner: subject_owner,
|
|
26
|
+
subject: subject,
|
|
27
|
+
context: context
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def policy_builder(context)
|
|
32
|
+
context[:policy_schema_builder] ||= SchemaBuilder.new(context[:namespace])
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def cache_key(subject_owner, subject)
|
|
36
|
+
owner_key = subject_owner_key(subject_owner)
|
|
37
|
+
"#{owner_key}:#{subject_key(subject)}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def subject_owner_key(subject_owner)
|
|
43
|
+
if subject_owner.is_a?(ActiveAdmin::Page)
|
|
44
|
+
"page-owner:#{subject_owner.name}"
|
|
45
|
+
else
|
|
46
|
+
"resource-owner:#{subject_owner.resource_class.name}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def subject_key(subject)
|
|
51
|
+
if subject.is_a?(Class)
|
|
52
|
+
"class:#{subject.name}"
|
|
53
|
+
elsif subject.is_a?(ActiveAdmin::Page)
|
|
54
|
+
"page:#{subject.name}"
|
|
55
|
+
elsif subject.is_a?(ActiveRecord::Base)
|
|
56
|
+
"record:#{subject.class.name}:#{PrimaryKey.graphql_id_value(subject)}"
|
|
57
|
+
else
|
|
58
|
+
"object:#{subject.object_id}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -10,21 +10,34 @@ module ActiveAdmin
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def fetch(ids)
|
|
13
|
-
keys = ids
|
|
13
|
+
keys = normalized_keys(ids)
|
|
14
14
|
uniq = keys.compact.uniq
|
|
15
|
-
return ids.
|
|
15
|
+
return Array.new(ids.size) { nil } if uniq.empty?
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
by_id = records.index_by { |r| r.public_send(pk_sym) }
|
|
25
|
-
keys.map { |k| k.nil? ? nil : by_id[k] }
|
|
17
|
+
indexed = load_records(uniq)
|
|
18
|
+
keys.map { |key| key.nil? ? nil : indexed[key] }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def normalized_keys(ids)
|
|
22
|
+
ids.map do |raw|
|
|
23
|
+
raw.nil? ? nil : ActiveAdmin::PrimaryKey.dataloader_tuple(@model_class, raw)
|
|
26
24
|
end
|
|
27
25
|
end
|
|
26
|
+
|
|
27
|
+
def load_records(keys)
|
|
28
|
+
ActiveAdmin::PrimaryKey.composite?(@model_class) ? composite_index(keys) : single_column_index(keys)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def composite_index(keys)
|
|
32
|
+
records = @model_class.where(@pk => keys).to_a
|
|
33
|
+
records.index_by { |record| ActiveAdmin::PrimaryKey.dataloader_tuple_from_record(record) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def single_column_index(keys)
|
|
37
|
+
pk_sym = @pk.to_sym
|
|
38
|
+
records = @model_class.where(pk_sym => keys).to_a
|
|
39
|
+
records.index_by { |record| record.public_send(pk_sym) }
|
|
40
|
+
end
|
|
28
41
|
end
|
|
29
42
|
end
|
|
30
43
|
end
|
|
@@ -12,15 +12,24 @@ module ActiveAdmin
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def param_hash_for(action, extra)
|
|
15
|
+
base_graphql_params(action)
|
|
16
|
+
.merge(extra.stringify_keys)
|
|
17
|
+
.yield_self { |params| merge_graph_params!(params) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def base_graphql_params(action)
|
|
15
21
|
{
|
|
16
22
|
"action" => action,
|
|
17
23
|
"controller" => "active_admin/graphql"
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def merge_graph_params!(hash)
|
|
28
|
+
merge_ransack!(hash)
|
|
29
|
+
assign_if_present!(hash, "scope", @graph_params["scope"])
|
|
30
|
+
assign_if_present!(hash, "order", @graph_params["order"])
|
|
31
|
+
merge_belongs_to!(hash)
|
|
32
|
+
hash
|
|
24
33
|
end
|
|
25
34
|
|
|
26
35
|
def merge_ransack!(h)
|
|
@@ -50,6 +59,13 @@ module ActiveAdmin
|
|
|
50
59
|
h[key] = val.to_s if val.present?
|
|
51
60
|
end
|
|
52
61
|
|
|
62
|
+
def assign_if_present!(hash, key, value)
|
|
63
|
+
return hash if value.blank?
|
|
64
|
+
|
|
65
|
+
hash[key] = value.to_s
|
|
66
|
+
hash
|
|
67
|
+
end
|
|
68
|
+
|
|
53
69
|
def stub_controller!(controller, hash)
|
|
54
70
|
user = @user
|
|
55
71
|
params_obj = ActionController::Parameters.new(hash)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rack/mock"
|
|
4
|
+
require "stringio"
|
|
4
5
|
|
|
5
6
|
require_relative "resource_query_proxy/controller"
|
|
6
7
|
|
|
@@ -26,6 +27,26 @@ module ActiveAdmin
|
|
|
26
27
|
def find_member(id)
|
|
27
28
|
extra = member_route_params_for_find(id)
|
|
28
29
|
controller_for("show", extra).send(:find_resource)
|
|
30
|
+
rescue ActiveRecord::RecordNotFound
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def find_members(ids)
|
|
35
|
+
string_ids = Array(ids).map(&:to_s).uniq
|
|
36
|
+
return {} if string_ids.empty?
|
|
37
|
+
|
|
38
|
+
model = @aa_resource.resource_class
|
|
39
|
+
if ActiveAdmin::PrimaryKey.composite?(model)
|
|
40
|
+
return string_ids.index_with { |identifier| find_member(identifier) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
controller = controller_for("index")
|
|
44
|
+
relation = controller.send(:apply_authorization_scope, controller.send(:scoped_collection))
|
|
45
|
+
primary_key = model.primary_key.to_sym
|
|
46
|
+
indexed = relation.where(primary_key => string_ids).index_by do |record|
|
|
47
|
+
record.public_send(primary_key).to_s
|
|
48
|
+
end
|
|
49
|
+
string_ids.index_with { |identifier| indexed[identifier] }
|
|
29
50
|
end
|
|
30
51
|
|
|
31
52
|
def build_new(attributes)
|
|
@@ -49,10 +70,9 @@ module ActiveAdmin
|
|
|
49
70
|
extra = {
|
|
50
71
|
"batch_action" => batch_sym.to_s,
|
|
51
72
|
"collection_selection" => Array(ids).map(&:to_s),
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
|
|
55
|
-
"batch_action_inputs" => JSON.dump(inputs, +"")
|
|
73
|
+
# TruffleRuby freezes JSON's internal "{}" buffer, so Hash#to_json raises FrozenError.
|
|
74
|
+
# Dumping into a dedicated StringIO avoids mutating the shared literal.
|
|
75
|
+
"batch_action_inputs" => dump_batch_inputs(inputs)
|
|
56
76
|
}
|
|
57
77
|
c = controller_for("batch_action", extra)
|
|
58
78
|
perform_controller_command!(c) { c.send(:batch_action) }
|
|
@@ -110,6 +130,14 @@ module ActiveAdmin
|
|
|
110
130
|
stub_controller!(c, h)
|
|
111
131
|
c
|
|
112
132
|
end
|
|
133
|
+
|
|
134
|
+
def dump_batch_inputs(hash)
|
|
135
|
+
buffer = StringIO.new
|
|
136
|
+
JSON.dump(hash, buffer)
|
|
137
|
+
buffer.string
|
|
138
|
+
rescue FrozenError
|
|
139
|
+
JSON.generate(hash)
|
|
140
|
+
end
|
|
113
141
|
end
|
|
114
142
|
end
|
|
115
143
|
end
|
|
@@ -12,6 +12,8 @@ module ActiveAdmin
|
|
|
12
12
|
attr_accessor :resolve_proc
|
|
13
13
|
# Optional block evaluated in the graphql-ruby +field+ DSL context (+argument+, …) for per-action fields.
|
|
14
14
|
attr_accessor :arguments_proc
|
|
15
|
+
# Optional authorization toggle for this mutation configuration. +nil+ means namespace default.
|
|
16
|
+
attr_accessor :authorize
|
|
15
17
|
|
|
16
18
|
def self.ensure_graphql_object_subclass!(type)
|
|
17
19
|
unless type.is_a?(Class) && type < ::GraphQL::Schema::Object
|
|
@@ -21,7 +21,8 @@ module ActiveAdmin
|
|
|
21
21
|
field :body, ::GraphQL::Types::String, null: true,
|
|
22
22
|
description: "Response body text when the action rendered (e.g. JSON)."
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
# keyword_init required for Ruby 3.2 consumers; cop targets 3.4 default Struct behavior
|
|
25
|
+
Result = Struct.new(:ok, :status, :location, :body, keyword_init: true) # rubocop:disable Style/RedundantStructKeywordInit
|
|
25
26
|
end
|
|
26
27
|
end
|
|
27
28
|
end
|
|
@@ -29,7 +29,9 @@ module ActiveAdmin
|
|
|
29
29
|
define_method(fname.to_sym) do |batch_action:, ids:, inputs: nil, **kw|
|
|
30
30
|
auth = context[:auth]
|
|
31
31
|
mdl = aa_res.resource_class
|
|
32
|
-
|
|
32
|
+
cfg = aa_res.graphql_config.batch_run_action
|
|
33
|
+
auth_enabled = cfg.authorize.nil? ? (ns.graphql_custom_mutation_authorization_default != false) : cfg.authorize
|
|
34
|
+
if auth_enabled && !auth.authorized?(aa_res, ActiveAdmin::Authorization::READ, mdl)
|
|
33
35
|
raise ::GraphQL::ExecutionError, "not authorized to read #{mdl.name}"
|
|
34
36
|
end
|
|
35
37
|
|
|
@@ -38,6 +40,12 @@ module ActiveAdmin
|
|
|
38
40
|
raise ::GraphQL::ExecutionError, "Unknown batch_action #{ba.inspect} for #{plural}"
|
|
39
41
|
end
|
|
40
42
|
|
|
43
|
+
max_ids = ns.graphql_batch_action_max_ids
|
|
44
|
+
if max_ids.is_a?(Integer) && max_ids.positive? && ids.size > max_ids
|
|
45
|
+
raise ::GraphQL::ExecutionError,
|
|
46
|
+
"ids cannot exceed #{max_ids} entries (received #{ids.size})"
|
|
47
|
+
end
|
|
48
|
+
|
|
41
49
|
proxy = ResourceQueryProxy.new(
|
|
42
50
|
aa_resource: aa_res,
|
|
43
51
|
user: context[:auth].user,
|