graphiti 2.0.0.beta.5 → 2.0.0.beta.6

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: cdd882c646fa88d0ac6522b7725ad6132b7ad890043822f5e2a2ce8af3f0fc4c
4
- data.tar.gz: 687c64e9c5e6d2931a09bde9f5595b965e36f1a090d863fa17b9edf11559533f
3
+ metadata.gz: e108e5f71103c3fb7f38f5328a890ff131b264201c583509081cfd3106a7b255
4
+ data.tar.gz: 18edd07b0ab429ec89309ec94164a2324a21bbbfbd6dd37f6e49bb6f9707f1c9
5
5
  SHA512:
6
- metadata.gz: 1907226aa19ad065b0affe66f5ea22bb7149fd11aef92d225d7b433a5c67111bde2d7ef5911e633c5ea9059559ff903654c21f835099b5e15e72fdf7ab59cc9b
7
- data.tar.gz: 91fd8e83a1452d29ffb45076b94a9eeb316c4e508a2f6565c5e079e6c54a184eede79da45ecb9d25f340f395551d907e10eb152e05666387792c434ed5fb4361
6
+ metadata.gz: f2affdd800c2966ef15c5d1765b9d1acc48a032c3ee143b9283d69b9545290e227454d77559e25c94b4cd7b0131d0cdc2addbb031a4177155d8085086d818b3b
7
+ data.tar.gz: 79c294b775b8be269194cae7e61cf179a16ec8dd01e8b0b24e7a141d3333683bfbd54032e4c07623f6d2dc3fbe3ce6c721884658da89cbe24bfd9dac935f8e07
@@ -41,25 +41,30 @@ jobs:
41
41
  - { ruby: "3.2", gemfile: Gemfile, appraisal: false }
42
42
  - { ruby: "3.3", gemfile: Gemfile, appraisal: false }
43
43
  - { ruby: "3.4", gemfile: Gemfile, appraisal: false }
44
+ - { ruby: "4.0", gemfile: Gemfile, appraisal: false }
44
45
  - { ruby: ruby-head, gemfile: Gemfile, appraisal: false }
45
46
 
46
47
  # Rails 7.1 - the floor
47
48
  - { ruby: "3.2", gemfile: gemfiles/rails_7_1.gemfile, appraisal: true }
48
49
  - { ruby: "3.4", gemfile: gemfiles/rails_7_1.gemfile, appraisal: true }
50
+ - { ruby: "4.0", gemfile: gemfiles/rails_7_1.gemfile, appraisal: true }
49
51
 
50
52
  # Rails 7.2
51
53
  - { ruby: "3.2", gemfile: gemfiles/rails_7_2.gemfile, appraisal: true }
52
54
  - { ruby: "3.4", gemfile: gemfiles/rails_7_2.gemfile, appraisal: true }
55
+ - { ruby: "4.0", gemfile: gemfiles/rails_7_2.gemfile, appraisal: true }
53
56
  - { ruby: ruby-head, gemfile: gemfiles/rails_7_2.gemfile, appraisal: true }
54
57
 
55
58
  # Rails 8.0
56
59
  - { ruby: "3.2", gemfile: gemfiles/rails_8_0.gemfile, appraisal: true }
57
60
  - { ruby: "3.4", gemfile: gemfiles/rails_8_0.gemfile, appraisal: true }
61
+ - { ruby: "4.0", gemfile: gemfiles/rails_8_0.gemfile, appraisal: true }
58
62
  - { ruby: ruby-head, gemfile: gemfiles/rails_8_0.gemfile, appraisal: true }
59
63
 
60
64
  # Rails 8.1
61
65
  - { ruby: "3.2", gemfile: gemfiles/rails_8_1.gemfile, appraisal: true }
62
66
  - { ruby: "3.4", gemfile: gemfiles/rails_8_1.gemfile, appraisal: true }
67
+ - { ruby: "4.0", gemfile: gemfiles/rails_8_1.gemfile, appraisal: true }
63
68
  - { ruby: ruby-head, gemfile: gemfiles/rails_8_1.gemfile, appraisal: true }
64
69
 
65
70
  continue-on-error: ${{ matrix.ruby == 'ruby-head' }}
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  graphiti changelog
2
2
 
3
+ # [2.0.0-beta.6](https://github.com/graphiti-api/graphiti/compare/v2.0.0-beta.5...v2.0.0-beta.6) (2026-08-09)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * reject invalid page parameters ([#537](https://github.com/graphiti-api/graphiti/issues/537)) ([bb3698b](https://github.com/graphiti-api/graphiti/commit/bb3698b52911c658c5d023ad8fd55b5106195001)), closes [#347](https://github.com/graphiti-api/graphiti/issues/347)
9
+ * treat empty polymorphic configuration as unset ([#538](https://github.com/graphiti-api/graphiti/issues/538)) ([5c17899](https://github.com/graphiti-api/graphiti/commit/5c1789993fbb4638d4443cf014b8bef358addb80)), closes [#199](https://github.com/graphiti-api/graphiti/issues/199)
10
+
11
+
12
+ ### Features
13
+
14
+ * Add graphiti:audit task to audit resources for issues ([84436ea](https://github.com/graphiti-api/graphiti/commit/84436eab7c7d0713ec6f355b62ba8b06913ee31a))
15
+ * raise MissingRelationshipMethod when rendering reads an association the model does not define ([1c41cee](https://github.com/graphiti-api/graphiti/commit/1c41cee787b4f747d2906b2230f8fb926e3d887d))
16
+ * rename always_include_resource_ids to resource_ids, with a belongs_to_resource_ids_by_default setting ([8b11151](https://github.com/graphiti-api/graphiti/commit/8b111516822f6fc6e3675b59941688791cb34e74))
17
+
3
18
  # [2.0.0-beta.5](https://github.com/graphiti-api/graphiti/compare/v2.0.0-beta.4...v2.0.0-beta.5) (2026-08-07)
4
19
 
5
20
 
@@ -70,10 +70,20 @@ has_many :positions,
70
70
  writable: true,
71
71
  link: self.autolink, # default true
72
72
  single: false, # only allow this sideload when one employee
73
- always_include_resource_ids: true # default: true for belongs_to, false otherwise
73
+ resource_ids: false
74
74
  ```
75
75
 
76
- `belongs_to` renders resource linkage by default, so a client can see which record a relationship points at without following the link:
76
+ `resource_ids` is the one whose default depends on the relationship type:
77
+
78
+ | type | renders resource ids by default |
79
+ |---|---|
80
+ | `belongs_to` | yes, when its foreign key already holds the related id |
81
+ | `has_one` | no |
82
+ | `has_many` | no |
83
+ | `many_to_many` | no |
84
+ | `polymorphic_belongs_to` | no |
85
+
86
+ `belongs_to` renders them so a client can see which record a relationship points at without following the link:
77
87
 
78
88
  ```json
79
89
  "employee": {
@@ -82,19 +92,109 @@ has_many :positions,
82
92
  }
83
93
  ```
84
94
 
85
- That costs nothing, because the id is already on the parent as its foreign key. A `has_many` would have to run a query per record to answer the same question, so it stays off unless you ask for it.
95
+ That costs nothing, because the id is already on the parent as its foreign key.
96
+
97
+ No other relationship type has a free source for its ids. A collection accepts `resource_ids: true`, but that reads the association on every render of every parent record, whether or not the request wants the relationship. That is the N+1 from [#167](https://github.com/graphiti-api/graphiti/issues/167#issuecomment-686866646) on every response. Leave collections off and let clients `?include=` them.
86
98
 
87
- A `belongs_to` falls back to loading the association when the foreign key cannot answer for it: a `params` block or `base_scope` can filter out the record the key points at, a polymorphic target takes its type from the record rather than the relationship, and a remote resource has no local key to read. Turning linkage on for those, or for a `has_many`, brings back the 1+N described in [#167](https://github.com/graphiti-api/graphiti/issues/167#issuecomment-686866646).
99
+ Not every `belongs_to` can use its foreign key. A `scope` or `params` block or a `base_scope` can filter out the record the key points at, a polymorphic target's type varies per record while rendered ids carry one type for the whole relationship, a remote resource has no local key to read, and a custom `primary_key` points the relationship at some other column. Those load the association instead, so they stay off by default too.
88
100
 
89
- Set the default for every relationship on a resource, whatever its type:
101
+ <details>
102
+ <summary>Which `belongs_to` declarations render resource ids, and which do not</summary>
103
+
104
+ ```ruby
105
+ # yes. employee_id is the employee's id, so the payload already has it
106
+ belongs_to :employee
107
+
108
+ # no. nothing renders at all, ids included
109
+ belongs_to :employee, readable: false
110
+
111
+ # no. employee_id holds a name, not the related id
112
+ belongs_to :employee, primary_key: :first_name
113
+
114
+ # no. the base scope can exclude the employee the key points at, and
115
+ # graphiti cannot know whether it does without running it
116
+ belongs_to :employee, base_scope: -> { Employee.all }
117
+
118
+ # no. a remote resource has no local foreign key to read
119
+ belongs_to :employee, remote: "http://foo.com/employees"
120
+
121
+ # no. the record's own class decides its type, so the key gives an id
122
+ # with no type to pair it with
123
+ belongs_to :employee, resource: CreditCardResource
124
+
125
+ # no. the scope can exclude the employee the key points at, and graphiti
126
+ # cannot know whether it does without running it
127
+ belongs_to :employee do
128
+ scope { |ids| {type: :employees, conditions: {id: ids}} }
129
+ end
130
+
131
+ # no. same, a params filter can exclude the employee the key points at
132
+ belongs_to :employee do
133
+ params { |hash, positions| hash[:filter][:active] = true }
134
+ end
135
+
136
+ # no. credit_card_type is local, but rendered ids carry one type for the
137
+ # whole relationship and this one's varies per record
138
+ polymorphic_belongs_to :credit_card do
139
+ group_by(:credit_card_type) do
140
+ on(:Visa).belongs_to :visa, resource: VisaResource
141
+ end
142
+ end
143
+ ```
144
+
145
+ Watch for the `scope`, `params` and `base_scope` cases. Nothing about those declarations looks like it concerns resource ids, so adding a scope block to filter a relationship also stops its ids from rendering.
146
+
147
+ If you keep a `schema.json`, the schema check catches that. A relationship that renders resource ids is marked `linkage: true`, and one that stops rendering them is reported as a breaking change. Gaining them is additive and passes.
148
+
149
+ To render ids anyway, opt in on the relationship and accept the query:
150
+
151
+ ```ruby
152
+ belongs_to :employee, resource_ids: true do
153
+ scope { |ids| {type: :employees, conditions: {id: ids}} }
154
+ end
155
+ ```
156
+
157
+ Know what that buys for the `scope`, `params` and `base_scope` cases. Rendering reads the association off the model, which does not apply the block, so if the block narrows what sideloading returns, the ids will disagree with it. Opting in there says you know the two agree. A `primary_key`, polymorphic or remote relationship does resolve to the right id this way.
158
+
159
+ </details>
160
+
161
+ ### belongs_to_resource_ids_by_default {#belongs-to-resource-ids}
162
+
163
+ To change how far a `belongs_to` goes, across a whole API, set it on the resource everything inherits from:
90
164
 
91
165
  ```ruby
92
166
  class ApplicationResource < Graphiti::Resource
93
- self.always_include_resource_ids_by_default = false
167
+ self.belongs_to_resource_ids_by_default = :foreign_key
94
168
  end
95
169
  ```
96
170
 
97
- Subclasses inherit it, and a relationship passing `always_include_resource_ids` explicitly still wins.
171
+ | | |
172
+ |---|---|
173
+ | `:foreign_key` | Default. Render resource ids wherever the foreign key already holds the related id, and never run an extra query. |
174
+ | `:always` | Render them for every `belongs_to`, loading the association when the foreign key cannot answer. A query per record, per relationship, on every render. |
175
+ | `:never` | Render none. This is the 1.x payload. |
176
+
177
+ Subclasses inherit it, and a relationship passing `resource_ids` explicitly still wins.
178
+
179
+ All three describe requests that do not include the relationship. A relationship the request does include renders its ids whatever this is set to, `:never` included, because the records are already loaded and sitting in `included`.
180
+
181
+ Before flipping the setting, [`bin/rake graphiti:audit`](/topics/debugging#graphiti-audit) reports how every relationship renders resource ids today and which would start loading the association.
182
+
183
+ #### What a client sees {#relationship-payload-shapes}
184
+
185
+ A client never has to work out which rule applied. The relationship object says what it knows:
186
+
187
+ ```json
188
+ "employee": { "data": { "type": "employees", "id": "1" } } // here is the id
189
+ "employee": { "links": { "related": "..." } } // fetch it yourself
190
+ "employee": { "meta": { "included": false } } // neither
191
+ ```
192
+
193
+ The last shape appears only when a relationship has no ids **and** no link, which usually means `link: false`. It is not a general "was this sideloaded" flag. Relationships are autolinked by default, so the link shape is the one you normally see.
194
+
195
+ The setting covers `belongs_to` and `polymorphic_belongs_to`, and no collection, deliberately. An API-wide `:always` on collections would be the N+1 from [#167](https://github.com/graphiti-api/graphiti/issues/167#issuecomment-686866646) applied everywhere at once.
196
+
197
+ `:always` renders ids by loading the association, so a relationship naming a method the model does not have raises on every render once you set it.
98
198
 
99
199
  ### Conditional Relationships {#conditional-relationships}
100
200
 
@@ -221,7 +321,7 @@ Defaults to these common options:
221
321
  has_many :positions,
222
322
  foreign_key: :employee_id,
223
323
  primary_key: :id,
224
- always_include_resource_ids: false,
324
+ resource_ids: false,
225
325
  resource: PositionResource
226
326
  ```
227
327
 
@@ -265,7 +365,7 @@ Defaults to these common options:
265
365
  belongs_to :employee,
266
366
  foreign_key: :employee_id,
267
367
  primary_key: :id,
268
- always_include_resource_ids: false,
368
+ resource_ids: true,
269
369
  resource: EmployeeResource
270
370
  ```
271
371
 
@@ -172,6 +172,32 @@ Which add Debugger output as well.
172
172
  The `PATH` should not contain the domain unless you want to hit a live
173
173
  API instead of a test server.
174
174
 
175
+ #### graphiti:audit {#graphiti-audit}
176
+
177
+ > `bin/rake graphiti:audit`
178
+
179
+ Audits every relationship declared across your resources. It reports anything that will raise at request time, relationships that load an association just to render resource ids, and `belongs_to` relationships that render no ids unless included. A checklist at the end shows what was checked:
180
+
181
+ ```
182
+ ERROR will raise when the relationship is included: the model has no association method
183
+
184
+ EmployeeResource
185
+ has_many :positions Employee has no #positions method
186
+
187
+ fix: define it, point the relationship at the real association with `as:`, or remove the relationship
188
+
189
+ checks
190
+
191
+ ✓ all relationships inspectable
192
+ ✗ 1 association method missing
193
+ ✓ all readable guards defined
194
+ ✓ all sideload filters declared
195
+
196
+ graphiti: 12 resources, 40 relationships, 1 error.
197
+ ```
198
+
199
+ The task exits nonzero when there are errors, so it can hold the line in CI. Run it before and after flipping [belongs_to_resource_ids_by_default](/concepts/relationships#belongs-to-resource-ids) to see exactly what the setting changes.
200
+
175
201
  #### graphiti:benchmark {#graphiti-benchmark}
176
202
 
177
203
  > `bin/rake graphiti:benchmark[PATH,NUM_REQUESTS]`
data/docs/upgrading.md CHANGED
@@ -128,9 +128,9 @@ It has to be a request spec. Exceptions are rendered in Rack middleware, which c
128
128
  Nothing to do here. These change what a client receives or when a callback runs, and none of them warns you, because none of them is a rename.
129
129
 
130
130
  <details>
131
- <summary>A `belongs_to` renders resource linkage in every payload, where 1.x sent only a link</summary>
131
+ <summary>A `belongs_to` renders resource ids when its foreign key already holds them, where 1.x sent only a link</summary>
132
132
 
133
- A `belongs_to` now includes resource linkage in the payload by default, where 1.x sent only a link:
133
+ A `belongs_to` now renders resource ids in the payload by default, where 1.x sent only a link:
134
134
 
135
135
  ```json
136
136
  "employee": { "data": { "type": "employees", "id": "1" }, "links": { "related": "..." } }
@@ -138,27 +138,29 @@ A `belongs_to` now includes resource linkage in the payload by default, where 1.
138
138
 
139
139
  The id comes from the foreign key already on the parent, so this costs no extra queries, and clients can resolve the relationship against data they already hold instead of following the link. `has_many` is unchanged, since answering there means a query per record.
140
140
 
141
- Not every `belongs_to` qualifies. A `scope` or `params` block, a `base_scope`, a polymorphic or remote target or a custom `primary_key` all mean the foreign key is not the related id, and those keep loading the association, so they stay opt-in as in 1.x.
141
+ Not every `belongs_to` qualifies. A remote target or a custom `primary_key` mean the foreign key is not the related id, a polymorphic target means one rendered type cannot cover every record, and a `scope` or `params` block or a `base_scope` mean the key might not survive the filter. Rendering ids for those means loading the association, so they stay opt-in as in 1.x and render nothing until you ask.
142
142
 
143
- Relationships are marked `linkage: true` in `schema.json`, and the schema check reports a relationship that stops including it.
143
+ Run [`bin/rake graphiti:audit`](/topics/debugging#graphiti-audit) to see where your API stands: it lists every relationship that renders no ids, and why.
144
144
 
145
145
  To go back to the old payload for one relationship:
146
146
 
147
147
  ```ruby
148
- belongs_to :employee, always_include_resource_ids: false
148
+ belongs_to :employee, resource_ids: false
149
149
  ```
150
150
 
151
- Or for the whole API, on the resource everything inherits from. The same setting with `true` is the 2.0 replacement for the `Sideload::BelongsTo` monkey patch that [#167](https://github.com/graphiti-api/graphiti/issues/167) has been recommending, and it now covers every relationship type rather than only `belongs_to`:
151
+ Or for the whole API, on the resource everything inherits from:
152
152
 
153
153
  ```ruby
154
154
  class ApplicationResource < Graphiti::Resource
155
155
  self.abstract_class = true
156
156
 
157
- self.always_include_resource_ids_by_default = false
157
+ self.belongs_to_resource_ids_by_default = :never
158
158
  end
159
159
  ```
160
160
 
161
- How linkage is configured, and when a `belongs_to` cannot use its foreign key, is covered in [Customizing Relationships](/concepts/relationships#customizing-relationships).
161
+ If you carry the `Sideload::BelongsTo` monkey patch from [#167](https://github.com/graphiti-api/graphiti/issues/167), delete it and set nothing. The default now covers the safe cases on its own. To force ids onto the rest the way the patch did, set `self.belongs_to_resource_ids_by_default = :always`, at a query per record for each one.
162
+
163
+ The three settings, and when a `belongs_to` cannot use its foreign key, are covered in [Customizing Relationships](/concepts/relationships#belongs-to-resource-ids).
162
164
 
163
165
  </details>
164
166
 
@@ -224,6 +226,7 @@ Every name below still works, warns, and will be removed in the next major. They
224
226
  | `context_namespace` | `current_action` |
225
227
  | `Graphiti::Rails::DEPRECATOR` | `Graphiti::DEPRECATOR` (the old name still resolves) |
226
228
  | `require "graphiti_errors"`, `require "graphiti/responders"` | remove / no longer needed |
229
+ | `always_include_resource_ids: true` on a relationship | `resource_ids: true` |
227
230
 
228
231
  `RSpec.describe PostResource, type: :resource` still picks up the resource-testing context automatically. That has not changed.
229
232
 
@@ -234,6 +237,9 @@ Every name below still works, warns, and will be removed in the next major. They
234
237
  | `include GraphitiErrors` | `register_exception` is available on every controller |
235
238
  | `GraphitiErrors::ExceptionHandler` | subclass `Graphiti::Rails::ExceptionHandler` |
236
239
  | `GraphitiErrors.enable!` / `.disable!` | `handle_request_exceptions` |
240
+ | `self.always_include_resource_ids_by_default` | `self.belongs_to_resource_ids_by_default`, which takes `:foreign_key`, `:always` or `:never` |
241
+
242
+ `always_include_resource_ids_by_default` only ever shipped in `2.0.0.beta.4`, so it is gone rather than deprecated and raises `NoMethodError` at class-definition time. It applied to every relationship type, and only a `belongs_to` can render resource ids without loading an association, so the replacement covers `belongs_to` alone. `= false` becomes `:never`. There is no equivalent of `= true`, because arming every collection API-wide is the behavior it was removed for. Use `:always` for `belongs_to`.
237
243
 
238
244
  ## Without Rails {#without-rails}
239
245
 
data/graphiti.gemspec CHANGED
@@ -24,7 +24,7 @@ Gem::Specification.new do |spec|
24
24
  spec.add_dependency "rescue_registry", "~> 1.0"
25
25
  spec.add_dependency "concurrent-ruby", ">= 1.2", "< 2.0"
26
26
  spec.add_dependency "activesupport", ">= 7.1"
27
- # Bundled (no longer default) as of Ruby 3.5; graphiti uses OpenStruct in lib/
27
+ # Bundled (no longer default) as of Ruby 4.0; graphiti uses OpenStruct in lib/
28
28
  spec.add_dependency "ostruct", ">= 0.5"
29
29
 
30
30
  spec.add_development_dependency "faraday", "~> 0.15"
@@ -0,0 +1,226 @@
1
+ module Graphiti
2
+ class Audit
3
+ class Report
4
+ ANSI = {red: 31, green: 32, yellow: 33, blue: 34, magenta: 35, cyan: 36, bold: 1, dim: 2}.freeze
5
+
6
+ ISSUE_HEADINGS = {
7
+ broken_relationship: "raised while being inspected",
8
+ missing_association_method: "will raise when the relationship is included: the model has no association method",
9
+ missing_guard_method: "will raise whenever the resource renders: the readable guard is not defined",
10
+ missing_sideload_filter: "will raise when the relationship is included: the related resource is missing the filter"
11
+ }.freeze
12
+
13
+ CHECKLIST = {
14
+ broken_relationship: ["all relationships inspectable", "relationship", "raised while being inspected"],
15
+ missing_association_method: ["all association methods defined", "association method", "missing"],
16
+ missing_guard_method: ["all readable guards defined", "readable guard", "missing"],
17
+ missing_sideload_filter: ["all sideload filters declared", "sideload filter", "missing"],
18
+ loads_on_every_render: ["all id-rendering loads preloaded", "relationship", "loading ids without preloading"]
19
+ }.freeze
20
+
21
+ Cell = Struct.new(:raw, :painted) do
22
+ def pad_to(width)
23
+ painted + " " * (width - raw.length)
24
+ end
25
+ end
26
+
27
+ def initialize(rows, color: $stdout.tty?)
28
+ @rows = rows
29
+ @color = color
30
+ end
31
+
32
+ def to_s
33
+ return "graphiti: no relationships found.\n" if @rows.empty?
34
+
35
+ (body + [summary]).join("\n")
36
+ end
37
+
38
+ private
39
+
40
+ def body
41
+ [""] + issue_sections + loading_section + would_load_section + checks_section
42
+ end
43
+
44
+ def issue_sections
45
+ row_finding_pairs = @rows.flat_map do |row|
46
+ row.findings.map { |finding| [row, finding] }
47
+ end
48
+
49
+ row_finding_pairs
50
+ .group_by { |_, finding| finding.check }
51
+ .sort_by { |check, _| ISSUE_HEADINGS.keys.index(check) || ISSUE_HEADINGS.size }
52
+ .flat_map { |check, group| issue_section(check, group) }
53
+ end
54
+
55
+ def issue_section(check, group)
56
+ findings = group.map(&:last)
57
+ label = findings.first.error? ? "ERROR" : "WARNING"
58
+ color = findings.first.error? ? :red : :yellow
59
+ fixes = findings.filter_map(&:remedy).uniq
60
+
61
+ section_heading(label, color, ISSUE_HEADINGS.fetch(check, check.to_s.tr("_", " "))) +
62
+ lines_grouped_by_resource(group.map { |row, finding| [row, finding.message] }) +
63
+ (fixes.size == 1 ? [fix_line(fixes.first), ""] : [])
64
+ end
65
+
66
+ def loading_section
67
+ rows = @rows.select { |row| loads_without_preload?(row) }
68
+ return [] if rows.empty?
69
+
70
+ section_heading("WARNING", :yellow, "rendering resource ids by loading an association the base_scope does not preload") +
71
+ lines_grouped_by_resource(rows.map { |row| [row, nil] }) +
72
+ [" #{paint("That is a query per record rendered.", :dim)}",
73
+ fix_line("preload the association in base_scope, or drop `resource_ids`"),
74
+ " #{paint("See www.graphiti.dev/concepts/relationships#customizing-relationships", :dim)}",
75
+ ""]
76
+ end
77
+
78
+ def loads_without_preload?(row)
79
+ row.resource_ids_source == :load && row.preloaded == false
80
+ end
81
+
82
+ def would_load_section
83
+ rows = @rows.select(&:would_start_loading?)
84
+ return [] if rows.empty?
85
+
86
+ verb = rows.size == 1 ? "renders" : "render"
87
+
88
+ section_heading("FYI", :blue, "#{count(rows.size, "belongs_to relationship")} #{verb} no resource ids") +
89
+ lines_grouped_by_resource(rows.map { |row| [row, nil] }) +
90
+ [" #{paint("Nothing to fix. A request that includes the relationship still gets its ids.", :dim)}",
91
+ "",
92
+ " #{paint("To render ids on every response, opt in:", :dim)}",
93
+ ""] +
94
+ opt_in_lines +
95
+ ["",
96
+ " #{paint("Either would load the association on every render. That potential performance cost is why ids are opt-in.", :dim)}",
97
+ " #{paint("Preloading the association in base_scope keeps that load cheap.", :dim)}",
98
+ " #{paint("See www.graphiti.dev/concepts/relationships#belongs-to-resource-ids", :dim)}",
99
+ ""]
100
+ end
101
+
102
+ def checks_section
103
+ failure_counts = @rows.flat_map(&:findings).map(&:check).tally
104
+ loading = @rows.count { |row| loads_without_preload?(row) }
105
+ failure_counts[:loads_on_every_render] = loading if loading > 0
106
+
107
+ no_loads = @rows.none? { |row| row.resource_ids_source == :load }
108
+
109
+ lines = CHECKLIST.filter_map do |check, (passed, failure_noun, failure_suffix)|
110
+ if (failures = failure_counts[check])
111
+ " #{paint("✗", :red)} #{count(failures, failure_noun)} #{failure_suffix}"
112
+ elsif check == :loads_on_every_render && no_loads
113
+ nil
114
+ else
115
+ " #{paint("✓", :green)} #{passed}"
116
+ end
117
+ end
118
+
119
+ [paint("checks", :bold), ""] + lines + [""]
120
+ end
121
+
122
+ def section_heading(label, color, heading)
123
+ ["#{paint(label, color, :bold)} #{paint(heading, :bold)}", ""]
124
+ end
125
+
126
+ def fix_line(text)
127
+ " #{paint("fix:", :green)} #{text}"
128
+ end
129
+
130
+ def lines_grouped_by_resource(pairs)
131
+ width = pairs.map { |row, _| declaration_cell(row).raw.length }.max
132
+
133
+ pairs.group_by { |row, _| row.resource }.sort_by { |resource, _| resource }.flat_map do |resource, group|
134
+ [" #{paint(resource, :bold)}"] +
135
+ group.sort_by { |row, _| row.relationship.to_s }.map { |row, note|
136
+ declaration = declaration_cell(row)
137
+ note ? " #{declaration.pad_to(width)} #{note}" : " #{declaration.painted}"
138
+ } +
139
+ [""]
140
+ end
141
+ end
142
+
143
+ def opt_in_lines
144
+ options = [
145
+ [[["resource_ids: ", :cyan], ["true", :yellow]], "on one relationship"],
146
+ [[["self.belongs_to_resource_ids_by_default = ", nil], [":always", :cyan]], "across the whole API"]
147
+ ]
148
+
149
+ rows = options.map do |parts, scope|
150
+ [cell(parts), cell([[scope, :dim]])]
151
+ end
152
+
153
+ aligned(rows, " ")
154
+ end
155
+
156
+ def declaration_cell(row)
157
+ cell(declaration_parts(row))
158
+ end
159
+
160
+ def declaration_parts(row)
161
+ parts = [[row.type.to_s, :blue], [" ", nil], [":#{row.relationship}", :cyan]]
162
+
163
+ row.options.each do |key, value|
164
+ parts << [", ", nil] << ["#{key}: ", :cyan] << option_part(value)
165
+ end
166
+
167
+ parts
168
+ end
169
+
170
+ def option_part(value)
171
+ case value
172
+ when "..." then ["{ ... }", :dim]
173
+ when true, false then [value.inspect, :yellow]
174
+ when Symbol then [value.inspect, :cyan]
175
+ when String then [value.inspect, :green]
176
+ else [value.inspect, nil]
177
+ end
178
+ end
179
+
180
+ def cell(parts)
181
+ Cell.new(
182
+ parts.map { |text, _| text }.join,
183
+ parts.map { |text, style| paint(text, style) }.join
184
+ )
185
+ end
186
+
187
+ def aligned(rows, indent)
188
+ widths = rows.transpose.map { |column| column.map { |cell| cell.raw.length }.max }
189
+
190
+ rows.map do |cells|
191
+ line = cells.each_with_index.map { |cell, index|
192
+ index == cells.size - 1 ? cell.painted : cell.pad_to(widths[index])
193
+ }.join(" ")
194
+ indent + line
195
+ end
196
+ end
197
+
198
+ def summary
199
+ errors = @rows.count(&:error?)
200
+ loading = @rows.count { |row| loads_without_preload?(row) }
201
+ no_ids = @rows.count(&:would_start_loading?)
202
+
203
+ counts = [
204
+ count(@rows.group_by(&:resource).size, "resource"),
205
+ count(@rows.size, "relationship"),
206
+ paint(count(errors, "error"), errors > 0 ? :red : nil)
207
+ ]
208
+ counts << paint("#{loading} loading ids without preloading", :yellow) if loading > 0
209
+ counts << "#{no_ids} without resource ids" if no_ids > 0
210
+
211
+ "graphiti: #{counts.join(", ")}."
212
+ end
213
+
214
+ def count(number, noun)
215
+ "#{number} #{noun}#{"s" unless number == 1}"
216
+ end
217
+
218
+ def paint(text, *styles)
219
+ styles = styles.compact
220
+ return text unless @color && styles.any?
221
+
222
+ "\e[#{styles.map { |style| ANSI.fetch(style) }.join(";")}m#{text}\e[0m"
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,231 @@
1
+ module Graphiti
2
+ # Resources register themselves as they load, so eager load before auditing.
3
+ class Audit
4
+ Finding = Struct.new(:severity, :check, :message, :remedy, keyword_init: true) do
5
+ def error?
6
+ severity == :error
7
+ end
8
+ end
9
+
10
+ Row = Struct.new(
11
+ :resource,
12
+ :relationship,
13
+ :type,
14
+ :target,
15
+ :resource_ids_source,
16
+ :resource_ids_source_if_always,
17
+ :resource_ids_default,
18
+ :resource_ids_blocker,
19
+ :preloaded,
20
+ :options,
21
+ :findings,
22
+ keyword_init: true
23
+ ) do
24
+ def would_start_loading?
25
+ return false if resource_ids_default == :never
26
+
27
+ resource_ids_source != :load && resource_ids_source_if_always == :load
28
+ end
29
+
30
+ def severity
31
+ return :error if findings.any?(&:error?)
32
+ findings.empty? ? :ok : :warning
33
+ end
34
+
35
+ def error?
36
+ severity == :error
37
+ end
38
+ end
39
+
40
+ def self.run(resources = nil)
41
+ new(resources).run
42
+ end
43
+
44
+ def self.findings(resources = nil)
45
+ run(resources).flat_map(&:findings)
46
+ end
47
+
48
+ def initialize(resources = nil)
49
+ @resources = resources || Graphiti.resources.reject(&:abstract_class?)
50
+ end
51
+
52
+ def run
53
+ @resources.sort_by { |resource| resource.name.to_s }.flat_map { |resource| rows_for(resource) }
54
+ end
55
+
56
+ private
57
+
58
+ def rows_for(resource_class)
59
+ model = inferred_model(resource_class)
60
+
61
+ resource_class.sideloads.map do |name, sideload|
62
+ row_for(resource_class, name, sideload, model)
63
+ end
64
+ end
65
+
66
+ def row_for(resource_class, name, sideload, model)
67
+ source = resource_ids_source(sideload)
68
+
69
+ Row.new(
70
+ resource: resource_class.name,
71
+ relationship: name,
72
+ type: sideload.type,
73
+ target: target_name(sideload),
74
+ resource_ids_source: source,
75
+ resource_ids_source_if_always: resource_ids_source_if_always(resource_class, sideload),
76
+ resource_ids_default: resource_class.belongs_to_resource_ids_by_default,
77
+ resource_ids_blocker: sideload.resource_ids_blocker,
78
+ preloaded: source == :load ? association_preloaded?(resource_class, sideload) : nil,
79
+ options: declaration_options(sideload),
80
+ findings: [
81
+ missing_association_method(sideload, model),
82
+ missing_guard_method(resource_class, sideload),
83
+ missing_sideload_filter(sideload)
84
+ ].compact
85
+ )
86
+ rescue => error
87
+ Row.new(
88
+ resource: resource_class.name,
89
+ relationship: name,
90
+ type: sideload.type,
91
+ resource_ids_source: :none,
92
+ options: {},
93
+ findings: [
94
+ Finding.new(
95
+ severity: :error,
96
+ check: :broken_relationship,
97
+ message: "#{error.class}: #{error.message.strip.lines.first.to_s.strip}"
98
+ )
99
+ ]
100
+ )
101
+ end
102
+
103
+ def declaration_options(sideload)
104
+ options = sideload.non_default_options
105
+ options[:base_scope] = "..." if sideload.customized_base_scope?
106
+ options[:scope] = "..." if sideload.class.scope_proc
107
+ options[:params] = "..." if sideload.class.params_proc
108
+ options
109
+ end
110
+
111
+ def resource_ids_source(sideload)
112
+ return :none unless sideload.render_resource_ids?
113
+
114
+ sideload.resource_ids_blocker.nil? ? :key : :load
115
+ end
116
+
117
+ def resource_ids_source_if_always(resource_class, sideload)
118
+ original = resource_class.belongs_to_resource_ids_by_default
119
+ resource_class.belongs_to_resource_ids_by_default = :always
120
+ resource_ids_source(sideload)
121
+ ensure
122
+ resource_class.belongs_to_resource_ids_by_default = original
123
+ end
124
+
125
+ def target_name(sideload)
126
+ if sideload.type == :polymorphic_belongs_to
127
+ children = sideload.children.values.filter_map { |child| short_name(child) }
128
+ return children.empty? ? nil : children.join(", ")
129
+ end
130
+
131
+ sideload.resource.class.name
132
+ rescue
133
+ nil
134
+ end
135
+
136
+ def short_name(sideload)
137
+ sideload.resource.class.name&.split("::")&.last
138
+ rescue
139
+ nil
140
+ end
141
+
142
+ def inferred_model(resource_class)
143
+ model = resource_class.model
144
+ model.is_a?(Class) ? model : nil
145
+ rescue Errors::ModelNotFound
146
+ nil
147
+ end
148
+
149
+ def missing_association_method(sideload, model)
150
+ return unless model
151
+ return if sideload.type == :polymorphic_belongs_to
152
+ return if model.method_defined?(sideload.association_name)
153
+ return if model.private_method_defined?(sideload.association_name)
154
+
155
+ Finding.new(
156
+ severity: :error,
157
+ check: :missing_association_method,
158
+ message: "#{model.name} has no ##{sideload.association_name} method",
159
+ remedy: "define it, point the relationship at the real association with `as:`, or remove the relationship"
160
+ )
161
+ end
162
+
163
+ def missing_guard_method(resource_class, sideload)
164
+ guard = sideload.readable_guard_name
165
+ return unless guard
166
+ return if defines?(resource_class, guard)
167
+ return if defines?(sideload.resource.class, guard)
168
+
169
+ Finding.new(
170
+ severity: :error,
171
+ check: :missing_guard_method,
172
+ message: "##{guard} is defined on neither #{resource_class.name} nor its related resource",
173
+ remedy: "define the guard on either resource"
174
+ )
175
+ end
176
+
177
+ def defines?(resource_class, method_name)
178
+ resource_class.method_defined?(method_name) ||
179
+ resource_class.private_method_defined?(method_name)
180
+ end
181
+
182
+ def missing_sideload_filter(sideload)
183
+ return if builds_its_own_query?(sideload)
184
+
185
+ key = sideload_filter_key(sideload)
186
+ return unless key
187
+
188
+ related = sideload.resource.class
189
+ return if related.filters.key?(key)
190
+
191
+ Finding.new(
192
+ severity: :error,
193
+ check: :missing_sideload_filter,
194
+ message: "#{related.name} is missing `filter #{key.inspect}`",
195
+ remedy: "declare the filter on the related resource"
196
+ )
197
+ end
198
+
199
+ def builds_its_own_query?(sideload)
200
+ sideload.class.params_proc || sideload.class.scope_proc
201
+ end
202
+
203
+ def sideload_filter_key(sideload)
204
+ case sideload.type
205
+ when :has_many, :has_one then sideload.foreign_key
206
+ when :many_to_many then sideload.true_foreign_key
207
+ when :belongs_to then sideload.primary_key
208
+ end
209
+ end
210
+
211
+ def association_preloaded?(resource_class, sideload)
212
+ scope = resource_class.new.base_scope
213
+ return nil unless scope.respond_to?(:includes_values)
214
+
215
+ preloads = [scope.includes_values, scope.preload_values, scope.eager_load_values]
216
+ association_names(preloads).include?(sideload.association_name.to_sym)
217
+ rescue
218
+ nil
219
+ end
220
+
221
+ def association_names(preload_values)
222
+ Array(preload_values).flat_map do |value|
223
+ case value
224
+ when Hash then value.keys.map(&:to_sym)
225
+ when Array then association_names(value)
226
+ else [value.to_sym]
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
@@ -689,6 +689,53 @@ module Graphiti
689
689
  end
690
690
  end
691
691
 
692
+ class InvalidBelongsToResourceIds < Base
693
+ def initialize(resource_class, value)
694
+ @resource_class = resource_class
695
+ @value = value
696
+ end
697
+
698
+ def message
699
+ <<~MSG
700
+ #{@resource_class.name}: belongs_to_resource_ids_by_default must be one of :foreign_key, :always, or :never. Got #{@value.inspect}.
701
+
702
+ :foreign_key - render resource ids wherever the foreign key already holds the related id (default)
703
+ :always - render them for every belongs_to, loading the association when the foreign key cannot answer
704
+ :never - render none
705
+
706
+ To render resource ids for a collection, ask for it one relationship at a time with `resource_ids: true`.
707
+ MSG
708
+ end
709
+ end
710
+
711
+ class MissingRelationshipMethod < Base
712
+ def initialize(resource_class, sideload, model)
713
+ @resource_class = resource_class
714
+ @sideload = sideload
715
+ @model = model
716
+ end
717
+
718
+ def message
719
+ <<~MSG
720
+ #{@resource_class.name}: relationship #{@sideload.name.inspect} is declared, but #{@model.class.name} has no ##{@sideload.association_name} method.
721
+
722
+ Rendering the relationship reads the association off the model. Define ##{@sideload.association_name} on #{@model.class.name}, point the relationship at the real association with `as:`, or remove the relationship.
723
+ #{resource_ids_note}
724
+ MSG
725
+ end
726
+
727
+ private
728
+
729
+ def resource_ids_note
730
+ return "" unless @sideload.render_resource_ids?
731
+
732
+ <<~MSG
733
+
734
+ resource_ids is set on this relationship, so every render reads the association, not just requests that include it. See www.graphiti.dev/concepts/relationships#customizing-relationships.
735
+ MSG
736
+ end
737
+ end
738
+
692
739
  class MissingDependentFilter < Base
693
740
  def initialize(resource, filters)
694
741
  @resource = resource
@@ -11,10 +11,12 @@ module Graphiti
11
11
  end
12
12
 
13
13
  def validate
14
+ validate_page
15
+
14
16
  # Right now, all requests - even reads - go through the validator
15
17
  # In the future these should have their own validation logic, but
16
18
  # for now we can just bypass
17
- return true unless @params.has_key?(:data)
19
+ return errors.blank? unless @params.has_key?(:data)
18
20
 
19
21
  resource = @root_resource
20
22
  if @params[:data].has_key?(:type)
@@ -47,6 +49,13 @@ module Graphiti
47
49
 
48
50
  private
49
51
 
52
+ def validate_page
53
+ page = @params[:page]
54
+ return if page.nil? || page.respond_to?(:each_pair)
55
+
56
+ @errors.add(:page, :invalid, message: "must be an object")
57
+ end
58
+
50
59
  def process_relationships(resource, relationships, payload_path)
51
60
  relationships.each_key do |name|
52
61
  unless resource.class.sideload(name.to_sym)
@@ -6,6 +6,16 @@ module Graphiti
6
6
  DEFAULT_MAX_PAGE_SIZE = 1_000
7
7
 
8
8
  module Overrides
9
+ BELONGS_TO_RESOURCE_IDS_VALUES = [:foreign_key, :always, :never].freeze
10
+
11
+ def belongs_to_resource_ids_by_default=(val)
12
+ unless BELONGS_TO_RESOURCE_IDS_VALUES.include?(val)
13
+ raise Errors::InvalidBelongsToResourceIds.new(self, val)
14
+ end
15
+
16
+ super
17
+ end
18
+
9
19
  def serializer=(val)
10
20
  if val
11
21
  if super(Class.new(val))
@@ -21,6 +31,10 @@ module Graphiti
21
31
  send(:prepend, Polymorphism)
22
32
  end
23
33
 
34
+ def polymorphic?
35
+ polymorphic.present?
36
+ end
37
+
24
38
  def type=(val)
25
39
  val = val&.to_sym
26
40
  if (val = super)
@@ -90,7 +104,7 @@ module Graphiti
90
104
  :attributes_schema_by_default,
91
105
  :relationships_readable_by_default,
92
106
  :relationships_writable_by_default,
93
- :always_include_resource_ids_by_default,
107
+ :belongs_to_resource_ids_by_default,
94
108
  :filters_accept_nil_by_default,
95
109
  :filters_deny_empty_by_default,
96
110
  :graphql_entrypoint,
@@ -116,6 +130,7 @@ module Graphiti
116
130
  default(klass, :attributes_schema_by_default, true)
117
131
  default(klass, :relationships_readable_by_default, true)
118
132
  default(klass, :relationships_writable_by_default, true)
133
+ default(klass, :belongs_to_resource_ids_by_default, :foreign_key)
119
134
  default(klass, :filters_accept_nil_by_default, false)
120
135
  default(klass, :filters_deny_empty_by_default, false)
121
136
 
@@ -8,6 +8,8 @@ module Graphiti
8
8
  end
9
9
 
10
10
  def serializer_for(model)
11
+ return super unless self.class.polymorphic?
12
+
11
13
  if polymorphic_child?
12
14
  serializer
13
15
  else
@@ -17,10 +19,14 @@ module Graphiti
17
19
  end
18
20
 
19
21
  def associate_all(*args)
22
+ return super unless self.class.polymorphic?
23
+
20
24
  _associate(:associate_all, *args)
21
25
  end
22
26
 
23
27
  def associate(*args)
28
+ return super unless self.class.polymorphic?
29
+
24
30
  _associate(:associate, *args)
25
31
  end
26
32
 
@@ -34,6 +40,8 @@ module Graphiti
34
40
 
35
41
  module ClassMethods
36
42
  def inherited(klass)
43
+ return super unless polymorphic?
44
+
37
45
  klass.type = nil
38
46
  klass.model = klass.infer_model
39
47
  klass.endpoint = klass.infer_endpoint
@@ -42,6 +50,8 @@ module Graphiti
42
50
  end
43
51
 
44
52
  def sideload(name)
53
+ return super unless polymorphic?
54
+
45
55
  if (split_on = name.to_s.split(/^on__/)).length > 1
46
56
  on_type, name = split_on[1].split("--").map(&:to_sym)
47
57
  end
@@ -251,7 +251,7 @@ module Graphiti
251
251
  schema[:guard] = true
252
252
  end
253
253
 
254
- if config.always_include_resource_ids?
254
+ if config.render_resource_ids?
255
255
  schema[:linkage] = true
256
256
  end
257
257
 
@@ -3,40 +3,33 @@ class Graphiti::Sideload::BelongsTo < Graphiti::Sideload
3
3
  :belongs_to
4
4
  end
5
5
 
6
- def default_include_resource_ids?
7
- linkage_from_foreign_key?
6
+ def default_render_resource_ids?
7
+ case parent_resource_class&.belongs_to_resource_ids_by_default
8
+ when :always then renderable_at_all?
9
+ when :never then false
10
+ else resource_ids_from_foreign_key?
11
+ end
12
+ end
13
+
14
+ def renderable_at_all?
15
+ readable_guarded? || readable?
8
16
  end
9
17
 
10
- # The parent already carries the foreign key, and for a plain belongs_to
11
- # that key *is* the related id, so linkage costs nothing. Anything that can
12
- # change which record the relationship resolves to, or what type it carries,
13
- # has to load the association instead:
14
- #
15
- # - a scope/params block or a base_scope can filter out the record the
16
- # foreign key points at, so the key would claim a relationship the API
17
- # would not actually return
18
- # - a polymorphic target takes its type from the record, not from the
19
- # relationship, so the key alone cannot say what type the id has
20
- # - a remote resource has no local foreign key to read
21
- # - a custom primary_key points the relationship at some other column, so
22
- # the key holds that column's value rather than the related id
23
- def linkage_from_foreign_key?
24
- # Ask before resolving #resource: an unreadable relationship renders
25
- # nothing, and its resource class may not even be inferrable.
26
- return false unless readable?
27
- return false unless foreign_key_is_related_id?
28
- return false if polymorphic_child?
29
- return false if self.class.scope_proc || self.class.params_proc
30
- return false if @base_scope
31
- return false if remote?
32
- return false if resource.class.polymorphic.present?
18
+ def resource_ids_blocker
19
+ return :unreadable unless renderable_at_all?
20
+ return :custom_primary_key unless foreign_key_is_related_id?
21
+ return :polymorphic_child if polymorphic_child?
22
+ return :scope_block if self.class.scope_proc
23
+ return :params_block if self.class.params_proc
24
+ return :base_scope if @base_scope
25
+ return :remote if remote?
26
+ return :polymorphic_resource if resource.class.polymorphic.present?
33
27
 
34
- true
28
+ nil
35
29
  end
36
30
 
37
- # The foreign key can stand in for the related id only when the two hold the
38
- # same value. base_filter matches the key against primary_key, so pointing
39
- # that at another column means the key holds that column instead.
31
+ # base_filter matches the foreign key against primary_key, so a custom
32
+ # primary_key means the key holds that column's value, not the related id.
40
33
  def foreign_key_is_related_id?
41
34
  primary_key == :id
42
35
  end
@@ -20,6 +20,7 @@ module Graphiti
20
20
  def initialize(name, opts)
21
21
  @name = name
22
22
  validate_options!(opts)
23
+ translate_deprecated_options!(opts)
23
24
  @parent_resource_class = opts[:parent_resource]
24
25
  @resource_class_name = opts[:resource]
25
26
  @primary_key = opts[:primary_key]
@@ -42,7 +43,7 @@ module Graphiti
42
43
  @group_name = opts[:group_name]
43
44
  @polymorphic_child = opts[:polymorphic_child]
44
45
  @parent = opts[:parent]
45
- @always_include_resource_ids = opts[:always_include_resource_ids]
46
+ @render_resource_ids = opts[:resource_ids]
46
47
 
47
48
  if polymorphic_child?
48
49
  parent.resource.polymorphic << resource_class
@@ -110,6 +111,31 @@ module Graphiti
110
111
  dynamic_flag?(@readable) || dynamic_flag?(@writable)
111
112
  end
112
113
 
114
+ def readable_guarded?
115
+ dynamic_flag?(@readable)
116
+ end
117
+
118
+ def readable_guard_name
119
+ @readable.to_sym if @readable.is_a?(Symbol) || @readable.is_a?(String)
120
+ end
121
+
122
+ def non_default_options
123
+ options = {}
124
+ options[:as] = association_name if @as
125
+ options[:primary_key] = primary_key unless primary_key == :id
126
+ options[:single] = true if single?
127
+ options[:remote] = @remote if remote?
128
+ options[:link] = @link unless @link.nil?
129
+ options[:readable] = @readable unless @readable.nil? || @readable == true
130
+ options[:writable] = @writable unless @writable.nil? || @writable == true
131
+ options[:resource_ids] = @render_resource_ids unless @render_resource_ids.nil?
132
+ options
133
+ end
134
+
135
+ def customized_base_scope?
136
+ !!@base_scope
137
+ end
138
+
113
139
  def single?
114
140
  !!@single
115
141
  end
@@ -122,24 +148,21 @@ module Graphiti
122
148
  !!@polymorphic_as
123
149
  end
124
150
 
125
- # False everywhere but a plain belongs_to - see
126
- # Sideload::BelongsTo#linkage_from_foreign_key?.
127
- def linkage_from_foreign_key?
128
- false
151
+ def resource_ids_from_foreign_key?
152
+ resource_ids_blocker.nil?
129
153
  end
130
154
 
131
- # nil at either of the first two levels means "not specified" rather
132
- # than "false".
133
- def always_include_resource_ids?
134
- return !!@always_include_resource_ids unless @always_include_resource_ids.nil?
155
+ def resource_ids_blocker
156
+ :no_foreign_key_on_parent
157
+ end
135
158
 
136
- configured = parent_resource_class&.always_include_resource_ids_by_default
137
- return !!configured unless configured.nil?
159
+ def render_resource_ids?
160
+ return !!@render_resource_ids unless @render_resource_ids.nil?
138
161
 
139
- default_include_resource_ids?
162
+ default_render_resource_ids?
140
163
  end
141
164
 
142
- def default_include_resource_ids?
165
+ def default_render_resource_ids?
143
166
  false
144
167
  end
145
168
 
@@ -434,6 +457,18 @@ module Graphiti
434
457
  false
435
458
  end
436
459
 
460
+ def translate_deprecated_options!(opts)
461
+ return unless opts.key?(:always_include_resource_ids)
462
+
463
+ Graphiti::DEPRECATOR.deprecation_warning(
464
+ :always_include_resource_ids,
465
+ "Use :resource_ids instead (#{opts[:parent_resource]&.name}##{@name})"
466
+ )
467
+
468
+ value = opts.delete(:always_include_resource_ids)
469
+ opts[:resource_ids] = value unless opts.key?(:resource_ids)
470
+ end
471
+
437
472
  def validate_options!(opts)
438
473
  if opts[:remote]
439
474
  if opts[:resource]
@@ -68,9 +68,9 @@ module Graphiti
68
68
  # sideload can resolve it to something the foreign key alone would
69
69
  # not predict, so the loaded records win. Only the un-included case
70
70
  # is worth short-circuiting.
71
- if sideload_ref.linkage_from_foreign_key? &&
71
+ if sideload_ref.resource_ids_from_foreign_key? &&
72
72
  !self_ref.send(:included_anywhere?, @proxy.query.include_hash, sideload_ref.name)
73
- linkage always: sideload_ref.always_include_resource_ids? do
73
+ linkage always: sideload_ref.render_resource_ids? do
74
74
  foreign_key = @object.public_send(sideload_ref.foreign_key)
75
75
 
76
76
  unless foreign_key.nil?
@@ -81,7 +81,7 @@ module Graphiti
81
81
  end
82
82
  end
83
83
  else
84
- linkage always: sideload_ref.always_include_resource_ids?
84
+ linkage always: sideload_ref.render_resource_ids?
85
85
  end
86
86
 
87
87
  if link_ref
@@ -107,8 +107,31 @@ module Graphiti
107
107
 
108
108
  def data_proc
109
109
  sideload_ref = @sideload
110
+ resource_class_ref = @resource_class
110
111
  ->(_) {
111
- if (records = @object.public_send(sideload_ref.association_name))
112
+ begin
113
+ records = @object.public_send(sideload_ref.association_name)
114
+ rescue NoMethodError => error
115
+ # #receiver raises ArgumentError when the error was built by hand
116
+ # rather than raised by a failed call, and a hand-built one can
117
+ # still carry a matching #name.
118
+ receiver = begin
119
+ error.receiver
120
+ rescue ArgumentError
121
+ nil
122
+ end
123
+
124
+ raise unless error.name == sideload_ref.association_name &&
125
+ receiver.equal?(@object)
126
+
127
+ # A private method exists, so "has no such method" would be a lie.
128
+ raise if @object.respond_to?(sideload_ref.association_name, true)
129
+
130
+ raise Errors::MissingRelationshipMethod
131
+ .new(resource_class_ref, sideload_ref, @object)
132
+ end
133
+
134
+ if records
112
135
  if records.respond_to?(:to_ary)
113
136
  records.each { |r| sideload_ref.resource.decorate_record(r) }
114
137
  else
@@ -1,3 +1,3 @@
1
1
  module Graphiti
2
- VERSION = "2.0.0.beta.5"
2
+ VERSION = "2.0.0.beta.6"
3
3
  end
data/lib/graphiti.rb CHANGED
@@ -166,6 +166,8 @@ require "graphiti/configuration"
166
166
  require "graphiti/context"
167
167
  require "graphiti/errors"
168
168
  require "graphiti/types"
169
+ require "graphiti/audit"
170
+ require "graphiti/audit/report"
169
171
  require "graphiti/schema"
170
172
  require "graphiti/schema_diff"
171
173
  require "graphiti/adapters/abstract"
@@ -16,6 +16,15 @@ namespace :graphiti do
16
16
  Graphiti::Debugger.flush if debug
17
17
  end
18
18
 
19
+ desc "Audit every relationship: what will raise, what loads to render ids, which render no ids, and which checks passed."
20
+ task audit: [:environment] do
21
+ helpers.setup_rails!
22
+ rows = Graphiti::Audit.run
23
+ puts Graphiti::Audit::Report.new(rows).to_s
24
+
25
+ exit 1 if rows.any?(&:error?)
26
+ end
27
+
19
28
  desc "Execute benchmark without web server."
20
29
  task :benchmark, [:path, :requests] => [:environment] do |_, args|
21
30
  helpers.setup_rails!
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphiti
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0.beta.5
4
+ version: 2.0.0.beta.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lee Richmond
@@ -335,6 +335,8 @@ files:
335
335
  - lib/graphiti/adapters/graphiti_api.rb
336
336
  - lib/graphiti/adapters/null.rb
337
337
  - lib/graphiti/adapters/persistence/associations.rb
338
+ - lib/graphiti/audit.rb
339
+ - lib/graphiti/audit/report.rb
338
340
  - lib/graphiti/cli.rb
339
341
  - lib/graphiti/configuration.rb
340
342
  - lib/graphiti/context.rb