rails_audit_log-graphql 0.6.0 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ca2df96cd0a69c6449fc8585832293c9a06d487a7525f40c1d15762c61710b5
4
- data.tar.gz: 8262257281bef88c4ab07700ec88b5755ca6b2a095fd879bf5462d403ec1ac00
3
+ metadata.gz: 681be68e65f4b6f543de2ce47434d6b547db12299d15c2f136d94f1db5b15355
4
+ data.tar.gz: 184994ef369aea31293293d01e22663bb1e3dc48f3e958aec6f523997ec9ed7b
5
5
  SHA512:
6
- metadata.gz: 4598525fe46095cbd1209e9bc3af297950526eb9c7ecfab9e382ee3554f376e73b0db3073986d49cdd05190e42f90e1d09e3f1a00c3ecb948a0f02d04f5ba9f0
7
- data.tar.gz: 2069e44509afdccf33c443bcf443da4480e83e60034f73bf24b0a7139ab012910d60e17dd85d06897b0642a6e4cb0d0d548a08c6bfbd5ac8318f6bd787d998b3
6
+ metadata.gz: 5d06f3473ed427c34208a6067d4e77a445a10068e8ffc269f4725a858092c1a55c9dd12b504632df53e260f5e5b3b0b6c3aa8eab3a4499c475bdb165165d9403
7
+ data.tar.gz: a9c2936cc464d8288a815f1345b12c23918aaf7a70a15241e77e50ccf90ce0daefd27e172f1f554e7861d7d0e3d6bb9a34557cde7c614f162652795d5288a287
data/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.0.0] - 2026-06-04
4
+
5
+ ### Added
6
+
7
+ - **RSpec matcher** — `have_graphql_audit_entry(:update).touching(:title).for_type("Post")` for asserting audit entries in `Schema.execute` responses; include `RailsAuditLog::Graphql::Testing::RSpecMatchers` in your RSpec config
8
+ - **Minitest assertions** — `assert_graphql_audit_entry` and `refute_graphql_audit_entry` with the same filter interface; include `RailsAuditLog::Graphql::Testing::MinitestAssertions` in your test class
9
+ - Full YARD documentation on all public modules, classes, and methods
10
+ - RBS type signatures for `Testing::RSpecMatchers` and `Testing::MinitestAssertions`
11
+
12
+ ### Changed
13
+
14
+ - API stability guarantee — no breaking changes to public interfaces without a major version bump
15
+
16
+ ## [0.6.1] - 2026-06-04
17
+
18
+ ### Added
19
+
20
+ - `actorType:` filter argument on `auditLogEntries`, `auditLogEntriesConnection`, and `auditLogEntriesCount` — filter by actor model class name (e.g. `"User"`)
21
+ - `forTenant:` argument on `auditLogReify` and `auditLogEntriesCount` — consistent tenant scoping across all query fields; both also respect `RailsAuditLog.current_tenant` auto-tenant
22
+
23
+ ### Changed
24
+
25
+ - `auditLogReify` return type changed from generic `JSON` to `AuditLogJson` for consistency with other entry fields
26
+ - `rails g rails_audit_log:graphql:install` now also injects `SchemaPlugin` into the host schema file (detected via `app/graphql/**/*schema*.rb` glob); `print_next_steps` updated to mention all available queries and the complexity config override
27
+
3
28
  ## [0.6.0] - 2026-06-04
4
29
 
5
30
  ### Added
data/README.md CHANGED
@@ -29,6 +29,9 @@ A [graphql-ruby](https://graphql-ruby.org) API layer for the [`rails_audit_log`]
29
29
  - [AuditLogSubscriptionsMixin](#auditlogsubscriptionsmixin)
30
30
  - [auditLogEntryCreated](#auditlogentrycreated)
31
31
  - [Broadcaster](#broadcaster)
32
+ - [Testing](#testing)
33
+ - [RSpec matchers](#rspec-matchers)
34
+ - [Minitest assertions](#minitest-assertions)
32
35
  - [Development](#development)
33
36
  - [Contributing](#contributing)
34
37
  - [License](#license)
@@ -152,6 +155,7 @@ List entries with optional filters and offset pagination.
152
155
  | `itemType` | `String` | — | Filter by audited model class name |
153
156
  | `itemId` | `ID` | — | Filter by audited record ID |
154
157
  | `actorId` | `ID` | — | Filter by actor ID |
158
+ | `actorType` | `String` | — | Filter by actor model class name (e.g. `"User"`) |
155
159
  | `since` | `ISO8601DateTime` | — | Return entries created at or after this time |
156
160
  | `until` | `ISO8601DateTime` | — | Return entries created at or before this time |
157
161
  | `touching` | `String` | — | Filter to entries that changed a specific attribute |
@@ -172,6 +176,7 @@ Same filters as `auditLogEntries`, but returns a [Relay-style connection](https:
172
176
  | `itemType` | `String` | Filter by audited model class name |
173
177
  | `itemId` | `ID` | Filter by audited record ID |
174
178
  | `actorId` | `ID` | Filter by actor ID |
179
+ | `actorType` | `String` | Filter by actor model class name (e.g. `"User"`) |
175
180
  | `forTenant` | `String` | Scope to a specific tenant ID; overrides auto-tenant |
176
181
  | `first` | `Int` | Return the first N edges after `after` |
177
182
  | `after` | `String` | Cursor to paginate forward from |
@@ -221,7 +226,9 @@ Returns the count of matching audit log entries. Respects auto-tenant when `Rail
221
226
  |---|---|---|
222
227
  | `event` | `String` | Filter by event type (`create`, `update`, `destroy`) |
223
228
  | `itemType` | `String` | Filter by audited model class name |
229
+ | `actorType` | `String` | Filter by actor model class name (e.g. `"User"`) |
224
230
  | `since` | `ISO8601DateTime` | Count entries created at or after this time |
231
+ | `forTenant` | `String` | Scope to a specific tenant ID; overrides auto-tenant |
225
232
 
226
233
  ```graphql
227
234
  { auditLogEntriesCount(event: "update", itemType: "Post") }
@@ -291,9 +298,9 @@ The plugin also adds `AuditLogActor.record` and `AuditedResource.record` fields
291
298
 
292
299
  [↑ Back to top](#table-of-contents)
293
300
 
294
- ### `auditLogReify(itemType:, itemId:, at:): JSON`
301
+ ### `auditLogReify(itemType:, itemId:, at:): AuditLogJson`
295
302
 
296
- Reconstructs the attribute state of a record at a given point in time. Returns the attributes as JSON, or `nil` when no entry exists at or before `at` or the record was destroyed at that time.
303
+ Reconstructs the attribute state of a record at a given point in time. Returns the attributes as `AuditLogJson`, or `nil` when no entry exists at or before `at` or the record was destroyed at that time. Accepts `forTenant:` and respects auto-tenant.
297
304
 
298
305
  ```graphql
299
306
  {
@@ -384,6 +391,82 @@ For each entry, the broadcaster triggers:
384
391
 
385
392
  [↑ Back to top](#table-of-contents)
386
393
 
394
+ ## Testing
395
+
396
+ `rails_audit_log-graphql` ships test helpers for both RSpec and Minitest so you can
397
+ assert that your mutations actually produce the expected audit log entries.
398
+
399
+ Require the testing helpers from your test helper:
400
+
401
+ ```ruby
402
+ # spec/spec_helper.rb or test/test_helper.rb
403
+ require "rails_audit_log/graphql/testing"
404
+ ```
405
+
406
+ ### RSpec matchers
407
+
408
+ Include `RailsAuditLog::Graphql::Testing::RSpecMatchers` in your configuration:
409
+
410
+ ```ruby
411
+ RSpec.configure do |config|
412
+ config.include RailsAuditLog::Graphql::Testing::RSpecMatchers
413
+ end
414
+ ```
415
+
416
+ Then use `have_graphql_audit_entry` against any `GraphQL::Query::Result` (or plain hash) returned by `Schema.execute`:
417
+
418
+ ```ruby
419
+ result = MySchema.execute('{ auditLogEntries { event diff { attribute } } }')
420
+
421
+ # Assert an entry with a specific event exists
422
+ expect(result).to have_graphql_audit_entry(:update)
423
+
424
+ # Assert the update entry touched the :title attribute
425
+ # (requires diff { attribute } in the query)
426
+ expect(result).to have_graphql_audit_entry(:update).touching(:title)
427
+
428
+ # Scope to a specific model
429
+ expect(result).to have_graphql_audit_entry(:create).for_type("Post")
430
+
431
+ # Combine chains
432
+ expect(result).to have_graphql_audit_entry(:update).for_type("Post").touching(:title)
433
+ ```
434
+
435
+ The matcher searches `auditLogEntries` and `auditLogEntriesConnection.nodes` in the
436
+ response data.
437
+
438
+ [↑ Back to top](#table-of-contents)
439
+
440
+ ### Minitest assertions
441
+
442
+ Include `RailsAuditLog::Graphql::Testing::MinitestAssertions` in your test class:
443
+
444
+ ```ruby
445
+ class ActiveSupport::TestCase
446
+ include RailsAuditLog::Graphql::Testing::MinitestAssertions
447
+ end
448
+ ```
449
+
450
+ Use `assert_graphql_audit_entry` and `refute_graphql_audit_entry`:
451
+
452
+ ```ruby
453
+ result = MySchema.execute('{ auditLogEntries { event diff { attribute } } }')
454
+
455
+ # Assert an update entry exists
456
+ assert_graphql_audit_entry result, event: :update
457
+
458
+ # Assert an update entry that touched :title
459
+ assert_graphql_audit_entry result, event: :update, touching: :title
460
+
461
+ # Assert no destroy entry exists
462
+ refute_graphql_audit_entry result, event: :destroy
463
+
464
+ # Custom failure message
465
+ assert_graphql_audit_entry result, event: :update, message: "mutation should have created an update entry"
466
+ ```
467
+
468
+ [↑ Back to top](#table-of-contents)
469
+
387
470
  ## Development
388
471
 
389
472
  ```bash
data/ROADMAP.md CHANGED
@@ -4,10 +4,4 @@ This gem adds a GraphQL API layer on top of [`rails_audit_log`](https://github.c
4
4
 
5
5
  ---
6
6
 
7
- ## 1.0.0 Stable API
8
-
9
- - Full YARD documentation
10
- - **RSpec matchers** — `expect(response).to have_graphql_audit_entry(:update).touching(:title)`
11
- - **Minitest assertions** — `assert_graphql_audit_entry`
12
- - API stability guarantee — no breaking changes without a major version bump
13
- - Complete README with setup guide, examples, and schema reference
7
+ _No planned milestones at this time._
@@ -7,10 +7,11 @@ module RailsAuditLog
7
7
  module Graphql
8
8
  class InstallGenerator < Rails::Generators::Base
9
9
  source_root File.expand_path("templates", __dir__)
10
- desc "Injects AuditLogEntriesQueryMixin into your GraphQL QueryType."
10
+ desc "Injects AuditLogEntriesQueryMixin into your GraphQL QueryType and SchemaPlugin into your schema."
11
11
 
12
12
  QUERY_TYPE_PATH = "app/graphql/types/query_type.rb"
13
13
  MIXIN = "RailsAuditLog::Graphql::Queries::AuditLogEntriesQueryMixin"
14
+ SCHEMA_PLUGIN = "RailsAuditLog::Graphql::SchemaPlugin"
14
15
 
15
16
  def inject_mixin
16
17
  if File.exist?(File.join(destination_root, QUERY_TYPE_PATH))
@@ -24,11 +25,31 @@ module RailsAuditLog
24
25
  end
25
26
  end
26
27
 
28
+ def inject_schema_plugin
29
+ schema_files = Dir.glob(File.join(destination_root, "app/graphql/**/*schema*.rb"))
30
+ if schema_files.any?
31
+ schema_path = schema_files.first.delete_prefix("#{destination_root}/")
32
+ inject_into_file schema_path,
33
+ " include #{SCHEMA_PLUGIN}\n",
34
+ after: /class\s+\S+\s*<\s*GraphQL::Schema\s*\n/
35
+ else
36
+ say ""
37
+ say "No schema file found. Add this line manually to your GraphQL::Schema subclass:", :yellow
38
+ say " include #{SCHEMA_PLUGIN}", :green
39
+ end
40
+ end
41
+
27
42
  def print_next_steps
28
43
  say ""
29
44
  say "Done! Your GraphQL API now has:", :green
30
45
  say " auditLogEntry(id: ID!): AuditLogEntry"
31
46
  say " auditLogEntries(...): [AuditLogEntry!]!"
47
+ say " auditLogReify(itemType:, itemId:, at:): AuditLogJson"
48
+ say " auditLogEntriesCount(...): Int!"
49
+ say ""
50
+ say "SchemaPlugin applies complexity/depth limits and enables dataloader."
51
+ say "Override defaults in an initializer:"
52
+ say " RailsAuditLog::Graphql.max_complexity = 500"
32
53
  say ""
33
54
  say "See the README for full documentation."
34
55
  end
@@ -3,7 +3,24 @@
3
3
  module RailsAuditLog
4
4
  module Graphql
5
5
  module Queries
6
+ # Mixin that adds all audit log query fields to a host application's +QueryType+.
7
+ #
8
+ # Include in your +QueryType+:
9
+ #
10
+ # class Types::QueryType < Types::BaseObject
11
+ # include RailsAuditLog::Graphql::Queries::AuditLogEntriesQueryMixin
12
+ # end
13
+ #
14
+ # This adds the following fields to the schema:
15
+ # - +auditLogEntry(id:)+ — fetch a single entry by ID
16
+ # - +auditLogEntries(...)+ — offset-paginated list with filters
17
+ # - +auditLogEntriesConnection(...)+ — cursor-paginated Relay connection
18
+ # - +auditLogEntriesCount(...)+ — count matching entries
19
+ # - +auditLogReify(itemType:, itemId:, at:)+ — reconstruct record state at a point in time
20
+ #
21
+ # @see https://github.com/eclectic-coding/rails_audit_log-graphql README
6
22
  module AuditLogEntriesQueryMixin
23
+ # @api private
7
24
  def self.included(base)
8
25
  base.field(
9
26
  :audit_log_entry,
@@ -29,6 +46,7 @@ module RailsAuditLog
29
46
  argument :item_type, String, required: false, description: "Filter by audited model class name."
30
47
  argument :item_id, GraphQL::Types::ID, required: false, description: "Filter by audited record ID."
31
48
  argument :actor_id, GraphQL::Types::ID, required: false, description: "Filter by actor ID."
49
+ argument :actor_type, String, required: false, description: "Filter by actor model class name (e.g. \"User\")."
32
50
  argument :since, GraphQL::Types::ISO8601DateTime, required: false, description: "Return entries created at or after this time."
33
51
  argument :until, GraphQL::Types::ISO8601DateTime, required: false, as: :until_time, description: "Return entries created at or before this time."
34
52
  argument :touching, String, required: false, description: "Filter to entries that changed a specific attribute (matches object_changes keys)."
@@ -50,6 +68,7 @@ module RailsAuditLog
50
68
  argument :item_type, String, required: false, description: "Filter by audited model class name."
51
69
  argument :item_id, GraphQL::Types::ID, required: false, description: "Filter by audited record ID."
52
70
  argument :actor_id, GraphQL::Types::ID, required: false, description: "Filter by actor ID."
71
+ argument :actor_type, String, required: false, description: "Filter by actor model class name (e.g. \"User\")."
53
72
  argument :since, GraphQL::Types::ISO8601DateTime, required: false, description: "Return entries created at or after this time."
54
73
  argument :until, GraphQL::Types::ISO8601DateTime, required: false, as: :until_time, description: "Return entries created at or before this time."
55
74
  argument :touching, String, required: false, description: "Filter to entries that changed a specific attribute (matches object_changes keys)."
@@ -60,7 +79,7 @@ module RailsAuditLog
60
79
 
61
80
  base.field(
62
81
  :audit_log_reify,
63
- GraphQL::Types::JSON,
82
+ Types::AuditLogJsonScalar,
64
83
  null: true,
65
84
  description: "Reconstruct the attribute state of a record at a given point in time. Returns nil when no entry exists at or before `at`, or when the record was destroyed.",
66
85
  resolver_method: :resolve_audit_log_reify
@@ -68,6 +87,8 @@ module RailsAuditLog
68
87
  argument :item_type, String, required: true, description: "The audited model class name."
69
88
  argument :item_id, GraphQL::Types::ID, required: true, description: "The audited record ID."
70
89
  argument :at, GraphQL::Types::ISO8601DateTime, required: true, description: "Reconstruct state as of this time."
90
+ argument :for_tenant, String, required: false,
91
+ description: "Scope to a specific tenant ID. Overrides auto-tenant when RailsAuditLog.current_tenant is configured."
71
92
  end
72
93
 
73
94
  base.field(
@@ -79,10 +100,18 @@ module RailsAuditLog
79
100
  ) do
80
101
  argument :event, String, required: false, description: "Filter by event type (create, update, destroy)."
81
102
  argument :item_type, String, required: false, description: "Filter by audited model class name."
103
+ argument :actor_type, String, required: false, description: "Filter by actor model class name (e.g. \"User\")."
82
104
  argument :since, GraphQL::Types::ISO8601DateTime, required: false, description: "Count entries created at or after this time."
105
+ argument :for_tenant, String, required: false,
106
+ description: "Scope to a specific tenant ID. Overrides auto-tenant when RailsAuditLog.current_tenant is configured."
83
107
  end
84
108
  end
85
109
 
110
+ # Resolves the +auditLogEntry+ field.
111
+ #
112
+ # @param id [String] the entry ID
113
+ # @param for_tenant [String, nil] optional tenant scope
114
+ # @return [RailsAuditLog::AuditLogEntry, nil]
86
115
  def resolve_audit_log_entry(id:, for_tenant: nil)
87
116
  check_authentication!
88
117
  tenant_id = for_tenant || RailsAuditLog.current_tenant&.call
@@ -90,43 +119,62 @@ module RailsAuditLog
90
119
  base.find_by(id: id)
91
120
  end
92
121
 
93
- def resolve_audit_log_entries(event: nil, item_type: nil, item_id: nil, actor_id: nil, since: nil, until_time: nil, touching: nil, order_by: nil, for_tenant: nil, page: 1, per_page: 25)
122
+ # Resolves the +auditLogEntries+ field.
123
+ #
124
+ # @return [ActiveRecord::Relation]
125
+ def resolve_audit_log_entries(event: nil, item_type: nil, item_id: nil, actor_id: nil, actor_type: nil, since: nil, until_time: nil, touching: nil, order_by: nil, for_tenant: nil, page: 1, per_page: 25)
94
126
  check_authentication!
95
- scope = build_scope(event: event, item_type: item_type, item_id: item_id, actor_id: actor_id, since: since, until_time: until_time, touching: touching, order_by: order_by, for_tenant: for_tenant)
127
+ scope = build_scope(event: event, item_type: item_type, item_id: item_id, actor_id: actor_id, actor_type: actor_type, since: since, until_time: until_time, touching: touching, order_by: order_by, for_tenant: for_tenant)
96
128
  scope.limit(per_page).offset((page - 1) * per_page)
97
129
  end
98
130
 
99
- def resolve_audit_log_entries_connection(event: nil, item_type: nil, item_id: nil, actor_id: nil, since: nil, until_time: nil, touching: nil, order_by: nil, for_tenant: nil)
131
+ # Resolves the +auditLogEntriesConnection+ field.
132
+ #
133
+ # @return [ActiveRecord::Relation]
134
+ def resolve_audit_log_entries_connection(event: nil, item_type: nil, item_id: nil, actor_id: nil, actor_type: nil, since: nil, until_time: nil, touching: nil, order_by: nil, for_tenant: nil)
100
135
  check_authentication!
101
- build_scope(event: event, item_type: item_type, item_id: item_id, actor_id: actor_id, since: since, until_time: until_time, touching: touching, order_by: order_by, for_tenant: for_tenant)
136
+ build_scope(event: event, item_type: item_type, item_id: item_id, actor_id: actor_id, actor_type: actor_type, since: since, until_time: until_time, touching: touching, order_by: order_by, for_tenant: for_tenant)
102
137
  end
103
138
 
104
- def resolve_audit_log_reify(item_type:, item_id:, at:)
139
+ # Resolves the +auditLogReify+ field. Returns the reconstructed attribute
140
+ # hash for +item_type+/+item_id+ as of +at+, or +nil+ when no entry exists.
141
+ #
142
+ # @param item_type [String]
143
+ # @param item_id [String]
144
+ # @param at [Time]
145
+ # @param for_tenant [String, nil]
146
+ # @return [Hash, nil]
147
+ def resolve_audit_log_reify(item_type:, item_id:, at:, for_tenant: nil)
105
148
  check_authentication!
106
- entry = RailsAuditLog::AuditLogEntry
149
+ tenant_id = for_tenant || RailsAuditLog.current_tenant&.call
150
+ scope = RailsAuditLog::AuditLogEntry
107
151
  .where(item_type: item_type, item_id: item_id)
108
152
  .where("created_at <= ?", at)
109
- .order(created_at: :desc, id: :desc)
110
- .first
153
+ scope = scope.for_tenant(tenant_id) if tenant_id
154
+ entry = scope.order(created_at: :desc, id: :desc).first
111
155
  return nil if entry.nil? || entry.event == "destroy"
112
156
  to_attrs = (entry.object_changes || {}).transform_values { |v| v[1] }
113
157
  entry.object.present? ? entry.object.merge(to_attrs) : to_attrs
114
158
  end
115
159
 
116
- def resolve_audit_log_entries_count(event: nil, item_type: nil, since: nil)
160
+ # Resolves the +auditLogEntriesCount+ field.
161
+ #
162
+ # @return [Integer]
163
+ def resolve_audit_log_entries_count(event: nil, item_type: nil, actor_type: nil, since: nil, for_tenant: nil)
117
164
  check_authentication!
118
165
  scope = RailsAuditLog::AuditLogEntry.all
119
166
  scope = scope.where(event: event) if event
120
167
  scope = scope.where(item_type: item_type) if item_type
168
+ scope = scope.where(actor_type: actor_type) if actor_type
121
169
  scope = scope.where("created_at >= ?", since) if since
122
- tenant_id = RailsAuditLog.current_tenant&.call
170
+ tenant_id = for_tenant || RailsAuditLog.current_tenant&.call
123
171
  scope = scope.for_tenant(tenant_id) if tenant_id
124
172
  scope.count
125
173
  end
126
174
 
127
175
  private
128
176
 
129
- def build_scope(event: nil, item_type: nil, item_id: nil, actor_id: nil, since: nil, until_time: nil, touching: nil, order_by: nil, for_tenant: nil)
177
+ def build_scope(event: nil, item_type: nil, item_id: nil, actor_id: nil, actor_type: nil, since: nil, until_time: nil, touching: nil, order_by: nil, for_tenant: nil)
130
178
  sort_field = order_by&.field || :created_at
131
179
  sort_direction = order_by&.direction || :desc
132
180
  scope = RailsAuditLog::AuditLogEntry.order(sort_field => sort_direction)
@@ -134,6 +182,7 @@ module RailsAuditLog
134
182
  scope = scope.where(item_type: item_type) if item_type
135
183
  scope = scope.where(item_id: item_id) if item_id
136
184
  scope = scope.where(actor_id: actor_id) if actor_id
185
+ scope = scope.where(actor_type: actor_type) if actor_type
137
186
  scope = scope.where("created_at >= ?", since) if since
138
187
  scope = scope.where("created_at <= ?", until_time) if until_time
139
188
  if touching
@@ -2,7 +2,29 @@
2
2
 
3
3
  module RailsAuditLog
4
4
  module Graphql
5
+ # Schema-level plugin that applies query-protection limits and enables
6
+ # dataloader batching in one +include+.
7
+ #
8
+ # Include in your GraphQL schema class:
9
+ #
10
+ # class MySchema < GraphQL::Schema
11
+ # include RailsAuditLog::Graphql::SchemaPlugin
12
+ # query Types::QueryType
13
+ # end
14
+ #
15
+ # This applies the following defaults (all overridable via
16
+ # {RailsAuditLog::Graphql} class-level accessors):
17
+ #
18
+ # | Setting | Default | Description |
19
+ # |------------------------|---------|--------------------------------------------------|
20
+ # | +max_complexity+ | 200 | Reject queries whose complexity exceeds this |
21
+ # | +max_depth+ | 10 | Reject queries nested deeper than this |
22
+ # | +default_max_page_size+| 25 | Page-size assumption for connection complexity |
23
+ #
24
+ # Also enables +GraphQL::Dataloader+ for N+1-free batch loading of
25
+ # +actor.record+ and +auditedResource.record+ fields.
5
26
  module SchemaPlugin
27
+ # @api private
6
28
  def self.included(base)
7
29
  base.max_complexity(RailsAuditLog::Graphql.max_complexity)
8
30
  base.max_depth(RailsAuditLog::Graphql.max_depth)
@@ -3,11 +3,25 @@
3
3
  module RailsAuditLog
4
4
  module Graphql
5
5
  module Sources
6
+ # Dataloader source that batch-loads ActiveRecord records by class name and ID,
7
+ # returning their +attributes+ hash.
8
+ #
9
+ # Used internally by {Types::ActorType} and {Types::AuditedResourceType} to
10
+ # resolve the +record+ field without N+1 queries.
11
+ #
12
+ # @example Manual use in a custom resolver
13
+ # dataloader.with(RecordByIdSource, "User").load("42")
6
14
  class RecordByIdSource < GraphQL::Dataloader::Source
15
+ # @param class_name [String] the ActiveRecord model class name to load from
7
16
  def initialize(class_name)
8
17
  @class_name = class_name
9
18
  end
10
19
 
20
+ # Batch-loads records for the given IDs.
21
+ #
22
+ # @param ids [Array<String>] record IDs to load
23
+ # @return [Array<Hash, nil>] +attributes+ hash for each ID, or +nil+ when
24
+ # the record does not exist or the class cannot be constantized
11
25
  def fetch(ids)
12
26
  klass = @class_name.safe_constantize
13
27
  return ids.map { nil } unless klass
@@ -3,7 +3,26 @@
3
3
  module RailsAuditLog
4
4
  module Graphql
5
5
  module Subscriptions
6
+ # Mixin that adds the +auditLogEntryCreated+ subscription field to a host
7
+ # application's +SubscriptionType+.
8
+ #
9
+ # Include in your +SubscriptionType+:
10
+ #
11
+ # class Types::SubscriptionType < Types::BaseObject
12
+ # include RailsAuditLog::Graphql::Subscriptions::AuditLogSubscriptionsMixin
13
+ # end
14
+ #
15
+ # The schema must also use +GraphQL::Subscriptions::ActionCableSubscriptions+:
16
+ #
17
+ # class MySchema < GraphQL::Schema
18
+ # subscription Types::SubscriptionType
19
+ # use GraphQL::Subscriptions::ActionCableSubscriptions
20
+ # end
21
+ #
22
+ # @see AuditLogEntryCreated
23
+ # @see Broadcaster
6
24
  module AuditLogSubscriptionsMixin
25
+ # @api private
7
26
  def self.included(base)
8
27
  base.field(
9
28
  :audit_log_entry_created,
@@ -3,25 +3,56 @@
3
3
  module RailsAuditLog
4
4
  module Graphql
5
5
  module Subscriptions
6
+ # Bridges +ActiveSupport::Notifications+ events emitted by
7
+ # +rails_audit_log+ to GraphQL subscription triggers.
8
+ #
9
+ # Start it once in an initializer after the schema is defined:
10
+ #
11
+ # Rails.application.config.after_initialize do
12
+ # RailsAuditLog::Graphql::Subscriptions::Broadcaster.new(schema: MySchema).start
13
+ # end
14
+ #
15
+ # For each +rails_audit_log.entry_created+ notification the broadcaster
16
+ # fires two subscription triggers:
17
+ # - +auditLogEntryCreated(itemType:, itemId:)+ for record-specific subscribers
18
+ # - +auditLogEntryCreated(actorId:)+ for actor-specific subscribers (when an
19
+ # actor is present on the entry)
6
20
  class Broadcaster
21
+ # @api private
7
22
  EVENT = "rails_audit_log.entry_created"
8
23
 
24
+ # @param schema [Class] the GraphQL schema class (must use
25
+ # +GraphQL::Subscriptions::ActionCableSubscriptions+)
9
26
  def initialize(schema:)
10
27
  @schema = schema
11
28
  @subscriber = nil
12
29
  end
13
30
 
31
+ # Subscribe to +rails_audit_log.entry_created+ notifications and begin
32
+ # broadcasting to GraphQL subscribers. Idempotent — calling +start+ a
33
+ # second time replaces the previous subscriber.
34
+ #
35
+ # @return [void]
14
36
  def start
15
37
  @subscriber = ActiveSupport::Notifications.subscribe(EVENT) do |*, payload|
16
38
  broadcast(payload[:entry])
17
39
  end
18
40
  end
19
41
 
42
+ # Unsubscribe from +ActiveSupport::Notifications+. After calling +stop+,
43
+ # no further subscription triggers will fire until {#start} is called again.
44
+ #
45
+ # @return [void]
20
46
  def stop
21
47
  ActiveSupport::Notifications.unsubscribe(@subscriber) if @subscriber
22
48
  @subscriber = nil
23
49
  end
24
50
 
51
+ # Trigger GraphQL subscriptions for +entry+. Fires both the
52
+ # record-scoped and actor-scoped variants.
53
+ #
54
+ # @param entry [RailsAuditLog::AuditLogEntry] the newly created entry
55
+ # @return [void]
25
56
  def broadcast(entry)
26
57
  @schema.subscriptions.trigger(
27
58
  "audit_log_entry_created",
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAuditLog
4
+ module Graphql
5
+ module Testing
6
+ # Minitest assertions for GraphQL audit log entries.
7
+ #
8
+ # Include this module in a test class or in a shared support file:
9
+ #
10
+ # class ActiveSupport::TestCase
11
+ # include RailsAuditLog::Graphql::Testing::MinitestAssertions
12
+ # end
13
+ #
14
+ # The assertions inspect the +auditLogEntries+ and +auditLogEntriesConnection.nodes+
15
+ # keys in the response data. To use the +touching:+ option, include +diff { attribute }+
16
+ # in your GraphQL query.
17
+ module MinitestAssertions
18
+ # Asserts that the GraphQL response contains at least one audit log entry
19
+ # matching the given criteria.
20
+ #
21
+ # @param response [Hash, GraphQL::Query::Result] the result of +Schema.execute+
22
+ # @param event [Symbol, String] expected event type (:create, :update, :destroy)
23
+ # @param touching [Symbol, String, nil] when given, requires the entry's +diff+ to
24
+ # include an attribute with this name (query must include +diff { attribute }+)
25
+ # @param item_type [String, nil] when given, requires the entry's +itemType+ to match
26
+ # @param message [String, nil] custom failure message
27
+ #
28
+ # @example Assert an update entry exists
29
+ # result = MySchema.execute('{ auditLogEntries { event } }')
30
+ # assert_graphql_audit_entry result, event: :update
31
+ #
32
+ # @example Assert an update entry that touched :title
33
+ # result = MySchema.execute('{ auditLogEntries { event diff { attribute } } }')
34
+ # assert_graphql_audit_entry result, event: :update, touching: :title
35
+ def assert_graphql_audit_entry(response, event:, touching: nil, item_type: nil, message: nil)
36
+ matched = filter_entries(response, event: event, touching: touching, item_type: item_type)
37
+ default_msg = build_message("Expected", event, touching, item_type)
38
+ assert matched.any?, message || default_msg
39
+ end
40
+
41
+ # Asserts that the GraphQL response does NOT contain an audit log entry
42
+ # matching the given criteria.
43
+ #
44
+ # @param response [Hash, GraphQL::Query::Result] the result of +Schema.execute+
45
+ # @param event [Symbol, String] the event type to check
46
+ # @param touching [Symbol, String, nil] attribute name to match against +diff+
47
+ # @param item_type [String, nil] model class name to match against +itemType+
48
+ # @param message [String, nil] custom failure message
49
+ def refute_graphql_audit_entry(response, event:, touching: nil, item_type: nil, message: nil)
50
+ matched = filter_entries(response, event: event, touching: touching, item_type: item_type)
51
+ default_msg = build_message("Expected no", event, touching, item_type)
52
+ refute matched.any?, message || default_msg
53
+ end
54
+
55
+ private
56
+
57
+ def filter_entries(response, event:, touching:, item_type:)
58
+ entries = extract_graphql_entries(response)
59
+ matched = entries.select { |e| e["event"] == event.to_s }
60
+ matched = matched.select { |e| e["itemType"] == item_type.to_s } if item_type
61
+ if touching
62
+ matched = matched.select { |e| e["diff"]&.any? { |d| d["attribute"] == touching.to_s } }
63
+ end
64
+ matched
65
+ end
66
+
67
+ def build_message(prefix, event, touching, item_type)
68
+ msg = "#{prefix} GraphQL audit entry with event #{event.inspect}"
69
+ msg += " touching #{touching.inspect}" if touching
70
+ msg += " for type #{item_type.inspect}" if item_type
71
+ msg
72
+ end
73
+
74
+ def extract_graphql_entries(response)
75
+ data = response.respond_to?(:[]) ? (response["data"] || response[:data]) : nil
76
+ return [] unless data
77
+
78
+ entries = []
79
+ entries += Array(data["auditLogEntries"])
80
+ entries += Array(data.dig("auditLogEntriesConnection", "nodes"))
81
+ entries += Array(data.dig("auditLogEntriesConnection", "edges")).filter_map { |e| e["node"] }
82
+ entries
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAuditLog
4
+ module Graphql
5
+ module Testing
6
+ # RSpec matchers for asserting GraphQL audit log entries in a query response.
7
+ #
8
+ # Include this module in your RSpec configuration to use the {#have_graphql_audit_entry}
9
+ # matcher:
10
+ #
11
+ # RSpec.configure do |config|
12
+ # config.include RailsAuditLog::Graphql::Testing::RSpecMatchers
13
+ # end
14
+ #
15
+ # Or include it in a single example group:
16
+ #
17
+ # RSpec.describe "audit logging" do
18
+ # include RailsAuditLog::Graphql::Testing::RSpecMatchers
19
+ # end
20
+ #
21
+ # The matcher inspects the +auditLogEntries+ and +auditLogEntriesConnection.nodes+
22
+ # keys in the response data. To use the +touching+ chain, include +diff { attribute }+
23
+ # in your GraphQL query.
24
+ module RSpecMatchers
25
+ # Returns a matcher that asserts a GraphQL response contains an audit log
26
+ # entry with the given event type.
27
+ #
28
+ # @param event [Symbol, String] expected event type (:create, :update, :destroy)
29
+ # @return [HaveGraphqlAuditEntry]
30
+ #
31
+ # @example Assert an update entry exists
32
+ # result = MySchema.execute('{ auditLogEntries { event } }')
33
+ # expect(result).to have_graphql_audit_entry(:update)
34
+ #
35
+ # @example Assert an update entry that touched :title
36
+ # result = MySchema.execute('{ auditLogEntries { event diff { attribute } } }')
37
+ # expect(result).to have_graphql_audit_entry(:update).touching(:title)
38
+ #
39
+ # @example Assert a create entry for a specific model
40
+ # expect(result).to have_graphql_audit_entry(:create).for_type("Post")
41
+ def have_graphql_audit_entry(event)
42
+ HaveGraphqlAuditEntry.new(event.to_s)
43
+ end
44
+
45
+ # @api private
46
+ class HaveGraphqlAuditEntry
47
+ # @param event [String] the required event value
48
+ def initialize(event)
49
+ @event = event
50
+ @touching = nil
51
+ @item_type = nil
52
+ end
53
+
54
+ # Requires at least one matching entry's +diff+ to contain +attribute+.
55
+ # The query must include +diff { attribute }+ for this chain to work.
56
+ #
57
+ # @param attribute [Symbol, String] the changed attribute name
58
+ # @return [self]
59
+ def touching(attribute)
60
+ @touching = attribute.to_s
61
+ self
62
+ end
63
+
64
+ # Requires all matching entries to have +itemType+ equal to +item_type+.
65
+ #
66
+ # @param item_type [String] the ActiveRecord model class name
67
+ # @return [self]
68
+ def for_type(item_type)
69
+ @item_type = item_type.to_s
70
+ self
71
+ end
72
+
73
+ # @api private
74
+ def matches?(response)
75
+ @response = response
76
+ matched = extract_entries(response).select { |e| e["event"] == @event }
77
+ matched = matched.select { |e| e["itemType"] == @item_type } if @item_type
78
+ if @touching
79
+ matched = matched.select { |e| e["diff"]&.any? { |d| d["attribute"] == @touching } }
80
+ end
81
+ matched.any?
82
+ end
83
+
84
+ # @api private
85
+ def failure_message
86
+ "expected GraphQL response to include an audit entry with event #{@event.inspect}" \
87
+ "#{touching_clause}#{type_clause}, but it did not.\n" \
88
+ "Entries found: #{extract_entries(@response).map { |e| e["event"] }.inspect}"
89
+ end
90
+
91
+ # @api private
92
+ def failure_message_when_negated
93
+ "expected GraphQL response not to include an audit entry with event #{@event.inspect}" \
94
+ "#{touching_clause}#{type_clause}, but one was found."
95
+ end
96
+
97
+ private
98
+
99
+ def touching_clause
100
+ @touching ? " touching #{@touching.inspect}" : ""
101
+ end
102
+
103
+ def type_clause
104
+ @item_type ? " for type #{@item_type.inspect}" : ""
105
+ end
106
+
107
+ def extract_entries(response)
108
+ data = response.respond_to?(:[]) ? (response["data"] || response[:data]) : nil
109
+ return [] unless data
110
+
111
+ entries = []
112
+ entries += Array(data["auditLogEntries"])
113
+ entries += Array(data.dig("auditLogEntriesConnection", "nodes"))
114
+ entries += Array(data.dig("auditLogEntriesConnection", "edges")).filter_map { |e| e["node"] }
115
+ entries
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "testing/rspec_matchers"
4
+ require_relative "testing/minitest_assertions"
5
+
6
+ module RailsAuditLog
7
+ module Graphql
8
+ # Test helpers for asserting GraphQL audit log entries in query responses.
9
+ #
10
+ # Require this file from your test helper to load both RSpec matchers and
11
+ # Minitest assertions:
12
+ #
13
+ # # spec/spec_helper.rb or test/test_helper.rb
14
+ # require "rails_audit_log/graphql/testing"
15
+ #
16
+ # Then include the appropriate module for your test framework:
17
+ #
18
+ # # RSpec
19
+ # RSpec.configure do |config|
20
+ # config.include RailsAuditLog::Graphql::Testing::RSpecMatchers
21
+ # end
22
+ #
23
+ # # Minitest
24
+ # class ActiveSupport::TestCase
25
+ # include RailsAuditLog::Graphql::Testing::MinitestAssertions
26
+ # end
27
+ module Testing
28
+ end
29
+ end
30
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RailsAuditLog
4
4
  module Graphql
5
- VERSION = "0.6.0"
5
+ VERSION = "1.0.0"
6
6
  end
7
7
  end
@@ -19,7 +19,24 @@ require_relative "graphql/subscriptions/audit_log_subscriptions_mixin"
19
19
  require_relative "graphql/subscriptions/broadcaster"
20
20
  require_relative "graphql/schema_plugin"
21
21
 
22
+ # The top-level namespace for the rails_audit_log gem.
22
23
  module RailsAuditLog
24
+ # GraphQL API layer for rails_audit_log.
25
+ #
26
+ # Provides ready-made types, queries, subscriptions, and test helpers for
27
+ # exposing {https://github.com/eclectic-coding/rails_audit_log rails_audit_log}
28
+ # audit log entries through a graphql-ruby schema.
29
+ #
30
+ # == Configuration
31
+ #
32
+ # Override query-protection defaults in an initializer:
33
+ #
34
+ # RailsAuditLog::Graphql.max_complexity = 500
35
+ # RailsAuditLog::Graphql.max_depth = 15
36
+ # RailsAuditLog::Graphql.default_max_page_size = 50
37
+ #
38
+ # These values are picked up by {SchemaPlugin} when it is included in the
39
+ # host schema.
23
40
  module Graphql
24
41
  class Error < StandardError; end
25
42
 
@@ -28,7 +45,23 @@ module RailsAuditLog
28
45
  @default_max_page_size = 25
29
46
 
30
47
  class << self
31
- attr_accessor :max_complexity, :max_depth, :default_max_page_size
48
+ # Maximum allowed query complexity score.
49
+ # Queries whose field-complexity sum exceeds this value are rejected.
50
+ # Default: +200+.
51
+ # @return [Integer]
52
+ attr_accessor :max_complexity
53
+
54
+ # Maximum allowed query depth.
55
+ # Queries nested deeper than this value are rejected.
56
+ # Default: +10+.
57
+ # @return [Integer]
58
+ attr_accessor :max_depth
59
+
60
+ # Default maximum page size for Relay connections.
61
+ # Used by graphql-ruby when calculating connection complexity.
62
+ # Default: +25+.
63
+ # @return [Integer]
64
+ attr_accessor :default_max_page_size
32
65
  end
33
66
  end
34
67
  end
@@ -112,6 +112,7 @@ module RailsAuditLog
112
112
  ?item_type: String?,
113
113
  ?item_id: String?,
114
114
  ?actor_id: String?,
115
+ ?actor_type: String?,
115
116
  ?since: Time?,
116
117
  ?until_time: Time?,
117
118
  ?touching: String?,
@@ -126,6 +127,7 @@ module RailsAuditLog
126
127
  ?item_type: String?,
127
128
  ?item_id: String?,
128
129
  ?actor_id: String?,
130
+ ?actor_type: String?,
129
131
  ?since: Time?,
130
132
  ?until_time: Time?,
131
133
  ?touching: String?,
@@ -136,13 +138,16 @@ module RailsAuditLog
136
138
  def resolve_audit_log_reify: (
137
139
  item_type: String,
138
140
  item_id: String,
139
- at: Time
141
+ at: Time,
142
+ ?for_tenant: String?
140
143
  ) -> Hash[String, untyped]?
141
144
 
142
145
  def resolve_audit_log_entries_count: (
143
146
  ?event: String?,
144
147
  ?item_type: String?,
145
- ?since: Time?
148
+ ?actor_type: String?,
149
+ ?since: Time?,
150
+ ?for_tenant: String?
146
151
  ) -> Integer
147
152
 
148
153
  private
@@ -152,6 +157,7 @@ module RailsAuditLog
152
157
  ?item_type: String?,
153
158
  ?item_id: String?,
154
159
  ?actor_id: String?,
160
+ ?actor_type: String?,
155
161
  ?since: Time?,
156
162
  ?until_time: Time?,
157
163
  ?touching: String?,
@@ -162,5 +168,38 @@ module RailsAuditLog
162
168
  def check_authentication!: () -> void
163
169
  end
164
170
  end
171
+
172
+ module Testing
173
+ module RSpecMatchers
174
+ def have_graphql_audit_entry: (Symbol | String event) -> HaveGraphqlAuditEntry
175
+
176
+ class HaveGraphqlAuditEntry
177
+ def initialize: (String event) -> void
178
+ def touching: (Symbol | String attribute) -> self
179
+ def for_type: (String item_type) -> self
180
+ def matches?: (untyped response) -> bool
181
+ def failure_message: () -> String
182
+ def failure_message_when_negated: () -> String
183
+ end
184
+ end
185
+
186
+ module MinitestAssertions
187
+ def assert_graphql_audit_entry: (
188
+ untyped response,
189
+ event: Symbol | String,
190
+ ?touching: (Symbol | String)?,
191
+ ?item_type: String?,
192
+ ?message: String?
193
+ ) -> void
194
+
195
+ def refute_graphql_audit_entry: (
196
+ untyped response,
197
+ event: Symbol | String,
198
+ ?touching: (Symbol | String)?,
199
+ ?item_type: String?,
200
+ ?message: String?
201
+ ) -> void
202
+ end
203
+ end
165
204
  end
166
205
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_audit_log-graphql
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -91,6 +91,9 @@ files:
91
91
  - lib/rails_audit_log/graphql/subscriptions/audit_log_entry_created.rb
92
92
  - lib/rails_audit_log/graphql/subscriptions/audit_log_subscriptions_mixin.rb
93
93
  - lib/rails_audit_log/graphql/subscriptions/broadcaster.rb
94
+ - lib/rails_audit_log/graphql/testing.rb
95
+ - lib/rails_audit_log/graphql/testing/minitest_assertions.rb
96
+ - lib/rails_audit_log/graphql/testing/rspec_matchers.rb
94
97
  - lib/rails_audit_log/graphql/types/actor_type.rb
95
98
  - lib/rails_audit_log/graphql/types/audit_log_entry_sort_field_enum.rb
96
99
  - lib/rails_audit_log/graphql/types/audit_log_entry_type.rb