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 +4 -4
- data/.github/workflows/ci.yml +5 -0
- data/CHANGELOG.md +15 -0
- data/docs/concepts/relationships.md +109 -9
- data/docs/topics/debugging.md +26 -0
- data/docs/upgrading.md +14 -8
- data/graphiti.gemspec +1 -1
- data/lib/graphiti/audit/report.rb +226 -0
- data/lib/graphiti/audit.rb +231 -0
- data/lib/graphiti/errors.rb +47 -0
- data/lib/graphiti/request_validators/validator.rb +10 -1
- data/lib/graphiti/resource/configuration.rb +16 -1
- data/lib/graphiti/resource/polymorphism.rb +10 -0
- data/lib/graphiti/schema.rb +1 -1
- data/lib/graphiti/sideload/belongs_to.rb +22 -29
- data/lib/graphiti/sideload.rb +48 -13
- data/lib/graphiti/util/serializer_relationships.rb +27 -4
- data/lib/graphiti/version.rb +1 -1
- data/lib/graphiti.rb +2 -0
- data/lib/tasks/graphiti.rake +9 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e108e5f71103c3fb7f38f5328a890ff131b264201c583509081cfd3106a7b255
|
|
4
|
+
data.tar.gz: 18edd07b0ab429ec89309ec94164a2324a21bbbfbd6dd37f6e49bb6f9707f1c9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f2affdd800c2966ef15c5d1765b9d1acc48a032c3ee143b9283d69b9545290e227454d77559e25c94b4cd7b0131d0cdc2addbb031a4177155d8085086d818b3b
|
|
7
|
+
data.tar.gz: 79c294b775b8be269194cae7e61cf179a16ec8dd01e8b0b24e7a141d3333683bfbd54032e4c07623f6d2dc3fbe3ce6c721884658da89cbe24bfd9dac935f8e07
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -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
|
-
|
|
73
|
+
resource_ids: false
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
`
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
167
|
+
self.belongs_to_resource_ids_by_default = :foreign_key
|
|
94
168
|
end
|
|
95
169
|
```
|
|
96
170
|
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
368
|
+
resource_ids: true,
|
|
269
369
|
resource: EmployeeResource
|
|
270
370
|
```
|
|
271
371
|
|
data/docs/topics/debugging.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
148
|
+
belongs_to :employee, resource_ids: false
|
|
149
149
|
```
|
|
150
150
|
|
|
151
|
-
Or for the whole API, on the resource everything inherits from
|
|
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.
|
|
157
|
+
self.belongs_to_resource_ids_by_default = :never
|
|
158
158
|
end
|
|
159
159
|
```
|
|
160
160
|
|
|
161
|
-
|
|
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
|
|
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
|
data/lib/graphiti/errors.rb
CHANGED
|
@@ -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
|
|
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
|
-
:
|
|
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
|
data/lib/graphiti/schema.rb
CHANGED
|
@@ -3,40 +3,33 @@ class Graphiti::Sideload::BelongsTo < Graphiti::Sideload
|
|
|
3
3
|
:belongs_to
|
|
4
4
|
end
|
|
5
5
|
|
|
6
|
-
def
|
|
7
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
28
|
+
nil
|
|
35
29
|
end
|
|
36
30
|
|
|
37
|
-
#
|
|
38
|
-
#
|
|
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
|
data/lib/graphiti/sideload.rb
CHANGED
|
@@ -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
|
-
@
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
137
|
-
return
|
|
159
|
+
def render_resource_ids?
|
|
160
|
+
return !!@render_resource_ids unless @render_resource_ids.nil?
|
|
138
161
|
|
|
139
|
-
|
|
162
|
+
default_render_resource_ids?
|
|
140
163
|
end
|
|
141
164
|
|
|
142
|
-
def
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
data/lib/graphiti/version.rb
CHANGED
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"
|
data/lib/tasks/graphiti.rake
CHANGED
|
@@ -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.
|
|
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
|