activeadmin-graphql 0.3.1 → 0.3.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5fc45489403fdf5c4d16bd66966cc4c7c5641440663d3f5b229b0d627a0d0978
4
- data.tar.gz: '04786131baffbec749af13c347660836c1ca31efa074c73eddfc49c5fbfb588e'
3
+ metadata.gz: 95e56a00a0c78ea55f44c186012af72c1ba6ff80ada2adbf905ce9b2d190c47d
4
+ data.tar.gz: 1e1dc89e1ffd41d05c6f2962a83857188a239ba677e6f492dbc304a09bb123b9
5
5
  SHA512:
6
- metadata.gz: 1c93c24f91a208c8eb00076d1fd3d8b398c99d155a8ce92f4aaa075b6615ea321a3303b2186e85fa32178a4e8ad158fb4ab1f081752993c6a44e5466fad1ac83
7
- data.tar.gz: f5c1acc62e5f1aa0cd925234c4df76d9870dd592cb97aeae7927df578340d70936e02bcf727635a8f07d7caacfb84316e037383c7666b35cb472d11b8776240e
6
+ metadata.gz: 02533503de4c86355e28bc7212115b043a9d7da293db40d1134251f7892539fca8257d6c03140c110d12da8e4de1b4112668c7e6fc2d721273c990d9fff930e3
7
+ data.tar.gz: 6e13f9a69b6cc7df900cf9883ab60600be21bfea50196209c7324306030d74784cb3e66082ffc3ec225192c3c0c385fffb3f24840061e9f12415b60479de9d3c
data/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.2 (2026-10-10)
6
+
7
+ - Add `graphql { identity ... }` so keyless views can declare GraphQL id columns, optional `separator`, and `encode` / `decode` (RFC 0007).
8
+ - Fail schema build when a registered model has neither a primary key nor an `id` column and no `identity` declaration; set `self.primary_key`, use `identity`, or `graphql { disable! }`.
9
+
5
10
  ## 0.3.1 (2026-10-10)
6
11
 
7
12
  - Fix GraphQL identity handling for database views and nonconventional primary keys: keep one `id` field, support inferred `id` lookups, reject ambiguous identifiers, and honor attribute filters.
@@ -11,7 +11,10 @@ primary key is blank. Object types skip emitting a column field named `id`
11
11
  (interface owns GraphQL `id`) and expose a single non-`id` primary-key column
12
12
  as a readable scalar unless the resource's attribute configuration omits it.
13
13
  Models with neither a primary key nor an `id` column fail schema build with an
14
- `ArgumentError` that asks for `self.primary_key`. Inferred `id` values must be
14
+ `ArgumentError` that asks for `self.primary_key`. On 0.3.0 those models could
15
+ still enter the schema with a broken GraphQL `id`. Apps that keep such a
16
+ resource registered must set `self.primary_key` or use `graphql { disable! }`
17
+ so the rest of the namespace schema still builds. Inferred `id` values must be
15
18
  unique and non-null; ambiguous member and batched lookups fail closed.
16
19
 
17
20
  Member lookup uses the inferred column while preserving an explicitly
@@ -21,9 +24,19 @@ entries for distinct records when ActiveRecord has no declared primary key.
21
24
  Dummy coverage includes SQLite views that project `code AS id` and
22
25
  `id AS row_key`, plus a table with an `id` column and no primary key.
23
26
 
27
+ ## Later pass 2026-10-10
28
+
29
+ Downstream check: a database view with neither primary key nor `id` column
30
+ could stay GraphQL-disabled via `graphql { disable! }`. Spec covers that
31
+ escape hatch next to the ArgumentError path.
32
+
33
+ Later the same day: RFC 0007 adds `graphql { identity ... }` so those views
34
+ can stay enabled when columns (and optional separator) are declared. See
35
+ docs/changelogs/20261010110600_graphql-identity-dsl.md.
36
+
24
37
  ## Next
25
38
 
26
- Folded into 0.3.1 release metadata. Tag and gem push wait on make release.
39
+ Core fix shipped in 0.3.1. Identity DSL follow-on is 0.3.2.
27
40
 
28
41
  ## Source
29
42
 
@@ -32,3 +45,4 @@ Folded into 0.3.1 release metadata. Tag and gem push wait on make release.
32
45
  `spec/unit/resource_query_proxy_identity_spec.rb`,
33
46
  `spec/unit/policy_set_cache_spec.rb`
34
47
  - Views: `spec/dummy/db/schema.rb` (`string_id_records`, `alternate_key_records`)
48
+ - Follow-on: docs/changelogs/20261010110600_graphql-identity-dsl.md
@@ -6,7 +6,11 @@ Andrei Makarov
6
6
 
7
7
  ## Decisions
8
8
 
9
- Cut 0.3.1 after published 0.3.0. One user-visible fix: GraphQL identity for database views and nonconventional primary keys.
9
+ Cut 0.3.1 after published 0.3.0. User-visible work: GraphQL identity for
10
+ database views and nonconventional primary keys; fail-closed when a model has
11
+ neither a primary key nor an `id` column without configuration; and
12
+ `graphql { identity ... }` (RFC 0007) for keyless views with a virtual
13
+ composite id.
10
14
 
11
15
  CHANGELOG 0.3.1 dated 2026-10-10. Version constant is 0.3.1. Path gem pins in Gemfile.lock and appraisal lockfiles match.
12
16
 
@@ -24,9 +28,12 @@ PATH specs for activeadmin-graphql in Gemfile.lock, gemfiles/rails72.gemfile.loc
24
28
 
25
29
  ## Next
26
30
 
27
- Run make release from a clean tree when ready to push the gem and tag 0.3.1.
31
+ 0.3.1 tagged and published on RubyGems. Post-tag identity DSL work ships in
32
+ 0.3.2; see docs/changelogs/20261010111800_release-0-3-2.md.
28
33
 
29
34
  ## Source
30
35
 
31
36
  docs/changelogs/20261010101500_nonconventional-id-fields.md
37
+ docs/changelogs/20261010110600_graphql-identity-dsl.md
38
+ rfcs/0007-graphql-identity.md
32
39
  usr/bin/release.rb
@@ -0,0 +1,34 @@
1
+ # GraphQL identity DSL (RFC 0007)
2
+
3
+ ## Decisions
4
+
5
+ Ship `graphql { identity *columns, separator:, encode:, decode: }` so resources
6
+ without an ActiveRecord primary key and without an `id` column can stay in the
7
+ schema. Prefer resource configuration over forcing `self.primary_key`.
8
+
9
+ Separator form matches HTML admins that use a virtual `id` method such as
10
+ `[entry_type, entry_id, event_type].join(":")`. Without separator, multi-column
11
+ identity keeps the existing composite JSON string.
12
+
13
+ ## Effects
14
+
15
+ Schema build, member lookup, mutations that resolve by where/id, and policy
16
+ cache keys go through `ActiveAdmin::GraphQL::ResourceIdentity` when identity is
17
+ configured. Ambiguous matches still fail closed.
18
+
19
+ Dummy coverage: view `alert_events` / model `AlertEvent` with virtual composite
20
+ `id`.
21
+
22
+ ## Next
23
+
24
+ Folded into 0.3.2 release metadata (0.3.1 already published without this DSL).
25
+ Tag and gem push wait on make release.
26
+
27
+ ## Source
28
+
29
+ rfcs/0007-graphql-identity.md
30
+ docs/changelogs/20261010101500_nonconventional-id-fields.md
31
+ docs/issues/20261010111159_rails-composite-primary-keys-alignment.md
32
+ https://guides.rubyonrails.org/active_record_composite_primary_keys.html
33
+ spec/unit/resource_identity_spec.rb
34
+ spec/requests/graphql_identity_dsl_spec.rb
@@ -0,0 +1,46 @@
1
+ # Release 0.3.2 metadata
2
+
3
+ ## Participants
4
+
5
+ Andrei Makarov
6
+
7
+ ## Decisions
8
+
9
+ Cut 0.3.2 after published 0.3.1. User-visible work since the 0.3.1 tag:
10
+ `graphql { identity ... }` (RFC 0007) for keyless views, and schema-build
11
+ error text that names `identity` alongside `self.primary_key` and `disable!`.
12
+
13
+ CHANGELOG 0.3.1 restored to the published one-bullet text. Identity DSL bullets
14
+ live under 0.3.2 dated 2026-10-10.
15
+
16
+ Version constant is 0.3.2. Path gem pins in Gemfile.lock and appraisal
17
+ lockfiles match.
18
+
19
+ Docs-only notes (Rails composite PK alignment, partitions/shards unsupported,
20
+ federation unsupported) stay out of CHANGELOG.md.
21
+
22
+ make release (usr/bin/release.rb) still owns gem build, gem push, git tag, and
23
+ GitHub release. This pass only prepares metadata.
24
+
25
+ ## Effects
26
+
27
+ lib/active_admin/graphql/version.rb VERSION is 0.3.2.
28
+
29
+ CHANGELOG identity DSL bullets moved from the post-tag 0.3.1 draft under 0.3.2.
30
+
31
+ PATH specs for activeadmin-graphql in Gemfile.lock,
32
+ gemfiles/rails72.gemfile.lock, gemfiles/rails8ruby4.gemfile.lock, and
33
+ gemfiles/rails8ruby34.gemfile.lock are 0.3.2.
34
+
35
+ ## Next
36
+
37
+ Run make release from a clean tree when ready to push the gem and tag 0.3.2.
38
+
39
+ ## Source
40
+
41
+ docs/changelogs/20261010110600_graphql-identity-dsl.md
42
+ rfcs/0007-graphql-identity.md
43
+ docs/issues/20261010111159_rails-composite-primary-keys-alignment.md
44
+ docs/issues/20261010111300_partitions-and-shards-support.md
45
+ docs/issues/20261010111500_graphql-federation-support.md
46
+ usr/bin/release.rb
data/docs/graphql-api.md CHANGED
@@ -229,12 +229,28 @@ GraphQL `id` always comes from `ActiveAdminResource` (`ID!`). An ActiveRecord
229
229
  `id` column is never added as a second field. When `self.primary_key` is blank
230
230
  but the model still has an `id` column (common for database views that project
231
231
  `… AS id`), that column supplies the GraphQL `id` value. When the model has
232
- neither a primary key nor an `id` column, schema build raises until you set
233
- `self.primary_key` on the model (for example a view keyed by `row_key`). A
234
- single-column primary key named something other than `id` is also exposed as
235
- its own readable field beside `id`, unless `only` or `exclude` omits it. An
236
- inferred `id` column must contain a unique, non-null value for every row;
237
- ambiguous member and batched lookups fail instead of selecting one row.
232
+ neither a primary key nor an `id` column, declare GraphQL identity on the
233
+ resource (RFC 0007), set `self.primary_key` on the model, or omit the resource
234
+ with `graphql { disable! }`. A single-column primary key named something other
235
+ than `id` is also exposed as its own readable field beside `id`, unless `only`
236
+ or `exclude` omits it. An inferred `id` column must contain a unique, non-null
237
+ value for every row; ambiguous member and batched lookups fail instead of
238
+ selecting one row.
239
+
240
+ ```ruby
241
+ # View with no PK and no id column; virtual identity matches HTML admin.
242
+ ActiveAdmin.register AlertEvent do
243
+ graphql do
244
+ identity :entry_type, :entry_id, :event_type, separator: ":"
245
+ # optional: encode: ->(record) { record.id }, decode: ->(id) { ... }
246
+ end
247
+ end
248
+ ```
249
+
250
+ With `separator: ":"`, GraphQL `id` is `entry_type:entry_id:event_type`. Keep
251
+ the model’s `def id` (if any) in the same shape so HTML and GraphQL share
252
+ member ids. Without `separator`, multi-column identity uses the same JSON
253
+ object string as composite ActiveRecord primary keys.
238
254
 
239
255
  ### Composite primary keys (Rails 7.1+)
240
256
 
@@ -242,8 +258,10 @@ For models with `self.primary_key = [:book_code, :seq]` (and no separate `id`
242
258
  column), Active Admin treats GraphQL as follows:
243
259
 
244
260
  * `id` on the object type — JSON object string with all key columns in
245
- model order (same format Rails uses when casting composite ids), for example
246
- `{"book_code":"CPK","seq":7}`.
261
+ model order, for example `{"book_code":"CPK","seq":7}`. This is the GraphQL
262
+ wire format. It is not the Rails form/URL underscore packing described in
263
+ the Composite Primary Keys guide (`/books/2_25` with
264
+ `params.extract_value(:id)`).
247
265
  * Readable fields — each primary-key column is also exposed as its own field
248
266
  (in addition to `id`) so clients can load scalars without parsing JSON.
249
267
  * `LibraryEditionWhereInput` (and similar) — either optional `id` (that JSON
@@ -255,12 +273,20 @@ column), Active Admin treats GraphQL as follows:
255
273
  inputs (unlike single `id` tables, where the key is omitted from assignable
256
274
  attributes).
257
275
 
258
- For REST/HTML member routes, `ResourceController#find_resource` must resolve composite ids from `params[:id]` (JSON string) or from individual primary-key params. Stock ActiveAdmin 3.x only passes a single `params[:id]` into `find`; an ActiveAdmin fork (or patch) that implements composite-aware `find_resource` keeps the admin UI aligned with GraphQL. The GraphQL layer also works with `ActiveAdmin::GraphQL::ResourceQueryProxy` using Rails’ tuple id convention for `Relation#find`.
276
+ For REST/HTML member routes, `ResourceController#find_resource` must resolve
277
+ composite ids from `params[:id]` or from individual primary-key params. Stock
278
+ ActiveAdmin 3.x only passes a single `params[:id]` into `find`; an ActiveAdmin
279
+ fork (or patch) that implements composite-aware `find_resource` keeps the
280
+ admin UI aligned with GraphQL. After decoding the GraphQL JSON `id`, the gem
281
+ uses Rails’ array/tuple conventions for `Relation#find` and
282
+ `where(primary_key => [tuple, …])` as in the guide.
259
283
 
260
- The gem ships `ActiveAdmin::PrimaryKey` when the core constant is missing (upstream). A fork may define `PrimaryKey` and extended `find_resource` in `ResourceController` instead.
284
+ The gem ships `ActiveAdmin::PrimaryKey` when the core constant is missing
285
+ (upstream). A fork may define `PrimaryKey` and extended `find_resource` in
286
+ `ResourceController` instead.
261
287
 
262
- `ActiveAdmin::GraphQL::RecordSource` batch-loads composite associations using
263
- Rails `where(primary_key => [tuple, …])`.
288
+ Alignment notes versus the Rails guide:
289
+ `docs/issues/20261010111159_rails-composite-primary-keys-alignment.md`.
264
290
 
265
291
  ### Collection query arguments
266
292
 
@@ -359,6 +385,7 @@ Inside `ActiveAdmin.register` you can narrow the GraphQL surface:
359
385
  ActiveAdmin.register Post do
360
386
  graphql do
361
387
  disable! # omit this resource from the schema
388
+ # identity :entry_type, :entry_id, :event_type, separator: ":" # RFC 0007
362
389
  type_name "BlogPost" # GraphQL object / mutation type basename (`graphql_name` alias)
363
390
  only :title, :body, :published_at # expose only these attributes on queries
364
391
  exclude :internal_score # or `except` / `exclude`
@@ -379,6 +406,9 @@ end
379
406
  ```
380
407
 
381
408
  * `disable!` — resource is not included in Query or Mutation.
409
+ * `identity` — declare GraphQL id columns (and optional `separator`, `encode`,
410
+ `decode`) when the model has no ActiveRecord primary key and no `id` column
411
+ (RFC 0007).
382
412
  * `type_name` (or `graphql_name`) — overrides the default GraphQL type name
383
413
  derived from the model (object types, mutations, and Rails enum GraphQL
384
414
  types use this basename).
@@ -0,0 +1,81 @@
1
+ # Rails composite primary keys alignment
2
+
3
+ ## Participants
4
+
5
+ Andrei Makarov
6
+
7
+ ## Decisions
8
+
9
+ Treat ActiveRecord composite primary keys and GraphQL `identity` as related but
10
+ not identical contracts. Keep GraphQL `id` as a single `ID!` string. Do not
11
+ adopt Rails form URL underscore packing as the default GraphQL id encoding.
12
+
13
+ Record alignment against the Rails guide Composite Primary Keys
14
+ (https://guides.rubyonrails.org/active_record_composite_primary_keys.html)
15
+ after shipping inferred id columns and RFC 0007 identity DSL.
16
+
17
+ ## Effects
18
+
19
+ Compared gem behavior to the guide’s model, query, form, and parameter rules.
20
+
21
+ ActiveRecord composite primary key (self.primary_key = array):
22
+
23
+ - Aligns: model declares composite key the same way as the guide’s
24
+ `self.primary_key = [:store_id, :sku]` style. Dummy `LibraryEdition` uses
25
+ `[:book_code, :seq]`.
26
+ - Aligns: internal batch and find paths convert that string id into an ordered
27
+ value list and use Rails tuple/`Relation#find` style lookups
28
+ (`ActiveAdmin::PrimaryKey.dataloader_tuple`, `ResourceQueryProxy` composite
29
+ branch). That matches the guide’s `Product.find([3, "XYZ12345"])` and
30
+ `where(primary_key => [[...], [...]])` idea at the Ruby API layer.
31
+ - Diverges: GraphQL and the published docs encode composite `id` as a JSON
32
+ object string (`{"book_code":"CPK","seq":7}`), not as the guide’s form/URL
33
+ underscore packing (`/books/2_25` with `params.extract_value(:id)`). JSON
34
+ keeps column names explicit and avoids ambiguity when a key value contains
35
+ `_`. Underscore packing is not accepted as GraphQL `id` today.
36
+ - Diverges: GraphQL also accepts per-key arguments and `WhereInput` fields for
37
+ composite models. The guide’s controller example extracts one `:id` param
38
+ into an array; we keep named keys as a GraphQL-friendly alternate.
39
+ - Aligns with guide caution: `where(id: ...)` / `find_by(id:)` on composite
40
+ models can mean an `:id` attribute, not the full primary key. The gem’s
41
+ GraphQL `id` field is the ActiveAdminResource identity string, not
42
+ necessarily an ActiveRecord `:id` column. Member lookup uses primary-key
43
+ columns (or configured identity), not a bare `where(id: graphql_id)` on
44
+ composite models.
45
+
46
+ Keyless views and RFC 0007 `graphql { identity ... }`:
47
+
48
+ - Outside the guide’s scope. The guide assumes a declared composite primary
49
+ key on the table/model. AlertEvent-style views with `primary_key = nil` and
50
+ a virtual Ruby `id` are not Rails composite primary keys.
51
+ - `identity :a, :b, :c, separator: ":"` is an application-level encoding so
52
+ GraphQL can share the same string as a hand-rolled `def id; [...].join(":")`.
53
+ That separator form is closer in spirit to Rails’ delimited URL id than our
54
+ JSON composite encoding, but the delimiter and segment rules are app-chosen,
55
+ not `extract_value`’s underscore default.
56
+ - Without `separator`, configured multi-column identity reuses the gem’s JSON
57
+ object string so keyless and AR-composite GraphQL ids stay one family.
58
+
59
+ Stock ActiveAdmin HTML member routes still need a composite-aware
60
+ `find_resource` (or custom finder) for AR composite keys; GraphQL does not
61
+ make Rails form URLs use JSON.
62
+
63
+ ## Next
64
+
65
+ Leave GraphQL composite `id` as JSON for AR composite primary keys unless a
66
+ later RFC deliberately adds underscore (or `extract_value`-compatible) accept
67
+ and emit. Document that choice next to the Rails guide, not as “the same
68
+ format Rails uses for casting composite ids” without qualification.
69
+
70
+ No change required for RFC 0007 separator identity; it is the escape hatch
71
+ when the model is not a Rails composite primary key.
72
+
73
+ ## Source
74
+
75
+ https://guides.rubyonrails.org/active_record_composite_primary_keys.html
76
+ docs/graphql-api.md (Nonconventional primary keys, Composite primary keys)
77
+ rfcs/0007-graphql-identity.md
78
+ lib/active_admin/primary_key.rb
79
+ lib/active_admin/graphql/resource_identity.rb
80
+ spec/dummy/app/models/library_edition.rb
81
+ spec/dummy/app/models/alert_event.rb
@@ -0,0 +1,55 @@
1
+ # Table partitions and database shards support
2
+
3
+ ## Participants
4
+
5
+ Andrei Makarov
6
+
7
+ ## Decisions
8
+
9
+ activeadmin-graphql does not treat database table partitions or Rails
10
+ database shards as a GraphQL configuration surface. Identity DSL (RFC 0007)
11
+ and ActiveRecord composite primary keys only encode lookup columns into
12
+ GraphQL `id`. They do not select a shard, connection role, or physical
13
+ partition.
14
+
15
+ App-level Rails multi-database setup (`connects_to`, `connected_to`,
16
+ horizontal shards, tenant middleware) may still apply implicitly: GraphQL
17
+ resolvers reuse ActiveAdmin `scoped_collection` / `find_resource` on whatever
18
+ connection the request process already has. That inheritance is undocumented
19
+ as a product feature and untested in this gem.
20
+
21
+ ## Effects
22
+
23
+ Searched lib, docs, rfcs, and spec for partition, shard, connects_to,
24
+ multiple database, tenant routing, writing_role, and reading_role.
25
+
26
+ Product code has no shard or partition API. Hits for “shard” and “partition”
27
+ are Polyrun/CI test parallelism and agent IO-fault vocabulary, not databases.
28
+
29
+ Dummy app uses one SQLite database (`spec/dummy/config/database.yml`). No
30
+ multi-DB, no partitioned table fixtures, no `connected_to` specs.
31
+
32
+ `RecordSource`, `PrimaryKey`, and `ResourceIdentity` run `where` / encode on
33
+ the current model class only. A partition-key column can appear in
34
+ `graphql { identity ... }` as part of the id string; that still queries the
35
+ already-selected connection and does not prune or route partitions.
36
+
37
+ ## Next
38
+
39
+ If a host needs GraphQL across shards or partitioned tables, keep routing in
40
+ the Rails/ActiveAdmin request boundary (middleware, `connected_to`, custom
41
+ finder). Do not invent gem DSL until a concrete host failure and RFC name the
42
+ contract (shard key in GraphQL args, role switching, cross-shard batch limits).
43
+
44
+ Optional later: document “unsupported / inherited only” next to identity in
45
+ `docs/graphql-api.md` when a reader asks again.
46
+
47
+ ## Source
48
+
49
+ lib/active_admin/graphql/resource_identity.rb
50
+ lib/active_admin/graphql/record_source.rb
51
+ lib/active_admin/graphql/resource_query_proxy.rb
52
+ spec/dummy/config/database.yml
53
+ rfcs/0007-graphql-identity.md
54
+ docs/issues/20261010111159_rails-composite-primary-keys-alignment.md
55
+ polyrun.yml (test shards only)
@@ -0,0 +1,46 @@
1
+ # GraphQL Federation support
2
+
3
+ ## Participants
4
+
5
+ Andrei Makarov
6
+
7
+ ## Decisions
8
+
9
+ activeadmin-graphql does not implement GraphQL Federation (Apollo Federation
10
+ v1/v2, subgraph/supergraph, `@key` entities, `_entities` / `_service`). The
11
+ product is one graphql-ruby schema per ActiveAdmin namespace for that admin
12
+ HTTP endpoint.
13
+
14
+ RFC 0007 `graphql { identity ... }` and `ActiveAdminResource.id` are record
15
+ lookup identity inside that schema. They are not federation entity keys and
16
+ do not emit federation SDL directives.
17
+
18
+ ## Effects
19
+
20
+ Searched lib, docs, rfcs, spec, and the gemspec for federation, subgraph,
21
+ supergraph, Apollo, `@key`, shareable, apollo-federation, `_entities`, and
22
+ `_service`. No product hits.
23
+
24
+ Runtime dependencies are `activeadmin` and `graphql` only. Schema build is
25
+ `Class.new(::GraphQL::Schema)` with query, mutation, dataloader, and optional
26
+ visibility (`lib/active_admin/graphql/schema_builder/build.rb`).
27
+ `ResourceInterface` is a local interface for fragment reuse, not a federation
28
+ entity type.
29
+
30
+ An app could in principle call `graphql_configure_schema` on the built schema
31
+ and layer a federation library itself. That path is neither documented nor
32
+ tested here.
33
+
34
+ ## Next
35
+
36
+ Keep federation out of scope until a host names a concrete subgraph contract
37
+ and an RFC. Prefer documenting “single-schema admin API; federation
38
+ unsupported” if readers keep asking.
39
+
40
+ ## Source
41
+
42
+ activeadmin-graphql.gemspec
43
+ lib/active_admin/graphql/schema_builder/build.rb
44
+ lib/active_admin/graphql/resource_interface.rb
45
+ rfcs/0007-graphql-identity.md
46
+ docs/issues/20261010111300_partitions-and-shards-support.md
@@ -34,7 +34,7 @@ module ActiveAdmin
34
34
 
35
35
  def cache_key(subject_owner, subject)
36
36
  owner_key = subject_owner_key(subject_owner)
37
- "#{owner_key}:#{subject_key(subject)}"
37
+ "#{owner_key}:#{subject_key(subject_owner, subject)}"
38
38
  end
39
39
 
40
40
  private
@@ -47,13 +47,18 @@ module ActiveAdmin
47
47
  end
48
48
  end
49
49
 
50
- def subject_key(subject)
50
+ def subject_key(subject_owner, subject)
51
51
  if subject.is_a?(Class)
52
52
  "class:#{subject.name}"
53
53
  elsif subject.is_a?(ActiveAdmin::Page)
54
54
  "page:#{subject.name}"
55
55
  elsif subject.is_a?(ActiveRecord::Base)
56
- identity = PrimaryKey.graphql_id_value(subject)
56
+ identity =
57
+ if subject_owner.respond_to?(:graphql_config)
58
+ ResourceIdentity.graphql_id_value(subject_owner, subject)
59
+ else
60
+ PrimaryKey.graphql_id_value(subject)
61
+ end
57
62
  identity = "#{identity}:object:#{subject.object_id}" if subject.class.primary_key.blank?
58
63
  "record:#{subject.class.name}:#{identity}"
59
64
  else
@@ -14,6 +14,10 @@ module ActiveAdmin
14
14
  attr_accessor :permit_params_attributes
15
15
  attr_accessor :html_permit_params_attributes
16
16
  attr_accessor :extension_block
17
+ attr_accessor :identity_columns
18
+ attr_accessor :identity_separator
19
+ attr_accessor :identity_encode_proc
20
+ attr_accessor :identity_decode_proc
17
21
 
18
22
  # Optional resolver overrides (set from +graphql do+). SchemaBuilder still owns field names,
19
23
  # arguments, and types; procs replace only the Ruby resolution body.
@@ -16,6 +16,15 @@ module ActiveAdmin
16
16
  @config.enabled = false
17
17
  end
18
18
 
19
+ # Declare GraphQL identity columns when ActiveRecord has no primary key / id column.
20
+ # See RFC 0007.
21
+ def identity(*columns, separator: nil, encode: nil, decode: nil)
22
+ @config.identity_columns = columns.flatten.map(&:to_s)
23
+ @config.identity_separator = separator
24
+ @config.identity_encode_proc = encode
25
+ @config.identity_decode_proc = decode
26
+ end
27
+
19
28
  def type_name(name)
20
29
  @config.graphql_type_name = name.to_s
21
30
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ActiveAdmin
6
+ module GraphQL
7
+ # Resolves GraphQL identity columns and id encode/decode for a registered resource.
8
+ # Prefer `graphql { identity ... }` when ActiveRecord has no primary key / id column.
9
+ module ResourceIdentity
10
+ module_function
11
+
12
+ def configured?(aa_res)
13
+ aa_res.graphql_config.identity_columns.present?
14
+ end
15
+
16
+ def columns(aa_res)
17
+ configured = aa_res.graphql_config.identity_columns
18
+ return configured if configured.present?
19
+
20
+ ActiveAdmin::PrimaryKey.columns(aa_res.resource_class)
21
+ end
22
+
23
+ def graphql_id_value(aa_res, record)
24
+ cfg = aa_res.graphql_config
25
+ if (encode = cfg.identity_encode_proc)
26
+ return encode.call(record).to_s
27
+ end
28
+
29
+ cols = columns(aa_res)
30
+ return ActiveAdmin::PrimaryKey.graphql_id_value(record) unless configured?(aa_res)
31
+
32
+ encode_columns(record, cols, cfg.identity_separator)
33
+ end
34
+
35
+ def attributes_for_id(aa_res, id)
36
+ cfg = aa_res.graphql_config
37
+ if (decode = cfg.identity_decode_proc)
38
+ return stringify_attrs(decode.call(id))
39
+ end
40
+
41
+ cols = columns(aa_res)
42
+ return ActiveAdmin::PrimaryKey.find_attributes(aa_res.resource_class, id) unless configured?(aa_res)
43
+
44
+ decode_columns(cols, id, cfg.identity_separator)
45
+ end
46
+
47
+ def encode_columns(record, cols, separator)
48
+ if cols.size == 1
49
+ record.public_send(cols.first).to_s
50
+ elsif separator
51
+ cols.map { |c| record.public_send(c) }.join(separator)
52
+ else
53
+ payload = cols.each_with_object({}) { |c, memo| memo[c] = record.read_attribute(c) }
54
+ JSON.generate(payload)
55
+ end
56
+ end
57
+
58
+ def decode_columns(cols, id, separator)
59
+ if cols.size == 1
60
+ {cols.first => id.to_s}
61
+ elsif separator
62
+ parts = id.to_s.split(separator, cols.size)
63
+ raise ArgumentError, "identity id segment count mismatch" if parts.size != cols.size
64
+
65
+ cols.zip(parts).to_h
66
+ else
67
+ parsed = JSON.parse(id.to_s)
68
+ raise ArgumentError, "composite id must be a JSON object" unless parsed.is_a?(Hash)
69
+
70
+ parsed.stringify_keys.slice(*cols)
71
+ end
72
+ end
73
+
74
+ def field_kw_to_param_hash(aa_res, id:, **kw)
75
+ return ActiveAdmin::PrimaryKey.field_kw_to_param_hash(aa_res.resource_class, id: id, **kw) unless configured?(aa_res)
76
+
77
+ raise ArgumentError, "id is required" if id.blank?
78
+
79
+ {"id" => id.to_s}
80
+ end
81
+
82
+ def member_param_hash(aa_res, blob)
83
+ return ActiveAdmin::PrimaryKey.member_param_hash(aa_res.resource_class, blob) unless configured?(aa_res)
84
+
85
+ blob = blob.stringify_keys
86
+ raise ArgumentError, "id is required" if blob["id"].blank?
87
+
88
+ {"id" => blob["id"].to_s}
89
+ end
90
+
91
+ def stringify_attrs(hash)
92
+ raise ArgumentError, "identity decode must return a Hash" unless hash.is_a?(Hash)
93
+
94
+ hash.stringify_keys.transform_values { |value| value&.to_s }
95
+ end
96
+ end
97
+ end
98
+ end
@@ -22,7 +22,7 @@ module ActiveAdmin
22
22
 
23
23
  def graphql_assignable_attribute_names
24
24
  names = attributes_for_graphql.map(&:to_s)
25
- pk_cols = ActiveAdmin::PrimaryKey.columns(resource_class)
25
+ pk_cols = ActiveAdmin::GraphQL::ResourceIdentity.columns(self)
26
26
  return names if pk_cols.size > 1
27
27
 
28
28
  names - pk_cols
@@ -6,11 +6,11 @@ module ActiveAdmin
6
6
  module Identity
7
7
  private
8
8
 
9
- def find_inferred_member(controller, model, id)
10
- primary_key = ActiveAdmin::PrimaryKey.columns(model).first
11
- raise ActiveRecord::RecordNotFound unless primary_key
9
+ def find_identity_member(controller, model, id)
10
+ attrs = ActiveAdmin::GraphQL::ResourceIdentity.attributes_for_id(@aa_resource, id)
11
+ raise ActiveRecord::RecordNotFound if attrs.blank?
12
12
 
13
- records = controller.send(:scoped_collection).where(primary_key => id).limit(2).to_a
13
+ records = controller.send(:scoped_collection).where(attrs).limit(2).to_a
14
14
  raise_ambiguous_id!(model) if records.size > 1
15
15
 
16
16
  records.first || raise(ActiveRecord::RecordNotFound)
@@ -31,8 +31,8 @@ module ActiveAdmin
31
31
  finder = controller.send(:method_for_find)
32
32
  return controller.send(:find_resource) if model.primary_key.present? || finder != :find
33
33
 
34
- find_inferred_member(controller, model, extra.fetch("id"))
35
- rescue ActiveRecord::RecordNotFound
34
+ find_identity_member(controller, model, extra.fetch("id"))
35
+ rescue ActiveRecord::RecordNotFound, ArgumentError
36
36
  nil
37
37
  end
38
38
 
@@ -41,13 +41,14 @@ module ActiveAdmin
41
41
  return {} if string_ids.empty?
42
42
 
43
43
  model = @aa_resource.resource_class
44
- if ActiveAdmin::PrimaryKey.composite?(model)
44
+ identity_cols = ActiveAdmin::GraphQL::ResourceIdentity.columns(@aa_resource)
45
+ if ActiveAdmin::PrimaryKey.composite?(model) || identity_cols.size > 1
45
46
  return string_ids.index_with { |identifier| find_member(identifier) }
46
47
  end
47
48
 
48
49
  controller = controller_for("index")
49
50
  relation = controller.send(:apply_authorization_scope, controller.send(:scoped_collection))
50
- primary_key = ActiveAdmin::PrimaryKey.columns(model).first&.to_sym
51
+ primary_key = identity_cols.first&.to_sym
51
52
  return string_ids.index_with { nil } if primary_key.nil?
52
53
 
53
54
  indexed = index_records_by_id(relation.where(primary_key => string_ids), model, primary_key)
@@ -24,7 +24,7 @@ module ActiveAdmin
24
24
  blob = where.to_h.stringify_keys
25
25
  graph = sb.graph_params_from_find_blob(aa_res, blob)
26
26
  begin
27
- rid = ActiveAdmin::PrimaryKey.member_param_hash(model, blob)
27
+ rid = ActiveAdmin::GraphQL::ResourceIdentity.member_param_hash(aa_res, blob)
28
28
  rescue ArgumentError => e
29
29
  raise ::GraphQL::ExecutionError, e.message
30
30
  end
@@ -90,7 +90,7 @@ module ActiveAdmin
90
90
  blob = where.to_h.stringify_keys
91
91
  graph = sb.graph_params_from_find_blob(aa_res, blob)
92
92
  begin
93
- rid = ActiveAdmin::PrimaryKey.member_param_hash(model, blob)
93
+ rid = ActiveAdmin::GraphQL::ResourceIdentity.member_param_hash(aa_res, blob)
94
94
  rescue ArgumentError => e
95
95
  raise ::GraphQL::ExecutionError, e.message
96
96
  end
@@ -51,10 +51,12 @@ module ActiveAdmin
51
51
  if where
52
52
  blob = where.to_h.stringify_keys
53
53
  graph = builder.graph_params_from_find_blob(aa_res, blob)
54
- rid = ActiveAdmin::PrimaryKey.member_param_hash(model, blob)
54
+ rid = ActiveAdmin::GraphQL::ResourceIdentity.member_param_hash(aa_res, blob)
55
55
  else
56
56
  graph = builder.graph_params_for_mutation(aa_res, kw)
57
- rid = ActiveAdmin::PrimaryKey.field_kw_to_param_hash(model, id: id, **kw)
57
+ rid = ActiveAdmin::GraphQL::ResourceIdentity.field_kw_to_param_hash(
58
+ aa_res, id: id, **kw
59
+ )
58
60
  end
59
61
  rescue ArgumentError => e
60
62
  raise ::GraphQL::ExecutionError, e.message
@@ -82,14 +82,20 @@ module ActiveAdmin
82
82
  define_singleton_method(:activeadmin_graphql_resource) { aa_res }
83
83
  end
84
84
 
85
- pk_cols = ActiveAdmin::PrimaryKey.columns(model)
85
+ pk_cols = ActiveAdmin::GraphQL::ResourceIdentity.columns(aa_res)
86
86
  if pk_cols.empty?
87
87
  raise ArgumentError,
88
- "#{model.name} has no primary key for GraphQL `id`; set self.primary_key on the model"
88
+ "#{model.name} has no primary key for GraphQL `id`; set self.primary_key on the model " \
89
+ "or graphql { identity ... }"
89
90
  end
90
91
 
91
92
  type_class.field :id, ::GraphQL::Types::ID, null: false, authorize: false
92
- type_class.define_method(:id) { ActiveAdmin::PrimaryKey.graphql_id_value(object) }
93
+ type_class.define_method(:id) do
94
+ ActiveAdmin::GraphQL::ResourceIdentity.graphql_id_value(
95
+ self.class.activeadmin_graphql_resource,
96
+ object
97
+ )
98
+ end
93
99
 
94
100
  # ActiveAdmin omits a single-column primary key from resource_attributes; still expose
95
101
  # non-id key columns as scalars (composite keys already remain in attributes).
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveAdmin
4
4
  module GraphQL
5
- VERSION = "0.3.1"
5
+ VERSION = "0.3.2"
6
6
  end
7
7
  end
@@ -71,6 +71,7 @@ module ActiveAdmin
71
71
  graphql/schema_field
72
72
  graphql/auth_context
73
73
  graphql/record_source
74
+ graphql/resource_identity
74
75
  graphql/resource_query_proxy
75
76
  graphql/run_action_payload
76
77
  graphql/run_action_mutation_config
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activeadmin-graphql
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -376,6 +376,8 @@ files:
376
376
  - docs/changelogs/20260921120000_graphql-floor-and-activeadmin-4.md
377
377
  - docs/changelogs/20261010101500_nonconventional-id-fields.md
378
378
  - docs/changelogs/20261010103800_release-0-3-1.md
379
+ - docs/changelogs/20261010110600_graphql-identity-dsl.md
380
+ - docs/changelogs/20261010111800_release-0-3-2.md
379
381
  - docs/dependencies/20260907141000_json-cve-2026-71847.md
380
382
  - docs/dependencies/20260907141000_sqlite3-ghsa-mwm8-39rw-8826.md
381
383
  - docs/dependencies/20260921114000_rails-json-parse-kwargs.md
@@ -388,6 +390,9 @@ files:
388
390
  - docs/issues/20260921104400_convention-alignment-audit.md
389
391
  - docs/issues/20260921114500_dependency-audit.md
390
392
  - docs/issues/20260921115500_file-length-metrics.md
393
+ - docs/issues/20261010111159_rails-composite-primary-keys-alignment.md
394
+ - docs/issues/20261010111300_partitions-and-shards-support.md
395
+ - docs/issues/20261010111500_graphql-federation-support.md
391
396
  - docs/plan/20260817201500_rfc-starter.md
392
397
  - lib/active_admin/graphql.rb
393
398
  - lib/active_admin/graphql/auth_context.rb
@@ -402,6 +407,7 @@ files:
402
407
  - lib/active_admin/graphql/record_source.rb
403
408
  - lib/active_admin/graphql/resource_config.rb
404
409
  - lib/active_admin/graphql/resource_definition_dsl.rb
410
+ - lib/active_admin/graphql/resource_identity.rb
405
411
  - lib/active_admin/graphql/resource_interface.rb
406
412
  - lib/active_admin/graphql/resource_methods.rb
407
413
  - lib/active_admin/graphql/resource_query_proxy.rb