graphiti 2.0.0.beta.7 → 2.0.0.beta.8
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/.git-blame-ignore-revs +2 -0
- data/.github/workflows/ci.yml +55 -3
- data/.github/workflows/docs.yml +1 -1
- data/.github/workflows/notify-example-app.yml +18 -0
- data/.gitignore +2 -0
- data/.standard.yml +6 -0
- data/CHANGELOG.md +45 -0
- data/CONTRIBUTING.md +3 -1
- data/README.md +13 -206
- data/Rakefile +6 -3
- data/UPGRADING.md +1 -1
- data/graphiti.gemspec +8 -4
- data/lib/generators/graphiti/generator_mixin.rb +1 -1
- data/lib/generators/graphiti/install_generator.rb +1 -1
- data/lib/generators/graphiti/resource_generator.rb +6 -3
- data/lib/generators/graphiti/templates/application_resource.rb.erb +19 -0
- data/lib/generators/graphiti/templates/controller.rb.erb +26 -8
- data/lib/graphiti/audit/report.rb +6 -6
- data/lib/graphiti/audit.rb +3 -4
- data/lib/graphiti/delegates/pagination.rb +2 -2
- data/lib/graphiti/deserializer.rb +3 -3
- data/lib/graphiti/errors.rb +6 -6
- data/lib/graphiti/extensions/temp_id.rb +1 -1
- data/lib/graphiti/query.rb +3 -3
- data/lib/graphiti/rails/context.rb +2 -1
- data/lib/graphiti/rails/controller.rb +3 -0
- data/lib/graphiti/rails/debugging.rb +1 -1
- data/lib/graphiti/rails/responders.rb +2 -1
- data/lib/graphiti/resource/configuration.rb +1 -0
- data/lib/graphiti/resource/dsl.rb +2 -2
- data/lib/graphiti/resource/links.rb +1 -1
- data/lib/graphiti/resource/persistence.rb +1 -1
- data/lib/graphiti/resource/polymorphism.rb +1 -1
- data/lib/graphiti/resource.rb +2 -2
- data/lib/graphiti/resource_proxy.rb +0 -1
- data/lib/graphiti/scoping/filter.rb +5 -3
- data/lib/graphiti/scoping/paginate.rb +1 -1
- data/lib/graphiti/scoping/sort.rb +1 -1
- data/lib/graphiti/sideload/has_many.rb +1 -1
- data/lib/graphiti/spec_helpers/errors_proxy.rb +1 -1
- data/lib/graphiti/spec_helpers/matchers.rb +1 -1
- data/lib/graphiti/types.rb +5 -1
- data/lib/graphiti/util/cache_debug.rb +3 -3
- data/lib/graphiti/util/hash.rb +1 -1
- data/lib/graphiti/util/persistence.rb +6 -6
- data/lib/graphiti/util/serializer_relationships.rb +1 -1
- data/lib/graphiti/version.rb +1 -1
- data/lib/tasks/graphiti.rake +1 -1
- metadata +31 -197
- data/docs/concepts/backends-and-models.md +0 -122
- data/docs/concepts/endpoints.md +0 -183
- data/docs/concepts/links.md +0 -212
- data/docs/concepts/overview.md +0 -80
- data/docs/concepts/persisting.md +0 -376
- data/docs/concepts/relationships.md +0 -627
- data/docs/concepts/resources.md +0 -677
- data/docs/getting-started/first-api.md +0 -289
- data/docs/getting-started/installation.md +0 -185
- data/docs/intro.md +0 -307
- data/docs/js/authentication.md +0 -63
- data/docs/js/ddau.md +0 -20
- data/docs/js/extra-params.md +0 -41
- data/docs/js/index.md +0 -112
- data/docs/js/installation.md +0 -120
- data/docs/js/middleware.md +0 -72
- data/docs/js/models.md +0 -202
- data/docs/js/reads.md +0 -494
- data/docs/js/state-syncing.md +0 -100
- data/docs/js/writes.md +0 -373
- data/docs/reference/vandal.md +0 -63
- data/docs/reference/why.md +0 -13
- data/docs/topics/authorization.md +0 -155
- data/docs/topics/caching.md +0 -55
- data/docs/topics/customizing-sideloads.md +0 -156
- data/docs/topics/debugging.md +0 -242
- data/docs/topics/error-handling.md +0 -210
- data/docs/topics/etags.md +0 -46
- data/docs/topics/hopping-relationships.md +0 -149
- data/docs/topics/json-attributes.md +0 -77
- data/docs/topics/openstruct-models.md +0 -50
- data/docs/topics/remote-resources.md +0 -291
- data/docs/topics/testing.md +0 -916
- data/docs/topics/without-activerecord.md +0 -324
- data/docs/tutorial/index.md +0 -58
- data/docs/tutorial/step_0.md +0 -107
- data/docs/tutorial/step_1.md +0 -199
- data/docs/tutorial/step_2.md +0 -312
- data/docs/tutorial/step_3.md +0 -142
- data/docs/tutorial/step_4.md +0 -135
- data/docs/tutorial/step_5.md +0 -69
- data/docs/tutorial/step_6.md +0 -82
- data/docs/tutorial/step_7.md +0 -205
- data/docs/tutorial/step_8.md +0 -128
- data/docs/tutorial/step_9.md +0 -171
- data/docs/upgrading.md +0 -267
- data/website/.gitignore +0 -20
- data/website/README.md +0 -43
- data/website/docusaurus.config.js +0 -197
- data/website/package-lock.json +0 -19474
- data/website/package.json +0 -46
- data/website/sidebars.js +0 -82
- data/website/src/css/custom.css +0 -85
- data/website/src/pages/markdown-page.mdx +0 -7
- data/website/static/.nojekyll +0 -0
- data/website/static/1.13/2019/03/31/graphiti-1-0.html +0 -205
- data/website/static/1.13/2019/05/08/graphiti-1-1.html +0 -212
- data/website/static/1.13/2019/05/20/graphiti-1-2.html +0 -214
- data/website/static/1.13/2019/10/14/tutorial.html +0 -198
- data/website/static/1.13/CNAME +0 -1
- data/website/static/1.13/README.md +0 -16
- data/website/static/1.13/assets/css/syntax.css +0 -60
- data/website/static/1.13/assets/favicons/android-chrome-192x192.png +0 -0
- data/website/static/1.13/assets/favicons/android-chrome-256x256.png +0 -0
- data/website/static/1.13/assets/favicons/apple-touch-icon.png +0 -0
- data/website/static/1.13/assets/favicons/browserconfig.xml +0 -9
- data/website/static/1.13/assets/favicons/favicon-16x16.png +0 -0
- data/website/static/1.13/assets/favicons/favicon-32x32.png +0 -0
- data/website/static/1.13/assets/favicons/favicon.ico +0 -0
- data/website/static/1.13/assets/favicons/mstile-150x150.png +0 -0
- data/website/static/1.13/assets/favicons/safari-pinned-tab.svg +0 -1
- data/website/static/1.13/assets/favicons/site.webmanifest +0 -19
- data/website/static/1.13/assets/img/backend.gif +0 -0
- data/website/static/1.13/assets/img/conformity.png +0 -0
- data/website/static/1.13/assets/img/error_payload.png +0 -0
- data/website/static/1.13/assets/img/fancy-cushion.png +0 -0
- data/website/static/1.13/assets/img/gh.png +0 -0
- data/website/static/1.13/assets/img/home-bg.jpg +0 -0
- data/website/static/1.13/assets/img/lifecycle.gif +0 -0
- data/website/static/1.13/assets/img/logo-500.png +0 -0
- data/website/static/1.13/assets/img/logo.png +0 -0
- data/website/static/1.13/assets/img/love-graffiti.jpg +0 -0
- data/website/static/1.13/assets/img/meta_total_count.png +0 -0
- data/website/static/1.13/assets/img/persist.jpg +0 -0
- data/website/static/1.13/assets/img/resource.gif +0 -0
- data/website/static/1.13/assets/img/rest-graffiti.jpg +0 -0
- data/website/static/1.13/assets/img/rest1.gif +0 -0
- data/website/static/1.13/assets/img/rest2.gif +0 -0
- data/website/static/1.13/assets/img/rest3.gif +0 -0
- data/website/static/1.13/assets/img/rethink-rest-graffiti.jpg +0 -0
- data/website/static/1.13/assets/img/sunrise.png +0 -0
- data/website/static/1.13/assets/img/why.png +0 -0
- data/website/static/1.13/assets/js/highlight.pack.js +0 -2
- data/website/static/1.13/assets/main.css +0 -15518
- data/website/static/1.13/assets/main.css.map +0 -1
- data/website/static/1.13/bin/bundle +0 -109
- data/website/static/1.13/bin/jekyll +0 -27
- data/website/static/1.13/bin/kramdown +0 -27
- data/website/static/1.13/bin/listen +0 -27
- data/website/static/1.13/bin/rake +0 -27
- data/website/static/1.13/bin/rougify +0 -27
- data/website/static/1.13/bin/safe_yaml +0 -27
- data/website/static/1.13/bin/sass +0 -27
- data/website/static/1.13/bin/sass-convert +0 -27
- data/website/static/1.13/bin/scss +0 -27
- data/website/static/1.13/blog.html +0 -259
- data/website/static/1.13/cheatsheet.html +0 -316
- data/website/static/1.13/cookbooks/authorization.md +0 -0
- data/website/static/1.13/cookbooks/caching.md +0 -0
- data/website/static/1.13/cookbooks/customizing-sideloads.html +0 -325
- data/website/static/1.13/cookbooks/etags.md +0 -0
- data/website/static/1.13/cookbooks/hopping-relationships.html +0 -324
- data/website/static/1.13/cookbooks/json_attributes.md +0 -0
- data/website/static/1.13/cookbooks/openstruct-models.md +0 -0
- data/website/static/1.13/cookbooks/remote-resources.md +0 -0
- data/website/static/1.13/cookbooks/without-activerecord.html +0 -510
- data/website/static/1.13/features.html +0 -249
- data/website/static/1.13/feed.xml +0 -106
- data/website/static/1.13/guides/concepts/backends-and-models.html +0 -467
- data/website/static/1.13/guides/concepts/debugging.html +0 -440
- data/website/static/1.13/guides/concepts/endpoints.html +0 -432
- data/website/static/1.13/guides/concepts/error-handling.html +0 -396
- data/website/static/1.13/guides/concepts/links.html +0 -501
- data/website/static/1.13/guides/concepts/remote-resources.html +0 -536
- data/website/static/1.13/guides/concepts/resources.html +0 -2176
- data/website/static/1.13/guides/concepts/testing.html +0 -1469
- data/website/static/1.13/guides/getting-started/installation.html +0 -420
- data/website/static/1.13/guides/graphiti-rails-migration.html +0 -242
- data/website/static/1.13/guides/index.html +0 -269
- data/website/static/1.13/guides/overview.html +0 -325
- data/website/static/1.13/guides/upgrading-2-0.html +0 -193
- data/website/static/1.13/guides/upgrading.html +0 -314
- data/website/static/1.13/guides/vandal.html +0 -282
- data/website/static/1.13/guides/why.html +0 -1121
- data/website/static/1.13/index.html +0 -72
- data/website/static/1.13/js/authentication.html +0 -295
- data/website/static/1.13/js/ddau.html +0 -238
- data/website/static/1.13/js/extra-params.html +0 -270
- data/website/static/1.13/js/index.html +0 -321
- data/website/static/1.13/js/installation.html +0 -637
- data/website/static/1.13/js/introduction.html +0 -257
- data/website/static/1.13/js/middleware.html +0 -318
- data/website/static/1.13/js/reads/fieldsets.html +0 -271
- data/website/static/1.13/js/reads/filtering.html +0 -289
- data/website/static/1.13/js/reads/includes.html +0 -260
- data/website/static/1.13/js/reads/index.html +0 -497
- data/website/static/1.13/js/reads/nested-queries.html +0 -353
- data/website/static/1.13/js/reads/pagination.html +0 -260
- data/website/static/1.13/js/reads/sorting.html +0 -265
- data/website/static/1.13/js/reads/statistics.html +0 -289
- data/website/static/1.13/js/state-syncing.html +0 -340
- data/website/static/1.13/js/writes/deferred.html +0 -296
- data/website/static/1.13/js/writes/dirty-tracking.html +0 -399
- data/website/static/1.13/js/writes/index.html +0 -391
- data/website/static/1.13/js/writes/nested.html +0 -330
- data/website/static/1.13/js/writes/validations.html +0 -272
- data/website/static/1.13/quickstart.html +0 -660
- data/website/static/1.13/template +0 -161
- data/website/static/1.13/tutorial/index.html +0 -250
- data/website/static/1.13/tutorial/step_0.html +0 -292
- data/website/static/1.13/tutorial/step_1.html +0 -517
- data/website/static/1.13/tutorial/step_2.html +0 -481
- data/website/static/1.13/tutorial/step_3.html +0 -323
- data/website/static/1.13/tutorial/step_4.html +0 -318
- data/website/static/1.13/tutorial/step_5.html +0 -265
- data/website/static/1.13/tutorial/step_6.html +0 -276
- data/website/static/1.13/tutorial/step_7.html +0 -390
- data/website/static/1.13/tutorial/step_8.html +0 -316
- data/website/static/1.13/tutorial/step_9.html +0 -365
- data/website/static/CNAME +0 -1
- data/website/static/assets/img/error_payload.png +0 -0
- data/website/static/assets/img/legacy/legacy-0378a3bb39.png +0 -0
- data/website/static/assets/img/legacy/legacy-05bbd3e5fd.png +0 -0
- data/website/static/assets/img/legacy/legacy-07aa104495.png +0 -0
- data/website/static/assets/img/legacy/legacy-0c75a16b3a.gif +0 -0
- data/website/static/assets/img/legacy/legacy-3076df6209.png +0 -0
- data/website/static/assets/img/legacy/legacy-7f6889bc89.png +0 -0
- data/website/static/assets/img/legacy/legacy-a2cc4363c3.png +0 -0
- data/website/static/assets/img/legacy/legacy-f67cfa89ab.png +0 -0
- data/website/static/assets/img/meta_total_count.png +0 -0
- data/website/static/img/docusaurus-social-card.jpg +0 -0
- data/website/static/img/docusaurus.png +0 -0
- data/website/static/img/favicon.ico +0 -0
- data/website/static/img/logo.png +0 -0
- data/website/static/img/logo.svg +0 -1
- data/website/static/img/undraw_docusaurus_mountain.svg +0 -171
- data/website/static/img/undraw_docusaurus_react.svg +0 -170
- data/website/static/img/undraw_docusaurus_tree.svg +0 -40
|
@@ -1,627 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: 'Relationships'
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
# Relationships {#relationships}
|
|
6
|
-
|
|
7
|
-
Resources rarely stand alone. This page covers how to connect them together for sideloading, sideposting, and links.
|
|
8
|
-
|
|
9
|
-
Resources can connect to other Resources via **relationships**.
|
|
10
|
-
Each relationship determines behavior for:
|
|
11
|
-
|
|
12
|
-
* Sideloading (load both Resources in a single request)
|
|
13
|
-
* Links (URL to lazy-load in separate request)
|
|
14
|
-
* Sideposting (save both in single request)
|
|
15
|
-
|
|
16
|
-
When connecting resources, you can imagine the logic similar to
|
|
17
|
-
`ActiveRecord`'s `.includes`:
|
|
18
|
-
|
|
19
|
-
```ruby
|
|
20
|
-
class PostResource < ApplicationResource
|
|
21
|
-
has_many :comments
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
class CommentResource < ApplicationResource
|
|
25
|
-
attribute :post_id, :integer, only: [:filterable]
|
|
26
|
-
belongs_to :post
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
PostResource.all(include: 'comments')
|
|
30
|
-
# Under the hood:
|
|
31
|
-
# CommentResource.all(filter: { post_id: array_of_post_ids })
|
|
32
|
-
|
|
33
|
-
CommentResource.all(include: 'post')
|
|
34
|
-
# Under the hood:
|
|
35
|
-
# PostResource.all(filter: { id: array_of_comment_ids })
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
> Note the explicit `post_id` filter on `CommentResource`
|
|
39
|
-
|
|
40
|
-
## Deep Queries {#deep-queries}
|
|
41
|
-
|
|
42
|
-
A query that applies to a relationship is referred to as a **deep
|
|
43
|
-
query**. Use the dot-syntax to deep query:
|
|
44
|
-
|
|
45
|
-
`/employees?include=positions&filter[positions.title]=Manager`
|
|
46
|
-
|
|
47
|
-
`/employees?include=positions.department&filter[positions.department.name]=Engineering`
|
|
48
|
-
|
|
49
|
-
The above references the **relationship name**. For simplicity, you can
|
|
50
|
-
also pass the JSONAPI type in brackets:
|
|
51
|
-
|
|
52
|
-
`/employees?include=positions.department&filter[departments][name]=Engineering`
|
|
53
|
-
|
|
54
|
-
Sorting and pagination currently only support the JSONAPI type:
|
|
55
|
-
|
|
56
|
-
`/employees?include=positions.department&sort=departments.name`
|
|
57
|
-
|
|
58
|
-
`/employees?include=positions.department&page[departments][size]=10`
|
|
59
|
-
|
|
60
|
-
## Customizing Relationships {#customizing-relationships}
|
|
61
|
-
|
|
62
|
-
The default options you can override are:
|
|
63
|
-
|
|
64
|
-
```ruby
|
|
65
|
-
has_many :positions,
|
|
66
|
-
foreign_key: :employee_id,
|
|
67
|
-
primary_key: :id,
|
|
68
|
-
resource: EmployeeResource,
|
|
69
|
-
readable: true,
|
|
70
|
-
writable: true,
|
|
71
|
-
link: self.autolink, # default true
|
|
72
|
-
single: false, # only allow this sideload when one employee
|
|
73
|
-
resource_ids: false
|
|
74
|
-
```
|
|
75
|
-
|
|
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:
|
|
87
|
-
|
|
88
|
-
```json
|
|
89
|
-
"employee": {
|
|
90
|
-
"data": { "type": "employees", "id": "1" },
|
|
91
|
-
"links": { "related": "/employees?filter[id]=1" }
|
|
92
|
-
}
|
|
93
|
-
```
|
|
94
|
-
|
|
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.
|
|
98
|
-
|
|
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.
|
|
100
|
-
|
|
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:
|
|
164
|
-
|
|
165
|
-
```ruby
|
|
166
|
-
class ApplicationResource < Graphiti::Resource
|
|
167
|
-
self.belongs_to_resource_ids_by_default = :foreign_key
|
|
168
|
-
end
|
|
169
|
-
```
|
|
170
|
-
|
|
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.
|
|
198
|
-
|
|
199
|
-
### Conditional Relationships {#conditional-relationships}
|
|
200
|
-
|
|
201
|
-
Like attributes, the `readable` and `writable` flags on a relationship accept more than a boolean: pass a symbol, string, or proc and the relationship becomes conditional, evaluated per-request.
|
|
202
|
-
|
|
203
|
-
```ruby
|
|
204
|
-
class EmployeeResource < ApplicationResource
|
|
205
|
-
has_many :salary_histories, readable: :admin?, writable: :admin?
|
|
206
|
-
|
|
207
|
-
def admin?
|
|
208
|
-
context.current_user.admin?
|
|
209
|
-
end
|
|
210
|
-
end
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
When a readable guard returns `false`, the relationship is omitted from the serialized output and any attempt to sideload it via `?include=` is silently scrubbed from the request. When a writable guard returns `false`, sideposting to that relationship is rejected with an `unwritable_relationship` validation error.
|
|
214
|
-
|
|
215
|
-
Unlike attribute guards, relationship guards take no arguments. Include scrubbing happens before any records have been fetched, so there is no model to hand them. Base the decision on `context` alone.
|
|
216
|
-
|
|
217
|
-
The guard can live on either side of the relationship. Graphiti first looks for the method on the resource declaring the relationship. If it isn't defined there but is defined on the related resource, the related resource's method is used. Defining the guard on the related resource lets a single guard cover every relationship pointing at it:
|
|
218
|
-
|
|
219
|
-
```ruby
|
|
220
|
-
class SalaryHistoryResource < ApplicationResource
|
|
221
|
-
# Any resource declaring a relationship to SalaryHistoryResource with
|
|
222
|
-
# readable: :admin? will use this method, unless it defines its own.
|
|
223
|
-
def admin?
|
|
224
|
-
context.current_user.admin?
|
|
225
|
-
end
|
|
226
|
-
end
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
> **Upgrading to 1.12:** relationship guards are new enforcement, not a new
|
|
230
|
-
> option. Before 1.12, a symbol, string, or proc passed to a relationship's
|
|
231
|
-
> `readable`/`writable` was accepted and silently treated as `true`. The guard
|
|
232
|
-
> was never called. Those guards now run. If your app already passes one of
|
|
233
|
-
> these, a relationship that has been serialized all along may start
|
|
234
|
-
> disappearing from responses.
|
|
235
|
-
>
|
|
236
|
-
> To list every guarded relationship in your app before deploying, run
|
|
237
|
-
> `bin/rails runner 'puts Graphiti.guarded_relationships'`.
|
|
238
|
-
>
|
|
239
|
-
> Apps using `schema.json` also get this for free: guarded relationships are
|
|
240
|
-
> flagged in the schema, and the schema check reports them as
|
|
241
|
-
> `became guarded`.
|
|
242
|
-
|
|
243
|
-
### Customizing Scope {#customizing-scope}
|
|
244
|
-
|
|
245
|
-
Use `params` to change the query parameters that will be passed to the
|
|
246
|
-
associated Resource:
|
|
247
|
-
|
|
248
|
-
```ruby
|
|
249
|
-
has_many :active_positions, resource: PositionResource do
|
|
250
|
-
params do |hash, employees|
|
|
251
|
-
hash[:filter][:active] = true
|
|
252
|
-
end
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
# Would cause the underlying query:
|
|
256
|
-
#
|
|
257
|
-
# PositionResource.all({
|
|
258
|
-
# filter: {
|
|
259
|
-
# employee_id: array_of_employee_ids
|
|
260
|
-
# active: true
|
|
261
|
-
# }
|
|
262
|
-
# })
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
If there is no existing AR association for this we would also need to make it a getter/setter on the model.
|
|
266
|
-
|
|
267
|
-
```ruby
|
|
268
|
-
# app/models/position.rb
|
|
269
|
-
attr_accessor :active_positions
|
|
270
|
-
```
|
|
271
|
-
|
|
272
|
-
### Customizing Assignment {#customizing-assignment}
|
|
273
|
-
|
|
274
|
-
Once we've fetched primary data and its relationship (e.g. we have an
|
|
275
|
-
`employees` array and `positions` array), we need to associate these
|
|
276
|
-
objects:
|
|
277
|
-
|
|
278
|
-
```ruby
|
|
279
|
-
employees.each do |e|
|
|
280
|
-
e.positions = positions.select { |p| p.employee_id == e.id }
|
|
281
|
-
end
|
|
282
|
-
```
|
|
283
|
-
|
|
284
|
-
Occasionally this logic will be non-standard or more complex. Use
|
|
285
|
-
`assign_each` to customize, returning all relevant children for the
|
|
286
|
-
given parent:
|
|
287
|
-
|
|
288
|
-
```ruby
|
|
289
|
-
has_many :positions do
|
|
290
|
-
assign_each do |employee, positions|
|
|
291
|
-
positions.select { |p| p.belongs_to?(employee) }
|
|
292
|
-
end
|
|
293
|
-
end
|
|
294
|
-
```
|
|
295
|
-
|
|
296
|
-
Or if all else fails, use `#assign` to control all the logic:
|
|
297
|
-
|
|
298
|
-
```ruby
|
|
299
|
-
has_many :positions do
|
|
300
|
-
assign do |employees, positions|
|
|
301
|
-
employees.each do |employee|
|
|
302
|
-
positions.select { |p| p.belongs_to?(employee) }
|
|
303
|
-
end
|
|
304
|
-
end
|
|
305
|
-
end
|
|
306
|
-
```
|
|
307
|
-
|
|
308
|
-
**Note**: ActiveRecord will sometimes cause unexpected queries when
|
|
309
|
-
assigning. If you're overriding `#assign`, make sure to keep an eye on this. If using `#assign_each`, you're fine because the adapter will take
|
|
310
|
-
care of this for you.
|
|
311
|
-
|
|
312
|
-
## has_many {#has-many}
|
|
313
|
-
|
|
314
|
-
```ruby
|
|
315
|
-
has_many :positions
|
|
316
|
-
```
|
|
317
|
-
|
|
318
|
-
Defaults to these common options:
|
|
319
|
-
|
|
320
|
-
```ruby
|
|
321
|
-
has_many :positions,
|
|
322
|
-
foreign_key: :employee_id,
|
|
323
|
-
primary_key: :id,
|
|
324
|
-
resource_ids: false,
|
|
325
|
-
resource: PositionResource
|
|
326
|
-
```
|
|
327
|
-
|
|
328
|
-
Which would cause the following query when sideloading:
|
|
329
|
-
|
|
330
|
-
```ruby
|
|
331
|
-
PositionResource.all({ filter: { employee_id => employee_ids } })
|
|
332
|
-
```
|
|
333
|
-
|
|
334
|
-
This means **we need to make sure that filter is supported**:
|
|
335
|
-
|
|
336
|
-
```ruby
|
|
337
|
-
class PositionResource < ApplicationResource
|
|
338
|
-
attribute :employee_id, :integer, only: [:filterable]
|
|
339
|
-
# ... code ...
|
|
340
|
-
end
|
|
341
|
-
```
|
|
342
|
-
|
|
343
|
-
Once we've resolved `employees` and `positions` the resulting objects
|
|
344
|
-
would be associated with logic similar to:
|
|
345
|
-
|
|
346
|
-
```ruby
|
|
347
|
-
employees.each do |e|
|
|
348
|
-
e.positions = positions.select { |p| p.employee_id == e.id }
|
|
349
|
-
end
|
|
350
|
-
```
|
|
351
|
-
|
|
352
|
-
And generate a Link:
|
|
353
|
-
|
|
354
|
-
`/positions?filter[employee_id]=1,2,3`
|
|
355
|
-
|
|
356
|
-
## belongs_to {#belongs-to}
|
|
357
|
-
|
|
358
|
-
```ruby
|
|
359
|
-
belongs_to :employee
|
|
360
|
-
```
|
|
361
|
-
|
|
362
|
-
Defaults to these common options:
|
|
363
|
-
|
|
364
|
-
```ruby
|
|
365
|
-
belongs_to :employee,
|
|
366
|
-
foreign_key: :employee_id,
|
|
367
|
-
primary_key: :id,
|
|
368
|
-
resource_ids: true,
|
|
369
|
-
resource: EmployeeResource
|
|
370
|
-
```
|
|
371
|
-
|
|
372
|
-
Which would cause the following query when sideloading:
|
|
373
|
-
|
|
374
|
-
```ruby
|
|
375
|
-
EmployeeResource.all({ filter: { id => position_ids } })
|
|
376
|
-
```
|
|
377
|
-
|
|
378
|
-
And assign the resulting objects with logic similar to:
|
|
379
|
-
|
|
380
|
-
```ruby
|
|
381
|
-
positions.each do |p|
|
|
382
|
-
p.employee = employees.find { |e| p.employee_id == e.id }
|
|
383
|
-
end
|
|
384
|
-
```
|
|
385
|
-
|
|
386
|
-
And generate a Link:
|
|
387
|
-
|
|
388
|
-
`/employees?filter[id]=1,2,3`
|
|
389
|
-
|
|
390
|
-
## has_one {#has-one}
|
|
391
|
-
|
|
392
|
-
`has_one` works exactly like `has_many`, but only one record will be
|
|
393
|
-
returned. When sideloading this will be a single element, much like
|
|
394
|
-
`belongs_to`.
|
|
395
|
-
|
|
396
|
-
There is one small caveat: Links always point to an `index` action, so we can apply filters. That means following *`has_one` Link will lead to
|
|
397
|
-
an array*, and you should select the first record.
|
|
398
|
-
|
|
399
|
-
### Faux has_one {#faux-has-one}
|
|
400
|
-
|
|
401
|
-
A "Faux Has One" occurs when there is more than one record of
|
|
402
|
-
associated data, but we only want to return the *first* record in that
|
|
403
|
-
array. Consider this `ActiveRecord` relationship:
|
|
404
|
-
|
|
405
|
-
```ruby
|
|
406
|
-
# app/models/employee.rb
|
|
407
|
-
has_many :positions
|
|
408
|
-
has_one :current_position, -> { where(created_at: :desc) }, class_name: 'Position'
|
|
409
|
-
|
|
410
|
-
Employee.includes('current_position').to_a
|
|
411
|
-
|
|
412
|
-
# SELECT * FROM employees
|
|
413
|
-
# SELECT * FROM positions WHERE employee_id IN (?) ORDER BY created_at DESC
|
|
414
|
-
```
|
|
415
|
-
|
|
416
|
-
When we eager load, *more than one Position is returned from the
|
|
417
|
-
database query*. Assigning only the first record and dropping the rest
|
|
418
|
-
occurs in ruby, not the database query.
|
|
419
|
-
|
|
420
|
-
The same thing happens in Graphiti:
|
|
421
|
-
|
|
422
|
-
```ruby
|
|
423
|
-
# app/resources/employee_resource.rb
|
|
424
|
-
has_many :positions
|
|
425
|
-
has_one :current_position, resource: PositionResource do
|
|
426
|
-
params do |hash|
|
|
427
|
-
hash[:sort] = '-created_at'
|
|
428
|
-
end
|
|
429
|
-
end
|
|
430
|
-
|
|
431
|
-
EmployeeResource.all(include: 'current_position')
|
|
432
|
-
# PositionResource.all({
|
|
433
|
-
# filter: { employee_id: employee_ids },
|
|
434
|
-
# sort: '-created_at'
|
|
435
|
-
# })
|
|
436
|
-
```
|
|
437
|
-
|
|
438
|
-
Though everything works as expected, a large number of Position records
|
|
439
|
-
can incur a performance penalty (as we'd be instantiating a large number
|
|
440
|
-
of ActiveRecord objects).
|
|
441
|
-
|
|
442
|
-
For this reason, you are encouraged to model Faux Has One's in such a
|
|
443
|
-
way that the underlying database query only returns the relevant single
|
|
444
|
-
record. Imagine if we had a `historical_index` column on `positions`, where a value of `1` meant "most recent":
|
|
445
|
-
|
|
446
|
-
```ruby
|
|
447
|
-
# app/models/employee.rb
|
|
448
|
-
has_many :positions
|
|
449
|
-
has_one :current_position, -> { where(historical_index: 1) }, class_name: 'Position'
|
|
450
|
-
|
|
451
|
-
Employee.includes('current_position').to_a
|
|
452
|
-
|
|
453
|
-
# SELECT * FROM employees
|
|
454
|
-
# SELECT * FROM positions WHERE employee_id IN (?) AND historical_index = 1
|
|
455
|
-
```
|
|
456
|
-
|
|
457
|
-
We've ensured the *query itself* only returns a single record.
|
|
458
|
-
Optimizing a Graphiti API is the same as optimizing queries.
|
|
459
|
-
|
|
460
|
-
## many_to_many {#many-to-many}
|
|
461
|
-
|
|
462
|
-
> This relationship is specific to relational databases that use a "join
|
|
463
|
-
> table" between two tables.
|
|
464
|
-
|
|
465
|
-
Though you can make this work for other ORMs/clients, it's easiest to
|
|
466
|
-
explain by focusing on `ActiveRecord`.
|
|
467
|
-
|
|
468
|
-
First, **you must use [has_many :through](https://guides.rubyonrails.org/association_basics.html#the-has-many-through-association) and not has_and_belongs_to_many**:
|
|
469
|
-
|
|
470
|
-
```ruby
|
|
471
|
-
class Employee < ApplicationRecord
|
|
472
|
-
has_many :team_memberships
|
|
473
|
-
has_many :teams, through: :team_memberships
|
|
474
|
-
end
|
|
475
|
-
|
|
476
|
-
class TeamMembership < ApplicationRecord
|
|
477
|
-
belongs_to :employee
|
|
478
|
-
belongs_to :team
|
|
479
|
-
end
|
|
480
|
-
|
|
481
|
-
class Team < ApplicationRecord
|
|
482
|
-
has_many :team_memberships
|
|
483
|
-
has_many :employees, through: :team_memberships
|
|
484
|
-
end
|
|
485
|
-
```
|
|
486
|
-
|
|
487
|
-
You can always expose `team_memberships` to your API - particularly
|
|
488
|
-
useful if that table holds metadata about the relationship.
|
|
489
|
-
|
|
490
|
-
Other times, however, clients of the API should not have knowledge of
|
|
491
|
-
this implementation detail. In these cases, use `many_to_many`:
|
|
492
|
-
|
|
493
|
-
```ruby
|
|
494
|
-
class EmployeeResource < ApplicationResource
|
|
495
|
-
many_to_many :teams
|
|
496
|
-
end
|
|
497
|
-
# Generates the Link
|
|
498
|
-
# /teams?filter[employee_id]=1,2,3
|
|
499
|
-
|
|
500
|
-
class TeamResource < ApplicationResource
|
|
501
|
-
many_to_many :employees
|
|
502
|
-
end
|
|
503
|
-
# Generates the Link
|
|
504
|
-
# /teams?filter[team_id]=1,2,3
|
|
505
|
-
```
|
|
506
|
-
|
|
507
|
-
The `many_to_many` call will automatically add a Filter to the associated resource. The logic for that filter, in the case of `ActiveRecord`:
|
|
508
|
-
|
|
509
|
-
```ruby
|
|
510
|
-
# app/resources/employee_resource.rb
|
|
511
|
-
|
|
512
|
-
filter :team_id, :integer do
|
|
513
|
-
eq do |scope, value|
|
|
514
|
-
scope
|
|
515
|
-
.includes(:team_memberships)
|
|
516
|
-
.where(team_memberships: { team_id: value }
|
|
517
|
-
end
|
|
518
|
-
end
|
|
519
|
-
```
|
|
520
|
-
|
|
521
|
-
To customize the foreign key, you will need to specify a hash rather
|
|
522
|
-
than a symbol. The hash key is the relationship name, so the above is
|
|
523
|
-
equivalent to
|
|
524
|
-
|
|
525
|
-
```ruby
|
|
526
|
-
# app/resources/employee_resource.rb
|
|
527
|
-
|
|
528
|
-
many_to_many :teams, foreign_key: { team_memberships: :team_id }
|
|
529
|
-
```
|
|
530
|
-
|
|
531
|
-
If using ActiveRecord, and the API relationship name does not match your
|
|
532
|
-
Model relationship name, use `:as` to specify the model relationship
|
|
533
|
-
that should be used to derive the query:
|
|
534
|
-
|
|
535
|
-
```ruby
|
|
536
|
-
# The API relationship is "teams", ActiveRecord has "groups"
|
|
537
|
-
many_to_many :teams, as: :groups
|
|
538
|
-
```
|
|
539
|
-
|
|
540
|
-
## polymorphic_belongs_to {#polymorphic-belongs-to}
|
|
541
|
-
|
|
542
|
-
With polymorphic associations, a Resource can belong to more than one other Resource, on a single association. Though these relationships are not specific to `ActiveRecord`, we'll use `ActiveRecord` conventions to describe the use case.
|
|
543
|
-
|
|
544
|
-
Given the following [polymorphic ActiveRecords](https://guides.rubyonrails.org/association_basics.html#polymorphic-associations):
|
|
545
|
-
|
|
546
|
-
```ruby
|
|
547
|
-
class Note < ApplicationRecord
|
|
548
|
-
belongs_to :notable, polymorphic: true
|
|
549
|
-
end
|
|
550
|
-
|
|
551
|
-
class Employee < ApplicationRecord
|
|
552
|
-
has_many :notes, as: :notable
|
|
553
|
-
end
|
|
554
|
-
|
|
555
|
-
class Department < ApplicationRecord
|
|
556
|
-
has_many :notes, as: :notable
|
|
557
|
-
end
|
|
558
|
-
|
|
559
|
-
class Team < ApplicationRecord
|
|
560
|
-
has_many :notes, as: :notable
|
|
561
|
-
end
|
|
562
|
-
```
|
|
563
|
-
|
|
564
|
-
By `ActiveRecord` convention, the `notes` table would have columns `notable_id` and `notable_type`.
|
|
565
|
-
|
|
566
|
-
Graphiti has the same concept. In this case we would group all the notes
|
|
567
|
-
by a given `notable_type`, and follow a different `belongs_to`
|
|
568
|
-
association for each group:
|
|
569
|
-
|
|
570
|
-
```ruby
|
|
571
|
-
# app/resources/note_resource.rb
|
|
572
|
-
polymorphic_belongs_to :notable do
|
|
573
|
-
group_by(:notable_type) do
|
|
574
|
-
on(:Employee)
|
|
575
|
-
on(:Department)
|
|
576
|
-
on(:Team)
|
|
577
|
-
end
|
|
578
|
-
end
|
|
579
|
-
```
|
|
580
|
-
|
|
581
|
-
The `on` DSL is shorthand for a `belongs_to` relationship that accepts
|
|
582
|
-
all the usual options and customizations:
|
|
583
|
-
|
|
584
|
-
```ruby
|
|
585
|
-
on(:Employee).belongs_to :employee,
|
|
586
|
-
resource: EmployeeResource
|
|
587
|
-
# ... etc ...
|
|
588
|
-
```
|
|
589
|
-
|
|
590
|
-
In other words: group all Notes by `notable_type`, and for all that have the value of `"Employee"` use the `belongs_to :employee` relationship
|
|
591
|
-
for further querying.
|
|
592
|
-
|
|
593
|
-
## polymorphic_has_many {#polymorphic-has-many}
|
|
594
|
-
|
|
595
|
-
Continuing from the prior section, the corresponding association of a
|
|
596
|
-
`polymorphic_belongs_to` is a `polymorphic_has_many`:
|
|
597
|
-
|
|
598
|
-
```ruby
|
|
599
|
-
class EmployeeResource < ApplicationResource
|
|
600
|
-
polymorphic_has_many :notes, as: :notable
|
|
601
|
-
end
|
|
602
|
-
```
|
|
603
|
-
|
|
604
|
-
Predictably, this causes the query:
|
|
605
|
-
|
|
606
|
-
```ruby
|
|
607
|
-
NoteResource.all({
|
|
608
|
-
filter: {
|
|
609
|
-
notable_type: 'Employee',
|
|
610
|
-
notable_id: employee_ids
|
|
611
|
-
}
|
|
612
|
-
})
|
|
613
|
-
```
|
|
614
|
-
|
|
615
|
-
And the Link
|
|
616
|
-
|
|
617
|
-
`/notes?filter[notable_id]=1,2,3&filter[notable_type]=Employee`
|
|
618
|
-
|
|
619
|
-
Which means the following filters are required:
|
|
620
|
-
|
|
621
|
-
```ruby
|
|
622
|
-
class NoteResource < ApplicationResource
|
|
623
|
-
attribute :notable_id, :integer, only: [:filterable]
|
|
624
|
-
attribute :notable_type, :string, only: [:filterable]
|
|
625
|
-
# ... code ...
|
|
626
|
-
end
|
|
627
|
-
```
|