activeadmin-graphql 0.2.1 → 0.3.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/CODE_OF_CONDUCT.md +14 -19
  4. data/CONTRIBUTING.md +12 -16
  5. data/README.md +19 -2
  6. data/activeadmin-graphql.gemspec +7 -6
  7. data/app/controllers/active_admin/graphql_controller.rb +9 -0
  8. data/docs/changelogs/20260818111600_rfc-0002-problems.md +25 -0
  9. data/docs/changelogs/20260818114000_rfc-as-built-coverage.md +27 -0
  10. data/docs/changelogs/20260818114700_rfc-design-not-version.md +27 -0
  11. data/docs/changelogs/20260907141000_advisory-lockfile-refresh.md +21 -0
  12. data/docs/changelogs/20260921103000_mutation-attribute-allowlists.md +24 -0
  13. data/docs/changelogs/20260921111000_release-0-3-0.md +42 -0
  14. data/docs/changelogs/20260921112800_drop-standard-rubocop-direct.md +35 -0
  15. data/docs/changelogs/20260921120000_graphql-floor-and-activeadmin-4.md +41 -0
  16. data/docs/changelogs/20261010101500_nonconventional-id-fields.md +34 -0
  17. data/docs/changelogs/20261010103800_release-0-3-1.md +32 -0
  18. data/docs/dependencies/20260907141000_json-cve-2026-71847.md +68 -0
  19. data/docs/dependencies/20260907141000_sqlite3-ghsa-mwm8-39rw-8826.md +62 -0
  20. data/docs/dependencies/20260921114000_rails-json-parse-kwargs.md +61 -0
  21. data/docs/dependencies/20260921114500_graphql-ghsa-rmxg-5p3r-j6hh.md +69 -0
  22. data/docs/dependencies/20261007161308_association-list-combobox.md +23 -0
  23. data/docs/graphql-api.md +49 -11
  24. data/docs/issues/20260907141000_engineering-and-dependency-audit.md +30 -0
  25. data/docs/issues/20260917123253_ci-drop-automatic-dependency-audit.md +29 -0
  26. data/docs/issues/20260921103000_mutation-attribute-allowlists.md +40 -0
  27. data/docs/issues/20260921104400_convention-alignment-audit.md +60 -0
  28. data/docs/issues/20260921114500_dependency-audit.md +90 -0
  29. data/docs/issues/20260921115500_file-length-metrics.md +31 -0
  30. data/docs/plan/20260817201500_rfc-starter.md +31 -0
  31. data/lib/active_admin/graphql/integration.rb +22 -23
  32. data/lib/active_admin/graphql/mutation_execution_error.rb +16 -0
  33. data/lib/active_admin/graphql/mutation_input_config.rb +11 -0
  34. data/lib/active_admin/graphql/mutation_input_definition_dsl.rb +22 -0
  35. data/lib/active_admin/graphql/policy_set_cache.rb +3 -1
  36. data/lib/active_admin/graphql/record_source.rb +3 -2
  37. data/lib/active_admin/graphql/resource_config.rb +21 -2
  38. data/lib/active_admin/graphql/resource_definition_dsl.rb +19 -0
  39. data/lib/active_admin/graphql/resource_methods.rb +55 -0
  40. data/lib/active_admin/graphql/resource_query_proxy/identity.rb +38 -0
  41. data/lib/active_admin/graphql/resource_query_proxy.rb +13 -8
  42. data/lib/active_admin/graphql/schema_builder/graph_params.rb +2 -2
  43. data/lib/active_admin/graphql/schema_builder/mutation_create.rb +6 -2
  44. data/lib/active_admin/graphql/schema_builder/mutation_update_destroy.rb +19 -9
  45. data/lib/active_admin/graphql/schema_builder/types_inputs.rb +24 -4
  46. data/lib/active_admin/graphql/schema_builder/types_object.rb +15 -2
  47. data/lib/active_admin/graphql/version.rb +1 -1
  48. data/lib/active_admin/graphql.rb +1 -0
  49. data/lib/active_admin/primary_key.rb +13 -4
  50. metadata +71 -24
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "resource_definition_dsl"
4
+ require_relative "resource_methods"
4
5
 
5
6
  module ActiveAdmin
6
7
  module GraphQL
@@ -60,36 +61,34 @@ module ActiveAdmin
60
61
  end
61
62
  end
62
63
 
63
- module ResourceMethods
64
- def graphql_config
65
- @graphql_config ||= ActiveAdmin::GraphQL::ResourceConfig.new
64
+ module ResourceDSLMethods
65
+ def graphql(&block)
66
+ if block
67
+ ActiveAdmin::GraphQL::ResourceDefinitionDSL.new(config.graphql_config).instance_exec(&block)
68
+ end
69
+ config.graphql_config
66
70
  end
67
71
 
68
- def attributes_for_graphql
69
- keys = resource_attributes.keys
70
- cfg = graphql_config
71
- if cfg.only_attributes
72
- keys &= cfg.only_attributes
72
+ def permit_params(*args, &block)
73
+ unless block
74
+ config.graphql_config.html_permit_params_attributes = flatten_html_permit_params(args)
73
75
  end
74
- keys -= cfg.exclude_attributes
75
- keys
76
+ super
76
77
  end
77
78
 
78
- def graphql_assignable_attribute_names
79
- names = attributes_for_graphql.map(&:to_s)
80
- pk_cols = ActiveAdmin::PrimaryKey.columns(resource_class)
81
- return names if pk_cols.size > 1
82
-
83
- names - pk_cols
84
- end
85
- end
79
+ private
86
80
 
87
- module ResourceDSLMethods
88
- def graphql(&block)
89
- if block
90
- ActiveAdmin::GraphQL::ResourceDefinitionDSL.new(config.graphql_config).instance_exec(&block)
81
+ def flatten_html_permit_params(args)
82
+ args.flat_map do |arg|
83
+ case arg
84
+ when Symbol, String
85
+ [arg.to_sym]
86
+ when Hash
87
+ arg.keys.map(&:to_sym)
88
+ else
89
+ []
90
+ end
91
91
  end
92
- config.graphql_config
93
92
  end
94
93
  end
95
94
 
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module GraphQL
5
+ module MutationExecutionError
6
+ module_function
7
+
8
+ def validation(record)
9
+ ::GraphQL::ExecutionError.new(
10
+ record.errors.full_messages.to_sentence,
11
+ extensions: {"errors" => record.errors.full_messages}
12
+ )
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module GraphQL
5
+ # Create or update input options set from +graphql { create do … }+ / +update do … }+.
6
+ class MutationInputConfig
7
+ attr_accessor :permit_params_attributes
8
+ attr_accessor :resolve_proc
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module GraphQL
5
+ # Nested DSL for +graphql { create do … }+ and +update do … }+, pairing
6
+ # ActiveAdmin +permit_params+ with graphql-ruby-style +resolve+.
7
+ class MutationInputDefinitionDSL
8
+ def initialize(config)
9
+ @config = config
10
+ end
11
+
12
+ def permit_params(*attrs)
13
+ @config.permit_params_attributes = attrs.flatten.map(&:to_sym)
14
+ end
15
+ alias_method :permit, :permit_params
16
+
17
+ def resolve(&block)
18
+ @config.resolve_proc = block
19
+ end
20
+ end
21
+ end
22
+ end
@@ -53,7 +53,9 @@ module ActiveAdmin
53
53
  elsif subject.is_a?(ActiveAdmin::Page)
54
54
  "page:#{subject.name}"
55
55
  elsif subject.is_a?(ActiveRecord::Base)
56
- "record:#{subject.class.name}:#{PrimaryKey.graphql_id_value(subject)}"
56
+ identity = PrimaryKey.graphql_id_value(subject)
57
+ identity = "#{identity}:object:#{subject.object_id}" if subject.class.primary_key.blank?
58
+ "record:#{subject.class.name}:#{identity}"
57
59
  else
58
60
  "object:#{subject.object_id}"
59
61
  end
@@ -6,13 +6,14 @@ module ActiveAdmin
6
6
  class RecordSource < ::GraphQL::Dataloader::Source
7
7
  def initialize(model_class)
8
8
  @model_class = model_class
9
- @pk = model_class.primary_key
9
+ @pk_columns = ActiveAdmin::PrimaryKey.columns(model_class)
10
+ @pk = ActiveAdmin::PrimaryKey.composite?(model_class) ? model_class.primary_key : @pk_columns.first
10
11
  end
11
12
 
12
13
  def fetch(ids)
13
14
  keys = normalized_keys(ids)
14
15
  uniq = keys.compact.uniq
15
- return Array.new(ids.size) { nil } if uniq.empty?
16
+ return Array.new(ids.size) { nil } if uniq.empty? || @pk.nil?
16
17
 
17
18
  indexed = load_records(uniq)
18
19
  keys.map { |key| key.nil? ? nil : indexed[key] }
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "run_action_mutation_config"
4
+ require_relative "mutation_input_config"
4
5
 
5
6
  module ActiveAdmin
6
7
  module GraphQL
@@ -10,14 +11,16 @@ module ActiveAdmin
10
11
  attr_accessor :graphql_type_name
11
12
  attr_accessor :only_attributes
12
13
  attr_accessor :exclude_attributes
14
+ attr_accessor :permit_params_attributes
15
+ attr_accessor :html_permit_params_attributes
13
16
  attr_accessor :extension_block
14
17
 
15
18
  # Optional resolver overrides (set from +graphql do+). SchemaBuilder still owns field names,
16
19
  # arguments, and types; procs replace only the Ruby resolution body.
17
20
  attr_accessor :resolve_index_proc
18
21
  attr_accessor :resolve_show_proc
19
- attr_accessor :resolve_create_proc
20
- attr_accessor :resolve_update_proc
22
+ attr_writer :resolve_create_proc
23
+ attr_writer :resolve_update_proc
21
24
  attr_accessor :resolve_destroy_proc
22
25
 
23
26
  # Default return type for run-action mutations (+batch+, +member+, +collection+) when a kind-specific
@@ -33,6 +36,22 @@ module ActiveAdmin
33
36
  !enabled
34
37
  end
35
38
 
39
+ def create_input
40
+ @create_input ||= MutationInputConfig.new
41
+ end
42
+
43
+ def update_input
44
+ @update_input ||= MutationInputConfig.new
45
+ end
46
+
47
+ def resolve_create_proc
48
+ @resolve_create_proc || create_input.resolve_proc
49
+ end
50
+
51
+ def resolve_update_proc
52
+ @resolve_update_proc || update_input.resolve_proc
53
+ end
54
+
36
55
  def batch_run_action
37
56
  @batch_run_action ||= RunActionMutationConfig.new
38
57
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "run_action_mutation_config"
4
4
  require_relative "run_action_mutation_dsl"
5
+ require_relative "mutation_input_definition_dsl"
5
6
 
6
7
  module ActiveAdmin
7
8
  module GraphQL
@@ -18,6 +19,7 @@ module ActiveAdmin
18
19
  def type_name(name)
19
20
  @config.graphql_type_name = name.to_s
20
21
  end
22
+ alias_method :graphql_name, :type_name
21
23
 
22
24
  def only(*attrs)
23
25
  @config.only_attributes = attrs.flatten.map(&:to_sym)
@@ -28,6 +30,23 @@ module ActiveAdmin
28
30
  end
29
31
  alias_method :exclude, :except
30
32
 
33
+ def permit_params(*attrs)
34
+ @config.permit_params_attributes = attrs.flatten.map(&:to_sym)
35
+ end
36
+ alias_method :permit, :permit_params
37
+
38
+ def create(&block)
39
+ return unless block
40
+
41
+ MutationInputDefinitionDSL.new(@config.create_input).instance_exec(&block)
42
+ end
43
+
44
+ def update(&block)
45
+ return unless block
46
+
47
+ MutationInputDefinitionDSL.new(@config.update_input).instance_exec(&block)
48
+ end
49
+
31
50
  def configure(&block)
32
51
  @config.extension_block = block
33
52
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module GraphQL
5
+ module Integration
6
+ module ResourceMethods
7
+ SERVER_OWNED_TIMESTAMP_COLUMNS = %w[created_at updated_at].freeze
8
+
9
+ def graphql_config
10
+ @graphql_config ||= ActiveAdmin::GraphQL::ResourceConfig.new
11
+ end
12
+
13
+ def attributes_for_graphql
14
+ keys = resource_attributes.keys
15
+ cfg = graphql_config
16
+ if cfg.only_attributes
17
+ keys &= cfg.only_attributes
18
+ end
19
+ keys -= cfg.exclude_attributes
20
+ keys
21
+ end
22
+
23
+ def graphql_assignable_attribute_names
24
+ names = attributes_for_graphql.map(&:to_s)
25
+ pk_cols = ActiveAdmin::PrimaryKey.columns(resource_class)
26
+ return names if pk_cols.size > 1
27
+
28
+ names - pk_cols
29
+ end
30
+
31
+ def graphql_create_attribute_names
32
+ mutation_permit_params_names(
33
+ graphql_config.create_input.permit_params_attributes || graphql_config.permit_params_attributes
34
+ )
35
+ end
36
+
37
+ def graphql_update_attribute_names
38
+ mutation_permit_params_names(
39
+ graphql_config.update_input.permit_params_attributes || graphql_config.permit_params_attributes
40
+ )
41
+ end
42
+
43
+ private
44
+
45
+ def mutation_permit_params_names(permit_list)
46
+ names = graphql_assignable_attribute_names.map(&:to_s)
47
+ permit_list = graphql_config.html_permit_params_attributes if permit_list.nil?
48
+ return names - SERVER_OWNED_TIMESTAMP_COLUMNS if permit_list.nil?
49
+
50
+ names & permit_list.map(&:to_s)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module GraphQL
5
+ class ResourceQueryProxy
6
+ module Identity
7
+ private
8
+
9
+ def find_inferred_member(controller, model, id)
10
+ primary_key = ActiveAdmin::PrimaryKey.columns(model).first
11
+ raise ActiveRecord::RecordNotFound unless primary_key
12
+
13
+ records = controller.send(:scoped_collection).where(primary_key => id).limit(2).to_a
14
+ raise_ambiguous_id!(model) if records.size > 1
15
+
16
+ records.first || raise(ActiveRecord::RecordNotFound)
17
+ end
18
+
19
+ def index_records_by_id(relation, model, primary_key)
20
+ if model.primary_key.present?
21
+ return relation.index_by { |record| record.public_send(primary_key).to_s }
22
+ end
23
+
24
+ grouped = relation.to_a.group_by { |record| record.public_send(primary_key).to_s }
25
+ if grouped.any? { |_id, matches| matches.size > 1 }
26
+ raise_ambiguous_id!(model)
27
+ end
28
+
29
+ grouped.transform_values(&:first)
30
+ end
31
+
32
+ def raise_ambiguous_id!(model)
33
+ raise ::GraphQL::ExecutionError, "ambiguous GraphQL id for #{model.name}"
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -4,14 +4,14 @@ require "rack/mock"
4
4
  require "stringio"
5
5
 
6
6
  require_relative "resource_query_proxy/controller"
7
+ require_relative "resource_query_proxy/identity"
7
8
 
8
9
  module ActiveAdmin
9
10
  module GraphQL
10
- # Reuses {ResourceController} data-access behaviour (+scoped_collection+, +find_collection+,
11
- # +find_resource+, authorization scoping, Ransack, menu scopes, +sorting+, +includes+)
12
- # so GraphQL list/detail mutations align with the HTML/JSON list and member REST endpoints.
11
+ # Reuses ResourceController data access so GraphQL operations align with the REST endpoints.
13
12
  class ResourceQueryProxy
14
13
  include Controller
14
+ include Identity
15
15
 
16
16
  def initialize(aa_resource:, user:, namespace:, graph_params: {})
17
17
  @aa_resource = aa_resource
@@ -26,7 +26,12 @@ module ActiveAdmin
26
26
 
27
27
  def find_member(id)
28
28
  extra = member_route_params_for_find(id)
29
- controller_for("show", extra).send(:find_resource)
29
+ controller = controller_for("show", extra)
30
+ model = @aa_resource.resource_class
31
+ finder = controller.send(:method_for_find)
32
+ return controller.send(:find_resource) if model.primary_key.present? || finder != :find
33
+
34
+ find_inferred_member(controller, model, extra.fetch("id"))
30
35
  rescue ActiveRecord::RecordNotFound
31
36
  nil
32
37
  end
@@ -42,10 +47,10 @@ module ActiveAdmin
42
47
 
43
48
  controller = controller_for("index")
44
49
  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
50
+ primary_key = ActiveAdmin::PrimaryKey.columns(model).first&.to_sym
51
+ return string_ids.index_with { nil } if primary_key.nil?
52
+
53
+ indexed = index_records_by_id(relation.where(primary_key => string_ids), model, primary_key)
49
54
  string_ids.index_with { |identifier| indexed[identifier] }
50
55
  end
51
56
 
@@ -28,9 +28,9 @@ module ActiveAdmin
28
28
  h
29
29
  end
30
30
 
31
- def assignable_slice_from_input(aa_res, input)
31
+ def assignable_slice_from_input(aa_res, input, attribute_names: nil)
32
32
  blob = input.to_h.stringify_keys
33
- names = aa_res.graphql_assignable_attribute_names.map(&:to_s)
33
+ names = (attribute_names || aa_res.graphql_assignable_attribute_names).map(&:to_s)
34
34
  if (btc = aa_res.belongs_to_config)
35
35
  names -= [btc.to_param.to_s]
36
36
  end
@@ -29,7 +29,11 @@ module ActiveAdmin
29
29
  namespace: ns,
30
30
  graph_params: sb.graph_params_from_input(aa_res, input)
31
31
  )
32
- attrs = sb.assignable_slice_from_input(aa_res, input)
32
+ attrs = sb.assignable_slice_from_input(
33
+ aa_res,
34
+ input,
35
+ attribute_names: aa_res.graphql_create_attribute_names
36
+ )
33
37
  record = if (hook = aa_res.graphql_config.resolve_create_proc)
34
38
  hook.call(
35
39
  proxy: proxy,
@@ -46,7 +50,7 @@ module ActiveAdmin
46
50
  end
47
51
 
48
52
  unless r.save
49
- raise ::GraphQL::ExecutionError, r.errors.full_messages.to_sentence
53
+ raise MutationExecutionError.validation(r)
50
54
  end
51
55
  r
52
56
  end
@@ -42,7 +42,11 @@ module ActiveAdmin
42
42
  raise ::GraphQL::ExecutionError, "not authorized to update this record"
43
43
  end
44
44
 
45
- attrs = sb.assignable_slice_from_input(aa_res, input)
45
+ attrs = sb.assignable_slice_from_input(
46
+ aa_res,
47
+ input,
48
+ attribute_names: aa_res.graphql_update_attribute_names
49
+ )
46
50
  record = if (hook = aa_res.graphql_config.resolve_update_proc)
47
51
  hook.call(
48
52
  proxy: proxy,
@@ -54,9 +58,9 @@ module ActiveAdmin
54
58
  aa_resource: aa_res
55
59
  )
56
60
  else
57
- attrs = attrs.stringify_keys.slice(*aa_res.graphql_assignable_attribute_names)
61
+ attrs = attrs.stringify_keys.slice(*aa_res.graphql_update_attribute_names)
58
62
  unless record.update(attrs)
59
- raise ::GraphQL::ExecutionError, record.errors.full_messages.to_sentence
63
+ raise MutationExecutionError.validation(record)
60
64
  end
61
65
  record
62
66
  end
@@ -66,17 +70,22 @@ module ActiveAdmin
66
70
  end
67
71
 
68
72
  def mutation_destroy_field(sb, ns, aa_res, model)
69
- fname = "delete_#{aa_res.resource_name.route_key.singularize.tr("-", "_")}"
73
+ stem = aa_res.resource_name.route_key.singularize.tr("-", "_")
74
+ delete_name = "delete_#{stem}"
75
+ destroy_name = "destroy_#{stem}"
70
76
  find_type = @find_input_types[model]
77
+ type_basename = sb.send(:graphql_type_name_for, aa_res)
71
78
 
72
79
  proc do
73
- field fname.to_sym, ::GraphQL::Types::Boolean, null: false, camelize: false,
74
- visibility: {kind: :mutation_delete, graphql_type_name: sb.send(:graphql_type_name_for, aa_res), resource: aa_res},
75
- description: "Delete #{model.name}" do
76
- argument :where, find_type, required: true, camelize: false
80
+ [delete_name, destroy_name].each do |fname|
81
+ field fname.to_sym, ::GraphQL::Types::Boolean, null: false, camelize: false,
82
+ visibility: {kind: :mutation_delete, graphql_type_name: type_basename, resource: aa_res},
83
+ description: "Destroy #{model.name}" do
84
+ argument :where, find_type, required: true, camelize: false
85
+ end
77
86
  end
78
87
 
79
- define_method(fname.to_sym) do |where:, **|
88
+ define_method(delete_name.to_sym) do |where:, **|
80
89
  auth = context[:auth]
81
90
  blob = where.to_h.stringify_keys
82
91
  graph = sb.graph_params_from_find_blob(aa_res, blob)
@@ -112,6 +121,7 @@ module ActiveAdmin
112
121
  end
113
122
  true
114
123
  end
124
+ alias_method destroy_name.to_sym, delete_name.to_sym
115
125
  end
116
126
  end
117
127
  end
@@ -6,10 +6,16 @@ module ActiveAdmin
6
6
  module TypesInputs
7
7
  private
8
8
 
9
- def define_input_assignable_arguments!(input_class, aa_res, required:)
9
+ def mark_empty_input_object!(input_class)
10
+ return if input_class.any_arguments?
11
+
12
+ input_class.has_no_arguments(true)
13
+ end
14
+
15
+ def define_input_assignable_arguments!(input_class, aa_res, required:, attribute_names:)
10
16
  model = aa_res.resource_class
11
17
  cols_by_name = model.columns.index_by(&:name)
12
- gassign = aa_res.graphql_assignable_attribute_names.map(&:to_s)
18
+ gassign = attribute_names.map(&:to_s)
13
19
  if (btc = aa_res.belongs_to_config)
14
20
  gassign -= [btc.to_param.to_s]
15
21
  end
@@ -33,12 +39,19 @@ module ActiveAdmin
33
39
  graphql_name "#{gname}CreateInput"
34
40
  description "Attributes (and nested route params) for creating #{model.name}"
35
41
 
36
- builder.send(:define_input_assignable_arguments!, self, aa_res, required: false)
42
+ builder.send(
43
+ :define_input_assignable_arguments!,
44
+ self,
45
+ aa_res,
46
+ required: false,
47
+ attribute_names: aa_res.graphql_create_attribute_names
48
+ )
37
49
 
38
50
  if btc
39
51
  argument btc.to_param.to_sym, ::GraphQL::Types::ID, required: btc.required?, camelize: false
40
52
  end
41
53
  end
54
+ mark_empty_input_object!(klass)
42
55
  attach_input_object_visibility!(klass, "#{gname}CreateInput", aa_res, :create_input)
43
56
  klass
44
57
  end
@@ -53,12 +66,19 @@ module ActiveAdmin
53
66
  graphql_name "#{gname}UpdateInput"
54
67
  description "Partial attributes (and nested route params) for updating #{model.name}"
55
68
 
56
- builder.send(:define_input_assignable_arguments!, self, aa_res, required: false)
69
+ builder.send(
70
+ :define_input_assignable_arguments!,
71
+ self,
72
+ aa_res,
73
+ required: false,
74
+ attribute_names: aa_res.graphql_update_attribute_names
75
+ )
57
76
 
58
77
  if btc
59
78
  argument btc.to_param.to_sym, ::GraphQL::Types::ID, required: false, camelize: false
60
79
  end
61
80
  end
81
+ mark_empty_input_object!(klass)
62
82
  attach_input_object_visibility!(klass, "#{gname}UpdateInput", aa_res, :update_input)
63
83
  klass
64
84
  end
@@ -83,12 +83,25 @@ module ActiveAdmin
83
83
  end
84
84
 
85
85
  pk_cols = ActiveAdmin::PrimaryKey.columns(model)
86
+ if pk_cols.empty?
87
+ raise ArgumentError,
88
+ "#{model.name} has no primary key for GraphQL `id`; set self.primary_key on the model"
89
+ end
86
90
 
87
91
  type_class.field :id, ::GraphQL::Types::ID, null: false, authorize: false
88
92
  type_class.define_method(:id) { ActiveAdmin::PrimaryKey.graphql_id_value(object) }
89
93
 
90
- attr_names.each do |name|
91
- next if pk_cols.include?(name) && pk_cols.size == 1
94
+ # ActiveAdmin omits a single-column primary key from resource_attributes; still expose
95
+ # non-id key columns as scalars (composite keys already remain in attributes).
96
+ # ActiveAdminResource owns GraphQL `id`; never emit a second field from an `id` column.
97
+ configured_pk_cols = pk_cols
98
+ if (only = aa_res.graphql_config.only_attributes)
99
+ configured_pk_cols &= only.map(&:to_s)
100
+ end
101
+ configured_pk_cols -= aa_res.graphql_config.exclude_attributes.map(&:to_s)
102
+ readable_names = (attr_names + configured_pk_cols).uniq
103
+ readable_names.each do |name|
104
+ next if name == "id"
92
105
 
93
106
  col = cols_by_name[name]
94
107
  next unless col
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveAdmin
4
4
  module GraphQL
5
- VERSION = "0.2.1"
5
+ VERSION = "0.3.1"
6
6
  end
7
7
  end
@@ -77,6 +77,7 @@ module ActiveAdmin
77
77
  graphql/run_action_mutation_dsl
78
78
  graphql/key_value_pair_input
79
79
  graphql/policy_set_cache
80
+ graphql/mutation_execution_error
80
81
  graphql/schema_builder
81
82
  ].each { |path| require_relative path }
82
83
  end
@@ -14,13 +14,22 @@ unless defined?(ActiveAdmin::PrimaryKey)
14
14
  end
15
15
 
16
16
  def ordered_columns(model)
17
- Array(model.primary_key).map(&:to_s)
18
- end
17
+ cols = case (pk = model.primary_key)
18
+ when nil, false then []
19
+ when Array then pk.map(&:to_s).reject(&:empty?)
20
+ else
21
+ (name = pk.to_s).empty? ? [] : [name]
22
+ end
23
+ return cols if cols.any?
24
+ return %w[id] if model.respond_to?(:columns_hash) && model.columns_hash.key?("id")
19
25
 
20
- def columns(model)
21
- ordered_columns(model)
26
+ []
27
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
28
+ []
22
29
  end
23
30
 
31
+ def columns(model) = ordered_columns(model)
32
+
24
33
  def composite_attribute_hash(model, id_param)
25
34
  cols = ordered_columns(model)
26
35
  hash = normalize_composite_input(cols, id_param)